@bitkyc08/opencodex 2.44.0 → 2.45.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-CCfD72yq.js +115 -0
- package/gui/dist/assets/index-J96sug5C.css +1 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/openai-responses.ts +14 -0
- package/src/chat/outbound.ts +286 -35
- package/src/claude/compatibility.ts +192 -0
- package/src/claude/model-info.ts +14 -2
- package/src/cli/account-extended.ts +24 -1
- package/src/cli/init.ts +70 -13
- package/src/codex/catalog/provider-fetch.ts +38 -9
- package/src/codex/catalog/sync.ts +34 -2
- package/src/codex/catalog.ts +1 -1
- package/src/config/initialize.ts +132 -0
- package/src/config/rebase-provenance.ts +26 -0
- package/src/config.ts +50 -1
- package/src/generated/compatibility-version.json +41 -29
- package/src/lib/windows-secret-acl.ts +8 -4
- package/src/providers/quota.ts +26 -15
- package/src/responses/state.ts +48 -3
- package/src/server/chat-completions.ts +32 -36
- package/src/server/chat-native-sse.ts +23 -3
- package/src/server/chat-native.ts +15 -8
- package/src/server/claude-messages.ts +27 -0
- package/src/server/index.ts +5 -1
- package/src/server/management/agent-settings-routes.ts +101 -14
- package/src/server/management/logs-usage-routes.ts +9 -2
- package/src/server/request-log-cursor.ts +84 -0
- package/src/server/request-log.ts +46 -3
- package/src/server/responses/agent-task-recovery.ts +50 -14
- package/src/server/responses/codex-ws-exchange.ts +79 -0
- package/src/server/responses/compact.ts +39 -33
- package/src/server/responses/core.ts +43 -27
- package/src/storage/cleanup.ts +49 -35
- package/src/types/config.ts +4 -0
- package/src/usage/log.ts +22 -0
- package/gui/dist/assets/index-B7_K1Hsj.js +0 -115
- package/gui/dist/assets/index-ltx3L-WS.css +0 -1
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
const MAX_CURSOR_LENGTH = 512;
|
|
4
|
+
const MAX_WINDOW_ROWS = 2000;
|
|
5
|
+
// A restart must invalidate even an identical window hydrated from usage.jsonl.
|
|
6
|
+
const processEpoch = randomBytes(16).toString("hex");
|
|
7
|
+
|
|
8
|
+
interface SnapshotCursor {
|
|
9
|
+
v: 2;
|
|
10
|
+
e: string;
|
|
11
|
+
n: number;
|
|
12
|
+
q: string;
|
|
13
|
+
h: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface LegacyCursor {
|
|
17
|
+
v: 1;
|
|
18
|
+
t: number;
|
|
19
|
+
id: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type RequestLogCursor = SnapshotCursor | LegacyCursor;
|
|
23
|
+
|
|
24
|
+
/** A cursor is a bounded freshness hint, never an admission credential. */
|
|
25
|
+
export function decodeRequestLogCursor(raw: string): RequestLogCursor | null {
|
|
26
|
+
if (!raw || raw.length > MAX_CURSOR_LENGTH || !/^[A-Za-z0-9_-]+$/.test(raw)) return null;
|
|
27
|
+
try {
|
|
28
|
+
const bytes = Buffer.from(raw, "base64url");
|
|
29
|
+
if (bytes.toString("base64url") !== raw) return null;
|
|
30
|
+
const value: unknown = JSON.parse(bytes.toString("utf8"));
|
|
31
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
32
|
+
const row = value as Record<string, unknown>;
|
|
33
|
+
const keys = Object.keys(row).sort().join(",");
|
|
34
|
+
if (row.v === 1 && keys === "id,t,v"
|
|
35
|
+
&& typeof row.t === "number" && Number.isFinite(row.t) && row.t >= 0
|
|
36
|
+
&& typeof row.id === "string" && row.id.length > 0 && row.id.length <= 256) {
|
|
37
|
+
return { v: 1, t: row.t, id: row.id };
|
|
38
|
+
}
|
|
39
|
+
if (row.v !== 2 || keys !== "e,h,n,q,v"
|
|
40
|
+
|| typeof row.e !== "string" || !/^[a-f0-9]{32}$/.test(row.e)
|
|
41
|
+
|| typeof row.n !== "number" || !Number.isSafeInteger(row.n) || row.n < 0 || row.n > MAX_WINDOW_ROWS
|
|
42
|
+
|| typeof row.q !== "string" || !/^[a-f0-9]{64}$/.test(row.q)
|
|
43
|
+
|| typeof row.h !== "string" || !/^[a-f0-9]{64}$/.test(row.h)) return null;
|
|
44
|
+
return { v: 2, e: row.e, n: row.n, q: row.q, h: row.h };
|
|
45
|
+
} catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Compare the current projected window, not ring identities: live entries and
|
|
52
|
+
* display-time pricing can change without append. This saves response bytes for
|
|
53
|
+
* stable prefixes; DTO projection and hashing still cost O(window bytes).
|
|
54
|
+
* No per-client rows or history are retained. The route calls this synchronously
|
|
55
|
+
* after projecting the full filtered/paginated window.
|
|
56
|
+
*/
|
|
57
|
+
export function selectRequestLogPoll<T extends object>(
|
|
58
|
+
rows: readonly T[],
|
|
59
|
+
params: URLSearchParams,
|
|
60
|
+
cursor: RequestLogCursor | null,
|
|
61
|
+
epoch = processEpoch,
|
|
62
|
+
): { logs: T[]; cursor: string; reset: boolean } {
|
|
63
|
+
const query = new URLSearchParams(params);
|
|
64
|
+
query.delete("cursor");
|
|
65
|
+
query.sort();
|
|
66
|
+
const queryDigest = createHash("sha256").update(query.toString()).digest("hex");
|
|
67
|
+
const candidate = cursor?.v === 2 && cursor.e === epoch && cursor.q === queryDigest
|
|
68
|
+
&& cursor.n <= rows.length ? cursor : null;
|
|
69
|
+
const full = createHash("sha256");
|
|
70
|
+
const prefix = createHash("sha256");
|
|
71
|
+
for (let index = 0; index < rows.length; index++) {
|
|
72
|
+
// JSON escapes embedded newlines, so the delimiter frames each whole row.
|
|
73
|
+
const serialized = JSON.stringify(rows[index]) + "\n";
|
|
74
|
+
full.update(serialized);
|
|
75
|
+
if (candidate && index < candidate.n) prefix.update(serialized);
|
|
76
|
+
}
|
|
77
|
+
const unchangedPrefix = candidate !== null && prefix.digest("hex") === candidate.h;
|
|
78
|
+
const next: SnapshotCursor = { v: 2, e: epoch, n: rows.length, q: queryDigest, h: full.digest("hex") };
|
|
79
|
+
return {
|
|
80
|
+
logs: rows.slice(unchangedPrefix ? candidate.n : 0),
|
|
81
|
+
cursor: Buffer.from(JSON.stringify(next)).toString("base64url"),
|
|
82
|
+
reset: cursor !== null && !unchangedPrefix,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
isClientClosedMessage,
|
|
9
9
|
isCyberPolicyCode,
|
|
10
10
|
isCyberPolicyMessage,
|
|
11
|
+
isRateLimitOrQuotaFailureMessage,
|
|
11
12
|
upstreamErrorMessageFromPayload,
|
|
12
13
|
} from "../lib/errors";
|
|
13
14
|
import { CODEX_CONFIG_PATH, readRootTomlString } from "../codex/paths";
|
|
@@ -24,6 +25,7 @@ import {
|
|
|
24
25
|
isKnownUsageSurface,
|
|
25
26
|
isCodexUsageAccountLogLabel,
|
|
26
27
|
isValidReasoningWireValue,
|
|
28
|
+
normalizeClaudeCompatibilityUsageLog,
|
|
27
29
|
readRecentUsageEntries,
|
|
28
30
|
usageForFinalLog,
|
|
29
31
|
usageStatusForFinalLog,
|
|
@@ -31,6 +33,7 @@ import {
|
|
|
31
33
|
type AttemptRecoveryKind,
|
|
32
34
|
type PersistedUsageAttempt,
|
|
33
35
|
type PersistedUsageEntry,
|
|
36
|
+
type PersistedClaudeCompatibilityLog,
|
|
34
37
|
type UsageStatus,
|
|
35
38
|
} from "../usage/log";
|
|
36
39
|
import {
|
|
@@ -138,6 +141,8 @@ export interface RequestLogContext {
|
|
|
138
141
|
terminalSource?: "upstream" | "synthetic";
|
|
139
142
|
/** Bounded route-decision trace (RI-01); never contains secrets. */
|
|
140
143
|
routeDecision?: RouteDecisionTraceV1;
|
|
144
|
+
/** Opt-in shadow evidence, normalized again at the logging boundary. */
|
|
145
|
+
claudeCompatibility?: PersistedClaudeCompatibilityLog;
|
|
141
146
|
}
|
|
142
147
|
|
|
143
148
|
export interface RequestLogEntry {
|
|
@@ -203,6 +208,8 @@ export interface RequestLogEntry {
|
|
|
203
208
|
terminalSource?: "upstream" | "synthetic";
|
|
204
209
|
/** Bounded route-decision trace (RI-01); never contains secrets. */
|
|
205
210
|
routeDecision?: RouteDecisionTraceV1;
|
|
211
|
+
/** Closed Claude protocol codes; no request or header values. */
|
|
212
|
+
claudeCompatibility?: PersistedClaudeCompatibilityLog;
|
|
206
213
|
}
|
|
207
214
|
|
|
208
215
|
const requestLog: RequestLogEntry[] = [];
|
|
@@ -271,6 +278,7 @@ export function requestLogEntryFromPersistedUsage(entry: PersistedUsageEntry): R
|
|
|
271
278
|
const terminalStatus = asTerminalStatus(entry.terminalStatus);
|
|
272
279
|
const closeReason = asCloseReason(entry.closeReason);
|
|
273
280
|
const routeDecision = normalizeRouteDecisionTraceForLog(entry.routeDecision);
|
|
281
|
+
const claudeCompatibility = normalizeClaudeCompatibilityUsageLog(entry.claudeCompatibility);
|
|
274
282
|
return {
|
|
275
283
|
requestId: entry.requestId,
|
|
276
284
|
timestamp: entry.timestamp,
|
|
@@ -313,6 +321,7 @@ export function requestLogEntryFromPersistedUsage(entry: PersistedUsageEntry): R
|
|
|
313
321
|
...(entry.totalTokens !== undefined ? { totalTokens: entry.totalTokens } : {}),
|
|
314
322
|
...(entry.attempts !== undefined ? { attempts: entry.attempts } : {}),
|
|
315
323
|
...(routeDecision ? { routeDecision } : {}),
|
|
324
|
+
...(claudeCompatibility ? { claudeCompatibility } : {}),
|
|
316
325
|
};
|
|
317
326
|
}
|
|
318
327
|
|
|
@@ -368,10 +377,13 @@ export function addRequestLog(entry: RequestLogEntry) {
|
|
|
368
377
|
// line-oriented viewer — while `usage.jsonl` looked clean, which is the worst shape for a
|
|
369
378
|
// sanitization bug because the safe surface is the one you check.
|
|
370
379
|
const shadowCallRewrittenFrom = sanitizeLogMetadataString(entry.shadowCallRewrittenFrom);
|
|
371
|
-
const
|
|
380
|
+
const claudeCompatibility = normalizeClaudeCompatibilityUsageLog(entry.claudeCompatibility);
|
|
381
|
+
const retained: RequestLogEntry = shadowCallRewrittenFrom === entry.shadowCallRewrittenFrom && entry.claudeCompatibility === undefined
|
|
372
382
|
? entry
|
|
373
383
|
: { ...entry, ...(shadowCallRewrittenFrom ? { shadowCallRewrittenFrom } : {}) };
|
|
374
384
|
if (!shadowCallRewrittenFrom && retained !== entry) delete retained.shadowCallRewrittenFrom;
|
|
385
|
+
if (claudeCompatibility) retained.claudeCompatibility = claudeCompatibility;
|
|
386
|
+
else if (retained !== entry) delete retained.claudeCompatibility;
|
|
375
387
|
entry = retained;
|
|
376
388
|
retainRequestLogEntry(entry);
|
|
377
389
|
try {
|
|
@@ -431,6 +443,7 @@ export function addRequestLog(entry: RequestLogEntry) {
|
|
|
431
443
|
...(entry.attempts !== undefined ? { attempts: entry.attempts } : {}),
|
|
432
444
|
...failureDiagnostics,
|
|
433
445
|
...(entry.routeDecision ? { routeDecision: entry.routeDecision } : {}),
|
|
446
|
+
...(entry.claudeCompatibility ? { claudeCompatibility: entry.claudeCompatibility } : {}),
|
|
434
447
|
});
|
|
435
448
|
} catch {
|
|
436
449
|
/* request logging must never fail a user request */
|
|
@@ -852,7 +865,7 @@ function captureTerminalHttpStatus(
|
|
|
852
865
|
last_error?: { type?: unknown; code?: unknown; message?: unknown };
|
|
853
866
|
response?: {
|
|
854
867
|
error?: { type?: unknown; code?: unknown; message?: unknown };
|
|
855
|
-
incomplete_details?: { code?: unknown; message?: unknown };
|
|
868
|
+
incomplete_details?: { code?: unknown; message?: unknown; reason?: unknown };
|
|
856
869
|
};
|
|
857
870
|
},
|
|
858
871
|
): void {
|
|
@@ -861,7 +874,9 @@ function captureTerminalHttpStatus(
|
|
|
861
874
|
if (type !== "response.failed" && type !== "response.incomplete" && type !== "error") return;
|
|
862
875
|
const responseError = json.response?.error;
|
|
863
876
|
const responseDetails = json.response?.incomplete_details;
|
|
864
|
-
const candidates
|
|
877
|
+
const candidates: Array<{ type?: unknown; code?: unknown; message?: unknown } | undefined> = [
|
|
878
|
+
json.error, json.last_error, responseError, responseDetails, json,
|
|
879
|
+
];
|
|
865
880
|
const policy = candidates.some(candidate => (
|
|
866
881
|
candidate?.code === null || typeof candidate?.code === "string"
|
|
867
882
|
) && isCyberPolicyCode(candidate.code as string | null | undefined))
|
|
@@ -875,6 +890,29 @@ function captureTerminalHttpStatus(
|
|
|
875
890
|
logCtx.terminalHttpStatus = 400;
|
|
876
891
|
return;
|
|
877
892
|
}
|
|
893
|
+
// A quota terminal can carry only a structured reason, without an error message.
|
|
894
|
+
// Keep this separate from normal output limits and from the policy precedence above.
|
|
895
|
+
const quotaTag = (value: unknown): boolean => value === "usage_limit_reached"
|
|
896
|
+
|| value === "rate_limit_exceeded" || value === "insufficient_quota";
|
|
897
|
+
const structuredRefusal = candidates.some(candidate => [400, 401, 403, 499].includes(
|
|
898
|
+
httpStatusFromTerminalError({
|
|
899
|
+
type: typeof candidate?.type === "string" ? candidate.type : undefined,
|
|
900
|
+
code: typeof candidate?.code === "string" ? candidate.code : undefined,
|
|
901
|
+
}),
|
|
902
|
+
));
|
|
903
|
+
const ordinaryIncompleteReason = typeof responseDetails?.reason === "string"
|
|
904
|
+
&& ["max_output_tokens", "content_filter", "steered", "upstream_stall_timeout", "adapter_eof"].includes(responseDetails.reason);
|
|
905
|
+
if (type === "response.incomplete" && !structuredRefusal && (quotaTag(responseDetails?.reason) || candidates.some(candidate =>
|
|
906
|
+
quotaTag(candidate?.code)
|
|
907
|
+
|| quotaTag(candidate?.type) || candidate?.type === "rate_limit_error"
|
|
908
|
+
|| (!ordinaryIncompleteReason && typeof candidate?.message === "string" && isRateLimitOrQuotaFailureMessage(candidate.message))
|
|
909
|
+
))) {
|
|
910
|
+
// The shared quota classifier also accepts a numeric HTTP status as its message.
|
|
911
|
+
// Preserve explicit payment-required evidence rather than relabeling it as 429.
|
|
912
|
+
logCtx.terminalHttpStatus = candidates.some(candidate => typeof candidate?.message === "string"
|
|
913
|
+
&& Number(candidate.message.trim()) === 402) ? 402 : 429;
|
|
914
|
+
return;
|
|
915
|
+
}
|
|
878
916
|
if (type !== "response.failed" || !responseError || typeof responseError !== "object") return;
|
|
879
917
|
const responseCode = responseError.code === null || typeof responseError.code === "string"
|
|
880
918
|
? responseError.code
|
|
@@ -903,6 +941,9 @@ export function httpStatusForRequestLogTerminal(
|
|
|
903
941
|
status: ResponsesTerminalStatus,
|
|
904
942
|
logCtx?: RequestLogContext,
|
|
905
943
|
): number {
|
|
944
|
+
if (status === "incomplete" && (logCtx?.terminalHttpStatus === 429 || logCtx?.terminalHttpStatus === 402)) {
|
|
945
|
+
return logCtx.terminalHttpStatus;
|
|
946
|
+
}
|
|
906
947
|
/**
|
|
907
948
|
* [Decision Log]
|
|
908
949
|
* - 목적과 의도: Keep request logs aligned with the successful HTTP/SSE contract.
|
|
@@ -985,6 +1026,7 @@ export function addFinalRequestLog(
|
|
|
985
1026
|
// means a future caller cannot reintroduce the hole by forgetting to sanitize first, and
|
|
986
1027
|
// the in-memory /api/logs row matches what usage.jsonl already stores.
|
|
987
1028
|
const shadowCallRewrittenFrom = sanitizeLogMetadataString(logCtx.shadowCallRewrittenFrom);
|
|
1029
|
+
const claudeCompatibility = normalizeClaudeCompatibilityUsageLog(logCtx.claudeCompatibility);
|
|
988
1030
|
addLog({
|
|
989
1031
|
requestId,
|
|
990
1032
|
timestamp: start,
|
|
@@ -1034,6 +1076,7 @@ export function addFinalRequestLog(
|
|
|
1034
1076
|
...(logCtx.transportPhase ? { transportPhase: logCtx.transportPhase } : {}),
|
|
1035
1077
|
...(logCtx.terminalSource ? { terminalSource: logCtx.terminalSource } : {}),
|
|
1036
1078
|
...(logCtx.routeDecision ? { routeDecision: logCtx.routeDecision } : {}),
|
|
1079
|
+
...(claudeCompatibility ? { claudeCompatibility } : {}),
|
|
1037
1080
|
});
|
|
1038
1081
|
if (isUsageDebugEnabled()) {
|
|
1039
1082
|
appendUsageDebug({
|
|
@@ -41,6 +41,18 @@ export interface AgentTaskRecoveryOptions {
|
|
|
41
41
|
cacheEntries?: number;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
export type AgentTaskRecoveryFailureReason =
|
|
45
|
+
| "unsupported_envelope"
|
|
46
|
+
| "admission_denied"
|
|
47
|
+
// Includes cache capacity rejection; does not imply an upstream request was attempted.
|
|
48
|
+
| "recovery_unavailable"
|
|
49
|
+
| "caller_cancelled"
|
|
50
|
+
| "input_changed";
|
|
51
|
+
|
|
52
|
+
export type AgentTaskRecoveryResult =
|
|
53
|
+
| { readonly recovered: true }
|
|
54
|
+
| { readonly recovered: false; readonly reason: AgentTaskRecoveryFailureReason };
|
|
55
|
+
|
|
44
56
|
export function agentTaskRecoveryConfig(config: OcxConfig): AgentTaskRecoveryOptions | null {
|
|
45
57
|
const raw = config.agentTaskRecovery;
|
|
46
58
|
if (!raw || raw.enabled !== true) return null;
|
|
@@ -275,16 +287,20 @@ interface AdmittedRecovery {
|
|
|
275
287
|
cacheKey: string;
|
|
276
288
|
}
|
|
277
289
|
|
|
290
|
+
type RecoveryAdmissionResult =
|
|
291
|
+
| { admitted: true; recovery: AdmittedRecovery }
|
|
292
|
+
| { admitted: false; reason: "unsupported_envelope" | "admission_denied" };
|
|
293
|
+
|
|
278
294
|
function admittedRecovery(
|
|
279
295
|
req: Request,
|
|
280
296
|
input: unknown,
|
|
281
297
|
config: OcxConfig,
|
|
282
298
|
parentThreadId?: string | null,
|
|
283
|
-
):
|
|
299
|
+
): RecoveryAdmissionResult {
|
|
284
300
|
const envelope = findEnvelope(input);
|
|
285
|
-
if (!envelope) return
|
|
301
|
+
if (!envelope) return { admitted: false, reason: "unsupported_envelope" };
|
|
286
302
|
const admission = recoveryAdmission(req, config);
|
|
287
|
-
if (!admission) return
|
|
303
|
+
if (!admission) return { admitted: false, reason: "admission_denied" };
|
|
288
304
|
const cacheKey = createHash("sha256")
|
|
289
305
|
.update(admission.cacheScope)
|
|
290
306
|
.update("\0")
|
|
@@ -298,7 +314,7 @@ function admittedRecovery(
|
|
|
298
314
|
.update("\0")
|
|
299
315
|
.update(envelope.ciphertext)
|
|
300
316
|
.digest("hex");
|
|
301
|
-
return { envelope, admission, cacheKey };
|
|
317
|
+
return { admitted: true, recovery: { envelope, admission, cacheKey } };
|
|
302
318
|
}
|
|
303
319
|
|
|
304
320
|
function recoveryPayload(envelope: AgentEnvelope, model: string): string {
|
|
@@ -465,23 +481,43 @@ export async function recoverEncryptedAgentTask(
|
|
|
465
481
|
config: OcxConfig,
|
|
466
482
|
context: { parentThreadId?: string | null; abortSignal?: AbortSignal } = {},
|
|
467
483
|
): Promise<boolean> {
|
|
484
|
+
return (await recoverEncryptedAgentTaskWithResult(req, input, options, config, context)).recovered;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** Returns only bounded, caller-local diagnostics; no native error or payload content. */
|
|
488
|
+
export async function recoverEncryptedAgentTaskWithResult(
|
|
489
|
+
req: Request,
|
|
490
|
+
input: unknown,
|
|
491
|
+
options: AgentTaskRecoveryOptions,
|
|
492
|
+
config: OcxConfig,
|
|
493
|
+
context: { parentThreadId?: string | null; abortSignal?: AbortSignal } = {},
|
|
494
|
+
): Promise<AgentTaskRecoveryResult> {
|
|
468
495
|
// Admission is deliberately checked before cache access. A cache hit must not
|
|
469
496
|
// turn this process into a plaintext oracle for an unauthenticated caller.
|
|
470
497
|
const admitted = admittedRecovery(req, input, config, context.parentThreadId);
|
|
471
|
-
if (!admitted) return false;
|
|
472
|
-
const { admission, cacheKey, envelope } = admitted;
|
|
498
|
+
if (!admitted.admitted) return { recovered: false, reason: admitted.reason };
|
|
499
|
+
const { admission, cacheKey, envelope } = admitted.recovery;
|
|
473
500
|
const assignment = await resolveCachedAgentTaskRecovery(
|
|
474
501
|
cacheKey,
|
|
475
502
|
options.cacheEntries ?? 200,
|
|
476
503
|
signal => requestRecovery(admission, envelope, options, signal),
|
|
477
504
|
context.abortSignal,
|
|
478
505
|
);
|
|
479
|
-
if (!assignment)
|
|
480
|
-
|
|
506
|
+
if (!assignment) {
|
|
507
|
+
return {
|
|
508
|
+
recovered: false,
|
|
509
|
+
reason: context.abortSignal?.aborted ? "caller_cancelled" : "recovery_unavailable",
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
if (context.abortSignal?.aborted) {
|
|
481
513
|
discardCachedAgentTaskRecovery(cacheKey);
|
|
482
|
-
return false;
|
|
514
|
+
return { recovered: false, reason: "caller_cancelled" };
|
|
483
515
|
}
|
|
484
|
-
|
|
516
|
+
if (!injectAssignment(input, envelope, assignment)) {
|
|
517
|
+
discardCachedAgentTaskRecovery(cacheKey);
|
|
518
|
+
return { recovered: false, reason: "input_changed" };
|
|
519
|
+
}
|
|
520
|
+
return { recovered: true };
|
|
485
521
|
}
|
|
486
522
|
|
|
487
523
|
export function discardEncryptedAgentTaskRecovery(
|
|
@@ -491,7 +527,7 @@ export function discardEncryptedAgentTaskRecovery(
|
|
|
491
527
|
context: { parentThreadId?: string | null } = {},
|
|
492
528
|
): void {
|
|
493
529
|
const admitted = admittedRecovery(req, input, config, context.parentThreadId);
|
|
494
|
-
if (admitted) discardCachedAgentTaskRecovery(admitted.cacheKey);
|
|
530
|
+
if (admitted.admitted) discardCachedAgentTaskRecovery(admitted.recovery.cacheKey);
|
|
495
531
|
}
|
|
496
532
|
|
|
497
533
|
export function resetAgentTaskRecoveryState(): void {
|
|
@@ -510,9 +546,9 @@ export function restoreCachedEncryptedAgentTasks(
|
|
|
510
546
|
const single = [item];
|
|
511
547
|
// Revalidates caller credentials and the exact supported agent envelope before cache access.
|
|
512
548
|
const admitted = admittedRecovery(req, single, config, context.parentThreadId);
|
|
513
|
-
if (!admitted) continue;
|
|
514
|
-
const assignment = cachedAgentTaskRecovery(admitted.cacheKey);
|
|
515
|
-
if (assignment && injectAssignment(single, admitted.envelope, assignment)) restored += 1;
|
|
549
|
+
if (!admitted.admitted) continue;
|
|
550
|
+
const assignment = cachedAgentTaskRecovery(admitted.recovery.cacheKey);
|
|
551
|
+
if (assignment && injectAssignment(single, admitted.recovery.envelope, assignment)) restored += 1;
|
|
516
552
|
}
|
|
517
553
|
return restored;
|
|
518
554
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { MAX_CLIENT_SSE_FRAME_BYTES } from "../sse-frame-buffer";
|
|
2
|
+
import { isSafeResponseHeader } from "../safe-response-headers";
|
|
2
3
|
import { CodexWsMetadata, type CodexWsQuotaObserver } from "./codex-ws-metadata";
|
|
3
4
|
import { CODEX_RESPONSES_HTTP_URL, type PreparedCodexWsRequest } from "./codex-ws-request";
|
|
4
5
|
import { CodexWsCorrelation } from "./codex-ws-correlation";
|
|
@@ -16,6 +17,69 @@ interface ExchangeOptions {
|
|
|
16
17
|
beforeDispatch?: (headers: Headers) => void;
|
|
17
18
|
}
|
|
18
19
|
|
|
20
|
+
const HTTP_HEADER_TOKEN = /^[!#$%&'*+.^_`|~0-9a-z-]+$/i;
|
|
21
|
+
|
|
22
|
+
function record(value: unknown): value is Record<string, unknown> {
|
|
23
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Rebuild only permitted metadata: upstream framing describes a different body. */
|
|
27
|
+
function rejectionHeaders(source: Record<string, unknown>, prelude: Headers): Headers {
|
|
28
|
+
const connectionHeaders = new Set<string>();
|
|
29
|
+
for (const [name, value] of Object.entries(source)) {
|
|
30
|
+
if (name.toLowerCase() !== "connection" || typeof value !== "string") continue;
|
|
31
|
+
for (const token of value.split(",")) {
|
|
32
|
+
const lower = token.trim().toLowerCase();
|
|
33
|
+
if (HTTP_HEADER_TOKEN.test(lower)) connectionHeaders.add(lower);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
// Reuse the metadata owner's count/value/family budgets and window freshness
|
|
37
|
+
// rules, without publishing quota twice. The unmarked HTTP response owns it.
|
|
38
|
+
const projected = new CodexWsMetadata();
|
|
39
|
+
try {
|
|
40
|
+
for (const values of [Object.fromEntries(prelude), source]) {
|
|
41
|
+
const headers = Object.fromEntries(Object.entries(values).filter(([name, value]) => {
|
|
42
|
+
if (!HTTP_HEADER_TOKEN.test(name) || !isSafeResponseHeader(name)
|
|
43
|
+
|| connectionHeaders.has(name.toLowerCase())) return false;
|
|
44
|
+
if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") return false;
|
|
45
|
+
return !(typeof value === "number" && !Number.isFinite(value)) && !/[\r\n\0]/.test(String(value));
|
|
46
|
+
}));
|
|
47
|
+
if (Object.keys(headers).length === 0) continue;
|
|
48
|
+
const event = { type: "codex.response.metadata", headers };
|
|
49
|
+
// Bound the combined serialized seed and updates, even for replacements.
|
|
50
|
+
projected.consume(event, Buffer.byteLength(JSON.stringify(event)));
|
|
51
|
+
}
|
|
52
|
+
const headers = projected.snapshot();
|
|
53
|
+
headers.set("content-type", "application/json");
|
|
54
|
+
headers.set("cache-control", "no-store");
|
|
55
|
+
return headers;
|
|
56
|
+
} finally {
|
|
57
|
+
projected.finish();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Carry #3740's refused-create status back to the HTTP recovery path. Codex's
|
|
63
|
+
* responses_websocket.rs accepts status/status_code and scalar header values;
|
|
64
|
+
* unlike its native client, this relay converts only precommit 4xx. Returning a
|
|
65
|
+
* post-send 5xx or fetch rejection could cause the outer retry wrapper to resend.
|
|
66
|
+
*/
|
|
67
|
+
function wrappedRejectionResponse(payload: Record<string, unknown>, prelude: Headers): Response | null {
|
|
68
|
+
if (payload.type !== "error" || payload.stream_id !== undefined) return null;
|
|
69
|
+
// The native typed wrapper has one aliased field, not two competing statuses.
|
|
70
|
+
if (Object.hasOwn(payload, "status_code") && Object.hasOwn(payload, "status")) return null;
|
|
71
|
+
const status = Object.hasOwn(payload, "status_code") ? payload.status_code : payload.status;
|
|
72
|
+
if (typeof status !== "number" || !Number.isInteger(status) || status < 400 || status > 499) return null;
|
|
73
|
+
const error = payload.error;
|
|
74
|
+
if (error != null && (!record(error)
|
|
75
|
+
|| [error.code, error.message].some(value => value != null && typeof value !== "string"))) return null;
|
|
76
|
+
if (payload.headers != null && !record(payload.headers)) return null;
|
|
77
|
+
const headers = rejectionHeaders(record(payload.headers) ? payload.headers : {}, prelude);
|
|
78
|
+
return new Response(JSON.stringify({
|
|
79
|
+
error: error ?? { type: "upstream_error", message: "Upstream rejected the request" },
|
|
80
|
+
}), { status, headers });
|
|
81
|
+
}
|
|
82
|
+
|
|
19
83
|
/** The sole SSE exchange state machine for both one-shot and retained sockets. */
|
|
20
84
|
export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
|
|
21
85
|
const { session, url, init, prepared, sseFallback, onQuota, beforeDispatch } = options;
|
|
@@ -193,6 +257,21 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
|
|
|
193
257
|
if (!controlFrame && !type.startsWith("response.") && type !== "error") return;
|
|
194
258
|
if (!controlFrame) {
|
|
195
259
|
try { correlation?.accept(normalized.payload); } catch (error) { failStream(error); return; }
|
|
260
|
+
// Correlation must run first: a reused socket's foreign-stream error
|
|
261
|
+
// must not become an HTTP refusal that could authorize account replay.
|
|
262
|
+
if (metadata && sent && !responseCommitted && type === "error") {
|
|
263
|
+
let rejection: Response | null;
|
|
264
|
+
try { rejection = wrappedRejectionResponse(normalized.payload, metadata.snapshot()); }
|
|
265
|
+
catch (error) { failStream(error); return; }
|
|
266
|
+
if (rejection) {
|
|
267
|
+
terminal = true;
|
|
268
|
+
cleanup();
|
|
269
|
+
try { controller.close(); } catch { /* unused stream already closed */ }
|
|
270
|
+
session.dispose();
|
|
271
|
+
resolve(rejection);
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
196
275
|
commitResponse();
|
|
197
276
|
}
|
|
198
277
|
const prefix = encoder.encode(`event: ${type}\ndata: `);
|
|
@@ -112,7 +112,8 @@ import type { WsData } from "../ws-bridge";
|
|
|
112
112
|
import { codexAccountSelectionForTurn, registerTurn, trackStreamLifetime, unregisterTurn } from "../lifecycle";
|
|
113
113
|
import type { AdmissionLease } from "../../lib/admission";
|
|
114
114
|
import { redactSecretString } from "../../lib/redact";
|
|
115
|
-
import {
|
|
115
|
+
import { readBoundedResponseBytes } from "../../lib/bounded-body";
|
|
116
|
+
import { resolveStallTimeoutSec } from "../../stall-timeout";
|
|
116
117
|
import { isRateLimitOrQuotaFailureMessage } from "../../lib/errors";
|
|
117
118
|
import { supportedLadderFor } from "../effort-policy";
|
|
118
119
|
import {
|
|
@@ -212,6 +213,8 @@ function compactHandoffRoute(req: Request, previousModel: string, now = Date.now
|
|
|
212
213
|
|
|
213
214
|
export interface HandleResponsesCompactOptions {
|
|
214
215
|
nativeMainRefreshDependencies?: NativeMainRefreshDependencies;
|
|
216
|
+
/** Release the listener's idle guard only after the complete request body is accepted. */
|
|
217
|
+
onRequestBodyRead?: () => void;
|
|
215
218
|
}
|
|
216
219
|
|
|
217
220
|
export function compactResponseTooLargeError(): Response {
|
|
@@ -464,43 +467,45 @@ function compactResponseHeaders(upstream: Response): Headers {
|
|
|
464
467
|
return headers;
|
|
465
468
|
}
|
|
466
469
|
|
|
467
|
-
export async function bufferCompactResponse(
|
|
468
|
-
|
|
470
|
+
export async function bufferCompactResponse(
|
|
471
|
+
upstream: Response,
|
|
472
|
+
signal: AbortSignal,
|
|
473
|
+
stallTimeoutSec?: number,
|
|
474
|
+
): Promise<Response> {
|
|
469
475
|
const headers = compactResponseHeaders(upstream);
|
|
470
|
-
if (!reader) return new Response(null, { status: upstream.status, statusText: upstream.statusText, headers });
|
|
471
|
-
const declaredLength = Number(upstream.headers.get("content-length"));
|
|
472
|
-
if (Number.isFinite(declaredLength) && declaredLength > COMPACT_RESPONSE_MAX_BYTES) {
|
|
473
|
-
await reader.cancel("compact_response_too_large").catch(() => undefined);
|
|
474
|
-
return compactResponseTooLargeError();
|
|
475
|
-
}
|
|
476
|
-
const chunks: Uint8Array[] = [];
|
|
477
|
-
let total = 0;
|
|
478
476
|
try {
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
}
|
|
484
|
-
const { done, value } = await reader.read();
|
|
485
|
-
if (done) break;
|
|
486
|
-
total += value.byteLength;
|
|
487
|
-
if (total > COMPACT_RESPONSE_MAX_BYTES) {
|
|
488
|
-
await reader.cancel("compact_response_too_large").catch(() => undefined);
|
|
489
|
-
return compactResponseTooLargeError();
|
|
490
|
-
}
|
|
491
|
-
chunks.push(value);
|
|
477
|
+
if (signal.aborted) {
|
|
478
|
+
// No reader is attached yet. Cancellation must not wait for a broken source's cleanup.
|
|
479
|
+
void upstream.body?.cancel(signal.reason).catch(() => undefined);
|
|
480
|
+
return formatErrorResponse(499, "client_cancelled", "Client cancelled compact request");
|
|
492
481
|
}
|
|
493
|
-
|
|
482
|
+
if (!upstream.body) return new Response(null, { status: upstream.status, statusText: upstream.statusText, headers });
|
|
483
|
+
const declaredLength = Number(upstream.headers.get("content-length"));
|
|
484
|
+
if (Number.isFinite(declaredLength) && declaredLength > COMPACT_RESPONSE_MAX_BYTES) {
|
|
485
|
+
void upstream.body.cancel("compact_response_too_large").catch(() => undefined);
|
|
486
|
+
return compactResponseTooLargeError();
|
|
487
|
+
}
|
|
488
|
+
// Header admission has finished; only non-empty body chunks re-arm this deadline.
|
|
489
|
+
// The raw reader preserves bytes and cancels/releases without awaiting source cleanup.
|
|
490
|
+
const result = await readBoundedResponseBytes(upstream, {
|
|
491
|
+
signal,
|
|
492
|
+
maxBytes: COMPACT_RESPONSE_MAX_BYTES,
|
|
493
|
+
inactivityTimeoutMs: resolveStallTimeoutSec(stallTimeoutSec) * 1_000,
|
|
494
|
+
});
|
|
495
|
+
if (signal.aborted) return formatErrorResponse(499, "client_cancelled", "Client cancelled compact request");
|
|
496
|
+
if (result.oversized) return compactResponseTooLargeError();
|
|
497
|
+
return new Response(result.bytes, { status: upstream.status, statusText: upstream.statusText, headers });
|
|
498
|
+
} catch (error) {
|
|
494
499
|
if (signal.aborted) return formatErrorResponse(499, "client_cancelled", "Client cancelled compact request");
|
|
500
|
+
if (error instanceof DOMException && error.name === "TimeoutError") {
|
|
501
|
+
return Response.json({ error: {
|
|
502
|
+
message: "Compact response body stalled",
|
|
503
|
+
type: "upstream_stall_timeout",
|
|
504
|
+
code: "upstream_stall_timeout",
|
|
505
|
+
} }, { status: 504 });
|
|
506
|
+
}
|
|
495
507
|
return formatErrorResponse(502, "upstream_error", "Failed to read compact response");
|
|
496
508
|
}
|
|
497
|
-
const body = new Uint8Array(total);
|
|
498
|
-
let offset = 0;
|
|
499
|
-
for (const chunk of chunks) {
|
|
500
|
-
body.set(chunk, offset);
|
|
501
|
-
offset += chunk.byteLength;
|
|
502
|
-
}
|
|
503
|
-
return new Response(body, { status: upstream.status, statusText: upstream.statusText, headers });
|
|
504
509
|
}
|
|
505
510
|
|
|
506
511
|
|
|
@@ -526,6 +531,7 @@ export async function handleResponsesCompact(
|
|
|
526
531
|
if (typeof raw.model !== "string" || raw.model.length === 0) {
|
|
527
532
|
return formatErrorResponse(400, "invalid_request_error", "compaction request requires a model");
|
|
528
533
|
}
|
|
534
|
+
options.onRequestBodyRead?.();
|
|
529
535
|
// Correct the IDENTITY before routing, or the synthetic id does not route at all. Held in
|
|
530
536
|
// a local rather than written back to `raw.model`: assigning to the property widens it out
|
|
531
537
|
// of the `string` narrowing the guard above just established.
|
|
@@ -1037,7 +1043,7 @@ export async function handleResponsesCompact(
|
|
|
1037
1043
|
upstream.headers.get("x-codex-secondary-reset-at"),
|
|
1038
1044
|
upstream.headers.get("x-codex-tertiary-reset-at"),
|
|
1039
1045
|
].filter(Boolean);
|
|
1040
|
-
const buffered = await bufferCompactResponse(upstream, req.signal);
|
|
1046
|
+
const buffered = await bufferCompactResponse(upstream, req.signal, config.stallTimeoutSec);
|
|
1041
1047
|
const bufferedErrorText = buffered.ok
|
|
1042
1048
|
? ""
|
|
1043
1049
|
: await buffered.clone().text().catch(() => "");
|