@vacbo/opencode-anthropic-fix 0.0.44 → 0.1.1
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 +19 -0
- package/dist/bun-proxy.mjs +282 -55
- package/dist/opencode-anthropic-auth-cli.mjs +194 -55
- package/dist/opencode-anthropic-auth-plugin.js +1816 -594
- package/package.json +1 -1
- package/src/__tests__/billing-edge-cases.test.ts +84 -0
- package/src/__tests__/bun-proxy.parallel.test.ts +460 -0
- package/src/__tests__/debug-gating.test.ts +76 -0
- package/src/__tests__/decomposition-smoke.test.ts +92 -0
- package/src/__tests__/fingerprint-regression.test.ts +1 -1
- package/src/__tests__/helpers/conversation-history.smoke.test.ts +338 -0
- package/src/__tests__/helpers/conversation-history.ts +376 -0
- package/src/__tests__/helpers/deferred.smoke.test.ts +161 -0
- package/src/__tests__/helpers/deferred.ts +122 -0
- package/src/__tests__/helpers/in-memory-storage.smoke.test.ts +166 -0
- package/src/__tests__/helpers/in-memory-storage.ts +152 -0
- package/src/__tests__/helpers/mock-bun-proxy.smoke.test.ts +92 -0
- package/src/__tests__/helpers/mock-bun-proxy.ts +229 -0
- package/src/__tests__/helpers/plugin-fetch-harness.smoke.test.ts +337 -0
- package/src/__tests__/helpers/plugin-fetch-harness.ts +401 -0
- package/src/__tests__/helpers/sse.smoke.test.ts +243 -0
- package/src/__tests__/helpers/sse.ts +288 -0
- package/src/__tests__/index.parallel.test.ts +711 -0
- package/src/__tests__/sanitization-regex.test.ts +65 -0
- package/src/__tests__/state-bounds.test.ts +110 -0
- package/src/account-identity.test.ts +213 -0
- package/src/account-identity.ts +108 -0
- package/src/accounts.dedup.test.ts +696 -0
- package/src/accounts.test.ts +2 -1
- package/src/accounts.ts +485 -191
- package/src/bun-fetch.test.ts +379 -0
- package/src/bun-fetch.ts +447 -174
- package/src/bun-proxy.ts +289 -57
- package/src/circuit-breaker.test.ts +274 -0
- package/src/circuit-breaker.ts +235 -0
- package/src/cli.test.ts +1 -0
- package/src/cli.ts +37 -18
- package/src/commands/router.ts +25 -5
- package/src/env.ts +1 -0
- package/src/headers/billing.ts +31 -13
- package/src/index.ts +224 -247
- package/src/oauth.ts +7 -1
- package/src/parent-pid-watcher.test.ts +219 -0
- package/src/parent-pid-watcher.ts +99 -0
- package/src/plugin-helpers.ts +112 -0
- package/src/refresh-helpers.ts +169 -0
- package/src/refresh-lock.test.ts +36 -9
- package/src/refresh-lock.ts +2 -2
- package/src/request/body.history.test.ts +398 -0
- package/src/request/body.ts +200 -13
- package/src/request/metadata.ts +6 -2
- package/src/response/index.ts +1 -1
- package/src/response/mcp.ts +60 -31
- package/src/response/streaming.test.ts +382 -0
- package/src/response/streaming.ts +403 -76
- package/src/storage.test.ts +127 -104
- package/src/storage.ts +152 -62
- package/src/system-prompt/builder.ts +33 -3
- package/src/system-prompt/sanitize.ts +12 -2
- package/src/token-refresh.test.ts +84 -1
- package/src/token-refresh.ts +14 -8
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
export enum CircuitState {
|
|
2
|
+
CLOSED = "CLOSED",
|
|
3
|
+
OPEN = "OPEN",
|
|
4
|
+
HALF_OPEN = "HALF_OPEN",
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export const BreakerState = CircuitState;
|
|
8
|
+
|
|
9
|
+
export interface CircuitBreakerOptions {
|
|
10
|
+
clientId?: string;
|
|
11
|
+
failureThreshold?: number;
|
|
12
|
+
resetTimeoutMs?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface CircuitBreakerConfig {
|
|
16
|
+
clientId?: string;
|
|
17
|
+
failureThreshold: number;
|
|
18
|
+
resetTimeoutMs: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface CircuitBreakerSuccess<T> {
|
|
22
|
+
success: true;
|
|
23
|
+
data: T;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface CircuitBreakerFailure {
|
|
27
|
+
success: false;
|
|
28
|
+
error: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type CircuitBreakerResult<T> = CircuitBreakerSuccess<T> | CircuitBreakerFailure;
|
|
32
|
+
|
|
33
|
+
const DEFAULT_FAILURE_THRESHOLD = 5;
|
|
34
|
+
const DEFAULT_RESET_TIMEOUT_MS = 30_000;
|
|
35
|
+
|
|
36
|
+
const pendingClientBreakers = new Map<string, CircuitBreaker>();
|
|
37
|
+
|
|
38
|
+
function normalizeConfig(options: CircuitBreakerOptions = {}): CircuitBreakerConfig {
|
|
39
|
+
return {
|
|
40
|
+
clientId: options.clientId,
|
|
41
|
+
failureThreshold: Math.max(1, Math.trunc(options.failureThreshold ?? DEFAULT_FAILURE_THRESHOLD)),
|
|
42
|
+
resetTimeoutMs: Math.max(0, Math.trunc(options.resetTimeoutMs ?? DEFAULT_RESET_TIMEOUT_MS)),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function getErrorMessage(error: unknown): string {
|
|
47
|
+
if (error instanceof Error && error.message) {
|
|
48
|
+
return error.message;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (typeof error === "string" && error) {
|
|
52
|
+
return error;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return "Unknown error";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function isPromiseLike<T>(value: T | Promise<T>): value is Promise<T> {
|
|
59
|
+
return typeof value === "object" && value !== null && "then" in value;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function releasePendingClientBreaker(clientId: string, breaker: CircuitBreaker): void {
|
|
63
|
+
queueMicrotask(() => {
|
|
64
|
+
if (pendingClientBreakers.get(clientId) === breaker) {
|
|
65
|
+
pendingClientBreakers.delete(clientId);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export class CircuitBreaker {
|
|
71
|
+
private readonly config: CircuitBreakerConfig;
|
|
72
|
+
|
|
73
|
+
private state = CircuitState.CLOSED;
|
|
74
|
+
private failureCount = 0;
|
|
75
|
+
private openedAt: number | null = null;
|
|
76
|
+
private resetTimer: ReturnType<typeof setTimeout> | null = null;
|
|
77
|
+
|
|
78
|
+
constructor(options: CircuitBreakerOptions = {}) {
|
|
79
|
+
this.config = normalizeConfig(options);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
getState(): CircuitState {
|
|
83
|
+
return this.state;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
getFailureCount(): number {
|
|
87
|
+
return this.failureCount;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
getOpenedAt(): number | null {
|
|
91
|
+
return this.openedAt;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
getConfig(): CircuitBreakerConfig {
|
|
95
|
+
return { ...this.config };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
canExecute(): boolean {
|
|
99
|
+
return this.state !== CircuitState.OPEN;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
recordSuccess(): void {
|
|
103
|
+
if (this.state === CircuitState.HALF_OPEN) {
|
|
104
|
+
this.transitionToClosed();
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (this.state === CircuitState.CLOSED) {
|
|
109
|
+
this.failureCount = 0;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
recordFailure(): void {
|
|
114
|
+
if (this.state === CircuitState.HALF_OPEN) {
|
|
115
|
+
this.transitionToOpen();
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (this.state === CircuitState.OPEN) {
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
this.failureCount += 1;
|
|
124
|
+
|
|
125
|
+
if (this.failureCount >= this.config.failureThreshold) {
|
|
126
|
+
this.transitionToOpen();
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
transitionToHalfOpen(): void {
|
|
131
|
+
this.clearResetTimer();
|
|
132
|
+
this.state = CircuitState.HALF_OPEN;
|
|
133
|
+
this.openedAt = null;
|
|
134
|
+
this.failureCount = 0;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
execute<T>(
|
|
138
|
+
operation: () => T | Promise<T>,
|
|
139
|
+
): CircuitBreakerResult<Awaited<T>> | Promise<CircuitBreakerResult<Awaited<T>>> {
|
|
140
|
+
if (!this.canExecute()) {
|
|
141
|
+
return {
|
|
142
|
+
success: false,
|
|
143
|
+
error: "Circuit breaker is OPEN",
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
const result = operation();
|
|
149
|
+
|
|
150
|
+
if (isPromiseLike(result)) {
|
|
151
|
+
return result
|
|
152
|
+
.then((data) => {
|
|
153
|
+
this.recordSuccess();
|
|
154
|
+
return {
|
|
155
|
+
success: true,
|
|
156
|
+
data: data as Awaited<T>,
|
|
157
|
+
} satisfies CircuitBreakerSuccess<Awaited<T>>;
|
|
158
|
+
})
|
|
159
|
+
.catch((error: unknown) => {
|
|
160
|
+
this.recordFailure();
|
|
161
|
+
return {
|
|
162
|
+
success: false,
|
|
163
|
+
error: getErrorMessage(error),
|
|
164
|
+
} satisfies CircuitBreakerFailure;
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
this.recordSuccess();
|
|
169
|
+
return {
|
|
170
|
+
success: true,
|
|
171
|
+
data: result as Awaited<T>,
|
|
172
|
+
};
|
|
173
|
+
} catch (error) {
|
|
174
|
+
this.recordFailure();
|
|
175
|
+
return {
|
|
176
|
+
success: false,
|
|
177
|
+
error: getErrorMessage(error),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
dispose(): void {
|
|
183
|
+
this.clearResetTimer();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
private transitionToClosed(): void {
|
|
187
|
+
this.clearResetTimer();
|
|
188
|
+
this.state = CircuitState.CLOSED;
|
|
189
|
+
this.failureCount = 0;
|
|
190
|
+
this.openedAt = null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
private transitionToOpen(): void {
|
|
194
|
+
this.clearResetTimer();
|
|
195
|
+
this.state = CircuitState.OPEN;
|
|
196
|
+
this.openedAt = Date.now();
|
|
197
|
+
this.scheduleHalfOpenTransition();
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private scheduleHalfOpenTransition(): void {
|
|
201
|
+
this.resetTimer = setTimeout(() => {
|
|
202
|
+
this.resetTimer = null;
|
|
203
|
+
if (this.state === CircuitState.OPEN) {
|
|
204
|
+
this.transitionToHalfOpen();
|
|
205
|
+
}
|
|
206
|
+
}, this.config.resetTimeoutMs);
|
|
207
|
+
|
|
208
|
+
this.resetTimer.unref?.();
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
private clearResetTimer(): void {
|
|
212
|
+
if (!this.resetTimer) {
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
clearTimeout(this.resetTimer);
|
|
217
|
+
this.resetTimer = null;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function createCircuitBreaker(options: CircuitBreakerOptions = {}): CircuitBreaker {
|
|
222
|
+
if (!options.clientId) {
|
|
223
|
+
return new CircuitBreaker(options);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const existingBreaker = pendingClientBreakers.get(options.clientId);
|
|
227
|
+
if (existingBreaker) {
|
|
228
|
+
return existingBreaker;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const breaker = new CircuitBreaker(options);
|
|
232
|
+
pendingClientBreakers.set(options.clientId, breaker);
|
|
233
|
+
releasePendingClientBreaker(options.clientId, breaker);
|
|
234
|
+
return breaker;
|
|
235
|
+
}
|
package/src/cli.test.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import "../cli.test.ts";
|
package/src/cli.ts
CHANGED
|
@@ -52,6 +52,7 @@
|
|
|
52
52
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
53
53
|
import { exec } from "node:child_process";
|
|
54
54
|
import { pathToFileURL } from "node:url";
|
|
55
|
+
import { findByIdentity, resolveIdentityFromOAuthExchange } from "./account-identity.js";
|
|
55
56
|
import { CLIENT_ID, getConfigPath, loadConfig, saveConfig, VALID_STRATEGIES } from "./config.js";
|
|
56
57
|
import { authorize, exchange, revoke } from "./oauth.js";
|
|
57
58
|
import { createDefaultStats, getStoragePath, loadAccounts, saveAccounts } from "./storage.js";
|
|
@@ -129,7 +130,7 @@ function shortPath(p: string) {
|
|
|
129
130
|
* @returns {string}
|
|
130
131
|
*/
|
|
131
132
|
function stripAnsi(str: string) {
|
|
132
|
-
// eslint-disable-next-line no-control-regex
|
|
133
|
+
// eslint-disable-next-line no-control-regex -- ANSI escape sequences start with \x1b which is a control char
|
|
133
134
|
return str.replace(new RegExp("\x1b\\[[0-9;]*m", "g"), "");
|
|
134
135
|
}
|
|
135
136
|
|
|
@@ -165,6 +166,7 @@ function rpad(str: string, width: number) {
|
|
|
165
166
|
* @param {{ refreshToken: string, access?: string, expires?: number }} account
|
|
166
167
|
* @returns {Promise<string | null>}
|
|
167
168
|
*/
|
|
169
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- mutable account shape shared across CLI/plugin; full typing deferred
|
|
168
170
|
export async function refreshAccessToken(account: Record<string, any>) {
|
|
169
171
|
try {
|
|
170
172
|
const resp = await fetch("https://platform.claude.com/v1/oauth/token", {
|
|
@@ -178,6 +180,7 @@ export async function refreshAccessToken(account: Record<string, any>) {
|
|
|
178
180
|
signal: AbortSignal.timeout(5000),
|
|
179
181
|
});
|
|
180
182
|
if (!resp.ok) return null;
|
|
183
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- OAuth token response shape is external API contract
|
|
181
184
|
const json: any = await resp.json();
|
|
182
185
|
account.access = json.access_token;
|
|
183
186
|
account.expires = Date.now() + json.expires_in * 1000;
|
|
@@ -216,6 +219,7 @@ export async function fetchUsage(accessToken: string) {
|
|
|
216
219
|
* @param {{ refreshToken: string, access?: string, expires?: number, enabled: boolean }} account
|
|
217
220
|
* @returns {Promise<{ usage: Record<string, any> | null, tokenRefreshed: boolean }>}
|
|
218
221
|
*/
|
|
222
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- mutable account shape shared across CLI/plugin; full typing deferred
|
|
219
223
|
export async function ensureTokenAndFetchUsage(account: Record<string, any>) {
|
|
220
224
|
if (!account.enabled) return { usage: null, tokenRefreshed: false };
|
|
221
225
|
|
|
@@ -288,6 +292,7 @@ const USAGE_LABEL_WIDTH = 13;
|
|
|
288
292
|
* @param {Record<string, any>} usage
|
|
289
293
|
* @returns {string[]}
|
|
290
294
|
*/
|
|
295
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- upstream Anthropic usage API response has unstable bucket shapes
|
|
291
296
|
export function renderUsageLines(usage: Record<string, any>) {
|
|
292
297
|
const lines = [];
|
|
293
298
|
for (const { key, label } of QUOTA_BUCKETS) {
|
|
@@ -403,18 +408,26 @@ export async function cmdLogin() {
|
|
|
403
408
|
|
|
404
409
|
// Load or create storage
|
|
405
410
|
const storage = stored || { version: 1, accounts: [], activeIndex: 0 };
|
|
411
|
+
const identity = resolveIdentityFromOAuthExchange(credentials);
|
|
412
|
+
|
|
413
|
+
const existing =
|
|
414
|
+
findByIdentity(storage.accounts, identity) ||
|
|
415
|
+
storage.accounts.find((acc) => acc.refreshToken === credentials.refresh);
|
|
416
|
+
if (existing) {
|
|
417
|
+
const existingIdx = storage.accounts.indexOf(existing);
|
|
406
418
|
|
|
407
|
-
// Check for duplicate refresh token
|
|
408
|
-
const existingIdx = storage.accounts.findIndex((acc) => acc.refreshToken === credentials.refresh);
|
|
409
|
-
if (existingIdx >= 0) {
|
|
410
419
|
// Update existing account
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
420
|
+
existing.refreshToken = credentials.refresh;
|
|
421
|
+
existing.access = credentials.access;
|
|
422
|
+
existing.expires = credentials.expires;
|
|
423
|
+
existing.token_updated_at = Date.now();
|
|
424
|
+
if (credentials.email) existing.email = credentials.email;
|
|
425
|
+
existing.identity = identity;
|
|
426
|
+
existing.source = existing.source ?? "oauth";
|
|
427
|
+
existing.enabled = true;
|
|
415
428
|
await saveAccounts(storage);
|
|
416
429
|
|
|
417
|
-
const label = credentials.email || `Account ${existingIdx + 1}`;
|
|
430
|
+
const label = credentials.email || existing.email || `Account ${existingIdx + 1}`;
|
|
418
431
|
log.success(`Updated existing account #${existingIdx + 1} (${label}).`);
|
|
419
432
|
return 0;
|
|
420
433
|
}
|
|
@@ -429,6 +442,7 @@ export async function cmdLogin() {
|
|
|
429
442
|
storage.accounts.push({
|
|
430
443
|
id: `${now}:${credentials.refresh.slice(0, 12)}`,
|
|
431
444
|
email: credentials.email,
|
|
445
|
+
identity,
|
|
432
446
|
refreshToken: credentials.refresh,
|
|
433
447
|
access: credentials.access,
|
|
434
448
|
expires: credentials.expires,
|
|
@@ -440,6 +454,7 @@ export async function cmdLogin() {
|
|
|
440
454
|
consecutiveFailures: 0,
|
|
441
455
|
lastFailureTime: null,
|
|
442
456
|
stats: createDefaultStats(now),
|
|
457
|
+
source: "oauth",
|
|
443
458
|
});
|
|
444
459
|
|
|
445
460
|
// If this is the first account, it's already active at index 0
|
|
@@ -728,7 +743,11 @@ export async function cmdList() {
|
|
|
728
743
|
}
|
|
729
744
|
}
|
|
730
745
|
if (anyRefreshed) {
|
|
731
|
-
|
|
746
|
+
// Best-effort persistence — if saveAccounts fails, the CLI continues to render the
|
|
747
|
+
// status view with the in-memory refreshed tokens. The next command run will retry persisting.
|
|
748
|
+
await saveAccounts(stored).catch((err) => {
|
|
749
|
+
console.error("[opencode-anthropic-auth] failed to persist refreshed tokens:", err);
|
|
750
|
+
});
|
|
732
751
|
}
|
|
733
752
|
|
|
734
753
|
log.message(c.bold("Anthropic Multi-Account Status"));
|
|
@@ -1215,7 +1234,7 @@ export async function cmdStrategy(arg?: string) {
|
|
|
1215
1234
|
|
|
1216
1235
|
const normalized = arg.toLowerCase().trim();
|
|
1217
1236
|
|
|
1218
|
-
if (!VALID_STRATEGIES.includes(normalized as
|
|
1237
|
+
if (!VALID_STRATEGIES.includes(normalized as (typeof VALID_STRATEGIES)[number])) {
|
|
1219
1238
|
log.error(`Invalid strategy '${arg}'. Valid strategies: ${VALID_STRATEGIES.join(", ")}`);
|
|
1220
1239
|
return 1;
|
|
1221
1240
|
}
|
|
@@ -1625,10 +1644,10 @@ ${c.dim("Files:")}
|
|
|
1625
1644
|
// Main entry point
|
|
1626
1645
|
// ---------------------------------------------------------------------------
|
|
1627
1646
|
|
|
1628
|
-
/** @type {AsyncLocalStorage<{ log?: (...args:
|
|
1647
|
+
/** @type {AsyncLocalStorage<{ log?: (...args: unknown[]) => void, error?: (...args: unknown[]) => void }>} */
|
|
1629
1648
|
type IoStore = {
|
|
1630
|
-
log?: (...args:
|
|
1631
|
-
error?: (...args:
|
|
1649
|
+
log?: (...args: unknown[]) => void;
|
|
1650
|
+
error?: (...args: unknown[]) => void;
|
|
1632
1651
|
};
|
|
1633
1652
|
const ioContext = new AsyncLocalStorage<IoStore>();
|
|
1634
1653
|
|
|
@@ -1662,10 +1681,10 @@ function uninstallConsoleRouter() {
|
|
|
1662
1681
|
|
|
1663
1682
|
/**
|
|
1664
1683
|
* Run with async-local IO capture without persistent global side effects.
|
|
1665
|
-
* @param {{ log?: (...args:
|
|
1684
|
+
* @param {{ log?: (...args: unknown[]) => void, error?: (...args: unknown[]) => void }} io
|
|
1666
1685
|
* @param {() => Promise<number>} fn
|
|
1667
1686
|
*/
|
|
1668
|
-
async function runWithIoContext(io:
|
|
1687
|
+
async function runWithIoContext(io: IoStore, fn: () => Promise<number>) {
|
|
1669
1688
|
installConsoleRouter();
|
|
1670
1689
|
try {
|
|
1671
1690
|
return await ioContext.run(io, fn);
|
|
@@ -2013,13 +2032,13 @@ async function dispatch(argv: string[]) {
|
|
|
2013
2032
|
/**
|
|
2014
2033
|
* Parse argv and route to the appropriate command.
|
|
2015
2034
|
* @param {string[]} argv - process.argv.slice(2)
|
|
2016
|
-
* @param {{ io?: { log?: (...args:
|
|
2035
|
+
* @param {{ io?: { log?: (...args: unknown[]) => void, error?: (...args: unknown[]) => void } }} [options]
|
|
2017
2036
|
* @returns {Promise<number>} exit code
|
|
2018
2037
|
*/
|
|
2019
2038
|
export async function main(
|
|
2020
2039
|
argv: string[],
|
|
2021
2040
|
options: {
|
|
2022
|
-
io?:
|
|
2041
|
+
io?: IoStore;
|
|
2023
2042
|
} = {},
|
|
2024
2043
|
) {
|
|
2025
2044
|
if (options.io) {
|
package/src/commands/router.ts
CHANGED
|
@@ -15,6 +15,26 @@ import { completeSlashOAuth, startSlashOAuth, type OAuthFlowDeps, type PendingOA
|
|
|
15
15
|
|
|
16
16
|
export const ANTHROPIC_COMMAND_HANDLED = "__ANTHROPIC_COMMAND_HANDLED__";
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Maximum number of file-to-account pinning entries retained in memory.
|
|
20
|
+
* Bounded to prevent unbounded growth across long sessions that touch many
|
|
21
|
+
* Files API uploads. Eviction is FIFO: when the cap is hit, the oldest entry
|
|
22
|
+
* (Maps preserve insertion order) is dropped before inserting the new one.
|
|
23
|
+
*/
|
|
24
|
+
export const FILE_ACCOUNT_MAP_MAX_SIZE = 1000;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Insert a fileId→accountIndex binding with FIFO eviction when the cap is reached.
|
|
28
|
+
* See {@link FILE_ACCOUNT_MAP_MAX_SIZE} for the rationale.
|
|
29
|
+
*/
|
|
30
|
+
export function capFileAccountMap(fileAccountMap: Map<string, number>, fileId: string, accountIndex: number): void {
|
|
31
|
+
if (fileAccountMap.size >= FILE_ACCOUNT_MAP_MAX_SIZE) {
|
|
32
|
+
const oldestKey = fileAccountMap.keys().next().value;
|
|
33
|
+
if (oldestKey !== undefined) fileAccountMap.delete(oldestKey);
|
|
34
|
+
}
|
|
35
|
+
fileAccountMap.set(fileId, accountIndex);
|
|
36
|
+
}
|
|
37
|
+
|
|
18
38
|
export interface CliResult {
|
|
19
39
|
code: number;
|
|
20
40
|
stdout: string;
|
|
@@ -38,7 +58,7 @@ export interface CommandDeps {
|
|
|
38
58
|
* Remove ANSI color/control codes from output text.
|
|
39
59
|
*/
|
|
40
60
|
export function stripAnsi(value: string): string {
|
|
41
|
-
// eslint-disable-next-line no-control-regex
|
|
61
|
+
// eslint-disable-next-line no-control-regex -- ANSI escape sequences start with \x1b which is a control char
|
|
42
62
|
return value.replace(/\x1b\[[0-9;]*m/g, "");
|
|
43
63
|
}
|
|
44
64
|
|
|
@@ -410,7 +430,7 @@ export async function handleAnthropicSlashCommand(
|
|
|
410
430
|
data?: Array<{ id: string; filename: string; size: number; purpose: string }>;
|
|
411
431
|
};
|
|
412
432
|
const files = data.data || [];
|
|
413
|
-
for (const f of files) fileAccountMap
|
|
433
|
+
for (const f of files) capFileAccountMap(fileAccountMap, f.id, account.index);
|
|
414
434
|
if (files.length === 0) {
|
|
415
435
|
await sendCommandMessage(input.sessionID, `▣ Anthropic Files [${label}]\n\nNo files uploaded.`);
|
|
416
436
|
return;
|
|
@@ -441,7 +461,7 @@ export async function handleAnthropicSlashCommand(
|
|
|
441
461
|
data?: Array<{ id: string; filename: string; size: number; purpose: string }>;
|
|
442
462
|
};
|
|
443
463
|
const files = data.data || [];
|
|
444
|
-
for (const f of files) fileAccountMap
|
|
464
|
+
for (const f of files) capFileAccountMap(fileAccountMap, f.id, acct.index);
|
|
445
465
|
totalFiles += files.length;
|
|
446
466
|
if (files.length === 0) {
|
|
447
467
|
allLines.push(`[${label}] No files`);
|
|
@@ -517,7 +537,7 @@ export async function handleAnthropicSlashCommand(
|
|
|
517
537
|
}
|
|
518
538
|
const file = (await res.json()) as { id: string; filename: string; size?: number };
|
|
519
539
|
const sizeKB = ((file.size || 0) / 1024).toFixed(1);
|
|
520
|
-
fileAccountMap
|
|
540
|
+
capFileAccountMap(fileAccountMap, file.id, account.index);
|
|
521
541
|
await sendCommandMessage(
|
|
522
542
|
input.sessionID,
|
|
523
543
|
`▣ Anthropic Files [${label}]\n\nUploaded: ${file.id}\n Filename: ${file.filename}\n Size: ${sizeKB} KB`,
|
|
@@ -551,7 +571,7 @@ export async function handleAnthropicSlashCommand(
|
|
|
551
571
|
mime_type?: string;
|
|
552
572
|
created_at?: string;
|
|
553
573
|
};
|
|
554
|
-
fileAccountMap
|
|
574
|
+
capFileAccountMap(fileAccountMap, file.id, account.index);
|
|
555
575
|
const lines = [
|
|
556
576
|
`▣ Anthropic Files [${label}]`,
|
|
557
577
|
"",
|
package/src/env.ts
CHANGED
|
@@ -112,6 +112,7 @@ export function logTransformedSystemPrompt(body: string | undefined): void {
|
|
|
112
112
|
) {
|
|
113
113
|
return;
|
|
114
114
|
}
|
|
115
|
+
// eslint-disable-next-line no-console -- explicit debug logger gated by OPENCODE_ANTHROPIC_DEBUG_SYSTEM_PROMPT
|
|
115
116
|
console.error(
|
|
116
117
|
"[opencode-anthropic-auth][system-debug] transformed system:",
|
|
117
118
|
JSON.stringify(parsed.system, null, 2),
|
package/src/headers/billing.ts
CHANGED
|
@@ -9,25 +9,43 @@ export function buildAnthropicBillingHeader(claudeCliVersion: string, messages:
|
|
|
9
9
|
// the CLI version, then taking the first 3 hex chars of that combined string.
|
|
10
10
|
let versionSuffix = "";
|
|
11
11
|
if (Array.isArray(messages)) {
|
|
12
|
+
// Find first user message (CC uses first non-meta user turn)
|
|
12
13
|
const firstUserMsg = messages.find(
|
|
13
|
-
(m) =>
|
|
14
|
-
m !== null &&
|
|
15
|
-
typeof m === "object" &&
|
|
16
|
-
(m as Record<string, unknown>).role === "user" &&
|
|
17
|
-
typeof (m as Record<string, unknown>).content === "string",
|
|
14
|
+
(m) => m !== null && typeof m === "object" && (m as Record<string, unknown>).role === "user",
|
|
18
15
|
) as Record<string, unknown> | undefined;
|
|
19
16
|
if (firstUserMsg) {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
17
|
+
// Extract text from string or content-block array
|
|
18
|
+
let text = "";
|
|
19
|
+
const content = firstUserMsg.content;
|
|
20
|
+
if (typeof content === "string") {
|
|
21
|
+
text = content;
|
|
22
|
+
} else if (Array.isArray(content)) {
|
|
23
|
+
const textBlock = (content as Array<Record<string, unknown>>).find((b) => b.type === "text");
|
|
24
|
+
if (textBlock && typeof textBlock.text === "string") {
|
|
25
|
+
text = textBlock.text;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
if (text) {
|
|
29
|
+
const salt = "59cf53e54c78";
|
|
30
|
+
const picked = [4, 7, 20].map((i) => (i < text.length ? text[i] : "0")).join("");
|
|
31
|
+
const hash = createHash("sha256")
|
|
32
|
+
.update(salt + picked + claudeCliVersion)
|
|
33
|
+
.digest("hex");
|
|
34
|
+
versionSuffix = `.${hash.slice(0, 3)}`;
|
|
35
|
+
}
|
|
27
36
|
}
|
|
28
37
|
}
|
|
29
38
|
|
|
30
|
-
const entrypoint = process.env.CLAUDE_CODE_ENTRYPOINT
|
|
39
|
+
const entrypoint = process.env.CLAUDE_CODE_ENTRYPOINT ?? "cli";
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Billing header construction — mimics CC's mk_() function with two deliberate gaps:
|
|
43
|
+
// 1. cc_workload field: CC tracks this via AsyncLocalStorage for session-level
|
|
44
|
+
// workload attribution. Not applicable to the plugin (no workload tracking).
|
|
45
|
+
// See .omc/research/cch-source-analysis.md:124-131
|
|
46
|
+
// 2. cch value: CC uses placeholder "00000". Plugin computes a deterministic hash
|
|
47
|
+
// from prompt content for consistent routing. See cch-source-analysis.md:28-39
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
31
49
|
|
|
32
50
|
// CC's Bun binary computes a 5-char hex attestation hash via Attestation.zig
|
|
33
51
|
// and overwrites the "00000" placeholder before sending. On Node.js (npm CC)
|