@ex-machina/opencode-anthropic-auth 2.0.0-next.0 → 2.0.0-next.2

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.
@@ -0,0 +1,354 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import { contentLength } from "./bounded.js";
3
+ import { headersAfterBodyTransform } from "./transform.js";
4
+ const MAX_RATE_LIMIT_BODY_BYTES = 64 * 1024;
5
+ const MAX_RATE_LIMIT_BODY_CHUNKS = 1024;
6
+ const DEFAULT_RATE_LIMIT_PROBE_TIMEOUT_MS = 1000;
7
+ const MAX_RATE_LIMIT_PROBE_TIMEOUT_MS = 5000;
8
+ const SAFE_GENERATED_LABEL = /^Claude OAuth • [A-F0-9]{8}$/;
9
+ const SAFE_GENERIC_LABEL = /^Anthropic(?: [1-9][0-9]{0,5})?$/;
10
+ const SAFE_REQUEST_ID = /^req_[a-z0-9][a-z0-9_-]{0,91}$/i;
11
+ const SAFE_ERROR_TYPES = new Set(['rate_limit_error']);
12
+ class ProbeTimeoutError extends Error {
13
+ }
14
+ function guardedRead(reader) {
15
+ return reader.read().then((result) => ({ ok: true, result }), (error) => ({ ok: false, error }));
16
+ }
17
+ function isRecord(value) {
18
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
19
+ }
20
+ function classifyRateLimit(message) {
21
+ if (/\b(?:usage|weekly|monthly(?: spend)?|session|subscription|plan) limit\b/i.test(message) ||
22
+ /\bquota exhausted\b/i.test(message) ||
23
+ /\bextra usage (?:is )?required\b/i.test(message)) {
24
+ return 'subscription-usage';
25
+ }
26
+ if (/\b(?:rate[- ]?limit(?:ed| exceeded| reached)?|too many requests|requests? per minute|tokens? per minute|acceleration limit|temporarily limiting requests)\b/i.test(message)) {
27
+ return 'transient-rate-limit';
28
+ }
29
+ return 'unknown-rate-limit';
30
+ }
31
+ function canonicalMessage(category) {
32
+ switch (category) {
33
+ case 'subscription-usage':
34
+ return 'Anthropic reports that this account has reached a subscription or usage limit.';
35
+ case 'transient-rate-limit':
36
+ return 'Anthropic reports a transient request rate limit.';
37
+ default:
38
+ return 'Anthropic returned an unclassified HTTP 429 response.';
39
+ }
40
+ }
41
+ function probeTimeout(options) {
42
+ const requested = options.probeTimeoutMs;
43
+ if (requested === undefined || !Number.isFinite(requested)) {
44
+ return DEFAULT_RATE_LIMIT_PROBE_TIMEOUT_MS;
45
+ }
46
+ return Math.min(MAX_RATE_LIMIT_PROBE_TIMEOUT_MS, Math.max(1, Math.floor(requested)));
47
+ }
48
+ async function readBeforeTimeout(operation, timeoutMs) {
49
+ let timer;
50
+ try {
51
+ return await Promise.race([
52
+ operation,
53
+ new Promise((_, reject) => {
54
+ timer = setTimeout(() => reject(new ProbeTimeoutError()), timeoutMs);
55
+ }),
56
+ ]);
57
+ }
58
+ finally {
59
+ if (timer)
60
+ clearTimeout(timer);
61
+ }
62
+ }
63
+ function replayResponse(response, reader, prefix, pending, sourceDone, terminalError) {
64
+ let index = 0;
65
+ let next = pending;
66
+ let done = sourceDone;
67
+ let released = false;
68
+ const release = () => {
69
+ if (released)
70
+ return;
71
+ released = true;
72
+ reader.releaseLock();
73
+ };
74
+ const body = new ReadableStream({
75
+ async pull(controller) {
76
+ if (index < prefix.length) {
77
+ const chunk = prefix[index];
78
+ index += 1;
79
+ if (chunk)
80
+ controller.enqueue(chunk);
81
+ return;
82
+ }
83
+ if (terminalError !== undefined) {
84
+ release();
85
+ controller.error(terminalError);
86
+ return;
87
+ }
88
+ if (done) {
89
+ release();
90
+ controller.close();
91
+ return;
92
+ }
93
+ try {
94
+ const outcome = next ? await next : await guardedRead(reader);
95
+ next = undefined;
96
+ if (!outcome.ok)
97
+ throw outcome.error;
98
+ const result = outcome.result;
99
+ if (result.done) {
100
+ done = true;
101
+ release();
102
+ controller.close();
103
+ return;
104
+ }
105
+ controller.enqueue(result.value);
106
+ }
107
+ catch (error) {
108
+ release();
109
+ controller.error(error);
110
+ }
111
+ },
112
+ async cancel(reason) {
113
+ try {
114
+ await reader.cancel(reason);
115
+ }
116
+ finally {
117
+ release();
118
+ }
119
+ },
120
+ });
121
+ return new Response(body, {
122
+ status: response.status,
123
+ statusText: response.statusText,
124
+ headers: response.headers,
125
+ });
126
+ }
127
+ async function probeBody(response, timeoutMs) {
128
+ if (!response.body)
129
+ return { type: 'passthrough', response };
130
+ const reader = response.body.getReader();
131
+ const decoder = new TextDecoder('utf-8', { fatal: true });
132
+ const chunks = [];
133
+ const parts = [];
134
+ const deadline = Date.now() + timeoutMs;
135
+ let chunkCount = 0;
136
+ let total = 0;
137
+ while (true) {
138
+ const remaining = deadline - Date.now();
139
+ if (remaining <= 0) {
140
+ return {
141
+ type: 'passthrough',
142
+ response: replayResponse(response, reader, chunks, undefined, false),
143
+ };
144
+ }
145
+ const operation = guardedRead(reader);
146
+ let outcome;
147
+ try {
148
+ outcome = await readBeforeTimeout(operation, remaining);
149
+ }
150
+ catch (error) {
151
+ if (error instanceof ProbeTimeoutError) {
152
+ return {
153
+ type: 'passthrough',
154
+ response: replayResponse(response, reader, chunks, operation, false),
155
+ };
156
+ }
157
+ return {
158
+ type: 'passthrough',
159
+ response: replayResponse(response, reader, chunks, undefined, false, error),
160
+ };
161
+ }
162
+ if (!outcome.ok) {
163
+ return {
164
+ type: 'passthrough',
165
+ response: replayResponse(response, reader, chunks, undefined, false, outcome.error),
166
+ };
167
+ }
168
+ const result = outcome.result;
169
+ if (result.done) {
170
+ try {
171
+ parts.push(decoder.decode());
172
+ }
173
+ catch {
174
+ return {
175
+ type: 'passthrough',
176
+ response: replayResponse(response, reader, chunks, undefined, true),
177
+ };
178
+ }
179
+ return {
180
+ type: 'complete',
181
+ text: parts.join(''),
182
+ passthrough: () => replayResponse(response, reader, chunks, undefined, true),
183
+ dispose: () => reader.releaseLock(),
184
+ };
185
+ }
186
+ chunkCount += 1;
187
+ const chunk = result.value;
188
+ if (chunk.byteLength === 0) {
189
+ if (chunkCount > MAX_RATE_LIMIT_BODY_CHUNKS) {
190
+ return {
191
+ type: 'passthrough',
192
+ response: replayResponse(response, reader, chunks, undefined, false),
193
+ };
194
+ }
195
+ continue;
196
+ }
197
+ chunks.push(chunk);
198
+ if (chunkCount > MAX_RATE_LIMIT_BODY_CHUNKS) {
199
+ return {
200
+ type: 'passthrough',
201
+ response: replayResponse(response, reader, chunks, undefined, false),
202
+ };
203
+ }
204
+ total += chunk.byteLength;
205
+ if (total > MAX_RATE_LIMIT_BODY_BYTES) {
206
+ return {
207
+ type: 'passthrough',
208
+ response: replayResponse(response, reader, chunks, undefined, false),
209
+ };
210
+ }
211
+ try {
212
+ parts.push(decoder.decode(chunk, { stream: true }));
213
+ }
214
+ catch {
215
+ return {
216
+ type: 'passthrough',
217
+ response: replayResponse(response, reader, chunks, undefined, false),
218
+ };
219
+ }
220
+ }
221
+ }
222
+ function safeRequestID(value) {
223
+ return typeof value === 'string' && SAFE_REQUEST_ID.test(value)
224
+ ? value
225
+ : undefined;
226
+ }
227
+ function retryAfterDiagnostic(headers) {
228
+ const value = headers.get('retry-after');
229
+ if (!value)
230
+ return undefined;
231
+ const seconds = Number(value);
232
+ if (Number.isFinite(seconds) && seconds >= 0 && seconds <= 24 * 60 * 60) {
233
+ return `${seconds}s`;
234
+ }
235
+ const timestamp = Date.parse(value);
236
+ if (!Number.isNaN(timestamp))
237
+ return new Date(timestamp).toUTCString();
238
+ return undefined;
239
+ }
240
+ function jsonMediaType(headers) {
241
+ const mediaType = headers
242
+ .get('content-type')
243
+ ?.split(';', 1)[0]
244
+ ?.trim()
245
+ .toLowerCase();
246
+ return (mediaType === 'application/json' || mediaType?.endsWith('+json') === true);
247
+ }
248
+ export function createConnectionLabel(entropy = randomBytes(4)) {
249
+ const suffix = Array.from(entropy.slice(0, 4), (byte) => byte.toString(16).padStart(2, '0'))
250
+ .join('')
251
+ .toUpperCase();
252
+ if (suffix.length !== 8)
253
+ throw new Error('Connection label entropy is too short');
254
+ return `Claude OAuth • ${suffix}`;
255
+ }
256
+ export function describeConnection(connection) {
257
+ if (connection.type !== 'credential' || !connection.id) {
258
+ return 'Non-credential Anthropic connection';
259
+ }
260
+ const fingerprint = createHash('sha256')
261
+ .update(connection.id, 'utf8')
262
+ .digest('hex')
263
+ .slice(0, 10);
264
+ const label = typeof connection.label === 'string' &&
265
+ (SAFE_GENERIC_LABEL.test(connection.label) ||
266
+ SAFE_GENERATED_LABEL.test(connection.label))
267
+ ? connection.label
268
+ : 'Custom label hidden';
269
+ return `${label} [connection ${fingerprint}]`;
270
+ }
271
+ export function isSubscriptionUsageDiagnostic(message) {
272
+ return message.startsWith('[anthropic-auth category=subscription-usage;');
273
+ }
274
+ export async function enhanceRateLimitResponse(response, connection, options = {}) {
275
+ const unknown = {
276
+ response,
277
+ category: 'unknown-rate-limit',
278
+ };
279
+ if (response.status !== 429 ||
280
+ !response.body ||
281
+ !jsonMediaType(response.headers)) {
282
+ return unknown;
283
+ }
284
+ const declaredLength = contentLength(response.headers);
285
+ if (declaredLength !== undefined &&
286
+ declaredLength > MAX_RATE_LIMIT_BODY_BYTES) {
287
+ return unknown;
288
+ }
289
+ const probe = await probeBody(response, probeTimeout(options));
290
+ if (probe.type === 'passthrough') {
291
+ return { response: probe.response, category: 'unknown-rate-limit' };
292
+ }
293
+ let parsed;
294
+ try {
295
+ parsed = JSON.parse(probe.text);
296
+ }
297
+ catch {
298
+ return { response: probe.passthrough(), category: 'unknown-rate-limit' };
299
+ }
300
+ if (!isRecord(parsed)) {
301
+ return { response: probe.passthrough(), category: 'unknown-rate-limit' };
302
+ }
303
+ const root = parsed;
304
+ const error = root.error;
305
+ if (!isRecord(error)) {
306
+ return { response: probe.passthrough(), category: 'unknown-rate-limit' };
307
+ }
308
+ if (typeof error.message !== 'string' || error.message.trim().length === 0) {
309
+ return { response: probe.passthrough(), category: 'unknown-rate-limit' };
310
+ }
311
+ const category = classifyRateLimit(error.message);
312
+ const headers = headersAfterBodyTransform(response.headers);
313
+ headers.set('content-type', 'application/json');
314
+ if (category === 'subscription-usage')
315
+ headers.set('x-should-retry', 'false');
316
+ for (const name of ['request-id', 'x-request-id']) {
317
+ const value = headers.get(name);
318
+ if (value && !safeRequestID(value))
319
+ headers.delete(name);
320
+ }
321
+ const retryAfter = retryAfterDiagnostic(headers);
322
+ if (headers.has('retry-after') && !retryAfter)
323
+ headers.delete('retry-after');
324
+ const requestID = safeRequestID(headers.get('request-id')) ??
325
+ safeRequestID(headers.get('x-request-id')) ??
326
+ safeRequestID(root.request_id);
327
+ const errorType = typeof error.type === 'string' && SAFE_ERROR_TYPES.has(error.type)
328
+ ? error.type
329
+ : 'rate_limit_error';
330
+ const details = [
331
+ `category=${category}`,
332
+ `active=${connection}`,
333
+ ...(retryAfter ? [`retry-after=${retryAfter}`] : []),
334
+ ...(requestID ? [`request-id=${requestID}`] : []),
335
+ ];
336
+ const action = category === 'subscription-usage'
337
+ ? ' Verify or explicitly switch the active Anthropic connection; no automatic account fallback was attempted.'
338
+ : '';
339
+ const message = `[anthropic-auth ${details.join('; ')}] ${canonicalMessage(category)}${action}`;
340
+ const body = JSON.stringify({
341
+ type: 'error',
342
+ error: { type: errorType, message },
343
+ ...(requestID ? { request_id: requestID } : {}),
344
+ });
345
+ probe.dispose();
346
+ return {
347
+ category,
348
+ response: new Response(body, {
349
+ status: response.status,
350
+ statusText: response.statusText,
351
+ headers,
352
+ }),
353
+ };
354
+ }
@@ -1,5 +1,33 @@
1
+ import { MAX_JSON_TOOL_NAME_BYTES } from './json-response-stream.ts';
1
2
  export declare const MAX_SSE_LINE_BYTES: number;
