codex-multi-auth 2.22.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 (117) hide show
  1. package/.codex-plugin/plugin.json +9 -9
  2. package/LICENSE +21 -21
  3. package/README.md +159 -150
  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/config/schema/config.schema.json +8 -0
  11. package/dist/index.js +33 -10
  12. package/dist/lib/account-policy.d.ts +2 -0
  13. package/dist/lib/account-policy.js +3 -1
  14. package/dist/lib/accounts/rate-limits.d.ts +6 -0
  15. package/dist/lib/accounts/rate-limits.js +14 -1
  16. package/dist/lib/accounts.d.ts +6 -1
  17. package/dist/lib/accounts.js +33 -8
  18. package/dist/lib/api-route-store.d.ts +1 -0
  19. package/dist/lib/api-route-store.js +2 -1
  20. package/dist/lib/auth/account-access.js +2 -1
  21. package/dist/lib/auth/auth.js +3 -2
  22. package/dist/lib/auth/device-auth.js +2 -1
  23. package/dist/lib/auth/http.d.ts +1 -0
  24. package/dist/lib/auth/http.js +1 -0
  25. package/dist/lib/auth/token-utils.d.ts +5 -0
  26. package/dist/lib/auth/token-utils.js +10 -0
  27. package/dist/lib/codex-manager/account-manager-commands.js +1 -0
  28. package/dist/lib/codex-manager/commands/account.js +15 -0
  29. package/dist/lib/codex-manager/commands/locale.d.ts +12 -0
  30. package/dist/lib/codex-manager/commands/locale.js +25 -0
  31. package/dist/lib/codex-manager/commands/resets.js +18 -5
  32. package/dist/lib/codex-manager/commands/status.d.ts +4 -0
  33. package/dist/lib/codex-manager/commands/status.js +476 -5
  34. package/dist/lib/codex-manager/commands/workspace.d.ts +4 -2
  35. package/dist/lib/codex-manager/commands/workspace.js +33 -2
  36. package/dist/lib/codex-manager/help.js +5 -3
  37. package/dist/lib/codex-manager.js +112 -30
  38. package/dist/lib/config.js +3 -0
  39. package/dist/lib/constants.d.ts +2 -0
  40. package/dist/lib/constants.js +2 -0
  41. package/dist/lib/context-budget-response.js +8 -8
  42. package/dist/lib/context-overflow.js +10 -10
  43. package/dist/lib/locale.d.ts +7 -0
  44. package/dist/lib/locale.js +35 -0
  45. package/dist/lib/oauth-success.html +125 -125
  46. package/dist/lib/policy/runtime-policy-cache.js +1 -1
  47. package/dist/lib/policy/runtime-policy.d.ts +1 -0
  48. package/dist/lib/policy/runtime-policy.js +4 -0
  49. package/dist/lib/preemptive-quota-scheduler.js +12 -3
  50. package/dist/lib/prompts/codex-host-bridge.js +138 -138
  51. package/dist/lib/prompts/codex.js +79 -79
  52. package/dist/lib/quota-cache.d.ts +4 -0
  53. package/dist/lib/quota-probe.js +2 -1
  54. package/dist/lib/quota-readiness.d.ts +1 -1
  55. package/dist/lib/quota-readiness.js +9 -2
  56. package/dist/lib/request/proxy-fetch.d.ts +5 -0
  57. package/dist/lib/request/proxy-fetch.js +37 -0
  58. package/dist/lib/request/rate-limit-decision.js +1 -1
  59. package/dist/lib/request/url-rewriting.js +17 -0
  60. package/dist/lib/runtime/account-model-catalog.d.ts +2 -0
  61. package/dist/lib/runtime/account-model-catalog.js +7 -2
  62. package/dist/lib/runtime/account-reset-credits.d.ts +11 -2
  63. package/dist/lib/runtime/account-reset-credits.js +35 -12
  64. package/dist/lib/runtime/app-bind.d.ts +4 -1
  65. package/dist/lib/runtime/app-bind.js +63 -3
  66. package/dist/lib/runtime/automatic-account-checks.js +6 -5
  67. package/dist/lib/runtime/automatic-subscription-checks.js +4 -3
  68. package/dist/lib/runtime/model-discovery-status.js +2 -2
  69. package/dist/lib/runtime/reset-credit-routing.d.ts +11 -3
  70. package/dist/lib/runtime/reset-credit-routing.js +66 -21
  71. package/dist/lib/runtime/reset-credits.d.ts +31 -7
  72. package/dist/lib/runtime/reset-credits.js +135 -28
  73. package/dist/lib/runtime/responses-websocket.d.ts +30 -0
  74. package/dist/lib/runtime/responses-websocket.js +243 -29
  75. package/dist/lib/runtime/rotation-account-selection.d.ts +5 -4
  76. package/dist/lib/runtime/rotation-proxy-state.d.ts +3 -0
  77. package/dist/lib/runtime/rotation-server-types.d.ts +4 -1
  78. package/dist/lib/runtime/subscription-quota-order.d.ts +1 -0
  79. package/dist/lib/runtime/subscription-quota-order.js +7 -3
  80. package/dist/lib/runtime/workspace-model-scopes.d.ts +2 -0
  81. package/dist/lib/runtime/workspace-model-scopes.js +14 -4
  82. package/dist/lib/runtime-rotation-proxy.js +138 -38
  83. package/dist/lib/schemas.d.ts +5 -0
  84. package/dist/lib/schemas.js +1 -0
  85. package/dist/lib/storage/snapshot-merge.js +15 -4
  86. package/dist/lib/web-dashboard-html.d.ts +1 -0
  87. package/dist/lib/web-dashboard-html.js +66 -0
  88. package/dist/lib/web-dashboard.d.ts +36 -0
  89. package/dist/lib/web-dashboard.js +322 -0
  90. package/node_modules/@codex-ai/plugin/dist/index.d.ts +2 -2
  91. package/node_modules/@codex-ai/plugin/dist/index.js +2 -2
  92. package/node_modules/@codex-ai/plugin/dist/tool.d.ts +42 -42
  93. package/node_modules/@codex-ai/plugin/dist/tool.js +29 -29
  94. package/node_modules/@codex-ai/plugin/package.json +9 -9
  95. package/node_modules/@codex-ai/sdk/dist/index.d.ts +4 -4
  96. package/node_modules/@codex-ai/sdk/dist/index.js +2 -2
  97. package/node_modules/@codex-ai/sdk/package.json +8 -8
  98. package/package.json +209 -207
  99. package/scripts/codex-app-launcher.js +653 -653
  100. package/scripts/codex-app-router.js +446 -402
  101. package/scripts/codex-bin-resolver.js +332 -332
  102. package/scripts/codex-multi-auth.js +65 -65
  103. package/scripts/codex-routing.js +63 -62
  104. package/scripts/codex.js +7143 -7006
  105. package/scripts/install-codex-auth-utils.js +213 -213
  106. package/scripts/mcodex.js +367 -367
  107. package/scripts/postinstall.js +114 -114
  108. package/scripts/preuninstall.js +265 -265
  109. package/skills/codex-auth-setup/SKILL.md +73 -73
  110. package/vendor/codex-ai-plugin/dist/index.d.ts +2 -2
  111. package/vendor/codex-ai-plugin/dist/index.js +2 -2
  112. package/vendor/codex-ai-plugin/dist/tool.d.ts +42 -42
  113. package/vendor/codex-ai-plugin/dist/tool.js +29 -29
  114. package/vendor/codex-ai-plugin/package.json +9 -9
  115. package/vendor/codex-ai-sdk/dist/index.d.ts +4 -4
  116. package/vendor/codex-ai-sdk/dist/index.js +2 -2
  117. 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
  }
