@bitkyc08/opencodex 2.7.39 → 2.7.40-preview.20260725

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 (49) hide show
  1. package/README.md +4 -4
  2. package/gui/dist/assets/index-BxQ8N_K5.js +52 -0
  3. package/gui/dist/assets/index-CMip1DzF.css +1 -0
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +1 -1
  6. package/src/adapters/cursor/arg-normalize.ts +23 -7
  7. package/src/adapters/cursor/live-transport.ts +26 -14
  8. package/src/adapters/cursor/native-exec-fs.ts +1 -1
  9. package/src/adapters/cursor/native-exec-network.ts +1 -1
  10. package/src/adapters/cursor/native-exec-shell.ts +1 -1
  11. package/src/adapters/cursor/protobuf-events.ts +72 -13
  12. package/src/adapters/cursor/protobuf-request.ts +82 -11
  13. package/src/adapters/cursor/request-builder.ts +35 -11
  14. package/src/adapters/cursor/tool-definitions.ts +175 -30
  15. package/src/adapters/openai-chat.ts +28 -7
  16. package/src/adapters/openai-responses.ts +150 -4
  17. package/src/bridge.ts +20 -1
  18. package/src/claude/outbound.ts +91 -6
  19. package/src/codex/auth-api.ts +12 -25
  20. package/src/codex/auth-context.ts +48 -3
  21. package/src/codex/catalog/provider-fetch.ts +56 -24
  22. package/src/codex/model-cache.ts +23 -0
  23. package/src/codex/quota.ts +120 -0
  24. package/src/codex/routing.ts +178 -9
  25. package/src/config.ts +56 -1
  26. package/src/providers/openai-sidecar.ts +8 -1
  27. package/src/providers/openai-tiers.ts +18 -0
  28. package/src/server/adapter-resolve.ts +24 -10
  29. package/src/server/auth-cors.ts +3 -0
  30. package/src/server/chat-completions.ts +4 -0
  31. package/src/server/claude-messages.ts +4 -0
  32. package/src/server/index.ts +3 -1
  33. package/src/server/live.ts +56 -0
  34. package/src/server/memory-watchdog.ts +1 -1
  35. package/src/server/responses/compact.ts +40 -10
  36. package/src/server/responses/core.ts +180 -26
  37. package/src/server/responses/terminal-guard.ts +230 -0
  38. package/src/service.ts +113 -30
  39. package/src/types.ts +52 -0
  40. package/src/usage/expected-prices.ts +12 -0
  41. package/src/web-search/anthropic-executor.ts +3 -1
  42. package/src/web-search/index.ts +7 -1
  43. package/src/web-search/loop.ts +17 -3
  44. package/README.ja.md +0 -445
  45. package/README.ko.md +0 -435
  46. package/README.ru.md +0 -486
  47. package/README.zh-CN.md +0 -411
  48. package/gui/dist/assets/index-B-cheu55.js +0 -52
  49. package/gui/dist/assets/index-oOZcqVmj.css +0 -1
@@ -95,7 +95,14 @@ export async function resolveFirstUsableOpenAiSidecar(
95
95
  authContext,
96
96
  headers: headersForCodexAuthContext(incomingHeaders, authContext),
97
97
  ...(authContext.kind === "pool" || authContext.kind === "main-pool"
98
- ? { recordOutcome: (outcome: CodexUpstreamOutcome) => recordCodexUpstreamOutcome(config, authContext.accountId, outcome) }
98
+ ? {
99
+ recordOutcome: (outcome: CodexUpstreamOutcome) => recordCodexUpstreamOutcome(
100
+ config,
101
+ authContext.accountId,
102
+ outcome,
103
+ { probeLeaseId: authContext.probeLeaseId },
104
+ ),
105
+ }
99
106
  : {}),
100
107
  };
101
108
  }
@@ -35,6 +35,24 @@ export function isCanonicalOpenAiForwardProvider(provider: OcxProviderConfig): b
35
35
  && normalizedBaseUrl(provider.baseUrl) === CODEX_FORWARD_BASE_URL;
36
36
  }
37
37
 
