@bitkyc08/opencodex 2.7.35 → 2.7.36

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 (80) hide show
  1. package/README.ja.md +1 -1
  2. package/README.ko.md +1 -1
  3. package/README.md +4 -2
  4. package/README.ru.md +1 -1
  5. package/README.zh-CN.md +1 -1
  6. package/bin/ocx.mjs +52 -0
  7. package/gui/dist/assets/index-BpX-hoSd.css +1 -0
  8. package/gui/dist/assets/index-ZmFopEYw.js +52 -0
  9. package/gui/dist/index.html +2 -2
  10. package/package.json +1 -1
  11. package/src/adapters/cursor/cursor-errors.ts +38 -1
  12. package/src/adapters/cursor/discovery.ts +1 -0
  13. package/src/adapters/cursor/effort-map.ts +1 -0
  14. package/src/adapters/cursor/live-models.ts +22 -5
  15. package/src/adapters/cursor/live-transport.ts +82 -7
  16. package/src/adapters/cursor/transport.ts +2 -0
  17. package/src/adapters/cursor.ts +5 -2
  18. package/src/adapters/openai-responses.ts +64 -1
  19. package/src/cli/doctor.ts +10 -0
  20. package/src/cli/help.ts +10 -0
  21. package/src/cli/index.ts +88 -9
  22. package/src/cli/internal-dispatch.ts +20 -0
  23. package/src/cli/status.ts +15 -4
  24. package/src/cli/tray-proxy.ts +52 -0
  25. package/src/codex/auth-api.ts +46 -5
  26. package/src/codex/autostart-health.ts +149 -0
  27. package/src/codex/catalog/aggregation.ts +268 -0
  28. package/src/codex/catalog/bundled.ts +188 -0
  29. package/src/codex/catalog/effort.ts +263 -0
  30. package/src/codex/catalog/metadata.ts +176 -0
  31. package/src/codex/catalog/parsing.ts +399 -0
  32. package/src/codex/catalog/provider-fetch.ts +609 -0
  33. package/src/codex/catalog/sync.ts +540 -0
  34. package/src/codex/catalog.ts +11 -2426
  35. package/src/codex/inject.ts +165 -3
  36. package/src/codex/shim.ts +141 -8
  37. package/src/codex/sync.ts +17 -2
  38. package/src/config.ts +23 -0
  39. package/src/lib/errors.ts +11 -0
  40. package/src/providers/antigravity-models.ts +33 -0
  41. package/src/providers/kiro-models.ts +2 -0
  42. package/src/providers/registry.ts +2 -2
  43. package/src/responses/state.ts +69 -6
  44. package/src/server/auth-cors.ts +3 -0
  45. package/src/server/management/agent-settings-routes.ts +536 -0
  46. package/src/server/management/combo-routes.ts +210 -0
  47. package/src/server/management/config-routes.ts +302 -0
  48. package/src/server/management/context.ts +21 -0
  49. package/src/server/management/logs-usage-routes.ts +176 -0
  50. package/src/server/management/model-routes.ts +253 -0
  51. package/src/server/management/oauth-account-routes.ts +301 -0
  52. package/src/server/management/provider-routes.ts +408 -0
  53. package/src/server/management/shared.ts +186 -0
  54. package/src/server/management-api.ts +23 -1806
  55. package/src/server/responses/collaboration.ts +300 -0
  56. package/src/server/responses/compact.ts +342 -0
  57. package/src/server/responses/core.ts +1498 -0
  58. package/src/server/responses/encrypted-payload.ts +231 -0
  59. package/src/server/responses/fetch-helpers.ts +157 -0
  60. package/src/server/responses.ts +9 -2172
  61. package/src/server/startup-action-control.ts +41 -0
  62. package/src/server/startup-health-cache.ts +100 -0
  63. package/src/server/windows-tray-control.ts +41 -0
  64. package/src/service.ts +171 -19
  65. package/src/tray/assets/opencodex-tray-offline.ico +0 -0
  66. package/src/tray/assets/opencodex-tray-online.ico +0 -0
  67. package/src/tray/assets/opencodex-tray-warning.ico +0 -0
  68. package/src/tray/assets/opencodex-tray.png +0 -0
  69. package/src/tray/windows-tray.ps1 +290 -0
  70. package/src/tray/windows.ts +628 -0
  71. package/src/types.ts +5 -0
  72. package/src/update/index.ts +43 -0
  73. package/src/update/job.ts +46 -0
  74. package/src/update/tray-update-plan.d.mts +18 -0
  75. package/src/update/tray-update-plan.mjs +38 -0
  76. package/src/usage/cost.ts +0 -0
  77. package/src/usage/expected-prices.ts +9 -2
  78. package/src/usage/summary.ts +42 -7
  79. package/gui/dist/assets/index-BunUANVE.js +0 -52
  80. package/gui/dist/assets/index-Sg-7L_oZ.css +0 -1
