refactor: expand provider capability registry

This commit is contained in:
Peter Steinberger
2026-03-08 16:07:50 +00:00
parent 75e1521660
commit b41bcb08a2
5 changed files with 138 additions and 34 deletions

View File

@@ -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<string, unknown>;
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<string, unknown> => !!tool);
}
if (
usesOpenAiStringModeAnthropicToolChoice(
typeof model.provider === "string" ? model.provider : undefined,
)
) {
if (usesOpenAiStringModeAnthropicToolChoice(provider)) {
payloadObj.tool_choice = normalizeOpenAiStringModeAnthropicToolChoice(
payloadObj.tool_choice,
);

View File

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

View File

@@ -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<string, Partial<ProviderCapabilities>> = {
@@ -18,6 +24,20 @@ const PROVIDER_CAPABILITIES: Record<string, Partial<ProviderCapabilities>> = {
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;
}

View File

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

View File

@@ -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",