@molecule/api-ai-deepseek 1.0.12 → 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
@@ -93,6 +93,15 @@ declare class DeepseekAIProviderImpl implements AIProvider {
93
93
  * @yields {ChatEvent} Chat events parsed from the response.
94
94
  */
95
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;
96
105
  /**
97
106
  * Parses a streaming SSE response from the DeepSeek API (OpenAI-compatible
98
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;AAwGhD,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;IAsRzD;;;;;;;;;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;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
@@ -191,7 +191,7 @@ class DeepseekAIProviderImpl {
191
191
  // prose-only backstops, whereas a 400 produces nothing at all.
192
192
  //
193
193
  // LIVE for an entry that exposes opt-in reasoning (deepseek-flash, whose
194
- // effort levels include the local `off` default): a project that picks
194
+ // effort levels include a local `off` switch): a project that uses
195
195
  // low/high/max gets thinking mode on a discovery turn, and the forced
196
196
  // nudge is degraded here instead of 400ing the first turn of the
197
197
  // conversation.
@@ -215,9 +215,8 @@ class DeepseekAIProviderImpl {
215
215
  // assistant turn's `reasoning_content` back — including a turn where the
216
216
  // model made no tool call — or the API answers 400. `formatMessages` replays
217
217
  // `ChatMessage.reasoning` for exactly that reason. The catalog decides
218
- // whether a turn carries a thinking block: deepseek-flash defaults to the
219
- // local `off` level (no block → reasoning explicitly disabled below), and
220
- // only a project that opts into low/high/max sends one.
218
+ // whether a turn carries a thinking block. No block explicitly disables
219
+ // reasoning below; a low/high/max block enables that native effort.
221
220
  if (params.thinking) {
222
221
  body.thinking = { type: 'enabled' };
223
222
  // Prefer a caller-resolved native value (`low` | `high` | `max`) from the
@@ -352,9 +351,8 @@ class DeepseekAIProviderImpl {
352
351
  return;
353
352
  }
354
353
  const state = {
355
- inputTokens: 0,
356
- outputTokens: 0,
357
- cachedTokens: 0,
354
+ finishReason: null,
355
+ usage: {},
358
356
  keepAlives: 0,
359
357
  streamedChars: 0,
360
358
  contentSeen: false,
@@ -648,48 +646,90 @@ class DeepseekAIProviderImpl {
648
646
  */
649
647
  *parseNonStreamingResponse(data) {
650
648
  const choices = data.choices;
651
- if (choices?.length) {
652
- const message = choices[0].message;
653
- if (message) {
654
- // Reasoning/thinking content
655
- if (message.reasoning_content && typeof message.reasoning_content === 'string') {
656
- yield { type: 'thinking', content: message.reasoning_content };
657
- }
658
- if (message.content && typeof message.content === 'string') {
659
- yield { type: 'text', content: message.content };
660
- }
661
- const toolCalls = message.tool_calls;
662
- if (toolCalls) {
663
- for (const tc of toolCalls) {
664
- const fn = tc.function;
665
- let input = {};
666
- try {
667
- input = JSON.parse(fn.arguments);
668
- }
669
- catch (_error) {
670
- // JSON.parse failed — fall back to empty object; malformed args are non-fatal.
671
- }
672
- yield {
673
- type: 'tool_use',
674
- id: tc.id,
675
- name: fn.name,
676
- input,
677
- };
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');
678
698
  }
679
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
+ });
680
711
  }
712
+ yield* completedTools;
681
713
  }
682
- const usage = data.usage;
683
- const cached = usage?.prompt_tokens_details?.cached_tokens ?? 0;
684
714
  yield {
685
715
  type: 'done',
686
- usage: {
687
- // Report uncached input + cached separately (Anthropic semantics) so the
688
- // consumer's context/cost math doesn't double-count the cached subset.
689
- inputTokens: Math.max(0, (usage?.prompt_tokens ?? 0) - cached),
690
- outputTokens: usage?.completion_tokens ?? 0,
691
- cacheReadInputTokens: cached,
692
- },
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,
693
733
  };
694
734
  }
695
735
  /**
@@ -774,19 +814,22 @@ class DeepseekAIProviderImpl {
774
814
  return;
775
815
  }
776
816
  }
777
- // Emit any tool calls still pending after stream ends (safety net for missing finish_reason)
778
- if (pendingTools.size > 0) {
779
- for (const [, pending] of pendingTools) {
780
- let input = {};
781
- try {
782
- input = JSON.parse(pending.args);
783
- }
784
- catch (_error) {
785
- // JSON.parse failed — fall back to empty object; malformed args are non-fatal.
786
- }
787
- yield { type: 'tool_use', id: pending.id, name: pending.name, input };
788
- }
789
- 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;
790
833
  }
791
834
  }
792
835
  finally {
@@ -794,11 +837,7 @@ class DeepseekAIProviderImpl {
794
837
  }
795
838
  yield {
796
839
  type: 'done',
797
- usage: {
798
- inputTokens: Math.max(0, state.inputTokens - state.cachedTokens),
799
- outputTokens: state.outputTokens,
800
- cacheReadInputTokens: state.cachedTokens,
801
- },
840
+ usage: this.parseUsage(state.usage),
802
841
  provider: call.meta,
803
842
  };
804
843
  }
@@ -898,20 +937,22 @@ class DeepseekAIProviderImpl {
898
937
  // Usage info (may appear in the final chunk)
899
938
  const usage = event.usage;
900
939
  if (usage) {
901
- if (usage.prompt_tokens)
902
- state.inputTokens = usage.prompt_tokens;
903
- if (usage.completion_tokens)
904
- state.outputTokens = usage.completion_tokens;
905
- if (usage.prompt_tokens_details?.cached_tokens)
906
- 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
+ };
907
950
  }
908
951
  const choices = event.choices;
909
952
  if (!choices?.length)
910
953
  continue;
911
954
  const choice = choices[0];
912
- const delta = choice.delta;
913
- if (!delta)
914
- continue;
955
+ const delta = (choice.delta ?? {});
915
956
  // Reasoning/thinking content
916
957
  if (delta.reasoning_content && typeof delta.reasoning_content === 'string') {
917
958
  yield { type: 'thinking', content: delta.reasoning_content };
@@ -955,30 +996,74 @@ class DeepseekAIProviderImpl {
955
996
  }
956
997
  }
957
998
  }
958
- // 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`.
959
1002
  const finishReason = choice.finish_reason;
960
- if (finishReason === 'tool_calls' || finishReason === 'stop') {
961
- // 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 = [];
962
1028
  for (const [, pending] of pendingTools) {
963
- let input = {};
1029
+ let input;
964
1030
  try {
965
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
+ }
966
1041
  }
967
- catch (_error) {
968
- // 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;
969
1050
  }
970
- yield {
1051
+ completedTools.push({
971
1052
  type: 'tool_use',
972
1053
  id: pending.id,
973
1054
  name: pending.name,
974
1055
  input,
975
- };
1056
+ });
976
1057
  }
977
1058
  pendingTools.clear();
1059
+ state.finishReason = finishReason;
1060
+ yield* completedTools;
978
1061
  }
979
1062
  }
980
1063
  catch (error) {
981
- 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;
982
1067
  }
983
1068
  }
984
1069
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@molecule/api-ai-deepseek",
3
- "version": "1.0.12",
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",