@bitkyc08/opencodex 2.65.0 → 2.66.0-preview.20260925

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 (213) hide show
  1. package/README.md +14 -0
  2. package/gui/dist/assets/App-BVjA0T6O.js +51 -0
  3. package/gui/dist/assets/App-gkoJIEOV.css +1 -0
  4. package/gui/dist/assets/{Tray-D4bA1vff.js → Tray-BCFtiWwD.js} +1 -1
  5. package/gui/dist/assets/index-ComVsdSk.css +1 -0
  6. package/gui/dist/assets/index-vO4dgfiU.js +86 -0
  7. package/gui/dist/assets/{tray-data-BZXhB3qz.js → tray-data-C4FiNaxk.js} +1 -1
  8. package/gui/dist/index.html +2 -2
  9. package/package.json +1 -2
  10. package/src/adapters/anthropic/beta-allowlist.ts +80 -0
  11. package/src/adapters/anthropic/passthrough.ts +221 -0
  12. package/src/adapters/anthropic-image-codec.ts +56 -19
  13. package/src/adapters/anthropic-image-normalize.ts +20 -10
  14. package/src/adapters/anthropic.ts +55 -24
  15. package/src/adapters/coding-agent/protocol.ts +29 -19
  16. package/src/adapters/cursor/live-transport.ts +18 -3
  17. package/src/adapters/cursor/native-exec-shell.ts +18 -63
  18. package/src/adapters/cursor/native-exec.ts +5 -2
  19. package/src/adapters/cursor/native-foreground-shell.ts +44 -0
  20. package/src/adapters/devin/cloud-direct/chat.ts +7 -1
  21. package/src/adapters/exec-tool-result-normalize.ts +4 -1
  22. package/src/adapters/google.ts +12 -0
  23. package/src/adapters/ollama-native.ts +32 -1
  24. package/src/adapters/openai-chat/serialized-tool-call-content.ts +131 -34
  25. package/src/adapters/openai-chat.ts +6 -4
  26. package/src/adapters/openai-responses/passthrough.ts +1 -0
  27. package/src/adapters/openai-responses/reasoning.ts +45 -1
  28. package/src/bridge/response-json.ts +10 -2
  29. package/src/chat/outbound.ts +104 -52
  30. package/src/claude/claude-code-block.ts +16 -0
  31. package/src/claude/context-windows.ts +52 -4
  32. package/src/claude/desktop-first-party.ts +25 -8
  33. package/src/claude/inbound.ts +22 -0
  34. package/src/claude/intercept/connect-proxy.ts +37 -4
  35. package/src/claude/intercept/proxy-auth.ts +166 -0
  36. package/src/claude/intercept/runtime.ts +21 -0
  37. package/src/claude/intercept/settings.ts +33 -12
  38. package/src/claude/outbound.ts +45 -27
  39. package/src/cli/account-extended.ts +47 -15
  40. package/src/cli/account-target.ts +115 -0
  41. package/src/cli/account.ts +20 -28
  42. package/src/cli/agent.ts +10 -1
  43. package/src/cli/api-protocols.ts +206 -0
  44. package/src/cli/capabilities.ts +86 -0
  45. package/src/cli/combo.ts +2 -2
  46. package/src/cli/connect.ts +127 -3
  47. package/src/cli/dispatch.ts +8 -0
  48. package/src/cli/doctor.ts +33 -0
  49. package/src/cli/help.ts +2 -0
  50. package/src/cli/link.ts +288 -0
  51. package/src/cli/registry.ts +23 -0
  52. package/src/cli/runtime-api.ts +79 -0
  53. package/src/cli/system-command.ts +21 -2
  54. package/src/client/connect.ts +125 -23
  55. package/src/client/hub-relay.ts +12 -8
  56. package/src/client/link-join.ts +323 -0
  57. package/src/client/link-relay.ts +241 -0
  58. package/src/client/link-state.ts +110 -0
  59. package/src/client/link-teardown.ts +63 -0
  60. package/src/client/link-tunnel.ts +465 -0
  61. package/src/client/machine-listener.ts +14 -5
  62. package/src/client/runtime.ts +91 -42
  63. package/src/client/state.ts +4 -0
  64. package/src/codex/catalog/build-entries.ts +4 -0
  65. package/src/codex/catalog/derive-entry.ts +5 -0
  66. package/src/codex/catalog/parsing.ts +4 -0
  67. package/src/codex/catalog/retained-sync.ts +4 -1
  68. package/src/codex/desktop-switches.ts +88 -8
  69. package/src/codex/inject/plan.ts +16 -3
  70. package/src/codex/inject.ts +3 -0
  71. package/src/codex/internal/catalog-writer.ts +13 -2
  72. package/src/combos/failover.ts +3 -2
  73. package/src/combos/index.ts +12 -0
  74. package/src/combos/jev.ts +646 -0
  75. package/src/combos/request.ts +5 -0
  76. package/src/combos/resolve.ts +17 -15
  77. package/src/combos/types.ts +47 -3
  78. package/src/config/atomic-write.ts +3 -0
  79. package/src/config/live-reconcile.ts +20 -4
  80. package/src/config/persist-unlocked.ts +20 -7
  81. package/src/config/schema/config-schema.ts +16 -0
  82. package/src/config/schema/leaf-validators.ts +38 -1
  83. package/src/generated/compatibility-version.json +416 -128
  84. package/src/integrations/omp-yaml-source.ts +109 -4
  85. package/src/lab/automation/orchestrator.ts +60 -4
  86. package/src/lab/events/limits.ts +1 -1
  87. package/src/lib/bounded-body.ts +55 -38
  88. package/src/lib/config-ownership.ts +11 -22
  89. package/src/lib/local-desktop-snapshot-capability.ts +56 -0
  90. package/src/lib/local-management-capability.ts +25 -3
  91. package/src/lib/optional-shutdown-hooks.ts +17 -0
  92. package/src/lib/state-store-registrations.ts +2 -2
  93. package/src/lib/windows-elevation.ts +62 -25
  94. package/src/lib/windows-secret-acl.ts +114 -23
  95. package/src/link/admission-wait.ts +73 -0
  96. package/src/link/compensation.ts +100 -0
  97. package/src/link/fingerprint.ts +21 -0
  98. package/src/link/paths.ts +19 -0
  99. package/src/link/ports.ts +12 -0
  100. package/src/link/routes.ts +32 -0
  101. package/src/link/ssh-argv.ts +170 -0
  102. package/src/link/ssh-config.ts +152 -0
  103. package/src/link/ssh-runner.ts +161 -0
  104. package/src/link/status-projection.ts +96 -0
  105. package/src/link/store.ts +139 -0
  106. package/src/link/supervisor.ts +404 -0
  107. package/src/link/tunnel-state.ts +91 -0
  108. package/src/protocols/baseline.ts +88 -0
  109. package/src/protocols/codecs/chat.ts +17 -0
  110. package/src/protocols/codecs/messages.ts +18 -0
  111. package/src/protocols/codecs/responses.ts +17 -0
  112. package/src/protocols/contract.ts +185 -0
  113. package/src/protocols/dto.ts +267 -0
  114. package/src/protocols/encoders/adapter-events.ts +876 -0
  115. package/src/protocols/encoders/chat.ts +243 -0
  116. package/src/protocols/encoders/messages.ts +462 -0
  117. package/src/protocols/envelope.ts +43 -0
  118. package/src/protocols/features.ts +295 -0
  119. package/src/protocols/guard.ts +33 -0
  120. package/src/protocols/opaque-state.ts +132 -0
  121. package/src/protocols/path.ts +39 -0
  122. package/src/protocols/plan-snapshot.ts +226 -0
  123. package/src/protocols/plan.ts +192 -0
  124. package/src/protocols/provider-summary.ts +101 -0
  125. package/src/protocols/settings.ts +118 -0
  126. package/src/protocols/shadow-plan.ts +36 -0
  127. package/src/protocols/shadow.ts +62 -0
  128. package/src/protocols/trace.ts +335 -0
  129. package/src/providers/model-rename-migration.ts +3 -3
  130. package/src/providers/registry/entries-core.ts +6 -1
  131. package/src/providers/registry/entries-extended.ts +15 -0
  132. package/src/providers/registry/model-ids.ts +1 -0
  133. package/src/providers/registry/types.ts +6 -0
  134. package/src/providers/registry.ts +1 -1
  135. package/src/remote-control/workspace-hub.ts +5 -4
  136. package/src/responses/apply-patch-envelope.ts +6 -1
  137. package/src/responses/code-mode-shell-input.ts +4 -2
  138. package/src/responses/freeform-wrapper-scan.ts +51 -24
  139. package/src/responses/progressive-freeform-input.ts +14 -20
  140. package/src/responses/spill-store.ts +187 -33
  141. package/src/responses/state/snapshot-select.ts +49 -0
  142. package/src/responses/state/spill-inspect.ts +46 -0
  143. package/src/responses/state/spill-queue.ts +16 -0
  144. package/src/responses/state.ts +51 -27
  145. package/src/server/audio-client.ts +8 -3
  146. package/src/server/audio-upstream.ts +9 -4
  147. package/src/server/auth-cors.ts +25 -8
  148. package/src/server/chat-completions.ts +71 -11
  149. package/src/server/chat-native-eligibility.ts +74 -0
  150. package/src/server/chat-native.ts +139 -74
  151. package/src/server/claude-messages.ts +140 -22
  152. package/src/server/hub-usage.ts +4 -3
  153. package/src/server/index/link-listener.ts +195 -0
  154. package/src/server/index/optional-listeners.ts +115 -0
  155. package/src/server/index/serve-options.ts +44 -10
  156. package/src/server/index.ts +10 -9
  157. package/src/server/inference/attempt.ts +51 -0
  158. package/src/server/inference/client-encoder-delivery.ts +235 -0
  159. package/src/server/inference/client-wire-log.ts +49 -0
  160. package/src/server/inference/client-wire.ts +69 -0
  161. package/src/server/inference/context.ts +15 -0
  162. package/src/server/inference/final-log.ts +37 -0
  163. package/src/server/local-desktop-snapshot-auth.ts +57 -0
  164. package/src/server/management/agent-settings-routes.ts +15 -13
  165. package/src/server/management/api-access.ts +11 -1
  166. package/src/server/management/config-routes.ts +3 -6
  167. package/src/server/management/context.ts +25 -0
  168. package/src/server/management/link-routes.ts +535 -0
  169. package/src/server/management/logs-usage-routes.ts +31 -1
  170. package/src/server/management/native-integration-routes.ts +6 -11
  171. package/src/server/management/oauth-account-routes.ts +42 -16
  172. package/src/server/management/protocol-routes.ts +159 -0
  173. package/src/server/management/protocol-settings-patch.ts +145 -0
  174. package/src/server/management/provider-patch-transaction.ts +61 -0
  175. package/src/server/management/provider-routes.ts +39 -18
  176. package/src/server/management/route-registry.ts +12 -0
  177. package/src/server/management/sidebar-routes.ts +8 -3
  178. package/src/server/management/system-routes.ts +18 -1
  179. package/src/server/management/usage-aggregate-cache.ts +206 -6
  180. package/src/server/management-api.ts +26 -1
  181. package/src/server/management-auth.ts +19 -5
  182. package/src/server/messages-native-eligibility.ts +161 -0
  183. package/src/server/messages-native-oauth.ts +149 -0
  184. package/src/server/messages-native.ts +790 -0
  185. package/src/server/relay.ts +9 -0
  186. package/src/server/request-log-filter.ts +92 -0
  187. package/src/server/request-log.ts +31 -70
  188. package/src/server/responses/adapter-delivery.ts +43 -16
  189. package/src/server/responses/agent-task-recovery.ts +90 -22
  190. package/src/server/responses/codex-ws-exchange.ts +4 -0
  191. package/src/server/responses/codex-ws-wire.ts +10 -1
  192. package/src/server/responses/core-combo-native.ts +323 -0
  193. package/src/server/responses/core-combo.ts +224 -13
  194. package/src/server/responses/core-options.ts +22 -0
  195. package/src/server/responses/core.ts +14 -26
  196. package/src/server/responses/request-sidecar-auth.ts +8 -3
  197. package/src/server/responses/run-turn-execution.ts +257 -63
  198. package/src/server/responses/sidecar-execution.ts +7 -4
  199. package/src/server/system-env.ts +7 -0
  200. package/src/service/diagnostics.ts +7 -1
  201. package/src/service/launchd.ts +26 -37
  202. package/src/service/windows-ops.ts +24 -20
  203. package/src/types/config.ts +58 -1
  204. package/src/types.ts +2 -0
  205. package/src/update/transactional-install.d.mts +0 -1
  206. package/src/update/transactional-install.mjs +17 -17
  207. package/src/usage/jev-stats.ts +495 -0
  208. package/src/usage/log.ts +24 -3
  209. package/src/web-search/run-turn-loop.ts +568 -0
  210. package/gui/dist/assets/App-8NMiZxT0.css +0 -1
  211. package/gui/dist/assets/App-DGLte6IR.js +0 -50
  212. package/gui/dist/assets/index--EWgGQvZ.css +0 -1
  213. package/gui/dist/assets/index-Bi1K37Wl.js +0 -86
