@bitkyc08/opencodex 2.75.0 → 2.76.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 (217) hide show
  1. package/README.md +6 -1
  2. package/bin/ocx.mjs +12 -1
  3. package/gui/dist/assets/App-CddorHdA.js +51 -0
  4. package/gui/dist/assets/App-DMudtISW.css +1 -0
  5. package/gui/dist/assets/{Tray-CGAD2AGk.js → Tray-DjNwwrWS.js} +1 -1
  6. package/gui/dist/assets/index-Btze1I3T.css +1 -0
  7. package/gui/dist/assets/index-CvnY7E6i.js +86 -0
  8. package/gui/dist/assets/quota-summary-B_AWpHfP.js +1 -0
  9. package/gui/dist/index.html +2 -2
  10. package/gui/dist/provider-icons/opengateway.svg +12 -0
  11. package/package.json +1 -1
  12. package/src/adapters/anthropic/account-metadata.ts +17 -0
  13. package/src/adapters/anthropic/client-identity.ts +69 -0
  14. package/src/adapters/anthropic/passthrough.ts +23 -4
  15. package/src/adapters/anthropic.ts +18 -1
  16. package/src/adapters/command-code-project-context.ts +7 -11
  17. package/src/adapters/devin/cloud-direct/tool-schema.ts +12 -48
  18. package/src/adapters/kiro-retry.ts +23 -8
  19. package/src/adapters/openai-chat/messages.ts +1 -1
  20. package/src/adapters/registry.ts +8 -1
  21. package/src/adapters/zed.ts +377 -0
  22. package/src/bridge/sse.ts +4 -2
  23. package/src/chatgpt/app-server-shim/app-server-rewrite.ts +45 -0
  24. package/src/chatgpt/app-server-shim/bundle-trust.ts +77 -0
  25. package/src/chatgpt/app-server-shim/filter.ts +157 -0
  26. package/src/chatgpt/app-server-shim/gate-rewrite.ts +99 -0
  27. package/src/chatgpt/app-server-shim/launcher.ts +91 -0
  28. package/src/claude/desktop-3p-startup.ts +62 -0
  29. package/src/claude/desktop-3p.ts +5 -0
  30. package/src/claude/first-party-settings.ts +14 -0
  31. package/src/claude/intercept/cli-catalog.ts +150 -0
  32. package/src/claude/intercept/cli-picker.ts +82 -0
  33. package/src/claude/intercept/listener.ts +21 -6
  34. package/src/claude/intercept/picker-bootstrap.ts +23 -5
  35. package/src/claude/intercept/picker-models.ts +57 -13
  36. package/src/claude/intercept/runtime.ts +30 -2
  37. package/src/claude/intercept/settings.ts +3 -0
  38. package/src/cli/agent.ts +172 -0
  39. package/src/cli/alias.ts +2 -1
  40. package/src/cli/capabilities-command.ts +3 -2
  41. package/src/cli/capabilities.ts +87 -0
  42. package/src/cli/chatgpt-command.ts +140 -0
  43. package/src/cli/combo.ts +144 -19
  44. package/src/cli/config-command.ts +3 -1
  45. package/src/cli/dispatch.ts +10 -1
  46. package/src/cli/help.ts +1 -0
  47. package/src/cli/index.ts +5 -24
  48. package/src/cli/init.ts +16 -2
  49. package/src/cli/integrations.ts +13 -0
  50. package/src/cli/internal-command.ts +12 -5
  51. package/src/cli/observe.ts +6 -1
  52. package/src/cli/registry.ts +6 -0
  53. package/src/cli/route-policy.ts +3 -1
  54. package/src/cli/runtime-api.ts +5 -2
  55. package/src/cli/status-oauth.ts +5 -0
  56. package/src/cli/status.ts +8 -1
  57. package/src/cli/stop-approval.ts +55 -16
  58. package/src/cli/stop-report.ts +5 -0
  59. package/src/clients/lazycodex.ts +48 -0
  60. package/src/clients/omo-role-models.ts +111 -0
  61. package/src/codex/account-credit-use.ts +99 -0
  62. package/src/codex/account-lifecycle.ts +2 -0
  63. package/src/codex/agent-role-models.ts +171 -0
  64. package/src/codex/auth-api/account-list.ts +10 -3
  65. package/src/codex/auth-api/routes.ts +34 -0
  66. package/src/codex/auth-context.ts +52 -11
  67. package/src/codex/catalog/discovered-natives.ts +11 -1
  68. package/src/codex/catalog/gather-capture.ts +6 -1
  69. package/src/codex/catalog/model-hints.ts +9 -1
  70. package/src/codex/catalog/provider-models.ts +60 -0
  71. package/src/codex/catalog/routed-gather.ts +30 -5
  72. package/src/codex/convergence-types.ts +2 -1
  73. package/src/codex/main-account-external-usage.ts +62 -0
  74. package/src/codex/main-account-hard-lock.ts +24 -13
  75. package/src/codex/native-profile-processes.ts +53 -16
  76. package/src/codex/prompt-layers/toml-edit.ts +1 -1
  77. package/src/codex/quota-types.ts +3 -7
  78. package/src/codex/quota.ts +19 -4
  79. package/src/codex/role-auto-assign.ts +159 -0
  80. package/src/codex/role-sizing-limits.ts +2 -0
  81. package/src/codex/role-sizing.ts +216 -0
  82. package/src/codex/routing/selection.ts +36 -0
  83. package/src/codex/routing/thread-affinity.ts +2 -0
  84. package/src/codex/routing.ts +4 -4
  85. package/src/codex/subagent-model-fallback.ts +82 -26
  86. package/src/codex/subagent-selectable-models.ts +32 -0
  87. package/src/combos/index.ts +18 -0
  88. package/src/combos/jev-decision-contract.ts +20 -0
  89. package/src/combos/jev-dispatch.ts +18 -0
  90. package/src/combos/jev-model-backend.ts +127 -0
  91. package/src/combos/jev.ts +249 -33
  92. package/src/combos/types.ts +125 -2
  93. package/src/config/diagnostics.ts +18 -0
  94. package/src/config/macos-system-proxy.ts +72 -15
  95. package/src/config/owner-registry.ts +13 -4
  96. package/src/config/process-state.ts +17 -8
  97. package/src/config/proxy-env.ts +35 -5
  98. package/src/config/schema/config-schema.ts +30 -1
  99. package/src/config/schema/leaf-validators.ts +4 -0
  100. package/src/config/serving-runtimes.ts +95 -1
  101. package/src/generated/compatibility-version.json +336 -156
  102. package/src/lib/errors.ts +7 -7
  103. package/src/lib/jsonc.ts +13 -0
  104. package/src/lib/local-chat-completion.ts +144 -0
  105. package/src/lib/provider-outbound.ts +87 -1
  106. package/src/lib/provider-runtime-fetch.ts +23 -0
  107. package/src/lib/provider-tls-profile.ts +221 -0
  108. package/src/lib/request-execution-budget.ts +16 -6
  109. package/src/lib/spend-reservation-ledger.ts +54 -20
  110. package/src/lib/sse-decoder.ts +20 -17
  111. package/src/lib/synced-state-location.ts +96 -0
  112. package/src/lib/winsw.ts +14 -5
  113. package/src/oauth/anthropic-account-refusal.ts +53 -4
  114. package/src/oauth/anthropic-model-quota.ts +83 -0
  115. package/src/oauth/anthropic-rate-limit-policy.ts +37 -0
  116. package/src/oauth/anthropic-routing.ts +112 -58
  117. package/src/oauth/health.ts +39 -3
  118. package/src/oauth/index.ts +14 -0
  119. package/src/oauth/kiro-account-load.ts +10 -0
  120. package/src/oauth/zed.ts +134 -0
  121. package/src/providers/model-discovery.ts +19 -8
  122. package/src/providers/model-rename-fields.ts +1 -0
  123. package/src/providers/provider-id-rewrite.ts +9 -2
  124. package/src/providers/quota/account-cache.ts +29 -7
  125. package/src/providers/quota/anthropic-cooldown-recovery.ts +18 -0
  126. package/src/providers/quota/anthropic-family-headers.ts +25 -0
  127. package/src/providers/quota/vendor-probes-oauth.ts +5 -1
  128. package/src/providers/quota-types.ts +4 -0
  129. package/src/providers/registry/entries-core.ts +18 -0
  130. package/src/providers/registry/entries-extended.ts +55 -0
  131. package/src/providers/registry/types.ts +3 -0
  132. package/src/providers/xai-fast-model.ts +12 -4
  133. package/src/providers/zed.ts +598 -0
  134. package/src/responses/state/snapshot-select.ts +22 -9
  135. package/src/responses/state.ts +2 -3
  136. package/src/router.ts +9 -0
  137. package/src/server/anthropic-rate-limit-headers.ts +7 -0
  138. package/src/server/auth-cors.ts +7 -0
  139. package/src/server/chat-completions.ts +9 -4
  140. package/src/server/claude-messages.ts +59 -21
  141. package/src/server/index/claude-intercept-lifecycle.ts +71 -25
  142. package/src/server/index/optional-listeners.ts +4 -0
  143. package/src/server/index/spend-ledger-lifecycle.ts +4 -0
  144. package/src/server/management/agent-settings-routes.ts +74 -37
  145. package/src/server/management/claude-desktop-picker-routes.ts +7 -0
  146. package/src/server/management/claude-intercept-routes.ts +35 -0
  147. package/src/server/management/codex-agent-role-routes.ts +122 -0
  148. package/src/server/management/codex-role-auto-assign.ts +208 -0
  149. package/src/server/management/combo-routes.ts +62 -5
  150. package/src/server/management/config-routes.ts +35 -3
  151. package/src/server/management/context.ts +4 -1
  152. package/src/server/management/decision-discovery.ts +74 -0
  153. package/src/server/management/decision-model-validation.ts +80 -0
  154. package/src/server/management/decision-routes.ts +160 -0
  155. package/src/server/management/native-integration-routes.ts +16 -0
  156. package/src/server/management/provider-routes.ts +30 -34
  157. package/src/server/management/route-registry.ts +12 -0
  158. package/src/server/management/sibling-guard.ts +1 -0
  159. package/src/server/management/zed-provider-probe.ts +38 -0
  160. package/src/server/management-api.ts +12 -1
  161. package/src/server/messages-native-oauth.ts +7 -2
  162. package/src/server/messages-native.ts +7 -3
  163. package/src/server/relay.ts +3 -1
  164. package/src/server/request-log.ts +2 -0
  165. package/src/server/responses/adapter-continuation.ts +3 -3
  166. package/src/server/responses/adapter-dispatch.ts +18 -3
  167. package/src/server/responses/compact.ts +14 -1
  168. package/src/server/responses/core-auth.ts +2 -0
  169. package/src/server/responses/core-combo.ts +67 -11
  170. package/src/server/responses/core-normalize.ts +21 -1
  171. package/src/server/responses/core-options.ts +11 -1
  172. package/src/server/responses/core.ts +5 -3
  173. package/src/server/responses/fetch-helpers.ts +6 -3
  174. package/src/server/responses/jev-model-invoke.ts +190 -0
  175. package/src/server/responses/passthrough-delivery.ts +16 -0
  176. package/src/server/responses/policy-fallback.ts +55 -3
  177. package/src/server/responses/policy-request-scope.ts +80 -0
  178. package/src/server/responses/request-prepare.ts +70 -18
  179. package/src/server/responses/request-transport.ts +54 -23
  180. package/src/server/responses/run-turn-execution.ts +1 -0
  181. package/src/server/responses/sidecar-execution.ts +9 -3
  182. package/src/server/responses-hosted-image-display.ts +225 -0
  183. package/src/service/managing-cli.ts +34 -4
  184. package/src/service/systemd-env.ts +75 -0
  185. package/src/service/windows-guarded-manager.ts +60 -19
  186. package/src/service/windows-scheduler.ts +22 -3
  187. package/src/service/windows-taskxml.ts +32 -10
  188. package/src/service-manager-probe.ts +72 -19
  189. package/src/types/config.ts +31 -1
  190. package/src/types/provider.ts +2 -0
  191. package/src/types/request.ts +2 -0
  192. package/src/update/index.ts +39 -6
  193. package/src/update/job.ts +63 -63
  194. package/src/update/npm-cache-preflight.d.mts +33 -1
  195. package/src/update/npm-cache-preflight.mjs +131 -18
  196. package/src/update/ownership-transaction.ts +19 -1
  197. package/src/update/restart-ownership.ts +80 -6
  198. package/src/update/transactional-install.d.mts +2 -0
  199. package/src/update/transactional-install.mjs +6 -1
  200. package/src/usage/jev-stats.ts +28 -1
  201. package/src/usage/log.ts +1 -1
  202. package/src/vision/anthropic-describe.ts +5 -3
  203. package/src/vision/index.ts +1 -0
  204. package/src/vision/plan.ts +2 -1
  205. package/src/vision/routed-describe.ts +29 -121
  206. package/src/web-search/alpha-search.ts +3 -3
  207. package/src/web-search/anthropic-executor.ts +6 -4
  208. package/src/web-search/index.ts +1 -1
  209. package/src/web-search/loop.ts +3 -3
  210. package/src/web-search/passthrough-bridge.ts +2 -1
  211. package/src/web-search/run-turn-loop.ts +1 -1
  212. package/src/web-search/sidecar-providers.ts +2 -1
  213. package/gui/dist/assets/App-ia4U0ufY.css +0 -1
  214. package/gui/dist/assets/App-xm0zGNOe.js +0 -51
  215. package/gui/dist/assets/index-DBT3Q31Y.css +0 -1
  216. package/gui/dist/assets/index-D_RGAT6Q.js +0 -86
  217. package/gui/dist/assets/quota-summary-CUFq48t-.js +0 -1
@@ -0,0 +1,190 @@
1
+ import { readBoundedResponseBytes } from "../../lib/bounded-body";
2
+ import { JevModelInvokeError, type JevModelInvoke, type JevModelInvokeResult } from "../../combos/jev-model-backend";
3
+ import { JEV_MAX_REQUEST_BYTES, JEV_MAX_RESPONSE_BYTES } from "../../combos/jev";
4
+ import type { OcxConfig } from "../../types";
5
+ import { codexEffortRank, isCodexReasoningEffort } from "../../reasoning-effort";
6
+ import { routeConcreteModel } from "../../router";
7
+ import { supportedLadderFor } from "../effort-policy";
8
+ import { createInferenceSendBudget } from "../inference/context";
9
+ import { tryAdmitTurn } from "../lifecycle";
10
+ import type { RequestLogContext } from "../request-log";
11
+ import { createSseInspector } from "../relay";
12
+ import type { HandleResponsesOptions } from "./core-options";
13
+
14
+ type HandleResponses = (
15
+ req: Request,
16
+ config: OcxConfig,
17
+ logCtx: RequestLogContext,
18
+ options?: HandleResponsesOptions,
19
+ ) => Promise<Response>;
20
+
21
+ export interface JevModelInvokerContext {
22
+ /** The request that owns the combo; only its spend root is reused, never its headers. */
23
+ req: Pick<Request, "headers">;
24
+ config: OcxConfig;
25
+ /** Parent options: only the typed admission scope and Codex auth policy cross over. */
26
+ options: Pick<HandleResponsesOptions, "admission" | "codexAuthPolicy">;
27
+ handleResponses: HandleResponses;
28
+ }
29
+
30
+ function isRecord(value: unknown): value is Record<string, unknown> {
31
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
32
+ }
33
+
34
+ /** Concatenate assistant message text from a Responses output array. */
35
+ function messageOutputText(output: unknown): string | undefined {
36
+ if (!Array.isArray(output)) return undefined;
37
+ const parts: string[] = [];
38
+ for (const item of output) {
39
+ if (!isRecord(item) || item.type !== "message" || !Array.isArray(item.content)) continue;
40
+ for (const part of item.content) {
41
+ if (isRecord(part) && part.type === "output_text" && typeof part.text === "string") parts.push(part.text);
42
+ }
43
+ }
44
+ return parts.length > 0 ? parts.join("") : undefined;
45
+ }
46
+
47
+ function responseUsage(response: Record<string, unknown>): Record<string, number> | undefined {
48
+ if (!isRecord(response.usage)) return undefined;
49
+ const usage: Record<string, number> = {};
50
+ for (const key of ["input_tokens", "output_tokens"] as const) {
51
+ const value = response.usage[key];
52
+ if (typeof value === "number" && Number.isSafeInteger(value) && value >= 0) usage[key] = value;
53
+ }
54
+ return Object.keys(usage).length > 0 ? usage : undefined;
55
+ }
56
+
57
+ function terminalResult(response: Record<string, unknown>, deltaText: string): JevModelInvokeResult {
58
+ if (response.error !== undefined && response.error !== null) throw new JevModelInvokeError("http", "decision model failed");
59
+ if (response.status !== "completed") throw new JevModelInvokeError("malformed", "decision model did not complete");
60
+ const text = messageOutputText(response.output) ?? deltaText;
61
+ const usage = responseUsage(response);
62
+ return { text, ...(usage ? { usage } : {}) };
63
+ }
64
+
65
+ function decodeSse(bytes: Uint8Array): JevModelInvokeResult {
66
+ let completed: Record<string, unknown> | undefined;
67
+ let terminal: string | undefined;
68
+ let deltaText = "";
69
+ const inspector = createSseInspector({
70
+ onTerminal: status => { terminal ??= status; },
71
+ onCompletedResponse: value => { if (isRecord(value)) completed = value; },
72
+ onParsedPayload: payload => {
73
+ if (isRecord(payload) && payload.type === "response.output_text.delta" && typeof payload.delta === "string") {
74
+ deltaText += payload.delta;
75
+ }
76
+ },
77
+ });
78
+ try {
79
+ inspector.feed(bytes);
80
+ inspector.finish();
81
+ } finally {
82
+ inspector.dispose();
83
+ }
84
+ if (terminal !== "completed" || !completed) {
85
+ throw new JevModelInvokeError(terminal === "failed" ? "http" : "malformed", "decision model did not complete");
86
+ }
87
+ return terminalResult({ ...completed, status: completed.status ?? "completed" }, deltaText);
88
+ }
89
+
90
+ /**
91
+ * The answer is one short JSON object, so the decision turn carries its own output ceiling. It
92
+ * bounds billed output (including reasoning) and gives the spend reservation a nonzero ceiling;
93
+ * a truncated answer is malformed and fails open like any other bad reply.
94
+ */
95
+ export const JEV_MODEL_MAX_OUTPUT_TOKENS = 1024;
96
+
97
+ /**
98
+ * The cheapest reasoning effort the decision model declares, so a reasoning model cannot spend
99
+ * the whole output ceiling thinking before it writes the JSON answer. A model with no known
100
+ * ladder gets no reasoning field at all: some OpenAI-compatible servers reject one they do not
101
+ * support, and an unknown capability is not evidence that the field is safe to send.
102
+ */
103
+ export function jevDecisionReasoningEffort(config: OcxConfig, model: string): string | undefined {
104
+ let ladder: string[] | undefined;
105
+ try {
106
+ const route = routeConcreteModel(config, model);
107
+ ladder = supportedLadderFor({ provider: route.provider, modelId: route.modelId });
108
+ } catch {
109
+ return undefined;
110
+ }
111
+ const rankable = (ladder ?? []).filter(isCodexReasoningEffort);
112
+ if (rankable.length === 0) return undefined;
113
+ if (rankable.includes("low")) return "low";
114
+ return rankable.reduce((lowest, effort) => (codexEffortRank(effort) < codexEffortRank(lowest) ? effort : lowest));
115
+ }
116
+
117
+ /**
118
+ * Build the invoker that runs one decision prompt as an internal Responses turn through the
119
+ * normal router. The decision request is its own logical request: a fresh turn lease and send
120
+ * budget, a detached log context whose spend tracker is settled here, and no caller credential
121
+ * or conversation header. It therefore runs on credentials configured on provider rows only.
122
+ */
123
+ export function createJevModelInvoker(context: JevModelInvokerContext): JevModelInvoke {
124
+ return async ({ model, instructions, input, signal }) => {
125
+ const effort = jevDecisionReasoningEffort(context.config, model);
126
+ const body = JSON.stringify({
127
+ model,
128
+ stream: true,
129
+ store: false,
130
+ instructions,
131
+ input: [{ role: "user", content: [{ type: "input_text", text: input }] }],
132
+ tools: [],
133
+ max_output_tokens: JEV_MODEL_MAX_OUTPUT_TOKENS,
134
+ ...(effort ? { reasoning: { effort } } : {}),
135
+ });
136
+ if (new TextEncoder().encode(body).byteLength > JEV_MAX_REQUEST_BYTES) {
137
+ throw new JevModelInvokeError("malformed", "decision request too large");
138
+ }
139
+ const lease = tryAdmitTurn();
140
+ if (!lease) throw new JevModelInvokeError("network", "decision turn not admitted");
141
+ const childLog: RequestLogContext = { model, provider: "unknown", inboundProtocol: "responses" };
142
+ let usage: Record<string, number> | undefined;
143
+ try {
144
+ const request = new Request("http://localhost/v1/responses", {
145
+ method: "POST",
146
+ headers: { "content-type": "application/json" },
147
+ body,
148
+ signal,
149
+ });
150
+ const response = await context.handleResponses(request, context.config, childLog, {
151
+ abortSignal: signal,
152
+ ...(context.options.admission ? { admission: context.options.admission } : {}),
153
+ ...(context.options.codexAuthPolicy ? { codexAuthPolicy: context.options.codexAuthPolicy } : {}),
154
+ // Explicit nulls: handleResponses captures caller credentials only when these are undefined.
155
+ callerDirectAuth: null,
156
+ openAiSidecarAuth: null,
157
+ nativeCallerAuth: null,
158
+ sendBudget: createInferenceSendBudget(context.req, childLog),
159
+ turnAdmissionLease: lease,
160
+ internalDecisionCall: true,
161
+ });
162
+ if (!response.ok) {
163
+ try { void response.body?.cancel().catch(() => undefined); } catch { /* best effort */ }
164
+ throw new JevModelInvokeError("http", `decision model returned ${response.status}`);
165
+ }
166
+ const bounded = await readBoundedResponseBytes(response, { maxBytes: JEV_MAX_RESPONSE_BYTES, signal });
167
+ if (bounded.oversized) throw new JevModelInvokeError("malformed", "decision response too large");
168
+ const result = response.headers.get("content-type")?.includes("text/event-stream")
169
+ ? decodeSse(bounded.bytes)
170
+ : (() => {
171
+ let parsed: unknown;
172
+ try {
173
+ parsed = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bounded.bytes));
174
+ } catch {
175
+ throw new JevModelInvokeError("malformed", "decision response is not JSON");
176
+ }
177
+ if (!isRecord(parsed)) throw new JevModelInvokeError("malformed", "decision response is not an object");
178
+ return terminalResult(parsed, "");
179
+ })();
180
+ usage = result.usage;
181
+ return result;
182
+ } finally {
183
+ // No final log row is written for this detached context, so settle its spend here.
184
+ childLog.spendTracker?.settle(childLog.usage ?? (usage
185
+ ? { inputTokens: usage.input_tokens ?? 0, outputTokens: usage.output_tokens ?? 0 }
186
+ : undefined));
187
+ lease.release();
188
+ }
189
+ };
190
+ }
@@ -99,6 +99,7 @@ import {
99
99
  PLAINTEXT_V2_AGENT_MESSAGE_RESTORE_OVERFLOW_MESSAGE,
100
100
  } from "../../responses/plaintext-v2-agent-messages";
