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<OpenAICompat> 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<OpenAICompat> literals updated for the field set.

Fixes CI red on main.
This commit is contained in:
can1357
2026-04-30 05:23:20 +02:00
parent 7bacfc4e31
commit fed95ce524
19 changed files with 85 additions and 66 deletions
+2 -2
View File
@@ -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<Api> {
export function getBundledModel<TApi extends Api = Api>(provider: GeneratedProvider, modelId: string): Model<TApi> {
const providerModels = modelRegistry.get(provider);
return providerModels?.get(modelId) as Model<Api>;
return providerModels?.get(modelId) as Model<TApi>;
}
export function getBundledProviders(): KnownProvider[] {
+1 -1
View File
@@ -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<string, unknown>).id = msg.toolCallId;
(block as unknown as Record<string, unknown>).id = msg.toolCallId;
}
return block;
}
@@ -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,
+4 -4
View File
@@ -14,16 +14,16 @@ import type { AssistantMessage, Model, ToolResultMessage, UserMessage } from "@o
* endpoints must remain unchanged (no `id` field).
*/
const baseModel = {
const baseModel: Omit<Model<"anthropic-messages">, "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(),
};
+7 -4
View File
@@ -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 } }>();
+8 -5
View File
@@ -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");
});
});
@@ -73,6 +73,7 @@ describe("openai-completions compatibility", () => {
reasoningEffortMap: {},
supportsUsageInStreaming: true,
supportsToolChoice: true,
disableReasoningOnForcedToolChoice: false,
maxTokensField: "max_completion_tokens",
requiresToolResultName: false,
requiresAssistantAfterToolResult: false,
@@ -19,6 +19,7 @@ const compat: Required<OpenAICompat> = {
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",
@@ -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"> = {
@@ -71,7 +71,7 @@ function captureResponsesPayload(model: Model<"openai-responses">): Promise<unkn
describe("OpenAI tool strict mode", () => {
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,
};
+1 -1
View File
@@ -71,7 +71,7 @@ function createSseResponse(events: unknown[], headers: Record<string, string> =
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",
};
+3 -3
View File
@@ -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",
+2 -2
View File
@@ -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",
@@ -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",
+1 -1
View File
@@ -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",
};
+2 -2
View File
@@ -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" });
@@ -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<Api>["compat"],
overrideCompat: ModelOverride["compat"],
): Model<Api>["compat"] | undefined {
function mergeCompat<TBase extends object, TOverride extends object>(
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<Model<Api>["compat"]> = { ...base, ...override };
if (baseCompat?.reasoningEffortMap || overrideCompat.reasoningEffortMap) {
merged.reasoningEffortMap = { ...baseCompat?.reasoningEffortMap, ...overrideCompat.reasoningEffortMap };
const merged: Record<string, unknown> = { ...(baseCompat as Record<string, unknown>) };
for (const [key, overrideValue] of Object.entries(overrideCompat)) {
const baseValue = (baseCompat as Record<string, unknown>)[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<Api>, override: ModelOverride): Model<Api> {
@@ -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);
@@ -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<string, string>) {
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 () => {