@ex-machina/opencode-anthropic-auth 2.0.0-next.1 → 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.
package/dist/index.js CHANGED
@@ -1,11 +1,102 @@
1
- import { Plugin } from '@opencode-ai/plugin';
1
+ import { createHash, createHmac, randomBytes } from 'node:crypto';
2
+ import { Plugin } from '@opencode/plugin';
2
3
  import { authorize, exchange, refreshToken } from "./auth.js";
4
+ import { BodyLimitError, contentLength, readBoundedText } from "./bounded.js";
3
5
  import { resolveClaudeCodeVersion } from "./config.js";
4
6
  import { CLAUDE_CODE_VERSION, REQUIRED_BETAS } from "./constants.js";
5
- import { createStrippedStream, isInsecure, mergeHeaders, rewriteRequestBody, rewriteUrl, setOAuthHeaders, } from "./transform.js";
7
+ import { createConnectionLabel, describeConnection, enhanceRateLimitResponse, } from "./rate-limit.js";
8
+ import { createStrippedStream, headersAfterBodyTransform, isInsecure, isTrustedAnthropicUrl, mergeHeaders, rewriteRequestBody, rewriteUrl, setOAuthHeaders, ToolNameAliasTable, } from "./transform.js";
6
9
  const PLUGIN_ID = 'ex-machina.anthropic-auth';
7
10
  const INTEGRATION_ID = 'anthropic';
8
11
  const REFRESH_CACHE_GRACE_MS = 30_000;
