@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.
- package/CHANGELOG.md +30 -0
- package/README.md +36 -15
- package/dist/catalogue/catalogue.d.ts +106 -0
- package/dist/catalogue/catalogue.d.ts.map +1 -0
- package/dist/catalogue/catalogue.js +325 -0
- package/dist/catalogue/catalogue.js.map +1 -0
- package/dist/catalogue/derive.d.ts +203 -0
- package/dist/catalogue/derive.d.ts.map +1 -0
- package/dist/catalogue/derive.js +485 -0
- package/dist/catalogue/derive.js.map +1 -0
- package/dist/catalogue/fetch.d.ts +63 -0
- package/dist/catalogue/fetch.d.ts.map +1 -0
- package/dist/catalogue/fetch.js +180 -0
- package/dist/catalogue/fetch.js.map +1 -0
- package/dist/catalogue/index.d.ts +17 -0
- package/dist/catalogue/index.d.ts.map +1 -0
- package/dist/catalogue/index.js +17 -0
- package/dist/catalogue/index.js.map +1 -0
- package/dist/client.d.ts +5 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +39 -8
- package/dist/client.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/models.d.ts +6 -0
- package/dist/models.d.ts.map +1 -1
- package/dist/models.js +63 -10
- package/dist/models.js.map +1 -1
- package/dist/options.d.ts.map +1 -1
- package/dist/options.js +4 -1
- package/dist/options.js.map +1 -1
- package/dist/prompt.d.ts.map +1 -1
- package/dist/prompt.js +46 -0
- package/dist/prompt.js.map +1 -1
- package/dist/types.d.ts +11 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +6 -2
- package/src/catalogue/catalogue.ts +462 -0
- package/src/catalogue/derive.ts +638 -0
- package/src/catalogue/fetch.ts +229 -0
- package/src/catalogue/index.ts +37 -0
- package/src/client.ts +47 -9
- package/src/index.ts +2 -1
- package/src/models.review.json +4 -0
- package/src/models.ts +64 -10
- package/src/options.ts +4 -1
- package/src/prompt.ts +51 -0
- 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
|
+
}
|