@bitkyc08/opencodex 2.7.39 → 2.7.40-preview.20260725

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-BxQ8N_K5.js +52 -0
  3. package/gui/dist/assets/index-CMip1DzF.css +1 -0
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +1 -1
  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
@@ -14,6 +14,7 @@ import {
14
14
  markModelsFetchFailure,
15
15
  markProviderDiscoveryFailed,
16
16
  markProviderDiscoveryOk,
17
+ shouldLogDiscoveryFailure,
17
18
  setCached,
18
19
  type ProviderModelDiscoveryFailure,
19
20
  } from "../model-cache";
@@ -302,7 +303,11 @@ export async function fetchProviderModels(name: string, prov: OcxProviderConfig,
302
303
  : "provider-models";
303
304
  const failedDiscoveryFallback = (
304
305
  failure: ProviderModelDiscoveryFailure,
305
- ): { models: CatalogModel[]; fallback: "stale" | "configured" } => {
306
+ ): { models: CatalogModel[]; fallback: "stale" | "configured"; shouldLog: boolean } => {
307
+ // Decide logging BEFORE recording the new status, so we can compare against the prior one and
308
+ // suppress an identical repeated failure (#395 log flood). The failure stays observable via the
309
+ // discovery-status API regardless.
310
+ const shouldLog = shouldLogDiscoveryFailure(name, failure);
306
311
  markModelsFetchFailure(name);
307
312
  markProviderDiscoveryFailed(name, failure);
308
313
  const stale = getStaleCached(name);
@@ -311,6 +316,7 @@ export async function fetchProviderModels(name: string, prov: OcxProviderConfig,
311
316
  ? withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, stale, contextCap))
312
317
  : failedDiscoveryConfigured,
313
318
  fallback: stale ? "stale" : "configured",
319
+ shouldLog,
314
320
  };
315
321
  };
316
322
  try {
@@ -319,19 +325,23 @@ export async function fetchProviderModels(name: string, prov: OcxProviderConfig,
319
325
  allowPrivateNetwork: prov.allowPrivateNetwork,
320
326
  });
321
327
  if (destinationError) {
322
- const { models, fallback } = failedDiscoveryFallback({ reason: "blocked" });
323
- console.warn(
324
- `[opencodex] Provider model discovery for "${name}" was blocked by destination policy: ${destinationError} [urlClass=${urlClass}, fallback=${fallback}].`,
325
- );
328
+ const { models, fallback, shouldLog } = failedDiscoveryFallback({ reason: "blocked" });
329
+ if (shouldLog) {
330
+ console.warn(
331
+ `[opencodex] Provider model discovery for "${name}" was blocked by destination policy: ${destinationError} [urlClass=${urlClass}, fallback=${fallback}].`,
332
+ );
333
+ }
326
334
  return models;
327
335
  }
328
336
 
329
337
  const res = await fetch(url, { headers, signal: AbortSignal.timeout(8000) });
330
338
  if (!res.ok) {
331
- const { models, fallback } = failedDiscoveryFallback({ reason: "http", httpStatus: res.status });
332
- console.warn(
333
- `[opencodex] Provider model discovery for "${name}" failed with HTTP ${res.status} [urlClass=${urlClass}, fallback=${fallback}].`,
334
- );
339
+ const { models, fallback, shouldLog } = failedDiscoveryFallback({ reason: "http", httpStatus: res.status });
340
+ if (shouldLog) {
341
+ console.warn(
342
+ `[opencodex] Provider model discovery for "${name}" failed with HTTP ${res.status} [urlClass=${urlClass}, fallback=${fallback}].`,
343
+ );
344
+ }
335
345
  return models;
336
346
  }
337
347
 