2
- export declare const MAX_JSON_TOOL_NAME_BYTES = 1024;
3
+ export declare const MAX_SSE_EVENT_BYTES: number;
4
+ export { MAX_JSON_TOOL_NAME_BYTES };
5
+ type ToolNameAliasOptions = {
6
+ maxEntries?: number;
7
+ maxBytes?: number;
8
+ };
9
+ /**
10
+ * Reversible tool-name aliases bounded to Anthropic's 64-byte limit.
11
+ *
12
+ * Names up to 44 UTF-8 bytes are encoded inline. Longer names use a stable
13
+ * SHA-256 alias and are retained in a bounded table because a lossless,
14
+ * stateless 64-byte source -> 60-byte payload mapping is impossible.
15
+ */
16
+ export declare class ToolNameAliasTable {
17
+ private readonly maxEntries;
18
+ private readonly maxBytes;
19
+ private readonly longByName;
20
+ private readonly longByAlias;
21
+ private retainedBytes;
22
+ private disposed;
23
+ constructor(options?: ToolNameAliasOptions);
24
+ private assertActive;
25
+ encode(name: string): string;
26
+ decode(alias: string): string | undefined;
27
+ get hasStatefulAliases(): boolean;
28
+ dispose(): void;
29
+ }
30
+ export declare function headersAfterBodyTransform(source: Headers): Headers;
3
31
  export type FetchInput = string | URL | Request;
