@vacbo/opencode-anthropic-fix 0.1.7 → 0.1.9

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 (107) hide show
  1. package/README.md +88 -88
  2. package/dist/opencode-anthropic-auth-cli.mjs +804 -507
  3. package/dist/opencode-anthropic-auth-plugin.js +4751 -4109
  4. package/package.json +67 -59
  5. package/src/__tests__/billing-edge-cases.test.ts +59 -59
  6. package/src/__tests__/bun-proxy.parallel.test.ts +388 -382
  7. package/src/__tests__/cc-comparison.test.ts +87 -87
  8. package/src/__tests__/cc-credentials.test.ts +254 -250
  9. package/src/__tests__/cch-drift-checker.test.ts +51 -51
  10. package/src/__tests__/cch-native-style.test.ts +56 -56
  11. package/src/__tests__/debug-gating.test.ts +42 -42
  12. package/src/__tests__/decomposition-smoke.test.ts +68 -68
  13. package/src/__tests__/fingerprint-regression.test.ts +575 -566
  14. package/src/__tests__/helpers/conversation-history.smoke.test.ts +271 -271
  15. package/src/__tests__/helpers/conversation-history.ts +119 -119
  16. package/src/__tests__/helpers/deferred.smoke.test.ts +103 -103
  17. package/src/__tests__/helpers/deferred.ts +69 -69
  18. package/src/__tests__/helpers/in-memory-storage.smoke.test.ts +155 -155
  19. package/src/__tests__/helpers/in-memory-storage.ts +88 -88
  20. package/src/__tests__/helpers/mock-bun-proxy.smoke.test.ts +68 -68
  21. package/src/__tests__/helpers/mock-bun-proxy.ts +189 -189
  22. package/src/__tests__/helpers/plugin-fetch-harness.smoke.test.ts +273 -273
  23. package/src/__tests__/helpers/plugin-fetch-harness.ts +288 -288
  24. package/src/__tests__/helpers/sse.smoke.test.ts +236 -236
  25. package/src/__tests__/helpers/sse.ts +209 -209
  26. package/src/__tests__/index.parallel.test.ts +605 -595
  27. package/src/__tests__/sanitization-regex.test.ts +112 -112
  28. package/src/__tests__/state-bounds.test.ts +90 -90
  29. package/src/account-identity.test.ts +197 -192
  30. package/src/account-identity.ts +69 -67
  31. package/src/account-state.test.ts +86 -86
  32. package/src/account-state.ts +25 -25
  33. package/src/accounts/matching.test.ts +335 -0
  34. package/src/accounts/matching.ts +167 -0
  35. package/src/accounts/persistence.test.ts +345 -0
  36. package/src/accounts/persistence.ts +432 -0
  37. package/src/accounts/repair.test.ts +276 -0
  38. package/src/accounts/repair.ts +407 -0
  39. package/src/accounts.dedup.test.ts +621 -621
  40. package/src/accounts.test.ts +933 -929
  41. package/src/accounts.ts +633 -989
  42. package/src/backoff.test.ts +345 -345
  43. package/src/backoff.ts +219 -219
  44. package/src/betas.ts +124 -124
  45. package/src/bun-fetch.test.ts +345 -342
  46. package/src/bun-fetch.ts +424 -424
  47. package/src/bun-proxy.test.ts +25 -25
  48. package/src/bun-proxy.ts +209 -209
  49. package/src/cc-credentials.ts +111 -111
  50. package/src/circuit-breaker.test.ts +184 -184
  51. package/src/circuit-breaker.ts +169 -169
  52. package/src/cli/commands/auth.ts +963 -0
  53. package/src/cli/commands/config.ts +547 -0
  54. package/src/cli/formatting.test.ts +406 -0
  55. package/src/cli/formatting.ts +219 -0
  56. package/src/cli.ts +255 -2022
  57. package/src/commands/handlers/betas.ts +100 -0
  58. package/src/commands/handlers/config.ts +99 -0
  59. package/src/commands/handlers/files.ts +375 -0
  60. package/src/commands/oauth-flow.ts +181 -166
  61. package/src/commands/prompts.ts +61 -61
  62. package/src/commands/router.test.ts +421 -0
  63. package/src/commands/router.ts +143 -635
  64. package/src/config.test.ts +482 -482
  65. package/src/config.ts +412 -404
  66. package/src/constants.ts +48 -48
  67. package/src/drift/cch-constants.ts +95 -95
  68. package/src/env.ts +111 -105
  69. package/src/headers/billing.ts +33 -33
  70. package/src/headers/builder.ts +130 -130
  71. package/src/headers/cch.ts +75 -75
  72. package/src/headers/stainless.ts +25 -25
  73. package/src/headers/user-agent.ts +23 -23
  74. package/src/index.ts +436 -828
  75. package/src/models.ts +27 -27
  76. package/src/oauth.test.ts +102 -102
  77. package/src/oauth.ts +178 -178
  78. package/src/parent-pid-watcher.test.ts +148 -148
  79. package/src/parent-pid-watcher.ts +69 -69
  80. package/src/plugin-helpers.ts +82 -82
  81. package/src/refresh-helpers.ts +145 -139
  82. package/src/refresh-lock.test.ts +94 -94
  83. package/src/refresh-lock.ts +93 -93
  84. package/src/request/body.history.test.ts +579 -571
  85. package/src/request/body.ts +255 -255
  86. package/src/request/metadata.ts +65 -65
  87. package/src/request/retry.test.ts +156 -156
  88. package/src/request/retry.ts +67 -67
  89. package/src/request/url.ts +21 -21
  90. package/src/request-orchestration-helpers.ts +648 -0
  91. package/src/response/index.ts +5 -5
  92. package/src/response/mcp.ts +58 -58
  93. package/src/response/streaming.test.ts +313 -311
  94. package/src/response/streaming.ts +412 -410
  95. package/src/rotation.test.ts +304 -301
  96. package/src/rotation.ts +205 -205
  97. package/src/storage.test.ts +547 -547
  98. package/src/storage.ts +315 -291
  99. package/src/system-prompt/builder.ts +38 -38
  100. package/src/system-prompt/index.ts +5 -5
  101. package/src/system-prompt/normalize.ts +60 -60
  102. package/src/system-prompt/sanitize.ts +30 -30
  103. package/src/thinking.ts +21 -20
  104. package/src/token-refresh.test.ts +265 -265
  105. package/src/token-refresh.ts +219 -214
  106. package/src/types.ts +30 -30
  107. package/dist/bun-proxy.mjs +0 -291