@@ -343,23 +353,27 @@ export async function fetchProviderModels(name: string, prov: OcxProviderConfig,
343
353
  try {
344
354
  json = JSON.parse(body) as unknown;
345
355
  } catch {
346
- const { models, fallback } = failedDiscoveryFallback({ reason: "invalid_response" });
356
+ const { models, fallback, shouldLog } = failedDiscoveryFallback({ reason: "invalid_response" });
347
357
  const diagnostic = contentType === "application/json" || contentType.endsWith("+json")
348
358
  ? "returned invalid JSON in a 2xx response"
349
359
  : "returned a non-JSON 2xx response";
350
- console.warn(
351
- `[opencodex] Provider model discovery for "${name}" ${diagnostic} [status=${res.status}, contentType=${contentType}, urlClass=${urlClass}, fallback=${fallback}].`,
352
- );
360
+ if (shouldLog) {
361
+ console.warn(
362
+ `[opencodex] Provider model discovery for "${name}" ${diagnostic} [status=${res.status}, contentType=${contentType}, urlClass=${urlClass}, fallback=${fallback}].`,
363
+ );
364
+ }
353
365
  return models;
354
366
  }
355
367
  const data = json !== null && typeof json === "object" && !Array.isArray(json)
356
368
  ? (json as { data?: unknown }).data
357
369
  : undefined;
358
370
  if (!isProviderModelsApiItems(data)) {
359
- const { models, fallback } = failedDiscoveryFallback({ reason: "invalid_response" });
360
- console.warn(
361
- `[opencodex] Provider model discovery for "${name}" returned malformed 2xx data [status=${res.status}, contentType=${contentType}, urlClass=${urlClass}, fallback=${fallback}].`,
362
- );
371
+ const { models, fallback, shouldLog } = failedDiscoveryFallback({ reason: "invalid_response" });
372
+ if (shouldLog) {
373
+ console.warn(
374
+ `[opencodex] Provider model discovery for "${name}" returned malformed 2xx data [status=${res.status}, contentType=${contentType}, urlClass=${urlClass}, fallback=${fallback}].`,
375
+ );
376
+ }
363
377
  return models;
364
378
  }
365
379
  const items = data;
@@ -402,10 +416,12 @@ export async function fetchProviderModels(name: string, prov: OcxProviderConfig,
402
416
  setCached(name, live);
403
417
  return live;
404
418
  } catch (error) {
405
- const { models, fallback } = failedDiscoveryFallback({ reason: "network" });
406
- console.warn(
407
- `[opencodex] Provider model discovery for "${name}" threw ${error instanceof Error ? error.name : "unknown"} [urlClass=${urlClass}, fallback=${fallback}].`,
408
- );
419
+ const { models, fallback, shouldLog } = failedDiscoveryFallback({ reason: "network" });
420
+ if (shouldLog) {
421
+ console.warn(
422
+ `[opencodex] Provider model discovery for "${name}" threw ${error instanceof Error ? error.name : "unknown"} [urlClass=${urlClass}, fallback=${fallback}].`,
423
+ );
424
+ }
409
425
  return models;
410
426
  }
411
427
  }
@@ -525,10 +541,13 @@ export async function gatherRoutedModels(config: OcxConfig): Promise<CatalogMode
525
541
  else warnUncataloguedComboOnce(id, combo, members);
526
542
  }
527
543
  all.sort((a, b) => (a.provider === b.provider ? a.id.localeCompare(b.id) : a.provider.localeCompare(b.provider)));
