feat: add model catalog provider index contract

This commit is contained in:
Shakker
2026-04-26 04:27:23 +01:00
parent ac0fa474f8
commit 96ac51d23d
7 changed files with 340 additions and 0 deletions

View File

@@ -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";

View File

@@ -0,0 +1,7 @@
export { loadOpenClawProviderIndex } from "./load.js";
export { normalizeOpenClawProviderIndex } from "./normalize.js";
export type {
OpenClawProviderIndex,
OpenClawProviderIndexPlugin,
OpenClawProviderIndexProvider,
} from "./types.js";

View File

@@ -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: {} };
}

View File

@@ -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");
});
});

View File

@@ -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<string, OpenClawProviderIndexProvider> = {};
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)),
),
};
}

View File

@@ -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;

View File

@@ -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<Record<string, OpenClawProviderIndexProvider>>;
};