dsh-plugin-subscription-usage 0.0.0-stage → 0.1.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/LICENSE +22 -0
- package/README.md +38 -2
- package/cordis.patch.yml +5 -0
- package/icon.svg +9 -0
- package/lib/auth/claude-code-creds.d.ts +23 -0
- package/lib/auth/claude-code-creds.js +172 -0
- package/lib/auth/device-flow.d.ts +55 -0
- package/lib/auth/device-flow.js +177 -0
- package/lib/auth/jwt.d.ts +10 -0
- package/lib/auth/jwt.js +25 -0
- package/lib/auth/oauth-flow.d.ts +91 -0
- package/lib/auth/oauth-flow.js +227 -0
- package/lib/auth/pkce.d.ts +31 -0
- package/lib/auth/pkce.js +35 -0
- package/lib/auth/rpc.d.ts +233 -0
- package/lib/auth/rpc.js +501 -0
- package/lib/auth/store.d.ts +198 -0
- package/lib/auth/store.js +473 -0
- package/lib/client/Footer.d.ts +3 -0
- package/lib/client/Footer.js +73 -0
- package/lib/client/Gauge.d.ts +28 -0
- package/lib/client/Gauge.js +139 -0
- package/lib/client/ImageGallery.d.ts +54 -0
- package/lib/client/ImageGallery.js +112 -0
- package/lib/client/ImageGenerateToolview.d.ts +48 -0
- package/lib/client/ImageGenerateToolview.js +130 -0
- package/lib/client/Notice.d.ts +15 -0
- package/lib/client/Notice.js +93 -0
- package/lib/client/ProviderCard.d.ts +35 -0
- package/lib/client/ProviderCard.js +240 -0
- package/lib/client/SpeedSelect.d.ts +72 -0
- package/lib/client/SpeedSelect.js +214 -0
- package/lib/client/Usage.d.ts +7 -0
- package/lib/client/Usage.js +83 -0
- package/lib/client/VideoGenerateToolview.d.ts +42 -0
- package/lib/client/VideoGenerateToolview.js +172 -0
- package/lib/client/context.d.ts +16 -0
- package/lib/client/context.js +11 -0
- package/lib/client/fast-command.d.ts +2 -0
- package/lib/client/fast-command.js +4 -0
- package/lib/client/hooks.d.ts +2 -0
- package/lib/client/hooks.js +10 -0
- package/lib/client/index.d.ts +3 -0
- package/lib/client/index.js +113 -0
- package/lib/client/locales.d.ts +139 -0
- package/lib/client/locales.js +267 -0
- package/lib/client/meter.d.ts +91 -0
- package/lib/client/meter.js +196 -0
- package/lib/client/prefs.d.ts +51 -0
- package/lib/client/prefs.js +71 -0
- package/lib/client/rpc.d.ts +161 -0
- package/lib/client/rpc.js +31 -0
- package/lib/client/store.d.ts +67 -0
- package/lib/client/store.js +129 -0
- package/lib/client/styles.d.ts +7 -0
- package/lib/client/styles.js +176 -0
- package/lib/client.js +3933 -0
- package/lib/client.js.map +1 -0
- package/lib/compat.d.ts +55 -0
- package/lib/compat.js +20 -0
- package/lib/http.d.ts +134 -0
- package/lib/http.js +431 -0
- package/lib/index.d.ts +121 -0
- package/lib/index.js +13210 -0
- package/lib/json-text.d.ts +2 -0
- package/lib/json-text.js +4 -0
- package/lib/model-defaults.d.ts +23 -0
- package/lib/model-defaults.js +238 -0
- package/lib/model-speeds.d.ts +10 -0
- package/lib/model-speeds.js +48 -0
- package/lib/models-directory.d.ts +17 -0
- package/lib/models-directory.js +25 -0
- package/lib/provider-settings.d.ts +55 -0
- package/lib/provider-settings.js +215 -0
- package/lib/providers/account-preferences.d.ts +46 -0
- package/lib/providers/account-preferences.js +188 -0
- package/lib/providers/accounts.d.ts +113 -0
- package/lib/providers/accounts.js +142 -0
- package/lib/providers/antigravity-oauth-client.d.ts +7 -0
- package/lib/providers/antigravity-oauth-client.js +7 -0
- package/lib/providers/antigravity.d.ts +119 -0
- package/lib/providers/antigravity.js +552 -0
- package/lib/providers/catalog-refresh.d.ts +4 -0
- package/lib/providers/catalog-refresh.js +30 -0
- package/lib/providers/catalog-store.d.ts +47 -0
- package/lib/providers/catalog-store.js +283 -0
- package/lib/providers/claude-cli-version.d.ts +15 -0
- package/lib/providers/claude-cli-version.js +24 -0
- package/lib/providers/claude.d.ts +212 -0
- package/lib/providers/claude.js +726 -0
- package/lib/providers/codex-client-version.d.ts +8 -0
- package/lib/providers/codex-client-version.js +17 -0
- package/lib/providers/codex-search.d.ts +29 -0
- package/lib/providers/codex-search.js +198 -0
- package/lib/providers/codex.d.ts +253 -0
- package/lib/providers/codex.js +930 -0
- package/lib/providers/common.d.ts +415 -0
- package/lib/providers/common.js +679 -0
- package/lib/providers/copilot.d.ts +365 -0
- package/lib/providers/copilot.js +956 -0
- package/lib/providers/grok.d.ts +214 -0
- package/lib/providers/grok.js +750 -0
- package/lib/providers/image-pool.d.ts +30 -0
- package/lib/providers/image-pool.js +89 -0
- package/lib/providers/npm-cli-version.d.ts +56 -0
- package/lib/providers/npm-cli-version.js +98 -0
- package/lib/providers/pool-family.d.ts +56 -0
- package/lib/providers/pool-family.js +45 -0
- package/lib/providers/pool-health.d.ts +91 -0
- package/lib/providers/pool-health.js +164 -0
- package/lib/providers/pool-usage.d.ts +106 -0
- package/lib/providers/pool-usage.js +302 -0
- package/lib/providers/pool.d.ts +115 -0
- package/lib/providers/pool.js +461 -0
- package/lib/providers/rate-limit.d.ts +192 -0
- package/lib/providers/rate-limit.js +338 -0
- package/lib/providers/replay.d.ts +4 -0
- package/lib/providers/replay.js +37 -0
- package/lib/providers/reset-redemption.d.ts +20 -0
- package/lib/providers/reset-redemption.js +70 -0
- package/lib/tools/image-generate.d.ts +128 -0
- package/lib/tools/image-generate.js +438 -0
- package/lib/tools/registration.d.ts +15 -0
- package/lib/tools/registration.js +24 -0
- package/lib/tools/video-generate.d.ts +89 -0
- package/lib/tools/video-generate.js +257 -0
- package/lib/tools/x-search.d.ts +58 -0
- package/lib/tools/x-search.js +197 -0
- package/lib/translate/anthropic.d.ts +180 -0
- package/lib/translate/anthropic.js +534 -0
- package/lib/translate/antigravity-images.d.ts +11 -0
- package/lib/translate/antigravity-images.js +62 -0
- package/lib/translate/antigravity-schema.d.ts +4 -0
- package/lib/translate/antigravity-schema.js +145 -0
- package/lib/translate/antigravity-thinking.d.ts +5 -0
- package/lib/translate/antigravity-thinking.js +44 -0
- package/lib/translate/antigravity.d.ts +120 -0
- package/lib/translate/antigravity.js +412 -0
- package/lib/translate/chat-completions.d.ts +120 -0
- package/lib/translate/chat-completions.js +382 -0
- package/lib/translate/resolved.d.ts +98 -0
- package/lib/translate/resolved.js +186 -0
- package/lib/translate/responses.d.ts +192 -0
- package/lib/translate/responses.js +443 -0
- package/lib/translate/sse.d.ts +21 -0
- package/lib/translate/sse.js +62 -0
- package/lib/usage/headers.d.ts +21 -0
- package/lib/usage/headers.js +64 -0
- package/lib/usage/pace.d.ts +53 -0
- package/lib/usage/pace.js +48 -0
- package/lib/usage/service.d.ts +64 -0
- package/lib/usage/service.js +104 -0
- package/lib/usage-providers/common.d.ts +23 -0
- package/lib/usage-providers/common.js +28 -0
- package/lib/usage-providers/index.d.ts +42 -0
- package/lib/usage-providers/index.js +71 -0
- package/lib/usage-providers/keys.d.ts +11 -0
- package/lib/usage-providers/keys.js +52 -0
- package/lib/usage-providers/kimi.d.ts +4 -0
- package/lib/usage-providers/kimi.js +52 -0
- package/lib/usage-providers/ollama-cloud.d.ts +4 -0
- package/lib/usage-providers/ollama-cloud.js +42 -0
- package/lib/usage-providers/zai.d.ts +5 -0
- package/lib/usage-providers/zai.js +53 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +115 -4
|
@@ -0,0 +1,106 @@
|
|
|
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, UsageWindow } from './common.js';
|
|
14
|
+
import type { UsageProviderId } from '../usage-providers/index.js';
|
|
15
|
+
import type { ConcretePoolMember } from './pool-family.js';
|
|
16
|
+
/** Near-full members are tried only after members below this fill level. */
|
|
17
|
+
export declare const QUOTA_FULL_PERCENT = 95;
|
|
18
|
+
/** Exhausted subscription windows must not silently spill over into paid credits. */
|
|
19
|
+
export declare const QUOTA_EXHAUSTED_PERCENT = 100;
|
|
20
|
+
/** How long a usage snapshot is trusted before a background refresh. */
|
|
21
|
+
export declare const USAGE_TTL_MS: number;
|
|
22
|
+
/** The scheduling view of one member's quota. */
|
|
23
|
+
export interface MemberQuota {
|
|
24
|
+
/** False when a window is near-full or the login is gone. */
|
|
25
|
+
available: boolean;
|
|
26
|
+
/** A known exhausted window: exclude even if the upstream would accept paid credits. */
|
|
27
|
+
exhausted?: true;
|
|
28
|
+
/** Burn rate in strategy-specific units; 0 when unmeasured. */
|
|
29
|
+
urgency: number;
|
|
30
|
+
/** Epoch ms of the snapshot this was computed from; 0 when none. */
|
|
31
|
+
fetchedAt: number;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Per-ACCOUNT usage snapshots with in-flight dedupe and
|
|
35
|
+
* stale-while-revalidate refresh. Providers without a usage endpoint
|
|
36
|
+
* (copilot) resolve no fetcher and score a constant zero urgency — which
|
|
37
|
+
* naturally ranks them behind every measured member. Fetchers are resolved
|
|
38
|
+
* lazily per (provider, account) so accounts added after startup join
|
|
39
|
+
* tracking on their first score.
|
|
40
|
+
*/
|
|
41
|
+
export declare class PoolUsageTracker {
|
|
42
|
+
private readonly fetcherFor;
|
|
43
|
+
private readonly ttlMs;
|
|
44
|
+
private readonly entries;
|
|
45
|
+
private readonly inflight;
|
|
46
|
+
constructor(fetcherFor: (provider: UsageProviderId, account: string) => (() => Promise<ProviderUsage>) | undefined, ttlMs?: number);
|
|
47
|
+
/**
|
|
48
|
+
* The quota view of one member. A cold cache awaits the first fetch; a
|
|
49
|
+
* stale non-exhausted snapshot answers immediately while refreshing in the
|
|
50
|
+
* background. Exhausted snapshots await their expired-cache refresh so a
|
|
51
|
+
* reset can restore routing on this call. A live failure cooldown never
|
|
52
|
+
* triggers another fetch.
|
|
53
|
+
*
|
|
54
|
+
* Failures discard stale urgency but preserve known exhaustion: an outage
|
|
55
|
+
* must not turn a blocked subscription into permission to spend credits.
|
|
56
|
+
* Only a successful usage lookup (or explicit invalidation) clears it.
|
|
57
|
+
* @param member - the pool member to score (account resolved).
|
|
58
|
+
* @returns availability plus the urgency score.
|
|
59
|
+
*/
|
|
60
|
+
quotaFor(member: ConcretePoolMember, normalized?: boolean): Promise<MemberQuota>;
|
|
61
|
+
/** Keep a known credit-spending risk blocked through usage-endpoint failures. */
|
|
62
|
+
private failedQuota;
|
|
63
|
+
/**
|
|
64
|
+
* Same cache as {@link quotaFor}, for direct display (the Settings page):
|
|
65
|
+
* the raw snapshot, or the original fetch error, instead of a routing
|
|
66
|
+
* score.
|
|
67
|
+
* @param provider - the account's provider.
|
|
68
|
+
* @param account - the account key.
|
|
69
|
+
* @param force - bypass a fresh cached SNAPSHOT for an honest re-check (the
|
|
70
|
+
* manual Refresh button). A live failure cooldown is never bypassed —
|
|
71
|
+
* retrying through it is exactly what turns a 429 into a permanent
|
|
72
|
+
* lockout, so even a forced call still answers from the negative cache.
|
|
73
|
+
* @returns `{ supported: false }` when the provider has no usage fetcher.
|
|
74
|
+
*/
|
|
75
|
+
snapshotFor(provider: UsageProviderId, account: string, force?: boolean): Promise<ProviderUsage>;
|
|
76
|
+
/** Decorate a copy for UI consumers; raw snapshots and routing stay unchanged. */
|
|
77
|
+
displaySnapshotFor(provider: UsageProviderId, account: string, force?: boolean): Promise<ProviderUsage>;
|
|
78
|
+
/**
|
|
79
|
+
* Record windows read from a model reply as a fresh snapshot. Windows the
|
|
80
|
+
* reply carries replace same-kind unscoped windows; the endpoint's other
|
|
81
|
+
* windows (model-scoped lanes, plan, reset credits) stay as they were.
|
|
82
|
+
* A live failure cooldown is left in place: it guards the usage endpoint,
|
|
83
|
+
* which this did not call.
|
|
84
|
+
* @returns the merged snapshot, or undefined when nothing was recorded.
|
|
85
|
+
*/
|
|
86
|
+
ingest(provider: UsageProviderId, account: string, windows: readonly UsageWindow[], at?: number): ProviderUsage | undefined;
|
|
87
|
+
/** Epoch ms of the account's last successful snapshot, live or fetched. */
|
|
88
|
+
fetchedAt(provider: UsageProviderId, account: string): number | undefined;
|
|
89
|
+
/** Recheck after an inference quota failure without erasing another model lane's exhaustion. */
|
|
90
|
+
expire(provider: UsageProviderId, account: string): void;
|
|
91
|
+
/** Drop cached snapshots: one account, or a whole provider when `account` is omitted. */
|
|
92
|
+
invalidate(provider: UsageProviderId, account?: string): void;
|
|
93
|
+
/**
|
|
94
|
+
* Run (or join) the single in-flight fetch for one account key, caching
|
|
95
|
+
* either outcome. A missing/invalid credential is deliberately NOT
|
|
96
|
+
* negative-cached: it costs no network round trip (the session lookup
|
|
97
|
+
* fails before the request goes out) and re-checking live means the
|
|
98
|
+
* member rejoins routing the instant its login is fixed, rather than
|
|
99
|
+
* waiting out a stale cooldown.
|
|
100
|
+
*/
|
|
101
|
+
private refresh;
|
|
102
|
+
/** Score one member against a snapshot's windows. */
|
|
103
|
+
private score;
|
|
104
|
+
}
|
|
105
|
+
/** Dimensionless urgency for the opt-in strategy. Missing timing adds no reset bonus. */
|
|
106
|
+
export declare function normalizedWindowUrgency(window: UsageWindow, now: number): number;
|
|
@@ -0,0 +1,302 @@
|
|
|
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, OAuthEndpointError } from './common.js';
|
|
14
|
+
/** Near-full members are tried only after members below this fill level. */
|
|
15
|
+
export const QUOTA_FULL_PERCENT = 95;
|
|
16
|
+
/** Exhausted subscription windows must not silently spill over into paid credits. */
|
|
17
|
+
export const QUOTA_EXHAUSTED_PERCENT = 100;
|
|
18
|
+
/** How long a usage snapshot is trusted before a background refresh. */
|
|
19
|
+
export const USAGE_TTL_MS = 5 * 60_000;
|
|
20
|
+
/** Assumed window length when the provider discloses no `resetsAt`. */
|
|
21
|
+
const FALLBACK_HORIZON_MS = {
|
|
22
|
+
session: 5 * 60 * 60_000,
|
|
23
|
+
weekly: 7 * 24 * 60 * 60_000,
|
|
24
|
+
other: 30 * 24 * 60 * 60_000,
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* Per-ACCOUNT usage snapshots with in-flight dedupe and
|
|
28
|
+
* stale-while-revalidate refresh. Providers without a usage endpoint
|
|
29
|
+
* (copilot) resolve no fetcher and score a constant zero urgency — which
|
|
30
|
+
* naturally ranks them behind every measured member. Fetchers are resolved
|
|
31
|
+
* lazily per (provider, account) so accounts added after startup join
|
|
32
|
+
* tracking on their first score.
|
|
33
|
+
*/
|
|
34
|
+
export class PoolUsageTracker {
|
|
35
|
+
fetcherFor;
|
|
36
|
+
ttlMs;
|
|
37
|
+
entries = new Map();
|
|
38
|
+
inflight = new Map();
|
|
39
|
+
constructor(fetcherFor, ttlMs = USAGE_TTL_MS) {
|
|
40
|
+
this.fetcherFor = fetcherFor;
|
|
41
|
+
this.ttlMs = ttlMs;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* The quota view of one member. A cold cache awaits the first fetch; a
|
|
45
|
+
* stale non-exhausted snapshot answers immediately while refreshing in the
|
|
46
|
+
* background. Exhausted snapshots await their expired-cache refresh so a
|
|
47
|
+
* reset can restore routing on this call. A live failure cooldown never
|
|
48
|
+
* triggers another fetch.
|
|
49
|
+
*
|
|
50
|
+
* Failures discard stale urgency but preserve known exhaustion: an outage
|
|
51
|
+
* must not turn a blocked subscription into permission to spend credits.
|
|
52
|
+
* Only a successful usage lookup (or explicit invalidation) clears it.
|
|
53
|
+
* @param member - the pool member to score (account resolved).
|
|
54
|
+
* @returns availability plus the urgency score.
|
|
55
|
+
*/
|
|
56
|
+
async quotaFor(member, normalized = false) {
|
|
57
|
+
const key = `${member.provider}/${member.account}`;
|
|
58
|
+
const fetcher = this.fetcherFor(member.provider, member.account);
|
|
59
|
+
if (fetcher === undefined)
|
|
60
|
+
return { available: true, urgency: 0, fetchedAt: 0 };
|
|
61
|
+
const entry = this.entries.get(key);
|
|
62
|
+
if (entry !== undefined) {
|
|
63
|
+
const fresh = Date.now() - entry.at < (entry.cooldownMs ?? this.ttlMs);
|
|
64
|
+
if (entry.snapshot !== undefined) {
|
|
65
|
+
const quota = this.score(member, entry, normalized);
|
|
66
|
+
if (fresh)
|
|
67
|
+
return quota;
|
|
68
|
+
if (!quota.exhausted && entry.at !== Number.NEGATIVE_INFINITY) {
|
|
69
|
+
void this.refresh(key, fetcher).catch(() => undefined);
|
|
70
|
+
return quota;
|
|
71
|
+
}
|
|
72
|
+
// A reset may have restored the subscription since the last lookup.
|
|
73
|
+
}
|
|
74
|
+
else if (fresh) {
|
|
75
|
+
return this.failedQuota(member, entry.error, entry.lastSnapshot);
|
|
76
|
+
}
|
|
77
|
+
// The cooldown expired: fall through to a fresh, blocking attempt.
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
const snapshot = await this.refresh(key, fetcher);
|
|
81
|
+
return this.score(member, { snapshot, at: Date.now() }, normalized);
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
return this.failedQuota(member, error, entry?.snapshot ?? entry?.lastSnapshot);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/** Keep a known credit-spending risk blocked through usage-endpoint failures. */
|
|
88
|
+
failedQuota(member, error, snapshot) {
|
|
89
|
+
const degraded = degradedQuota(error);
|
|
90
|
+
if (snapshot !== undefined && this.score(member, { snapshot, at: 0 }).exhausted) {
|
|
91
|
+
return { ...degraded, available: false, exhausted: true };
|
|
92
|
+
}
|
|
93
|
+
return degraded;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Same cache as {@link quotaFor}, for direct display (the Settings page):
|
|
97
|
+
* the raw snapshot, or the original fetch error, instead of a routing
|
|
98
|
+
* score.
|
|
99
|
+
* @param provider - the account's provider.
|
|
100
|
+
* @param account - the account key.
|
|
101
|
+
* @param force - bypass a fresh cached SNAPSHOT for an honest re-check (the
|
|
102
|
+
* manual Refresh button). A live failure cooldown is never bypassed —
|
|
103
|
+
* retrying through it is exactly what turns a 429 into a permanent
|
|
104
|
+
* lockout, so even a forced call still answers from the negative cache.
|
|
105
|
+
* @returns `{ supported: false }` when the provider has no usage fetcher.
|
|
106
|
+
*/
|
|
107
|
+
async snapshotFor(provider, account, force = false) {
|
|
108
|
+
const fetcher = this.fetcherFor(provider, account);
|
|
109
|
+
if (fetcher === undefined)
|
|
110
|
+
return { supported: false };
|
|
111
|
+
const key = `${provider}/${account}`;
|
|
112
|
+
const entry = this.entries.get(key);
|
|
113
|
+
if (entry !== undefined && Date.now() - entry.at < (entry.cooldownMs ?? this.ttlMs)) {
|
|
114
|
+
if (entry.snapshot !== undefined) {
|
|
115
|
+
if (!force)
|
|
116
|
+
return entry.snapshot;
|
|
117
|
+
}
|
|
118
|
+
else if (entry.lastSnapshot !== undefined) {
|
|
119
|
+
// A stale-but-real snapshot beats surfacing the cooldown error to
|
|
120
|
+
// every display surface — this is what was previously showing, so
|
|
121
|
+
// keep showing it (even through a forced refresh: retrying past the
|
|
122
|
+
// cooldown is exactly the retry storm `cooldownMs` exists to avoid).
|
|
123
|
+
return entry.lastSnapshot;
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
throw entry.error;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
try {
|
|
130
|
+
return await this.refresh(key, fetcher);
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
// Same fallback as the already-cooling-down branch above, for the
|
|
134
|
+
// fetch that just failed on THIS call: `refresh` recorded whatever
|
|
135
|
+
// snapshot was on record before it ran onto the new failure entry.
|
|
136
|
+
const failed = this.entries.get(key);
|
|
137
|
+
if (failed?.snapshot === undefined && failed?.lastSnapshot !== undefined)
|
|
138
|
+
return failed.lastSnapshot;
|
|
139
|
+
throw error;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/** Decorate a copy for UI consumers; raw snapshots and routing stay unchanged. */
|
|
143
|
+
async displaySnapshotFor(provider, account, force = false) {
|
|
144
|
+
const snapshot = await this.snapshotFor(provider, account, force);
|
|
145
|
+
const entry = this.entries.get(`${provider}/${account}`);
|
|
146
|
+
if (!entry)
|
|
147
|
+
return snapshot;
|
|
148
|
+
const fetchedAt = entry.snapshot !== undefined ? (entry.fetchedAt ?? entry.at) : entry.lastSnapshotAt;
|
|
149
|
+
return {
|
|
150
|
+
...snapshot,
|
|
151
|
+
...(fetchedAt === undefined || !Number.isFinite(fetchedAt) ? {} : { fetchedAt, staleAt: fetchedAt + this.ttlMs }),
|
|
152
|
+
stale: entry.snapshot === undefined || Date.now() - entry.at >= this.ttlMs,
|
|
153
|
+
refreshFailed: entry.snapshot === undefined,
|
|
154
|
+
...(entry.snapshot === undefined ? { retryAt: entry.at + entry.cooldownMs } : {}),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Record windows read from a model reply as a fresh snapshot. Windows the
|
|
159
|
+
* reply carries replace same-kind unscoped windows; the endpoint's other
|
|
160
|
+
* windows (model-scoped lanes, plan, reset credits) stay as they were.
|
|
161
|
+
* A live failure cooldown is left in place: it guards the usage endpoint,
|
|
162
|
+
* which this did not call.
|
|
163
|
+
* @returns the merged snapshot, or undefined when nothing was recorded.
|
|
164
|
+
*/
|
|
165
|
+
ingest(provider, account, windows, at = Date.now()) {
|
|
166
|
+
if (windows.length === 0)
|
|
167
|
+
return undefined;
|
|
168
|
+
const key = `${provider}/${account}`;
|
|
169
|
+
const entry = this.entries.get(key);
|
|
170
|
+
if (entry !== undefined && entry.snapshot === undefined && Date.now() - entry.at < entry.cooldownMs)
|
|
171
|
+
return undefined;
|
|
172
|
+
const base = entry?.snapshot ?? entry?.lastSnapshot ?? { supported: true };
|
|
173
|
+
const replaced = new Set(windows.map(window => window.kind));
|
|
174
|
+
const kept = (base.windows ?? []).filter(window => window.scope !== undefined || !replaced.has(window.kind));
|
|
175
|
+
const snapshot = { ...base, supported: true, windows: [...windows, ...kept] };
|
|
176
|
+
this.entries.set(key, { snapshot, at, fetchedAt: at });
|
|
177
|
+
return snapshot;
|
|
178
|
+
}
|
|
179
|
+
/** Epoch ms of the account's last successful snapshot, live or fetched. */
|
|
180
|
+
fetchedAt(provider, account) {
|
|
181
|
+
const entry = this.entries.get(`${provider}/${account}`);
|
|
182
|
+
if (entry === undefined)
|
|
183
|
+
return undefined;
|
|
184
|
+
return entry.snapshot !== undefined ? (entry.fetchedAt ?? entry.at) : entry.lastSnapshotAt;
|
|
185
|
+
}
|
|
186
|
+
/** Recheck after an inference quota failure without erasing another model lane's exhaustion. */
|
|
187
|
+
expire(provider, account) {
|
|
188
|
+
const key = `${provider}/${account}`;
|
|
189
|
+
const entry = this.entries.get(key);
|
|
190
|
+
// Respect a usage-endpoint cooldown; inference errors do not lift it.
|
|
191
|
+
if (entry?.snapshot !== undefined)
|
|
192
|
+
this.entries.set(key, { ...entry, at: Number.NEGATIVE_INFINITY });
|
|
193
|
+
}
|
|
194
|
+
/** Drop cached snapshots: one account, or a whole provider when `account` is omitted. */
|
|
195
|
+
invalidate(provider, account) {
|
|
196
|
+
if (account !== undefined) {
|
|
197
|
+
this.entries.delete(`${provider}/${account}`);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
for (const key of [...this.entries.keys()]) {
|
|
201
|
+
if (key.startsWith(`${provider}/`))
|
|
202
|
+
this.entries.delete(key);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Run (or join) the single in-flight fetch for one account key, caching
|
|
207
|
+
* either outcome. A missing/invalid credential is deliberately NOT
|
|
208
|
+
* negative-cached: it costs no network round trip (the session lookup
|
|
209
|
+
* fails before the request goes out) and re-checking live means the
|
|
210
|
+
* member rejoins routing the instant its login is fixed, rather than
|
|
211
|
+
* waiting out a stale cooldown.
|
|
212
|
+
*/
|
|
213
|
+
refresh(key, fetcher) {
|
|
214
|
+
let pending = this.inflight.get(key);
|
|
215
|
+
if (pending === undefined) {
|
|
216
|
+
// Captured before the fetch starts: whichever real snapshot is on
|
|
217
|
+
// record right now is what a failure below should fall back to. A
|
|
218
|
+
// failure entry's own `lastSnapshot` counts too — otherwise the stale
|
|
219
|
+
// snapshot would survive exactly one cooldown and vanish on the next
|
|
220
|
+
// consecutive failure, even though nothing newer ever replaced it.
|
|
221
|
+
const prior = this.entries.get(key);
|
|
222
|
+
const lastSnapshot = prior?.snapshot ?? prior?.lastSnapshot;
|
|
223
|
+
const lastSnapshotAt = prior?.snapshot !== undefined ? (prior.fetchedAt ?? prior.at) : prior?.lastSnapshotAt;
|
|
224
|
+
pending = fetcher().then((snapshot) => {
|
|
225
|
+
const fetchedAt = Date.now();
|
|
226
|
+
this.entries.set(key, { snapshot, at: fetchedAt, fetchedAt });
|
|
227
|
+
return snapshot;
|
|
228
|
+
}, (error) => {
|
|
229
|
+
if (!isMissingOrInvalidCredential(error)) {
|
|
230
|
+
this.entries.set(key, {
|
|
231
|
+
error,
|
|
232
|
+
at: Date.now(),
|
|
233
|
+
cooldownMs: cooldownFor(error, this.ttlMs),
|
|
234
|
+
...lastSnapshot === undefined ? {} : { lastSnapshot },
|
|
235
|
+
...lastSnapshotAt === undefined ? {} : { lastSnapshotAt },
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
throw error;
|
|
239
|
+
}).finally(() => {
|
|
240
|
+
this.inflight.delete(key);
|
|
241
|
+
});
|
|
242
|
+
this.inflight.set(key, pending);
|
|
243
|
+
}
|
|
244
|
+
return pending;
|
|
245
|
+
}
|
|
246
|
+
/** Score one member against a snapshot's windows. */
|
|
247
|
+
score(member, entry, normalized = false) {
|
|
248
|
+
const windows = (entry.snapshot.windows ?? []).filter(window => windowApplies(window, member.model));
|
|
249
|
+
let available = true;
|
|
250
|
+
let urgency = 0;
|
|
251
|
+
for (const window of windows) {
|
|
252
|
+
if (window.usedPercent >= QUOTA_FULL_PERCENT)
|
|
253
|
+
available = false;
|
|
254
|
+
urgency = Math.max(urgency, normalized ? normalizedWindowUrgency(window, Date.now()) : windowUrgency(window));
|
|
255
|
+
}
|
|
256
|
+
const exhausted = windows.some(window => window.usedPercent >= QUOTA_EXHAUSTED_PERCENT);
|
|
257
|
+
return { available, urgency, fetchedAt: entry.at, ...exhausted ? { exhausted: true } : {} };
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* The routing view of a fetch failure. Logged out: the member cannot serve
|
|
262
|
+
* at all. Any other failure (network, endpoint rate limit) must not block
|
|
263
|
+
* routing — the member stays available with a zero score, degrading the
|
|
264
|
+
* strategy to plain priority order for it.
|
|
265
|
+
*/
|
|
266
|
+
function degradedQuota(error) {
|
|
267
|
+
return isMissingOrInvalidCredential(error)
|
|
268
|
+
? { available: false, urgency: 0, fetchedAt: 0 }
|
|
269
|
+
: { available: true, urgency: 0, fetchedAt: 0 };
|
|
270
|
+
}
|
|
271
|
+
/** How long to hold a failure in the negative cache: the endpoint's own `retry-after`, or the default TTL. */
|
|
272
|
+
function cooldownFor(error, defaultTtlMs) {
|
|
273
|
+
return error instanceof OAuthEndpointError && error.retryAfterMs !== undefined ? error.retryAfterMs : defaultTtlMs;
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Whether a window constrains this model: unscoped windows always do; a
|
|
277
|
+
* model-scoped window (Claude's Opus/Sonnet lanes) applies when its scope
|
|
278
|
+
* names the model family.
|
|
279
|
+
*/
|
|
280
|
+
function windowApplies(window, model) {
|
|
281
|
+
if (window.scope === undefined)
|
|
282
|
+
return true;
|
|
283
|
+
return model.toLowerCase().includes(window.scope.toLowerCase());
|
|
284
|
+
}
|
|
285
|
+
/** Dimensionless urgency for the opt-in strategy. Missing timing adds no reset bonus. */
|
|
286
|
+
export function normalizedWindowUrgency(window, now) {
|
|
287
|
+
const remaining = Math.max(0, Math.min(1, 1 - window.usedPercent / 100));
|
|
288
|
+
const { durationMs, resetsAt } = window;
|
|
289
|
+
if (durationMs === undefined || !Number.isFinite(durationMs) || durationMs <= 0
|
|
290
|
+
|| resetsAt === undefined || !Number.isFinite(resetsAt))
|
|
291
|
+
return remaining;
|
|
292
|
+
const timeFraction = Math.min(1, Math.max(0.001, (resetsAt - now) / durationMs));
|
|
293
|
+
return remaining / timeFraction;
|
|
294
|
+
}
|
|
295
|
+
/** The required burn rate of one window (fraction per ms). */
|
|
296
|
+
function windowUrgency(window, now = Date.now()) {
|
|
297
|
+
const remaining = Math.max(0, 1 - window.usedPercent / 100);
|
|
298
|
+
const horizon = window.resetsAt !== undefined
|
|
299
|
+
? Math.max(window.resetsAt - now, 1)
|
|
300
|
+
: FALLBACK_HORIZON_MS[window.kind];
|
|
301
|
+
return remaining / horizon;
|
|
302
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
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
|
+
* content chunk has been emitted (leading usage remains buffered).
|
|
9
|
+
*/
|
|
10
|
+
import { LlmAdapter } from '@deepseek-ai/dsh-llm';
|
|
11
|
+
import type { GenerateOptions, LlmModelInfo, LlmResolvedModelInfo, PreparedAdapterCall, 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' | 'quota_normalized';
|
|
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
|
+
/** Resolve legacy account aliases before pool deduplication and cache identity. */
|
|
30
|
+
resolveAccount?: (provider: ProviderId, account: string) => Promise<string>;
|
|
31
|
+
/** Account pools (auto-aggregated plus config overrides), resolved lazily. */
|
|
32
|
+
families: () => Promise<Map<string, PoolDefinition>>;
|
|
33
|
+
/** User-configured extra picker entries (heterogeneous fallbacks), by pool id. */
|
|
34
|
+
tiers: Record<string, PoolMemberRef[]>;
|
|
35
|
+
onWarn: (message: string) => void;
|
|
36
|
+
}
|
|
37
|
+
export declare class PoolAdapter extends LlmAdapter {
|
|
38
|
+
private readonly options;
|
|
39
|
+
/** sessionId|poolId → member key of the last member that served a chunk. */
|
|
40
|
+
private readonly sticky;
|
|
41
|
+
/** Messages already warned about — configuration diagnostics repeat every request otherwise. */
|
|
42
|
+
private readonly warned;
|
|
43
|
+
/**
|
|
44
|
+
* Short-lived pools snapshot. `owns()` runs on every resolveModel — the
|
|
45
|
+
* model picker issues one per entry — and pool assembly touches every
|
|
46
|
+
* provider's catalog and account store, so recompute at most this often.
|
|
47
|
+
* Auth changes bump {@link generation} so a stale snapshot cannot land.
|
|
48
|
+
*/
|
|
49
|
+
private poolsCache;
|
|
50
|
+
private poolsInflight;
|
|
51
|
+
private generation;
|
|
52
|
+
constructor(options: PoolAdapterOptions);
|
|
53
|
+
/** Drop the pools snapshot so the next read reflects the current accounts. */
|
|
54
|
+
invalidate(): void;
|
|
55
|
+
/** Warn once per distinct message (pools() runs on every request). */
|
|
56
|
+
private warnOnce;
|
|
57
|
+
/** Drop members whose adapter is not registered (copy — caller state is shared). */
|
|
58
|
+
private usable;
|
|
59
|
+
/** Account pools (auto-aggregated plus config overrides) with usable members. */
|
|
60
|
+
private familyPools;
|
|
61
|
+
/** All pools (account pools merged with extra tiers) with usable members. */
|
|
62
|
+
private pools;
|
|
63
|
+
/** Recompute the pools snapshot (account pools merged with extra tiers). */
|
|
64
|
+
private assemblePools;
|
|
65
|
+
/**
|
|
66
|
+
* Extra picker rows one provider lists (configured tiers). Account pools
|
|
67
|
+
* reuse the catalog entry of the same wire id, so they are not listed
|
|
68
|
+
* again — the picker stays one row per model in ChatGPT / Claude / ….
|
|
69
|
+
*/
|
|
70
|
+
modelsForProvider(provider: ProviderId): Promise<LlmModelInfo[]>;
|
|
71
|
+
/**
|
|
72
|
+
* Whether `model` on `provider`'s route is served here (several accounts
|
|
73
|
+
* fail over, one account is pinned, or a configured tier).
|
|
74
|
+
*/
|
|
75
|
+
owns(provider: ProviderId, model: string): Promise<boolean>;
|
|
76
|
+
/**
|
|
77
|
+
* Resolve every member's account (config members may omit it to mean "the
|
|
78
|
+
* default account") and drop members with no resolvable login. Duplicates
|
|
79
|
+
* collapse — an explicitly pinned account and the default may coincide.
|
|
80
|
+
*/
|
|
81
|
+
private concrete;
|
|
82
|
+
/**
|
|
83
|
+
* Resolve a pool model to the conservative INTERSECTION of its members'
|
|
84
|
+
* capabilities: the smallest context window and output cap, the reasoning
|
|
85
|
+
* efforts every member supports, and the modalities all of them accept —
|
|
86
|
+
* so a request valid for the pool stays valid after a failover. Capability
|
|
87
|
+
* metadata is account-specific, so every distinct member is resolved.
|
|
88
|
+
*/
|
|
89
|
+
resolveModel(provider: string, model: string, signal?: AbortSignal): Promise<LlmResolvedModelInfo>;
|
|
90
|
+
prepareCall(provider: string, model: string, signal?: AbortSignal): Promise<PreparedAdapterCall>;
|
|
91
|
+
stream(options: GenerateOptions): AsyncIterable<StreamChunk>;
|
|
92
|
+
private streamMembers;
|
|
93
|
+
/**
|
|
94
|
+
* Health and known quota exhaustion filter BOTH strategies: >=100% must
|
|
95
|
+
* never silently spend paid credits. `quota_aware` ranks healthy members
|
|
96
|
+
* by urgency, then tries near-full (95–<100%) members by urgency too.
|
|
97
|
+
* Members without telemetry score zero. Stickiness cannot retain an
|
|
98
|
+
* exhausted member; within the preferred class it uses `switchMargin`.
|
|
99
|
+
*/
|
|
100
|
+
private select;
|
|
101
|
+
/** Pin the serving member to the session (with bounded memory). */
|
|
102
|
+
private remember;
|
|
103
|
+
/**
|
|
104
|
+
* The error for an exhausted pool. It says WHY (issue #139): the last
|
|
105
|
+
* member failure of this request when members were tried, or the cooling
|
|
106
|
+
* record that kept every member out of the candidate set — and its `code`
|
|
107
|
+
* is that failure's own code, never a synthetic `RATE_LIMIT` (a transport
|
|
108
|
+
* blip on a single-member pool used to be reported as a rate limit). The
|
|
109
|
+
* earliest recovery hint is scoped to THIS pool's members (the health
|
|
110
|
+
* registry is shared across pools). `retryNow` drops the hint: a member
|
|
111
|
+
* that returned an empty completion keeps no cooldown and can be retried
|
|
112
|
+
* at once, so another member's cooldown must not set the wait.
|
|
113
|
+
*/
|
|
114
|
+
private exhausted;
|
|
115
|
+
}
|