Skip to content

Commit 8a35ceb

Browse files
authored
fix(provider): reasoning-budget, gemini-3, interleaved-merge & catalog-refresh correctness (#120)
Four provider/transform correctness fixes from the backend audit: - max reasoning variant no longer starves text to 1 token. On a 32k-output Claude, the max thinking budget of cap-1 (31,999) left maxOutputTokens returning a single text token; keep it proportional (~3/4 of cap). Large caps still clamp at 31,999. (#17) - Gemini-3 effort variants are nested under thinkingConfig. @ai-sdk/google only reads providerOptions.google.thinkingConfig.*, so the old top-level keys were dropped and effort selection was silently ignored (always high). (#24) - Config-model merge falls back to the catalog model's interleaved shape like every other capability, so overriding one field (e.g. cost) no longer drops the interleaved-reasoning relocation. (#29) - ModelsDev.refresh() now invalidates provider state so a long-running session picks up the hourly-refreshed catalog instead of the first snapshot. (#25)
1 parent b23df88 commit 8a35ceb

5 files changed

Lines changed: 75 additions & 11 deletions

File tree

backend/cli/src/provider/models.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,14 @@ export namespace ModelsDev {
141141
if (result) {
142142
await Bun.write(file, JSON.stringify(result))
143143
ModelsDev.Data.reset()
144+
// Drop the memoized provider state so a long-running session picks up the
145+
// refreshed catalog (new/renamed/removed models) instead of serving the
146+
// snapshot captured at first build. Dynamic import avoids a static cycle
147+
// (provider.ts imports ModelsDev). Best-effort.
148+
try {
149+
const { Provider } = await import("./provider")
150+
Provider.invalidate()
151+
} catch {}
144152
}
145153
}
146154
}

backend/cli/src/provider/provider.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -977,7 +977,11 @@ export namespace Provider {
977977
video: model.modalities?.output?.includes("video") ?? existingModel?.capabilities.output.video ?? false,
978978
pdf: model.modalities?.output?.includes("pdf") ?? existingModel?.capabilities.output.pdf ?? false,
979979
},
980-
interleaved: model.interleaved ?? false,
980+
// Fall back to the catalog model's interleaved shape like every other
981+
// capability above — otherwise overriding any single field (e.g. cost)
982+
// on an interleaved-reasoning model dropped its {field} object, so
983+
// normalizeMessages stopped relocating prior-turn reasoning.
984+
interleaved: model.interleaved ?? existingModel?.capabilities.interleaved ?? false,
981985
},
982986
cost: {
983987
input: model?.cost?.input ?? existingModel?.cost?.input ?? 0,

backend/cli/src/provider/transform.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -600,8 +600,13 @@ export namespace ProviderTransform {
600600
},
601601
max: {
602602
thinking: {
603+
// Keep the thinking budget proportional (~3/4 of the output cap)
604+
// instead of `cap - 1`: on a 32k-output model, a 31,999-token
605+
// budget made maxOutputTokens() return just 1 text token, so the
606+
// visible answer was truncated to a single token. Large-cap models
607+
// are unaffected (still clamp at 31,999).
603608
type: "enabled",
604-
budgetTokens: Math.min(31_999, cap - 1),
609+
budgetTokens: Math.min(31_999, Math.floor((cap * 3) / 4)),
605610
},
606611
},
607612
}
@@ -672,13 +677,19 @@ export namespace ProviderTransform {
672677
},
673678
}
674679
}
675-
// Gemini 3+ uses thinkingLevel
680+
// Gemini 3+ uses thinkingLevel. Nest under `thinkingConfig` — the
681+
// @ai-sdk/google provider only reads providerOptions.google.thinkingConfig.*,
682+
// so the previous top-level keys were dropped and the selected effort was
683+
// silently ignored (every call defaulted to the high thinkingLevel from
684+
// options()). options()/smallOptions() already nest correctly.
676685
return Object.fromEntries(
677686
WIDELY_SUPPORTED_EFFORTS.map((effort) => [
678687
effort,
679688
{
680-
includeThoughts: true,
681-
thinkingLevel: effort,
689+
thinkingConfig: {
690+
includeThoughts: true,
691+
thinkingLevel: effort,
692+
},
682693
},
683694
]),
684695
)

