@bitkyc08/opencodex 2.19.0 → 2.20.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 (119) hide show
  1. package/gui/dist/assets/index-DF_UFrGS.css +1 -0
  2. package/gui/dist/assets/index-DSK3S5HY.js +76 -0
  3. package/gui/dist/index.html +2 -2
  4. package/package.json +1 -1
  5. package/src/adapters/mimo-free.ts +17 -0
  6. package/src/adapters/openai-chat.ts +367 -32
  7. package/src/adapters/registry.ts +144 -0
  8. package/src/chat/inbound.ts +13 -7
  9. package/src/cli/claude.ts +2 -1
  10. package/src/cli/combo.ts +3 -0
  11. package/src/cli/dispatch.ts +8 -0
  12. package/src/cli/export-command.ts +2 -2
  13. package/src/cli/help.ts +2 -0
  14. package/src/cli/index.ts +3 -2
  15. package/src/cli/lab.ts +135 -1
  16. package/src/cli/minimax.ts +491 -0
  17. package/src/cli/models-runtime.ts +22 -1
  18. package/src/cli/models.ts +67 -2
  19. package/src/cli/opencode.ts +2 -1
  20. package/src/cli/registry.ts +22 -2
  21. package/src/clients/config-export.ts +125 -7
  22. package/src/codex/app-server-processes.ts +57 -2
  23. package/src/codex/app-server-restart-service.ts +232 -0
  24. package/src/codex/catalog/aggregation.ts +10 -1
  25. package/src/codex/catalog/effort.ts +15 -3
  26. package/src/codex/catalog/parsing.ts +3 -1
  27. package/src/codex/catalog/provider-fetch.ts +45 -5
  28. package/src/codex/catalog/sync.ts +74 -4
  29. package/src/codex/convergence.ts +2 -0
  30. package/src/combos/index.ts +1 -0
  31. package/src/combos/request.ts +30 -0
  32. package/src/combos/types.ts +6 -0
  33. package/src/config.ts +52 -0
  34. package/src/generated/compatibility-version.json +224 -76
  35. package/src/images/loop.ts +11 -1
  36. package/src/integrations/registry.ts +7 -0
  37. package/src/lab/conformance/jcs.ts +42 -2
  38. package/src/lab/conformance/negative-controls.ts +6 -2
  39. package/src/lab/conformance/runner.ts +16 -5
  40. package/src/lab/fabric/observe.ts +49 -14
  41. package/src/lab/index.ts +16 -0
  42. package/src/lab/ledger/purge.ts +152 -83
  43. package/src/lab/ledger/store.ts +168 -54
  44. package/src/lab/observe/from-conformance.ts +8 -6
  45. package/src/lab/observe/from-live.ts +8 -2
  46. package/src/lab/paths.ts +23 -0
  47. package/src/lab/public/bundle.ts +217 -0
  48. package/src/lab/public/community-authority.ts +175 -0
  49. package/src/lab/public/community-files.ts +29 -0
  50. package/src/lab/public/community.ts +479 -0
  51. package/src/lab/public/file-safety.ts +155 -0
  52. package/src/lab/public/ids.ts +26 -0
  53. package/src/lab/public/index.ts +16 -0
  54. package/src/lab/public/mutation-lock.ts +424 -0
  55. package/src/lab/public/operator.ts +353 -0
  56. package/src/lab/public/origin-purge.ts +79 -0
  57. package/src/lab/public/origin.ts +203 -0
  58. package/src/lab/public/privacy.ts +143 -0
  59. package/src/lab/public/private-file.ts +261 -0
  60. package/src/lab/public/project.ts +124 -0
  61. package/src/lab/public/purge-test-fault.ts +21 -0
  62. package/src/lab/public/purge.ts +223 -0
  63. package/src/lab/public/registry.ts +44 -0
  64. package/src/lab/public/revocation.ts +252 -0
  65. package/src/lab/public/signature.ts +219 -0
  66. package/src/lab/public/storage.ts +105 -0
  67. package/src/lab/public/strict-json.ts +206 -0
  68. package/src/lab/public/time.ts +26 -0
  69. package/src/lab/public/types.ts +172 -0
  70. package/src/lab/public/validate.ts +391 -0
  71. package/src/lib/codex-restart-contract.ts +120 -0
  72. package/src/lib/lab-activation.ts +109 -47
  73. package/src/lib/lab-live-pinned-sender.ts +16 -5
  74. package/src/lib/pinned-http.ts +70 -16
  75. package/src/lib/self-launch-argv.ts +15 -0
  76. package/src/lib/state-store-registrations.ts +2 -0
  77. package/src/lib/upstream-reachability.ts +4 -0
  78. package/src/lib/windows-elevation.ts +10 -1
  79. package/src/providers/derive.ts +24 -4
  80. package/src/providers/registry.ts +7 -3
  81. package/src/providers/request-pacing.ts +310 -0
  82. package/src/providers/service-tier.ts +143 -0
  83. package/src/providers/static-model-discovery.ts +86 -0
  84. package/src/reasoning-effort.ts +27 -1
  85. package/src/router.ts +23 -6
  86. package/src/routing/capability.ts +4 -2
  87. package/src/routing/compatibility/behavior.ts +5 -1
  88. package/src/server/adapter-resolve.ts +2 -32
  89. package/src/server/auth-cors.ts +8 -0
  90. package/src/server/chat-completions.ts +74 -36
  91. package/src/server/chat-native-sse.ts +331 -0
  92. package/src/server/chat-native.ts +371 -0
  93. package/src/server/management/combo-routes.ts +16 -2
  94. package/src/server/management/config-routes.ts +6 -4
  95. package/src/server/management/context.ts +17 -0
  96. package/src/server/management/lab-routes.ts +181 -19
  97. package/src/server/management/model-routes.ts +76 -2
  98. package/src/server/management/model-rows.ts +8 -0
  99. package/src/server/management/provider-capability-config.ts +48 -0
  100. package/src/server/management/provider-routes.ts +76 -4
  101. package/src/server/management/system-restart.ts +4 -2
  102. package/src/server/management/system-routes.ts +38 -0
  103. package/src/server/relay.ts +17 -3
  104. package/src/server/responses/compact.ts +4 -1
  105. package/src/server/responses/core.ts +257 -46
  106. package/src/server/responses/empty-completion-guard.ts +276 -0
  107. package/src/server/responses/fetch-helpers.ts +35 -4
  108. package/src/server/responses/pacing-overload.ts +13 -0
  109. package/src/server/responses/policy-fallback.ts +16 -2
  110. package/src/server/responses/terminal-guard.ts +1 -1
  111. package/src/server/responses/upstream-error.ts +5 -0
  112. package/src/server/responses.ts +17 -2
  113. package/src/types.ts +66 -4
  114. package/src/update/index.ts +6 -5
  115. package/src/update/job.ts +5 -6
  116. package/src/update/notify.ts +5 -3
  117. package/src/usage/log.ts +11 -1
  118. package/gui/dist/assets/index-CQ7bIKee.css +0 -1
  119. package/gui/dist/assets/index-D_JUZLEC.js +0 -76
