-
Notifications
You must be signed in to change notification settings - Fork 1.1k
feat(reasoning): derive routed effort ladders from models.dev and replay a refused rung #4409
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
yxr1995-maker
wants to merge
3
commits into
lidge-jun:dev
Choose a base branch
from
yxr1995-maker:feat/reasoning-metadata
base: dev
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
41 changes: 41 additions & 0 deletions
41
devlog/_plan/260912_reasoning_effort_downgrade/000_decision.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| # 260912 — A refused reasoning rung is learned and replayed once | ||
|
|
||
| ## Decision | ||
|
|
||
| When a routed upstream answers 400/403 and names reasoning effort in the body, the pipeline records | ||
| that (provider, model, effort) as refused, replays the request once at the next lower published rung, | ||
| and keeps the rung out of every later ladder (see the metadata record in | ||
| devlog/_plan/260912_reasoning_metadata/). The attempt is logged with recovery kind | ||
| reasoning-effort-downgrade, so requestedEffort and effectiveEffort stay distinguishable in usage. | ||
|
|
||
| ## Why the ladder is not enough | ||
|
|
||
| The published ladder describes the model, not the account. Live 2026-09-12: | ||
| muse-spark-1.3-contributor answered 400 for max with | ||
|
|
||
| Error from provider (Console Go): Upstream request failed: [invalid_request_error] | ||
| reasoning_effort max requires an active Muse Code subscription for model | ||
| muse-spark-1.3-contributor. | ||
|
|
||
| while xhigh answered 200. Clamping against the published ladder removes that case before dispatch, | ||
| but any entitlement-driven refusal for a published rung would otherwise fail the turn outright. | ||
|
|
||
| ## Shape | ||
|
|
||
| - Detection is narrow on purpose: 400/403 only, the body must be complete and display-safe (the same | ||
| contract as the other rejection peeks), and the text has to name reasoning effort. An unrelated 400 | ||
| never triggers a replay, which keeps the single extra send honest. | ||
| - One replay per request, guarded per recovery loop. The streamed passthroughRecovery loop and the | ||
| non-streamed recovery loop both carry the same block, matching the file's existing convention that | ||
| recovery kinds stay in sync across the two. | ||
| - Before the rebuild the parsed effort is replaced and the same-target cache is invalidated | ||
| (invalidateSameTargetRequest), because that cache keys on parsed identity and would otherwise | ||
| replay the original body byte-for-byte. | ||
| - No new failure surface: when the refusal is the only rung (or every lower rung is known-refused), | ||
| the original error is returned untouched. | ||
|
|
||
| ## Evidence | ||
|
|
||
| tests/responses/responses-reasoning-effort-downgrade.test.ts (4 cases, mocked upstream): | ||
| pre-dispatch clamp, learn-then-replay on the non-streamed path, learn-then-replay on the streamed | ||
| path, and no replay for an unrelated 400. tests/responses runs 2040 pass / 0 fail with the change. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,73 @@ | ||
| # 260912 — Routed reasoning ladders come from models.dev | ||
|
|
||
| ## Decision | ||
|
|
||
| For a routed provider whose destination models.dev publishes, the Codex catalog and the outbound | ||
| wire value prefer the published reasoning ladder over a hand-written one, and a rung the upstream | ||
| actually refused is dropped from every later ladder (registry config included). | ||
|
|
||
| Layers: | ||
|
|
||
| 1. src/providers/reasoning-metadata.ts snapshots models.dev (reasoning + reasoning_options, the | ||
| effort / toggle / budget_tokens option types) into ~/.opencodex/reasoning-metadata-cache.json | ||
| (24h TTL, stale-but-readable offline, atomic write). The v2 snapshot stores ladders for the | ||
| gated destinations (OpenCode Zen + Zen Go, 133 models / ~20 KB) and the published `api` URL of | ||
| every provider models.dev lists, so the gate can be checked against real data. | ||
| 2. configuredReasoningEfforts() consults that snapshot only when nothing was configured for the | ||
| model, so every hand-written contract stays authoritative; mapReasoningEffort() clamps through | ||
| the same function, which is what keeps the catalog and the wire in agreement. | ||
| 3. reasoning-support-cache.json records (provider, model, effort) refusals; the filter at the | ||
| configuredReasoningEfforts() exit removes those rungs whether the ladder came from the snapshot | ||
| or from the registry. | ||
|
|
||
| ## Why the hand-written table was not enough | ||
|
|
||
| OpenCode Zen Go answers GET https://opencode.ai/zen/go/v1/models with ids only (id, object, created, | ||
| owned_by — 37 models, verified 2026-09-12), so opencodex had to guess: | ||
|
|
||
| - muse-spark-1.3-contributor was advertised up to ultra while the gateway refuses max with | ||
| 400 {"param":"reasoning.effort","type":"invalid_request_error","message":"Error from provider | ||
| (Console Go): Upstream request failed: [invalid_request_error] reasoning_effort max requires an | ||
| active Muse Code subscription for model muse-spark-1.3-contributor."} ; xhigh answers 200. | ||
| models.dev publishes [minimal, low, medium, high, xhigh] for that model — the refusals were the | ||
| synthetic tiers, not the model. | ||
| - deepseek-v4.1-flash needs [low, high, max] before it advertises any control at all; models.dev | ||
| publishes exactly that. | ||
|
|
||
| Verified after the change: the catalog lists [low, medium, high, xhigh] for muse-spark and | ||
| [low, high, max] for deepseek-v4.1-flash, max on muse-spark is sent as xhigh, and a refusal replays | ||
| once at the next lower published rung (usage.jsonl recovery kind reasoning-effort-downgrade) | ||
| instead of failing the turn. | ||
|
|
||
| ## Source resolution (2026-09-12 review follow-up) | ||
|
|
||
| models.dev publishes each provider's own `api` URL (`opencode-go` -> `https://opencode.ai/zen/go/v1`, | ||
| `opencode` -> `https://opencode.ai/zen/v1`), so the destination is resolvable from data rather than from a | ||
| guess. Resolution stays gated: BASE_URL_TO_METADATA_PROVIDER is the authoritative list (both URLs are | ||
| compared normalised, so a trailing slash or a `/v1` suffix never decides), and reasoningMetadataMapping() | ||
| reports for each gated destination whether the snapshot confirms it against the published URL. | ||
|
|
||
| Measured the same day: **36 of the registry's 83 destinations** match a models.dev provider, and 13 of a live | ||
| 27-provider config do; 11 of those 13 already carry hand-written ladders (the metadata fallback is never | ||
| consulted) and the other 2 (`openrouter`, 4 models) would change catalog ladders. Resolving by URL alone | ||
| would therefore move ladders for providers this change has no evidence for, so widening the gate is a | ||
| separate decision with those numbers in hand -- the snapshot already carries the data it needs. | ||
|
|
||
| ## Learned refusals are credential-scoped in practice | ||
|
|
||
| A refusal is recorded per `(provider, model, effort)`. Every destination that can reach this path is | ||
| `authKind: key`, i.e. one credential per provider entry, so that key already has the credential dimension; | ||
| the catalog is account-independent by construction (built once per process, not per request). Three | ||
| properties bound the rest: only the refused rung is dropped, the fact expires after 30 days, and the clamp | ||
| is visible as requestedEffort versus effectiveEffort in usage.jsonl. A credential-scoped key becomes | ||
| necessary only if opencodex ever pools several credentials behind one metadata-mapped provider entry. | ||
|
|
||
| ## Known follow-ups | ||
|
|
||
| - Destination to models.dev provider id stays a gated table (two OpenCode destinations today). | ||
| Widening it to every URL match is measured above and is a maintainer call, not a mechanical edit. A | ||
| shared registry-side helper would replace the table itself, but importing providers/registry from this | ||
| module widened an unrelated supported_reasoning_levels literal type during development, so the naive | ||
| import was reverted. | ||
| - The snapshot refresh is triggered on first read with TTL and in-flight guards rather than from the | ||
| startup path, so a long-lived proxy refreshes at most daily. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Correct the documented ladder precedence.
Lines 5-7 state that published metadata takes precedence over a hand-written ladder. Lines 16-18 and
configuredReasoningEfforts()implement the opposite rule.State that hand-written model and provider ladders take precedence. State that models.dev metadata is only a fallback.
🤖 Prompt for AI Agents