@molecule/api-ai-deepseek 1.0.14 → 1.1.1
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/README.md +5 -3
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -2
- package/dist/provider.d.ts +22 -2
- package/dist/provider.d.ts.map +1 -1
- package/dist/provider.js +221 -23
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@ AUTO-GENERATED — DO NOT EDIT THIS FILE.
|
|
|
3
3
|
Generated by `mlcl sync-docs` from the package's src/index.ts JSDoc + mlcl/registry.json.
|
|
4
4
|
Edits here are overwritten on the next commit (molecule's pre-commit hook regenerates).
|
|
5
5
|
To change this document, edit the module-level JSDoc in src/index.ts.
|
|
6
|
-
Generated: 2026-10-
|
|
6
|
+
Generated: 2026-10-27T11:22:37.877Z
|
|
7
7
|
-->
|
|
8
8
|
|
|
9
9
|
# @molecule/api-ai-deepseek
|
|
@@ -225,8 +225,10 @@ malformed tool schema) gets its own non-retryable message distinct from the gene
|
|
|
225
225
|
**A stream that opens and then produces nothing is bounded, not waited on.** DeepSeek answers
|
|
226
226
|
200 and then keeps a stalled prefill alive with `: keep-alive` comments, so no HTTP status is
|
|
227
227
|
ever wrong and a byte-level idle timeout never sees the stall. `chat()` therefore bounds the
|
|
228
|
-
wait for the first CONTENT event (45 s, `MOL_DEEPSEEK_FIRST_TOKEN_TIMEOUT_MS`)
|
|
229
|
-
|
|
228
|
+
wait for the first CONTENT event (45 s, `MOL_DEEPSEEK_FIRST_TOKEN_TIMEOUT_MS`) — a window that
|
|
229
|
+
starts when the request is sent, so it also bounds an attempt whose response HEADERS never
|
|
230
|
+
arrive — and asks once more with a fresh stream; after the reply has started it bounds the
|
|
231
|
+
silence between chunks
|
|
230
232
|
instead (60 s, `MOL_DEEPSEEK_STREAM_SILENCE_TIMEOUT_MS`) and surfaces an error event, keeping
|
|
231
233
|
the partial reply. Hosts observe both through the `onStreamStall` callback.
|
|
232
234
|
|
package/dist/index.d.ts
CHANGED
|
@@ -34,8 +34,10 @@
|
|
|
34
34
|
* **A stream that opens and then produces nothing is bounded, not waited on.** DeepSeek answers
|
|
35
35
|
* 200 and then keeps a stalled prefill alive with `: keep-alive` comments, so no HTTP status is
|
|
36
36
|
* ever wrong and a byte-level idle timeout never sees the stall. `chat()` therefore bounds the
|
|
37
|
-
* wait for the first CONTENT event (45 s, `MOL_DEEPSEEK_FIRST_TOKEN_TIMEOUT_MS`)
|
|
38
|
-
*
|
|
37
|
+
* wait for the first CONTENT event (45 s, `MOL_DEEPSEEK_FIRST_TOKEN_TIMEOUT_MS`) — a window that
|
|
38
|
+
* starts when the request is sent, so it also bounds an attempt whose response HEADERS never
|
|
39
|
+
* arrive — and asks once more with a fresh stream; after the reply has started it bounds the
|
|
40
|
+
* silence between chunks
|
|
39
41
|
* instead (60 s, `MOL_DEEPSEEK_STREAM_SILENCE_TIMEOUT_MS`) and surfaces an error event, keeping
|
|
40
42
|
* the partial reply. Hosts observe both through the `onStreamStall` callback.
|
|
41
43
|
*
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4CG;AAEH,cAAc,oBAAoB,CAAA;AAClC,cAAc,eAAe,CAAA;AAC7B,cAAc,cAAc,CAAA;AAC5B,cAAc,YAAY,CAAA;AAE1B,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAA;AAMlD;;GAEG;AACH,eAAO,MAAM,QAAQ,EAAE,UAUrB,CAAA"}
|
package/dist/index.js
CHANGED
|
@@ -34,8 +34,10 @@
|
|
|
34
34
|
* **A stream that opens and then produces nothing is bounded, not waited on.** DeepSeek answers
|
|
35
35
|
* 200 and then keeps a stalled prefill alive with `: keep-alive` comments, so no HTTP status is
|
|
36
36
|
* ever wrong and a byte-level idle timeout never sees the stall. `chat()` therefore bounds the
|
|
37
|
-
* wait for the first CONTENT event (45 s, `MOL_DEEPSEEK_FIRST_TOKEN_TIMEOUT_MS`)
|
|
38
|
-
*
|
|
37
|
+
* wait for the first CONTENT event (45 s, `MOL_DEEPSEEK_FIRST_TOKEN_TIMEOUT_MS`) — a window that
|
|
38
|
+
* starts when the request is sent, so it also bounds an attempt whose response HEADERS never
|
|
39
|
+
* arrive — and asks once more with a fresh stream; after the reply has started it bounds the
|
|
40
|
+
* silence between chunks
|
|
39
41
|
* instead (60 s, `MOL_DEEPSEEK_STREAM_SILENCE_TIMEOUT_MS`) and surfaces an error event, keeping
|
|
40
42
|
* the partial reply. Hosts observe both through the `onStreamStall` callback.
|
|
41
43
|
*
|
package/dist/provider.d.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*
|
|
9
9
|
* @module
|
|
10
10
|
*/
|
|
11
|
-
import type { AIProvider, ChatEvent, ChatParams } from '@molecule/api-ai';
|
|
11
|
+
import type { AIProvider, ChatEvent, ChatParams, ImageInputSize, ImageInputTokenEstimate } from '@molecule/api-ai';
|
|
12
12
|
import './secrets.js';
|
|
13
13
|
import type { DeepseekConfig } from './types.js';
|
|
14
14
|
/** How long a stream may wait for its first content event. */
|
|
@@ -38,6 +38,26 @@ declare class DeepseekAIProviderImpl implements AIProvider {
|
|
|
38
38
|
* @yields {ChatEvent} Chat events including text chunks, tool use requests, done signals, and errors.
|
|
39
39
|
*/
|
|
40
40
|
chat(params: ChatParams): AsyncIterable<ChatEvent>;
|
|
41
|
+
/**
|
|
42
|
+
* DeepSeek's own documented price for one image, in input tokens.
|
|
43
|
+
*
|
|
44
|
+
* Priced only when BOTH conditions hold: the request is going to DeepSeek's
|
|
45
|
+
* own documented endpoint ({@link isNativeDeepseekEndpoint}), and the model
|
|
46
|
+
* the wire will carry is a documented DeepSeek vision id. The alias is
|
|
47
|
+
* resolved exactly the way {@link chat} resolves it — the bond's `modelMap`
|
|
48
|
+
* first, a miss passing through unchanged — so a host that renames the model
|
|
49
|
+
* on the wire (a US re-host) is priced under the id it actually serves, and a
|
|
50
|
+
* custom endpoint that keeps the native names is still unknown because
|
|
51
|
+
* another backend does the billing. Anything else — another model, another
|
|
52
|
+
* format, another endpoint — is an explicit unknown rather than DeepSeek's
|
|
53
|
+
* ceiling.
|
|
54
|
+
*
|
|
55
|
+
* @param image - The selected catalog model id and the image's media type.
|
|
56
|
+
* @returns 1024 tokens per image for a documented vision model and a
|
|
57
|
+
* supported format on DeepSeek's own endpoint, otherwise an explicit
|
|
58
|
+
* unknown.
|
|
59
|
+
*/
|
|
60
|
+
estimateImageInputTokens(image: ImageInputSize): ImageInputTokenEstimate;
|
|
41
61
|
/**
|
|
42
62
|
* POST the chat body, retrying with exponential backoff on a rate-limited
|
|
43
63
|
* (429) or overloaded (503) status. Extracted from {@link chat} so the
|
|
@@ -46,7 +66,7 @@ declare class DeepseekAIProviderImpl implements AIProvider {
|
|
|
46
66
|
* them in one loop made each one's attempt count depend on the other's.
|
|
47
67
|
*
|
|
48
68
|
* @param args - The request body, headers, upstream model and abort signal.
|
|
49
|
-
* @returns The
|
|
69
|
+
* @returns The response and the final dispatch send time (deliberate backoff excluded).
|
|
50
70
|
*/
|
|
51
71
|
private requestWithRateLimitRetries;
|
|
52
72
|
/**
|
package/dist/provider.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"provider.d.ts","sourceRoot":"","sources":["../src/provider.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;
|
|
1
|
+
{"version":3,"file":"provider.d.ts","sourceRoot":"","sources":["../src/provider.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAKH,OAAO,KAAK,EACV,UAAU,EAIV,SAAS,EAET,UAAU,EAEV,cAAc,EACd,uBAAuB,EAGxB,MAAM,kBAAkB,CAAA;AASzB,OAAO,cAAc,CAAA;AAErB,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,YAAY,CAAA;AAgHhD,8DAA8D;AAC9D,eAAO,MAAM,8BAA8B,QAAS,CAAA;AA8MpD;;;;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;IAsTzD;;;;;;;;;;;;;;;;;;OAkBG;IACH,wBAAwB,CAAC,KAAK,EAAE,cAAc,GAAG,uBAAuB;IAQxE;;;;;;;;;OASG;YACW,2BAA2B;IA4GzC;;;;;;;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
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* @module
|
|
10
10
|
*/
|
|
11
11
|
import { randomUUID } from 'node:crypto';
|
|
12
|
+
import { URL } from 'node:url';
|
|
12
13
|
import { getLogger } from '@molecule/api-bond';
|
|
13
14
|
import { t } from '@molecule/api-i18n';
|
|
14
15
|
const logger = getLogger();
|
|
@@ -41,6 +42,101 @@ const MAX_TIMER_MS = 2_147_483_647;
|
|
|
41
42
|
* A longer list is sent truncated rather than refused by the API.
|
|
42
43
|
*/
|
|
43
44
|
const MAX_STOP_SEQUENCES = 16;
|
|
45
|
+
/**
|
|
46
|
+
* Model ids DeepSeek documents as accepting image input (vision guide, read
|
|
47
|
+
* 2026-10-25): `deepseek-flash` takes images alongside text, and the retired
|
|
48
|
+
* `deepseek-v4-flash-vision-exp` id is still accepted and served by the latest
|
|
49
|
+
* Flash model. Any other id — including one this bond's own `modelMap` sends to
|
|
50
|
+
* a different host — has no documented image rule, so it answers unknown.
|
|
51
|
+
*
|
|
52
|
+
* A documented id is necessary but not sufficient: the ceiling is a fact about
|
|
53
|
+
* DeepSeek's OWN backend, so it is applied only when the request is going to
|
|
54
|
+
* DeepSeek's own documented endpoint (see {@link isNativeDeepseekEndpoint}).
|
|
55
|
+
*/
|
|
56
|
+
const VISION_MODEL_IDS = ['deepseek-flash', 'deepseek-v4-flash-vision-exp'];
|
|
57
|
+
/**
|
|
58
|
+
* The image formats the vision guide lists as supported: JPEG, PNG, GIF and
|
|
59
|
+
* WebP. A payload of any other type is not covered by the token rule.
|
|
60
|
+
*/
|
|
61
|
+
const VISION_MEDIA_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
|
|
62
|
+
/**
|
|
63
|
+
* DeepSeek's documented ceiling for ONE image, in input tokens: the guide states
|
|
64
|
+
* "there is an upper bound of 1024 tokens per image", each image "counted
|
|
65
|
+
* independently under the same rule" with no separate multi-image calculation.
|
|
66
|
+
* Images below roughly 544×544 are scaled up and larger ones down to roughly
|
|
67
|
+
* 1300×1300, so the bound holds whatever the source dimensions are — including
|
|
68
|
+
* `detail: 'low'` (downscaled to 512×512) and `'original'`/`'auto'`.
|
|
69
|
+
*
|
|
70
|
+
* The request-level limits around it (32 MiB per base64 image, 8192 px per side,
|
|
71
|
+
* images in `user` messages only) reject an unusable request rather than change
|
|
72
|
+
* the price, so this bond prices the image alone and leaves message placement to
|
|
73
|
+
* the caller that knows it.
|
|
74
|
+
*/
|
|
75
|
+
const VISION_TOKENS_PER_IMAGE_UPPER_BOUND = 1024;
|
|
76
|
+
/**
|
|
77
|
+
* The host DeepSeek documents as its own API endpoint. The per-image ceiling is
|
|
78
|
+
* a fact about THIS backend's accounting; the same model ids served by a
|
|
79
|
+
* gateway, a proxy or another OpenAI-compatible host are billed by that host's
|
|
80
|
+
* rule, which this bond cannot point at documentation for.
|
|
81
|
+
*/
|
|
82
|
+
const NATIVE_ENDPOINT_HOST = 'api.deepseek.com';
|
|
83
|
+
/**
|
|
84
|
+
* The chat-completions path DeepSeek documents, in both of its published base
|
|
85
|
+
* URL forms: `https://api.deepseek.com` + `/chat/completions`, and
|
|
86
|
+
* `https://api.deepseek.com/v1` + `/chat/completions` (this bond's default is
|
|
87
|
+
* the root base with `/v1/chat/completions`).
|
|
88
|
+
*/
|
|
89
|
+
const NATIVE_COMPLETIONS_PATHS = ['/v1/chat/completions', '/chat/completions'];
|
|
90
|
+
/**
|
|
91
|
+
* Whether the endpoint this bond will actually POST to is DeepSeek's own
|
|
92
|
+
* documented HTTPS endpoint.
|
|
93
|
+
*
|
|
94
|
+
* The documented per-image ceiling belongs to DeepSeek's backend, and a
|
|
95
|
+
* `baseUrl`, `DEEPSEEK_BASE_URL` or `completionsPath` can point this bond at a
|
|
96
|
+
* gateway, a proxy or another provider's OpenAI-compatible host. Model alias
|
|
97
|
+
* resolution alone cannot catch that: a custom endpoint may keep the native
|
|
98
|
+
* model names, so the wire still says `deepseek-flash` while another backend
|
|
99
|
+
* does the billing. Only the RESOLVED endpoint can say which backend it is.
|
|
100
|
+
*
|
|
101
|
+
* Parsed with the WHATWG URL parser, so the answer is about the endpoint as the
|
|
102
|
+
* request will be sent: https only, the exact hostname, no explicit
|
|
103
|
+
* non-default port (an explicit `:443` normalizes away, being the same
|
|
104
|
+
* endpoint), no credentials, no query or fragment, and a pathname equal to one
|
|
105
|
+
* of the two documented completions paths. Neither the parser nor the request
|
|
106
|
+
* normalizes duplicate or trailing slashes, so a base URL carrying one composes
|
|
107
|
+
* to a different path — `/v1/chat/completions/` or `//v1/chat/completions` —
|
|
108
|
+
* which is not native; anything else (another host, a port, a query, a
|
|
109
|
+
* fragment, an unparsable or relative URL) is not native either.
|
|
110
|
+
*
|
|
111
|
+
* Returns a boolean only: no part of the URL is logged, and nothing here reads,
|
|
112
|
+
* echoes or reports a credential.
|
|
113
|
+
*
|
|
114
|
+
* @param endpoint - The resolved request URL, `baseUrl + completionsPath`.
|
|
115
|
+
* @returns True only for DeepSeek's own documented endpoint.
|
|
116
|
+
*/
|
|
117
|
+
function isNativeDeepseekEndpoint(endpoint) {
|
|
118
|
+
let url;
|
|
119
|
+
try {
|
|
120
|
+
url = new URL(endpoint);
|
|
121
|
+
}
|
|
122
|
+
catch (_error) {
|
|
123
|
+
// Not a URL this bond can reason about, so the documented rule does not
|
|
124
|
+
// apply. The caller answers unknown, which needs neither a log line nor an
|
|
125
|
+
// echo of the value that failed to parse.
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
if (url.protocol !== 'https:')
|
|
129
|
+
return false;
|
|
130
|
+
if (url.hostname !== NATIVE_ENDPOINT_HOST)
|
|
131
|
+
return false;
|
|
132
|
+
if (url.port !== '')
|
|
133
|
+
return false;
|
|
134
|
+
if (url.username !== '' || url.password !== '')
|
|
135
|
+
return false;
|
|
136
|
+
if (url.search !== '' || url.hash !== '')
|
|
137
|
+
return false;
|
|
138
|
+
return NATIVE_COMPLETIONS_PATHS.includes(url.pathname);
|
|
139
|
+
}
|
|
44
140
|
/**
|
|
45
141
|
* Resolve a stream bound from the config, then the environment, then the
|
|
46
142
|
* default. A value that is not a positive finite number is IGNORED rather than
|
|
@@ -102,6 +198,22 @@ function isContentEvent(event) {
|
|
|
102
198
|
event.type === 'tool_use_start' ||
|
|
103
199
|
event.type === 'tool_input_delta');
|
|
104
200
|
}
|
|
201
|
+
/**
|
|
202
|
+
* The first-token window expired before response HEADERS arrived.
|
|
203
|
+
*
|
|
204
|
+
* Module-private: the only consumer is this provider's own stall loop, which
|
|
205
|
+
* reports it in the existing `before_content` shape and asks once more. It is
|
|
206
|
+
* never thrown for a caller cancellation — a caller abort (or the whole-attempt
|
|
207
|
+
* ceiling) outranks the window and is re-thrown untouched.
|
|
208
|
+
*/
|
|
209
|
+
class FirstTokenHeaderTimeoutError extends Error {
|
|
210
|
+
boundMs;
|
|
211
|
+
constructor(boundMs) {
|
|
212
|
+
super(`DeepSeek sent no response headers within ${boundMs} ms`);
|
|
213
|
+
this.boundMs = boundMs;
|
|
214
|
+
this.name = 'FirstTokenHeaderTimeoutError';
|
|
215
|
+
}
|
|
216
|
+
}
|
|
105
217
|
/**
|
|
106
218
|
* DeepSeek AI provider that implements the `AIProvider` interface using
|
|
107
219
|
* DeepSeek's OpenAI-compatible Chat Completions API with support for streaming,
|
|
@@ -272,14 +384,47 @@ class DeepseekAIProviderImpl {
|
|
|
272
384
|
// present, still wins over it (both abort this one controller).
|
|
273
385
|
const ceiling = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS);
|
|
274
386
|
ceiling.unref?.();
|
|
275
|
-
const signal = controller.signal;
|
|
276
387
|
try {
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
388
|
+
let response;
|
|
389
|
+
let requestSentAt = 0;
|
|
390
|
+
try {
|
|
391
|
+
const dispatched = await this.requestWithRateLimitRetries({
|
|
392
|
+
body,
|
|
393
|
+
headers,
|
|
394
|
+
model,
|
|
395
|
+
controller,
|
|
396
|
+
// The first-token window covers an attempt that never receives
|
|
397
|
+
// response HEADERS too, not only one that receives them and then
|
|
398
|
+
// sends no content: a window that started at the headers left the
|
|
399
|
+
// whole pre-header phase to the five-minute attempt ceiling alone.
|
|
400
|
+
// Passing it only while streaming keeps the non-streaming path —
|
|
401
|
+
// which has no first token to wait for — on that ceiling.
|
|
402
|
+
...(params.stream === false ? {} : { firstTokenTimeoutMs }),
|
|
403
|
+
});
|
|
404
|
+
response = dispatched.response;
|
|
405
|
+
requestSentAt = dispatched.sentAt;
|
|
406
|
+
}
|
|
407
|
+
catch (error) {
|
|
408
|
+
if (!(error instanceof FirstTokenHeaderTimeoutError) || params.signal?.aborted)
|
|
409
|
+
throw error;
|
|
410
|
+
// Nothing reached the consumer, so the same once-only re-ask a
|
|
411
|
+
// post-header stall gets is safe here too — reported in the
|
|
412
|
+
// established `before_content` shape, so hosts and analytics see one
|
|
413
|
+
// kind of stall either way.
|
|
414
|
+
const willRetry = stalledAttempt < MAX_STALL_RETRIES;
|
|
415
|
+
this.reportStreamStall(model, {
|
|
416
|
+
stallMs: error.boundMs,
|
|
417
|
+
attempt: stalledAttempt + 1,
|
|
418
|
+
willRetry,
|
|
419
|
+
streamedChars: 0,
|
|
420
|
+
keepAlives: 0,
|
|
421
|
+
phase: 'before_content',
|
|
422
|
+
});
|
|
423
|
+
if (willRetry)
|
|
424
|
+
continue;
|
|
425
|
+
yield { type: 'error', message: t('ai.error.apiError'), errorKey: 'ai.error.apiError' };
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
283
428
|
// Whatever the transport exposes about this call. DeepSeek's edge
|
|
284
429
|
// answers with `x-ds-trace-id`; other OpenAI-compatible hosts use
|
|
285
430
|
// `x-request-id`. Recorded rather than read for control flow: it is the
|
|
@@ -371,14 +516,17 @@ class DeepseekAIProviderImpl {
|
|
|
371
516
|
controller.abort();
|
|
372
517
|
}, ms);
|
|
373
518
|
};
|
|
374
|
-
|
|
519
|
+
// Headers and content share this dispatch's existing first-token window.
|
|
520
|
+
// A 429/503 retry has its own send time; deliberate backoff is excluded.
|
|
521
|
+
armBound(Math.max(0, firstTokenTimeoutMs - (Date.now() - requestSentAt)), 'before_content');
|
|
522
|
+
bounds.windowStartedAt = requestSentAt;
|
|
375
523
|
try {
|
|
376
524
|
yield* this.parseStreamingResponse(response, state, { silenceMs: this.streamSilenceTimeoutMs, arm: armBound }, call);
|
|
377
525
|
}
|
|
378
526
|
catch (error) {
|
|
379
527
|
// Only a bound expiry is this provider's to absorb — anything else
|
|
380
528
|
// (the caller aborting, a socket failure) belongs to the caller.
|
|
381
|
-
if (!bounds.expired)
|
|
529
|
+
if (!bounds.expired || params.signal?.aborted)
|
|
382
530
|
throw error;
|
|
383
531
|
const stall = {
|
|
384
532
|
stallMs: Date.now() - bounds.windowStartedAt,
|
|
@@ -410,6 +558,35 @@ class DeepseekAIProviderImpl {
|
|
|
410
558
|
}
|
|
411
559
|
}
|
|
412
560
|
}
|
|
561
|
+
/**
|
|
562
|
+
* DeepSeek's own documented price for one image, in input tokens.
|
|
563
|
+
*
|
|
564
|
+
* Priced only when BOTH conditions hold: the request is going to DeepSeek's
|
|
565
|
+
* own documented endpoint ({@link isNativeDeepseekEndpoint}), and the model
|
|
566
|
+
* the wire will carry is a documented DeepSeek vision id. The alias is
|
|
567
|
+
* resolved exactly the way {@link chat} resolves it — the bond's `modelMap`
|
|
568
|
+
* first, a miss passing through unchanged — so a host that renames the model
|
|
569
|
+
* on the wire (a US re-host) is priced under the id it actually serves, and a
|
|
570
|
+
* custom endpoint that keeps the native names is still unknown because
|
|
571
|
+
* another backend does the billing. Anything else — another model, another
|
|
572
|
+
* format, another endpoint — is an explicit unknown rather than DeepSeek's
|
|
573
|
+
* ceiling.
|
|
574
|
+
*
|
|
575
|
+
* @param image - The selected catalog model id and the image's media type.
|
|
576
|
+
* @returns 1024 tokens per image for a documented vision model and a
|
|
577
|
+
* supported format on DeepSeek's own endpoint, otherwise an explicit
|
|
578
|
+
* unknown.
|
|
579
|
+
*/
|
|
580
|
+
estimateImageInputTokens(image) {
|
|
581
|
+
if (!isNativeDeepseekEndpoint(`${this.baseUrl}${this.completionsPath}`))
|
|
582
|
+
return { known: false };
|
|
583
|
+
const upstream = this.modelMap[image.model] ?? image.model;
|
|
584
|
+
if (!VISION_MODEL_IDS.includes(upstream))
|
|
585
|
+
return { known: false };
|
|
586
|
+
if (!VISION_MEDIA_TYPES.includes(image.mediaType.toLowerCase()))
|
|
587
|
+
return { known: false };
|
|
588
|
+
return { known: true, upperBoundTokens: VISION_TOKENS_PER_IMAGE_UPPER_BOUND };
|
|
589
|
+
}
|
|
413
590
|
/**
|
|
414
591
|
* POST the chat body, retrying with exponential backoff on a rate-limited
|
|
415
592
|
* (429) or overloaded (503) status. Extracted from {@link chat} so the
|
|
@@ -418,20 +595,38 @@ class DeepseekAIProviderImpl {
|
|
|
418
595
|
* them in one loop made each one's attempt count depend on the other's.
|
|
419
596
|
*
|
|
420
597
|
* @param args - The request body, headers, upstream model and abort signal.
|
|
421
|
-
* @returns The
|
|
598
|
+
* @returns The response and the final dispatch send time (deliberate backoff excluded).
|
|
422
599
|
*/
|
|
423
600
|
async requestWithRateLimitRetries(args) {
|
|
424
|
-
const { body, headers, model,
|
|
601
|
+
const { body, headers, model, controller, firstTokenTimeoutMs } = args;
|
|
602
|
+
const signal = controller.signal;
|
|
425
603
|
// Retry with exponential backoff for rate limits (429) and overloaded (503)
|
|
426
604
|
const MAX_RETRIES = 3;
|
|
427
605
|
let response = null;
|
|
606
|
+
let sentAt = 0;
|
|
428
607
|
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
608
|
+
// Bound THIS dispatch, not the retry loop: a deliberate backoff sleep is
|
|
609
|
+
// this provider's own wait, so no window is running while it is spent.
|
|
610
|
+
let headerDeadline = null;
|
|
611
|
+
sentAt = Date.now();
|
|
612
|
+
if (firstTokenTimeoutMs !== undefined) {
|
|
613
|
+
headerDeadline = setTimeout(() => controller.abort(new FirstTokenHeaderTimeoutError(firstTokenTimeoutMs)), firstTokenTimeoutMs);
|
|
614
|
+
headerDeadline.unref?.();
|
|
615
|
+
}
|
|
616
|
+
try {
|
|
617
|
+
response = await fetch(`${this.baseUrl}${this.completionsPath}`, {
|
|
618
|
+
method: 'POST',
|
|
619
|
+
headers,
|
|
620
|
+
body: JSON.stringify(body),
|
|
621
|
+
signal,
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
finally {
|
|
625
|
+
// Release the header timer when fetch settles. The successful SSE reader
|
|
626
|
+
// uses the remaining window; a rate-limit retry starts a new dispatch.
|
|
627
|
+
if (headerDeadline)
|
|
628
|
+
clearTimeout(headerDeadline);
|
|
629
|
+
}
|
|
435
630
|
if (response.status === 429 || response.status === 503) {
|
|
436
631
|
const willRetry = attempt < MAX_RETRIES;
|
|
437
632
|
// Retry-After is equally-valid as delta-seconds or an HTTP-date;
|
|
@@ -468,21 +663,24 @@ class DeepseekAIProviderImpl {
|
|
|
468
663
|
attempt: attempt + 1,
|
|
469
664
|
delayMs,
|
|
470
665
|
});
|
|
666
|
+
// A host may cancel inside onRateLimit, before a backoff listener exists.
|
|
667
|
+
signal.throwIfAborted();
|
|
471
668
|
await new Promise((resolve) => {
|
|
472
|
-
const
|
|
473
|
-
signal.addEventListener('abort', () => {
|
|
669
|
+
const finish = () => {
|
|
474
670
|
clearTimeout(timer);
|
|
671
|
+
signal.removeEventListener('abort', finish);
|
|
475
672
|
resolve();
|
|
476
|
-
}
|
|
673
|
+
};
|
|
674
|
+
const timer = setTimeout(finish, delayMs);
|
|
675
|
+
signal.addEventListener('abort', finish, { once: true });
|
|
477
676
|
});
|
|
478
|
-
|
|
479
|
-
break;
|
|
677
|
+
signal.throwIfAborted();
|
|
480
678
|
continue;
|
|
481
679
|
}
|
|
482
680
|
}
|
|
483
681
|
break;
|
|
484
682
|
}
|
|
485
|
-
return response;
|
|
683
|
+
return { response: response, sentAt };
|
|
486
684
|
}
|
|
487
685
|
/**
|
|
488
686
|
* Record one stalled attempt: a warn line always, plus the host's
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@molecule/api-ai-deepseek",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.1",
|
|
4
4
|
"description": "DeepSeek AI provider for molecule.dev",
|
|
5
5
|
"homepage": "https://www.molecule.dev/packages/api-ai-deepseek",
|
|
6
6
|
"type": "module",
|
|
@@ -33,8 +33,8 @@
|
|
|
33
33
|
"directory": "packages/api/bonds/ai/deepseek"
|
|
34
34
|
},
|
|
35
35
|
"devDependencies": {
|
|
36
|
-
"@molecule/api-ai": "1.
|
|
37
|
-
"@molecule/api-bond": "1.0.
|
|
36
|
+
"@molecule/api-ai": "1.4.0",
|
|
37
|
+
"@molecule/api-bond": "1.0.4",
|
|
38
38
|
"@types/node": "26.6.5",
|
|
39
39
|
"typescript": "6.0.3",
|
|
40
40
|
"vitest": "4.1.12"
|