dsh-subscriptions 0.1.3 → 0.1.5
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 +64 -0
- package/lib/auth/device-flow.js +6 -3
- package/lib/auth/oauth-flow.js +41 -8
- package/lib/auth/rpc.d.ts +3 -1
- package/lib/auth/rpc.js +10 -1
- package/lib/client/ProviderAccountManager.js +34 -41
- package/lib/client/ProviderModelEditor.js +5 -9
- package/lib/client/SpeedSelect.js +8 -7
- package/lib/client/SubscriptionUsageBadge.js +6 -5
- package/lib/client/SubscriptionsSection.d.ts +21 -73
- package/lib/client/SubscriptionsSection.js +130 -104
- package/lib/client/UsageBadgeDisplaySetting.js +4 -8
- package/lib/client/host-contract.d.ts +1 -1
- package/lib/client/host-contract.js +22 -0
- package/lib/client/locales.d.ts +58 -0
- package/lib/client/locales.js +58 -0
- package/lib/client/provider-settings-styles.d.ts +3 -0
- package/lib/client/provider-settings-styles.js +206 -0
- package/lib/client/reset-action.d.ts +14 -0
- package/lib/client/reset-action.js +15 -0
- package/lib/client/reset-credits-view.d.ts +53 -0
- package/lib/client/reset-credits-view.js +103 -0
- package/lib/client.js +937 -310
- package/lib/client.js.map +1 -1
- package/lib/index.d.ts +9 -0
- package/lib/index.js +6058 -5298
- package/lib/model-defaults.d.ts +4 -5
- package/lib/model-defaults.js +18 -14
- package/lib/providers/account-preferences.js +3 -2
- package/lib/providers/antigravity.d.ts +0 -2
- package/lib/providers/antigravity.js +5 -9
- package/lib/providers/catalog-store.d.ts +3 -2
- package/lib/providers/catalog-store.js +57 -26
- package/lib/providers/claude.d.ts +22 -3
- package/lib/providers/claude.js +118 -20
- package/lib/providers/codex-search.js +5 -2
- package/lib/providers/codex.d.ts +15 -2
- package/lib/providers/codex.js +77 -9
- package/lib/providers/common.d.ts +38 -0
- package/lib/providers/common.js +44 -18
- package/lib/providers/copilot.d.ts +2 -1
- package/lib/providers/copilot.js +13 -6
- package/lib/providers/cursor-adapter.d.ts +35 -0
- package/lib/providers/cursor-adapter.js +245 -4
- package/lib/providers/cursor-auth.js +11 -4
- package/lib/providers/cursor-usage.js +2 -1
- package/lib/providers/external-usage.js +2 -1
- package/lib/providers/grok.d.ts +2 -2
- package/lib/providers/grok.js +16 -11
- package/lib/providers/minimax-usage.js +2 -1
- package/lib/providers/pool-health.d.ts +2 -0
- package/lib/providers/pool-health.js +9 -0
- package/lib/providers/pool-usage.d.ts +13 -1
- package/lib/providers/pool-usage.js +39 -7
- package/lib/providers/pool.d.ts +7 -5
- package/lib/providers/pool.js +89 -12
- package/lib/providers/rate-limit.d.ts +0 -14
- package/lib/providers/rate-limit.js +0 -26
- package/lib/providers/replay.d.ts +4 -0
- package/lib/providers/replay.js +50 -0
- package/lib/providers/reset-redemption.d.ts +51 -0
- package/lib/providers/reset-redemption.js +102 -0
- package/lib/tools/image-generate.js +2 -1
- package/lib/tools/video-generate.d.ts +0 -5
- package/lib/tools/video-generate.js +25 -14
- package/lib/tools/x-search.js +2 -2
- package/lib/translate/anthropic.d.ts +21 -2
- package/lib/translate/anthropic.js +35 -11
- package/lib/translate/antigravity.js +13 -6
- package/lib/translate/chat-completions.js +1 -1
- package/lib/translate/resolved.d.ts +24 -0
- package/lib/translate/resolved.js +66 -3
- package/lib/translate/responses.js +8 -6
- package/lib/translate/sse.js +8 -0
- package/package.json +1 -1
package/lib/providers/pool.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { EMPTY_RESPONSE_CODE, LlmAdapter, LlmError, QUOTA_EXCEEDED_CODE, } from '@deepseek-ai/dsh-llm';
|
|
11
11
|
import { poolKey } from './pool-family.js';
|
|
12
12
|
import { accountKey, classifyPoolFailure, memberKey, PoolHealthRegistry } from './pool-health.js';
|
|
13
|
+
import { streamAccountWithReplay } from './replay.js';
|
|
13
14
|
/** Bound on sticky-session memory; oldest entries evict past it. */
|
|
14
15
|
const STICKY_SESSION_LIMIT = 1000;
|
|
15
16
|
/** Display form of one member (account shown when pinned). */
|
|
@@ -200,6 +201,12 @@ export class PoolAdapter extends LlmAdapter {
|
|
|
200
201
|
...modalities === undefined ? {} : { inputModalities: modalities },
|
|
201
202
|
};
|
|
202
203
|
}
|
|
204
|
+
/**
|
|
205
|
+
* Fail over before output. A single non-quota failure keeps its identity
|
|
206
|
+
* and shared provider message, with scoped pool recovery or its own hint as fallback.
|
|
207
|
+
* Multiple failures carry a bounded summary and all attempts in an AggregateError cause.
|
|
208
|
+
* Pure quota/rate exhaustion retains RATE_LIMIT and this pool's earliest recovery hint.
|
|
209
|
+
*/
|
|
203
210
|
async *stream(options) {
|
|
204
211
|
const definition = (await this.pools()).get(poolKey(options.provider, options.model));
|
|
205
212
|
if (definition === undefined)
|
|
@@ -208,12 +215,12 @@ export class PoolAdapter extends LlmAdapter {
|
|
|
208
215
|
const candidates = await this.select(options.model, members, options.sessionId);
|
|
209
216
|
if (candidates.length === 0)
|
|
210
217
|
throw this.exhausted(options.model, members);
|
|
211
|
-
|
|
218
|
+
const failures = [];
|
|
212
219
|
for (const member of candidates) {
|
|
213
220
|
const adapter = this.options.adapters[member.provider];
|
|
214
221
|
if (adapter === undefined)
|
|
215
222
|
continue;
|
|
216
|
-
const iterator = adapter
|
|
223
|
+
const iterator = streamAccountWithReplay(adapter, { ...options, provider: member.provider }, member.account, member.model)[Symbol.asyncIterator]();
|
|
217
224
|
let first;
|
|
218
225
|
try {
|
|
219
226
|
first = await iterator.next();
|
|
@@ -223,7 +230,7 @@ export class PoolAdapter extends LlmAdapter {
|
|
|
223
230
|
}
|
|
224
231
|
catch (error) {
|
|
225
232
|
const classification = classifyPoolFailure(error, member.provider);
|
|
226
|
-
if (classification.action === 'throw')
|
|
233
|
+
if (classification.action === 'throw' || !(error instanceof LlmError))
|
|
227
234
|
throw error;
|
|
228
235
|
if ('cooldownMs' in classification) {
|
|
229
236
|
this.options.health.markUnavailable(classification.scope === 'account'
|
|
@@ -238,7 +245,7 @@ export class PoolAdapter extends LlmAdapter {
|
|
|
238
245
|
}
|
|
239
246
|
this.options.onWarn(`pool "${options.model}": ${memberLabel(member)} failed before any output`
|
|
240
247
|
+ ` (${error instanceof Error ? error.message : String(error)}); trying the next member`);
|
|
241
|
-
|
|
248
|
+
failures.push(error);
|
|
242
249
|
continue;
|
|
243
250
|
}
|
|
244
251
|
this.remember(options.model, options.sessionId, member);
|
|
@@ -264,7 +271,7 @@ export class PoolAdapter extends LlmAdapter {
|
|
|
264
271
|
}
|
|
265
272
|
return;
|
|
266
273
|
}
|
|
267
|
-
throw this.exhausted(options.model, members,
|
|
274
|
+
throw this.exhausted(options.model, members, failures);
|
|
268
275
|
}
|
|
269
276
|
/**
|
|
270
277
|
* Order the candidates for one request. Health filters both strategies;
|
|
@@ -316,12 +323,11 @@ export class PoolAdapter extends LlmAdapter {
|
|
|
316
323
|
}
|
|
317
324
|
this.sticky.set(key, memberKey(member.provider, member.account, member.model));
|
|
318
325
|
}
|
|
319
|
-
/**
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
exhausted(model, pool, cause) {
|
|
326
|
+
/** Report attempt failures or this pool's active cooldowns, never unrelated health records. */
|
|
327
|
+
exhausted(model, pool, failures = []) {
|
|
328
|
+
if (pool.length === 0) {
|
|
329
|
+
return new LlmError(`pool "${model}" has no usable member: no account could be resolved`, 'NO_ADAPTER');
|
|
330
|
+
}
|
|
325
331
|
const keys = new Set();
|
|
326
332
|
for (const member of pool) {
|
|
327
333
|
keys.add(memberKey(member.provider, member.account, member.model));
|
|
@@ -329,12 +335,83 @@ export class PoolAdapter extends LlmAdapter {
|
|
|
329
335
|
}
|
|
330
336
|
const recovery = this.options.health.earliestRecovery(keys);
|
|
331
337
|
const retryAfterMs = recovery === undefined ? undefined : Math.max(recovery - Date.now(), 1);
|
|
332
|
-
|
|
338
|
+
const coolingReasons = this.options.health.unavailableReasons(keys);
|
|
339
|
+
const codes = [...failures.map(error => error.code), ...coolingReasons];
|
|
340
|
+
if (codes.length === 0)
|
|
341
|
+
return new LlmError(`pool "${model}" has no usable member`, 'NO_ADAPTER');
|
|
342
|
+
const quotaOnly = codes.every(isQuotaFailure);
|
|
343
|
+
if (failures.length === 1 && !isQuotaFailure(failures[0].code)) {
|
|
344
|
+
const failure = failures[0];
|
|
345
|
+
// Keep the same error, code, status and cause. The message arrives already
|
|
346
|
+
// free of provider text: the shared converter in common.ts owns that, and
|
|
347
|
+
// non-pooled callers receive the same text, so the pool adds no sanitizer
|
|
348
|
+
// of its own and cannot make the two paths disagree.
|
|
349
|
+
// The pool's earliest scoped recovery wins even if the member's hint is
|
|
350
|
+
// later: another member may recover sooner. Without scoped recovery,
|
|
351
|
+
// keep the member hint; with neither, leave it absent.
|
|
352
|
+
// Keep the error object itself. LlmError freezes its facts snapshot, so
|
|
353
|
+
// replace that snapshot to correct only providerRetryAfterMs for pool
|
|
354
|
+
// routing; this is not permission to rewrite other frozen failure facts.
|
|
355
|
+
if (retryAfterMs !== undefined && retryAfterMs !== failure.failure.providerRetryAfterMs) {
|
|
356
|
+
Object.defineProperty(failure, 'failure', {
|
|
357
|
+
value: Object.freeze({ ...failure.failure, providerRetryAfterMs: retryAfterMs }),
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
return failure;
|
|
361
|
+
}
|
|
362
|
+
const attemptedCodes = failures.map(error => error.code);
|
|
363
|
+
const details = [...attemptedCodes, ...coolingReasons.filter(code => !attemptedCodes.includes(code))];
|
|
364
|
+
const summary = failureSummary(details);
|
|
365
|
+
const cause = failures.length === 0
|
|
366
|
+
? undefined
|
|
367
|
+
: failures.length === 1
|
|
368
|
+
? failures[0]
|
|
369
|
+
: new AggregateError(failures, `${failures.length} pool members failed: ${summary}`);
|
|
370
|
+
// Subscription quotas reopen: preserve the existing retry classification and text.
|
|
371
|
+
const message = quotaOnly
|
|
372
|
+
? `pool "${model}" exhausted: every member is unavailable or failed`
|
|
373
|
+
: failures.length > 0
|
|
374
|
+
? `pool "${model}" exhausted: ${failures.length} ${failures.length === 1 ? 'member' : 'members'} failed (${summary})`
|
|
375
|
+
: `pool "${model}" unavailable: ${pool.length} members are cooling down (${summary})`;
|
|
376
|
+
// For mixed failures, a real non-quota failure must not become a rate limit
|
|
377
|
+
// merely because the last member hit its quota. No new aggregate error code.
|
|
378
|
+
const code = quotaOnly
|
|
379
|
+
? 'RATE_LIMIT'
|
|
380
|
+
: attemptedCodes.filter(code => !isQuotaFailure(code)).at(-1)
|
|
381
|
+
?? coolingReasons.filter(code => !isQuotaFailure(code)).at(-1);
|
|
382
|
+
return new LlmError(message, code, {
|
|
333
383
|
...retryAfterMs === undefined ? {} : { providerRetryAfterMs: retryAfterMs },
|
|
334
384
|
...cause === undefined ? {} : { cause },
|
|
335
385
|
});
|
|
336
386
|
}
|
|
337
387
|
}
|
|
388
|
+
function isQuotaFailure(code) {
|
|
389
|
+
return code === QUOTA_EXCEEDED_CODE || code === 'RATE_LIMIT';
|
|
390
|
+
}
|
|
391
|
+
/** Summaries use stable codes only: adapter messages and nested causes can contain raw payloads. */
|
|
392
|
+
function failureSummary(codes) {
|
|
393
|
+
const descriptions = {
|
|
394
|
+
AUTH: 'authentication failed',
|
|
395
|
+
INVALID_CREDENTIAL: 'credential is invalid',
|
|
396
|
+
MISSING_CREDENTIAL: 'credential is missing',
|
|
397
|
+
SERVER: 'provider server failed',
|
|
398
|
+
TIMEOUT: 'provider request timed out',
|
|
399
|
+
EMPTY_RESPONSE: 'provider returned no output',
|
|
400
|
+
TRANSPORT: 'provider connection failed',
|
|
401
|
+
HTTP_402: 'provider plan unavailable',
|
|
402
|
+
HTTP_404: 'provider model unavailable',
|
|
403
|
+
QUOTA: 'provider quota exhausted',
|
|
404
|
+
RATE_LIMIT: 'provider rate limit reached',
|
|
405
|
+
};
|
|
406
|
+
const limit = 5;
|
|
407
|
+
const shown = codes.slice(0, limit).map(code => {
|
|
408
|
+
const description = descriptions[code];
|
|
409
|
+
return description === undefined ? 'member unavailable' : `${code}: ${description}`;
|
|
410
|
+
});
|
|
411
|
+
if (codes.length > limit)
|
|
412
|
+
shown.push(`${codes.length - limit} more`);
|
|
413
|
+
return shown.join('; ');
|
|
414
|
+
}
|
|
338
415
|
function stickyKey(poolId, sessionId) {
|
|
339
416
|
return `${String(sessionId)}|${poolId}`;
|
|
340
417
|
}
|
|
@@ -110,20 +110,6 @@ export declare function earliestReset(...candidates: (number | undefined)[]): nu
|
|
|
110
110
|
* @returns the wait in milliseconds, never below {@link MIN_WAIT_MS}.
|
|
111
111
|
*/
|
|
112
112
|
export declare function waitFromReset(instant: number, now: number): number;
|
|
113
|
-
/**
|
|
114
|
-
* Render the rate-limit-shaped headers and the head of the body of a 429 whose
|
|
115
|
-
* reset instant nothing parsed. Emitted through the adapter's `onWarn`, this is
|
|
116
|
-
* how an unrecognized provider field gets named from live traffic instead of
|
|
117
|
-
* being guessed at.
|
|
118
|
-
*
|
|
119
|
-
* It is also where the per-bucket rollover snapshots land by design — no reader
|
|
120
|
-
* parks a turn on one, because on a 429 they cannot say which bucket refused —
|
|
121
|
-
* so the operator still sees what the provider disclosed.
|
|
122
|
-
* @param response - the failed response.
|
|
123
|
-
* @param body - the complete response body.
|
|
124
|
-
* @returns a one-line diagnostic.
|
|
125
|
-
*/
|
|
126
|
-
export declare function rateLimitDiagnostics(response: Response, body: string): string;
|
|
127
113
|
/** Per-route retry shape a subscription adapter starts from. */
|
|
128
114
|
export interface RetryDefaults {
|
|
129
115
|
/** Retries after the first attempt. */
|
|
@@ -238,32 +238,6 @@ export function earliestReset(...candidates) {
|
|
|
238
238
|
export function waitFromReset(instant, now) {
|
|
239
239
|
return Math.max(MIN_WAIT_MS, instant - now + RESET_GRACE_MS);
|
|
240
240
|
}
|
|
241
|
-
/** Header names worth showing when a 429 disclosed no reset this code recognizes. */
|
|
242
|
-
const DIAGNOSTIC_HEADER = /rate-?limit|retry|reset|^x-codex-/i;
|
|
243
|
-
/**
|
|
244
|
-
* Render the rate-limit-shaped headers and the head of the body of a 429 whose
|
|
245
|
-
* reset instant nothing parsed. Emitted through the adapter's `onWarn`, this is
|
|
246
|
-
* how an unrecognized provider field gets named from live traffic instead of
|
|
247
|
-
* being guessed at.
|
|
248
|
-
*
|
|
249
|
-
* It is also where the per-bucket rollover snapshots land by design — no reader
|
|
250
|
-
* parks a turn on one, because on a 429 they cannot say which bucket refused —
|
|
251
|
-
* so the operator still sees what the provider disclosed.
|
|
252
|
-
* @param response - the failed response.
|
|
253
|
-
* @param body - the complete response body.
|
|
254
|
-
* @returns a one-line diagnostic.
|
|
255
|
-
*/
|
|
256
|
-
export function rateLimitDiagnostics(response, body) {
|
|
257
|
-
const headers = [];
|
|
258
|
-
response.headers.forEach((value, key) => {
|
|
259
|
-
if (DIAGNOSTIC_HEADER.test(key))
|
|
260
|
-
headers.push(`${key}: ${value}`);
|
|
261
|
-
});
|
|
262
|
-
headers.sort();
|
|
263
|
-
const rendered = headers.length > 0 ? headers.join('; ') : '(none)';
|
|
264
|
-
const head = body.slice(0, 200);
|
|
265
|
-
return `429 disclosed no reset time; headers [${rendered}]; body ${head.length > 0 ? head : '(empty)'}`;
|
|
266
|
-
}
|
|
267
241
|
/**
|
|
268
242
|
* The retry shape every subscription route starts from: Claude Code's own SDK
|
|
269
243
|
* numbers — ten retries after the first attempt, exponential backoff from 1s
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm';
|
|
2
|
+
import type { AccountAwareAdapter } from './accounts.js';
|
|
3
|
+
/** Bind opaque replay to the concrete account/model behind a route alias. */
|
|
4
|
+
export declare function streamAccountWithReplay(adapter: AccountAwareAdapter, options: GenerateOptions, account: string, model?: string): AsyncIterable<StreamChunk>;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
const REPLAY_KIND = 'subscriptions-account-replay';
|
|
3
|
+
/** Bind opaque replay to the concrete account/model behind a route alias. */
|
|
4
|
+
export async function* streamAccountWithReplay(adapter, options, account, model = options.model) {
|
|
5
|
+
const key = createHash('sha256')
|
|
6
|
+
.update(JSON.stringify([options.provider, account, model])).digest('hex');
|
|
7
|
+
const messages = options.messages.map(message => {
|
|
8
|
+
if (message.role !== 'assistant'
|
|
9
|
+
|| message.source?.replayState === undefined)
|
|
10
|
+
return message;
|
|
11
|
+
const source = message.source;
|
|
12
|
+
if (source.replayState === null || typeof source.replayState !== 'object'
|
|
13
|
+
|| !('response' in source.replayState))
|
|
14
|
+
return message;
|
|
15
|
+
const replay = source.replayState;
|
|
16
|
+
const response = replay.response;
|
|
17
|
+
if (response !== null && typeof response === 'object'
|
|
18
|
+
&& 'kind' in response && response.kind === REPLAY_KIND) {
|
|
19
|
+
if ('version' in response && response.version === 1
|
|
20
|
+
&& 'key' in response && response.key === key && 'state' in response) {
|
|
21
|
+
return { ...message, source: {
|
|
22
|
+
...source, provider: options.provider, model,
|
|
23
|
+
replayState: { ...replay, response: response.state },
|
|
24
|
+
} };
|
|
25
|
+
}
|
|
26
|
+
const { replayState: _discarded, ...withoutReplay } = source;
|
|
27
|
+
return { ...message, source: withoutReplay };
|
|
28
|
+
}
|
|
29
|
+
// Legacy replay has no originating-account identity. Preserve direct
|
|
30
|
+
// histories when provider/model match, as upstream does; switching
|
|
31
|
+
// accounts mid-session can therefore reuse the previous account's signed
|
|
32
|
+
// request prefix. An alias cannot identify the legacy concrete member.
|
|
33
|
+
if (source.provider === options.provider && source.model === model) {
|
|
34
|
+
return message;
|
|
35
|
+
}
|
|
36
|
+
const { replayState: _discarded, ...withoutReplay } = source;
|
|
37
|
+
return { ...message, source: withoutReplay };
|
|
38
|
+
});
|
|
39
|
+
for await (const chunk of adapter.streamAccount({
|
|
40
|
+
...options, model, messages,
|
|
41
|
+
}, account)) {
|
|
42
|
+
if (chunk.type === 'finish' && chunk.replayState !== undefined) {
|
|
43
|
+
yield { ...chunk, replayState: { ...chunk.replayState, response: {
|
|
44
|
+
kind: REPLAY_KIND, version: 1, key, state: chunk.replayState.response,
|
|
45
|
+
} } };
|
|
46
|
+
}
|
|
47
|
+
else
|
|
48
|
+
yield chunk;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { ProviderUsage } from './common.js';
|
|
2
|
+
export interface ResetConfirmation {
|
|
3
|
+
ticket: string;
|
|
4
|
+
expiresAt: number;
|
|
5
|
+
creditExpiresAt?: number;
|
|
6
|
+
weeklyUsedPercent: number;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Manual-only redemption with single-use tickets. An ambiguous submission parks
|
|
10
|
+
* the account to prevent an immediate blind retry. This block is process-local,
|
|
11
|
+
* not a durable rate limit: restarting the host clears it. Re-check credits and
|
|
12
|
+
* limits in Codex before retrying; a lost success response may have spent a
|
|
13
|
+
* credit already, and another submission can spend another credit.
|
|
14
|
+
* Rationale: [manual reset note](../../.agents/notes/implemented/provider/2026-10-04-codex-manual-reset.md).
|
|
15
|
+
*/
|
|
16
|
+
export declare class ResetRedemption {
|
|
17
|
+
private readonly read;
|
|
18
|
+
private readonly consume;
|
|
19
|
+
private readonly invalidate;
|
|
20
|
+
/**
|
|
21
|
+
* Stable identity for one real account: the same key the usage cache uses,
|
|
22
|
+
* so the guards and the cache invalidate together. The RPC carries whatever
|
|
23
|
+
* reference the browser holds — a canonical id, a legacy key, an email, a
|
|
24
|
+
* workspace id — and all of them resolve to one stored account.
|
|
25
|
+
*
|
|
26
|
+
* It must not fall back to the raw reference on failure. A fallback would
|
|
27
|
+
* re-key the state by whatever happened to arrive, so a transient lookup
|
|
28
|
+
* error could let a later alias past an uncertain park. Resolution here
|
|
29
|
+
* reads the local credential store only, so failing is safe and honest.
|
|
30
|
+
*/
|
|
31
|
+
private readonly canonical;
|
|
32
|
+
private tickets;
|
|
33
|
+
private busy;
|
|
34
|
+
private uncertain;
|
|
35
|
+
constructor(read: (account: string, signal: AbortSignal) => Promise<ProviderUsage>, consume: (account: string, creditId: string, requestId: string, signal: AbortSignal) => Promise<void>, invalidate: (account: string) => void,
|
|
36
|
+
/**
|
|
37
|
+
* Stable identity for one real account: the same key the usage cache uses,
|
|
38
|
+
* so the guards and the cache invalidate together. The RPC carries whatever
|
|
39
|
+
* reference the browser holds — a canonical id, a legacy key, an email, a
|
|
40
|
+
* workspace id — and all of them resolve to one stored account.
|
|
41
|
+
*
|
|
42
|
+
* It must not fall back to the raw reference on failure. A fallback would
|
|
43
|
+
* re-key the state by whatever happened to arrive, so a transient lookup
|
|
44
|
+
* error could let a later alias past an uncertain park. Resolution here
|
|
45
|
+
* reads the local credential store only, so failing is safe and honest.
|
|
46
|
+
*/
|
|
47
|
+
canonical: (account: string, signal: AbortSignal) => Promise<string>);
|
|
48
|
+
private eligible;
|
|
49
|
+
prepare(account: string, signal: AbortSignal): Promise<ResetConfirmation>;
|
|
50
|
+
redeem(account: string, ticket: string, signal: AbortSignal): Promise<void>;
|
|
51
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
/**
|
|
3
|
+
* Manual-only redemption with single-use tickets. An ambiguous submission parks
|
|
4
|
+
* the account to prevent an immediate blind retry. This block is process-local,
|
|
5
|
+
* not a durable rate limit: restarting the host clears it. Re-check credits and
|
|
6
|
+
* limits in Codex before retrying; a lost success response may have spent a
|
|
7
|
+
* credit already, and another submission can spend another credit.
|
|
8
|
+
* Rationale: [manual reset note](../../.agents/notes/implemented/provider/2026-10-04-codex-manual-reset.md).
|
|
9
|
+
*/
|
|
10
|
+
export class ResetRedemption {
|
|
11
|
+
read;
|
|
12
|
+
consume;
|
|
13
|
+
invalidate;
|
|
14
|
+
canonical;
|
|
15
|
+
tickets = new Map();
|
|
16
|
+
busy = new Set();
|
|
17
|
+
uncertain = new Set();
|
|
18
|
+
constructor(read, consume, invalidate,
|
|
19
|
+
/**
|
|
20
|
+
* Stable identity for one real account: the same key the usage cache uses,
|
|
21
|
+
* so the guards and the cache invalidate together. The RPC carries whatever
|
|
22
|
+
* reference the browser holds — a canonical id, a legacy key, an email, a
|
|
23
|
+
* workspace id — and all of them resolve to one stored account.
|
|
24
|
+
*
|
|
25
|
+
* It must not fall back to the raw reference on failure. A fallback would
|
|
26
|
+
* re-key the state by whatever happened to arrive, so a transient lookup
|
|
27
|
+
* error could let a later alias past an uncertain park. Resolution here
|
|
28
|
+
* reads the local credential store only, so failing is safe and honest.
|
|
29
|
+
*/
|
|
30
|
+
canonical) {
|
|
31
|
+
this.read = read;
|
|
32
|
+
this.consume = consume;
|
|
33
|
+
this.invalidate = invalidate;
|
|
34
|
+
this.canonical = canonical;
|
|
35
|
+
}
|
|
36
|
+
eligible(usage) {
|
|
37
|
+
const weekly = usage.windows?.find(w => w.kind === 'weekly' && w.scope === undefined);
|
|
38
|
+
if (!weekly || !Number.isFinite(weekly.usedPercent) || weekly.usedPercent < 80
|
|
39
|
+
|| weekly.resetsAt === undefined || weekly.resetsAt <= Date.now()) {
|
|
40
|
+
throw new Error('Reset blocked: fresh weekly usage must be at least 80%.');
|
|
41
|
+
}
|
|
42
|
+
if (usage.resetCreditsError)
|
|
43
|
+
throw new Error('Reset credits could not be verified.');
|
|
44
|
+
return weekly.usedPercent;
|
|
45
|
+
}
|
|
46
|
+
async prepare(account, signal) {
|
|
47
|
+
const who = await this.canonical(account, signal);
|
|
48
|
+
if (this.busy.has(who) || this.uncertain.has(who))
|
|
49
|
+
throw new Error('Reset blocked: an operation is pending or its outcome is uncertain. Check Codex before retrying.');
|
|
50
|
+
const usage = await this.read(account, signal);
|
|
51
|
+
// Re-check after the read. A confirmation prepared while another client was
|
|
52
|
+
// redeeming would otherwise be minted against a park that has since been
|
|
53
|
+
// cleared, letting the next click spend a second credit.
|
|
54
|
+
if (this.busy.has(who) || this.uncertain.has(who))
|
|
55
|
+
throw new Error('Reset blocked: an operation is pending or its outcome is uncertain. Check Codex before retrying.');
|
|
56
|
+
const weeklyUsedPercent = this.eligible(usage);
|
|
57
|
+
const credit = usage.resetCredits?.filter(c => c.id && (c.expiresAt === undefined || c.expiresAt > Date.now()))
|
|
58
|
+
.sort((a, b) => (a.expiresAt ?? Infinity) - (b.expiresAt ?? Infinity))[0];
|
|
59
|
+
if (!credit?.id)
|
|
60
|
+
throw new Error('No available reset credit.');
|
|
61
|
+
for (const [key, value] of this.tickets)
|
|
62
|
+
if (value.account === who || value.expiresAt <= Date.now())
|
|
63
|
+
this.tickets.delete(key);
|
|
64
|
+
const ticket = randomUUID();
|
|
65
|
+
const expiresAt = Date.now() + 60_000;
|
|
66
|
+
this.tickets.set(ticket, { account: who, creditId: credit.id, expiresAt });
|
|
67
|
+
return { ticket, expiresAt, weeklyUsedPercent, ...(credit.expiresAt === undefined ? {} : { creditExpiresAt: credit.expiresAt }) };
|
|
68
|
+
}
|
|
69
|
+
async redeem(account, ticket, signal) {
|
|
70
|
+
const who = await this.canonical(account, signal);
|
|
71
|
+
const pending = this.tickets.get(ticket);
|
|
72
|
+
this.tickets.delete(ticket);
|
|
73
|
+
if (!pending || pending.account !== who || pending.expiresAt <= Date.now())
|
|
74
|
+
throw new Error('Confirmation expired. Start again.');
|
|
75
|
+
if (this.busy.has(who) || this.uncertain.has(who))
|
|
76
|
+
throw new Error('Reset blocked: operation pending or uncertain.');
|
|
77
|
+
this.busy.add(who);
|
|
78
|
+
try {
|
|
79
|
+
const usage = await this.read(account, signal);
|
|
80
|
+
// The ticket bounds how long the confirmation counts. A read that
|
|
81
|
+
// outlives it must not go on to submit, so this comes first.
|
|
82
|
+
if (pending.expiresAt <= Date.now())
|
|
83
|
+
throw new Error('Confirmation expired. Start again.');
|
|
84
|
+
this.eligible(usage);
|
|
85
|
+
if (!usage.resetCredits?.some(c => c.id === pending.creditId && (c.expiresAt === undefined || c.expiresAt > Date.now())))
|
|
86
|
+
throw new Error('The selected reset is no longer available.');
|
|
87
|
+
// Mark before submission: no blind retry after transport or response ambiguity.
|
|
88
|
+
this.uncertain.add(who);
|
|
89
|
+
try {
|
|
90
|
+
await this.consume(account, pending.creditId, randomUUID(), signal);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
throw new Error('Reset outcome could not be confirmed. Do not retry or restart to bypass this block; verify credits and limits in Codex first.');
|
|
94
|
+
}
|
|
95
|
+
this.uncertain.delete(who);
|
|
96
|
+
}
|
|
97
|
+
finally {
|
|
98
|
+
this.invalidate(who);
|
|
99
|
+
this.busy.delete(who);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -21,6 +21,7 @@ import { AccountTokenManager } from '../providers/accounts.js';
|
|
|
21
21
|
import { ImageAccountPool } from '../providers/image-pool.js';
|
|
22
22
|
import { codexRateLimitReset } from '../providers/codex.js';
|
|
23
23
|
import { grokRateLimitReset } from '../providers/grok.js';
|
|
24
|
+
import { parseProviderJson } from '../providers/common.js';
|
|
24
25
|
import { hostFetch } from '../http.js';
|
|
25
26
|
/** Endpoint the codex generation request is posted to. */
|
|
26
27
|
export const IMAGE_GENERATE_URL = 'https://chatgpt.com/backend-api/codex/images/generations';
|
|
@@ -387,7 +388,7 @@ export function createImageGenerateTool(options) {
|
|
|
387
388
|
await manager.session(); // logged out: throws the provider's log-in hint
|
|
388
389
|
throw new Error('image_generate: no image provider is logged in');
|
|
389
390
|
}
|
|
390
|
-
const images = parseImageGenerateResponse(await response
|
|
391
|
+
const images = parseImageGenerateResponse(await parseProviderJson(response, 'image generate'));
|
|
391
392
|
const directory = options.imagesDir ?? imagesDirectory();
|
|
392
393
|
await mkdir(directory, { recursive: true });
|
|
393
394
|
const paths = [];
|
|
@@ -57,10 +57,6 @@ export declare function buildVideoGenerateBody(args: {
|
|
|
57
57
|
resolution?: '480p' | '720p' | '1080p';
|
|
58
58
|
image_url?: string;
|
|
59
59
|
}): VideoGenerateRequestBody;
|
|
60
|
-
/**
|
|
61
|
-
* Extract the request id from the submit response. Throws when the payload
|
|
62
|
-
* carries none.
|
|
63
|
-
*/
|
|
64
60
|
export declare function parseVideoStartResponse(payload: unknown): string;
|
|
65
61
|
/** One decoded poll response. */
|
|
66
62
|
export type VideoStatus = {
|
|
@@ -71,7 +67,6 @@ export type VideoStatus = {
|
|
|
71
67
|
duration?: number;
|
|
72
68
|
} | {
|
|
73
69
|
status: 'failed' | 'expired';
|
|
74
|
-
detail?: string;
|
|
75
70
|
};
|
|
76
71
|
/**
|
|
77
72
|
* Decode one poll response. A `done` payload without a video URL and an
|
|
@@ -12,7 +12,7 @@ import { mkdir, writeFile } from 'node:fs/promises';
|
|
|
12
12
|
import { basename, join } from 'node:path';
|
|
13
13
|
import { dshHomePath } from '@deepseek-ai/dsh-home-paths';
|
|
14
14
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
15
|
-
import { httpLlmError } from '../providers/common.js';
|
|
15
|
+
import { httpLlmError, parseProviderJson } from '../providers/common.js';
|
|
16
16
|
import { AccountTokenManager } from '../providers/accounts.js';
|
|
17
17
|
import { hostFetch } from '../http.js';
|
|
18
18
|
/** Endpoint the generation request is posted to. */
|
|
@@ -60,10 +60,20 @@ function isRecord(value) {
|
|
|
60
60
|
* Extract the request id from the submit response. Throws when the payload
|
|
61
61
|
* carries none.
|
|
62
62
|
*/
|
|
63
|
+
/**
|
|
64
|
+
* A provider-assigned request id, accepted only in a bounded identifier shape.
|
|
65
|
+
* The value reaches a user-visible failure message, so an unchecked one would
|
|
66
|
+
* let a provider put arbitrary text there. The shape rules out prose, spaces
|
|
67
|
+
* and message excerpts; it does NOT prove the value is harmless, because a
|
|
68
|
+
* credential-shaped string such as `SHORT_SECRET` matches it. Treat this as a
|
|
69
|
+
* bounded, format-constrained provider value that is displayed for support
|
|
70
|
+
* correlation — not as the absence of provider-supplied content.
|
|
71
|
+
*/
|
|
72
|
+
const REQUEST_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/;
|
|
63
73
|
export function parseVideoStartResponse(payload) {
|
|
64
74
|
const body = isRecord(payload) ? payload : {};
|
|
65
|
-
if (typeof body.request_id !== 'string' || body.request_id
|
|
66
|
-
throw new Error('video_generate: the response carried no request_id');
|
|
75
|
+
if (typeof body.request_id !== 'string' || !REQUEST_ID_PATTERN.test(body.request_id)) {
|
|
76
|
+
throw new Error('video_generate: the response carried no usable request_id');
|
|
67
77
|
}
|
|
68
78
|
return body.request_id;
|
|
69
79
|
}
|
|
@@ -89,15 +99,13 @@ export function parseVideoStatusResponse(payload) {
|
|
|
89
99
|
};
|
|
90
100
|
}
|
|
91
101
|
case 'failed':
|
|
92
|
-
case 'expired':
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
: typeof body.error === 'string' && body.error.length > 0 ? body.error : undefined;
|
|
97
|
-
return { status: body.status, ...detail === undefined ? {} : { detail } };
|
|
98
|
-
}
|
|
102
|
+
case 'expired':
|
|
103
|
+
// Safe means this failure path carries no provider-controlled free text,
|
|
104
|
+
// not that no secret can exist elsewhere in the system.
|
|
105
|
+
return { status: body.status };
|
|
99
106
|
default:
|
|
100
|
-
|
|
107
|
+
// Safe here also excludes provider-controlled free text, not all system secrets.
|
|
108
|
+
throw new Error('video_generate: unexpected status: [provider response body omitted]');
|
|
101
109
|
}
|
|
102
110
|
}
|
|
103
111
|
/** Directory the downloaded MP4 files are written to. */
|
|
@@ -212,7 +220,7 @@ export function createVideoGenerateTool(options) {
|
|
|
212
220
|
});
|
|
213
221
|
if (!submit.ok)
|
|
214
222
|
throw await httpLlmError(submit, 'video_generate');
|
|
215
|
-
const requestId = parseVideoStartResponse(await submit
|
|
223
|
+
const requestId = parseVideoStartResponse(await parseProviderJson(submit, 'video generate start'));
|
|
216
224
|
const deadline = Date.now() + maxWaitMs;
|
|
217
225
|
let done;
|
|
218
226
|
for (;;) {
|
|
@@ -224,14 +232,17 @@ export function createVideoGenerateTool(options) {
|
|
|
224
232
|
});
|
|
225
233
|
if (!poll.ok)
|
|
226
234
|
throw await httpLlmError(poll, 'video_generate');
|
|
227
|
-
const status = parseVideoStatusResponse(await poll
|
|
235
|
+
const status = parseVideoStatusResponse(await parseProviderJson(poll, 'video generate status poll'));
|
|
228
236
|
if (status.status === 'done') {
|
|
229
237
|
done = status;
|
|
230
238
|
break;
|
|
231
239
|
}
|
|
232
240
|
if (status.status === 'failed' || status.status === 'expired') {
|
|
241
|
+
// No provider response text on this path. The request id is shaped like an
|
|
242
|
+
// opaque id before it is stored (see REQUEST_ID_PATTERN), so it is an
|
|
243
|
+
// identifier rather than provider prose; anything else was refused at parse.
|
|
233
244
|
throw new Error(`video_generate: generation ${status.status} (request ${requestId})`
|
|
234
|
-
+
|
|
245
|
+
+ ': [provider response body omitted]');
|
|
235
246
|
}
|
|
236
247
|
if (Date.now() >= deadline) {
|
|
237
248
|
throw new Error(`video_generate: timed out after ${String(maxWaitMs)}ms waiting for request ${requestId}`);
|
package/lib/tools/x-search.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* output is `{ answer, citations }`.
|
|
6
6
|
*/
|
|
7
7
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
8
|
-
import { httpLlmError } from '../providers/common.js';
|
|
8
|
+
import { httpLlmError, parseProviderJson } from '../providers/common.js';
|
|
9
9
|
import { AccountTokenManager } from '../providers/accounts.js';
|
|
10
10
|
import { hostFetch } from '../http.js';
|
|
11
11
|
/** Endpoint the search request is posted to. */
|
|
@@ -191,7 +191,7 @@ export function createXSearchTool(options) {
|
|
|
191
191
|
});
|
|
192
192
|
if (!response.ok)
|
|
193
193
|
throw await httpLlmError(response, 'x_search');
|
|
194
|
-
return parseXSearchResponse(await response
|
|
194
|
+
return parseXSearchResponse(await parseProviderJson(response, 'x search'));
|
|
195
195
|
},
|
|
196
196
|
});
|
|
197
197
|
}
|
|
@@ -32,6 +32,23 @@ export declare const CACHE_BLOCK_STRIDE = 15;
|
|
|
32
32
|
* blocks before a read is lost.
|
|
33
33
|
*/
|
|
34
34
|
export declare const MESSAGE_CACHE_BREAKPOINTS = 3;
|
|
35
|
+
/**
|
|
36
|
+
* How long Anthropic keeps a cache entry after its last use: five minutes
|
|
37
|
+
* (the API default) or one hour. A one-hour entry is written at twice the base
|
|
38
|
+
* input price instead of 1.25×, and reads cost the same either way.
|
|
39
|
+
*/
|
|
40
|
+
export type PromptCacheTtl = '5m' | '1h';
|
|
41
|
+
/** The API default; a marker at this TTL carries no `ttl` field at all. */
|
|
42
|
+
export declare const DEFAULT_PROMPT_CACHE_TTL: PromptCacheTtl;
|
|
43
|
+
/**
|
|
44
|
+
* Build one `cache_control` marker.
|
|
45
|
+
*
|
|
46
|
+
* Five minutes is spelled by omitting `ttl`, so a request that does not opt in
|
|
47
|
+
* stays byte-identical to what earlier releases sent.
|
|
48
|
+
* @param ttl - cache lifetime; defaults to five minutes.
|
|
49
|
+
* @returns a fresh marker object (callers attach it to one block each).
|
|
50
|
+
*/
|
|
51
|
+
export declare function cacheControl(ttl?: PromptCacheTtl): Record<string, unknown>;
|
|
35
52
|
/** One Anthropic request message. */
|
|
36
53
|
export interface AnthropicMessage {
|
|
37
54
|
role: 'user' | 'assistant';
|
|
@@ -62,17 +79,19 @@ export declare function toAnthropicMessages(messages: readonly TranslatableMessa
|
|
|
62
79
|
* Marks are counted across the flattened block sequence, not per message,
|
|
63
80
|
* because the lookback window Anthropic walks counts blocks the same way.
|
|
64
81
|
* @param messages - assembled Anthropic messages, marked in place.
|
|
82
|
+
* @param ttl - cache lifetime for every mark; defaults to five minutes.
|
|
65
83
|
*/
|
|
66
|
-
export declare function markMessageCache(messages: readonly AnthropicMessage[]): void;
|
|
84
|
+
export declare function markMessageCache(messages: readonly AnthropicMessage[], ttl?: PromptCacheTtl): void;
|
|
67
85
|
/**
|
|
68
86
|
* Build the Anthropic `system` array: the mandatory Claude Code identity
|
|
69
87
|
* block, then the explicit system prompt, then any system-role messages.
|
|
70
88
|
* @param system - explicit system prompt, when set.
|
|
71
89
|
* @param messages - conversation messages; the system-role text preceding the
|
|
72
90
|
* conversation is appended, and a later one is left to {@link toAnthropicMessages}.
|
|
91
|
+
* @param ttl - cache lifetime for the tools+system mark; defaults to five minutes.
|
|
73
92
|
* @returns the system content blocks.
|
|
74
93
|
*/
|
|
75
|
-
export declare function toAnthropicSystem(system?: string, messages?: readonly TranslatableMessage[]): Record<string, unknown>[];
|
|
94
|
+
export declare function toAnthropicSystem(system?: string, messages?: readonly TranslatableMessage[], ttl?: PromptCacheTtl): Record<string, unknown>[];
|
|
76
95
|
/**
|
|
77
96
|
* Map harness tool schemas to Anthropic tools, in name order.
|
|
78
97
|
*
|