@molecule/api-ai-deepseek 1.0.11 → 1.0.13

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.
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Uses the DeepSeek API (OpenAI-compatible) for streaming chat completions with
5
5
  * tool use and reasoning. DeepSeek's Chat Completions endpoint mirrors OpenAI's
6
- * wire format (tool_calls, reasoning_content, prompt_tokens_details.cached_tokens),
6
+ * wire format (tool_calls, reasoning_content, native and nested cache usage),
7
7
  * so this provider shares its shape with the xAI provider.
8
8
  *
9
9
  * @module
@@ -62,8 +62,20 @@ declare class DeepseekAIProviderImpl implements AIProvider {
62
62
  * Converts internal `ChatMessage` objects to the OpenAI-compatible message format.
63
63
  * System prompt is prepended as a system role message.
64
64
  *
65
+ * Thinking mode has one wire obligation that non-thinking mode does not:
66
+ * DeepSeek's thinking-mode guide requires the `reasoning_content` of every
67
+ * retained assistant turn to be sent back on a later request carrying `tools`,
68
+ * including turns where the model made no tool call — omitting it is a 400.
69
+ * So when `replayReasoning` is set (the caller passed a `thinking` block), each
70
+ * assistant turn carries `ChatMessage.reasoning` verbatim. That reasoning is
71
+ * never synthesized: a turn with no recorded reasoning sends `''`, which is
72
+ * the only representation of "this turn ran without thinking" the wire has.
73
+ * A non-thinking request keeps the historical shape (no `reasoning_content`);
74
+ * the guide says a non-thinking request ignores the field anyway.
75
+ *
65
76
  * @param messages - The chat messages to format.
66
77
  * @param system - Optional system prompt to prepend.
78
+ * @param replayReasoning - Whether thinking mode is on for this request.
67
79
  * @returns The formatted messages array for the DeepSeek API.
68
80
  */
69
81
  private formatMessages;
@@ -81,6 +93,15 @@ declare class DeepseekAIProviderImpl implements AIProvider {
81
93
  * @yields {ChatEvent} Chat events parsed from the response.
82
94
  */
83
95
  private parseNonStreamingResponse;
96
+ /**
97
+ * Normalize native usage once for streaming and JSON responses. Native flat
98
+ * counters take precedence, including zero; other compatible hosts may expose
99
+ * only the nested count. `inputTokens` excludes cache hits in the core contract.
100
+ *
101
+ * @param usage - The latest upstream counters.
102
+ * @returns Uncached input, output and cached input without double-counting.
103
+ */
104
+ private parseUsage;
84
105
  /**
85
106
  * Parses a streaming SSE response from the DeepSeek API (OpenAI-compatible
86
107
  * format), yielding `ChatEvent` objects as they arrive.
@@ -1 +1 @@
1
- {"version":3,"file":"provider.d.ts","sourceRoot":"","sources":["../src/provider.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAIH,OAAO,KAAK,EACV,UAAU,EAIV,SAAS,EAET,UAAU,EAGX,MAAM,kBAAkB,CAAA;AASzB,OAAO,cAAc,CAAA;AAErB,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,YAAY,CAAA;AAsGhD,8DAA8D;AAC9D,eAAO,MAAM,8BAA8B,QAAS,CAAA;AAgGpD;;;;GAIG;AACH,cAAM,sBAAuB,YAAW,UAAU;IAChD,QAAQ,CAAC,IAAI,cAAa;IAC1B,OAAO,CAAC,MAAM,CAAQ;IACtB,OAAO,CAAC,YAAY,CAAQ;IAC5B,OAAO,CAAC,SAAS,CAAQ;IACzB,OAAO,CAAC,OAAO,CAAQ;IACvB,OAAO,CAAC,eAAe,CAAQ;IAC/B,OAAO,CAAC,QAAQ,CAAwB;IACxC,OAAO,CAAC,WAAW,CAAC,CAAqB;IACzC,OAAO,CAAC,aAAa,CAAC,CAAuB;IAC7C,OAAO,CAAC,mBAAmB,CAAQ;IACnC,OAAO,CAAC,sBAAsB,CAAQ;gBAE1B,MAAM,GAAE,cAAmB;IAwBvC;;;;;OAKG;IACI,IAAI,CAAC,MAAM,EAAE,UAAU,GAAG,aAAa,CAAC,SAAS,CAAC;IAiRzD;;;;;;;;;OASG;YACW,2BAA2B;IA0EzC;;;;;;;OAOG;IACH,OAAO,CAAC,iBAAiB;IA2BzB;;;;;;;OAOG;IACH,OAAO,CAAC,cAAc;IAgFtB;;;;;OAKG;IACH,OAAO,CAAC,WAAW;IAWnB;;;;;OAKG;IACH,OAAO,CAAE,yBAAyB;IAuDlC;;;;;;;;;;;;OAYG;YACY,sBAAsB;IA2GrC;;;;;;;;;;OAUG;IACH,OAAO,CAAE,eAAe;CA0LzB;AAED;;GAEG;AACH,eAAO,MAAM,kBAAkB,+BAAyB,CAAA;AAExD;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,MAAM,CAAC,EAAE,cAAc,GAAG,UAAU,CAElE"}
1
+ {"version":3,"file":"provider.d.ts","sourceRoot":"","sources":["../src/provider.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAIH,OAAO,KAAK,EACV,UAAU,EAIV,SAAS,EAET,UAAU,EAIX,MAAM,kBAAkB,CAAA;AASzB,OAAO,cAAc,CAAA;AAErB,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,YAAY,CAAA;AAgHhD,8DAA8D;AAC9D,eAAO,MAAM,8BAA8B,QAAS,CAAA;AAgGpD;;;;GAIG;AACH,cAAM,sBAAuB,YAAW,UAAU;IAChD,QAAQ,CAAC,IAAI,cAAa;IAC1B,OAAO,CAAC,MAAM,CAAQ;IACtB,OAAO,CAAC,YAAY,CAAQ;IAC5B,OAAO,CAAC,SAAS,CAAQ;IACzB,OAAO,CAAC,OAAO,CAAQ;IACvB,OAAO,CAAC,eAAe,CAAQ;IAC/B,OAAO,CAAC,QAAQ,CAAwB;IACxC,OAAO,CAAC,WAAW,CAAC,CAAqB;IACzC,OAAO,CAAC,aAAa,CAAC,CAAuB;IAC7C,OAAO,CAAC,mBAAmB,CAAQ;IACnC,OAAO,CAAC,sBAAsB,CAAQ;gBAE1B,MAAM,GAAE,cAAmB;IAwBvC;;;;;OAKG;IACI,IAAI,CAAC,MAAM,EAAE,UAAU,GAAG,aAAa,CAAC,SAAS,CAAC;IAoRzD;;;;;;;;;OASG;YACW,2BAA2B;IA0EzC;;;;;;;OAOG;IACH,OAAO,CAAC,iBAAiB;IA2BzB;;;;;;;;;;;;;;;;;;;OAmBG;IACH,OAAO,CAAC,cAAc;IA8FtB;;;;;OAKG;IACH,OAAO,CAAC,WAAW;IAWnB;;;;;OAKG;IACH,OAAO,CAAE,yBAAyB;IA+ElC;;;;;;;OAOG;IACH,OAAO,CAAC,UAAU;IAUlB;;;;;;;;;;;;OAYG;YACY,sBAAsB;IA2GrC;;;;;;;;;;OAUG;IACH,OAAO,CAAE,eAAe;CAwOzB;AAED;;GAEG;AACH,eAAO,MAAM,kBAAkB,+BAAyB,CAAA;AAExD;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,MAAM,CAAC,EAAE,cAAc,GAAG,UAAU,CAElE"}
package/dist/provider.js CHANGED
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Uses the DeepSeek API (OpenAI-compatible) for streaming chat completions with
5
5
  * tool use and reasoning. DeepSeek's Chat Completions endpoint mirrors OpenAI's
6
- * wire format (tool_calls, reasoning_content, prompt_tokens_details.cached_tokens),
6
+ * wire format (tool_calls, reasoning_content, native and nested cache usage),
7
7
  * so this provider shares its shape with the xAI provider.
8
8
  *
9
9
  * @module
@@ -17,9 +17,11 @@ const logger = getLogger();
17
17
  // (not through the package barrel).
18
18
  import './secrets.js';
19
19
  /**
20
- * Map thinking budget tokens to a DeepSeek reasoning_effort level. DeepSeek V4
21
- * accepts only `'high'` or `'max'` for `reasoning_effort`, so larger budgets map
22
- * to `'max'` and everything else to `'high'`.
20
+ * Map thinking budget tokens to a DeepSeek reasoning_effort level, used ONLY as
21
+ * a fallback when the caller supplies a budget without a resolved native level.
22
+ * The native API accepts `low`/`high`/`max`; a caller that resolved one of them
23
+ * (the catalog's `supportedEffortLevels`) sends it verbatim and never reaches
24
+ * this function, so the fallback stays the two coarse buckets.
23
25
  *
24
26
  * @param budgetTokens - Requested thinking budget in tokens.
25
27
  * @returns `'high'` or `'max'` for the upstream effort hint.
@@ -149,7 +151,7 @@ class DeepseekAIProviderImpl {
149
151
  const canonicalModel = params.model ?? this.defaultModel;
150
152
  const model = this.modelMap[canonicalModel] ?? canonicalModel;
151
153
  const maxTokens = params.maxTokens ?? this.maxTokens;
152
- const messages = this.formatMessages(params.messages, params.system);
154
+ const messages = this.formatMessages(params.messages, params.system, Boolean(params.thinking));
153
155
  // Stop sequences, capped at the API's own limit. Verified honoured by the
154
156
  // native endpoint on 2026-10-17: with `stop: ['<analysis>']` a reply that
155
157
  // would have opened with that tag streamed nothing at all, and one that
@@ -188,10 +190,11 @@ class DeepseekAIProviderImpl {
188
190
  // weaker nudge still produces a turn, and the agent loop has its own
189
191
  // prose-only backstops, whereas a 400 produces nothing at all.
190
192
  //
191
- // Latent rather than live at the time of writing — no DeepSeek model in
192
- // the catalog sets supportsThinking — but the model-health cron adds
193
- // models to that catalog unattended, and the one place forceToolUse fires
194
- // is discovery mode, i.e. the FIRST turn of every new conversation.
193
+ // LIVE for an entry that exposes opt-in reasoning (deepseek-flash, whose
194
+ // effort levels include a local `off` switch): a project that uses
195
+ // low/high/max gets thinking mode on a discovery turn, and the forced
196
+ // nudge is degraded here instead of 400ing the first turn of the
197
+ // conversation.
195
198
  const forcedChoice = params.toolChoice === 'required' ||
196
199
  (typeof params.toolChoice === 'object' && params.toolChoice?.type === 'tool');
197
200
  if (forcedChoice && params.thinking) {
@@ -206,15 +209,18 @@ class DeepseekAIProviderImpl {
206
209
  body.tool_choice = { type: 'function', function: { name: params.toolChoice.name } };
207
210
  }
208
211
  }
209
- // DeepSeek V4 defaults `thinking` to ENABLED. Thinking mode requires the
210
- // assistant's reasoning_content to be echoed back on every subsequent turn
211
- // (a 400 otherwise) and is awkward to combine with the tool-calling loop, so
212
- // we run non-thinking unless a thinking budget is explicitly requested. The
213
- // chat handler gates that on the model's `supportsThinking` flag.
212
+ // DeepSeek V4 defaults `thinking` to ENABLED. Per DeepSeek's thinking-mode
213
+ // guide (https://api-docs.deepseek.com/guides/thinking_mode/), a
214
+ // thinking-mode request that carries tools must send every retained
215
+ // assistant turn's `reasoning_content` back — including a turn where the
216
+ // model made no tool call — or the API answers 400. `formatMessages` replays
217
+ // `ChatMessage.reasoning` for exactly that reason. The catalog decides
218
+ // whether a turn carries a thinking block. No block explicitly disables
219
+ // reasoning below; a low/high/max block enables that native effort.
214
220
  if (params.thinking) {
215
221
  body.thinking = { type: 'enabled' };
216
- // Prefer a caller-resolved native value ('high' | 'max') from the model
217
- // catalog; fall back to the budget threshold.
222
+ // Prefer a caller-resolved native value (`low` | `high` | `max`) from the
223
+ // model catalog; fall back to the budget threshold.
218
224
  body.reasoning_effort = params.thinking.effort ?? budgetToEffort(params.thinking.budgetTokens);
219
225
  }
220
226
  else {
@@ -345,9 +351,8 @@ class DeepseekAIProviderImpl {
345
351
  return;
346
352
  }
347
353
  const state = {
348
- inputTokens: 0,
349
- outputTokens: 0,
350
- cachedTokens: 0,
354
+ finishReason: null,
355
+ usage: {},
351
356
  keepAlives: 0,
352
357
  streamedChars: 0,
353
358
  contentSeen: false,
@@ -518,11 +523,23 @@ class DeepseekAIProviderImpl {
518
523
  * Converts internal `ChatMessage` objects to the OpenAI-compatible message format.
519
524
  * System prompt is prepended as a system role message.
520
525
  *
526
+ * Thinking mode has one wire obligation that non-thinking mode does not:
527
+ * DeepSeek's thinking-mode guide requires the `reasoning_content` of every
528
+ * retained assistant turn to be sent back on a later request carrying `tools`,
529
+ * including turns where the model made no tool call — omitting it is a 400.
530
+ * So when `replayReasoning` is set (the caller passed a `thinking` block), each
531
+ * assistant turn carries `ChatMessage.reasoning` verbatim. That reasoning is
532
+ * never synthesized: a turn with no recorded reasoning sends `''`, which is
533
+ * the only representation of "this turn ran without thinking" the wire has.
534
+ * A non-thinking request keeps the historical shape (no `reasoning_content`);
535
+ * the guide says a non-thinking request ignores the field anyway.
536
+ *
521
537
  * @param messages - The chat messages to format.
522
538
  * @param system - Optional system prompt to prepend.
539
+ * @param replayReasoning - Whether thinking mode is on for this request.
523
540
  * @returns The formatted messages array for the DeepSeek API.
524
541
  */
525
- formatMessages(messages, system) {
542
+ formatMessages(messages, system, replayReasoning = false) {
526
543
  const formatted = [];
527
544
  if (system) {
528
545
  formatted.push({ role: 'system', content: system });
@@ -530,8 +547,14 @@ class DeepseekAIProviderImpl {
530
547
  for (const m of messages) {
531
548
  if (m.role === 'system')
532
549
  continue; // handled above
550
+ // Only assistant turns have a reasoning channel. `reasoning_content: ''`
551
+ // is deliberate for a retained turn with no recorded reasoning (a
552
+ // non-thinking turn inside a thinking-mode history): the vendor requires
553
+ // the field on every retained assistant turn, and an empty string is the
554
+ // only way to say "nothing was recorded" without inventing content.
555
+ const reasoningField = replayReasoning && m.role === 'assistant' ? { reasoning_content: m.reasoning ?? '' } : {};
533
556
  if (typeof m.content === 'string') {
534
- formatted.push({ role: m.role, content: m.content });
557
+ formatted.push({ role: m.role, content: m.content, ...reasoningField });
535
558
  continue;
536
559
  }
537
560
  // Content blocks — convert to OpenAI format
@@ -578,13 +601,14 @@ class DeepseekAIProviderImpl {
578
601
  // screenshot tool) must not be dropped: Chat Completions accepts a user
579
602
  // message after the tool messages, so emit them instead of losing them.
580
603
  if (parts.length > 0)
581
- formatted.push({ role: m.role, content: parts });
604
+ formatted.push({ role: m.role, content: parts, ...reasoningField });
582
605
  }
583
606
  else if (toolCalls.length > 0) {
584
607
  // Assistant message with tool calls
585
608
  formatted.push({
586
609
  role: 'assistant',
587
610
  content: parts.length > 0 ? parts : null,
611
+ ...reasoningField,
588
612
  tool_calls: toolCalls,
589
613
  });
590
614
  }
@@ -592,6 +616,7 @@ class DeepseekAIProviderImpl {
592
616
  formatted.push({
593
617
  role: m.role,
594
618
  content: parts.length === 1 && parts[0].type === 'text' ? parts[0].text : parts,
619
+ ...reasoningField,
595
620
  });
596
621
  }
597
622
  }
@@ -621,48 +646,90 @@ class DeepseekAIProviderImpl {
621
646
  */
622
647
  *parseNonStreamingResponse(data) {
623
648
  const choices = data.choices;
624
- if (choices?.length) {
625
- const message = choices[0].message;
626
- if (message) {
627
- // Reasoning/thinking content
628
- if (message.reasoning_content && typeof message.reasoning_content === 'string') {
629
- yield { type: 'thinking', content: message.reasoning_content };
630
- }
631
- if (message.content && typeof message.content === 'string') {
632
- yield { type: 'text', content: message.content };
633
- }
634
- const toolCalls = message.tool_calls;
635
- if (toolCalls) {
636
- for (const tc of toolCalls) {
637
- const fn = tc.function;
638
- let input = {};
639
- try {
640
- input = JSON.parse(fn.arguments);
641
- }
642
- catch (_error) {
643
- // JSON.parse failed — fall back to empty object; malformed args are non-fatal.
644
- }
645
- yield {
646
- type: 'tool_use',
647
- id: tc.id,
648
- name: fn.name,
649
- input,
650
- };
649
+ const choice = choices?.[0];
650
+ const message = choice?.message;
651
+ if (!message) {
652
+ logger.warn('DeepSeek response has no assistant message');
653
+ yield { type: 'error', message: t('ai.error.apiError'), errorKey: 'ai.error.apiError' };
654
+ return;
655
+ }
656
+ // Reasoning/thinking content
657
+ if (message.reasoning_content && typeof message.reasoning_content === 'string') {
658
+ yield { type: 'thinking', content: message.reasoning_content };
659
+ }
660
+ if (message.content && typeof message.content === 'string') {
661
+ yield { type: 'text', content: message.content };
662
+ }
663
+ const finishReason = choice?.finish_reason;
664
+ if (finishReason !== 'stop' && finishReason !== 'tool_calls') {
665
+ logger.warn('DeepSeek response did not complete', { finishReason });
666
+ yield {
667
+ type: 'error',
668
+ message: t('ai.error.apiError', undefined, {
669
+ defaultValue: finishReason === 'length'
670
+ ? 'AI response reached the output token limit before completion.'
671
+ : 'AI response ended before completion. Please try again.',
672
+ }),
673
+ errorKey: 'ai.error.apiError',
674
+ };
675
+ return;
676
+ }
677
+ const toolCalls = message.tool_calls;
678
+ if (finishReason === 'tool_calls' && !toolCalls?.length) {
679
+ logger.warn('DeepSeek finished tool use without a tool call');
680
+ yield { type: 'error', message: t('ai.error.apiError'), errorKey: 'ai.error.apiError' };
681
+ return;
682
+ }
683
+ if (toolCalls) {
684
+ const completedTools = [];
685
+ for (const tc of toolCalls) {
686
+ const fn = tc.function;
687
+ let input;
688
+ try {
689
+ input = JSON.parse(fn.arguments);
690
+ if (typeof tc.id !== 'string' ||
691
+ !tc.id ||
692
+ typeof fn.name !== 'string' ||
693
+ !fn.name ||
694
+ !input ||
695
+ typeof input !== 'object' ||
696
+ Array.isArray(input)) {
697
+ throw new Error('Tool call must have an identity and JSON object arguments');
651
698
  }
652
699
  }
700
+ catch (error) {
701
+ logger.warn('DeepSeek completed a tool call with invalid arguments', { error });
702
+ yield { type: 'error', message: t('ai.error.apiError'), errorKey: 'ai.error.apiError' };
703
+ return;
704
+ }
705
+ completedTools.push({
706
+ type: 'tool_use',
707
+ id: tc.id,
708
+ name: fn.name,
709
+ input,
710
+ });
653
711
  }
712
+ yield* completedTools;
654
713
  }
655
- const usage = data.usage;
656
- const cached = usage?.prompt_tokens_details?.cached_tokens ?? 0;
657
714
  yield {
658
715
  type: 'done',
659
- usage: {
660
- // Report uncached input + cached separately (Anthropic semantics) so the
661
- // consumer's context/cost math doesn't double-count the cached subset.
662
- inputTokens: Math.max(0, (usage?.prompt_tokens ?? 0) - cached),
663
- outputTokens: usage?.completion_tokens ?? 0,
664
- cacheReadInputTokens: cached,
665
- },
716
+ usage: this.parseUsage((data.usage ?? {})),
717
+ };
718
+ }
719
+ /**
720
+ * Normalize native usage once for streaming and JSON responses. Native flat
721
+ * counters take precedence, including zero; other compatible hosts may expose
722
+ * only the nested count. `inputTokens` excludes cache hits in the core contract.
723
+ *
724
+ * @param usage - The latest upstream counters.
725
+ * @returns Uncached input, output and cached input without double-counting.
726
+ */
727
+ parseUsage(usage) {
728
+ const cached = usage.prompt_cache_hit_tokens ?? usage.prompt_tokens_details?.cached_tokens ?? 0;
729
+ return {
730
+ inputTokens: usage.prompt_cache_miss_tokens ?? Math.max(0, (usage.prompt_tokens ?? 0) - cached),
731
+ outputTokens: usage.completion_tokens ?? 0,
732
+ cacheReadInputTokens: cached,
666
733
  };
667
734
  }
668
735
  /**
@@ -747,19 +814,22 @@ class DeepseekAIProviderImpl {
747
814
  return;
748
815
  }
749
816
  }
750
- // Emit any tool calls still pending after stream ends (safety net for missing finish_reason)
751
- if (pendingTools.size > 0) {
752
- for (const [, pending] of pendingTools) {
753
- let input = {};
754
- try {
755
- input = JSON.parse(pending.args);
756
- }
757
- catch (_error) {
758
- // JSON.parse failed — fall back to empty object; malformed args are non-fatal.
759
- }
760
- yield { type: 'tool_use', id: pending.id, name: pending.name, input };
761
- }
762
- pendingTools.clear();
817
+ // A clean socket EOF can still cut off a response or tool arguments. Never
818
+ // turn that into executable `{}` arguments or a successful completion.
819
+ if (!state.finishReason || pendingTools.size > 0) {
820
+ logger.warn('DeepSeek stream ended before an explicit completion', {
821
+ finishReason: state.finishReason,
822
+ pendingTools: pendingTools.size,
823
+ requestId: call.meta.requestId,
824
+ });
825
+ yield {
826
+ type: 'error',
827
+ message: t('ai.error.apiError', undefined, {
828
+ defaultValue: 'AI response ended before completion. Please try again.',
829
+ }),
830
+ errorKey: 'ai.error.apiError',
831
+ };
832
+ return;
763
833
  }
764
834
  }
765
835
  finally {
@@ -767,11 +837,7 @@ class DeepseekAIProviderImpl {
767
837
  }
768
838
  yield {
769
839
  type: 'done',
770
- usage: {
771
- inputTokens: Math.max(0, state.inputTokens - state.cachedTokens),
772
- outputTokens: state.outputTokens,
773
- cacheReadInputTokens: state.cachedTokens,
774
- },
840
+ usage: this.parseUsage(state.usage),
775
841
  provider: call.meta,
776
842
  };
777
843
  }
@@ -871,20 +937,22 @@ class DeepseekAIProviderImpl {
871
937
  // Usage info (may appear in the final chunk)
872
938
  const usage = event.usage;
873
939
  if (usage) {
874
- if (usage.prompt_tokens)
875
- state.inputTokens = usage.prompt_tokens;
876
- if (usage.completion_tokens)
877
- state.outputTokens = usage.completion_tokens;
878
- if (usage.prompt_tokens_details?.cached_tokens)
879
- state.cachedTokens = usage.prompt_tokens_details.cached_tokens;
940
+ // These are snapshots, never increments. Preserve partial counters
941
+ // across frames while allowing a later explicit zero to reset them.
942
+ state.usage = {
943
+ ...state.usage,
944
+ ...usage,
945
+ prompt_tokens_details: {
946
+ ...state.usage.prompt_tokens_details,
947
+ ...usage.prompt_tokens_details,
948
+ },
949
+ };
880
950
  }
881
951
  const choices = event.choices;
882
952
  if (!choices?.length)
883
953
  continue;
884
954
  const choice = choices[0];
885
- const delta = choice.delta;
886
- if (!delta)
887
- continue;
955
+ const delta = (choice.delta ?? {});
888
956
  // Reasoning/thinking content
889
957
  if (delta.reasoning_content && typeof delta.reasoning_content === 'string') {
890
958
  yield { type: 'thinking', content: delta.reasoning_content };
@@ -928,30 +996,74 @@ class DeepseekAIProviderImpl {
928
996
  }
929
997
  }
930
998
  }
931
- // finish_reason signals completion
999
+ // Only these upstream reasons denote a completed response. `length`
1000
+ // is the output-token cap; filters/resource failures/aborts also retain
1001
+ // partial text but must not release pending tools or emit `done`.
932
1002
  const finishReason = choice.finish_reason;
933
- if (finishReason === 'tool_calls' || finishReason === 'stop') {
934
- // Emit any accumulated tool calls
1003
+ if (finishReason !== undefined && finishReason !== null) {
1004
+ if (finishReason !== 'tool_calls' && finishReason !== 'stop') {
1005
+ logger.warn('DeepSeek response did not complete', {
1006
+ finishReason,
1007
+ requestId: call.meta.requestId,
1008
+ });
1009
+ yield {
1010
+ type: 'error',
1011
+ message: t('ai.error.apiError', undefined, {
1012
+ defaultValue: finishReason === 'length'
1013
+ ? 'AI response reached the output token limit before completion.'
1014
+ : 'AI response was interrupted before completion. Please try again.',
1015
+ }),
1016
+ errorKey: 'ai.error.apiError',
1017
+ };
1018
+ return;
1019
+ }
1020
+ // Validate ALL calls before releasing any: a malformed parallel call
1021
+ // must not execute alongside an apparently successful partial batch.
1022
+ if (finishReason === 'tool_calls' && pendingTools.size === 0) {
1023
+ logger.warn('DeepSeek finished tool use without a tool call');
1024
+ yield { type: 'error', message: t('ai.error.apiError'), errorKey: 'ai.error.apiError' };
1025
+ return;
1026
+ }
1027
+ const completedTools = [];
935
1028
  for (const [, pending] of pendingTools) {
936
- let input = {};
1029
+ let input;
937
1030
  try {
938
1031
  input = JSON.parse(pending.args);
1032
+ if (typeof pending.id !== 'string' ||
1033
+ !pending.id ||
1034
+ typeof pending.name !== 'string' ||
1035
+ !pending.name ||
1036
+ !input ||
1037
+ typeof input !== 'object' ||
1038
+ Array.isArray(input)) {
1039
+ throw new Error('Tool call must have an identity and JSON object arguments');
1040
+ }
939
1041
  }
940
- catch (_error) {
941
- // JSON.parse failed — fall back to empty object; malformed args are non-fatal.
1042
+ catch (error) {
1043
+ logger.warn('DeepSeek completed a tool call with invalid arguments', { error });
1044
+ yield {
1045
+ type: 'error',
1046
+ message: t('ai.error.apiError'),
1047
+ errorKey: 'ai.error.apiError',
1048
+ };
1049
+ return;
942
1050
  }
943
- yield {
1051
+ completedTools.push({
944
1052
  type: 'tool_use',
945
1053
  id: pending.id,
946
1054
  name: pending.name,
947
1055
  input,
948
- };
1056
+ });
949
1057
  }
950
1058
  pendingTools.clear();
1059
+ state.finishReason = finishReason;
1060
+ yield* completedTools;
951
1061
  }
952
1062
  }
953
1063
  catch (error) {
954
- logger.debug('Skipping malformed SSE JSON line', { json, error });
1064
+ logger.warn('DeepSeek returned malformed SSE JSON', { error });
1065
+ yield { type: 'error', message: t('ai.error.apiError'), errorKey: 'ai.error.apiError' };
1066
+ return;
955
1067
  }
956
1068
  }
957
1069
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@molecule/api-ai-deepseek",
3
- "version": "1.0.11",
3
+ "version": "1.0.13",
4
4
  "description": "DeepSeek AI provider for molecule.dev",
5
5
  "homepage": "https://www.molecule.dev/packages/api-ai-deepseek",
6
6
  "type": "module",
@@ -37,7 +37,7 @@
37
37
  "@molecule/api-bond": "1.0.2",
38
38
  "@types/node": "26.1.2",
39
39
  "typescript": "6.0.3",
40
- "vitest": "4.1.11"
40
+ "vitest": "4.1.12"
41
41
  },
42
42
  "peerDependencies": {
43
43
  "@molecule/api-ai": "^1.0.1",