diff --git a/AMICODE-PATCHES.md b/AMICODE-PATCHES.md index c2da0deabb..c098e2dcf0 100644 --- a/AMICODE-PATCHES.md +++ b/AMICODE-PATCHES.md @@ -265,16 +265,14 @@ Rebuilt with the exact T3 recipe (`OPENCODE_VERSION=1.17.3 bun run script/build. if something else (e.g. the harmoniqs website dev server) holds 3000. - Checks: `tsgo -b` clean; `bun run test:unit` 376 pass / 0 fail. -15. (Aero wordmark, re-added) — amicode: AMICODE wordmark/logo font-family gains `'Aero'` ahead of the existing fallback chain, both call sites: logo.tsx (`'Aero', var(--font-family-sans, ...)`) and wordmark-v2.tsx (`'Aero', 'Racing Sans One', var(--font-family-mono, ...)`). Additive only — no fallback removed, no sizing/weight/spacing touched. This had been iterated as uncommitted working-tree WIP before the branch fast-forwarded 57 commits to current `origin/local/amicode` (e9b695191); re-applied fresh against the current font stacks (logo.tsx's had independently moved to a sans-serif stack + weight 750/letter-spacing 4 in the interim — preserved as-is, only the family list changed). +10. (Aero wordmark, re-added) — amicode: AMICODE wordmark/logo font-family gains `'Aero'` ahead of the existing fallback chain, both call sites: logo.tsx (`'Aero', var(--font-family-sans, ...)`) and wordmark-v2.tsx (`'Aero', 'Racing Sans One', var(--font-family-mono, ...)`). Additive only — no fallback removed, no sizing/weight/spacing touched. This had been iterated as uncommitted working-tree WIP before the branch fast-forwarded 57 commits to current `origin/local/amicode` (e9b695191); re-applied fresh against the current font stacks (logo.tsx's had independently moved to a sans-serif stack + weight 750/letter-spacing 4 in the interim — preserved as-is, only the family list changed). > ⚠️ **LICENSE-PENDING — release blocker.** "Aero" by Nirmal Biswas (Picatype) is donationware, USD 25+ to nirmalbiswas@gmail.com. Bundled at `packages/app/public/assets/Aero-Regular.ttf` for PREVIEW ONLY; not locked as the official face. Before any release: pay the license and record proof here, or drop `'Aero'` from both `font-family` chains + remove its `@font-face` (index.css) + the asset. - - index.css: new `@font-face` for "Aero" (truetype, weight 400, `font-display: swap`), inserted immediately before the existing Racing Sans One face; carries the license-pending comment inline. - New asset (git-added): `public/assets/Aero-Regular.ttf` (44,464 B). - Verify: `bun run typecheck` green in both `packages/ui` and `packages/app` (tsgo, no errors). Visual/glyph confirmation is manual-only (see build note under #16, which builds and vendors this alongside the paste fix). -16. (composer paste bridge) — amicode: wire the main chat composer's `handlePaste` (prompt-input/attachments.ts) to the extension-clipboard bridge that patch #14's PR already shipped for a different input (home-cards.tsx's `readClipboardViaBridge`/`pasteFallback`, commit `5a2f1dde44`) — that bridge answers `{source:"amicode", kind:"clipboard-request"}` on the extension side (`packages/extension/src/chat_panel.ts`, already on `main`), but nothing in the composer's own paste path ever sent it. Root cause matches home-cards.tsx's own comment verbatim: the chat iframe is sandboxed + cross-origin, so `navigator.clipboard` is denied and the native `paste` event's `clipboardData` arrives empty — Cmd/Ctrl+V into the composer was a silent no-op. - +11. (composer paste bridge) — amicode: wire the main chat composer's `handlePaste` (prompt-input/attachments.ts) to the extension-clipboard bridge that patch #14's PR already shipped for a different input (home-cards.tsx's `readClipboardViaBridge`/`pasteFallback`, commit `5a2f1dde44`) — that bridge answers `{source:"amicode", kind:"clipboard-request"}` on the extension side (`packages/extension/src/chat_panel.ts`, already on `main`), but nothing in the composer's own paste path ever sent it. Root cause matches home-cards.tsx's own comment verbatim: the chat iframe is sandboxed + cross-origin, so `navigator.clipboard` is denied and the native `paste` event's `clipboardData` arrives empty — Cmd/Ctrl+V into the composer was a silent no-op. - platform.tsx: new optional capability `readClipboardText?(): Promise`, alongside the existing `readClipboardImage?`. - entry.tsx: implements it — no-op (`null`) when not framed (`window.parent === window`, i.e. plain browser/opencode.ai/desktop — zero behavior change there); when framed, posts `{source:"amicode", kind:"clipboard-request", nonce}` to `window.parent` and resolves on the matching `{kind:"clipboard", nonce}` reply or a 1500 ms timeout — same nonce/timeout/listener shape as the proven `readClipboardViaBridge`, just exposed as a platform capability instead of a component-local closure (this file already hosts the analogous `readClipboardImage`). - attachments.ts: `handlePaste` no longer bails out when `event.clipboardData` is null (only when it's ALSO missing `readClipboardText`, i.e. non-amicode contexts are untouched); after the existing file/image-bridge attempts, falls back to `readClipboardText()` when there's still no plain text. @@ -288,7 +286,7 @@ Rebuilt with the exact T3 recipe (`OPENCODE_VERSION=1.17.3 bun run script/build. - Tests: same 376/376 pass (app suite), typecheck green — no regressions. No unit coverage added, same house-precedent reasoning as above (DOM/postMessage bridge code, build+manual verified here). - **Final build sha256 (covers #15 + #16 + this addendum together):** `115216e82a6db15843fca46d5b32b587ba9a1e542297501cf2b45c394f745ea9`. Verify: smoke test `--version` → `1.17.3`; string-literal checks confirm the underlying protocol compiled in (`Aero` ×5, `clipboard-request` ×2, `readClipboardText` ×3 — note: the new `installGlobalPasteFallback`/`isFormField` are local identifiers, not string literals, so they're minifier-renamed and don't grep-match; absence there is expected, not a failure signal); channel gate ON (`newLayoutDesigns` sentinel `=!0`). **Interactive Cmd/Ctrl+V confirmation (composer AND the generalized fallback) is live-VS-Code-only, NOT yet human-verified past Aaron's one negative report on the pre-addendum build** — same deferred-to-dev-host-acceptance caveat as patches #12/#13. -17. (mark drift fix, synced to amicode PR #99 final) — amicode: consolidated the fork's brand mark to ONE geometry, matching amicode's redesigned mark. The "kept in sync manually" cross-repo promise from patch #8 had already silently failed. +12. (mark drift fix, synced to amicode PR #99 final) — amicode: consolidated the fork's brand mark to ONE geometry, matching amicode's redesigned mark. The "kept in sync manually" cross-repo promise from patch #8 had already silently failed. - Trigger: amicode's mark was redesigned (PR #99) without a corresponding update here — the fork still rendered the OLD "digi" pixel-accented H-robot everywhere. PR #99 went through several iterations before landing on its final geometry; this entry tracks that FINAL state (square viewBox `0 0 3600 3600`), not the intermediate "hackathon mark" (viewBox `116 287 3377 3035`) an earlier draft of this fork PR had copied — that intermediate geometry is now itself stale and was replaced here. - Two copies of near-identical geometry lived in THIS repo (logo.tsx's `Robot` used by `Mark`/`Splash`, and spinner.tsx's `AmicoSpinner`), plus a third in favicon/amico.svg. Consolidated to a single `MARK_PATH` exported from logo.tsx. @@ -340,3 +338,18 @@ Rebuilt with the exact T3 recipe (`OPENCODE_VERSION=1.17.3 bun run script/build. - Service account (`client_credentials`) is the documented exit condition (retires the password entirely, ADR-clean); request is with Pasqal (Baptiste Peyras). Browser/device/token/refresh paths are evidence-ruled-out (see #194); the scaffold's tolerant method model harmlessly still parses `auth_methods` if a future server ever advertises them. - CAVEAT (cannot be verified here): the real @napi-rs/keyring backend + its load inside the bun-compiled binary needs a real-machine smoke test before trusted, and the dep must be added to packages/opencode/package.json (node-pty/tree-sitter precedent). All LOGIC is tested behind the injected in-memory store. - Tests: pasqal-secret.test.ts +5; amicode-connections.test.ts +6 silent-reauth (store-on-submit / re-mint / reject-wipes / unreachable-stands / disconnect-wipes / secret-free-response) + hermetic secret-store isolation in setup; existing expiry test made explicit about the no-password fallback. opencode `bun test test/server/amicode-connections.test.ts test/server/pasqal-secret.test.ts test/server/amicode-connections-routes.test.ts` → 105 pass. typecheck (tsgo) green. (15 unrelated `built-in widget sources` failures are a pre-existing missing-build-artifact issue, not this diff.) + +24. (upstream provider/model port — Jack, 2026-07-27) — **first deliberate partial upstream sync.** Our base is sst/opencode `v1.17.3` (2026-06-10); upstream was 1,150 commits ahead at v1.18.7. Rather than a full merge (54 fork-touched files overlap), only the provider/model bucket was ported, because Claude 5 was silently broken at our base. + - THE BUG: `anthropicOpus47OrLater()` matched `/opus-(\d+)[.-](\d+)/` — requires BOTH major and minor. `claude-opus-5` has no minor and `claude-sonnet-5` isn't "opus", so neither matched. Both fell through to the legacy `thinking:{type:"enabled",budgetTokens:16000}` path with only `high`/`max` tiers, instead of the 5-tier `type:"adaptive"` path. Sonnet 5 released 2026-06-29 and Opus 5 on 2026-07-24 — both AFTER our fork point. Upstream fixed this in #38757 (generalize Claude adaptive thinking): the version regex now tolerates a missing minor and defaults unversioned Claude IDs to modern. + - NOT a "new models" problem: the catalog is fetched live from models.dev at BUILD time (`script/generate.ts`; `amicode-release.yml` pins no snapshot), so Opus 5 already appeared in the picker — just hobbled. QA-ing "can I select Opus 5?" gives a false negative on the need to sync. + - WHAT WAS PORTED (deliberately minimal — the END STATE of files, not 112 commits of history): + - `packages/opencode/src/provider/transform.ts` — replaced wholesale with upstream/dev. Fork had never touched it → 0 conflicts. Also carries #38424 (prompt cache keys by SDK), #36624/#36543 (derive variants from reasoning metadata), and the Grok/GLM/Bedrock-Mantle variant fixes. + - `packages/opencode/test/provider/transform.test.ts` — replaced wholesale with upstream/dev (our copy asserted 6 now-superseded behaviors). + - `packages/core/src/models-dev.ts` — ADDITIVE only: upstream's `ReasoningOption` union + optional `reasoning_options` field on `Model`. transform.ts reads it; without it the port is 4 type errors. + - `packages/opencode/src/session/llm/request.ts` — 3-way merged (`git merge-file` ours/v1.17.3/upstream), 0 conflicts. Brings upstream #33392 (`strict: false` on tools for `@ai-sdk/openai`, `@ai-sdk/azure`, `@ai-sdk/amazon-bedrock/mantle`). **amicode patch #18 (PROMPT_COMMUNICATING) is preserved** — different region of the file. + - WHAT WAS DELIBERATELY NOT PORTED: the Effect `layer-node → app-node` refactor and the `@opencode-ai/schema` extraction (upstream gutted `core/src/model.ts` 127→41 and `core/src/provider.ts` 85→25 into a new package). Cherry-picking the bucket's 112 commits would drag both in. Take file end-states, not history. + - MEASURED BLAST RADIUS: ran both transform versions over all 454 reasoning models in the live models.dev catalog — **418 identical, 36 changed, 0 errors**. The 36: Claude 5 (opus/sonnet, across `anthropic` + all `amazon-bedrock` regions + `opencode` zen) goes `high,max` → `low,medium,high,xhigh,max` with `type:"adaptive"`; xAI Grok 4.3/4.5/4.20/build and GLM-5.2 gain effort variants where they previously had NONE; `claude-opus-4-5` keeps its `low,medium,high` keys but now also sends `budgetTokens:16000` alongside `effort`. Claude 4.8/4.6/4.1 unchanged. + - VERIFICATION: `tsgo --noEmit` green on packages/opencode AND packages/core. `bun test test/provider/` → 503 pass / 0 fail. `bun test test/session/ test/server/` → identical results before and after the port (3 pre-existing `llm-native-recorded` failures + a pre-existing Bun 1.3.14 teardown segfault, both reproduced byte-for-byte on the unported tree). + - STILL UNVERIFIED (needs live API creds): whether the OLD legacy `thinking:{type:"enabled",budgetTokens}` form actually 400s against Opus 5 or merely degrades — i.e. whether this was "hobbled" or "unusable". Also unexercised by CI: the Grok/GLM effort variants and the opus-4-5 `budgetTokens`+`effort` combination. + - FUTURE SYNC COST: this is a knowing trade — porting file end-states now makes a future clean upstream merge harder on these 4 files. Accepted. NOTE the bug class recurs every model generation (version-regex parsing of model IDs will break again at Opus 6), which argues for a standing narrow sync lane on `transform.ts` rather than one-off unfreezes. + - SEPARATE RISK SPOTTED (not fixed here): the release build does a bare `fetch(models.dev/api.json)` with no fallback, so a models.dev outage hard-fails the build. Consider pinning `MODELS_DEV_API_JSON` for the hackathon build. diff --git a/packages/core/src/models-dev.ts b/packages/core/src/models-dev.ts index 3f9f670374..940dac5ab0 100644 --- a/packages/core/src/models-dev.ts +++ b/packages/core/src/models-dev.ts @@ -43,6 +43,24 @@ const Cost = Schema.Struct({ ), }) +// How a model exposes its reasoning control in the models.dev catalog. Ported +// from upstream alongside provider/transform.ts — the transform derives thinking +// variants from this instead of pattern-matching model IDs. See AMICODE-PATCHES.md. +const ReasoningOption = Schema.Union([ + Schema.Struct({ + type: Schema.Literal("effort"), + values: Schema.Array(Schema.NullOr(Schema.String)), + }), + Schema.Struct({ + type: Schema.Literal("toggle"), + }), + Schema.Struct({ + type: Schema.Literal("budget_tokens"), + min: Schema.optional(Schema.Finite), + max: Schema.optional(Schema.Finite), + }), +]) + export const Model = Schema.Struct({ id: Schema.String, name: Schema.String, @@ -52,6 +70,7 @@ export const Model = Schema.Struct({ reasoning: Schema.Boolean, temperature: Schema.Boolean, tool_call: Schema.Boolean, + reasoning_options: Schema.optional(Schema.Array(ReasoningOption)), interleaved: Schema.optional( Schema.Union([ Schema.Literal(true), diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index cce3c7014b..705af3d5c1 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -26,6 +26,18 @@ export function sanitizeSurrogates(content: string) { return content.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? { + const value = id.toLowerCase() + return value.includes("kimi") || value.includes("moonshot") + }) + ) + return true + const url = model.api.url.toLowerCase() + return ["api.kimi.com", "api.moonshot.ai", "api.moonshot.cn", "api.moonshotai.cn"].some((host) => url.includes(host)) +} + // Maps npm package to the key the AI SDK expects for providerOptions function sdkKey(npm: string): string | undefined { switch (npm) { @@ -46,6 +58,28 @@ function sdkKey(npm: string): string | undefined { return "vertex" case "@ai-sdk/google": return "google" + case "@ai-sdk/alibaba": + return "alibaba" + case "@ai-sdk/cerebras": + return "cerebras" + case "@ai-sdk/cohere": + return "cohere" + case "@ai-sdk/deepinfra": + return "deepinfra" + case "@ai-sdk/groq": + return "groq" + case "@ai-sdk/mistral": + return "mistral" + case "@ai-sdk/perplexity": + return "perplexity" + case "@ai-sdk/togetherai": + return "togetherai" + case "@ai-sdk/vercel": + return "vercel" + case "@ai-sdk/xai": + return "xai" + case "venice-ai-sdk-provider": + return "venice" case "@ai-sdk/gateway": return "gateway" case "@openrouter/ai-sdk-provider": @@ -215,10 +249,10 @@ function normalizeMessages( }) } + const modelID = model.api.id.toLowerCase() if ( model.providerID === "mistral" || - model.api.id.toLowerCase().includes("mistral") || - model.api.id.toLocaleLowerCase().includes("devstral") + ["mistral", "devstral", "codestral", "pixtral", "mixtral"].some((family) => modelID.includes(family)) ) { const scrub = (id: string) => { return id @@ -430,6 +464,9 @@ function mapProviderOptions( export function message(msgs: ModelMessage[], model: Provider.Model, options: Record) { msgs = unsupportedParts(msgs, model) msgs = normalizeMessages(msgs, model, options) + const usesAnthropicAutomaticCaching = + options.cacheControl !== undefined && + (model.api.npm === "@ai-sdk/anthropic" || model.api.npm === "@ai-sdk/google-vertex/anthropic") if ( (model.providerID === "anthropic" || model.providerID === "google-vertex-anthropic" || @@ -439,7 +476,8 @@ export function message(msgs: ModelMessage[], model: Provider.Model, options: Re model.id.includes("claude") || model.api.npm === "@ai-sdk/anthropic" || model.api.npm === "@ai-sdk/alibaba") && - model.api.npm !== "@ai-sdk/gateway" + model.api.npm !== "@ai-sdk/gateway" && + !usesAnthropicAutomaticCaching ) { msgs = applyCaching(msgs, model) } @@ -463,7 +501,9 @@ export function message(msgs: ModelMessage[], model: Provider.Model, options: Re if ( options.store !== true && key && - ["@ai-sdk/openai", "@ai-sdk/azure", "@ai-sdk/amazon-bedrock/mantle"].includes(model.api.npm) + ["@ai-sdk/openai", "@ai-sdk/azure", "@ai-sdk/amazon-bedrock/mantle", "@ai-sdk/github-copilot"].includes( + model.api.npm, + ) ) { msgs = mapProviderOptions(msgs, (options) => { if (!options?.[key] || !("itemId" in options[key])) return options @@ -595,18 +635,23 @@ function openaiCompatibleReasoningEfforts(id: string) { return gpt5CodexReasoningEfforts(apiId) ?? versionedGpt5ReasoningEfforts(apiId) ?? OPENAI_EFFORTS } -function anthropicOpus47OrLater(apiId: string) { - // Matches "opus-4.7" (Anthropic/Bedrock/Vertex) and "claude-4.7-opus" (SAP AI Core inverted). - // Greedy \d+ correctly extends to multi-digit majors (e.g. "claude-10.0-opus") for forward compatibility. - const version = /opus-(\d+)[.-](\d+)(?:[.@-]|$)|claude-(\d+)[.-](\d+)-opus(?:[.@-]|$)/i.exec(apiId) - if (!version) return false - const major = Number(version[1] ?? version[3]) - const minor = Number(version[2] ?? version[4]) +function anthropicUsesModernAdaptiveThinking(apiId: string) { + if (!apiId.toLowerCase().includes("claude-")) return false + // Covers family-first IDs such as claude-opus-4.7 and version-first IDs such as claude-4.7-opus. + // Limit minors to two digits so release dates in IDs such as claude-opus-4-20250514 are not versions. + const version = /claude-(?:[a-z]+-)?(\d+)(?:[.-](\d{1,2}))?(?:[.@-]|$)/i.exec(apiId) + if (!version) return true + const major = Number(version[1]) + const minor = Number(version[2] ?? 0) return major > 4 || (major === 4 && minor >= 7) } +function anthropicOpus45(apiId: string) { + return ["opus-4-5", "opus-4.5"].some((value) => apiId.includes(value)) +} + function anthropicAdaptiveEfforts(apiId: string): string[] | null { - if (anthropicOpus47OrLater(apiId) || apiId.includes("fable-5")) { + if (anthropicUsesModernAdaptiveThinking(apiId)) { return ["low", "medium", "high", "xhigh", "max"] } if ( @@ -620,7 +665,7 @@ function anthropicAdaptiveEfforts(apiId: string): string[] | null { } function anthropicOmitsThinking(apiId: string) { - return anthropicOpus47OrLater(apiId) || apiId.includes("fable-5") + return anthropicUsesModernAdaptiveThinking(apiId) } function googleThinkingLevelEfforts(apiId: string) { @@ -666,10 +711,19 @@ export function variants(model: Provider.Model): Record id.includes(name) || model.api.id.toLowerCase().includes(name), + ) if ( model.api.id.toLowerCase().includes("minimax-m3") && ["@ai-sdk/anthropic", "@ai-sdk/openai-compatible"].includes(model.api.npm) ) { + if (["nvidia", "lilac"].includes(model.providerID)) { + return { + none: { chat_template_kwargs: { thinking_mode: "disabled" } }, + thinking: { chat_template_kwargs: { thinking_mode: "enabled" } }, + } + } return { none: { thinking: { type: "disabled" } }, thinking: { thinking: { type: "adaptive" } }, @@ -677,13 +731,41 @@ export function variants(model: Provider.Model): Record [ + effort, + { thinking: { type: "adaptive", display: "summarized" }, effort }, + ]), + ) + } if ( id.includes("deepseek-chat") || id.includes("deepseek-reasoner") || id.includes("deepseek-r1") || id.includes("deepseek-v3") || id.includes("minimax") || - id.includes("glm") || + (id.includes("glm") && !glm52) || id.includes("kimi") || id.includes("k2p") || id.includes("qwen") || @@ -704,7 +786,6 @@ export function variants(model: Provider.Model): Record [ @@ -763,8 +844,8 @@ export function variants(model: Provider.Model): Record [ + effort, + { + reasoningEffort: effort, + reasoningSummary: "auto", + include: INCLUDE_ENCRYPTED_REASONING, + }, + ]), + ) + } // https://v5.ai-sdk.dev/providers/ai-sdk-providers/openai const efforts = openaiReasoningEfforts(model.api.id, model.release_date) return Object.fromEntries( @@ -896,8 +989,10 @@ export function variants(model: Provider.Model): Record model.api.id.includes(v))) { - return Object.fromEntries(WIDELY_SUPPORTED_EFFORTS.map((effort) => [effort, { effort }])) + if (anthropicOpus45(model.api.id)) { + return Object.fromEntries( + WIDELY_SUPPORTED_EFFORTS.map((effort) => [effort, anthropicOpus45Effort(model, effort)]), + ) } return { @@ -1009,7 +1104,7 @@ export function variants(model: Provider.Model): Record [ @@ -1061,14 +1156,14 @@ export function options(input: { input.model.providerID === "openai" || input.model.api.npm === "@ai-sdk/openai" || input.model.api.npm === "@ai-sdk/github-copilot" || - input.model.api.npm === "@ai-sdk/amazon-bedrock/mantle" + input.model.api.npm === "@ai-sdk/amazon-bedrock/mantle" || + input.model.api.npm === "@ai-sdk/xai" ) { result["store"] = false } if (input.model.api.npm === "@ai-sdk/azure") { result["store"] = false - result["promptCacheKey"] = input.sessionID } if (input.model.api.npm === "@openrouter/ai-sdk-provider" || input.model.api.npm === "@llmgateway/ai-sdk-provider") { @@ -1097,8 +1192,9 @@ export function options(input: { } } - if (input.model.providerID === "openai" || input.providerOptions?.setCacheKey) { - result["promptCacheKey"] = input.sessionID + if (input.model.providerID === "meta" && input.model.api.npm === "@ai-sdk/openai") { + result["reasoningSummary"] = "auto" + result["include"] = INCLUDE_ENCRYPTED_REASONING } if (input.model.api.npm === "@ai-sdk/google" || input.model.api.npm === "@ai-sdk/google-vertex") { @@ -1119,15 +1215,15 @@ export function options(input: { result["thinking"] = { type: "adaptive" } } - // Enable thinking by default for kimi models using anthropic SDK + // Moonshot's Anthropic-compatible API uses adaptive effort rather than token budgets. + // Request summaries so thinking content survives replay on subsequent turns. if ( - (input.model.api.npm === "@ai-sdk/anthropic" || input.model.api.npm === "@ai-sdk/google-vertex/anthropic") && - (modelId.includes("k2p") || modelId.includes("kimi-k2.") || modelId.includes("kimi-k2p")) + ["@ai-sdk/anthropic", "@ai-sdk/google-vertex/anthropic"].includes(input.model.api.npm) && + isKimiFamily(input.model) && + input.model.capabilities.reasoning ) { - result["thinking"] = { - type: "enabled", - budgetTokens: Math.min(16_000, Math.floor(input.model.limit.output / 2 - 1)), - } + result["thinking"] = { type: "adaptive", display: "summarized" } + result["effort"] = "high" } // Enable thinking for reasoning models on alibaba-cn (DashScope). @@ -1144,6 +1240,25 @@ export function options(input: { result["enable_thinking"] = true } + if (input.providerOptions?.setCacheKey !== false) { + if (input.model.api.npm === "@ai-sdk/deepinfra" || input.model.api.npm === "@ai-sdk/cerebras") { + result["prompt_cache_key"] = input.sessionID + } else if ( + input.model.api.npm === "@ai-sdk/openai" || + input.model.api.npm === "@ai-sdk/azure" || + input.model.api.npm === "@ai-sdk/xai" || + input.model.api.npm === "@ai-sdk/mistral" || + input.model.api.npm === "venice-ai-sdk-provider" || + input.providerOptions?.setCacheKey === true + ) { + result["promptCacheKey"] = input.sessionID + } + } + + if (input.model.api.npm === "@ai-sdk/gateway") { + result["gateway"] = { caching: "auto" } + } + if (input.model.api.npm === "@ai-sdk/azure" && input.model.api.id.includes("gpt-5.5")) { result["reasoningSummary"] = "auto" return result @@ -1176,26 +1291,13 @@ export function options(input: { result["textVerbosity"] = "low" } - if (input.model.providerID.startsWith("opencode")) { + if (input.model.providerID.startsWith("opencode") && input.providerOptions?.setCacheKey !== false) { result["promptCacheKey"] = input.sessionID result["include"] = INCLUDE_ENCRYPTED_REASONING result["reasoningSummary"] = "auto" } } - if (input.model.providerID === "venice") { - result["promptCacheKey"] = input.sessionID - } - - if (input.model.providerID === "openrouter") { - result["prompt_cache_key"] = input.sessionID - } - if (input.model.api.npm === "@ai-sdk/gateway") { - result["gateway"] = { - caching: "auto", - } - } - return result } @@ -1204,15 +1306,13 @@ export function smallOptions(model: Provider.Model) { if ( model.providerID === "openai" || model.api.npm === "@ai-sdk/openai" || - model.api.npm === "@ai-sdk/github-copilot" + model.api.npm === "@ai-sdk/github-copilot" || + model.api.npm === "@ai-sdk/xai" ) { const base = { store: false } return mergeDeep(base, small) } if (model.providerID === "openrouter" || model.providerID === "llmgateway") { - if (model.providerID === "openrouter" && small.reasoning?.effort === "low") { - return { reasoning: { effort: "none" } } - } if (Object.keys(small).length === 0 && model.api.id.includes("google")) { return { reasoning: { enabled: false } } } @@ -1233,6 +1333,16 @@ const SLUG_OVERRIDES: Record = { } export function providerOptions(model: Provider.Model, options: { [x: string]: any }) { + const usesOpenAIReasoningGate = + model.api.npm === "@ai-sdk/openai" || + model.api.npm === "@ai-sdk/azure" || + model.api.npm === "@ai-sdk/amazon-bedrock/mantle" + const normalized = + usesOpenAIReasoningGate && + (model.capabilities.reasoning || options.reasoningEffort !== undefined || options.reasoningSummary !== undefined) + ? { ...options, forceReasoning: true } + : options + if (model.api.npm === "@ai-sdk/gateway") { // Gateway providerOptions are split across two namespaces: // - `gateway`: gateway-native routing/caching controls (order, only, byok, etc.) @@ -1242,8 +1352,8 @@ export function providerOptions(model: Provider.Model, options: { [x: string]: a const i = model.api.id.indexOf("/") const rawSlug = i > 0 ? model.api.id.slice(0, i) : undefined const slug = rawSlug ? (SLUG_OVERRIDES[rawSlug] ?? rawSlug) : undefined - const gateway = options.gateway - const rest = Object.fromEntries(Object.entries(options).filter(([k]) => k !== "gateway")) + const gateway = normalized.gateway + const rest = Object.fromEntries(Object.entries(normalized).filter(([k]) => k !== "gateway")) const has = Object.keys(rest).length > 0 const result: Record = {} @@ -1277,15 +1387,105 @@ export function providerOptions(model: Provider.Model, options: { [x: string]: a // providerOptions["openai"], but OpenAIResponsesLanguageModel checks // "azure" first. Pass both so model options work on either code path. if (model.api.npm === "@ai-sdk/azure") { - return { openai: options, azure: options } + return { openai: normalized, azure: normalized } } - return { [key]: options } + return { [key]: normalized } } export function maxOutputTokens(model: Provider.Model, outputTokenMax = OUTPUT_TOKEN_MAX): number { return Math.min(model.limit.output, outputTokenMax) || outputTokenMax } +type JsonRecord = Record + +function isPlainObject(value: unknown): value is JsonRecord { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +// Mirrors Codex's Rust JSON schema compatibility lowering for OpenAI tool schemas. +function sanitizeOpenAISchema(value: unknown): unknown { + const types = ["string", "number", "boolean", "integer", "object", "array", "null"] + const compositionKeys = ["anyOf", "oneOf", "allOf"] + + // JSON Schema's boolean form (`true`/`false`) is unsupported by OpenAI tool schemas. + if (typeof value === "boolean") return { type: "string" } + if (Array.isArray(value)) return value.map(sanitizeOpenAISchema) + if (!isPlainObject(value)) return value + + const result: JsonRecord = {} + + if (typeof value.$ref === "string") result.$ref = value.$ref + if (typeof value.description === "string") result.description = value.description + if ("const" in value) result.enum = [value.const] + else if (Array.isArray(value.enum)) result.enum = value.enum + + if (isPlainObject(value.properties)) { + result.properties = Object.fromEntries( + Object.entries(value.properties).map(([key, item]) => [key, sanitizeOpenAISchema(item)]), + ) + } + + if (Array.isArray(value.required)) { + result.required = value.required.filter((item) => typeof item === "string") + } + + if ("items" in value) result.items = sanitizeOpenAISchema(value.items) + + if ("additionalProperties" in value) { + result.additionalProperties = + typeof value.additionalProperties === "boolean" + ? value.additionalProperties + : sanitizeOpenAISchema(value.additionalProperties) + } + + for (const key of compositionKeys) { + if (Array.isArray(value[key])) result[key] = value[key].map(sanitizeOpenAISchema) + } + + for (const key of ["$defs", "definitions"]) { + if (isPlainObject(value[key])) { + result[key] = Object.fromEntries( + Object.entries(value[key]).map(([name, item]) => [name, sanitizeOpenAISchema(item)]), + ) + } + } + + const schemaTypes = + typeof value.type === "string" + ? types.includes(value.type) + ? [value.type] + : [] + : Array.isArray(value.type) + ? value.type.filter((item) => typeof item === "string" && types.includes(item)) + : [] + + if (schemaTypes.length === 0 && (typeof result.$ref === "string" || compositionKeys.some((key) => key in result))) { + return result + } + + // MCP schemas may omit `type` while still using keywords that imply one. + // Keep the schema usable after unsupported keywords are dropped. + const inferredTypes = + schemaTypes.length > 0 + ? schemaTypes + : ["properties", "required", "additionalProperties"].some((key) => key in value) + ? ["object"] + : ["items", "prefixItems"].some((key) => key in value) + ? ["array"] + : "enum" in result || "format" in value + ? ["string"] + : ["minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum", "multipleOf"].some((key) => key in value) + ? ["number"] + : [] + + if (inferredTypes.length === 0) return {} + + result.type = inferredTypes.length === 1 ? inferredTypes[0] : inferredTypes + if (inferredTypes.includes("object") && !("properties" in result)) result.properties = {} + if (inferredTypes.includes("array") && !("items" in result)) result.items = { type: "string" } + return result +} + export function schema(model: Provider.Model, schema: JSONSchema7): JSONSchema7 { /* if (["openai", "azure"].includes(providerID)) { @@ -1305,6 +1505,11 @@ export function schema(model: Provider.Model, schema: JSONSchema7): JSONSchema7 } */ + if (model.api.npm === "@ai-sdk/openai" || model.api.npm === "@ai-sdk/azure") { + schema = sanitizeOpenAISchema(schema) as JSONSchema7 + // Codex also applies lossy compaction above 4 KB; defer that until OpenCode needs the same schema budget. + } + if (model.providerID === "moonshotai" || model.api.id.toLowerCase().includes("kimi")) { const sanitizeMoonshot = (obj: unknown): unknown => { if (obj === null || typeof obj !== "object") return obj @@ -1375,6 +1580,24 @@ export function schema(model: Provider.Model, schema: JSONSchema7): JSONSchema7 } } + // Gemini requires a single `type`, not a JSON Schema type array such as + // `["number","string"]` (emitted by some MCP servers). Plain `@ai-sdk/google` + // rewrites these into an `anyOf` of single-type schemas, but OpenAI-compatible + // transports (e.g. GitHub Copilot proxying to Gemini) forward them verbatim + // and the backend rejects the array form. Mirror the SDK: split non-null + // types into `anyOf`, and lift `null` into `nullable`. + if (Array.isArray(result.type)) { + const hasNull = result.type.includes("null") + const nonNull = result.type.filter((entry: unknown) => entry !== "null") + if (nonNull.length === 0) { + result.type = "null" + } else { + delete result.type + result.anyOf = nonNull.map((entry: unknown) => ({ type: entry })) + if (hasNull) result.nullable = true + } + } + // Filter required array to only include fields that exist in properties if (result.type === "object" && result.properties && Array.isArray(result.required)) { result.required = result.required.filter((field: any) => field in result.properties) @@ -1405,4 +1628,205 @@ export function schema(model: Provider.Model, schema: JSONSchema7): JSONSchema7 return schema } +export function reasoningVariants(model: ModelsDev.Model, target: Provider.Model): Provider.Model["variants"] { + const options = model.reasoning_options + if (options === undefined) return + if (options.length === 0) return {} + + const effort = options.find((option) => option.type === "effort") + if (effort) return effortVariants(target, effort.values) + + const toggle = options.some((option) => option.type === "toggle") + const budget = options.find((option) => option.type === "budget_tokens") + if (!budget) return toggle ? nonEmptyVariants(reasoningToggle(target)) : undefined + + return nonEmptyVariants({ + ...(toggle ? reasoningToggle(target) : {}), + ...budgetVariants(target, budget.min, budget.max), + }) +} + +function effortVariants(model: Provider.Model, values: readonly unknown[]) { + return Object.fromEntries( + values.flatMap((value) => { + const id = (() => { + if (value === null) return "none" + if (typeof value === "string") return value + })() + if (id === undefined) return [] + const settings = reasoningEffort(model, id) + return settings ? [[id, settings]] : [] + }), + ) +} + +function budgetVariants(model: Provider.Model, min?: number, max?: number) { + const maximum = Math.min(max ?? OUTPUT_TOKEN_MAX - 1, model.limit.output - 1, OUTPUT_TOKEN_MAX - 1) + if (maximum <= 0) return {} + const high = Math.min(Math.max(min ?? 0, Math.floor((maximum + 1) / 2)), maximum) + return Object.fromEntries( + [ + { id: "high", budget: high }, + { id: "max", budget: maximum }, + ].flatMap((item) => { + const settings = reasoningBudget(model, item.budget) + return settings ? [[item.id, settings]] : [] + }), + ) +} + +function nonEmptyVariants(variants: NonNullable): Provider.Model["variants"] { + return Object.keys(variants).length > 0 ? variants : undefined +} + +function reasoningToggle(model: Provider.Model): NonNullable { + if (model.api.npm === "@ai-sdk/alibaba") + return { + none: { enableThinking: false }, + high: { enableThinking: true }, + } + if (model.api.npm === "@ai-sdk/cohere") + return { + none: { thinking: { type: "disabled" } }, + high: { thinking: { type: "enabled" } }, + } + return {} +} + +function reasoningEffort(model: Provider.Model, effort: string) { + switch (model.api.npm) { + case "@openrouter/ai-sdk-provider": + return { reasoning: { effort } } + case "@ai-sdk/anthropic": + case "@ai-sdk/google-vertex/anthropic": + return anthropicEffort(model, effort) ?? { effort } + case "@ai-sdk/google": + case "@ai-sdk/google-vertex": + return { thinkingConfig: { includeThoughts: true, thinkingLevel: effort } } + case "@ai-sdk/amazon-bedrock": + if (anthropicAdaptiveEfforts(model.api.id)) + return { + reasoningConfig: { + type: "adaptive", + maxReasoningEffort: effort, + ...(anthropicOmitsThinking(model.api.id) ? { display: "summarized" } : {}), + }, + } + if (anthropicOpus45(model.api.id)) + return { + reasoningConfig: { + type: "enabled", + budgetTokens: Math.min(16_000, Math.floor(model.limit.output / 2 - 1)), + maxReasoningEffort: effort, + }, + } + if (model.api.id.includes("anthropic")) return + return { reasoningConfig: { type: "enabled", maxReasoningEffort: effort } } + case "@ai-sdk/gateway": + if (model.id.includes("anthropic")) return { thinking: { type: "adaptive", display: "summarized" }, effort } + if (model.id.includes("google")) return { thinkingConfig: { includeThoughts: true, thinkingLevel: effort } } + return { reasoningEffort: effort } + case "@ai-sdk/github-copilot": + // OAuth discovery replaces these with variants from Copilot's /models capabilities. + if (model.id.includes("gemini")) return + if (model.id.includes("claude")) return { reasoningEffort: effort } + return { reasoningEffort: effort, reasoningSummary: "auto", include: INCLUDE_ENCRYPTED_REASONING } + case "@ai-sdk/openai": + case "@ai-sdk/amazon-bedrock/mantle": + return { reasoningEffort: effort, reasoningSummary: "auto", include: INCLUDE_ENCRYPTED_REASONING } + case "@ai-sdk/azure": + return { reasoningEffort: effort, reasoningSummary: "auto", include: INCLUDE_ENCRYPTED_REASONING } + case "@jerome-benoit/sap-ai-provider-v2": + if (model.id.includes("anthropic")) + return { modelParams: { thinking: { type: "adaptive", display: "summarized" }, output_config: { effort } } } + return { modelParams: { reasoning_effort: effort } } + case "@ai-sdk/openai-compatible": + case "@ai-sdk/xai": + case "@ai-sdk/mistral": + case "@ai-sdk/groq": + case "@ai-sdk/cerebras": + case "@ai-sdk/deepinfra": + case "@ai-sdk/togetherai": + case "venice-ai-sdk-provider": + case "ai-gateway-provider": + return { reasoningEffort: effort } + case "@ai-sdk/cohere": + case "@ai-sdk/perplexity": + case "@ai-sdk/vercel": + case "@ai-sdk/alibaba": + case "gitlab-ai-provider": + return + } +} + +function anthropicEffort(model: Provider.Model, effort: string) { + if (anthropicOpus45(model.api.id)) return anthropicOpus45Effort(model, effort) + // Kimi defaults to omitting adaptive thinking text unless summarized display is requested. + if (isKimiFamily(model)) return { thinking: { type: "adaptive", display: "summarized" }, effort } + if (!anthropicAdaptiveEfforts(model.api.id)) return + return { + thinking: { + type: "adaptive", + ...(anthropicOmitsThinking(model.api.id) ? { display: "summarized" } : {}), + }, + effort, + } +} + +function anthropicOpus45Effort(model: Provider.Model, effort: string) { + return { + thinking: { + type: "enabled", + budgetTokens: Math.min(16_000, Math.floor(model.limit.output / 2 - 1)), + }, + effort, + } +} + +function reasoningBudget(model: Provider.Model, budget: number) { + switch (model.api.npm) { + case "@openrouter/ai-sdk-provider": + return { reasoning: { max_tokens: budget } } + case "@ai-sdk/anthropic": + case "@ai-sdk/google-vertex/anthropic": + return { thinking: { type: "enabled", budgetTokens: budget } } + case "@ai-sdk/google": + case "@ai-sdk/google-vertex": + return { thinkingConfig: { includeThoughts: true, thinkingBudget: budget } } + case "@ai-sdk/amazon-bedrock": + return { reasoningConfig: { type: "enabled", budgetTokens: budget } } + case "@ai-sdk/gateway": + if (model.id.includes("anthropic")) return { thinking: { type: "enabled", budgetTokens: budget } } + if (model.id.includes("google")) return { thinkingConfig: { includeThoughts: true, thinkingBudget: budget } } + return + case "@ai-sdk/cohere": + return { thinking: { type: "enabled", tokenBudget: budget } } + case "@ai-sdk/alibaba": + return { enableThinking: true, thinkingBudget: budget } + case "@jerome-benoit/sap-ai-provider-v2": + if (model.id.includes("anthropic")) + return { modelParams: { thinking: { type: "enabled", budget_tokens: budget } } } + if (model.id.includes("gemini")) + return { modelParams: { thinkingConfig: { includeThoughts: true, thinkingBudget: budget } } } + return + case "@ai-sdk/amazon-bedrock/mantle": + case "@ai-sdk/azure": + case "@ai-sdk/cerebras": + case "@ai-sdk/deepinfra": + case "@ai-sdk/github-copilot": + case "@ai-sdk/groq": + case "@ai-sdk/mistral": + case "@ai-sdk/openai": + case "@ai-sdk/openai-compatible": + case "@ai-sdk/perplexity": + case "@ai-sdk/togetherai": + case "@ai-sdk/vercel": + case "@ai-sdk/xai": + case "ai-gateway-provider": + case "gitlab-ai-provider": + case "venice-ai-sdk-provider": + return + } +} + export * as ProviderTransform from "./transform" diff --git a/packages/opencode/src/session/llm/request.ts b/packages/opencode/src/session/llm/request.ts index 2ca390a967..24c8bbec2a 100644 --- a/packages/opencode/src/session/llm/request.ts +++ b/packages/opencode/src/session/llm/request.ts @@ -154,6 +154,16 @@ export const prepare = Effect.fn("LLMRequestPrep.prepare")(function* (input: Pre ) const tools = resolveTools(input) + // Codex parity: OpenAI Responses-family providers hardcode `strict: false` + // on every function tool so MCP-sourced and dynamic schemas that don't + // satisfy OpenAI's structured-outputs constraints still register. + if ( + input.model.api.npm === "@ai-sdk/openai" || + input.model.api.npm === "@ai-sdk/azure" || + input.model.api.npm === "@ai-sdk/amazon-bedrock/mantle" + ) { + for (const key of Object.keys(tools)) tools[key] = { ...tools[key], strict: false } + } if ( input.model.providerID.includes("github-copilot") && Object.keys(tools).length === 0 && diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index c3c8cbf817..93e166c4a8 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -4,6 +4,8 @@ import { ProviderTransform } from "@/provider/transform" import { LLMRequestPrep } from "@/session/llm/request" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" +import { ModelsDev } from "@opencode-ai/core/models-dev" +import { jsonSchema } from "ai" describe("ProviderTransform.options - setCacheKey", () => { const sessionID = "test-session-123" @@ -72,7 +74,7 @@ describe("ProviderTransform.options - setCacheKey", () => { expect(result.promptCacheKey).toBeUndefined() }) - test("should set promptCacheKey for openai provider regardless of setCacheKey", () => { + test("should set promptCacheKey for openai provider by default", () => { const openaiModel = { ...mockModel, providerID: "openai", @@ -86,6 +88,82 @@ describe("ProviderTransform.options - setCacheKey", () => { expect(result.promptCacheKey).toBe(sessionID) }) + test("should set promptCacheKey for the OpenAI SDK regardless of provider ID", () => { + const result = ProviderTransform.options({ + model: { + ...mockModel, + providerID: "custom-openai", + api: { id: "gpt-5", url: "https://example.com", npm: "@ai-sdk/openai" }, + }, + sessionID, + providerOptions: {}, + }) + expect(result.promptCacheKey).toBe(sessionID) + }) + + test("should not set promptCacheKey for the OpenAI-compatible SDK by provider name", () => { + const result = ProviderTransform.options({ + model: { + ...mockModel, + providerID: "openai", + api: { id: "gpt-5", url: "https://example.com", npm: "@ai-sdk/openai-compatible" }, + }, + sessionID, + providerOptions: {}, + }) + expect(result.promptCacheKey).toBeUndefined() + }) + + test("should not set promptCacheKey for openai when explicitly disabled", () => { + const openaiModel = { + ...mockModel, + providerID: "openai", + api: { + id: "gpt-4", + url: "https://api.openai.com", + npm: "@ai-sdk/openai", + }, + } + const result = ProviderTransform.options({ + model: openaiModel, + sessionID, + providerOptions: { setCacheKey: false }, + }) + expect(result.promptCacheKey).toBeUndefined() + }) + + test("should set promptCacheKey for the xAI SDK by default regardless of provider ID", () => { + const xaiModel = { + ...mockModel, + providerID: "custom-xai", + api: { + id: "grok-4", + url: "https://api.x.ai", + npm: "@ai-sdk/xai", + }, + } + const result = ProviderTransform.options({ model: xaiModel, sessionID, providerOptions: {} }) + expect(result.promptCacheKey).toBe(sessionID) + }) + + test("should not set promptCacheKey for the xAI SDK when explicitly disabled", () => { + const xaiModel = { + ...mockModel, + providerID: "xai", + api: { + id: "grok-4", + url: "https://api.x.ai", + npm: "@ai-sdk/xai", + }, + } + const result = ProviderTransform.options({ + model: xaiModel, + sessionID, + providerOptions: { setCacheKey: false }, + }) + expect(result.promptCacheKey).toBeUndefined() + }) + test("should set store=false for openai provider", () => { const openaiModel = { ...mockModel, @@ -104,6 +182,43 @@ describe("ProviderTransform.options - setCacheKey", () => { expect(result.store).toBe(false) }) + test("should set store=false for xAI provider by default", () => { + const xaiModel = { + ...mockModel, + providerID: "xai", + api: { + id: "grok-4", + url: "https://api.x.ai", + npm: "@ai-sdk/xai", + }, + } + const result = ProviderTransform.options({ + model: xaiModel, + sessionID, + providerOptions: {}, + }) + expect(result.store).toBe(false) + expect(result.promptCacheKey).toBe(sessionID) + }) + + test("should set store=false for xAI SDK regardless of provider ID", () => { + const xaiModel = { + ...mockModel, + providerID: "custom-xai", + api: { + id: "grok-4", + url: "https://api.x.ai", + npm: "@ai-sdk/xai", + }, + } + const result = ProviderTransform.options({ + model: xaiModel, + sessionID, + providerOptions: {}, + }) + expect(result.store).toBe(false) + }) + test("should set store=false for azure provider by default", () => { const azureModel = { ...mockModel, @@ -120,6 +235,70 @@ describe("ProviderTransform.options - setCacheKey", () => { providerOptions: {}, }) expect(result.store).toBe(false) + expect(result.promptCacheKey).toBe(sessionID) + }) + + test("should disable the Azure cache key without disabling store=false", () => { + const result = ProviderTransform.options({ + model: { + ...mockModel, + providerID: "azure", + api: { id: "gpt-5", url: "https://azure.com", npm: "@ai-sdk/azure" }, + }, + sessionID, + providerOptions: { setCacheKey: false }, + }) + expect(result.store).toBe(false) + expect(result.promptCacheKey).toBeUndefined() + }) + + test("should keep the Azure cache key for gpt-5.5 early return", () => { + const result = ProviderTransform.options({ + model: { + ...mockModel, + providerID: "azure", + api: { id: "gpt-5.5", url: "https://azure.com", npm: "@ai-sdk/azure" }, + }, + sessionID, + providerOptions: {}, + }) + expect(result.store).toBe(false) + expect(result.reasoningSummary).toBe("auto") + expect(result.promptCacheKey).toBe(sessionID) + }) + + for (const npm of ["@ai-sdk/deepinfra", "@ai-sdk/cerebras"]) { + test(`should set the snake-case cache key for ${npm}`, () => { + const result = ProviderTransform.options({ + model: { ...mockModel, providerID: "custom", api: { ...mockModel.api, npm } }, + sessionID, + providerOptions: {}, + }) + expect(result.prompt_cache_key).toBe(sessionID) + expect(result.promptCacheKey).toBeUndefined() + }) + } + + test("should set promptCacheKey for the Mistral SDK", () => { + const result = ProviderTransform.options({ + model: { ...mockModel, providerID: "custom", api: { ...mockModel.api, npm: "@ai-sdk/mistral" } }, + sessionID, + providerOptions: {}, + }) + expect(result.promptCacheKey).toBe(sessionID) + }) + + test("should not send an undocumented OpenRouter prompt_cache_key", () => { + const result = ProviderTransform.options({ + model: { + ...mockModel, + providerID: "openrouter", + api: { ...mockModel.api, npm: "@openrouter/ai-sdk-provider" }, + }, + sessionID, + providerOptions: {}, + }) + expect(result.prompt_cache_key).toBeUndefined() }) }) @@ -384,7 +563,12 @@ describe("ProviderTransform.options - gpt-5 textVerbosity", () => { } as any, system: [], messages: [{ role: "user", content: "Hello" }], - tools: {}, + tools: { + lookup: { + description: "Look up a value", + inputSchema: jsonSchema({ type: "object", properties: {} }), + }, + }, provider: { id: "azure", options: { useCompletionUrls: true } } as any, auth: undefined, plugin: { @@ -399,6 +583,7 @@ describe("ProviderTransform.options - gpt-5 textVerbosity", () => { expect(result.params.options.reasoningEffort).toBe("high") expect(result.params.options.reasoningSummary).toBeUndefined() expect(result.params.options.include).toBeUndefined() + expect(result.tools.lookup.strict).toBe(false) }) test("gpt-5.1 should have textVerbosity set to low", () => { @@ -606,6 +791,95 @@ describe("ProviderTransform.providerOptions", () => { }) }) + test("forces reasoning for custom OpenAI package models with explicit effort", () => { + const model = createModel({ + providerID: "meta", + api: { + id: "muse-spark", + url: "https://api.ai.meta.com/v1", + npm: "@ai-sdk/openai", + }, + }) + + expect(ProviderTransform.providerOptions(model, { reasoningEffort: "xhigh", reasoningSummary: "auto" })).toEqual({ + openai: { forceReasoning: true, reasoningEffort: "xhigh", reasoningSummary: "auto" }, + }) + }) + + test("forces reasoning for OpenAI package models marked reasoning-capable", () => { + expect(ProviderTransform.providerOptions(createModel(), { store: false })).toEqual({ + openai: { forceReasoning: true, store: false }, + }) + }) + + test("uses canonical sdk key for custom xAI models", () => { + const model = createModel({ + providerID: "my-xai", + api: { id: "grok-4", url: "https://api.x.ai", npm: "@ai-sdk/xai" }, + }) + + expect(ProviderTransform.providerOptions(model, { promptCacheKey: "session" })).toEqual({ + xai: { promptCacheKey: "session" }, + }) + }) + + test("forces reasoning for explicit effort even when model is not marked reasoning-capable", () => { + const model = createModel({ + capabilities: { + temperature: true, + reasoning: false, + attachment: true, + toolcall: true, + input: { text: true, audio: false, image: true, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + }) + + expect(ProviderTransform.providerOptions(model, { reasoningEffort: "xhigh" })).toEqual({ + openai: { forceReasoning: true, reasoningEffort: "xhigh" }, + }) + }) + + test("forces reasoning for Azure OpenAI models with explicit effort", () => { + const model = createModel({ + providerID: "azure", + api: { + id: "custom-gpt-5-deployment", + url: "https://azure.openai.example.com/openai/v1", + npm: "@ai-sdk/azure", + }, + }) + + expect(ProviderTransform.providerOptions(model, { reasoningEffort: "xhigh" })).toEqual({ + openai: { forceReasoning: true, reasoningEffort: "xhigh" }, + azure: { forceReasoning: true, reasoningEffort: "xhigh" }, + }) + }) + + test("forces reasoning for Bedrock Mantle OpenAI models with explicit effort", () => { + const model = createModel({ + providerID: "amazon-bedrock", + api: { + id: "openai.gpt-5-custom", + url: "https://bedrock-mantle.us-east-2.api.aws/openai/v1", + npm: "@ai-sdk/amazon-bedrock/mantle", + }, + }) + + expect(ProviderTransform.providerOptions(model, { reasoningEffort: "xhigh" })).toEqual({ + openai: { forceReasoning: true, reasoningEffort: "xhigh" }, + }) + }) + + test("overrides forceReasoning false when reasoning should be forced", () => { + expect( + ProviderTransform.providerOptions(createModel(), { forceReasoning: false, reasoningEffort: "xhigh" }), + ).toEqual({ + openai: { forceReasoning: true, reasoningEffort: "xhigh" }, + }) + }) + test("uses gateway model provider slug for gateway models", () => { const model = createModel({ providerID: "vercel", @@ -700,7 +974,7 @@ describe("ProviderTransform.providerOptions", () => { }) expect(ProviderTransform.providerOptions(model, { reasoningEffort: "medium" })).toEqual({ - openai: { reasoningEffort: "medium" }, + openai: { forceReasoning: true, reasoningEffort: "medium" }, }) }) @@ -857,6 +1131,93 @@ describe("ProviderTransform.schema - gemini nested array items", () => { }) }) +describe("ProviderTransform.schema - gemini type arrays", () => { + // Mirrors @ai-sdk/google's convertJSONSchemaToOpenAPISchema: JSON Schema type + // arrays (e.g. `["number","string"]`, common in MCP tool schemas) become an + // `anyOf` of single-type schemas, with `null` lifted into `nullable`. Plain + // @ai-sdk/google rewrites these, but OpenAI-compatible transports such as + // GitHub Copilot (proxying to Gemini) forward them verbatim and the backend + // rejects the array form. + const geminiModel = { + providerID: "google", + api: { + id: "gemini-3-pro", + }, + } as any + + test("splits a multi-type array into anyOf and drops the type array", () => { + const schema = { + type: "object", + properties: { + status: { type: ["number", "string"], description: "status filter" }, + }, + } as any + + const result = ProviderTransform.schema(geminiModel, schema) as any + + expect(result.properties.status.type).toBeUndefined() + expect(result.properties.status.anyOf).toEqual([{ type: "number" }, { type: "string" }]) + expect(result.properties.status.nullable).toBeUndefined() + // Sibling keywords stay alongside the generated anyOf. + expect(result.properties.status.description).toBe("status filter") + }) + + test("lifts null into nullable for a nullable type array", () => { + const schema = { + type: "object", + properties: { + maybe: { type: ["string", "null"], description: "nullable string" }, + }, + } as any + + const result = ProviderTransform.schema(geminiModel, schema) as any + + expect(result.properties.maybe.type).toBeUndefined() + expect(result.properties.maybe.anyOf).toEqual([{ type: "string" }]) + expect(result.properties.maybe.nullable).toBe(true) + }) + + test("collapses an all-null type array to type null", () => { + const schema = { + type: "object", + properties: { + nothing: { type: ["null"] }, + }, + } as any + + const result = ProviderTransform.schema(geminiModel, schema) as any + + expect(result.properties.nothing.type).toBe("null") + expect(result.properties.nothing.anyOf).toBeUndefined() + }) + + test("rewrites type arrays for gemini served through github-copilot", () => { + const copilotGeminiModel = { + providerID: "github-copilot", + api: { + id: "gemini-3.5-flash", + npm: "@ai-sdk/github-copilot", + }, + } as any + + const schema = { + type: "object", + properties: { + hook_id: { type: "number", description: "ID of the webhook" }, + status: { type: ["number", "string"], description: "Filter by response status code" }, + }, + required: ["hook_id"], + additionalProperties: false, + } as any + + const result = ProviderTransform.schema(copilotGeminiModel, schema) as any + + expect(result.properties.status.anyOf).toEqual([{ type: "number" }, { type: "string" }]) + expect(result.properties.status.type).toBeUndefined() + expect(result.properties.hook_id.type).toBe("number") + }) +}) + describe("ProviderTransform.schema - gemini combiner nodes", () => { const geminiModel = { providerID: "google", @@ -1067,33 +1428,234 @@ describe("ProviderTransform.schema - gemini non-object properties removal", () = }) }) -describe("ProviderTransform.schema - moonshot $ref siblings", () => { - const moonshotModel = { - providerID: "moonshotai", +describe("ProviderTransform.schema - openai supported schema subset", () => { + const openaiModel = { + providerID: "openai", api: { - id: "kimi-k2", + id: "gpt-4.1", + npm: "@ai-sdk/openai", }, } as any - test("removes sibling descriptions from referenced tool parameter schemas", () => { - const schema = { + test("removes unsupported JSON Schema keywords recursively", () => { + const result = ProviderTransform.schema(openaiModel, { + $schema: "https://json-schema.org/draft/2020-12/schema", + title: "Search", type: "object", properties: { - deviceType: { - description: "Optional. The type of device that captured the screenshot, e.g. mobile or desktop.", - enum: ["DEVICE_TYPE_UNSPECIFIED", "MOBILE", "DESKTOP", "TABLET", "AGNOSTIC"], - type: "string", - }, - modelId: { - description: "Optional. The model to use for generation.", - enum: ["MODEL_ID_UNSPECIFIED", "GEMINI_3_PRO", "GEMINI_3_FLASH", "GEMINI_3_1_PRO"], + query: { type: "string", + description: "Search query", + format: "uri", + pattern: "^https://", + minLength: 1, + maxLength: 100, + default: "https://example.com", + }, + count: { + type: "integer", + minimum: 1, + maximum: 10, + multipleOf: 1, + }, + createdAt: { + format: "date-time", + }, + mode: { + const: "fast", + }, + tags: { + type: "array", + minItems: 1, + maxItems: 3, + uniqueItems: true, }, - projectId: { - description: "Required. The project ID of screens to generate variants for.", - type: "string", + tuple: { + type: "array", + items: [ + { type: "number", minimum: 0 }, + { type: "string", pattern: "^ok$" }, + ], }, - prompt: { + metadata: { + type: "object", + patternProperties: { + "^x-": { type: "string" }, + }, + additionalProperties: { + type: "string", + pattern: "^safe$", + }, + }, + }, + patternProperties: { + "^extra": { type: "string" }, + }, + required: ["query"], + additionalProperties: false, + } as any) as any + + expect(result).toEqual({ + type: "object", + properties: { + query: { + type: "string", + description: "Search query", + }, + count: { + type: "integer", + }, + createdAt: { + type: "string", + }, + mode: { + enum: ["fast"], + type: "string", + }, + tags: { + type: "array", + items: { type: "string" }, + }, + tuple: { + type: "array", + items: [{ type: "number" }, { type: "string" }], + }, + metadata: { + type: "object", + properties: {}, + additionalProperties: { + type: "string", + }, + }, + }, + required: ["query"], + additionalProperties: false, + }) + }) + + test("keeps local references and sanitizes definitions", () => { + const result = ProviderTransform.schema(openaiModel, { + type: "object", + properties: { + value: { + $ref: "#/$defs/Value", + description: "Referenced value", + examples: ["ignored"], + }, + }, + $defs: { + Value: { + type: "string", + pattern: "^value$", + description: "Definition description", + }, + Unused: { + type: "number", + minimum: 0, + }, + }, + } as any) as any + + expect(result.properties.value).toEqual({ + $ref: "#/$defs/Value", + description: "Referenced value", + }) + expect(result.$defs).toEqual({ + Value: { + type: "string", + description: "Definition description", + }, + Unused: { + type: "number", + }, + }) + }) + + test("does not sanitize non-openai providers", () => { + const result = ProviderTransform.schema( + { + providerID: "anthropic", + api: { + id: "claude-sonnet-4", + npm: "@ai-sdk/anthropic", + }, + } as any, + { + type: "object", + properties: { + query: { + type: "string", + pattern: "^https://", + }, + }, + } as any, + ) as any + + expect(result.properties.query.pattern).toBe("^https://") + }) + + test.each([ + ["opencode", "@ai-sdk/openai"], + ["custom-openai-compatible", "@ai-sdk/openai"], + ["azure", "@ai-sdk/azure"], + ])("sanitizes %s models using %s", (providerID, npm) => { + expect( + ProviderTransform.schema( + { + providerID, + api: { + id: "custom-model", + npm, + }, + } as any, + { + type: "object", + properties: { + query: { + type: "string", + pattern: "^https://", + }, + }, + } as any, + ), + ).toEqual({ + type: "object", + properties: { + query: { + type: "string", + }, + }, + }) + }) +}) + +describe("ProviderTransform.schema - moonshot $ref siblings", () => { + const moonshotModel = { + providerID: "moonshotai", + api: { + id: "kimi-k2", + }, + } as any + + test("removes sibling descriptions from referenced tool parameter schemas", () => { + const schema = { + type: "object", + properties: { + deviceType: { + description: "Optional. The type of device that captured the screenshot, e.g. mobile or desktop.", + enum: ["DEVICE_TYPE_UNSPECIFIED", "MOBILE", "DESKTOP", "TABLET", "AGNOSTIC"], + type: "string", + }, + modelId: { + description: "Optional. The model to use for generation.", + enum: ["MODEL_ID_UNSPECIFIED", "GEMINI_3_PRO", "GEMINI_3_FLASH", "GEMINI_3_1_PRO"], + type: "string", + }, + projectId: { + description: "Required. The project ID of screens to generate variants for.", + type: "string", + }, + prompt: { description: "Required. The input text used to generate the variants.", type: "string", }, @@ -1211,6 +1773,55 @@ describe("ProviderTransform.schema - moonshot $ref siblings", () => { }) }) +describe("ProviderTransform.message - Mistral tool call IDs", () => { + test.each(["codestral-latest", "pixtral-large-latest", "open-mixtral-8x22b"])( + "normalizes IDs for custom OpenAI-compatible %s models", + (id) => { + const result = ProviderTransform.message( + [ + { + role: "assistant", + content: [ + { + type: "tool-call", + toolCallId: "toolu_01CBhTTz95qkd9LJMdC9sf8t", + toolName: "read", + input: { filePath: "/tmp/test" }, + }, + ], + }, + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "toolu_01CBhTTz95qkd9LJMdC9sf8t", + toolName: "read", + output: { type: "text", value: "test" }, + }, + ], + }, + ] as any, + { + id: `custom/${id}`, + providerID: "custom", + api: { + id, + url: "https://example.com/v1", + npm: "@ai-sdk/openai-compatible", + }, + } as any, + {}, + ) + + expect(result).toMatchObject([ + { role: "assistant", content: [{ type: "tool-call", toolCallId: "toolu01CB" }] }, + { role: "tool", content: [{ type: "tool-result", toolCallId: "toolu01CB" }] }, + ]) + }, + ) +}) + describe("ProviderTransform.message - DeepSeek reasoning content", () => { test("DeepSeek with tool calls includes reasoning_content in providerOptions", () => { const msgs = [ @@ -1973,6 +2584,82 @@ describe("ProviderTransform.message - strip openai metadata when store=false", ( expect(result[0].content[0].providerOptions?.openai?.reasoningEncryptedContent).toBe("encrypted") }) + test("strips GitHub Copilot itemId from the copilot namespace, preserving other copilot options", () => { + const copilotModel = { + ...openaiModel, + id: "github-copilot/gpt-5.5", + providerID: "github-copilot", + api: { + id: "gpt-5.5", + url: "https://api.githubcopilot.com", + npm: "@ai-sdk/github-copilot", + }, + } + const msgs = [ + { + role: "assistant", + content: [ + { + type: "reasoning", + text: "thinking...", + providerOptions: { + copilot: { itemId: "rs_123", reasoningEncryptedContent: "encrypted" }, + }, + }, + { + // The stale itemId on tool-call parts is what Copilot echoes back as the + // `function_call` item `id`, which is what the upstream connection rejects. + type: "tool-call", + toolCallId: "call_1", + toolName: "bash", + input: { command: "ls" }, + providerOptions: { + copilot: { itemId: "fc_456", reasoningEffort: "medium" }, + }, + }, + ], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, copilotModel, { store: false }) as any[] + + expect(result[0].content[0].providerOptions?.copilot?.itemId).toBeUndefined() + expect(result[0].content[0].providerOptions?.copilot?.reasoningEncryptedContent).toBe("encrypted") + expect(result[0].content[1].providerOptions?.copilot?.itemId).toBeUndefined() + expect(result[0].content[1].providerOptions?.copilot?.reasoningEffort).toBe("medium") + }) + + test("leaves a stray openai namespace on a Copilot model untouched, since Copilot's Responses model only reads the copilot namespace", () => { + const copilotModel = { + ...openaiModel, + id: "github-copilot/gpt-5.5", + providerID: "github-copilot", + api: { + id: "gpt-5.5", + url: "https://api.githubcopilot.com", + npm: "@ai-sdk/github-copilot", + }, + } + const msgs = [ + { + role: "assistant", + content: [ + { + type: "text", + text: "Hello", + providerOptions: { + openai: { itemId: "msg_456" }, + }, + }, + ], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, copilotModel, { store: false }) as any[] + + expect(result[0].content[0].providerOptions?.openai?.itemId).toBe("msg_456") + }) + test("preserves metadata for openai package when store is true", () => { const msgs = [ { @@ -2425,6 +3112,20 @@ describe("ProviderTransform.message - cache control on gateway", () => { }) }) + test("does not add explicit breakpoints when Anthropic automatic caching is enabled", () => { + const model = createModel({ + providerID: "anthropic", + api: { id: "claude-sonnet-4", url: "https://api.anthropic.com", npm: "@ai-sdk/anthropic" }, + }) + const msgs = [ + { role: "system", content: "You are a helpful assistant" }, + { role: "user", content: "Hello" }, + ] as any[] + + const result = ProviderTransform.message(msgs, model, { cacheControl: { type: "ephemeral" } }) as any[] + expect(result.every((message) => message.providerOptions === undefined)).toBe(true) + }) + test("google-vertex-anthropic applies cache control", () => { const model = createModel({ providerID: "google-vertex-anthropic", @@ -2489,6 +3190,333 @@ describe("ProviderTransform.temperature - Cohere North", () => { }) }) +describe("ProviderTransform.reasoningVariants", () => { + const model = (reasoning_options: ModelsDev.Model["reasoning_options"]) => ({ reasoning_options }) as ModelsDev.Model + const target = (npm: string, id = "test-model") => + ({ + id, + providerID: "test", + api: { id, npm, url: "" }, + capabilities: { reasoning: true }, + limit: { output: 64_000 }, + }) as any + + test("respects explicitly empty reasoning options", () => { + expect(ProviderTransform.reasoningVariants(model([]), target("@ai-sdk/openai"))).toEqual({}) + }) + + test.each([ + ["@openrouter/ai-sdk-provider", { reasoning: { effort: "high" } }], + ["@ai-sdk/anthropic", { thinking: { type: "adaptive" }, effort: "high" }, "claude-opus-4-6"], + [ + "@ai-sdk/google-vertex/anthropic", + { thinking: { type: "adaptive", display: "summarized" }, effort: "high" }, + "claude-opus-4-7", + ], + ["@ai-sdk/anthropic", { thinking: { type: "adaptive", display: "summarized" }, effort: "high" }, "claude-opus-5"], + ["@ai-sdk/google", { thinkingConfig: { includeThoughts: true, thinkingLevel: "high" } }], + ["@ai-sdk/google-vertex", { thinkingConfig: { includeThoughts: true, thinkingLevel: "high" } }], + [ + "@ai-sdk/azure", + { + reasoningEffort: "high", + reasoningSummary: "auto", + include: ["reasoning.encrypted_content"], + }, + ], + [ + "@ai-sdk/openai", + { + reasoningEffort: "high", + reasoningSummary: "auto", + include: ["reasoning.encrypted_content"], + }, + ], + [ + "@ai-sdk/amazon-bedrock/mantle", + { + reasoningEffort: "high", + reasoningSummary: "auto", + include: ["reasoning.encrypted_content"], + }, + ], + [ + "@ai-sdk/github-copilot", + { + reasoningEffort: "high", + reasoningSummary: "auto", + include: ["reasoning.encrypted_content"], + }, + ], + ["@ai-sdk/openai-compatible", { reasoningEffort: "high" }], + ["@ai-sdk/xai", { reasoningEffort: "high" }], + ["@ai-sdk/mistral", { reasoningEffort: "high" }], + ["@ai-sdk/groq", { reasoningEffort: "high" }], + ["@ai-sdk/cerebras", { reasoningEffort: "high" }], + ["@ai-sdk/deepinfra", { reasoningEffort: "high" }], + ["@ai-sdk/togetherai", { reasoningEffort: "high" }], + ["venice-ai-sdk-provider", { reasoningEffort: "high" }], + ["ai-gateway-provider", { reasoningEffort: "high" }], + ["@ai-sdk/amazon-bedrock", { reasoningConfig: { type: "enabled", maxReasoningEffort: "high" } }], + ])("converts effort for %s", (npm, expected, ...args) => { + const id = args[0] as string | undefined + expect(ProviderTransform.reasoningVariants(model([{ type: "effort", values: ["high"] }]), target(npm, id))).toEqual( + { high: expected }, + ) + }) + + test("combines effort with extended thinking for Claude Opus 4.5", () => { + expect( + ProviderTransform.reasoningVariants( + model([{ type: "effort", values: ["high"] }]), + target("@ai-sdk/anthropic", "claude-opus-4-5"), + ), + ).toEqual({ + high: { + thinking: { type: "enabled", budgetTokens: 16_000 }, + effort: "high", + }, + }) + }) + + test("uses explicit effort metadata for Anthropic-compatible models", () => { + expect( + ProviderTransform.reasoningVariants( + model([{ type: "effort", values: ["high"] }]), + target("@ai-sdk/anthropic", "claude-sonnet-4"), + ), + ).toEqual({ high: { effort: "high" } }) + + expect( + ProviderTransform.reasoningVariants( + model([{ type: "effort", values: ["max"] }]), + target("@ai-sdk/anthropic", "k3"), + ), + ).toEqual({ max: { effort: "max" } }) + }) + + test("maps Kimi effort metadata to adaptive thinking", () => { + expect( + ProviderTransform.reasoningVariants( + model([{ type: "effort", values: ["low", "high", "max"] }]), + target("@ai-sdk/anthropic", "kimi-k3"), + ), + ).toEqual({ + low: { thinking: { type: "adaptive", display: "summarized" }, effort: "low" }, + high: { thinking: { type: "adaptive", display: "summarized" }, effort: "high" }, + max: { thinking: { type: "adaptive", display: "summarized" }, effort: "max" }, + }) + }) + + test("uses adaptive reasoning config for Anthropic models on Bedrock", () => { + expect( + ProviderTransform.reasoningVariants( + model([{ type: "effort", values: ["high"] }]), + target("@ai-sdk/amazon-bedrock", "anthropic.claude-opus-4-7-v1:0"), + ), + ).toEqual({ + high: { + reasoningConfig: { + type: "adaptive", + maxReasoningEffort: "high", + display: "summarized", + }, + }, + }) + }) + + test("uses adaptive reasoning config for Claude Opus 5 on Bedrock", () => { + const result = ProviderTransform.reasoningVariants( + model([{ type: "effort", values: ["low", "medium", "high", "xhigh", "max"] }]), + target("@ai-sdk/amazon-bedrock", "us.anthropic.claude-opus-5"), + ) + expect(Object.keys(result ?? {})).toEqual(["low", "medium", "high", "xhigh", "max"]) + expect(result?.high).toEqual({ + reasoningConfig: { + type: "adaptive", + maxReasoningEffort: "high", + display: "summarized", + }, + }) + }) + + test("combines effort with extended thinking for Claude Opus 4.5 on Bedrock", () => { + expect( + ProviderTransform.reasoningVariants( + model([{ type: "effort", values: ["high"] }]), + target("@ai-sdk/amazon-bedrock", "us.anthropic.claude-opus-4-5-20251101-v1:0"), + ), + ).toEqual({ + high: { + reasoningConfig: { + type: "enabled", + budgetTokens: 16_000, + maxReasoningEffort: "high", + }, + }, + }) + }) + + test("does not replace unsupported Anthropic Bedrock effort options with token budgets", () => { + expect( + ProviderTransform.reasoningVariants( + model([{ type: "effort", values: ["high"] }]), + target("@ai-sdk/amazon-bedrock", "anthropic.claude-sonnet-4-v1:0"), + ), + ).toEqual({}) + }) + + test.each([ + ["@openrouter/ai-sdk-provider", { reasoning: { max_tokens: 16_000 } }], + ["@ai-sdk/anthropic", { thinking: { type: "enabled", budgetTokens: 16_000 } }], + ["@ai-sdk/google-vertex/anthropic", { thinking: { type: "enabled", budgetTokens: 16_000 } }], + ["@ai-sdk/google", { thinkingConfig: { includeThoughts: true, thinkingBudget: 16_000 } }], + ["@ai-sdk/google-vertex", { thinkingConfig: { includeThoughts: true, thinkingBudget: 16_000 } }], + ["@ai-sdk/amazon-bedrock", { reasoningConfig: { type: "enabled", budgetTokens: 16_000 } }], + ["@ai-sdk/cohere", { thinking: { type: "enabled", tokenBudget: 16_000 } }], + ["@ai-sdk/alibaba", { enableThinking: true, thinkingBudget: 16_000 }], + ])("converts token budgets for %s", (npm, high) => { + const variants = ProviderTransform.reasoningVariants(model([{ type: "budget_tokens", min: 1_024 }]), target(npm)) + expect(variants?.high).toEqual(high) + expect(Object.keys(variants ?? {})).toEqual(["high", "max"]) + }) + + test("maps null effort to none", () => { + expect( + ProviderTransform.reasoningVariants(model([{ type: "effort", values: [null] }]), target("@ai-sdk/openai")), + ).toEqual({ + none: { + reasoningEffort: "none", + reasoningSummary: "auto", + include: ["reasoning.encrypted_content"], + }, + }) + }) + + test.each([ + ["@ai-sdk/alibaba", { none: { enableThinking: false }, high: { enableThinking: true } }], + [ + "@ai-sdk/cohere", + { + none: { thinking: { type: "disabled" } }, + high: { thinking: { type: "enabled" } }, + }, + ], + ])("converts toggle options for %s", (npm, expected) => { + expect(ProviderTransform.reasoningVariants(model([{ type: "toggle" }]), target(npm))).toEqual(expected) + }) + + test("combines Cohere toggle and budget options", () => { + expect( + ProviderTransform.reasoningVariants( + model([{ type: "toggle" }, { type: "budget_tokens", min: 1 }]), + target("@ai-sdk/cohere"), + ), + ).toEqual({ + none: { thinking: { type: "disabled" } }, + high: { thinking: { type: "enabled", tokenBudget: 16_000 } }, + max: { thinking: { type: "enabled", tokenBudget: 31_999 } }, + }) + }) + + test("generates bounded high and max token budgets", () => { + expect( + ProviderTransform.reasoningVariants( + model([{ type: "budget_tokens", min: 1_024, max: 64_000 }]), + target("@ai-sdk/anthropic"), + ), + ).toEqual({ + high: { thinking: { type: "enabled", budgetTokens: 16_000 } }, + max: { thinking: { type: "enabled", budgetTokens: 31_999 } }, + }) + }) + + test("caps token budgets below the model output limit", () => { + const anthropic = target("@ai-sdk/anthropic") + anthropic.limit.output = 5_000 + expect( + ProviderTransform.reasoningVariants(model([{ type: "budget_tokens", min: 1_024, max: 64_000 }]), anthropic), + ).toEqual({ + high: { thinking: { type: "enabled", budgetTokens: 2_500 } }, + max: { thinking: { type: "enabled", budgetTokens: 4_999 } }, + }) + }) + + test("derives high and max budgets when models.dev omits max", () => { + expect( + ProviderTransform.reasoningVariants( + model([{ type: "budget_tokens", min: 1_024 }]), + target("@ai-sdk/anthropic", "claude-haiku-4-5"), + ), + ).toEqual({ + high: { thinking: { type: "enabled", budgetTokens: 16_000 } }, + max: { thinking: { type: "enabled", budgetTokens: 31_999 } }, + }) + }) + + test("preserves explicit inclusive budget maxima", () => { + expect( + ProviderTransform.reasoningVariants( + model([{ type: "budget_tokens", min: 1_024, max: 24_576 }]), + target("@ai-sdk/google", "gemini-2.5-pro"), + ), + ).toEqual({ + high: { thinkingConfig: { includeThoughts: true, thinkingBudget: 12_288 } }, + max: { thinkingConfig: { includeThoughts: true, thinkingBudget: 24_576 } }, + }) + }) + + test("prefers effort options over token budgets", () => { + expect( + ProviderTransform.reasoningVariants( + model([ + { type: "budget_tokens", min: 1_024, max: 64_000 }, + { type: "effort", values: ["low"] }, + ]), + target("@ai-sdk/openai"), + ), + ).toEqual({ + low: { + reasoningEffort: "low", + reasoningSummary: "auto", + include: ["reasoning.encrypted_content"], + }, + }) + }) + + test("does not replace unsupported effort options with heuristic variants", () => { + expect( + ProviderTransform.reasoningVariants(model([{ type: "effort", values: ["high"] }]), target("@ai-sdk/perplexity")), + ).toEqual({}) + }) + + test("leaves unsupported toggle options for heuristic fallback", () => { + expect(ProviderTransform.reasoningVariants(model([{ type: "toggle" }]), target("@ai-sdk/openai"))).toBeUndefined() + }) + + test("uses model-family options for gateway and GitHub Copilot", () => { + const effort = model([{ type: "effort", values: ["high"] }]) + expect(ProviderTransform.reasoningVariants(effort, target("@ai-sdk/gateway", "anthropic/claude-sonnet-4"))).toEqual( + { + high: { thinking: { type: "adaptive", display: "summarized" }, effort: "high" }, + }, + ) + expect(ProviderTransform.reasoningVariants(effort, target("@ai-sdk/gateway", "google/gemini-3-pro"))).toEqual({ + high: { thinkingConfig: { includeThoughts: true, thinkingLevel: "high" } }, + }) + expect(ProviderTransform.reasoningVariants(effort, target("@ai-sdk/github-copilot", "gemini-3-pro"))).toEqual({}) + }) + + test.each(["@ai-sdk/cohere", "@ai-sdk/perplexity", "@ai-sdk/vercel", "@ai-sdk/alibaba", "gitlab-ai-provider"])( + "does not invent effort controls for %s", + (npm) => { + expect(ProviderTransform.reasoningVariants(model([{ type: "effort", values: ["high"] }]), target(npm))).toEqual( + {}, + ) + }, + ) +}) + describe("ProviderTransform.variants", () => { const createMockModel = (overrides: Partial = {}): any => ({ id: "test/test-model", @@ -2542,69 +3570,181 @@ describe("ProviderTransform.variants", () => { npm: "@ai-sdk/openai-compatible", }, }) - const result = ProviderTransform.variants(model) - expect(result).toEqual({}) + const result = ProviderTransform.variants(model) + expect(result).toEqual({}) + }) + + test("minimax returns empty object", () => { + const model = createMockModel({ + id: "minimax/minimax-model", + providerID: "minimax", + api: { + id: "minimax-model", + url: "https://api.minimax.com", + npm: "@ai-sdk/openai-compatible", + }, + }) + const result = ProviderTransform.variants(model) + expect(result).toEqual({}) + }) + + test("minimax m3 using anthropic returns thinking toggles", () => { + const model = createMockModel({ + id: "minimax/minimax-m3", + providerID: "minimax", + api: { + id: "MiniMax-M3", + url: "https://api.minimax.com/anthropic/v1", + npm: "@ai-sdk/anthropic", + }, + }) + const result = ProviderTransform.variants(model) + expect(result).toEqual({ + none: { thinking: { type: "disabled" } }, + thinking: { thinking: { type: "adaptive" } }, + }) + }) + + test("minimax m3 using openai-compatible returns thinking toggles", () => { + const model = createMockModel({ + id: "minimax/minimax-m3", + providerID: "minimax", + api: { + id: "minimax-m3", + url: "https://api.minimax.com/v1", + npm: "@ai-sdk/openai-compatible", + }, + }) + expect(ProviderTransform.variants(model)).toEqual({ + none: { thinking: { type: "disabled" } }, + thinking: { thinking: { type: "adaptive" } }, + }) + }) + + test.each(["nvidia", "lilac"])("%s minimax m3 returns chat template thinking toggles", (providerID) => { + const model = createMockModel({ + id: `${providerID}/minimaxai/minimax-m3`, + providerID, + api: { + id: "minimaxai/minimax-m3", + url: "https://api.example.com/v1", + npm: "@ai-sdk/openai-compatible", + }, + }) + expect(ProviderTransform.variants(model)).toEqual({ + none: { chat_template_kwargs: { thinking_mode: "disabled" } }, + thinking: { chat_template_kwargs: { thinking_mode: "enabled" } }, + }) + }) + + test("glm returns empty object", () => { + const model = createMockModel({ + id: "glm/glm-4", + providerID: "glm", + api: { + id: "glm-4", + url: "https://api.glm.com", + npm: "@ai-sdk/openai-compatible", + }, + }) + const result = ProviderTransform.variants(model) + expect(result).toEqual({}) + }) + + test("glm-5.2 returns native effort variants for openai-compatible providers", () => { + const model = createMockModel({ + id: "zhipuai/glm-5.2", + providerID: "zhipuai", + api: { + id: "glm-5.2", + url: "https://open.bigmodel.cn/api/paas/v4", + npm: "@ai-sdk/openai-compatible", + }, + }) + expect(ProviderTransform.variants(model)).toEqual({ + high: { reasoningEffort: "high" }, + max: { reasoningEffort: "max" }, + }) }) - test("minimax returns empty object", () => { + test("recognizes GLM-5.2 provider model IDs", () => { + for (const id of ["accounts/fireworks/models/glm-5p2", "zai-org-glm-5-2", "umans-glm-5.2"]) { + const model = createMockModel({ + id: `test/${id}`, + api: { + id, + url: "https://api.test.com", + npm: "@ai-sdk/openai-compatible", + }, + }) + expect(ProviderTransform.variants(model)).toEqual({ + high: { reasoningEffort: "high" }, + max: { reasoningEffort: "max" }, + }) + } + }) + + test("recognizes GLM-5.2 from the API ID when the configured model ID is an alias", () => { const model = createMockModel({ - id: "minimax/minimax-model", - providerID: "minimax", + id: "custom/my-glm", api: { - id: "minimax-model", - url: "https://api.minimax.com", + id: "accounts/fireworks/models/glm-5p2", + url: "https://api.fireworks.ai/inference/v1", npm: "@ai-sdk/openai-compatible", }, }) - const result = ProviderTransform.variants(model) - expect(result).toEqual({}) + expect(ProviderTransform.variants(model)).toEqual({ + high: { reasoningEffort: "high" }, + max: { reasoningEffort: "max" }, + }) }) - test("minimax m3 using anthropic returns thinking toggles", () => { + test("glm-5.2 returns openrouter effort variants for openrouter", () => { const model = createMockModel({ - id: "minimax/minimax-m3", - providerID: "minimax", + id: "openrouter/z-ai/glm-5.2", + providerID: "openrouter", api: { - id: "MiniMax-M3", - url: "https://api.minimax.com/anthropic/v1", - npm: "@ai-sdk/anthropic", + id: "z-ai/glm-5.2", + url: "https://openrouter.ai/api/v1", + npm: "@openrouter/ai-sdk-provider", }, }) - const result = ProviderTransform.variants(model) - expect(result).toEqual({ - none: { thinking: { type: "disabled" } }, - thinking: { thinking: { type: "adaptive" } }, + expect(ProviderTransform.variants(model)).toEqual({ + high: { reasoning: { effort: "high" } }, + xhigh: { reasoning: { effort: "xhigh" } }, }) }) - test("minimax m3 using openai-compatible returns thinking toggles", () => { + test("glm-5.2 returns effort variants for anthropic-compatible providers", () => { const model = createMockModel({ - id: "minimax/minimax-m3", - providerID: "minimax", + id: "zai-coding-plan/glm-5.2", + providerID: "zai-coding-plan", api: { - id: "minimax-m3", - url: "https://api.minimax.com/v1", - npm: "@ai-sdk/openai-compatible", + id: "glm-5.2", + url: "https://api.z.ai/api/anthropic", + npm: "@ai-sdk/anthropic", }, }) expect(ProviderTransform.variants(model)).toEqual({ - none: { thinking: { type: "disabled" } }, - thinking: { thinking: { type: "adaptive" } }, + high: { effort: "high" }, + max: { effort: "max" }, }) }) - test("glm returns empty object", () => { + test("glm-5.2 falls back to provider defaults for other packages", () => { const model = createMockModel({ - id: "glm/glm-4", - providerID: "glm", + id: "test/glm-5.2", api: { - id: "glm-4", - url: "https://api.glm.com", - npm: "@ai-sdk/openai-compatible", + id: "glm-5.2", + url: "https://api.test.com", + npm: "@ai-sdk/amazon-bedrock", }, }) - const result = ProviderTransform.variants(model) - expect(result).toEqual({}) + expect(ProviderTransform.variants(model)).toEqual({ + low: { reasoningConfig: { type: "enabled", maxReasoningEffort: "low" } }, + medium: { reasoningConfig: { type: "enabled", maxReasoningEffort: "medium" } }, + high: { reasoningConfig: { type: "enabled", maxReasoningEffort: "high" } }, + }) }) test("mistral models with reasoning support return variants", () => { @@ -2744,7 +3884,7 @@ describe("ProviderTransform.variants", () => { expect(Object.keys(result)).toEqual(["low", "medium", "high"]) }) - test("grok-4 returns empty object", () => { + test("grok-4 uses the provider's standard efforts", () => { const model = createMockModel({ id: "openrouter/grok-4", providerID: "openrouter", @@ -2755,7 +3895,8 @@ describe("ProviderTransform.variants", () => { }, }) const result = ProviderTransform.variants(model) - expect(result).toEqual({}) + expect(Object.keys(result)).toEqual(["low", "medium", "high"]) + expect(result.medium).toEqual({ reasoning: { effort: "medium" } }) }) test("grok-3-mini returns low and high with reasoning", () => { @@ -2776,6 +3917,42 @@ describe("ProviderTransform.variants", () => { }) describe("@ai-sdk/gateway", () => { + test("configured anthropic aliases route by the API ID", () => { + const model = createMockModel({ + id: "my-claude", + providerID: "gateway", + api: { + id: "anthropic/claude-sonnet-4-6", + url: "https://gateway.ai", + npm: "@ai-sdk/gateway", + }, + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["low", "medium", "high", "max"]) + expect(result.high).toEqual({ + thinking: { + type: "adaptive", + }, + effort: "high", + }) + }) + + test("configured google aliases route by the API ID", () => { + const model = createMockModel({ + id: "my-gemini", + providerID: "gateway", + api: { + id: "google/gemini-2.5-pro", + url: "https://gateway.ai", + npm: "@ai-sdk/gateway", + }, + }) + expect(ProviderTransform.variants(model)).toEqual({ + high: { thinkingConfig: { includeThoughts: true, thinkingBudget: 16_000 } }, + max: { thinkingConfig: { includeThoughts: true, thinkingBudget: 32_768 } }, + }) + }) + test("anthropic sonnet 4.6 models return adaptive thinking options", () => { const model = createMockModel({ id: "anthropic/claude-sonnet-4-6", @@ -2899,6 +4076,27 @@ describe("ProviderTransform.variants", () => { }) }) + test("anthropic sonnet 5 returns adaptive thinking options with xhigh", () => { + const model = createMockModel({ + id: "anthropic/claude-sonnet-5", + providerID: "gateway", + api: { + id: "anthropic/claude-sonnet-5", + url: "https://gateway.ai", + npm: "@ai-sdk/gateway", + }, + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"]) + expect(result.high).toEqual({ + thinking: { + type: "adaptive", + display: "summarized", + }, + effort: "high", + }) + }) + test("anthropic opus 4.6 omits display so it keeps the summarized default", () => { const model = createMockModel({ id: "anthropic/claude-opus-4-6", @@ -3150,18 +4348,19 @@ describe("ProviderTransform.variants", () => { }) describe("@ai-sdk/xai", () => { - test("grok-3 returns empty object", () => { + test("grok-4.5 uses standard reasoning efforts", () => { const model = createMockModel({ - id: "xai/grok-3", + id: "xai/grok-4.5", providerID: "xai", api: { - id: "grok-3", + id: "grok-4.5", url: "https://api.x.ai", npm: "@ai-sdk/xai", }, }) const result = ProviderTransform.variants(model) - expect(result).toEqual({}) + expect(Object.keys(result)).toEqual(["low", "medium", "high"]) + expect(result.medium).toEqual({ reasoningEffort: "medium" }) }) test("grok-3-mini returns low and high with reasoningEffort", () => { @@ -3456,11 +4655,17 @@ describe("ProviderTransform.variants", () => { describe("@ai-sdk/anthropic", () => { for (const testCase of [ + { + name: "opus 4 dated", + apiIds: ["claude-opus-4-20250514"], + efforts: ["high", "max"], + expectedHigh: { thinking: { type: "enabled", budgetTokens: 16000 } }, + }, { name: "opus 4.5", apiIds: ["claude-opus-4-5-20251101", "claude-opus-4.5-20251101"], efforts: ["low", "medium", "high"], - expectedHigh: { effort: "high" }, + expectedHigh: { thinking: { type: "enabled", budgetTokens: 16000 }, effort: "high" }, }, { name: "sonnet 4.6", @@ -3486,6 +4691,24 @@ describe("ProviderTransform.variants", () => { efforts: ["low", "medium", "high", "xhigh", "max"], expectedHigh: { thinking: { type: "adaptive", display: "summarized" }, effort: "high" }, }, + { + name: "sonnet 5", + apiIds: ["claude-sonnet-5", "claude-sonnet-5-20260630"], + efforts: ["low", "medium", "high", "xhigh", "max"], + expectedHigh: { thinking: { type: "adaptive", display: "summarized" }, effort: "high" }, + }, + { + name: "opus 5", + apiIds: ["claude-opus-5", "claude-opus-5-20260724"], + efforts: ["low", "medium", "high", "xhigh", "max"], + expectedHigh: { thinking: { type: "adaptive", display: "summarized" }, effort: "high" }, + }, + { + name: "unversioned future model", + apiIds: ["claude-future"], + efforts: ["low", "medium", "high", "xhigh", "max"], + expectedHigh: { thinking: { type: "adaptive", display: "summarized" }, effort: "high" }, + }, { name: "fable 5", apiIds: ["claude-fable-5"], @@ -3583,6 +4806,50 @@ describe("ProviderTransform.variants", () => { effort: "high", }) }) + + test("sonnet 5 uses adaptive reasoning for Vertex model IDs", () => { + const result = ProviderTransform.variants( + createMockModel({ + id: "google-vertex-anthropic/claude-sonnet-5@default", + providerID: "google-vertex-anthropic", + api: { + id: "claude-sonnet-5@default", + url: "https://us-central1-aiplatform.googleapis.com", + npm: "@ai-sdk/google-vertex/anthropic", + }, + }), + ) + expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"]) + expect(result.high).toEqual({ + thinking: { + type: "adaptive", + display: "summarized", + }, + effort: "high", + }) + }) + + test("opus 5 uses adaptive reasoning for Vertex model IDs", () => { + const result = ProviderTransform.variants( + createMockModel({ + id: "google-vertex-anthropic/claude-opus-5@default", + providerID: "google-vertex-anthropic", + api: { + id: "claude-opus-5@default", + url: "https://us-central1-aiplatform.googleapis.com", + npm: "@ai-sdk/google-vertex/anthropic", + }, + }), + ) + expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"]) + expect(result.high).toEqual({ + thinking: { + type: "adaptive", + display: "summarized", + }, + effort: "high", + }) + }) }) describe("@ai-sdk/amazon-bedrock", () => { @@ -3656,6 +4923,50 @@ describe("ProviderTransform.variants", () => { }) }) + test("anthropic sonnet 5 returns adaptive reasoning options with xhigh", () => { + const result = ProviderTransform.variants( + createMockModel({ + id: "bedrock/anthropic-claude-sonnet-5", + providerID: "bedrock", + api: { + id: "anthropic.claude-sonnet-5", + url: "https://bedrock.amazonaws.com", + npm: "@ai-sdk/amazon-bedrock", + }, + }), + ) + expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"]) + expect(result.high).toEqual({ + reasoningConfig: { + type: "adaptive", + maxReasoningEffort: "high", + display: "summarized", + }, + }) + }) + + test("anthropic opus 5 returns adaptive reasoning options with xhigh", () => { + const result = ProviderTransform.variants( + createMockModel({ + id: "bedrock/anthropic-claude-opus-5", + providerID: "bedrock", + api: { + id: "us.anthropic.claude-opus-5-v1:0", + url: "https://bedrock.amazonaws.com", + npm: "@ai-sdk/amazon-bedrock", + }, + }), + ) + expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"]) + expect(result.high).toEqual({ + reasoningConfig: { + type: "adaptive", + maxReasoningEffort: "high", + display: "summarized", + }, + }) + }) + test("returns WIDELY_SUPPORTED_EFFORTS with reasoningConfig", () => { const model = createMockModel({ id: "bedrock/llama-4", @@ -3838,6 +5149,18 @@ describe("ProviderTransform.variants", () => { efforts: ["low", "medium", "high", "xhigh", "max"], thinking: { type: "adaptive", display: "summarized" }, }, + { + name: "sonnet 5", + apiIds: ["anthropic--claude-sonnet-5", "anthropic--claude-5-sonnet"], + efforts: ["low", "medium", "high", "xhigh", "max"], + thinking: { type: "adaptive", display: "summarized" }, + }, + { + name: "opus 5", + apiIds: ["anthropic--claude-opus-5", "anthropic--claude-5-opus"], + efforts: ["low", "medium", "high", "xhigh", "max"], + thinking: { type: "adaptive", display: "summarized" }, + }, ]) { for (const apiId of testCase.apiIds) { test(`${testCase.name} ${apiId} returns adaptive thinking variants under modelParams`, () => { @@ -4016,12 +5339,12 @@ describe("ProviderTransform.smallOptions - gpt-5 chat/search", () => { } }) -test("ProviderTransform.smallOptions disables OpenRouter reasoning when the weakest effort is low", () => { +test("ProviderTransform.smallOptions preserves the weakest OpenRouter reasoning effort", () => { expect( ProviderTransform.smallOptions({ providerID: "openrouter", api: { - id: "anthropic/claude-sonnet-4.6", + id: "google/gemini-3.5-flash", npm: "@openrouter/ai-sdk-provider", }, variants: { @@ -4030,7 +5353,7 @@ test("ProviderTransform.smallOptions disables OpenRouter reasoning when the weak high: { reasoning: { effort: "high" } }, }, } as any), - ).toEqual({ reasoning: { effort: "none" } }) + ).toEqual({ reasoning: { effort: "low" } }) }) describe("ProviderTransform.smallOptions - google thinking controls", () => { @@ -4118,3 +5441,95 @@ describe("ProviderTransform.providerOptions - ai-gateway-provider", () => { expect(result).toEqual({ openaiCompatible: { reasoningEffort: "high" } }) }) }) + +describe("ProviderTransform.options - kimi family adaptive thinking", () => { + const createModel = (overrides: Record = {}) => + ({ + id: "moonshotai/kimi-k2-thinking", + providerID: "moonshotai", + api: { + id: "kimi-k2-thinking", + url: "https://api.moonshot.ai/anthropic", + npm: "@ai-sdk/anthropic", + }, + name: "Kimi K2 Thinking", + capabilities: { + temperature: true, + reasoning: true, + attachment: false, + toolcall: true, + input: { text: true, audio: false, image: false, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + cost: { input: 0.001, output: 0.002, cache: { read: 0.0001, write: 0.0002 } }, + limit: { context: 262144, output: 262144 }, + status: "active", + options: {}, + headers: {}, + ...overrides, + }) as any + + test("uses adaptive thinking with effort instead of budget tokens", () => { + const result = ProviderTransform.options({ model: createModel(), sessionID: "s1", providerOptions: {} }) + expect(result.thinking).toEqual({ type: "adaptive", display: "summarized" }) + expect(result.effort).toBe("high") + expect(JSON.stringify(result)).not.toContain("budgetTokens") + }) + + test("uses adaptive thinking through Google Vertex Anthropic", () => { + const model = createModel() + model.api.npm = "@ai-sdk/google-vertex/anthropic" + const result = ProviderTransform.options({ model, sessionID: "s1", providerOptions: {} }) + expect(result.thinking).toEqual({ type: "adaptive", display: "summarized" }) + expect(result.effort).toBe("high") + }) + + test("provides adaptive effort variants without models.dev metadata", () => { + expect(ProviderTransform.variants(createModel())).toEqual( + Object.fromEntries( + ["low", "medium", "high", "xhigh", "max"].map((effort) => [ + effort, + { thinking: { type: "adaptive", display: "summarized" }, effort }, + ]), + ), + ) + + const model = createModel() + model.api.npm = "@ai-sdk/openai-compatible" + expect(ProviderTransform.variants(model)).toEqual({}) + }) + + test("does not enable thinking for kimi models without reasoning capability", () => { + const model = createModel() + model.capabilities.reasoning = false + const result = ProviderTransform.options({ model, sessionID: "s1", providerOptions: {} }) + expect(result.thinking).toBeUndefined() + }) + + test("does not set thinking defaults for non-kimi anthropic models", () => { + const model = createModel({ + id: "anthropic/claude-sonnet-4-5", + providerID: "anthropic", + api: { + id: "claude-sonnet-4-5", + url: "https://api.anthropic.com", + npm: "@ai-sdk/anthropic", + }, + }) + const result = ProviderTransform.options({ model, sessionID: "s1", providerOptions: {} }) + expect(result.thinking).toBeUndefined() + expect(result.effort).toBeUndefined() + }) + + test("does not set adaptive thinking for kimi on openai-compatible", () => { + const model = createModel() + model.api = { + id: "kimi-k2-thinking", + url: "https://api.moonshot.ai/v1", + npm: "@ai-sdk/openai-compatible", + } + const result = ProviderTransform.options({ model, sessionID: "s1", providerOptions: {} }) + expect(result.thinking).toBeUndefined() + }) +})