@bitkyc08/opencodex 2.54.0-preview.20260914 → 2.55.0-preview.20260914

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 (86) hide show
  1. package/gui/dist/assets/{index-B4VYfZcY.js → index-DH2PUHqr.js} +10 -10
  2. package/gui/dist/index.html +1 -1
  3. package/package.json +1 -1
  4. package/src/adapters/anthropic-image-codec.ts +57 -0
  5. package/src/adapters/anthropic-image-normalize.ts +28 -1
  6. package/src/adapters/anthropic.ts +68 -6
  7. package/src/adapters/base.ts +8 -0
  8. package/src/adapters/coding-agent/protocol.ts +41 -16
  9. package/src/adapters/cursor/cursor-errors.ts +1 -1
  10. package/src/adapters/cursor/live-transport.ts +5 -1
  11. package/src/adapters/cursor/native-exec-fs.ts +10 -10
  12. package/src/adapters/cursor/native-exec-network.ts +2 -2
  13. package/src/adapters/cursor/native-exec-shell.ts +13 -12
  14. package/src/adapters/cursor/native-exec.ts +51 -10
  15. package/src/adapters/cursor/policy-error.ts +75 -0
  16. package/src/adapters/cursor/protobuf-request.ts +105 -1
  17. package/src/adapters/devin/cloud-direct/catalog.ts +34 -2
  18. package/src/adapters/devin/live-models.ts +33 -2
  19. package/src/adapters/google-wire-compiler.ts +8 -0
  20. package/src/adapters/google.ts +46 -0
  21. package/src/adapters/input-media-guard.ts +45 -0
  22. package/src/adapters/kiro/adapter.ts +8 -0
  23. package/src/adapters/kiro/payload.ts +28 -6
  24. package/src/adapters/kiro-events.ts +25 -6
  25. package/src/adapters/kiro-images.ts +30 -0
  26. package/src/adapters/kiro-retry.ts +8 -0
  27. package/src/adapters/openai-chat.ts +33 -4
  28. package/src/adapters/openai-responses.ts +26 -0
  29. package/src/adapters/registry.ts +4 -0
  30. package/src/bridge.ts +163 -116
  31. package/src/chat/image-parts.ts +151 -0
  32. package/src/chat/inbound.ts +70 -33
  33. package/src/cli/connect.ts +30 -9
  34. package/src/cli/dispatch.ts +7 -3
  35. package/src/cli/index.ts +3 -0
  36. package/src/cli/runtime-api.ts +25 -0
  37. package/src/cli/status.ts +21 -19
  38. package/src/cli/system-restart-client.ts +25 -0
  39. package/src/clients/config-export.ts +14 -4
  40. package/src/codex/app-server-processes.ts +25 -0
  41. package/src/codex/auth-context.ts +8 -0
  42. package/src/codex/autostart-health.ts +36 -2
  43. package/src/codex/catalog/provider-fetch.ts +41 -0
  44. package/src/codex/catalog-auto-refresh.ts +182 -0
  45. package/src/codex/catalog-refresh-status.ts +93 -0
  46. package/src/codex/history-provider.ts +55 -0
  47. package/src/codex/model-entitlements.ts +78 -0
  48. package/src/codex/native-profile-processes.ts +114 -15
  49. package/src/codex/prompt-text-probe.ts +274 -41
  50. package/src/codex/routing-adoption.ts +189 -0
  51. package/src/codex/routing.ts +520 -48
  52. package/src/codex/runtime.ts +249 -7
  53. package/src/combos/failover.ts +45 -0
  54. package/src/config.ts +124 -4
  55. package/src/generated/compatibility-version.json +110 -74
  56. package/src/generated/model-metadata.ts +1 -0
  57. package/src/lib/request-execution-budget.ts +202 -0
  58. package/src/lib/upstream-retry.ts +95 -8
  59. package/src/lib/workflow-budget.ts +172 -0
  60. package/src/oauth/devin.ts +57 -12
  61. package/src/providers/quota.ts +37 -6
  62. package/src/providers/registry.ts +53 -6
  63. package/src/responses/input-media.ts +65 -0
  64. package/src/responses/parser-content.ts +42 -0
  65. package/src/responses/schema.ts +12 -2
  66. package/src/server/audio-live.ts +1 -2
  67. package/src/server/audio-transcriptions.ts +1 -2
  68. package/src/server/auth-cors.ts +1 -1
  69. package/src/server/background-lifecycle.ts +18 -0
  70. package/src/server/chat-completions.ts +23 -8
  71. package/src/server/chat-native.ts +17 -17
  72. package/src/server/index.ts +24 -0
  73. package/src/server/management/request-history-routes.ts +5 -0
  74. package/src/server/request-log.ts +8 -2
  75. package/src/server/responses/compact.ts +51 -3
  76. package/src/server/responses/core.ts +238 -28
  77. package/src/server/search.ts +7 -9
  78. package/src/types/config.ts +51 -6
  79. package/src/usage/log.ts +37 -0
  80. package/src/vision/eligibility.ts +37 -4
  81. package/src/vision/index.ts +1 -0
  82. package/src/vision/plan.ts +45 -10
  83. package/src/web-search/alpha-search.ts +324 -0
  84. package/src/web-search/index.ts +13 -22
  85. package/src/web-search/passthrough-bridge.ts +195 -22
  86. package/src/web-search/sidecar-providers.ts +22 -0
