@bitkyc08/opencodex 2.8.0 → 2.9.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 +24 -0
  2. package/bin/ocx.mjs +32 -4
  3. package/gui/dist/assets/index-CHwf3tTD.css +1 -0
  4. package/gui/dist/assets/index-u5eFOv2y.js +67 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +1 -1
  7. package/src/adapters/anthropic-image-normalize.ts +114 -20
  8. package/src/adapters/anthropic.ts +126 -10
  9. package/src/adapters/azure.ts +3 -3
  10. package/src/adapters/base.ts +7 -3
  11. package/src/adapters/cursor/discovery.ts +14 -4
  12. package/src/adapters/cursor/effort-map.ts +18 -6
  13. package/src/adapters/cursor/framing.ts +102 -27
  14. package/src/adapters/cursor/kv-store.ts +30 -3
  15. package/src/adapters/cursor/live-models.ts +22 -2
  16. package/src/adapters/cursor/live-transport.ts +245 -49
  17. package/src/adapters/cursor/mcp-manager.ts +105 -8
  18. package/src/adapters/cursor/native-exec-mcp.ts +5 -3
  19. package/src/adapters/cursor/native-exec-shell.ts +296 -14
  20. package/src/adapters/cursor/native-exec.ts +381 -33
  21. package/src/adapters/cursor/protobuf-events.ts +28 -1
  22. package/src/adapters/cursor/protobuf-request.ts +71 -39
  23. package/src/adapters/cursor/request-builder.ts +2 -2
  24. package/src/adapters/cursor/transport.ts +2 -0
  25. package/src/adapters/cursor.ts +13 -2
  26. package/src/adapters/google-antigravity-replay.ts +184 -17
  27. package/src/adapters/google.ts +58 -8
  28. package/src/adapters/kiro-thinking.ts +23 -9
  29. package/src/adapters/kiro-tools.ts +49 -18
  30. package/src/adapters/kiro.ts +377 -133
  31. package/src/adapters/mimo-free.ts +36 -4
  32. package/src/adapters/openai-chat.ts +143 -17
  33. package/src/adapters/openai-responses.ts +130 -14
  34. package/src/adapters/run-turn-queue.ts +7 -1
  35. package/src/bridge.ts +466 -69
  36. package/src/chat/outbound.ts +144 -38
  37. package/src/claude/inbound-debug.ts +53 -8
  38. package/src/claude/outbound.ts +224 -38
  39. package/src/cli/agent-driven.ts +34 -1
  40. package/src/cli/catalog-prewarm.ts +5 -2
  41. package/src/cli/claude-desktop.ts +2 -2
  42. package/src/cli/doctor.ts +12 -0
  43. package/src/cli/export-command.ts +187 -0
  44. package/src/cli/help.ts +11 -0
  45. package/src/cli/index.ts +13 -3
  46. package/src/cli/init.ts +129 -102
  47. package/src/cli/opencode.ts +36 -151
  48. package/src/cli/star-prompt.ts +13 -4
  49. package/src/cli/status-oauth.ts +12 -2
  50. package/src/clients/config-export.ts +377 -0
  51. package/src/codex/account-runtime-state.ts +19 -1
  52. package/src/codex/account-store.ts +162 -82
  53. package/src/codex/auth-api.ts +467 -159
  54. package/src/codex/auth-context.ts +15 -2
  55. package/src/codex/catalog/aggregation.ts +15 -0
  56. package/src/codex/catalog/effort.ts +16 -6
  57. package/src/codex/catalog/metadata.ts +6 -0
  58. package/src/codex/catalog/parsing.ts +3 -1
  59. package/src/codex/catalog/provider-fetch.ts +29 -0
  60. package/src/codex/catalog/sync.ts +64 -7
  61. package/src/codex/catalog.ts +2 -2
  62. package/src/codex/inject.ts +5 -5
  63. package/src/codex/main-account-cache.ts +8 -1
  64. package/src/codex/model-cache.ts +81 -2
  65. package/src/codex/pool-rotation.ts +39 -0
  66. package/src/codex/project-config-warnings.ts +12 -1
  67. package/src/codex/quota.ts +35 -3
  68. package/src/codex/routing.ts +46 -1
  69. package/src/codex/shim.ts +10 -4
  70. package/src/codex/subagent-model-fallback.ts +12 -0
  71. package/src/codex/websocket-registry.ts +27 -0
  72. package/src/combos/failover.ts +31 -1
  73. package/src/combos/request.ts +9 -0
  74. package/src/combos/resolve.ts +60 -4
  75. package/src/combos/types.ts +12 -0
  76. package/src/config.ts +510 -55
  77. package/src/github/star-state.ts +13 -1
  78. package/src/images/fulfill.ts +39 -1
  79. package/src/images/loop.ts +52 -12
  80. package/src/lib/admission.ts +83 -0
  81. package/src/lib/app-owned-memory-stores.ts +173 -0
  82. package/src/lib/app-owned-memory.ts +265 -0
  83. package/src/lib/bun-stream-caps.ts +31 -7
  84. package/src/lib/config-ownership.ts +33 -0
  85. package/src/lib/crash-guard.ts +65 -5
  86. package/src/lib/debug-log-buffer.ts +47 -6
  87. package/src/lib/destination-policy.ts +12 -1
  88. package/src/lib/errors.ts +3 -0
  89. package/src/lib/gcp-adc.ts +40 -2
  90. package/src/lib/injection-debug-log.ts +26 -2
  91. package/src/lib/provider-outbound.ts +3 -0
  92. package/src/lib/sidecar-tracker.ts +5 -2
  93. package/src/lib/sse-decoder.ts +257 -37
  94. package/src/lib/state-store-registrations.ts +109 -0
  95. package/src/lib/state-store-sweeper.ts +184 -0
  96. package/src/lib/translator-budget.ts +356 -0
  97. package/src/lib/windows-secret-acl.ts +33 -12
  98. package/src/lib/winsw.ts +14 -1
  99. package/src/oauth/anthropic-routing.ts +31 -7
  100. package/src/oauth/google-antigravity.ts +2 -1
  101. package/src/oauth/health.ts +30 -12
  102. package/src/oauth/index.ts +127 -23
  103. package/src/oauth/kiro-credentials.ts +72 -1
  104. package/src/oauth/kiro.ts +23 -4
  105. package/src/oauth/store.ts +165 -18
  106. package/src/oauth/token-guardian.ts +43 -4
  107. package/src/oauth/types.ts +2 -1
  108. package/src/providers/base-url-choices.ts +10 -0
  109. package/src/providers/derive.ts +12 -0
  110. package/src/providers/free-directory.ts +4 -1
  111. package/src/providers/key-failover.ts +12 -0
  112. package/src/providers/openai-sidecar.ts +4 -1
  113. package/src/providers/quota.ts +68 -7
  114. package/src/providers/registry.ts +279 -3
  115. package/src/responses/parser.ts +5 -1
  116. package/src/responses/spill-store.ts +394 -0
  117. package/src/responses/state.ts +520 -102
  118. package/src/router.ts +18 -1
  119. package/src/server/adapter-resolve.ts +20 -3
  120. package/src/server/auth-cors.ts +121 -28
  121. package/src/server/chat-completions.ts +57 -12
  122. package/src/server/claude-messages.ts +85 -13
  123. package/src/server/index.ts +242 -100
  124. package/src/server/lifecycle.ts +155 -25
  125. package/src/server/management/agent-settings-routes.ts +79 -36
  126. package/src/server/management/api-key-usage.ts +167 -0
  127. package/src/server/management/body.ts +35 -0
  128. package/src/server/management/combo-routes.ts +5 -1
  129. package/src/server/management/config-routes.ts +42 -12
  130. package/src/server/management/logs-usage-routes.ts +41 -21
  131. package/src/server/management/model-routes.ts +188 -54
  132. package/src/server/management/oauth-account-routes.ts +115 -26
  133. package/src/server/management/provider-routes.ts +56 -6
  134. package/src/server/management/shared.ts +16 -3
  135. package/src/server/management/sidebar-routes.ts +50 -1
  136. package/src/server/management/system-restart.ts +13 -6
  137. package/src/server/management/system-routes.ts +15 -3
  138. package/src/server/management/usage-summary-cache.ts +86 -0
  139. package/src/server/management-api.ts +39 -5
  140. package/src/server/management-auth.ts +65 -14
  141. package/src/server/port-reclaim.ts +58 -12
  142. package/src/server/ports.ts +2 -0
  143. package/src/server/proxy-liveness.ts +60 -14
  144. package/src/server/relay-eager.ts +20 -4
  145. package/src/server/relay.ts +548 -154
  146. package/src/server/request-decompress.ts +51 -4
  147. package/src/server/request-log.ts +134 -15
  148. package/src/server/responses/collaboration.ts +15 -4
  149. package/src/server/responses/compact.ts +3 -0
  150. package/src/server/responses/core.ts +241 -66
  151. package/src/server/responses-image-gen-repair.ts +19 -5
  152. package/src/server/responses-item-id-repair.ts +23 -5
  153. package/src/server/sse-payload-rewrite.ts +71 -12
  154. package/src/server/startup-health-cache.ts +14 -1
  155. package/src/server/system-env.ts +8 -1
  156. package/src/server/windows-tcp-drop.ts +15 -5
  157. package/src/server/ws-bridge.ts +25 -0
  158. package/src/service.ts +179 -13
  159. package/src/storage/policy-job.ts +93 -23
  160. package/src/storage/policy-worker.ts +6 -0
  161. package/src/storage/restore-job.ts +62 -16
  162. package/src/storage/restore-worker.ts +6 -0
  163. package/src/storage/storage-mutation-coordinator.ts +36 -6
  164. package/src/storage/worker-lifecycle.ts +181 -47
  165. package/src/tray/windows.ts +97 -25
  166. package/src/types.ts +39 -6
  167. package/src/update/index.ts +24 -5
  168. package/src/update/job.ts +598 -73
  169. package/src/usage/log.ts +115 -17
  170. package/src/usage/summary.ts +67 -2
  171. package/src/vision/index.ts +112 -22
  172. package/src/web-search/loop.ts +38 -6
  173. package/src/web-search/progress-stream.ts +14 -3
  174. package/gui/dist/assets/index-BDjpkcRN.js +0 -67
  175. package/gui/dist/assets/index-BHsKRFh9.css +0 -1
