Skip to content

fix: derive thinking-strength controls from models.dev reasoning_options - #2050

Merged
Astro-Han merged 4 commits into
mainfrom
fix/1858-thinking-options-passthrough
Aug 3, 2026
Merged

fix: derive thinking-strength controls from models.dev reasoning_options#2050
Astro-Han merged 4 commits into
mainfrom
fix/1858-thinking-options-passthrough

Conversation

@Astro-Han

@Astro-HanAstro-Han commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes#1858.

models.dev declares per-model reasoning_options, but scripts/sync-model-metadata.mjs only read the reasoning boolean, so the generated metadata could never carry effort options. Thinking controls were hand-declared per provider path in STATIC_MODEL_METADATA, and the wire was a second hand-maintained switch in buildProviderOptions. Both silently missed most providers (42 of 44 with reasoning models had at least one gap), so reasoning models showed no thinking knob and, even when forced, sent nothing.

The fix makes models.dev reasoning_options the single source of truth for both the knob and the wire:

  • Sync passthrough: toMetadata now normalizes reasoning_options (effort values + toggle) into thinkingOptions in the generated metadata. The existing static-over-generated merge in lookupModelMetadata means the UI and thinkingOptionsForModel needed zero changes. budget_tokens is recognized but not carried (no wire consumer yet); unknown option types fail loudly instead of silent drift.
  • Wire family fallback: buildProviderOptions falls back to a wire-family mapper keyed on the resolved adapter kind — the same object getAIModel switches on, including per-model models.dev npm package overrides. Namespace derivation is shared with getAIModel via openAiCompatibleProviderOptionsName so the two cannot drift. The variant gate (level is defined only when metadata declares it) is what makes the generalization safe.
  • Contract test (thinking-wire-contract.test.ts): every declared thinking variant across the full generated model universe must wire a real, non-empty shape, and declared off levels must map to a real off wire — the silent "forgot" state now fails CI.
  • Snapshot regenerated: includes upstream drift (80 models removed, 149 added, 668 existing models gain thinkingOptions). opencode-free pin updated for the now-deprecated hy3-free; vision characterization tests re-pinned to models still present.
  • Static table slimming: 22 thinkingOptions pins byte-identical to the regenerated snapshot were removed (duplication the passthrough made redundant); pins that are the only source (static-only models, offBehavior adapter knowledge, Maka-verified deltas) stay.

Commit 2 is a pure refactor: the eleven duplicated reasoningEffort one-liner cases collapse into the family fallback (reachable behavior identical — verified), and the identity openaiCompatibleNamespace helper is deleted.

Commit 3 addresses three independent deepseek-v4-flash reviews:

  • cohere north-mini-code-1-0: carries a models.dev package override to the OpenAI-compatible endpoint, but the old explicit case wired it with the native Cohere thinking object. The cohere wire now lives in the family mapper keyed on the resolved adapter kind, and the static suppression hiding its declared efforts is gone.
  • groq qwen3-32b: models.dev declares ['none','default'] — qwen3.6-27b's value set misapplied; Groq docs and the repo's own verified comment say qwen3-32b has no reasoning_effort. Pinned to no options until a live check proves otherwise.
  • contract test: sweeps the generated universe (not just fallbackModels), requires non-empty inner wire shapes, and requires a real off wire for declared off.
  • displayMetadataOnly now inherits source thinkingOptions when no override exists, so claude-subscription automatically inherits upstream anthropic declarations instead of requiring hand-pins.

Commit 4 addresses a second review round (gpt-5.6-sol, k3-256k):

  • single-source metadata universe: the contract sweep previously hand-mirrored lookupModelMetadata's alias rules and enumerated fallback models only, so alias drift or static-only models (e.g. the claude-subscription derived table) could silently shrink coverage. Core now exports modelMetadataIdsForProvider (generated snapshot + static overrides under the same alias rules) and the sweep uses it.
  • dropped the 19-case drift guard in model-factory-thinking.test.ts: it asserted the same invariant as the sweep with a weaker JSON-substring off-wire check and is fully superseded.
  • deleted leftover empty static records (deepseek, stepfun-ai-step-plan, xai) and the now-duplicate tencent-token-plan/hy3 pin.
  • added a github-copilot protocol-branch test (anthropic-messages / openai-responses wires), unreachable by the sweep without account models.

Verification

  • scripts/sync-model-metadata.test.mjs: transform-level tests for reasoning_options passthrough (effort/toggle/budget_tokens), malformed shapes, unknown-type tripwire.
  • thinking-wire-contract.test.ts: issue case + full-universe wireability invariant.
  • model-factory-thinking.test.ts: family-fallback deepEqual pins (opencode override paths, zenmux, github-copilot, cohere native vs compat wires), groq pin characterization.
  • npm run format:check, npm run lint: clean.
  • Workspace test suite: all packages pass except 4 runtime path-containment tests and 1 storage writer-lock test that fail identically on main in this environment (pre-existing, unrelated).
  • Live endpoint check: zen/go/v1 honors reasoning_effort (dose-response verified, invalid value 400s with the enum list), so the opencode-go wire is real, not speculative.

Review focus

  • The declaration-as-gate invariant: models.dev's per-model reasoning_options now drives both UI and wire. Where verified adapter knowledge contradicts upstream (groq qwen3-32b), a static pin suppresses with a documented reason; the contract test keeps suppressions honest.
  • OpenRouter/vercel/zenmux etc. now send reasoning_effort for every upstream-declared effort model. If any gateway rejects the param, it becomes a static suppression entry (documented, test-enforced) rather than a silent gap.
  • toggle is carried through but still has no production consumer (off wires are gated on offBehavior/none); a future real off switch can consume it.