@@ -3,29 +3,29 @@
3
3
  // ---------------------------------------------------------------------------
4
4
 
5
5
  export function transformRequestUrl(input: unknown): {
6
- requestInput: unknown;
7
- requestUrl: URL | null;
6
+ requestInput: unknown;
7
+ requestUrl: URL | null;
8
8
  } {
9
- let requestInput = input;
10
- let requestUrl: URL | null = null;
11
- try {
12
- if (typeof input === "string" || input instanceof URL) {
13
- requestUrl = new URL(input.toString());
14
- } else if (input instanceof Request) {
15
- requestUrl = new URL(input.url);
9
+ let requestInput = input;
10
+ let requestUrl: URL | null = null;
11
+ try {
12
+ if (typeof input === "string" || input instanceof URL) {
13
+ requestUrl = new URL(input.toString());
14
+ } else if (input instanceof Request) {
15
+ requestUrl = new URL(input.url);
16
+ }
17
+ } catch {
18
+ requestUrl = null;
16
19
  }
17
- } catch {
18
- requestUrl = null;
19
- }
20
20
 
21
- if (
22
- requestUrl &&
23
- (requestUrl.pathname === "/v1/messages" || requestUrl.pathname === "/v1/messages/count_tokens") &&
24
- !requestUrl.searchParams.has("beta")
25
- ) {
26
- requestUrl.searchParams.set("beta", "true");
27
- requestInput = input instanceof Request ? new Request(requestUrl.toString(), input) : requestUrl;
28
- }
21
+ if (
22
+ requestUrl &&
23
+ (requestUrl.pathname === "/v1/messages" || requestUrl.pathname === "/v1/messages/count_tokens") &&
24
+ !requestUrl.searchParams.has("beta")
25
+ ) {
26
+ requestUrl.searchParams.set("beta", "true");
27
+ requestInput = input instanceof Request ? new Request(requestUrl.toString(), input) : requestUrl;
28
+ }
29
29
 
30
- return { requestInput, requestUrl };
30
+ return { requestInput, requestUrl };
31
31
  }
@@ -0,0 +1,648 @@
1
+ import type { AccountManager, ManagedAccount } from "./accounts.js";
2
+ import type { RateLimitReason } from "./backoff.js";
3
+ import { isAccountSpecificError, parseRateLimitReason, parseRetryAfterHeader } from "./backoff.js";
4
+ import type { AnthropicAuthConfig } from "./config.js";
5
+ import { FOREGROUND_EXPIRY_BUFFER_MS } from "./constants.js";
6
+ import { logTransformedSystemPrompt } from "./env.js";
7
+ import { buildRequestHeaders } from "./headers/builder.js";
8
+ import type { PluginHelpers } from "./plugin-helpers.js";
9
+ import { cloneBodyForRetry, transformRequestBody } from "./request/body.js";
10
+ import { extractFileIds, getAccountIdentifier } from "./request/metadata.js";
11
+ import { fetchWithRetry } from "./request/retry.js";
12
+ import { transformRequestUrl } from "./request/url.js";
13
+ import type { RefreshHelpers } from "./refresh-helpers.js";
14
+ import { isEventStreamResponse, stripMcpPrefixFromJsonBody, transformResponse } from "./response/index.js";
15
+ import { StreamTruncatedError } from "./response/streaming.js";
16
+ import { formatSwitchReason, markTokenStateUpdated, readDiskAccountAuth } from "./token-refresh.js";
17
+ import type { UsageStats } from "./types.js";
18
+
19
+ type PromptCompactionMode = "minimal" | "off";
20
+ type ToastFn = PluginHelpers["toast"];
21
+ type ParseRefreshFailureFn = RefreshHelpers["parseRefreshFailure"];
22
+ type RefreshAccountTokenSingleFlightFn = RefreshHelpers["refreshAccountTokenSingleFlight"];
23
+ type MaybeRefreshIdleAccountsFn = RefreshHelpers["maybeRefreshIdleAccounts"];
24
+
25
+ type RequestContext = {
26
+ attempt: number;
27
+ cloneBody: string | undefined;
28
+ preparedBody: string | undefined;
29
+ };
30
+
31
+ type PreparedRequest = {
32
+ requestInput: string | URL | Request;
33
+ requestInit: RequestInit;
34
+ requestUrl: URL | null;
35
+ requestMethod: string;
36
+ showUsageToast: boolean;
37
+ requestContext: RequestContext;
38
+ };
39
+
40
+ type FinalizeResponseAccountErrorDetails = {
41
+ reason: string;
42
+ invalidateToken: boolean;
43
+ };
44
+
45
+ export interface RequestOrchestrationDeps {
46
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- plugin config accepts forward-compatible arbitrary keys
47
+ config: AnthropicAuthConfig & Record<string, any>;
48
+ debugLog: (...args: unknown[]) => void;
49
+ toast: ToastFn;
50
+ getAccountManager: () => AccountManager | null;
51
+ getClaudeCliVersion: () => string;
52
+ getInitialAccountPinned: () => boolean;
53
+ getLastToastedIndex: () => number;
54
+ setLastToastedIndex: (index: number) => void;
55
+ fileAccountMap: Map<string, number>;
56
+ fetchWithTransport: (input: string | URL | Request, init: RequestInit) => Promise<Response>;
57
+ parseRefreshFailure: ParseRefreshFailureFn;
58
+ refreshAccountTokenSingleFlight: RefreshAccountTokenSingleFlightFn;
59
+ maybeRefreshIdleAccounts: MaybeRefreshIdleAccountsFn;
60
+ signatureEmulationEnabled: boolean;
61
+ promptCompactionMode: PromptCompactionMode;
62
+ signatureSanitizeSystemPrompt: boolean;
63
+ signatureSessionId: string;
64
+ signatureUserId: string;
65
+ }
66
+
67
+ function getAccountManagerOrThrow(getAccountManager: () => AccountManager | null): AccountManager {
68
+ const accountManager = getAccountManager();
69
+ if (!accountManager) {
70
+ throw new Error("No available Anthropic account for request.");
71
+ }
72
+
73
+ return accountManager;
74
+ }
75
+
76
+ async function finalizeResponse(
77
+ response: Response,
78
+ onUsage?: ((stats: UsageStats) => void) | null,
79
+ onAccountError?: ((details: FinalizeResponseAccountErrorDetails) => void) | null,
80
+ onStreamError?: ((error: Error) => void) | null,
81
+ ): Promise<Response> {
82
+ if (!isEventStreamResponse(response)) {
83
+ const body = stripMcpPrefixFromJsonBody(await response.text());
84
+ return new Response(body, {
85
+ status: response.status,
86
+ statusText: response.statusText,
87
+ headers: new Headers(response.headers),
88
+ });
89
+ }
90
+
91
+ return transformResponse(response, onUsage, onAccountError, onStreamError);
92
+ }
93
+
94
+ function resolveShowUsageToast(requestUrl: URL | null, requestMethod: string): boolean {
95
+ try {
96
+ return requestUrl?.pathname === "/v1/messages" && requestMethod === "POST";
97
+ } catch {
98
+ return false;
99
+ }
100
+ }
101
+
102
+ async function prepareRequest(input: string | URL | Request, init: RequestInit | undefined): Promise<PreparedRequest> {
103
+ const requestInit = { ...(init ?? {}) };
104
+ const { requestInput, requestUrl } = transformRequestUrl(input);
105
+ const resolvedBody =
106
+ requestInit.body !== undefined
107
+ ? requestInit.body
108
+ : requestInput instanceof Request && requestInput.body
109
+ ? await requestInput.clone().text()
110
+ : undefined;
111
+
112
+ if (resolvedBody !== undefined) {
113
+ requestInit.body = resolvedBody;
114
+ }
115
+
116
+ const requestMethod = String(
117
+ requestInit.method || (requestInput instanceof Request ? requestInput.method : "POST"),
118
+ ).toUpperCase();
119
+
120
+ return {
121
+ requestInput: requestInput as string | URL | Request,
122
+ requestInit,
123
+ requestUrl,
124
+ requestMethod,
125
+ showUsageToast: resolveShowUsageToast(requestUrl, requestMethod),
126
+ requestContext: {
127
+ attempt: 0,
128
+ cloneBody: typeof resolvedBody === "string" ? cloneBodyForRetry(resolvedBody) : undefined,
129
+ preparedBody: undefined,
130
+ },
131
+ };
132
+ }
133
+
134
+ function resolvePinnedAccount(
135
+ accountManager: AccountManager,
136
+ requestBody: RequestInit["body"],
137
+ fileAccountMap: Map<string, number>,
138
+ debugLog: (...args: unknown[]) => void,
139
+ ): ManagedAccount | null {
140
+ if (typeof requestBody !== "string" || fileAccountMap.size === 0) {
141
+ return null;
142
+ }
143
+
144
+ try {
145
+ const bodyObj = JSON.parse(requestBody);
146
+ const fileIds = extractFileIds(bodyObj);
147
+ for (const fileId of fileIds) {
148
+ const pinnedIndex = fileAccountMap.get(fileId);
149
+ if (pinnedIndex === undefined) {
150
+ continue;
151
+ }
152
+
153
+ const pinnedAccount =
154
+ accountManager.getEnabledAccounts().find((account) => account.index === pinnedIndex) ?? null;
155
+ if (!pinnedAccount) {
156
+ continue;
157
+ }
158
+
159
+ debugLog("file-id pinning: routing to account", {
160
+ fileId,
161
+ accountIndex: pinnedIndex,
162
+ email: pinnedAccount.email,
163
+ });
164
+ return pinnedAccount;
165
+ }
166
+ } catch {
167
+ // Non-JSON body
168
+ }
169
+
170
+ return null;
171
+ }
172
+
173
+ function maybeToastAccountUsage(params: {
174
+ showUsageToast: boolean;
175
+ account: ManagedAccount | null;
176
+ accountManager: AccountManager;
177
+ getLastToastedIndex: () => number;
178
+ setLastToastedIndex: (index: number) => void;
179
+ toast: ToastFn;
180
+ }): Promise<void> | undefined {
181
+ const { showUsageToast, account, accountManager, getLastToastedIndex, setLastToastedIndex, toast } = params;
182
+
183
+ if (!showUsageToast || !account) {
184
+ return undefined;
185
+ }
186
+
187
+ const currentIndex = accountManager.getCurrentIndex();
188
+ if (currentIndex === getLastToastedIndex()) {
189
+ return undefined;
190
+ }
191
+
192
+ const name = account.email || `Account ${currentIndex + 1}`;
193
+ const total = accountManager.getAccountCount();
194
+ const message = total > 1 ? `Claude: ${name} (${currentIndex + 1}/${total})` : `Claude: ${name}`;
195
+
196
+ return toast(message, "info", { debounceKey: "account-usage" }).then(() => {
197
+ setLastToastedIndex(currentIndex);
198
+ });
199
+ }
200
+
201
+ async function resolveAccessToken(
202
+ account: ManagedAccount,
203
+ accountManager: AccountManager,
204
+ transientRefreshSkips: Set<number>,
205
+ deps: Pick<
206
+ RequestOrchestrationDeps,
207
+ "debugLog" | "toast" | "parseRefreshFailure" | "refreshAccountTokenSingleFlight"
208
+ >,
209
+ ): Promise<{ accessToken?: string; lastError?: unknown }> {
210
+ if (account.access && account.expires && account.expires >= Date.now() + FOREGROUND_EXPIRY_BUFFER_MS) {
211
+ return { accessToken: account.access };
212
+ }
213
+
214
+ const attemptedRefreshToken = account.refreshToken;
215
+ try {
216
+ return { accessToken: await deps.refreshAccountTokenSingleFlight(account) };
217
+ } catch (err) {
218
+ let finalError = err;
219
+ let details = deps.parseRefreshFailure(err);
220
+
221
+ if (details.isInvalidGrant || details.isTerminalStatus) {
222
+ const diskAuth = await readDiskAccountAuth(account.id);
223
+ const retryToken = diskAuth?.refreshToken;
224
+ if (retryToken && retryToken !== attemptedRefreshToken && account.refreshToken === attemptedRefreshToken) {
225
+ deps.debugLog("refresh token on disk differs from in-memory, retrying with disk token", {
226
+ accountIndex: account.index,
227
+ });
228
+ account.refreshToken = retryToken;
229
+ if (diskAuth?.tokenUpdatedAt) {
230
+ account.tokenUpdatedAt = diskAuth.tokenUpdatedAt;
231
+ } else {
232
+ markTokenStateUpdated(account);
233
+ }
234
+ } else if (retryToken && retryToken !== attemptedRefreshToken) {
235
+ deps.debugLog("skipping disk token adoption because in-memory token already changed", {
236
+ accountIndex: account.index,
237
+ });
238
+ }
239
+
240
+ try {
241
+ return { accessToken: await deps.refreshAccountTokenSingleFlight(account) };
242
+ } catch (retryErr) {
243
+ finalError = retryErr;
244
+ details = deps.parseRefreshFailure(retryErr);
245
+ deps.debugLog("retry refresh failed", {
246
+ accountIndex: account.index,
247
+ status: details.status,
248
+ errorCode: details.errorCode,
249
+ message: details.message,
250
+ });
251
+ }
252
+ }
253
+
254
+ accountManager.markFailure(account);
255
+ if (details.isInvalidGrant || details.isTerminalStatus) {
256
+ const name = account.email || `Account ${accountManager.getCurrentIndex() + 1}`;
257
+ deps.debugLog("disabling account after terminal refresh failure", {
258
+ accountIndex: account.index,
259
+ status: details.status,
260
+ errorCode: details.errorCode,
261
+ message: details.message,
262
+ });
263
+ account.enabled = false;
264
+ accountManager.requestSaveToDisk();
265
+ const statusLabel = Number.isFinite(details.status) ? `HTTP ${details.status}` : "unknown status";
266
+ await deps.toast(`Disabled ${name} (token refresh failed: ${details.errorCode || statusLabel})`, "error");
267
+ } else {
268
+ transientRefreshSkips.add(account.index);
269
+ }
270
+
271
+ return { lastError: finalError };
272
+ }
273
+ }
274
+
275
+ function buildAttemptBody(
276
+ account: ManagedAccount,
277
+ requestContext: RequestContext,
278
+ deps: Pick<
279
+ RequestOrchestrationDeps,
280
+ | "config"
281
+ | "debugLog"
282
+ | "getClaudeCliVersion"
283
+ | "signatureEmulationEnabled"
284
+ | "promptCompactionMode"
285
+ | "signatureSanitizeSystemPrompt"
286
+ | "signatureSessionId"
287
+ | "signatureUserId"
288
+ >,
289
+ ): string | undefined {
290
+ const transformedBody = transformRequestBody(
291
+ requestContext.cloneBody === undefined ? undefined : cloneBodyForRetry(requestContext.cloneBody),
292
+ {
293
+ enabled: deps.signatureEmulationEnabled,
294
+ claudeCliVersion: deps.getClaudeCliVersion(),
295
+ promptCompactionMode: deps.promptCompactionMode,
296
+ sanitizeSystemPrompt: deps.signatureSanitizeSystemPrompt,
297
+ },
298
+ {
299
+ persistentUserId: deps.signatureUserId,
300
+ sessionId: deps.signatureSessionId,
301
+ accountId: getAccountIdentifier(account),
302
+ },
303
+ deps.config.relocate_third_party_prompts,
304
+ deps.debugLog,
305
+ );
306
+
307
+ requestContext.preparedBody = typeof transformedBody === "string" ? cloneBodyForRetry(transformedBody) : undefined;
308
+ return transformedBody;
309
+ }
310
+
311
+ function logFingerprintSnapshot(
312
+ body: string | undefined,
313
+ requestHeaders: Headers,
314
+ deps: Pick<RequestOrchestrationDeps, "config" | "debugLog" | "getClaudeCliVersion" | "signatureEmulationEnabled">,
315
+ ): void {
316
+ if (!deps.config.debug) {
317
+ return;
318
+ }
319
+
320
+ const billingHeader = body
321
+ ? (() => {
322
+ try {
323
+ const parsed = JSON.parse(body) as Record<string, unknown>;
324
+ const system = parsed.system;
325
+ if (Array.isArray(system)) {
326
+ return (system as Array<{ text?: string }>).find(
327
+ (block) =>
328
+ typeof block.text === "string" && block.text.startsWith("x-anthropic-billing-header:"),
329
+ )?.text;
330
+ }
331
+ } catch {
332
+ // JSON parse failed — body is not valid JSON
333
+ }
334
+
335
+ return undefined;
336
+ })()
337
+ : undefined;
338
+
339
+ deps.debugLog("fingerprint snapshot", {
340
+ billingHeader: billingHeader ?? "(not in system prompt)",
341
+ userAgent: requestHeaders.get("user-agent"),
342
+ anthropicBeta: requestHeaders.get("anthropic-beta"),
343
+ stainlessPackageVersion: requestHeaders.get("x-stainless-package-version"),
344
+ xApp: requestHeaders.get("x-app"),
345
+ claudeCliVersion: deps.getClaudeCliVersion(),
346
+ signatureEnabled: deps.signatureEmulationEnabled,
347
+ });
348
+ }
349
+
350
+ function buildTransportRequestInit(
351
+ requestInit: RequestInit,
352
+ headers: Headers,
353
+ requestBody: RequestInit["body"],
354
+ forceFreshConnection: boolean,
355
+ ): RequestInit {
356
+ const requestHeadersForTransport = new Headers(headers);
357
+ if (forceFreshConnection) {
358
+ requestHeadersForTransport.set("connection", "close");
359
+ requestHeadersForTransport.set("x-proxy-disable-keepalive", "true");
360
+ } else {
361
+ requestHeadersForTransport.delete("connection");
362
+ requestHeadersForTransport.delete("x-proxy-disable-keepalive");
363
+ }
364
+
365
+ return {
366
+ ...requestInit,
367
+ body: requestBody,
368
+ headers: requestHeadersForTransport,
369
+ ...(forceFreshConnection ? { keepalive: false } : {}),
370
+ };
371
+ }
372
+
373
+ async function retryServiceWideResponse(params: {
374
+ response: Response;
375
+ fetchInput: string | URL | Request;
376
+ requestHeaders: Headers;
377
+ requestInit: RequestInit;
378
+ requestContext: RequestContext;
379
+ fetchWithTransport: RequestOrchestrationDeps["fetchWithTransport"];
380
+ }): Promise<Response> {
381
+ let retryCount = 0;
382
+ return fetchWithRetry(
383
+ async ({ forceFreshConnection }) => {
384
+ if (retryCount === 0) {
385
+ retryCount += 1;
386
+ return params.response;
387
+ }
388
+
389
+ const headersForRetry = new Headers(params.requestHeaders);
390
+ headersForRetry.set("x-stainless-retry-count", String(retryCount));
391
+ retryCount += 1;
392
+ const retryUrl =
393
+ params.fetchInput instanceof Request ? params.fetchInput.url : params.fetchInput.toString();
394
+ const retryBody =
395
+ params.requestContext.preparedBody === undefined
396
+ ? undefined
397
+ : cloneBodyForRetry(params.requestContext.preparedBody);
398
+
399
+ return params.fetchWithTransport(
400
+ retryUrl,
401
+ buildTransportRequestInit(params.requestInit, headersForRetry, retryBody, forceFreshConnection),
402
+ );
403
+ },
404
+ { maxRetries: 2 },
405
+ );
406
+ }
407
+
408
+ function buildFinalizeCallbacks(
409
+ response: Response,
410
+ account: ManagedAccount,
411
+ accountManager: AccountManager,
412
+ debugLog: (...args: unknown[]) => void,
413
+ ) {
414
+ const shouldInspectStream = response.ok && isEventStreamResponse(response);
415
+ const onUsage = shouldInspectStream
416
+ ? (usage: UsageStats) => {
417
+ accountManager.recordUsage(account.index, usage);
418
+ }
419
+ : null;
420
+ const onAccountError = shouldInspectStream
421
+ ? (details: FinalizeResponseAccountErrorDetails) => {
422
+ if (details.invalidateToken) {
423
+ account.access = undefined;
424
+ account.expires = undefined;
425
+ markTokenStateUpdated(account);
426
+ }
427
+ accountManager.markRateLimited(account, details.reason as RateLimitReason, null);
428
+ }
429
+ : null;
430
+ const onStreamError = shouldInspectStream
431
+ ? (error: Error) => {
432
+ if (!(error instanceof StreamTruncatedError)) {
433
+ return;
434
+ }
435
+
436
+ debugLog("stream truncated during response consumption", {
437
+ accountIndex: account.index,
438
+ message: error.message,
439
+ context: error.context,
440
+ });
441
+ }
442
+ : null;
443
+
444
+ return { onUsage, onAccountError, onStreamError };
445
+ }
446
+
447
+ export function createRequestOrchestrationHelpers(deps: RequestOrchestrationDeps) {
448
+ async function executeOAuthFetch(input: string | URL | Request, init?: RequestInit): Promise<Response> {
449
+ const preparedRequest = await prepareRequest(input, init);
450
+ const transientRefreshSkips = new Set<number>();
451
+ let lastError: unknown = null;
452
+
453
+ if (!deps.getInitialAccountPinned()) {
454
+ const accountManager = deps.getAccountManager();
455
+ if (accountManager) {
456
+ await accountManager.syncActiveIndexFromDisk();
457
+ }
458
+ }
459
+
460
+ const maxAttempts = getAccountManagerOrThrow(deps.getAccountManager).getTotalAccountCount();
461
+ const pinnedAccount = resolvePinnedAccount(
462
+ getAccountManagerOrThrow(deps.getAccountManager),
463
+ preparedRequest.requestInit.body,
464
+ deps.fileAccountMap,
465
+ deps.debugLog,
466
+ );
467
+
468
+ for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
469
+ preparedRequest.requestContext.attempt = attempt + 1;
470
+
471
+ const accountManager = getAccountManagerOrThrow(deps.getAccountManager);
472
+ const account =
473
+ attempt === 0 && pinnedAccount && !transientRefreshSkips.has(pinnedAccount.index)
474
+ ? pinnedAccount
475
+ : accountManager.getCurrentAccount(transientRefreshSkips);
476
+
477
+ await maybeToastAccountUsage({
478
+ showUsageToast: preparedRequest.showUsageToast,
479
+ account,
480
+ accountManager,
481
+ getLastToastedIndex: deps.getLastToastedIndex,
482
+ setLastToastedIndex: deps.setLastToastedIndex,
483
+ toast: deps.toast,
484
+ });
485
+
486
+ if (!account) {
487
+ const enabledCount = accountManager.getAccountCount();
488
+ if (enabledCount === 0) {
489
+ throw new Error(
490
+ "No enabled Anthropic accounts available. Enable one with 'opencode-anthropic-auth enable <N>'.",
491
+ );
492
+ }
493
+
494
+ throw new Error("No available Anthropic account for request.");
495
+ }
496
+
497
+ const { accessToken, lastError: refreshError } = await resolveAccessToken(
498
+ account,
499
+ accountManager,
500
+ transientRefreshSkips,
501
+ {
502
+ debugLog: deps.debugLog,
503
+ toast: deps.toast,
504
+ parseRefreshFailure: deps.parseRefreshFailure,
505
+ refreshAccountTokenSingleFlight: deps.refreshAccountTokenSingleFlight,
506
+ },
507
+ );
508
+ if (!accessToken) {
509
+ lastError = refreshError;
510
+ continue;
511
+ }
512
+
513
+ deps.maybeRefreshIdleAccounts(account);
514
+
515
+ const body = buildAttemptBody(account, preparedRequest.requestContext, deps);
516
+ logTransformedSystemPrompt(body);
517
+
518
+ const requestHeaders = buildRequestHeaders(
519
+ input,
520
+ preparedRequest.requestInit as Record<string, unknown>,
521
+ accessToken,
522
+ body,
523
+ preparedRequest.requestUrl,
524
+ {
525
+ enabled: deps.signatureEmulationEnabled,
526
+ claudeCliVersion: deps.getClaudeCliVersion(),
527
+ promptCompactionMode: deps.promptCompactionMode,
528
+ sanitizeSystemPrompt: deps.signatureSanitizeSystemPrompt,
529
+ customBetas: deps.config.custom_betas,
530
+ strategy: deps.config.account_selection_strategy,
531
+ },
532
+ );
533
+ logFingerprintSnapshot(body, requestHeaders, deps);
534
+
535
+ const fetchInput = preparedRequest.requestInput;
536
+ let response: Response;
537
+ try {
538
+ response = await fetchWithRetry(
539
+ async ({ forceFreshConnection }) =>
540
+ deps.fetchWithTransport(
541
+ fetchInput,
542
+ buildTransportRequestInit(
543
+ preparedRequest.requestInit,
544
+ requestHeaders,
545
+ body,
546
+ forceFreshConnection,
547
+ ),
548
+ ),
549
+ {
550
+ maxRetries: 2,
551
+ shouldRetryResponse: () => false,
552
+ },
553
+ );
554
+ } catch (err) {
555
+ const fetchError = err instanceof Error ? err : new Error(String(err));
556
+ accountManager.markFailure(account);
557
+ transientRefreshSkips.add(account.index);
558
+ lastError = fetchError;
559
+ deps.debugLog("request fetch threw, trying next account", {
560
+ accountIndex: account.index,
561
+ message: fetchError.message,
562
+ });
563
+ continue;
564
+ }
565
+
566
+ if (!response.ok) {
567
+ let errorBody: string | null = null;
568
+ try {
569
+ errorBody = await response.clone().text();
570
+ } catch {
571
+ // Ignore clone/read failures for best-effort diagnostics.
572
+ }
573
+
574
+ if (isAccountSpecificError(response.status, errorBody)) {
575
+ const reason = parseRateLimitReason(response.status, errorBody);
576
+ const retryAfterMs = parseRetryAfterHeader(response);
577
+ if (reason === "AUTH_FAILED") {
578
+ account.access = undefined;
579
+ account.expires = undefined;
580
+ markTokenStateUpdated(account);
581
+ }
582
+
583
+ deps.debugLog("account-specific error, switching account", {
584
+ accountIndex: account.index,
585
+ status: response.status,
586
+ reason,
587
+ });
588
+ accountManager.markRateLimited(account, reason, reason === "AUTH_FAILED" ? null : retryAfterMs);
589
+ transientRefreshSkips.add(account.index);
590
+ if (accountManager.getAccountCount() > 1) {
591
+ const name = account.email || `Account ${accountManager.getCurrentIndex() + 1}`;
592
+ const switchReason = formatSwitchReason(response.status, reason);
593
+ await deps.toast(`${name} ${switchReason}, switching account`, "warning", {
594
+ debounceKey: "account-switch",
595
+ });
596
+ }
597
+ continue;
598
+ }
599
+
600
+ if (response.status === 500 || response.status === 503 || response.status === 529) {
601
+ deps.debugLog("service-wide response error, attempting retry", { status: response.status });
602
+ const retried = await retryServiceWideResponse({
603
+ response,
604
+ fetchInput,
605
+ requestHeaders,
606
+ requestInit: preparedRequest.requestInit,
607
+ requestContext: preparedRequest.requestContext,
608
+ fetchWithTransport: deps.fetchWithTransport,
609
+ });
610
+ if (!retried.ok) {
611
+ return finalizeResponse(retried);
612
+ }
613
+
614
+ response = retried;
615
+ } else {
616
+ deps.debugLog("non-account-specific response error, returning directly", {
617
+ status: response.status,
618
+ });
619
+ return finalizeResponse(response);
620
+ }
621
+ }
622
+
623
+ if (response.ok) {
624
+ accountManager.markSuccess(account);
625
+ }
626
+
627
+ const finalizeCallbacks = buildFinalizeCallbacks(response, account, accountManager, deps.debugLog);
628
+ return finalizeResponse(
629
+ response,
630
+ finalizeCallbacks.onUsage,
631
+ finalizeCallbacks.onAccountError,
632
+ finalizeCallbacks.onStreamError,
633
+ );
634
+ }
635
+
636
+ if (lastError) {
637
+ throw lastError;
638
+ }
639
+
640
+ throw new Error("All accounts exhausted — no account could serve this request");
641
+ }
642
+
643
+ return {
644
+ executeOAuthFetch,
645
+ };
646
+ }
647
+
648
+ export type RequestOrchestrationHelpers = ReturnType<typeof createRequestOrchestrationHelpers>;