diff options
Diffstat (limited to 'packages/openai-stream/src/stream.test.ts')
| -rw-r--r-- | packages/openai-stream/src/stream.test.ts | 1652 |
1 files changed, 826 insertions, 826 deletions
diff --git a/packages/openai-stream/src/stream.test.ts b/packages/openai-stream/src/stream.test.ts index 0650153..ea8a079 100644 --- a/packages/openai-stream/src/stream.test.ts +++ b/packages/openai-stream/src/stream.test.ts @@ -5,860 +5,860 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { type StreamConfig, streamChat } from "./stream.js"; async function collectEvents(iter: AsyncIterable<ProviderEvent>): Promise<ProviderEvent[]> { - const events: ProviderEvent[] = []; - for await (const event of iter) { - events.push(event); - } - return events; + const events: ProviderEvent[] = []; + for await (const event of iter) { + events.push(event); + } + return events; } function assertDefined<T>(v: T, msg?: string): asserts v is NonNullable<T> { - if (v === undefined || v === null) { - throw new Error(msg ?? "expected defined"); - } + if (v === undefined || v === null) { + throw new Error(msg ?? "expected defined"); + } } interface CapturedSpan { - name: string; - attrs: Record<string, string | number | boolean | null>; - body?: string | undefined; - endOutcome?: - | { err?: unknown; attrs?: Record<string, string | number | boolean | null> } - | undefined; + name: string; + attrs: Record<string, string | number | boolean | null>; + body?: string | undefined; + endOutcome?: + | { err?: unknown; attrs?: Record<string, string | number | boolean | null> } + | undefined; } function createFakeLogger(): { logger: Logger; spans: CapturedSpan[] } { - const spans: CapturedSpan[] = []; - let spanAttrBuffer: Record<string, string | number | boolean | null> = {}; - let spanBodyBuffer: string | undefined; - - const fakeSpan: Span = { - id: "fake-span-id", - log: {} as Logger, - setAttributes(attrs) { - Object.assign(spanAttrBuffer, attrs); - }, - addLink() {}, - child() { - return fakeSpan; - }, - end(outcome?) { - spans.push({ - name: "provider.request", - attrs: { ...spanAttrBuffer }, - body: spanBodyBuffer, - endOutcome: outcome as CapturedSpan["endOutcome"], - }); - }, - }; - - const logger: Logger = { - debug() {}, - info() {}, - warn() {}, - error() {}, - child() { - return logger; - }, - span(_name, attrs, body) { - spanAttrBuffer = attrs ? { ...attrs } : {}; - spanBodyBuffer = body; - return fakeSpan; - }, - }; - - return { logger, spans }; + const spans: CapturedSpan[] = []; + let spanAttrBuffer: Record<string, string | number | boolean | null> = {}; + let spanBodyBuffer: string | undefined; + + const fakeSpan: Span = { + id: "fake-span-id", + log: {} as Logger, + setAttributes(attrs) { + Object.assign(spanAttrBuffer, attrs); + }, + addLink() {}, + child() { + return fakeSpan; + }, + end(outcome?) { + spans.push({ + name: "provider.request", + attrs: { ...spanAttrBuffer }, + body: spanBodyBuffer, + endOutcome: outcome as CapturedSpan["endOutcome"], + }); + }, + }; + + const logger: Logger = { + debug() {}, + info() {}, + warn() {}, + error() {}, + child() { + return logger; + }, + span(_name, attrs, body) { + spanAttrBuffer = attrs ? { ...attrs } : {}; + spanBodyBuffer = body; + return fakeSpan; + }, + }; + + return { logger, spans }; } function makeConfig(apiKey = "sk-test-1234567890abcdef"): StreamConfig { - return { - baseURL: "https://api.example.com/v1", - apiKey, - model: "test-model", - }; + return { + baseURL: "https://api.example.com/v1", + apiKey, + model: "test-model", + }; } function mockFetch(handler: (url: string | URL | Request, init?: RequestInit) => unknown): void { - globalThis.fetch = vi.fn(handler) as unknown as typeof globalThis.fetch; + globalThis.fetch = vi.fn(handler) as unknown as typeof globalThis.fetch; } function makeMessages(): readonly ChatMessage[] { - return [ - { - role: "user", - chunks: [{ type: "text", text: "Hello" }], - }, - ]; + return [ + { + role: "user", + chunks: [{ type: "text", text: "Hello" }], + }, + ]; } function sseBody(...lines: string[]): ReadableStream<Uint8Array> { - const encoder = new TextEncoder(); - const chunks = lines.map((l) => encoder.encode(`${l}\n`)); - let index = 0; - return new ReadableStream<Uint8Array>({ - pull(controller) { - if (index < chunks.length) { - const chunk = chunks[index]; - assertDefined(chunk); - controller.enqueue(chunk); - index++; - } else { - controller.close(); - } - }, - }); + const encoder = new TextEncoder(); + const chunks = lines.map((l) => encoder.encode(`${l}\n`)); + let index = 0; + return new ReadableStream<Uint8Array>({ + pull(controller) { + if (index < chunks.length) { + const chunk = chunks[index]; + assertDefined(chunk); + controller.enqueue(chunk); + index++; + } else { + controller.close(); + } + }, + }); } describe("streamChat — provider.request AFTER capture", () => { - let originalFetch: typeof globalThis.fetch; - - beforeEach(() => { - originalFetch = globalThis.fetch; - }); - - afterEach(() => { - globalThis.fetch = originalFetch; - }); - - it("opens a provider.request span with verbatim request body", async () => { - const { logger, spans } = createFakeLogger(); - const config = makeConfig(); - - mockFetch( - () => - new Response( - sseBody( - 'data: {"id":"cmpl-1","choices":[{"delta":{"content":"Hi"},"index":0}]}', - 'data: {"id":"cmpl-1","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', - "data: [DONE]", - ), - { status: 200, headers: { "Content-Type": "text/event-stream" } }, - ), - ); - - const events = await collectEvents(streamChat(config, makeMessages(), [], { logger })); - - expect(events.some((e) => e.type === "text-delta")).toBe(true); - expect(spans).toHaveLength(1); - - assertDefined(spans[0]); - const span = spans[0]; - expect(span.name).toBe("provider.request"); - expect(span.attrs["request.method"]).toBe("POST"); - expect(span.attrs["request.body"]).toBeUndefined(); - - assertDefined(span.body); - const capturedBody = JSON.parse(span.body); - expect(capturedBody.model).toBe("test-model"); - expect(capturedBody.stream).toBe(true); - expect(capturedBody.messages).toEqual([{ role: "user", content: "Hello" }]); - - expect(span.endOutcome?.attrs?.status).toBe(200); - }); - - it("redacts a long API key (≥13 chars → reveal 3 each side)", async () => { - const { logger, spans } = createFakeLogger(); - const config = makeConfig("sk-abcdefghijkmnop"); - - mockFetch( - () => - new Response( - sseBody( - 'data: {"id":"cmpl-2","choices":[{"delta":{"content":"ok"},"index":0}]}', - 'data: {"id":"cmpl-2","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', - "data: [DONE]", - ), - { status: 200, headers: { "Content-Type": "text/event-stream" } }, - ), - ); - - await collectEvents(streamChat(config, makeMessages(), [], { logger })); - - assertDefined(spans[0]); - const span = spans[0]; - const authHeader = span.attrs["request.headers.authorization"] as string; - expect(authHeader).toBe("Bearer sk-…redacted…nop"); - expect(authHeader).not.toContain("abcdefghijkm"); - }); - - it("redacts a medium API key (8–10 chars → reveal 1 each side)", async () => { - const { logger, spans } = createFakeLogger(); - const config = makeConfig("sk-abcde"); - - mockFetch( - () => - new Response( - sseBody( - 'data: {"id":"cmpl-3","choices":[{"delta":{"content":"ok"},"index":0}]}', - 'data: {"id":"cmpl-3","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', - "data: [DONE]", - ), - { status: 200, headers: { "Content-Type": "text/event-stream" } }, - ), - ); - - await collectEvents(streamChat(config, makeMessages(), [], { logger })); - - assertDefined(spans[0]); - const span = spans[0]; - const authHeader = span.attrs["request.headers.authorization"] as string; - expect(authHeader).toBe("Bearer s…redacted…e"); - }); - - it("redacts a short API key (≤7 chars → full mask)", async () => { - const { logger, spans } = createFakeLogger(); - const config = makeConfig("secret!"); - - mockFetch( - () => - new Response( - sseBody( - 'data: {"id":"cmpl-4","choices":[{"delta":{"content":"ok"},"index":0}]}', - 'data: {"id":"cmpl-4","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', - "data: [DONE]", - ), - { status: 200, headers: { "Content-Type": "text/event-stream" } }, - ), - ); - - await collectEvents(streamChat(config, makeMessages(), [], { logger })); - - assertDefined(spans[0]); - const span = spans[0]; - const authHeader = span.attrs["request.headers.authorization"] as string; - expect(authHeader).toBe("Bearer …redacted…"); - }); - - it("captures cache tokens from the response", async () => { - const { logger, spans } = createFakeLogger(); - const config = makeConfig(); - - mockFetch( - () => - new Response( - sseBody( - 'data: {"id":"cmpl-5","choices":[{"delta":{"content":"Hi"},"index":0}]}', - 'data: {"id":"cmpl-5","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', - 'data: {"id":"cmpl-5","usage":{"prompt_tokens":100,"completion_tokens":20,"cache_read_tokens":80,"cache_write_tokens":10}}', - "data: [DONE]", - ), - { status: 200, headers: { "Content-Type": "text/event-stream" } }, - ), - ); - - await collectEvents(streamChat(config, makeMessages(), [], { logger })); - - assertDefined(spans[0]); - const span = spans[0]; - expect(span.endOutcome?.attrs?.["usage.inputTokens"]).toBe(100); - expect(span.endOutcome?.attrs?.["usage.outputTokens"]).toBe(20); - expect(span.endOutcome?.attrs?.["usage.cacheReadTokens"]).toBe(80); - expect(span.endOutcome?.attrs?.["usage.cacheWriteTokens"]).toBe(10); - }); - - it("captures cache_read_tokens alone", async () => { - const { logger, spans } = createFakeLogger(); - const config = makeConfig(); - - mockFetch( - () => - new Response( - sseBody( - 'data: {"id":"cmpl-6","choices":[{"delta":{"content":"ok"},"index":0}]}', - 'data: {"id":"cmpl-6","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', - 'data: {"id":"cmpl-6","usage":{"prompt_tokens":50,"completion_tokens":5,"cache_read_tokens":45}}', - "data: [DONE]", - ), - { status: 200, headers: { "Content-Type": "text/event-stream" } }, - ), - ); - - await collectEvents(streamChat(config, makeMessages(), [], { logger })); - - assertDefined(spans[0]); - const span = spans[0]; - expect(span.endOutcome?.attrs?.["usage.cacheReadTokens"]).toBe(45); - expect(span.endOutcome?.attrs?.["usage.cacheWriteTokens"]).toBeUndefined(); - }); - - it("records HTTP error status and error body without throwing", async () => { - const { logger, spans } = createFakeLogger(); - const config = makeConfig(); - - mockFetch( - () => - new Response("Invalid request body", { - status: 400, - headers: { "Content-Type": "text/plain" }, - }), - ); - - const events = await collectEvents(streamChat(config, makeMessages(), [], { logger })); - - expect(events).toHaveLength(1); - expect(events[0]).toEqual({ - type: "error", - message: "HTTP 400: Invalid request body", - code: "400", - retryable: false, - }); - - expect(spans).toHaveLength(1); - assertDefined(spans[0]); - const span = spans[0]; - expect(span.endOutcome?.attrs?.status).toBe(400); - expect(span.endOutcome?.attrs?.["response.error_body"]).toBe("Invalid request body"); - expect(span.endOutcome?.err).toBeInstanceOf(Error); - }); - - it("records network error without throwing", async () => { - const { logger, spans } = createFakeLogger(); - const config = makeConfig(); - - mockFetch(() => { - throw new Error("connection refused"); - }); - - const events = await collectEvents(streamChat(config, makeMessages(), [], { logger })); - - expect(events).toHaveLength(1); - expect(events[0]).toEqual({ - type: "error", - message: "connection refused", - retryable: true, - }); - - expect(spans).toHaveLength(1); - assertDefined(spans[0]); - const span = spans[0]; - expect(span.endOutcome?.err).toBeInstanceOf(Error); - expect((span.endOutcome?.err as Error).message).toBe("connection refused"); - }); - - it("detects cache_control breakpoint absence in a normal request body", async () => { - const { logger, spans } = createFakeLogger(); - const config = makeConfig(); - - mockFetch( - () => - new Response( - sseBody( - 'data: {"id":"cmpl-7","choices":[{"delta":{"content":"ok"},"index":0}]}', - 'data: {"id":"cmpl-7","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', - "data: [DONE]", - ), - { status: 200, headers: { "Content-Type": "text/event-stream" } }, - ), - ); - - await collectEvents(streamChat(config, makeMessages(), [], { logger })); - - assertDefined(spans[0]); - const span = spans[0]; - expect(span.attrs["request.cache_control_present"]).toBe(false); - }); - - it("does not open a span when opts.logger is absent", async () => { - const config = makeConfig(); - - mockFetch( - () => - new Response( - sseBody( - 'data: {"id":"cmpl-8","choices":[{"delta":{"content":"ok"},"index":0}]}', - 'data: {"id":"cmpl-8","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', - "data: [DONE]", - ), - { status: 200, headers: { "Content-Type": "text/event-stream" } }, - ), - ); - - const events = await collectEvents(streamChat(config, makeMessages(), [])); - - expect(events.some((e) => e.type === "text-delta")).toBe(true); - }); - - it("fail-safe: logger throwing does not break stream()", async () => { - const brokenLogger: Logger = { - debug() {}, - info() {}, - warn() {}, - error() {}, - child() { - return brokenLogger; - }, - span() { - throw new Error("logger exploded"); - }, - }; - - const config = makeConfig(); - - mockFetch( - () => - new Response( - sseBody( - 'data: {"id":"cmpl-9","choices":[{"delta":{"content":"ok"},"index":0}]}', - 'data: {"id":"cmpl-9","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', - "data: [DONE]", - ), - { status: 200, headers: { "Content-Type": "text/event-stream" } }, - ), - ); - - const events = await collectEvents( - streamChat(config, makeMessages(), [], { logger: brokenLogger }), - ); - - expect(events.some((e) => e.type === "text-delta")).toBe(true); - expect(events.some((e) => e.type === "finish")).toBe(true); - }); - - it("redacts an 11-char API key (reveal 2 each side)", async () => { - const { logger, spans } = createFakeLogger(); - const config = makeConfig("sk-abcde1234"); - - mockFetch( - () => - new Response( - sseBody( - 'data: {"id":"cmpl-10","choices":[{"delta":{"content":"ok"},"index":0}]}', - 'data: {"id":"cmpl-10","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', - "data: [DONE]", - ), - { status: 200, headers: { "Content-Type": "text/event-stream" } }, - ), - ); - - await collectEvents(streamChat(config, makeMessages(), [], { logger })); - - assertDefined(spans[0]); - const span = spans[0]; - const authHeader = span.attrs["request.headers.authorization"] as string; - expect(authHeader).toBe("Bearer sk…redacted…34"); - }); - - it("records server error (500) as retryable", async () => { - const { logger, spans } = createFakeLogger(); - const config = makeConfig(); - - mockFetch( - () => - new Response("Internal Server Error", { - status: 500, - headers: { "Content-Type": "text/plain" }, - }), - ); - - const events = await collectEvents(streamChat(config, makeMessages(), [], { logger })); - - expect(events).toHaveLength(1); - expect(events[0]).toEqual({ - type: "error", - message: "HTTP 500: Internal Server Error", - code: "500", - retryable: true, - }); - - expect(spans).toHaveLength(1); - assertDefined(spans[0]); - expect(spans[0].endOutcome?.attrs?.status).toBe(500); - }); - - it("captures model and url on the span", async () => { - const { logger, spans } = createFakeLogger(); - const config = makeConfig(); - - mockFetch( - () => - new Response( - sseBody( - 'data: {"id":"cmpl-11","choices":[{"delta":{"content":"ok"},"index":0}]}', - 'data: {"id":"cmpl-11","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', - "data: [DONE]", - ), - { status: 200, headers: { "Content-Type": "text/event-stream" } }, - ), - ); - - await collectEvents(streamChat(config, makeMessages(), [], { logger })); - - assertDefined(spans[0]); - const span = spans[0]; - expect(span.attrs.model).toBe("test-model"); - expect(span.attrs.url).toBe("https://api.example.com/v1/chat/completions"); - }); - - it("uses opts.model override in capture", async () => { - const { logger, spans } = createFakeLogger(); - const config = makeConfig(); - - mockFetch( - () => - new Response( - sseBody( - 'data: {"id":"cmpl-12","choices":[{"delta":{"content":"ok"},"index":0}]}', - 'data: {"id":"cmpl-12","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', - "data: [DONE]", - ), - { status: 200, headers: { "Content-Type": "text/event-stream" } }, - ), - ); - - await collectEvents( - streamChat(config, makeMessages(), [], { logger, model: "override-model" }), - ); - - assertDefined(spans[0]); - const span = spans[0]; - expect(span.attrs.model).toBe("override-model"); - - assertDefined(span.body); - const capturedBody = JSON.parse(span.body); - expect(capturedBody.model).toBe("override-model"); - }); + let originalFetch: typeof globalThis.fetch; + + beforeEach(() => { + originalFetch = globalThis.fetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it("opens a provider.request span with verbatim request body", async () => { + const { logger, spans } = createFakeLogger(); + const config = makeConfig(); + + mockFetch( + () => + new Response( + sseBody( + 'data: {"id":"cmpl-1","choices":[{"delta":{"content":"Hi"},"index":0}]}', + 'data: {"id":"cmpl-1","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', + "data: [DONE]", + ), + { status: 200, headers: { "Content-Type": "text/event-stream" } }, + ), + ); + + const events = await collectEvents(streamChat(config, makeMessages(), [], { logger })); + + expect(events.some((e) => e.type === "text-delta")).toBe(true); + expect(spans).toHaveLength(1); + + assertDefined(spans[0]); + const span = spans[0]; + expect(span.name).toBe("provider.request"); + expect(span.attrs["request.method"]).toBe("POST"); + expect(span.attrs["request.body"]).toBeUndefined(); + + assertDefined(span.body); + const capturedBody = JSON.parse(span.body); + expect(capturedBody.model).toBe("test-model"); + expect(capturedBody.stream).toBe(true); + expect(capturedBody.messages).toEqual([{ role: "user", content: "Hello" }]); + + expect(span.endOutcome?.attrs?.status).toBe(200); + }); + + it("redacts a long API key (≥13 chars → reveal 3 each side)", async () => { + const { logger, spans } = createFakeLogger(); + const config = makeConfig("sk-abcdefghijkmnop"); + + mockFetch( + () => + new Response( + sseBody( + 'data: {"id":"cmpl-2","choices":[{"delta":{"content":"ok"},"index":0}]}', + 'data: {"id":"cmpl-2","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', + "data: [DONE]", + ), + { status: 200, headers: { "Content-Type": "text/event-stream" } }, + ), + ); + + await collectEvents(streamChat(config, makeMessages(), [], { logger })); + + assertDefined(spans[0]); + const span = spans[0]; + const authHeader = span.attrs["request.headers.authorization"] as string; + expect(authHeader).toBe("Bearer sk-…redacted…nop"); + expect(authHeader).not.toContain("abcdefghijkm"); + }); + + it("redacts a medium API key (8–10 chars → reveal 1 each side)", async () => { + const { logger, spans } = createFakeLogger(); + const config = makeConfig("sk-abcde"); + + mockFetch( + () => + new Response( + sseBody( + 'data: {"id":"cmpl-3","choices":[{"delta":{"content":"ok"},"index":0}]}', + 'data: {"id":"cmpl-3","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', + "data: [DONE]", + ), + { status: 200, headers: { "Content-Type": "text/event-stream" } }, + ), + ); + + await collectEvents(streamChat(config, makeMessages(), [], { logger })); + + assertDefined(spans[0]); + const span = spans[0]; + const authHeader = span.attrs["request.headers.authorization"] as string; + expect(authHeader).toBe("Bearer s…redacted…e"); + }); + + it("redacts a short API key (≤7 chars → full mask)", async () => { + const { logger, spans } = createFakeLogger(); + const config = makeConfig("secret!"); + + mockFetch( + () => + new Response( + sseBody( + 'data: {"id":"cmpl-4","choices":[{"delta":{"content":"ok"},"index":0}]}', + 'data: {"id":"cmpl-4","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', + "data: [DONE]", + ), + { status: 200, headers: { "Content-Type": "text/event-stream" } }, + ), + ); + + await collectEvents(streamChat(config, makeMessages(), [], { logger })); + + assertDefined(spans[0]); + const span = spans[0]; + const authHeader = span.attrs["request.headers.authorization"] as string; + expect(authHeader).toBe("Bearer …redacted…"); + }); + + it("captures cache tokens from the response", async () => { + const { logger, spans } = createFakeLogger(); + const config = makeConfig(); + + mockFetch( + () => + new Response( + sseBody( + 'data: {"id":"cmpl-5","choices":[{"delta":{"content":"Hi"},"index":0}]}', + 'data: {"id":"cmpl-5","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', + 'data: {"id":"cmpl-5","usage":{"prompt_tokens":100,"completion_tokens":20,"cache_read_tokens":80,"cache_write_tokens":10}}', + "data: [DONE]", + ), + { status: 200, headers: { "Content-Type": "text/event-stream" } }, + ), + ); + + await collectEvents(streamChat(config, makeMessages(), [], { logger })); + + assertDefined(spans[0]); + const span = spans[0]; + expect(span.endOutcome?.attrs?.["usage.inputTokens"]).toBe(100); + expect(span.endOutcome?.attrs?.["usage.outputTokens"]).toBe(20); + expect(span.endOutcome?.attrs?.["usage.cacheReadTokens"]).toBe(80); + expect(span.endOutcome?.attrs?.["usage.cacheWriteTokens"]).toBe(10); + }); + + it("captures cache_read_tokens alone", async () => { + const { logger, spans } = createFakeLogger(); + const config = makeConfig(); + + mockFetch( + () => + new Response( + sseBody( + 'data: {"id":"cmpl-6","choices":[{"delta":{"content":"ok"},"index":0}]}', + 'data: {"id":"cmpl-6","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', + 'data: {"id":"cmpl-6","usage":{"prompt_tokens":50,"completion_tokens":5,"cache_read_tokens":45}}', + "data: [DONE]", + ), + { status: 200, headers: { "Content-Type": "text/event-stream" } }, + ), + ); + + await collectEvents(streamChat(config, makeMessages(), [], { logger })); + + assertDefined(spans[0]); + const span = spans[0]; + expect(span.endOutcome?.attrs?.["usage.cacheReadTokens"]).toBe(45); + expect(span.endOutcome?.attrs?.["usage.cacheWriteTokens"]).toBeUndefined(); + }); + + it("records HTTP error status and error body without throwing", async () => { + const { logger, spans } = createFakeLogger(); + const config = makeConfig(); + + mockFetch( + () => + new Response("Invalid request body", { + status: 400, + headers: { "Content-Type": "text/plain" }, + }), + ); + + const events = await collectEvents(streamChat(config, makeMessages(), [], { logger })); + + expect(events).toHaveLength(1); + expect(events[0]).toEqual({ + type: "error", + message: "HTTP 400: Invalid request body", + code: "400", + retryable: false, + }); + + expect(spans).toHaveLength(1); + assertDefined(spans[0]); + const span = spans[0]; + expect(span.endOutcome?.attrs?.status).toBe(400); + expect(span.endOutcome?.attrs?.["response.error_body"]).toBe("Invalid request body"); + expect(span.endOutcome?.err).toBeInstanceOf(Error); + }); + + it("records network error without throwing", async () => { + const { logger, spans } = createFakeLogger(); + const config = makeConfig(); + + mockFetch(() => { + throw new Error("connection refused"); + }); + + const events = await collectEvents(streamChat(config, makeMessages(), [], { logger })); + + expect(events).toHaveLength(1); + expect(events[0]).toEqual({ + type: "error", + message: "connection refused", + retryable: true, + }); + + expect(spans).toHaveLength(1); + assertDefined(spans[0]); + const span = spans[0]; + expect(span.endOutcome?.err).toBeInstanceOf(Error); + expect((span.endOutcome?.err as Error).message).toBe("connection refused"); + }); + + it("detects cache_control breakpoint absence in a normal request body", async () => { + const { logger, spans } = createFakeLogger(); + const config = makeConfig(); + + mockFetch( + () => + new Response( + sseBody( + 'data: {"id":"cmpl-7","choices":[{"delta":{"content":"ok"},"index":0}]}', + 'data: {"id":"cmpl-7","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', + "data: [DONE]", + ), + { status: 200, headers: { "Content-Type": "text/event-stream" } }, + ), + ); + + await collectEvents(streamChat(config, makeMessages(), [], { logger })); + + assertDefined(spans[0]); + const span = spans[0]; + expect(span.attrs["request.cache_control_present"]).toBe(false); + }); + + it("does not open a span when opts.logger is absent", async () => { + const config = makeConfig(); + + mockFetch( + () => + new Response( + sseBody( + 'data: {"id":"cmpl-8","choices":[{"delta":{"content":"ok"},"index":0}]}', + 'data: {"id":"cmpl-8","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', + "data: [DONE]", + ), + { status: 200, headers: { "Content-Type": "text/event-stream" } }, + ), + ); + + const events = await collectEvents(streamChat(config, makeMessages(), [])); + + expect(events.some((e) => e.type === "text-delta")).toBe(true); + }); + + it("fail-safe: logger throwing does not break stream()", async () => { + const brokenLogger: Logger = { + debug() {}, + info() {}, + warn() {}, + error() {}, + child() { + return brokenLogger; + }, + span() { + throw new Error("logger exploded"); + }, + }; + + const config = makeConfig(); + + mockFetch( + () => + new Response( + sseBody( + 'data: {"id":"cmpl-9","choices":[{"delta":{"content":"ok"},"index":0}]}', + 'data: {"id":"cmpl-9","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', + "data: [DONE]", + ), + { status: 200, headers: { "Content-Type": "text/event-stream" } }, + ), + ); + + const events = await collectEvents( + streamChat(config, makeMessages(), [], { logger: brokenLogger }), + ); + + expect(events.some((e) => e.type === "text-delta")).toBe(true); + expect(events.some((e) => e.type === "finish")).toBe(true); + }); + + it("redacts an 11-char API key (reveal 2 each side)", async () => { + const { logger, spans } = createFakeLogger(); + const config = makeConfig("sk-abcde1234"); + + mockFetch( + () => + new Response( + sseBody( + 'data: {"id":"cmpl-10","choices":[{"delta":{"content":"ok"},"index":0}]}', + 'data: {"id":"cmpl-10","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', + "data: [DONE]", + ), + { status: 200, headers: { "Content-Type": "text/event-stream" } }, + ), + ); + + await collectEvents(streamChat(config, makeMessages(), [], { logger })); + + assertDefined(spans[0]); + const span = spans[0]; + const authHeader = span.attrs["request.headers.authorization"] as string; + expect(authHeader).toBe("Bearer sk…redacted…34"); + }); + + it("records server error (500) as retryable", async () => { + const { logger, spans } = createFakeLogger(); + const config = makeConfig(); + + mockFetch( + () => + new Response("Internal Server Error", { + status: 500, + headers: { "Content-Type": "text/plain" }, + }), + ); + + const events = await collectEvents(streamChat(config, makeMessages(), [], { logger })); + + expect(events).toHaveLength(1); + expect(events[0]).toEqual({ + type: "error", + message: "HTTP 500: Internal Server Error", + code: "500", + retryable: true, + }); + + expect(spans).toHaveLength(1); + assertDefined(spans[0]); + expect(spans[0].endOutcome?.attrs?.status).toBe(500); + }); + + it("captures model and url on the span", async () => { + const { logger, spans } = createFakeLogger(); + const config = makeConfig(); + + mockFetch( + () => + new Response( + sseBody( + 'data: {"id":"cmpl-11","choices":[{"delta":{"content":"ok"},"index":0}]}', + 'data: {"id":"cmpl-11","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', + "data: [DONE]", + ), + { status: 200, headers: { "Content-Type": "text/event-stream" } }, + ), + ); + + await collectEvents(streamChat(config, makeMessages(), [], { logger })); + + assertDefined(spans[0]); + const span = spans[0]; + expect(span.attrs.model).toBe("test-model"); + expect(span.attrs.url).toBe("https://api.example.com/v1/chat/completions"); + }); + + it("uses opts.model override in capture", async () => { + const { logger, spans } = createFakeLogger(); + const config = makeConfig(); + + mockFetch( + () => + new Response( + sseBody( + 'data: {"id":"cmpl-12","choices":[{"delta":{"content":"ok"},"index":0}]}', + 'data: {"id":"cmpl-12","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', + "data: [DONE]", + ), + { status: 200, headers: { "Content-Type": "text/event-stream" } }, + ), + ); + + await collectEvents( + streamChat(config, makeMessages(), [], { logger, model: "override-model" }), + ); + + assertDefined(spans[0]); + const span = spans[0]; + expect(span.attrs.model).toBe("override-model"); + + assertDefined(span.body); + const capturedBody = JSON.parse(span.body); + expect(capturedBody.model).toBe("override-model"); + }); }); describe("streamChat — hermetic replay (trace-replay)", () => { - const testDir = new URL(".", import.meta.url).pathname; - const fixturePath = `${testDir}__fixtures__/flash-text-turn.json`; - const toolFixturePath = `${testDir}__fixtures__/tool-call-turn.json`; - - it("replays a text-turn fixture and produces correct ProviderEvents", async () => { - const fixture = loadFixture(fixturePath); - const { fetch: replayFetchFn, getCapturedRequest } = replayFetch(fixture, { chunkBytes: 64 }); - - const config: StreamConfig = { - baseURL: "https://api.example.com/v1", - apiKey: "sk-test-1234567890abcdef", - model: "deepseek-v4-flash", - fetchFn: replayFetchFn, - }; - - const messages: ChatMessage[] = [ - { role: "user", chunks: [{ type: "text", text: "Hello, how are you?" }] }, - ]; - - const events = await collectEvents(streamChat(config, messages, [])); - - const textDeltas = events.filter( - (e): e is Extract<ProviderEvent, { type: "text-delta" }> => e.type === "text-delta", - ); - const fullText = textDeltas.map((e) => e.delta).join(""); - expect(fullText).toBe("Hello there friend"); - - const finishEvents = events.filter((e) => e.type === "finish"); - expect(finishEvents).toHaveLength(1); - expect(finishEvents[0]).toEqual({ type: "finish", reason: "stop" }); - - const usageEvents = events.filter( - (e): e is Extract<ProviderEvent, { type: "usage" }> => e.type === "usage", - ); - expect(usageEvents).toHaveLength(1); - expect(usageEvents[0]?.usage.inputTokens).toBe(665); - expect(usageEvents[0]?.usage.outputTokens).toBe(90); - expect(usageEvents[0]?.usage.cacheReadTokens).toBe(384); - - const captured = getCapturedRequest(); - assertDefined(captured); - expect(captured.method).toBe("POST"); - expect(captured.url).toBe("https://api.example.com/v1/chat/completions"); - expect(captured.headers["Content-Type"]).toBe("application/json"); - expect(captured.headers.Authorization).toBe("Bearer sk-test-1234567890abcdef"); - - assertDefined(captured.body); - const capturedBody = JSON.parse(captured.body); - expect(capturedBody.model).toBe("deepseek-v4-flash"); - expect(capturedBody.stream).toBe(true); - expect(capturedBody.messages).toEqual([{ role: "user", content: "Hello, how are you?" }]); - }); - - it("replays a tool-call-turn fixture and produces tool-call + finish events", async () => { - const fixture = loadFixture(toolFixturePath); - const { fetch: replayFetchFn, getCapturedRequest } = replayFetch(fixture, { chunkBytes: 48 }); - - const config: StreamConfig = { - baseURL: "https://api.example.com/v1", - apiKey: "sk-test-1234567890abcdef", - model: "deepseek-v4-flash", - fetchFn: replayFetchFn, - }; - - const messages: ChatMessage[] = [ - { role: "user", chunks: [{ type: "text", text: "What is the weather in Tokyo?" }] }, - ]; - - const weatherTool = { - name: "get_weather", - description: "Get current weather for a location", - parameters: { - type: "object" as const, - properties: { location: { type: "string" as const } }, - required: ["location"], - }, - execute: async () => ({ content: "" }), - }; - - const events = await collectEvents(streamChat(config, messages, [weatherTool])); - - const toolCalls = events.filter( - (e): e is Extract<ProviderEvent, { type: "tool-call" }> => e.type === "tool-call", - ); - expect(toolCalls).toHaveLength(1); - expect(toolCalls[0]?.toolCallId).toBe("call_abc123"); - expect(toolCalls[0]?.toolName).toBe("get_weather"); - expect(toolCalls[0]?.input).toEqual({ location: "Tokyo" }); - - const finishEvents = events.filter((e) => e.type === "finish"); - expect(finishEvents).toHaveLength(1); - expect(finishEvents[0]).toEqual({ type: "finish", reason: "tool_calls" }); - - const usageEvents = events.filter( - (e): e is Extract<ProviderEvent, { type: "usage" }> => e.type === "usage", - ); - expect(usageEvents).toHaveLength(1); - expect(usageEvents[0]?.usage.inputTokens).toBe(45); - expect(usageEvents[0]?.usage.outputTokens).toBe(12); - expect(usageEvents[0]?.usage.cacheReadTokens).toBe(30); - expect(usageEvents[0]?.usage.cacheWriteTokens).toBe(5); - - const captured = getCapturedRequest(); - assertDefined(captured); - expect(captured.method).toBe("POST"); - assertDefined(captured.body); - const capturedBody = JSON.parse(captured.body); - expect(capturedBody.tools).toHaveLength(1); - expect(capturedBody.tools[0].function.name).toBe("get_weather"); - }); + const testDir = new URL(".", import.meta.url).pathname; + const fixturePath = `${testDir}__fixtures__/flash-text-turn.json`; + const toolFixturePath = `${testDir}__fixtures__/tool-call-turn.json`; + + it("replays a text-turn fixture and produces correct ProviderEvents", async () => { + const fixture = loadFixture(fixturePath); + const { fetch: replayFetchFn, getCapturedRequest } = replayFetch(fixture, { chunkBytes: 64 }); + + const config: StreamConfig = { + baseURL: "https://api.example.com/v1", + apiKey: "sk-test-1234567890abcdef", + model: "deepseek-v4-flash", + fetchFn: replayFetchFn, + }; + + const messages: ChatMessage[] = [ + { role: "user", chunks: [{ type: "text", text: "Hello, how are you?" }] }, + ]; + + const events = await collectEvents(streamChat(config, messages, [])); + + const textDeltas = events.filter( + (e): e is Extract<ProviderEvent, { type: "text-delta" }> => e.type === "text-delta", + ); + const fullText = textDeltas.map((e) => e.delta).join(""); + expect(fullText).toBe("Hello there friend"); + + const finishEvents = events.filter((e) => e.type === "finish"); + expect(finishEvents).toHaveLength(1); + expect(finishEvents[0]).toEqual({ type: "finish", reason: "stop" }); + + const usageEvents = events.filter( + (e): e is Extract<ProviderEvent, { type: "usage" }> => e.type === "usage", + ); + expect(usageEvents).toHaveLength(1); + expect(usageEvents[0]?.usage.inputTokens).toBe(665); + expect(usageEvents[0]?.usage.outputTokens).toBe(90); + expect(usageEvents[0]?.usage.cacheReadTokens).toBe(384); + + const captured = getCapturedRequest(); + assertDefined(captured); + expect(captured.method).toBe("POST"); + expect(captured.url).toBe("https://api.example.com/v1/chat/completions"); + expect(captured.headers["Content-Type"]).toBe("application/json"); + expect(captured.headers.Authorization).toBe("Bearer sk-test-1234567890abcdef"); + + assertDefined(captured.body); + const capturedBody = JSON.parse(captured.body); + expect(capturedBody.model).toBe("deepseek-v4-flash"); + expect(capturedBody.stream).toBe(true); + expect(capturedBody.messages).toEqual([{ role: "user", content: "Hello, how are you?" }]); + }); + + it("replays a tool-call-turn fixture and produces tool-call + finish events", async () => { + const fixture = loadFixture(toolFixturePath); + const { fetch: replayFetchFn, getCapturedRequest } = replayFetch(fixture, { chunkBytes: 48 }); + + const config: StreamConfig = { + baseURL: "https://api.example.com/v1", + apiKey: "sk-test-1234567890abcdef", + model: "deepseek-v4-flash", + fetchFn: replayFetchFn, + }; + + const messages: ChatMessage[] = [ + { role: "user", chunks: [{ type: "text", text: "What is the weather in Tokyo?" }] }, + ]; + + const weatherTool = { + name: "get_weather", + description: "Get current weather for a location", + parameters: { + type: "object" as const, + properties: { location: { type: "string" as const } }, + required: ["location"], + }, + execute: async () => ({ content: "" }), + }; + + const events = await collectEvents(streamChat(config, messages, [weatherTool])); + + const toolCalls = events.filter( + (e): e is Extract<ProviderEvent, { type: "tool-call" }> => e.type === "tool-call", + ); + expect(toolCalls).toHaveLength(1); + expect(toolCalls[0]?.toolCallId).toBe("call_abc123"); + expect(toolCalls[0]?.toolName).toBe("get_weather"); + expect(toolCalls[0]?.input).toEqual({ location: "Tokyo" }); + + const finishEvents = events.filter((e) => e.type === "finish"); + expect(finishEvents).toHaveLength(1); + expect(finishEvents[0]).toEqual({ type: "finish", reason: "tool_calls" }); + + const usageEvents = events.filter( + (e): e is Extract<ProviderEvent, { type: "usage" }> => e.type === "usage", + ); + expect(usageEvents).toHaveLength(1); + expect(usageEvents[0]?.usage.inputTokens).toBe(45); + expect(usageEvents[0]?.usage.outputTokens).toBe(12); + expect(usageEvents[0]?.usage.cacheReadTokens).toBe(30); + expect(usageEvents[0]?.usage.cacheWriteTokens).toBe(5); + + const captured = getCapturedRequest(); + assertDefined(captured); + expect(captured.method).toBe("POST"); + assertDefined(captured.body); + const capturedBody = JSON.parse(captured.body); + expect(capturedBody.tools).toHaveLength(1); + expect(capturedBody.tools[0].function.name).toBe("get_weather"); + }); }); describe("streamChat — record-mode redaction (trace-replay)", () => { - /** - * Graduated secret mask — §6 tiers. Duplicated locally (isolation-over-dry). - * ≥13 → reveal 3 each side · 11–12 → 2 · 8–10 → 1 · ≤7 → full mask. - */ - function maskSecret(value: string): string { - const len = value.length; - if (len <= 7) return "…redacted…"; - let reveal: number; - if (len >= 13) { - reveal = 3; - } else if (len >= 11) { - reveal = 2; - } else { - reveal = 1; - } - return `${value.slice(0, reveal)}…redacted…${value.slice(-reveal)}`; - } - - it("self-redacts auth header in onExchange and produces a secret-free fixture", async () => { - const apiKey = "sk-abcdefghijkmnop"; - const responseBody = - 'data: {"id":"cmpl-r","choices":[{"delta":{"content":"ok"},"index":0}],"usage":{"prompt_tokens":5,"completion_tokens":1,"cache_read_tokens":0,"cache_write_tokens":0}}\n\ndata: [DONE]\n'; - - let capturedFixture: HttpExchangeFixture | undefined; - const wrappedFetch = recordFetch( - async () => - new Response(responseBody, { - status: 200, - headers: { "content-type": "text/event-stream" }, - }), - (fx) => { - const redactedHeaders: Record<string, string> = {}; - for (const [key, value] of Object.entries(fx.request.headers)) { - if (key.toLowerCase() === "authorization") { - const token = value.replace(/^Bearer\s+/i, ""); - redactedHeaders[key] = `Bearer ${maskSecret(token)}`; - } else { - redactedHeaders[key] = value; - } - } - capturedFixture = { - request: { ...fx.request, headers: redactedHeaders }, - response: fx.response, - ...(fx.meta !== undefined ? { meta: fx.meta } : {}), - }; - }, - ); - - await wrappedFetch("https://api.example.com/v1/chat/completions", { - method: "POST", - headers: { - "content-type": "application/json", - authorization: `Bearer ${apiKey}`, - }, - body: '{"model":"test","messages":[{"role":"user","content":"hi"}],"stream":true}', - }); - - assertDefined(capturedFixture); - - expect(capturedFixture.request.headers.authorization).toBe("Bearer sk-…redacted…nop"); - expect(capturedFixture.request.headers.authorization).not.toContain("abcdefghijkm"); - expect(capturedFixture.request.headers["content-type"]).toBe("application/json"); - - expect(capturedFixture.request.body).toContain('"model":"test"'); - expect(capturedFixture.request.body).toContain('"content":"hi"'); - - expect(capturedFixture.response.status).toBe(200); - expect(capturedFixture.response.body).toBe(responseBody); - - const serialized = serializeFixture(capturedFixture); - expect(serialized).toContain("Bearer sk-…redacted…nop"); - expect(serialized).not.toContain("abcdefghijkm"); - expect(serialized).toContain("content"); - expect(serialized).toContain("hi"); - }); - - it("redacts capitalized Authorization header (the real leak casing)", async () => { - const apiKey = "sk-LIVEKEY1234567890abcdef"; - const responseBody = "data: [DONE]\n"; - - let capturedFixture: HttpExchangeFixture | undefined; - const wrappedFetch = recordFetch( - async () => - new Response(responseBody, { - status: 200, - headers: { "content-type": "text/event-stream" }, - }), - (fx) => { - const redactedHeaders: Record<string, string> = {}; - for (const [key, value] of Object.entries(fx.request.headers)) { - if (key.toLowerCase() === "authorization") { - const token = value.replace(/^Bearer\s+/i, ""); - redactedHeaders[key] = `Bearer ${maskSecret(token)}`; - } else { - redactedHeaders[key] = value; - } - } - capturedFixture = { - request: { ...fx.request, headers: redactedHeaders }, - response: fx.response, - }; - }, - ); - - await wrappedFetch("https://api.example.com/v1/chat/completions", { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${apiKey}`, - }, - body: '{"model":"test","messages":[],"stream":true}', - }); - - assertDefined(capturedFixture); - expect(capturedFixture.request.headers.Authorization).toBe("Bearer sk-…redacted…def"); - expect(capturedFixture.request.headers.Authorization).not.toContain("LIVEKEY1234567890abc"); - - const serialized = serializeFixture(capturedFixture); - expect(serialized).not.toContain("LIVEKEY1234567890abc"); - expect(serialized).toContain("Bearer sk-…redacted…def"); - }); - - it("redacts lowercase authorization header", async () => { - const apiKey = "sk-abcdefghijkmnop"; - - let capturedFixture: HttpExchangeFixture | undefined; - const wrappedFetch = recordFetch( - async () => new Response("data: [DONE]\n", { status: 200 }), - (fx) => { - const redactedHeaders: Record<string, string> = {}; - for (const [key, value] of Object.entries(fx.request.headers)) { - if (key.toLowerCase() === "authorization") { - const token = value.replace(/^Bearer\s+/i, ""); - redactedHeaders[key] = `Bearer ${maskSecret(token)}`; - } else { - redactedHeaders[key] = value; - } - } - capturedFixture = { - request: { ...fx.request, headers: redactedHeaders }, - response: fx.response, - }; - }, - ); - - await wrappedFetch("https://api.example.com/v1/chat/completions", { - method: "POST", - headers: { authorization: `Bearer ${apiKey}` }, - body: null, - }); - - assertDefined(capturedFixture); - expect(capturedFixture.request.headers.authorization).toBe("Bearer sk-…redacted…nop"); - expect(capturedFixture.request.headers.authorization).not.toContain("abcdefghijkm"); - }); - - it("guard: no header named authorization (any case) survives with a raw sk- token", async () => { - const apiKey = "sk-REALKEY_1234567890abcdef"; - - let capturedFixture: HttpExchangeFixture | undefined; - const wrappedFetch = recordFetch( - async () => new Response("data: [DONE]\n", { status: 200 }), - (fx) => { - const redactedHeaders: Record<string, string> = {}; - for (const [key, value] of Object.entries(fx.request.headers)) { - if (key.toLowerCase() === "authorization") { - const token = value.replace(/^Bearer\s+/i, ""); - redactedHeaders[key] = `Bearer ${maskSecret(token)}`; - } else { - redactedHeaders[key] = value; - } - } - capturedFixture = { - request: { ...fx.request, headers: redactedHeaders }, - response: fx.response, - }; - }, - ); - - await wrappedFetch("https://api.example.com/v1/chat/completions", { - method: "POST", - headers: { Authorization: `Bearer ${apiKey}` }, - body: null, - }); - - assertDefined(capturedFixture); - for (const [key, value] of Object.entries(capturedFixture.request.headers)) { - if (key.toLowerCase() === "authorization") { - expect(value).not.toContain(apiKey); - expect(value).not.toMatch(/sk-[A-Za-z0-9]{10,}/); - } - } - - const serialized = serializeFixture(capturedFixture); - expect(serialized).not.toContain(apiKey); - expect(serialized).not.toMatch(/sk-[A-Za-z0-9]{10,}/); - }); - - it("redacts a short API key (≤7 chars → full mask)", async () => { - let capturedFixture: HttpExchangeFixture | undefined; - const wrappedFetch = recordFetch( - async () => new Response("data: [DONE]\n", { status: 200 }), - (fx) => { - const redactedHeaders: Record<string, string> = {}; - for (const [key, value] of Object.entries(fx.request.headers)) { - if (key.toLowerCase() === "authorization") { - const token = value.replace(/^Bearer\s+/i, ""); - redactedHeaders[key] = `Bearer ${maskSecret(token)}`; - } else { - redactedHeaders[key] = value; - } - } - capturedFixture = { - request: { ...fx.request, headers: redactedHeaders }, - response: fx.response, - }; - }, - ); - - await wrappedFetch("https://api.example.com/v1/chat/completions", { - method: "POST", - headers: { authorization: "Bearer secret!" }, - body: null, - }); - - assertDefined(capturedFixture); - expect(capturedFixture.request.headers.authorization).toBe("Bearer …redacted…"); - }); + /** + * Graduated secret mask — §6 tiers. Duplicated locally (isolation-over-dry). + * ≥13 → reveal 3 each side · 11–12 → 2 · 8–10 → 1 · ≤7 → full mask. + */ + function maskSecret(value: string): string { + const len = value.length; + if (len <= 7) return "…redacted…"; + let reveal: number; + if (len >= 13) { + reveal = 3; + } else if (len >= 11) { + reveal = 2; + } else { + reveal = 1; + } + return `${value.slice(0, reveal)}…redacted…${value.slice(-reveal)}`; + } + + it("self-redacts auth header in onExchange and produces a secret-free fixture", async () => { + const apiKey = "sk-abcdefghijkmnop"; + const responseBody = + 'data: {"id":"cmpl-r","choices":[{"delta":{"content":"ok"},"index":0}],"usage":{"prompt_tokens":5,"completion_tokens":1,"cache_read_tokens":0,"cache_write_tokens":0}}\n\ndata: [DONE]\n'; + + let capturedFixture: HttpExchangeFixture | undefined; + const wrappedFetch = recordFetch( + async () => + new Response(responseBody, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }), + (fx) => { + const redactedHeaders: Record<string, string> = {}; + for (const [key, value] of Object.entries(fx.request.headers)) { + if (key.toLowerCase() === "authorization") { + const token = value.replace(/^Bearer\s+/i, ""); + redactedHeaders[key] = `Bearer ${maskSecret(token)}`; + } else { + redactedHeaders[key] = value; + } + } + capturedFixture = { + request: { ...fx.request, headers: redactedHeaders }, + response: fx.response, + ...(fx.meta !== undefined ? { meta: fx.meta } : {}), + }; + }, + ); + + await wrappedFetch("https://api.example.com/v1/chat/completions", { + method: "POST", + headers: { + "content-type": "application/json", + authorization: `Bearer ${apiKey}`, + }, + body: '{"model":"test","messages":[{"role":"user","content":"hi"}],"stream":true}', + }); + + assertDefined(capturedFixture); + + expect(capturedFixture.request.headers.authorization).toBe("Bearer sk-…redacted…nop"); + expect(capturedFixture.request.headers.authorization).not.toContain("abcdefghijkm"); + expect(capturedFixture.request.headers["content-type"]).toBe("application/json"); + + expect(capturedFixture.request.body).toContain('"model":"test"'); + expect(capturedFixture.request.body).toContain('"content":"hi"'); + + expect(capturedFixture.response.status).toBe(200); + expect(capturedFixture.response.body).toBe(responseBody); + + const serialized = serializeFixture(capturedFixture); + expect(serialized).toContain("Bearer sk-…redacted…nop"); + expect(serialized).not.toContain("abcdefghijkm"); + expect(serialized).toContain("content"); + expect(serialized).toContain("hi"); + }); + + it("redacts capitalized Authorization header (the real leak casing)", async () => { + const apiKey = "sk-LIVEKEY1234567890abcdef"; + const responseBody = "data: [DONE]\n"; + + let capturedFixture: HttpExchangeFixture | undefined; + const wrappedFetch = recordFetch( + async () => + new Response(responseBody, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }), + (fx) => { + const redactedHeaders: Record<string, string> = {}; + for (const [key, value] of Object.entries(fx.request.headers)) { + if (key.toLowerCase() === "authorization") { + const token = value.replace(/^Bearer\s+/i, ""); + redactedHeaders[key] = `Bearer ${maskSecret(token)}`; + } else { + redactedHeaders[key] = value; + } + } + capturedFixture = { + request: { ...fx.request, headers: redactedHeaders }, + response: fx.response, + }; + }, + ); + + await wrappedFetch("https://api.example.com/v1/chat/completions", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + body: '{"model":"test","messages":[],"stream":true}', + }); + + assertDefined(capturedFixture); + expect(capturedFixture.request.headers.Authorization).toBe("Bearer sk-…redacted…def"); + expect(capturedFixture.request.headers.Authorization).not.toContain("LIVEKEY1234567890abc"); + + const serialized = serializeFixture(capturedFixture); + expect(serialized).not.toContain("LIVEKEY1234567890abc"); + expect(serialized).toContain("Bearer sk-…redacted…def"); + }); + + it("redacts lowercase authorization header", async () => { + const apiKey = "sk-abcdefghijkmnop"; + + let capturedFixture: HttpExchangeFixture | undefined; + const wrappedFetch = recordFetch( + async () => new Response("data: [DONE]\n", { status: 200 }), + (fx) => { + const redactedHeaders: Record<string, string> = {}; + for (const [key, value] of Object.entries(fx.request.headers)) { + if (key.toLowerCase() === "authorization") { + const token = value.replace(/^Bearer\s+/i, ""); + redactedHeaders[key] = `Bearer ${maskSecret(token)}`; + } else { + redactedHeaders[key] = value; + } + } + capturedFixture = { + request: { ...fx.request, headers: redactedHeaders }, + response: fx.response, + }; + }, + ); + + await wrappedFetch("https://api.example.com/v1/chat/completions", { + method: "POST", + headers: { authorization: `Bearer ${apiKey}` }, + body: null, + }); + + assertDefined(capturedFixture); + expect(capturedFixture.request.headers.authorization).toBe("Bearer sk-…redacted…nop"); + expect(capturedFixture.request.headers.authorization).not.toContain("abcdefghijkm"); + }); + + it("guard: no header named authorization (any case) survives with a raw sk- token", async () => { + const apiKey = "sk-REALKEY_1234567890abcdef"; + + let capturedFixture: HttpExchangeFixture | undefined; + const wrappedFetch = recordFetch( + async () => new Response("data: [DONE]\n", { status: 200 }), + (fx) => { + const redactedHeaders: Record<string, string> = {}; + for (const [key, value] of Object.entries(fx.request.headers)) { + if (key.toLowerCase() === "authorization") { + const token = value.replace(/^Bearer\s+/i, ""); + redactedHeaders[key] = `Bearer ${maskSecret(token)}`; + } else { + redactedHeaders[key] = value; + } + } + capturedFixture = { + request: { ...fx.request, headers: redactedHeaders }, + response: fx.response, + }; + }, + ); + + await wrappedFetch("https://api.example.com/v1/chat/completions", { + method: "POST", + headers: { Authorization: `Bearer ${apiKey}` }, + body: null, + }); + + assertDefined(capturedFixture); + for (const [key, value] of Object.entries(capturedFixture.request.headers)) { + if (key.toLowerCase() === "authorization") { + expect(value).not.toContain(apiKey); + expect(value).not.toMatch(/sk-[A-Za-z0-9]{10,}/); + } + } + + const serialized = serializeFixture(capturedFixture); + expect(serialized).not.toContain(apiKey); + expect(serialized).not.toMatch(/sk-[A-Za-z0-9]{10,}/); + }); + + it("redacts a short API key (≤7 chars → full mask)", async () => { + let capturedFixture: HttpExchangeFixture | undefined; + const wrappedFetch = recordFetch( + async () => new Response("data: [DONE]\n", { status: 200 }), + (fx) => { + const redactedHeaders: Record<string, string> = {}; + for (const [key, value] of Object.entries(fx.request.headers)) { + if (key.toLowerCase() === "authorization") { + const token = value.replace(/^Bearer\s+/i, ""); + redactedHeaders[key] = `Bearer ${maskSecret(token)}`; + } else { + redactedHeaders[key] = value; + } + } + capturedFixture = { + request: { ...fx.request, headers: redactedHeaders }, + response: fx.response, + }; + }, + ); + + await wrappedFetch("https://api.example.com/v1/chat/completions", { + method: "POST", + headers: { authorization: "Bearer secret!" }, + body: null, + }); + + assertDefined(capturedFixture); + expect(capturedFixture.request.headers.authorization).toBe("Bearer …redacted…"); + }); }); |