12
+ const MAX_REQUEST_BODY_BYTES = 10 * 1024 * 1024;
13
+ const MAX_ACTIVE_ALIAS_REQUESTS = 256;
14
+ const MAX_REQUEST_ALIAS_ENTRIES = 256;
15
+ const MAX_REQUEST_ALIAS_BYTES = 16 * 1024;
16
+ const ALIAS_REQUEST_TTL_MS = 5 * 60_000;
17
+ const MAX_BLOCKED_REFRESH_TOKENS = 1024;
18
+ const BLOCKED_REFRESH_FILTER_BYTES = 8 * 1024;
19
+ const BLOCKED_REFRESH_FILTER_HASHES = 4;
20
+ const MAX_REFRESH_TOKEN_BYTES = 8 * 1024;
21
+ const MAX_REFRESH_IN_FLIGHT = 256;
22
+ const MAX_REFRESH_CACHE_ENTRIES = 256;
23
+ const MAX_TRACKED_CONNECTIONS = 256;
24
+ const CONNECTION_TRACKING_TTL_MS = 5 * 60_000;
25
+ const MAX_ACTIVE_RESPONSE_TRANSFORMS = 256;
26
+ const MAX_RECONSTRUCTED_ALIAS_LOOKUPS = 32;
27
+ const MAX_TRACKED_REQUEST_URL_BYTES = 8 * 1024;
28
+ const UNKNOWN_CONNECTION = 'Unknown OAuth connection';
29
+ const AMBIGUOUS_CONNECTION = 'Ambiguous OAuth connection';
30
+ // setup() is location-scoped while the credential store is process-global.
31
+ // Share only active rotations; settled credentials remain location-local.
32
+ const refreshInFlight = new Map();
33
+ const blockedRefreshTokens = new Map();
34
+ // Overflow identities intentionally never expire, rotate, or lose bits:
35
+ // forgetting one could replay a refresh token whose prior outcome was
36
+ // ambiguous. The fixed-size filter bounds memory and fails closed; its
37
+ // false-positive probability therefore grows over the process lifetime.
38
+ const blockedRefreshTokenFilter = new Uint8Array(BLOCKED_REFRESH_FILTER_BYTES);
39
+ let activeResponseTransforms = 0;
40
+ function acquireResponseTransform() {
41
+ if (activeResponseTransforms >= MAX_ACTIVE_RESPONSE_TRANSFORMS) {
42
+ throw new Error('Too many active Anthropic response transforms');
43
+ }
44
+ activeResponseTransforms += 1;
45
+ let active = true;
46
+ return () => {
47
+ if (!active)
48
+ return;
49
+ active = false;
50
+ activeResponseTransforms -= 1;
51
+ };
52
+ }
53
+ function refreshTokenKey(refreshToken) {
54
+ if (refreshToken.length === 0 ||
55
+ refreshToken.length > MAX_REFRESH_TOKEN_BYTES) {
56
+ return undefined;
57
+ }
58
+ const bytes = new TextEncoder().encode(refreshToken);
59
+ if (bytes.byteLength > MAX_REFRESH_TOKEN_BYTES)
60
+ return undefined;
61
+ return createHash('sha256').update(bytes).digest('base64url');
62
+ }
63
+ function isAmbiguousRefreshFailure(status) {
64
+ return status === 0 || status >= 500 || (status >= 200 && status < 300);
65
+ }
66
+ function blockedRefreshFilterIndexes(key) {
67
+ const digest = createHash('sha256').update(`blocked:${key}`).digest();
68
+ const bits = BLOCKED_REFRESH_FILTER_BYTES * 8;
69
+ return Array.from({ length: BLOCKED_REFRESH_FILTER_HASHES }, (_, index) => digest.readUInt32BE(index * 4) % bits);
70
+ }
71
+ function addBlockedRefreshToFilter(key) {
72
+ for (const index of blockedRefreshFilterIndexes(key)) {
73
+ const byteIndex = index >> 3;
74
+ blockedRefreshTokenFilter[byteIndex] =
75
+ (blockedRefreshTokenFilter[byteIndex] ?? 0) | (1 << (index & 7));
76
+ }
77
+ }
78
+ function blockedRefreshFilterHas(key) {
79
+ return blockedRefreshFilterIndexes(key).every((index) => ((blockedRefreshTokenFilter[index >> 3] ?? 0) & (1 << (index & 7))) !== 0);
80
+ }
81
+ function blockRefreshToken(key, status) {
82
+ if (blockedRefreshTokens.has(key) || blockedRefreshFilterHas(key))
83
+ return;
84
+ if (blockedRefreshTokens.size >= MAX_BLOCKED_REFRESH_TOKENS) {
85
+ addBlockedRefreshToFilter(key);
86
+ return;
87
+ }
88
+ blockedRefreshTokens.set(key, status);
89
+ }
90
+ function blockedRefreshError(key) {
91
+ const status = blockedRefreshTokens.get(key);
92
+ if (status !== undefined) {
93
+ return new Error(`Anthropic token refresh blocked after ambiguous failure: ${status}`);
94
+ }
95
+ if (blockedRefreshFilterHas(key)) {
96
+ return new Error('Anthropic token refresh blocked after an ambiguous failure');
97
+ }
98
+ return undefined;
99
+ }
9
100
  // `methodID` is a branded `Integration.MethodID` at the type level (a
10
101
  // compile-time-only tag — there's no runtime representation), so a plain
11
102
  // string literal needs a cast to satisfy the branded field.
@@ -25,10 +116,57 @@ async function resolveActiveOAuth(ctx) {
25
116
  return undefined;
26
117
  const credential = await ctx.integration.connection.resolve(connection);
27
118
  if (credential?.type === 'oauth' && credential.methodID === METHOD_ID) {
28
- return credential;
119
+ return { connection, credential };
29
120
  }
30
121
  return undefined;
31
122
  }
