refactor: centralize transcript provider quirks

This commit is contained in:
Peter Steinberger
2026-03-08 16:54:56 +00:00
parent 8a18e2598f
commit ef2541ceb3
3 changed files with 131 additions and 53 deletions

View File

@@ -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);
});
});

View File

@@ -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<string, Partial<ProviderCapabilities>> = {
anthropic: {
providerFamily: "anthropic",
},
"amazon-bedrock": {
providerFamily: "anthropic",
},
"kimi-coding": {
anthropicToolSchemaMode: "openai-functions",
anthropicToolChoiceMode: "openai-string-modes",
@@ -26,17 +40,38 @@ const PROVIDER_CAPABILITIES: Record<string, Partial<ProviderCapabilities>> = {
},
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;
}

View File

@@ -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;