@bitkyc08/opencodex 2.55.0 → 2.56.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 (167) hide show
  1. package/gui/dist/assets/{index-VuoiWj9J.js → index-D4zuyIxQ.js} +1 -1
  2. package/gui/dist/index.html +1 -1
  3. package/package.json +2 -1
  4. package/src/adapters/base.ts +21 -0
  5. package/src/adapters/cursor/transport-retry.ts +46 -1
  6. package/src/adapters/cursor.ts +4 -0
  7. package/src/adapters/kiro/adapter.ts +42 -1
  8. package/src/adapters/kiro-retry.ts +23 -4
  9. package/src/adapters/openai-chat/errors.ts +116 -0
  10. package/src/adapters/openai-chat/messages.ts +346 -0
  11. package/src/adapters/openai-chat/passthrough.ts +146 -0
  12. package/src/adapters/openai-chat/response-events.ts +117 -0
  13. package/src/adapters/openai-chat/tool-call-validation.ts +200 -0
  14. package/src/adapters/openai-chat/tool-schema.ts +477 -0
  15. package/src/adapters/openai-chat/wire.ts +50 -0
  16. package/src/adapters/openai-chat.ts +33 -1445
  17. package/src/adapters/openai-responses/canonical-forward.ts +202 -0
  18. package/src/adapters/openai-responses/image-gen.ts +406 -0
  19. package/src/adapters/openai-responses/internal.ts +3 -0
  20. package/src/adapters/openai-responses/passthrough.ts +611 -0
  21. package/src/adapters/openai-responses/prompt-cache.ts +83 -0
  22. package/src/adapters/openai-responses/reasoning.ts +220 -0
  23. package/src/adapters/openai-responses/request-strips.ts +185 -0
  24. package/src/adapters/openai-responses/tool-output-recovery.ts +509 -0
  25. package/src/adapters/openai-responses/tool-schema.ts +293 -0
  26. package/src/adapters/openai-responses/web-search.ts +156 -0
  27. package/src/adapters/openai-responses.ts +4 -2625
  28. package/src/bridge/errors.ts +34 -0
  29. package/src/bridge/internal.ts +174 -0
  30. package/src/bridge/response-json.ts +624 -0
  31. package/src/bridge/sse.ts +1444 -0
  32. package/src/bridge.ts +5 -2204
  33. package/src/chat/inbound.ts +12 -1
  34. package/src/codex/account-lifecycle.ts +3 -0
  35. package/src/codex/account-store.ts +71 -9
  36. package/src/codex/auth-api/account-list.ts +507 -0
  37. package/src/codex/auth-api/http.ts +32 -0
  38. package/src/codex/auth-api/login-flow.ts +554 -0
  39. package/src/codex/auth-api/login-state.ts +64 -0
  40. package/src/codex/auth-api/main-account-probe.ts +331 -0
  41. package/src/codex/auth-api/pool-mode-gate.ts +274 -0
  42. package/src/codex/auth-api/pool-quota-probe.ts +512 -0
  43. package/src/codex/auth-api/reset-credit-service.ts +422 -0
  44. package/src/codex/auth-api/routes.ts +425 -0
  45. package/src/codex/auth-api/runtime-config.ts +48 -0
  46. package/src/codex/auth-api.ts +27 -3118
  47. package/src/codex/auth-context.ts +95 -28
  48. package/src/codex/catalog/auto-review.ts +507 -0
  49. package/src/codex/catalog/build-entries.ts +981 -0
  50. package/src/codex/catalog/combo-member.ts +375 -0
  51. package/src/codex/catalog/derive-entry.ts +229 -0
  52. package/src/codex/catalog/effort.ts +0 -1
  53. package/src/codex/catalog/gated-native-warn.ts +63 -0
  54. package/src/codex/catalog/gather-capture.ts +533 -0
  55. package/src/codex/catalog/model-hints.ts +691 -0
  56. package/src/codex/catalog/model-visibility.ts +304 -0
  57. package/src/codex/catalog/provider-fetch.ts +52 -2942
  58. package/src/codex/catalog/provider-models.ts +685 -0
  59. package/src/codex/catalog/restore.ts +132 -0
  60. package/src/codex/catalog/retained-sync.ts +706 -0
  61. package/src/codex/catalog/routed-gather.ts +858 -0
  62. package/src/codex/catalog/subagent-roster.ts +176 -0
  63. package/src/codex/catalog/sync.ts +52 -2698
  64. package/src/codex/inject/config-toml.ts +563 -0
  65. package/src/codex/inject/remove.ts +192 -0
  66. package/src/codex/inject/restore.ts +540 -0
  67. package/src/codex/inject/routing-classify.ts +109 -0
  68. package/src/codex/inject/routing-target.ts +125 -0
  69. package/src/codex/inject.ts +81 -1436
  70. package/src/codex/lineage.ts +458 -0
  71. package/src/codex/pool-refresh-backoff.ts +152 -0
  72. package/src/codex/routing/active-account.ts +194 -0
  73. package/src/codex/routing/cooldown-math.ts +275 -0
  74. package/src/codex/routing/health-store.ts +402 -0
  75. package/src/codex/routing/probe-lease.ts +358 -0
  76. package/src/codex/routing/selection.ts +703 -0
  77. package/src/codex/routing/thread-affinity.ts +538 -0
  78. package/src/codex/routing.ts +353 -2234
  79. package/src/codex/shim-fingerprint.ts +223 -0
  80. package/src/codex/shim-inspect.ts +175 -0
  81. package/src/codex/shim-probe.ts +367 -0
  82. package/src/codex/shim-restore-lock.ts +169 -0
  83. package/src/codex/shim-state-file.ts +151 -0
  84. package/src/codex/shim-templates.ts +265 -0
  85. package/src/codex/shim.ts +48 -1268
  86. package/src/config/diagnostics.ts +705 -0
  87. package/src/config/feature-flags.ts +55 -0
  88. package/src/config/live-reconcile.ts +403 -0
  89. package/src/config/load-degrade.ts +880 -0
  90. package/src/config/mutation-lock.ts +244 -0
  91. package/src/config/openai-tier-backup.ts +268 -0
  92. package/src/config/persist-unlocked.ts +92 -0
  93. package/src/config/proxy-env.ts +188 -0
  94. package/src/config/salvage.ts +244 -0
  95. package/src/config/schema/config-schema.ts +640 -0
  96. package/src/config/schema/leaf-validators.ts +855 -0
  97. package/src/config/warn-memo.ts +28 -0
  98. package/src/config.ts +234 -4481
  99. package/src/generated/compatibility-version.json +539 -39
  100. package/src/lib/request-execution-budget.ts +69 -20
  101. package/src/lib/spend-reservation-ledger.ts +940 -0
  102. package/src/lib/upstream-retry.ts +55 -11
  103. package/src/lib/workflow-budget.ts +553 -30
  104. package/src/providers/quota/account-cache.ts +441 -0
  105. package/src/providers/quota/antigravity.ts +295 -0
  106. package/src/providers/quota/report-cache.ts +320 -0
  107. package/src/providers/quota/vendor-probes-key.ts +1243 -0
  108. package/src/providers/quota/vendor-probes-oauth.ts +590 -0
  109. package/src/providers/quota.ts +324 -3079
  110. package/src/providers/registry/entries-core.ts +1221 -0
  111. package/src/providers/registry/entries-extended.ts +1204 -0
  112. package/src/providers/registry/model-seeds.ts +908 -0
  113. package/src/providers/registry/types.ts +352 -0
  114. package/src/providers/registry.ts +24 -3536
  115. package/src/responses/continuation-ownership.ts +29 -0
  116. package/src/responses/state/replay-fingerprint.ts +80 -0
  117. package/src/responses/state/snapshot-codec.ts +104 -0
  118. package/src/responses/state/spill-failure.ts +118 -0
  119. package/src/responses/state/spill-queue.ts +665 -0
  120. package/src/responses/state/temp-recovery.ts +257 -0
  121. package/src/responses/state.ts +82 -1143
  122. package/src/routing/identity-domains.ts +449 -0
  123. package/src/routing/probe-lease.ts +511 -0
  124. package/src/server/index/bounded-request.ts +88 -0
  125. package/src/server/index/live-sideband.ts +565 -0
  126. package/src/server/index/serve-options.ts +1766 -0
  127. package/src/server/index/startup-warnings.ts +213 -0
  128. package/src/server/index/websocket-handler.ts +335 -0
  129. package/src/server/index.ts +40 -2547
  130. package/src/server/management/route-registry.ts +26 -23
  131. package/src/server/management/shared.ts +8 -5
  132. package/src/server/management/workflow-budget-routes.ts +133 -0
  133. package/src/server/management-api.ts +12 -0
  134. package/src/server/request-log-conversation.ts +9 -7
  135. package/src/server/request-log.ts +245 -1
  136. package/src/server/responses/account-change-state.ts +233 -0
  137. package/src/server/responses/adapter-continuation.ts +514 -0
  138. package/src/server/responses/adapter-delivery.ts +214 -0
  139. package/src/server/responses/adapter-dispatch.ts +971 -0
  140. package/src/server/responses/compact.ts +59 -4
  141. package/src/server/responses/completion-policy.ts +33 -0
  142. package/src/server/responses/core-auth.ts +527 -0
  143. package/src/server/responses/core-codex-account.ts +859 -0
  144. package/src/server/responses/core-combo-failure.ts +210 -0
  145. package/src/server/responses/core-combo.ts +707 -0
  146. package/src/server/responses/core-errors.ts +152 -0
  147. package/src/server/responses/core-lifetime.ts +95 -0
  148. package/src/server/responses/core-normalize.ts +350 -0
  149. package/src/server/responses/core-opaque-recovery.ts +380 -0
  150. package/src/server/responses/core-options.ts +159 -0
  151. package/src/server/responses/core-replay.ts +225 -0
  152. package/src/server/responses/core.ts +192 -8893
  153. package/src/server/responses/passthrough-delivery.ts +856 -0
  154. package/src/server/responses/passthrough-dispatch.ts +1476 -0
  155. package/src/server/responses/passthrough-execution.ts +54 -0
  156. package/src/server/responses/request-prepare.ts +970 -0
  157. package/src/server/responses/request-send-budget.ts +164 -0
  158. package/src/server/responses/request-sidecar-auth.ts +149 -0
  159. package/src/server/responses/request-transport.ts +744 -0
  160. package/src/server/responses/response-effects.ts +157 -0
  161. package/src/server/responses/run-turn-execution.ts +448 -0
  162. package/src/server/responses/sidecar-execution.ts +469 -0
  163. package/src/server/responses-image-gen-repair.ts +1 -1
  164. package/src/server/workflow-refusal.ts +84 -0
  165. package/src/types/config.ts +30 -0
  166. package/src/usage/log.ts +146 -0
  167. package/src/usage/summary.ts +171 -21