123
+ function hasTransformedOAuthShape(request) {
124
+ const url = new URL(request.url);
125
+ const betas = new Set((request.headers.get('anthropic-beta') ?? '')
126
+ .split(',')
127
+ .map((beta) => beta.trim()));
128
+ return (request.method === 'POST' &&
129
+ isTrustedAnthropicUrl(url) &&
130
+ request.headers.get('authorization')?.startsWith('Bearer ') === true &&
131
+ REQUIRED_BETAS.every((beta) => betas.has(beta)) &&
132
+ url.pathname === '/v1/messages');
133
+ }
134
+ const ownershipEncoder = new TextEncoder();
135
+ function updateFramed(hmac, bytes) {
136
+ const length = new Uint8Array(8);
137
+ new DataView(length.buffer).setBigUint64(0, BigInt(bytes.byteLength));
138
+ hmac.update(length);
139
+ hmac.update(bytes);
140
+ }
141
+ function requestLeaseKey(ownershipKey, request, body) {
142
+ const authorizationKey = requestAuthorizationKey(request);
143
+ if (!authorizationKey)
144
+ return undefined;
145
+ const urlBytes = ownershipEncoder.encode(request.url);
146
+ if (urlBytes.byteLength > MAX_TRACKED_REQUEST_URL_BYTES)
147
+ return undefined;
148
+ const bodyBytes = ownershipEncoder.encode(body);
149
+ const hmac = createHmac('sha256', ownershipKey);
150
+ hmac.update('opencode-anthropic-auth/request-ownership/v1');
151
+ updateFramed(hmac, ownershipEncoder.encode(request.method));
152
+ updateFramed(hmac, urlBytes);
153
+ updateFramed(hmac, ownershipEncoder.encode(authorizationKey));
154
+ updateFramed(hmac, bodyBytes);
155
+ return `${bodyBytes.byteLength}:${hmac.digest('base64url')}`;
156
+ }
157
+ function requestAuthorizationKey(request) {
158
+ const authorization = request.headers.get('authorization');
159
+ if (!authorization?.startsWith('Bearer '))
160
+ return undefined;
161
+ const token = authorization.slice('Bearer '.length);
162
+ if (token.length === 0 || token.length > MAX_REFRESH_TOKEN_BYTES) {
163
+ return undefined;
164
+ }
165
+ const bytes = new TextEncoder().encode(token);
166
+ if (bytes.byteLength > MAX_REFRESH_TOKEN_BYTES)
167
+ return undefined;
168
+ return createHash('sha256').update(bytes).digest('base64url');
169
+ }
32
170
  function warnIfInsecureUnsupported() {
33
171
  if (!isInsecure())
34
172
  return;
@@ -37,20 +175,12 @@ function warnIfInsecureUnsupported() {
37
175
  'ANTHROPIC_BASE_URL endpoint. TLS verification remains enabled — ' +
38
176
  'requests to an untrusted/self-signed endpoint will fail.');
39
177
  }
