@bitkyc08/opencodex 2.11.1 → 2.12.0

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 (175) hide show
  1. package/README.md +1 -1
  2. package/bin/ocx.mjs +4 -0
  3. package/gui/dist/assets/index-BNVYzdn0.css +1 -0
  4. package/gui/dist/assets/index-Cw1_mxO8.js +70 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +1 -1
  7. package/src/adapters/command-code.ts +94 -5
  8. package/src/adapters/cursor/live-transport.ts +14 -1
  9. package/src/adapters/google-tool-schema.ts +80 -15
  10. package/src/adapters/google.ts +24 -7
  11. package/src/adapters/openai-chat.ts +233 -305
  12. package/src/adapters/openai-responses.ts +62 -0
  13. package/src/adapters/tool-catalog-nudge.ts +2 -11
  14. package/src/claude/outbound.ts +42 -14
  15. package/src/cli/claude.ts +61 -9
  16. package/src/cli/codex-shim-autorestore.ts +2 -0
  17. package/src/cli/codex-shim-readiness.ts +8 -1
  18. package/src/cli/config-command.ts +4 -1
  19. package/src/cli/doctor.ts +10 -0
  20. package/src/cli/help.ts +15 -0
  21. package/src/cli/index.ts +27 -11
  22. package/src/cli/lab.ts +367 -0
  23. package/src/cli/provider.ts +9 -1
  24. package/src/clients/config-export.ts +100 -3
  25. package/src/codex/app-server-processes.ts +11 -8
  26. package/src/codex/auth-api.ts +92 -28
  27. package/src/codex/auth-collision.ts +5 -3
  28. package/src/codex/catalog/metadata.ts +7 -3
  29. package/src/codex/catalog/provider-fetch.ts +191 -47
  30. package/src/codex/catalog.ts +2 -2
  31. package/src/codex/history-job.ts +83 -18
  32. package/src/codex/history-migration-guardian.ts +9 -16
  33. package/src/codex/history-provider.ts +178 -1
  34. package/src/codex/history-transition.ts +5 -5
  35. package/src/codex/history-worker.ts +22 -2
  36. package/src/codex/native-profile-store.ts +1 -1
  37. package/src/codex/plan.ts +15 -0
  38. package/src/codex/quota.ts +6 -6
  39. package/src/codex/routing.ts +3 -3
  40. package/src/codex/subagent-model-fallback.ts +246 -22
  41. package/src/codex/user-identity.ts +63 -12
  42. package/src/config.ts +265 -32
  43. package/src/images/loop.ts +3 -1
  44. package/src/images/plan.ts +23 -13
  45. package/src/integrations/omp-yaml-source.ts +225 -0
  46. package/src/integrations/ownership.ts +5 -5
  47. package/src/integrations/state.ts +45 -6
  48. package/src/integrations/writer.ts +38 -3
  49. package/src/lab/artifacts/sanitize.ts +586 -0
  50. package/src/lab/artifacts/secure-fs.ts +475 -0
  51. package/src/lab/artifacts/store.ts +310 -0
  52. package/src/lab/conformance/assertion.ts +325 -0
  53. package/src/lab/conformance/digest.ts +22 -0
  54. package/src/lab/conformance/executor.ts +741 -0
  55. package/src/lab/conformance/fixture-provider.ts +27 -0
  56. package/src/lab/conformance/fixtures/live-v1-cases.json +175 -0
  57. package/src/lab/conformance/fixtures/protocol-v1-cases.json +461 -0
  58. package/src/lab/conformance/harness-budget.ts +47 -0
  59. package/src/lab/conformance/index.ts +5 -0
  60. package/src/lab/conformance/jcs.ts +24 -0
  61. package/src/lab/conformance/json-pointer.ts +39 -0
  62. package/src/lab/conformance/manifest.ts +180 -0
  63. package/src/lab/conformance/mcp-stub.ts +179 -0
  64. package/src/lab/conformance/negative-controls.ts +160 -0
  65. package/src/lab/conformance/observation.ts +355 -0
  66. package/src/lab/conformance/runner.ts +57 -0
  67. package/src/lab/conformance/sse-normalize.ts +59 -0
  68. package/src/lab/conformance/suite-manifest.ts +78 -0
  69. package/src/lab/conformance/types.ts +191 -0
  70. package/src/lab/constants.ts +126 -0
  71. package/src/lab/digest.ts +64 -0
  72. package/src/lab/events/errors.ts +9 -0
  73. package/src/lab/events/limits.ts +117 -0
  74. package/src/lab/events/types.ts +228 -0
  75. package/src/lab/events/validate.ts +780 -0
  76. package/src/lab/index.ts +36 -0
  77. package/src/lab/ledger/artifact-refs.ts +127 -0
  78. package/src/lab/ledger/invalidation.ts +136 -0
  79. package/src/lab/ledger/purge.ts +241 -0
  80. package/src/lab/ledger/store.ts +263 -0
  81. package/src/lab/live/credential-lease.ts +53 -0
  82. package/src/lab/live/destination.ts +155 -0
  83. package/src/lab/live/executor.ts +311 -0
  84. package/src/lab/live/inert-tools.ts +56 -0
  85. package/src/lab/live/manifest.ts +85 -0
  86. package/src/lab/live/mcp-loopback.ts +57 -0
  87. package/src/lab/live/runner.ts +19 -0
  88. package/src/lab/live/sandbox.ts +61 -0
  89. package/src/lab/live/suite-manifest.ts +41 -0
  90. package/src/lab/live/transport.ts +116 -0
  91. package/src/lab/live/types.ts +197 -0
  92. package/src/lab/observe/from-conformance.ts +344 -0
  93. package/src/lab/observe/from-live.ts +103 -0
  94. package/src/lab/paths.ts +65 -0
  95. package/src/lab/projection/rebuild.ts +477 -0
  96. package/src/lab/projection/schema.ts +122 -0
  97. package/src/lab/projection/verdicts.ts +438 -0
  98. package/src/lab/projection/verification.ts +332 -0
  99. package/src/lab/query/catalog.ts +72 -0
  100. package/src/lab/query/connection.ts +107 -0
  101. package/src/lab/query/constants.ts +4 -0
  102. package/src/lab/query/cursor.ts +132 -0
  103. package/src/lab/query/dto-map.ts +277 -0
  104. package/src/lab/query/errors.ts +22 -0
  105. package/src/lab/query/index.ts +34 -0
  106. package/src/lab/query/queries.ts +444 -0
  107. package/src/lab/query/types.ts +266 -0
  108. package/src/lab/subject/behavior-fingerprint.ts +77 -0
  109. package/src/lab/subject/installation-salt.ts +81 -0
  110. package/src/lab/subject/route-subject.ts +73 -0
  111. package/src/lib/bounded-body.ts +104 -0
  112. package/src/lib/lab-live-execution-authority.ts +13 -0
  113. package/src/lib/lab-live-host.ts +30 -0
  114. package/src/lib/lab-live-pinned-sender.ts +45 -0
  115. package/src/lib/pinned-http.ts +36 -7
  116. package/src/oauth/index.ts +20 -1
  117. package/src/oauth/local-token-detect.ts +11 -2
  118. package/src/oauth/login-cli.ts +38 -4
  119. package/src/providers/alibaba-region-migration.ts +1 -1
  120. package/src/providers/codex-capacity.ts +7 -4
  121. package/src/providers/command-code-efforts.ts +5 -2
  122. package/src/providers/derive.ts +105 -0
  123. package/src/providers/label.ts +1 -1
  124. package/src/providers/openai-tiers.ts +59 -3
  125. package/src/providers/opencode-zen-rate-limit.ts +102 -0
  126. package/src/providers/quota.ts +67 -21
  127. package/src/providers/registry.ts +118 -5
  128. package/src/router.ts +15 -1
  129. package/src/routing/analytics.ts +8 -7
  130. package/src/routing/evaluator.ts +51 -5
  131. package/src/routing/profile.ts +10 -1
  132. package/src/routing/trace.ts +42 -6
  133. package/src/server/auth-cors.ts +15 -0
  134. package/src/server/background-lifecycle.ts +169 -0
  135. package/src/server/chat-completions.ts +18 -3
  136. package/src/server/claude-messages.ts +1 -0
  137. package/src/server/images.ts +78 -11
  138. package/src/server/index.ts +72 -18
  139. package/src/server/lifecycle.ts +6 -0
  140. package/src/server/live.ts +5 -1
  141. package/src/server/management/agent-settings-routes.ts +41 -24
  142. package/src/server/management/body.ts +6 -0
  143. package/src/server/management/config-routes.ts +51 -8
  144. package/src/server/management/lab-routes.ts +388 -0
  145. package/src/server/management/logs-usage-routes.ts +19 -1
  146. package/src/server/management/model-rows.ts +25 -11
  147. package/src/server/management/native-integration-routes.ts +3 -3
  148. package/src/server/management/provider-routes.ts +5 -0
  149. package/src/server/management/shared.ts +8 -1
  150. package/src/server/management/usage-summary-cache.ts +2 -0
  151. package/src/server/management/vision-sidecar-options.ts +125 -0
  152. package/src/server/management-api.ts +2 -0
  153. package/src/server/request-decompress.ts +106 -5
  154. package/src/server/responses/collaboration.ts +4 -1
  155. package/src/server/responses/core.ts +117 -31
  156. package/src/server/responses-json-events.ts +50 -12
  157. package/src/server/search.ts +23 -7
  158. package/src/server/ws-bridge.ts +3 -1
  159. package/src/service.ts +40 -20
  160. package/src/types.ts +74 -0
  161. package/src/update/job.ts +10 -2
  162. package/src/usage/cost.ts +0 -0
  163. package/src/usage/expected-prices.ts +9 -0
  164. package/src/usage/log.ts +9 -9
  165. package/src/usage/user-cost-overlay-reconciler.ts +313 -0
  166. package/src/usage/user-cost-overlays.ts +314 -0
  167. package/src/vision/describe.ts +2 -2
  168. package/src/vision/eligibility.ts +229 -0
  169. package/src/vision/index.ts +20 -2
  170. package/src/web-search/executor.ts +2 -2
  171. package/src/web-search/index.ts +4 -2
  172. package/src/web-search/loop.ts +3 -1
  173. package/src/web-search/parse.ts +72 -3
  174. package/gui/dist/assets/index-BA1dgo4q.css +0 -1
  175. package/gui/dist/assets/index-DDZpgzKk.js +0 -70
