From 9d7d1320f1737ff271244bdf436c1d7baf63a717 Mon Sep 17 00:00:00 2001 From: JiangJiahao Date: Thu, 6 Aug 2026 02:44:49 +0800 Subject: [PATCH 1/5] fix(model-access): align provider-reported metadata contract Signed-off-by: JiangJiahao --- docs/model-access-protocol.md | 18 ++++- .../src/__tests__/protocol.test.ts | 41 +++++++++- packages/model-access-protocol/src/parse.ts | 74 +++++++++++++++++-- packages/model-access-protocol/src/types.ts | 15 +++- 4 files changed, 136 insertions(+), 12 deletions(-) diff --git a/docs/model-access-protocol.md b/docs/model-access-protocol.md index aca5e84..7f71fbe 100644 --- a/docs/model-access-protocol.md +++ b/docs/model-access-protocol.md @@ -55,12 +55,22 @@ v2 新增模型目录 resolve 契约,同时保持 v1 的所有导出和字段 "id": "vendor-model-id", "name": "Vendor display name", "providerReported": { + "description": "Provider description", + "family": "vendor-family", + "group": "gpt", "contextWindow": 200000, "maxOutput": 8192, + "sortOrder": 10, + "efforts": ["low", "medium", "high"], + "defaultEffort": "medium", + "supportsFastMode": true, "modalities": { "input": ["text"], "output": ["text"] }, "capabilities": { "reasoning": true }, + "cost": { "input": 1, "output": 2 }, + "releaseDate": "2026-07-31", + "status": "active", "mode": "chat", - "type": "chat" + "type": "model" } } ] @@ -69,8 +79,10 @@ v2 新增模型目录 resolve 契约,同时保持 v1 的所有导出和字段 } ``` -`providerReported` 是上游事实提示,不是客户端目录元数据。未知模型必须照样进入 -request,服务端不能因为知识库没有匹配而过滤它。 +`providerReported` 是上游事实提示,不是客户端目录元数据。它可以携带 Provider 官方 +端点返回的名称、描述、family、group、mode/type、上下文与输出窗口、排序、effort、Fast +Mode、多模态、能力、价格、发布日期和生命周期状态;服务端按字段与 Catalog / 知识库 +合并。未知模型必须照样进入 request,服务端不能因为知识库没有匹配而过滤它。 ### Resolve response diff --git a/packages/model-access-protocol/src/__tests__/protocol.test.ts b/packages/model-access-protocol/src/__tests__/protocol.test.ts index 1418752..a9f6f95 100644 --- a/packages/model-access-protocol/src/__tests__/protocol.test.ts +++ b/packages/model-access-protocol/src/__tests__/protocol.test.ts @@ -615,11 +615,23 @@ describe('model access schema v2', () => { { id: 'unknown-vendor-model', providerReported: { + name: 'Provider model name', + description: 'Provider model description', + family: 'provider-family', + group: 'gpt', + mode: 'chat', + type: 'model', contextWindow: 200_000, maxOutput: 8_192, + sortOrder: 10, + efforts: ['low', 'medium', 'high'], + defaultEffort: 'medium', + supportsFastMode: true, modalities: { input: ['text'], output: ['text'] }, capabilities: { reasoning: true }, - mode: 'chat', + cost: { input: 1, output: 2, cacheRead: 0.5 }, + releaseDate: '2026-07-31', + status: 'active', }, }, ], @@ -659,6 +671,33 @@ describe('model access schema v2', () => { }); }); + it('rejects malformed extended provider-reported facts', () => { + const requestEntry = resolveRequest.entries[0]!; + const requestModel = requestEntry.models[0]!; + const providerReported = requestModel.providerReported!; + const cases = [ + [{ ...providerReported, status: 'retired' }, '.status'], + [{ ...providerReported, sortOrder: 0 }, '.sortOrder'], + [{ ...providerReported, defaultEffort: 'very-high' }, '.defaultEffort'], + [{ ...providerReported, cost: { input: -1 } }, '.cost.input'], + [{ ...providerReported, modalities: { input: [], output: ['text'] } }, '.modalities'], + ] as const; + + for (const [facts, field] of cases) { + const result = parseResolveRequest({ + ...resolveRequest, + entries: [ + { + ...requestEntry, + models: [{ ...requestModel, providerReported: facts }], + }, + ], + }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toContain(field); + } + }); + it('parses resolved responses and rejects malformed metadata without clearing snapshots', () => { expect(parseResolveResponse(JSON.parse(JSON.stringify(resolveResponse)))).toEqual({ ok: true, diff --git a/packages/model-access-protocol/src/parse.ts b/packages/model-access-protocol/src/parse.ts index 261e811..5e8ce74 100644 --- a/packages/model-access-protocol/src/parse.ts +++ b/packages/model-access-protocol/src/parse.ts @@ -96,12 +96,23 @@ const RESOLVE_REQUEST_ENTRY_FIELDS = ['providerId', 'agent', 'wireProtocol', 'mo const RESOLVE_RESPONSE_ENTRY_FIELDS = ['providerId', 'agent', 'models'] as const; const RESOLVE_REQUEST_MODEL_FIELDS = ['id', 'name', 'providerReported'] as const; const PROVIDER_REPORTED_MODEL_FIELDS = [ + 'name', + 'description', + 'family', + 'group', + 'mode', + 'type', 'contextWindow', 'maxOutput', + 'sortOrder', + 'efforts', + 'defaultEffort', + 'supportsFastMode', 'modalities', 'capabilities', - 'mode', - 'type', + 'cost', + 'releaseDate', + 'status', ] as const; const RESOLVED_MODEL_FIELDS = [ 'id', @@ -543,22 +554,73 @@ function providerReportedError(value: unknown, path: string): string | null { if (!isPlainObject(value)) return `${path} must be an object`; let error = unknownFieldError(value, PROVIDER_REPORTED_MODEL_FIELDS, path); if (error) return error; + for (const [key, max] of [ + ['name', 256], + ['description', 2_000], + ['family', 128], + ['group', 128], + ['releaseDate', 64], + ] as const) { + if (value[key] === undefined) continue; + error = requiredStringError(value[key], `${path}.${key}`, max); + if (error) return error; + } + error = optionalChatModeError(value.mode, `${path}.mode`); + if (error) return error; + error = optionalStringError(value.type, `${path}.type`, 128); + if (error) return error; error = optionalPositiveIntegerError(value.contextWindow, `${path}.contextWindow`); if (error) return error; error = optionalPositiveIntegerError(value.maxOutput, `${path}.maxOutput`); if (error) return error; + error = optionalFiniteNumberError(value.sortOrder, `${path}.sortOrder`); + if (error) return error; + if (typeof value.sortOrder === 'number' && value.sortOrder <= 0) { + return `${path}.sortOrder must be positive when present`; + } + error = effortListError(value.efforts, `${path}.efforts`); + if (error) return error; + if ( + value.defaultEffort !== undefined && + value.defaultEffort !== null && + !isModelEffort(value.defaultEffort) + ) { + return `${path}.defaultEffort must be a supported effort value or null when present`; + } + if (value.supportsFastMode !== undefined && typeof value.supportsFastMode !== 'boolean') { + return `${path}.supportsFastMode must be a boolean when present`; + } if (value.modalities !== undefined) { error = modalitiesError(value.modalities, `${path}.modalities`); if (error) return error; + const modalities = value.modalities as { input: unknown[]; output: unknown[] }; + if (modalities.input.length === 0 || modalities.output.length === 0) { + return `${path}.modalities input and output must be non-empty`; + } } if (value.capabilities !== undefined) { error = capabilitiesError(value.capabilities, `${path}.capabilities`); if (error) return error; } - error = optionalChatModeError(value.mode, `${path}.mode`); - if (error) return error; - error = optionalStringError(value.type, `${path}.type`, 128); - if (error) return error; + if (value.cost !== undefined) { + if (!isPlainObject(value.cost)) return `${path}.cost must be an object`; + error = unknownFieldError(value.cost, RESOLVED_MODEL_COST_FIELDS, `${path}.cost`); + if (error) return error; + for (const field of RESOLVED_MODEL_COST_FIELDS) { + error = optionalFiniteNumberError(value.cost[field], `${path}.cost.${field}`, { + nonNegative: true, + }); + if (error) return error; + } + } + if ( + value.status !== undefined && + value.status !== 'active' && + value.status !== 'alpha' && + value.status !== 'deprecated' + ) { + return `${path}.status must be active, alpha, or deprecated when present`; + } return null; } diff --git a/packages/model-access-protocol/src/types.ts b/packages/model-access-protocol/src/types.ts index e8fa70e..aebda40 100644 --- a/packages/model-access-protocol/src/types.ts +++ b/packages/model-access-protocol/src/types.ts @@ -317,12 +317,23 @@ export interface ResolveRequestModel { } export interface ProviderReportedModel { + name?: string; + description?: string; + family?: string; + group?: string; + mode?: ModelChatMode; + type?: string; contextWindow?: number; maxOutput?: number; + sortOrder?: number; + efforts?: ModelEffort[]; + defaultEffort?: ModelEffort | null; + supportsFastMode?: boolean; modalities?: ResolvedModelModalities; capabilities?: ResolvedModelCapabilities; - mode?: ModelChatMode; - type?: string; + cost?: ResolvedModelCost; + releaseDate?: string; + status?: 'active' | 'alpha' | 'deprecated'; } export interface ResolveRequestEntry { From c049844e6076891b318641ad555165b80256dba3 Mon Sep 17 00:00:00 2001 From: JiangJiahao Date: Thu, 6 Aug 2026 02:55:47 +0800 Subject: [PATCH 2/5] fix(model-access): validate reported default effort Signed-off-by: JiangJiahao --- .../model-access-protocol/src/__tests__/protocol.test.ts | 1 + packages/model-access-protocol/src/parse.ts | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/packages/model-access-protocol/src/__tests__/protocol.test.ts b/packages/model-access-protocol/src/__tests__/protocol.test.ts index a9f6f95..6812b21 100644 --- a/packages/model-access-protocol/src/__tests__/protocol.test.ts +++ b/packages/model-access-protocol/src/__tests__/protocol.test.ts @@ -679,6 +679,7 @@ describe('model access schema v2', () => { [{ ...providerReported, status: 'retired' }, '.status'], [{ ...providerReported, sortOrder: 0 }, '.sortOrder'], [{ ...providerReported, defaultEffort: 'very-high' }, '.defaultEffort'], + [{ ...providerReported, efforts: ['low'], defaultEffort: 'high' }, '.defaultEffort'], [{ ...providerReported, cost: { input: -1 } }, '.cost.input'], [{ ...providerReported, modalities: { input: [], output: ['text'] } }, '.modalities'], ] as const; diff --git a/packages/model-access-protocol/src/parse.ts b/packages/model-access-protocol/src/parse.ts index 5e8ce74..6098ce8 100644 --- a/packages/model-access-protocol/src/parse.ts +++ b/packages/model-access-protocol/src/parse.ts @@ -587,6 +587,14 @@ function providerReportedError(value: unknown, path: string): string | null { ) { return `${path}.defaultEffort must be a supported effort value or null when present`; } + if ( + value.defaultEffort !== undefined && + value.defaultEffort !== null && + Array.isArray(value.efforts) && + !value.efforts.includes(value.defaultEffort) + ) { + return `${path}.defaultEffort must be included in ${path}.efforts when both are present`; + } if (value.supportsFastMode !== undefined && typeof value.supportsFastMode !== 'boolean') { return `${path}.supportsFastMode must be a boolean when present`; } From d76d87a8e5221fce36061bfa083e5385b210e430 Mon Sep 17 00:00:00 2001 From: JiangJiahao Date: Thu, 6 Aug 2026 03:09:35 +0800 Subject: [PATCH 3/5] fix(model-access): normalize optional provider facts Signed-off-by: JiangJiahao --- docs/model-access-protocol.md | 4 +- .../src/__tests__/protocol.test.ts | 48 ++++++++++++++++++- packages/model-access-protocol/src/parse.ts | 45 ++++++++++++++--- 3 files changed, 89 insertions(+), 8 deletions(-) diff --git a/docs/model-access-protocol.md b/docs/model-access-protocol.md index 7f71fbe..f38642f 100644 --- a/docs/model-access-protocol.md +++ b/docs/model-access-protocol.md @@ -82,7 +82,9 @@ v2 新增模型目录 resolve 契约,同时保持 v1 的所有导出和字段 `providerReported` 是上游事实提示,不是客户端目录元数据。它可以携带 Provider 官方 端点返回的名称、描述、family、group、mode/type、上下文与输出窗口、排序、effort、Fast Mode、多模态、能力、价格、发布日期和生命周期状态;服务端按字段与 Catalog / 知识库 -合并。未知模型必须照样进入 request,服务端不能因为知识库没有匹配而过滤它。 +合并。可选文本字段为空或仅含空白时按“未上报”处理;`sortOrder` 是有限数值排序键,允许 +`0` 和负数,不表示从 1 开始的名次。未知模型必须照样进入 request,服务端不能因为知识库 +没有匹配而过滤它。 ### Resolve response diff --git a/packages/model-access-protocol/src/__tests__/protocol.test.ts b/packages/model-access-protocol/src/__tests__/protocol.test.ts index 6812b21..fd0060e 100644 --- a/packages/model-access-protocol/src/__tests__/protocol.test.ts +++ b/packages/model-access-protocol/src/__tests__/protocol.test.ts @@ -12,6 +12,7 @@ import { parseListModelsResponse, parseListModelsResponseV2, parseModelRegistry, + parseProviderReportedModel, parseResolveRequest, parseResolveResponse, type ListModelsResponse, @@ -677,7 +678,6 @@ describe('model access schema v2', () => { const providerReported = requestModel.providerReported!; const cases = [ [{ ...providerReported, status: 'retired' }, '.status'], - [{ ...providerReported, sortOrder: 0 }, '.sortOrder'], [{ ...providerReported, defaultEffort: 'very-high' }, '.defaultEffort'], [{ ...providerReported, efforts: ['low'], defaultEffort: 'high' }, '.defaultEffort'], [{ ...providerReported, cost: { input: -1 } }, '.cost.input'], @@ -699,6 +699,52 @@ describe('model access schema v2', () => { } }); + it('accepts zero-based provider ordering and ignores blank optional text facts', () => { + const requestEntry = resolveRequest.entries[0]!; + const requestModel = requestEntry.models[0]!; + const providerReported = { + ...requestModel.providerReported!, + name: ' ', + description: '', + family: '\t', + group: '', + releaseDate: ' ', + sortOrder: 0, + }; + const result = parseResolveRequest({ + ...resolveRequest, + entries: [ + { + ...requestEntry, + models: [{ ...requestModel, providerReported }], + }, + ], + }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.entries[0]!.models[0]!.providerReported).toEqual({ + mode: 'chat', + type: 'model', + contextWindow: 200_000, + maxOutput: 8_192, + sortOrder: 0, + efforts: ['low', 'medium', 'high'], + defaultEffort: 'medium', + supportsFastMode: true, + modalities: { input: ['text'], output: ['text'] }, + capabilities: { reasoning: true }, + cost: { input: 1, output: 2, cacheRead: 0.5 }, + status: 'active', + }); + } + expect(parseProviderReportedModel(providerReported)).toEqual( + result.ok + ? { ok: true, value: result.value.entries[0]!.models[0]!.providerReported } + : result, + ); + }); + it('parses resolved responses and rejects malformed metadata without clearing snapshots', () => { expect(parseResolveResponse(JSON.parse(JSON.stringify(resolveResponse)))).toEqual({ ok: true, diff --git a/packages/model-access-protocol/src/parse.ts b/packages/model-access-protocol/src/parse.ts index 6098ce8..8b8782d 100644 --- a/packages/model-access-protocol/src/parse.ts +++ b/packages/model-access-protocol/src/parse.ts @@ -562,7 +562,7 @@ function providerReportedError(value: unknown, path: string): string | null { ['releaseDate', 64], ] as const) { if (value[key] === undefined) continue; - error = requiredStringError(value[key], `${path}.${key}`, max); + error = optionalStringError(value[key], `${path}.${key}`, max); if (error) return error; } error = optionalChatModeError(value.mode, `${path}.mode`); @@ -575,9 +575,6 @@ function providerReportedError(value: unknown, path: string): string | null { if (error) return error; error = optionalFiniteNumberError(value.sortOrder, `${path}.sortOrder`); if (error) return error; - if (typeof value.sortOrder === 'number' && value.sortOrder <= 0) { - return `${path}.sortOrder must be positive when present`; - } error = effortListError(value.efforts, `${path}.efforts`); if (error) return error; if ( @@ -632,6 +629,42 @@ function providerReportedError(value: unknown, path: string): string | null { return null; } +const PROVIDER_REPORTED_OPTIONAL_TEXT_FIELDS = [ + 'name', + 'description', + 'family', + 'group', + 'releaseDate', +] as const satisfies readonly (keyof ProviderReportedModel)[]; + +function normalizeProviderReportedModel(value: ProviderReportedModel): ProviderReportedModel { + let normalized: ProviderReportedModel | undefined; + for (const key of PROVIDER_REPORTED_OPTIONAL_TEXT_FIELDS) { + const text = value[key]; + if (typeof text !== 'string' || text.trim().length > 0) continue; + normalized ??= { ...value }; + delete normalized[key]; + } + return normalized ?? value; +} + +function normalizeResolveRequest(value: ResolveRequest): ResolveRequest { + let changed = false; + const entries = value.entries.map((entry) => { + const models = entry.models.map((model) => { + if (!model.providerReported) return model; + const providerReported = normalizeProviderReportedModel(model.providerReported); + if (providerReported === model.providerReported) return model; + changed = true; + return { ...model, providerReported }; + }); + return models.some((model, index) => model !== entry.models[index]) + ? { ...entry, models } + : entry; + }); + return changed ? { ...value, entries } : value; +} + function resolvedModelError(value: unknown, path: string): string | null { if (!isPlainObject(value)) return `${path} must be an object`; let error = unknownFieldError(value, RESOLVED_MODEL_FIELDS, path); @@ -776,7 +809,7 @@ export function parseResolveRequest(value: unknown): ModelAccessParseResult { const error = providerReportedError(value, 'providerReported'); - return error ? fail(error) : ok(value as ProviderReportedModel); + return error ? fail(error) : ok(normalizeProviderReportedModel(value as ProviderReportedModel)); } export function parseListModelsResponse( From bd617bded1fd8d882f8c95fa2190356984ec9337 Mon Sep 17 00:00:00 2001 From: JiangJiahao Date: Thu, 6 Aug 2026 03:29:25 +0800 Subject: [PATCH 4/5] fix(model-access): ignore padded blank provider facts Signed-off-by: JiangJiahao --- .../model-access-protocol/src/__tests__/protocol.test.ts | 2 +- packages/model-access-protocol/src/parse.ts | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/model-access-protocol/src/__tests__/protocol.test.ts b/packages/model-access-protocol/src/__tests__/protocol.test.ts index fd0060e..c0d5299 100644 --- a/packages/model-access-protocol/src/__tests__/protocol.test.ts +++ b/packages/model-access-protocol/src/__tests__/protocol.test.ts @@ -705,7 +705,7 @@ describe('model access schema v2', () => { const providerReported = { ...requestModel.providerReported!, name: ' ', - description: '', + description: ' '.repeat(2_001), family: '\t', group: '', releaseDate: ' ', diff --git a/packages/model-access-protocol/src/parse.ts b/packages/model-access-protocol/src/parse.ts index 8b8782d..5905a22 100644 --- a/packages/model-access-protocol/src/parse.ts +++ b/packages/model-access-protocol/src/parse.ts @@ -562,7 +562,11 @@ function providerReportedError(value: unknown, path: string): string | null { ['releaseDate', 64], ] as const) { if (value[key] === undefined) continue; - error = optionalStringError(value[key], `${path}.${key}`, max); + const text = value[key]; + // Blank provider hints are normalized away after validation. Ignore their raw length here so + // a padded placeholder cannot reject an otherwise useful model batch. + if (typeof text === 'string' && text.trim().length === 0) continue; + error = optionalStringError(text, `${path}.${key}`, max); if (error) return error; } error = optionalChatModeError(value.mode, `${path}.mode`); From ae6ec6a8d5b4f28a8336a6cae220a0cddddaccdd Mon Sep 17 00:00:00 2001 From: JiangJiahao Date: Thu, 6 Aug 2026 03:42:26 +0800 Subject: [PATCH 5/5] fix(model-access): normalize blank provider model types Signed-off-by: JiangJiahao --- packages/model-access-protocol/src/__tests__/protocol.test.ts | 2 +- packages/model-access-protocol/src/parse.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/model-access-protocol/src/__tests__/protocol.test.ts b/packages/model-access-protocol/src/__tests__/protocol.test.ts index c0d5299..b457c76 100644 --- a/packages/model-access-protocol/src/__tests__/protocol.test.ts +++ b/packages/model-access-protocol/src/__tests__/protocol.test.ts @@ -708,6 +708,7 @@ describe('model access schema v2', () => { description: ' '.repeat(2_001), family: '\t', group: '', + type: ' '.repeat(129), releaseDate: ' ', sortOrder: 0, }; @@ -725,7 +726,6 @@ describe('model access schema v2', () => { if (result.ok) { expect(result.value.entries[0]!.models[0]!.providerReported).toEqual({ mode: 'chat', - type: 'model', contextWindow: 200_000, maxOutput: 8_192, sortOrder: 0, diff --git a/packages/model-access-protocol/src/parse.ts b/packages/model-access-protocol/src/parse.ts index 5905a22..7e150ad 100644 --- a/packages/model-access-protocol/src/parse.ts +++ b/packages/model-access-protocol/src/parse.ts @@ -559,6 +559,7 @@ function providerReportedError(value: unknown, path: string): string | null { ['description', 2_000], ['family', 128], ['group', 128], + ['type', 128], ['releaseDate', 64], ] as const) { if (value[key] === undefined) continue; @@ -571,8 +572,6 @@ function providerReportedError(value: unknown, path: string): string | null { } error = optionalChatModeError(value.mode, `${path}.mode`); if (error) return error; - error = optionalStringError(value.type, `${path}.type`, 128); - if (error) return error; error = optionalPositiveIntegerError(value.contextWindow, `${path}.contextWindow`); if (error) return error; error = optionalPositiveIntegerError(value.maxOutput, `${path}.maxOutput`); @@ -638,6 +637,7 @@ const PROVIDER_REPORTED_OPTIONAL_TEXT_FIELDS = [ 'description', 'family', 'group', + 'type', 'releaseDate', ] as const satisfies readonly (keyof ProviderReportedModel)[];