101
101
  import { createResponsesFieldBackfillBlockRewrite } from "./responses-field-backfill";
102
+ import { createHostedImageDisplayRewrite, isLocalCodexImageClient } from "../responses-hosted-image-display";
102
103
  import { createResponsesFunctionToolRepairBlockRewrite } from "../responses-function-tool-repair";
103
104
  import {
104
105
  createUndeclaredToolCallGuardBlockRewrite,
@@ -840,6 +841,10 @@ export async function deliverPassthroughResponse(
840
841
  : undefined,
841
842
  createTerminalErrorRedactionBlockRewrite(nativeExchange.request.headers, maskCredential),
842
843
  rememberPlaintextBlock,
844
+ // Display-only projection must follow every continuation-cache observer.
845
+ isLocalCodexImageClient(req.headers, options.admission?.kind, options.inboundWire)
846
+ ? createHostedImageDisplayRewrite()
847
+ : undefined,
843
848
  ].filter((rewrite): rewrite is NonNullable<typeof rewrite> => rewrite !== undefined);
844
849
  const clientBlockRewrite = blockRewrites.length > 0
845
850
  ? composeSseBlockRewrites(...blockRewrites)
@@ -1318,6 +1323,17 @@ export async function deliverPassthroughResponse(
1318
1323
  JSON.parse(grokUpstreamEchoEnabled ? clientJson : text) as { id?: unknown; output?: unknown; status?: unknown; model?: unknown },
1319
1324
  );
1320
1325
  } catch { /* non-JSON despite content-type; recording is best-effort */ }