@@ -9,6 +9,7 @@ import type {
9
9
  OcxConfig,
10
10
  OcxRoutingProfileConfig,
11
11
  OcxRoutingUnknownEvidenceMode,
12
+ OcxRoutingUnknownCostCapMode,
12
13
  } from "../types";
13
14
  import { codexAccountNamespaceEntries } from "../codex/account-namespaces";
14
15
  import { listComboIds, resolveComboId } from "../combos";
@@ -60,7 +61,7 @@ export interface NormalizedRoutingProfile {
60
61
  candidates: Array<{ provider: string; model: string }>;
61
62
  require: NormalizedRoutingProfileRequirements;
62
63
  optimize: { latency: number; health: number; cost: number; quota: number };
63
- limits: { maxEstimatedCostUsd?: number };
64
+ limits: { maxEstimatedCostUsd?: number; onUnknownCost?: OcxRoutingUnknownCostCapMode };
64
65
  unknownEvidence: Record<"capability" | "health" | "quota" | "cost", OcxRoutingUnknownEvidenceMode>;
65
66
  revision: string;
66
67
  }
@@ -317,6 +318,11 @@ export function routingProfileIssues(
317
318
  || limits.maxEstimatedCostUsd < 0)) {
318
319
  issues.push({ path: ["limits", "maxEstimatedCostUsd"], message: "maxEstimatedCostUsd must be a non-negative number" });
319
320
  }
