@bitkyc08/opencodex 2.7.29 → 2.7.31

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 (41) hide show
  1. package/README.ko.md +1 -1
  2. package/README.md +1 -1
  3. package/README.ru.md +479 -0
  4. package/README.zh-CN.md +1 -1
  5. package/gui/dist/assets/index-BPa0R6EN.js +46 -0
  6. package/gui/dist/assets/index-BY7KvJRB.css +1 -0
  7. package/gui/dist/index.html +2 -2
  8. package/package.json +1 -1
  9. package/src/adapters/cursor/cursor-errors.ts +7 -2
  10. package/src/adapters/cursor/discovery.ts +2 -2
  11. package/src/adapters/cursor/request-builder.ts +90 -6
  12. package/src/adapters/cursor/tool-definitions.ts +23 -6
  13. package/src/adapters/google.ts +7 -0
  14. package/src/codex/auth-api.ts +18 -0
  15. package/src/codex/catalog.ts +39 -11
  16. package/src/codex/routing.ts +85 -4
  17. package/src/combos/index.ts +1 -1
  18. package/src/combos/request.ts +26 -4
  19. package/src/combos/types.ts +4 -4
  20. package/src/lib/errors.ts +4 -0
  21. package/src/lib/upstream-retry.ts +21 -0
  22. package/src/lib/winsw.ts +7 -1
  23. package/src/oauth/anthropic.ts +23 -1
  24. package/src/oauth/index.ts +83 -4
  25. package/src/oauth/local-token-detect.ts +3 -0
  26. package/src/oauth/store.ts +32 -0
  27. package/src/providers/antigravity-models.ts +23 -12
  28. package/src/providers/base-url-choices.ts +18 -0
  29. package/src/providers/registry.ts +122 -7
  30. package/src/responses/parser.ts +11 -6
  31. package/src/router.ts +6 -1
  32. package/src/server/index.ts +3 -3
  33. package/src/server/relay.ts +31 -5
  34. package/src/server/request-log.ts +12 -0
  35. package/src/server/responses.ts +111 -27
  36. package/src/service.ts +12 -3
  37. package/src/storage/scanner.ts +18 -6
  38. package/src/types.ts +4 -2
  39. package/src/usage/expected-prices.ts +12 -9
  40. package/gui/dist/assets/index-CMKZnkG9.js +0 -40
  41. package/gui/dist/assets/index-DyBPh28A.css +0 -1
@@ -2,7 +2,10 @@ import type { CodexAccountMode, OcxProviderConfig } from "../types";
2
2
  import { KIRO_MODELS, KIRO_MODEL_CONTEXT_WINDOWS, KIRO_MODEL_REASONING_EFFORTS } from "./kiro-models";
3
3
  import { ANTIGRAVITY_MODELS, ANTIGRAVITY_MODEL_CONTEXT_WINDOWS } from "./antigravity-models";
4
4
  import type { ProviderBaseUrlChoice } from "./base-url-choices";