@@ -0,0 +1,469 @@
1
+ import type { ResponsesRequestContext } from "./core-options";
2
+ import type { PreparedResponsesRequest } from "./request-prepare";
3
+ import type { ResponsesTransport } from "./request-transport";
4
+ import type { ResponsesSidecarAuth } from "./request-sidecar-auth";
5
+ import type { ResponsesEffects } from "./response-effects";
6
+ import type { ResponsesSendBudget } from "./request-send-budget";
7
+ import { formatErrorResponse } from "../../bridge";
8
+ import { planWebSearch, buildWebSearchTool, runWithWebSearch } from "../../web-search";
9
+ import {
10
+ planImageBridge,
11
+ planVideoBridge,
12
+ IMAGE_GEN_TOOL_NAME,
13
+ buildImageTool,
14
+ VIDEO_GEN_TOOL_NAME,
15
+ buildVideoTool,
16
+ runWithImageBridge,
17
+ clampImageMaxRounds,
18
+ } from "../../images";
19
+ import type { ProviderAdapter } from "../../adapters/base";
20
+ import { rotateProviderTransportOn429, rateLimitRetryPolicyFor } from "../../providers/key-failover";
21
+ import {
22
+ GENERIC_OAUTH_MAX_FAILOVERS_PER_REQUEST,
23
+ isGenericOAuthFailoverEnabled,
24
+ rotateGenericOAuthAccountOn429,
25
+ failoverAccountSnapshot,
26
+ } from "../../oauth/generic-account-failover";
27
+ import {
28
+ ANTHROPIC_POOL_MAX_FAILOVERS_PER_REQUEST,
29
+ rotateAnthropicAccountOn429,
30
+ getAnthropicPoolAccessSnapshot,
31
+ formatAnthropicProviderForLog,
32
+ } from "../../oauth/anthropic-routing";
33
+ import { resolveWireProtocolOverride } from "../adapter-resolve";
34
+ import { bindRouteReasoningReplayScope, adapterNeedsForcedContinuation } from "./core-replay";
35
+ import { namespacedToolName } from "../../types";
36
+ import { providerFetch } from "./fetch-helpers";
37
+ import type { AttemptRecoveryKind } from "../../usage/log";
38
+ import { noteAttemptSend, recordAdapterReasoning, recordAdapterTier } from "../request-log";
39
+ import { normalizeLogConversationId } from "../request-log-conversation";
40
+ import { rememberResponseState } from "../../responses/state";
41
+ import { trackStreamLifetime } from "../lifecycle";
42
+
43
+ /** One responsibility of the Responses request pipeline; state owners are explicit. */
44
+ export async function executeResponsesSidecars(
45
+ requestContext: Pick<ResponsesRequestContext, "config" | "options" | "logCtx">,
46
+ requestState: Pick<
47
+ PreparedResponsesRequest,
48
+ | "parsed"
49
+ | "route"
50
+ | "inboundWire"
51
+ | "selectedForwardHeaders"
52
+ | "translatorBudget"
53
+ | "rememberKiroDeliveredFinalAnswer"
54
+ | "responseStateOptions"
55
+ >,
56
+ transportState: Pick<
57
+ ResponsesTransport,
58
+ | "adapter"
59
+ | "genericFailoverAccountId"
60
+ | "genericFailovers"
61
+ | "applyFailoverSnapshot"
62
+ | "anthropicPoolAccountId"
63
+ | "anthropicPoolFailovers"
64
+ | "anthropicSessionKey"
65
+ | "commitResolvedOAuthSelection"
66
+ | "resolveSelectionAdapter"
67
+ | "oauthDispatch"
68
+ >,
69
+ sidecarState: Pick<ResponsesSidecarAuth, "routedCompaction" | "openAiSidecar">,
70
+ responseEffects: Pick<
71
+ ResponsesEffects,
72
+ | "commitReasoningReplayServingRoute"
73
+ | "continuationStateForResponse"
74
+ | "notifyResponseComplete"
75
+ | "cancelResponseCompletion"
76
+ >,
77
+ sendBudgetState: Pick<ResponsesSendBudget, "reserveCredentialHop">,
78
+ ) {
79
+ const { config, options, logCtx } = requestContext;
80
+ const {
81
+ applyFailoverSnapshot,
82
+ anthropicSessionKey,
83
+ commitResolvedOAuthSelection,
84
+ resolveSelectionAdapter,
85
+ oauthDispatch,
86
+ } = transportState;
87
+ const {
88
+ parsed,
89
+ route,
90
+ inboundWire,
91
+ translatorBudget,
92
+ rememberKiroDeliveredFinalAnswer,
93
+ responseStateOptions,
94
+ } = requestState;
95
+ const { routedCompaction, openAiSidecar } = sidecarState;
96
+ const { reserveCredentialHop } = sendBudgetState;
97
+ const {
98
+ commitReasoningReplayServingRoute,
99
+ continuationStateForResponse,
100
+ notifyResponseComplete,
101
+ cancelResponseCompletion,
102
+ } = responseEffects;
103
+
104
+
105
+ // Tool results are PAIRED by call_id. parseRequest writes it into OcxToolResultMessage.toolCallId
106
+ // (parser.ts:738/752) without validating it, because inputItemSchema's permissive catch-all
107
+ // (schema.ts:106) accepts a tool item whose strict schema failed only for a missing call_id. A
108
+ // translating adapter then consumes `toolCallId: string` holding undefined: kiro-wire.ts:32
109
+ // TypeErrors, ollama-native.ts:334 throws, and anthropic.ts:775 sends
110
+ // "[tool_result without adjacent tool_use: undefined]" upstream (issue #3259).
111
+ //
112
+ // This CANNOT move into the schema. parseRequest (:2812) runs before the passthrough branch
113
+ // (:3719), so a parse-time rejection would also kill forward/key passthrough and routed
114
+ // compaction — paths that never read context.messages, build from _rawBody, and already
115
+ // degrade an unpaired output to "[tool output for unknown call]" on their own.
116
+ //
117
+ // Keyed on the adapter, not on position: routedCompaction skips the passthrough branch above
118
+ // yet still builds from _rawBody (see the :3703 comment).
119
+ if (!("passthrough" in transportState.adapter && transportState.adapter.passthrough)) {
120
+ const unpaired = parsed.context.messages.find(
121
+ message => message.role === "toolResult"
122
+ && (typeof (message as { toolCallId?: unknown }).toolCallId !== "string"
123
+ || (message as { toolCallId: string }).toolCallId.length === 0),
124
+ );
125
+ if (unpaired) {
126
+ // Never interpolate the tool output: this message reaches the client and the logs.
127
+ return formatErrorResponse(
128
+ 400,
129
+ "invalid_request_error",
130
+ "tool result requires a non-empty string call_id",
131
+ );
132
+ }
133
+ }
134
+
135
+ // Image / web-search sidecars: plan once, then dispatch with runTurn-aware priority.
136
+ // Routed-compaction turns must NOT hit the image bridge: compaction clears tools/_webSearch but
137
+ // leaves _imageGeneration, so planImageBridge would activate and return a normal Responses
138
+ // completion instead of the synthetic compaction item Codex expects (#424).
139
+ //
140
+ // Web-search's loop only supports buildRequest/fetch/parseStream — NOT adapter.runTurn. Sending
141
+ // Cursor/runTurn requests into runWithWebSearch produces empty HTTP failures. So:
142
+ // - non-runTurn: web-search wins over image when both eligible (documented priority)
143
+ // - runTurn: image bridge may run (it supports runTurn); web-search is skipped so runTurn
144
+ // can proceed for web-search-only turns
145
+ const wsPlan = !routedCompaction
146
+ ? planWebSearch(config, parsed, false, route.provider, route.modelId, openAiSidecar, {
147
+ admission: options.admission, codexAuthPolicy: options.codexAuthPolicy, providerName: route.providerName,
148
+ })
149
+ : undefined;
150
+ const imgPlan = !routedCompaction ? await planImageBridge(config, parsed, route.provider) : undefined;
151
+ const vidPlan = !routedCompaction ? await planVideoBridge(config, parsed, route.provider) : undefined;
152
+ const canRunWebSearch = !!wsPlan && !transportState.adapter.runTurn;
153
+ const rotateSidecarProviderOn429 = async (
154
+ retryAfter: string | null,
155
+ responseHeaders?: Headers,
156
+ ): Promise<ProviderAdapter | null> => {
157
+ const rotated = rotateProviderTransportOn429(config, route.providerName, route.provider, {
158
+ retryAfter,
159
+ now: Date.now(),
160
+ attemptedKey: route.provider.apiKey,
161
+ promptCacheKey: parsed.options.promptCacheKey,
162
+ });
163
+ if (rotated) {
164
+ route.provider = rotated;
165
+ } else if (
166
+ // A POSITIVE gate, not an early return. An early `return null` here made every later arm
167
+ // unreachable: Anthropic never has a genericFailoverAccountId (isGenericFailoverProvider
168
+ // excludes it), so its sidecar 429s died on this guard before the Anthropic arm below
169
+ // could ever be considered.
170
+ transportState.genericFailoverAccountId
171
+ && transportState.genericFailovers < GENERIC_OAUTH_MAX_FAILOVERS_PER_REQUEST
172
+ && isGenericOAuthFailoverEnabled(config, route.providerName)
173
+ ) {
174
+ // Intersection with the request's shared budget. The sidecar replay is dispatched by the
175
+ // web-search/image loop and never reaches `onSendsConsumed`, so this reservation is the
176
+ // charge; a refusal returns null and the caller keeps the real 429 it already has.
177
+ const hop = reserveCredentialHop(
178
+ "auth-recovery",
179
+ `${route.providerName}|${route.modelId}|sidecar-oauth-429`,
180
+ );
181
+ if (!hop.allowed) return null;
182
+ const nextAccountId = rotateGenericOAuthAccountOn429(
183
+ config,
184
+ route.providerName,
185
+ transportState.genericFailoverAccountId,
186
+ retryAfter,
187
+ );
188
+ if (!nextAccountId) {
189
+ hop.permit?.release();
190
+ return null;
191
+ }
192
+ try {
193
+ const snapshot = await failoverAccountSnapshot(route.providerName, nextAccountId);
194
+ transportState.genericFailovers += 1;
195
+ if (!await applyFailoverSnapshot(snapshot)) {
196
+ hop.permit?.release();
197
+ return null;
198
+ }
199
+ } catch {
200
+ hop.permit?.release();
201
+ return null;
202
+ }
203
+ hop.permit?.use();
204
+ } else if (
205
+ // Anthropic's pool is excluded from generic failover, so without this arm a 429 inside a
206
+ // web-search or image-bridge turn was terminal even with the pool fully enabled -- while
207
+ // the very same 429 on the main response path rotated.
208
+ transportState.anthropicPoolAccountId
209
+ && transportState.anthropicPoolFailovers < ANTHROPIC_POOL_MAX_FAILOVERS_PER_REQUEST
210
+ ) {
211
+ // Same intersection for the Anthropic roster: its own per-request bound still applies,
212
+ // and the shared budget decides whether this request may spend another send at all.
213
+ const hop = reserveCredentialHop(
214
+ "auth-recovery",
215
+ `${route.providerName}|${route.modelId}|sidecar-anthropic-429`,
216
+ );
217
+ if (!hop.allowed) return null;
218
+ const nextAccountId = rotateAnthropicAccountOn429(
219
+ config,
220
+ transportState.anthropicPoolAccountId,
221
+ retryAfter,
222
+ anthropicSessionKey,
223
+ Date.now(),
224
+ responseHeaders,
225
+ );
226
+ if (!nextAccountId) {
227
+ hop.permit?.release();
228
+ return null;
229
+ }
230
+ try {
231
+ // Deliberately NOT applyFailoverSnapshot: that helper exists to pair per-account routing
232
+ // metadata (Copilot origin, Antigravity project, Kiro context) with its bearer. Anthropic
233
+ // carries none, and getAnthropicPoolAccessToken is what enforces its fail-closed
234
+ // local-cli credential rule. Both existing Anthropic rotation sites apply the token the
235
+ // same way.
236
+ const admitted = await commitResolvedOAuthSelection(await getAnthropicPoolAccessSnapshot(nextAccountId));
237
+ if (!admitted) throw new Error("OAuth selection changed during recovery");
238
+ transportState.anthropicPoolAccountId = admitted.accountId;
239
+ transportState.anthropicPoolFailovers += 1;
240
+ route.provider = { ...route.provider, apiKey: admitted.accessToken };
241
+ logCtx.provider = formatAnthropicProviderForLog("anthropic", admitted.accountId, config);
242
+ } catch {
243
+ hop.permit?.release();
244
+ return null;
245
+ }
246
+ hop.permit?.use();
247
+ } else {
248
+ // No key pool, no generic OAuth roster, no Anthropic pool could produce a replacement
249
+ // credential. The 429 is terminal for this sidecar turn.
250
+ return null;
251
+ }
252
+ const rotatedAdapter = resolveSelectionAdapter(
253
+ resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire),
254
+ config.cacheRetention,
255
+ );
256
+ bindRouteReasoningReplayScope({
257
+ parsed,
258
+ providerName: route.providerName,
259
+ provider: route.provider,
260
+ adapterName: rotatedAdapter.name,
261
+ });
262
+ return rotatedAdapter;
263
+ };
264
+ if ((imgPlan || vidPlan) && canRunWebSearch) {
265
+ // Web search takes priority when both are active — the media bridge cannot run
266
+ // alongside runWithWebSearch. Surface a runtime signal so the user knows their
267
+ // configured video/image bridge was skipped for this turn, rather than silently
268
+ // dropping a paid capability.
269
+ if (vidPlan) console.warn("[videos] video bridge skipped: web search is active for this turn");
270
+ if (imgPlan) console.warn("[images] image bridge skipped: web search is active for this turn");
271
+ }
272
+ if ((imgPlan || vidPlan) && (!wsPlan || transportState.adapter.runTurn)) {
273
+ // The image bridge detects a hosted image_generation tool and requires streaming.
274
+ // The video bridge activates from config and injects a tool — it also needs streaming
275
+ // (the loop returns SSE). For video-only (no imgPlan) on a non-streaming request, skip
276
+ // the bridge entirely so enabling the feature doesn't break ordinary non-streaming traffic.
277
+ if (!parsed.stream) {
278
+ if (imgPlan) {
279
+ return formatErrorResponse(400, "invalid_request_error", "image bridge requires stream=true");
280
+ }
281
+ // Video-only: skip bridge for non-streaming requests
282
+ } else {
283
+ // Replace any pre-existing image_gen/video_gen aliases instead of appending duplicate wire names.
284
+ const priorTools = parsed.context.tools ?? [];
285
+ const bridgeTools = [...priorTools.filter(t => {
286
+ if (t.imageGeneration) return false;
287
+ if (t.videoGeneration) return false;
288
+ if (imgPlan && imgPlan.toolNames.has(t.name)) return false;
289
+ if (imgPlan && t.namespace && imgPlan.toolNames.has(namespacedToolName(t.namespace, t.name))) return false;
290
+ // Only strip unnamespaced video_gen aliases — a namespaced MCP video_gen is left alone.
291
+ if (vidPlan && !t.namespace && vidPlan.toolNames.has(t.name)) return false;
292
+ return true;
293
+ })];
294
+ const existingNames = new Set(bridgeTools.map(t => t.name));
295
+ if (imgPlan && !existingNames.has(IMAGE_GEN_TOOL_NAME)) bridgeTools.push(buildImageTool());
296
+ if (vidPlan && !existingNames.has(VIDEO_GEN_TOOL_NAME)) bridgeTools.push(buildVideoTool());
297
+ parsed.context.tools = bridgeTools;
298
+ // Hosted image_generation tool_choice / allowed_tools must target the synthetic function name.
299
+ // Gate on imgPlan — in a video-only turn buildImageTool() was never injected, so rewriting
300
+ // image_generation/image_gen aliases would add an undeclared tool that strict upstreams reject.
301
+ const tc = parsed.options.toolChoice;
302
+ if (imgPlan && tc && typeof tc === "object" && "allowedTools" in tc && Array.isArray(tc.allowedTools)) {
303
+ const mapped = tc.allowedTools.map(name =>
304
+ name === "image_generation" || name === "image_gen" || (imgPlan.toolNames.has(name) ?? false)
305
+ ? IMAGE_GEN_TOOL_NAME
306
+ : name,
307
+ );
308
+ parsed.options.toolChoice = { ...tc, allowedTools: [...new Set(mapped)] };
309
+ } else if (imgPlan && tc && typeof tc === "object" && "name" in tc && typeof tc.name === "string"
310
+ && (tc.name === "image_generation" || imgPlan.toolNames.has(tc.name))) {
311
+ parsed.options.toolChoice = { ...tc, name: IMAGE_GEN_TOOL_NAME };
312
+ }
313
+ const imageProviderFetch = providerFetch(
314
+ route.provider,
315
+ options.codexWsRuntimeIdentity,
316
+ { providerName: route.providerName, modelId: route.modelId },
317
+ );
318
+ const imgResponse = await runWithImageBridge({
319
+ parsed, adapter: transportState.adapter,
320
+ incomingMeta: { headers: requestState.selectedForwardHeaders, abortSignal: options.abortSignal, translatorBudget },
321
+ ...(imgPlan ? { plan: imgPlan } : {}),
322
+ ...(vidPlan ? { videoPlan: vidPlan } : {}),
323
+ forwardHeaders: requestState.selectedForwardHeaders,
324
+ onAttemptSend: (recovery?: AttemptRecoveryKind) =>
325
+ noteAttemptSend(logCtx.activeAttempt, logCtx.usageLogInputTokens, recovery),
326
+ abortSignal: options.abortSignal,
327
+ maxRounds: imgPlan && vidPlan
328
+ ? clampImageMaxRounds(Math.min(config.images?.maxRounds ?? 3, config.images?.videoMaxRounds ?? 2))
329
+ : imgPlan
330
+ ? clampImageMaxRounds(config.images?.maxRounds)
331
+ : clampImageMaxRounds(config.images?.videoMaxRounds ?? 2),
332
+ connectTimeoutMs: config.connectTimeoutMs ?? 200_000,
333
+ stallTimeoutSec: config.stallTimeoutSec,
334
+ waitForRequestSlot: imageProviderFetch.waitForPacing,
335
+ fetchImpl: imageProviderFetch.unpacedFetch ?? imageProviderFetch,
336
+ fetchForRequest: (request, iterParsed) => {
337
+ const fetch = providerFetch(route.provider, options.codexWsRuntimeIdentity, {
338
+ dispatchOverride: oauthDispatch(request, iterParsed),
339
+ providerName: route.providerName, modelId: route.modelId,
340
+ });
341
+ return fetch.unpacedFetch ?? fetch;
342
+ },
343
+ onRequestBuilt: request => {
344
+ recordAdapterReasoning(logCtx, request);
345
+ recordAdapterTier(logCtx, request);
346
+ },
347
+ ...(vidPlan?.timeoutMs ? { videoTimeoutMs: vidPlan.timeoutMs } : {}),
348
+ onUsage: usage => {
349
+ // Cursor may assign _cursorConversationId inside the image loop's first runTurn;
350
+ // backfill so Logs can filter/total that opening request (parity with the normal
351
+ // runTurn branch).
352
+ if (!logCtx.conversationId && parsed._cursorConversationId) {
353
+ logCtx.conversationId = normalizeLogConversationId(parsed._cursorConversationId);
354
+ }
355
+ logCtx.usageFromBridge = true;
356
+ if (usage) {
357
+ logCtx.usage = usage;
358
+ if (logCtx.activeAttempt) logCtx.activeAttempt.usage = usage;
359
+ }
360
+ },
361
+ on429: rotateSidecarProviderOn429,
362
+ retryOn429Policy: rateLimitRetryPolicyFor(route.provider),
363
+ ...(options.onFirstOutput ? { onFirstOutput: options.onFirstOutput } : {}),
364
+ ...(options.forceEmptyResponseId ? { forceEmptyResponseId: true } : {}),
365
+ onCompletedResponse: (response, providerState) => {
366
+ commitReasoningReplayServingRoute();
367
+ rememberKiroDeliveredFinalAnswer(transportState.adapter.name, response);
368
+ rememberResponseState(
369
+ parsed._rawBody,
370
+ response,
371
+ continuationStateForResponse(providerState),
372
+ responseStateOptions(adapterNeedsForcedContinuation(transportState.adapter.name)),
373
+ );
374
+ notifyResponseComplete(response);
375
+ },
376
+ });
377
+ if (imgResponse.body) {
378
+ const imgTurnAc = new AbortController();
379
+ imgTurnAc.signal.addEventListener("abort", cancelResponseCompletion, { once: true });
380
+ return new Response(trackStreamLifetime(imgResponse.body, imgTurnAc, undefined, options.turnAdmissionLease), {
381
+ status: imgResponse.status,
382
+ headers: imgResponse.headers,
383
+ });
384
+ }
385
+ return imgResponse;
386
+ } // end else (streaming bridge)
387
+ }
388
+
389
+ // Web-search sidecar: Codex enabled web_search but this is a routed (non-OpenAI) model that can't
390
+ // run it server-side. Expose web_search as a function tool and run searches via the gpt-mini sidecar
391
+ // through the ChatGPT passthrough, looping until the model answers. Otherwise take the normal path.
392
+ // Placed BEFORE the runTurn early-return for non-runTurn adapters so dual-tool turns dispatch
393
+ // through web-search instead of being swallowed. runTurn adapters never enter this branch.
394
+ if (canRunWebSearch && wsPlan) {
395
+ parsed.context.tools = [...(parsed.context.tools ?? []), buildWebSearchTool()];
396
+ // Resolve the mutable route at send time: a 429 rotation replaces route.provider, so retaining
397
+ // one pre-rotation providerFetch would keep the old credential and transport pin.
398
+ const routedProviderFetch = ((input: Parameters<typeof globalThis.fetch>[0], init?: RequestInit) =>
399
+ providerFetch(route.provider, options.codexWsRuntimeIdentity, {
400
+ providerName: route.providerName,
401
+ modelId: route.modelId,
402
+ })(input, init)) as typeof globalThis.fetch;
403
+ const wsResponse = await runWithWebSearch({
404
+ parsed, adapter: transportState.adapter,
405
+ fetchForRequest: (request, iterParsed) => providerFetch(route.provider, options.codexWsRuntimeIdentity, {
406
+ dispatchOverride: oauthDispatch(request, iterParsed),
407
+ providerName: route.providerName, modelId: route.modelId,
408
+ }),
409
+ incomingMeta: {
410
+ headers: requestState.selectedForwardHeaders,
411
+ abortSignal: options.abortSignal,
412
+ translatorBudget,
413
+ providerFetch: routedProviderFetch,
414
+ },
415
+ backend: wsPlan.backend,
416
+ forwardProvider: wsPlan.forwardSidecar?.provider,
417
+ anthropicSidecar: wsPlan.anthropicSidecar,
418
+ xaiSidecar: wsPlan.xaiSidecar,
419
+ geminiSidecar: wsPlan.geminiSidecar,
420
+ xaiSearchOptions: wsPlan.xaiSearchOptions,
421
+ // The exa key never rides the plan: read it from config at unpack time (L9).
422
+ ...(wsPlan.exaConfigured ? { exaApiKey: config.webSearchSidecar?.exaApiKey } : {}),
423
+ hostedTool: wsPlan.hostedTool,
424
+ selectedForwardHeaders: wsPlan.forwardSidecar?.headers ?? requestState.selectedForwardHeaders,
425
+ settings: wsPlan.settings,
426
+ maxSearches: wsPlan.maxSearches,
427
+ forceEmptyResponseId: true,
428
+ abortSignal: options.abortSignal,
429
+ ...(options.onFirstOutput ? { onFirstOutput: options.onFirstOutput } : {}),
430
+ onRequestBuilt: request => {
431
+ recordAdapterReasoning(logCtx, request);
432
+ recordAdapterTier(logCtx, request);
433
+ },
434
+ onAttemptSend: (recovery?: AttemptRecoveryKind) =>
435
+ noteAttemptSend(logCtx.activeAttempt, logCtx.usageLogInputTokens, recovery),
436
+ onUsage: usage => {
437
+ logCtx.usageFromBridge = true;
438
+ if (usage) {
439
+ logCtx.usage = usage;
440
+ if (logCtx.activeAttempt) logCtx.activeAttempt.usage = usage;
441
+ }
442
+ },
443
+ recordSidecarOutcome: wsPlan.forwardSidecar?.recordOutcome,
444
+ connectTimeoutMs: config.connectTimeoutMs ?? 200_000,
445
+ routedModelStallTimeoutMs: wsPlan.routedModelStallTimeoutMs,
446
+ stallTimeoutSec: wsPlan.stallTimeoutSec,
447
+ streamRoutedModelOutput: wsPlan.streamRoutedModelOutput,
448
+ on429: rotateSidecarProviderOn429,
449
+ retryOn429Policy: rateLimitRetryPolicyFor(route.provider),
450
+ onCompletedResponse: response => {
451
+ commitReasoningReplayServingRoute();
452
+ notifyResponseComplete(response);
453
+ },
454
+ });
455
+ // Register the sidecar stream as an active turn so drainAndShutdown waits for (or aborts)
456
+ // in-flight web-search turns instead of skipping them during graceful shutdown.
457
+ if (wsResponse.body) {
458
+ const wsTurnAc = new AbortController();
459
+ wsTurnAc.signal.addEventListener("abort", cancelResponseCompletion, { once: true });
460
+ return new Response(trackStreamLifetime(wsResponse.body, wsTurnAc, undefined, options.turnAdmissionLease), {
461
+ status: wsResponse.status,
462
+ headers: wsResponse.headers,
463
+ });
464
+ }
465
+ return wsResponse;
466
+ }
467
+
468
+ return undefined;
469
+ }
@@ -2,7 +2,7 @@ import { collectResponsesToolGroups } from "../responses/tool-groups";
2
2
  import { relaySseWithPayloadRewrite, type SsePayloadRewrite } from "./sse-payload-rewrite";
