Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 12 additions & 6 deletions src/internal/utils/bytes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,17 @@ export function encodeUTF8(str: string) {
)(str);
}

let decodeUTF8_: (bytes: Uint8Array) => string;
let decodeUTF8Decoder_: { decode: (input?: ArrayBuffer | ArrayBufferView | null) => string };
export function decodeUTF8(bytes: Uint8Array) {
let decoder;
return (
decodeUTF8_ ??
((decoder = new (globalThis as any).TextDecoder()), (decodeUTF8_ = decoder.decode.bind(decoder)))
)(bytes);
const decoder = decodeUTF8Decoder_ ?? (decodeUTF8Decoder_ = new (globalThis as any).TextDecoder());

try {
return decoder.decode(bytes);
} catch (error) {
if (!(error instanceof TypeError)) {
throw error;
}

return decoder.decode(bytes.slice().buffer);
}
}
72 changes: 72 additions & 0 deletions tests/api-resources/MessageStream.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,78 @@ describe('MessageStream class', () => {
expect(finalText).toBe('Hello there!');
});

it('handles strict TextDecoder behavior on messages.stream()', async () => {
const OriginalTextDecoder = globalThis.TextDecoder;
const nativeDecoder = new OriginalTextDecoder();

class ArrayBufferOnlyTextDecoder {
readonly encoding = 'utf-8';
readonly fatal = false;
readonly ignoreBOM = false;

constructor(label?: string, options?: TextDecoderOptions) {
void label;
void options;
}

decode(input?: ArrayBuffer | ArrayBufferView | null) {
if (input == null) {
return nativeDecoder.decode();
}

if (!(input instanceof ArrayBuffer)) {
throw new TypeError(
"Failed to execute 'decode' on 'TextDecoder': parameter 1 is not of type 'ArrayBuffer'",
);
}

return nativeDecoder.decode(input);
}
}

Object.defineProperty(globalThis, 'TextDecoder', {
configurable: true,
writable: true,
value: ArrayBufferOnlyTextDecoder as unknown as typeof TextDecoder,
});
jest.resetModules();

try {
const { default: ReloadedAnthropic } = await import('@anthropic-ai/sdk');
const { fetch, handleStreamEvents } = mockFetch();
const anthropic = new ReloadedAnthropic({ apiKey: '...', fetch });

const fixtureContent = loadFixture('basic_response.txt');
const streamEvents = await parseSSEFixture(fixtureContent);
handleStreamEvents(streamEvents);

const stream = anthropic.messages.stream({
max_tokens: 1024,
model: 'claude-opus-4-20250514',
messages: [{ role: 'user', content: 'Say hello there!' }],
});

const events: MessageStreamEvent[] = [];
for await (const event of stream) {
events.push(event);
}

await stream.done();
const finalMessage = await stream.finalMessage();
const finalText = await stream.finalText();

assertBasicResponse(events, finalMessage);
expect(finalText).toBe('Hello there!');
} finally {
Object.defineProperty(globalThis, 'TextDecoder', {
configurable: true,
writable: true,
value: OriginalTextDecoder,
});
jest.resetModules();
}
});

it('handles tool use response fixture', async () => {
const { fetch, handleStreamEvents } = mockFetch();

Expand Down
48 changes: 48 additions & 0 deletions tests/internal/utils/bytes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
describe('decodeUTF8', () => {
const OriginalTextDecoder = globalThis.TextDecoder;

afterEach(() => {
Object.defineProperty(globalThis, 'TextDecoder', {
configurable: true,
writable: true,
value: OriginalTextDecoder,
});
jest.resetModules();
});

test('falls back to ArrayBuffer when TextDecoder rejects Uint8Array views', async () => {
const nativeDecoder = new OriginalTextDecoder();

class ArrayBufferOnlyTextDecoder {
readonly encoding = 'utf-8';
readonly fatal = false;
readonly ignoreBOM = false;

decode(input?: ArrayBuffer | ArrayBufferView | null) {
if (input == null) {
return nativeDecoder.decode();
}

if (!(input instanceof ArrayBuffer)) {
throw new TypeError(
"Failed to execute 'decode' on 'TextDecoder': parameter 1 is not of type 'ArrayBuffer'",
);
}

return nativeDecoder.decode(input);
}
}

Object.defineProperty(globalThis, 'TextDecoder', {
configurable: true,
writable: true,
value: ArrayBufferOnlyTextDecoder as unknown as typeof TextDecoder,
});
jest.resetModules();

const { decodeUTF8 } = await import('@anthropic-ai/sdk/internal/utils/bytes');
const bytes = new Uint8Array([0x78, 0x66, 0x6f, 0x6f, 0x79]).subarray(1, 4);

expect(decodeUTF8(bytes)).toBe('foo');
});
});