4
32
  /**
5
33
  * Merge headers from a Request object and/or a RequestInit headers value
@@ -14,26 +42,27 @@ export declare function mergeBetaHeaders(headers: Headers): string;
14
42
  * Set OAuth-required headers on the request: authorization, beta, user-agent.
15
43
  * Removes x-api-key since we're using OAuth.
16
44
  */
17
- export declare function setOAuthHeaders(headers: Headers, accessToken: string): Headers;
45
+ export declare function setOAuthHeaders(headers: Headers, accessToken: string, version?: string): Headers;
18
46
  /**
19
47
  * Add TOOL_PREFIX to tool names in the request body.
20
48
  * Prefixes both tool definitions and tool_use blocks in messages.
21
49
  */
22
- export declare function prefixToolNames(parsed: Record<string, unknown>): string;
50
+ export declare function prefixToolNames(parsed: Record<string, unknown>, alreadyPrefixed?: boolean, aliases?: ToolNameAliasTable): string;
23
51
  /**
24
52
  * Strip TOOL_PREFIX from tool names in streaming response text.
25
53
  */
26
- export declare function stripToolPrefix(text: string): string;
54
+ export declare function stripToolPrefix(text: string, aliases?: ToolNameAliasTable): string;
55
+ /** Check whether TLS verification was explicitly requested off for a custom endpoint. */
56
+ export declare function isInsecure(): boolean;
27
57
  /**
28
- * Check if TLS verification should be skipped for custom API endpoints.
29
- * Only effective when ANTHROPIC_BASE_URL is also set.
58
+ * Allow OAuth bearer credentials only for Anthropic's official API or the
59
+ * exact origin of an explicitly configured, validated custom endpoint.
30
60
  */
