diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c6dcf200689a..76ea9ef2af0d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -601,7 +601,7 @@ jobs: uses: actions/cache@v5 with: path: .artifacts/build-all-cache - key: ${{ runner.os }}-build-all-v3-${{ hashFiles('package.json', 'pnpm-lock.yaml', 'npm-shrinkwrap.json', 'packages/plugin-sdk/package.json', 'packages/llm-core/package.json', 'packages/memory-host-sdk/package.json', 'scripts/build-all.mjs', 'scripts/write-plugin-sdk-entry-dts.ts', 'scripts/lib/plugin-sdk-entries.mjs', 'tsconfig.json', 'tsconfig.plugin-sdk.dts.json', 'src/plugin-sdk/**', 'packages/llm-core/src/**', 'packages/memory-host-sdk/src/**', 'src/types/**', 'src/video-generation/dashscope-compatible.ts', 'src/video-generation/types.ts', 'scripts/copy-export-html-templates.ts', 'scripts/lib/copy-assets.ts', 'src/auto-reply/reply/export-html/**') }} + key: ${{ runner.os }}-build-all-v3-${{ hashFiles('package.json', 'pnpm-lock.yaml', 'npm-shrinkwrap.json', 'packages/plugin-sdk/package.json', 'packages/llm-core/package.json', 'packages/model-catalog-core/package.json', 'packages/memory-host-sdk/package.json', 'scripts/build-all.mjs', 'scripts/write-plugin-sdk-entry-dts.ts', 'scripts/lib/plugin-sdk-entries.mjs', 'tsconfig.json', 'tsconfig.plugin-sdk.dts.json', 'src/plugin-sdk/**', 'packages/llm-core/src/**', 'packages/model-catalog-core/src/**', 'packages/memory-host-sdk/src/**', 'src/types/**', 'src/video-generation/dashscope-compatible.ts', 'src/video-generation/types.ts', 'scripts/copy-export-html-templates.ts', 'scripts/lib/copy-assets.ts', 'src/auto-reply/reply/export-html/**') }} restore-keys: | ${{ runner.os }}-build-all-v3- @@ -1403,7 +1403,7 @@ jobs: packages/plugin-sdk/dist extensions/*/dist/.boundary-tsc.tsbuildinfo extensions/*/dist/.boundary-tsc.stamp - key: ${{ runner.os }}-extension-package-boundary-v1-${{ hashFiles('tsconfig.json', 'tsconfig.plugin-sdk.dts.json', 'packages/plugin-sdk/tsconfig.json', 'packages/llm-core/package.json', 'scripts/check-extension-package-tsc-boundary.mjs', 'scripts/prepare-extension-package-boundary-artifacts.mjs', 'scripts/write-plugin-sdk-entry-dts.ts', 'scripts/lib/plugin-sdk-entrypoints.json', 'scripts/lib/plugin-sdk-entries.mjs', 'src/plugin-sdk/**', 'src/auto-reply/**', 'packages/llm-core/src/**', 'src/video-generation/dashscope-compatible.ts', 'src/video-generation/types.ts', 'src/types/**', 'extensions/**', 'extensions/tsconfig.package-boundary*.json', 'package.json', 'pnpm-lock.yaml') }} + key: ${{ runner.os }}-extension-package-boundary-v1-${{ hashFiles('tsconfig.json', 'tsconfig.plugin-sdk.dts.json', 'packages/plugin-sdk/tsconfig.json', 'packages/llm-core/package.json', 'packages/model-catalog-core/package.json', 'scripts/check-extension-package-tsc-boundary.mjs', 'scripts/prepare-extension-package-boundary-artifacts.mjs', 'scripts/write-plugin-sdk-entry-dts.ts', 'scripts/lib/plugin-sdk-entrypoints.json', 'scripts/lib/plugin-sdk-entries.mjs', 'src/plugin-sdk/**', 'src/auto-reply/**', 'packages/llm-core/src/**', 'packages/model-catalog-core/src/**', 'src/video-generation/dashscope-compatible.ts', 'src/video-generation/types.ts', 'src/types/**', 'extensions/**', 'extensions/tsconfig.package-boundary*.json', 'package.json', 'pnpm-lock.yaml') }} restore-keys: | ${{ runner.os }}-extension-package-boundary-v1- @@ -1425,11 +1425,17 @@ jobs: -type f \( -name '*.ts' -o -name '*.tsx' -o -name '*.mts' -o -name '*.cts' -o -name '*.js' -o -name '*.mjs' -o -name '*.json' \) \ -exec touch -t 200001010000 {} + fi + if [ -d packages/model-catalog-core/src ]; then + find packages/model-catalog-core/src \ + -type f \( -name '*.ts' -o -name '*.tsx' -o -name '*.mts' -o -name '*.cts' -o -name '*.js' -o -name '*.mjs' -o -name '*.json' \) \ + -exec touch -t 200001010000 {} + + fi cache_inputs=( tsconfig.json \ tsconfig.plugin-sdk.dts.json \ packages/plugin-sdk/tsconfig.json \ packages/llm-core/package.json \ + packages/model-catalog-core/package.json \ scripts/check-extension-package-tsc-boundary.mjs \ scripts/prepare-extension-package-boundary-artifacts.mjs \ scripts/write-plugin-sdk-entry-dts.ts \ diff --git a/extensions/mattermost/src/mattermost/model-picker.ts b/extensions/mattermost/src/mattermost/model-picker.ts index 5165af1e47bf..ae87f5dab150 100644 --- a/extensions/mattermost/src/mattermost/model-picker.ts +++ b/extensions/mattermost/src/mattermost/model-picker.ts @@ -342,7 +342,7 @@ export function renderMattermostModelsPickerView(params: { const page = paginateItems(models, params.page); const rows: MattermostInteractiveButtonInput[][] = page.items.map((model) => { - const isCurrent = current?.provider === provider && current.model === model; + const isCurrent = current?.provider === provider && current?.model === model; return [ buildButton({ action: "select", diff --git a/extensions/tsconfig.package-boundary.paths.json b/extensions/tsconfig.package-boundary.paths.json index 0d516cd63700..4fa938bf8496 100644 --- a/extensions/tsconfig.package-boundary.paths.json +++ b/extensions/tsconfig.package-boundary.paths.json @@ -102,6 +102,24 @@ "../dist/plugin-sdk/packages/llm-core/src/validation.d.ts" ], "@openclaw/llm-core/*": ["../dist/plugin-sdk/packages/llm-core/src/*.d.ts"], + "@openclaw/model-catalog-core": [ + "../dist/plugin-sdk/packages/model-catalog-core/src/index.d.ts" + ], + "@openclaw/model-catalog-core/configured-model-refs": [ + "../dist/plugin-sdk/packages/model-catalog-core/src/configured-model-refs.d.ts" + ], + "@openclaw/model-catalog-core/provider-id": [ + "../dist/plugin-sdk/packages/model-catalog-core/src/provider-id.d.ts" + ], + "@openclaw/model-catalog-core/provider-model-id-normalization": [ + "../dist/plugin-sdk/packages/model-catalog-core/src/provider-model-id-normalization.d.ts" + ], + "@openclaw/model-catalog-core/provider-model-id-normalize": [ + "../dist/plugin-sdk/packages/model-catalog-core/src/provider-model-id-normalize.d.ts" + ], + "@openclaw/model-catalog-core/*": [ + "../dist/plugin-sdk/packages/model-catalog-core/src/*.d.ts" + ], "@openclaw/markdown-core": [ "../dist/plugin-sdk/packages/markdown-core/src/index.d.ts" ], diff --git a/extensions/xai/tsconfig.json b/extensions/xai/tsconfig.json index 45c5b1a13c33..c76c229f0ad2 100644 --- a/extensions/xai/tsconfig.json +++ b/extensions/xai/tsconfig.json @@ -111,6 +111,24 @@ "@openclaw/llm-core/*": [ "../../dist/plugin-sdk/packages/llm-core/src/*.d.ts" ], + "@openclaw/model-catalog-core": [ + "../../dist/plugin-sdk/packages/model-catalog-core/src/index.d.ts" + ], + "@openclaw/model-catalog-core/configured-model-refs": [ + "../../dist/plugin-sdk/packages/model-catalog-core/src/configured-model-refs.d.ts" + ], + "@openclaw/model-catalog-core/provider-id": [ + "../../dist/plugin-sdk/packages/model-catalog-core/src/provider-id.d.ts" + ], + "@openclaw/model-catalog-core/provider-model-id-normalization": [ + "../../dist/plugin-sdk/packages/model-catalog-core/src/provider-model-id-normalization.d.ts" + ], + "@openclaw/model-catalog-core/provider-model-id-normalize": [ + "../../dist/plugin-sdk/packages/model-catalog-core/src/provider-model-id-normalize.d.ts" + ], + "@openclaw/model-catalog-core/*": [ + "../../dist/plugin-sdk/packages/model-catalog-core/src/*.d.ts" + ], "@openclaw/markdown-core": [ "../../dist/plugin-sdk/packages/markdown-core/src/index.d.ts" ], diff --git a/packages/model-catalog-core/package.json b/packages/model-catalog-core/package.json new file mode 100644 index 000000000000..2933c6ec3b76 --- /dev/null +++ b/packages/model-catalog-core/package.json @@ -0,0 +1,38 @@ +{ + "name": "@openclaw/model-catalog-core", + "version": "0.0.0-private", + "private": true, + "files": [ + "dist" + ], + "type": "module", + "main": "./dist/index.mjs", + "types": "./dist/index.d.mts", + "exports": { + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs", + "default": "./dist/index.mjs" + }, + "./configured-model-refs": { + "types": "./dist/configured-model-refs.d.mts", + "import": "./dist/configured-model-refs.mjs", + "default": "./dist/configured-model-refs.mjs" + }, + "./provider-id": { + "types": "./dist/provider-id.d.mts", + "import": "./dist/provider-id.mjs", + "default": "./dist/provider-id.mjs" + }, + "./provider-model-id-normalization": { + "types": "./dist/provider-model-id-normalization.d.mts", + "import": "./dist/provider-model-id-normalization.mjs", + "default": "./dist/provider-model-id-normalization.mjs" + }, + "./provider-model-id-normalize": { + "types": "./dist/provider-model-id-normalize.d.mts", + "import": "./dist/provider-model-id-normalize.mjs", + "default": "./dist/provider-model-id-normalize.mjs" + } + } +} diff --git a/packages/model-catalog-core/src/configured-model-refs.test.ts b/packages/model-catalog-core/src/configured-model-refs.test.ts new file mode 100644 index 000000000000..e10c359a2b3d --- /dev/null +++ b/packages/model-catalog-core/src/configured-model-refs.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import { + collectConfiguredModelRefs, + collectConfiguredModelRefValues, + extractProviderFromModelRef, +} from "./configured-model-refs.js"; + +describe("configured model refs", () => { + it("collects agent, hook, message, and channel model refs with config paths", () => { + expect( + collectConfiguredModelRefs({ + agents: { + defaults: { + model: { primary: "openai/gpt-5.5", fallbacks: ["anthropic/claude-sonnet-4-6"] }, + compaction: { memoryFlush: { model: "openai/gpt-5.5-mini" } }, + }, + list: [{ id: "custom", model: "xai/grok-4-fast" }], + }, + hooks: { + mappings: [{ model: "openai/gpt-5.5-nano" }], + }, + messages: { + tts: { summaryModel: "openai/gpt-5.5-mini" }, + }, + channels: { + modelByChannel: { + discord: { + guild: "anthropic/claude-opus-4-8", + }, + }, + }, + }), + ).toEqual([ + { path: "agents.defaults.model.primary", value: "openai/gpt-5.5" }, + { path: "agents.defaults.model.fallbacks.0", value: "anthropic/claude-sonnet-4-6" }, + { path: "agents.defaults.compaction.memoryFlush.model", value: "openai/gpt-5.5-mini" }, + { path: "agents.list.0.model", value: "xai/grok-4-fast" }, + { path: "channels.modelByChannel.discord.guild", value: "anthropic/claude-opus-4-8" }, + { path: "hooks.mappings.0.model", value: "openai/gpt-5.5-nano" }, + { path: "messages.tts.summaryModel", value: "openai/gpt-5.5-mini" }, + ]); + }); + + it("can exclude channel model overrides from configured refs", () => { + expect( + collectConfiguredModelRefValues( + { + agents: { defaults: { model: "openai/gpt-5.5" } }, + channels: { modelByChannel: { discord: { guild: "anthropic/claude-sonnet-4-6" } } }, + }, + { includeChannelModelOverrides: false }, + ), + ).toEqual(["openai/gpt-5.5"]); + }); + + it("ignores array-shaped malformed records", () => { + expect( + collectConfiguredModelRefs({ + agents: { + defaults: { + models: ["openai/gpt-5.5"], + }, + }, + }), + ).toEqual([]); + }); + + it("extracts normalized providers from provider-prefixed refs", () => { + expect(extractProviderFromModelRef(" OpenAI/gpt-5.5 ")).toBe("openai"); + expect(extractProviderFromModelRef("gpt-5.5")).toBeNull(); + }); +}); diff --git a/packages/model-catalog-core/src/configured-model-refs.ts b/packages/model-catalog-core/src/configured-model-refs.ts new file mode 100644 index 000000000000..8e05d3cb70af --- /dev/null +++ b/packages/model-catalog-core/src/configured-model-refs.ts @@ -0,0 +1,134 @@ +import { normalizeProviderId } from "./provider-id.js"; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export type ConfiguredModelRef = { + path: string; + value: string; +}; + +export const AGENT_MODEL_CONFIG_KEYS = [ + "model", + "imageModel", + "imageGenerationModel", + "videoGenerationModel", + "musicGenerationModel", + "voiceModel", + "pdfModel", +] as const; + +export function collectConfiguredModelRefs( + config: unknown, + options: { includeChannelModelOverrides?: boolean } = {}, +): ConfiguredModelRef[] { + const refs: ConfiguredModelRef[] = []; + const pushModelRef = (path: string, value: unknown) => { + if (typeof value === "string" && value.trim()) { + refs.push({ path, value: value.trim() }); + } + }; + const collectModelConfig = (path: string, value: unknown) => { + if (typeof value === "string") { + pushModelRef(path, value); + return; + } + if (!isRecord(value)) { + return; + } + pushModelRef(`${path}.primary`, value.primary); + if (Array.isArray(value.fallbacks)) { + for (const [index, entry] of value.fallbacks.entries()) { + pushModelRef(`${path}.fallbacks.${index}`, entry); + } + } + }; + const collectFromAgent = (path: string, agent: unknown) => { + if (!isRecord(agent)) { + return; + } + for (const key of AGENT_MODEL_CONFIG_KEYS) { + collectModelConfig(`${path}.${key}`, agent[key]); + } + pushModelRef( + `${path}.heartbeat.model`, + isRecord(agent.heartbeat) ? agent.heartbeat.model : undefined, + ); + collectModelConfig( + `${path}.subagents.model`, + isRecord(agent.subagents) ? agent.subagents.model : undefined, + ); + if (isRecord(agent.compaction)) { + pushModelRef(`${path}.compaction.model`, agent.compaction.model); + pushModelRef( + `${path}.compaction.memoryFlush.model`, + isRecord(agent.compaction.memoryFlush) ? agent.compaction.memoryFlush.model : undefined, + ); + } + if (isRecord(agent.models)) { + for (const modelRef of Object.keys(agent.models)) { + pushModelRef(`${path}.models.${modelRef}`, modelRef); + } + } + }; + + const root = isRecord(config) ? config : {}; + const agents = isRecord(root.agents) ? root.agents : {}; + collectFromAgent("agents.defaults", agents.defaults); + if (Array.isArray(agents.list)) { + for (const [index, entry] of agents.list.entries()) { + collectFromAgent(`agents.list.${index}`, entry); + } + } + if (options.includeChannelModelOverrides !== false) { + const channels = isRecord(root.channels) ? root.channels : {}; + const modelByChannel = isRecord(channels.modelByChannel) ? channels.modelByChannel : {}; + for (const [channelId, channelMap] of Object.entries(modelByChannel)) { + if (!isRecord(channelMap)) { + continue; + } + for (const [targetId, modelRef] of Object.entries(channelMap)) { + pushModelRef(`channels.modelByChannel.${channelId}.${targetId}`, modelRef); + } + } + } + const hooks = isRecord(root.hooks) ? root.hooks : {}; + if (Array.isArray(hooks.mappings)) { + for (const [index, mapping] of hooks.mappings.entries()) { + pushModelRef(`hooks.mappings.${index}.model`, isRecord(mapping) ? mapping.model : undefined); + } + } + pushModelRef("hooks.gmail.model", isRecord(hooks.gmail) ? hooks.gmail.model : undefined); + pushModelRef( + "messages.tts.summaryModel", + isRecord(root.messages) && isRecord(root.messages.tts) + ? root.messages.tts.summaryModel + : undefined, + ); + pushModelRef( + "channels.discord.voice.model", + isRecord(root.channels) && + isRecord(root.channels.discord) && + isRecord(root.channels.discord.voice) + ? root.channels.discord.voice.model + : undefined, + ); + return refs; +} + +export function collectConfiguredModelRefValues( + config: unknown, + options?: { includeChannelModelOverrides?: boolean }, +): string[] { + return collectConfiguredModelRefs(config, options).map((ref) => ref.value); +} + +export function extractProviderFromModelRef(value: string): string | null { + const trimmed = value.trim(); + const slash = trimmed.indexOf("/"); + if (slash <= 0) { + return null; + } + return normalizeProviderId(trimmed.slice(0, slash)); +} diff --git a/packages/model-catalog-core/src/index.ts b/packages/model-catalog-core/src/index.ts new file mode 100644 index 000000000000..4bbd3b2e5a8e --- /dev/null +++ b/packages/model-catalog-core/src/index.ts @@ -0,0 +1,4 @@ +export * from "./configured-model-refs.js"; +export * from "./provider-id.js"; +export * from "./provider-model-id-normalization.js"; +export * from "./provider-model-id-normalize.js"; diff --git a/packages/model-catalog-core/src/provider-id.ts b/packages/model-catalog-core/src/provider-id.ts new file mode 100644 index 000000000000..16a62c48f097 --- /dev/null +++ b/packages/model-catalog-core/src/provider-id.ts @@ -0,0 +1,39 @@ +export function normalizeLowercaseStringOrEmpty(value: unknown): string { + return typeof value === "string" ? value.trim().toLowerCase() : ""; +} + +export function normalizeProviderId(provider: string): string { + return normalizeLowercaseStringOrEmpty(provider); +} + +/** Normalize provider ID before manifest-owned auth alias lookup. */ +export function normalizeProviderIdForAuth(provider: string): string { + return normalizeProviderId(provider); +} + +export function findNormalizedProviderValue( + entries: Record | undefined, + provider: string, +): T | undefined { + if (!entries) { + return undefined; + } + const providerKey = normalizeProviderId(provider); + for (const [key, value] of Object.entries(entries)) { + if (normalizeProviderId(key) === providerKey) { + return value; + } + } + return undefined; +} + +export function findNormalizedProviderKey( + entries: Record | undefined, + provider: string, +): string | undefined { + if (!entries) { + return undefined; + } + const providerKey = normalizeProviderId(provider); + return Object.keys(entries).find((key) => normalizeProviderId(key) === providerKey); +} diff --git a/packages/model-catalog-core/src/provider-model-id-normalization.test.ts b/packages/model-catalog-core/src/provider-model-id-normalization.test.ts new file mode 100644 index 000000000000..153e5abe1575 --- /dev/null +++ b/packages/model-catalog-core/src/provider-model-id-normalization.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { + collectManifestModelIdNormalizationPolicies, + normalizeConfiguredProviderCatalogModelId, + normalizeStaticProviderModelIdWithPolicies, +} from "./provider-model-id-normalization.js"; + +describe("provider model id policy normalization", () => { + it("applies manifest policies before built-in provider normalization", () => { + const policies = collectManifestModelIdNormalizationPolicies([ + { + modelIdNormalization: { + providers: { + "Google-Vertex": { + aliases: { + pro: "gemini-3-pro", + }, + }, + }, + }, + }, + ]); + + expect(normalizeStaticProviderModelIdWithPolicies("google-vertex", "pro", policies)).toBe( + "gemini-3.1-pro-preview", + ); + }); + + it("normalizes provider-prefixed Google catalog refs behind gateway prefixes", () => { + expect( + normalizeConfiguredProviderCatalogModelId( + "openrouter", + "openrouter/google/gemini-3-pro-preview", + ), + ).toBe("openrouter/google/gemini-3.1-pro-preview"); + }); +}); diff --git a/packages/model-catalog-core/src/provider-model-id-normalization.ts b/packages/model-catalog-core/src/provider-model-id-normalization.ts new file mode 100644 index 000000000000..5589e7ded6d8 --- /dev/null +++ b/packages/model-catalog-core/src/provider-model-id-normalization.ts @@ -0,0 +1,206 @@ +import { normalizeLowercaseStringOrEmpty } from "./provider-id.js"; +import { + normalizeGooglePreviewModelId, + normalizeTogetherModelId, +} from "./provider-model-id-normalize.js"; + +export type ManifestModelIdNormalizationProvider = { + aliases?: Record; + stripPrefixes?: string[]; + prefixWhenBare?: string; + prefixWhenBareAfterAliasStartsWith?: { + modelPrefix: string; + prefix: string; + }[]; +}; + +export type ManifestModelIdNormalizationRecord = { + modelIdNormalization?: { + providers?: Record; + }; +}; + +let currentManifestModelIdNormalizationPolicies: + | ReadonlyMap + | undefined; + +export function collectManifestModelIdNormalizationPolicies( + plugins: readonly ManifestModelIdNormalizationRecord[], +): Map { + const policies = new Map(); + for (const plugin of plugins) { + for (const [provider, policy] of Object.entries(plugin.modelIdNormalization?.providers ?? {})) { + policies.set(normalizeLowercaseStringOrEmpty(provider), policy); + } + } + return policies; +} + +export function setCurrentManifestModelIdNormalizationRecords( + plugins: readonly ManifestModelIdNormalizationRecord[] | undefined, +): void { + currentManifestModelIdNormalizationPolicies = plugins + ? collectManifestModelIdNormalizationPolicies(plugins) + : undefined; +} + +export function getCurrentManifestModelIdNormalizationPolicies(): + | ReadonlyMap + | undefined { + return currentManifestModelIdNormalizationPolicies; +} + +function hasProviderPrefix(modelId: string): boolean { + return modelId.includes("/"); +} + +function formatPrefixedModelId(prefix: string, modelId: string): string { + return `${prefix.replace(/\/+$/u, "")}/${modelId.replace(/^\/+/u, "")}`; +} + +export function normalizeProviderModelIdWithPolicies(params: { + provider: string; + policies: ReadonlyMap; + context: { + modelId: string; + }; +}): string | undefined { + const policy = params.policies.get(normalizeLowercaseStringOrEmpty(params.provider)); + if (!policy) { + return undefined; + } + + let modelId = params.context.modelId.trim(); + if (!modelId) { + return modelId; + } + + for (const prefix of policy.stripPrefixes ?? []) { + const normalizedPrefix = normalizeLowercaseStringOrEmpty(prefix); + if (normalizedPrefix && normalizeLowercaseStringOrEmpty(modelId).startsWith(normalizedPrefix)) { + modelId = modelId.slice(prefix.length); + break; + } + } + + modelId = policy.aliases?.[normalizeLowercaseStringOrEmpty(modelId)] ?? modelId; + + if (!hasProviderPrefix(modelId)) { + for (const rule of policy.prefixWhenBareAfterAliasStartsWith ?? []) { + if (normalizeLowercaseStringOrEmpty(modelId).startsWith(rule.modelPrefix.toLowerCase())) { + return formatPrefixedModelId(rule.prefix, modelId); + } + } + if (policy.prefixWhenBare) { + return formatPrefixedModelId(policy.prefixWhenBare, modelId); + } + } + + return modelId; +} + +export function normalizeBuiltInProviderModelId(provider: string, model: string): string { + const normalizedProvider = normalizeLowercaseStringOrEmpty(provider); + if ( + normalizedProvider === "google" || + normalizedProvider === "google-gemini-cli" || + normalizedProvider === "google-vertex" + ) { + return normalizeGooglePreviewModelId(model); + } + if (normalizedProvider === "openrouter") { + const trimmed = model.trim(); + return trimmed && !trimmed.includes("/") ? `openrouter/${trimmed}` : model; + } + if (normalizedProvider === "anthropic") { + const anthropicAliases: Record = { + "opus-4.8": "claude-opus-4-8", + opus: "claude-opus-4-8", + "opus-4.6": "claude-opus-4-6", + "sonnet-4.6": "claude-sonnet-4-6", + }; + return anthropicAliases[normalizeLowercaseStringOrEmpty(model)] ?? model; + } + if (normalizedProvider === "vercel-ai-gateway") { + const vercelAliases: Record = { + "opus-4.6": "claude-opus-4-6", + "sonnet-4.6": "claude-sonnet-4-6", + }; + const aliased = vercelAliases[normalizeLowercaseStringOrEmpty(model)] ?? model; + return normalizeLowercaseStringOrEmpty(aliased).startsWith("claude-") + ? `anthropic/${aliased}` + : aliased; + } + if (normalizedProvider === "huggingface") { + const prefix = "huggingface/"; + return normalizeLowercaseStringOrEmpty(model).startsWith(prefix) + ? model.slice(prefix.length) + : model; + } + if (normalizedProvider === "nvidia") { + const trimmed = model.trim(); + return trimmed && !trimmed.includes("/") ? `nvidia/${trimmed}` : model; + } + if (normalizedProvider === "xai") { + const xaiAliases: Record = { + "grok-4-fast-reasoning": "grok-4-fast", + "grok-4-1-fast-reasoning": "grok-4-1-fast", + "grok-4.20-experimental-beta-0304-reasoning": "grok-4.20-beta-latest-reasoning", + "grok-4.20-experimental-beta-0304-non-reasoning": "grok-4.20-beta-latest-non-reasoning", + "grok-4.20-reasoning": "grok-4.20-beta-latest-reasoning", + "grok-4.20-non-reasoning": "grok-4.20-beta-latest-non-reasoning", + }; + return xaiAliases[normalizeLowercaseStringOrEmpty(model)] ?? model; + } + if (normalizedProvider === "together") { + return normalizeTogetherModelId(model); + } + return model; +} + +export function normalizeStaticProviderModelIdWithPolicies( + provider: string, + model: string, + policies?: ReadonlyMap, +): string { + const normalizedProvider = normalizeLowercaseStringOrEmpty(provider); + const manifestModelId = policies + ? (normalizeProviderModelIdWithPolicies({ + provider: normalizedProvider, + policies, + context: { + modelId: model, + }, + }) ?? model) + : model; + return normalizeBuiltInProviderModelId(normalizedProvider, manifestModelId); +} + +export function normalizeConfiguredProviderCatalogModelId( + provider: string, + model: string, + policies = getCurrentManifestModelIdNormalizationPolicies(), +): string { + const providerModel = normalizeStaticProviderModelIdWithPolicies(provider, model, policies); + return normalizeConfiguredProviderCatalogModelRef(providerModel); +} + +export function normalizeConfiguredProviderCatalogModelRef(providerModel: string): string { + const googlePrefix = "google/"; + if (!providerModel.startsWith(googlePrefix)) { + const slash = providerModel.indexOf("/"); + if (slash <= 0 || slash >= providerModel.length - 1) { + return providerModel; + } + const prefix = providerModel.slice(0, slash + 1); + const suffix = providerModel.slice(slash + 1); + if (!suffix.startsWith(googlePrefix)) { + return providerModel; + } + const normalizedSuffix = normalizeGooglePreviewModelId(suffix); + return normalizedSuffix === suffix ? providerModel : `${prefix}${normalizedSuffix}`; + } + const modelId = providerModel.slice(googlePrefix.length); + const normalizedModelId = normalizeGooglePreviewModelId(modelId); + return normalizedModelId === modelId ? providerModel : `${googlePrefix}${normalizedModelId}`; +} diff --git a/src/plugin-sdk/provider-model-id-normalize.test.ts b/packages/model-catalog-core/src/provider-model-id-normalize.test.ts similarity index 100% rename from src/plugin-sdk/provider-model-id-normalize.test.ts rename to packages/model-catalog-core/src/provider-model-id-normalize.test.ts diff --git a/packages/model-catalog-core/src/provider-model-id-normalize.ts b/packages/model-catalog-core/src/provider-model-id-normalize.ts new file mode 100644 index 000000000000..9259d9723055 --- /dev/null +++ b/packages/model-catalog-core/src/provider-model-id-normalize.ts @@ -0,0 +1,43 @@ +const ANTIGRAVITY_BARE_PRO_IDS = new Set(["gemini-3-pro", "gemini-3.1-pro", "gemini-3-1-pro"]); +const GOOGLE_PROVIDER_PREFIX = "google/"; + +export function normalizeGooglePreviewModelId(id: string): string { + if (id.startsWith(GOOGLE_PROVIDER_PREFIX)) { + const modelId = id.slice(GOOGLE_PROVIDER_PREFIX.length); + const normalizedModelId = normalizeGooglePreviewModelId(modelId); + return normalizedModelId === modelId ? id : `${GOOGLE_PROVIDER_PREFIX}${normalizedModelId}`; + } + if (id === "gemini-3-pro" || id === "gemini-3-pro-preview") { + return "gemini-3.1-pro-preview"; + } + if (id === "gemini-3-flash") { + return "gemini-3-flash-preview"; + } + if (id === "gemini-3.1-pro") { + return "gemini-3.1-pro-preview"; + } + // Gemini 3.1 Flash Lite graduated to GA on 2026-05-07; the -preview + // endpoint is deprecated (shutdown 2026-05-25). Map old preview name + // to the stable GA id. + if (id === "gemini-3.1-flash-lite-preview") { + return "gemini-3.1-flash-lite"; + } + if (id === "gemini-3.1-flash" || id === "gemini-3.1-flash-preview") { + return "gemini-3-flash-preview"; + } + return id; +} + +export function normalizeTogetherModelId(id: string): string { + if (id === "moonshotai/Kimi-K2.5") { + return "moonshotai/Kimi-K2.6"; + } + return id; +} + +export function normalizeAntigravityPreviewModelId(id: string): string { + if (ANTIGRAVITY_BARE_PRO_IDS.has(id)) { + return `${id}-low`; + } + return id; +} diff --git a/packages/model-catalog-core/tsconfig.json b/packages/model-catalog-core/tsconfig.json new file mode 100644 index 000000000000..60c7df18111f --- /dev/null +++ b/packages/model-catalog-core/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src/**/*"] +} diff --git a/packages/plugin-sdk/tsconfig.json b/packages/plugin-sdk/tsconfig.json index 21584911e538..c9c3894c33bb 100644 --- a/packages/plugin-sdk/tsconfig.json +++ b/packages/plugin-sdk/tsconfig.json @@ -15,6 +15,7 @@ "include": [ "../../packages/markdown-core/src/**/*.ts", "../../packages/media-generation-core/src/**/*.ts", + "../../packages/model-catalog-core/src/**/*.ts", "../../packages/terminal-core/src/**/*.ts", "../../src/plugin-sdk/**/*.ts", "../../src/video-generation/dashscope-compatible.ts", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 01169a277b23..d067f21f5c61 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1830,6 +1830,8 @@ importers: packages/memory-host-sdk: {} + packages/model-catalog-core: {} + packages/net-policy: dependencies: ipaddr.js: diff --git a/scripts/build-all.mjs b/scripts/build-all.mjs index 72d9b845a2f5..6d7b7fa04196 100644 --- a/scripts/build-all.mjs +++ b/scripts/build-all.mjs @@ -50,12 +50,14 @@ export const BUILD_ALL_STEPS = [ "packages/markdown-core/package.json", "packages/media-understanding-common/package.json", "packages/terminal-core/package.json", + "packages/model-catalog-core/package.json", "packages/memory-host-sdk/package.json", "tsconfig.json", "tsconfig.plugin-sdk.dts.json", "src/plugin-sdk", "packages/llm-core/src", "packages/markdown-core/src", + "packages/model-catalog-core/src", "packages/memory-host-sdk/src", "packages/media-generation-core/src", "packages/media-understanding-common/src", diff --git a/scripts/lib/extension-package-boundary.ts b/scripts/lib/extension-package-boundary.ts index 258827d88e7f..0a65da10cc17 100644 --- a/scripts/lib/extension-package-boundary.ts +++ b/scripts/lib/extension-package-boundary.ts @@ -59,6 +59,20 @@ export const EXTENSION_PACKAGE_BOUNDARY_BASE_PATHS = { "@openclaw/llm-core/types": ["../dist/plugin-sdk/packages/llm-core/src/types.d.ts"], "@openclaw/llm-core/validation": ["../dist/plugin-sdk/packages/llm-core/src/validation.d.ts"], "@openclaw/llm-core/*": ["../dist/plugin-sdk/packages/llm-core/src/*.d.ts"], + "@openclaw/model-catalog-core": ["../dist/plugin-sdk/packages/model-catalog-core/src/index.d.ts"], + "@openclaw/model-catalog-core/configured-model-refs": [ + "../dist/plugin-sdk/packages/model-catalog-core/src/configured-model-refs.d.ts", + ], + "@openclaw/model-catalog-core/provider-id": [ + "../dist/plugin-sdk/packages/model-catalog-core/src/provider-id.d.ts", + ], + "@openclaw/model-catalog-core/provider-model-id-normalization": [ + "../dist/plugin-sdk/packages/model-catalog-core/src/provider-model-id-normalization.d.ts", + ], + "@openclaw/model-catalog-core/provider-model-id-normalize": [ + "../dist/plugin-sdk/packages/model-catalog-core/src/provider-model-id-normalize.d.ts", + ], + "@openclaw/model-catalog-core/*": ["../dist/plugin-sdk/packages/model-catalog-core/src/*.d.ts"], "@openclaw/markdown-core": ["../dist/plugin-sdk/packages/markdown-core/src/index.d.ts"], "@openclaw/markdown-core/code-spans": [ "../dist/plugin-sdk/packages/markdown-core/src/code-spans.d.ts", diff --git a/scripts/prepare-extension-package-boundary-artifacts.mjs b/scripts/prepare-extension-package-boundary-artifacts.mjs index dcb84553ac56..d1a386e14c21 100644 --- a/scripts/prepare-extension-package-boundary-artifacts.mjs +++ b/scripts/prepare-extension-package-boundary-artifacts.mjs @@ -17,6 +17,7 @@ const PLUGIN_SDK_TYPE_INPUTS = [ "src/auto-reply", "packages/llm-core/src", "packages/markdown-core/src", + "packages/model-catalog-core/src", "packages/memory-host-sdk/src", "packages/media-generation-core/src", "packages/media-understanding-common/src", @@ -68,6 +69,10 @@ const ROOT_DTS_REQUIRED_OUTPUTS = [ "dist/plugin-sdk/packages/terminal-core/src/table.d.ts", "dist/plugin-sdk/packages/terminal-core/src/terminal-link.d.ts", "dist/plugin-sdk/packages/terminal-core/src/theme.d.ts", + "dist/plugin-sdk/packages/model-catalog-core/src/configured-model-refs.d.ts", + "dist/plugin-sdk/packages/model-catalog-core/src/provider-id.d.ts", + "dist/plugin-sdk/packages/model-catalog-core/src/provider-model-id-normalization.d.ts", + "dist/plugin-sdk/packages/model-catalog-core/src/provider-model-id-normalize.d.ts", "dist/plugin-sdk/error-runtime.d.ts", "dist/plugin-sdk/plugin-entry.d.ts", "dist/plugin-sdk/provider-auth.d.ts", @@ -90,6 +95,10 @@ const PACKAGE_DTS_REQUIRED_OUTPUTS = [ "packages/plugin-sdk/dist/packages/media-generation-core/src/index.d.ts", "packages/plugin-sdk/dist/packages/media-generation-core/src/model-ref.d.ts", "packages/plugin-sdk/dist/packages/media-generation-core/src/normalization.d.ts", + "packages/plugin-sdk/dist/packages/model-catalog-core/src/configured-model-refs.d.ts", + "packages/plugin-sdk/dist/packages/model-catalog-core/src/provider-id.d.ts", + "packages/plugin-sdk/dist/packages/model-catalog-core/src/provider-model-id-normalization.d.ts", + "packages/plugin-sdk/dist/packages/model-catalog-core/src/provider-model-id-normalize.d.ts", "packages/plugin-sdk/dist/packages/terminal-core/src/ansi.d.ts", "packages/plugin-sdk/dist/packages/terminal-core/src/decorative-emoji.d.ts", "packages/plugin-sdk/dist/packages/terminal-core/src/health-style.d.ts", diff --git a/src/agents/embedded-agent-runner/model.ts b/src/agents/embedded-agent-runner/model.ts index d3d69aafd234..9556b1f4a7d8 100644 --- a/src/agents/embedded-agent-runner/model.ts +++ b/src/agents/embedded-agent-runner/model.ts @@ -74,6 +74,12 @@ type ProviderRuntimeHooks = { normalizeProviderTransportWithPlugin: typeof normalizeProviderTransportWithPlugin; }; +type StaticCatalogFallbackModel = Model & { + compat?: ModelCompatConfig; + contextTokens?: number; + mediaInput?: ModelMediaInputConfig; +}; + const TARGET_PROVIDER_RUNTIME_HOOKS: ProviderRuntimeHooks = { buildProviderUnknownModelHintWithPlugin, prepareProviderDynamicModel, @@ -989,14 +995,16 @@ function resolveConfiguredFallbackModel(params: { } const staticCatalogModel = configuredModel ? undefined - : resolveBundledStaticCatalogModel({ + : (resolveBundledStaticCatalogModel({ provider, modelId, cfg, workspaceDir, includeRuntimeDiscovery: true, - }); + }) as StaticCatalogFallbackModel | undefined); const metadataModel = configuredModel ?? staticCatalogModel; + const fallbackCompat = configuredModel?.compat ?? staticCatalogModel?.compat; + const fallbackMediaInput = configuredModel?.mediaInput ?? staticCatalogModel?.mediaInput; const providerHeaders = sanitizeModelHeaders(providerConfig?.headers, { stripSecretRefMarkers: true, }); @@ -1009,7 +1017,7 @@ function resolveConfiguredFallbackModel(params: { provider, modelId, providerParams: providerConfig?.params, - configuredParams: metadataModel?.params, + configuredParams: configuredModel?.params, }); const fallbackTransport = resolveProviderTransport({ provider, @@ -1036,7 +1044,7 @@ function resolveConfiguredFallbackModel(params: { }); const fallbackReasoning = resolveConfiguredFallbackReasoning({ provider, - compat: metadataModel?.compat, + compat: fallbackCompat, reasoning: metadataModel?.reasoning, }); return normalizeResolvedModel({ @@ -1080,8 +1088,8 @@ function resolveConfiguredFallbackModel(params: { ...(resolvedParams ? { params: resolvedParams } : {}), ...(requestTimeoutMs !== undefined ? { requestTimeoutMs } : {}), headers: requestConfig.headers, - compat: metadataModel?.compat, - mediaInput: metadataModel?.mediaInput, + compat: fallbackCompat, + mediaInput: fallbackMediaInput, } as Model, providerRequest, ), diff --git a/src/agents/provider-id.ts b/src/agents/provider-id.ts index c8404a48fe70..01d16903ea86 100644 --- a/src/agents/provider-id.ts +++ b/src/agents/provider-id.ts @@ -1,37 +1,6 @@ -import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; - -export function normalizeProviderId(provider: string): string { - return normalizeLowercaseStringOrEmpty(provider); -} - -/** Normalize provider ID before manifest-owned auth alias lookup. */ -export function normalizeProviderIdForAuth(provider: string): string { - return normalizeProviderId(provider); -} - -export function findNormalizedProviderValue( - entries: Record | undefined, - provider: string, -): T | undefined { - if (!entries) { - return undefined; - } - const providerKey = normalizeProviderId(provider); - for (const [key, value] of Object.entries(entries)) { - if (normalizeProviderId(key) === providerKey) { - return value; - } - } - return undefined; -} - -export function findNormalizedProviderKey( - entries: Record | undefined, - provider: string, -): string | undefined { - if (!entries) { - return undefined; - } - const providerKey = normalizeProviderId(provider); - return Object.keys(entries).find((key) => normalizeProviderId(key) === providerKey); -} +export { + findNormalizedProviderKey, + findNormalizedProviderValue, + normalizeProviderId, + normalizeProviderIdForAuth, +} from "../../packages/model-catalog-core/src/provider-id.js"; diff --git a/src/agents/tools/pdf-tool.model-config.ts b/src/agents/tools/pdf-tool.model-config.ts index ef1eead2abb0..d3faba6dad01 100644 --- a/src/agents/tools/pdf-tool.model-config.ts +++ b/src/agents/tools/pdf-tool.model-config.ts @@ -43,7 +43,7 @@ function resolveConfiguredTextModelFromConfig(params: { const providerCfg = findNormalizedProviderValue(providers, params.providerId); const modelId = providerCfg?.models ?.find( - (model) => + (model: { id?: string; input?: readonly string[] }) => Boolean(model?.id?.trim()) && Array.isArray(model?.input) && model.input.includes("text"), ) ?.id?.trim(); diff --git a/src/config/model-refs.ts b/src/config/model-refs.ts index f5d08f45862f..e2e539c0a4b6 100644 --- a/src/config/model-refs.ts +++ b/src/config/model-refs.ts @@ -1,131 +1,7 @@ -import { normalizeProviderId } from "../agents/provider-id.js"; -import { isRecord } from "../utils.js"; - -export type ConfiguredModelRef = { - path: string; - value: string; -}; - -export const AGENT_MODEL_CONFIG_KEYS = [ - "model", - "imageModel", - "imageGenerationModel", - "videoGenerationModel", - "musicGenerationModel", - "voiceModel", - "pdfModel", -] as const; - -export function collectConfiguredModelRefs( - config: unknown, - options: { includeChannelModelOverrides?: boolean } = {}, -): ConfiguredModelRef[] { - const refs: ConfiguredModelRef[] = []; - const pushModelRef = (path: string, value: unknown) => { - if (typeof value === "string" && value.trim()) { - refs.push({ path, value: value.trim() }); - } - }; - const collectModelConfig = (path: string, value: unknown) => { - if (typeof value === "string") { - pushModelRef(path, value); - return; - } - if (!isRecord(value)) { - return; - } - pushModelRef(`${path}.primary`, value.primary); - if (Array.isArray(value.fallbacks)) { - for (const [index, entry] of value.fallbacks.entries()) { - pushModelRef(`${path}.fallbacks.${index}`, entry); - } - } - }; - const collectFromAgent = (path: string, agent: unknown) => { - if (!isRecord(agent)) { - return; - } - for (const key of AGENT_MODEL_CONFIG_KEYS) { - collectModelConfig(`${path}.${key}`, agent[key]); - } - pushModelRef( - `${path}.heartbeat.model`, - isRecord(agent.heartbeat) ? agent.heartbeat.model : undefined, - ); - collectModelConfig( - `${path}.subagents.model`, - isRecord(agent.subagents) ? agent.subagents.model : undefined, - ); - if (isRecord(agent.compaction)) { - pushModelRef(`${path}.compaction.model`, agent.compaction.model); - pushModelRef( - `${path}.compaction.memoryFlush.model`, - isRecord(agent.compaction.memoryFlush) ? agent.compaction.memoryFlush.model : undefined, - ); - } - if (isRecord(agent.models)) { - for (const modelRef of Object.keys(agent.models)) { - pushModelRef(`${path}.models.${modelRef}`, modelRef); - } - } - }; - - const root = isRecord(config) ? config : {}; - const agents = isRecord(root.agents) ? root.agents : {}; - collectFromAgent("agents.defaults", agents.defaults); - if (Array.isArray(agents.list)) { - for (const [index, entry] of agents.list.entries()) { - collectFromAgent(`agents.list.${index}`, entry); - } - } - if (options.includeChannelModelOverrides !== false) { - const channels = isRecord(root.channels) ? root.channels : {}; - const modelByChannel = isRecord(channels.modelByChannel) ? channels.modelByChannel : {}; - for (const [channelId, channelMap] of Object.entries(modelByChannel)) { - if (!isRecord(channelMap)) { - continue; - } - for (const [targetId, modelRef] of Object.entries(channelMap)) { - pushModelRef(`channels.modelByChannel.${channelId}.${targetId}`, modelRef); - } - } - } - const hooks = isRecord(root.hooks) ? root.hooks : {}; - if (Array.isArray(hooks.mappings)) { - for (const [index, mapping] of hooks.mappings.entries()) { - pushModelRef(`hooks.mappings.${index}.model`, isRecord(mapping) ? mapping.model : undefined); - } - } - pushModelRef("hooks.gmail.model", isRecord(hooks.gmail) ? hooks.gmail.model : undefined); - pushModelRef( - "messages.tts.summaryModel", - isRecord(root.messages) && isRecord(root.messages.tts) - ? root.messages.tts.summaryModel - : undefined, - ); - pushModelRef( - "channels.discord.voice.model", - isRecord(root.channels) && - isRecord(root.channels.discord) && - isRecord(root.channels.discord.voice) - ? root.channels.discord.voice.model - : undefined, - ); - return refs; -} - -export function collectConfiguredModelRefValues( - config: unknown, - options?: { includeChannelModelOverrides?: boolean }, -): string[] { - return collectConfiguredModelRefs(config, options).map((ref) => ref.value); -} - -export function extractProviderFromModelRef(value: string): string | null { - const trimmed = value.trim(); - const slash = trimmed.indexOf("/"); - if (slash <= 0) { - return null; - } - return normalizeProviderId(trimmed.slice(0, slash)); -} +export { + AGENT_MODEL_CONFIG_KEYS, + collectConfiguredModelRefs, + collectConfiguredModelRefValues, + extractProviderFromModelRef, + type ConfiguredModelRef, +} from "../../packages/model-catalog-core/src/configured-model-refs.js"; diff --git a/src/plugin-sdk/provider-model-id-normalize.ts b/src/plugin-sdk/provider-model-id-normalize.ts index 9259d9723055..d402652d637f 100644 --- a/src/plugin-sdk/provider-model-id-normalize.ts +++ b/src/plugin-sdk/provider-model-id-normalize.ts @@ -1,43 +1,5 @@ -const ANTIGRAVITY_BARE_PRO_IDS = new Set(["gemini-3-pro", "gemini-3.1-pro", "gemini-3-1-pro"]); -const GOOGLE_PROVIDER_PREFIX = "google/"; - -export function normalizeGooglePreviewModelId(id: string): string { - if (id.startsWith(GOOGLE_PROVIDER_PREFIX)) { - const modelId = id.slice(GOOGLE_PROVIDER_PREFIX.length); - const normalizedModelId = normalizeGooglePreviewModelId(modelId); - return normalizedModelId === modelId ? id : `${GOOGLE_PROVIDER_PREFIX}${normalizedModelId}`; - } - if (id === "gemini-3-pro" || id === "gemini-3-pro-preview") { - return "gemini-3.1-pro-preview"; - } - if (id === "gemini-3-flash") { - return "gemini-3-flash-preview"; - } - if (id === "gemini-3.1-pro") { - return "gemini-3.1-pro-preview"; - } - // Gemini 3.1 Flash Lite graduated to GA on 2026-05-07; the -preview - // endpoint is deprecated (shutdown 2026-05-25). Map old preview name - // to the stable GA id. - if (id === "gemini-3.1-flash-lite-preview") { - return "gemini-3.1-flash-lite"; - } - if (id === "gemini-3.1-flash" || id === "gemini-3.1-flash-preview") { - return "gemini-3-flash-preview"; - } - return id; -} - -export function normalizeTogetherModelId(id: string): string { - if (id === "moonshotai/Kimi-K2.5") { - return "moonshotai/Kimi-K2.6"; - } - return id; -} - -export function normalizeAntigravityPreviewModelId(id: string): string { - if (ANTIGRAVITY_BARE_PRO_IDS.has(id)) { - return `${id}-low`; - } - return id; -} +export { + normalizeAntigravityPreviewModelId, + normalizeGooglePreviewModelId, + normalizeTogetherModelId, +} from "../../packages/model-catalog-core/src/provider-model-id-normalize.js"; diff --git a/src/plugins/contracts/extension-package-project-boundaries.test.ts b/src/plugins/contracts/extension-package-project-boundaries.test.ts index 48197a660633..c75e34ab2e3f 100644 --- a/src/plugins/contracts/extension-package-project-boundaries.test.ts +++ b/src/plugins/contracts/extension-package-project-boundaries.test.ts @@ -196,6 +196,7 @@ describe("opt-in extension package boundaries", () => { expect(tsconfig.include).toEqual([ "../../packages/markdown-core/src/**/*.ts", "../../packages/media-generation-core/src/**/*.ts", + "../../packages/model-catalog-core/src/**/*.ts", "../../packages/terminal-core/src/**/*.ts", "../../src/plugin-sdk/**/*.ts", "../../src/video-generation/dashscope-compatible.ts", diff --git a/src/plugins/sdk-alias.test.ts b/src/plugins/sdk-alias.test.ts index b5e998c49cb9..06bac5d312cd 100644 --- a/src/plugins/sdk-alias.test.ts +++ b/src/plugins/sdk-alias.test.ts @@ -1420,6 +1420,12 @@ describe("plugin sdk alias helpers", () => { srcFile: "ip.ts", distFile: "ip.mjs", }); + const modelCatalogProviderId = writeWorkspacePackageEntry({ + root: fixture.root, + packageDir: "model-catalog-core", + srcFile: "provider-id.ts", + distFile: "provider-id.mjs", + }); fs.rmSync(gatewayClient.distFile); fs.rmSync(gatewayClientTimeouts.distFile); fs.rmSync(gatewayProtocol.distFile); @@ -1432,6 +1438,7 @@ describe("plugin sdk alias helpers", () => { fs.rmSync(terminalCoreTheme.distFile); fs.rmSync(netPolicy.distFile); fs.rmSync(netPolicyIp.distFile); + fs.rmSync(modelCatalogProviderId.distFile); const sourcePluginEntry = writePluginEntry( fixture.root, bundledPluginFile("demo", "src/index.ts"), @@ -1477,6 +1484,9 @@ describe("plugin sdk alias helpers", () => { expect(fs.realpathSync(aliases["@openclaw/net-policy/ip"] ?? "")).toBe( fs.realpathSync(netPolicyIp.srcFile), ); + expect(fs.realpathSync(aliases["@openclaw/model-catalog-core/provider-id"] ?? "")).toBe( + fs.realpathSync(modelCatalogProviderId.srcFile), + ); }); it("aliases workspace package subpaths to dist when available", () => { @@ -1520,6 +1530,12 @@ describe("plugin sdk alias helpers", () => { srcFile: "redact-sensitive-url.ts", distFile: "redact-sensitive-url.mjs", }); + const modelCatalogCore = writeWorkspacePackageEntry({ + root: fixture.root, + packageDir: "model-catalog-core", + srcFile: "provider-model-id-normalize.ts", + distFile: "provider-model-id-normalize.mjs", + }); const sourcePluginEntry = writePluginEntry( fixture.root, bundledPluginFile("demo", "src/index.ts"), @@ -1547,6 +1563,9 @@ describe("plugin sdk alias helpers", () => { expect(fs.realpathSync(aliases["@openclaw/net-policy/redact-sensitive-url"] ?? "")).toBe( fs.realpathSync(netPolicy.distFile), ); + expect( + fs.realpathSync(aliases["@openclaw/model-catalog-core/provider-model-id-normalize"] ?? ""), + ).toBe(fs.realpathSync(modelCatalogCore.distFile)); }); it("aliases bundled plugin package public surfaces for source plugin transforms", () => { diff --git a/src/plugins/sdk-alias.ts b/src/plugins/sdk-alias.ts index 9cc6faaf8879..d7f67c7c95eb 100644 --- a/src/plugins/sdk-alias.ts +++ b/src/plugins/sdk-alias.ts @@ -819,6 +819,41 @@ const WORKSPACE_PACKAGE_ALIAS_ENTRIES = [ srcFile: "url-userinfo.ts", distFile: "url-userinfo.mjs", }, + { + packageName: "@openclaw/model-catalog-core", + packageDir: "model-catalog-core", + subpath: "", + srcFile: "index.ts", + distFile: "index.mjs", + }, + { + packageName: "@openclaw/model-catalog-core", + packageDir: "model-catalog-core", + subpath: "configured-model-refs", + srcFile: "configured-model-refs.ts", + distFile: "configured-model-refs.mjs", + }, + { + packageName: "@openclaw/model-catalog-core", + packageDir: "model-catalog-core", + subpath: "provider-id", + srcFile: "provider-id.ts", + distFile: "provider-id.mjs", + }, + { + packageName: "@openclaw/model-catalog-core", + packageDir: "model-catalog-core", + subpath: "provider-model-id-normalization", + srcFile: "provider-model-id-normalization.ts", + distFile: "provider-model-id-normalization.mjs", + }, + { + packageName: "@openclaw/model-catalog-core", + packageDir: "model-catalog-core", + subpath: "provider-model-id-normalize", + srcFile: "provider-model-id-normalize.ts", + distFile: "provider-model-id-normalize.mjs", + }, ] as const; function isUsableDistPluginSdkArtifact(candidate: string): boolean { diff --git a/src/shared/provider-model-id-normalization.ts b/src/shared/provider-model-id-normalization.ts index 128b060d6e33..cd11a941b4f3 100644 --- a/src/shared/provider-model-id-normalization.ts +++ b/src/shared/provider-model-id-normalization.ts @@ -1,206 +1,12 @@ -import { - normalizeGooglePreviewModelId, - normalizeTogetherModelId, -} from "../plugin-sdk/provider-model-id-normalize.js"; -import { normalizeLowercaseStringOrEmpty } from "./string-coerce.js"; - -export type ManifestModelIdNormalizationProvider = { - aliases?: Record; - stripPrefixes?: string[]; - prefixWhenBare?: string; - prefixWhenBareAfterAliasStartsWith?: { - modelPrefix: string; - prefix: string; - }[]; -}; - -export type ManifestModelIdNormalizationRecord = { - modelIdNormalization?: { - providers?: Record; - }; -}; - -let currentManifestModelIdNormalizationPolicies: - | ReadonlyMap - | undefined; - -export function collectManifestModelIdNormalizationPolicies( - plugins: readonly ManifestModelIdNormalizationRecord[], -): Map { - const policies = new Map(); - for (const plugin of plugins) { - for (const [provider, policy] of Object.entries(plugin.modelIdNormalization?.providers ?? {})) { - policies.set(normalizeLowercaseStringOrEmpty(provider), policy); - } - } - return policies; -} - -export function setCurrentManifestModelIdNormalizationRecords( - plugins: readonly ManifestModelIdNormalizationRecord[] | undefined, -): void { - currentManifestModelIdNormalizationPolicies = plugins - ? collectManifestModelIdNormalizationPolicies(plugins) - : undefined; -} - -export function getCurrentManifestModelIdNormalizationPolicies(): - | ReadonlyMap - | undefined { - return currentManifestModelIdNormalizationPolicies; -} - -function hasProviderPrefix(modelId: string): boolean { - return modelId.includes("/"); -} - -function formatPrefixedModelId(prefix: string, modelId: string): string { - return `${prefix.replace(/\/+$/u, "")}/${modelId.replace(/^\/+/u, "")}`; -} - -export function normalizeProviderModelIdWithPolicies(params: { - provider: string; - policies: ReadonlyMap; - context: { - modelId: string; - }; -}): string | undefined { - const policy = params.policies.get(normalizeLowercaseStringOrEmpty(params.provider)); - if (!policy) { - return undefined; - } - - let modelId = params.context.modelId.trim(); - if (!modelId) { - return modelId; - } - - for (const prefix of policy.stripPrefixes ?? []) { - const normalizedPrefix = normalizeLowercaseStringOrEmpty(prefix); - if (normalizedPrefix && normalizeLowercaseStringOrEmpty(modelId).startsWith(normalizedPrefix)) { - modelId = modelId.slice(prefix.length); - break; - } - } - - modelId = policy.aliases?.[normalizeLowercaseStringOrEmpty(modelId)] ?? modelId; - - if (!hasProviderPrefix(modelId)) { - for (const rule of policy.prefixWhenBareAfterAliasStartsWith ?? []) { - if (normalizeLowercaseStringOrEmpty(modelId).startsWith(rule.modelPrefix.toLowerCase())) { - return formatPrefixedModelId(rule.prefix, modelId); - } - } - if (policy.prefixWhenBare) { - return formatPrefixedModelId(policy.prefixWhenBare, modelId); - } - } - - return modelId; -} - -export function normalizeBuiltInProviderModelId(provider: string, model: string): string { - const normalizedProvider = normalizeLowercaseStringOrEmpty(provider); - if ( - normalizedProvider === "google" || - normalizedProvider === "google-gemini-cli" || - normalizedProvider === "google-vertex" - ) { - return normalizeGooglePreviewModelId(model); - } - if (normalizedProvider === "openrouter") { - const trimmed = model.trim(); - return trimmed && !trimmed.includes("/") ? `openrouter/${trimmed}` : model; - } - if (normalizedProvider === "anthropic") { - const anthropicAliases: Record = { - "opus-4.8": "claude-opus-4-8", - opus: "claude-opus-4-8", - "opus-4.6": "claude-opus-4-6", - "sonnet-4.6": "claude-sonnet-4-6", - }; - return anthropicAliases[normalizeLowercaseStringOrEmpty(model)] ?? model; - } - if (normalizedProvider === "vercel-ai-gateway") { - const vercelAliases: Record = { - "opus-4.6": "claude-opus-4-6", - "sonnet-4.6": "claude-sonnet-4-6", - }; - const aliased = vercelAliases[normalizeLowercaseStringOrEmpty(model)] ?? model; - return normalizeLowercaseStringOrEmpty(aliased).startsWith("claude-") - ? `anthropic/${aliased}` - : aliased; - } - if (normalizedProvider === "huggingface") { - const prefix = "huggingface/"; - return normalizeLowercaseStringOrEmpty(model).startsWith(prefix) - ? model.slice(prefix.length) - : model; - } - if (normalizedProvider === "nvidia") { - const trimmed = model.trim(); - return trimmed && !trimmed.includes("/") ? `nvidia/${trimmed}` : model; - } - if (normalizedProvider === "xai") { - const xaiAliases: Record = { - "grok-4-fast-reasoning": "grok-4-fast", - "grok-4-1-fast-reasoning": "grok-4-1-fast", - "grok-4.20-experimental-beta-0304-reasoning": "grok-4.20-beta-latest-reasoning", - "grok-4.20-experimental-beta-0304-non-reasoning": "grok-4.20-beta-latest-non-reasoning", - "grok-4.20-reasoning": "grok-4.20-beta-latest-reasoning", - "grok-4.20-non-reasoning": "grok-4.20-beta-latest-non-reasoning", - }; - return xaiAliases[normalizeLowercaseStringOrEmpty(model)] ?? model; - } - if (normalizedProvider === "together") { - return normalizeTogetherModelId(model); - } - return model; -} - -export function normalizeStaticProviderModelIdWithPolicies( - provider: string, - model: string, - policies?: ReadonlyMap, -): string { - const normalizedProvider = normalizeLowercaseStringOrEmpty(provider); - const manifestModelId = policies - ? (normalizeProviderModelIdWithPolicies({ - provider: normalizedProvider, - policies, - context: { - modelId: model, - }, - }) ?? model) - : model; - return normalizeBuiltInProviderModelId(normalizedProvider, manifestModelId); -} - -export function normalizeConfiguredProviderCatalogModelId( - provider: string, - model: string, - policies = getCurrentManifestModelIdNormalizationPolicies(), -): string { - const providerModel = normalizeStaticProviderModelIdWithPolicies(provider, model, policies); - return normalizeConfiguredProviderCatalogModelRef(providerModel); -} - -export function normalizeConfiguredProviderCatalogModelRef(providerModel: string): string { - const googlePrefix = "google/"; - if (!providerModel.startsWith(googlePrefix)) { - const slash = providerModel.indexOf("/"); - if (slash <= 0 || slash >= providerModel.length - 1) { - return providerModel; - } - const prefix = providerModel.slice(0, slash + 1); - const suffix = providerModel.slice(slash + 1); - if (!suffix.startsWith(googlePrefix)) { - return providerModel; - } - const normalizedSuffix = normalizeGooglePreviewModelId(suffix); - return normalizedSuffix === suffix ? providerModel : `${prefix}${normalizedSuffix}`; - } - const modelId = providerModel.slice(googlePrefix.length); - const normalizedModelId = normalizeGooglePreviewModelId(modelId); - return normalizedModelId === modelId ? providerModel : `${googlePrefix}${normalizedModelId}`; -} +export { + collectManifestModelIdNormalizationPolicies, + getCurrentManifestModelIdNormalizationPolicies, + normalizeBuiltInProviderModelId, + normalizeConfiguredProviderCatalogModelId, + normalizeConfiguredProviderCatalogModelRef, + normalizeProviderModelIdWithPolicies, + normalizeStaticProviderModelIdWithPolicies, + setCurrentManifestModelIdNormalizationRecords, + type ManifestModelIdNormalizationProvider, + type ManifestModelIdNormalizationRecord, +} from "../../packages/model-catalog-core/src/provider-model-id-normalization.js"; diff --git a/test/vitest/vitest.shared.config.ts b/test/vitest/vitest.shared.config.ts index 1a8062695aa2..0c7f520bf9b4 100644 --- a/test/vitest/vitest.shared.config.ts +++ b/test/vitest/vitest.shared.config.ts @@ -237,6 +237,44 @@ export const sharedVitestConfig = { find: "@openclaw/llm-core", replacement: path.join(repoRoot, "packages", "llm-core", "src", "index.ts"), }, + { + find: "@openclaw/model-catalog-core/configured-model-refs", + replacement: path.join( + repoRoot, + "packages", + "model-catalog-core", + "src", + "configured-model-refs.ts", + ), + }, + { + find: "@openclaw/model-catalog-core/provider-id", + replacement: path.join(repoRoot, "packages", "model-catalog-core", "src", "provider-id.ts"), + }, + { + find: "@openclaw/model-catalog-core/provider-model-id-normalization", + replacement: path.join( + repoRoot, + "packages", + "model-catalog-core", + "src", + "provider-model-id-normalization.ts", + ), + }, + { + find: "@openclaw/model-catalog-core/provider-model-id-normalize", + replacement: path.join( + repoRoot, + "packages", + "model-catalog-core", + "src", + "provider-model-id-normalize.ts", + ), + }, + { + find: "@openclaw/model-catalog-core", + replacement: path.join(repoRoot, "packages", "model-catalog-core", "src", "index.ts"), + }, { find: "@openclaw/net-policy/ip", replacement: path.join(repoRoot, "packages", "net-policy", "src", "ip.ts"), diff --git a/tsconfig.json b/tsconfig.json index 0329f84ab187..2ec2ac443ff1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -36,6 +36,20 @@ "@openclaw/llm-core/*": ["./packages/llm-core/src/*"], "@openclaw/llm-runtime": ["./packages/llm-runtime/src/index.ts"], "@openclaw/llm-runtime/*": ["./packages/llm-runtime/src/*"], + "@openclaw/model-catalog-core": ["./packages/model-catalog-core/src/index.ts"], + "@openclaw/model-catalog-core/configured-model-refs": [ + "./packages/model-catalog-core/src/configured-model-refs.ts" + ], + "@openclaw/model-catalog-core/provider-id": [ + "./packages/model-catalog-core/src/provider-id.ts" + ], + "@openclaw/model-catalog-core/provider-model-id-normalization": [ + "./packages/model-catalog-core/src/provider-model-id-normalization.ts" + ], + "@openclaw/model-catalog-core/provider-model-id-normalize": [ + "./packages/model-catalog-core/src/provider-model-id-normalize.ts" + ], + "@openclaw/model-catalog-core/*": ["./packages/model-catalog-core/src/*"], "@openclaw/gateway-client": ["./packages/gateway-client/src/index.ts"], "@openclaw/gateway-client/*": ["./packages/gateway-client/src/*"], "@openclaw/gateway-protocol": ["./packages/gateway-protocol/src/index.ts"], diff --git a/tsconfig.plugin-sdk.dts.json b/tsconfig.plugin-sdk.dts.json index 3d2836e45ef2..5d5a8fae6e16 100644 --- a/tsconfig.plugin-sdk.dts.json +++ b/tsconfig.plugin-sdk.dts.json @@ -16,6 +16,7 @@ "packages/llm-core/src/**/*.ts", "packages/markdown-core/src/**/*.ts", "packages/media-generation-core/src/**/*.ts", + "packages/model-catalog-core/src/**/*.ts", "packages/memory-host-sdk/src/**/*.ts", "packages/terminal-core/src/**/*.ts", "src/video-generation/dashscope-compatible.ts", diff --git a/tsdown.config.ts b/tsdown.config.ts index 7285ecc72a1b..41de13768612 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -466,6 +466,17 @@ function buildLlmCoreDistEntries(): Record { }; } +function buildModelCatalogCoreDistEntries(): Record { + return { + index: "packages/model-catalog-core/src/index.ts", + "configured-model-refs": "packages/model-catalog-core/src/configured-model-refs.ts", + "provider-id": "packages/model-catalog-core/src/provider-id.ts", + "provider-model-id-normalization": + "packages/model-catalog-core/src/provider-model-id-normalization.ts", + "provider-model-id-normalize": "packages/model-catalog-core/src/provider-model-id-normalize.ts", + }; +} + function buildLlmRuntimeDistEntries(): Record { return { index: "packages/llm-runtime/src/index.ts", @@ -650,6 +661,12 @@ export default defineConfig([ neverBundle: shouldExternalizeLlmCoreDependency, }, }), + nodeWorkspacePackageBuildConfig({ + clean: true, + dts: RUN_NODE_SKIP_DTS_BUILD ? false : undefined, + entry: buildModelCatalogCoreDistEntries(), + outDir: "packages/model-catalog-core/dist", + }), nodeWorkspacePackageBuildConfig({ clean: true, dts: RUN_NODE_SKIP_DTS_BUILD ? false : undefined,