5
- import { QWEN_CLOUD_BASE_URL_CHOICES, QWEN_CLOUD_TOKEN_PLAN_BASE_URL } from "./base-url-choices";
5
+ import {
6
+ QWEN_CLOUD_BASE_URL_CHOICES, QWEN_CLOUD_TOKEN_PLAN_BASE_URL,
7
+ ALIBABA_INTL_BASE_URL_CHOICES, ALIBABA_INTL_TOKEN_PLAN_BASE_URL,
8
+ } from "./base-url-choices";
6
9
  import {
7
10
  CURSOR_STATIC_MODELS,
8
11
  cursorModelContextWindows,
@@ -193,12 +196,45 @@ const ALIBABA_TOKEN_PLAN_QWEN_MODELS = [
193
196
  ];
194
197
  const ALIBABA_TOKEN_PLAN_INPUT_MODALITIES: Record<string, string[]> = {
195
198
  "qwen3.8-max-preview": ["text", "image"],
196
- "qwen3.7-max": ["text"],
199
+ "qwen3.7-max": ["text", "image"],
197
200
  "qwen3.7-plus": ["text", "image"],
198
201
  "qwen3.6-flash": ["text", "image"],
199
202
  "glm-5.2": ["text"],
200
203
  "deepseek-v4-pro": ["text"],
201
204
  };
205
+
206
+ // 260721 Alibaba Token Plan International (ap-southeast-1 / Singapore, hardened 260721).
207
+ // Multi-vendor lineup distinct from Beijing — includes DeepSeek V4 flash, Kimi K2.7, MiniMax.
208
+ // Evidence: https://www.alibabacloud.com/help/en/model-studio/token-plan-overview
209
+ // https://qwencloud.com/pricing/token-plan (qwen3.8 metadata)
210
+ const ALIBABA_INTL_TOKEN_PLAN_MODELS = [
211
+ "qwen3.8-max-preview", "qwen3.7-max", "qwen3.7-plus", "qwen3.6-plus", "qwen3.6-flash",
212
+ "deepseek-v4-pro", "deepseek-v4-flash", "deepseek-v3.2",
213
+ "kimi-k2.7-code", "kimi-k2.6", "kimi-k2.5",
214
+ "glm-5.2", "glm-5.1", "glm-5",
215
+ "MiniMax-M2.5",
216
+ ];
217
+ const ALIBABA_INTL_TOKEN_PLAN_QWEN_MODELS = [
218
+ "qwen3.8-max-preview", "qwen3.7-max", "qwen3.7-plus", "qwen3.6-plus", "qwen3.6-flash",
219
+ ];
220
+ const ALIBABA_INTL_TOKEN_PLAN_INPUT_MODALITIES: Record<string, string[]> = {
221
+ "qwen3.8-max-preview": ["text", "image"],
222
+ "qwen3.7-max": ["text", "image"],
223
+ "qwen3.7-plus": ["text", "image"],
224
+ "qwen3.6-plus": ["text", "image"],
225
+ "qwen3.6-flash": ["text", "image"],
226
+ "deepseek-v4-pro": ["text"],
227
+ "deepseek-v4-flash": ["text"],
228
+ "deepseek-v3.2": ["text"],
229
+ "kimi-k2.7-code": ["text", "image"],
230
+ "kimi-k2.6": ["text", "image"],
231
+ "kimi-k2.5": ["text", "image"],
232
+ "glm-5.2": ["text"],
233
+ "glm-5.1": ["text"],
234
+ "glm-5": ["text"],
235
+ "MiniMax-M2.5": ["text"],
236
+ };
237
+
202
238
  // 260717 Kimi K3: the subscription endpoint uses one upstream id (`k3`) for both
203
239
  // entitlement tiers. Bare `k3` advertises the Moderato 256K ceiling; the local `[1m]`
204
240
  // alias advertises Allegretto's 1M ceiling and is stripped before the upstream request.
@@ -564,14 +600,49 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
564
600
  preserveReasoningContentModels: NEURALWATT_REASONING_HISTORY_MODELS,
565
601
  },
566
602
  { id: "openrouter", label: "OpenRouter", adapter: "openai-chat", baseUrl: "https://openrouter.ai/api/v1", authKind: "key", featured: true, dashboardUrl: "https://openrouter.ai/keys", jawcodeBundle: "openrouter", models: ["anthropic/claude-sonnet-5", ...OPENROUTER_GPT56_MODELS], modelContextWindows: { "anthropic/claude-sonnet-5": 1_000_000, ...OPENROUTER_GPT56_CONTEXT_WINDOWS } },
