diff --git a/src/plugin-sdk/provider-catalog-shared.test.ts b/src/plugin-sdk/provider-catalog-shared.test.ts index c36053b21ea..79e9d13f180 100644 --- a/src/plugin-sdk/provider-catalog-shared.test.ts +++ b/src/plugin-sdk/provider-catalog-shared.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from "vitest"; +import type { ModelCatalogProvider } from "../model-catalog/types.js"; import { applyProviderNativeStreamingUsageCompat, + buildManifestModelProviderConfig, readConfiguredProviderCatalogEntries, supportsNativeStreamingUsageCompat, } from "./provider-catalog-shared.js"; @@ -95,3 +97,89 @@ describe("provider-catalog-shared configured catalog entries", () => { ]); }); }); + +describe("provider-catalog-shared manifest provider configs", () => { + it("converts manifest model catalog rows into provider config rows", () => { + const catalog: ModelCatalogProvider = { + baseUrl: "https://api.example.test/v1", + api: "openai-completions", + headers: { "x-provider": "example" }, + models: [ + { + id: "example-model", + name: "Example Model", + input: ["text", "image"], + reasoning: true, + contextWindow: 128_000, + contextTokens: 64_000, + maxTokens: 8192, + cost: { + input: 1, + output: 2, + cacheRead: 0.25, + cacheWrite: 0.5, + tieredPricing: [ + { + input: 0.5, + output: 1, + cacheRead: 0.1, + cacheWrite: 0.2, + range: [0, 1_000_000], + }, + ], + }, + compat: { supportsUsageInStreaming: true }, + }, + ], + }; + + expect(buildManifestModelProviderConfig({ providerId: "example", catalog })).toEqual({ + baseUrl: "https://api.example.test/v1", + api: "openai-completions", + headers: { "x-provider": "example" }, + models: [ + { + id: "example-model", + name: "Example Model", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1, + output: 2, + cacheRead: 0.25, + cacheWrite: 0.5, + tieredPricing: [ + { + input: 0.5, + output: 1, + cacheRead: 0.1, + cacheWrite: 0.2, + range: [0, 1_000_000], + }, + ], + }, + contextWindow: 128_000, + contextTokens: 64_000, + maxTokens: 8192, + compat: { supportsUsageInStreaming: true }, + }, + ], + }); + }); + + it("rejects incomplete manifest rows before building provider runtime config", () => { + expect(() => + buildManifestModelProviderConfig({ + providerId: "example", + catalog: { + models: [ + { + id: "missing-context", + maxTokens: 8192, + }, + ], + }, + }), + ).toThrow("missing contextWindow"); + }); +}); diff --git a/src/plugin-sdk/provider-catalog-shared.ts b/src/plugin-sdk/provider-catalog-shared.ts index 3c7e561701d..47f21ba082b 100644 --- a/src/plugin-sdk/provider-catalog-shared.ts +++ b/src/plugin-sdk/provider-catalog-shared.ts @@ -7,6 +7,12 @@ import { resolveProviderRequestCapabilities } from "../agents/provider-attributi import { findNormalizedProviderKey } from "../agents/provider-id.js"; import type { ModelDefinitionConfig } from "../config/types.models.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { + ModelCatalogCost, + ModelCatalogModel, + ModelCatalogProvider, + ModelCatalogTieredCost, +} from "../model-catalog/types.js"; import type { ModelProviderConfig } from "./provider-model-shared.js"; export type { ProviderCatalogContext, ProviderCatalogResult } from "../plugins/types.js"; @@ -26,6 +32,69 @@ export type ConfiguredProviderCatalogEntry = { input?: Array<"text" | "image" | "audio" | "video" | "document">; }; +function cloneManifestCatalogTieredCost( + tier: ModelCatalogTieredCost, +): NonNullable[number] { + return { + input: tier.input, + output: tier.output, + cacheRead: tier.cacheRead, + cacheWrite: tier.cacheWrite, + range: tier.range.length === 1 ? [tier.range[0]] : [tier.range[0], tier.range[1]], + }; +} + +function cloneManifestCatalogCost(cost: ModelCatalogCost): ModelDefinitionConfig["cost"] { + return { + input: cost.input ?? 0, + output: cost.output ?? 0, + cacheRead: cost.cacheRead ?? 0, + cacheWrite: cost.cacheWrite ?? 0, + ...(cost.tieredPricing + ? { tieredPricing: cost.tieredPricing.map(cloneManifestCatalogTieredCost) } + : {}), + }; +} + +function buildManifestCatalogModel(model: ModelCatalogModel): ModelDefinitionConfig { + if (model.contextWindow === undefined) { + throw new Error(`Manifest modelCatalog row ${model.id} is missing contextWindow`); + } + if (model.maxTokens === undefined) { + throw new Error(`Manifest modelCatalog row ${model.id} is missing maxTokens`); + } + const input = model.input?.filter((item) => item !== "document") ?? ["text"]; + return { + id: model.id, + name: model.name ?? model.id, + ...(model.api ? { api: model.api } : {}), + ...(model.baseUrl ? { baseUrl: model.baseUrl } : {}), + reasoning: model.reasoning ?? false, + input: input.length > 0 ? input : ["text"], + cost: cloneManifestCatalogCost(model.cost ?? {}), + contextWindow: model.contextWindow, + ...(model.contextTokens !== undefined ? { contextTokens: model.contextTokens } : {}), + maxTokens: model.maxTokens, + ...(model.headers ? { headers: { ...model.headers } } : {}), + ...(model.compat ? { compat: { ...model.compat } } : {}), + }; +} + +export function buildManifestModelProviderConfig(params: { + providerId: string; + catalog: ModelCatalogProvider | undefined; +}): ModelProviderConfig { + if (!params.catalog) { + throw new Error(`Missing modelCatalog.providers.${params.providerId}`); + } + return { + baseUrl: params.catalog.baseUrl ?? "", + ...(params.catalog.api ? { api: params.catalog.api } : {}), + ...(params.catalog.headers ? { headers: { ...params.catalog.headers } } : {}), + models: params.catalog.models.map(buildManifestCatalogModel), + }; +} + function normalizeConfiguredCatalogModelInput( input: unknown, ): ConfiguredProviderCatalogEntry["input"] | undefined {