mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 07:40:44 +00:00
feat: add model catalog provider index contract
This commit is contained in:
@@ -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";
|
||||
|
||||
7
src/model-catalog/provider-index/index.ts
Normal file
7
src/model-catalog/provider-index/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export { loadOpenClawProviderIndex } from "./load.js";
|
||||
export { normalizeOpenClawProviderIndex } from "./normalize.js";
|
||||
export type {
|
||||
OpenClawProviderIndex,
|
||||
OpenClawProviderIndexPlugin,
|
||||
OpenClawProviderIndexProvider,
|
||||
} from "./types.js";
|
||||
9
src/model-catalog/provider-index/load.ts
Normal file
9
src/model-catalog/provider-index/load.ts
Normal 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: {} };
|
||||
}
|
||||
112
src/model-catalog/provider-index/normalize.test.ts
Normal file
112
src/model-catalog/provider-index/normalize.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
123
src/model-catalog/provider-index/normalize.ts
Normal file
123
src/model-catalog/provider-index/normalize.ts
Normal 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)),
|
||||
),
|
||||
};
|
||||
}
|
||||
59
src/model-catalog/provider-index/openclaw-provider-index.ts
Normal file
59
src/model-catalog/provider-index/openclaw-provider-index.ts
Normal 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;
|
||||
21
src/model-catalog/provider-index/types.ts
Normal file
21
src/model-catalog/provider-index/types.ts
Normal 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>>;
|
||||
};
|
||||
Reference in New Issue
Block a user