@bitkyc08/opencodex 2.35.0 → 2.36.0-preview.20260830

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 (155) hide show
  1. package/gui/dist/assets/index-Cy7Z_pl0.css +1 -0
  2. package/gui/dist/assets/index-DPl4nBMA.js +112 -0
  3. package/gui/dist/index.html +2 -2
  4. package/package.json +2 -1
  5. package/src/AGENTS.md +2 -1
  6. package/src/adapters/agentrouter.ts +50 -0
  7. package/src/adapters/anthropic.ts +1 -51
  8. package/src/adapters/cursor/call-id.ts +76 -8
  9. package/src/adapters/cursor/checkpoint-store.ts +6 -1
  10. package/src/adapters/cursor/cursor-errors.ts +44 -0
  11. package/src/adapters/cursor/native-exec.ts +13 -0
  12. package/src/adapters/cursor/protobuf-request.ts +651 -29
  13. package/src/adapters/cursor/tool-result-normalize.ts +3 -3
  14. package/src/adapters/cursor/transport-retry.ts +5 -1
  15. package/src/adapters/cursor.ts +15 -1
  16. package/src/adapters/empty-tool-output-annotation.ts +43 -0
  17. package/src/adapters/exec-tool-result-normalize.ts +70 -5
  18. package/src/adapters/google.ts +22 -2
  19. package/src/adapters/kiro.ts +26 -2
  20. package/src/adapters/ollama-native-url.ts +111 -0
  21. package/src/adapters/ollama-native.ts +1131 -0
  22. package/src/adapters/openai-chat.ts +30 -7
  23. package/src/adapters/openai-responses.ts +72 -4
  24. package/src/adapters/registry.ts +7 -0
  25. package/src/adapters/xai-web-search.ts +58 -0
  26. package/src/claude/desktop-3p.ts +21 -1
  27. package/src/claude/desktop-policy.ts +149 -0
  28. package/src/cli/account.ts +16 -2
  29. package/src/cli/claude-desktop.ts +13 -3
  30. package/src/cli/combo.ts +8 -5
  31. package/src/cli/doctor.ts +77 -11
  32. package/src/cli/help.ts +1 -1
  33. package/src/cli/index.ts +16 -0
  34. package/src/cli/models.ts +20 -3
  35. package/src/cli/registry.ts +2 -1
  36. package/src/cli/status.ts +140 -2
  37. package/src/cli/storage.ts +10 -1
  38. package/src/codex/account-runtime-state.ts +39 -5
  39. package/src/codex/account-store.ts +393 -13
  40. package/src/codex/account-usability.ts +11 -4
  41. package/src/codex/app-server-processes.ts +46 -5
  42. package/src/codex/auth-context.ts +160 -32
  43. package/src/codex/catalog/bundled.ts +7 -5
  44. package/src/codex/catalog/metadata.ts +1 -1
  45. package/src/codex/catalog/parsing.ts +57 -1
  46. package/src/codex/catalog/provider-fetch.ts +61 -4
  47. package/src/codex/catalog/sync.ts +4 -3
  48. package/src/codex/convergence.ts +3 -2
  49. package/src/codex/data/upstream-models.json +40 -8
  50. package/src/codex/inject-coordination.ts +111 -14
  51. package/src/codex/integration-record.ts +12 -2
  52. package/src/codex/main-account.ts +225 -1
  53. package/src/codex/model-entitlements.ts +339 -27
  54. package/src/codex/prompt-layers.ts +346 -7
  55. package/src/codex/prompt-text-probe.ts +272 -21
  56. package/src/codex/routing.ts +693 -132
  57. package/src/codex/runtime.ts +12 -0
  58. package/src/codex/subagent-model-fallback.ts +62 -24
  59. package/src/codex/user-identity.ts +33 -25
  60. package/src/combos/index.ts +1 -0
  61. package/src/combos/reset-window.ts +46 -0
  62. package/src/combos/resolve.ts +84 -2
  63. package/src/combos/types.ts +5 -2
  64. package/src/config/atomic-write.ts +104 -22
  65. package/src/config/provider-validation.ts +11 -0
  66. package/src/config.ts +75 -3
  67. package/src/generated/compatibility-version.json +207 -131
  68. package/src/generated/model-metadata.ts +1 -1
  69. package/src/grok/catalog.ts +71 -0
  70. package/src/grok/effort.ts +83 -0
  71. package/src/grok/inject.ts +952 -127
  72. package/src/grok/models.ts +56 -0
  73. package/src/grok/status.ts +21 -8
  74. package/src/grok/sync.ts +10 -18
  75. package/src/images/loop.ts +6 -3
  76. package/src/integrations/native/ownership-preflight.ts +4 -1
  77. package/src/lab/fabric/producer-isolate.ts +36 -3
  78. package/src/lib/destination-policy.ts +93 -7
  79. package/src/lib/redact.ts +6 -1
  80. package/src/lib/shadow-call.ts +38 -3
  81. package/src/lib/test-home-guard.ts +18 -3
  82. package/src/lib/upstream-retry.ts +43 -6
  83. package/src/lib/windows-secret-acl.ts +66 -0
  84. package/src/lib/windows-text.ts +28 -2
  85. package/src/lib/windows-user-principal.ts +35 -23
  86. package/src/oauth/account-quota-rank.ts +107 -0
  87. package/src/oauth/anthropic-routing.ts +125 -30
  88. package/src/oauth/chatgpt.ts +5 -1
  89. package/src/oauth/generic-account-failover.ts +114 -7
  90. package/src/oauth/index.ts +15 -8
  91. package/src/oauth/store.ts +16 -0
  92. package/src/providers/account-quota-disk.ts +79 -0
  93. package/src/providers/command-code-efforts.ts +24 -0
  94. package/src/providers/derive.ts +6 -0
  95. package/src/providers/key-failover.ts +33 -1
  96. package/src/providers/kiro-usage.ts +272 -0
  97. package/src/providers/ollama-show.ts +311 -0
  98. package/src/providers/openai-sidecar.ts +5 -0
  99. package/src/providers/quota-routing-cache.ts +32 -0
  100. package/src/providers/quota-types.ts +36 -0
  101. package/src/providers/quota-wire.ts +102 -0
  102. package/src/providers/quota.ts +208 -147
  103. package/src/providers/registry.ts +68 -8
  104. package/src/providers/slug-codec.ts +12 -4
  105. package/src/providers/vercel-gateway-routing.ts +108 -0
  106. package/src/router.ts +22 -12
  107. package/src/server/auth-cors.ts +26 -0
  108. package/src/server/catalog-download.ts +73 -0
  109. package/src/server/chat-native.ts +12 -2
  110. package/src/server/gui-static.ts +4 -1
  111. package/src/server/index.ts +132 -9
  112. package/src/server/management/agent-settings-routes.ts +38 -5
  113. package/src/server/management/codex-prompt-routes.ts +7 -1
  114. package/src/server/management/combo-routes.ts +10 -1
  115. package/src/server/management/config-routes.ts +9 -1
  116. package/src/server/management/context.ts +5 -0
  117. package/src/server/management/model-routes.ts +16 -6
  118. package/src/server/management/native-integration-routes.ts +12 -17
  119. package/src/server/management/oauth-account-routes.ts +13 -0
  120. package/src/server/management/provider-routes.ts +32 -5
  121. package/src/server/management/routing-profile-routes.ts +15 -0
  122. package/src/server/management/shadow-call-validation.ts +29 -0
  123. package/src/server/management-api.ts +7 -3
  124. package/src/server/request-log.ts +3 -5
  125. package/src/server/responses/agent-task-recovery-cache.ts +8 -0
  126. package/src/server/responses/agent-task-recovery.ts +52 -20
  127. package/src/server/responses/codex-auth-error.ts +26 -0
  128. package/src/server/responses/compact.ts +345 -10
  129. package/src/server/responses/core.ts +736 -108
  130. package/src/server/responses/empty-completion-guard.ts +16 -0
  131. package/src/server/responses/fetch-helpers.ts +42 -0
  132. package/src/server/responses/policy-fallback.ts +11 -6
  133. package/src/server/responses-undeclared-tool-guard.ts +16 -3
  134. package/src/server/startup-health-cache.ts +59 -13
  135. package/src/service-manager-probe.ts +115 -9
  136. package/src/service.ts +139 -40
  137. package/src/storage/cleanup.ts +10 -0
  138. package/src/storage/storage-mutation-coordinator.ts +14 -3
  139. package/src/tray/windows-tray.ps1 +10 -4
  140. package/src/tray/windows.ts +30 -2
  141. package/src/types/config.ts +27 -14
  142. package/src/types/provider.ts +54 -0
  143. package/src/types/tools.ts +13 -3
  144. package/src/types.ts +4 -0
  145. package/src/usage/summary.ts +421 -177
  146. package/src/vision/anthropic-describe.ts +3 -3
  147. package/src/vision/describe.ts +5 -3
  148. package/src/web-search/anthropic-executor.ts +9 -2
  149. package/src/web-search/exa-executor.ts +3 -3
  150. package/src/web-search/executor.ts +8 -3
  151. package/src/web-search/gemini-executor.ts +3 -3
  152. package/src/web-search/loop.ts +11 -3
  153. package/src/web-search/xai-executor.ts +3 -3
  154. package/gui/dist/assets/index-DNdRKXK9.js +0 -112
  155. package/gui/dist/assets/index-DQ-Ie18T.css +0 -1
