dsh-plugin-subscription-oauth 0.8.1 → 0.8.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.
@@ -17,9 +17,13 @@
17
17
  export interface AllowanceState {
18
18
  /** Epoch milliseconds at which the account's allowance resets, when known. */
19
19
  resetsAt?: number;
20
+ /** Epoch milliseconds until which the account is cooling after a refused request. */
21
+ cooldownUntil?: number;
20
22
  /** Epoch milliseconds at which this state was last learned. */
21
23
  learnedAt: number;
22
24
  }
25
+ /** Default cooldown after a refused request, so the provider's gate is not re-hit at once. */
26
+ export declare const DEFAULT_REFUSAL_COOLDOWN_MS: number;
23
27
  /** In-memory, per-provider memory of account reset times. */
24
28
  export declare class AllowanceScheduler {
25
29
  private readonly byProvider;
@@ -33,6 +37,38 @@ export declare class AllowanceScheduler {
33
37
  * @param resetsAt - epoch milliseconds of the allowance reset, when known.
34
38
  */
35
39
  noteReset(provider: string, account: string, resetsAt?: number): void;
40
+ /**
41
+ * Cool one account after its request was refused, so the provider's gate is not
42
+ * re-hit at once.
43
+ *
44
+ * A 403 "verify your account" is Google's safety gate, not a dead credential; hitting
45
+ * it in a tight loop across every account is what turns a transient check into a
46
+ * sustained block. Cooling the refused account for a short beat (and trying another)
47
+ * is what keeps the pool reachable — the same discipline a careful client keeps.
48
+ *
49
+ * @param provider - the provider route id.
50
+ * @param account - the account key.
51
+ * @param until - epoch milliseconds to cool until; defaults to a short beat.
52
+ */
53
+ coolDown(provider: string, account: string, until?: number): void;
54
+ /**
55
+ * Whether one account is cooling after a refused request and should be left alone.
56
+ *
57
+ * @param provider - the provider route id.
58
+ * @param account - the account key.
59
+ * @returns whether the account is mid-cooldown.
60
+ */
61
+ isCooling(provider: string, account: string): boolean;
62
+ /**
63
+ * Forget one account's remembered state, when the account is removed.
64
+ *
65
+ * A signed-out account's allowance state is no longer meaningful — there is no
66
+ * account to schedule around — and a re-added account starts fresh.
67
+ *
68
+ * @param provider - the provider route id.
69
+ * @param account - the account key to forget.
70
+ */
71
+ clear(provider: string, account: string): void;
36
72
  /**
37
73
  * Whether one account's allowance is known to be spent now.
38
74
  *
@@ -13,6 +13,8 @@
13
13
  *
14
14
  * @module dsh-plugin-subscription-oauth/auth/allowance-scheduler
15
15
  */
16
+ /** Default cooldown after a refused request, so the provider's gate is not re-hit at once. */
17
+ export const DEFAULT_REFUSAL_COOLDOWN_MS = 2 * 60 * 1000;
16
18
  /** In-memory, per-provider memory of account reset times. */
17
19
  export class AllowanceScheduler {
18
20
  byProvider = new Map();
@@ -33,7 +35,56 @@ export class AllowanceScheduler {
33
35
  * @param resetsAt - epoch milliseconds of the allowance reset, when known.
34
36
  */
35
37
  noteReset(provider, account, resetsAt) {
36
- this.provider(provider).set(account, { ...resetsAt === undefined ? {} : { resetsAt }, learnedAt: Date.now() });
38
+ const existing = this.byProvider.get(provider)?.get(account);
39
+ this.provider(provider).set(account, {
40
+ ...existing?.cooldownUntil === undefined ? {} : { cooldownUntil: existing.cooldownUntil },
41
+ ...resetsAt === undefined ? {} : { resetsAt },
42
+ learnedAt: Date.now(),
43
+ });
44
+ }
45
+ /**
46
+ * Cool one account after its request was refused, so the provider's gate is not
47
+ * re-hit at once.
48
+ *
49
+ * A 403 "verify your account" is Google's safety gate, not a dead credential; hitting
50
+ * it in a tight loop across every account is what turns a transient check into a
51
+ * sustained block. Cooling the refused account for a short beat (and trying another)
52
+ * is what keeps the pool reachable — the same discipline a careful client keeps.
53
+ *
54
+ * @param provider - the provider route id.
55
+ * @param account - the account key.
56
+ * @param until - epoch milliseconds to cool until; defaults to a short beat.
57
+ */
58
+ coolDown(provider, account, until = Date.now() + DEFAULT_REFUSAL_COOLDOWN_MS) {
59
+ const existing = this.byProvider.get(provider)?.get(account);
60
+ this.provider(provider).set(account, {
61
+ ...existing?.resetsAt === undefined ? {} : { resetsAt: existing.resetsAt },
62
+ cooldownUntil: until,
63
+ learnedAt: Date.now(),
64
+ });
65
+ }
66
+ /**
67
+ * Whether one account is cooling after a refused request and should be left alone.
68
+ *
69
+ * @param provider - the provider route id.
70
+ * @param account - the account key.
71
+ * @returns whether the account is mid-cooldown.
72
+ */
73
+ isCooling(provider, account) {
74
+ const state = this.byProvider.get(provider)?.get(account);
75
+ return state?.cooldownUntil !== undefined && Date.now() < state.cooldownUntil;
76
+ }
77
+ /**
78
+ * Forget one account's remembered state, when the account is removed.
79
+ *
80
+ * A signed-out account's allowance state is no longer meaningful — there is no
81
+ * account to schedule around — and a re-added account starts fresh.
82
+ *
83
+ * @param provider - the provider route id.
84
+ * @param account - the account key to forget.
85
+ */
86
+ clear(provider, account) {
87
+ this.byProvider.get(provider)?.delete(account);
37
88
  }
38
89
  /**
39
90
  * Whether one account's allowance is known to be spent now.
@@ -50,6 +101,8 @@ export class AllowanceScheduler {
50
101
  const state = this.byProvider.get(provider)?.get(account);
51
102
  if (state === undefined)
52
103
  return false;
104
+ if (state.cooldownUntil !== undefined && Date.now() < state.cooldownUntil)
105
+ return true;
53
106
  if (state.resetsAt === undefined)
54
107
  return false;
55
108
  return Date.now() < state.resetsAt;
@@ -16,6 +16,7 @@
16
16
  import { ACCOUNT_NOT_FOUND, BAD_REQUEST, MODEL_NOT_FOUND, REAUTH_REQUIRED, ProviderError } from '../errors.js';
17
17
  import { entryMatches, resolveCatalog } from '../providers/catalog.js';
18
18
  import { accountLabelOf, accountKeyOf } from './store.js';
19
+ import { allowanceScheduler } from './allowance-scheduler.js';
19
20
  /** Human-readable message for any thrown value. */
20
21
  function messageOf(error) {
21
22
  return error instanceof Error ? error.message : String(error);
@@ -239,8 +240,14 @@ export class ProviderController {
239
240
  async logout(account) {
240
241
  this.refused.delete(account ?? '');
241
242
  const key = account ?? await this.options.store.defaultAccount();
242
- if (key !== undefined)
243
+ if (key !== undefined) {
243
244
  await this.options.store.remove(key);
245
+ // Drop every trace of the signed-out account: the remembered allowance state (so
246
+ // the scheduler stops treating it as live) and the cached usage figures (so the
247
+ // card and chip stop showing its quota).
248
+ allowanceScheduler.clear(this.options.spec.id, key);
249
+ this.usageByAccount.delete(key);
250
+ }
244
251
  return this.status();
245
252
  }
246
253
  /** Promote one account to default. */
@@ -425,6 +425,9 @@ export class AntigravityAdapter extends LlmAdapter {
425
425
  const accounts = typeof this.options.tokens.accounts === 'function'
426
426
  ? await this.options.tokens.accounts()
427
427
  : [];
428
+ // Surface the account pool and the 429 outcome to the caller, so a non-switch is
429
+ // diagnosable from the browser where the node stdout is hidden.
430
+ const diag = [`pool=${accounts.join(',') || '(none)'}`];
428
431
  const tried = new Set();
429
432
  let token = await this.options.tokens.resolve();
430
433
  // Move an account known to be spent (from a past failure) to the back, so a fresh
@@ -436,33 +439,71 @@ export class AntigravityAdapter extends LlmAdapter {
436
439
  continue;
437
440
  tried.add(accountKey);
438
441
  token = await this.options.tokens.resolve(accountKey);
439
- let response = await this.request(options, model, token.account, token.accessToken, watchdog.signal);
442
+ // A transport failure (DNS, connect timeout) on one account may be that account's
443
+ // path, not the provider's; try the next account before giving up, the same way a
444
+ // spent allowance does. A lone account still surfaces the real error.
445
+ let response;
446
+ try {
447
+ response = await this.request(options, model, token.account, token.accessToken, watchdog.signal);
448
+ }
449
+ catch (error) {
450
+ const transport = error instanceof TypeError || error.name === 'AbortError';
451
+ if (transport && order.length > 1 && !watchdog.timedOut) {
452
+ diag.push(`transport on ${token.account}, moving to next`);
453
+ continue;
454
+ }
455
+ throw error;
456
+ }
440
457
  if (response.status === 401 || response.status === 403) {
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.
458
+ // The stored token was accepted locally but refused on the wire; force one
459
+ // refresh. A 403 that survives the refresh is Google's "verify your account"
460
+ // gate, not a dead credential — the account is fine, it just cannot serve now —
461
+ // so the call moves to the next account rather than failing. Only a 401 marks
462
+ // the credential as dead.
443
463
  token = await this.options.tokens.resolve(token.account, true);
444
464
  response = await this.request(options, model, token.account, token.accessToken, watchdog.signal);
445
- if (response.status === 401 || response.status === 403) {
465
+ if (response.status === 401) {
446
466
  this.options.onCredentialRejected?.(token.account);
447
467
  }
448
468
  }
449
- // A spent allowance (402/429) moves to the next account, remembering when this
469
+ // A spent allowance (402/429) moves to the next account, remembering when a spent
450
470
  // one refills so later requests skip it until then.
451
471
  if (!response.ok && (response.status === 402 || response.status === 429) && order.length > 1) {
472
+ diag.push(`${response.status} on ${token.account}, moving to next`);
452
473
  this.noteSpent(token.account);
453
474
  const resetAt = await this.resetAt(token.account);
454
475
  allowanceScheduler.noteReset(ANTIGRAVITY_PROVIDER_ID, token.account, resetAt);
455
476
  continue;
456
477
  }
457
- if (!response.ok)
458
- throw await httpLlmError(response, 'Antigravity API');
478
+ // A 403 "verify your account" is Google's safety gate, not a dead credential.
479
+ // Cool the account (so the gate is not re-hit at once) and move to the next —
480
+ // but only if one has not been cooled already this pass, else the loop would
481
+ // sweep every account and trip the gate on all of them at once.
482
+ if (!response.ok && response.status === 403 && order.length > 1) {
483
+ const alreadyCooled = [...tried].some((account) => allowanceScheduler.isCooling(ANTIGRAVITY_PROVIDER_ID, account));
484
+ if (!alreadyCooled) {
485
+ diag.push(`403 on ${token.account}, cooling and moving to next`);
486
+ allowanceScheduler.coolDown(ANTIGRAVITY_PROVIDER_ID, token.account);
487
+ continue;
488
+ }
489
+ diag.push(`403 on ${token.account}, another account already cooling; stopping the sweep`);
490
+ }
491
+ if (!response.ok) {
492
+ diag.push(`fail ${response.status} on ${token.account} (no switch)`);
493
+ const error = await httpLlmError(response, 'Antigravity API');
494
+ // The diagnostic rides the message so it surfaces where the user sees the
495
+ // failure, since the node half's stdout is unreachable in the desktop shell.
496
+ error.message = `${error.message} [${diag.join(' | ')}]`;
497
+ throw error;
498
+ }
459
499
  if (response.body === null) {
460
500
  throw new LlmError('Antigravity API returned no response body', EMPTY_RESPONSE_CODE);
461
501
  }
462
502
  yield* streamAntigravity(response.body, () => { watchdog.pulse(); });
463
503
  return;
464
504
  }
465
- throw new LlmError('Antigravity API: every account\'s allowance is spent', EMPTY_RESPONSE_CODE);
505
+ const exhausted = new LlmError(`Antigravity API: every account's allowance is spent [${diag.join(' | ')}]`, EMPTY_RESPONSE_CODE);
506
+ throw exhausted;
466
507
  }
467
508
  catch (error) {
468
509
  throw mapFetchFailure('Antigravity API', this.refreshHint(error), watchdog, options.signal);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-subscription-oauth",
3
- "version": "0.8.1",
3
+ "version": "0.8.3",
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",