321
+ if (limits.onUnknownCost !== undefined
322
+ && limits.onUnknownCost !== "allow"
323
+ && limits.onUnknownCost !== "exclude") {
324
+ issues.push({ path: ["limits", "onUnknownCost"], message: 'onUnknownCost must be "allow" or "exclude"' });
325
+ }
320
326
  }
321
327
  }
322
328
 
@@ -402,6 +408,9 @@ export function normalizeRoutingProfile(id: string, raw: OcxRoutingProfileConfig
402
408
  ...(raw.limits?.maxEstimatedCostUsd !== undefined
403
409
  ? { maxEstimatedCostUsd: raw.limits.maxEstimatedCostUsd }
404
410
  : {}),
411
+ ...(raw.limits?.onUnknownCost !== undefined
412
+ ? { onUnknownCost: raw.limits.onUnknownCost }
413
+ : {}),
405
414
  },
406
415
  unknownEvidence: normalizedUnknownEvidence(raw),
407
416
  };
@@ -77,12 +77,29 @@ export interface RouteQuotaEvidence {
77
77
  source?: string;
78
78
  }
79
79
 
80
+ /**
81
+ * Stable wire outcome for `limits.maxEstimatedCostUsd` against this candidate's
82
+ * cost evidence. Present only when a cap is configured. Non-excluding outcomes
83
+ * (`satisfied`, `unknown-allowed`) must not be mirrored into `exclusions`.
84
+ */
85
+ export type RouteCostCapOutcome =
86
+ | "satisfied"
87
+ | "exceeded"
88
+ | "unknown-allowed"
89
+ | "unknown-excluded";
90
+
80
91
  export interface RouteCostEvidence {
81
92
  estimatedUsd?: number;
82
93
  /** Stable wire code for the price source (e.g. "registry" | "expected"). */
83
94
  priceSource?: string;
84
95
  incomplete?: boolean;
85
96
  limitUsd?: number;
97
+ /**
98
+ * Cap evaluation outcome when `limitUsd` / profile `maxEstimatedCostUsd` is set.
99
+ * Distinguishes "known under the cap" from "unknown cost allowed by policy"
100
+ * without treating the latter as an exclusion.
101
+ */
102
+ capOutcome?: RouteCostCapOutcome;
86
103
  }
87
104
 
