mirror of https://github.com/openclaw/openclaw.git
refactor(xai): split provider compat facades
Co-authored-by: Harold Hunt <harold@pwrdrvr.com>
This commit is contained in:
parent
c4e6fdf94d
commit
ab2bd34b66
|
|
@ -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<T extends { compat?: unknown }>(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<string, unknown>;
|
||||
const compat =
|
||||
model.compat && typeof model.compat === "object"
|
||||
? (model.compat as Record<string, unknown>)
|
||||
: 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<T extends { compat?: ModelCompatConfig }>(
|
||||
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<T extends { compat?: ModelCompatConfig }>(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<Api>): 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<Api>): 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<Api>): Model<Api> {
|
||||
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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue