diff --git a/src/agents/provider-capabilities.test.ts b/src/agents/provider-capabilities.test.ts index d080c00f3..5f97ac957 100644 --- a/src/agents/provider-capabilities.test.ts +++ b/src/agents/provider-capabilities.test.ts @@ -1,9 +1,12 @@ import { describe, expect, it } from "vitest"; import { + isAnthropicProviderFamily, + isOpenAiProviderFamily, requiresOpenAiCompatibleAnthropicToolPayload, resolveProviderCapabilities, resolveTranscriptToolCallIdMode, - sanitizesGeminiThoughtSignatures, + shouldDropThinkingBlocksForModel, + shouldSanitizeGeminiThoughtSignaturesForModel, supportsOpenAiCompatTurnValidation, } from "./provider-capabilities.js"; @@ -12,10 +15,14 @@ describe("resolveProviderCapabilities", () => { expect(resolveProviderCapabilities("anthropic")).toEqual({ anthropicToolSchemaMode: "native", anthropicToolChoiceMode: "native", + providerFamily: "anthropic", preserveAnthropicThinkingSignatures: true, openAiCompatTurnValidation: true, geminiThoughtSignatureSanitization: false, transcriptToolCallIdMode: "default", + transcriptToolCallIdModelHints: [], + geminiThoughtSignatureModelHints: [], + dropThinkingBlockModelHints: [], }); }); @@ -26,10 +33,14 @@ describe("resolveProviderCapabilities", () => { expect(resolveProviderCapabilities("kimi-code")).toEqual({ anthropicToolSchemaMode: "openai-functions", anthropicToolChoiceMode: "openai-string-modes", + providerFamily: "default", preserveAnthropicThinkingSignatures: false, openAiCompatTurnValidation: true, geminiThoughtSignatureSanitization: false, transcriptToolCallIdMode: "default", + transcriptToolCallIdModelHints: [], + geminiThoughtSignatureModelHints: [], + dropThinkingBlockModelHints: [], }); }); @@ -40,9 +51,19 @@ describe("resolveProviderCapabilities", () => { }); it("resolves transcript thought-signature and tool-call quirks through the registry", () => { - expect(sanitizesGeminiThoughtSignatures("openrouter")).toBe(true); - expect(sanitizesGeminiThoughtSignatures("kilocode")).toBe(true); - expect(resolveTranscriptToolCallIdMode("mistral")).toBe("strict9"); + expect( + shouldSanitizeGeminiThoughtSignaturesForModel({ + provider: "openrouter", + modelId: "google/gemini-2.5-pro-preview", + }), + ).toBe(true); + expect( + shouldSanitizeGeminiThoughtSignaturesForModel({ + provider: "kilocode", + modelId: "gemini-2.0-flash", + }), + ).toBe(true); + expect(resolveTranscriptToolCallIdMode("mistral", "mistral-large-latest")).toBe("strict9"); }); it("treats kimi aliases as anthropic tool payload compatibility providers", () => { @@ -50,4 +71,15 @@ describe("resolveProviderCapabilities", () => { expect(requiresOpenAiCompatibleAnthropicToolPayload("kimi-code")).toBe(true); expect(requiresOpenAiCompatibleAnthropicToolPayload("anthropic")).toBe(false); }); + + it("tracks provider families and model-specific transcript quirks in the registry", () => { + expect(isOpenAiProviderFamily("openai")).toBe(true); + expect(isAnthropicProviderFamily("amazon-bedrock")).toBe(true); + expect( + shouldDropThinkingBlocksForModel({ + provider: "github-copilot", + modelId: "claude-3.7-sonnet", + }), + ).toBe(true); + }); }); diff --git a/src/agents/provider-capabilities.ts b/src/agents/provider-capabilities.ts index 57fd364fb..807c72e22 100644 --- a/src/agents/provider-capabilities.ts +++ b/src/agents/provider-capabilities.ts @@ -3,22 +3,36 @@ import { normalizeProviderId } from "./model-selection.js"; export type ProviderCapabilities = { anthropicToolSchemaMode: "native" | "openai-functions"; anthropicToolChoiceMode: "native" | "openai-string-modes"; + providerFamily: "default" | "openai" | "anthropic"; preserveAnthropicThinkingSignatures: boolean; openAiCompatTurnValidation: boolean; geminiThoughtSignatureSanitization: boolean; transcriptToolCallIdMode: "default" | "strict9"; + transcriptToolCallIdModelHints: string[]; + geminiThoughtSignatureModelHints: string[]; + dropThinkingBlockModelHints: string[]; }; const DEFAULT_PROVIDER_CAPABILITIES: ProviderCapabilities = { anthropicToolSchemaMode: "native", anthropicToolChoiceMode: "native", + providerFamily: "default", preserveAnthropicThinkingSignatures: true, openAiCompatTurnValidation: true, geminiThoughtSignatureSanitization: false, transcriptToolCallIdMode: "default", + transcriptToolCallIdModelHints: [], + geminiThoughtSignatureModelHints: [], + dropThinkingBlockModelHints: [], }; const PROVIDER_CAPABILITIES: Record> = { + anthropic: { + providerFamily: "anthropic", + }, + "amazon-bedrock": { + providerFamily: "anthropic", + }, "kimi-coding": { anthropicToolSchemaMode: "openai-functions", anthropicToolChoiceMode: "openai-string-modes", @@ -26,17 +40,38 @@ const PROVIDER_CAPABILITIES: Record> = { }, mistral: { transcriptToolCallIdMode: "strict9", + transcriptToolCallIdModelHints: [ + "mistral", + "mixtral", + "codestral", + "pixtral", + "devstral", + "ministral", + "mistralai", + ], + }, + openai: { + providerFamily: "openai", + }, + "openai-codex": { + providerFamily: "openai", }, openrouter: { openAiCompatTurnValidation: false, geminiThoughtSignatureSanitization: true, + geminiThoughtSignatureModelHints: ["gemini"], }, opencode: { openAiCompatTurnValidation: false, geminiThoughtSignatureSanitization: true, + geminiThoughtSignatureModelHints: ["gemini"], }, kilocode: { geminiThoughtSignatureSanitization: true, + geminiThoughtSignatureModelHints: ["gemini"], + }, + "github-copilot": { + dropThinkingBlockModelHints: ["claude"], }, }; @@ -76,7 +111,51 @@ export function sanitizesGeminiThoughtSignatures(provider?: string | null): bool return resolveProviderCapabilities(provider).geminiThoughtSignatureSanitization; } -export function resolveTranscriptToolCallIdMode(provider?: string | null): "strict9" | undefined { - const mode = resolveProviderCapabilities(provider).transcriptToolCallIdMode; +function modelIncludesAnyHint(modelId: string | null | undefined, hints: string[]): boolean { + const normalized = (modelId ?? "").toLowerCase(); + return Boolean(normalized) && hints.some((hint) => normalized.includes(hint)); +} + +export function isOpenAiProviderFamily(provider?: string | null): boolean { + return resolveProviderCapabilities(provider).providerFamily === "openai"; +} + +export function isAnthropicProviderFamily(provider?: string | null): boolean { + return resolveProviderCapabilities(provider).providerFamily === "anthropic"; +} + +export function shouldDropThinkingBlocksForModel(params: { + provider?: string | null; + modelId?: string | null; +}): boolean { + return modelIncludesAnyHint( + params.modelId, + resolveProviderCapabilities(params.provider).dropThinkingBlockModelHints, + ); +} + +export function shouldSanitizeGeminiThoughtSignaturesForModel(params: { + provider?: string | null; + modelId?: string | null; +}): boolean { + const capabilities = resolveProviderCapabilities(params.provider); + return ( + capabilities.geminiThoughtSignatureSanitization && + modelIncludesAnyHint(params.modelId, capabilities.geminiThoughtSignatureModelHints) + ); +} + +export function resolveTranscriptToolCallIdMode( + provider?: string | null, + modelId?: string | null, +): "strict9" | undefined { + const capabilities = resolveProviderCapabilities(provider); + const mode = capabilities.transcriptToolCallIdMode; + if (mode === "strict9") { + return mode; + } + if (modelIncludesAnyHint(modelId, capabilities.transcriptToolCallIdModelHints)) { + return "strict9"; + } return mode === "strict9" ? mode : undefined; } diff --git a/src/agents/transcript-policy.ts b/src/agents/transcript-policy.ts index 491c22ab5..d6d9ec591 100644 --- a/src/agents/transcript-policy.ts +++ b/src/agents/transcript-policy.ts @@ -1,9 +1,12 @@ import { normalizeProviderId } from "./model-selection.js"; import { isGoogleModelApi } from "./pi-embedded-helpers/google.js"; import { + isAnthropicProviderFamily, + isOpenAiProviderFamily, preservesAnthropicThinkingSignatures, resolveTranscriptToolCallIdMode, - sanitizesGeminiThoughtSignatures, + shouldDropThinkingBlocksForModel, + shouldSanitizeGeminiThoughtSignaturesForModel, supportsOpenAiCompatTurnValidation, } from "./provider-capabilities.js"; import type { ToolCallIdMode } from "./tool-call-id.js"; @@ -28,22 +31,12 @@ export type TranscriptPolicy = { allowSyntheticToolResults: boolean; }; -const MISTRAL_MODEL_HINTS = [ - "mistral", - "mixtral", - "codestral", - "pixtral", - "devstral", - "ministral", - "mistralai", -]; const OPENAI_MODEL_APIS = new Set([ "openai", "openai-completions", "openai-responses", "openai-codex-responses", ]); -const OPENAI_PROVIDERS = new Set(["openai", "openai-codex"]); function isOpenAiApi(modelApi?: string | null): boolean { if (!modelApi) { @@ -53,41 +46,15 @@ function isOpenAiApi(modelApi?: string | null): boolean { } function isOpenAiProvider(provider?: string | null): boolean { - if (!provider) { - return false; - } - return OPENAI_PROVIDERS.has(normalizeProviderId(provider)); + return isOpenAiProviderFamily(provider); } function isAnthropicApi(modelApi?: string | null, provider?: string | null): boolean { if (modelApi === "anthropic-messages" || modelApi === "bedrock-converse-stream") { return true; } - const normalized = normalizeProviderId(provider ?? ""); // MiniMax now uses openai-completions API, not anthropic-messages - return normalized === "anthropic" || normalized === "amazon-bedrock"; -} - -function isMistralModel(modelId?: string | null): boolean { - const normalizedModelId = (modelId ?? "").toLowerCase(); - if (!normalizedModelId) { - return false; - } - return MISTRAL_MODEL_HINTS.some((hint) => normalizedModelId.includes(hint)); -} - -function shouldSanitizeGeminiThoughtSignatures(params: { - provider?: string | null; - modelId?: string | null; -}): boolean { - if (!sanitizesGeminiThoughtSignatures(params.provider)) { - return false; - } - const modelId = (params.modelId ?? "").toLowerCase(); - if (!modelId) { - return false; - } - return modelId.includes("gemini"); + return isAnthropicProviderFamily(provider); } export function resolveTranscriptPolicy(params: { @@ -104,19 +71,19 @@ export function resolveTranscriptPolicy(params: { params.modelApi === "openai-completions" && !isOpenAi && supportsOpenAiCompatTurnValidation(provider); - const providerToolCallIdMode = resolveTranscriptToolCallIdMode(provider); - const isMistral = providerToolCallIdMode === "strict9" || isMistralModel(modelId); - const shouldSanitizeGeminiThoughtSignaturesForProvider = shouldSanitizeGeminiThoughtSignatures({ - provider, - modelId, - }); - const isCopilotClaude = provider === "github-copilot" && modelId.toLowerCase().includes("claude"); + const providerToolCallIdMode = resolveTranscriptToolCallIdMode(provider, modelId); + const isMistral = providerToolCallIdMode === "strict9"; + const shouldSanitizeGeminiThoughtSignaturesForProvider = + shouldSanitizeGeminiThoughtSignaturesForModel({ + provider, + modelId, + }); const requiresOpenAiCompatibleToolIdSanitization = params.modelApi === "openai-completions"; // GitHub Copilot's Claude endpoints can reject persisted `thinking` blocks with // non-binary/non-base64 signatures (e.g. thinkingSignature: "reasoning_text"). // Drop these blocks at send-time to keep sessions usable. - const dropThinkingBlocks = isCopilotClaude; + const dropThinkingBlocks = shouldDropThinkingBlocksForModel({ provider, modelId }); const needsNonImageSanitize = isGoogle || isAnthropic || isMistral || shouldSanitizeGeminiThoughtSignaturesForProvider;