Skip to content
This repository was archived by the owner on Aug 11, 2026. It is now read-only.
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions docs/model-access-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
]
Expand All @@ -69,8 +79,12 @@ v2 新增模型目录 resolve 契约,同时保持 v1 的所有导出和字段
}
```

`providerReported` 是上游事实提示,不是客户端目录元数据。未知模型必须照样进入
request,服务端不能因为知识库没有匹配而过滤它。
`providerReported` 是上游事实提示,不是客户端目录元数据。它可以携带 Provider 官方
端点返回的名称、描述、family、group、mode/type、上下文与输出窗口、排序、effort、Fast
Mode、多模态、能力、价格、发布日期和生命周期状态;服务端按字段与 Catalog / 知识库
合并。可选文本字段为空或仅含空白时按“未上报”处理;`sortOrder` 是有限数值排序键,允许
`0` 和负数,不表示从 1 开始的名次。未知模型必须照样进入 request,服务端不能因为知识库
没有匹配而过滤它。

### Resolve response

Expand Down
88 changes: 87 additions & 1 deletion packages/model-access-protocol/src/__tests__/protocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
parseListModelsResponse,
parseListModelsResponseV2,
parseModelRegistry,
parseProviderReportedModel,
parseResolveRequest,
parseResolveResponse,
type ListModelsResponse,
Expand Down Expand Up @@ -615,11 +616,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',
},
},
],
Expand Down Expand Up @@ -659,6 +672,79 @@ 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, defaultEffort: 'very-high' }, '.defaultEffort'],
[{ ...providerReported, efforts: ['low'], defaultEffort: '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('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: ' '.repeat(2_001),
family: '\t',
group: '',
type: ' '.repeat(129),
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',
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,
Expand Down
123 changes: 115 additions & 8 deletions packages/model-access-protocol/src/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -543,25 +554,121 @@ 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],
['type', 128],
['releaseDate', 64],
] as const) {
if (value[key] === undefined) continue;
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`);
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;
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`;
Comment thread
horizon554 marked this conversation as resolved.
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
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`;
}
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;
}

const PROVIDER_REPORTED_OPTIONAL_TEXT_FIELDS = [
'name',
'description',
'family',
'group',
'type',
'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);
Expand Down Expand Up @@ -706,7 +813,7 @@ export function parseResolveRequest(value: unknown): ModelAccessParseResult<Reso
return fail(`request.schemaVersion must be ${MODEL_ACCESS_RESOLVE_SCHEMA_VERSION}`);
}
const error = parseEntries(value.entries, 'request.entries', 'request');
return error ? fail(error) : ok(value as unknown as ResolveRequest);
return error ? fail(error) : ok(normalizeResolveRequest(value as unknown as ResolveRequest));
}

/** Strictly parse a v2 resolve response. Invalid responses must not replace a cached snapshot. */
Expand Down Expand Up @@ -818,7 +925,7 @@ export function parseProviderReportedModel(
value: unknown,
): ModelAccessParseResult<ProviderReportedModel> {
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(
Expand Down
15 changes: 13 additions & 2 deletions packages/model-access-protocol/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading