@bitkyc08/opencodex 2.7.21 → 2.7.22
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-BqbMn1sv.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/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
|
@@ -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 */
|
package/src/server/responses.ts
CHANGED
|
@@ -14,8 +14,10 @@ import { injectionDebugLog } from "../lib/injection-debug-log";
|
|
|
14
14
|
import { modelInList, namespacedToolName } from "../types";
|
|
15
15
|
import type { AdapterEvent, OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../types";
|
|
16
16
|
import {
|
|
17
|
+
forceRefreshOAuthAccessSnapshot,
|
|
17
18
|
getOAuthCredentialProjectId,
|
|
18
|
-
|
|
19
|
+
getValidAccessTokenSnapshot,
|
|
20
|
+
type OAuthAccessSnapshot,
|
|
19
21
|
UnsupportedOAuthProviderError,
|
|
20
22
|
} from "../oauth";
|
|
21
23
|
import { buildWebSearchTool, planWebSearch, runWithWebSearch } from "../web-search";
|
|
@@ -36,7 +38,7 @@ import {
|
|
|
36
38
|
recordCodexUpstreamOutcome,
|
|
37
39
|
type CodexUpstreamOutcome,
|
|
38
40
|
} from "../codex/routing";
|
|
39
|
-
import { fetchWithResetRetry } from "../lib/upstream-retry";
|
|
41
|
+
import { fetchWithResetRetry, fetchWithTransientRetry } from "../lib/upstream-retry";
|
|
40
42
|
import { isUsageDebugEnabled } from "../usage/debug";
|
|
41
43
|
import { readJsonRequestBody, DecompressedBodyTooLargeError, UnsupportedContentEncodingError } from "./request-decompress";
|
|
42
44
|
import { resolveAdapter, resolveWireProtocolOverride } from "./adapter-resolve";
|
|
@@ -611,9 +613,13 @@ export async function handleResponses(
|
|
|
611
613
|
|
|
612
614
|
// OAuth providers: swap in a fresh access token (auto-refreshed) as the Bearer key, so the
|
|
613
615
|
// existing openai-chat / anthropic adapters authenticate with no change.
|
|
616
|
+
const isXaiOAuthRequest = route.providerName === "xai" && route.provider.authMode === "oauth";
|
|
617
|
+
let sentOAuthSnapshot: OAuthAccessSnapshot | undefined;
|
|
614
618
|
if (route.provider.authMode === "oauth") {
|
|
615
619
|
try {
|
|
616
|
-
|
|
620
|
+
const resolved = await getValidAccessTokenSnapshot(route.providerName);
|
|
621
|
+
if (isXaiOAuthRequest) sentOAuthSnapshot = resolved;
|
|
622
|
+
route.provider = { ...route.provider, apiKey: resolved.accessToken };
|
|
617
623
|
// Antigravity (cloud-code-assist) needs the discovered Cloud Code Assist project id in the
|
|
618
624
|
// CCA envelope; the server injects only the bare token, so pull project from the credential.
|
|
619
625
|
if (route.provider.googleMode === "cloud-code-assist" && !route.provider.project) {
|
|
@@ -694,12 +700,15 @@ export async function handleResponses(
|
|
|
694
700
|
const connectMs = config.connectTimeoutMs ?? 200_000;
|
|
695
701
|
let upstreamResponse: Response;
|
|
696
702
|
try {
|
|
697
|
-
|
|
703
|
+
// Transient-5xx pre-stream retry (devlog/_plan/260716_claudecode_hardening/010):
|
|
704
|
+
// the ChatGPT backend emits transient 502/520s that an immediate retry absorbs.
|
|
705
|
+
// Body is a replayable string; nothing has streamed to the client yet.
|
|
706
|
+
upstreamResponse = await fetchWithTransientRetry(
|
|
698
707
|
() => fetchWithHeaderTimeout(request.url, {
|
|
699
708
|
method: request.method,
|
|
700
709
|
headers: request.headers,
|
|
701
710
|
body: request.body,
|
|
702
|
-
}, upstream.signal, connectMs, parsed.stream),
|
|
711
|
+
}, upstream.signal, connectMs, parsed.stream, providerFetch(route.provider)),
|
|
703
712
|
{ abortSignal: upstream.signal, label: safeHostLabel(request.url) },
|
|
704
713
|
);
|
|
705
714
|
} catch (err) {
|
|
@@ -726,22 +735,24 @@ export async function handleResponses(
|
|
|
726
735
|
const terminalRecorder = codexForwardTerminalOutcomeRecorder(config, authCtx, route.provider);
|
|
727
736
|
const terminalBodyWillRecord = !!terminalRecorder && upstreamResponse.ok && isEventStream;
|
|
728
737
|
// Capture quota from upstream response for multi-account tracking
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
738
|
+
if (usesCodexForwardPoolAuth(authCtx, route.provider)) {
|
|
739
|
+
// primary was the 5h window; it now carries weekly data for GPT plans.
|
|
740
|
+
// Prefer primary when present, fall back to secondary for compatibility.
|
|
741
|
+
const primaryRaw = upstreamResponse.headers.get("x-codex-primary-used-percent");
|
|
742
|
+
const secondaryRaw = upstreamResponse.headers.get("x-codex-secondary-used-percent");
|
|
743
|
+
const weeklyRaw = primaryRaw ?? secondaryRaw;
|
|
732
744
|
const monthlyRaw = upstreamResponse.headers.get("x-codex-tertiary-used-percent");
|
|
733
|
-
const
|
|
734
|
-
const
|
|
745
|
+
const primaryResetRaw = upstreamResponse.headers.get("x-codex-primary-reset-at");
|
|
746
|
+
const secondaryResetRaw = upstreamResponse.headers.get("x-codex-secondary-reset-at");
|
|
747
|
+
const weeklyResetRaw = primaryRaw ? primaryResetRaw : secondaryResetRaw;
|
|
735
748
|
const monthlyResetRaw = upstreamResponse.headers.get("x-codex-tertiary-reset-at");
|
|
736
749
|
const retryAfterRaw = upstreamResponse.headers.get("retry-after");
|
|
737
|
-
if (weeklyRaw ||
|
|
750
|
+
if (weeklyRaw || monthlyRaw) {
|
|
738
751
|
const { updateAccountQuota } = await import("../codex/auth-api");
|
|
739
752
|
updateAccountQuota(
|
|
740
753
|
authCtx.accountId,
|
|
741
754
|
weeklyRaw,
|
|
742
|
-
fiveHourRaw,
|
|
743
755
|
weeklyResetRaw,
|
|
744
|
-
fiveHourResetRaw,
|
|
745
756
|
monthlyRaw,
|
|
746
757
|
monthlyResetRaw,
|
|
747
758
|
);
|
|
@@ -753,8 +764,8 @@ export async function handleResponses(
|
|
|
753
764
|
});
|
|
754
765
|
} else {
|
|
755
766
|
recordCodexUpstreamOutcome(config, authCtx.accountId, upstreamResponse.status, {
|
|
756
|
-
|
|
757
|
-
|
|
767
|
+
retryAfter: retryAfterRaw,
|
|
768
|
+
resetAt: [primaryResetRaw, secondaryResetRaw, monthlyResetRaw].filter(Boolean),
|
|
758
769
|
});
|
|
759
770
|
}
|
|
760
771
|
}
|
|
@@ -945,7 +956,7 @@ export async function handleResponses(
|
|
|
945
956
|
: await fetchWithResetRetry(
|
|
946
957
|
() => fetchWithHeaderTimeout(request.url, {
|
|
947
958
|
method: request.method, headers: request.headers, body: request.body,
|
|
948
|
-
}, upstream.signal, connectMs, parsed.stream),
|
|
959
|
+
}, upstream.signal, connectMs, parsed.stream, providerFetch(route.provider)),
|
|
949
960
|
{ abortSignal: upstream.signal, label: safeHostLabel(request.url) },
|
|
950
961
|
);
|
|
951
962
|
} catch (err) {
|
|
@@ -966,6 +977,7 @@ export async function handleResponses(
|
|
|
966
977
|
let activeAdapter = adapter;
|
|
967
978
|
let imageTierBias = 0;
|
|
968
979
|
let imageRetryAttempted = false;
|
|
980
|
+
let oauth401ReplayAttempted = false;
|
|
969
981
|
const rebuildAndRefetch = async (): Promise<Response | { failed: Response }> => {
|
|
970
982
|
const retryRequest = await activeAdapter.buildRequest(parsed, {
|
|
971
983
|
headers: selectedForwardHeaders,
|
|
@@ -976,7 +988,7 @@ export async function handleResponses(
|
|
|
976
988
|
? await activeAdapter.fetchResponse(retryRequest, { abortSignal: upstream.signal, timeoutMs: connectMs, stream: parsed.stream })
|
|
977
989
|
: await fetchWithHeaderTimeout(retryRequest.url, {
|
|
978
990
|
method: retryRequest.method, headers: retryRequest.headers, body: retryRequest.body,
|
|
979
|
-
}, upstream.signal, connectMs, parsed.stream);
|
|
991
|
+
}, upstream.signal, connectMs, parsed.stream, providerFetch(route.provider));
|
|
980
992
|
} catch (err) {
|
|
981
993
|
cleanupUpstreamAbort();
|
|
982
994
|
upstream.abort();
|
|
@@ -987,6 +999,38 @@ export async function handleResponses(
|
|
|
987
999
|
}
|
|
988
1000
|
};
|
|
989
1001
|
recovery: for (;;) {
|
|
1002
|
+
if (
|
|
1003
|
+
upstreamResponse.status === 401
|
|
1004
|
+
&& isXaiOAuthRequest
|
|
1005
|
+
&& sentOAuthSnapshot
|
|
1006
|
+
&& !oauth401ReplayAttempted
|
|
1007
|
+
) {
|
|
1008
|
+
oauth401ReplayAttempted = true;
|
|
1009
|
+
try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ }
|
|
1010
|
+
let refreshed: OAuthAccessSnapshot;
|
|
1011
|
+
try {
|
|
1012
|
+
refreshed = await forceRefreshOAuthAccessSnapshot(sentOAuthSnapshot);
|
|
1013
|
+
} catch (err) {
|
|
1014
|
+
cleanupUpstreamAbort();
|
|
1015
|
+
return formatErrorResponse(401, "authentication_error", err instanceof Error ? err.message : String(err));
|
|
1016
|
+
}
|
|
1017
|
+
sentOAuthSnapshot = refreshed;
|
|
1018
|
+
const refreshedProvider = resolveProviderTransport(
|
|
1019
|
+
route.providerName,
|
|
1020
|
+
{ ...route.provider, apiKey: refreshed.accessToken },
|
|
1021
|
+
parsed.options.promptCacheKey,
|
|
1022
|
+
);
|
|
1023
|
+
route.provider = refreshedProvider;
|
|
1024
|
+
activeAdapter = resolveAdapter(
|
|
1025
|
+
resolveWireProtocolOverride(route.providerName, route.modelId, refreshedProvider),
|
|
1026
|
+
config.cacheRetention,
|
|
1027
|
+
);
|
|
1028
|
+
const result = await rebuildAndRefetch();
|
|
1029
|
+
if ("failed" in result) return result.failed;
|
|
1030
|
+
upstreamResponse = result;
|
|
1031
|
+
continue recovery;
|
|
1032
|
+
}
|
|
1033
|
+
|
|
990
1034
|
// Multi-key 429 failover: rotate to the next pool key (cooldown-aware) and retry the
|
|
991
1035
|
// SAME request once per remaining key. OAuth/forward providers and single-key pools
|
|
992
1036
|
// return null immediately, so this stays a no-op for them (src/providers/key-failover.ts).
|
|
@@ -1222,12 +1266,17 @@ export function safeHostLabel(url: string): string {
|
|
|
1222
1266
|
}
|
|
1223
1267
|
}
|
|
1224
1268
|
|
|
1269
|
+
function providerFetch(provider: OcxProviderConfig): typeof globalThis.fetch {
|
|
1270
|
+
return (provider as OcxProviderConfig & { fetch?: typeof globalThis.fetch }).fetch ?? globalThis.fetch;
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1225
1273
|
export async function fetchWithHeaderTimeout(
|
|
1226
1274
|
url: string,
|
|
1227
1275
|
init: Omit<RequestInit, "signal">,
|
|
1228
1276
|
abortSignal: AbortSignal,
|
|
1229
1277
|
timeoutMs: number,
|
|
1230
1278
|
preferIdentityEncoding = false,
|
|
1279
|
+
executor: typeof globalThis.fetch = globalThis.fetch,
|
|
1231
1280
|
): Promise<Response> {
|
|
1232
1281
|
const timeout = new AbortController();
|
|
1233
1282
|
const timer = setTimeout(() => {
|
|
@@ -1240,7 +1289,7 @@ export async function fetchWithHeaderTimeout(
|
|
|
1240
1289
|
headers.set("accept-encoding", "identity");
|
|
1241
1290
|
}
|
|
1242
1291
|
try {
|
|
1243
|
-
return await
|
|
1292
|
+
return await executor(url, {
|
|
1244
1293
|
...init,
|
|
1245
1294
|
headers,
|
|
1246
1295
|
signal: AbortSignal.any([abortSignal, timeout.signal]),
|
package/src/types.ts
CHANGED
|
@@ -258,6 +258,19 @@ export interface OcxClaudeCodeConfig {
|
|
|
258
258
|
nativePassthrough?: boolean;
|
|
259
259
|
/** Upstream for the native passthrough (tests/enterprise gateways). Default: https://api.anthropic.com */
|
|
260
260
|
anthropicBaseUrl?: string;
|
|
261
|
+
/**
|
|
262
|
+
* Native passthrough body inactivity budget in SECONDS — raw upstream-byte silence
|
|
263
|
+
* while a read is pending, NOT total duration (slow-but-alive streams never trip it;
|
|
264
|
+
* devlog 260716_passthrough_followups/010). Default 90. Min 1. Exactly 0 disables;
|
|
265
|
+
* negative/non-finite values fall back to the default.
|
|
266
|
+
*/
|
|
267
|
+
bodyStallSec?: number;
|
|
268
|
+
/**
|
|
269
|
+
* Native passthrough cumulative body byte cap (streamed SSE and buffered non-stream
|
|
270
|
+
* alike) — an OOM/occupancy guard, not a correctness limit. Default 67108864 (64 MiB).
|
|
271
|
+
* Exactly 0 disables; negative/non-finite values fall back to the default.
|
|
272
|
+
*/
|
|
273
|
+
bodyMaxBytes?: number;
|
|
261
274
|
/** Default model slot injected as ANTHROPIC_MODEL by `ocx claude`. */
|
|
262
275
|
model?: string;
|
|
263
276
|
/** Haiku/small-fast slot injected as ANTHROPIC_DEFAULT_HAIKU_MODEL (+ legacy SMALL_FAST). */
|
package/src/usage/log.ts
CHANGED
|
@@ -18,6 +18,13 @@ export interface PersistedUsageEntry {
|
|
|
18
18
|
usageStatus: UsageStatus;
|
|
19
19
|
usage?: OcxUsage;
|
|
20
20
|
totalTokens?: number;
|
|
21
|
+
// Failure diagnostics (devlog/_plan/260716_claudecode_hardening/030): persisted for
|
|
22
|
+
// status>=400 or non-completed terminals so incidents survive the in-memory ring buffer.
|
|
23
|
+
errorCode?: string;
|
|
24
|
+
terminalStatus?: string;
|
|
25
|
+
closeReason?: "terminal" | "client_cancel" | "non_stream" | "body_stall" | "body_overflow";
|
|
26
|
+
/** Already redacted + capped at capture (request-log.ts redactSecretString().slice(0,500)). */
|
|
27
|
+
upstreamError?: string;
|
|
21
28
|
}
|
|
22
29
|
|
|
23
30
|
export function usageLogPath(): string {
|
|
@@ -76,6 +83,10 @@ function normalizeUsageEntry(entry: PersistedUsageEntry): PersistedUsageEntry {
|
|
|
76
83
|
usageStatus: entry.usageStatus,
|
|
77
84
|
...(entry.usage ? { usage: normalizeUsageValue(entry.usage) } : {}),
|
|
78
85
|
...(typeof entry.totalTokens === "number" ? { totalTokens: entry.totalTokens } : {}),
|
|
86
|
+
...(entry.errorCode ? { errorCode: entry.errorCode } : {}),
|
|
87
|
+
...(entry.terminalStatus ? { terminalStatus: entry.terminalStatus } : {}),
|
|
88
|
+
...(entry.closeReason ? { closeReason: entry.closeReason } : {}),
|
|
89
|
+
...(entry.upstreamError ? { upstreamError: entry.upstreamError } : {}),
|
|
79
90
|
};
|
|
80
91
|
}
|
|
81
92
|
|