@bitkyc08/opencodex 2.7.21 → 2.7.23
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-CILVKWmx.css → index-Bk_GgFrh.css} +1 -1
- package/gui/dist/assets/index-DQjt6Hly.js +40 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/claude/outbound.ts +15 -4
- package/src/cli/index.ts +1 -1
- package/src/codex/auth-api.ts +7 -6
- package/src/codex/quota.ts +13 -27
- package/src/codex/routing.ts +1 -2
- package/src/lib/abort.ts +55 -0
- package/src/lib/upstream-retry.ts +62 -0
- package/src/oauth/index.ts +82 -24
- package/src/oauth/local-token-detect.ts +23 -1
- package/src/oauth/store.ts +51 -31
- package/src/oauth/xai.ts +17 -10
- package/src/providers/quota.ts +2 -6
- package/src/providers/registry.ts +38 -13
- package/src/providers/xai-transport.ts +107 -60
- package/src/responses/parser.ts +59 -17
- package/src/server/claude-messages.ts +240 -14
- package/src/server/management-api.ts +3 -3
- package/src/server/request-log.ts +13 -1
- package/src/server/responses.ts +67 -18
- package/src/types.ts +13 -0
- package/src/usage/log.ts +11 -0
- package/gui/dist/assets/index-DYIS0tTL.js +0 -40
package/src/responses/parser.ts
CHANGED
|
@@ -228,6 +228,20 @@ export function parseRequest(body: unknown): OcxParsedRequest {
|
|
|
228
228
|
const now = Date.now();
|
|
229
229
|
const messages: OcxMessage[] = [];
|
|
230
230
|
const systemPrompt: string[] = [];
|
|
231
|
+
// Responses reasoning siblings belong to the following assistant, including across call items.
|
|
232
|
+
// Keep them off the message list until that assistant arrives; turn boundaries clear the array.
|
|
233
|
+
const pendingReasoning: Array<{ part: OcxThinkingContent; envelopeSigned: boolean }> = [];
|
|
234
|
+
// Assistant placeholder that first folds any pending reasoning into the same turn (official
|
|
235
|
+
// grok-build preserves reasoning across call items; Anthropic replay requires thinking to
|
|
236
|
+
// precede tool_use inside one assistant message).
|
|
237
|
+
const assistantHolderWithReasoning = (): OcxAssistantMessage => {
|
|
238
|
+
const holder = ensureAssistantPlaceholder(messages, data.model, now);
|
|
239
|
+
if (pendingReasoning.length > 0) {
|
|
240
|
+
holder.content.push(...pendingReasoning.map(entry => entry.part));
|
|
241
|
+
pendingReasoning.length = 0;
|
|
242
|
+
}
|
|
243
|
+
return holder;
|
|
244
|
+
};
|
|
231
245
|
// Tool specs surfaced by a prior tool_search (deferred tools, e.g. subagents). Codex does not
|
|
232
246
|
// re-list these in `tools`, but chat models can only call listed tools — so we re-inject them.
|
|
233
247
|
const loadedToolSpecs: unknown[] = [];
|
|
@@ -270,6 +284,7 @@ export function parseRequest(body: unknown): OcxParsedRequest {
|
|
|
270
284
|
// is dropped silently. It must NOT flag _compactionRequest.
|
|
271
285
|
const encrypted = (item as { encrypted_content?: unknown }).encrypted_content;
|
|
272
286
|
if (effectiveType === "context_compaction" && typeof encrypted !== "string") continue;
|
|
287
|
+
pendingReasoning.length = 0;
|
|
273
288
|
messages.push({
|
|
274
289
|
role: "user",
|
|
275
290
|
content: compactionItemToText(typeof encrypted === "string" ? encrypted : undefined),
|
|
@@ -297,6 +312,7 @@ export function parseRequest(body: unknown): OcxParsedRequest {
|
|
|
297
312
|
// An agent_message is external input delivered to the parent agent.
|
|
298
313
|
// Preserve it as a user-role turn so signed Anthropic thinking blocks
|
|
299
314
|
// on either side are never merged into one modified assistant response.
|
|
315
|
+
pendingReasoning.length = 0;
|
|
300
316
|
messages.push({
|
|
301
317
|
role: "user",
|
|
302
318
|
content: hasContent ? content : "(sub-agent message received)",
|
|
@@ -310,6 +326,7 @@ export function parseRequest(body: unknown): OcxParsedRequest {
|
|
|
310
326
|
const msg = item as { role?: string; content?: unknown };
|
|
311
327
|
switch (msg.role) {
|
|
312
328
|
case "system": {
|
|
329
|
+
pendingReasoning.length = 0;
|
|
313
330
|
const text = inputContentParts(msg.content as unknown[] | string | undefined);
|
|
314
331
|
const flat = typeof text === "string" ? text : text.map(p => (p.type === "text" ? p.text : "")).join("");
|
|
315
332
|
if (flat.length > 0) systemPrompt.push(flat);
|
|
@@ -317,13 +334,22 @@ export function parseRequest(body: unknown): OcxParsedRequest {
|
|
|
317
334
|
}
|
|
318
335
|
case "user":
|
|
319
336
|
case "developer": {
|
|
337
|
+
pendingReasoning.length = 0;
|
|
320
338
|
const content = inputContentParts(msg.content as unknown[] | string | undefined);
|
|
321
339
|
messages.push({ role: msg.role, content, timestamp: now });
|
|
322
340
|
break;
|
|
323
341
|
}
|
|
324
342
|
case "assistant": {
|
|
325
343
|
const parts = outputTextOf(msg.content as unknown[] | string | undefined);
|
|
326
|
-
messages.push({
|
|
344
|
+
messages.push({
|
|
345
|
+
role: "assistant",
|
|
346
|
+
content: pendingReasoning.length > 0
|
|
347
|
+
? [...pendingReasoning.map(entry => entry.part), ...parts]
|
|
348
|
+
: parts,
|
|
349
|
+
model: data.model,
|
|
350
|
+
timestamp: now,
|
|
351
|
+
});
|
|
352
|
+
pendingReasoning.length = 0;
|
|
327
353
|
break;
|
|
328
354
|
}
|
|
329
355
|
}
|
|
@@ -334,20 +360,33 @@ export function parseRequest(body: unknown): OcxParsedRequest {
|
|
|
334
360
|
const reasoning = item as { id?: string; summary?: { text: string }[]; content?: { text: string }[]; encrypted_content?: string };
|
|
335
361
|
const fromSummary = (reasoning.summary ?? []).map(c => c.text).join("");
|
|
336
362
|
const text = fromSummary || (reasoning.content ?? []).map(c => c.text).join("");
|
|
337
|
-
// ocxr1 envelope: the REAL Anthropic signature (+ redacted blocks, + hidden signed text)
|
|
338
|
-
// captured by the bridge. Native OpenAI-encrypted blobs decode to null and keep today's
|
|
339
|
-
// placeholder signature (which the anthropic adapter correctly rejects on replay).
|
|
340
363
|
const envelope = typeof reasoning.encrypted_content === "string"
|
|
341
364
|
? decodeReasoningEnvelope(reasoning.encrypted_content)
|
|
342
365
|
: null;
|
|
343
|
-
const
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
366
|
+
const thinkingText = envelope?.txt || text;
|
|
367
|
+
|
|
368
|
+
// Native/non-ocxr1 encrypted-only reasoning is opaque here. Do not create a detached
|
|
369
|
+
// assistant turn or invent replayable plaintext/signatures from the encrypted payload.
|
|
370
|
+
if (thinkingText.length > 0) {
|
|
371
|
+
const part: OcxThinkingContent = {
|
|
372
|
+
type: "thinking",
|
|
373
|
+
thinking: thinkingText,
|
|
374
|
+
signature: envelope?.sig ?? JSON.stringify(reasoning),
|
|
375
|
+
...(envelope?.red ? { redacted: envelope.red } : {}),
|
|
376
|
+
...(reasoning.id ? { itemId: reasoning.id } : {}),
|
|
377
|
+
};
|
|
378
|
+
const envelopeSigned = typeof envelope?.sig === "string";
|
|
379
|
+
const previous = pendingReasoning[pendingReasoning.length - 1];
|
|
380
|
+
|
|
381
|
+
if (!envelopeSigned && previous && !previous.envelopeSigned) {
|
|
382
|
+
previous.part = {
|
|
383
|
+
...part,
|
|
384
|
+
thinking: `${previous.part.thinking}\n${part.thinking}`,
|
|
385
|
+
};
|
|
386
|
+
} else {
|
|
387
|
+
pendingReasoning.push({ part, envelopeSigned });
|
|
388
|
+
}
|
|
389
|
+
}
|
|
351
390
|
continue;
|
|
352
391
|
}
|
|
353
392
|
|
|
@@ -370,7 +409,7 @@ export function parseRequest(body: unknown): OcxParsedRequest {
|
|
|
370
409
|
...(call.id ? { thoughtSignature: call.id } : {}),
|
|
371
410
|
...(call.namespace ? { namespace: call.namespace } : {}),
|
|
372
411
|
};
|
|
373
|
-
|
|
412
|
+
assistantHolderWithReasoning().content.push(toolCall);
|
|
374
413
|
continue;
|
|
375
414
|
}
|
|
376
415
|
|
|
@@ -382,7 +421,7 @@ export function parseRequest(body: unknown): OcxParsedRequest {
|
|
|
382
421
|
customWireName: call.name,
|
|
383
422
|
...(call.id ? { thoughtSignature: call.id } : {}),
|
|
384
423
|
};
|
|
385
|
-
|
|
424
|
+
assistantHolderWithReasoning().content.push(toolCall);
|
|
386
425
|
continue;
|
|
387
426
|
}
|
|
388
427
|
|
|
@@ -393,7 +432,7 @@ export function parseRequest(body: unknown): OcxParsedRequest {
|
|
|
393
432
|
const callId = call.call_id ?? call.id;
|
|
394
433
|
if (callId) {
|
|
395
434
|
const command = Array.isArray(call.action?.command) ? call.action.command : [];
|
|
396
|
-
|
|
435
|
+
assistantHolderWithReasoning().content.push({
|
|
397
436
|
type: "toolCall", id: callId, name: "shell",
|
|
398
437
|
arguments: command.length > 0 ? { command } : {},
|
|
399
438
|
});
|
|
@@ -406,7 +445,7 @@ export function parseRequest(body: unknown): OcxParsedRequest {
|
|
|
406
445
|
// knows the search already ran (prevents re-search loops); there is no output to pair.
|
|
407
446
|
const call = item as { action?: { type?: string; query?: string } };
|
|
408
447
|
const query = typeof call.action?.query === "string" ? call.action.query : "";
|
|
409
|
-
|
|
448
|
+
assistantHolderWithReasoning().content.push({
|
|
410
449
|
type: "text", text: query ? `[web search performed: ${query}]` : "[web search performed]",
|
|
411
450
|
});
|
|
412
451
|
continue;
|
|
@@ -417,7 +456,7 @@ export function parseRequest(body: unknown): OcxParsedRequest {
|
|
|
417
456
|
// history stays complete (otherwise the model re-issues tool_search forever).
|
|
418
457
|
const call = item as { id?: string; call_id?: string; arguments?: unknown };
|
|
419
458
|
const callId = call.call_id ?? call.id ?? "";
|
|
420
|
-
|
|
459
|
+
assistantHolderWithReasoning().content.push({
|
|
421
460
|
type: "toolCall", id: callId, name: "tool_search",
|
|
422
461
|
arguments: isObj(call.arguments) ? call.arguments : {},
|
|
423
462
|
});
|
|
@@ -425,6 +464,7 @@ export function parseRequest(body: unknown): OcxParsedRequest {
|
|
|
425
464
|
}
|
|
426
465
|
|
|
427
466
|
if (effectiveType === "tool_search_output") {
|
|
467
|
+
pendingReasoning.length = 0;
|
|
428
468
|
// Pair the tool_search call with its result so the model sees what was loaded.
|
|
429
469
|
const out = item as { call_id?: string; status?: string; tools?: unknown[] };
|
|
430
470
|
const specs = Array.isArray(out.tools) ? (out.tools as Record<string, unknown>[]) : [];
|
|
@@ -455,6 +495,7 @@ export function parseRequest(body: unknown): OcxParsedRequest {
|
|
|
455
495
|
}
|
|
456
496
|
|
|
457
497
|
if (effectiveType === "function_call_output") {
|
|
498
|
+
pendingReasoning.length = 0;
|
|
458
499
|
const output = item as { call_id: string; output?: string | unknown[] };
|
|
459
500
|
const toolInfo = findToolById(messages, output.call_id);
|
|
460
501
|
messages.push({
|
|
@@ -466,6 +507,7 @@ export function parseRequest(body: unknown): OcxParsedRequest {
|
|
|
466
507
|
}
|
|
467
508
|
|
|
468
509
|
if (effectiveType === "custom_tool_call_output") {
|
|
510
|
+
pendingReasoning.length = 0;
|
|
469
511
|
const output = item as { call_id: string; output: string | unknown[] };
|
|
470
512
|
const toolInfo = findToolById(messages, output.call_id);
|
|
471
513
|
messages.push({
|
|
@@ -12,6 +12,7 @@ import { normalizeAnthropicImages } from "../adapters/anthropic-image-normalize"
|
|
|
12
12
|
import { AnthropicRequestError, anthropicToResponsesTranslation, extractOcxRouteDirective, resolveInboundModel, type ClaudeCacheKeySource } from "../claude/inbound";
|
|
13
13
|
import { stripOneMillionMarker } from "../claude/context-windows";
|
|
14
14
|
import { captureClaudeInbound } from "../claude/inbound-debug";
|
|
15
|
+
import { isTransientUpstreamStatus } from "../lib/upstream-retry";
|
|
15
16
|
import {
|
|
16
17
|
anthropicErrorBody,
|
|
17
18
|
anthropicErrorResponse,
|
|
@@ -19,7 +20,7 @@ import {
|
|
|
19
20
|
responsesJsonToAnthropicMessage,
|
|
20
21
|
responsesSseToAnthropicSse,
|
|
21
22
|
} from "../claude/outbound";
|
|
22
|
-
import { clearableDeadline } from "../lib/abort";
|
|
23
|
+
import { clearableDeadline, idleDeadline } from "../lib/abort";
|
|
23
24
|
import { estimateTokens } from "../lib/token-estimate";
|
|
24
25
|
import { routeModel } from "../router";
|
|
25
26
|
import type { OcxConfig } from "../types";
|
|
@@ -117,13 +118,35 @@ function anthropicUsageToOcx(usage: Rec | undefined): { inputTokens: number; out
|
|
|
117
118
|
};
|
|
118
119
|
}
|
|
119
120
|
|
|
120
|
-
/**
|
|
121
|
-
|
|
121
|
+
/** Body-occupancy guard for the native passthrough (devlog 260716_passthrough_followups/010). */
|
|
122
|
+
export interface PassthroughBodyGuard {
|
|
123
|
+
/** Idle window in ms — raw upstream-byte inactivity while a read is pending. 0 disables. */
|
|
124
|
+
stallMs: number;
|
|
125
|
+
/** Cumulative body byte cap. 0 disables. */
|
|
126
|
+
maxBytes: number;
|
|
127
|
+
/** Client request signal for deterministic cancel classification. */
|
|
128
|
+
reqSignal?: AbortSignal;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
type PassthroughCloseReason = "terminal" | "client_cancel" | "body_stall" | "body_overflow";
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Tap an Anthropic-vocabulary SSE stream for the request log (usage + terminal),
|
|
135
|
+
* bounding body occupancy: idle (silence-only, timed ONLY while a reader.read() is
|
|
136
|
+
* pending so downstream backpressure never counts as upstream inactivity) and a
|
|
137
|
+
* cumulative byte cap. On stall/overflow it appends a protocol-compatible Anthropic
|
|
138
|
+
* `event: error` terminal frame after a blank-line boundary, closes, and cancels the
|
|
139
|
+
* upstream reader — never a total-wall-clock bound (slow-but-alive streams live).
|
|
140
|
+
* Exported for deterministic unit tests.
|
|
141
|
+
*/
|
|
142
|
+
export function tapAnthropicSseForLog(
|
|
122
143
|
upstream: ReadableStream<Uint8Array>,
|
|
123
144
|
logCtx: RequestLogContext,
|
|
124
|
-
finalize: (status: number, meta: { closeReason:
|
|
145
|
+
finalize: (status: number, meta: { closeReason: PassthroughCloseReason }) => void,
|
|
146
|
+
guard?: PassthroughBodyGuard,
|
|
125
147
|
): ReadableStream<Uint8Array> {
|
|
126
148
|
const decoder = new TextDecoder();
|
|
149
|
+
const encoder = new TextEncoder();
|
|
127
150
|
let buffer = "";
|
|
128
151
|
let usageAcc: Rec = {};
|
|
129
152
|
const inspect = (chunk: Uint8Array) => {
|
|
@@ -145,25 +168,110 @@ function tapAnthropicSseForLog(
|
|
|
145
168
|
}
|
|
146
169
|
};
|
|
147
170
|
const reader = upstream.getReader();
|
|
171
|
+
let settled = false;
|
|
172
|
+
let bodyBytes = 0;
|
|
173
|
+
let tapController: ReadableStreamDefaultController<Uint8Array> | undefined;
|
|
174
|
+
|
|
175
|
+
const recordUsage = () => {
|
|
176
|
+
logCtx.usage = anthropicUsageToOcx(Object.keys(usageAcc).length > 0 ? usageAcc : undefined);
|
|
177
|
+
};
|
|
178
|
+
const failBody = (closeReason: "body_stall" | "body_overflow", errType: string, message: string) => {
|
|
179
|
+
if (settled) return;
|
|
180
|
+
settled = true;
|
|
181
|
+
idle.cancel();
|
|
182
|
+
detachAbort();
|
|
183
|
+
recordUsage();
|
|
184
|
+
finalize(200, { closeReason });
|
|
185
|
+
const payload = JSON.stringify({ type: "error", error: { type: errType, message } });
|
|
186
|
+
try {
|
|
187
|
+
// Leading blank line terminates any partial SSE block so the frame parses cleanly
|
|
188
|
+
// (relaySseWithFailedTail policy, Anthropic wire shape).
|
|
189
|
+
tapController?.enqueue(encoder.encode(`\n\nevent: error\ndata: ${payload}\n\n`));
|
|
190
|
+
tapController?.close();
|
|
191
|
+
} catch { /* client already torn down */ }
|
|
192
|
+
reader.cancel(new DOMException(message, closeReason === "body_stall" ? "TimeoutError" : "QuotaExceededError")).catch(() => {});
|
|
193
|
+
};
|
|
194
|
+
const idle = idleDeadline(guard?.stallMs ?? 0, () => {
|
|
195
|
+
failBody(
|
|
196
|
+
"body_stall",
|
|
197
|
+
"timeout_error",
|
|
198
|
+
`anthropic passthrough body stalled: no upstream bytes for ${Math.round((guard?.stallMs ?? 0) / 1000)}s`,
|
|
199
|
+
);
|
|
200
|
+
});
|
|
201
|
+
// Deterministic client-cancel classification: Bun may surface a client abort as a
|
|
202
|
+
// reader.read() rejection OR a resolved done (src/lib/abort.ts cancelBodyOnAbort
|
|
203
|
+
// rationale), so the listener performs first-wins settlement itself instead of
|
|
204
|
+
// relying on which shape the read takes.
|
|
205
|
+
const onClientAbort = () => {
|
|
206
|
+
if (settled) return;
|
|
207
|
+
settled = true;
|
|
208
|
+
idle.cancel();
|
|
209
|
+
detachAbort();
|
|
210
|
+
finalize(499, { closeReason: "client_cancel" });
|
|
211
|
+
try { tapController?.close(); } catch { /* downstream already torn down */ }
|
|
212
|
+
reader.cancel(guard?.reqSignal?.reason).catch(() => {});
|
|
213
|
+
};
|
|
214
|
+
const detachAbort = (() => {
|
|
215
|
+
const signal = guard?.reqSignal;
|
|
216
|
+
if (!signal) return () => {};
|
|
217
|
+
if (signal.aborted) {
|
|
218
|
+
queueMicrotask(onClientAbort);
|
|
219
|
+
return () => {};
|
|
220
|
+
}
|
|
221
|
+
signal.addEventListener("abort", onClientAbort, { once: true });
|
|
222
|
+
return () => signal.removeEventListener("abort", onClientAbort);
|
|
223
|
+
})();
|
|
224
|
+
|
|
148
225
|
return new ReadableStream<Uint8Array>({
|
|
226
|
+
start(controller) {
|
|
227
|
+
tapController = controller;
|
|
228
|
+
},
|
|
149
229
|
async pull(controller) {
|
|
230
|
+
if (settled) return;
|
|
150
231
|
try {
|
|
232
|
+
idle.reset();
|
|
151
233
|
const { done, value } = await reader.read();
|
|
234
|
+
idle.pause();
|
|
235
|
+
if (settled) return; // stall/overflow/abort won the race while we awaited
|
|
152
236
|
if (done) {
|
|
153
|
-
|
|
237
|
+
settled = true;
|
|
238
|
+
idle.cancel();
|
|
239
|
+
detachAbort();
|
|
240
|
+
recordUsage();
|
|
154
241
|
finalize(200, { closeReason: "terminal" });
|
|
155
242
|
controller.close();
|
|
156
243
|
return;
|
|
157
244
|
}
|
|
245
|
+
if (value.byteLength > 0) {
|
|
246
|
+
bodyBytes += value.byteLength;
|
|
247
|
+
if (guard && guard.maxBytes > 0 && bodyBytes > guard.maxBytes) {
|
|
248
|
+
failBody(
|
|
249
|
+
"body_overflow",
|
|
250
|
+
"api_error",
|
|
251
|
+
`anthropic passthrough body exceeded ${guard.maxBytes} bytes`,
|
|
252
|
+
);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
158
256
|
inspect(value);
|
|
159
257
|
controller.enqueue(value);
|
|
160
258
|
} catch (err) {
|
|
259
|
+
if (settled) return;
|
|
260
|
+
settled = true;
|
|
261
|
+
idle.cancel();
|
|
262
|
+
detachAbort();
|
|
263
|
+
recordUsage();
|
|
161
264
|
finalize(200, { closeReason: "terminal" });
|
|
162
265
|
try { controller.error(err); } catch { /* torn down */ }
|
|
163
266
|
}
|
|
164
267
|
},
|
|
165
268
|
cancel(reason) {
|
|
166
|
-
|
|
269
|
+
if (!settled) {
|
|
270
|
+
settled = true;
|
|
271
|
+
idle.cancel();
|
|
272
|
+
detachAbort();
|
|
273
|
+
finalize(499, { closeReason: "client_cancel" });
|
|
274
|
+
}
|
|
167
275
|
reader.cancel(reason).catch(() => {});
|
|
168
276
|
},
|
|
169
277
|
});
|
|
@@ -182,7 +290,7 @@ async function anthropicNativePassthrough(
|
|
|
182
290
|
logCtx.provider = "anthropic-native";
|
|
183
291
|
logCtx.requestedModel = model;
|
|
184
292
|
let logged = false;
|
|
185
|
-
const finalize = (status: number, meta: { closeReason:
|
|
293
|
+
const finalize = (status: number, meta: { closeReason: PassthroughCloseReason | "non_stream" }) => {
|
|
186
294
|
if (!logIds || logged) return;
|
|
187
295
|
logged = true;
|
|
188
296
|
addFinalRequestLog(logIds.requestId, logIds.start, logCtx, status, meta);
|
|
@@ -208,7 +316,7 @@ async function anthropicNativePassthrough(
|
|
|
208
316
|
const result = await fetchWithHeaderDeadline(
|
|
209
317
|
`${base}${pathname}${search}`,
|
|
210
318
|
{ method: "POST", headers, body: JSON.stringify(body) },
|
|
211
|
-
config.connectTimeoutMs ??
|
|
319
|
+
config.connectTimeoutMs ?? 200_000,
|
|
212
320
|
req.signal,
|
|
213
321
|
);
|
|
214
322
|
if (result.kind === "timeout") {
|
|
@@ -223,8 +331,9 @@ async function anthropicNativePassthrough(
|
|
|
223
331
|
const upstream = result.upstream;
|
|
224
332
|
|
|
225
333
|
const contentType = upstream.headers.get("content-type") ?? "application/json";
|
|
334
|
+
const bodyGuard = resolvePassthroughBodyGuard(config, req.signal);
|
|
226
335
|
if (upstream.ok && contentType.includes("text/event-stream") && upstream.body) {
|
|
227
|
-
return new Response(tapAnthropicSseForLog(upstream.body, logCtx, finalize), {
|
|
336
|
+
return new Response(tapAnthropicSseForLog(upstream.body, logCtx, finalize, bodyGuard), {
|
|
228
337
|
status: upstream.status,
|
|
229
338
|
headers: {
|
|
230
339
|
"Content-Type": contentType,
|
|
@@ -233,8 +342,22 @@ async function anthropicNativePassthrough(
|
|
|
233
342
|
},
|
|
234
343
|
});
|
|
235
344
|
}
|
|
236
|
-
// Non-stream (count_tokens, errors, stream:false): relay verbatim
|
|
237
|
-
|
|
345
|
+
// Non-stream (count_tokens, errors, stream:false): relay verbatim under the same
|
|
346
|
+
// idle/size bounds — headers are NOT yet sent here, so real statuses are available.
|
|
347
|
+
const bodyResult = await readBoundedPassthroughBody(upstream, bodyGuard);
|
|
348
|
+
if (bodyResult.kind === "client_cancel") {
|
|
349
|
+
finalize(499, { closeReason: "client_cancel" });
|
|
350
|
+
return anthropicErrorResponse(499, "client closed request during anthropic passthrough", "api_error");
|
|
351
|
+
}
|
|
352
|
+
if (bodyResult.kind === "stall") {
|
|
353
|
+
finalize(504, { closeReason: "body_stall" });
|
|
354
|
+
return anthropicErrorResponse(504, `anthropic passthrough body stalled: no upstream bytes for ${Math.round(bodyGuard.stallMs / 1000)}s`, "timeout_error");
|
|
355
|
+
}
|
|
356
|
+
if (bodyResult.kind === "overflow") {
|
|
357
|
+
finalize(502, { closeReason: "body_overflow" });
|
|
358
|
+
return anthropicErrorResponse(502, `anthropic passthrough body exceeded ${bodyGuard.maxBytes} bytes`, "api_error");
|
|
359
|
+
}
|
|
360
|
+
const text = bodyResult.text;
|
|
238
361
|
if (upstream.ok) {
|
|
239
362
|
try {
|
|
240
363
|
const parsed = JSON.parse(text) as { usage?: Rec };
|
|
@@ -249,6 +372,99 @@ async function anthropicNativePassthrough(
|
|
|
249
372
|
});
|
|
250
373
|
}
|
|
251
374
|
|
|
375
|
+
const DEFAULT_BODY_STALL_SEC = 90;
|
|
376
|
+
const DEFAULT_BODY_MAX_BYTES = 64 * 1024 * 1024;
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Normalize the claudeCode body-guard config (devlog 260716_passthrough_followups/010).
|
|
380
|
+
* Policy: exactly 0 disables; finite positive values are honored (stall clamped to
|
|
381
|
+
* min 1s); negative/non-finite/absent values fall back to the defaults.
|
|
382
|
+
*/
|
|
383
|
+
export function resolvePassthroughBodyGuard(config: OcxConfig, reqSignal?: AbortSignal): PassthroughBodyGuard {
|
|
384
|
+
const rawSec = config.claudeCode?.bodyStallSec;
|
|
385
|
+
const stallSec = rawSec === 0
|
|
386
|
+
? 0
|
|
387
|
+
: typeof rawSec === "number" && Number.isFinite(rawSec) && rawSec > 0
|
|
388
|
+
? Math.max(1, rawSec)
|
|
389
|
+
: DEFAULT_BODY_STALL_SEC;
|
|
390
|
+
const rawBytes = config.claudeCode?.bodyMaxBytes;
|
|
391
|
+
const maxBytes = rawBytes === 0
|
|
392
|
+
? 0
|
|
393
|
+
: typeof rawBytes === "number" && Number.isFinite(rawBytes) && rawBytes > 0
|
|
394
|
+
? Math.floor(rawBytes)
|
|
395
|
+
: DEFAULT_BODY_MAX_BYTES;
|
|
396
|
+
return { stallMs: stallSec * 1000, maxBytes, ...(reqSignal ? { reqSignal } : {}) };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
type BoundedPassthroughBody =
|
|
400
|
+
| { kind: "ok"; text: string }
|
|
401
|
+
| { kind: "stall" }
|
|
402
|
+
| { kind: "overflow" }
|
|
403
|
+
| { kind: "client_cancel" };
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Bounded replacement for `await upstream.text()` on the non-stream passthrough
|
|
407
|
+
* branch: same idle-only + size-cap semantics as the SSE tap. NOTE: reader.cancel()
|
|
408
|
+
* resolves a pending read as done rather than rejecting, so the stalled flag is
|
|
409
|
+
* re-checked after every read settlement (audit round 3).
|
|
410
|
+
*/
|
|
411
|
+
export async function readBoundedPassthroughBody(
|
|
412
|
+
upstream: Response,
|
|
413
|
+
guard: PassthroughBodyGuard,
|
|
414
|
+
): Promise<BoundedPassthroughBody> {
|
|
415
|
+
if (!upstream.body) return { kind: "ok", text: await upstream.text() };
|
|
416
|
+
const reader = upstream.body.getReader();
|
|
417
|
+
const decoder = new TextDecoder();
|
|
418
|
+
let text = "";
|
|
419
|
+
let bytes = 0;
|
|
420
|
+
let stalled = false;
|
|
421
|
+
let aborted = false;
|
|
422
|
+
const idle = idleDeadline(guard.stallMs, () => {
|
|
423
|
+
stalled = true;
|
|
424
|
+
reader.cancel(new DOMException("anthropic passthrough body stalled", "TimeoutError")).catch(() => {});
|
|
425
|
+
});
|
|
426
|
+
// Deterministic client-abort classification (audit round 4): Bun may surface the
|
|
427
|
+
// abort as a read rejection OR a resolved done, so we cancel the reader ourselves
|
|
428
|
+
// and classify via the flag rather than the read's settlement shape.
|
|
429
|
+
const signal = guard.reqSignal;
|
|
430
|
+
const onAbort = () => {
|
|
431
|
+
aborted = true;
|
|
432
|
+
reader.cancel(signal?.reason).catch(() => {});
|
|
433
|
+
};
|
|
434
|
+
if (signal?.aborted) onAbort();
|
|
435
|
+
else signal?.addEventListener("abort", onAbort, { once: true });
|
|
436
|
+
try {
|
|
437
|
+
while (true) {
|
|
438
|
+
idle.reset();
|
|
439
|
+
let result: Awaited<ReturnType<typeof reader.read>>;
|
|
440
|
+
try {
|
|
441
|
+
result = await reader.read();
|
|
442
|
+
} catch (err) {
|
|
443
|
+
if (aborted) return { kind: "client_cancel" };
|
|
444
|
+
if (stalled) return { kind: "stall" };
|
|
445
|
+
throw err;
|
|
446
|
+
} finally {
|
|
447
|
+
idle.pause();
|
|
448
|
+
}
|
|
449
|
+
if (aborted) return { kind: "client_cancel" };
|
|
450
|
+
if (stalled) return { kind: "stall" };
|
|
451
|
+
if (result.done) break;
|
|
452
|
+
if (result.value.byteLength === 0) continue;
|
|
453
|
+
bytes += result.value.byteLength;
|
|
454
|
+
if (guard.maxBytes > 0 && bytes > guard.maxBytes) {
|
|
455
|
+
reader.cancel(new DOMException("anthropic passthrough body exceeded byte cap", "QuotaExceededError")).catch(() => {});
|
|
456
|
+
return { kind: "overflow" };
|
|
457
|
+
}
|
|
458
|
+
text += decoder.decode(result.value, { stream: true });
|
|
459
|
+
}
|
|
460
|
+
text += decoder.decode();
|
|
461
|
+
return { kind: "ok", text };
|
|
462
|
+
} finally {
|
|
463
|
+
idle.cancel();
|
|
464
|
+
signal?.removeEventListener("abort", onAbort);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
252
468
|
/**
|
|
253
469
|
* Header-phase fetch guarded by a clearable deadline (PR #136 follow-up hardening).
|
|
254
470
|
*
|
|
@@ -461,9 +677,19 @@ export async function handleClaudeMessages(
|
|
|
461
677
|
}
|
|
462
678
|
} catch { /* keep fallback message */ }
|
|
463
679
|
const retryAfter = response.headers.get("retry-after");
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
680
|
+
// Transient upstream 5xx (already retried pre-stream, 010): reclassify as Anthropic
|
|
681
|
+
// 529 overloaded_error so the Claude Code client applies its built-in backoff retry
|
|
682
|
+
// instead of dying on a fatal api_error (260716 sol-builder incident). The request
|
|
683
|
+
// log keeps the upstream status (captured in the deferred-log closure before this
|
|
684
|
+
// rewrite): log = upstream truth, client = retry signal.
|
|
685
|
+
const transient = isTransientUpstreamStatus(response.status);
|
|
686
|
+
const outStatus = transient ? 529 : response.status;
|
|
687
|
+
const out = new Response(JSON.stringify(anthropicErrorBody(outStatus, message)), {
|
|
688
|
+
status: outStatus,
|
|
689
|
+
headers: {
|
|
690
|
+
"Content-Type": "application/json",
|
|
691
|
+
...(retryAfter ? { "Retry-After": retryAfter } : (transient ? { "Retry-After": "2" } : {})),
|
|
692
|
+
},
|
|
467
693
|
});
|
|
468
694
|
return out;
|
|
469
695
|
}
|
|
@@ -1071,7 +1071,7 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
|
|
|
1071
1071
|
if (url.pathname === "/api/oauth/logout" && req.method === "POST") {
|
|
1072
1072
|
const provider = (url.searchParams.get("provider") ?? "").trim().toLowerCase();
|
|
1073
1073
|
if (!isOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400);
|
|
1074
|
-
removeCredential(provider);
|
|
1074
|
+
await removeCredential(provider);
|
|
1075
1075
|
clearLoginState(provider);
|
|
1076
1076
|
return jsonResponse({ success: true });
|
|
1077
1077
|
}
|
|
@@ -1090,7 +1090,7 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
|
|
|
1090
1090
|
if (!isOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400);
|
|
1091
1091
|
if (!body.accountId) return jsonResponse({ error: "missing accountId" }, 400);
|
|
1092
1092
|
const { setActiveAccount } = await import("../oauth/store");
|
|
1093
|
-
if (!setActiveAccount(provider, body.accountId)) return jsonResponse({ error: "account not found" }, 404);
|
|
1093
|
+
if (!(await setActiveAccount(provider, body.accountId))) return jsonResponse({ error: "account not found" }, 404);
|
|
1094
1094
|
const { clearProviderQuotaCache } = await import("../providers/quota");
|
|
1095
1095
|
clearProviderQuotaCache();
|
|
1096
1096
|
return jsonResponse({ ok: true, provider, activeAccountId: body.accountId });
|
|
@@ -1101,7 +1101,7 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
|
|
|
1101
1101
|
if (!isOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400);
|
|
1102
1102
|
if (!id) return jsonResponse({ error: "missing id" }, 400);
|
|
1103
1103
|
const { removeAccount, getAccountSet } = await import("../oauth/store");
|
|
1104
|
-
if (!removeAccount(provider, id)) return jsonResponse({ error: "account not found" }, 404);
|
|
1104
|
+
if (!(await removeAccount(provider, id))) return jsonResponse({ error: "account not found" }, 404);
|
|
1105
1105
|
if (!getAccountSet(provider)) clearLoginState(provider);
|
|
1106
1106
|
const { clearProviderQuotaCache } = await import("../providers/quota");
|
|
1107
1107
|
clearProviderQuotaCache();
|
|
@@ -68,7 +68,7 @@ export interface RequestLogEntry {
|
|
|
68
68
|
durationMs: number;
|
|
69
69
|
errorCode?: string;
|
|
70
70
|
terminalStatus?: ResponsesTerminalStatus;
|
|
71
|
-
closeReason?: "terminal" | "client_cancel" | "non_stream";
|
|
71
|
+
closeReason?: "terminal" | "client_cancel" | "non_stream" | "body_stall" | "body_overflow";
|
|
72
72
|
/** Secret-redacted upstream error reason, surfaced in /api/logs and the GUI detail modal. */
|
|
73
73
|
upstreamError?: string;
|
|
74
74
|
usageStatus: UsageStatus;
|
|
@@ -84,6 +84,17 @@ export function addRequestLog(entry: RequestLogEntry) {
|
|
|
84
84
|
requestLog.push(entry);
|
|
85
85
|
if (requestLog.length > MAX_LOG_SIZE) requestLog.shift();
|
|
86
86
|
try {
|
|
87
|
+
// Failure diagnostics survive the 200-entry ring buffer by riding the persisted
|
|
88
|
+
// usage entry (devlog/_plan/260716_claudecode_hardening/030). Success rows stay
|
|
89
|
+
// in their existing shape; the >=400 gate deliberately includes 499 client-cancels.
|
|
90
|
+
const failureDiagnostics = entry.status >= 400 || (entry.terminalStatus && entry.terminalStatus !== "completed")
|
|
91
|
+
? {
|
|
92
|
+
...(entry.errorCode ? { errorCode: entry.errorCode } : {}),
|
|
93
|
+
...(entry.terminalStatus ? { terminalStatus: entry.terminalStatus } : {}),
|
|
94
|
+
...(entry.closeReason ? { closeReason: entry.closeReason } : {}),
|
|
95
|
+
...(entry.upstreamError ? { upstreamError: entry.upstreamError } : {}),
|
|
96
|
+
}
|
|
97
|
+
: {};
|
|
87
98
|
appendUsageEntry({
|
|
88
99
|
requestId: entry.requestId,
|
|
89
100
|
timestamp: entry.timestamp,
|
|
@@ -96,6 +107,7 @@ export function addRequestLog(entry: RequestLogEntry) {
|
|
|
96
107
|
usageStatus: entry.usageStatus,
|
|
97
108
|
...(entry.usage ? { usage: entry.usage } : {}),
|
|
98
109
|
...(entry.totalTokens !== undefined ? { totalTokens: entry.totalTokens } : {}),
|
|
110
|
+
...failureDiagnostics,
|
|
99
111
|
});
|
|
100
112
|
} catch {
|
|
101
113
|
/* request logging must never fail a user request */
|