603
+ {
604
+ // OrcaRouter: OpenAI-compatible adaptive router (api.orcarouter.ai). Model ids are
605
+ // vendor-namespaced (`<vendor>/<model>`) and pass through to the upstream as-is.
606
+ // The default pins a tool-capable model; the adaptive `orcarouter/auto` router is also
607
+ // selectable. Live-verified 2026-07-20: /v1/chat/completions accepts the `tools` field
608
+ // and routes to a function-calling-capable upstream.
609
+ id: "orcarouter", label: "OrcaRouter", adapter: "openai-chat", baseUrl: "https://api.orcarouter.ai/v1",
610
+ authKind: "key", dashboardUrl: "https://www.orcarouter.ai/console",
611
+ defaultModel: "openai/gpt-5.5",
612
+ models: [
613
+ "openai/gpt-5.5",
614
+ "anthropic/claude-opus-4.8",
615
+ "google/gemini-3.5-flash",
616
+ "deepseek/deepseek-v4-pro",
617
+ "orcarouter/auto",
618
+ ],
619
+ // Text-only models → the vision sidecar describes images instead.
620
+ noVisionModels: ["deepseek/deepseek-v4-pro"],
621
+ // Reasoning/temperature behavior verified live 2026-07-20 against api.orcarouter.ai:
622
+ // - openai/gpt-5.5 accepts reasoning_effort none|low|medium|high|xhigh but rejects `max` (400),
623
+ // so advertise up to xhigh and let mapReasoningEffort clamp a `max`/`ultra` request to xhigh.
624
+ // - deepseek/deepseek-v4-pro mirrors the direct-DeepSeek wiring (thinking-effort map +
625
+ // reasoning_content history replay) so the namespaced selection behaves identically.
626
+ // - temperature is accepted by every seeded model (gpt-5.5, claude-opus-4.8, deepseek-v4-pro all
627
+ // returned 200), so no noTemperatureModels entry is warranted here.
628
+ modelReasoningEfforts: {
629
+ "openai/gpt-5.5": ["low", "medium", "high", "xhigh"],
630
+ "deepseek/deepseek-v4-pro": DEEPSEEK_THINKING_EFFORTS,
631
+ },
632
+ modelReasoningEffortMap: { "deepseek/deepseek-v4-pro": DEEPSEEK_THINKING_REASONING_MAP },
633
+ preserveReasoningContentModels: ["deepseek/deepseek-v4-pro"],
634
+ note: "OpenAI-compatible adaptive router. Default is a tool-capable model; orcarouter/auto (adaptive routing) is also selectable. Full catalog: https://www.orcarouter.ai/models",
635
+ },
567
636
  { id: "groq", label: "Groq", adapter: "openai-chat", baseUrl: "https://api.groq.com/openai/v1", authKind: "key", featured: true, dashboardUrl: "https://console.groq.com/keys" },
568
637
  // 2026-07-10 Gemini API refresh: Tier-2 ai.google.dev evidence recorded in
569
638
  // devlog/_plan/260710_provider_hardening/001_research_frontier.md.
