diff --git a/src/providers/model-discovery.ts b/src/providers/model-discovery.ts index 78b68e55ef..acf267e29b 100644 --- a/src/providers/model-discovery.ts +++ b/src/providers/model-discovery.ts @@ -129,6 +129,14 @@ export function providerModelDiscoverySpecError(spec: ProviderModelDiscoverySpec if (queryEntries.some(([key, value]) => !key.trim() || key.length > 128 || typeof value !== "string" || value.length > 512)) { return "discovery query keys/values exceed their bounds"; } + for (const [field, value] of [ + ["envelopeKey", spec.envelopeKey], + ["idField", spec.idField], + ] as const) { + if (value !== undefined && ( + typeof value !== "string" || !value || value !== value.trim() || value.length > 128 + )) return `${field} must be a nonblank field name up to 128 characters`; + } for (const [field, value, hardLimit] of [ ["maxResponseBytes", spec.maxResponseBytes, MODEL_DISCOVERY_MAX_RESPONSE_BYTES], ["maxModels", spec.maxModels, MODEL_DISCOVERY_MAX_MODELS], @@ -422,7 +430,7 @@ export function extractModelEnvelopeRows( return { ok: true, rows }; } -/** Validate, bound, deduplicate, and declaratively filter OpenAI `{data:[...]}` or top-level arrays (Together `#617`). */ +/** Validate, bound, deduplicate, and filter the declared envelope or a top-level array (Together `#617`). */ /** * Metadata a sibling `models[]` array may contribute to an ALREADY-ADMITTED * `data[]` row (#1797). @@ -501,24 +509,26 @@ export function extractProviderModelItems( let data: unknown[]; let siblings: SiblingIndex | null = null; if (Array.isArray(value)) { - // Together-style top-level /models arrays. Catalog discovery must not treat a stray - // `models` key on openai-chat responses as valid — only `data` envelopes or top-level arrays. + // Together-style top-level /models arrays. The default contract must not treat a stray + // `models` key on openai-chat responses as valid; only a provider spec may opt into it. if (value.length > limit) return { ok: false, reason: "too_many_models" }; data = value; } else { - const envelope = extractModelEnvelopeRows(value, discovery.maxModels, ["data"]); + const envelopeKey = discovery.spec?.envelopeKey ?? "data"; + const envelope = extractModelEnvelopeRows(value, discovery.maxModels, [envelopeKey]); if (!envelope.ok) return envelope; data = envelope.rows; - siblings = buildSiblingIndex(value, limit); + siblings = envelopeKey === "data" ? buildSiblingIndex(value, limit) : null; } const items: ProviderModelsApiItem[] = []; const seen = new Set(); + const idField = discovery.spec?.idField ?? "id"; for (const raw of data) { if (raw === null || typeof raw !== "object" || Array.isArray(raw)) { return { ok: false, reason: "invalid_shape" }; } - const id = (raw as { id?: unknown }).id; + const id = (raw as Record)[idField]; if (!isValidModelDiscoveryModelId(id)) return { ok: false, reason: "invalid_shape" }; const prefix = discovery.spec?.stripIdPrefix; let finalId = id; @@ -526,7 +536,9 @@ export function extractProviderModelItems( finalId = finalId.slice(prefix.length); if (!isValidModelDiscoveryModelId(finalId)) continue; } - const item = finalId === id ? raw as ProviderModelsApiItem : { ...(raw as ProviderModelsApiItem), id: finalId }; + const item = finalId === id && idField === "id" + ? raw as ProviderModelsApiItem + : { ...(raw as Record), id: finalId }; // Admission is decided on the ORIGINAL `data[]` row, before any sibling // enrichment. Merging first let a `models[]` entry supply the very field a // provider filter requires — reproduced against the real Chutes policy, diff --git a/src/providers/registry/entries-extended.ts b/src/providers/registry/entries-extended.ts index 1791659615..9c5991c42c 100644 --- a/src/providers/registry/entries-extended.ts +++ b/src/providers/registry/entries-extended.ts @@ -427,6 +427,7 @@ export const PROVIDER_REGISTRY_EXTENDED: readonly ProviderRegistryEntry[] = [ // model_access_denied, which is why the Chat path cannot simply hang off the new base. responsesPath: "/api/v1/responses", chatCompletionsPath: "/api/coding/paas/v4/chat/completions", + modelDiscovery: { path: "/api/v1/models", envelopeKey: "models", idField: "slug" }, // The address this row occupied before the move. A saved custom provider still pointing // at the Chat endpoint keeps receiving this row's metadata (#1100). destinationAliases: [{ baseUrl: "https://api.z.ai/api/coding/paas/v4", adapter: "openai-chat" }], diff --git a/src/providers/registry/types.ts b/src/providers/registry/types.ts index f71c0fafe4..5192149cf4 100644 --- a/src/providers/registry/types.ts +++ b/src/providers/registry/types.ts @@ -64,6 +64,10 @@ export interface ProviderModelDiscoveryFilter { interface ProviderModelDiscoverySharedSpec { /** Query parameters applied to the resolved discovery URL. */ query?: Readonly>; + /** Top-level response key containing model rows; defaults to `data`. */ + envelopeKey?: string; + /** Model-row field containing the provider-native identifier; defaults to `id`. */ + idField?: string; /** Declarative eligibility rules evaluated against each untrusted model row. */ filter?: ProviderModelDiscoveryFilter; /** Optional lower byte ceiling; the process-wide hard ceiling still wins. */ diff --git a/structure/runtime.md b/structure/runtime.md index 17a3ccbf95..808b5774a8 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -234,9 +234,10 @@ Custom providers keep the conventional `${baseUrl}/models` request, normalized b whitespace and trailing slashes are trimmed and an already-pasted `/models` is not doubled, so a `baseUrl` written with or without a trailing slash yields the identical discovery URL and an existing path prefix is preserved. Canonical presets may select a -trusted URL/path/query and declarative eligibility filter without persisting that policy into user -config. A response is rejected before caching when it exceeds 4 MiB, contains more than 2,000 raw -rows, has a malformed OpenAI list envelope, or includes an invalid model id. Tests use fixtures and +trusted URL/path/query, response envelope key, model identifier field, and declarative eligibility +filter without persisting that policy into user config. A response is rejected before caching when +it exceeds 4 MiB, contains more than 2,000 raw rows, has a malformed declared list envelope, or +includes an invalid model id. Tests use fixtures and must never depend on live provider endpoints. Newly promoted fixed key presets opt into `preserveCustomDestination`, so an older same-named custom provider keeps its configured adapter, destination, and key boundary instead of being silently canonicalized onto the new host. Fixed diff --git a/tests/providers/provider-model-discovery-contract.test.ts b/tests/providers/provider-model-discovery-contract.test.ts index 9a4a6fae25..3a7cf8aa30 100644 --- a/tests/providers/provider-model-discovery-contract.test.ts +++ b/tests/providers/provider-model-discovery-contract.test.ts @@ -148,6 +148,44 @@ describe("registry-owned provider model discovery", () => { path: "models", } as unknown as ProviderModelDiscoverySpec)).toContain("mutually exclusive"); expect(providerModelDiscoverySpecError({ maxModels: 25 })).toBeNull(); + expect(providerModelDiscoverySpecError({ envelopeKey: " models ", idField: "slug" })) + .toContain("envelopeKey"); + expect(providerModelDiscoverySpecError({ envelopeKey: "models", idField: "" })) + .toContain("idField"); + }); + + test("zai uses its provider-specific discovery endpoint and response shape (#4822)", () => { + const entry = PROVIDER_REGISTRY.find(row => row.id === "zai"); + if (!entry?.modelDiscovery) throw new Error("zai must declare modelDiscovery"); + const seed = providerConfigSeed(entry); + const canonical = "https://api.z.ai/api/v1/models"; + + expect(resolveProviderModelDiscoveryUrl( + entry.id, + seed, + entry.baseUrl, + providerModelsUrl(entry.baseUrl), + )).toBe(canonical); + expect(isRegistryModelDiscoveryUrl(entry.id, canonical)).toBe(true); + expect(isRegistryModelDiscoveryUrl(entry.id, "https://api.z.ai/models")).toBe(false); + + const discovery = resolveProviderModelDiscovery(entry.id, seed); + expect(extractProviderModelItems({ models: [{ slug: "glm-5.3" }] }, discovery)).toEqual({ + ok: true, + rawCount: 1, + items: [{ slug: "glm-5.3", id: "glm-5.3" }], + }); + expect(extractProviderModelItems( + { models: [{ slug: "glm-5.3" }] }, + { maxResponseBytes: discovery.maxResponseBytes, maxModels: discovery.maxModels }, + )).toEqual({ ok: false, reason: "invalid_shape" }); + + expect(entry.baseUrl).toBe("https://api.z.ai"); + expect(entry.responsesPath).toBe("/api/v1/responses"); + expect(entry.chatCompletionsPath).toBe("/api/coding/paas/v4/chat/completions"); + expect(entry.destinationAliases).toEqual([ + { baseUrl: "https://api.z.ai/api/coding/paas/v4", adapter: "openai-chat" }, + ]); }); test("clears cached rows before applying a temporary registry discovery policy", async () => {