@@ -6,16 +6,23 @@ import { windowsSecretAclApplies } from "../lib/windows-secret-acl";
6
6
  import type { OcxProviderContinuationState } from "../types";
7
7
  import {
8
8
  deleteResponseSpill,
9
+ inspectResponseSpillDir,
9
10
  noteStubSwapForTest,
10
11
  readResponseSpill,
11
12
  recoverOrphanedResponseSpills,
13
+ resetPeriodicSpillSweepCursorForTests,
12
14
  responseSpillDirectory,
13
15
  responseSpillPayloadCap,
16
+ sweepOrphanedResponseSpillsPeriodically,
17
+ type ResponseSpillDirInspection,
14
18
  type ResponseSpillRef,
15
19
  writeResponseSpillDurably,
16
20
  } from "./spill-store";
21
+ import { collectReferencedSpillFileNames, snapshotReferencedSpillFileNames } from "./state/spill-inspect";
22
+ import { selectSnapshotEntries } from "./state/snapshot-select";
17
23
  import { clientCarriedPrefixLength, providerIssuedIdentity } from "./state/replay-fingerprint";
18
24
  export type { ResponseStateTempRecoveryResult, ResponseStateTempRecoveryOptions } from "./state/temp-recovery";
25
+ export type { ResponseSpillDirInspection } from "./spill-store";
19
26
  export { recoverStaleResponseStateTemps, reclaimAbandonedResponseStateTemps, inspectAbandonedResponseStateTemps, sweepAbandonedResponseStateTemps } from "./state/temp-recovery";
