@bitkyc08/opencodex 2.88.0 → 2.89.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-BnRSujOq.js +58 -0
- package/gui/dist/assets/App-dnkJ85te.css +1 -0
- package/gui/dist/assets/{Tray-B5bC7AWL.js → Tray-7z1COnmz.js} +1 -1
- package/gui/dist/assets/{index-B5bsvxor.js → index-uchD-ngf.js} +13 -13
- package/gui/dist/assets/{quota-summary-D8y2wTsB.js → quota-summary-BY_10IEz.js} +1 -1
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- 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/coding-agent/protocol.ts +6 -0
- package/src/adapters/google-http.ts +3 -2
- package/src/adapters/google-vertex-endpoint.ts +67 -0
- package/src/adapters/google.ts +35 -29
- package/src/adapters/openai-responses/compaction-search-history.ts +27 -0
- package/src/adapters/openai-responses/passthrough.ts +17 -12
- package/src/adapters/openai-responses/reasoning.ts +68 -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/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/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-pause.ts +6 -1
- package/src/codex/account-runtime-state.ts +5 -0
- package/src/codex/account-store.ts +30 -3
- package/src/codex/auth-api/account-list.ts +3 -0
- package/src/codex/auth-api/main-account-probe.ts +3 -2
- package/src/codex/auth-context.ts +9 -4
- 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/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.ts +6 -2
- package/src/codex/native-residue.ts +2 -3
- package/src/codex/quota.ts +26 -2
- package/src/combos/failover.ts +2 -1
- 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/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 +344 -144
- package/src/images/loop.ts +32 -9
- 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 +68 -0
- package/src/lib/decision-quota-sink.ts +76 -0
- package/src/lib/state-store-registrations.ts +25 -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-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/index.ts +5 -35
- 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 +5 -0
- package/src/providers/key-failover.ts +11 -11
- package/src/providers/model-rename-fields.ts +1 -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 +2 -1
- 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 +39 -6
- package/src/providers/quota/report-cache.ts +14 -0
- package/src/providers/quota/vendor-probes-oauth.ts +3 -3
- 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 +3 -0
- package/src/providers/quota-types.ts +6 -1
- package/src/providers/quota.ts +3 -2
- package/src/providers/registry/entries-extended.ts +8 -52
- package/src/providers/registry/model-seeds.ts +1 -1
- package/src/providers/registry.ts +1 -2
- package/src/providers/stale-vision-classification-migration.ts +1 -1
- package/src/responses/parser.ts +18 -10
- package/src/responses/web-search-replay.ts +138 -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 +28 -13
- package/src/server/claude-messages.ts +2 -1
- package/src/server/index/optional-listeners.ts +6 -0
- package/src/server/management/combo-routes.ts +17 -2
- package/src/server/management/oauth-account-routes.ts +10 -1
- 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 +18 -4
- package/src/server/messages-shared.ts +4 -1
- package/src/server/request-log.ts +7 -2
- package/src/server/responses/account-auto-pause-recovery.ts +26 -0
- package/src/server/responses/adapter-dispatch.ts +77 -28
- package/src/server/responses/combo-session-recall.ts +25 -0
- package/src/server/responses/combo-stream-preflight.ts +3 -1
- package/src/server/responses/combo-tool-loop-pin.ts +70 -0
- package/src/server/responses/compact.ts +8 -5
- package/src/server/responses/context-overflow.ts +141 -2
- package/src/server/responses/core-codex-account.ts +7 -1
- package/src/server/responses/core-combo.ts +31 -5
- package/src/server/responses/core.ts +12 -9
- package/src/server/responses/empty-completion-guard.ts +2 -0
- package/src/server/responses/fetch-helpers.ts +35 -4
- package/src/server/responses/jev-model-invoke.ts +13 -14
- package/src/server/responses/native-steering-log.ts +2 -2
- package/src/server/responses/passthrough-delivery.ts +3 -2
- package/src/server/responses/passthrough-dispatch.ts +30 -33
- package/src/server/responses/passthrough-observations.ts +38 -0
- package/src/server/responses/request-compression.ts +98 -0
- package/src/server/responses/request-transport.ts +32 -3
- 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/types/account-auto-pause.ts +15 -0
- package/src/types/config.ts +23 -1
- package/src/types/provider.ts +9 -5
- package/src/types/request.ts +20 -2
- package/src/types.ts +1 -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 +37 -11
- 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
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { captureAccountAutoPauseFence, accountAutoPauseFenceIsLocal, accountAutoPauseFenceMatchesDisk } from "../config/account-auto-pause-revisions";
|
|
2
|
+
import type { OcxConfig, OcxProviderConfig } from "../types";
|
|
3
|
+
import type { OAuthAccessSnapshot } from "./index";
|
|
4
|
+
import type { CodexAuthContext } from "../codex/auth-context";
|
|
5
|
+
import type { AccountPauseReason } from "../types/account-auto-pause";
|
|
6
|
+
import { credentialGeneration, getAccountSet, setAccountPaused } from "./store";
|
|
7
|
+
import { mutatePersistedConfig } from "../config";
|
|
8
|
+
import { isCodexAccountGenerationLive, readCodexAccountRecord } from "../codex/account-store";
|
|
9
|
+
import { captureMainQuotaDispatch, isMainQuotaDispatchLive } from "../codex/main-account-cache";
|
|
10
|
+
import { isCodexAccountPaused, setCodexAccountPaused } from "../codex/account-pause";
|
|
11
|
+
import { apiKeyPoolEntryId } from "../providers/api-keys";
|
|
12
|
+
import { resolveProviderApiKey } from "../providers/api-key-resolve";
|
|
13
|
+
import { createHash } from "node:crypto";
|
|
14
|
+
import { setProviderApiKeyPaused } from "../providers/api-key-pause";
|
|
15
|
+
import { AccountRefusalStreaks, autoPauseThreshold, permanentAccountRefusal } from "./account-auto-pause-policy";
|
|
16
|
+
import { clearThreadAccountMapForAccount, reconcileCodexActiveAfterExclusion } from "../codex/routing";
|
|
17
|
+
import { isNonReplayableResponse } from "../lib/upstream-retry";
|
|
18
|
+
import { hasUpstreamRewriters, upstreamRewriterEpoch } from "../plugins/upstream-hooks";
|
|
19
|
+
|
|
20
|
+
const streaks = new WeakMap<OcxConfig, AccountRefusalStreaks>();
|
|
21
|
+
export { wasAccountAutoPaused, wasPermanentAccountRefusal } from "../lib/account-auto-pause-response";
|
|
22
|
+
import { markAccountAutoPaused, markPermanentAccountRefusal } from "../lib/account-auto-pause-response";
|
|
23
|
+
export interface AccountAutoPauseBinding {
|
|
24
|
+
config: OcxConfig;
|
|
25
|
+
oauth?: OAuthAccessSnapshot;
|
|
26
|
+
codex?: CodexAuthContext;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Capture before transport. A later response cannot acquire authority over a newer login/resume. */
|
|
30
|
+
export type AccountAutoPauseObserver = ((response: Response) => Promise<Response>) & {
|
|
31
|
+
/** Final-send report: only a response whose wire credential and destination match is counted. */
|
|
32
|
+
physical: (sameDestination: boolean, headers: Headers) => void;
|
|
33
|
+
};
|
|
34
|
+
const CREDENTIAL_HEADERS = ["authorization", "x-api-key", "x-goog-api-key", "api-key"] as const;
|
|
35
|
+
const CODEX_SELECTOR_HEADERS = ["openai-organization", "openai-project"] as const;
|
|
36
|
+
// Nested capture sites (a providerFetch observer around a dispatchOverride's own observer) see the
|
|
37
|
+
// same physical response. It counts once per account, and a response whose final send was reported
|
|
38
|
+
// to any observer is never attributed by an observer that received no report.
|
|
39
|
+
const countedResponses = new WeakMap<Response, Set<string>>();
|
|
40
|
+
const reportedResponses = new WeakSet<Response>();
|
|
41
|
+
export function captureAccountAutoPauseAttempt(name: string, provider: OcxProviderConfig, binding?: AccountAutoPauseBinding, headers?: Headers):
|
|
42
|
+
AccountAutoPauseObserver | undefined {
|
|
43
|
+
if (!binding) return;
|
|
44
|
+
const { config, oauth, codex } = binding;
|
|
45
|
+
const threshold = autoPauseThreshold(config.providers[name]?.accountAutoPause);
|
|
46
|
+
if (!threshold) return;
|
|
47
|
+
const configured = config.providers[name];
|
|
48
|
+
if (!configured || configured.disabled) return;
|
|
49
|
+
const fence = captureAccountAutoPauseFence(config, name);
|
|
50
|
+
if (!fence) return;
|
|
51
|
+
const route = JSON.stringify([configured.baseUrl, configured.adapter, configured.authMode]);
|
|
52
|
+
const policyCurrent = () => accountAutoPauseFenceIsLocal(name, fence) && !config.providers[name]?.disabled
|
|
53
|
+
&& autoPauseThreshold(config.providers[name]?.accountAutoPause) === threshold
|
|
54
|
+
&& JSON.stringify([config.providers[name]?.baseUrl, config.providers[name]?.adapter, config.providers[name]?.authMode]) === route;
|
|
55
|
+
let key: string, incarnation: string, current: () => boolean, owns: (wire: Headers) => boolean;
|
|
56
|
+
let pause: (reason: AccountPauseReason) => Promise<boolean>;
|
|
57
|
+
if (provider.authMode === "oauth") {
|
|
58
|
+
if (!oauth || oauth.provider !== name || headers?.get("authorization") !== `Bearer ${oauth.accessToken}` || headers.has("x-api-key")) return;
|
|
59
|
+
const row = getAccountSet(name)?.accounts.find(a => a.id === oauth.accountId);
|
|
60
|
+
if (!row || row.paused || credentialGeneration(row.credential) !== oauth.generation) return;
|
|
61
|
+
const revision = row.pauseRevision;
|
|
62
|
+
owns = wire => wire.get("authorization") === `Bearer ${oauth.accessToken}` && !wire.has("x-api-key");
|
|
63
|
+
key = `${name}\0oauth\0${oauth.accountId}`;
|
|
64
|
+
incarnation = `${row.loginId ?? row.addedAt ?? oauth.accountId}\0${revision ?? ""}`;
|
|
65
|
+
current = () => {
|
|
66
|
+
const live = getAccountSet(name)?.accounts.find(a => a.id === oauth.accountId);
|
|
67
|
+
return !!live && !live.paused && live.pauseRevision === revision && credentialGeneration(live.credential) === oauth.generation;
|
|
68
|
+
};
|
|
69
|
+
pause = async reason => (await setAccountPaused(name, oauth.accountId, true, {
|
|
70
|
+
generation: oauth.generation, pauseRevision: revision, isCurrent: policyCurrent,
|
|
71
|
+
assertPolicyBeforePersist: () => { if (!accountAutoPauseFenceMatchesDisk(name, fence)) throw new Error("Account auto-pause policy changed before persistence"); }, details: { reason, pausedAt: Date.now() },
|
|
72
|
+
})).status === "updated";
|
|
73
|
+
} else if (provider.authMode === "forward") {
|
|
74
|
+
if (!codex || codex.kind !== "pool" && codex.kind !== "main-pool") return;
|
|
75
|
+
const { accountId } = codex;
|
|
76
|
+
// The bearer and the workspace together name the Codex account; organization/project selectors
|
|
77
|
+
// must stay as captured, so a rewrite that keeps the bearer cannot redirect the refusal.
|
|
78
|
+
const selectors = CODEX_SELECTOR_HEADERS.map(header => headers?.get(header) ?? null);
|
|
79
|
+
const codexOwns = (wire: Headers) => wire.get("authorization") === `Bearer ${codex.accessToken}`
|
|
80
|
+
&& wire.get("chatgpt-account-id") === codex.chatgptAccountId
|
|
81
|
+
&& CODEX_SELECTOR_HEADERS.every((header, index) => wire.get(header) === selectors[index]);
|
|
82
|
+
if (!headers || !codexOwns(headers)) return;
|
|
83
|
+
owns = codexOwns;
|
|
84
|
+
const revision = config.codexAccountPauseState?.[accountId]?.revision;
|
|
85
|
+
const mainDispatch = codex.kind === "main-pool" ? captureMainQuotaDispatch(codex.accessToken, codex.chatgptAccountId, codex.writerGeneration) : undefined;
|
|
86
|
+
const credentialCurrent = () => codex.kind === "pool"
|
|
87
|
+
? isCodexAccountGenerationLive(accountId, codex.generation)
|
|
88
|
+
: !!mainDispatch && isMainQuotaDispatchLive(mainDispatch);
|
|
89
|
+
key = `${name}\0codex\0${accountId}`;
|
|
90
|
+
incarnation = `${codex.kind === "pool" ? readCodexAccountRecord(accountId)?.quotaHistoryIdentity ?? codex.generation : mainDispatch?.credentialGeneration}\0${revision ?? ""}`;
|
|
91
|
+
current = () => !isCodexAccountPaused(config, accountId)
|
|
92
|
+
&& config.codexAccountPauseState?.[accountId]?.revision === revision && credentialCurrent();
|
|
93
|
+
pause = async reason => {
|
|
94
|
+
let pauseState: OcxConfig["codexAccountPauseState"];
|
|
95
|
+
const outcome = mutatePersistedConfig(fresh => {
|
|
96
|
+
if (!policyCurrent() || !accountAutoPauseFenceMatchesDisk(name, fence, fresh) || !credentialCurrent() || isCodexAccountPaused(fresh, accountId)
|
|
97
|
+
|| fresh.codexAccountPauseState?.[accountId]?.revision !== revision
|
|
98
|
+
|| autoPauseThreshold(fresh.providers[name]?.accountAutoPause) !== threshold) return { changed: false, value: false };
|
|
99
|
+
setCodexAccountPaused(fresh, accountId, true, { reason, pausedAt: Date.now() });
|
|
100
|
+
pauseState = structuredClone(fresh.codexAccountPauseState);
|
|
101
|
+
return { changed: true, value: true };
|
|
102
|
+
});
|
|
103
|
+
if (outcome.status !== "committed" || !outcome.value) return false;
|
|
104
|
+
config.pausedCodexAccountIds = [...new Set([...(config.pausedCodexAccountIds ?? []), accountId])];
|
|
105
|
+
config.codexAccountPauseState = pauseState;
|
|
106
|
+
clearThreadAccountMapForAccount(accountId);
|
|
107
|
+
reconcileCodexActiveAfterExclusion(config, accountId);
|
|
108
|
+
return true;
|
|
109
|
+
};
|
|
110
|
+
} else {
|
|
111
|
+
const selection = provider._apiKeyAttempt;
|
|
112
|
+
if (!headers || !provider.apiKey || ![headers.get("authorization"), headers.get("x-api-key"), headers.get("x-goog-api-key")].some(value => value === provider.apiKey || value === `Bearer ${provider.apiKey}`)) return;
|
|
113
|
+
const entry = configured.apiKeyPool?.find(e => e.id === selection?.entryId && e.key === selection.reference)
|
|
114
|
+
?? (!configured.apiKeyPool?.length && configured.apiKey ? { id: apiKeyPoolEntryId(configured.apiKey), key: configured.apiKey, disabled: false, pauseRevision: undefined } : undefined);
|
|
115
|
+
if (!entry || entry.disabled || !selection) return;
|
|
116
|
+
const { id, key: reference, pauseRevision } = entry;
|
|
117
|
+
key = `${name}\0key\0${id}`;
|
|
118
|
+
const resolvedKey = provider.apiKey;
|
|
119
|
+
owns = wire => {
|
|
120
|
+
const sent = CREDENTIAL_HEADERS.map(header => wire.get(header)).filter((value): value is string => value !== null);
|
|
121
|
+
return sent.length > 0 && sent.every(value => value === resolvedKey || value === `Bearer ${resolvedKey}`);
|
|
122
|
+
};
|
|
123
|
+
incarnation = `${createHash("sha256").update(resolvedKey).digest("hex")}\0${pauseRevision ?? ""}`;
|
|
124
|
+
current = () => {
|
|
125
|
+
const configured = config.providers[name];
|
|
126
|
+
const live = configured?.apiKeyPool?.find(e => e.id === id) ?? (!configured?.apiKeyPool?.length && configured?.apiKey === reference ? entry : undefined);
|
|
127
|
+
return !!live && !live.disabled && live.key === reference && live.pauseRevision === pauseRevision && resolveProviderApiKey(reference) === resolvedKey;
|
|
128
|
+
};
|
|
129
|
+
pause = async reason => setProviderApiKeyPaused(config, name, id, true, {
|
|
130
|
+
selection, pauseRevision, resolvedKey, fence, details: { reason, pausedAt: Date.now() },
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
incarnation += `\0${fence.diskRevision}\0${fence.localGen}`;
|
|
134
|
+
let tracker = streaks.get(config);
|
|
135
|
+
if (!tracker) { tracker = new AccountRefusalStreaks(); streaks.set(config, tracker); }
|
|
136
|
+
const observed = new WeakSet<Response>();
|
|
137
|
+
// undefined: no HTTP final-send report (Codex WebSocket). Such a send is attributable only when
|
|
138
|
+
// no rewriter was registered at capture and none was registered or removed before the response,
|
|
139
|
+
// so no plugin could have rewritten the dial in between.
|
|
140
|
+
const rewriterEpoch = hasUpstreamRewriters() ? undefined : upstreamRewriterEpoch();
|
|
141
|
+
let wireOwned: boolean | undefined;
|
|
142
|
+
const observer = (async (response: Response) => {
|
|
143
|
+
if (observed.has(response) || isNonReplayableResponse(response)) return response;
|
|
144
|
+
observed.add(response);
|
|
145
|
+
if (wireOwned !== undefined) reportedResponses.add(response);
|
|
146
|
+
else if (reportedResponses.has(response)) return response;
|
|
147
|
+
if (!(wireOwned ?? (rewriterEpoch !== undefined && upstreamRewriterEpoch() === rewriterEpoch))) return response;
|
|
148
|
+
const counted = countedResponses.get(response) ?? new Set<string>();
|
|
149
|
+
if (counted.has(key)) return response;
|
|
150
|
+
countedResponses.set(response, counted.add(key));
|
|
151
|
+
try {
|
|
152
|
+
const verdict = permanentAccountRefusal(response).then(reason => {
|
|
153
|
+
if (reason) markPermanentAccountRefusal(response);
|
|
154
|
+
return reason;
|
|
155
|
+
});
|
|
156
|
+
const paused = await tracker.observe(key, incarnation, threshold, verdict, response.ok,
|
|
157
|
+
() => policyCurrent() && current(), pause);
|
|
158
|
+
if (paused) {
|
|
159
|
+
markAccountAutoPaused(response);
|
|
160
|
+
// Fixed codes only. Never print the upstream body, provider label, or account identifier.
|
|
161
|
+
console.warn(`[account-auto-pause] paused after ${threshold} consecutive permanent account refusals`);
|
|
162
|
+
}
|
|
163
|
+
} catch {
|
|
164
|
+
// Optional persistence must preserve the original provider response.
|
|
165
|
+
console.warn("[account-auto-pause] could not persist account pause; upstream response preserved");
|
|
166
|
+
}
|
|
167
|
+
return response;
|
|
168
|
+
}) as AccountAutoPauseObserver;
|
|
169
|
+
observer.physical = (sameDestination, wire) => { wireOwned = sameDestination && owns(wire); };
|
|
170
|
+
return observer;
|
|
171
|
+
}
|
|
@@ -153,7 +153,8 @@ export function accountResetTimestamp(
|
|
|
153
153
|
const quota = getCachedProviderAccountQuota(provider, accountId);
|
|
154
154
|
if (!quota) return null;
|
|
155
155
|
const family = classifyModelFamilyForQuota(provider, requestedModelId);
|
|
156
|
-
|
|
156
|
+
// Model-attributed rows are diagnostics; one must never become the account's reset time.
|
|
157
|
+
const windows = (quota.customWindows ?? []).filter(w => w.modelId === undefined && (!family || windowMatchesFamily(w.label, family)));
|
|
157
158
|
const weekly = windows.find(w => /week/i.test(w.label));
|
|
158
159
|
let rawReset = weekly?.resetAt ?? quota.weeklyResetAt;
|
|
159
160
|
if (rawReset === undefined && provider !== "google-antigravity" && provider !== "anthropic") {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { wasAccountAutoPaused } from "./account-auto-pause";
|
|
1
2
|
import { anthropicModelFamily } from "./anthropic-model-quota";
|
|
2
3
|
/** Narrow pre-output account recovery, fenced to the bearer that physically sent the turn. */
|
|
3
4
|
import { readBoundedResponseBody } from "../lib/bounded-body";
|
|
@@ -117,6 +118,11 @@ export async function rotateAnthropicAccountOnResponseForInstance(
|
|
|
117
118
|
const { pauseAnthropicRateAdmission, anthropicRatePauseUntil } = anthropicRatePolicyFor(instance);
|
|
118
119
|
const { recordAnthropicAccountRefusal, rotateAnthropicAccountOnRefusal,
|
|
119
120
|
hasAnthropicFailoverQuorum, isAnthropicAccountPoolEnabled, pickAlternateAnthropicAccount } = routing;
|
|
121
|
+
if (wasAccountAutoPaused(response)) {
|
|
122
|
+
if (!options.canRetry || options.signal?.aborted) return null;
|
|
123
|
+
return pickAlternateAnthropicAccount(options.config, sent.accountId, Date.now(),
|
|
124
|
+
options.currentDecision ? options.currentDecision() : options.decision ?? null, options.model, options.excludedAccountIds);
|
|
125
|
+
}
|
|
120
126
|
const ownedCurrent = () => {
|
|
121
127
|
if (configuredAnthropicInstance(options.config, instance) !== instance
|
|
122
128
|
|| !anthropicPhysicalSendOwnershipIsCurrent(sent)) return undefined;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Pure family identity shared by existing hard policy and loaded-only advisory evidence. */
|
|
2
|
+
export type AnthropicModelFamily = "Fable" | "Opus" | "Sonnet";
|
|
3
|
+
/** Return the Anthropic model family (Fable, Opus or Sonnet) named by a model id, or undefined for other models. */
|
|
4
|
+
export function anthropicModelFamily(model?: string): AnthropicModelFamily | undefined {
|
|
5
|
+
return /(?:^|[/])claude-fable-5(?:-|$)/i.test(model ?? "") ? "Fable"
|
|
6
|
+
: /(?:^|[/])claude-opus-/i.test(model ?? "") ? "Opus"
|
|
7
|
+
: /(?:^|[/])claude-sonnet-/i.test(model ?? "") ? "Sonnet" : undefined;
|
|
8
|
+
}
|
|
@@ -6,12 +6,8 @@ import type { AnthropicInstanceId } from "../providers/anthropic-instance-id";
|
|
|
6
6
|
import type { GenerationContext } from "../lib/state-store-sweeper";
|
|
7
7
|
|
|
8
8
|
export const ANTHROPIC_PASSIVE_FAMILY_MAX_AGE_MS = 30 * 60_000;
|
|
9
|
-
|
|
10
|
-
export
|
|
11
|
-
return /(?:^|[/])claude-fable-5(?:-|$)/i.test(model ?? "") ? "Fable"
|
|
12
|
-
: /(?:^|[/])claude-opus-/i.test(model ?? "") ? "Opus"
|
|
13
|
-
: /(?:^|[/])claude-sonnet-/i.test(model ?? "") ? "Sonnet" : undefined;
|
|
14
|
-
}
|
|
9
|
+
import { anthropicModelFamily } from "./anthropic-model-family";
|
|
10
|
+
export { anthropicModelFamily, type AnthropicModelFamily } from "./anthropic-model-family";
|
|
15
11
|
|
|
16
12
|
export function anthropicModelPercents(quota: ProviderQuota | null, model?: string): number[] {
|
|
17
13
|
const family = anthropicModelFamily(model);
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { OcxProviderConfig } from "../types";
|
|
2
|
+
import { sanitizeApiKeyValue } from "../providers/api-keys";
|
|
3
|
+
import { accountPauseDetailsSchema } from "../config/schema/account-auto-pause";
|
|
4
|
+
|
|
5
|
+
/** Runtime guards: provider config is intentionally passthrough, so persisted fields may be malformed. */
|
|
6
|
+
export function preservableApiKeyPool(value: unknown): NonNullable<OcxProviderConfig["apiKeyPool"]> | undefined {
|
|
7
|
+
if (!Array.isArray(value)) return undefined;
|
|
8
|
+
const pool: NonNullable<OcxProviderConfig["apiKeyPool"]> = [];
|
|
9
|
+
const ids = new Set<string>();
|
|
10
|
+
const keys = new Set<string>();
|
|
11
|
+
for (const entry of value as unknown[]) {
|
|
12
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue;
|
|
13
|
+
const candidate = entry as Record<string, unknown>;
|
|
14
|
+
const id = typeof candidate.id === "string" ? candidate.id.trim() : "";
|
|
15
|
+
const key = sanitizeApiKeyValue(candidate.key);
|
|
16
|
+
if (!id || !key || ids.has(id) || keys.has(key)) continue;
|
|
17
|
+
const label = typeof candidate.label === "string" ? candidate.label : undefined;
|
|
18
|
+
const addedAt = typeof candidate.addedAt === "number" && Number.isFinite(candidate.addedAt)
|
|
19
|
+
? candidate.addedAt
|
|
20
|
+
: undefined;
|
|
21
|
+
const details = accountPauseDetailsSchema.safeParse(candidate.pauseDetails);
|
|
22
|
+
ids.add(id);
|
|
23
|
+
keys.add(key);
|
|
24
|
+
pool.push({
|
|
25
|
+
id,
|
|
26
|
+
key,
|
|
27
|
+
...(label !== undefined ? { label } : {}),
|
|
28
|
+
...(addedAt !== undefined ? { addedAt } : {}),
|
|
29
|
+
...(typeof candidate.disabled === "boolean" ? { disabled: candidate.disabled } : {}),
|
|
30
|
+
...(typeof candidate.pauseRevision === "string" ? { pauseRevision: candidate.pauseRevision } : {}),
|
|
31
|
+
...(details.success ? { pauseDetails: details.data } : {}),
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
// Retain paused slots too: their metadata and credentials are needed for explicit resume.
|
|
35
|
+
return pool.length > 0 ? pool : undefined;
|
|
36
|
+
}
|
|
37
|
+
|
package/src/oauth/index.ts
CHANGED
|
@@ -55,6 +55,7 @@ import { loginOrcaRouter, orcaRouterInferenceBaseUrl, refreshOrcaRouterKey } fro
|
|
|
55
55
|
import { ANTIGRAVITY_DISCOVERY_UA } from "../adapters/google-antigravity-wire";
|
|
56
56
|
import { deriveOAuthDefaultModel, deriveOAuthProviderConfig } from "../providers/derive";
|
|
57
57
|
import { apiKeyPoolEntryId, sanitizeApiKeyValue } from "../providers/api-keys";
|
|
58
|
+
import { preservableApiKeyPool } from "./api-key-pool-preserve";
|
|
58
59
|
import { effectiveGoogleMode, getProviderRegistryEntry, mergeRegistryStaticHeaders, providerMatchesRegistryTransport } from "../providers/registry";
|
|
59
60
|
import { providerModelsUrl, resolveProviderModelDiscoveryUrl } from "../providers/model-discovery";
|
|
60
61
|
import { resolveProviderTransport } from "../providers/xai-transport";
|
|
@@ -1513,36 +1514,6 @@ export function reconcileOAuthProviders(config: OcxConfig, persist = true): bool
|
|
|
1513
1514
|
return true;
|
|
1514
1515
|
}
|
|
1515
1516
|
|
|
1516
|
-
/** Runtime guards: provider config is intentionally passthrough, so persisted fields may be malformed. */
|
|
1517
|
-
function preservableApiKeyPool(value: unknown): NonNullable<OcxProviderConfig["apiKeyPool"]> | undefined {
|
|
1518
|
-
if (!Array.isArray(value)) return undefined;
|
|
1519
|
-
const pool: NonNullable<OcxProviderConfig["apiKeyPool"]> = [];
|
|
1520
|
-
const ids = new Set<string>();
|
|
1521
|
-
const keys = new Set<string>();
|
|
1522
|
-
for (const entry of value as unknown[]) {
|
|
1523
|
-
if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue;
|
|
1524
|
-
const candidate = entry as Record<string, unknown>;
|
|
1525
|
-
const id = typeof candidate.id === "string" ? candidate.id.trim() : "";
|
|
1526
|
-
const key = sanitizeApiKeyValue(candidate.key);
|
|
1527
|
-
if (!id || !key || ids.has(id) || keys.has(key)) continue;
|
|
1528
|
-
const label = typeof candidate.label === "string" ? candidate.label : undefined;
|
|
1529
|
-
const addedAt = typeof candidate.addedAt === "number" && Number.isFinite(candidate.addedAt)
|
|
1530
|
-
? candidate.addedAt
|
|
1531
|
-
: undefined;
|
|
1532
|
-
ids.add(id);
|
|
1533
|
-
keys.add(key);
|
|
1534
|
-
pool.push({
|
|
1535
|
-
id,
|
|
1536
|
-
key,
|
|
1537
|
-
...(label !== undefined ? { label } : {}),
|
|
1538
|
-
...(addedAt !== undefined ? { addedAt } : {}),
|
|
1539
|
-
});
|
|
1540
|
-
}
|
|
1541
|
-
// `apiKey` remains the routing source of truth. Keep valid alternate slots even when a
|
|
1542
|
-
// hand-edited config left the pool out of sync, rather than deleting usable credentials.
|
|
1543
|
-
return pool.length > 0 ? pool : undefined;
|
|
1544
|
-
}
|
|
1545
|
-
|
|
1546
1517
|
const OAUTH_LOGIN_OWNED_PROVIDER_FIELDS = [
|
|
1547
1518
|
"adapter",
|
|
1548
1519
|
"baseUrl",
|
|
@@ -1597,11 +1568,10 @@ export function upsertOAuthProvider(config: OcxConfig, provider: string): void {
|
|
|
1597
1568
|
// Shared sanitizeApiKeyValue trim / no-CRLF checks from api-key pool writes.
|
|
1598
1569
|
let storedApiKey = sanitizeApiKeyValue(existing.apiKey);
|
|
1599
1570
|
const storedApiKeyPool = preservableApiKeyPool(existing.apiKeyPool);
|
|
1600
|
-
//
|
|
1601
|
-
|
|
1602
|
-
if (storedApiKey === undefined
|
|
1603
|
-
|
|
1604
|
-
}
|
|
1571
|
+
// Retain all-paused pools without activating any paused credential during login.
|
|
1572
|
+
if (storedApiKeyPool?.some(entry => entry.key === storedApiKey && entry.disabled)) storedApiKey = undefined;
|
|
1573
|
+
if (storedApiKey === undefined) storedApiKey = storedApiKeyPool?.find(entry => !entry.disabled)?.key;
|
|
1574
|
+
if (storedApiKeyPool) next.apiKeyPool = storedApiKeyPool;
|
|
1605
1575
|
if (storedApiKey !== undefined) {
|
|
1606
1576
|
const pool = storedApiKeyPool ? [...storedApiKeyPool] : [];
|
|
1607
1577
|
// Keep routing and listProviderApiKeys in sync: never leave a hidden active key that
|
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;
|