@bitkyc08/opencodex 2.18.2 → 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.
- package/gui/dist/assets/index-DF_UFrGS.css +1 -0
- package/gui/dist/assets/index-DSK3S5HY.js +76 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/client-fingerprint.ts +14 -10
- package/src/adapters/google-antigravity-wire.ts +4 -3
- package/src/adapters/google.ts +1 -1
- package/src/adapters/mimo-free.ts +17 -0
- package/src/adapters/openai-chat.ts +367 -32
- package/src/adapters/registry.ts +144 -0
- package/src/chat/inbound.ts +37 -8
- package/src/cli/claude.ts +2 -1
- package/src/cli/combo.ts +3 -0
- package/src/cli/dispatch.ts +8 -0
- package/src/cli/export-command.ts +2 -2
- package/src/cli/help.ts +2 -0
- package/src/cli/index.ts +3 -2
- package/src/cli/lab.ts +135 -1
- package/src/cli/minimax.ts +491 -0
- package/src/cli/models-runtime.ts +22 -1
- package/src/cli/models.ts +67 -2
- package/src/cli/opencode.ts +2 -1
- package/src/cli/registry.ts +22 -2
- package/src/clients/config-export.ts +125 -7
- package/src/codex/app-server-processes.ts +57 -2
- package/src/codex/app-server-restart-service.ts +232 -0
- package/src/codex/catalog/aggregation.ts +10 -1
- package/src/codex/catalog/effort.ts +15 -3
- package/src/codex/catalog/parsing.ts +3 -1
- package/src/codex/catalog/provider-fetch.ts +45 -5
- package/src/codex/catalog/sync.ts +74 -4
- package/src/codex/convergence.ts +2 -0
- package/src/codex/shim.ts +100 -5
- package/src/combos/index.ts +1 -0
- package/src/combos/request.ts +30 -0
- package/src/combos/types.ts +6 -0
- package/src/config.ts +52 -0
- package/src/generated/compatibility-version.json +239 -87
- package/src/images/loop.ts +11 -1
- package/src/integrations/registry.ts +7 -0
- package/src/lab/conformance/jcs.ts +42 -2
- package/src/lab/conformance/negative-controls.ts +6 -2
- package/src/lab/conformance/runner.ts +16 -5
- package/src/lab/fabric/observe.ts +49 -14
- package/src/lab/index.ts +16 -0
- package/src/lab/ledger/purge.ts +152 -83
- package/src/lab/ledger/store.ts +168 -54
- package/src/lab/observe/from-conformance.ts +8 -6
- package/src/lab/observe/from-live.ts +8 -2
- package/src/lab/paths.ts +23 -0
- package/src/lab/public/bundle.ts +217 -0
- package/src/lab/public/community-authority.ts +175 -0
- package/src/lab/public/community-files.ts +29 -0
- package/src/lab/public/community.ts +479 -0
- package/src/lab/public/file-safety.ts +155 -0
- package/src/lab/public/ids.ts +26 -0
- package/src/lab/public/index.ts +16 -0
- package/src/lab/public/mutation-lock.ts +424 -0
- package/src/lab/public/operator.ts +353 -0
- package/src/lab/public/origin-purge.ts +79 -0
- package/src/lab/public/origin.ts +203 -0
- package/src/lab/public/privacy.ts +143 -0
- package/src/lab/public/private-file.ts +261 -0
- package/src/lab/public/project.ts +124 -0
- package/src/lab/public/purge-test-fault.ts +21 -0
- package/src/lab/public/purge.ts +223 -0
- package/src/lab/public/registry.ts +44 -0
- package/src/lab/public/revocation.ts +252 -0
- package/src/lab/public/signature.ts +219 -0
- package/src/lab/public/storage.ts +105 -0
- package/src/lab/public/strict-json.ts +206 -0
- package/src/lab/public/time.ts +26 -0
- package/src/lab/public/types.ts +172 -0
- package/src/lab/public/validate.ts +391 -0
- package/src/lib/codex-restart-contract.ts +120 -0
- package/src/lib/errors.ts +27 -0
- package/src/lib/lab-activation.ts +109 -47
- package/src/lib/lab-live-pinned-sender.ts +16 -5
- package/src/lib/pinned-http.ts +70 -16
- package/src/lib/self-launch-argv.ts +15 -0
- package/src/lib/state-store-registrations.ts +2 -0
- package/src/lib/upstream-reachability.ts +4 -0
- package/src/lib/windows-elevation.ts +10 -1
- package/src/providers/derive.ts +24 -4
- package/src/providers/registry.ts +9 -3
- package/src/providers/request-pacing.ts +310 -0
- package/src/providers/service-tier.ts +143 -0
- package/src/providers/static-model-discovery.ts +86 -0
- package/src/reasoning-effort.ts +27 -1
- package/src/responses/spill-store.ts +20 -1
- package/src/responses/state.ts +159 -3
- package/src/router.ts +23 -6
- package/src/routing/capability.ts +4 -2
- package/src/routing/compatibility/behavior.ts +5 -1
- package/src/server/adapter-resolve.ts +2 -32
- package/src/server/auth-cors.ts +8 -0
- package/src/server/chat-completions.ts +74 -34
- package/src/server/chat-native-sse.ts +331 -0
- package/src/server/chat-native.ts +371 -0
- package/src/server/effort-policy.ts +18 -0
- package/src/server/management/combo-routes.ts +16 -2
- package/src/server/management/config-routes.ts +6 -4
- package/src/server/management/context.ts +17 -0
- package/src/server/management/lab-routes.ts +181 -19
- package/src/server/management/model-routes.ts +76 -2
- package/src/server/management/model-rows.ts +8 -0
- package/src/server/management/provider-capability-config.ts +48 -0
- package/src/server/management/provider-routes.ts +76 -4
- package/src/server/management/system-restart.ts +4 -2
- package/src/server/management/system-routes.ts +38 -0
- package/src/server/relay.ts +17 -3
- package/src/server/responses/compact.ts +4 -1
- package/src/server/responses/core.ts +278 -48
- package/src/server/responses/empty-completion-guard.ts +276 -0
- package/src/server/responses/fetch-helpers.ts +35 -4
- package/src/server/responses/input-admission.ts +169 -0
- package/src/server/responses/pacing-overload.ts +13 -0
- package/src/server/responses/policy-fallback.ts +16 -2
- package/src/server/responses/terminal-guard.ts +1 -1
- package/src/server/responses/upstream-error.ts +5 -0
- package/src/server/responses.ts +17 -2
- package/src/service-manager-probe.ts +2 -3
- package/src/types.ts +76 -6
- package/src/update/index.ts +6 -5
- package/src/update/job.ts +5 -6
- package/src/update/notify.ts +5 -3
- package/src/usage/log.ts +11 -1
- package/src/vision/index.ts +21 -4
- package/src/web-search/index.ts +2 -1
- package/gui/dist/assets/index-CXI1262_.js +0 -76
- package/gui/dist/assets/index-DUCH59lJ.css +0 -1
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pre-dispatch input admission (#1412).
|
|
3
|
+
*
|
|
4
|
+
* Refuses a turn whose estimated input cannot plausibly fit the model context window,
|
|
5
|
+
* BEFORE auth resolution, circuit admission, or any upstream I/O. #1412 reported ~127k of
|
|
6
|
+
* real context compounding to 1.3M-1.6M tokens and crashing the proxy; the provider would
|
|
7
|
+
* reject such a turn anyway, so paying for the round trip buys nothing.
|
|
8
|
+
*
|
|
9
|
+
* Deliberately narrow. This is not a context manager and not a compaction trigger: it
|
|
10
|
+
* catches the pathological case and stays out of the way otherwise. Every uncertainty
|
|
11
|
+
* resolves toward admitting.
|
|
12
|
+
*/
|
|
13
|
+
import { nativeOpenAiContextWindow } from "../../codex/catalog/metadata";
|
|
14
|
+
import { estimateTokens } from "../../lib/token-estimate";
|
|
15
|
+
import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers";
|
|
16
|
+
import type { OcxContentPart, OcxParsedRequest, OcxProviderConfig } from "../../types";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Multiplier applied to the ceiling before refusing.
|
|
20
|
+
*
|
|
21
|
+
* 2.5, not something tighter, because `estimateTokens` can overshoot by 1.6x on its own.
|
|
22
|
+
* `cjkRatio` samples every `stride`-th character, so a payload of fixed-width records whose
|
|
23
|
+
* length aligns with the stride samples as 100% CJK while being ~1.6% CJK, firing the
|
|
24
|
+
* 2.5-chars/token clamp instead of the 4.0 default. Measured on Bun 1.3.14: 126,046 chars,
|
|
25
|
+
* true CJK ratio 0.0161, sampled ratio 1.0, estimate inflated 1.6x. Since 4.0 / 2.5 = 1.6
|
|
26
|
+
* is that branch maximum divergence, a threshold at or under 1.6 would convert the
|
|
27
|
+
* estimator error bar into false 413s.
|
|
28
|
+
*
|
|
29
|
+
* 2.5 sits above it with room for the ~10% model-family ratio spread, and still refuses the
|
|
30
|
+
* #1412 shape (10x) four times over.
|
|
31
|
+
*/
|
|
32
|
+
export const ADMISSION_TOLERANCE = 2.5;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Token cost charged for a remote image URL. The bytes are not in this request — the
|
|
36
|
+
* provider fetches them — so the URL own length is not the cost. A small flat charge
|
|
37
|
+
* acknowledges the tiles the image will occupy without pretending to know its dimensions.
|
|
38
|
+
*/
|
|
39
|
+
const REMOTE_IMAGE_TOKENS = 850;
|
|
40
|
+
|
|
41
|
+
/** Decoded image bytes per token. Coarse tile-count proxy, not a provider formula. */
|
|
42
|
+
const IMAGE_BYTES_PER_TOKEN = 750;
|
|
43
|
+
|
|
44
|
+
export interface InputAdmissionResult {
|
|
45
|
+
admitted: boolean;
|
|
46
|
+
estimatedTokens: number;
|
|
47
|
+
/** Resolved ceiling, or null when nothing could be resolved (=> always admitted). */
|
|
48
|
+
ceiling: number | null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function positive(value: unknown): number | null {
|
|
52
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Charge a `data:` URL by its DECODED size rather than its character length: base64 inflates
|
|
57
|
+
* by 4/3, so charging the string would overcount by a third. A remote URL is charged flat.
|
|
58
|
+
*/
|
|
59
|
+
function imageTokens(imageUrl: string): number {
|
|
60
|
+
if (!imageUrl.startsWith("data:")) return REMOTE_IMAGE_TOKENS;
|
|
61
|
+
const comma = imageUrl.indexOf(",");
|
|
62
|
+
if (comma < 0) return REMOTE_IMAGE_TOKENS;
|
|
63
|
+
const payload = imageUrl.length - comma - 1;
|
|
64
|
+
if (payload <= 0) return 0;
|
|
65
|
+
const decoded = Math.floor((payload * 3) / 4);
|
|
66
|
+
return Math.max(1, Math.ceil(decoded / IMAGE_BYTES_PER_TOKEN));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function contentPartTokens(part: OcxContentPart, modelId: string): number {
|
|
70
|
+
return part.type === "image" ? imageTokens(part.imageUrl) : estimateTokens(part.text, modelId);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function contentTokens(content: string | readonly OcxContentPart[], modelId: string): number {
|
|
74
|
+
if (typeof content === "string") return estimateTokens(content, modelId);
|
|
75
|
+
let total = 0;
|
|
76
|
+
for (const part of content) total += contentPartTokens(part, modelId);
|
|
77
|
+
return total;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Estimate the input tokens of a parsed request.
|
|
82
|
+
*
|
|
83
|
+
* Walks the whole `OcxMessage` union rather than user text alone. Assistant turns carry
|
|
84
|
+
* their content as `OcxAssistantContentPart[]` — text, thinking blocks, and tool calls whose
|
|
85
|
+
* JSON arguments are frequently the largest single item in an agent conversation. A walk
|
|
86
|
+
* that counted only `{type:"text"}` would undercount exactly the turns that trigger this
|
|
87
|
+
* gate.
|
|
88
|
+
*/
|
|
89
|
+
export function estimateInputTokens(parsed: OcxParsedRequest, modelId: string): number {
|
|
90
|
+
const { context } = parsed;
|
|
91
|
+
let total = 0;
|
|
92
|
+
|
|
93
|
+
for (const prompt of context.systemPrompt ?? []) total += estimateTokens(prompt, modelId);
|
|
94
|
+
|
|
95
|
+
for (const message of context.messages) {
|
|
96
|
+
if (message.role === "assistant") {
|
|
97
|
+
for (const part of message.content) {
|
|
98
|
+
if (part.type === "text") total += estimateTokens(part.text, modelId);
|
|
99
|
+
else if (part.type === "thinking") total += estimateTokens(part.thinking, modelId);
|
|
100
|
+
else total += estimateTokens(part.name, modelId) + estimateTokens(JSON.stringify(part.arguments), modelId);
|
|
101
|
+
}
|
|
102
|
+
// Opaque provider blob replayed verbatim upstream, so it costs real input tokens.
|
|
103
|
+
if (message.kiroRedactedReasoning) total += estimateTokens(message.kiroRedactedReasoning, modelId);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
total += contentTokens(message.content, modelId);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Tool schemas ride every turn: name, description, and the JSON parameter schema all
|
|
110
|
+
// reach the upstream, and a large MCP catalog can dominate a short conversation.
|
|
111
|
+
for (const tool of context.tools ?? []) {
|
|
112
|
+
total += estimateTokens(tool.name, modelId)
|
|
113
|
+
+ estimateTokens(tool.description, modelId)
|
|
114
|
+
+ estimateTokens(JSON.stringify(tool.parameters), modelId);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return total;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Resolve the admission ceiling. Pure: no filesystem, no catalog, no registry scan.
|
|
122
|
+
*
|
|
123
|
+
* `provider` must be the ROUTED config (`route.provider`), which `routedProviderConfig`
|
|
124
|
+
* has already transport-guarded and merged. Re-deriving from `config.providers[name]` would
|
|
125
|
+
* reject a user-defined provider that merely shares a built-in name using limits that
|
|
126
|
+
* belong to a different service.
|
|
127
|
+
*/
|
|
128
|
+
export function resolveInputCeiling(
|
|
129
|
+
provider: OcxProviderConfig,
|
|
130
|
+
providerName: string,
|
|
131
|
+
modelId: string,
|
|
132
|
+
): number | null {
|
|
133
|
+
const configured = positive(provider.modelContextWindows?.[modelId]) ?? positive(provider.contextWindow);
|
|
134
|
+
|
|
135
|
+
// The canonical `openai` registry entry declares no context fields, so without this the
|
|
136
|
+
// gate would be inert on the default Codex route. All three clauses are load-bearing: a
|
|
137
|
+
// transport-mismatched custom provider named "openai" is preserved verbatim by routing
|
|
138
|
+
// and must not inherit built-in native limits, and a routed `provider/model` id is not a
|
|
139
|
+
// native slug. Static maps only — no catalog read.
|
|
140
|
+
const native = configured === null
|
|
141
|
+
&& providerName === OPENAI_CODEX_PROVIDER_ID
|
|
142
|
+
&& isCanonicalOpenAiForwardProvider(provider)
|
|
143
|
+
&& !modelId.includes("/")
|
|
144
|
+
? positive(nativeOpenAiContextWindow(modelId))
|
|
145
|
+
: null;
|
|
146
|
+
|
|
147
|
+
const window = configured ?? native;
|
|
148
|
+
// modelMaxInputTokens is an input-only cap, so it can only tighten the window.
|
|
149
|
+
const maxInput = positive(provider.modelMaxInputTokens?.[modelId]);
|
|
150
|
+
if (window === null) return maxInput;
|
|
151
|
+
return maxInput === null ? window : Math.min(window, maxInput);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Fail-open when no ceiling is known; refuse only past `ceiling * ADMISSION_TOLERANCE`.
|
|
156
|
+
*
|
|
157
|
+
* The caller is responsible for skipping compaction turns — see the call site in core.ts.
|
|
158
|
+
*/
|
|
159
|
+
export function checkInputAdmission(
|
|
160
|
+
parsed: OcxParsedRequest,
|
|
161
|
+
provider: OcxProviderConfig,
|
|
162
|
+
providerName: string,
|
|
163
|
+
modelId: string,
|
|
164
|
+
): InputAdmissionResult {
|
|
165
|
+
const ceiling = resolveInputCeiling(provider, providerName, modelId);
|
|
166
|
+
if (ceiling === null) return { admitted: true, estimatedTokens: 0, ceiling: null };
|
|
167
|
+
const estimatedTokens = estimateInputTokens(parsed, modelId);
|
|
168
|
+
return { admitted: estimatedTokens <= ceiling * ADMISSION_TOLERANCE, estimatedTokens, ceiling };
|
|
169
|
+
}
|
|
@@ -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
|
|
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
|
-
|
|
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
|
}
|
package/src/server/responses.ts
CHANGED
|
@@ -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
|
|
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
|
+
}
|
|
@@ -264,9 +264,8 @@ function inspectSystemd(deps: Required<Pick<ProbeDeps, "run" | "home">>): Servic
|
|
|
264
264
|
"--user", "show", TASK,
|
|
265
265
|
"-p", "LoadState", "-p", "ActiveState", "-p", "FragmentPath", "-p", "NeedDaemonReload",
|
|
266
266
|
]);
|
|
267
|
-
if (shown.spawnFailed
|
|
268
|
-
|
|
269
|
-
}
|
|
267
|
+
if (shown.spawnFailed) return { kind: "absent" };
|
|
268
|
+
if (shown.timedOut) return unknown("systemctl could not be asked: timed out");
|
|
270
269
|
if (shown.status !== 0) {
|
|
271
270
|
// A missing unit still exits ZERO and says not-found; a non-zero status means
|
|
272
271
|
// the question never reached the bus.
|