From fed95ce52418e2894da8a6bad549b4975ee93c64 Mon Sep 17 00:00:00 2001 From: can1357 Date: Thu, 30 Apr 2026 05:23:20 +0200 Subject: [PATCH] fix(ai,coding-agent): narrow Model.compat consumers after AnthropicCompat split Commit a190397d8 made `Model.compat` resolve to `OpenAICompat | AnthropicCompat` under the default `TApi = any`. The widened union broke every site that treated `compat` as openai-shaped: model-registry deep-merge, openai-completions resolved compat, and ~20 test fixtures. This restores the assumption locally instead of papering over it with casts. - getBundledModel is now generic on TApi so test fixtures that spread it into `Model<"openai-completions">` get the narrow compat back. - mergeCompat is generic over TBase/TOverride; the schema-driven model-registry override path keeps its OpenAICompat-shaped merge fields, anthropic overrides pass through untouched. - OpenAICompatSchema gains the openai-only fields it was missing (requiresMistralToolIds, reasoningContentField, requiresReasoningContent*, thinkingFormat, requiresThinkingAsText, disableReasoningOnForcedToolChoice). - resolveOpenAICompat fills in disableReasoningOnForcedToolChoice so the Required shape stays satisfied. - Anthropic tool-result block id assignment uses the proper unknown double-cast. - isForcedToolChoice accepts unknown so it can read `params.tool_choice` whose type comes from the OpenAI SDK ChatCompletionToolChoiceOption (now wider than our local OpenAICompletionsToolChoice). - Test fixtures and Required literals updated for the field set. Fixes CI red on main. --- packages/ai/src/models.ts | 4 +- packages/ai/src/providers/anthropic.ts | 2 +- .../providers/openai-completions-compat.ts | 2 + packages/ai/test/issue-814-repro.test.ts | 8 ++-- packages/ai/test/issue-826-repro.test.ts | 11 ++++-- packages/ai/test/issue-830-repro.test.ts | 13 ++++--- .../ai/test/openai-completions-compat.test.ts | 1 + ...nai-completions-tool-result-images.test.ts | 7 ++-- .../test/openai-first-event-timeout.test.ts | 2 +- .../ai/test/openai-tool-strict-mode.test.ts | 10 ++--- packages/ai/test/provider-response.test.ts | 2 +- packages/ai/test/stream.test.ts | 6 +-- packages/ai/test/tokens.test.ts | 4 +- .../ai/test/tool-call-without-result.test.ts | 4 +- packages/ai/test/total-tokens.test.ts | 2 +- packages/ai/test/xhigh.test.ts | 4 +- .../coding-agent/src/config/model-registry.ts | 39 ++++++++++--------- .../coding-agent/test/issue-816-repro.test.ts | 1 + .../coding-agent/test/model-registry.test.ts | 29 ++++++++------ 19 files changed, 85 insertions(+), 66 deletions(-) diff --git a/packages/ai/src/models.ts b/packages/ai/src/models.ts index 55bf553e3..72dcef516 100644 --- a/packages/ai/src/models.ts +++ b/packages/ai/src/models.ts @@ -21,9 +21,9 @@ for (const [provider, models] of Object.entries(MODELS)) { export type GeneratedProvider = keyof typeof MODELS; -export function getBundledModel(provider: GeneratedProvider, modelId: string): Model { +export function getBundledModel(provider: GeneratedProvider, modelId: string): Model { const providerModels = modelRegistry.get(provider); - return providerModels?.get(modelId) as Model; + return providerModels?.get(modelId) as Model; } export function getBundledProviders(): KnownProvider[] { diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index a69ef0bd7..1d40214a4 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -1661,7 +1661,7 @@ function buildToolResultBlock(model: Model<"anthropic-messages">, msg: ToolResul }; if (isZaiAnthropicEndpoint(model)) { // Z.AI workaround (issue #814): include `id` aliased to `tool_use_id`. - (block as Record).id = msg.toolCallId; + (block as unknown as Record).id = msg.toolCallId; } return block; } diff --git a/packages/ai/src/providers/openai-completions-compat.ts b/packages/ai/src/providers/openai-completions-compat.ts index a65c91c56..d27c6d0be 100644 --- a/packages/ai/src/providers/openai-completions-compat.ts +++ b/packages/ai/src/providers/openai-completions-compat.ts @@ -185,6 +185,8 @@ export function resolveOpenAICompat( model.compat.requiresReasoningContentForToolCalls ?? detected.requiresReasoningContentForToolCalls, requiresAssistantContentForToolCalls: model.compat.requiresAssistantContentForToolCalls ?? detected.requiresAssistantContentForToolCalls, + disableReasoningOnForcedToolChoice: + model.compat.disableReasoningOnForcedToolChoice ?? detected.disableReasoningOnForcedToolChoice, openRouterRouting: model.compat.openRouterRouting ?? detected.openRouterRouting, vercelGatewayRouting: model.compat.vercelGatewayRouting ?? detected.vercelGatewayRouting, supportsStrictMode: model.compat.supportsStrictMode ?? detected.supportsStrictMode, diff --git a/packages/ai/test/issue-814-repro.test.ts b/packages/ai/test/issue-814-repro.test.ts index daaedbe9b..17009bc48 100644 --- a/packages/ai/test/issue-814-repro.test.ts +++ b/packages/ai/test/issue-814-repro.test.ts @@ -14,16 +14,16 @@ import type { AssistantMessage, Model, ToolResultMessage, UserMessage } from "@o * endpoints must remain unchanged (no `id` field). */ -const baseModel = { +const baseModel: Omit, "provider" | "baseUrl"> = { api: "anthropic-messages", id: "glm-4.6", name: "GLM-4.6", - input: ["text"] as const, + input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, maxTokens: 8192, contextWindow: 200000, reasoning: false, -} as const; +}; const zaiModel: Model<"anthropic-messages"> = { ...baseModel, @@ -66,7 +66,7 @@ const assistant: AssistantMessage = { totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }, - stopReason: "tool_use", + stopReason: "toolUse", timestamp: Date.now(), }; diff --git a/packages/ai/test/issue-826-repro.test.ts b/packages/ai/test/issue-826-repro.test.ts index b9ad2adbb..1a3beb38a 100644 --- a/packages/ai/test/issue-826-repro.test.ts +++ b/packages/ai/test/issue-826-repro.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "bun:test"; +import { Effort } from "../src/model-thinking"; import { streamAnthropic } from "../src/providers/anthropic"; import type { Context, Model, Tool } from "../src/types"; @@ -79,8 +80,9 @@ describe("issue #826: Anthropic strict-tools opt-out for Vertex-style proxies", reasoning: true, thinking: { mode: "anthropic-adaptive", - supportsAdaptiveEffort: true, - } as Model<"anthropic-messages">["thinking"], + minLevel: Effort.Minimal, + maxLevel: Effort.XHigh, + }, }; const { promise, resolve } = Promise.withResolvers<{ thinking?: { type?: string } }>(); void streamAnthropic(adaptiveModel, baseContext, { @@ -104,8 +106,9 @@ describe("issue #826: Anthropic strict-tools opt-out for Vertex-style proxies", reasoning: true, thinking: { mode: "anthropic-adaptive", - supportsAdaptiveEffort: true, - } as Model<"anthropic-messages">["thinking"], + minLevel: Effort.Minimal, + maxLevel: Effort.XHigh, + }, compat: { disableAdaptiveThinking: true }, }; const { promise, resolve } = Promise.withResolvers<{ thinking?: { type?: string; budget_tokens?: number } }>(); diff --git a/packages/ai/test/issue-830-repro.test.ts b/packages/ai/test/issue-830-repro.test.ts index d29afdf15..9832110db 100644 --- a/packages/ai/test/issue-830-repro.test.ts +++ b/packages/ai/test/issue-830-repro.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"; import { DEFAULT_MODEL_PER_PROVIDER, PROVIDER_DESCRIPTORS } from "../src/provider-models/descriptors"; import { MODELS_DEV_PROVIDER_DESCRIPTORS } from "../src/provider-models/openai-compat"; import { getEnvApiKey } from "../src/stream"; +import type { OpenAICompat } from "../src/types"; describe("deepseek built-in provider (issue #830)", () => { test("registers built-in runtime descriptor with DEEPSEEK_API_KEY env discovery", () => { @@ -35,10 +36,12 @@ describe("deepseek built-in provider (issue #830)", () => { // Per-model compat: deepseek-v4 reasoning models leak chat-template tool-call markers // (#798) and 400 on tool_choice when xhigh effort is used (#830 thread). Reasoning content // must round-trip on tool calls (interleaved.field=reasoning_content from models.dev). - expect(descriptor?.compat?.supportsReasoningEffort).toBe(true); - expect(descriptor?.compat?.supportsToolChoice).toBe(false); - expect(descriptor?.compat?.requiresReasoningContentForToolCalls).toBe(true); - expect(descriptor?.compat?.reasoningContentField).toBe("reasoning_content"); - expect(descriptor?.compat?.reasoningEffortMap?.xhigh).toBe("max"); + const compat = + descriptor?.api === "openai-completions" ? (descriptor.compat as OpenAICompat | undefined) : undefined; + expect(compat?.supportsReasoningEffort).toBe(true); + expect(compat?.supportsToolChoice).toBe(false); + expect(compat?.requiresReasoningContentForToolCalls).toBe(true); + expect(compat?.reasoningContentField).toBe("reasoning_content"); + expect(compat?.reasoningEffortMap?.xhigh).toBe("max"); }); }); diff --git a/packages/ai/test/openai-completions-compat.test.ts b/packages/ai/test/openai-completions-compat.test.ts index 6fc3ca9af..ded39be21 100644 --- a/packages/ai/test/openai-completions-compat.test.ts +++ b/packages/ai/test/openai-completions-compat.test.ts @@ -73,6 +73,7 @@ describe("openai-completions compatibility", () => { reasoningEffortMap: {}, supportsUsageInStreaming: true, supportsToolChoice: true, + disableReasoningOnForcedToolChoice: false, maxTokensField: "max_completion_tokens", requiresToolResultName: false, requiresAssistantAfterToolResult: false, diff --git a/packages/ai/test/openai-completions-tool-result-images.test.ts b/packages/ai/test/openai-completions-tool-result-images.test.ts index 36d5c729a..198e1355f 100644 --- a/packages/ai/test/openai-completions-tool-result-images.test.ts +++ b/packages/ai/test/openai-completions-tool-result-images.test.ts @@ -19,6 +19,7 @@ const compat: Required = { reasoningEffortMap: {}, supportsUsageInStreaming: true, supportsToolChoice: true, + disableReasoningOnForcedToolChoice: false, maxTokensField: "max_completion_tokens", requiresToolResultName: false, requiresAssistantAfterToolResult: false, @@ -51,7 +52,7 @@ function buildToolResult(toolCallId: string, timestamp: number): ToolResultMessa describe("openai-completions convertMessages", () => { it("batches tool-result images after consecutive tool results", () => { - const baseModel = getBundledModel("openai", "gpt-4o-mini"); + const baseModel = getBundledModel("openai", "gpt-4o-mini") as Model<"openai-completions">; const model: Model<"openai-completions"> = { ...baseModel, api: "openai-completions", @@ -94,7 +95,7 @@ describe("openai-completions convertMessages", () => { expect(imageParts.length).toBe(2); }); it("uses generated tool_call_id values when assistant/tool IDs are empty", () => { - const baseModel = getBundledModel("openai", "gpt-4o-mini"); + const baseModel = getBundledModel("openai", "gpt-4o-mini") as Model<"openai-completions">; const model: Model<"openai-completions"> = { ...baseModel, api: "openai-completions", @@ -143,7 +144,7 @@ describe("openai-completions convertMessages", () => { }); it("serializes string tool arguments into valid JSON objects", () => { - const baseModel = getBundledModel("openai", "gpt-4o-mini"); + const baseModel = getBundledModel("openai", "gpt-4o-mini") as Model<"openai-completions">; const model: Model<"openai-completions"> = { ...baseModel, api: "openai-completions", diff --git a/packages/ai/test/openai-first-event-timeout.test.ts b/packages/ai/test/openai-first-event-timeout.test.ts index 7d367d2c1..77368ab1d 100644 --- a/packages/ai/test/openai-first-event-timeout.test.ts +++ b/packages/ai/test/openai-first-event-timeout.test.ts @@ -10,7 +10,7 @@ const originalFetch = global.fetch; const openAIResponsesModel = getBundledModel("openai", "gpt-5-mini") as Model<"openai-responses">; const openAICompletionsModel = { - ...getBundledModel("openai", "gpt-4o-mini"), + ...(getBundledModel("openai", "gpt-4o-mini") as Model<"openai-completions">), api: "openai-completions", } satisfies Model<"openai-completions">; const azureOpenAIResponsesModel: Model<"azure-openai-responses"> = { diff --git a/packages/ai/test/openai-tool-strict-mode.test.ts b/packages/ai/test/openai-tool-strict-mode.test.ts index fa6eaedde..f05a69d3f 100644 --- a/packages/ai/test/openai-tool-strict-mode.test.ts +++ b/packages/ai/test/openai-tool-strict-mode.test.ts @@ -71,7 +71,7 @@ function captureResponsesPayload(model: Model<"openai-responses">): Promise { it("sends strict=true for openai-completions tool schemas", async () => { const model: Model<"openai-completions"> = { - ...getBundledModel("openai", "gpt-4o-mini"), + ...(getBundledModel("openai", "gpt-4o-mini") as Model<"openai-completions">), api: "openai-completions", }; @@ -83,7 +83,7 @@ describe("OpenAI tool strict mode", () => { it("omits strict for openai-completions when compatibility disables strict mode", async () => { const model: Model<"openai-completions"> = { - ...getBundledModel("openai", "gpt-4o-mini"), + ...(getBundledModel("openai", "gpt-4o-mini") as Model<"openai-completions">), api: "openai-completions", compat: { supportsStrictMode: false } satisfies OpenAICompat, }; @@ -123,7 +123,7 @@ describe("OpenAI tool strict mode", () => { it("uses uniformly non-strict tool schemas when provider requires all-or-none strictness", async () => { const model: Model<"openai-completions"> = { - ...getBundledModel("openai", "gpt-4o-mini"), + ...(getBundledModel("openai", "gpt-4o-mini") as Model<"openai-completions">), api: "openai-completions", compat: { toolStrictMode: "all_strict" } satisfies OpenAICompat, }; @@ -150,7 +150,7 @@ describe("OpenAI tool strict mode", () => { it("surfaces captured JSON error bodies when the SDK reports no body", async () => { const model: Model<"openai-completions"> = { - ...getBundledModel("openai", "gpt-4o-mini"), + ...(getBundledModel("openai", "gpt-4o-mini") as Model<"openai-completions">), api: "openai-completions", }; global.fetch = Object.assign( @@ -179,7 +179,7 @@ describe("OpenAI tool strict mode", () => { it("retries with non-strict tool schemas after strict-mode request errors", async () => { const model: Model<"openai-completions"> = { - ...getBundledModel("openai", "gpt-4o-mini"), + ...(getBundledModel("openai", "gpt-4o-mini") as Model<"openai-completions">), api: "openai-completions", compat: { toolStrictMode: "all_strict" } satisfies OpenAICompat, }; diff --git a/packages/ai/test/provider-response.test.ts b/packages/ai/test/provider-response.test.ts index 9c04de9f4..f4d3cb393 100644 --- a/packages/ai/test/provider-response.test.ts +++ b/packages/ai/test/provider-response.test.ts @@ -71,7 +71,7 @@ function createSseResponse(events: unknown[], headers: Record = describe("streamSimple onResponse propagation", () => { it("invokes onResponse for the default openai-completions path through streamSimple", async () => { const model: Model<"openai-completions"> = { - ...getBundledModel("openai", "gpt-4o-mini"), + ...(getBundledModel("openai", "gpt-4o-mini") as Model<"openai-completions">), api: "openai-completions", }; diff --git a/packages/ai/test/stream.test.ts b/packages/ai/test/stream.test.ts index d0084353c..324de9db2 100644 --- a/packages/ai/test/stream.test.ts +++ b/packages/ai/test/stream.test.ts @@ -588,7 +588,7 @@ describe("Generate E2E Tests", () => { describe.skipIf(!e2eApiKey("OPENAI_API_KEY"))("OpenAI Completions Provider (gpt-4o-mini)", () => { const llm: Model<"openai-completions"> = { - ...getBundledModel("openai", "gpt-4o-mini"), + ...(getBundledModel("openai", "gpt-4o-mini") as Model<"openai-completions">), api: "openai-completions", }; @@ -626,7 +626,7 @@ describe("Generate E2E Tests", () => { }); describe.skipIf(!e2eApiKey("OPENAI_API_KEY"))("OpenAI Responses Provider (gpt-5-mini)", () => { - const llm = getBundledModel("openai", "gpt-5-mini"); + const llm = getBundledModel("openai", "gpt-5-mini") as Model<"openai-responses">; it( "should complete basic text generation", @@ -714,7 +714,7 @@ describe("Generate E2E Tests", () => { }); describe.skipIf(!e2eApiKey("OPENAI_API_KEY"))("OpenAI Responses Provider (gpt-5-mini)", () => { - const model = getBundledModel("openai", "gpt-5-mini"); + const model = getBundledModel("openai", "gpt-5-mini") as Model<"openai-responses">; it( "should complete basic text generation", diff --git a/packages/ai/test/tokens.test.ts b/packages/ai/test/tokens.test.ts index 857fc7528..dcb62d543 100644 --- a/packages/ai/test/tokens.test.ts +++ b/packages/ai/test/tokens.test.ts @@ -83,7 +83,7 @@ describe("Token Statistics on Abort", () => { describe.skipIf(!e2eApiKey("OPENAI_API_KEY"))("OpenAI Completions Provider", () => { const llm: Model<"openai-completions"> = { - ...getBundledModel("openai", "gpt-4o-mini")!, + ...(getBundledModel("openai", "gpt-4o-mini") as Model<"openai-completions">)!, api: "openai-completions", }; @@ -97,7 +97,7 @@ describe("Token Statistics on Abort", () => { }); describe.skipIf(!e2eApiKey("OPENAI_API_KEY"))("OpenAI Responses Provider", () => { - const llm = getBundledModel("openai", "gpt-5-mini"); + const llm = getBundledModel("openai", "gpt-5-mini") as Model<"openai-responses">; it( "should include token stats when aborted mid-stream", diff --git a/packages/ai/test/tool-call-without-result.test.ts b/packages/ai/test/tool-call-without-result.test.ts index 2ad5f5106..08a0478af 100644 --- a/packages/ai/test/tool-call-without-result.test.ts +++ b/packages/ai/test/tool-call-without-result.test.ts @@ -109,7 +109,7 @@ describe("Tool Call Without Result Tests", () => { describe.skipIf(!e2eApiKey("OPENAI_API_KEY"))("OpenAI Completions Provider", () => { const model: Model<"openai-completions"> = { - ...getBundledModel("openai", "gpt-4o-mini")!, + ...(getBundledModel("openai", "gpt-4o-mini") as Model<"openai-completions">)!, api: "openai-completions", }; @@ -123,7 +123,7 @@ describe("Tool Call Without Result Tests", () => { }); describe.skipIf(!e2eApiKey("OPENAI_API_KEY"))("OpenAI Responses Provider", () => { - const model = getBundledModel("openai", "gpt-5-mini"); + const model = getBundledModel("openai", "gpt-5-mini") as Model<"openai-responses">; it( "should filter out tool calls without corresponding tool results", diff --git a/packages/ai/test/total-tokens.test.ts b/packages/ai/test/total-tokens.test.ts index 77dfad981..1677d47b0 100644 --- a/packages/ai/test/total-tokens.test.ts +++ b/packages/ai/test/total-tokens.test.ts @@ -154,7 +154,7 @@ describe("totalTokens field", () => { "gpt-4o-mini - should return totalTokens equal to sum of components", async () => { const llm: Model<"openai-completions"> = { - ...getBundledModel("openai", "gpt-4o-mini")!, + ...(getBundledModel("openai", "gpt-4o-mini") as Model<"openai-completions">)!, api: "openai-completions", }; diff --git a/packages/ai/test/xhigh.test.ts b/packages/ai/test/xhigh.test.ts index 5fa795e1a..febd6fd59 100644 --- a/packages/ai/test/xhigh.test.ts +++ b/packages/ai/test/xhigh.test.ts @@ -39,7 +39,7 @@ describe.skipIf(!e2eApiKey("OPENAI_API_KEY"))("xhigh reasoning", () => { describe("gpt-5-mini (does not support xhigh)", () => { it("should error with openai-responses when using xhigh", async () => { - const model = getBundledModel("openai", "gpt-5-mini"); + const model = getBundledModel("openai", "gpt-5-mini") as Model<"openai-responses">; const s = stream(model, makeContext(), { reasoning: "xhigh" }); for await (const _ of s) { @@ -53,7 +53,7 @@ describe.skipIf(!e2eApiKey("OPENAI_API_KEY"))("xhigh reasoning", () => { it("should error with openai-completions when using xhigh", async () => { const model: Model<"openai-completions"> = { - ...getBundledModel("openai", "gpt-5-mini"), + ...(getBundledModel("openai", "gpt-5-mini") as Model<"openai-completions">), api: "openai-completions", }; const s = stream(model, makeContext(), { reasoning: "xhigh" }); diff --git a/packages/coding-agent/src/config/model-registry.ts b/packages/coding-agent/src/config/model-registry.ts index f2c10b9c1..54824273a 100644 --- a/packages/coding-agent/src/config/model-registry.ts +++ b/packages/coding-agent/src/config/model-registry.ts @@ -145,8 +145,16 @@ const OpenAICompatSchema = Type.Object({ maxTokensField: Type.Optional(Type.Union([Type.Literal("max_completion_tokens"), Type.Literal("max_tokens")])), supportsUsageInStreaming: Type.Optional(Type.Boolean()), requiresToolResultName: Type.Optional(Type.Boolean()), + requiresMistralToolIds: Type.Optional(Type.Boolean()), requiresAssistantAfterToolResult: Type.Optional(Type.Boolean()), requiresThinkingAsText: Type.Optional(Type.Boolean()), + reasoningContentField: Type.Optional( + Type.Union([Type.Literal("reasoning_content"), Type.Literal("reasoning"), Type.Literal("reasoning_text")]), + ), + requiresReasoningContentForToolCalls: Type.Optional(Type.Boolean()), + requiresAssistantContentForToolCalls: Type.Optional(Type.Boolean()), + supportsToolChoice: Type.Optional(Type.Boolean()), + disableReasoningOnForcedToolChoice: Type.Optional(Type.Boolean()), thinkingFormat: Type.Optional( Type.Union([ Type.Literal("openai"), @@ -559,27 +567,20 @@ function resolveOAuthAccountIdForAccessToken( return undefined; } -function mergeCompat( - baseCompat: Model["compat"], - overrideCompat: ModelOverride["compat"], -): Model["compat"] | undefined { +function mergeCompat( + baseCompat: TBase | null | undefined, + overrideCompat: TOverride | null | undefined, +): (TBase & TOverride) | TBase | TOverride | undefined { + if (!baseCompat) return overrideCompat ?? undefined; if (!overrideCompat) return baseCompat; - const base = baseCompat ?? {}; - const override = overrideCompat; - const merged: NonNullable["compat"]> = { ...base, ...override }; - if (baseCompat?.reasoningEffortMap || overrideCompat.reasoningEffortMap) { - merged.reasoningEffortMap = { ...baseCompat?.reasoningEffortMap, ...overrideCompat.reasoningEffortMap }; + + const merged: Record = { ...(baseCompat as Record) }; + for (const [key, overrideValue] of Object.entries(overrideCompat)) { + const baseValue = (baseCompat as Record)[key]; + merged[key] = + isRecord(baseValue) && isRecord(overrideValue) ? mergeCompat(baseValue, overrideValue) : overrideValue; } - if (baseCompat?.openRouterRouting || overrideCompat.openRouterRouting) { - merged.openRouterRouting = { ...baseCompat?.openRouterRouting, ...overrideCompat.openRouterRouting }; - } - if (baseCompat?.vercelGatewayRouting || overrideCompat.vercelGatewayRouting) { - merged.vercelGatewayRouting = { ...baseCompat?.vercelGatewayRouting, ...overrideCompat.vercelGatewayRouting }; - } - if (baseCompat?.extraBody || overrideCompat.extraBody) { - merged.extraBody = { ...baseCompat?.extraBody, ...overrideCompat.extraBody }; - } - return merged; + return merged as TBase & TOverride; } function applyModelOverride(model: Model, override: ModelOverride): Model { diff --git a/packages/coding-agent/test/issue-816-repro.test.ts b/packages/coding-agent/test/issue-816-repro.test.ts index 242627fe8..7ac0a5eec 100644 --- a/packages/coding-agent/test/issue-816-repro.test.ts +++ b/packages/coding-agent/test/issue-816-repro.test.ts @@ -67,6 +67,7 @@ describe("issue #816 — plan mode pendingModelSwitch leak", () => { model: planModel, thinkingLevel: undefined, explicitThinkingLevel: false, + warning: undefined, }); // Avoid kicking off real session work during plan mode entry. vi.spyOn(session, "sendPlanModeContext").mockResolvedValue(undefined); diff --git a/packages/coding-agent/test/model-registry.test.ts b/packages/coding-agent/test/model-registry.test.ts index 4821062b2..f1d7420f5 100644 --- a/packages/coding-agent/test/model-registry.test.ts +++ b/packages/coding-agent/test/model-registry.test.ts @@ -93,6 +93,10 @@ describe("ModelRegistry", () => { return registry.getAll().filter(m => m.provider === provider); } + function getOpenAICompat(model: Model | undefined): OpenAICompat | undefined { + return model?.api === "openai-completions" ? (model.compat as OpenAICompat | undefined) : undefined; + } + /** Create a baseUrl-only override (no custom models) */ function overrideConfig(baseUrl: string, headers?: Record) { return { baseUrl, ...(headers && { headers }) }; @@ -511,8 +515,8 @@ describe("ModelRegistry", () => { const models = getModelsForProvider(registry, "openrouter"); expect(models.length).toBeGreaterThan(0); for (const model of models) { - expect(model.compat?.supportsUsageInStreaming).toBe(false); - expect(model.compat?.supportsStrictMode).toBe(false); + expect(getOpenAICompat(model)?.supportsUsageInStreaming).toBe(false); + expect(getOpenAICompat(model)?.supportsStrictMode).toBe(false); } }); @@ -541,8 +545,9 @@ describe("ModelRegistry", () => { const registry = new ModelRegistry(authStorage, modelsJsonPath); const model = registry.find("demo", "demo-model"); - expect(model?.compat?.supportsUsageInStreaming).toBe(false); - expect(model?.compat?.maxTokensField).toBe("max_tokens"); + const compat = getOpenAICompat(model); + expect(compat?.supportsUsageInStreaming).toBe(false); + expect(compat?.maxTokensField).toBe("max_tokens"); }); test("model-level compat overrides provider-level compat for custom models", () => { @@ -574,8 +579,9 @@ describe("ModelRegistry", () => { const registry = new ModelRegistry(authStorage, modelsJsonPath); const model = registry.find("demo", "demo-model"); - expect(model?.compat?.supportsUsageInStreaming).toBe(true); - expect(model?.compat?.maxTokensField).toBe("max_completion_tokens"); + const compat = getOpenAICompat(model); + expect(compat?.supportsUsageInStreaming).toBe(true); + expect(compat?.maxTokensField).toBe("max_completion_tokens"); }); }); @@ -883,12 +889,12 @@ describe("ModelRegistry", () => { }, }); const registry = new ModelRegistry(authStorage, modelsJsonPath); - expect(registry.find("openai", "gpt-5.4")?.compat?.extraBody).toEqual({ source: "proxy" }); + expect(getOpenAICompat(registry.find("openai", "gpt-5.4"))?.extraBody).toEqual({ source: "proxy" }); using _hook = mockOpenAiCompatibleModels("https://my-proxy.example.com/v1/models", ["gpt-5.4"]); await registry.refreshProvider("openai", "online"); - expect(registry.find("openai", "gpt-5.4")?.compat?.extraBody).toEqual({ source: "proxy" }); + expect(getOpenAICompat(registry.find("openai", "gpt-5.4"))?.extraBody).toEqual({ source: "proxy" }); }); test("modelOverrides still apply after discoverable refresh", async () => { @@ -962,9 +968,10 @@ describe("ModelRegistry", () => { const registry = new ModelRegistry(authStorage, modelsJsonPath); const model = registry.find("minimax-code", "MiniMax-M2.5"); - expect(model?.compat?.thinkingFormat).toBeUndefined(); - expect(model?.compat?.reasoningContentField).toBeUndefined(); - expect(model?.compat?.extraBody).toEqual({ source: "proxy" }); + const compat = getOpenAICompat(model); + expect(compat?.thinkingFormat).toBeUndefined(); + expect(compat?.reasoningContentField).toBeUndefined(); + expect(compat?.extraBody).toEqual({ source: "proxy" }); }); test("removing custom models from models.json keeps built-in provider models", async () => {