fix: tighten model catalog manifest validation

This commit is contained in:
Shakker
2026-04-25 02:31:41 +01:00
committed by Shakker
parent d39e89e6b0
commit 8fa1052838
3 changed files with 113 additions and 22 deletions

View File

@@ -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"
}
}
}

View File

@@ -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,

View File

@@ -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<string, unknown> = {};
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<string>,
): Record<string, PluginManifestModelCatalogProvider> | undefined {
if (!isRecord(value)) {
return undefined;
@@ -817,7 +892,7 @@ function normalizeModelCatalogProviders(
const providers: Record<string, PluginManifestModelCatalogProvider> = {};
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<string>,
): Record<string, PluginManifestModelCatalogAlias> | 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<string>,
): Record<string, PluginManifestModelCatalogDiscovery> | 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<string>,
): 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);