@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/transform.js CHANGED
@@ -1,8 +1,167 @@
1
+ import { Buffer } from 'node:buffer';
2
+ import { createHash } from 'node:crypto';
1
3
  import { buildBillingHeaderValue } from "./cch.js";
2
4
  import { CLAUDE_CODE_ENTRYPOINT, CLAUDE_CODE_IDENTITY, CLAUDE_CODE_VERSION, formatUserAgent, OPENCODE_IDENTITY_PREFIX, PARAGRAPH_REMOVAL_ANCHORS, REQUIRED_BETAS, TEXT_REPLACEMENTS, TOOL_PREFIX, } from "./constants.js";
5
+ import { assertWellFormedUtf16, createBoundedJsonToolNameStream, MAX_JSON_NODES, MAX_JSON_OBJECT_KEYS, MAX_JSON_TOOL_NAME_BYTES, } from "./json-response-stream.js";
3
6
  // Bound an incomplete SSE line so malformed streams cannot grow memory forever.
4
7
  export const MAX_SSE_LINE_BYTES = 5 * 1024 * 1024;
5
- function headersAfterBodyTransform(source) {
8
+ export const MAX_SSE_EVENT_BYTES = 8 * 1024 * 1024;
9
+ export { MAX_JSON_TOOL_NAME_BYTES };
10
+ const SHORT_TOOL_ALIAS_PREFIX = `${TOOL_PREFIX}T`;
11
+ const LONG_TOOL_ALIAS_PREFIX = `${TOOL_PREFIX}H`;
12
+ const MAX_TOOL_ALIAS_BYTES = 64;
13
+ const MAX_INLINE_TOOL_NAME_BYTES = 44;
14
+ const DEFAULT_ALIAS_ENTRIES = 4096;
15
+ const DEFAULT_ALIAS_BYTES = 256 * 1024;
16
+ const utf8Encoder = new TextEncoder();
17
+ const utf8Decoder = new TextDecoder('utf-8', { fatal: true });
18
+ function base64url(bytes) {
19
+ return Buffer.from(bytes).toString('base64url');
20
+ }
21
+ /**
22
+ * Reversible tool-name aliases bounded to Anthropic's 64-byte limit.
23
+ *
24
+ * Names up to 44 UTF-8 bytes are encoded inline. Longer names use a stable
25
+ * SHA-256 alias and are retained in a bounded table because a lossless,
26
+ * stateless 64-byte source -> 60-byte payload mapping is impossible.
27
+ */
28
+ export class ToolNameAliasTable {
29
+ maxEntries;
30
+ maxBytes;
31
+ longByName = new Map();
32
+ longByAlias = new Map();
33
+ retainedBytes = 0;
34
+ disposed = false;
35
+ constructor(options = {}) {
36
+ this.maxEntries = options.maxEntries ?? DEFAULT_ALIAS_ENTRIES;
37
+ this.maxBytes = options.maxBytes ?? DEFAULT_ALIAS_BYTES;
38
+ if (!Number.isSafeInteger(this.maxEntries) ||
39
+ this.maxEntries < 0 ||
40
+ !Number.isSafeInteger(this.maxBytes) ||
41
+ this.maxBytes < 0) {
42
+ throw new TypeError('Tool-name alias limits must be safe non-negative integers');
43
+ }
44
+ }
45
+ assertActive() {
46
+ if (this.disposed)
47
+ throw new Error('Tool-name alias table is disposed');
48
+ }
49
+ encode(name) {
50
+ this.assertActive();
51
+ assertWellFormedUtf16(name);
52
+ const bytes = utf8Encoder.encode(name);
53
+ if (bytes.byteLength === 0) {
54
+ throw new Error('Tool names must not be empty');
55
+ }
56
+ if (bytes.byteLength <= MAX_INLINE_TOOL_NAME_BYTES) {
57
+ const alias = `${SHORT_TOOL_ALIAS_PREFIX}${base64url(bytes)}`;
58
+ if (utf8Encoder.encode(alias).byteLength > MAX_TOOL_ALIAS_BYTES) {
59
+ throw new Error('Encoded tool name exceeds Anthropic alias limit');
60
+ }
61
+ return alias;
62
+ }
63
+ const existing = this.longByName.get(name);
64
+ if (existing)
65
+ return existing;
66
+ const alias = `${LONG_TOOL_ALIAS_PREFIX}${createHash('sha256')
67
+ .update(bytes)
68
+ .digest('base64url')}`;
69
+ const collision = this.longByAlias.get(alias);
70
+ if (collision !== undefined && collision !== name) {
71
+ throw new Error('Tool-name alias collision');
72
+ }
73
+ if (this.longByName.size >= this.maxEntries) {
74
+ throw new Error('Tool-name alias entry limit exceeded');
75
+ }
76
+ if (bytes.byteLength > this.maxBytes - this.retainedBytes) {
77
+ throw new Error('Tool-name alias byte limit exceeded');
78
+ }
79
+ this.longByName.set(name, alias);
80
+ this.longByAlias.set(alias, name);
81
+ this.retainedBytes += bytes.byteLength;
82
+ return alias;
83
+ }
84
+ decode(alias) {
85
+ this.assertActive();
86
+ if (alias.startsWith(SHORT_TOOL_ALIAS_PREFIX)) {
87
+ const encoded = alias.slice(SHORT_TOOL_ALIAS_PREFIX.length);
88
+ if (!encoded || !/^[A-Za-z0-9_-]+$/.test(encoded))
89
+ return undefined;
90
+ try {
91
+ const bytes = Buffer.from(encoded, 'base64url');
92
+ if (bytes.byteLength > MAX_INLINE_TOOL_NAME_BYTES)
93
+ return undefined;
94
+ if (base64url(bytes) !== encoded)
95
+ return undefined;
96
+ const name = utf8Decoder.decode(bytes);
97
+ const canonical = `${SHORT_TOOL_ALIAS_PREFIX}${base64url(utf8Encoder.encode(name))}`;
98
+ return canonical === alias ? name : undefined;
99
+ }
100
+ catch {
101
+ return undefined;
102
+ }
103
+ }
104
+ if (alias.startsWith(LONG_TOOL_ALIAS_PREFIX)) {
105
+ return this.longByAlias.get(alias);
106
+ }
107
+ return undefined;
108
+ }
109
+ get hasStatefulAliases() {
110
+ this.assertActive();
111
+ return this.longByName.size > 0;
112
+ }
113
+ dispose() {
114
+ if (this.disposed)
115
+ return;
116
+ this.disposed = true;
117
+ this.longByName.clear();
118
+ this.longByAlias.clear();
119
+ this.retainedBytes = 0;
120
+ }
121
+ }
122
+ function statelessToolNameAliases() {
123
+ return new ToolNameAliasTable({ maxEntries: 0, maxBytes: 0 });
124
+ }
125
+ function finalizeStream(stream, finalize) {
126
+ const reader = stream.getReader();
127
+ let released = false;
128
+ const release = () => {
129
+ if (released)
130
+ return;
131
+ released = true;
132
+ reader.releaseLock();
133
+ };
134
+ return new ReadableStream({
135
+ async pull(controller) {
136
+ try {
137
+ const result = await reader.read();
138
+ if (result.done) {
139
+ release();
140
+ finalize();
141
+ controller.close();
142
+ }
143
+ else {
144
+ controller.enqueue(result.value);
145
+ }
146
+ }
147
+ catch (error) {
148
+ release();
149
+ finalize();
150
+ controller.error(error);
151
+ }
152
+ },
153
+ async cancel(reason) {
154
+ try {
155
+ await reader.cancel(reason);
156
+ }
157
+ finally {
158
+ release();
159
+ finalize();
160
+ }
161
+ },
162
+ });
163
+ }
164
+ export function headersAfterBodyTransform(source) {
6
165
  const headers = new Headers(source);
7
166
  for (const name of [
8
167
  'content-digest',
@@ -17,210 +176,6 @@ function headersAfterBodyTransform(source) {
17
176
  }
18
177
  return headers;
19
178
  }
20
- const JSON_NAME_KEY_SUFFIX = new TextEncoder().encode('name"');
21
- const JSON_TOOL_PREFIX = new TextEncoder().encode(TOOL_PREFIX);
22
- const UTF8_ENCODER = new TextEncoder();
23
- const UTF8_FATAL_DECODER = new TextDecoder('utf-8', { fatal: true });
24
- export const MAX_JSON_TOOL_NAME_BYTES = 1024;
25
- function isJsonWhitespace(byte) {
26
- return byte === 0x20 || byte === 0x09 || byte === 0x0a || byte === 0x0d;
27
- }
28
- /**
29
- * Rewrite JSON `name` string values without buffering the whole document.
30
- * Only a bounded tool-name candidate is retained across chunks; all document
31
- * content outside that string value is emitted immediately.
32
- */
33
- function createJsonToolNameStream(body) {
34
- let state = 'outside';
35
- let held = [];
36
- let candidateIndex = 0;
37
- let escaped = false;
38
- const enterStringAfter = (byte) => {
39
- if (byte === 0x22) {
40
- state = 'outside';
41
- escaped = false;
42
- return;
43
- }
44
- state = 'string';
45
- escaped = byte === 0x5c;
46
- };
47
- return body.pipeThrough(new TransformStream({
48
- transform(chunk, controller) {
49
- const output = new Uint8Array(chunk.byteLength + 32);
50
- let outputLength = 0;
51
- const write = (byte) => {
52
- output[outputLength++] = byte;
53
- };
54
- const enqueueOutput = () => {
55
- if (outputLength === 0)
56
- return;
57
- controller.enqueue(output.slice(0, outputLength));
58
- outputLength = 0;
59
- };
60
- const writeHeld = () => {
61
- for (const byte of held)
62
- write(byte);
63
- held = [];
64
- };
65
- const processOutside = (byte) => {
66
- if (byte === 0x22) {
67
- held = [byte];
68
- candidateIndex = 0;
69
- state = 'key-candidate';
70
- return;
71
- }
72
- write(byte);
73
- };
74
- for (const byte of chunk) {
75
- if (state === 'outside') {
76
- processOutside(byte);
77
- continue;
78
- }
79
- if (state === 'key-candidate') {
80
- if (byte === JSON_NAME_KEY_SUFFIX[candidateIndex]) {
81
- held.push(byte);
82
- candidateIndex++;
83
- if (candidateIndex === JSON_NAME_KEY_SUFFIX.byteLength) {
84
- writeHeld();
85
- state = 'after-name-key';
86
- }
87
- continue;
88
- }
89
- writeHeld();
90
- write(byte);
91
- enterStringAfter(byte);
92
- continue;
93
- }
94
- if (state === 'string') {
95
- write(byte);
96
- if (escaped) {
97
- escaped = false;
98
- }
99
- else if (byte === 0x5c) {
100
- escaped = true;
101
- }
102
- else if (byte === 0x22) {
103
- state = 'outside';
104
- }
105
- continue;
106
- }
107
- if (state === 'after-name-key') {
108
- if (isJsonWhitespace(byte)) {
109
- write(byte);
110
- }
111
- else if (byte === 0x3a) {
112
- write(byte);
113
- state = 'after-colon';
114
- }
115
- else {
116
- processOutside(byte);
117
- }
118
- continue;
119
- }
120
- if (state === 'after-colon') {
121
- if (isJsonWhitespace(byte)) {
122
- write(byte);
123
- }
124
- else if (byte === 0x22) {
125
- write(byte);
126
- held = [];
127
- candidateIndex = 0;
128
- state = 'prefix-candidate';
129
- }
130
- else {
131
- processOutside(byte);
132
- }
133
- continue;
134
- }
135
- if (state === 'prefix-candidate') {
136
- if (byte === JSON_TOOL_PREFIX[candidateIndex]) {
137
- held.push(byte);
138
- candidateIndex++;
139
- if (candidateIndex === JSON_TOOL_PREFIX.byteLength) {
140
- held = [];
141
- candidateIndex = 0;
142
- escaped = false;
143
- state = 'tool-name-candidate';
144
- }
145
- continue;
146
- }
147
- writeHeld();
148
- write(byte);
149
- enterStringAfter(byte);
150
- continue;
151
- }
152
- if (escaped) {
153
- held.push(byte);
154
- escaped = false;
155
- if (held.length > MAX_JSON_TOOL_NAME_BYTES) {
156
- throw new Error(`JSON tool name exceeds ${MAX_JSON_TOOL_NAME_BYTES} byte limit`);
157
- }
158
- continue;
159
- }
160
- if (byte === 0x5c) {
161
- held.push(byte);
162
- escaped = true;
163
- if (held.length > MAX_JSON_TOOL_NAME_BYTES) {
164
- throw new Error(`JSON tool name exceeds ${MAX_JSON_TOOL_NAME_BYTES} byte limit`);
165
- }
166
- continue;
167
- }
168
- if (byte === 0x22) {
169
- let replacement;
170
- if (held.length === 0) {
171
- replacement = JSON_TOOL_PREFIX;
172
- }
173
- else {
174
- try {
175
- replacement = UTF8_ENCODER.encode(unprefixName(UTF8_FATAL_DECODER.decode(Uint8Array.from(held))));
176
- }
177
- catch {
178
- replacement = Uint8Array.from([...JSON_TOOL_PREFIX, ...held]);
179
- }
180
- }
181
- enqueueOutput();
182
- controller.enqueue(replacement);
183
- write(byte);
184
- held = [];
185
- candidateIndex = 0;
186
- state = 'outside';
187
- continue;
188
- }
189
- held.push(byte);
190
- if (held.length > MAX_JSON_TOOL_NAME_BYTES) {
191
- throw new Error(`JSON tool name exceeds ${MAX_JSON_TOOL_NAME_BYTES} byte limit`);
192
- }
193
- }
194
- enqueueOutput();
195
- },
196
- flush(controller) {
197
- const trailing = Uint8Array.from(state === 'tool-name-candidate'
198
- ? [...JSON_TOOL_PREFIX, ...held]
199
- : held);
200
- if (trailing.byteLength === 0)
201
- return;
202
- controller.enqueue(trailing);
203
- },
204
- }));
205
- }
206
- /**
207
- * Prefix a tool name with TOOL_PREFIX and uppercase the first character.
208
- * Claude Code uses PascalCase tool names (e.g. mcp_Bash, mcp_Read);
209
- * lowercase names (mcp_bash, mcp_read) are flagged as non-Claude-Code clients.
210
- */
211
- function prefixName(name) {
212
- return `${TOOL_PREFIX}${name.charAt(0).toUpperCase()}${name.slice(1)}`;
213
- }
214
- /**
215
- * Reverse prefixName: strip TOOL_PREFIX and restore the original leading case.
216
- */
217
- function unprefixName(name) {
218
- // StructuredOutput is still used as StructuredOutput
219
- if (name === 'StructuredOutput') {
220
- return name;
221
- }
222
- return `${name.charAt(0).toLowerCase()}${name.slice(1)}`;
223
- }
224
179
  /**
225
180
  * Merge headers from a Request object and/or a RequestInit headers value
226
181
  * into a single Headers instance.
@@ -271,9 +226,6 @@ export function mergeBetaHeaders(headers) {
271
226
  /**
272
227
  * Set OAuth-required headers on the request: authorization, beta, user-agent.
273
228
  * Removes x-api-key since we're using OAuth.
274
- *
275
- * `version` must be the same value passed to rewriteRequestBody for the same
276
- * request, otherwise the two reported versions disagree.
277
229
  */
278
230
  export function setOAuthHeaders(headers, accessToken, version = CLAUDE_CODE_VERSION) {
279
231
  headers.set('authorization', `Bearer ${accessToken}`);
@@ -286,24 +238,28 @@ export function setOAuthHeaders(headers, accessToken, version = CLAUDE_CODE_VERS
286
238
  * Add TOOL_PREFIX to tool names in the request body.
287
239
  * Prefixes both tool definitions and tool_use blocks in messages.
288
240
  */
289
- export function prefixToolNames(parsed) {
241
+ export function prefixToolNames(parsed, alreadyPrefixed = false, aliases = statelessToolNameAliases()) {
242
+ const prefix = (name) => alreadyPrefixed && name.startsWith(TOOL_PREFIX)
243
+ ? name
244
+ : aliases.encode(name);
290
245
  if (parsed.tools && Array.isArray(parsed.tools)) {
291
- parsed.tools = parsed.tools.map((tool) => ({
292
- ...tool,
293
- name: tool.name ? prefixName(tool.name) : tool.name,
294
- }));
246
+ parsed.tools = parsed.tools.map((tool) => isRecord(tool) && typeof tool.name === 'string' && tool.name.length > 0
247
+ ? { ...tool, name: prefix(tool.name) }
248
+ : tool);
295
249
  }
296
250
  if (parsed.messages && Array.isArray(parsed.messages)) {
297
- parsed.messages = parsed.messages.map((msg) => {
298
- if (msg.content && Array.isArray(msg.content)) {
299
- msg.content = msg.content.map((block) => {
300
- if (block.type === 'tool_use' && block.name) {
301
- return { ...block, name: prefixName(block.name) };
302
- }
303
- return block;
304
- });
305
- }
306
- return msg;
251
+ parsed.messages = parsed.messages.map((message) => {
252
+ if (!isRecord(message) || !Array.isArray(message.content))
253
+ return message;
254
+ return {
255
+ ...message,
256
+ content: message.content.map((block) => isRecord(block) &&
257
+ block.type === 'tool_use' &&
258
+ typeof block.name === 'string' &&
259
+ block.name.length > 0
260
+ ? { ...block, name: prefix(block.name) }
261
+ : block),
262
+ };
307
263
  });
308
264
  }
309
265
  return JSON.stringify(parsed);
@@ -311,23 +267,324 @@ export function prefixToolNames(parsed) {
311
267
  /**
312
268
  * Strip TOOL_PREFIX from tool names in streaming response text.
313
269
  */
314
- export function stripToolPrefix(text) {
315
- return text.replace(/"name"\s*:\s*"mcp_([^"]+)"/g, (_match, name) => `"name": "${unprefixName(name)}"`);
270
+ export function stripToolPrefix(text, aliases = statelessToolNameAliases()) {
271
+ return text.replace(/"name"\s*:\s*"(mcp_[A-Za-z0-9_-]+)"/g, (match, alias) => {
272
+ const name = aliases.decode(alias);
273
+ return name === undefined ? match : `"name": ${JSON.stringify(name)}`;
274
+ });
316
275
  }
317
- /**
318
- * Check if TLS verification should be skipped for custom API endpoints.
319
- * Only effective when ANTHROPIC_BASE_URL is also set.
320
- */
276
+ const MAX_RESPONSE_JSON_DEPTH = 256;
277
+ function isToolUseWithAlias(value) {
278
+ return (isRecord(value) &&
279
+ value.type === 'tool_use' &&
280
+ typeof value.name === 'string' &&
281
+ value.name.startsWith(TOOL_PREFIX));
282
+ }
283
+ function validateResponseToolName(name) {
284
+ assertWellFormedUtf16(name);
285
+ if (utf8Encoder.encode(name).byteLength > MAX_JSON_TOOL_NAME_BYTES) {
286
+ throw new Error(`JSON tool name exceeds ${MAX_JSON_TOOL_NAME_BYTES} byte limit`);
287
+ }
288
+ }
289
+ function responseToolNameReplacements(value, aliases) {
290
+ if (!isRecord(value))
291
+ return [];
292
+ if (value.type === 'content_block_start' &&
293
+ isToolUseWithAlias(value.content_block)) {
294
+ validateResponseToolName(value.content_block.name);
295
+ const name = aliases.decode(value.content_block.name);
296
+ return name === undefined
297
+ ? []
298
+ : [{ path: ['content_block', 'name'], value: name }];
299
+ }
300
+ if (value.type !== 'message' || !Array.isArray(value.content))
301
+ return [];
302
+ const replacements = [];
303
+ for (let index = 0; index < value.content.length; index++) {
304
+ const block = value.content[index];
305
+ if (!isToolUseWithAlias(block))
306
+ continue;
307
+ validateResponseToolName(block.name);
308
+ const name = aliases.decode(block.name);
309
+ if (name !== undefined) {
310
+ replacements.push({ path: ['content', index, 'name'], value: name });
311
+ }
312
+ }
313
+ return replacements;
314
+ }
315
+ function pathKey(path) {
316
+ return JSON.stringify(path);
317
+ }
318
+ function rewriteJsonStringTokens(text, replacements) {
319
+ const wanted = new Map(replacements.map((replacement) => [
320
+ pathKey(replacement.path),
321
+ JSON.stringify(replacement.value),
322
+ ]));
323
+ const edits = new Map();
324
+ let visited = 0;
325
+ let objectKeys = 0;
326
+ function failLimits() {
327
+ throw new Error('Anthropic response JSON exceeds traversal limits');
328
+ }
329
+ function skipWhitespace(offset) {
330
+ while (/\s/.test(text[offset] ?? ''))
331
+ offset++;
332
+ return offset;
333
+ }
334
+ function scanString(offset) {
335
+ let cursor = offset + 1;
336
+ while (cursor < text.length) {
337
+ const char = text[cursor];
338
+ if (char === '"')
339
+ return cursor + 1;
340
+ if (char === '\\')
341
+ cursor++;
342
+ cursor++;
343
+ }
344
+ return text.length;
345
+ }
346
+ function walkValue(offset, path, depth) {
347
+ visited++;
348
+ if (visited > MAX_JSON_NODES || depth > MAX_RESPONSE_JSON_DEPTH) {
349
+ failLimits();
350
+ }
351
+ let cursor = skipWhitespace(offset);
352
+ const char = text[cursor];
353
+ if (char === '"') {
354
+ const end = scanString(cursor);
355
+ const key = pathKey(path);
356
+ const replacement = wanted.get(key);
357
+ if (replacement !== undefined) {
358
+ edits.set(key, { start: cursor, end, value: replacement });
359
+ }
360
+ return end;
361
+ }
362
+ if (char === '{') {
363
+ cursor = skipWhitespace(cursor + 1);
364
+ const seenKeys = new Set();
365
+ while (text[cursor] !== '}' && cursor < text.length) {
366
+ const keyStart = cursor;
367
+ const keyEnd = scanString(keyStart);
368
+ const key = JSON.parse(text.slice(keyStart, keyEnd));
369
+ objectKeys += 1;
370
+ if (objectKeys > MAX_JSON_OBJECT_KEYS) {
371
+ throw new Error('Anthropic response JSON exceeds object-key limit');
372
+ }
373
+ if (seenKeys.has(key)) {
374
+ throw new Error('Malformed Anthropic response JSON: duplicate object key');
375
+ }
376
+ seenKeys.add(key);
377
+ cursor = skipWhitespace(keyEnd);
378
+ cursor = skipWhitespace(cursor + 1);
379
+ cursor = walkValue(cursor, [...path, key], depth + 1);
380
+ cursor = skipWhitespace(cursor);
381
+ if (text[cursor] === ',')
382
+ cursor = skipWhitespace(cursor + 1);
383
+ }
384
+ return cursor + 1;
385
+ }
386
+ if (char === '[') {
387
+ cursor = skipWhitespace(cursor + 1);
388
+ let index = 0;
389
+ while (text[cursor] !== ']' && cursor < text.length) {
390
+ cursor = walkValue(cursor, [...path, index], depth + 1);
391
+ index++;
392
+ cursor = skipWhitespace(cursor);
393
+ if (text[cursor] === ',')
394
+ cursor = skipWhitespace(cursor + 1);
395
+ }
396
+ return cursor + 1;
397
+ }
398
+ while (cursor < text.length && !/[\s,\]}]/.test(text[cursor] ?? '')) {
399
+ cursor++;
400
+ }
401
+ return cursor;
402
+ }
403
+ walkValue(0, [], 0);
404
+ let output = text;
405
+ const orderedEdits = [...edits.values()].sort((left, right) => right.start - left.start);
406
+ for (const edit of orderedEdits) {
407
+ output = output.slice(0, edit.start) + edit.value + output.slice(edit.end);
408
+ }
409
+ return output;
410
+ }
411
+ function rewriteResponseJson(text, aliases) {
412
+ let value;
413
+ try {
414
+ value = JSON.parse(text);
415
+ }
416
+ catch {
417
+ return undefined;
418
+ }
419
+ return rewriteJsonStringTokens(text, responseToolNameReplacements(value, aliases));
420
+ }
421
+ function splitSseLines(event) {
422
+ const lines = [];
423
+ let offset = 0;
424
+ while (offset < event.length) {
425
+ let cursor = offset;
426
+ while (cursor < event.length &&
427
+ event[cursor] !== '\r' &&
428
+ event[cursor] !== '\n') {
429
+ cursor += 1;
430
+ }
431
+ let ending = '';
432
+ if (event[cursor] === '\r' && event[cursor + 1] === '\n')
433
+ ending = '\r\n';
434
+ else if (event[cursor] === '\r')
435
+ ending = '\r';
436
+ else if (event[cursor] === '\n')
437
+ ending = '\n';
438
+ lines.push({ content: event.slice(offset, cursor), ending });
439
+ offset = cursor + ending.length;
440
+ }
441
+ return lines;
442
+ }
443
+ function sseDataValue(line) {
444
+ if (line === 'data')
445
+ return '';
446
+ if (!line.startsWith('data:'))
447
+ return undefined;
448
+ const value = line.slice('data:'.length);
449
+ return value.startsWith(' ') ? value.slice(1) : value;
450
+ }
451
+ function transformSseEvent(bytes, aliases) {
452
+ const decoder = new TextDecoder('utf-8', { fatal: true });
453
+ const encoder = new TextEncoder();
454
+ const event = decoder.decode(bytes);
455
+ const lines = splitSseLines(event);
456
+ const dataIndexes = [];
457
+ const dataValues = [];
458
+ for (let index = 0; index < lines.length; index += 1) {
459
+ const value = sseDataValue(lines[index]?.content ?? '');
460
+ if (value === undefined)
461
+ continue;
462
+ dataIndexes.push(index);
463
+ dataValues.push(value);
464
+ }
465
+ if (dataIndexes.length === 0)
466
+ return bytes;
467
+ const payload = dataValues.join('\n');
468
+ if (payload.trim() === '[DONE]')
469
+ return bytes;
470
+ const firstPayloadCharacter = payload.trimStart()[0];
471
+ const jsonLooking = firstPayloadCharacter === '{' || firstPayloadCharacter === '[';
472
+ if (!payload.includes(TOOL_PREFIX) && !jsonLooking)
473
+ return bytes;
474
+ const rewritten = rewriteResponseJson(payload, aliases);
475
+ if (rewritten === undefined) {
476
+ if (jsonLooking) {
477
+ throw new Error('Malformed or truncated Anthropic response JSON');
478
+ }
479
+ return bytes;
480
+ }
481
+ if (rewritten === payload)
482
+ return bytes;
483
+ const canonical = /[\r\n]/.test(rewritten) || dataIndexes.length > 1
484
+ ? JSON.stringify(JSON.parse(rewritten))
485
+ : rewritten;
486
+ const firstIndex = dataIndexes[0];
487
+ const dataIndexSet = new Set(dataIndexes);
488
+ const output = lines
489
+ .map((line, index) => {
490
+ if (index === firstIndex)
491
+ return `data: ${canonical}${line.ending}`;
492
+ if (dataIndexSet.has(index))
493
+ return '';
494
+ return `${line.content}${line.ending}`;
495
+ })
496
+ .join('');
497
+ return encoder.encode(output);
498
+ }
499
+ function createBoundedSseToolNameStream(body, aliases) {
500
+ let event = new Uint8Array(1024);
501
+ let eventLength = 0;
502
+ let lineLength = 0;
503
+ let pendingCr = false;
504
+ const appendEvent = (bytes) => {
505
+ const required = eventLength + bytes.byteLength;
506
+ if (required > MAX_SSE_EVENT_BYTES) {
507
+ throw new Error(`SSE event exceeds ${MAX_SSE_EVENT_BYTES} byte limit`);
508
+ }
509
+ if (required > event.byteLength) {
510
+ let capacity = event.byteLength;
511
+ while (capacity < required) {
512
+ capacity = Math.min(MAX_SSE_EVENT_BYTES, capacity * 2);
513
+ }
514
+ const expanded = new Uint8Array(capacity);
515
+ expanded.set(event.subarray(0, eventLength));
516
+ event = expanded;
517
+ }
518
+ event.set(bytes, eventLength);
519
+ eventLength = required;
520
+ };
521
+ const appendContent = (bytes) => {
522
+ if (lineLength + bytes.byteLength > MAX_SSE_LINE_BYTES) {
523
+ throw new Error(`SSE line exceeds ${MAX_SSE_LINE_BYTES} byte limit`);
524
+ }
525
+ lineLength += bytes.byteLength;
526
+ appendEvent(bytes);
527
+ };
528
+ const dispatch = (controller) => {
529
+ if (eventLength === 0)
530
+ return;
531
+ controller.enqueue(transformSseEvent(event.slice(0, eventLength), aliases));
532
+ eventLength = 0;
533
+ };
534
+ const finishLine = (controller) => {
535
+ const blank = lineLength === 0;
536
+ lineLength = 0;
537
+ if (blank)
538
+ dispatch(controller);
539
+ };
540
+ return body.pipeThrough(new TransformStream({
541
+ transform(chunk, controller) {
542
+ let segmentStart = 0;
543
+ if (pendingCr) {
544
+ pendingCr = false;
545
+ if (chunk[0] === 0x0a) {
546
+ appendEvent(chunk.subarray(0, 1));
547
+ segmentStart = 1;
548
+ }
549
+ finishLine(controller);
550
+ }
551
+ for (let index = segmentStart; index < chunk.byteLength; index += 1) {
552
+ const byte = chunk[index];
553
+ if (byte !== 0x0d && byte !== 0x0a)
554
+ continue;
555
+ appendContent(chunk.subarray(segmentStart, index));
556
+ appendEvent(chunk.subarray(index, index + 1));
557
+ if (byte === 0x0d && index + 1 === chunk.byteLength) {
558
+ pendingCr = true;
559
+ segmentStart = index + 1;
560
+ break;
561
+ }
562
+ if (byte === 0x0d && chunk[index + 1] === 0x0a) {
563
+ appendEvent(chunk.subarray(index + 1, index + 2));
564
+ index += 1;
565
+ }
566
+ finishLine(controller);
567
+ segmentStart = index + 1;
568
+ }
569
+ appendContent(chunk.subarray(segmentStart));
570
+ },
571
+ flush(controller) {
572
+ if (pendingCr) {
573
+ pendingCr = false;
574
+ finishLine(controller);
575
+ }
576
+ dispatch(controller);
577
+ },
578
+ }));
579
+ }
580
+ /** Check whether TLS verification was explicitly requested off for a custom endpoint. */
321
581
  export function isInsecure() {
322
582
  if (!process.env.ANTHROPIC_BASE_URL?.trim())
323
583
  return false;
324
584
  const raw = process.env.ANTHROPIC_INSECURE?.trim();
325
585
  return raw === '1' || raw === 'true';
326
586
  }
327
- /**
328
- * Parse ANTHROPIC_BASE_URL from the environment.
329
- * Returns a valid HTTP(S) URL or null if unset/invalid.
330
- */
587
+ /** Resolve the configured custom endpoint without accepting embedded credentials. */
331
588
  function resolveBaseUrl() {
332
589
  const raw = process.env.ANTHROPIC_BASE_URL?.trim();
333
590
  if (!raw)
@@ -339,6 +596,11 @@ function resolveBaseUrl() {
339
596
  baseUrl.password) {
340
597
  return null;
341
598
  }
599
+ const loopback = baseUrl.hostname === 'localhost' ||
600
+ baseUrl.hostname === '127.0.0.1' ||
601
+ baseUrl.hostname === '[::1]';
602
+ if (baseUrl.protocol === 'http:' && !loopback)
603
+ return null;
342
604
  return baseUrl;
343
605
  }
344
606
  catch {
@@ -346,10 +608,26 @@ function resolveBaseUrl() {
346
608
  }
347
609
  }
348
610
  /**
349
- * Rewrite the request URL to add ?beta=true for /v1/messages requests.
350
- * When ANTHROPIC_BASE_URL is set, overrides the origin (protocol + host)
351
- * for all API requests flowing through the fetch wrapper.
352
- * Returns the modified input and URL (if applicable).
611
+ * Allow OAuth bearer credentials only for Anthropic's official API or the
612
+ * exact origin of an explicitly configured, validated custom endpoint.
613
+ */
614
+ export function isTrustedAnthropicUrl(input) {
615
+ try {
616
+ const url = new URL(input.toString());
617
+ const configuredOrigin = resolveBaseUrl()?.origin;
618
+ return (url.username === '' &&
619
+ url.password === '' &&
620
+ (url.origin === 'https://api.anthropic.com' ||
621
+ (configuredOrigin !== undefined && url.origin === configuredOrigin)));
622
+ }
623
+ catch {
624
+ return false;
625
+ }
626
+ }
627
+ /**
628
+ * Rewrite the request URL to add ?beta=true for /v1/messages requests. When
629
+ * ANTHROPIC_BASE_URL is set, override the origin for API requests while
630
+ * retaining the original path and query.
353
631
  */
354
632
  export function rewriteUrl(input) {
355
633
  let requestUrl = null;
@@ -436,77 +714,114 @@ export function prependClaudeCodeIdentity(system) {
436
714
  return [identityBlock];
437
715
  if (typeof system === 'string') {
438
716
  const sanitized = sanitizeSystemText(system);
439
- if (sanitized === CLAUDE_CODE_IDENTITY)
717
+ if (!sanitized || sanitized === CLAUDE_CODE_IDENTITY) {
440
718
  return [identityBlock];
719
+ }
441
720
  return [identityBlock, { type: 'text', text: sanitized }];
442
721
  }
443
722
  if (isRecord(system)) {
444
- const type = typeof system.type === 'string' ? system.type : 'text';
445
- const text = typeof system.text === 'string' ? system.text : '';
446
- return [identityBlock, { ...system, type, text: sanitizeSystemText(text) }];
723
+ if (system.type !== 'text' || typeof system.text !== 'string') {
724
+ return [identityBlock];
725
+ }
726
+ const text = sanitizeSystemText(system.text);
727
+ if (!text || text === CLAUDE_CODE_IDENTITY)
728
+ return [identityBlock];
729
+ return [identityBlock, { ...system, type: 'text', text }];
447
730
  }
448
731
  if (!Array.isArray(system))
449
732
  return [identityBlock];
450
- const sanitized = system.map((item) => {
733
+ const sanitized = [];
734
+ for (const item of system) {
451
735
  if (typeof item === 'string') {
452
- return { type: 'text', text: sanitizeSystemText(item) };
736
+ const text = sanitizeSystemText(item);
737
+ if (text && text !== CLAUDE_CODE_IDENTITY) {
738
+ sanitized.push({ type: 'text', text });
739
+ }
740
+ continue;
453
741
  }
454
742
  if (isRecord(item) &&
455
743
  item.type === 'text' &&
456
744
  typeof item.text === 'string') {
457
- return {
458
- ...item,
459
- type: 'text',
460
- text: sanitizeSystemText(item.text),
461
- };
745
+ const text = sanitizeSystemText(item.text);
746
+ if (text && text !== CLAUDE_CODE_IDENTITY) {
747
+ sanitized.push({ ...item, type: 'text', text });
748
+ }
462
749
  }
463
- return { type: 'text', text: String(item) };
464
- });
465
- // Idempotency: don't double-prepend if first block already has the identity
466
- if (sanitized[0]?.text === CLAUDE_CODE_IDENTITY) {
467
- return sanitized;
468
750
  }
469
751
  return [identityBlock, ...sanitized];
470
752
  }
753
+ const BILLING_HEADER_PREFIX = 'x-anthropic-billing-header:';
754
+ function isBillingBlock(value) {
755
+ return (isRecord(value) &&
756
+ value.type === 'text' &&
757
+ typeof value.text === 'string' &&
758
+ value.text.startsWith(BILLING_HEADER_PREFIX));
759
+ }
760
+ function hasRewriteMarker(parsed) {
761
+ if (!Array.isArray(parsed.system))
762
+ return false;
763
+ const hasIdentity = parsed.system.some((block) => isRecord(block) && block.text === CLAUDE_CODE_IDENTITY);
764
+ if (!hasIdentity)
765
+ return false;
766
+ const hasUser = Array.isArray(parsed.messages) &&
767
+ parsed.messages.some((message) => isRecord(message) && message.role === 'user');
768
+ return !hasUser || parsed.system.some(isBillingBlock);
769
+ }
471
770
  /**
472
771
  * Rewrite the full request body: sanitize system prompt and prefix tool names.
473
- *
474
- * `version` must be the same value passed to setOAuthHeaders for the same
475
- * request, otherwise the two reported versions disagree.
476
772
  */
477
- export function rewriteRequestBody(body, version = CLAUDE_CODE_VERSION) {
773
+ export function rewriteRequestBody(body, version = CLAUDE_CODE_VERSION, aliases = statelessToolNameAliases()) {
774
+ let value;
478
775
  try {
479
- const parsed = JSON.parse(body);
480
- const billingHeader = Array.isArray(parsed.messages) &&
481
- parsed.messages.some((message) => message.role === 'user')
482
- ? buildBillingHeaderValue(parsed.messages, version, CLAUDE_CODE_ENTRYPOINT)
483
- : null;
484
- // Sanitize system prompt and prepend Claude Code identity
485
- parsed.system = prependClaudeCodeIdentity(parsed.system);
486
- // Prepend the billing header as a separate system block so the
487
- // final layout is: [billing header, identity, ...rest]
488
- if (billingHeader && Array.isArray(parsed.system)) {
489
- parsed.system.unshift({ type: 'text', text: billingHeader });
490
- }
491
- return prefixToolNames(parsed);
776
+ value = JSON.parse(body);
492
777
  }
493
778
  catch {
494
779
  return body;
495
780
  }
781
+ if (!isRecord(value))
782
+ return body;
783
+ const parsed = value;
784
+ const alreadyPrefixed = hasRewriteMarker(parsed);
785
+ const billingHeader = Array.isArray(parsed.messages) &&
786
+ parsed.messages.some((message) => isRecord(message) && message.role === 'user')
787
+ ? buildBillingHeaderValue(parsed.messages, version, CLAUDE_CODE_ENTRYPOINT)
788
+ : null;
789
+ // Sanitize system prompt and prepend Claude Code identity
790
+ const system = prependClaudeCodeIdentity(parsed.system).filter((block) => !isBillingBlock(block));
791
+ // Prepend the billing header as a separate system block so the
792
+ // final layout is: [billing header, identity, ...rest]
793
+ if (billingHeader) {
794
+ system.unshift({ type: 'text', text: billingHeader });
795
+ }
796
+ parsed.system = system;
797
+ return prefixToolNames(parsed, alreadyPrefixed, aliases);
496
798
  }
497
799
  /**
498
800
  * Create a streaming response that strips the tool prefix from tool names.
499
801
  */
500
- export function createStrippedStream(response) {
802
+ export function createStrippedStream(response, aliases = statelessToolNameAliases(), onFinalize = () => { }) {
803
+ let finalized = false;
804
+ const finalize = () => {
805
+ if (finalized)
806
+ return;
807
+ finalized = true;
808
+ onFinalize();
809
+ };
810
+ if (!response.ok) {
811
+ finalize();
812
+ return response;
813
+ }
501
814
  const mediaType = response.headers
502
815
  .get('content-type')
503
816
  ?.split(';', 1)[0]
504
817
  ?.trim()
505
818
  .toLowerCase();
506
- if (!response.body)
819
+ if (!response.body) {
820
+ finalize();
507
821
  return response;
822
+ }
508
823
  if (mediaType === 'application/json' || mediaType?.endsWith('+json')) {
509
- const stream = createJsonToolNameStream(response.body);
824
+ const stream = finalizeStream(createBoundedJsonToolNameStream(response.body, TOOL_PREFIX, (alias) => aliases.decode(alias)), finalize);
510
825
  const headers = headersAfterBodyTransform(response.headers);
511
826
  return new Response(stream, {
512
827
  status: response.status,
@@ -514,63 +829,11 @@ export function createStrippedStream(response) {
514
829
  headers,
515
830
  });
516
831
  }
517
- if (mediaType !== 'text/event-stream')
832
+ if (mediaType !== 'text/event-stream') {
833
+ finalize();
518
834
  return response;
519
- const decoder = new TextDecoder();
520
- const encoder = new TextEncoder();
521
- let pending = new Uint8Array(0);
522
- let pendingLength = 0;
523
- const appendPending = (bytes) => {
524
- const requiredLength = pendingLength + bytes.byteLength;
525
- if (requiredLength > MAX_SSE_LINE_BYTES) {
526
- throw new Error(`SSE line exceeds ${MAX_SSE_LINE_BYTES} byte limit`);
527
- }
528
- if (requiredLength > pending.byteLength) {
529
- let capacity = Math.max(1024, pending.byteLength);
530
- while (capacity < requiredLength) {
531
- capacity = Math.min(MAX_SSE_LINE_BYTES, capacity * 2);
532
- }
533
- const expanded = new Uint8Array(capacity);
534
- expanded.set(pending.subarray(0, pendingLength));
535
- pending = expanded;
536
- }
537
- pending.set(bytes, pendingLength);
538
- pendingLength = requiredLength;
539
- };
540
- const stream = response.body.pipeThrough(new TransformStream({
541
- transform(chunk, controller) {
542
- let lastLineBreak = -1;
543
- let lineLength = pendingLength;
544
- for (let index = 0; index < chunk.byteLength; index++) {
545
- if (chunk[index] === 0x0a || chunk[index] === 0x0d) {
546
- lastLineBreak = index;
547
- lineLength = 0;
548
- }
549
- else {
550
- lineLength++;
551
- if (lineLength > MAX_SSE_LINE_BYTES) {
552
- throw new Error(`SSE line exceeds ${MAX_SSE_LINE_BYTES} byte limit`);
553
- }
554
- }
555
- }
556
- if (lastLineBreak < 0) {
557
- appendPending(chunk);
558
- return;
559
- }
560
- const completeLines = decoder.decode(pending.subarray(0, pendingLength), {
561
- stream: true,
562
- }) + decoder.decode(chunk.subarray(0, lastLineBreak + 1));
563
- pendingLength = 0;
564
- appendPending(chunk.subarray(lastLineBreak + 1));
565
- controller.enqueue(encoder.encode(stripToolPrefix(completeLines)));
566
- },
567
- flush(controller) {
568
- const trailing = decoder.decode(pending.subarray(0, pendingLength));
569
- if (trailing) {
570
- controller.enqueue(encoder.encode(stripToolPrefix(trailing)));
571
- }
572
- },
573
- }));
835
+ }
836
+ const stream = finalizeStream(createBoundedSseToolNameStream(response.body, aliases), finalize);
574
837
  const headers = headersAfterBodyTransform(response.headers);
575
838
  return new Response(stream, {
576
839
  status: response.status,