From 61fcbe7dce3de3a49d372ae410a44b650282d489 Mon Sep 17 00:00:00 2001 From: Shakker Date: Sat, 25 Apr 2026 02:16:11 +0100 Subject: [PATCH] feat: add model catalog manifest contract --- src/plugins/manifest.ts | 353 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 353 insertions(+) diff --git a/src/plugins/manifest.ts b/src/plugins/manifest.ts index 0a280c0844e..248acb99d18 100644 --- a/src/plugins/manifest.ts +++ b/src/plugins/manifest.ts @@ -3,6 +3,7 @@ import path from "node:path"; import JSON5 from "json5"; import type { ChannelConfigRuntimeSchema } from "../channels/plugins/types.config.js"; import { MANIFEST_KEY } from "../compat/legacy-names.js"; +import { MODEL_APIS, type ModelApi, type ModelCompatConfig } from "../config/types.models.js"; import { matchBoundaryFileOpenFailure, openBoundaryFileSync } from "../infra/boundary-file-read.js"; import { isBlockedObjectKey } from "../infra/prototype-keys.js"; import type { JsonSchemaObject } from "../shared/json-schema.types.js"; @@ -42,6 +43,72 @@ export type PluginManifestModelSupport = { modelPatterns?: string[]; }; +export type PluginManifestModelCatalogInput = "text" | "image" | "document"; +export type PluginManifestModelCatalogDiscovery = "static" | "refreshable" | "runtime"; +export type PluginManifestModelCatalogStatus = "available" | "preview" | "deprecated" | "disabled"; + +export type PluginManifestModelCatalogTieredCost = { + input: number; + output: number; + cacheRead?: number; + cacheWrite?: number; + range: [number, number] | [number]; +}; + +export type PluginManifestModelCatalogCost = { + input?: number; + output?: number; + cacheRead?: number; + cacheWrite?: number; + tieredPricing?: PluginManifestModelCatalogTieredCost[]; +}; + +export type PluginManifestModelCatalogModel = { + id: string; + name?: string; + api?: ModelApi; + baseUrl?: string; + headers?: Record; + input?: PluginManifestModelCatalogInput[]; + reasoning?: boolean; + contextWindow?: number; + contextTokens?: number; + maxTokens?: number; + cost?: PluginManifestModelCatalogCost; + compat?: ModelCompatConfig; + status?: PluginManifestModelCatalogStatus; + statusReason?: string; + replaces?: string[]; + replacedBy?: string; + tags?: string[]; +}; + +export type PluginManifestModelCatalogProvider = { + baseUrl?: string; + api?: ModelApi; + headers?: Record; + models: PluginManifestModelCatalogModel[]; +}; + +export type PluginManifestModelCatalogAlias = { + provider: string; + api?: ModelApi; + baseUrl?: string; +}; + +export type PluginManifestModelCatalogSuppression = { + provider: string; + model: string; + reason?: string; +}; + +export type PluginManifestModelCatalog = { + providers?: Record; + aliases?: Record; + suppressions?: PluginManifestModelCatalogSuppression[]; + discovery?: Record; +}; + export type PluginManifestProviderEndpoint = { /** * Core endpoint class this plugin-owned endpoint should map to. Core must @@ -176,6 +243,11 @@ export type PluginManifest = { * Use this for shorthand model refs that omit an explicit provider prefix. */ modelSupport?: PluginManifestModelSupport; + /** + * Declarative model catalog metadata used by future read-only listing, + * onboarding, and model picker surfaces before provider runtime loads. + */ + modelCatalog?: PluginManifestModelCatalog; /** Cheap provider endpoint metadata used before provider runtime loads. */ providerEndpoints?: PluginManifestProviderEndpoint[]; /** Cheap startup activation lookup for plugin-owned CLI inference backends. */ @@ -344,6 +416,30 @@ function normalizeStringRecord(value: unknown): Record | undefin return Object.keys(normalized).length > 0 ? normalized : undefined; } +function isSafeManifestRecordKey(key: string): boolean { + return key !== "__proto__" && key !== "constructor" && key !== "prototype"; +} + +function normalizeSafeRecordKey(value: unknown): string { + const key = normalizeOptionalString(value) ?? ""; + return key && isSafeManifestRecordKey(key) ? key : ""; +} + +function normalizeStringMap(value: unknown): Record | undefined { + if (!isRecord(value)) { + return undefined; + } + const normalized: Record = {}; + for (const [rawKey, rawValue] of Object.entries(value)) { + const key = normalizeSafeRecordKey(rawKey); + const value = normalizeOptionalString(rawValue) ?? ""; + if (key && value) { + normalized[key] = value; + } + } + return Object.keys(normalized).length > 0 ? normalized : undefined; +} + const MEDIA_UNDERSTANDING_CAPABILITIES = new Set(["image", "audio", "video"]); function normalizeMediaUnderstandingCapabilityRecord( @@ -577,6 +673,261 @@ function normalizeManifestModelSupport(value: unknown): PluginManifestModelSuppo return Object.keys(modelSupport).length > 0 ? modelSupport : undefined; } +const MODEL_CATALOG_INPUTS = new Set(["text", "image", "document"]); +const MODEL_CATALOG_DISCOVERY_MODES = new Set(["static", "refreshable", "runtime"]); +const MODEL_CATALOG_STATUSES = new Set(["available", "preview", "deprecated", "disabled"]); +const MODEL_CATALOG_APIS = new Set(MODEL_APIS); + +function normalizeModelCatalogApi(value: unknown): ModelApi | undefined { + const api = normalizeOptionalString(value) ?? ""; + return MODEL_CATALOG_APIS.has(api) ? (api as ModelApi) : undefined; +} + +function normalizeModelCatalogInputs( + value: unknown, +): PluginManifestModelCatalogInput[] | undefined { + const inputs = normalizeTrimmedStringList(value).filter( + (input): input is PluginManifestModelCatalogInput => MODEL_CATALOG_INPUTS.has(input), + ); + return inputs.length > 0 ? inputs : undefined; +} + +function normalizeModelCatalogNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined; +} + +function normalizeModelCatalogTieredCost( + value: unknown, +): PluginManifestModelCatalogTieredCost[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + const normalized: PluginManifestModelCatalogTieredCost[] = []; + for (const entry of value) { + if (!isRecord(entry)) { + continue; + } + const input = normalizeModelCatalogNumber(entry.input); + const output = normalizeModelCatalogNumber(entry.output); + if (input === undefined || output === undefined || !Array.isArray(entry.range)) { + continue; + } + const rangeValues = entry.range + .map((rangeValue) => normalizeModelCatalogNumber(rangeValue)) + .filter((rangeValue): rangeValue is number => rangeValue !== undefined); + const range = + rangeValues.length === 1 + ? ([rangeValues[0]] as [number]) + : rangeValues.length >= 2 + ? ([rangeValues[0], rangeValues[1]] as [number, number]) + : undefined; + if (!range) { + continue; + } + const cacheRead = normalizeModelCatalogNumber(entry.cacheRead); + const cacheWrite = normalizeModelCatalogNumber(entry.cacheWrite); + normalized.push({ + input, + output, + ...(cacheRead !== undefined ? { cacheRead } : {}), + ...(cacheWrite !== undefined ? { cacheWrite } : {}), + range, + }); + } + return normalized.length > 0 ? normalized : undefined; +} + +function normalizeModelCatalogCost(value: unknown): PluginManifestModelCatalogCost | undefined { + if (!isRecord(value)) { + return undefined; + } + const input = normalizeModelCatalogNumber(value.input); + const output = normalizeModelCatalogNumber(value.output); + const cacheRead = normalizeModelCatalogNumber(value.cacheRead); + const cacheWrite = normalizeModelCatalogNumber(value.cacheWrite); + const tieredPricing = normalizeModelCatalogTieredCost(value.tieredPricing); + const cost = { + ...(input !== undefined ? { input } : {}), + ...(output !== undefined ? { output } : {}), + ...(cacheRead !== undefined ? { cacheRead } : {}), + ...(cacheWrite !== undefined ? { cacheWrite } : {}), + ...(tieredPricing ? { tieredPricing } : {}), + } satisfies PluginManifestModelCatalogCost; + return Object.keys(cost).length > 0 ? cost : undefined; +} + +function normalizeModelCatalogStatus(value: unknown): PluginManifestModelCatalogStatus | undefined { + const status = normalizeOptionalString(value) ?? ""; + return MODEL_CATALOG_STATUSES.has(status) + ? (status as PluginManifestModelCatalogStatus) + : undefined; +} + +function normalizeModelCatalogModel(value: unknown): PluginManifestModelCatalogModel | undefined { + if (!isRecord(value)) { + return undefined; + } + const id = normalizeOptionalString(value.id) ?? ""; + if (!id) { + return undefined; + } + const name = normalizeOptionalString(value.name) ?? ""; + const api = normalizeModelCatalogApi(value.api); + const baseUrl = normalizeOptionalString(value.baseUrl) ?? ""; + const headers = normalizeStringMap(value.headers); + const input = normalizeModelCatalogInputs(value.input); + const reasoning = typeof value.reasoning === "boolean" ? value.reasoning : undefined; + const contextWindow = normalizeModelCatalogNumber(value.contextWindow); + const contextTokens = normalizeModelCatalogNumber(value.contextTokens); + const maxTokens = normalizeModelCatalogNumber(value.maxTokens); + const cost = normalizeModelCatalogCost(value.cost); + const compat = isRecord(value.compat) ? (value.compat as ModelCompatConfig) : undefined; + const status = normalizeModelCatalogStatus(value.status); + const statusReason = normalizeOptionalString(value.statusReason) ?? ""; + const replaces = normalizeTrimmedStringList(value.replaces); + const replacedBy = normalizeOptionalString(value.replacedBy) ?? ""; + const tags = normalizeTrimmedStringList(value.tags); + return { + id, + ...(name ? { name } : {}), + ...(api ? { api } : {}), + ...(baseUrl ? { baseUrl } : {}), + ...(headers ? { headers } : {}), + ...(input ? { input } : {}), + ...(reasoning !== undefined ? { reasoning } : {}), + ...(contextWindow !== undefined ? { contextWindow } : {}), + ...(contextTokens !== undefined ? { contextTokens } : {}), + ...(maxTokens !== undefined ? { maxTokens } : {}), + ...(cost ? { cost } : {}), + ...(compat ? { compat } : {}), + ...(status ? { status } : {}), + ...(statusReason ? { statusReason } : {}), + ...(replaces.length > 0 ? { replaces } : {}), + ...(replacedBy ? { replacedBy } : {}), + ...(tags.length > 0 ? { tags } : {}), + }; +} + +function normalizeModelCatalogProviders( + value: unknown, +): Record | undefined { + if (!isRecord(value)) { + return undefined; + } + const providers: Record = {}; + for (const [rawProviderId, rawProvider] of Object.entries(value)) { + const providerId = normalizeSafeRecordKey(rawProviderId); + if (!providerId || !isRecord(rawProvider)) { + continue; + } + const models = Array.isArray(rawProvider.models) + ? rawProvider.models + .map((entry) => normalizeModelCatalogModel(entry)) + .filter((entry): entry is PluginManifestModelCatalogModel => Boolean(entry)) + : []; + if (models.length === 0) { + continue; + } + const baseUrl = normalizeOptionalString(rawProvider.baseUrl) ?? ""; + const api = normalizeModelCatalogApi(rawProvider.api); + const headers = normalizeStringMap(rawProvider.headers); + providers[providerId] = { + ...(baseUrl ? { baseUrl } : {}), + ...(api ? { api } : {}), + ...(headers ? { headers } : {}), + models, + }; + } + return Object.keys(providers).length > 0 ? providers : undefined; +} + +function normalizeModelCatalogAliases( + value: unknown, +): Record | undefined { + if (!isRecord(value)) { + return undefined; + } + const aliases: Record = {}; + for (const [rawAlias, rawTarget] of Object.entries(value)) { + const alias = normalizeSafeRecordKey(rawAlias); + if (!alias || !isRecord(rawTarget)) { + continue; + } + const provider = normalizeOptionalString(rawTarget.provider) ?? ""; + if (!provider) { + continue; + } + const api = normalizeModelCatalogApi(rawTarget.api); + const baseUrl = normalizeOptionalString(rawTarget.baseUrl) ?? ""; + aliases[alias] = { + provider, + ...(api ? { api } : {}), + ...(baseUrl ? { baseUrl } : {}), + }; + } + return Object.keys(aliases).length > 0 ? aliases : undefined; +} + +function normalizeModelCatalogSuppressions( + value: unknown, +): PluginManifestModelCatalogSuppression[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + const suppressions: PluginManifestModelCatalogSuppression[] = []; + for (const entry of value) { + if (!isRecord(entry)) { + continue; + } + const provider = normalizeOptionalString(entry.provider) ?? ""; + const model = normalizeOptionalString(entry.model) ?? ""; + if (!provider || !model) { + continue; + } + const reason = normalizeOptionalString(entry.reason) ?? ""; + suppressions.push({ + provider, + model, + ...(reason ? { reason } : {}), + }); + } + return suppressions.length > 0 ? suppressions : undefined; +} + +function normalizeModelCatalogDiscovery( + value: unknown, +): Record | undefined { + if (!isRecord(value)) { + return undefined; + } + const discovery: Record = {}; + for (const [rawProviderId, rawMode] of Object.entries(value)) { + const providerId = normalizeSafeRecordKey(rawProviderId); + const mode = normalizeOptionalString(rawMode) ?? ""; + if (providerId && MODEL_CATALOG_DISCOVERY_MODES.has(mode)) { + discovery[providerId] = mode as PluginManifestModelCatalogDiscovery; + } + } + return Object.keys(discovery).length > 0 ? discovery : undefined; +} + +function normalizeManifestModelCatalog(value: unknown): PluginManifestModelCatalog | undefined { + if (!isRecord(value)) { + return undefined; + } + const providers = normalizeModelCatalogProviders(value.providers); + const aliases = normalizeModelCatalogAliases(value.aliases); + const suppressions = normalizeModelCatalogSuppressions(value.suppressions); + const discovery = normalizeModelCatalogDiscovery(value.discovery); + const modelCatalog = { + ...(providers ? { providers } : {}), + ...(aliases ? { aliases } : {}), + ...(suppressions ? { suppressions } : {}), + ...(discovery ? { discovery } : {}), + } satisfies PluginManifestModelCatalog; + return Object.keys(modelCatalog).length > 0 ? modelCatalog : undefined; +} + function normalizeManifestProviderEndpoints( value: unknown, ): PluginManifestProviderEndpoint[] | undefined { @@ -886,6 +1237,7 @@ export function loadPluginManifest( const providers = normalizeTrimmedStringList(raw.providers); const providerDiscoveryEntry = normalizeOptionalString(raw.providerDiscoveryEntry); const modelSupport = normalizeManifestModelSupport(raw.modelSupport); + const modelCatalog = normalizeManifestModelCatalog(raw.modelCatalog); const providerEndpoints = normalizeManifestProviderEndpoints(raw.providerEndpoints); const cliBackends = normalizeTrimmedStringList(raw.cliBackends); const syntheticAuthRefs = normalizeTrimmedStringList(raw.syntheticAuthRefs); @@ -926,6 +1278,7 @@ export function loadPluginManifest( providers, providerDiscoveryEntry, modelSupport, + modelCatalog, providerEndpoints, cliBackends, syntheticAuthRefs,