From ab2bd34b66b02b081d8d08e2fb2af5f1fe1466ba Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 28 Mar 2026 05:02:28 +0000 Subject: [PATCH] refactor(xai): split provider compat facades Co-authored-by: Harold Hunt --- extensions/xai/api.ts | 21 +-- src/agents/model-compat.ts | 163 ------------------ src/agents/pi-model-discovery.ts | 2 +- ...e-aliases-schemas-without-dropping.test.ts | 4 +- .../pi-tools.model-provider-collision.test.ts | 2 +- src/agents/pi-tools.schema.ts | 7 +- src/config/types.models.ts | 4 +- src/config/zod-schema.core.ts | 4 +- src/plugin-sdk/provider-model-shared.ts | 13 +- src/plugin-sdk/provider-models.ts | 12 +- 10 files changed, 33 insertions(+), 199 deletions(-) delete mode 100644 src/agents/model-compat.ts diff --git a/extensions/xai/api.ts b/extensions/xai/api.ts index 319b345f6e0..2d9677656eb 100644 --- a/extensions/xai/api.ts +++ b/extensions/xai/api.ts @@ -1,3 +1,6 @@ +import { applyModelCompatPatch } from "openclaw/plugin-sdk/provider-model-shared"; +import type { ModelCompatConfig } from "openclaw/plugin-sdk/provider-model-shared"; + export { buildXaiProvider } from "./provider-catalog.js"; export { buildXaiCatalogModels, @@ -17,23 +20,9 @@ export const XAI_TOOL_SCHEMA_PROFILE = "xai"; export const HTML_ENTITY_TOOL_CALL_ARGUMENTS_ENCODING = "html-entities"; export function applyXaiModelCompat(model: T): T { - const patch = { + return applyModelCompatPatch(model as T & { compat?: ModelCompatConfig }, { toolSchemaProfile: XAI_TOOL_SCHEMA_PROFILE, nativeWebSearchTool: true, toolCallArgumentsEncoding: HTML_ENTITY_TOOL_CALL_ARGUMENTS_ENCODING, - } satisfies Record; - const compat = - model.compat && typeof model.compat === "object" - ? (model.compat as Record) - : undefined; - if (compat && Object.entries(patch).every(([key, value]) => compat[key] === value)) { - return model; - } - return { - ...model, - compat: { - ...compat, - ...patch, - } as T extends { compat?: infer TCompat } ? TCompat : never, - } as T; + }) as T; } diff --git a/src/agents/model-compat.ts b/src/agents/model-compat.ts deleted file mode 100644 index 88fbb85930f..00000000000 --- a/src/agents/model-compat.ts +++ /dev/null @@ -1,163 +0,0 @@ -import type { Api, Model } from "@mariozechner/pi-ai"; -import type { ModelCompatConfig } from "../config/types.models.js"; - -export const XAI_TOOL_SCHEMA_PROFILE = "xai"; -export const HTML_ENTITY_TOOL_CALL_ARGUMENTS_ENCODING = "html-entities"; - -function extractModelCompat( - modelOrCompat: { compat?: unknown } | ModelCompatConfig | undefined, -): ModelCompatConfig | undefined { - if (!modelOrCompat || typeof modelOrCompat !== "object") { - return undefined; - } - if ("compat" in modelOrCompat) { - const compat = (modelOrCompat as { compat?: unknown }).compat; - return compat && typeof compat === "object" ? (compat as ModelCompatConfig) : undefined; - } - return modelOrCompat as ModelCompatConfig; -} - -export function applyModelCompatPatch( - model: T, - patch: ModelCompatConfig, -): T { - const nextCompat = { ...model.compat, ...patch }; - if ( - model.compat && - Object.entries(patch).every( - ([key, value]) => model.compat?.[key as keyof ModelCompatConfig] === value, - ) - ) { - return model; - } - return { - ...model, - compat: nextCompat, - }; -} - -export function applyXaiModelCompat(model: T): T { - return applyModelCompatPatch(model, { - toolSchemaProfile: XAI_TOOL_SCHEMA_PROFILE, - nativeWebSearchTool: true, - toolCallArgumentsEncoding: HTML_ENTITY_TOOL_CALL_ARGUMENTS_ENCODING, - }); -} - -export function usesXaiToolSchemaProfile( - modelOrCompat: { compat?: unknown } | ModelCompatConfig | undefined, -): boolean { - return extractModelCompat(modelOrCompat)?.toolSchemaProfile === XAI_TOOL_SCHEMA_PROFILE; -} - -export function hasNativeWebSearchTool( - modelOrCompat: { compat?: unknown } | ModelCompatConfig | undefined, -): boolean { - return extractModelCompat(modelOrCompat)?.nativeWebSearchTool === true; -} - -export function resolveToolCallArgumentsEncoding( - modelOrCompat: { compat?: unknown } | ModelCompatConfig | undefined, -): ModelCompatConfig["toolCallArgumentsEncoding"] | undefined { - return extractModelCompat(modelOrCompat)?.toolCallArgumentsEncoding; -} - -function isOpenAiCompletionsModel(model: Model): model is Model<"openai-completions"> { - return model.api === "openai-completions"; -} - -/** - * Extracts and lowercases the hostname from a URL string. - * Returns null for malformed URLs. - */ -function getHostname(baseUrl: string): string | null { - try { - return new URL(baseUrl).hostname.toLowerCase(); - } catch { - return null; - } -} - -/** - * Returns true only for endpoints that are confirmed to be native OpenAI - * infrastructure and therefore accept the `developer` message role. - * Azure OpenAI uses the Chat Completions API and does NOT accept `developer`. - * All other openai-completions backends (proxies, Qwen, GLM, DeepSeek, etc.) - * only support the standard `system` role. - */ -function isOpenAINativeEndpoint(baseUrl: string): boolean { - return getHostname(baseUrl) === "api.openai.com"; -} - -function isAnthropicMessagesModel(model: Model): model is Model<"anthropic-messages"> { - return model.api === "anthropic-messages"; -} - -/** - * pi-ai constructs the Anthropic API endpoint as `${baseUrl}/v1/messages`. - * If a user configures `baseUrl` with a trailing `/v1` (e.g. the previously - * recommended format "https://api.anthropic.com/v1"), the resulting URL - * becomes "…/v1/v1/messages" which the Anthropic API rejects with a 404. - * - * Strip a single trailing `/v1` (with optional trailing slash) from the - * baseUrl for anthropic-messages models so users with either format work. - */ -function normalizeAnthropicBaseUrl(baseUrl: string): string { - return baseUrl.replace(/\/v1\/?$/, ""); -} -export function normalizeModelCompat(model: Model): Model { - const baseUrl = model.baseUrl ?? ""; - - // Normalise anthropic-messages baseUrl: strip trailing /v1 that users may - // have included in their config. pi-ai appends /v1/messages itself. - if (isAnthropicMessagesModel(model) && baseUrl) { - const normalised = normalizeAnthropicBaseUrl(baseUrl); - if (normalised !== baseUrl) { - return { ...model, baseUrl: normalised } as Model<"anthropic-messages">; - } - } - - if (!isOpenAiCompletionsModel(model)) { - return model; - } - - // The `developer` role and stream usage chunks are OpenAI-native behaviors. - // Many OpenAI-compatible backends reject `developer` and/or emit usage-only - // chunks that break strict parsers expecting choices[0]. Additionally, the - // `strict` boolean inside tools validation is rejected by several providers - // causing tool calls to be ignored. For non-native openai-completions endpoints, - // default these compat flags off unless explicitly opted in. - const compat = model.compat ?? undefined; - // When baseUrl is empty the pi-ai library defaults to api.openai.com, so - // leave compat unchanged and let default native behavior apply. - const needsForce = baseUrl ? !isOpenAINativeEndpoint(baseUrl) : false; - if (!needsForce) { - return model; - } - const forcedDeveloperRole = compat?.supportsDeveloperRole === true; - const hasStreamingUsageOverride = compat?.supportsUsageInStreaming !== undefined; - const targetStrictMode = compat?.supportsStrictMode ?? false; - const forcedUsageStreaming = compat?.supportsUsageInStreaming === true; - if ( - compat?.supportsDeveloperRole !== undefined && - hasStreamingUsageOverride && - compat?.supportsStrictMode !== undefined - ) { - return model; - } - - const normalizedCompat: ModelCompatConfig = compat - ? { - ...compat, - supportsDeveloperRole: forcedDeveloperRole || false, - supportsUsageInStreaming: forcedUsageStreaming || false, - supportsStrictMode: targetStrictMode, - } - : { supportsDeveloperRole: false, supportsUsageInStreaming: false, supportsStrictMode: false }; - - // Return a new object — do not mutate the caller's model reference. - return { - ...model, - compat: normalizedCompat, - } as typeof model; -} diff --git a/src/agents/pi-model-discovery.ts b/src/agents/pi-model-discovery.ts index c20ffad28f8..7b6c17b30ee 100644 --- a/src/agents/pi-model-discovery.ts +++ b/src/agents/pi-model-discovery.ts @@ -6,12 +6,12 @@ import type { AuthStorage as PiAuthStorage, ModelRegistry as PiModelRegistry, } from "@mariozechner/pi-coding-agent"; +import { normalizeModelCompat } from "../plugins/provider-model-compat.js"; import { normalizeProviderResolvedModelWithPlugin } from "../plugins/provider-runtime.js"; import type { ProviderRuntimeModel } from "../plugins/types.js"; import { ensureAuthProfileStore } from "./auth-profiles.js"; import { PROVIDER_ENV_API_KEY_CANDIDATES } from "./model-auth-env-vars.js"; import { resolveEnvApiKey } from "./model-auth-env.js"; -import { normalizeModelCompat } from "./model-compat.js"; import { resolvePiCredentialMapFromStore, type PiCredentialMap } from "./pi-auth-credentials.js"; const PiAuthStorageClass = PiCodingAgent.AuthStorage; diff --git a/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping.test.ts b/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping.test.ts index 020cb4a49c8..e0db37b3e90 100644 --- a/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping.test.ts +++ b/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping.test.ts @@ -5,9 +5,9 @@ import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core"; import { Type } from "@sinclair/typebox"; import { describe, expect, it, vi } from "vitest"; import { createBrowserTool } from "../plugin-sdk/browser.js"; -import "./test-helpers/fast-coding-tools.js"; import { XAI_UNSUPPORTED_SCHEMA_KEYWORDS } from "../plugin-sdk/provider-tools.js"; -import { applyXaiModelCompat } from "./model-compat.js"; +import { applyXaiModelCompat } from "../plugin-sdk/xai.js"; +import "./test-helpers/fast-coding-tools.js"; import { createOpenClawTools } from "./openclaw-tools.js"; import { findUnsupportedSchemaKeywords } from "./pi-embedded-runner/google.js"; import { __testing, createOpenClawCodingTools } from "./pi-tools.js"; diff --git a/src/agents/pi-tools.model-provider-collision.test.ts b/src/agents/pi-tools.model-provider-collision.test.ts index 3b8b36f1e81..c49d4a5248c 100644 --- a/src/agents/pi-tools.model-provider-collision.test.ts +++ b/src/agents/pi-tools.model-provider-collision.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { HTML_ENTITY_TOOL_CALL_ARGUMENTS_ENCODING, XAI_TOOL_SCHEMA_PROFILE, -} from "./model-compat.js"; +} from "../plugin-sdk/xai.js"; import { __testing } from "./pi-tools.js"; import type { AnyAgentTool } from "./pi-tools.types.js"; diff --git a/src/agents/pi-tools.schema.ts b/src/agents/pi-tools.schema.ts index 5e84bb4dfc0..caf6358b555 100644 --- a/src/agents/pi-tools.schema.ts +++ b/src/agents/pi-tools.schema.ts @@ -1,10 +1,11 @@ import type { ModelCompatConfig } from "../config/types.models.js"; +import { stripXaiUnsupportedKeywords } from "../plugin-sdk/provider-tools.js"; +import { XAI_TOOL_SCHEMA_PROFILE } from "../plugin-sdk/xai.js"; +import { hasToolSchemaProfile } from "../plugins/provider-model-compat.js"; import { copyPluginToolMeta } from "../plugins/tools.js"; import { copyChannelAgentToolMeta } from "./channel-tools.js"; -import { usesXaiToolSchemaProfile } from "./model-compat.js"; import type { AnyAgentTool } from "./pi-tools.types.js"; import { cleanSchemaForGemini } from "./schema/clean-for-gemini.js"; -import { stripXaiUnsupportedKeywords } from "./schema/clean-for-xai.js"; function extractEnumValues(schema: unknown): unknown[] | undefined { if (!schema || typeof schema !== "object") { @@ -97,7 +98,7 @@ export function normalizeToolParameters( options?.modelProvider?.toLowerCase().includes("google") || options?.modelProvider?.toLowerCase().includes("gemini"); const isAnthropicProvider = options?.modelProvider?.toLowerCase().includes("anthropic"); - const hasXaiSchemaProfile = usesXaiToolSchemaProfile(options?.modelCompat); + const hasXaiSchemaProfile = hasToolSchemaProfile(options?.modelCompat, XAI_TOOL_SCHEMA_PROFILE); function applyProviderCleaning(s: unknown): unknown { if (isGeminiProvider && !isAnthropicProvider) { diff --git a/src/config/types.models.ts b/src/config/types.models.ts index 9131899b086..e5a19b4f0f9 100644 --- a/src/config/types.models.ts +++ b/src/config/types.models.ts @@ -35,9 +35,9 @@ type SupportedThinkingFormat = export type ModelCompatConfig = SupportedOpenAICompatFields & { thinkingFormat?: SupportedThinkingFormat; supportsTools?: boolean; - toolSchemaProfile?: "xai"; + toolSchemaProfile?: string; nativeWebSearchTool?: boolean; - toolCallArgumentsEncoding?: "html-entities"; + toolCallArgumentsEncoding?: string; requiresMistralToolIds?: boolean; requiresOpenAiAnthropicToolPayload?: boolean; }; diff --git a/src/config/zod-schema.core.ts b/src/config/zod-schema.core.ts index 273be855a4a..6a9c8098290 100644 --- a/src/config/zod-schema.core.ts +++ b/src/config/zod-schema.core.ts @@ -204,9 +204,9 @@ export const ModelCompatSchema = z requiresToolResultName: z.boolean().optional(), requiresAssistantAfterToolResult: z.boolean().optional(), requiresThinkingAsText: z.boolean().optional(), - toolSchemaProfile: z.literal("xai").optional(), + toolSchemaProfile: z.string().optional(), nativeWebSearchTool: z.boolean().optional(), - toolCallArgumentsEncoding: z.literal("html-entities").optional(), + toolCallArgumentsEncoding: z.string().optional(), requiresMistralToolIds: z.boolean().optional(), requiresOpenAiAnthropicToolPayload: z.boolean().optional(), }) diff --git a/src/plugin-sdk/provider-model-shared.ts b/src/plugin-sdk/provider-model-shared.ts index c418ed5df27..c5519c6b7ba 100644 --- a/src/plugin-sdk/provider-model-shared.ts +++ b/src/plugin-sdk/provider-model-shared.ts @@ -6,19 +6,22 @@ import type { BedrockDiscoveryConfig, ModelDefinitionConfig } from "../config/types.models.js"; export type { ModelApi, ModelProviderConfig } from "../config/types.models.js"; -export type { BedrockDiscoveryConfig, ModelDefinitionConfig } from "../config/types.models.js"; +export type { + BedrockDiscoveryConfig, + ModelCompatConfig, + ModelDefinitionConfig, +} from "../config/types.models.js"; export type { ProviderPlugin } from "../plugins/types.js"; export type { KilocodeModelCatalogEntry } from "../plugins/provider-model-kilocode.js"; export { DEFAULT_CONTEXT_TOKENS } from "../agents/defaults.js"; export { + applyModelCompatPatch, + hasToolSchemaProfile, hasNativeWebSearchTool, - HTML_ENTITY_TOOL_CALL_ARGUMENTS_ENCODING, normalizeModelCompat, resolveToolCallArgumentsEncoding, - usesXaiToolSchemaProfile, - XAI_TOOL_SCHEMA_PROFILE, -} from "../agents/model-compat.js"; +} from "../plugins/provider-model-compat.js"; export { normalizeProviderId } from "../agents/provider-id.js"; export { createMoonshotThinkingWrapper, diff --git a/src/plugin-sdk/provider-models.ts b/src/plugin-sdk/provider-models.ts index 9efbeb303b9..35d3d85d63a 100644 --- a/src/plugin-sdk/provider-models.ts +++ b/src/plugin-sdk/provider-models.ts @@ -11,19 +11,23 @@ export type { export { DEFAULT_CONTEXT_TOKENS, + applyModelCompatPatch, cloneFirstTemplateModel, createMoonshotThinkingWrapper, + hasToolSchemaProfile, hasNativeWebSearchTool, - HTML_ENTITY_TOOL_CALL_ARGUMENTS_ENCODING, matchesExactOrPrefix, normalizeModelCompat, normalizeProviderId, resolveMoonshotThinkingType, resolveToolCallArgumentsEncoding, - usesXaiToolSchemaProfile, - XAI_TOOL_SCHEMA_PROFILE, } from "./provider-model-shared.js"; -export { applyXaiModelCompat, normalizeXaiModelId } from "./xai.js"; +export { + applyXaiModelCompat, + HTML_ENTITY_TOOL_CALL_ARGUMENTS_ENCODING, + normalizeXaiModelId, + XAI_TOOL_SCHEMA_PROFILE, +} from "./xai.js"; export { isMiniMaxModernModelId, MINIMAX_DEFAULT_MODEL_ID,