@namzu/zen 2.5.2 → 3.0.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/src/client.ts CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  APICallError,
8
8
  type LanguageModelV3,
9
9
  type LanguageModelV3Content,
10
+ type LanguageModelV3Prompt,
10
11
  type LanguageModelV3StreamPart,
11
12
  type LanguageModelV3Usage,
12
13
  type SharedV3ProviderMetadata,
@@ -31,9 +32,15 @@ import {
31
32
  findZenCatalogueModel,
32
33
  isUnroutedZenModel,
33
34
  } from './catalogue/catalogue.js'
35
+ import { isDirectAnonymousZenId } from './catalogue/derive.js'
34
36
  import type { ZenModel, ZenService } from './models.js'
35
37
  import { createCallOptions } from './options.js'
36
- import { createReplayState, toReasoningBlocks } from './prompt.js'
38
+ import {
39
+ createReplayState,
40
+ isGoDeepSeekV4ResponsesModel,
41
+ isMuseSparkResponsesModel,
42
+ toReasoningBlocks,
43
+ } from './prompt.js'
37
44
  import type { ZenConfig, ZenGoConfig } from './types.js'
38
45
 
39
46
  export const ZEN_CAPABILITIES: ProviderCapabilities = {
@@ -50,6 +57,219 @@ export const ZEN_CAPABILITIES: ProviderCapabilities = {
50
57
  export const ZEN_BASE_URL = 'https://opencode.ai/zen/v1'
51
58
  export const ZEN_GO_BASE_URL = 'https://opencode.ai/zen/go/v1'
52
59
 
60
+ function withoutMuseSparkEncryptedReasoning(init?: RequestInit): RequestInit {
61
+ // The pinned OpenAI adapter adds this include automatically whenever a
62
+ // reasoning model uses store:false, even if providerOptions omits it.
63
+ // Muse Spark cannot round-trip that gateway-issued ciphertext. Keep the
64
+ // request stateless and preserve every other include the adapter may add.
65
+ if (typeof init?.body !== 'string')
66
+ throw new Error('Zen Muse Spark adapter emitted a request body that cannot be checked.')
67
+ const body: unknown = JSON.parse(init.body)
68
+ if (typeof body !== 'object' || body === null || Array.isArray(body))
69
+ throw new Error('Zen Muse Spark adapter emitted an invalid request body.')
70
+ const request = body as Record<string, unknown>
71
+ if (request.include !== undefined) {
72
+ if (
73
+ !Array.isArray(request.include) ||
74
+ !request.include.every((item) => typeof item === 'string')
75
+ )
76
+ throw new Error('Zen Muse Spark adapter emitted an invalid include list.')
77
+ const include = request.include.filter((item) => item !== 'reasoning.encrypted_content')
78
+ if (include.length > 0) request.include = include
79
+ else request.include = undefined
80
+ }
81
+ return { ...init, body: JSON.stringify(request) }
82
+ }
83
+
84
+ function isGoDeepSeekV4ChatModel(service: ZenService, model: string, protocol: string): boolean {
85
+ return (
86
+ service === 'go' &&
87
+ protocol === 'chat' &&
88
+ (model === 'deepseek-v4-pro' ||
89
+ model === 'deepseek-v4.1-flash' ||
90
+ model === 'deepseek-v4-flash')
91
+ )
92
+ }
93
+
94
+ function adapterBody(init: RequestInit | undefined, model: string): Record<string, unknown> {
95
+ if (typeof init?.body !== 'string')
96
+ throw new Error(`Zen adapter emitted a non-JSON request body for ${model}.`)
97
+ const body: unknown = JSON.parse(init.body)
98
+ if (typeof body !== 'object' || body === null || Array.isArray(body))
99
+ throw new Error(`Zen adapter emitted an invalid request body for ${model}.`)
100
+ return body as Record<string, unknown>
101
+ }
102
+
103
+ function withGoDeepSeekV4ChatHistory(init: RequestInit | undefined, model: string): RequestInit {
104
+ const body = adapterBody(init, model)
105
+ if (!Array.isArray(body.messages)) throw new Error('Go DeepSeek chat request has no messages.')
106
+ for (const value of body.messages) {
107
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
108
+ throw new Error('Go DeepSeek chat request has an invalid message.')
109
+ const message = value as Record<string, unknown>
110
+ if (message.role !== 'assistant') continue
111
+ if (message.content === null) message.content = ''
112
+ if (message.reasoning_content === undefined || message.reasoning_content === null)
113
+ message.reasoning_content = ''
114
+ else if (typeof message.reasoning_content !== 'string')
115
+ throw new Error('Go DeepSeek chat request has invalid reasoning content.')
116
+ }
117
+ return { ...init, body: JSON.stringify(body) }
118
+ }
119
+
120
+ function withGoDeepSeekV4ResponsesHistory(
121
+ init: RequestInit | undefined,
122
+ model: string,
123
+ prompt: LanguageModelV3Prompt,
124
+ ): RequestInit {
125
+ const body = adapterBody(init, model)
126
+ if (!Array.isArray(body.input)) throw new Error('Go DeepSeek Responses request has no input.')
127
+ const input: Record<string, unknown>[] = []
128
+ let turn: Record<string, unknown>[] = []
129
+ const sourceTurns = prompt.filter((message) => message.role === 'assistant')
130
+ let sourceIndex = 0
131
+ const refuse = (): never => {
132
+ throw new ProviderRequestError({
133
+ providerId: 'zen-go',
134
+ kind: 'bad_request',
135
+ detail: `Go DeepSeek ${model} requires captured, nonempty reasoning_text for each previous assistant turn. This history cannot be replayed safely.`,
136
+ })
137
+ }
138
+ const flushTurn = () => {
139
+ if (turn.length === 0) return
140
+ const source = sourceTurns[sourceIndex++]
141
+ if (source?.role !== 'assistant') return refuse()
142
+ const messages = turn.filter((item) => item.role === 'assistant')
143
+ const calls = turn.filter((item) => item.type === 'function_call')
144
+ const sourceText = source.content
145
+ .filter((part) => part.type === 'text')
146
+ .map((part) => part.text)
147
+ const wireText = messages.flatMap((message) =>
148
+ Array.isArray(message.content)
149
+ ? message.content
150
+ .filter(
151
+ (part) => typeof part === 'object' && part !== null && part.type === 'output_text',
152
+ )
153
+ .map((part) => part.text)
154
+ : [],
155
+ )
156
+ const sourceCalls = source.content
157
+ .filter((part) => part.type === 'tool-call')
158
+ .map((part) => part.toolCallId)
159
+ const wireCalls = calls.map((call) => call.call_id)
160
+ if (
161
+ JSON.stringify(sourceText) !== JSON.stringify(wireText) ||
162
+ JSON.stringify(sourceCalls) !== JSON.stringify(wireCalls)
163
+ )
164
+ refuse()
165
+ const captured = source.content
166
+ .filter((part) => part.type === 'reasoning')
167
+ .map((part) => part.text)
168
+ .filter((text) => text.trim().length > 0)
169
+ .join('\n')
170
+ if (!captured) refuse()
171
+ const reasoning = {
172
+ type: 'reasoning',
173
+ summary: [],
174
+ content: [{ type: 'reasoning_text', text: captured }],
175
+ }
176
+ // The adapter can replay a native [tool-call, text] turn as a call then
177
+ // an assistant message. Go rejects a message between calls and outputs.
178
+ input.push(reasoning, ...messages, ...calls)
179
+ turn = []
180
+ }
181
+ for (const value of body.input) {
182
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
183
+ throw new Error('Go DeepSeek Responses request has an invalid input item.')
184
+ const item = value as Record<string, unknown>
185
+ if (item.type === 'reasoning' || item.type === 'function_call' || item.role === 'assistant') {
186
+ turn.push(item)
187
+ } else {
188
+ flushTurn()
189
+ input.push(item)
190
+ }
191
+ }
192
+ flushTurn()
193
+ if (sourceIndex !== sourceTurns.length) refuse()
194
+ body.input = input
195
+ return { ...init, body: JSON.stringify(body) }
196
+ }
197
+
198
+ function withGoDeepSeekReasoningTextEvents(response: Response): Response {
199
+ if (
200
+ !response.ok ||
201
+ !response.body ||
202
+ !response.headers.get('content-type')?.includes('text/event-stream')
203
+ )
204
+ return response
205
+ // @ai-sdk/openai 3.0.109 understands reasoning summary deltas but not the
206
+ // reasoning_text delta that Go DeepSeek streams. Translate that one event
207
+ // before the adapter parses SSE; the original text then enters durable replay.
208
+ const decoder = new TextDecoder()
209
+ const encoder = new TextEncoder()
210
+ let pending = ''
211
+ const rewrite = (event: string): string => {
212
+ const lines = event.split(/\r\n|\n|\r/)
213
+ const dataLines = lines.filter((line) => line.startsWith('data:'))
214
+ if (dataLines.length !== 1) return event
215
+ let value: unknown
216
+ try {
217
+ value = JSON.parse(dataLines[0]?.slice(5).trimStart() ?? '')
218
+ } catch {
219
+ return event
220
+ }
221
+ if (
222
+ typeof value !== 'object' ||
223
+ value === null ||
224
+ !('type' in value) ||
225
+ value.type !== 'response.reasoning_text.delta' ||
226
+ !('item_id' in value) ||
227
+ typeof value.item_id !== 'string' ||
228
+ !('delta' in value) ||
229
+ typeof value.delta !== 'string'
230
+ )
231
+ return event
232
+ const translated = {
233
+ ...value,
234
+ type: 'response.reasoning_summary_text.delta',
235
+ summary_index: 0,
236
+ }
237
+ return lines
238
+ .map((line) => {
239
+ if (line.startsWith('data:')) return `data: ${JSON.stringify(translated)}`
240
+ if (line.startsWith('event:')) return 'event: response.reasoning_summary_text.delta'
241
+ return line
242
+ })
243
+ .join('\n')
244
+ }
245
+ const body = response.body.pipeThrough(
246
+ new TransformStream<Uint8Array, Uint8Array>({
247
+ transform(chunk, controller) {
248
+ pending += decoder.decode(chunk, { stream: true })
249
+ while (true) {
250
+ const boundary = /\r\n\r\n|\n\n|\r\r/.exec(pending)
251
+ if (!boundary) break
252
+ const event = pending.slice(0, boundary.index)
253
+ pending = pending.slice(boundary.index + boundary[0].length)
254
+ controller.enqueue(encoder.encode(`${rewrite(event)}\n\n`))
255
+ }
256
+ },
257
+ flush(controller) {
258
+ pending += decoder.decode()
259
+ if (pending) controller.enqueue(encoder.encode(pending))
260
+ },
261
+ }),
262
+ )
263
+ const headers = new Headers(response.headers)
264
+ headers.delete('content-length')
265
+ headers.delete('content-encoding')
266
+ return new Response(body, {
267
+ status: response.status,
268
+ statusText: response.statusText,
269
+ headers,
270
+ })
271
+ }
272
+
53
273
  /**
54
274
  * The words a backend's own finish reason uses, in `LanguageModelV3FinishReason.raw`,
55
275
  * for "the model's context window, not its output-token budget, is what ran
@@ -135,12 +355,13 @@ export class ZenProvider implements LLMProvider {
135
355
 
136
356
  async *chatStream(params: ChatCompletionParams): AsyncIterable<StreamChunk> {
137
357
  const model =
138
- params.model ||
139
- this.config.model ||
140
- (this.anonymous ? 'muse-spark-1.3-contributor-free' : 'glm-5.3-flash')
358
+ params.model || this.config.model || (this.anonymous ? 'space-bunny-free' : 'glm-5.3-flash')
141
359
  const catalogue = this.runtimeCatalogue()
142
360
  const info = findZenCatalogueModel(catalogue, this.service, model)
143
- if (this.anonymous && info?.supportsAnonymousAccess !== true)
361
+ if (
362
+ this.anonymous &&
363
+ (!isDirectAnonymousZenId(model) || info?.supportsAnonymousAccess !== true)
364
+ )
144
365
  throw new ProviderRequestError({
145
366
  providerId: this.id,
146
367
  kind: 'auth',
@@ -156,7 +377,11 @@ export class ZenProvider implements LLMProvider {
156
377
  ? 'The service serves this model, but no source states its wire format. Configure protocol explicitly.'
157
378
  : 'This model has no known wire format. Update the provider or configure protocol explicitly.',
158
379
  })
159
- const route = params.providerRoute ?? { providerId: this.id, model, chainIndex: 0 }
380
+ const route = params.providerRoute ?? {
381
+ providerId: this.id,
382
+ model,
383
+ chainIndex: 0,
384
+ }
160
385
  const controller = new AbortController()
161
386
  const signal = AbortSignal.any([
162
387
  controller.signal,
@@ -168,7 +393,7 @@ export class ZenProvider implements LLMProvider {
168
393
  signal.throwIfAborted()
169
394
  let options: ReturnType<typeof createCallOptions>
170
395
  try {
171
- options = createCallOptions({ ...params, model }, route, this.service, protocol)
396
+ options = createCallOptions({ ...params, model }, route, this.service, protocol, info)
172
397
  } catch (error) {
173
398
  if (isProviderRequestError(error)) throw error
174
399
  throw new ProviderRequestError({
@@ -193,8 +418,20 @@ export class ZenProvider implements LLMProvider {
193
418
  body.generationConfig ??= {}
194
419
  delete body.generationConfig.responseSchema
195
420
  body.generationConfig.responseJsonSchema = params.responseFormat.json_schema.schema
196
- return this.requestFetch(input, { ...init, body: JSON.stringify(body) })
421
+ return this.requestFetch(input, {
422
+ ...init,
423
+ body: JSON.stringify(body),
424
+ })
197
425
  }
426
+ if (isMuseSparkResponsesModel(model, protocol))
427
+ return this.requestFetch(input, withoutMuseSparkEncryptedReasoning(init))
428
+ if (isGoDeepSeekV4ChatModel(this.service, model, protocol))
429
+ return this.requestFetch(input, withGoDeepSeekV4ChatHistory(init, model))
430
+ if (isGoDeepSeekV4ResponsesModel(this.service, model, protocol))
431
+ return this.requestFetch(
432
+ input,
433
+ withGoDeepSeekV4ResponsesHistory(init, model, options.prompt),
434
+ ).then(withGoDeepSeekReasoningTextEvents)
198
435
  return this.requestFetch(input, init)
199
436
  }) as typeof fetch,
200
437
  }
@@ -225,7 +462,10 @@ export class ZenProvider implements LLMProvider {
225
462
  const texts = new Map<string, Extract<LanguageModelV3Content, { type: 'text' }>>()
226
463
  const thoughts = new Map<
227
464
  string,
228
- { index: number; value: Extract<LanguageModelV3Content, { type: 'reasoning' }> }
465
+ {
466
+ index: number
467
+ value: Extract<LanguageModelV3Content, { type: 'reasoning' }>
468
+ }
229
469
  >()
230
470
  const tools = new Map<
231
471
  string,
@@ -290,7 +530,10 @@ export class ZenProvider implements LLMProvider {
290
530
  const index = thoughts.size
291
531
  thoughts.set(part.id, { index, value })
292
532
  content.push(value)
293
- yield { id, delta: { reasoning: { index, type: 'thinking', text: '' } } }
533
+ yield {
534
+ id,
535
+ delta: { reasoning: { index, type: 'thinking', text: '' } },
536
+ }
294
537
  break
295
538
  }
296
539
  case 'reasoning-delta':
@@ -358,7 +601,9 @@ export class ZenProvider implements LLMProvider {
358
601
  )
359
602
  yield {
360
603
  id,
361
- delta: { toolCalls: [{ index: tool.index, function: { arguments: part.delta } }] },
604
+ delta: {
605
+ toolCalls: [{ index: tool.index, function: { arguments: part.delta } }],
606
+ },
362
607
  }
363
608
  break
364
609
  }
@@ -377,7 +622,12 @@ export class ZenProvider implements LLMProvider {
377
622
  throw new Error('Provider-executed tools are not configured.')
378
623
  let tool = tools.get(part.toolCallId)
379
624
  if (!tool) {
380
- tool = { index: tools.size, input: '', ended: false, value: { ...part } }
625
+ tool = {
626
+ index: tools.size,
627
+ input: '',
628
+ ended: false,
629
+ value: { ...part },
630
+ }
381
631
  tools.set(part.toolCallId, tool)
382
632
  content.push(tool.value)
383
633
  yield {
@@ -401,7 +651,9 @@ export class ZenProvider implements LLMProvider {
401
651
  if (suffix)
402
652
  yield {
403
653
  id,
404
- delta: { toolCalls: [{ index: tool.index, function: { arguments: suffix } }] },
654
+ delta: {
655
+ toolCalls: [{ index: tool.index, function: { arguments: suffix } }],
656
+ },
405
657
  }
406
658
  tool.value.input = part.input
407
659
  tool.value.providerMetadata = mergeMetadata(
@@ -409,7 +661,12 @@ export class ZenProvider implements LLMProvider {
409
661
  part.providerMetadata,
410
662
  )
411
663
  tool.ended = true
412
- yield { id, delta: { toolCallEnd: { index: tool.index, id: part.toolCallId } } }
664
+ yield {
665
+ id,
666
+ delta: {
667
+ toolCallEnd: { index: tool.index, id: part.toolCallId },
668
+ },
669
+ }
413
670
  break
414
671
  }
415
672
  case 'finish':
@@ -493,7 +750,11 @@ export class ZenProvider implements LLMProvider {
493
750
  seen.add(item.id)
494
751
  const model = findZenCatalogueModel(catalogue, this.service, item.id)
495
752
  if (model) {
496
- if (!this.anonymous || model.supportsAnonymousAccess === true) result.push({ ...model })
753
+ if (
754
+ !this.anonymous ||
755
+ (isDirectAnonymousZenId(item.id) && model.supportsAnonymousAccess === true)
756
+ )
757
+ result.push({ ...model })
497
758
  continue
498
759
  }
499
760
  // Served, and named by the runtime catalogue as having no known wire:
@@ -603,7 +864,13 @@ export class ZenProvider implements LLMProvider {
603
864
  const envelope = safeErrorEnvelope(error)
604
865
  const body = scrub(JSON.stringify(envelope))
605
866
  if (envelope.status !== undefined)
606
- return named(providerHttpError({ providerId: this.id, status: envelope.status, body }))
867
+ return named(
868
+ providerHttpError({
869
+ providerId: this.id,
870
+ status: envelope.status,
871
+ body,
872
+ }),
873
+ )
607
874
  return named(
608
875
  providerVendorError({
609
876
  providerId: this.id,
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "omissions": {
3
3
  "go/deepseek-flash": "Served by the Go service and named by no route row on either page; models.dev carries no entry for it either, so neither a wire nor its limits can be derived.",
4
- "go/glm-5": "Served by Go and no longer routed by the Go page, which routes GLM 5.1, 5.2, 5.3 and 5.3 Flash. The Zen page still routes this id, and one service's route is not the other's, so none is taken from it.",
4
+ "go/glm-5": "Served by Go and no longer routed by the Go page, which routes GLM 5.2, 5.3 and 5.3 Flash. The Zen page still routes this id, and one service's route is not the other's, so none is taken from it.",
5
+ "go/glm-5.1": "Still served by Go, but the current Go page states neither a route nor a price for it. The /models answer states no wire, so the old bundled route is not carried without current evidence.",
5
6
  "go/grok-4.5": "Served by Go and absent from the Go page, which routes Grok 4.6. models.dev states an npm package for it, and that field is deliberately not used as a route, so none is carried.",
6
7
  "go/hy3-preview": "Served by Go and named by no route row on either page; models.dev carries no entry for it, so neither a wire nor its limits can be derived.",
7
8
  "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.",
@@ -11,7 +12,9 @@
11
12
  "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.",
12
13
  "go/minimax-m2.5": "Superseded on Go by MiniMax M2.7 and M3, which are both carried. The page still lists it.",
13
14
  "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.",
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.",
15
+ "go/qwen3.5-plus": "Served by Go and absent from the Go page, which routes Qwen 3.7 Plus, 3.8 Flash and 3.8 Max. The Zen page routes this id on Messages, and one service's route is not the other's, so none is taken from it.",
16
+ "go/qwen3.6-plus": "Still served by Go, but the current Go page states neither a route nor a price for it. The /models answer states no wire, and the old bundled Messages route conflicts with the reference client's Chat route, so it is not carried by guessing.",
17
+ "go/qwen3.7-max": "Still served by Go, but the current Go page states neither a route nor a price for it. The /models answer states no wire, so the old bundled route is not carried without current evidence.",
15
18
  "zen/claude-sonnet-4": "Served by Zen and named by no route row on either page: the Zen page routes Claude Sonnet 4.5 and 4.6. models.dev states an npm package for it, and that field is deliberately not used as a route, so none is carried.",
16
19
  "zen/deepseek-v4-flash-free": "Served by Zen and absent from the Zen page, which routes DeepSeek V4 Flash. models.dev states a name and limits for this free id and no wire, so none is carried.",
17
20
  "zen/glm-5": "Superseded by GLM 5.1, 5.2 and 5.3, which are all carried. The page still lists it.",