31
- export declare function isInsecure(): boolean;
61
+ export declare function isTrustedAnthropicUrl(input: string | URL): boolean;
32
62
  /**
33
- * Rewrite the request URL to add ?beta=true for /v1/messages requests.
34
- * When ANTHROPIC_BASE_URL is set, overrides the origin (protocol + host)
35
- * for all API requests flowing through the fetch wrapper.
36
- * Returns the modified input and URL (if applicable).
63
+ * Rewrite the request URL to add ?beta=true for /v1/messages requests. When
64
+ * ANTHROPIC_BASE_URL is set, override the origin for API requests while
65
+ * retaining the original path and query.
37
66
  */
38
67
  export declare function rewriteUrl(input: FetchInput): {
39
68
  input: FetchInput;
@@ -67,9 +96,8 @@ export declare function prependClaudeCodeIdentity(system: unknown): SystemBlock[
67
96
  /**
68
97
  * Rewrite the full request body: sanitize system prompt and prefix tool names.
69
98
  */
70
- export declare function rewriteRequestBody(body: string): string;
99
+ export declare function rewriteRequestBody(body: string, version?: string, aliases?: ToolNameAliasTable): string;
71
100
  /**
72
101
  * Create a streaming response that strips the tool prefix from tool names.
73
102
  */
74
- export declare function createStrippedStream(response: Response): Response;
75
- export {};
103
+ export declare function createStrippedStream(response: Response, aliases?: ToolNameAliasTable, onFinalize?: () => void): Response;