dsh-plugin-subscriptions 0.5.1 → 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.
- package/README.md +42 -1
- package/README.zh.md +42 -1
- package/lib/auth/device-flow.d.ts +0 -9
- package/lib/auth/device-flow.js +2 -1
- package/lib/auth/rpc.d.ts +44 -13
- package/lib/auth/rpc.js +127 -9
- package/lib/auth/store.d.ts +75 -17
- package/lib/auth/store.js +148 -27
- package/lib/client/SubscriptionsSection.d.ts +26 -3
- package/lib/client/SubscriptionsSection.js +263 -67
- package/lib/client/index.js +11 -0
- package/lib/client/locales.d.ts +82 -10
- package/lib/client/locales.js +82 -10
- package/lib/client.js +837 -223
- package/lib/client.js.map +1 -1
- package/lib/http.d.ts +114 -0
- package/lib/http.js +402 -0
- package/lib/index.d.ts +21 -0
- package/lib/index.js +1938 -208
- package/lib/providers/accounts.d.ts +102 -0
- package/lib/providers/accounts.js +123 -0
- package/lib/providers/antigravity.d.ts +90 -0
- package/lib/providers/antigravity.js +392 -0
- package/lib/providers/claude.d.ts +22 -4
- package/lib/providers/claude.js +97 -16
- package/lib/providers/codex.d.ts +24 -3
- package/lib/providers/codex.js +121 -21
- package/lib/providers/common.d.ts +17 -0
- package/lib/providers/common.js +67 -3
- package/lib/providers/copilot.d.ts +23 -4
- package/lib/providers/copilot.js +99 -19
- package/lib/providers/grok.d.ts +24 -4
- package/lib/providers/grok.js +106 -19
- 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 +57 -0
- package/lib/providers/pool-usage.js +130 -0
- package/lib/providers/pool.d.ts +107 -0
- package/lib/providers/pool.js +371 -0
- package/lib/tools/image-generate.d.ts +3 -3
- package/lib/tools/image-generate.js +4 -2
- package/lib/tools/video-generate.d.ts +2 -2
- package/lib/tools/video-generate.js +4 -2
- package/lib/tools/x-search.d.ts +2 -2
- package/lib/tools/x-search.js +4 -2
- package/lib/translate/antigravity.d.ts +110 -0
- package/lib/translate/antigravity.js +303 -0
- package/package.json +14 -9
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Same-subscription account routing: the picker is the union of every
|
|
3
|
+
* account's catalog (deduped by wire id). A model listed by two or more
|
|
4
|
+
* accounts failovers between them; a model listed by only one account is
|
|
5
|
+
* sent to that account. No extra pool identity, no cross-provider
|
|
6
|
+
* aggregation.
|
|
7
|
+
*/
|
|
8
|
+
/** Map key for one provider's pool of one model (ids collide across providers). */
|
|
9
|
+
export function poolKey(provider, model) {
|
|
10
|
+
return `${provider}/${model}`;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Build per-provider account routes. Each model id becomes a definition of
|
|
14
|
+
* the accounts that list it: two or more fail over; one is pinned to that
|
|
15
|
+
* account (so a Max-only model is never sent to a Plus login). The picker
|
|
16
|
+
* unions these catalogs; a logout that drops a model to one account keeps
|
|
17
|
+
* the same id and pins it to whoever remains.
|
|
18
|
+
* @param sources - per-account catalogs (providers with no accounts list
|
|
19
|
+
* nothing and simply never join a pool).
|
|
20
|
+
* @returns `provider/model` → pool definition (not listed as an extra entry).
|
|
21
|
+
*/
|
|
22
|
+
export function buildAccountPools(sources) {
|
|
23
|
+
const pools = new Map();
|
|
24
|
+
for (const [provider, source] of Object.entries(sources)) {
|
|
25
|
+
const byModel = new Map();
|
|
26
|
+
for (const catalog of source.catalogs) {
|
|
27
|
+
for (const model of catalog.models) {
|
|
28
|
+
let entry = byModel.get(model.id);
|
|
29
|
+
if (entry === undefined) {
|
|
30
|
+
entry = { members: [], info: model };
|
|
31
|
+
byModel.set(model.id, entry);
|
|
32
|
+
}
|
|
33
|
+
entry.members.push({ provider, account: catalog.account, model: model.id });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
for (const [id, { members, info }] of byModel) {
|
|
37
|
+
pools.set(poolKey(provider, id), {
|
|
38
|
+
members,
|
|
39
|
+
...info.name === undefined || info.name === id ? {} : { name: info.name },
|
|
40
|
+
...info.description === undefined ? {} : { description: info.description },
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return pools;
|
|
45
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Health bookkeeping for pool members: which `(provider, account, model)`
|
|
3
|
+
* member is cooling down after a failure, and for how long. Purely in-memory
|
|
4
|
+
* — a restart re-probes members naturally, so nothing here is persisted.
|
|
5
|
+
*
|
|
6
|
+
* The failure classifier maps the adapters' stable `LlmError` codes (see
|
|
7
|
+
* `httpLlmError`/`mapFetchFailure` in `common.ts`) to one of three actions:
|
|
8
|
+
* switch to another member with a cooldown, switch without recording a
|
|
9
|
+
* cooldown (transport blips say nothing about the account), or rethrow
|
|
10
|
+
* (the request itself is at fault and another account would fail alike).
|
|
11
|
+
*/
|
|
12
|
+
import type { ProviderId } from '../auth/store.js';
|
|
13
|
+
/** Registry key for one pool member. */
|
|
14
|
+
export declare function memberKey(provider: ProviderId, account: string, model: string): string;
|
|
15
|
+
/** Registry key parking EVERY member of one account (account-level failures). */
|
|
16
|
+
export declare function accountKey(provider: ProviderId, account: string): string;
|
|
17
|
+
/** Default cooldown when a quota/rate failure carries no `retry-after`. */
|
|
18
|
+
export declare const DEFAULT_QUOTA_COOLDOWN_MS: number;
|
|
19
|
+
/** Auth failures recheck after a day; a re-login clears the record immediately. */
|
|
20
|
+
export declare const AUTH_COOLDOWN_MS: number;
|
|
21
|
+
/** Transient server-side failures cool down briefly. */
|
|
22
|
+
export declare const TRANSIENT_COOLDOWN_MS = 60000;
|
|
23
|
+
/** Whether a failure parks one member or the account's whole quota. */
|
|
24
|
+
export type PoolFailureScope = 'member' | 'account';
|
|
25
|
+
/** What the pool should do with a member that just failed. */
|
|
26
|
+
export type PoolFailureAction = {
|
|
27
|
+
action: 'switch';
|
|
28
|
+
cooldownMs: number;
|
|
29
|
+
reason: string;
|
|
30
|
+
scope: PoolFailureScope;
|
|
31
|
+
} | {
|
|
32
|
+
action: 'switch';
|
|
33
|
+
} | {
|
|
34
|
+
action: 'throw';
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* Classify a member failure. Quota and rate-limit failures cool down (using
|
|
38
|
+
* the provider's own `retry-after` when sent, which is more accurate than
|
|
39
|
+
* any fixed guess) — account-wide for account-metered providers, per-member
|
|
40
|
+
* for model-scoped ones; auth failures park the account until re-login
|
|
41
|
+
* (credentials are account-level); server/timeout failures get a short
|
|
42
|
+
* per-member cooldown; transport failures switch without a record;
|
|
43
|
+
* everything else — most importantly CONTEXT_WINDOW_EXCEEDED and ABORTED —
|
|
44
|
+
* is the request's own fault and is rethrown untouched.
|
|
45
|
+
* @param error - the failure thrown by a member adapter's stream.
|
|
46
|
+
* @param provider - the failing member's provider (decides the quota scope).
|
|
47
|
+
* @returns the action the pool should take.
|
|
48
|
+
*/
|
|
49
|
+
export declare function classifyPoolFailure(error: unknown, provider: ProviderId): PoolFailureAction;
|
|
50
|
+
/**
|
|
51
|
+
* Cooldown registry keyed by {@link memberKey}. A member whose cooldown has
|
|
52
|
+
* expired is simply available again — recovery is proven by the next real
|
|
53
|
+
* request, not by a background probe.
|
|
54
|
+
*/
|
|
55
|
+
export declare class PoolHealthRegistry {
|
|
56
|
+
private readonly records;
|
|
57
|
+
/** Whether a member may serve: neither it nor its whole account is cooling. */
|
|
58
|
+
isMemberAvailable(provider: ProviderId, account: string, model: string, now?: number): boolean;
|
|
59
|
+
/** Whether one registry key is clear right now. */
|
|
60
|
+
isAvailable(key: string, now?: number): boolean;
|
|
61
|
+
/** Park a member for `cooldownMs`; a longer existing cooldown wins. */
|
|
62
|
+
markUnavailable(key: string, cooldownMs: number, reason: string, now?: number): void;
|
|
63
|
+
/**
|
|
64
|
+
* Epoch ms at which the earliest cooling record among `keys` recovers;
|
|
65
|
+
* `undefined` when none of them is cooling. The registry is shared by
|
|
66
|
+
* every pool, so the caller passes the keys of ITS members (member and
|
|
67
|
+
* account keys alike) — an unrelated pool's cooldown must not shape this
|
|
68
|
+
* pool's retry hint. Feeds the pool-exhausted error's
|
|
69
|
+
* `providerRetryAfterMs`.
|
|
70
|
+
*/
|
|
71
|
+
earliestRecovery(keys: ReadonlySet<string>, now?: number): number | undefined;
|
|
72
|
+
/** Drop records of one provider, or of a single account when given (auth changes). */
|
|
73
|
+
clear(provider: ProviderId, account?: string): void;
|
|
74
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Health bookkeeping for pool members: which `(provider, account, model)`
|
|
3
|
+
* member is cooling down after a failure, and for how long. Purely in-memory
|
|
4
|
+
* — a restart re-probes members naturally, so nothing here is persisted.
|
|
5
|
+
*
|
|
6
|
+
* The failure classifier maps the adapters' stable `LlmError` codes (see
|
|
7
|
+
* `httpLlmError`/`mapFetchFailure` in `common.ts`) to one of three actions:
|
|
8
|
+
* switch to another member with a cooldown, switch without recording a
|
|
9
|
+
* cooldown (transport blips say nothing about the account), or rethrow
|
|
10
|
+
* (the request itself is at fault and another account would fail alike).
|
|
11
|
+
*/
|
|
12
|
+
import { CONTEXT_WINDOW_EXCEEDED_CODE, LlmError, QUOTA_EXCEEDED_CODE } from '@deepseek-ai/dsh-llm';
|
|
13
|
+
/** Registry key for one pool member. */
|
|
14
|
+
export function memberKey(provider, account, model) {
|
|
15
|
+
return `${provider}/${account}/${model}`;
|
|
16
|
+
}
|
|
17
|
+
/** Registry key parking EVERY member of one account (account-level failures). */
|
|
18
|
+
export function accountKey(provider, account) {
|
|
19
|
+
return `${provider}/${account}/*`;
|
|
20
|
+
}
|
|
21
|
+
/** Default cooldown when a quota/rate failure carries no `retry-after`. */
|
|
22
|
+
export const DEFAULT_QUOTA_COOLDOWN_MS = 5 * 60_000;
|
|
23
|
+
/** Auth failures recheck after a day; a re-login clears the record immediately. */
|
|
24
|
+
export const AUTH_COOLDOWN_MS = 24 * 60 * 60_000;
|
|
25
|
+
/** Transient server-side failures cool down briefly. */
|
|
26
|
+
export const TRANSIENT_COOLDOWN_MS = 60_000;
|
|
27
|
+
/**
|
|
28
|
+
* Providers whose quota windows are model-scoped, so a quota failure on one
|
|
29
|
+
* model says nothing about its siblings (Claude's Opus/Sonnet lanes). Every
|
|
30
|
+
* other provider meters the account as a whole: one member hitting the wall
|
|
31
|
+
* means its siblings on the SAME account would too, so the cooldown parks
|
|
32
|
+
* the account (other accounts of the provider are unaffected).
|
|
33
|
+
*/
|
|
34
|
+
const MODEL_SCOPED_QUOTA_PROVIDERS = new Set(['claude']);
|
|
35
|
+
/** The `retry-after` an adapter propagated through `httpLlmError`, when any. */
|
|
36
|
+
function retryAfterMs(error) {
|
|
37
|
+
return error.failure.providerRetryAfterMs;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Classify a member failure. Quota and rate-limit failures cool down (using
|
|
41
|
+
* the provider's own `retry-after` when sent, which is more accurate than
|
|
42
|
+
* any fixed guess) — account-wide for account-metered providers, per-member
|
|
43
|
+
* for model-scoped ones; auth failures park the account until re-login
|
|
44
|
+
* (credentials are account-level); server/timeout failures get a short
|
|
45
|
+
* per-member cooldown; transport failures switch without a record;
|
|
46
|
+
* everything else — most importantly CONTEXT_WINDOW_EXCEEDED and ABORTED —
|
|
47
|
+
* is the request's own fault and is rethrown untouched.
|
|
48
|
+
* @param error - the failure thrown by a member adapter's stream.
|
|
49
|
+
* @param provider - the failing member's provider (decides the quota scope).
|
|
50
|
+
* @returns the action the pool should take.
|
|
51
|
+
*/
|
|
52
|
+
export function classifyPoolFailure(error, provider) {
|
|
53
|
+
if (!(error instanceof LlmError))
|
|
54
|
+
return { action: 'throw' };
|
|
55
|
+
switch (error.code) {
|
|
56
|
+
case QUOTA_EXCEEDED_CODE:
|
|
57
|
+
case 'RATE_LIMIT':
|
|
58
|
+
return {
|
|
59
|
+
action: 'switch',
|
|
60
|
+
cooldownMs: retryAfterMs(error) ?? DEFAULT_QUOTA_COOLDOWN_MS,
|
|
61
|
+
reason: error.code,
|
|
62
|
+
scope: MODEL_SCOPED_QUOTA_PROVIDERS.has(provider) ? 'member' : 'account',
|
|
63
|
+
};
|
|
64
|
+
case 'AUTH':
|
|
65
|
+
case 'INVALID_CREDENTIAL':
|
|
66
|
+
case 'MISSING_CREDENTIAL':
|
|
67
|
+
return { action: 'switch', cooldownMs: AUTH_COOLDOWN_MS, reason: error.code, scope: 'account' };
|
|
68
|
+
case 'SERVER':
|
|
69
|
+
case 'TIMEOUT':
|
|
70
|
+
case 'EMPTY_RESPONSE':
|
|
71
|
+
return { action: 'switch', cooldownMs: TRANSIENT_COOLDOWN_MS, reason: error.code, scope: 'member' };
|
|
72
|
+
case 'TRANSPORT':
|
|
73
|
+
return { action: 'switch' };
|
|
74
|
+
case 'HTTP_402':
|
|
75
|
+
case 'HTTP_404':
|
|
76
|
+
// Plan/model availability is account-shaped: another account of the
|
|
77
|
+
// same subscription may still serve. A 400 that is not a context-window
|
|
78
|
+
// error stays HTTP_400 and throws (the request itself is at fault).
|
|
79
|
+
return { action: 'switch', cooldownMs: TRANSIENT_COOLDOWN_MS, reason: error.code, scope: 'member' };
|
|
80
|
+
case CONTEXT_WINDOW_EXCEEDED_CODE:
|
|
81
|
+
case 'ABORTED':
|
|
82
|
+
default:
|
|
83
|
+
return { action: 'throw' };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Cooldown registry keyed by {@link memberKey}. A member whose cooldown has
|
|
88
|
+
* expired is simply available again — recovery is proven by the next real
|
|
89
|
+
* request, not by a background probe.
|
|
90
|
+
*/
|
|
91
|
+
export class PoolHealthRegistry {
|
|
92
|
+
records = new Map();
|
|
93
|
+
/** Whether a member may serve: neither it nor its whole account is cooling. */
|
|
94
|
+
isMemberAvailable(provider, account, model, now = Date.now()) {
|
|
95
|
+
return this.isAvailable(accountKey(provider, account), now)
|
|
96
|
+
&& this.isAvailable(memberKey(provider, account, model), now);
|
|
97
|
+
}
|
|
98
|
+
/** Whether one registry key is clear right now. */
|
|
99
|
+
isAvailable(key, now = Date.now()) {
|
|
100
|
+
const record = this.records.get(key);
|
|
101
|
+
if (record === undefined)
|
|
102
|
+
return true;
|
|
103
|
+
if (record.unavailableUntil <= now) {
|
|
104
|
+
this.records.delete(key);
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
/** Park a member for `cooldownMs`; a longer existing cooldown wins. */
|
|
110
|
+
markUnavailable(key, cooldownMs, reason, now = Date.now()) {
|
|
111
|
+
const until = now + cooldownMs;
|
|
112
|
+
const existing = this.records.get(key);
|
|
113
|
+
if (existing !== undefined && existing.unavailableUntil > until)
|
|
114
|
+
return;
|
|
115
|
+
this.records.set(key, { unavailableUntil: until, reason });
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Epoch ms at which the earliest cooling record among `keys` recovers;
|
|
119
|
+
* `undefined` when none of them is cooling. The registry is shared by
|
|
120
|
+
* every pool, so the caller passes the keys of ITS members (member and
|
|
121
|
+
* account keys alike) — an unrelated pool's cooldown must not shape this
|
|
122
|
+
* pool's retry hint. Feeds the pool-exhausted error's
|
|
123
|
+
* `providerRetryAfterMs`.
|
|
124
|
+
*/
|
|
125
|
+
earliestRecovery(keys, now = Date.now()) {
|
|
126
|
+
let earliest;
|
|
127
|
+
for (const [key, record] of this.records) {
|
|
128
|
+
if (record.unavailableUntil <= now) {
|
|
129
|
+
this.records.delete(key);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (!keys.has(key))
|
|
133
|
+
continue;
|
|
134
|
+
if (earliest === undefined || record.unavailableUntil < earliest) {
|
|
135
|
+
earliest = record.unavailableUntil;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return earliest;
|
|
139
|
+
}
|
|
140
|
+
/** Drop records of one provider, or of a single account when given (auth changes). */
|
|
141
|
+
clear(provider, account) {
|
|
142
|
+
const prefix = account === undefined ? `${provider}/` : `${provider}/${account}/`;
|
|
143
|
+
for (const key of [...this.records.keys()]) {
|
|
144
|
+
if (key.startsWith(prefix))
|
|
145
|
+
this.records.delete(key);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
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 type { ProviderUsage } from './common.js';
|
|
14
|
+
import type { ProviderId } from '../auth/store.js';
|
|
15
|
+
import type { ConcretePoolMember } from './pool-family.js';
|
|
16
|
+
/** A member is taken out of rotation once any window crosses this fill level. */
|
|
17
|
+
export declare const QUOTA_FULL_PERCENT = 95;
|
|
18
|
+
/** How long a usage snapshot is trusted before a background refresh. */
|
|
19
|
+
export declare const USAGE_TTL_MS: number;
|
|
20
|
+
/** The scheduling view of one member's quota. */
|
|
21
|
+
export interface MemberQuota {
|
|
22
|
+
/** False when a window is effectively full or the login is gone. */
|
|
23
|
+
available: boolean;
|
|
24
|
+
/** Required burn rate (fraction of window per ms); 0 when unknown. */
|
|
25
|
+
urgency: number;
|
|
26
|
+
/** Epoch ms of the snapshot this was computed from; 0 when none. */
|
|
27
|
+
fetchedAt: number;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Per-ACCOUNT usage snapshots with in-flight dedupe and
|
|
31
|
+
* stale-while-revalidate refresh. Providers without a usage endpoint
|
|
32
|
+
* (copilot) resolve no fetcher and score a constant zero urgency — which
|
|
33
|
+
* naturally ranks them behind every measured member. Fetchers are resolved
|
|
34
|
+
* lazily per (provider, account) so accounts added after startup join
|
|
35
|
+
* tracking on their first score.
|
|
36
|
+
*/
|
|
37
|
+
export declare class PoolUsageTracker {
|
|
38
|
+
private readonly fetcherFor;
|
|
39
|
+
private readonly ttlMs;
|
|
40
|
+
private readonly entries;
|
|
41
|
+
private readonly inflight;
|
|
42
|
+
constructor(fetcherFor: (provider: ProviderId, account: string) => (() => Promise<ProviderUsage>) | undefined, ttlMs?: number);
|
|
43
|
+
/**
|
|
44
|
+
* The quota view of one member. A cold cache awaits the first fetch; a
|
|
45
|
+
* stale one answers immediately while the refresh serves the NEXT call
|
|
46
|
+
* (member selection must never block on the network mid-conversation).
|
|
47
|
+
* @param member - the pool member to score (account resolved).
|
|
48
|
+
* @returns availability plus the urgency score.
|
|
49
|
+
*/
|
|
50
|
+
quotaFor(member: ConcretePoolMember): Promise<MemberQuota>;
|
|
51
|
+
/** Drop cached snapshots: one account, or a whole provider when `account` is omitted. */
|
|
52
|
+
invalidate(provider: ProviderId, account?: string): void;
|
|
53
|
+
/** Run (or join) the single in-flight fetch for one account key. */
|
|
54
|
+
private refresh;
|
|
55
|
+
/** Score one member against a snapshot's windows. */
|
|
56
|
+
private score;
|
|
57
|
+
}
|
|
@@ -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
|
+
}
|