40
- function isTransformedOAuthRequest(request) {
41
- const betas = new Set((request.headers.get('anthropic-beta') ?? '')
42
- .split(',')
43
- .map((beta) => beta.trim()));
44
- return (request.headers.get('authorization')?.startsWith('Bearer ') === true &&
45
- REQUIRED_BETAS.every((beta) => betas.has(beta)) &&
46
- new URL(request.url).searchParams.get('beta') === 'true');
47
- }
48
178
  export default Plugin.define({
49
179
  id: PLUGIN_ID,
50
180
  setup: async (ctx) => {
51
181
  warnIfInsecureUnsupported();
52
- // Resolved once per plugin instance so every request reports the same
53
- // version in both the user-agent and the billing header.
182
+ // Resolve once so user-agent and billing metadata agree for every request
183
+ // handled by this plugin generation.
54
184
  const versionResolution = resolveClaudeCodeVersion();
55
185
  if (versionResolution.type === 'invalid') {
56
186
  console.error(`[ex-machina.anthropic-auth] ${versionResolution.error}`);
@@ -58,42 +188,261 @@ export default Plugin.define({
58
188
  else if (versionResolution.type === 'outdated') {
59
189
  console.warn(`[ex-machina.anthropic-auth] ${versionResolution.warning}`);
60
190
  }
61
- // Only a malformed override lacks a usable version; an outdated one was
62
- // set deliberately, so it is reported as configured.
63
191
  const claudeCodeVersion = versionResolution.type === 'invalid'
64
192
  ? CLAUDE_CODE_VERSION
65
193
  : versionResolution.version;
66
- // Retain successful refreshes for this plugin generation so a host call
194
+ const aliasesByRequest = new WeakMap();
195
+ const aliasesByFingerprint = new Map();
196
+ const transformedRequests = new WeakSet();
197
+ const ownershipKey = randomBytes(32);
198
+ let reconstructedAliasLookups = 0;
199
+ let setupActive = true;
200
+ const connectionByRequest = new WeakMap();
201
+ const connectionByAuthorization = new Map();
202
+ const rememberConnection = (request, description) => {
203
+ connectionByRequest.set(request, description);
204
+ const key = requestAuthorizationKey(request);
205
+ if (!key)
206
+ return;
207
+ const existing = connectionByAuthorization.get(key);
208
+ if (!existing &&
209
+ connectionByAuthorization.size >= MAX_TRACKED_CONNECTIONS) {
210
+ return;
211
+ }
212
+ if (existing)
213
+ clearTimeout(existing.timer);
214
+ const retainedDescription = !existing || existing.description === description
215
+ ? description
216
+ : AMBIGUOUS_CONNECTION;
217
+ let entry;
218
+ const timer = setTimeout(() => {
219
+ if (connectionByAuthorization.get(key) === entry) {
220
+ connectionByAuthorization.delete(key);
221
+ }
222
+ }, CONNECTION_TRACKING_TTL_MS);
223
+ timer.unref?.();
224
+ entry = { description: retainedDescription, timer };
225
+ connectionByAuthorization.set(key, entry);
226
+ };
227
+ const connectionForRequest = (request) => {
228
+ const direct = connectionByRequest.get(request);
229
+ if (direct)
230
+ return direct;
231
+ const key = requestAuthorizationKey(request);
232
+ if (!key)
233
+ return UNKNOWN_CONNECTION;
234
+ return (connectionByAuthorization.get(key)?.description ?? UNKNOWN_CONNECTION);
235
+ };
236
+ const clearAliasLeaseTimer = (lease) => {
237
+ if (lease.timer === undefined)
238
+ return;
239
+ clearTimeout(lease.timer);
240
+ lease.timer = undefined;
241
+ };
242
+ const expireAliasLease = (lease) => {
243
+ if (!lease.active)
244
+ return;
245
+ lease.active = false;
246
+ if (aliasesByFingerprint.get(lease.key) === lease) {
247
+ aliasesByFingerprint.delete(lease.key);
248
+ }
249
+ clearAliasLeaseTimer(lease);
250
+ lease.aliases.dispose();
251
+ };
252
+ const scheduleAliasLeaseExpiry = (lease) => {
253
+ if (!lease.active || lease.retired || lease.activeResponses > 0)
254
+ return;
255
+ clearAliasLeaseTimer(lease);
256
+ const timer = setTimeout(() => {
257
+ if (lease.timer !== timer)
258
+ return;
259
+ lease.timer = undefined;
260
+ if (lease.activeResponses === 0)
261
+ expireAliasLease(lease);
262
+ }, ALIAS_REQUEST_TTL_MS);
263
+ timer.unref?.();
264
+ lease.timer = timer;
265
+ };
266
+ const beginAliasResponse = (lease) => {
267
+ if (!lease.active || lease.retired)
268
+ return;
269
+ if (lease.references <= lease.activeResponses)
270
+ lease.references += 1;
271
+ lease.activeResponses += 1;
272
+ clearAliasLeaseTimer(lease);
273
+ };
274
+ const retireAliasLease = (lease) => {
275
+ if (!lease.active || lease.retired)
276
+ return;
277
+ lease.retired = true;
278
+ if (aliasesByFingerprint.get(lease.key) === lease) {
279
+ aliasesByFingerprint.delete(lease.key);
280
+ }
281
+ clearAliasLeaseTimer(lease);
282
+ if (lease.activeResponses === 0) {
283
+ expireAliasLease(lease);
284
+ }
285
+ else {
286
+ lease.references = lease.activeResponses;
287
+ }
288
+ };
289
+ const releaseAliasLease = (request, lease) => {
290
+ aliasesByRequest.delete(request);
291
+ if (!lease.active)
292
+ return;
293
+ if (lease.activeResponses > 0)
294
+ lease.activeResponses -= 1;
295
+ lease.references -= 1;
296
+ if (lease.references <= 0 ||
297
+ (lease.retired && lease.activeResponses === 0)) {
298
+ expireAliasLease(lease);
299
+ }
300
+ else {
301
+ scheduleAliasLeaseExpiry(lease);
302
+ }
303
+ };
304
+ const registerAliasLease = (request, body, aliases) => {
305
+ const key = requestLeaseKey(ownershipKey, request, body);
306
+ if (!key) {
307
+ aliases.dispose();
308
+ throw new Error('Unable to track transformed Anthropic request');
309
+ }
310
+ const existing = aliasesByFingerprint.get(key);
311
+ if (existing?.active && !existing.retired) {
312
+ aliases.dispose();
313
+ existing.references += 1;
314
+ aliasesByRequest.set(request, existing);
315
+ scheduleAliasLeaseExpiry(existing);
316
+ return;
317
+ }
318
+ if (aliasesByFingerprint.size >= MAX_ACTIVE_ALIAS_REQUESTS) {
319
+ aliases.dispose();
320
+ throw new Error('Too many active Anthropic tool-name alias mappings');
321
+ }
322
+ const lease = {
323
+ key,
324
+ aliases,
325
+ references: 1,
326
+ activeResponses: 0,
327
+ active: true,
328
+ retired: false,
329
+ timer: undefined,
330
+ };
331
+ aliasesByFingerprint.set(key, lease);
332
+ aliasesByRequest.set(request, lease);
333
+ scheduleAliasLeaseExpiry(lease);
334
+ };
335
+ const resolveAliasLease = async (request) => {
336
+ const direct = aliasesByRequest.get(request);
337
+ if (direct?.active && !direct.retired) {
338
+ beginAliasResponse(direct);
339
+ return direct;
340
+ }
341
+ if (direct)
342
+ aliasesByRequest.delete(request);
343
+ if (!request.body)
344
+ return undefined;
345
+ if (request.bodyUsed || request.body.locked)
346
+ return undefined;
347
+ if (reconstructedAliasLookups >= MAX_RECONSTRUCTED_ALIAS_LOOKUPS) {
348
+ return undefined;
349
+ }
350
+ reconstructedAliasLookups += 1;
351
+ try {
352
+ const declaredLength = contentLength(request.headers);
353
+ if (declaredLength !== undefined &&
354
+ declaredLength > MAX_REQUEST_BODY_BYTES) {
355
+ throw new BodyLimitError('Anthropic request body', MAX_REQUEST_BODY_BYTES);
356
+ }
357
+ const body = await readBoundedText(request.clone().body, MAX_REQUEST_BODY_BYTES, 'Anthropic request body');
358
+ const key = requestLeaseKey(ownershipKey, request, body);
359
+ if (!key)
360
+ return undefined;
361
+ const lease = aliasesByFingerprint.get(key);
362
+ if (!lease?.active)
363
+ return undefined;
364
+ beginAliasResponse(lease);
365
+ aliasesByRequest.set(request, lease);
366
+ return lease;
367
+ }
368
+ finally {
369
+ reconstructedAliasLookups -= 1;
370
+ }
371
+ };
372
+ // Retain successful refreshes for this location so a host call
67
373
  // holding the rotated token cannot submit it again before persistence.
68
- const refreshInFlight = new Map();
374
+ const refreshCache = new Map();
375
+ const refreshCacheTimers = new Map();
376
+ const removeRefreshCacheEntry = (key, expected) => {
377
+ if (expected && refreshCache.get(key) !== expected)
378
+ return;
379
+ refreshCache.delete(key);
380
+ const timer = refreshCacheTimers.get(key);
381
+ if (timer)
382
+ clearTimeout(timer);
383
+ refreshCacheTimers.delete(key);
384
+ };
385
+ const makeRefreshCacheRoom = () => {
386
+ if (refreshCache.size < MAX_REFRESH_CACHE_ENTRIES)
387
+ return;
388
+ throw new Error('Anthropic token refresh blocked until the consumed-token cache expires');
389
+ };
69
390
  const refreshCredential = async (credential) => {
70
- const existing = refreshInFlight.get(credential.refresh);
391
+ const key = refreshTokenKey(credential.refresh);
392
+ if (!key)
393
+ throw new Error('Anthropic token refresh failed: 400');
394
+ const existing = refreshCache.get(key);
71
395
  if (existing)
72
396
  return existing;
73
- const pending = (async () => {
74
- const result = await refreshToken(credential.refresh);
75
- if (result.type === 'failed') {
76
- throw new Error(`Anthropic token refresh failed: ${result.status}`);
77
- }
78
- return toCredential(result);
79
- })();
80
- refreshInFlight.set(credential.refresh, pending);
397
+ const shared = refreshInFlight.get(key);
398
+ if (!shared) {
399
+ const blocked = blockedRefreshError(key);
400
+ if (blocked)
401
+ throw blocked;
402
+ }
403
+ if (!shared && refreshInFlight.size >= MAX_REFRESH_IN_FLIGHT) {
404
+ throw new Error('Too many active Anthropic token refreshes');
405
+ }
406
+ makeRefreshCacheRoom();
407
+ const pending = shared ??
408
+ (async () => {
409
+ let result;
410
+ try {
411
+ result = await refreshToken(credential.refresh);
412
+ }
413
+ catch (error) {
414
+ blockRefreshToken(key, 0);
415
+ throw error;
416
+ }
417
+ if (result.type === 'failed') {
418
+ if (isAmbiguousRefreshFailure(result.status)) {
419
+ blockRefreshToken(key, result.status);
420
+ }
421
+ throw new Error(`Anthropic token refresh failed: ${result.status}`);
422
+ }
423
+ return toCredential(result);
424
+ })();
425
+ refreshCache.set(key, pending);
426
+ if (!shared)
427
+ refreshInFlight.set(key, pending);
81
428
  try {
82
429
  const rotated = await pending;
83
- const timer = setTimeout(() => {
84
- if (refreshInFlight.get(credential.refresh) === pending) {
85
- refreshInFlight.delete(credential.refresh);
86
- }
87
- }, REFRESH_CACHE_GRACE_MS);
88
- timer.unref?.();
430
+ if (refreshCache.get(key) === pending) {
431
+ const timer = setTimeout(() => removeRefreshCacheEntry(key, pending), REFRESH_CACHE_GRACE_MS);
432
+ timer.unref?.();
433
+ refreshCacheTimers.set(key, timer);
434
+ }
89
435
  return rotated;
90
436
  }
91
437
  catch (error) {
92
- if (refreshInFlight.get(credential.refresh) === pending) {
93
- refreshInFlight.delete(credential.refresh);
94
- }
438
+ removeRefreshCacheEntry(key, pending);
95
439
  throw error;
96
440
  }
441
+ finally {
442
+ if (!shared && refreshInFlight.get(key) === pending) {
443
+ refreshInFlight.delete(key);
444
+ }
445
+ }
97
446
  };
98
447
  await ctx.integration.transform((draft) => {
99
448
  draft.method.update({
@@ -120,43 +469,161 @@ export default Plugin.define({
120
469
  };
121
470
  },
122
471
  refresh: refreshCredential,
472
+ label: () => createConnectionLabel(),
123
473
  });
124
474
  });
125
475
  await ctx.session.hook('http.request', async (event) => {
476
+ if (!setupActive)
477
+ return;
126
478
  if (event.model.providerID !== INTEGRATION_ID)
127
479
  return;
128
- const credential = await resolveActiveOAuth(ctx);
129
- if (!credential)
480
+ const active = await resolveActiveOAuth(ctx);
481
+ if (!setupActive || !active)
130
482
  return;
483
+ const { credential } = active;
484
+ const connectionDescription = describeConnection(active.connection);
131
485
  const request = event.request;
132
- const hasBody = request.method !== 'GET' && request.method !== 'HEAD';
133
- const bodyText = hasBody ? await request.clone().text() : undefined;
134
- const rewrittenBody = bodyText !== undefined
135
- ? rewriteRequestBody(bodyText, claudeCodeVersion)
486
+ const rewritten = rewriteUrl(request);
487
+ if (!rewritten.url || !isTrustedAnthropicUrl(rewritten.url)) {
488
+ throw new Error('Refusing to send Anthropic OAuth credentials to an untrusted origin');
489
+ }
490
+ const pathname = rewritten.url.pathname;
491
+ const transformsBody = request.method === 'POST' &&
492
+ (pathname === '/v1/messages' ||
493
+ pathname === '/v1/messages/count_tokens');
494
+ if (!transformsBody) {
495
+ const headers = mergeHeaders(request);
496
+ setOAuthHeaders(headers, credential.access, claudeCodeVersion);
497
+ const routedRequest = rewritten.input instanceof Request
498
+ ? rewritten.input
499
+ : new Request(rewritten.url.toString(), request);
500
+ event.request = new Request(routedRequest, {
501
+ headers,
502
+ signal: request.signal,
503
+ redirect: 'error',
504
+ });
505
+ rememberConnection(event.request, connectionDescription);
506
+ return;
507
+ }
508
+ const hasBody = request.body !== null;
509
+ const declaredLength = contentLength(request.headers);
510
+ if (hasBody &&
511
+ declaredLength !== undefined &&
512
+ declaredLength > MAX_REQUEST_BODY_BYTES) {
513
+ throw new BodyLimitError('Anthropic request body', MAX_REQUEST_BODY_BYTES);
514
+ }
515
+ const bodyText = hasBody
516
+ ? await readBoundedText(request.clone().body, MAX_REQUEST_BODY_BYTES, 'Anthropic request body')
136
517
  : undefined;
137
- const headers = mergeHeaders(request);
518
+ if (!setupActive)
519
+ return;
520
+ const aliases = new ToolNameAliasTable({
521
+ maxEntries: MAX_REQUEST_ALIAS_ENTRIES,
522
+ maxBytes: MAX_REQUEST_ALIAS_BYTES,
523
+ });
524
+ let rewrittenBody;
525
+ try {
526
+ rewrittenBody =
527
+ bodyText !== undefined
528
+ ? rewriteRequestBody(bodyText, claudeCodeVersion, aliases)
529
+ : undefined;
530
+ }
531
+ catch (error) {
532
+ aliases.dispose();
533
+ throw error;
534
+ }
535
+ const bodyChanged = bodyText !== undefined && rewrittenBody !== bodyText;
536
+ const headers = bodyChanged
537
+ ? headersAfterBodyTransform(mergeHeaders(request))
538
+ : mergeHeaders(request);
138
539
  setOAuthHeaders(headers, credential.access, claudeCodeVersion);
139
- if (rewrittenBody !== undefined)
140
- headers.delete('content-length');
141
- const { input: rewrittenInput } = rewriteUrl(request.url);
142
- const url = typeof rewrittenInput === 'string'
143
- ? rewrittenInput
144
- : rewrittenInput instanceof Request
145
- ? rewrittenInput.url
146
- : rewrittenInput.toString();
147
- event.request = new Request(url, {
540
+ const rewrittenRequest = new Request(rewritten.url.toString(), {
148
541
  method: request.method,
149
542
  headers,
150
543
  body: rewrittenBody,
151
544
  signal: request.signal,
545
+ redirect: 'error',
152
546
  });
547
+ event.request = rewrittenRequest;
548
+ if (pathname === '/v1/messages' && rewrittenBody !== undefined) {
549
+ try {
550
+ registerAliasLease(event.request, rewrittenBody, aliases);
551
+ }
552
+ catch (error) {
553
+ event.request = request;
554
+ throw error;
555
+ }
556
+ }
557
+ else {
558
+ aliases.dispose();
559
+ }
560
+ if (pathname === '/v1/messages') {
561
+ transformedRequests.add(event.request);
562
+ }
563
+ rememberConnection(event.request, connectionDescription);
153
564
  });
154
- await ctx.session.hook('http.response', (event) => {
565
+ await ctx.session.hook('http.response', async (event) => {
566
+ if (!setupActive)
567
+ return;
155
568
  if (event.model.providerID !== INTEGRATION_ID)
156
569
  return;
157
- if (!isTransformedOAuthRequest(event.request))
570
+ if (!hasTransformedOAuthShape(event.request))
571
+ return;
572
+ const ownedDirectly = transformedRequests.has(event.request);
573
+ let lease;
574
+ try {
575
+ lease = await resolveAliasLease(event.request);
576
+ }
577
+ catch {
578
+ if (!ownedDirectly)
579
+ return;
580
+ // A direct plugin-owned response survives a best-effort lease failure.
581
+ }
582
+ if (!ownedDirectly && !lease)
583
+ return;
584
+ if (!event.response.ok) {
585
+ if (lease)
586
+ releaseAliasLease(event.request, lease);
587
+ if (event.response.status === 429) {
588
+ const enhanced = await enhanceRateLimitResponse(event.response, connectionForRequest(event.request));
589
+ event.response = enhanced.response;
590
+ }
158
591
  return;
159
- event.response = createStrippedStream(event.response);
592
+ }
593
+ const aliases = lease?.aliases ?? new ToolNameAliasTable({ maxEntries: 0, maxBytes: 0 });
594
+ const releaseAliases = () => {
595
+ if (lease)
596
+ releaseAliasLease(event.request, lease);
597
+ else
598
+ aliases.dispose();
599
+ };
600
+ let releaseTransform;
601
+ try {
602
+ releaseTransform = acquireResponseTransform();
603
+ event.response = createStrippedStream(event.response, aliases, () => {
604
+ releaseAliases();
605
+ releaseTransform?.();
606
+ });
607
+ }
608
+ catch (error) {
609
+ releaseTransform?.();
610
+ releaseAliases();
611
+ throw error;
612
+ }
160
613
  });
614
+ return () => {
615
+ setupActive = false;
616
+ for (const lease of aliasesByFingerprint.values())
617
+ retireAliasLease(lease);
618
+ aliasesByFingerprint.clear();
619
+ for (const entry of connectionByAuthorization.values()) {
620
+ clearTimeout(entry.timer);
621
+ }
622
+ connectionByAuthorization.clear();
623
+ for (const timer of refreshCacheTimers.values())
624
+ clearTimeout(timer);
625
+ refreshCacheTimers.clear();
626
+ refreshCache.clear();
627
+ };
161
628
  },
162
629
  });
@@ -0,0 +1,10 @@
1
+ export declare const MAX_JSON_TOOL_NAME_BYTES = 64;
2
+ export declare const MAX_JSON_STRING_BYTES: number;
3
+ export declare const MAX_JSON_NUMBER_BYTES = 128;
4
+ export declare const MAX_JSON_DEPTH = 256;
5
+ export declare const MAX_JSON_OBJECT_KEYS = 100000;
6
+ export declare const MAX_JSON_RETAINED_KEY_BYTES: number;
7
+ export declare const MAX_JSON_NODES = 100000;
8
+ export declare const MAX_JSON_PENDING_BLOCK_BYTES: number;
9
+ export declare function assertWellFormedUtf16(value: string): void;
10
+ export declare function createBoundedJsonToolNameStream(body: ReadableStream<Uint8Array>, toolPrefix: string, rewriteName: (name: string) => string | undefined): ReadableStream<Uint8Array>;