dsh-plugin-subscription-oauth 0.7.0 → 0.8.1
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/lib/auth/allowance-scheduler.d.ts +58 -0
- package/lib/auth/allowance-scheduler.js +72 -0
- package/lib/client/UsageChip.d.ts +58 -2
- package/lib/client/UsageChip.js +116 -22
- package/lib/client/styles.d.ts +1 -1
- package/lib/client/styles.js +16 -4
- package/lib/client.js +106 -58
- package/lib/client.js.map +1 -1
- package/lib/providers/antigravity.d.ts +6 -0
- package/lib/providers/antigravity.js +65 -14
- package/lib/providers/ark-provider.js +8 -0
- package/lib/providers/ark.d.ts +3 -0
- package/lib/providers/ark.js +4 -1
- package/lib/providers/glm-provider.js +5 -0
- package/lib/providers/glm.d.ts +2 -0
- package/lib/providers/glm.js +4 -1
- package/lib/providers/grok.d.ts +3 -0
- package/lib/providers/grok.js +9 -1
- package/lib/providers/kimi-provider.js +5 -0
- package/lib/providers/kimi.d.ts +9 -0
- package/lib/providers/kimi.js +8 -1
- package/lib/providers/responses-stream.d.ts +44 -0
- package/lib/providers/responses-stream.js +125 -30
- package/package.json +1 -1
|
@@ -218,6 +218,12 @@ export declare class AntigravityAdapter extends LlmAdapter {
|
|
|
218
218
|
private shippedCatalog;
|
|
219
219
|
resolveModel(_provider: string, model: string): Promise<LlmResolvedModelInfo>;
|
|
220
220
|
stream(options: GenerateOptions): AsyncIterable<StreamChunk>;
|
|
221
|
+
/** Whether an account is remembered as spent until a future reset. */
|
|
222
|
+
private isSpent;
|
|
223
|
+
/** The moment an account's allowance is known to reset, when recorded. */
|
|
224
|
+
private resetAt;
|
|
225
|
+
/** Remember an account as spent (its exact reset is learned separately). */
|
|
226
|
+
private noteSpent;
|
|
221
227
|
/** Translate an auth failure into the message a user can act on. */
|
|
222
228
|
private refreshHint;
|
|
223
229
|
/** Build and send one streaming request for one model. */
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import { EMPTY_RESPONSE_CODE, errorChain, LlmAdapter, LlmError, } from '@deepseek-ai/dsh-llm';
|
|
13
13
|
import { REAUTH_REQUIRED, ProviderError } from '../errors.js';
|
|
14
14
|
import { attributedHeaders } from '../auth/headers.js';
|
|
15
|
+
import { allowanceScheduler } from '../auth/allowance-scheduler.js';
|
|
15
16
|
import { LoopbackFlow } from '../auth/pkce-flow.js';
|
|
16
17
|
import { TokenStore, finiteInstant, nonEmpty } from '../auth/store.js';
|
|
17
18
|
import { TokenManager } from '../auth/tokens.js';
|
|
@@ -415,25 +416,53 @@ export class AntigravityAdapter extends LlmAdapter {
|
|
|
415
416
|
const watchdog = idleWatchdog(options.signal, this.options.streamIdleTimeoutMs);
|
|
416
417
|
try {
|
|
417
418
|
// `auto` owns the fallback chain now, so this route serves exactly the
|
|
418
|
-
// model it was asked for
|
|
419
|
+
// model it was asked for — but it draws on every account the provider holds, so a
|
|
420
|
+
// spent allowance moves to the next account rather than failing the call.
|
|
419
421
|
const model = options.model;
|
|
422
|
+
// Accounts come from the manager when it can list them; a minimal manager (or a
|
|
423
|
+
// test double) without `accounts()` means a single account, which is just the
|
|
424
|
+
// default one.
|
|
425
|
+
const accounts = typeof this.options.tokens.accounts === 'function'
|
|
426
|
+
? await this.options.tokens.accounts()
|
|
427
|
+
: [];
|
|
428
|
+
const tried = new Set();
|
|
420
429
|
let token = await this.options.tokens.resolve();
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
430
|
+
// Move an account known to be spent (from a past failure) to the back, so a fresh
|
|
431
|
+
// request starts on one that has allowance. A lone account still serves.
|
|
432
|
+
const order = [...accounts.filter((a) => a !== token.account && !this.isSpent(a)), token.account,
|
|
433
|
+
...accounts.filter((a) => a !== token.account && this.isSpent(a))];
|
|
434
|
+
for (const accountKey of order) {
|
|
435
|
+
if (tried.has(accountKey))
|
|
436
|
+
continue;
|
|
437
|
+
tried.add(accountKey);
|
|
438
|
+
token = await this.options.tokens.resolve(accountKey);
|
|
439
|
+
let response = await this.request(options, model, token.account, token.accessToken, watchdog.signal);
|
|
427
440
|
if (response.status === 401 || response.status === 403) {
|
|
428
|
-
|
|
441
|
+
// The stored token was accepted locally but refused on the wire; force
|
|
442
|
+
// one refresh, then report the credential as dead if it still fails.
|
|
443
|
+
token = await this.options.tokens.resolve(token.account, true);
|
|
444
|
+
response = await this.request(options, model, token.account, token.accessToken, watchdog.signal);
|
|
445
|
+
if (response.status === 401 || response.status === 403) {
|
|
446
|
+
this.options.onCredentialRejected?.(token.account);
|
|
447
|
+
}
|
|
429
448
|
}
|
|
449
|
+
// A spent allowance (402/429) moves to the next account, remembering when this
|
|
450
|
+
// one refills so later requests skip it until then.
|
|
451
|
+
if (!response.ok && (response.status === 402 || response.status === 429) && order.length > 1) {
|
|
452
|
+
this.noteSpent(token.account);
|
|
453
|
+
const resetAt = await this.resetAt(token.account);
|
|
454
|
+
allowanceScheduler.noteReset(ANTIGRAVITY_PROVIDER_ID, token.account, resetAt);
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
if (!response.ok)
|
|
458
|
+
throw await httpLlmError(response, 'Antigravity API');
|
|
459
|
+
if (response.body === null) {
|
|
460
|
+
throw new LlmError('Antigravity API returned no response body', EMPTY_RESPONSE_CODE);
|
|
461
|
+
}
|
|
462
|
+
yield* streamAntigravity(response.body, () => { watchdog.pulse(); });
|
|
463
|
+
return;
|
|
430
464
|
}
|
|
431
|
-
|
|
432
|
-
throw await httpLlmError(response, 'Antigravity API');
|
|
433
|
-
if (response.body === null) {
|
|
434
|
-
throw new LlmError('Antigravity API returned no response body', EMPTY_RESPONSE_CODE);
|
|
435
|
-
}
|
|
436
|
-
yield* streamAntigravity(response.body, () => { watchdog.pulse(); });
|
|
465
|
+
throw new LlmError('Antigravity API: every account\'s allowance is spent', EMPTY_RESPONSE_CODE);
|
|
437
466
|
}
|
|
438
467
|
catch (error) {
|
|
439
468
|
throw mapFetchFailure('Antigravity API', this.refreshHint(error), watchdog, options.signal);
|
|
@@ -442,6 +471,28 @@ export class AntigravityAdapter extends LlmAdapter {
|
|
|
442
471
|
watchdog.stop();
|
|
443
472
|
}
|
|
444
473
|
}
|
|
474
|
+
/** Whether an account is remembered as spent until a future reset. */
|
|
475
|
+
isSpent(account) {
|
|
476
|
+
return allowanceScheduler.isSpentUntilReset(ANTIGRAVITY_PROVIDER_ID, account);
|
|
477
|
+
}
|
|
478
|
+
/** The moment an account's allowance is known to reset, when recorded. */
|
|
479
|
+
async resetAt(account) {
|
|
480
|
+
const held = await this.options.tokens.session(account);
|
|
481
|
+
if (held === undefined)
|
|
482
|
+
return undefined;
|
|
483
|
+
try {
|
|
484
|
+
const read = await this.usage(held.session.accessToken, held.session.projectId ?? '', held.session.email);
|
|
485
|
+
const spent = read.windows.find((window) => (window.usedPercent ?? 0) >= 100);
|
|
486
|
+
return spent?.resetsAt;
|
|
487
|
+
}
|
|
488
|
+
catch {
|
|
489
|
+
return undefined;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
/** Remember an account as spent (its exact reset is learned separately). */
|
|
493
|
+
noteSpent(account) {
|
|
494
|
+
this.options.onWarn?.(`antigravity: account ${account} allowance spent; moving to another account`);
|
|
495
|
+
}
|
|
445
496
|
/** Translate an auth failure into the message a user can act on. */
|
|
446
497
|
refreshHint(error) {
|
|
447
498
|
if (error instanceof ProviderError && error.code === REAUTH_REQUIRED) {
|
|
@@ -100,6 +100,14 @@ export function createArkProvider(options) {
|
|
|
100
100
|
discovery: options.discovery,
|
|
101
101
|
streamIdleTimeoutMs: options.streamIdleTimeoutMs,
|
|
102
102
|
tokens,
|
|
103
|
+
readUsage: async (account) => {
|
|
104
|
+
const held = await tokens.session(account);
|
|
105
|
+
const sts = held?.session.sts;
|
|
106
|
+
if (sts === undefined)
|
|
107
|
+
return [];
|
|
108
|
+
const read = await readArkUsage({ credentials: sts, fetchFn });
|
|
109
|
+
return read.windows;
|
|
110
|
+
},
|
|
103
111
|
...options.onWarn === undefined ? {} : { onWarn: options.onWarn },
|
|
104
112
|
...options.resolveAttachments === undefined ? {} : { resolveAttachments: options.resolveAttachments },
|
|
105
113
|
hiddenModels: () => options.preferences.hiddenIds(ARK_PROVIDER_ID),
|
package/lib/providers/ark.d.ts
CHANGED
|
@@ -22,6 +22,7 @@ import type { TokenManager } from '../auth/tokens.js';
|
|
|
22
22
|
import type { ArkStsCredentials } from '../auth/ark-control.js';
|
|
23
23
|
import type { FetchFn } from '../http.js';
|
|
24
24
|
import type { AttachmentStore } from '@deepseek-ai/dsh-attachment';
|
|
25
|
+
import type { UsageWindow } from '../protocol.js';
|
|
25
26
|
import type { CatalogEntry } from './catalog.js';
|
|
26
27
|
import type { UsageRead } from './usage.js';
|
|
27
28
|
/** Provider route this module serves. */
|
|
@@ -91,6 +92,8 @@ export interface ArkAdapterOptions {
|
|
|
91
92
|
resolveAttachments?: () => AttachmentStore | undefined;
|
|
92
93
|
onWarn?: (message: string) => void;
|
|
93
94
|
onCredentialRejected?: (account: string) => void;
|
|
95
|
+
/** Read one account's metered windows, so the streamer can steer away from a spent one. */
|
|
96
|
+
readUsage?: ((account: string) => Promise<readonly UsageWindow[]>) | undefined;
|
|
94
97
|
fetchFn?: FetchFn;
|
|
95
98
|
}
|
|
96
99
|
/** The adapter behind the `ark` route. */
|
package/lib/providers/ark.js
CHANGED
|
@@ -21,7 +21,7 @@ import { finiteInstant, nonEmpty } from '../auth/store.js';
|
|
|
21
21
|
import { callArkControl, parseArkFreeUsage, parseArkPlanUsage } from '../auth/ark-control.js';
|
|
22
22
|
import { proxiedFetch } from '../http.js';
|
|
23
23
|
import { findEntry, resolveCatalog } from './catalog.js';
|
|
24
|
-
import { streamProviderResponses } from './responses-stream.js';
|
|
24
|
+
import { streamProviderResponses, accountPoolHooks } from './responses-stream.js';
|
|
25
25
|
import { toResponsesInput, toResponsesTools } from '../translate/responses.js';
|
|
26
26
|
/** Provider route this module serves. */
|
|
27
27
|
export const ARK_PROVIDER_ID = 'ark';
|
|
@@ -167,6 +167,9 @@ export class ArkAdapter extends LlmAdapter {
|
|
|
167
167
|
tokens: this.options.tokens,
|
|
168
168
|
request: (call, model, accessToken, signal) => this.request(call, model, accessToken, signal),
|
|
169
169
|
streamIdleTimeoutMs: this.options.streamIdleTimeoutMs,
|
|
170
|
+
// A 402/429 means this account's allowance is spent: draw on the next account's.
|
|
171
|
+
isAllowanceExhausted: (status) => status === 402 || status === 429,
|
|
172
|
+
...accountPoolHooks({ provider: ARK_PROVIDER_ID, tokens: this.options.tokens, readUsage: this.options.readUsage }),
|
|
170
173
|
...this.options.onCredentialRejected === undefined
|
|
171
174
|
? {}
|
|
172
175
|
: { onCredentialRejected: this.options.onCredentialRejected },
|
|
@@ -110,6 +110,11 @@ export function createGlmProvider(options) {
|
|
|
110
110
|
...options.resolveAttachments === undefined ? {} : { resolveAttachments: options.resolveAttachments },
|
|
111
111
|
hiddenModels: () => options.preferences.hiddenIds(GLM_PROVIDER_ID),
|
|
112
112
|
userModels: () => options.preferences.extraIds(GLM_PROVIDER_ID),
|
|
113
|
+
readUsage: async (account) => {
|
|
114
|
+
const token = await tokens.resolve(account);
|
|
115
|
+
const read = await readGlmUsage({ origin: GLM_ORIGIN, accessToken: token.accessToken, fetchFn });
|
|
116
|
+
return read.windows;
|
|
117
|
+
},
|
|
113
118
|
...options.extraModels === undefined ? {} : { extraModels: options.extraModels },
|
|
114
119
|
fetchFn,
|
|
115
120
|
});
|
package/lib/providers/glm.d.ts
CHANGED
|
@@ -86,6 +86,8 @@ export interface GlmAdapterOptions {
|
|
|
86
86
|
resolveAttachments?: () => AttachmentStore | undefined;
|
|
87
87
|
onWarn?: (message: string) => void;
|
|
88
88
|
onCredentialRejected?: (account: string) => void;
|
|
89
|
+
/** Read one account's metered windows, so the streamer can steer away from a spent one. */
|
|
90
|
+
readUsage?: ((account: string) => Promise<readonly UsageWindow[]>) | undefined;
|
|
89
91
|
/** Fetch implementation; defaults to the proxy-aware fetch. */
|
|
90
92
|
fetchFn?: FetchFn;
|
|
91
93
|
}
|
package/lib/providers/glm.js
CHANGED
|
@@ -13,7 +13,7 @@ import { REAUTH_REQUIRED, ProviderError } from '../errors.js';
|
|
|
13
13
|
import { finiteInstant, nonEmpty } from '../auth/store.js';
|
|
14
14
|
import { proxiedFetch } from '../http.js';
|
|
15
15
|
import { findEntry, resolveCatalog } from './catalog.js';
|
|
16
|
-
import { streamProviderResponses } from './responses-stream.js';
|
|
16
|
+
import { streamProviderResponses, accountPoolHooks } from './responses-stream.js';
|
|
17
17
|
import { toResponsesInput, toResponsesTools } from '../translate/responses.js';
|
|
18
18
|
/** Provider route this module serves. */
|
|
19
19
|
export const GLM_PROVIDER_ID = 'glm';
|
|
@@ -278,6 +278,9 @@ export class GlmAdapter extends LlmAdapter {
|
|
|
278
278
|
tokens: this.options.tokens,
|
|
279
279
|
request: (call, model, accessToken, signal) => this.request(call, model, accessToken, signal),
|
|
280
280
|
streamIdleTimeoutMs: this.options.streamIdleTimeoutMs,
|
|
281
|
+
// A 402/429 means this account's allowance is spent: draw on the next account's.
|
|
282
|
+
isAllowanceExhausted: (status) => status === 402 || status === 429,
|
|
283
|
+
...accountPoolHooks({ provider: GLM_PROVIDER_ID, tokens: this.options.tokens, readUsage: this.options.readUsage }),
|
|
281
284
|
...this.options.onCredentialRejected === undefined
|
|
282
285
|
? {}
|
|
283
286
|
: { onCredentialRejected: this.options.onCredentialRejected },
|
package/lib/providers/grok.d.ts
CHANGED
|
@@ -18,6 +18,7 @@ import type { ProviderRoute } from '../auth/registry.js';
|
|
|
18
18
|
import type { ModelPreferences } from '../settings.js';
|
|
19
19
|
import type { FetchFn } from '../http.js';
|
|
20
20
|
import type { CatalogEntry } from './catalog.js';
|
|
21
|
+
import type { UsageWindow } from '../protocol.js';
|
|
21
22
|
/** Route key this module registers. */
|
|
22
23
|
export declare const GROK_PROVIDER_ID = "grok";
|
|
23
24
|
/** Display name for selectors and diagnostics. */
|
|
@@ -135,6 +136,8 @@ export interface GrokAdapterOptions {
|
|
|
135
136
|
modelOrder?: () => Promise<readonly string[]>;
|
|
136
137
|
fetchFn?: FetchFn;
|
|
137
138
|
onCredentialRejected?: (account: string) => void;
|
|
139
|
+
/** Read one account's metered windows, so the streamer can steer away from a spent one. */
|
|
140
|
+
readUsage?: ((account: string) => Promise<readonly UsageWindow[]>) | undefined;
|
|
138
141
|
}
|
|
139
142
|
/** Grok wire adapter: one instance serves the `grok` provider route. */
|
|
140
143
|
export declare class GrokAdapter extends LlmAdapter {
|
package/lib/providers/grok.js
CHANGED
|
@@ -18,7 +18,7 @@ import { ProviderController } from '../auth/provider-controller.js';
|
|
|
18
18
|
import { proxiedFetch } from '../http.js';
|
|
19
19
|
import { fallbackEntry, findEntry, resolveCatalog } from './catalog.js';
|
|
20
20
|
import { isMissingOrInvalidCredential } from './common.js';
|
|
21
|
-
import { streamProviderResponses } from './responses-stream.js';
|
|
21
|
+
import { streamProviderResponses, accountPoolHooks } from './responses-stream.js';
|
|
22
22
|
import { readGrokUsage } from './usage.js';
|
|
23
23
|
import { toResponsesInput, toResponsesTools } from '../translate/responses.js';
|
|
24
24
|
/** Route key this module registers. */
|
|
@@ -334,6 +334,9 @@ export class GrokAdapter extends LlmAdapter {
|
|
|
334
334
|
tokens: this.options.tokens,
|
|
335
335
|
request: (call, model, accessToken, signal) => this.request(call, model, accessToken, signal),
|
|
336
336
|
streamIdleTimeoutMs: this.options.streamIdleTimeoutMs,
|
|
337
|
+
// A 402/429 means this account's allowance is spent: draw on the next account's.
|
|
338
|
+
isAllowanceExhausted: (status) => status === 402 || status === 429,
|
|
339
|
+
...accountPoolHooks({ provider: GROK_PROVIDER_ID, tokens: this.options.tokens, readUsage: this.options.readUsage }),
|
|
337
340
|
...this.options.onCredentialRejected === undefined
|
|
338
341
|
? {}
|
|
339
342
|
: { onCredentialRejected: this.options.onCredentialRejected },
|
|
@@ -648,6 +651,11 @@ export function createGrokProvider(options) {
|
|
|
648
651
|
hiddenModels: () => options.preferences.hiddenIds(GROK_PROVIDER_ID),
|
|
649
652
|
userModels: () => options.preferences.extraIds(GROK_PROVIDER_ID),
|
|
650
653
|
modelOrder: () => options.preferences.orderOf(GROK_PROVIDER_ID),
|
|
654
|
+
readUsage: async (account) => {
|
|
655
|
+
const token = await tokens.resolve(account);
|
|
656
|
+
const read = await readGrokUsage({ accessToken: token.accessToken, fetchFn: options.discoveryFetchFn ?? proxiedFetch });
|
|
657
|
+
return read === undefined ? [] : read.windows;
|
|
658
|
+
},
|
|
651
659
|
...options.discoveryFetchFn === undefined ? {} : { fetchFn: options.discoveryFetchFn },
|
|
652
660
|
});
|
|
653
661
|
const spec = {
|
|
@@ -90,6 +90,11 @@ export function createKimiProvider(options) {
|
|
|
90
90
|
...options.onWarn === undefined ? {} : { onWarn: options.onWarn },
|
|
91
91
|
...options.resolveAttachments === undefined ? {} : { resolveAttachments: options.resolveAttachments },
|
|
92
92
|
identityHeaders: async () => kimiIdentityHeaders(await loadOrCreateDeviceId(options.dataDir)),
|
|
93
|
+
readUsage: async (account) => {
|
|
94
|
+
const token = await tokens.resolve(account);
|
|
95
|
+
const read = await readKimiUsage({ apiBase: options.apiBase, accessToken: token.accessToken, fetchFn });
|
|
96
|
+
return read === undefined ? [] : read.windows;
|
|
97
|
+
},
|
|
93
98
|
hiddenModels: () => options.preferences.hiddenIds(KIMI_PROVIDER_ID),
|
|
94
99
|
userModels: () => options.preferences.extraIds(KIMI_PROVIDER_ID),
|
|
95
100
|
modelOrder: () => options.preferences.orderOf(KIMI_PROVIDER_ID),
|
package/lib/providers/kimi.d.ts
CHANGED
|
@@ -10,6 +10,7 @@ import type { AttachmentStore } from '@deepseek-ai/dsh-attachment';
|
|
|
10
10
|
import type { FetchFn } from '../http.js';
|
|
11
11
|
import type { TokenManager } from '../auth/tokens.js';
|
|
12
12
|
import type { SessionBase } from '../auth/store.js';
|
|
13
|
+
import type { UsageWindow } from '../protocol.js';
|
|
13
14
|
import type { CatalogEntry } from './catalog.js';
|
|
14
15
|
/** One catalog entry exposed as a selectable model. */
|
|
15
16
|
export type KimiModelEntry = CatalogEntry;
|
|
@@ -62,6 +63,14 @@ export interface KimiAdapterOptions {
|
|
|
62
63
|
fetchFn?: FetchFn;
|
|
63
64
|
/** Called when a call proved the stored credential unusable. */
|
|
64
65
|
onCredentialRejected?: (account: string) => void;
|
|
66
|
+
/**
|
|
67
|
+
* Read one account's metered windows, so the streamer can steer away from an account
|
|
68
|
+
* whose allowance is spent before it fails there.
|
|
69
|
+
*
|
|
70
|
+
* Resolved per request; absence means the streamer only learns an account is spent by
|
|
71
|
+
* failing against it once.
|
|
72
|
+
*/
|
|
73
|
+
readUsage?: (account: string) => Promise<readonly UsageWindow[]>;
|
|
65
74
|
/** Extra model ids from the plugin configuration, appended to the catalog. */
|
|
66
75
|
extraModels?: readonly string[];
|
|
67
76
|
/**
|
package/lib/providers/kimi.js
CHANGED
|
@@ -11,7 +11,7 @@ import { kimiRequestHeaders } from '../auth/headers.js';
|
|
|
11
11
|
import { REAUTH_REQUIRED, ProviderError } from '../errors.js';
|
|
12
12
|
import { finiteInstant, nonEmpty } from '../auth/store.js';
|
|
13
13
|
import { idleWatchdog, httpLlmError, isMissingOrInvalidCredential, mapFetchFailure } from './common.js';
|
|
14
|
-
import { streamProviderResponses } from './responses-stream.js';
|
|
14
|
+
import { streamProviderResponses, accountPoolHooks } from './responses-stream.js';
|
|
15
15
|
import { fallbackEntry, findEntry, resolveCatalog } from './catalog.js';
|
|
16
16
|
import { streamResponses, toResponsesInput, toResponsesTools } from '../translate/responses.js';
|
|
17
17
|
import { prepareMoonshotTool } from '../translate/moonshot-schema.js';
|
|
@@ -228,6 +228,13 @@ export class KimiAdapter extends LlmAdapter {
|
|
|
228
228
|
tokens: this.options.tokens,
|
|
229
229
|
request: (call, model, accessToken, signal) => this.request(call, model, accessToken, signal),
|
|
230
230
|
streamIdleTimeoutMs: this.options.streamIdleTimeoutMs,
|
|
231
|
+
// A 402/429 means this account's allowance is spent: draw on the next account's
|
|
232
|
+
// before giving up, so one model id uses the whole pool's quota.
|
|
233
|
+
isAllowanceExhausted: (status) => status === 402 || status === 429,
|
|
234
|
+
...accountPoolHooks({ provider: KIMI_PROVIDER_ID, tokens: this.options.tokens, readUsage: this.options.readUsage }),
|
|
235
|
+
onAccountSwitch: (from, to, reason) => {
|
|
236
|
+
this.options.onWarn?.(`kimi: account ${from} allowance spent (${reason}); switching to ${to}`);
|
|
237
|
+
},
|
|
231
238
|
...this.options.onCredentialRejected === undefined
|
|
232
239
|
? {}
|
|
233
240
|
: { onCredentialRejected: this.options.onCredentialRejected },
|
|
@@ -14,6 +14,24 @@
|
|
|
14
14
|
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm';
|
|
15
15
|
import type { SessionBase } from '../auth/store.js';
|
|
16
16
|
import type { TokenManager } from '../auth/tokens.js';
|
|
17
|
+
import type { UsageWindow } from '../protocol.js';
|
|
18
|
+
/**
|
|
19
|
+
* Build the cross-account hooks one route needs to draw on its whole account pool.
|
|
20
|
+
*
|
|
21
|
+
* Every Responses-wire route (Kimi, Grok, GLM, Ark) meters per account and can run out;
|
|
22
|
+
* this wires the three account hooks identically, so a route only supplies how to read
|
|
23
|
+
* one account's allowance and its provider id. A route with no `readUsage` still rotates
|
|
24
|
+
* accounts on failure, it just cannot predict exhaustion before the first failure.
|
|
25
|
+
*
|
|
26
|
+
* @param provider - the provider route id, the scheduler's key.
|
|
27
|
+
* @param tokens - the route's token manager, enumerates accounts.
|
|
28
|
+
* @param readUsage - read one account's metered windows, when wired.
|
|
29
|
+
*/
|
|
30
|
+
export declare function accountPoolHooks<S extends SessionBase>(input: {
|
|
31
|
+
provider: string;
|
|
32
|
+
tokens: TokenManager<S>;
|
|
33
|
+
readUsage?: ((account: string) => Promise<readonly UsageWindow[]>) | undefined;
|
|
34
|
+
}): Pick<ResponsesStreamInput<S>, 'accounts' | 'isAccountSpent' | 'noteAccountSpent' | 'resetsAt'>;
|
|
17
35
|
/** Constructor inputs for {@link streamProviderResponses}. */
|
|
18
36
|
export interface ResponsesStreamInput<S extends SessionBase> {
|
|
19
37
|
/** Endpoint name used in failure messages, e.g. `Kimi API`. */
|
|
@@ -37,6 +55,32 @@ export interface ResponsesStreamInput<S extends SessionBase> {
|
|
|
37
55
|
describeFailure?(error: unknown): unknown;
|
|
38
56
|
/** Called when a model in the chain was skipped, with the reason. */
|
|
39
57
|
onFallback?: (from: string, to: string, reason: string) => void;
|
|
58
|
+
/**
|
|
59
|
+
* Whether a 402/429 on this provider means "this account's allowance is spent",
|
|
60
|
+
* so the call should move to the next account rather than fail.
|
|
61
|
+
*
|
|
62
|
+
* When omitted, an exhausted allowance fails the call (the old behaviour). When
|
|
63
|
+
* set, the caller enumerates the provider's other accounts and retries each until
|
|
64
|
+
* one serves the request or every account is spent.
|
|
65
|
+
*/
|
|
66
|
+
isAllowanceExhausted?: (status: number) => boolean;
|
|
67
|
+
/** Enumerate the provider's accounts, most preferred first; enables cross-account retry. */
|
|
68
|
+
accounts?: () => Promise<readonly string[]>;
|
|
69
|
+
/** Called when a call moved to another account because the first was spent. */
|
|
70
|
+
onAccountSwitch?: (from: string, to: string, reason: string) => void;
|
|
71
|
+
/**
|
|
72
|
+
* Whether one account's allowance is best not used for a fresh request right now.
|
|
73
|
+
*
|
|
74
|
+
* The caller supplies this to steer a request away from an account that is nearly
|
|
75
|
+
* spent (or already spent until a remembered reset), so the request starts on a fuller
|
|
76
|
+
* account instead of failing once to find out. It may read the account's live figures;
|
|
77
|
+
* an account flagged here is tried last, not skipped, so a lone account still serves.
|
|
78
|
+
*/
|
|
79
|
+
isAccountSpent?: (account: string) => Promise<boolean>;
|
|
80
|
+
/** Remember that an account's allowance is spent until a given reset moment. */
|
|
81
|
+
noteAccountSpent?: (account: string, resetsAt: number | undefined) => void;
|
|
82
|
+
/** The moment one account's allowance is known to reset, when it was recorded. */
|
|
83
|
+
resetsAt?: (account: string) => Promise<number | undefined>;
|
|
40
84
|
}
|
|
41
85
|
/**
|
|
42
86
|
* Run one model call over the Responses wire, walking a model chain.
|
|
@@ -12,8 +12,49 @@
|
|
|
12
12
|
* @module dsh-plugin-subscription-oauth/providers/responses-stream
|
|
13
13
|
*/
|
|
14
14
|
import { EMPTY_RESPONSE_CODE, LlmError } from '@deepseek-ai/dsh-llm';
|
|
15
|
+
import { allowanceScheduler } from '../auth/allowance-scheduler.js';
|
|
15
16
|
import { streamResponses } from '../translate/responses.js';
|
|
16
17
|
import { httpLlmError, idleWatchdog, mapFetchFailure } from './common.js';
|
|
18
|
+
/**
|
|
19
|
+
* Build the cross-account hooks one route needs to draw on its whole account pool.
|
|
20
|
+
*
|
|
21
|
+
* Every Responses-wire route (Kimi, Grok, GLM, Ark) meters per account and can run out;
|
|
22
|
+
* this wires the three account hooks identically, so a route only supplies how to read
|
|
23
|
+
* one account's allowance and its provider id. A route with no `readUsage` still rotates
|
|
24
|
+
* accounts on failure, it just cannot predict exhaustion before the first failure.
|
|
25
|
+
*
|
|
26
|
+
* @param provider - the provider route id, the scheduler's key.
|
|
27
|
+
* @param tokens - the route's token manager, enumerates accounts.
|
|
28
|
+
* @param readUsage - read one account's metered windows, when wired.
|
|
29
|
+
*/
|
|
30
|
+
export function accountPoolHooks(input) {
|
|
31
|
+
const { provider, tokens, readUsage } = input;
|
|
32
|
+
return {
|
|
33
|
+
accounts: () => tokens.accounts(),
|
|
34
|
+
// Steer away from an account that has no allowance: read its live figures when a
|
|
35
|
+
// reader is wired (so a 100 % window is avoided before it fails there), else fall
|
|
36
|
+
// back to the remembered reset time from a past failure.
|
|
37
|
+
isAccountSpent: async (account) => {
|
|
38
|
+
if (readUsage !== undefined) {
|
|
39
|
+
try {
|
|
40
|
+
const windows = await readUsage(account);
|
|
41
|
+
const spentWindow = windows.find((window) => (window.usedPercent ?? 0) >= 100);
|
|
42
|
+
if (spentWindow !== undefined) {
|
|
43
|
+
allowanceScheduler.noteReset(provider, account, spentWindow.resetsAt);
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// A failed read is not a reason to avoid the account; fall through to memory.
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return allowanceScheduler.isSpentUntilReset(provider, account);
|
|
53
|
+
},
|
|
54
|
+
noteAccountSpent: (account, resetsAt) => { allowanceScheduler.noteReset(provider, account, resetsAt); },
|
|
55
|
+
resetsAt: async (account) => allowanceScheduler.resetAt(provider, account),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
17
58
|
/**
|
|
18
59
|
* Whether a refusal says "this model cannot serve you now", as opposed to "this
|
|
19
60
|
* request is wrong".
|
|
@@ -60,20 +101,92 @@ export async function* streamProviderResponses(options, input) {
|
|
|
60
101
|
if (models.length === 0) {
|
|
61
102
|
throw new LlmError(`${label} has no enabled model to dispatch to`, EMPTY_RESPONSE_CODE);
|
|
62
103
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
104
|
+
// When the provider meters per account and an account's allowance can run out,
|
|
105
|
+
// every account is a candidate: a spent one falls through to the next, so one
|
|
106
|
+
// model id draws on the whole pool's allowance. Accounts are tried in order;
|
|
107
|
+
// the first that serves the request wins.
|
|
108
|
+
let accounts = input.accounts === undefined
|
|
109
|
+
? undefined
|
|
110
|
+
: [...await input.accounts()];
|
|
111
|
+
// Send work straight to an account that has allowance: one already known to be
|
|
112
|
+
// spent until a future reset is moved to the back, so a fresh request does not
|
|
113
|
+
// fail against it once to find out. This is a preference, not a skip — a lone
|
|
114
|
+
// account still serves, and the spent-retry below catches a stale record.
|
|
115
|
+
if (accounts !== undefined && accounts.length > 1 && input.isAccountSpent !== undefined) {
|
|
116
|
+
const fresh = [];
|
|
117
|
+
const tired = [];
|
|
118
|
+
for (const account of accounts) {
|
|
119
|
+
if (await input.isAccountSpent(account))
|
|
120
|
+
tired.push(account);
|
|
121
|
+
else
|
|
122
|
+
fresh.push(account);
|
|
123
|
+
}
|
|
124
|
+
if (tired.length > 0 && fresh.length > 0) {
|
|
125
|
+
accounts = [...fresh, ...tired];
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const tryAccounts = accounts === undefined || accounts.length === 0
|
|
129
|
+
? [undefined]
|
|
130
|
+
: accounts;
|
|
131
|
+
for (const accountKey of tryAccounts) {
|
|
132
|
+
let token = await tokens.resolve(accountKey);
|
|
133
|
+
// Track which accounts a spent-allowance already moved past, so a later model
|
|
134
|
+
// in the chain does not re-try an account known to be exhausted.
|
|
135
|
+
const spent = new Set();
|
|
136
|
+
for (const [index, model] of models.entries()) {
|
|
137
|
+
try {
|
|
138
|
+
if (spent.has(token.account))
|
|
139
|
+
continue;
|
|
140
|
+
let response = await request(options, model, token.accessToken, watchdog.signal);
|
|
70
141
|
if (response.status === 401 || response.status === 403) {
|
|
71
|
-
|
|
142
|
+
token = await tokens.resolve(token.account, true);
|
|
143
|
+
response = await request(options, model, token.accessToken, watchdog.signal);
|
|
144
|
+
if (response.status === 401 || response.status === 403) {
|
|
145
|
+
input.onCredentialRejected?.(token.account);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
// A spent allowance moves to the next account (when the provider supports
|
|
149
|
+
// it), not to the next model: the model is fine, the account is not.
|
|
150
|
+
if (!response.ok
|
|
151
|
+
&& input.isAllowanceExhausted !== undefined
|
|
152
|
+
&& input.accounts !== undefined
|
|
153
|
+
&& input.isAllowanceExhausted(response.status)
|
|
154
|
+
&& tryAccounts.length > 1) {
|
|
155
|
+
spent.add(token.account);
|
|
156
|
+
// Remember when this account's allowance refills, so later requests go
|
|
157
|
+
// straight to another account until then instead of failing here again.
|
|
158
|
+
input.noteAccountSpent?.(token.account, await input.resetsAt?.(token.account));
|
|
159
|
+
const next = tryAccounts.find((candidate) => candidate !== undefined && !spent.has(candidate));
|
|
160
|
+
if (next !== undefined) {
|
|
161
|
+
lastError = await httpLlmError(response, label);
|
|
162
|
+
input.onAccountSwitch?.(token.account, next, reasonOf(lastError));
|
|
163
|
+
token = await tokens.resolve(next);
|
|
164
|
+
// Re-try this same model on the fresh account.
|
|
165
|
+
response = await request(options, model, token.accessToken, watchdog.signal);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
if (!response.ok) {
|
|
169
|
+
const error = await httpLlmError(response, label);
|
|
170
|
+
if (models.length > 1 && isModelFallbackWorthy(response.status) && index < models.length - 1) {
|
|
171
|
+
const next = models[index + 1];
|
|
172
|
+
if (next !== undefined) {
|
|
173
|
+
lastError = error;
|
|
174
|
+
input.onFallback?.(model, next, reasonOf(error));
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
throw error;
|
|
72
179
|
}
|
|
180
|
+
if (response.body === null) {
|
|
181
|
+
throw new LlmError(`${label} returned no response body`, EMPTY_RESPONSE_CODE);
|
|
182
|
+
}
|
|
183
|
+
yield* streamResponses(response.body, () => { watchdog.pulse(); });
|
|
184
|
+
return;
|
|
73
185
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
186
|
+
catch (error) {
|
|
187
|
+
// A transport failure on a chain is also worth another model.
|
|
188
|
+
const transport = error instanceof TypeError || error.name === 'AbortError';
|
|
189
|
+
if (transport && options.signal?.aborted !== true && index < models.length - 1) {
|
|
77
190
|
const next = models[index + 1];
|
|
78
191
|
if (next !== undefined) {
|
|
79
192
|
lastError = error;
|
|
@@ -81,26 +194,8 @@ export async function* streamProviderResponses(options, input) {
|
|
|
81
194
|
continue;
|
|
82
195
|
}
|
|
83
196
|
}
|
|
84
|
-
throw error;
|
|
85
|
-
}
|
|
86
|
-
if (response.body === null) {
|
|
87
|
-
throw new LlmError(`${label} returned no response body`, EMPTY_RESPONSE_CODE);
|
|
88
|
-
}
|
|
89
|
-
yield* streamResponses(response.body, () => { watchdog.pulse(); });
|
|
90
|
-
return;
|
|
91
|
-
}
|
|
92
|
-
catch (error) {
|
|
93
|
-
// A transport failure on a chain is also worth another model.
|
|
94
|
-
const transport = error instanceof TypeError || error.name === 'AbortError';
|
|
95
|
-
if (transport && options.signal?.aborted !== true && index < models.length - 1) {
|
|
96
|
-
const next = models[index + 1];
|
|
97
|
-
if (next !== undefined) {
|
|
98
|
-
lastError = error;
|
|
99
|
-
input.onFallback?.(model, next, reasonOf(error));
|
|
100
|
-
continue;
|
|
101
|
-
}
|
|
197
|
+
throw input.describeFailure === undefined ? error : input.describeFailure(error);
|
|
102
198
|
}
|
|
103
|
-
throw input.describeFailure === undefined ? error : input.describeFailure(error);
|
|
104
199
|
}
|
|
105
200
|
}
|
|
106
201
|
throw lastError ?? new LlmError(`${label} served none of the requested models`, EMPTY_RESPONSE_CODE);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-subscription-oauth",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.1",
|
|
4
4
|
"description": "Use subscription accounts as DeepSeek Harness LLM providers — Kimi Code (OAuth device flow), Grok / X Premium (OIDC + PKCE), Google Antigravity (Google OAuth + Cloud Code Assist) and GLM / BigModel (ZCode's browser sign-in) — with per-model visibility, drag-to-reorder Auto fallback and plan quotas from the web Settings page",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "EricFL1998",
|