@bitkyc08/opencodex 2.7.30 → 2.7.33-preview.20260722

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 (59) hide show
  1. package/README.ja.md +438 -0
  2. package/README.ko.md +1 -1
  3. package/README.md +1 -1
  4. package/README.ru.md +480 -0
  5. package/README.zh-CN.md +1 -1
  6. package/bin/ocx.mjs +18 -1
  7. package/gui/dist/assets/index-B79f-04T.js +52 -0
  8. package/gui/dist/assets/index-D6Fcl4yM.css +1 -0
  9. package/gui/dist/index.html +2 -2
  10. package/package.json +1 -1
  11. package/src/adapters/anthropic.ts +17 -2
  12. package/src/adapters/cursor/discovery.ts +2 -2
  13. package/src/adapters/google-tool-schema.ts +4 -0
  14. package/src/adapters/google.ts +17 -2
  15. package/src/adapters/openai-chat.ts +36 -1
  16. package/src/adapters/openai-responses.ts +2 -1
  17. package/src/bridge.ts +12 -4
  18. package/src/cli/account-api.ts +4 -2
  19. package/src/cli/account-extended.ts +34 -0
  20. package/src/cli/account.ts +3 -1
  21. package/src/cli/claude.ts +6 -1
  22. package/src/cli/help.ts +25 -4
  23. package/src/cli/init.ts +38 -3
  24. package/src/cli/models.ts +206 -7
  25. package/src/codex/auth-api.ts +45 -3
  26. package/src/codex/catalog.ts +105 -12
  27. package/src/codex/routing.ts +85 -4
  28. package/src/combos/index.ts +1 -1
  29. package/src/combos/request.ts +26 -4
  30. package/src/combos/types.ts +4 -4
  31. package/src/config.ts +60 -3
  32. package/src/lib/upstream-retry.ts +21 -0
  33. package/src/lib/winsw.ts +7 -1
  34. package/src/oauth/anthropic.ts +23 -1
  35. package/src/oauth/github-copilot.ts +1 -0
  36. package/src/oauth/index.ts +88 -8
  37. package/src/oauth/kiro.ts +12 -1
  38. package/src/oauth/local-token-detect.ts +3 -0
  39. package/src/oauth/store.ts +43 -0
  40. package/src/oauth/types.ts +3 -1
  41. package/src/providers/antigravity-models.ts +123 -14
  42. package/src/providers/api-keys.ts +12 -0
  43. package/src/providers/openrouter-routing.ts +102 -0
  44. package/src/providers/registry.ts +41 -17
  45. package/src/router.ts +2 -1
  46. package/src/server/auth-cors.ts +5 -0
  47. package/src/server/index.ts +3 -3
  48. package/src/server/management-api.ts +143 -6
  49. package/src/server/relay.ts +31 -5
  50. package/src/server/request-log.ts +12 -0
  51. package/src/server/responses.ts +127 -31
  52. package/src/service.ts +12 -3
  53. package/src/types.ts +44 -3
  54. package/src/update/index.ts +19 -2
  55. package/src/update/job.ts +13 -3
  56. package/src/usage/expected-prices.ts +22 -9
  57. package/src/usage/summary.ts +43 -0
  58. package/gui/dist/assets/index-B-UauL1p.css +0 -1
  59. package/gui/dist/assets/index-avcinRsG.js +0 -40
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import { readFileSync } from "node:fs";
2
3
  import type { CatalogModel } from "../codex/catalog";
3
4
  import { invalidateCodexModelsCache, nativeModelRows } from "../codex/catalog";
@@ -46,7 +47,7 @@ import {
46
47
  setDebugSettings,
47
48
  type DebugFlag,
48
49
  } from "../lib/debug-settings";
49
- import type { OcxClaudeCodeConfig, OcxConfig, OcxProviderConfig } from "../types";
50
+ import type { OcxClaudeCodeConfig, OcxConfig, OcxCustomModel, OcxProviderConfig } from "../types";
50
51
  import { drainAndShutdown } from "./lifecycle";
51
52
  import { filterRequestLogs, getRequestLogEntries, type RequestLogEntry } from "./request-log";
52
53
  import { estimateComboCost, estimateRequestCost, normalizeCostTokens, tokensPerSecond } from "../usage/cost";
@@ -798,9 +799,26 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
798
799
  native: true,
799
800
  ...(row.contextWindow !== undefined ? { contextWindow: row.contextWindow } : {}),
800
801
  }));
801
- return jsonResponse([...native, ...models.map(m => {
802
+ const customModels = (config.customModels ?? []).map(cm => {
803
+ const namespaced = routedSlug(cm.provider, cm.modelId);
804
+ return {
805
+ provider: cm.provider,
806
+ id: cm.modelId,
807
+ namespaced,
808
+ disabled: [...disabled].some(stored => slugEquals(stored, cm.provider, cm.modelId)),
809
+ custom: true,
810
+ customId: cm.id,
811
+ displayName: cm.displayName,
812
+ ...(cm.contextWindow ? { contextWindow: cm.contextWindow } : {}),
813
+ ...(cm.inputModalities ? { inputModalities: cm.inputModalities } : {}),
814
+ };
815
+ });
816
+ // Custom metadata wins when a live/static routed row resolves to the same Codex-facing slug.
817
+ const customNamespaced = new Set(customModels.map(c => c.namespaced));
818
+ const dedupedRouted = models.map(m => {
802
819
  // Codex-facing slug (one "/", slug-codec); disabledModels compares tolerate both forms.
803
820
  const namespaced = routedSlug(m.provider, m.id);
821
+ if (customNamespaced.has(namespaced)) return null;
804
822
  const contextCap = providerContextCap(config, m.provider);
805
823
  return {
806
824
  ...m,
@@ -808,7 +826,8 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
808
826
  disabled: [...disabled].some(stored => slugEquals(stored, m.provider, m.id)),
809
827
  ...(contextCap !== undefined ? { contextCap, contextCapped: m.contextCapped === true } : {}),
810
828
  };
811
- })]);
829
+ }).filter(Boolean);
830
+ return jsonResponse([...native, ...dedupedRouted, ...customModels]);
812
831
  }
