import { describe, expect, spyOn, test } from "bun:test"; import { consumeJsonEvents } from "../src/lib/json-event-stream"; import { consumeChatCompletionStreamText, consumeGeminiStreamText, consumeResponseStreamText, parseGeminiImagePayload, parseGeminiToolResponse, parseToolResponse, readFetchError, readJsonPayload, responseErrorMessage, } from "../src/services/api/image-response"; import { blobToDataUrl, delay } from "../src/services/api/video-response"; import type { ChatCompletionStreamState, GeminiStreamState, ResponseStreamState } from "../src/services/api/image-contracts"; const frame = (payload: unknown) => `data: ${JSON.stringify(payload)}\r\n\r\n`; describe("provider response replacement contracts", () => { test("JSON event framing survives every chunk boundary, comments, multiple data lines and EOF", () => { const stream = ': heartbeat\r\nevent: message\r\ndata: {"text":\r\ndata: "你好"}\r\n\r\ndata: [DONE]\r\n\r\ndata: {"last":true}'; for (let split = 0; split <= stream.length; split++) { const state = { buffer: "" }; const result: unknown[] = []; consumeJsonEvents(state, stream.slice(0, split), (value) => result.push(value)); consumeJsonEvents(state, stream.slice(split), (value) => result.push(value), true); expect(result).toEqual([{ text: "你好" }, { last: true }]); expect(state.buffer).toBe(""); } }); test("invalid JSON fails without dropping the failed frame or its unprocessed tail", () => { const state = { buffer: "" }; const result: unknown[] = []; const invalid = 'data: not-json\n\ndata: {"after":true}\n\n'; expect(() => consumeJsonEvents(state, frame({ before: true }) + invalid, (value) => result.push(value))).toThrow(); expect(result).toEqual([{ before: true }]); expect(state.buffer).toBe(invalid); }); test("Responses delta, done and completion preserve separate channels for every split", () => { const payload = { output_text: "AB" }; const stream = [ { type: "response.output_text.delta", delta: "A" }, { type: "response.reasoning_summary_text.delta", delta: "why" }, { type: "response.output_text.delta", delta: "B" }, { type: "response.output_text.done", text: "AB" }, { type: "response.reasoning_summary_text.done", text: "why" }, { type: "constructor.delta", delta: "ignore" }, { type: "response.completed", response: payload }, ] .map(frame) .join(""); for (let split = 0; split <= stream.length; split++) { const state: ResponseStreamState = { buffer: "", text: "", reasoning: "" }; const visible: string[] = [], reasoning: string[] = []; consumeResponseStreamText( state, stream.slice(0, split), (value) => visible.push(value), (value) => reasoning.push(value), ); consumeResponseStreamText( state, stream.slice(split), (value) => visible.push(value), (value) => reasoning.push(value), true, ); expect(visible).toEqual(["A", "AB"]); expect(reasoning).toEqual(["why"]); expect(state.payload).toEqual(payload); expect(state.buffer).toBe(""); } const state: ResponseStreamState = { buffer: "", text: "", reasoning: "" }; consumeResponseStreamText(state, frame({ type: "response.output_text.done", text: "complete" }) + frame({ type: "response.reasoning.done", text: "reason" })); expect(state).toMatchObject({ text: "complete", reasoning: "reason" }); consumeResponseStreamText(state, frame({ type: "response.failed", response: { error: { message: "quota" } } })); expect(state.error).toBe("quota"); }); test("Chat Completions interleaves indexed tool fragments and reasoning aliases", () => { const stream = [ { choices: [ { delta: { content: "OK", reasoning: "r", tool_calls: [ { index: 3, id: "three", function: { name: "search", arguments: '{"q":' } }, { index: 1, id: "one", function: { name: "read", arguments: '{"id":' } }, ], }, }, ], }, { choices: [ { delta: { reasoning_text: "s", tool_calls: [ { index: 1, function: { arguments: "1}" } }, { index: 3, function: { arguments: '"画布"}' } }, ], }, }, ], }, ] .map(frame) .join(""); for (let split = 0; split <= stream.length; split++) { const state: ChatCompletionStreamState = { buffer: "", text: "", reasoning: "", toolCalls: new Map() }; consumeChatCompletionStreamText(state, stream.slice(0, split)); consumeChatCompletionStreamText(state, stream.slice(split), undefined, undefined, true); expect(state.text).toBe("OK"); expect(state.reasoning).toBe("rs"); expect(state.toolCalls.get(1)).toEqual({ id: "one", name: "read", arguments: '{"id":1}' }); expect(state.toolCalls.get(3)).toEqual({ id: "three", name: "search", arguments: '{"q":"画布"}' }); } }); test("Gemini preserves thought signatures and tool order without mixing thought text", () => { const payload = { candidates: [ { content: { parts: [{ thought: true, text: "plan" }, { text: "answer" }, { functionCall: { id: "a", name: "read", args: { id: 1 } }, thought_signature: "sig-a" }] } }, { content: { parts: [{ functionCall: { id: "b", name: "search" }, thoughtSignature: "sig-b" }] } }, ], }; const result = parseGeminiToolResponse(payload); expect(result).toEqual({ content: "answer", reasoning: "plan", toolCalls: [ { id: "a", type: "function", function: { name: "read", arguments: '{"id":1}' }, thoughtSignature: "sig-a" }, { id: "b", type: "function", function: { name: "search", arguments: "{}" }, thoughtSignature: "sig-b" }, ], }); const stream = frame(payload); const state: GeminiStreamState = { buffer: "", text: "", reasoning: "", toolCalls: [] }; for (const character of stream) consumeGeminiStreamText(state, character); expect(state).toMatchObject({ text: "answer", reasoning: "plan", toolCalls: result.toolCalls }); expect(() => parseGeminiToolResponse({ promptFeedback: { blockReason: "BLOCKED" } })).toThrow("Gemini 拒绝了本次请求:BLOCKED"); }); test("image sources preserve MIME and source order; Responses chooses explicit output text", () => { expect( parseGeminiImagePayload({ candidates: [{ content: { parts: [{ inlineData: { mimeType: "image/jpeg", data: "AAA=" } }, { inline_data: { mime_type: "image/webp", data: "BBB=" } }, { fileData: { fileUri: "https://example.test/image" } }, { text: "ignored" }] } }], }).map((image) => image.dataUrl), ).toEqual(["data:image/jpeg;base64,AAA=", "data:image/webp;base64,BBB=", "https://example.test/image"]); expect(() => parseGeminiImagePayload({ candidates: [] })).toThrow("Gemini 接口没有返回图片"); expect( parseToolResponse({ output_text: "explicit", output: [ { type: "message", content: [{ text: "fallback" }] }, { type: "function_call", id: "item", call_id: "call", name: "read" }, ], }), ).toEqual({ content: "explicit", toolCalls: [{ id: "call", type: "function", function: { name: "read", arguments: "{}" } }], }); }); test("HTTP and JSON errors retain precedence and distinguish proxy HTML", async () => { expect(responseErrorMessage({ msg: "first", error: { message: "second" }, response: { error: { message: "third" } } })).toBe("first"); expect(await readFetchError(new Response('{"error":{"message":"denied"}}', { status: 403 }), "失败")).toBe("denied"); expect(await readFetchError(new Response("{}", { status: 429 }), "失败")).toBe("请求被限流或额度不足,请稍后重试"); expect(await readFetchError(new Response("x".repeat(400), { status: 500 }), "失败")).toHaveLength(300); await expect(readJsonPayload(new Response(""), "失败")).rejects.toThrow("后端代理返回了前端网页"); await expect(readJsonPayload(new Response("not-json"), "失败")).rejects.toThrow("接口没有返回有效 JSON"); }); test("video polling removes abort listeners on completion and cancellation", async () => { const controller = new AbortController(); const remove = spyOn(controller.signal, "removeEventListener"); try { await delay(0, controller.signal); expect(remove).toHaveBeenCalledTimes(1); const pending = delay(60_000, controller.signal); controller.abort(); await expect(pending).rejects.toMatchObject({ name: "AbortError" }); expect(remove).toHaveBeenCalledTimes(2); await expect(delay(0, controller.signal)).rejects.toMatchObject({ name: "AbortError" }); } finally { remove.mockRestore(); } }); test("video data URLs preserve raw bytes and MIME without FileReader", async () => { const data = Uint8Array.from({ length: 256 }, (_, i) => i); const url = await blobToDataUrl(new Blob([data], { type: "video/webm" })); expect(url.startsWith("data:video/webm;base64,")).toBe(true); expect(new Uint8Array(await (await fetch(url)).arrayBuffer())).toEqual(data); }); });