1326
+ if (isLocalCodexImageClient(req.headers, options.admission?.kind, options.inboundWire)) {
1327
+ const imageDisplay = createHostedImageDisplayRewrite();
1328
+ try {
1329
+ clientJson = imageDisplay.json(clientJson);
1330
+ } catch (error) {
1331
+ if (error instanceof RangeError) {
1332
+ return formatErrorResponse(502, "upstream_error", "hosted image result count exceeds local display limit");
1333
+ }
1334
+ throw error;
1335
+ } finally { imageDisplay.dispose?.(); }
1336
+ }
1321
1337
  // #875: the transport-neutral reliability policy forced a bounded JSON
1322
1338
  // upstream for a client that asked for SSE. Reframe the completed JSON
1323
1339
  // as the canonical terminal SSE sequence (created → output_item.done →
@@ -11,6 +11,8 @@ import { captureExplicitOpenAiCallerAuth } from "../../providers/openai-sidecar"
11
11
  import { captureCallerDirectAuth } from "../../providers/caller-authorization";
12
12
  import { resolvePolicyProfileId } from "../../routing/profile";
13
13
  import { parseSyntheticRowId } from "../fast-row";
14
+ import { routeConcreteModel } from "../../router";
15
+ import { isPolicyCandidateRefusal, policyWireDestination, type PolicyRequestScope } from "./policy-request-scope";
14
16
 