813
832
 
814
833
  if (url.pathname === "/api/provider-context-caps" && req.method === "GET") {
@@ -880,6 +899,96 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
880
899
  return jsonResponse({ ok: true, disabled });
881
900
  }
882
901
 
902
+ if (url.pathname === "/api/custom-models" && req.method === "GET") {
903
+ return jsonResponse(config.customModels ?? []);
904
+ }
905
+
906
+ if (url.pathname === "/api/custom-models" && req.method === "POST") {
907
+ let body: { provider?: unknown; modelId?: unknown; displayName?: unknown; contextWindow?: unknown; inputModalities?: unknown };
908
+ try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
909
+ const provider = typeof body.provider === "string" ? body.provider.trim() : "";
910
+ const modelId = typeof body.modelId === "string" ? body.modelId.trim() : "";
911
+ if (!provider || !modelId) return jsonResponse({ error: "provider and modelId are required" }, 400);
912
+ if (modelId.includes("/")) return jsonResponse({ error: "modelId must not contain /" }, 400);
913
+ if (!isValidProviderName(provider)) return jsonResponse({ error: "invalid provider name" }, 400);
914
+ if (!hasOwnProvider(config.providers, provider)) return jsonResponse({ error: "provider not configured" }, 404);
915
+ const displayName = typeof body.displayName === "string" && body.displayName.trim() ? body.displayName.trim() : undefined;
916
+ if (displayName?.includes("/")) return jsonResponse({ error: "displayName must not contain /" }, 400);
917
+ const contextWindow = typeof body.contextWindow === "number" && body.contextWindow > 0 ? Math.floor(body.contextWindow) : undefined;
918
+ const inputModalities = Array.isArray(body.inputModalities) ? body.inputModalities.filter((m): m is string => typeof m === "string") : undefined;
919
+ const existing = config.customModels ?? [];
920
+ const newSlug = routedSlug(provider, modelId);
921
+ if (existing.some(cm => routedSlug(cm.provider, cm.modelId) === newSlug)) {
922
+ return jsonResponse({ error: "duplicate model" }, 409);
923
+ }
924
+ const entry: OcxCustomModel = {
925
+ id: randomUUID(),
926
+ provider,
927
+ modelId,
928
+ ...(displayName ? { displayName } : {}),
929
+ ...(contextWindow ? { contextWindow } : {}),
930
+ ...(inputModalities && inputModalities.length > 0 ? { inputModalities } : {}),
931
+ addedAt: new Date().toISOString(),
932
+ };
933
+ config.customModels = [...existing, entry];
934
+ const { saveConfig: save } = await import("../config");
935
+ save(config);
936
+ await refreshCodexCatalogBestEffort();
937
+ return jsonResponse(entry, 201);
938
+ }
939
+
940
+ const customPutMatch = url.pathname.match(/^\/api\/custom-models\/([^/]+)$/);
941
+ if (customPutMatch && req.method === "PUT") {
942
+ let id: string;
943
+ try { id = decodeURIComponent(customPutMatch[1]); } catch { return jsonResponse({ error: "invalid id encoding" }, 400); }
944
+ let body: { displayName?: unknown; contextWindow?: unknown; inputModalities?: unknown; modelId?: unknown };
945
+ try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
946
+ const list = config.customModels ?? [];
947
+ const idx = list.findIndex(cm => cm.id === id);
948
+ if (idx === -1) return jsonResponse({ error: "not found" }, 404);
949
+ const cm = { ...list[idx] };
950
+ if (typeof body.modelId === "string" && body.modelId.trim()) {
951
+ if (body.modelId.includes("/")) return jsonResponse({ error: "modelId must not contain /" }, 400);
952
+ cm.modelId = body.modelId.trim();
953
+ }
954
+ if (body.displayName !== undefined) {
955
+ const dn = typeof body.displayName === "string" ? body.displayName.trim() : "";
956
+ if (dn.includes("/")) return jsonResponse({ error: "displayName must not contain /" }, 400);
957
+ cm.displayName = dn || undefined;
958
+ }
959
+ if (body.contextWindow !== undefined) {
960
+ cm.contextWindow = typeof body.contextWindow === "number" && body.contextWindow > 0 ? Math.floor(body.contextWindow) : undefined;
961
+ }
962
+ if (body.inputModalities !== undefined) {
963
+ cm.inputModalities = Array.isArray(body.inputModalities) ? body.inputModalities.filter((m): m is string => typeof m === "string") : undefined;
964
+ }
965
+ const updatedSlug = routedSlug(cm.provider, cm.modelId);
966
+ if (list.some((other, i) => i !== idx && routedSlug(other.provider, other.modelId) === updatedSlug)) {
967
+ return jsonResponse({ error: "duplicate model" }, 409);
968
+ }
969
+ list[idx] = cm;
970
+ config.customModels = list;
971
+ const { saveConfig: save } = await import("../config");
972
+ save(config);
973
+ await refreshCodexCatalogBestEffort();
974
+ return jsonResponse(cm);
975
+ }
976
+
977
+ const customDelMatch = url.pathname.match(/^\/api\/custom-models\/([^/]+)$/);
978
+ if (customDelMatch && req.method === "DELETE") {
979
+ let id: string;
980
+ try { id = decodeURIComponent(customDelMatch[1]); } catch { return jsonResponse({ error: "invalid id encoding" }, 400); }
981
+ const list = config.customModels ?? [];
982
+ const idx = list.findIndex(cm => cm.id === id);
983
+ if (idx === -1) return jsonResponse({ error: "not found" }, 404);
984
+ list.splice(idx, 1);
985
+ config.customModels = list.length > 0 ? list : undefined;
986
+ const { saveConfig: save } = await import("../config");
987
+ save(config);
988
+ await refreshCodexCatalogBestEffort();
989
+ return jsonResponse({ ok: true });
990
+ }
991
+
883
992
  // multi_agent_v2 surface toggle. GET reports the flag + the agents.max_threads
