@namzu/zen 1.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/prompt.ts ADDED
@@ -0,0 +1,456 @@
1
+ import { createHash } from 'node:crypto'
2
+ import type {
3
+ JSONValue,
4
+ LanguageModelV3Content,
5
+ LanguageModelV3Message,
6
+ LanguageModelV3Prompt,
7
+ LanguageModelV3ToolResultOutput,
8
+ SharedV3ProviderMetadata,
9
+ } from '@ai-sdk/provider'
10
+ import {
11
+ type AssistantMessage,
12
+ type ChatCompletionParams,
13
+ type Message,
14
+ type MessageAttachment,
15
+ ProviderRequestError,
16
+ type ProviderRoute,
17
+ type ReasoningBlock,
18
+ type ToolResultBlock,
19
+ isModelContentOmission,
20
+ } from '@namzu/sdk'
21
+ import type { ZenProtocol, ZenService } from './models.js'
22
+
23
+ type NativePart = Extract<LanguageModelV3Content, { type: 'text' | 'reasoning' | 'tool-call' }>
24
+ type AssistantContent = Extract<LanguageModelV3Message, { role: 'assistant' }>['content']
25
+ type RichOutput = Extract<LanguageModelV3ToolResultOutput, { type: 'content' }>['value']
26
+
27
+ class HistoryConversionError extends Error {}
28
+
29
+ function record(value: unknown): Record<string, unknown> | undefined {
30
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
31
+ ? (value as Record<string, unknown>)
32
+ : undefined
33
+ }
34
+
35
+ function jsonValue(value: unknown, depth = 0): value is JSONValue {
36
+ if (depth > 40) return false
37
+ if (value === null || typeof value === 'string' || typeof value === 'boolean') return true
38
+ if (typeof value === 'number') return Number.isFinite(value)
39
+ if (Array.isArray(value)) return value.every((item) => jsonValue(item, depth + 1))
40
+ const object = record(value)
41
+ return (
42
+ object !== undefined &&
43
+ (Object.getPrototypeOf(object) === Object.prototype ||
44
+ Object.getPrototypeOf(object) === null) &&
45
+ Object.values(object).every((item) => item === undefined || jsonValue(item, depth + 1))
46
+ )
47
+ }
48
+
49
+ function metadata(value: unknown): value is SharedV3ProviderMetadata | undefined {
50
+ const object = record(value)
51
+ return (
52
+ value === undefined ||
53
+ (object !== undefined &&
54
+ jsonValue(value) &&
55
+ Object.values(object).every((namespace) => record(namespace) !== undefined))
56
+ )
57
+ }
58
+
59
+ function parseNativeParts(value: unknown): NativePart[] | undefined {
60
+ if (!Array.isArray(value)) return undefined
61
+ const parts: NativePart[] = []
62
+ for (const item of value) {
63
+ const part = record(item)
64
+ if (!part || !metadata(part.providerMetadata)) return undefined
65
+ const providerMetadata = part.providerMetadata
66
+ if (part.type === 'text' || part.type === 'reasoning') {
67
+ if (typeof part.text !== 'string') return undefined
68
+ parts.push({
69
+ type: part.type,
70
+ text: part.text,
71
+ ...(providerMetadata && { providerMetadata }),
72
+ })
73
+ } else if (part.type === 'tool-call') {
74
+ if (
75
+ typeof part.toolCallId !== 'string' ||
76
+ typeof part.toolName !== 'string' ||
77
+ typeof part.input !== 'string' ||
78
+ (part.providerExecuted !== undefined && part.providerExecuted !== false)
79
+ ) {
80
+ return undefined
81
+ }
82
+ try {
83
+ parsedArguments(part.input)
84
+ } catch {
85
+ return undefined
86
+ }
87
+ parts.push({
88
+ type: 'tool-call',
89
+ toolCallId: part.toolCallId,
90
+ toolName: part.toolName,
91
+ input: part.input,
92
+ ...(providerMetadata && { providerMetadata }),
93
+ })
94
+ } else {
95
+ return undefined
96
+ }
97
+ }
98
+ return parts
99
+ }
100
+
101
+ function canonical(value: unknown): unknown {
102
+ if (Array.isArray(value)) return value.map(canonical)
103
+ const object = record(value)
104
+ if (!object) return value
105
+ return Object.fromEntries(
106
+ Object.keys(object)
107
+ .sort()
108
+ .filter((key) => object[key] !== undefined)
109
+ .map((key) => [key, canonical(object[key])]),
110
+ )
111
+ }
112
+
113
+ function digest(value: unknown): string {
114
+ return createHash('sha256')
115
+ .update(JSON.stringify(canonical(value)))
116
+ .digest('hex')
117
+ }
118
+
119
+ function sameRoute(value: unknown, route: ProviderRoute): boolean {
120
+ const candidate = record(value)
121
+ return (
122
+ candidate?.providerId === route.providerId &&
123
+ candidate.model === route.model &&
124
+ candidate.chainIndex === route.chainIndex
125
+ )
126
+ }
127
+
128
+ function prefixDigest(messages: readonly Message[]): string {
129
+ return digest(
130
+ messages.map((message) => ({
131
+ ...message,
132
+ ...(message.role === 'assistant' && {
133
+ source: message.source && {
134
+ type: message.source.type,
135
+ providerId: message.source.providerId,
136
+ model: message.source.model,
137
+ chainIndex: message.source.chainIndex,
138
+ },
139
+ }),
140
+ timestamp: undefined,
141
+ })),
142
+ )
143
+ }
144
+
145
+ function metadataString(part: LanguageModelV3Content, key: string): string | undefined {
146
+ for (const namespace of Object.values(part.providerMetadata ?? {})) {
147
+ const value = namespace[key]
148
+ if (typeof value === 'string') return value
149
+ }
150
+ return undefined
151
+ }
152
+
153
+ /** Shared by stream materialization and the durable replay correspondence guard. */
154
+ export function toReasoningBlocks(content: readonly LanguageModelV3Content[]): ReasoningBlock[] {
155
+ return content.flatMap((part): ReasoningBlock[] => {
156
+ if (part.type !== 'reasoning') return []
157
+ const redacted = metadataString(part, 'redactedData')
158
+ const signature = metadataString(part, 'signature') ?? metadataString(part, 'thoughtSignature')
159
+ const encrypted = redacted ?? metadataString(part, 'reasoningEncryptedContent')
160
+ return [
161
+ {
162
+ type: redacted === undefined ? 'thinking' : 'redacted_thinking',
163
+ text: part.text,
164
+ ...(signature !== undefined && { signature }),
165
+ ...(encrypted !== undefined && { encrypted }),
166
+ },
167
+ ]
168
+ })
169
+ }
170
+
171
+ function parsedArguments(input: string): unknown {
172
+ try {
173
+ const value: unknown = JSON.parse(input)
174
+ if (!jsonValue(value)) throw new Error('Non-JSON argument value.')
175
+ return value
176
+ } catch {
177
+ throw new HistoryConversionError(
178
+ 'Zen history contains a tool call with invalid JSON arguments.',
179
+ )
180
+ }
181
+ }
182
+
183
+ function projection(
184
+ message: Pick<AssistantMessage, 'content' | 'toolCalls' | 'reasoning'>,
185
+ ): unknown {
186
+ return {
187
+ text: message.content ?? '',
188
+ tools: (message.toolCalls ?? []).map((call) => ({
189
+ id: call.id,
190
+ name: call.function.name,
191
+ input: parsedArguments(call.function.arguments),
192
+ truncated: call.metadata?.inputTruncated === true,
193
+ })),
194
+ reasoning: (message.reasoning ?? []).map((block) => ({
195
+ type: block.type,
196
+ text: block.text ?? '',
197
+ signature: block.signature ?? null,
198
+ encrypted: block.encrypted ?? null,
199
+ })),
200
+ }
201
+ }
202
+
203
+ function nativeProjection(content: readonly NativePart[]): unknown {
204
+ return projection({
205
+ content: content
206
+ .filter((part) => part.type === 'text')
207
+ .map((part) => part.text)
208
+ .join(''),
209
+ toolCalls: content.flatMap((part) =>
210
+ part.type === 'tool-call'
211
+ ? [
212
+ {
213
+ id: part.toolCallId,
214
+ type: 'function' as const,
215
+ function: { name: part.toolName, arguments: part.input },
216
+ },
217
+ ]
218
+ : [],
219
+ ),
220
+ reasoning: toReasoningBlocks(content),
221
+ })
222
+ }
223
+
224
+ /** Save completed native parts, bound to their request and durable public projection. */
225
+ export function createReplayState(
226
+ params: ChatCompletionParams,
227
+ route: ProviderRoute,
228
+ service: ZenService,
229
+ protocol: ZenProtocol,
230
+ content: LanguageModelV3Content[],
231
+ ): unknown {
232
+ const parts = parseNativeParts(content)
233
+ if (!parts) return undefined
234
+ return JSON.parse(
235
+ JSON.stringify({
236
+ kind: 'namzu.zen.model-content',
237
+ version: 1,
238
+ route,
239
+ model: params.model,
240
+ service,
241
+ protocol,
242
+ prefixDigest: prefixDigest(params.messages),
243
+ projectionDigest: digest(nativeProjection(parts)),
244
+ contentDigest: digest(parts),
245
+ content: parts,
246
+ }),
247
+ )
248
+ }
249
+
250
+ function replayContent(
251
+ message: AssistantMessage,
252
+ params: ChatCompletionParams,
253
+ index: number,
254
+ route: ProviderRoute,
255
+ service: ZenService,
256
+ protocol: ZenProtocol,
257
+ ): AssistantContent | undefined {
258
+ if (message.source?.type !== 'model' || !sameRoute(message.source, route)) return undefined
259
+ const state = record(message.source.replayState)
260
+ if (
261
+ state?.kind !== 'namzu.zen.model-content' ||
262
+ state.version !== 1 ||
263
+ !sameRoute(state.route, route) ||
264
+ state.model !== params.model ||
265
+ state.service !== service ||
266
+ state.protocol !== protocol ||
267
+ state.prefixDigest !== prefixDigest(params.messages.slice(0, index))
268
+ )
269
+ return undefined
270
+ const content = parseNativeParts(state.content)
271
+ if (
272
+ !content ||
273
+ state.contentDigest !== digest(content) ||
274
+ state.projectionDigest !== digest(nativeProjection(content)) ||
275
+ state.projectionDigest !== digest(projection(message))
276
+ )
277
+ return undefined
278
+ return content.map((part) => {
279
+ const providerOptions =
280
+ part.providerMetadata === undefined
281
+ ? undefined
282
+ : (JSON.parse(JSON.stringify(part.providerMetadata)) as SharedV3ProviderMetadata)
283
+ if (part.type === 'tool-call') {
284
+ // The compatible adapter reads Gemini call signatures from the google namespace.
285
+ const thoughtSignature = metadataString(part, 'thoughtSignature')
286
+ if (protocol === 'chat' && providerOptions && thoughtSignature !== undefined) {
287
+ providerOptions.google = { ...providerOptions.google, thoughtSignature }
288
+ }
289
+ return {
290
+ type: 'tool-call',
291
+ toolCallId: part.toolCallId,
292
+ toolName: part.toolName,
293
+ input: parsedArguments(part.input),
294
+ ...(providerOptions && { providerOptions }),
295
+ }
296
+ }
297
+ return { type: part.type, text: part.text, ...(providerOptions && { providerOptions }) }
298
+ })
299
+ }
300
+
301
+ function attachment(
302
+ attachment: MessageAttachment,
303
+ ): Extract<LanguageModelV3Message, { role: 'user' }>['content'] {
304
+ if (attachment.type === 'stored') {
305
+ throw new HistoryConversionError(
306
+ 'Zen requires stored attachments to be resolved before model delivery.',
307
+ )
308
+ }
309
+ if (attachment.type === 'document') {
310
+ if (attachment.citations)
311
+ throw new HistoryConversionError('Zen document citations are not supported.')
312
+ return [
313
+ {
314
+ type: 'file',
315
+ data: attachment.data,
316
+ mediaType: attachment.mediaType,
317
+ ...(attachment.name && { filename: attachment.name }),
318
+ },
319
+ ]
320
+ }
321
+ if (isModelContentOmission(attachment.modelOmission)) return []
322
+ return [{ type: 'file', data: attachment.data, mediaType: attachment.mediaType }]
323
+ }
324
+
325
+ function richOutput(blocks: readonly ToolResultBlock[]): RichOutput {
326
+ return blocks.flatMap((block): RichOutput => {
327
+ if (block.type === 'text') return [{ type: 'text', text: block.text }]
328
+ if (block.type === 'image') {
329
+ if (isModelContentOmission(block.modelOmission)) return []
330
+ return [{ type: 'image-data', data: block.data, mediaType: block.mediaType }]
331
+ }
332
+ if (block.citations)
333
+ throw new HistoryConversionError('Zen document citations are not supported.')
334
+ return [
335
+ {
336
+ type: 'file-data',
337
+ data: block.data,
338
+ mediaType: block.mediaType,
339
+ ...(block.name && { filename: block.name }),
340
+ },
341
+ ]
342
+ })
343
+ }
344
+
345
+ function toolOutput(
346
+ message: Extract<Message, { role: 'tool' }>,
347
+ protocol: ZenProtocol,
348
+ ): LanguageModelV3ToolResultOutput {
349
+ const textOutput = (text: string): LanguageModelV3ToolResultOutput => ({
350
+ type: message.isError ? 'error-text' : 'text',
351
+ // Only Messages serializes error-text with a native failure flag.
352
+ value: message.isError && protocol !== 'messages' ? `Tool execution failed.\n${text}` : text,
353
+ })
354
+ if (typeof message.content === 'string') {
355
+ return textOutput(message.content)
356
+ }
357
+ const value = richOutput(message.content)
358
+ if (value.every((part) => part.type === 'text')) {
359
+ return textOutput(value.map((part) => part.text).join('\n'))
360
+ }
361
+ if (message.isError)
362
+ throw new HistoryConversionError(
363
+ 'Zen cannot preserve both rich tool content and tool failure status.',
364
+ )
365
+ if (protocol === 'chat')
366
+ throw new HistoryConversionError('Zen chat protocol does not support rich tool results.')
367
+ if (
368
+ protocol === 'messages' &&
369
+ value.some((part) => part.type === 'file-data' && part.mediaType !== 'application/pdf')
370
+ ) {
371
+ throw new HistoryConversionError(
372
+ 'Zen messages protocol supports only PDF documents in rich tool results.',
373
+ )
374
+ }
375
+ return { type: 'content', value }
376
+ }
377
+
378
+ /** Convert public history; native metadata is admitted only by an exact replay match. */
379
+ function buildModelPrompt(
380
+ params: ChatCompletionParams,
381
+ route: ProviderRoute,
382
+ service: ZenService,
383
+ protocol: ZenProtocol,
384
+ ): LanguageModelV3Prompt {
385
+ const prompt: LanguageModelV3Prompt = []
386
+ const toolNames = new Map<string, string>()
387
+ for (const [index, message] of params.messages.entries()) {
388
+ switch (message.role) {
389
+ case 'system':
390
+ prompt.push({ role: 'system', content: message.content })
391
+ break
392
+ case 'user':
393
+ prompt.push({
394
+ role: 'user',
395
+ content: [
396
+ { type: 'text', text: message.content },
397
+ ...(message.attachments ?? []).flatMap(attachment),
398
+ ],
399
+ })
400
+ break
401
+ case 'assistant': {
402
+ for (const call of message.toolCalls ?? []) toolNames.set(call.id, call.function.name)
403
+ const content = replayContent(message, params, index, route, service, protocol) ?? [
404
+ ...(message.content ? [{ type: 'text' as const, text: message.content }] : []),
405
+ ...(message.toolCalls ?? []).map((call) => ({
406
+ type: 'tool-call' as const,
407
+ toolCallId: call.id,
408
+ toolName: call.function.name,
409
+ input: parsedArguments(call.function.arguments),
410
+ })),
411
+ ]
412
+ // Google and Anthropic reject empty assistant turns after foreign reasoning is removed.
413
+ if (content.length > 0) prompt.push({ role: 'assistant', content })
414
+ break
415
+ }
416
+ case 'tool': {
417
+ const toolName = toolNames.get(message.toolCallId)
418
+ if (!toolName)
419
+ throw new HistoryConversionError('Zen tool history is missing the preceding tool call.')
420
+ prompt.push({
421
+ role: 'tool',
422
+ content: [
423
+ {
424
+ type: 'tool-result',
425
+ toolCallId: message.toolCallId,
426
+ toolName,
427
+ output: toolOutput(message, protocol),
428
+ },
429
+ ],
430
+ })
431
+ break
432
+ }
433
+ }
434
+ }
435
+ return prompt
436
+ }
437
+
438
+ export function toModelPrompt(
439
+ params: ChatCompletionParams,
440
+ route: ProviderRoute,
441
+ service: ZenService,
442
+ protocol: ZenProtocol,
443
+ ): LanguageModelV3Prompt {
444
+ try {
445
+ return buildModelPrompt(params, route, service, protocol)
446
+ } catch (error) {
447
+ throw new ProviderRequestError({
448
+ providerId: service === 'go' ? 'zen-go' : 'zen',
449
+ kind: 'bad_request',
450
+ detail:
451
+ error instanceof HistoryConversionError
452
+ ? error.message
453
+ : 'Zen cannot represent the supplied message history.',
454
+ })
455
+ }
456
+ }
package/src/types.ts ADDED
@@ -0,0 +1,29 @@
1
+ import type { ZenProtocol } from './models.js'
2
+
3
+ export interface ZenConfig {
4
+ /** Omit for documented free Zen models. The public sentinel also selects anonymous access. */
5
+ apiKey?: string
6
+ /** Stable conversation identity, shared by every turn and auxiliary request. */
7
+ sessionId?: string
8
+ /** Defaults to free Muse Spark 1.3 anonymously, or glm-5.3-flash with an API key. */
9
+ model?: string
10
+ /** Override the service base URL, primarily for a host-owned proxy. */
11
+ baseURL?: string
12
+ /** Whole request timeout in milliseconds. Defaults to 120000. */
13
+ timeout?: number
14
+ /** Explicit wire format for a model absent from the bundled catalogue. */
15
+ protocol?: ZenProtocol
16
+ }
17
+
18
+ /** Go subscriptions require a real API key; anonymous Zen access does not apply. */
19
+ export interface ZenGoConfig extends ZenConfig {
20
+ apiKey: string
21
+ }
22
+
23
+ export interface ZenProviderConfig extends ZenConfig {
24
+ type: 'zen'
25
+ }
26
+
27
+ export interface ZenGoProviderConfig extends ZenGoConfig {
28
+ type: 'zen-go'
29
+ }