import { describe, expect, it } from "vitest"; import { createXllm, XllmError, registerAdapter, type XStreamEvent } from "./index"; import type { ProviderAdapter } from "./providers/types"; const toJsonResponse = (payload: unknown, status = 200, headers?: Record): Response => new Response(JSON.stringify(payload), { status, headers: { "content-type": "application/json", ...headers }, }); const toSSEBody = (frames: string[]): ReadableStream => { const encoder = new TextEncoder(); return new ReadableStream({ start(controller) { for (const frame of frames) { controller.enqueue(encoder.encode(frame)); } controller.close(); }, }); }; describe("xllm", () => { it("normalizes non-stream response", async () => { const mockFetch: typeof fetch = async () => toJsonResponse({ model: "gpt-4o-mini", choices: [ { message: { content: "hello", }, finish_reason: "stop", }, ], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2, }, }); const client = createXllm({ provider: "openai-compatible", model: "gpt-4o-mini", apiKey: "test", fetch: mockFetch, }); const result = await client.generate({ messages: [{ role: "user", content: [{ type: "text", text: "hi" }] }], }); expect(result.text).toBe("hello"); expect(result.usage?.totalTokens).toBe(2); }); it("accepts string content in messages", async () => { const mockFetch: typeof fetch = async () => toJsonResponse({ model: "gpt-4o-mini", choices: [{ message: { content: "hi back" }, finish_reason: "stop" }], }); const client = createXllm({ provider: "openai-compatible", apiKey: "test", fetch: mockFetch }); const result = await client.generate({ messages: [{ role: "user", content: "hello" }], }); expect(result.text).toBe("hi back"); }); it("streams text delta events", async () => { const sseFrames = [ 'data: {"id":"r1","model":"deepseek-chat","choices":[{"delta":{"content":"he"},"finish_reason":null}]}\n\n', 'data: {"id":"r1","model":"deepseek-chat","choices":[{"delta":{"content":"llo"},"finish_reason":"stop"}]}\n\n', "data: [DONE]\n\n", ]; const mockFetch: typeof fetch = async () => new Response(toSSEBody(sseFrames), { status: 200, headers: { "content-type": "text/event-stream" }, }); const client = createXllm({ provider: "deepseek", model: "deepseek-chat", apiKey: "test", fetch: mockFetch, }); const events: XStreamEvent[] = []; for await (const event of client.stream({ messages: [{ role: "user", content: [{ type: "text", text: "hi" }] }], })) { events.push(event); } const text = events .filter((event): event is Extract => event.type === "text.delta") .map((event) => event.text) .join(""); expect(text).toBe("hello"); expect(events.some((event) => event.type === "response.done")).toBe(true); }); it("runs tool loop and returns final response", async () => { let call = 0; let secondRequestBody: any = undefined; const mockFetch: typeof fetch = async (_input, init) => { call += 1; const request = init as RequestInit | undefined; if (call === 2 && typeof request?.body === "string") { secondRequestBody = JSON.parse(request.body); } if (call === 1) { return toJsonResponse({ model: "deepseek-chat", choices: [ { message: { content: "", reasoning_content: "need weather tool for Shanghai", tool_calls: [ { id: "call_1", function: { name: "get_weather", arguments: '{"city":"Shanghai"}', }, }, ], }, finish_reason: "tool_calls", }, ], }); } return toJsonResponse({ model: "deepseek-chat", choices: [ { message: { content: "上海当前多云,24 度。", }, finish_reason: "stop", }, ], }); }; const client = createXllm({ provider: "deepseek", model: "deepseek-chat", apiKey: "test", fetch: mockFetch, }); const result = await client.chatWithTools( { messages: [{ role: "user", content: [{ type: "text", text: "上海天气如何?" }] }], tools: [{ name: "get_weather", parameters: { type: "object" } }], }, { get_weather: (args) => ({ ok: true, args }), }, ); expect(call).toBe(2); expect(secondRequestBody?.messages?.some((msg: any) => Array.isArray(msg.tool_calls) && msg.tool_calls.length > 0)).toBe( true, ); const assistantWithReasoning = secondRequestBody?.messages?.find( (msg: any) => msg.role === "assistant" && typeof msg.reasoning_content === "string", ); expect(assistantWithReasoning?.reasoning_content).toBe("need weather tool for Shanghai"); expect(result.toolCallsExecuted).toBe(1); expect(result.response.text).toContain("上海"); }); it("streams with tool loop and yields final text", async () => { let call = 0; let secondBody: any = undefined; const sseToolRound = [ 'data: {"id":"r1","model":"deepseek-chat","choices":[{"delta":{"reasoning_content":"cot "},"finish_reason":null}]}\n\n', 'data: {"id":"r1","model":"deepseek-chat","choices":[{"delta":{"reasoning_content":"here"},"finish_reason":null}]}\n\n', 'data: {"id":"r1","model":"deepseek-chat","choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"get_weather","arguments":"{\\"city\\":\\"Shanghai\\"}"}}]},"finish_reason":"tool_calls"}]}\n\n', "data: [DONE]\n\n", ]; const sseFinalRound = [ 'data: {"id":"r2","model":"deepseek-chat","choices":[{"delta":{"content":"上海今天多云。"},"finish_reason":"stop"}]}\n\n', "data: [DONE]\n\n", ]; const mockFetch: typeof fetch = async (_input, init) => { call += 1; if (call === 2 && typeof init?.body === "string") { secondBody = JSON.parse(init.body); } const frames = call === 1 ? sseToolRound : sseFinalRound; return new Response(toSSEBody(frames), { status: 200, headers: { "content-type": "text/event-stream" }, }); }; const client = createXllm({ provider: "deepseek", model: "deepseek-chat", apiKey: "test", fetch: mockFetch, }); let text = ""; for await (const event of client.streamWithTools( { messages: [{ role: "user", content: [{ type: "text", text: "上海天气如何?" }] }], tools: [{ name: "get_weather", parameters: { type: "object" } }], }, { get_weather: () => ({ ok: true }), }, )) { if (event.type === "text.delta") text += event.text; } expect(call).toBe(2); expect(text).toContain("上海"); const asst = secondBody?.messages?.find((m: any) => m.role === "assistant" && m.reasoning_content); expect(asst?.reasoning_content).toBe("cot here"); }); it("returns tool error message when strategy is return_tool_error_message", async () => { let call = 0; const mockFetch: typeof fetch = async () => { call += 1; if (call === 1) { return toJsonResponse({ model: "deepseek-chat", choices: [ { message: { content: "", tool_calls: [ { id: "call_1", function: { name: "get_weather", arguments: '{"city":"Shanghai"}' }, }, ], }, finish_reason: "tool_calls", }, ], }); } return toJsonResponse({ model: "deepseek-chat", choices: [{ message: { content: "已收到工具错误信息。" }, finish_reason: "stop" }], }); }; const client = createXllm({ provider: "deepseek", model: "deepseek-chat", apiKey: "test", fetch: mockFetch, }); const result = await client.chatWithTools( { messages: [{ role: "user", content: [{ type: "text", text: "上海天气如何?" }] }], tools: [{ name: "get_weather", parameters: { type: "object" } }], toolErrorStrategy: "return_tool_error_message", }, { get_weather: () => { throw new Error("tool timeout"); }, }, ); expect(result.toolCallsExecuted).toBe(1); expect(result.response.text).toContain("工具错误"); }); it("sends thinking and reasoning_effort via providerExtras for DeepSeek-style bodies", async () => { const bodies: Array> = []; const mockFetch: typeof fetch = async (_input, init) => { bodies.push(typeof init?.body === "string" ? JSON.parse(init.body) : {}); return toJsonResponse({ model: "deepseek-chat", choices: [{ message: { content: "ok" }, finish_reason: "stop" }], }); }; const client = createXllm({ provider: "deepseek", apiKey: "test", fetch: mockFetch }); await client.generate({ messages: [{ role: "user", content: [{ type: "text", text: "hi" }] }], providerExtras: { thinking: { type: "disabled" }, reasoning_effort: "high" }, }); expect(bodies[0]?.thinking).toEqual({ type: "disabled" }); expect(bodies[0]?.reasoning_effort).toBe("high"); await client.generate({ messages: [{ role: "user", content: [{ type: "text", text: "hi" }] }], providerExtras: { reasoning_effort: "max" }, }); expect(bodies[1]?.reasoning_effort).toBe("max"); }); it("parses reasoning_content in non-stream response", async () => { const mockFetch: typeof fetch = async () => toJsonResponse({ choices: [ { message: { content: "final", reasoning_content: "chain of thought" }, finish_reason: "stop", }, ], }); const client = createXllm({ provider: "deepseek", apiKey: "test", fetch: mockFetch }); const result = await client.generate({ messages: [{ role: "user", content: [{ type: "text", text: "q" }] }], }); expect(result.text).toBe("final"); expect(result.reasoning).toBe("chain of thought"); }); it("streams reasoning.delta when delta.reasoning_content is present", async () => { const sseFrames = [ 'data: {"id":"r1","model":"deepseek-chat","choices":[{"delta":{"reasoning_content":"think"},"finish_reason":null}]}\n\n', 'data: {"id":"r1","model":"deepseek-chat","choices":[{"delta":{"content":"out"},"finish_reason":"stop"}]}\n\n', "data: [DONE]\n\n", ]; const mockFetch: typeof fetch = async () => new Response(toSSEBody(sseFrames), { status: 200, headers: { "content-type": "text/event-stream" }, }); const client = createXllm({ provider: "deepseek", apiKey: "test", fetch: mockFetch }); const events: XStreamEvent[] = []; for await (const event of client.stream({ messages: [{ role: "user", content: [{ type: "text", text: "hi" }] }], })) { events.push(event); } const reasoning = events .filter((e): e is Extract => e.type === "reasoning.delta") .map((e) => e.text) .join(""); const text = events .filter((e): e is Extract => e.type === "text.delta") .map((e) => e.text) .join(""); expect(reasoning).toBe("think"); expect(text).toBe("out"); }); it("merges providerExtras into request body last", async () => { let body: Record = {}; const mockFetch: typeof fetch = async (_input, init) => { body = typeof init?.body === "string" ? JSON.parse(init.body) : {}; return toJsonResponse({ choices: [{ message: { content: "ok" }, finish_reason: "stop" }], }); }; const client = createXllm({ provider: "openai-compatible", apiKey: "test", fetch: mockFetch }); await client.generate({ messages: [{ role: "user", content: [{ type: "text", text: "hi" }] }], temperature: 0.5, providerExtras: { temperature: 0.9, custom_vendor_flag: true }, }); expect(body.temperature).toBe(0.9); expect(body.custom_vendor_flag).toBe(true); }); it("does not include undefined optional fields in request body", async () => { let body: Record = {}; const mockFetch: typeof fetch = async (_input, init) => { body = typeof init?.body === "string" ? JSON.parse(init.body) : {}; return toJsonResponse({ choices: [{ message: { content: "ok" }, finish_reason: "stop" }], }); }; const client = createXllm({ provider: "openai-compatible", apiKey: "test", fetch: mockFetch }); await client.generate({ messages: [{ role: "user", content: "hi" }], }); expect(body).not.toHaveProperty("temperature"); expect(body).not.toHaveProperty("top_p"); expect(body).not.toHaveProperty("max_tokens"); expect(body).not.toHaveProperty("metadata"); }); // --- 错误路径测试 --- it("throws AUTH_ERROR on 401 response", async () => { const mockFetch: typeof fetch = async () => toJsonResponse({ error: { message: "Invalid API key" } }, 401, { "x-request-id": "req_123" }); const client = createXllm({ provider: "openai-compatible", apiKey: "bad", fetch: mockFetch }); try { await client.generate({ messages: [{ role: "user", content: "hi" }] }); expect.unreachable("Should have thrown"); } catch (error) { expect(error).toBeInstanceOf(XllmError); expect((error as XllmError).code).toBe("AUTH_ERROR"); expect((error as XllmError).statusCode).toBe(401); expect((error as XllmError).responseHeaders?.["x-request-id"]).toBe("req_123"); } }); it("throws RATE_LIMIT on 429 response", async () => { const mockFetch: typeof fetch = async () => toJsonResponse({ error: { message: "Rate limit exceeded" } }, 429); const client = createXllm({ provider: "deepseek", apiKey: "test", fetch: mockFetch }); try { await client.generate({ messages: [{ role: "user", content: "hi" }] }); expect.unreachable("Should have thrown"); } catch (error) { expect(error).toBeInstanceOf(XllmError); expect((error as XllmError).code).toBe("RATE_LIMIT"); } }); it("throws NETWORK_ERROR when fetch throws", async () => { const mockFetch: typeof fetch = async () => { throw new TypeError("fetch failed"); }; const client = createXllm({ provider: "openai-compatible", apiKey: "test", fetch: mockFetch }); try { await client.generate({ messages: [{ role: "user", content: "hi" }] }); expect.unreachable("Should have thrown"); } catch (error) { expect(error).toBeInstanceOf(XllmError); expect((error as XllmError).code).toBe("NETWORK_ERROR"); } }); it("throws AUTH_ERROR when apiKey is missing", async () => { const client = createXllm({ provider: "openai-compatible" }); try { await client.generate({ messages: [{ role: "user", content: "hi" }] }); expect.unreachable("Should have thrown"); } catch (error) { expect(error).toBeInstanceOf(XllmError); expect((error as XllmError).code).toBe("AUTH_ERROR"); } }); it("throws when maxRounds is exceeded in chatWithTools", async () => { const mockFetch: typeof fetch = async () => toJsonResponse({ choices: [ { message: { content: "", tool_calls: [{ id: "c1", function: { name: "loop_tool", arguments: "{}" } }], }, finish_reason: "tool_calls", }, ], }); const client = createXllm({ provider: "deepseek", apiKey: "test", fetch: mockFetch }); try { await client.chatWithTools( { messages: [{ role: "user", content: "hi" }], tools: [{ name: "loop_tool" }], maxRounds: 1 }, { loop_tool: () => "ok" }, ); expect.unreachable("Should have thrown"); } catch (error) { expect(error).toBeInstanceOf(XllmError); expect((error as XllmError).code).toBe("PROVIDER_ERROR"); expect((error as XllmError).message).toContain("maxRounds"); } }); it("throws INVALID_REQUEST when maxRounds < 1", async () => { const client = createXllm({ provider: "deepseek", apiKey: "test", fetch: async () => new Response() }); try { await client.chatWithTools( { messages: [{ role: "user", content: "hi" }], tools: [], maxRounds: 0 }, {}, ); expect.unreachable("Should have thrown"); } catch (error) { expect(error).toBeInstanceOf(XllmError); expect((error as XllmError).code).toBe("INVALID_REQUEST"); } }); it("skips tool call when strategy is skip and no executor found", async () => { let call = 0; const mockFetch: typeof fetch = async () => { call += 1; if (call === 1) { return toJsonResponse({ choices: [ { message: { content: "", tool_calls: [{ id: "c1", function: { name: "unknown_tool", arguments: "{}" } }], }, finish_reason: "tool_calls", }, ], }); } return toJsonResponse({ choices: [{ message: { content: "done" }, finish_reason: "stop" }], }); }; const client = createXllm({ provider: "deepseek", apiKey: "test", fetch: mockFetch }); const result = await client.chatWithTools( { messages: [{ role: "user", content: "hi" }], tools: [{ name: "unknown_tool" }], toolErrorStrategy: "skip", }, {}, ); expect(result.toolCallsExecuted).toBe(0); expect(result.response.text).toBe("done"); }); it("throws for unknown provider", async () => { const client = createXllm({ provider: "unknown-provider" as any, apiKey: "test" }); try { await client.generate({ messages: [{ role: "user", content: "hi" }] }); expect.unreachable("Should have thrown"); } catch (error) { expect((error as Error).message).toContain("No adapter registered"); } }); it("registerAdapter allows adding custom provider", async () => { const customAdapter: ProviderAdapter = { name: "custom-test", toProviderRequest: () => ({ method: "POST", url: "https://custom.example.com/v1/chat/completions", headers: { "content-type": "application/json", authorization: "Bearer test" }, body: { model: "custom-model", messages: [], stream: false }, }), fromProviderResponse: () => ({ text: "custom response", toolCalls: [], provider: "custom-test", model: "custom-model", }), fromProviderStreamChunk: () => [], normalizeError: (err: any) => new XllmError({ code: "PROVIDER_ERROR", message: err?.message ?? "error", provider: "custom-test" }), }; registerAdapter(customAdapter); const mockFetch: typeof fetch = async () => toJsonResponse({ choices: [{ message: { content: "custom response" }, finish_reason: "stop" }], }); const client = createXllm({ provider: "custom-test", apiKey: "test", fetch: mockFetch }); const result = await client.generate({ messages: [{ role: "user", content: "hi" }] }); expect(result.text).toBe("custom response"); expect(result.provider).toBe("custom-test"); }); it("with() creates derived client with overrides", async () => { const mockFetch: typeof fetch = async () => toJsonResponse({ choices: [{ message: { content: "derived" }, finish_reason: "stop" }], }); const base = createXllm({ provider: "openai-compatible", apiKey: "test", fetch: mockFetch }); const derived = base.with({ model: "gpt-4o" }); const result = await derived.generate({ messages: [{ role: "user", content: "hi" }] }); expect(result.text).toBe("derived"); }); it("onBeforeRequest hook modifies request", async () => { let capturedHeaders: Record = {}; const mockFetch: typeof fetch = async (_input, init) => { capturedHeaders = Object.fromEntries(new Headers((init as RequestInit)?.headers as Headers)); return toJsonResponse({ choices: [{ message: { content: "ok" }, finish_reason: "stop" }], }); }; const customAdapter: ProviderAdapter = { name: "hook-test", toProviderRequest: () => ({ method: "POST", url: "https://hook.example.com/v1/chat/completions", headers: { "content-type": "application/json", authorization: "Bearer test" }, body: { model: "hook-model", messages: [], stream: false }, }), fromProviderResponse: () => ({ text: "ok", toolCalls: [], provider: "hook-test", model: "hook-model", }), fromProviderStreamChunk: () => [], normalizeError: (err: any) => new XllmError({ code: "PROVIDER_ERROR", message: err?.message ?? "error", provider: "hook-test" }), onBeforeRequest(request) { return { ...request, headers: { ...request.headers, "x-custom-signature": "signed" } }; }, }; registerAdapter(customAdapter); const client = createXllm({ provider: "hook-test", apiKey: "test", fetch: mockFetch }); await client.generate({ messages: [{ role: "user", content: "hi" }] }); expect(capturedHeaders["x-custom-signature"]).toBe("signed"); }); });