@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.
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
- import { CLAUDE_CODE_ENTRYPOINT, CLAUDE_CODE_IDENTITY, OPENCODE_IDENTITY_PREFIX, PARAGRAPH_REMOVAL_ANCHORS, REQUIRED_BETAS, TEXT_REPLACEMENTS, TOOL_PREFIX, USER_AGENT, } from "./constants.js";
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.
@@ -272,10 +227,10 @@ export function mergeBetaHeaders(headers) {
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
229
  */
275
- export function setOAuthHeaders(headers, accessToken) {
230
+ export function setOAuthHeaders(headers, accessToken, version = CLAUDE_CODE_VERSION) {
276
231
  headers.set('authorization', `Bearer ${accessToken}`);
277
232
  headers.set('anthropic-beta', mergeBetaHeaders(headers));
278
- headers.set('user-agent', USER_AGENT);
233
+ headers.set('user-agent', formatUserAgent(version));
279
234
  headers.delete('x-api-key');
280
235
  return headers;
281
236
  }
@@ -283,24 +238,28 @@ export function setOAuthHeaders(headers, accessToken) {
283
238
  * Add TOOL_PREFIX to tool names in the request body.
284
239
  * Prefixes both tool definitions and tool_use blocks in messages.
285
240
  */
286
- 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);
287
245
  if (parsed.tools && Array.isArray(parsed.tools)) {
288
- parsed.tools = parsed.tools.map((tool) => ({
289
- ...tool,
290
- name: tool.name ? prefixName(tool.name) : tool.name,
291
- }));
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);
292
249
  }
293
250
  if (parsed.messages && Array.isArray(parsed.messages)) {
294
- parsed.messages = parsed.messages.map((msg) => {
295
- if (msg.content && Array.isArray(msg.content)) {
296
- msg.content = msg.content.map((block) => {
297
- if (block.type === 'tool_use' && block.name) {
298
- return { ...block, name: prefixName(block.name) };
299
- }
300
- return block;
301
- });
302
- }
303
- 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
+ };
304
263
  });
305
264
  }
306
265
  return JSON.stringify(parsed);
@@ -308,23 +267,324 @@ export function prefixToolNames(parsed) {
308
267
  /**
309
268
  * Strip TOOL_PREFIX from tool names in streaming response text.
310
269
  */
311
- export function stripToolPrefix(text) {
312
- 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
+ });
313
275
  }
314
- /**
315
- * Check if TLS verification should be skipped for custom API endpoints.
316
- * Only effective when ANTHROPIC_BASE_URL is also set.
317
- */
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. */
318
581
  export function isInsecure() {
319
582
  if (!process.env.ANTHROPIC_BASE_URL?.trim())
320
583
  return false;
321
584
  const raw = process.env.ANTHROPIC_INSECURE?.trim();
322
585
  return raw === '1' || raw === 'true';
323
586
  }
