diff --git a/docs/architecture.md b/docs/architecture.md index 6d041d7..93b5b02 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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, diff --git a/src/cerberus/app.py b/src/cerberus/app.py index 203d780..915a86a 100644 --- a/src/cerberus/app.py +++ b/src/cerberus/app.py @@ -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 @@ -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, } }, ) diff --git a/src/cerberus/router/dispatch.py b/src/cerberus/router/dispatch.py index 1de4414..141ebde 100644 --- a/src/cerberus/router/dispatch.py +++ b/src/cerberus/router/dispatch.py @@ -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, diff --git a/tests/test_caller_fields.py b/tests/test_caller_fields.py index acf4ca0..1b622bc 100644 --- a/tests/test_caller_fields.py +++ b/tests/test_caller_fields.py @@ -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"}] @@ -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"},