15
17
  type CoreHandler = typeof handleResponsesCore;
16
18
  type CoreOptions = Parameters<CoreHandler>[3];
@@ -23,6 +25,25 @@ function candidateKey(candidate: Pick<RouteCandidateTrace, "provider" | "model">
23
25
  return `${candidate.provider}\u0000${candidate.model}`;
24
26
  }
25
27
 
28
+ function staysWithinPolicyAfterRedirect(
29
+ config: OcxConfig,
30
+ policyEligibility: ReadonlySet<string>,
31
+ candidate: RouteCandidateTrace,
32
+ triedDestinations: ReadonlySet<string>,
33
+ ): boolean {
34
+ try {
35
+ // Both inspection and execution resolve the original concrete candidate, without public
36
+ // combo/profile aliases or combo selection writes. A redirect must remain in the evaluation.
37
+ const routed = routeConcreteModel(config, `${candidate.provider}/${candidate.model}`);
38
+ return policyEligibility.has(`${routed.providerName}\u0000${routed.modelId}`)
39
+ && !triedDestinations.has(policyWireDestination(routed));
40
+ } catch {
41
+ // A route that fails to resolve (e.g. a redirect cycle) cannot succeed on retry; its terminal
42
+ // error is rarely hop-worthy, so keep the fallback alive for the next eligible candidate.
43
+ return false;
44
+ }
45
+ }
46
+
26
47
  /**
27
48
  * Rank the remaining candidates from the ORIGINAL policy trace. The initial
28
49
  * decision stays immutable; fallback execution belongs in attempts[], not in a
@@ -87,6 +108,7 @@ async function shouldHopPolicyCandidate(response: Response, signal?: AbortSignal
87
108
  if (response.status < 400 || signal?.aborted) return false;
88
109
  // A response that must not be sent again cannot open a policy-candidate retry either.
89
110
  if (isNonReplayableResponse(response)) return false;
111
+ if (isPolicyCandidateRefusal(response)) return true;
90
112
  try {
91
113
  const inspected = await readBoundedResponseBody(response.clone(), { signal });
92
114
  const text = inspected.displaySafe ? inspected.text : "";
@@ -131,11 +153,13 @@ export async function handleResponsesWithPolicyFallback(
131
153
  deps: PolicyFallbackDeps = {},
132
154
  ): Promise<Response> {
133
155
  const runCore = deps.runCore ?? handleResponsesCore;
156
+ const policyScope: PolicyRequestScope = options.policyRequestScope ?? { triedDestinations: new Set() };
134
157
  let requestBodyReadNotified = false;
135
158
  let storedPool401ReplayDispatched = false;
136
159
  let rawBody: Record<string, unknown> | null = null;
137
160
  const coreOptions: CoreOptions = {
138
161
  ...options,
162
+ policyRequestScope: policyScope,
139
163
  openAiSidecarAuth: options.openAiSidecarAuth === undefined
140
164
  ? captureExplicitOpenAiCallerAuth(req.headers, config) : options.openAiSidecarAuth,
141
165
  nativeCallerAuth: options.nativeCallerAuth === undefined
@@ -175,25 +199,52 @@ export async function handleResponsesWithPolicyFallback(
175
199
  if (overload) return overload;
176
200
  throw error;
177
201
  }
178
- const initialTrace = logCtx.routeDecision;
202
+ const initialTrace = policyScope.decision ?? logCtx.routeDecision;
179
203
  const initialRequestedModel = logCtx.requestedModel;
180
204
  if (!rawBody || !isPolicyDecision(initialTrace)) return response;
181
205
 
182
206
  const tried = new Set<string>([
183
207
  candidateKey({ provider: initialTrace.selected.provider, model: initialTrace.selected.model }),
184
208
  ]);
209
+ const selectedCandidate = initialTrace.selected.candidateIndex === undefined ? undefined
210
+ : initialTrace.candidates[initialTrace.selected.candidateIndex];
211
+ if (selectedCandidate) tried.add(candidateKey(selectedCandidate));
212
+ // Redirect eligibility needs the full evaluation membership, not the bounded trace list.
213
+ const policyEligibility: ReadonlySet<string> = policyScope.eligibility ?? logCtx.policyEligibility
214
+ ?? new Set(initialTrace.candidates
215
+ .filter(candidate => candidate.eligible)
216
+ .map(candidate => candidateKey(candidate)));
217
+ policyScope.eligibility = policyEligibility;
218
+ policyScope.decision = initialTrace;
219
+ policyScope.triedDestinations.add(policyScope.preparedDestination ?? policyWireDestination({
220
+ providerName: initialTrace.selected.provider, modelId: initialTrace.selected.model,
221
+ }));
185
222
 
186
223
  while (!storedPool401ReplayDispatched && await shouldHopPolicyCandidate(response, req.signal)) {
187
224
  if (req.signal.aborted) return response;
188
- const next = rankPolicyFallbackCandidates(initialTrace, tried)[0];
225
+ const next = rankPolicyFallbackCandidates(initialTrace, tried)
226
+ .find(candidate => staysWithinPolicyAfterRedirect(config, policyEligibility, candidate, policyScope.triedDestinations));
189
227
  if (!next) return response;
190
228
  tried.add(candidateKey(next));
191
229
 
230
+ // Retain the failed response's log owner until a candidate actually replaces it. This is
231
+ // deliberately shallow: completed attempts and the live spend tracker keep their identity.
232
+ const failureLog = { ...logCtx };
192
233
  finishFailedPolicyAttempt(logCtx, response.status);
193
234
  const retryRequest = requestWithCandidate(req, rawBody, next);
235
+ delete policyScope.preparedDestination;
194
236
  try {
195
237
  try {
196
- response = await runCore(retryRequest, config, logCtx, coreOptions);
238
+ const nextResponse = await runCore(retryRequest, config, logCtx, { ...coreOptions, policyFallbackCandidate: next });
239
+ // A locally skipped route owns neither the returned failure nor its usage/settlement.
240
+ // Preparation can replace route metadata and the tracker, or add fields absent before it.
241
+ if (isPolicyCandidateRefusal(nextResponse)) {
242
+ for (const key of Object.keys(logCtx)) {
243
+ if (!Object.hasOwn(failureLog, key)) Reflect.deleteProperty(logCtx, key);
244
+ }
245
+ Object.assign(logCtx, failureLog);
246
+ } else response = nextResponse;
247
+ if (policyScope.preparedDestination) policyScope.triedDestinations.add(policyScope.preparedDestination);
197
248
  } catch (error) {
198
249
  const overload = requestPacingOverloadResponse(error);
199
250
  if (overload) return overload;
@@ -202,6 +253,7 @@ export async function handleResponsesWithPolicyFallback(
202
253
  } finally {
203
254
  logCtx.requestedModel = initialRequestedModel;
204
255
  logCtx.routeDecision = initialTrace;
256
+ logCtx.policyEligibility = policyEligibility;
205
257
  }
206
258
  }
207
259
 
@@ -0,0 +1,80 @@
1
+ import { formatErrorResponse } from "../../bridge";
2
+ import type { RouteResult } from "../../router";
3
+ import type { RouteDecisionTraceV1 } from "../../routing/trace";
4
+ import { resolveOpenAiVirtualModel } from "../../providers/openai-virtual-models";
5
+
6
+ /** Request-owned authorization, separate from the diagnostic trace and each physical attempt. */
7
+ export interface PolicyRequestScope {
8
+ decision?: RouteDecisionTraceV1;
9
+ eligibility?: ReadonlySet<string>;
10
+ readonly triedDestinations: Set<string>;
11
+ preparedDestination?: string;
12
+ }
13
+
14
+ export function policyDestinationKey(provider: string, model: string): string {
15
+ return `${provider}\u0000${model}`;
16
+ }
17
+
18
+ export function policyWireDestination(route: Pick<RouteResult, "providerName" | "modelId">): string {
19
+ const model = resolveOpenAiVirtualModel(route.providerName, route.modelId)?.wireModelId ?? route.modelId;
20
+ return policyDestinationKey(route.providerName, model);
21
+ }
22
+
23
+ export class PolicyCandidateUnavailableError extends Error {
24
+ constructor() {
25
+ super("The candidate cannot serve this request within its original routing policy.");
26
+ this.name = "PolicyCandidateUnavailableError";
27
+ }
28
+ }
29
+
30
+ const localRefusals = new WeakSet<Response>();
31
+
32
+ export function policyCandidateRefusalResponse(error: unknown): Response | undefined {
33
+ if (!(error instanceof PolicyCandidateUnavailableError)) return undefined;
34
+ const response = formatErrorResponse(409, "policy_candidate_unavailable", error.message);
35
+ localRefusals.add(response);
36
+ return response;
37
+ }
38
+
39
+ /** Upstream text or a matching error code cannot authorize another policy attempt. */
40
+ export function isPolicyCandidateRefusal(response: Response): boolean {
41
+ return localRefusals.has(response);
42
+ }
43
+
44
+ export function capturePolicyRequestRoute(scope: PolicyRequestScope, route: RouteResult): RouteResult {
45
+ if (!scope.eligibility && route.policyEligibility && route.routeDecision?.routeKind === "policy") {
46
+ scope.eligibility = new Set(route.policyEligibility);
47
+ scope.decision = route.routeDecision;
48
+ }
49
+ if (!scope.eligibility) return route;
50
+ if (!scope.eligibility.has(policyDestinationKey(route.providerName, route.modelId))) {
51
+ throw new PolicyCandidateUnavailableError();
52
+ }
53
+ // Preparation and normalization both project from RouteResult into the log. Keep the original
54
+ // authorization and decision on every allowed replacement, including recovery's second pass.
55
+ route.routeKind = "policy";
56
+ route.policyEligibility = scope.eligibility;
57
+ route.routeDecision = scope.decision;
58
+ return route;
59
+ }
60
+
61
+ /** Only the admitted selector's existing virtual-model rewrite may change its final route id. */
62
+ export function assertPolicyNormalizedRoute(
63
+ scope: PolicyRequestScope,
64
+ admitted: Pick<RouteResult, "providerName" | "modelId">,
65
+ route: RouteResult,
66
+ ): void {
67
+ if (!scope.eligibility) return;
68
+ if (route.providerName !== admitted.providerName || (route.modelId !== admitted.modelId
69
+ && route.modelId !== resolveOpenAiVirtualModel(admitted.providerName, admitted.modelId)?.wireModelId)) {
70
+ throw new PolicyCandidateUnavailableError();
71
+ }
72
+ }
73
+
74
+ /** Called once preparation has settled the wire, before dispatch; account retries keep their own budget. */
75
+ export function recordPolicyPreparedDestination(scope: PolicyRequestScope, provider: string, model: string): void {
76
+ if (!scope.eligibility) return;
77
+ const destination = policyDestinationKey(provider, model);
78
+ if (scope.triedDestinations.has(destination)) throw new PolicyCandidateUnavailableError();
79
+ scope.preparedDestination = destination;
80
+ }