@namzu/zen 2.5.0 → 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,235 @@ 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
+
273
+ /**
274
+ * The words a backend's own finish reason uses, in `LanguageModelV3FinishReason.raw`,
275
+ * for "the model's context window, not its output-token budget, is what ran
276
+ * out" — Anthropic's `model_context_window_exceeded` (folded, with
277
+ * `max_tokens`, into `unified: 'length'` by `@ai-sdk/anthropic`'s
278
+ * `mapAnthropicStopReason`) and the words other OpenAI-compatible backends
279
+ * use (see `@namzu/http`'s `mapOpenAIFinish`), since Zen also routes through
280
+ * `@ai-sdk/openai-compatible`.
281
+ */
282
+ const CONTEXT_WINDOW_RAW_FINISH_REASONS = new Set([
283
+ 'model_context_window_exceeded',
284
+ 'model_length',
285
+ 'context_length',
286
+ 'context_length_exceeded',
287
+ ])
288
+
53
289
  /** A provider instance belongs to one conversation; it never owns an agent loop. */
54
290
  export class ZenProvider implements LLMProvider {
55
291
  readonly id: 'zen' | 'zen-go'
@@ -119,12 +355,13 @@ export class ZenProvider implements LLMProvider {
119
355
 
120
356
  async *chatStream(params: ChatCompletionParams): AsyncIterable<StreamChunk> {
121
357
  const model =
122
- params.model ||
123
- this.config.model ||
124
- (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')
125
359
  const catalogue = this.runtimeCatalogue()
126
360
  const info = findZenCatalogueModel(catalogue, this.service, model)
127
- if (this.anonymous && info?.supportsAnonymousAccess !== true)
361
+ if (
362
+ this.anonymous &&
363
+ (!isDirectAnonymousZenId(model) || info?.supportsAnonymousAccess !== true)
364
+ )
128
365
  throw new ProviderRequestError({
129
366
  providerId: this.id,
130
367
  kind: 'auth',
@@ -140,7 +377,11 @@ export class ZenProvider implements LLMProvider {
140
377
  ? 'The service serves this model, but no source states its wire format. Configure protocol explicitly.'
141
378
  : 'This model has no known wire format. Update the provider or configure protocol explicitly.',
142
379
  })
143
- 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
+ }
144
385
  const controller = new AbortController()
145
386
  const signal = AbortSignal.any([
146
387
  controller.signal,
@@ -152,7 +393,7 @@ export class ZenProvider implements LLMProvider {
152
393
  signal.throwIfAborted()
153
394
  let options: ReturnType<typeof createCallOptions>
154
395
  try {
155
- options = createCallOptions({ ...params, model }, route, this.service, protocol)
396
+ options = createCallOptions({ ...params, model }, route, this.service, protocol, info)
156
397
  } catch (error) {
157
398
  if (isProviderRequestError(error)) throw error
158
399
  throw new ProviderRequestError({
@@ -177,8 +418,20 @@ export class ZenProvider implements LLMProvider {
177
418
  body.generationConfig ??= {}
178
419
  delete body.generationConfig.responseSchema
179
420
  body.generationConfig.responseJsonSchema = params.responseFormat.json_schema.schema
180
- return this.requestFetch(input, { ...init, body: JSON.stringify(body) })
421
+ return this.requestFetch(input, {
422
+ ...init,
423
+ body: JSON.stringify(body),
424
+ })
181
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)
182
435
  return this.requestFetch(input, init)
183
436
  }) as typeof fetch,
184
437
  }
@@ -209,7 +462,10 @@ export class ZenProvider implements LLMProvider {
209
462
  const texts = new Map<string, Extract<LanguageModelV3Content, { type: 'text' }>>()
210
463
  const thoughts = new Map<
211
464
  string,
212
- { index: number; value: Extract<LanguageModelV3Content, { type: 'reasoning' }> }
465
+ {
466
+ index: number
467
+ value: Extract<LanguageModelV3Content, { type: 'reasoning' }>
468
+ }
213
469
  >()
214
470
  const tools = new Map<
215
471
  string,
@@ -274,7 +530,10 @@ export class ZenProvider implements LLMProvider {
274
530
  const index = thoughts.size
275
531
  thoughts.set(part.id, { index, value })
276
532
  content.push(value)
277
- yield { id, delta: { reasoning: { index, type: 'thinking', text: '' } } }
533
+ yield {
534
+ id,
535
+ delta: { reasoning: { index, type: 'thinking', text: '' } },
536
+ }
278
537
  break
279
538
  }
280
539
  case 'reasoning-delta':
@@ -342,7 +601,9 @@ export class ZenProvider implements LLMProvider {
342
601
  )
343
602
  yield {
344
603
  id,
345
- delta: { toolCalls: [{ index: tool.index, function: { arguments: part.delta } }] },
604
+ delta: {
605
+ toolCalls: [{ index: tool.index, function: { arguments: part.delta } }],
606
+ },
346
607
  }
347
608
  break
348
609
  }
@@ -361,7 +622,12 @@ export class ZenProvider implements LLMProvider {
361
622
  throw new Error('Provider-executed tools are not configured.')
362
623
  let tool = tools.get(part.toolCallId)
363
624
  if (!tool) {
364
- tool = { index: tools.size, input: '', ended: false, value: { ...part } }
625
+ tool = {
626
+ index: tools.size,
627
+ input: '',
628
+ ended: false,
629
+ value: { ...part },
630
+ }
365
631
  tools.set(part.toolCallId, tool)
366
632
  content.push(tool.value)
367
633
  yield {
@@ -385,7 +651,9 @@ export class ZenProvider implements LLMProvider {
385
651
  if (suffix)
386
652
  yield {
387
653
  id,
388
- delta: { toolCalls: [{ index: tool.index, function: { arguments: suffix } }] },
654
+ delta: {
655
+ toolCalls: [{ index: tool.index, function: { arguments: suffix } }],
656
+ },
389
657
  }
390
658
  tool.value.input = part.input
391
659
  tool.value.providerMetadata = mergeMetadata(
@@ -393,7 +661,12 @@ export class ZenProvider implements LLMProvider {
393
661
  part.providerMetadata,
394
662
  )
395
663
  tool.ended = true
396
- 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
+ }
397
670
  break
398
671
  }
399
672
  case 'finish':
@@ -423,6 +696,11 @@ export class ZenProvider implements LLMProvider {
423
696
  id,
424
697
  delta: {},
425
698
  finishReason,
699
+ ...(finishReason === 'length' &&
700
+ finish.finishReason.raw &&
701
+ CONTEXT_WINDOW_RAW_FINISH_REASONS.has(finish.finishReason.raw)
702
+ ? { finishDetail: 'context_window' as const }
703
+ : {}),
426
704
  usage: mapUsage(finish.usage),
427
705
  replayState: createReplayState(
428
706
  { ...params, model },
@@ -472,7 +750,11 @@ export class ZenProvider implements LLMProvider {
472
750
  seen.add(item.id)
473
751
  const model = findZenCatalogueModel(catalogue, this.service, item.id)
474
752
  if (model) {
475
- 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 })
476
758
  continue
477
759
  }
478
760
  // Served, and named by the runtime catalogue as having no known wire:
@@ -582,7 +864,13 @@ export class ZenProvider implements LLMProvider {
582
864
  const envelope = safeErrorEnvelope(error)
583
865
  const body = scrub(JSON.stringify(envelope))
584
866
  if (envelope.status !== undefined)
585
- 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
+ )
586
874
  return named(
587
875
  providerVendorError({
588
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.",