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.
@@ -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
- let response = await this.request(options, model, token.account, token.accessToken, watchdog.signal);
422
- if (response.status === 401 || response.status === 403) {
423
- // The stored token was accepted locally but refused on the wire; force
424
- // one refresh, then report the credential as dead if it still fails.
425
- token = await this.options.tokens.resolve(token.account, true);
426
- response = await this.request(options, model, token.account, token.accessToken, watchdog.signal);
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
- this.options.onCredentialRejected?.(token.account);
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
- if (!response.ok)
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),
@@ -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. */
@@ -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
  });
@@ -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
  }
@@ -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 },
@@ -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 {
@@ -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),
@@ -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
  /**
@@ -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
- let token = await tokens.resolve();
64
- for (const [index, model] of models.entries()) {
65
- try {
66
- let response = await request(options, model, token.accessToken, watchdog.signal);
67
- if (response.status === 401 || response.status === 403) {
68
- token = await tokens.resolve(token.account, true);
69
- response = await request(options, model, token.accessToken, watchdog.signal);
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
- input.onCredentialRejected?.(token.account);
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
- if (!response.ok) {
75
- const error = await httpLlmError(response, label);
76
- if (models.length > 1 && isModelFallbackWorthy(response.status) && index < models.length - 1) {
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.7.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",