88
105
  export interface RouteScoreEvidence {
@@ -465,13 +482,20 @@ function parseCapability(raw: unknown, caps: ParseCaps): RouteCapabilityEvidence
465
482
  if (image !== undefined) out.image = image;
466
483
  const structuredOutput = unknownable(raw.structuredOutput);
467
484
  if (structuredOutput !== undefined) out.structuredOutput = structuredOutput;
468
- if (Array.isArray(raw.reasoningEfforts)
469
- && raw.reasoningEfforts.slice(0, 8).every((value): value is string => typeof value === "string")) {
470
- if (raw.reasoningEfforts.some((value: unknown) => typeof value === "string"
485
+ const rawReasoningEfforts = Array.isArray(raw.reasoningEfforts)
486
+ ? raw.reasoningEfforts
487
+ : undefined;
488
+ const reasoningEfforts = rawReasoningEfforts
489
+ ? Array.from(
490
+ { length: Math.min(rawReasoningEfforts.length, 8) },
491
+ (_, index) => rawReasoningEfforts[index],
492
+ )
493
+ : undefined;
494
+ if (reasoningEfforts
495
+ && reasoningEfforts.every((value): value is string => typeof value === "string")) {
496
+ if (reasoningEfforts.some((value: unknown) => typeof value === "string"
471
497
  && value.length > MAX_TRACE_STRING)) caps.strings = true;
472
- out.reasoningEfforts = raw.reasoningEfforts
473
- .slice(0, 8)
474
- .map(value => value.slice(0, MAX_TRACE_STRING));
498
+ out.reasoningEfforts = reasoningEfforts.map(value => value.slice(0, MAX_TRACE_STRING));
475
499
  }
476
500
  if (raw.serviceTier === "unknown") {
477
501
  out.serviceTier = "unknown";
@@ -521,6 +545,13 @@ function parseQuota(raw: unknown, caps: ParseCaps): RouteQuotaEvidence | undefin
521
545
  return out;
522
546
  }
523
547
 
548
+ const COST_CAP_OUTCOMES = new Set<RouteCostCapOutcome>([
549
+ "satisfied",
550
+ "exceeded",
551
+ "unknown-allowed",
552
+ "unknown-excluded",
553
+ ]);
554
+
524
555
  /** Whitelisted cost-evidence parse. */
525
556
  function parseCost(raw: unknown, caps: ParseCaps): RouteCostEvidence | undefined {
526
557
  if (!isPlainRecord(raw)) return undefined;
@@ -532,6 +563,11 @@ function parseCost(raw: unknown, caps: ParseCaps): RouteCostEvidence | undefined
532
563
  }
533
564
  if (typeof raw.incomplete === "boolean") out.incomplete = raw.incomplete;
534
565
  if (finiteNumber(raw.limitUsd)) out.limitUsd = raw.limitUsd;
566
+ if (out.limitUsd !== undefined
567
+ && typeof raw.capOutcome === "string"
568
+ && COST_CAP_OUTCOMES.has(raw.capOutcome as RouteCostCapOutcome)) {
569
+ out.capOutcome = raw.capOutcome as RouteCostCapOutcome;
570
+ }
535
571
  return Object.keys(out).length > 0 ? out : undefined;
536
572
  }
537
573
 
@@ -10,8 +10,10 @@ import {
10
10
  positiveIntegerRecordConfigError,
11
11
  providerBaseUrlConfigError,
12
12
  providerHeadersConfigError,
13
+ providerModelCostsConfigError,
13
14
  reasoningSummaryDeliveryRecordConfigError,
14
15
  retryOn429PolicyConfigError,
16
+ sanitizeModelCostsForDisplay,
15
17
  } from "../config";
16
18
  import { providerDestinationConfigError } from "../lib/destination-policy";
17
19
  import { redactSecretString } from "../lib/redact";
@@ -447,6 +449,9 @@ export function providerManagementConfigError(name: unknown, provider: unknown):
447
449
  if (seed) seed.codexAccountMode = raw.codexAccountMode;
448
450
  const canonicalCandidate = { ...raw };
449
451
  delete canonicalCandidate.responsesSnapshotRepair;
452
+ // modelCosts is a user-owned display overlay, not part of the canonical
453
+ // forward seed; it is validated separately below (providerModelCostsConfigError).
454
+ delete canonicalCandidate.modelCosts;
450
455
  const canonical = seed && sameCanonicalProviderSeed(canonicalCandidate, seed);
451
456
  if (!canonical) {
452
457
  return `provider ${name} must equal the canonical built-in provider seed`;
@@ -471,6 +476,12 @@ export function providerManagementConfigError(name: unknown, provider: unknown):
471
476
  // it before it reaches the management API response.
472
477
  return `provider ${JSON.stringify(redactSecretString(name))} ${retryOn429Error}`;
473
478
  }
479
+ const modelCostsError = providerModelCostsConfigError(raw.modelCosts);
480
+ if (modelCostsError) {
481
+ // The provider name is caller-controlled and can be token-shaped; redact and JSON-escape
482
+ // it before it reaches the management API response (same rule as retryOn429 above).
483
+ return `provider ${JSON.stringify(redactSecretString(name))} ${modelCostsError}`;
484
+ }
474
485
  const apiKeyTransportError = apiKeyTransportConfigError(typed);
475
486
  if (apiKeyTransportError) return `provider ${name} ${apiKeyTransportError}`;
476
487
  const maxInputError = positiveIntegerRecordConfigError(raw.modelMaxInputTokens, "modelMaxInputTokens");
@@ -544,6 +555,7 @@ export function copyIfDefined<K extends keyof OcxProviderConfig>(
544
555
  if (value !== undefined) out[key as string] = value as unknown;
545
556
  }
546
557
 
558
+ /** Public dashboard DTO for config.json: provider entries with secrets stripped and documented fields exposed (including `modelCosts`). */
547
559
  export function safeConfigDTO(config: OcxConfig): unknown {
548
560
  const providers: Record<string, Record<string, unknown>> = {};
549
561
  for (const [name, provider] of Object.entries(config.providers)) {
@@ -579,10 +591,13 @@ export function safeConfigDTO(config: OcxConfig): unknown {
579
591
  "noPenaltyModels",
580
592
  "autoToolChoiceOnlyModels",
581
593
  "preserveReasoningContentModels",
594
+ "requiresReasoningPlaceholderModels",
582
595
  "escapeBuiltinToolNames",
583
596
  ] as const) {
584
597
  copyIfDefined(dto, provider, key);
585
598
  }
599
+ const modelCosts = sanitizeModelCostsForDisplay(provider.modelCosts);
600
+ if (modelCosts) dto.modelCosts = modelCosts;
586
601
  // Resolve the note by DESTINATION, not by name. A preset saved under a custom name is
587
602
  // still pointed at the same vendor route, and a usage restriction the user needs to see
588
603
  // must not disappear because the row was renamed. Prefer the same-name entry so an
@@ -0,0 +1,169 @@
1
+ import type { StorageCleanupPolicy } from "../types";
2
+ import { startStateStoreSweeper } from "../lib/state-store-sweeper";
3
+ import {
4
+ abortStorageCleanupPolicyJobAsync,
5
+ setStorageCleanupPolicyJobLiveApply,
6
+ } from "../storage/policy-job";
7
+ import { setStorageCleanupPolicyLiveSink } from "../storage/policy";
8
+ import {
9
+ scheduleStorageCleanupStartupRun,
10
+ startStorageCleanupScheduler,
11
+ stopStorageCleanupScheduler,
12
+ } from "../storage/policy-scheduler";
13
+ import {
14
+ cancelQueuedStorageWorkerSpawns,
15
+ drainStorageWorkers,
16
+ } from "../storage/worker-lifecycle";
17
+ import {
18
+ startMemoryWatchdog,
19
+ type MemoryWatchdog,
20
+ } from "./memory-watchdog";
21
+
22
+ type PolicyApply = (policy: StorageCleanupPolicy) => void;
23
+
24
+ type ProcessLoops = {
25
+ memoryWatchdog: MemoryWatchdog;
26
+ stateStoreSweeper: ReturnType<typeof startStateStoreSweeper>;
27
+ };
28
+
29
+ type LeaseOwner = {
30
+ token: symbol;
31
+ applyPolicy: PolicyApply;
32
+ };
33
+
34
+ export type ServerBackgroundLifecycleLease = {
35
+ scheduleStartupRun(): void;
36
+ release(): Promise<void>;
37
+ releaseAfterFailedStart(): void;
38
+ };
39
+
40
+ const owners: LeaseOwner[] = [];
41
+ let processLoops: ProcessLoops | null = null;
42
+ let cleanupInProgress = false;
43
+
44
+ function setLivePolicyOwner(applyPolicy: PolicyApply | null): void {
45
+ setStorageCleanupPolicyLiveSink(applyPolicy);
46
+ setStorageCleanupPolicyJobLiveApply(applyPolicy);
47
+ }
48
+
49
+ function startProcessLoops(applyPolicy: PolicyApply): ProcessLoops {
50
+ let memoryWatchdog: MemoryWatchdog | null = null;
51
+ let stateStoreSweeper: ReturnType<typeof startStateStoreSweeper> | null = null;
52
+ try {
53
+ memoryWatchdog = startMemoryWatchdog();
54
+ stateStoreSweeper = startStateStoreSweeper();
55
+ setLivePolicyOwner(applyPolicy);
56
+ startStorageCleanupScheduler();
57
+ return { memoryWatchdog, stateStoreSweeper };
58
+ } catch (error) {
59
+ memoryWatchdog?.stop();
60
+ stateStoreSweeper?.stop();
61
+ stopStorageCleanupScheduler();
62
+ setLivePolicyOwner(null);
63
+ throw error;
64
+ }
65
+ }
66
+
67
+ function stopProcessLoops(): void {
68
+ const loops = processLoops;
69
+ processLoops = null;
70
+ loops?.memoryWatchdog.stop();
71
+ loops?.stateStoreSweeper.stop();
72
+ stopStorageCleanupScheduler();
73
+ setLivePolicyOwner(null);
74
+ }
75
+
76
+ async function stopStoragePolicyWorker(): Promise<void> {
77
+ cancelQueuedStorageWorkerSpawns();
78
+ const abortResult = await Promise.allSettled([abortStorageCleanupPolicyJobAsync()]);
79
+ if (abortResult[0]?.status === "rejected") {
80
+ console.warn(
81
+ "[storage] policy worker abort during server stop failed:",
82
+ abortResult[0].reason instanceof Error
83
+ ? abortResult[0].reason.message
84
+ : abortResult[0].reason,
85
+ );
86
+ }
87
+ try {
88
+ await drainStorageWorkers();
89
+ } catch (error) {
90
+ console.warn(
91
+ "[storage] worker drain during server stop failed:",
92
+ error instanceof Error ? error.message : error,
93
+ );
94
+ }
95
+ }
96
+
97
+ function removeOwner(owner: LeaseOwner): boolean {
98
+ const index = owners.findIndex(candidate => candidate.token === owner.token);
99
+ if (index === -1) return false;
100
+ owners.splice(index, 1);
101
+ return true;
102
+ }
103
+
104
+ function releaseOwnerSynchronously(owner: LeaseOwner): "inactive" | "shared" | "last" {
105
+ if (!removeOwner(owner)) return "inactive";
106
+ const nextOwner = owners.at(-1);
107
+ if (nextOwner) {
108
+ setLivePolicyOwner(nextOwner.applyPolicy);
109
+ return "shared";
110
+ }
111
+ stopProcessLoops();
112
+ return "last";
113
+ }
114
+
115
+ /**
116
+ * Acquire one server's lease on process-wide singleton loops.
117
+ *
118
+ * The first live server starts the loops. Later servers only add a reference and
119
+ * become the current policy sink; out-of-order release restores the newest
120
+ * remaining sink. The final release owns timer and Worker teardown.
121
+ */
122
+ export function acquireServerBackgroundLifecycle(
123
+ applyPolicy: PolicyApply,
124
+ ): ServerBackgroundLifecycleLease {
125
+ if (cleanupInProgress) {
126
+ throw new Error("server background lifecycle cleanup is still in progress");
127
+ }
128
+
129
+ const owner: LeaseOwner = {
130
+ token: Symbol("server-background-lifecycle"),
131
+ applyPolicy,
132
+ };
133
+ if (!processLoops) {
134
+ processLoops = startProcessLoops(applyPolicy);
135
+ } else {
136
+ setLivePolicyOwner(applyPolicy);
137
+ }
138
+ owners.push(owner);
139
+
140
+ let releaseFlight: Promise<void> | null = null;
141
+ return {
142
+ scheduleStartupRun() {
143
+ if (owners.some(candidate => candidate.token === owner.token)) {
144
+ scheduleStorageCleanupStartupRun();
145
+ }
146
+ },
147
+ release() {
148
+ if (releaseFlight) return releaseFlight;
149
+ const outcome = releaseOwnerSynchronously(owner);
150
+ if (outcome !== "last") {
151
+ releaseFlight = Promise.resolve();
152
+ return releaseFlight;
153
+ }
154
+ cleanupInProgress = true;
155
+ releaseFlight = stopStoragePolicyWorker().finally(() => {
156
+ cleanupInProgress = false;
157
+ });
158
+ return releaseFlight;
159
+ },
160
+ releaseAfterFailedStart() {
161
+ if (releaseFlight) return;
162
+ // Startup evaluation is scheduled only after both listeners bind, so a
163
+ // failed start cannot have spawned a Worker for this lease. Keep rollback
164
+ // synchronous so a caller may immediately retry a different port.
165
+ releaseOwnerSynchronously(owner);
166
+ releaseFlight = Promise.resolve();
167
+ },
168
+ };
169
+ }
@@ -85,7 +85,6 @@ async function handleChatCompletionsWithBudget(
85
85
  try {
86
86
  chatBody = await readChatBody(req, translatorBudget);
87
87
  internalBody = chatCompletionsToResponsesBody(chatBody);
88
- translatorBudget.chargeRetained(new TextEncoder().encode(JSON.stringify(internalBody)).byteLength, { kind: "request_copies" });
89
88
  } catch (err) {
90
89
  const overflow = isTranslatorBudgetExceededError(err);
91
90
  const status = overflow ? 413 : err instanceof ChatCompletionsRequestError ? 400 : 500;
@@ -180,8 +179,24 @@ async function handleChatCompletionsWithBudget(
180
179
  }
181
180
  }
182
181
 
183
- const internalBodyJson = JSON.stringify(internalBody);
184
- translatorBudget.chargeRetained(new TextEncoder().encode(internalBodyJson).byteLength, { kind: "request_copies" });
182
+ let internalBodyJson: string;
183
+ try {
184
+ internalBodyJson = JSON.stringify(internalBody);
185
+ translatorBudget.chargeRetained(
186
+ new TextEncoder().encode(internalBodyJson).byteLength,
187
+ { kind: "request_copies" },
188
+ );
189
+ } catch (err) {
190
+ const overflow = isTranslatorBudgetExceededError(err);
191
+ const status = overflow ? 413 : 500;
192
+ if (logIds) addFinalRequestLog(logIds.requestId, logIds.start, logCtx, status, { closeReason: "non_stream" });
193
+ return chatCompletionsErrorResponse(
194
+ status,
195
+ overflow ? "request translation buffer exceeded the safe limit" : err instanceof Error ? err.message : String(err),
196
+ overflow ? "request_too_large" : undefined,
197
+ overflow ? "translation_buffer_limit" : undefined,
198
+ );
199
+ }
185
200
  const internalReq = new Request("http://localhost/v1/responses", {
186
201
  method: "POST",
187
202
  headers,
@@ -732,6 +732,7 @@ async function handleClaudeMessagesWithBudget(
732
732
  // Without this the replay would look native and a Responses-scoped wire default
733
733
  // would fire, disagreeing with the pre-flight decision above.
734
734
  inboundWire: "anthropic",
735
+ stripClaudeMainAuthForNoncanonicalForward: true,
735
736
  translatorBudget,
736
737
  ...(logIds ? { onFirstOutput: () => recordFirstOutput(logCtx, logIds.start) } : {}),
737
738
  onNativePassthroughTerminal: status => finalizeNativeLog(httpStatusForTerminalStatus(status), { terminalStatus: status, closeReason: "terminal" }),
@@ -24,6 +24,7 @@ import {
24
24
  } from "../codex/auth-context";
25
25
  import { formatCodexProviderForLog } from "../codex/routing";
26
26
  import { signalWithTimeout } from "../lib/abort";
27
+ import { readBoundedResponseBytes, type BoundedBytesResult } from "../lib/bounded-body";
27
28
  import { sidecarEnter } from "../lib/sidecar-tracker";
28
29
  import type { OcxConfig } from "../types";
29
30
  import { resolveFirstUsableOpenAiSidecar, selectImagesProvider } from "../providers/openai-sidecar";
@@ -50,7 +51,56 @@ const IMAGES_UPSTREAM_TIMEOUT_MS = 300_000;
50
51
  * containing base64-encoded images — typically a few MB. This prevents an oversized or malicious
51
52
  * response from exhausting process memory.
52
53
  */
53
- const IMAGES_RESPONSE_MAX_BYTES = 100 * 1024 * 1024;
54
+ export const IMAGES_RESPONSE_MAX_BYTES = 100 * 1024 * 1024;
55
+
56
+ interface ImageResponseBytesOptions {
57
+ signal?: AbortSignal;
58
+ /** Smaller values are used only by focused reader tests. */
59
+ maxBytes?: number;
60
+ }
61
+
62
+ function cancelUnlockedResponseBody(response: Response | undefined, reason?: unknown): void {
63
+ const body = response?.body;
64
+ if (!body || body.locked) return;
65
+ try {
66
+ void body.cancel(reason).catch(() => undefined);
67
+ } catch {
68
+ // A broken stream can throw synchronously from cancel().
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Read one image relay response as exact raw bytes under the same cap used by
74
+ * the public handler. A trustworthy Content-Length can reject obvious excess
75
+ * before attaching a reader; the streaming reader remains authoritative when
76
+ * the header is absent or understated.
77
+ */
78
+ export async function readImageResponseBytes(
79
+ response: Response,
80
+ options: ImageResponseBytesOptions = {},
81
+ ): Promise<BoundedBytesResult> {
82
+ const signal = options.signal;
83
+ if (signal?.aborted) {
84
+ cancelUnlockedResponseBody(response, signal.reason);
85
+ throw signal.reason;
86
+ }
87
+ if (response.body === null) {
88
+ return { bytes: new Uint8Array(0), oversized: false };
89
+ }
90
+
91
+ const maxBytes = options.maxBytes ?? IMAGES_RESPONSE_MAX_BYTES;
92
+ const contentLength = response.headers.get("content-length");
93
+ const declaredBytes = contentLength === null ? Number.NaN : Number(contentLength);
94
+ if (Number.isSafeInteger(declaredBytes) && declaredBytes > maxBytes) {
95
+ cancelUnlockedResponseBody(
96
+ response,
97
+ new DOMException("Response body size limit reached", "QuotaExceededError"),
98
+ );
99
+ return { bytes: new Uint8Array(0), oversized: true };
100
+ }
101
+
102
+ return readBoundedResponseBytes(response, { maxBytes, signal });
103
+ }
54
104
 
55
105
  const CCA_IMAGE_MODEL = "gemini-3.1-flash-image";
56
106
 
@@ -440,34 +490,47 @@ export async function handleImages(
440
490
  const timeoutMs = config.images?.timeoutMs ?? IMAGES_UPSTREAM_TIMEOUT_MS;
441
491
  const linkedSignal = signalWithTimeout(timeoutMs, req.signal);
442
492
  const sidecarExit = sidecarEnter("images");
493
+ let upstreamResponse: Response | undefined;
443
494
  try {
444
495
  // Images POSTs create paid, non-idempotent work. One fetch only: no reset retry without a
445
496
  // source-proven idempotency contract.
446
- const upstreamResponse = await fetch(url, {
497
+ upstreamResponse = await fetch(url, {
447
498
  method: "POST",
448
499
  headers,
449
500
  body: JSON.stringify(body),
450
501
  signal: linkedSignal.signal,
451
502
  });
452
- // Buffer rather than stream: the payload is one JSON document (base64 image, typically a few
453
- // MB), and buffering keeps the timeout window covering the whole exchange. Cap the size to
454
- // prevent an oversized response from exhausting process memory.
455
- const payload = await upstreamResponse.arrayBuffer();
456
- if (payload.byteLength > IMAGES_RESPONSE_MAX_BYTES) {
457
- return formatErrorResponse(502, "upstream_error", `image ${endpoint} response too large (${payload.byteLength} bytes)`);
503
+ const observed = await readImageResponseBytes(upstreamResponse, {
504
+ maxBytes: IMAGES_RESPONSE_MAX_BYTES,
505
+ signal: linkedSignal.signal,
506
+ });
507
+ if (observed.oversized) {
508
+ forward?.recordOutcome?.(upstreamResponse.status);
509
+ return formatErrorResponse(
510
+ 502,
511
+ "upstream_error",
512
+ `image ${endpoint} response too large (exceeded ${IMAGES_RESPONSE_MAX_BYTES} bytes)`,
513
+ );
458
514
  }
459
- forward?.recordOutcome?.(upstreamResponse.status);
460
515
  const relayHeaders: Record<string, string> = {};
461
516
  const contentType = upstreamResponse.headers.get("content-type");
462
517
  if (contentType) relayHeaders["content-type"] = contentType;
463
- return new Response(payload, { status: upstreamResponse.status, headers: relayHeaders });
518
+ // Fetch represents 204/205/304 responses with a null body. Preserve that
519
+ // invariant: constructing those statuses with even an empty Uint8Array throws.
520
+ const relayBody = upstreamResponse.body === null ? null : observed.bytes;
521
+ const relayResponse = new Response(relayBody, {
522
+ status: upstreamResponse.status,
523
+ headers: relayHeaders,
524
+ });
525
+ forward?.recordOutcome?.(upstreamResponse.status);
526
+ return relayResponse;
464
527
  } catch (err) {
465
528
  // Client cancel first: it aborts the linked signal too, and must not be logged as an
466
529
  // upstream failure (499 maps to client_closed_request in the request log).
467
530
  if (req.signal.aborted) {
468
531
  return formatErrorResponse(499, "client_closed_request", `image ${endpoint} request canceled by client`);
469
532
  }
470
- if (err instanceof Error && err.name === "TimeoutError") {
533
+ if (linkedSignal.signal.aborted || (err instanceof Error && err.name === "TimeoutError")) {
471
534
  forward?.recordOutcome?.("timeout");
472
535
  // codex retries 5xx up to 4 more times; a retried 504 is acceptable for a transient hang.
473
536
  return formatErrorResponse(504, "upstream_error", `image ${endpoint} upstream timed out`);
@@ -481,5 +544,9 @@ export async function handleImages(
481
544
  } finally {
482
545
  sidecarExit();
483
546
  linkedSignal.cleanup();
547
+ // If cancellation won before readImageResponseBytes attached its reader, the
548
+ // response still owns an upstream body/socket. Consumed or locked bodies are
549
+ // already owned by the reader and need no second cancellation.
550
+ cancelUnlockedResponseBody(upstreamResponse);
484
551
  }
485
552
  }