dsh-subscriptions 0.1.2 → 0.1.4
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 +92 -10
- package/lib/auth/rpc.d.ts +3 -1
- package/lib/auth/rpc.js +10 -1
- package/lib/client/ExternalUsageCards.d.ts +11 -1
- package/lib/client/ExternalUsageCards.js +18 -3
- 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.d.ts +19 -2
- package/lib/client/SubscriptionUsageBadge.js +65 -9
- package/lib/client/SubscriptionsSection.d.ts +22 -0
- package/lib/client/SubscriptionsSection.js +128 -38
- package/lib/client/UsageBadgeDisplaySetting.js +6 -10
- package/lib/client/UsageMeter.js +11 -6
- package/lib/client/host-contract.d.ts +1 -1
- package/lib/client/host-contract.js +22 -0
- package/lib/client/locales.d.ts +60 -0
- package/lib/client/locales.js +62 -2
- 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/usage-badge-preferences.d.ts +7 -2
- package/lib/client/usage-badge-preferences.js +3 -2
- package/lib/client/usage-pace.d.ts +14 -1
- package/lib/client/usage-pace.js +14 -1
- package/lib/client.js +1036 -334
- package/lib/client.js.map +1 -1
- package/lib/index.d.ts +9 -0
- package/lib/index.js +1120 -602
- package/lib/providers/account-preferences.js +3 -2
- 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 +113 -15
- package/lib/providers/codex.d.ts +15 -2
- package/lib/providers/codex.js +72 -4
- package/lib/providers/common.d.ts +25 -0
- package/lib/providers/common.js +22 -18
- package/lib/providers/copilot.d.ts +2 -1
- package/lib/providers/copilot.js +10 -3
- package/lib/providers/grok.d.ts +2 -2
- package/lib/providers/grok.js +2 -2
- package/lib/providers/minimax-usage.d.ts +22 -1
- package/lib/providers/minimax-usage.js +37 -7
- 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/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 +21 -0
- package/lib/translate/resolved.js +63 -3
- package/lib/translate/responses.js +8 -6
- package/lib/translate/sse.js +8 -0
- package/package.json +1 -1
package/lib/providers/common.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { createHash, randomUUID } from 'node:crypto';
|
|
9
9
|
import { CONTEXT_WINDOW_EXCEEDED_CODE, isContextWindowExceededError, isQuotaExceededError, LlmError, QUOTA_EXCEEDED_CODE, ReasoningEffortId, } from '@deepseek-ai/dsh-llm';
|
|
10
|
-
import { durationMs,
|
|
10
|
+
import { durationMs, retryAfterInstant, waitFromReset } from './rate-limit.js';
|
|
11
11
|
/**
|
|
12
12
|
* Validate a configured model catalog (mirrors llm-deepseek's resolveModels).
|
|
13
13
|
* @param models - raw configured entries.
|
|
@@ -50,6 +50,17 @@ export function validateModels(models, label) {
|
|
|
50
50
|
};
|
|
51
51
|
});
|
|
52
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* Provider failures can echo credentials or customer input in any format.
|
|
55
|
+
* Shape-based filtering cannot make arbitrary response text safe to display.
|
|
56
|
+
* Here, safe means this diagnostic carries no provider-controlled free text,
|
|
57
|
+
* not that secrets cannot exist elsewhere in the system. Keep raw responses
|
|
58
|
+
* internal for classification and reset parsing; display only local summaries.
|
|
59
|
+
* This deliberately loses model, parameter and plan details useful to support.
|
|
60
|
+
*/
|
|
61
|
+
function providerDiagnostic(summary, hasBody) {
|
|
62
|
+
return hasBody ? `${summary}: [provider response body omitted]` : summary;
|
|
63
|
+
}
|
|
53
64
|
/**
|
|
54
65
|
* Build an LlmError from a non-2xx provider response, mapping the status to a
|
|
55
66
|
* stable code and, for a rate-limited request, the disclosed reset instant to
|
|
@@ -73,11 +84,8 @@ export async function httpLlmError(response, label, options = {}) {
|
|
|
73
84
|
catch {
|
|
74
85
|
// Only swallow error-body reading: the HTTP status still identifies the failure.
|
|
75
86
|
}
|
|
76
|
-
//
|
|
87
|
+
// Preserve the existing classification window; reset readers need the full body.
|
|
77
88
|
const shown = body.slice(0, 500);
|
|
78
|
-
const message = shown.length > 0
|
|
79
|
-
? `${label} error (HTTP ${String(response.status)}): ${shown}`
|
|
80
|
-
: `${label} error (HTTP ${String(response.status)})`;
|
|
81
89
|
let code;
|
|
82
90
|
if (response.status === 401 || response.status === 403)
|
|
83
91
|
code = 'AUTH';
|
|
@@ -93,6 +101,7 @@ export async function httpLlmError(response, label, options = {}) {
|
|
|
93
101
|
code = 'SERVER';
|
|
94
102
|
else
|
|
95
103
|
code = `HTTP_${String(response.status)}`;
|
|
104
|
+
const message = providerDiagnostic(`${label} error (HTTP ${String(response.status)}, ${code})`, body.length > 0);
|
|
96
105
|
const now = Date.now();
|
|
97
106
|
// The provider's reader runs on a 429 and nowhere else. Providers attach
|
|
98
107
|
// their rate-limit headers to every response, so reading them on a transient
|
|
@@ -111,7 +120,8 @@ export async function httpLlmError(response, label, options = {}) {
|
|
|
111
120
|
? options.rateLimitReset?.(response, body, now) ?? googleQuotaReset(body, now) ?? retryAfterInstant(response, now)
|
|
112
121
|
: retryAfterInstant(response, now);
|
|
113
122
|
if (reset === undefined && rateLimited) {
|
|
114
|
-
|
|
123
|
+
// Header names and values are provider-controlled too; none are displayed.
|
|
124
|
+
options.onWarn?.(providerDiagnostic(`${label}: 429 disclosed no reset time; [provider response headers omitted]`, body.length > 0));
|
|
115
125
|
}
|
|
116
126
|
return new LlmError(message, code, {
|
|
117
127
|
status: response.status,
|
|
@@ -246,9 +256,10 @@ export class OAuthEndpointError extends Error {
|
|
|
246
256
|
*/
|
|
247
257
|
export async function oauthEndpointError(response, label) {
|
|
248
258
|
let oauthCode;
|
|
249
|
-
let
|
|
259
|
+
let body = '';
|
|
250
260
|
try {
|
|
251
|
-
|
|
261
|
+
body = await response.text();
|
|
262
|
+
const parsed = JSON.parse(body);
|
|
252
263
|
if (typeof parsed.error === 'string') {
|
|
253
264
|
oauthCode = parsed.error;
|
|
254
265
|
}
|
|
@@ -256,20 +267,13 @@ export async function oauthEndpointError(response, label) {
|
|
|
256
267
|
const nested = parsed.error;
|
|
257
268
|
if (typeof nested.code === 'string' && nested.code.length > 0)
|
|
258
269
|
oauthCode = nested.code;
|
|
259
|
-
if (typeof nested.message === 'string')
|
|
260
|
-
detail = nested.message;
|
|
261
270
|
}
|
|
262
|
-
if (typeof parsed.error_description === 'string')
|
|
263
|
-
detail = parsed.error_description;
|
|
264
|
-
if (detail.length === 0)
|
|
265
|
-
detail = oauthCode ?? '';
|
|
266
271
|
}
|
|
267
272
|
catch {
|
|
268
|
-
// Only swallow error-body parsing:
|
|
273
|
+
// Only swallow error-body reading/parsing: HTTP status still identifies the failure.
|
|
269
274
|
}
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
: `${label} token endpoint error (HTTP ${String(response.status)})`;
|
|
275
|
+
// oauthCode remains structural for auth classification, never display text.
|
|
276
|
+
const message = providerDiagnostic(`${label} token endpoint error (HTTP ${String(response.status)})`, body.length > 0);
|
|
273
277
|
return new OAuthEndpointError(message, response.status, oauthCode, parseRetryAfterMs(response));
|
|
274
278
|
}
|
|
275
279
|
/**
|
|
@@ -297,7 +297,8 @@ export declare class CopilotAdapter extends LlmAdapter {
|
|
|
297
297
|
* account identity is the session's long-lived GitHub token (stable across
|
|
298
298
|
* Copilot-token refreshes, different per GitHub login); the conversation is
|
|
299
299
|
* the loop-stamped `sessionId`, falling back to the first message's id
|
|
300
|
-
* when a hand-built request carries no session stamp
|
|
300
|
+
* when a hand-built request carries no session stamp. Identity-free
|
|
301
|
+
* requests have no replay scope; the model separates
|
|
301
302
|
* wire families. A call id captured in one scope is invisible to every
|
|
302
303
|
* other scope, so reused ids cannot leak reasoning across accounts,
|
|
303
304
|
* conversations, or models.
|
package/lib/providers/copilot.js
CHANGED
|
@@ -677,7 +677,8 @@ export class CopilotAdapter extends LlmAdapter {
|
|
|
677
677
|
* account identity is the session's long-lived GitHub token (stable across
|
|
678
678
|
* Copilot-token refreshes, different per GitHub login); the conversation is
|
|
679
679
|
* the loop-stamped `sessionId`, falling back to the first message's id
|
|
680
|
-
* when a hand-built request carries no session stamp
|
|
680
|
+
* when a hand-built request carries no session stamp. Identity-free
|
|
681
|
+
* requests have no replay scope; the model separates
|
|
681
682
|
* wire families. A call id captured in one scope is invisible to every
|
|
682
683
|
* other scope, so reused ids cannot leak reasoning across accounts,
|
|
683
684
|
* conversations, or models.
|
|
@@ -685,9 +686,11 @@ export class CopilotAdapter extends LlmAdapter {
|
|
|
685
686
|
replayScope(tokenKey, options) {
|
|
686
687
|
const conversation = options.sessionId !== undefined
|
|
687
688
|
? `session:${String(options.sessionId)}`
|
|
688
|
-
: options.messages[0] !== undefined
|
|
689
|
+
: options.messages[0]?.id !== undefined
|
|
689
690
|
? `anchor:${String(options.messages[0].id)}`
|
|
690
|
-
:
|
|
691
|
+
: undefined;
|
|
692
|
+
if (conversation === undefined)
|
|
693
|
+
return undefined;
|
|
691
694
|
return `${tokenKey}\u0000${conversation}\u0000${options.model}`;
|
|
692
695
|
}
|
|
693
696
|
/**
|
|
@@ -701,6 +704,8 @@ export class CopilotAdapter extends LlmAdapter {
|
|
|
701
704
|
* per call.
|
|
702
705
|
*/
|
|
703
706
|
captureReasoning(scope, callIds, items) {
|
|
707
|
+
if (scope === undefined)
|
|
708
|
+
return;
|
|
704
709
|
let entries = this.replayByScope.get(scope);
|
|
705
710
|
if (entries === undefined) {
|
|
706
711
|
entries = new Map();
|
|
@@ -745,6 +750,8 @@ export class CopilotAdapter extends LlmAdapter {
|
|
|
745
750
|
* degradation, never an error.
|
|
746
751
|
*/
|
|
747
752
|
replayFor(scope, callId) {
|
|
753
|
+
if (scope === undefined)
|
|
754
|
+
return undefined;
|
|
748
755
|
const entries = this.replayByScope.get(scope);
|
|
749
756
|
const entry = entries?.get(callId);
|
|
750
757
|
if (entries === undefined || entry === undefined)
|
package/lib/providers/grok.d.ts
CHANGED
|
@@ -25,8 +25,8 @@ export declare const GROK_PREEMPT_MS: number;
|
|
|
25
25
|
* whose values are rollover durations (`6m0s`) present on every response, one
|
|
26
26
|
* per bucket — on a 429 the earliest of them is usually a bucket with room
|
|
27
27
|
* (`0s` for the request bucket while the token bucket is the one exhausted),
|
|
28
|
-
* which would burn the whole retry budget in seconds.
|
|
29
|
-
*
|
|
28
|
+
* which would burn the whole retry budget in seconds. The parsed body reset
|
|
29
|
+
* supplies the retry hint; diagnostics omit raw provider headers and body text.
|
|
30
30
|
*/
|
|
31
31
|
export declare const grokRateLimitReset: RateLimitResetReader;
|
|
32
32
|
/** Discovered OIDC endpoints for the xAI authorization server. */
|
package/lib/providers/grok.js
CHANGED
|
@@ -29,8 +29,8 @@ const GROK_RESET_FIELDS = ['retry_after', 'retry_after_seconds', 'resets_at', 'r
|
|
|
29
29
|
* whose values are rollover durations (`6m0s`) present on every response, one
|
|
30
30
|
* per bucket — on a 429 the earliest of them is usually a bucket with room
|
|
31
31
|
* (`0s` for the request bucket while the token bucket is the one exhausted),
|
|
32
|
-
* which would burn the whole retry budget in seconds.
|
|
33
|
-
*
|
|
32
|
+
* which would burn the whole retry budget in seconds. The parsed body reset
|
|
33
|
+
* supplies the retry hint; diagnostics omit raw provider headers and body text.
|
|
34
34
|
*/
|
|
35
35
|
export const grokRateLimitReset = (_response, body, now) => resetFromFields(jsonBody(body), GROK_RESET_FIELDS, now);
|
|
36
36
|
/** A discovered URL must be https on x.ai or a subdomain; anything else is a hostile document. */
|
|
@@ -1,3 +1,24 @@
|
|
|
1
1
|
import type { ProviderUsage } from './common.js';
|
|
2
|
-
/**
|
|
2
|
+
/**
|
|
3
|
+
* Read MiniMax subscription quota (M Plan and Token Plan), not the balance API
|
|
4
|
+
* that `sk-api-*` pay-as-you-go keys use.
|
|
5
|
+
*
|
|
6
|
+
* Each `model_remains` row can yield an interval window and a weekly window.
|
|
7
|
+
* A window needs a finite share: the explicit `*_remaining_percent` (the weekly
|
|
8
|
+
* one scaled by `weekly_boost_permille`), else the legacy counts. Time-based
|
|
9
|
+
* plans report zero counts beside the percentage for standard models, so the
|
|
10
|
+
* percentage stands on its own and a zero total does not hide the window.
|
|
11
|
+
* Windows that cannot be a finite bar are omitted rather than approximated:
|
|
12
|
+
* status 3 (unlimited, or no quota in the plan), a weekly pool boosted past
|
|
13
|
+
* 100% remaining, a percentage outside 0-100, and a row with neither a
|
|
14
|
+
* percentage nor positive counts. Start and end times are kept only when both
|
|
15
|
+
* are valid millisecond bounds.
|
|
16
|
+
* @param key - subscription key for the region; never copied into a result or error.
|
|
17
|
+
* @param region - `global` or `cn`, which selects the fixed quota host.
|
|
18
|
+
* @param http - fetch implementation (injectable for tests).
|
|
19
|
+
* @param signal - caller cancellation from the RPC transport.
|
|
20
|
+
* @returns the finite windows in the order the API lists them.
|
|
21
|
+
* @throws when the key is pay-as-you-go, the endpoint or its business status
|
|
22
|
+
* fails, the body has no `model_remains`, or no window is a finite bar.
|
|
23
|
+
*/
|
|
3
24
|
export declare function fetchMiniMaxUsage(key: string, region: 'global' | 'cn', http?: typeof fetch, signal?: AbortSignal): Promise<ProviderUsage>;
|
|
@@ -1,4 +1,36 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
|
+
* Used percentage from the legacy counts, read as the official CLI does when no
|
|
3
|
+
* explicit percentage exists: the count is the quota remaining. Undefined
|
|
4
|
+
* unless the total is a positive finite number, so a zero total never yields a
|
|
5
|
+
* bar.
|
|
6
|
+
*/
|
|
7
|
+
function usedFromCounts(total, count) {
|
|
8
|
+
return typeof total === 'number' && Number.isFinite(total) && total > 0 && typeof count === 'number'
|
|
9
|
+
? (1 - count / total) * 100
|
|
10
|
+
: undefined;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Read MiniMax subscription quota (M Plan and Token Plan), not the balance API
|
|
14
|
+
* that `sk-api-*` pay-as-you-go keys use.
|
|
15
|
+
*
|
|
16
|
+
* Each `model_remains` row can yield an interval window and a weekly window.
|
|
17
|
+
* A window needs a finite share: the explicit `*_remaining_percent` (the weekly
|
|
18
|
+
* one scaled by `weekly_boost_permille`), else the legacy counts. Time-based
|
|
19
|
+
* plans report zero counts beside the percentage for standard models, so the
|
|
20
|
+
* percentage stands on its own and a zero total does not hide the window.
|
|
21
|
+
* Windows that cannot be a finite bar are omitted rather than approximated:
|
|
22
|
+
* status 3 (unlimited, or no quota in the plan), a weekly pool boosted past
|
|
23
|
+
* 100% remaining, a percentage outside 0-100, and a row with neither a
|
|
24
|
+
* percentage nor positive counts. Start and end times are kept only when both
|
|
25
|
+
* are valid millisecond bounds.
|
|
26
|
+
* @param key - subscription key for the region; never copied into a result or error.
|
|
27
|
+
* @param region - `global` or `cn`, which selects the fixed quota host.
|
|
28
|
+
* @param http - fetch implementation (injectable for tests).
|
|
29
|
+
* @param signal - caller cancellation from the RPC transport.
|
|
30
|
+
* @returns the finite windows in the order the API lists them.
|
|
31
|
+
* @throws when the key is pay-as-you-go, the endpoint or its business status
|
|
32
|
+
* fails, the body has no `model_remains`, or no window is a finite bar.
|
|
33
|
+
*/
|
|
2
34
|
export async function fetchMiniMaxUsage(key, region, http = fetch, signal) {
|
|
3
35
|
if (key.startsWith('sk-api-'))
|
|
4
36
|
throw new Error('MiniMax requires a subscription key, not a pay-as-you-go key');
|
|
@@ -30,13 +62,11 @@ export async function fetchMiniMaxUsage(key, region, http = fetch, signal) {
|
|
|
30
62
|
const boost = prefix === 'current_weekly' ? row.weekly_boost_permille ?? 1000 : 1000;
|
|
31
63
|
if (typeof boost !== 'number' || !Number.isFinite(boost) || boost <= 0)
|
|
32
64
|
continue;
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
continue;
|
|
37
|
-
// Official CLI compatibility: absent percentages use legacy remaining counts.
|
|
65
|
+
// An explicit percentage needs no counts: time-based plans send zero
|
|
66
|
+
// counts beside it. Official CLI compatibility: without a percentage, the
|
|
67
|
+
// legacy counts are the quota remaining.
|
|
38
68
|
const usedPercent = typeof remaining === 'number' ? 100 - remaining * boost / 1000
|
|
39
|
-
:
|
|
69
|
+
: usedFromCounts(row[`${prefix}_total_count`], row[`${prefix}_usage_count`]);
|
|
40
70
|
if (usedPercent === undefined || !Number.isFinite(usedPercent) || usedPercent < 0 || usedPercent > 100)
|
|
41
71
|
continue;
|
|
42
72
|
const start = row[startKey];
|
|
@@ -69,6 +69,8 @@ export declare class PoolHealthRegistry {
|
|
|
69
69
|
* `providerRetryAfterMs`.
|
|
70
70
|
*/
|
|
71
71
|
earliestRecovery(keys: ReadonlySet<string>, now?: number): number | undefined;
|
|
72
|
+
/** Active failure codes for the given keys; reading them does not change cooldowns. */
|
|
73
|
+
unavailableReasons(keys: ReadonlySet<string>, now?: number): string[];
|
|
72
74
|
/** Drop records of one provider, or of a single account when given (auth changes). */
|
|
73
75
|
clear(provider: ProviderId, account?: string): void;
|
|
74
76
|
}
|
|
@@ -139,6 +139,15 @@ export class PoolHealthRegistry {
|
|
|
139
139
|
}
|
|
140
140
|
return earliest;
|
|
141
141
|
}
|
|
142
|
+
/** Active failure codes for the given keys; reading them does not change cooldowns. */
|
|
143
|
+
unavailableReasons(keys, now = Date.now()) {
|
|
144
|
+
const reasons = [];
|
|
145
|
+
for (const [key, record] of this.records) {
|
|
146
|
+
if (keys.has(key) && record.unavailableUntil > now)
|
|
147
|
+
reasons.push(record.reason);
|
|
148
|
+
}
|
|
149
|
+
return reasons;
|
|
150
|
+
}
|
|
142
151
|
/** Drop records of one provider, or of a single account when given (auth changes). */
|
|
143
152
|
clear(provider, account) {
|
|
144
153
|
const prefix = account === undefined ? `${provider}/` : `${provider}/${account}/`;
|
|
@@ -39,6 +39,11 @@ export declare class PoolUsageTracker {
|
|
|
39
39
|
private readonly ttlMs;
|
|
40
40
|
private readonly entries;
|
|
41
41
|
private readonly inflight;
|
|
42
|
+
/** Bumped by {@link invalidate} so a fetch started before it cannot repopulate the cache. */
|
|
43
|
+
private readonly generation;
|
|
44
|
+
/** Monotonic per-key write order: only the newest settled fetch may cache. */
|
|
45
|
+
private nextSequence;
|
|
46
|
+
private readonly lastWritten;
|
|
42
47
|
constructor(fetcherFor: (provider: ProviderId, account: string) => (() => Promise<ProviderUsage>) | undefined, ttlMs?: number);
|
|
43
48
|
/**
|
|
44
49
|
* The quota view of one member. A cold cache awaits the first fetch; a
|
|
@@ -70,7 +75,14 @@ export declare class PoolUsageTracker {
|
|
|
70
75
|
* @returns `{ supported: false }` when the provider has no usage fetcher.
|
|
71
76
|
*/
|
|
72
77
|
snapshotFor(provider: ProviderId, account: string, force?: boolean): Promise<ProviderUsage>;
|
|
73
|
-
/**
|
|
78
|
+
/**
|
|
79
|
+
* Drop cached snapshots: one account, or a whole provider when `account` is
|
|
80
|
+
* omitted. A fetch already in flight is abandoned too. It was started against
|
|
81
|
+
* state the caller has just declared changed — a redeemed credit, a completed
|
|
82
|
+
* login — so joining it would return the pre-change numbers and cache them for
|
|
83
|
+
* the full freshness window. Its result is discarded on arrival by the
|
|
84
|
+
* generation check in {@link refresh}.
|
|
85
|
+
*/
|
|
74
86
|
invalidate(provider: ProviderId, account?: string): void;
|
|
75
87
|
/**
|
|
76
88
|
* Run (or join) the single in-flight fetch for one account key, caching
|
|
@@ -34,6 +34,11 @@ export class PoolUsageTracker {
|
|
|
34
34
|
ttlMs;
|
|
35
35
|
entries = new Map();
|
|
36
36
|
inflight = new Map();
|
|
37
|
+
/** Bumped by {@link invalidate} so a fetch started before it cannot repopulate the cache. */
|
|
38
|
+
generation = new Map();
|
|
39
|
+
/** Monotonic per-key write order: only the newest settled fetch may cache. */
|
|
40
|
+
nextSequence = 0;
|
|
41
|
+
lastWritten = new Map();
|
|
37
42
|
constructor(fetcherFor, ttlMs = USAGE_TTL_MS) {
|
|
38
43
|
this.fetcherFor = fetcherFor;
|
|
39
44
|
this.ttlMs = ttlMs;
|
|
@@ -126,15 +131,27 @@ export class PoolUsageTracker {
|
|
|
126
131
|
throw error;
|
|
127
132
|
}
|
|
128
133
|
}
|
|
129
|
-
/**
|
|
134
|
+
/**
|
|
135
|
+
* Drop cached snapshots: one account, or a whole provider when `account` is
|
|
136
|
+
* omitted. A fetch already in flight is abandoned too. It was started against
|
|
137
|
+
* state the caller has just declared changed — a redeemed credit, a completed
|
|
138
|
+
* login — so joining it would return the pre-change numbers and cache them for
|
|
139
|
+
* the full freshness window. Its result is discarded on arrival by the
|
|
140
|
+
* generation check in {@link refresh}.
|
|
141
|
+
*/
|
|
130
142
|
invalidate(provider, account) {
|
|
143
|
+
const drop = (key) => {
|
|
144
|
+
this.entries.delete(key);
|
|
145
|
+
this.inflight.delete(key);
|
|
146
|
+
this.generation.set(key, (this.generation.get(key) ?? 0) + 1);
|
|
147
|
+
};
|
|
131
148
|
if (account !== undefined) {
|
|
132
|
-
|
|
149
|
+
drop(`${provider}/${account}`);
|
|
133
150
|
return;
|
|
134
151
|
}
|
|
135
|
-
for (const key of [...this.entries.keys()]) {
|
|
152
|
+
for (const key of [...this.entries.keys(), ...this.inflight.keys()]) {
|
|
136
153
|
if (key.startsWith(`${provider}/`))
|
|
137
|
-
|
|
154
|
+
drop(key);
|
|
138
155
|
}
|
|
139
156
|
}
|
|
140
157
|
/**
|
|
@@ -155,13 +172,26 @@ export class PoolUsageTracker {
|
|
|
155
172
|
// consecutive failure, even though nothing newer ever replaced it.
|
|
156
173
|
const prior = this.entries.get(key);
|
|
157
174
|
const lastSnapshot = prior?.snapshot ?? prior?.lastSnapshot;
|
|
175
|
+
const generation = this.generation.get(key) ?? 0;
|
|
176
|
+
// A newer fetch may settle before this one does. The generation only
|
|
177
|
+
// catches an invalidate; the sequence catches a plain race, where this
|
|
178
|
+
// fetch fails late and would otherwise cache a cooldown over a fresher
|
|
179
|
+
// success that already landed.
|
|
180
|
+
const sequence = ++this.nextSequence;
|
|
158
181
|
pending = fetcher().then((value) => {
|
|
159
182
|
const at = Date.now();
|
|
160
183
|
const snapshot = { ...value, observedAt: at, stale: false };
|
|
161
|
-
|
|
184
|
+
// An invalidate() during the fetch means this result describes a world
|
|
185
|
+
// that has moved on; caching it would resurrect the old numbers.
|
|
186
|
+
if ((this.generation.get(key) ?? 0) === generation && (this.lastWritten.get(key) ?? 0) < sequence) {
|
|
187
|
+
this.lastWritten.set(key, sequence);
|
|
188
|
+
this.entries.set(key, { snapshot, at });
|
|
189
|
+
}
|
|
162
190
|
return snapshot;
|
|
163
191
|
}, (error) => {
|
|
164
|
-
if (!isMissingOrInvalidCredential(error))
|
|
192
|
+
if (!isMissingOrInvalidCredential(error) && (this.generation.get(key) ?? 0) === generation
|
|
193
|
+
&& (this.lastWritten.get(key) ?? 0) < sequence) {
|
|
194
|
+
this.lastWritten.set(key, sequence);
|
|
165
195
|
this.entries.set(key, {
|
|
166
196
|
error,
|
|
167
197
|
at: Date.now(),
|
|
@@ -171,7 +201,9 @@ export class PoolUsageTracker {
|
|
|
171
201
|
}
|
|
172
202
|
throw error;
|
|
173
203
|
}).finally(() => {
|
|
174
|
-
|
|
204
|
+
// Only clear our own slot: an invalidate may have replaced it.
|
|
205
|
+
if (this.inflight.get(key) === pending)
|
|
206
|
+
this.inflight.delete(key);
|
|
175
207
|
});
|
|
176
208
|
this.inflight.set(key, pending);
|
|
177
209
|
}
|
package/lib/providers/pool.d.ts
CHANGED
|
@@ -87,6 +87,12 @@ export declare class PoolAdapter extends LlmAdapter {
|
|
|
87
87
|
* metadata is account-specific, so every distinct member is resolved.
|
|
88
88
|
*/
|
|
89
89
|
resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo>;
|
|
90
|
+
/**
|
|
91
|
+
* Fail over before output. A single non-quota failure keeps its identity
|
|
92
|
+
* and shared provider message, with scoped pool recovery or its own hint as fallback.
|
|
93
|
+
* Multiple failures carry a bounded summary and all attempts in an AggregateError cause.
|
|
94
|
+
* Pure quota/rate exhaustion retains RATE_LIMIT and this pool's earliest recovery hint.
|
|
95
|
+
*/
|
|
90
96
|
stream(options: GenerateOptions): AsyncIterable<StreamChunk>;
|
|
91
97
|
/**
|
|
92
98
|
* Order the candidates for one request. Health filters both strategies;
|
|
@@ -99,10 +105,6 @@ export declare class PoolAdapter extends LlmAdapter {
|
|
|
99
105
|
private select;
|
|
100
106
|
/** Pin the serving member to the session (with bounded memory). */
|
|
101
107
|
private remember;
|
|
102
|
-
/**
|
|
103
|
-
* The error for an exhausted pool, carrying the earliest recovery hint of
|
|
104
|
-
* THIS pool's members (the health registry is shared across pools, so the
|
|
105
|
-
* hint is scoped to the keys this pool can actually recover through).
|
|
106
|
-
*/
|
|
108
|
+
/** Report attempt failures or this pool's active cooldowns, never unrelated health records. */
|
|
107
109
|
private exhausted;
|
|
108
110
|
}
|
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>;
|