@namzu/zen 2.4.0 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +30 -11
  3. package/dist/catalogue/catalogue.d.ts +106 -0
  4. package/dist/catalogue/catalogue.d.ts.map +1 -0
  5. package/dist/catalogue/catalogue.js +325 -0
  6. package/dist/catalogue/catalogue.js.map +1 -0
  7. package/dist/catalogue/derive.d.ts +203 -0
  8. package/dist/catalogue/derive.d.ts.map +1 -0
  9. package/dist/catalogue/derive.js +485 -0
  10. package/dist/catalogue/derive.js.map +1 -0
  11. package/dist/catalogue/fetch.d.ts +63 -0
  12. package/dist/catalogue/fetch.d.ts.map +1 -0
  13. package/dist/catalogue/fetch.js +180 -0
  14. package/dist/catalogue/fetch.js.map +1 -0
  15. package/dist/catalogue/index.d.ts +17 -0
  16. package/dist/catalogue/index.d.ts.map +1 -0
  17. package/dist/catalogue/index.js +17 -0
  18. package/dist/catalogue/index.js.map +1 -0
  19. package/dist/client.d.ts +5 -1
  20. package/dist/client.d.ts.map +1 -1
  21. package/dist/client.js +39 -8
  22. package/dist/client.js.map +1 -1
  23. package/dist/index.d.ts +2 -1
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +1 -1
  26. package/dist/index.js.map +1 -1
  27. package/dist/models.d.ts +6 -0
  28. package/dist/models.d.ts.map +1 -1
  29. package/dist/models.js +49 -9
  30. package/dist/models.js.map +1 -1
  31. package/dist/types.d.ts +11 -1
  32. package/dist/types.d.ts.map +1 -1
  33. package/package.json +5 -1
  34. package/src/catalogue/catalogue.ts +462 -0
  35. package/src/catalogue/derive.ts +638 -0
  36. package/src/catalogue/fetch.ts +229 -0
  37. package/src/catalogue/index.ts +37 -0
  38. package/src/client.ts +47 -9
  39. package/src/index.ts +2 -1
  40. package/src/models.ts +50 -9
  41. package/src/types.ts +11 -1
