refactor: centralize transcript provider quirks
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user