feat: add model catalog manifest contract

This commit is contained in:
Shakker
2026-04-25 02:16:11 +01:00
committed by Shakker
parent fd65caf4b0
commit 61fcbe7dce

View File

@@ -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<string, string>;
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<string, string>;
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<string, PluginManifestModelCatalogProvider>;
aliases?: Record<string, PluginManifestModelCatalogAlias>;
suppressions?: PluginManifestModelCatalogSuppression[];
discovery?: Record<string, PluginManifestModelCatalogDiscovery>;
};
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<string, string> | 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<string, string> | undefined {
if (!isRecord(value)) {
return undefined;
}
const normalized: Record<string, string> = {};
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<string>(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<string, PluginManifestModelCatalogProvider> | undefined {
if (!isRecord(value)) {
return undefined;
}
const providers: Record<string, PluginManifestModelCatalogProvider> = {};
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<string, PluginManifestModelCatalogAlias> | undefined {
if (!isRecord(value)) {
return undefined;
}
const aliases: Record<string, PluginManifestModelCatalogAlias> = {};
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<string, PluginManifestModelCatalogDiscovery> | undefined {
if (!isRecord(value)) {
return undefined;
}
const discovery: Record<string, PluginManifestModelCatalogDiscovery> = {};
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,