@@ -44,19 +44,30 @@ import {
44
44
  applyCodexAuthContextToProvider,
45
45
  CodexMainProfileDrainingError,
46
46
  headersForCodexAuthContext,
47
- materializeCodexUpstreamAuth,
47
+ materializeCodexUpstreamAuthAsync,
48
48
  isCodexAuthContextUsable,
49
49
  resolveCodexAuthContext,
50
50
  codexProbeLeaseId,
51
51
  codexProbeQuotaScope,
52
52
  releaseCodexAuthContextProbeLease,
53
+ stripCodexRuntimeProviderFields,
53
54
  type CodexAuthContext,
54
55
  } from "../../codex/auth-context";
56
+ import {
57
+ forceRefreshMainAccountToken,
58
+ type NativeMainRefreshDependencies,
59
+ } from "../../codex/main-account";
55
60
  import {
56
61
  formatCodexProviderForLog,
62
+ handOffThreadAffinityGeneration,
57
63
  recordCodexUpstreamOutcome,
58
64
  type CodexUpstreamOutcome,
59
65
  } from "../../codex/routing";
66
+ import {
67
+ TokenRefreshError,
68
+ forceRefreshCodexPoolToken,
69
+ readCodexAccountRecord,
70
+ } from "../../codex/account-store";
60
71
  import {
61
72
  fetchWithResetRetry,
62
73
  fetchWithTransientRetry,
@@ -74,7 +85,11 @@ import {
74
85
  upstreamHostHealthKey,
75
86
  type UpstreamHostAdmissionLease,
76
87
  } from "../../codex/upstream-host-health";
77
- import { ForwardAdmissionCredentialError, validateForwardAdmissionCredential } from "../auth-cors";
88
+ import {
89
+ ForwardAdmissionCredentialError,
90
+ hasForwardableCodexBearer,
91
+ validateForwardAdmissionCredential,
92
+ } from "../auth-cors";
78
93
  import type { DataPlaneAdmission } from "../auth-cors";
79
94
  import { listOpenAiForwardSidecarCandidates, resolveFirstUsableOpenAiSidecar, type ResolvedOpenAiForwardSidecar } from "../../providers/openai-sidecar";
80
95
  import { CODEX_FORWARD_BASE_URL, isCanonicalOpenAiForwardProvider, supportsNativeResponsesCompactEndpoint } from "../../providers/openai-tiers";
@@ -91,6 +106,7 @@ import { codexAccountSelectionForTurn, registerTurn, trackStreamLifetime, unregi
91
106
  import type { AdmissionLease } from "../../lib/admission";
92
107
  import { redactSecretString } from "../../lib/redact";
93
108
  import { readBoundedResponseBody } from "../../lib/bounded-body";
109
+ import { isRateLimitOrQuotaFailureMessage } from "../../lib/errors";
94
110
  import { supportedLadderFor } from "../effort-policy";
95
111
  import {
96
112
  beginRequestAttempt,
@@ -126,10 +142,71 @@ import {
126
142
  usesCodexForwardPoolAuth,
127
143
  } from "./core";
128
144
  import { fetchWithHeaderTimeout, providerFetch, safeHostLabel, safeOriginLabel } from "./fetch-helpers";
129
- import { mapCodexAuthContextErrorToResponse } from "./codex-auth-error";
145
+ import { mapCodexAuthContextErrorToResponse, nativeMainRefreshFailureResponse } from "./codex-auth-error";
146
+ import { sessionLaneIdFromRequest } from "../request-log-conversation";
130
147
 
131
148
  export const COMPACT_RESPONSE_MAX_BYTES = 32 * 1024 * 1024;
132
149
 
150
+ const COMPACT_HANDOFF_ROUTE_TTL_MS = 24 * 60 * 60_000;
151
+ const COMPACT_HANDOFF_ROUTE_MAX_ENTRIES = 2_048;
152
+ const COMPACT_HANDOFF_MODEL_MAX_LENGTH = 512;
153
+
154
+ interface CompactHandoffRoute {
155
+ model: string;
156
+ lastUsedAt: number;
157
+ }
158
+
159
+ /**
160
+ * The Codex client does not send its newly selected model on an automatic
161
+ * previous-model compact request. Keep the last route that demonstrably compacted
162
+ * this same thread so a quota-blocked previous model has one safe fallback target.
163
+ */
164
+ const compactHandoffRoutes = new Map<string, CompactHandoffRoute>();
165
+
166
+ export function clearCompactHandoffRoutesForTests(): void {
167
+ compactHandoffRoutes.clear();
168
+ }
169
+
170
+ function pruneCompactHandoffRoutes(now: number): void {
171
+ for (const [key, entry] of compactHandoffRoutes) {
172
+ if (now - entry.lastUsedAt > COMPACT_HANDOFF_ROUTE_TTL_MS) compactHandoffRoutes.delete(key);
173
+ }
174
+ while (compactHandoffRoutes.size > COMPACT_HANDOFF_ROUTE_MAX_ENTRIES) {
175
+ const oldest = compactHandoffRoutes.keys().next().value;
176
+ if (typeof oldest !== "string") return;
177
+ compactHandoffRoutes.delete(oldest);
178
+ }
179
+ }
180
+
181
+ function rememberCompactHandoffRoute(req: Request, model: string, now = Date.now()): void {
182
+ const key = sessionLaneIdFromRequest(req.headers);
183
+ if (!key || model.length > COMPACT_HANDOFF_MODEL_MAX_LENGTH) return;
184
+ pruneCompactHandoffRoutes(now);
185
+ compactHandoffRoutes.delete(key);
186
+ compactHandoffRoutes.set(key, { model, lastUsedAt: now });
187
+ pruneCompactHandoffRoutes(now);
188
+ }
189
+
190
+ function forgetCompactHandoffRoute(req: Request): void {
191
+ const key = sessionLaneIdFromRequest(req.headers);
192
+ if (key) compactHandoffRoutes.delete(key);
193
+ }
194
+
195
+ function compactHandoffRoute(req: Request, previousModel: string, now = Date.now()): string | null {
196
+ const key = sessionLaneIdFromRequest(req.headers);
197
+ if (!key) return null;
198
+ pruneCompactHandoffRoutes(now);
199
+ const entry = compactHandoffRoutes.get(key);
200
+ if (!entry || entry.model === previousModel) return null;
201
+ compactHandoffRoutes.delete(key);
202
+ compactHandoffRoutes.set(key, { ...entry, lastUsedAt: now });
203
+ return entry.model;
204
+ }
205
+
206
+ export interface HandleResponsesCompactOptions {
207
+ nativeMainRefreshDependencies?: NativeMainRefreshDependencies;
208
+ }
209
+
133
210
  export function compactResponseTooLargeError(): Response {
134
211
  return new Response(JSON.stringify({
135
212
  error: {
@@ -140,6 +217,143 @@ export function compactResponseTooLargeError(): Response {
140
217
  }), { status: 502, headers: { "Content-Type": "application/json" } });
141
218
  }
142
219
 
220
+ async function refreshNativeMainCompactContext(args: {
221
+ req: Request;
222
+ authCtx: CodexAuthContext;
223
+ provider: OcxProviderConfig;
224
+ codexAccountMode?: CodexAccountMode;
225
+ substituteMainCredential: boolean;
226
+ options: HandleResponsesCompactOptions;
227
+ }): Promise<
228
+ | { ok: true; authCtx: CodexAuthContext; provider: OcxProviderConfig; headers: Headers }
229
+ | { ok: false; response: Response }
230
+ > {
231
+ const { req, authCtx, provider, codexAccountMode, substituteMainCredential, options } = args;
232
+ if (authCtx.kind !== "main-pool") {
233
+ return { ok: false, response: formatErrorResponse(401, "authentication_error", "No native main credential to refresh") };
234
+ }
235
+ try {
236
+ const refreshed = await forceRefreshMainAccountToken(authCtx.accessToken, {
237
+ signal: req.signal,
238
+ ...(options.nativeMainRefreshDependencies ?? {}),
239
+ });
240
+ if (!refreshed) {
241
+ return { ok: false, response: formatErrorResponse(401, "authentication_error", "Codex main account needs reauthentication") };
242
+ }
243
+ const refreshedAuthCtx: CodexAuthContext = {
244
+ ...authCtx,
245
+ accessToken: refreshed.accessToken,
246
+ chatgptAccountId: refreshed.chatgptAccountId,
247
+ };
248
+ const refreshedProvider = applyCodexAuthContextToProvider(
249
+ stripCodexRuntimeProviderFields(provider),
250
+ refreshedAuthCtx,
251
+ codexAccountMode,
252
+ );
253
+ const headers = new Headers({ "content-type": "application/json" });
254
+ const selected = await materializeCodexUpstreamAuthAsync(req.headers, refreshedAuthCtx, {
255
+ substituteMainCredential,
256
+ signal: req.signal,
257
+ nativeMainRefreshDependencies: options.nativeMainRefreshDependencies,
258
+ });
259
+ for (const name of FORWARD_HEADERS) {
260
+ const value = selected.get(name);
261
+ if (value) headers.set(name, value);
262
+ }
263
+ const override = (refreshedProvider as { _codexAccountOverride?: { accessToken: string; chatgptAccountId: string } })._codexAccountOverride;
264
+ if (override) {
265
+ headers.set("authorization", `Bearer ${override.accessToken}`);
266
+ headers.set("chatgpt-account-id", override.chatgptAccountId);
267
+ }
268
+ return { ok: true, authCtx: refreshedAuthCtx, provider: refreshedProvider, headers };
269
+ } catch (error) {
270
+ return { ok: false, response: nativeMainRefreshFailureResponse(error) };
271
+ }
272
+ }
273
+
274
+ /** See the core counterpart: only a dead grant is terminal (#2887). */
275
+ function isTerminalCompactPoolRefreshFailure(error: unknown): boolean {
276
+ return error instanceof TokenRefreshError && (error.reason === "revoked" || error.reason === "expired");
277
+ }
278
+
279
+ /**
280
+ * Compact's forced refresh for an ordinary stored pool credential rejected with a
281
+ * pre-stream 401. Mirrors {@link refreshNativeMainCompactContext} so the two 401
282
+ * contracts on this endpoint cannot drift.
283
+ */
284
+ async function refreshPoolCompactContext(args: {
285
+ req: Request;
286
+ authCtx: CodexAuthContext & { kind: "pool" };
287
+ provider: OcxProviderConfig;
288
+ codexAccountMode?: CodexAccountMode;
289
+ substituteMainCredential: boolean;
290
+ options: HandleResponsesCompactOptions;
291
+ }): Promise<
292
+ | { ok: true; authCtx: CodexAuthContext; provider: OcxProviderConfig; headers: Headers }
293
+ | { ok: false; response: Response; quarantine: boolean; quarantineGeneration?: number }
294
+ > {
295
+ const { req, authCtx, provider, codexAccountMode, substituteMainCredential, options } = args;
296
+ const reauthResponse = () => formatErrorResponse(
297
+ 401,
298
+ "authentication_error",
299
+ "Selected Codex account needs reauthentication",
300
+ );
301
+ try {
302
+ const refreshed = await forceRefreshCodexPoolToken(authCtx.accountId, {
303
+ rejectedGeneration: authCtx.generation,
304
+ rejectedAccessToken: authCtx.accessToken,
305
+ signal: req.signal,
306
+ });
307
+ // See the core counterpart: a successful response can rotate only the refresh grant,
308
+ // so the credential may already sit at a later generation than the one we rejected.
309
+ if (!refreshed.rotated) {
310
+ return { ok: false, quarantine: true, quarantineGeneration: refreshed.generation, response: reauthResponse() };
311
+ }
312
+ if (refreshed.selfRefreshed) {
313
+ handOffThreadAffinityGeneration(authCtx.accountId, authCtx.generation, refreshed.generation);
314
+ }
315
+ const refreshedAuthCtx: CodexAuthContext = {
316
+ ...authCtx,
317
+ accessToken: refreshed.accessToken,
318
+ chatgptAccountId: refreshed.chatgptAccountId,
319
+ generation: refreshed.generation,
320
+ };
321
+ const refreshedProvider = applyCodexAuthContextToProvider(
322
+ stripCodexRuntimeProviderFields(provider),
323
+ refreshedAuthCtx,
324
+ codexAccountMode,
325
+ );
326
+ const headers = new Headers({ "content-type": "application/json" });
327
+ const selected = await materializeCodexUpstreamAuthAsync(req.headers, refreshedAuthCtx, {
328
+ substituteMainCredential,
329
+ signal: req.signal,
330
+ nativeMainRefreshDependencies: options.nativeMainRefreshDependencies,
331
+ });
332
+ for (const name of FORWARD_HEADERS) {
333
+ const value = selected.get(name);
334
+ if (value) headers.set(name, value);
335
+ }
336
+ const override = (refreshedProvider as { _codexAccountOverride?: { accessToken: string; chatgptAccountId: string } })._codexAccountOverride;
337
+ if (override) {
338
+ headers.set("authorization", `Bearer ${override.accessToken}`);
339
+ headers.set("chatgpt-account-id", override.chatgptAccountId);
340
+ }
341
+ return { ok: true, authCtx: refreshedAuthCtx, provider: refreshedProvider, headers };
342
+ } catch (error) {
343
+ if (isTerminalCompactPoolRefreshFailure(error)) {
344
+ return { ok: false, quarantine: true, response: reauthResponse() };
345
+ }
346
+ const response = formatErrorResponse(
347
+ 503,
348
+ "server_busy",
349
+ "Codex credential refresh did not complete; retry this request",
350
+ );
351
+ const headers = new Headers(response.headers);
352
+ headers.set("Retry-After", "1");
353
+ return { ok: false, quarantine: false, response: new Response(response.body, { status: response.status, headers }) };
354
+ }
355
+ }
356
+
143
357
 
144
358
 
145
359
  /**
@@ -165,6 +379,7 @@ async function resolveAlternateCompactContext(args: {
165
379
  const authCtx = await resolveCodexAuthContext(req.headers, config, route.codexAccountMode, {
166
380
  ...(selectedModelId ? { modelId: selectedModelId } : {}),
167
381
  excludeAccountId,
382
+ requestScopedMainCredential: hasForwardableCodexBearer(req.headers, config),
168
383
  beginCodexAccountSelection: codexAccountSelectionForTurn(turnAdmissionLease),
169
384
  });
170
385
  if (!authCtx.accountId || authCtx.accountId === excludeAccountId) return null;
@@ -267,6 +482,7 @@ export async function handleResponsesCompact(
267
482
  logCtx: RequestLogContext,
268
483
  turnAdmissionLease?: AdmissionLease,
269
484
  admission?: DataPlaneAdmission,
485
+ options: HandleResponsesCompactOptions = {},
270
486
  ): Promise<Response> {
271
487
  let body: unknown;
272
488
  try {
@@ -326,6 +542,9 @@ export async function handleResponsesCompact(
326
542
  // consume that credential. See the longer note in core.ts resolveResponsesCodexAuth.
327
543
  const substituteMainCredential = admission?.source === "bearer"
328
544
  && route.codexAccountMode !== undefined;
545
+ const requestScopedMainCredential = route.codexAccountMode !== undefined
546
+ && !substituteMainCredential
547
+ && hasForwardableCodexBearer(req.headers, config);
329
548
  if (route.codexAccountMode === "direct" && !substituteMainCredential) {
330
549
  try { validateForwardAdmissionCredential(req.headers, config); }
331
550
  catch (err) {
@@ -365,17 +584,24 @@ export async function handleResponsesCompact(
365
584
  // headers would run compaction on the wrong account (or 401) whenever a pool account is
366
585
  // active for this thread while normal turns succeed.
367
586
  let compactProvider = route.provider;
368
- const headers = new Headers({ "content-type": "application/json" });
587
+ let headers = new Headers({ "content-type": "application/json" });
369
588
  try {
370
589
  if (route.codexAccountMode) {
371
590
  authCtx = await resolveCodexAuthContext(req.headers, config, route.codexAccountMode, {
372
591
  accountId: route.codexAccountId,
373
592
  modelId: selectedModelId,
374
593
  substituteMainCredentialForDirect: substituteMainCredential,
594
+ requestScopedMainCredential,
375
595
  beginCodexAccountSelection: codexAccountSelectionForTurn(turnAdmissionLease),
596
+ signal: req.signal,
597
+ nativeMainRefreshDependencies: options.nativeMainRefreshDependencies,
376
598
  });
377
599
  logCtx.accountLogLabel = codexAuthContextLogLabel(authCtx, config);
378
- const selected = materializeCodexUpstreamAuth(req.headers, authCtx, { substituteMainCredential });
600
+ const selected = await materializeCodexUpstreamAuthAsync(req.headers, authCtx, {
601
+ substituteMainCredential,
602
+ signal: req.signal,
603
+ nativeMainRefreshDependencies: options.nativeMainRefreshDependencies,
604
+ });
379
605
  compactProvider = applyCodexAuthContextToProvider(route.provider, authCtx, route.codexAccountMode);
380
606
  for (const name of FORWARD_HEADERS) {
381
607
  const value = selected.get(name);
@@ -466,6 +692,10 @@ export async function handleResponsesCompact(
466
692
  fixedAccount: ctx.fixedAccount,
467
693
  modelId: selectedModelId,
468
694
  probeLeaseId: codexProbeLeaseId(ctx),
695
+ // Fence a stored pool account's outcome on the credential the request was
696
+ // holding, so a 401 that lost a race with re-authentication cannot retire the
697
+ // replacement (#2887). Also covers the replay's own second 401.
698
+ ...(ctx.kind === "pool" ? { credentialGeneration: ctx.generation } : {}),
469
699
  probeQuotaScope: codexProbeQuotaScope(ctx),
470
700
  writerGeneration: ctx.kind === "pool" || ctx.kind === "main-pool"
471
701
  ? ctx.writerGeneration
@@ -514,6 +744,7 @@ export async function handleResponsesCompact(
514
744
  // actually happens, so every recorder call names the context that produced it.
515
745
  let outcomeCtx = authCtx;
516
746
  let upstream: Response;
747
+ let storedPool401ReplayAttempted = false;
517
748
  try {
518
749
  // Same connect timeout + keep-alive reset + transient-5xx recovery as /v1/responses —
519
750
  // compact hits the same ChatGPT host and must soft-avoid / clear affinity (#186).
@@ -543,12 +774,76 @@ export async function handleResponsesCompact(
543
774
  return formatErrorResponse(502, "upstream_error", "Failed to connect to compact upstream");
544
775
  }
545
776
 
777
+ if (
778
+ upstream.status === 401
779
+ && (authCtx.kind === "main-pool" || authCtx.kind === "pool")
780
+ && usesCodexForwardPoolAuth(authCtx, compactProvider)
781
+ && !req.signal.aborted
782
+ ) {
783
+ await upstream.body?.cancel().catch(() => undefined);
784
+ const poolAuthCtx = authCtx.kind === "pool" ? authCtx : undefined;
785
+ storedPool401ReplayAttempted = poolAuthCtx !== undefined;
786
+ const poolReplay = poolAuthCtx
787
+ ? await refreshPoolCompactContext({
788
+ req,
789
+ authCtx: poolAuthCtx,
790
+ provider: compactProvider,
791
+ codexAccountMode: route.codexAccountMode,
792
+ substituteMainCredential,
793
+ options,
794
+ })
795
+ : undefined;
796
+ const replay = poolReplay
797
+ ?? await refreshNativeMainCompactContext({
798
+ req,
799
+ authCtx,
800
+ provider: compactProvider,
801
+ codexAccountMode: route.codexAccountMode,
802
+ substituteMainCredential,
803
+ options,
804
+ });
805
+ if (!replay.ok) {
806
+ // A transient refresh failure must not retire the account; only a dead grant
807
+ // does, and only while the rejected credential is still the stored one (#2887).
808
+ if (poolAuthCtx) {
809
+ if (poolReplay && !poolReplay.ok && poolReplay.quarantine) {
810
+ recordCodexUpstreamOutcome(config, poolAuthCtx.accountId, 401, {
811
+ threadId: poolAuthCtx.affinityKey,
812
+ fixedAccount: poolAuthCtx.fixedAccount,
813
+ modelId: selectedModelId,
814
+ writerGeneration: poolAuthCtx.writerGeneration,
815
+ credentialGeneration: poolReplay.quarantineGeneration ?? poolAuthCtx.generation,
816
+ });
817
+ }
818
+ return replay.response;
819
+ }
820
+ recordCompactPoolOutcome(outcomeCtx, replay.response.status === 401 ? 401 : "connect_neutral");
821
+ return replay.response;
822
+ }
823
+ authCtx = replay.authCtx;
824
+ outcomeCtx = replay.authCtx;
825
+ compactProvider = replay.provider;
826
+ headers = replay.headers;
827
+ logCtx.accountLogLabel = codexAuthContextLogLabel(replay.authCtx, config);
828
+ try {
829
+ upstream = await sendCompactAttempt(compactProvider, headers, "single");
830
+ } catch (err) {
831
+ if (req.signal.aborted) {
832
+ recordCompactPoolOutcome(outcomeCtx, 499);
833
+ return formatErrorResponse(499, "client_cancelled", "Client cancelled compact request");
834
+ }
835
+ recordCompactPoolOutcome(outcomeCtx, classifyTransportFailureKind(err));
836
+ return formatErrorResponse(502, "upstream_error", "Failed to connect to compact upstream");
837
+ }
838
+ }
839
+
546
840
  // Bounded same-request alternate: the regular /v1/responses path already does this
547
841
  // (core.ts:319-423) and recognizes exactly 429/402. Without it a pool rejection
548
842
  // surfaces to the client, which retries the compact task OUTSIDE the logical request
549
843
  // — reporting exhausted retries while another pool account sat idle (#913).
550
844
  if (
551
845
  (upstream.status === 429 || upstream.status === 402)
846
+ && !storedPool401ReplayAttempted
552
847
  && usesCodexForwardPoolAuth(authCtx, route.provider)
553
848
  && !authCtx.fixedAccount
554
849
  && route.codexAccountMode
@@ -635,19 +930,56 @@ export async function handleResponsesCompact(
635
930
  upstream.headers.get("x-codex-tertiary-reset-at"),
636
931
  ].filter(Boolean);
637
932
  const buffered = await bufferCompactResponse(upstream, req.signal);
933
+ const bufferedErrorText = buffered.ok
934
+ ? ""
935
+ : await buffered.clone().text().catch(() => "");
936
+ const explicitQuotaStatus = buffered.status === 429 || buffered.status === 402;
937
+ const bodyInferredQuota = !buffered.ok
938
+ && !explicitQuotaStatus
939
+ && isRateLimitOrQuotaFailureMessage(bufferedErrorText);
940
+ const quotaFailure = explicitQuotaStatus || bodyInferredQuota;
638
941
  // Record pool health only after the body is fully delivered (or definitively failed).
639
942
  // A premature 200 would clear soft-avoid while the client still sees a buffer 502.
640
943
  if (buffered.status === 499) {
641
944
  recordCompactPoolOutcome(outcomeCtx, 499);
642
945
  return buffered;
643
946
  }
644
- // Always record the real upstream status: a local buffering failure after a
645
- // 200 upstream response must not soft-avoid a healthy account or rotate a thread.
646
- recordCompactPoolOutcome(outcomeCtx, upstream.status, { retryAfter, resetAt });
947
+ // A body-confirmed quota failure can arrive behind a generic 5xx. Record it as
948
+ // quota evidence; otherwise preserve the real upstream status so a local buffering
949
+ // failure after a 200 cannot soft-avoid a healthy account or rotate a thread.
950
+ recordCompactPoolOutcome(outcomeCtx, bodyInferredQuota ? 429 : upstream.status, { retryAfter, resetAt });
647
951
  // Lift usage and response metadata from the buffered upstream JSON into the
648
952
  // request log; the routed branch gets the same through handleResponses. The
649
953
  // synthetic buffer errors are not upstream bodies and stay uninspected.
650
- if (buffered.ok) inspectResponseLogJson(logCtx, await buffered.clone().text());
954
+ if (buffered.ok) {
955
+ inspectResponseLogJson(logCtx, await buffered.clone().text());
956
+ forgetCompactHandoffRoute(req);
957
+ } else if (quotaFailure && !storedPool401ReplayAttempted) {
958
+ const fallbackModel = compactHandoffRoute(req, raw.model);
959
+ if (fallbackModel && !req.signal.aborted) {
960
+ const fallbackReq = new Request(req.url, {
961
+ method: "POST",
962
+ headers: req.headers,
963
+ body: JSON.stringify({ ...raw, model: fallbackModel }),
964
+ signal: req.signal,
965
+ });
966
+ try {
967
+ const fallback = await handleResponsesCompact(
968
+ fallbackReq,
969
+ config,
970
+ logCtx,
971
+ turnAdmissionLease,
972
+ admission,
973
+ options,
974
+ );
975
+ if (fallback.ok || fallback.status === 499) return fallback;
976
+ await fallback.body?.cancel().catch(() => undefined);
977
+ } catch {
978
+ // The previous-model rejection is the authoritative failure when the
979
+ // remembered handoff route can no longer compact this thread.
980
+ }
981
+ }
982
+ }
651
983
  return buffered;
652
984
  } finally {
653
985
  releaseUpstreamHostAdmission(compactHostAdmissionLease);
@@ -740,9 +1072,11 @@ export async function handleResponsesCompact(
740
1072
  // The canonical Responses stream returns a real OpenAI-encrypted compaction item. OCX cannot
741
1073
  // and should not decrypt it; /responses/compact callers can consume that item directly.
742
1074
  if (accountGatedCompactWireModel) {
743
- return new Response(JSON.stringify({ output: compactionItems }), {
1075
+ const result = new Response(JSON.stringify({ output: compactionItems }), {
744
1076
  headers: { "Content-Type": "application/json" },
745
1077
  });
1078
+ rememberCompactHandoffRoute(req, raw.model);
1079
+ return result;
746
1080
  }
747
1081
  const encrypted = compactionItems[0]!.encrypted_content;
748
1082
  const decoded = typeof encrypted === "string" ? decodeCompactionSummary(encrypted) : null;
@@ -752,5 +1086,6 @@ export async function handleResponsesCompact(
752
1086
  }
753
1087
  const summary = decoded;
754
1088
  const output = buildCompactV1Output(extractCompactUserMessages(inputItems), summary);
1089
+ rememberCompactHandoffRoute(req, raw.model);
755
1090
  return new Response(JSON.stringify({ output }), { headers: { "Content-Type": "application/json" } });
756
1091
  }