Skip to content

Refuse caller-supplied model selection on dispatch-path aliases - #27

Closed
Deep-Sixed wants to merge 1 commit into
mainfrom
claude/trusting-rubin-oe8m1j
Closed

Deep-Sixed wants to merge 1 commit into
mainfrom
claude/trusting-rubin-oe8m1j

Conversation

@Deep-Sixed

Copy link
Copy Markdown
Owner

Problem

build_upstream_request builds the upstream body as {**body, "model": target.model}, so every key except model reaches the provider exactly as the caller sent it. OpenRouter treats several of those keys as model selection. On a dispatch or free alias with an OpenRouter candidate (for example cerberus/dispatch-dev in config/fusion-dev.yaml), a caller could therefore run models the pinned revision never named, including paid ones, and the operator's credential would pay. This bypasses cost_eligible and breaks the rule in docs/architecture.md that nothing at runtime can add a path to the pinned revision.

Change

  • router/engine.py: new caller_model_selection(body). It returns the body keys that would choose or add models upstream:
    • models and route (OpenRouter's fallback list)
    • plugins
    • preset (an OpenRouter preset can carry its own model routing)
    • tools when any entry's type starts with openrouter: (case-insensitive)
    • tool_choice when it names an openrouter: server tool
  • router/dispatch.py: if that list is non-empty, the request gets a 400 before the failover loop runs. The routing event uses the shape fusion already records: upstream_error, status 400, no attempts, candidates listed. The check covers every dispatch-path request (dispatch, free, and direct free models) for every provider, because the failover loop can reach any candidate.
  • Reject, not strip: this follows the fusion precedent and the "never silent" rule around reasoning_effort_override.
  • Still forwarded unchanged: function tools, a tool_choice that names one, sampling parameters, and provider preferences. provider only chooses which host serves the model Cerberus already pinned; it never picks a different model.
  • fusion/dispatch.py: build_body used to forward models, route and preset to OpenRouter alongside the deliberation, which is the same hole. _REJECTED_CALLER_KEYS is now ("tools", "tool_choice", *CALLER_ROUTING_KEYS).
  • docs/architecture.md: states the rule in the Dispatch control plane section and cross-references it from the fusion paragraph.

The shared helper lives in router/engine.py, not egress/client.py, because egress.client can't be the first cerberus module imported: it hits a circular import through router/__init__. That cycle already exists on main. If fusion imported from egress.client, import cerberus.fusion would break.

Tests

The new file tests/test_caller_model_selection.py runs every case against both a dispatch alias and a free alias, using an httpx MockTransport upstream:

  • models, route, plugins, preset, an openrouter:fusion tool mixed in with a function tool, an openrouter: tool_choice, and a streaming request: each returns 400, no request reaches the upstream, and exactly one routing event is emitted with status 400.
  • A function tool, a function tool_choice, provider and temperature are forwarded byte-for-byte, and model is replaced by the target.
  • Unit cases for the classifier: case-insensitive type, tools sent as a single dict, a string tool_choice, and null or empty values.

The fusion override test now also checks models, route and preset.

The new rejection tests and the extended fusion test fail on unpatched main; with this change, ./scripts/ci.sh (ruff + pytest) passes with 343 tests.

🤖 Generated with Claude Code

https://claude.ai/code/session_01TVM2AGUsSu6Fyr5s5LSvew


Generated by Claude Code

build_upstream_request overwrites only `model`, so every other key a caller
sends reaches the provider unchanged. OpenRouter reads several of them as
model selection: `models`/`route` (its fallback list), `plugins`, `preset`,
and `openrouter:*` server tools such as openrouter:fusion, which names a
panel and analyst of its own. Through a dispatch or free alias with an
OpenRouter candidate, a caller could run models absent from the pinned
revision, paid ones included, on the operator's credential, bypassing
cost_eligible and the route-universe invariant.

engine.caller_model_selection now names those keys, and the dispatch loop
answers 400 with a routing event (upstream_error/400, no attempts, the
shape fusion already records) before any upstream call. Function tools, a
tool_choice naming one, and keys that leave the model alone, including
`provider` host preferences, are forwarded unchanged.

Fusion forwarded models/route/preset to OpenRouter alongside the
deliberation, so its rejected keys now include the same set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TVM2AGUsSu6Fyr5s5LSvew

Copy link
Copy Markdown
Owner Author

Closing as superseded by #23, which has now been merged. #23 provides the shared upstream allowlist fix for dispatch-path requests, so keeping this PR open would duplicate/conflict with that path.

@Deep-Sixed Deep-Sixed closed this Oct 5, 2026

Copy link
Copy Markdown
Owner Author

Closing in favour of #23, which fixes the same caller-supplied model-selection gap with an allowlist and is now merged.


Generated by Claude Code


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants