Skip to content

litellm: read OpenRouter capabilities live, and make drop_params visible - #3625

Merged
jwbron merged 7 commits into
mainfrom
egg/openrouter-live-capabilities
Jul 26, 2026
Merged

litellm: read OpenRouter capabilities live, and make drop_params visible#3625
jwbron merged 7 commits into
mainfrom
egg/openrouter-live-capabilities

Conversation

@jwbron

@jwbron jwbron commented Jul 25, 2026

Copy link
Copy Markdown
Owner

Closes #3624.

Two build-time patches to the pinned egg-litellm image (stock litellm 1.86.2): stop the OpenRouter param gate from failing closed on current models, and make any remaining drop visible.

Patch 7 — read OpenRouter capabilities live

OpenrouterConfig.get_supported_openai_params gates reasoning_effort/thinking on litellm.supports_reasoning, which reads the bundled model-cost map. For OpenRouter that map is wrong by construction: OpenRouter ships new slugs continuously and the map lags. The gate is a bare if, so it fails closed, and drop_params: true then discards the parameter with no exception and no log line.

Every OpenRouter slug egg routes is absent from the 1.86.2 map:

Slug In model_cost supports_reasoning
openrouter/moonshotai/kimi-k3 absent False
openrouter/z-ai/glm-5.2 absent False
openrouter/poolside/laguna-s-2.1 absent False
openrouter/deepseek/deepseek-v4-pro absent False
openrouter/moonshotai/kimi-k2.7-code absent False
openrouter/deepseek/deepseek-r1 (control) present True

OpenRouter publishes the answer itself: GET /api/v1/models returns per-model supported_parameters and needs no API key (verified: HTTP 200 unauthenticated, 345 models). The new openrouter_capabilities.py module reads it, caches per process, and is unioned with the map answer.

Why union and not "live data wins." I built it the other way first and litellm's own test suite caught it. supported_parameters under-reports reasoning_effort: deepseek/deepseek-r1 is flagged supports_reasoning: true in the map and is plainly a reasoning model, yet OpenRouter advertises only reasoning for it, treating the OpenAI spelling as an alias. Reading that absence as a denial would drop a working param, trading one silent drop for another. The lookup can admit a knob the map does not know, but never withholds one the map allows, so the worst case is exactly the unpatched behaviour.

Fails soft throughout: any error, timeout, non-200 or malformed payload yields no opinion. A failed fetch is cached for the TTL too, so an offline deployment costs one attempt per hour rather than one per request, and a lock prevents a stampede. LITELLM_OPENROUTER_CAPABILITY_FETCH=0 restores previous behaviour exactly.

Verified in the built image against the live endpoint:

Slug stock patched fetch disabled
moonshotai/kimi-k3 False True False
z-ai/glm-5.2 False True False
poolside/laguna-s-2.1 False False False
moonshotai/kimi-k2.7-code False False False
deepseek/deepseek-r1 True True True
openai/gpt-4o-mini False False False

laguna and k2.7-code stay False correctly: OpenRouter advertises reasoning/include_reasoning for them but not reasoning_effort, on every endpoint.

Patch 8 — make drop_params say what it dropped

Stock pops unsupported params in a bare loop with no logging. That silence is what turned patch 7's bug into a full investigation instead of a log grep. Patch 7 removes the OpenRouter false-negative; this covers the rest, including drops that are correct: laguna genuinely does not accept reasoning_effort, so it is dropped on purpose and the operator otherwise cannot learn why their config line does nothing.

The message names the proxy-config remedy (allowed_openai_params in a model_list entry's litellm_params), not just the per-request kwarg, since the audience reads config.yaml. That route is verified to work (LiteLLM_Params is ConfigDict(extra="allow"), and the value reaches get_optional_params from there).

Deduped per (provider, model, param-set), bounded at 1000 entries, past which it stops recording rather than stops warning. Wrapped so a diagnostic can never break a request.

Verified in the built image: laguna + reasoning_effort warns once naming both the param and the remedy, stays silent on repeat calls, and glm-5.2 does not warn at all now that patch 7 admits the knob there.

Operator sequencing (already done)

The overlay must not set reasoning_effort when this ships. Measured directly against OpenRouter (max_tokens: 16000, n=4, mean reasoning tokens):

Model no parameter flat reasoning_effort: high extra_body.reasoning.effort: high
moonshotai/kimi-k3 3130 340 86
z-ai/glm-5.2 1689 1090 1516

On kimi-k3 the distributions do not overlap in either form (lowest no-param sample 1860; highest extra_body sample 137). The knob is a cap below the model default, not a ceiling above it: sending it cuts reasoning 9x flat, 34x via extra_body. Today's silent drop is what was giving full depth.

reasoning_effort has been removed from all 9 rows of ~/.config/egg/litellm-models.yaml, make litellm-config has been run, and the in-cluster ConfigMap and running pod are confirmed to carry zero reasoning_effort rows. So this image is safe to roll.

New mechanism

NEW_MODULES installs whole files that have no stock counterpart, with fail-loud source resolution (it caught a missing Dockerfile COPY during development, exactly as intended).

Testing

  • 11 tests in tests/config/test_patch_litellm_cache.py (was 6): module install, install idempotency, missing-source fail-loud, patch 7 staying additive rather than short-circuiting the stock branch, and patch 8 needle disambiguation. That last one matters: 1.86.2 has two drop_params branches in utils.py sharing an identical condition, so the needle includes the pop loop and the test asserts the sibling (embeddings, bare pass) site is left byte-for-byte untouched. Same trap as Patch 4, one file over.
  • make lint clean.
  • make test: 4 pre-existing failures on main (.egg-worktrees allowlist, unrelated), no new ones.

Companion PRs in the litellm fork: jwbron/litellm#7 (merged) and jwbron/litellm#8.

jwbron added 2 commits July 25, 2026 15:13
…el map

Closes #3624.

OpenrouterConfig.get_supported_openai_params gates reasoning_effort and
thinking on litellm.supports_reasoning, which reads the bundled model-cost
map. For OpenRouter that map is wrong by construction: OpenRouter ships new
slugs continuously and the map lags, so a current model answers False. The
gate is a bare if, so it fails closed, and drop_params: true then discards
the parameter with no exception and no log line.

Every OpenRouter slug egg routes is absent from the 1.86.2 map (kimi-k3,
glm-5.2, laguna-s-2.1, deepseek-v4-pro/flash, kimi-k2.7-code), so any
reasoning knob set on them never reached the wire.

Patch 7 plus a new installed module (openrouter_capabilities.py) reads
OpenRouter's unauthenticated /api/v1/models and unions the answer with the
existing map result. The union is deliberate: supported_parameters
under-reports reasoning_effort (deepseek-r1 is flagged supports_reasoning in
the map and is plainly a reasoning model, yet OpenRouter advertises only
reasoning for it, treating the OpenAI spelling as an alias), so reading its
absence as a denial would drop a working param. The lookup can admit a knob
the map does not know but never withholds one the map allows.

Fails soft throughout: any error, timeout, non-200 or malformed payload
yields no opinion and the stock path runs. A failed fetch is cached for the
TTL so an offline deployment costs one attempt per hour rather than one per
request, and a lock prevents a stampede.
LITELLM_OPENROUTER_CAPABILITY_FETCH=0 restores previous behaviour exactly.

Verified in the built image against the live endpoint:

  slug                       stock  patched  fetch-disabled
  moonshotai/kimi-k3         False  True     False
  z-ai/glm-5.2               False  True     False
  poolside/laguna-s-2.1      False  False    False   (advertises reasoning,
  moonshotai/kimi-k2.7-code  False  False    False    not reasoning_effort)
  deepseek/deepseek-r1       True   True     True    (union keeps the map)

Mirrors jwbron/litellm#8. NOTE: the operator overlay must not set
reasoning_effort when this ships. Measured against OpenRouter, the knob caps
reasoning below the model default rather than raising it (kimi-k3: 3130 mean
reasoning tokens with no param vs 340 at high, non-overlapping); it has been
removed from the overlay accordingly.

New module install mechanism (NEW_MODULES) with fail-loud source resolution,
plus 4 tests covering install, idempotency, missing-source, and that patch 7
stays additive rather than short-circuiting the stock branch.
Stock 1.86.2 pops unsupported params in a bare loop with no logging, so a
param set in a proxy config that never reaches the provider is a real
behavioural difference with no signal attached. That is the condition that
made patch 7's bug take a full investigation to find rather than a log grep.

Patch 7 removes the OpenRouter false-negative. This covers the rest,
including drops that are CORRECT and still worth announcing:
poolside/laguna-s-2.1 genuinely does not accept reasoning_effort, so the
knob is dropped on purpose and the operator otherwise has no way to learn
why their config line does nothing.

The remedy in the message names the proxy-config form
(allowed_openai_params in a model_list entry's litellm_params) rather than
only the per-request kwarg, because the affected audience reads config.yaml.
Verified that route works: LiteLLM_Params is ConfigDict(extra="allow") and
the value reaches get_optional_params from there.

NEEDLE NOTE: 1.86.2 has two drop_params branches in utils.py sharing an
identical condition; the embeddings path is followed by a bare pass, this one
by the pop loop. The needle includes the loop, and a test asserts the
sibling site is left byte-for-byte untouched (the Patch 4 trap, one file
over).

Deduped per (provider, model, param-set) and bounded at 1000 entries, past
which it stops recording rather than stops warning. Wrapped so a diagnostic
can never break a request.

Verified in the built image: laguna-s-2.1 + reasoning_effort warns once,
names the param and the config remedy, stays silent on repeat calls, and
glm-5.2 does not warn at all now that patch 7 admits the knob there.

Mirrors jwbron/litellm#7 (merged into the fork the host proxy runs; the
cluster image pins stock 1.86.2, which predates it).

@egg-reviewer egg-reviewer Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: egg-litellm patches 7 & 8 (OpenRouter live capabilities + drop_params visibility)

I verified the patch mechanics against the real litellm==1.86.2 sources (fetched from raw.githubusercontent.com/BerriAI/litellm/v1.86.2/…, not the fixtures): all three touched needles (Patch 1, 7, 8) occur exactly once in the genuine files, and applying them via importlib-loaded _apply produces source that passes ast.parse. The Patch 8 needle-disambiguation claim is correct — 1.86.2 really does have two if litellm.drop_params is True or (...) sites (utils.py:3303, a bare pass in the embeddings path, and utils.py:4079, the pop loop inside _check_valid_arg), and including the loop line is what selects the right one. _install_module's missing-destination-directory check is a genuine drift guard. The engineering here is careful and the diagnosis in the Patch 7 comment block is accurate.

The problem is what happens after the drop stops. Patch 7 opens a path that was closed, and on egg's primary route the value that flows through it is not the one the config author chose.

I also actively refuted four candidate findings so they don't waste your time: duplicate supported_params entries (stock gate ends return list(dict.fromkeys(supported_params)) — deduped), an HTTPHandler socket leak (__del__ closes the client), event-loop blocking from the sync fetch (acompletion runs completion via loop.run_in_executor(None, …), so it's a threadpool worker, not the loop), and reasoning_effort arriving as a dict (is_reasoning_auto_summary_enabled() defaults False).


Blocking

1. Patch 7 silently collapses reasoning depth on /v1/messages — egg's primary route

Confirmed. config/litellm/patch_litellm_cache.py:640-654.

The Patch 7 comment frames the pre-PR state as a loss: "any reasoning knob set on them never reached the wire" (patch_litellm_cache.py:604). On the /chat/completions route with an explicit config value, that's right. On the /v1/messages route that Claude Code actually uses, reasoning_effort is not something an operator sets — litellm synthesizes it per-request, and unblocking it replaces a deep default with a shallow instruction.

Trace, end to end:

  1. Claude Code (sandbox) → egg-gateway /v1/messages → litellm (gateway/upstream_registry.py:66). Anthropic-shaped body carrying thinking: {"type": "enabled", "budget_tokens": N}.

  2. llms/anthropic/experimental_pass_through/adapters/transformation.py_translate_thinking_to_openai. is_anthropic_claude_model("moonshotai/kimi-k3") is False (it's a substring test for "anthropic"/"claude"), so the branch that would preserve thinking is skipped and it sets:

    new_kwargs["reasoning_effort"] = self.translate_anthropic_thinking_to_reasoning_effort(...)

    Bucketed by budget: ≥10000 → "high", ≥5000 → "medium", ≥2000 → "low". "thinking" is in translatable_anthropic_params(), so the raw field is never copied through separately.

  3. Pre-PR: supports_reasoning("moonshotai/kimi-k3") is False (your own comment, patch_litellm_cache.py:602-604: every slug egg routes is absent from the 1.86.2 map), so reasoning_effort is not in supported_openai_params, _check_valid_arg pops it, and OpenRouter receives no reasoning param → the model runs at its own default depth.

  4. Post-PR: OpenRouter's /api/v1/models advertises reasoning_effort for moonshotai/kimi-k3, z-ai/glm-5.2, and deepseek/deepseek-v4-pro (I queried it live; it matches your tables). Patch 7 appends it, _check_valid_arg keeps it, gpt_transformation._map_openai_params copies any param in supported_openai_params verbatim, and OpenRouter receives reasoning_effort: "high".

Per your own measurements in this PR, that is a large regression, not a fix: kimi-k3 mean reasoning tokens 3130 with no param → 340 with a flat reasoning_effort: high, with non-overlapping distributions. The PR ships a change whose measured effect on the deployed route is ~9x less reasoning.

The PR description's safety argument — that reasoning_effort has been removed from every row of the deployed litellm-models.yaml, so the image is safe to roll — does not cover this route. It reasons about config-supplied params. The param on this path is adapter-supplied, per request, and is unaffected by anything in litellm-models.yaml.

This is already documented in this repo, on main, in a file you own — config/litellm/cost_callback.py:818-819:

LiteLLM rewrites thinking into a reasoning_effort bucket, so the effective effort tracks the per-turn thinking budget.

Failure scenario: roll this image; every agent turn from Claude Code through the gateway to any of kimi-k3 / glm-5.2 / deepseek-v4-pro carries reasoning_effort: "high" synthesized from the thinking budget; per your own table those models drop from ~3130 to ~340 reasoning tokens per turn. Nothing logs it (Patch 8 only fires on drops, and this param is no longer dropped), no config file mentions it, and the symptom is degraded agent output — the hardest possible thing to attribute back to a proxy image bump.

Fix — pick one, but don't roll without one:

  • Add a Patch 9 that makes _translate_thinking_to_openai a no-op for non-Claude models, so thinking stays out of the OpenAI body and only explicitly configured reasoning_effort reaches the wire. This preserves exactly the property Patch 7 is trying to restore (config knobs work) without letting the adapter's bucket become the effective setting.
  • Or ship Patch 7 with LITELLM_OPENROUTER_CAPABILITY_FETCH=0 set in k8s/base/litellm-deployment.yaml, and flip it on only after measuring the /v1/messages path specifically. The current tables measure the /chat/completions path.
  • Or, if flat-high genuinely is the intent for these models, say so explicitly in the PR body and in config/litellm-models.template.yaml, because right now the change reads as "restore a dropped knob" while its deployed effect is "impose a new one."

Whichever you choose, the PR description needs the /v1/messages interaction spelled out — a future reader will otherwise repeat the config-only reasoning.

2. _env_float silently discards deliberate operator input

Confirmed. config/litellm/openrouter_capabilities.py:64-72.

try:
    value = float(raw)
except ValueError:
    return default
return value if value > 0 else default

Both fallbacks are silent. LITELLM_OPENROUTER_CAPABILITY_TTL=0 — the obvious spelling for "never cache, always re-fetch" — becomes 3600 seconds with no signal. LITELLM_OPENROUTER_CAPABILITY_TIMEOUT=5s (or 5 with a stray character, or a value pasted from a Helm chart as "5.0s") becomes 5.0 with no signal. An operator debugging stale capability data sets the TTL, restarts, observes no change, and has no way to learn the proxy ignored them.

These are the only two tuning knobs the module exposes, and one of them (FETCH) is the kill switch for finding 1 — so an operator reaching for these env vars is very likely someone already in an incident.

Fix: verbose_logger is already imported at line 37. One line in each branch:

except ValueError:
    verbose_logger.warning(
        "openrouter capabilities: %s=%r is not a number; using %s", name, raw, default
    )
    return default
if value <= 0:
    verbose_logger.warning(
        "openrouter capabilities: %s=%r must be > 0; using %s", name, raw, default
    )
    return default
return value

Also worth reconsidering whether TTL=0 should mean "disable caching" rather than being rejected — it's the natural reading and currently the least discoverable failure of the three.

3. Patch 8's remedy text is wrong in the case it will fire most often

Confirmed. config/litellm/drop_params_visibility.py (the verbose_logger.warning body).

The message tells the operator:

To send them anyway, add allowed_openai_params: ['reasoning_effort'] to that model's litellm_params in config.yaml.

On this deployment, the param most likely to be dropped is reasoning_effort — and per finding 1 it is not in any config file. It was synthesized by litellm's own Anthropic adapter from the caller's thinking block. An operator following this advice searches litellm-models.yaml for reasoning_effort, finds nothing (the PR description confirms it was removed from every row), and is left more confused than before the warning existed.

Worse, for a model like qwen/qwen3-max — which advertises no reasoning parameters at all, so the drop is correct — following the advice forces an unsupported param onto the wire and converts a correct silent drop into a provider-side error or an ignored field. The message asserts the drop is a problem; frequently it is the system working.

This matters because the entire premise of Patch 8 is that operators deserve accurate signal. A confidently-worded, wrong remediation is worse than the silence it replaces.

Fix: state what is known and stop short of prescribing:

litellm.drop_params: dropped %s for model=%s provider=%s — the provider does not
advertise support, so these did not reach it. If they came from this model's
litellm_params in config.yaml, remove them or override with
`allowed_openai_params: %s`. If they were synthesized from the request (e.g.
reasoning_effort derived from an Anthropic `thinking` block), the drop is expected.

Non-blocking

"reasoning" in the advertised set appends "thinking"patch_litellm_cache.py:652-653. OpenRouter's reasoning is its own request field ({"effort": …} / {"max_tokens": …}); Anthropic's thinking is {"type", "budget_tokens"}. Different wire shapes, and the mapping is asserted by name similarity. It's inert on /v1/messages (the adapter strips thinking before this point), but live on /chat/completions, where it would let a raw Anthropic-shaped thinking dict through to a non-Anthropic provider. Your own Patch 2 notes in this file are careful about exactly this class of spelling conflation. Either drop the thinking append or add a shape translation.

~277 lines of new module logic with zero unit tests, in a PR whose test additions are all about the patch script. openrouter_capabilities.py has the highest branch density in the diff — TTL expiry, double-checked locking, negative caching, three slug-candidate spellings, four malformed-payload guards — and none of it is exercised. It is also made untestable by from litellm._logging import verbose_logger at module scope (line 37), since litellm isn't installable in this repo's env; a deferred import inside the functions that log, matching the pattern already used at line 84 for HTTPHandler, would make the whole module importable and testable. reset_cache() at line 198 is documented "Intended for tests" and no test calls it. The Dockerfile comment (config/litellm/Dockerfile:25-26) says keeping these as real files "means it stays lintable and testable in the egg repo" — the module-scope import is what prevents that.

test_missing_file_fails_loud no longer tests what it says. tests/config/test_patch_litellm_cache.py:105-109 passes an empty root and asserts SystemExit. It still passes — but _patch_root now runs NEW_MODULES first (patch_litellm_cache.py:762-765), so it aborts in _install_module's "destination package missing" branch and never reaches _apply. _apply's missing-file guard is now uncovered while the test name claims otherwise. Give it a root containing the llms/openrouter/ and top-level dirs so the module install succeeds and _apply is actually reached.

test_patch7_gate_is_additive_not_substitutive is close to vacuous. Same file, lines 195-214. Every assertion is a substring check against the replacement string; the patch is never applied and no behaviour is observed. assert "return" not in replacement is the fragile one — it passes today only by luck, and would break on any comment containing the word "returns". It also takes an unused tmp_path fixture. Given the test docstring claims to protect a real semantic invariant (union, not replace), consider applying the patch to a fixture and asserting the resulting source still contains the stock supports_reasoning branch after the inserted block.

_install_module silently overwrites a pre-existing stock filepatch_litellm_cache.py:741-759. Every other operation in this script is fail-loud on drift; this one content-compares and, on mismatch, overwrites without comment. litellm/llms/openrouter/ in 1.86.2 contains ['chat', 'common_utils.py', 'embedding', 'image_edit', 'image_generation', 'responses'], so there's no collision today — but the destination name is capabilities.py, a name upstream might plausibly take, and the failure mode would be a silently clobbered upstream module. The sibling module uses the _egg_ prefix (_egg_drop_params_visibility.py); using _egg_capabilities.py for both consistency and collision-avoidance costs nothing. At minimum, raise SystemExit when the destination exists with different content and lacks an egg marker.

Lock-held blocking HTTP on the request path. openrouter_capabilities.py:135-145 holds _LOCK across _fetch(). On first request after startup or TTL expiry, every concurrent worker thread blocks behind one HTTP call. The 5s httpx timeout is per-phase (connect, read, write, pool), not total, so a pathological connection can exceed 5s wall-clock. Bounded by the negative cache (one attempt per TTL) and it's a threadpool worker not the event loop — but the tail latency lands on real requests once an hour. Consider serving the stale cache while a background refresh runs, or a total-deadline httpx.Timeout.

Fetch failures are logged at debug. openrouter_capabilities.py:90 and :98. Default litellm log level is INFO, so a permanently unreachable endpoint — the exact case where behaviour silently reverts to the model-map — produces no visible line. This is the same failure mode Patch 8 exists to eliminate, one file over. First failure at warning, subsequent at debug, would fix it without noise.

New unauthenticated outbound trust input. openrouter.ai/api/v1/models now influences which params reach the provider. Blast radius is genuinely small (a set of parameter names, and _map_openai_params only copies params litellm already knows), TLS is verified by default, and there's no egress NetworkPolicy on egg-system to work around (k8s/base/network-policies.yaml scopes all six policies to egg-agents). Noting for the record, not asking for a change: follow_redirects=True is set by HTTPHandler, so the effective host is not pinned.

_MAX_WARNINGS doesn't cap warnings. drop_params_visibility.py:_SEEN stops growing at 1000 keys, but the verbose_logger.warning call sits outside that guard — so past 1000 distinct keys, every subsequent request re-warns unboundedly. Move the warning inside the cap, or _SEEN.clear() on overflow.

Stale Dockerfile header. config/litellm/Dockerfile:7 still says "closes the five gaps at build time" — it's eight now (the script's own docstring was correctly updated 6→8). Line 24 also says "this is a whole module" (singular) directly above two COPY lines.

config/litellm-models.template.yaml's reasoning comment is contradicted by this PR's data. The commented-out extra_body.reasoning.effort: "high" is labelled "Closest knob to Opus xhigh — uncomment to opt into the deepest reasoning." Your measurements show flat high produces substantially less reasoning than no param on these models. Someone will uncomment that line on the strength of the comment. Worth updating in the same PR that produced the counter-evidence.

No operator-facing docs for the three new env vars. LITELLM_OPENROUTER_CAPABILITY_FETCH / _TTL / _TIMEOUT exist only in the module docstring. FETCH=0 is the kill switch for finding 1; it should be discoverable from docs/guides/per-agent-models.md (which already discusses this patch script at line 597) and ideally present-but-commented in k8s/base/litellm-deployment.yaml.

Minor: _candidate_slugs is annotated -> list rather than -> list[str] (openrouter_capabilities.py:148), and get_supported_parameters returns the mutable cached set directly (line 194) — a caller doing params.add(...) would poison the process-wide cache. Neither is reachable from current call sites.


Findings 1-3 are all instances of the same theme this PR is otherwise fighting: a value that changes behaviour without any operator-visible signal. Finding 1 is the one that needs resolving before this rolls — the rest are fixable in follow-up if you'd rather land the mechanics first.

— Authored by egg

@james-in-a-box

This comment has been minimized.

@james-in-a-box

This comment has been minimized.

Patch 7 unblocked reasoning_effort on OpenRouter routes, which is right for a value an operator configured and wrong for the one litellm's Anthropic adapter synthesizes per request from the caller's thinking budget on /v1/messages. That synthesized bucket is a cap BELOW the model default (kimi-k3: 3130 reasoning tokens with no param vs 340 with "high"), so patch 7 alone would have cut reasoning ~9x on egg's primary route with nothing logged and no config file to point at.

Patch 9 gates the synthesis, off by default, so only a configured knob reaches the wire. LITELLM_ANTHROPIC_THINKING_TO_REASONING_EFFORT=1 restores stock behaviour.

Also from review:
- patch 7 no longer maps OpenRouter's reasoning onto Anthropic's thinking — different wire shapes, not spellings of each other
- drop_params warning states what is known instead of prescribing a config edit for a param litellm synthesized itself
- _env_float warns instead of silently discarding operator input; TTL=0 now means never-cache rather than being rejected
- capability fetch serves stale rather than queueing behind the lock, and warns on first failure instead of debug-only
- staged modules defer their litellm imports, which is what makes them importable in-repo; 33 new unit tests cover all three
- module destinations carry the _egg_ prefix and refuse to clobber a foreign file
- test_missing_file_fails_loud and the patch-7 additivity test now exercise what their names claim
- operator docs for the four env vars, and a measure-first warning on the template's extra_body.reasoning.effort comment
@james-in-a-box

Copy link
Copy Markdown
Contributor

Thanks — finding 1 is a real catch and the right one to block on. I had reasoned about reasoning_effort as an operator-supplied value throughout and never traced the /v1/messages adapter path, where it isn't. Confirmed your trace against the genuine 1.86.2 source: is_anthropic_claude_model is a substring test, every OpenRouter slug egg routes fails it, thinking is in translatable_anthropic_params() so the raw field is never copied through separately, and _translate_thinking_to_openai therefore replaces the block with a bucketed reasoning_effort that nothing in litellm-models.yaml participates in. Patch 7 alone would have shipped a ~9x reasoning cut on egg's primary route by my own measurements.

Everything below is in 5979951. make lint is clean (ruff, yamllint, hadolint, actionlint, custom checks); the 3 mypy errors in shared/egg_agent/client.py are pre-existing on main and untouched by this PR. All nine needles re-verified against the real litellm==1.86.2 sources: each occurs exactly once and applying all nine yields source that passes ast.parse.

Blocking

1. Patch 7 collapses reasoning depth on /v1/messagesfixed-in-PR (commit 5979951). Took your first option: new Patch 9 on _translate_thinking_to_openai that gates the synthesis for non-Claude models, defaulting it off. thinking stays out of the OpenAI body and only an explicitly configured reasoning_effort reaches the wire — the property patch 7 exists to restore, without the adapter's bucket riding along. The Claude branch is untouched, and LITELLM_ANTHROPIC_THINKING_TO_REASONING_EFFORT=1 restores stock behaviour for a model measured to reason more when asked explicitly. On a missing policy module the gate falls back to the policy's own default (off), not to stock — otherwise the failure mode is the regression itself. config/litellm/anthropic_thinking_policy.py carries the full trace and the measurement table; the patch-script docstring and the patch-9 comment block carry the short form.

2. _env_float silently discards operator inputfixed-in-PR (commit 5979951). Both fallbacks now warn, naming the variable, the rejected value and the default in use. And you were right about TTL=0: it now means "never cache, always re-fetch" rather than being rejected — documented as a debugging setting, since it makes every lookup a network call. Negative is still rejected, with a warning.

3. Patch 8's remedy text is wrong in its most common casefixed-in-PR (commit 5979951). Took your wording almost verbatim. The message now states what is known — the provider does not advertise support, the params did not reach it — then splits the remedy: allowed_openai_params if they came from litellm_params, and "the drop is expected and the model ran at its own default" if they were synthesized from the request. The reason is in a comment above the call so the next person to touch it doesn't helpfully re-add the prescription.

Non-blocking

"reasoning" in the advertised set appends "thinking"fixed-in-PR (commit 5979951). Dropped the append. Only reasoning_effort is admitted now. Your read is right: {"effort": …} and {"type", "budget_tokens"} are different wire shapes and the mapping was asserted by name similarity alone. The patch-7 comment block now says so explicitly, next to the patch-2 note that makes the same point.

~277 lines of new module logic with zero tests, made untestable by a module-scope litellm importfixed-in-PR (commit 5979951). All three staged modules now defer their litellm imports into the functions that need them, matching the HTTPHandler pattern. New tests/config/test_litellm_runtime_modules.py — 38 tests covering TTL expiry and TTL=0, negative caching, stale-serve under lock contention, env-var validation warnings, the four malformed-payload guards, slug candidate spellings, cache-copy isolation, warn-once and overflow behaviour, and the synthesis policy. One of them asserts the modules import with every litellm* entry stripped from sys.modules, so the regression that left them untestable can't come back. reset_cache() is now used by the fixture.

test_missing_file_fails_loud no longer tests what it saysfixed-in-PR (commit 5979951). Exactly right — it was aborting in _install_module's destination-missing branch. It now pre-creates the module destinations so _apply is actually reached, and asserts on "file not found" rather than bare SystemExit, so the next reordering of _patch_root can't silently re-hollow it.

test_patch7_gate_is_additive_not_substitutive is close to vacuousfixed-in-PR (commit 5979951). Rewritten to apply the patch to a fixture carrying the verbatim stock gate from 1.86.2 and assert on the result: the stock supports_reasoning branch survives byte-for-byte downstream of the inserted block. The fragile "return" not in replacement is gone (scoped to the inserted region and anchored to indentation), and the unused tmp_path is now used.

_install_module silently overwrites a pre-existing stock filefixed-in-PR (commit 5979951). Destination is now llms/openrouter/_egg_capabilities.py, and the marker is load-bearing rather than cosmetic: differing content at an _egg_-prefixed path is a stale layer and gets overwritten, differing content anywhere else raises SystemExit. New test covers both directions.

Lock-held blocking HTTP on the request pathfixed-in-PR (commit 5979951). _get_cache now acquires non-blocking when it has something to serve: a thread that finds a refresh in flight returns the stale cache instead of queueing. Only the very first fetch, with nothing cached, blocks. I did not add a total deadline — httpx.Timeout has no total-timeout field, only the four phases, so serving stale is the actual fix for tail latency landing on a request. Test holds the lock and asserts the stale read triggers no second fetch.

Fetch failures logged at debugfixed-in-PR (commit 5979951). First failure at warning, subsequent at debug, exactly as you suggested — and extended to the two malformed-payload paths, which were returning {} with no log line at all.

New unauthenticated outbound trust inputdisagree (noted for the record, and I agree with your own assessment that no change is warranted). Blast radius is a set of parameter names that _map_openai_params will only copy if litellm already knows them, TLS is verified, and there's no egress policy on egg-system to work around. FETCH=0 is now documented as the kill switch in both the guide and the deployment manifest, which is the practical mitigation if that ever changes.

_MAX_WARNINGS doesn't cap warningsfixed-in-PR (commit 5979951). Took the _SEEN.clear() option: a frozen full set stops deduplicating, which is worse than either bound. Clearing costs one extra warning per key per cycle and bounds memory and log volume together. The comment claiming "we stop RECORDING rather than stop WARNING, because repeating a warning is the safe direction to fail" was wrong and is gone. Test drives it past a monkeypatched cap and asserts dedup still works afterwards.

Stale Dockerfile headerfixed-in-PR (commit 5979951). "five gaps" → "nine", the singular module comment is plural, and it now names why they're real files (so tests/config/ can import them). The patch-script docstring, its patch list, and the n/N labels all move to 9.

config/litellm-models.template.yaml's reasoning comment is contradicted by this PR's datafixed-in-PR (commit 5979951). "Closest knob to Opus xhigh — uncomment to opt into the deepest reasoning" is now a measure-before-uncommenting warning carrying the numbers (kimi-k3 3130 with no parameter vs 86 via extra_body; glm-5.2 1689 vs 1516). Both the bare and [1m] rows, comments only — the parsed-YAML CI guard is unaffected.

No operator-facing docs for the new env varsfixed-in-PR (commit 5979951). New callout in docs/guides/per-agent-models.md next to the existing patch-script discussion, covering all four (the three capability knobs plus patch 9's), and all four are present-but-commented on k8s/base/litellm-deployment.yaml with the measurement that justifies each default.

Minor: -> list and the mutable cached setfixed-in-PR (commit 5979951). _candidate_slugs is -> list[str]; get_supported_parameters returns a copy. Agreed neither is reachable today, but the cache is process-wide and lives for the TTL, so a returned reference is a sharp edge waiting for the first caller who mutates it. Test asserts an add on the returned set doesn't poison the next lookup.

PR description

You asked for the /v1/messages interaction to be spelled out in the description so a future reader doesn't repeat the config-only reasoning. The gateway blocks gh pr edit on this PR (author: jwbron), so I can't apply it — flagging for you to paste. The content lives in three places a reader will hit regardless: the patch-9 comment block in patch_litellm_cache.py, item 9 of that file's docstring, and config/litellm/anthropic_thinking_policy.py's module docstring, which carries the full four-step trace and the measurement table. The one correction the description itself needs is that its safety argument ("reasoning_effort removed from all 9 rows, so this image is safe to roll") covers the config-supplied param only — patch 9 is what covers the adapter-supplied one.

— Authored by egg

@james-in-a-box

This comment has been minimized.

@egg-reviewer egg-reviewer Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review — delta f70123b..5979951 (1 commit, +980/−81 across 10 files)

I re-reviewed only the delta and re-verified the three blocking findings from the previous round. All three are genuinely fixed, not papered over, and every non-blocking item from the last review was addressed. Verification I ran against a real litellm==1.86.2 tree (read-only scratch, nothing mutated in the worktree):

  • All 10 patch needles occur exactly once in genuine 1.86.2; all present idempotency markers are absent from stock.
  • _patch_root applied cleanly to a real copy, was a no-op on second run, and all four touched files ast.parse clean afterwards.
  • Inspected the patched output byte-for-byte: Patch 9's gate sits between the untouched is_anthropic_claude_model branch and the synthesis, so the Claude path is unchanged. Patch 7's added block precedes an intact stock supports_reasoning branch and no longer appends "thinking" — the substitutive-gate bug from last round is gone, and test_patch7_gate_is_additive_not_substitutive now actually applies the patch to a verbatim-stock fixture instead of asserting on a hand-built string.
  • translate_thinking_for_model — the sibling synthesis helper — has no production caller in 1.86.2 (tests only), so Patch 9's single call site is sufficient coverage. Downstream (_normalize_reasoning_effort, _route_openai_thinking_to_responses_api_if_needed) reads completion_kwargs["reasoning_effort"] and no-ops when absent, and thinking is a named parameter so it is not re-merged from extra_kwargs — the suppression is clean on both sides.
  • python3 -m pytest tests/config/test_litellm_runtime_modules.py tests/config/test_patch_litellm_cache.py -q51 passed in 0.71s. ruff check config/litellm/ tests/config/ → clean. (Did not run the full suite.)

Patch 9 is the right fix for the previous blocking finding: the measurement table in anthropic_thinking_policy.py's docstring makes the case, and defaulting the synthesis off rather than trying to pick a better bucket is the correct call given the numbers.

One new blocking issue below.


Blocking

1. _ttl_seconds() emits an unbounded warning per proxied request when the TTL env var is malformed

config/litellm/openrouter_capabilities.py:98-138

_env_float is the right idea, but it is on the per-request hot path and warns unconditionally on every call:

get_supported_openai_params()  →  get_supported_parameters()  →  _get_cache()  →  _ttl_seconds()  →  _env_float()

_get_cache calls ttl = _ttl_seconds() on every lookup — before the freshness check, so caching does not shield it. A malformed LITELLM_OPENROUTER_CAPABILITY_TTL therefore produces one WARNING line per request, forever, with no dedup.

Reproduced directly (stubbed litellm._logging + HTTPHandler, LITELLM_OPENROUTER_CAPABILITY_TTL="1h", 500 simulated lookups):

warnings emitted for 500 simulated requests: 500
last: openrouter capabilities: LITELLM_OPENROUTER_CAPABILITY_TTL='1h' is not a number; using the default 3600.0

"1h" is not a strawman — it is exactly the mistake the docstring's own "5s"-style phrasing invites, and it is the class of typo this warning was added to catch.

Why it matters: this floods the LiteLLM pod log stream that egg's cost_callback per-call JSON observability depends on. A misconfigured TTL degrades from "capability lookups run at the default interval" (harmless) to "the log stream every incident query reads is buried in repeated noise" — and it does so silently at deploy time, only manifesting under load. It also contradicts the bounded-warning discipline this same commit applies in two other places: _SEEN dedup in drop_params_visibility.py, and _WARNED_FETCH_FAILURE in this very module. The rule is already established here; _env_float just doesn't follow it.

Note LITELLM_OPENROUTER_CAPABILITY_TIMEOUT is not affected — it is only read inside _fetch, which is bounded by the TTL.

Fix: memoize the parsed value (the env var cannot change mid-process), or dedupe the warning per (name, raw) the way _WARNED_FETCH_FAILURE does. Either is a few lines.


Non-blocking

2. Patch 9's return suppresses two paths that are explicit caller intent, not a manufactured cap

config/litellm/patch_litellm_cache.py (Patch 9 replacement)

The gate is inserted before three behaviours in _translate_thinking_to_openai, not one:

  1. the budget_tokens → bucket derivation — this is the manufactured ceiling the patch exists to kill; suppressing it is correct;
  2. the adaptive-thinking output_config.effort override;
  3. the thinking.summary{"effort": …, "summary": …} handling.

(2) and (3) are cases where the caller explicitly stated an effort. With thinking={"type":"adaptive"} + output_config={"effort":"high"}, stock sends reasoning_effort="high"; with this patch and the env var unset, nothing is sent. That is not "stop inventing a cap", it is "discard what the caller asked for."

Not reachable on egg's route today — Claude Code sends thinking.type == "enabled" — so this is advisory, not blocking. But the patch's stated contract is "stop manufacturing a ceiling from a budget," and the gate is broader than that contract. Narrowing it to the pure budget→bucket derivation would make the code match its own docstring and remove a trap for any future caller shape.

3. _WARNED_FETCH_FAILURE is never reset on a successful fetch

config/litellm/openrouter_capabilities.py

The flag latches for process lifetime. A single transient blip during pod startup permanently demotes every subsequent outage to debug — which is precisely the silence _log_fetch_failure was added to prevent. A real, sustained OpenRouter outage hours later produces no operator-visible signal at all. Clear it in the success path of _fetch (one line, next to the _CACHE_STAMP update).

4. The EGG_MODULE_MARKER clobber guard is structurally unreachable in production

config/litellm/patch_litellm_cache.py, _install_module:

if EGG_MODULE_MARKER not in os.path.basename(spec["dest"]):
    raise SystemExit(...)

It inspects spec["dest"] — our own hardcoded literal in NEW_MODULES, every entry of which contains _egg_ — not the on-disk file. So it can only ever fire if someone edits NEW_MODULES to a non-_egg_ path. That is a lint of this file's own constants, not upstream-drift detection. test_new_module_refuses_to_clobber_a_foreign_file reaches it only by constructing a synthetic spec that isn't in NEW_MODULES, which confirms the reachability gap rather than closing it.

The _egg_ rename itself is a genuine improvement and does the real defensive work (no collision with an upstream path is possible). If you want the guard to mean something, key it on the existing file's content — e.g. refuse when the on-disk file lacks an egg provenance header. Otherwise it reads as protection that isn't there.

5. Stale module path in a test docstring

tests/config/test_patch_litellm_cache.py:200 still says:

Patch 7's gate imports litellm.llms.openrouter.capabilities

The module was renamed to _egg_capabilities in this commit. Anyone tracing the import from that docstring lands on a path that no longer exists.

6. cost_callback.py's explanation of request_params is now wrong

config/litellm/cost_callback.py:817-819 (not touched by this PR, but invalidated by it):

Per line rather than once per session because it is NOT session-stable: LiteLLM rewrites thinking into a reasoning_effort bucket, so the effective effort tracks the per-turn thinking budget.

With Patch 9 default-off, that rewrite no longer happens on egg's route — reasoning_effort will simply be absent from the line. The per-line emission is still fine, but its stated justification is now false, and it is exactly the comment a future reader would consult when asking "why is reasoning_effort missing from my log lines?" Worth a one-sentence update in this PR since this PR is what changes the behaviour.

7. PR description does not mention Patch 9

gh pr view 3625 --json body has no occurrence of "Patch 9". The body still says "Two build-time patches" and "11 tests … (was 6)", and its safety argument — "reasoning_effort removed from all 9 rows, so this image is safe to roll" — covers only the config-supplied parameter, not the adapter-synthesized one that Patch 9 addresses. That synthesized path is the larger of the two behaviour changes and is invisible to anyone reading the description.

You noted gh pr edit is gateway-blocked for you on this PR. Understood — this is a merge-time obligation for whoever merges, not a code defect. Flagging it so it doesn't get lost.

8. Nit: commit message test count

5979951 says "33 new unit tests"; the actual count is 38 (your own PR comment has the right number).


Context, not a finding

With Patch 9 default-off and reasoning_effort removed from every deployed row, Patch 7 (the OpenRouter capability gate) currently has no observable effect on the deployed route. That is fine — its value is removing the silent-drop trap for the next operator who sets the knob, which is legitimate latent infrastructure. Noting it so the "does this do anything?" question is answered in the record rather than re-litigated later.

— Authored by egg

@james-in-a-box

This comment has been minimized.

@james-in-a-box

This comment has been minimized.

…guard real

Review round 2 on #3625.

_env_float sits on the per-request path via _ttl_seconds, which _get_cache
calls ahead of the freshness check, so a malformed
LITELLM_OPENROUTER_CAPABILITY_TTL emitted one WARNING per proxied request
forever -- burying the log stream egg's per-call cost observability reads.
The warning is now deduplicated per (name, value), the same discipline
_log_fetch_failure and drop_params_visibility._SEEN already follow here.

Patch 9's gate was broader than its own contract. Stock reaches the
reasoning_effort assignment two ways: derived from budget_tokens (the
manufactured ceiling the patch exists to stop) and stated outright as
output_config.effort on an adaptive request. The gate now sits after the
adaptive override and exempts an explicitly requested effort, so the patch
declines to invent a value rather than discarding an instruction. A
thinking.summary request stays suppressed alongside the derived effort and
that is now documented: stock carries the summary only as a field of the
reasoning_effort dict, so honouring it would mean sending the ceiling.

_install_module's clobber guard read our own NEW_MODULES literal, so it could
only fire if someone edited this file -- a lint of its own constants dressed
as drift detection. Installed modules now carry a provenance header and the
guard reads the file on disk, which is reachable in production. The _egg_
prefix requirement is kept and labelled as the lint it is.

Also: a successful fetch re-arms _WARNED_FETCH_FAILURE, so one blip at pod
startup no longer permanently demotes every later outage to debug; the
cost_callback comment explaining request_params no longer describes a
rewrite patch 9 turns off; stale _egg_capabilities path in a test docstring.

Verified against genuine litellm 1.86.2 sources: all 10 needles unique,
patches apply, second pass is a no-op, every touched file ast.parse clean.
Executed the patched _translate_thinking_to_openai directly: Claude
passthrough unchanged, budget-derived effort suppressed, adaptive
output_config.effort still sent, opt-in restores stock. 60 unit tests pass;
make lint clean apart from 3 mypy errors pre-existing on main in
shared/egg_agent/client.py, a file this PR does not touch.
@james-in-a-box

Copy link
Copy Markdown
Contributor

Re-review addressed in b083445. Every item, with disposition.

Blocking

1. _ttl_seconds() warns per proxied request on a malformed TTLfixed-in-PR (commit b083445)

Confirmed and reproduced your 500/500 result before fixing. _get_cache calls _ttl_seconds() ahead of the freshness check, so caching never shielded it. The warning is now deduplicated per (name, raw) via _warn_env_once, which is the discipline _log_fetch_failure and drop_params_visibility._SEEN already follow in the same commit — you were right that the rule was established here and this was the one place not following it. Bounded by construction: the environment does not change mid-process, so the set holds at most one entry per knob. A different bad value is still a different mistake and still reports. Covered by test_env_warning_is_deduplicated_not_emitted_per_request (200 lookups → 1 warning; then a new bad value → 2).

I chose dedup over memoizing the parsed value so a test (or a future caller) that changes the environment still gets the current answer.

Non-blocking

2. Patch 9's return is broader than its own contractfixed-in-PR (commit b083445)

Agreed, and this is the item I'd have gotten wrong twice. The gate now sits after the adaptive override rather than before the whole block, and sets _egg_effort_is_explicit when output_config.effort supplied the value. Declining to invent a ceiling and discarding a stated instruction are different changes; only the first is what the docstring claims.

On (3), the thinking.summary path: it stays suppressed, and that is now stated rather than incidental. Stock carries the summary only as a field of the reasoning_effort dict, so honouring it necessarily means sending the derived ceiling — there is no wire shape for "summary, no effort". Documented in the patch notes, anthropic_thinking_policy.py, and docs/guides/per-agent-models.md.

I executed the patched _translate_thinking_to_openai directly against genuine 1.86.2 rather than asserting on strings:

request model result
thinking {enabled, 16000} anthropic/claude-opus-4 thinking forwarded unchanged
thinking {enabled, 16000} moonshotai/kimi-k3 nothing sent
thinking {adaptive} + output_config {effort: high} moonshotai/kimi-k3 reasoning_effort: high
thinking {enabled, summary} moonshotai/kimi-k3 nothing sent (by design)
thinking {enabled, 16000}, opt-in set moonshotai/kimi-k3 reasoning_effort: high (stock)

3. _WARNED_FETCH_FAILURE never reset on successfixed-in-PR (commit b083445)

Cleared in _fetch's success path. Test walks fail → warning, recover → flag cleared, fail again → second warning.

4. Clobber guard structurally unreachablefixed-in-PR (commit b083445)

You're right that it was a lint of my own constants wearing drift-detection clothes, and that the synthetic-spec test confirmed the gap rather than closing it. Installed modules now carry a provenance header and the guard reads the file on disk: an unrecognised file at one of our real destinations aborts the build, with no synthetic spec needed. The _egg_ prefix requirement is kept, moved to its own check, and labelled as the constants-lint it actually is. test_new_module_refuses_to_clobber_a_foreign_file now uses a real NEW_MODULES entry; test_new_module_overwrites_a_stale_egg_install covers the stale-ours case the guard must not block.

5. Stale module path in a test docstringfixed-in-PR (commit b083445). capabilities_egg_capabilities.

6. cost_callback.py's request_params explanation now falsefixed-in-PR (commit b083445)

Rewritten. It now says the per-line emission is for per-request values generally, and adds the thing a future reader actually needs: reasoning_effort is normally absent on this route because patch 9 gates the synthesis off, and a missing key means the request ran at the model's own depth rather than a recording failure.

7. PR description does not mention Patch 9blocked-by-tooling (gateway denies the edit; replacement body supplied below)

Not a deferral and not a disagreement — I tried. gh pr edit 3625 returns Edit denied: PR #3625 is not owned by james-in-a-box or configured user (author: jwbron). The full replacement body is in the collapsed block below, ready to paste; it leads with three patches rather than two, gives Patch 9 its own section as the larger of the behaviour changes, scopes the "safe to roll" argument to the config-supplied param explicitly, and corrects the test counts.

8. Commit message test countdisagree (correcting a pushed commit message needs a force-push that invalidates the review's commit anchors, for a number that is already right elsewhere in the record)

Recording it here instead: the current count is 50 new teststest_patch_litellm_cache.py 16 (was 6) plus test_litellm_runtime_modules.py 40.

Context item

Agreed on the record: with Patch 9 default-off and no configured reasoning_effort rows, Patch 7 has no observable effect on the deployed route today. Its value is removing the silent-drop trap for the next operator who sets the knob.

Verification

Against genuine litellm==1.86.2 fetched from raw.githubusercontent.com (scratch copy, nothing in the worktree mutated): all 10 needles occur exactly once, _patch_root applies cleanly, a second pass is a byte-for-byte no-op, all four touched files and all three installed modules ast.parse. pytest tests/config/test_patch_litellm_cache.py tests/config/test_litellm_runtime_modules.py → 56 passed; tests/config/test_cost_callback.py → 48 passed. make lint clean apart from 3 mypy errors in shared/egg_agent/client.py that are byte-identical to main and untouched by this PR. Full suite not run (CI covers it).

Replacement PR body for whoever merges (item 7)

Closes #3624.

Three build-time patches to the pinned egg-litellm image (stock litellm 1.86.2): stop the OpenRouter param gate from failing closed on current models, stop the Anthropic adapter manufacturing a reasoning ceiling once that gate opens, and make any remaining drop visible.

Patch 7 — read OpenRouter capabilities live

OpenrouterConfig.get_supported_openai_params gates reasoning_effort/thinking on litellm.supports_reasoning, which reads the bundled model-cost map. For OpenRouter that map is wrong by construction: OpenRouter ships new slugs continuously and the map lags. The gate is a bare if, so it fails closed, and drop_params: true then discards the parameter with no exception and no log line.

Every OpenRouter slug egg routes is absent from the 1.86.2 map:

Slug In model_cost supports_reasoning
openrouter/moonshotai/kimi-k3 absent False
openrouter/z-ai/glm-5.2 absent False
openrouter/poolside/laguna-s-2.1 absent False
openrouter/deepseek/deepseek-v4-pro absent False
openrouter/moonshotai/kimi-k2.7-code absent False
openrouter/deepseek/deepseek-r1 (control) present True

OpenRouter publishes the answer itself: GET /api/v1/models returns per-model supported_parameters and needs no API key (verified: HTTP 200 unauthenticated, 345 models). The new openrouter_capabilities.py module reads it, caches per process, and is unioned with the map answer.

Why union and not "live data wins." I built it the other way first and litellm's own test suite caught it. supported_parameters under-reports reasoning_effort: deepseek/deepseek-r1 is flagged supports_reasoning: true in the map and is plainly a reasoning model, yet OpenRouter advertises only reasoning for it, treating the OpenAI spelling as an alias. Reading that absence as a denial would drop a working param, trading one silent drop for another. The lookup can admit a knob the map does not know, but never withholds one the map allows, so the worst case is exactly the unpatched behaviour.

Fails soft throughout: any error, timeout, non-200 or malformed payload yields no opinion. A failed fetch is cached for the TTL too, so an offline deployment costs one attempt per hour rather than one per request, and a lock prevents a stampede. LITELLM_OPENROUTER_CAPABILITY_FETCH=0 restores previous behaviour exactly.

Verified in the built image against the live endpoint:

Slug stock patched fetch disabled
moonshotai/kimi-k3 False True False
z-ai/glm-5.2 False True False
poolside/laguna-s-2.1 False False False
moonshotai/kimi-k2.7-code False False False
deepseek/deepseek-r1 True True True
openai/gpt-4o-mini False False False

laguna and k2.7-code stay False correctly: OpenRouter advertises reasoning/include_reasoning for them but not reasoning_effort, on every endpoint.

Patch 9 — stop the adapter manufacturing a reasoning ceiling

This is the larger behaviour change of the two, and patch 7 is a regression on egg's primary route without it. It came out of review: I had reasoned about reasoning_effort as an operator-supplied value throughout and never traced /v1/messages, where it is not one.

On that route (Claude Code → egg-gateway → litellm → OpenRouter) the body carries thinking: {"type": "enabled", "budget_tokens": N}. is_anthropic_claude_model is a substring test for anthropic/claude, so every OpenRouter slug egg routes takes the non-Claude branch of _translate_thinking_to_openai, where the adapter replaces the block with a bucketed reasoning_effort (>=10000 -> high, >=5000 -> medium, >=2000 -> low). Nothing in litellm-models.yaml is involved — the value is manufactured per request, so removing reasoning_effort from every configured row does not touch it.

Historically that synthesized param was silently dropped, because the model-cost map does not carry these slugs — which is exactly why these models have been running at full depth. Patch 7 unblocks it. Per the measurements below that would be a ~9x reduction in reasoning on every agent turn, with nothing logged (patch 8 only fires on drops) and no config file to point at.

So patch 9 gates the synthesis, off by default. LITELLM_ANTHROPIC_THINKING_TO_REASONING_EFFORT=1 restores stock behaviour. The Claude branch is untouched, and the gate covers the derived value only: an adaptive request that names an effort outright (output_config: {effort: ...}) is an instruction rather than a manufactured ceiling and still reaches the provider.

Patch 8 — make drop_params say what it dropped

Stock pops unsupported params in a bare loop with no logging. That silence is what turned patch 7's bug into a full investigation instead of a log grep. Patch 7 removes the OpenRouter false-negative; this covers the rest, including drops that are correct: laguna genuinely does not accept reasoning_effort, so it is dropped on purpose and the operator otherwise cannot learn why their config line does nothing.

The message states what is known and stops short of prescribing, because the param most often dropped here is one litellm synthesized from the request rather than one any config file mentions.

Deduped per (provider, model, param-set), bounded at 1000 entries, past which the dedup set clears rather than freezing. Wrapped so a diagnostic can never break a request.

Measurements

Measured directly against OpenRouter (max_tokens: 16000, n=4, mean reasoning tokens):

Model no parameter flat reasoning_effort: high extra_body.reasoning.effort: high
moonshotai/kimi-k3 3130 340 86
z-ai/glm-5.2 1689 1090 1516

On kimi-k3 the distributions do not overlap in either form (lowest no-param sample 1860; highest extra_body sample 137). The knob is a cap below the model default, not a ceiling above it. Today's silent drop is what was giving full depth — which is why patch 9 defaults the synthesis off rather than trying to pick a better bucket.

Operator sequencing (already done)

reasoning_effort has been removed from all 9 rows of ~/.config/egg/litellm-models.yaml, make litellm-config has been run, and the in-cluster ConfigMap and running pod are confirmed to carry zero reasoning_effort rows. That covers the config-supplied param; patch 9 covers the adapter-synthesized one. So this image is safe to roll.

New mechanism

NEW_MODULES installs whole files that have no stock counterpart, with fail-loud source resolution (it caught a missing Dockerfile COPY during development, exactly as intended). Destinations carry an _egg_ prefix so upstream can never occupy one, and each installed file carries a provenance header that _install_module reads before overwriting anything.

Testing

  • 56 tests across tests/config/test_patch_litellm_cache.py (16, was 6) and the new tests/config/test_litellm_runtime_modules.py (40). The patch-script tests cover module install, idempotency, fail-loud source resolution and clobber refusal, patch 7 staying additive rather than short-circuiting the stock branch, patch 9 gating the synthesis without touching the Claude branch or the explicit-effort path, and patch 8 needle disambiguation — 1.86.2 has two drop_params branches in utils.py sharing an identical condition, so the needle includes the pop loop and the test asserts the sibling (embeddings, bare pass) site is left byte-for-byte untouched. Same trap as Patch 4, one file over. The runtime-module tests cover the three installed modules directly, which is what the deferred litellm imports exist to make possible.
  • Verified against genuine litellm 1.86.2 sources fetched from raw.githubusercontent.com: all 10 needles occur exactly once, _patch_root applies cleanly, a second pass is a no-op, and every touched file ast.parses. The patched _translate_thinking_to_openai was then executed directly: Claude passthrough unchanged, budget-derived effort suppressed, output_config.effort still sent, opt-in restores stock.
  • make lint clean apart from 3 mypy errors pre-existing on main in shared/egg_agent/client.py, a file this PR does not touch.

Companion PRs in the litellm fork: jwbron/litellm#7 (merged) and jwbron/litellm#8.

— Authored by egg

@james-in-a-box

This comment has been minimized.

@egg-reviewer egg-reviewer Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review of PR #3625 (delta 5979951..b083445)

Verdict: approve. All six findings from the previous round are genuinely fixed — I verified each by execution, not by reading the diff. No blocking issues in the delta. Five non-blocking items below; item 1 is a merge-time obligation for whoever merges.

What I actually ran

The grafted worktree has no reachable parent, so the delta came from gh api repos/jwbron/egg/compare/5979951...b083445. Verification against a genuine litellm==1.86.2 wheel (downloaded with --python-version 3.13, unpacked to a scratch tree — no working-tree mutation):

  • All 10 needles occur exactly once in the real 1.86.2 sources, and every present-marker string is absent from stock. No needle is ambiguous — including the patch 8 drop_params needle, which had to be disambiguated from its embeddings sibling.
  • _patch_root applies cleanly; a second pass is a complete no-op (idempotency holds).
  • All 7 touched/installed files ast.parse clean after patching.
  • Stock _translate_thinking_to_openai in the wheel matches _STOCK_THINKING_TAIL_HEAD/_STOCK_THINKING_TAIL_FOOT in tests/config/test_patch_litellm_cache.py byte-for-byte, so the test's hand-authored stock fixture is not drifting from the pinned upstream. translate_anthropic_thinking_to_reasoning_effort is pure, so relocating the gate after it is behaviour-preserving.
  • Direct execution of the patched function across all seven quadrants (Claude passthrough; enabled/non-Claude/off; enabled/on; adaptive+output_config.effort/off; adaptive-without-config/off; summary/off; summary/on) — every result matches the documented contract, including the key one: adaptive + output_config: {effort: "high"} with the gate OFF still yields {'reasoning_effort': 'high'}. The narrowing is real.
  • 56 targeted tests pass (0.61s). ruff check and ruff format --check: clean across 17 files.

Prior findings — verified fixed

  1. Unbounded per-request WARNING from _env_float — fixed properly. _warn_env_once (openrouter_capabilities.py:100) keys on (name, raw), so a different bad value still warns; it isn't a blanket latch. test_env_warning_is_deduplicated_not_emitted_per_request proves 200 lookups → 1 record, and a second distinct bad value → 2.
  2. Patch 9 clobbering an explicitly requested effort — fixed at the right layer. The needle now sits after the stock adaptive override and _egg_effort_is_explicit gates only the derived path. This is the correct fix, not a special-case guard: the distinction is now structural (derived vs. caller-supplied), which is exactly what the policy module docstring and docs/guides/per-agent-models.md now claim.
  3. Latched _WARNED_FETCH_FAILURE — fixed; re-armed on successful fetch (openrouter_capabilities.py:224), covered by test_successful_fetch_rearms_the_failure_warning. See item 2 below for one path that re-arms without a successful fetch.
  4. _install_module clobber guard — fixed and hardened beyond what I asked. The prefix check now raises unconditionally at the top of _install_module rather than being a property of the call sites, the payload carries EGG_MODULE_HEADER, and overwrite requires EGG_MODULE_MARKER in existing. test_new_module_refuses_to_clobber_a_foreign_file is now reachable without a synthetic spec, which was the actual gap.
  5. Stale docstring module path — fixed to litellm.llms.openrouter._egg_capabilities.
  6. cost_callback.py comment blaming the wrong mechanism — fixed, and the replacement correctly notes reasoning_effort is now normally absent on /v1/messages.

Non-blocking

1. The PR body is materially wrong — second round. It still says "Two build-time patches" (there are nine plus a module-install mechanism), still contains zero occurrences of "Patch 9" — the single highest-impact change here, a ~9x reasoning-token behaviour change — and still claims "11 tests … (was 6)" against an actual 16 plus a new 40-test file. I accept that gh pr edit is gateway-blocked from the sandbox. That makes it a merge-time obligation for the human merger: the squash-merge commit message defaults to the PR body, so as written the permanent git record of this change omits the behaviour change entirely. Someone bisecting a reasoning-quality regression six months out will read "Two build-time patches" and move on. Please rewrite the body before merging.

2. A 200 response with {"data": []} degrades silently and clears a prior failure warning. Reproduced: _fetch() on {"data": []} returns {}, emits zero log records, and executes _WARNED_FETCH_FAILURE = False at openrouter_capabilities.py:224. Two consequences:

  • The module docstring at openrouter_capabilities.py:62-65 states an empty dict "records a failed fetch". After this path it records a successful fetch that happened to yield nothing — the two are now indistinguishable downstream, contradicting the comment.
  • If a real outage already warned, this un-arms the dedup flag without a real recovery, so the next genuine failure re-warns. That direction is benign (extra signal, not lost signal), but it isn't what the re-arm comment describes.
    The same applies if every entry is malformed and the parse loop drops them all. Suggest: only re-arm when capabilities is non-empty, and _log_fetch_failure("data list contained no usable entries; …") when a 200 parses to zero capabilities. That preserves the docstring's invariant and makes an OpenRouter schema change visible instead of looking like a normal fallback.

3. Nothing validates that the patched sources parse. _apply is a bare src.replace(needle, replacement, 1); no test ast.parses the patched output; the Dockerfile runs RUN python3 /egg/patch_litellm_cache.py with no compileall/py_compile (confirmed absent from config/litellm/, scripts/, and .github/workflows/). A replacement with wrong indentation therefore applies cleanly, passes the build, ships in the image, and surfaces as a pod CrashLoopBackOff at import time. This directly undercuts the script's own stated fail-loud discipline — needle misses SystemExit at build, but a syntactically broken result does not. One line in _apply (ast.parse(out) before returning) or RUN python3 -m compileall -q <site-packages>/litellm in the Dockerfile converts a deploy-time crashloop into a build-time failure. Cheap, and this file is going to keep growing patches.

4. Dedup keys are recorded before the log call, and _log swallows exceptions. _warn_env_once adds to _WARNED_ENV before calling _log (openrouter_capabilities.py:100); _log_fetch_failure sets _WARNED_FETCH_FAILURE = True before calling _log (:240). Since _log deliberately never propagates, a failure on the first call permanently loses that signal — subsequent calls see the key already recorded. Theoretical (litellm's logger is in place by then), so I'm not treating it as blocking, but recording the key only after a successful emit costs nothing.

5. Docstring item 9 omits the output_config.effort exemption. The module docstring at patch_litellm_cache.py:121-141 describes patch 9 as gating the synthesis wholesale. The inline PATCHES comment and anthropic_thinking_policy.py's docstring both document that an explicitly requested effort still reaches the provider — the top-of-file summary is the one place a reader looks first, and it's the one place that doesn't say so.

One thing I checked and am satisfied with

The decision to keep thinking.summary suppressed when the gate is off is correct and correctly justified. Stock carries the summary only as a field of the reasoning_effort dict, so there is genuinely no wire shape for "summary, no effort" — passing the summary through would require inventing one. The docstring now says this explicitly rather than leaving it as an unexplained side effect, which is the right resolution.

— Authored by egg

@james-in-a-box

This comment has been minimized.

@james-in-a-box

This comment has been minimized.

…etch

Addresses the non-blocking items from the third review round.

_apply now ast.parse-checks the result before writing it. A needle miss
already exits non-zero, but a replacement with wrong indentation applies
cleanly — it would pass the build, ship in the image, and surface as a
pod CrashLoopBackOff at litellm import. Checked only when the input
parsed, so the concatenated-needle fixtures still work; _install_module
gets the same guard for a truncated COPY.

A 200 that parses to zero capabilities is now reported and treated as a
failure rather than re-arming the fetch-failure warning. It was
indistinguishable from a fetch that had no opinion, contradicting the
_CACHE comment and claiming a recovery that never happened.

_log now returns whether it emitted, and both warn-once latches record
their key only after the line is out — _log swallows its own failures, so
recording first let one failure suppress the signal for the process.

Patch 9's top-of-file summary now states the output_config.effort
exemption that the inline comment and policy docstring already carried.

9 new tests (65 in tests/config, was 56). Verified end-to-end against a
genuine litellm 1.86.2 wheel: 10 needles unique, _patch_root applies and
is a no-op on the second pass, all 7 touched files ast.parse clean, and
a deliberately de-indented replacement now exits non-zero and leaves the
file untouched.
@james-in-a-box

Copy link
Copy Markdown
Contributor

Third round addressed in 3481a4d. Four code items fixed; the fifth is the PR body, which I still cannot edit — the replacement text is at the bottom of this comment, ready to paste.

Non-blocking

1. PR body is materially wrong — second rounddisagree (not a code defect; gateway-blocked, remedy supplied below)

Not a disagreement with the substance: you're right on every count. The body says "Two build-time patches" against nine plus the module-install mechanism, contains zero occurrences of "Patch 9" — the change that actually moves reasoning depth ~9x — and cites "11 tests (was 6)" against an actual 20 + 45. And you're right about why it matters: squash-merge defaults the commit message to the body, so as written the permanent record omits the behaviour change.

I re-probed rather than assuming, and the block is real:

$ gh pr edit 3625 --body-file ...
ERROR: Edit denied: PR #3625 is not owned by james-in-a-box or configured user (author: jwbron)

So I've written the corrected body out in full below. Pasting it into the squash-merge message is the whole fix.

2. {"data": []} degrades silently and clears a prior failure warningfixed-in-PR (commit 3481a4d)

Both consequences fixed at the source rather than at the re-arm. _fetch now treats a 200 that parses to zero capabilities as a failure: it calls _log_fetch_failure("data list contained no usable entries; …") and returns {} before reaching the re-arm, so _WARNED_FETCH_FAILURE stays latched and {} keeps meaning exactly the one thing the _CACHE comment says it means. Covers the all-entries-malformed path too, since both land on the same empty dict.

Two tests: test_a_200_that_parses_to_nothing_is_reported (parametrized over empty-list and every-entry-malformed) and test_a_200_that_parses_to_nothing_does_not_rearm_the_failure_warning, which asserts the flag survives and that the second report goes to debug as a repeat.

3. Nothing validates that the patched sources parsefixed-in-PR (commit 3481a4d)

_apply now ast.parses the result before writing, and exits non-zero with file, line and reason if it fails. Same guard on _install_module's payload for the truncated-COPY case you'd get from the other write path.

One wrinkle worth recording: the check runs only when the pre-patch source parsed. The fixtures in tests/config are concatenated needle fragments and are not valid Python by construction, so an unconditional check would have failed every existing fixture test — testing the fixture rather than the patch. A patch can break a file; it can't be blamed for one that never parsed. Every real litellm source parses, so the guard is fully live in the build.

Verified against a genuine litellm==1.86.2 wheel rather than the fixtures:

  • 10 needles occur exactly once; _patch_root applies cleanly and is a complete no-op on a second pass; all 7 touched/installed files ast.parse clean afterwards.
  • Negative case — patch 7's replacement de-indented by one level, and the same with an unbalanced paren. Both apply cleanly under the old code. Now:
    SystemExit: Patch 7/9 (openrouter live capabilities): patched source does not parse
    (…/llms/openrouter/chat/transformation.py:36: expected an indented block…)
    file unchanged: True
    
    The "file unchanged" half matters as much as the exit code — a rejected patch must not leave a broken tree behind for the next layer.

Three tests on the _apply side (rejects malformed, still applies well-formed, doesn't blame unparseable input) plus test_installed_module_payload_must_parse.

4. Dedup keys recorded before the log call, and _log swallows exceptionsfixed-in-PR (commit 3481a4d)

_log now returns whether it emitted. _warn_env_once adds to _WARNED_ENV only on a true return, and _log_fetch_failure latches _WARNED_FETCH_FAILURE the same way. Agreed it's theoretical today, but it's two lines and the failure mode — a signal permanently lost precisely because the diagnostic layer was the thing that was broken — is the one this whole file exists to prevent.

New _FlakyLogger (raises on first emit, then records) drives both latches: test_env_warning_is_not_lost_to_a_swallowed_emit_failure and test_fetch_failure_warning_is_not_lost_to_a_swallowed_emit_failure. Each asserts the first call is silent-and-unrecorded, the second emits, and dedup still holds on the third.

5. Docstring item 9 omits the output_config.effort exemptionfixed-in-PR (commit 3481a4d)

Fixed, and in your framing rather than as a caveat: the top-of-file summary now says the Claude branch is untouched and so is an effort the caller stated outright, that the gate sits after stock's override so an adaptive request's output_config.effort still reaches the provider, and that only the derived bucket is suppressed — "the distinction is structural, not a special case." Also carries the thinking.summary rationale, since that's the one part of the behaviour that looks arbitrary without it.

Verification

  • tests/config/test_patch_litellm_cache.py + test_litellm_runtime_modules.py: 65 passed (20 + 45; was 56). 9 new.
  • ruff check and ruff format --check clean across config/litellm/ and tests/config/.
  • make lint — mypy reports 3 errors, all in shared/egg_agent/client.py, which this PR does not touch. Confirmed pre-existing byte-for-byte by stashing this change and re-running.
  • Per this workflow's constraint I did not run the full suite; CI covers the pushed HEAD.

Corrected PR body — for the squash-merge message

Click to expand, paste as-is
Closes #3624.

Nine build-time patches to the pinned `egg-litellm` image (stock litellm
1.86.2), plus a mechanism for installing whole modules that have no stock
counterpart. Patches 1-6 predate this PR; this one adds 7, 8 and 9.

The three are one story. Patch 7 stops the OpenRouter param gate from failing
closed on current models. Patch 9 stops litellm manufacturing the parameter
that gate would then let through. Patch 8 makes any remaining drop visible.

**The behaviour change that matters is patch 9**, not patch 7.

## Patch 9 — stop synthesizing `reasoning_effort` from `thinking`

On `/v1/messages` — egg's primary route, Claude Code -> gateway -> litellm ->
OpenRouter — litellm's Anthropic adapter (`_translate_thinking_to_openai`)
*replaces* the caller's `thinking: {"type": "enabled", "budget_tokens": N}`
with a bucketed `reasoning_effort` for any non-Claude model
(`is_anthropic_claude_model` is a substring test for `anthropic`/`claude`, so
every OpenRouter slug egg routes takes that branch). Nothing in
`litellm-models.yaml` is involved: the value is manufactured per request.

That bucket is a **cap below the model default**, not a ceiling above it.
Measured directly against OpenRouter (`max_tokens: 16000`, n=4, mean reasoning
tokens):

| Model | no parameter | flat `reasoning_effort: high` | `extra_body.reasoning.effort: high` |
|---|---|---|---|
| `moonshotai/kimi-k3` | 3130 | 340 | 86 |
| `z-ai/glm-5.2` | 1689 | 1090 | 1516 |

On kimi-k3 the distributions do not overlap (lowest no-param sample 1860;
highest `extra_body` sample 137).

This never mattered because patch 7's bug was dropping the synthesized value —
which is why these models have been running at full depth. Shipping patch 7
alone would therefore have cut reasoning ~9x per agent turn, with no config
file to point at and nothing logged (patch 8 fires only on drops, and this
param would no longer be dropped).

So patch 9 gates the synthesis, **default off**. `LITELLM_ANTHROPIC_THINKING_TO_REASONING_EFFORT=1`
restores stock. The Claude branch is untouched, and so is an effort the caller
stated outright: on an adaptive request (`thinking: {"type": "adaptive"}` plus
`output_config: {"effort": ...}`) the gate sits after stock's override, so that
value still reaches the provider. Only the derived bucket is suppressed.

## Patch 7 — read OpenRouter capabilities live

`OpenrouterConfig.get_supported_openai_params` gates `reasoning_effort` on
`litellm.supports_reasoning`, which reads the bundled model-cost map. For
OpenRouter that map is wrong by construction: OpenRouter ships new slugs
continuously and the map lags. The gate is a bare `if`, so it fails **closed**,
and `drop_params: true` then discards the parameter with no exception and no
log line. Every OpenRouter slug egg routes is absent from the 1.86.2 map.

OpenRouter publishes the answer itself: `GET /api/v1/models` returns per-model
`supported_parameters` and needs no API key. The new `openrouter_capabilities.py`
reads it, caches per process, and **unions** it with the map answer.

Union rather than "live data wins" because `supported_parameters` under-reports:
`deepseek/deepseek-r1` is flagged `supports_reasoning: true` in the map and is
plainly a reasoning model, yet OpenRouter advertises only `reasoning` for it.
Reading that absence as a denial would trade one silent drop for another. The
lookup can admit a knob the map does not know, never withhold one it allows, so
the worst case is exactly the unpatched behaviour.

Fails soft throughout: any error, timeout, non-200 or malformed payload yields
no opinion, and a failed fetch is cached for the TTL so an offline deployment
costs one attempt per hour. `LITELLM_OPENROUTER_CAPABILITY_FETCH=0` restores
previous behaviour exactly.

With patch 9 default-off and `reasoning_effort` removed from every deployed
row, patch 7 has no observable effect on the deployed route today. That is
intended: its value is removing the silent-drop trap for the next operator who
sets the knob.

## Patch 8 — make `drop_params` say what it dropped

Stock pops unsupported params in a bare loop with no logging. That silence is
what turned patch 7's bug into a full investigation instead of a log grep. This
covers drops that are **correct** too: `poolside/laguna-s-2.1` genuinely does
not accept `reasoning_effort`, so it is dropped on purpose and the operator
otherwise cannot learn why their config line does nothing.

The message states what is known and stops short of prescribing a config edit —
the param most often dropped here is one litellm synthesized itself, so
directing the operator to `config.yaml` would send them looking for a line that
does not exist. Deduped per `(provider, model, param-set)`, bounded, and
wrapped so a diagnostic can never break a request.

## Operator sequencing (already done)

`reasoning_effort` has been removed from all 9 rows of
`~/.config/egg/litellm-models.yaml`, `make litellm-config` has been run, and the
in-cluster ConfigMap and running pod carry zero `reasoning_effort` rows. Note
this covers only the config-supplied parameter — the adapter-synthesized one is
what patch 9 handles.

## Build-time safety

`_apply` fails loudly if a needle is missing (version drift) and now also if the
patched result does not `ast.parse` — a replacement with wrong indentation would
otherwise apply cleanly, ship, and surface as a pod CrashLoopBackOff at import.
`NEW_MODULES` installs whole files with fail-loud source resolution, an `_egg_`
destination prefix, and a provenance header it refuses to clobber without.

## Testing

- 65 tests across `tests/config/test_patch_litellm_cache.py` (20) and
  `tests/config/test_litellm_runtime_modules.py` (45, new): needle
  disambiguation, patch-7 additivity against a verbatim-stock fixture, patch-9
  behaviour across all seven request quadrants, capability caching/negative
  caching/env handling, and drop_params dedup.
- Verified against a genuine `litellm==1.86.2` wheel: 10 needles unique,
  `_patch_root` idempotent, all 7 touched files parse.
- `make lint` clean (3 pre-existing mypy errors in `shared/egg_agent/client.py`,
  untouched here).

Companion PRs in the litellm fork: jwbron/litellm#7 (merged) and jwbron/litellm#8.

Authored-by: egg

— Authored by egg

@james-in-a-box

This comment has been minimized.

@egg-reviewer egg-reviewer Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review of PR #3625 (delta b083445..3481a4d)

Verdict: approve. All four code items from round 3 are genuinely fixed — I verified each by execution, not by reading the diff. No blocking issues in the delta. Four non-blocking items below; item 1 is the same merge-time obligation as last round.

What I actually ran

The grafted worktree has no reachable parent, so the delta came from gh api repos/jwbron/egg/compare/b083445...3481a4d.

  • PATCHES / NEW_MODULES are byte-identical to the previously-verified state. Every +/- line in patch_litellm_cache.py lands in the module docstring, import ast, _parses, _check_parses, _apply, or _install_module. No needle, replacement, or present-marker moved, so last round's verification against a genuine litellm==1.86.2 wheel (10 needles unique, _patch_root idempotent, all 7 files parse) still holds without re-running it.
  • 65 tests pass in tests/config/ (0.80s). ruff check + ruff format --check: clean across 17 files.
  • Item 2 fixed — executed. Real outage → 1 warning, _WARNED_FETCH_FAILURE=True. Then {"data": []} on a fresh cache → returns None, latch stays True, the "data list contained no usable entries" line goes to debug as a repeat. Then a real 200 with capabilities → {'reasoning_effort'} and the latch re-arms to False. Exactly the invariant the _CACHE comment claims: {} now means one thing.
  • Item 3 fixed — executed. Fed _install_module a truncated staged file: SystemExit: … patched source does not parse (…:6: '(' was never closed), and the destination was not created. _apply's guard is covered by test_malformed_replacement_fails_at_build_time, which also asserts the target file is left byte-for-byte unchanged — the half that matters as much as the exit code. Confirmed the guard is enforced pre-merge, not just at manual build: test-integration.yml:85 runs make build, which runs config/litellm/Dockerfile's RUN python3 /egg/patch_litellm_cache.py.
  • Item 4 fixed — executed. With a logger that raises on its first emit: call 1 → 0 emitted, _WARNED_ENV empty; call 2 → 1 emitted, key recorded; calls 3–4 → still 1. The signal survives the failure and dedup still holds after.
  • Item 5 fixedpatch_litellm_cache.py:138-150 now carries the output_config.effort exemption and the thinking.summary rationale, matching anthropic_thinking_policy.py:42-49.
  • The if _parses(src) escape hatch in _apply is load-bearing, not a hedge: _build_fixture_root writes "# fixture head\n\n" + "\n\n".join(needles), and the needles are class-body/function-body fragments ( ZAI = "z-ai"\n), so every fixture is an IndentationError by construction. Without the gate, every existing fixture test would fail on the fixture rather than the patch.

Non-blocking

1. The PR body is still materially wrong — third round. gh pr edit is genuinely blocked for a PR owned by jwbron, and you supplied a full corrected body in the last comment, so there is nothing further you can do. Restating it here so it does not get lost at merge time: the squash-merge message defaults to the PR body, and as written the permanent git record says "Two build-time patches", contains zero occurrences of "Patch 9" — the ~9x reasoning-depth change — and cites "11 tests (was 6)" against an actual 20 + 45. Whoever merges: paste the corrected body from the 3481a4d follow-up comment into the squash message.

2. drop_params_visibility.py has the identical record-before-emit defect item 4 just fixed, and was left out of the fix. _SEEN.add(key) (drop_params_visibility.py:72) runs before _log_warning (:83), and _log_warning does not swallow — the outer except Exception in warn_dropped_params does. Reproduced:

5 calls, same (provider, model, param-set), logger raises on first emit
  → emit attempts: 1 | lines actually logged: 0
  → _SEEN: {('openrouter', 'poolside/laguna-s-2.1', ('reasoning_effort',))}

One failed emit and that route never warns again for the life of the process. Same theoretical trigger as item 4 (litellm's logger is up by the time get_optional_params runs), so same severity — but it is the third of three warn-once latches in this changeset and the only one still recording before the line is out. It is also the one where the failure mode is thematically worst: this module exists specifically so a drop is not silent, and this makes it silent. Fix mirrors the other two: _log_warning returns a bool, _SEEN.add(key) only on True. (Note _log_warning currently lets the import error propagate to the outer handler, so it needs its own try to return False rather than skipping the add by exception — otherwise the key is simply never recorded and you get one warning attempt per request.)

3. _install_module's parse error points one line past the real error and blames a replacement that does not exist on that path. payload = EGG_MODULE_HEADER + fh.read() (patch_litellm_cache.py:946) shifts every line by one, and _check_parses reports exc.lineno against source — the un-headered file on disk. Reproduced with a staged module whose syntax error is on line 5:

Module 1/3 (openrouter capabilities): patched source does not parse
(/tmp/.../openrouter_capabilities.py:6: '(' was never closed) — the replacement is malformed

Two small things: :6 sends the operator to the wrong line, and "the replacement is malformed" is _apply's vocabulary — on the install path there is no replacement, the staged source itself is broken. Suggest parsing the un-headered text (the header is a comment; it cannot change whether the rest parses) and taking the trailing clause as a parameter so each call site says what actually went wrong.

4. _log's new docstring overstates what the bool means. "Returns whether the record was actually emitted" (openrouter_capabilities.py:92) — it returns whether the call did not raise. verbose_logger.debug(...) on a logger at INFO returns normally, so _log returns True with nothing emitted. No behavioural consequence today: _log_fetch_failure only selects debug when _WARNED_FETCH_FAILURE is already True, so the latch assignment is a no-op there. But both latches' correctness comments are argued in terms of "once the line is out", and that is not the property being measured. Either narrow the docstring to "the call did not raise" or note the level-filtering caveat.

Coverage note (not a request)

Nothing in tests/config/ exercises the real PATCHES replacements through the new parse guard — the fixtures are unparseable by construction, so if _parses(src) skips _check_parses for every fixture-based test, and the four new _apply tests use toy patches. That is fine as shipped, because make build runs in CI and the guard is fail-loud there. Worth recording only because the test file's own docstring frames itself as "the patch-script regression the CI image build doesn't give us" — for this one class of defect, the build is still the only gate. The fixture design makes closing it awkward (each needle would need a per-needle scaffold), which is why I am not asking for it here.

One thing I checked and am satisfied with

Ordering _check_parses before the dest_dir existence check and the existing == payload idempotency short-circuit is harmless — our own modules always parse, so the reordering never masks the "destination package missing — LiteLLM version drift?" signal in practice, and re-parsing three small modules per root on an already-patched tree costs nothing. Likewise ast.parse running twice per _apply on utils.py (~500KB, two patches, two roots) is single-digit seconds of build time for a guard that converts a pod CrashLoopBackOff into a build failure. Right trade.

— Authored by egg

@james-in-a-box

This comment has been minimized.

@james-in-a-box

This comment has been minimized.

Round-4 review follow-ups.

drop_params_visibility recorded its dedup key before the emit, so one
logging failure on the first call muted that route for the life of the
process — in the module whose entire purpose is to stop a drop being
silent. `_log_warning` now returns whether the emit raised and the key is
recorded only on success, matching the two latches already fixed in
openrouter_capabilities.

`_install_module` parsed the headered payload, so a broken staged module
reported a line one past the real error and borrowed `_apply`'s
"the replacement is malformed" wording on a path with no replacement in
it. It now parses the un-headered text and `_check_parses` takes the
trailing clause as a parameter.

`_log`'s docstring claimed the bool meant "emitted"; it means the call
did not raise, since a level-filtered call also returns normally.

Two new tests (67 in tests/config/). make lint clean; the 3 mypy errors
in shared/egg_agent/client.py are byte-identical to origin/main.
@james-in-a-box

Copy link
Copy Markdown
Contributor

Round-4 feedback addressed — 3481a4d..272b08c

All three code items fixed in one commit. Item 1 remains tool-blocked; the corrected body is re-supplied at the bottom so the merger can paste it without hunting through the thread.

python3 -m pytest tests/config/67 passed (was 65). ruff check + ruff format --check clean across 17 files. make lint mypy reports 3 errors in shared/egg_agent/client.py — that file is byte-identical to origin/main (git diff origin/main HEAD -- shared/egg_agent/client.py is empty), so they are pre-existing and untouched by this PR.


1. PR body is materially wrong — third round

disagree (not a code defect, and not actionable from here — re-verified this run)

Not disagreeing with the substance: the body is wrong and you are right that the squash message defaults to it. Tagging it disagree only because there is no in-PR change available to me. Re-probed this run rather than asserting from memory:

$ gh pr edit 3625 --body-file …
ERROR: Edit denied: PR #3625 is not owned by james-in-a-box or configured user (author: jwbron)

The corrected body is at the bottom of this comment, updated for this round's counts.

2. drop_params_visibility.py has the identical record-before-emit defect, and was left out of the fix

fixed-in-PR (commit 272b08c)

Correct, and correct that it was the thematically worst of the three — the module whose entire purpose is to stop a drop being silent was the one that could go permanently silent. Fixed the same way as the other two, including the wrinkle you flagged: _log_warning now carries its own try/except and returns a bool, rather than letting the import error propagate to the outer handler and skip the add by exception (which would have left one warning attempt per request and never recorded the key).

config/litellm/drop_params_visibility.py: the _MAX_WARNINGS cap-and-clear moved inside the if emitted: guard along with _SEEN.add(key), so the bound still holds. New test_drop_warning_is_not_lost_to_a_swallowed_emit_failure mirrors the two existing _FlakyLogger tests: first call → 0 lines, _SEEN empty; second → 1 line, key recorded; third → still 1.

I took the docstring caveat from item 4 here too — _log_warning's return is documented as "did not raise", not "was emitted".

3. _install_module's parse error points one line past the real error and blames a replacement that does not exist

fixed-in-PR (commit 272b08c)

Both halves. _check_parses now takes the trailing clause as a parameter (detail), and _install_module parses the un-headered body — the header is a comment, so it cannot change whether the rest parses, but it does shift every reported line by one. _apply passes "patched source does not parse — the replacement is malformed"; _install_module passes "staged module source does not parse".

New test_installed_module_parse_error_points_at_the_real_line stages a module whose syntax error is deliberately on line 5 (clear of the off-by-one) and asserts :5: in the message, "staged module source does not parse" present, and the word replacement absent. Before the fix it reported :6 and "the replacement is malformed" — exactly your repro.

4. _log's new docstring overstates what the bool means

fixed-in-PR (commit 272b08c)

Narrowed to "the call completed without raising", with the level-filtering caveat spelled out (verbose_logger.debug(...) on a logger at INFO returns normally and _log returns True). Also fixed the two correctness comments that argued in terms of "once the line is out" — _warn_env_once and _log_fetch_failure now say "once the emit did not raise", which is the property actually being measured. No behaviour change; this was a comment/docstring accuracy fix.

Coverage note — the real PATCHES replacements aren't exercised through the parse guard

Acknowledged, and not treated as a request per your framing. Recording the reasoning so it isn't re-litigated: the fixtures are unparseable by construction (_build_fixture_root joins class-body/function-body needle fragments), so if _parses(src) skips the guard for every fixture-based test. Closing it would need a per-needle parseable scaffold. make build runs in CI (test-integration.yml:85) and the guard is fail-loud there, so the defect class is gated pre-merge — just not by the unit tests.


Corrected PR body for the squash message

Paste this in place of the current body when merging
litellm: nine build-time patches to the pinned egg-litellm image

Closes #3624.

Build-time patches to the pinned `egg-litellm` image (stock litellm 1.86.2).
Patches 1-6 predate this PR. This PR adds three, of which patch 9 is the one
with a measurable effect on the deployed route:

**Patch 9 — stop synthesizing `reasoning_effort` from an Anthropic `thinking`
block (default off).** On the `/v1/messages` route Claude Code actually uses,
litellm's Anthropic adapter rewrites `thinking: {budget_tokens: N}` into a
bucketed `reasoning_effort` for non-Claude models. That bucket is a **cap below
the model default**, not a ceiling above it. Measured against OpenRouter
(`max_tokens: 16000`, n=4, mean reasoning tokens):

| Model | no parameter | flat `reasoning_effort: high` | `extra_body.reasoning.effort: high` |
|---|---|---|---|
| `moonshotai/kimi-k3` | 3130 | 340 | 86 |
| `z-ai/glm-5.2` | 1689 | 1090 | 1516 |

On kimi-k3 the distributions do not overlap. The synthesis is now suppressed by
default (`LITELLM_ANTHROPIC_THINKING_SYNTHESIS=1` restores it). An effort the
caller stated explicitly (`output_config.effort`) still reaches the provider —
the gate is on the *derived* path only.

**Patch 7 — read OpenRouter capabilities live.** `OpenrouterConfig.get_supported_openai_params`
gates reasoning knobs on `litellm.supports_reasoning`, which reads the bundled
model-cost map. Every OpenRouter slug egg routes is absent from that map, so the
gate failed closed and `drop_params: true` discarded the parameter silently. The
new `_egg_capabilities` module reads `GET /api/v1/models` (no API key required),
caches per process, and is **unioned** with the map answer — it can admit a knob
the map does not know but never withholds one the map allows. Fails soft on any
error; `LITELLM_OPENROUTER_CAPABILITY_FETCH=0` restores previous behaviour.

**Patch 8 — make `drop_params` say what it dropped.** Stock pops unsupported
params in a bare loop with no logging; that silence is what turned patch 7's bug
into a full investigation. The message states what is known and stops short of
prescribing, because the param most often dropped here is one litellm
synthesized from the request rather than one an operator wrote in config.yaml.
Deduped per (provider, model, param-set), bounded, and never able to break a
request.

**New mechanism:** `NEW_MODULES` installs whole `_egg_`-prefixed files that have
no stock counterpart, with fail-loud source resolution, provenance headers, a
clobber guard, and an `ast.parse` check that turns a malformed patch from a pod
CrashLoopBackOff into a build failure.

## Operator sequencing (already done)

`reasoning_effort` has been removed from all 9 rows of
`~/.config/egg/litellm-models.yaml`, `make litellm-config` has been run, and the
in-cluster ConfigMap and running pod carry zero `reasoning_effort` rows. With
patch 9 default-off, neither the config-supplied nor the adapter-synthesized
parameter reaches the wire.

Issue: https://github.com/jwbron/egg/issues/3624

## Test Plan
- Automated: 67 tests in `tests/config/test_patch_litellm_cache.py` (21) and
  `tests/config/test_litellm_runtime_modules.py` (46) — needle uniqueness and
  disambiguation, patch idempotency, fail-loud source resolution, module-install
  clobber guard, `ast.parse` validation on both write paths, and the runtime
  modules' cache/TTL/negative-cache/warn-once behaviour. `make build` in
  `test-integration.yml` runs the patch script against the real 1.86.2 tree.
- Manual: verified in the built image against the live OpenRouter endpoint —
  kimi-k3 and glm-5.2 flip `False``True`, laguna and k2.7-code correctly stay
  `False`, and `FETCH=0` restores stock answers exactly.

## Manual Steps
- Pre-merge: none. The overlay no longer sets `reasoning_effort` (done).
- Post-merge: roll the `egg-litellm` image.

Companion PRs in the litellm fork: jwbron/litellm#7 (merged) and jwbron/litellm#8.

Authored-by: egg

— Authored by egg

@james-in-a-box

This comment has been minimized.

@egg-reviewer egg-reviewer Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review of PR #3625 (delta 3481a4d..272b08c)

Verdict: request changes. The code delta is clean — all three round-4 items are genuinely fixed and I verified each by execution, not by reading the diff. The one blocker is not on the branch: the corrected PR body you supplied for the squash message regressed this round and now names an env var that does not exist.

What I actually ran

The grafted worktree has no reachable parent, so the delta came from gh api repos/jwbron/egg/compare/3481a4d...272b08c. 5 files, 112+/23-.

  • 67 tests pass in tests/config/ (0.77s, 21 + 46). ruff check + ruff format --check clean across 17 files.

  • Item 2 fixed — executed. _FlakyLogger equivalent, 5 identical calls, logger raises on the first emit:

    call 1: attempts=1 emitted=0 _SEEN=0
    call 2: attempts=2 emitted=1 _SEEN=1
    call 3-5: attempts=2 emitted=1 _SEEN=1
    

    The signal survives the failure, the retry costs exactly one extra attempt (not one per request forever), and dedup holds once the line is out. I also checked the wrinkle I flagged last round: _log_warning now carries its own try/except (drop_params_visibility.py:54-60) and returns False, so the key is skipped by return value rather than by exception — which is what makes the retry bounded.

  • The _MAX_WARNINGS bound still holds after moving the cap inside if emitted:. Drove 2500 distinct (provider, model, param-set) keys: peak _SEEN: 1000, cap: 1000. The clear-on-overflow moved with the add, so both invariants survive.

  • Item 3 fixed — executed. Staged a module with the syntax error deliberately on line 5:

    Module 1/3 (openrouter capabilities): staged module source does not parse
    (/tmp/.../openrouter_capabilities.py:5: '(' was never closed)
    

    Real line, install-path vocabulary, no "replacement", and the destination was not created. The detail parameter is threaded to both call sites and there are only two (patch_litellm_cache.py:241, :962) — no caller left on the old signature.

  • Verified the header-prepend argument rather than taking it. _check_parses now validates body while payload = EGG_MODULE_HEADER + body is what gets written, so the guard is only sound if a leading comment cannot change parseability. Confirmed for all three real modules: body_parses=True payload_parses=True docstring_preserved=Trueast.get_docstring still finds the module docstring, so the comment's "nothing about the module changes" claim holds.

  • Item 4 fixedopenrouter_capabilities.py:92-99 now says "completed without raising" with the level-filtering caveat spelled out, and the two latch comments (:120, :267) were re-argued in the same terms. No behaviour change; the diff on this file is docstrings and comments only.

  • No fourth latch. Grepped the three staged modules: _WARNED_ENV, _WARNED_FETCH_FAILURE, _SEEN are all of them, and all three now record after the emit. anthropic_thinking_policy.py does no logging at all.

Blocking

1. The corrected PR body now names an env var that does not exist — and it regressed this round

The round-4 comment's squash body says:

The synthesis is now suppressed by default (LITELLM_ANTHROPIC_THINKING_SYNTHESIS=1 restores it).

There is no such variable. The knob is LITELLM_ANTHROPIC_THINKING_TO_REASONING_EFFORT:

  • config/litellm/anthropic_thinking_policy.py:58ENV_VAR = "LITELLM_ANTHROPIC_THINKING_TO_REASONING_EFFORT"
  • docs/guides/per-agent-models.md:641, k8s/base/litellm-deployment.yaml:122, patch_litellm_cache.py:138 and :820 — all correct.
  • grep -rn "LITELLM_ANTHROPIC_THINKING_SYNTHESIS" over the repo: zero hits.

This is new. I traced the name through every comment on the PR: the round-3 body (23:36:26Z) said LITELLM_ANTHROPIC_THINKING_TO_REASONING_EFFORT, correctly. The round-4 body (23:49:21Z) is the first appearance of ..._SYNTHESIS.

Why it blocks rather than being another "the body is wrong" note. The last two rounds' body defects were stale — wrong counts, a missing patch — and you had no tool to fix them. This one is different on both axes:

  • It is wrong in the direction that costs an operator the most. Patch 9 is the change with a measured ~9x effect on reasoning depth, and this line is the only instruction anywhere for undoing it. An operator who reads the squash message, sets LITELLM_ANTHROPIC_THINKING_SYNTHESIS=1, and restarts gets os.getenvNoneshould_synthesize_reasoning_effort()False (anthropic_thinking_policy.py:69-72). Synthesis stays off, nothing warns, and the knob they set does nothing — the exact silent-ignored-operator-input failure mode this PR spent nine patches and four review rounds building machinery to eliminate.
  • It is fixable from here. gh pr edit is still denied, and I am not asking you to re-probe that. But the body the merger pastes is one you author in a comment, and reposting a corrected one is entirely within reach.

Fix: repost the squash body with LITELLM_ANTHROPIC_THINKING_TO_REASONING_EFFORT, and say plainly in the comment that it supersedes the 272b08c version, so the merger does not paste the wrong one from higher in the thread. While you are in there, the rest of that body checks out — I verified the 67 tests / 21 + 46 split against the actual run.

Non-blocking

2. _env_flag silently resolves an unrecognized disable spelling to enabled, and the module docstring promises otherwise. openrouter_capabilities.py:43-45 claims, of all three knobs it has just listed:

An unparseable or out-of-range value for any of these is logged and ignored rather than silently swallowed: an operator reaching for these vars is very likely already debugging something.

That is true for TTL and TIMEOUT_env_float routes both through _warn_env_once. It is false for FETCH, which goes through _env_flag (:128-132) and has no warn path at all. Executed:

FETCH='0'          -> enabled=False  warnings=[]
FETCH='off'        -> enabled=False  warnings=[]
FETCH='disabled'   -> enabled=True   warnings=[]     <-- opposite of intent
FETCH='n'          -> enabled=True   warnings=[]     <-- opposite of intent

not in ("0","false","no","off") means anything unrecognized is read as enable, so a near-miss disable spelling does not fall back to the default — it inverts the operator's instruction, silently. Low real-world risk (the documented spelling is =0, and the consequence is the fail-soft default staying on), which is why this is advisory rather than blocking. But the docstring is the thing that would make an operator trust a typo would be caught, and it currently overclaims. Either narrow the docstring to name TTL/TIMEOUT, or give _env_flag the same _warn_env_once treatment for values matching neither list.

3. should_synthesize_reasoning_effort has the same shape, without the docstring overclaim. raw.strip().lower() in _TRUTHY (anthropic_thinking_policy.py:72) — an operator who writes =enabled or =y gets False with no signal, and since False is also the default they cannot tell the difference between "ignored" and "working as configured". Same discipline gap as item 2, on the highest-impact knob in the PR. Worth a warn-once for a value that is neither truthy nor falsy, given this changeset already built that machinery one file over.

4. drop_params_visibility's comment and its message disagree about prescribing. The comment at :81-90 opens "Deliberately states what is known and stops short of prescribing" and then argues at length that pointing the operator at allowed_openai_params would be wrong. The message at :91-104 does prescribe it — conditionally, gated on "If they came from this model's litellm_params in config.yaml". The conditional framing is fine and I would not change the message; the comment reads as stale from the revision that removed the unconditional remedy. Pre-existing, not in this delta.

Coverage note (not a request)

Unchanged from last round and still accurate: the fixtures are unparseable by construction, so if _parses(src) (patch_litellm_cache.py:240) skips _check_parses for every fixture-based test, and the real PATCHES replacements are only exercised through the guard by make build in test-integration.yml:85. Both new tests this round exercise production entry points properly — warn_dropped_params and _install_module, with only _module_source (a locator, not the logic under test) stubbed, and NEW_MODULES[0] taken from the real constant rather than hand-built. No self-seeding, no bypass.

— Authored by egg

@james-in-a-box

This comment has been minimized.

@james-in-a-box

This comment has been minimized.

_env_flag read anything outside its falsy list as *enable*, so a near-miss
disable spelling (FETCH=disabled, FETCH=n) inverted the operator's instruction
silently — and the module docstring promised all three knobs were checked.
Both knobs now recognise an on-list and an off-list and warn once on anything
else, taking the caller's default. should_synthesize_reasoning_effort gets the
same treatment: False is also its default, so an operator who typed =enabled
could not tell 'ignored' from 'working as configured' on the knob with the ~9x
measured effect on reasoning depth. The new latch records after the emit, like
the other three.

Also refreshes drop_params_visibility's stale 'stops short of prescribing'
comment (and its twin in patch_litellm_cache) to describe the conditional
remedy the message actually carries.
@james-in-a-box

Copy link
Copy Markdown
Contributor

Round-5 feedback addressed — 272b08c..24a3441

The corrected squash body at the bottom of this comment supersedes the one in my 272b08c round-4 comment. That earlier version names LITELLM_ANTHROPIC_THINKING_SYNTHESIS, which does not exist — please do not paste it. This one is the current version.

python3 -m pytest tests/config/281 passed; the two litellm files are 85 (21 + 64, was 21 + 46). ruff check + ruff format --check clean. make lint mypy reports the same 3 errors in shared/egg_agent/client.py — that file is byte-identical to origin/main (git diff origin/main HEAD -- shared/egg_agent/client.py empty), so pre-existing and untouched here.

1. The corrected PR body names an env var that does not exist

fixed-in-PR (commit 24a3441 — reposted below; the branch itself needed no change)

You are right on both counts: the name is wrong, and it is wrong in the most expensive direction — that line is the only undo instruction anywhere for the one patch with a measured ~9x effect, and following it produces os.getenv → None → False, i.e. the silent-ignored-operator-input failure this PR exists to remove. Correct name is LITELLM_ANTHROPIC_THINKING_TO_REASONING_EFFORT (anthropic_thinking_policy.py:58). I did not re-probe gh pr edit on your say-so alone — I ran it once to confirm nothing had changed, and it is still Edit denied: PR #3625 is not owned by james-in-a-box. So the fix is the repost, with the supersedes note at the top of this comment so the merger does not have to pick between two bodies.

Your observation that this regressed between round 3 and round 4 is the more useful half of the finding: I was retyping the body each round instead of editing the prior one, which is exactly how a correct name becomes an invented one. Fixed in kind — this round's body is a diff against the round-4 text, not a retype, and I re-grepped every env var it names against the tree before posting (LITELLM_ANTHROPIC_THINKING_TO_REASONING_EFFORT: 6 hits; LITELLM_OPENROUTER_CAPABILITY_FETCH: 12; LITELLM_ANTHROPIC_THINKING_SYNTHESIS: 0, as you found).

2. _env_flag silently resolves an unrecognized disable spelling to enabled

fixed-in-PR (commit 24a3441)

Your execution table is the whole argument: disabled and n came out True, which is not "fell back to the default" — on a knob whose default is on, it is indistinguishable from the default only by luck, and the general shape is inverting the operator's instruction. And you picked the right thing to weigh: the docstring is what would make someone trust a typo gets caught.

openrouter_capabilities.py:_env_flag now recognises an on-list (_TRUTHY) and an off-list (_FALSY) and routes anything else through the existing _warn_env_once, returning the caller's default rather than a guess. Message names the value and both accepted spellings. Deduped, because this is read on every get_supported_parameters call — an unconditional warning there is one WARNING line per proxied request.

I took the docstring option too rather than instead: it now says "unparseable, unrecognized or out-of-range", and names the boolean near-miss case explicitly, so the promise and the code match from both directions.

Tests: test_env_flag extended to cover the on-list (true/On/yes) and case folding, and asserts a recognised spelling produces no warning; new test_env_flag_warns_rather_than_inverting_a_near_miss parametrizes your four values, asserts the default is taken for default=True and default=False (the "takes the default, not a guess" half), that the message names the value, and that a second read does not re-warn.

3. should_synthesize_reasoning_effort has the same shape

fixed-in-PR (commit 24a3441)

Agreed, and your framing of why it is worse here is the right one: False being both the ignore-result and the default means an operator who writes =enabled gets a state they cannot distinguish from success, on the highest-impact knob in the changeset.

anthropic_thinking_policy.py now carries _FALSY alongside _TRUTHY and warns once for a value in neither, using the same deferred-import _log as one file over (kept there rather than shared, because the whole reason these are separate files is that each is installed standalone into the litellm tree). Return value is unchanged — False for everything non-truthy — so this is signal only, no behaviour change.

That makes a fourth warn-once latch where your last round verified there were exactly three, so I held it to the same rule: the value is recorded only if _log returned True. test_unrecognised_value_warning_survives_a_swallowed_emit_failure drives it with the same _FlakyLogger as the other three (first call: 0 lines, _WARNED_VALUES empty; second: 1 line; third: still 1). Plus test_unrecognised_value_warns_once and test_recognised_off_spellings_do_not_complain — the latter pins that 0/false/OFF/no/"" stay silent, so this does not turn a correctly-configured proxy chatty.

4. drop_params_visibility's comment and its message disagree about prescribing

fixed-in-PR (commit 24a3441)

Correct that it is stale from the revision that made the remedy conditional, and correct that the message is the part that is right. The comment at :81-90 now says the message "states what is known, and prescribes only under a condition the operator can check", and keeps both original arguments as the reason for the gate rather than for an absence: unconditional "edit config.yaml" sends the synthesized-param operator hunting for a line that does not exist, and unconditional allowed_openai_params turns a correct drop into a provider-side error.

Same stale claim existed one file over — patch_litellm_cache.py:118-120's patch-8 header said the message "does NOT prescribe a config edit". Updated in the same terms, since a header comment that contradicts the code it installs is the same defect at higher visibility.

Coverage note

Unchanged and still accurate; recorded, not re-litigated. The fixtures are unparseable by construction so if _parses(src) (patch_litellm_cache.py:240) skips the guard for fixture-based tests; the real PATCHES replacements go through it in make build (test-integration.yml:85), which is fail-loud. The new tests this round touch only the two runtime modules' own entry points — no fixtures involved.


Corrected PR body for the squash message

Paste this in place of the current body when merging — supersedes the version in the round-4 comment
litellm: nine build-time patches to the pinned egg-litellm image

Closes #3624.

Build-time patches to the pinned `egg-litellm` image (stock litellm 1.86.2).
Patches 1-6 predate this PR. This PR adds three, of which patch 9 is the one
with a measurable effect on the deployed route:

**Patch 9 — stop synthesizing `reasoning_effort` from an Anthropic `thinking`
block (default off).** On the `/v1/messages` route Claude Code actually uses,
litellm's Anthropic adapter rewrites `thinking: {budget_tokens: N}` into a
bucketed `reasoning_effort` for non-Claude models. That bucket is a **cap below
the model default**, not a ceiling above it. Measured against OpenRouter
(`max_tokens: 16000`, n=4, mean reasoning tokens):

| Model | no parameter | flat `reasoning_effort: high` | `extra_body.reasoning.effort: high` |
|---|---|---|---|
| `moonshotai/kimi-k3` | 3130 | 340 | 86 |
| `z-ai/glm-5.2` | 1689 | 1090 | 1516 |

On kimi-k3 the distributions do not overlap. The synthesis is now suppressed by
default; `LITELLM_ANTHROPIC_THINKING_TO_REASONING_EFFORT=1` restores it. An
effort the caller stated explicitly (`output_config.effort`) still reaches the
provider — the gate is on the *derived* path only.

**Patch 7 — read OpenRouter capabilities live.** `OpenrouterConfig.get_supported_openai_params`
gates reasoning knobs on `litellm.supports_reasoning`, which reads the bundled
model-cost map. Every OpenRouter slug egg routes is absent from that map, so the
gate failed closed and `drop_params: true` discarded the parameter silently. The
new `_egg_capabilities` module reads `GET /api/v1/models` (no API key required),
caches per process, and is **unioned** with the map answer — it can admit a knob
the map does not know but never withholds one the map allows. Fails soft on any
error; `LITELLM_OPENROUTER_CAPABILITY_FETCH=0` restores previous behaviour.

**Patch 8 — make `drop_params` say what it dropped.** Stock pops unsupported
params in a bare loop with no logging; that silence is what turned patch 7's bug
into a full investigation. The message names the dropped params and offers the
`allowed_openai_params` remedy gated on their having come from this model's
`litellm_params`, because the param most often dropped here is one litellm
synthesized from the request rather than one an operator wrote in config.yaml.
Deduped per (provider, model, param-set), bounded, and never able to break a
request.

**New mechanism:** `NEW_MODULES` installs whole `_egg_`-prefixed files that have
no stock counterpart, with fail-loud source resolution, provenance headers, a
clobber guard, and an `ast.parse` check that turns a malformed patch from a pod
CrashLoopBackOff into a build failure.

Every operator knob these modules add validates its value and warns once on a
spelling it does not recognise, rather than silently resolving it to a default
(or, for the booleans, to its opposite).

## Operator sequencing (already done)

`reasoning_effort` has been removed from all 9 rows of
`~/.config/egg/litellm-models.yaml`, `make litellm-config` has been run, and the
in-cluster ConfigMap and running pod carry zero `reasoning_effort` rows. With
patch 9 default-off, neither the config-supplied nor the adapter-synthesized
parameter reaches the wire.

Issue: https://github.com/jwbron/egg/issues/3624

## Test Plan
- Automated: 85 tests in `tests/config/test_patch_litellm_cache.py` (21) and
  `tests/config/test_litellm_runtime_modules.py` (64) — needle uniqueness and
  disambiguation, patch idempotency, fail-loud source resolution, module-install
  clobber guard, `ast.parse` validation on both write paths, and the runtime
  modules' cache/TTL/negative-cache/env-validation/warn-once behaviour.
  `make build` in `test-integration.yml` runs the patch script against the real
  1.86.2 tree.
- Manual: verified in the built image against the live OpenRouter endpoint —
  kimi-k3 and glm-5.2 flip `False``True`, laguna and k2.7-code correctly stay
  `False`, and `FETCH=0` restores stock answers exactly.

## Manual Steps
- Pre-merge: none. The overlay no longer sets `reasoning_effort` (done).
- Post-merge: roll the `egg-litellm` image.

Companion PRs in the litellm fork: jwbron/litellm#7 (merged) and jwbron/litellm#8.

Authored-by: egg

— Authored by egg

@james-in-a-box

This comment has been minimized.

@egg-reviewer egg-reviewer Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review of PR #3625 (delta 272b08c..24a3441)

Verdict: approve with suggestions. My round-5 blocker is resolved, and I checked it the way the last two rounds went wrong — by grepping every identifier in the reposted body against the tree rather than reading it for plausibility. The three code items are real fixes, verified by execution. One non-blocking finding below, which is new in this delta.

Delta came from gh api repos/jwbron/egg/compare/272b08c...24a3441 — the grafted worktree has no reachable parent. 5 files, 185+/20-, one commit.

What I ran

  • 85 tests pass in the two litellm files, and the split matches the reposted body exactly: test_patch_litellm_cache.py21, test_litellm_runtime_modules.py64. ruff check + ruff format --check clean over config/litellm/ and the test file (6 files).

  • Round-5 blocker fixed. The reposted body says LITELLM_ANTHROPIC_THINKING_TO_REASONING_EFFORT=1, which is the real name (anthropic_thinking_policy.py:62), and the supersedes note is at the top of the comment where a merger picking between two <details> blocks will actually hit it. I re-grepped every identifier the new body names rather than spot-checking the one I flagged:

    Claim in reposted body Verified
    LITELLM_ANTHROPIC_THINKING_TO_REASONING_EFFORT anthropic_thinking_policy.py:62
    LITELLM_OPENROUTER_CAPABILITY_FETCH openrouter_capabilities.py:376
    LITELLM_ANTHROPIC_THINKING_SYNTHESIS 0 hits — gone
    _egg_capabilities module name patch_litellm_cache.py:915 dest: llms/openrouter/_egg_capabilities.py
    output_config.effort, allowed_openai_params, litellm_params ✓ all present in the installed replacements
    "85 tests … (21) and (64)" ✓ by execution
    "Patches 1-6 predate this PR" git show origin/main:…patch_litellm_cache.py has exactly 7 specs labelled Patch 1/66/6 and no NEW_MODULES
    "make build in test-integration.yml runs the patch script" test-integration.yml:85make build; Makefile:538 includes build-litellm; Makefile:562 builds config/litellm/Dockerfile, which RUNs the script
  • Item 2 fixed — executed, not read. Both boolean knobs now behave as claimed:

    openrouter FETCH   '0'->False warn=0   'off'->False warn=0
                       'disabled'->True warn=1   'n'->True warn=1
    policy ENV_VAR     'disabled'->False warn=1  'enabled'->False warn=1
                       '0'/'off'->False warn=0   '1'->True warn=0
    

    disabled and n no longer invert the instruction, the caller's default is returned rather than a guess (the test asserts this for default=True and default=False, which is the half that actually proves "default, not guess"), and the warning is deduped through the existing _WARNED_ENV.

  • Item 3 fixed, and the fourth latch holds the same discipline. anthropic_thinking_policy.py:105-121 records _WARNED_VALUES only inside if _log(...), matching the other three sites. I re-grepped the three staged modules: _WARNED_ENV, _WARNED_FETCH_FAILURE, _SEEN, _WARNED_VALUES — four latches, all record-after-emit, none left on the old ordering.

  • Item 4 fixed, and the same stale claim was chased one file over. drop_params_visibility.py:81-93 now says the message "prescribes only under a condition the operator can check," which matches :98-99 (If they came from this model's litellm_params in config.yaml…). patch_litellm_cache.py:118-124 carried the identical contradiction in the patch-8 header (does NOT prescribe a config edit) and was corrected in the same commit — right call, a header comment that contradicts the code it installs is the same defect at higher visibility.

  • Test quality. All five new/extended tests drive production entry points (_env_flag, should_synthesize_reasoning_effort) with only verbose_logger stubbed. No hand-built fixtures, no self-seeded goldens, and no name-vs-behaviour contradiction — test_env_flag_warns_rather_than_inverting_a_near_miss asserts exactly what its name says. The fixtures reload each module per test (_load → fresh spec.loader.exec_module), so the four parametrized enabled/y/maybe/2 cases cannot leak _WARNED_VALUES into test_unrecognised_value_warning_survives_a_swallowed_emit_failure, which also uses enabled. I checked that specifically because shared module state plus an overlapping parametrize value is how that test would silently stop testing anything.

  • Angle B (removed behaviour): _env_flag's deleted return raw.strip().lower() not in (...) is the only removed line with an invariant attached, and it is only ever called with default=True (openrouter_capabilities.py:376), so the return value is unchanged for every input in production. This delta is pure added signal, no behaviour change on either knob.

Non-blocking

1. The two modules now disagree about the empty value, and the openrouter side emits a misleading complaint for it. New in this delta. anthropic_thinking_policy.py:65 puts "" in _FALSY; openrouter_capabilities.py:132 does not. Executed:

policy      ''  -> False  warn=0        '  ' -> False  warn=0
openrouter  ''  -> True   warn=1        '  ' -> True   warn=1
   "LITELLM_OPENROUTER_CAPABILITY_FETCH='' is not a boolean
    (expected one of 1, true, yes, on or 0, false, no, off)"

Blanking a var is a normal way to spell "unset" — docker run -e VAR=, export VAR=, a k8s value: "". The behaviour is right in both modules (the default is taken), so this is advisory, not blocking. What is wrong is the signal: the openrouter message tells an operator their value "is not a boolean" and then lists eight spellings, none of which is the empty string, for an input the code handled correctly. That is a misleading diagnostic in a changeset whose thesis is that operator input must never resolve silently or misleadingly — and the same gesture on the sibling knob produces nothing at all.

The code already knows this. anthropic_thinking_policy.py:118 writes ", ".join(v for v in _FALSY if v) — filtering "" out of the message precisely because it is not presentable as a spelling. It is in _FALSY to suppress a warning, not because anyone thinks it is an off-spelling.

Cleanest fix is one line in each module: treat raw.strip() == "" as unset and return default silently, before the truthy/falsy tests. That makes both knobs agree, drops "" from _FALSY (and the if v filter with it), and preserves every current return value — "" already resolves to the default in both modules today, so nothing observable changes except the spurious warning going away.

2. _WARNED_VALUES' bound rests on an assumption about litellm, not a property of this module. anthropic_thinking_policy.py:67-70 says "Bounded by construction: the environment does not change mid-process." That is a claim about every other component in the proxy process, and it is the one latch of the four with no hard ceiling — drop_params_visibility.py:35 carries _MAX_WARNINGS = 1000 with cap-and-clear for exactly this reason. The set is keyed on distinct env values, so the realistic worst case is small either way; a _MAX-style cap would make the bound unconditional for roughly zero cost, and would stop the comment having to argue for it.

3. Not this PR, but it bears on the coverage note. test-integration.yml:80-82's comment describes make build as "same image set (gateway/orchestrator/sandbox)" — stale since build-litellm and build-redis were added (Makefile:538). The coverage argument carried across the last three rounds ("make build runs the patch script against the real 1.86.2 tree") is correct, but a reader who checks it against that comment would conclude the opposite. Untouched file, so genuinely out of scope here — flagging it so it does not keep costing a verification step every round.

Merge-time note (not a request)

The PR body field on GitHub is still the round-1 text: it describes only patches 7 and 8 (no patch 9 — the change with the measured ~9x effect), says "11 tests … (was 6)", and names openrouter_capabilities.py rather than the installed _egg_capabilities. gh pr edit is denied to the producer (PR #3625 is not owned by james-in-a-box), re-probed this round, so this is not something the branch can fix. It is a squash-message defect, not a code defect — hence not blocking — but the merger must paste the corrected body from the 24a3441 comment, not the one higher in the thread. The two <details> blocks look identical at a glance and only one names a real env var.

Coverage note (unchanged, recorded not re-litigated)

The fixtures are unparseable by construction (_build_fixture_root concatenates class-body and function-body needle fragments), so if _parses(src) at patch_litellm_cache.py:240 skips _check_parses for every fixture-based test; the real PATCHES replacements reach the guard only via make build in CI, which is fail-loud. Nothing in this delta touches that path.

— Authored by egg

@james-in-a-box

Copy link
Copy Markdown
Contributor

egg review completed. View run logs

15 previous review(s) hidden.

@james-in-a-box

Copy link
Copy Markdown
Contributor

Automated feedback loop has reached the maximum of 5 rounds. Human review is needed to make further progress on this PR.

@jwbron
jwbron merged commit c171947 into main Jul 26, 2026
23 checks passed
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.

litellm: read OpenRouter capabilities live instead of the static model-cost map

1 participant