20
27
  import { recoverStaleResponseStateTemps } from "./state/temp-recovery";
21
28
  export type { ResponseSpillWriteFailureCode, ResponseSpillWriteStatus, ResponseSpillWriteFailureOrigin } from "./state/spill-failure";
@@ -36,6 +43,7 @@ import {
36
43
  spillQueueAccounting,
37
44
  spillQueueHoldsResidentCandidate,
38
45
  spillQueuePendingBytes,
46
+ spillQueueReferencedSpillFileNames,
39
47
  spillQueueResidentCandidates,
40
48
  spillQueueSupersededSpillFor,
41
49
  } from "./state/spill-queue";
@@ -144,6 +152,7 @@ let residentResponseBytes = 0;
144
152
  let oldestResidentId: string | undefined;
145
153
  let oldestResidentAt: number | null = null;
146
154
  let byteCapOverride: number | null = null;
155
+ let snapshotTotalCapOverride: number | null = null;
147
156
  let stateRevision = 0;
148
157
  /** Byte length and digest of the last snapshot actually written, for the
149
158
  * identical-payload skip and the size-scaled debounce. The payload itself is not
@@ -215,6 +224,15 @@ export function setResponseStateByteCapForTests(bytes: number | null): void {
215
224
  byteCapOverride = bytes;
216
225
  }
217
226
 
227
+ function snapshotTotalBytes(): number {
228
+ return snapshotTotalCapOverride ?? SNAPSHOT_TOTAL_MAX_BYTES;
229
+ }
230
+
231
+ /** Test-only: lower/restore the durable snapshot byte budget (null restores the default). */
232
+ export function setResponseStateSnapshotByteCapForTests(bytes: number | null): void {
233
+ snapshotTotalCapOverride = bytes;
234
+ }
235
+
218
236
  /** Test-only: current in-memory byte accounting (proves evictions release their bytes). */
219
237
  export function getStoredResponseBytesForTests(): number {
220
238
  return storedResponseBytes;
@@ -655,14 +673,27 @@ function ensureLoaded(): void {
655
673
  } catch {
656
674
  /* missing/corrupt snapshot: start empty */
657
675
  }
658
- const referenced = new Set<string>();
659
- for (const state of states.values()) {
660
- if (state.kind === "spill") referenced.add(state.spill.fileName);
661
- }
662
- try { recoverOrphanedResponseSpills(referenced); } catch { /* best effort */ }
676
+ try { recoverOrphanedResponseSpills(ownedSpillFileNames()); } catch { /* best effort */ }
663
677
  pruneResponses();
664
678
  }