544
+ // Enriched (registry-hydrated) provider clones, keyed by name — the same view used above so
545
+ // custom rows get the same noVisionModels / inputModalities treatment as discovered rows.
546
+ const enrichedByName = new Map(activeProviders);
528
547
  const customModels = (config.customModels ?? []).map(cm => {
529
- const provider = config.providers[cm.provider] as OcxProviderConfigWithReasoningSummaries | undefined;
530
- const supportsReasoningSummaries = modelRecordValue(provider?.modelSupportsReasoningSummaries, cm.modelId);
531
- return {
548
+ const rawProvider = config.providers[cm.provider] as OcxProviderConfigWithReasoningSummaries | undefined;
549
+ const supportsReasoningSummaries = modelRecordValue(rawProvider?.modelSupportsReasoningSummaries, cm.modelId);
550
+ const base: CatalogModel = {
532
551
  id: cm.modelId,
533
552
  provider: cm.provider,
534
553
  // Display-only label: never feeds routing (customModels are keyed by routedSlug below).
@@ -537,6 +556,19 @@ export async function gatherRoutedModels(config: OcxConfig): Promise<CatalogMode
537
556
  ...(cm.inputModalities ? { inputModalities: cm.inputModalities } : {}),
538
557
  ...(typeof supportsReasoningSummaries === "boolean" ? { supportsReasoningSummaries } : {}),
539
558
  };
559
+ // Vision-sidecar coverage ONLY: if the custom model is in the enriched provider's
560
+ // noVisionModels, advertise image input so the Codex app lets images reach the sidecar
561
+ // (#349/#344). Deliberately NOT the full applyProviderConfigHints pass — custom rows are a
562
+ // user override, so their explicit contextWindow / inputModalities / reasoning fields must be
563
+ // preserved verbatim (the hint pass would cap context and overwrite modalities from registry).
564
+ const enrichedProvider = enrichedByName.get(cm.provider) ?? rawProvider;
565
+ if (enrichedProvider && modelInList(enrichedProvider.noVisionModels, base.id)) {
566
+ const current = base.inputModalities ?? ["text"];
567
+ if (!current.includes("image")) {
568
+ return { ...base, inputModalities: [...current, "image"] };
569
+ }
570
+ }
571
+ return base;
540
572
  });
541
573
  // Custom rows override discovered rows that encode to the same Codex-facing slug.
542
574
  const customKeys = new Set(customModels.map(c => routedSlug(c.provider, c.id)));
@@ -63,6 +63,29 @@ export function markProviderDiscoveryFailed(
63
63
  discoveryStatus.set(provider, { status: "failed", ...failure });
64
64
  }
65
65
 
66
+ /**
67
+ * Decide whether a discovery FAILURE should be logged, to avoid flooding the log with an identical
68
+ * warning on every poll (#395: an anthropic-adapter baseUrl without `/v1/models`, e.g. Azure AI
69
+ * Foundry, returns HTTP 404 forever; the 30s cooldown re-probes and previously re-logged each time).
70
+ *
71
+ * Returns true only when the failure SIGNATURE changed since the last observed status — i.e. the
72
+ * previous state was ok/undefined, or a different reason/httpStatus. Repeated identical failures
73
+ * stay observable through `getProviderDiscoveryStatus()` / the providers API without log spam.
74
+ * Call this BEFORE `markProviderDiscoveryFailed` so it can see the prior state.
75
+ */
76
+ export function shouldLogDiscoveryFailure(
77
+ provider: string,
78
+ failure: ProviderModelDiscoveryFailure,
79
+ ): boolean {
80
+ const prev = discoveryStatus.get(provider);
81
+ if (!prev || prev.status !== "failed") return true;
82
+ if (prev.reason !== failure.reason) return true;
83
+ if (prev.reason === "http" && failure.reason === "http") {
84
+ return prev.httpStatus !== failure.httpStatus;
85
+ }
86
+ return false;
87
+ }
88
+
66
89
  export function clearProviderDiscoveryStatus(provider: string): void {
67
90
  discoveryStatus.delete(provider);
68
91
  }
@@ -28,6 +28,7 @@ type WhamUsageWindow = {
28
28
  };
29
29
 
30
30
  const MONTHLY_WINDOW_MIN_SECONDS = 28 * 24 * 60 * 60;
31
+ const MONTHLY_WINDOW_MIN_MINUTES = MONTHLY_WINDOW_MIN_SECONDS / 60;
31
32
 
32
33
  const accountQuota = new Map<string, StoredAccountQuota>();
33
34
 
@@ -65,6 +66,125 @@ function isExplicitMonthlyWindow(window: WhamUsageWindow | null | undefined): bo
65
66
  && seconds >= MONTHLY_WINDOW_MIN_SECONDS;
66
67
  }
