@vacbo/opencode-anthropic-fix 0.1.7 → 0.1.9
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 +88 -88
- package/dist/opencode-anthropic-auth-cli.mjs +804 -507
- package/dist/opencode-anthropic-auth-plugin.js +4751 -4109
- package/package.json +67 -59
- package/src/__tests__/billing-edge-cases.test.ts +59 -59
- package/src/__tests__/bun-proxy.parallel.test.ts +388 -382
- package/src/__tests__/cc-comparison.test.ts +87 -87
- package/src/__tests__/cc-credentials.test.ts +254 -250
- package/src/__tests__/cch-drift-checker.test.ts +51 -51
- package/src/__tests__/cch-native-style.test.ts +56 -56
- package/src/__tests__/debug-gating.test.ts +42 -42
- package/src/__tests__/decomposition-smoke.test.ts +68 -68
- package/src/__tests__/fingerprint-regression.test.ts +575 -566
- package/src/__tests__/helpers/conversation-history.smoke.test.ts +271 -271
- package/src/__tests__/helpers/conversation-history.ts +119 -119
- package/src/__tests__/helpers/deferred.smoke.test.ts +103 -103
- package/src/__tests__/helpers/deferred.ts +69 -69
- package/src/__tests__/helpers/in-memory-storage.smoke.test.ts +155 -155
- package/src/__tests__/helpers/in-memory-storage.ts +88 -88
- package/src/__tests__/helpers/mock-bun-proxy.smoke.test.ts +68 -68
- package/src/__tests__/helpers/mock-bun-proxy.ts +189 -189
- package/src/__tests__/helpers/plugin-fetch-harness.smoke.test.ts +273 -273
- package/src/__tests__/helpers/plugin-fetch-harness.ts +288 -288
- package/src/__tests__/helpers/sse.smoke.test.ts +236 -236
- package/src/__tests__/helpers/sse.ts +209 -209
- package/src/__tests__/index.parallel.test.ts +605 -595
- package/src/__tests__/sanitization-regex.test.ts +112 -112
- package/src/__tests__/state-bounds.test.ts +90 -90
- package/src/account-identity.test.ts +197 -192
- package/src/account-identity.ts +69 -67
- package/src/account-state.test.ts +86 -86
- package/src/account-state.ts +25 -25
- package/src/accounts/matching.test.ts +335 -0
- package/src/accounts/matching.ts +167 -0
- package/src/accounts/persistence.test.ts +345 -0
- package/src/accounts/persistence.ts +432 -0
- package/src/accounts/repair.test.ts +276 -0
- package/src/accounts/repair.ts +407 -0
- package/src/accounts.dedup.test.ts +621 -621
- package/src/accounts.test.ts +933 -929
- package/src/accounts.ts +633 -989
- package/src/backoff.test.ts +345 -345
- package/src/backoff.ts +219 -219
- package/src/betas.ts +124 -124
- package/src/bun-fetch.test.ts +345 -342
- package/src/bun-fetch.ts +424 -424
- package/src/bun-proxy.test.ts +25 -25
- package/src/bun-proxy.ts +209 -209
- package/src/cc-credentials.ts +111 -111
- package/src/circuit-breaker.test.ts +184 -184
- package/src/circuit-breaker.ts +169 -169
- package/src/cli/commands/auth.ts +963 -0
- package/src/cli/commands/config.ts +547 -0
- package/src/cli/formatting.test.ts +406 -0
- package/src/cli/formatting.ts +219 -0
- package/src/cli.ts +255 -2022
- package/src/commands/handlers/betas.ts +100 -0
- package/src/commands/handlers/config.ts +99 -0
- package/src/commands/handlers/files.ts +375 -0
- package/src/commands/oauth-flow.ts +181 -166
- package/src/commands/prompts.ts +61 -61
- package/src/commands/router.test.ts +421 -0
- package/src/commands/router.ts +143 -635
- package/src/config.test.ts +482 -482
- package/src/config.ts +412 -404
- package/src/constants.ts +48 -48
- package/src/drift/cch-constants.ts +95 -95
- package/src/env.ts +111 -105
- package/src/headers/billing.ts +33 -33
- package/src/headers/builder.ts +130 -130
- package/src/headers/cch.ts +75 -75
- package/src/headers/stainless.ts +25 -25
- package/src/headers/user-agent.ts +23 -23
- package/src/index.ts +436 -828
- package/src/models.ts +27 -27
- package/src/oauth.test.ts +102 -102
- package/src/oauth.ts +178 -178
- package/src/parent-pid-watcher.test.ts +148 -148
- package/src/parent-pid-watcher.ts +69 -69
- package/src/plugin-helpers.ts +82 -82
- package/src/refresh-helpers.ts +145 -139
- package/src/refresh-lock.test.ts +94 -94
- package/src/refresh-lock.ts +93 -93
- package/src/request/body.history.test.ts +579 -571
- package/src/request/body.ts +255 -255
- package/src/request/metadata.ts +65 -65
- package/src/request/retry.test.ts +156 -156
- package/src/request/retry.ts +67 -67
- package/src/request/url.ts +21 -21
- package/src/request-orchestration-helpers.ts +648 -0
- package/src/response/index.ts +5 -5
- package/src/response/mcp.ts +58 -58
- package/src/response/streaming.test.ts +313 -311
- package/src/response/streaming.ts +412 -410
- package/src/rotation.test.ts +304 -301
- package/src/rotation.ts +205 -205
- package/src/storage.test.ts +547 -547
- package/src/storage.ts +315 -291
- package/src/system-prompt/builder.ts +38 -38
- package/src/system-prompt/index.ts +5 -5
- package/src/system-prompt/normalize.ts +60 -60
- package/src/system-prompt/sanitize.ts +30 -30
- package/src/thinking.ts +21 -20
- package/src/token-refresh.test.ts +265 -265
- package/src/token-refresh.ts +219 -214
- package/src/types.ts +30 -30
- package/dist/bun-proxy.mjs +0 -291
package/src/accounts.ts
CHANGED
|
@@ -1,1067 +1,711 @@
|
|
|
1
1
|
import type { RateLimitReason } from "./backoff.js";
|
|
2
2
|
import { calculateBackoffMs } from "./backoff.js";
|
|
3
3
|
import { readCCCredentials } from "./cc-credentials.js";
|
|
4
|
+
import { resolveIdentityFromCCCredential, type AccountIdentity } from "./account-identity.js";
|
|
4
5
|
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
6
|
+
createBootstrapAccountFromFallback,
|
|
7
|
+
loadManagedAccountsFromStorage,
|
|
8
|
+
mergeAuthFallbackIntoAccounts,
|
|
9
|
+
prepareStorageForSave,
|
|
10
|
+
reconcileManagedAccountsWithStorage,
|
|
11
|
+
} from "./accounts/persistence.js";
|
|
12
|
+
import {
|
|
13
|
+
createManagedAccount,
|
|
14
|
+
findMatchingManagedAccount,
|
|
15
|
+
reindexManagedAccounts,
|
|
16
|
+
resolveManagedAccountIdentity,
|
|
17
|
+
} from "./accounts/matching.js";
|
|
18
|
+
import { inferCCSourceFromId, repairCorruptedCCAccounts } from "./accounts/repair.js";
|
|
10
19
|
import type { AnthropicAuthConfig } from "./config.js";
|
|
11
20
|
import { HealthScoreTracker, selectAccount, TokenBucketTracker } from "./rotation.js";
|
|
12
|
-
import type {
|
|
21
|
+
import type { AccountStats, AccountStorage } from "./storage.js";
|
|
13
22
|
import { createDefaultStats, loadAccounts, saveAccounts } from "./storage.js";
|
|
14
23
|
|
|
15
24
|
export interface ManagedAccount {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
25
|
+
id: string;
|
|
26
|
+
index: number;
|
|
27
|
+
email?: string;
|
|
28
|
+
identity?: AccountIdentity;
|
|
29
|
+
label?: string;
|
|
30
|
+
refreshToken: string;
|
|
31
|
+
access?: string;
|
|
32
|
+
expires?: number;
|
|
33
|
+
tokenUpdatedAt: number;
|
|
34
|
+
addedAt: number;
|
|
35
|
+
lastUsed: number;
|
|
36
|
+
enabled: boolean;
|
|
37
|
+
rateLimitResetTimes: Record<string, number>;
|
|
38
|
+
consecutiveFailures: number;
|
|
39
|
+
lastFailureTime: number | null;
|
|
40
|
+
lastSwitchReason?: string;
|
|
41
|
+
stats: AccountStats;
|
|
42
|
+
source?: "cc-keychain" | "cc-file" | "oauth";
|
|
34
43
|
}
|
|
35
44
|
|
|
36
45
|
export interface StatsDelta {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
+
requests: number;
|
|
47
|
+
inputTokens: number;
|
|
48
|
+
outputTokens: number;
|
|
49
|
+
cacheReadTokens: number;
|
|
50
|
+
cacheWriteTokens: number;
|
|
51
|
+
/** If true, this delta represents an absolute reset, not an increment */
|
|
52
|
+
isReset: boolean;
|
|
53
|
+
/** The lastReset value when isReset is true */
|
|
54
|
+
resetTimestamp?: number;
|
|
46
55
|
}
|
|
47
56
|
|
|
48
57
|
const MAX_ACCOUNTS = 10;
|
|
49
58
|
const RATE_LIMIT_KEY = "anthropic";
|
|
50
59
|
|
|
51
|
-
type ManagedAccountSource = ManagedAccount["source"];
|
|
52
|
-
|
|
53
60
|
type AddAccountOptions = {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
61
|
+
identity?: AccountIdentity;
|
|
62
|
+
label?: string;
|
|
63
|
+
source?: ManagedAccount["source"];
|
|
57
64
|
};
|
|
58
65
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
66
|
+
export class AccountManager {
|
|
67
|
+
#accounts: ManagedAccount[] = [];
|
|
68
|
+
#cursor = 0;
|
|
69
|
+
#currentIndex = -1;
|
|
70
|
+
#healthTracker: HealthScoreTracker;
|
|
71
|
+
#tokenTracker: TokenBucketTracker;
|
|
72
|
+
#config: AnthropicAuthConfig;
|
|
73
|
+
#saveTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
74
|
+
#statsDeltas = new Map<string, StatsDelta>();
|
|
75
|
+
#pendingDroppedIds = new Set<string>();
|
|
76
|
+
/**
|
|
77
|
+
* Cap on pending stats deltas. When hit, a forced flush is scheduled so the
|
|
78
|
+
* map does not grow without bound between debounced saves. This is only a
|
|
79
|
+
* safety net — under normal load the 1s debounced save in `requestSaveToDisk`
|
|
80
|
+
* keeps the delta count below this cap.
|
|
81
|
+
*/
|
|
82
|
+
readonly #MAX_STATS_DELTAS = 100;
|
|
83
|
+
|
|
84
|
+
constructor(config: AnthropicAuthConfig) {
|
|
85
|
+
this.#config = config;
|
|
86
|
+
this.#healthTracker = new HealthScoreTracker(config.health_score);
|
|
87
|
+
this.#tokenTracker = new TokenBucketTracker(config.token_bucket);
|
|
88
|
+
}
|
|
80
89
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
label?: string;
|
|
86
|
-
source?: ManagedAccountSource;
|
|
87
|
-
}): AccountIdentity {
|
|
88
|
-
if (params.identity) {
|
|
89
|
-
return params.identity;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
if ((params.source === "cc-keychain" || params.source === "cc-file") && params.label) {
|
|
93
|
-
return {
|
|
94
|
-
kind: "cc",
|
|
95
|
-
source: params.source,
|
|
96
|
-
label: params.label,
|
|
97
|
-
};
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
if (params.email) {
|
|
101
|
-
return {
|
|
102
|
-
kind: "oauth",
|
|
103
|
-
email: params.email,
|
|
104
|
-
};
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
return {
|
|
108
|
-
kind: "legacy",
|
|
109
|
-
refreshToken: params.refreshToken,
|
|
110
|
-
};
|
|
111
|
-
}
|
|
90
|
+
#rebuildTrackers(): void {
|
|
91
|
+
this.#healthTracker = new HealthScoreTracker(this.#config.health_score);
|
|
92
|
+
this.#tokenTracker = new TokenBucketTracker(this.#config.token_bucket);
|
|
93
|
+
}
|
|
112
94
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
expires: init.expires,
|
|
137
|
-
tokenUpdatedAt,
|
|
138
|
-
addedAt,
|
|
139
|
-
lastUsed: init.lastUsed ?? 0,
|
|
140
|
-
enabled: init.enabled ?? true,
|
|
141
|
-
rateLimitResetTimes: { ...(init.rateLimitResetTimes ?? {}) },
|
|
142
|
-
consecutiveFailures: init.consecutiveFailures ?? 0,
|
|
143
|
-
lastFailureTime: init.lastFailureTime ?? null,
|
|
144
|
-
lastSwitchReason: init.lastSwitchReason ?? "initial",
|
|
145
|
-
stats: init.stats ?? createDefaultStats(addedAt),
|
|
146
|
-
source,
|
|
147
|
-
};
|
|
148
|
-
}
|
|
95
|
+
/**
|
|
96
|
+
* Load accounts from disk, optionally merging with an OpenCode auth fallback.
|
|
97
|
+
*/
|
|
98
|
+
static async load(
|
|
99
|
+
config: AnthropicAuthConfig,
|
|
100
|
+
authFallback?: {
|
|
101
|
+
refresh: string;
|
|
102
|
+
access?: string;
|
|
103
|
+
expires?: number;
|
|
104
|
+
} | null,
|
|
105
|
+
): Promise<AccountManager> {
|
|
106
|
+
const manager = new AccountManager(config);
|
|
107
|
+
const stored = await loadAccounts();
|
|
108
|
+
|
|
109
|
+
// If storage exists (even with zero accounts), treat disk as authoritative.
|
|
110
|
+
if (stored) {
|
|
111
|
+
const loaded = loadManagedAccountsFromStorage(stored);
|
|
112
|
+
manager.#accounts = loaded.accounts;
|
|
113
|
+
manager.#currentIndex = loaded.currentIndex;
|
|
114
|
+
|
|
115
|
+
if (authFallback && manager.#accounts.length > 0) {
|
|
116
|
+
mergeAuthFallbackIntoAccounts(manager.#accounts, authFallback);
|
|
117
|
+
}
|
|
149
118
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
refreshToken?: string;
|
|
156
|
-
},
|
|
157
|
-
): ManagedAccount | null {
|
|
158
|
-
if (params.id) {
|
|
159
|
-
const byId = accounts.find((account) => account.id === params.id);
|
|
160
|
-
if (byId) return byId;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
if (params.identity) {
|
|
164
|
-
const byIdentity = findByIdentity(accounts, params.identity);
|
|
165
|
-
if (byIdentity) return byIdentity;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
if (params.refreshToken) {
|
|
169
|
-
return accounts.find((account) => account.refreshToken === params.refreshToken) ?? null;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
return null;
|
|
173
|
-
}
|
|
119
|
+
// No stored accounts — bootstrap from fallback if available
|
|
120
|
+
} else if (authFallback && authFallback.refresh) {
|
|
121
|
+
manager.#accounts = [createBootstrapAccountFromFallback(authFallback)];
|
|
122
|
+
manager.#currentIndex = 0;
|
|
123
|
+
}
|
|
174
124
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
125
|
+
if (config.cc_credential_reuse?.enabled && config.cc_credential_reuse?.auto_detect) {
|
|
126
|
+
const currentAccountId = manager.#accounts[manager.#currentIndex]?.id ?? null;
|
|
127
|
+
const ccCredentials: ReturnType<typeof readCCCredentials> = (() => {
|
|
128
|
+
try {
|
|
129
|
+
return readCCCredentials();
|
|
130
|
+
} catch {
|
|
131
|
+
return [];
|
|
132
|
+
}
|
|
133
|
+
})();
|
|
134
|
+
|
|
135
|
+
// Heal corrupted CC rows that lost source/identity/label in an older
|
|
136
|
+
// write path, and collapse any resulting duplicates BEFORE auto-import
|
|
137
|
+
// runs. Otherwise auto-import would fail to match the corrupted row
|
|
138
|
+
// and create a fresh duplicate every load. Dropped ids are stashed on
|
|
139
|
+
// the manager so the next saveToDisk can tell prepareStorageForSave
|
|
140
|
+
// not to restore them via the disk-only union.
|
|
141
|
+
const repair = repairCorruptedCCAccounts(manager.#accounts, ccCredentials);
|
|
142
|
+
if (repair.result.collapsed > 0 || repair.result.repaired > 0) {
|
|
143
|
+
const beforeIds = new Set(manager.#accounts.map((account) => account.id));
|
|
144
|
+
manager.#accounts = repair.accounts;
|
|
145
|
+
reindexManagedAccounts(manager.#accounts);
|
|
146
|
+
const afterIds = new Set(manager.#accounts.map((account) => account.id));
|
|
147
|
+
for (const id of beforeIds) {
|
|
148
|
+
if (!afterIds.has(id)) manager.#pendingDroppedIds.add(id);
|
|
149
|
+
}
|
|
150
|
+
if (manager.#currentIndex >= manager.#accounts.length) {
|
|
151
|
+
manager.#currentIndex = manager.#accounts.length > 0 ? 0 : -1;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
180
154
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
155
|
+
for (const ccCredential of ccCredentials) {
|
|
156
|
+
const ccIdentity = resolveIdentityFromCCCredential(ccCredential);
|
|
157
|
+
let existingMatch = findMatchingManagedAccount(manager.#accounts, {
|
|
158
|
+
identity: ccIdentity,
|
|
159
|
+
refreshToken: ccCredential.refreshToken,
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
if (!existingMatch) {
|
|
163
|
+
const legacyUnlabeledMatches = manager.#accounts.filter(
|
|
164
|
+
(account) => account.source === ccCredential.source && !account.label && !account.email,
|
|
165
|
+
);
|
|
166
|
+
if (legacyUnlabeledMatches.length === 1) {
|
|
167
|
+
existingMatch = legacyUnlabeledMatches[0]!;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (existingMatch) {
|
|
172
|
+
existingMatch.refreshToken = ccCredential.refreshToken;
|
|
173
|
+
existingMatch.identity = ccIdentity;
|
|
174
|
+
existingMatch.source = ccCredential.source;
|
|
175
|
+
existingMatch.label = ccCredential.label;
|
|
176
|
+
existingMatch.enabled = true;
|
|
177
|
+
if (ccCredential.accessToken) {
|
|
178
|
+
existingMatch.access = ccCredential.accessToken;
|
|
179
|
+
}
|
|
180
|
+
if (ccCredential.expiresAt >= (existingMatch.expires ?? 0)) {
|
|
181
|
+
existingMatch.expires = ccCredential.expiresAt;
|
|
182
|
+
}
|
|
183
|
+
existingMatch.tokenUpdatedAt = Math.max(
|
|
184
|
+
existingMatch.tokenUpdatedAt || 0,
|
|
185
|
+
ccCredential.expiresAt || 0,
|
|
186
|
+
);
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (manager.#accounts.length >= MAX_ACCOUNTS) {
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const emailCollision = manager
|
|
195
|
+
.getOAuthAccounts()
|
|
196
|
+
.find((account) => account.email && ccCredential.label.includes(account.email));
|
|
197
|
+
if (emailCollision?.email) {
|
|
198
|
+
// Duplicate detection: CC credential may match existing OAuth account
|
|
199
|
+
// This is informational only - both accounts are kept
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const now = Date.now();
|
|
203
|
+
const ccAccount = createManagedAccount({
|
|
204
|
+
id: `cc-${ccCredential.source}-${now}:${ccCredential.refreshToken.slice(0, 12)}`,
|
|
205
|
+
index: manager.#accounts.length,
|
|
206
|
+
refreshToken: ccCredential.refreshToken,
|
|
207
|
+
access: ccCredential.accessToken,
|
|
208
|
+
expires: ccCredential.expiresAt,
|
|
209
|
+
tokenUpdatedAt: now,
|
|
210
|
+
addedAt: now,
|
|
211
|
+
identity: ccIdentity,
|
|
212
|
+
label: ccCredential.label,
|
|
213
|
+
lastSwitchReason: "cc-auto-detected",
|
|
214
|
+
source: ccCredential.source,
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
manager.#accounts.push(ccAccount);
|
|
218
|
+
}
|
|
211
219
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
* keeps the delta count below this cap.
|
|
226
|
-
*/
|
|
227
|
-
readonly #MAX_STATS_DELTAS = 100;
|
|
228
|
-
|
|
229
|
-
constructor(config: AnthropicAuthConfig) {
|
|
230
|
-
this.#config = config;
|
|
231
|
-
this.#healthTracker = new HealthScoreTracker(config.health_score);
|
|
232
|
-
this.#tokenTracker = new TokenBucketTracker(config.token_bucket);
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
#rebuildTrackers(): void {
|
|
236
|
-
this.#healthTracker = new HealthScoreTracker(this.#config.health_score);
|
|
237
|
-
this.#tokenTracker = new TokenBucketTracker(this.#config.token_bucket);
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
/**
|
|
241
|
-
* Load accounts from disk, optionally merging with an OpenCode auth fallback.
|
|
242
|
-
*/
|
|
243
|
-
static async load(
|
|
244
|
-
config: AnthropicAuthConfig,
|
|
245
|
-
authFallback?: {
|
|
246
|
-
refresh: string;
|
|
247
|
-
access?: string;
|
|
248
|
-
expires?: number;
|
|
249
|
-
} | null,
|
|
250
|
-
): Promise<AccountManager> {
|
|
251
|
-
const manager = new AccountManager(config);
|
|
252
|
-
const stored = await loadAccounts();
|
|
253
|
-
|
|
254
|
-
// If storage exists (even with zero accounts), treat disk as authoritative.
|
|
255
|
-
if (stored) {
|
|
256
|
-
manager.#accounts = stored.accounts.map((acc, index) =>
|
|
257
|
-
createManagedAccount({
|
|
258
|
-
id: acc.id || `${acc.addedAt}:${acc.refreshToken.slice(0, 12)}`,
|
|
259
|
-
index,
|
|
260
|
-
email: acc.email,
|
|
261
|
-
identity: acc.identity,
|
|
262
|
-
label: acc.label,
|
|
263
|
-
refreshToken: acc.refreshToken,
|
|
264
|
-
access: acc.access,
|
|
265
|
-
expires: acc.expires,
|
|
266
|
-
tokenUpdatedAt: acc.token_updated_at,
|
|
267
|
-
addedAt: acc.addedAt,
|
|
268
|
-
lastUsed: acc.lastUsed,
|
|
269
|
-
enabled: acc.enabled,
|
|
270
|
-
rateLimitResetTimes: acc.rateLimitResetTimes,
|
|
271
|
-
consecutiveFailures: acc.consecutiveFailures,
|
|
272
|
-
lastFailureTime: acc.lastFailureTime,
|
|
273
|
-
lastSwitchReason: acc.lastSwitchReason,
|
|
274
|
-
stats: acc.stats,
|
|
275
|
-
source: acc.source || "oauth",
|
|
276
|
-
}),
|
|
277
|
-
);
|
|
278
|
-
|
|
279
|
-
manager.#currentIndex =
|
|
280
|
-
manager.#accounts.length > 0 ? Math.min(stored.activeIndex, manager.#accounts.length - 1) : -1;
|
|
281
|
-
|
|
282
|
-
if (authFallback && manager.#accounts.length > 0) {
|
|
283
|
-
const fallbackIdentity = resolveAccountIdentity({
|
|
284
|
-
refreshToken: authFallback.refresh,
|
|
285
|
-
source: "oauth",
|
|
286
|
-
});
|
|
287
|
-
const match = findMatchingAccount(manager.#accounts, {
|
|
288
|
-
identity: fallbackIdentity,
|
|
289
|
-
refreshToken: authFallback.refresh,
|
|
290
|
-
});
|
|
291
|
-
if (match) {
|
|
292
|
-
const fallbackHasAccess = typeof authFallback.access === "string" && authFallback.access.length > 0;
|
|
293
|
-
const fallbackExpires = typeof authFallback.expires === "number" ? authFallback.expires : 0;
|
|
294
|
-
const matchExpires = typeof match.expires === "number" ? match.expires : 0;
|
|
295
|
-
const fallbackLooksFresh = fallbackHasAccess && fallbackExpires > Date.now();
|
|
296
|
-
const shouldAdoptFallback =
|
|
297
|
-
fallbackLooksFresh && (!match.access || !match.expires || fallbackExpires > matchExpires);
|
|
298
|
-
if (shouldAdoptFallback) {
|
|
299
|
-
match.access = authFallback.access;
|
|
300
|
-
match.expires = authFallback.expires;
|
|
301
|
-
match.tokenUpdatedAt = Math.max(match.tokenUpdatedAt || 0, fallbackExpires);
|
|
302
|
-
}
|
|
220
|
+
if (config.cc_credential_reuse.prefer_over_oauth && manager.getCCAccounts().length > 0) {
|
|
221
|
+
manager.#accounts = [...manager.getCCAccounts(), ...manager.getOAuthAccounts()];
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
reindexManagedAccounts(manager.#accounts);
|
|
225
|
+
|
|
226
|
+
if (config.cc_credential_reuse.prefer_over_oauth && manager.getCCAccounts().length > 0) {
|
|
227
|
+
manager.#currentIndex = 0;
|
|
228
|
+
} else if (currentAccountId) {
|
|
229
|
+
manager.#currentIndex = manager.#accounts.findIndex((account) => account.id === currentAccountId);
|
|
230
|
+
} else if (manager.#currentIndex < 0 && manager.#accounts.length > 0) {
|
|
231
|
+
manager.#currentIndex = 0;
|
|
232
|
+
}
|
|
303
233
|
}
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
// No stored accounts — bootstrap from fallback if available
|
|
307
|
-
} else if (authFallback && authFallback.refresh) {
|
|
308
|
-
const now = Date.now();
|
|
309
|
-
manager.#accounts = [
|
|
310
|
-
createManagedAccount({
|
|
311
|
-
id: `${now}:${authFallback.refresh.slice(0, 12)}`,
|
|
312
|
-
index: 0,
|
|
313
|
-
refreshToken: authFallback.refresh,
|
|
314
|
-
access: authFallback.access,
|
|
315
|
-
expires: authFallback.expires,
|
|
316
|
-
tokenUpdatedAt: now,
|
|
317
|
-
addedAt: now,
|
|
318
|
-
lastSwitchReason: "initial",
|
|
319
|
-
source: "oauth",
|
|
320
|
-
}),
|
|
321
|
-
];
|
|
322
|
-
manager.#currentIndex = 0;
|
|
234
|
+
|
|
235
|
+
return manager;
|
|
323
236
|
}
|
|
324
237
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
return [];
|
|
332
|
-
}
|
|
333
|
-
})();
|
|
238
|
+
/**
|
|
239
|
+
* Get the number of enabled accounts.
|
|
240
|
+
*/
|
|
241
|
+
getAccountCount(): number {
|
|
242
|
+
return this.#accounts.filter((acc) => acc.enabled).length;
|
|
243
|
+
}
|
|
334
244
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
245
|
+
/**
|
|
246
|
+
* Get the total number of accounts (including disabled).
|
|
247
|
+
*/
|
|
248
|
+
getTotalAccountCount(): number {
|
|
249
|
+
return this.#accounts.length;
|
|
250
|
+
}
|
|
341
251
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
}
|
|
349
|
-
}
|
|
252
|
+
/**
|
|
253
|
+
* Get a snapshot of all accounts (for display/management).
|
|
254
|
+
*/
|
|
255
|
+
getAccountsSnapshot(): ManagedAccount[] {
|
|
256
|
+
return this.#accounts.map((acc) => ({ ...acc }));
|
|
257
|
+
}
|
|
350
258
|
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
259
|
+
/**
|
|
260
|
+
* Get the current active account index.
|
|
261
|
+
*/
|
|
262
|
+
getCurrentIndex(): number {
|
|
263
|
+
return this.#currentIndex;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Force the active account to a specific index.
|
|
268
|
+
* Used by OPENCODE_ANTHROPIC_INITIAL_ACCOUNT to pin a session to one account.
|
|
269
|
+
*/
|
|
270
|
+
forceCurrentIndex(index: number): boolean {
|
|
271
|
+
const account = this.#accounts[index];
|
|
272
|
+
if (!account || !account.enabled) return false;
|
|
273
|
+
this.#currentIndex = index;
|
|
274
|
+
this.#cursor = index;
|
|
275
|
+
return true;
|
|
276
|
+
}
|
|
366
277
|
|
|
367
|
-
|
|
368
|
-
|
|
278
|
+
/**
|
|
279
|
+
* Get enabled account references for internal plugin operations.
|
|
280
|
+
*/
|
|
281
|
+
getEnabledAccounts(excludedIndices?: Set<number>): ManagedAccount[] {
|
|
282
|
+
return this.#accounts.filter((acc) => acc.enabled && !excludedIndices?.has(acc.index));
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
getCCAccounts(): ManagedAccount[] {
|
|
286
|
+
return this.#accounts.filter((acc) => acc.source === "cc-keychain" || acc.source === "cc-file");
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
getOAuthAccounts(): ManagedAccount[] {
|
|
290
|
+
return this.#accounts.filter((acc) => !acc.source || acc.source === "oauth");
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
#clearExpiredRateLimits(account: ManagedAccount): void {
|
|
294
|
+
const now = Date.now();
|
|
295
|
+
for (const key of Object.keys(account.rateLimitResetTimes)) {
|
|
296
|
+
if (account.rateLimitResetTimes[key]! <= now) {
|
|
297
|
+
delete account.rateLimitResetTimes[key];
|
|
298
|
+
}
|
|
369
299
|
}
|
|
300
|
+
}
|
|
370
301
|
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
302
|
+
#isRateLimited(account: ManagedAccount): boolean {
|
|
303
|
+
this.#clearExpiredRateLimits(account);
|
|
304
|
+
const resetTime = account.rateLimitResetTimes[RATE_LIMIT_KEY];
|
|
305
|
+
return resetTime !== undefined && Date.now() < resetTime;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Select the best account for the current request.
|
|
310
|
+
*/
|
|
311
|
+
getCurrentAccount(excludedIndices?: Set<number>): ManagedAccount | null {
|
|
312
|
+
if (this.#accounts.length === 0) return null;
|
|
313
|
+
|
|
314
|
+
const candidates = this.#accounts
|
|
315
|
+
.filter((acc) => acc.enabled && !excludedIndices?.has(acc.index))
|
|
316
|
+
.map((acc) => {
|
|
317
|
+
this.#clearExpiredRateLimits(acc);
|
|
318
|
+
return {
|
|
319
|
+
index: acc.index,
|
|
320
|
+
lastUsed: acc.lastUsed,
|
|
321
|
+
healthScore: this.#healthTracker.getScore(acc.index),
|
|
322
|
+
isRateLimited: this.#isRateLimited(acc),
|
|
323
|
+
enabled: acc.enabled,
|
|
324
|
+
};
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
const result = selectAccount(
|
|
328
|
+
candidates,
|
|
329
|
+
this.#config.account_selection_strategy,
|
|
330
|
+
this.#currentIndex >= 0 ? this.#currentIndex : null,
|
|
331
|
+
this.#healthTracker,
|
|
332
|
+
this.#tokenTracker,
|
|
333
|
+
this.#cursor,
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
if (!result) return null;
|
|
337
|
+
|
|
338
|
+
this.#cursor = result.cursor;
|
|
339
|
+
this.#currentIndex = result.index;
|
|
340
|
+
|
|
341
|
+
const account = this.#accounts[result.index];
|
|
342
|
+
if (account) {
|
|
343
|
+
account.lastUsed = Date.now();
|
|
344
|
+
this.#tokenTracker.consume(account.index);
|
|
377
345
|
}
|
|
378
346
|
|
|
347
|
+
return account ?? null;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Mark an account as rate-limited.
|
|
352
|
+
* @returns The backoff duration in ms
|
|
353
|
+
*/
|
|
354
|
+
markRateLimited(account: ManagedAccount, reason: RateLimitReason, retryAfterMs?: number | null): number {
|
|
379
355
|
const now = Date.now();
|
|
380
|
-
const ccAccount = createManagedAccount({
|
|
381
|
-
id: `cc-${ccCredential.source}-${now}:${ccCredential.refreshToken.slice(0, 12)}`,
|
|
382
|
-
index: manager.#accounts.length,
|
|
383
|
-
refreshToken: ccCredential.refreshToken,
|
|
384
|
-
access: ccCredential.accessToken,
|
|
385
|
-
expires: ccCredential.expiresAt,
|
|
386
|
-
tokenUpdatedAt: now,
|
|
387
|
-
addedAt: now,
|
|
388
|
-
identity: ccIdentity,
|
|
389
|
-
label: ccCredential.label,
|
|
390
|
-
lastSwitchReason: "cc-auto-detected",
|
|
391
|
-
source: ccCredential.source,
|
|
392
|
-
});
|
|
393
356
|
|
|
394
|
-
|
|
395
|
-
|
|
357
|
+
if (
|
|
358
|
+
account.lastFailureTime !== null &&
|
|
359
|
+
now - account.lastFailureTime > this.#config.failure_ttl_seconds * 1000
|
|
360
|
+
) {
|
|
361
|
+
account.consecutiveFailures = 0;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
account.consecutiveFailures += 1;
|
|
365
|
+
account.lastFailureTime = now;
|
|
396
366
|
|
|
397
|
-
|
|
398
|
-
manager.#accounts = [...manager.getCCAccounts(), ...manager.getOAuthAccounts()];
|
|
399
|
-
}
|
|
367
|
+
const backoffMs = calculateBackoffMs(reason, account.consecutiveFailures - 1, retryAfterMs);
|
|
400
368
|
|
|
401
|
-
|
|
369
|
+
account.rateLimitResetTimes[RATE_LIMIT_KEY] = now + backoffMs;
|
|
402
370
|
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
manager.#currentIndex = 0;
|
|
409
|
-
}
|
|
371
|
+
this.#healthTracker.recordRateLimit(account.index);
|
|
372
|
+
|
|
373
|
+
this.requestSaveToDisk();
|
|
374
|
+
|
|
375
|
+
return backoffMs;
|
|
410
376
|
}
|
|
411
377
|
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
return this.#accounts.filter((acc) => acc.enabled).length;
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
/**
|
|
423
|
-
* Get the total number of accounts (including disabled).
|
|
424
|
-
*/
|
|
425
|
-
getTotalAccountCount(): number {
|
|
426
|
-
return this.#accounts.length;
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
/**
|
|
430
|
-
* Get a snapshot of all accounts (for display/management).
|
|
431
|
-
*/
|
|
432
|
-
getAccountsSnapshot(): ManagedAccount[] {
|
|
433
|
-
return this.#accounts.map((acc) => ({ ...acc }));
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
/**
|
|
437
|
-
* Get the current active account index.
|
|
438
|
-
*/
|
|
439
|
-
getCurrentIndex(): number {
|
|
440
|
-
return this.#currentIndex;
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
/**
|
|
444
|
-
* Force the active account to a specific index.
|
|
445
|
-
* Used by OPENCODE_ANTHROPIC_INITIAL_ACCOUNT to pin a session to one account.
|
|
446
|
-
*/
|
|
447
|
-
forceCurrentIndex(index: number): boolean {
|
|
448
|
-
const account = this.#accounts[index];
|
|
449
|
-
if (!account || !account.enabled) return false;
|
|
450
|
-
this.#currentIndex = index;
|
|
451
|
-
this.#cursor = index;
|
|
452
|
-
return true;
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
/**
|
|
456
|
-
* Get enabled account references for internal plugin operations.
|
|
457
|
-
*/
|
|
458
|
-
getEnabledAccounts(excludedIndices?: Set<number>): ManagedAccount[] {
|
|
459
|
-
return this.#accounts.filter((acc) => acc.enabled && !excludedIndices?.has(acc.index));
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
getCCAccounts(): ManagedAccount[] {
|
|
463
|
-
return this.#accounts.filter((acc) => acc.source === "cc-keychain" || acc.source === "cc-file");
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
getOAuthAccounts(): ManagedAccount[] {
|
|
467
|
-
return this.#accounts.filter((acc) => !acc.source || acc.source === "oauth");
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
#clearExpiredRateLimits(account: ManagedAccount): void {
|
|
471
|
-
const now = Date.now();
|
|
472
|
-
for (const key of Object.keys(account.rateLimitResetTimes)) {
|
|
473
|
-
if (account.rateLimitResetTimes[key]! <= now) {
|
|
474
|
-
delete account.rateLimitResetTimes[key];
|
|
475
|
-
}
|
|
378
|
+
/**
|
|
379
|
+
* Mark a successful request for an account.
|
|
380
|
+
*/
|
|
381
|
+
markSuccess(account: ManagedAccount): void {
|
|
382
|
+
account.consecutiveFailures = 0;
|
|
383
|
+
account.lastFailureTime = null;
|
|
384
|
+
this.#healthTracker.recordSuccess(account.index);
|
|
476
385
|
}
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
/**
|
|
486
|
-
* Select the best account for the current request.
|
|
487
|
-
*/
|
|
488
|
-
getCurrentAccount(excludedIndices?: Set<number>): ManagedAccount | null {
|
|
489
|
-
if (this.#accounts.length === 0) return null;
|
|
490
|
-
|
|
491
|
-
const candidates = this.#accounts
|
|
492
|
-
.filter((acc) => acc.enabled && !excludedIndices?.has(acc.index))
|
|
493
|
-
.map((acc) => {
|
|
494
|
-
this.#clearExpiredRateLimits(acc);
|
|
495
|
-
return {
|
|
496
|
-
index: acc.index,
|
|
497
|
-
lastUsed: acc.lastUsed,
|
|
498
|
-
healthScore: this.#healthTracker.getScore(acc.index),
|
|
499
|
-
isRateLimited: this.#isRateLimited(acc),
|
|
500
|
-
enabled: acc.enabled,
|
|
501
|
-
};
|
|
502
|
-
});
|
|
503
|
-
|
|
504
|
-
const result = selectAccount(
|
|
505
|
-
candidates,
|
|
506
|
-
this.#config.account_selection_strategy,
|
|
507
|
-
this.#currentIndex >= 0 ? this.#currentIndex : null,
|
|
508
|
-
this.#healthTracker,
|
|
509
|
-
this.#tokenTracker,
|
|
510
|
-
this.#cursor,
|
|
511
|
-
);
|
|
512
|
-
|
|
513
|
-
if (!result) return null;
|
|
514
|
-
|
|
515
|
-
this.#cursor = result.cursor;
|
|
516
|
-
this.#currentIndex = result.index;
|
|
517
|
-
|
|
518
|
-
const account = this.#accounts[result.index];
|
|
519
|
-
if (account) {
|
|
520
|
-
account.lastUsed = Date.now();
|
|
521
|
-
this.#tokenTracker.consume(account.index);
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Mark a general failure (not rate limit) for an account.
|
|
389
|
+
*/
|
|
390
|
+
markFailure(account: ManagedAccount): void {
|
|
391
|
+
this.#healthTracker.recordFailure(account.index);
|
|
392
|
+
this.#tokenTracker.refund(account.index);
|
|
522
393
|
}
|
|
523
394
|
|
|
524
|
-
|
|
525
|
-
|
|
395
|
+
/**
|
|
396
|
+
* Add a new account to the pool.
|
|
397
|
+
* @returns The new account, or null if at capacity
|
|
398
|
+
*/
|
|
399
|
+
addAccount(
|
|
400
|
+
refreshToken: string,
|
|
401
|
+
accessToken: string,
|
|
402
|
+
expires: number,
|
|
403
|
+
email?: string,
|
|
404
|
+
options?: AddAccountOptions,
|
|
405
|
+
): ManagedAccount | null {
|
|
406
|
+
const identity = resolveManagedAccountIdentity({
|
|
407
|
+
refreshToken,
|
|
408
|
+
email,
|
|
409
|
+
identity: options?.identity,
|
|
410
|
+
label: options?.label,
|
|
411
|
+
source: options?.source ?? "oauth",
|
|
412
|
+
});
|
|
413
|
+
const existing = findMatchingManagedAccount(this.#accounts, {
|
|
414
|
+
identity,
|
|
415
|
+
refreshToken,
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
if (existing) {
|
|
419
|
+
// Refuse to downgrade a CC-sourced row to oauth/legacy. The id prefix
|
|
420
|
+
// `cc-cc-(keychain|file)-` proves the row was born as a CC import; a
|
|
421
|
+
// caller without explicit CC options must only refresh tokens, never
|
|
422
|
+
// reshape source/identity/label/email.
|
|
423
|
+
const isExistingCC = existing.source === "cc-keychain" || existing.source === "cc-file";
|
|
424
|
+
const isNewCC = options?.source === "cc-keychain" || options?.source === "cc-file";
|
|
425
|
+
const isCCBornId = inferCCSourceFromId(existing.id) !== null;
|
|
426
|
+
const callerWouldDowngrade = (isExistingCC || isCCBornId) && !isNewCC;
|
|
427
|
+
|
|
428
|
+
existing.refreshToken = refreshToken;
|
|
429
|
+
existing.access = accessToken;
|
|
430
|
+
existing.expires = expires;
|
|
431
|
+
existing.tokenUpdatedAt = Date.now();
|
|
432
|
+
existing.enabled = true;
|
|
433
|
+
|
|
434
|
+
if (!callerWouldDowngrade) {
|
|
435
|
+
existing.email = email ?? existing.email;
|
|
436
|
+
existing.identity = identity;
|
|
437
|
+
existing.label = options?.label ?? existing.label;
|
|
438
|
+
existing.source = options?.source ?? existing.source ?? "oauth";
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
this.requestSaveToDisk();
|
|
442
|
+
return existing;
|
|
443
|
+
}
|
|
526
444
|
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
445
|
+
if (this.#accounts.length >= MAX_ACCOUNTS) return null;
|
|
446
|
+
|
|
447
|
+
const now = Date.now();
|
|
448
|
+
const account = createManagedAccount({
|
|
449
|
+
id: `${now}:${refreshToken.slice(0, 12)}`,
|
|
450
|
+
index: this.#accounts.length,
|
|
451
|
+
refreshToken,
|
|
452
|
+
access: accessToken,
|
|
453
|
+
expires,
|
|
454
|
+
tokenUpdatedAt: now,
|
|
455
|
+
addedAt: now,
|
|
456
|
+
lastSwitchReason: "initial",
|
|
457
|
+
email,
|
|
458
|
+
identity,
|
|
459
|
+
label: options?.label,
|
|
460
|
+
source: options?.source ?? "oauth",
|
|
461
|
+
});
|
|
533
462
|
|
|
534
|
-
|
|
535
|
-
|
|
463
|
+
this.#accounts.push(account);
|
|
464
|
+
|
|
465
|
+
if (this.#accounts.length === 1) {
|
|
466
|
+
this.#currentIndex = 0;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
this.requestSaveToDisk();
|
|
470
|
+
return account;
|
|
536
471
|
}
|
|
537
472
|
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
*/
|
|
564
|
-
markFailure(account: ManagedAccount): void {
|
|
565
|
-
this.#healthTracker.recordFailure(account.index);
|
|
566
|
-
this.#tokenTracker.refund(account.index);
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
/**
|
|
570
|
-
* Add a new account to the pool.
|
|
571
|
-
* @returns The new account, or null if at capacity
|
|
572
|
-
*/
|
|
573
|
-
addAccount(
|
|
574
|
-
refreshToken: string,
|
|
575
|
-
accessToken: string,
|
|
576
|
-
expires: number,
|
|
577
|
-
email?: string,
|
|
578
|
-
options?: AddAccountOptions,
|
|
579
|
-
): ManagedAccount | null {
|
|
580
|
-
const identity = resolveAccountIdentity({
|
|
581
|
-
refreshToken,
|
|
582
|
-
email,
|
|
583
|
-
identity: options?.identity,
|
|
584
|
-
label: options?.label,
|
|
585
|
-
source: options?.source ?? "oauth",
|
|
586
|
-
});
|
|
587
|
-
const existing = findMatchingAccount(this.#accounts, {
|
|
588
|
-
identity,
|
|
589
|
-
refreshToken,
|
|
590
|
-
});
|
|
591
|
-
|
|
592
|
-
if (existing) {
|
|
593
|
-
existing.refreshToken = refreshToken;
|
|
594
|
-
existing.access = accessToken;
|
|
595
|
-
existing.expires = expires;
|
|
596
|
-
existing.tokenUpdatedAt = Date.now();
|
|
597
|
-
existing.email = email ?? existing.email;
|
|
598
|
-
existing.identity = identity;
|
|
599
|
-
existing.label = options?.label ?? existing.label;
|
|
600
|
-
existing.source = options?.source ?? existing.source ?? "oauth";
|
|
601
|
-
existing.enabled = true;
|
|
602
|
-
this.requestSaveToDisk();
|
|
603
|
-
return existing;
|
|
473
|
+
/**
|
|
474
|
+
* Remove an account by index.
|
|
475
|
+
*/
|
|
476
|
+
removeAccount(index: number): boolean {
|
|
477
|
+
if (index < 0 || index >= this.#accounts.length) return false;
|
|
478
|
+
|
|
479
|
+
this.#accounts.splice(index, 1);
|
|
480
|
+
|
|
481
|
+
reindexManagedAccounts(this.#accounts);
|
|
482
|
+
|
|
483
|
+
if (this.#accounts.length === 0) {
|
|
484
|
+
this.#currentIndex = -1;
|
|
485
|
+
this.#cursor = 0;
|
|
486
|
+
} else {
|
|
487
|
+
if (this.#currentIndex >= this.#accounts.length) {
|
|
488
|
+
this.#currentIndex = this.#accounts.length - 1;
|
|
489
|
+
}
|
|
490
|
+
if (this.#cursor > 0) {
|
|
491
|
+
this.#cursor = Math.min(this.#cursor, this.#accounts.length);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
this.#rebuildTrackers();
|
|
496
|
+
this.requestSaveToDisk();
|
|
497
|
+
return true;
|
|
604
498
|
}
|
|
605
499
|
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
lastSwitchReason: "initial",
|
|
618
|
-
email,
|
|
619
|
-
identity,
|
|
620
|
-
label: options?.label,
|
|
621
|
-
source: options?.source ?? "oauth",
|
|
622
|
-
});
|
|
623
|
-
|
|
624
|
-
this.#accounts.push(account);
|
|
625
|
-
|
|
626
|
-
if (this.#accounts.length === 1) {
|
|
627
|
-
this.#currentIndex = 0;
|
|
500
|
+
/**
|
|
501
|
+
* Toggle an account's enabled state.
|
|
502
|
+
* @returns New enabled state
|
|
503
|
+
*/
|
|
504
|
+
toggleAccount(index: number): boolean {
|
|
505
|
+
const account = this.#accounts[index];
|
|
506
|
+
if (!account) return false;
|
|
507
|
+
|
|
508
|
+
account.enabled = !account.enabled;
|
|
509
|
+
this.requestSaveToDisk();
|
|
510
|
+
return account.enabled;
|
|
628
511
|
}
|
|
629
512
|
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
removeAccount(index: number): boolean {
|
|
638
|
-
if (index < 0 || index >= this.#accounts.length) return false;
|
|
639
|
-
|
|
640
|
-
this.#accounts.splice(index, 1);
|
|
641
|
-
|
|
642
|
-
reindexAccounts(this.#accounts);
|
|
643
|
-
|
|
644
|
-
if (this.#accounts.length === 0) {
|
|
645
|
-
this.#currentIndex = -1;
|
|
646
|
-
this.#cursor = 0;
|
|
647
|
-
} else {
|
|
648
|
-
if (this.#currentIndex >= this.#accounts.length) {
|
|
649
|
-
this.#currentIndex = this.#accounts.length - 1;
|
|
650
|
-
}
|
|
651
|
-
if (this.#cursor > 0) {
|
|
652
|
-
this.#cursor = Math.min(this.#cursor, this.#accounts.length);
|
|
653
|
-
}
|
|
513
|
+
/**
|
|
514
|
+
* Clear all accounts and reset state.
|
|
515
|
+
*/
|
|
516
|
+
clearAll(): void {
|
|
517
|
+
this.#accounts = [];
|
|
518
|
+
this.#currentIndex = -1;
|
|
519
|
+
this.#cursor = 0;
|
|
654
520
|
}
|
|
655
521
|
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
this.requestSaveToDisk();
|
|
671
|
-
return account.enabled;
|
|
672
|
-
}
|
|
673
|
-
|
|
674
|
-
/**
|
|
675
|
-
* Clear all accounts and reset state.
|
|
676
|
-
*/
|
|
677
|
-
clearAll(): void {
|
|
678
|
-
this.#accounts = [];
|
|
679
|
-
this.#currentIndex = -1;
|
|
680
|
-
this.#cursor = 0;
|
|
681
|
-
}
|
|
682
|
-
|
|
683
|
-
/**
|
|
684
|
-
* Request a debounced save to disk.
|
|
685
|
-
*/
|
|
686
|
-
requestSaveToDisk(): void {
|
|
687
|
-
if (this.#saveTimeout) clearTimeout(this.#saveTimeout);
|
|
688
|
-
this.#saveTimeout = setTimeout(() => {
|
|
689
|
-
this.#saveTimeout = null;
|
|
690
|
-
this.saveToDisk().catch((err) => {
|
|
691
|
-
if (this.#config.debug) {
|
|
692
|
-
// eslint-disable-next-line no-console -- debug-gated stderr logging; plugin has no dedicated logger
|
|
693
|
-
console.error("[opencode-anthropic-auth] saveToDisk failed:", (err as Error).message);
|
|
694
|
-
}
|
|
695
|
-
});
|
|
696
|
-
}, 1000);
|
|
697
|
-
}
|
|
698
|
-
|
|
699
|
-
/**
|
|
700
|
-
* Persist current state to disk immediately.
|
|
701
|
-
* Stats use merge-on-save: read disk values, add this instance's deltas,
|
|
702
|
-
* write merged result.
|
|
703
|
-
*/
|
|
704
|
-
async saveToDisk(): Promise<void> {
|
|
705
|
-
let diskAccountsById: Map<string, AccountMetadata> | null = null;
|
|
706
|
-
let diskAccountsByAddedAt: Map<number, AccountMetadata[]> | null = null;
|
|
707
|
-
let diskAccountsByRefreshToken: Map<string, AccountMetadata> | null = null;
|
|
708
|
-
let diskAccounts: AccountMetadata[] = [];
|
|
709
|
-
try {
|
|
710
|
-
const diskData = await loadAccounts();
|
|
711
|
-
if (diskData) {
|
|
712
|
-
diskAccounts = diskData.accounts;
|
|
713
|
-
diskAccountsById = new Map(diskData.accounts.map((a) => [a.id, a]));
|
|
714
|
-
diskAccountsByAddedAt = new Map();
|
|
715
|
-
diskAccountsByRefreshToken = new Map();
|
|
716
|
-
for (const diskAcc of diskData.accounts) {
|
|
717
|
-
const bucket = diskAccountsByAddedAt.get(diskAcc.addedAt) || [];
|
|
718
|
-
bucket.push(diskAcc);
|
|
719
|
-
diskAccountsByAddedAt.set(diskAcc.addedAt, bucket);
|
|
720
|
-
diskAccountsByRefreshToken.set(diskAcc.refreshToken, diskAcc);
|
|
721
|
-
}
|
|
722
|
-
}
|
|
723
|
-
} catch {
|
|
724
|
-
// If we can't read, fall through to writing absolute values
|
|
522
|
+
/**
|
|
523
|
+
* Request a debounced save to disk.
|
|
524
|
+
*/
|
|
525
|
+
requestSaveToDisk(): void {
|
|
526
|
+
if (this.#saveTimeout) clearTimeout(this.#saveTimeout);
|
|
527
|
+
this.#saveTimeout = setTimeout(() => {
|
|
528
|
+
this.#saveTimeout = null;
|
|
529
|
+
this.saveToDisk().catch((err) => {
|
|
530
|
+
if (this.#config.debug) {
|
|
531
|
+
// eslint-disable-next-line no-console -- debug-gated stderr logging; plugin has no dedicated logger
|
|
532
|
+
console.error("[opencode-anthropic-auth] saveToDisk failed:", (err as Error).message);
|
|
533
|
+
}
|
|
534
|
+
});
|
|
535
|
+
}, 1000);
|
|
725
536
|
}
|
|
726
537
|
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
if (byToken) return byToken;
|
|
739
|
-
|
|
740
|
-
if (byAddedAt && byAddedAt.length > 0) return byAddedAt[0]!;
|
|
741
|
-
return null;
|
|
742
|
-
};
|
|
743
|
-
|
|
744
|
-
const matchedDiskAccounts = new Set<AccountMetadata>();
|
|
745
|
-
const activeAccountId = this.#accounts[this.#currentIndex]?.id ?? null;
|
|
746
|
-
const accountsToPersist = this.#accounts.filter((account) => account.enabled || !!findDiskAccount(account));
|
|
747
|
-
|
|
748
|
-
const persistedAccounts = accountsToPersist.map((acc) => {
|
|
749
|
-
const delta = this.#statsDeltas.get(acc.id);
|
|
750
|
-
let mergedStats = acc.stats;
|
|
751
|
-
const diskAcc = findDiskAccount(acc);
|
|
752
|
-
|
|
753
|
-
if (diskAcc) {
|
|
754
|
-
matchedDiskAccounts.add(diskAcc);
|
|
755
|
-
}
|
|
756
|
-
|
|
757
|
-
if (delta) {
|
|
758
|
-
const diskStats = diskAcc?.stats;
|
|
759
|
-
|
|
760
|
-
if (delta.isReset) {
|
|
761
|
-
mergedStats = {
|
|
762
|
-
requests: delta.requests,
|
|
763
|
-
inputTokens: delta.inputTokens,
|
|
764
|
-
outputTokens: delta.outputTokens,
|
|
765
|
-
cacheReadTokens: delta.cacheReadTokens,
|
|
766
|
-
cacheWriteTokens: delta.cacheWriteTokens,
|
|
767
|
-
lastReset: delta.resetTimestamp ?? acc.stats.lastReset,
|
|
768
|
-
};
|
|
769
|
-
} else if (diskStats) {
|
|
770
|
-
mergedStats = {
|
|
771
|
-
requests: diskStats.requests + delta.requests,
|
|
772
|
-
inputTokens: diskStats.inputTokens + delta.inputTokens,
|
|
773
|
-
outputTokens: diskStats.outputTokens + delta.outputTokens,
|
|
774
|
-
cacheReadTokens: diskStats.cacheReadTokens + delta.cacheReadTokens,
|
|
775
|
-
cacheWriteTokens: diskStats.cacheWriteTokens + delta.cacheWriteTokens,
|
|
776
|
-
lastReset: diskStats.lastReset,
|
|
777
|
-
};
|
|
538
|
+
/**
|
|
539
|
+
* Persist current state to disk immediately.
|
|
540
|
+
* Stats use merge-on-save: read disk values, add this instance's deltas,
|
|
541
|
+
* write merged result.
|
|
542
|
+
*/
|
|
543
|
+
async saveToDisk(): Promise<void> {
|
|
544
|
+
let diskData: AccountStorage | null = null;
|
|
545
|
+
try {
|
|
546
|
+
diskData = await loadAccounts();
|
|
547
|
+
} catch {
|
|
548
|
+
// If we can't read, fall through to writing absolute values
|
|
778
549
|
}
|
|
779
|
-
}
|
|
780
|
-
|
|
781
|
-
const memTokenUpdatedAt = acc.tokenUpdatedAt || 0;
|
|
782
|
-
const diskTokenUpdatedAt = diskAcc?.token_updated_at || 0;
|
|
783
|
-
const freshestAuth =
|
|
784
|
-
diskAcc && diskTokenUpdatedAt > memTokenUpdatedAt
|
|
785
|
-
? {
|
|
786
|
-
refreshToken: diskAcc.refreshToken,
|
|
787
|
-
access: diskAcc.access,
|
|
788
|
-
expires: diskAcc.expires,
|
|
789
|
-
tokenUpdatedAt: diskTokenUpdatedAt,
|
|
790
|
-
}
|
|
791
|
-
: {
|
|
792
|
-
refreshToken: acc.refreshToken,
|
|
793
|
-
access: acc.access,
|
|
794
|
-
expires: acc.expires,
|
|
795
|
-
tokenUpdatedAt: memTokenUpdatedAt,
|
|
796
|
-
};
|
|
797
|
-
|
|
798
|
-
acc.refreshToken = freshestAuth.refreshToken;
|
|
799
|
-
acc.access = freshestAuth.access;
|
|
800
|
-
acc.expires = freshestAuth.expires;
|
|
801
|
-
acc.tokenUpdatedAt = freshestAuth.tokenUpdatedAt;
|
|
802
|
-
|
|
803
|
-
return {
|
|
804
|
-
id: acc.id,
|
|
805
|
-
email: acc.email,
|
|
806
|
-
identity: acc.identity,
|
|
807
|
-
label: acc.label,
|
|
808
|
-
refreshToken: freshestAuth.refreshToken,
|
|
809
|
-
access: freshestAuth.access,
|
|
810
|
-
expires: freshestAuth.expires,
|
|
811
|
-
token_updated_at: freshestAuth.tokenUpdatedAt,
|
|
812
|
-
addedAt: acc.addedAt,
|
|
813
|
-
lastUsed: acc.lastUsed,
|
|
814
|
-
enabled: acc.enabled,
|
|
815
|
-
rateLimitResetTimes: Object.keys(acc.rateLimitResetTimes).length > 0 ? acc.rateLimitResetTimes : {},
|
|
816
|
-
consecutiveFailures: acc.consecutiveFailures,
|
|
817
|
-
lastFailureTime: acc.lastFailureTime,
|
|
818
|
-
lastSwitchReason: acc.lastSwitchReason,
|
|
819
|
-
stats: mergedStats,
|
|
820
|
-
source: acc.source,
|
|
821
|
-
};
|
|
822
|
-
});
|
|
823
|
-
|
|
824
|
-
const diskOnlyAccounts = diskAccounts.filter((account) => !matchedDiskAccounts.has(account));
|
|
825
|
-
const allAccounts = accountsToPersist.length > 0 ? [...persistedAccounts, ...diskOnlyAccounts] : persistedAccounts;
|
|
826
|
-
const resolvedActiveIndex = activeAccountId
|
|
827
|
-
? allAccounts.findIndex((account) => account.id === activeAccountId)
|
|
828
|
-
: -1;
|
|
829
|
-
|
|
830
|
-
const storage: AccountStorage = {
|
|
831
|
-
version: 1,
|
|
832
|
-
accounts: allAccounts,
|
|
833
|
-
activeIndex:
|
|
834
|
-
resolvedActiveIndex >= 0
|
|
835
|
-
? resolvedActiveIndex
|
|
836
|
-
: allAccounts.length > 0
|
|
837
|
-
? Math.max(0, Math.min(this.#currentIndex, allAccounts.length - 1))
|
|
838
|
-
: 0,
|
|
839
|
-
};
|
|
840
|
-
|
|
841
|
-
await saveAccounts(storage);
|
|
842
|
-
|
|
843
|
-
this.#statsDeltas.clear();
|
|
844
|
-
|
|
845
|
-
for (const saved of storage.accounts) {
|
|
846
|
-
const acc = this.#accounts.find((a) => a.id === saved.id);
|
|
847
|
-
if (acc) {
|
|
848
|
-
acc.stats = saved.stats;
|
|
849
|
-
}
|
|
850
|
-
}
|
|
851
|
-
}
|
|
852
|
-
|
|
853
|
-
/**
|
|
854
|
-
* Sync activeIndex from disk (picks up CLI changes while OpenCode is running).
|
|
855
|
-
*/
|
|
856
|
-
async syncActiveIndexFromDisk(): Promise<void> {
|
|
857
|
-
const stored = await loadAccounts();
|
|
858
|
-
if (!stored) return;
|
|
859
|
-
|
|
860
|
-
const matchedAccounts = new Set<ManagedAccount>();
|
|
861
|
-
const reconciledAccounts: ManagedAccount[] = [];
|
|
862
|
-
let structuralChange = false;
|
|
863
|
-
|
|
864
|
-
for (const [index, storedAccount] of stored.accounts.entries()) {
|
|
865
|
-
const existing = findMatchingAccount(this.#accounts, {
|
|
866
|
-
id: storedAccount.id,
|
|
867
|
-
identity: resolveIdentity(storedAccount),
|
|
868
|
-
refreshToken: storedAccount.refreshToken,
|
|
869
|
-
});
|
|
870
|
-
|
|
871
|
-
if (existing) {
|
|
872
|
-
updateManagedAccountFromStorage(existing, storedAccount, index);
|
|
873
|
-
matchedAccounts.add(existing);
|
|
874
|
-
reconciledAccounts.push(existing);
|
|
875
|
-
continue;
|
|
876
|
-
}
|
|
877
|
-
|
|
878
|
-
const addedAccount = createManagedAccount({
|
|
879
|
-
id: storedAccount.id,
|
|
880
|
-
index,
|
|
881
|
-
email: storedAccount.email,
|
|
882
|
-
identity: storedAccount.identity,
|
|
883
|
-
label: storedAccount.label,
|
|
884
|
-
refreshToken: storedAccount.refreshToken,
|
|
885
|
-
access: storedAccount.access,
|
|
886
|
-
expires: storedAccount.expires,
|
|
887
|
-
tokenUpdatedAt: storedAccount.token_updated_at,
|
|
888
|
-
addedAt: storedAccount.addedAt,
|
|
889
|
-
lastUsed: storedAccount.lastUsed,
|
|
890
|
-
enabled: storedAccount.enabled,
|
|
891
|
-
rateLimitResetTimes: storedAccount.rateLimitResetTimes,
|
|
892
|
-
consecutiveFailures: storedAccount.consecutiveFailures,
|
|
893
|
-
lastFailureTime: storedAccount.lastFailureTime,
|
|
894
|
-
lastSwitchReason: storedAccount.lastSwitchReason,
|
|
895
|
-
stats: storedAccount.stats,
|
|
896
|
-
source: storedAccount.source || "oauth",
|
|
897
|
-
});
|
|
898
|
-
matchedAccounts.add(addedAccount);
|
|
899
|
-
reconciledAccounts.push(addedAccount);
|
|
900
|
-
structuralChange = true;
|
|
901
|
-
}
|
|
902
550
|
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
551
|
+
const droppedIdsSnapshot = new Set(this.#pendingDroppedIds);
|
|
552
|
+
const prepared = prepareStorageForSave({
|
|
553
|
+
accounts: this.#accounts,
|
|
554
|
+
currentIndex: this.#currentIndex,
|
|
555
|
+
statsDeltas: this.#statsDeltas,
|
|
556
|
+
diskData,
|
|
557
|
+
droppedIds: droppedIdsSnapshot,
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
await saveAccounts(prepared.storage, { droppedIds: droppedIdsSnapshot });
|
|
907
561
|
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
structuralChange = true;
|
|
911
|
-
}
|
|
562
|
+
this.#statsDeltas.clear();
|
|
563
|
+
this.#pendingDroppedIds.clear();
|
|
912
564
|
|
|
913
|
-
|
|
565
|
+
for (const [id, persistedState] of prepared.persistedStateById.entries()) {
|
|
566
|
+
const account = this.#accounts.find((candidate) => candidate.id === id);
|
|
567
|
+
if (account) {
|
|
568
|
+
account.stats = persistedState.stats;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
914
571
|
}
|
|
915
572
|
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
573
|
+
/**
|
|
574
|
+
* Sync activeIndex from disk (picks up CLI changes while OpenCode is running).
|
|
575
|
+
*/
|
|
576
|
+
async syncActiveIndexFromDisk(): Promise<void> {
|
|
577
|
+
const stored = await loadAccounts();
|
|
578
|
+
if (!stored) return;
|
|
579
|
+
|
|
580
|
+
const reconciled = reconcileManagedAccountsWithStorage({
|
|
581
|
+
accounts: this.#accounts,
|
|
582
|
+
stored,
|
|
583
|
+
currentIndex: this.#currentIndex,
|
|
584
|
+
statsDeltaIds: this.#statsDeltas.keys(),
|
|
585
|
+
});
|
|
919
586
|
|
|
920
|
-
|
|
921
|
-
|
|
587
|
+
this.#accounts = reconciled.accounts;
|
|
588
|
+
reindexManagedAccounts(this.#accounts);
|
|
922
589
|
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
590
|
+
if (reconciled.shouldRebuildTrackers) {
|
|
591
|
+
this.#rebuildTrackers();
|
|
592
|
+
}
|
|
926
593
|
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
this.#statsDeltas.delete(id);
|
|
931
|
-
}
|
|
932
|
-
}
|
|
594
|
+
for (const id of reconciled.staleDeltaIds) {
|
|
595
|
+
this.#statsDeltas.delete(id);
|
|
596
|
+
}
|
|
933
597
|
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
this.#currentIndex = -1;
|
|
937
|
-
this.#cursor = 0;
|
|
938
|
-
return;
|
|
939
|
-
}
|
|
598
|
+
this.#currentIndex = reconciled.currentIndex;
|
|
599
|
+
this.#cursor = reconciled.cursor;
|
|
940
600
|
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
if (!this.#accounts[this.#currentIndex]?.enabled) {
|
|
945
|
-
const fallback = enabledAccounts[0]!;
|
|
946
|
-
this.#currentIndex = fallback.index;
|
|
947
|
-
this.#cursor = fallback.index;
|
|
948
|
-
}
|
|
949
|
-
return;
|
|
601
|
+
if (reconciled.resetHealthTrackerIndex !== null) {
|
|
602
|
+
this.#healthTracker.reset(reconciled.resetHealthTrackerIndex);
|
|
603
|
+
}
|
|
950
604
|
}
|
|
951
605
|
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
606
|
+
/**
|
|
607
|
+
* Record token usage for an account after a successful API response.
|
|
608
|
+
*/
|
|
609
|
+
recordUsage(
|
|
610
|
+
index: number,
|
|
611
|
+
usage: {
|
|
612
|
+
inputTokens?: number;
|
|
613
|
+
outputTokens?: number;
|
|
614
|
+
cacheReadTokens?: number;
|
|
615
|
+
cacheWriteTokens?: number;
|
|
616
|
+
},
|
|
617
|
+
): void {
|
|
618
|
+
const account = this.#accounts[index];
|
|
619
|
+
if (!account) return;
|
|
620
|
+
|
|
621
|
+
const inTok = usage.inputTokens || 0;
|
|
622
|
+
const outTok = usage.outputTokens || 0;
|
|
623
|
+
const crTok = usage.cacheReadTokens || 0;
|
|
624
|
+
const cwTok = usage.cacheWriteTokens || 0;
|
|
625
|
+
|
|
626
|
+
account.stats.requests += 1;
|
|
627
|
+
account.stats.inputTokens += inTok;
|
|
628
|
+
account.stats.outputTokens += outTok;
|
|
629
|
+
account.stats.cacheReadTokens += crTok;
|
|
630
|
+
account.stats.cacheWriteTokens += cwTok;
|
|
631
|
+
|
|
632
|
+
const delta = this.#statsDeltas.get(account.id);
|
|
633
|
+
if (delta) {
|
|
634
|
+
delta.requests += 1;
|
|
635
|
+
delta.inputTokens += inTok;
|
|
636
|
+
delta.outputTokens += outTok;
|
|
637
|
+
delta.cacheReadTokens += crTok;
|
|
638
|
+
delta.cacheWriteTokens += cwTok;
|
|
639
|
+
} else {
|
|
640
|
+
if (this.#statsDeltas.size >= this.#MAX_STATS_DELTAS) {
|
|
641
|
+
this.saveToDisk().catch((err) => {
|
|
642
|
+
if (this.#config.debug) {
|
|
643
|
+
// eslint-disable-next-line no-console -- debug-gated stderr logging; plugin has no dedicated logger
|
|
644
|
+
console.error(
|
|
645
|
+
"[opencode-anthropic-auth] forced statsDeltas flush failed:",
|
|
646
|
+
(err as Error).message,
|
|
647
|
+
);
|
|
648
|
+
}
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
this.#statsDeltas.set(account.id, {
|
|
652
|
+
requests: 1,
|
|
653
|
+
inputTokens: inTok,
|
|
654
|
+
outputTokens: outTok,
|
|
655
|
+
cacheReadTokens: crTok,
|
|
656
|
+
cacheWriteTokens: cwTok,
|
|
657
|
+
isReset: false,
|
|
658
|
+
});
|
|
659
|
+
}
|
|
957
660
|
|
|
958
|
-
|
|
959
|
-
this.#currentIndex = activeAccount.index;
|
|
960
|
-
this.#cursor = activeAccount.index;
|
|
961
|
-
this.#healthTracker.reset(activeAccount.index);
|
|
661
|
+
this.requestSaveToDisk();
|
|
962
662
|
}
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
delta.requests += 1;
|
|
994
|
-
delta.inputTokens += inTok;
|
|
995
|
-
delta.outputTokens += outTok;
|
|
996
|
-
delta.cacheReadTokens += crTok;
|
|
997
|
-
delta.cacheWriteTokens += cwTok;
|
|
998
|
-
} else {
|
|
999
|
-
if (this.#statsDeltas.size >= this.#MAX_STATS_DELTAS) {
|
|
1000
|
-
this.saveToDisk().catch((err) => {
|
|
1001
|
-
if (this.#config.debug) {
|
|
1002
|
-
// eslint-disable-next-line no-console -- debug-gated stderr logging; plugin has no dedicated logger
|
|
1003
|
-
console.error("[opencode-anthropic-auth] forced statsDeltas flush failed:", (err as Error).message);
|
|
1004
|
-
}
|
|
1005
|
-
});
|
|
1006
|
-
}
|
|
1007
|
-
this.#statsDeltas.set(account.id, {
|
|
1008
|
-
requests: 1,
|
|
1009
|
-
inputTokens: inTok,
|
|
1010
|
-
outputTokens: outTok,
|
|
1011
|
-
cacheReadTokens: crTok,
|
|
1012
|
-
cacheWriteTokens: cwTok,
|
|
1013
|
-
isReset: false,
|
|
1014
|
-
});
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* Reset stats for a specific account or all accounts.
|
|
666
|
+
*/
|
|
667
|
+
resetStats(target: number | "all"): void {
|
|
668
|
+
const now = Date.now();
|
|
669
|
+
const resetAccount = (acc: ManagedAccount) => {
|
|
670
|
+
acc.stats = createDefaultStats(now);
|
|
671
|
+
this.#statsDeltas.set(acc.id, {
|
|
672
|
+
requests: 0,
|
|
673
|
+
inputTokens: 0,
|
|
674
|
+
outputTokens: 0,
|
|
675
|
+
cacheReadTokens: 0,
|
|
676
|
+
cacheWriteTokens: 0,
|
|
677
|
+
isReset: true,
|
|
678
|
+
resetTimestamp: now,
|
|
679
|
+
});
|
|
680
|
+
};
|
|
681
|
+
|
|
682
|
+
if (target === "all") {
|
|
683
|
+
for (const acc of this.#accounts) {
|
|
684
|
+
resetAccount(acc);
|
|
685
|
+
}
|
|
686
|
+
} else {
|
|
687
|
+
const account = this.#accounts[target];
|
|
688
|
+
if (account) {
|
|
689
|
+
resetAccount(account);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
this.requestSaveToDisk();
|
|
1015
693
|
}
|
|
1016
694
|
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
cacheWriteTokens: 0,
|
|
1033
|
-
isReset: true,
|
|
1034
|
-
resetTimestamp: now,
|
|
1035
|
-
});
|
|
1036
|
-
};
|
|
1037
|
-
|
|
1038
|
-
if (target === "all") {
|
|
1039
|
-
for (const acc of this.#accounts) {
|
|
1040
|
-
resetAccount(acc);
|
|
1041
|
-
}
|
|
1042
|
-
} else {
|
|
1043
|
-
const account = this.#accounts[target];
|
|
1044
|
-
if (account) {
|
|
1045
|
-
resetAccount(account);
|
|
1046
|
-
}
|
|
695
|
+
/**
|
|
696
|
+
* Convert a managed account to the format expected by OpenCode's auth.json.
|
|
697
|
+
*/
|
|
698
|
+
toAuthDetails(account: ManagedAccount): {
|
|
699
|
+
type: "oauth";
|
|
700
|
+
refresh: string;
|
|
701
|
+
access: string | undefined;
|
|
702
|
+
expires: number | undefined;
|
|
703
|
+
} {
|
|
704
|
+
return {
|
|
705
|
+
type: "oauth",
|
|
706
|
+
refresh: account.refreshToken,
|
|
707
|
+
access: account.access,
|
|
708
|
+
expires: account.expires,
|
|
709
|
+
};
|
|
1047
710
|
}
|
|
1048
|
-
this.requestSaveToDisk();
|
|
1049
|
-
}
|
|
1050
|
-
|
|
1051
|
-
/**
|
|
1052
|
-
* Convert a managed account to the format expected by OpenCode's auth.json.
|
|
1053
|
-
*/
|
|
1054
|
-
toAuthDetails(account: ManagedAccount): {
|
|
1055
|
-
type: "oauth";
|
|
1056
|
-
refresh: string;
|
|
1057
|
-
access: string | undefined;
|
|
1058
|
-
expires: number | undefined;
|
|
1059
|
-
} {
|
|
1060
|
-
return {
|
|
1061
|
-
type: "oauth",
|
|
1062
|
-
refresh: account.refreshToken,
|
|
1063
|
-
access: account.access,
|
|
1064
|
-
expires: account.expires,
|
|
1065
|
-
};
|
|
1066
|
-
}
|
|
1067
711
|
}
|