opencode-anthropic-fix 0.0.14

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/lib/oauth.mjs ADDED
@@ -0,0 +1,103 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { CLIENT_ID } from "./config.mjs";
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // OAuth helpers — shared between plugin (index.mjs) and CLI (cli.mjs)
6
+ // ---------------------------------------------------------------------------
7
+
8
+ function base64url(input) {
9
+ return Buffer.from(input).toString("base64url");
10
+ }
11
+
12
+ function generatePKCE() {
13
+ const verifier = base64url(randomBytes(32));
14
+ const challenge = base64url(createHash("sha256").update(verifier).digest());
15
+ return { verifier, challenge };
16
+ }
17
+
18
+ /**
19
+ * Build an OAuth authorization URL with PKCE challenge.
20
+ * @param {"max" | "console"} mode
21
+ * @returns {Promise<{url: string, verifier: string}>}
22
+ */
23
+ export async function authorize(mode) {
24
+ const pkce = generatePKCE();
25
+
26
+ const url = new URL(`https://${mode === "console" ? "console.anthropic.com" : "claude.ai"}/oauth/authorize`);
27
+ url.searchParams.set("code", "true");
28
+ url.searchParams.set("client_id", CLIENT_ID);
29
+ url.searchParams.set("response_type", "code");
30
+ url.searchParams.set("redirect_uri", "https://console.anthropic.com/oauth/code/callback");
31
+ url.searchParams.set("scope", "org:create_api_key user:profile user:inference");
32
+ url.searchParams.set("code_challenge", pkce.challenge);
33
+ url.searchParams.set("code_challenge_method", "S256");
34
+ url.searchParams.set("state", pkce.verifier);
35
+ return {
36
+ url: url.toString(),
37
+ verifier: pkce.verifier,
38
+ };
39
+ }
40
+
41
+ /**
42
+ * Exchange an authorization code for tokens.
43
+ * @param {string} code
44
+ * @param {string} verifier
45
+ * @returns {Promise<{type: "success", refresh: string, access: string, expires: number, email?: string} | {type: "failed"}>}
46
+ */
47
+ export async function exchange(code, verifier) {
48
+ const splits = code.split("#");
49
+ const result = await fetch("https://console.anthropic.com/v1/oauth/token", {
50
+ method: "POST",
51
+ headers: {
52
+ "Content-Type": "application/json",
53
+ },
54
+ body: JSON.stringify({
55
+ code: splits[0],
56
+ state: splits[1],
57
+ grant_type: "authorization_code",
58
+ client_id: CLIENT_ID,
59
+ redirect_uri: "https://console.anthropic.com/oauth/code/callback",
60
+ code_verifier: verifier,
61
+ }),
62
+ });
63
+ if (!result.ok)
64
+ return {
65
+ type: "failed",
66
+ };
67
+ const json = await result.json();
68
+ return {
69
+ type: "success",
70
+ refresh: json.refresh_token,
71
+ access: json.access_token,
72
+ expires: Date.now() + json.expires_in * 1000,
73
+ email: json.account?.email_address || undefined,
74
+ };
75
+ }
76
+
77
+ /**
78
+ * Attempt to revoke a refresh token server-side (best-effort, RFC 7009).
79
+ *
80
+ * Anthropic may or may not support this endpoint. The function returns
81
+ * `true` on a 2xx response and `false` otherwise — callers should always
82
+ * proceed with local cleanup regardless of the result.
83
+ *
84
+ * @param {string} refreshToken
85
+ * @returns {Promise<boolean>}
86
+ */
87
+ export async function revoke(refreshToken) {
88
+ try {
89
+ const resp = await fetch("https://console.anthropic.com/v1/oauth/revoke", {
90
+ method: "POST",
91
+ headers: { "Content-Type": "application/json" },
92
+ body: JSON.stringify({
93
+ token: refreshToken,
94
+ token_type_hint: "refresh_token",
95
+ client_id: CLIENT_ID,
96
+ }),
97
+ signal: AbortSignal.timeout(5000),
98
+ });
99
+ return resp.ok;
100
+ } catch {
101
+ return false;
102
+ }
103
+ }
@@ -0,0 +1,287 @@
1
+ import { DEFAULT_CONFIG } from "./config.mjs";
2
+
3
+ /**
4
+ * @typedef {import('./config.mjs').HealthScoreConfig} HealthScoreConfig
5
+ * @typedef {import('./config.mjs').TokenBucketConfig} TokenBucketConfig
6
+ * @typedef {import('./config.mjs').AccountSelectionStrategy} AccountSelectionStrategy
7
+ */
8
+
9
+ /**
10
+ * @typedef {object} AccountCandidate
11
+ * @property {number} index
12
+ * @property {number} lastUsed
13
+ * @property {number} healthScore
14
+ * @property {boolean} isRateLimited
15
+ * @property {boolean} enabled
16
+ */
17
+
18
+ // --- Health Score Tracker ---
19
+
20
+ export class HealthScoreTracker {
21
+ /** @type {Map<number, {score: number, lastUpdated: number, consecutiveFailures: number}>} */
22
+ #scores = new Map();
23
+ /** @type {HealthScoreConfig} */
24
+ #config;
25
+
26
+ /**
27
+ * @param {Partial<HealthScoreConfig>} [config]
28
+ */
29
+ constructor(config = {}) {
30
+ this.#config = { ...DEFAULT_CONFIG.health_score, ...config };
31
+ }
32
+
33
+ /**
34
+ * Get the current health score for an account, including passive recovery.
35
+ * @param {number} accountIndex
36
+ * @returns {number}
37
+ */
38
+ getScore(accountIndex) {
39
+ const state = this.#scores.get(accountIndex);
40
+ if (!state) return this.#config.initial;
41
+
42
+ const hoursSinceUpdate = (Date.now() - state.lastUpdated) / (1000 * 60 * 60);
43
+ const recoveredPoints = Math.floor(hoursSinceUpdate * this.#config.recovery_rate_per_hour);
44
+
45
+ return Math.min(this.#config.max_score, state.score + recoveredPoints);
46
+ }
47
+
48
+ /**
49
+ * Record a successful request.
50
+ * @param {number} accountIndex
51
+ */
52
+ recordSuccess(accountIndex) {
53
+ const current = this.getScore(accountIndex);
54
+ this.#scores.set(accountIndex, {
55
+ score: Math.min(this.#config.max_score, current + this.#config.success_reward),
56
+ lastUpdated: Date.now(),
57
+ consecutiveFailures: 0,
58
+ });
59
+ }
60
+
61
+ /**
62
+ * Record a rate limit event.
63
+ * @param {number} accountIndex
64
+ */
65
+ recordRateLimit(accountIndex) {
66
+ const current = this.getScore(accountIndex);
67
+ const state = this.#scores.get(accountIndex);
68
+ this.#scores.set(accountIndex, {
69
+ score: Math.max(0, current + this.#config.rate_limit_penalty),
70
+ lastUpdated: Date.now(),
71
+ consecutiveFailures: (state?.consecutiveFailures ?? 0) + 1,
72
+ });
73
+ }
74
+
75
+ /**
76
+ * Record a general failure.
77
+ * @param {number} accountIndex
78
+ */
79
+ recordFailure(accountIndex) {
80
+ const current = this.getScore(accountIndex);
81
+ const state = this.#scores.get(accountIndex);
82
+ this.#scores.set(accountIndex, {
83
+ score: Math.max(0, current + this.#config.failure_penalty),
84
+ lastUpdated: Date.now(),
85
+ consecutiveFailures: (state?.consecutiveFailures ?? 0) + 1,
86
+ });
87
+ }
88
+
89
+ /**
90
+ * Check if an account is usable (score above minimum).
91
+ * @param {number} accountIndex
92
+ * @returns {boolean}
93
+ */
94
+ isUsable(accountIndex) {
95
+ return this.getScore(accountIndex) >= this.#config.min_usable;
96
+ }
97
+
98
+ /**
99
+ * Reset tracking for an account.
100
+ * @param {number} accountIndex
101
+ */
102
+ reset(accountIndex) {
103
+ this.#scores.delete(accountIndex);
104
+ }
105
+ }
106
+
107
+ // --- Token Bucket Tracker ---
108
+
109
+ export class TokenBucketTracker {
110
+ /** @type {Map<number, {tokens: number, lastUpdated: number}>} */
111
+ #buckets = new Map();
112
+ /** @type {TokenBucketConfig} */
113
+ #config;
114
+
115
+ /**
116
+ * @param {Partial<TokenBucketConfig>} [config]
117
+ */
118
+ constructor(config = {}) {
119
+ this.#config = { ...DEFAULT_CONFIG.token_bucket, ...config };
120
+ }
121
+
122
+ /**
123
+ * Get current token count for an account, including regeneration.
124
+ * @param {number} accountIndex
125
+ * @returns {number}
126
+ */
127
+ getTokens(accountIndex) {
128
+ const state = this.#buckets.get(accountIndex);
129
+ if (!state) return this.#config.initial_tokens;
130
+
131
+ const minutesSinceUpdate = (Date.now() - state.lastUpdated) / (1000 * 60);
132
+ const recoveredTokens = minutesSinceUpdate * this.#config.regeneration_rate_per_minute;
133
+
134
+ return Math.min(this.#config.max_tokens, state.tokens + recoveredTokens);
135
+ }
136
+
137
+ /**
138
+ * Check if an account has enough tokens.
139
+ * @param {number} accountIndex
140
+ * @param {number} [cost=1]
141
+ * @returns {boolean}
142
+ */
143
+ hasTokens(accountIndex, cost = 1) {
144
+ return this.getTokens(accountIndex) >= cost;
145
+ }
146
+
147
+ /**
148
+ * Consume tokens for a request.
149
+ * @param {number} accountIndex
150
+ * @param {number} [cost=1]
151
+ * @returns {boolean} Whether tokens were available and consumed
152
+ */
153
+ consume(accountIndex, cost = 1) {
154
+ const current = this.getTokens(accountIndex);
155
+ if (current < cost) return false;
156
+
157
+ this.#buckets.set(accountIndex, {
158
+ tokens: current - cost,
159
+ lastUpdated: Date.now(),
160
+ });
161
+ return true;
162
+ }
163
+
164
+ /**
165
+ * Refund tokens (e.g., on non-rate-limit failure).
166
+ * @param {number} accountIndex
167
+ * @param {number} [amount=1]
168
+ */
169
+ refund(accountIndex, amount = 1) {
170
+ const current = this.getTokens(accountIndex);
171
+ this.#buckets.set(accountIndex, {
172
+ tokens: Math.min(this.#config.max_tokens, current + amount),
173
+ lastUpdated: Date.now(),
174
+ });
175
+ }
176
+
177
+ /**
178
+ * Get the max tokens value (for scoring calculations).
179
+ * @returns {number}
180
+ */
181
+ getMaxTokens() {
182
+ return this.#config.max_tokens;
183
+ }
184
+ }
185
+
186
+ // --- Selection Algorithms ---
187
+
188
+ const STICKINESS_BONUS = 150;
189
+ const SWITCH_THRESHOLD = 100;
190
+
191
+ /**
192
+ * Calculate hybrid score for an account.
193
+ * @param {AccountCandidate & {tokens: number}} account
194
+ * @param {number} maxTokens
195
+ * @returns {number}
196
+ */
197
+ function calculateHybridScore(account, maxTokens) {
198
+ const healthComponent = account.healthScore * 2;
199
+ const tokenComponent = (account.tokens / maxTokens) * 100 * 5;
200
+ const secondsSinceUsed = (Date.now() - account.lastUsed) / 1000;
201
+ const freshnessComponent = Math.min(secondsSinceUsed, 3600) * 0.1;
202
+
203
+ return Math.max(0, healthComponent + tokenComponent + freshnessComponent);
204
+ }
205
+
206
+ /**
207
+ * Select the best account based on the configured strategy.
208
+ *
209
+ * @param {AccountCandidate[]} candidates - All enabled, non-rate-limited accounts
210
+ * @param {AccountSelectionStrategy} strategy
211
+ * @param {number | null} currentIndex - Currently active account index
212
+ * @param {HealthScoreTracker} healthTracker
213
+ * @param {TokenBucketTracker} tokenTracker
214
+ * @param {number} cursor - Round-robin cursor position
215
+ * @returns {{index: number, cursor: number} | null}
216
+ */
217
+ export function selectAccount(candidates, strategy, currentIndex, healthTracker, tokenTracker, cursor) {
218
+ const available = candidates.filter((acc) => acc.enabled && !acc.isRateLimited);
219
+
220
+ if (available.length === 0) return null;
221
+
222
+ switch (strategy) {
223
+ case "sticky": {
224
+ // Use current if available. Sticky should not switch based on health score.
225
+ if (currentIndex !== null) {
226
+ const current = available.find((acc) => acc.index === currentIndex);
227
+ if (current) {
228
+ return { index: current.index, cursor };
229
+ }
230
+ }
231
+ // Fall through to next available
232
+ const next = available[cursor % available.length];
233
+ return next ? { index: next.index, cursor: cursor + 1 } : null;
234
+ }
235
+
236
+ case "round-robin": {
237
+ const next = available[cursor % available.length];
238
+ return next ? { index: next.index, cursor: cursor + 1 } : null;
239
+ }
240
+
241
+ case "hybrid": {
242
+ const scoredCandidates = available
243
+ .filter((acc) => healthTracker.isUsable(acc.index) && tokenTracker.hasTokens(acc.index))
244
+ .map((acc) => ({
245
+ ...acc,
246
+ tokens: tokenTracker.getTokens(acc.index),
247
+ }));
248
+
249
+ if (scoredCandidates.length === 0) {
250
+ // Fall back to any available account
251
+ const fallback = available[0];
252
+ return fallback ? { index: fallback.index, cursor } : null;
253
+ }
254
+
255
+ const maxTokens = tokenTracker.getMaxTokens();
256
+ const scored = scoredCandidates
257
+ .map((acc) => {
258
+ const baseScore = calculateHybridScore(acc, maxTokens);
259
+ const stickinessBonus = acc.index === currentIndex ? STICKINESS_BONUS : 0;
260
+ return {
261
+ index: acc.index,
262
+ baseScore,
263
+ score: baseScore + stickinessBonus,
264
+ isCurrent: acc.index === currentIndex,
265
+ };
266
+ })
267
+ .sort((a, b) => b.score - a.score);
268
+
269
+ const best = scored[0];
270
+ if (!best) return null;
271
+
272
+ // Only switch if the advantage is significant
273
+ const currentCandidate = scored.find((s) => s.isCurrent);
274
+ if (currentCandidate && !best.isCurrent) {
275
+ const advantage = best.baseScore - currentCandidate.baseScore;
276
+ if (advantage < SWITCH_THRESHOLD) {
277
+ return { index: currentCandidate.index, cursor };
278
+ }
279
+ }
280
+
281
+ return { index: best.index, cursor };
282
+ }
283
+
284
+ default:
285
+ return available[0] ? { index: available[0].index, cursor } : null;
286
+ }
287
+ }