dsh-plugin-subscriptions 0.5.2 → 0.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +36 -1
- package/README.zh.md +36 -1
- package/lib/auth/rpc.d.ts +29 -12
- package/lib/auth/rpc.js +29 -6
- package/lib/auth/store.d.ts +75 -17
- package/lib/auth/store.js +148 -27
- package/lib/client/SubscriptionsSection.d.ts +9 -3
- package/lib/client/SubscriptionsSection.js +93 -65
- package/lib/client/locales.d.ts +18 -10
- package/lib/client/locales.js +18 -10
- package/lib/client.js +250 -127
- package/lib/client.js.map +1 -1
- package/lib/index.d.ts +21 -0
- package/lib/index.js +1482 -168
- package/lib/providers/accounts.d.ts +102 -0
- package/lib/providers/accounts.js +123 -0
- package/lib/providers/claude.d.ts +22 -4
- package/lib/providers/claude.js +91 -11
- package/lib/providers/codex.d.ts +24 -3
- package/lib/providers/codex.js +116 -17
- package/lib/providers/common.d.ts +17 -0
- package/lib/providers/common.js +67 -3
- package/lib/providers/copilot.d.ts +22 -3
- package/lib/providers/copilot.js +91 -12
- package/lib/providers/grok.d.ts +24 -4
- package/lib/providers/grok.js +100 -14
- 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 +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/package.json +1 -1
package/lib/providers/grok.d.ts
CHANGED
|
@@ -7,8 +7,9 @@ import { LlmAdapter } from '@deepseek-ai/dsh-llm';
|
|
|
7
7
|
import type { GenerateOptions, LlmModelInfo, LlmProviderInfo, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm';
|
|
8
8
|
import type { FlowSpec } from '../auth/oauth-flow.js';
|
|
9
9
|
import type { GrokSession } from '../auth/store.js';
|
|
10
|
+
import type { PoolAdapter } from './pool.js';
|
|
10
11
|
import type { AttachmentStore } from '@deepseek-ai/dsh-attachment';
|
|
11
|
-
import {
|
|
12
|
+
import { AccountTokenManager } from './accounts.js';
|
|
12
13
|
import type { CatalogPersistence, DiscoveredModel, FetchFn, ModelEntry, ProviderUsage } from './common.js';
|
|
13
14
|
export declare const GROK_CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828";
|
|
14
15
|
export declare const GROK_DISCOVERY_URL = "https://auth.x.ai/.well-known/openid-configuration";
|
|
@@ -93,9 +94,10 @@ type GrokCliModelMeta = Partial<Pick<DiscoveredModel, 'name' | 'description' | '
|
|
|
93
94
|
* Fetch the CLI catalog and index its per-model metadata by model id.
|
|
94
95
|
* @param session - the stored session (used as-is; never refreshed here).
|
|
95
96
|
* @param fetchFn - fetch implementation (injectable for tests).
|
|
97
|
+
* @param signal - caller cancellation (pool-assembly timeout).
|
|
96
98
|
* @returns model id → contributed metadata.
|
|
97
99
|
*/
|
|
98
|
-
export declare function fetchGrokCliCatalog(session: GrokSession, fetchFn?: FetchFn): Promise<Map<string, GrokCliModelMeta>>;
|
|
100
|
+
export declare function fetchGrokCliCatalog(session: GrokSession, fetchFn?: FetchFn, signal?: AbortSignal): Promise<Map<string, GrokCliModelMeta>>;
|
|
99
101
|
/**
|
|
100
102
|
* Fetch the live grok model list, enriched with the CLI catalog's per-model
|
|
101
103
|
* metadata (display name, context window, reasoning efforts). The api.x.ai
|
|
@@ -109,14 +111,17 @@ export declare function fetchGrokCliCatalog(session: GrokSession, fetchFn?: Fetc
|
|
|
109
111
|
* @param onWarn - warning sink for a failed CLI catalog fetch.
|
|
110
112
|
* @param previous - last-known catalog used to keep enrichment when the CLI
|
|
111
113
|
* catalog is down or omits a model.
|
|
114
|
+
* @param signal - caller cancellation (pool-assembly timeout).
|
|
112
115
|
* @returns discovered chat models in endpoint order.
|
|
113
116
|
*/
|
|
114
|
-
export declare function fetchGrokModels(session: GrokSession, fetchFn?: FetchFn, onWarn?: (message: string) => void, previous?: readonly DiscoveredModel[]): Promise<DiscoveredModel[]>;
|
|
117
|
+
export declare function fetchGrokModels(session: GrokSession, fetchFn?: FetchFn, onWarn?: (message: string) => void, previous?: readonly DiscoveredModel[], signal?: AbortSignal): Promise<DiscoveredModel[]>;
|
|
115
118
|
/** Constructor dependencies for {@link GrokAdapter}. */
|
|
116
119
|
export interface GrokAdapterOptions {
|
|
117
120
|
models: readonly ModelEntry[];
|
|
118
121
|
streamIdleTimeoutMs: number;
|
|
119
|
-
tokens:
|
|
122
|
+
tokens: AccountTokenManager<GrokSession>;
|
|
123
|
+
/** Late-bound pool facade (wired after adapter construction); pools list under their first member's provider. */
|
|
124
|
+
pool?: () => PoolAdapter | undefined;
|
|
120
125
|
/** Whether to fetch the live catalog when logged in (false when config `models` overrides). */
|
|
121
126
|
discovery: boolean;
|
|
122
127
|
/** Warning sink for discovery failures that fall back to the static catalog. */
|
|
@@ -132,13 +137,23 @@ export interface GrokAdapterOptions {
|
|
|
132
137
|
export declare class GrokAdapter extends LlmAdapter {
|
|
133
138
|
private readonly options;
|
|
134
139
|
private readonly catalog;
|
|
140
|
+
/** In-memory catalogs for non-default accounts (the persisted cache is the default's). */
|
|
141
|
+
private readonly accountCatalogs;
|
|
142
|
+
/** Account whose snapshot currently lives in {@link catalog}; cleared on default change. */
|
|
143
|
+
private catalogOwner;
|
|
135
144
|
constructor(options: GrokAdapterOptions);
|
|
136
145
|
/** Discovery fetcher: resolves the session through the refresh-aware path. */
|
|
137
146
|
private fetchCatalog;
|
|
147
|
+
/** Drop cached catalogs after login/logout so the next list does not reuse a stale plan. */
|
|
148
|
+
clearAccountCatalog(account?: string): void;
|
|
149
|
+
/** Persisted cache for the default account; a throwaway cache for any other. */
|
|
150
|
+
private catalogFor;
|
|
138
151
|
private listed;
|
|
139
152
|
providerInfo(provider: string): LlmProviderInfo;
|
|
140
153
|
private staticModels;
|
|
141
154
|
listModels(provider: string): Promise<readonly LlmModelInfo[]>;
|
|
155
|
+
/** The provider's own catalog: union of every account, or one account when named. */
|
|
156
|
+
listOwnModels(provider: string, account?: string, signal?: AbortSignal): Promise<readonly LlmModelInfo[]>;
|
|
142
157
|
/**
|
|
143
158
|
* The discovered entry for one model. Resolved through the cache's
|
|
144
159
|
* stale-while-revalidate path: capability metadata must stay stable across
|
|
@@ -149,7 +164,12 @@ export declare class GrokAdapter extends LlmAdapter {
|
|
|
149
164
|
*/
|
|
150
165
|
private discovered;
|
|
151
166
|
resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo>;
|
|
167
|
+
/** Capability resolution of the provider's own models (the pool resolves members here). */
|
|
168
|
+
resolveOwnModel(provider: string, model: string): Promise<LlmResolvedModelInfo>;
|
|
152
169
|
stream(options: GenerateOptions): AsyncIterable<StreamChunk>;
|
|
170
|
+
/** Pool seam: stream through one specific account instead of the default. */
|
|
171
|
+
streamAccount(options: GenerateOptions, account: string): AsyncIterable<StreamChunk>;
|
|
172
|
+
private streamCore;
|
|
153
173
|
private request;
|
|
154
174
|
}
|
|
155
175
|
export {};
|
package/lib/providers/grok.js
CHANGED
|
@@ -7,7 +7,8 @@ import { attributionHeaders, EMPTY_RESPONSE_CODE, errorChain, LlmAdapter, LlmErr
|
|
|
7
7
|
import { decodeJwtPayload } from '../auth/jwt.js';
|
|
8
8
|
import { resolveImages } from '../translate/resolved.js';
|
|
9
9
|
import { streamResponses, toResponsesInput, toResponsesTools } from '../translate/responses.js';
|
|
10
|
-
import { httpLlmError, idleWatchdog, mapFetchFailure, ModelCatalogCache, discoverOrRetryAuth, isMissingOrInvalidCredential, oauthEndpointError, OAuthEndpointError,
|
|
10
|
+
import { httpLlmError, idleWatchdog, mapFetchFailure, ModelCatalogCache, discoverAcrossAccounts, discoverOrRetryAuth, isDiscoveryAborted, isMissingOrInvalidCredential, oauthEndpointError, OAuthEndpointError, } from './common.js';
|
|
11
|
+
import { AccountTokenManager, DISCOVERY_TIMEOUT_MS, unionAccountCatalogs } from './accounts.js';
|
|
11
12
|
import { proxiedFetch } from '../http.js';
|
|
12
13
|
export const GROK_CLIENT_ID = 'b1a00492-073a-47ea-816f-4c329264a828';
|
|
13
14
|
export const GROK_DISCOVERY_URL = 'https://auth.x.ai/.well-known/openid-configuration';
|
|
@@ -312,9 +313,10 @@ function grokCliReasoning(entry) {
|
|
|
312
313
|
* Fetch the CLI catalog and index its per-model metadata by model id.
|
|
313
314
|
* @param session - the stored session (used as-is; never refreshed here).
|
|
314
315
|
* @param fetchFn - fetch implementation (injectable for tests).
|
|
316
|
+
* @param signal - caller cancellation (pool-assembly timeout).
|
|
315
317
|
* @returns model id → contributed metadata.
|
|
316
318
|
*/
|
|
317
|
-
export async function fetchGrokCliCatalog(session, fetchFn = proxiedFetch) {
|
|
319
|
+
export async function fetchGrokCliCatalog(session, fetchFn = proxiedFetch, signal) {
|
|
318
320
|
const response = await fetchFn(GROK_CLI_MODELS_URL, {
|
|
319
321
|
headers: {
|
|
320
322
|
'authorization': `Bearer ${session.accessToken}`,
|
|
@@ -323,6 +325,7 @@ export async function fetchGrokCliCatalog(session, fetchFn = proxiedFetch) {
|
|
|
323
325
|
'accept': 'application/json',
|
|
324
326
|
...attributionHeaders(),
|
|
325
327
|
},
|
|
328
|
+
...signal === undefined ? {} : { signal },
|
|
326
329
|
});
|
|
327
330
|
if (!response.ok)
|
|
328
331
|
throw await oauthEndpointError(response, 'grok CLI catalog');
|
|
@@ -384,9 +387,10 @@ function grokPriorMeta(prior) {
|
|
|
384
387
|
* @param onWarn - warning sink for a failed CLI catalog fetch.
|
|
385
388
|
* @param previous - last-known catalog used to keep enrichment when the CLI
|
|
386
389
|
* catalog is down or omits a model.
|
|
390
|
+
* @param signal - caller cancellation (pool-assembly timeout).
|
|
387
391
|
* @returns discovered chat models in endpoint order.
|
|
388
392
|
*/
|
|
389
|
-
export async function fetchGrokModels(session, fetchFn = proxiedFetch, onWarn, previous) {
|
|
393
|
+
export async function fetchGrokModels(session, fetchFn = proxiedFetch, onWarn, previous, signal) {
|
|
390
394
|
const previousById = previous === undefined || previous.length === 0
|
|
391
395
|
? undefined
|
|
392
396
|
: new Map(previous.map(model => [model.id, model]));
|
|
@@ -397,8 +401,11 @@ export async function fetchGrokModels(session, fetchFn = proxiedFetch, onWarn, p
|
|
|
397
401
|
'accept': 'application/json',
|
|
398
402
|
...attributionHeaders(),
|
|
399
403
|
},
|
|
404
|
+
...signal === undefined ? {} : { signal },
|
|
400
405
|
}),
|
|
401
|
-
fetchGrokCliCatalog(session, fetchFn).catch((error) => {
|
|
406
|
+
fetchGrokCliCatalog(session, fetchFn, signal).catch((error) => {
|
|
407
|
+
if (isDiscoveryAborted(error, signal))
|
|
408
|
+
throw error;
|
|
402
409
|
onWarn?.(previousById === undefined
|
|
403
410
|
? `grok CLI catalog fetch failed; reasoning efforts are unavailable (${errorChain(error)})`
|
|
404
411
|
: `grok CLI catalog fetch failed; keeping last-known reasoning efforts (${errorChain(error)})`);
|
|
@@ -436,14 +443,50 @@ export async function fetchGrokModels(session, fetchFn = proxiedFetch, onWarn, p
|
|
|
436
443
|
export class GrokAdapter extends LlmAdapter {
|
|
437
444
|
options;
|
|
438
445
|
catalog;
|
|
446
|
+
/** In-memory catalogs for non-default accounts (the persisted cache is the default's). */
|
|
447
|
+
accountCatalogs = new Map();
|
|
448
|
+
/** Account whose snapshot currently lives in {@link catalog}; cleared on default change. */
|
|
449
|
+
catalogOwner;
|
|
439
450
|
constructor(options) {
|
|
440
451
|
super();
|
|
441
452
|
this.options = options;
|
|
442
453
|
this.catalog = new ModelCatalogCache(options.catalogStore);
|
|
443
454
|
}
|
|
444
455
|
/** Discovery fetcher: resolves the session through the refresh-aware path. */
|
|
445
|
-
async fetchCatalog() {
|
|
446
|
-
|
|
456
|
+
async fetchCatalog(account, signal) {
|
|
457
|
+
const lastKnown = account === undefined || account === await this.options.tokens.defaultAccount()
|
|
458
|
+
? this.catalog.lastKnown()
|
|
459
|
+
: this.accountCatalogs.get(account)?.lastKnown();
|
|
460
|
+
return fetchGrokModels(await this.options.tokens.session(account), this.options.fetchFn, this.options.onWarn, lastKnown, signal);
|
|
461
|
+
}
|
|
462
|
+
/** Drop cached catalogs after login/logout so the next list does not reuse a stale plan. */
|
|
463
|
+
clearAccountCatalog(account) {
|
|
464
|
+
if (account === undefined)
|
|
465
|
+
this.accountCatalogs.clear();
|
|
466
|
+
else
|
|
467
|
+
this.accountCatalogs.delete(account);
|
|
468
|
+
if (account === undefined || this.catalogOwner === account || this.catalogOwner === undefined) {
|
|
469
|
+
this.catalogOwner = undefined;
|
|
470
|
+
this.catalog.invalidate();
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
/** Persisted cache for the default account; a throwaway cache for any other. */
|
|
474
|
+
async catalogFor(account) {
|
|
475
|
+
const defaultKey = await this.options.tokens.defaultAccount();
|
|
476
|
+
const key = account ?? defaultKey;
|
|
477
|
+
if (key === undefined || key === defaultKey) {
|
|
478
|
+
if (this.catalogOwner !== undefined && this.catalogOwner !== defaultKey) {
|
|
479
|
+
this.catalog.invalidate();
|
|
480
|
+
}
|
|
481
|
+
this.catalogOwner = defaultKey;
|
|
482
|
+
return this.catalog;
|
|
483
|
+
}
|
|
484
|
+
let cache = this.accountCatalogs.get(key);
|
|
485
|
+
if (cache === undefined) {
|
|
486
|
+
cache = new ModelCatalogCache();
|
|
487
|
+
this.accountCatalogs.set(key, cache);
|
|
488
|
+
}
|
|
489
|
+
return cache;
|
|
447
490
|
}
|
|
448
491
|
listed(provider, discovered) {
|
|
449
492
|
return discovered.map(model => ({
|
|
@@ -466,19 +509,38 @@ export class GrokAdapter extends LlmAdapter {
|
|
|
466
509
|
}));
|
|
467
510
|
}
|
|
468
511
|
async listModels(provider) {
|
|
469
|
-
|
|
470
|
-
const
|
|
471
|
-
if (
|
|
512
|
+
const own = await this.listOwnModels(provider);
|
|
513
|
+
const pool = this.options.pool?.();
|
|
514
|
+
if (pool === undefined)
|
|
515
|
+
return own;
|
|
516
|
+
const extra = await pool.modelsForProvider(provider);
|
|
517
|
+
const seen = new Set(own.map(model => model.id));
|
|
518
|
+
// Account pools reuse the catalog row; only configured tiers are extra.
|
|
519
|
+
return [...own, ...extra.filter(model => !seen.has(model.id))];
|
|
520
|
+
}
|
|
521
|
+
/** The provider's own catalog: union of every account, or one account when named. */
|
|
522
|
+
async listOwnModels(provider, account, signal) {
|
|
523
|
+
if (account === undefined) {
|
|
524
|
+
const accounts = (await this.options.tokens.list()).map(entry => entry.key);
|
|
525
|
+
if (accounts.length === 0)
|
|
526
|
+
return [];
|
|
527
|
+
return unionAccountCatalogs(accounts, (key, accountSignal) => this.listOwnModels(provider, key, accountSignal), { timeoutMs: DISCOVERY_TIMEOUT_MS, ...signal === undefined ? {} : { signal } });
|
|
528
|
+
}
|
|
529
|
+
if (!await this.options.tokens.hasSession(account)) {
|
|
472
530
|
return [];
|
|
531
|
+
}
|
|
473
532
|
if (!this.options.discovery)
|
|
474
533
|
return this.staticModels(provider);
|
|
534
|
+
const catalog = await this.catalogFor(account);
|
|
475
535
|
try {
|
|
476
536
|
// The fetcher runs only on a cache miss, and resolves the session
|
|
477
537
|
// through the refresh-aware path so an expired access token renews here
|
|
478
538
|
// instead of failing discovery into the static fallback.
|
|
479
|
-
return this.listed(provider, await discoverOrRetryAuth(force => this.options.tokens.session(force),
|
|
539
|
+
return this.listed(provider, await discoverOrRetryAuth(force => this.options.tokens.session(account, force), catalog, () => catalog.get(() => this.fetchCatalog(account, signal))));
|
|
480
540
|
}
|
|
481
541
|
catch (error) {
|
|
542
|
+
if (isDiscoveryAborted(error, signal))
|
|
543
|
+
throw error;
|
|
482
544
|
// A permanent refresh failure deletes the stored session: the provider
|
|
483
545
|
// is logged out, so hide it instead of showing a stale static catalog.
|
|
484
546
|
if (isMissingOrInvalidCredential(error))
|
|
@@ -498,10 +560,22 @@ export class GrokAdapter extends LlmAdapter {
|
|
|
498
560
|
async discovered(model) {
|
|
499
561
|
if (!this.options.discovery)
|
|
500
562
|
return undefined;
|
|
501
|
-
const
|
|
502
|
-
return
|
|
563
|
+
const accounts = (await this.options.tokens.list()).map(entry => entry.key);
|
|
564
|
+
return discoverAcrossAccounts(accounts, async (account) => {
|
|
565
|
+
const catalog = await this.catalogFor(account);
|
|
566
|
+
const models = await catalog.resolve(() => this.fetchCatalog(account));
|
|
567
|
+
return models?.find(entry => entry.id === model);
|
|
568
|
+
});
|
|
503
569
|
}
|
|
504
570
|
async resolveModel(provider, model) {
|
|
571
|
+
const pool = this.options.pool?.();
|
|
572
|
+
if (pool !== undefined && await pool.owns(provider, model)) {
|
|
573
|
+
return pool.resolveModel(provider, model);
|
|
574
|
+
}
|
|
575
|
+
return this.resolveOwnModel(provider, model);
|
|
576
|
+
}
|
|
577
|
+
/** Capability resolution of the provider's own models (the pool resolves members here). */
|
|
578
|
+
async resolveOwnModel(provider, model) {
|
|
505
579
|
const discovered = await this.discovered(model);
|
|
506
580
|
const configured = this.options.models.find(entry => entry.id === model);
|
|
507
581
|
return {
|
|
@@ -519,13 +593,25 @@ export class GrokAdapter extends LlmAdapter {
|
|
|
519
593
|
};
|
|
520
594
|
}
|
|
521
595
|
async *stream(options) {
|
|
596
|
+
const pool = this.options.pool?.();
|
|
597
|
+
if (pool !== undefined && await pool.owns(options.provider, options.model)) {
|
|
598
|
+
yield* pool.stream(options);
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
yield* this.streamCore(options);
|
|
602
|
+
}
|
|
603
|
+
/** Pool seam: stream through one specific account instead of the default. */
|
|
604
|
+
streamAccount(options, account) {
|
|
605
|
+
return this.streamCore(options, account);
|
|
606
|
+
}
|
|
607
|
+
async *streamCore(options, account) {
|
|
522
608
|
const watchdog = idleWatchdog(options.signal, this.options.streamIdleTimeoutMs);
|
|
523
609
|
try {
|
|
524
|
-
let session = await this.options.tokens.session();
|
|
610
|
+
let session = await this.options.tokens.session(account);
|
|
525
611
|
let response = await this.request(options, session, watchdog.signal);
|
|
526
612
|
if (response.status === 401) {
|
|
527
613
|
// One forced refresh + retry on an unexpired-but-rejected token.
|
|
528
|
-
session = await this.options.tokens.session(true);
|
|
614
|
+
session = await this.options.tokens.session(account, true);
|
|
529
615
|
response = await this.request(options, session, watchdog.signal);
|
|
530
616
|
}
|
|
531
617
|
if (!response.ok)
|
|
@@ -0,0 +1,56 @@
|
|
|
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
|
+
import type { LlmModelInfo } from '@deepseek-ai/dsh-llm';
|
|
9
|
+
import type { ProviderId } from '../auth/store.js';
|
|
10
|
+
/** One pool member: an exact provider/account/model route. */
|
|
11
|
+
export interface PoolMemberRef {
|
|
12
|
+
provider: ProviderId;
|
|
13
|
+
/** Account key; omitted in configured members to mean "the default account". */
|
|
14
|
+
account?: string;
|
|
15
|
+
model: string;
|
|
16
|
+
}
|
|
17
|
+
/** A member with its account resolved (no config indirection left). */
|
|
18
|
+
export type ConcretePoolMember = PoolMemberRef & {
|
|
19
|
+
account: string;
|
|
20
|
+
};
|
|
21
|
+
/** One pool: its members plus display metadata for the picker. */
|
|
22
|
+
export interface PoolDefinition {
|
|
23
|
+
members: PoolMemberRef[];
|
|
24
|
+
/** Display name of the catalog entry (account pools) or the pool id (extras). */
|
|
25
|
+
name?: string;
|
|
26
|
+
/** Description of the catalog entry, when the pool borrowed one. */
|
|
27
|
+
description?: string;
|
|
28
|
+
/**
|
|
29
|
+
* When true, the pool is an extra picker entry (a configured tier). Account
|
|
30
|
+
* pools leave this unset so they reuse the provider's existing catalog row.
|
|
31
|
+
*/
|
|
32
|
+
extra?: boolean;
|
|
33
|
+
}
|
|
34
|
+
/** One account's catalog as seen through that account's credentials. */
|
|
35
|
+
export interface AccountCatalog {
|
|
36
|
+
account: string;
|
|
37
|
+
models: readonly LlmModelInfo[];
|
|
38
|
+
}
|
|
39
|
+
/** One provider's contribution to account-pool aggregation. */
|
|
40
|
+
export interface ProviderPoolSource {
|
|
41
|
+
/** Per-account catalogs, default first. A model pools only the accounts that list it. */
|
|
42
|
+
catalogs: readonly AccountCatalog[];
|
|
43
|
+
}
|
|
44
|
+
/** Map key for one provider's pool of one model (ids collide across providers). */
|
|
45
|
+
export declare function poolKey(provider: string, model: string): string;
|
|
46
|
+
/**
|
|
47
|
+
* Build per-provider account routes. Each model id becomes a definition of
|
|
48
|
+
* the accounts that list it: two or more fail over; one is pinned to that
|
|
49
|
+
* account (so a Max-only model is never sent to a Plus login). The picker
|
|
50
|
+
* unions these catalogs; a logout that drops a model to one account keeps
|
|
51
|
+
* the same id and pins it to whoever remains.
|
|
52
|
+
* @param sources - per-account catalogs (providers with no accounts list
|
|
53
|
+
* nothing and simply never join a pool).
|
|
54
|
+
* @returns `provider/model` → pool definition (not listed as an extra entry).
|
|
55
|
+
*/
|
|
56
|
+
export declare function buildAccountPools(sources: Partial<Record<ProviderId, ProviderPoolSource>>): Map<string, PoolDefinition>;
|
|
@@ -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
|
+
}
|