package/src/router.ts CHANGED
@@ -152,6 +152,15 @@ function configuredOriginForLog(url: string): string {
152
152
  // `routedProviderConfig` runs per request, so warn once per (provider, discarded, effective) triple.
153
153
  // Keyed by the URLs too: editing config.json to a different wrong value warns again.
154
154
  const discardedBaseUrlWarnings = new Set<string>();
155
+ let lastWarningReconciledGeneration = 0;
156
+
157
+ export function reconcileRouterWarningMemos(generation: number): number {
158
+ if (generation <= lastWarningReconciledGeneration) return 0;
159
+ const removed = discardedBaseUrlWarnings.size;
160
+ discardedBaseUrlWarnings.clear();
161
+ lastWarningReconciledGeneration = generation;
162
+ return removed;
163
+ }
155
164
 
156
165
  /**
157
166
  * A pinned registry entry — non-template `baseUrl`, no `allowBaseUrlOverride` — outranks a saved
@@ -247,6 +256,9 @@ function routedProviderConfig(providerName: string, provider: OcxProviderConfig)
247
256
  ...provider,
248
257
  adapter: registryEntry.adapter,
249
258
  baseUrl,
259
+ ...(provider.responsesPath === undefined && registryEntry.responsesPath !== undefined
260
+ ? { responsesPath: registryEntry.responsesPath }
261
+ : {}),
250
262
  authMode: canonicalAuthMode,
251
263
  apiKey: resolvedApiKey,
252
264
  // Backfill the Google wire mode + Vertex project/location from the registry when the user
@@ -303,8 +315,13 @@ export class NoEnabledOpenAiProviderError extends Error {
303
315
  }
304
316
  }
305
317
 
318
+ // Codex uses a small number of control-plane model ids that are not part of the public GPT/o
319
+ // naming families. Keep this exact: a broad `codex-*` rule could capture a third-party model.
320
+ const CODEX_INTERNAL_OPENAI_MODELS = new Set(["codex-auto-review"]);
321
+
306
322
  function isBareOpenAiFamilyModel(modelId: string): boolean {
307
- return !modelId.includes("/") && /^(?:gpt-|o1-|o3-|o4-)/.test(modelId);
323
+ return !modelId.includes("/")
324
+ && (/^(?:gpt-|o1-|o3-|o4-)/.test(modelId) || CODEX_INTERNAL_OPENAI_MODELS.has(modelId));
308
325
  }
309
326
 
310
327
  function routeResult(providerName: string, provider: OcxProviderConfig, modelId: string): RouteResult {
@@ -9,22 +9,39 @@ import { createResponsesPassthroughAdapter } from "../adapters/openai-responses"
9
9
  import type { OcxProviderConfig } from "../types";
10
10
  import { isWirePinnedModel, MODEL_ADAPTER_OVERRIDE_ALLOWED, pinnedWireAdapter } from "../types";
11
11
  import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers";
12
+ import { type InboundWire, providerModelWireDefault } from "../providers/registry";
12
13
 
13
14
  /**
14
15
  * Resolve the wire a single model should use: a hard pin first, then a configured
15
- * per-model override, then the provider's own adapter.
16
+ * per-model override, then a registry default for a mixed-wire provider, then the provider's
17
+ * own adapter.
16
18
  *
17
19
  * Safe to call more than once on its own output — the pin check does not look at the
18
20
  * current adapter, so a second pass cannot let an override displace a pin.
21
+ *
22
+ * `inbound` is the protocol the ORIGINAL client spoke. It defaults to `"responses"`
23
+ * because the Chat and Anthropic surfaces translate into a Responses-shaped body and
24
+ * replay through `handleResponses`; those two callers pass their real inbound so a
25
+ * scoped registry default cannot fire for a client that never asked for that wire.
19
26
  */
20
- export function resolveWireProtocolOverride(providerName: string, modelId: string, providerConfig: OcxProviderConfig): OcxProviderConfig {
27
+ export function resolveWireProtocolOverride(
28
+ providerName: string,
29
+ modelId: string,
30
+ providerConfig: OcxProviderConfig,
31
+ inbound: InboundWire = "responses",
32
+ ): OcxProviderConfig {
21
33
  const pinned = pinnedWireAdapter(providerName, modelId);
22
34
  if (pinned && providerConfig.adapter !== pinned) {
23
35
  return { ...providerConfig, adapter: pinned };
24
36
  }
25
37
  // Re-check the allow-list here, not just in the config validator: the file may have
26
38
  // been hand-edited, or written by a build that allowed more values.
27
- const requested = providerConfig.modelAdapters?.[modelId];
39
+ const configured = providerConfig.modelAdapters?.[modelId];
40
+ // An explicit allowed override wins, including one naming the provider-wide adapter (the
41
+ // opt-out from a registry default). Invalid hand-edited values fall through to the default.
42
+ const requested = configured && MODEL_ADAPTER_OVERRIDE_ALLOWED.has(configured)
43
+ ? configured
44
+ : providerModelWireDefault(providerName, providerConfig, modelId, MODEL_ADAPTER_OVERRIDE_ALLOWED, inbound);
28
45
  if (requested
29
46
  && MODEL_ADAPTER_OVERRIDE_ALLOWED.has(requested)
30
47
  && requested !== providerConfig.adapter
@@ -12,7 +12,7 @@ import {
12
12
  reasoningSummaryDeliveryRecordConfigError,
13
13
  } from "../config";
14
14
  import { providerDestinationConfigError } from "../lib/destination-policy";
15
- import { getProviderRegistryEntry, providerCodexAccountMode, providerMatchesRegistryTransport } from "../providers/registry";
15
+ import { getProviderRegistryEntry, providerCodexAccountMode, providerMatchesRegistryTransport, registryEntryForProviderDestination } from "../providers/registry";
16
16
  import { providerConfigSeed } from "../providers/derive";
17
17
  import type { OcxConfig, OcxProviderConfig } from "../types";
18
18
  import { openRouterRoutingConfigError } from "../providers/openrouter-routing";
@@ -70,16 +70,6 @@ export function isSameOriginAsRequest(req: Request, origin: string): boolean {
70
70
  }
71
71
 
72
72
  export function isAllowedRequestOrigin(req: Request, config: OcxConfig): boolean {
73
- function isExtraAllowedOrigin(origin: string, cfg: OcxConfig): boolean {
74
- if (!cfg.corsAllowOrigins?.length) return false;
75
- return cfg.corsAllowOrigins.some(allowed => {
76
- try {
77
- return new URL(allowed).origin === new URL(origin).origin;
78
- } catch {
79
- return allowed === origin;
80
- }
81
- });
82
- }
83
73
  const origin = req.headers.get("Origin");
84
74
  if (!isApiAuthRequired(config)) {
85
75
  if (!isLoopbackRequestHost(req.headers.get("Host"))) return false;
@@ -88,6 +78,17 @@ export function isAllowedRequestOrigin(req: Request, config: OcxConfig): boolean
88
78
  return !origin || isLoopbackOriginValue(origin) || isSameOriginAsRequest(req, origin) || isExtraAllowedOrigin(origin, config);
89
79
  }
90
80
 
81
+ function isExtraAllowedOrigin(origin: string, cfg: OcxConfig): boolean {
82
+ if (!cfg.corsAllowOrigins?.length) return false;
83
+ return cfg.corsAllowOrigins.some(allowed => {
84
+ try {
85
+ return new URL(allowed).origin === new URL(origin).origin;
86
+ } catch {
87
+ return allowed === origin;
88
+ }
89
+ });
90
+ }
91
+
91
92
  export function managementRequestOrigin(req: Request, config: OcxConfig): string | null {
92
93
  const host = req.headers.get("Host");
93
94
  const parsedHost = parseHttpHost(host);
@@ -106,7 +107,9 @@ export function isAllowedManagementOrigin(req: Request, config: OcxConfig): bool
106
107
  const requestOrigin = managementRequestOrigin(req, config);
107
108
  if (!requestOrigin) return false;
108
109
  const origin = req.headers.get("Origin");
109
- return !origin || origin === requestOrigin;
110
+ // Exact match against the process-derived origin, or an operator-listed corsAllowOrigins
111
+ // entry (covers TLS-terminator https://… when the process observes http://…).
112
+ return !origin || origin === requestOrigin || isExtraAllowedOrigin(origin, config);
110
113
  }
111
114
 
112
115
  export function browserSecurityHeaders(): Record<string, string> {
@@ -210,17 +213,87 @@ function secretEquals(actual: string, expected: string | undefined): boolean {
210
213
  return expectedBytes.length === actualBytes.length && timingSafeEqual(actualBytes, expectedBytes);
211
214
  }
212
215
 
213
- /** Whether `token` is a data-plane admission secret. */
214
- export function isDataPlaneAdmissionSecret(token: string, config: OcxConfig): boolean {
216
+ /**
217
+ * Which admission a data-plane request used.
218
+ *
219
+ * `configured` carries the matched key's id so a request can be attributed to
220
+ * the key that opened it. The other two exist so an unattributed request is a
221
+ * stated fact rather than a missing field: neither has a configured entry to
222
+ * point at, and a sentinel string in the id would collide with a hand-edited
223
+ * entry that happens to be named `loopback`.
224
+ */
225
+ export type DataPlaneAdmission =
226
+ | { kind: "configured"; keyId: string }
227
+ | { kind: "environment" }
228
+ | { kind: "loopback" };
229
+
230
+ /**
231
+ * Which admission secret `token` is, or null when it is none of them.
232
+ *
233
+ * Identical comparisons in an identical order to the boolean form this replaces —
234
+ * `secretEquals` still length-guards before `timingSafeEqual`. The only
235
+ * difference is that the matched entry's id survives the loop instead of being
236
+ * discarded, which is what makes per-key attribution possible without touching
237
+ * the admission decision itself.
238
+ */
239
+ export function resolveDataPlaneAdmissionSecret(token: string, config: OcxConfig): DataPlaneAdmission | null {
215
240
  const actual = token.trim();
216
- if (!actual) return false;
217
- if (secretEquals(actual, configuredApiAuthToken(config))) return true;
241
+ if (!actual) return null;
242
+ if (secretEquals(actual, configuredApiAuthToken(config))) return { kind: "environment" };
218
243
  for (const k of config.apiKeys ?? []) {
219
- if (secretEquals(actual, k.key)) return true;
244
+ if (secretEquals(actual, k.key)) return { kind: "configured", keyId: k.id };
220
245
  }
221
- return false;
246
+ return null;
247
+ }
248
+
249
+ /** Whether `token` is a data-plane admission secret. */
250
+ export function isDataPlaneAdmissionSecret(token: string, config: OcxConfig): boolean {
251
+ return resolveDataPlaneAdmissionSecret(token, config) !== null;
222
252
  }
223
253
 
254
+ /**
255
+ * Split an admission into the fields a log row records.
256
+ *
257
+ * `apiKeyId` is set only for a configured key. The other two kinds have no
258
+ * configured entry to name, and folding them into the id as sentinel strings
259
+ * would collide with a hand-edited entry that happens to be called `loopback` —
260
+ * ids are only validated as non-empty strings.
261
+ */
262
+ export function admissionFields(admission: DataPlaneAdmission): {
263
+ admissionKind: DataPlaneAdmission["kind"];
264
+ apiKeyId?: string;
265
+ } {
266
+ return admission.kind === "configured"
267
+ ? { admissionKind: "configured", apiKeyId: admission.keyId }
268
+ : { admissionKind: admission.kind };
269
+ }
270
+
271
+ export type ApiAuthDisposition = "required" | "accepted" | "rejected";
272
+
273
+ export interface ApiAuthMatrixRow {
274
+ endpoint: string;
275
+ bearer: ApiAuthDisposition;
276
+ dedicated: ApiAuthDisposition;
277
+ xApiKey: ApiAuthDisposition;
278
+ }
279
+
280
+ /**
281
+ * Which headers each data-plane endpoint actually accepts, shipped to the GUI so
282
+ * it stops describing the rule from memory. The dashboard has been telling users
283
+ * that Chat Completions takes `Authorization: Bearer`, which this file has never
284
+ * allowed — that route uses the dedicated-header-only wrapper because
285
+ * `Authorization` there may belong to Codex Direct passthrough.
286
+ *
287
+ * It lives next to the wrappers it describes, and a test drives real requests
288
+ * against every cell rather than reading the table back to itself.
289
+ */
290
+ export const AUTH_MATRIX: readonly ApiAuthMatrixRow[] = [
291
+ { endpoint: "/v1/responses", bearer: "rejected", dedicated: "required", xApiKey: "rejected" },
292
+ { endpoint: "/v1/chat/completions", bearer: "rejected", dedicated: "required", xApiKey: "rejected" },
293
+ { endpoint: "/v1/messages", bearer: "accepted", dedicated: "accepted", xApiKey: "accepted" },
294
+ { endpoint: "/v1/models", bearer: "accepted", dedicated: "accepted", xApiKey: "accepted" },
295
+ ];
296
+
224
297
  /** Whether `token` is the environment-provided management secret. */
225
298
  export function isManagementAdmissionSecret(token: string): boolean {
226
299
  const actual = token.trim();
@@ -247,14 +320,23 @@ export function validateForwardAdmissionCredential(headers: Headers, config: Ocx
247
320
  if (bearer && isProxyAdmissionSecret(bearer, config)) throw new ForwardAdmissionCredentialError();
248
321
  }
249
322
 
250
- export function hasValidApiAuth(req: Request, config: OcxConfig): boolean {
251
- if (!isApiAuthRequired(config)) return true;
323
+ /**
324
+ * Resolving form of `hasValidApiAuth`: identical header precedence, identical
325
+ * decision, but it names the admission instead of collapsing it to a boolean.
326
+ */
327
+ export function resolveApiAuth(req: Request, config: OcxConfig): DataPlaneAdmission | null {
328
+ // A loopback bind never reads a token at all, so there is no key to name.
329
+ if (!isApiAuthRequired(config)) return { kind: "loopback" };
252
330
  const actual = req.headers.get("x-opencodex-api-key")?.trim()
253
331
  || req.headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim()
254
332
  // Anthropic-SDK clients (Claude Code with ANTHROPIC_API_KEY) authenticate via x-api-key.
255
333
  || req.headers.get("x-api-key")?.trim();
256
- if (!actual) return false;
257
- return isDataPlaneAdmissionSecret(actual, config);
334
+ if (!actual) return null;
335
+ return resolveDataPlaneAdmissionSecret(actual, config);
336
+ }
337
+
338
+ export function hasValidApiAuth(req: Request, config: OcxConfig): boolean {
339
+ return resolveApiAuth(req, config) !== null;
258
340
  }
259
341
 
260
342
  export function requireApiAuth(req: Request, config: OcxConfig, _kind: "data-plane"): Response | null {
@@ -267,10 +349,17 @@ export function requireApiAuth(req: Request, config: OcxConfig, _kind: "data-pla
267
349
  * Codex Direct. Remote binds must use the dedicated proxy header so the two bearer
268
350
  * domains can never be confused.
269
351
  */
270
- export function requireResponsesApiAuth(req: Request, config: OcxConfig): Response | null {
271
- if (!isApiAuthRequired(config)) return null;
352
+ export function resolveResponsesApiAuth(req: Request, config: OcxConfig): DataPlaneAdmission | null {
353
+ if (!isApiAuthRequired(config)) return { kind: "loopback" };
354
+ // Dedicated header ONLY. `Authorization` on these transports may belong to
355
+ // Codex Direct passthrough, and the two bearer domains must stay unconfusable.
272
356
  const actual = req.headers.get("x-opencodex-api-key")?.trim();
273
- if (actual && isDataPlaneAdmissionSecret(actual, config)) return null;
357
+ if (!actual) return null;
358
+ return resolveDataPlaneAdmissionSecret(actual, config);
359
+ }
360
+
361
+ export function requireResponsesApiAuth(req: Request, config: OcxConfig): Response | null {
362
+ if (resolveResponsesApiAuth(req, config)) return null;
274
363
  return formatErrorResponse(401, "authentication_error", "opencodex API key required");
275
364
  }
276
365
 
@@ -418,9 +507,13 @@ export function safeConfigDTO(config: OcxConfig): unknown {
418
507
  ] as const) {
419
508
  copyIfDefined(dto, provider, key);
420
509
  }
421
- const registryNote = providerMatchesRegistryTransport(name, provider)
422
- ? getProviderRegistryEntry(name)?.note
423
- : undefined;
510
+ // Resolve the note by DESTINATION, not by name. A preset saved under a custom name is
511
+ // still pointed at the same vendor route, and a usage restriction the user needs to see
512
+ // must not disappear because the row was renamed. Prefer the same-name entry so an
513
+ // unrenamed provider keeps its exact registry note.
514
+ const registryNote = (providerMatchesRegistryTransport(name, provider)
515
+ ? getProviderRegistryEntry(name)
516
+ : registryEntryForProviderDestination(provider))?.note;
424
517
  if (typeof registryNote === "string" && registryNote.trim()) dto.note = registryNote;
425
518
  const codexAccountMode = providerCodexAccountMode(name, provider);
426
519
  if (codexAccountMode) dto.codexAccountMode = codexAccountMode;
@@ -31,16 +31,24 @@ import {
31
31
  } from "./request-log";
32
32
  import { responseWithDeferredRequestLog } from "./relay";
33
33
  import { handleResponses } from "./responses";
34
+ import type { AdmissionLease } from "../lib/admission";
35
+ import {
36
+ createTranslatorBudget,
37
+ finalizeTranslatorBudgetResponse,
38
+ isTranslatorBudgetExceededError,
39
+ type TranslatorBudget,
40
+ } from "../lib/translator-budget";
34
41
 
35
42
  type Rec = Record<string, unknown>;
36
43
 
37
44
  function isRec(v: unknown): v is Rec {
38
45
  return !!v && typeof v === "object" && !Array.isArray(v);
39
46
  }
40
- async function readChatBody(req: Request): Promise<unknown> {
47
+ async function readChatBody(req: Request, budget: TranslatorBudget): Promise<unknown> {
41
48
  try {
42
- return await readJsonRequestBody(req);
49
+ return await readJsonRequestBody(req, budget);
43
50
  } catch (err) {
51
+ if (isTranslatorBudgetExceededError(err)) throw err;
44
52
  throw new ChatCompletionsRequestError(err instanceof Error && err.message ? err.message : "Invalid JSON body");
45
53
  }
46
54
  }
@@ -49,17 +57,43 @@ export async function handleChatCompletions(
49
57
  req: Request,
50
58
  config: OcxConfig,
51
59
  logCtx: RequestLogContext,
52
- logIds?: { requestId: string; start: number },
60
+ logIds?: { requestId: string; start: number; turnAdmissionLease?: AdmissionLease },
61
+ ): Promise<Response> {
62
+ const translatorBudget = createTranslatorBudget();
63
+ try {
64
+ return finalizeTranslatorBudgetResponse(
65
+ await handleChatCompletionsWithBudget(req, config, logCtx, translatorBudget, logIds),
66
+ translatorBudget,
67
+ );
68
+ } catch (error) {
69
+ translatorBudget.dispose();
70
+ throw error;
71
+ }
72
+ }
73
+
74
+ async function handleChatCompletionsWithBudget(
75
+ req: Request,
76
+ config: OcxConfig,
77
+ logCtx: RequestLogContext,
78
+ translatorBudget: TranslatorBudget,
79
+ logIds?: { requestId: string; start: number; turnAdmissionLease?: AdmissionLease },
53
80
  ): Promise<Response> {
54
81
  let chatBody: unknown;
55
82
  let internalBody: Rec;
56
83
  try {
57
- chatBody = await readChatBody(req);
84
+ chatBody = await readChatBody(req, translatorBudget);
58
85
  internalBody = chatCompletionsToResponsesBody(chatBody);
86
+ translatorBudget.chargeRetained(new TextEncoder().encode(JSON.stringify(internalBody)).byteLength, { kind: "request_copies" });
59
87
  } catch (err) {
60
- const status = err instanceof ChatCompletionsRequestError ? 400 : 500;
88
+ const overflow = isTranslatorBudgetExceededError(err);
89
+ const status = overflow ? 413 : err instanceof ChatCompletionsRequestError ? 400 : 500;
61
90
  if (logIds) addFinalRequestLog(logIds.requestId, logIds.start, logCtx, status, { closeReason: "non_stream" });
62
- return chatCompletionsErrorResponse(status, err instanceof Error ? err.message : String(err));
91
+ return chatCompletionsErrorResponse(
92
+ status,
93
+ overflow ? "request translation buffer exceeded the safe limit" : err instanceof Error ? err.message : String(err),
94
+ overflow ? "request_too_large" : undefined,
95
+ overflow ? "translation_buffer_limit" : undefined,
96
+ );
63
97
  }
64
98
 
65
99
  const requestedModel = (chatBody as Rec).model as string;
@@ -78,7 +112,7 @@ export async function handleChatCompletions(
78
112
  const route = routeModel(config, internalBody.model as string);
79
113
  // Settle the wire once so every branch below reads the adapter this model will
80
114
  // actually use, not the provider-wide default (#404).
81
- route.provider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider);
115
+ route.provider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, "chat");
82
116
  logCtx.model = route.modelId;
83
117
  logCtx.providerAdapter = route.provider.adapter;
84
118
  logCtx.requestedModel = requestedModel;
@@ -135,10 +169,12 @@ export async function handleChatCompletions(
135
169
  /* optional */
136
170
  }
137
171
 
172
+ const internalBodyJson = JSON.stringify(internalBody);
173
+ translatorBudget.chargeRetained(new TextEncoder().encode(internalBodyJson).byteLength, { kind: "request_copies" });
138
174
  const internalReq = new Request("http://localhost/v1/responses", {
139
175
  method: "POST",
140
176
  headers,
141
- body: JSON.stringify(internalBody),
177
+ body: internalBodyJson,
142
178
  });
143
179
 
144
180
  let nativeLogged = false;
@@ -148,7 +184,11 @@ export async function handleChatCompletions(
148
184
  addFinalRequestLog(logIds.requestId, logIds.start, logCtx, status, meta);
149
185
  };
150
186
  const upstream = await handleResponses(internalReq, config, logCtx, {
187
+ ...(logIds?.turnAdmissionLease ? { turnAdmissionLease: logIds.turnAdmissionLease } : {}),
151
188
  abortSignal: req.signal,
189
+ // Body is Responses-shaped by now, but the client spoke Chat Completions.
190
+ inboundWire: "chat",
191
+ translatorBudget,
152
192
  ...(logIds ? { onFirstOutput: () => recordFirstOutput(logCtx, logIds.start) } : {}),
153
193
  onNativePassthroughTerminal: status => finalizeNativeLog(httpStatusForTerminalStatus(status), { terminalStatus: status, closeReason: "terminal" }),
154
194
  onNativePassthroughCancel: () => finalizeNativeLog(499, { closeReason: "client_cancel" }),
@@ -231,7 +271,7 @@ export async function handleChatCompletions(
231
271
 
232
272
  const contentType = response.headers.get("content-type") ?? "";
233
273
  if (contentType.includes("text/event-stream") && response.body) {
234
- const chatSse = responsesSseToChatCompletionsSse(response.body, requestedModel);
274
+ const chatSse = responsesSseToChatCompletionsSse(response.body, requestedModel, { translatorBudget });
235
275
  if (stream) {
236
276
  // Stream failures surface as an error SSE frame then abort the body — never a
237
277
  // success completion that embeds `[error] ...` + clean [DONE].
@@ -245,7 +285,7 @@ export async function handleChatCompletions(
245
285
  });
246
286
  }
247
287
  try {
248
- const completion = await collectChatCompletion(chatSse, requestedModel);
288
+ const completion = await collectChatCompletion(chatSse, requestedModel, translatorBudget);
249
289
  return new Response(JSON.stringify(completion), {
250
290
  status: 200,
251
291
  headers: { "Content-Type": "application/json" },
@@ -274,7 +314,10 @@ export async function handleChatCompletions(
274
314
  const error = (json as { error?: { message?: string; type?: string; code?: string | null } }).error;
275
315
  const message = error?.message ?? "upstream request failed";
276
316
  const classified = classifyError(502, error?.type ?? "server_error", message);
277
- if (isCyberPolicyCode(error?.code)) {
317
+ if (error?.code === "translation_buffer_limit") {
318
+ classified.code = "translation_buffer_limit";
319
+ classified.type = "invalid_request_error";
320
+ } else if (isCyberPolicyCode(error?.code)) {
278
321
  classified.code = CYBER_POLICY_ERROR_CODE;
279
322
  classified.type = "invalid_request_error";
280
323
  } else if (error?.code === "model_not_found") {
@@ -283,7 +326,9 @@ export async function handleChatCompletions(
283
326
  classified.type = "invalid_request_error";
284
327
  }
285
328
  return chatCompletionsErrorResponse(
286
- isCyberPolicyCode(classified.code) ? 400 : 502,
329
+ classified.code === "translation_buffer_limit"
330
+ ? 413
331
+ : isCyberPolicyCode(classified.code) ? 400 : 502,
287
332
  message,
288
333
  classified.type,
289
334
  classified.code,
@@ -33,6 +33,13 @@ import { addFinalRequestLog, httpStatusForTerminalStatus, recordFirstOutput, typ
33
33
  import { conversationIdFromClaudeMetadata } from "./request-log-conversation";
34
34
  import { responseWithDeferredRequestLog } from "./relay";
35
35
  import { handleResponses } from "./responses";
36
+ import type { AdmissionLease } from "../lib/admission";
37
+ import {
38
+ createTranslatorBudget,
39
+ finalizeTranslatorBudgetResponse,
40
+ isTranslatorBudgetExceededError,
41
+ type TranslatorBudget,
42
+ } from "../lib/translator-budget";
36
43
 
37
44
  type Rec = Record<string, unknown>;
38
45
 
@@ -62,10 +69,11 @@ function claudeInboundDisabled(config: OcxConfig): Response | null {
62
69
  return null;
63
70
  }
64
71
 
65
- async function readAnthropicBody(req: Request): Promise<unknown> {
72
+ async function readAnthropicBody(req: Request, budget: TranslatorBudget): Promise<unknown> {
66
73
  try {
67
- return await readJsonRequestBody(req);
74
+ return await readJsonRequestBody(req, budget);
68
75
  } catch (err) {
76
+ if (isTranslatorBudgetExceededError(err)) throw err;
69
77
  throw new AnthropicRequestError(err instanceof Error && err.message ? err.message : "Invalid JSON body");
70
78
  }
71
79
  }
@@ -511,7 +519,26 @@ export async function handleClaudeMessages(
511
519
  req: Request,
512
520
  config: OcxConfig,
513
521
  logCtx: RequestLogContext,
514
- logIds?: { requestId: string; start: number },
522
+ logIds?: { requestId: string; start: number; turnAdmissionLease?: AdmissionLease },
523
+ ): Promise<Response> {
524
+ const translatorBudget = createTranslatorBudget();
525
+ try {
526
+ return finalizeTranslatorBudgetResponse(
527
+ await handleClaudeMessagesWithBudget(req, config, logCtx, translatorBudget, logIds),
528
+ translatorBudget,
529
+ );
530
+ } catch (error) {
531
+ translatorBudget.dispose();
532
+ throw error;
533
+ }
534
+ }
535
+
536
+ async function handleClaudeMessagesWithBudget(
537
+ req: Request,
538
+ config: OcxConfig,
539
+ logCtx: RequestLogContext,
540
+ translatorBudget: TranslatorBudget,
541
+ logIds?: { requestId: string; start: number; turnAdmissionLease?: AdmissionLease },
515
542
  ): Promise<Response> {
516
543
  logCtx.surface = "claude";
517
544
  const disabled = claudeInboundDisabled(config);
@@ -525,7 +552,7 @@ export async function handleClaudeMessages(
525
552
  let cacheKeySource: ClaudeCacheKeySource = null;
526
553
  let effortOverride: ReturnType<typeof extractOcxEffortDirective> = null;
527
554
  try {
528
- anthropicBody = await readAnthropicBody(req);
555
+ anthropicBody = await readAnthropicBody(req, translatorBudget);
529
556
  // Defensive [1m] strip (devlog 138): clients normally remove the context-variant
530
557
  // marker themselves; the 1M signal we act on is the anthropic-beta header.
531
558
  // Case-insensitive — the CLI matches /\[1m\]/i (audit 021 #7).
@@ -575,11 +602,18 @@ export async function handleClaudeMessages(
575
602
  }
576
603
  const translation = anthropicToResponsesTranslation(anthropicBody, config.claudeCode);
577
604
  internalBody = translation.body;
605
+ translatorBudget.chargeRetained(new TextEncoder().encode(JSON.stringify(internalBody)).byteLength, { kind: "request_copies" });
578
606
  cacheKeySource = translation.cacheKeySource;
579
607
  } catch (err) {
580
- const status = err instanceof AnthropicRequestError ? 400 : 500;
608
+ const overflow = isTranslatorBudgetExceededError(err);
609
+ const status = overflow ? 413 : err instanceof AnthropicRequestError ? 400 : 500;
581
610
  if (logIds) addFinalRequestLog(logIds.requestId, logIds.start, logCtx, status, { closeReason: "non_stream" });
582
- return anthropicErrorResponse(status, err instanceof Error ? err.message : String(err));
611
+ return anthropicErrorResponse(
612
+ status,
613
+ overflow ? "request translation buffer exceeded the safe limit" : err instanceof Error ? err.message : String(err),
614
+ overflow ? "request_too_large" : undefined,
615
+ overflow ? "translation_buffer_limit" : undefined,
616
+ );
583
617
  }
584
618
 
585
619
  const requestedModel = (anthropicBody as Rec).model as string;
@@ -596,7 +630,7 @@ export async function handleClaudeMessages(
596
630
  const route = routeModel(config, internalBody.model as string);
597
631
  // Settle the wire once so the sampling decision below reads the effective
598
632
  // adapter rather than the provider-wide default (#404).
599
- route.provider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider);
633
+ route.provider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, "anthropic");
600
634
  if (route.provider.adapter === "openai-responses") {
601
635
  nativeRoute = true;
602
636
  delete internalBody.max_output_tokens;
@@ -666,10 +700,12 @@ export async function handleClaudeMessages(
666
700
  headers.set("session_id", uuidFromHex(internalBody.prompt_cache_key));
667
701
  }
668
702
  }
703
+ const internalBodyJson = JSON.stringify(internalBody);
704
+ translatorBudget.chargeRetained(new TextEncoder().encode(internalBodyJson).byteLength, { kind: "request_copies" });
669
705
  const internalReq = new Request("http://localhost/v1/responses", {
670
706
  method: "POST",
671
707
  headers,
672
- body: JSON.stringify(internalBody),
708
+ body: internalBodyJson,
673
709
  });
674
710
 
675
711
  // Request-log wiring mirrors the /v1/responses route: native passthrough finalizes
@@ -683,8 +719,14 @@ export async function handleClaudeMessages(
683
719
  addFinalRequestLog(logIds.requestId, logIds.start, logCtx, status, meta);
684
720
  };
685
721
  const upstream = await handleResponses(internalReq, buildClaudeReplayConfig(config), logCtx, {
722
+ ...(logIds?.turnAdmissionLease ? { turnAdmissionLease: logIds.turnAdmissionLease } : {}),
686
723
  abortSignal: req.signal,
687
724
  promptCacheKeyIsSharedCohort: cacheKeySource === "system",
725
+ // The body is Responses-shaped by now, but the client spoke Anthropic Messages.
726
+ // Without this the replay would look native and a Responses-scoped wire default
727
+ // would fire, disagreeing with the pre-flight decision above.
728
+ inboundWire: "anthropic",
729
+ translatorBudget,
688
730
  ...(logIds ? { onFirstOutput: () => recordFirstOutput(logCtx, logIds.start) } : {}),
689
731
  onNativePassthroughTerminal: status => finalizeNativeLog(httpStatusForTerminalStatus(status), { terminalStatus: status, closeReason: "terminal" }),
690
732
  onNativePassthroughCancel: () => finalizeNativeLog(499, { closeReason: "client_cancel" }),
@@ -735,7 +777,7 @@ export async function handleClaudeMessages(
735
777
 
736
778
  const contentType = response.headers.get("content-type") ?? "";
737
779
  if (contentType.includes("text/event-stream") && response.body) {
738
- const anthropicSse = responsesSseToAnthropicSse(response.body, requestedModel);
780
+ const anthropicSse = responsesSseToAnthropicSse(response.body, requestedModel, { translatorBudget });
739
781
  if (stream) {
740
782
  return new Response(anthropicSse, {
741
783
  status: 200,
@@ -746,8 +788,29 @@ export async function handleClaudeMessages(
746
788
  },
747
789
  });
748
790
  }
749
- const message = await collectAnthropicMessage(anthropicSse, requestedModel);
791
+ let message: Rec;
792
+ try {
793
+ message = await collectAnthropicMessage(anthropicSse, requestedModel, translatorBudget);
794
+ } catch (error) {
795
+ if (isTranslatorBudgetExceededError(error)) {
796
+ return anthropicErrorResponse(413, error.message, "request_too_large", error.code);
797
+ }
798
+ return anthropicErrorResponse(502, error instanceof Error ? error.message : String(error), "api_error");
799
+ }
750
800
  const isError = (message as Rec).type === "error";
801
+ const translatedError = isError && typeof (message as Rec).error === "object"
802
+ ? (message as { error: { code?: unknown; message?: unknown } }).error
803
+ : undefined;
804
+ if (translatedError?.code === "translation_buffer_limit") {
805
+ return anthropicErrorResponse(
806
+ 413,
807
+ typeof translatedError.message === "string"
808
+ ? translatedError.message
809
+ : "upstream translation buffer exceeded the safe limit",
810
+ "request_too_large",
811
+ "translation_buffer_limit",
812
+ );
813
+ }
751
814
  return new Response(JSON.stringify(message), {
752
815
  status: isError ? 502 : 200,
753
816
  headers: { "Content-Type": "application/json" },
@@ -763,7 +826,15 @@ export async function handleClaudeMessages(
763
826
  }
764
827
  const status = (json as Rec)?.status;
765
828
  if (status === "failed") {
766
- const error = (json as { error?: { message?: string } }).error;
829
+ const error = (json as { error?: { message?: string; code?: string } }).error;
830
+ if (error?.code === "translation_buffer_limit") {
831
+ return anthropicErrorResponse(
832
+ 413,
833
+ error.message ?? "upstream translation buffer exceeded the safe limit",
834
+ "request_too_large",
835
+ "translation_buffer_limit",
836
+ );
837
+ }
767
838
  return anthropicErrorResponse(502, error?.message ?? "upstream request failed", "api_error");
768
839
  }
769
840
  const message = responsesJsonToAnthropicMessage(json, requestedModel);
@@ -800,12 +871,13 @@ export async function handleClaudeCountTokens(req: Request, config: OcxConfig):
800
871
  if (disabled) return disabled;
801
872
 
802
873
  let body: unknown;
874
+ const translatorBudget = createTranslatorBudget();
803
875
  try {
804
- body = await readAnthropicBody(req);
876
+ body = await readAnthropicBody(req, translatorBudget);
805
877
  } catch (err) {
806
878
  if (err instanceof AnthropicRequestError) return anthropicErrorResponse(400, err.message);
807
879
  return anthropicErrorResponse(500, err instanceof Error ? err.message : String(err));
808
- }
880
+ } finally { translatorBudget.dispose(); }
809
881
  if (!body || typeof body !== "object" || Array.isArray(body)) {
810
882
  return anthropicErrorResponse(400, "request body must be a JSON object");
811
883
  }