@@ -0,0 +1,276 @@
1
+ import type { AdapterEvent, OcxConfig, OcxUsage } from "../../types";
2
+
3
+ /**
4
+ * Empty-completion guard for Responses turns (port of codex-router's
5
+ * empty-completion-guard + single retry, PR #145).
6
+ *
7
+ * Failure mode: the upstream answers 200 and completes the turn but never
8
+ * produced output text or a tool call (a reasoning-only stream that ends with
9
+ * nothing is the canonical shape). The client has no code path for "the model
10
+ * said nothing", so it silently records the turn as done — the "random stop"
11
+ * nobody can explain. The guard holds pre-content events (reasoning deltas
12
+ * are deliberately NOT content), and when a terminal event arrives with no
13
+ * content it suppresses the terminal and retries the IDENTICAL turn once
14
+ * (same request bytes, same headers). If the retry is also empty — or fails
15
+ * upstream — the client sees a stated failure instead of a second silent
16
+ * success.
17
+ *
18
+ * The retry is explicitly enabled by top-level config. The environment switch
19
+ * is a disable-only emergency override: OCX_EMPTY_COMPLETION_RETRY=0 restores
20
+ * the previous relay behavior without editing the persisted config.
21
+ */
22
+ export const EMPTY_COMPLETION_RETRY_ENV = "OCX_EMPTY_COMPLETION_RETRY";
23
+
24
+ /** Retained pre-content events are bounded independently by count and encoded size. */
25
+ export const EMPTY_COMPLETION_MAX_BUFFERED_EVENTS = 1_024;
26
+ export const EMPTY_COMPLETION_MAX_BUFFERED_BYTES = 1_048_576;
27
+
28
+ export function emptyCompletionRetryEnabled(
29
+ config: Pick<OcxConfig, "emptyCompletionRetry">,
30
+ env: Record<string, string | undefined> = process.env,
31
+ ): boolean {
32
+ return config.emptyCompletionRetry === true && env[EMPTY_COMPLETION_RETRY_ENV] !== "0";
33
+ }
34
+
35
+ /** Surfaced when the single retry was also empty or failed upstream. */
36
+ export const EMPTY_COMPLETION_RETRY_FAILED_CODE = "empty_completion_retry_failed";
37
+
38
+ /**
39
+ * Terminal stop reasons the bridge renders as a visible `response.incomplete`
40
+ * (max_tokens / content_filter). Those are already a stated failure, not the
41
+ * silent empty success this guard exists to catch, and retrying the identical
42
+ * request would burn tokens for the same truncated result.
43
+ */
44
+ const VISIBLE_INCOMPLETE_STOP_REASONS = new Set(["max_tokens", "content_filter"]);
45
+ const UTF8_ENCODER = new TextEncoder();
46
+
47
+ function retainedEventBytes(event: AdapterEvent): number {
48
+ return UTF8_ENCODER.encode(JSON.stringify(event)).byteLength;
49
+ }
50
+
51
+ function isReasoningEvent(event: AdapterEvent): boolean {
52
+ return event.type === "thinking_delta"
53
+ || event.type === "thinking_signature"
54
+ || event.type === "redacted_thinking"
55
+ || event.type === "kiro_redacted_reasoning"
56
+ || event.type === "reasoning_raw_delta";
57
+ }
58
+
59
+ function isTerminalEvent(
60
+ event: AdapterEvent,
61
+ ): event is Extract<AdapterEvent, { type: "done" | "incomplete" | "error" }> {
62
+ return event.type === "done" || event.type === "incomplete" || event.type === "error";
63
+ }
64
+
65
+ /**
66
+ * Content means something the client can act on: output text or a tool call
67
+ * (web-search cells included). Reasoning deltas are deliberately not content —
68
+ * a turn that streams only reasoning and then completes with nothing is
69
+ * exactly the empty completion this guard exists to catch. Empty text deltas
70
+ * (some batch adapters always carry `""`) are not content either.
71
+ */
72
+ export function isContentEvent(event: AdapterEvent): boolean {
73
+ switch (event.type) {
74
+ case "text_delta":
75
+ return event.text.length > 0;
76
+ case "tool_call_start":
77
+ case "tool_call_delta":
78
+ case "tool_call_end":
79
+ case "web_search_call_begin":
80
+ case "web_search_call_end":
81
+ return true;
82
+ default:
83
+ return false;
84
+ }
85
+ }
86
+
87
+ export function emptyCompletionRetryFailedEvent(
88
+ usage?: OcxUsage,
89
+ retryFailedUpstream = false,
90
+ ): Extract<AdapterEvent, { type: "error" }> {
91
+ return {
92
+ type: "error",
93
+ status: 502,
94
+ errorType: "upstream_error",
95
+ code: EMPTY_COMPLETION_RETRY_FAILED_CODE,
96
+ message: retryFailedUpstream
97
+ ? "The model returned an empty completion and the retry failed upstream."
98
+ : "The model returned an empty completion. opencodex retried once and the completion was empty again.",
99
+ ...(usage ? { usage } : {}),
100
+ };
101
+ }
102
+
103
+ /**
104
+ * Sum two usage snapshots. Same semantics as terminal-guard's mergeUsage and
105
+ * request-log's aggregateAttemptUsage: token totals add across the attempts;
106
+ * `estimated` wins when either attempt only estimated.
107
+ */
108
+ export function mergeUsage(
109
+ first: OcxUsage | undefined,
110
+ second: OcxUsage | undefined,
111
+ ): OcxUsage | undefined {
112
+ if (!first) return second;
113
+ if (!second) return first;
114
+ const sumOptional = (key: keyof OcxUsage): number | undefined => {
115
+ const left = first[key];
116
+ const right = second[key];
117
+ return typeof left === "number" || typeof right === "number"
118
+ ? (typeof left === "number" ? left : 0) + (typeof right === "number" ? right : 0)
119
+ : undefined;
120
+ };
121
+ const cachedInputTokens = sumOptional("cachedInputTokens");
122
+ const cacheReadInputTokens = sumOptional("cacheReadInputTokens");
123
+ const cacheCreationInputTokens = sumOptional("cacheCreationInputTokens");
124
+ const reasoningOutputTokens = sumOptional("reasoningOutputTokens");
125
+ const contextTotalTokens = second.contextTotalTokens ?? first.contextTotalTokens;
126
+ const inputTokens = first.inputTokens + second.inputTokens;
127
+ const outputTokens = first.outputTokens + second.outputTokens;
128
+ return {
129
+ inputTokens,
130
+ outputTokens,
131
+ totalTokens: inputTokens + outputTokens,
132
+ ...(contextTotalTokens !== undefined ? { contextTotalTokens } : {}),
133
+ ...(cachedInputTokens !== undefined ? { cachedInputTokens } : {}),
134
+ ...(cacheReadInputTokens !== undefined ? { cacheReadInputTokens } : {}),
135
+ ...(cacheCreationInputTokens !== undefined ? { cacheCreationInputTokens } : {}),
136
+ ...(reasoningOutputTokens !== undefined ? { reasoningOutputTokens } : {}),
137
+ ...(first.estimated || second.estimated ? { estimated: true } : {}),
138
+ };
139
+ }
140
+
141
+ export interface EmptyCompletionGuardOptions {
142
+ firstEvents: AsyncIterable<AdapterEvent>;
143
+ /**
144
+ * Re-run the IDENTICAL turn: same request body, same headers, same signal.
145
+ * Receives no arguments — the request must not be modified between attempts.
146
+ */
147
+ continuation: () => AsyncIterable<AdapterEvent> | Promise<AsyncIterable<AdapterEvent>>;
148
+ /** How many times an empty completion is retried; default 1 (the router's single retry). */
149
+ maxRetries?: number;
150
+ }
151
+
152
+ /**
153
+ * Watch an adapter event stream for the empty-completion failure mode. Events
154
+ * are held until the turn produces content or ends: reasoning and other
155
+ * pre-content events stay buffered (released in order on first content), the
156
+ * terminal is withheld, and an empty terminal triggers one identical-turn
157
+ * retry through `continuation`. Usage is merged across attempts so the bridge
158
+ * and request log meter the whole turn, not just the attempt that succeeded.
159
+ *
160
+ * Heartbeats always pass through untouched: they feed the bridge's stall
161
+ * watchdog, so holding them behind the content gate would trip false
162
+ * upstream_stall_timeout failures on slow reasoning-only turns.
163
+ */
164
+ export async function* guardEmptyCompletionEventStream(
165
+ options: EmptyCompletionGuardOptions,
166
+ ): AsyncGenerator<AdapterEvent> {
167
+ const maxRetries = Math.max(0, Math.floor(options.maxRetries ?? 1));
168
+ let source = options.firstEvents;
169
+ let held: AdapterEvent[] = [];
170
+ let heldBytes = 0;
171
+ let sawContent = false;
172
+ let passthrough = false;
173
+ let retries = 0;
174
+ let usage: OcxUsage | undefined;
175
+
176
+ const withUsage = (event: AdapterEvent & { usage?: OcxUsage }): AdapterEvent => {
177
+ const merged = mergeUsage(usage, event.usage);
178
+ return merged ? { ...event, ...(merged ? { usage: merged } : {}) } : event;
179
+ };
180
+ const releaseHeld = (): AdapterEvent[] => {
181
+ const released = held;
182
+ held = [];
183
+ heldBytes = 0;
184
+ return released;
185
+ };
186
+
187
+ while (true) {
188
+ let terminalSeen = false;
189
+ for await (const event of source) {
190
+ if (event.type === "heartbeat") {
191
+ yield event;
192
+ continue;
193
+ }
194
+ if (sawContent || passthrough) {
195
+ // Buffered content is already flowing; everything downstream passes
196
+ // through. Every terminal carries usage merged across every attempt.
197
+ yield isTerminalEvent(event) ? withUsage(event) : event;
198
+ continue;
199
+ }
200
+ if (isContentEvent(event)) {
201
+ sawContent = true;
202
+ yield* releaseHeld();
203
+ yield event;
204
+ continue;
205
+ }
206
+ if (event.type === "done") {
207
+ usage = mergeUsage(usage, event.usage);
208
+ if (event.stopReason !== undefined && VISIBLE_INCOMPLETE_STOP_REASONS.has(event.stopReason)) {
209
+ // Rendered as response.incomplete: a stated failure, not the silent
210
+ // empty success this guard exists to catch.
211
+ yield* releaseHeld();
212
+ yield { ...event, ...(usage ? { usage } : {}) };
213
+ return;
214
+ }
215
+ if (retries < maxRetries) {
216
+ // Suppress the terminal: the client must never see a completed event
217
+ // for a turn that produced nothing. Retry the identical turn.
218
+ retries += 1;
219
+ try {
220
+ source = await options.continuation();
221
+ } catch {
222
+ yield emptyCompletionRetryFailedEvent(usage, true);
223
+ return;
224
+ }
225
+ terminalSeen = true;
226
+ break;
227
+ }
228
+ // The retry was also empty: a stated failure, not a second silent
229
+ // success.
230
+ yield emptyCompletionRetryFailedEvent(usage);
231
+ return;
232
+ }
233
+ if (event.type === "error") {
234
+ if (retries > 0 && event.status !== 499) {
235
+ // The retry failed upstream. Its body cannot reach the client (the
236
+ // 200 head went out with the first attempt), so state the failure in
237
+ // the stream's own error framing — same move as the router's
238
+ // empty_completion_retry_failed. Client cancels (499) pass through.
239
+ yield emptyCompletionRetryFailedEvent(mergeUsage(usage, event.usage), true);
240
+ return;
241
+ }
242
+ yield* releaseHeld();
243
+ yield withUsage(event);
244
+ return;
245
+ }
246
+ if (event.type === "incomplete") {
247
+ // A structured incomplete is already a visible failure; never convert
248
+ // it into an empty completion.
249
+ yield* releaseHeld();
250
+ yield withUsage(event);
251
+ return;
252
+ }
253
+ const eventBytes = retainedEventBytes(event);
254
+ if (held.length + 1 > EMPTY_COMPLETION_MAX_BUFFERED_EVENTS
255
+ || heldBytes + eventBytes > EMPTY_COMPLETION_MAX_BUFFERED_BYTES) {
256
+ // Preserve data rather than retaining without bound: release the prefix,
257
+ // emit this event, and stop attempting an empty-completion retry for the turn.
258
+ yield* releaseHeld();
259
+ yield event;
260
+ passthrough = true;
261
+ continue;
262
+ }
263
+ held.push(event);
264
+ heldBytes += eventBytes;
265
+ // The bridge watchdog sees only yielded events. Feed it while reasoning is
266
+ // held so a long reasoning-only prefix remains live without exposing it early.
267
+ if (isReasoningEvent(event)) yield { type: "heartbeat" };
268
+ }
269
+ if (!terminalSeen) {
270
+ // The source ended without a terminal event (truncated stream). Release
271
+ // what was held so the bridge can mark the stream incomplete.
272
+ yield* releaseHeld();
273
+ return;
274
+ }
275
+ }
276
+ }
@@ -102,6 +102,7 @@ import {
102
102
  } from "../relay";
