@argszero/cordis-plugin-thinking-level-fallback
v0.1.1
Published
Thinking-level fallback for dsh: wraps the llm/stream waterfall and re-issues an auxiliary model call the provider rejected for naming an unsupported thinking level (e.g. Google: "Thinking level MINIMAL is not supported for this model"). On that provider
Maintainers
Readme
@argszero/cordis-plugin-thinking-level-fallback
A model call the provider rejects for naming a thinking level the route never declared, re-issued once with a level the model actually declares — so an automatic compaction is not silently lost to a 400.
The defect (discussion #6478)
On Google's Gemini 3 models, "no reasoning effort" is not "no parameter on the wire". It is a concrete level the deployment never declared:
HTTP 400 INVALID_ARGUMENT
Thinking level MINIMAL is not supported for this model. Please retry with other thinking level.Two facts combine.
1. The harness omits the reasoning option when nothing resolved an effort.
// packages/llm/llm-pi-ai/src/adapter.ts:119-123
const enabledReasoning: ThinkingLevel | undefined = reasoning === 'off' ? undefined : reasoning
return {
...
...enabledReasoning === undefined ? {} : { reasoning: enabledReasoning },
}The omission is deliberate — adapter.ts:171-182 calls it "the seam's way of
saying the capability is unavailable, which leaves the surface offering only the
provider's default".
2. pi-ai reads an omitted option as "thinking disabled" and spells disabled as a level.
// @earendil-works/[email protected], dist/api/google-generative-ai.js:238-241
if (!options?.reasoning) {
return stream(model, context, { ...base, thinking: { enabled: false } });
}
// dist/api/google-generative-ai.js:341-354
function getDisabledThinkingConfig(model) {
// Google docs: Gemini 3.1 Pro cannot disable thinking, and Gemini 3 Flash /
// Flash-Lite do not support full thinking-off either. For Gemini 3 models, use
// the lowest supported thinkingLevel ...
if (isGemini3ProModel(model)) return { thinkingLevel: "LOW" };
if (isGemini3FlashModel(model)) return { thinkingLevel: "MINIMAL" };
if (isGemma4Model(model)) return { thinkingLevel: "MINIMAL" };
return { thinkingBudget: 0 }; // Gemini 2.x disables properly
}getDisabledThinkingConfig never consults the model's thinkingLevelMap. That
map is what the route declares:
models:
- id: gemini-3.8-flash
reasoningEfforts:
high: high # minimal/low/medium/xhigh/max are pinned to null = unsupportedresolveModelReasoning pins every undeclared level to null on purpose
(llm-pi-ai/src/catalog.ts:716-743), so the harness knows minimal is
unsupported — and still receives it, because substitution for "disabled" happens
below the map. The declaration and the wire disagree.
Which requests resolve no effort?
- An auxiliary call.
compaction-basic/src/summarizer.ts:126-160copies onlyproviderandmodelout of the routed request header (agent.session.requestHeader()?.config) and builds the request withpurpose: 'compaction'and noreasoningEffort.purposeis documented as the place for exactly this policy — adapters "may map the purpose to ... purpose-specific generation policy" (llm/src/types.ts:453-458) — andllm-deepseekalready uses it forsession-title(llm-deepseek/src/serialize.ts:84).llm-pi-aiapplies no purpose policy at all. - An
offselection. The adapter converts it to an omitted option, i.e. to the same request.
The failure chain the report produced:
context pressure reaches the compaction threshold
-> automatic compaction starts
-> the summary request omits reasoning effort
-> pi-ai substitutes thinkingLevel MINIMAL
-> provider rejects it: HTTP 400 INVALID_ARGUMENT
-> compaction-basic logs a warning and continues the turn
(compaction-basic/src/index.ts:161-162)
-> pressure keeps growing
-> CONTEXT_WINDOW_EXCEEDED
-> the overflow-recovery compaction is rejected the same wayInstall and mount
npm install @argszero/cordis-plugin-thinking-level-fallbackThe bundle patch mounts it; no configuration is required:
- insert:
- id: thinking-level-fallback
name: '@argszero/cordis-plugin-thinking-level-fallback'What it does
It wraps the public llm/stream waterfall — the around-dispatch seam every
streaming model call passes through, and the only seam that has the request in
hand (guard/ cannot reach it: guard/timeout-policy is a per-tool
tools/execute deadline).
When an in-scope call ends in a provider rejection naming an unsupported
thinking level, it asks llm.resolveModelInfo(provider, model) which levels that
exact model declares, picks the nearest one to what the request carried
(walking pi-ai's own escalation order upward, then downward — the order
clampThinkingLevel uses), and re-issues the call once with it:
compaction call, google/gemini-3.8-flash, declared { off, high }
attempt 1: no reasoningEffort -> HTTP 400 "Thinking level MINIMAL is not supported"
attempt 2: reasoningEffort 'high' -> 200The failed attempt is discarded whole. Its chunks are buffered and never yielded, so a re-issued call is indistinguishable from a first one at the caller's boundary; a kept attempt starts flowing at its first reader-visible chunk, so token streaming and the durable log are untouched.
Deliberate narrowness
Five conditions must all hold:
| Condition | Why |
|---|---|
| Purpose is auxiliary (compaction, session-title) by default | Those are the calls the harness builds. A conversation request carries a model-selection choice, and silently exchanging it would answer a question the user did not ask. Add 'conversation' to purposes to extend coverage. |
| The terminal frame is an error (never aborted) whose message names a thinking level | Anything else is not this defect. |
| The code is not UNSUPPORTED_REASONING_EFFORT | That is the harness' own refusal of a routing effort the model does not declare, raised before any provider I/O, and deliberate: "The request path still refuses, which is where a bad configuration belongs" (llm-pi-ai/src/adapter.ts:139-142). Re-issuing over it would hide a misconfigured route default behind a working request. |
| No reader-visible content has streamed (isVisibleChunk) | After that a re-run would duplicate output. |
| The exact model declares a level besides off, and it differs from the rejected one | off is the value the adapter translates back into the rejected request, so it is never a candidate; and repeating the same level is not a retry. |
The re-issued request carries a process-local marker read by the same listener, which is what bounds the recursion — not a counter, so the bound survives any copy of the request the harness makes on its way to the adapter.
Configuration
- set:
- id: thinking-level-fallback
config:
enabled: true # false = pure pass-through
purposes: [compaction, session-title] # + 'conversation' to cover plain calls
maxRetries: 1 # re-issues per call
extraPatterns: [] # other providers' wording, as regex sourcesextraPatterns sources are compiled once at mount, so a bad pattern fails
loudly there rather than inside a stream, where the failure would be attributed
to the provider. Each source's first capture group is the level the provider
named; without a group the plugin still matches, and the fallback is the model's
first declared level.
Relationship to other guards
@argszero/cordis-plugin-overflow-classifier-guardrepairs the classification of an over-large rejection so compaction runs. This one repairs a compaction that ran and was rejected. Both observellm/stream; neither changes the other's verdict.@argszero/cordis-plugin-turn-budget-guardand-repeat-guard-escalationbound a loop that keeps going. This one is about a call that never gets to run at all.
Scope, honestly
This is a seam-level stopgap, and it is a two-sided one:
- It only repairs calls in
purposes. A conversation request that names anoffeffort on such a model still fails, unless'conversation'is enabled. - It cannot fix the request before it is sent —
llm/streamis dispatched after the adapter has built the request, and the omitted option is an adapter-internal decision. The provider has to say no first.
It becomes a no-op the moment either side is fixed: when the harness resolves an
auxiliary call's effort from the routed header it already reads for
provider/model, or when pi-ai stops substituting a level the model's
thinkingLevelMap marks unsupported.
Compatibility
- dsh 0.1.3, 0.1.5 and 0.1.6 lines (one peer comparator group per
published tuple — a single
>=0.1.2-rc.1 <0.2.0 || >=0.1.5-alpha.1 <0.2.0range silently excludes the whole 0.1.3 line). - Verified against
@deepseek-ai/[email protected]and@earendil-works/[email protected](the version reported in the discussion).
0.1.1 corrected two claims 0.1.0 made and did not honour:
- The 0.1.2 line was dropped. This plugin imports
isVisibleChunkfrom the harness; it lives inassistant-stream, which first exists in 0.1.3-alpha.2. On 0.1.2-rc.1 the artifact installs and then dies at mount withdoes not provide an export named 'isVisibleChunk'. - The 0.1.6 line was admitted. A comparator only admits a prerelease when
some comparator shares that prerelease's
major.minor.patch, so>=0.1.5-alpha.1 <0.1.6-0is not "0.1.5 and later" — it drops every0.1.6-*release, onenpm installafter0.1.6-alpha.2became thealphadist-tag.
test/package.test.js now computes admission over the published version list
with semver and asserts the admitted set exactly. It previously asserted the
shape of the range (three groups naming 0.1.2/0.1.3/0.1.5) — which is how it
certified both defects rather than catching them, since it required a 0.1.2
group and could not see an absent 0.1.6 one.
Tests
npm test # tsc && node --test "test/*.test.js"32 tests: the reported Google rejection end to end (re-issued, rejected attempt invisible), every narrowness condition above, the level walk, the marker bound, and the manifest/peer-range invariants.
License
MIT
