@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.
- package/README.md +88 -88
- package/dist/opencode-anthropic-auth-cli.mjs +804 -507
- package/dist/opencode-anthropic-auth-plugin.js +4751 -4109
- package/package.json +67 -59
- package/src/__tests__/billing-edge-cases.test.ts +59 -59
- package/src/__tests__/bun-proxy.parallel.test.ts +388 -382
- package/src/__tests__/cc-comparison.test.ts +87 -87
- package/src/__tests__/cc-credentials.test.ts +254 -250
- package/src/__tests__/cch-drift-checker.test.ts +51 -51
- package/src/__tests__/cch-native-style.test.ts +56 -56
- package/src/__tests__/debug-gating.test.ts +42 -42
- package/src/__tests__/decomposition-smoke.test.ts +68 -68
- package/src/__tests__/fingerprint-regression.test.ts +575 -566
- package/src/__tests__/helpers/conversation-history.smoke.test.ts +271 -271
- package/src/__tests__/helpers/conversation-history.ts +119 -119
- package/src/__tests__/helpers/deferred.smoke.test.ts +103 -103
- package/src/__tests__/helpers/deferred.ts +69 -69
- package/src/__tests__/helpers/in-memory-storage.smoke.test.ts +155 -155
- package/src/__tests__/helpers/in-memory-storage.ts +88 -88
- package/src/__tests__/helpers/mock-bun-proxy.smoke.test.ts +68 -68
- package/src/__tests__/helpers/mock-bun-proxy.ts +189 -189
- package/src/__tests__/helpers/plugin-fetch-harness.smoke.test.ts +273 -273
- package/src/__tests__/helpers/plugin-fetch-harness.ts +288 -288
- package/src/__tests__/helpers/sse.smoke.test.ts +236 -236
- package/src/__tests__/helpers/sse.ts +209 -209
- package/src/__tests__/index.parallel.test.ts +605 -595
- package/src/__tests__/sanitization-regex.test.ts +112 -112
- package/src/__tests__/state-bounds.test.ts +90 -90
- package/src/account-identity.test.ts +197 -192
- package/src/account-identity.ts +69 -67
- package/src/account-state.test.ts +86 -86
- package/src/account-state.ts +25 -25
- package/src/accounts/matching.test.ts +335 -0
- package/src/accounts/matching.ts +167 -0
- package/src/accounts/persistence.test.ts +345 -0
- package/src/accounts/persistence.ts +432 -0
- package/src/accounts/repair.test.ts +276 -0
- package/src/accounts/repair.ts +407 -0
- package/src/accounts.dedup.test.ts +621 -621
- package/src/accounts.test.ts +933 -929
- package/src/accounts.ts +633 -989
- package/src/backoff.test.ts +345 -345
- package/src/backoff.ts +219 -219
- package/src/betas.ts +124 -124
- package/src/bun-fetch.test.ts +345 -342
- package/src/bun-fetch.ts +424 -424
- package/src/bun-proxy.test.ts +25 -25
- package/src/bun-proxy.ts +209 -209
- package/src/cc-credentials.ts +111 -111
- package/src/circuit-breaker.test.ts +184 -184
- package/src/circuit-breaker.ts +169 -169
- package/src/cli/commands/auth.ts +963 -0
- package/src/cli/commands/config.ts +547 -0
- package/src/cli/formatting.test.ts +406 -0
- package/src/cli/formatting.ts +219 -0
- package/src/cli.ts +255 -2022
- package/src/commands/handlers/betas.ts +100 -0
- package/src/commands/handlers/config.ts +99 -0
- package/src/commands/handlers/files.ts +375 -0
- package/src/commands/oauth-flow.ts +181 -166
- package/src/commands/prompts.ts +61 -61
- package/src/commands/router.test.ts +421 -0
- package/src/commands/router.ts +143 -635
- package/src/config.test.ts +482 -482
- package/src/config.ts +412 -404
- package/src/constants.ts +48 -48
- package/src/drift/cch-constants.ts +95 -95
- package/src/env.ts +111 -105
- package/src/headers/billing.ts +33 -33
- package/src/headers/builder.ts +130 -130
- package/src/headers/cch.ts +75 -75
- package/src/headers/stainless.ts +25 -25
- package/src/headers/user-agent.ts +23 -23
- package/src/index.ts +436 -828
- package/src/models.ts +27 -27
- package/src/oauth.test.ts +102 -102
- package/src/oauth.ts +178 -178
- package/src/parent-pid-watcher.test.ts +148 -148
- package/src/parent-pid-watcher.ts +69 -69
- package/src/plugin-helpers.ts +82 -82
- package/src/refresh-helpers.ts +145 -139
- package/src/refresh-lock.test.ts +94 -94
- package/src/refresh-lock.ts +93 -93
- package/src/request/body.history.test.ts +579 -571
- package/src/request/body.ts +255 -255
- package/src/request/metadata.ts +65 -65
- package/src/request/retry.test.ts +156 -156
- package/src/request/retry.ts +67 -67
- package/src/request/url.ts +21 -21
- package/src/request-orchestration-helpers.ts +648 -0
- package/src/response/index.ts +5 -5
- package/src/response/mcp.ts +58 -58
- package/src/response/streaming.test.ts +313 -311
- package/src/response/streaming.ts +412 -410
- package/src/rotation.test.ts +304 -301
- package/src/rotation.ts +205 -205
- package/src/storage.test.ts +547 -547
- package/src/storage.ts +315 -291
- package/src/system-prompt/builder.ts +38 -38
- package/src/system-prompt/index.ts +5 -5
- package/src/system-prompt/normalize.ts +60 -60
- package/src/system-prompt/sanitize.ts +30 -30
- package/src/thinking.ts +21 -20
- package/src/token-refresh.test.ts +265 -265
- package/src/token-refresh.ts +219 -214
- package/src/types.ts +30 -30
- package/dist/bun-proxy.mjs +0 -291
package/src/request/url.ts
CHANGED
|
@@ -3,29 +3,29 @@
|
|
|
3
3
|
// ---------------------------------------------------------------------------
|
|
4
4
|
|
|
5
5
|
export function transformRequestUrl(input: unknown): {
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
requestInput: unknown;
|
|
7
|
+
requestUrl: URL | null;
|
|
8
8
|
} {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
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>;
|