@bitkyc08/opencodex 2.43.0 → 2.44.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 (138) hide show
  1. package/README.md +25 -17
  2. package/gui/dist/assets/index-B7_K1Hsj.js +115 -0
  3. package/gui/dist/assets/index-ltx3L-WS.css +1 -0
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +1 -1
  6. package/src/adapters/command-code.ts +23 -2
  7. package/src/adapters/cursor/native-exec.ts +63 -11
  8. package/src/adapters/cursor/tool-definitions.ts +1 -1
  9. package/src/adapters/cursor/tool-schemas.ts +59 -0
  10. package/src/adapters/kiro.ts +54 -8
  11. package/src/adapters/openai-chat.ts +23 -6
  12. package/src/adapters/openai-responses.ts +3 -1
  13. package/src/adapters/opencode-go.ts +35 -0
  14. package/src/claude/agents-inject.ts +12 -5
  15. package/src/claude/desktop-3p-library.ts +89 -0
  16. package/src/claude/desktop-3p.ts +166 -95
  17. package/src/claude/desktop-discovery-inputs.ts +44 -0
  18. package/src/claude/desktop-profile.ts +1 -1
  19. package/src/claude/desktop-remote-store-artifact.ts +185 -0
  20. package/src/claude/desktop-remote-store-io.ts +91 -0
  21. package/src/claude/desktop-remote-store-state.ts +127 -0
  22. package/src/claude/desktop-remote-store.ts +269 -0
  23. package/src/claude/inbound-model-options.ts +10 -2
  24. package/src/claude/inbound-records.ts +7 -0
  25. package/src/claude/inbound.ts +1 -1
  26. package/src/claude/model-info.ts +16 -3
  27. package/src/cli/account-api.ts +7 -1
  28. package/src/cli/aside-profiles.ts +17 -0
  29. package/src/cli/capabilities.ts +39 -0
  30. package/src/cli/claude-agent-startup-sync.ts +3 -1
  31. package/src/cli/claude-desktop.ts +127 -18
  32. package/src/cli/connect.ts +27 -14
  33. package/src/cli/dispatch.ts +30 -16
  34. package/src/cli/export-command.ts +3 -1
  35. package/src/cli/index.ts +28 -16
  36. package/src/cli/integrations.ts +46 -12
  37. package/src/cli/opencode.ts +11 -4
  38. package/src/cli/runtime-api.ts +7 -2
  39. package/src/cli/uninstall-client-state.ts +78 -0
  40. package/src/client/connect.ts +476 -223
  41. package/src/client/hub-client.ts +81 -0
  42. package/src/client/hub-relay.ts +38 -6
  43. package/src/client/lifecycle-lock.ts +129 -0
  44. package/src/client/state.ts +71 -42
  45. package/src/clients/aside-profiles.ts +224 -0
  46. package/src/codex/account-lifecycle.ts +5 -9
  47. package/src/codex/auth-api.ts +50 -4
  48. package/src/codex/catalog/metadata.ts +0 -2
  49. package/src/codex/catalog/provider-fetch.ts +7 -5
  50. package/src/codex/catalog/sync.ts +88 -24
  51. package/src/codex/convergence.ts +2 -0
  52. package/src/codex/inject.ts +45 -14
  53. package/src/codex/journal.ts +6 -6
  54. package/src/codex/quota-refresh-outcome.ts +27 -0
  55. package/src/codex/shim.ts +7 -1
  56. package/src/combos/resolve.ts +10 -7
  57. package/src/config.ts +18 -5
  58. package/src/generated/compatibility-version.json +209 -105
  59. package/src/images/loop.ts +5 -2
  60. package/src/integrations/aside-profile-context.ts +270 -0
  61. package/src/integrations/aside-profile-journal.ts +229 -0
  62. package/src/integrations/aside-profiles.ts +176 -0
  63. package/src/integrations/catalog-refresh.ts +40 -0
  64. package/src/integrations/config-io.ts +16 -1
  65. package/src/integrations/owned-refresh.ts +14 -2
  66. package/src/integrations/state.ts +3 -1
  67. package/src/integrations/writer.ts +3 -1
  68. package/src/lab/fabric/producer-isolate.ts +84 -46
  69. package/src/lib/account-selection-events.ts +32 -0
  70. package/src/lib/errors.ts +9 -1
  71. package/src/lib/provider-outbound.ts +2 -45
  72. package/src/lib/proxy-env.ts +67 -0
  73. package/src/oauth/account-quota-rank.ts +9 -1
  74. package/src/oauth/anthropic-routing.ts +91 -23
  75. package/src/oauth/generic-account-failover.ts +15 -53
  76. package/src/oauth/index.ts +67 -68
  77. package/src/oauth/pool-settings-capability.ts +2 -1
  78. package/src/oauth/store.ts +91 -7
  79. package/src/oauth/types.ts +8 -0
  80. package/src/providers/api-key-selection.ts +110 -0
  81. package/src/providers/api-keys.ts +38 -31
  82. package/src/providers/context-cap.ts +27 -3
  83. package/src/providers/key-failover.ts +27 -17
  84. package/src/providers/provider-id-rewrite.ts +10 -8
  85. package/src/providers/registry.ts +1 -0
  86. package/src/responses/custom-tool-compat.ts +10 -0
  87. package/src/responses/function-call-compat.ts +173 -0
  88. package/src/responses/namespace-tool-compat.ts +27 -4
  89. package/src/responses/parser.ts +8 -0
  90. package/src/responses/task-input.ts +36 -0
  91. package/src/responses/tool-name-aliases.ts +79 -0
  92. package/src/router.ts +2 -0
  93. package/src/routing/capability.ts +10 -3
  94. package/src/routing/compatibility/assemble.ts +16 -1
  95. package/src/routing/evaluator.ts +5 -1
  96. package/src/server/auth-cors.ts +3 -0
  97. package/src/server/chat-native.ts +44 -9
  98. package/src/server/claude-messages.ts +71 -36
  99. package/src/server/grok-responses-snapshot-repair.ts +333 -0
  100. package/src/server/index.ts +26 -10
  101. package/src/server/management/account-selection-stream.ts +70 -0
  102. package/src/server/management/aside-profile-routes.ts +231 -0
  103. package/src/server/management/config-routes.ts +36 -41
  104. package/src/server/management/context.ts +3 -0
  105. package/src/server/management/integration-routes.ts +61 -3
  106. package/src/server/management/logs-usage-routes.ts +1 -0
  107. package/src/server/management/model-routes.ts +20 -9
  108. package/src/server/management/model-rows.ts +10 -2
  109. package/src/server/management/oauth-account-routes.ts +7 -0
  110. package/src/server/management/provider-routes.ts +10 -8
  111. package/src/server/management/route-registry.ts +20 -2
  112. package/src/server/management-auth.ts +16 -0
  113. package/src/server/relay-eager.ts +17 -5
  114. package/src/server/relay.ts +43 -4
  115. package/src/server/request-log.ts +29 -1
  116. package/src/server/responses/agent-task-recovery-cache.ts +11 -0
  117. package/src/server/responses/agent-task-recovery.ts +24 -4
  118. package/src/server/responses/codex-ws-pool.ts +4 -4
  119. package/src/server/responses/codex-ws-session.ts +2 -2
  120. package/src/server/responses/collaboration.ts +2 -1
  121. package/src/server/responses/combo-stream-preflight.ts +26 -7
  122. package/src/server/responses/core.ts +668 -151
  123. package/src/server/responses/fetch-helpers.ts +6 -1
  124. package/src/server/responses/ws-upstream.ts +8 -3
  125. package/src/server/responses-custom-tool-repair.ts +29 -9
  126. package/src/server/responses-function-tool-repair.ts +183 -0
  127. package/src/server/responses-snapshot-codec.ts +14 -0
  128. package/src/server/responses-snapshot-repair.ts +1 -13
  129. package/src/server/responses-undeclared-tool-guard.ts +1 -68
  130. package/src/server/sse-payload-rewrite.ts +3 -1
  131. package/src/service.ts +6 -2
  132. package/src/types/config.ts +23 -16
  133. package/src/types/provider.ts +14 -3
  134. package/src/types/request.ts +2 -0
  135. package/src/usage/log.ts +9 -0
  136. package/src/web-search/loop.ts +5 -2
  137. package/gui/dist/assets/index-DS1NE4Jn.css +0 -1
  138. package/gui/dist/assets/index-Djowl68T.js +0 -112
