@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.
- package/README.md +4 -4
- package/gui/dist/assets/index-BxQ8N_K5.js +52 -0
- package/gui/dist/assets/index-CMip1DzF.css +1 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- 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
|
@@ -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
|
-
|
|
324
|
-
|
|
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
|
-
|
|
333
|
-
|
|
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
|
-
|
|
351
|
-
|
|
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
|
-
|
|
361
|
-
|
|
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
|
-
|
|
407
|
-
|
|
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
|
|
530
|
-
const supportsReasoningSummaries = modelRecordValue(
|
|
531
|
-
|
|
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)));
|
package/src/codex/model-cache.ts
CHANGED
|
@@ -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
|
}
|
package/src/codex/quota.ts
CHANGED
|
@@ -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,
|
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",
|