Skip to content

Commit ef2541c

Browse files
committed
refactor: centralize transcript provider quirks
1 parent 8a18e25 commit ef2541c

File tree

3 files changed

+131
-53
lines changed

3 files changed

+131
-53
lines changed

src/agents/provider-capabilities.test.ts

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import { describe, expect, it } from "vitest";
22
import {
3+
isAnthropicProviderFamily,
4+
isOpenAiProviderFamily,
35
requiresOpenAiCompatibleAnthropicToolPayload,
46
resolveProviderCapabilities,
57
resolveTranscriptToolCallIdMode,
6-
sanitizesGeminiThoughtSignatures,
8+
shouldDropThinkingBlocksForModel,
9+
shouldSanitizeGeminiThoughtSignaturesForModel,
710
supportsOpenAiCompatTurnValidation,
811
} from "./provider-capabilities.js";
912

@@ -12,10 +15,14 @@ describe("resolveProviderCapabilities", () => {
1215
expect(resolveProviderCapabilities("anthropic")).toEqual({
1316
anthropicToolSchemaMode: "native",
1417
anthropicToolChoiceMode: "native",
18+
providerFamily: "anthropic",
1519
preserveAnthropicThinkingSignatures: true,
1620
openAiCompatTurnValidation: true,
1721
geminiThoughtSignatureSanitization: false,
1822
transcriptToolCallIdMode: "default",
23+
transcriptToolCallIdModelHints: [],
24+
geminiThoughtSignatureModelHints: [],
25+
dropThinkingBlockModelHints: [],
1926
});
2027
});
2128

@@ -26,10 +33,14 @@ describe("resolveProviderCapabilities", () => {
2633
expect(resolveProviderCapabilities("kimi-code")).toEqual({
2734
anthropicToolSchemaMode: "openai-functions",
2835
anthropicToolChoiceMode: "openai-string-modes",
36+
providerFamily: "default",
2937
preserveAnthropicThinkingSignatures: false,
3038
openAiCompatTurnValidation: true,
3139
geminiThoughtSignatureSanitization: false,
3240
transcriptToolCallIdMode: "default",
41+
transcriptToolCallIdModelHints: [],
42+
geminiThoughtSignatureModelHints: [],
43+
dropThinkingBlockModelHints: [],
3344
});
3445
});
3546

@@ -40,14 +51,35 @@ describe("resolveProviderCapabilities", () => {
4051
});
4152

4253
it("resolves transcript thought-signature and tool-call quirks through the registry", () => {
43-
expect(sanitizesGeminiThoughtSignatures("openrouter")).toBe(true);
44-
expect(sanitizesGeminiThoughtSignatures("kilocode")).toBe(true);
45-
expect(resolveTranscriptToolCallIdMode("mistral")).toBe("strict9");
54+
expect(
55+
shouldSanitizeGeminiThoughtSignaturesForModel({
56+
provider: "openrouter",
57+
modelId: "google/gemini-2.5-pro-preview",
58+
}),
59+
).toBe(true);
60+
expect(
61+
shouldSanitizeGeminiThoughtSignaturesForModel({
62+
provider: "kilocode",
63+
modelId: "gemini-2.0-flash",
64+
}),
65+
).toBe(true);
66+
expect(resolveTranscriptToolCallIdMode("mistral", "mistral-large-latest")).toBe("strict9");
4667
});
4768

4869
it("treats kimi aliases as anthropic tool payload compatibility providers", () => {
4970
expect(requiresOpenAiCompatibleAnthropicToolPayload("kimi-coding")).toBe(true);
5071
expect(requiresOpenAiCompatibleAnthropicToolPayload("kimi-code")).toBe(true);
5172
expect(requiresOpenAiCompatibleAnthropicToolPayload("anthropic")).toBe(false);
5273
});
74+
75+
it("tracks provider families and model-specific transcript quirks in the registry", () => {
76+
expect(isOpenAiProviderFamily("openai")).toBe(true);
77+
expect(isAnthropicProviderFamily("amazon-bedrock")).toBe(true);
78+
expect(
79+
shouldDropThinkingBlocksForModel({
80+
provider: "github-copilot",
81+
modelId: "claude-3.7-sonnet",
82+
}),
83+
).toBe(true);
84+
});
5385
});

src/agents/provider-capabilities.ts

Lines changed: 81 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,40 +3,75 @@ import { normalizeProviderId } from "./model-selection.js";
33
export type ProviderCapabilities = {
44
anthropicToolSchemaMode: "native" | "openai-functions";
55
anthropicToolChoiceMode: "native" | "openai-string-modes";
6+
providerFamily: "default" | "openai" | "anthropic";
67
preserveAnthropicThinkingSignatures: boolean;
78
openAiCompatTurnValidation: boolean;
89
geminiThoughtSignatureSanitization: boolean;
910
transcriptToolCallIdMode: "default" | "strict9";
11+
transcriptToolCallIdModelHints: string[];
12+
geminiThoughtSignatureModelHints: string[];
13+
dropThinkingBlockModelHints: string[];
1014
};
1115

