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/.github/workflows/publish.yml +31 -0
- package/.husky/pre-commit +2 -0
- package/.husky/pre-push +3 -0
- package/.planning/CLI-ENHANCEMENTS.md +221 -0
- package/.planning/codebase/ARCHITECTURE.md +201 -0
- package/.planning/codebase/CONCERNS.md +219 -0
- package/.planning/codebase/CONVENTIONS.md +335 -0
- package/.planning/codebase/INTEGRATIONS.md +215 -0
- package/.planning/codebase/STACK.md +160 -0
- package/.planning/codebase/STRUCTURE.md +244 -0
- package/.planning/codebase/TESTING.md +456 -0
- package/.prettierignore +3 -0
- package/.prettierrc +10 -0
- package/CONTRIBUTING.md +525 -0
- package/LICENSE +21 -0
- package/README.md +478 -0
- package/agents.md +22 -0
- package/bun.lock +43 -0
- package/cli.mjs +1773 -0
- package/cli.test.mjs +1797 -0
- package/docs/mimese-http-header-system-prompt.md +326 -0
- package/docs/plans/afcw-upstream-watcher.json +495 -0
- package/eslint.config.mjs +69 -0
- package/index.mjs +2209 -0
- package/index.test.mjs +2690 -0
- package/lib/accounts.mjs +670 -0
- package/lib/accounts.test.mjs +782 -0
- package/lib/backoff.mjs +217 -0
- package/lib/backoff.test.mjs +296 -0
- package/lib/config.mjs +366 -0
- package/lib/config.test.mjs +307 -0
- package/lib/oauth.mjs +103 -0
- package/lib/rotation.mjs +287 -0
- package/lib/rotation.test.mjs +346 -0
- package/lib/storage.mjs +265 -0
- package/lib/storage.test.mjs +470 -0
- package/nul +1 -0
- package/package.json +40 -0
- package/script/publish.ts +29 -0
- package/scripts/build.mjs +34 -0
- package/scripts/install.mjs +319 -0
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
|
+
}
|
package/lib/rotation.mjs
ADDED
|
@@ -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
|
+
}
|