@bitkyc08/opencodex 2.7.39 → 2.7.40
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/README.md +4 -4
- package/gui/dist/assets/index-CMip1DzF.css +1 -0
- package/gui/dist/assets/index-cydcmbzC.js +52 -0
- package/gui/dist/index.html +2 -2
- package/package.json +2 -2
- package/src/adapters/cursor/arg-normalize.ts +23 -7
- package/src/adapters/cursor/live-transport.ts +26 -14
- package/src/adapters/cursor/native-exec-fs.ts +1 -1
- package/src/adapters/cursor/native-exec-network.ts +1 -1
- package/src/adapters/cursor/native-exec-shell.ts +1 -1
- package/src/adapters/cursor/protobuf-events.ts +72 -13
- package/src/adapters/cursor/protobuf-request.ts +82 -11
- package/src/adapters/cursor/request-builder.ts +35 -11
- package/src/adapters/cursor/tool-definitions.ts +175 -30
- package/src/adapters/openai-chat.ts +28 -7
- package/src/adapters/openai-responses.ts +150 -4
- package/src/bridge.ts +20 -1
- package/src/claude/outbound.ts +91 -6
- package/src/codex/auth-api.ts +12 -25
- package/src/codex/auth-context.ts +48 -3
- package/src/codex/catalog/provider-fetch.ts +56 -24
- package/src/codex/model-cache.ts +23 -0
- package/src/codex/quota.ts +120 -0
- package/src/codex/routing.ts +178 -9
- package/src/config.ts +56 -1
- package/src/providers/openai-sidecar.ts +8 -1
- package/src/providers/openai-tiers.ts +18 -0
- package/src/server/adapter-resolve.ts +24 -10
- package/src/server/auth-cors.ts +3 -0
- package/src/server/chat-completions.ts +4 -0
- package/src/server/claude-messages.ts +4 -0
- package/src/server/index.ts +3 -1
- package/src/server/live.ts +56 -0
- package/src/server/memory-watchdog.ts +1 -1
- package/src/server/responses/compact.ts +40 -10
- package/src/server/responses/core.ts +180 -26
- package/src/server/responses/terminal-guard.ts +230 -0
- package/src/service.ts +113 -30
- package/src/types.ts +52 -0
- package/src/usage/expected-prices.ts +12 -0
- package/src/web-search/anthropic-executor.ts +3 -1
- package/src/web-search/index.ts +7 -1
- package/src/web-search/loop.ts +17 -3
- package/README.ja.md +0 -445
- package/README.ko.md +0 -435
- package/README.ru.md +0 -486
- package/README.zh-CN.md +0 -411
- package/gui/dist/assets/index-B-cheu55.js +0 -52
- package/gui/dist/assets/index-oOZcqVmj.css +0 -1
package/src/codex/routing.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
1
2
|
import { saveConfig } from "../config";
|
|
2
3
|
import { isCodexAccountGenerationLive, readCodexAccountRecord } from "./account-store";
|
|
3
4
|
import { codexAccountLogLabel } from "./account-label";
|
|
@@ -31,6 +32,29 @@ type CodexUpstreamHealth = {
|
|
|
31
32
|
lastFailureAt?: number;
|
|
32
33
|
/** Hard cooldown (quota 429). Survives a later 2xx; blocks auth + selection. */
|
|
33
34
|
cooldownUntil?: number;
|
|
35
|
+
/** When the current cooldown was recorded; origin of the probe interval clock. */
|
|
36
|
+
cooldownSince?: number;
|
|
37
|
+
/**
|
|
38
|
+
* What produced the cooldown. An explicit Retry-After is a literal retry
|
|
39
|
+
* directive and is never probed; a quota resetAt only announces a window
|
|
40
|
+
* refresh, so it may be probed early (#433).
|
|
41
|
+
*/
|
|
42
|
+
cooldownSource?: CodexCooldownSource;
|
|
43
|
+
/**
|
|
44
|
+
* Bumped on every cooldown write. A probe lease records the generation it was
|
|
45
|
+
* issued for so a lease cannot clear a cooldown that a later 429 replaced.
|
|
46
|
+
*/
|
|
47
|
+
cooldownGeneration?: number;
|
|
48
|
+
/**
|
|
49
|
+
* Identity of the in-flight probe. A cooled-down account sends no traffic, so
|
|
50
|
+
* no organic 2xx can prove recovery; only the outcome carrying this id may
|
|
51
|
+
* clear the cooldown.
|
|
52
|
+
*/
|
|
53
|
+
probeLeaseId?: string;
|
|
54
|
+
/** Cooldown generation at the moment the lease was granted. */
|
|
55
|
+
probeLeaseGeneration?: number;
|
|
56
|
+
/** Last probe grant or conclusion; paces the probe interval. */
|
|
57
|
+
lastProbeAt?: number;
|
|
34
58
|
/**
|
|
35
59
|
* Soft avoid after connect_error / timeout / transient 5xx. Cleared on 2xx.
|
|
36
60
|
* Blocks pool selection + thread affinity reuse so a sticky session can leave a
|
|
@@ -41,6 +65,15 @@ type CodexUpstreamHealth = {
|
|
|
41
65
|
|
|
42
66
|
const CODEX_DEFAULT_QUOTA_COOLDOWN_MS = 60_000;
|
|
43
67
|
const CODEX_MAX_QUOTA_COOLDOWN_MS = 24 * 60 * 60_000;
|
|
68
|
+
/**
|
|
69
|
+
* A weekly/monthly quota `resetAt` announces when the window refreshes; it is not
|
|
70
|
+
* a "come back after this" directive like Retry-After. Plan quota routinely frees
|
|
71
|
+
* up long before the advertised reset, so cap reset-derived cooldowns far below
|
|
72
|
+
* the Retry-After ceiling (#433).
|
|
73
|
+
*/
|
|
74
|
+
const CODEX_MAX_RESET_DERIVED_COOLDOWN_MS = 15 * 60_000;
|
|
75
|
+
/** Minimum gap between probe leases for one cooled-down account. */
|
|
76
|
+
export const CODEX_QUOTA_PROBE_INTERVAL_MS = 5 * 60_000;
|
|
44
77
|
export const CODEX_FAILURE_WINDOW_MS = 5 * 60_000;
|
|
45
78
|
/** How long a transient failure keeps the account out of pool selection. */
|
|
46
79
|
export const CODEX_TRANSIENT_SOFT_AVOID_MS = 30_000;
|
|
@@ -60,12 +93,19 @@ const upstreamHealth = new Map<string, CodexUpstreamHealth>();
|
|
|
60
93
|
|
|
61
94
|
export type CodexUpstreamOutcome = number | "connect_error" | "timeout";
|
|
62
95
|
export type CodexUpstreamOutcomeClass = "success" | "credential" | "quota" | "transient" | "caller" | "unknown";
|
|
96
|
+
export type CodexCooldownSource = "retry-after" | "reset-derived" | "default";
|
|
63
97
|
export type CodexUpstreamOutcomeMeta = {
|
|
64
98
|
retryAfter?: string | null;
|
|
65
99
|
resetAt?: unknown | unknown[];
|
|
66
100
|
now?: number;
|
|
67
101
|
/** When set, clears affinity for this thread immediately on transient failure. */
|
|
68
102
|
threadId?: string | null;
|
|
103
|
+
/**
|
|
104
|
+
* Probe lease held by this request, when it was admitted through an active
|
|
105
|
+
* quota cooldown. Only the outcome carrying the current lease may clear the
|
|
106
|
+
* cooldown (#433).
|
|
107
|
+
*/
|
|
108
|
+
probeLeaseId?: string;
|
|
69
109
|
};
|
|
70
110
|
|
|
71
111
|
function hasConfiguredPoolAccount(config: OcxConfig, accountId: string): boolean {
|
|
@@ -159,17 +199,104 @@ export function parseResetCooldownMs(resetAt: unknown | unknown[] | undefined, n
|
|
|
159
199
|
if (timestamp === undefined) continue;
|
|
160
200
|
const delay = timestamp - now;
|
|
161
201
|
if (delay <= 0) continue;
|
|
162
|
-
|
|
202
|
+
// A far-future reset must not pin the account for the full Retry-After
|
|
203
|
+
// ceiling: quota usually frees up well before the advertised window (#433).
|
|
204
|
+
const clamped = Math.min(clampCooldownMs(delay), CODEX_MAX_RESET_DERIVED_COOLDOWN_MS);
|
|
163
205
|
if (best === undefined || clamped < best) best = clamped;
|
|
164
206
|
}
|
|
165
207
|
return best;
|
|
166
208
|
}
|
|
167
209
|
|
|
168
|
-
export function
|
|
210
|
+
export function computeQuotaCooldown(meta: CodexUpstreamOutcomeMeta = {}): {
|
|
211
|
+
until: number;
|
|
212
|
+
source: CodexCooldownSource;
|
|
213
|
+
} {
|
|
169
214
|
const now = meta.now ?? Date.now();
|
|
170
215
|
const retryAfterMs = parseRetryAfterMs(meta.retryAfter, now);
|
|
171
|
-
|
|
172
|
-
|
|
216
|
+
if (retryAfterMs !== undefined) return { until: now + retryAfterMs, source: "retry-after" };
|
|
217
|
+
const resetCooldownMs = parseResetCooldownMs(meta.resetAt, now);
|
|
218
|
+
if (resetCooldownMs !== undefined) return { until: now + resetCooldownMs, source: "reset-derived" };
|
|
219
|
+
return { until: now + CODEX_DEFAULT_QUOTA_COOLDOWN_MS, source: "default" };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function computeQuotaCooldownUntil(meta: CodexUpstreamOutcomeMeta = {}): number {
|
|
223
|
+
return computeQuotaCooldown(meta).until;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Grant at most one probe lease per interval for a cooled-down account.
|
|
228
|
+
*
|
|
229
|
+
* A cooled-down account is short-circuited locally, so it never sends traffic and
|
|
230
|
+
* no organic 2xx can prove that upstream quota recovered — the cooldown can only
|
|
231
|
+
* end by expiry or a proxy restart (#433). Releasing a single probe breaks that
|
|
232
|
+
* deadlock. Explicit Retry-After cooldowns are excluded: those are literal retry
|
|
233
|
+
* directives, not window announcements.
|
|
234
|
+
*
|
|
235
|
+
* Returns the lease id, or null when no probe may go out right now.
|
|
236
|
+
*/
|
|
237
|
+
export function tryAcquireCodexQuotaProbeLease(accountId: string, now = Date.now()): string | null {
|
|
238
|
+
const health = upstreamHealth.get(accountId);
|
|
239
|
+
if (!health) return null;
|
|
240
|
+
const cooldownUntil = health.cooldownUntil;
|
|
241
|
+
if (typeof cooldownUntil !== "number" || !Number.isFinite(cooldownUntil) || cooldownUntil <= now) return null;
|
|
242
|
+
if (health.cooldownSource === "retry-after") return null;
|
|
243
|
+
if (health.probeLeaseId !== undefined) return null;
|
|
244
|
+
const origin = health.lastProbeAt ?? health.cooldownSince ?? cooldownUntil;
|
|
245
|
+
if (now - origin < CODEX_QUOTA_PROBE_INTERVAL_MS) return null;
|
|
246
|
+
const probeLeaseId = randomUUID();
|
|
247
|
+
upstreamHealth.set(accountId, {
|
|
248
|
+
...health,
|
|
249
|
+
probeLeaseId,
|
|
250
|
+
probeLeaseGeneration: health.cooldownGeneration ?? 0,
|
|
251
|
+
lastProbeAt: now,
|
|
252
|
+
});
|
|
253
|
+
return probeLeaseId;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Hand a probe lease back without recording an upstream outcome. Used by paths
|
|
258
|
+
* that take a lease and then fail before any request reaches upstream.
|
|
259
|
+
*/
|
|
260
|
+
export function releaseCodexQuotaProbeLease(accountId: string, leaseId: string, now = Date.now()): void {
|
|
261
|
+
const health = upstreamHealth.get(accountId);
|
|
262
|
+
if (!health || health.probeLeaseId !== leaseId) return;
|
|
263
|
+
upstreamHealth.set(accountId, withProbeLeaseReleased(health, now));
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* True when this outcome belongs to the account's in-flight probe. The
|
|
268
|
+
* undefined-id guard matters: without it an outcome carrying no lease would match
|
|
269
|
+
* an account holding no lease and be mistaken for the probe owner.
|
|
270
|
+
*/
|
|
271
|
+
function ownsProbeLease(health: CodexUpstreamHealth | undefined, meta: CodexUpstreamOutcomeMeta): boolean {
|
|
272
|
+
return meta.probeLeaseId !== undefined && meta.probeLeaseId === health?.probeLeaseId;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* True when the owning probe may still clear the cooldown. A later 429 bumps the
|
|
277
|
+
* generation, so a probe that started under an older cooldown must not erase the
|
|
278
|
+
* newer restriction (which may carry an explicit Retry-After).
|
|
279
|
+
*/
|
|
280
|
+
function probeMayClearCooldown(health: CodexUpstreamHealth | undefined, meta: CodexUpstreamOutcomeMeta): boolean {
|
|
281
|
+
return ownsProbeLease(health, meta)
|
|
282
|
+
&& (health!.probeLeaseGeneration ?? 0) === (health!.cooldownGeneration ?? 0);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Strip the in-flight lease while preserving every hard-cooldown field. */
|
|
286
|
+
function withProbeLeaseReleased(health: CodexUpstreamHealth, now: number): CodexUpstreamHealth {
|
|
287
|
+
const { probeLeaseId: _id, probeLeaseGeneration: _gen, ...rest } = health;
|
|
288
|
+
return { ...rest, lastProbeAt: now };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Hard-cooldown bookkeeping that ordinary success/transient transitions rebuild
|
|
293
|
+
* their health object from. Dropping these would let one late unrelated response
|
|
294
|
+
* erase a Retry-After source, a cooldown generation, or someone else's live probe.
|
|
295
|
+
*/
|
|
296
|
+
function preservedCooldownFields(health: CodexUpstreamHealth | undefined): Partial<CodexUpstreamHealth> {
|
|
297
|
+
if (!health) return {};
|
|
298
|
+
const { consecutiveFailures: _f, consecutiveSuccesses: _s, lastFailureStatus: _st, lastFailureAt: _at, softAvoidUntil: _sa, ...cooldownFields } = health;
|
|
299
|
+
return cooldownFields;
|
|
173
300
|
}
|
|
174
301
|
|
|
175
302
|
export function getCodexAccountCooldownUntil(accountId: string, now = Date.now()): number | null {
|
|
@@ -459,28 +586,49 @@ export function recordCodexUpstreamOutcome(
|
|
|
459
586
|
if (outcomeClass === "success") {
|
|
460
587
|
const current = upstreamHealth.get(accountId);
|
|
461
588
|
const cooldownUntil = getCodexAccountCooldownUntil(accountId, now);
|
|
589
|
+
// A leased probe that is still on its own cooldown generation proves the
|
|
590
|
+
// account recovered: clear the hard cooldown outright (#433).
|
|
591
|
+
if (cooldownUntil && probeMayClearCooldown(current, meta)) {
|
|
592
|
+
upstreamHealth.delete(accountId);
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
// Owning probe on a stale generation: the lease is done, but a newer 429
|
|
596
|
+
// replaced the cooldown in the meantime, so only give the lease back.
|
|
597
|
+
// Non-owners keep every hard-cooldown field, including someone else's live lease.
|
|
598
|
+
const base = ownsProbeLease(current, meta) ? withProbeLeaseReleased(current!, now) : current;
|
|
599
|
+
const preserved = preservedCooldownFields(base);
|
|
462
600
|
const failoverEnabled = (config.upstreamFailoverThreshold ?? 3) > 0;
|
|
463
601
|
if (failoverEnabled && current && current.consecutiveFailures >= 2) {
|
|
464
602
|
const consecutiveSuccesses = (current.consecutiveSuccesses ?? 0) + 1;
|
|
465
603
|
if (consecutiveSuccesses < 2) {
|
|
466
604
|
upstreamHealth.set(accountId, {
|
|
467
|
-
...
|
|
605
|
+
...base!,
|
|
606
|
+
...preserved,
|
|
468
607
|
consecutiveSuccesses,
|
|
469
|
-
...(cooldownUntil ? { cooldownUntil } : {}),
|
|
470
608
|
});
|
|
471
609
|
return;
|
|
472
610
|
}
|
|
473
611
|
}
|
|
474
612
|
// Level 1 clears immediately; escalated accounts need two consecutive healthy terminals.
|
|
475
613
|
// Hard quota cooldown intentionally survives either recovery path.
|
|
476
|
-
if (cooldownUntil) upstreamHealth.set(accountId, { consecutiveFailures: 0,
|
|
614
|
+
if (cooldownUntil) upstreamHealth.set(accountId, { consecutiveFailures: 0, ...preserved });
|
|
477
615
|
else upstreamHealth.delete(accountId);
|
|
478
616
|
return;
|
|
479
617
|
}
|
|
480
|
-
if (outcomeClass === "caller")
|
|
618
|
+
if (outcomeClass === "caller") {
|
|
619
|
+
// A 4xx does not change account health, but it does conclude an in-flight
|
|
620
|
+
// probe — otherwise the lease would never be handed back.
|
|
621
|
+
const current = upstreamHealth.get(accountId);
|
|
622
|
+
if (ownsProbeLease(current, meta)) {
|
|
623
|
+
upstreamHealth.set(accountId, withProbeLeaseReleased(current!, now));
|
|
624
|
+
}
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
481
627
|
|
|
482
628
|
const lastFailureStatus = typeof outcome === "number" ? outcome : 0;
|
|
483
629
|
if (outcomeClass === "credential") {
|
|
630
|
+
// 401/403 quarantines the account for reauth. That supersedes quota state
|
|
631
|
+
// entirely: a cooldown (and any probe lease) on an unusable account is moot.
|
|
484
632
|
upstreamHealth.set(accountId, {
|
|
485
633
|
consecutiveFailures: 1,
|
|
486
634
|
lastFailureStatus,
|
|
@@ -492,11 +640,28 @@ export function recordCodexUpstreamOutcome(
|
|
|
492
640
|
}
|
|
493
641
|
|
|
494
642
|
if (outcomeClass === "quota") {
|
|
643
|
+
const prior = upstreamHealth.get(accountId);
|
|
644
|
+
const { until, source } = computeQuotaCooldown(meta);
|
|
645
|
+
// Every cooldown write bumps the generation so a probe issued against the
|
|
646
|
+
// previous cooldown can no longer clear this one (#433).
|
|
647
|
+
const cooldownGeneration = (prior?.cooldownGeneration ?? 0) + 1;
|
|
648
|
+
// A failed probe concludes its lease; an unrelated 429 leaves the live probe alone.
|
|
649
|
+
const ownsLease = ownsProbeLease(prior, meta);
|
|
495
650
|
upstreamHealth.set(accountId, {
|
|
496
651
|
consecutiveFailures: 0,
|
|
497
652
|
lastFailureStatus,
|
|
498
653
|
lastFailureAt: now,
|
|
499
|
-
cooldownUntil:
|
|
654
|
+
cooldownUntil: until,
|
|
655
|
+
cooldownSince: now,
|
|
656
|
+
cooldownSource: source,
|
|
657
|
+
cooldownGeneration,
|
|
658
|
+
...(ownsLease
|
|
659
|
+
? { lastProbeAt: now }
|
|
660
|
+
: {
|
|
661
|
+
...(prior?.probeLeaseId !== undefined ? { probeLeaseId: prior.probeLeaseId } : {}),
|
|
662
|
+
...(prior?.probeLeaseGeneration !== undefined ? { probeLeaseGeneration: prior.probeLeaseGeneration } : {}),
|
|
663
|
+
...(prior?.lastProbeAt !== undefined ? { lastProbeAt: prior.lastProbeAt } : {}),
|
|
664
|
+
}),
|
|
500
665
|
});
|
|
501
666
|
clearThreadAccountMapForAccount(accountId);
|
|
502
667
|
if (config.activeCodexAccountId === accountId) {
|
|
@@ -508,6 +673,9 @@ export function recordCodexUpstreamOutcome(
|
|
|
508
673
|
|
|
509
674
|
// transient (connect_error / timeout / 5xx)
|
|
510
675
|
const current = upstreamHealth.get(accountId);
|
|
676
|
+
// A transient failure concludes an owning probe; an unrelated 5xx must not
|
|
677
|
+
// consume someone else's live lease or drop hard-cooldown bookkeeping (#433).
|
|
678
|
+
const transientBase = ownsProbeLease(current, meta) ? withProbeLeaseReleased(current!, now) : current;
|
|
511
679
|
const stale = current?.lastFailureAt ? now - current.lastFailureAt > CODEX_FAILURE_WINDOW_MS : false;
|
|
512
680
|
const hardCooldownUntil = getCodexAccountCooldownUntil(accountId, now) ?? undefined;
|
|
513
681
|
// Soft avoid + affinity clears are part of failover. When threshold is 0, leave
|
|
@@ -524,6 +692,7 @@ export function recordCodexUpstreamOutcome(
|
|
|
524
692
|
)
|
|
525
693
|
: undefined;
|
|
526
694
|
upstreamHealth.set(accountId, {
|
|
695
|
+
...preservedCooldownFields(transientBase),
|
|
527
696
|
consecutiveFailures,
|
|
528
697
|
lastFailureStatus,
|
|
529
698
|
lastFailureAt: now,
|
package/src/config.ts
CHANGED
|
@@ -7,7 +7,14 @@ import { comboConfigIssues } from "./combos/types";
|
|
|
7
7
|
import { hardenSecretDir, hardenSecretPath } from "./lib/windows-secret-acl";
|
|
8
8
|
import { providerDestinationConfigError } from "./lib/destination-policy";
|
|
9
9
|
import { openRouterRoutingConfigError } from "./providers/openrouter-routing";
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
isWirePinnedModel,
|
|
12
|
+
MODEL_ADAPTER_OVERRIDE_ALLOWED,
|
|
13
|
+
OPENAI_PROVIDER_TIER_VERSION,
|
|
14
|
+
type OcxConfig,
|
|
15
|
+
type OcxProviderConfig,
|
|
16
|
+
} from "./types";
|
|
17
|
+
import { isCanonicalOpenAiForwardProvider } from "./providers/openai-tiers";
|
|
11
18
|
|
|
12
19
|
let _atomicSeq = 0;
|
|
13
20
|
|
|
@@ -434,6 +441,41 @@ export function booleanRecordConfigError(value: unknown, field: string): string
|
|
|
434
441
|
return null;
|
|
435
442
|
}
|
|
436
443
|
|
|
444
|
+
/**
|
|
445
|
+
* Validate a provider's per-model wire override map (#404).
|
|
446
|
+
*
|
|
447
|
+
* Rejects, rather than silently ignoring, configurations the resolver would refuse:
|
|
448
|
+
* a value outside the allowed wires, a model the upstream pins to one wire, and any
|
|
449
|
+
* override on a canonical forward provider (where switching wires would drop the
|
|
450
|
+
* caller's forwarded credential). Silently dropping them would leave the user
|
|
451
|
+
* believing an override is in effect.
|
|
452
|
+
*/
|
|
453
|
+
export function modelAdapterRecordConfigError(
|
|
454
|
+
value: unknown,
|
|
455
|
+
field: string,
|
|
456
|
+
providerName: string,
|
|
457
|
+
provider: { adapter?: unknown; authMode?: unknown; baseUrl?: unknown },
|
|
458
|
+
): string | null {
|
|
459
|
+
if (value === undefined) return null;
|
|
460
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return `${field} must be a plain object`;
|
|
461
|
+
const prototype = Object.getPrototypeOf(value);
|
|
462
|
+
if (prototype !== Object.prototype && prototype !== null) return `${field} must be a plain object with own properties`;
|
|
463
|
+
const entries = Object.entries(value);
|
|
464
|
+
if (entries.length > 0 && isCanonicalOpenAiForwardProvider(provider as OcxProviderConfig)) {
|
|
465
|
+
return `${field} is not supported on the canonical ChatGPT forward provider`;
|
|
466
|
+
}
|
|
467
|
+
for (const [key, entry] of entries) {
|
|
468
|
+
if (!key.trim()) return `${field} keys must be nonblank model ids`;
|
|
469
|
+
if (typeof entry !== "string" || !MODEL_ADAPTER_OVERRIDE_ALLOWED.has(entry)) {
|
|
470
|
+
return `${field}.${key} must be one of: ${[...MODEL_ADAPTER_OVERRIDE_ALLOWED].join(", ")}`;
|
|
471
|
+
}
|
|
472
|
+
if (isWirePinnedModel(providerName, key.trim())) {
|
|
473
|
+
return `${field}.${key} cannot be overridden: the upstream only speaks one wire for this model`;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
return null;
|
|
477
|
+
}
|
|
478
|
+
|
|
437
479
|
const configSchema = z.object({
|
|
438
480
|
port: z.number().int().min(0).max(65535).default(10100),
|
|
439
481
|
providers: z.record(z.string(), providerConfigSchema),
|
|
@@ -511,6 +553,19 @@ const configSchema = z.object({
|
|
|
511
553
|
message: headersError,
|
|
512
554
|
});
|
|
513
555
|
}
|
|
556
|
+
const modelAdaptersError = modelAdapterRecordConfigError(
|
|
557
|
+
(provider as { modelAdapters?: unknown }).modelAdapters,
|
|
558
|
+
"modelAdapters",
|
|
559
|
+
name,
|
|
560
|
+
provider,
|
|
561
|
+
);
|
|
562
|
+
if (modelAdaptersError) {
|
|
563
|
+
ctx.addIssue({
|
|
564
|
+
code: "custom",
|
|
565
|
+
path: ["providers", name, "modelAdapters"],
|
|
566
|
+
message: modelAdaptersError,
|
|
567
|
+
});
|
|
568
|
+
}
|
|
514
569
|
const maxInputError = positiveIntegerRecordConfigError(
|
|
515
570
|
(provider as { modelMaxInputTokens?: unknown }).modelMaxInputTokens,
|
|
516
571
|
"modelMaxInputTokens",
|
|
@@ -95,7 +95,14 @@ export async function resolveFirstUsableOpenAiSidecar(
|
|
|
95
95
|
authContext,
|
|
96
96
|
headers: headersForCodexAuthContext(incomingHeaders, authContext),
|
|
97
97
|
...(authContext.kind === "pool" || authContext.kind === "main-pool"
|
|
98
|
-
? {
|
|
98
|
+
? {
|
|
99
|
+
recordOutcome: (outcome: CodexUpstreamOutcome) => recordCodexUpstreamOutcome(
|
|
100
|
+
config,
|
|
101
|
+
authContext.accountId,
|
|
102
|
+
outcome,
|
|
103
|
+
{ probeLeaseId: authContext.probeLeaseId },
|
|
104
|
+
),
|
|
105
|
+
}
|
|
99
106
|
: {}),
|
|
100
107
|
};
|
|
101
108
|
}
|
|
@@ -35,6 +35,24 @@ export function isCanonicalOpenAiForwardProvider(provider: OcxProviderConfig): b
|
|
|
35
35
|
&& normalizedBaseUrl(provider.baseUrl) === CODEX_FORWARD_BASE_URL;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
const OPENAI_API_BASE_URL = "https://api.openai.com/v1";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Whether this provider can serve `POST /responses/compact`. The canonical ChatGPT
|
|
42
|
+
* backend can, and so can the official OpenAI API — but an arbitrary gateway that
|
|
43
|
+
* merely speaks the Responses wire cannot, and calling it there fails compaction
|
|
44
|
+
* with an unhelpful error instead of falling back to a routed summary (#422).
|
|
45
|
+
*/
|
|
46
|
+
export function supportsNativeResponsesCompactEndpoint(
|
|
47
|
+
providerName: string,
|
|
48
|
+
provider: OcxProviderConfig,
|
|
49
|
+
): boolean {
|
|
50
|
+
if (isCanonicalOpenAiForwardProvider(provider)) return true;
|
|
51
|
+
return providerName === OPENAI_API_PROVIDER_ID
|
|
52
|
+
&& provider.adapter === "openai-responses"
|
|
53
|
+
&& normalizedBaseUrl(provider.baseUrl) === OPENAI_API_BASE_URL;
|
|
54
|
+
}
|
|
55
|
+
|
|
38
56
|
export interface OpenAiTierMigrationProjection {
|
|
39
57
|
config: OcxConfig;
|
|
40
58
|
changed: boolean;
|
|
@@ -7,18 +7,32 @@ import { createMimoFreeAdapter } from "../adapters/mimo-free";
|
|
|
7
7
|
import { createOpenAIChatAdapter } from "../adapters/openai-chat";
|
|
8
8
|
import { createResponsesPassthroughAdapter } from "../adapters/openai-responses";
|
|
9
9
|
import type { OcxProviderConfig } from "../types";
|
|
10
|
+
import { isWirePinnedModel, MODEL_ADAPTER_OVERRIDE_ALLOWED, pinnedWireAdapter } from "../types";
|
|
11
|
+
import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers";
|
|
10
12
|
|
|
11
|
-
/**
|
|
12
|
-
*
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
13
|
+
/**
|
|
14
|
+
* Resolve the wire a single model should use: a hard pin first, then a configured
|
|
15
|
+
* per-model override, then the provider's own adapter.
|
|
16
|
+
*
|
|
17
|
+
* Safe to call more than once on its own output — the pin check does not look at the
|
|
18
|
+
* current adapter, so a second pass cannot let an override displace a pin.
|
|
19
|
+
*/
|
|
18
20
|
export function resolveWireProtocolOverride(providerName: string, modelId: string, providerConfig: OcxProviderConfig): OcxProviderConfig {
|
|
19
|
-
const
|
|
20
|
-
if (
|
|
21
|
-
return { ...providerConfig, adapter:
|
|
21
|
+
const pinned = pinnedWireAdapter(providerName, modelId);
|
|
22
|
+
if (pinned && providerConfig.adapter !== pinned) {
|
|
23
|
+
return { ...providerConfig, adapter: pinned };
|
|
24
|
+
}
|
|
25
|
+
// Re-check the allow-list here, not just in the config validator: the file may have
|
|
26
|
+
// been hand-edited, or written by a build that allowed more values.
|
|
27
|
+
const requested = providerConfig.modelAdapters?.[modelId];
|
|
28
|
+
if (requested
|
|
29
|
+
&& MODEL_ADAPTER_OVERRIDE_ALLOWED.has(requested)
|
|
30
|
+
&& requested !== providerConfig.adapter
|
|
31
|
+
&& !isWirePinnedModel(providerName, modelId)
|
|
32
|
+
// A forward provider hands the caller's own credential upstream; the chat adapter
|
|
33
|
+
// only ever sends provider.apiKey, so switching wires here would drop the auth.
|
|
34
|
+
&& !isCanonicalOpenAiForwardProvider(providerConfig)) {
|
|
35
|
+
return { ...providerConfig, adapter: requested };
|
|
22
36
|
}
|
|
23
37
|
return providerConfig;
|
|
24
38
|
}
|
package/src/server/auth-cors.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { timingSafeEqual } from "node:crypto";
|
|
|
2
2
|
import { formatErrorResponse } from "../bridge";
|
|
3
3
|
import {
|
|
4
4
|
booleanRecordConfigError,
|
|
5
|
+
modelAdapterRecordConfigError,
|
|
5
6
|
codexAutoStartEnabled,
|
|
6
7
|
positiveIntegerConfigError,
|
|
7
8
|
positiveIntegerRecordConfigError,
|
|
@@ -234,6 +235,8 @@ export function providerManagementConfigError(name: unknown, provider: unknown):
|
|
|
234
235
|
if (maxInputError) return `provider ${name} ${maxInputError}`;
|
|
235
236
|
const reasoningSummariesError = booleanRecordConfigError(raw.modelSupportsReasoningSummaries, "modelSupportsReasoningSummaries");
|
|
236
237
|
if (reasoningSummariesError) return `provider ${name} ${reasoningSummariesError}`;
|
|
238
|
+
const modelAdaptersError = modelAdapterRecordConfigError(raw.modelAdapters, "modelAdapters", name, typed);
|
|
239
|
+
if (modelAdaptersError) return `provider ${name} ${modelAdaptersError}`;
|
|
237
240
|
const defaultMaxOutputError = positiveIntegerConfigError(raw.defaultMaxOutputTokens, "defaultMaxOutputTokens");
|
|
238
241
|
if (defaultMaxOutputError) return `provider ${name} ${defaultMaxOutputError}`;
|
|
239
242
|
const maxOutputError = positiveIntegerRecordConfigError(raw.modelMaxOutputTokens, "modelMaxOutputTokens");
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
} from "../chat/outbound";
|
|
17
17
|
import { estimateTokens } from "../lib/token-estimate";
|
|
18
18
|
import { routeModel } from "../router";
|
|
19
|
+
import { resolveWireProtocolOverride } from "./adapter-resolve";
|
|
19
20
|
import type { OcxConfig } from "../types";
|
|
20
21
|
import { readJsonRequestBody } from "./request-decompress";
|
|
21
22
|
import {
|
|
@@ -68,6 +69,9 @@ export async function handleChatCompletions(
|
|
|
68
69
|
let directRoute = false;
|
|
69
70
|
try {
|
|
70
71
|
const route = routeModel(config, internalBody.model as string);
|
|
72
|
+
// Settle the wire once so every branch below reads the adapter this model will
|
|
73
|
+
// actually use, not the provider-wide default (#404).
|
|
74
|
+
route.provider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider);
|
|
71
75
|
logCtx.model = route.modelId;
|
|
72
76
|
logCtx.providerAdapter = route.provider.adapter;
|
|
73
77
|
logCtx.requestedModel = requestedModel;
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
import { clearableDeadline, idleDeadline } from "../lib/abort";
|
|
24
24
|
import { estimateTokens } from "../lib/token-estimate";
|
|
25
25
|
import { routeModel } from "../router";
|
|
26
|
+
import { resolveWireProtocolOverride } from "./adapter-resolve";
|
|
26
27
|
import type { OcxConfig } from "../types";
|
|
27
28
|
import { readJsonRequestBody } from "./request-decompress";
|
|
28
29
|
import { addFinalRequestLog, httpStatusForTerminalStatus, recordFirstOutput, type RequestLogContext, type RequestLogEntry } from "./request-log";
|
|
@@ -570,6 +571,9 @@ export async function handleClaudeMessages(
|
|
|
570
571
|
let nativeRoute = false;
|
|
571
572
|
try {
|
|
572
573
|
const route = routeModel(config, internalBody.model as string);
|
|
574
|
+
// Settle the wire once so the sampling decision below reads the effective
|
|
575
|
+
// adapter rather than the provider-wide default (#404).
|
|
576
|
+
route.provider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider);
|
|
573
577
|
if (route.provider.adapter === "openai-responses") {
|
|
574
578
|
nativeRoute = true;
|
|
575
579
|
delete internalBody.max_output_tokens;
|
package/src/server/index.ts
CHANGED
|
@@ -122,7 +122,7 @@ import { handleChatCompletions } from "./chat-completions";
|
|
|
122
122
|
import { anthropicErrorResponse } from "../claude/outbound";
|
|
123
123
|
import { buildDesktop3pRegistry } from "../claude/desktop-3p";
|
|
124
124
|
import { handleImages } from "./images";
|
|
125
|
-
import { handleLive, parseLiveSidebandTarget, resolveLiveSidebandUpgrade } from "./live";
|
|
125
|
+
import { handleLive, logLiveSidebandFrame, parseLiveSidebandTarget, resolveLiveSidebandUpgrade } from "./live";
|
|
126
126
|
import { handleSearch } from "./search";
|
|
127
127
|
import { fetchAllModels, handleManagementAPI, VERSION } from "./management-api";
|
|
128
128
|
|
|
@@ -185,6 +185,7 @@ function attachLiveSidebandUpstream(ws: ServerWebSocket<WsData>): void {
|
|
|
185
185
|
});
|
|
186
186
|
upstream.addEventListener("message", (event) => {
|
|
187
187
|
try {
|
|
188
|
+
logLiveSidebandFrame("u2c", event.data);
|
|
188
189
|
if (typeof event.data === "string") ws.send(event.data);
|
|
189
190
|
else if (event.data instanceof ArrayBuffer) ws.send(event.data);
|
|
190
191
|
else if (ArrayBuffer.isView(event.data)) {
|
|
@@ -679,6 +680,7 @@ export function startServer(port?: number) {
|
|
|
679
680
|
},
|
|
680
681
|
message(ws: ServerWebSocket<WsData>, raw: string | Buffer) {
|
|
681
682
|
if (ws.data.kind === "live-sideband") {
|
|
683
|
+
logLiveSidebandFrame("c2u", raw);
|
|
682
684
|
const upstream = ws.data.liveUpstream;
|
|
683
685
|
if (!upstream || upstream.readyState === WebSocket.CONNECTING || !ws.data.liveOpened) {
|
|
684
686
|
const pending = ws.data.livePending ?? (ws.data.livePending = []);
|
package/src/server/live.ts
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
* - `GET /v1/realtime/calls/{callId}` — path-form join
|
|
21
21
|
* - `GET /v1/realtime?call_id=` — Realtime v1/v2 join
|
|
22
22
|
*/
|
|
23
|
+
import { appendFileSync } from "node:fs";
|
|
23
24
|
import { formatErrorResponse } from "../bridge";
|
|
24
25
|
import {
|
|
25
26
|
CodexAccountCooldownError,
|
|
@@ -69,6 +70,61 @@ export const LIVE_CLIENT_PROTOCOL_HEADERS = [
|
|
|
69
70
|
"x-oai-attestation",
|
|
70
71
|
] as const;
|
|
71
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Env-gated sideband frame forensics (diagnostic for multibyte transcript corruption).
|
|
75
|
+
*
|
|
76
|
+
* When `OCX_LIVE_FRAME_LOG` is set to a file path, every relayed sideband frame appends one
|
|
77
|
+
* JSONL record: direction, frame kind, byte length, and whether the payload contains U+FFFD.
|
|
78
|
+
* Privacy: full frame payloads are never written — only when U+FFFD is present, a short
|
|
79
|
+
* excerpt around the first replacement character is included so the corruption point can be
|
|
80
|
+
* attributed (upstream vs relay vs client). Disabled entirely when the env var is unset.
|
|
81
|
+
*/
|
|
82
|
+
export const LIVE_FRAME_LOG_ENV = "OCX_LIVE_FRAME_LOG";
|
|
83
|
+
const LIVE_FRAME_LOG_CONTEXT_CHARS = 24;
|
|
84
|
+
|
|
85
|
+
function fffdContext(text: string): string | undefined {
|
|
86
|
+
const idx = text.indexOf("\uFFFD");
|
|
87
|
+
if (idx < 0) return undefined;
|
|
88
|
+
const start = Math.max(0, idx - LIVE_FRAME_LOG_CONTEXT_CHARS);
|
|
89
|
+
const end = Math.min(text.length, idx + LIVE_FRAME_LOG_CONTEXT_CHARS);
|
|
90
|
+
return text.slice(start, end);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function logLiveSidebandFrame(dir: "c2u" | "u2c", data: unknown): void {
|
|
94
|
+
const logPath = process.env[LIVE_FRAME_LOG_ENV];
|
|
95
|
+
if (!logPath) return;
|
|
96
|
+
try {
|
|
97
|
+
let kind: "text" | "binary" = "binary";
|
|
98
|
+
let bytes = 0;
|
|
99
|
+
let context: string | undefined;
|
|
100
|
+
if (typeof data === "string") {
|
|
101
|
+
kind = "text";
|
|
102
|
+
bytes = Buffer.byteLength(data);
|
|
103
|
+
context = fffdContext(data);
|
|
104
|
+
} else if (data instanceof ArrayBuffer) {
|
|
105
|
+
bytes = data.byteLength;
|
|
106
|
+
context = fffdContext(new TextDecoder().decode(new Uint8Array(data)));
|
|
107
|
+
} else if (ArrayBuffer.isView(data)) {
|
|
108
|
+
const view = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
109
|
+
bytes = data.byteLength;
|
|
110
|
+
context = fffdContext(new TextDecoder().decode(view));
|
|
111
|
+
} else {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const record = {
|
|
115
|
+
ts: new Date().toISOString(),
|
|
116
|
+
dir,
|
|
117
|
+
kind,
|
|
118
|
+
bytes,
|
|
119
|
+
fffd: context !== undefined,
|
|
120
|
+
...(context !== undefined ? { context } : {}),
|
|
121
|
+
};
|
|
122
|
+
appendFileSync(logPath, `${JSON.stringify(record)}\n`);
|
|
123
|
+
} catch {
|
|
124
|
+
// Frame forensics must never break the relay.
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
72
128
|
function clientProtocolHeaders(reqHeaders: Headers): Record<string, string> {
|
|
73
129
|
const out: Record<string, string> = {};
|
|
74
130
|
for (const name of LIVE_CLIENT_PROTOCOL_HEADERS) {
|
|
@@ -39,7 +39,7 @@ const DEFAULT_INTERVAL_MS = 60_000;
|
|
|
39
39
|
const DEFAULT_WARN_THRESHOLD_BYTES = 4 * 1024 ** 3; // 4 GiB
|
|
40
40
|
const DEFAULT_RING_SIZE = 360; // ≈6h at 60s
|
|
41
41
|
const WARN_INTERVAL_MS = 30 * 60_000;
|
|
42
|
-
const DOCS_URL = "https://
|
|
42
|
+
const DOCS_URL = "https://opencodex.me/troubleshooting/windows-memory/";
|
|
43
43
|
|
|
44
44
|
let active: MemoryWatchdog | null = null;
|
|
45
45
|
|