@@ -0,0 +1,1498 @@
1
+ import type { Server } from "bun";
2
+ import { bridgeToResponsesSSE, buildResponseJSON, formatErrorResponse, type ResponsesTerminalStatus } from "../../bridge";
3
+ import {
4
+ getConfigPath,
5
+ multiAgentGuidanceEnabled,
6
+ resolveEnvValue,
7
+ } from "../../config";
8
+ import { parseRequest } from "../../responses/parser";
9
+ import { buildCompactV1Output, COMPACT_PROMPT, decodeCompactionSummary, extractCompactUserMessages } from "../../responses/compaction";
10
+ import { FORWARD_HEADERS, sanitizeReasoningInputContent } from "../../adapters/openai-responses";
11
+ import { expandPreviousResponseInput, previousResponseProviderState, rememberResponseState } from "../../responses/state";
12
+ import { routeModel } from "../../router";
13
+ import {
14
+ advanceComboAfterFailure,
15
+ comboDefaultEffort,
16
+ comboFailureDecision,
17
+ comboIdFromRawBody,
18
+ concreteComboRequestBody,
19
+ getCombo,
20
+ isComboTargetInCooldown,
21
+ NoAvailableComboTargetsError,
22
+ noteComboSuccess,
23
+ parseRetryAfterMs,
24
+ pickComboTarget,
25
+ targetKey,
26
+ } from "../../combos";
27
+ import { isInjectionDebugEnabled } from "../../lib/debug-settings";
28
+ import { injectionDebugLog } from "../../lib/injection-debug-log";
29
+ import { modelInList, namespacedToolName } from "../../types";
30
+ import type { AdapterEvent, OcxConfig, OcxParsedRequest, OcxProviderConfig, OcxProviderContinuationState, OcxUsage } from "../../types";
31
+ import {
32
+ forceRefreshOAuthAccessSnapshot,
33
+ getOAuthCredentialApiBaseUrl,
34
+ getOAuthCredentialProjectId,
35
+ getValidAccessTokenSnapshot,
36
+ type OAuthAccessSnapshot,
37
+ UnsupportedOAuthProviderError,
38
+ } from "../../oauth";
39
+ import { buildWebSearchTool, planWebSearch, runWithWebSearch, shouldResolveOpenAiWebSearchSidecar } from "../../web-search";
40
+ import { describeImagesInPlace, planVisionSidecar, shouldResolveOpenAiVisionSidecar, stripImagesInPlace } from "../../vision";
41
+ import { createAdapterEventQueue, preflightAdapterEvents } from "../../adapters/run-turn-queue";
42
+ import {
43
+ applyCodexAuthContextToProvider,
44
+ CodexAccountCooldownError,
45
+ CodexAuthContextError,
46
+ CodexDirectAuthenticationError,
47
+ CodexPoolAuthenticationError,
48
+ CodexThreadAffinityExpiredError,
49
+ headersForCodexAuthContext,
50
+ isCodexAuthContextUsable,
51
+ resolveCodexAuthContext,
52
+ type CodexAuthContext,
53
+ } from "../../codex/auth-context";
54
+ import {
55
+ formatCodexProviderForLog,
56
+ recordCodexUpstreamOutcome,
57
+ type CodexUpstreamOutcome,
58
+ } from "../../codex/routing";
59
+ import { fetchWithResetRetry, fetchWithTransientRetry, applyUpstreamRecoveryInit } from "../../lib/upstream-retry";
60
+ import { ForwardAdmissionCredentialError, validateForwardAdmissionCredential } from "../auth-cors";
61
+ import { listOpenAiForwardSidecarCandidates, resolveFirstUsableOpenAiSidecar, type ResolvedOpenAiForwardSidecar } from "../../providers/openai-sidecar";
62
+ import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
63
+ import { slugsEquivalent } from "../../providers/slug-codec";
64
+ import { applyOpenAiVirtualModel, resolveOpenAiCompactModel } from "../../providers/openai-virtual-models";
65
+ import { isUsageDebugEnabled } from "../../usage/debug";
66
+ import { readJsonRequestBody, DecompressedBodyTooLargeError, UnsupportedContentEncodingError } from "../request-decompress";
67
+ import { resolveAdapter, resolveWireProtocolOverride } from "../adapter-resolve";
68
+ import { hasKeyPoolFailover, rotateProviderTransportOn429 } from "../../providers/key-failover";
69
+ import { shouldAttemptImageTierRetry } from "../image-retry";
70
+ import { resolveProviderTransport } from "../../providers/xai-transport";
71
+ import type { WsData } from "../ws-bridge";
72
+ import { registerTurn, trackStreamLifetime, unregisterTurn } from "../lifecycle";
73
+ import { redactSecretString } from "../../lib/redact";
74
+ import { readBoundedResponseBody } from "../../lib/bounded-body";
75
+ import { supportedLadderFor } from "../effort-policy";
76
+ import {
77
+ beginRequestAttempt,
78
+ catalogModelSupportsServiceTier,
79
+ finishRequestAttempt,
80
+ inspectResponseLogJson,
81
+ noteAttemptSend,
82
+ readConfiguredCodexServiceTier,
83
+ requestLogSpeedLabel,
84
+ sealRequestAttemptIdentity,
85
+ usageFromResponsesPayload,
86
+ type RequestLogContext,
87
+ } from "../request-log";
88
+ import type { AttemptRecoveryKind } from "../../usage/log";
89
+ import {
90
+ consumeForInspection,
91
+ consumeForResponseLogMetadata,
92
+ markNativePassthroughSseResponse,
93
+ relaySseWithFailedTail,
94
+ relayWithAbort,
95
+ sanitizePassthroughHeaders,
96
+ } from "../relay";
97
+ import { hasResponsesItemIdRepair, relaySseWithResponsesItemIdRepair } from "../responses-item-id-repair";
98
+ import type { EffectiveSubagentRoster, SpawnAgentSurface } from "../../codex/catalog";
99
+
100
+ import { buildToolBridgeMaps, collabSurface, injectDeveloperMessage, multiAgentGuidanceText } from "./collaboration";
101
+ import { hasUnreadableEncryptedAgentTask, looksLikeBackendCiphertext, sanitizeEncryptedContentInPlace } from "./encrypted-payload";
102
+ import { fetchWithHeaderTimeout, providerFetch, safeHostLabel } from "./fetch-helpers";
103
+
104
+ /**
105
+ * Adapters whose continuation state must survive Codex's store:false requests.
106
+ */
107
+ export function adapterNeedsForcedContinuation(name: string): boolean {
108
+ return name === "kiro" || name === "cursor";
109
+ }
110
+
111
+ export function sidecarOutcomeRecorder(
112
+ config: OcxConfig,
113
+ authCtx: CodexAuthContext,
114
+ threadId?: string | null,
115
+ ): ((outcome: CodexUpstreamOutcome) => void) | undefined {
116
+ return authCtx.kind === "pool" || authCtx.kind === "main-pool"
117
+ ? outcome => recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, { threadId })
118
+ : undefined;
119
+ }
120
+
121
+
122
+
123
+ export const DEFAULT_SHADOW_SOURCE_MODELS = ["gpt-5.4-mini", "gpt-5.6-luna"] as const;
124
+
125
+ export function isShadowSourceModel(modelId: string, configured?: unknown): boolean {
126
+ if (modelId.includes("/")) return false;
127
+ const configuredStrings = Array.isArray(configured)
128
+ ? configured.filter((v): v is string => typeof v === "string" && v.trim() !== "")
129
+ : [];
130
+ const prefixes = configuredStrings.length > 0 ? configuredStrings : DEFAULT_SHADOW_SOURCE_MODELS;
131
+ return prefixes.some(prefix => modelId.startsWith(prefix.trim()));
132
+ }
133
+
134
+
135
+
136
+ export function codexLogAccountId(authCtx: CodexAuthContext): string | null {
137
+ return authCtx.kind === "pool" || authCtx.kind === "main-pool" ? authCtx.accountId : null;
138
+ }
139
+
140
+
141
+
142
+ export function usesCodexForwardPoolAuth(
143
+ authCtx: CodexAuthContext,
144
+ provider: OcxProviderConfig,
145
+ ): authCtx is Extract<CodexAuthContext, { kind: "pool" | "main-pool" }> {
146
+ return (authCtx.kind === "pool" || authCtx.kind === "main-pool")
147
+ && provider.authMode === "forward" && provider.adapter === "openai-responses";
148
+ }
149
+
150
+
151
+
152
+ export function codexForwardTerminalOutcomeRecorder(
153
+ config: OcxConfig,
154
+ authCtx: CodexAuthContext,
155
+ provider: OcxProviderConfig,
156
+ logCtx?: RequestLogContext,
157
+ threadId?: string | null,
158
+ ): ((status: ResponsesTerminalStatus, httpStatusOverride?: number) => void) | undefined {
159
+ if (!usesCodexForwardPoolAuth(authCtx, provider)) return undefined;
160
+ return (status, httpStatusOverride) => {
161
+ if (status === "incomplete") {
162
+ // Normal limit/content-filter/stall terminal — the account served the
163
+ // request. Don't penalize account health; record success to clear any
164
+ // prior soft-avoid so a healthy account isn't stuck avoided.
165
+ recordCodexUpstreamOutcome(config, authCtx.accountId, 200, { threadId });
166
+ return;
167
+ }
168
+ // status === "completed" or "failed": use the semantic HTTP status derived
169
+ // from the terminal SSE error payload (httpStatusFromTerminalError in
170
+ // request-log inspection) instead of collapsing every non-completed terminal
171
+ // to 502. A 400 invalid_request_error must not soft-avoid the account or
172
+ // rebind threads — only genuine transport/5xx failures should trigger
173
+ // transient health recording.
174
+ // httpStatusOverride: the combo WS path inspects SSE payloads into the parent
175
+ // logCtx, but this recorder closes over the child logCtx. The caller passes
176
+ // the parent's terminalHttpStatus so the semantic status is not lost.
177
+ const outcome = status === "completed"
178
+ ? 200
179
+ : (httpStatusOverride ?? logCtx?.terminalHttpStatus ?? 502);
180
+ recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, { threadId });
181
+ };
182
+ }
183
+
184
+
185
+
186
+ export function decodeRequestErrorResponse(err: unknown, label: string): Response {
187
+ if (err instanceof UnsupportedContentEncodingError) {
188
+ return formatErrorResponse(415, "invalid_request_error", err.message);
189
+ }
190
+ if (err instanceof DecompressedBodyTooLargeError) {
191
+ return formatErrorResponse(413, "invalid_request_error", err.message);
192
+ }
193
+ console.warn(`[${label}] request body decode/parse failed: ${err instanceof Error ? `${err.name}: ${err.message}` : String(err)}`);
194
+ return formatErrorResponse(400, "invalid_request_error", "Invalid JSON body");
195
+ }
196
+
197
+
198
+
199
+ export function comboUnavailableResponse(message: string): Response {
200
+ return new Response(
201
+ JSON.stringify({
202
+ error: { message, type: "server_error", code: "combo_unavailable" },
203
+ }),
204
+ { status: 503, headers: { "Content-Type": "application/json" } },
205
+ );
206
+ }
207
+
208
+
209
+
210
+ export interface ConsumedComboFailure {
211
+ response: Response;
212
+ classificationText: string;
213
+ /** Valid numeric/date value used only for cooldown calculation. */
214
+ retryAfter?: string;
215
+ /** Reserved for 040 usage attribution without adding another body read. */
216
+ usage?: OcxUsage;
217
+ }
218
+
219
+
220
+
221
+ export interface HandleResponsesOptions {
222
+ forceEmptyResponseId?: boolean;
223
+ abortSignal?: AbortSignal;
224
+ /** One-shot TTFT callback: first non-empty model output observed (WP4). */
225
+ onFirstOutput?: () => void;
226
+ onCodexAuthContextResolved?: (context: CodexAuthContext | undefined) => void;
227
+ recordTerminalOutcomes?: boolean;
228
+ setTerminalOutcomeRecorder?: (recorder: ((status: ResponsesTerminalStatus, httpStatusOverride?: number) => void) | undefined) => void;
229
+ onNativePassthroughTerminal?: (status: ResponsesTerminalStatus) => void;
230
+ onNativePassthroughCancel?: () => void;
231
+ /** Internal recursion guard; callers outside this module must not set it. */
232
+ comboAttempt?: boolean;
233
+ /** 030-owned handoff when a child consumed the original failure under bounds. */
234
+ onConsumedComboFailure?: (failure: ConsumedComboFailure) => void;
235
+ }
236
+
237
+
238
+
239
+ export function clientCancelledResponse(): Response {
240
+ return formatErrorResponse(499, "client_cancelled", "Client cancelled request");
241
+ }
242
+
243
+
244
+
245
+ export function sanitizedRetryAfter(value: string | null, now: number): string | undefined {
246
+ const trimmed = value?.trim();
247
+ if (!trimmed || trimmed.length > 128) return undefined;
248
+ return parseRetryAfterMs(trimmed, now) !== undefined ? trimmed : undefined;
249
+ }
250
+
251
+
252
+
253
+ export async function consumeComboFailure(
254
+ response: Response,
255
+ signal?: AbortSignal,
256
+ now = Date.now(),
257
+ ): Promise<ConsumedComboFailure> {
258
+ const fallback = `Provider error ${response.status}`;
259
+ let classificationText = fallback;
260
+ let usage: OcxUsage | undefined;
261
+ try {
262
+ const body = await readBoundedResponseBody(response, { signal });
263
+ usage = usageFromComboFailureText(body.text);
264
+ if (body.displaySafe) {
265
+ const safeText = redactSecretString(body.text).slice(0, 500);
266
+ if (safeText) classificationText = safeText;
267
+ }
268
+ } catch (error) {
269
+ if (signal?.aborted) throw error;
270
+ classificationText = fallback;
271
+ }
272
+ const message = classificationText === fallback
273
+ ? fallback
274
+ : `${fallback}: ${classificationText}`;
275
+ const retryAfter = sanitizedRetryAfter(response.headers.get("retry-after"), now);
276
+ return {
277
+ response: formatErrorResponse(response.status, "upstream_error", message),
278
+ classificationText,
279
+ ...(retryAfter !== undefined ? { retryAfter } : {}),
280
+ ...(usage ? { usage } : {}),
281
+ };
282
+ }
283
+
284
+
285
+
286
+ export function usageFromComboFailureText(text: string): OcxUsage | undefined {
287
+ try {
288
+ const payload = JSON.parse(text) as Record<string, unknown>;
289
+ const nested = payload.response;
290
+ const source = nested && typeof nested === "object" && !Array.isArray(nested)
291
+ ? nested as Record<string, unknown>
292
+ : payload;
293
+ return usageFromResponsesPayload(source.usage);
294
+ } catch {
295
+ return undefined;
296
+ }
297
+ }
298
+
299
+
300
+
301
+ export function createChildPassthroughCallbackGate(options: HandleResponsesOptions) {
302
+ type Pending =
303
+ | { kind: "terminal"; status: ResponsesTerminalStatus }
304
+ | { kind: "cancel" };
305
+ let state: "pending" | "committed" | "discarded" = "pending";
306
+ let pending: Pending | undefined;
307
+ let accepted = false;
308
+ const publish = (value: Pending): void => {
309
+ if (value.kind === "terminal") options.onNativePassthroughTerminal?.(value.status);
310
+ else options.onNativePassthroughCancel?.();
311
+ };
312
+ const receive = (value: Pending): void => {
313
+ if (state === "discarded" || accepted) return;
314
+ accepted = true;
315
+ if (state === "committed") return publish(value);
316
+ pending ??= value;
317
+ };
318
+ return {
319
+ onTerminal: (status: ResponsesTerminalStatus) => receive({ kind: "terminal", status }),
320
+ onCancel: () => receive({ kind: "cancel" }),
321
+ commit: () => {
322
+ if (state !== "pending") return;
323
+ state = "committed";
324
+ if (pending) publish(pending);
325
+ pending = undefined;
326
+ },
327
+ discard: () => {
328
+ state = "discarded";
329
+ pending = undefined;
330
+ },
331
+ };
332
+ }
333
+
334
+
335
+
336
+ export function buildComboChildHeaders(parentHeaders: HeadersInit): Headers {
337
+ const childHeaders = new Headers(parentHeaders);
338
+ // Combo children re-serialize already-decoded JSON. Keeping transport metadata from
339
+ // the parent would make the child decoder treat plain JSON as compressed bytes.
340
+ childHeaders.delete("content-length");
341
+ childHeaders.delete("content-encoding");
342
+ return childHeaders;
343
+ }
344
+
345
+
346
+
347
+ export async function handleComboResponses(
348
+ req: Request,
349
+ rawBody: unknown,
350
+ comboId: string,
351
+ config: OcxConfig,
352
+ logCtx: RequestLogContext,
353
+ options: HandleResponsesOptions,
354
+ ): Promise<Response> {
355
+ const requestedModel = typeof (rawBody as { model?: unknown } | null)?.model === "string"
356
+ ? (rawBody as { model: string }).model
357
+ : `combo/${comboId}`;
358
+ Object.assign(logCtx, {
359
+ requestedModel,
360
+ model: requestedModel,
361
+ provider: "combo",
362
+ comboId,
363
+ });
364
+ const combo = getCombo(config, comboId);
365
+ if (!combo) {
366
+ return formatErrorResponse(404, "invalid_request_error", `Unknown combo: ${comboId}`);
367
+ }
368
+
369
+ const initialNow = Date.now();
370
+ let pick = pickComboTarget(config, comboId, {
371
+ eligible: target => !isComboTargetInCooldown(comboId, target, initialNow),
372
+ });
373
+ if (!pick) {
374
+ return comboUnavailableResponse(`No available targets for combo: ${comboId}`);
375
+ }
376
+
377
+ let lastFailure: Response | null = null;
378
+ while (pick) {
379
+ if (options.abortSignal?.aborted) return clientCancelledResponse();
380
+ const childLog: RequestLogContext = {
381
+ model: pick.target.model,
382
+ provider: pick.target.provider,
383
+ };
384
+ const targetRoute = routeModel(config, `${pick.target.provider}/${pick.target.model}`);
385
+ const childBody = concreteComboRequestBody(
386
+ rawBody,
387
+ pick.target,
388
+ comboDefaultEffort(config, comboId),
389
+ supportedLadderFor({ provider: targetRoute.provider, modelId: targetRoute.modelId }),
390
+ );
391
+ const childHeaders = buildComboChildHeaders(req.headers);
392
+ const childRequest = new Request(req.url, {
393
+ method: req.method,
394
+ headers: childHeaders,
395
+ body: JSON.stringify(childBody),
396
+ });
397
+ let resolvedAuth: CodexAuthContext | undefined;
398
+ let terminalRecorder: ((status: ResponsesTerminalStatus, httpStatusOverride?: number) => void) | undefined;
399
+ const started = Date.now();
400
+ const attempt = beginRequestAttempt(
401
+ (logCtx.attempts?.length ?? 0) + 1,
402
+ pick.target.provider,
403
+ pick.target.model,
404
+ config.providers[pick.target.provider]!.adapter,
405
+ );
406
+ childLog.activeAttempt = attempt;
407
+ let attemptRetained = false;
408
+ const retainCancelledAttempt = (): void => {
409
+ if (attemptRetained) return;
410
+ sealRequestAttemptIdentity(
411
+ attempt,
412
+ childLog.provider,
413
+ childLog.providerAdapter ?? attempt.adapter,
414
+ );
415
+ finishRequestAttempt(attempt, 499, Date.now() - started, childLog.usage);
416
+ (logCtx.attempts ??= []).push(attempt);
417
+ attemptRetained = true;
418
+ };
419
+ let consumedChildFailure: ConsumedComboFailure | undefined;
420
+ const callbackGate = createChildPassthroughCallbackGate(options);
421
+ let response: Response;
422
+ try {
423
+ response = await handleResponses(childRequest, config, childLog, {
424
+ ...options,
425
+ comboAttempt: true,
426
+ // Attempt-relative TTFT is recorded HERE (not via childLog.firstOutputMs — a later
427
+ // Object.assign(logCtx, childLog) would overwrite the request-relative value).
428
+ onFirstOutput: () => {
429
+ if (attempt.firstOutputMs === undefined) {
430
+ attempt.firstOutputMs = Math.max(0, Date.now() - started);
431
+ }
432
+ options.onFirstOutput?.();
433
+ },
434
+ onCodexAuthContextResolved: value => { resolvedAuth = value; },
435
+ setTerminalOutcomeRecorder: value => { terminalRecorder = value; },
436
+ onConsumedComboFailure: value => { consumedChildFailure = value; },
437
+ onNativePassthroughTerminal: callbackGate.onTerminal,
438
+ onNativePassthroughCancel: callbackGate.onCancel,
439
+ });
440
+ } catch (error) {
441
+ callbackGate.discard();
442
+ if (options.abortSignal?.aborted) {
443
+ retainCancelledAttempt();
444
+ return clientCancelledResponse();
445
+ }
446
+ throw error;
447
+ }
448
+
449
+ if (options.abortSignal?.aborted) {
450
+ callbackGate.discard();
451
+ retainCancelledAttempt();
452
+ return clientCancelledResponse();
453
+ }
454
+
455
+ if (response.ok) {
456
+ sealRequestAttemptIdentity(
457
+ attempt,
458
+ childLog.provider,
459
+ childLog.providerAdapter ?? attempt.adapter,
460
+ );
461
+ (logCtx.attempts ??= []).push(attempt);
462
+ attemptRetained = true;
463
+ noteComboSuccess(comboId, combo, pick.target);
464
+ Object.assign(logCtx, childLog, {
465
+ requestedModel,
466
+ model: requestedModel,
467
+ provider: "combo",
468
+ comboId,
469
+ attempts: logCtx.attempts,
470
+ activeAttempt: attempt,
471
+ activeAttemptStartedAt: started,
472
+ resolvedModel: childLog.resolvedModel ?? childLog.model,
473
+ });
474
+ options.onCodexAuthContextResolved?.(resolvedAuth);
475
+ options.setTerminalOutcomeRecorder?.(terminalRecorder);
476
+ callbackGate.commit();
477
+ return response;
478
+ }
479
+
480
+ callbackGate.discard();
481
+ if (response.status === 499) {
482
+ retainCancelledAttempt();
483
+ return clientCancelledResponse();
484
+ }
485
+ let failure: ConsumedComboFailure;
486
+ try {
487
+ failure = consumedChildFailure
488
+ ?? await consumeComboFailure(response, options.abortSignal);
489
+ } catch (error) {
490
+ if (options.abortSignal?.aborted) {
491
+ retainCancelledAttempt();
492
+ return clientCancelledResponse();
493
+ }
494
+ throw error;
495
+ }
496
+ if (options.abortSignal?.aborted) {
497
+ retainCancelledAttempt();
498
+ return clientCancelledResponse();
499
+ }
500
+ sealRequestAttemptIdentity(
501
+ attempt,
502
+ childLog.provider,
503
+ childLog.providerAdapter ?? attempt.adapter,
504
+ );
505
+ finishRequestAttempt(
506
+ attempt,
507
+ response.status,
508
+ Date.now() - started,
509
+ failure.usage,
510
+ );
511
+ (logCtx.attempts ??= []).push(attempt);
512
+ attemptRetained = true;
513
+ lastFailure = failure.response;
514
+ if (comboFailureDecision(response.status, failure.classificationText) === "stop") {
515
+ Object.assign(logCtx, childLog, {
516
+ requestedModel,
517
+ model: requestedModel,
518
+ provider: "combo",
519
+ comboId,
520
+ attempts: logCtx.attempts,
521
+ activeAttempt: undefined,
522
+ activeAttemptStartedAt: undefined,
523
+ });
524
+ return lastFailure;
525
+ }
526
+ console.warn(
527
+ `[combo] ${comboId}: ${targetKey(pick.target)} failed with ${response.status} after ${Date.now() - started}ms`,
528
+ );
529
+ pick = advanceComboAfterFailure(config, pick, {
530
+ retryAfter: failure.retryAfter,
531
+ now: Date.now(),
532
+ });
533
+ }
534
+ return lastFailure!;
535
+ }
536
+
537
+
538
+
539
+ export async function handleResponses(
540
+ req: Request,
541
+ config: OcxConfig,
542
+ logCtx: RequestLogContext,
543
+ options: HandleResponsesOptions = {},
544
+ ): Promise<Response> {
545
+ let body: unknown;
546
+ try {
547
+ body = await readJsonRequestBody(req);
548
+ } catch (err) {
549
+ return decodeRequestErrorResponse(err, "responses");
550
+ }
551
+ const comboId = !options.comboAttempt ? comboIdFromRawBody(body, config) : null;
552
+ if (comboId && Object.hasOwn(config.combos ?? {}, comboId)) {
553
+ return handleComboResponses(req, body, comboId, config, logCtx, options);
554
+ }
555
+ const originalBody = body;
556
+ body = expandPreviousResponseInput(body);
557
+ const previousResponseInputExpanded = body !== originalBody;
558
+ const unreadableEncryptedAgentTask = hasUnreadableEncryptedAgentTask(
559
+ (body as { input?: unknown } | undefined)?.input,
560
+ );
561
+
562
+ // Spawn-message compatibility (both directions): agent_message task payloads ride in
563
+ // encrypted_content slots as plaintext. Rewrite them to input_text on the RAW body BEFORE
564
+ // parsing so every consumer sees the payload: parseRequest (routed/translated providers read
565
+ // the parsed messages) and the native passthrough (_rawBody is this same object, serialized
566
+ // verbatim). Genuine backend ciphertext is left byte-identical (looksLikeBackendCiphertext).
567
+ {
568
+ const rewritten = sanitizeEncryptedContentInPlace(
569
+ (body as { input?: unknown } | undefined)?.input,
570
+ );
571
+ if (rewritten > 0)
572
+ console.warn(
573
+ `[opencodex] rewrote ${rewritten} plaintext encrypted_content part(s) to input_text (spawn-message compatibility)`,
574
+ );
575
+ }
576
+
577
+ let parsed;
578
+ try {
579
+ parsed = parseRequest(body);
580
+ if (previousResponseInputExpanded) parsed._previousResponseInputExpanded = true;
581
+ parsed._providerContinuation = previousResponseProviderState(parsed.previousResponseId);
582
+ parsed._cursorConversationId = parsed._providerContinuation?.cursor?.conversationId;
583
+ } catch (err) {
584
+ return formatErrorResponse(400, "invalid_request_error", err instanceof Error ? err.message : String(err));
585
+ }
586
+ logCtx.requestedModel = parsed.modelId;
587
+ logCtx.requestedEffort = parsed.options.reasoning;
588
+ logCtx.requestedServiceTier = parsed.options.serviceTier;
589
+ logCtx.requestedSpeedLabel = requestLogSpeedLabel(parsed.options.serviceTier);
590
+ logCtx.configuredServiceTier = readConfiguredCodexServiceTier();
591
+ logCtx.configuredSpeedLabel = requestLogSpeedLabel(logCtx.configuredServiceTier);
592
+
593
+ // Shadow call intercept: rewrite Codex's hard-coded helper calls
594
+ // (gpt-5.4-mini on older clients, gpt-5.6-luna on 0.145.0+)
595
+ const _sci = config.shadowCallIntercept;
596
+ if (_sci?.enabled && _sci.model && isShadowSourceModel(parsed.modelId, _sci.sourceModels)) {
597
+ const _sciOriginal = parsed.modelId;
598
+ parsed.modelId = _sci.model;
599
+ if (parsed._rawBody && typeof parsed._rawBody === "object") {
600
+ (parsed._rawBody as { model?: string }).model = _sci.model;
601
+ }
602
+ // Force effort to low for shadow/helper calls (matching upstream behavior)
603
+ parsed.options.reasoning = "low";
604
+ if (parsed._rawBody && typeof parsed._rawBody === "object") {
605
+ (parsed._rawBody as Record<string, unknown>).reasoning = { effort: "low" };
606
+ }
607
+ (logCtx as unknown as Record<string, unknown>).shadowCallRewrittenFrom = _sciOriginal;
608
+ }
609
+
610
+ let route;
611
+ try {
612
+ route = routeModel(config, parsed.modelId);
613
+ } catch (err) {
614
+ if (err instanceof NoAvailableComboTargetsError) {
615
+ return comboUnavailableResponse(err.message);
616
+ }
617
+ return formatErrorResponse(404, "invalid_request_error", err instanceof Error ? err.message : String(err));
618
+ }
619
+
620
+ // The canonical ChatGPT backend can decrypt its V2 Fernet task tokens; routed
621
+ // providers cannot. Reject the raw-input classification before adapter construction
622
+ // or provider dispatch so an unreadable worker task cannot trigger a cost storm.
623
+ if (!isCanonicalOpenAiForwardProvider(route.provider) && unreadableEncryptedAgentTask) {
624
+ return formatErrorResponse(
625
+ 400,
626
+ "invalid_request_error",
627
+ "Routed V2 worker task is encrypted for the native ChatGPT backend and cannot be read by the selected provider. Use plaintext V2 agent-message delivery or select a native ChatGPT model.",
628
+ );
629
+ }
630
+
631
+ // Apply the routed model id upstream: routing may strip a "<provider>/" namespace
632
+ // (e.g. "opencode-go/deepseek-v4-pro" → "deepseek-v4-pro"). Adapters read parsed.modelId,
633
+ // and the passthrough adapter serializes _rawBody, so rewrite both.
634
+ if (route.modelId !== parsed.modelId) {
635
+ if (parsed._rawBody && typeof parsed._rawBody === "object") {
636
+ (parsed._rawBody as { model?: string }).model = route.modelId;
637
+ }
638
+ parsed.modelId = route.modelId;
639
+ }
640
+ logCtx.model = route.modelId;
641
+ logCtx.provider = route.providerName;
642
+ logCtx.providerAdapter = route.provider.adapter;
643
+
644
+ // Virtual model rewriting: Pro aliases → base model + reasoning.mode="pro".
645
+ // Must run before effort caps/native clamps so the base model gets correct limits.
646
+ applyOpenAiVirtualModel(parsed, route, logCtx);
647
+
648
+ // Fast mode override: when config.fastMode is explicitly set, inject or strip
649
+ // service_tier for OpenAI-routed models. Undefined = passthrough (client decides).
650
+ if (config.fastMode !== undefined && route.provider.adapter === "openai-responses") {
651
+ const tier = config.fastMode ? "priority" : undefined;
652
+ if (parsed._rawBody && typeof parsed._rawBody === "object") {
653
+ if (tier) (parsed._rawBody as Record<string, unknown>).service_tier = tier;
654
+ else delete (parsed._rawBody as Record<string, unknown>).service_tier;
655
+ }
656
+ parsed.options.serviceTier = tier;
657
+ }
658
+
659
+ // Multi-agent guidance shim: codex-rs emits its Proactive delegation developer
660
+ // message only on the v2 surface. The proxy fills the gaps: the Proactive text
661
+ // for v1 collab surfaces at the top tier (no model designation on v1), and the
662
+ // sub-agent model/roster designation plus fork_turns override rules on v2.
663
+ // The surface is judged from the request's own tool list. Runs BEFORE the
664
+ // mock-max clamp below so the synthetic top tier (ultra arrives as max on the
665
+ // codex wire) is still visible. Both request shapes are rewritten.
666
+ {
667
+ const guidance = await multiAgentGuidanceText(parsed, {
668
+ multiAgentGuidanceEnabled: config.multiAgentGuidanceEnabled,
669
+ injectionModel: config.injectionModel,
670
+ injectionEffort: config.injectionEffort,
671
+ subagentModels: config.subagentModels,
672
+ injectionPrompt: config.injectionPrompt,
673
+ });
674
+ if (guidance) {
675
+ injectDeveloperMessage(parsed, guidance);
676
+ if (isInjectionDebugEnabled()) injectionDebugLog(`[opencodex] ${route.modelId}: multi-agent guidance injected (surface=${collabSurface(parsed)}, guidanceEnabled=${multiAgentGuidanceEnabled(config)}, ${guidance.length} chars)`);
677
+ } else if (isInjectionDebugEnabled() && collabSurface(parsed) !== null) {
678
+ injectionDebugLog(`[opencodex] ${route.modelId}: collab surface=${collabSurface(parsed)}, guidance silent (effort=${parsed.options.reasoning ?? "unset"}, injectionModel=${config.injectionModel ?? "unset"})`);
679
+ }
680
+ }
681
+
682
+ // Hard effort caps (effortCap / subagentEffortCap): enforcement companion to the advisory
683
+ // injection above — spawn-arg prompting cannot stop codex-rs from inheriting the parent's
684
+ // ultra-tier default on bare spawns (see src/server/effort-policy.ts). Runs BEFORE the
685
+ // mock-max clamp so a capped effort is what nativeness clamping then validates; rewrites
686
+ // both request shapes (same dual-write contract as the clamp below).
687
+ // GATE: v2 feature only (effortCapAppliesTo) — v2-surface main turns plus header-marked
688
+ // child turns admitted regardless of tool surface (depth-limited leaves carry no collab
689
+ // tools while shallower children do, so tool sniffing alone would cap siblings
690
+ // inconsistently); multiAgentMode "v1" disables caps entirely; compaction turns bypass
691
+ // caps so routed compaction matches native /v1/responses/compact (which never enters
692
+ // handleResponses).
693
+ {
694
+ const { applyEffortCap, effortCapAppliesTo, supportedLadderFor } = await import("../effort-policy");
695
+ const surface = collabSurface(parsed);
696
+ if (effortCapAppliesTo(surface, req.headers, config, parsed._compactionRequest === true)) {
697
+ const capped = applyEffortCap(parsed, req.headers, config, supportedLadderFor(route));
698
+ if (capped) {
699
+ logCtx.requestedEffort = `${capped.from}->${capped.to}`;
700
+ if (isInjectionDebugEnabled()) {
701
+ injectionDebugLog(`[opencodex] ${route.modelId}: effort cap applied (${capped.from} -> ${capped.to}, ${capped.subagent ? "sub-agent" : "main"} turn)`);
702
+ }
703
+ }
704
+ } else if (isInjectionDebugEnabled() && (config.effortCap || config.subagentEffortCap)) {
705
+ injectionDebugLog(`[opencodex] ${route.modelId}: effort cap skipped (surface=${surface ?? "none"}, v2 feature only)`);
706
+ }
707
+ }
708
+
709
+ // Mock-max clamp: native models whose real ladder stops below max (gpt-5.5/5.4/…)
710
+ // receive `max` when the user picks Ultra (codex converts ultra->max client-side).
711
+ // Clamp to the model's highest real effort BEFORE any adapter — the ChatGPT
712
+ // passthrough serializes _rawBody verbatim, so both shapes must be rewritten.
713
+ // GUARD: judge nativeness by BOTH the originally requested id (logCtx.requestedModel)
714
+ // and the resolved provider identity. Routing strips the "<provider>/" namespace, and
715
+ // some third-party providers expose bare `defaultModel` selectors, so route.modelId
716
+ // alone can make a routed model masquerade as an off-snapshot native. Only the
717
+ // canonical built-in ChatGPT forward provider should receive the native clamp.
718
+ {
719
+ const requestedModelId = logCtx.requestedModel ?? route.modelId;
720
+ const { nativeEffortClamp, shouldApplyNativeEffortClamp } = await import("../../codex/catalog");
721
+ const clamped = shouldApplyNativeEffortClamp(route.providerName, route.provider, requestedModelId)
722
+ ? nativeEffortClamp(route.modelId, parsed.options.reasoning)
723
+ : null;
724
+ if (clamped) {
725
+ parsed.options.reasoning = clamped;
726
+ const raw = parsed._rawBody as { reasoning?: { effort?: string } } | undefined;
727
+ if (raw?.reasoning && typeof raw.reasoning === "object") raw.reasoning.effort = clamped;
728
+ logCtx.requestedEffort = `${logCtx.requestedEffort ?? "max"}->${clamped}`;
729
+ }
730
+ }
731
+ logCtx.modelSupportsServiceTier = catalogModelSupportsServiceTier(
732
+ route.modelId,
733
+ logCtx.requestedServiceTier ?? logCtx.configuredServiceTier,
734
+ );
735
+
736
+ let authCtx: CodexAuthContext = { kind: "main", accountId: null };
737
+ let selectedForwardHeaders: Headers;
738
+ try {
739
+ if (route.codexAccountMode === "direct") validateForwardAdmissionCredential(req.headers, config);
740
+ if (route.codexAccountMode) {
741
+ authCtx = await resolveCodexAuthContext(req.headers, config, route.codexAccountMode);
742
+ options.onCodexAuthContextResolved?.(authCtx);
743
+ } else {
744
+ options.onCodexAuthContextResolved?.(undefined);
745
+ }
746
+ selectedForwardHeaders = headersForCodexAuthContext(req.headers, authCtx);
747
+ } catch (err) {
748
+ if (err instanceof CodexAccountCooldownError) {
749
+ return formatErrorResponse(429, "rate_limit_error", "Selected Codex account is cooling down");
750
+ }
751
+ if (err instanceof CodexThreadAffinityExpiredError) {
752
+ return formatErrorResponse(409, "invalid_request_error", "Codex thread account affinity expired; start a new session");
753
+ }
754
+ if (err instanceof CodexAuthContextError) {
755
+ const safeAccountLabel = formatCodexProviderForLog(route.providerName, err.accountId, config);
756
+ console.error(`[codex-auth] Pool account ${safeAccountLabel} token failed; reauthentication required`);
757
+ return formatErrorResponse(401, "authentication_error", "Selected Codex account needs reauthentication");
758
+ }
759
+ if (err instanceof CodexPoolAuthenticationError) {
760
+ return formatErrorResponse(401, "authentication_error", err.message);
761
+ }
762
+ if (err instanceof CodexDirectAuthenticationError) {
763
+ return formatErrorResponse(401, "authentication_error", err.message);
764
+ }
765
+ if (err instanceof ForwardAdmissionCredentialError) {
766
+ return formatErrorResponse(401, "authentication_error", err.message);
767
+ }
768
+ throw err;
769
+ }
770
+ if (!isCodexAuthContextUsable(authCtx, config)) {
771
+ return formatErrorResponse(401, "authentication_error", "Selected Codex account needs reauthentication");
772
+ }
773
+ route.provider = applyCodexAuthContextToProvider(route.provider, authCtx, route.codexAccountMode);
774
+ logCtx.provider = formatCodexProviderForLog(route.providerName, codexLogAccountId(authCtx), config);
775
+
776
+ // OAuth providers: swap in a fresh access token (auto-refreshed) as the Bearer key, so the
777
+ // existing openai-chat / anthropic adapters authenticate with no change.
778
+ const isOAuth401ReplayProvider = (route.providerName === "xai" || route.providerName === "github-copilot" || route.providerName === "kiro")
779
+ && route.provider.authMode === "oauth";
780
+ let sentOAuthSnapshot: OAuthAccessSnapshot | undefined;
781
+ if (route.provider.authMode === "oauth") {
782
+ try {
783
+ const resolved = await getValidAccessTokenSnapshot(route.providerName);
784
+ if (isOAuth401ReplayProvider) sentOAuthSnapshot = resolved;
785
+ route.provider = { ...route.provider, apiKey: resolved.accessToken };
786
+ // Antigravity (cloud-code-assist) needs the discovered Cloud Code Assist project id in the
787
+ // CCA envelope; the server injects only the bare token, so pull project from the credential.
788
+ if (route.provider.googleMode === "cloud-code-assist" && !route.provider.project) {
789
+ const projectId = getOAuthCredentialProjectId(route.providerName);
790
+ if (projectId) route.provider = { ...route.provider, project: projectId };
791
+ }
792
+ } catch (err) {
793
+ if (err instanceof UnsupportedOAuthProviderError) {
794
+ return formatErrorResponse(
795
+ 400,
796
+ "invalid_request_error",
797
+ `${err.message}. Remove or reconfigure provider '${route.providerName}' in ${getConfigPath()}.`,
798
+ );
799
+ }
800
+ return formatErrorResponse(401, "authentication_error", err instanceof Error ? err.message : String(err));
801
+ }
802
+ }
803
+ route.provider = resolveProviderTransport(
804
+ route.providerName,
805
+ route.provider,
806
+ parsed.options.promptCacheKey,
807
+ route.providerName === "github-copilot" ? getOAuthCredentialApiBaseUrl(route.providerName) : undefined,
808
+ );
809
+ const adapterProvider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider);
810
+ const adapter = resolveAdapter(adapterProvider, config.cacheRetention);
811
+ logCtx.providerAdapter = adapter.name;
812
+ sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, adapter.name);
813
+ const isPassthrough = "passthrough" in adapter && !!adapter.passthrough;
814
+
815
+ if (adapter.name === "kiro" && parsed.previousResponseId && !parsed._previousResponseInputExpanded) {
816
+ return formatErrorResponse(
817
+ 400,
818
+ "invalid_request_error",
819
+ "Kiro continuation state is missing; start a new session instead of reusing this previous_response_id.",
820
+ );
821
+ }
822
+
823
+ let openAiSidecar: ResolvedOpenAiForwardSidecar | undefined;
824
+ const needsOpenAiVision = shouldResolveOpenAiVisionSidecar(config, route.provider, route.modelId, parsed);
825
+ const needsOpenAiSearch = shouldResolveOpenAiWebSearchSidecar(config, parsed, isPassthrough);
826
+ if (needsOpenAiVision || needsOpenAiSearch) {
827
+ try {
828
+ openAiSidecar = await resolveFirstUsableOpenAiSidecar(
829
+ listOpenAiForwardSidecarCandidates(config),
830
+ req.headers,
831
+ config,
832
+ );
833
+ } catch (err) {
834
+ // Sidecars are optional helpers for an otherwise independent routed turn.
835
+ // An unavailable/cooling/expired Multi credential disables the helper; it
836
+ // must not turn a valid routed-provider request into a Codex-auth failure.
837
+ if (
838
+ !(err instanceof CodexPoolAuthenticationError)
839
+ && !(err instanceof CodexAuthContextError)
840
+ && !(err instanceof CodexAccountCooldownError)
841
+ && !(err instanceof CodexThreadAffinityExpiredError)
842
+ ) throw err;
843
+ }
844
+ }
845
+
846
+ // Vision sidecar: the routed model can't see images (provider.noVisionModels). Describe each
847
+ // attached image through the selected sidecar backend and replace it with text BEFORE the main
848
+ // call, so the text-only model can reason about it.
849
+ const visionPlan = planVisionSidecar(config, route.provider, route.modelId, parsed, openAiSidecar);
850
+ const recordSidecarOutcome = openAiSidecar?.recordOutcome;
851
+ if (visionPlan) {
852
+ await describeImagesInPlace(parsed, visionPlan, openAiSidecar?.headers ?? selectedForwardHeaders, options.abortSignal, recordSidecarOutcome);
853
+ } else if (modelInList(route.provider.noVisionModels, route.modelId)) {
854
+ // Sidecar-covered model but NO plan (no forward provider / missing forwarded auth / sidecar
855
+ // disabled): fail closed — never forward raw images to a text-only upstream.
856
+ stripImagesInPlace(parsed);
857
+ }
858
+
859
+ const recordTerminalOutcomes = options.recordTerminalOutcomes !== false;
860
+
861
+ const continuationStateForResponse = (
862
+ emitted?: OcxProviderContinuationState,
863
+ ): OcxProviderContinuationState | undefined => {
864
+ const cursorConversationId = parsed._cursorConversationId;
865
+ const inherited = parsed._providerContinuation;
866
+ if (!emitted && !inherited && !cursorConversationId) return undefined;
867
+ return {
868
+ ...(inherited ?? {}),
869
+ ...(emitted ?? {}),
870
+ ...((inherited?.kiro || emitted?.kiro)
871
+ ? { kiro: { ...(inherited?.kiro ?? {}), ...(emitted?.kiro ?? {}) } }
872
+ : {}),
873
+ ...(cursorConversationId
874
+ ? {
875
+ cursor: {
876
+ ...(inherited?.cursor ?? {}),
877
+ ...(emitted?.cursor ?? {}),
878
+ conversationId: cursorConversationId,
879
+ },
880
+ }
881
+ : {}),
882
+ };
883
+ };
884
+
885
+ // Remote compaction v2 on a ROUTED model: Codex sent `compaction_trigger` and requires exactly
886
+ // one `{type:"compaction"}` output item (codex-rs compact_remote_v2.rs). Passthrough handles it
887
+ // natively upstream; here we run the routed model as a plain summarizer — no tools, no web-search
888
+ // sidecar — and the bridge appends the synthetic compaction item (src/responses/compaction.ts).
889
+ const routedCompaction = parsed._compactionRequest === true && !("passthrough" in adapter && adapter.passthrough);
890
+ if (routedCompaction) {
891
+ delete parsed.context.tools;
892
+ delete parsed._webSearch;
893
+ delete parsed.options.toolChoice;
894
+ delete parsed.options.parallelToolCalls;
895
+ parsed.context.messages.push({ role: "user", content: COMPACT_PROMPT, timestamp: Date.now() });
896
+ }
897
+
898
+ if ("passthrough" in adapter && adapter.passthrough) {
899
+ // Local continuation cache for the ChatGPT passthrough. Codex WS turns chain with
900
+ // previous_response_id, ocx converts them to internal HTTP requests, and the ChatGPT Codex
901
+ // REST backend rejects the parameter — the adapter strips it in forward mode, so the ONLY
902
+ // way a chained turn keeps its earlier context is the local replay expansion. Record
903
+ // completed passthrough responses (force bypasses Codex's blanket store:false) so the next
904
+ // turn's expansion hits. Never record a body whose own previous_response_id failed to
905
+ // expand: its input is a delta, and storing it would replay a truncated conversation.
906
+ // Compaction turns are excluded: _rawBody still carries the full pre-compaction history and
907
+ // recording it would let a later expansion rehydrate the chain Codex just replaced.
908
+ const passthroughRecordEligible = parsed._compactionRequest !== true
909
+ && (!parsed.previousResponseId || parsed._previousResponseInputExpanded === true);
910
+ const rememberPassthroughResponse = passthroughRecordEligible
911
+ ? (response: { id?: unknown; output?: unknown; status?: unknown }) =>
912
+ rememberResponseState(parsed._rawBody, response, undefined, { force: true })
913
+ : undefined;
914
+ if (parsed.previousResponseId && !parsed._previousResponseInputExpanded) {
915
+ console.warn(
916
+ `[responses] previous_response_id ${parsed.previousResponseId} not found in local replay state `
917
+ + `(model ${parsed.modelId}); forwarding without it — earlier turns may be missing from this request`,
918
+ );
919
+ }
920
+ const request = await adapter.buildRequest(parsed, { headers: selectedForwardHeaders });
921
+ const passthroughEstimate = typeof request.usageLog?.inputTokens === "number"
922
+ ? request.usageLog.inputTokens
923
+ : undefined;
924
+ if (passthroughEstimate !== undefined) {
925
+ logCtx.usageLogInputTokens = passthroughEstimate;
926
+ }
927
+ // Abort the upstream if the client disconnects. A directly-relayed body does not propagate the
928
+ // consumer's cancel to a signalled fetch, so we pass the signal and relay through relayWithAbort,
929
+ // whose cancel() aborts the upstream — preventing leaked connections (RC2, passthrough path).
930
+ const upstream = new AbortController();
931
+ linkAbortSignal(upstream, options.abortSignal);
932
+ const connectMs = config.connectTimeoutMs ?? 200_000;
933
+ let upstreamResponse: Response;
934
+ try {
935
+ // Transient-5xx pre-stream retry (devlog/_plan/260716_claudecode_hardening/010):
936
+ // the ChatGPT backend emits transient 502/520s that an immediate retry absorbs.
937
+ // Body is a replayable string; nothing has streamed to the client yet.
938
+ upstreamResponse = await fetchWithTransientRetry(
939
+ recovery => {
940
+ noteAttemptSend(logCtx.activeAttempt, passthroughEstimate, recovery);
941
+ return fetchWithHeaderTimeout(request.url, applyUpstreamRecoveryInit({
942
+ method: request.method,
943
+ headers: request.headers,
944
+ body: request.body,
945
+ }, recovery), upstream.signal, connectMs, parsed.stream, providerFetch(route.provider));
946
+ },
947
+ { abortSignal: upstream.signal, label: safeHostLabel(request.url) },
948
+ );
949
+ } catch (err) {
950
+ upstream.abort();
951
+ if (options.abortSignal?.aborted) return clientCancelledResponse();
952
+ const outcome = err instanceof Error && err.name === "TimeoutError" ? "timeout" : "connect_error";
953
+ if (usesCodexForwardPoolAuth(authCtx, route.provider)) {
954
+ recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, {
955
+ threadId: req.headers.get("x-codex-parent-thread-id"),
956
+ });
957
+ }
958
+ const msg = outcome === "timeout"
959
+ ? `Provider connect timeout after ${connectMs}ms`
960
+ : `Provider unreachable: ${err instanceof Error ? err.message : String(err)}`;
961
+ return formatErrorResponse(502, "upstream_error", msg);
962
+ }
963
+ const headers = sanitizePassthroughHeaders(upstreamResponse.headers);
964
+ const resolvedModel = headers.get("openai-model")?.trim();
965
+ if (resolvedModel) logCtx.resolvedModel = resolvedModel;
966
+ if (isUsageDebugEnabled()) {
967
+ const upstreamContentType = upstreamResponse.headers.get("content-type");
968
+ if (upstreamContentType) logCtx.usageDebugContentType = upstreamContentType;
969
+ }
970
+ // The chatgpt backend may omit Content-Type on SSE responses. Fall back to
971
+ // treating a successful body as SSE when the caller requested streaming.
972
+ const passthroughCt = headers.get("content-type")?.toLowerCase();
973
+ const isEventStream = passthroughCt?.includes("text/event-stream")
974
+ || (upstreamResponse.ok && !!upstreamResponse.body && !passthroughCt && parsed.stream);
975
+ const terminalRecorder = codexForwardTerminalOutcomeRecorder(
976
+ config,
977
+ authCtx,
978
+ route.provider,
979
+ logCtx,
980
+ req.headers.get("x-codex-parent-thread-id"),
981
+ );
982
+ const terminalBodyWillRecord = !!terminalRecorder && upstreamResponse.ok && isEventStream;
983
+ // Capture quota from upstream response for multi-account tracking
984
+ if (usesCodexForwardPoolAuth(authCtx, route.provider)) {
985
+ // primary was the 5h window; it now carries weekly data for GPT plans.
986
+ // Prefer primary when present, fall back to secondary for compatibility.
987
+ const primaryRaw = upstreamResponse.headers.get("x-codex-primary-used-percent");
988
+ const secondaryRaw = upstreamResponse.headers.get("x-codex-secondary-used-percent");
989
+ const weeklyRaw = primaryRaw ?? secondaryRaw;
990
+ const monthlyRaw = upstreamResponse.headers.get("x-codex-tertiary-used-percent");
991
+ const primaryResetRaw = upstreamResponse.headers.get("x-codex-primary-reset-at");
992
+ const secondaryResetRaw = upstreamResponse.headers.get("x-codex-secondary-reset-at");
993
+ const weeklyResetRaw = primaryRaw ? primaryResetRaw : secondaryResetRaw;
994
+ const monthlyResetRaw = upstreamResponse.headers.get("x-codex-tertiary-reset-at");
995
+ const retryAfterRaw = upstreamResponse.headers.get("retry-after");
996
+ if (weeklyRaw || monthlyRaw) {
997
+ const { updateAccountQuota } = await import("../../codex/auth-api");
998
+ updateAccountQuota(
999
+ authCtx.accountId,
1000
+ weeklyRaw,
1001
+ weeklyResetRaw,
1002
+ monthlyRaw,
1003
+ monthlyResetRaw,
1004
+ );
1005
+ }
1006
+ if (terminalBodyWillRecord) {
1007
+ options.setTerminalOutcomeRecorder?.((status, httpStatusOverride) => {
1008
+ terminalRecorder(status, httpStatusOverride);
1009
+ options.onNativePassthroughTerminal?.(status);
1010
+ });
1011
+ } else {
1012
+ recordCodexUpstreamOutcome(config, authCtx.accountId, upstreamResponse.status, {
1013
+ retryAfter: retryAfterRaw,
1014
+ resetAt: [primaryResetRaw, secondaryResetRaw, monthlyResetRaw].filter(Boolean),
1015
+ threadId: req.headers.get("x-codex-parent-thread-id"),
1016
+ });
1017
+ }
1018
+ }
1019
+
1020
+ // Bun#32111 workaround: passthrough SSE uses tee()+native relay to avoid the
1021
+ // async-pull segfault on Windows. Branch[0] goes directly to the Response (Bun
1022
+ // native relay, never enters JS Sink.write); branch[1] is consumed in the
1023
+ // background for terminal-outcome/quota inspection only.
1024
+ if (upstreamResponse.ok && isEventStream && upstreamResponse.body) {
1025
+ const [nativeBody, inspectBody] = upstreamResponse.body.tee();
1026
+ const repairConfig = route.provider.responsesItemIdRepair;
1027
+ const turnAc = new AbortController();
1028
+ linkAbortSignal(upstream, turnAc.signal);
1029
+ registerTurn(turnAc);
1030
+ if (recordTerminalOutcomes) {
1031
+ // A real terminal was parsed from the (teed) inspection stream — record it as the outcome
1032
+ // even if the client has already disconnected: the turn genuinely reached that terminal, so
1033
+ // it must log as completed/failed, not be dropped or downgraded to a cancel (#44). A pure
1034
+ // client-cancel (no terminal seen) is finalized separately via consumeForInspection's onCancel.
1035
+ const reportNativeTerminal = (status: ResponsesTerminalStatus, httpStatusOverride?: number) => {
1036
+ terminalRecorder?.(status, httpStatusOverride);
1037
+ options.onNativePassthroughTerminal?.(status);
1038
+ };
1039
+ consumeForInspection(
1040
+ inspectBody,
1041
+ reportNativeTerminal,
1042
+ turnAc.signal,
1043
+ () => unregisterTurn(turnAc),
1044
+ logCtx,
1045
+ () => options.onNativePassthroughCancel?.(),
1046
+ rememberPassthroughResponse,
1047
+ options.onFirstOutput,
1048
+ );
1049
+ } else {
1050
+ consumeForResponseLogMetadata(
1051
+ inspectBody,
1052
+ logCtx,
1053
+ turnAc.signal,
1054
+ () => unregisterTurn(turnAc),
1055
+ rememberPassthroughResponse,
1056
+ options.onFirstOutput,
1057
+ );
1058
+ }
1059
+ if (!headers.has("content-type")) headers.set("content-type", "text/event-stream");
1060
+ // win32 must keep the pure native relay (Bun#32111 JS-sink segfault); elsewhere a JS pull
1061
+ // relay is established practice (relayWithAbort, relaySseWithHeartbeat) and lets a
1062
+ // mid-stream reset end with a clean response.failed terminal instead of a raw socket error.
1063
+ const repairedBody = hasResponsesItemIdRepair(repairConfig)
1064
+ ? relaySseWithResponsesItemIdRepair(nativeBody, repairConfig!)
1065
+ : nativeBody;
1066
+ const clientBody = process.platform === "win32" && !hasResponsesItemIdRepair(repairConfig)
1067
+ ? nativeBody
1068
+ : relaySseWithFailedTail(repairedBody, upstream);
1069
+ return markNativePassthroughSseResponse(new Response(clientBody, {
1070
+ status: upstreamResponse.status,
1071
+ headers,
1072
+ }));
1073
+ }
1074
+ if (headers.get("content-type")?.toLowerCase().includes("application/json")) {
1075
+ if (!upstreamResponse.ok && options.comboAttempt) {
1076
+ const failure = await consumeComboFailure(upstreamResponse, options.abortSignal);
1077
+ options.onConsumedComboFailure?.(failure);
1078
+ return failure.response;
1079
+ }
1080
+ const text = await upstreamResponse.text();
1081
+ inspectResponseLogJson(logCtx, text);
1082
+ if (upstreamResponse.ok && rememberPassthroughResponse) {
1083
+ try {
1084
+ rememberPassthroughResponse(JSON.parse(text) as { id?: unknown; output?: unknown; status?: unknown });
1085
+ } catch { /* non-JSON despite content-type; recording is best-effort */ }
1086
+ }
1087
+ return new Response(text, {
1088
+ status: upstreamResponse.status,
1089
+ statusText: upstreamResponse.statusText,
1090
+ headers,
1091
+ });
1092
+ }
1093
+ const body = relayWithAbort(upstreamResponse.body, upstream);
1094
+ const turnAc = new AbortController();
1095
+ const tracked = body ? trackStreamLifetime(body, turnAc) : null;
1096
+ return new Response(tracked, {
1097
+ status: upstreamResponse.status,
1098
+ headers,
1099
+ });
1100
+ }
1101
+
1102
+ if (adapter.runTurn) {
1103
+ const runTurnAbort = new AbortController();
1104
+ linkAbortSignal(runTurnAbort, options.abortSignal);
1105
+ const queue = createAdapterEventQueue();
1106
+ const runTurn = async (): Promise<void> => {
1107
+ try {
1108
+ noteAttemptSend(logCtx.activeAttempt, logCtx.usageLogInputTokens);
1109
+ await adapter.runTurn?.(
1110
+ parsed,
1111
+ { headers: selectedForwardHeaders, abortSignal: runTurnAbort.signal },
1112
+ queue.push,
1113
+ );
1114
+ } catch (err) {
1115
+ queue.push({
1116
+ type: "error",
1117
+ message: err instanceof Error ? err.message : String(err),
1118
+ });
1119
+ } finally {
1120
+ queue.close();
1121
+ }
1122
+ };
1123
+
1124
+ const { toolNsMap, freeformToolNames, toolSearchToolNames } = buildToolBridgeMaps(parsed);
1125
+ if (parsed.stream) {
1126
+ void runTurn();
1127
+ let eventSource: AsyncIterable<AdapterEvent> = queue.stream();
1128
+ if (options.comboAttempt) {
1129
+ const preflight = await preflightAdapterEvents(eventSource);
1130
+ if (preflight.error || preflight.empty) {
1131
+ runTurnAbort.abort();
1132
+ queue.close();
1133
+ const message = preflight.error?.message ?? "Adapter ended before producing a response";
1134
+ return formatErrorResponse(502, "upstream_error", redactSecretString(message));
1135
+ }
1136
+ eventSource = preflight.stream;
1137
+ }
1138
+ const sseStream = bridgeToResponsesSSE(
1139
+ eventSource, parsed.modelId, toolNsMap, freeformToolNames, toolSearchToolNames,
1140
+ () => {
1141
+ runTurnAbort.abort();
1142
+ queue.close();
1143
+ }, 2_000,
1144
+ {
1145
+ ...(options.forceEmptyResponseId ? { responseId: "" } : {}),
1146
+ stallTimeoutSec: config.stallTimeoutSec,
1147
+ hideThinkingSummary: parsed.options.hideThinkingSummary,
1148
+ ...(options.onFirstOutput ? { onFirstOutput: options.onFirstOutput } : {}),
1149
+ ...(routedCompaction ? { compaction: true } : {}),
1150
+ ...(routedCompaction ? {} : {
1151
+ onCompletedResponse: (response: Record<string, unknown>, providerState?: OcxProviderContinuationState) =>
1152
+ rememberResponseState(
1153
+ parsed._rawBody,
1154
+ response,
1155
+ continuationStateForResponse(providerState),
1156
+ adapterNeedsForcedContinuation(adapter.name) ? { force: true } : undefined,
1157
+ ),
1158
+ }),
1159
+ },
1160
+ );
1161
+ const bridgeTurnAc = new AbortController();
1162
+ const trackedSse = trackStreamLifetime(sseStream, bridgeTurnAc);
1163
+ return new Response(trackedSse, {
1164
+ headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no" },
1165
+ });
1166
+ }
1167
+
1168
+ await runTurn();
1169
+ const events = await queue.collect();
1170
+ if (options.comboAttempt) {
1171
+ const firstMeaningful = events.find(event => event.type !== "heartbeat");
1172
+ if (!firstMeaningful || firstMeaningful.type === "error") {
1173
+ const message = firstMeaningful?.type === "error"
1174
+ ? firstMeaningful.message
1175
+ : "Adapter ended before producing a response";
1176
+ return formatErrorResponse(502, "upstream_error", redactSecretString(message));
1177
+ }
1178
+ }
1179
+ let providerState: OcxProviderContinuationState | undefined;
1180
+ const json = buildResponseJSON(events, parsed.modelId, {
1181
+ hideThinkingSummary: parsed.options.hideThinkingSummary,
1182
+ toolNsMap,
1183
+ freeformToolNames,
1184
+ toolSearchToolNames,
1185
+ ...(routedCompaction ? { compaction: true } : {}),
1186
+ onProviderState: state => { providerState = state; },
1187
+ });
1188
+ if (!routedCompaction) {
1189
+ rememberResponseState(
1190
+ parsed._rawBody,
1191
+ json,
1192
+ continuationStateForResponse(providerState),
1193
+ adapterNeedsForcedContinuation(adapter.name) ? { force: true } : undefined,
1194
+ );
1195
+ }
1196
+ return new Response(JSON.stringify(json), { headers: { "Content-Type": "application/json" } });
1197
+ }
1198
+
1199
+ // Web-search sidecar: Codex enabled web_search but this is a routed (non-OpenAI) model that can't
1200
+ // run it server-side. Expose web_search as a function tool and run searches via the gpt-mini sidecar
1201
+ // through the ChatGPT passthrough, looping until the model answers. Otherwise take the normal path.
1202
+ const wsPlan = planWebSearch(config, parsed, false, route.provider, route.modelId, openAiSidecar);
1203
+ if (wsPlan) {
1204
+ parsed.context.tools = [...(parsed.context.tools ?? []), buildWebSearchTool()];
1205
+ noteAttemptSend(logCtx.activeAttempt, logCtx.usageLogInputTokens);
1206
+ const wsResponse = await runWithWebSearch({
1207
+ parsed, adapter,
1208
+ backend: wsPlan.backend,
1209
+ forwardProvider: wsPlan.forwardSidecar?.provider,
1210
+ anthropicSidecar: wsPlan.anthropicSidecar,
1211
+ hostedTool: wsPlan.hostedTool,
1212
+ selectedForwardHeaders: wsPlan.forwardSidecar?.headers ?? selectedForwardHeaders,
1213
+ settings: wsPlan.settings,
1214
+ maxSearches: wsPlan.maxSearches,
1215
+ forceEmptyResponseId: true,
1216
+ abortSignal: options.abortSignal,
1217
+ ...(options.onFirstOutput ? { onFirstOutput: options.onFirstOutput } : {}),
1218
+ recordSidecarOutcome: wsPlan.forwardSidecar?.recordOutcome,
1219
+ connectTimeoutMs: config.connectTimeoutMs ?? 200_000,
1220
+ routedModelStallTimeoutMs: wsPlan.routedModelStallTimeoutMs,
1221
+ stallTimeoutSec: wsPlan.stallTimeoutSec,
1222
+ on429: retryAfter => {
1223
+ const rotated = rotateProviderTransportOn429(config, route.providerName, {
1224
+ retryAfter,
1225
+ now: Date.now(),
1226
+ attemptedKey: route.provider.apiKey,
1227
+ promptCacheKey: parsed.options.promptCacheKey,
1228
+ });
1229
+ if (!rotated) return null;
1230
+ route.provider = rotated;
1231
+ return resolveAdapter(
1232
+ resolveWireProtocolOverride(route.providerName, route.modelId, route.provider),
1233
+ config.cacheRetention,
1234
+ );
1235
+ },
1236
+ });
1237
+ // Register the sidecar stream as an active turn so drainAndShutdown waits for (or aborts)
1238
+ // in-flight web-search turns instead of skipping them during graceful shutdown.
1239
+ if (wsResponse.body) {
1240
+ const wsTurnAc = new AbortController();
1241
+ return new Response(trackStreamLifetime(wsResponse.body, wsTurnAc), {
1242
+ status: wsResponse.status,
1243
+ headers: wsResponse.headers,
1244
+ });
1245
+ }
1246
+ return wsResponse;
1247
+ }
1248
+
1249
+ const upstream = new AbortController();
1250
+ const cleanupUpstreamAbort = linkAbortSignal(upstream, options.abortSignal);
1251
+ const connectMs = config.connectTimeoutMs ?? 200_000;
1252
+ let activeAdapter = adapter;
1253
+
1254
+ const request = await activeAdapter.buildRequest(parsed, { headers: selectedForwardHeaders });
1255
+ const inputTokenEstimate = typeof request.usageLog?.inputTokens === "number"
1256
+ ? request.usageLog.inputTokens
1257
+ : undefined;
1258
+ if (inputTokenEstimate !== undefined) logCtx.usageLogInputTokens = inputTokenEstimate;
1259
+ let upstreamResponse: Response;
1260
+ try {
1261
+ if (activeAdapter.fetchResponse) {
1262
+ noteAttemptSend(logCtx.activeAttempt, inputTokenEstimate);
1263
+ upstreamResponse = await activeAdapter.fetchResponse(request, {
1264
+ abortSignal: upstream.signal,
1265
+ timeoutMs: connectMs,
1266
+ stream: parsed.stream,
1267
+ });
1268
+ } else {
1269
+ upstreamResponse = await fetchWithResetRetry(
1270
+ recovery => {
1271
+ noteAttemptSend(logCtx.activeAttempt, inputTokenEstimate, recovery);
1272
+ return fetchWithHeaderTimeout(request.url, applyUpstreamRecoveryInit({
1273
+ method: request.method,
1274
+ headers: request.headers,
1275
+ body: request.body,
1276
+ }, recovery), upstream.signal, connectMs, parsed.stream, providerFetch(route.provider));
1277
+ },
1278
+ { abortSignal: upstream.signal, label: safeHostLabel(request.url) },
1279
+ );
1280
+ }
1281
+ } catch (err) {
1282
+ cleanupUpstreamAbort();
1283
+ upstream.abort();
1284
+ if (options.abortSignal?.aborted) return clientCancelledResponse();
1285
+ const msg = err instanceof Error && err.name === "TimeoutError"
1286
+ ? `Provider connect timeout after ${connectMs}ms`
1287
+ : `Provider unreachable: ${err instanceof Error ? err.message : String(err)}`;
1288
+ return formatErrorResponse(502, "upstream_error", msg);
1289
+ }
1290
+
1291
+ if (!upstreamResponse.ok) {
1292
+ // Recovery loop: multi-key 429 failover + at most ONE anthropic 413 tightened retry
1293
+ // (devlog/260714_image_normalization_pipeline/030). One mutable activeAdapter serves
1294
+ // both paths so a 429→413 sequence never rebuilds against a stale pre-rotation
1295
+ // adapter, and imageTierBias — once armed — rides EVERY subsequent rebuild so a
1296
+ // 413→429 rotation cannot silently undo the tightening.
1297
+ let imageTierBias = 0;
1298
+ let imageRetryAttempted = false;
1299
+ let oauth401ReplayAttempted = false;
1300
+ const rebuildAndRefetch = async (
1301
+ recovery: AttemptRecoveryKind,
1302
+ ): Promise<Response | { failed: Response }> => {
1303
+ const retryRequest = await activeAdapter.buildRequest(parsed, {
1304
+ headers: selectedForwardHeaders,
1305
+ ...(imageTierBias > 0 ? { imageTierBias } : {}),
1306
+ });
1307
+ const retryEstimate = typeof retryRequest.usageLog?.inputTokens === "number"
1308
+ ? retryRequest.usageLog.inputTokens
1309
+ : undefined;
1310
+ if (retryEstimate !== undefined) logCtx.usageLogInputTokens = retryEstimate;
1311
+ logCtx.providerAdapter = activeAdapter.name;
1312
+ sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, activeAdapter.name);
1313
+ noteAttemptSend(logCtx.activeAttempt, retryEstimate, recovery);
1314
+ try {
1315
+ return activeAdapter.fetchResponse
1316
+ ? await activeAdapter.fetchResponse(retryRequest, { abortSignal: upstream.signal, timeoutMs: connectMs, stream: parsed.stream })
1317
+ : await fetchWithHeaderTimeout(retryRequest.url, {
1318
+ method: retryRequest.method, headers: retryRequest.headers, body: retryRequest.body,
1319
+ }, upstream.signal, connectMs, parsed.stream, providerFetch(route.provider));
1320
+ } catch (err) {
1321
+ cleanupUpstreamAbort();
1322
+ upstream.abort();
1323
+ if (options.abortSignal?.aborted) {
1324
+ return { failed: clientCancelledResponse() };
1325
+ }
1326
+ const msg = err instanceof Error && err.name === "TimeoutError"
1327
+ ? `Provider connect timeout after ${connectMs}ms`
1328
+ : `Provider unreachable: ${err instanceof Error ? err.message : String(err)}`;
1329
+ return { failed: formatErrorResponse(502, "upstream_error", msg) };
1330
+ }
1331
+ };
1332
+ recovery: for (;;) {
1333
+ if (
1334
+ upstreamResponse.status === 401
1335
+ && isOAuth401ReplayProvider
1336
+ && sentOAuthSnapshot
1337
+ && !oauth401ReplayAttempted
1338
+ ) {
1339
+ oauth401ReplayAttempted = true;
1340
+ try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ }
1341
+ let refreshed: OAuthAccessSnapshot;
1342
+ try {
1343
+ refreshed = await forceRefreshOAuthAccessSnapshot(sentOAuthSnapshot);
1344
+ } catch (err) {
1345
+ cleanupUpstreamAbort();
1346
+ return formatErrorResponse(401, "authentication_error", err instanceof Error ? err.message : String(err));
1347
+ }
1348
+ sentOAuthSnapshot = refreshed;
1349
+ const refreshedProvider = resolveProviderTransport(
1350
+ route.providerName,
1351
+ { ...route.provider, apiKey: refreshed.accessToken },
1352
+ parsed.options.promptCacheKey,
1353
+ route.providerName === "github-copilot" ? getOAuthCredentialApiBaseUrl(route.providerName) : undefined,
1354
+ );
1355
+ route.provider = refreshedProvider;
1356
+ activeAdapter = resolveAdapter(
1357
+ resolveWireProtocolOverride(route.providerName, route.modelId, refreshedProvider),
1358
+ config.cacheRetention,
1359
+ );
1360
+ const result = await rebuildAndRefetch("oauth-401");
1361
+ if ("failed" in result) return result.failed;
1362
+ upstreamResponse = result;
1363
+ continue recovery;
1364
+ }
1365
+
1366
+ // Multi-key 429 failover: rotate to the next pool key (cooldown-aware) and retry the
1367
+ // SAME request once per remaining key. OAuth/forward providers and single-key pools
1368
+ // return null immediately, so this stays a no-op for them (src/providers/key-failover.ts).
1369
+ while (upstreamResponse.status === 429 && hasKeyPoolFailover(route.provider)) {
1370
+ const rotated = rotateProviderTransportOn429(config, route.providerName, {
1371
+ retryAfter: upstreamResponse.headers.get("retry-after"),
1372
+ now: Date.now(),
1373
+ attemptedKey: route.provider.apiKey,
1374
+ promptCacheKey: parsed.options.promptCacheKey,
1375
+ });
1376
+ if (!rotated) break;
1377
+ // Release the failed response's socket before retrying; unread bodies otherwise linger
1378
+ // until runtime cleanup (one per rotated key under a rate-limit storm).
1379
+ try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ }
1380
+ route.provider = rotated;
1381
+ activeAdapter = resolveAdapter(
1382
+ resolveWireProtocolOverride(route.providerName, route.modelId, route.provider),
1383
+ config.cacheRetention,
1384
+ );
1385
+ const result = await rebuildAndRefetch("key-429");
1386
+ if ("failed" in result) return result.failed;
1387
+ upstreamResponse = result;
1388
+ }
1389
+ // Anthropic 413 request_too_large: rebuild once with every image one tier lower
1390
+ // (spiral guard: single attempt). The biased response re-enters the 429 check above.
1391
+ if (shouldAttemptImageTierRetry({
1392
+ status: upstreamResponse.status,
1393
+ adapterName: activeAdapter.name,
1394
+ parsed,
1395
+ alreadyAttempted: imageRetryAttempted,
1396
+ })) {
1397
+ imageRetryAttempted = true;
1398
+ imageTierBias = 1;
1399
+ try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ }
1400
+ const result = await rebuildAndRefetch("image-413");
1401
+ if ("failed" in result) return result.failed;
1402
+ upstreamResponse = result;
1403
+ continue recovery;
1404
+ }
1405
+ break;
1406
+ }
1407
+ if (!upstreamResponse.ok) {
1408
+ if (options.comboAttempt) {
1409
+ const failure = await consumeComboFailure(upstreamResponse, options.abortSignal)
1410
+ .finally(cleanupUpstreamAbort);
1411
+ options.onConsumedComboFailure?.(failure);
1412
+ return failure.response;
1413
+ }
1414
+ const errorText = await upstreamResponse.text().catch(() => "unknown error");
1415
+ cleanupUpstreamAbort();
1416
+ // Upstreams occasionally echo request details in error bodies — scrub token-shaped
1417
+ // material before it reaches the client-facing error surface.
1418
+ return formatErrorResponse(upstreamResponse.status, "upstream_error", `Provider error ${upstreamResponse.status}: ${redactSecretString(errorText.slice(0, 500))}`);
1419
+ }
1420
+ }
1421
+
1422
+ if (parsed.stream) {
1423
+ const eventStream = activeAdapter.parseStream(upstreamResponse);
1424
+ const { toolNsMap, freeformToolNames, toolSearchToolNames } = buildToolBridgeMaps(parsed);
1425
+ const sseStream = bridgeToResponsesSSE(
1426
+ eventStream, parsed.modelId, toolNsMap, freeformToolNames, toolSearchToolNames,
1427
+ () => upstream.abort(), 2_000,
1428
+ {
1429
+ ...(options.forceEmptyResponseId ? { responseId: "" } : {}),
1430
+ stallTimeoutSec: config.stallTimeoutSec,
1431
+ hideThinkingSummary: parsed.options.hideThinkingSummary,
1432
+ ...(options.onFirstOutput ? { onFirstOutput: options.onFirstOutput } : {}),
1433
+ ...(routedCompaction ? { compaction: true } : {}),
1434
+ // Compaction turns must NOT enter the continuation cache: _rawBody still holds the full
1435
+ // PRE-compaction history, and a later previous_response_id expansion would rehydrate the
1436
+ // giant stale chain Codex just replaced.
1437
+ ...(routedCompaction ? {} : {
1438
+ onCompletedResponse: (response: Record<string, unknown>, providerState?: OcxProviderContinuationState) =>
1439
+ rememberResponseState(
1440
+ parsed._rawBody,
1441
+ response,
1442
+ continuationStateForResponse(providerState),
1443
+ activeAdapter.name === "kiro" ? { force: true } : undefined,
1444
+ ),
1445
+ }),
1446
+ },
1447
+ );
1448
+ const bridgeTurnAc = new AbortController();
1449
+ const trackedSse = trackStreamLifetime(sseStream, bridgeTurnAc, cleanupUpstreamAbort);
1450
+ return new Response(trackedSse, {
1451
+ headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no" },
1452
+ });
1453
+ }
1454
+
1455
+ if (activeAdapter.parseResponse) {
1456
+ let events: AdapterEvent[];
1457
+ try {
1458
+ events = await activeAdapter.parseResponse(upstreamResponse);
1459
+ } finally {
1460
+ cleanupUpstreamAbort();
1461
+ }
1462
+ const { toolNsMap, freeformToolNames, toolSearchToolNames } = buildToolBridgeMaps(parsed);
1463
+ let providerState: OcxProviderContinuationState | undefined;
1464
+ const json = buildResponseJSON(events, parsed.modelId, {
1465
+ hideThinkingSummary: parsed.options.hideThinkingSummary,
1466
+ toolNsMap,
1467
+ freeformToolNames,
1468
+ toolSearchToolNames,
1469
+ ...(routedCompaction ? { compaction: true } : {}),
1470
+ onProviderState: state => { providerState = state; },
1471
+ });
1472
+ // See the streaming branch: compaction turns skip the continuation cache.
1473
+ if (!routedCompaction) {
1474
+ rememberResponseState(
1475
+ parsed._rawBody,
1476
+ json,
1477
+ continuationStateForResponse(providerState),
1478
+ activeAdapter.name === "kiro" ? { force: true } : undefined,
1479
+ );
1480
+ }
1481
+ return new Response(JSON.stringify(json), { headers: { "Content-Type": "application/json" } });
1482
+ }
1483
+
1484
+ return formatErrorResponse(400, "invalid_request_error", "Non-streaming not supported by this adapter");
1485
+ }
1486
+
1487
+
1488
+
1489
+ export function linkAbortSignal(upstream: AbortController, signal?: AbortSignal): () => void {
1490
+ if (!signal) return () => {};
1491
+ if (signal.aborted) {
1492
+ upstream.abort(signal.reason);
1493
+ return () => {};
1494
+ }
1495
+ const onAbort = () => upstream.abort(signal.reason);
1496
+ signal.addEventListener("abort", onAbort, { once: true });
1497
+ return () => signal.removeEventListener("abort", onAbort);
1498
+ }