dsh-plugin-subscriptions 0.5.2 → 0.5.3

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.
Files changed (41) hide show
  1. package/README.md +36 -1
  2. package/README.zh.md +36 -1
  3. package/lib/auth/rpc.d.ts +29 -12
  4. package/lib/auth/rpc.js +29 -6
  5. package/lib/auth/store.d.ts +75 -17
  6. package/lib/auth/store.js +148 -27
  7. package/lib/client/SubscriptionsSection.d.ts +9 -3
  8. package/lib/client/SubscriptionsSection.js +93 -65
  9. package/lib/client/locales.d.ts +18 -10
  10. package/lib/client/locales.js +18 -10
  11. package/lib/client.js +250 -127
  12. package/lib/client.js.map +1 -1
  13. package/lib/index.d.ts +21 -0
  14. package/lib/index.js +1482 -168
  15. package/lib/providers/accounts.d.ts +102 -0
  16. package/lib/providers/accounts.js +123 -0
  17. package/lib/providers/claude.d.ts +22 -4
  18. package/lib/providers/claude.js +91 -11
  19. package/lib/providers/codex.d.ts +24 -3
  20. package/lib/providers/codex.js +116 -17
  21. package/lib/providers/common.d.ts +17 -0
  22. package/lib/providers/common.js +67 -3
  23. package/lib/providers/copilot.d.ts +22 -3
  24. package/lib/providers/copilot.js +91 -12
  25. package/lib/providers/grok.d.ts +24 -4
  26. package/lib/providers/grok.js +100 -14
  27. package/lib/providers/pool-family.d.ts +56 -0
  28. package/lib/providers/pool-family.js +45 -0
  29. package/lib/providers/pool-health.d.ts +74 -0
  30. package/lib/providers/pool-health.js +148 -0
  31. package/lib/providers/pool-usage.d.ts +57 -0
  32. package/lib/providers/pool-usage.js +130 -0
  33. package/lib/providers/pool.d.ts +107 -0
  34. package/lib/providers/pool.js +371 -0
  35. package/lib/tools/image-generate.d.ts +3 -3
  36. package/lib/tools/image-generate.js +2 -1
  37. package/lib/tools/video-generate.d.ts +2 -2
  38. package/lib/tools/video-generate.js +2 -1
  39. package/lib/tools/x-search.d.ts +2 -2
  40. package/lib/tools/x-search.js +2 -1
  41. package/package.json +1 -1
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Quota tracking for pool members: polls the providers' usage endpoints
3
+ * (the same normalized `ProviderUsage` shape the Settings page consumes) and
4
+ * turns the windows into a scheduling score.
5
+ *
6
+ * The score is a REQUIRED BURN RATE: the fraction of the window that must be
7
+ * consumed per millisecond for the quota to be exactly used up at reset time
8
+ * (`remaining / timeUntilReset`). Subscription quota does not roll over, so a
9
+ * window about to reset with plenty left is the most urgent to spend — the
10
+ * `quota_aware` strategy therefore prefers the highest-urgency member, which
11
+ * over time converges on every window hitting zero right at its reset.
12
+ */
13
+ import { isMissingOrInvalidCredential } from './common.js';
14
+ /** A member is taken out of rotation once any window crosses this fill level. */
15
+ export const QUOTA_FULL_PERCENT = 95;
16
+ /** How long a usage snapshot is trusted before a background refresh. */
17
+ export const USAGE_TTL_MS = 5 * 60_000;
18
+ /** Assumed window length when the provider discloses no `resetsAt`. */
19
+ const FALLBACK_HORIZON_MS = {
20
+ session: 5 * 60 * 60_000,
21
+ weekly: 7 * 24 * 60 * 60_000,
22
+ other: 30 * 24 * 60 * 60_000,
23
+ };
24
+ /**
25
+ * Per-ACCOUNT usage snapshots with in-flight dedupe and
26
+ * stale-while-revalidate refresh. Providers without a usage endpoint
27
+ * (copilot) resolve no fetcher and score a constant zero urgency — which
28
+ * naturally ranks them behind every measured member. Fetchers are resolved
29
+ * lazily per (provider, account) so accounts added after startup join
30
+ * tracking on their first score.
31
+ */
32
+ export class PoolUsageTracker {
33
+ fetcherFor;
34
+ ttlMs;
35
+ entries = new Map();
36
+ inflight = new Map();
37
+ constructor(fetcherFor, ttlMs = USAGE_TTL_MS) {
38
+ this.fetcherFor = fetcherFor;
39
+ this.ttlMs = ttlMs;
40
+ }
41
+ /**
42
+ * The quota view of one member. A cold cache awaits the first fetch; a
43
+ * stale one answers immediately while the refresh serves the NEXT call
44
+ * (member selection must never block on the network mid-conversation).
45
+ * @param member - the pool member to score (account resolved).
46
+ * @returns availability plus the urgency score.
47
+ */
48
+ async quotaFor(member) {
49
+ const key = `${member.provider}/${member.account}`;
50
+ const fetcher = this.fetcherFor(member.provider, member.account);
51
+ if (fetcher === undefined)
52
+ return { available: true, urgency: 0, fetchedAt: 0 };
53
+ const entry = this.entries.get(key);
54
+ if (entry !== undefined && Date.now() - entry.at < this.ttlMs) {
55
+ return this.score(member, entry);
56
+ }
57
+ if (entry !== undefined) {
58
+ void this.refresh(key, fetcher).catch(() => undefined);
59
+ return this.score(member, entry);
60
+ }
61
+ try {
62
+ const snapshot = await this.refresh(key, fetcher);
63
+ return this.score(member, { snapshot, at: Date.now() });
64
+ }
65
+ catch (error) {
66
+ // Logged out: the member cannot serve at all. Any other failure
67
+ // (network, endpoint rate limit) must not block routing — the member
68
+ // stays available with a zero score, degrading the strategy to plain
69
+ // priority order for it.
70
+ return isMissingOrInvalidCredential(error)
71
+ ? { available: false, urgency: 0, fetchedAt: 0 }
72
+ : { available: true, urgency: 0, fetchedAt: 0 };
73
+ }
74
+ }
75
+ /** Drop cached snapshots: one account, or a whole provider when `account` is omitted. */
76
+ invalidate(provider, account) {
77
+ if (account !== undefined) {
78
+ this.entries.delete(`${provider}/${account}`);
79
+ return;
80
+ }
81
+ for (const key of [...this.entries.keys()]) {
82
+ if (key.startsWith(`${provider}/`))
83
+ this.entries.delete(key);
84
+ }
85
+ }
86
+ /** Run (or join) the single in-flight fetch for one account key. */
87
+ refresh(key, fetcher) {
88
+ let pending = this.inflight.get(key);
89
+ if (pending === undefined) {
90
+ pending = fetcher().then((snapshot) => {
91
+ this.entries.set(key, { snapshot, at: Date.now() });
92
+ return snapshot;
93
+ }).finally(() => {
94
+ this.inflight.delete(key);
95
+ });
96
+ this.inflight.set(key, pending);
97
+ }
98
+ return pending;
99
+ }
100
+ /** Score one member against a snapshot's windows. */
101
+ score(member, entry) {
102
+ const windows = (entry.snapshot.windows ?? []).filter(window => windowApplies(window, member.model));
103
+ let available = true;
104
+ let urgency = 0;
105
+ for (const window of windows) {
106
+ if (window.usedPercent >= QUOTA_FULL_PERCENT)
107
+ available = false;
108
+ urgency = Math.max(urgency, windowUrgency(window));
109
+ }
110
+ return { available, urgency, fetchedAt: entry.at };
111
+ }
112
+ }
113
+ /**
114
+ * Whether a window constrains this model: unscoped windows always do; a
115
+ * model-scoped window (Claude's Opus/Sonnet lanes) applies when its scope
116
+ * names the model family.
117
+ */
118
+ function windowApplies(window, model) {
119
+ if (window.scope === undefined)
120
+ return true;
121
+ return model.toLowerCase().includes(window.scope.toLowerCase());
122
+ }
123
+ /** The required burn rate of one window (fraction per ms). */
124
+ function windowUrgency(window, now = Date.now()) {
125
+ const remaining = Math.max(0, 1 - window.usedPercent / 100);
126
+ const horizon = window.resetsAt !== undefined
127
+ ? Math.max(window.resetsAt - now, 1)
128
+ : FALLBACK_HORIZON_MS[window.kind];
129
+ return remaining / horizon;
130
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * The pool adapter: same-subscription account routing, plus optional
3
+ * configured tier extras. The picker is the union of every account's
4
+ * catalog. A model listed by several accounts failovers; a model listed by
5
+ * one account is pinned to it. Tiers are extra picker rows. Member
6
+ * selection is sticky per session (so prompt caches survive) and optionally
7
+ * quota-aware; failures fail over to the next member as long as no stream
8
+ * chunk has been emitted.
9
+ */
10
+ import { LlmAdapter } from '@deepseek-ai/dsh-llm';
11
+ import type { GenerateOptions, LlmModelInfo, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm';
12
+ import type { ProviderId } from '../auth/store.js';
13
+ import type { AccountAwareAdapter } from './accounts.js';
14
+ import type { PoolDefinition, PoolMemberRef } from './pool-family.js';
15
+ import { PoolHealthRegistry } from './pool-health.js';
16
+ import type { PoolUsageTracker } from './pool-usage.js';
17
+ /** Member-selection strategy: plain priority failover or quota-aware scheduling. */
18
+ export type PoolStrategy = 'priority' | 'quota_aware';
19
+ export interface PoolAdapterOptions {
20
+ /** The live subscription adapters, by provider route. */
21
+ adapters: Partial<Record<ProviderId, AccountAwareAdapter>>;
22
+ health: PoolHealthRegistry;
23
+ usage: PoolUsageTracker;
24
+ strategy: PoolStrategy;
25
+ /** A challenger must out-urgency the sticky member by this factor to take over. */
26
+ switchMargin: number;
27
+ /** The default account of one provider (for config members omitting `account`). */
28
+ defaultAccount: (provider: ProviderId) => Promise<string | undefined>;
29
+ /** Account pools (auto-aggregated plus config overrides), resolved lazily. */
30
+ families: () => Promise<Map<string, PoolDefinition>>;
31
+ /** User-configured extra picker entries (heterogeneous fallbacks), by pool id. */
32
+ tiers: Record<string, PoolMemberRef[]>;
33
+ onWarn: (message: string) => void;
34
+ }
35
+ export declare class PoolAdapter extends LlmAdapter {
36
+ private readonly options;
37
+ /** sessionId|poolId → member key of the last member that served a chunk. */
38
+ private readonly sticky;
39
+ /** Messages already warned about — configuration diagnostics repeat every request otherwise. */
40
+ private readonly warned;
41
+ /**
42
+ * Short-lived pools snapshot. `owns()` runs on every resolveModel — the
43
+ * model picker issues one per entry — and pool assembly touches every
44
+ * provider's catalog and account store, so recompute at most this often.
45
+ * Auth changes bump {@link generation} so a stale snapshot cannot land.
46
+ */
47
+ private poolsCache;
48
+ private poolsInflight;
49
+ private generation;
50
+ constructor(options: PoolAdapterOptions);
51
+ /** Drop the pools snapshot so the next read reflects the current accounts. */
52
+ invalidate(): void;
53
+ /** Warn once per distinct message (pools() runs on every request). */
54
+ private warnOnce;
55
+ /** Drop members whose adapter is not registered (copy — caller state is shared). */
56
+ private usable;
57
+ /** Account pools (auto-aggregated plus config overrides) with usable members. */
58
+ private familyPools;
59
+ /** All pools (account pools merged with extra tiers) with usable members. */
60
+ private pools;
61
+ /** Recompute the pools snapshot (account pools merged with extra tiers). */
62
+ private assemblePools;
63
+ /**
64
+ * Extra picker rows one provider lists (configured tiers). Account pools
65
+ * reuse the catalog entry of the same wire id, so they are not listed
66
+ * again — the picker stays one row per model in ChatGPT / Claude / ….
67
+ */
68
+ modelsForProvider(provider: ProviderId): Promise<LlmModelInfo[]>;
69
+ /**
70
+ * Whether `model` on `provider`'s route is served here (several accounts
71
+ * fail over, one account is pinned, or a configured tier).
72
+ */
73
+ owns(provider: ProviderId, model: string): Promise<boolean>;
74
+ /**
75
+ * Resolve every member's account (config members may omit it to mean "the
76
+ * default account") and drop members with no resolvable login. Duplicates
77
+ * collapse — an explicitly pinned account and the default may coincide.
78
+ */
79
+ private concrete;
80
+ /**
81
+ * Resolve a pool model to the conservative INTERSECTION of its members'
82
+ * capabilities: the smallest context window and output cap, the reasoning
83
+ * efforts every member supports, and the modalities all of them accept —
84
+ * so a request valid for the pool stays valid after a failover. Capability
85
+ * metadata is provider-level, so each provider resolves once regardless of
86
+ * how many accounts it pools.
87
+ */
88
+ resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo>;
89
+ stream(options: GenerateOptions): AsyncIterable<StreamChunk>;
90
+ /**
91
+ * Order the candidates for one request. Health filters both strategies;
92
+ * `quota_aware` then ranks by urgency (members without telemetry, e.g.
93
+ * copilot, score zero and sink to the bottom of their class), while
94
+ * quota-exhausted members stay as a last-resort tail in pool order. The
95
+ * sticky member keeps its lead unless a challenger out-scores it by
96
+ * `switchMargin`.
97
+ */
98
+ private select;
99
+ /** Pin the serving member to the session (with bounded memory). */
100
+ private remember;
101
+ /**
102
+ * The error for an exhausted pool, carrying the earliest recovery hint of
103
+ * THIS pool's members (the health registry is shared across pools, so the
104
+ * hint is scoped to the keys this pool can actually recover through).
105
+ */
106
+ private exhausted;
107
+ }
@@ -0,0 +1,371 @@
1
+ /**
2
+ * The pool adapter: same-subscription account routing, plus optional
3
+ * configured tier extras. The picker is the union of every account's
4
+ * catalog. A model listed by several accounts failovers; a model listed by
5
+ * one account is pinned to it. Tiers are extra picker rows. Member
6
+ * selection is sticky per session (so prompt caches survive) and optionally
7
+ * quota-aware; failures fail over to the next member as long as no stream
8
+ * chunk has been emitted.
9
+ */
10
+ import { EMPTY_RESPONSE_CODE, LlmAdapter, LlmError, QUOTA_EXCEEDED_CODE, } from '@deepseek-ai/dsh-llm';
11
+ import { poolKey } from './pool-family.js';
12
+ import { accountKey, classifyPoolFailure, memberKey, PoolHealthRegistry } from './pool-health.js';
13
+ /** Bound on sticky-session memory; oldest entries evict past it. */
14
+ const STICKY_SESSION_LIMIT = 1000;
15
+ /** Display form of one member (account shown when pinned). */
16
+ function memberLabel(member) {
17
+ return member.account === undefined
18
+ ? `${member.provider}/${member.model}`
19
+ : `${member.provider}/${member.account}/${member.model}`;
20
+ }
21
+ /** How long a pools snapshot is trusted (auth changes invalidate immediately). */
22
+ const POOLS_CACHE_TTL_MS = 5_000;
23
+ export class PoolAdapter extends LlmAdapter {
24
+ options;
25
+ /** sessionId|poolId → member key of the last member that served a chunk. */
26
+ sticky = new Map();
27
+ /** Messages already warned about — configuration diagnostics repeat every request otherwise. */
28
+ warned = new Set();
29
+ /**
30
+ * Short-lived pools snapshot. `owns()` runs on every resolveModel — the
31
+ * model picker issues one per entry — and pool assembly touches every
32
+ * provider's catalog and account store, so recompute at most this often.
33
+ * Auth changes bump {@link generation} so a stale snapshot cannot land.
34
+ */
35
+ poolsCache;
36
+ poolsInflight;
37
+ generation = 0;
38
+ constructor(options) {
39
+ super();
40
+ this.options = options;
41
+ }
42
+ /** Drop the pools snapshot so the next read reflects the current accounts. */
43
+ invalidate() {
44
+ this.generation += 1;
45
+ this.poolsCache = undefined;
46
+ this.poolsInflight = undefined;
47
+ }
48
+ /** Warn once per distinct message (pools() runs on every request). */
49
+ warnOnce(message) {
50
+ if (this.warned.has(message))
51
+ return;
52
+ this.warned.add(message);
53
+ this.options.onWarn(message);
54
+ }
55
+ /** Drop members whose adapter is not registered (copy — caller state is shared). */
56
+ usable(pools) {
57
+ const result = new Map(pools);
58
+ for (const [id, definition] of [...result]) {
59
+ const kept = definition.members.filter(member => this.options.adapters[member.provider] !== undefined);
60
+ if (kept.length === 0)
61
+ result.delete(id);
62
+ else if (kept.length < definition.members.length)
63
+ result.set(id, { ...definition, members: kept });
64
+ }
65
+ return result;
66
+ }
67
+ /** Account pools (auto-aggregated plus config overrides) with usable members. */
68
+ async familyPools() {
69
+ return this.usable(new Map(await this.options.families()));
70
+ }
71
+ /** All pools (account pools merged with extra tiers) with usable members. */
72
+ async pools() {
73
+ const cached = this.poolsCache;
74
+ if (cached !== undefined && Date.now() - cached.at < POOLS_CACHE_TTL_MS)
75
+ return cached.pools;
76
+ const gen = this.generation;
77
+ this.poolsInflight ??= this.assemblePools()
78
+ .then((pools) => {
79
+ if (this.generation === gen)
80
+ this.poolsCache = { at: Date.now(), pools };
81
+ return pools;
82
+ })
83
+ .finally(() => {
84
+ this.poolsInflight = undefined;
85
+ });
86
+ return this.poolsInflight;
87
+ }
88
+ /** Recompute the pools snapshot (account pools merged with extra tiers). */
89
+ async assemblePools() {
90
+ const pools = await this.familyPools();
91
+ for (const [id, members] of Object.entries(this.options.tiers)) {
92
+ if (members.length === 0)
93
+ continue;
94
+ const owner = members[0].provider;
95
+ const key = poolKey(owner, id);
96
+ if (pools.has(key))
97
+ this.warnOnce(`tier pool "${id}" overrides the account pool of the same id under ${owner}`);
98
+ pools.set(key, { members, extra: true });
99
+ }
100
+ return this.usable(pools);
101
+ }
102
+ /**
103
+ * Extra picker rows one provider lists (configured tiers). Account pools
104
+ * reuse the catalog entry of the same wire id, so they are not listed
105
+ * again — the picker stays one row per model in ChatGPT / Claude / ….
106
+ */
107
+ async modelsForProvider(provider) {
108
+ const pools = await this.pools();
109
+ const models = [];
110
+ for (const [key, definition] of pools) {
111
+ if (definition.extra !== true)
112
+ continue;
113
+ if (!key.startsWith(`${provider}/`))
114
+ continue;
115
+ const id = key.slice(provider.length + 1);
116
+ models.push({
117
+ provider,
118
+ id,
119
+ name: definition.name ?? id,
120
+ ...definition.description === undefined ? {} : { description: definition.description },
121
+ });
122
+ }
123
+ return models;
124
+ }
125
+ /**
126
+ * Whether `model` on `provider`'s route is served here (several accounts
127
+ * fail over, one account is pinned, or a configured tier).
128
+ */
129
+ async owns(provider, model) {
130
+ return (await this.pools()).has(poolKey(provider, model));
131
+ }
132
+ /**
133
+ * Resolve every member's account (config members may omit it to mean "the
134
+ * default account") and drop members with no resolvable login. Duplicates
135
+ * collapse — an explicitly pinned account and the default may coincide.
136
+ */
137
+ async concrete(members) {
138
+ const seen = new Set();
139
+ const resolved = [];
140
+ for (const member of members) {
141
+ const account = member.account ?? await this.options.defaultAccount(member.provider);
142
+ if (account === undefined)
143
+ continue;
144
+ const key = memberKey(member.provider, account, member.model);
145
+ if (seen.has(key))
146
+ continue;
147
+ seen.add(key);
148
+ resolved.push({ provider: member.provider, account, model: member.model });
149
+ }
150
+ return resolved;
151
+ }
152
+ /**
153
+ * Resolve a pool model to the conservative INTERSECTION of its members'
154
+ * capabilities: the smallest context window and output cap, the reasoning
155
+ * efforts every member supports, and the modalities all of them accept —
156
+ * so a request valid for the pool stays valid after a failover. Capability
157
+ * metadata is provider-level, so each provider resolves once regardless of
158
+ * how many accounts it pools.
159
+ */
160
+ async resolveModel(provider, model) {
161
+ const definition = (await this.pools()).get(poolKey(provider, model));
162
+ if (definition === undefined)
163
+ throw new LlmError(`unknown pool model "${model}"`, 'NO_ADAPTER');
164
+ const resolved = [];
165
+ let lastFailure;
166
+ const seenProviders = new Set();
167
+ for (const member of definition.members) {
168
+ if (seenProviders.has(member.provider))
169
+ continue;
170
+ seenProviders.add(member.provider);
171
+ const adapter = this.options.adapters[member.provider];
172
+ if (adapter === undefined)
173
+ continue;
174
+ // Tolerate per-member failures (a misconfigured tier member, a
175
+ // logged-out provider throwing AUTH): the pool serves as long as ONE
176
+ // member resolves, mirroring stream()'s failover semantics.
177
+ try {
178
+ resolved.push(await adapter.resolveOwnModel(member.provider, member.model));
179
+ }
180
+ catch (error) {
181
+ lastFailure = error;
182
+ this.warnOnce(`pool "${model}": member ${memberLabel(member)} failed to resolve`
183
+ + ` (${error instanceof Error ? error.message : String(error)}); excluding it`);
184
+ }
185
+ }
186
+ if (resolved.length === 0) {
187
+ throw new LlmError(`pool "${model}" has no usable member`, 'NO_ADAPTER', {
188
+ ...lastFailure === undefined ? {} : { cause: lastFailure },
189
+ });
190
+ }
191
+ const contextWindows = resolved.map(info => info.context?.contextWindow).filter(isNumber);
192
+ const maxTokens = resolved.map(info => info.defaultMaxTokens).filter(isNumber);
193
+ const reasoning = intersectReasoning(resolved);
194
+ const modalities = intersectModalities(resolved);
195
+ return {
196
+ provider,
197
+ id: model,
198
+ name: definition.name ?? model,
199
+ ...definition.description === undefined ? {} : { description: definition.description },
200
+ ...contextWindows.length > 0 ? { context: { contextWindow: Math.min(...contextWindows) } } : {},
201
+ ...maxTokens.length > 0 ? { defaultMaxTokens: Math.min(...maxTokens) } : {},
202
+ ...reasoning === undefined ? {} : { reasoning },
203
+ ...modalities === undefined ? {} : { inputModalities: modalities },
204
+ };
205
+ }
206
+ async *stream(options) {
207
+ const definition = (await this.pools()).get(poolKey(options.provider, options.model));
208
+ if (definition === undefined)
209
+ throw new LlmError(`unknown pool model "${options.model}"`, 'NO_ADAPTER');
210
+ const members = await this.concrete(definition.members);
211
+ const candidates = await this.select(options.model, members, options.sessionId);
212
+ if (candidates.length === 0)
213
+ throw this.exhausted(options.model, members);
214
+ let lastError;
215
+ for (const member of candidates) {
216
+ const adapter = this.options.adapters[member.provider];
217
+ if (adapter === undefined)
218
+ continue;
219
+ const iterator = adapter.streamAccount({ ...options, provider: member.provider, model: member.model }, member.account)[Symbol.asyncIterator]();
220
+ let first;
221
+ try {
222
+ first = await iterator.next();
223
+ if (first.done === true) {
224
+ throw new LlmError(`${memberLabel(member)} returned an empty stream`, EMPTY_RESPONSE_CODE);
225
+ }
226
+ }
227
+ catch (error) {
228
+ const classification = classifyPoolFailure(error, member.provider);
229
+ if (classification.action === 'throw')
230
+ throw error;
231
+ if ('cooldownMs' in classification) {
232
+ this.options.health.markUnavailable(classification.scope === 'account'
233
+ ? accountKey(member.provider, member.account)
234
+ : memberKey(member.provider, member.account, member.model), classification.cooldownMs, classification.reason);
235
+ // A quota failure invalidates the cached usage snapshot so the NEXT
236
+ // selection re-polls instead of trusting minutes-old percentages.
237
+ // Transient/auth failures say nothing about quota — keep the cache.
238
+ if (classification.reason === QUOTA_EXCEEDED_CODE || classification.reason === 'RATE_LIMIT') {
239
+ this.options.usage.invalidate(member.provider, member.account);
240
+ }
241
+ }
242
+ this.options.onWarn(`pool "${options.model}": ${memberLabel(member)} failed before any output`
243
+ + ` (${error instanceof Error ? error.message : String(error)}); trying the next member`);
244
+ lastError = error;
245
+ continue;
246
+ }
247
+ this.remember(options.model, options.sessionId, member);
248
+ // Past the first chunk there is no clean attempt boundary: whatever
249
+ // the member does next (including failing) reaches the caller as-is.
250
+ // The finally closes the member stream when the CALLER walks away
251
+ // early (break / .return()) — manual iteration does not propagate
252
+ // closure the way `yield*` would, and a half-consumed member stream
253
+ // must not linger holding its connection.
254
+ try {
255
+ yield first.value;
256
+ for (let next = await iterator.next(); next.done !== true; next = await iterator.next()) {
257
+ yield next.value;
258
+ }
259
+ }
260
+ finally {
261
+ try {
262
+ await iterator.return?.();
263
+ }
264
+ catch {
265
+ // Closing a half-consumed member stream must not mask the outcome.
266
+ }
267
+ }
268
+ return;
269
+ }
270
+ throw this.exhausted(options.model, members, lastError);
271
+ }
272
+ /**
273
+ * Order the candidates for one request. Health filters both strategies;
274
+ * `quota_aware` then ranks by urgency (members without telemetry, e.g.
275
+ * copilot, score zero and sink to the bottom of their class), while
276
+ * quota-exhausted members stay as a last-resort tail in pool order. The
277
+ * sticky member keeps its lead unless a challenger out-scores it by
278
+ * `switchMargin`.
279
+ */
280
+ async select(poolId, members, sessionId) {
281
+ const usable = members.filter(member => this.options.adapters[member.provider] !== undefined
282
+ && this.options.health.isMemberAvailable(member.provider, member.account, member.model));
283
+ if (usable.length === 0)
284
+ return [];
285
+ const stickyMember = sessionId === undefined
286
+ ? undefined
287
+ : usable.find(member => memberKey(member.provider, member.account, member.model) === this.sticky.get(stickyKey(poolId, sessionId)));
288
+ if (this.options.strategy === 'priority') {
289
+ return stickyMember === undefined
290
+ ? usable
291
+ : [stickyMember, ...usable.filter(member => member !== stickyMember)];
292
+ }
293
+ const quotas = new Map(await Promise.all(usable.map(async (member) => [member, await this.options.usage.quotaFor(member)])));
294
+ const scored = usable.filter(member => quotas.get(member)?.available === true);
295
+ const quotaFull = usable.filter(member => quotas.get(member)?.available === false);
296
+ scored.sort((a, b) => (quotas.get(b)?.urgency ?? 0) - (quotas.get(a)?.urgency ?? 0));
297
+ if (stickyMember !== undefined && scored.includes(stickyMember)) {
298
+ const best = scored[0];
299
+ const stickyUrgency = quotas.get(stickyMember)?.urgency ?? 0;
300
+ const bestUrgency = quotas.get(best)?.urgency ?? 0;
301
+ if (best === stickyMember || bestUrgency <= stickyUrgency * this.options.switchMargin) {
302
+ // Sticky holds (no challenger beats it by the margin): lead with it.
303
+ scored.splice(scored.indexOf(stickyMember), 1);
304
+ scored.unshift(stickyMember);
305
+ }
306
+ }
307
+ return [...scored, ...quotaFull];
308
+ }
309
+ /** Pin the serving member to the session (with bounded memory). */
310
+ remember(poolId, sessionId, member) {
311
+ if (sessionId === undefined)
312
+ return;
313
+ const key = stickyKey(poolId, sessionId);
314
+ this.sticky.delete(key);
315
+ if (this.sticky.size >= STICKY_SESSION_LIMIT) {
316
+ const oldest = this.sticky.keys().next();
317
+ if (oldest.done !== true)
318
+ this.sticky.delete(oldest.value);
319
+ }
320
+ this.sticky.set(key, memberKey(member.provider, member.account, member.model));
321
+ }
322
+ /**
323
+ * The error for an exhausted pool, carrying the earliest recovery hint of
324
+ * THIS pool's members (the health registry is shared across pools, so the
325
+ * hint is scoped to the keys this pool can actually recover through).
326
+ */
327
+ exhausted(model, pool, cause) {
328
+ const keys = new Set();
329
+ for (const member of pool) {
330
+ keys.add(memberKey(member.provider, member.account, member.model));
331
+ keys.add(accountKey(member.provider, member.account));
332
+ }
333
+ const recovery = this.options.health.earliestRecovery(keys);
334
+ const retryAfterMs = recovery === undefined ? undefined : Math.max(recovery - Date.now(), 1);
335
+ return new LlmError(`pool "${model}" exhausted: every member is unavailable or failed`, 'RATE_LIMIT', {
336
+ ...retryAfterMs === undefined ? {} : { providerRetryAfterMs: retryAfterMs },
337
+ ...cause === undefined ? {} : { cause },
338
+ });
339
+ }
340
+ }
341
+ function stickyKey(poolId, sessionId) {
342
+ return `${String(sessionId)}|${poolId}`;
343
+ }
344
+ function isNumber(value) {
345
+ return value !== undefined;
346
+ }
347
+ /** Reasoning efforts every member supports (id intersection, first member's order). */
348
+ function intersectReasoning(resolved) {
349
+ const [first, ...rest] = resolved;
350
+ if (first?.reasoning === undefined)
351
+ return undefined;
352
+ const efforts = first.reasoning.efforts.filter(effort => rest.every(info => info.reasoning?.efforts.some(other => other.id === effort.id) === true));
353
+ if (efforts.length === 0)
354
+ return undefined;
355
+ const defaultEffort = first.reasoning.defaultEffort !== undefined
356
+ && efforts.some(effort => effort.id === first.reasoning?.defaultEffort)
357
+ ? first.reasoning.defaultEffort
358
+ : undefined;
359
+ return { efforts, ...defaultEffort === undefined ? {} : { defaultEffort } };
360
+ }
361
+ /** Modalities all members accept; undefined when any member leaves it unknown. */
362
+ function intersectModalities(resolved) {
363
+ const [first, ...rest] = resolved;
364
+ if (first?.inputModalities === undefined)
365
+ return undefined;
366
+ const modalities = first.inputModalities.filter(modality => rest.every(info => info.inputModalities?.includes(modality) === true));
367
+ // An empty intersection would declare negative capability ("accepts
368
+ // nothing"); report unknown instead — the serving member enforces its own
369
+ // limits at request time.
370
+ return modalities.length === 0 ? undefined : modalities;
371
+ }
@@ -16,7 +16,7 @@ import type { AttachmentStore } from '@deepseek-ai/dsh-attachment';
16
16
  import type { LlmRuntime } from '@deepseek-ai/dsh-llm';
17
17
  import type { ToolDefinition } from '@deepseek-ai/dsh-tools';
18
18
  import type { CodexSession, GrokSession } from '../auth/store.js';
19
- import { TokenManager } from '../providers/common.js';
19
+ import { AccountTokenManager } from '../providers/accounts.js';
20
20
  import type { FetchFn } from '../providers/common.js';
21
21
  /** Endpoint the codex generation request is posted to. */
22
22
  export declare const IMAGE_GENERATE_URL = "https://chatgpt.com/backend-api/codex/images/generations";
@@ -29,9 +29,9 @@ export declare const GROK_IMAGE_GENERATE_MODEL = "grok-imagine-image-2.0";
29
29
  /** Dependencies of the `image_generate` tool. */
30
30
  export interface ImageGenerateToolOptions {
31
31
  /** Codex session source; the default preferred provider (`provider: 'gpt'`). */
32
- codexTokens?: TokenManager<CodexSession>;
32
+ codexTokens?: AccountTokenManager<CodexSession>;
33
33
  /** Grok session source; preferred when the call passes `provider: 'grok'`. */
34
- grokTokens?: TokenManager<GrokSession>;
34
+ grokTokens?: AccountTokenManager<GrokSession>;
35
35
  /** Fetch implementation (injectable for tests). */
36
36
  fetchFn?: FetchFn;
37
37
  /** Directory override for saved images (defaults under the harness home). */
@@ -17,7 +17,8 @@ import { basename, join } from 'node:path';
17
17
  import { dshHomePath } from '@deepseek-ai/dsh-home-paths';
18
18
  import { AttachmentId } from '@deepseek-ai/dsh-attachment';
19
19
  import { defineTool } from '@deepseek-ai/dsh-tools';
20
- import { httpLlmError, TokenManager } from '../providers/common.js';
20
+ import { httpLlmError } from '../providers/common.js';
21
+ import { AccountTokenManager } from '../providers/accounts.js';
21
22
  import { proxiedFetch } from '../http.js';
22
23
  /** Endpoint the codex generation request is posted to. */
23
24
  export const IMAGE_GENERATE_URL = 'https://chatgpt.com/backend-api/codex/images/generations';
@@ -10,7 +10,7 @@
10
10
  */
11
11
  import type { ToolDefinition } from '@deepseek-ai/dsh-tools';
12
12
  import type { GrokSession } from '../auth/store.js';
13
- import { TokenManager } from '../providers/common.js';
13
+ import { AccountTokenManager } from '../providers/accounts.js';
14
14
  import type { FetchFn } from '../providers/common.js';
15
15
  /** Endpoint the generation request is posted to. */
16
16
  export declare const VIDEO_GENERATE_URL = "https://api.x.ai/v1/videos/generations";
@@ -25,7 +25,7 @@ export declare const DEFAULT_MAX_WAIT_MS: number;
25
25
  /** Dependencies of the `video_generate` tool. */
26
26
  export interface VideoGenerateToolOptions {
27
27
  /** Grok session source; a missing session throws the log-in hint. */
28
- tokens: TokenManager<GrokSession>;
28
+ tokens: AccountTokenManager<GrokSession>;
29
29
  /** Fetch implementation (injectable for tests). */
30
30
  fetchFn?: FetchFn;
31
31
  /** Directory override for saved videos (defaults under the harness home). */
@@ -12,7 +12,8 @@ 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, TokenManager } from '../providers/common.js';
15
+ import { httpLlmError } from '../providers/common.js';
16
+ import { AccountTokenManager } from '../providers/accounts.js';
16
17
  import { proxiedFetch } from '../http.js';
17
18
  /** Endpoint the generation request is posted to. */
18
19
  export const VIDEO_GENERATE_URL = 'https://api.x.ai/v1/videos/generations';