From 96ac51d23de23fa634fe0e1be6cf38d7dcce5757 Mon Sep 17 00:00:00 2001 From: Shakker Date: Sun, 26 Apr 2026 04:27:23 +0100 Subject: [PATCH] feat: add model catalog provider index contract --- src/model-catalog/index.ts | 9 ++ src/model-catalog/provider-index/index.ts | 7 + src/model-catalog/provider-index/load.ts | 9 ++ .../provider-index/normalize.test.ts | 112 ++++++++++++++++ src/model-catalog/provider-index/normalize.ts | 123 ++++++++++++++++++ .../provider-index/openclaw-provider-index.ts | 59 +++++++++ src/model-catalog/provider-index/types.ts | 21 +++ 7 files changed, 340 insertions(+) create mode 100644 src/model-catalog/provider-index/index.ts create mode 100644 src/model-catalog/provider-index/load.ts create mode 100644 src/model-catalog/provider-index/normalize.test.ts create mode 100644 src/model-catalog/provider-index/normalize.ts create mode 100644 src/model-catalog/provider-index/openclaw-provider-index.ts create mode 100644 src/model-catalog/provider-index/types.ts diff --git a/src/model-catalog/index.ts b/src/model-catalog/index.ts index dac4ecbcc0d..5261f03ebaf 100644 --- a/src/model-catalog/index.ts +++ b/src/model-catalog/index.ts @@ -8,6 +8,10 @@ export { normalizeModelCatalogProviderRows, normalizeModelCatalogRows, } from "./normalize.js"; +export { + loadOpenClawProviderIndex, + normalizeOpenClawProviderIndex, +} from "./provider-index/index.js"; export { planManifestModelCatalogRows } from "./manifest-planner.js"; export type { ManifestModelCatalogConflict, @@ -30,3 +34,8 @@ export type { ModelCatalogTieredCost, NormalizedModelCatalogRow, } from "./types.js"; +export type { + OpenClawProviderIndex, + OpenClawProviderIndexPlugin, + OpenClawProviderIndexProvider, +} from "./provider-index/index.js"; diff --git a/src/model-catalog/provider-index/index.ts b/src/model-catalog/provider-index/index.ts new file mode 100644 index 00000000000..753ea309be9 --- /dev/null +++ b/src/model-catalog/provider-index/index.ts @@ -0,0 +1,7 @@ +export { loadOpenClawProviderIndex } from "./load.js"; +export { normalizeOpenClawProviderIndex } from "./normalize.js"; +export type { + OpenClawProviderIndex, + OpenClawProviderIndexPlugin, + OpenClawProviderIndexProvider, +} from "./types.js"; diff --git a/src/model-catalog/provider-index/load.ts b/src/model-catalog/provider-index/load.ts new file mode 100644 index 00000000000..241ad1090d5 --- /dev/null +++ b/src/model-catalog/provider-index/load.ts @@ -0,0 +1,9 @@ +import { normalizeOpenClawProviderIndex } from "./normalize.js"; +import { OPENCLAW_PROVIDER_INDEX } from "./openclaw-provider-index.js"; +import type { OpenClawProviderIndex } from "./types.js"; + +export function loadOpenClawProviderIndex( + source: unknown = OPENCLAW_PROVIDER_INDEX, +): OpenClawProviderIndex { + return normalizeOpenClawProviderIndex(source) ?? { version: 1, providers: {} }; +} diff --git a/src/model-catalog/provider-index/normalize.test.ts b/src/model-catalog/provider-index/normalize.test.ts new file mode 100644 index 00000000000..d60879ee45f --- /dev/null +++ b/src/model-catalog/provider-index/normalize.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from "vitest"; +import { loadOpenClawProviderIndex, normalizeOpenClawProviderIndex } from "./index.js"; + +describe("OpenClaw provider index", () => { + it("normalizes provider preview catalog rows through model catalog validation", () => { + const index = normalizeOpenClawProviderIndex({ + version: 1, + providers: { + Moonshot: { + id: "moonshot", + name: "Moonshot AI", + plugin: { id: "moonshot", package: " @openclaw/plugin-moonshot " }, + docs: "/providers/moonshot", + categories: ["cloud", "llm"], + previewCatalog: { + api: "openai-responses", + baseUrl: "https://api.moonshot.ai/v1", + models: [ + { + id: "kimi-k2.6", + name: "Kimi K2.6", + input: ["text", "image", "audio"], + contextWindow: 262144, + }, + { id: "" }, + ], + }, + }, + }, + }); + + expect(index).toEqual({ + version: 1, + providers: { + moonshot: { + id: "moonshot", + name: "Moonshot AI", + plugin: { + id: "moonshot", + package: "@openclaw/plugin-moonshot", + }, + docs: "/providers/moonshot", + categories: ["cloud", "llm"], + previewCatalog: { + api: "openai-responses", + baseUrl: "https://api.moonshot.ai/v1", + models: [ + { + id: "kimi-k2.6", + name: "Kimi K2.6", + input: ["text", "image"], + contextWindow: 262144, + status: "preview", + }, + ], + }, + }, + }, + }); + }); + + it("drops unsafe providers and malformed preview catalog rows", () => { + const index = normalizeOpenClawProviderIndex({ + version: 1, + providers: { + __proto__: { + id: "__proto__", + name: "Bad", + plugin: { id: "bad" }, + }, + mismatch: { + id: "other", + name: "Mismatch", + plugin: { id: "mismatch" }, + }, + valid: { + id: "valid", + name: "Valid", + plugin: { id: "valid" }, + previewCatalog: { + models: [{ name: "missing id" }], + }, + }, + }, + }); + + expect(index).toEqual({ + version: 1, + providers: { + valid: { + id: "valid", + name: "Valid", + plugin: { id: "valid" }, + }, + }, + }); + }); + + it("loads the bundled provider index without runtime plugin loading", () => { + const index = loadOpenClawProviderIndex(); + + expect(index.providers.moonshot?.previewCatalog?.models).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "kimi-k2.6", + status: "preview", + }), + ]), + ); + expect(index.providers.deepseek?.plugin.id).toBe("deepseek"); + }); +}); diff --git a/src/model-catalog/provider-index/normalize.ts b/src/model-catalog/provider-index/normalize.ts new file mode 100644 index 00000000000..6140577d781 --- /dev/null +++ b/src/model-catalog/provider-index/normalize.ts @@ -0,0 +1,123 @@ +import { isBlockedObjectKey } from "../../infra/prototype-keys.js"; +import { normalizeOptionalString } from "../../shared/string-coerce.js"; +import { normalizeTrimmedStringList } from "../../shared/string-normalization.js"; +import { isRecord } from "../../utils.js"; +import { normalizeModelCatalog } from "../normalize.js"; +import { normalizeModelCatalogProviderId } from "../refs.js"; +import type { ModelCatalogProvider } from "../types.js"; +import type { + OpenClawProviderIndex, + OpenClawProviderIndexPlugin, + OpenClawProviderIndexProvider, +} from "./types.js"; + +const OPENCLAW_PROVIDER_INDEX_VERSION = 1; + +function normalizeSafeKey(value: unknown): string { + const key = normalizeOptionalString(value) ?? ""; + return key && !isBlockedObjectKey(key) ? key : ""; +} + +function normalizePlugin(value: unknown): OpenClawProviderIndexPlugin | undefined { + if (!isRecord(value)) { + return undefined; + } + const id = normalizeSafeKey(value.id); + if (!id) { + return undefined; + } + const packageName = normalizeOptionalString(value.package) ?? ""; + const source = normalizeOptionalString(value.source) ?? ""; + return { + id, + ...(packageName ? { package: packageName } : {}), + ...(source ? { source } : {}), + }; +} + +function normalizeCategories(value: unknown): readonly string[] { + return [...new Set(normalizeTrimmedStringList(value))]; +} + +function normalizePreviewCatalog(params: { + providerId: string; + value: unknown; +}): ModelCatalogProvider | undefined { + const catalog = normalizeModelCatalog( + { providers: { [params.providerId]: params.value } }, + { ownedProviders: new Set([params.providerId]) }, + ); + const provider = catalog?.providers?.[params.providerId]; + if (!provider) { + return undefined; + } + return { + ...provider, + models: provider.models.map((model) => ({ + ...model, + status: model.status ?? "preview", + })), + }; +} + +function normalizeProvider( + rawProviderId: string, + value: unknown, +): OpenClawProviderIndexProvider | undefined { + if (!isRecord(value)) { + return undefined; + } + const providerId = normalizeModelCatalogProviderId(rawProviderId); + if (!providerId) { + return undefined; + } + const id = normalizeModelCatalogProviderId(normalizeOptionalString(value.id) ?? ""); + if (id && id !== providerId) { + return undefined; + } + const name = normalizeOptionalString(value.name) ?? ""; + const plugin = normalizePlugin(value.plugin); + if (!name || !plugin) { + return undefined; + } + const docs = normalizeOptionalString(value.docs) ?? ""; + const categories = normalizeCategories(value.categories); + const previewCatalog = normalizePreviewCatalog({ + providerId, + value: value.previewCatalog, + }); + return { + id: providerId, + name, + plugin, + ...(docs ? { docs } : {}), + ...(categories.length > 0 ? { categories } : {}), + ...(previewCatalog ? { previewCatalog } : {}), + }; +} + +export function normalizeOpenClawProviderIndex(value: unknown): OpenClawProviderIndex | undefined { + if (!isRecord(value) || value.version !== OPENCLAW_PROVIDER_INDEX_VERSION) { + return undefined; + } + if (!isRecord(value.providers)) { + return undefined; + } + const providers: Record = {}; + for (const [rawProviderId, rawProvider] of Object.entries(value.providers)) { + const providerId = normalizeModelCatalogProviderId(rawProviderId); + if (!providerId || isBlockedObjectKey(providerId)) { + continue; + } + const provider = normalizeProvider(providerId, rawProvider); + if (provider) { + providers[providerId] = provider; + } + } + return { + version: OPENCLAW_PROVIDER_INDEX_VERSION, + providers: Object.fromEntries( + Object.entries(providers).toSorted(([left], [right]) => left.localeCompare(right)), + ), + }; +} diff --git a/src/model-catalog/provider-index/openclaw-provider-index.ts b/src/model-catalog/provider-index/openclaw-provider-index.ts new file mode 100644 index 00000000000..47c5357a063 --- /dev/null +++ b/src/model-catalog/provider-index/openclaw-provider-index.ts @@ -0,0 +1,59 @@ +import type { OpenClawProviderIndex } from "./types.js"; + +// OpenClaw-owned preview metadata for providers whose plugins may not be +// installed yet. Installed plugin manifests remain authoritative; this index is +// a fallback for installable-provider and pre-install model picker surfaces. +export const OPENCLAW_PROVIDER_INDEX = { + version: 1, + providers: { + moonshot: { + id: "moonshot", + name: "Moonshot AI", + plugin: { + id: "moonshot", + }, + docs: "/providers/moonshot", + categories: ["cloud", "llm"], + previewCatalog: { + api: "openai-responses", + baseUrl: "https://api.moonshot.ai/v1", + models: [ + { + id: "kimi-k2.6", + name: "Kimi K2.6", + input: ["text", "image"], + contextWindow: 262144, + }, + ], + }, + }, + deepseek: { + id: "deepseek", + name: "DeepSeek", + plugin: { + id: "deepseek", + }, + docs: "/providers/deepseek", + categories: ["cloud", "llm"], + previewCatalog: { + api: "openai-responses", + baseUrl: "https://api.deepseek.com/v1", + models: [ + { + id: "deepseek-chat", + name: "DeepSeek Chat", + input: ["text"], + contextWindow: 64000, + }, + { + id: "deepseek-reasoner", + name: "DeepSeek Reasoner", + input: ["text"], + reasoning: true, + contextWindow: 64000, + }, + ], + }, + }, + }, +} satisfies OpenClawProviderIndex; diff --git a/src/model-catalog/provider-index/types.ts b/src/model-catalog/provider-index/types.ts new file mode 100644 index 00000000000..873888c4aec --- /dev/null +++ b/src/model-catalog/provider-index/types.ts @@ -0,0 +1,21 @@ +import type { ModelCatalogProvider } from "../types.js"; + +export type OpenClawProviderIndexPlugin = { + id: string; + package?: string; + source?: string; +}; + +export type OpenClawProviderIndexProvider = { + id: string; + name: string; + plugin: OpenClawProviderIndexPlugin; + docs?: string; + categories?: readonly string[]; + previewCatalog?: ModelCatalogProvider; +}; + +export type OpenClawProviderIndex = { + version: number; + providers: Readonly>; +};