Repository navigation
Refuse caller-supplied model selection on dispatch-path aliases - #27
Closed
Deep-Sixed wants to merge 1 commit into
Closed
Deep-Sixed wants to merge 1 commit into
Deep-Sixed wants to merge 1 commit into
Conversation
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
5 of 6 tasks
Owner
Author
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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
build_upstream_requestbuilds the upstream body as{**body, "model": target.model}, so every key exceptmodelreaches 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 examplecerberus/dispatch-devinconfig/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 bypassescost_eligibleand breaks the rule indocs/architecture.mdthat nothing at runtime can add a path to the pinned revision.Change
router/engine.py: newcaller_model_selection(body). It returns the body keys that would choose or add models upstream:modelsandroute(OpenRouter's fallback list)pluginspreset(an OpenRouter preset can carry its own model routing)toolswhen any entry'stypestarts withopenrouter:(case-insensitive)tool_choicewhen it names anopenrouter:server toolrouter/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.reasoning_effort_override.tool_choicethat names one, sampling parameters, andproviderpreferences.provideronly chooses which host serves the model Cerberus already pinned; it never picks a different model.fusion/dispatch.py:build_bodyused to forwardmodels,routeandpresetto OpenRouter alongside the deliberation, which is the same hole._REJECTED_CALLER_KEYSis 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, notegress/client.py, becauseegress.clientcan't be the firstcerberusmodule imported: it hits a circular import throughrouter/__init__. That cycle already exists onmain. If fusion imported fromegress.client,import cerberus.fusionwould break.Tests
The new file
tests/test_caller_model_selection.pyruns every case against both adispatchalias and afreealias, using an httpxMockTransportupstream:models,route,plugins,preset, anopenrouter:fusiontool mixed in with a function tool, anopenrouter:tool_choice, and a streaming request: each returns 400, no request reaches the upstream, and exactly one routing event is emitted with status 400.tool_choice,providerandtemperatureare forwarded byte-for-byte, andmodelis replaced by the target.toolssent as a single dict, a stringtool_choice, and null or empty values.The fusion override test now also checks
models,routeandpreset.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