884
993
  // boot conflict; PUT flips it via the official `codex features` CLI and RESYNCS
885
994
  // the catalog so multi-agent surface metadata stays fresh. The catalog build
@@ -1375,18 +1484,18 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
1375
1484
  }
1376
1485
  }
1377
1486
  // addAccount / reauth forces a fresh browser identity (skips local-CLI token import).
1378
- const { url: authUrl, instructions } = await startLoginFlow(provider, {
1487
+ const { url: authUrl, instructions, deviceCode } = await startLoginFlow(provider, {
1379
1488
  forceLogin: body.addAccount === true || reauth,
1380
1489
  ...(accountId ? { reauthAccountId: accountId } : {}),
1381
1490
  });
1382
1491
  upsertOAuthProvider(config, provider); // mutate LIVE config — routing sees it without restart
1383
- if (authUrl) {
1492
+ if (authUrl && !deviceCode) {
1384
1493
  // Open the browser server-side (the proxy runs on the user's machine) — the GUI's
1385
1494
  // window.open is popup-blocked because it runs after an await, not a direct click.
1386
1495
  const { openUrl } = await import("../lib/open-url");
1387
1496
  openUrl(authUrl);
1388
1497
  }
1389
- return jsonResponse({ url: authUrl, instructions });
1498
+ return jsonResponse({ url: authUrl, instructions, deviceCode });
1390
1499
  } catch (err) {
1391
1500
  return jsonResponse({ error: err instanceof Error ? err.message : String(err) }, 409);
1392
1501
  }
@@ -1454,6 +1563,20 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
1454
1563
  clearProviderQuotaCache();
1455
1564
  return jsonResponse({ ok: true, provider, activeAccountId: body.accountId });
1456
1565
  }
1566
+ if (url.pathname === "/api/oauth/accounts/alias" && req.method === "PUT") {
1567
+ const body = await req.json().catch(() => ({})) as { provider?: unknown; accountId?: unknown; alias?: unknown };
1568
+ const provider = typeof body.provider === "string" ? body.provider.trim().toLowerCase() : "";
1569
+ const accountId = typeof body.accountId === "string" ? body.accountId.trim() : "";
1570
+ const alias = typeof body.alias === "string" ? body.alias.trim() : "";
1571
+ if (!isPublicOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400);
1572
+ if (!accountId) return jsonResponse({ error: "missing accountId" }, 400);
1573
+ if (typeof body.alias !== "string" || alias.length > 80 || /[\x00-\x1f\x7f]/.test(alias)) {
1574
+ return jsonResponse({ error: "alias must be at most 80 printable characters" }, 400);
1575
+ }
1576
+ const { setAccountAlias } = await import("../oauth/store");
1577
+ if (!(await setAccountAlias(provider, accountId, alias || undefined))) return jsonResponse({ error: "account not found" }, 404);
1578
+ return jsonResponse({ ok: true, provider, accountId, alias: alias || null });
1579
+ }
1457
1580
  if (url.pathname === "/api/oauth/accounts" && req.method === "DELETE") {
1458
1581
  const provider = (url.searchParams.get("provider") ?? "").trim().toLowerCase();
1459
1582
  const id = url.searchParams.get("id") ?? "";
@@ -1507,6 +1630,20 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
1507
1630
  clearKeyCooldowns(name); // manual key management resets 429 cooldown state
1508
1631
  return jsonResponse({ ok: true, name, activeId: body.id });
1509
1632
  }
1633
+ if (url.pathname === "/api/providers/keys/alias" && req.method === "PUT") {
1634
+ const body = await req.json().catch(() => ({})) as { name?: unknown; id?: unknown; alias?: unknown };
1635
+ const name = typeof body.name === "string" ? body.name.trim() : "";
1636
+ const id = typeof body.id === "string" ? body.id.trim() : "";
1637
+ const alias = typeof body.alias === "string" ? body.alias.trim() : "";
1638
+ if (!name || !isValidProviderName(name) || !hasOwnProvider(config.providers, name)) return jsonResponse({ error: "unknown provider" }, 404);
1639
+ if (!id) return jsonResponse({ error: "missing id" }, 400);
1640
+ if (typeof body.alias !== "string" || alias.length > 80 || /[\x00-\x1f\x7f]/.test(alias)) {
1641
+ return jsonResponse({ error: "alias must be at most 80 printable characters" }, 400);
1642
+ }
1643
+ const { setProviderApiKeyLabel } = await import("../providers/api-keys");
1644
+ if (!setProviderApiKeyLabel(config, name, id, alias || undefined)) return jsonResponse({ error: "key not found" }, 404);
1645
+ return jsonResponse({ ok: true, name, id, alias: alias || null });
1646
+ }
1510
1647
  if (url.pathname === "/api/providers/keys" && req.method === "DELETE") {
1511
1648
  const name = (url.searchParams.get("name") ?? "").trim();
1512
1649
  const id = url.searchParams.get("id") ?? "";
@@ -406,7 +406,7 @@ export function relaySseWithHeartbeat(
406
406
  */
407
407
  export function consumeForInspection(
408
408
  body: ReadableStream<Uint8Array>,
409
- onTerminal: (status: ResponsesTerminalStatus) => void,
409
+ onTerminal: (status: ResponsesTerminalStatus, httpStatusOverride?: number) => void,
410
410
  signal?: AbortSignal,
411
411
  onDone?: () => void,
412
412
  logCtx?: RequestLogContext,
@@ -451,14 +451,24 @@ export function consumeForInspection(
451
451
  reportFirstOutput(payload);
452
452
  if (payload) {
453
453
  const status = terminalStatusFromSsePayload(payload);
454
- if (status) { reported = true; onTerminal(status); }
454
+ if (status) {
455
+ reported = true;
456
+ if (logCtx) {
457
+ logCtx.transportPhase = "terminal_sse";
458
+ logCtx.terminalSource = "upstream";
459
+ }
460
+ onTerminal(status);
461
+ }
455
462
  if (onCompletedResponse) {
456
463
  const response = completedResponseFromSsePayload(payload);
457
464
  if (response) onCompletedResponse(response);
458
465
  }
459
466
  }
460
467
  }
461
- if (!reported && !cancelled) onTerminal("incomplete");
468
+ if (!reported && !cancelled) {
469
+ if (logCtx) logCtx.terminalSource = "synthetic";
470
+ onTerminal("incomplete");
471
+ }
462
472
  return;
463
473
  }
464
474
  buffer += decoder.decode(value, { stream: true });
@@ -472,7 +482,14 @@ export function consumeForInspection(
472
482
  if (!payload) continue;
473
483
  if (!reported) {
474
484
  const status = terminalStatusFromSsePayload(payload);
475
- if (status) { reported = true; onTerminal(status); }
485
+ if (status) {
486
+ reported = true;
487
+ if (logCtx) {
488
+ logCtx.transportPhase = "terminal_sse";
489
+ logCtx.terminalSource = "upstream";
490
+ }
491
+ onTerminal(status);
492
+ }
476
493
  }
477
494
  if (onCompletedResponse) {
478
495
  const response = completedResponseFromSsePayload(payload);
@@ -481,7 +498,16 @@ export function consumeForInspection(
481
498
  }
482
499
  }
483
500
  } catch {
484
- if (!reported && !cancelled) onTerminal("incomplete");
501
+ // Upstream read failure after HTTP 200 (mid-stream socket reset) is not a
502
+ // protocol `response.incomplete` terminal. Report a synthetic 502 so account
503
+ // health treats it as transient; abort-driven client cancellation still wins.
504
+ if (!reported && !cancelled) {
505
+ if (logCtx) {
506
+ logCtx.transportPhase = "mid_stream";
507
+ logCtx.terminalSource = "synthetic";
508
+ }
509
+ onTerminal("failed", 502);
510
+ }
485
511
  } finally {
486
512
  onDone?.();
487
513
  }
@@ -62,6 +62,9 @@ export interface RequestLogContext {
62
62
  upstreamError?: string;
63
63
  /** HTTP status derived from a terminal `response.failed` SSE payload (429/401/503/etc.). */
64
64
  terminalHttpStatus?: number;
65
+ affinity?: "reused" | "new_bind" | "rebound" | "cleared";
66
+ transportPhase?: "pre_headers" | "mid_stream" | "terminal_sse";
67
+ terminalSource?: "upstream" | "synthetic";
65
68
  }
66
69
 
67
70
  export interface RequestLogEntry {
@@ -92,6 +95,12 @@ export interface RequestLogEntry {
92
95
  usage?: OcxUsage;
93
96
  totalTokens?: number;
94
97
  attempts?: PersistedUsageAttempt[];
98
+ /** Codex pool affinity decision for this request (diagnostics for #186). */
99
+ affinity?: "reused" | "new_bind" | "rebound" | "cleared";
100
+ /** Where the upstream terminal/failure was observed. */
101
+ transportPhase?: "pre_headers" | "mid_stream" | "terminal_sse";
102
+ /** Whether the terminal came from a real upstream SSE event or a proxy synthetic tail. */
103
+ terminalSource?: "upstream" | "synthetic";
95
104
  }
96
105
 
97
106
  const requestLog: RequestLogEntry[] = [];
@@ -587,6 +596,9 @@ export function addFinalRequestLog(
587
596
  ...(loggedUsage ? { usage: loggedUsage } : {}),
588
597
  ...(totalTokens !== undefined ? { totalTokens } : {}),
589
598
  ...(attempts?.length ? { attempts } : {}),
599
+ ...(logCtx.affinity ? { affinity: logCtx.affinity } : {}),
600
+ ...(logCtx.transportPhase ? { transportPhase: logCtx.transportPhase } : {}),
601
+ ...(logCtx.terminalSource ? { terminalSource: logCtx.terminalSource } : {}),
590
602
  });
591
603
  if (isUsageDebugEnabled()) {
592
604
  appendUsageDebug({
@@ -6,7 +6,7 @@ import {
6
6
  } from "../config";
7
7
  import { parseRequest } from "../responses/parser";
8
8
  import { buildCompactV1Output, COMPACT_PROMPT, decodeCompactionSummary, extractCompactUserMessages } from "../responses/compaction";
9
- import { FORWARD_HEADERS } from "../adapters/openai-responses";
9
+ import { FORWARD_HEADERS, sanitizeReasoningInputContent } from "../adapters/openai-responses";
10
10
  import { expandPreviousResponseInput, previousResponseConversationId, rememberResponseState } from "../responses/state";
11
11
  import { routeModel } from "../router";
12
12
  import {
@@ -55,7 +55,7 @@ import {
55
55
  recordCodexUpstreamOutcome,
56
56
  type CodexUpstreamOutcome,
57
57
  } from "../codex/routing";
58
- import { fetchWithResetRetry, fetchWithTransientRetry } from "../lib/upstream-retry";
58
+ import { fetchWithResetRetry, fetchWithTransientRetry, applyUpstreamRecoveryInit } from "../lib/upstream-retry";
59
59
  import { ForwardAdmissionCredentialError, validateForwardAdmissionCredential } from "./auth-cors";
60
60
  import { listOpenAiForwardSidecarCandidates, resolveFirstUsableOpenAiSidecar, type ResolvedOpenAiForwardSidecar } from "../providers/openai-sidecar";
61
61
  import { applyOpenAiVirtualModel, resolveOpenAiCompactModel } from "../providers/openai-virtual-models";
@@ -69,6 +69,7 @@ import type { WsData } from "./ws-bridge";
69
69
  import { registerTurn, trackStreamLifetime, unregisterTurn } from "./lifecycle";
70
70
  import { redactSecretString } from "../lib/redact";
71
71
  import { readBoundedResponseBody } from "../lib/bounded-body";
72
+ import { supportedLadderFor } from "./effort-policy";
72
73
  import {
73
74
  beginRequestAttempt,
74
75
  catalogModelSupportsServiceTier,
@@ -395,9 +396,13 @@ export function sanitizeEncryptedContentInPlace(input: unknown): number {
395
396
  return rewritten;
396
397
  }
397
398
 
398
- export function sidecarOutcomeRecorder(config: OcxConfig, authCtx: CodexAuthContext): ((outcome: CodexUpstreamOutcome) => void) | undefined {
399
+ export function sidecarOutcomeRecorder(
400
+ config: OcxConfig,
401
+ authCtx: CodexAuthContext,
402
+ threadId?: string | null,
403
+ ): ((outcome: CodexUpstreamOutcome) => void) | undefined {
399
404
  return authCtx.kind === "pool" || authCtx.kind === "main-pool"
400
- ? outcome => recordCodexUpstreamOutcome(config, authCtx.accountId, outcome)
405
+ ? outcome => recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, { threadId })
401
406
  : undefined;
402
407
  }
403
408
 
@@ -418,9 +423,32 @@ export function codexForwardTerminalOutcomeRecorder(
418
423
  config: OcxConfig,
419
424
  authCtx: CodexAuthContext,
420
425
  provider: OcxProviderConfig,
421
- ): ((status: ResponsesTerminalStatus) => void) | undefined {
426
+ logCtx?: RequestLogContext,
427
+ threadId?: string | null,
428
+ ): ((status: ResponsesTerminalStatus, httpStatusOverride?: number) => void) | undefined {
422
429
  if (!usesCodexForwardPoolAuth(authCtx, provider)) return undefined;
423
- return status => recordCodexUpstreamOutcome(config, authCtx.accountId, status === "completed" ? 200 : 502);
430
+ return (status, httpStatusOverride) => {
431
+ if (status === "incomplete") {
432
+ // Normal limit/content-filter/stall terminal — the account served the
433
+ // request. Don't penalize account health; record success to clear any
434
+ // prior soft-avoid so a healthy account isn't stuck avoided.
435
+ recordCodexUpstreamOutcome(config, authCtx.accountId, 200, { threadId });
436
+ return;
437
+ }
438
+ // status === "completed" or "failed": use the semantic HTTP status derived
439
+ // from the terminal SSE error payload (httpStatusFromTerminalError in
440
+ // request-log inspection) instead of collapsing every non-completed terminal
441
+ // to 502. A 400 invalid_request_error must not soft-avoid the account or
442
+ // rebind threads — only genuine transport/5xx failures should trigger
443
+ // transient health recording.
444
+ // httpStatusOverride: the combo WS path inspects SSE payloads into the parent
445
+ // logCtx, but this recorder closes over the child logCtx. The caller passes
446
+ // the parent's terminalHttpStatus so the semantic status is not lost.
447
+ const outcome = status === "completed"
448
+ ? 200
449
+ : (httpStatusOverride ?? logCtx?.terminalHttpStatus ?? 502);
450
+ recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, { threadId });
451
+ };
424
452
  }
425
453
 
426
454
  /**
@@ -466,7 +494,7 @@ interface HandleResponsesOptions {
466
494
  onFirstOutput?: () => void;
467
495
  onCodexAuthContextResolved?: (context: CodexAuthContext | undefined) => void;
468
496
  recordTerminalOutcomes?: boolean;
469
- setTerminalOutcomeRecorder?: (recorder: ((status: ResponsesTerminalStatus) => void) | undefined) => void;
497
+ setTerminalOutcomeRecorder?: (recorder: ((status: ResponsesTerminalStatus, httpStatusOverride?: number) => void) | undefined) => void;
470
498
  onNativePassthroughTerminal?: (status: ResponsesTerminalStatus) => void;
471
499
  onNativePassthroughCancel?: () => void;
472
500
  /** Internal recursion guard; callers outside this module must not set it. */
@@ -562,6 +590,15 @@ function createChildPassthroughCallbackGate(options: HandleResponsesOptions) {
562
590
  };
563
591
  }
564
592
 
593
+ export function buildComboChildHeaders(parentHeaders: HeadersInit): Headers {
594
+ const childHeaders = new Headers(parentHeaders);
595
+ // Combo children re-serialize already-decoded JSON. Keeping transport metadata from
596
+ // the parent would make the child decoder treat plain JSON as compressed bytes.
597
+ childHeaders.delete("content-length");
598
+ childHeaders.delete("content-encoding");
599
+ return childHeaders;
600
+ }
601
+
565
602
  async function handleComboResponses(
566
603
  req: Request,
567
604
  rawBody: unknown,
@@ -595,20 +632,21 @@ async function handleComboResponses(
595
632
  model: pick.target.model,
596
633
  provider: pick.target.provider,
597
634
  };
635
+ const targetRoute = routeModel(config, `${pick.target.provider}/${pick.target.model}`);
598
636
  const childBody = concreteComboRequestBody(
599
637
  rawBody,
600
638
  pick.target,
601
639
  comboDefaultEffort(config, comboId),
640
+ supportedLadderFor({ provider: targetRoute.provider, modelId: targetRoute.modelId }),
602
641
  );
603
- const childHeaders = new Headers(req.headers);
604
- childHeaders.delete("content-length");
642
+ const childHeaders = buildComboChildHeaders(req.headers);
605
643
  const childRequest = new Request(req.url, {
606
644
  method: req.method,
607
645
  headers: childHeaders,
608
646
  body: JSON.stringify(childBody),
609
647
  });
610
648
  let resolvedAuth: CodexAuthContext | undefined;
611
- let terminalRecorder: ((status: ResponsesTerminalStatus) => void) | undefined;
649
+ let terminalRecorder: ((status: ResponsesTerminalStatus, httpStatusOverride?: number) => void) | undefined;
612
650
  const started = Date.now();
613
651
  const attempt = beginRequestAttempt(
614
652
  (logCtx.attempts?.length ?? 0) + 1,
@@ -1092,11 +1130,11 @@ export async function handleResponses(
1092
1130
  upstreamResponse = await fetchWithTransientRetry(
1093
1131
  recovery => {
1094
1132
  noteAttemptSend(logCtx.activeAttempt, passthroughEstimate, recovery);
1095
- return fetchWithHeaderTimeout(request.url, {
1133
+ return fetchWithHeaderTimeout(request.url, applyUpstreamRecoveryInit({
1096
1134
  method: request.method,
1097
1135
  headers: request.headers,
1098
1136
  body: request.body,
1099
- }, upstream.signal, connectMs, parsed.stream, providerFetch(route.provider));
1137
+ }, recovery), upstream.signal, connectMs, parsed.stream, providerFetch(route.provider));
1100
1138
  },
1101
1139
  { abortSignal: upstream.signal, label: safeHostLabel(request.url) },
1102
1140
  );
@@ -1104,7 +1142,11 @@ export async function handleResponses(
1104
1142
  upstream.abort();
1105
1143
  if (options.abortSignal?.aborted) return clientCancelledResponse();
1106
1144
  const outcome = err instanceof Error && err.name === "TimeoutError" ? "timeout" : "connect_error";
1107
- if (usesCodexForwardPoolAuth(authCtx, route.provider)) recordCodexUpstreamOutcome(config, authCtx.accountId, outcome);
1145
+ if (usesCodexForwardPoolAuth(authCtx, route.provider)) {
1146
+ recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, {
1147
+ threadId: req.headers.get("x-codex-parent-thread-id"),
1148
+ });
1149
+ }
1108
1150
  const msg = outcome === "timeout"
1109
1151
  ? `Provider connect timeout after ${connectMs}ms`
1110
1152
  : `Provider unreachable: ${err instanceof Error ? err.message : String(err)}`;
@@ -1122,7 +1164,13 @@ export async function handleResponses(
1122
1164
  const passthroughCt = headers.get("content-type")?.toLowerCase();
1123
1165
  const isEventStream = passthroughCt?.includes("text/event-stream")
1124
1166
  || (upstreamResponse.ok && !!upstreamResponse.body && !passthroughCt && parsed.stream);
1125
- const terminalRecorder = codexForwardTerminalOutcomeRecorder(config, authCtx, route.provider);
1167
+ const terminalRecorder = codexForwardTerminalOutcomeRecorder(
1168
+ config,
1169
+ authCtx,
1170
+ route.provider,
1171
+ logCtx,
1172
+ req.headers.get("x-codex-parent-thread-id"),
1173
+ );
1126
1174
  const terminalBodyWillRecord = !!terminalRecorder && upstreamResponse.ok && isEventStream;
1127
1175
  // Capture quota from upstream response for multi-account tracking
1128
1176
  if (usesCodexForwardPoolAuth(authCtx, route.provider)) {
@@ -1148,14 +1196,15 @@ export async function handleResponses(
1148
1196
  );
1149
1197
  }
1150
1198
  if (terminalBodyWillRecord) {
1151
- options.setTerminalOutcomeRecorder?.(status => {
1152
- terminalRecorder(status);
1199
+ options.setTerminalOutcomeRecorder?.((status, httpStatusOverride) => {
1200
+ terminalRecorder(status, httpStatusOverride);
1153
1201
  options.onNativePassthroughTerminal?.(status);
1154
1202
  });
1155
1203
  } else {
1156
1204
  recordCodexUpstreamOutcome(config, authCtx.accountId, upstreamResponse.status, {
1157
1205
  retryAfter: retryAfterRaw,
1158
1206
  resetAt: [primaryResetRaw, secondaryResetRaw, monthlyResetRaw].filter(Boolean),
1207
+ threadId: req.headers.get("x-codex-parent-thread-id"),
1159
1208
  });
1160
1209
  }
1161
1210
  }
@@ -1174,8 +1223,8 @@ export async function handleResponses(
1174
1223
  // even if the client has already disconnected: the turn genuinely reached that terminal, so
1175
1224
  // it must log as completed/failed, not be dropped or downgraded to a cancel (#44). A pure
1176
1225
  // client-cancel (no terminal seen) is finalized separately via consumeForInspection's onCancel.
1177
- const reportNativeTerminal = (status: ResponsesTerminalStatus) => {
1178
- terminalRecorder?.(status);
1226
+ const reportNativeTerminal = (status: ResponsesTerminalStatus, httpStatusOverride?: number) => {
1227
+ terminalRecorder?.(status, httpStatusOverride);
1179
1228
  options.onNativePassthroughTerminal?.(status);
1180
1229
  };
1181
1230
  consumeForInspection(
@@ -1390,9 +1439,11 @@ export async function handleResponses(
1390
1439
  upstreamResponse = await fetchWithResetRetry(
1391
1440
  recovery => {
1392
1441
  noteAttemptSend(logCtx.activeAttempt, inputTokenEstimate, recovery);
1393
- return fetchWithHeaderTimeout(request.url, {
1394
- method: request.method, headers: request.headers, body: request.body,
1395
- }, upstream.signal, connectMs, parsed.stream, providerFetch(route.provider));
1442
+ return fetchWithHeaderTimeout(request.url, applyUpstreamRecoveryInit({
1443
+ method: request.method,
1444
+ headers: request.headers,
1445
+ body: request.body,
1446
+ }, recovery), upstream.signal, connectMs, parsed.stream, providerFetch(route.provider));
1396
1447
  },
1397
1448
  { abortSignal: upstream.signal, label: safeHostLabel(request.url) },
1398
1449
  );
@@ -1710,10 +1761,11 @@ export async function handleResponsesCompact(
1710
1761
  // headers would run compaction on the wrong account (or 401) whenever a pool account is
1711
1762
  // active for this thread while normal turns succeed.
1712
1763
  let compactProvider = route.provider;
1764
+ let authCtx: CodexAuthContext = { kind: "main", accountId: null };
1713
1765
  const headers = new Headers({ "content-type": "application/json" });
1714
1766
  try {
1715
1767
  if (route.codexAccountMode) {
1716
- const authCtx = await resolveCodexAuthContext(req.headers, config, route.codexAccountMode);
1768
+ authCtx = await resolveCodexAuthContext(req.headers, config, route.codexAccountMode);
1717
1769
  const selected = headersForCodexAuthContext(req.headers, authCtx);
1718
1770
  compactProvider = applyCodexAuthContextToProvider(route.provider, authCtx, route.codexAccountMode);
1719
1771
  for (const name of FORWARD_HEADERS) {
@@ -1743,20 +1795,64 @@ export async function handleResponsesCompact(
1743
1795
  }
1744
1796
  const base = (compactProvider.baseUrl ?? "").replace(/\/$/, "");
1745
1797
  if (compactProvider.apiKey) headers.set("authorization", `Bearer ${resolveEnvValue(compactProvider.apiKey)}`);
1746
- const { reasoning: _reasoning, ...compactBody } = raw as typeof raw & { reasoning?: unknown };
1798
+ const { reasoning: _reasoning, ...compactBodyRaw } = raw as typeof raw & { reasoning?: unknown };
1799
+ // The regular /v1/responses path applies sanitizeReasoningInputContent via the adapter's
1800
+ // buildRequest, but the compact endpoint forwards directly. Apply the same sanitizer here
1801
+ // so routed-model reasoning items (reasoning_text content) don't 400 the ChatGPT backend.
1802
+ const compactBody = sanitizeReasoningInputContent(compactBodyRaw) as typeof compactBodyRaw;
1803
+ const compactUrl = `${base}/responses/compact`;
1804
+ const compactThreadId = req.headers.get("x-codex-parent-thread-id");
1805
+ const connectMs = config.connectTimeoutMs ?? 200_000;
1806
+ const recordCompactPoolOutcome = (outcome: CodexUpstreamOutcome, meta: { retryAfter?: string | null } = {}) => {
1807
+ if (!usesCodexForwardPoolAuth(authCtx, route.provider)) return;
1808
+ recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, {
1809
+ ...meta,
1810
+ threadId: compactThreadId,
1811
+ });
1812
+ };
1747
1813
  let upstream: Response;
1748
1814
  try {
1749
- upstream = await fetch(`${base}/responses/compact`, {
1750
- method: "POST",
1751
- headers,
1752
- body: JSON.stringify({ ...compactBody, model: route.modelId }),
1753
- signal: req.signal,
1754
- });
1755
- } catch {
1815
+ // Same connect timeout + keep-alive reset + transient-5xx recovery as /v1/responses
1816
+ // compact hits the same ChatGPT host and must soft-avoid / clear affinity (#186).
1817
+ upstream = await fetchWithTransientRetry(
1818
+ recovery => fetchWithHeaderTimeout(
1819
+ compactUrl,
1820
+ applyUpstreamRecoveryInit({
1821
+ method: "POST",
1822
+ headers,
1823
+ body: JSON.stringify({ ...compactBody, model: route.modelId }),
1824
+ }, recovery),
1825
+ req.signal,
1826
+ connectMs,
1827
+ false,
1828
+ providerFetch(compactProvider),
1829
+ ),
1830
+ { abortSignal: req.signal, label: safeHostLabel(compactUrl) },
1831
+ );
1832
+ } catch (err) {
1756
1833
  if (req.signal.aborted) return formatErrorResponse(499, "client_cancelled", "Client cancelled compact request");
1834
+ const outcome = err instanceof Error && err.name === "TimeoutError" ? "timeout" : "connect_error";
1835
+ recordCompactPoolOutcome(outcome);
1757
1836
  return formatErrorResponse(502, "upstream_error", "Failed to connect to compact upstream");
1758
1837
  }
1759
- return bufferCompactResponse(upstream, req.signal);
1838
+ const retryAfter = upstream.headers.get("retry-after");
1839
+ const buffered = await bufferCompactResponse(upstream, req.signal);
1840
+ // Record pool health only after the body is fully delivered (or definitively failed).
1841
+ // A premature 200 would clear soft-avoid while the client still sees a buffer 502.
1842
+ if (buffered.status === 499) {
1843
+ return buffered;
1844
+ }
1845
+ if (upstream.ok && buffered.status >= 500) {
1846
+ // The upstream account returned 200 — it is healthy. The buffering failure
1847
+ // (oversized body exceeding COMPACT_RESPONSE_MAX_BYTES, or a rare mid-read
1848
+ // reset on a small JSON payload) is a local proxy issue, not account flakiness.
1849
+ // Record the upstream status so a deterministic payload-size limit does not
1850
+ // soft-avoid a healthy account and rotate a thread for 30s.
1851
+ recordCompactPoolOutcome(upstream.status, { retryAfter });
1852
+ } else {
1853
+ recordCompactPoolOutcome(upstream.status, { retryAfter });
1854
+ }
1855
+ return buffered;
1760
1856
  }
1761
1857
 
1762
1858
  // ROUTED model: run the v2 synthetic-compaction turn internally (appends COMPACT_PROMPT, no
package/src/service.ts CHANGED
@@ -503,7 +503,7 @@ function installWindows(): void {
503
503
  throw new Error(`Cannot remove the native service before switching to Task Scheduler: ${err instanceof Error ? err.message : String(err)}. Remove it manually with 'sc delete ${WINSW_SERVICE_ID}' or retry.`);
504
504
  }
505
505
  if (statusWinswRaw() !== "nonexistent") {
506
- throw new Error("Native service still present after removal attempt — aborting switch. Remove it manually with 'sc delete opencodex-proxy-native'.");
506
+ throw new Error(`Native service registration could not be re-verified after the removal attempt — aborting switch. Check 'sc.exe query ${WINSW_SERVICE_ID}' and remove it manually if present.`);
507
507
  }
508
508
  }
509
509
  // End a running task BEFORE rewriting the assets it is executing — cmd.exe reading the
@@ -761,7 +761,9 @@ export function stopServiceIfInstalled(): boolean {
761
761
  const q = schtasks(["/query", "/tn", TASK]);
762
762
  if (q.includes(TASK)) { stopWindows(); stopped = true; }
763
763
  } catch { /* task not found */ }
764
- if (statusWinswRaw() !== "nonexistent") { stopWinswService(); stopped = true; }
764
+ if (statusWinswRaw() !== "nonexistent") {
765
+ try { stopWinswService(); stopped = true; } catch { /* best-effort */ }
766
+ }
765
767
  if (stopped) return true;
766
768
  } else if (process.platform === "linux" && isSystemd() && existsSync(unitPath())) {
767
769
  try { stopSystemd(); return true; } catch { return false; }
@@ -793,7 +795,14 @@ export function uninstallServiceIfInstalled(): boolean {
793
795
  const q = schtasks(["/query", "/tn", TASK]);
794
796
  if (q.includes(TASK)) { uninstallWindows(); removed = true; }
795
797
  } catch { /* task not found */ }
796
- if (statusWinswRaw() !== "nonexistent") { uninstallWinswService(); removed = true; }
798
+ if (statusWinswRaw() !== "nonexistent") {
799
+ try {
800
+ uninstallWinswService();
801
+ removed = true;
802
+ } catch (err) {
803
+ console.warn(`⚠️ Failed to remove native service: ${err instanceof Error ? err.message : String(err)}. Check 'sc.exe query ${WINSW_SERVICE_ID}'.`);
804
+ }
805
+ }
797
806
  if (removed) { removeServiceInstallState(); return true; }
798
807
  } else if (process.platform === "linux" && existsSync(unitPath())) {
799
808
  try { uninstallSystemd(); removeServiceInstallState(); return true; } catch {