fix: route gateways via their own litellm provider so prompt caching works - #55
Merged
Merged
Conversation
…works
Prompt caching was silently dead on every OpenAI-compatible gateway. _resolve_model
hardcoded an "openai/" prefix, so litellm resolved custom_llm_provider="openai" and
OpenAIGPTConfig.remove_cache_control_flag_from_messages_and_tools recursively deleted
every cache_control key from the request before it was sent. OpenHands emitted the
breakpoints correctly; litellm dropped them with no error and no warning.
The capability gate was independently broken: OpenHands keys PROMPT_CACHE_MODELS on
the vendor's hyphenated id ("claude-haiku-4-5"), but a gateway routes by its own id
("anthropic/claude-haiku-4.5"), so the substring match failed and litellm could not
map the model at all -- leaving max_input_tokens and max_output_tokens as None and
cost reported as $0.00.
Both are now data, not code:
- Provider gains an optional "litellm_prefix" (default "openai"). openrouter.json
sets "openrouter", so litellm applies OpenRouter's own transform, which preserves
cache_control, hoists tool-message markers into content blocks, and requests usage
data. Providers that omit it are byte-identical to before.
- MetaAgentProfile gains an optional "model_canonical_name" for capability lookups
only, leaving "model" as the routing id.
reasoning_effort is now pinned to None. The SDK defaults it to "high" and litellm
forwards it for providers it reports as reasoning-capable, so naming a gateway's
litellm provider would otherwise have switched the meta agent to extended thinking as
an invisible side effect of a caching fix. Opting into thinking should be its own
measured change.
The canonical name is forwarded only when set, so runners registered against the older
signature -- including third-party impls, since register() is a public extension point
-- keep working.
Verified by capturing the serialised request body with a monkeypatched transport: under
"openrouter/" cache_control reaches the wire and max_input/max_output are populated;
under "openai/" it is stripped. The capability gate reports caching active in both
cases, so only the payload assertion catches this.
Also fixes a latent bug in the prefix guard: a gateway model id beginning "openai/"
(e.g. "openai/gpt-oss-120b") matched the old startswith check and was left unprefixed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HPkffXZbzVPFLiaEy2PRfB
selvamHexo
force-pushed
the
fix/openrouter-prompt-caching
branch
from
August 25, 2026 22:24
062f93a to
bc6a3b2
Compare
selvamHexo
changed the base branch from
feat/openrouter-provider-profiles
to
main
August 25, 2026 22:24
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.
Summary
Prompt caching was silently dead on every OpenAI-compatible gateway, and the meta agent's
context window and cost were undetectable. A gen-0 run on OpenRouter cost $0.63 with a
0.00% cache hit rate on every turn — 400K cumulative input tokens re-sent across 20 turns.
Two independent causes:
_resolve_modelhardcoded anopenai/prefix, so litellm resolvedcustom_llm_provider="openai"andOpenAIGPTConfig.remove_cache_control_flag_from_messages_and_tools(litellm/llms/openai/chat/gpt_transformation.py:400-420, called unconditionally at:438) recursively deleted everycache_controlkey before the request was serialised. OpenHands emitted them correctly; litellm dropped them with no error and no warning.PROMPT_CACHE_MODELSon the vendor's hyphenated id (claude-haiku-4-5), but a gateway routes by its own id (anthropic/claude-haiku-4.5). The substring match failed, and litellm could not map the model at all — leavingmax_input_tokensandmax_output_tokensasNoneand cost reported as$0.00.Both become configuration rather than code, so a new gateway or model needs no code change.
cache_controlon the wiremax_input_tokensNonemax_output_tokensNone$0.00Type of change
Select all that apply:
Related issue
Closes #
What changed
sia/providers.py—Providergains optionallitellm_prefix(default"openai"). Providers that omit it behave byte-identically to before.sia/defaults/providers/openrouter.json— sets"litellm_prefix": "openrouter", so litellm appliesOpenrouterConfig, which preservescache_control(transformation.py:91-102), hoists tool-message markers into content blocks as OpenRouter requires (:103-145), and addsusage: {include: true}(:167-169).sia/profiles.py—MetaAgentProfilegains optionalmodel_canonical_name, used only for SDK capability lookups whilemodelstays the routing id.sia/defaults/profiles/openrouter-meta.json— sets"model_canonical_name": "claude-haiku-4-5".sia/agent_impls/openhands.py—_resolve_modeluses the provider-declared prefix; the canonical name is passed toLLM(...);reasoning_effortis pinned toNone(see below); a warning fires if the installed SDK ignored the canonical name.sia/agent_impls/base.py,sia/orchestrator.py— thread the canonical name from the profile to the impl.docs/configuration.md— documents both fields and corrects the OpenRouter section.Why
reasoning_effort=NoneNaming a gateway's litellm provider has a side effect that has nothing to do with caching: the SDK
defaults
reasoning_effortto"high"(llm.py:358-364) and only sends it when litellm reportsthe provider reasoning-capable — true for
openrouter/…, false foropenai/…. Captured bodies,same messages, only the prefix differing:
That is extended thinking at high budget — more output tokens, higher latency, different tool
behaviour. Shipping it inside a caching fix would make any measured cost delta uninterpretable, so
it is pinned off. Enabling thinking should be a separate change with its own before/after eval.
Backward compatibility
The canonical name is forwarded only when set, so runners registered against the older
signature keep working —
register()is a documented extension point (base.py:5-9) andAgentRunnerisCallable[..., Awaitable[None]], so no type checker would catch a break. When thevalue is set and a runner cannot accept it, the
TypeErroris loud rather than a silentlyignored capability.
How I tested this
New tests:
test_openrouter_prefix_preserves_cache_control_on_the_wire— monkeypatches the HTTP transport and assertscache_controlis present in the serialised request body underopenrouter/and absent underopenai/, thatreasoning_effortis not sent, and that the token limits are populated. Asserting on the capability flag alone would prove nothing: it reports caching active in both cases — only the payload differs. No network, no spend.test_openhands_model_uses_provider_declared_litellm_prefix— covers the new prefix, no double-prefixing, thatnebiusis unchanged, and thatopenai/gpt-oss-120bon a gateway is now correctly prefixed (the old hardcodedstartswith("openai/")guard skipped it and left it misrouted — a latent bug this fixes).test_run_agent_forwards_model_canonical_name_only_when_set— covers both branches of the conditional forwarding, including a legacy runner signature.litellm_prefixandmodel_canonical_name.The wire-capture test uses
pytest.importorskip("openhands"), matching the existingpydantic_aitest, so it skips in CI where optional SDKs are not installed. It runs for anyone following the
CONTRIBUTING dev setup.
Not yet measured live. The predicted saving is inferred from the payload and the published
cache multipliers (writes 1.25×, reads 0.1×), not observed on a real run — the test key's credit
cap could not fund a second gen-0. Someone should confirm a non-zero cache hit rate and the actual
dollar delta before we quote a number.
Known limitation, deliberately out of scope
sia/context_manager.py:155-161callsrun_agentwithout a provider, so the per-generationsummariser resolves
anthropic/claude-haiku-4.5with no base_url andsia/api_keys.py:21reachesfor
ANTHROPIC_API_KEY. An OpenRouter-only user gets a 401 againstapi.anthropic.com, and thefailure is swallowed (
context_manager.py:167-172returnsNone), so generation summaries gosilently missing. That predates this PR and needs
ContextManagerto receive the profile; flaggingit here so the summariser still being empty is not mistaken for this fix not working.
Security and privacy checklist
Documentation checklist
Contributor checklist
🤖 Generated with Claude Code
https://claude.ai/code/session_01HPkffXZbzVPFLiaEy2PRfB