Skip to content
Merged
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
26 changes: 19 additions & 7 deletions src/providers/model-discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -501,32 +509,36 @@ 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 validonly `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<string>();
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<string, unknown>)[idField];
if (!isValidModelDiscoveryModelId(id)) return { ok: false, reason: "invalid_shape" };
const prefix = discovery.spec?.stripIdPrefix;
let finalId = id;
if (prefix && finalId.startsWith(prefix)) {
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<string, unknown>), 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,
Expand Down
1 change: 1 addition & 0 deletions src/providers/registry/entries-extended.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use the declared parser when testing Z.AI connections

Route this declared response shape through extractProviderModelItems in the management connection-test path. That path currently invokes the shared parser only for arrays or record.data; a Z.AI record.models response instead falls through to extractModelEnvelopeRows, which merely counts rows and never validates the declared slug identifier or applies discovery filters. Consequently a response such as {"models":[{}]} is reported as “Connected — 1 model available” even though catalog discovery rejects the same response as invalid_shape.

Useful? React with 👍 / 👎.

// 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" }],
Expand Down
4 changes: 4 additions & 0 deletions src/providers/registry/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ export interface ProviderModelDiscoveryFilter {
interface ProviderModelDiscoverySharedSpec {
/** Query parameters applied to the resolved discovery URL. */
query?: Readonly<Record<string, string>>;
/** 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;
Comment on lines +67 to +70

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include shape fields in the discovery-policy identity

Include envelopeKey and idField in CatalogProviderDiscoveryPolicySnapshot and the value hashed as discoveryPolicyIdentity. During an overlapping in-process registry-policy override, two gathers that differ only in either new field currently produce the same authority identity, so gatherRoutedModelsWithAuth can join the second caller to a flight whose captured parser expects a different envelope or identifier and return the wrong catalog. The existing codex-gather-authority.test.ts concurrency case for differing live registry policies should cover these fields as well.

Useful? React with 👍 / 👎.

/** Declarative eligibility rules evaluated against each untrusted model row. */
filter?: ProviderModelDiscoveryFilter;
/** Optional lower byte ceiling; the process-wide hard ceiling still wins. */
Expand Down
7 changes: 4 additions & 3 deletions structure/runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 38 additions & 0 deletions tests/providers/provider-model-discovery-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
Loading