Skip to content

fix(thinking): keep client thinking config for uncatalogued models - #182

Open
warelik wants to merge 2 commits into
kaitranntt:mainfrom
warelik:fix/configured-model-thinking-passthrough
Open

fix(thinking): keep client thinking config for uncatalogued models#182
warelik wants to merge 2 commits into
kaitranntt:mainfrom
warelik:fix/configured-model-thinking-passthrough

Conversation

@warelik

@warelik warelik commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Summary

A model declared in config but missing from the bundled internal/registry/models/models.json silently loses reasoning: the client's generationConfig.thinkingConfig is stripped before dispatch, so includeThoughts never reaches the upstream. The provider still runs and bills the reasoning pass, it just returns no thought parts.

This affects every model newer than the embedded catalog — which is the normal case for a freshly released Gemini model added through gemini-api-key.models[].

Root cause

internal/modelconfig/model_info.go built the capability snapshot for a configured model and unconditionally cleared the user-defined marker:

info := registry.LookupStaticModelInfo(baseName)  // nil for an uncatalogued model
if info == nil {
    info = &registry.ModelInfo{}                  // Thinking == nil
}
...
info.UserDefined = false

So an uncatalogued model resolves to Thinking == nil and UserDefined == false. internal/thinking/apply.go reads exactly that pair as an authoritative "this model cannot reason":

if modelInfo.Thinking == nil {
    config := extractThinkingConfig(body, providerFormat)
    if hasThinkingConfig(config) || summaryConfig.Mode != SummaryUnspecified {
        return StripThinkingConfig(body, providerFormat), nil
    }

The two states are not the same: an absent catalog entry means the capability is unknown, not proven absent. Only the second justifies stripping. This also contradicts the documented contract on IsUserDefinedModel, which states that models configured via *-api-key.models[] should have their thinking configuration applied directly and validated by the upstream.

Live evidence

Same prompt, same key, same model (gemini-3.7-flash behind a configured alias), before the fix:

parts thought part thoughtsTokenCount
Direct to generativelanguage.googleapis.com 2 present, 1192 chars 302
Through the proxy 1 missing 188

The upstream request log confirms the cause — the whole block was removed:

Body: {"contents": [...], "generationConfig": {},"safetySettings":[...]}

Non-zero thoughtsTokenCount alongside a missing thought part is the signature of the bug: Gemini only emits thought parts when includeThoughts is set, but it reasons (and bills) regardless.

Change

ResolveModelInfo now distinguishes unknown capability from proven-absent capability:

info.UserDefined = !catalogKnown && support == nil
  • Uncatalogued model, no explicit thinking: block → unknown capability → snapshot is user-defined → the caller's configuration is forwarded and the upstream validates it.
  • Catalog-known model whose entry records no thinking support → absence is authoritative → still stripped, unchanged.
  • Explicit thinking: block in config → still wins, unchanged.

After the fix (same live setup)

gemini-native  parts=2, part[0] thought=true (1029 chars), thoughtsTokenCount=157
OpenAI path    reasoning_content len=1171, reasoning_tokens=780
upstream body  "generationConfig": {"thinkingConfig": {"thinkingBudget": 2048, "includeThoughts": true}}

Test plan

  • New internal/modelconfig/model_info_unknown_thinking_test.go covers both directions: an uncatalogued model must retain includeThoughts and thinkingBudget through ApplyThinkingWithModelInfoAndSummary, and a catalog model documented without reasoning must keep its authoritative absence.
  • Two existing tests asserted the previous contract (TestResolveModelInfoUnknownModelKeepsMissingCapability, TestAttachResolvedAPIKeyModelInfoBindsUnknownConfiguredCapability) and are updated with the rationale inline.
  • go build ./... — clean
  • go test ./... -count=1 — 0 failures
  • gofmt -l — clean
  • Live verification against Google Gemini on both the Gemini-native and OpenAI-compatible paths, as quoted above.

Mirror

CPA: router-for-me/CLIProxyAPI#4991 (identical changes)

A configured model that the bundled catalog does not carry resolved to an
empty capability snapshot: Thinking == nil and UserDefined == false. The
thinking applier reads that pair as "this model provably cannot reason" and
strips generationConfig.thinkingConfig before dispatch, so includeThoughts
never reaches the upstream.

The effect is a silent capability loss for every model newer than the
embedded models.json. Verified live against Google Gemini through a
configured gemini-api-key entry for gemini-3.7-flash:

  direct  -> 2 parts (thought=true, 1192 chars + answer), thoughtsTokenCount 302
  via CPA -> 1 part  (answer only),                       thoughtsTokenCount 188

The upstream still runs and bills the reasoning pass, it just never returns
the thought parts, because Gemini only emits them when includeThoughts is
set. The same request was logged upstream as "generationConfig": {} — the
whole thinkingConfig removed.

Distinguish unknown capability from proven-absent capability in
ResolveModelInfo: a snapshot is marked user-defined only when the catalog
has no entry for the model and no explicit thinking block was configured.
Such a request is then forwarded for the upstream to validate, matching the
documented contract for config-declared models. A catalog-known model whose
entry records no thinking support keeps its authoritative absence and is
still stripped, and an explicitly configured thinking block still wins.

Adds model_info_unknown_thinking_test.go covering both directions: the
uncatalogued model must retain includeThoughts and thinkingBudget through
ApplyThinkingWithModelInfoAndSummary, and a catalog model documented without
reasoning must stay authoritative. Two existing tests asserted the previous
contract and are updated with the rationale.
TestExecuteStream_PublishesUsageRecordFromStreamUsage failed once in CI on
PR kaitranntt#182 after exactly 2.000s while the PR diff touches only modelconfig.
Local reproduction: 30/30 passes at both PR head and base commit, and
100/100 passes with the timeout raised to 5s. The wait helper polls for a
usage.Record published by the executor goroutine; on a loaded CI runner the
goroutine can be scheduled past the tight 2s budget. The deadline is a
failure accelerator, not part of the assertion — widening it does not
weaken what the test verifies.
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.

1 participant