From 8fa1052838e0a881ed4b86920a2de3ab1120579b Mon Sep 17 00:00:00 2001 From: Shakker Date: Sat, 25 Apr 2026 02:31:41 +0100 Subject: [PATCH] fix: tighten model catalog manifest validation --- docs/plugins/manifest.md | 20 ++--- src/plugins/manifest-registry.test.ts | 11 +++ src/plugins/manifest.ts | 104 +++++++++++++++++++++++--- 3 files changed, 113 insertions(+), 22 deletions(-) diff --git a/docs/plugins/manifest.md b/docs/plugins/manifest.md index 781a7a3e89d..b0d95de847d 100644 --- a/docs/plugins/manifest.md +++ b/docs/plugins/manifest.md @@ -594,24 +594,24 @@ is required. ```json { - "providers": ["moonshot"], + "providers": ["openai"], "modelCatalog": { "providers": { - "moonshot": { - "baseUrl": "https://api.moonshot.ai/v1", + "openai": { + "baseUrl": "https://api.openai.com/v1", "api": "openai-responses", "models": [ { - "id": "kimi-k2.6", - "name": "Kimi K2.6", + "id": "gpt-5.4", + "name": "GPT-5.4", "input": ["text", "image"], "reasoning": true, "contextWindow": 256000, "maxTokens": 128000, "cost": { - "input": 0.6, - "output": 2.5, - "cacheRead": 0.15 + "input": 1.25, + "output": 10, + "cacheRead": 0.125 }, "status": "available", "tags": ["default"] @@ -627,13 +627,13 @@ is required. }, "suppressions": [ { - "provider": "openai", + "provider": "azure-openai-responses", "model": "gpt-5.3-codex-spark", "reason": "not available on Azure OpenAI Responses" } ], "discovery": { - "moonshot": "static" + "openai": "static" } } } diff --git a/src/plugins/manifest-registry.test.ts b/src/plugins/manifest-registry.test.ts index 53856775f9a..b9ec4a73a91 100644 --- a/src/plugins/manifest-registry.test.ts +++ b/src/plugins/manifest-registry.test.ts @@ -498,6 +498,7 @@ describe("loadPluginManifestRegistry", () => { input: ["text", "image", "bogus"], reasoning: true, contextWindow: 256000, + contextTokens: 200000, maxTokens: 128000, cost: { input: 0.6, @@ -513,18 +514,26 @@ describe("loadPluginManifestRegistry", () => { }, compat: { supportsTools: true, + supportsStore: "yes", + unknownFlag: true, }, status: "available", tags: ["default"], }, ], }, + openai: { + models: [{ id: "gpt-5.4" }], + }, }, aliases: { kimi: { provider: "moonshot", api: "openai-responses", }, + openai: { + provider: "openai", + }, }, suppressions: [ { @@ -535,6 +544,7 @@ describe("loadPluginManifestRegistry", () => { ], discovery: { moonshot: "static", + openai: "static", ignored: "unknown", }, }, @@ -562,6 +572,7 @@ describe("loadPluginManifestRegistry", () => { input: ["text", "image"], reasoning: true, contextWindow: 256000, + contextTokens: 200000, maxTokens: 128000, cost: { input: 0.6, diff --git a/src/plugins/manifest.ts b/src/plugins/manifest.ts index 248acb99d18..3fb843b24d6 100644 --- a/src/plugins/manifest.ts +++ b/src/plugins/manifest.ts @@ -696,6 +696,14 @@ function normalizeModelCatalogNumber(value: unknown): number | undefined { return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined; } +function normalizeModelCatalogPositiveNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined; +} + +function normalizeModelCatalogPositiveInteger(value: unknown): number | undefined { + return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined; +} + function normalizeModelCatalogTieredCost( value: unknown, ): PluginManifestModelCatalogTieredCost[] | undefined { @@ -756,6 +764,72 @@ function normalizeModelCatalogCost(value: unknown): PluginManifestModelCatalogCo return Object.keys(cost).length > 0 ? cost : undefined; } +function normalizeModelCatalogCompat(value: unknown): ModelCompatConfig | undefined { + if (!isRecord(value)) { + return undefined; + } + const compat: Record = {}; + const booleanFields = [ + "supportsStore", + "supportsPromptCacheKey", + "supportsDeveloperRole", + "supportsReasoningEffort", + "supportsUsageInStreaming", + "supportsTools", + "supportsStrictMode", + "requiresStringContent", + "requiresToolResultName", + "requiresAssistantAfterToolResult", + "requiresThinkingAsText", + "nativeWebSearchTool", + "requiresMistralToolIds", + "requiresOpenAiAnthropicToolPayload", + ] as const; + for (const field of booleanFields) { + if (typeof value[field] === "boolean") { + compat[field] = value[field]; + } + } + + const stringFields = ["toolSchemaProfile", "toolCallArgumentsEncoding"] as const; + for (const field of stringFields) { + const normalized = normalizeOptionalString(value[field]) ?? ""; + if (normalized) { + compat[field] = normalized; + } + } + + const stringListFields = [ + "visibleReasoningDetailTypes", + "unsupportedToolSchemaKeywords", + ] as const; + for (const field of stringListFields) { + const normalized = normalizeTrimmedStringList(value[field]); + if (normalized.length > 0) { + compat[field] = normalized; + } + } + + const maxTokensField = normalizeOptionalString(value.maxTokensField) ?? ""; + if (maxTokensField === "max_completion_tokens" || maxTokensField === "max_tokens") { + compat.maxTokensField = maxTokensField; + } + + const thinkingFormat = normalizeOptionalString(value.thinkingFormat) ?? ""; + if ( + thinkingFormat === "openai" || + thinkingFormat === "openrouter" || + thinkingFormat === "deepseek" || + thinkingFormat === "zai" || + thinkingFormat === "qwen" || + thinkingFormat === "qwen-chat-template" + ) { + compat.thinkingFormat = thinkingFormat; + } + + return Object.keys(compat).length > 0 ? (compat as ModelCompatConfig) : undefined; +} + function normalizeModelCatalogStatus(value: unknown): PluginManifestModelCatalogStatus | undefined { const status = normalizeOptionalString(value) ?? ""; return MODEL_CATALOG_STATUSES.has(status) @@ -777,11 +851,11 @@ function normalizeModelCatalogModel(value: unknown): PluginManifestModelCatalogM 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 contextWindow = normalizeModelCatalogPositiveNumber(value.contextWindow); + const contextTokens = normalizeModelCatalogPositiveInteger(value.contextTokens); + const maxTokens = normalizeModelCatalogPositiveNumber(value.maxTokens); const cost = normalizeModelCatalogCost(value.cost); - const compat = isRecord(value.compat) ? (value.compat as ModelCompatConfig) : undefined; + const compat = normalizeModelCatalogCompat(value.compat); const status = normalizeModelCatalogStatus(value.status); const statusReason = normalizeOptionalString(value.statusReason) ?? ""; const replaces = normalizeTrimmedStringList(value.replaces); @@ -810,6 +884,7 @@ function normalizeModelCatalogModel(value: unknown): PluginManifestModelCatalogM function normalizeModelCatalogProviders( value: unknown, + ownedProviders: ReadonlySet, ): Record | undefined { if (!isRecord(value)) { return undefined; @@ -817,7 +892,7 @@ function normalizeModelCatalogProviders( const providers: Record = {}; for (const [rawProviderId, rawProvider] of Object.entries(value)) { const providerId = normalizeSafeRecordKey(rawProviderId); - if (!providerId || !isRecord(rawProvider)) { + if (!providerId || !ownedProviders.has(providerId) || !isRecord(rawProvider)) { continue; } const models = Array.isArray(rawProvider.models) @@ -843,6 +918,7 @@ function normalizeModelCatalogProviders( function normalizeModelCatalogAliases( value: unknown, + ownedProviders: ReadonlySet, ): Record | undefined { if (!isRecord(value)) { return undefined; @@ -854,7 +930,7 @@ function normalizeModelCatalogAliases( continue; } const provider = normalizeOptionalString(rawTarget.provider) ?? ""; - if (!provider) { + if (!provider || !ownedProviders.has(provider)) { continue; } const api = normalizeModelCatalogApi(rawTarget.api); @@ -896,6 +972,7 @@ function normalizeModelCatalogSuppressions( function normalizeModelCatalogDiscovery( value: unknown, + ownedProviders: ReadonlySet, ): Record | undefined { if (!isRecord(value)) { return undefined; @@ -904,21 +981,24 @@ function normalizeModelCatalogDiscovery( for (const [rawProviderId, rawMode] of Object.entries(value)) { const providerId = normalizeSafeRecordKey(rawProviderId); const mode = normalizeOptionalString(rawMode) ?? ""; - if (providerId && MODEL_CATALOG_DISCOVERY_MODES.has(mode)) { + if (providerId && ownedProviders.has(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 { +function normalizeManifestModelCatalog( + value: unknown, + ownedProviders: ReadonlySet, +): PluginManifestModelCatalog | undefined { if (!isRecord(value)) { return undefined; } - const providers = normalizeModelCatalogProviders(value.providers); - const aliases = normalizeModelCatalogAliases(value.aliases); + const providers = normalizeModelCatalogProviders(value.providers, ownedProviders); + const aliases = normalizeModelCatalogAliases(value.aliases, ownedProviders); const suppressions = normalizeModelCatalogSuppressions(value.suppressions); - const discovery = normalizeModelCatalogDiscovery(value.discovery); + const discovery = normalizeModelCatalogDiscovery(value.discovery, ownedProviders); const modelCatalog = { ...(providers ? { providers } : {}), ...(aliases ? { aliases } : {}), @@ -1237,7 +1317,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 modelCatalog = normalizeManifestModelCatalog(raw.modelCatalog, new Set(providers)); const providerEndpoints = normalizeManifestProviderEndpoints(raw.providerEndpoints); const cliBackends = normalizeTrimmedStringList(raw.cliBackends); const syntheticAuthRefs = normalizeTrimmedStringList(raw.syntheticAuthRefs);