models.dev declares per-model reasoning_options, but the sync script only
read the reasoning boolean, so generated metadata could never carry effort
options. Thinking options were hand-declared per provider path in
STATIC_MODEL_METADATA and the wire was a second hand-maintained switch;
both silently missed most providers (42 of 44 had gaps), so reasoning
models showed no thinking knob and, even when forced, sent nothing.
- sync-model-metadata.mjs passes reasoning_options through (effort +
toggle; budget_tokens recognized, no wire consumer yet) and is now
importable with a main guard so the transform is unit-testable
- buildProviderOptions falls back to a wire family mapper keyed on the
resolved adapter kind (the same object getAIModel switches on,
including per-model models.dev package overrides), so every
upstream-declared variant has a wire; namespace derivation shared
with getAIModel so the two cannot drift
- github-copilot and xai non-grok-4.5 paths wired; cohere north-mini
suppresses the undeclared effort levels its wire cannot express
- contract test: every declared thinking variant on every provider path
must wire non-empty (closes the silent 'forgot' state)
- snapshot regenerated (models.dev drift included: 80 removed, 149
added, 668 existing models gain thinkingOptions); opencode-free pin
updated for the deprecated hy3-free; vision tests re-pinned to models
still present
Fixes#1858
…allback
The switch enumerated the same reasoningEffort one-liner for eleven
provider paths (vercel, ollama-cloud, deepinfra, openrouter, groq,
deepseek, moonshot, tencent-token-plan, zai-coding-plan, stepfun-step-
plan, stepfun-ai-step-plan). All are now handled by the resolved-adapter
family fallback with identical reachable behavior: the shape-B providers
never declare an off level (verified against the snapshot), so the
off->none mapping change is unreachable there. Also deleted the identity
openaiCompatibleNamespace helper (every mapping was the provider type
itself) in favor of the shared openAiCompatibleProviderOptionsName rule.
… contract, redundant pins
Review feedback (three independent deepseek-v4-flash reviews) surfaced
one real wire bug and several hardening items:
- cohere north-mini-code-1-0 carries a models.dev package override to
the OpenAI-compatible endpoint, but the explicit cohere case wired it
with the native Cohere thinking object. Moved the cohere wire into the
family mapper keyed on the resolved adapter kind (native cohere models
keep thinking.disabled; overridden models get reasoning_effort) and
dropped the static suppression that hid its declared efforts.
- groq qwen3-32b: models.dev declares ['none','default'], which is
qwen3.6-27b's value set misapplied; Groq docs and the repo's own
verified comment say qwen3-32b has no reasoning_effort. Pinned it to
no options until a live check proves otherwise.
- contract test now sweeps the generated model universe (not just
fallbackModels), requires a non-empty inner wire shape, and requires a
real off wire for declared off levels.
- removed 22 static thinkingOptions pins that were byte-identical to the
regenerated snapshot (they were pure duplication created by the
passthrough); kept pins that are the only source or carry real deltas.
- displayMetadataOnly now inherits source thinkingOptions when no
override exists, so claude-subscription automatically inherits
upstream anthropic declarations instead of requiring hand-pins.
- added sync tests for budget_tokens, unknown option types, and
toggle-only models.
- xai non-grok-4.5 path delegates to the shared family wire instead of
duplicating it.
…rseded guards
Second review round (gpt-5.6-sol, k3-256k) found the contract sweep's
enumeration was a hand-mirror of lookupModelMetadata's alias rules plus
fallback models only, so alias drift or static-only models (e.g. the
claude-subscription derived table) could silently shrink coverage.
- export modelMetadataIdsForProvider from core: the metadata universe
per provider (generated snapshot + static overrides) under the same
alias rules lookupModelMetadata applies; the sweep and the lookup can
no longer drift. Removes the need for the model-metadata.generated
subpath export added in the previous round.
- drop the 19-case resolver/options drift guard from
model-factory-thinking.test.ts: it asserted the same invariant as the
contract sweep with a weaker JSON-substring off-wire check, and every
case is now covered by the sweep's stricter exact-value checks.
- delete empty static records left after pin removal (deepseek,
stepfun-ai-step-plan, xai) and the now-duplicate tencent-token-plan
hy3 pin (hy3's effort set comes from the snapshot; hy3-preview stays).
- add a github-copilot protocol-branch test (anthropic-messages and
openai-responses wires) that the sweep cannot reach without account
models.
@Astro-Han
Astro-Han marked this pull request as ready for review August 3, 2026 19:30
@Astro-Han
Astro-Han merged commit 41a9838 into mainAug 3, 2026
11 checks passed
@Astro-Han
Astro-Han deleted the fix/1858-thinking-options-passthrough branch August 3, 2026 19:31
Astro-Han added a commit that referenced this pull request Aug 4, 2026
…iniMax coding plans (#2067)
* fix: make Kimi Coding Plan thinking strength follow the chosen level
Kimi Coding Plan was a pre-#2050 orphan: it was missing from the
sync-model-metadata PROVIDERS map, so its metadata stayed a hand-written
static block (k3 pinned to a single 'max' effort, k3-256k absent), and
the runtime wire hard-coded max and never read the level.
- sync-model-metadata.mjs maps kimi-coding-plan to models.dev's
kimi-for-coding provider; the snapshot now declares k3 (toggle +
low/high/max) and k3-256k (low/high/max), and the stale static block
is deleted (regenerated snapshot also carries a little openrouter
drift from upstream)
- buildProviderOptions passes the chosen level through both Kimi wires
(anthropic effort and openai-chat reasoningEffort), defaulting to max
when unset; k3-256k joins the K3 adaptive-thinking wire
- the thinking wire contract test now sweeps kimi-coding-plan's declared
levels automatically, closing the silent-forget gap for this provider
Live-check pending: confirm Kimi's coding API accepts low/high for
k3/k3-256k before relying on non-max levels.
* fix: wire StepFun Step Plan metadata to its own models.dev provider
stepfun-step-plan was a dormant twin of the kimi-coding-plan orphan: the
sync map only covered stepfun / stepfun-ai / stepfun-ai-step-plan, so
stepfun-step-plan lived on a hand-written static block that expanded
stepfun (API) records and pinned reasoning facts by hand.
- sync-model-metadata.mjs maps stepfun-step-plan to its own models.dev
provider; the snapshot now declares all four Step Plan models with
upstream effort levels (step-3.7-flash low/medium/high, the 3.5-flash
pair low/high) and the static block is deleted
- provider-registry.ts: stepfun-step-plan modelsDevId was mislabeled as
stepfun; now resolves its own provider facts and the model guard
checks the stepfun-step-plan snapshot segment
- note: step-router-v1's reasoning capability now follows models.dev
(false) instead of the hand-written true; no thinking options were
declared either way, so the wire behavior is unchanged
Declared levels are swept by the existing thinking wire contract, so
this closes the same silent-forget gap for stepfun-step-plan.
* fix(core): point MiniMax Coding Plan at its own models.dev segment and guard the sync map
The MiniMax Coding Plan access path was the last alias-style provider:
it declared modelsDevId minimax and reached metadata through a static
alias to the MiniMax API segment, so the two models.dev segments could
drift apart without anyone noticing (their docsUrl already differs).
- sync-model-metadata.mjs maps minimax-coding-plan to its own models.dev
provider; the snapshot now carries a dedicated segment (7 models,
MiniMax-M3 toggle preserved) and the alias line is deleted
- provider-registry.ts: minimax-coding-plan modelsDevId resolves its own
segment facts and the fallback model guard checks its own segment
- new models-dev-sync-contract test closes the silent-forget gap for
good: every registry modelsDevId must resolve to the provider's own
snapshot segment (or alias metadata), and every snapshot segment must
be declared by the registry with its own id — adding a provider to the
sync map without wiring its registry entry is now a test failure
- registry modelsDevId now declared for every snapshot-backed provider:
anthropic, openai, google, deepseek, kimi-coding-plan, zai-coding-plan,
MiniMax, MiniMax-cn, gemini-cli (aliases xai-oauth/opencode-free keep
their existing alias ids)
* fix(core): close the silent-forget gap with a three-way models.dev sync contract
Independent review of the previous fix found the sync contract only
covered the already-declared universe: a registry provider with no
modelsDevId and no sync-map entry (the exact kimi-coding-plan bug shape)
was invisible to both directions, and the forward check was tautological
(every modelsDevId was assigned from the same generated facts object it
compared against).
- sync-model-metadata.mjs now also emits GENERATED_MODELS_DEV_DIRECTORY:
the complete models.dev provider catalog (id, name, api), not just the
mapped subset
- models-dev-sync-contract.test.ts becomes three-way:
* forward: a declared modelsDevId must name a provider that exists in
the directory (catches sync keys pointing at nonexistent sources)
* reverse: every snapshot segment must be declared by the registry
(unchanged)
* directory: a registry provider whose base URL host matches a
directory provider must declare modelsDevId — the kimi bug shape is
now a test failure. Localhost/self-hosted endpoints are excluded
- registry: kimi-coding-plan fallback models now guarded at import time
like stepfun/minimax; claude-subscription and openai-codex declare
their anthropic/openai segment sources; kimi-for-coding comment notes
the intentionally-missing knob is not a sync gap
- runtime: kimi case explicitly rejects off (future-proofing for a
models.dev 'none' declaration) instead of the dead level !== 'off'
guard; openai-chat namespace comment documents the ai-sdk camelCase
alias dependency; highspeed branch comment states the no-wire intent
- tests: openai-chat high passthrough asserted, kimi off entry-gate
behavior asserted, step-router-v1 no-variants intent locked
Verified the new contract has teeth: removing kimi's modelsDevId makes
both the reverse and directory checks fail.
* fix: address Claude Opus and Codex review — off rejection, alias contract, main() coverage
Two independent external reviews (Claude Opus, Codex) both found no
P0/P1, but converged on real gaps in the previous review round:
- kimi 'off' rejection was dead code: the entry gate normalized off to
undefined before the case guard, so an explicit off silently became
max (the test even locked that in). The case now checks the raw
thinkingLevel argument and rejects off with empty options.
- the sync contract could not see alias-type orphans: a provider
declaring a *neighbour* segment id (the exact pre-fix stepfun/minimax
shape) passed all three checks, and claude-subscription pointing at
openai passed too. Replaced host-based matching with a deterministic
completeness check: every provider must have its own segment, a
declared alias (xai-oauth/opencode-free/claude-subscription/openai-
codex), or a whitelist reason (9 local/user-configured/volcengine
entries). Both mutation shapes now fail the suite.
- sync-model-metadata.mjs main() had zero coverage (the place the
orphan bug lived): added an end-to-end fixture test over the real
main() path, and exported PROVIDERS/main(argv) to make it testable.
- the wire-contract sweep only exercised kimi's anthropic branch: added
an explicit openai-chat sweep for kimi-coding-plan.
- GENERATED_MODELS_DEV_DIRECTORY shrank to { api?: string } (name was
never consumed); dropped the unused FACTS_BY_PROVIDER alias.
- stepfun guard now covers all 4 fallback models (slice(0,3) was stale);
minimax guard label says MiniMax Coding Plan.
Deferred (recorded, not implemented): k3 low/high live check against
the Kimi API (B1), kimi protocol-decision consolidation onto
resolveModelRuntime (B3) and camelCase namespace cleanup (D3) as a
runtime refactor, kimi-for-coding forced-max semantics pending the live
check, user-visible upstream drift noted in the PR description.
Verified: core 772/772, runtime 3089 pass / 4 pre-existing macOS symlink
failures, sync scripts 9/9, lint/format/typecheck clean. Mutation tests:
claude-subscription→openai and minimax-coding-plan→MiniMax.id both now
fail the contract suite.
* style: fix biome format in the kimi openai-chat sweep test
Astro-Han added a commit that referenced this pull request Aug 20, 2026
The committed models.dev snapshot had drifted far enough that it no longer
described the catalog it claims to mirror: 1690 models to 1779, deprecations 82
to 98, and pricing rows 1239 to 1316 (+86 new, -9 removed, 37 repriced). The
refresh is regenerated from one pinned api.json payload rather than a live
fetch, so every number here is reproducible from a fixed input.
Three consumers had to follow.
- Upstream deprecated two of the four ids pinned for the OpenCode Free
offering, `north-mini-code-free` and `laguna-s-2.1-free`. Both are removed.
`provider-registry.ts` asserts its pins are active and tool-capable at module
load, so leaving them would fail the process at import, not at first use.
- `gemini-3-pro-preview` left the catalog, so the Google thinking test moves to
`gemini-3.1-pro-preview`, whose effort values carry the same no-`off` shape
the test asserts. Two Google fallback ids the snapshot no longer contains,
`gemini-2.0-flash` and `gemini-1.5-pro`, are dropped from that list.
- The snapshot now sits about 2 KB under Biome's 1 MiB default, and this refresh
alone added 57 KB, so the next one crosses it. An over-size file is skipped
with a warning while `format:check` still exits 0, so crossing would quietly
stop checking the snapshot rather than fail on it. The ceiling is raised for
that one file through an override; a global raise would also disarm the guard
for hand-written files, which is what it exists for.
The refresh also flips 15 ZenMux models from active to deprecated. Two carry a
model-level protocol override and are excluded from the fallback source, so 13
reach it. Discovery keeps only the fallback set and the catalog marks what
survives available and default-capable, so all 13 would have been offered as
usable choices for a provider that had none before. ZenMux now filters
deprecated ids at the call site, as six other providers already do.
That fix is deliberately narrow. Six providers still ship deprecated ids in
their fallback lists — 28 models across `togetherai`, `mistral`, `xiaomi`,
`deepinfra`, `nvidia`, and `openai`, the last writing its list by hand rather
than through `toolCallingModelIds`. Reverting this refresh changes none of them,
so converging them is tracked in #3355, and a new contract test records that set
as an explicit boundary which fails both on regression into it and on a listed
provider being cleaned up without being removed.
Not addressed here: persisted user catalogs keep ids this refresh retires,
because nothing reconciles `enabledModelIds` against the current registry and
`opencode-free` has no discovery contract that could later repair it. That
predates this branch — #2050 removed `hy3-free` the same way and touched no
persistence — and reverting this refresh does not fix it, since the ids stay
retired upstream. Tracked in #3354.
The four surviving OpenCode Free pins are not evidence of anonymous entitlement
and are not claimed as such. Individual ids have had operational attention —
#1720 called `big-pickle` anonymously, #2166 replaced an unhealthy bootstrap
model — but the four have never been verified as a set, and this refresh adds
no verification and no reachable model; it only drops two the upstream retired.
Reviewed by @hqhq1025 with Codex assistance under the maintainer-approved review
workflow; two findings were raised, one fixed here and one filed as #3354.
Generated-by: Claude Code
Sign up for freeto 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.

opencode-go shows no thinking-strength control for deepseek-v4-flash despite models.dev declaring reasoning_options

1 participant

@Astro-Han