@@ -9,12 +9,33 @@ export interface WebSocketDisconnectDiagnostic {
9
9
  routeKind?: "oauth" | "api" | "zdr";
10
10
  connectionAgeMs?: number;
11
11
  eventCount?: number;
12
+ requestBytes?: number;
13
+ inputItemCount?: number;
14
+ isPrewarm?: boolean;
15
+ hasPreviousResponse?: boolean;
16
+ reusedConnection?: boolean;
17
+ /** Local send callback success, not evidence of upstream receipt. */
18
+ sendCompleted?: boolean;
19
+ compressionNegotiated?: boolean;
20
+ transportErrorCode?: "ECONNRESET" | "EPIPE" | "ETIMEDOUT" | "OTHER";
21
+ tcpEndReceived?: boolean;
22
+ tcpCloseHadError?: boolean;
23
+ }
24
+ /** Local admission evidence only; no headers, credentials, or conversation IDs. */
25
+ export interface WebSocketUpgradeRejectionDiagnostic {
26
+ at: number;
27
+ reason: "origin" | "capacity" | "repeated_disconnect";
28
+ status: number;
29
+ activeConnections: number;
12
30
  }
13
31
  interface WebSocketStats {
14
32
  connections: number;
33
+ activeConnections: number;
34
+ capacityReclaims: number;
15
35
  upstreamConnections: number;
16
36
  upstreamRequests: number;
17
37
  lastDisconnect?: WebSocketDisconnectDiagnostic;
38
+ lastUpgradeRejection?: WebSocketUpgradeRejectionDiagnostic;
18
39
  }
19
40
  /** Adapts only the transport; authenticated HTTP routing remains the policy authority. */
20
41
  export declare class ResponsesWebSocketGateway {
@@ -24,6 +45,9 @@ export declare class ResponsesWebSocketGateway {
24
45
  private readonly contexts;
25
46
  private readonly scope;
26
47
  private readonly sessions;
48
+ /** Credential-hashed, bounded API handshake backoff survives local reconnects. */
49
+ private readonly apiHandshakeBackoff;
50
+ private readonly websocketFailures;
27
51
  private readonly wss;
28
52
  private readonly maxBytes;
29
53
  /** Loopback hops must never go through an environment HTTP proxy (NODE_USE_ENV_PROXY). */
@@ -35,6 +59,12 @@ export declare class ResponsesWebSocketGateway {
35
59
  routeModel?: (model: string, headers: Headers) => string;
36
60
  });
37
61
  readonly fetch: typeof fetch;
62
+ private rememberDisconnect;
63
+ private needsHttpFallback;
64
+ /** One writer for the upgrade-rejection diagnostic so the two reject paths cannot drift. */
65
+ private recordUpgradeRejection;
66
+ /** Release idle setup sockets only; active work and generated history stay pinned. */
67
+ private reclaimIdleSession;
38
68
  handle(req: IncomingMessage, handler: () => Promise<void>): void;
39
69
  attach(server: Server, baseUrl: string): void;
40
70
  private serve;