codex-multi-auth 2.23.0 → 2.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/.codex-plugin/plugin.json +9 -9
  2. package/LICENSE +21 -21
  3. package/README.md +159 -152
  4. package/assets/codex-multi-auth-icon.svg +39 -39
  5. package/assets/readme-hero.svg +31 -31
  6. package/config/README.md +31 -31
  7. package/config/codex-legacy.json +1021 -1021
  8. package/config/codex-modern.json +347 -347
  9. package/config/minimal-codex.json +15 -15
  10. package/dist/index.js +33 -10
  11. package/dist/lib/account-policy.d.ts +2 -0
  12. package/dist/lib/account-policy.js +3 -1
  13. package/dist/lib/accounts/rate-limits.d.ts +6 -0
  14. package/dist/lib/accounts/rate-limits.js +14 -1
  15. package/dist/lib/accounts.d.ts +6 -1
  16. package/dist/lib/accounts.js +33 -8
  17. package/dist/lib/auth/token-utils.d.ts +5 -0
  18. package/dist/lib/auth/token-utils.js +10 -0
  19. package/dist/lib/codex-manager/commands/account.js +15 -0
  20. package/dist/lib/codex-manager/commands/resets.js +18 -5
  21. package/dist/lib/codex-manager/commands/workspace.d.ts +4 -2
  22. package/dist/lib/codex-manager/commands/workspace.js +33 -2
  23. package/dist/lib/codex-manager/help.js +2 -2
  24. package/dist/lib/codex-manager.js +14 -1
  25. package/dist/lib/constants.d.ts +2 -0
  26. package/dist/lib/constants.js +2 -0
  27. package/dist/lib/context-budget-response.js +8 -8
  28. package/dist/lib/context-overflow.js +10 -10
  29. package/dist/lib/oauth-success.html +125 -125
  30. package/dist/lib/policy/runtime-policy-cache.js +1 -1
  31. package/dist/lib/policy/runtime-policy.d.ts +1 -0
  32. package/dist/lib/policy/runtime-policy.js +4 -0
  33. package/dist/lib/preemptive-quota-scheduler.js +12 -3
  34. package/dist/lib/prompts/codex-host-bridge.js +138 -138
  35. package/dist/lib/prompts/codex.js +79 -79
  36. package/dist/lib/quota-cache.d.ts +4 -0
  37. package/dist/lib/quota-readiness.d.ts +1 -1
  38. package/dist/lib/quota-readiness.js +9 -2
  39. package/dist/lib/request/rate-limit-decision.js +1 -1
  40. package/dist/lib/runtime/account-reset-credits.d.ts +11 -2
  41. package/dist/lib/runtime/account-reset-credits.js +35 -12
  42. package/dist/lib/runtime/automatic-account-checks.js +6 -5
  43. package/dist/lib/runtime/automatic-subscription-checks.js +4 -3
  44. package/dist/lib/runtime/model-discovery-status.js +2 -2
  45. package/dist/lib/runtime/reset-credit-routing.d.ts +11 -3
  46. package/dist/lib/runtime/reset-credit-routing.js +66 -21
  47. package/dist/lib/runtime/reset-credits.d.ts +31 -7
  48. package/dist/lib/runtime/reset-credits.js +135 -28
  49. package/dist/lib/runtime/rotation-proxy-state.d.ts +3 -0
  50. package/dist/lib/runtime/subscription-quota-order.d.ts +1 -0
  51. package/dist/lib/runtime/subscription-quota-order.js +7 -3
  52. package/dist/lib/runtime/workspace-model-scopes.d.ts +2 -0
  53. package/dist/lib/runtime/workspace-model-scopes.js +14 -4
  54. package/dist/lib/runtime-rotation-proxy.js +108 -29
  55. package/dist/lib/storage/snapshot-merge.js +15 -4
  56. package/dist/lib/web-dashboard-html.js +53 -53
  57. package/node_modules/@codex-ai/plugin/dist/index.d.ts +2 -2
  58. package/node_modules/@codex-ai/plugin/dist/index.js +2 -2
  59. package/node_modules/@codex-ai/plugin/dist/tool.d.ts +42 -42
  60. package/node_modules/@codex-ai/plugin/dist/tool.js +29 -29
  61. package/node_modules/@codex-ai/plugin/package.json +9 -9
  62. package/node_modules/@codex-ai/sdk/dist/index.d.ts +4 -4
  63. package/node_modules/@codex-ai/sdk/dist/index.js +2 -2
  64. package/node_modules/@codex-ai/sdk/package.json +8 -8
  65. package/package.json +209 -209
  66. package/scripts/codex-app-launcher.js +653 -653
  67. package/scripts/codex-app-router.js +446 -446
  68. package/scripts/codex-bin-resolver.js +332 -332
  69. package/scripts/codex-multi-auth.js +65 -65
  70. package/scripts/codex-routing.js +63 -63
  71. package/scripts/codex.js +7143 -7143
  72. package/scripts/install-codex-auth-utils.js +213 -213
  73. package/scripts/mcodex.js +367 -367
  74. package/scripts/postinstall.js +114 -114
  75. package/scripts/preuninstall.js +265 -265
  76. package/skills/codex-auth-setup/SKILL.md +73 -73
  77. package/vendor/codex-ai-plugin/dist/index.d.ts +2 -2
  78. package/vendor/codex-ai-plugin/dist/index.js +2 -2
  79. package/vendor/codex-ai-plugin/dist/tool.d.ts +42 -42
  80. package/vendor/codex-ai-plugin/dist/tool.js +29 -29
  81. package/vendor/codex-ai-plugin/package.json +9 -9
  82. package/vendor/codex-ai-sdk/dist/index.d.ts +4 -4
  83. package/vendor/codex-ai-sdk/dist/index.js +2 -2
  84. package/vendor/codex-ai-sdk/package.json +8 -8