570
639
  {
571
640
  id: "google", label: "Google Gemini", adapter: "google", baseUrl: "https://generativelanguage.googleapis.com", authKind: "key", featured: true,
572
- dashboardUrl: "https://aistudio.google.com/apikey", defaultModel: "gemini-3.5-flash", models: ["gemini-3.5-flash", "gemini-3.1-pro-preview"],
573
- modelContextWindows: { "gemini-3.5-flash": 1_000_000 },
641
+ dashboardUrl: "https://aistudio.google.com/apikey", defaultModel: "gemini-3.5-flash", models: ["gemini-3.6-flash", "gemini-3.5-flash", "gemini-3.1-pro-preview"],
642
+ modelContextWindows: { "gemini-3.6-flash": 1_048_576, "gemini-3.5-flash": 1_000_000 },
643
+ modelInputModalities: { "gemini-3.6-flash": ["text", "image"] },
574
644
  modelReasoningEfforts: {
645
+ "gemini-3.6-flash": ["minimal", "low", "medium", "high"],
575
646
  "gemini-3.5-flash": ["minimal", "low", "medium", "high"],
576
647
  "gemini-3.1-pro-preview": ["low", "medium", "high"],
577
648
  },
@@ -580,7 +651,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
580
651
  // 2026-07-10: defaultModel is frozen pending Vertex-specific Tier-2 evidence; Gemini API
581
652
  // evidence from ai.google.dev does not establish Vertex publisher availability.
582
653
  { id: "google-vertex", label: "Google Vertex AI", adapter: "google", baseUrl: "https://aiplatform.googleapis.com", authKind: "key", dashboardUrl: "https://console.cloud.google.com/vertex-ai", defaultModel: "gemini-3-pro", googleMode: "vertex", jawcodeBundle: "google", extraMetadataAliases: ["gemini-vertex"] },
583
- { id: "google-antigravity", label: "Google Antigravity", adapter: "google", baseUrl: "https://daily-cloudcode-pa.googleapis.com", authKind: "oauth", dashboardUrl: "https://antigravity.google", models: ANTIGRAVITY_MODELS, defaultModel: "gemini-3.5-flash-low", modelContextWindows: ANTIGRAVITY_MODEL_CONTEXT_WINDOWS, googleMode: "cloud-code-assist", jawcodeBundle: "google", extraMetadataAliases: ["antigravity", "gemini-antigravity"] },
654
+ { id: "google-antigravity", label: "Google Antigravity", adapter: "google", baseUrl: "https://daily-cloudcode-pa.googleapis.com", authKind: "oauth", dashboardUrl: "https://antigravity.google", models: ANTIGRAVITY_MODELS, defaultModel: "gemini-3.6-flash-medium", modelContextWindows: ANTIGRAVITY_MODEL_CONTEXT_WINDOWS, googleMode: "cloud-code-assist", jawcodeBundle: "google", extraMetadataAliases: ["antigravity", "gemini-antigravity"] },
584
655
  { id: "azure-openai", label: "Azure OpenAI", adapter: "azure-openai", baseUrl: "https://{resource}.openai.azure.com/openai", authKind: "key", featured: true, dashboardUrl: "https://portal.azure.com" },
585
656
  { id: "ollama", label: "Ollama (local)", adapter: "openai-chat", baseUrl: "http://localhost:11434/v1", authKind: "local", allowPrivateNetworkByDefault: true, allowBaseUrlOverride: true, featured: true, note: "Local — key usually blank" },
586
657
  { id: "vllm", label: "vLLM (local)", adapter: "openai-chat", baseUrl: "http://localhost:8000/v1", authKind: "local", allowPrivateNetworkByDefault: true, allowBaseUrlOverride: true, featured: true, note: "Local — key usually blank" },
@@ -691,7 +762,6 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
691
762
  id: "alibaba-token-plan",
692
763
  label: "Alibaba Token Plan (Beijing)",
693
764
  baseUrl: "https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1",
694
- allowBaseUrlOverride: true,
695
765
  adapter: "openai-chat",
696
766
  authKind: "key",
697
767
  dashboardUrl: "https://bailian.console.aliyun.com/cn-beijing?tab=plan",
@@ -700,6 +770,10 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
700
770
  liveModels: false,
701
771
  note: "Token Plan Personal Edition · China (Beijing)",
702
772
  modelInputModalities: ALIBABA_TOKEN_PLAN_INPUT_MODALITIES,
773
+ modelContextWindows: {
774
+ "qwen3.8-max-preview": 983_616, "qwen3.7-max": 1_000_000, "qwen3.7-plus": 1_000_000,
775
+ "qwen3.6-flash": 1_000_000, "glm-5.2": 1_000_000, "deepseek-v4-pro": 1_000_000,
776
+ },
703
777
  modelReasoningEfforts: {
704
778
  ...Object.fromEntries(ALIBABA_TOKEN_PLAN_QWEN_MODELS.map(id => [id, THINKING_BUDGET_EFFORTS])),
705
779
  "glm-5.2": ZAI_GLM_52_REASONING_EFFORTS,
@@ -707,7 +781,48 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
707
781
  },
708
782
  modelReasoningEffortMap: { "deepseek-v4-pro": DEEPSEEK_THINKING_REASONING_MAP },
709
783
  thinkingBudgetModels: ALIBABA_TOKEN_PLAN_QWEN_MODELS,
710
- preserveReasoningContentModels: ["glm-5.2", "deepseek-v4-pro", "qwen3.8-max-preview"],
784
+ preserveReasoningContentModels: ["glm-5.2", "deepseek-v4-pro", "qwen3.8-max-preview", "qwen3.7-max", "qwen3.7-plus", "qwen3.6-flash"],
785
+ noVisionModels: ["glm-5.2", "deepseek-v4-pro"],
786
+ },
787
+ {
788
+ id: "alibaba-token-plan-intl",
789
+ label: "Alibaba Token Plan (International)",
790
+ baseUrl: ALIBABA_INTL_TOKEN_PLAN_BASE_URL,
791
+ adapter: "openai-chat",
792
+ authKind: "key",
793
+ allowBaseUrlOverride: true,
794
+ baseUrlChoices: ALIBABA_INTL_BASE_URL_CHOICES,
795
+ dashboardUrl: "https://modelstudio.console.alibabacloud.com/?tab=api#/api",
796
+ defaultModel: "qwen3.7-max",
797
+ models: ALIBABA_INTL_TOKEN_PLAN_MODELS,
798
+ liveModels: false,
799
+ note: "Token Plan Team Edition · Singapore (ap-southeast-1)",
800
+ metadataModelIdNormalize: "case-insensitive",
801
+ modelInputModalities: ALIBABA_INTL_TOKEN_PLAN_INPUT_MODALITIES,
802
+ modelContextWindows: {
803
+ "qwen3.8-max-preview": 983_616,
804
+ "qwen3.7-max": 1_000_000, "qwen3.7-plus": 1_000_000, "qwen3.6-plus": 1_000_000, "qwen3.6-flash": 1_000_000,
805
+ "deepseek-v4-pro": 1_000_000, "deepseek-v4-flash": 1_000_000, "deepseek-v3.2": 131_072,
806
+ "kimi-k2.7-code": 262_144, "kimi-k2.6": 262_144, "kimi-k2.5": 262_144,
807
+ "glm-5.2": 1_000_000, "glm-5.1": 1_000_000, "glm-5": 1_000_000,
808
+ "MiniMax-M2.5": 204_800,
809
+ },
810
+ modelReasoningEfforts: {
811
+ ...Object.fromEntries(ALIBABA_INTL_TOKEN_PLAN_QWEN_MODELS.map(id => [id, THINKING_BUDGET_EFFORTS])),
812
+ "qwen3.8-max-preview": ["low", "high", "xhigh"],
813
+ "glm-5.2": ZAI_GLM_52_REASONING_EFFORTS,
814
+ "deepseek-v4-pro": DEEPSEEK_THINKING_EFFORTS,
815
+ "deepseek-v4-flash": DEEPSEEK_THINKING_EFFORTS,
816
+ },
817
+ modelReasoningEffortMap: {
818
+ "deepseek-v4-pro": DEEPSEEK_THINKING_REASONING_MAP,
819
+ "deepseek-v4-flash": DEEPSEEK_THINKING_REASONING_MAP,
820
+ },
821
+ thinkingBudgetModels: ALIBABA_INTL_TOKEN_PLAN_QWEN_MODELS,
822
+ preserveReasoningContentModels: ["glm-5.2", "deepseek-v4-pro", "deepseek-v4-flash", "qwen3.8-max-preview", "qwen3.7-max", "qwen3.7-plus", "qwen3.6-plus", "qwen3.6-flash"],
823
+ noVisionModels: ["deepseek-v4-pro", "deepseek-v4-flash", "deepseek-v3.2", "glm-5.2", "glm-5.1", "glm-5", "MiniMax-M2.5"],
824
+ noReasoningModels: ["kimi-k2.7-code", "kimi-k2.6", "kimi-k2.5", "deepseek-v3.2", "glm-5.1", "glm-5", "MiniMax-M2.5"],
825
+ modelDefaultReasoningEfforts: { "qwen3.8-max-preview": "xhigh" },
711
826
  },
