Skip to content
Merged
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
15 changes: 15 additions & 0 deletions src/cerberus/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
71 changes: 71 additions & 0 deletions src/cerberus/caller_fields.py
Original file line number Diff line number Diff line change
@@ -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
4 changes: 2 additions & 2 deletions src/cerberus/egress/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion src/cerberus/fusion/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

import httpx

from cerberus.caller_fields import caller_body
from cerberus.telemetry import AttemptOutcome

FusionOutcome = Literal["upstream_error", "fusion_unavailable"]
Expand Down Expand Up @@ -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"] = [
{
Expand Down
97 changes: 97 additions & 0 deletions tests/test_caller_fields.py
Original file line number Diff line number Diff line change
@@ -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"}
Loading