@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.
Files changed (49) hide show
  1. package/README.md +4 -4
  2. package/gui/dist/assets/index-CMip1DzF.css +1 -0
  3. package/gui/dist/assets/index-cydcmbzC.js +52 -0
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +2 -2
  6. package/src/adapters/cursor/arg-normalize.ts +23 -7
  7. package/src/adapters/cursor/live-transport.ts +26 -14
  8. package/src/adapters/cursor/native-exec-fs.ts +1 -1
  9. package/src/adapters/cursor/native-exec-network.ts +1 -1
  10. package/src/adapters/cursor/native-exec-shell.ts +1 -1
  11. package/src/adapters/cursor/protobuf-events.ts +72 -13
  12. package/src/adapters/cursor/protobuf-request.ts +82 -11
  13. package/src/adapters/cursor/request-builder.ts +35 -11
  14. package/src/adapters/cursor/tool-definitions.ts +175 -30
  15. package/src/adapters/openai-chat.ts +28 -7
  16. package/src/adapters/openai-responses.ts +150 -4
  17. package/src/bridge.ts +20 -1
  18. package/src/claude/outbound.ts +91 -6
  19. package/src/codex/auth-api.ts +12 -25
  20. package/src/codex/auth-context.ts +48 -3
  21. package/src/codex/catalog/provider-fetch.ts +56 -24
  22. package/src/codex/model-cache.ts +23 -0
  23. package/src/codex/quota.ts +120 -0
  24. package/src/codex/routing.ts +178 -9
  25. package/src/config.ts +56 -1
  26. package/src/providers/openai-sidecar.ts +8 -1
  27. package/src/providers/openai-tiers.ts +18 -0
  28. package/src/server/adapter-resolve.ts +24 -10
  29. package/src/server/auth-cors.ts +3 -0
  30. package/src/server/chat-completions.ts +4 -0
  31. package/src/server/claude-messages.ts +4 -0
  32. package/src/server/index.ts +3 -1
  33. package/src/server/live.ts +56 -0
  34. package/src/server/memory-watchdog.ts +1 -1
  35. package/src/server/responses/compact.ts +40 -10
  36. package/src/server/responses/core.ts +180 -26
  37. package/src/server/responses/terminal-guard.ts +230 -0
  38. package/src/service.ts +113 -30
  39. package/src/types.ts +52 -0
  40. package/src/usage/expected-prices.ts +12 -0
  41. package/src/web-search/anthropic-executor.ts +3 -1
  42. package/src/web-search/index.ts +7 -1
  43. package/src/web-search/loop.ts +17 -3
  44. package/README.ja.md +0 -445
  45. package/README.ko.md +0 -435
  46. package/README.ru.md +0 -486
  47. package/README.zh-CN.md +0 -411
  48. package/gui/dist/assets/index-B-cheu55.js +0 -52
  49. package/gui/dist/assets/index-oOZcqVmj.css +0 -1
@@ -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
- const clamped = clampCooldownMs(delay);
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 computeQuotaCooldownUntil(meta: CodexUpstreamOutcomeMeta = {}): number {
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
- const resetCooldownMs = retryAfterMs === undefined ? parseResetCooldownMs(meta.resetAt, now) : undefined;
172
- return now + (retryAfterMs ?? resetCooldownMs ?? CODEX_DEFAULT_QUOTA_COOLDOWN_MS);
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
- ...current,
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, cooldownUntil });
614
+ if (cooldownUntil) upstreamHealth.set(accountId, { consecutiveFailures: 0, ...preserved });
477
615
  else upstreamHealth.delete(accountId);
478
616
  return;
479
617
  }
480
- if (outcomeClass === "caller") return;
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: computeQuotaCooldownUntil(meta),
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 { OPENAI_PROVIDER_TIER_VERSION, type OcxConfig } from "./types";
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
- ? { recordOutcome: (outcome: CodexUpstreamOutcome) => recordCodexUpstreamOutcome(config, authContext.accountId, outcome) }
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
- /** Providers whose listed model ids must be driven over the Anthropic wire even if the provider's
12
- * configured adapter is something else (the upstream only speaks Anthropic for these models). */
13
- const ANTHROPIC_WIRE_MODELS: Record<string, Set<string>> = {
14
- "opencode-go": new Set(["minimax-m2.5", "minimax-m2.7", "minimax-m3"]),
15
- };
16
-
17
- /** Return a provider config whose adapter is forced to "anthropic" when the model id is wire-pinned. */
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 overrideSet = ANTHROPIC_WIRE_MODELS[providerName];
20
- if (overrideSet?.has(modelId) && providerConfig.adapter !== "anthropic") {
21
- return { ...providerConfig, adapter: "anthropic" };
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
  }
@@ -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;
@@ -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 = []);
@@ -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://lidge-jun.github.io/opencodex/troubleshooting/windows-memory/";
42
+ const DOCS_URL = "https://opencodex.me/troubleshooting/windows-memory/";
43
43
 
44
44
  let active: MemoryWatchdog | null = null;
45
45