@bitkyc08/opencodex 2.35.0 → 2.36.0-preview.20260830
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-DPl4nBMA.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-DNdRKXK9.js +0 -112
- package/gui/dist/assets/index-DQ-Ie18T.css +0 -1
|
@@ -33,6 +33,7 @@ import { existsSync, statSync } from "node:fs";
|
|
|
33
33
|
import { env, platform } from "node:process";
|
|
34
34
|
import { resolveTrustedWindowsIcaclsExe } from "./windows-elevation";
|
|
35
35
|
import {
|
|
36
|
+
cachedCurrentWindowsIdentity,
|
|
36
37
|
resolveCurrentWindowsPrincipal,
|
|
37
38
|
resolveCurrentWindowsPrincipalAsync,
|
|
38
39
|
setSyntheticWindowsPrincipalForTests,
|
|
@@ -500,6 +501,69 @@ function grantAce(user: string, directory: boolean): string {
|
|
|
500
501
|
return directory ? `${user}:(OI)(CI)(F)` : `${user}:(F)`;
|
|
501
502
|
}
|
|
502
503
|
|
|
504
|
+
function existingAclIsCompliant(
|
|
505
|
+
targetPath: string,
|
|
506
|
+
directory: boolean,
|
|
507
|
+
stdout: string,
|
|
508
|
+
ownerName: string,
|
|
509
|
+
): boolean {
|
|
510
|
+
const lines = stdout.replaceAll("\r", "").split("\n");
|
|
511
|
+
const first = lines.shift();
|
|
512
|
+
if (!first || first.slice(0, targetPath.length).toLowerCase() !== targetPath.toLowerCase()) {
|
|
513
|
+
return false;
|
|
514
|
+
}
|
|
515
|
+
const separator = first[targetPath.length];
|
|
516
|
+
if (separator !== undefined && !/\s/.test(separator)) return false;
|
|
517
|
+
|
|
518
|
+
const aceLines: string[] = [];
|
|
519
|
+
const firstAce = first.slice(targetPath.length).trim();
|
|
520
|
+
if (firstAce) aceLines.push(firstAce);
|
|
521
|
+
for (const line of lines) {
|
|
522
|
+
if (!line.trim()) break;
|
|
523
|
+
// Localized summary text is not indented like a continuation ACE.
|
|
524
|
+
if (!/^\s/.test(line)) break;
|
|
525
|
+
aceLines.push(line.trim());
|
|
526
|
+
}
|
|
527
|
+
if (aceLines.length !== 1) return false;
|
|
528
|
+
|
|
529
|
+
const match = /^([^:]+):((?:\([A-Z]+\))+)$/.exec(aceLines[0]!);
|
|
530
|
+
if (!match || match[1]!.trim().toLowerCase() !== ownerName.toLowerCase()) return false;
|
|
531
|
+
const rights = [...match[2]!.matchAll(/\(([A-Z]+)\)/g)].map(part => part[1]);
|
|
532
|
+
const expected = directory ? ["OI", "CI", "F"] : ["F"];
|
|
533
|
+
return rights.length === expected.length && rights.every((right, index) => right === expected[index]);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function shouldVerifyExistingAcl(): boolean {
|
|
537
|
+
return env["OPENCODEX_ACL_VERIFY_EXISTING"] === "1";
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function existingAclAlreadyCompliant(targetPath: string, directory: boolean): boolean {
|
|
541
|
+
if (!shouldVerifyExistingAcl()) return false;
|
|
542
|
+
const identity = cachedCurrentWindowsIdentity();
|
|
543
|
+
if (!identity) return false;
|
|
544
|
+
try {
|
|
545
|
+
const result = icaclsRunner([targetPath], resolveHardenDeadlineMs());
|
|
546
|
+
return result.success && existingAclIsCompliant(targetPath, directory, result.stdout, identity.name);
|
|
547
|
+
} catch {
|
|
548
|
+
return false;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
async function existingAclAlreadyCompliantAsync(
|
|
553
|
+
targetPath: string,
|
|
554
|
+
directory: boolean,
|
|
555
|
+
): Promise<boolean> {
|
|
556
|
+
if (!shouldVerifyExistingAcl()) return false;
|
|
557
|
+
const identity = cachedCurrentWindowsIdentity();
|
|
558
|
+
if (!identity) return false;
|
|
559
|
+
try {
|
|
560
|
+
const result = await asyncIcaclsRunner([targetPath], resolveHardenDeadlineMs());
|
|
561
|
+
return result.success && existingAclIsCompliant(targetPath, directory, result.stdout, identity.name);
|
|
562
|
+
} catch {
|
|
563
|
+
return false;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
503
567
|
function runIcacls(targetPath: string, directory: boolean, deadline: number): void {
|
|
504
568
|
const principal = currentWindowsPrincipal(deadline);
|
|
505
569
|
|
|
@@ -724,6 +788,7 @@ function hardenEntry(
|
|
|
724
788
|
if (!existsSync(targetPath)) { cache.delete(targetPath); return { ok: true }; }
|
|
725
789
|
if (effectivePlatform() !== "win32") return { ok: true };
|
|
726
790
|
if (memoSatisfied(cache, targetPath)) return { ok: true };
|
|
791
|
+
if (existingAclAlreadyCompliant(targetPath, directory)) return { ok: true };
|
|
727
792
|
const memoKey = timeoutMemoKey(targetPath, opts);
|
|
728
793
|
const timeoutMemoError = timeoutMemoErrorIfBlocked(memoKey, opts);
|
|
729
794
|
if (timeoutMemoError) {
|
|
@@ -776,6 +841,7 @@ async function hardenEntryAsync(
|
|
|
776
841
|
if (!existsSync(targetPath)) { cache.delete(targetPath); return { ok: true }; }
|
|
777
842
|
if (effectivePlatform() !== "win32") return { ok: true };
|
|
778
843
|
if (memoSatisfied(cache, targetPath)) return { ok: true };
|
|
844
|
+
if (await existingAclAlreadyCompliantAsync(targetPath, directory)) return { ok: true };
|
|
779
845
|
const memoKey = timeoutMemoKey(targetPath, opts);
|
|
780
846
|
const timeoutMemoError = timeoutMemoErrorIfBlocked(memoKey, opts);
|
|
781
847
|
if (timeoutMemoError) {
|
package/src/lib/windows-text.ts
CHANGED
|
@@ -53,14 +53,40 @@ function decodeUtf16Be(buffer: Uint8Array): string {
|
|
|
53
53
|
* Western fallback deliberately narrow: treating CP932, CP1250, or CP1251
|
|
54
54
|
* bytes as Windows-1252 can fabricate a different valid-looking filesystem
|
|
55
55
|
* path, which is worse than the previous replacement-character refusal.
|
|
56
|
+
*
|
|
57
|
+
* The CJK double-byte pages are named for the same reason `euc-kr` is: they are
|
|
58
|
+
* the ANSI code page on their own hosts, they are unambiguous for that language
|
|
59
|
+
* tag, and `decodeStrict` rejects a mismatch instead of inventing a path. A
|
|
60
|
+
* zh-CN host's schtasks stderr is CP936 (`gbk`), which the UTF-8 attempt above
|
|
61
|
+
* fails on and which previously fell through to a lossy UTF-8 decode — the
|
|
62
|
+
* mojibake made every localized message unmatchable (#2914).
|
|
63
|
+
*
|
|
64
|
+
* zh-Hant is a separate page (`big5`), not a variant of the same one, so the
|
|
65
|
+
* region subtag decides: `zh-TW`/`zh-HK`/`zh-MO` are Big5, bare `zh` and
|
|
66
|
+
* `zh-CN`/`zh-SG` are GBK. Guessing wrong here is exactly the fabricated-path
|
|
67
|
+
* risk the Western note describes, so an unrecognized `zh-*` region keeps the
|
|
68
|
+
* mainland default rather than trying both.
|
|
56
69
|
*/
|
|
57
|
-
function legacyEncodingForLocale(locale: string):
|
|
58
|
-
const
|
|
70
|
+
function legacyEncodingForLocale(locale: string): LegacyWindowsEncoding | null {
|
|
71
|
+
const parts = locale.trim().split(/[-_]/);
|
|
72
|
+
const language = parts[0]?.toLowerCase();
|
|
59
73
|
if (language === "ko") return "euc-kr";
|
|
74
|
+
if (language === "ja") return "shift_jis";
|
|
75
|
+
if (language === "zh") return traditionalChineseRegion(parts) ? "big5" : "gbk";
|
|
60
76
|
if (language && WINDOWS_1252_LANGUAGES.has(language)) return "windows-1252";
|
|
61
77
|
return null;
|
|
62
78
|
}
|
|
63
79
|
|
|
80
|
+
type LegacyWindowsEncoding = "euc-kr" | "shift_jis" | "gbk" | "big5" | "windows-1252";
|
|
81
|
+
|
|
82
|
+
/** `zh-Hant`, or a region that ships Big5 as its ANSI code page. */
|
|
83
|
+
function traditionalChineseRegion(parts: readonly string[]): boolean {
|
|
84
|
+
return parts.slice(1).some(part => {
|
|
85
|
+
const tag = part.toLowerCase();
|
|
86
|
+
return tag === "hant" || tag === "tw" || tag === "hk" || tag === "mo";
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
64
90
|
const WINDOWS_1252_LANGUAGES = new Set([
|
|
65
91
|
"af", "br", "ca", "co", "cy", "da", "de", "en", "es", "eu", "fi", "fo", "fr",
|
|
66
92
|
"ga", "gd", "gl", "id", "is", "it", "lb", "ms", "nl", "no", "oc", "pt", "sq",
|
|
@@ -29,8 +29,8 @@ import {
|
|
|
29
29
|
} from "./windows-elevation";
|
|
30
30
|
|
|
31
31
|
const SID_PATTERN = /^S-1-(?:\d+-)+\d+$/i;
|
|
32
|
-
const
|
|
33
|
-
"[System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value";
|
|
32
|
+
const IDENTITY_EXPRESSION =
|
|
33
|
+
"$identity=[System.Security.Principal.WindowsIdentity]::GetCurrent();$identity.User.Value;$identity.Name";
|
|
34
34
|
const DEFAULT_WINDOWS_ARM64_POWERSHELL = windowsPath.join(
|
|
35
35
|
"C:\\Windows\\System32",
|
|
36
36
|
"WindowsPowerShell",
|
|
@@ -106,7 +106,7 @@ const POWERSHELL_ARGS = [
|
|
|
106
106
|
"-NoProfile",
|
|
107
107
|
"-NonInteractive",
|
|
108
108
|
"-Command",
|
|
109
|
-
|
|
109
|
+
IDENTITY_EXPRESSION,
|
|
110
110
|
] as const;
|
|
111
111
|
|
|
112
112
|
function windowsPrincipalPowerShellCommand(): string[] {
|
|
@@ -167,7 +167,12 @@ async function defaultAsyncWindowsPrincipalRunner(
|
|
|
167
167
|
|
|
168
168
|
let principalRunner: WindowsPrincipalRunner = defaultWindowsPrincipalRunner;
|
|
169
169
|
let asyncPrincipalRunner: AsyncWindowsPrincipalRunner = defaultAsyncWindowsPrincipalRunner;
|
|
170
|
-
|
|
170
|
+
export interface WindowsPrincipalIdentity {
|
|
171
|
+
readonly sid: string;
|
|
172
|
+
readonly name: string;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
let cachedIdentity: WindowsPrincipalIdentity | null = null;
|
|
171
176
|
let asyncLookupInFlight: Promise<string> | null = null;
|
|
172
177
|
|
|
173
178
|
/**
|
|
@@ -191,7 +196,7 @@ let syntheticPrincipalForTests: string | null = null;
|
|
|
191
196
|
*/
|
|
192
197
|
export function setSyntheticWindowsPrincipalForTests(principal: string | null): void {
|
|
193
198
|
syntheticPrincipalForTests = principal;
|
|
194
|
-
|
|
199
|
+
cachedIdentity = null;
|
|
195
200
|
}
|
|
196
201
|
|
|
197
202
|
/** True when an explicit runner override is installed and must take precedence. */
|
|
@@ -211,17 +216,27 @@ function identityError(reason: string): NodeJS.ErrnoException {
|
|
|
211
216
|
return error;
|
|
212
217
|
}
|
|
213
218
|
|
|
214
|
-
function
|
|
219
|
+
function identityFromResult(result: WindowsPrincipalLookupResult): WindowsPrincipalIdentity {
|
|
215
220
|
if (!result.success) {
|
|
216
221
|
throw identityError(result.timedOut
|
|
217
222
|
? "timed out"
|
|
218
223
|
: `exited ${result.exitCode ?? "null"}`);
|
|
219
224
|
}
|
|
220
|
-
const
|
|
225
|
+
const lines = result.stdout.trim().split(/\r?\n/);
|
|
226
|
+
const sid = lines[0]?.trim() ?? "";
|
|
227
|
+
const name = lines[1]?.trim() ?? "";
|
|
221
228
|
if (!SID_PATTERN.test(sid)) {
|
|
222
229
|
throw identityError(sid ? "returned an invalid SID" : "returned an empty SID");
|
|
223
230
|
}
|
|
224
|
-
|
|
231
|
+
if (!name || lines.length !== 2) {
|
|
232
|
+
throw identityError(name ? "returned an ambiguous account name" : "returned an empty account name");
|
|
233
|
+
}
|
|
234
|
+
return Object.freeze({ sid: sid.toUpperCase(), name });
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Read the effective-token identity only when an earlier lookup already cached it. */
|
|
238
|
+
export function cachedCurrentWindowsIdentity(): WindowsPrincipalIdentity | null {
|
|
239
|
+
return cachedIdentity;
|
|
225
240
|
}
|
|
226
241
|
|
|
227
242
|
/** Resolve and process-cache the effective token SID for synchronous ACL paths. */
|
|
@@ -229,7 +244,7 @@ export function resolveCurrentWindowsPrincipal(timeoutMs: number): string {
|
|
|
229
244
|
// Order matters: an explicitly injected runner outranks the synthetic value,
|
|
230
245
|
// so a test can inject a FAILURE on a POSIX host. See the seam comment above.
|
|
231
246
|
if (hasSyncRunnerOverride()) {
|
|
232
|
-
if (
|
|
247
|
+
if (cachedIdentity) return `*${cachedIdentity.sid}`;
|
|
233
248
|
if (timeoutMs <= 0) throw identityError("had no remaining deadline");
|
|
234
249
|
let overridden: WindowsPrincipalLookupResult;
|
|
235
250
|
try {
|
|
@@ -237,11 +252,10 @@ export function resolveCurrentWindowsPrincipal(timeoutMs: number): string {
|
|
|
237
252
|
} catch {
|
|
238
253
|
throw identityError("could not start");
|
|
239
254
|
}
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
return principal;
|
|
255
|
+
cachedIdentity = identityFromResult(overridden);
|
|
256
|
+
return `*${cachedIdentity.sid}`;
|
|
243
257
|
}
|
|
244
|
-
if (
|
|
258
|
+
if (cachedIdentity) return `*${cachedIdentity.sid}`;
|
|
245
259
|
if (syntheticPrincipalForTests) return syntheticPrincipalForTests;
|
|
246
260
|
if (timeoutMs <= 0) throw identityError("had no remaining deadline");
|
|
247
261
|
let result: WindowsPrincipalLookupResult;
|
|
@@ -250,9 +264,8 @@ export function resolveCurrentWindowsPrincipal(timeoutMs: number): string {
|
|
|
250
264
|
} catch {
|
|
251
265
|
throw identityError("could not start");
|
|
252
266
|
}
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
return principal;
|
|
267
|
+
cachedIdentity = identityFromResult(result);
|
|
268
|
+
return `*${cachedIdentity.sid}`;
|
|
256
269
|
}
|
|
257
270
|
|
|
258
271
|
async function waitForExistingLookup(
|
|
@@ -284,7 +297,7 @@ async function waitForExistingLookup(
|
|
|
284
297
|
*/
|
|
285
298
|
export async function resolveCurrentWindowsPrincipalAsync(timeoutMs: number): Promise<string> {
|
|
286
299
|
const overridden = hasAsyncRunnerOverride();
|
|
287
|
-
if (
|
|
300
|
+
if (cachedIdentity) return `*${cachedIdentity.sid}`;
|
|
288
301
|
if (asyncLookupInFlight) return waitForExistingLookup(asyncLookupInFlight, timeoutMs);
|
|
289
302
|
// Same precedence rule as the sync path: an injected runner beats the synthetic.
|
|
290
303
|
if (!overridden && syntheticPrincipalForTests) return syntheticPrincipalForTests;
|
|
@@ -297,9 +310,8 @@ export async function resolveCurrentWindowsPrincipalAsync(timeoutMs: number): Pr
|
|
|
297
310
|
} catch {
|
|
298
311
|
throw identityError("could not start");
|
|
299
312
|
}
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
return principal;
|
|
313
|
+
cachedIdentity = identityFromResult(result);
|
|
314
|
+
return `*${cachedIdentity.sid}`;
|
|
303
315
|
})();
|
|
304
316
|
asyncLookupInFlight = lookup;
|
|
305
317
|
try {
|
|
@@ -317,7 +329,7 @@ export function setWindowsPrincipalRunnerForTests(
|
|
|
317
329
|
throw new Error("Cannot replace the Windows principal runner while a lookup is in flight.");
|
|
318
330
|
}
|
|
319
331
|
principalRunner = runner ?? defaultWindowsPrincipalRunner;
|
|
320
|
-
|
|
332
|
+
cachedIdentity = null;
|
|
321
333
|
}
|
|
322
334
|
|
|
323
335
|
/** Test seam: replace the async resolver process and clear its successful cache. */
|
|
@@ -328,7 +340,7 @@ export function setAsyncWindowsPrincipalRunnerForTests(
|
|
|
328
340
|
throw new Error("Cannot replace the Windows principal runner while a lookup is in flight.");
|
|
329
341
|
}
|
|
330
342
|
asyncPrincipalRunner = runner ?? defaultAsyncWindowsPrincipalRunner;
|
|
331
|
-
|
|
343
|
+
cachedIdentity = null;
|
|
332
344
|
}
|
|
333
345
|
|
|
334
346
|
/** Test seam: clear only process-local principal state. */
|
|
@@ -336,6 +348,6 @@ export function resetWindowsPrincipalForTests(): void {
|
|
|
336
348
|
if (asyncLookupInFlight) {
|
|
337
349
|
throw new Error("Cannot reset the Windows principal while a lookup is in flight.");
|
|
338
350
|
}
|
|
339
|
-
|
|
351
|
+
cachedIdentity = null;
|
|
340
352
|
syntheticPrincipalForTests = null;
|
|
341
353
|
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Order failover candidates by what we know about their remaining allowance.
|
|
3
|
+
*
|
|
4
|
+
* Rotation without this walks the roster blind: the account right after the one that just
|
|
5
|
+
* 429'd may itself be spent, so the request burns a second rotation from a budget of three
|
|
6
|
+
* to learn what a cached quota row already knew.
|
|
7
|
+
*
|
|
8
|
+
* Deliberately NOT a scoring function. Percentages from different providers measure
|
|
9
|
+
* different things, and a weight would invite tuning a number nobody can validate. Three
|
|
10
|
+
* categories answer the only question rotation asks — "which of these is most likely to
|
|
11
|
+
* serve the retry" — and within the healthy group a simple headroom sort is enough.
|
|
12
|
+
*/
|
|
13
|
+
import { getCachedProviderAccountQuota } from "../providers/quota";
|
|
14
|
+
import { getKiroAccountExhaustion } from "../providers/kiro-usage";
|
|
15
|
+
|
|
16
|
+
/** Lower sorts earlier. Unknown sits between measured-healthy and measured-empty. */
|
|
17
|
+
const RANK_HEALTHY = 0;
|
|
18
|
+
const RANK_UNKNOWN = 1;
|
|
19
|
+
const RANK_EXHAUSTED = 2;
|
|
20
|
+
|
|
21
|
+
interface Ranked {
|
|
22
|
+
id: string;
|
|
23
|
+
bucket: number;
|
|
24
|
+
/** Remaining percentage points, descending within the healthy bucket. */
|
|
25
|
+
headroom: number;
|
|
26
|
+
/** Preserves the caller's ring order for ties. */
|
|
27
|
+
index: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Remaining headroom across every window the provider reports.
|
|
32
|
+
*
|
|
33
|
+
* The minimum wins: an account at 5% of its five-hour window is unusable right now even if
|
|
34
|
+
* its monthly allowance is barely touched.
|
|
35
|
+
*/
|
|
36
|
+
function headroomOf(provider: string, accountId: string): number | null {
|
|
37
|
+
const quota = getCachedProviderAccountQuota(provider, accountId);
|
|
38
|
+
if (!quota) return null;
|
|
39
|
+
const percents = [
|
|
40
|
+
quota.fiveHourPercent,
|
|
41
|
+
quota.weeklyPercent,
|
|
42
|
+
quota.monthlyPercent,
|
|
43
|
+
...(quota.customWindows ?? []).map(window => window.percent),
|
|
44
|
+
].filter((value): value is number => typeof value === "number");
|
|
45
|
+
if (percents.length === 0) return null;
|
|
46
|
+
return 100 - Math.max(...percents);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Order candidates best-first.
|
|
51
|
+
*
|
|
52
|
+
* Returns the input untouched when no candidate has quota evidence, which keeps every
|
|
53
|
+
* provider without per-account quota on exactly the behaviour it has today.
|
|
54
|
+
*/
|
|
55
|
+
export function rankAccountsByHeadroom(provider: string, ring: readonly string[]): string[] {
|
|
56
|
+
if (ring.length < 2) return [...ring];
|
|
57
|
+
|
|
58
|
+
let sawEvidence = false;
|
|
59
|
+
const ranked: Ranked[] = ring.map((id, index) => {
|
|
60
|
+
// A provider-declared exhaustion verdict outranks the percentage: an account may sit at
|
|
61
|
+
// 100% and still be servable when overage is enabled, and the verdict knows that.
|
|
62
|
+
const exhaustion = provider === "kiro" ? getKiroAccountExhaustion(`${provider}\u0000${id}`) : null;
|
|
63
|
+
const headroom = headroomOf(provider, id);
|
|
64
|
+
if (exhaustion !== null || headroom !== null) sawEvidence = true;
|
|
65
|
+
|
|
66
|
+
if (exhaustion?.exhausted === true) return { id, bucket: RANK_EXHAUSTED, headroom: 0, index };
|
|
67
|
+
if (headroom === null) return { id, bucket: RANK_UNKNOWN, headroom: 0, index };
|
|
68
|
+
return { id, bucket: RANK_HEALTHY, headroom, index };
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
if (!sawEvidence) return [...ring];
|
|
72
|
+
|
|
73
|
+
return ranked
|
|
74
|
+
.sort((a, b) => (a.bucket - b.bucket) || (b.headroom - a.headroom) || (a.index - b.index))
|
|
75
|
+
.map(entry => entry.id);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Do we hold any measurement at all for these accounts?
|
|
80
|
+
*
|
|
81
|
+
* Ranking a single candidate is trivially the identity, which makes it useless as an
|
|
82
|
+
* evidence test: a caller that has already filtered its list down to one account would be
|
|
83
|
+
* told "ranked" when nothing was measured. Pre-dispatch selection asks this first so it
|
|
84
|
+
* can decline to act on a roster it knows nothing about.
|
|
85
|
+
*/
|
|
86
|
+
export function hasHeadroomEvidence(provider: string, ids: readonly string[]): boolean {
|
|
87
|
+
return ids.some(id =>
|
|
88
|
+
headroomOf(provider, id) !== null
|
|
89
|
+
|| (provider === "kiro" && getKiroAccountExhaustion(`${provider}\u0000${id}`) !== null));
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* How long to cool an account that just 429'd, when we know its allowance is spent.
|
|
93
|
+
*
|
|
94
|
+
* A monthly-exhausted account retried every minute is pure waste, but an upstream reset
|
|
95
|
+
* date is not something to trust unbounded — the clamp keeps a bogus far-future value from
|
|
96
|
+
* parking an account for weeks, and a near-instant one from being pointless.
|
|
97
|
+
*/
|
|
98
|
+
const MIN_EXHAUSTED_COOLDOWN_MS = 5 * 60_000;
|
|
99
|
+
const MAX_EXHAUSTED_COOLDOWN_MS = 24 * 60 * 60_000;
|
|
100
|
+
|
|
101
|
+
export function exhaustedCooldownMs(provider: string, accountId: string, now = Date.now()): number | null {
|
|
102
|
+
if (provider !== "kiro") return null;
|
|
103
|
+
const exhaustion = getKiroAccountExhaustion(`${provider}\u0000${accountId}`, now);
|
|
104
|
+
if (!exhaustion?.exhausted) return null;
|
|
105
|
+
const untilReset = exhaustion.nextResetAt === undefined ? MIN_EXHAUSTED_COOLDOWN_MS : exhaustion.nextResetAt - now;
|
|
106
|
+
return Math.min(Math.max(untilReset, MIN_EXHAUSTED_COOLDOWN_MS), MAX_EXHAUSTED_COOLDOWN_MS);
|
|
107
|
+
}
|
|
@@ -27,7 +27,7 @@ import {
|
|
|
27
27
|
POOL_KEY_ANTHROPIC,
|
|
28
28
|
seedPoolRotationAccount,
|
|
29
29
|
} from "../codex/pool-rotation";
|
|
30
|
-
import type { OcxAccountPoolRotationStrategy, OcxConfig } from "../types";
|
|
30
|
+
import type { OcxAccountPoolQuotaWindow, OcxAccountPoolRotationStrategy, OcxConfig } from "../types";
|
|
31
31
|
import { sweepExpiredOnWrite } from "../lib/state-store-sweeper";
|
|
32
32
|
import { retainedUtf8Bytes } from "../lib/admission";
|
|
33
33
|
|
|
@@ -39,6 +39,8 @@ const MAX_AFFINITY_ENTRIES = 2_000;
|
|
|
39
39
|
const MAX_AFFINITY_COMPONENT_BYTES = 512;
|
|
40
40
|
const UNKNOWN_USAGE_SCORE = 100;
|
|
41
41
|
const DEFAULT_AUTO_SWITCH_THRESHOLD = 80;
|
|
42
|
+
const DEFAULT_QUOTA_WINDOW: OcxAccountPoolQuotaWindow = "five-hour";
|
|
43
|
+
const VALID_QUOTA_WINDOWS = new Set<OcxAccountPoolQuotaWindow>(["five-hour", "weekly", "max-utilization"]);
|
|
42
44
|
/** Cap same-request 429 rotations so short Retry-After cannot infinite-loop. */
|
|
43
45
|
export const ANTHROPIC_POOL_MAX_FAILOVERS_PER_REQUEST = 3;
|
|
44
46
|
|
|
@@ -50,6 +52,8 @@ export interface AnthropicAccountPoolConfig {
|
|
|
50
52
|
strategy?: OcxAccountPoolRotationStrategy;
|
|
51
53
|
/** Successful new-session binds retained on one round-robin selection. Default 1; range 1..100. */
|
|
52
54
|
stickyLimit?: number;
|
|
55
|
+
/** Usage window for quota-based scoring. Default "five-hour" (today's behaviour). */
|
|
56
|
+
quotaWindow?: OcxAccountPoolQuotaWindow;
|
|
53
57
|
}
|
|
54
58
|
|
|
55
59
|
interface AccountHealth {
|
|
@@ -86,6 +90,22 @@ export function anthropicAutoSwitchThreshold(config: OcxConfig): number {
|
|
|
86
90
|
return DEFAULT_AUTO_SWITCH_THRESHOLD;
|
|
87
91
|
}
|
|
88
92
|
|
|
93
|
+
/** Strict parse for management APIs — returns null instead of defaulting. */
|
|
94
|
+
export function parseAccountPoolQuotaWindow(raw: unknown): OcxAccountPoolQuotaWindow | null {
|
|
95
|
+
if (typeof raw === "string" && VALID_QUOTA_WINDOWS.has(raw as OcxAccountPoolQuotaWindow)) {
|
|
96
|
+
return raw as OcxAccountPoolQuotaWindow;
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function normalizeAccountPoolQuotaWindow(raw: unknown): OcxAccountPoolQuotaWindow {
|
|
102
|
+
return parseAccountPoolQuotaWindow(raw) ?? DEFAULT_QUOTA_WINDOW;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function anthropicQuotaWindow(config: AnthropicAccountPoolConfig): OcxAccountPoolQuotaWindow {
|
|
106
|
+
return normalizeAccountPoolQuotaWindow(config.quotaWindow);
|
|
107
|
+
}
|
|
108
|
+
|
|
89
109
|
function parseRetryAfterMs(value: string | null | undefined, now: number): number | undefined {
|
|
90
110
|
const text = value?.trim();
|
|
91
111
|
if (!text) return undefined;
|
|
@@ -142,17 +162,56 @@ function isCooled(accountId: string, now: number): boolean {
|
|
|
142
162
|
return getAnthropicAccountHealthSnapshot(accountId, now) !== null;
|
|
143
163
|
}
|
|
144
164
|
|
|
145
|
-
function
|
|
146
|
-
const
|
|
147
|
-
return typeof
|
|
165
|
+
function fiveHourKnown(accountId: string): boolean {
|
|
166
|
+
const percent = getCachedProviderAccountQuota(PROVIDER, accountId)?.fiveHourPercent;
|
|
167
|
+
return typeof percent === "number" && Number.isFinite(percent);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function weeklyKnown(accountId: string): boolean {
|
|
171
|
+
const percent = getCachedProviderAccountQuota(PROVIDER, accountId)?.weeklyPercent;
|
|
172
|
+
return typeof percent === "number" && Number.isFinite(percent);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function fiveHourScore(accountId: string): number {
|
|
176
|
+
const percent = getCachedProviderAccountQuota(PROVIDER, accountId)?.fiveHourPercent;
|
|
177
|
+
return typeof percent === "number" && Number.isFinite(percent)
|
|
178
|
+
? Math.max(0, Math.min(100, percent))
|
|
179
|
+
: UNKNOWN_USAGE_SCORE;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function weeklyScore(accountId: string): number {
|
|
183
|
+
const percent = getCachedProviderAccountQuota(PROVIDER, accountId)?.weeklyPercent;
|
|
184
|
+
return typeof percent === "number" && Number.isFinite(percent)
|
|
185
|
+
? Math.max(0, Math.min(100, percent))
|
|
186
|
+
: UNKNOWN_USAGE_SCORE;
|
|
148
187
|
}
|
|
149
188
|
|
|
150
|
-
function
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
189
|
+
function exhausted5h(accountId: string): boolean {
|
|
190
|
+
return fiveHourKnown(accountId) && fiveHourScore(accountId) >= 100;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function hasKnownUsage(config: OcxConfig, accountId: string): boolean {
|
|
194
|
+
const window = anthropicQuotaWindow(anthropicAccountPoolConfig(config));
|
|
195
|
+
switch (window) {
|
|
196
|
+
case "five-hour": return fiveHourKnown(accountId);
|
|
197
|
+
case "weekly": return weeklyKnown(accountId);
|
|
198
|
+
case "max-utilization": return fiveHourKnown(accountId) || weeklyKnown(accountId);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function usageScore(config: OcxConfig, accountId: string): number {
|
|
203
|
+
const window = anthropicQuotaWindow(anthropicAccountPoolConfig(config));
|
|
204
|
+
switch (window) {
|
|
205
|
+
case "five-hour": return fiveHourScore(accountId);
|
|
206
|
+
case "weekly": return weeklyScore(accountId);
|
|
207
|
+
case "max-utilization": {
|
|
208
|
+
const scores = [
|
|
209
|
+
...(fiveHourKnown(accountId) ? [fiveHourScore(accountId)] : []),
|
|
210
|
+
...(weeklyKnown(accountId) ? [weeklyScore(accountId)] : []),
|
|
211
|
+
];
|
|
212
|
+
return scores.length > 0 ? Math.max(...scores) : UNKNOWN_USAGE_SCORE;
|
|
213
|
+
}
|
|
154
214
|
}
|
|
155
|
-
return Math.max(0, Math.min(100, quota.fiveHourPercent));
|
|
156
215
|
}
|
|
157
216
|
|
|
158
217
|
const TOKEN_SKEW_MS = 60_000;
|
|
@@ -191,20 +250,48 @@ export function getAnthropicPoolRetryAfterSeconds(now = Date.now()): number | nu
|
|
|
191
250
|
return Math.max(1, Math.ceil((earliest - now) / 1000));
|
|
192
251
|
}
|
|
193
252
|
|
|
194
|
-
|
|
195
|
-
|
|
253
|
+
interface ScoredAccount {
|
|
254
|
+
accountId: string;
|
|
255
|
+
hasKnownUsage: boolean;
|
|
256
|
+
score: number;
|
|
257
|
+
fiveHourTieBreak: number;
|
|
258
|
+
/** True only under the opt-in weekly window; see compareScoredAccounts. */
|
|
259
|
+
knownFirst: boolean;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function compareScoredAccounts(a: ScoredAccount, b: ScoredAccount): number {
|
|
263
|
+
// known-before-unknown belongs to the OPT-IN windows (weekly, max-utilization), not the
|
|
264
|
+
// legacy five-hour default. Applying it unconditionally changed ordering for operators who
|
|
265
|
+
// never opted in: an account measured at 100% would sort ahead of an unmeasured one purely
|
|
266
|
+
// because it had a reading. The accepted scope preserves the five-hour default exactly.
|
|
267
|
+
if (a.knownFirst && b.knownFirst && a.hasKnownUsage !== b.hasKnownUsage) {
|
|
268
|
+
return a.hasKnownUsage ? -1 : 1;
|
|
269
|
+
}
|
|
270
|
+
return a.score - b.score || a.fiveHourTieBreak - b.fiveHourTieBreak;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function pickLowestUsage(config: OcxConfig, excludeId: string | undefined, now: number): string | null {
|
|
274
|
+
const window = anthropicQuotaWindow(anthropicAccountPoolConfig(config));
|
|
275
|
+
const unfiltered = getEligibleAnthropicAccounts(now).filter(id => id !== excludeId);
|
|
276
|
+
const available = window === "weekly" ? unfiltered.filter(id => !exhausted5h(id)) : unfiltered;
|
|
277
|
+
const eligible = available.length > 0 ? available : unfiltered;
|
|
196
278
|
if (eligible.length === 0) return null;
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
279
|
+
const scored: ScoredAccount[] = eligible.map(accountId => ({
|
|
280
|
+
accountId,
|
|
281
|
+
hasKnownUsage: hasKnownUsage(config, accountId),
|
|
282
|
+
score: usageScore(config, accountId),
|
|
283
|
+
fiveHourTieBreak: window === "five-hour" ? 0 : fiveHourScore(accountId),
|
|
284
|
+
// Every window EXCEPT the legacy five-hour default is an explicit opt-in, so
|
|
285
|
+
// known-before-unknown applies to all of them and to none of the default path.
|
|
286
|
+
knownFirst: window !== "five-hour",
|
|
287
|
+
}));
|
|
288
|
+
let best = scored[0]!;
|
|
289
|
+
for (let i = 1; i < scored.length; i++) {
|
|
290
|
+
const candidate = scored[i]!;
|
|
291
|
+
// Strict `< 0` keeps the earliest eligible account on an exact tie.
|
|
292
|
+
if (compareScoredAccounts(candidate, best) < 0) best = candidate;
|
|
206
293
|
}
|
|
207
|
-
return best;
|
|
294
|
+
return best.accountId;
|
|
208
295
|
}
|
|
209
296
|
|
|
210
297
|
/** Next eligible Anthropic account in stable order after `afterId` (wrapping). */
|
|
@@ -213,8 +300,11 @@ function pickNextFillFirstAnthropicAccount(
|
|
|
213
300
|
afterId: string,
|
|
214
301
|
eligible: string[],
|
|
215
302
|
): string | null {
|
|
216
|
-
|
|
217
|
-
const
|
|
303
|
+
const window = anthropicQuotaWindow(anthropicAccountPoolConfig(config));
|
|
304
|
+
const available = window === "weekly" ? eligible.filter(id => !exhausted5h(id)) : eligible;
|
|
305
|
+
const candidates = available.length > 0 ? available : eligible;
|
|
306
|
+
if (candidates.length === 0) return null;
|
|
307
|
+
const ordered = [...candidates].sort((a, b) => a.localeCompare(b));
|
|
218
308
|
const set = getAccountSet(PROVIDER);
|
|
219
309
|
const stableAll = set
|
|
220
310
|
? [...set.accounts.map(a => a.id)].sort((a, b) => a.localeCompare(b))
|
|
@@ -230,7 +320,7 @@ function pickNextFillFirstAnthropicAccount(
|
|
|
230
320
|
let fallback: string | null = null;
|
|
231
321
|
for (let step = 1; step <= stableAll.length; step++) {
|
|
232
322
|
const candidate = stableAll[(startIdx + step) % stableAll.length]!;
|
|
233
|
-
if (!
|
|
323
|
+
if (!candidates.includes(candidate)) continue;
|
|
234
324
|
if (!fallback) fallback = candidate;
|
|
235
325
|
if (isActiveUnderFillFirstThreshold(config, candidate)) return candidate;
|
|
236
326
|
}
|
|
@@ -250,7 +340,7 @@ function pickAlternateAnthropicAccount(
|
|
|
250
340
|
if (strategy === "fill-first") {
|
|
251
341
|
return pickNextFillFirstAnthropicAccount(config, excludeId, eligible);
|
|
252
342
|
}
|
|
253
|
-
return pickLowestUsage(excludeId, now);
|
|
343
|
+
return pickLowestUsage(config, excludeId, now);
|
|
254
344
|
}
|
|
255
345
|
|
|
256
346
|
function pruneExpiredAffinity(now: number): void {
|
|
@@ -290,9 +380,11 @@ function anthropicPoolStrategy(config: OcxConfig): OcxAccountPoolRotationStrateg
|
|
|
290
380
|
function isActiveUnderFillFirstThreshold(config: OcxConfig, accountId: string): boolean {
|
|
291
381
|
const threshold = anthropicAutoSwitchThreshold(config);
|
|
292
382
|
if (threshold <= 0) return true;
|
|
383
|
+
const window = anthropicQuotaWindow(anthropicAccountPoolConfig(config));
|
|
384
|
+
if (window === "weekly" && exhausted5h(accountId)) return false;
|
|
293
385
|
// Unknown usage must not force fill-first to abandon the active account.
|
|
294
|
-
if (!hasKnownUsage(accountId)) return true;
|
|
295
|
-
return usageScore(accountId) < threshold;
|
|
386
|
+
if (!hasKnownUsage(config, accountId)) return true;
|
|
387
|
+
return usageScore(config, accountId) < threshold;
|
|
296
388
|
}
|
|
297
389
|
|
|
298
390
|
/**
|
|
@@ -412,12 +504,15 @@ export function resolveAnthropicAccountForSession(
|
|
|
412
504
|
let reason: AnthropicAccountSelectionReason = "none";
|
|
413
505
|
|
|
414
506
|
if (threshold > 0) {
|
|
507
|
+
const window = anthropicQuotaWindow(anthropicAccountPoolConfig(config));
|
|
415
508
|
// Unknown usage must NOT force a switch away from the healthy active account.
|
|
416
|
-
if (activeOk
|
|
509
|
+
if (activeOk
|
|
510
|
+
&& !(window === "weekly" && exhausted5h(set.activeAccountId))
|
|
511
|
+
&& (!hasKnownUsage(config, set.activeAccountId) || usageScore(config, set.activeAccountId) < threshold)) {
|
|
417
512
|
accountId = set.activeAccountId;
|
|
418
513
|
reason = "active";
|
|
419
514
|
} else {
|
|
420
|
-
const picked = pickLowestUsage(undefined, now);
|
|
515
|
+
const picked = pickLowestUsage(config, undefined, now);
|
|
421
516
|
if (picked) {
|
|
422
517
|
accountId = picked;
|
|
423
518
|
reason = activeOk && picked === set.activeAccountId ? "active" : "lowest-usage";
|
|
@@ -430,7 +525,7 @@ export function resolveAnthropicAccountForSession(
|
|
|
430
525
|
accountId = set.activeAccountId;
|
|
431
526
|
reason = "active";
|
|
432
527
|
} else {
|
|
433
|
-
const picked = pickLowestUsage(set.activeAccountId, now);
|
|
528
|
+
const picked = pickLowestUsage(config, set.activeAccountId, now);
|
|
434
529
|
if (picked) {
|
|
435
530
|
accountId = picked;
|
|
436
531
|
reason = "only-eligible";
|
package/src/oauth/chatgpt.ts
CHANGED
|
@@ -143,7 +143,10 @@ export async function loginChatGPT(ctrl: OAuthController, opts?: { forceLogin?:
|
|
|
143
143
|
|
|
144
144
|
// Note: uses form-urlencoded per OAuth 2.0 spec (RFC 6749 §6).
|
|
145
145
|
// Codex-rs uses JSON for refresh — intentional divergence; both accepted by auth.openai.com.
|
|
146
|
-
export async function refreshChatGPTToken(
|
|
146
|
+
export async function refreshChatGPTToken(
|
|
147
|
+
refreshToken: string,
|
|
148
|
+
options: { signal?: AbortSignal } = {},
|
|
149
|
+
): Promise<OAuthCredentials> {
|
|
147
150
|
const resp = await fetch(TOKEN_URL, {
|
|
148
151
|
method: "POST",
|
|
149
152
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
@@ -152,6 +155,7 @@ export async function refreshChatGPTToken(refreshToken: string): Promise<OAuthCr
|
|
|
152
155
|
client_id: CLIENT_ID,
|
|
153
156
|
refresh_token: refreshToken,
|
|
154
157
|
}).toString(),
|
|
158
|
+
signal: options.signal,
|
|
155
159
|
});
|
|
156
160
|
if (!resp.ok) {
|
|
157
161
|
const errDesc = await safeErrorDescription(resp);
|