@@ -0,0 +1,638 @@
1
+ /**
2
+ * The derivation rules that turn Zen's and Go's upstream documents into a
3
+ * model roster.
4
+ *
5
+ * Pure: no network, no file system, no logger. `fetch.ts` reads the sources and
6
+ * `catalogue.ts` assembles a catalogue from them; the maintainer's generator
7
+ * (`scripts/generate-zen-models.mjs`) imports these same functions to render
8
+ * the bundled snapshot in `models.ts`, so the runtime refresh and the snapshot
9
+ * can never parse the same page two different ways.
10
+ *
11
+ * ## Where each field comes from
12
+ *
13
+ * Routes come from the service's own documentation page, as the pair
14
+ * (endpoint, AI SDK package) that page states per model. Both halves are read
15
+ * and required to agree, because a page that changed shape must stop the run
16
+ * rather than have one half guessed: the wire a model is served on is a routing
17
+ * fact, and the wrong one is a request to the wrong endpoint rather than a
18
+ * clean failure. A row may state its endpoint and no package, with the `-` the
19
+ * page's own tables use for a cell that has no value: that row is read and
20
+ * recorded with no wire, and `deriveService` reports it as a model it will not
21
+ * carry. Losing a column, or carrying a marker that is not `-`, is the page
22
+ * moving and stops the run.
23
+ *
24
+ * Prices come from the same page's per-1M-token table, at the base tier: the
25
+ * unqualified row when a model has one, the "(Off-Peak)" row for the Go
26
+ * DeepSeek entries that publish both, and the "(≤ n tokens)" row when a model
27
+ * is only published as a tier pair. A model with no price row at all must
28
+ * appear in that page's free-model list and is then zero — "no row" is never
29
+ * quietly read as free.
30
+ *
31
+ * Limits, modalities, tool support and effort levels come from the matching
32
+ * provider entry in models.dev. Its `npm` is deliberately NOT used as a route,
33
+ * in either place models.dev states one: the provider-level field names a
34
+ * single Chat-Completions package for both services, which would send the Go
35
+ * Qwen rows — documented on `/messages` — to `/chat/completions`, and the
36
+ * per-model `provider.npm` is stated for fewer than half the documented
37
+ * models, so it cannot route the rest.
38
+ *
39
+ * The two services' own `/models` answers carry `id`, `object`, `created` and
40
+ * `owned_by` and nothing else: they say a model EXISTS and nothing about how to
41
+ * call it. An id served there and documented nowhere cannot be carried, only
42
+ * reported.
43
+ */
44
+
45
+ import type { ModelInputModality, ReasoningEffort } from '@namzu/sdk'
46
+
47
+ import type { ZenProtocol, ZenService } from '../models.js'
48
+
49
+ /**
50
+ * A source that could not be read, or no longer has the shape these rules
51
+ * parse. Never a curation decision: nothing a caller does to the catalogue
52
+ * clears it, and a refresh that meets one must keep whatever it had before.
53
+ */
54
+ export class ZenCatalogueSourceError extends Error {
55
+ override readonly name = 'ZenCatalogueSourceError'
56
+ }
57
+
58
+ function unusable(message: string): never {
59
+ throw new ZenCatalogueSourceError(message)
60
+ }
61
+
62
+ /**
63
+ * Characters a model name may not carry: controls (C0, DEL, C1), format
64
+ * characters (bidi overrides, zero-widths) and line or paragraph separators.
65
+ *
66
+ * A name is upstream text that a host prints as it is, in a terminal picker
67
+ * among other places. An escape sequence in it would be an OSC 52 clipboard
68
+ * write or a screen clear in every session that lists the model, and a bidi
69
+ * override would show one name while meaning another. No real name needs any
70
+ * of them, so a source whose name carries one is a source these rules refuse.
71
+ */
72
+ const UNDISPLAYABLE = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u
73
+
74
+ /** Whether `name` is safe to print as it is: no control, format or separator character. */
75
+ export function isDisplayableName(name: string): boolean {
76
+ return !UNDISPLAYABLE.test(name)
77
+ }
78
+
79
+ function routeName(cell: string, page: string): string {
80
+ const name = cell.trim()
81
+ if (!isDisplayableName(name)) {
82
+ unusable(
83
+ `${page} has a route row whose model name, ${JSON.stringify(name)}, carries a control,\n format or separator character. A name is printed as it is wherever the model is\n listed, so the run stops rather than carry it.`,
84
+ )
85
+ }
86
+ return name
87
+ }
88
+
89
+ /** A service, its documentation page, and the models.dev provider holding its limits. */
90
+ export const ZEN_SERVICES: readonly {
91
+ readonly service: ZenService
92
+ readonly page: string
93
+ readonly provider: string
94
+ }[] = Object.freeze([
95
+ { service: 'zen', page: 'zen.mdx', provider: 'opencode' },
96
+ { service: 'go', page: 'go.mdx', provider: 'opencode-go' },
97
+ ])
98
+
99
+ /**
100
+ * Every (AI SDK package, endpoint) pair a page may state, and the protocol
101
+ * each is. `kind` says how the endpoint names the model: the first three put
102
+ * it in the body of a fixed path, Google puts it in the path itself.
103
+ */
104
+ export const ROUTES: readonly {
105
+ readonly npm: string
106
+ readonly path: string
107
+ readonly protocol: ZenProtocol
108
+ readonly kind: 'path' | 'model'
109
+ }[] = Object.freeze([
110
+ { npm: '@ai-sdk/openai-compatible', path: '/chat/completions', protocol: 'chat', kind: 'path' },
111
+ { npm: '@ai-sdk/openai', path: '/responses', protocol: 'responses', kind: 'path' },
112
+ { npm: '@ai-sdk/anthropic', path: '/messages', protocol: 'messages', kind: 'path' },
113
+ { npm: '@ai-sdk/google', path: '/models/', protocol: 'google', kind: 'model' },
114
+ ])
115
+
116
+ /** The Google route's tail: `models/<id>`, and nothing else. */
117
+ const MODEL_PATH = /^models\/[a-z0-9][a-z0-9.-]*$/
118
+
119
+ /** The SDK's input modalities, and the models.dev names that map onto them. */
120
+ export const MODALITIES: Readonly<Record<string, ModelInputModality>> = Object.freeze({
121
+ text: 'text',
122
+ image: 'image',
123
+ pdf: 'document',
124
+ })
125
+
126
+ /** The service each base host serves, by the path prefix its endpoints carry. */
127
+ export const SERVICE_PREFIX: Readonly<Record<ZenService, string>> = Object.freeze({
128
+ zen: '/zen/v1/',
129
+ go: '/zen/go/v1/',
130
+ })
131
+
132
+ /** One route-table row. `npm` is undefined when the page states no wire. */
133
+ export interface ZenRouteRow {
134
+ readonly name: string
135
+ readonly id: string
136
+ readonly endpoint: string
137
+ readonly npm: string | undefined
138
+ }
139
+
140
+ /** One price-table row, in USD per million tokens. */
141
+ export interface ZenPriceRow {
142
+ readonly name: string
143
+ readonly input: number
144
+ readonly output: number
145
+ }
146
+
147
+ /** A documentation page, split into the three tables the rules read. */
148
+ export interface ZenParsedPage {
149
+ readonly routes: readonly ZenRouteRow[]
150
+ readonly prices: readonly ZenPriceRow[]
151
+ readonly freeNames: readonly string[]
152
+ }
153
+
154
+ /**
155
+ * One `/models` answer, as the set of ids it says the service serves.
156
+ *
157
+ * The shape is asserted rather than assumed, and an answer with no ids in it is
158
+ * refused: a service that returns nothing is a source that moved, and reading
159
+ * it as "nothing is served" would turn an outage into an empty roster.
160
+ */
161
+ export function parseServed(service: string, text: string): Set<string> {
162
+ let body: unknown
163
+ try {
164
+ body = JSON.parse(text)
165
+ } catch (error) {
166
+ unusable(
167
+ `The ${service} /models answer is not JSON: ${error instanceof Error ? error.message : error}`,
168
+ )
169
+ }
170
+ const data =
171
+ body !== null && typeof body === 'object' ? (body as { data?: unknown }).data : undefined
172
+ if (!Array.isArray(data)) {
173
+ unusable(
174
+ `The ${service} /models answer has no \`data\` array. It is the field the served roster\n is read from, so a service that changed shape stops here.`,
175
+ )
176
+ }
177
+ const ids = new Set<string>()
178
+ for (const item of data) {
179
+ const id = item !== null && typeof item === 'object' ? (item as { id?: unknown }).id : undefined
180
+ if (typeof id !== 'string' || id.length === 0) {
181
+ unusable(
182
+ `The ${service} /models answer has an entry with no string \`id\`. Every id it\n serves is checked against the catalogue, so an entry that cannot be read is\n an id nobody checked.`,
183
+ )
184
+ }
185
+ ids.add(id)
186
+ }
187
+ if (ids.size === 0) {
188
+ unusable(
189
+ `The ${service} /models answer lists no models at all. That is a source that moved,\n not a service that serves nothing, and accepting it would empty the roster\n for as long as the outage lasted.`,
190
+ )
191
+ }
192
+ return ids
193
+ }
194
+
195
+ /**
196
+ * Whether a line looks like a row of the route table.
197
+ *
198
+ * The account has to CLOSE, not merely to be attempted: a line shaped like a
199
+ * route row that the route pattern did not capture is a page these rules no
200
+ * longer read correctly, and dropping it silently is the defect the whole
201
+ * arrangement exists to remove. Two shapes count, both measured against the
202
+ * live pages:
203
+ *
204
+ * - two or more backticked cells, which is a row carrying both its endpoint
205
+ * and its package;
206
+ * - three or more cells with at least one backticked cell, which is a row that
207
+ * kept a value and lost a column: an empty package cell leaves four cells,
208
+ * and a row whose package COLUMN is gone leaves three.
209
+ *
210
+ * The backtick guard keeps the wider rule off table separators
211
+ * (`| --- | --- | --- | --- |` carries no code span), and a prose row that
212
+ * quotes a code span has two cells.
213
+ */
214
+ export function isRouteRowShaped(line: string): boolean {
215
+ const ticked = [...line.matchAll(/`[^`]*`/g)].length
216
+ const cells = line.split('|').length - 2
217
+ return ticked >= 2 || (cells >= 3 && ticked >= 1)
218
+ }
219
+
220
+ /** A documentation page, split into the three tables the rules read. */
221
+ export function parsePage(
222
+ text: string,
223
+ { page, service }: { readonly page: string; readonly service: string },
224
+ ): ZenParsedPage {
225
+ const routes: ZenRouteRow[] = []
226
+ const prices: ZenPriceRow[] = []
227
+ const freeNames: string[] = []
228
+ const unroutable: string[] = []
229
+ // The free-model list is the only place a model is stated to be served at no
230
+ // charge, and it is a bullet list under its own heading. It is read as a
231
+ // block rather than by scanning for prose that looks like it, because the
232
+ // same page ends with per-model data-handling notes that are also bullets
233
+ // and also name models — reading those as free models would be a silent
234
+ // widening of anonymous access.
235
+ let inFreeSection = false
236
+ for (const line of text.split('\n')) {
237
+ if (/^The free models:/.test(line)) {
238
+ inFreeSection = true
239
+ continue
240
+ }
241
+ if (inFreeSection) {
242
+ const bullet = line.match(/^-\s+(.+?)\s+is\s/)
243
+ if (bullet?.[1] !== undefined) {
244
+ freeNames.push(bullet[1].trim())
245
+ continue
246
+ }
247
+ if (line.trim().length > 0 && !line.startsWith('-')) inFreeSection = false
248
+ }
249
+ const route = line.match(
250
+ /^\|\s*(.+?)\s*\|\s*([a-z0-9][a-z0-9.\-]*)\s*\|\s*`([^`]+)`\s*\|\s*`([^`]+)`\s*\|$/,
251
+ )
252
+ if (route) {
253
+ routes.push({
254
+ name: routeName(route[1] as string, page),
255
+ id: route[2] as string,
256
+ endpoint: route[3] as string,
257
+ npm: route[4] as string,
258
+ })
259
+ continue
260
+ }
261
+ // A route row may state NO wire: the package column carries the `-` the
262
+ // page's own tables use for a cell with no value. Narrow on purpose — the
263
+ // fourth cell has to be exactly that marker, and the name cell refuses a
264
+ // `|`, so a five-cell line whose extra cell sits in the middle cannot be
265
+ // read as a wireless route named after two cells.
266
+ const wireless = line.match(
267
+ /^\|\s*([^|]+?)\s*\|\s*([a-z0-9][a-z0-9.\-]*)\s*\|\s*`([^`]+)`\s*\|\s*-\s*\|$/,
268
+ )
269
+ if (wireless) {
270
+ routes.push({
271
+ name: routeName(wireless[1] as string, page),
272
+ id: wireless[2] as string,
273
+ endpoint: wireless[3] as string,
274
+ npm: undefined,
275
+ })
276
+ continue
277
+ }
278
+ const price = line.match(/^\|\s*(.+?)\s*\|\s*([^|]*?)\s*\|\s*([^|]*?)\s*\|/)
279
+ if (price) {
280
+ // A price cell is a dollar figure or the word "Free". Nothing else is
281
+ // read, so a table whose columns mean something else cannot be picked
282
+ // up by being three cells wide.
283
+ const input = priceCell(price[2] as string)
284
+ const output = priceCell(price[3] as string)
285
+ if (input !== undefined && output !== undefined) {
286
+ prices.push({ name: (price[1] as string).trim(), input, output })
287
+ continue
288
+ }
289
+ }
290
+ if (line.startsWith('|') && isRouteRowShaped(line)) unroutable.push(line.trim())
291
+ }
292
+ if (unroutable.length > 0) {
293
+ unusable(
294
+ `${page} has ${unroutable.length} line(s) shaped like a route row that this script\n did not read as one. A model the page routes but this script does not capture is\n a model that silently disappears from the catalogue, so the run stops instead:\n${unroutable
295
+ .slice(0, 5)
296
+ .map((line) => ` ${line}`)
297
+ .join('\n')}${unroutable.length > 5 ? `\n … and ${unroutable.length - 5} more` : ''}`,
298
+ )
299
+ }
300
+ if (routes.length === 0) unusable(`${page} has no route table.`)
301
+ const seen = new Set<string>()
302
+ for (const route of routes) {
303
+ if (seen.has(route.id)) {
304
+ unusable(
305
+ `${page} routes "${route.id}" on more than one row. Which row states its wire and\n its name is not something these rules can choose, so the run stops instead.`,
306
+ )
307
+ }
308
+ seen.add(route.id)
309
+ }
310
+ if (service === 'zen' && freeNames.length === 0) {
311
+ unusable(
312
+ `${page} has no free-model list. Anonymous admission is stated there and is never\n inferred from a zero price, so an empty list is a page that moved, not a\n catalogue that serves nothing for free.`,
313
+ )
314
+ }
315
+ return { routes, prices, freeNames }
316
+ }
317
+
318
+ /**
319
+ * A price cell, or undefined when the cell is not a price.
320
+ *
321
+ * Go states its free models' rate as the word "Free" rather than as `$0.00`,
322
+ * which is a statement about the price and not a missing one, so it is read as
323
+ * zero.
324
+ */
325
+ export function priceCell(cell: string): number | undefined {
326
+ // One figure with at most one decimal point. A looser pattern reads `$1.2.3`
327
+ // as `Number('1.2.3')`, which is NaN, and a NaN rate is not a price.
328
+ const dollars = /^\$([0-9]+(?:\.[0-9]+)?)$/.exec(cell.trim())
329
+ if (dollars) return Number(dollars[1])
330
+ return /^free$/i.test(cell.trim()) ? 0 : undefined
331
+ }
332
+
333
+ /** Model names are matched across tables by their letters, digits and dots. */
334
+ export function nameKey(name: string): string {
335
+ return name
336
+ .toLowerCase()
337
+ .replace(/\([^)]*\)/g, '')
338
+ .replace(/[^a-z0-9.]/g, '')
339
+ }
340
+
341
+ /**
342
+ * The base-tier price row for a model: the unqualified row, the "(≤ …)" row of
343
+ * a tier pair, or the "(Off-Peak)" row of a peak/off-peak pair.
344
+ */
345
+ export function priceRowFor(rows: readonly ZenPriceRow[], name: string): ZenPriceRow | undefined {
346
+ const candidates = rows.filter((row) => nameKey(row.name) === nameKey(name))
347
+ if (candidates.length === 0) return undefined
348
+ return (
349
+ candidates.find((row) => /off-peak/i.test(row.name)) ??
350
+ candidates.find((row) => !row.name.includes('(')) ??
351
+ candidates.find((row) => row.name.includes('≤'))
352
+ )
353
+ }
354
+
355
+ /**
356
+ * The protocol a (package, endpoint) pair is, refusing anything unrecognised.
357
+ *
358
+ * The pair is checked against the URL's own parts rather than by substring: a
359
+ * cell reading `https://evil.example/messages` contains the path a Messages
360
+ * route has, and a routing decision that accepts it is a routing decision made
361
+ * on the wrong string. Host, service prefix, path and package must all agree.
362
+ */
363
+ export function protocolFor(
364
+ { id, endpoint, npm }: Pick<ZenRouteRow, 'id' | 'endpoint' | 'npm'>,
365
+ service: string,
366
+ ): ZenProtocol {
367
+ const refuse = (why: string): never =>
368
+ unusable(
369
+ `"${id}" is documented as ${npm} on ${endpoint}, ${why}. A model whose wire is not\n known cannot be routed, so the run stops rather than picking one.`,
370
+ )
371
+ if (npm === undefined) {
372
+ unusable(
373
+ `"${id}" is documented on ${endpoint} with no AI SDK package stated, so no wire is\n known. A model whose wire is not known cannot be routed, so the run stops\n rather than picking one.`,
374
+ )
375
+ }
376
+ let url: URL
377
+ try {
378
+ url = new URL(endpoint)
379
+ } catch {
380
+ return refuse('which is not a URL')
381
+ }
382
+ if (url.host !== 'opencode.ai') refuse("whose host is not the service's")
383
+ const prefix = (SERVICE_PREFIX as Record<string, string | undefined>)[service]
384
+ if (prefix === undefined) return refuse('on a service this script does not know')
385
+ if (!url.pathname.startsWith(prefix)) refuse(`which is not on the ${service} service's own path`)
386
+ const route = ROUTES.find((candidate) => candidate.npm === npm)
387
+ if (!route) {
388
+ return refuse(
389
+ `with a package that is not one of the ${ROUTES.length} routes this driver implements`,
390
+ )
391
+ }
392
+ const tail = url.pathname.slice(prefix.length)
393
+ const matches = route.kind === 'path' ? tail === route.path.slice(1) : MODEL_PATH.test(tail)
394
+ if (!matches) refuse(`which does not address ${route.path} on the ${service} service`)
395
+ return route.protocol
396
+ }
397
+
398
+ /** A models.dev model entry, as far as these rules read it. */
399
+ interface ModelsDevEntry {
400
+ readonly limit?: { readonly context?: number; readonly output?: number }
401
+ readonly modalities?: { readonly input?: readonly string[] }
402
+ readonly tool_call?: boolean
403
+ readonly reasoning_options?: readonly { readonly type?: string; readonly values?: unknown }[]
404
+ }
405
+
406
+ /** A models.dev provider entry: its models, keyed by id. */
407
+ export interface ModelsDevProvider {
408
+ readonly models: Readonly<Record<string, unknown>>
409
+ }
410
+
411
+ /** One model a service's derivation carries, before it becomes a `ZenModel`. */
412
+ export interface DerivedZenModel {
413
+ readonly id: string
414
+ readonly name: string
415
+ readonly protocol: ZenProtocol
416
+ readonly contextWindow: number
417
+ readonly maxOutputTokens: number
418
+ readonly inputModalities: ModelInputModality[]
419
+ readonly inputPrice: number
420
+ readonly outputPrice: number
421
+ readonly supportsToolUse: boolean
422
+ readonly effortLevels: ReasoningEffort[]
423
+ readonly supportsAnonymousAccess: boolean
424
+ }
425
+
426
+ /** Everything one service's derivation produced, carried and not. */
427
+ export interface DerivedZenService {
428
+ readonly models: DerivedZenModel[]
429
+ /** `[service/id, why]`: documented models it did not carry and nothing omits. */
430
+ readonly undecided: [string, string][]
431
+ /** Omitted ids upstream neither documents nor serves any more. */
432
+ readonly stale: string[]
433
+ /** Price rows no route row names. */
434
+ readonly orphanPrices: string[]
435
+ /** Ids the service serves that its page does not document and nothing omits. */
436
+ readonly servedUncurried: string[]
437
+ }
438
+
439
+ /** Omission decisions, as the review file's record or as a set of `service/id` keys. */
440
+ export type ZenOmissions = Readonly<Record<string, string>> | ReadonlySet<string>
441
+
442
+ function omissionKeys(omissions: ZenOmissions): ReadonlySet<string> {
443
+ return omissions instanceof Set ? omissions : new Set(Object.keys(omissions))
444
+ }
445
+
446
+ /**
447
+ * One service's carried models, plus everything either source names and the
448
+ * catalogue does not carry.
449
+ *
450
+ * `served` is the set of ids the service's own `/models` answer carries, and it
451
+ * is required rather than defaulted: a caller that forgot it would turn the
452
+ * served dimension off silently.
453
+ */
454
+ export function derive(
455
+ service: string,
456
+ parsed: ZenParsedPage,
457
+ providerKey: string,
458
+ provider: ModelsDevProvider,
459
+ omissions: ZenOmissions,
460
+ served: ReadonlySet<string>,
461
+ ): DerivedZenService {
462
+ const omitted = omissionKeys(omissions)
463
+ const freeIds = new Set<string>()
464
+ for (const name of parsed.freeNames) {
465
+ const row = parsed.routes.find((candidate) => nameKey(candidate.name) === nameKey(name))
466
+ if (!row) {
467
+ unusable(
468
+ `The free-model list names "${name}", which the route table does not. Anonymous\n admission is derived from that pairing, so the page has moved.`,
469
+ )
470
+ }
471
+ freeIds.add(row.id)
472
+ }
473
+ const documented = new Set(parsed.routes.map((row) => row.id))
474
+ const routeNames = new Set(parsed.routes.map((row) => nameKey(row.name)))
475
+ const orphanPrices = parsed.prices
476
+ .filter((row) => !routeNames.has(nameKey(row.name)))
477
+ .map((row) => row.name)
478
+ const models: DerivedZenModel[] = []
479
+ const undecided: [string, string][] = []
480
+ const incomplete: string[] = []
481
+ // The rows actually put to models.dev: an omitted row is never asked about,
482
+ // and neither is one that states no wire — no answer there could make it
483
+ // carryable.
484
+ let considered = 0
485
+ let silentOnTools = 0
486
+ let modelsDevEntries = 0
487
+ for (const row of parsed.routes) {
488
+ if (omitted.has(`${service}/${row.id}`)) continue
489
+ if (row.npm === undefined) {
490
+ undecided.push([
491
+ `${service}/${row.id}`,
492
+ 'the page states no AI SDK package for it, so no wire is known',
493
+ ])
494
+ continue
495
+ }
496
+ considered += 1
497
+ const metadata = provider.models[row.id] as ModelsDevEntry | undefined
498
+ if (!metadata || typeof metadata !== 'object') {
499
+ undecided.push([`${service}/${row.id}`, `models.dev has no \`${providerKey}\` entry`])
500
+ continue
501
+ }
502
+ if (metadata.tool_call === undefined) silentOnTools += 1
503
+ modelsDevEntries += 1
504
+ const priceRow = priceRowFor(parsed.prices, row.name)
505
+ if (!priceRow && !freeIds.has(row.id)) {
506
+ undecided.push([`${service}/${row.id}`, 'the page neither prices it nor names it as free'])
507
+ continue
508
+ }
509
+ const effort = (
510
+ Array.isArray(metadata.reasoning_options) ? metadata.reasoning_options : []
511
+ ).find((option) => option?.type === 'effort')
512
+ const inputModalities = (
513
+ Array.isArray(metadata.modalities?.input) ? metadata.modalities.input : []
514
+ )
515
+ .map((modality) => MODALITIES[modality])
516
+ .filter((modality): modality is ModelInputModality => modality !== undefined)
517
+ const contextWindow = metadata.limit?.context
518
+ const maxOutputTokens = metadata.limit?.output
519
+ if (
520
+ !isPositiveInteger(contextWindow) ||
521
+ !isPositiveInteger(maxOutputTokens) ||
522
+ inputModalities.length === 0
523
+ ) {
524
+ incomplete.push(
525
+ `${service}/${row.id} (context ${contextWindow}, output ${maxOutputTokens}, ${inputModalities.length} input modalities)`,
526
+ )
527
+ continue
528
+ }
529
+ // The page either prices the model or names it free; the guard above
530
+ // refused every model that is neither. So the zero written when there is
531
+ // no price row is a rate these rules KNOW, not a default.
532
+ const inputPrice = priceRow ? priceRow.input : 0
533
+ const outputPrice = priceRow ? priceRow.output : 0
534
+ models.push({
535
+ id: row.id,
536
+ name: row.name,
537
+ protocol: protocolFor(row, service),
538
+ contextWindow,
539
+ maxOutputTokens,
540
+ inputModalities,
541
+ inputPrice,
542
+ outputPrice,
543
+ supportsToolUse: metadata.tool_call === true,
544
+ effortLevels: effortValues(effort?.values, `${service}/${row.id}`),
545
+ // Anonymous admission is a Zen concept: the Go constructor refuses an
546
+ // absent key outright, so the flag would mean nothing on that service.
547
+ supportsAnonymousAccess: service === 'zen' && freeIds.has(row.id),
548
+ })
549
+ }
550
+ // Incomplete metadata is a source that moved, not a decision anybody makes:
551
+ // an entry that is there and has lost the fields is models.dev having
552
+ // changed shape, and every model reporting it at once is what a rename
553
+ // looks like.
554
+ if (incomplete.length > 0) {
555
+ const all = incomplete.length === considered
556
+ unusable(
557
+ `${incomplete.length}${all ? ' — every one put to models.dev —' : ` of ${considered}`} model(s) on the\n ${service} page have an entry in models.dev that is missing the fields the catalogue\n needs. An entry that exists and has lost its limits is models.dev having changed\n shape, not a curation decision, so this is reported as drift in the SOURCE:\n${incomplete
558
+ .slice(0, 8)
559
+ .map((line) => ` ${line}`)
560
+ .join('\n')}${incomplete.length > 8 ? `\n … and ${incomplete.length - 8} more` : ''}`,
561
+ )
562
+ }
563
+ // A `tool_call` missing from every entry at once is a rename, and it is the
564
+ // loss that fails QUIETLY: read as "no tools", it would rewrite the whole
565
+ // roster to `supportsToolUse: false`. One entry of many is a real answer.
566
+ if (modelsDevEntries > 0 && silentOnTools === modelsDevEntries) {
567
+ unusable(
568
+ `All ${modelsDevEntries} models.dev entr${modelsDevEntries === 1 ? 'y' : 'ies'} for the ${service}\n provider omit \`tool_call\`. A field that vanished from every entry at once is models.dev\n having changed shape, not every model losing its tools, so this is reported as a\n SOURCE failure rather than written into the catalogue as \`supportsToolUse: false\`.`,
569
+ )
570
+ }
571
+ const servedUncurried = [...served]
572
+ .filter((id) => !documented.has(id) && !omitted.has(`${service}/${id}`))
573
+ .sort()
574
+ // An omission expires when upstream stops naming the model at all: neither
575
+ // its page nor the service that serves it.
576
+ const stale = [...omitted]
577
+ .filter((key) => key.startsWith(`${service}/`))
578
+ .map((key) => key.slice(service.length + 1))
579
+ .filter((id) => !documented.has(id) && !served.has(id))
580
+ return { models, undecided, stale, orphanPrices, servedUncurried }
581
+ }
582
+
583
+ const EFFORTS: ReadonlySet<string> = new Set([
584
+ 'none',
585
+ 'minimal',
586
+ 'low',
587
+ 'medium',
588
+ 'high',
589
+ 'xhigh',
590
+ 'max',
591
+ 'ultra',
592
+ ])
593
+
594
+ function effortValues(values: unknown, key: string): ReasoningEffort[] {
595
+ if (values === undefined) return []
596
+ if (!Array.isArray(values)) unusable(`models.dev states effort options for ${key} as a non-list.`)
597
+ for (const value of values) {
598
+ if (typeof value !== 'string' || !EFFORTS.has(value)) {
599
+ unusable(
600
+ `models.dev states an effort level ${JSON.stringify(value)} for ${key}, which the SDK\n has no \`ReasoningEffort\` for. A level the driver cannot send is not carried by guessing.`,
601
+ )
602
+ }
603
+ }
604
+ return [...(values as ReasoningEffort[])]
605
+ }
606
+
607
+ function isPositiveInteger(value: unknown): value is number {
608
+ return typeof value === 'number' && Number.isSafeInteger(value) && value > 0
609
+ }
610
+
611
+ /**
612
+ * Refuse a roster that collapsed.
613
+ *
614
+ * A roster that came out empty, or that lost a fifth of what the baseline
615
+ * carries, is a source that moved far more often than a decision anybody made,
616
+ * and both look identical to a successful run from the inside.
617
+ */
618
+ export const ROSTER_FLOOR = 0.8
619
+
620
+ export function checkRosterFloor(
621
+ built: Readonly<Record<ZenService, readonly unknown[]>>,
622
+ committed: Readonly<Record<ZenService, readonly unknown[]>>,
623
+ ): void {
624
+ for (const { service } of ZEN_SERVICES) {
625
+ const derived = built[service].length
626
+ const carried = committed[service].length
627
+ if (derived === 0) {
628
+ unusable(
629
+ `The ${service} roster came out empty. Every documented model was rejected — a\n catalogue with nothing in it is not a result, and a review file that omits\n every model produces exactly this. Refusing rather than writing it.`,
630
+ )
631
+ }
632
+ if (carried > 0 && derived < carried * ROSTER_FLOOR) {
633
+ unusable(
634
+ `The ${service} roster fell from ${carried} models to ${derived}, past the floor of\n ${ROSTER_FLOOR * 100}% of what is committed. A fall that size is far more likely to be a source\n that changed shape than a decision somebody made, so it is reported as one.\n If upstream really did withdraw that many models, change ROSTER_FLOOR in\n packages/providers/zen/src/catalogue/derive.ts and say so in the commit.`,
635
+ )
636
+ }
637
+ }
638
+ }