712
827
  // NEEDS_HUMAN 2026-07-10: kept for config compatibility, but this is a dashboard URL,
713
828
  // no /models endpoint is documented, and tools are silently ignored upstream per docs.parallel.ai.
@@ -525,13 +525,18 @@ export function parseRequest(body: unknown): OcxParsedRequest {
525
525
 
526
526
  const declaredTools = buildTools(data.tools as unknown[] | undefined) ?? [];
527
527
  const loadedTools = buildTools(loadedToolSpecs) ?? [];
528
+ const loadedToolNames = new Set(loadedTools.map(t => namespacedToolName(t.namespace, t.name)));
528
529
  const seenTools = new Set<string>();
529
- const mergedTools = [...declaredTools, ...loadedTools].filter(t => {
530
- const k = namespacedToolName(t.namespace, t.name);
531
- if (seenTools.has(k)) return false;
532
- seenTools.add(k);
533
- return true;
534
- });
530
+ const mergedTools = [...declaredTools, ...loadedTools]
531
+ .filter(t => {
532
+ const k = namespacedToolName(t.namespace, t.name);
533
+ if (seenTools.has(k)) return false;
534
+ seenTools.add(k);
535
+ return true;
536
+ })
537
+ .map(t => loadedToolNames.has(namespacedToolName(t.namespace, t.name))
538
+ ? { ...t, loadedFromToolSearch: true }
539
+ : t);
535
540
  const context: OcxContext = {
536
541
  ...(systemPrompt.length > 0 ? { systemPrompt } : {}),
537
542
  messages,
package/src/router.ts CHANGED
@@ -253,9 +253,14 @@ export function routeModel(config: OcxConfig, modelId: string): RouteResult {
253
253
  if (hasOwnProvider(config.providers, provName)) {
254
254
  const prov = config.providers[provName];
255
255
  if (prov.disabled === true) throw new Error(`Provider is disabled: ${provName}`);
256
+ const known = knownModelIdsForProvider(provName, prov);
257
+ // Self-namespaced native id — the vendor segment equals the provider id, so the FULL ref is
258
+ // itself a known model (e.g. orcarouter/auto). Route it whole instead of stripping to the
259
+ // remainder, which would send a bare `auto` the upstream cannot resolve.
260
+ if (known.includes(modelId)) return routeResult(provName, prov, modelId);
256
261
  // Codex-facing alias ids (`provider/vendor-model`) decode back to the native
257
262
  // slash id via an exact known-id lookup; raw full-slash selectors keep working.
258
- return routeResult(provName, prov, decodeRoutedModelId(modelId.slice(slash + 1), knownModelIdsForProvider(provName, prov)));
263
+ return routeResult(provName, prov, decodeRoutedModelId(modelId.slice(slash + 1), known));
259
264
  }
260
265
  }
261
266
 
@@ -537,7 +537,7 @@ export function startServer(port?: number) {
537
537
  void (async () => {
538
538
  const start = Date.now();
539
539
  const requestId = nextRequestLogId(start);
540
- const logCtx = { model: "unknown", provider: "unknown" };
540
+ const logCtx: RequestLogContext = { model: "unknown", provider: "unknown" };
541
541
  let logged = false;
542
542
  const finalizeLog = (
543
543
  status: number,
@@ -556,7 +556,7 @@ export function startServer(port?: number) {
556
556
  body: JSON.stringify({ ...payload, stream: true }),
557
557
  });
558
558
  try {
559
- let terminalRecorder: ((status: ResponsesTerminalStatus) => void) | undefined;
559
+ let terminalRecorder: ((status: ResponsesTerminalStatus, httpStatusOverride?: number) => void) | undefined;
560
560
  const response = await handleResponses(req, config, logCtx, {
561
561
  forceEmptyResponseId: true,
562
562
  abortSignal: turnAbort.signal,
@@ -570,7 +570,7 @@ export function startServer(port?: number) {
570
570
  await sendResponseToWebSocket(ws, response, isCurrent, {
571
571
  onSsePayload: payload => inspectResponseLogSsePayload(logCtx, payload),
572
572
  onTerminal: status => {
573
- terminalRecorder?.(status);
573
+ terminalRecorder?.(status, logCtx.terminalHttpStatus);
574
574
  finalizeLog(httpStatusForRequestLogTerminal(status, logCtx), {
575
575
  terminalStatus: status,
576
576
  closeReason: "terminal",
@@ -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({
@@ -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. */
@@ -595,10 +623,12 @@ async function handleComboResponses(
595
623
  model: pick.target.model,
596
624
  provider: pick.target.provider,
597
625
  };
626
+ const targetRoute = routeModel(config, `${pick.target.provider}/${pick.target.model}`);
598
627
  const childBody = concreteComboRequestBody(
599
628
  rawBody,
600
629
  pick.target,
601
630
  comboDefaultEffort(config, comboId),
631
+ supportedLadderFor({ provider: targetRoute.provider, modelId: targetRoute.modelId }),
602
632
  );
603
633
  const childHeaders = new Headers(req.headers);
604
634
  childHeaders.delete("content-length");
@@ -608,7 +638,7 @@ async function handleComboResponses(
608
638
  body: JSON.stringify(childBody),
609
639
  });
610
640
  let resolvedAuth: CodexAuthContext | undefined;
611
- let terminalRecorder: ((status: ResponsesTerminalStatus) => void) | undefined;
641
+ let terminalRecorder: ((status: ResponsesTerminalStatus, httpStatusOverride?: number) => void) | undefined;
612
642
  const started = Date.now();
613
643
  const attempt = beginRequestAttempt(
614
644
  (logCtx.attempts?.length ?? 0) + 1,
@@ -1092,11 +1122,11 @@ export async function handleResponses(
1092
1122
  upstreamResponse = await fetchWithTransientRetry(
1093
1123
  recovery => {
1094
1124
  noteAttemptSend(logCtx.activeAttempt, passthroughEstimate, recovery);
1095
- return fetchWithHeaderTimeout(request.url, {
1125
+ return fetchWithHeaderTimeout(request.url, applyUpstreamRecoveryInit({
1096
1126
  method: request.method,
1097
1127
  headers: request.headers,
1098
1128
  body: request.body,
1099
- }, upstream.signal, connectMs, parsed.stream, providerFetch(route.provider));
1129
+ }, recovery), upstream.signal, connectMs, parsed.stream, providerFetch(route.provider));
1100
1130
  },
1101
1131
  { abortSignal: upstream.signal, label: safeHostLabel(request.url) },
1102
1132
  );
@@ -1104,7 +1134,11 @@ export async function handleResponses(
1104
1134
  upstream.abort();
1105
1135
  if (options.abortSignal?.aborted) return clientCancelledResponse();
1106
1136
  const outcome = err instanceof Error && err.name === "TimeoutError" ? "timeout" : "connect_error";
1107
- if (usesCodexForwardPoolAuth(authCtx, route.provider)) recordCodexUpstreamOutcome(config, authCtx.accountId, outcome);
1137
+ if (usesCodexForwardPoolAuth(authCtx, route.provider)) {
1138
+ recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, {
1139
+ threadId: req.headers.get("x-codex-parent-thread-id"),
1140
+ });
1141
+ }
1108
1142
  const msg = outcome === "timeout"
1109
1143
  ? `Provider connect timeout after ${connectMs}ms`
1110
1144
  : `Provider unreachable: ${err instanceof Error ? err.message : String(err)}`;
@@ -1122,7 +1156,13 @@ export async function handleResponses(
1122
1156
  const passthroughCt = headers.get("content-type")?.toLowerCase();
1123
1157
  const isEventStream = passthroughCt?.includes("text/event-stream")
1124
1158
  || (upstreamResponse.ok && !!upstreamResponse.body && !passthroughCt && parsed.stream);
1125
- const terminalRecorder = codexForwardTerminalOutcomeRecorder(config, authCtx, route.provider);
1159
+ const terminalRecorder = codexForwardTerminalOutcomeRecorder(
1160
+ config,
1161
+ authCtx,
1162
+ route.provider,
1163
+ logCtx,
1164
+ req.headers.get("x-codex-parent-thread-id"),
1165
+ );
1126
1166
  const terminalBodyWillRecord = !!terminalRecorder && upstreamResponse.ok && isEventStream;
1127
1167
  // Capture quota from upstream response for multi-account tracking
1128
1168
  if (usesCodexForwardPoolAuth(authCtx, route.provider)) {
@@ -1148,14 +1188,15 @@ export async function handleResponses(
1148
1188
  );
1149
1189
  }
1150
1190
  if (terminalBodyWillRecord) {
1151
- options.setTerminalOutcomeRecorder?.(status => {
1152
- terminalRecorder(status);
1191
+ options.setTerminalOutcomeRecorder?.((status, httpStatusOverride) => {
1192
+ terminalRecorder(status, httpStatusOverride);
1153
1193
  options.onNativePassthroughTerminal?.(status);
1154
1194
  });
1155
1195
  } else {
1156
1196
  recordCodexUpstreamOutcome(config, authCtx.accountId, upstreamResponse.status, {
1157
1197
  retryAfter: retryAfterRaw,
1158
1198
  resetAt: [primaryResetRaw, secondaryResetRaw, monthlyResetRaw].filter(Boolean),
1199
+ threadId: req.headers.get("x-codex-parent-thread-id"),
1159
1200
  });
1160
1201
  }
1161
1202
  }
@@ -1174,8 +1215,8 @@ export async function handleResponses(
1174
1215
  // even if the client has already disconnected: the turn genuinely reached that terminal, so
1175
1216
  // it must log as completed/failed, not be dropped or downgraded to a cancel (#44). A pure
1176
1217
  // client-cancel (no terminal seen) is finalized separately via consumeForInspection's onCancel.
1177
- const reportNativeTerminal = (status: ResponsesTerminalStatus) => {
1178
- terminalRecorder?.(status);
1218
+ const reportNativeTerminal = (status: ResponsesTerminalStatus, httpStatusOverride?: number) => {
1219
+ terminalRecorder?.(status, httpStatusOverride);
1179
1220
  options.onNativePassthroughTerminal?.(status);
1180
1221
  };
1181
1222
  consumeForInspection(
@@ -1390,9 +1431,11 @@ export async function handleResponses(
1390
1431
  upstreamResponse = await fetchWithResetRetry(
1391
1432
  recovery => {
1392
1433
  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));
1434
+ return fetchWithHeaderTimeout(request.url, applyUpstreamRecoveryInit({
1435
+ method: request.method,
1436
+ headers: request.headers,
1437
+ body: request.body,
1438
+ }, recovery), upstream.signal, connectMs, parsed.stream, providerFetch(route.provider));
1396
1439
  },
1397
1440
  { abortSignal: upstream.signal, label: safeHostLabel(request.url) },
1398
1441
  );
@@ -1710,10 +1753,11 @@ export async function handleResponsesCompact(
1710
1753
  // headers would run compaction on the wrong account (or 401) whenever a pool account is
1711
1754
  // active for this thread while normal turns succeed.
1712
1755
  let compactProvider = route.provider;
1756
+ let authCtx: CodexAuthContext = { kind: "main", accountId: null };
1713
1757
  const headers = new Headers({ "content-type": "application/json" });
1714
1758
  try {
1715
1759
  if (route.codexAccountMode) {
1716
- const authCtx = await resolveCodexAuthContext(req.headers, config, route.codexAccountMode);
1760
+ authCtx = await resolveCodexAuthContext(req.headers, config, route.codexAccountMode);
1717
1761
  const selected = headersForCodexAuthContext(req.headers, authCtx);
1718
1762
  compactProvider = applyCodexAuthContextToProvider(route.provider, authCtx, route.codexAccountMode);
1719
1763
  for (const name of FORWARD_HEADERS) {
@@ -1744,19 +1788,59 @@ export async function handleResponsesCompact(
1744
1788
  const base = (compactProvider.baseUrl ?? "").replace(/\/$/, "");
1745
1789
  if (compactProvider.apiKey) headers.set("authorization", `Bearer ${resolveEnvValue(compactProvider.apiKey)}`);
1746
1790
  const { reasoning: _reasoning, ...compactBody } = raw as typeof raw & { reasoning?: unknown };
1791
+ const compactUrl = `${base}/responses/compact`;
1792
+ const compactThreadId = req.headers.get("x-codex-parent-thread-id");
1793
+ const connectMs = config.connectTimeoutMs ?? 200_000;
1794
+ const recordCompactPoolOutcome = (outcome: CodexUpstreamOutcome, meta: { retryAfter?: string | null } = {}) => {
1795
+ if (!usesCodexForwardPoolAuth(authCtx, route.provider)) return;
1796
+ recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, {
1797
+ ...meta,
1798
+ threadId: compactThreadId,
1799
+ });
1800
+ };
1747
1801
  let upstream: Response;
1748
1802
  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 {
1803
+ // Same connect timeout + keep-alive reset + transient-5xx recovery as /v1/responses
1804
+ // compact hits the same ChatGPT host and must soft-avoid / clear affinity (#186).
1805
+ upstream = await fetchWithTransientRetry(
1806
+ recovery => fetchWithHeaderTimeout(
1807
+ compactUrl,
1808
+ applyUpstreamRecoveryInit({
1809
+ method: "POST",
1810
+ headers,
1811
+ body: JSON.stringify({ ...compactBody, model: route.modelId }),
1812
+ }, recovery),
1813
+ req.signal,
1814
+ connectMs,
1815
+ false,
1816
+ providerFetch(compactProvider),
1817
+ ),
1818
+ { abortSignal: req.signal, label: safeHostLabel(compactUrl) },
1819
+ );
1820
+ } catch (err) {
1756
1821
  if (req.signal.aborted) return formatErrorResponse(499, "client_cancelled", "Client cancelled compact request");
1822
+ const outcome = err instanceof Error && err.name === "TimeoutError" ? "timeout" : "connect_error";
1823
+ recordCompactPoolOutcome(outcome);
1757
1824
  return formatErrorResponse(502, "upstream_error", "Failed to connect to compact upstream");
1758
1825
  }
1759
- return bufferCompactResponse(upstream, req.signal);
1826
+ const retryAfter = upstream.headers.get("retry-after");
1827
+ const buffered = await bufferCompactResponse(upstream, req.signal);
1828
+ // Record pool health only after the body is fully delivered (or definitively failed).
1829
+ // A premature 200 would clear soft-avoid while the client still sees a buffer 502.
1830
+ if (buffered.status === 499) {
1831
+ return buffered;
1832
+ }
1833
+ if (upstream.ok && buffered.status >= 500) {
1834
+ // The upstream account returned 200 — it is healthy. The buffering failure
1835
+ // (oversized body exceeding COMPACT_RESPONSE_MAX_BYTES, or a rare mid-read
1836
+ // reset on a small JSON payload) is a local proxy issue, not account flakiness.
1837
+ // Record the upstream status so a deterministic payload-size limit does not
1838
+ // soft-avoid a healthy account and rotate a thread for 30s.
1839
+ recordCompactPoolOutcome(upstream.status, { retryAfter });
1840
+ } else {
1841
+ recordCompactPoolOutcome(upstream.status, { retryAfter });
1842
+ }
1843
+ return buffered;
1760
1844
  }
1761
1845
 
1762
1846
  // ROUTED model: run the v2 synthetic-compaction turn internally (appends COMPACT_PROMPT, no