From b41bcb08a283810e45b3f657992b64c6eec96a10 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 8 Mar 2026 16:07:50 +0000 Subject: [PATCH] refactor: expand provider capability registry --- src/agents/pi-embedded-runner/extra-params.ts | 24 ++++---- src/agents/provider-capabilities.test.ts | 32 +++++++++- src/agents/provider-capabilities.ts | 41 +++++++++++++ src/agents/transcript-policy.test.ts | 16 +++++ src/agents/transcript-policy.ts | 59 +++++++++++++------ 5 files changed, 138 insertions(+), 34 deletions(-) diff --git a/src/agents/pi-embedded-runner/extra-params.ts b/src/agents/pi-embedded-runner/extra-params.ts index 254d74c8f1d..7ac14f9ee98 100644 --- a/src/agents/pi-embedded-runner/extra-params.ts +++ b/src/agents/pi-embedded-runner/extra-params.ts @@ -4,6 +4,7 @@ import { streamSimple } from "@mariozechner/pi-ai"; import type { ThinkLevel } from "../../auto-reply/thinking.js"; import type { OpenClawConfig } from "../../config/config.js"; import { + requiresOpenAiCompatibleAnthropicToolPayload, usesOpenAiFunctionAnthropicToolSchema, usesOpenAiStringModeAnthropicToolChoice, } from "../provider-capabilities.js"; @@ -790,7 +791,7 @@ function createMoonshotThinkingWrapper( }; } -function requiresAnthropicToolPayloadCompatibility(model: { +function requiresAnthropicToolPayloadCompatibilityForModel(model: { api?: unknown; provider?: unknown; baseUrl?: unknown; @@ -799,7 +800,10 @@ function requiresAnthropicToolPayloadCompatibility(model: { return false; } - if (typeof model.provider === "string" && usesOpenAiFunctionAnthropicToolSchema(model.provider)) { + if ( + typeof model.provider === "string" && + requiresOpenAiCompatibleAnthropicToolPayload(model.provider) + ) { return true; } @@ -899,27 +903,19 @@ function createAnthropicToolPayloadCompatibilityWrapper( return underlying(model, context, { ...options, onPayload: (payload) => { + const provider = typeof model.provider === "string" ? model.provider : undefined; if ( payload && typeof payload === "object" && - requiresAnthropicToolPayloadCompatibility(model) + requiresAnthropicToolPayloadCompatibilityForModel(model) ) { const payloadObj = payload as Record; - if ( - Array.isArray(payloadObj.tools) && - usesOpenAiFunctionAnthropicToolSchema( - typeof model.provider === "string" ? model.provider : undefined, - ) - ) { + if (Array.isArray(payloadObj.tools) && usesOpenAiFunctionAnthropicToolSchema(provider)) { payloadObj.tools = payloadObj.tools .map((tool) => normalizeOpenAiFunctionAnthropicToolDefinition(tool)) .filter((tool): tool is Record => !!tool); } - if ( - usesOpenAiStringModeAnthropicToolChoice( - typeof model.provider === "string" ? model.provider : undefined, - ) - ) { + if (usesOpenAiStringModeAnthropicToolChoice(provider)) { payloadObj.tool_choice = normalizeOpenAiStringModeAnthropicToolChoice( payloadObj.tool_choice, ); diff --git a/src/agents/provider-capabilities.test.ts b/src/agents/provider-capabilities.test.ts index 80290f2019b..d080c00f36d 100644 --- a/src/agents/provider-capabilities.test.ts +++ b/src/agents/provider-capabilities.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from "vitest"; -import { resolveProviderCapabilities } from "./provider-capabilities.js"; +import { + requiresOpenAiCompatibleAnthropicToolPayload, + resolveProviderCapabilities, + resolveTranscriptToolCallIdMode, + sanitizesGeminiThoughtSignatures, + supportsOpenAiCompatTurnValidation, +} from "./provider-capabilities.js"; describe("resolveProviderCapabilities", () => { it("returns native anthropic defaults for ordinary providers", () => { @@ -7,6 +13,9 @@ describe("resolveProviderCapabilities", () => { anthropicToolSchemaMode: "native", anthropicToolChoiceMode: "native", preserveAnthropicThinkingSignatures: true, + openAiCompatTurnValidation: true, + geminiThoughtSignatureSanitization: false, + transcriptToolCallIdMode: "default", }); }); @@ -18,6 +27,27 @@ describe("resolveProviderCapabilities", () => { anthropicToolSchemaMode: "openai-functions", anthropicToolChoiceMode: "openai-string-modes", preserveAnthropicThinkingSignatures: false, + openAiCompatTurnValidation: true, + geminiThoughtSignatureSanitization: false, + transcriptToolCallIdMode: "default", }); }); + + it("flags providers that opt out of OpenAI-compatible turn validation", () => { + expect(supportsOpenAiCompatTurnValidation("openrouter")).toBe(false); + expect(supportsOpenAiCompatTurnValidation("opencode")).toBe(false); + expect(supportsOpenAiCompatTurnValidation("moonshot")).toBe(true); + }); + + 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"); + }); + + it("treats kimi aliases as anthropic tool payload compatibility providers", () => { + expect(requiresOpenAiCompatibleAnthropicToolPayload("kimi-coding")).toBe(true); + expect(requiresOpenAiCompatibleAnthropicToolPayload("kimi-code")).toBe(true); + expect(requiresOpenAiCompatibleAnthropicToolPayload("anthropic")).toBe(false); + }); }); diff --git a/src/agents/provider-capabilities.ts b/src/agents/provider-capabilities.ts index 1d3cc055047..57fd364fbee 100644 --- a/src/agents/provider-capabilities.ts +++ b/src/agents/provider-capabilities.ts @@ -4,12 +4,18 @@ export type ProviderCapabilities = { anthropicToolSchemaMode: "native" | "openai-functions"; anthropicToolChoiceMode: "native" | "openai-string-modes"; preserveAnthropicThinkingSignatures: boolean; + openAiCompatTurnValidation: boolean; + geminiThoughtSignatureSanitization: boolean; + transcriptToolCallIdMode: "default" | "strict9"; }; const DEFAULT_PROVIDER_CAPABILITIES: ProviderCapabilities = { anthropicToolSchemaMode: "native", anthropicToolChoiceMode: "native", preserveAnthropicThinkingSignatures: true, + openAiCompatTurnValidation: true, + geminiThoughtSignatureSanitization: false, + transcriptToolCallIdMode: "default", }; const PROVIDER_CAPABILITIES: Record> = { @@ -18,6 +24,20 @@ const PROVIDER_CAPABILITIES: Record> = { anthropicToolChoiceMode: "openai-string-modes", preserveAnthropicThinkingSignatures: false, }, + mistral: { + transcriptToolCallIdMode: "strict9", + }, + openrouter: { + openAiCompatTurnValidation: false, + geminiThoughtSignatureSanitization: true, + }, + opencode: { + openAiCompatTurnValidation: false, + geminiThoughtSignatureSanitization: true, + }, + kilocode: { + geminiThoughtSignatureSanitization: true, + }, }; export function resolveProviderCapabilities(provider?: string | null): ProviderCapabilities { @@ -32,6 +52,14 @@ export function preservesAnthropicThinkingSignatures(provider?: string | null): return resolveProviderCapabilities(provider).preserveAnthropicThinkingSignatures; } +export function requiresOpenAiCompatibleAnthropicToolPayload(provider?: string | null): boolean { + const capabilities = resolveProviderCapabilities(provider); + return ( + capabilities.anthropicToolSchemaMode !== "native" || + capabilities.anthropicToolChoiceMode !== "native" + ); +} + export function usesOpenAiFunctionAnthropicToolSchema(provider?: string | null): boolean { return resolveProviderCapabilities(provider).anthropicToolSchemaMode === "openai-functions"; } @@ -39,3 +67,16 @@ export function usesOpenAiFunctionAnthropicToolSchema(provider?: string | null): export function usesOpenAiStringModeAnthropicToolChoice(provider?: string | null): boolean { return resolveProviderCapabilities(provider).anthropicToolChoiceMode === "openai-string-modes"; } + +export function supportsOpenAiCompatTurnValidation(provider?: string | null): boolean { + return resolveProviderCapabilities(provider).openAiCompatTurnValidation; +} + +export function sanitizesGeminiThoughtSignatures(provider?: string | null): boolean { + return resolveProviderCapabilities(provider).geminiThoughtSignatureSanitization; +} + +export function resolveTranscriptToolCallIdMode(provider?: string | null): "strict9" | undefined { + const mode = resolveProviderCapabilities(provider).transcriptToolCallIdMode; + return mode === "strict9" ? mode : undefined; +} diff --git a/src/agents/transcript-policy.test.ts b/src/agents/transcript-policy.test.ts index 0737fc43b20..3534bfad92b 100644 --- a/src/agents/transcript-policy.test.ts +++ b/src/agents/transcript-policy.test.ts @@ -153,4 +153,20 @@ describe("resolveTranscriptPolicy", () => { expect(policy.validateGeminiTurns).toBe(false); expect(policy.validateAnthropicTurns).toBe(false); }); + + it.each([ + { provider: "openrouter", modelId: "google/gemini-2.5-pro-preview" }, + { provider: "opencode", modelId: "google/gemini-2.5-flash" }, + { provider: "kilocode", modelId: "gemini-2.0-flash" }, + ])("sanitizes Gemini thought signatures for $provider routes", ({ provider, modelId }) => { + const policy = resolveTranscriptPolicy({ + provider, + modelId, + modelApi: "openai-completions", + }); + expect(policy.sanitizeThoughtSignatures).toEqual({ + allowBase64Only: true, + includeCamelCase: true, + }); + }); }); diff --git a/src/agents/transcript-policy.ts b/src/agents/transcript-policy.ts index 5acf96e89e7..491c22ab59d 100644 --- a/src/agents/transcript-policy.ts +++ b/src/agents/transcript-policy.ts @@ -1,6 +1,11 @@ import { normalizeProviderId } from "./model-selection.js"; import { isGoogleModelApi } from "./pi-embedded-helpers/google.js"; -import { preservesAnthropicThinkingSignatures } from "./provider-capabilities.js"; +import { + preservesAnthropicThinkingSignatures, + resolveTranscriptToolCallIdMode, + sanitizesGeminiThoughtSignatures, + supportsOpenAiCompatTurnValidation, +} from "./provider-capabilities.js"; import type { ToolCallIdMode } from "./tool-call-id.js"; export type TranscriptSanitizeMode = "full" | "images-only"; @@ -39,7 +44,6 @@ const OPENAI_MODEL_APIS = new Set([ "openai-codex-responses", ]); const OPENAI_PROVIDERS = new Set(["openai", "openai-codex"]); -const OPENAI_COMPAT_TURN_MERGE_EXCLUDED_PROVIDERS = new Set(["openrouter", "opencode"]); function isOpenAiApi(modelApi?: string | null): boolean { if (!modelApi) { @@ -64,16 +68,26 @@ function isAnthropicApi(modelApi?: string | null, provider?: string | null): boo return normalized === "anthropic" || normalized === "amazon-bedrock"; } -function isMistralModel(params: { provider?: string | null; modelId?: string | null }): boolean { - const provider = normalizeProviderId(params.provider ?? ""); - if (provider === "mistral") { - return true; +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 MISTRAL_MODEL_HINTS.some((hint) => modelId.includes(hint)); + return modelId.includes("gemini"); } export function resolveTranscriptPolicy(params: { @@ -89,11 +103,13 @@ export function resolveTranscriptPolicy(params: { const isStrictOpenAiCompatible = params.modelApi === "openai-completions" && !isOpenAi && - !OPENAI_COMPAT_TURN_MERGE_EXCLUDED_PROVIDERS.has(provider); - const isMistral = isMistralModel({ provider, modelId }); - const isOpenRouterGemini = - (provider === "openrouter" || provider === "opencode" || provider === "kilocode") && - modelId.toLowerCase().includes("gemini"); + 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 requiresOpenAiCompatibleToolIdSanitization = params.modelApi === "openai-completions"; @@ -102,21 +118,26 @@ export function resolveTranscriptPolicy(params: { // Drop these blocks at send-time to keep sessions usable. const dropThinkingBlocks = isCopilotClaude; - const needsNonImageSanitize = isGoogle || isAnthropic || isMistral || isOpenRouterGemini; + const needsNonImageSanitize = + isGoogle || isAnthropic || isMistral || shouldSanitizeGeminiThoughtSignaturesForProvider; const sanitizeToolCallIds = isGoogle || isMistral || isAnthropic || requiresOpenAiCompatibleToolIdSanitization; - const toolCallIdMode: ToolCallIdMode | undefined = isMistral - ? "strict9" - : sanitizeToolCallIds - ? "strict" - : undefined; + const toolCallIdMode: ToolCallIdMode | undefined = providerToolCallIdMode + ? providerToolCallIdMode + : isMistral + ? "strict9" + : sanitizeToolCallIds + ? "strict" + : undefined; // All providers need orphaned tool_result repair after history truncation. // OpenAI rejects function_call_output items whose call_id has no matching // function_call in the conversation, so the repair must run universally. const repairToolUseResultPairing = true; const sanitizeThoughtSignatures = - isOpenRouterGemini || isGoogle ? { allowBase64Only: true, includeCamelCase: true } : undefined; + shouldSanitizeGeminiThoughtSignaturesForProvider || isGoogle + ? { allowBase64Only: true, includeCamelCase: true } + : undefined; return { sanitizeMode: isOpenAi ? "images-only" : needsNonImageSanitize ? "full" : "images-only",