backend/cli/test/provider/transform-reasoning.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,49 @@ describe("ProviderTransform.options — BYOK / direct paths stay untouched", ()
121121
})
122122
})
123123

124+
describe("ProviderTransform.variants — Anthropic max thinking budget", () => {
125+
const claude = (cap: number) =>
126+
model({
127+
id: "anthropic/claude-opus-4-1",
128+
providerID: "anthropic",
129+
api: { id: "claude-opus-4-1", url: "https://api.anthropic.com", npm: "@ai-sdk/anthropic" },
130+
limit: { context: 200_000, output: cap },
131+
})
132+
133+
test("max thinking budget leaves real text headroom on a 32k-output model", () => {
134+
const v = ProviderTransform.variants(claude(32_000))
135+
const budget = (v.max as any).thinking.budgetTokens
136+
expect(budget).toBeLessThan(32_000)
137+
const textTokens = ProviderTransform.maxOutputTokens(
138+
"@ai-sdk/anthropic",
139+
{ thinking: { type: "enabled", budgetTokens: budget } },
140+
32_000,
141+
32_000,
142+
)
143+
// Was 1 before the fix — now a usable amount of text.
144+
expect(textTokens).toBeGreaterThanOrEqual(4_096)
145+
})
146+
147+
test("large-cap models still clamp the budget at 31,999", () => {
148+
const v = ProviderTransform.variants(claude(64_000))
149+
expect((v.max as any).thinking.budgetTokens).toBe(31_999)
150+
})
151+
})
152+
153+
describe("ProviderTransform.variants — Gemini-3 nests under thinkingConfig", () => {
154+
test("effort variants are wrapped so @ai-sdk/google actually reads them", () => {
155+
const gem = model({
156+
id: "google/gemini-3-pro",
157+
providerID: "google",
158+
api: { id: "gemini-3-pro", url: "https://generativelanguage.googleapis.com", npm: "@ai-sdk/google" },
159+
})
160+
const v = ProviderTransform.variants(gem)
161+
expect(v.low).toEqual({ thinkingConfig: { includeThoughts: true, thinkingLevel: "low" } })
162+
expect(v.high).toEqual({ thinkingConfig: { includeThoughts: true, thinkingLevel: "high" } })
163+
expect((v.low as any).thinkingLevel).toBeUndefined()
164+
})
165+
})
166+
124167
describe("ProviderTransform.smallOptions — OpenRouter", () => {
125168
test("small OR calls disable reasoning via the unified shape", () => {
126169
const result = ProviderTransform.smallOptions(orModel("openrouter/openai/gpt-5-nano", "openai/gpt-5-nano"))

backend/cli/test/provider/transform.test.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1814,17 +1814,15 @@ describe("ProviderTransform.variants", () => {
18141814
})
18151815
const result = ProviderTransform.variants(model)
18161816
expect(Object.keys(result)).toEqual(["low", "medium", "high"])
1817+
// Nested under thinkingConfig so @ai-sdk/google actually reads the level.
18171818
expect(result.low).toEqual({
1818-
includeThoughts: true,
1819-
thinkingLevel: "low",
1819+
thinkingConfig: { includeThoughts: true, thinkingLevel: "low" },
18201820
})
18211821
expect(result.medium).toEqual({
1822-
includeThoughts: true,
1823-
thinkingLevel: "medium",
1822+
thinkingConfig: { includeThoughts: true, thinkingLevel: "medium" },
18241823
})
18251824
expect(result.high).toEqual({
1826-
includeThoughts: true,
1827-
thinkingLevel: "high",
1825+
thinkingConfig: { includeThoughts: true, thinkingLevel: "high" },
18281826
})
18291827
})
18301828
})

0 commit comments

Comments
 (0)