mono项目开发模板,内置cli管理构建
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

602 lines
22 KiB

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<string, string>): Response =>
new Response(JSON.stringify(payload), {
status,
headers: { "content-type": "application/json", ...headers },
});
const toSSEBody = (frames: string[]): ReadableStream<Uint8Array> => {
const encoder = new TextEncoder();
return new ReadableStream<Uint8Array>({
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<XStreamEvent, { type: "text.delta" }> => 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<Record<string, unknown>> = [];
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<XStreamEvent, { type: "reasoning.delta" }> => e.type === "reasoning.delta")
.map((e) => e.text)
.join("");
const text = events
.filter((e): e is Extract<XStreamEvent, { type: "text.delta" }> => 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<string, unknown> = {};
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<string, unknown> = {};
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<string, string> = {};
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");
});
});