1216
const DEFAULT_PROVIDER_CAPABILITIES: ProviderCapabilities = {
1317
anthropicToolSchemaMode: "native",
1418
anthropicToolChoiceMode: "native",
19+
providerFamily: "default",
1520
preserveAnthropicThinkingSignatures: true,
1621
openAiCompatTurnValidation: true,
1722
geminiThoughtSignatureSanitization: false,
1823
transcriptToolCallIdMode: "default",
24+
transcriptToolCallIdModelHints: [],
25+
geminiThoughtSignatureModelHints: [],
26+
dropThinkingBlockModelHints: [],
1927
};
2028

2129
const PROVIDER_CAPABILITIES: Record<string, Partial<ProviderCapabilities>> = {
30+
anthropic: {
31+
providerFamily: "anthropic",
32+
},
33+
"amazon-bedrock": {
34+
providerFamily: "anthropic",
35+
},
2236
"kimi-coding": {
2337
anthropicToolSchemaMode: "openai-functions",
2438
anthropicToolChoiceMode: "openai-string-modes",
2539
preserveAnthropicThinkingSignatures: false,
2640
},
2741
mistral: {
2842
transcriptToolCallIdMode: "strict9",
43+
transcriptToolCallIdModelHints: [
44+
"mistral",
45+
"mixtral",
46+
"codestral",
47+
"pixtral",
48+
"devstral",
49+
"ministral",
50+
"mistralai",
51+
],
52+
},
53+
openai: {
54+
providerFamily: "openai",
55+
},
56+
"openai-codex": {
57+
providerFamily: "openai",
2958
},
3059
openrouter: {
3160
openAiCompatTurnValidation: false,
3261
geminiThoughtSignatureSanitization: true,
62+
geminiThoughtSignatureModelHints: ["gemini"],
3363
},
3464
opencode: {
3565
openAiCompatTurnValidation: false,
3666
geminiThoughtSignatureSanitization: true,
67+
geminiThoughtSignatureModelHints: ["gemini"],
3768
},
3869
kilocode: {
3970
geminiThoughtSignatureSanitization: true,
71+
geminiThoughtSignatureModelHints: ["gemini"],
72+
},
73+
"github-copilot": {
74+
dropThinkingBlockModelHints: ["claude"],
4075
},
4176
};
4277

@@ -76,7 +111,51 @@ export function sanitizesGeminiThoughtSignatures(provider?: string | null): bool
76111
return resolveProviderCapabilities(provider).geminiThoughtSignatureSanitization;
77112
}
78113

79-
export function resolveTranscriptToolCallIdMode(provider?: string | null): "strict9" | undefined {
80-
const mode = resolveProviderCapabilities(provider).transcriptToolCallIdMode;
114+
function modelIncludesAnyHint(modelId: string | null | undefined, hints: string[]): boolean {
115+
const normalized = (modelId ?? "").toLowerCase();
116+
return Boolean(normalized) && hints.some((hint) => normalized.includes(hint));
117+
}
118+
119+
export function isOpenAiProviderFamily(provider?: string | null): boolean {
120+
return resolveProviderCapabilities(provider).providerFamily === "openai";
121+
}
122+
123+
export function isAnthropicProviderFamily(provider?: string | null): boolean {
124+
return resolveProviderCapabilities(provider).providerFamily === "anthropic";
125+
}
126+
127+
export function shouldDropThinkingBlocksForModel(params: {
128+
provider?: string | null;
129+
modelId?: string | null;
130+
}): boolean {
131+
return modelIncludesAnyHint(
132+
params.modelId,
133+
resolveProviderCapabilities(params.provider).dropThinkingBlockModelHints,
134+
);
135+
}
136+
137+
export function shouldSanitizeGeminiThoughtSignaturesForModel(params: {
138+
provider?: string | null;
139+
modelId?: string | null;
140+
}): boolean {
141+
const capabilities = resolveProviderCapabilities(params.provider);
142+
return (
143+
capabilities.geminiThoughtSignatureSanitization &&
144+
modelIncludesAnyHint(params.modelId, capabilities.geminiThoughtSignatureModelHints)
145+
);
146+
}
147+
148+
export function resolveTranscriptToolCallIdMode(
149+
provider?: string | null,
150+
modelId?: string | null,
151+
): "strict9" | undefined {
152+
const capabilities = resolveProviderCapabilities(provider);
153+
const mode = capabilities.transcriptToolCallIdMode;
154+
if (mode === "strict9") {
155+
return mode;
156+
}
157+
if (modelIncludesAnyHint(modelId, capabilities.transcriptToolCallIdModelHints)) {
158+
return "strict9";
159+
}
81160
return mode === "strict9" ? mode : undefined;
82161
}

src/agents/transcript-policy.ts

Lines changed: 14 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import { normalizeProviderId } from "./model-selection.js";
22
import { isGoogleModelApi } from "./pi-embedded-helpers/google.js";
33
import {
4+
isAnthropicProviderFamily,
5+
isOpenAiProviderFamily,
46
preservesAnthropicThinkingSignatures,
57
resolveTranscriptToolCallIdMode,
6-
sanitizesGeminiThoughtSignatures,
8+
shouldDropThinkingBlocksForModel,
9+
shouldSanitizeGeminiThoughtSignaturesForModel,
710
supportsOpenAiCompatTurnValidation,
811
} from "./provider-capabilities.js";
912
import type { ToolCallIdMode } from "./tool-call-id.js";
@@ -28,22 +31,12 @@ export type TranscriptPolicy = {
2831
allowSyntheticToolResults: boolean;
2932
};
3033

31-
const MISTRAL_MODEL_HINTS = [
32-
"mistral",
33-
"mixtral",
34-
"codestral",
35-
"pixtral",
36-
"devstral",
37-
"ministral",
38-
"mistralai",
39-
];
4034
const OPENAI_MODEL_APIS = new Set([
4135
"openai",
4236
"openai-completions",
4337
"openai-responses",
4438
"openai-codex-responses",
4539
]);
46-
const OPENAI_PROVIDERS = new Set(["openai", "openai-codex"]);
4740

4841
function isOpenAiApi(modelApi?: string | null): boolean {
4942
if (!modelApi) {
@@ -53,41 +46,15 @@ function isOpenAiApi(modelApi?: string | null): boolean {
5346
}
5447

5548
function isOpenAiProvider(provider?: string | null): boolean {
56-
if (!provider) {
57-
return false;
58-
}
59-
return OPENAI_PROVIDERS.has(normalizeProviderId(provider));
49+
return isOpenAiProviderFamily(provider);
6050
}
6151

6252
function isAnthropicApi(modelApi?: string | null, provider?: string | null): boolean {
6353
if (modelApi === "anthropic-messages" || modelApi === "bedrock-converse-stream") {
6454
return true;
6555
}
66-
const normalized = normalizeProviderId(provider ?? "");
6756
// MiniMax now uses openai-completions API, not anthropic-messages
68-
return normalized === "anthropic" || normalized === "amazon-bedrock";
69-
}
70-
71-
function isMistralModel(modelId?: string | null): boolean {
72-
const normalizedModelId = (modelId ?? "").toLowerCase();
73-
if (!normalizedModelId) {
74-
return false;
75-
}
76-
return MISTRAL_MODEL_HINTS.some((hint) => normalizedModelId.includes(hint));
77-
}
78-
79-
function shouldSanitizeGeminiThoughtSignatures(params: {
80-
provider?: string | null;
81-
modelId?: string | null;
82-
}): boolean {
83-
if (!sanitizesGeminiThoughtSignatures(params.provider)) {
84-
return false;
85-
}
86-
const modelId = (params.modelId ?? "").toLowerCase();
87-
if (!modelId) {
88-
return false;
89-
}
90-
return modelId.includes("gemini");
57+
return isAnthropicProviderFamily(provider);
9158
}
9259

9360
export function resolveTranscriptPolicy(params: {
@@ -104,19 +71,19 @@ export function resolveTranscriptPolicy(params: {
10471
params.modelApi === "openai-completions" &&
10572
!isOpenAi &&
10673
supportsOpenAiCompatTurnValidation(provider);
107-
const providerToolCallIdMode = resolveTranscriptToolCallIdMode(provider);
108-
const isMistral = providerToolCallIdMode === "strict9" || isMistralModel(modelId);
109-
const shouldSanitizeGeminiThoughtSignaturesForProvider = shouldSanitizeGeminiThoughtSignatures({
110-
provider,
111-
modelId,
112-
});
113-
const isCopilotClaude = provider === "github-copilot" && modelId.toLowerCase().includes("claude");
74+
const providerToolCallIdMode = resolveTranscriptToolCallIdMode(provider, modelId);
75+
const isMistral = providerToolCallIdMode === "strict9";
76+
const shouldSanitizeGeminiThoughtSignaturesForProvider =
77+
shouldSanitizeGeminiThoughtSignaturesForModel({
78+
provider,
79+
modelId,
80+
});
11481
const requiresOpenAiCompatibleToolIdSanitization = params.modelApi === "openai-completions";
11582

11683
// GitHub Copilot's Claude endpoints can reject persisted `thinking` blocks with
11784
// non-binary/non-base64 signatures (e.g. thinkingSignature: "reasoning_text").
11885
// Drop these blocks at send-time to keep sessions usable.
119-
const dropThinkingBlocks = isCopilotClaude;
86+
const dropThinkingBlocks = shouldDropThinkingBlocksForModel({ provider, modelId });
12087

12188
const needsNonImageSanitize =
12289
isGoogle || isAnthropic || isMistral || shouldSanitizeGeminiThoughtSignaturesForProvider;

0 commit comments

Comments
 (0)