Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,25 @@ per-request failures are excluded at their own scope by the cooldown store.
Normal label lookup reads the in-memory snapshot and performs no SQL.
Routing and usage records are written asynchronously after a decision.

The request body cannot add a path either. On every alias the failover loop
serves (`dispatch`, `free`, `jev`, and direct free models), the body a provider
receives is the caller's restricted to the standard chat-completions fields in
`caller_fields.CALLER_FIELDS`, with `model` replaced by the target being
attempted. Every other field is dropped before the upstream call, without
error, as most gateways do. That includes OpenRouter's `models` fallback list,
`route`, `plugins`, `preset` and `provider`, because each can select or add a
model the pinned revision never named, or the cost gate never weighed, under
the operator's credential. Function tools are forwarded; a `tools` entry or a
`tool_choice` of any other type is refused with 400 (`reason: unsupported_tool`).
Such a tool is a server tool that runs upstream: `openrouter:fusion` runs a
panel of its own. The refusal is recorded as a routing event with outcome
`invalid_request`, under the caller's identity and the request's revision, and
the response carries the event's `request_id`, so a refused request appears in
`/admin/events` beside every other routing decision. Fusion builds its backend
body from the same allowlist and refuses caller `tools`, `tool_choice` and
`plugins` outright, also with a 400 and a routing event. Jev Router refuses
rather than drops every field outside its own allowlist (see Jev Router).

`/health` is the only ungated endpoint and answers liveness only —
`{"status": "ok", "service": "cerberus"}` — which is what a container or
orchestrator probe needs. Operational diagnostics (config version and checksum,
Expand Down
14 changes: 12 additions & 2 deletions src/cerberus/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@
from cerberus.jev_router import JevRouterBackend, OpenRouterJevRouterBackend, jev_router_dispatch
from cerberus.registry import CerberusConfig, ConfigDocument, load_config_document
from cerberus.registry.schema import Alias, Candidate
from cerberus.router.dispatch import dispatch, shadow_decision_event, unauthorized_event
from cerberus.router.dispatch import dispatch, invalid_request_event, shadow_decision_event, unauthorized_event
from cerberus.state import InMemoryCooldownStore, SqliteCooldownStore
from cerberus.telemetry import TelemetryEmitter

Expand Down Expand Up @@ -946,15 +946,25 @@ async def chat_completions(request: Request) -> JSONResponse | StreamingResponse
# fusion rejects caller tools outright (fusion_dispatch) and
# jev-router refuses anything outside its own allowlist
# (jev_router_dispatch), each with a routing event; elsewhere only
# function tools are forwarded
# function tools are forwarded, and refusing one is a routing
# decision too, so it is recorded the same way
server_tools = non_function_tools(body)
if server_tools:
refused = invalid_request_event(
alias_name=alias_name,
mode=alias.mode,
identity=context.name if context else None,
config_version=document.version,
config_checksum=document.checksum,
)
telemetry.emit(refused)
return JSONResponse(
status_code=400,
content={
"error": {
"message": f"Only function tools are accepted; got {', '.join(sorted(set(server_tools)))}",
"reason": "unsupported_tool",
"request_id": refused.request_id,
}
},
)
Expand Down
27 changes: 27 additions & 0 deletions src/cerberus/router/dispatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,33 @@ def unauthorized_event(
)


def invalid_request_event(
*,
alias_name: str,
mode: str,
identity: str | None,
config_version: str | None = None,
config_checksum: str | None = None,
) -> RoutingEvent:
"""A request refused for its own content (400) before any upstream call."""
return _event(
request_id=str(uuid.uuid4()),
alias=alias_name,
mode=mode,
target=None,
attempts=[],
http_status=400,
outcome="invalid_request",
started_at=time.perf_counter(),
usage=None,
streaming=False,
fallback=False,
identity=identity,
config_version=config_version,
config_checksum=config_checksum,
)


def shadow_decision_event(
*,
document: ConfigDocument,
Expand Down
40 changes: 40 additions & 0 deletions tests/test_caller_fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@
from cerberus.app import create_app
from cerberus.caller_fields import caller_body, non_function_tools
from cerberus.fusion.backend import FusionRequest, OpenRouterFusionBackend
from cerberus.registry import load_config_document
from tests.test_app import call, make_config
from tests.test_control import ok_upstream, write_config
from tests.test_fusion_dispatch import AUTH, DISPATCH_REQ, Capture, client_for, fusion_raw

MESSAGES = [{"role": "user", "content": "hi"}]

Expand Down Expand Up @@ -83,6 +86,43 @@ async def upstream(request):
assert seen == []


@pytest.mark.asyncio
@pytest.mark.parametrize(
"extra",
[
{"tools": [{"type": "openrouter:fusion", "parameters": {"analysis_models": ["paid/model"]}}]},
{"tool_choice": {"type": "openrouter:fusion"}},
],
ids=["tools", "tool_choice"],
)
async def test_server_tool_refusal_is_a_routing_event(monkeypatch, tmp_path, extra):
"""The refusal is recorded like every other routing decision, under the
caller's identity, and the response names the event's request id."""

monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
monkeypatch.setenv("CB_KEY_DEV", "cb-dev")
monkeypatch.setenv("CB_KEY_DISPATCH", "cb-dispatch")
doc = load_config_document(write_config(tmp_path, "v.yaml", fusion_raw()), validate_credentials=False)
upstream = Capture(ok_upstream)
app = create_app(doc, http_transport=httpx.MockTransport(upstream))
async with app.router.lifespan_context(app):
async with client_for(app) as client:
response = await client.post("/v1/chat/completions", json={**DISPATCH_REQ, **extra}, headers=AUTH)
events = (await client.get("/admin/events")).json()["events"]

assert response.status_code == 400
error = response.json()["error"]
assert error["reason"] == "unsupported_tool"
assert upstream.requests == []
assert len(events) == 1
event = events[0]
assert (event["outcome"], event["http_status"]) == ("invalid_request", 400)
assert (event["alias"], event["mode"], event["identity"]) == ("cerberus/dispatch-dev", "dispatch", "dev")
assert event["request_id"] == error["request_id"]
assert event["attempt_count"] == 0 and event["provider"] is None and event["model"] is None
assert event["config_version"] == doc.version and event["config_checksum"] == doc.checksum


def test_fusion_body_drops_provider_extensions():
request = FusionRequest(
body={"messages": MESSAGES, "models": ["paid/model"], "provider": {"order": ["x"]}, "route": "fallback"},
Expand Down
Loading