@@ -4,9 +4,22 @@ export interface ResetTarget {
4
4
  key: string;
5
5
  accountId: string;
6
6
  }
7
+ /** A routing observation may invalidate a healthy check, never authorize spending. */
8
+ export interface ResetExhaustionEvidence extends ResetTarget {
9
+ observedAt: number;
10
+ }
11
+ declare const creditDetailSchema: z.ZodObject<{
12
+ expiresAt: z.ZodCatch<z.ZodOptional<z.ZodNullable<z.ZodNumber>>>;
13
+ title: z.ZodCatch<z.ZodOptional<z.ZodNullable<z.ZodString>>>;
14
+ }, z.core.$strip>;
15
+ export type ResetCreditDetail = z.infer<typeof creditDetailSchema>;
7
16
  declare const snapshotSchema: z.ZodObject<{
8
17
  updatedAt: z.ZodNumber;
9
18
  availableCount: z.ZodNullable<z.ZodNumber>;
19
+ credits: z.ZodCatch<z.ZodOptional<z.ZodNullable<z.ZodArray<z.ZodObject<{
20
+ expiresAt: z.ZodCatch<z.ZodOptional<z.ZodNullable<z.ZodNumber>>>;
21
+ title: z.ZodCatch<z.ZodOptional<z.ZodNullable<z.ZodString>>>;
22
+ }, z.core.$strip>>>>>;
10
23
  ordinaryUsageAllowed: z.ZodNullable<z.ZodBoolean>;
11
24
  planType: z.ZodNullable<z.ZodString>;
12
25
  primary: z.ZodObject<{
@@ -22,7 +35,7 @@ declare const snapshotSchema: z.ZodObject<{
22
35
  }, z.core.$strip>;
23
36
  export type ResetSnapshot = z.infer<typeof snapshotSchema>;
24
37
  declare const stateSchema: z.ZodObject<{
25
- version: z.ZodLiteral<1>;
38
+ version: z.ZodUnion<readonly [z.ZodLiteral<1>, z.ZodLiteral<2>]>;
26
39
  policy: z.ZodDefault<z.ZodEnum<{
27
40
  "last-resort": "last-resort";
28
41
  manual: "manual";
@@ -30,6 +43,10 @@ declare const stateSchema: z.ZodObject<{
30
43
  snapshots: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodObject<{
31
44
  updatedAt: z.ZodNumber;
32
45
  availableCount: z.ZodNullable<z.ZodNumber>;
46
+ credits: z.ZodCatch<z.ZodOptional<z.ZodNullable<z.ZodArray<z.ZodObject<{
47
+ expiresAt: z.ZodCatch<z.ZodOptional<z.ZodNullable<z.ZodNumber>>>;
48
+ title: z.ZodCatch<z.ZodOptional<z.ZodNullable<z.ZodString>>>;
49
+ }, z.core.$strip>>>>>;
33
50
  ordinaryUsageAllowed: z.ZodNullable<z.ZodBoolean>;
34
51
  planType: z.ZodNullable<z.ZodString>;
35
52
  primary: z.ZodObject<{
@@ -46,6 +63,7 @@ declare const stateSchema: z.ZodObject<{
46
63
  pending: z.ZodOptional<z.ZodObject<{
47
64
  key: z.ZodString;
48
65
  idempotencyKey: z.ZodString;
66
+ creditId: z.ZodOptional<z.ZodString>;
49
67
  }, z.core.$strip>>;
50
68
  lastAutomaticCheckAt: z.ZodOptional<z.ZodNumber>;
51
69
  lastRedemptionAt: z.ZodOptional<z.ZodNumber>;
@@ -75,10 +93,17 @@ export declare function parseResetSnapshot(value: unknown, accountId: string, no
75
93
  export declare function resetSnapshotQuota(snapshot: ResetSnapshot): QuotaCacheEntry;
76
94
  export declare function isResetSubscription(snapshot: ResetSnapshot): boolean;
77
95
  export interface ResetCreditIO {
78
- read: (target: ResetTarget) => Promise<unknown>;
79
- consume: (target: ResetTarget, idempotencyKey: string) => Promise<unknown>;
96
+ /** creditDetails asks the backend for per-credit rows; display refreshes and credit selection need them. */
97
+ read: (target: ResetTarget, options: {
98
+ creditDetails: boolean;
99
+ }) => Promise<unknown>;
100
+ consume: (target: ResetTarget, idempotencyKey: string, creditId?: string) => Promise<unknown>;
80
101
  now?: () => number;
81
102
  }
103
+ type AutomaticResult = {
104
+ key: string;
105
+ outcome: ResetOutcome;
106
+ } | null;
82
107
  export declare class ResetCreditService {
83
108
  private readonly path;
84
109
  private readonly io;
@@ -87,14 +112,13 @@ export declare class ResetCreditService {
87
112
  status(): Promise<State>;
88
113
  private save;
89
114
  setPolicy(policy: State['policy']): Promise<void>;
115
+ private readDetails;
90
116
  private read;
91
117
  refresh(targets: ResetTarget[]): Promise<Record<string, ResetSnapshot>>;
92
118
  private consumeLocked;
93
119
  redeem(target: ResetTarget): Promise<ResetOutcome>;
94
- automatic(targets: ResetTarget[]): Promise<{
95
- key: string;
96
- outcome: ResetOutcome;
97
- } | null>;
120
+ automatic(targets: ResetTarget[], evidence?: ResetExhaustionEvidence[]): Promise<AutomaticResult>;
121
+ private automaticBatch;
98
122
  private automaticLocked;
99
123
  }
100
124
  export {};
@@ -6,32 +6,96 @@ import { z } from 'zod';
6
6
  import { withFileTransactionLock } from '../storage/file-lock.js';
7
7
  import { mapWithConcurrency } from '../concurrency.js';
8
8
  import { MAX_RATE_LIMIT_DELAY_MS } from '../constants.js';
9
+ // Display-only projection: unknown keys (credit ids, backend extras) are stripped before
10
+ // persistence, and a malformed field degrades to unknown instead of failing the count read.
11
+ // expiresAt is Unix seconds; null means the credit does not expire.
12
+ const creditDetailSchema = z.object({ expiresAt: z.number().nullish().catch(undefined), title: z.string().nullish().catch(undefined) });
13
+ const creditDetailsSchema = z.array(creditDetailSchema).nullish().catch(null);
9
14
  const windowSchema = z.object({ usedPercent: z.number().min(0).max(100).optional(), resetsAt: z.number().nonnegative().nullish(), windowDurationMins: z.number().nonnegative().nullish() });
10
- const snapshotSchema = z.object({ updatedAt: z.number(), availableCount: z.number().int().nonnegative().nullable(), ordinaryUsageAllowed: z.boolean().nullable(), planType: z.string().nullable(), primary: windowSchema, secondary: windowSchema });
11
- const stateSchema = z.object({ version: z.literal(1), policy: z.enum(['manual', 'last-resort']).default('manual'), snapshots: z.record(z.string(), snapshotSchema).default({}), pending: z.object({ key: z.string(), idempotencyKey: z.string().uuid() }).optional(), lastAutomaticCheckAt: z.number().optional(), lastRedemptionAt: z.number().optional(), lastRedemption: z.object({ key: z.string(), at: z.number(), outcome: z.enum(['reset', 'nothingToReset', 'noCredit', 'alreadyRedeemed']), automatic: z.boolean() }).optional() });
12
- const readSchema = z.object({ accountId: z.string(), ordinaryUsageAllowed: z.boolean().nullish(), rateLimitResetCredits: z.object({ availableCount: z.number().int().nonnegative() }).nullish(), rateLimits: z.object({ planType: z.string().nullish(), primary: windowSchema.nullish(), secondary: windowSchema.nullish() }) });
15
+ const snapshotSchema = z.object({ updatedAt: z.number(), availableCount: z.number().int().nonnegative().nullable(), credits: creditDetailsSchema, ordinaryUsageAllowed: z.boolean().nullable(), planType: z.string().nullable(), primary: windowSchema, secondary: windowSchema });
16
+ const creditIdSchema = z.string().min(1).refine(id => id.trim().length > 0);
17
+ const stateSchema = z.object({ version: z.union([z.literal(1), z.literal(2)]), policy: z.enum(['manual', 'last-resort']).default('manual'), snapshots: z.record(z.string(), snapshotSchema).default({}), pending: z.object({ key: z.string(), idempotencyKey: z.string().uuid(), creditId: creditIdSchema.optional() }).optional(), lastAutomaticCheckAt: z.number().optional(), lastRedemptionAt: z.number().optional(), lastRedemption: z.object({ key: z.string(), at: z.number(), outcome: z.enum(['reset', 'nothingToReset', 'noCredit', 'alreadyRedeemed']), automatic: z.boolean() }).optional() });
18
+ // Raw credit rows stay in memory for credit selection; only the display projection is persisted.
19
+ const readSchema = z.object({ accountId: z.string(), ordinaryUsageAllowed: z.boolean().nullish(), rateLimitResetCredits: z.object({ availableCount: z.number().int().nonnegative(), credits: z.unknown().optional() }).nullish(), rateLimits: z.object({ planType: z.string().nullish(), primary: windowSchema.nullish(), secondary: windowSchema.nullish() }) });
20
+ const creditSchema = z.object({ id: creditIdSchema, status: z.literal('available'), resetType: z.literal('codexRateLimits'), expiresAt: z.number().int().safe().nonnegative().nullish() });
13
21
  const outcomeSchema = z.object({ outcome: z.enum(['reset', 'nothingToReset', 'noCredit', 'alreadyRedeemed']) });
14
22
  export function parseResetSnapshot(value, accountId, now) {
23
+ return parseResetReading(value, accountId, now).snapshot;
24
+ }
25
+ function parseResetReading(value, accountId, now) {
15
26
  const parsed = readSchema.parse(value);
16
27
  if (parsed.accountId !== accountId)
17
28
  throw Error('Reset credit workspace identity mismatch');
18
- return { updatedAt: now, availableCount: parsed.rateLimitResetCredits?.availableCount ?? null, ordinaryUsageAllowed: parsed.ordinaryUsageAllowed ?? null, planType: parsed.rateLimits.planType ?? null, primary: parsed.rateLimits.primary ?? {}, secondary: parsed.rateLimits.secondary ?? {} };
29
+ const snapshot = { updatedAt: now, availableCount: parsed.rateLimitResetCredits?.availableCount ?? null, credits: creditDetailsSchema.parse(parsed.rateLimitResetCredits?.credits) ?? null, ordinaryUsageAllowed: parsed.ordinaryUsageAllowed ?? null, planType: parsed.rateLimits.planType ?? null, primary: parsed.rateLimits.primary ?? {}, secondary: parsed.rateLimits.secondary ?? {} };
30
+ const rows = parsed.rateLimitResetCredits?.credits;
31
+ return { snapshot, credits: Array.isArray(rows) ? rows : [] };
32
+ }
33
+ /** Counts authorize availability; potentially capped detail rows only rank it.
34
+ * Never confuse credit expiry (seconds) with a quota window's scheduled reset. */
35
+ function firstExpiringCredit(rows, now) {
36
+ const ids = rows.map(row => z.object({ id: creditIdSchema }).safeParse(row));
37
+ const counts = new Map();
38
+ for (const id of ids)
39
+ if (id.success)
40
+ counts.set(id.data.id, (counts.get(id.data.id) ?? 0) + 1);
41
+ let best = { expiresAt: Infinity };
42
+ for (const row of rows) {
43
+ const parsed = creditSchema.safeParse(row);
44
+ if (!parsed.success || counts.get(parsed.data.id) !== 1)
45
+ continue;
46
+ const credit = parsed.data;
47
+ if (typeof credit.expiresAt === 'number' && credit.expiresAt <= now / 1000)
48
+ continue;
49
+ const expiresAt = credit.expiresAt ?? Infinity;
50
+ if (best.creditId === undefined || expiresAt < best.expiresAt)
51
+ best = { creditId: credit.id, expiresAt };
52
+ }
53
+ return best;
19
54
  }
20
55
  export function resetSnapshotQuota(snapshot) {
21
56
  // A bogus resetsAt must not read as a window that never resets.
22
57
  const resetAtMs = (s) => typeof s === 'number' && Number.isFinite(s) ? Math.min(s * 1000, snapshot.updatedAt + MAX_RATE_LIMIT_DELAY_MS) : undefined;
23
58
  const window = (w) => ({ usedPercent: w.usedPercent, resetAtMs: resetAtMs(w.resetsAt), windowMinutes: w.windowDurationMins ?? undefined });
24
- return { updatedAt: snapshot.updatedAt, status: 200, model: 'usage-read', planType: snapshot.planType ?? undefined, primary: window(snapshot.primary), secondary: window(snapshot.secondary) };
59
+ return { updatedAt: snapshot.updatedAt, status: 200, model: 'usage-read', ordinaryUsageAllowed: snapshot.ordinaryUsageAllowed, planType: snapshot.planType ?? undefined, primary: window(snapshot.primary), secondary: window(snapshot.secondary) };
25
60
  }
26
61
  export function isResetSubscription(snapshot) {
27
- return ['plus', 'pro', 'prolite', 'team', 'business', 'self_serve_business_prolite', 'enterprise', 'edu', 'edu_plus', 'edu_pro'].includes(snapshot.planType ?? '');
62
+ return ['plus', 'pro', 'promax', 'prolite', 'team', 'business', 'self_serve_business_prolite', 'enterprise', 'edu', 'edu_plus', 'edu_pro'].includes(snapshot.planType ?? '');
28
63
  }
29
- /** No credentials or credit IDs are persisted. One global redemption lease covers
30
- * all accounts: concurrent exhausted requests cannot independently spend credits. */
64
+ /** No credentials or credit lists are persisted (snapshot credit rows keep only expiry
65
+ * and title). Only an explicitly selected credit ID accompanies its pending idempotency
66
+ * key until completion. One global lease prevents concurrent exhausted requests from
67
+ * independently spending. */
31
68
  /** A stored time further ahead than this came from a clock that has since stepped back: treat it as expired. */
32
69
  const FUTURE_SKEW_MS = 5 * 60000;
33
70
  const within = (at, now, windowMs) => at !== undefined && at <= now + FUTURE_SKEW_MS && now - at < windowMs;
71
+ function validExhaustionEvidence(targets, proof, now) {
72
+ return targets.some(target => target.key === proof.key && target.accountId === proof.accountId) &&
73
+ Number.isFinite(proof.observedAt) && proof.observedAt <= now && now - proof.observedAt <= 60000;
74
+ }
75
+ function hasNewExhaustion(state, targets, evidence, now) {
76
+ return evidence.some(proof => {
77
+ const snapshot = state.snapshots[proof.key];
78
+ return validExhaustionEvidence(targets, proof, now) &&
79
+ state.lastAutomaticCheckAt !== undefined && proof.observedAt > state.lastAutomaticCheckAt &&
80
+ snapshot?.ordinaryUsageAllowed === true && Number.isFinite(snapshot.updatedAt) &&
81
+ snapshot.updatedAt <= now && proof.observedAt > snapshot.updatedAt;
82
+ });
83
+ }
34
84
  const automaticChecks = new Map();
85
+ // Stop even if failures keep advancing. New evidence after the final pass can
86
+ // be retried by a later request; no batch can hold callers indefinitely.
87
+ const MAX_AUTOMATIC_CHECK_PASSES = 3;
88
+ function mergeExhaustionEvidence(check, targets, evidence, now) {
89
+ for (const proof of evidence) {
90
+ if (!validExhaustionEvidence(targets, proof, now))
91
+ continue;
92
+ const key = JSON.stringify([proof.key, proof.accountId]), previous = check.evidence.get(key);
93
+ if (previous && previous.observedAt >= proof.observedAt)
94
+ continue;
95
+ check.evidence.set(key, { key: proof.key, accountId: proof.accountId, observedAt: proof.observedAt });
96
+ check.version++;
97
+ }
98
+ }
35
99
  export class ResetCreditService {
36
100
  path;
37
101
  io;
@@ -54,6 +118,9 @@ export class ResetCreditService {
54
118
  }
55
119
  }
56
120
  async save(state) {
121
+ // Older readers strip unknown pending fields. Make them reject an explicit
122
+ // credit attempt instead of retrying its UUID with different parameters.
123
+ state.version = state.pending?.creditId === undefined ? 1 : 2;
57
124
  const path = this.path + '.' + randomUUID() + '.tmp';
58
125
  try {
59
126
  const file = await fs.open(path, 'wx', 0o600);
@@ -71,10 +138,11 @@ export class ResetCreditService {
71
138
  }
72
139
  }
73
140
  async setPolicy(policy) { await withFileTransactionLock(this.path, async () => { const s = await this.status(); s.policy = policy; await this.save(s); }); }
74
- async read(target) { return parseResetSnapshot(await this.io.read(target), target.accountId, this.now()); }
141
+ async readDetails(target, creditDetails) { return parseResetReading(await this.io.read(target, { creditDetails }), target.accountId, this.now()); }
142
+ async read(target, creditDetails = false) { return (await this.readDetails(target, creditDetails)).snapshot; }
75
143
  async refresh(targets) {
76
144
  const results = await mapWithConcurrency(targets, 3, async (t) => { try {
77
- return { key: t.key, snapshot: await this.read(t) };
145
+ return { key: t.key, snapshot: await this.read(t, true) };
78
146
  }
79
147
  catch {
80
148
  return null;
@@ -87,13 +155,13 @@ export class ResetCreditService {
87
155
  updated[row.key] = row.snapshot;
88
156
  } await this.save(state); return updated; });
89
157
  }
90
- async consumeLocked(state, target, automatic = false) {
158
+ async consumeLocked(state, target, automatic = false, creditId) {
91
159
  if (state.pending && state.pending.key !== target.key)
92
160
  throw Error('A reset redemption is pending for another account; retry that account explicitly first.');
93
- state.pending ??= { key: target.key, idempotencyKey: randomUUID() };
161
+ state.pending ??= { key: target.key, idempotencyKey: randomUUID(), ...(creditId === undefined ? {} : { creditId }) };
94
162
  state.lastRedemptionAt = this.now();
95
163
  await this.save(state);
96
- const result = outcomeSchema.parse(await this.io.consume(target, state.pending.idempotencyKey));
164
+ const result = outcomeSchema.parse(await this.io.consume(target, state.pending.idempotencyKey, state.pending.creditId));
97
165
  // Keep the same key on *any* ambiguous outcome, including a failed follow-up read.
98
166
  state.snapshots[target.key] = await this.read(target);
99
167
  state.lastRedemption = { key: target.key, at: this.now(), outcome: result.outcome, automatic };
@@ -118,33 +186,63 @@ export class ResetCreditService {
118
186
  return this.consumeLocked(state, target);
119
187
  });
120
188
  }
121
- automatic(targets) {
189
+ automatic(targets, evidence = []) {
122
190
  const key = resolve(this.path), signature = JSON.stringify(targets), pending = automaticChecks.get(key);
123
- if (pending)
124
- return pending.targets === signature ? pending.promise : pending.promise.then(() => null);
191
+ if (pending?.accepting) {
192
+ if (pending.targets !== signature)
193
+ return pending.promise.then(() => null);
194
+ mergeExhaustionEvidence(pending, targets, evidence, this.now());
195
+ return pending.promise;
196
+ }
125
197
  if (automaticChecks.size >= 1000)
126
198
  return Promise.resolve(null);
127
- const promise = this.automaticLocked(targets).finally(() => { automaticChecks.delete(key); });
128
- automaticChecks.set(key, { targets: signature, promise });
129
- return promise;
199
+ const currentTargets = targets.map(target => ({ key: target.key, accountId: target.accountId }));
200
+ const check = { targets: signature, accepting: true, evidence: new Map(), version: 0, promise: Promise.resolve(null) };
201
+ mergeExhaustionEvidence(check, currentTargets, evidence, this.now());
202
+ check.promise = this.automaticBatch(currentTargets, check).finally(() => {
203
+ if (automaticChecks.get(key) === check)
204
+ automaticChecks.delete(key);
205
+ });
206
+ automaticChecks.set(key, check);
207
+ return check.promise;
130
208
  }
131
- async automaticLocked(targets) {
209
+ async automaticBatch(targets, check) {
210
+ try {
211
+ for (let pass = 0; pass < MAX_AUTOMATIC_CHECK_PASSES; pass++) {
212
+ const version = check.version;
213
+ const result = await this.automaticLocked(targets, [...check.evidence.values()]);
214
+ if (result !== null || check.version === version)
215
+ return result;
216
+ }
217
+ return null;
218
+ }
219
+ finally {
220
+ // Close admission before resolving: a late caller must not append evidence
221
+ // between batch completion and the outer promise's map cleanup.
222
+ check.accepting = false;
223
+ }
224
+ }
225
+ async automaticLocked(targets, evidence) {
132
226
  if (!targets.length || (await this.status()).policy !== 'last-resort')
133
227
  return null;
134
228
  return withFileTransactionLock(this.path, async () => {
135
229
  const state = await this.status();
136
- if (state.policy !== 'last-resort' || state.pending || within(state.lastAutomaticCheckAt, this.now(), 60000) || within(state.lastRedemptionAt, this.now(), 300000))
230
+ const now = this.now();
231
+ if (state.policy !== 'last-resort' || state.pending || within(state.lastRedemptionAt, now, 300000))
137
232
  return null;
138
- state.lastAutomaticCheckAt = this.now();
233
+ if (within(state.lastAutomaticCheckAt, now, 60000) && !hasNewExhaustion(state, targets, evidence, now))
234
+ return null;
235
+ state.lastAutomaticCheckAt = now;
139
236
  await this.save(state);
140
237
  // Missing/failed reads prevent spending. A scheduled reset that has arrived
141
238
  // is confirmed by ordinaryUsageAllowed, never inferred from a clock or %.
142
- const reads = await mapWithConcurrency(targets, 3, async (t) => { try {
143
- return await this.read(t);
239
+ const details = await mapWithConcurrency(targets, 3, async (t) => { try {
240
+ return await this.readDetails(t, true);
144
241
  }
145
242
  catch {
146
243
  return null;
147
244
  } });
245
+ const reads = details.map(row => row?.snapshot ?? null);
148
246
  reads.forEach((s, i) => { const t = targets[i]; if (s && t)
149
247
  state.snapshots[t.key] = s; });
150
248
  await this.save(state);
@@ -152,11 +250,20 @@ export class ResetCreditService {
152
250
  return null;
153
251
  if (reads.some(s => s && [s.primary, s.secondary].some(w => w.usedPercent === 100 && typeof w.resetsAt === 'number' && w.resetsAt * 1000 <= this.now() + 60000)))
154
252
  return null;
155
- const index = reads.findIndex(s => s !== null && (s.availableCount ?? 0) > 0);
156
- const target = targets[index];
157
- if (!target)
253
+ let best;
254
+ for (const [index, row] of details.entries()) {
255
+ const target = targets[index];
256
+ if (!row || !target || (row.snapshot.availableCount ?? 0) <= 0)
257
+ continue;
258
+ const credit = firstExpiringCredit(row.credits, this.now());
259
+ // Stable ties retain the caller's preferred-account/priority ordering.
260
+ // Unknown, omitted and nonexpiring details follow all known deadlines.
261
+ if (!best || credit.expiresAt < best.expiresAt)
262
+ best = { target, ...credit };
263
+ }
264
+ if (!best)
158
265
  return null;
159
- return { key: target.key, outcome: await this.consumeLocked(state, target, true) };
266
+ return { key: best.target.key, outcome: await this.consumeLocked(state, best.target, true, best.creditId) };
160
267
  }, { waitMs: 1000 });
161
268
  }
162
269
  }
@@ -1,3 +1,4 @@
1
+ import type { ResetExhaustionEvidence } from "./reset-credits.js";
1
2
  import type { NativeAccountSnapshot } from "./native-account-storage.js";
2
3
  import type { QuotaCacheData, QuotaCacheEntry } from "../quota-cache.js";
3
4
  import type { RuntimeCapabilityFailures } from "./runtime-capability-failures.js";
@@ -98,6 +99,8 @@ export interface RotationProxyState extends RotationProxyStateInit {
98
99
  catalogApiRoutes?: ApiRouteCredential[];
99
100
  catalogInventory?: ModelInventory;
100
101
  subscriptionQuotaObservations?: Map<string, QuotaCacheEntry>;
102
+ /** Explicit pre-inference quota rejections, scoped to immutable workspace/model identity. */
103
+ subscriptionQuotaRejections?: Map<string, ResetExhaustionEvidence>;
101
104
  readonly threadGoalFallbacks: Map<string, string | null>;
102
105
  lastGlobalAccountIndex: number | null;
103
106
  lastGlobalSwitchAt: number;
@@ -9,6 +9,7 @@ export interface SubscriptionQuotaPreference {
9
9
  }
10
10
  export declare const SUBSCRIPTION_RESERVE_PERCENT = 5;
11
11
  export declare const SUBSCRIPTION_QUOTA_MAX_AGE_MS: number;
12
+ export declare function hasFreshOrdinaryUsagePermission(entry: QuotaCacheEntry | null | undefined, now: number): boolean;
12
13
  /** Scheduling hint only; percentages are not comparable dollar/token balances. */
13
14
  export declare function subscriptionQuotaPreference(entry: QuotaCacheEntry | null | undefined, now: number): SubscriptionQuotaPreference;
14
15
  export declare function usesSubscriptionReserve(preference: SubscriptionQuotaPreference | undefined): boolean;
@@ -1,11 +1,15 @@
1
1
  import { isQuotaCacheEntryExhausted } from "../quota-readiness.js";
2
2
  export const SUBSCRIPTION_RESERVE_PERCENT = 5;
3
3
  export const SUBSCRIPTION_QUOTA_MAX_AGE_MS = 15 * 60_000;
4
+ export function hasFreshOrdinaryUsagePermission(entry, now) {
5
+ const checkedAt = entry?.ordinaryUsageCheckedAt ?? entry?.updatedAt;
6
+ return entry?.status === 200 && entry.ordinaryUsageAllowed === true && typeof checkedAt === "number" && Number.isFinite(checkedAt) && checkedAt <= now && now - checkedAt <= 60_000;
7
+ }
4
8
  /** Scheduling hint only; percentages are not comparable dollar/token balances. */
5
9
  export function subscriptionQuotaPreference(entry, now) {
6
10
  const planType = entry?.planType?.trim().toLowerCase();
7
- const plan = planType === "free" ? "free" : ["plus", "pro", "team", "business", "enterprise", "edu"].includes(planType ?? "") ? "subscription" : "unknown";
8
- const result = { plan, remainingPercent: null, resetAtMs: null, urgency: null, exhausted: isQuotaCacheEntryExhausted(entry, now), observedAt: entry?.updatedAt ?? null };
11
+ const plan = planType === "free" ? "free" : ["plus", "pro", "promax", "prolite", "team", "business", "self_serve_business_prolite", "enterprise", "edu", "edu_plus", "edu_pro"].includes(planType ?? "") ? "subscription" : "unknown";
12
+ const result = { plan, remainingPercent: null, resetAtMs: null, urgency: null, exhausted: !hasFreshOrdinaryUsagePermission(entry, now) && isQuotaCacheEntryExhausted(entry, now), observedAt: entry?.updatedAt ?? null };
9
13
  if (!entry || !Number.isFinite(entry.updatedAt) || entry.updatedAt > now || now - entry.updatedAt > SUBSCRIPTION_QUOTA_MAX_AGE_MS || entry.status !== 200)
10
14
  return result;
11
15
  const windows = [entry.primary, entry.secondary].filter(w => typeof w.usedPercent === "number" && Number.isFinite(w.usedPercent) && w.usedPercent >= 0 && w.usedPercent <= 100 && !(typeof w.resetAtMs === "number" && w.resetAtMs <= now) && !(w.resetAtMs === undefined && typeof w.windowMinutes === "number" && w.windowMinutes > 0 && now >= entry.updatedAt + w.windowMinutes * 60_000));
@@ -15,7 +19,7 @@ export function subscriptionQuotaPreference(entry, now) {
15
19
  if (!window)
16
20
  return result;
17
21
  result.remainingPercent = 100 - (window.usedPercent ?? 0);
18
- result.exhausted = result.remainingPercent === 0;
22
+ result.exhausted = result.remainingPercent === 0 && !hasFreshOrdinaryUsagePermission(entry, now);
19
23
  if (typeof window.resetAtMs === "number" && Number.isFinite(window.resetAtMs) && window.resetAtMs > now) {
20
24
  result.resetAtMs = window.resetAtMs;
21
25
  result.urgency = result.remainingPercent / (Math.max(60_000, window.resetAtMs - now) / 3_600_000);
@@ -1,10 +1,12 @@
1
1
  import { type ManagedAccount } from "../accounts.js";
2
+ export { nativeWorkspaceIdentity } from "../auth/token-utils.js";
2
3
  export declare function modelScopeId(...parts: string[]): string;
3
4
  /** Request scopes are immutable views. Discovery must never switch a saved account. */
4
5
  export declare function workspaceModelScopes(account: ManagedAccount): {
5
6
  accountId: string;
6
7
  workspaceIndex: number;
7
8
  enabled: boolean;
9
+ storedWorkspaceId: string | undefined;
8
10
  id: string;
9
11
  accountIndex: number;
10
12
  routable: boolean;
@@ -1,32 +1,42 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { resolveAccountRecordId } from "../accounts.js";
3
- import { extractAccountId } from "../auth/token-utils.js";
3
+ import { extractAccountId, nativeWorkspaceIdentity } from "../auth/token-utils.js";
4
+ export { nativeWorkspaceIdentity } from "../auth/token-utils.js";
4
5
  export function modelScopeId(...parts) {
5
6
  return createHash("sha256").update(JSON.stringify(parts)).digest("hex");
6
7
  }
7
8
  /** Request scopes are immutable views. Discovery must never switch a saved account. */
8
9
  export function workspaceModelScopes(account) {
9
- const boundId = account.accountId?.trim() || extractAccountId(account.access);
10
+ const boundId = nativeWorkspaceIdentity(account, account.accountId?.trim() || extractAccountId(account.access));
11
+ const selectedIndex = account.currentWorkspaceIndex ?? 0;
10
12
  const candidates = (account.workspaces ?? []).map((workspace, index) => ({
11
- accountId: workspace.id.trim(), workspaceIndex: index,
13
+ accountId: index === selectedIndex ? nativeWorkspaceIdentity(account, workspace.id) ?? "" : workspace.id.trim(), workspaceIndex: index,
12
14
  enabled: account.enabled !== false && workspace.enabled !== false,
13
15
  }));
14
16
  if (boundId && !candidates.some(scope => scope.accountId === boundId))
15
17
  candidates.unshift({ accountId: boundId, workspaceIndex: -1, enabled: account.enabled !== false });
16
18
  if (!candidates.length)
17
19
  candidates.push({ accountId: "", workspaceIndex: -1, enabled: account.enabled !== false });
20
+ // A selected alias may resolve to the same identity as an earlier token entry.
21
+ // Keep the selected entry so its enablement and index remain authoritative.
18
22
  const seen = new Set();
19
23
  return candidates.filter(scope => {
24
+ if (scope.workspaceIndex !== selectedIndex && candidates.some(other => other.workspaceIndex === selectedIndex && other.accountId === scope.accountId))
25
+ return false;
20
26
  if (seen.has(scope.accountId))
21
27
  return false;
22
28
  seen.add(scope.accountId);
23
29
  return true;
24
30
  }).map(scope => ({
25
31
  ...scope,
32
+ // Keep the stored identity across async inventory syncs, including Personal aliases.
33
+ storedWorkspaceId: account.workspaces?.[scope.workspaceIndex]?.id,
26
34
  // Same record identity as reset-credit targets (resolveAccountRecordId), or their keys diverge.
27
35
  id: modelScopeId("oauth", resolveAccountRecordId(account), scope.accountId),
28
36
  accountIndex: account.index,
29
- routable: scope.enabled && Boolean(scope.accountId),
37
+ routable: scope.enabled && Boolean(scope.accountId) && ((account.workspaces?.length ?? 0) > 0
38
+ ? scope.workspaceIndex === (account.currentWorkspaceIndex ?? 0)
39
+ : scope.accountId === boundId),
30
40
  bound: scope.accountId === boundId,
31
41
  selected: scope.workspaceIndex >= 0 && scope.workspaceIndex === (account.currentWorkspaceIndex ?? 0),
32
42
  label: `Account ${account.index + 1}${scope.workspaceIndex >= 0 ? ` / Workspace ${scope.workspaceIndex + 1}` : candidates.length > 1 ? " / Stored binding" : ""}`,