@bitkyc08/opencodex 2.7.40 → 2.7.41
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 +63 -12
- package/assets/claude-code-models.gif +0 -0
- package/gui/dist/assets/index-B2J4t3te.css +1 -0
- package/gui/dist/assets/index-BmvM6wRb.js +65 -0
- package/gui/dist/index.html +2 -2
- package/package.json +3 -2
- package/src/adapters/google.ts +48 -9
- package/src/adapters/kiro-events.ts +15 -3
- package/src/adapters/kiro.ts +292 -28
- package/src/adapters/openai-chat.ts +63 -15
- package/src/adapters/openai-responses.ts +18 -0
- package/src/bridge.ts +76 -21
- package/src/chat/outbound.ts +66 -34
- package/src/claude/auth-detect.ts +229 -0
- package/src/claude/auth-mode-migration.ts +32 -0
- package/src/claude/auth-mode.ts +62 -0
- package/src/claude/desktop-3p-guard.ts +35 -0
- package/src/claude/desktop-3p.ts +121 -21
- package/src/claude/desktop-health.ts +26 -0
- package/src/claude/desktop-profile.ts +263 -0
- package/src/claude/inbound-debug.ts +4 -0
- package/src/claude/model-info.ts +9 -3
- package/src/cli/account-extended.ts +34 -0
- package/src/cli/account.ts +3 -1
- package/src/cli/claude-desktop.ts +152 -0
- package/src/cli/claude.ts +37 -3
- package/src/cli/doctor.ts +187 -6
- package/src/cli/help.ts +13 -1
- package/src/cli/index.ts +151 -57
- package/src/cli/status-oauth.ts +68 -0
- package/src/cli/status.ts +4 -0
- package/src/codex/account-lifecycle.ts +31 -0
- package/src/codex/auth-api.ts +133 -26
- package/src/codex/auth-collision.ts +55 -11
- package/src/codex/auth-context.ts +50 -6
- package/src/codex/catalog/provider-fetch.ts +6 -2
- package/src/codex/home.ts +61 -1
- package/src/codex/inject.ts +28 -66
- package/src/codex/injected-marker.ts +72 -0
- package/src/codex/journal.ts +39 -3
- package/src/codex/main-account-cache.ts +25 -0
- package/src/codex/model-cache.ts +20 -1
- package/src/codex/paths.ts +5 -0
- package/src/codex/routing.ts +138 -11
- package/src/codex/subagent-model-fallback.ts +455 -0
- package/src/codex/sync.ts +17 -0
- package/src/combos/failover.ts +10 -2
- package/src/combos/index.ts +1 -0
- package/src/combos/types.ts +9 -0
- package/src/config.ts +117 -0
- package/src/grok/inject.ts +339 -0
- package/src/grok/status.ts +88 -0
- package/src/grok/sync.ts +66 -0
- package/src/lib/destination-policy.ts +13 -0
- package/src/lib/errors.ts +59 -0
- package/src/lib/privacy.ts +9 -0
- package/src/lib/process-control.ts +57 -4
- package/src/oauth/health.ts +375 -0
- package/src/oauth/index.ts +69 -22
- package/src/oauth/kiro.ts +5 -3
- package/src/oauth/log.ts +48 -0
- package/src/oauth/store.ts +55 -6
- package/src/providers/alibaba-region-backup.ts +75 -0
- package/src/providers/alibaba-region-migration.ts +143 -0
- package/src/providers/alibaba-region-startup.ts +36 -0
- package/src/providers/api-keys.ts +5 -5
- package/src/providers/derive.ts +22 -1
- package/src/providers/free-directory.ts +181 -0
- package/src/providers/key-failover.ts +2 -2
- package/src/providers/kiro-models.ts +3 -2
- package/src/providers/openai-tiers.ts +1 -1
- package/src/providers/provider-id-rewrite.ts +150 -0
- package/src/providers/registry.ts +49 -2
- package/src/responses/parser.ts +49 -20
- package/src/responses/state.ts +156 -2
- package/src/router.ts +73 -5
- package/src/server/chat-completions.ts +78 -22
- package/src/server/claude-messages.ts +8 -0
- package/src/server/images.ts +2 -1
- package/src/server/index.ts +50 -30
- package/src/server/live.ts +2 -1
- package/src/server/management/agent-settings-routes.ts +337 -18
- package/src/server/management/api-access.ts +141 -0
- package/src/server/management/combo-routes.ts +3 -3
- package/src/server/management/config-routes.ts +4 -4
- package/src/server/management/logs-usage-routes.ts +64 -4
- package/src/server/management/model-routes.ts +114 -8
- package/src/server/management/oauth-account-routes.ts +31 -5
- package/src/server/management/provider-routes.ts +42 -11
- package/src/server/management/shared.ts +81 -2
- package/src/server/management/system-routes.ts +6 -1
- package/src/server/management-api.ts +24 -7
- package/src/server/port-reclaim.ts +261 -0
- package/src/server/request-log.ts +11 -5
- package/src/server/responses/collaboration.ts +11 -6
- package/src/server/responses/compact.ts +2 -1
- package/src/server/responses/core.ts +358 -155
- package/src/server/responses/passthrough-error.ts +53 -0
- package/src/server/search.ts +2 -1
- package/src/server/system-env.ts +23 -6
- package/src/server/windows-tcp-drop.ts +174 -0
- package/src/service.ts +43 -3
- package/src/types.ts +74 -6
- package/src/update/index.ts +30 -19
- package/src/update/job.ts +30 -15
- package/src/usage/log.ts +158 -2
- package/src/usage/summary.ts +8 -4
- package/src/web-search/loop.ts +4 -1
- package/gui/dist/assets/index-CMip1DzF.css +0 -1
- package/gui/dist/assets/index-cydcmbzC.js +0 -52
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quota-aware subagent model fallback (issue #374).
|
|
3
|
+
*
|
|
4
|
+
* codex-rs spawns children with the agent-role TOML `model` pinned; when that model's
|
|
5
|
+
* provider quota is exhausted the child fails immediately. This module rewrites thread_spawn
|
|
6
|
+
* requests at the proxy choke point to the next healthy model in a configured fallback chain.
|
|
7
|
+
*/
|
|
8
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { hasOwnProvider } from "../config";
|
|
11
|
+
import { isRateLimitOrQuotaFailureMessage } from "../lib/errors";
|
|
12
|
+
import type { OcxParsedRequest, OcxConfig } from "../types";
|
|
13
|
+
import { slugsEquivalent } from "../providers/slug-codec";
|
|
14
|
+
import { CODEX_HOME, getCodexHome } from "./paths";
|
|
15
|
+
import { CODEX_UNKNOWN_USAGE_SCORE, getAccountQuota } from "./quota";
|
|
16
|
+
import {
|
|
17
|
+
canAcquireCodexQuotaProbeLease,
|
|
18
|
+
computeCodexUsageScore,
|
|
19
|
+
getPoolAccountPlan,
|
|
20
|
+
isCodexAccountInCooldown,
|
|
21
|
+
} from "./routing";
|
|
22
|
+
import { isCodexAccountUsable } from "./account-usability";
|
|
23
|
+
import { slugEquals } from "../providers/slug-codec";
|
|
24
|
+
import { isThreadSpawnRequest } from "../server/effort-policy";
|
|
25
|
+
import { PROVIDER_REGISTRY } from "../providers/registry";
|
|
26
|
+
import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers";
|
|
27
|
+
import { routeModel, type RouteResult } from "../router";
|
|
28
|
+
export const DEFAULT_SUBAGENT_MODEL_FALLBACK_POLL_MS = 60_000;
|
|
29
|
+
|
|
30
|
+
type SubagentQuotaPrimeFn = (config: OcxConfig, reason: string) => Promise<void>;
|
|
31
|
+
let subagentQuotaPrimeForTests: SubagentQuotaPrimeFn | null = null;
|
|
32
|
+
let quotaPrimeInFlight: Promise<void> | null = null;
|
|
33
|
+
|
|
34
|
+
type ModelHealth = {
|
|
35
|
+
unavailableUntil: number;
|
|
36
|
+
reason: string;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const modelHealth = new Map<string, ModelHealth>();
|
|
40
|
+
const quotaPrimedAt = new Map<string, number>();
|
|
41
|
+
const knownProviderIdSet = new Set(PROVIDER_REGISTRY.map(entry => entry.id.toLowerCase()));
|
|
42
|
+
|
|
43
|
+
function tryRouteFallbackModel(config: OcxConfig, model: string): RouteResult | null {
|
|
44
|
+
try {
|
|
45
|
+
return routeModel(config, model);
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function isPoolCodexRoute(route: RouteResult): boolean {
|
|
52
|
+
return route.codexAccountMode === "pool";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function healthKey(model: string, accountId: string | null, poolScoped: boolean): string {
|
|
56
|
+
const scopedAccountId = poolScoped ? accountId : null;
|
|
57
|
+
return `${scopedAccountId ?? "none"}::${model.toLowerCase()}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function isDisabledFallbackModel(model: string, config: OcxConfig): boolean {
|
|
61
|
+
const disabled = config.disabledModels ?? [];
|
|
62
|
+
if (disabled.length === 0) return false;
|
|
63
|
+
if (!model.includes("/")) {
|
|
64
|
+
return disabled.some(stored => stored === model || slugEquals(stored, "openai", model));
|
|
65
|
+
}
|
|
66
|
+
const slash = model.indexOf("/");
|
|
67
|
+
const provider = model.slice(0, slash);
|
|
68
|
+
const modelId = model.slice(slash + 1);
|
|
69
|
+
return disabled.some(stored => stored === model || slugEquals(stored, provider, modelId));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function pollIntervalMs(config: OcxConfig): number {
|
|
73
|
+
const configured = config.subagentModelFallbackPollMs;
|
|
74
|
+
if (typeof configured !== "number" || !Number.isFinite(configured) || configured < 1_000) {
|
|
75
|
+
return DEFAULT_SUBAGENT_MODEL_FALLBACK_POLL_MS;
|
|
76
|
+
}
|
|
77
|
+
return configured;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function normalizedChain(primary: string, config: OcxConfig, extra: readonly string[] = []): string[] {
|
|
81
|
+
const chain: string[] = [];
|
|
82
|
+
const seen = new Set<string>();
|
|
83
|
+
const push = (model: string | undefined) => {
|
|
84
|
+
if (!model || model.trim() === "") return;
|
|
85
|
+
const trimmed = model.trim();
|
|
86
|
+
const key = trimmed.toLowerCase();
|
|
87
|
+
if (seen.has(key)) return;
|
|
88
|
+
seen.add(key);
|
|
89
|
+
chain.push(trimmed);
|
|
90
|
+
};
|
|
91
|
+
push(primary);
|
|
92
|
+
for (const model of extra) push(model);
|
|
93
|
+
for (const model of config.subagentModelFallback ?? []) push(model);
|
|
94
|
+
return chain;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function buildSubagentModelChain(
|
|
98
|
+
primary: string,
|
|
99
|
+
config: OcxConfig,
|
|
100
|
+
extraFallback: readonly string[] = [],
|
|
101
|
+
): string[] {
|
|
102
|
+
return normalizedChain(primary, config, extraFallback);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function quotaThreshold(config: OcxConfig): number {
|
|
106
|
+
const threshold = config.autoSwitchThreshold ?? 80;
|
|
107
|
+
return threshold > 0 ? threshold : Number.POSITIVE_INFINITY;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function activeCodexAccountId(config: OcxConfig): string | null {
|
|
111
|
+
return config.activeCodexAccountId ?? null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Resolve the account id used for pool-scoped quota/health checks.
|
|
116
|
+
* Explicit `null` means the pre-fallback preview found no usable account — do not
|
|
117
|
+
* substitute `activeCodexAccountId` (that active id may itself be unusable).
|
|
118
|
+
*/
|
|
119
|
+
function resolvePoolFallbackAccountId(
|
|
120
|
+
config: OcxConfig,
|
|
121
|
+
accountId?: string | null,
|
|
122
|
+
): string | null {
|
|
123
|
+
if (typeof accountId === "string") return accountId;
|
|
124
|
+
if (accountId === null) return null;
|
|
125
|
+
return activeCodexAccountId(config);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function isRoutableFallbackModel(model: string, config: OcxConfig): boolean {
|
|
129
|
+
const slash = model.indexOf("/");
|
|
130
|
+
if (slash > 0) {
|
|
131
|
+
const providerName = model.slice(0, slash);
|
|
132
|
+
if (!hasOwnProvider(config.providers, providerName)) {
|
|
133
|
+
// Allow well-known "vendor/model" ids (e.g. anthropic/claude-*) to flow as
|
|
134
|
+
// raw model ids through the default provider, but reject stale/typo prefixes.
|
|
135
|
+
return knownProviderIdSet.has(providerName.toLowerCase());
|
|
136
|
+
}
|
|
137
|
+
const provider = config.providers[providerName];
|
|
138
|
+
if (provider?.disabled === true) return false;
|
|
139
|
+
}
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function isNativeModelQuotaExhausted(
|
|
144
|
+
model: string,
|
|
145
|
+
config: OcxConfig,
|
|
146
|
+
accountId?: string | null,
|
|
147
|
+
now = Date.now(),
|
|
148
|
+
): boolean {
|
|
149
|
+
const route = tryRouteFallbackModel(config, model);
|
|
150
|
+
if (!route || !isPoolCodexRoute(route)) return false;
|
|
151
|
+
const resolvedAccountId = resolvePoolFallbackAccountId(config, accountId);
|
|
152
|
+
if (!resolvedAccountId) return false;
|
|
153
|
+
const quota = getAccountQuota(resolvedAccountId);
|
|
154
|
+
const usage = computeCodexUsageScore(quota, getPoolAccountPlan(config, resolvedAccountId));
|
|
155
|
+
if (usage >= CODEX_UNKNOWN_USAGE_SCORE) return false;
|
|
156
|
+
return usage >= quotaThreshold(config);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function isModelHealthBlocked(
|
|
160
|
+
model: string,
|
|
161
|
+
config: OcxConfig,
|
|
162
|
+
accountId?: string | null,
|
|
163
|
+
now = Date.now(),
|
|
164
|
+
): boolean {
|
|
165
|
+
const route = tryRouteFallbackModel(config, model);
|
|
166
|
+
const poolScoped = !!route && isPoolCodexRoute(route);
|
|
167
|
+
const health = modelHealth.get(
|
|
168
|
+
healthKey(model, resolvePoolFallbackAccountId(config, accountId), poolScoped),
|
|
169
|
+
);
|
|
170
|
+
return !!health && health.unavailableUntil > now;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function isSubagentModelUnavailable(
|
|
174
|
+
model: string,
|
|
175
|
+
config: OcxConfig,
|
|
176
|
+
accountId?: string | null,
|
|
177
|
+
now = Date.now(),
|
|
178
|
+
): boolean {
|
|
179
|
+
if (isDisabledFallbackModel(model, config)) return true;
|
|
180
|
+
if (!isRoutableFallbackModel(model, config)) return true;
|
|
181
|
+
const route = tryRouteFallbackModel(config, model);
|
|
182
|
+
if (!route || route.provider.disabled === true) return true;
|
|
183
|
+
if (isModelHealthBlocked(model, config, accountId, now)) return true;
|
|
184
|
+
if (!isPoolCodexRoute(route)) return false;
|
|
185
|
+
|
|
186
|
+
// Pool candidates need a usable account. Derive requirement from the resolved
|
|
187
|
+
// route (canonical openai defaults to pool even when codexAccountMode is omitted).
|
|
188
|
+
const resolvedAccountId = resolvePoolFallbackAccountId(config, accountId);
|
|
189
|
+
if (!resolvedAccountId) return true;
|
|
190
|
+
if (!isCodexAccountUsable(config, resolvedAccountId)) return true;
|
|
191
|
+
if (
|
|
192
|
+
isCodexAccountInCooldown(resolvedAccountId, now)
|
|
193
|
+
&& !canAcquireCodexQuotaProbeLease(resolvedAccountId, now)
|
|
194
|
+
) {
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
return isNativeModelQuotaExhausted(model, config, accountId, now);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function selectAvailableSubagentModel(
|
|
201
|
+
primary: string,
|
|
202
|
+
config: OcxConfig,
|
|
203
|
+
extraFallback: readonly string[] = [],
|
|
204
|
+
accountId?: string | null,
|
|
205
|
+
now = Date.now(),
|
|
206
|
+
nativeFallbackOnly = false,
|
|
207
|
+
): { model: string; rewritten: boolean; skipped: string[] } {
|
|
208
|
+
const chain = normalizedChain(primary, config, extraFallback);
|
|
209
|
+
const skipped: string[] = [];
|
|
210
|
+
for (const candidate of chain) {
|
|
211
|
+
if (nativeFallbackOnly) {
|
|
212
|
+
const route = tryRouteFallbackModel(config, candidate);
|
|
213
|
+
if (!route || !isCanonicalOpenAiForwardProvider(route.provider)) {
|
|
214
|
+
skipped.push(candidate);
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
if (isSubagentModelUnavailable(candidate, config, accountId, now)) {
|
|
219
|
+
skipped.push(candidate);
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
return { model: candidate, rewritten: !slugsEquivalent(candidate, primary), skipped };
|
|
223
|
+
}
|
|
224
|
+
return { model: primary, rewritten: false, skipped };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function noteSubagentModelFailure(
|
|
228
|
+
model: string,
|
|
229
|
+
message: string,
|
|
230
|
+
config: OcxConfig,
|
|
231
|
+
accountId?: string | null,
|
|
232
|
+
now = Date.now(),
|
|
233
|
+
ttlMs?: number,
|
|
234
|
+
): void {
|
|
235
|
+
const interval = ttlMs ?? DEFAULT_SUBAGENT_MODEL_FALLBACK_POLL_MS;
|
|
236
|
+
if (!isRateLimitOrQuotaFailureMessage(message)) return;
|
|
237
|
+
const route = tryRouteFallbackModel(config, model);
|
|
238
|
+
const poolScoped = !!route && isPoolCodexRoute(route);
|
|
239
|
+
modelHealth.set(
|
|
240
|
+
healthKey(model, resolvePoolFallbackAccountId(config, accountId), poolScoped),
|
|
241
|
+
{
|
|
242
|
+
unavailableUntil: now + interval,
|
|
243
|
+
reason: "quota_exhausted",
|
|
244
|
+
},
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export function resetSubagentModelFallbackStateForTests(): void {
|
|
249
|
+
modelHealth.clear();
|
|
250
|
+
quotaPrimedAt.clear();
|
|
251
|
+
quotaPrimeInFlight = null;
|
|
252
|
+
subagentQuotaPrimeForTests = null;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Test-only: inject the quota prime implementation used by {@link maybePrimeSubagentQuota}. */
|
|
256
|
+
export function setSubagentQuotaPrimeForTests(fn: SubagentQuotaPrimeFn | null): void {
|
|
257
|
+
subagentQuotaPrimeForTests = fn;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Test-only: inspect shared prime TTL / in-flight state. */
|
|
261
|
+
export function getSubagentQuotaPrimeStateForTests(): {
|
|
262
|
+
primedAt: number;
|
|
263
|
+
inFlight: boolean;
|
|
264
|
+
} {
|
|
265
|
+
return {
|
|
266
|
+
primedAt: quotaPrimedAt.get("global") ?? 0,
|
|
267
|
+
inFlight: quotaPrimeInFlight !== null,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function rewriteParsedModel(parsed: OcxParsedRequest, model: string): void {
|
|
272
|
+
parsed.modelId = model;
|
|
273
|
+
if (parsed._rawBody && typeof parsed._rawBody === "object") {
|
|
274
|
+
(parsed._rawBody as { model?: string }).model = model;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const TOML_MODEL = /^(model)\s*=\s*("(?:\\.|[^"\\])*")\s*$/;
|
|
279
|
+
|
|
280
|
+
function parseTomlQuotedString(raw: string): string {
|
|
281
|
+
const trimmed = raw.trim();
|
|
282
|
+
if ((trimmed.startsWith("\"") && trimmed.endsWith("\""))
|
|
283
|
+
|| (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
|
|
284
|
+
return trimmed.slice(1, -1).replace(/\\"/g, "\"");
|
|
285
|
+
}
|
|
286
|
+
return trimmed;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function readAgentModel(filePath: string): string | null {
|
|
290
|
+
try {
|
|
291
|
+
const content = readFileSync(filePath, "utf8");
|
|
292
|
+
for (const line of content.split(/\r?\n/)) {
|
|
293
|
+
const match = line.match(TOML_MODEL);
|
|
294
|
+
if (!match) continue;
|
|
295
|
+
const model = parseTomlQuotedString(match[2] ?? "");
|
|
296
|
+
return model.trim() === "" ? null : model.trim();
|
|
297
|
+
}
|
|
298
|
+
} catch {
|
|
299
|
+
return null;
|
|
300
|
+
}
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export function readCodexAgentModel(role: string, codexHome = CODEX_HOME): string | null {
|
|
305
|
+
const file = join(codexHome, "agents", `${role}.toml`);
|
|
306
|
+
if (!existsSync(file)) return null;
|
|
307
|
+
return readAgentModel(file);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export function resolveAgentModelFallbackForPrimary(
|
|
311
|
+
primary: string,
|
|
312
|
+
codexHome = CODEX_HOME,
|
|
313
|
+
): string[] {
|
|
314
|
+
const merged: string[] = [];
|
|
315
|
+
const seen = new Set<string>();
|
|
316
|
+
const push = (model: string | null | undefined) => {
|
|
317
|
+
if (!model || model.trim() === "") return;
|
|
318
|
+
const trimmed = model.trim();
|
|
319
|
+
const key = trimmed.toLowerCase();
|
|
320
|
+
if (seen.has(key)) return;
|
|
321
|
+
seen.add(key);
|
|
322
|
+
merged.push(trimmed);
|
|
323
|
+
};
|
|
324
|
+
for (const role of listCodexAgentRoles(codexHome)) {
|
|
325
|
+
const model = readCodexAgentModel(role, codexHome);
|
|
326
|
+
if (!model || !slugsEquivalent(model, primary)) continue;
|
|
327
|
+
for (const fallback of readCodexAgentModelFallback(role, codexHome)) push(fallback);
|
|
328
|
+
}
|
|
329
|
+
return merged;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Best-effort quota refresh before subagent model selection.
|
|
334
|
+
* Concurrent callers share one in-flight promise. The success TTL is updated only
|
|
335
|
+
* after a successful refresh so failures remain retryable. Errors are swallowed so
|
|
336
|
+
* spawn routing can continue.
|
|
337
|
+
*/
|
|
338
|
+
export function maybePrimeSubagentQuota(config: OcxConfig, now = Date.now()): Promise<void> {
|
|
339
|
+
if (quotaPrimeInFlight) return quotaPrimeInFlight;
|
|
340
|
+
if (!shouldPrimeSubagentQuota(config, now)) return Promise.resolve();
|
|
341
|
+
|
|
342
|
+
quotaPrimeInFlight = (async () => {
|
|
343
|
+
try {
|
|
344
|
+
if (subagentQuotaPrimeForTests) {
|
|
345
|
+
await subagentQuotaPrimeForTests(config, "subagent-spawn");
|
|
346
|
+
} else {
|
|
347
|
+
const { primeCodexPoolQuotas } = await import("./auth-api");
|
|
348
|
+
await primeCodexPoolQuotas(config, "subagent-spawn");
|
|
349
|
+
}
|
|
350
|
+
quotaPrimedAt.set("global", Date.now());
|
|
351
|
+
} catch {
|
|
352
|
+
// Owning boundary: do not fail the spawn path when priming is unavailable.
|
|
353
|
+
// Leave quotaPrimedAt untouched so a later spawn can retry.
|
|
354
|
+
} finally {
|
|
355
|
+
quotaPrimeInFlight = null;
|
|
356
|
+
}
|
|
357
|
+
})();
|
|
358
|
+
return quotaPrimeInFlight;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export function recordSubagentQuotaFailureForThreadSpawn(
|
|
362
|
+
headers: Headers,
|
|
363
|
+
model: string,
|
|
364
|
+
message: string | number,
|
|
365
|
+
config: OcxConfig,
|
|
366
|
+
accountId?: string | null,
|
|
367
|
+
now = Date.now(),
|
|
368
|
+
): void {
|
|
369
|
+
if (!isThreadSpawnRequest(headers)) return;
|
|
370
|
+
noteSubagentModelFailure(model, String(message), config, accountId, now, pollIntervalMs(config));
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
export function applySubagentModelFallback(
|
|
374
|
+
parsed: OcxParsedRequest,
|
|
375
|
+
headers: Headers,
|
|
376
|
+
config: OcxConfig,
|
|
377
|
+
accountId?: string | null,
|
|
378
|
+
now = Date.now(),
|
|
379
|
+
nativeFallbackOnly = false,
|
|
380
|
+
): { from?: string; to?: string; skipped?: string[] } | null {
|
|
381
|
+
if (!isThreadSpawnRequest(headers)) return null;
|
|
382
|
+
const roleFallback = resolveAgentModelFallbackForPrimary(parsed.modelId, getCodexHome());
|
|
383
|
+
const globalFallback = config.subagentModelFallback ?? [];
|
|
384
|
+
if (globalFallback.length === 0 && roleFallback.length === 0) return null;
|
|
385
|
+
const selection = selectAvailableSubagentModel(
|
|
386
|
+
parsed.modelId,
|
|
387
|
+
config,
|
|
388
|
+
roleFallback,
|
|
389
|
+
accountId,
|
|
390
|
+
now,
|
|
391
|
+
nativeFallbackOnly,
|
|
392
|
+
);
|
|
393
|
+
if (!selection.rewritten) return selection.skipped.length > 0
|
|
394
|
+
? { from: parsed.modelId, to: parsed.modelId, skipped: selection.skipped }
|
|
395
|
+
: null;
|
|
396
|
+
const from = parsed.modelId;
|
|
397
|
+
rewriteParsedModel(parsed, selection.model);
|
|
398
|
+
return { from, to: selection.model, skipped: selection.skipped };
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
export function subagentFallbackGuidanceText(config: OcxConfig): string {
|
|
402
|
+
const chain = config.subagentModelFallback ?? [];
|
|
403
|
+
if (chain.length === 0) return "";
|
|
404
|
+
const quoted = chain.map(model => `"${model}"`).join(", ");
|
|
405
|
+
return ` Subagent model fallback chain (priority order): ${quoted}. When the primary model is quota-exhausted, opencodex rewrites thread_spawn requests to the next available model automatically.`;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const TOML_STRING_ARRAY = /^(model_fallback)\s*=\s*\[(.*)\]\s*$/s;
|
|
409
|
+
|
|
410
|
+
function parseTomlStringArray(raw: string): string[] {
|
|
411
|
+
const matches = [...raw.matchAll(/"((?:\\.|[^"\\])*)"/g)];
|
|
412
|
+
return matches.map(match => match[1]!.replace(/\\"/g, "\""));
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function parseTomlModelFallback(content: string): string[] | null {
|
|
416
|
+
const match = content.match(/^\s*model_fallback\s*=\s*\[(.*?)\]\s*$/ms);
|
|
417
|
+
if (!match) return null;
|
|
418
|
+
return parseTomlStringArray(match[1] ?? "");
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
export function readAgentModelFallback(filePath: string): string[] | null {
|
|
422
|
+
try {
|
|
423
|
+
const content = readFileSync(filePath, "utf8");
|
|
424
|
+
const multiline = parseTomlModelFallback(content);
|
|
425
|
+
if (multiline) return multiline;
|
|
426
|
+
for (const line of content.split(/\r?\n/)) {
|
|
427
|
+
const match = line.match(TOML_STRING_ARRAY);
|
|
428
|
+
if (!match) continue;
|
|
429
|
+
return parseTomlStringArray(match[2] ?? "");
|
|
430
|
+
}
|
|
431
|
+
} catch {
|
|
432
|
+
return null;
|
|
433
|
+
}
|
|
434
|
+
return null;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
export function readCodexAgentModelFallback(role: string, codexHome = CODEX_HOME): string[] {
|
|
438
|
+
const file = join(codexHome, "agents", `${role}.toml`);
|
|
439
|
+
if (!existsSync(file)) return [];
|
|
440
|
+
return readAgentModelFallback(file) ?? [];
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
export function listCodexAgentRoles(codexHome = CODEX_HOME): string[] {
|
|
444
|
+
const dir = join(codexHome, "agents");
|
|
445
|
+
if (!existsSync(dir)) return [];
|
|
446
|
+
return readdirSync(dir)
|
|
447
|
+
.filter(name => name.endsWith(".toml"))
|
|
448
|
+
.map(name => name.slice(0, -".toml".length));
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** True when a new quota prime should start (no success within the poll interval). */
|
|
452
|
+
export function shouldPrimeSubagentQuota(config: OcxConfig, now = Date.now()): boolean {
|
|
453
|
+
const last = quotaPrimedAt.get("global") ?? 0;
|
|
454
|
+
return now - last >= pollIntervalMs(config);
|
|
455
|
+
}
|
package/src/codex/sync.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { printProjectCodexConfigWarnings, groupProjectCodexConfigWarningsByPath,
|
|
|
3
3
|
import { refreshCodexModelCatalog } from "./refresh";
|
|
4
4
|
import { applyProxyEnv, loadConfig } from "../config";
|
|
5
5
|
import type { OcxConfig } from "../types";
|
|
6
|
+
import { collectOrcaCodexHomeDiagnostic } from "./home";
|
|
6
7
|
|
|
7
8
|
export interface CodexSyncResult {
|
|
8
9
|
ok: boolean;
|
|
@@ -20,6 +21,7 @@ interface CodexSyncDeps {
|
|
|
20
21
|
refreshCodexModelCatalog: typeof refreshCodexModelCatalog;
|
|
21
22
|
injectCodexConfig: typeof injectCodexConfig;
|
|
22
23
|
currentExternalCodexModelProvider?: typeof currentExternalCodexModelProvider;
|
|
24
|
+
collectCodexHomeDiagnostic?: typeof collectOrcaCodexHomeDiagnostic;
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
const defaultDeps: CodexSyncDeps = {
|
|
@@ -27,6 +29,19 @@ const defaultDeps: CodexSyncDeps = {
|
|
|
27
29
|
injectCodexConfig,
|
|
28
30
|
};
|
|
29
31
|
|
|
32
|
+
function reportCodexHomeTarget(
|
|
33
|
+
log: Pick<Console, "log" | "error"> | null,
|
|
34
|
+
collectDiagnostic: typeof collectOrcaCodexHomeDiagnostic,
|
|
35
|
+
): void {
|
|
36
|
+
if (!log) return;
|
|
37
|
+
const target = collectDiagnostic();
|
|
38
|
+
log.log(` Target Codex home: ${target.effectiveCodexHome}`);
|
|
39
|
+
if (target.warning) {
|
|
40
|
+
log.error(`WARNING: ${target.warning}`);
|
|
41
|
+
log.error(`Action: ${target.action}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
30
45
|
export async function syncModelsToCodex(
|
|
31
46
|
port?: number,
|
|
32
47
|
config: OcxConfig = loadConfig(),
|
|
@@ -38,6 +53,7 @@ export async function syncModelsToCodex(
|
|
|
38
53
|
if (externalProvider) {
|
|
39
54
|
const result = await deps.injectCodexConfig(p, config, {});
|
|
40
55
|
log?.log(result.message);
|
|
56
|
+
reportCodexHomeTarget(log, deps.collectCodexHomeDiagnostic ?? collectOrcaCodexHomeDiagnostic);
|
|
41
57
|
return {
|
|
42
58
|
ok: result.success,
|
|
43
59
|
added: 0,
|
|
@@ -76,6 +92,7 @@ export async function syncModelsToCodex(
|
|
|
76
92
|
|
|
77
93
|
const result = await deps.injectCodexConfig(p, config, { catalogPath: catalogPathForInjection });
|
|
78
94
|
log?.log(result.message);
|
|
95
|
+
reportCodexHomeTarget(log, deps.collectCodexHomeDiagnostic ?? collectOrcaCodexHomeDiagnostic);
|
|
79
96
|
const projectConfigWarnings = printProjectCodexConfigWarnings(log, { cwd: process.cwd() });
|
|
80
97
|
return {
|
|
81
98
|
ok: result.success,
|
package/src/combos/failover.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { classifyError } from "../lib/errors";
|
|
1
|
+
import { classifyError, isCyberPolicyCode } from "../lib/errors";
|
|
2
2
|
import type { OcxComboTarget } from "../types";
|
|
3
3
|
import { targetKey } from "./types";
|
|
4
4
|
|
|
@@ -79,10 +79,18 @@ export function clearComboTargetCooldowns(comboId?: string): void {
|
|
|
79
79
|
|
|
80
80
|
export type ComboFailureDecision = "hop" | "stop";
|
|
81
81
|
|
|
82
|
-
export function comboFailureDecision(
|
|
82
|
+
export function comboFailureDecision(
|
|
83
|
+
status: number,
|
|
84
|
+
message: string,
|
|
85
|
+
options?: { code?: string | null },
|
|
86
|
+
): ComboFailureDecision {
|
|
83
87
|
if (status === 499) return "stop";
|
|
84
88
|
if (message.toLowerCase().includes("origin_rejected")) return "stop";
|
|
89
|
+
// Cyber policy is a hard non-retryable refusal — honor structured code even when
|
|
90
|
+
// classificationText was truncated before the JSON code field.
|
|
91
|
+
if (isCyberPolicyCode(options?.code)) return "stop";
|
|
85
92
|
const error = classifyError(status, "upstream_error", message);
|
|
93
|
+
if (isCyberPolicyCode(error.code)) return "stop";
|
|
86
94
|
if (["origin_rejected", "context_length_exceeded", "invalid_request_error"].includes(error.code ?? "")) {
|
|
87
95
|
return "stop";
|
|
88
96
|
}
|
package/src/combos/index.ts
CHANGED
package/src/combos/types.ts
CHANGED
|
@@ -4,10 +4,19 @@ import type {
|
|
|
4
4
|
OcxComboDefaultEffort,
|
|
5
5
|
OcxComboStrategy,
|
|
6
6
|
OcxComboTarget,
|
|
7
|
+
OcxConfig,
|
|
7
8
|
OcxProviderConfig,
|
|
8
9
|
} from "../types";
|
|
9
10
|
|
|
10
11
|
export const COMBO_NAMESPACE = "combo";
|
|
12
|
+
|
|
13
|
+
export function preservesPhysicalComboProvider(
|
|
14
|
+
config: Pick<OcxConfig, "providers" | "combos">,
|
|
15
|
+
): boolean {
|
|
16
|
+
return Object.hasOwn(config.providers, COMBO_NAMESPACE)
|
|
17
|
+
&& Object.keys(config.combos ?? {}).length === 0;
|
|
18
|
+
}
|
|
19
|
+
|
|
11
20
|
const COMBO_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
12
21
|
/**
|
|
13
22
|
* Public alias shape: one optional "/" segment, each segment id-shaped. Bare aliases
|