38
+ const OPENAI_API_BASE_URL = "https://api.openai.com/v1";
39
+
40
+ /**
41
+ * Whether this provider can serve `POST /responses/compact`. The canonical ChatGPT
42
+ * backend can, and so can the official OpenAI API — but an arbitrary gateway that
43
+ * merely speaks the Responses wire cannot, and calling it there fails compaction
44
+ * with an unhelpful error instead of falling back to a routed summary (#422).
45
+ */
46
+ export function supportsNativeResponsesCompactEndpoint(
47
+ providerName: string,
48
+ provider: OcxProviderConfig,
49
+ ): boolean {
50
+ if (isCanonicalOpenAiForwardProvider(provider)) return true;
51
+ return providerName === OPENAI_API_PROVIDER_ID
52
+ && provider.adapter === "openai-responses"
53
+ && normalizedBaseUrl(provider.baseUrl) === OPENAI_API_BASE_URL;
54
+ }
55
+
38
56
  export interface OpenAiTierMigrationProjection {
39
57
  config: OcxConfig;
40
58
  changed: boolean;
@@ -7,18 +7,32 @@ import { createMimoFreeAdapter } from "../adapters/mimo-free";
7
7
  import { createOpenAIChatAdapter } from "../adapters/openai-chat";
8
8
  import { createResponsesPassthroughAdapter } from "../adapters/openai-responses";
9
9
  import type { OcxProviderConfig } from "../types";
10
+ import { isWirePinnedModel, MODEL_ADAPTER_OVERRIDE_ALLOWED, pinnedWireAdapter } from "../types";
11
+ import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers";
10
12
 
11
- /** Providers whose listed model ids must be driven over the Anthropic wire even if the provider's
12
- * configured adapter is something else (the upstream only speaks Anthropic for these models). */
13
- const ANTHROPIC_WIRE_MODELS: Record<string, Set<string>> = {
14
- "opencode-go": new Set(["minimax-m2.5", "minimax-m2.7", "minimax-m3"]),
15
- };
16
-
17
- /** Return a provider config whose adapter is forced to "anthropic" when the model id is wire-pinned. */
13
+ /**
14
+ * Resolve the wire a single model should use: a hard pin first, then a configured
15
+ * per-model override, then the provider's own adapter.
16
+ *
17
+ * Safe to call more than once on its own output — the pin check does not look at the
18
+ * current adapter, so a second pass cannot let an override displace a pin.
19
+ */
18
20
  export function resolveWireProtocolOverride(providerName: string, modelId: string, providerConfig: OcxProviderConfig): OcxProviderConfig {
19
- const overrideSet = ANTHROPIC_WIRE_MODELS[providerName];
20
- if (overrideSet?.has(modelId) && providerConfig.adapter !== "anthropic") {
21
- return { ...providerConfig, adapter: "anthropic" };
21
+ const pinned = pinnedWireAdapter(providerName, modelId);
22
+ if (pinned && providerConfig.adapter !== pinned) {
23
+ return { ...providerConfig, adapter: pinned };
24
+ }
25
+ // Re-check the allow-list here, not just in the config validator: the file may have
26
+ // been hand-edited, or written by a build that allowed more values.
27
+ const requested = providerConfig.modelAdapters?.[modelId];
28
+ if (requested
29
+ && MODEL_ADAPTER_OVERRIDE_ALLOWED.has(requested)
30
+ && requested !== providerConfig.adapter
31
+ && !isWirePinnedModel(providerName, modelId)
32
+ // A forward provider hands the caller's own credential upstream; the chat adapter
33
+ // only ever sends provider.apiKey, so switching wires here would drop the auth.
34
+ && !isCanonicalOpenAiForwardProvider(providerConfig)) {
35
+ return { ...providerConfig, adapter: requested };
22
36
  }
23
37
  return providerConfig;
24
38
  }
@@ -2,6 +2,7 @@ import { timingSafeEqual } from "node:crypto";
2
2
  import { formatErrorResponse } from "../bridge";
3
3
  import {
4
4
  booleanRecordConfigError,
5
+ modelAdapterRecordConfigError,
5
6
  codexAutoStartEnabled,
6
7
  positiveIntegerConfigError,
7
8
  positiveIntegerRecordConfigError,
@@ -234,6 +235,8 @@ export function providerManagementConfigError(name: unknown, provider: unknown):
234
235
  if (maxInputError) return `provider ${name} ${maxInputError}`;
235
236
  const reasoningSummariesError = booleanRecordConfigError(raw.modelSupportsReasoningSummaries, "modelSupportsReasoningSummaries");
236
237
  if (reasoningSummariesError) return `provider ${name} ${reasoningSummariesError}`;
238
+ const modelAdaptersError = modelAdapterRecordConfigError(raw.modelAdapters, "modelAdapters", name, typed);
239
+ if (modelAdaptersError) return `provider ${name} ${modelAdaptersError}`;
237
240
  const defaultMaxOutputError = positiveIntegerConfigError(raw.defaultMaxOutputTokens, "defaultMaxOutputTokens");
238
241
  if (defaultMaxOutputError) return `provider ${name} ${defaultMaxOutputError}`;
239
242
  const maxOutputError = positiveIntegerRecordConfigError(raw.modelMaxOutputTokens, "modelMaxOutputTokens");
@@ -16,6 +16,7 @@ import {
16
16
  } from "../chat/outbound";
17
17
  import { estimateTokens } from "../lib/token-estimate";
18
18
  import { routeModel } from "../router";
19
+ import { resolveWireProtocolOverride } from "./adapter-resolve";
19
20
  import type { OcxConfig } from "../types";
20
21
  import { readJsonRequestBody } from "./request-decompress";
21
22
  import {
@@ -68,6 +69,9 @@ export async function handleChatCompletions(
68
69
  let directRoute = false;
69
70
  try {
70
71
  const route = routeModel(config, internalBody.model as string);
72
+ // Settle the wire once so every branch below reads the adapter this model will
73
+ // actually use, not the provider-wide default (#404).
74
+ route.provider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider);
71
75
  logCtx.model = route.modelId;
72
76
  logCtx.providerAdapter = route.provider.adapter;
73
77
  logCtx.requestedModel = requestedModel;
@@ -23,6 +23,7 @@ import {
23
23
  import { clearableDeadline, idleDeadline } from "../lib/abort";
24
24
  import { estimateTokens } from "../lib/token-estimate";
25
25
  import { routeModel } from "../router";
26
+ import { resolveWireProtocolOverride } from "./adapter-resolve";
26
27
  import type { OcxConfig } from "../types";
27
28
  import { readJsonRequestBody } from "./request-decompress";
28
29
  import { addFinalRequestLog, httpStatusForTerminalStatus, recordFirstOutput, type RequestLogContext, type RequestLogEntry } from "./request-log";
@@ -570,6 +571,9 @@ export async function handleClaudeMessages(
570
571
  let nativeRoute = false;
571
572
  try {
572
573
  const route = routeModel(config, internalBody.model as string);
574
+ // Settle the wire once so the sampling decision below reads the effective
575
+ // adapter rather than the provider-wide default (#404).
576
+ route.provider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider);
573
577
  if (route.provider.adapter === "openai-responses") {
574
578
  nativeRoute = true;
575
579
  delete internalBody.max_output_tokens;
@@ -122,7 +122,7 @@ import { handleChatCompletions } from "./chat-completions";
122
122
  import { anthropicErrorResponse } from "../claude/outbound";
123
123
  import { buildDesktop3pRegistry } from "../claude/desktop-3p";
124
124
  import { handleImages } from "./images";
125
- import { handleLive, parseLiveSidebandTarget, resolveLiveSidebandUpgrade } from "./live";
125
+ import { handleLive, logLiveSidebandFrame, parseLiveSidebandTarget, resolveLiveSidebandUpgrade } from "./live";
126
126
  import { handleSearch } from "./search";
127
127
  import { fetchAllModels, handleManagementAPI, VERSION } from "./management-api";
128
128
 
@@ -185,6 +185,7 @@ function attachLiveSidebandUpstream(ws: ServerWebSocket<WsData>): void {
185
185
  });
186
186
  upstream.addEventListener("message", (event) => {
187
187
  try {
188
+ logLiveSidebandFrame("u2c", event.data);
188
189
  if (typeof event.data === "string") ws.send(event.data);
189
190
  else if (event.data instanceof ArrayBuffer) ws.send(event.data);
190
191
  else if (ArrayBuffer.isView(event.data)) {
@@ -679,6 +680,7 @@ export function startServer(port?: number) {
679
680
  },
680
681
  message(ws: ServerWebSocket<WsData>, raw: string | Buffer) {
681
682
  if (ws.data.kind === "live-sideband") {
683
+ logLiveSidebandFrame("c2u", raw);
682
684
  const upstream = ws.data.liveUpstream;
683
685
  if (!upstream || upstream.readyState === WebSocket.CONNECTING || !ws.data.liveOpened) {
684
686
  const pending = ws.data.livePending ?? (ws.data.livePending = []);
@@ -20,6 +20,7 @@
20
20
  * - `GET /v1/realtime/calls/{callId}` — path-form join
21
21
  * - `GET /v1/realtime?call_id=` — Realtime v1/v2 join
22
22
  */
23
+ import { appendFileSync } from "node:fs";
23
24
  import { formatErrorResponse } from "../bridge";
24
25
  import {
25
26
  CodexAccountCooldownError,
@@ -69,6 +70,61 @@ export const LIVE_CLIENT_PROTOCOL_HEADERS = [
69
70
  "x-oai-attestation",
70
71
  ] as const;
71
72
 
73
+ /**
74
+ * Env-gated sideband frame forensics (diagnostic for multibyte transcript corruption).
75
+ *
76
+ * When `OCX_LIVE_FRAME_LOG` is set to a file path, every relayed sideband frame appends one
77
+ * JSONL record: direction, frame kind, byte length, and whether the payload contains U+FFFD.
78
+ * Privacy: full frame payloads are never written — only when U+FFFD is present, a short
79
+ * excerpt around the first replacement character is included so the corruption point can be
80
+ * attributed (upstream vs relay vs client). Disabled entirely when the env var is unset.
81
+ */
82
+ export const LIVE_FRAME_LOG_ENV = "OCX_LIVE_FRAME_LOG";
83
+ const LIVE_FRAME_LOG_CONTEXT_CHARS = 24;
84
+
85
+ function fffdContext(text: string): string | undefined {
86
+ const idx = text.indexOf("\uFFFD");
87
+ if (idx < 0) return undefined;
88
+ const start = Math.max(0, idx - LIVE_FRAME_LOG_CONTEXT_CHARS);
89
+ const end = Math.min(text.length, idx + LIVE_FRAME_LOG_CONTEXT_CHARS);
90
+ return text.slice(start, end);
91
+ }
92
+
93
+ export function logLiveSidebandFrame(dir: "c2u" | "u2c", data: unknown): void {
94
+ const logPath = process.env[LIVE_FRAME_LOG_ENV];
95
+ if (!logPath) return;
96
+ try {
97
+ let kind: "text" | "binary" = "binary";
98
+ let bytes = 0;
99
+ let context: string | undefined;
100
+ if (typeof data === "string") {
101
+ kind = "text";
102
+ bytes = Buffer.byteLength(data);
103
+ context = fffdContext(data);
104
+ } else if (data instanceof ArrayBuffer) {
105
+ bytes = data.byteLength;
106
+ context = fffdContext(new TextDecoder().decode(new Uint8Array(data)));
107
+ } else if (ArrayBuffer.isView(data)) {
108
+ const view = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
109
+ bytes = data.byteLength;
110
+ context = fffdContext(new TextDecoder().decode(view));
111
+ } else {
112
+ return;
113
+ }
114
+ const record = {
115
+ ts: new Date().toISOString(),
116
+ dir,
117
+ kind,
118
+ bytes,
119
+ fffd: context !== undefined,
120
+ ...(context !== undefined ? { context } : {}),
121
+ };
122
+ appendFileSync(logPath, `${JSON.stringify(record)}\n`);
123
+ } catch {
124
+ // Frame forensics must never break the relay.
125
+ }
126
+ }
127
+
72
128
  function clientProtocolHeaders(reqHeaders: Headers): Record<string, string> {
73
129
  const out: Record<string, string> = {};
74
130
  for (const name of LIVE_CLIENT_PROTOCOL_HEADERS) {
@@ -39,7 +39,7 @@ const DEFAULT_INTERVAL_MS = 60_000;
39
39
  const DEFAULT_WARN_THRESHOLD_BYTES = 4 * 1024 ** 3; // 4 GiB
40
40
  const DEFAULT_RING_SIZE = 360; // ≈6h at 60s
41
41
  const WARN_INTERVAL_MS = 30 * 60_000;
42
- const DOCS_URL = "https://lidge-jun.github.io/opencodex/troubleshooting/windows-memory/";
42
+ const DOCS_URL = "https://opencodex.me/troubleshooting/windows-memory/";
43
43
 
44
44
  let active: MemoryWatchdog | null = null;
45
45
 
@@ -49,6 +49,7 @@ import {
49
49
  headersForCodexAuthContext,
50
50
  isCodexAuthContextUsable,
51
51
  resolveCodexAuthContext,
52
+ codexProbeLeaseId,
52
53
  type CodexAuthContext,
53
54
  } from "../../codex/auth-context";
54
55
  import {
@@ -59,7 +60,7 @@ import {
59
60
  import { fetchWithResetRetry, fetchWithTransientRetry, applyUpstreamRecoveryInit } from "../../lib/upstream-retry";
60
61
  import { ForwardAdmissionCredentialError, validateForwardAdmissionCredential } from "../auth-cors";
61
62
  import { listOpenAiForwardSidecarCandidates, resolveFirstUsableOpenAiSidecar, type ResolvedOpenAiForwardSidecar } from "../../providers/openai-sidecar";
62
- import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
63
+ import { isCanonicalOpenAiForwardProvider, supportsNativeResponsesCompactEndpoint } from "../../providers/openai-tiers";
63
64
  import { slugsEquivalent } from "../../providers/slug-codec";
64
65
  import { applyOpenAiVirtualModel, resolveOpenAiCompactModel } from "../../providers/openai-virtual-models";
65
66
  import { isUsageDebugEnabled } from "../../usage/debug";
@@ -202,7 +203,10 @@ export async function handleResponsesCompact(
202
203
  }
203
204
  }
204
205
 
205
- if (route.provider.adapter === "openai-responses") {
206
+ // Native /responses/compact exists on the canonical ChatGPT backend and on the
207
+ // official OpenAI API. Any other Responses-shaped gateway must take the routed
208
+ // summarizer path below, or compaction fails against an endpoint it never had (#422).
209
+ if (supportsNativeResponsesCompactEndpoint(route.providerName, route.provider)) {
206
210
  // Native ChatGPT/OpenAI model: forward the compact request verbatim to the real backend.
207
211
  // Resolve the SAME pool/thread auth context as /v1/responses — forwarding the caller's raw
208
212
  // headers would run compaction on the wrong account (or 401) whenever a pool account is
@@ -255,6 +259,7 @@ export async function handleResponsesCompact(
255
259
  recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, {
256
260
  ...meta,
257
261
  threadId: compactThreadId,
262
+ probeLeaseId: codexProbeLeaseId(authCtx),
258
263
  });
259
264
  };
260
265
  let upstream: Response;
@@ -322,21 +327,46 @@ export async function handleResponsesCompact(
322
327
  });
323
328
  const response = await handleResponses(internalReq, config, logCtx, { abortSignal: req.signal });
324
329
  if (!response.ok) return response;
325
- let json: { output?: unknown[] };
330
+ let json: { output?: unknown[]; status?: unknown; error?: unknown };
326
331
  try {
327
- json = await response.json() as { output?: unknown[] };
332
+ json = await response.json() as { output?: unknown[]; status?: unknown; error?: unknown };
328
333
  } catch {
329
334
  return formatErrorResponse(502, "server_error", "compaction turn returned a non-JSON response");
330
335
  }
331
- const compactionItem = (json.output ?? []).find(
336
+ // The internal turn answers 200 even when it failed or was truncated, so the body
337
+ // has to be inspected. Reporting a failure beats installing "(no summary
338
+ // available)" as replacement history and silently losing the conversation (#422).
339
+ if (json.error) {
340
+ const message = typeof json.error === "string"
341
+ ? json.error
342
+ : (json.error as { message?: unknown })?.message;
343
+ return formatErrorResponse(502, "upstream_error", typeof message === "string" ? message : "compaction turn failed");
344
+ }
345
+ if (json.status !== "completed") {
346
+ return formatErrorResponse(
347
+ 502,
348
+ "upstream_error",
349
+ `compaction turn did not complete (status: ${String(json.status ?? "unknown")})`,
350
+ );
351
+ }
352
+ const compactionItems = (json.output ?? []).filter(
332
353
  (item): item is { type: string; encrypted_content?: string } =>
333
354
  !!item && typeof item === "object" && (item as { type?: string }).type === "compaction",
334
355
  );
335
- const summary = compactionItem?.encrypted_content
336
- ? decodeCompactionSummary(compactionItem.encrypted_content) ?? ""
337
- : "";
356
+ if (compactionItems.length !== 1) {
357
+ return formatErrorResponse(
358
+ 502,
359
+ "invalid_response_error",
360
+ `compaction turn produced ${compactionItems.length} compaction items, expected exactly 1`,
361
+ );
362
+ }
363
+ const encrypted = compactionItems[0]!.encrypted_content;
364
+ const decoded = typeof encrypted === "string" ? decodeCompactionSummary(encrypted) : null;
365
+ // An empty `ocx1:` envelope decodes to "" rather than null, so length is what matters.
366
+ if (decoded === null || decoded.trim().length === 0) {
367
+ return formatErrorResponse(502, "invalid_response_error", "compaction turn produced an empty summary");
368
+ }
369
+ const summary = decoded;
338
370
  const output = buildCompactV1Output(extractCompactUserMessages(inputItems), summary);
339
371
  return new Response(JSON.stringify({ output }), { headers: { "Content-Type": "application/json" } });
340
372
  }
341
-
342
-
@@ -49,6 +49,8 @@ import {
49
49
  headersForCodexAuthContext,
50
50
  isCodexAuthContextUsable,
51
51
  resolveCodexAuthContext,
52
+ codexProbeLeaseId,
53
+ releaseCodexAuthContextProbeLease,
52
54
  stripCodexRuntimeProviderFields,
53
55
  type CodexAuthContext,
54
56
  } from "../../codex/auth-context";
@@ -105,6 +107,7 @@ import type { EffectiveSubagentRoster, SpawnAgentSurface } from "../../codex/cat
105
107
  import { buildToolBridgeMaps, collabSurface, injectDeveloperMessage, multiAgentGuidanceText } from "./collaboration";
106
108
  import { hasUnreadableEncryptedAgentTask, looksLikeBackendCiphertext, sanitizeEncryptedContentInPlace } from "./encrypted-payload";
107
109
  import { fetchWithHeaderTimeout, providerFetch, safeHostLabel } from "./fetch-helpers";
110
+ import { guardTerminalEventStream } from "./terminal-guard";
108
111
 
109
112
  /**
110
113
  * Adapters whose continuation state must survive Codex's store:false requests.
@@ -119,7 +122,10 @@ export function sidecarOutcomeRecorder(
119
122
  threadId?: string | null,
120
123
  ): ((outcome: CodexUpstreamOutcome) => void) | undefined {
121
124
  return authCtx.kind === "pool" || authCtx.kind === "main-pool"
122
- ? outcome => recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, { threadId })
125
+ ? outcome => recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, {
126
+ threadId,
127
+ probeLeaseId: authCtx.probeLeaseId,
128
+ })
123
129
  : undefined;
124
130
  }
125
131
 
@@ -206,7 +212,10 @@ export function codexForwardTerminalOutcomeRecorder(
206
212
  // Normal limit/content-filter/stall terminal — the account served the
207
213
  // request. Don't penalize account health; record success to clear any
208
214
  // prior soft-avoid so a healthy account isn't stuck avoided.
209
- recordCodexUpstreamOutcome(config, authCtx.accountId, 200, { threadId });
215
+ recordCodexUpstreamOutcome(config, authCtx.accountId, 200, {
216
+ threadId,
217
+ probeLeaseId: codexProbeLeaseId(authCtx),
218
+ });
210
219
  return;
211
220
  }
212
221
  // status === "completed" or "failed": use the semantic HTTP status derived
@@ -221,7 +230,10 @@ export function codexForwardTerminalOutcomeRecorder(
221
230
  const outcome = status === "completed"
222
231
  ? 200
223
232
  : (httpStatusOverride ?? logCtx?.terminalHttpStatus ?? 502);
224
- recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, { threadId });
233
+ recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, {
234
+ threadId,
235
+ probeLeaseId: codexProbeLeaseId(authCtx),
236
+ });
225
237
  };
226
238
  }
227
239
 
@@ -720,6 +732,10 @@ export async function handleResponses(
720
732
  }
721
733
  parsed.modelId = route.modelId;
722
734
  }
735
+ // Settle the wire once, right after the native model id is known, so logging,
736
+ // fast-mode injection, auth, and sidecar decisions all read the adapter this
737
+ // request will actually use rather than the provider-wide default (#404).
738
+ route.provider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider);
723
739
  logCtx.model = route.modelId;
724
740
  logCtx.provider = route.providerName;
725
741
  logCtx.providerAdapter = route.provider.adapter;
@@ -851,6 +867,8 @@ export async function handleResponses(
851
867
  throw err;
852
868
  }
853
869
  if (!isCodexAuthContextUsable(authCtx, config)) {
870
+ // Nothing reaches upstream on this path, so give the probe back.
871
+ releaseCodexAuthContextProbeLease(authCtx);
854
872
  return formatErrorResponse(401, "authentication_error", "Selected Codex account needs reauthentication");
855
873
  }
856
874
  route.provider = applyCodexAuthContextToProvider(route.provider, authCtx, route.codexAccountMode);
@@ -973,7 +991,12 @@ export async function handleResponses(
973
991
  // one `{type:"compaction"}` output item (codex-rs compact_remote_v2.rs). Passthrough handles it
974
992
  // natively upstream; here we run the routed model as a plain summarizer — no tools, no web-search
975
993
  // sidecar — and the bridge appends the synthetic compaction item (src/responses/compaction.ts).
976
- const routedCompaction = parsed._compactionRequest === true && !("passthrough" in adapter && adapter.passthrough);
994
+ // A Responses-shaped wire does not imply support for Codex's private
995
+ // `compaction_trigger` item — only the canonical ChatGPT backend speaks that
996
+ // contract. An API-key gateway would receive the trigger, answer with an ordinary
997
+ // message, and leave Codex fataling on a missing compaction item (#422).
998
+ const routedCompaction = parsed._compactionRequest === true
999
+ && !isCanonicalOpenAiForwardProvider(route.provider);
977
1000
  if (routedCompaction) {
978
1001
  delete parsed.context.tools;
979
1002
  delete parsed._webSearch;
@@ -982,7 +1005,7 @@ export async function handleResponses(
982
1005
  parsed.context.messages.push({ role: "user", content: COMPACT_PROMPT, timestamp: Date.now() });
983
1006
  }
984
1007
 
985
- if ("passthrough" in adapter && adapter.passthrough) {
1008
+ if ("passthrough" in adapter && adapter.passthrough && !routedCompaction) {
986
1009
  // Local continuation cache for the ChatGPT passthrough. Codex WS turns chain with
987
1010
  // previous_response_id, ocx converts them to internal HTTP requests, and the ChatGPT Codex
988
1011
  // REST backend rejects the parameter — the adapter strips it in forward mode, so the ONLY
@@ -1025,6 +1048,7 @@ export async function handleResponses(
1025
1048
  if (usesCodexForwardPoolAuth(authCtx, route.provider)) {
1026
1049
  recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, {
1027
1050
  threadId: req.headers.get("x-codex-parent-thread-id"),
1051
+ probeLeaseId: codexProbeLeaseId(authCtx),
1028
1052
  });
1029
1053
  }
1030
1054
  const msg = outcome === "timeout"
@@ -1079,6 +1103,7 @@ export async function handleResponses(
1079
1103
  if (retryAuthCtx?.kind === "pool" || retryAuthCtx?.kind === "main-pool") {
1080
1104
  recordCodexUpstreamOutcome(config, firstAuthCtx.accountId, 400, {
1081
1105
  threadId: req.headers.get("x-codex-parent-thread-id"),
1106
+ probeLeaseId: codexProbeLeaseId(firstAuthCtx),
1082
1107
  });
1083
1108
 
1084
1109
  const retryHeaders = headersForCodexAuthContext(req.headers, retryAuthCtx);
@@ -1147,25 +1172,9 @@ export async function handleResponses(
1147
1172
  if (usesCodexForwardPoolAuth(authCtx, route.provider)) {
1148
1173
  // primary was the 5h window; it now carries weekly data for GPT plans.
1149
1174
  // Prefer primary when present, fall back to secondary for compatibility.
1150
- const primaryRaw = upstreamResponse.headers.get("x-codex-primary-used-percent");
1151
- const secondaryRaw = upstreamResponse.headers.get("x-codex-secondary-used-percent");
1152
- const weeklyRaw = primaryRaw ?? secondaryRaw;
1153
- const monthlyRaw = upstreamResponse.headers.get("x-codex-tertiary-used-percent");
1154
- const primaryResetRaw = upstreamResponse.headers.get("x-codex-primary-reset-at");
1155
- const secondaryResetRaw = upstreamResponse.headers.get("x-codex-secondary-reset-at");
1156
- const weeklyResetRaw = primaryRaw ? primaryResetRaw : secondaryResetRaw;
1157
- const monthlyResetRaw = upstreamResponse.headers.get("x-codex-tertiary-reset-at");
1158
1175
  const retryAfterRaw = upstreamResponse.headers.get("retry-after");
1159
- if (weeklyRaw || monthlyRaw) {
1160
- const { updateAccountQuota } = await import("../../codex/auth-api");
1161
- updateAccountQuota(
1162
- authCtx.accountId,
1163
- weeklyRaw,
1164
- weeklyResetRaw,
1165
- monthlyRaw,
1166
- monthlyResetRaw,
1167
- );
1168
- }
1176
+ const { applyAccountQuotaFromUpstreamHeaders } = await import("../../codex/auth-api");
1177
+ applyAccountQuotaFromUpstreamHeaders(authCtx.accountId, upstreamResponse.headers);
1169
1178
  if (terminalBodyWillRecord) {
1170
1179
  options.setTerminalOutcomeRecorder?.((status, httpStatusOverride) => {
1171
1180
  terminalRecorder(status, httpStatusOverride);
@@ -1174,8 +1183,13 @@ export async function handleResponses(
1174
1183
  } else {
1175
1184
  recordCodexUpstreamOutcome(config, authCtx.accountId, upstreamResponse.status, {
1176
1185
  retryAfter: retryAfterRaw,
1177
- resetAt: [primaryResetRaw, secondaryResetRaw, monthlyResetRaw].filter(Boolean),
1186
+ resetAt: [
1187
+ upstreamResponse.headers.get("x-codex-primary-reset-at"),
1188
+ upstreamResponse.headers.get("x-codex-secondary-reset-at"),
1189
+ upstreamResponse.headers.get("x-codex-tertiary-reset-at"),
1190
+ ].filter(Boolean),
1178
1191
  threadId: req.headers.get("x-codex-parent-thread-id"),
1192
+ probeLeaseId: codexProbeLeaseId(authCtx),
1179
1193
  });
1180
1194
  }
1181
1195
  }
@@ -1634,8 +1648,136 @@ export async function handleResponses(
1634
1648
 
1635
1649
  cancelBodyOnAbort(upstreamResponse.body, upstream.signal);
1636
1650
 
1651
+ // Claude can return a clean end_turn after announcing an edit without emitting any tool call.
1652
+ // Keep the normal request/recovery path above intact, and use this bounded callback only for the
1653
+ // one internal continuation pass. A continuation failure becomes an in-stream adapter error so
1654
+ // the client never sees a second hidden HTTP response or an unbounded retry loop.
1655
+ const terminalGuardEnabled = activeAdapter.name === "anthropic" && !options.comboAttempt && !routedCompaction;
1656
+ const fetchTerminalGuardContinuation = async function* (nextParsed: OcxParsedRequest): AsyncGenerator<AdapterEvent> {
1657
+ let imageTierBias = 0;
1658
+ let response: Response | undefined;
1659
+ while (true) {
1660
+ try {
1661
+ const continuationRequest = await activeAdapter.buildRequest(nextParsed, {
1662
+ headers: selectedForwardHeaders,
1663
+ ...(imageTierBias > 0 ? { imageTierBias } : {}),
1664
+ });
1665
+ const continuationEstimate = typeof continuationRequest.usageLog?.inputTokens === "number"
1666
+ ? continuationRequest.usageLog.inputTokens
1667
+ : undefined;
1668
+ if (continuationEstimate !== undefined) logCtx.usageLogInputTokens = continuationEstimate;
1669
+ if (activeAdapter.fetchResponse) {
1670
+ noteAttemptSend(logCtx.activeAttempt, continuationEstimate);
1671
+ response = await activeAdapter.fetchResponse(continuationRequest, {
1672
+ abortSignal: upstream.signal,
1673
+ timeoutMs: connectMs,
1674
+ stream: nextParsed.stream,
1675
+ });
1676
+ } else {
1677
+ response = await fetchWithResetRetry(
1678
+ recovery => {
1679
+ noteAttemptSend(logCtx.activeAttempt, continuationEstimate, recovery);
1680
+ return fetchWithHeaderTimeout(
1681
+ continuationRequest.url,
1682
+ applyUpstreamRecoveryInit({
1683
+ method: continuationRequest.method,
1684
+ headers: continuationRequest.headers,
1685
+ body: continuationRequest.body,
1686
+ }, recovery),
1687
+ upstream.signal,
1688
+ connectMs,
1689
+ nextParsed.stream,
1690
+ providerFetch(route.provider),
1691
+ );
1692
+ },
1693
+ { abortSignal: upstream.signal, label: safeHostLabel(continuationRequest.url) },
1694
+ );
1695
+ }
1696
+ } catch (error) {
1697
+ if (options.abortSignal?.aborted) {
1698
+ yield { type: "error", message: "client closed request during terminal continuation", status: 499 };
1699
+ } else {
1700
+ yield { type: "error", message: `Provider continuation failed: ${error instanceof Error ? error.message : String(error)}` };
1701
+ }
1702
+ return;
1703
+ }
1704
+
1705
+ if (response.status === 429 && hasKeyPoolFailover(route.provider)) {
1706
+ const rotated = rotateProviderTransportOn429(config, route.providerName, {
1707
+ retryAfter: response.headers.get("retry-after"),
1708
+ now: Date.now(),
1709
+ attemptedKey: route.provider.apiKey,
1710
+ promptCacheKey: nextParsed.options.promptCacheKey,
1711
+ });
1712
+ if (rotated) {
1713
+ try { void response.body?.cancel().catch(() => {}); } catch { /* already closed */ }
1714
+ route.provider = rotated;
1715
+ activeAdapter = resolveAdapter(
1716
+ resolveWireProtocolOverride(route.providerName, route.modelId, route.provider),
1717
+ config.cacheRetention,
1718
+ );
1719
+ continue;
1720
+ }
1721
+ }
1722
+ if (shouldAttemptImageTierRetry({
1723
+ status: response.status,
1724
+ adapterName: activeAdapter.name,
1725
+ parsed: nextParsed,
1726
+ alreadyAttempted: imageTierBias > 0,
1727
+ })) {
1728
+ imageTierBias = 1;
1729
+ try { void response.body?.cancel().catch(() => {}); } catch { /* already closed */ }
1730
+ continue;
1731
+ }
1732
+ break;
1733
+ }
1734
+
1735
+ if (!response.ok) {
1736
+ const errorText = await response.text().catch(() => "unknown error");
1737
+ yield {
1738
+ type: "error",
1739
+ status: response.status,
1740
+ message: `Provider continuation error ${response.status}: ${redactSecretString(errorText.slice(0, 500))}`,
1741
+ };
1742
+ return;
1743
+ }
1744
+
1745
+ try {
1746
+ // Protect the continuation body against a client abort landing between fetch resolution and
1747
+ // reader attach, exactly as the initial response is guarded above (#390/366e3053). Without
1748
+ // this, a client cancel during the continuation reopens the Bun fetch-to-reader abort race.
1749
+ const detachContinuationBodyGuard = cancelBodyOnAbort(response.body, upstream.signal);
1750
+ try {
1751
+ if (nextParsed.stream) {
1752
+ yield* activeAdapter.parseStream(response);
1753
+ } else if (activeAdapter.parseResponse) {
1754
+ yield* await activeAdapter.parseResponse(response);
1755
+ } else {
1756
+ yield { type: "error", message: "Provider continuation does not support response parsing" };
1757
+ }
1758
+ } finally {
1759
+ detachContinuationBodyGuard();
1760
+ }
1761
+ } catch (error) {
1762
+ if (options.abortSignal?.aborted) {
1763
+ yield { type: "error", message: "client closed request during terminal continuation", status: 499 };
1764
+ } else {
1765
+ yield { type: "error", message: `Provider continuation parse failed: ${redactSecretString(error instanceof Error ? error.message : String(error))}` };
1766
+ }
1767
+ }
1768
+ };
1769
+
1637
1770
  if (parsed.stream) {
1638
- const eventStream = activeAdapter.parseStream(upstreamResponse);
1771
+ const initialEventStream = activeAdapter.parseStream(upstreamResponse);
1772
+ const eventStream = terminalGuardEnabled
1773
+ ? guardTerminalEventStream({
1774
+ parsed,
1775
+ firstEvents: initialEventStream,
1776
+ adapterName: activeAdapter.name,
1777
+ maxAutoContinuations: 1,
1778
+ continuation: fetchTerminalGuardContinuation,
1779
+ })
1780
+ : initialEventStream;
1639
1781
  const { toolNsMap, freeformToolNames, toolSearchToolNames } = buildToolBridgeMaps(parsed);
1640
1782
  const sseStream = bridgeToResponsesSSE(
1641
1783
  eventStream, parsed.modelId, toolNsMap, freeformToolNames, toolSearchToolNames,
@@ -1670,7 +1812,19 @@ export async function handleResponses(
1670
1812
  if (activeAdapter.parseResponse) {
1671
1813
  let events: AdapterEvent[];
1672
1814
  try {
1673
- events = await activeAdapter.parseResponse(upstreamResponse);
1815
+ const initialEvents = await activeAdapter.parseResponse(upstreamResponse);
1816
+ if (terminalGuardEnabled) {
1817
+ events = [];
1818
+ for await (const event of guardTerminalEventStream({
1819
+ parsed,
1820
+ firstEvents: (async function* () { yield* initialEvents; })(),
1821
+ adapterName: activeAdapter.name,
1822
+ maxAutoContinuations: 1,
1823
+ continuation: fetchTerminalGuardContinuation,
1824
+ })) events.push(event);
1825
+ } else {
1826
+ events = initialEvents;
1827
+ }
1674
1828
  } finally {
1675
1829
  cleanupUpstreamAbort();
1676
1830
  }