@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.
Files changed (61) hide show
  1. package/README.md +19 -0
  2. package/dist/bun-proxy.mjs +282 -55
  3. package/dist/opencode-anthropic-auth-cli.mjs +194 -55
  4. package/dist/opencode-anthropic-auth-plugin.js +1816 -594
  5. package/package.json +1 -1
  6. package/src/__tests__/billing-edge-cases.test.ts +84 -0
  7. package/src/__tests__/bun-proxy.parallel.test.ts +460 -0
  8. package/src/__tests__/debug-gating.test.ts +76 -0
  9. package/src/__tests__/decomposition-smoke.test.ts +92 -0
  10. package/src/__tests__/fingerprint-regression.test.ts +1 -1
  11. package/src/__tests__/helpers/conversation-history.smoke.test.ts +338 -0
  12. package/src/__tests__/helpers/conversation-history.ts +376 -0
  13. package/src/__tests__/helpers/deferred.smoke.test.ts +161 -0
  14. package/src/__tests__/helpers/deferred.ts +122 -0
  15. package/src/__tests__/helpers/in-memory-storage.smoke.test.ts +166 -0
  16. package/src/__tests__/helpers/in-memory-storage.ts +152 -0
  17. package/src/__tests__/helpers/mock-bun-proxy.smoke.test.ts +92 -0
  18. package/src/__tests__/helpers/mock-bun-proxy.ts +229 -0
  19. package/src/__tests__/helpers/plugin-fetch-harness.smoke.test.ts +337 -0
  20. package/src/__tests__/helpers/plugin-fetch-harness.ts +401 -0
  21. package/src/__tests__/helpers/sse.smoke.test.ts +243 -0
  22. package/src/__tests__/helpers/sse.ts +288 -0
  23. package/src/__tests__/index.parallel.test.ts +711 -0
  24. package/src/__tests__/sanitization-regex.test.ts +65 -0
  25. package/src/__tests__/state-bounds.test.ts +110 -0
  26. package/src/account-identity.test.ts +213 -0
  27. package/src/account-identity.ts +108 -0
  28. package/src/accounts.dedup.test.ts +696 -0
  29. package/src/accounts.test.ts +2 -1
  30. package/src/accounts.ts +485 -191
  31. package/src/bun-fetch.test.ts +379 -0
  32. package/src/bun-fetch.ts +447 -174
  33. package/src/bun-proxy.ts +289 -57
  34. package/src/circuit-breaker.test.ts +274 -0
  35. package/src/circuit-breaker.ts +235 -0
  36. package/src/cli.test.ts +1 -0
  37. package/src/cli.ts +37 -18
  38. package/src/commands/router.ts +25 -5
  39. package/src/env.ts +1 -0
  40. package/src/headers/billing.ts +31 -13
  41. package/src/index.ts +224 -247
  42. package/src/oauth.ts +7 -1
  43. package/src/parent-pid-watcher.test.ts +219 -0
  44. package/src/parent-pid-watcher.ts +99 -0
  45. package/src/plugin-helpers.ts +112 -0
  46. package/src/refresh-helpers.ts +169 -0
  47. package/src/refresh-lock.test.ts +36 -9
  48. package/src/refresh-lock.ts +2 -2
  49. package/src/request/body.history.test.ts +398 -0
  50. package/src/request/body.ts +200 -13
  51. package/src/request/metadata.ts +6 -2
  52. package/src/response/index.ts +1 -1
  53. package/src/response/mcp.ts +60 -31
  54. package/src/response/streaming.test.ts +382 -0
  55. package/src/response/streaming.ts +403 -76
  56. package/src/storage.test.ts +127 -104
  57. package/src/storage.ts +152 -62
  58. package/src/system-prompt/builder.ts +33 -3
  59. package/src/system-prompt/sanitize.ts +12 -2
  60. package/src/token-refresh.test.ts +84 -1
  61. 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
+ }
@@ -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
- storage.accounts[existingIdx].access = credentials.access;
412
- storage.accounts[existingIdx].expires = credentials.expires;
413
- if (credentials.email) storage.accounts[existingIdx].email = credentials.email;
414
- storage.accounts[existingIdx].enabled = true;
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
- await saveAccounts(stored).catch(() => {});
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 any)) {
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: any[]) => void, error?: (...args: any[]) => void }>} */
1647
+ /** @type {AsyncLocalStorage<{ log?: (...args: unknown[]) => void, error?: (...args: unknown[]) => void }>} */
1629
1648
  type IoStore = {
1630
- log?: (...args: any[]) => void;
1631
- error?: (...args: any[]) => void;
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: any[]) => void, error?: (...args: any[]) => void }} io
1684
+ * @param {{ log?: (...args: unknown[]) => void, error?: (...args: unknown[]) => void }} io
1666
1685
  * @param {() => Promise<number>} fn
1667
1686
  */
1668
- async function runWithIoContext(io: Record<string, any>, fn: () => Promise<number>) {
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: any[]) => void, error?: (...args: any[]) => void } }} [options]
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?: { log?: (...args: any[]) => void; error?: (...args: any[]) => void };
2041
+ io?: IoStore;
2023
2042
  } = {},
2024
2043
  ) {
2025
2044
  if (options.io) {
@@ -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.set(f.id, account.index);
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.set(f.id, acct.index);
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.set(file.id, account.index);
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.set(file.id, account.index);
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),
@@ -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
- const text = firstUserMsg.content as string;
21
- const salt = "59cf53e54c78";
22
- const picked = [4, 7, 20].map((i) => (i < text.length ? text[i] : "0")).join("");
23
- const hash = createHash("sha256")
24
- .update(salt + picked + claudeCliVersion)
25
- .digest("hex");
26
- versionSuffix = `.${hash.slice(0, 3)}`;
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 || "cli";
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)