@namzu/deepseek 0.1.0 → 1.1.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
@@ -1,14 +1,19 @@
1
1
  import type {
2
2
  ChatCompletionParams,
3
3
  ChatCompletionResponse,
4
+ ImageAttachment,
4
5
  LLMProvider,
5
6
  ModelInfo,
7
+ ModelInputModality,
6
8
  ProviderCapabilities,
9
+ ProviderRoute,
7
10
  ReasoningBlock,
11
+ ReasoningEffort,
8
12
  StreamChunk,
9
13
  ThinkingConfig,
10
14
  TokenUsage,
11
15
  ToolChoice,
16
+ ToolResultContent,
12
17
  } from '@namzu/sdk'
13
18
  import {
14
19
  ProviderRequestError,
@@ -16,7 +21,7 @@ import {
16
21
  isCallerAbortError,
17
22
  isProviderRequestError,
18
23
  providerVendorError,
19
- toolResultToText,
24
+ toToolResultBlocks,
20
25
  } from '@namzu/sdk'
21
26
  import OpenAI from 'openai'
22
27
  import type {
@@ -26,24 +31,29 @@ import type {
26
31
  } from 'openai/resources/chat/completions'
27
32
  import type { DeepSeekConfig } from './types.js'
28
33
 
29
- /**
30
- * No vision, no documents.
31
- *
32
- * The wire is OpenAI's Chat Completions, which has `image_url` content parts —
33
- * but the models behind this endpoint are text-only, and a capability set is a
34
- * claim about what a call will actually do, not about what the request format
35
- * can express. Claiming vision here would route an image-bearing run to a
36
- * driver that sends the image and gets prose about nothing.
37
- */
34
+ /** Driver-level mapping capabilities; model input support is listed separately. */
38
35
  export const DEEPSEEK_CAPABILITIES: ProviderCapabilities = {
39
36
  supportsTools: true,
40
37
  supportsStreaming: true,
41
38
  supportsFunctionCalling: true,
42
- supportsVision: false,
39
+ supportsVision: true,
43
40
  supportsDocuments: false,
44
41
  }
45
42
 
46
43
  const DEFAULT_BASE_URL = 'https://api.deepseek.com'
44
+ const VISION_MODEL = 'deepseek-v4-flash-vision-exp'
45
+ const TEXT_MODALITIES = Object.freeze(['text'] as const)
46
+ const VISION_MODALITIES = Object.freeze(['text', 'image'] as const)
47
+ const IMAGE_MEDIA_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif'])
48
+ const KNOWN_MODELS = Object.freeze([
49
+ { id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', inputModalities: TEXT_MODALITIES },
50
+ { id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro', inputModalities: TEXT_MODALITIES },
51
+ {
52
+ id: VISION_MODEL,
53
+ name: 'DeepSeek-V4-Flash-Vision-Exp',
54
+ inputModalities: VISION_MODALITIES,
55
+ },
56
+ ] as const)
47
57
 
48
58
  type DeepSeekFinishReason =
49
59
  | 'stop'
@@ -188,13 +198,87 @@ export function assertEffortUnsupported(params: ChatCompletionParams): void {
188
198
  }
189
199
 
190
200
  /**
191
- * The reasoning blocks an assistant message carries, in wire form.
201
+ * Adapter-private state for one completed reasoning response.
202
+ *
203
+ * The route is repeated inside the opaque envelope rather than trusted from
204
+ * `AssistantMessage.source`: durable content is authoritative, and native
205
+ * metadata is used only after the two independent records agree.
206
+ */
207
+ interface DeepSeekReplayState {
208
+ readonly kind: 'namzu-deepseek-reasoning'
209
+ readonly version: 1
210
+ readonly route: ProviderRoute
211
+ readonly reasoningContent: string
212
+ }
213
+
214
+ function sameRoute(left: ProviderRoute, right: ProviderRoute): boolean {
215
+ return (
216
+ left.providerId === right.providerId &&
217
+ left.model === right.model &&
218
+ left.chainIndex === right.chainIndex
219
+ )
220
+ }
221
+
222
+ function resolveDeepSeekRoute(model: string, candidate: ProviderRoute | undefined): ProviderRoute {
223
+ if (candidate === undefined) return { providerId: 'deepseek', model, chainIndex: 0 }
224
+ if (
225
+ candidate.providerId !== 'deepseek' ||
226
+ candidate.model !== model ||
227
+ !Number.isSafeInteger(candidate.chainIndex) ||
228
+ candidate.chainIndex < 0
229
+ ) {
230
+ throw new Error(
231
+ 'DeepSeekProvider: providerRoute must name this provider, the requested model, and a non-negative integer chainIndex.',
232
+ )
233
+ }
234
+ return candidate
235
+ }
236
+
237
+ function isDeepSeekReplayState(value: unknown): value is DeepSeekReplayState {
238
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
239
+ const state = value as Record<string, unknown>
240
+ const route = state.route
241
+ return (
242
+ state.kind === 'namzu-deepseek-reasoning' &&
243
+ state.version === 1 &&
244
+ typeof state.reasoningContent === 'string' &&
245
+ state.reasoningContent.length > 0 &&
246
+ typeof route === 'object' &&
247
+ route !== null &&
248
+ !Array.isArray(route) &&
249
+ typeof (route as Record<string, unknown>).providerId === 'string' &&
250
+ typeof (route as Record<string, unknown>).model === 'string' &&
251
+ Number.isSafeInteger((route as Record<string, unknown>).chainIndex) &&
252
+ ((route as Record<string, unknown>).chainIndex as number) >= 0
253
+ )
254
+ }
255
+
256
+ function durableDeepSeekReasoning(
257
+ reasoning: readonly ReasoningBlock[] | undefined,
258
+ ): string | undefined {
259
+ if (!reasoning || reasoning.length === 0) return undefined
260
+ if (
261
+ reasoning.some(
262
+ (block) =>
263
+ block.type !== 'thinking' ||
264
+ typeof block.text !== 'string' ||
265
+ block.signature !== undefined ||
266
+ block.encrypted !== undefined,
267
+ )
268
+ ) {
269
+ return undefined
270
+ }
271
+ const text = reasoning.map((block) => block.text as string).join('')
272
+ return text.length > 0 ? text : undefined
273
+ }
274
+
275
+ /**
276
+ * Restore `reasoning_content` only from a validated same-route envelope.
192
277
  *
193
- * The vendor's rule: with tool calls in play, an assistant turn's
194
- * `reasoning_content` **must** be replayed in every later turn of the same
195
- * flow; without tool calls it is ignored if sent. Replayed unconditionally
196
- * here, which satisfies both halves and matches what `AssistantMessage.reasoning`
197
- * already promises — "replayed verbatim and ahead of the text/tool blocks".
278
+ * The vendor's rule: with tool calls in play, an assistant turn's native
279
+ * reasoning must be echoed on later turns. Matching provider/model names are
280
+ * not enough: state can come from another chain member or another adapter
281
+ * format, and legacy messages carry no proof at all.
198
282
  *
199
283
  * Measured note, because it matters to anyone debugging this: as of
200
284
  * 2026-08-17 omitting the replay does NOT produce the documented 400 on either
@@ -202,53 +286,170 @@ export function assertEffortUnsupported(params: ChatCompletionParams): void {
202
286
  * a contract the vendor states and does not currently enforce is a contract
203
287
  * that can start being enforced in any release.
204
288
  */
205
- function replayReasoning(reasoning: readonly ReasoningBlock[] | undefined): string | undefined {
206
- if (!reasoning || reasoning.length === 0) return undefined
207
- const text = reasoning
208
- .filter((b) => b.type === 'thinking' && typeof b.text === 'string')
209
- .map((b) => b.text as string)
210
- .join('')
211
- return text.length > 0 ? text : undefined
289
+ function replayReasoning(
290
+ message: Extract<ChatCompletionParams['messages'][number], { role: 'assistant' }>,
291
+ targetRoute: ProviderRoute,
292
+ ): string | undefined {
293
+ const source = message.source
294
+ if (!source || source.type !== 'model' || !sameRoute(source, targetRoute)) return undefined
295
+ const state = source.replayState
296
+ if (!isDeepSeekReplayState(state) || !sameRoute(state.route, source)) return undefined
297
+ const durable = durableDeepSeekReasoning(message.reasoning)
298
+ return durable !== undefined && durable === state.reasoningContent ? durable : undefined
212
299
  }
213
300
 
214
301
  export function toDeepSeekMessages(
215
302
  messages: ChatCompletionParams['messages'],
303
+ targetRoute: ProviderRoute,
216
304
  ): ChatCompletionMessageParam[] {
217
- return messages.map((msg): ChatCompletionMessageParam => {
218
- if (msg.role === 'system') return { role: 'system', content: msg.content }
305
+ assertDeepSeekRichContent(messages, targetRoute.model)
306
+
307
+ type ImagePart = { type: 'image_url'; image_url: { url: string } }
308
+ type UserPart = { type: 'text'; text: string } | ImagePart
309
+ const imagePart = (image: ImageAttachment): ImagePart => ({
310
+ type: 'image_url',
311
+ image_url: { url: `data:${image.mediaType};base64,${image.data}` },
312
+ })
313
+ const wire: ChatCompletionMessageParam[] = []
314
+ let pendingToolImages: ImagePart[] = []
315
+ const flushToolImages = (): void => {
316
+ if (pendingToolImages.length === 0) return
317
+ wire.push({
318
+ role: 'user',
319
+ content: [
320
+ { type: 'text', text: 'Attached image(s) from tool result:' },
321
+ ...pendingToolImages,
322
+ ],
323
+ })
324
+ pendingToolImages = []
325
+ }
326
+
327
+ for (const msg of messages) {
328
+ if (msg.role !== 'tool') flushToolImages()
329
+ if (msg.role === 'system') {
330
+ wire.push({ role: 'system', content: msg.content })
331
+ continue
332
+ }
219
333
  if (msg.role === 'user') {
220
- // No attachment branch, deliberately. `DEEPSEEK_CAPABILITIES` says
221
- // this driver has neither vision nor documents, and a driver that
222
- // quietly encoded an image the model cannot read would answer about
223
- // a picture it never saw.
224
- if (msg.attachments && msg.attachments.length > 0) {
225
- throw new Error(
226
- 'DeepSeekProvider: this driver has no vision or document support, and a message carried attachments. Route multimodal turns to a provider whose capabilities include them.',
227
- )
334
+ if (!msg.attachments || msg.attachments.length === 0) {
335
+ wire.push({ role: 'user', content: msg.content })
336
+ continue
337
+ }
338
+ const content: UserPart[] = []
339
+ if (msg.content.length > 0) content.push({ type: 'text', text: msg.content })
340
+ for (const attachment of msg.attachments) {
341
+ // The preflight above proves these are inline images. This local check
342
+ // keeps the narrowing explicit instead of casting a public union.
343
+ if (attachment.type === 'stored' || attachment.type === 'document') continue
344
+ content.push(imagePart(attachment))
228
345
  }
229
- return { role: 'user', content: msg.content }
346
+ wire.push({ role: 'user', content })
347
+ continue
230
348
  }
231
349
  if (msg.role === 'tool') {
232
- return {
350
+ const { text, images } = splitToolResult(msg.content)
351
+ wire.push({
233
352
  role: 'tool',
234
- content: toolResultToText(msg.content),
353
+ content: text || '(no output)',
235
354
  tool_call_id: msg.toolCallId,
236
- }
355
+ })
356
+ pendingToolImages.push(...images.map(imagePart))
357
+ continue
358
+ }
359
+ const assistant: ChatCompletionMessageParam = {
360
+ role: 'assistant',
361
+ content: msg.content,
237
362
  }
238
- const assistant: ChatCompletionMessageParam = { role: 'assistant', content: msg.content }
239
363
  if ('toolCalls' in msg && msg.toolCalls && msg.toolCalls.length > 0) {
240
364
  ;(assistant as { tool_calls?: unknown }).tool_calls = msg.toolCalls.map((tc) => ({
241
365
  id: tc.id,
242
366
  type: 'function' as const,
243
- function: { name: tc.function.name, arguments: tc.function.arguments },
367
+ function: {
368
+ name: tc.function.name,
369
+ arguments: tc.function.arguments,
370
+ },
244
371
  }))
245
372
  }
246
- const replayed = replayReasoning((msg as { reasoning?: readonly ReasoningBlock[] }).reasoning)
373
+ const replayed = replayReasoning(msg, targetRoute)
247
374
  if (replayed !== undefined) {
248
375
  ;(assistant as { reasoning_content?: string }).reasoning_content = replayed
249
376
  }
250
- return assistant
251
- })
377
+ wire.push(assistant)
378
+ }
379
+ flushToolImages()
380
+ return wire
381
+ }
382
+
383
+ function splitToolResult(content: ToolResultContent): {
384
+ readonly text: string
385
+ readonly images: readonly ImageAttachment[]
386
+ } {
387
+ const text: string[] = []
388
+ const images: ImageAttachment[] = []
389
+ for (const block of toToolResultBlocks(content)) {
390
+ if (block.type === 'text') text.push(block.text)
391
+ else if (block.type === 'image') images.push(block)
392
+ }
393
+ return { text: text.join('\n'), images }
394
+ }
395
+
396
+ /**
397
+ * Refuse every rich-content shape this exact model/wire cannot represent.
398
+ *
399
+ * This runs before the request object is built, so a text model never sees an
400
+ * image data URL and a document never reaches a wire that would silently drop
401
+ * it. Stored user refs should have been resolved by the SDK attachment store;
402
+ * accepting one here would serialize a reference rather than its bytes.
403
+ */
404
+ function assertDeepSeekRichContent(
405
+ messages: ChatCompletionParams['messages'],
406
+ model: string,
407
+ ): void {
408
+ let hasImage = false
409
+ const acceptImage = (mediaType: string): void => {
410
+ if (!IMAGE_MEDIA_TYPES.has(mediaType)) {
411
+ throw new Error(
412
+ `DeepSeekProvider: image type '${mediaType}' is not supported. Use image/png, image/jpeg, image/webp, or image/gif.`,
413
+ )
414
+ }
415
+ hasImage = true
416
+ }
417
+ for (const message of messages) {
418
+ if (message.role === 'user') {
419
+ for (const attachment of message.attachments ?? []) {
420
+ if (attachment.type === 'stored') {
421
+ throw new Error(
422
+ 'DeepSeekProvider: an unresolved stored attachment reached the driver. Configure an AttachmentStore so Namzu can resolve its bytes before the model request.',
423
+ )
424
+ }
425
+ if (attachment.type === 'document') {
426
+ throw new Error(
427
+ 'DeepSeekProvider: this Chat Completions driver does not support document input. Route the turn to a document-capable provider.',
428
+ )
429
+ }
430
+ acceptImage(attachment.mediaType)
431
+ }
432
+ continue
433
+ }
434
+ if (message.role !== 'tool') continue
435
+ for (const block of toToolResultBlocks(message.content)) {
436
+ if (block.type === 'document') {
437
+ throw new Error(
438
+ 'DeepSeekProvider: this Chat Completions driver does not support document tool results. Route the turn to a document-capable provider.',
439
+ )
440
+ }
441
+ if (block.type === 'image') acceptImage(block.mediaType)
442
+ }
443
+ }
444
+ if (hasImage && model !== VISION_MODEL) {
445
+ throw new Error(
446
+ `DeepSeekProvider: model '${model}' does not accept image input. Select '${VISION_MODEL}' or remove the images.`,
447
+ )
448
+ }
449
+ }
450
+
451
+ function inputModalitiesFor(model: string): readonly ModelInputModality[] | undefined {
452
+ return KNOWN_MODELS.find((candidate) => candidate.id === model)?.inputModalities
252
453
  }
253
454
 
254
455
  export function toDeepSeekTools(params: ChatCompletionParams): ChatCompletionTool[] | undefined {
@@ -286,7 +487,10 @@ export class DeepSeekProvider implements LLMProvider {
286
487
  baseURL: config.baseURL ?? DEFAULT_BASE_URL,
287
488
  }
288
489
  if (config.timeout !== undefined) clientOptions.timeout = config.timeout
289
- clientOptions.defaultHeaders = { ...attributionHeaders(), ...(config.defaultHeaders ?? {}) }
490
+ clientOptions.defaultHeaders = {
491
+ ...attributionHeaders(),
492
+ ...(config.defaultHeaders ?? {}),
493
+ }
290
494
 
291
495
  this.client = new OpenAI(clientOptions)
292
496
  this.defaultModel = config.model
@@ -303,8 +507,14 @@ export class DeepSeekProvider implements LLMProvider {
303
507
  return model
304
508
  }
305
509
 
510
+ /** This Chat Completions endpoint explicitly carries no effort control. */
511
+ reasoningEffortLevelsFor(_model: string): readonly ReasoningEffort[] {
512
+ return []
513
+ }
514
+
306
515
  async *chatStream(params: ChatCompletionParams): AsyncIterable<StreamChunk> {
307
516
  const model = this.resolveModel(params)
517
+ const providerRoute = resolveDeepSeekRoute(model, params.providerRoute)
308
518
  assertEffortUnsupported(params)
309
519
  assertSamplingUsable(params, this.samplingMode)
310
520
 
@@ -324,7 +534,7 @@ export class DeepSeekProvider implements LLMProvider {
324
534
  // to the non-streaming overload, whose result has no async iterator.
325
535
  const body = {
326
536
  model,
327
- messages: toDeepSeekMessages(params.messages),
537
+ messages: toDeepSeekMessages(params.messages, providerRoute),
328
538
  stream: true as const,
329
539
  stream_options: { include_usage: true },
330
540
  tools: toDeepSeekTools(params),
@@ -362,6 +572,8 @@ export class DeepSeekProvider implements LLMProvider {
362
572
  // `reasoning_content` as a flat run of deltas with no index and no
363
573
  // block boundaries, so index 0 is the whole of it rather than a guess.
364
574
  let reasoningOpen = false
575
+ let reasoningContent = ''
576
+ let replayStateEmitted = false
365
577
 
366
578
  try {
367
579
  for await (const chunk of stream) {
@@ -369,7 +581,11 @@ export class DeepSeekProvider implements LLMProvider {
369
581
  try {
370
582
  const choice = chunk.choices[0]
371
583
  const delta = choice?.delta as
372
- | { content?: string | null; reasoning_content?: string | null; tool_calls?: unknown[] }
584
+ | {
585
+ content?: string | null
586
+ reasoning_content?: string | null
587
+ tool_calls?: unknown[]
588
+ }
373
589
  | undefined
374
590
 
375
591
  const toolCalls = (
@@ -398,9 +614,12 @@ export class DeepSeekProvider implements LLMProvider {
398
614
 
399
615
  if (reasoningText) {
400
616
  reasoningOpen = true
617
+ reasoningContent += reasoningText
401
618
  yield {
402
619
  id: chunk.id,
403
- delta: { reasoning: { index: 0, type: 'thinking', text: reasoningText } },
620
+ delta: {
621
+ reasoning: { index: 0, type: 'thinking', text: reasoningText },
622
+ },
404
623
  }
405
624
  }
406
625
 
@@ -413,7 +632,10 @@ export class DeepSeekProvider implements LLMProvider {
413
632
  (toolCalls !== undefined && toolCalls.length > 0)
414
633
  if (reasoningOpen && contentStarting) {
415
634
  reasoningOpen = false
416
- yield { id: chunk.id, delta: { reasoning: { index: 0, done: true } } }
635
+ yield {
636
+ id: chunk.id,
637
+ delta: { reasoning: { index: 0, done: true } },
638
+ }
417
639
  }
418
640
 
419
641
  const hasDelta =
@@ -421,8 +643,20 @@ export class DeepSeekProvider implements LLMProvider {
421
643
  (toolCalls !== undefined && toolCalls.length > 0)
422
644
  if (!hasDelta && !finishReason && !usage) continue
423
645
 
646
+ const replayState =
647
+ !replayStateEmitted && finishReason && reasoningContent.length > 0
648
+ ? ({
649
+ kind: 'namzu-deepseek-reasoning',
650
+ version: 1,
651
+ route: providerRoute,
652
+ reasoningContent,
653
+ } satisfies DeepSeekReplayState)
654
+ : undefined
655
+ if (replayState !== undefined) replayStateEmitted = true
656
+
424
657
  yield {
425
658
  id: chunk.id,
659
+ ...(replayState !== undefined ? { replayState } : {}),
426
660
  delta: { content: delta?.content ?? undefined, toolCalls },
427
661
  finishReason,
428
662
  usage,
@@ -439,7 +673,10 @@ export class DeepSeekProvider implements LLMProvider {
439
673
  // A turn that was all reasoning and no content — the model thought
440
674
  // and then stopped — still has to close its block.
441
675
  if (reasoningOpen) {
442
- yield { id: 'deepseek-reasoning-close', delta: { reasoning: { index: 0, done: true } } }
676
+ yield {
677
+ id: 'deepseek-reasoning-close',
678
+ delta: { reasoning: { index: 0, done: true } },
679
+ }
443
680
  }
444
681
  } catch (err) {
445
682
  if (isCallerAbortError(err, params.signal)) throw params.signal?.reason ?? err
@@ -448,20 +685,35 @@ export class DeepSeekProvider implements LLMProvider {
448
685
  }
449
686
  }
450
687
 
451
- async probeCredential(): Promise<void> {
452
- await this.client.models.list()
688
+ async probeCredential(signal?: AbortSignal): Promise<void> {
689
+ signal?.throwIfAborted()
690
+ await this.client.models.list(signal ? { signal } : undefined)
691
+ signal?.throwIfAborted()
453
692
  }
454
693
 
455
- async listModels(): Promise<ModelInfo[]> {
456
- const page = await this.client.models.list()
457
- return page.data.map((m) => ({
458
- id: m.id,
459
- name: m.id,
460
- inputPrice: 0,
461
- outputPrice: 0,
462
- supportsToolUse: true,
463
- supportsStreaming: true,
464
- }))
694
+ async listModels(signal?: AbortSignal): Promise<ModelInfo[]> {
695
+ signal?.throwIfAborted()
696
+ const page = await this.client.models.list(signal ? { signal } : undefined)
697
+ signal?.throwIfAborted()
698
+ const models = new Map<string, { id: string; name: string }>()
699
+ for (const model of page.data) models.set(model.id, { id: model.id, name: model.id })
700
+ // The preview model is part of the source adapter's verified default
701
+ // catalogue but may lag the account listing endpoint. A successful list
702
+ // therefore augments rather than erases the known selectable catalogue.
703
+ for (const model of KNOWN_MODELS) {
704
+ models.set(model.id, { id: model.id, name: model.name })
705
+ }
706
+ return [...models.values()].map((model) => {
707
+ const inputModalities = inputModalitiesFor(model.id)
708
+ return {
709
+ ...model,
710
+ ...(inputModalities !== undefined ? { inputModalities } : {}),
711
+ inputPrice: 0,
712
+ outputPrice: 0,
713
+ supportsToolUse: true,
714
+ supportsStreaming: true,
715
+ }
716
+ })
465
717
  }
466
718
 
467
719
  async healthCheck(): Promise<boolean> {