665
679
 
680
+ /** Every file name the process still needs: live stubs, deferred unlinks, and queued publications. */
681
+ function ownedSpillFileNames(): Set<string> {
682
+ const referenced = collectReferencedSpillFileNames(states.values(), pendingSpillUnlinks);
683
+ for (const name of spillQueueReferencedSpillFileNames()) referenced.add(name);
684
+ return referenced;
685
+ }
686
+
687
+ /** Liveness-tick counterpart of the orphan GC in ensureLoaded; a no-op until first load. */
688
+ export function sweepOrphanedResponseSpills(): number {
689
+ if (!loaded) return 0;
690
+ try {
691
+ return sweepOrphanedResponseSpillsPeriodically(ownedSpillFileNames()).removed;
692
+ } catch {
693
+ return 0;
694
+ }
695
+ }
696
+
666
697
  type SnapshotWriteOutcome = "stable" | "unstable" | "failed";
667
698
 
668
699
  async function writeBoundedSnapshot(path: string, attemptLimit: number): Promise<SnapshotWriteOutcome> {
@@ -674,28 +705,7 @@ async function writeBoundedSnapshot(path: string, attemptLimit: number): Promise
674
705
  try {
675
706
  for (let attempt = 0; attempt < attemptLimit; attempt += 1) {
676
707
  const revision = stateRevision;
677
- const entries: Array<[string, unknown]> = [];
678
- let total = 0;
679
- // Newest-first so the most recent chains survive both legacy snapshot caps.
680
- for (const [id, state] of [...states].reverse()) {
681
- let persistable: unknown;
682
- if (state.kind === "resident") {
683
- const { sizeBytes: _sizeBytes, kind: _kind, ...resident } = state;
684
- persistable = resident;
685
- } else {
686
- const { sizeBytes: _sizeBytes, ...smallState } = state;
687
- persistable = smallState;
688
- }
689
- const persistEntry: [string, unknown] = [id, persistable];
690
- // UTF-8 bytes, not UTF-16 code units: multibyte items otherwise slip
691
- // past both snapshot caps at up to 2x the intended size.
692
- const size = Buffer.byteLength(JSON.stringify(persistEntry), "utf8");
693
- if (state.kind === "resident" && size > SNAPSHOT_ENTRY_MAX_BYTES) continue;
694
- if (total + size > SNAPSHOT_TOTAL_MAX_BYTES) break;
695
- total += size;
696
- entries.push(persistEntry);
697
- }
698
- entries.reverse();
708
+ const entries = selectSnapshotEntries(states, snapshotTotalBytes(), SNAPSHOT_ENTRY_MAX_BYTES);
699
709
  const payload = JSON.stringify({ version: 2, states: entries });
700
710
  const payloadBytes = Buffer.byteLength(payload, "utf8");
701
711
  const payloadDigest = Bun.hash(payload).toString(36);
@@ -1226,6 +1236,19 @@ export function responseStateMetrics(): ResponseStateMetrics {
1226
1236
  };
1227
1237
  }
1228
1238
 
1239
+ /**
1240
+ * Read-only spill report for `ocx doctor` and /api/system/memory. Owned = live
1241
+ * stubs, deferred unlinks, queued publications, and snapshot references (what a
1242
+ * restart re-owns). Shares the reclaim's orphan predicate; never unlinks.
1243
+ */
1244
+ export function inspectResponseSpillStorage(): ResponseSpillDirInspection {
1245
+ const referenced = ownedSpillFileNames();
1246
+ for (const name of snapshotReferencedSpillFileNames(snapshotPath(), SNAPSHOT_FILE_MAX_BYTES)) {
1247
+ referenced.add(name);
1248
+ }
1249
+ return inspectResponseSpillDir(referenced);
1250
+ }
1251
+
1229
1252
  /**
1230
1253
  * Cache completed output and max_output_tokens partial output for previous_response_id replay.
1231
1254
  * Content-filtered incomplete and failed output are not authoritative replay history.
@@ -1307,6 +1330,7 @@ export function clearResponseStateMemoryForTests(): void {
1307
1330
  }
1308
1331
  pendingPersistPath = null;
1309
1332
  resetSpillQueueForTests();
1333
+ resetPeriodicSpillSweepCursorForTests();
1310
1334
  states.clear();
1311
1335
  storedResponseBytes = 0;
1312
1336
  residentResponseBytes = 0;
@@ -2,7 +2,7 @@ import { createHash, timingSafeEqual } from "node:crypto";
2
2
  import { formatErrorResponse } from "../bridge";
3
3
  import type { OcxConfig } from "../types";
4
4
  import { captureExplicitOpenAiCallerAuth, selectOpenAiImagesProvider } from "../providers/openai-sidecar";
5
- import { isProxyAdmissionSecret, type DataPlaneAdmission } from "./auth-cors";
5
+ import { isProxyAdmissionSecret, type DataPlaneAdmission, type DataPlaneAdmissionOptions } from "./auth-cors";
6
6
  import { resolveAudioAdmission } from "./audio-upstream";
7
7
 
8
8
  export const AUDIO_WEBSOCKET_PROTOCOL = "opencodex-audio";
@@ -16,7 +16,12 @@ export interface AudioClient {
16
16
  }
17
17
 
18
18
  /** Browser protocols are an audio-only credential carrier; only the public marker is echoed. */
19
- export function resolveAudioClient(req: Request, config: OcxConfig, required = false): AudioClient | Response | null {
19
+ export function resolveAudioClient(
20
+ req: Request,
21
+ config: OcxConfig,
22
+ required = false,
23
+ options: DataPlaneAdmissionOptions = {},
24
+ ): AudioClient | Response | null {
20
25
  const headers = new Headers(req.headers);
21
26
  let protocol: string | undefined;
22
27
  let carrier = false;
@@ -43,7 +48,7 @@ export function resolveAudioClient(req: Request, config: OcxConfig, required = f
43
48
  }
44
49
  }
45
50
  }
46
- const admission = resolveAudioAdmission(headers, config);
51
+ const admission = resolveAudioAdmission(headers, config, options);
47
52
  if (!admission) {
48
53
  const bearer = /^Bearer\s+([^\s,]+)$/i.exec(headers.get("authorization") ?? "")?.[1];
49
54
  const platformKey = selectOpenAiImagesProvider(config).keyed?.apiKey;
@@ -20,6 +20,7 @@ import {
20
20
  resolveDataPlaneAdmissionSecret,
21
21
  validateForwardAdmissionCredential,
22
22
  type DataPlaneAdmission,
23
+ type DataPlaneAdmissionOptions,
23
24
  } from "./auth-cors";
24
25
  import { codexAccountSelectionForTurn } from "./lifecycle";
25
26
  import type { RequestLogContext } from "./request-log";
@@ -28,16 +29,20 @@ export const TRANSCRIPTION_MODEL = "gpt-4o-transcribe";
28
29
  export const LIVE_AUDIO_MODEL = "gpt-live-1-codex";
29
30
 
30
31
  /** Audio keys remain identifiable even on the otherwise unauthenticated local listener. */
31
- export function resolveAudioAdmission(headers: Headers, config: OcxConfig): DataPlaneAdmission | null {
32
+ export function resolveAudioAdmission(
33
+ headers: Headers,
34
+ config: OcxConfig,
35
+ options: DataPlaneAdmissionOptions = {},
36
+ ): DataPlaneAdmission | null {
32
37
  const dedicated = headers.get("x-opencodex-api-key")?.trim();
33
- if (dedicated) return resolveDataPlaneAdmissionSecret(dedicated, config, "dedicated");
38
+ if (dedicated) return resolveDataPlaneAdmissionSecret(dedicated, config, "dedicated", options);
34
39
  const authorization = headers.get("authorization")?.trim();
35
40
  if (authorization) {
36
41
  const token = /^Bearer\s+([^\s,]+)$/i.exec(authorization)?.[1];
37
- return token ? resolveDataPlaneAdmissionSecret(token, config, "bearer") : null;
42
+ return token ? resolveDataPlaneAdmissionSecret(token, config, "bearer", options) : null;
38
43
  }
39
44
  const key = headers.get("x-api-key")?.trim();
40
- return key ? resolveDataPlaneAdmissionSecret(key, config, "x-api-key") : null;
45
+ return key ? resolveDataPlaneAdmissionSecret(key, config, "x-api-key", options) : null;
41
46
  }
42
47
 
43
48
  export interface AudioUpstream {
@@ -312,14 +312,29 @@ export function isApiAuthRequired(config: Pick<OcxConfig, "hostname">): boolean
312
312
  * So this type is deliberately narrow: it cannot masquerade as a business config, and a policy
313
313
  * view that leaks into a routing path fails to typecheck rather than silently taking effect.
314
314
  */
315
- export type RequestPolicyView = Pick<OcxConfig, "hostname" | "corsAllowOrigins" | "apiKeys">;
315
+ export interface LinkIngressPolicy {
316
+ allowedKeyIds: ReadonlySet<string>;
317
+ }
318
+
319
+ export type RequestPolicyView = Pick<OcxConfig, "hostname" | "corsAllowOrigins" | "apiKeys"> & {
320
+ linkIngress?: LinkIngressPolicy;
321
+ };
322
+
323
+ export type DataPlaneAdmissionOptions = {
324
+ linkIngress?: ReadonlySet<string>;
325
+ };
316
326
 
317
327
  /** Derive the per-request policy view for a listener. Cheap enough to build per request. */
318
- export function requestPolicyView(config: OcxConfig, bindHostname: string): RequestPolicyView {
328
+ export function requestPolicyView(
329
+ config: OcxConfig,
330
+ bindHostname: string,
331
+ linkIngress?: LinkIngressPolicy,
332
+ ): RequestPolicyView {
319
333
  return {
320
334
  hostname: bindHostname,
321
335
  ...(config.corsAllowOrigins ? { corsAllowOrigins: config.corsAllowOrigins } : {}),
322
336
  ...(config.apiKeys ? { apiKeys: config.apiKeys } : {}),
337
+ ...(linkIngress ? { linkIngress } : {}),
323
338
  };
324
339
  }
325
340
 
@@ -398,13 +413,15 @@ export function resolveDataPlaneAdmissionSecret(
398
413
  token: string,
399
414
  config: Pick<OcxConfig, "apiKeys">,
400
415
  source: DataPlaneAdmissionSource = "dedicated",
416
+ options: DataPlaneAdmissionOptions = {},
401
417
  ): DataPlaneAdmission | null {
402
418
  const actual = token.trim();
403
419
  if (!actual) return null;
404
- if (secretEquals(actual, configuredApiAuthToken(config))) {
420
+ if (!options.linkIngress && secretEquals(actual, configuredApiAuthToken(config))) {
405
421
  return { kind: "environment", source, contextPrincipalId: mintContextPrincipal("environment", "", actual) };
406
422
  }
407
423
  for (const k of config.apiKeys ?? []) {
424
+ if (options.linkIngress && !options.linkIngress.has(k.id)) continue;
408
425
  if (secretEquals(actual, k.key)) {
409
426
  return { kind: "configured", keyId: k.id, source, contextPrincipalId: mintContextPrincipal("configured", k.id, actual) };
410
427
  }
@@ -555,12 +572,12 @@ export function resolveApiAuth(req: Request, config: RequestPolicyView): DataPla
555
572
  // A loopback bind never reads a token at all, so there is no key to name.
556
573
  if (!isApiAuthRequired(config)) return { kind: "loopback", source: "loopback" };
557
574
  const dedicated = req.headers.get("x-opencodex-api-key")?.trim();
558
- if (dedicated) return resolveDataPlaneAdmissionSecret(dedicated, config, "dedicated");
575
+ if (dedicated) return resolveDataPlaneAdmissionSecret(dedicated, config, "dedicated", { linkIngress: config.linkIngress?.allowedKeyIds });
559
576
  const bearer = req.headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim();
560
- if (bearer) return resolveDataPlaneAdmissionSecret(bearer, config, "bearer");
577
+ if (bearer) return resolveDataPlaneAdmissionSecret(bearer, config, "bearer", { linkIngress: config.linkIngress?.allowedKeyIds });
561
578
  // Anthropic-SDK clients (Claude Code with ANTHROPIC_API_KEY) authenticate via x-api-key.
562
579
  const apiKey = req.headers.get("x-api-key")?.trim();
563
- if (apiKey) return resolveDataPlaneAdmissionSecret(apiKey, config, "x-api-key");
580
+ if (apiKey) return resolveDataPlaneAdmissionSecret(apiKey, config, "x-api-key", { linkIngress: config.linkIngress?.allowedKeyIds });
564
581
  return null;
565
582
  }
566
583
 
@@ -582,14 +599,14 @@ export function resolveResponsesApiAuth(req: Request, config: RequestPolicyView)
582
599
  if (!isApiAuthRequired(config)) return { kind: "loopback", source: "loopback" };
583
600
  // The dedicated header still WINS, because it is unambiguous.
584
601
  const dedicated = req.headers.get("x-opencodex-api-key")?.trim();
585
- if (dedicated) return resolveDataPlaneAdmissionSecret(dedicated, config, "dedicated");
602
+ if (dedicated) return resolveDataPlaneAdmissionSecret(dedicated, config, "dedicated", { linkIngress: config.linkIngress?.allowedKeyIds });
586
603
  // #1686: a bearer may also be one of OUR admission secrets. Rejecting it outright meant a
587
604
  // Codex client configured with `env_key` could not reach Direct at all. Admitting it is only
588
605
  // safe because the upstream credential is then SUBSTITUTED rather than forwarded -- see
589
606
  // materializeCodexUpstreamAuth. A bearer that is NOT our secret stays unadmitted here and
590
607
  // remains Codex Direct passthrough, so the two bearer domains still never mix.
591
608
  const bearer = req.headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim();
592
- if (bearer) return resolveDataPlaneAdmissionSecret(bearer, config, "bearer");
609
+ if (bearer) return resolveDataPlaneAdmissionSecret(bearer, config, "bearer", { linkIngress: config.linkIngress?.allowedKeyIds });
593
610
  // `x-api-key` is deliberately NOT accepted on this transport.
594
611
  return null;
595
612
  }
@@ -9,8 +9,8 @@ import { FORWARD_HEADERS } from "../adapters/openai-responses";
9
9
  import {
10
10
  assertChatCompletionsRoutingBody,
11
11
  ChatCompletionsRequestError,
12
- chatCompletionsToResponsesBody,
13
12
  } from "../chat/inbound";
13
+ import { chatToResponsesBody } from "../protocols/codecs/chat";
14
14
  import { normalizeChatImageParts } from "../chat/image-parts";
15
15
  import {
16
16
  chatCompletionsErrorResponse,
@@ -47,8 +47,10 @@ import {
47
47
  httpStatusForRequestLogTerminal,
48
48
  recordFirstOutput,
49
49
  type RequestLogContext,
50
- type RequestLogEntry,
51
50
  } from "./request-log";
51
+ import { createFinalRequestLog } from "./inference/final-log";
52
+ import { clientWireLogOf, clientWireOf } from "./inference/client-wire";
53
+ import { directEncodersApply } from "./inference/client-encoder-delivery";
52
54
  import { responseWithDeferredRequestLog } from "./relay";
53
55
  import { handleResponses } from "./responses";
54
56
  import { providerConsumesCallerAuthorization } from "../providers/caller-authorization";
@@ -69,7 +71,15 @@ import {
69
71
  isTranslatorBudgetExceededError,
70
72
  type TranslatorBudget,
71
73
  } from "../lib/translator-budget";
72
- import { handleNativeChatCompletions, isNativeChatRouteEligible } from "./chat-native";
74
+ import { createNativeChatComboSource, handleNativeChatCompletions, nativeChatDeclineReason } from "./chat-native";
75
+ import { upstreamWireForAdapter, type ProtocolReasonCode } from "../protocols/contract";
76
+ import { createProtocolEnvelope } from "../protocols/envelope";
77
+ import { featuresFromChatBody } from "../protocols/features";
78
+ import { checkRepresentable, unrepresentableMessage } from "../protocols/guard";
79
+ import { requestPathForLane } from "../protocols/path";
80
+ import { resolveProtocolSettings } from "../protocols/settings";
81
+ import { markProtocolBlocked, markProtocolEntry } from "../protocols/trace";
82
+ import { recordProtocolShadowPlan } from "../protocols/shadow-plan";
73
83
  import { jsonCompletionSse } from "./chat-native-sse";
74
84
  import { parseRequestEffortRowId } from "./effort-row";
75
85
  import { parseSyntheticRowId } from "./fast-row";
@@ -163,6 +173,8 @@ async function handleChatCompletionsWithBudget(
163
173
  let routeMayChangeCredentialDomain = false;
164
174
  let settledRoute: ReturnType<typeof routeModel> | null = null;
165
175
  let chatNativeRoute: ReturnType<typeof routeModel> | null = null;
176
+ // Why the native Chat lane was not taken; stays `unknown-model` when routing threw.
177
+ let nativeDecline: ProtocolReasonCode | undefined = "unknown-model";
166
178
  try {
167
179
  const route = routeModel(config, chatBody.model as string, evidenceFromBody(chatBody));
168
180
  // The native Chat lane sends without re-entering the Responses path, so it
@@ -199,7 +211,10 @@ async function handleChatCompletionsWithBudget(
199
211
  }
200
212
  // Combos must enter the Responses routing path so child selection, forced default
201
213
  // effort, failover, and per-attempt telemetry run before any native Chat send.
202
- if (!route.combo && !effortRow && isNativeChatRouteEligible(route, chatBody, config)) {
214
+ nativeDecline = route.combo ? "combo-or-policy-route"
215
+ : effortRow ? "effort-row"
216
+ : nativeChatDeclineReason(route, chatBody, config);
217
+ if (nativeDecline === undefined) {
203
218
  chatNativeRoute = route;
204
219
  // Reserve an input estimate for spend without recording it as usage: native Chat attempts
205
220
  // keep the provider-reported counts, as they did before the reservation existed.
@@ -232,6 +247,39 @@ async function handleChatCompletionsWithBudget(
232
247
  /* unknown model: let handleResponses shape the 404 */
233
248
  }
234
249
 
250
+ // Off by default: under the legacy policy with `nativeChatCombos` off nothing below is built
251
+ // and the request is unchanged. An effort row keeps its effort on the Responses body only, so
252
+ // its combo stays on the bridge.
253
+ const protocolSettings = resolveProtocolSettings(config);
254
+ const nativeChatCombos = protocolSettings.rollout.nativeChatCombos
255
+ && settledRoute?.combo !== undefined && !effortRow;
256
+ const envelope = protocolSettings.unrepresentable === "reject" || nativeChatCombos
257
+ ? createProtocolEnvelope({ inbound: "chat", body: chatBody, translatorBudget })
258
+ : undefined;
259
+ // Combo and policy children are judged per candidate (PF-07); an unknown model has no route.
260
+ if (envelope && settledRoute && !settledRoute.combo && settledRoute.routeKind !== "policy") {
261
+ const verdict = checkRepresentable({
262
+ inbound: "chat",
263
+ requestPath: chatNativeRoute
264
+ ? requestPathForLane("chat", "native", "chat")
265
+ : requestPathForLane("chat", "bridge", upstreamWireForAdapter(settledRoute.provider.adapter)),
266
+ features: envelope.features(),
267
+ policy: "reject",
268
+ });
269
+ if (!verdict.ok) {
270
+ markProtocolBlocked(logCtx, { inbound: "chat", reasonCodes: verdict.reasonCodes, features: verdict.features });
271
+ logCtx.errorCode = "unsupported_feature";
272
+ if (logIds) addFinalRequestLog(logIds.requestId, logIds.start, logCtx, 400, { closeReason: "non_stream" });
273
+ return chatCompletionsErrorResponse(400, unrepresentableMessage(verdict.features), "invalid_request_error", "unsupported_feature");
274
+ }
275
+ }
276
+ markProtocolEntry(logCtx, {
277
+ inbound: "chat",
278
+ lane: chatNativeRoute ? "native" : "bridge",
279
+ reasonCodes: !chatNativeRoute && nativeDecline ? [nativeDecline] : [],
280
+ features: envelope ? () => envelope.features() : () => featuresFromChatBody(chatBody),
281
+ });
282
+ recordProtocolShadowPlan(logCtx, config, { inbound: "chat", model: requestedModel });
235
283
  if (chatNativeRoute) {
236
284
  return handleNativeChatCompletions({
237
285
  req,
@@ -250,7 +298,7 @@ async function handleChatCompletionsWithBudget(
250
298
  try {
251
299
  // Validate the full Chat boundary after routing. Native Chat keeps `chatBody` as
252
300
  // its wire source; this Responses projection is used only by the fallback path.
253
- internalBody = chatCompletionsToResponsesBody(chatBody);
301
+ internalBody = chatToResponsesBody(chatBody);
254
302
  if (effortRow) {
255
303
  internalBody.reasoning = {
256
304
  ...(isRec(internalBody.reasoning) ? internalBody.reasoning : {}),
@@ -373,12 +421,7 @@ async function handleChatCompletionsWithBudget(
373
421
  });
374
422
  linkRequestSessionLane(req, internalReq);
375
423
 
376
- let nativeLogged = false;
377
- const finalizeNativeLog = (status: number, meta: { terminalStatus?: RequestLogEntry["terminalStatus"]; closeReason: "terminal" | "client_cancel" | "non_stream" }) => {
378
- if (!logIds || nativeLogged) return;
379
- nativeLogged = true;
380
- addFinalRequestLog(logIds.requestId, logIds.start, logCtx, status, meta);
381
- };
424
+ const finalizeNativeLog = createFinalRequestLog(logIds, logCtx).finish;
382
425
  const upstream = await handleResponses(internalReq, config, logCtx, {
383
426
  openAiSidecarAuth,
384
427
  allowStoredOpenAiSidecarAuth: !!(callerAuthorizationRoute && settledRoute
@@ -392,6 +435,12 @@ async function handleChatCompletionsWithBudget(
392
435
  abortSignal: req.signal,
393
436
  // Body is Responses-shaped by now, but the client spoke Chat Completions.
394
437
  inboundWire: "chat",
438
+ // PF-07: the combo sends eligible candidates natively from this envelope.
439
+ ...(envelope && nativeChatCombos ? {
440
+ protocolSource: createNativeChatComboSource({
441
+ req, config, envelope, requestedModel, requestedStream: stream, translatorBudget,
442
+ }),
443
+ } : {}),
395
444
  // Terminal vision-describe marker (roadmap 180): the bridge rebuilds
396
445
  // headers from the FORWARD_HEADERS allowlist, which would drop the raw
397
446
  // header — so the fact is detected here and carried as an option flag.
@@ -400,7 +449,18 @@ async function handleChatCompletionsWithBudget(
400
449
  ...(logIds ? { onFirstOutput: () => recordFirstOutput(logCtx, logIds.start) } : {}),
401
450
  onNativePassthroughTerminal: status => finalizeNativeLog(httpStatusForRequestLogTerminal(status, logCtx), { terminalStatus: status, closeReason: "terminal" }),
402
451
  onNativePassthroughCancel: () => finalizeNativeLog(499, { closeReason: "client_cancel" }),
452
+ ...(directEncodersApply(config, settledRoute)
453
+ ? { clientEncoder: { protocol: "chat" as const, stream, model: requestedModel } }
454
+ : {}),
403
455
  });
456
+ // Already in the Chat wire: no conversion. A direct-encoder body (PF-09) reports its own log
457
+ // facts to the deferred request log. A native combo child (PF-07) carries none: its row is
458
+ // written by the terminal callbacks above, and the deferred log's Responses-shaped inspector
459
+ // would misread a Chat stream, so of those only a refusal is wrapped.
460
+ if (clientWireOf(upstream) === "chat") {
461
+ if (!logIds || (upstream.ok && !clientWireLogOf(upstream))) return upstream;
462
+ return responseWithDeferredRequestLog(upstream, logIds.requestId, logIds.start, logCtx);
463
+ }
404
464
 
405
465
  // Rewrite non-2xx before deferred logging so /api/logs records the client-facing status
406
466
  // (e.g. cyber_policy remapped from a passthrough 5xx to HTTP 400).
@@ -0,0 +1,74 @@
1
+ import { chatBodyCarriesImage, chatBodyCarriesToolResultImage } from "../chat/image-parts";
2
+ import type { ProtocolReasonCode } from "../protocols/contract";
3
+ import type { RouteResult } from "../router";
4
+ import type { OcxConfig } from "../types";
5
+ import { isModelTextOnly, requiresVisionPreprocessing } from "../vision";
6
+
7
+ type Rec = Record<string, unknown>;
8
+
9
+ function isRec(value: unknown): value is Rec {
10
+ return value !== null && typeof value === "object" && !Array.isArray(value);
11
+ }
12
+
13
+ /** Why the native Chat lane declines a route, as a protocol reason code. */
14
+ export type NativeChatDeclineReason = Extract<
15
+ ProtocolReasonCode,
16
+ | "cross-wire-ir"
17
+ | "auth-mode-not-native"
18
+ | "combo-or-policy-route"
19
+ | "responses-only-feature"
20
+ | "tool-result-image"
21
+ | "vision-preprocessing"
22
+ | "hosted-tool"
23
+ >;
24
+
25
+ /**
26
+ * The first rule that keeps a Chat request off the native Chat lane, or `undefined` when the
27
+ * route is eligible. The order is the order the checks always ran in, so the reason reported is
28
+ * the one that actually decided.
29
+ */
30
+ export function nativeChatDeclineReason(
31
+ route: RouteResult,
32
+ rawBody: Rec,
33
+ config?: OcxConfig,
34
+ ): NativeChatDeclineReason | undefined {
35
+ const provider = route.provider;
36
+ if (provider.adapter !== "openai-chat") return "cross-wire-ir";
37
+ if (provider.authMode !== undefined && provider.authMode !== "key" && provider.authMode !== "local") return "auth-mode-not-native";
38
+ // Combo and policy execution own multi-candidate retries in the Responses pipeline.
39
+ if (route.combo || route.routeKind === "combo" || route.routeKind === "policy") return "combo-or-policy-route";
40
+ if (rawBody.store === true || rawBody.background === true) return "responses-only-feature";
41
+ if (typeof rawBody.previous_response_id === "string" && rawBody.previous_response_id.length > 0) return "responses-only-feature";
42
+ if (rawBody.compaction_trigger !== undefined) return "responses-only-feature";
43
+ // A standard Chat tool message accepts a string or text parts, not image_url, so
44
+ // normalizing a Pi/Anthropic tool image into image_url is not enough on its own —
45
+ // the part is still inside a tool message. The translated adapter already places
46
+ // tool-result images in a following user carrier after the complete paired batch
47
+ // (flushToolResultImages), so divert these requests there. Ordinary user images and
48
+ // text-only tool results keep the native fast path.
49
+ if (chatBodyCarriesToolResultImage(rawBody)) return "tool-result-image";
50
+ // Vision sidecar coverage (roadmap 180): a text-only routed model with an
51
+ // image-bearing body must go through the Responses pipeline, whose plan
52
+ // site describes or strips the image. The native fast path has no vision
53
+ // handling, so letting it keep such a request forwards raw pixels to a
54
+ // model the operator declared blind.
55
+ if (chatBodyCarriesImage(rawBody)) {
56
+ const needsVision = config
57
+ ? requiresVisionPreprocessing(config, provider, route.modelId, route.providerName)
58
+ : isModelTextOnly(provider, route.modelId);
59
+ if (needsVision) return "vision-preprocessing";
60
+ }
61
+ if (Array.isArray(rawBody.tools)) {
62
+ for (const tool of rawBody.tools) {
63
+ if (!isRec(tool)) continue;
64
+ if (tool.type === "web_search" || tool.type === "web_search_preview" || tool.type === "image_generation") {
65
+ return "hosted-tool";
66
+ }
67
+ }
68
+ }
69
+ return undefined;
70
+ }
71
+
72
+ export function isNativeChatRouteEligible(route: RouteResult, rawBody: Rec, config?: OcxConfig): boolean {
73
+ return nativeChatDeclineReason(route, rawBody, config) === undefined;
74
+ }