@namzu/zen 2.4.0 → 2.5.2

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 (45) hide show
  1. package/CHANGELOG.md +28 -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 +59 -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/prompt.d.ts.map +1 -1
  32. package/dist/prompt.js +7 -0
  33. package/dist/prompt.js.map +1 -1
  34. package/dist/types.d.ts +11 -1
  35. package/dist/types.d.ts.map +1 -1
  36. package/package.json +6 -2
  37. package/src/catalogue/catalogue.ts +462 -0
  38. package/src/catalogue/derive.ts +638 -0
  39. package/src/catalogue/fetch.ts +229 -0
  40. package/src/catalogue/index.ts +37 -0
  41. package/src/client.ts +68 -9
  42. package/src/index.ts +2 -1
  43. package/src/models.ts +50 -9
  44. package/src/prompt.ts +7 -0
  45. package/src/types.ts +11 -1
@@ -0,0 +1,462 @@
1
+ /**
2
+ * A Zen catalogue assembled at run time from the same sources, by the same
3
+ * rules, as the bundled snapshot in `models.ts`.
4
+ *
5
+ * Nothing here reaches the network. `buildZenCatalogue` turns source TEXTS
6
+ * into a catalogue; `fetch.ts` is the one place those texts are fetched, and
7
+ * only when a caller asks. `parseZenCatalogue` re-admits a catalogue a host
8
+ * stored earlier, with the same strictness a fresh derivation has: a document
9
+ * that is not exactly a catalogue is refused whole, never read in part.
10
+ */
11
+
12
+ import type { ModelInputModality, ReasoningEffort } from '@namzu/sdk'
13
+
14
+ import {
15
+ ZEN_OMITTED_MODELS,
16
+ type ZenModel,
17
+ type ZenProtocol,
18
+ type ZenService,
19
+ findZenModel,
20
+ getZenModels,
21
+ } from '../models.js'
22
+ import {
23
+ type DerivedZenModel,
24
+ type ModelsDevProvider,
25
+ ZEN_SERVICES,
26
+ ZenCatalogueSourceError,
27
+ type ZenOmissions,
28
+ checkRosterFloor,
29
+ derive,
30
+ isDisplayableName,
31
+ parsePage,
32
+ parseServed,
33
+ } from './derive.js'
34
+
35
+ /** The stored shape's version. A document carrying any other is refused. */
36
+ export const ZEN_CATALOGUE_VERSION = 1
37
+
38
+ /**
39
+ * A complete Zen and Go roster.
40
+ *
41
+ * `unrouted` lists, per service, the ids the service's own `/models` answer
42
+ * serves that this catalogue neither carries nor omits by review: no source
43
+ * states a wire for them (or the rest of what carrying needs), so a driver
44
+ * lists them as having no known wire format and calls one only when its host
45
+ * names the protocol explicitly.
46
+ */
47
+ export interface ZenCatalogue {
48
+ readonly version: typeof ZEN_CATALOGUE_VERSION
49
+ /** When the sources were read, as an ISO 8601 timestamp. */
50
+ readonly fetchedAt: string
51
+ readonly zen: readonly ZenModel[]
52
+ readonly go: readonly ZenModel[]
53
+ readonly unrouted: Readonly<Record<ZenService, readonly string[]>>
54
+ }
55
+
56
+ /** What a derivation found and did not carry. Informational: none of it fails a build. */
57
+ export interface ZenCatalogueReport {
58
+ /** `service/id`: served by the service, documented on no page, omitted by nothing. */
59
+ readonly servedUndocumented: readonly string[]
60
+ /** `[service/id, why]`: documented, not carryable, omitted by nothing. */
61
+ readonly undecided: readonly (readonly [string, string])[]
62
+ /** `service/id`: omissions upstream neither documents nor serves any more. */
63
+ readonly stale: readonly string[]
64
+ /** `service: name`: price rows no route row names. */
65
+ readonly orphanPrices: readonly string[]
66
+ }
67
+
68
+ /** The five upstream documents, as the texts they were served as. */
69
+ export interface ZenCatalogueSources {
70
+ readonly docs: Readonly<Record<ZenService, string>>
71
+ /** `https://models.dev/api.json`. */
72
+ readonly modelsDev: string
73
+ /** Each service's own `/models` answer. */
74
+ readonly served: Readonly<Record<ZenService, string>>
75
+ }
76
+
77
+ export interface BuildZenCatalogueOptions {
78
+ /**
79
+ * Reviewed omissions, as `service/id` keys or the review file's record.
80
+ * Defaults to the decisions the bundled snapshot was generated under.
81
+ */
82
+ readonly omissions?: ZenOmissions
83
+ /**
84
+ * The roster a result is measured against for collapse: fewer than 80% of
85
+ * its models on either service refuses the result. Defaults to the bundled
86
+ * snapshot.
87
+ */
88
+ readonly baseline?: Readonly<Record<ZenService, readonly unknown[]>>
89
+ /** Stamped as `fetchedAt`. Defaults to now. */
90
+ readonly fetchedAt?: Date
91
+ }
92
+
93
+ export interface ZenCatalogueResult {
94
+ readonly catalogue: ZenCatalogue
95
+ readonly report: ZenCatalogueReport
96
+ }
97
+
98
+ /**
99
+ * Derive a catalogue from upstream texts, or throw `ZenCatalogueSourceError`.
100
+ *
101
+ * All or nothing: a page that moved, a models.dev that changed shape, a served
102
+ * answer with no ids or a roster that collapsed past the floor refuses the
103
+ * whole result, so a caller holding an older catalogue keeps it rather than
104
+ * receiving part of a new one.
105
+ */
106
+ export function buildZenCatalogue(
107
+ sources: ZenCatalogueSources,
108
+ options: BuildZenCatalogueOptions = {},
109
+ ): ZenCatalogueResult {
110
+ const omissions = options.omissions ?? new Set(ZEN_OMITTED_MODELS)
111
+ const omitted = omissions instanceof Set ? omissions : new Set(Object.keys(omissions))
112
+ let modelsDev: unknown
113
+ try {
114
+ modelsDev = JSON.parse(sources.modelsDev)
115
+ } catch (error) {
116
+ throw new ZenCatalogueSourceError(
117
+ `models.dev is not JSON: ${error instanceof Error ? error.message : String(error)}`,
118
+ )
119
+ }
120
+ const carried: Record<ZenService, ZenModel[]> = { zen: [], go: [] }
121
+ const unrouted: Record<ZenService, string[]> = { zen: [], go: [] }
122
+ const servedUndocumented: string[] = []
123
+ const undecided: [string, string][] = []
124
+ const stale: string[] = []
125
+ const orphanPrices: string[] = []
126
+ for (const { service, page, provider } of ZEN_SERVICES) {
127
+ const parsed = parsePage(sources.docs[service], { page, service })
128
+ const served = parseServed(service, sources.served[service])
129
+ const entry =
130
+ modelsDev !== null && typeof modelsDev === 'object'
131
+ ? (modelsDev as Record<string, unknown>)[provider]
132
+ : undefined
133
+ const models =
134
+ entry !== null && typeof entry === 'object'
135
+ ? (entry as { models?: unknown }).models
136
+ : undefined
137
+ if (models === null || typeof models !== 'object' || Array.isArray(models)) {
138
+ throw new ZenCatalogueSourceError(`models.dev has no \`${provider}\` provider entry.`)
139
+ }
140
+ const derived = derive(
141
+ service,
142
+ parsed,
143
+ provider,
144
+ { models } as ModelsDevProvider,
145
+ omitted,
146
+ served,
147
+ )
148
+ carried[service] = derived.models.map(toZenModel)
149
+ const ids = new Set(carried[service].map((model) => model.id))
150
+ unrouted[service] = [...served]
151
+ .filter((id) => !ids.has(id) && !omitted.has(`${service}/${id}`))
152
+ .sort()
153
+ servedUndocumented.push(...derived.servedUncurried.map((id) => `${service}/${id}`))
154
+ undecided.push(...derived.undecided)
155
+ stale.push(...derived.stale.map((id) => `${service}/${id}`))
156
+ orphanPrices.push(...derived.orphanPrices.map((name) => `${service}: ${name}`))
157
+ }
158
+ checkRosterFloor(
159
+ carried,
160
+ options.baseline ?? { zen: getZenModels('zen'), go: getZenModels('go') },
161
+ )
162
+ // The result is admitted by the same checks a stored copy of it will face.
163
+ // A derivation that yields something `parseZenCatalogue` would refuse on the
164
+ // next launch — a NaN rate, a repeated id, a name no picker can show — is
165
+ // refused now, whole, instead of becoming this session's roster.
166
+ let catalogue: ZenCatalogue
167
+ try {
168
+ catalogue = parseZenCatalogue({
169
+ version: ZEN_CATALOGUE_VERSION,
170
+ fetchedAt: (options.fetchedAt ?? new Date()).toISOString(),
171
+ zen: carried.zen,
172
+ go: carried.go,
173
+ unrouted,
174
+ })
175
+ } catch (error) {
176
+ if (!(error instanceof ZenCatalogueFormatError)) throw error
177
+ throw new ZenCatalogueSourceError(
178
+ `The derived catalogue is not one a stored copy could be read back as: ${error.message}`,
179
+ )
180
+ }
181
+ return {
182
+ catalogue,
183
+ report: Object.freeze({
184
+ servedUndocumented: Object.freeze(servedUndocumented),
185
+ undecided: Object.freeze(undecided.map((pair) => Object.freeze(pair))),
186
+ stale: Object.freeze(stale),
187
+ orphanPrices: Object.freeze(orphanPrices),
188
+ }),
189
+ }
190
+ }
191
+
192
+ /** The shape the bundled snapshot uses: streaming stated, anonymity only when true. */
193
+ function toZenModel(model: DerivedZenModel): ZenModel {
194
+ return {
195
+ id: model.id,
196
+ name: model.name,
197
+ protocol: model.protocol,
198
+ contextWindow: model.contextWindow,
199
+ maxOutputTokens: model.maxOutputTokens,
200
+ inputModalities: [...model.inputModalities],
201
+ inputPrice: model.inputPrice,
202
+ outputPrice: model.outputPrice,
203
+ supportsToolUse: model.supportsToolUse,
204
+ supportsStreaming: true,
205
+ effortLevels: [...model.effortLevels],
206
+ ...(model.supportsAnonymousAccess ? { supportsAnonymousAccess: true } : {}),
207
+ }
208
+ }
209
+
210
+ function freezeCatalogue(catalogue: {
211
+ version: typeof ZEN_CATALOGUE_VERSION
212
+ fetchedAt: string
213
+ zen: ZenModel[]
214
+ go: ZenModel[]
215
+ unrouted: Record<ZenService, string[]>
216
+ }): ZenCatalogue {
217
+ for (const service of ['zen', 'go'] as const) {
218
+ for (const model of catalogue[service]) {
219
+ if (model.inputModalities) Object.freeze(model.inputModalities)
220
+ if (model.effortLevels) Object.freeze(model.effortLevels)
221
+ Object.freeze(model)
222
+ }
223
+ Object.freeze(catalogue[service])
224
+ Object.freeze(catalogue.unrouted[service])
225
+ }
226
+ Object.freeze(catalogue.unrouted)
227
+ return Object.freeze(catalogue)
228
+ }
229
+
230
+ const MODEL_ID = /^[a-z0-9][a-z0-9.-]*$/
231
+ const PROTOCOLS: ReadonlySet<string> = new Set<ZenProtocol>([
232
+ 'chat',
233
+ 'responses',
234
+ 'messages',
235
+ 'google',
236
+ ])
237
+ const INPUT_MODALITIES: ReadonlySet<string> = new Set<ModelInputModality>([
238
+ 'text',
239
+ 'image',
240
+ 'document',
241
+ ])
242
+ const EFFORTS: ReadonlySet<string> = new Set<ReasoningEffort>([
243
+ 'none',
244
+ 'minimal',
245
+ 'low',
246
+ 'medium',
247
+ 'high',
248
+ 'xhigh',
249
+ 'max',
250
+ 'ultra',
251
+ ])
252
+ const MODEL_KEYS: ReadonlySet<string> = new Set([
253
+ 'id',
254
+ 'name',
255
+ 'protocol',
256
+ 'contextWindow',
257
+ 'maxOutputTokens',
258
+ 'inputModalities',
259
+ 'inputPrice',
260
+ 'outputPrice',
261
+ 'supportsToolUse',
262
+ 'supportsStreaming',
263
+ 'effortLevels',
264
+ 'supportsAnonymousAccess',
265
+ ])
266
+
267
+ /** A stored catalogue that is not exactly a catalogue. */
268
+ export class ZenCatalogueFormatError extends Error {
269
+ override readonly name = 'ZenCatalogueFormatError'
270
+ }
271
+
272
+ function invalid(where: string, why: string): never {
273
+ throw new ZenCatalogueFormatError(`${where} ${why}.`)
274
+ }
275
+
276
+ function record(value: unknown, where: string): Record<string, unknown> {
277
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
278
+ invalid(where, 'is not an object')
279
+ }
280
+ return value as Record<string, unknown>
281
+ }
282
+
283
+ function onlyKeys(value: Record<string, unknown>, keys: ReadonlySet<string>, where: string): void {
284
+ for (const key of Object.keys(value)) {
285
+ if (!keys.has(key)) invalid(`${where}.${key}`, 'is not a catalogue field')
286
+ }
287
+ }
288
+
289
+ function positiveInteger(value: unknown, where: string): number {
290
+ if (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0) {
291
+ invalid(where, 'is not a positive integer')
292
+ }
293
+ return value
294
+ }
295
+
296
+ function price(value: unknown, where: string): number {
297
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
298
+ invalid(where, 'is not a non-negative price')
299
+ }
300
+ return value
301
+ }
302
+
303
+ function members<T extends string>(
304
+ value: unknown,
305
+ allowed: ReadonlySet<string>,
306
+ where: string,
307
+ nonEmpty: boolean,
308
+ ): T[] {
309
+ if (!Array.isArray(value)) invalid(where, 'is not a list')
310
+ if (nonEmpty && value.length === 0) invalid(where, 'is empty')
311
+ for (const item of value) {
312
+ if (typeof item !== 'string' || !allowed.has(item)) {
313
+ invalid(where, `names ${JSON.stringify(item)}, which is not one it may name`)
314
+ }
315
+ }
316
+ if (new Set(value).size !== value.length) invalid(where, 'repeats an entry')
317
+ return [...(value as T[])]
318
+ }
319
+
320
+ function parseModel(value: unknown, service: ZenService, where: string): ZenModel {
321
+ const raw = record(value, where)
322
+ onlyKeys(raw, MODEL_KEYS, where)
323
+ const id = raw.id
324
+ if (typeof id !== 'string' || !MODEL_ID.test(id)) invalid(`${where}.id`, 'is not a model id')
325
+ const name = raw.name
326
+ if (
327
+ typeof name !== 'string' ||
328
+ name.trim().length === 0 ||
329
+ name.length > 200 ||
330
+ !isDisplayableName(name)
331
+ ) {
332
+ invalid(`${where}.name`, 'is not a model name')
333
+ }
334
+ const protocol = raw.protocol
335
+ if (typeof protocol !== 'string' || !PROTOCOLS.has(protocol)) {
336
+ invalid(`${where}.protocol`, 'is not a Zen protocol')
337
+ }
338
+ if (typeof raw.supportsToolUse !== 'boolean') {
339
+ invalid(`${where}.supportsToolUse`, 'is not true or false')
340
+ }
341
+ if (raw.supportsStreaming !== true) invalid(`${where}.supportsStreaming`, 'is not true')
342
+ const anonymous = raw.supportsAnonymousAccess
343
+ if (anonymous !== undefined && (anonymous !== true || service !== 'zen')) {
344
+ invalid(`${where}.supportsAnonymousAccess`, 'is only ever `true`, and only on zen')
345
+ }
346
+ return {
347
+ id,
348
+ name,
349
+ protocol: protocol as ZenProtocol,
350
+ contextWindow: positiveInteger(raw.contextWindow, `${where}.contextWindow`),
351
+ maxOutputTokens: positiveInteger(raw.maxOutputTokens, `${where}.maxOutputTokens`),
352
+ inputModalities: members<ModelInputModality>(
353
+ raw.inputModalities,
354
+ INPUT_MODALITIES,
355
+ `${where}.inputModalities`,
356
+ true,
357
+ ),
358
+ inputPrice: price(raw.inputPrice, `${where}.inputPrice`),
359
+ outputPrice: price(raw.outputPrice, `${where}.outputPrice`),
360
+ supportsToolUse: raw.supportsToolUse,
361
+ supportsStreaming: true,
362
+ effortLevels: members<ReasoningEffort>(
363
+ raw.effortLevels,
364
+ EFFORTS,
365
+ `${where}.effortLevels`,
366
+ false,
367
+ ),
368
+ ...(anonymous === true ? { supportsAnonymousAccess: true } : {}),
369
+ }
370
+ }
371
+
372
+ /**
373
+ * Re-admit a catalogue a host stored — the output of `JSON.parse` on what
374
+ * `JSON.stringify(catalogue)` wrote — or throw `ZenCatalogueFormatError`.
375
+ *
376
+ * Every field is checked as strictly as a derivation checks its sources, and a
377
+ * document with one bad field is refused whole: a cache someone edited, a
378
+ * truncated write or a file from another version never becomes a partial
379
+ * roster.
380
+ */
381
+ export function parseZenCatalogue(value: unknown): ZenCatalogue {
382
+ const raw = record(value, 'catalogue')
383
+ onlyKeys(raw, new Set(['version', 'fetchedAt', 'zen', 'go', 'unrouted']), 'catalogue')
384
+ if (raw.version !== ZEN_CATALOGUE_VERSION) {
385
+ invalid('catalogue.version', `is not ${ZEN_CATALOGUE_VERSION}`)
386
+ }
387
+ const fetchedAt = raw.fetchedAt
388
+ if (
389
+ typeof fetchedAt !== 'string' ||
390
+ Number.isNaN(Date.parse(fetchedAt)) ||
391
+ new Date(fetchedAt).toISOString() !== fetchedAt
392
+ ) {
393
+ invalid('catalogue.fetchedAt', 'is not an ISO 8601 timestamp')
394
+ }
395
+ const unroutedRaw = record(raw.unrouted, 'catalogue.unrouted')
396
+ onlyKeys(unroutedRaw, new Set(['zen', 'go']), 'catalogue.unrouted')
397
+ const carried: Record<ZenService, ZenModel[]> = { zen: [], go: [] }
398
+ const unrouted: Record<ZenService, string[]> = { zen: [], go: [] }
399
+ for (const service of ['zen', 'go'] as const) {
400
+ const list = raw[service]
401
+ if (!Array.isArray(list) || list.length === 0) {
402
+ invalid(`catalogue.${service}`, 'is not a non-empty list of models')
403
+ }
404
+ carried[service] = list.map((entry, index) =>
405
+ parseModel(entry, service, `catalogue.${service}[${index}]`),
406
+ )
407
+ const ids = new Set(carried[service].map((model) => model.id))
408
+ if (ids.size !== carried[service].length) invalid(`catalogue.${service}`, 'repeats a model id')
409
+ const names = unroutedRaw[service]
410
+ if (!Array.isArray(names)) invalid(`catalogue.unrouted.${service}`, 'is not a list')
411
+ for (const id of names) {
412
+ if (typeof id !== 'string' || !MODEL_ID.test(id)) {
413
+ invalid(
414
+ `catalogue.unrouted.${service}`,
415
+ `names ${JSON.stringify(id)}, which is not a model id`,
416
+ )
417
+ }
418
+ if (ids.has(id)) invalid(`catalogue.unrouted.${service}`, `names the carried model "${id}"`)
419
+ }
420
+ if (new Set(names).size !== names.length) {
421
+ invalid(`catalogue.unrouted.${service}`, 'repeats an id')
422
+ }
423
+ unrouted[service] = [...(names as string[])]
424
+ }
425
+ return freezeCatalogue({
426
+ version: ZEN_CATALOGUE_VERSION,
427
+ fetchedAt,
428
+ zen: carried.zen,
429
+ go: carried.go,
430
+ unrouted,
431
+ })
432
+ }
433
+
434
+ /**
435
+ * Exact lookup with the runtime catalogue first and the bundled snapshot
436
+ * second. `catalogue` undefined is the bundled snapshot alone.
437
+ *
438
+ * An id the runtime catalogue lists in `unrouted` is found in neither: the
439
+ * newer sources say the service serves it and state no wire for it, and the
440
+ * bundled snapshot's older entry is not allowed to answer for them. Falling
441
+ * back would route the model on a wire nobody states any more, anonymously
442
+ * too if the old entry said so, and without saying anything.
443
+ */
444
+ export function findZenCatalogueModel(
445
+ catalogue: ZenCatalogue | undefined,
446
+ service: ZenService,
447
+ id: string,
448
+ ): ZenModel | undefined {
449
+ const runtime = catalogue?.[service].find((model) => model.id === id)
450
+ if (runtime) return runtime
451
+ if (isUnroutedZenModel(catalogue, service, id)) return undefined
452
+ return findZenModel(service, id)
453
+ }
454
+
455
+ /** Whether a runtime catalogue lists `id` as served with no known wire format. */
456
+ export function isUnroutedZenModel(
457
+ catalogue: ZenCatalogue | undefined,
458
+ service: ZenService,
459
+ id: string,
460
+ ): boolean {
461
+ return catalogue?.unrouted[service].includes(id) === true
462
+ }