mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-21 18:08:05 -06:00
568b920b21
* refactor(imports): dedupe and hoist imports * feat(lint): enforce import/no-duplicates and import/first
461 lines
16 KiB
TypeScript
461 lines
16 KiB
TypeScript
// Provider model helpers normalize model catalog entries shared by provider plugins.
|
|
import { normalizeProviderId as normalizeProviderIdCore } from "@openclaw/model-catalog-core/provider-id";
|
|
import {
|
|
normalizeAntigravityPreviewModelId as normalizeAntigravityPreviewModelIdCore,
|
|
normalizeGooglePreviewModelId as normalizeGooglePreviewModelIdCore,
|
|
} from "@openclaw/model-catalog-core/provider-model-id-normalize";
|
|
import { normalizeOptionalLowercaseString } from "../../packages/normalization-core/src/string-coerce.js";
|
|
import {
|
|
buildAnthropicReplayPolicyForModel,
|
|
buildGoogleGeminiReplayPolicy,
|
|
buildHybridAnthropicOrOpenAIReplayPolicy,
|
|
buildNativeAnthropicReplayPolicyForModel,
|
|
buildOpenAICompatibleReplayPolicy,
|
|
buildPassthroughGeminiSanitizingReplayPolicy,
|
|
buildStrictAnthropicReplayPolicy,
|
|
resolveTaggedReasoningOutputMode,
|
|
sanitizeGoogleGeminiReplayHistory,
|
|
} from "../plugins/provider-replay-helpers.js";
|
|
import type { ProviderPlugin } from "../plugins/types.js";
|
|
import { definePluginEntry } from "./plugin-entry.js";
|
|
import type {
|
|
ProviderReasoningOutputModeContext,
|
|
ProviderReplayPolicyContext,
|
|
ProviderRuntimeModel,
|
|
ProviderSanitizeReplayHistoryContext,
|
|
} from "./plugin-entry.js";
|
|
|
|
type SelfHostedOpenAICompatibleProviderOverrides = Partial<
|
|
Omit<ProviderPlugin, "id" | "label" | "docsPath" | "envVars" | "auth" | "catalog" | "wizard">
|
|
>;
|
|
|
|
export type SelfHostedOpenAICompatibleProviderOptions = {
|
|
id: string;
|
|
label: string;
|
|
hint: string;
|
|
groupHint: string;
|
|
defaultBaseUrl: string;
|
|
apiKeyEnvVar: string;
|
|
modelPlaceholder: string;
|
|
overrides?: SelfHostedOpenAICompatibleProviderOverrides;
|
|
};
|
|
|
|
/** Defines the canonical setup, discovery, and wizard flow for one self-hosted OpenAI endpoint. */
|
|
export function defineSelfHostedOpenAICompatibleProvider(
|
|
options: SelfHostedOpenAICompatibleProviderOptions,
|
|
): ReturnType<typeof definePluginEntry> {
|
|
// Provider entries load during plugin discovery; setup/wizard code stays lazy until used.
|
|
const loadProviderSetup = async () => await import("./provider-setup.js");
|
|
return definePluginEntry({
|
|
id: options.id,
|
|
name: `${options.label} Provider`,
|
|
description: `Bundled ${options.label} provider plugin`,
|
|
register(api) {
|
|
api.registerProvider({
|
|
...options.overrides,
|
|
id: options.id,
|
|
label: options.label,
|
|
docsPath: `/providers/${options.id}`,
|
|
envVars: [options.apiKeyEnvVar],
|
|
auth: [
|
|
{
|
|
id: "custom",
|
|
label: options.label,
|
|
hint: options.hint,
|
|
kind: "custom",
|
|
run: async (ctx) => {
|
|
const setup = await loadProviderSetup();
|
|
return await setup.promptAndConfigureOpenAICompatibleSelfHostedProviderAuth({
|
|
cfg: ctx.config,
|
|
prompter: ctx.prompter,
|
|
providerId: options.id,
|
|
providerLabel: options.label,
|
|
defaultBaseUrl: options.defaultBaseUrl,
|
|
defaultApiKeyEnvVar: options.apiKeyEnvVar,
|
|
modelPlaceholder: options.modelPlaceholder,
|
|
});
|
|
},
|
|
runNonInteractive: async (ctx) => {
|
|
const setup = await loadProviderSetup();
|
|
return await setup.configureOpenAICompatibleSelfHostedProviderNonInteractive({
|
|
ctx,
|
|
providerId: options.id,
|
|
providerLabel: options.label,
|
|
defaultBaseUrl: options.defaultBaseUrl,
|
|
defaultApiKeyEnvVar: options.apiKeyEnvVar,
|
|
modelPlaceholder: options.modelPlaceholder,
|
|
});
|
|
},
|
|
},
|
|
],
|
|
catalog: {
|
|
order: "late",
|
|
run: async (ctx) => {
|
|
const setup = await loadProviderSetup();
|
|
return await setup.discoverOpenAICompatibleSelfHostedProvider({
|
|
ctx,
|
|
providerId: options.id,
|
|
buildProvider: async (params) => {
|
|
const baseUrl = (params?.baseUrl?.trim() || options.defaultBaseUrl).replace(
|
|
/\/+$/,
|
|
"",
|
|
);
|
|
const models = await setup.discoverOpenAICompatibleLocalModels({
|
|
baseUrl,
|
|
apiKey: params?.apiKey,
|
|
label: options.label,
|
|
discoverRuntimeContext: false,
|
|
});
|
|
return { baseUrl, api: "openai-completions", models };
|
|
},
|
|
});
|
|
},
|
|
},
|
|
wizard: {
|
|
setup: {
|
|
choiceId: options.id,
|
|
choiceLabel: options.label,
|
|
choiceHint: options.hint,
|
|
groupId: options.id,
|
|
groupLabel: options.label,
|
|
groupHint: options.groupHint,
|
|
methodId: "custom",
|
|
},
|
|
modelPicker: {
|
|
label: `${options.label} (custom)`,
|
|
hint: `Enter ${options.label} URL + API key + model`,
|
|
methodId: "custom",
|
|
},
|
|
},
|
|
});
|
|
},
|
|
});
|
|
}
|
|
|
|
export type {
|
|
ModelApi,
|
|
ModelProviderDeclarationConfig as ModelProviderConfig,
|
|
} from "../config/types.models.js";
|
|
export {
|
|
resolveClaudeFable5ModelIdentity,
|
|
resolveClaudeModelIdentity,
|
|
resolveClaudeMythos5ModelIdentity,
|
|
resolveClaudeNativeThinkingLevelMap,
|
|
resolveClaudeOpus5ModelIdentity,
|
|
resolveClaudeSonnet5ModelIdentity,
|
|
requiresClaudeDefaultSampling,
|
|
requiresClaudeMandatoryAdaptiveThinking,
|
|
supportsClaude1MContext,
|
|
supportsClaudeAdaptiveThinking,
|
|
supportsClaudeFastMode,
|
|
supportsClaudeNativeMaxEffort,
|
|
supportsClaudeNativeXhighEffort,
|
|
} from "@openclaw/llm-core";
|
|
export type {
|
|
UnifiedModelCatalogEntry,
|
|
UnifiedModelCatalogKind,
|
|
UnifiedModelCatalogSource,
|
|
} from "@openclaw/model-catalog-core/model-catalog-types";
|
|
export { isCloudModelRef } from "@openclaw/model-catalog-core/model-catalog-refs";
|
|
export { parseModelRef } from "../agents/model-selection-normalize.js";
|
|
export type {
|
|
BedrockDiscoveryConfig,
|
|
ModelCompatConfig,
|
|
ModelDefinitionConfig,
|
|
} from "../config/types.models.js";
|
|
export type {
|
|
ProviderEndpointClass,
|
|
ProviderEndpointResolution,
|
|
} from "../agents/provider-attribution.js";
|
|
export type {
|
|
ProviderPlugin,
|
|
UnifiedModelCatalogProviderContext,
|
|
UnifiedModelCatalogProviderPlugin,
|
|
} from "../plugins/types.js";
|
|
|
|
export { DEFAULT_CONTEXT_TOKENS } from "../agents/defaults.js";
|
|
export {
|
|
GPT5_BEHAVIOR_CONTRACT,
|
|
GPT5_FRIENDLY_CHAT_PROMPT_OVERLAY,
|
|
GPT5_FRIENDLY_PROMPT_OVERLAY,
|
|
GPT5_HEARTBEAT_PROMPT_OVERLAY,
|
|
isGpt5ModelId,
|
|
normalizeGpt5PromptOverlayMode,
|
|
resolveGpt5PromptOverlayMode,
|
|
resolveGpt5SystemPromptContribution,
|
|
type Gpt5PromptOverlayMode,
|
|
} from "../agents/gpt5-prompt-overlay.js";
|
|
export { resolveProviderEndpoint } from "../agents/provider-attribution.js";
|
|
export {
|
|
applyModelCompatPatch,
|
|
hasToolSchemaProfile,
|
|
normalizeModelCompat,
|
|
resolveUnsupportedToolSchemaKeywords,
|
|
resolveToolCallArgumentsEncoding,
|
|
} from "../plugins/provider-model-compat.js";
|
|
export {
|
|
buildAnthropicReplayPolicyForModel,
|
|
buildGoogleGeminiReplayPolicy,
|
|
buildHybridAnthropicOrOpenAIReplayPolicy,
|
|
buildNativeAnthropicReplayPolicyForModel,
|
|
buildOpenAICompatibleReplayPolicy,
|
|
buildPassthroughGeminiSanitizingReplayPolicy,
|
|
resolveTaggedReasoningOutputMode,
|
|
sanitizeGoogleGeminiReplayHistory,
|
|
buildStrictAnthropicReplayPolicy,
|
|
};
|
|
|
|
/**
|
|
* Normalizes provider ids for config, catalog, and plugin-registry matching.
|
|
*/
|
|
export function normalizeProviderId(
|
|
/** Provider id from config, catalog, or plugin metadata. */
|
|
provider: string,
|
|
): string {
|
|
return normalizeProviderIdCore(provider);
|
|
}
|
|
|
|
/** Compare canonical flat rates without assuming display-only models include cost metadata. */
|
|
export function modelCostsEqual(
|
|
current: ProviderRuntimeModel["cost"] | undefined,
|
|
expected: ProviderRuntimeModel["cost"],
|
|
): boolean {
|
|
return (
|
|
current?.input === expected.input &&
|
|
current?.output === expected.output &&
|
|
current?.cacheRead === expected.cacheRead &&
|
|
current?.cacheWrite === expected.cacheWrite
|
|
);
|
|
}
|
|
|
|
const LOCAL_MODEL_FAMILY_PREFERENCES = [
|
|
// Gemma 4 leads: live bench of the system-agent contract (planner JSON +
|
|
// openclaw tool calls) scored gemma4:e4b well above qwen3.5:4b on approval
|
|
// follow-through and structured-command accuracy at ~2.5x lower latency.
|
|
/gemma[-_.]?4(?!\d)/,
|
|
/qwen[-_.]?3[._]5(?!\d)/,
|
|
/qwen[-_.]?3(?!\d)/,
|
|
/gpt[-_.]?oss/,
|
|
/gemma[-_.]?3(?!\d)/,
|
|
/llama[-_.]?4(?!\d)/,
|
|
/llama[-_.]?3(?!\d)/,
|
|
/phi[-_.]?4(?!\d)/,
|
|
/mistral/,
|
|
/deepseek/,
|
|
] as const;
|
|
const LOCAL_MODEL_SPECIALIST_PATTERN = /embed|rerank|whisper|-vl\b|vision|omni|guard/;
|
|
|
|
/**
|
|
* Setup-assistant preference for agentic tool-calling quality in current BFCL-class results.
|
|
* Heuristic contract; safe to retune as local model families improve.
|
|
*/
|
|
export function selectPreferredLocalModelId(modelIds: readonly string[]): string | undefined {
|
|
const familyCount = LOCAL_MODEL_FAMILY_PREFERENCES.length;
|
|
let preferred: string | undefined;
|
|
let preferredRank = Number.POSITIVE_INFINITY;
|
|
|
|
for (const rawId of modelIds) {
|
|
const id = rawId.trim();
|
|
if (!id) {
|
|
continue;
|
|
}
|
|
const normalized = id.toLowerCase();
|
|
const familyRank = LOCAL_MODEL_FAMILY_PREFERENCES.findIndex((pattern) =>
|
|
pattern.test(normalized),
|
|
);
|
|
// Rank buckets, best to worst: known family (0..N-1), known-family coder
|
|
// (N..2N-1), unknown chat (2N), unknown coder (2N+1), specialist (3N).
|
|
// Strict `<` below keeps the caller's original order within a bucket.
|
|
const rank = LOCAL_MODEL_SPECIALIST_PATTERN.test(normalized)
|
|
? familyCount * 3
|
|
: familyRank >= 0
|
|
? familyRank + (normalized.includes("coder") ? familyCount : 0)
|
|
: familyCount * 2 + (normalized.includes("coder") ? 1 : 0);
|
|
if (rank < preferredRank) {
|
|
preferred = id;
|
|
preferredRank = rank;
|
|
}
|
|
}
|
|
|
|
return preferred;
|
|
}
|
|
export {
|
|
createMoonshotThinkingWrapper,
|
|
resolveMoonshotThinkingType,
|
|
} from "../llm/providers/stream-wrappers/moonshot-thinking.js";
|
|
export {
|
|
cloneFirstTemplateModel,
|
|
matchesExactOrPrefix,
|
|
resolveFamilyForwardCompatModel,
|
|
} from "../plugins/provider-model-helpers.js";
|
|
|
|
export {
|
|
isClaudeAdaptiveThinkingDefaultModelId,
|
|
resolveClaudeThinkingProfile,
|
|
} from "../plugins/provider-claude-thinking.js";
|
|
|
|
function getModelProviderHint(modelId: string): string | null {
|
|
const trimmed = normalizeOptionalLowercaseString(modelId);
|
|
if (!trimmed) {
|
|
return null;
|
|
}
|
|
const slashIndex = trimmed.indexOf("/");
|
|
if (slashIndex <= 0) {
|
|
return null;
|
|
}
|
|
return trimmed.slice(0, slashIndex) || null;
|
|
}
|
|
|
|
/** @deprecated Proxy provider-owned model helper; do not use from third-party plugins. */
|
|
export function isProxyReasoningUnsupportedModelHint(
|
|
/** Model id that may include a provider prefix such as `x-ai/model`. */
|
|
modelId: string,
|
|
): boolean {
|
|
return getModelProviderHint(modelId) === "x-ai";
|
|
}
|
|
|
|
/**
|
|
* Normalizes Antigravity preview model ids to the canonical provider catalog form.
|
|
*/
|
|
export function normalizeAntigravityPreviewModelId(
|
|
/** Antigravity preview model id from config or catalog data. */
|
|
id: string,
|
|
): string {
|
|
return normalizeAntigravityPreviewModelIdCore(id);
|
|
}
|
|
|
|
/**
|
|
* Normalizes Google preview model ids to the canonical provider catalog form.
|
|
*/
|
|
export function normalizeGooglePreviewModelId(
|
|
/** Google preview model id from config or catalog data. */
|
|
id: string,
|
|
): string {
|
|
return normalizeGooglePreviewModelIdCore(id);
|
|
}
|
|
|
|
/**
|
|
* Shared replay-policy families reused by provider plugins with matching transcript semantics.
|
|
*/
|
|
export type ProviderReplayFamily =
|
|
| "openai-compatible"
|
|
| "anthropic-by-model"
|
|
| "native-anthropic-by-model"
|
|
| "google-gemini"
|
|
| "passthrough-gemini"
|
|
| "hybrid-anthropic-openai";
|
|
|
|
type ProviderReplayFamilyHooks = Pick<
|
|
ProviderPlugin,
|
|
"buildReplayPolicy" | "sanitizeReplayHistory" | "resolveReasoningOutputMode"
|
|
>;
|
|
|
|
type BuildProviderReplayFamilyHooksOptions =
|
|
| {
|
|
/** OpenAI-compatible transcript family using OpenAI-style tool calls. */
|
|
family: "openai-compatible";
|
|
/** Whether replay policy should rewrite tool call ids for provider compatibility. */
|
|
sanitizeToolCallIds?: boolean;
|
|
/** Optional output style for repeated tool call ids. */
|
|
duplicateToolCallIdStyle?: "openai";
|
|
/** Whether replay policy should strip reasoning blocks from history. */
|
|
dropReasoningFromHistory?: boolean;
|
|
}
|
|
| {
|
|
/** Anthropic-style transcript policy selected by Claude model id. */
|
|
family: "anthropic-by-model";
|
|
}
|
|
| {
|
|
/** Native Anthropic transcript policy preserving Anthropic ids/signatures. */
|
|
family: "native-anthropic-by-model";
|
|
}
|
|
| {
|
|
/** Google Gemini transcript policy with Gemini replay sanitation hooks. */
|
|
family: "google-gemini";
|
|
}
|
|
| {
|
|
/** OpenAI-compatible transport carrying Gemini-style thought signatures. */
|
|
family: "passthrough-gemini";
|
|
}
|
|
| {
|
|
/** Family that switches between Anthropic and OpenAI-compatible replay by request context. */
|
|
family: "hybrid-anthropic-openai";
|
|
/** Whether Anthropic-model replay should drop thinking blocks in hybrid mode. */
|
|
anthropicModelDropThinkingBlocks?: boolean;
|
|
};
|
|
|
|
/**
|
|
* Builds provider replay hooks for a known transcript/reasoning compatibility family.
|
|
*/
|
|
export function buildProviderReplayFamilyHooks(
|
|
options: BuildProviderReplayFamilyHooksOptions,
|
|
): ProviderReplayFamilyHooks {
|
|
switch (options.family) {
|
|
case "openai-compatible": {
|
|
const policyOptions = {
|
|
sanitizeToolCallIds: options.sanitizeToolCallIds,
|
|
duplicateToolCallIdStyle: options.duplicateToolCallIdStyle,
|
|
dropReasoningFromHistory: options.dropReasoningFromHistory,
|
|
};
|
|
return {
|
|
buildReplayPolicy: (ctx: ProviderReplayPolicyContext) =>
|
|
buildOpenAICompatibleReplayPolicy(ctx.modelApi, {
|
|
...policyOptions,
|
|
modelId: ctx.modelId,
|
|
}),
|
|
};
|
|
}
|
|
case "anthropic-by-model":
|
|
return {
|
|
buildReplayPolicy: ({ modelId, model }: ProviderReplayPolicyContext) =>
|
|
buildAnthropicReplayPolicyForModel(modelId, model),
|
|
};
|
|
case "native-anthropic-by-model":
|
|
return {
|
|
buildReplayPolicy: ({ modelId, model }: ProviderReplayPolicyContext) =>
|
|
buildNativeAnthropicReplayPolicyForModel(modelId, model),
|
|
};
|
|
case "google-gemini":
|
|
return {
|
|
buildReplayPolicy: () => buildGoogleGeminiReplayPolicy(),
|
|
sanitizeReplayHistory: (ctx: ProviderSanitizeReplayHistoryContext) =>
|
|
sanitizeGoogleGeminiReplayHistory(ctx),
|
|
resolveReasoningOutputMode: (_ctx: ProviderReasoningOutputModeContext) =>
|
|
resolveTaggedReasoningOutputMode(),
|
|
};
|
|
case "passthrough-gemini":
|
|
return {
|
|
buildReplayPolicy: ({ modelId }: ProviderReplayPolicyContext) =>
|
|
buildPassthroughGeminiSanitizingReplayPolicy(modelId),
|
|
};
|
|
case "hybrid-anthropic-openai":
|
|
return {
|
|
buildReplayPolicy: (ctx: ProviderReplayPolicyContext) =>
|
|
buildHybridAnthropicOrOpenAIReplayPolicy(ctx, {
|
|
anthropicModelDropThinkingBlocks: options.anthropicModelDropThinkingBlocks,
|
|
}),
|
|
};
|
|
}
|
|
throw new Error("Unsupported provider replay family");
|
|
}
|
|
|
|
/** @deprecated Provider-owned replay hook shortcut; use local provider hooks instead. */
|
|
export const OPENAI_COMPATIBLE_REPLAY_HOOKS = buildProviderReplayFamilyHooks({
|
|
family: "openai-compatible",
|
|
});
|
|
|
|
/** @deprecated Anthropic provider-owned replay hook shortcut; use local provider hooks instead. */
|
|
export const ANTHROPIC_BY_MODEL_REPLAY_HOOKS = buildProviderReplayFamilyHooks({
|
|
family: "anthropic-by-model",
|
|
});
|
|
|
|
/** @deprecated Anthropic provider-owned replay hook shortcut; use local provider hooks instead. */
|
|
export const NATIVE_ANTHROPIC_REPLAY_HOOKS = buildProviderReplayFamilyHooks({
|
|
family: "native-anthropic-by-model",
|
|
});
|
|
|
|
/** @deprecated Google provider-owned replay hook shortcut; use local provider hooks instead. */
|
|
export const PASSTHROUGH_GEMINI_REPLAY_HOOKS = buildProviderReplayFamilyHooks({
|
|
family: "passthrough-gemini",
|
|
});
|