dsh-plugin-subscriptions 0.5.2 → 0.6.0
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 +79 -5
- package/README.zh.md +78 -4
- package/lib/auth/rpc.d.ts +64 -13
- package/lib/auth/rpc.js +75 -10
- package/lib/auth/store.d.ts +75 -17
- package/lib/auth/store.js +148 -27
- package/lib/client/ImageGenerateToolview.d.ts +1 -1
- package/lib/client/SpeedSelect.d.ts +25 -2
- package/lib/client/SpeedSelect.js +10 -6
- package/lib/client/SubscriptionsSection.d.ts +83 -3
- package/lib/client/SubscriptionsSection.js +411 -62
- package/lib/client/VideoGenerateToolview.d.ts +1 -1
- package/lib/client/index.d.ts +1 -9
- package/lib/client/index.js +7 -4
- package/lib/client/locales.d.ts +46 -10
- package/lib/client/locales.js +46 -10
- package/lib/client.js +703 -132
- package/lib/client.js.map +1 -1
- package/lib/compat.d.ts +36 -0
- package/lib/compat.js +20 -0
- package/lib/index.d.ts +26 -1
- package/lib/index.js +2377 -309
- package/lib/model-defaults.d.ts +23 -0
- package/lib/model-defaults.js +237 -0
- package/lib/providers/accounts.d.ts +102 -0
- package/lib/providers/accounts.js +123 -0
- package/lib/providers/claude.d.ts +46 -7
- package/lib/providers/claude.js +125 -34
- package/lib/providers/codex.d.ts +45 -3
- package/lib/providers/codex.js +152 -26
- package/lib/providers/common.d.ts +87 -6
- package/lib/providers/common.js +185 -22
- package/lib/providers/copilot.d.ts +32 -3
- package/lib/providers/copilot.js +111 -19
- package/lib/providers/grok.d.ts +45 -4
- package/lib/providers/grok.js +136 -20
- package/lib/providers/pool-family.d.ts +56 -0
- package/lib/providers/pool-family.js +45 -0
- package/lib/providers/pool-health.d.ts +74 -0
- package/lib/providers/pool-health.js +148 -0
- package/lib/providers/pool-usage.d.ts +78 -0
- package/lib/providers/pool-usage.js +185 -0
- package/lib/providers/pool.d.ts +107 -0
- package/lib/providers/pool.js +371 -0
- package/lib/providers/rate-limit.d.ts +192 -0
- package/lib/providers/rate-limit.js +338 -0
- package/lib/tools/image-generate.d.ts +3 -3
- package/lib/tools/image-generate.js +2 -1
- package/lib/tools/video-generate.d.ts +2 -2
- package/lib/tools/video-generate.js +2 -1
- package/lib/tools/x-search.d.ts +2 -2
- package/lib/tools/x-search.js +2 -1
- package/lib/translate/anthropic.js +5 -4
- package/lib/translate/chat-completions.js +5 -4
- package/lib/translate/responses.js +5 -4
- package/package.json +21 -21
- package/lib/providers/antigravity.d.ts +0 -90
- package/lib/providers/antigravity.js +0 -392
- package/lib/translate/antigravity.d.ts +0 -110
- package/lib/translate/antigravity.js +0 -303
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rate-limit window handling shared by the subscription adapters.
|
|
3
|
+
*
|
|
4
|
+
* A subscription plan is rate-limit shaped by design — a five-hour session
|
|
5
|
+
* window, a weekly window, and on some plans a per-model weekly one — so a 429
|
|
6
|
+
* is not a dead end: the window reopens at a time the provider discloses. This
|
|
7
|
+
* module turns that disclosure into the `providerRetryAfterMs` the optional
|
|
8
|
+
* `@deepseek-ai/dsh-llm-retry` plugin waits out, and resolves the retry policy
|
|
9
|
+
* whose `maxDelayMs` decides how long a route is allowed to hold the turn.
|
|
10
|
+
*
|
|
11
|
+
* The wait itself is provider-independent: adapters own the policy, the retry
|
|
12
|
+
* plugin executes it. Only the extraction of the reset instant differs, so each
|
|
13
|
+
* adapter contributes one {@link RateLimitResetReader} built from the parsing
|
|
14
|
+
* primitives here.
|
|
15
|
+
*
|
|
16
|
+
* @module dsh-plugin-subscriptions/providers/rate-limit
|
|
17
|
+
*/
|
|
18
|
+
import type { ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm';
|
|
19
|
+
/**
|
|
20
|
+
* Reads the instant one provider's rate-limit window reopens off a 429.
|
|
21
|
+
* @param response - the failed response, for its headers.
|
|
22
|
+
* @param body - the complete response body (never truncated: readers parse JSON).
|
|
23
|
+
* @param now - the current epoch milliseconds, injected so parsing is testable.
|
|
24
|
+
* @returns epoch milliseconds of the reset, or undefined when the provider said nothing.
|
|
25
|
+
*/
|
|
26
|
+
export type RateLimitResetReader = (response: Response, body: string, now: number) => number | undefined;
|
|
27
|
+
/** Default ceiling on a rate-limit wait: six hours covers a five-hour session window with slack. */
|
|
28
|
+
export declare const DEFAULT_RATE_LIMIT_MAX_WAIT_MS: number;
|
|
29
|
+
/**
|
|
30
|
+
* Interpret a bare numeric rate-limit value, which providers write in three
|
|
31
|
+
* shapes: epoch milliseconds, epoch seconds, or a delay in seconds. The
|
|
32
|
+
* magnitude separates them unambiguously for any plausible value — an epoch in
|
|
33
|
+
* seconds is ~1.8e9 today, while a delay of even a full week is ~6e5.
|
|
34
|
+
* @param value - the raw numeric value.
|
|
35
|
+
* @param now - the current epoch milliseconds.
|
|
36
|
+
* @returns epoch milliseconds of the reset, or undefined when the value is unusable.
|
|
37
|
+
*/
|
|
38
|
+
export declare function resetInstantFromNumber(value: number, now: number): number | undefined;
|
|
39
|
+
/**
|
|
40
|
+
* Parse a Go-style duration (`6m0s`, `1h2m3.5s`, `150ms`) into milliseconds —
|
|
41
|
+
* the form OpenAI-compatible `x-ratelimit-reset-*` headers use.
|
|
42
|
+
* @param text - the raw header value.
|
|
43
|
+
* @returns the duration in milliseconds, or undefined when the text is not one.
|
|
44
|
+
*/
|
|
45
|
+
export declare function durationMs(text: string): number | undefined;
|
|
46
|
+
/**
|
|
47
|
+
* Interpret any single rate-limit value — a number, a numeric string, a
|
|
48
|
+
* duration (`6m0s`), or a date — as the instant a window reopens. One reader
|
|
49
|
+
* for every shape, so a provider that changes the encoding of a field it
|
|
50
|
+
* already sends does not need a code change here.
|
|
51
|
+
* @param value - the raw header value or JSON field.
|
|
52
|
+
* @param now - the current epoch milliseconds.
|
|
53
|
+
* @returns epoch milliseconds of the reset, or undefined when the value is unusable.
|
|
54
|
+
*/
|
|
55
|
+
export declare function resetInstantFromValue(value: unknown, now: number): number | undefined;
|
|
56
|
+
/**
|
|
57
|
+
* Read a header carrying any of the {@link resetInstantFromValue} shapes.
|
|
58
|
+
* @param response - the failed response.
|
|
59
|
+
* @param name - the header to read.
|
|
60
|
+
* @param now - the current epoch milliseconds.
|
|
61
|
+
* @returns epoch milliseconds of the reset, or undefined when absent or unusable.
|
|
62
|
+
*/
|
|
63
|
+
export declare function resetInstantFromHeader(response: Response, name: string, now: number): number | undefined;
|
|
64
|
+
/**
|
|
65
|
+
* Read the RFC 7231 `retry-after` header in both its forms: a delay in seconds
|
|
66
|
+
* (never an epoch stamp, whatever its magnitude) or an HTTP-date.
|
|
67
|
+
* @param response - the failed response.
|
|
68
|
+
* @param now - the current epoch milliseconds.
|
|
69
|
+
* @returns epoch milliseconds of the reset, or undefined when absent or unusable.
|
|
70
|
+
*/
|
|
71
|
+
export declare function retryAfterInstant(response: Response, now: number): number | undefined;
|
|
72
|
+
/**
|
|
73
|
+
* Parse a response body as JSON without throwing on the non-JSON bodies
|
|
74
|
+
* providers occasionally return under load (an HTML gateway page, say).
|
|
75
|
+
* @param body - the complete response body.
|
|
76
|
+
* @returns the parsed value, or undefined when the body is not JSON.
|
|
77
|
+
*/
|
|
78
|
+
export declare function jsonBody(body: string): unknown;
|
|
79
|
+
/**
|
|
80
|
+
* Find a reset instant under any of the named keys, anywhere in a parsed body.
|
|
81
|
+
*
|
|
82
|
+
* The search is by key rather than by path on purpose: providers move the same
|
|
83
|
+
* field between containers (`detail`, `error`, top level) across endpoints and
|
|
84
|
+
* versions, and a path-shaped reader silently stops working when they do. Only
|
|
85
|
+
* the key list is provider-specific.
|
|
86
|
+
* @param value - the parsed body, or any nested value.
|
|
87
|
+
* @param keys - field names this provider uses for a reset or delay.
|
|
88
|
+
* @param now - the current epoch milliseconds.
|
|
89
|
+
* @param depth - remaining recursion depth.
|
|
90
|
+
* @returns the earliest instant found, or undefined when no key matched.
|
|
91
|
+
*/
|
|
92
|
+
export declare function resetFromFields(value: unknown, keys: readonly string[], now: number, depth?: number): number | undefined;
|
|
93
|
+
/**
|
|
94
|
+
* The earliest of several candidate reset instants, ignoring absent ones. The
|
|
95
|
+
* earliest is the one that matters: it is the first moment any of the reported
|
|
96
|
+
* limits allows a request again.
|
|
97
|
+
* @param candidates - reset instants in no particular order.
|
|
98
|
+
* @returns the earliest instant, or undefined when every candidate is absent.
|
|
99
|
+
*/
|
|
100
|
+
export declare function earliestReset(...candidates: (number | undefined)[]): number | undefined;
|
|
101
|
+
/**
|
|
102
|
+
* Turn a reset instant into the wait to report as `providerRetryAfterMs`.
|
|
103
|
+
*
|
|
104
|
+
* Deliberately not capped: a reset beyond the policy's `maxDelayMs` makes the
|
|
105
|
+
* retry plugin delegate immediately, failing the turn at once with the real
|
|
106
|
+
* reset in the message, rather than clamping the wait down and burning the
|
|
107
|
+
* retry budget against a window that is still closed.
|
|
108
|
+
* @param instant - epoch milliseconds the window reopens.
|
|
109
|
+
* @param now - the current epoch milliseconds.
|
|
110
|
+
* @returns the wait in milliseconds, never below {@link MIN_WAIT_MS}.
|
|
111
|
+
*/
|
|
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
|
+
/** Per-route retry shape a subscription adapter starts from. */
|
|
128
|
+
export interface RetryDefaults {
|
|
129
|
+
/** Retries after the first attempt. */
|
|
130
|
+
readonly maxRetries: number;
|
|
131
|
+
/** First local backoff delay. */
|
|
132
|
+
readonly initialDelayMs: number;
|
|
133
|
+
/** Local backoff ceiling, and the accepted-provider-delay ceiling when waiting is off. */
|
|
134
|
+
readonly maxDelayMs: number;
|
|
135
|
+
/** Symmetric jitter around each local delay. */
|
|
136
|
+
readonly jitterRatio: number;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* The retry shape every subscription route starts from: Claude Code's own SDK
|
|
140
|
+
* numbers — ten retries after the first attempt, exponential backoff from 1s
|
|
141
|
+
* doubling per attempt, capped at 60s, plus 20% jitter.
|
|
142
|
+
*
|
|
143
|
+
* Shared across all four routes rather than kept to claude, because what these
|
|
144
|
+
* numbers are tuned for is the shape of a subscription endpoint — a consumer
|
|
145
|
+
* plan behind a session window, which sheds load in bursts and rewards an
|
|
146
|
+
* attempt that outlasts them — and that is the same on all four. The dsh-llm
|
|
147
|
+
* defaults (5 retries from 500ms to 10s) give up after about fifteen seconds,
|
|
148
|
+
* which is short for that.
|
|
149
|
+
*
|
|
150
|
+
* The 60s cap governs local backoff only: a disclosed rate-limit reset is
|
|
151
|
+
* accepted up to the configured wait ceiling instead.
|
|
152
|
+
*/
|
|
153
|
+
export declare const DEFAULT_RETRY: RetryDefaults;
|
|
154
|
+
/** How long a route may hold a turn open waiting for a rate-limit window. */
|
|
155
|
+
export interface RateLimitWait {
|
|
156
|
+
/** Whether a disclosed reset may be waited out at all. */
|
|
157
|
+
readonly wait: boolean;
|
|
158
|
+
/** Ceiling on one wait; a reset further out fails the turn instead. */
|
|
159
|
+
readonly maxWaitMs: number;
|
|
160
|
+
}
|
|
161
|
+
/** Rate-limit waiting as the plugin config accepts it. */
|
|
162
|
+
export interface RateLimitConfig {
|
|
163
|
+
/** Wait for a disclosed reset instead of failing the turn (default true). */
|
|
164
|
+
wait?: boolean;
|
|
165
|
+
/** Ceiling on one wait in milliseconds (default six hours). */
|
|
166
|
+
maxWaitMs?: number;
|
|
167
|
+
}
|
|
168
|
+
/** Waiting behavior a route falls back to when the plugin passed none (waiting on, six-hour ceiling). */
|
|
169
|
+
export declare const DEFAULT_RATE_LIMIT_WAIT: RateLimitWait;
|
|
170
|
+
/**
|
|
171
|
+
* Validate and default the rate-limit waiting config.
|
|
172
|
+
* @param config - the raw plugin config section, when present.
|
|
173
|
+
* @param path - diagnostic path naming the config that owns the value.
|
|
174
|
+
* @returns the resolved, immutable behavior.
|
|
175
|
+
*/
|
|
176
|
+
export declare function resolveRateLimitWait(config: RateLimitConfig | undefined, path: string): RateLimitWait;
|
|
177
|
+
/**
|
|
178
|
+
* Resolve one route's retry policy, widening the delay ceiling to the
|
|
179
|
+
* configured wait so a disclosed reset hours out is accepted rather than
|
|
180
|
+
* refused.
|
|
181
|
+
*
|
|
182
|
+
* The ceiling is shared with local exponential backoff, so widening it also
|
|
183
|
+
* raises how long an unrelated transient failure may back off for. That stays
|
|
184
|
+
* bounded by the finite retry budget — the claude route's ten retries reach
|
|
185
|
+
* 512 s per attempt at most — and it only governs when the provider disclosed
|
|
186
|
+
* nothing, which is exactly the case where a longer wait is the safer guess.
|
|
187
|
+
* @param defaults - the route's retry shape.
|
|
188
|
+
* @param rateLimit - resolved waiting behavior.
|
|
189
|
+
* @param path - diagnostic path naming the provider route.
|
|
190
|
+
* @returns the policy to report from `providerRetryPolicy`.
|
|
191
|
+
*/
|
|
192
|
+
export declare function subscriptionRetryPolicy(defaults: RetryDefaults, rateLimit: RateLimitWait, path: string): ResolvedRetryPolicy;
|