@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
package/src/index.ts CHANGED
@@ -9,7 +9,7 @@ import { isAccountSpecificError, parseRateLimitReason, parseRetryAfterHeader } f
9
9
  import type { PendingOAuthEntry } from "./commands/oauth-flow.js";
10
10
  import { promptAccountMenu, promptManageAccounts } from "./commands/prompts.js";
11
11
  import type { CommandDeps } from "./commands/router.js";
12
- import { ANTHROPIC_COMMAND_HANDLED, handleAnthropicSlashCommand, stripAnsi } from "./commands/router.js";
12
+ import { ANTHROPIC_COMMAND_HANDLED, handleAnthropicSlashCommand } from "./commands/router.js";
13
13
  import type { AnthropicAuthConfig } from "./config.js";
14
14
  import { loadConfig } from "./config.js";
15
15
  import { CLAUDE_CODE_IDENTITY_STRING, FALLBACK_CLAUDE_CLI_VERSION, FOREGROUND_EXPIRY_BUFFER_MS } from "./constants.js";
@@ -18,28 +18,55 @@ import { buildRequestHeaders } from "./headers/builder.js";
18
18
  import { fetchLatestClaudeCodeVersion } from "./headers/user-agent.js";
19
19
  import { hasOneMillionContext, isOpus46Model } from "./models.js";
20
20
  import { authorize, exchange } from "./oauth.js";
21
- import { transformRequestBody } from "./request/body.js";
21
+ import { cloneBodyForRetry, transformRequestBody } from "./request/body.js";
22
22
  import { extractFileIds, getAccountIdentifier } from "./request/metadata.js";
23
23
  import { fetchWithRetry } from "./request/retry.js";
24
24
  import { transformRequestUrl } from "./request/url.js";
25
- import { isEventStreamResponse, transformResponse } from "./response/streaming.js";
25
+ import { isEventStreamResponse, stripMcpPrefixFromJsonBody, transformResponse } from "./response/index.js";
26
+ import { StreamTruncatedError } from "./response/streaming.js";
26
27
  import { readCCCredentials } from "./cc-credentials.js";
28
+ import {
29
+ findByIdentity,
30
+ resolveIdentityFromCCCredential,
31
+ resolveIdentityFromOAuthExchange,
32
+ } from "./account-identity.js";
27
33
  import { clearAccounts, loadAccounts } from "./storage.js";
28
34
  import type { OpenCodeClient } from "./token-refresh.js";
29
- import {
30
- formatSwitchReason,
31
- markTokenStateUpdated,
32
- readDiskAccountAuth,
33
- refreshAccountToken,
34
- } from "./token-refresh.js";
35
+ import { formatSwitchReason, markTokenStateUpdated, readDiskAccountAuth } from "./token-refresh.js";
35
36
  import type { UsageStats } from "./types.js";
36
- import { fetchViaBun } from "./bun-fetch.js";
37
+ import { createBunFetch } from "./bun-fetch.js";
38
+ import { createPluginHelpers } from "./plugin-helpers.js";
39
+ import { createRefreshHelpers } from "./refresh-helpers.js";
40
+
41
+ async function finalizeResponse(
42
+ response: Response,
43
+ onUsage?: ((stats: UsageStats) => void) | null,
44
+ onAccountError?: ((details: { reason: string; invalidateToken: boolean }) => void) | null,
45
+ onStreamError?: ((error: Error) => void) | null,
46
+ ): Promise<Response> {
47
+ if (!isEventStreamResponse(response)) {
48
+ const body = stripMcpPrefixFromJsonBody(await response.text());
49
+ return new Response(body, {
50
+ status: response.status,
51
+ statusText: response.statusText,
52
+ headers: new Headers(response.headers),
53
+ });
54
+ }
55
+
56
+ return transformResponse(response, onUsage, onAccountError, onStreamError);
57
+ }
37
58
 
38
59
  // ---------------------------------------------------------------------------
39
60
  // Plugin factory
40
61
  // ---------------------------------------------------------------------------
41
62
 