3
3
  import type { TranslatorBudget } from "../lib/translator-budget";
4
4
 
5
- interface NamespacedTool {
5
+ export interface NamespacedTool {
6
6
  namespace: string;
7
7
  name: string;
8
8
  }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * The one place that knows how this proxy refuses a turn on its own workflow budget.
3
+ *
4
+ * It is a module rather than two inline blocks because the two call sites -- the HTTP admission
5
+ * check in `src/server/index.ts` and the pre-dispatch ceiling check in
6
+ * `src/server/responses/core.ts` -- had drifted into saying different things about the same
7
+ * refusal, and because the non-obvious part below has to be stated once and not twice.
8
+ */
9
+ import { formatErrorResponse } from "../bridge";
10
+ import {
11
+ addFinalRequestLog,
12
+ markLocalRequestLogRefusal,
13
+ type RequestLogContext,
14
+ } from "./request-log";
15
+ import {
16
+ WORKFLOW_LOCAL_REFUSAL_HEADER,
17
+ workflowDenialSummary,
18
+ recordWorkflowRefusalEvent,
19
+ type WorkflowDenial,
20
+ } from "../lib/workflow-budget";
21
+
22
+ /**
23
+ * What a caller needs to hand over for the refusal to become a row on `/api/logs`.
24
+ *
25
+ * The HTTP admission check refuses before the body is parsed, so its `logCtx` still carries the
26
+ * `unknown` model and provider the caller seeded it with. That is the honest record -- this
27
+ * request genuinely never resolved either -- and it is the same placeholder the native
28
+ * passthrough path already writes. Skipping the row entirely was the worse option: an operator
29
+ * reading the logs saw no trace at all of a request the proxy had refused.
30
+ */
31
+ export interface WorkflowRefusalLog {
32
+ readonly requestId: string;
33
+ readonly start: number;
34
+ readonly logCtx: RequestLogContext;
35
+ }
36
+
37
+ /**
38
+ * Build the 429 for a refusal this proxy made itself.
39
+ *
40
+ * The status and type arguments below do not reach the client: `classifyError` rewrites every
41
+ * 429 to `rate_limit_error` / `rate_limit_exceeded`, so the body is shaped exactly like a
42
+ * provider rate limit. That is a deliberate wire contract -- changing it would change how every
43
+ * client retries -- which leaves two places to carry the truth. The message names the ceiling
44
+ * that fired and says no provider was contacted, and the header carries the machine-readable
45
+ * name. Nothing upstream sets that header, so its presence is conclusive.
46
+ *
47
+ * The row is where an operator actually looks, so it gets the same treatment #4639 established:
48
+ * `terminalSource: "synthetic"`, a local reason, and an error code naming the ceiling. Pass
49
+ * `logCtx` when the caller is inside a turn that will write its own row, or `refusalLog` when
50
+ * the refusal happens before any row exists and this is the only chance to write one.
51
+ */
52
+ export function workflowRefusalResponse(
53
+ reason: WorkflowDenial,
54
+ logCtx?: RequestLogContext,
55
+ refusalLog?: WorkflowRefusalLog,
56
+ rootId?: string,
57
+ ): Response {
58
+ const summary = workflowDenialSummary(reason);
59
+ // Only a caller that decided the refusal ITSELF passes a root id. admitWorkflowTurn already
60
+ // records its own denials, so passing one there would double-count them.
61
+ if (rootId) recordWorkflowRefusalEvent(rootId, reason);
62
+ const recordOn = logCtx ?? refusalLog?.logCtx;
63
+ if (recordOn) {
64
+ markLocalRequestLogRefusal(recordOn, summary.code);
65
+ // A locally assigned code wins in addFinalRequestLog, so this is what names the ceiling in
66
+ // the logs column rather than the generic rate-limit classification a 429 would get.
67
+ recordOn.errorCode = summary.code;
68
+ }
69
+ if (refusalLog) {
70
+ addFinalRequestLog(refusalLog.requestId, refusalLog.start, refusalLog.logCtx, 429, {
71
+ closeReason: "terminal",
72
+ });
73
+ }
74
+ const refusal = formatErrorResponse(
75
+ 429,
76
+ reason === "workflow-sends-exhausted" ? "workflow_budget_exhausted" : "queue_capacity_exceeded",
77
+ summary.message,
78
+ );
79
+ refusal.headers.set(WORKFLOW_LOCAL_REFUSAL_HEADER, summary.code);
80
+ // Without this a browser dashboard cannot read the header at all: the data plane never sets
81
+ // Access-Control-Expose-Headers, so a cross-origin reader sees only the CORS-safelisted ones.
82
+ refusal.headers.set("Access-Control-Expose-Headers", WORKFLOW_LOCAL_REFUSAL_HEADER);
83
+ return refusal;
84
+ }
@@ -882,6 +882,36 @@ export interface OcxConfig {
882
882
  * binding under either setting -- neither is a cache-affinity preference.
883
883
  */
884
884
  cacheAffinity?: boolean;
885
+ /**
886
+ * Operator-declared quota domains: groups of credential ids that demonstrably share
887
+ * one upstream usage limit (#4546, wp6). Members of one group count once toward
888
+ * available capacity, and a quota refusal inside a group is never answered by
889
+ * rotating to another member -- the limit is the same, so the move would pay a cold
890
+ * prefix for zero new capacity.
891
+ *
892
+ * Declared groups speak only to quota. Sharing a usage limit says nothing about
893
+ * prompt-cache compatibility, which keeps its own provider-documented domain.
894
+ * Absent or empty means no declared grouping, so an unconfigured install behaves
895
+ * exactly as before.
896
+ *
897
+ * A declaration has to mean exactly one thing, so the config rejects the spellings
898
+ * that could mean two. Credential ids are provider-scoped elsewhere (the auth store
899
+ * keys an account by provider and id), so each member is written
900
+ * `"<provider>:<credential-id>"` -- a bare `"acct-1"` names one credential per
901
+ * provider and would merge unrelated domains. The provider segment is matched
902
+ * case-insensitively through the usual aliases, so `chatgpt:` and `codex:` both mean
903
+ * OpenAI. Group ids must be unique, `credentials` must be non-empty, and a credential
904
+ * may belong to at most one group; a declaration that breaks any of those is rejected
905
+ * on write and dropped with a warning on load, never resolved by list order.
906
+ */
907
+ credentialGroups?: Array<{
908
+ /** Operator-chosen group identifier; only equality matters. */
909
+ id: string;
910
+ /** Provider-qualified credential ids (`"<provider>:<credential-id>"`), non-empty. */
911
+ credentials: string[];
912
+ /** Free-text provenance note for the operator's own records. */
913
+ note?: string;
914
+ }>;
885
915
  };
886
916
  /** Active pool account id for next session. undefined = main (passthrough as-is). */
887
917
  activeCodexAccountId?: string;