@namzu/zen 2.3.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 (50) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +36 -15
  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 +63 -10
  30. package/dist/models.js.map +1 -1
  31. package/dist/options.d.ts.map +1 -1
  32. package/dist/options.js +4 -1
  33. package/dist/options.js.map +1 -1
  34. package/dist/prompt.d.ts.map +1 -1
  35. package/dist/prompt.js +46 -0
  36. package/dist/prompt.js.map +1 -1
  37. package/dist/types.d.ts +11 -1
  38. package/dist/types.d.ts.map +1 -1
  39. package/package.json +6 -2
  40. package/src/catalogue/catalogue.ts +462 -0
  41. package/src/catalogue/derive.ts +638 -0
  42. package/src/catalogue/fetch.ts +229 -0
  43. package/src/catalogue/index.ts +37 -0
  44. package/src/client.ts +47 -9
  45. package/src/index.ts +2 -1
  46. package/src/models.review.json +4 -0
  47. package/src/models.ts +64 -10
  48. package/src/options.ts +4 -1
  49. package/src/prompt.ts +51 -0
  50. package/src/types.ts +11 -1
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Reading the Zen catalogue's sources over the network — the one module in
3
+ * this package that does, and only when a caller invokes it. Importing it,
4
+ * constructing a provider or reading the bundled snapshot fetches nothing.
5
+ */
6
+
7
+ import type { ZenService } from '../models.js'
8
+ import {
9
+ type BuildZenCatalogueOptions,
10
+ type ZenCatalogueResult,
11
+ type ZenCatalogueSources,
12
+ buildZenCatalogue,
13
+ } from './catalogue.js'
14
+ import { SERVICE_PREFIX, ZenCatalogueSourceError } from './derive.js'
15
+
16
+ /** The branch of OpenCode's repository the two documentation pages are read from. */
17
+ export const ZEN_DOCS_REF = 'dev'
18
+ export const ZEN_MODELS_DEV_URL = 'https://models.dev/api.json'
19
+ /** The host both services' own `/models` endpoints hang off. */
20
+ export const ZEN_SERVICE_BASE = 'https://opencode.ai'
21
+
22
+ /** Byte ceilings per source. A source past its ceiling is refused, never truncated. */
23
+ export interface ZenCatalogueLimits {
24
+ /** Each documentation page. Default 1 MiB (the live pages are ~32 KB). */
25
+ readonly pageBytes?: number
26
+ /** models.dev's whole document. Default 32 MiB (measured 4.8 MB on 2026-09-22). */
27
+ readonly modelsDevBytes?: number
28
+ /** Each `/models` answer. Default 1 MiB, the driver's own `listModels` ceiling. */
29
+ readonly servedBytes?: number
30
+ }
31
+
32
+ export interface FetchZenCatalogueOptions {
33
+ /** Defaults to the global `fetch`. */
34
+ readonly fetch?: typeof fetch
35
+ /** Aborts every outstanding read; the call then rejects with the signal's reason. */
36
+ readonly signal?: AbortSignal
37
+ /** Deadline for each source read, in milliseconds. Default 15,000. */
38
+ readonly timeoutMs?: number
39
+ /** Tries per source; a 404 is never retried. Default 1. */
40
+ readonly attempts?: number
41
+ readonly limits?: ZenCatalogueLimits
42
+ /** OpenCode branch for the two pages. Default `dev`. */
43
+ readonly docsRef?: string
44
+ readonly modelsDevUrl?: string
45
+ /** Host for the two `/models` answers. Default `https://opencode.ai`. */
46
+ readonly serviceBase?: string
47
+ }
48
+
49
+ const DEFAULT_TIMEOUT_MS = 15_000
50
+ const MIB = 1024 * 1024
51
+
52
+ function docsBase(ref: string): string {
53
+ return `https://raw.githubusercontent.com/anomalyco/opencode/${encodeURIComponent(ref)}/packages/web/src/content/docs`
54
+ }
55
+
56
+ /** The models-list endpoint of one service. */
57
+ export function zenServedUrl(service: ZenService, serviceBase = ZEN_SERVICE_BASE): string {
58
+ return `${serviceBase.replace(/\/+$/, '')}${SERVICE_PREFIX[service]}models`
59
+ }
60
+
61
+ function positive(value: number | undefined, fallback: number, name: string): number {
62
+ if (value === undefined) return fallback
63
+ if (!Number.isSafeInteger(value) || value <= 0)
64
+ throw new RangeError(`${name} must be a positive integer.`)
65
+ return value
66
+ }
67
+
68
+ async function readBounded(response: Response, limit: number, url: string): Promise<string> {
69
+ const declared = Number(response.headers.get('content-length'))
70
+ if (Number.isFinite(declared) && declared > limit) {
71
+ await response.body?.cancel().catch(() => {})
72
+ throw new ZenCatalogueSourceError(
73
+ `${url} declares ${declared} bytes, past its ${limit}-byte limit.`,
74
+ )
75
+ }
76
+ const reader = response.body?.getReader()
77
+ if (!reader) throw new ZenCatalogueSourceError(`${url} answered with no body.`)
78
+ const chunks: Uint8Array[] = []
79
+ let bytes = 0
80
+ try {
81
+ while (true) {
82
+ const next = await reader.read()
83
+ if (next.done) break
84
+ bytes += next.value.byteLength
85
+ if (bytes > limit) {
86
+ throw new ZenCatalogueSourceError(`${url} exceeds its ${limit}-byte limit.`)
87
+ }
88
+ chunks.push(next.value)
89
+ }
90
+ } finally {
91
+ await reader.cancel().catch(() => {})
92
+ reader.releaseLock()
93
+ }
94
+ const body = new Uint8Array(bytes)
95
+ let offset = 0
96
+ for (const chunk of chunks) {
97
+ body.set(chunk, offset)
98
+ offset += chunk.byteLength
99
+ }
100
+ try {
101
+ return new TextDecoder('utf-8', { fatal: true }).decode(body)
102
+ } catch {
103
+ throw new ZenCatalogueSourceError(`${url} is not UTF-8 text.`)
104
+ }
105
+ }
106
+
107
+ /**
108
+ * One source, as text, inside its deadline and its byte ceiling.
109
+ *
110
+ * Redirects are refused: every source is a fixed URL that answers directly, and
111
+ * a source that starts redirecting has moved. A 404 is a branch or a path that
112
+ * moved and is not retried; a caller's abort is not retried either.
113
+ */
114
+ export async function readZenSource(
115
+ url: string,
116
+ limit: number,
117
+ options: Pick<FetchZenCatalogueOptions, 'fetch' | 'signal' | 'timeoutMs' | 'attempts'> = {},
118
+ ): Promise<string> {
119
+ const fetchFn = options.fetch ?? globalThis.fetch
120
+ const timeoutMs = positive(options.timeoutMs, DEFAULT_TIMEOUT_MS, 'timeoutMs')
121
+ const attempts = positive(options.attempts, 1, 'attempts')
122
+ let lastError: unknown
123
+ for (let attempt = 1; attempt <= attempts; attempt++) {
124
+ options.signal?.throwIfAborted()
125
+ try {
126
+ const signal = AbortSignal.any([
127
+ AbortSignal.timeout(timeoutMs),
128
+ ...(options.signal ? [options.signal] : []),
129
+ ])
130
+ const response = await fetchFn(url, { signal, redirect: 'error' })
131
+ if (response.ok) return await readBounded(response, limit, url)
132
+ await response.body?.cancel().catch(() => {})
133
+ if (response.status === 404) {
134
+ throw new ZenCatalogueSourceError(`${url} answered 404 — the branch or the path moved.`)
135
+ }
136
+ throw new Error(`${url} answered ${response.status}.`)
137
+ } catch (error) {
138
+ if (options.signal?.aborted) throw options.signal.reason
139
+ if (error instanceof ZenCatalogueSourceError) throw error
140
+ lastError = error
141
+ if (attempt < attempts) await delay(500 * attempt, options.signal)
142
+ }
143
+ }
144
+ throw new ZenCatalogueSourceError(
145
+ `${url} could not be read after ${attempts} attempt${attempts === 1 ? '' : 's'}: ${
146
+ lastError instanceof Error ? lastError.message : String(lastError)
147
+ }`,
148
+ )
149
+ }
150
+
151
+ function delay(ms: number, signal: AbortSignal | undefined): Promise<void> {
152
+ return new Promise((resolve, reject) => {
153
+ const timer = setTimeout(done, ms)
154
+ function done() {
155
+ signal?.removeEventListener('abort', onAbort)
156
+ resolve()
157
+ }
158
+ function onAbort() {
159
+ clearTimeout(timer)
160
+ reject(signal?.reason)
161
+ }
162
+ signal?.addEventListener('abort', onAbort, { once: true })
163
+ })
164
+ }
165
+
166
+ /** The two `/models` answers, as text. */
167
+ export async function fetchZenServedRosters(
168
+ options: FetchZenCatalogueOptions = {},
169
+ ): Promise<Record<ZenService, string>> {
170
+ const limit = positive(options.limits?.servedBytes, MIB, 'limits.servedBytes')
171
+ const [zen, go] = await Promise.all([
172
+ readZenSource(zenServedUrl('zen', options.serviceBase), limit, options),
173
+ readZenSource(zenServedUrl('go', options.serviceBase), limit, options),
174
+ ])
175
+ return { zen, go }
176
+ }
177
+
178
+ /**
179
+ * All five sources, as text: the two documentation pages, models.dev and the
180
+ * two `/models` answers. Rejects on the first source that cannot be read.
181
+ */
182
+ export async function fetchZenCatalogueSources(
183
+ options: FetchZenCatalogueOptions = {},
184
+ ): Promise<ZenCatalogueSources> {
185
+ const pageLimit = positive(options.limits?.pageBytes, MIB, 'limits.pageBytes')
186
+ const modelsDevLimit = positive(options.limits?.modelsDevBytes, 32 * MIB, 'limits.modelsDevBytes')
187
+ const base = docsBase(options.docsRef ?? ZEN_DOCS_REF)
188
+ const failFast = new AbortController()
189
+ const scoped: FetchZenCatalogueOptions = {
190
+ ...options,
191
+ signal: AbortSignal.any([failFast.signal, ...(options.signal ? [options.signal] : [])]),
192
+ }
193
+ const read = <T>(promise: Promise<T>): Promise<T> =>
194
+ promise.catch((error) => {
195
+ // One unreadable source decides the result, so the others stop
196
+ // downloading rather than finishing for nothing.
197
+ failFast.abort(error)
198
+ throw error
199
+ })
200
+ try {
201
+ const [zen, go, modelsDev, served] = await Promise.all([
202
+ read(readZenSource(`${base}/zen.mdx`, pageLimit, scoped)),
203
+ read(readZenSource(`${base}/go.mdx`, pageLimit, scoped)),
204
+ read(readZenSource(options.modelsDevUrl ?? ZEN_MODELS_DEV_URL, modelsDevLimit, scoped)),
205
+ read(fetchZenServedRosters(scoped)),
206
+ ])
207
+ return { docs: { zen, go }, modelsDev, served }
208
+ } catch (error) {
209
+ if (options.signal?.aborted) throw options.signal.reason
210
+ // A sibling aborted by `failFast` rejects with the first failure as its
211
+ // reason, which is the error worth reporting.
212
+ throw failFast.signal.aborted ? failFast.signal.reason : error
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Read every source and derive a catalogue from them, or reject.
218
+ *
219
+ * Opt-in by construction: nothing in this package calls it. A rejection is a
220
+ * `ZenCatalogueSourceError` for a source that could not be read or no longer
221
+ * parses, or the caller's abort reason; there is no partial result.
222
+ */
223
+ export async function fetchZenCatalogue(
224
+ options: FetchZenCatalogueOptions & BuildZenCatalogueOptions = {},
225
+ ): Promise<ZenCatalogueResult> {
226
+ const sources = await fetchZenCatalogueSources(options)
227
+ options.signal?.throwIfAborted()
228
+ return buildZenCatalogue(sources, options)
229
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * `@namzu/zen/catalogue`: derive the Zen and Go roster at run time.
3
+ *
4
+ * Opt-in. Nothing here runs unless called, and only `fetchZenCatalogue` and
5
+ * `fetchZenCatalogueSources` touch the network. A host that wants a fresher
6
+ * roster than the bundled snapshot calls `fetchZenCatalogue()`, stores the
7
+ * result if it likes, and hands it to `ZenProvider` through
8
+ * `ZenConfig.catalogue`.
9
+ *
10
+ * The parsing rules themselves (`./derive.js`) are not part of this subpath:
11
+ * they are shared with the repository's snapshot generator, which imports the
12
+ * built module directly, and are free to change shape between releases.
13
+ */
14
+
15
+ export {
16
+ type BuildZenCatalogueOptions,
17
+ ZEN_CATALOGUE_VERSION,
18
+ type ZenCatalogue,
19
+ ZenCatalogueFormatError,
20
+ type ZenCatalogueReport,
21
+ type ZenCatalogueResult,
22
+ type ZenCatalogueSources,
23
+ buildZenCatalogue,
24
+ findZenCatalogueModel,
25
+ isUnroutedZenModel,
26
+ parseZenCatalogue,
27
+ } from './catalogue.js'
28
+ export { ZenCatalogueSourceError, type ZenOmissions } from './derive.js'
29
+ export {
30
+ type FetchZenCatalogueOptions,
31
+ ZEN_DOCS_REF,
32
+ ZEN_MODELS_DEV_URL,
33
+ ZEN_SERVICE_BASE,
34
+ type ZenCatalogueLimits,
35
+ fetchZenCatalogue,
36
+ fetchZenCatalogueSources,
37
+ } from './fetch.js'
package/src/client.ts CHANGED
@@ -26,7 +26,12 @@ import {
26
26
  providerHttpError,
27
27
  providerVendorError,
28
28
  } from '@namzu/sdk'
29
- import { type ZenService, findZenModel } from './models.js'
29
+ import {
30
+ type ZenCatalogue,
31
+ findZenCatalogueModel,
32
+ isUnroutedZenModel,
33
+ } from './catalogue/catalogue.js'
34
+ import type { ZenModel, ZenService } from './models.js'
30
35
  import { createCallOptions } from './options.js'
31
36
  import { createReplayState, toReasoningBlocks } from './prompt.js'
32
37
  import type { ZenConfig, ZenGoConfig } from './types.js'
@@ -64,6 +69,12 @@ export class ZenProvider implements LLMProvider {
64
69
  if (service !== 'zen' && service !== 'go') throw new Error('Unknown service.')
65
70
  if (config.protocol && !['chat', 'responses', 'messages', 'google'].includes(config.protocol))
66
71
  throw new Error('Unknown model protocol.')
72
+ if (
73
+ config.catalogue !== undefined &&
74
+ typeof config.catalogue !== 'function' &&
75
+ (typeof config.catalogue !== 'object' || config.catalogue === null)
76
+ )
77
+ throw new Error('catalogue must be a Zen catalogue or a function returning one.')
67
78
  this.id = service === 'go' ? 'zen-go' : 'zen'
68
79
  this.name = service === 'go' ? 'Zen Go' : 'Zen'
69
80
  if (config.apiKey !== undefined && typeof config.apiKey !== 'string')
@@ -111,7 +122,8 @@ export class ZenProvider implements LLMProvider {
111
122
  params.model ||
112
123
  this.config.model ||
113
124
  (this.anonymous ? 'muse-spark-1.3-contributor-free' : 'glm-5.3-flash')
114
- const info = findZenModel(this.service, model)
125
+ const catalogue = this.runtimeCatalogue()
126
+ const info = findZenCatalogueModel(catalogue, this.service, model)
115
127
  if (this.anonymous && info?.supportsAnonymousAccess !== true)
116
128
  throw new ProviderRequestError({
117
129
  providerId: this.id,
@@ -124,8 +136,9 @@ export class ZenProvider implements LLMProvider {
124
136
  throw new ProviderRequestError({
125
137
  providerId: this.id,
126
138
  kind: 'bad_request',
127
- detail:
128
- 'This model has no known wire format. Update the provider or configure protocol explicitly.',
139
+ detail: isUnroutedZenModel(catalogue, this.service, model)
140
+ ? 'The service serves this model, but no source states its wire format. Configure protocol explicitly.'
141
+ : 'This model has no known wire format. Update the provider or configure protocol explicitly.',
129
142
  })
130
143
  const route = params.providerRoute ?? { providerId: this.id, model, chainIndex: 0 }
131
144
  const controller = new AbortController()
@@ -452,13 +465,27 @@ export class ZenProvider implements LLMProvider {
452
465
  throw new Error('The model catalogue returned an invalid response.')
453
466
  const result: ModelInfo[] = []
454
467
  const seen = new Set<string>()
468
+ const catalogue = this.runtimeCatalogue()
455
469
  for (const item of body.data) {
456
470
  if (!item || typeof item !== 'object' || typeof item.id !== 'string' || seen.has(item.id))
457
471
  continue
458
472
  seen.add(item.id)
459
- const model = findZenModel(this.service, item.id)
460
- if (model && (!this.anonymous || model.supportsAnonymousAccess === true))
461
- result.push({ ...model })
473
+ const model = findZenCatalogueModel(catalogue, this.service, item.id)
474
+ if (model) {
475
+ if (!this.anonymous || model.supportsAnonymousAccess === true) result.push({ ...model })
476
+ continue
477
+ }
478
+ // Served, and named by the runtime catalogue as having no known wire:
479
+ // listed as exactly that, so an operator can see it exists, and
480
+ // callable only with an explicit protocol. Never anonymous, and
481
+ // nothing about it — price, limits, tools — is invented.
482
+ if (!this.anonymous && isUnroutedZenModel(catalogue, this.service, item.id))
483
+ result.push({
484
+ id: item.id,
485
+ name: `${item.id} (no known wire format)`,
486
+ supportsToolUse: false,
487
+ supportsStreaming: true,
488
+ })
462
489
  }
463
490
  return result
464
491
  } catch (error) {
@@ -472,12 +499,23 @@ export class ZenProvider implements LLMProvider {
472
499
  model: string,
473
500
  _thinking?: ThinkingConfig,
474
501
  ): readonly ReasoningEffort[] | undefined {
475
- return findZenModel(this.service, model)?.effortLevels
502
+ return this.model(model)?.effortLevels
476
503
  }
477
504
 
478
505
  async resolveContextWindow(model: string, signal?: AbortSignal): Promise<number | undefined> {
479
506
  signal?.throwIfAborted()
480
- return findZenModel(this.service, model)?.contextWindow
507
+ return this.model(model)?.contextWindow
508
+ }
509
+
510
+ /** The injected runtime catalogue as it stands now, or undefined for the bundled one alone. */
511
+ private runtimeCatalogue(): ZenCatalogue | undefined {
512
+ const source = this.config.catalogue
513
+ return typeof source === 'function' ? source() : source
514
+ }
515
+
516
+ /** Exact lookup: the runtime catalogue first, then the bundled snapshot. */
517
+ private model(id: string): ZenModel | undefined {
518
+ return findZenCatalogueModel(this.runtimeCatalogue(), this.service, id)
481
519
  }
482
520
 
483
521
  /**
package/src/index.ts CHANGED
@@ -25,8 +25,9 @@ export {
25
25
  ZenGoProvider,
26
26
  } from './client.js'
27
27
  export type { ZenConfig, ZenGoConfig, ZenProviderConfig, ZenGoProviderConfig } from './types.js'
28
- export { getZenModels, findZenModel } from './models.js'
28
+ export { ZEN_OMITTED_MODELS, getZenModels, findZenModel } from './models.js'
29
29
  export type { ZenModel, ZenProtocol, ZenService } from './models.js'
30
+ export type { ZenCatalogue } from './catalogue/catalogue.js'
30
31
  export type {
31
32
  ChatCompletionParams,
32
33
  ModelInfo,
@@ -7,6 +7,8 @@
7
7
  "go/kimi-k2.5": "Served by Go and absent from the Go page, which routes Kimi K2.6, K2.7 Code and K3. The Zen page still routes this id, and one service's route is not the other's, so none is taken from it.",
8
8
  "go/mimo-v2-omni": "Served by Go and absent from the Go page, which routes MiMo V2.5 and V2.5 Pro. models.dev states a name and limits for it and no wire, so none is carried.",
9
9
  "go/mimo-v2-pro": "Served by Go and absent from the Go page, which routes MiMo V2.5 and V2.5 Pro. models.dev states a name and limits for it and no wire, so none is carried.",
10
+ "go/mimo-v2.6-flash": "Routed by the Go page since 2026-09-21, and models.dev carries no `opencode-go` entry for it yet, so its limits cannot be derived and it is not carried.",
11
+ "go/mimo-v2.6-pro": "Routed by the Go page since 2026-09-21, and models.dev carries no `opencode-go` entry for it yet, so its limits cannot be derived and it is not carried.",
10
12
  "go/minimax-m2.5": "Superseded on Go by MiniMax M2.7 and M3, which are both carried. The page still lists it.",
11
13
  "go/omen-alpha": "Served by Go and no longer routed by the Go page, which is why this release dropped it from the catalogue. No source states a wire for it any more, so a caller that needs it must name the protocol itself.",
12
14
  "go/qwen3.5-plus": "Served by Go and absent from the Go page, which routes Qwen 3.6 Plus and later. The Zen page routes this id on Messages, and one service's route is not the other's, so none is taken from it.",
@@ -18,9 +20,11 @@
18
20
  "zen/gpt-5.1-codex-max": "Superseded Codex generation; gpt-5.3-codex and gpt-5.3-codex-spark are carried.",
19
21
  "zen/gpt-5.1-codex-mini": "Superseded Codex generation; gpt-5.3-codex and gpt-5.3-codex-spark are carried.",
20
22
  "zen/gpt-5.2-codex": "Superseded Codex generation; gpt-5.3-codex and gpt-5.3-codex-spark are carried.",
23
+ "zen/grok-4.7": "Routed by the Zen page since 2026-09-21, and models.dev carries no `opencode` entry for it yet, so its limits cannot be derived and it is not carried.",
21
24
  "zen/jev-1.13": "Routed by the Zen page on an endpoint whose AI SDK package column carries a dash, the marker that page uses for a cell with no value: no wire is stated for it, so none can be carried. The service serves the id, and models.dev carries a name and limits for it and no package either.",
22
25
  "zen/jev-1.13-free": "Routed by the Zen page on an endpoint whose AI SDK package column carries a dash, the marker that page uses for a cell with no value: no wire is stated for it, so none can be carried. The service serves the id and the page's free-model list names it, and models.dev carries a name and limits for it and no package either.",
23
26
  "zen/kimi-k2.5": "Superseded by Kimi K2.6, K2.7 Code and K3, which are all carried.",
27
+ "zen/mimo-v2.6-flash-free": "Routed by the Zen page since 2026-09-21, and models.dev carries no `opencode` entry for it yet, so its limits cannot be derived and it is not carried.",
24
28
  "zen/minimax-m2.5": "Superseded by MiniMax M2.7 and M3, which are both carried.",
25
29
  "zen/muse-spark-1.2-contributor-free": "Served by Zen under an id no page routes: the Zen page's free-model list names the 1.3 contributor model, and Go's 1.2 contributor row is a different id on the other service. models.dev states an npm package for it, and that field is deliberately not used as a route, so none is carried.",
26
30
  "zen/qwen3.7-max": "models.dev carries no entry for it, so its context window, limits and tool support cannot be derived and are never invented.",
package/src/models.ts CHANGED
@@ -29,13 +29,18 @@ export interface ZenModel {
29
29
  /*
30
30
  * GENERATED FILE — do not edit. Regenerate with:
31
31
  *
32
- * node scripts/generate-zen-models.mjs
32
+ * pnpm --filter @namzu/zen build && node scripts/generate-zen-models.mjs
33
33
  *
34
34
  * A curation decision is an edit to src/models.review.json, and how a field is
35
- * derived is an edit to the script. Both survive regeneration; a hand edit here
36
- * does not, and the CI gate "Zen catalogue matches its source" fails on one.
35
+ * derived is an edit to src/catalogue/derive.ts. Both survive regeneration; a
36
+ * hand edit here does not.
37
37
  *
38
- * Refreshed: 2026-09-19
38
+ * Refreshed: 2026-09-21
39
+ *
40
+ * This is the BUNDLED snapshot: what the driver knows with no network. A host
41
+ * can derive a fresher roster at run time from the same sources, by the same
42
+ * rules, through `@namzu/zen/catalogue` and hand it to the provider as
43
+ * `ZenConfig.catalogue`; the CLI does that in the background on every launch.
39
44
  *
40
45
  * Routes come from each service's own documentation page, as the pair
41
46
  * (endpoint, AI SDK package) that page states per model; both halves must agree
@@ -53,12 +58,11 @@ export interface ZenModel {
53
58
  *
54
59
  * Not every model either service serves is carried here. A model that is
55
60
  * documented and not carried is omitted by name, with a reason, in
56
- * src/models.review.json — and the CI gate fails on any documented model that
57
- * is neither carried nor omitted, so a new upstream model is a decision someone
58
- * makes rather than a row that arrives by itself. The gate also reads what the
59
- * two services' own `/models` answers say they serve: an id served and
60
- * documented nowhere has no derivable wire, so it is reported as a decision
61
- * too, rather than dropped by the driver in silence.
61
+ * src/models.review.json, and the generator refuses to write while a
62
+ * documented model is neither carried nor omitted. It also reads what the two
63
+ * services' own `/models` answers say they serve: an id served and documented
64
+ * nowhere has no derivable wire, so it is reported as a decision too, rather
65
+ * than dropped by the driver in silence.
62
66
  *
63
67
  * Prices are estimates, not invoices: context tiers, cache, Go peak/off-peak
64
68
  * rates, subscription allowances and promotions can change the effective cost.
@@ -887,6 +891,19 @@ const ZEN_MODELS = freezeModels([
887
891
  ])
888
892
 
889
893
  const GO_MODELS = freezeModels([
894
+ {
895
+ id: 'grok-4.7',
896
+ name: 'Grok 4.7',
897
+ protocol: 'responses',
898
+ contextWindow: 500000,
899
+ maxOutputTokens: 500000,
900
+ inputModalities: ['text', 'image', 'document'],
901
+ inputPrice: 2.0,
902
+ outputPrice: 6.0,
903
+ supportsToolUse: true,
904
+ supportsStreaming: true,
905
+ effortLevels: ['low', 'medium', 'high', 'xhigh'],
906
+ },
890
907
  {
891
908
  id: 'grok-4.6',
892
909
  name: 'Grok 4.6',
@@ -1240,6 +1257,43 @@ const GO_MODELS = freezeModels([
1240
1257
  },
1241
1258
  ])
1242
1259
 
1260
+ /**
1261
+ * Reviewed omissions, as `service/id`: models a source names that this
1262
+ * snapshot deliberately does not carry. A runtime refresh through
1263
+ * `@namzu/zen/catalogue` honours the same decisions by default.
1264
+ */
1265
+ export const ZEN_OMITTED_MODELS: readonly string[] = Object.freeze([
1266
+ 'go/deepseek-flash',
1267
+ 'go/glm-5',
1268
+ 'go/grok-4.5',
1269
+ 'go/hy3-preview',
1270
+ 'go/kimi-k2.5',
1271
+ 'go/mimo-v2-omni',
1272
+ 'go/mimo-v2-pro',
1273
+ 'go/mimo-v2.6-flash',
1274
+ 'go/mimo-v2.6-pro',
1275
+ 'go/minimax-m2.5',
1276
+ 'go/omen-alpha',
1277
+ 'go/qwen3.5-plus',
1278
+ 'zen/claude-sonnet-4',
1279
+ 'zen/deepseek-v4-flash-free',
1280
+ 'zen/glm-5',
1281
+ 'zen/gpt-5-codex',
1282
+ 'zen/gpt-5.1-codex',
1283
+ 'zen/gpt-5.1-codex-max',
1284
+ 'zen/gpt-5.1-codex-mini',
1285
+ 'zen/gpt-5.2-codex',
1286
+ 'zen/grok-4.7',
1287
+ 'zen/jev-1.13',
1288
+ 'zen/jev-1.13-free',
1289
+ 'zen/kimi-k2.5',
1290
+ 'zen/mimo-v2.6-flash-free',
1291
+ 'zen/minimax-m2.5',
1292
+ 'zen/muse-spark-1.2-contributor-free',
1293
+ 'zen/qwen3.7-max',
1294
+ 'zen/qwen3.7-plus',
1295
+ ])
1296
+
1243
1297
  /** Supported metadata; actual account availability is established by live discovery. */
1244
1298
  export function getZenModels(service: ZenService): readonly ZenModel[] {
1245
1299
  return service === 'zen' ? ZEN_MODELS : GO_MODELS
package/src/options.ts CHANGED
@@ -177,7 +177,10 @@ export function createCallOptions(
177
177
  ...(params.parallelToolCalls !== undefined
178
178
  ? { disableParallelToolUse: !params.parallelToolCalls }
179
179
  : {}),
180
- ...(params.cacheControl ? { cacheControl: { type: 'ephemeral' } } : {}),
180
+ // Cache breakpoints are block-level, placed by `toModelPrompt`:
181
+ // a request-level `cacheControl` would become Anthropic's
182
+ // automatic caching, whose breakpoint lands on the request-only
183
+ // context at the tail.
181
184
  }
182
185
  break
183
186
  }
package/src/prompt.ts CHANGED
@@ -384,9 +384,13 @@ function buildModelPrompt(
384
384
  ): LanguageModelV3Prompt {
385
385
  const prompt: LanguageModelV3Prompt = []
386
386
  const toolNames = new Map<string, string>()
387
+ let lastStatic = -1
388
+ let historyEnd: number | undefined
387
389
  for (const [index, message] of params.messages.entries()) {
390
+ if (isRequestOnlyContext(message)) historyEnd ??= prompt.length
388
391
  switch (message.role) {
389
392
  case 'system':
393
+ if (message.cacheHint === 'cache') lastStatic = prompt.length
390
394
  prompt.push({ role: 'system', content: message.content })
391
395
  break
392
396
  case 'user':
@@ -432,9 +436,56 @@ function buildModelPrompt(
432
436
  }
433
437
  }
434
438
  }
439
+ if (protocol === 'messages' && params.cacheControl)
440
+ applyCacheBreakpoints(prompt, lastStatic, historyEnd ?? prompt.length)
435
441
  return prompt
436
442
  }
437
443
 
444
+ /**
445
+ * Request-only context the runtime appends after the conversation: a
446
+ * runtime-context user message of kind `step-context`. It changes from one
447
+ * request to the next. The same test as the Anthropic driver's.
448
+ */
449
+ function isRequestOnlyContext(message: Message): boolean {
450
+ return (
451
+ message.role === 'user' &&
452
+ message.source?.type === 'runtime-context' &&
453
+ message.source.kind === 'step-context'
454
+ )
455
+ }
456
+
457
+ const BREAKPOINT = { anthropic: { cacheControl: { type: 'ephemeral' } } } as const
458
+
459
+ /**
460
+ * Messages-protocol cache breakpoints, block-level: after the last static
461
+ * system message, and on the last non-system message before request-only
462
+ * context. The native adapter turns a message's `providerOptions` into
463
+ * `cache_control` on that message's last content block.
464
+ *
465
+ * Replaces the request-level `cacheControl` option, which the adapter sends
466
+ * as a top-level `cache_control` — Anthropic's automatic caching, which
467
+ * "applies the cache breakpoint to the last cacheable block"
468
+ * (platform.claude.com/docs/en/build-with-claude/prompt-caching). With
469
+ * request-only context at the tail that block is the context, which the
470
+ * next request replaces, so no later request read the cached history.
471
+ * System messages are passed over when walking back: a trailing system
472
+ * message is a step's guidance, which changes from step to step as well.
473
+ */
474
+ function applyCacheBreakpoints(
475
+ prompt: LanguageModelV3Prompt,
476
+ lastStatic: number,
477
+ historyEnd: number,
478
+ ): void {
479
+ const staticMessage = lastStatic >= 0 ? prompt[lastStatic] : undefined
480
+ if (staticMessage) staticMessage.providerOptions = { ...BREAKPOINT }
481
+ for (let i = historyEnd - 1; i > lastStatic; i--) {
482
+ const message = prompt[i]
483
+ if (!message || message.role === 'system' || message.content.length === 0) continue
484
+ message.providerOptions = { ...BREAKPOINT }
485
+ return
486
+ }
487
+ }
488
+
438
489
  export function toModelPrompt(
439
490
  params: ChatCompletionParams,
440
491
  route: ProviderRoute,
package/src/types.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { ZenCatalogue } from './catalogue/catalogue.js'
1
2
  import type { ZenProtocol } from './models.js'
2
3
 
3
4
  export interface ZenConfig {
@@ -11,8 +12,17 @@ export interface ZenConfig {
11
12
  baseURL?: string
12
13
  /** Whole request timeout in milliseconds. Defaults to 120000. */
13
14
  timeout?: number
14
- /** Explicit wire format for a model absent from the bundled catalogue. */
15
+ /** Explicit wire format for a model absent from the catalogue. */
15
16
  protocol?: ZenProtocol
17
+ /**
18
+ * A runtime catalogue, from `@namzu/zen/catalogue`, consulted before the
19
+ * bundled snapshot for every lookup: routing, anonymous admission, listing,
20
+ * context windows and effort levels. A function is called at each lookup, so
21
+ * a host can swap in a fresher catalogue for providers it already built;
22
+ * returning `undefined` means the bundled snapshot alone. Omitted, the
23
+ * provider uses the bundled snapshot and nothing is ever fetched for it.
24
+ */
25
+ catalogue?: ZenCatalogue | (() => ZenCatalogue | undefined)
16
26
  }
17
27
 
18
28
  /** Go subscriptions require a real API key; anonymous Zen access does not apply. */