diff --git a/src/cerberus/app.py b/src/cerberus/app.py index b91b273..e175081 100644 --- a/src/cerberus/app.py +++ b/src/cerberus/app.py @@ -44,6 +44,7 @@ resolve_identity, supplied_credential, ) +from cerberus.caller_fields import non_function_tools from cerberus.fusion import FusionBackend, OpenRouterFusionBackend, fusion_dispatch, fusion_status from cerberus.identity.session_store import InMemorySessionStore, SqliteSessionStore from cerberus.identity.sso import AdminSSO @@ -922,6 +923,20 @@ async def chat_completions(request: Request) -> JSONResponse | StreamingResponse status_code=403, content={"error": {"message": f"Identity not authorized for {alias_name!r}", "reason": denial}}, ) + if alias.mode != "fusion": + # fusion rejects caller tools outright (fusion_dispatch); elsewhere + # only function tools are forwarded + server_tools = non_function_tools(body) + if server_tools: + return JSONResponse( + status_code=400, + content={ + "error": { + "message": f"Only function tools are accepted; got {', '.join(sorted(set(server_tools)))}", + "reason": "unsupported_tool", + } + }, + ) shadow = lifecycle.shadow if shadow is not None: shadow_event = shadow_decision_event( diff --git a/src/cerberus/caller_fields.py b/src/cerberus/caller_fields.py new file mode 100644 index 0000000..e009529 --- /dev/null +++ b/src/cerberus/caller_fields.py @@ -0,0 +1,71 @@ +"""What a caller may put in a chat-completions request that Cerberus forwards. + +A leaf module with no Cerberus imports: both the dispatch egress and the fusion +backend read it, and those two packages import each other's neighbours. +""" + +from __future__ import annotations + +from typing import Any + +# Standard OpenAI chat-completions fields a caller may set. Anything else is +# dropped before the upstream call: provider extensions such as OpenRouter's +# `models` fallback list, `provider`, `route` and `plugins` can select or add +# models outside the alias's registry and cost policy, so forwarding them +# verbatim would let a caller on a free-only alias spend paid quota. +CALLER_FIELDS = frozenset( + { + "messages", + "model", + "stream", + "stream_options", + "temperature", + "top_p", + "n", + "stop", + "max_tokens", + "max_completion_tokens", + "presence_penalty", + "frequency_penalty", + "logit_bias", + "logprobs", + "top_logprobs", + "seed", + "user", + "response_format", + "tools", + "tool_choice", + "parallel_tool_calls", + "functions", + "function_call", + "reasoning_effort", + "chat_template_kwargs", + } +) + + +def caller_body(body: dict[str, Any]) -> dict[str, Any]: + """The caller's request restricted to CALLER_FIELDS.""" + + return {key: value for key, value in body.items() if key in CALLER_FIELDS} + + +def non_function_tools(body: dict[str, Any]) -> list[str]: + """Tool types other than ``function`` named by ``tools`` or ``tool_choice``. + + Server-side tools (``openrouter:fusion``, web search and the like) run on + the provider under Cerberus's credential and can bill models the alias + never listed, so they are refused rather than forwarded. + """ + + found: list[str] = [] + tools = body.get("tools") + entries = tools if isinstance(tools, list) else ([] if tools is None else [tools]) + choice = body.get("tool_choice") + if isinstance(choice, dict): + entries = [*entries, choice] + for entry in entries: + kind = entry.get("type") if isinstance(entry, dict) else None + if kind != "function": + found.append(str(kind)) + return found diff --git a/src/cerberus/egress/client.py b/src/cerberus/egress/client.py index 08f37bc..864208a 100644 --- a/src/cerberus/egress/client.py +++ b/src/cerberus/egress/client.py @@ -11,11 +11,11 @@ import httpx +from cerberus.caller_fields import caller_body from cerberus.router.engine import Target RETRYABLE_STATUS_CODES = frozenset({408, 429, 500, 502, 503, 504}) - def retry_after_seconds(response: httpx.Response) -> float | None: value = response.headers.get("retry-after") if value is None: @@ -94,7 +94,7 @@ def _read_line(self, line: bytes) -> None: def build_upstream_request( client: httpx.AsyncClient, target: Target, body: dict[str, Any], api_key: str ) -> httpx.Request: - upstream_body = {**body, "model": target.model} + upstream_body = {**caller_body(body), "model": target.model} # Optional per-candidate reasoning budget. Absent -> nothing is added and the # body is exactly what it was before this feature existed. Present -> injected # only when the caller did not state one, unless the route explicitly claims diff --git a/src/cerberus/fusion/backend.py b/src/cerberus/fusion/backend.py index 603c50d..7fcd80c 100644 --- a/src/cerberus/fusion/backend.py +++ b/src/cerberus/fusion/backend.py @@ -16,6 +16,7 @@ import httpx +from cerberus.caller_fields import caller_body from cerberus.telemetry import AttemptOutcome FusionOutcome = Literal["upstream_error", "fusion_unavailable"] @@ -104,7 +105,11 @@ def __init__(self, transport: httpx.AsyncBaseTransport | None = None) -> None: @staticmethod def build_body(request: FusionRequest) -> dict[str, Any]: - body = {k: v for k, v in request.body.items() if k not in ("model", "tools", "tool_choice", "stream")} + body = { + k: v + for k, v in caller_body(request.body).items() + if k not in ("model", "tools", "tool_choice", "stream", "functions", "function_call") + } body["model"] = request.outer_model body["tools"] = [ { diff --git a/tests/test_caller_fields.py b/tests/test_caller_fields.py new file mode 100644 index 0000000..acf4ca0 --- /dev/null +++ b/tests/test_caller_fields.py @@ -0,0 +1,97 @@ +"""Caller request bodies are restricted before they reach a provider. + +Provider extensions (OpenRouter's `models` fallback list, `provider`, `route`, +`plugins`, server tools like `openrouter:fusion`) can select or add models the +alias never listed. Forwarding them verbatim let a caller on a free-only alias +spend paid quota under Cerberus's credential. +""" + +import json + +import httpx +import pytest + +from cerberus.app import create_app +from cerberus.caller_fields import caller_body, non_function_tools +from cerberus.fusion.backend import FusionRequest, OpenRouterFusionBackend +from tests.test_app import call, make_config + +MESSAGES = [{"role": "user", "content": "hi"}] + + +def test_caller_body_drops_provider_extensions(): + body = { + "messages": MESSAGES, + "temperature": 0.1, + "models": ["paid/model"], + "provider": {"order": ["x"]}, + "route": "fallback", + "plugins": [{"id": "web"}], + "service_tier": "priority", + } + assert caller_body(body) == {"messages": MESSAGES, "temperature": 0.1} + + +def test_non_function_tools_are_named(): + assert non_function_tools({"tools": [{"type": "function", "function": {"name": "f"}}]}) == [] + assert non_function_tools({"tools": [{"type": "openrouter:fusion"}]}) == ["openrouter:fusion"] + assert non_function_tools({"tool_choice": {"type": "web_search"}}) == ["web_search"] + assert non_function_tools({"tools": ["bogus"]}) == ["None"] + assert non_function_tools({"tool_choice": "auto"}) == [] + + +@pytest.mark.asyncio +async def test_dispatch_forwards_only_standard_fields(monkeypatch): + seen = [] + + async def upstream(request): + seen.append(json.loads(request.content)) + return httpx.Response(200, json={"choices": [{"message": {"content": "ok"}}]}) + + app = create_app(make_config(monkeypatch), http_transport=httpx.MockTransport(upstream)) + response = await call( + app, + "POST", + "/v1/chat/completions", + json={"model": "cerberus/main", "messages": MESSAGES, "models": ["paid/model"], "plugins": [{"id": "web"}]}, + ) + assert response.status_code == 200, response.text + assert seen == [{"model": "alpha-free", "messages": MESSAGES}] + + +@pytest.mark.asyncio +async def test_dispatch_refuses_server_tools(monkeypatch): + seen = [] + + async def upstream(request): + seen.append(request) + return httpx.Response(200, json={}) + + app = create_app(make_config(monkeypatch), http_transport=httpx.MockTransport(upstream)) + response = await call( + app, + "POST", + "/v1/chat/completions", + json={ + "model": "cerberus/main", + "messages": MESSAGES, + "tools": [{"type": "openrouter:fusion", "parameters": {"analysis_models": ["paid/model"]}}], + }, + ) + assert response.status_code == 400 + assert response.json()["error"]["reason"] == "unsupported_tool" + assert seen == [] + + +def test_fusion_body_drops_provider_extensions(): + request = FusionRequest( + body={"messages": MESSAGES, "models": ["paid/model"], "provider": {"order": ["x"]}, "route": "fallback"}, + panel_models=["a"], + analyst_model="c", + outer_model="d", + base_url="https://openrouter.ai/api/v1", + api_key="k", + timeout_seconds=5, + ) + body = OpenRouterFusionBackend.build_body(request) + assert set(body) == {"messages", "model", "tools", "tool_choice"}