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
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");
|
|
});
|
|
});
|
|
|