@bitkyc08/opencodex 2.36.0-preview.20260829 → 2.36.0
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/gui/dist/assets/index-Cy7Z_pl0.css +1 -0
- package/gui/dist/assets/index-DO8liQVL.js +112 -0
- package/gui/dist/index.html +2 -2
- package/package.json +2 -1
- package/src/AGENTS.md +2 -1
- package/src/adapters/agentrouter.ts +50 -0
- package/src/adapters/anthropic.ts +1 -51
- package/src/adapters/cursor/call-id.ts +76 -8
- package/src/adapters/cursor/checkpoint-store.ts +6 -1
- package/src/adapters/cursor/cursor-errors.ts +44 -0
- package/src/adapters/cursor/native-exec.ts +13 -0
- package/src/adapters/cursor/protobuf-request.ts +651 -29
- package/src/adapters/cursor/tool-result-normalize.ts +3 -3
- package/src/adapters/cursor/transport-retry.ts +5 -1
- package/src/adapters/cursor.ts +15 -1
- package/src/adapters/empty-tool-output-annotation.ts +43 -0
- package/src/adapters/exec-tool-result-normalize.ts +70 -5
- package/src/adapters/google.ts +22 -2
- package/src/adapters/kiro.ts +26 -2
- package/src/adapters/ollama-native-url.ts +111 -0
- package/src/adapters/ollama-native.ts +1131 -0
- package/src/adapters/openai-chat.ts +30 -7
- package/src/adapters/openai-responses.ts +72 -4
- package/src/adapters/registry.ts +7 -0
- package/src/adapters/xai-web-search.ts +58 -0
- package/src/claude/desktop-3p.ts +21 -1
- package/src/claude/desktop-policy.ts +149 -0
- package/src/cli/account.ts +16 -2
- package/src/cli/claude-desktop.ts +13 -3
- package/src/cli/combo.ts +8 -5
- package/src/cli/doctor.ts +77 -11
- package/src/cli/help.ts +1 -1
- package/src/cli/index.ts +16 -0
- package/src/cli/models.ts +20 -3
- package/src/cli/registry.ts +2 -1
- package/src/cli/status.ts +140 -2
- package/src/cli/storage.ts +10 -1
- package/src/codex/account-runtime-state.ts +39 -5
- package/src/codex/account-store.ts +393 -13
- package/src/codex/account-usability.ts +11 -4
- package/src/codex/app-server-processes.ts +46 -5
- package/src/codex/auth-context.ts +160 -32
- package/src/codex/catalog/bundled.ts +7 -5
- package/src/codex/catalog/metadata.ts +1 -1
- package/src/codex/catalog/parsing.ts +57 -1
- package/src/codex/catalog/provider-fetch.ts +61 -4
- package/src/codex/catalog/sync.ts +4 -3
- package/src/codex/convergence.ts +3 -2
- package/src/codex/data/upstream-models.json +40 -8
- package/src/codex/inject-coordination.ts +111 -14
- package/src/codex/integration-record.ts +12 -2
- package/src/codex/main-account.ts +225 -1
- package/src/codex/model-entitlements.ts +339 -27
- package/src/codex/prompt-layers.ts +346 -7
- package/src/codex/prompt-text-probe.ts +272 -21
- package/src/codex/routing.ts +693 -132
- package/src/codex/runtime.ts +12 -0
- package/src/codex/subagent-model-fallback.ts +62 -24
- package/src/codex/user-identity.ts +33 -25
- package/src/combos/index.ts +1 -0
- package/src/combos/reset-window.ts +46 -0
- package/src/combos/resolve.ts +84 -2
- package/src/combos/types.ts +5 -2
- package/src/config/atomic-write.ts +104 -22
- package/src/config/provider-validation.ts +11 -0
- package/src/config.ts +75 -3
- package/src/generated/compatibility-version.json +207 -131
- package/src/generated/model-metadata.ts +1 -1
- package/src/grok/catalog.ts +71 -0
- package/src/grok/effort.ts +83 -0
- package/src/grok/inject.ts +952 -127
- package/src/grok/models.ts +56 -0
- package/src/grok/status.ts +21 -8
- package/src/grok/sync.ts +10 -18
- package/src/images/loop.ts +6 -3
- package/src/integrations/native/ownership-preflight.ts +4 -1
- package/src/lab/fabric/producer-isolate.ts +36 -3
- package/src/lib/destination-policy.ts +93 -7
- package/src/lib/redact.ts +6 -1
- package/src/lib/shadow-call.ts +38 -3
- package/src/lib/test-home-guard.ts +18 -3
- package/src/lib/upstream-retry.ts +43 -6
- package/src/lib/windows-secret-acl.ts +66 -0
- package/src/lib/windows-text.ts +28 -2
- package/src/lib/windows-user-principal.ts +35 -23
- package/src/oauth/account-quota-rank.ts +107 -0
- package/src/oauth/anthropic-routing.ts +125 -30
- package/src/oauth/chatgpt.ts +5 -1
- package/src/oauth/generic-account-failover.ts +114 -7
- package/src/oauth/index.ts +15 -8
- package/src/oauth/store.ts +16 -0
- package/src/providers/account-quota-disk.ts +79 -0
- package/src/providers/command-code-efforts.ts +24 -0
- package/src/providers/derive.ts +6 -0
- package/src/providers/key-failover.ts +33 -1
- package/src/providers/kiro-usage.ts +272 -0
- package/src/providers/ollama-show.ts +311 -0
- package/src/providers/openai-sidecar.ts +5 -0
- package/src/providers/quota-routing-cache.ts +32 -0
- package/src/providers/quota-types.ts +36 -0
- package/src/providers/quota-wire.ts +102 -0
- package/src/providers/quota.ts +208 -147
- package/src/providers/registry.ts +68 -8
- package/src/providers/slug-codec.ts +12 -4
- package/src/providers/vercel-gateway-routing.ts +108 -0
- package/src/router.ts +22 -12
- package/src/server/auth-cors.ts +26 -0
- package/src/server/catalog-download.ts +73 -0
- package/src/server/chat-native.ts +12 -2
- package/src/server/gui-static.ts +4 -1
- package/src/server/index.ts +132 -9
- package/src/server/management/agent-settings-routes.ts +38 -5
- package/src/server/management/codex-prompt-routes.ts +7 -1
- package/src/server/management/combo-routes.ts +10 -1
- package/src/server/management/config-routes.ts +9 -1
- package/src/server/management/context.ts +5 -0
- package/src/server/management/model-routes.ts +16 -6
- package/src/server/management/native-integration-routes.ts +12 -17
- package/src/server/management/oauth-account-routes.ts +13 -0
- package/src/server/management/provider-routes.ts +32 -5
- package/src/server/management/routing-profile-routes.ts +15 -0
- package/src/server/management/shadow-call-validation.ts +29 -0
- package/src/server/management-api.ts +7 -3
- package/src/server/request-log.ts +3 -5
- package/src/server/responses/agent-task-recovery-cache.ts +8 -0
- package/src/server/responses/agent-task-recovery.ts +52 -20
- package/src/server/responses/codex-auth-error.ts +26 -0
- package/src/server/responses/compact.ts +345 -10
- package/src/server/responses/core.ts +736 -108
- package/src/server/responses/empty-completion-guard.ts +16 -0
- package/src/server/responses/fetch-helpers.ts +42 -0
- package/src/server/responses/policy-fallback.ts +11 -6
- package/src/server/responses-undeclared-tool-guard.ts +16 -3
- package/src/server/startup-health-cache.ts +59 -13
- package/src/service-manager-probe.ts +115 -9
- package/src/service.ts +139 -40
- package/src/storage/cleanup.ts +10 -0
- package/src/storage/storage-mutation-coordinator.ts +14 -3
- package/src/tray/windows-tray.ps1 +10 -4
- package/src/tray/windows.ts +30 -2
- package/src/types/config.ts +27 -14
- package/src/types/provider.ts +54 -0
- package/src/types/tools.ts +13 -3
- package/src/types.ts +4 -0
- package/src/usage/summary.ts +421 -177
- package/src/vision/anthropic-describe.ts +3 -3
- package/src/vision/describe.ts +5 -3
- package/src/web-search/anthropic-executor.ts +9 -2
- package/src/web-search/exa-executor.ts +3 -3
- package/src/web-search/executor.ts +8 -3
- package/src/web-search/gemini-executor.ts +3 -3
- package/src/web-search/loop.ts +11 -3
- package/src/web-search/xai-executor.ts +3 -3
- package/gui/dist/assets/index-DQ-Ie18T.css +0 -1
- package/gui/dist/assets/index-DX0RGj3e.js +0 -112
package/src/codex/runtime.ts
CHANGED
|
@@ -435,6 +435,18 @@ export type CodexRuntimeProcessCachePeek =
|
|
|
435
435
|
let resolveCacheEpoch = 0;
|
|
436
436
|
let resolveCache: ResolveCacheMemo | null = null;
|
|
437
437
|
|
|
438
|
+
/**
|
|
439
|
+
* Bumped whenever persisted runtime state is replaced or process authority is cleared.
|
|
440
|
+
*
|
|
441
|
+
* Consumers that memoize anything derived from `codex-runtime.json` — entitlement's client
|
|
442
|
+
* version, for one — read this instead of a timestamp, so a runtime switch invalidates them at
|
|
443
|
+
* the moment of the write rather than after a delay window. Exposed because the alternative was
|
|
444
|
+
* a time-based guess that could answer under the previous version after the file had changed.
|
|
445
|
+
*/
|
|
446
|
+
export function codexRuntimeStateEpoch(): number {
|
|
447
|
+
return resolveCacheEpoch;
|
|
448
|
+
}
|
|
449
|
+
|
|
438
450
|
function publishResolveCache(key: string, at: number, value: ResolveCodexRuntimeResult): void {
|
|
439
451
|
const epoch = ++resolveCacheEpoch;
|
|
440
452
|
resolveCache = {
|
|
@@ -39,6 +39,7 @@ import { routeModel, type RouteResult } from "../router";
|
|
|
39
39
|
import { sweepExpiredOnWrite } from "../lib/state-store-sweeper";
|
|
40
40
|
import { codexAccountNamespaceForModel } from "./account-namespace-match";
|
|
41
41
|
import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS } from "./catalog/native-models";
|
|
42
|
+
import { MAIN_CODEX_ACCOUNT_ID } from "./main-account";
|
|
42
43
|
import {
|
|
43
44
|
getUpstreamHostHealth,
|
|
44
45
|
normalizeUpstreamHostCircuitThreshold,
|
|
@@ -276,31 +277,68 @@ export function isSubagentModelUnavailable(
|
|
|
276
277
|
poolAccountPreview,
|
|
277
278
|
candidateAccountUsabilityOptions?.modelEligibleAccountIds,
|
|
278
279
|
);
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
//
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
if (
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
280
|
+
const accountUnavailable = (
|
|
281
|
+
candidateAccountId: string | null,
|
|
282
|
+
usabilityOptions: CodexAccountUsabilityOptions | undefined,
|
|
283
|
+
includeQuotaExhaustion: boolean,
|
|
284
|
+
): boolean => {
|
|
285
|
+
if (isModelHealthBlocked(model, config, candidateAccountId, now)) return true;
|
|
286
|
+
if (!isPoolCodexRoute(route)) return false;
|
|
287
|
+
|
|
288
|
+
// Pool candidates need a usable account. Derive requirement from the resolved
|
|
289
|
+
// route (canonical openai defaults to pool even when codexAccountMode is omitted).
|
|
290
|
+
if (!candidateAccountId) return true;
|
|
291
|
+
if (isCodexAccountPaused(config, candidateAccountId)) return true;
|
|
292
|
+
if (!isCodexAccountUsable(config, candidateAccountId, usabilityOptions)) return true;
|
|
293
|
+
if (route.codexAccountId !== undefined) {
|
|
294
|
+
// An account-qualified route is pinned and cannot consume Pool's recovery-probe
|
|
295
|
+
// escape hatch. Honor both account-wide and model-scoped cooldowns so fallback
|
|
296
|
+
// advances instead of selecting a candidate that exact auth will reject.
|
|
297
|
+
const quotaScope = codexQuotaScopeForModel(route.modelId);
|
|
298
|
+
if (getCodexQuotaHealthSnapshot(candidateAccountId, quotaScope, now) !== null) return true;
|
|
299
|
+
} else {
|
|
300
|
+
const quotaScope = codexQuotaScopeForModel(route.modelId);
|
|
301
|
+
const cooldown = getCodexQuotaHealthSnapshot(candidateAccountId, quotaScope, now);
|
|
302
|
+
if (cooldown !== null) {
|
|
303
|
+
const probeAvailable = cooldown.quotaScope
|
|
304
|
+
? canAcquireCodexQuotaScopeProbeLease(candidateAccountId, cooldown.quotaScope, now)
|
|
305
|
+
: canAcquireCodexQuotaProbeLease(candidateAccountId, now);
|
|
306
|
+
if (!probeAvailable) return true;
|
|
307
|
+
}
|
|
301
308
|
}
|
|
302
|
-
|
|
303
|
-
|
|
309
|
+
if (
|
|
310
|
+
!includeQuotaExhaustion
|
|
311
|
+
|| (
|
|
312
|
+
candidateAccountId === MAIN_CODEX_ACCOUNT_ID
|
|
313
|
+
&& usabilityOptions?.nativeMainSelectionOnly === true
|
|
314
|
+
)
|
|
315
|
+
) return false;
|
|
316
|
+
return isNativeModelQuotaExhausted(model, config, candidateAccountId, now);
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
// Prefer a genuinely usable entitled pool account. Preview can deliberately return
|
|
320
|
+
// the configured active account even when no selectable candidate exists, so a
|
|
321
|
+
// null/main-only check is not enough to detect the temporary-drain case.
|
|
322
|
+
if (!accountUnavailable(resolvedAccountId, candidateAccountUsabilityOptions, true)) return false;
|
|
323
|
+
|
|
324
|
+
// During a temporary native-main drain, entitlement discovery excludes main to
|
|
325
|
+
// preserve the credential fence. If no non-main candidate can serve an unqualified
|
|
326
|
+
// gated model, retain main only as a read-free sentinel: final auth owns the atomic
|
|
327
|
+
// claim and returns maintenance instead of letting a routed fallback bypass it.
|
|
328
|
+
const preserveDrainingMainCandidate = route.codexAccountId === undefined
|
|
329
|
+
&& candidateAccountUsabilityOptions?.nativeMainSelectionOnly === true
|
|
330
|
+
&& ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(route.modelId);
|
|
331
|
+
if (!preserveDrainingMainCandidate) return true;
|
|
332
|
+
const drainingMainUsabilityOptions: CodexAccountUsabilityOptions = {
|
|
333
|
+
...candidateAccountUsabilityOptions,
|
|
334
|
+
modelEligibleAccountIds: new Set([
|
|
335
|
+
...(modelEligibleAccountIds ?? []),
|
|
336
|
+
MAIN_CODEX_ACCOUNT_ID,
|
|
337
|
+
]),
|
|
338
|
+
};
|
|
339
|
+
// Quota scoring main would lazily read the native credential/plan. Cached health,
|
|
340
|
+
// pause, reauth, and cooldown state are safe; defer physical scoring to final auth.
|
|
341
|
+
return accountUnavailable(MAIN_CODEX_ACCOUNT_ID, drainingMainUsabilityOptions, false);
|
|
304
342
|
}
|
|
305
343
|
|
|
306
344
|
export function selectAvailableSubagentModel(
|
|
@@ -36,27 +36,29 @@ const SID_PATTERN = /^S-1-(?:\d+-)+\d+$/i;
|
|
|
36
36
|
* Hard budget for the Windows identity-lookup PowerShell child. These lookups
|
|
37
37
|
* run at startup and on config writes; a hung child must fail the lookup
|
|
38
38
|
* (recoverable — the caller refuses) rather than wedge the proxy indefinitely.
|
|
39
|
-
*/
|
|
40
|
-
const WINDOWS_POWERSHELL_LOOKUP_TIMEOUT_MS = 8_000;
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* The same budget, widened for a contended CI runner.
|
|
44
39
|
*
|
|
45
|
-
*
|
|
46
|
-
* on
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
40
|
+
* 30s, and the same on a desktop as in CI. The split below used to give desktops
|
|
41
|
+
* 8s, on the theory that only a shared runner is contended enough to need more.
|
|
42
|
+
* A zh-CN Windows 10 host measured 3.2s for the SID expression and 4.6s for the
|
|
43
|
+
* `Add-Type` LocalAppData expression — per spawn, with a bare
|
|
44
|
+
* `powershell.exe -NoProfile -Command exit` costing ~3s where a typical desktop
|
|
45
|
+
* pays ~150ms — so ordinary spawn jitter breached 8s intermittently and `ocx sync`
|
|
46
|
+
* failed with "Windows effective-account lookup timed out" (#2914).
|
|
47
|
+
*
|
|
48
|
+
* That is the same contention the CI branch was widened for, which is what makes
|
|
49
|
+
* the split vestigial rather than merely conservative: it encoded an assumption
|
|
50
|
+
* about WHERE contention happens, and the assumption was wrong. Antivirus
|
|
51
|
+
* real-time scanning, a loaded Task Scheduler, and cold PowerShell startup do not
|
|
52
|
+
* care whether the machine is a runner.
|
|
50
53
|
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
54
|
+
* The contract this budget protects is unchanged: a genuinely hung child still
|
|
55
|
+
* fails the lookup and the caller still refuses rather than writing. Only the
|
|
56
|
+
* ceiling moved, and it moved for the case where the lookup would have succeeded.
|
|
53
57
|
*/
|
|
54
|
-
const
|
|
58
|
+
const WINDOWS_POWERSHELL_LOOKUP_TIMEOUT_MS = 30_000;
|
|
55
59
|
|
|
56
60
|
function windowsIdentityLookupTimeoutMs(): number {
|
|
57
|
-
return
|
|
58
|
-
? WINDOWS_POWERSHELL_LOOKUP_TIMEOUT_CI_MS
|
|
59
|
-
: WINDOWS_POWERSHELL_LOOKUP_TIMEOUT_MS;
|
|
61
|
+
return WINDOWS_POWERSHELL_LOOKUP_TIMEOUT_MS;
|
|
60
62
|
}
|
|
61
63
|
|
|
62
64
|
/**
|
|
@@ -435,6 +437,18 @@ function resolveWindowsRuntimeRoot(identity: Extract<UserIdentity, { platform: "
|
|
|
435
437
|
}
|
|
436
438
|
}
|
|
437
439
|
|
|
440
|
+
/**
|
|
441
|
+
* Resolve the canonical, effective-user runtime root shared by Codex lock domains.
|
|
442
|
+
*
|
|
443
|
+
* This is a directory, not a final lock or database path. Callers create their
|
|
444
|
+
* own named child so independent exclusion domains cannot self-contend.
|
|
445
|
+
*/
|
|
446
|
+
export function resolveEffectiveUserRuntimeRoot(identity: UserIdentity): string {
|
|
447
|
+
return identity.platform === "posix"
|
|
448
|
+
? resolvePosixRuntimeRoot(identity.uid)
|
|
449
|
+
: resolveWindowsRuntimeRoot(identity);
|
|
450
|
+
}
|
|
451
|
+
|
|
438
452
|
/**
|
|
439
453
|
* Windows path identity is case-insensitive; everywhere else it is exact.
|
|
440
454
|
*
|
|
@@ -461,9 +475,7 @@ export const resolveCodexCoordinatorDatabasePath: ResolveCodexCoordinatorDatabas
|
|
|
461
475
|
if (!isAbsolute(canonicalCodexHome)) {
|
|
462
476
|
refuse("The canonical CODEX_HOME must be an absolute path.");
|
|
463
477
|
}
|
|
464
|
-
const root = identity
|
|
465
|
-
? resolvePosixRuntimeRoot(identity.uid)
|
|
466
|
-
: resolveWindowsRuntimeRoot(identity);
|
|
478
|
+
const root = resolveEffectiveUserRuntimeRoot(identity);
|
|
467
479
|
const locks = join(root, "native-write-locks");
|
|
468
480
|
if (identity.platform === "posix") ensurePrivatePosixDirectory(locks, identity.uid);
|
|
469
481
|
else {
|
|
@@ -495,9 +507,7 @@ export const resolveCodexCatalogSerializationDatabasePath:
|
|
|
495
507
|
if (!isAbsolute(canonicalCodexHome)) {
|
|
496
508
|
refuse("The canonical CODEX_HOME must be an absolute path.");
|
|
497
509
|
}
|
|
498
|
-
const root = identity
|
|
499
|
-
? resolvePosixRuntimeRoot(identity.uid)
|
|
500
|
-
: resolveWindowsRuntimeRoot(identity);
|
|
510
|
+
const root = resolveEffectiveUserRuntimeRoot(identity);
|
|
501
511
|
const locks = join(root, "catalog-write-locks");
|
|
502
512
|
if (identity.platform === "posix") ensurePrivatePosixDirectory(locks, identity.uid);
|
|
503
513
|
else {
|
|
@@ -534,9 +544,7 @@ export const resolveCodexHistorySerializationDatabasePath:
|
|
|
534
544
|
if (!isAbsolute(canonicalStateDbPath)) {
|
|
535
545
|
refuse("The canonical Codex state database must be an absolute path.");
|
|
536
546
|
}
|
|
537
|
-
const root = identity
|
|
538
|
-
? resolvePosixRuntimeRoot(identity.uid)
|
|
539
|
-
: resolveWindowsRuntimeRoot(identity);
|
|
547
|
+
const root = resolveEffectiveUserRuntimeRoot(identity);
|
|
540
548
|
const locks = join(root, "history-write-locks");
|
|
541
549
|
if (identity.platform === "posix") ensurePrivatePosixDirectory(locks, identity.uid);
|
|
542
550
|
else {
|
package/src/combos/index.ts
CHANGED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { ProviderQuota } from "../providers/quota";
|
|
2
|
+
|
|
3
|
+
function collectResetCandidates(quota: ProviderQuota): number[] {
|
|
4
|
+
const candidates: number[] = [];
|
|
5
|
+
const push = (value: number | undefined): void => {
|
|
6
|
+
if (value !== undefined && Number.isFinite(value)) candidates.push(value);
|
|
7
|
+
};
|
|
8
|
+
push(quota.fiveHourResetAt);
|
|
9
|
+
push(quota.weeklyResetAt);
|
|
10
|
+
push(quota.monthlyResetAt);
|
|
11
|
+
if (quota.customWindows) {
|
|
12
|
+
for (const w of quota.customWindows) {
|
|
13
|
+
push(w.resetAt);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return candidates;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Earliest future reset timestamp from a cached provider quota snapshot,
|
|
21
|
+
* or null when no fresh quota data exists or all resets have elapsed.
|
|
22
|
+
*/
|
|
23
|
+
export function earliestQuotaResetAt(
|
|
24
|
+
quota: ProviderQuota | null,
|
|
25
|
+
now: number,
|
|
26
|
+
): number | null {
|
|
27
|
+
if (!quota) return null;
|
|
28
|
+
const future = collectResetCandidates(quota).filter(ts => ts > now);
|
|
29
|
+
if (future.length > 0) return Math.min(...future);
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Milliseconds until the soonest known quota-window reset.
|
|
35
|
+
* Returns Infinity when no quota data exists, quota is stale, or all known
|
|
36
|
+
* reset timestamps have elapsed. An elapsed reset is stale evidence — it
|
|
37
|
+
* does not prove the next request has fresh capacity.
|
|
38
|
+
*/
|
|
39
|
+
export function quotaResetRemainingMs(
|
|
40
|
+
quota: ProviderQuota | null,
|
|
41
|
+
now: number,
|
|
42
|
+
): number {
|
|
43
|
+
const nearest = earliestQuotaResetAt(quota, now);
|
|
44
|
+
if (nearest === null) return Number.POSITIVE_INFINITY;
|
|
45
|
+
return nearest - now;
|
|
46
|
+
}
|
package/src/combos/resolve.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { OcxComboTarget, OcxConfig } from "../types";
|
|
2
|
+
import { getCachedProviderQuota } from "../providers/quota-routing-cache";
|
|
2
3
|
import { coolComboTarget, isComboTargetInCooldown } from "./failover";
|
|
4
|
+
import { quotaResetRemainingMs } from "./reset-window";
|
|
3
5
|
import { getCombo, resolveComboId, targetKey } from "./types";
|
|
4
6
|
import type { NormalizedComboConfig } from "./types";
|
|
5
7
|
import {
|
|
@@ -19,6 +21,7 @@ interface SelectionState {
|
|
|
19
21
|
activeKey?: string;
|
|
20
22
|
successes: number;
|
|
21
23
|
currentWeights: Map<string, number>;
|
|
24
|
+
successfulUses: Map<string, number>;
|
|
22
25
|
}
|
|
23
26
|
|
|
24
27
|
const selectionState = new Map<string, SelectionState>();
|
|
@@ -82,6 +85,36 @@ function smoothWeightedIndex(
|
|
|
82
85
|
return best;
|
|
83
86
|
}
|
|
84
87
|
|
|
88
|
+
/**
|
|
89
|
+
* Select the eligible target whose earliest known quota reset is nearest.
|
|
90
|
+
*
|
|
91
|
+
* Only reads the last successfully cached provider-quota snapshot; it never
|
|
92
|
+
* triggers an upstream quota probe. When no target has fresh reset data,
|
|
93
|
+
* every remaining value is Infinity and configured order becomes the
|
|
94
|
+
* fallback. Targets with elapsed or stale reset timestamps are treated as
|
|
95
|
+
* unknown (Infinity).
|
|
96
|
+
*/
|
|
97
|
+
function resetWindowIndex(
|
|
98
|
+
targets: Required<OcxComboTarget>[],
|
|
99
|
+
eligible: (target: Required<OcxComboTarget>) => boolean,
|
|
100
|
+
now = Date.now(),
|
|
101
|
+
): number {
|
|
102
|
+
let selected = -1;
|
|
103
|
+
let smallestRemaining = Number.POSITIVE_INFINITY;
|
|
104
|
+
for (let index = 0; index < targets.length; index++) {
|
|
105
|
+
const target = targets[index]!;
|
|
106
|
+
if (!eligible(target)) continue;
|
|
107
|
+
const remaining = quotaResetRemainingMs(getCachedProviderQuota(target.provider, now), now);
|
|
108
|
+
// Strict comparison deliberately retains configured order for ties,
|
|
109
|
+
// including the no-snapshot fallback where every value is Infinity.
|
|
110
|
+
if (selected < 0 || remaining < smallestRemaining) {
|
|
111
|
+
selected = index;
|
|
112
|
+
smallestRemaining = remaining;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return selected;
|
|
116
|
+
}
|
|
117
|
+
|
|
85
118
|
export function pickComboTarget(
|
|
86
119
|
config: OcxConfig,
|
|
87
120
|
comboId: string,
|
|
@@ -103,7 +136,7 @@ export function pickComboTarget(
|
|
|
103
136
|
if (combo.strategy === "round-robin") {
|
|
104
137
|
let state = selectionState.get(comboId);
|
|
105
138
|
if (!state) {
|
|
106
|
-
state = { successes: 0, currentWeights: new Map() };
|
|
139
|
+
state = { successes: 0, currentWeights: new Map(), successfulUses: new Map() };
|
|
107
140
|
selectionState.set(comboId, state);
|
|
108
141
|
}
|
|
109
142
|
if (state.activeKey) {
|
|
@@ -120,6 +153,41 @@ export function pickComboTarget(
|
|
|
120
153
|
state.successes = 0;
|
|
121
154
|
}
|
|
122
155
|
}
|
|
156
|
+
} else if (combo.strategy === "random") {
|
|
157
|
+
// Weighted random selection happens independently for every request.
|
|
158
|
+
const eligibleTargets = combo.targets
|
|
159
|
+
.map((target, index) => ({ target, index }))
|
|
160
|
+
.filter(({ target }) => eligible(target));
|
|
161
|
+
if (eligibleTargets.length > 0) {
|
|
162
|
+
const totalWeight = eligibleTargets.reduce((sum, entry) => sum + entry.target.weight, 0);
|
|
163
|
+
let random = Math.random() * totalWeight;
|
|
164
|
+
for (const entry of eligibleTargets) {
|
|
165
|
+
random -= entry.target.weight;
|
|
166
|
+
if (random <= 0) {
|
|
167
|
+
targetIndex = entry.index;
|
|
168
|
+
break;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (targetIndex < 0) targetIndex = eligibleTargets[eligibleTargets.length - 1]!.index;
|
|
172
|
+
}
|
|
173
|
+
} else if (combo.strategy === "least-used") {
|
|
174
|
+
let state = selectionState.get(comboId);
|
|
175
|
+
if (!state) {
|
|
176
|
+
state = { successes: 0, currentWeights: new Map(), successfulUses: new Map() };
|
|
177
|
+
selectionState.set(comboId, state);
|
|
178
|
+
}
|
|
179
|
+
let fewestUses = Number.POSITIVE_INFINITY;
|
|
180
|
+
for (let index = 0; index < combo.targets.length; index++) {
|
|
181
|
+
const target = combo.targets[index]!;
|
|
182
|
+
if (!eligible(target)) continue;
|
|
183
|
+
const uses = state.successfulUses.get(targetKey(target)) ?? 0;
|
|
184
|
+
if (targetIndex < 0 || uses < fewestUses) {
|
|
185
|
+
targetIndex = index;
|
|
186
|
+
fewestUses = uses;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
} else if (combo.strategy === "reset-window") {
|
|
190
|
+
targetIndex = resetWindowIndex(combo.targets, eligible);
|
|
123
191
|
} else {
|
|
124
192
|
targetIndex = combo.targets.findIndex(eligible);
|
|
125
193
|
}
|
|
@@ -141,9 +209,18 @@ export function noteComboSuccess(
|
|
|
141
209
|
target: Required<OcxComboTarget>,
|
|
142
210
|
writerGeneration = captureConfigGeneration(),
|
|
143
211
|
): void {
|
|
144
|
-
if (combo.strategy !== "round-robin") return;
|
|
145
212
|
const key = targetKey(target);
|
|
146
213
|
if (!mayCommitComboState(comboId, key, writerGeneration)) return;
|
|
214
|
+
if (combo.strategy === "least-used") {
|
|
215
|
+
let state = selectionState.get(comboId);
|
|
216
|
+
if (!state) {
|
|
217
|
+
state = { successes: 0, currentWeights: new Map(), successfulUses: new Map() };
|
|
218
|
+
selectionState.set(comboId, state);
|
|
219
|
+
}
|
|
220
|
+
state.successfulUses.set(key, (state.successfulUses.get(key) ?? 0) + 1);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
if (combo.strategy !== "round-robin") return;
|
|
147
224
|
const state = selectionState.get(comboId);
|
|
148
225
|
if (!state || state.activeKey !== key) return;
|
|
149
226
|
state.successes += 1;
|
|
@@ -206,6 +283,11 @@ export function reconcileComboRotationState(context: GenerationContext): number
|
|
|
206
283
|
state.currentWeights.delete(key);
|
|
207
284
|
removed += 1;
|
|
208
285
|
}
|
|
286
|
+
for (const key of state.successfulUses.keys()) {
|
|
287
|
+
if (context.comboTargets.has(comboTargetOwnerKey(comboId, key))) continue;
|
|
288
|
+
state.successfulUses.delete(key);
|
|
289
|
+
removed += 1;
|
|
290
|
+
}
|
|
209
291
|
}
|
|
210
292
|
liveComboTargets = new Set(context.comboTargets);
|
|
211
293
|
lastReconciledGeneration = context.generation;
|
package/src/combos/types.ts
CHANGED
|
@@ -215,8 +215,11 @@ export function comboConfigIssues(
|
|
|
215
215
|
}
|
|
216
216
|
if (body.strategy !== undefined
|
|
217
217
|
&& body.strategy !== "failover"
|
|
218
|
-
&& body.strategy !== "round-robin"
|
|
219
|
-
|
|
218
|
+
&& body.strategy !== "round-robin"
|
|
219
|
+
&& body.strategy !== "random"
|
|
220
|
+
&& body.strategy !== "least-used"
|
|
221
|
+
&& body.strategy !== "reset-window") {
|
|
222
|
+
issues.push({ path: ["strategy"], message: 'strategy must be "failover", "round-robin", "random", "least-used", or "reset-window"' });
|
|
220
223
|
}
|
|
221
224
|
if (body.stickyLimit !== undefined
|
|
222
225
|
&& (typeof body.stickyLimit !== "number" || !Number.isInteger(body.stickyLimit)
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import {
|
|
2
2
|
chmodSync,
|
|
3
|
+
closeSync,
|
|
4
|
+
constants,
|
|
5
|
+
fchmodSync,
|
|
6
|
+
fstatSync,
|
|
3
7
|
lstatSync,
|
|
8
|
+
openSync,
|
|
4
9
|
realpathSync,
|
|
5
10
|
truncateSync,
|
|
6
11
|
unlinkSync,
|
|
@@ -42,6 +47,12 @@ export interface AtomicWriteIO {
|
|
|
42
47
|
unlink: (path: string) => void;
|
|
43
48
|
}
|
|
44
49
|
|
|
50
|
+
export interface AtomicWriteHooks {
|
|
51
|
+
afterTempWrite?: (tempPath: string, targetPath: string) => void;
|
|
52
|
+
beforeRename?: (tempPath: string, targetPath: string) => void;
|
|
53
|
+
validateBeforeRename?: (targetPath: string) => void;
|
|
54
|
+
}
|
|
55
|
+
|
|
45
56
|
export class AtomicWriteResidualTempError extends Error {
|
|
46
57
|
constructor(readonly tempPath: string, readonly hardened = true, options?: ErrorOptions) {
|
|
47
58
|
super(`Atomic config write left a ${hardened ? "hardened " : ""}zero-byte temporary file`, options);
|
|
@@ -92,52 +103,120 @@ function assertResolvedTargetAllowed(path: string, target: string): void {
|
|
|
92
103
|
assertNotRealHomeUnderTest(dirname(target));
|
|
93
104
|
}
|
|
94
105
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
}
|
|
106
|
+
function assertPrivateTempDescriptor(path: string, descriptor: number): void {
|
|
107
|
+
const opened = fstatSync(descriptor);
|
|
108
|
+
const linked = lstatSync(path);
|
|
109
|
+
if (!opened.isFile() || !linked.isFile()
|
|
110
|
+
|| opened.dev !== linked.dev || opened.ino !== linked.ino) {
|
|
111
|
+
throw new Error("atomic temporary file identity changed before write");
|
|
112
|
+
}
|
|
113
|
+
if (process.platform !== "win32" && (opened.mode & 0o777) !== 0o600) {
|
|
114
|
+
throw new Error("atomic temporary file permissions are not owner-only");
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function writePrivateTempFile(
|
|
119
|
+
path: string,
|
|
120
|
+
content: string,
|
|
121
|
+
timeoutMemoKey: string,
|
|
122
|
+
onCreated: () => void,
|
|
123
|
+
): void {
|
|
124
|
+
const descriptor = openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600);
|
|
125
|
+
onCreated();
|
|
126
|
+
try {
|
|
127
|
+
if (process.platform === "win32") {
|
|
128
|
+
hardenSecretPath(path, { required: true, timeoutMemoKey });
|
|
129
|
+
} else {
|
|
130
|
+
fchmodSync(descriptor, 0o600);
|
|
131
|
+
}
|
|
132
|
+
assertPrivateTempDescriptor(path, descriptor);
|
|
133
|
+
writeFileSync(descriptor, content, { encoding: "utf-8" });
|
|
134
|
+
} finally {
|
|
135
|
+
closeSync(descriptor);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function writePrivateTempFileAsync(
|
|
140
|
+
path: string,
|
|
141
|
+
content: string,
|
|
142
|
+
timeoutMemoKey: string,
|
|
143
|
+
onCreated: () => void,
|
|
144
|
+
): Promise<void> {
|
|
145
|
+
const descriptor = openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600);
|
|
146
|
+
onCreated();
|
|
147
|
+
try {
|
|
148
|
+
if (process.platform === "win32") {
|
|
149
|
+
await hardenSecretPathAsync(path, { required: true, timeoutMemoKey });
|
|
150
|
+
} else {
|
|
151
|
+
fchmodSync(descriptor, 0o600);
|
|
152
|
+
}
|
|
153
|
+
assertPrivateTempDescriptor(path, descriptor);
|
|
154
|
+
writeFileSync(descriptor, content, { encoding: "utf-8" });
|
|
155
|
+
} finally {
|
|
156
|
+
closeSync(descriptor);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function atomicWriteFile(
|
|
161
|
+
path: string,
|
|
162
|
+
content: string,
|
|
163
|
+
io?: AtomicWriteIO,
|
|
164
|
+
hooks: AtomicWriteHooks = {},
|
|
165
|
+
): void {
|
|
105
166
|
recordOwnedConfigPath(getConfigDir(), path);
|
|
106
167
|
const target = resolveWriteTarget(path);
|
|
107
168
|
assertResolvedTargetAllowed(path, target);
|
|
108
169
|
const tmp = `${target}.ocx.${process.pid}.${nextAtomicTempSequence()}.tmp`;
|
|
109
170
|
let hardened = false;
|
|
171
|
+
let ownsTemp = false;
|
|
172
|
+
const effective: AtomicWriteIO = io ?? {
|
|
173
|
+
write: (tempPath, value) => writePrivateTempFile(tempPath, value, path, () => { ownsTemp = true; }),
|
|
174
|
+
harden: tempPath => {
|
|
175
|
+
try { chmodSync(tempPath, 0o600); } catch { /* platform may ignore chmod */ }
|
|
176
|
+
if (process.platform === "win32") {
|
|
177
|
+
hardenSecretPath(tempPath, { required: true, timeoutMemoKey: path });
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
rename: renameAtomicFile,
|
|
181
|
+
truncate: tempPath => truncateSync(tempPath, 0),
|
|
182
|
+
unlink: unlinkSync,
|
|
183
|
+
};
|
|
110
184
|
try {
|
|
111
|
-
io
|
|
112
|
-
|
|
185
|
+
if (io) ownsTemp = true;
|
|
186
|
+
effective.write(tmp, content);
|
|
187
|
+
hooks.afterTempWrite?.(tmp, target);
|
|
188
|
+
effective.harden(tmp);
|
|
113
189
|
hardened = true;
|
|
114
|
-
|
|
190
|
+
hooks.beforeRename?.(tmp, target);
|
|
191
|
+
hooks.validateBeforeRename?.(target);
|
|
192
|
+
effective.rename(tmp, target);
|
|
115
193
|
forgetEphemeralSecretPath(tmp);
|
|
116
194
|
} catch (cause) {
|
|
195
|
+
if (!ownsTemp) throw cause;
|
|
117
196
|
let scrubbed = false;
|
|
118
197
|
try {
|
|
119
|
-
|
|
198
|
+
effective.truncate(tmp);
|
|
120
199
|
scrubbed = true;
|
|
121
200
|
} catch (error) {
|
|
122
201
|
if (isMissingPathError(error)) scrubbed = true;
|
|
123
202
|
else {
|
|
124
|
-
try {
|
|
203
|
+
try { effective.write(tmp, ""); scrubbed = true; } catch { /* removal may still succeed */ }
|
|
125
204
|
}
|
|
126
205
|
}
|
|
127
206
|
let removed = false;
|
|
128
207
|
try {
|
|
129
|
-
|
|
208
|
+
effective.unlink(tmp);
|
|
130
209
|
removed = true;
|
|
131
210
|
} catch (error) {
|
|
132
211
|
if (isMissingPathError(error)) removed = true;
|
|
133
212
|
else {
|
|
134
|
-
try {
|
|
213
|
+
try { effective.unlink(tmp); removed = true; }
|
|
135
214
|
catch (retryError) { if (isMissingPathError(retryError)) removed = true; }
|
|
136
215
|
}
|
|
137
216
|
}
|
|
138
217
|
if (!removed && !scrubbed) throw new AtomicWriteSecretResidualError(tmp, { cause });
|
|
139
218
|
if (!removed && !hardened) {
|
|
140
|
-
try {
|
|
219
|
+
try { effective.harden(tmp); hardened = true; } catch { /* reported below */ }
|
|
141
220
|
}
|
|
142
221
|
if (removed) forgetEphemeralSecretPath(tmp);
|
|
143
222
|
if (!removed) throw new AtomicWriteResidualTempError(tmp, hardened, { cause });
|
|
@@ -163,12 +242,13 @@ export async function atomicWriteFileAsync(
|
|
|
163
242
|
io?: AtomicWriteAsyncIO,
|
|
164
243
|
testSeam?: AtomicWriteAsyncTestSeam,
|
|
165
244
|
): Promise<void> {
|
|
245
|
+
let ownsTemp = false;
|
|
166
246
|
const effective: AtomicWriteAsyncIO = io ?? {
|
|
167
|
-
write: (
|
|
168
|
-
harden: async
|
|
169
|
-
try { chmodSync(
|
|
247
|
+
write: (tempPath, value) => writePrivateTempFileAsync(tempPath, value, path, () => { ownsTemp = true; }),
|
|
248
|
+
harden: async tempPath => {
|
|
249
|
+
try { chmodSync(tempPath, 0o600); } catch { /* platform may ignore chmod */ }
|
|
170
250
|
if (process.platform === "win32") {
|
|
171
|
-
await hardenSecretPathAsync(
|
|
251
|
+
await hardenSecretPathAsync(tempPath, { required: true, timeoutMemoKey: path });
|
|
172
252
|
}
|
|
173
253
|
},
|
|
174
254
|
rename: renameAtomicFileAsync,
|
|
@@ -180,6 +260,7 @@ export async function atomicWriteFileAsync(
|
|
|
180
260
|
const tmp = `${target}.ocx.${process.pid}.${nextAtomicTempSequence()}.tmp`;
|
|
181
261
|
let hardened = false;
|
|
182
262
|
try {
|
|
263
|
+
if (io) ownsTemp = true;
|
|
183
264
|
await effective.write(tmp, content);
|
|
184
265
|
await testSeam?.afterTempWrite?.(tmp);
|
|
185
266
|
await effective.harden(tmp);
|
|
@@ -187,6 +268,7 @@ export async function atomicWriteFileAsync(
|
|
|
187
268
|
await effective.rename(tmp, target);
|
|
188
269
|
forgetEphemeralSecretPath(tmp);
|
|
189
270
|
} catch (cause) {
|
|
271
|
+
if (!ownsTemp) throw cause;
|
|
190
272
|
let scrubbed = false;
|
|
191
273
|
try {
|
|
192
274
|
await effective.truncate(tmp);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers";
|
|
2
|
+
import { redactSecretString } from "../lib/redact";
|
|
2
3
|
import { modelRecordValue } from "../reasoning-effort";
|
|
3
4
|
import {
|
|
4
5
|
isWirePinnedModel,
|
|
@@ -123,6 +124,16 @@ export function booleanRecordConfigError(value: unknown, field: string): string
|
|
|
123
124
|
return null;
|
|
124
125
|
}
|
|
125
126
|
|
|
127
|
+
/** Validate the management DTO boundary for the opt-in empty-tool-output annotation. */
|
|
128
|
+
export function providerEmptyToolOutputConfigError(name: string, provider: unknown): string | null {
|
|
129
|
+
const raw = provider as Record<string, unknown> | null | undefined;
|
|
130
|
+
const value = raw === null || raw === undefined ? undefined : raw.annotateEmptyToolOutputs;
|
|
131
|
+
if (value !== undefined && typeof value !== "boolean") {
|
|
132
|
+
return `provider ${JSON.stringify(redactSecretString(name))} annotateEmptyToolOutputs must be a boolean`;
|
|
133
|
+
}
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
|
|
126
137
|
export function reasoningSummaryDeliveryRecordConfigError(
|
|
127
138
|
value: unknown,
|
|
128
139
|
supportsReasoningSummaries: unknown,
|