@coseung2/opencodex 2.8.0-cs.13 → 2.8.0-cs.15
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-BhXIu7c0.js +67 -0
- package/gui/dist/index.html +1 -1
- package/package.json +3 -3
- package/packages/ocx-notch/README.md +2 -1
- package/src/adapters/cursor/discovery.ts +6 -2
- package/src/adapters/cursor/effort-map.ts +3 -0
- package/src/adapters/google-antigravity-replay.ts +24 -0
- package/src/adapters/google.ts +16 -11
- package/src/chat/inbound.ts +5 -11
- package/src/cli/account-api.ts +9 -1
- package/src/cli/account-extended.ts +4 -1
- package/src/codex/account-label.ts +14 -1
- package/src/codex/account-lifecycle.ts +12 -1
- package/src/codex/account-namespaces.ts +21 -0
- package/src/codex/account-priority.ts +49 -0
- package/src/codex/account-store.ts +2 -1
- package/src/codex/auth-api.ts +108 -17
- package/src/codex/auth-context.ts +61 -16
- package/src/codex/catalog/metadata.ts +34 -12
- package/src/codex/catalog/parsing.ts +8 -1
- package/src/codex/catalog/provider-fetch.ts +24 -6
- package/src/codex/catalog.ts +1 -1
- package/src/codex/pool-rotation.ts +51 -4
- package/src/codex/quota.ts +154 -35
- package/src/codex/routing.ts +139 -33
- package/src/codex/warmup.ts +193 -85
- package/src/config.ts +84 -1
- package/src/lib/bounded-body.ts +13 -6
- package/src/lib/bun-stream-caps.ts +5 -6
- package/src/lib/redact.ts +13 -0
- package/src/oauth/index.ts +79 -12
- package/src/oauth/log.ts +3 -1
- package/src/oauth/store.ts +31 -8
- package/src/providers/antigravity-models.ts +53 -24
- package/src/providers/codex-capacity.ts +303 -0
- package/src/providers/model-rename-migration.ts +147 -0
- package/src/providers/model-rename-startup.ts +29 -0
- package/src/providers/quota.ts +126 -16
- package/src/providers/registry.ts +258 -38
- package/src/responses/parser.ts +19 -12
- package/src/responses/spill-store.ts +14 -1
- package/src/responses/state.ts +108 -14
- package/src/server/index.ts +9 -1
- package/src/server/management/logs-usage-routes.ts +1 -0
- package/src/server/management/oauth-account-routes.ts +8 -1
- package/src/server/relay.ts +10 -42
- package/src/server/request-log.ts +42 -1
- package/src/server/responses/compact.ts +16 -4
- package/src/server/responses/core.ts +217 -59
- package/src/server/responses/empty-completion-guard.ts +275 -0
- package/src/server/responses/encrypted-payload.ts +54 -39
- package/src/server/responses/fetch-helpers.ts +24 -3
- package/src/server/responses/ws-upstream.ts +318 -0
- package/src/server/sse-frame-buffer.ts +292 -0
- package/src/server/ws-bridge.ts +17 -11
- package/src/types.ts +8 -0
- package/src/usage/log.ts +24 -0
- package/src/usage/summary.ts +152 -2
- package/vendor/ocx-notch/win32-x64/ocx-notch.exe +0 -0
- package/gui/dist/assets/index-BucjyD4I.js +0 -67
package/src/codex/warmup.ts
CHANGED
|
@@ -1,19 +1,21 @@
|
|
|
1
|
+
import { readBoundedResponseBody } from "../lib/bounded-body";
|
|
2
|
+
|
|
1
3
|
export class CodexWarmupError extends Error {
|
|
2
|
-
code: "http_status" | "missing_body" | "stream_failed" | "stream_incomplete" | "stream_error" | "invalid_sse" | "no_terminal" | "transport";
|
|
4
|
+
code: "http_status" | "missing_body" | "stream_failed" | "stream_incomplete" | "stream_error" | "stream_too_large" | "invalid_sse" | "no_terminal" | "transport";
|
|
3
5
|
status?: number;
|
|
4
|
-
/**
|
|
5
|
-
|
|
6
|
+
/** Internal-only classification used to preserve exhausted-account registration. */
|
|
7
|
+
quotaLike?: boolean;
|
|
6
8
|
|
|
7
9
|
constructor(
|
|
8
10
|
code: CodexWarmupError["code"],
|
|
9
11
|
message = "Codex warmup failed",
|
|
10
|
-
options: { status?: number; cause?: unknown;
|
|
12
|
+
options: { status?: number; cause?: unknown; quotaLike?: boolean } = {},
|
|
11
13
|
) {
|
|
12
14
|
super(message);
|
|
13
15
|
this.name = "CodexWarmupError";
|
|
14
16
|
this.code = code;
|
|
15
17
|
this.status = options.status;
|
|
16
|
-
this.
|
|
18
|
+
this.quotaLike = options.quotaLike;
|
|
17
19
|
if (options.cause !== undefined) this.cause = options.cause;
|
|
18
20
|
}
|
|
19
21
|
}
|
|
@@ -29,37 +31,42 @@ const CODEX_RESPONSES_URL = "https://chatgpt.com/backend-api/codex/responses";
|
|
|
29
31
|
const DEFAULT_MODEL = "gpt-5.4-mini";
|
|
30
32
|
const FALLBACK_MODELS = ["gpt-5.5"];
|
|
31
33
|
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
34
|
+
const MAX_TIMEOUT_MS = 0x7fff_ffff;
|
|
32
35
|
const MAX_ERROR_BODY_BYTES = 2048;
|
|
36
|
+
const MAX_WARMUP_STREAM_BYTES = 1024 * 1024;
|
|
33
37
|
|
|
34
|
-
/**
|
|
35
|
-
async function
|
|
38
|
+
/** Classify a bounded structured error without retaining or exposing upstream text. */
|
|
39
|
+
async function readErrorQuotaClassification(res: Response, signal: AbortSignal): Promise<boolean> {
|
|
36
40
|
try {
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
41
|
+
const bounded = await readBoundedResponseBody(res, {
|
|
42
|
+
signal,
|
|
43
|
+
maxBytes: MAX_ERROR_BODY_BYTES,
|
|
44
|
+
fatalUtf8: true,
|
|
45
|
+
});
|
|
46
|
+
if (!bounded.displaySafe) return false;
|
|
47
|
+
const json = JSON.parse(bounded.text) as Record<string, unknown>;
|
|
48
|
+
const nested = json.error;
|
|
49
|
+
const detail = nested && typeof nested === "object" && typeof (nested as Record<string, unknown>).message === "string"
|
|
50
|
+
? (nested as Record<string, unknown>).message
|
|
51
|
+
: typeof json.detail === "string"
|
|
52
|
+
? json.detail
|
|
53
|
+
: typeof json.error === "string"
|
|
54
|
+
? json.error
|
|
55
|
+
: typeof json.message === "string"
|
|
56
|
+
? json.message
|
|
57
|
+
: "";
|
|
58
|
+
return /\b(?:quota|rate[\s_-]*limit|usage[\s_-]*limit)\b/i.test(String(detail));
|
|
59
|
+
} catch (error) {
|
|
60
|
+
if (signal.aborted) {
|
|
61
|
+
throw new CodexWarmupError("transport", "Codex warmup request failed", { cause: error });
|
|
52
62
|
}
|
|
53
|
-
return
|
|
54
|
-
} catch {
|
|
55
|
-
return undefined;
|
|
63
|
+
return false;
|
|
56
64
|
}
|
|
57
65
|
}
|
|
58
66
|
|
|
59
67
|
function safeWarmupReason(err: unknown): string {
|
|
60
68
|
if (err instanceof CodexWarmupError) {
|
|
61
|
-
|
|
62
|
-
return err.upstreamDetail ? `${base} — ${err.upstreamDetail}` : base;
|
|
69
|
+
return err.status ? `${err.code}:${err.status}` : err.code;
|
|
63
70
|
}
|
|
64
71
|
return "transport";
|
|
65
72
|
}
|
|
@@ -77,9 +84,7 @@ export function codexWarmupFailureReason(err: unknown): string {
|
|
|
77
84
|
export function isCodexWarmupQuotaFailure(err: unknown): boolean {
|
|
78
85
|
if (!(err instanceof CodexWarmupError) || err.code !== "http_status") return false;
|
|
79
86
|
if (err.status === 429) return true;
|
|
80
|
-
return err.status === 403
|
|
81
|
-
&& typeof err.upstreamDetail === "string"
|
|
82
|
-
&& /\b(?:quota|rate[\s_-]*limit|usage[\s_-]*limit)\b/i.test(err.upstreamDetail);
|
|
87
|
+
return err.status === 403 && err.quotaLike === true;
|
|
83
88
|
}
|
|
84
89
|
|
|
85
90
|
function eventTypeFromData(data: unknown): string | undefined {
|
|
@@ -103,83 +108,186 @@ function parseSseFrame(frame: string): unknown | null {
|
|
|
103
108
|
}
|
|
104
109
|
}
|
|
105
110
|
|
|
106
|
-
async function drainWarmupSse(body: ReadableStream<Uint8Array
|
|
111
|
+
async function drainWarmupSse(body: ReadableStream<Uint8Array>, signal: AbortSignal): Promise<void> {
|
|
107
112
|
const reader = body.getReader();
|
|
108
113
|
const decoder = new TextDecoder();
|
|
109
|
-
let buffer =
|
|
114
|
+
let buffer = new Uint8Array(Math.min(MAX_WARMUP_STREAM_BYTES, 64 * 1024));
|
|
115
|
+
let bufferedBytes = 0;
|
|
116
|
+
let scanOffset = 0;
|
|
117
|
+
let bytesRead = 0;
|
|
118
|
+
const abortError = () => new CodexWarmupError("transport", "Codex warmup request failed", {
|
|
119
|
+
cause: signal.reason,
|
|
120
|
+
});
|
|
121
|
+
const cancelReader = () => {
|
|
122
|
+
try {
|
|
123
|
+
void reader.cancel(signal.reason).catch(() => {});
|
|
124
|
+
} catch {
|
|
125
|
+
// Custom streams may throw synchronously from cancel().
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
const readWithSignal = (): Promise<Awaited<ReturnType<typeof reader.read>>> => {
|
|
129
|
+
if (signal.aborted) {
|
|
130
|
+
cancelReader();
|
|
131
|
+
return Promise.reject(abortError());
|
|
132
|
+
}
|
|
133
|
+
const read = reader.read();
|
|
134
|
+
void read.catch(() => {});
|
|
135
|
+
return new Promise((resolve, reject) => {
|
|
136
|
+
let settled = false;
|
|
137
|
+
const finish = (action: () => void) => {
|
|
138
|
+
if (settled) return;
|
|
139
|
+
settled = true;
|
|
140
|
+
signal.removeEventListener("abort", onAbort);
|
|
141
|
+
action();
|
|
142
|
+
};
|
|
143
|
+
const onAbort = () => {
|
|
144
|
+
cancelReader();
|
|
145
|
+
finish(() => reject(abortError()));
|
|
146
|
+
};
|
|
147
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
148
|
+
if (signal.aborted) {
|
|
149
|
+
onAbort();
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
read.then(
|
|
153
|
+
result => finish(() => resolve(result)),
|
|
154
|
+
error => finish(() => reject(error)),
|
|
155
|
+
);
|
|
156
|
+
});
|
|
157
|
+
};
|
|
158
|
+
const ensureCapacity = (requiredBytes: number) => {
|
|
159
|
+
if (requiredBytes <= buffer.byteLength) return;
|
|
160
|
+
const grown = new Uint8Array(Math.min(
|
|
161
|
+
MAX_WARMUP_STREAM_BYTES,
|
|
162
|
+
Math.max(requiredBytes, buffer.byteLength * 2),
|
|
163
|
+
));
|
|
164
|
+
grown.set(buffer.subarray(0, bufferedBytes));
|
|
165
|
+
buffer = grown;
|
|
166
|
+
};
|
|
167
|
+
const findFrameDelimiter = (start: number): { index: number; length: 2 | 3 | 4 } | undefined => {
|
|
168
|
+
for (let index = start; index < bufferedBytes - 1; index += 1) {
|
|
169
|
+
const firstLength = buffer[index] === 10
|
|
170
|
+
? 1
|
|
171
|
+
: buffer[index] === 13 && buffer[index + 1] === 10 ? 2 : 0;
|
|
172
|
+
if (firstLength === 0) continue;
|
|
173
|
+
const secondStart = index + firstLength;
|
|
174
|
+
const secondLength = buffer[secondStart] === 10
|
|
175
|
+
? 1
|
|
176
|
+
: buffer[secondStart] === 13 && buffer[secondStart + 1] === 10 ? 2 : 0;
|
|
177
|
+
if (secondLength > 0) return { index, length: (firstLength + secondLength) as 2 | 3 | 4 };
|
|
178
|
+
}
|
|
179
|
+
return undefined;
|
|
180
|
+
};
|
|
181
|
+
const acceptFrame = (frame: Uint8Array): boolean => {
|
|
182
|
+
const parsed = parseSseFrame(decoder.decode(frame));
|
|
183
|
+
const type = eventTypeFromData(parsed);
|
|
184
|
+
if (type === "response.completed") return true;
|
|
185
|
+
if (type === "response.failed") throw new CodexWarmupError("stream_failed");
|
|
186
|
+
if (type === "response.incomplete") throw new CodexWarmupError("stream_incomplete");
|
|
187
|
+
if (type === "error") throw new CodexWarmupError("stream_error");
|
|
188
|
+
return false;
|
|
189
|
+
};
|
|
110
190
|
|
|
111
191
|
try {
|
|
192
|
+
if (signal.aborted) throw abortError();
|
|
112
193
|
for (;;) {
|
|
113
|
-
const { done, value } = await
|
|
194
|
+
const { done, value } = await readWithSignal();
|
|
195
|
+
if (signal.aborted) throw abortError();
|
|
114
196
|
if (done) break;
|
|
115
|
-
|
|
197
|
+
if (value.byteLength > MAX_WARMUP_STREAM_BYTES - bytesRead) {
|
|
198
|
+
throw new CodexWarmupError("stream_too_large", "Codex warmup stream exceeded the size limit");
|
|
199
|
+
}
|
|
200
|
+
bytesRead += value.byteLength;
|
|
201
|
+
ensureCapacity(bufferedBytes + value.byteLength);
|
|
202
|
+
buffer.set(value, bufferedBytes);
|
|
203
|
+
bufferedBytes += value.byteLength;
|
|
116
204
|
|
|
205
|
+
let consumedBytes = 0;
|
|
117
206
|
for (;;) {
|
|
118
|
-
const
|
|
119
|
-
if (
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
207
|
+
const delimiter = findFrameDelimiter(scanOffset);
|
|
208
|
+
if (!delimiter) {
|
|
209
|
+
scanOffset = Math.max(consumedBytes, bufferedBytes - 3);
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
if (acceptFrame(buffer.subarray(consumedBytes, delimiter.index))) return;
|
|
213
|
+
consumedBytes = delimiter.index + delimiter.length;
|
|
214
|
+
scanOffset = consumedBytes;
|
|
215
|
+
}
|
|
216
|
+
if (consumedBytes > 0) {
|
|
217
|
+
buffer.copyWithin(0, consumedBytes, bufferedBytes);
|
|
218
|
+
bufferedBytes -= consumedBytes;
|
|
219
|
+
scanOffset = Math.max(0, scanOffset - consumedBytes);
|
|
129
220
|
}
|
|
130
221
|
}
|
|
131
222
|
|
|
132
|
-
if (buffer.
|
|
133
|
-
const parsed = parseSseFrame(buffer);
|
|
134
|
-
const type = eventTypeFromData(parsed);
|
|
135
|
-
if (type === "response.completed") return;
|
|
136
|
-
if (type === "response.failed") throw new CodexWarmupError("stream_failed");
|
|
137
|
-
if (type === "response.incomplete") throw new CodexWarmupError("stream_incomplete");
|
|
138
|
-
if (type === "error") throw new CodexWarmupError("stream_error");
|
|
139
|
-
}
|
|
140
|
-
|
|
223
|
+
if (bufferedBytes > 0 && acceptFrame(buffer.subarray(0, bufferedBytes))) return;
|
|
141
224
|
throw new CodexWarmupError("no_terminal", "Codex warmup ended before completion");
|
|
142
225
|
} finally {
|
|
143
|
-
|
|
226
|
+
try {
|
|
227
|
+
reader.releaseLock();
|
|
228
|
+
} catch {
|
|
229
|
+
// A non-settling cancellation can keep the pending read locked briefly.
|
|
230
|
+
}
|
|
144
231
|
}
|
|
145
232
|
}
|
|
146
233
|
|
|
147
234
|
async function tryWarmup(options: CodexWarmupOptions, model: string): Promise<void> {
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
headers: {
|
|
153
|
-
Authorization: `Bearer ${options.accessToken}`,
|
|
154
|
-
"ChatGPT-Account-Id": options.chatgptAccountId,
|
|
155
|
-
"Content-Type": "application/json",
|
|
156
|
-
},
|
|
157
|
-
body: JSON.stringify({
|
|
158
|
-
model,
|
|
159
|
-
instructions: "Reply with OK.",
|
|
160
|
-
input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }],
|
|
161
|
-
stream: true,
|
|
162
|
-
store: false,
|
|
163
|
-
}),
|
|
164
|
-
signal: AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS),
|
|
165
|
-
});
|
|
166
|
-
} catch (err) {
|
|
167
|
-
throw new CodexWarmupError("transport", "Codex warmup request failed", { cause: err });
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
if (!res.ok) {
|
|
171
|
-
const upstreamDetail = await readErrorDetail(res);
|
|
172
|
-
throw new CodexWarmupError("http_status", "Codex warmup was rejected", {
|
|
173
|
-
status: res.status,
|
|
174
|
-
upstreamDetail,
|
|
235
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
236
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 0 || timeoutMs > MAX_TIMEOUT_MS) {
|
|
237
|
+
throw new CodexWarmupError("transport", "Codex warmup request failed", {
|
|
238
|
+
cause: new RangeError("Codex warmup timeout is outside the supported range"),
|
|
175
239
|
});
|
|
176
240
|
}
|
|
177
|
-
|
|
241
|
+
const deadline = new AbortController();
|
|
242
|
+
const signal = deadline.signal;
|
|
243
|
+
const timer = setTimeout(() => {
|
|
244
|
+
deadline.abort(new DOMException("Codex warmup timed out", "TimeoutError"));
|
|
245
|
+
}, timeoutMs);
|
|
178
246
|
|
|
179
247
|
try {
|
|
180
|
-
|
|
248
|
+
let res: Response;
|
|
249
|
+
try {
|
|
250
|
+
res = await fetch(CODEX_RESPONSES_URL, {
|
|
251
|
+
method: "POST",
|
|
252
|
+
headers: {
|
|
253
|
+
Authorization: `Bearer ${options.accessToken}`,
|
|
254
|
+
"ChatGPT-Account-Id": options.chatgptAccountId,
|
|
255
|
+
"Content-Type": "application/json",
|
|
256
|
+
},
|
|
257
|
+
body: JSON.stringify({
|
|
258
|
+
model,
|
|
259
|
+
instructions: "Reply with OK.",
|
|
260
|
+
input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }],
|
|
261
|
+
stream: true,
|
|
262
|
+
store: false,
|
|
263
|
+
}),
|
|
264
|
+
signal,
|
|
265
|
+
});
|
|
266
|
+
} catch (err) {
|
|
267
|
+
throw new CodexWarmupError("transport", "Codex warmup request failed", { cause: err });
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (!res.ok) {
|
|
271
|
+
const quotaLike = await readErrorQuotaClassification(res, signal);
|
|
272
|
+
throw new CodexWarmupError("http_status", "Codex warmup was rejected", {
|
|
273
|
+
status: res.status,
|
|
274
|
+
quotaLike,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
const body = res.body;
|
|
278
|
+
if (!body) throw new CodexWarmupError("missing_body");
|
|
279
|
+
|
|
280
|
+
try {
|
|
281
|
+
await drainWarmupSse(body, signal);
|
|
282
|
+
} finally {
|
|
283
|
+
try {
|
|
284
|
+
void body.cancel().catch(() => {});
|
|
285
|
+
} catch {
|
|
286
|
+
// Some custom streams throw synchronously from cancel().
|
|
287
|
+
}
|
|
288
|
+
}
|
|
181
289
|
} finally {
|
|
182
|
-
|
|
290
|
+
clearTimeout(timer);
|
|
183
291
|
}
|
|
184
292
|
}
|
|
185
293
|
|
package/src/config.ts
CHANGED
|
@@ -12,6 +12,8 @@ import {
|
|
|
12
12
|
isValidCodexAccountNamespaceTarget,
|
|
13
13
|
MAIN_CODEX_ACCOUNT_NAMESPACE_TARGET,
|
|
14
14
|
} from "./codex/account-namespace-match";
|
|
15
|
+
import { isCodexAccountPriorityKey } from "./codex/account-priority";
|
|
16
|
+
import { parseAccountPriority } from "./codex/pool-rotation";
|
|
15
17
|
import { COMBO_NAMESPACE, comboConfigIssues } from "./combos/types";
|
|
16
18
|
import {
|
|
17
19
|
forgetHardenedSecretPath,
|
|
@@ -712,6 +714,25 @@ const codexAccountNamespacesSchema = z.custom<Record<string, unknown>>(
|
|
|
712
714
|
}
|
|
713
715
|
}).pipe(z.record(z.string(), z.string()));
|
|
714
716
|
|
|
717
|
+
const codexAccountPrioritiesSchema = z.custom<Record<string, unknown>>(
|
|
718
|
+
(value): value is Record<string, unknown> => !!value
|
|
719
|
+
&& typeof value === "object"
|
|
720
|
+
&& !Array.isArray(value)
|
|
721
|
+
&& (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null),
|
|
722
|
+
{ error: "codexAccountPriorities must be a plain object mapping account ids to integers" },
|
|
723
|
+
).superRefine((priorities, ctx) => {
|
|
724
|
+
for (const [accountId, priority] of Object.entries(priorities)) {
|
|
725
|
+
if (!isCodexAccountPriorityKey(accountId)) {
|
|
726
|
+
ctx.addIssue({ code: "custom", path: [accountId], message: "invalid Codex account id" });
|
|
727
|
+
}
|
|
728
|
+
if (parseAccountPriority(priority) === null) {
|
|
729
|
+
ctx.addIssue({ code: "custom", path: [accountId], message: "selection order must be an integer between -100 and 100" });
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
}).pipe(z.record(z.string(), z.number().int()));
|
|
733
|
+
|
|
734
|
+
const CODEX_ACCOUNT_PIN_PATTERN = /^[a-zA-Z0-9._-]{1,64}$/;
|
|
735
|
+
|
|
715
736
|
/**
|
|
716
737
|
* Deliberately permissive. A user's config is not ours to invalidate: a strict
|
|
717
738
|
* entry fails the whole parse, and loadConfig's fallback then backs the file up
|
|
@@ -779,13 +800,18 @@ const configSchema = z.object({
|
|
|
779
800
|
// hashes, UUIDs), so validate only length, never charset: a foreign id must not
|
|
780
801
|
// drop the whole paused set on config load.
|
|
781
802
|
pausedOauthAccountIds: z.record(z.string(), z.array(z.string().min(1).max(128))).optional(),
|
|
803
|
+
codexAccountPriorities: codexAccountPrioritiesSchema.optional().catch(undefined),
|
|
804
|
+
activeCodexAccountPinned: z.string().regex(CODEX_ACCOUNT_PIN_PATTERN).optional().catch(undefined),
|
|
782
805
|
codexAccountNamespaces: codexAccountNamespacesSchema.optional(),
|
|
806
|
+
codexAccountPickerEnabled: z.boolean().optional().catch(false),
|
|
783
807
|
// Model ids excluded from the Grok Build managed block (dashboard switches).
|
|
784
808
|
grokExcludedModels: z.array(z.string()).optional(),
|
|
785
809
|
// Invalid values degrade to undefined ("auto") instead of failing the whole
|
|
786
810
|
// parse: a hand-edited typo must never trip the backup-and-defaults repair
|
|
787
811
|
// path below and wipe providers/pool accounts. Warning emitted in loadConfig.
|
|
788
812
|
streamMode: z.enum(["auto", "legacy-tee", "eager-relay"]).optional().catch(undefined),
|
|
813
|
+
// A retry can be billable, so absence and malformed hand edits both stay off.
|
|
814
|
+
emptyCompletionRetry: z.boolean().optional().catch(false),
|
|
789
815
|
// Same degrade-don't-reject rationale as the fields above: a hand-edited
|
|
790
816
|
// non-string must not trip the backup-and-defaults repair path. Unset then
|
|
791
817
|
// takes the canonical sideband path (src/server/live.ts normalizeSidebandRoot).
|
|
@@ -1258,6 +1284,29 @@ function rawConfigRecord(rawParsed: unknown): Record<string, unknown> | null {
|
|
|
1258
1284
|
: null;
|
|
1259
1285
|
}
|
|
1260
1286
|
|
|
1287
|
+
function degradedCodexAccountRoutingWarnings(rawParsed: unknown, validated: OcxConfig): string[] {
|
|
1288
|
+
const raw = rawConfigRecord(rawParsed);
|
|
1289
|
+
if (!raw) return [];
|
|
1290
|
+
const warnings: string[] = [];
|
|
1291
|
+
if (raw.codexAccountPriorities !== undefined && validated.codexAccountPriorities === undefined) {
|
|
1292
|
+
warnings.push("codexAccountPriorities ignored: expected account ids mapped to integers between -100 and 100");
|
|
1293
|
+
}
|
|
1294
|
+
if (raw.activeCodexAccountPinned !== undefined && validated.activeCodexAccountPinned === undefined) {
|
|
1295
|
+
warnings.push("activeCodexAccountPinned ignored: expected a Codex account id");
|
|
1296
|
+
}
|
|
1297
|
+
if (raw.codexAccountPickerEnabled !== undefined
|
|
1298
|
+
&& typeof raw.codexAccountPickerEnabled !== "boolean") {
|
|
1299
|
+
warnings.push("codexAccountPickerEnabled ignored: expected a boolean");
|
|
1300
|
+
}
|
|
1301
|
+
return warnings;
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
function warnDegradedCodexAccountRouting(rawParsed: unknown, validated: OcxConfig): void {
|
|
1305
|
+
for (const warning of degradedCodexAccountRoutingWarnings(rawParsed, validated)) {
|
|
1306
|
+
console.warn(`⚠️ config.json ${warning}. Other settings were preserved.`);
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1261
1310
|
function malformedNativeSubagentFields(rawParsed: unknown): NativeSubagentPersistedField[] {
|
|
1262
1311
|
const raw = rawConfigRecord(rawParsed);
|
|
1263
1312
|
if (!raw) return [];
|
|
@@ -1325,6 +1374,7 @@ export function loadConfig(): OcxConfig {
|
|
|
1325
1374
|
const config = normalizeApiKeyIds(result.data as OcxConfig);
|
|
1326
1375
|
warnDegradedStreamMode(parsed, config);
|
|
1327
1376
|
warnDegradedHostname(parsed, config);
|
|
1377
|
+
warnDegradedCodexAccountRouting(parsed, config);
|
|
1328
1378
|
warnDegradedApiKeys(parsed, config);
|
|
1329
1379
|
warnDegradedClaudeSubagentEffort(parsed);
|
|
1330
1380
|
warnDegradedNativeSubagentConfig(parsed, config);
|
|
@@ -1344,6 +1394,7 @@ export function loadConfig(): OcxConfig {
|
|
|
1344
1394
|
warnConfigRepaired(configPath, result.error);
|
|
1345
1395
|
const config = normalizeApiKeyIds(retryResult.data as OcxConfig);
|
|
1346
1396
|
warnDegradedHostname(parsed, config);
|
|
1397
|
+
warnDegradedCodexAccountRouting(parsed, config);
|
|
1347
1398
|
warnDegradedApiKeys(parsed, config);
|
|
1348
1399
|
warnDegradedClaudeSubagentEffort(parsed);
|
|
1349
1400
|
warnDegradedNativeSubagentConfig(parsed, config);
|
|
@@ -1391,6 +1442,7 @@ function validFileConfigDiagnostics(config: OcxConfig, rawParsed: unknown): Conf
|
|
|
1391
1442
|
const rawEffort = rawClaudeSubagentEffort(rawParsed);
|
|
1392
1443
|
const normalized = normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, rawParsed), rawParsed);
|
|
1393
1444
|
const warnings = configPlaceholderWarnings(normalized);
|
|
1445
|
+
warnings.push(...degradedCodexAccountRoutingWarnings(rawParsed, normalized));
|
|
1394
1446
|
if (rawEffort !== undefined && !isClaudeSubagentEffort(rawEffort)) {
|
|
1395
1447
|
warnings.push(`claudeCode.subagentEffort ignored: expected one of ${CLAUDE_SUBAGENT_EFFORTS.join(", ")}`);
|
|
1396
1448
|
}
|
|
@@ -1472,12 +1524,42 @@ function googleAntigravityStaticCatalogVersionError(value: unknown): string | nu
|
|
|
1472
1524
|
return "schema_invalid: googleAntigravityStaticCatalogVersion: must be 1 or omitted";
|
|
1473
1525
|
}
|
|
1474
1526
|
|
|
1527
|
+
function codexAccountRoutingPreferenceError(value: unknown): string | null {
|
|
1528
|
+
const raw = rawConfigRecord(value);
|
|
1529
|
+
if (!raw) return null;
|
|
1530
|
+
if (raw.codexAccountPriorities !== undefined) {
|
|
1531
|
+
const parsed = codexAccountPrioritiesSchema.safeParse(raw.codexAccountPriorities);
|
|
1532
|
+
if (!parsed.success) {
|
|
1533
|
+
return schemaDiagnosticsError(parsed.error).replace("schema_invalid: ", "schema_invalid: codexAccountPriorities.");
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
const pin = raw.activeCodexAccountPinned;
|
|
1537
|
+
if (pin !== undefined && (typeof pin !== "string" || !CODEX_ACCOUNT_PIN_PATTERN.test(pin))) {
|
|
1538
|
+
return "schema_invalid: activeCodexAccountPinned: must be an account id";
|
|
1539
|
+
}
|
|
1540
|
+
const picker = raw.codexAccountPickerEnabled;
|
|
1541
|
+
if (picker !== undefined && typeof picker !== "boolean") {
|
|
1542
|
+
return "schema_invalid: codexAccountPickerEnabled: must be a boolean";
|
|
1543
|
+
}
|
|
1544
|
+
return null;
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
function emptyCompletionRetryError(value: unknown): string | null {
|
|
1548
|
+
const raw = rawConfigRecord(value);
|
|
1549
|
+
if (!raw || !Object.hasOwn(raw, "emptyCompletionRetry")) return null;
|
|
1550
|
+
return raw.emptyCompletionRetry === undefined || typeof raw.emptyCompletionRetry === "boolean"
|
|
1551
|
+
? null
|
|
1552
|
+
: "schema_invalid: emptyCompletionRetry: must be a boolean or omitted";
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1475
1555
|
/** Validate an in-memory config candidate without touching disk. Used by headless CLI import/set. */
|
|
1476
1556
|
export function validateConfigCandidate(value: unknown): { ok: true; config: OcxConfig } | { ok: false; error: string } {
|
|
1477
1557
|
const boundaryError = blankHostnameError(value)
|
|
1478
1558
|
?? claudeSubagentEffortError(value)
|
|
1479
1559
|
?? appOwnedMemoryBudgetError(value)
|
|
1480
|
-
?? googleAntigravityStaticCatalogVersionError(value)
|
|
1560
|
+
?? googleAntigravityStaticCatalogVersionError(value)
|
|
1561
|
+
?? codexAccountRoutingPreferenceError(value)
|
|
1562
|
+
?? emptyCompletionRetryError(value);
|
|
1481
1563
|
if (boundaryError) return { ok: false, error: boundaryError };
|
|
1482
1564
|
const result = configSchema.safeParse(value);
|
|
1483
1565
|
if (result.success) return { ok: true, config: normalizeApiKeyIds(result.data as OcxConfig) };
|
|
@@ -2009,6 +2091,7 @@ export function getDefaultConfig(): OcxConfig {
|
|
|
2009
2091
|
// Adding extra providers (e.g. opencode-go) and switching defaultProvider is a user/runtime choice.
|
|
2010
2092
|
return {
|
|
2011
2093
|
port: 10100,
|
|
2094
|
+
emptyCompletionRetry: false,
|
|
2012
2095
|
managementUsageMaxReadBytes: 64 * 1024 * 1024,
|
|
2013
2096
|
appOwnedMemoryBudgetMb: DEFAULT_APP_OWNED_MEMORY_BUDGET_BYTES / (1024 * 1024),
|
|
2014
2097
|
// Fresh/re-initialized configs are already written in the current three-tier
|
package/src/lib/bounded-body.ts
CHANGED
|
@@ -7,10 +7,16 @@ export const BOUNDED_BODY_TIMEOUT_MS = 5_000;
|
|
|
7
7
|
export interface BoundedBodyOptions {
|
|
8
8
|
/** Abort the read with this signal. Its reason is rethrown by identity. */
|
|
9
9
|
signal?: AbortSignal;
|
|
10
|
+
/** Reject malformed or truncated UTF-8 instead of replacing invalid bytes. */
|
|
11
|
+
fatalUtf8?: boolean;
|
|
12
|
+
/** Per-call retained byte ceiling. Defaults to BOUNDED_BODY_MAX_BYTES. */
|
|
13
|
+
maxBytes?: number;
|
|
10
14
|
/** Total wall-clock deadline. Exposed for focused tests. */
|
|
11
15
|
totalTimeoutMs?: number;
|
|
12
16
|
/** Deadline between non-empty raw chunks. Exposed for focused tests. */
|
|
13
17
|
inactivityTimeoutMs?: number;
|
|
18
|
+
/** Deadline for the first non-empty chunk. Defaults to inactivityTimeoutMs. */
|
|
19
|
+
firstByteTimeoutMs?: number;
|
|
14
20
|
}
|
|
15
21
|
|
|
16
22
|
export interface BoundedBodyResult {
|
|
@@ -56,8 +62,8 @@ function cancelWithoutWaiting(reader: ReadableStreamDefaultReader<Uint8Array>, r
|
|
|
56
62
|
}
|
|
57
63
|
}
|
|
58
64
|
|
|
59
|
-
function decodeUtf8(chunks: readonly Uint8Array[]): string {
|
|
60
|
-
const decoder = new TextDecoder();
|
|
65
|
+
function decodeUtf8(chunks: readonly Uint8Array[], fatal: boolean): string {
|
|
66
|
+
const decoder = new TextDecoder("utf-8", { fatal });
|
|
61
67
|
let text = "";
|
|
62
68
|
for (const chunk of chunks) text += decoder.decode(chunk, { stream: true });
|
|
63
69
|
// Flush an incomplete trailing UTF-8 sequence deterministically.
|
|
@@ -93,13 +99,14 @@ export async function readBoundedResponseBody(
|
|
|
93
99
|
}
|
|
94
100
|
|
|
95
101
|
const reader = body.getReader();
|
|
102
|
+
const maxBytes = options.maxBytes ?? BOUNDED_BODY_MAX_BYTES;
|
|
96
103
|
const chunks: Uint8Array[] = [];
|
|
97
104
|
let retainedBytes = 0;
|
|
98
105
|
let mustCancel = false;
|
|
99
106
|
let cancelReason: unknown;
|
|
100
107
|
const total = timeoutPromise(options.totalTimeoutMs ?? BOUNDED_BODY_TIMEOUT_MS, TOTAL_TIMEOUT);
|
|
101
108
|
let inactivity = timeoutPromise(
|
|
102
|
-
options.inactivityTimeoutMs ?? BOUNDED_BODY_TIMEOUT_MS,
|
|
109
|
+
options.firstByteTimeoutMs ?? options.inactivityTimeoutMs ?? BOUNDED_BODY_TIMEOUT_MS,
|
|
103
110
|
INACTIVITY_TIMEOUT,
|
|
104
111
|
);
|
|
105
112
|
|
|
@@ -134,7 +141,7 @@ export async function readBoundedResponseBody(
|
|
|
134
141
|
"TimeoutError",
|
|
135
142
|
);
|
|
136
143
|
return {
|
|
137
|
-
text: decodeUtf8(chunks),
|
|
144
|
+
text: decodeUtf8(chunks, options.fatalUtf8 === true),
|
|
138
145
|
truncated: true,
|
|
139
146
|
timedOut: true,
|
|
140
147
|
totalTimedOut: outcome === TOTAL_TIMEOUT,
|
|
@@ -147,7 +154,7 @@ export async function readBoundedResponseBody(
|
|
|
147
154
|
const { value, done } = outcome as ReadableStreamReadResult<Uint8Array>;
|
|
148
155
|
if (done) {
|
|
149
156
|
return {
|
|
150
|
-
text: decodeUtf8(chunks),
|
|
157
|
+
text: decodeUtf8(chunks, options.fatalUtf8 === true),
|
|
151
158
|
truncated: false,
|
|
152
159
|
timedOut: false,
|
|
153
160
|
totalTimedOut: false,
|
|
@@ -165,7 +172,7 @@ export async function readBoundedResponseBody(
|
|
|
165
172
|
INACTIVITY_TIMEOUT,
|
|
166
173
|
);
|
|
167
174
|
|
|
168
|
-
if (value.byteLength >
|
|
175
|
+
if (value.byteLength > maxBytes - retainedBytes) {
|
|
169
176
|
mustCancel = true;
|
|
170
177
|
cancelReason = new DOMException("Error body size limit reached", "QuotaExceededError");
|
|
171
178
|
chunks.length = 0;
|
|
@@ -3,9 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* The eager bounded relay (src/server/relay-eager.ts) uses a JS async producer
|
|
5
5
|
* loop — the exact shape of the Bun#32111 use-after-free (fixed upstream by Bun
|
|
6
|
-
* PR #32120, merged 2026-06-21).
|
|
7
|
-
* that fix
|
|
8
|
-
* "known-bad" until a bundle-bump commit sets it. Windows no-rewrite traffic
|
|
6
|
+
* PR #32120, merged 2026-06-21). Bun 1.4.0 is the first released version proven
|
|
7
|
+
* to carry that fix, so older runtimes remain "known-bad". Windows no-rewrite traffic
|
|
9
8
|
* follows this runtime/config decision. Darwin no-rewrite traffic stays on tee
|
|
10
9
|
* for `auto` regardless of runtime capability and reaches eager relay only via
|
|
11
10
|
* explicit `streamMode: "eager-relay"` opt-in (see
|
|
@@ -18,10 +17,10 @@
|
|
|
18
17
|
*/
|
|
19
18
|
|
|
20
19
|
/**
|
|
21
|
-
*
|
|
22
|
-
*
|
|
20
|
+
* Keep this in sync with the first bundled stable release verified to include
|
|
21
|
+
* Bun PR #32120. null = no released version is known-fixed.
|
|
23
22
|
*/
|
|
24
|
-
export const MIN_FIXED_BUN_VERSION: string | null =
|
|
23
|
+
export const MIN_FIXED_BUN_VERSION: string | null = "1.4.0";
|
|
25
24
|
|
|
26
25
|
export type StreamMode = "auto" | "legacy-tee" | "eager-relay";
|
|
27
26
|
|
package/src/lib/redact.ts
CHANGED
|
@@ -38,6 +38,19 @@ export function redactSecretString(value: string): string {
|
|
|
38
38
|
return redacted;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/** Shared bounded single-line representation for caller-controlled log metadata. */
|
|
42
|
+
export function sanitizeLogMetadataString(value: unknown, maxLength = 64): string | undefined {
|
|
43
|
+
if (typeof value !== "string" || !Number.isInteger(maxLength) || maxLength < 1) return undefined;
|
|
44
|
+
// Replace record separators with spaces so two adjacent fragments cannot be
|
|
45
|
+
// concatenated into a token that evades boundary-aware secret redaction.
|
|
46
|
+
const filtered = value.trim()
|
|
47
|
+
.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, " ")
|
|
48
|
+
.replace(/\s+/g, " ");
|
|
49
|
+
if (!filtered) return undefined;
|
|
50
|
+
const redacted = redactSecretString(filtered).trim();
|
|
51
|
+
return redacted ? redacted.slice(0, maxLength) : undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
41
54
|
export function redactSecrets(value: unknown): unknown {
|
|
42
55
|
if (typeof value === "string") return redactSecretString(value);
|
|
43
56
|
if (Array.isArray(value)) return value.map(item => redactSecrets(item));
|