@bitkyc08/opencodex 2.75.0 → 2.76.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/README.md +6 -1
- package/bin/ocx.mjs +12 -1
- package/gui/dist/assets/App-CddorHdA.js +51 -0
- package/gui/dist/assets/App-DMudtISW.css +1 -0
- package/gui/dist/assets/{Tray-CGAD2AGk.js → Tray-DjNwwrWS.js} +1 -1
- package/gui/dist/assets/index-Btze1I3T.css +1 -0
- package/gui/dist/assets/index-CvnY7E6i.js +86 -0
- package/gui/dist/assets/quota-summary-B_AWpHfP.js +1 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/opengateway.svg +12 -0
- package/package.json +1 -1
- package/src/adapters/anthropic/account-metadata.ts +17 -0
- package/src/adapters/anthropic/client-identity.ts +69 -0
- package/src/adapters/anthropic/passthrough.ts +23 -4
- package/src/adapters/anthropic.ts +18 -1
- package/src/adapters/command-code-project-context.ts +7 -11
- package/src/adapters/devin/cloud-direct/tool-schema.ts +12 -48
- package/src/adapters/kiro-retry.ts +23 -8
- package/src/adapters/openai-chat/messages.ts +1 -1
- package/src/adapters/registry.ts +8 -1
- package/src/adapters/zed.ts +377 -0
- package/src/bridge/sse.ts +4 -2
- package/src/chatgpt/app-server-shim/app-server-rewrite.ts +45 -0
- package/src/chatgpt/app-server-shim/bundle-trust.ts +77 -0
- package/src/chatgpt/app-server-shim/filter.ts +157 -0
- package/src/chatgpt/app-server-shim/gate-rewrite.ts +99 -0
- package/src/chatgpt/app-server-shim/launcher.ts +91 -0
- package/src/claude/desktop-3p-startup.ts +62 -0
- package/src/claude/desktop-3p.ts +5 -0
- package/src/claude/first-party-settings.ts +14 -0
- package/src/claude/intercept/cli-catalog.ts +150 -0
- package/src/claude/intercept/cli-picker.ts +82 -0
- package/src/claude/intercept/listener.ts +21 -6
- package/src/claude/intercept/picker-bootstrap.ts +23 -5
- package/src/claude/intercept/picker-models.ts +57 -13
- package/src/claude/intercept/runtime.ts +30 -2
- package/src/claude/intercept/settings.ts +3 -0
- package/src/cli/agent.ts +172 -0
- package/src/cli/alias.ts +2 -1
- package/src/cli/capabilities-command.ts +3 -2
- package/src/cli/capabilities.ts +87 -0
- package/src/cli/chatgpt-command.ts +140 -0
- package/src/cli/combo.ts +144 -19
- package/src/cli/config-command.ts +3 -1
- package/src/cli/dispatch.ts +10 -1
- package/src/cli/help.ts +1 -0
- package/src/cli/index.ts +5 -24
- package/src/cli/init.ts +16 -2
- package/src/cli/integrations.ts +13 -0
- package/src/cli/internal-command.ts +12 -5
- package/src/cli/observe.ts +6 -1
- package/src/cli/registry.ts +6 -0
- package/src/cli/route-policy.ts +3 -1
- package/src/cli/runtime-api.ts +5 -2
- package/src/cli/status-oauth.ts +5 -0
- package/src/cli/status.ts +8 -1
- package/src/cli/stop-approval.ts +55 -16
- package/src/cli/stop-report.ts +5 -0
- package/src/clients/lazycodex.ts +48 -0
- package/src/clients/omo-role-models.ts +111 -0
- package/src/codex/account-credit-use.ts +99 -0
- package/src/codex/account-lifecycle.ts +2 -0
- package/src/codex/agent-role-models.ts +171 -0
- package/src/codex/auth-api/account-list.ts +10 -3
- package/src/codex/auth-api/routes.ts +34 -0
- package/src/codex/auth-context.ts +52 -11
- package/src/codex/catalog/discovered-natives.ts +11 -1
- package/src/codex/catalog/gather-capture.ts +6 -1
- package/src/codex/catalog/model-hints.ts +9 -1
- package/src/codex/catalog/provider-models.ts +60 -0
- package/src/codex/catalog/routed-gather.ts +30 -5
- package/src/codex/convergence-types.ts +2 -1
- package/src/codex/main-account-external-usage.ts +62 -0
- package/src/codex/main-account-hard-lock.ts +24 -13
- package/src/codex/native-profile-processes.ts +53 -16
- package/src/codex/prompt-layers/toml-edit.ts +1 -1
- package/src/codex/quota-types.ts +3 -7
- package/src/codex/quota.ts +19 -4
- package/src/codex/role-auto-assign.ts +159 -0
- package/src/codex/role-sizing-limits.ts +2 -0
- package/src/codex/role-sizing.ts +216 -0
- package/src/codex/routing/selection.ts +36 -0
- package/src/codex/routing/thread-affinity.ts +2 -0
- package/src/codex/routing.ts +4 -4
- package/src/codex/subagent-model-fallback.ts +82 -26
- package/src/codex/subagent-selectable-models.ts +32 -0
- package/src/combos/index.ts +18 -0
- package/src/combos/jev-decision-contract.ts +20 -0
- package/src/combos/jev-dispatch.ts +18 -0
- package/src/combos/jev-model-backend.ts +127 -0
- package/src/combos/jev.ts +249 -33
- package/src/combos/types.ts +125 -2
- package/src/config/diagnostics.ts +18 -0
- package/src/config/macos-system-proxy.ts +72 -15
- package/src/config/owner-registry.ts +13 -4
- package/src/config/process-state.ts +17 -8
- package/src/config/proxy-env.ts +35 -5
- package/src/config/schema/config-schema.ts +30 -1
- package/src/config/schema/leaf-validators.ts +4 -0
- package/src/config/serving-runtimes.ts +95 -1
- package/src/generated/compatibility-version.json +336 -156
- package/src/lib/errors.ts +7 -7
- package/src/lib/jsonc.ts +13 -0
- package/src/lib/local-chat-completion.ts +144 -0
- package/src/lib/provider-outbound.ts +87 -1
- package/src/lib/provider-runtime-fetch.ts +23 -0
- package/src/lib/provider-tls-profile.ts +221 -0
- package/src/lib/request-execution-budget.ts +16 -6
- package/src/lib/spend-reservation-ledger.ts +54 -20
- package/src/lib/sse-decoder.ts +20 -17
- package/src/lib/synced-state-location.ts +96 -0
- package/src/lib/winsw.ts +14 -5
- package/src/oauth/anthropic-account-refusal.ts +53 -4
- package/src/oauth/anthropic-model-quota.ts +83 -0
- package/src/oauth/anthropic-rate-limit-policy.ts +37 -0
- package/src/oauth/anthropic-routing.ts +112 -58
- package/src/oauth/health.ts +39 -3
- package/src/oauth/index.ts +14 -0
- package/src/oauth/kiro-account-load.ts +10 -0
- package/src/oauth/zed.ts +134 -0
- package/src/providers/model-discovery.ts +19 -8
- package/src/providers/model-rename-fields.ts +1 -0
- package/src/providers/provider-id-rewrite.ts +9 -2
- package/src/providers/quota/account-cache.ts +29 -7
- package/src/providers/quota/anthropic-cooldown-recovery.ts +18 -0
- package/src/providers/quota/anthropic-family-headers.ts +25 -0
- package/src/providers/quota/vendor-probes-oauth.ts +5 -1
- package/src/providers/quota-types.ts +4 -0
- package/src/providers/registry/entries-core.ts +18 -0
- package/src/providers/registry/entries-extended.ts +55 -0
- package/src/providers/registry/types.ts +3 -0
- package/src/providers/xai-fast-model.ts +12 -4
- package/src/providers/zed.ts +598 -0
- package/src/responses/state/snapshot-select.ts +22 -9
- package/src/responses/state.ts +2 -3
- package/src/router.ts +9 -0
- package/src/server/anthropic-rate-limit-headers.ts +7 -0
- package/src/server/auth-cors.ts +7 -0
- package/src/server/chat-completions.ts +9 -4
- package/src/server/claude-messages.ts +59 -21
- package/src/server/index/claude-intercept-lifecycle.ts +71 -25
- package/src/server/index/optional-listeners.ts +4 -0
- package/src/server/index/spend-ledger-lifecycle.ts +4 -0
- package/src/server/management/agent-settings-routes.ts +74 -37
- package/src/server/management/claude-desktop-picker-routes.ts +7 -0
- package/src/server/management/claude-intercept-routes.ts +35 -0
- package/src/server/management/codex-agent-role-routes.ts +122 -0
- package/src/server/management/codex-role-auto-assign.ts +208 -0
- package/src/server/management/combo-routes.ts +62 -5
- package/src/server/management/config-routes.ts +35 -3
- package/src/server/management/context.ts +4 -1
- package/src/server/management/decision-discovery.ts +74 -0
- package/src/server/management/decision-model-validation.ts +80 -0
- package/src/server/management/decision-routes.ts +160 -0
- package/src/server/management/native-integration-routes.ts +16 -0
- package/src/server/management/provider-routes.ts +30 -34
- package/src/server/management/route-registry.ts +12 -0
- package/src/server/management/sibling-guard.ts +1 -0
- package/src/server/management/zed-provider-probe.ts +38 -0
- package/src/server/management-api.ts +12 -1
- package/src/server/messages-native-oauth.ts +7 -2
- package/src/server/messages-native.ts +7 -3
- package/src/server/relay.ts +3 -1
- package/src/server/request-log.ts +2 -0
- package/src/server/responses/adapter-continuation.ts +3 -3
- package/src/server/responses/adapter-dispatch.ts +18 -3
- package/src/server/responses/compact.ts +14 -1
- package/src/server/responses/core-auth.ts +2 -0
- package/src/server/responses/core-combo.ts +67 -11
- package/src/server/responses/core-normalize.ts +21 -1
- package/src/server/responses/core-options.ts +11 -1
- package/src/server/responses/core.ts +5 -3
- package/src/server/responses/fetch-helpers.ts +6 -3
- package/src/server/responses/jev-model-invoke.ts +190 -0
- package/src/server/responses/passthrough-delivery.ts +16 -0
- package/src/server/responses/policy-fallback.ts +55 -3
- package/src/server/responses/policy-request-scope.ts +80 -0
- package/src/server/responses/request-prepare.ts +70 -18
- package/src/server/responses/request-transport.ts +54 -23
- package/src/server/responses/run-turn-execution.ts +1 -0
- package/src/server/responses/sidecar-execution.ts +9 -3
- package/src/server/responses-hosted-image-display.ts +225 -0
- package/src/service/managing-cli.ts +34 -4
- package/src/service/systemd-env.ts +75 -0
- package/src/service/windows-guarded-manager.ts +60 -19
- package/src/service/windows-scheduler.ts +22 -3
- package/src/service/windows-taskxml.ts +32 -10
- package/src/service-manager-probe.ts +72 -19
- package/src/types/config.ts +31 -1
- package/src/types/provider.ts +2 -0
- package/src/types/request.ts +2 -0
- package/src/update/index.ts +39 -6
- package/src/update/job.ts +63 -63
- package/src/update/npm-cache-preflight.d.mts +33 -1
- package/src/update/npm-cache-preflight.mjs +131 -18
- package/src/update/ownership-transaction.ts +19 -1
- package/src/update/restart-ownership.ts +80 -6
- package/src/update/transactional-install.d.mts +2 -0
- package/src/update/transactional-install.mjs +6 -1
- package/src/usage/jev-stats.ts +28 -1
- package/src/usage/log.ts +1 -1
- package/src/vision/anthropic-describe.ts +5 -3
- package/src/vision/index.ts +1 -0
- package/src/vision/plan.ts +2 -1
- package/src/vision/routed-describe.ts +29 -121
- package/src/web-search/alpha-search.ts +3 -3
- package/src/web-search/anthropic-executor.ts +6 -4
- package/src/web-search/index.ts +1 -1
- package/src/web-search/loop.ts +3 -3
- package/src/web-search/passthrough-bridge.ts +2 -1
- package/src/web-search/run-turn-loop.ts +1 -1
- package/src/web-search/sidecar-providers.ts +2 -1
- package/gui/dist/assets/App-ia4U0ufY.css +0 -1
- package/gui/dist/assets/App-xm0zGNOe.js +0 -51
- package/gui/dist/assets/index-DBT3Q31Y.css +0 -1
- package/gui/dist/assets/index-D_RGAT6Q.js +0 -86
- package/gui/dist/assets/quota-summary-CUFq48t-.js +0 -1
|
@@ -442,17 +442,54 @@ function ledgerEntryExists(path: string): boolean {
|
|
|
442
442
|
}
|
|
443
443
|
}
|
|
444
444
|
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
445
|
+
/** Which ledger file a refusal is about. A role, never a path: the path names the user's home. */
|
|
446
|
+
export type SpendLedgerFileRole = "journal" | "journal-compaction" | "salt";
|
|
447
|
+
|
|
448
|
+
/** The one condition that refused the file, so a report can say which guard fired. */
|
|
449
|
+
export type SpendLedgerFileRefusal =
|
|
450
|
+
| "not-regular-file"
|
|
451
|
+
| "symbolic-link"
|
|
452
|
+
| "extra-hard-link"
|
|
453
|
+
| "foreign-owner"
|
|
454
|
+
| "invalid-salt";
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* A ledger file failed a safety condition.
|
|
458
|
+
*
|
|
459
|
+
* The guard is unchanged; this only says which file role and which condition refused it. Issue
|
|
460
|
+
* #6314 sat for days on "could not be opened safely" because the same sentence covered five
|
|
461
|
+
* conditions and two files, and the one that fired (a second hard link to the journal, most
|
|
462
|
+
* likely held briefly by a cloud-sync daemon) could only be found with an instrumented build.
|
|
463
|
+
* Role and condition are fixed vocabulary, so the message carries no path, salt, alias or
|
|
464
|
+
* request content.
|
|
465
|
+
*/
|
|
466
|
+
export class SpendLedgerFileRefusedError extends SpendLedgerOwnerError {
|
|
467
|
+
constructor(readonly role: SpendLedgerFileRole, readonly refusal: SpendLedgerFileRefusal) {
|
|
468
|
+
super(
|
|
450
469
|
"SPEND_LEDGER_OWNER_UNAVAILABLE",
|
|
451
|
-
|
|
470
|
+
`Spend-ledger storage could not be opened safely (${role}: ${refusal}).`
|
|
471
|
+
+ (refusal === "extra-hard-link"
|
|
472
|
+
? " Another directory entry links to this file; if the opencodex state directory is inside an iCloud Drive or other synced folder, move it out."
|
|
473
|
+
: ""),
|
|
452
474
|
);
|
|
475
|
+
this.name = "SpendLedgerFileRefusedError";
|
|
453
476
|
}
|
|
454
477
|
}
|
|
455
478
|
|
|
479
|
+
function ledgerFileRefusal(path: string): SpendLedgerFileRefusal | undefined {
|
|
480
|
+
const stat = lstatSync(path);
|
|
481
|
+
if (stat.isSymbolicLink()) return "symbolic-link";
|
|
482
|
+
if (!stat.isFile()) return "not-regular-file";
|
|
483
|
+
if (stat.nlink !== 1) return "extra-hard-link";
|
|
484
|
+
if (process.platform !== "win32" && stat.uid !== process.getuid!()) return "foreign-owner";
|
|
485
|
+
return undefined;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function assertSafeLedgerFile(path: string, role: SpendLedgerFileRole): void {
|
|
489
|
+
const refusal = ledgerFileRefusal(path);
|
|
490
|
+
if (refusal !== undefined) throw new SpendLedgerFileRefusedError(role, refusal);
|
|
491
|
+
}
|
|
492
|
+
|
|
456
493
|
/**
|
|
457
494
|
* The production journal. Its location comes from the owned state directory and every touch
|
|
458
495
|
* proves that ownership, so there is no entrypoint here that writes a caller-chosen path.
|
|
@@ -471,7 +508,7 @@ export function createOwnedFileSpendJournal(storage: SpendLedgerStorage): SpendJ
|
|
|
471
508
|
read(): string[] {
|
|
472
509
|
assertStorageOwned(storage);
|
|
473
510
|
if (!ledgerEntryExists(path)) return [];
|
|
474
|
-
assertSafeLedgerFile(path);
|
|
511
|
+
assertSafeLedgerFile(path, "journal");
|
|
475
512
|
// Replay is once per process and is the moment a journal inherited from an older build
|
|
476
513
|
// or a restored backup first passes through here.
|
|
477
514
|
hardenLedgerFile(path, { force: true });
|
|
@@ -481,9 +518,9 @@ export function createOwnedFileSpendJournal(storage: SpendLedgerStorage): SpendJ
|
|
|
481
518
|
assertStorageOwned(storage);
|
|
482
519
|
ensureDir();
|
|
483
520
|
const created = !ledgerEntryExists(path);
|
|
484
|
-
if (!created) assertSafeLedgerFile(path);
|
|
521
|
+
if (!created) assertSafeLedgerFile(path, "journal");
|
|
485
522
|
appendFileSync(path, line + "\n", { encoding: "utf8", mode: 0o600 });
|
|
486
|
-
assertSafeLedgerFile(path);
|
|
523
|
+
assertSafeLedgerFile(path, "journal");
|
|
487
524
|
hardenLedgerFile(path, { force: created });
|
|
488
525
|
},
|
|
489
526
|
rewrite(lines: string[]): void {
|
|
@@ -491,7 +528,7 @@ export function createOwnedFileSpendJournal(storage: SpendLedgerStorage): SpendJ
|
|
|
491
528
|
ensureDir();
|
|
492
529
|
// Same directory, so the rename is atomic on the same filesystem: a crash mid-compaction
|
|
493
530
|
// leaves either the old journal or the new one, never a half-written ledger.
|
|
494
|
-
if (ledgerEntryExists(path)) assertSafeLedgerFile(path);
|
|
531
|
+
if (ledgerEntryExists(path)) assertSafeLedgerFile(path, "journal");
|
|
495
532
|
const temp = `${path}.compact-${process.pid}-${randomBytes(6).toString("hex")}`;
|
|
496
533
|
// The creation is INSIDE the cleanup, not before it. The name carries random bytes, so a
|
|
497
534
|
// failure anywhere after the entry exists used to leave a uniquely named file and the next
|
|
@@ -514,7 +551,7 @@ export function createOwnedFileSpendJournal(storage: SpendLedgerStorage): SpendJ
|
|
|
514
551
|
closeSync(fd);
|
|
515
552
|
fd = undefined;
|
|
516
553
|
journalFaultForTests?.("validate", temp);
|
|
517
|
-
assertSafeLedgerFile(temp);
|
|
554
|
+
assertSafeLedgerFile(temp, "journal-compaction");
|
|
518
555
|
journalFaultForTests?.("harden", temp);
|
|
519
556
|
hardenLedgerFile(temp, { force: true });
|
|
520
557
|
journalFaultForTests?.("rename", temp);
|
|
@@ -528,7 +565,7 @@ export function createOwnedFileSpendJournal(storage: SpendLedgerStorage): SpendJ
|
|
|
528
565
|
try { unlinkSync(temp); } catch { /* same */ }
|
|
529
566
|
}
|
|
530
567
|
}
|
|
531
|
-
assertSafeLedgerFile(path);
|
|
568
|
+
assertSafeLedgerFile(path, "journal");
|
|
532
569
|
hardenLedgerFile(path, { force: true });
|
|
533
570
|
},
|
|
534
571
|
};
|
|
@@ -545,14 +582,11 @@ export function loadOrCreateSpendLedgerSalt(storage: SpendLedgerStorage): string
|
|
|
545
582
|
assertStorageOwned(storage);
|
|
546
583
|
const path = spendLedgerStoragePath(storage);
|
|
547
584
|
if (ledgerEntryExists(path)) {
|
|
548
|
-
assertSafeLedgerFile(path);
|
|
585
|
+
assertSafeLedgerFile(path, "salt");
|
|
549
586
|
hardenLedgerFile(path, { force: true });
|
|
550
587
|
const existing = readFileSync(path, "utf8").trim();
|
|
551
588
|
if (/^[0-9a-f]{32,}$/.test(existing)) return existing;
|
|
552
|
-
throw new
|
|
553
|
-
"SPEND_LEDGER_OWNER_UNAVAILABLE",
|
|
554
|
-
"Spend-ledger storage could not be opened safely.",
|
|
555
|
-
);
|
|
589
|
+
throw new SpendLedgerFileRefusedError("salt", "invalid-salt");
|
|
556
590
|
}
|
|
557
591
|
const dir = dirname(path);
|
|
558
592
|
assertStorageOwned(storage);
|
|
@@ -560,7 +594,7 @@ export function loadOrCreateSpendLedgerSalt(storage: SpendLedgerStorage): string
|
|
|
560
594
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
561
595
|
const salt = randomBytes(32).toString("hex");
|
|
562
596
|
writeFileSync(path, salt + "\n", { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
563
|
-
assertSafeLedgerFile(path);
|
|
597
|
+
assertSafeLedgerFile(path, "salt");
|
|
564
598
|
hardenLedgerFile(path, { force: true });
|
|
565
599
|
return salt;
|
|
566
600
|
}
|
|
@@ -1205,8 +1239,8 @@ export function sharedSpendLedger(): SpendReservationLedger {
|
|
|
1205
1239
|
const saltPath = spendLedgerStoragePath(saltStorage);
|
|
1206
1240
|
const assertOwnedAccounting = (): void => {
|
|
1207
1241
|
assertStorageOwned(journalStorage);
|
|
1208
|
-
if (ledgerEntryExists(journalPath)) assertSafeLedgerFile(journalPath);
|
|
1209
|
-
if (ledgerEntryExists(saltPath)) assertSafeLedgerFile(saltPath);
|
|
1242
|
+
if (ledgerEntryExists(journalPath)) assertSafeLedgerFile(journalPath, "journal");
|
|
1243
|
+
if (ledgerEntryExists(saltPath)) assertSafeLedgerFile(saltPath, "salt");
|
|
1210
1244
|
};
|
|
1211
1245
|
sharedLedger = createSpendReservationLedger({
|
|
1212
1246
|
journal: createOwnedFileSpendJournal(journalStorage),
|
package/src/lib/sse-decoder.ts
CHANGED
|
@@ -56,6 +56,7 @@ export function sseFieldOffset(text: string, lineStart: number, lineEnd: number,
|
|
|
56
56
|
|
|
57
57
|
/**
|
|
58
58
|
* Decode text/event-stream records across arbitrary fetch chunk boundaries.
|
|
59
|
+
* CR, LF and CRLF terminate lines; a split CRLF is one delimiter.
|
|
59
60
|
*
|
|
60
61
|
* The final record is dispatched at EOF even when the upstream omits the trailing blank line or
|
|
61
62
|
* final newline. That matters for compatible APIs that place a terminal event in the last bytes of
|
|
@@ -77,6 +78,7 @@ export async function* decodeServerSentEvents(
|
|
|
77
78
|
const reader = source.getReader();
|
|
78
79
|
const decoder = new TextDecoder();
|
|
79
80
|
let lineBuffer = "";
|
|
81
|
+
let skipLineFeed = false;
|
|
80
82
|
let lineRawBytes = 0;
|
|
81
83
|
let lineRetainedBytes = 0;
|
|
82
84
|
let event: string | undefined;
|
|
@@ -183,8 +185,8 @@ export async function* decodeServerSentEvents(
|
|
|
183
185
|
};
|
|
184
186
|
|
|
185
187
|
const acceptLine = (): { record: ServerSentEvent | SseRecord; bytes: number } | undefined => {
|
|
186
|
-
|
|
187
|
-
|
|
188
|
+
const line = lineBuffer;
|
|
189
|
+
const retainedLineBytes = lineRetainedBytes;
|
|
188
190
|
let lineOwned = true;
|
|
189
191
|
const releaseLine = (): void => {
|
|
190
192
|
if (!lineOwned) return;
|
|
@@ -195,20 +197,6 @@ export async function* decodeServerSentEvents(
|
|
|
195
197
|
lineRawBytes = 0;
|
|
196
198
|
lineRetainedBytes = 0;
|
|
197
199
|
try {
|
|
198
|
-
if (line.endsWith("\r")) {
|
|
199
|
-
const nextLineBytes = retainedLineBytes - 1;
|
|
200
|
-
const reservation = translatorBudget.reserveTransient(nextLineBytes, scope);
|
|
201
|
-
try {
|
|
202
|
-
const nextLine = line.slice(0, -1);
|
|
203
|
-
reservation.commitRetained();
|
|
204
|
-
translatorBudget.releaseRetained(retainedLineBytes, scope);
|
|
205
|
-
line = nextLine;
|
|
206
|
-
retainedLineBytes = nextLineBytes;
|
|
207
|
-
} catch (error) {
|
|
208
|
-
reservation.release();
|
|
209
|
-
throw error;
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
200
|
if (line === "") {
|
|
213
201
|
releaseLine();
|
|
214
202
|
return dispatch();
|
|
@@ -296,13 +284,28 @@ export async function* decodeServerSentEvents(
|
|
|
296
284
|
decoded: string,
|
|
297
285
|
): AsyncGenerator<ServerSentEvent | SseRecord> {
|
|
298
286
|
let offset = 0;
|
|
287
|
+
// Cached positions of the next CR and LF at or after `offset`, found with the native search so
|
|
288
|
+
// every stream keeps linear scanning. -1 means this decoded string has none left; it is never
|
|
289
|
+
// searched again, which keeps LF-only and CR-only chunks linear too.
|
|
290
|
+
let nextCr = decoded.indexOf("\r");
|
|
291
|
+
let nextLf = decoded.indexOf("\n");
|
|
299
292
|
while (offset < decoded.length) {
|
|
300
|
-
|
|
293
|
+
if (skipLineFeed) {
|
|
294
|
+
skipLineFeed = false;
|
|
295
|
+
if (decoded[offset] === "\n") {
|
|
296
|
+
offset++;
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
if (nextCr >= 0 && nextCr < offset) nextCr = decoded.indexOf("\r", offset);
|
|
301
|
+
if (nextLf >= 0 && nextLf < offset) nextLf = decoded.indexOf("\n", offset);
|
|
302
|
+
const newline = nextCr < 0 ? nextLf : nextLf < 0 ? nextCr : Math.min(nextCr, nextLf);
|
|
301
303
|
if (newline < 0) {
|
|
302
304
|
appendLine(decoded, offset, decoded.length);
|
|
303
305
|
return;
|
|
304
306
|
}
|
|
305
307
|
appendLine(decoded, offset, newline);
|
|
308
|
+
skipLineFeed = decoded[newline] === "\r";
|
|
306
309
|
const accepted = acceptLine();
|
|
307
310
|
if (accepted) {
|
|
308
311
|
try {
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { lstatSync, realpathSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
// POSIX path operations on purpose: every path this module classifies is a macOS path. The
|
|
4
|
+
// host's own path module would build backslash or drive-letter paths on Windows and make the
|
|
5
|
+
// injected-platform tests compare different strings there (dev CI windows 5/9 after #6398).
|
|
6
|
+
import { posix } from "node:path";
|
|
7
|
+
|
|
8
|
+
const { join, resolve } = posix;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Where a state directory sits relative to the folders macOS keeps in sync with a cloud.
|
|
12
|
+
*
|
|
13
|
+
* A sync daemon can hold a second hard link to a file while it stages or uploads a change. The
|
|
14
|
+
* spend ledger refuses a journal with a second link on purpose, so a state directory inside a
|
|
15
|
+
* synced folder turns ordinary requests into intermittent 502s (#6314). This only answers
|
|
16
|
+
* "is it likely synced"; the guard itself stays strict.
|
|
17
|
+
*/
|
|
18
|
+
export type SyncedStateLocation = "icloud-drive" | "file-provider" | "icloud-desktop-documents";
|
|
19
|
+
|
|
20
|
+
export interface SyncedStateLocationProbe {
|
|
21
|
+
readonly platform?: NodeJS.Platform;
|
|
22
|
+
readonly home?: string;
|
|
23
|
+
/** Resolve symlinks. Throws when the path does not exist yet. */
|
|
24
|
+
readonly realpath?: (path: string) => string;
|
|
25
|
+
/** Whether a directory entry exists at the path, without following it. */
|
|
26
|
+
readonly entryExists?: (path: string) => boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const defaultEntryExists = (path: string): boolean => {
|
|
30
|
+
try { lstatSync(path); return true; } catch { return false; }
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Advisory only. Apple publishes no API a CLI can ask, so this reads the observed layout:
|
|
35
|
+
* iCloud Drive lives under ~/Library/Mobile Documents, File Provider clients (OneDrive, Dropbox,
|
|
36
|
+
* Google Drive) under ~/Library/CloudStorage, and with "Desktop & Documents Folders" turned on
|
|
37
|
+
* iCloud Drive holds a Desktop/Documents entry of its own. A false positive costs one warning
|
|
38
|
+
* line; nothing is refused on the strength of it.
|
|
39
|
+
*/
|
|
40
|
+
export function syncedStateLocation(dir: string, probe: SyncedStateLocationProbe = {}): SyncedStateLocation | undefined {
|
|
41
|
+
if ((probe.platform ?? process.platform) !== "darwin") return undefined;
|
|
42
|
+
const realpath = probe.realpath ?? ((path: string) => realpathSync.native(path));
|
|
43
|
+
const entryExists = probe.entryExists ?? defaultEntryExists;
|
|
44
|
+
const canonical = (path: string): string => {
|
|
45
|
+
try { return realpath(path); } catch { return resolve(path); }
|
|
46
|
+
};
|
|
47
|
+
// The default APFS volume is case-insensitive, so ~/documents and ~/Documents are one folder.
|
|
48
|
+
const fold = (path: string): string => path.toLowerCase();
|
|
49
|
+
const home = canonical(probe.home ?? homedir());
|
|
50
|
+
const target = fold(canonical(dir));
|
|
51
|
+
const within = (root: string): boolean => {
|
|
52
|
+
const folded = fold(root);
|
|
53
|
+
return target === folded || target.startsWith(folded + "/");
|
|
54
|
+
};
|
|
55
|
+
const mobileDocuments = join(home, "Library", "Mobile Documents");
|
|
56
|
+
if (within(mobileDocuments)) return "icloud-drive";
|
|
57
|
+
if (within(join(home, "Library", "CloudStorage"))) return "file-provider";
|
|
58
|
+
for (const folder of ["Desktop", "Documents"]) {
|
|
59
|
+
if (within(join(home, folder)) && entryExists(join(mobileDocuments, "com~apple~CloudDocs", folder))) {
|
|
60
|
+
return "icloud-desktop-documents";
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const LOCATION_LABEL: Record<SyncedStateLocation, string> = {
|
|
67
|
+
"icloud-drive": "inside iCloud Drive",
|
|
68
|
+
"file-provider": "inside a cloud-storage (File Provider) folder",
|
|
69
|
+
"icloud-desktop-documents": "in Desktop or Documents, which iCloud Drive appears to sync",
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** The startup warning. Names the location kind, never the path. */
|
|
73
|
+
export function syncedStateWarning(location: SyncedStateLocation): string[] {
|
|
74
|
+
return [
|
|
75
|
+
`⚠️ The opencodex state directory (OPENCODEX_HOME) is ${LOCATION_LABEL[location]}.`,
|
|
76
|
+
" A sync service can briefly add a second link to the spend ledger, which opencodex",
|
|
77
|
+
" refuses, so requests may fail intermittently. Set OPENCODEX_HOME to a folder outside",
|
|
78
|
+
" synced locations (the default ~/.opencodex is not synced).",
|
|
79
|
+
];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let warned = false;
|
|
83
|
+
|
|
84
|
+
/** Warn once per process when the state directory looks synced. Never throws. */
|
|
85
|
+
export function warnIfSyncedStateDirectory(dir: string, warn: (line: string) => void = console.warn): void {
|
|
86
|
+
if (warned) return;
|
|
87
|
+
let location: SyncedStateLocation | undefined;
|
|
88
|
+
try { location = syncedStateLocation(dir); } catch { return; }
|
|
89
|
+
if (location === undefined) return;
|
|
90
|
+
warned = true;
|
|
91
|
+
// Runs while the startup owner lease is held and before its rollback is registered, so a
|
|
92
|
+
// throwing sink must not escape and strand the lease.
|
|
93
|
+
try {
|
|
94
|
+
for (const line of syncedStateWarning(location)) warn(line);
|
|
95
|
+
} catch { /* advisory output only */ }
|
|
96
|
+
}
|
package/src/lib/winsw.ts
CHANGED
|
@@ -179,8 +179,16 @@ export async function ensureWinswBinary(fetchImpl: typeof fetch = fetch): Promis
|
|
|
179
179
|
return exe;
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
+
/** SCM round-trips are sub-second on a healthy system; a wedged service controller must not block a guarded stop forever — a killed command still classifies fail-closed. */
|
|
183
|
+
const SERVICE_COMMAND_TIMEOUT_MS = 15_000;
|
|
184
|
+
/** `stopwait` outlasts the SCM `<stoptimeout>` (20s in the generated service definition) plus overhead; still bounded so a wedged SCM cannot hang the CLI. */
|
|
185
|
+
const SERVICE_STOPWAIT_TIMEOUT_MS = 40_000;
|
|
186
|
+
|
|
182
187
|
function runWinsw(args: string[]): string {
|
|
183
|
-
|
|
188
|
+
const timeoutMs = args[0] === "stopwait" ? SERVICE_STOPWAIT_TIMEOUT_MS : SERVICE_COMMAND_TIMEOUT_MS;
|
|
189
|
+
return execFileSync(winswExePath(), args, {
|
|
190
|
+
encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], windowsHide: true, timeout: timeoutMs,
|
|
191
|
+
}).trim();
|
|
184
192
|
}
|
|
185
193
|
|
|
186
194
|
/** `install /p` prompts for the service-account password on the console — stdin must be inherited. */
|
|
@@ -191,13 +199,14 @@ function runWinswInteractive(args: string[]): void {
|
|
|
191
199
|
+ "Run `ocx service install --native` from an elevated Command Prompt or PowerShell window, not a hidden or piped session.",
|
|
192
200
|
);
|
|
193
201
|
}
|
|
202
|
+
// Unbounded by design: the service-account password prompt waits on the user.
|
|
194
203
|
execFileSync(winswExePath(), args, { stdio: "inherit" });
|
|
195
204
|
}
|
|
196
205
|
|
|
197
206
|
function scQc(): string {
|
|
198
207
|
const sc = join(process.env.SystemRoot ?? "C:\\Windows", "System32", "sc.exe");
|
|
199
208
|
return execFileSync(existsSync(sc) ? sc : "sc.exe", ["qc", WINSW_SERVICE_ID], {
|
|
200
|
-
encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], windowsHide: true,
|
|
209
|
+
encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], windowsHide: true, timeout: SERVICE_COMMAND_TIMEOUT_MS,
|
|
201
210
|
});
|
|
202
211
|
}
|
|
203
212
|
|
|
@@ -269,7 +278,7 @@ function scExePath(): string {
|
|
|
269
278
|
|
|
270
279
|
function queryScmForService(): string {
|
|
271
280
|
return execFileSync(scExePath(), ["query", WINSW_SERVICE_ID], {
|
|
272
|
-
encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], windowsHide: true,
|
|
281
|
+
encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], windowsHide: true, timeout: SERVICE_COMMAND_TIMEOUT_MS,
|
|
273
282
|
});
|
|
274
283
|
}
|
|
275
284
|
|
|
@@ -370,9 +379,9 @@ export function uninstallWinswService(): void {
|
|
|
370
379
|
}
|
|
371
380
|
if (probe === true) {
|
|
372
381
|
try {
|
|
373
|
-
execFileSync(scExePath(), ["stop", WINSW_SERVICE_ID], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
|
|
382
|
+
execFileSync(scExePath(), ["stop", WINSW_SERVICE_ID], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], windowsHide: true, timeout: SERVICE_COMMAND_TIMEOUT_MS });
|
|
374
383
|
} catch { /* not running */ }
|
|
375
|
-
execFileSync(scExePath(), ["delete", WINSW_SERVICE_ID], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
|
|
384
|
+
execFileSync(scExePath(), ["delete", WINSW_SERVICE_ID], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], windowsHide: true, timeout: SERVICE_COMMAND_TIMEOUT_MS });
|
|
376
385
|
}
|
|
377
386
|
}
|
|
378
387
|
return;
|
|
@@ -1,13 +1,16 @@
|
|
|
1
|
+
import { anthropicModelFamily } from "./anthropic-model-quota";
|
|
1
2
|
/** Narrow pre-output account recovery, fenced to the bearer that physically sent the turn. */
|
|
2
3
|
import { readBoundedResponseBody } from "../lib/bounded-body";
|
|
3
|
-
import {
|
|
4
|
+
import { classifyAnthropic429, anthropicRetryAfterMs, pauseAnthropicRateAdmission, anthropicRatePauseUntil, ANTHROPIC_SHORT_RETRY_MS, ANTHROPIC_MAX_INLINE_THROTTLE_MS } from "./anthropic-rate-limit-policy";
|
|
5
|
+
import { isNonReplayableResponse, sleepWithAbort } from "../lib/upstream-retry";
|
|
4
6
|
import { credentialGeneration, getAccountCredentialWithStatus } from "./store";
|
|
5
7
|
import type { OAuthAccessSnapshot } from "./index";
|
|
6
8
|
import type { OcxConfig } from "../types";
|
|
7
9
|
import type { AnthropicRouteDecision } from "./anthropic-model-routes";
|
|
8
|
-
import { recordAnthropicAccountRefusal, rotateAnthropicAccountOnRefusal } from "./anthropic-routing";
|
|
10
|
+
import { recordAnthropicAccountRefusal, rotateAnthropicAccountOnRefusal, hasAnthropicFailoverQuorum, isAnthropicAccountPoolEnabled, pickAlternateAnthropicAccount } from "./anthropic-routing";
|
|
9
11
|
|
|
10
12
|
const responseCredentials = new WeakMap<Response, Pick<OAuthAccessSnapshot, "accountId" | "generation">>();
|
|
13
|
+
const retryStates = new WeakMap<object, { firstAccountId: string; sameAccount: boolean; detour: boolean }>();
|
|
11
14
|
const verdicts = new WeakMap<Response, Promise<boolean>>();
|
|
12
15
|
|
|
13
16
|
/** Called only when the outgoing headers prove ownership of the selected stored bearer. */
|
|
@@ -43,15 +46,61 @@ export async function rotateAnthropicAccountOnResponse(
|
|
|
43
46
|
options: {
|
|
44
47
|
config: OcxConfig;
|
|
45
48
|
accountId: string;
|
|
49
|
+
model?: string;
|
|
46
50
|
sessionKey?: string | null;
|
|
47
51
|
decision?: AnthropicRouteDecision | null;
|
|
48
52
|
signal?: AbortSignal;
|
|
49
53
|
canRetry: boolean;
|
|
54
|
+
/** Shared by main, continuation and sidecar consumers of one logical request. */
|
|
55
|
+
requestKey?: object;
|
|
56
|
+
allow429Recovery?: boolean;
|
|
50
57
|
allowAccountRefusal?: boolean;
|
|
51
58
|
},
|
|
52
59
|
): Promise<string | null> {
|
|
53
60
|
if (options.signal?.aborted || isNonReplayableResponse(response)) return null;
|
|
54
|
-
if (response.status
|
|
61
|
+
if (response.status === 429) {
|
|
62
|
+
const sent = responseCredentials.get(response);
|
|
63
|
+
const current = sent && getAccountCredentialWithStatus("anthropic", sent.accountId);
|
|
64
|
+
if (!sent || sent.accountId !== options.accountId || !current || current.needsReauth
|
|
65
|
+
|| credentialGeneration(current.credential) !== sent.generation) return null;
|
|
66
|
+
const kind = classifyAnthropic429(response.headers);
|
|
67
|
+
if (kind === "family-quota") {
|
|
68
|
+
if (anthropicModelFamily(options.model) !== "Fable" || !options.canRetry || options.allow429Recovery === false) return null;
|
|
69
|
+
return pickAlternateAnthropicAccount(options.config, sent.accountId, Date.now(), options.decision ?? null, options.model);
|
|
70
|
+
}
|
|
71
|
+
if (kind !== "shared-quota") {
|
|
72
|
+
if (!isAnthropicAccountPoolEnabled(options.config) && !hasAnthropicFailoverQuorum() && !current.paused) return null;
|
|
73
|
+
const now = Date.now();
|
|
74
|
+
const delay = anthropicRetryAfterMs(response.headers.get("retry-after"), now) ?? ANTHROPIC_SHORT_RETRY_MS;
|
|
75
|
+
if (kind === "transient-rate") pauseAnthropicRateAdmission(sent.accountId, now + delay);
|
|
76
|
+
if (!options.canRetry || options.allow429Recovery === false || !options.requestKey) return null;
|
|
77
|
+
let state = retryStates.get(options.requestKey);
|
|
78
|
+
if (!state) { state = { firstAccountId: sent.accountId, sameAccount: false, detour: false }; retryStates.set(options.requestKey, state); }
|
|
79
|
+
// A concurrent committed selection may have moved the same-account proposal.
|
|
80
|
+
if (state.firstAccountId !== sent.accountId) state.detour = true;
|
|
81
|
+
const wait = Math.max(delay, (anthropicRatePauseUntil(sent.accountId) ?? now) - now);
|
|
82
|
+
if (!state.sameAccount && wait <= ANTHROPIC_MAX_INLINE_THROTTLE_MS && !current.paused) {
|
|
83
|
+
state.sameAccount = true;
|
|
84
|
+
// Millisecond rounding can wake just before this deadline; later extensions still bind.
|
|
85
|
+
const retryAt = now + wait;
|
|
86
|
+
try { await sleepWithAbort(wait, options.signal); } catch { return null; }
|
|
87
|
+
const live = getAccountCredentialWithStatus("anthropic", sent.accountId);
|
|
88
|
+
if (!live || live.paused || live.needsReauth || options.signal?.aborted
|
|
89
|
+
|| credentialGeneration(live.credential) !== sent.generation
|
|
90
|
+
|| anthropicRatePauseUntil(sent.accountId, Math.max(Date.now(), retryAt))) return null;
|
|
91
|
+
return sent.accountId;
|
|
92
|
+
}
|
|
93
|
+
if (kind === "transient-rate" && !state.detour) {
|
|
94
|
+
state.detour = true;
|
|
95
|
+
return pickAlternateAnthropicAccount(options.config, sent.accountId, Date.now(), options.decision ?? null, options.model);
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
if (options.allow429Recovery === false) {
|
|
100
|
+
recordAnthropicAccountRefusal(options.config, options.accountId, 429, response.headers.get("retry-after"), Date.now(), response.headers);
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
} else {
|
|
55
104
|
if (response.status !== 403 || options.allowAccountRefusal === false) return null;
|
|
56
105
|
const sent = responseCredentials.get(response);
|
|
57
106
|
if (!sent || sent.accountId !== options.accountId) return null;
|
|
@@ -72,5 +121,5 @@ export async function rotateAnthropicAccountOnResponse(
|
|
|
72
121
|
return null;
|
|
73
122
|
}
|
|
74
123
|
return rotateAnthropicAccountOnRefusal(options.config, options.accountId, status,
|
|
75
|
-
response.headers.get("retry-after"), options.sessionKey, Date.now(), response.headers, options.decision);
|
|
124
|
+
response.headers.get("retry-after"), options.sessionKey, Date.now(), response.headers, options.decision, options.model);
|
|
76
125
|
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/** Model-scoped passive quota evidence. Reimplemented from documented behavior, not ported code. */
|
|
2
|
+
import { credentialGeneration, getAccountCredential } from "./store";
|
|
3
|
+
import { getCachedProviderAccountQuota } from "../providers/quota/account-cache";
|
|
4
|
+
import type { ProviderQuota, ProviderQuotaWindow } from "../providers/quota-types";
|
|
5
|
+
|
|
6
|
+
export const ANTHROPIC_PASSIVE_FAMILY_MAX_AGE_MS = 30 * 60_000;
|
|
7
|
+
export type AnthropicModelFamily = "Fable" | "Opus" | "Sonnet";
|
|
8
|
+
export function anthropicModelFamily(model?: string): AnthropicModelFamily | undefined {
|
|
9
|
+
return /(?:^|[/])claude-fable-5(?:-|$)/i.test(model ?? "") ? "Fable"
|
|
10
|
+
: /(?:^|[/])claude-opus-/i.test(model ?? "") ? "Opus"
|
|
11
|
+
: /(?:^|[/])claude-sonnet-/i.test(model ?? "") ? "Sonnet" : undefined;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function anthropicModelPercents(quota: ProviderQuota | null, model?: string): number[] {
|
|
15
|
+
const family = anthropicModelFamily(model);
|
|
16
|
+
return [quota?.fiveHourPercent, quota?.weeklyPercent, ...(!model ? [quota?.monthlyPercent] : []),
|
|
17
|
+
...(quota?.customWindows ?? []).filter(window => !model || window.scope !== "model" || (family && window.label === family)).map(window => window.percent)]
|
|
18
|
+
.filter((value): value is number => typeof value === "number" && Number.isFinite(value));
|
|
19
|
+
}
|
|
20
|
+
export function anthropicModelExhausted(accountId: string, model?: string): boolean {
|
|
21
|
+
return anthropicModelPercents(getCachedProviderAccountQuota("anthropic", accountId), model).some(percent => percent >= 100);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface FamilyObservation { generation: string; seenAt: number; resetAt?: number; busy: boolean; }
|
|
25
|
+
const spentFamilies = new Map<string, FamilyObservation>();
|
|
26
|
+
const familyGenerations = new Map<string, number>();
|
|
27
|
+
let nextFamilyGeneration = 1;
|
|
28
|
+
export function anthropicFamilyQuotaGeneration(id: string): number { return familyGenerations.get(id) ?? 0; }
|
|
29
|
+
const keyFor = (id: string, family: string) => `${id}\0${family}`;
|
|
30
|
+
function ownedObservation(id: string, family: string): FamilyObservation | undefined {
|
|
31
|
+
const key = keyFor(id, family);
|
|
32
|
+
const entry = spentFamilies.get(key);
|
|
33
|
+
if (!entry) return undefined;
|
|
34
|
+
const credential = getAccountCredential("anthropic", id);
|
|
35
|
+
if (!credential || credentialGeneration(credential) !== entry.generation) { spentFamilies.delete(key); return undefined; }
|
|
36
|
+
return entry;
|
|
37
|
+
}
|
|
38
|
+
export function observeAnthropicFamilyQuota(id: string, windows: ProviderQuotaWindow[], now: number, authoritative = false): void {
|
|
39
|
+
const credential = getAccountCredential("anthropic", id);
|
|
40
|
+
if (!credential) return;
|
|
41
|
+
if (authoritative || windows.some(window => window.scope === "model")) familyGenerations.set(id, nextFamilyGeneration++);
|
|
42
|
+
if (authoritative) for (const key of spentFamilies.keys()) if (key.startsWith(`${id}\0`)) spentFamilies.delete(key);
|
|
43
|
+
for (const window of windows) {
|
|
44
|
+
if (window.scope !== "model") continue;
|
|
45
|
+
const key = keyFor(id, window.label);
|
|
46
|
+
if (!window.rejected) { spentFamilies.delete(key); continue; }
|
|
47
|
+
spentFamilies.set(key, { generation: credentialGeneration(credential), seenAt: now, resetAt: window.resetAt, busy: false });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
export function anthropicFamilyRejected(id: string, model?: string, now = Date.now()): boolean {
|
|
51
|
+
const family = anthropicModelFamily(model);
|
|
52
|
+
const entry = family && ownedObservation(id, family);
|
|
53
|
+
if (!entry) return false;
|
|
54
|
+
if (entry.resetAt !== undefined && entry.resetAt <= now) return entry.busy;
|
|
55
|
+
return entry.busy || now - entry.seenAt < ANTHROPIC_PASSIVE_FAMILY_MAX_AGE_MS;
|
|
56
|
+
}
|
|
57
|
+
/** Only stale passive family exclusions require a request-driven single-flight physical send. */
|
|
58
|
+
export function claimAnthropicFamilyRevalidation(id: string, model?: string, now = Date.now()): (() => void) | null {
|
|
59
|
+
const family = anthropicModelFamily(model);
|
|
60
|
+
const entry = family && ownedObservation(id, family);
|
|
61
|
+
if (!entry) return () => {};
|
|
62
|
+
if (anthropicFamilyRejected(id, model, now)) return null;
|
|
63
|
+
entry.busy = true;
|
|
64
|
+
return () => { entry.busy = false; };
|
|
65
|
+
}
|
|
66
|
+
/** An owned successful send proves admission for its requested family even without headers. */
|
|
67
|
+
export function clearAnthropicRequestedFamilyQuota(id: string, model?: string): void {
|
|
68
|
+
const family = anthropicModelFamily(model);
|
|
69
|
+
if (family && spentFamilies.delete(keyFor(id, family))) familyGenerations.set(id, nextFamilyGeneration++);
|
|
70
|
+
}
|
|
71
|
+
export function clearAnthropicFamilyQuota(): void { spentFamilies.clear(); familyGenerations.clear(); }
|
|
72
|
+
|
|
73
|
+
export function anthropicModelWeeklyPercent(quota: ProviderQuota | null, model?: string): number | undefined {
|
|
74
|
+
const family = anthropicModelFamily(model);
|
|
75
|
+
return family ? quota?.customWindows?.find(window => window.scope === "model" && window.label === family)?.percent : undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function anthropicFamilyRetryAt(id: string, model?: string, now = Date.now()): number | undefined {
|
|
79
|
+
const family = anthropicModelFamily(model);
|
|
80
|
+
const entry = family && ownedObservation(id, family);
|
|
81
|
+
if (!entry || !anthropicFamilyRejected(id, model, now)) return undefined;
|
|
82
|
+
return entry.busy ? now + 1000 : Math.min(entry.resetAt ?? Infinity, entry.seenAt + ANTHROPIC_PASSIVE_FAMILY_MAX_AGE_MS);
|
|
83
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** Response-header policy for managed Anthropic sends; no account or request payload data. */
|
|
2
|
+
export type AnthropicRateLimitKind = "shared-quota" | "family-quota" | "transient-rate" | "request-scoped-unknown";
|
|
3
|
+
export type RateLimitHeaders = Pick<Headers, "get">;
|
|
4
|
+
export const ANTHROPIC_SHORT_RETRY_MS = 100;
|
|
5
|
+
export const ANTHROPIC_MAX_INLINE_THROTTLE_MS = 1_000;
|
|
6
|
+
|
|
7
|
+
export function anthropicRetryAfterMs(value: string | null | undefined, now: number): number | undefined {
|
|
8
|
+
const text = value?.trim();
|
|
9
|
+
if (!text || text.length > 128) return undefined;
|
|
10
|
+
const at = /^\d+(?:\.\d+)?$/.test(text) ? now + Number(text) * 1000 : Date.parse(text);
|
|
11
|
+
return Number.isFinite(new Date(at).getTime()) && at > now ? Math.ceil(at - now) : undefined;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function classifyAnthropic429(headers: RateLimitHeaders, now = Date.now()): AnthropicRateLimitKind {
|
|
15
|
+
const rejected = (window: string) => headers.get(`anthropic-ratelimit-unified-${window}-status`)?.trim() === "rejected";
|
|
16
|
+
if (rejected("5h") || rejected("7d")) return "shared-quota";
|
|
17
|
+
if (rejected("7d_oi")) return "family-quota";
|
|
18
|
+
if (headers.get("anthropic-ratelimit-unified-status")?.trim() === "rejected") return "shared-quota";
|
|
19
|
+
// Retry-After is an account-local admission pause, not proof of spent subscription quota.
|
|
20
|
+
if (anthropicRetryAfterMs(headers.get("retry-after"), now) !== undefined
|
|
21
|
+
|| headers.get("anthropic-ratelimit-unified-status")?.trim() === "allowed") return "transient-rate";
|
|
22
|
+
return "request-scoped-unknown";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const pauses = new Map<string, number>();
|
|
26
|
+
export function pauseAnthropicRateAdmission(accountId: string, until: number): void {
|
|
27
|
+
const now = Date.now();
|
|
28
|
+
for (const [id, deadline] of pauses) if (deadline <= now) pauses.delete(id);
|
|
29
|
+
pauses.set(accountId, Math.max(pauses.get(accountId) ?? 0, until));
|
|
30
|
+
}
|
|
31
|
+
export function anthropicRatePauseUntil(accountId: string, now = Date.now()): number | undefined {
|
|
32
|
+
const until = pauses.get(accountId);
|
|
33
|
+
if (until === undefined) return undefined;
|
|
34
|
+
if (until <= now) { pauses.delete(accountId); return undefined; }
|
|
35
|
+
return until;
|
|
36
|
+
}
|
|
37
|
+
export function clearAnthropicRatePauses(): void { pauses.clear(); }
|