103
103
  import { hasResponsesItemIdRepair, relaySseWithResponsesItemIdRepair } from "../responses-item-id-repair";
104
104
  import type { EffectiveSubagentRoster, SpawnAgentSurface } from "../../codex/catalog";
105
+ import { waitForProviderRequestSlot } from "../../providers/request-pacing";
105
106
 
106
107
 
107
108
  export function disableResponsesRequestTimeout(req: Request, server: Pick<Server<WsData>, "timeout"> | undefined): boolean {
@@ -136,21 +137,48 @@ export function safeOriginLabel(url: string): string {
136
137
 
137
138
 
138
139
 
140
+ export interface PaceAwareFetch {
141
+ waitForPacing?: (signal?: AbortSignal) => Promise<void>;
142
+ unpacedFetch?: typeof globalThis.fetch;
143
+ }
144
+
145
+ export type ProviderFetch = typeof globalThis.fetch & PaceAwareFetch;
146
+
147
+ export interface ProviderFetchOptions {
148
+ providerName?: string;
149
+ modelId?: string;
150
+ }
151
+
139
152
  export function providerFetch(
140
153
  provider: OcxProviderConfig,
141
154
  runtime: BunRuntimeGateInput = currentBunRuntimeIdentity(),
142
- ): typeof globalThis.fetch {
155
+ options: ProviderFetchOptions = {},
156
+ ): ProviderFetch {
143
157
  const base = (provider as OcxProviderConfig & { fetch?: typeof globalThis.fetch }).fetch ?? globalThis.fetch;
144
158
  // ChatGPT Codex backend: streaming turns ride the responses_websockets
145
159
  // transport (measured ~3s faster TTFT than the SSE POST queue); everything
146
160
  // else keeps the provider's HTTP fetch. See ws-upstream.ts for the details.
147
- const wrapped = (input: Parameters<typeof globalThis.fetch>[0], init?: RequestInit) => {
161
+ const unpaced = async (input: Parameters<typeof globalThis.fetch>[0], init?: RequestInit) => {
148
162
  if (typeof input === "string" && init && shouldUseCodexWsUpstream(input, init, runtime)) {
149
163
  return codexWsUpstreamFetch(input, init, base, runtime);
150
164
  }
151
165
  return base(input, init);
152
166
  };
153
- return wrapped as typeof globalThis.fetch;
167
+ const waitForPacing = (signal?: AbortSignal) => options.providerName
168
+ ? waitForProviderRequestSlot(options.providerName, provider, options.modelId, signal)
169
+ : Promise.resolve();
170
+ const wrapped = async (input: Parameters<typeof globalThis.fetch>[0], init?: RequestInit) => {
171
+ await waitForPacing(init?.signal ?? undefined);
172
+ return unpaced(input, init);
173
+ };
174
+ const preconnect = (...args: Parameters<typeof globalThis.fetch.preconnect>): void => {
175
+ base.preconnect?.(...args);
176
+ };
177
+ return Object.assign(wrapped, {
178
+ preconnect,
179
+ waitForPacing,
180
+ unpacedFetch: Object.assign(unpaced, { preconnect }),
181
+ });
154
182
  }
155
183
 
156
184
 
@@ -164,6 +192,9 @@ export async function fetchWithHeaderTimeout(
164
192
  executor: typeof globalThis.fetch = globalThis.fetch,
165
193
  manualRedirect = false,
166
194
  ): Promise<Response> {
195
+ const pacing = executor as ProviderFetch;
196
+ await pacing.waitForPacing?.(abortSignal);
197
+ const fetchExecutor = pacing.unpacedFetch ?? executor;
167
198
  const timeout = new AbortController();
168
199
  const timer = setTimeout(() => {
169
200
  if (!timeout.signal.aborted) timeout.abort(new DOMException("Timeout elapsed", "TimeoutError"));
@@ -175,7 +206,7 @@ export async function fetchWithHeaderTimeout(
175
206
  headers.set("accept-encoding", "identity");
176
207
  }
177
208
  try {
178
- return await executor(url, {
209
+ return await fetchExecutor(url, {
179
210
  ...init,
180
211
  headers,
181
212
  // Credential-bearing sends opt into manual redirects so a 3xx is relayed
@@ -0,0 +1,13 @@
1
+ import { formatErrorResponse } from "../../bridge";
2
+ import { RequestPacingQueueOverloadError } from "../../providers/request-pacing";
3
+
4
+ /** Convert local request-pacing admission failures into a retryable HTTP response. */
5
+ export function requestPacingOverloadResponse(error: unknown): Response | undefined {
6
+ if (!(error instanceof RequestPacingQueueOverloadError)) return undefined;
7
+ return formatErrorResponse(
8
+ 429,
9
+ "rate_limit_error",
10
+ error.message,
11
+ { retryAfter: String(error.retryAfterSeconds) },
12
+ );
13
+ }
@@ -5,6 +5,7 @@ import { finishRequestAttempt, type RequestLogContext } from "../request-log";
5
5
  import type { OcxConfig } from "../../types";
6
6
  import type { RouteCandidateTrace, RouteDecisionTraceV1 } from "../../routing/trace";
7
7
  import { handleResponses as handleResponsesCore } from "./core";
8
+ import { requestPacingOverloadResponse } from "./pacing-overload";
8
9
 
9
10
  type CoreHandler = typeof handleResponsesCore;
10
11
  type CoreOptions = Parameters<CoreHandler>[3];
@@ -133,7 +134,14 @@ export async function handleResponsesWithPolicyFallback(
133
134
  // Core owns the client-facing parse/decompression error.
134
135
  }
135
136
 
136
- let response = await runCore(req, config, logCtx, coreOptions);
137
+ let response: Response;
138
+ try {
139
+ response = await runCore(req, config, logCtx, coreOptions);
140
+ } catch (error) {
141
+ const overload = requestPacingOverloadResponse(error);
142
+ if (overload) return overload;
143
+ throw error;
144
+ }
137
145
  const initialTrace = logCtx.routeDecision;
138
146
  const initialRequestedModel = logCtx.requestedModel;
139
147
  if (!rawBody || !isPolicyDecision(initialTrace)) return response;
@@ -151,7 +159,13 @@ export async function handleResponsesWithPolicyFallback(
151
159
  finishFailedPolicyAttempt(logCtx, response.status);
152
160
  const retryRequest = requestWithCandidate(req, rawBody, next);
153
161
  try {
154
- response = await runCore(retryRequest, config, logCtx, coreOptions);
162
+ try {
163
+ response = await runCore(retryRequest, config, logCtx, coreOptions);
164
+ } catch (error) {
165
+ const overload = requestPacingOverloadResponse(error);
166
+ if (overload) return overload;
167
+ throw error;
168
+ }
155
169
  } finally {
156
170
  logCtx.requestedModel = initialRequestedModel;
157
171
  logCtx.routeDecision = initialTrace;
@@ -195,7 +195,7 @@ export async function* guardTerminalEventStream(options: GuardedEventStreamOptio
195
195
  for await (const event of source) {
196
196
  if (event.type === "done") {
197
197
  terminalSeen = true;
198
- const analysis = options.adapterName === "anthropic"
198
+ const analysis = (options.adapterName === "anthropic" || options.adapterName === "openai-chat")
199
199
  ? analyzeTerminalTurn(parsed, seen)
200
200
  : { decision: "pass" as const };
201
201
  const normalStop = event.stopReason !== "max_tokens" && event.stopReason !== "content_filter";
@@ -5,7 +5,12 @@
5
5
  * #553 looking for an adapter URL bug that does not exist. Name the likely cause and the
6
6
  * command that settles it.
7
7
  */
8
+ import { RequestPacingQueueOverloadError } from "../../providers/request-pacing";
9
+
8
10
  export function describeUpstreamConnectFailure(err: unknown, connectMs: number): string {
11
+ // Local pacing admission is not a transport failure. Let the outer response boundary
12
+ // preserve its retryable 429 identity instead of laundering it into a 502.
13
+ if (err instanceof RequestPacingQueueOverloadError) throw err;
9
14
  if (err instanceof Error && err.name === "TimeoutError") {
10
15
  return `Provider connect timeout after ${connectMs}ms`;
11
16
  }
@@ -1,10 +1,25 @@
1
1
  // AUTO-SPLIT facade: original responses.ts body moved into ./responses/* modules.
2
2
  // Public surface preserved exactly; importers keep using "src/server/responses".
3
+ import { handleResponsesCompact as handleResponsesCompactImpl } from "./responses/compact";
4
+ import { requestPacingOverloadResponse } from "./responses/pacing-overload";
5
+
3
6
  export { buildToolBridgeMaps, isV1CollabSurface, collabSurface, multiAgentGuidanceText, V2_GUIDANCE_CHAR_BUDGET, injectDeveloperMessage } from "./responses/collaboration";
4
7
  export type { MultiAgentGuidanceOptions, MultiAgentGuidanceDeps } from "./responses/collaboration";
5
8
  export { hasUnreadableEncryptedAgentTask, sanitizeEncryptedContentInPlace } from "./responses/encrypted-payload";
6
- export { COMPACT_RESPONSE_MAX_BYTES, bufferCompactResponse, handleResponsesCompact } from "./responses/compact";
9
+ export { COMPACT_RESPONSE_MAX_BYTES, bufferCompactResponse } from "./responses/compact";
7
10
  export { disableResponsesRequestTimeout, safeHostLabel, fetchWithHeaderTimeout } from "./responses/fetch-helpers";
8
11
  export { sidecarOutcomeRecorder, isShadowSourceModel, codexLogAccountId, usesCodexForwardPoolAuth, codexForwardTerminalOutcomeRecorder, decodeRequestErrorResponse, buildComboChildHeaders, linkAbortSignal } from "./responses/core";
9
12
  export { handleResponses, handleResponsesWithPolicyFallback, rankPolicyFallbackCandidates } from "./responses/policy-fallback";
10
- export { adapterNeedsForcedContinuation } from "./responses/core";
13
+ export { adapterNeedsForcedContinuation } from "./responses/core";
14
+
15
+ export async function handleResponsesCompact(
16
+ ...args: Parameters<typeof handleResponsesCompactImpl>
17
+ ): Promise<Response> {
18
+ try {
19
+ return await handleResponsesCompactImpl(...args);
20
+ } catch (error) {
21
+ const overload = requestPacingOverloadResponse(error);
22
+ if (overload) return overload;
23
+ throw error;
24
+ }
25
+ }
package/src/types.ts CHANGED
@@ -587,6 +587,13 @@ export interface OcxCustomModel {
587
587
  contextWindow?: number;
588
588
  /** 입력 모달리티 (선택, 기본 ["text"]) */
589
589
  inputModalities?: string[];
590
+ /**
591
+ * Reasoning ladder (Codex labels) this custom row explicitly advertises. An empty array
592
+ * hides the effort control; an omitted key leaves the provider-derived ladder in charge.
593
+ */
594
+ reasoningEfforts?: string[];
595
+ /** Default effort label when `reasoningEfforts` is non-empty. */
596
+ defaultReasoningEffort?: string;
590
597
  /** 추가 시각 (ISO 8601) */
591
598
  addedAt?: string;
592
599
  }
@@ -623,6 +630,8 @@ export interface OcxClientIntegrationsConfig {
623
630
 
624
631
  export interface OcxConfig {
625
632
  port: number;
633
+ /** Opt in to one identical-turn retry when a Responses completion has no text or tool call. */
634
+ emptyCompletionRetry?: boolean;
626
635
  /** Maximum usage-log bytes read for one management snapshot. */
627
636
  managementUsageMaxReadBytes?: number;
628
637
  providers: Record<string, OcxProviderConfig>;
@@ -645,6 +654,20 @@ export interface OcxConfig {
645
654
  * into a selector-qualified group; Codex still advertises only the first 5 visible rows.
646
655
  */
647
656
  subagentModels?: string[];
657
+ /**
658
+ * Optional full picker ordering for the Codex model catalog, independent of the
659
+ * 5-slot `subagentModels` spawn_agent cap. DISPLAY-ONLY: it controls the visual order of
660
+ * the Codex model picker for large routed catalogs (10-20+ models) that would otherwise sort
661
+ * arbitrarily and reshuffle on every rebuild. Values are routed `<provider>/<model>` catalog
662
+ * slugs (matched by exact slug or `provider/id`); native OpenAI passthrough rows and
663
+ * account-qualified native rows are not reordered (order native rows via `subagentModels`).
664
+ * Listed routed rows appear in array order; rows not listed keep their normal display order.
665
+ * `subagentModels`-featured rows keep their top position. When unset or empty, catalog
666
+ * priority is unchanged. This changes ONLY what the user sees in the picker: the spawn_agent
667
+ * candidate set is derived from each row's natural priority and is provably unaffected, even
668
+ * when every routed row is listed (see opencodex_spawn_priority / effectiveSubagentRoster).
669
+ */
670
+ modelPickerOrder?: string[];
648
671
  /**
649
672
  * Priority-ordered fallback models for spawned sub-agents. When the requested
650
673
  * model is quota-exhausted or recently failed, opencodex rewrites the child
@@ -966,6 +989,12 @@ export interface OcxComboConfig {
966
989
  stickyLimit?: number;
967
990
  /** Used when the client omits reasoning.effort. null/omitted leaves the target default unchanged. */
968
991
  defaultEffort?: OcxComboDefaultEffort | null;
992
+ /**
993
+ * Disable image input even when every target supports it.
994
+ * Omitted / `"auto"` keeps automatic capability derivation (default: enabled when
995
+ * the target intersection includes image).
996
+ */
997
+ imageInput?: "auto" | "disabled";
969
998
  /**
970
999
  * Optional public model name replacing the default `combo/<id>` slug. Bare names
971
1000
  * without "/" are allowed (e.g. "deepseek-v4-flash") so the combo can answer to a
@@ -1234,12 +1263,28 @@ export interface ProviderCostOverlay {
1234
1263
  cacheWrite: number;
1235
1264
  }
1236
1265
 
1266
+ export interface RequestPacingRule {
1267
+ /** Evenly spread request starts to this many requests per minute. */
1268
+ requestsPerMinute?: number;
1269
+ /** Minimum delay between request starts. The slower configured value wins. */
1270
+ minIntervalMs?: number;
1271
+ }
1272
+
1273
+ export interface ProviderRequestPacingConfig extends RequestPacingRule {
1274
+ /** False preserves legacy behavior with no client-side waiting. */
1275
+ enabled: boolean;
1276
+ /** Exact upstream model-id overrides; other models inherit the provider rule. */
1277
+ models?: Record<string, RequestPacingRule>;
1278
+ }
1279
+
1237
1280
  /**
1238
1281
  * One configured provider entry. `authMode` (default `"key"`) decides whether same-target 429
1239
1282
  * retries are allowed; OAuth/forward credentials and local runtimes are never replayed.
1240
1283
  */
1241
1284
  export interface OcxProviderConfig {
1242
1285
  adapter: string;
1286
+ /** Optional outbound request-start pacing shared by this provider and its model overrides. */
1287
+ requestPacing?: ProviderRequestPacingConfig;
1243
1288
  /** Cursor MCP compatibility bounds; positive integers when configured. */
1244
1289
  mcpMaxTools?: number;
1245
1290
  mcpMaxSchemaBytes?: number;
@@ -1282,8 +1327,10 @@ export interface OcxProviderConfig {
1282
1327
  */
1283
1328
  requiresAdjacentResponsesToolResults?: boolean;
1284
1329
  /**
1285
- * Whether this provider's Responses route honours the OpenAI `service_tier`
1286
- * parameter. Tri-state: `true` lets fast mode inject/remove the field (an unset
1330
+ * Provider fallback for the OpenAI `service_tier` parameter. On Responses routes this
1331
+ * is the complete wire opt-in; Chat routes additionally require `chatServiceTier` or an
1332
+ * exact-model true declaration.
1333
+ * Tri-state: `true` lets fast mode inject/remove the field (an unset
1287
1334
  * fast mode preserves a caller-supplied value); `false` strips the field and
1288
1335
  * never injects, because an upstream documented as not supporting the parameter
1289
1336
  * must not receive it; absent (`undefined`) leaves the provider unclassified —
@@ -1291,6 +1338,8 @@ export interface OcxProviderConfig {
1291
1338
  * An explicit config value always wins over the registry default.
1292
1339
  */
1293
1340
  supportsServiceTier?: boolean;
1341
+ /** Exact upstream model ids that override the provider-level service-tier capability. */
1342
+ modelSupportsServiceTier?: Record<string, boolean>;
1294
1343
  /**
1295
1344
  * Responses upstream whose native contract accepts plaintext reasoning replay
1296
1345
  * (DeepSeek documents reasoning items with plaintext content). When set, the
@@ -1468,6 +1517,18 @@ export interface OcxProviderConfig {
1468
1517
  * No effect unless `parallelToolCalls === false`; ignored by non-`openai-chat` adapters.
1469
1518
  */
1470
1519
  pinParallelToolCallsFalse?: boolean;
1520
+ /**
1521
+ * Opt-in: extend the no-tool-call terminal continuation guard to this provider's
1522
+ * `openai-chat` routed turns. The guard (originally Anthropic-only, see
1523
+ * devlog/_fin/260706_previous-response-id-400) issues one bounded internal re-ask when a
1524
+ * model announces work but ends the turn without emitting a tool call. Self-hosted
1525
+ * OpenAI-compatible gateways (GLM/Kimi-family, etc.) hit the same premature-completion
1526
+ * pattern, but the heuristic that decides a "suspicious no-tool stop" was tuned on
1527
+ * Anthropic turns, so it stays OFF by default for the many registry providers that share
1528
+ * the `openai-chat` adapter. Enable only for a provider whose models are known to stop
1529
+ * mid-work; non-`openai-chat` adapters ignore this flag.
1530
+ */
1531
+ terminalContinuationGuard?: boolean;
1471
1532
  /**
1472
1533
  * Opt-in: forward `prompt_cache_key` to the upstream `/chat/completions` body.
1473
1534
  * OpenAI-specific extension; strict backends (Groq, Cerebras, etc.) reject unknown
@@ -1479,8 +1540,9 @@ export interface OcxProviderConfig {
1479
1540
  * OpenAI-specific extension with the same hazard as `promptCacheKey` — strict backends
1480
1541
  * reject unknown fields, and 66 registry providers share the `openai-chat` adapter, so a
1481
1542
  * caller-supplied `service_tier` would otherwise turn working requests into upstream 400s.
1482
- * `supportsServiceTier` is the Responses-wire flag and does not apply here.
1483
- * Default off; only enable for providers that document this parameter on the chat wire.
1543
+ * Exact models may opt in through `modelSupportsServiceTier` instead; provider-level
1544
+ * `supportsServiceTier: false` remains a global denial. Default off; only enable for
1545
+ * providers that document this parameter on the chat wire.
1484
1546
  */
1485
1547
  chatServiceTier?: boolean;
1486
1548
  /**
@@ -10,6 +10,7 @@ import {
10
10
  } from "./npm-cache-preflight.mjs";
11
11
  import { handoffWindowsTrayForUpdate, planWindowsTrayUpdate } from "./tray-update-plan.mjs";
12
12
  import { withProcessRuntimeProvenance } from "../lib/bun-runtime";
13
+ import { selfLaunchArgv } from "../lib/self-launch-argv";
13
14
 
14
15
  /**
15
16
  * A `codex-history-backup-*.json` surviving a stop means the native-history restore was
@@ -248,7 +249,7 @@ export async function runUpdate(): Promise<void> {
248
249
  if (serviceWasInstalled || readPid() || readRuntimePort()) {
249
250
  console.log("⏹ Stopping the running proxy before updating...");
250
251
  const stopStdio = updateChildStdio();
251
- const stop = spawnSync(process.execPath, [process.argv[1], "stop"], {
252
+ const stop = spawnSync(process.execPath, selfLaunchArgv(["stop"]), {
252
253
  stdio: stopStdio,
253
254
  encoding: stopStdio === "pipe" ? "utf8" : undefined,
254
255
  windowsHide: true,
@@ -298,13 +299,13 @@ export async function runUpdate(): Promise<void> {
298
299
  console.warn(`⚠️ Shim repair skipped: ${e instanceof Error ? e.message : e}`);
299
300
  }
300
301
  if (trayWasInstalled) {
301
- const trayArgs = [process.argv[1], ...planWindowsTrayUpdate({ installed: trayWasInstalled, running: trayWasRunning }).installArgs];
302
+ const trayArgs = selfLaunchArgv(planWindowsTrayUpdate({ installed: trayWasInstalled, running: trayWasRunning }).installArgs);
302
303
  const tray = spawnSync(process.execPath, trayArgs, { stdio: "inherit", windowsHide: true });
303
304
  if (tray.status === 0) {
304
305
  console.log("🔧 Refreshed Windows tray startup paths.");
305
306
  } else {
306
307
  console.warn("⚠️ Windows tray refresh failed. Run 'ocx tray install'.");
307
- if (trayWasRunning) spawnSync(process.execPath, [process.argv[1], "tray", "start"], { stdio: "ignore", windowsHide: true });
308
+ if (trayWasRunning) spawnSync(process.execPath, selfLaunchArgv(["tray", "start"]), { stdio: "ignore", windowsHide: true });
308
309
  }
309
310
  }
310
311
  // The stop above unloaded any managed service; repair it with the NEW files
@@ -328,7 +329,7 @@ export async function runUpdate(): Promise<void> {
328
329
  process.env.OCX_BAKE_PORT = String(capturedListen.port);
329
330
  try {
330
331
  const svcStdio = updateChildStdio();
331
- const svc = spawnSync(process.execPath, [process.argv[1], ...serviceReinstallArgs()], {
332
+ const svc = spawnSync(process.execPath, selfLaunchArgv(serviceReinstallArgs()), {
332
333
  stdio: svcStdio,
333
334
  encoding: svcStdio === "pipe" ? "utf8" : undefined,
334
335
  windowsHide: true,
@@ -372,7 +373,7 @@ export async function runUpdate(): Promise<void> {
372
373
  : " Run 'ocx service repair' to refresh the background service and see why it failed.");
373
374
  const env = { ...process.env };
374
375
  delete env.OCX_SERVICE;
375
- const child = spawn(process.execPath, [process.argv[1], "start", "--port", String(capturedListen.port)], {
376
+ const child = spawn(process.execPath, selfLaunchArgv(["start", "--port", String(capturedListen.port)]), {
376
377
  detached: true,
377
378
  stdio: "ignore",
378
379
  windowsHide: true,