324
- /**
325
- * Parse ANTHROPIC_BASE_URL from the environment.
326
- * Returns a valid HTTP(S) URL or null if unset/invalid.
327
- */
587
+ /** Resolve the configured custom endpoint without accepting embedded credentials. */
328
588
  function resolveBaseUrl() {
329
589
  const raw = process.env.ANTHROPIC_BASE_URL?.trim();
330
590
  if (!raw)
@@ -336,6 +596,11 @@ function resolveBaseUrl() {
336
596
  baseUrl.password) {
337
597
  return null;
338
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;
339
604
  return baseUrl;
340
605
  }
341
606
  catch {
@@ -343,10 +608,26 @@ function resolveBaseUrl() {
343
608
  }
344
609
  }
345
610
  /**
346
- * Rewrite the request URL to add ?beta=true for /v1/messages requests.
347
- * When ANTHROPIC_BASE_URL is set, overrides the origin (protocol + host)
348
- * for all API requests flowing through the fetch wrapper.
349
- * 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.
350
631
  */
351
632
  export function rewriteUrl(input) {
352
633
  let requestUrl = null;
@@ -433,74 +714,114 @@ export function prependClaudeCodeIdentity(system) {
433
714
  return [identityBlock];
434
715
  if (typeof system === 'string') {
435
716
  const sanitized = sanitizeSystemText(system);
436
- if (sanitized === CLAUDE_CODE_IDENTITY)
717
+ if (!sanitized || sanitized === CLAUDE_CODE_IDENTITY) {
437
718
  return [identityBlock];
719
+ }
438
720
  return [identityBlock, { type: 'text', text: sanitized }];
439
721
  }
440
722
  if (isRecord(system)) {
441
- const type = typeof system.type === 'string' ? system.type : 'text';
442
- const text = typeof system.text === 'string' ? system.text : '';
443
- 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 }];
444
730
  }
445
731
  if (!Array.isArray(system))
446
732
  return [identityBlock];
447
- const sanitized = system.map((item) => {
733
+ const sanitized = [];
734
+ for (const item of system) {
448
735
  if (typeof item === 'string') {
449
- 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;
450
741
  }
451
742
  if (isRecord(item) &&
452
743
  item.type === 'text' &&
453
744
  typeof item.text === 'string') {
454
- return {
455
- ...item,
456
- type: 'text',
457
- text: sanitizeSystemText(item.text),
458
- };
745
+ const text = sanitizeSystemText(item.text);
746
+ if (text && text !== CLAUDE_CODE_IDENTITY) {
747
+ sanitized.push({ ...item, type: 'text', text });
748
+ }
459
749
  }
460
- return { type: 'text', text: String(item) };
461
- });
462
- // Idempotency: don't double-prepend if first block already has the identity
463
- if (sanitized[0]?.text === CLAUDE_CODE_IDENTITY) {
464
- return sanitized;
465
750
  }
466
751
  return [identityBlock, ...sanitized];
467
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
+ }
468
770
  /**
469
771
  * Rewrite the full request body: sanitize system prompt and prefix tool names.
470
772
  */
471
- export function rewriteRequestBody(body) {
773
+ export function rewriteRequestBody(body, version = CLAUDE_CODE_VERSION, aliases = statelessToolNameAliases()) {
774
+ let value;
472
775
  try {
473
- const parsed = JSON.parse(body);
474
- const billingHeader = Array.isArray(parsed.messages) &&
475
- parsed.messages.some((message) => message.role === 'user')
476
- ? buildBillingHeaderValue(parsed.messages, undefined, CLAUDE_CODE_ENTRYPOINT)
477
- : null;
478
- // Sanitize system prompt and prepend Claude Code identity
479
- parsed.system = prependClaudeCodeIdentity(parsed.system);
480
- // Prepend the billing header as a separate system block so the
481
- // final layout is: [billing header, identity, ...rest]
482
- if (billingHeader && Array.isArray(parsed.system)) {
483
- parsed.system.unshift({ type: 'text', text: billingHeader });
484
- }
485
- return prefixToolNames(parsed);
776
+ value = JSON.parse(body);
486
777
  }
487
778
  catch {
488
779
  return body;
489
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);
490
798
  }
491
799
  /**
492
800
  * Create a streaming response that strips the tool prefix from tool names.
493
801
  */
494
- 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
+ }
495
814
  const mediaType = response.headers
496
815
  .get('content-type')
497
816
  ?.split(';', 1)[0]
498
817
  ?.trim()
499
818
  .toLowerCase();
500
- if (!response.body)
819
+ if (!response.body) {
820
+ finalize();
501
821
  return response;
822
+ }
502
823
  if (mediaType === 'application/json' || mediaType?.endsWith('+json')) {
503
- const stream = createJsonToolNameStream(response.body);
824
+ const stream = finalizeStream(createBoundedJsonToolNameStream(response.body, TOOL_PREFIX, (alias) => aliases.decode(alias)), finalize);
504
825
  const headers = headersAfterBodyTransform(response.headers);
505
826
  return new Response(stream, {
506
827
  status: response.status,
@@ -508,63 +829,11 @@ export function createStrippedStream(response) {
508
829
  headers,
509
830
  });
510
831
  }
511
- if (mediaType !== 'text/event-stream')
832
+ if (mediaType !== 'text/event-stream') {
833
+ finalize();
512
834
  return response;
513
- const decoder = new TextDecoder();
514
- const encoder = new TextEncoder();
515
- let pending = new Uint8Array(0);
516
- let pendingLength = 0;
517
- const appendPending = (bytes) => {
518
- const requiredLength = pendingLength + bytes.byteLength;
519
- if (requiredLength > MAX_SSE_LINE_BYTES) {
520
- throw new Error(`SSE line exceeds ${MAX_SSE_LINE_BYTES} byte limit`);
521
- }
522
- if (requiredLength > pending.byteLength) {
523
- let capacity = Math.max(1024, pending.byteLength);
524
- while (capacity < requiredLength) {
525
- capacity = Math.min(MAX_SSE_LINE_BYTES, capacity * 2);
526
- }
527
- const expanded = new Uint8Array(capacity);
528
- expanded.set(pending.subarray(0, pendingLength));
529
- pending = expanded;
530
- }
531
- pending.set(bytes, pendingLength);
532
- pendingLength = requiredLength;
533
- };
534
- const stream = response.body.pipeThrough(new TransformStream({
535
- transform(chunk, controller) {
536
- let lastLineBreak = -1;
537
- let lineLength = pendingLength;
538
- for (let index = 0; index < chunk.byteLength; index++) {
539
- if (chunk[index] === 0x0a || chunk[index] === 0x0d) {
540
- lastLineBreak = index;
541
- lineLength = 0;
542
- }
543
- else {
544
- lineLength++;
545
- if (lineLength > MAX_SSE_LINE_BYTES) {
546
- throw new Error(`SSE line exceeds ${MAX_SSE_LINE_BYTES} byte limit`);
547
- }
548
- }
549
- }
550
- if (lastLineBreak < 0) {
551
- appendPending(chunk);
552
- return;
553
- }
554
- const completeLines = decoder.decode(pending.subarray(0, pendingLength), {
555
- stream: true,
556
- }) + decoder.decode(chunk.subarray(0, lastLineBreak + 1));
557
- pendingLength = 0;
558
- appendPending(chunk.subarray(lastLineBreak + 1));
559
- controller.enqueue(encoder.encode(stripToolPrefix(completeLines)));
560
- },
561
- flush(controller) {
562
- const trailing = decoder.decode(pending.subarray(0, pendingLength));
563
- if (trailing) {
564
- controller.enqueue(encoder.encode(stripToolPrefix(trailing)));
565
- }
566
- },
567
- }));
835
+ }
836
+ const stream = finalizeStream(createBoundedSseToolNameStream(response.body, aliases), finalize);
568
837
  const headers = headersAfterBodyTransform(response.headers);
569
838
  return new Response(stream, {
570
839
  status: response.status,