67
68
 
69
+ function isExplicitMonthlyWindowMinutes(windowMinutes: unknown): boolean {
70
+ const minutes = typeof windowMinutes === "number"
71
+ ? windowMinutes
72
+ : typeof windowMinutes === "string" && windowMinutes.trim() !== ""
73
+ ? Number(windowMinutes)
74
+ : undefined;
75
+ return typeof minutes === "number"
76
+ && Number.isFinite(minutes)
77
+ && minutes >= MONTHLY_WINDOW_MIN_MINUTES;
78
+ }
79
+
80
+
81
+ function snapshotHasWeekly(quota: Omit<StoredAccountQuota, "updatedAt">): boolean {
82
+ return quota.weeklyPercent !== undefined || quota.weeklyResetAt !== undefined;
83
+ }
84
+
85
+ function snapshotHasMonthly(quota: Omit<StoredAccountQuota, "updatedAt">): boolean {
86
+ return quota.monthlyPercent !== undefined || quota.monthlyResetAt !== undefined;
87
+ }
88
+
89
+ function snapshotHasUsage(quota: Omit<StoredAccountQuota, "updatedAt">): boolean {
90
+ return snapshotHasWeekly(quota) || snapshotHasMonthly(quota);
91
+ }
92
+ export function setAccountQuotaFromParsed(
93
+ accountId: string,
94
+ quota: Omit<StoredAccountQuota, "updatedAt"> | null,
95
+ ): void {
96
+ if (!quota) return;
97
+ const existing = accountQuota.get(accountId);
98
+ const next: StoredAccountQuota = { updatedAt: Date.now() };
99
+ const creditsOnly = quota.resetCredits !== undefined && !snapshotHasUsage(quota);
100
+
101
+ if (creditsOnly) {
102
+ if (existing?.weeklyPercent !== undefined) next.weeklyPercent = existing.weeklyPercent;
103
+ if (existing?.weeklyResetAt !== undefined) next.weeklyResetAt = existing.weeklyResetAt;
104
+ if (existing?.monthlyPercent !== undefined) next.monthlyPercent = existing.monthlyPercent;
105
+ if (existing?.monthlyResetAt !== undefined) next.monthlyResetAt = existing.monthlyResetAt;
106
+ next.resetCredits = quota.resetCredits;
107
+ accountQuota.set(accountId, next);
108
+ return;
109
+ }
110
+
111
+ if (snapshotHasWeekly(quota)) {
112
+ if (quota.weeklyPercent !== undefined) next.weeklyPercent = quota.weeklyPercent;
113
+ if (quota.weeklyResetAt !== undefined) next.weeklyResetAt = quota.weeklyResetAt;
114
+ } else if (snapshotHasMonthly(quota) && !snapshotHasWeekly(quota)) {
115
+ // Monthly-only snapshots intentionally clear stale weekly values (issue #382).
116
+ } else if (existing?.weeklyPercent !== undefined) {
117
+ next.weeklyPercent = existing.weeklyPercent;
118
+ if (existing.weeklyResetAt !== undefined) next.weeklyResetAt = existing.weeklyResetAt;
119
+ }
120
+
121
+ if (snapshotHasMonthly(quota)) {
122
+ if (quota.monthlyPercent !== undefined) next.monthlyPercent = quota.monthlyPercent;
123
+ if (quota.monthlyResetAt !== undefined) next.monthlyResetAt = quota.monthlyResetAt;
124
+ } else if (snapshotHasWeekly(quota) && existing?.monthlyPercent !== undefined) {
125
+ next.monthlyPercent = existing.monthlyPercent;
126
+ if (existing.monthlyResetAt !== undefined) next.monthlyResetAt = existing.monthlyResetAt;
127
+ }
128
+
129
+ if (quota.resetCredits !== undefined) next.resetCredits = quota.resetCredits;
130
+ else if (existing?.resetCredits !== undefined) next.resetCredits = existing.resetCredits;
131
+
132
+ accountQuota.set(accountId, next);
133
+ }
134
+
135
+ export function parseUpstreamQuotaHeaders(headers: Headers): Omit<StoredAccountQuota, "updatedAt"> | null {
136
+ const primaryRaw = headers.get("x-codex-primary-used-percent");
137
+ const secondaryRaw = headers.get("x-codex-secondary-used-percent");
138
+ const tertiaryRaw = headers.get("x-codex-tertiary-used-percent");
139
+ const primaryResetRaw = headers.get("x-codex-primary-reset-at");
140
+ const secondaryResetRaw = headers.get("x-codex-secondary-reset-at");
141
+ const tertiaryResetRaw = headers.get("x-codex-tertiary-reset-at");
142
+ const primaryWindowMinutes = headers.get("x-codex-primary-window-minutes");
143
+ const secondaryWindowMinutes = headers.get("x-codex-secondary-window-minutes");
144
+
145
+ const quota: Omit<StoredAccountQuota, "updatedAt"> = {};
146
+ const primaryPercent = normalizeUsagePercent(primaryRaw);
147
+ const secondaryPercent = normalizeUsagePercent(secondaryRaw);
148
+ const tertiaryPercent = normalizeUsagePercent(tertiaryRaw);
149
+ const primaryResetAt = normalizeResetAt(primaryResetRaw);
150
+ const secondaryResetAt = normalizeResetAt(secondaryResetRaw);
151
+ const tertiaryResetAt = normalizeResetAt(tertiaryResetRaw);
152
+ const primaryIsMonthly = primaryRaw !== null && isExplicitMonthlyWindowMinutes(primaryWindowMinutes);
153
+
154
+ if (primaryIsMonthly) {
155
+ if (primaryPercent !== undefined) {
156
+ quota.monthlyPercent = primaryPercent;
157
+ if (primaryResetAt !== undefined) quota.monthlyResetAt = primaryResetAt;
158
+ }
159
+ if (secondaryPercent !== undefined) {
160
+ quota.weeklyPercent = secondaryPercent;
161
+ if (secondaryResetAt !== undefined) quota.weeklyResetAt = secondaryResetAt;
162
+ }
163
+ } else {
164
+ const weeklyPercent = primaryPercent ?? secondaryPercent;
165
+ const weeklyResetAt = primaryPercent !== undefined
166
+ ? primaryResetAt
167
+ : secondaryResetAt;
168
+ if (weeklyPercent !== undefined) {
169
+ quota.weeklyPercent = weeklyPercent;
170
+ if (weeklyResetAt !== undefined) quota.weeklyResetAt = weeklyResetAt;
171
+ }
172
+ }
173
+
174
+ if (tertiaryPercent !== undefined && quota.monthlyPercent === undefined) {
175
+ quota.monthlyPercent = tertiaryPercent;
176
+ if (tertiaryResetAt !== undefined) quota.monthlyResetAt = tertiaryResetAt;
177
+ }
178
+
179
+ return hasKnownQuotaValue(quota) ? quota : null;
180
+ }
181
+
182
+ export function applyAccountQuotaFromUpstreamHeaders(accountId: string, headers: Headers): void {
183
+ const quota = parseUpstreamQuotaHeaders(headers);
184
+ if (!quota) return;
185
+ setAccountQuotaFromParsed(accountId, quota);
186
+ }
187
+
68
188
  export function updateAccountQuota(
69
189
  accountId: string,
70
190
  weekly: unknown,
@@ -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",