@@ -1,4 +1,5 @@
1
1
  import { buildOpenAIChatPassthroughRequest, createOpenAIChatAdapter } from "../adapters/openai-chat";
2
+ import { chatBodyCarriesImage, chatBodyCarriesToolResultImage } from "../chat/image-parts";
2
3
  import type { AdapterRequest, ProviderAdapter } from "../adapters/base";
3
4
  import {
4
5
  chatCompletionsErrorBody,
@@ -19,7 +20,7 @@ import type { AdmissionLease } from "../lib/admission";
19
20
  import { readBoundedResponseBody } from "../lib/bounded-body";
20
21
  import { redactSecretString } from "../lib/redact";
21
22
  import { resolveClientRetryAfter } from "../lib/retry-after";
22
- import { isModelTextOnly } from "../vision";
23
+ import { isModelTextOnly, requiresVisionPreprocessing } from "../vision";
23
24
  import {
24
25
  applyUpstreamRecoveryInit,
25
26
  fetchWithResetRetry,
@@ -138,7 +139,7 @@ function isRec(value: unknown): value is Rec {
138
139
  return value !== null && typeof value === "object" && !Array.isArray(value);
139
140
  }
140
141
 
141
- export function isNativeChatRouteEligible(route: RouteResult, rawBody: Rec): boolean {
142
+ export function isNativeChatRouteEligible(route: RouteResult, rawBody: Rec, config?: OcxConfig): boolean {
142
143
  const provider = route.provider;
143
144
  if (provider.adapter !== "openai-chat") return false;
144
145
  if (provider.authMode !== undefined && provider.authMode !== "key" && provider.authMode !== "local") return false;
@@ -147,12 +148,24 @@ export function isNativeChatRouteEligible(route: RouteResult, rawBody: Rec): boo
147
148
  if (rawBody.store === true || rawBody.background === true) return false;
148
149
  if (typeof rawBody.previous_response_id === "string" && rawBody.previous_response_id.length > 0) return false;
149
150
  if (rawBody.compaction_trigger !== undefined) return false;
151
+ // A standard Chat tool message accepts a string or text parts, not image_url, so
152
+ // normalizing a Pi/Anthropic tool image into image_url is not enough on its own —
153
+ // the part is still inside a tool message. The translated adapter already places
154
+ // tool-result images in a following user carrier after the complete paired batch
155
+ // (flushToolResultImages), so divert these requests there. Ordinary user images and
156
+ // text-only tool results keep the native fast path.
157
+ if (chatBodyCarriesToolResultImage(rawBody)) return false;
150
158
  // Vision sidecar coverage (roadmap 180): a text-only routed model with an
151
159
  // image-bearing body must go through the Responses pipeline, whose plan
152
160
  // site describes or strips the image. The native fast path has no vision
153
161
  // handling, so letting it keep such a request forwards raw pixels to a
154
162
  // model the operator declared blind.
155
- if (isModelTextOnly(provider, route.modelId) && chatBodyCarriesImage(rawBody)) return false;
163
+ if (chatBodyCarriesImage(rawBody)) {
164
+ const needsVision = config
165
+ ? requiresVisionPreprocessing(config, provider, route.modelId, route.providerName)
166
+ : isModelTextOnly(provider, route.modelId);
167
+ if (needsVision) return false;
168
+ }
156
169
  if (Array.isArray(rawBody.tools)) {
157
170
  for (const tool of rawBody.tools) {
158
171
  if (!isRec(tool)) continue;
@@ -164,19 +177,6 @@ export function isNativeChatRouteEligible(route: RouteResult, rawBody: Rec): boo
164
177
  return true;
165
178
  }
166
179
 
167
- /** Any messages[].content[] part of type image_url. */
168
- function chatBodyCarriesImage(rawBody: Rec): boolean {
169
- const messages = rawBody.messages;
170
- if (!Array.isArray(messages)) return false;
171
- for (const message of messages) {
172
- if (!isRec(message) || !Array.isArray(message.content)) continue;
173
- for (const part of message.content) {
174
- if (isRec(part) && part.type === "image_url") return true;
175
- }
176
- }
177
- return false;
178
- }
179
-
180
180
  function chatCompletionJson(value: unknown): Rec | null {
181
181
  if (!isRec(value) || !Array.isArray(value.choices) || value.choices.length === 0) return null;
182
182
  return value;
@@ -326,7 +326,7 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio
326
326
  dispatchOverride: async (_input, init, execute) => {
327
327
  if (!providerApiKeySelectionIsCurrent(config, route.providerName, activeProvider)) {
328
328
  const current = resolveCurrentProviderApiKeyTransport(config, route.providerName, activeProvider);
329
- if (!current || !isNativeChatRouteEligible({ ...route, provider: current }, options.chatBody)) {
329
+ if (!current || !isNativeChatRouteEligible({ ...route, provider: current }, options.chatBody, config)) {
330
330
  throw new Error("Provider key selection is no longer available for native Chat");
331
331
  }
332
332
  activeProvider = current;
@@ -134,6 +134,7 @@ import {
134
134
  type RequestLogEntry,
135
135
  } from "./request-log";
136
136
  import { sessionLaneIdFromRequest } from "./request-log-conversation";
137
+ import { admitWorkflowTurn, type WorkflowLane } from "../lib/workflow-budget";
137
138
  export {
138
139
  addFinalRequestLog,
139
140
  filterRequestLogs,
@@ -1292,13 +1293,36 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
1292
1293
  ): Promise<Response> {
1293
1294
  const lease = tryAdmitTurn(sessionLaneIdFromRequest(req.headers));
1294
1295
  if (!lease) return serverBusyResponse(req, "active turns", policy);
1296
+ // A fan-out shares the conversation it serves. Without a reserve, a worker burst takes every
1297
+ // slot under its own root and the interactive turn that started it waits behind its own
1298
+ // children. A request that names a parent is treated as that fan-out; a top-level request is
1299
+ // the conversation and may use the reserved slots.
1300
+ const workflowRootId = req.headers.get("x-codex-parent-thread-id")?.trim() || undefined;
1301
+ const workflowThreadId = req.headers.get("thread-id")?.trim() || undefined;
1302
+ const workflowLane: WorkflowLane = workflowRootId !== undefined
1303
+ && workflowThreadId !== undefined
1304
+ && workflowThreadId !== workflowRootId
1305
+ ? "worker"
1306
+ : "interactive";
1307
+ const workflow = admitWorkflowTurn(workflowRootId, workflowLane, undefined, workflowThreadId);
1308
+ if (workflow && !workflow.admitted) {
1309
+ lease.release();
1310
+ return formatErrorResponse(
1311
+ 429,
1312
+ workflow.reason === "workflow-sends-exhausted" ? "workflow_budget_exhausted" : "queue_capacity_exceeded",
1313
+ "This task has reached its concurrent-work limit, so no further upstream request was made. Work already in flight settles as it finishes.",
1314
+ );
1315
+ }
1316
+ const releaseWorkflow = (): void => { if (workflow?.admitted) workflow.lease.release(); };
1295
1317
  let response: Response;
1296
1318
  try {
1297
1319
  response = await work(lease);
1298
1320
  } catch (error) {
1321
+ releaseWorkflow();
1299
1322
  lease.release();
1300
1323
  throw error;
1301
1324
  }
1325
+ releaseWorkflow();
1302
1326
  if (!lease.isTransferred()) {
1303
1327
  lease.release();
1304
1328
  }
@@ -151,6 +151,11 @@ export async function handleRequestHistoryRoutes(ctx: ManagementContext): Promis
151
151
  return jsonResponse({
152
152
  requestId,
153
153
  routeDecision: trace,
154
+ // The account decision belongs in the why-this-route answer: a rebound with its cause is
155
+ // the difference between "the pool moved this conversation" and "this is a new session".
156
+ affinity: entry.affinity
157
+ ? { move: entry.affinity, reason: entry.affinityReason ?? null }
158
+ : null,
154
159
  attemptSequence: entry.attempts ?? [],
155
160
  outcome: {
156
161
  status: entry.status,
@@ -12,6 +12,7 @@ import {
12
12
  upstreamErrorMessageFromPayload,
13
13
  } from "../lib/errors";
14
14
  import { CODEX_CONFIG_PATH, readRootTomlString } from "../codex/paths";
15
+ import type { CodexAffinityMove, CodexAffinityReason } from "../codex/routing";
15
16
  import { readCodexCatalogPath } from "../codex/catalog";
16
17
  import type { AttemptTierOutcome, OcxProviderConfig, OcxUsage } from "../types";
17
18
  import { normalizeRouteDecisionTrace, type RouteDecisionTraceV1 } from "../routing/trace";
@@ -139,7 +140,9 @@ export interface RequestLogContext {
139
140
  errorCode?: string;
140
141
  /** Structured reason from `response.incomplete`; internal-only input to log classification. */
141
142
  terminalIncompleteReason?: string;
142
- affinity?: "reused" | "new_bind" | "rebound" | "cleared";
143
+ affinity?: CodexAffinityMove;
144
+ /** Why the binding was kept, moved, or released (#4546). */
145
+ affinityReason?: CodexAffinityReason;
143
146
  transportPhase?: "pre_headers" | "mid_stream" | "terminal_sse";
144
147
  terminalSource?: "upstream" | "synthetic";
145
148
  /** Bounded route-decision trace (RI-01); never contains secrets. */
@@ -204,7 +207,9 @@ export interface RequestLogEntry {
204
207
  totalTokens?: number;
205
208
  attempts?: PersistedUsageAttempt[];
206
209
  /** Codex pool affinity decision for this request (diagnostics for #186). */
207
- affinity?: "reused" | "new_bind" | "rebound" | "cleared";
210
+ affinity?: CodexAffinityMove;
211
+ /** Why that decision was made (#4546): a move is the expensive event, so it names its cause. */
212
+ affinityReason?: CodexAffinityReason;
208
213
  /** Where the upstream terminal/failure was observed. */
209
214
  transportPhase?: "pre_headers" | "mid_stream" | "terminal_sse";
210
215
  /** Whether the terminal came from a real upstream SSE event or a proxy synthetic tail. */
@@ -1080,6 +1085,7 @@ export function addFinalRequestLog(
1080
1085
  ...(totalTokens !== undefined ? { totalTokens } : {}),
1081
1086
  ...(attempts !== undefined ? { attempts } : {}),
1082
1087
  ...(logCtx.affinity ? { affinity: logCtx.affinity } : {}),
1088
+ ...(logCtx.affinityReason ? { affinityReason: logCtx.affinityReason } : {}),
1083
1089
  ...(logCtx.transportPhase ? { transportPhase: logCtx.transportPhase } : {}),
1084
1090
  ...(logCtx.terminalSource ? { terminalSource: logCtx.terminalSource } : {}),
1085
1091
  ...(logCtx.routeDecision ? { routeDecision: logCtx.routeDecision } : {}),
@@ -76,8 +76,14 @@ import {
76
76
  fetchWithResetRetry,
77
77
  fetchWithTransientRetry,
78
78
  applyUpstreamRecoveryInit,
79
+ SendBudgetExhaustedError,
80
+ TRANSIENT_RETRY_MAX_ATTEMPTS,
79
81
  type UpstreamSendRecovery,
80
82
  } from "../../lib/upstream-retry";
83
+ import {
84
+ createRequestExecutionBudget,
85
+ type RequestExecutionBudget,
86
+ } from "../../lib/request-execution-budget";
81
87
  import { classifyTransportFailureKind, transportErrorCode } from "../../lib/upstream-reachability";
82
88
  import {
83
89
  acquireUpstreamHostAdmission,
@@ -224,6 +230,14 @@ export interface HandleResponsesCompactOptions {
224
230
  nativeMainRefreshDependencies?: NativeMainRefreshDependencies;
225
231
  /** Release the listener's idle guard only after the complete request body is accepted. */
226
232
  onRequestBodyRead?: () => void;
233
+ /**
234
+ * The logical request's send budget (#4546). Compact used to hold its own: the normal send
235
+ * took a fresh transient allowance of three, the 401 replay and the 429 alternate each added
236
+ * one -- and the guard that was supposed to make those two mutually exclusive keys on
237
+ * `kind === "pool"`, so a main-pool credential could spend all five. The recursive handoff
238
+ * child then started over, so one compact could reach ten.
239
+ */
240
+ sendBudget?: RequestExecutionBudget;
227
241
  }
228
242
 
229
243
  export function compactResponseTooLargeError(): Response {
@@ -760,6 +774,10 @@ export async function handleResponsesCompact(
760
774
  // so routed-model reasoning items (reasoning_text content) don't 400 the ChatGPT backend.
761
775
  const compactBody = sanitizeReasoningInputContent(compactBodyRaw) as typeof compactBodyRaw;
762
776
  const compactUrl = `${base}/responses/compact`;
777
+ // One holder for this logical compact, inherited by the handoff child so a second model
778
+ // does not start over with a fresh four.
779
+ const sendBudget: RequestExecutionBudget = options.sendBudget ?? createRequestExecutionBudget();
780
+ const compactTargetKey = `${route.providerName}|${route.modelId}|compact`;
763
781
  const actualCompactHostKey = upstreamHostHealthKey(
764
782
  route.providerName,
765
783
  safeOriginLabel(compactUrl),
@@ -834,6 +852,27 @@ export async function handleResponsesCompact(
834
852
  // wrapping reset retry — because those retries happen before any alternate is even
835
853
  // considered. The alternate is one bounded send: a second ladder would multiply the
836
854
  // work an already-rejecting pool is doing.
855
+ //
856
+ // Both modes now draw one shared budget. The comment below used to say the 401 replay
857
+ // spends the account budget so the 429 alternate is skipped, but that guard is keyed on
858
+ // `kind === "pool"` and a main-pool credential left it false -- so 401 then 429 really did
859
+ // reach five. The single sends spend the base allowance first and then the one shared
860
+ // final-recovery reserve, which is the same rule the Responses path follows.
861
+ const sendSingleCompactAttempt = (
862
+ doFetch: () => Promise<Response>,
863
+ ): Promise<Response> => {
864
+ if (sendBudget.remainingBaseSends(TRANSIENT_RETRY_MAX_ATTEMPTS) > 0) {
865
+ sendBudget.used += 1;
866
+ return doFetch();
867
+ }
868
+ const decision = sendBudget.reserveDispatch({
869
+ sendClass: "auth-recovery",
870
+ targetKey: compactTargetKey,
871
+ });
872
+ if (!decision.allowed) return Promise.reject(new SendBudgetExhaustedError(safeHostLabel(compactUrl)));
873
+ if (!decision.permit.use()) return Promise.reject(new SendBudgetExhaustedError(safeHostLabel(compactUrl)));
874
+ return doFetch();
875
+ };
837
876
  const sendCompactAttempt = (
838
877
  sendProvider: OcxProviderConfig,
839
878
  sendHeaders: Headers,
@@ -866,8 +905,15 @@ export async function handleResponsesCompact(
866
905
  return res;
867
906
  });
868
907
  return recovery === "single"
869
- ? doFetch()
870
- : fetchWithTransientRetry(doFetch, { abortSignal: req.signal, label: safeHostLabel(compactUrl) });
908
+ ? sendSingleCompactAttempt(doFetch)
909
+ : fetchWithTransientRetry(doFetch, {
910
+ abortSignal: req.signal,
911
+ label: safeHostLabel(compactUrl),
912
+ // Draws the shared remainder instead of a fresh three. Compact is a native endpoint
913
+ // of the same logical turn, so its sends belong to the same cap.
914
+ attempts: sendBudget.remainingBaseSends(TRANSIENT_RETRY_MAX_ATTEMPTS),
915
+ onSendsConsumed: (used: number) => { sendBudget.used += Math.max(0, used); },
916
+ });
871
917
  };
872
918
 
873
919
  // The account each outcome belongs to. Reassigned only when the alternate send below
@@ -1133,7 +1179,9 @@ export async function handleResponsesCompact(
1133
1179
  logCtx,
1134
1180
  turnAdmissionLease,
1135
1181
  admission,
1136
- options,
1182
+ // The handoff child is the same logical compact on a second model, so it inherits
1183
+ // the holder. Forwarding `options` alone was not enough: the child minted its own.
1184
+ { ...options, sendBudget },
1137
1185
  );
1138
1186
  if (fallback.ok || fallback.status === 499) return fallback;
1139
1187
  await fallback.body?.cancel().catch(() => undefined);