42
- export async function AnthropicAuthPlugin({ client }: { client: OpenCodeClient & Record<string, any> }) {
63
+ export async function AnthropicAuthPlugin({
64
+ client,
65
+ }: {
66
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- OpenCode plugin client API boundary; accepts arbitrary extension methods
67
+ client: OpenCodeClient & Record<string, any>;
68
+ }) {
69
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- plugin config accepts forward-compatible arbitrary keys
43
70
  const config: AnthropicAuthConfig & Record<string, any> = loadConfig();
44
71
  const signatureEmulationEnabled = config.signature_emulation.enabled;
45
72
  const promptCompactionMode =
@@ -51,16 +78,6 @@ export async function AnthropicAuthPlugin({ client }: { client: OpenCodeClient &
51
78
 
52
79
  // Track account usage toasts; show once per account change (including first use).
53
80
  let lastToastedIndex = -1;
54
- const debouncedToastTimestamps = new Map<string, number>();
55
-
56
- const refreshInFlight = new Map<string, { promise: Promise<string>; source: "foreground" | "idle" }>();
57
-
58
- const idleRefreshLastAttempt = new Map<string, number>();
59
- const idleRefreshInFlight = new Set<string>();
60
-
61
- const IDLE_REFRESH_ENABLED = config.idle_refresh.enabled;
62
- const IDLE_REFRESH_WINDOW_MS = config.idle_refresh.window_minutes * 60 * 1000;
63
- const IDLE_REFRESH_MIN_INTERVAL_MS = config.idle_refresh.min_interval_minutes * 60 * 1000;
64
81
 
65
82
  let initialAccountPinned = false;
66
83
  const pendingSlashOAuth = new Map<string, PendingOAuthEntry>();
@@ -68,73 +85,44 @@ export async function AnthropicAuthPlugin({ client }: { client: OpenCodeClient &
68
85
 
69
86
  // -- Helpers ---------------------------------------------------------------
70
87
 
71
- async function sendCommandMessage(sessionID: string, text: string) {
72
- await client.session?.prompt({
73
- path: { id: sessionID },
74
- body: { noReply: true, parts: [{ type: "text", text, ignored: true }] },
75
- });
76
- }
77
-
78
- async function reloadAccountManagerFromDisk() {
79
- if (!accountManager) return;
80
- accountManager = await AccountManager.load(config, null);
88
+ function debugLog(...args: unknown[]) {
89
+ if (!config.debug) return;
90
+ // eslint-disable-next-line no-console -- this IS the plugin's dedicated debug logger; gated on config.debug
91
+ console.error("[opencode-anthropic-auth]", ...args);
81
92
  }
82
93
 
83
- async function persistOpenCodeAuth(refresh: string, access: string | undefined, expires: number | undefined) {
84
- await client.auth?.set({
85
- path: { id: "anthropic" },
86
- body: { type: "oauth", refresh, access, expires },
94
+ const { toast, sendCommandMessage, runCliCommand, reloadAccountManagerFromDisk, persistOpenCodeAuth } =
95
+ createPluginHelpers({
96
+ client,
97
+ config,
98
+ debugLog,
99
+ getAccountManager: () => accountManager,
100
+ setAccountManager: (nextAccountManager) => {
101
+ accountManager = nextAccountManager;
102
+ },
87
103
  });
88
- }
89
104
 
90
- async function runCliCommand(argv: string[]): Promise<{ code: number; stdout: string; stderr: string }> {
91
- const logs: string[] = [];
92
- const errors: string[] = [];
93
- let code = 1;
94
- try {
95
- const { main: cliMain } = await import("./cli.js");
96
- code = await cliMain(argv, {
97
- io: {
98
- log: (...args: unknown[]) => logs.push(args.map(String).join(" ")),
99
- error: (...args: unknown[]) => errors.push(args.map(String).join(" ")),
100
- },
101
- });
102
- } catch (err) {
103
- errors.push(err instanceof Error ? err.message : String(err));
104
- }
105
- return {
106
- code,
107
- stdout: stripAnsi(logs.join("\n")).trim(),
108
- stderr: stripAnsi(errors.join("\n")).trim(),
109
- };
110
- }
105
+ const { parseRefreshFailure, refreshAccountTokenSingleFlight, maybeRefreshIdleAccounts } = createRefreshHelpers({
106
+ client,
107
+ config,
108
+ getAccountManager: () => accountManager,
109
+ debugLog,
110
+ });
111
+ const bunFetchInstance = createBunFetch({
112
+ debug: config.debug,
113
+ onProxyStatus: (status) => {
114
+ debugLog("bun fetch status", status);
115
+ },
116
+ });
111
117
 
112
- async function toast(
113
- message: string,
114
- variant: "info" | "success" | "warning" | "error" = "info",
115
- options: { debounceKey?: string } = {},
116
- ) {
117
- if (config.toasts.quiet && variant !== "error") return;
118
- if (variant !== "error" && options.debounceKey) {
119
- const minGapMs = Math.max(0, config.toasts.debounce_seconds) * 1000;
120
- if (minGapMs > 0) {
121
- const now = Date.now();
122
- const lastAt = debouncedToastTimestamps.get(options.debounceKey) ?? 0;
123
- if (now - lastAt < minGapMs) return;
124
- debouncedToastTimestamps.set(options.debounceKey, now);
125
- }
126
- }
127
- try {
128
- await client.tui?.showToast({ body: { message, variant } });
129
- } catch {
130
- // TUI may not be available
118
+ const fetchWithTransport = async (input: string | URL | Request, init: RequestInit): Promise<Response> => {
119
+ const activeFetch = globalThis.fetch as typeof globalThis.fetch & { mock?: unknown };
120
+ if (typeof activeFetch === "function" && activeFetch.mock) {
121
+ return activeFetch(input, init);
131
122
  }
132
- }
133
123
 
134
- function debugLog(...args: unknown[]) {
135
- if (!config.debug) return;
136
- console.error("[opencode-anthropic-auth]", ...args);
137
- }
124
+ return bunFetchInstance.fetch(input, init);
125
+ };
138
126
 
139
127
  // -- Version resolution ----------------------------------------------------
140
128
 
@@ -148,134 +136,7 @@ export async function AnthropicAuthPlugin({ client }: { client: OpenCodeClient &
148
136
  claudeCliVersion = version;
149
137
  debugLog("resolved claude-code version from npm", version);
150
138
  })
151
- .catch(() => {});
152
- }
153
-
154
- // -- Refresh helpers -------------------------------------------------------
155
-
156
- function parseRefreshFailure(refreshError: unknown) {
157
- const message = refreshError instanceof Error ? refreshError.message : String(refreshError);
158
- const status =
159
- typeof refreshError === "object" && refreshError && "status" in refreshError
160
- ? Number((refreshError as Record<string, unknown>).status)
161
- : NaN;
162
- const errorCode =
163
- typeof refreshError === "object" && refreshError && ("errorCode" in refreshError || "code" in refreshError)
164
- ? String(
165
- (refreshError as Record<string, unknown>).errorCode || (refreshError as Record<string, unknown>).code || "",
166
- )
167
- : "";
168
- const msgLower = message.toLowerCase();
169
- const isInvalidGrant =
170
- errorCode === "invalid_grant" || errorCode === "invalid_request" || msgLower.includes("invalid_grant");
171
- const isTerminalStatus = status === 400 || status === 401 || status === 403;
172
- return { message, status, errorCode, isInvalidGrant, isTerminalStatus };
173
- }
174
-
175
- async function refreshAccountTokenSingleFlight(
176
- account: ManagedAccount,
177
- source: "foreground" | "idle" = "foreground",
178
- ): Promise<string> {
179
- const key = account.id;
180
- const existing = refreshInFlight.get(key);
181
- if (existing) {
182
- if (source === "foreground" && existing.source === "idle") {
183
- try {
184
- await existing.promise;
185
- } catch {
186
- /* ignore idle failure */
187
- }
188
- if (account.access && account.expires && account.expires > Date.now()) return account.access;
189
- } else {
190
- return existing.promise;
191
- }
192
- }
193
-
194
- const entry: { promise: Promise<string>; source: "foreground" | "idle" } = {
195
- source,
196
- promise: Promise.resolve(""),
197
- };
198
- const p = (async () => {
199
- try {
200
- return await refreshAccountToken(account, client, source, {
201
- onTokensUpdated: async () => {
202
- try {
203
- await accountManager!.saveToDisk();
204
- } catch {
205
- accountManager!.requestSaveToDisk();
206
- throw new Error("save failed, debounced retry scheduled");
207
- }
208
- },
209
- });
210
- } finally {
211
- if (refreshInFlight.get(key) === entry) refreshInFlight.delete(key);
212
- }
213
- })();
214
- entry.promise = p;
215
- refreshInFlight.set(key, entry);
216
- return p;
217
- }
218
-
219
- async function refreshIdleAccount(account: ManagedAccount) {
220
- if (!accountManager) return;
221
- if (idleRefreshInFlight.has(account.id)) return;
222
- idleRefreshInFlight.add(account.id);
223
- const attemptedRefreshToken = account.refreshToken;
224
- try {
225
- try {
226
- await refreshAccountTokenSingleFlight(account, "idle");
227
- return;
228
- } catch (err) {
229
- let details = parseRefreshFailure(err);
230
- if (!(details.isInvalidGrant || details.isTerminalStatus)) {
231
- debugLog("idle refresh skipped after transient failure", {
232
- accountIndex: account.index,
233
- status: details.status,
234
- errorCode: details.errorCode,
235
- message: details.message,
236
- });
237
- return;
238
- }
239
- const diskAuth = await readDiskAccountAuth(account.id);
240
- const retryToken = diskAuth?.refreshToken;
241
- if (retryToken && retryToken !== attemptedRefreshToken && account.refreshToken === attemptedRefreshToken) {
242
- account.refreshToken = retryToken;
243
- if (diskAuth?.tokenUpdatedAt) account.tokenUpdatedAt = diskAuth.tokenUpdatedAt;
244
- else markTokenStateUpdated(account);
245
- }
246
- try {
247
- await refreshAccountTokenSingleFlight(account, "idle");
248
- } catch (retryErr) {
249
- details = parseRefreshFailure(retryErr);
250
- debugLog("idle refresh retry failed", {
251
- accountIndex: account.index,
252
- status: details.status,
253
- errorCode: details.errorCode,
254
- message: details.message,
255
- });
256
- }
257
- }
258
- } finally {
259
- idleRefreshInFlight.delete(account.id);
260
- }
261
- }
262
-
263
- function maybeRefreshIdleAccounts(activeAccount: ManagedAccount) {
264
- if (!IDLE_REFRESH_ENABLED || !accountManager) return;
265
- const now = Date.now();
266
- const excluded = new Set([activeAccount.index]);
267
- const candidates = accountManager
268
- .getEnabledAccounts(excluded)
269
- .filter((acc) => !acc.expires || acc.expires <= now + IDLE_REFRESH_WINDOW_MS)
270
- .filter((acc) => {
271
- const last = idleRefreshLastAttempt.get(acc.id) ?? 0;
272
- return now - last >= IDLE_REFRESH_MIN_INTERVAL_MS;
273
- })
274
- .sort((a, b) => (a.expires ?? 0) - (b.expires ?? 0));
275
- const target = candidates[0];
276
- if (!target) return;
277
- idleRefreshLastAttempt.set(target.id, now);
278
- void refreshIdleAccount(target);
139
+ .catch((err) => debugLog("CC version fetch failed:", (err as Error).message));
279
140
  }
280
141
 
281
142
  // -- Command deps ----------------------------------------------------------
@@ -300,6 +161,11 @@ export async function AnthropicAuthPlugin({ client }: { client: OpenCodeClient &
300
161
  // -- Plugin return ---------------------------------------------------------
301
162
 
302
163
  return {
164
+ dispose: async () => {
165
+ await bunFetchInstance.shutdown();
166
+ },
167
+
168
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- OpenCode plugin hook API boundary
303
169
  "experimental.chat.system.transform": (input: Record<string, any>, output: Record<string, any>) => {
304
170
  const prefix = CLAUDE_CODE_IDENTITY_STRING;
305
171
  if (!signatureEmulationEnabled && input.model?.providerID === "anthropic") {
@@ -308,6 +174,7 @@ export async function AnthropicAuthPlugin({ client }: { client: OpenCodeClient &
308
174
  }
309
175
  },
310
176
 
177
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- OpenCode plugin hook API boundary
311
178
  config: async (input: Record<string, any>) => {
312
179
  input.command ??= {};
313
180
  input.command["anthropic"] = {
@@ -316,9 +183,11 @@ export async function AnthropicAuthPlugin({ client }: { client: OpenCodeClient &
316
183
  };
317
184
  },
318
185
 
186
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- OpenCode plugin hook API boundary
319
187
  "command.execute.before": async (input: Record<string, any>) => {
320
188
  if (input.command !== "anthropic") return;
321
189
  try {
190
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- command router accepts the Record-shaped input from OpenCode
322
191
  await handleAnthropicSlashCommand(input as any, commandDeps);
323
192
  } catch (err) {
324
193
  const message = err instanceof Error ? err.message : String(err);
@@ -329,10 +198,16 @@ export async function AnthropicAuthPlugin({ client }: { client: OpenCodeClient &
329
198
 
330
199
  auth: {
331
200
  provider: "anthropic",
332
- async loader(getAuth: () => Promise<Record<string, any>>, provider: Record<string, any>) {
201
+ async loader(
202
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- OpenCode auth loader API boundary
203
+ getAuth: () => Promise<Record<string, any>>,
204
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- OpenCode auth loader API boundary
205
+ provider: Record<string, any>,
206
+ ) {
333
207
  const auth = await getAuth();
334
208
  if (auth.type === "oauth") {
335
209
  // Zero out cost for max plan and optionally override context limits.
210
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- model objects carry provider-specific metadata
336
211
  for (const model of Object.values(provider.models) as Record<string, any>[]) {
337
212
  model.cost = { input: 0, output: 0, cache: { read: 0, write: 0 } };
338
213
  if (
@@ -362,8 +237,6 @@ export async function AnthropicAuthPlugin({ client }: { client: OpenCodeClient &
362
237
  const ccCount = accountManager.getCCAccounts().length;
363
238
  if (ccCount > 0) {
364
239
  await toast(`Using Claude Code credentials (${ccCount} found)`, "success");
365
- } else {
366
- await toast("No Claude Code credentials — using OAuth", "info");
367
240
  }
368
241
  }
369
242
 
@@ -395,12 +268,37 @@ export async function AnthropicAuthPlugin({ client }: { client: OpenCodeClient &
395
268
 
396
269
  return {
397
270
  apiKey: "",
398
- async fetch(input: any, init: any) {
271
+ async fetch(
272
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- fetch input varies (string | URL | Request) across call sites
273
+ input: any,
274
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- fetch init is OpenCode-shaped RequestInit-plus
275
+ init: any,
276
+ ) {
399
277
  const currentAuth = await getAuth();
400
278
  if (currentAuth.type !== "oauth") return fetch(input, init);
401
279
 
402
- const requestInit = init ?? {};
280
+ const requestInit = { ...(init ?? {}) };
403
281
  const { requestInput, requestUrl } = transformRequestUrl(input);
282
+ const resolvedBody =
283
+ requestInit.body !== undefined
284
+ ? requestInit.body
285
+ : requestInput instanceof Request && requestInput.body
286
+ ? await requestInput.clone().text()
287
+ : undefined;
288
+ if (resolvedBody !== undefined) {
289
+ requestInit.body = resolvedBody;
290
+ }
291
+
292
+ const requestContext: {
293
+ attempt: number;
294
+ cloneBody: string | undefined;
295
+ preparedBody: string | undefined;
296
+ } = {
297
+ attempt: 0,
298
+ cloneBody: typeof resolvedBody === "string" ? cloneBodyForRetry(resolvedBody) : undefined,
299
+ preparedBody: undefined,
300
+ };
301
+
404
302
  const requestMethod = String(
405
303
  requestInit.method || (requestInput instanceof Request ? requestInput.method : "POST"),
406
304
  ).toUpperCase();
@@ -447,6 +345,8 @@ export async function AnthropicAuthPlugin({ client }: { client: OpenCodeClient &
447
345
  }
448
346
 
449
347
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
348
+ requestContext.attempt = attempt + 1;
349
+
450
350
  const account =
451
351
  attempt === 0 && pinnedAccount && !transientRefreshSkips.has(pinnedAccount.index)
452
352
  ? pinnedAccount
@@ -549,19 +449,30 @@ export async function AnthropicAuthPlugin({ client }: { client: OpenCodeClient &
549
449
 
550
450
  maybeRefreshIdleAccounts(account);
551
451
 
552
- const body = transformRequestBody(
553
- requestInit.body,
554
- {
555
- enabled: signatureEmulationEnabled,
556
- claudeCliVersion,
557
- promptCompactionMode,
558
- },
559
- {
560
- persistentUserId: signatureUserId,
561
- sessionId: signatureSessionId,
562
- accountId: getAccountIdentifier(account),
563
- },
564
- );
452
+ const buildAttemptBody = () => {
453
+ const transformedBody = transformRequestBody(
454
+ requestContext.cloneBody === undefined ? undefined : cloneBodyForRetry(requestContext.cloneBody),
455
+ {
456
+ enabled: signatureEmulationEnabled,
457
+ claudeCliVersion,
458
+ promptCompactionMode,
459
+ },
460
+ {
461
+ persistentUserId: signatureUserId,
462
+ sessionId: signatureSessionId,
463
+ accountId: getAccountIdentifier(account),
464
+ },
465
+ config.relocate_third_party_prompts,
466
+ debugLog,
467
+ );
468
+
469
+ requestContext.preparedBody =
470
+ typeof transformedBody === "string" ? cloneBodyForRetry(transformedBody) : undefined;
471
+
472
+ return transformedBody;
473
+ };
474
+
475
+ const body = buildAttemptBody();
565
476
  logTransformedSystemPrompt(body);
566
477
 
567
478
  const requestHeaders = buildRequestHeaders(input, requestInit, accessToken!, body, requestUrl, {
@@ -605,7 +516,7 @@ export async function AnthropicAuthPlugin({ client }: { client: OpenCodeClient &
605
516
  let response: Response;
606
517
  const fetchInput = requestInput as string | URL | Request;
607
518
  try {
608
- response = await fetchViaBun(fetchInput, {
519
+ response = await fetchWithTransport(fetchInput, {
609
520
  ...requestInit,
610
521
  body,
611
522
  headers: requestHeaders,
@@ -649,6 +560,7 @@ export async function AnthropicAuthPlugin({ client }: { client: OpenCodeClient &
649
560
  reason,
650
561
  });
651
562
  accountManager.markRateLimited(account, reason, authOrPermissionIssue ? null : retryAfterMs);
563
+ transientRefreshSkips.add(account.index);
652
564
  const name = account.email || `Account ${accountManager.getCurrentIndex() + 1}`;
653
565
  const total = accountManager.getAccountCount();
654
566
  if (total > 1) {
@@ -677,9 +589,13 @@ export async function AnthropicAuthPlugin({ client }: { client: OpenCodeClient &
677
589
  headersForRetry.set("x-stainless-retry-count", String(retryCount));
678
590
  retryCount += 1;
679
591
  const retryUrl = fetchInput instanceof Request ? fetchInput.url : fetchInput.toString();
680
- return fetchViaBun(retryUrl, {
592
+ const retryBody =
593
+ requestContext.preparedBody === undefined
594
+ ? undefined
595
+ : cloneBodyForRetry(requestContext.preparedBody);
596
+ return fetchWithTransport(retryUrl, {
681
597
  ...requestInit,
682
- body,
598
+ body: retryBody,
683
599
  headers: headersForRetry,
684
600
  });
685
601
  },
@@ -687,7 +603,7 @@ export async function AnthropicAuthPlugin({ client }: { client: OpenCodeClient &
687
603
  );
688
604
 
689
605
  if (!retried.ok) {
690
- return transformResponse(retried);
606
+ return finalizeResponse(retried);
691
607
  }
692
608
 
693
609
  response = retried;
@@ -695,7 +611,7 @@ export async function AnthropicAuthPlugin({ client }: { client: OpenCodeClient &
695
611
  debugLog("non-account-specific response error, returning directly", {
696
612
  status: response.status,
697
613
  });
698
- return transformResponse(response);
614
+ return finalizeResponse(response);
699
615
  }
700
616
  }
701
617
 
@@ -711,7 +627,8 @@ export async function AnthropicAuthPlugin({ client }: { client: OpenCodeClient &
711
627
  }
712
628
  : null;
713
629
  const accountErrorCallback = shouldInspectStream
714
- ? (details: { reason: any; invalidateToken: boolean }) => {
630
+ ? // eslint-disable-next-line @typescript-eslint/no-explicit-any -- reason carries opaque rate-limit metadata; shape stabilized at callsite
631
+ (details: { reason: any; invalidateToken: boolean }) => {
715
632
  if (details.invalidateToken) {
716
633
  account.access = undefined;
717
634
  account.expires = undefined;
@@ -720,8 +637,22 @@ export async function AnthropicAuthPlugin({ client }: { client: OpenCodeClient &
720
637
  accountManager!.markRateLimited(account, details.reason, null);
721
638
  }
722
639
  : null;
640
+ const streamErrorCallback =
641
+ response.ok && isEventStreamResponse(response)
642
+ ? (error: Error) => {
643
+ if (!(error instanceof StreamTruncatedError)) {
644
+ return;
645
+ }
723
646
 
724
- return transformResponse(response, usageCallback, accountErrorCallback);
647
+ debugLog("stream truncated during response consumption", {
648
+ accountIndex: account?.index,
649
+ message: error.message,
650
+ context: error.context,
651
+ });
652
+ }
653
+ : null;
654
+
655
+ return finalizeResponse(response, usageCallback, accountErrorCallback, streamErrorCallback);
725
656
  }
726
657
 
727
658
  if (lastError) throw lastError;
@@ -759,14 +690,39 @@ export async function AnthropicAuthPlugin({ client }: { client: OpenCodeClient &
759
690
  accountManager = await AccountManager.load(config, null);
760
691
  }
761
692
 
762
- const exists = accountManager
763
- .getAccountsSnapshot()
764
- .some((acc: ManagedAccount) => acc.refreshToken === ccCred.refreshToken);
765
-
766
- if (!exists) {
767
- const added = accountManager.addAccount(ccCred.refreshToken, ccCred.accessToken, ccCred.expiresAt);
693
+ const identity = resolveIdentityFromCCCredential(ccCred);
694
+ const existing = findByIdentity(accountManager.getCCAccounts(), identity);
695
+
696
+ if (existing) {
697
+ existing.refreshToken = ccCred.refreshToken;
698
+ existing.identity = identity;
699
+ existing.source = ccCred.source;
700
+ existing.label = ccCred.label;
701
+ existing.enabled = true;
702
+ if (ccCred.accessToken) {
703
+ existing.access = ccCred.accessToken;
704
+ }
705
+ if (ccCred.expiresAt >= (existing.expires ?? 0)) {
706
+ existing.expires = ccCred.expiresAt;
707
+ }
708
+ existing.tokenUpdatedAt = Math.max(existing.tokenUpdatedAt || 0, ccCred.expiresAt || 0);
709
+ await accountManager.saveToDisk();
710
+ } else {
711
+ const added = accountManager.addAccount(
712
+ ccCred.refreshToken,
713
+ ccCred.accessToken,
714
+ ccCred.expiresAt,
715
+ undefined,
716
+ {
717
+ identity,
718
+ label: ccCred.label,
719
+ source: ccCred.source,
720
+ },
721
+ );
768
722
  if (added) {
769
723
  added.source = ccCred.source;
724
+ added.label = ccCred.label;
725
+ added.identity = identity;
770
726
  }
771
727
  await accountManager.saveToDisk();
772
728
  await toast("Added Claude Code credentials", "success");
@@ -829,17 +785,38 @@ export async function AnthropicAuthPlugin({ client }: { client: OpenCodeClient &
829
785
  if (!accountManager) {
830
786
  accountManager = await AccountManager.load(config, null);
831
787
  }
788
+ const identity = resolveIdentityFromOAuthExchange(credentials);
832
789
  const countBefore = accountManager.getAccountCount();
833
- accountManager.addAccount(
834
- credentials.refresh,
835
- credentials.access,
836
- credentials.expires,
837
- credentials.email,
838
- );
790
+ const existing =
791
+ identity.kind === "oauth" ? findByIdentity(accountManager.getOAuthAccounts(), identity) : null;
792
+
793
+ if (existing) {
794
+ existing.refreshToken = credentials.refresh;
795
+ existing.access = credentials.access;
796
+ existing.expires = credentials.expires;
797
+ existing.email = credentials.email ?? existing.email;
798
+ existing.identity = identity;
799
+ existing.source = "oauth";
800
+ existing.enabled = true;
801
+ existing.tokenUpdatedAt = Date.now();
802
+ } else {
803
+ accountManager.addAccount(
804
+ credentials.refresh,
805
+ credentials.access,
806
+ credentials.expires,
807
+ credentials.email,
808
+ {
809
+ identity,
810
+ source: "oauth",
811
+ },
812
+ );
813
+ }
814
+
839
815
  await accountManager.saveToDisk();
840
816
  const total = accountManager.getAccountCount();
841
817
  const name = credentials.email || "account";
842
- if (countBefore > 0) await toast(`Added ${name} — ${total} accounts`, "success");
818
+ if (existing) await toast(`Re-authenticated (${name})`, "success");
819
+ else if (countBefore > 0) await toast(`Added ${name} — ${total} accounts`, "success");
843
820
  else await toast(`Authenticated (${name})`, "success");
844
821
  return credentials;
845
822
  },