@bitkyc08/opencodex 2.88.0 → 2.90.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 +11 -1
- package/bin/ocx.mjs +15 -4
- package/gui/dist/assets/App-DgxkE6Md.js +58 -0
- package/gui/dist/assets/App-dnkJ85te.css +1 -0
- package/gui/dist/assets/{Tray-B5bC7AWL.js → Tray-DbTNc_88.js} +1 -1
- package/gui/dist/assets/{index-B5bsvxor.js → index-TmaHa54u.js} +15 -15
- package/gui/dist/assets/quota-summary-B16x5DZZ.js +1 -0
- package/gui/dist/index.html +1 -1
- package/package.json +2 -2
- package/src/adapters/anthropic/cache-retention.ts +117 -0
- package/src/adapters/anthropic/passthrough.ts +7 -4
- package/src/adapters/anthropic.ts +10 -3
- package/src/adapters/base.ts +4 -0
- package/src/adapters/claude-cli/adapter.ts +13 -7
- package/src/adapters/claude-cli/usage-admission.ts +14 -2
- package/src/adapters/coding-agent/protocol.ts +6 -0
- package/src/adapters/coding-agent/turn.ts +14 -4
- package/src/adapters/cursor/protobuf-request.ts +3 -0
- package/src/adapters/google-http.ts +3 -2
- package/src/adapters/google-progress-history.ts +19 -1
- package/src/adapters/google-vertex-endpoint.ts +67 -0
- package/src/adapters/google.ts +62 -37
- package/src/adapters/image.ts +5 -3
- package/src/adapters/input-media-guard.ts +5 -2
- package/src/adapters/kiro-refusal.ts +41 -1
- package/src/adapters/ollama-native.ts +1 -0
- package/src/adapters/openai-chat/messages.ts +34 -4
- package/src/adapters/openai-chat/passthrough.ts +37 -4
- package/src/adapters/openai-chat/reasoning-replay.ts +98 -13
- package/src/adapters/openai-chat/serialized-tool-call-content.ts +95 -134
- package/src/adapters/openai-chat/serialized-tool-call-markup.ts +108 -0
- package/src/adapters/openai-chat/standalone-bare-tool-calls.ts +136 -0
- package/src/adapters/openai-chat.ts +7 -6
- package/src/adapters/openai-responses/compaction-search-history.ts +27 -0
- package/src/adapters/openai-responses/opengateway-input.ts +139 -0
- package/src/adapters/openai-responses/passthrough.ts +22 -13
- package/src/adapters/openai-responses/reasoning.ts +68 -1
- package/src/adapters/openai-responses/transport.ts +13 -1
- package/src/adapters/registry.ts +1 -1
- package/src/adapters/upstream-http-error.ts +3 -2
- package/src/adapters/zed.ts +7 -4
- package/src/bridge/internal.ts +11 -1
- package/src/bridge/response-json.ts +2 -2
- package/src/bridge/sse.ts +10 -4
- package/src/claude/desktop-3p-library.ts +43 -3
- package/src/claude/desktop-3p.ts +20 -10
- package/src/claude/desktop-picker-profile.ts +18 -9
- package/src/claude/desktop-remote-store-io.ts +2 -2
- package/src/claude/inbound.ts +12 -26
- package/src/claude/outbound.ts +5 -0
- package/src/cli/account-api.ts +13 -1
- package/src/cli/account-extended.ts +17 -5
- package/src/cli/account-key-quota.ts +4 -1
- package/src/cli/account.ts +1 -1
- package/src/cli/capabilities-agents-routing.ts +45 -8
- package/src/cli/capabilities-base.ts +5 -5
- package/src/cli/capabilities-provider-models.ts +5 -4
- package/src/cli/combo-result.ts +8 -2
- package/src/cli/combo.ts +17 -0
- package/src/cli/ensure-desktop-cold-start.ts +2 -2
- package/src/cli/help.ts +1 -1
- package/src/cli/message-command.ts +4 -1
- package/src/cli/message-remote-args.ts +69 -0
- package/src/cli/message-remote-command.ts +77 -0
- package/src/cli/provider-runtime.ts +69 -1
- package/src/cli/registry.ts +3 -3
- package/src/codex/account-lifecycle.ts +19 -6
- package/src/codex/account-pause.ts +6 -1
- package/src/codex/account-runtime-state.ts +5 -0
- package/src/codex/account-store.ts +55 -9
- package/src/codex/account-usability.ts +4 -1
- package/src/codex/auth-api/account-list.ts +13 -5
- package/src/codex/auth-api/account-pause-group.ts +2 -22
- package/src/codex/auth-api/login-collision.ts +59 -0
- package/src/codex/auth-api/login-flow.ts +12 -7
- package/src/codex/auth-api/main-account-probe.ts +3 -2
- package/src/codex/auth-api/pool-mode-gate.ts +81 -13
- package/src/codex/auth-collision.ts +31 -4
- package/src/codex/auth-context.ts +57 -10
- package/src/codex/catalog/aggregation.ts +15 -0
- package/src/codex/catalog/auto-review.ts +2 -1
- package/src/codex/catalog/build-entries.ts +17 -7
- package/src/codex/catalog/derive-entry.ts +3 -1
- package/src/codex/catalog/metadata.ts +2 -0
- package/src/codex/catalog/model-visibility.ts +0 -2
- package/src/codex/catalog/ownership.ts +71 -0
- package/src/codex/catalog/parsing.ts +12 -3
- package/src/codex/catalog/retained-sync.ts +2 -1
- package/src/codex/catalog/routed-removal.ts +4 -4
- package/src/codex/cli-update-plan.ts +18 -3
- package/src/codex/collision-identity.ts +78 -0
- package/src/codex/convergence.ts +2 -1
- package/src/codex/low-quota-desktop-notifier.ts +125 -0
- package/src/codex/low-quota-protection.ts +57 -31
- package/src/codex/main-account-cache.ts +77 -3
- package/src/codex/main-account-hard-lock.ts +14 -0
- package/src/codex/main-account.ts +6 -2
- package/src/codex/native-residue.ts +2 -3
- package/src/codex/quota.ts +26 -2
- package/src/codex/routing/cooldown-math.ts +5 -0
- package/src/codex/routing/failure-window.ts +61 -9
- package/src/codex/routing.ts +4 -3
- package/src/codex/shim-diagnostics.ts +2 -1
- package/src/codex/shim-fingerprint.ts +11 -2
- package/src/codex/shim-templates.ts +16 -5
- package/src/codex/shim-windows-refresh.ts +224 -0
- package/src/codex/shim.ts +45 -21
- package/src/combos/failover.ts +10 -2
- package/src/combos/jev-model-backend.ts +30 -7
- package/src/combos/jev-model-request.ts +20 -0
- package/src/combos/jev-quota-activation.ts +153 -0
- package/src/combos/jev-quota-config.ts +62 -0
- package/src/combos/jev-quota-route.ts +26 -0
- package/src/combos/jev-quota.ts +88 -0
- package/src/combos/jev.ts +25 -12
- package/src/combos/types.ts +27 -0
- package/src/compatibility/index.ts +3 -0
- package/src/compatibility/openai-responses.ts +8 -1
- package/src/compatibility/opengateway-responses.ts +61 -0
- package/src/config/account-auto-pause-revisions.ts +136 -0
- package/src/config/diagnostics.ts +13 -0
- package/src/config/live-reconcile.ts +9 -0
- package/src/config/persist-unlocked.ts +10 -1
- package/src/config/provider-validation.ts +7 -2
- package/src/config/removed-opencode-free.ts +135 -0
- package/src/config/schema/account-auto-pause.ts +14 -0
- package/src/config/schema/config-schema.ts +15 -0
- package/src/config/schema/leaf-validators.ts +7 -0
- package/src/config.ts +10 -3
- package/src/generated/compatibility-version.json +476 -216
- package/src/generated/model-metadata.ts +1 -1
- package/src/images/loop.ts +34 -11
- package/src/lib/account-auto-pause-response.ts +16 -0
- package/src/lib/bun-runtime.ts +18 -1
- package/src/lib/bun-transpiler-cache-scope.d.mts +8 -0
- package/src/lib/bun-transpiler-cache-scope.mjs +82 -0
- package/src/lib/decision-quota-sink.ts +76 -0
- package/src/lib/provider-admission-wait.ts +14 -0
- package/src/lib/service-secrets.ts +37 -2
- package/src/lib/state-store-registrations.ts +25 -0
- package/src/lib/translator-budget.ts +1 -1
- package/src/lib/upstream-retry.ts +15 -0
- package/src/link/ssh-argv.ts +3 -0
- package/src/messaging/envelope.ts +6 -3
- package/src/messaging/remote-auth.ts +83 -0
- package/src/messaging/remote-bridge.ts +270 -0
- package/src/messaging/remote-contract.ts +108 -0
- package/src/messaging/remote-enrollment-recovery.ts +59 -0
- package/src/messaging/remote-enrollment.ts +244 -0
- package/src/messaging/remote-envelope.ts +40 -0
- package/src/messaging/remote-files.ts +117 -0
- package/src/messaging/remote-owner.ts +169 -0
- package/src/messaging/remote-ports.ts +56 -0
- package/src/messaging/remote-process.ts +131 -0
- package/src/messaging/remote-replies.ts +98 -0
- package/src/messaging/remote-rpc-admission.ts +32 -0
- package/src/messaging/remote-send.ts +72 -0
- package/src/messaging/remote-store.ts +111 -0
- package/src/messaging/remote-tunnels.ts +65 -0
- package/src/messaging/rpc.ts +13 -1
- package/src/oauth/account-auto-pause-policy.ts +66 -0
- package/src/oauth/account-auto-pause.ts +171 -0
- package/src/oauth/account-import/google-antigravity-adapter.ts +38 -2
- package/src/oauth/account-import/parser.ts +15 -2
- package/src/oauth/account-import/types.ts +7 -0
- package/src/oauth/account-quota-rank.ts +2 -1
- package/src/oauth/anthropic-account-refusal.ts +6 -0
- package/src/oauth/anthropic-model-family.ts +8 -0
- package/src/oauth/anthropic-model-quota.ts +2 -6
- package/src/oauth/api-key-pool-preserve.ts +37 -0
- package/src/oauth/chatgpt-shared.ts +10 -1
- package/src/oauth/dsh.ts +45 -2
- package/src/oauth/index.ts +5 -35
- package/src/oauth/kiro.ts +44 -1
- package/src/oauth/login-flow-state.ts +11 -2
- package/src/oauth/store.ts +35 -8
- package/src/oauth/types.ts +2 -0
- package/src/plugins/upstream-hooks.ts +14 -1
- package/src/providers/api-key-pause.ts +32 -0
- package/src/providers/api-key-resolve.ts +10 -4
- package/src/providers/api-key-selection.ts +6 -1
- package/src/providers/api-keys.ts +8 -4
- package/src/providers/claude-cli-usage.ts +25 -5
- package/src/providers/initial-model-selection.ts +2 -2
- package/src/providers/key-failover.ts +21 -13
- package/src/providers/key-store.ts +8 -3
- package/src/providers/mistral-transport.ts +207 -0
- package/src/providers/model-rename-fields.ts +1 -0
- package/src/providers/openai-sidecar.ts +5 -0
- package/src/providers/opencode-zen-rate-limit.ts +6 -72
- package/src/providers/quota/account-cache.ts +15 -1
- package/src/providers/quota/anthropic-account-quota.ts +20 -7
- package/src/providers/quota/anthropic-cooldown-recovery.ts +22 -4
- package/src/providers/quota/anthropic-decision-windows.ts +47 -0
- package/src/providers/quota/antigravity.ts +45 -8
- package/src/providers/quota/failure-codes.ts +18 -0
- package/src/providers/quota/report-cache.ts +14 -0
- package/src/providers/quota/vendor-probes-oauth.ts +15 -6
- package/src/providers/quota-decision-publication.ts +77 -0
- package/src/providers/quota-decision-snapshot.ts +145 -0
- package/src/providers/quota-routing-cache.ts +4 -1
- package/src/providers/quota-types.ts +6 -1
- package/src/providers/quota.ts +8 -3
- package/src/providers/registry/entries-extended.ts +14 -53
- package/src/providers/registry/model-seeds.ts +1 -1
- package/src/providers/registry.ts +1 -2
- package/src/providers/routed-config.ts +1 -1
- package/src/providers/stale-vision-classification-migration.ts +1 -1
- package/src/providers/xai-transport.ts +2 -1
- package/src/responses/audio.ts +46 -0
- package/src/responses/input-media.ts +37 -15
- package/src/responses/parser-content.ts +22 -27
- package/src/responses/parser.ts +18 -10
- package/src/responses/reasoning-retention.ts +218 -11
- package/src/responses/schema.ts +3 -4
- package/src/responses/web-search-replay.ts +147 -0
- package/src/routing/compatibility/behavior.ts +4 -1
- package/src/server/auth-cors.ts +7 -0
- package/src/server/background-lifecycle.ts +2 -1
- package/src/server/chat-native.ts +36 -20
- package/src/server/claude-messages.ts +2 -1
- package/src/server/index/optional-listeners.ts +6 -0
- package/src/server/index/serve-options.ts +8 -2
- package/src/server/index/websocket-handler.ts +10 -0
- package/src/server/lifecycle.ts +17 -1
- package/src/server/management/combo-routes.ts +17 -2
- package/src/server/management/oauth-account-routes.ts +12 -2
- package/src/server/management/provider-completion-test.ts +289 -0
- package/src/server/management/provider-routes.ts +9 -1
- package/src/server/management/route-registry.ts +2 -0
- package/src/server/messages-native.ts +29 -4
- package/src/server/messages-shared.ts +4 -1
- package/src/server/request-log.ts +23 -9
- package/src/server/responses/account-auto-pause-recovery.ts +26 -0
- package/src/server/responses/adapter-continuation.ts +2 -2
- package/src/server/responses/adapter-dispatch.ts +79 -30
- package/src/server/responses/codex-ws-exchange.ts +3 -0
- package/src/server/responses/codex-ws-metadata.ts +44 -0
- package/src/server/responses/codex-ws-session.ts +21 -1
- package/src/server/responses/combo-session-recall.ts +25 -0
- package/src/server/responses/combo-stream-preflight.ts +10 -3
- package/src/server/responses/combo-tool-loop-pin.ts +70 -0
- package/src/server/responses/compact.ts +10 -5
- package/src/server/responses/context-overflow.ts +141 -2
- package/src/server/responses/core-auth.ts +20 -0
- package/src/server/responses/core-codex-account.ts +28 -15
- package/src/server/responses/core-combo.ts +34 -5
- package/src/server/responses/core-opaque-recovery.ts +20 -11
- package/src/server/responses/core.ts +12 -9
- package/src/server/responses/deepseek-image-files.ts +121 -0
- package/src/server/responses/empty-completion-guard.ts +10 -0
- package/src/server/responses/fetch-helpers.ts +65 -14
- package/src/server/responses/input-admission.ts +6 -2
- package/src/server/responses/jev-model-invoke.ts +13 -14
- package/src/server/responses/main-hard-lock-routing.ts +33 -8
- package/src/server/responses/native-steering-log.ts +2 -2
- package/src/server/responses/opengateway-completion-guard.ts +232 -0
- package/src/server/responses/passthrough-completion.ts +41 -0
- package/src/server/responses/passthrough-delivery.ts +5 -2
- package/src/server/responses/passthrough-dispatch.ts +42 -45
- package/src/server/responses/passthrough-key-rotation.ts +40 -0
- package/src/server/responses/passthrough-observations.ts +38 -0
- package/src/server/responses/reasoning-archive-error.ts +21 -0
- package/src/server/responses/request-compression.ts +98 -0
- package/src/server/responses/request-prepare.ts +11 -1
- package/src/server/responses/request-transport.ts +92 -5
- package/src/server/responses/run-turn-execution.ts +2 -0
- package/src/server/responses/sidecar-execution.ts +16 -3
- package/src/server/responses/terminal-guard.ts +2 -0
- package/src/server/startup-action-control.ts +2 -2
- package/src/server/startup-health-cache.ts +2 -2
- package/src/server/windows-tray-control.ts +2 -2
- package/src/service/definition.ts +9 -1
- package/src/service/orchestration.ts +17 -0
- package/src/types/account-auto-pause.ts +15 -0
- package/src/types/config.ts +23 -1
- package/src/types/provider.ts +11 -5
- package/src/types/request.ts +36 -3
- package/src/types.ts +2 -0
- package/src/usage/cost.ts +17 -1
- package/src/usage/expected-prices.ts +3 -1
- package/src/usage/jev-stats.ts +4 -0
- package/src/usage/log.ts +2 -0
- package/src/usage/summary.ts +7 -0
- package/src/web-search/loop.ts +39 -13
- package/src/web-search/progress-stream.ts +4 -1
- package/src/web-search/run-turn-loop.ts +9 -3
- package/src/web-search/sidecar-search-replay.ts +96 -0
- package/gui/dist/assets/App-CHC89EXQ.js +0 -58
- package/gui/dist/assets/App-wT3mXqFz.css +0 -1
- package/gui/dist/assets/quota-summary-D8y2wTsB.js +0 -1
package/src/oauth/store.ts
CHANGED
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
* distinct one. Kimi extracts JWT `user_id`/`sub` as accountId; Cursor extracts JWT `sub` —
|
|
20
20
|
* both append distinct identified accounts under multiauth.
|
|
21
21
|
*/
|
|
22
|
+
import { notifyAuthStoreLoaded } from "../lib/decision-quota-sink";
|
|
23
|
+
import { accountPauseDetailsSchema } from "../config/schema/account-auto-pause";
|
|
22
24
|
import { normalizeOAuthPlan, mergeOAuthPlan } from "./plan";
|
|
23
25
|
import { normalizeAnthropicIdentity, matchesAnthropicImport, mergeAnthropicIdentity } from "./anthropic-identity";
|
|
24
26
|
import { findKiroAccountForLogin, withRetainedKiroUser } from "./kiro-account-identity";
|
|
@@ -354,17 +356,23 @@ export function credentialGeneration(cred: OAuthCredentials): string {
|
|
|
354
356
|
return createHash("sha256").update(JSON.stringify([cred.refresh, cred.access, cred.expires])).digest("hex");
|
|
355
357
|
}
|
|
356
358
|
|
|
359
|
+
/** Load and normalize the OAuth auth store with hardened permissions, and hand the result to the opt-in decision-quota slot (a no-op unless JEV quota signals are active). */
|
|
357
360
|
function loadAuthStoreInternal(): { store: AuthStore; hadLegacy: boolean } {
|
|
358
361
|
const path = getAuthStorePath();
|
|
359
362
|
hardenConfigDir();
|
|
360
363
|
hardenExistingSecret(path);
|
|
361
|
-
if (!existsSync(path)) return { store: {}, hadLegacy: false };
|
|
364
|
+
if (!existsSync(path)) { notifyAuthStoreLoaded({}); return { store: {}, hadLegacy: false }; }
|
|
365
|
+
let result: { store: AuthStore; hadLegacy: boolean };
|
|
362
366
|
try {
|
|
363
|
-
|
|
367
|
+
result = normalizeAuthStore(JSON.parse(readFileSync(path, "utf-8")));
|
|
364
368
|
} catch {
|
|
365
369
|
backupInvalidConfig(path);
|
|
370
|
+
notifyAuthStoreLoaded({});
|
|
366
371
|
return { store: {}, hadLegacy: false };
|
|
367
372
|
}
|
|
373
|
+
// Outside the parse try: roster publication is advisory and must never route a loaded store into the invalid-config backup path.
|
|
374
|
+
notifyAuthStoreLoaded(result.store);
|
|
375
|
+
return result;
|
|
368
376
|
}
|
|
369
377
|
|
|
370
378
|
export function loadAuthStore(): AuthStore {
|
|
@@ -403,6 +411,7 @@ export function peekAuthStore(): AuthStore {
|
|
|
403
411
|
return snapshot.kind === "ready" ? snapshot.store : {};
|
|
404
412
|
}
|
|
405
413
|
|
|
414
|
+
/** Atomically write the auth store with owner-only permissions, then hand it to the opt-in decision-quota slot. */
|
|
406
415
|
function persist(store: AuthStore): void {
|
|
407
416
|
const dir = getConfigDir();
|
|
408
417
|
assertNotRealHomeUnderTest(dir);
|
|
@@ -413,6 +422,7 @@ function persist(store: AuthStore): void {
|
|
|
413
422
|
}
|
|
414
423
|
hardenConfigDir();
|
|
415
424
|
atomicWriteFile(getAuthStorePath(), JSON.stringify(store, null, 2) + "\n");
|
|
425
|
+
notifyAuthStoreLoaded(store);
|
|
416
426
|
}
|
|
417
427
|
|
|
418
428
|
export class OAuthFileLockError extends Error { readonly code = "OAUTH_FILE_LOCK_UNAVAILABLE"; constructor(message: string, options?: { cause?: unknown }) { super(message, options); this.name = "OAuthFileLockError"; } }
|
|
@@ -638,7 +648,12 @@ function normalizeAccount(value: unknown): ProviderAccount | null {
|
|
|
638
648
|
if (candidate.refreshAttentionGeneration === credentialGeneration(credential)) {
|
|
639
649
|
account.refreshAttentionGeneration = candidate.refreshAttentionGeneration;
|
|
640
650
|
}
|
|
641
|
-
if (candidate.paused === true)
|
|
651
|
+
if (candidate.paused === true) {
|
|
652
|
+
account.paused = true;
|
|
653
|
+
const details = accountPauseDetailsSchema.safeParse(candidate.pauseDetails);
|
|
654
|
+
if (details.success) account.pauseDetails = details.data;
|
|
655
|
+
}
|
|
656
|
+
if (typeof candidate.pauseRevision === "string") account.pauseRevision = candidate.pauseRevision;
|
|
642
657
|
const threshold = parseAnthropicAccountThreshold(candidate.autoSwitchThresholdOverride);
|
|
643
658
|
if (threshold !== null) account.autoSwitchThresholdOverride = threshold;
|
|
644
659
|
if (candidate.needsReauth === true && candidate.needsReauthReason === "verify_account") {
|
|
@@ -801,7 +816,7 @@ function serializeMutation<T>(work: () => Promise<T>, retainedValues: readonly u
|
|
|
801
816
|
drainOAuthMutations();
|
|
802
817
|
return result;
|
|
803
818
|
}
|
|
804
|
-
export function mutateStore<T>(fn:(store:AuthStore)=>T|Promise<T>, retainedValues: readonly unknown[] = [], options?: { waitMs?: number; assertBeforePersist?: () => void; scrubLegacyBackup?: (result: T) => readonly string[]; finalizeResult?: (result: T, store: AuthStore) => void; afterPersist?: (result: T) => void }):Promise<T>{return serializeMutation(async()=>{const guard=await createOAuthFileLock({path:getAuthStoreLockPath(),staleAfterMs:30000}).acquire();try{
|
|
819
|
+
export function mutateStore<T>(fn:(store:AuthStore)=>T|Promise<T>, retainedValues: readonly unknown[] = [], options?: { waitMs?: number; assertBeforePersist?: () => void; scrubLegacyBackup?: (result: T) => readonly string[]; finalizeResult?: (result: T, store: AuthStore) => void; afterPersist?: (result: T) => void; withConfigLock?: boolean }):Promise<T>{return serializeMutation(async()=>{const guard=await createOAuthFileLock({path:getAuthStoreLockPath(),staleAfterMs:30000}).acquire();try{
|
|
805
820
|
const { store, hadLegacy } = loadAuthStoreInternal();
|
|
806
821
|
if (hadLegacy) backupLegacyOnce();
|
|
807
822
|
const selections = new Map(Object.entries(store).map(([provider, set]) => [provider, {
|
|
@@ -814,7 +829,7 @@ export function mutateStore<T>(fn:(store:AuthStore)=>T|Promise<T>, retainedValue
|
|
|
814
829
|
// Only providers whose credentials this mutation actually removed leave the downgrade
|
|
815
830
|
// backup; a no-op removal names none. The result decides, so it is read here.
|
|
816
831
|
const scrubbedProviders = options?.scrubLegacyBackup?.(result) ?? [];
|
|
817
|
-
options?.assertBeforePersist?.();
|
|
832
|
+
if (!options?.withConfigLock) options?.assertBeforePersist?.();
|
|
818
833
|
const changedProviders: string[] = [];
|
|
819
834
|
for (const provider of new Set([...selections.keys(), ...Object.keys(store)])) {
|
|
820
835
|
const before = selections.get(provider);
|
|
@@ -837,7 +852,8 @@ export function mutateStore<T>(fn:(store:AuthStore)=>T|Promise<T>, retainedValue
|
|
|
837
852
|
// Receipt-producing mutations need the revision assigned by the bookkeeping above, not the
|
|
838
853
|
// provisional value visible inside their callback. Finalization cannot await or mutate disk.
|
|
839
854
|
options?.finalizeResult?.(result, store);
|
|
840
|
-
persist(store);
|
|
855
|
+
const publish = () => { if (options?.withConfigLock) options.assertBeforePersist?.(); persist(store); };
|
|
856
|
+
if (options?.withConfigLock) withConfigMutationLockSync(publish); else publish();
|
|
841
857
|
if (scrubbedProviders.length > 0) scrubLegacyBackup(scrubbedProviders);
|
|
842
858
|
// A committed observer may establish ordering before the generic selection
|
|
843
859
|
// publication, but its failure can never turn a durable write into a reported failure.
|
|
@@ -1388,17 +1404,23 @@ export async function setAccountPaused(
|
|
|
1388
1404
|
provider: string,
|
|
1389
1405
|
accountId: string,
|
|
1390
1406
|
paused: boolean,
|
|
1407
|
+
automatic?: { generation: string; pauseRevision?: string; details: import("../types/account-auto-pause").AccountPauseDetails; isCurrent?: () => boolean; assertPolicyBeforePersist?: () => void },
|
|
1391
1408
|
): Promise<SetAccountPausedResult> {
|
|
1392
1409
|
const result = await mutateStore(store => {
|
|
1393
1410
|
const set = store[provider];
|
|
1394
1411
|
const account = set?.accounts.find(candidate => candidate.id === accountId);
|
|
1395
1412
|
if (!set || !account) return { status: "not-found" } as const;
|
|
1396
|
-
if ((
|
|
1413
|
+
if (automatic && (automatic.isCurrent?.() === false || credentialGeneration(account.credential) !== automatic.generation
|
|
1414
|
+
|| account.pauseRevision !== automatic.pauseRevision)) return { status: "not-found" } as const;
|
|
1415
|
+
if (paused && account.paused === true) {
|
|
1397
1416
|
return { status: "unchanged", activeAccountId: set.activeAccountId, activeAccountChanged: false } as const;
|
|
1398
1417
|
}
|
|
1399
1418
|
|
|
1400
1419
|
if (paused) account.paused = true;
|
|
1401
1420
|
else delete account.paused;
|
|
1421
|
+
account.pauseRevision = randomUUID();
|
|
1422
|
+
if (automatic) account.pauseDetails = automatic.details;
|
|
1423
|
+
else delete account.pauseDetails;
|
|
1402
1424
|
// Pause is part of selection eligibility even when the operator changes a non-active
|
|
1403
1425
|
// account. Bump the provider selection revision so observers receive the existing
|
|
1404
1426
|
// post-persistence roster invalidation and stale automatic proposals cannot commit.
|
|
@@ -1432,7 +1454,10 @@ export async function setAccountPaused(
|
|
|
1432
1454
|
activeAccountId: set.activeAccountId,
|
|
1433
1455
|
activeAccountChanged,
|
|
1434
1456
|
} as const;
|
|
1435
|
-
}, [provider, accountId, paused])
|
|
1457
|
+
}, [provider, accountId, paused], { withConfigLock: !!automatic, assertBeforePersist: () => {
|
|
1458
|
+
automatic?.assertPolicyBeforePersist?.();
|
|
1459
|
+
if (automatic?.isCurrent?.() === false) throw new Error("Account auto-pause policy changed before persistence");
|
|
1460
|
+
} });
|
|
1436
1461
|
if (result.status === "updated") publishOAuthAccountPauseChange(provider);
|
|
1437
1462
|
return result;
|
|
1438
1463
|
}
|
|
@@ -1485,6 +1510,8 @@ export async function replaceProviderAccountSet(
|
|
|
1485
1510
|
...(account.needsReauth ? { needsReauth: true } : {}),
|
|
1486
1511
|
...(account.refreshAttentionGeneration ? { refreshAttentionGeneration: account.refreshAttentionGeneration } : {}),
|
|
1487
1512
|
...(account.paused ? { paused: true } : {}),
|
|
1513
|
+
...(account.pauseDetails ? { pauseDetails: account.pauseDetails } : {}),
|
|
1514
|
+
...(account.pauseRevision ? { pauseRevision: account.pauseRevision } : {}),
|
|
1488
1515
|
...(account.autoSwitchThresholdOverride !== undefined ? { autoSwitchThresholdOverride: account.autoSwitchThresholdOverride } : {}),
|
|
1489
1516
|
...(account.needsReauthReason === "verify_account" ? { needsReauthReason: account.needsReauthReason } : {}),
|
|
1490
1517
|
...(account.addedAt !== undefined ? { addedAt: account.addedAt } : {}),
|
package/src/oauth/types.ts
CHANGED
|
@@ -96,6 +96,8 @@ export interface ProviderAccount {
|
|
|
96
96
|
refreshAttentionGeneration?: string;
|
|
97
97
|
/** Operator exclusion from generic OAuth account selection until explicitly resumed. */
|
|
98
98
|
paused?: boolean;
|
|
99
|
+
pauseDetails?: import("../types/account-auto-pause").AccountPauseDetails;
|
|
100
|
+
pauseRevision?: string;
|
|
99
101
|
/** Anthropic-only usage-switch override; absent inherits its pool default, zero disables it. */
|
|
100
102
|
autoSwitchThresholdOverride?: number;
|
|
101
103
|
/**
|
|
@@ -31,13 +31,20 @@ interface Registration {
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
const registrations: Registration[] = [];
|
|
34
|
+
// Changes on every registration and removal, so an observer can tell that no rewriter could
|
|
35
|
+
// have touched a send between two points even when the slot is empty at both.
|
|
36
|
+
let registrationEpoch = 0;
|
|
34
37
|
|
|
35
38
|
export function registerUpstreamRewriter(name: string, rewrite: UpstreamRewriter): () => void {
|
|
36
39
|
const registration: Registration = { name, rewrite, disabled: false };
|
|
37
40
|
registrations.push(registration);
|
|
41
|
+
registrationEpoch += 1;
|
|
38
42
|
return () => {
|
|
39
43
|
const index = registrations.indexOf(registration);
|
|
40
|
-
if (index >= 0)
|
|
44
|
+
if (index >= 0) {
|
|
45
|
+
registrations.splice(index, 1);
|
|
46
|
+
registrationEpoch += 1;
|
|
47
|
+
}
|
|
41
48
|
};
|
|
42
49
|
}
|
|
43
50
|
|
|
@@ -45,6 +52,11 @@ export function hasUpstreamRewriters(): boolean {
|
|
|
45
52
|
return registrations.length > 0;
|
|
46
53
|
}
|
|
47
54
|
|
|
55
|
+
/** Monotonic registration epoch; equal values bracket a window with no registration change. */
|
|
56
|
+
export function upstreamRewriterEpoch(): number {
|
|
57
|
+
return registrationEpoch;
|
|
58
|
+
}
|
|
59
|
+
|
|
48
60
|
/**
|
|
49
61
|
* Run every active rewriter over one send. Returns the input untouched (same objects) when
|
|
50
62
|
* no rewriter is registered, so the common path allocates nothing.
|
|
@@ -116,4 +128,5 @@ export function rewriteWebSocketDial(
|
|
|
116
128
|
|
|
117
129
|
export function resetUpstreamRewritersForTests(): void {
|
|
118
130
|
registrations.length = 0;
|
|
131
|
+
registrationEpoch += 1;
|
|
119
132
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { accountAutoPauseFenceMatchesDisk, type AccountAutoPauseFence } from "../config/account-auto-pause-revisions";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import type { OcxConfig } from "../types";
|
|
4
|
+
import type { ProviderApiKeySelection } from "../types/provider";
|
|
5
|
+
import type { AccountPauseDetails } from "../types/account-auto-pause";
|
|
6
|
+
import { apiKeyPoolEntryId } from "./api-keys";
|
|
7
|
+
import { resolveProviderApiKey } from "./api-key-resolve";
|
|
8
|
+
import { commitProviderApiKeySelection } from "./api-key-selection";
|
|
9
|
+
|
|
10
|
+
export function setProviderApiKeyPaused(config: OcxConfig, name: string, id: string, paused: boolean,
|
|
11
|
+
automatic?: { fence: AccountAutoPauseFence; selection: ProviderApiKeySelection; pauseRevision?: string; resolvedKey?: string; details: AccountPauseDetails }): boolean {
|
|
12
|
+
const result = commitProviderApiKeySelection(config, name, provider => {
|
|
13
|
+
if (automatic && !accountAutoPauseFenceMatchesDisk(name, automatic.fence)) return { changed: false, value: false };
|
|
14
|
+
const pool = provider.apiKeyPool?.length ? provider.apiKeyPool
|
|
15
|
+
: provider.apiKey ? [{ id: apiKeyPoolEntryId(provider.apiKey), key: provider.apiKey }] : [];
|
|
16
|
+
const entry = pool.find(e => e.id === id);
|
|
17
|
+
if (!entry || automatic && (entry.disabled || entry.key !== automatic.selection.reference || entry.pauseRevision !== automatic.pauseRevision
|
|
18
|
+
|| provider.accountAutoPause?.enabled !== true || resolveProviderApiKey(entry.key) !== automatic.resolvedKey)) return { changed: false, value: false };
|
|
19
|
+
provider.apiKeyPool = pool;
|
|
20
|
+
entry.disabled = paused;
|
|
21
|
+
entry.pauseRevision = randomUUID();
|
|
22
|
+
if (automatic) entry.pauseDetails = automatic.details;
|
|
23
|
+
else delete entry.pauseDetails;
|
|
24
|
+
if (paused && provider.apiKey === entry.key) {
|
|
25
|
+
const next = provider.apiKeyPool?.find(e => !e.disabled);
|
|
26
|
+
if (next) provider.apiKey = next.key;
|
|
27
|
+
else delete provider.apiKey;
|
|
28
|
+
} else if (!paused && !provider.apiKey) provider.apiKey = entry.key;
|
|
29
|
+
return { changed: true, selectionChanged: true, value: true };
|
|
30
|
+
}, automatic?.selection);
|
|
31
|
+
return result.status === "committed" && result.value;
|
|
32
|
+
}
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* Policy: a reference that cannot be resolved fails closed (no key) and is warned once per
|
|
17
17
|
* account; nothing ever rewrites plaintext into config or its backups.
|
|
18
18
|
*/
|
|
19
|
+
import { invalidateDecisionKeyQuotas, observeDecisionKeyCredential } from "../lib/decision-quota-sink";
|
|
19
20
|
import { resolveEnvValue } from "../config/proxy-env";
|
|
20
21
|
import { loadKeyringBinding } from "../lib/keyring-native";
|
|
21
22
|
import { isGcpCredentialMaterial } from "../lib/gcp-credential-input";
|
|
@@ -52,6 +53,7 @@ export function providerKeyResolutionGeneration(): number {
|
|
|
52
53
|
/** Test seam: swap the OS entry for an in-memory one and drop caches. */
|
|
53
54
|
export function setProviderKeychainEntryFactoryForTests(factory: ProviderKeychainEntryFactory | null): void {
|
|
54
55
|
entryFactory = factory ?? defaultEntryFactory;
|
|
56
|
+
invalidateDecisionKeyQuotas();
|
|
55
57
|
resolvedCache.clear();
|
|
56
58
|
resolutionGeneration++;
|
|
57
59
|
warnedAccounts.clear();
|
|
@@ -59,6 +61,7 @@ export function setProviderKeychainEntryFactoryForTests(factory: ProviderKeychai
|
|
|
59
61
|
|
|
60
62
|
/** Write-path seam: a store/restore mutated secrets, so cached reads and warnings are stale. */
|
|
61
63
|
export function invalidateResolvedProviderKeyCache(): void {
|
|
64
|
+
invalidateDecisionKeyQuotas();
|
|
62
65
|
resolvedCache.clear();
|
|
63
66
|
resolutionGeneration++;
|
|
64
67
|
warnedAccounts.clear();
|
|
@@ -114,15 +117,18 @@ function readKeychain(account: string, quiet = false): string | undefined {
|
|
|
114
117
|
*/
|
|
115
118
|
export function resolveProviderApiKey(value: string | undefined): string | undefined {
|
|
116
119
|
if (!value) return undefined;
|
|
117
|
-
|
|
118
|
-
|
|
120
|
+
const resolved = isKeychainReference(value) ? readKeychain(keychainAccount(value)) : resolveEnvValue(value);
|
|
121
|
+
observeDecisionKeyCredential(value, resolved);
|
|
122
|
+
return resolved;
|
|
119
123
|
}
|
|
120
124
|
|
|
121
125
|
/** The same resolution without the unreadable-reference warning, for observe-only callers. */
|
|
122
126
|
export function resolveProviderApiKeyQuietly(value: string | undefined): string | undefined {
|
|
123
127
|
if (!value) return undefined;
|
|
124
|
-
|
|
125
|
-
|
|
128
|
+
const resolved = isKeychainReference(value) ? readKeychain(keychainAccount(value), true) : resolveEnvValue(value);
|
|
129
|
+
// A quiet read still sees a replaced secret; advisory key evidence published under the old one must not outlive it.
|
|
130
|
+
observeDecisionKeyCredential(value, resolved);
|
|
131
|
+
return resolved;
|
|
126
132
|
}
|
|
127
133
|
|
|
128
134
|
export type ProviderKeyStoreKind = "keychain" | "env" | "file" | "none";
|
|
@@ -13,6 +13,9 @@ import { captureProviderApiKeySelection } from "./api-key-selection-capture";
|
|
|
13
13
|
export { captureProviderApiKeySelection } from "./api-key-selection-capture";
|
|
14
14
|
|
|
15
15
|
function matchesSelection(provider: OcxProviderConfig, expected: ProviderApiKeySelection): boolean {
|
|
16
|
+
if (expected.distributed) return provider.apiKeySelectionRevision === expected.revision
|
|
17
|
+
&& provider.requestPacing?.enabled === true && provider.requestPacing.keySelection === "round-robin"
|
|
18
|
+
&& !!provider.apiKeyPool?.some(entry => !entry.disabled && entry.id === expected.entryId && entry.key === expected.reference);
|
|
16
19
|
const current = captureProviderApiKeySelection(provider);
|
|
17
20
|
return current.entryId === expected.entryId && current.reference === expected.reference
|
|
18
21
|
&& current.revision === expected.revision;
|
|
@@ -24,10 +27,12 @@ function currentKeyProvider(config: OcxConfig, name: string, selection?: Provide
|
|
|
24
27
|
let row = configured;
|
|
25
28
|
if (selection?.distributed) {
|
|
26
29
|
const entry = configured.apiKeyPool?.find(key => key.id === selection.entryId && key.key === selection.reference);
|
|
27
|
-
if (!entry || (!allowCooling && isKeyInCooldown(name, entry.id)) || configured.apiKeySelectionRevision !== selection.revision
|
|
30
|
+
if (!entry || entry.disabled || (!allowCooling && isKeyInCooldown(name, entry.id)) || configured.apiKeySelectionRevision !== selection.revision
|
|
28
31
|
|| !configured.requestPacing?.enabled || configured.requestPacing.keySelection !== "round-robin") return null;
|
|
29
32
|
row = { ...configured, apiKey: entry.key, _apiKeyAttempt: selection };
|
|
30
33
|
}
|
|
34
|
+
if (row.apiKeyPool?.some(entry => entry.disabled && entry.key === row.apiKey)
|
|
35
|
+
|| row.apiKeyPool?.length && row.apiKeyPool.every(entry => entry.disabled)) return null;
|
|
31
36
|
const current = routedProviderConfig(name, { ...row, _apiKeyAttempt: selection?.distributed ? selection : undefined });
|
|
32
37
|
if (current.authMode === "oauth" || current.authMode === "forward") return null;
|
|
33
38
|
if (current.authMode === "key" && !current.keyOptional && !current.apiKey?.trim()) return null;
|
|
@@ -21,6 +21,8 @@ export interface ProviderApiKeyInfo extends AccountQuotaFields {
|
|
|
21
21
|
/** First/last 4 chars only; env references (`${VAR}`) are shown verbatim (not secrets). */
|
|
22
22
|
masked: string;
|
|
23
23
|
active: boolean;
|
|
24
|
+
disabled?: boolean;
|
|
25
|
+
pauseDetails?: import("../types/account-auto-pause").AccountPauseDetails;
|
|
24
26
|
addedAt?: number;
|
|
25
27
|
}
|
|
26
28
|
|
|
@@ -217,7 +219,7 @@ export function listProviderApiKeys(config: OcxConfig, name: string): { activeId
|
|
|
217
219
|
const pool = provider.apiKeyPool?.length
|
|
218
220
|
? provider.apiKeyPool
|
|
219
221
|
: provider.apiKey ? [{ id: apiKeyPoolEntryId(provider.apiKey), key: provider.apiKey }] : [];
|
|
220
|
-
const activeId =
|
|
222
|
+
const activeId = pool.find(entry => entry.key === provider.apiKey)?.id ?? null;
|
|
221
223
|
return {
|
|
222
224
|
activeId,
|
|
223
225
|
keys: pool.map(entry => ({
|
|
@@ -225,6 +227,8 @@ export function listProviderApiKeys(config: OcxConfig, name: string): { activeId
|
|
|
225
227
|
...(entry.label ? { label: entry.label } : {}),
|
|
226
228
|
masked: maskApiKey(entry.key),
|
|
227
229
|
active: entry.id === activeId,
|
|
230
|
+
...(entry.disabled ? { disabled: true } : {}),
|
|
231
|
+
...(entry.pauseDetails ? { pauseDetails: entry.pauseDetails } : {}),
|
|
228
232
|
...(entry.addedAt !== undefined ? { addedAt: entry.addedAt } : {}),
|
|
229
233
|
})),
|
|
230
234
|
};
|
|
@@ -300,7 +304,7 @@ export function addProviderApiKey(config: OcxConfig, name: string, key: string,
|
|
|
300
304
|
} else {
|
|
301
305
|
pool.push({ id, key: trimmed, ...(label?.trim() ? { label: label.trim() } : {}), addedAt: Date.now() });
|
|
302
306
|
}
|
|
303
|
-
fresh.apiKey = trimmed;
|
|
307
|
+
if (!existing?.disabled) fresh.apiKey = trimmed;
|
|
304
308
|
return { changed: true, selectionChanged: true, value: id };
|
|
305
309
|
});
|
|
306
310
|
if (committed.status !== "committed") return { error: "provider selection unavailable" };
|
|
@@ -313,7 +317,7 @@ export function setActiveProviderApiKey(config: OcxConfig, name: string, id: str
|
|
|
313
317
|
const entry = provider.apiKeyPool?.find(e => e.id === id)
|
|
314
318
|
?? (!provider.apiKeyPool?.length && provider.apiKey && apiKeyPoolEntryId(provider.apiKey) === id
|
|
315
319
|
? { id, key: provider.apiKey } : undefined);
|
|
316
|
-
if (!entry) return { changed: false, value: false };
|
|
320
|
+
if (!entry || entry.disabled) return { changed: false, value: false };
|
|
317
321
|
ensurePool(provider);
|
|
318
322
|
provider.apiKey = entry.key;
|
|
319
323
|
return { changed: true, selectionChanged: true, value: true };
|
|
@@ -344,7 +348,7 @@ export function removeProviderApiKey(config: OcxConfig, name: string, id: string
|
|
|
344
348
|
removed = entry.key;
|
|
345
349
|
provider.apiKeyPool = pool.filter(e => e.id !== id);
|
|
346
350
|
if (provider.apiKey === entry.key) {
|
|
347
|
-
const next = provider.apiKeyPool
|
|
351
|
+
const next = provider.apiKeyPool.find(candidate => !candidate.disabled);
|
|
348
352
|
if (next) provider.apiKey = next.key;
|
|
349
353
|
else delete provider.apiKey;
|
|
350
354
|
}
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { markAnthropicFamilyEnumeration } from "./quota/anthropic-family-headers";
|
|
2
|
+
import { rawAnthropicUsageWindows } from "./quota/anthropic-decision-windows";
|
|
3
|
+
import { bindRawDecisionWindows } from "./quota-decision-publication";
|
|
4
|
+
import { decisionQuotaSinkActive } from "../lib/decision-quota-sink";
|
|
2
5
|
import { CLAUDE_CLI_USER_AGENT } from "./claude-cli-identity";
|
|
3
6
|
import { asRecord, normalizePercent, normalizeResetAt, readQuotaJson, REQUEST_TIMEOUT_MS } from "./quota-wire";
|
|
4
|
-
import type { ProviderQuota, ProviderQuotaWindow } from "./quota-types";
|
|
7
|
+
import type { ProviderQuota, ProviderQuotaWindow, QuotaFailureCode } from "./quota-types";
|
|
8
|
+
import { httpQuotaFailure } from "./quota/failure-codes";
|
|
5
9
|
const hasQuotaRows = (q: ProviderQuota) => q.fiveHourPercent !== undefined || q.weeklyPercent !== undefined || Boolean(q.customWindows?.length);
|
|
6
10
|
|
|
7
11
|
function parseClaudeBucket(value: unknown): { percent?: number; resetAt?: number } | null {
|
|
@@ -41,7 +45,18 @@ function parseClaudeLimit(value: unknown): ProviderQuotaWindow | null {
|
|
|
41
45
|
return { label, scope: "model", percent, ...(resetAt !== undefined ? { resetAt } : {}) };
|
|
42
46
|
}
|
|
43
47
|
|
|
48
|
+
/** Read the Anthropic OAuth usage endpoint for an access token and return the parsed quota, or null on a non-OK or malformed response. */
|
|
44
49
|
export async function readAnthropicUsageQuota(accessToken: string): Promise<ProviderQuota | null> {
|
|
50
|
+
return (await probeAnthropicUsageQuota(accessToken)).quota;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** A usage read plus, when it yields no quota, the closed reason why. A thrown fetch still throws. */
|
|
54
|
+
export interface AnthropicUsageProbe {
|
|
55
|
+
quota: ProviderQuota | null;
|
|
56
|
+
failure?: QuotaFailureCode;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function probeAnthropicUsageQuota(accessToken: string): Promise<AnthropicUsageProbe> {
|
|
45
60
|
const response = await fetch("https://api.anthropic.com/api/oauth/usage", {
|
|
46
61
|
headers: {
|
|
47
62
|
Accept: "application/json, text/plain, */*",
|
|
@@ -52,9 +67,12 @@ export async function readAnthropicUsageQuota(accessToken: string): Promise<Prov
|
|
|
52
67
|
},
|
|
53
68
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
54
69
|
});
|
|
55
|
-
if (!response.ok)
|
|
70
|
+
if (!response.ok) {
|
|
71
|
+
try { await response.body?.cancel(); } catch { /* Best-effort release; the status is the diagnosis. */ }
|
|
72
|
+
return { quota: null, failure: httpQuotaFailure(response.status) };
|
|
73
|
+
}
|
|
56
74
|
const body = asRecord(await readQuotaJson(response));
|
|
57
|
-
if (!body) return null;
|
|
75
|
+
if (!body) return { quota: null, failure: "response_unusable" };
|
|
58
76
|
const fiveHour = parseClaudeBucket(body.five_hour);
|
|
59
77
|
const sevenDay = parseClaudeBucket(body.seven_day);
|
|
60
78
|
const fable = parseClaudeBucket(body.seven_day_fable);
|
|
@@ -87,10 +105,12 @@ export async function readAnthropicUsageQuota(accessToken: string): Promise<Prov
|
|
|
87
105
|
...(customWindows.length > 0 ? { customWindows } : {}),
|
|
88
106
|
updatedAt: Date.now(),
|
|
89
107
|
};
|
|
108
|
+
if (decisionQuotaSinkActive()) bindRawDecisionWindows(quota, rawAnthropicUsageWindows(body, quota.updatedAt));
|
|
90
109
|
// Empty / schema-changed payloads must not cache as "success with no bars".
|
|
91
|
-
|
|
110
|
+
if (!hasQuotaRows(quota)) return { quota: null, failure: "response_unusable" };
|
|
111
|
+
return { quota: markAnthropicFamilyEnumeration(quota, Array.isArray(body.limits) && body.limits.every(raw => {
|
|
92
112
|
const kind = asRecord(raw)?.kind;
|
|
93
113
|
return kind === "session" || kind === "weekly_all" || kind === "weekly_scoped" && parseClaudeLimit(raw) !== null;
|
|
94
|
-
}))
|
|
114
|
+
})) };
|
|
95
115
|
}
|
|
96
116
|
|
|
@@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto";
|
|
|
3
3
|
import { getProviderRegistryEntry, providerMatchesRegistryTransport } from "./registry";
|
|
4
4
|
import { routedSlug, slugEquivalenceKey } from "./slug-codec";
|
|
5
5
|
import { comboDisabledModelSelectors } from "../combos/types";
|
|
6
|
-
import { providerUsesKeyAuthOverride
|
|
6
|
+
import { providerUsesKeyAuthOverride } from "./key-store";
|
|
7
7
|
|
|
8
8
|
export const INITIAL_MODEL_SELECTION_THRESHOLD = 20;
|
|
9
9
|
type Selection = NonNullable<OcxProviderConfig["initialModelSelection"]>;
|
|
@@ -31,7 +31,7 @@ function loginConnection(name: string, provider: OcxProviderConfig): boolean {
|
|
|
31
31
|
if (entry && providerMatchesRegistryTransport(name, provider)) {
|
|
32
32
|
if (entry.authKind === "forward") return true;
|
|
33
33
|
if (entry.authKind === "oauth") {
|
|
34
|
-
return !providerUsesKeyAuthOverride(entry, provider
|
|
34
|
+
return !providerUsesKeyAuthOverride(entry, provider);
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
37
|
return provider.authMode === "oauth" || provider.authMode === "forward";
|
|
@@ -12,6 +12,7 @@ import { isKeyInCooldown, getKeyCooldownUntil, setKeyCooldown } from "./api-key-
|
|
|
12
12
|
export { clearKeyCooldowns, getKeyCooldownUntil, sweepExpiredApiKeyCooldowns } from "./api-key-cooldowns";
|
|
13
13
|
import { commitProviderApiKeySelection, providerApiKeySelectionIsCurrent } from "./api-key-selection";
|
|
14
14
|
import { RequestPacingQueueOverloadError } from "./request-pacing";
|
|
15
|
+
import { isCanonicalMistralChat } from "./mistral-transport";
|
|
15
16
|
import type { ProviderApiKeySelection } from "../types/provider";
|
|
16
17
|
import { routedProviderConfig } from "./routed-config";
|
|
17
18
|
import { getProviderRegistryEntry } from "./registry";
|
|
@@ -493,9 +494,9 @@ export function selectProactiveApiKey(
|
|
|
493
494
|
|
|
494
495
|
const activeEntry = pool.find(entry => entry.key === provider.apiKey);
|
|
495
496
|
// A healthy committed key wins, whether the operator chose it or a previous rotation did.
|
|
496
|
-
if (activeEntry && !isKeyInCooldown(providerName, activeEntry.id, now)) return null;
|
|
497
|
+
if (activeEntry && !activeEntry.disabled && !isKeyInCooldown(providerName, activeEntry.id, now)) return null;
|
|
497
498
|
|
|
498
|
-
const eligible = pool.filter(entry => !isKeyInCooldown(providerName, entry.id, now));
|
|
499
|
+
const eligible = pool.filter(entry => !entry.disabled && !isKeyInCooldown(providerName, entry.id, now));
|
|
499
500
|
if (eligible.length === 0) return null;
|
|
500
501
|
|
|
501
502
|
let chosen = eligible[0]!;
|
|
@@ -504,7 +505,7 @@ export function selectProactiveApiKey(
|
|
|
504
505
|
const lastIndex = lastId ? pool.findIndex(entry => entry.id === lastId) : -1;
|
|
505
506
|
for (let offset = 1; offset <= pool.length; offset += 1) {
|
|
506
507
|
const candidate = pool[(lastIndex + offset) % pool.length]!;
|
|
507
|
-
if (isKeyInCooldown(providerName, candidate.id, now)) continue;
|
|
508
|
+
if (candidate.disabled || isKeyInCooldown(providerName, candidate.id, now)) continue;
|
|
508
509
|
chosen = candidate;
|
|
509
510
|
break;
|
|
510
511
|
}
|
|
@@ -518,11 +519,11 @@ export function selectProactiveApiKey(
|
|
|
518
519
|
const outcome = commitProviderApiKeySelection<string | null>(config, providerName, freshProvider => {
|
|
519
520
|
const freshPool = freshProvider.apiKeyPool ?? [];
|
|
520
521
|
const target = freshPool.find(entry => entry.id === chosen.id);
|
|
521
|
-
if (!target) return { changed: false, value: null };
|
|
522
|
+
if (!target || target.disabled) return { changed: false, value: null };
|
|
522
523
|
if (freshProvider.apiKey === target.key) return { changed: false, value: null };
|
|
523
524
|
const freshActive = freshPool.find(entry => entry.key === freshProvider.apiKey);
|
|
524
525
|
// Re-check under the lock: a concurrent manual selection may have landed a healthy key.
|
|
525
|
-
if (freshActive && !isKeyInCooldown(providerName, freshActive.id, now)) {
|
|
526
|
+
if (freshActive && !freshActive.disabled && !isKeyInCooldown(providerName, freshActive.id, now)) {
|
|
526
527
|
return { changed: false, value: null };
|
|
527
528
|
}
|
|
528
529
|
freshProvider.apiKey = target.key;
|
|
@@ -564,7 +565,7 @@ export function selectProactiveApiKeyTransport(
|
|
|
564
565
|
|
|
565
566
|
const dispatchKeyCursor = new Map<string, string>();
|
|
566
567
|
function allocateDistributedKey(providerName: string, provider: OcxProviderConfig, now: number): OcxProviderConfig {
|
|
567
|
-
const pool = (provider.apiKeyPool ?? []).filter((entry, index, all) => all.findIndex(other => other.key === entry.key) === index);
|
|
568
|
+
const pool = (provider.apiKeyPool ?? []).filter(entry => !entry.disabled).filter((entry, index, all) => all.findIndex(other => other.key === entry.key) === index);
|
|
568
569
|
const previous = dispatchKeyCursor.get(providerName);
|
|
569
570
|
const start = pool.findIndex(entry => entry.id === previous);
|
|
570
571
|
for (let offset = 1; offset <= pool.length; offset++) {
|
|
@@ -575,7 +576,7 @@ function allocateDistributedKey(providerName: string, provider: OcxProviderConfi
|
|
|
575
576
|
entryId: entry.id, reference: entry.key, revision: provider.apiKeySelectionRevision, distributed: true,
|
|
576
577
|
} };
|
|
577
578
|
}
|
|
578
|
-
const next = Math.min(...pool.map(entry => getKeyCooldownUntil(providerName, entry.id, now) ?? now + 1000));
|
|
579
|
+
const next = pool.length ? Math.min(...pool.map(entry => getKeyCooldownUntil(providerName, entry.id, now) ?? now + 1000)) : now + 1000;
|
|
579
580
|
throw new RequestPacingQueueOverloadError(providerName, "keys_exhausted", Math.max(1, Math.ceil((next - now) / 1000)));
|
|
580
581
|
}
|
|
581
582
|
|
|
@@ -585,7 +586,7 @@ function allocateDistributedKey(providerName: string, provider: OcxProviderConfi
|
|
|
585
586
|
* (subscription traffic such as Muse Spark) falls back to a patient same-key policy so a
|
|
586
587
|
* burst 429 waits and replays instead of surfacing to the client and aborting a long
|
|
587
588
|
* session; key-auth Command Code at its canonical endpoints gets the same fallback (#5180), and
|
|
588
|
-
*
|
|
589
|
+
* canonical Mistral Chat uses a slower fallback; other destinations stay fail-fast.
|
|
589
590
|
* OAuth/forward/local credentials are never replayed on the same token. The returned
|
|
590
591
|
* policy is fully defaulted so callers never re-check fields.
|
|
591
592
|
*/
|
|
@@ -609,8 +610,15 @@ export function rateLimitRetryPolicyFor(
|
|
|
609
610
|
respectRetryAfter: policy.respectRetryAfter ?? DEFAULT_RATE_LIMIT_RETRY.respectRetryAfter,
|
|
610
611
|
};
|
|
611
612
|
}
|
|
612
|
-
// No explicit knob:
|
|
613
|
+
// No explicit knob: destination-specific fallbacks, never OAuth or local credentials.
|
|
613
614
|
if (provider.authMode !== undefined && provider.authMode !== "key") return null;
|
|
615
|
+
// Mistral's token-throughput and temporary capacity refusals both use HTTP 429.
|
|
616
|
+
// Keep relays and other wire adapters opt-in, and reuse the existing send budget.
|
|
617
|
+
if (typeof provider.baseUrl === "string" && typeof provider.adapter === "string" && isCanonicalMistralChat({
|
|
618
|
+
adapter: provider.adapter, authMode: provider.authMode, baseUrl: provider.baseUrl,
|
|
619
|
+
})) {
|
|
620
|
+
return { ...DEFAULT_RATE_LIMIT_RETRY, attempts: 6, intervalMs: 10_000 };
|
|
621
|
+
}
|
|
614
622
|
if (isOpenCodeGoDestination(provider)) return { ...OPENCODE_GO_RATE_LIMIT_RETRY };
|
|
615
623
|
// Command Code's Provider API rate-limits long muse-spark turns the same way (#5180): a single
|
|
616
624
|
// key cannot fail over, and the Codex client does not retry a 429, so the turn aborts. The key
|
|
@@ -711,7 +719,7 @@ export function recordDistributedKeyFailure(
|
|
|
711
719
|
const provider = config.providers[providerName];
|
|
712
720
|
if (!selection?.distributed || !provider || provider.authMode === "oauth" || provider.authMode === "forward") return false;
|
|
713
721
|
const failed = provider.apiKeyPool?.find(entry => entry.id === selection.entryId && entry.key === selection.reference);
|
|
714
|
-
if (!failed || provider.apiKeySelectionRevision !== selection.revision) return false;
|
|
722
|
+
if (!failed || failed.disabled || provider.apiKeySelectionRevision !== selection.revision) return false;
|
|
715
723
|
const delay = status === "billing" ? ANTHROPIC_BILLING_COOLDOWN_MS : status === 401 ? MAX_COOLDOWN_MS : quotaResetAt !== undefined
|
|
716
724
|
? Math.max(1, quotaResetAt - now) : parseRetryAfterMs(retryAfter, now) ?? DEFAULT_COOLDOWN_MS;
|
|
717
725
|
for (const entry of provider.apiKeyPool ?? []) if (entry.key === failed.key) {
|
|
@@ -739,7 +747,7 @@ function rotateKeyAfterFailure(
|
|
|
739
747
|
if (attemptedSelection?.distributed) {
|
|
740
748
|
if (acceptsFreshProvider && !acceptsFreshProvider(provider)) return null;
|
|
741
749
|
if (!recordDistributedKeyFailure(config, providerName, attemptedSelection, failureStatus, now, retryAfterHeader, quotaResetAt)) return null;
|
|
742
|
-
if (!(provider.apiKeyPool ?? []).some(entry => !isKeyInCooldown(providerName, entry.id, now))) return null;
|
|
750
|
+
if (!(provider.apiKeyPool ?? []).some(entry => !entry.disabled && !isKeyInCooldown(providerName, entry.id, now))) return null;
|
|
743
751
|
return allocateDistributedKey(providerName, provider, now);
|
|
744
752
|
}
|
|
745
753
|
|
|
@@ -764,7 +772,7 @@ function rotateKeyAfterFailure(
|
|
|
764
772
|
|
|
765
773
|
if (freshProvider.apiKey !== failedKey) {
|
|
766
774
|
const activeEntry = pool.find(entry => entry.key === freshProvider.apiKey);
|
|
767
|
-
if (activeEntry && !isKeyInCooldown(providerName, activeEntry.id, now)) {
|
|
775
|
+
if (activeEntry && !activeEntry.disabled && !isKeyInCooldown(providerName, activeEntry.id, now)) {
|
|
768
776
|
return {
|
|
769
777
|
changed: false,
|
|
770
778
|
value: { failedId: failedEntry?.id },
|
|
@@ -776,7 +784,7 @@ function rotateKeyAfterFailure(
|
|
|
776
784
|
const candidateCount = failedEntry ? pool.length - 1 : pool.length;
|
|
777
785
|
for (let offset = 1; offset <= candidateCount; offset += 1) {
|
|
778
786
|
const candidate = pool[(currentIndex + offset) % pool.length]!;
|
|
779
|
-
if (isKeyInCooldown(providerName, candidate.id, now)) continue;
|
|
787
|
+
if (candidate.disabled || isKeyInCooldown(providerName, candidate.id, now)) continue;
|
|
780
788
|
freshProvider.apiKey = candidate.key;
|
|
781
789
|
return {
|
|
782
790
|
changed: true,
|
|
@@ -32,14 +32,19 @@ export type {
|
|
|
32
32
|
ProviderKeyStoreKind,
|
|
33
33
|
} from "./api-key-resolve";
|
|
34
34
|
|
|
35
|
-
/**
|
|
35
|
+
/**
|
|
36
|
+
* Shared with routing: a key-mode override is operator intent, not a credential probe.
|
|
37
|
+
* The configured authMode alone decides the lane; a key that cannot be resolved right now
|
|
38
|
+
* (paused last key, missing env var, disabled pool) fails closed at the transport and must
|
|
39
|
+
* never implicitly reclassify the provider to the registry's OAuth lane, where a stored
|
|
40
|
+
* OAuth account would be billed instead of the operator's key.
|
|
41
|
+
*/
|
|
36
42
|
export function providerUsesKeyAuthOverride(
|
|
37
43
|
entry: Pick<ProviderRegistryEntry, "authKind" | "allowKeyAuthOverride">,
|
|
38
44
|
provider: Pick<OcxProviderConfig, "authMode">,
|
|
39
|
-
resolvedKey: string | undefined,
|
|
40
45
|
): boolean {
|
|
41
46
|
return entry.authKind === "oauth" && entry.allowKeyAuthOverride === true
|
|
42
|
-
&& provider.authMode === "key"
|
|
47
|
+
&& provider.authMode === "key";
|
|
43
48
|
}
|
|
44
49
|
|
|
45
50
|
/**
|