@@ -60,7 +60,7 @@ import { clearThreadAccountMap } from "../../codex/routing";
60
60
  import { primeCodexPoolQuotas } from "../../codex/auth-api";
61
61
  import { clearModelCache, getProviderDiscoveryStatus } from "../../codex/model-cache";
62
62
  import { getCodexModelEntitlementStatus } from "../../codex/model-entitlements";
63
- import { DEFAULT_PROVIDER_CONTEXT_CAP, globalContextCapValue, providerContextCap, providerContextCaps, setAllProviderContextCaps, setGlobalContextCapValue, setProviderContextCap } from "../../providers/context-cap";
63
+ import { DEFAULT_PROVIDER_CONTEXT_CAP, globalContextCapValue, providerContextCap, providerContextCaps, selectedProviderContextCaps, forgetProviderContextCap, setAllProviderContextCaps, setGlobalContextCapValue, setProviderContextCap } from "../../providers/context-cap";
64
64
  import { modelAutoCompactTokenLimitsConfigError } from "../../providers/auto-compact-budget";
65
65
  import { resolveCodexHomeDir } from "../../codex/home";
66
66
  import { readUsageEntries } from "../../usage/log";
@@ -268,7 +268,7 @@ function providerEditorCandidate(
268
268
  candidate.providers = providers;
269
269
  for (const name of removedProviders) {
270
270
  dropProviderCustomModels(candidate, name);
271
- setProviderContextCap(candidate, name, false);
271
+ forgetProviderContextCap(candidate, name);
272
272
  }
273
273
  const validated = validateConfigCandidate(candidate);
274
274
  if (!validated.ok) {
@@ -289,6 +289,8 @@ function adoptProviderEditorCandidate(live: OcxConfig, persisted: OcxConfig): vo
289
289
  else live.customModels = structuredClone(persisted.customModels);
290
290
  if (persisted.providerContextCaps === undefined) delete live.providerContextCaps;
291
291
  else live.providerContextCaps = structuredClone(persisted.providerContextCaps);
292
+ if (persisted.providerContextCapValues === undefined) delete live.providerContextCapValues;
293
+ else live.providerContextCapValues = structuredClone(persisted.providerContextCapValues);
292
294
  if (persisted.disabledModels === undefined) delete live.disabledModels;
293
295
  else live.disabledModels = [...persisted.disabledModels];
294
296
  if (persisted.modelDiscovery === undefined) delete live.modelDiscovery;
@@ -849,7 +851,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
849
851
  persisted.modelDiscovery = candidate.config.modelDiscovery;
850
852
  for (const name of candidate.removedProviders) {
851
853
  dropProviderCustomModels(persisted, name);
852
- setProviderContextCap(persisted, name, false);
854
+ forgetProviderContextCap(persisted, name);
853
855
  }
854
856
  return {
855
857
  changed: true,
@@ -1381,7 +1383,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
1381
1383
  delete config.providers[name];
1382
1384
  const { dropProviderCustomModels } = await import("../../providers/provider-id-rewrite");
1383
1385
  const droppedCustomModels = dropProviderCustomModels(config, name);
1384
- setProviderContextCap(config, name, false);
1386
+ forgetProviderContextCap(config, name);
1385
1387
  save(config);
1386
1388
  await replaceProviderAccountSet(name, null);
1387
1389
  reconcileLiveStateStores();
@@ -1397,7 +1399,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
1397
1399
  }
1398
1400
 
1399
1401
  if (url.pathname === "/api/provider-context-caps" && req.method === "GET") {
1400
- return jsonResponse({ cap: DEFAULT_PROVIDER_CONTEXT_CAP, value: globalContextCapValue(config), caps: providerContextCaps(config) });
1402
+ return jsonResponse({ cap: DEFAULT_PROVIDER_CONTEXT_CAP, value: globalContextCapValue(config), caps: providerContextCaps(config), values: selectedProviderContextCaps(config) });
1401
1403
  }
1402
1404
 
1403
1405
  if (url.pathname === "/api/provider-context-caps" && req.method === "PUT") {
@@ -1413,7 +1415,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
1413
1415
  ok: true,
1414
1416
  cap: DEFAULT_PROVIDER_CONTEXT_CAP,
1415
1417
  value: globalContextCapValue(config),
1416
- caps: providerContextCaps(config),
1418
+ caps: providerContextCaps(config), values: selectedProviderContextCaps(config),
1417
1419
  catalogRefresh,
1418
1420
  });
1419
1421
 
@@ -1432,8 +1434,8 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
1432
1434
 
1433
1435
  // Branch 1: per-provider toggle (checked first: a per-provider request may carry an
1434
1436
  // explicit `value`, which must never fall through to the global-value branch). Enable
1435
- // writes the current global default unless an explicit per-provider value is supplied;
1436
- // that value is never copied to other providers.
1437
+ // restores the selected provider value, then the global default, unless an explicit
1438
+ // per-provider value is supplied; that value is never copied to other providers.
1437
1439
  if (typeof body.provider === "string" && typeof body.enabled === "boolean") {
1438
1440
  const provider = body.provider.trim();
1439
1441
  if (!isValidProviderName(provider)) {
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Declared inventory of every reachable management route.
3
3
  *
4
- * DECLARED, not harvested. A grep cannot see this surface: 19 routes are registered
4
+ * DECLARED, not harvested. A grep cannot see this surface: some routes are registered
5
5
  * through a regex, an `endsWith`, a `pathname.slice`, a prefix decode, a path constant, or a
6
6
  * negated `pathname !== "…"` guard, and two of those are live routes whose only textual
7
7
  * trace is the negated form. For `GET /api/storage` an equality scan finds solely the dead
@@ -26,6 +26,8 @@ export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD";
26
26
  * can check, not a way to quiet the parity test.
27
27
  */
28
28
  export type ExemptionReason =
29
+ /** Invalidates dashboard state; the CLI reads the underlying resource directly. */
30
+ | "gui-invalidation"
29
31
  /** Requires a dashboard browser session. Includes the user-consent star boundary. */
30
32
  | "session-only"
31
33
  /** Deliberately returns 405; there is nothing to drive. */
@@ -36,6 +38,8 @@ export type ExemptionReason =
36
38
  | "test-seam"
37
39
  /** The CLI reaches the same data through a local transport instead of HTTP. */
38
40
  | "local-transport"
41
+ /** Older clients use this alias; the current CLI drives its declared replacement. */
42
+ | "compatibility-alias"
39
43
  /** Unreachable in the live dispatch order; delete rather than expose. */
40
44
  | "dead"
41
45
  /**
@@ -135,6 +139,19 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [
135
139
  { method: "PUT", path: "/api/subagent-model-fallback", module: "server/management/agent-settings-routes", mutates: true },
136
140
  { method: "PUT", path: "/api/subagent-models", module: "server/management/agent-settings-routes", mutates: true },
137
141
  { method: "PUT", path: "/api/v2", module: "server/management/agent-settings-routes", mutates: true },
142
+ // server/management/aside-profile-routes
143
+ { method: "GET", path: "/api/client-integrations/aside", module: "server/management/aside-profile-routes", mutates: false, mechanism: "path-constant", exempt: { reason: "compatibility-alias", why: "Legacy Aside status alias; the current CLI reads the same aggregate through GET /api/client-integrations/aside/profiles." } },
144
+ { method: "PUT", path: "/api/client-integrations/aside", module: "server/management/aside-profile-routes", mutates: true, mechanism: "path-constant", exempt: { reason: "compatibility-alias", why: "Legacy Aside toggle alias; the current CLI uses PUT /api/client-integrations/aside/profiles so older servers cannot mistake a bulk request for a current-account toggle." } },
145
+ { method: "GET", path: "/api/client-integrations/aside/profiles", module: "server/management/aside-profile-routes", mutates: false, mechanism: "path-constant" },
146
+ { method: "PUT", path: "/api/client-integrations/aside/profiles", module: "server/management/aside-profile-routes", mutates: true, mechanism: "path-constant" },
147
+ { method: "POST", path: "/api/client-integrations/aside/sync", module: "server/management/aside-profile-routes", mutates: true },
148
+ { method: "GET", path: "/api/client-integrations/aside/profiles/{profileId}", module: "server/management/aside-profile-routes", mutates: false, mechanism: "prefix-decode" },
149
+ { method: "PUT", path: "/api/client-integrations/aside/profiles/{profileId}", module: "server/management/aside-profile-routes", mutates: true, mechanism: "prefix-decode" },
150
+ { method: "GET", path: "/api/client-integrations/aside/profiles/journal", module: "server/management/aside-profile-routes", mutates: false, mechanism: "prefix-decode" },
151
+ { method: "DELETE", path: "/api/client-integrations/aside/profiles/journal", module: "server/management/aside-profile-routes", mutates: true, mechanism: "prefix-decode", exempt: { reason: "deferred-verb", why: "Aside history deletion uses the dashboard journal cleanup; the CLI has history and restore but no deletion verb yet.", owner: "260904_priority65_closeout WP7", ownerDoc: "devlog/_plan/260904_priority65_closeout/060_wp7_rollback_journal_crud.md" } },
152
+ { method: "GET", path: "/api/client-integrations/aside/profiles/{profileId}/journal", module: "server/management/aside-profile-routes", mutates: false, mechanism: "prefix-decode" },
153
+ { method: "DELETE", path: "/api/client-integrations/aside/profiles/{profileId}/journal", module: "server/management/aside-profile-routes", mutates: true, mechanism: "prefix-decode", exempt: { reason: "deferred-verb", why: "Aside profile history deletion uses the dashboard journal cleanup; the CLI has scoped history and restore but no deletion verb yet.", owner: "260904_priority65_closeout WP7", ownerDoc: "devlog/_plan/260904_priority65_closeout/060_wp7_rollback_journal_crud.md" } },
154
+ { method: "POST", path: "/api/client-integrations/aside/profiles/{profileId}/restore", module: "server/management/aside-profile-routes", mutates: true, mechanism: "prefix-decode" },
138
155
  // server/management/codex-prompt-routes
139
156
  { method: "GET", path: "/api/codex-prompt", module: "server/management/codex-prompt-routes", mutates: false },
140
157
  { method: "GET", path: "/api/codex-prompt/text", module: "server/management/codex-prompt-routes", mutates: false },
@@ -241,6 +258,7 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [
241
258
  { method: "GET", path: "/api/key-providers", module: "server/management/oauth-account-routes", mutates: false },
242
259
  { method: "GET", path: "/api/keys", module: "server/management/oauth-account-routes", mutates: false },
243
260
  { method: "GET", path: "/api/oauth/accounts", module: "server/management/oauth-account-routes", mutates: false },
261
+ { method: "GET", path: "/api/accounts/events", module: "server/management/oauth-account-routes", mutates: false, exempt: { reason: "gui-invalidation", why: "Dashboard selection invalidation stream; CLI account commands read the authoritative account/key resources directly rather than subscribing to browser refresh notifications." } },
244
262
  { method: "GET", path: "/api/oauth/accounts/pool", module: "server/management/oauth-account-routes", mutates: false },
245
263
  { method: "GET", path: "/api/oauth/providers", module: "server/management/oauth-account-routes", mutates: false },
246
264
  { method: "GET", path: "/api/oauth/status", module: "server/management/oauth-account-routes", mutates: false },
@@ -303,7 +321,7 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [
303
321
  { method: "GET", path: "/api/system/memory", module: "server/management/system-routes", mutates: false },
304
322
  { method: "GET", path: "/api/system/windows-replace-retries", module: "server/management/system-routes", mutates: false },
305
323
  { method: "POST", path: "/api/system/restart", module: "server/management/system-routes", mutates: true },
306
- // --- Routes an equality scan of their own file cannot see (19). ---
324
+ // --- Further routes an equality scan of their own file cannot see. ---
307
325
  // Each carries `mechanism`; the reconciliation test counts these separately.
308
326
  { method: "GET", path: "/api/storage", module: "server/management/storage-log-guard-routes", mutates: false, mechanism: "negated-guard" },
309
327
  { method: "GET", path: "/api/routing-analytics", module: "server/management/routing-analytics-routes", mutates: false, mechanism: "negated-guard" },
@@ -251,10 +251,26 @@ export function issueGuiSession(
251
251
 
252
252
  export interface ManagementSessionControl {
253
253
  revokeCurrent(req: Request): boolean;
254
+ /** Revalidate a long-lived request against current authority, without cached admission or renewal. */
255
+ isCurrent(req: Request, config: OcxConfig): boolean;
254
256
  }
255
257
 
256
258
  export function createManagementSessionControl(state: ManagementAuthState): ManagementSessionControl {
257
259
  return {
260
+ isCurrent(req: Request, config: OcxConfig): boolean {
261
+ if (!state.available) return false;
262
+ const credential = requestManagementCredential(req);
263
+ if (!credential) return false;
264
+ if (equalSecret(credential, state.token)) return true;
265
+ const session = state.sessions.get(credential);
266
+ if (!session) return false;
267
+ // Reuse the full origin/expiry/CSRF predicate against the current record, but
268
+ // isolate its sliding-expiry mutation: SSE heartbeats are not browser activity.
269
+ return authorizeGuiSessionRequest(req, config, {
270
+ sessions: new Map([[credential, { ...session }]]),
271
+ pairingGrants: state.pairingGrants,
272
+ }).ok;
273
+ },
258
274
  revokeCurrent(req: Request): boolean {
259
275
  if (!state.available) return false;
260
276
  const credential = requestManagementCredential(req);
@@ -29,6 +29,7 @@ import {
29
29
  createSseTerminalOutputBoundary,
30
30
  doneFrame,
31
31
  failedTailFrame,
32
+ upstreamErrorTailFrame,
32
33
  } from "./relay";
33
34
  import {
34
35
  nextSseBlock,
@@ -63,7 +64,7 @@ export type EagerRelayHooks = {
63
64
  /** True once inspection has reported a protocol terminal (inspector.reported). */
64
65
  sawTerminal: () => boolean;
65
66
  /** Record a synthetic terminal (caller decides incomplete vs failed-502). */
66
- onSynthetic: (kind: "incomplete" | "failed") => void;
67
+ onSynthetic: (kind: "incomplete" | "failed", reason?: "upstream_error") => void;
67
68
  /** Client cancelled and NO terminal arrived within the drain bounds. */
68
69
  onClientCancel: () => void;
69
70
  /** Exactly once, after the producer fully stops (unregisterTurn parity). */
@@ -81,6 +82,8 @@ export type EagerRelayOptions = {
81
82
  postCancelDrainMs?: number;
82
83
  /** Post-cancel discard-drain byte bound. Default 32 MiB. */
83
84
  postCancelDrainBytes?: number;
85
+ /** Last known upstream failure to preserve when EOF would otherwise become adapter_eof. */
86
+ upstreamError?: string;
84
87
  /** Injectable clock for tests. */
85
88
  now?: () => number;
86
89
  };
@@ -239,6 +242,7 @@ export function relaySseEagerBounded(
239
242
 
240
243
  const producer = async () => {
241
244
  let syntheticKind: "incomplete" | "failed" | null = null;
245
+ let syntheticReason: "upstream_error" | undefined;
242
246
  let deliveryFallbackSent = false;
243
247
  let priorRewriteFailure = false;
244
248
  let priorRewriteError: unknown;
@@ -303,12 +307,17 @@ export function relaySseEagerBounded(
303
307
  } else if (!hooks.sawTerminal() && canDeliver()) {
304
308
  // A clean 200 EOF without a Responses terminal must be visible to
305
309
  // Codex as one incomplete turn, followed by the normal sentinel.
306
- queuedBytes += adapterEofFrame.byteLength + terminalSentinel.byteLength;
310
+ const upstreamError = terminalBoundary.upstreamError() ?? opts?.upstreamError;
311
+ const upstreamErrorFrame = upstreamError === undefined
312
+ ? adapterEofFrame
313
+ : upstreamErrorTailFrame(terminalEncoder, upstreamError);
314
+ queuedBytes += upstreamErrorFrame.byteLength + terminalSentinel.byteLength;
307
315
  try {
308
- controllerRef?.enqueue(adapterEofFrame);
316
+ controllerRef?.enqueue(upstreamErrorFrame);
309
317
  controllerRef?.enqueue(terminalSentinel);
310
318
  } catch { /* client already gone */ }
311
- syntheticKind = "incomplete";
319
+ syntheticKind = upstreamError === undefined ? "incomplete" : "failed";
320
+ syntheticReason = upstreamError === undefined ? undefined : "upstream_error";
312
321
  }
313
322
  break;
314
323
  }
@@ -449,7 +458,10 @@ export function relaySseEagerBounded(
449
458
  frameBufferBytes = 0;
450
459
  }
451
460
  terminalBoundary.dispose();
452
- if (syntheticKind && canDeliver()) hooks.onSynthetic(syntheticKind);
461
+ if (syntheticKind && canDeliver()) {
462
+ if (syntheticReason === undefined) hooks.onSynthetic(syntheticKind);
463
+ else hooks.onSynthetic(syntheticKind, syntheticReason);
464
+ }
453
465
  if (cancelled && !hooks.sawTerminal()) {
454
466
  hooks.onClientCancel();
455
467
  }
@@ -5,6 +5,7 @@ import {
5
5
  CYBER_POLICY_FALLBACK_MESSAGE,
6
6
  isCyberPolicyCode,
7
7
  isCyberPolicyMessage,
8
+ upstreamErrorMessageFromPayload,
8
9
  } from "../lib/errors";
9
10
  import { redactSecretString } from "../lib/redact";
10
11
  import { isTranslatorBudgetExceededError } from "../lib/translator-budget";
@@ -147,11 +148,31 @@ export function failedTailFrame(encoder: TextEncoder, err: unknown): Uint8Array
147
148
  return encoder.encode(`\n\nevent: response.failed\ndata: ${payload}\n\n${DONE_SSE_FRAME_TEXT}`);
148
149
  }
149
150
 
151
+ export function upstreamErrorTailFrame(encoder: TextEncoder, message: string): Uint8Array {
152
+ const error = {
153
+ type: "upstream_error",
154
+ code: "upstream_server_error",
155
+ message: redactSecretString(message).slice(0, MAX_TAIL_ERROR_MESSAGE_CHARS),
156
+ };
157
+ return encoder.encode(`event: response.failed\ndata: ${JSON.stringify({
158
+ type: "response.failed",
159
+ response: { status: "failed", error, last_error: error },
160
+ })}\n\n`);
161
+ }
162
+
163
+ function boundedBareUpstreamErrorMessage(payload: unknown): string | undefined {
164
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)
165
+ || (payload as { type?: unknown }).type !== "error") return undefined;
166
+ const message = upstreamErrorMessageFromPayload(payload);
167
+ return message ? redactSecretString(message).slice(0, MAX_TAIL_ERROR_MESSAGE_CHARS) : undefined;
168
+ }
169
+
150
170
  export type SseTerminalOutputBoundary = {
151
171
  feed(chunk: Uint8Array): Uint8Array;
152
172
  finish(): Uint8Array;
153
173
  terminalSeen(): boolean;
154
174
  doneSeen(): boolean;
175
+ upstreamError(): string | undefined;
155
176
  dispose(): void;
156
177
  };
157
178
 
@@ -170,6 +191,7 @@ export function createSseTerminalOutputBoundary(): SseTerminalOutputBoundary {
170
191
  let done = false;
171
192
  let pendingDone: { block: Uint8Array; delimiter: Uint8Array } | null = null;
172
193
  let disposed = false;
194
+ let upstreamError: string | undefined;
173
195
 
174
196
  const processFrames = (
175
197
  frames: ReturnType<BoundedSseFrameBuffer["feed"]>,
@@ -181,6 +203,10 @@ export function createSseTerminalOutputBoundary(): SseTerminalOutputBoundary {
181
203
  const payload = sseDataPayload(decoder.decode(frame.block));
182
204
  const isDone = payload === "[DONE]";
183
205
  const parsed = payload === null ? undefined : parseSsePayload(payload);
206
+ // Observe on the client reader itself: a tee inspection branch may lag
207
+ // behind EOF, so its log context cannot determine the outgoing terminal.
208
+ const message = boundedBareUpstreamErrorMessage(parsed);
209
+ if (message !== undefined) upstreamError = message;
184
210
  const policyError = parsed !== undefined && isPolicyRewriteType(parsed)
185
211
  ? cyberPolicyTerminalError(parsed)
186
212
  : undefined;
@@ -239,6 +265,7 @@ export function createSseTerminalOutputBoundary(): SseTerminalOutputBoundary {
239
265
  },
240
266
  terminalSeen: () => terminal,
241
267
  doneSeen: () => done,
268
+ upstreamError: () => upstreamError,
242
269
  dispose() {
243
270
  if (disposed) return;
244
271
  disposed = true;
@@ -260,6 +287,7 @@ export function relaySseWithFailedTail(
260
287
  body: ReadableStream<Uint8Array>,
261
288
  upstream: AbortController,
262
289
  onClientGone?: (reason?: unknown) => void,
290
+ opts?: { upstreamError?: string },
263
291
  ): ReadableStream<Uint8Array> {
264
292
  const reader = body.getReader();
265
293
  const encoder = new TextEncoder();
@@ -306,8 +334,10 @@ export function relaySseWithFailedTail(
306
334
  // A clean upstream EOF is still a failed Responses turn when no
307
335
  // protocol terminal arrived. Make that state explicit so Codex
308
336
  // does not treat HTTP 200 + bare EOF as a retryable disconnect.
309
- const incomplete = adapterEofIncompleteFrame(encoder);
310
- controller.enqueue(incomplete);
337
+ const upstreamError = terminalBoundary.upstreamError() ?? opts?.upstreamError;
338
+ controller.enqueue(upstreamError === undefined
339
+ ? adapterEofIncompleteFrame(encoder)
340
+ : upstreamErrorTailFrame(encoder, upstreamError));
311
341
  controller.enqueue(doneFrame(encoder));
312
342
  }
313
343
  terminalBoundary.dispose();
@@ -1354,11 +1384,16 @@ export function consumeForInspection(
1354
1384
  options?: InspectionConsumerOptions,
1355
1385
  ): void {
1356
1386
  const reader = body.getReader();
1387
+ let bareUpstreamError: string | undefined;
1357
1388
  const inspector = (options?.inspectorFactory ?? createSseInspector)({
1358
1389
  onTerminal,
1359
1390
  logCtx,
1360
1391
  onCompletedResponse,
1361
- onParsedPayload: options?.onParsedPayload,
1392
+ onParsedPayload: payload => {
1393
+ const message = boundedBareUpstreamErrorMessage(payload);
1394
+ if (message !== undefined) bareUpstreamError = message;
1395
+ options?.onParsedPayload?.(payload);
1396
+ },
1362
1397
  onFirstOutput,
1363
1398
  pinCompletedResponseIdToFirstSeen: options?.pinCompletedResponseIdToFirstSeen,
1364
1399
  });
@@ -1372,7 +1407,11 @@ export function consumeForInspection(
1372
1407
  onCleanEof: () => {
1373
1408
  if (!inspector.reported()) {
1374
1409
  if (logCtx) logCtx.terminalSource = "synthetic";
1375
- onTerminal("incomplete");
1410
+ if (bareUpstreamError !== undefined) {
1411
+ onTerminal("failed", httpStatusForRequestLogTerminal("failed", logCtx));
1412
+ } else {
1413
+ onTerminal("incomplete");
1414
+ }
1376
1415
  }
1377
1416
  },
1378
1417
  onReadError: () => {
@@ -12,7 +12,7 @@ import {
12
12
  } from "../lib/errors";
13
13
  import { CODEX_CONFIG_PATH, readRootTomlString } from "../codex/paths";
14
14
  import { readCodexCatalogPath } from "../codex/catalog";
15
- import type { AttemptTierOutcome, OcxUsage } from "../types";
15
+ import type { AttemptTierOutcome, OcxProviderConfig, OcxUsage } from "../types";
16
16
  import { normalizeRouteDecisionTrace, type RouteDecisionTraceV1 } from "../routing/trace";
17
17
  import type { AdapterRequest } from "../adapters/base";
18
18
  import type { AdapterTierMetadata } from "../providers/fastwire";
@@ -1211,11 +1211,39 @@ export function sealRequestAttemptIdentity(
1211
1211
  accountLogLabel?: string,
1212
1212
  ): void {
1213
1213
  if (!attempt) return;
1214
+ if (attempt.provider !== provider || attempt.adapter !== adapter) delete attempt.credentialSource;
1214
1215
  attempt.provider = provider;
1215
1216
  attempt.adapter = adapter;
1216
1217
  if (isCodexUsageAccountLogLabel(accountLogLabel)) attempt.accountLogLabel = accountLogLabel;
1217
1218
  }
1218
1219
 
1220
+ /** Capture only the resolved upstream route; inbound auth and today's config cannot label old usage. */
1221
+ export function recordAttemptCredentialSource(
1222
+ attempt: PersistedUsageAttempt | undefined,
1223
+ providerName: string,
1224
+ provider: Pick<OcxProviderConfig, "authMode" | "baseUrl" | "adapter">,
1225
+ adapterName: string = provider.adapter,
1226
+ ): void {
1227
+ if (!attempt) return;
1228
+ // Rebinding an attempt to an unrecognized route must not retain its previous attribution.
1229
+ delete attempt.credentialSource;
1230
+ if (providerName !== "xai"
1231
+ || !["openai-chat", "openai-responses"].includes(adapterName)) return;
1232
+ try {
1233
+ const url = new URL(provider.baseUrl ?? "");
1234
+ if (url.protocol !== "https:" || url.port || url.username || url.password
1235
+ || url.search || url.hash || !["/v1", "/v1/"].includes(url.pathname)) return;
1236
+ if (provider.authMode === "oauth" && url.hostname === "cli-chat-proxy.grok.com") {
1237
+ attempt.credentialSource = "grok-oauth";
1238
+ } else if ((provider.authMode === "key" || provider.authMode === undefined)
1239
+ && url.hostname === "api.x.ai") {
1240
+ attempt.credentialSource = "xai-api-key";
1241
+ }
1242
+ } catch {
1243
+ // Invalid/custom destinations have no known subscription provenance.
1244
+ }
1245
+ }
1246
+
1219
1247
  export function noteAttemptSend(
1220
1248
  attempt: PersistedUsageAttempt | undefined,
1221
1249
  inputTokenEstimate: number | undefined,
@@ -149,3 +149,14 @@ export function agentTaskRecoveryWaiterCountForTests(): number {
149
149
  export function agentTaskRecoveryCacheSnapshotForTests(): { entries: number; bytes: number } {
150
150
  return { entries: RECOVERY_CACHE.size, bytes: recoveryCacheBytes };
151
151
  }
152
+
153
+ /** Read an existing recovery without starting a request or extending its lifetime. */
154
+ export function cachedAgentTaskRecovery(key: string): string | null {
155
+ const entry = RECOVERY_CACHE.get(key);
156
+ if (!entry) return null;
157
+ if (entry.expiresAt <= Date.now()) {
158
+ deleteRecoveryCacheEntry(key, entry);
159
+ return null;
160
+ }
161
+ return entry.assignment;
162
+ }
@@ -5,6 +5,7 @@ import { readBoundedResponseBody } from "../../lib/bounded-body";
5
5
  import { isApiAuthRequired, isProxyAdmissionSecret } from "../auth-cors";
6
6
  import { structurallyValidFernetTokens } from "./encrypted-payload";
7
7
  import {
8
+ cachedAgentTaskRecovery,
8
9
  discardCachedAgentTaskRecovery,
9
10
  resetAgentTaskRecoveryCache,
10
11
  resolveCachedAgentTaskRecovery,
@@ -61,7 +62,7 @@ interface AgentEnvelope {
61
62
  itemIndex: number;
62
63
  encryptedIndex: number;
63
64
  headerText: string;
64
- messageType: "NEW_TASK";
65
+ messageType: "NEW_TASK" | "MESSAGE";
65
66
  taskName: string;
66
67
  sender: string;
67
68
  ciphertext: string;
@@ -69,7 +70,7 @@ interface AgentEnvelope {
69
70
  recipient: string;
70
71
  }
71
72
 
72
- const ROUTING_HEADER = /(?:^|\n)Message Type\s*:\s*(NEW_TASK)\s*\nTask name\s*:\s*(\S+)\s*\nSender\s*:\s*(\S+)\s*\nPayload\s*:\s*(?:\n|$)/;
73
+ const ROUTING_HEADER = /(?:^|\n)Message Type\s*:\s*(NEW_TASK|MESSAGE)\s*\nTask name\s*:\s*(\S+)\s*\nSender\s*:\s*(\S+)\s*\nPayload\s*:\s*(?:\n|$)/;
73
74
 
74
75
  function findEnvelope(input: unknown): AgentEnvelope | null {
75
76
  if (!Array.isArray(input)) return null;
@@ -90,7 +91,7 @@ function findEnvelope(input: unknown): AgentEnvelope | null {
90
91
  if (!Array.isArray(content)) return null;
91
92
 
92
93
  let headerText: string | null = null;
93
- let messageType: "NEW_TASK" | null = null;
94
+ let messageType: "NEW_TASK" | "MESSAGE" | null = null;
94
95
  let taskName: string | null = null;
95
96
  let sender: string | null = null;
96
97
  let encryptedIndex = -1;
@@ -113,7 +114,7 @@ function findEnvelope(input: unknown): AgentEnvelope | null {
113
114
  || part.text.slice(match.index + match[0].length).trim().length > 0
114
115
  ) return null;
115
116
  headerText = match[0].startsWith("\n") ? match[0].slice(1) : match[0];
116
- messageType = "NEW_TASK";
117
+ messageType = match[1] as "NEW_TASK" | "MESSAGE";
117
118
  taskName = match[2]!;
118
119
  sender = match[3]!;
119
120
  }
@@ -496,3 +497,22 @@ export function discardEncryptedAgentTaskRecovery(
496
497
  export function resetAgentTaskRecoveryState(): void {
497
498
  resetAgentTaskRecoveryCache();
498
499
  }
500
+
501
+ /** Codex replays the original encrypted agent messages after tool calls. Reuse only an admitted cache hit. */
502
+ export function restoreCachedEncryptedAgentTasks(
503
+ req: Request, input: unknown, config: OcxConfig,
504
+ context: { parentThreadId?: string | null } = {},
505
+ ): number {
506
+ if (!Array.isArray(input)) return 0;
507
+ let restored = 0;
508
+ for (const item of input) {
509
+ if (!item || typeof item !== "object" || item.type !== "agent_message") continue;
510
+ const single = [item];
511
+ // Revalidates caller credentials and the exact supported agent envelope before cache access.
512
+ const admitted = admittedRecovery(req, single, config, context.parentThreadId);
513
+ if (!admitted) continue;
514
+ const assignment = cachedAgentTaskRecovery(admitted.cacheKey);
515
+ if (assignment && injectAssignment(single, admitted.envelope, assignment)) restored += 1;
516
+ }
517
+ return restored;
518
+ }
@@ -25,7 +25,7 @@ function digest(input: unknown): string {
25
25
  }
26
26
 
27
27
  /** Identity comes from the selected outgoing request, never a model label or caller hint. */
28
- export function codexWsReuseIdentity(url: string, headers: Record<string, string>, frameText: string): CodexWsReuseIdentity | null {
28
+ export function codexWsReuseIdentity(url: string, headers: Record<string, string>, frameText: string, proxy?: string): CodexWsReuseIdentity | null {
29
29
  if (url !== CODEX_RESPONSES_HTTP_URL) return null;
30
30
  let body: unknown;
31
31
  try { body = JSON.parse(frameText); } catch { return null; }
@@ -52,7 +52,7 @@ export function codexWsReuseIdentity(url: string, headers: Record<string, string
52
52
  const scope = digest([url, account, thread, turn]);
53
53
  const lite = metadata.ws_request_header_x_openai_internal_codex_responses_lite;
54
54
  if (lite !== undefined && lite !== "true" && lite !== "false") return null;
55
- return { scope, key: digest([scope, authorization, body.model, body.service_tier ?? null, lite ?? null, immutable]) };
55
+ return { scope, key: digest([scope, authorization, body.model, body.service_tier ?? null, lite ?? null, immutable, proxy ?? null]) };
56
56
  }
57
57
 
58
58
  interface Entry { identity: CodexWsReuseIdentity; session: CodexWsSession; createdAt: number; idleAt: number; retired: boolean }
@@ -75,7 +75,7 @@ export class CodexWsPool {
75
75
  this.maxAgeMs = options.maxAgeMs ?? CODEX_WS_POOL_MAX_AGE_MS;
76
76
  }
77
77
 
78
- acquire(identity: CodexWsReuseIdentity, url: string, headers: Record<string, string>): CodexWsSession | null {
78
+ acquire(identity: CodexWsReuseIdentity, url: string, headers: Record<string, string>, proxy?: string): CodexWsSession | null {
79
79
  this.sweep();
80
80
  for (const entry of this.entries.values()) {
81
81
  if (entry.identity.scope !== identity.scope || entry.identity.key === identity.key) continue;
@@ -94,7 +94,7 @@ export class CodexWsPool {
94
94
  this.remove(oldest);
95
95
  }
96
96
  const createdAt = this.now();
97
- const session = new CodexWsSession(url, headers, true, () => this.changed(entry));
97
+ const session = new CodexWsSession(url, headers, true, () => this.changed(entry), proxy);
98
98
  const entry: Entry = { identity, session, createdAt, idleAt: createdAt, retired: false };
99
99
  session.reserve();
100
100
  this.entries.set(identity.key, entry);
@@ -10,8 +10,8 @@ export class CodexWsSession {
10
10
  private readonly completedIds = new Set<string>();
11
11
 
12
12
  constructor(url: string, headers: Record<string, string>, readonly retainable = false,
13
- private readonly changed: () => void = () => {}) {
14
- this.socket = new WebSocket(url, { headers } as unknown as string[]);
13
+ private readonly changed: () => void = () => {}, proxy?: string) {
14
+ this.socket = new WebSocket(url, { headers, ...(proxy ? { proxy } : {}) } as unknown as string[]);
15
15
  this.socket.addEventListener("open", this.onOpen);
16
16
  this.socket.addEventListener("message", this.onIdleMessage);
17
17
  this.socket.addEventListener("close", this.onClose);
@@ -7,6 +7,7 @@ import {
7
7
  resolveEnvValue,
8
8
  } from "../../config";
9
9
  import { parseRequest } from "../../responses/parser";
10
+ import { externalTaskInputContent } from "../../responses/task-input";
10
11
  import { buildCompactV1Output, COMPACT_PROMPT, decodeCompactionSummary, extractCompactUserMessages } from "../../responses/compaction";
11
12
  import { FORWARD_HEADERS, sanitizeReasoningInputContent } from "../../adapters/openai-responses";
12
13
  import { expandPreviousResponseInput, previousResponseProviderState, rememberResponseState } from "../../responses/state";
@@ -558,7 +559,7 @@ function leadingDeveloperPrefixLength(items: readonly unknown[]): number {
558
559
 
559
560
  function isConversationalItem(item: unknown): boolean {
560
561
  if (!isRecord(item)) return false;
561
- if (item.type === "agent_message") return true;
562
+ if (item.type === "agent_message" || externalTaskInputContent(item) !== undefined) return true;
562
563
  const type = item.type ?? (typeof item.role === "string" ? "message" : undefined);
563
564
  return type === "message" && (item.role === "user" || item.role === "assistant");
564
565
  }
@@ -135,9 +135,13 @@ export type ComboStreamPreflightResult =
135
135
  export async function preflightComboStreamResponse(
136
136
  response: Response,
137
137
  logCtx: RequestLogContext,
138
+ retryableTerminal: (payload: unknown) => boolean = retryableZeroOutputTerminal,
139
+ options?: { allowMissingContentType?: boolean; replayReadErrors?: boolean },
138
140
  ): Promise<ComboStreamPreflightResult> {
139
141
  const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
140
- if (!response.ok || !response.body || !contentType.includes("text/event-stream")) {
142
+ const isEventStream = contentType.includes("text/event-stream")
143
+ || (!contentType && options?.allowMissingContentType === true);
144
+ if (!response.ok || !response.body || !isEventStream) {
141
145
  return { kind: "accepted", response };
142
146
  }
143
147
 
@@ -150,18 +154,30 @@ export async function preflightComboStreamResponse(
150
154
  const inspector = createSseInspector({
151
155
  logCtx,
152
156
  onParsedPayload: payload => {
153
- if (comboStreamPayloadCommitsOutput(payload)) outputCommitted = true;
157
+ const retryable = retryableTerminal(payload);
158
+ const matchedBareError = retryable && payload !== null && typeof payload === "object"
159
+ && !Array.isArray(payload) && (payload as { type?: unknown }).type === "error";
160
+ // Only an explicit caller predicate may opt a known bare error into replay.
161
+ // Default combo classification still commits unknown/error events.
162
+ if (comboStreamPayloadCommitsOutput(payload) && !matchedBareError) outputCommitted = true;
154
163
  if (!payload || typeof payload !== "object" || Array.isArray(payload)) return;
155
- if (retryableZeroOutputTerminal(payload)) {
156
- retryableTerminalPayload = payload as Record<string, unknown>;
157
- }
164
+ if (retryable) retryableTerminalPayload = payload as Record<string, unknown>;
158
165
  },
159
166
  onTerminal: status => { terminalStatus = status; },
160
167
  });
161
168
 
162
169
  try {
163
170
  for (;;) {
164
- const next = await reader.read();
171
+ let next: Awaited<ReturnType<typeof reader.read>>;
172
+ try {
173
+ next = await reader.read();
174
+ } catch (error) {
175
+ if (!options?.replayReadErrors) throw error;
176
+ // The native relay still owns post-header transport failures. Preserve
177
+ // the bounded prefix and the errored reader; cancelling it here would
178
+ // erase the failure before either client relay or inspection sees it.
179
+ return { kind: "accepted", response: replayBufferedResponse(response, reader, buffered) };
180
+ }
165
181
  if (next.done) {
166
182
  inspector.finish();
167
183
  } else {
@@ -180,7 +196,10 @@ export async function preflightComboStreamResponse(
180
196
  inspector.feed(retained);
181
197
  }
182
198
 
183
- if ((terminalStatus === "failed" || terminalStatus === "incomplete")
199
+ // A bare error event is not a protocol terminal (terminalStatus stays undefined),
200
+ // so its exact-message retryable match doubles as the terminal evidence.
201
+ if ((terminalStatus === "failed" || terminalStatus === "incomplete"
202
+ || retryableTerminalPayload?.type === "error")
184
203
  && !outputCommitted && retryableTerminalPayload) {
185
204
  await reader.cancel("retrying zero-output combo stream terminal").catch(() => undefined);
186
205
  return { kind: "failed", response: failedTerminalResponse(response, retryableTerminalPayload, logCtx) };