Browse Source

feat: implement DeepSeek thinking mode with reasoning effort support

Add new features to the xllm package, including the ability to enable a thinking mode and specify reasoning effort for DeepSeek-compatible providers. Update the environment configuration and demo implementations to showcase these features. Enhance the README and documentation to reflect the new functionality and usage examples.
main
dash 2 months ago
parent
commit
d741ce5af9
  1. 7
      packages/example/.env.example
  2. 8
      packages/example/src/xllm/demos/catalog.ts
  3. 8
      packages/example/src/xllm/demos/stream-with-tools.ts
  4. 76
      packages/example/src/xllm/demos/thinking.ts
  5. 8
      packages/example/src/xllm/env.ts
  6. 20
      packages/xllm/README.md
  7. 33
      packages/xllm/docs/LLM-USAGE.md
  8. 4
      packages/xllm/src/client/chat-with-tools.ts
  9. 11
      packages/xllm/src/client/stream-with-tools.ts
  10. 31
      packages/xllm/src/core/types.ts
  11. 105
      packages/xllm/src/index.test.ts
  12. 2
      packages/xllm/src/index.ts
  13. 50
      packages/xllm/src/providers/openai-compatible.adapter.ts
  14. 1
      tsconfig.tsbuildinfo

7
packages/example/.env.example

@ -6,6 +6,9 @@ VITE_XLLM_PROVIDER_2=openai-compatible
VITE_XLLM_MODEL_2=gpt-4o-mini
VITE_XLLM_API_KEY_2=
VITE_XLLM_BASE_URL_2=
VITE_XLLM_DEMO_IMAGE_URSL=
VITE_XLLM_TOOL_ERROR_TRATEGY=throw
VITE_XLLM_DEMO_IMAGE_URL=
VITE_XLLM_TOOL_ERROR_STRATEGY=throw
VITE_XLLM_FORCE_TOOL_FAILURE=0
# DeepSeek 思考模式:enabled | disabled;强度 high | medium | low | max | xhigh(@dm/xllm 会映射为 high/max)
VITE_XLLM_THINKING=enabled
VITE_XLLM_REASONING_EFFORT=high

8
packages/example/src/xllm/demos/catalog.ts

@ -4,6 +4,7 @@ import { runGenerateDemo } from "./generate";
import { runRequestSwitchDemo } from "./request-switch";
import { runStreamDemo } from "./stream";
import { runStreamWithToolsDemo } from "./stream-with-tools";
import { runThinkingDemo } from "./thinking";
import { runToolsMultimodalDemo } from "./tools-multimodal";
import type { DemoLog, XllmClient } from "./types";
@ -40,6 +41,13 @@ export const xllmDemoCatalog: DemoDefinition[] = [
run: (client, log) => runGenerateDemo(client, log),
},
{
id: "thinking",
title: "DeepSeek 思考模式",
description:
"thinking + reasoning_effort;流式区分 reasoning.delta 与 text.delta;generate 可读 reasoning。需 provider=deepseek(见 .env.example)。",
run: (client, log, ctx) => runThinkingDemo(client, log, ctx),
},
{
id: "request-switch",
title: "请求级切换 provider / model",
description: "单次请求覆盖默认 client 的 provider、model、apiKey(需配置 *_2 环境变量)。",

8
packages/example/src/xllm/demos/stream-with-tools.ts

@ -13,6 +13,8 @@ export async function runStreamWithToolsDemo(client: XllmClient, log: DemoLog):
tools: [{ name: "get_weather", parameters: { type: "object", properties: { city: { type: "string" } } } }],
toolChoice: "auto",
toolErrorStrategy,
thinking: { type: "enabled" },
reasoningEffort: "high",
},
{
get_weather: (args) => {
@ -22,9 +24,9 @@ export async function runStreamWithToolsDemo(client: XllmClient, log: DemoLog):
return {
city:
typeof args === "object" &&
args !== null &&
"city" in args &&
typeof (args as { city?: unknown }).city === "string"
args !== null &&
"city" in args &&
typeof (args as { city?: unknown }).city === "string"
? (args as { city: string }).city
: "Beijing",
weather: "晴",

76
packages/example/src/xllm/demos/thinking.ts

@ -0,0 +1,76 @@
import type { XMessage, XProviderName, XReasoningEffortInput } from "@dm/xllm";
import { xllmReasoningEffort, xllmThinkingType } from "../env";
import type { DemoLog, XllmClient } from "./types";
function thinkingRequestOptions(provider: XProviderName): {
thinking?: { type: "enabled" | "disabled" };
reasoningEffort?: XReasoningEffortInput;
} {
if (provider !== "deepseek") {
return {};
}
return {
thinking: { type: xllmThinkingType },
reasoningEffort: xllmReasoningEffort ?? "high",
};
}
export async function runThinkingDemo(
client: XllmClient,
log: DemoLog,
ctx: { provider: XProviderName; model: string },
): Promise<void> {
log("========== DeepSeek 思考模式(thinking + reasoning_effort)==========");
const extra = thinkingRequestOptions(ctx.provider);
if (Object.keys(extra).length === 0) {
log(
`[thinking] 当前默认 provider 为「${ctx.provider}」,非 deepseek 时不自动附带 thinking 参数(避免部分网关拒绝未知字段)。将 VITE_XLLM_PROVIDER 设为 deepseek 后再试,或只看下方说明。`,
);
log("[thinking] 库内字段:thinking + reasoningEffort → 请求体 thinking、reasoning_effort;流式事件 reasoning.delta。");
return;
}
log(
`[thinking] 请求参数: thinking=${JSON.stringify(extra.thinking)}, reasoningEffort=${extra.reasoningEffort ?? "high"}`,
);
let reasoning = "";
let text = "";
log("--- stream() ---");
const messages = [
{ role: "user", content: [{ type: "text", text: "用一句话说明「思考模式」对回答质量的帮助,不要分点。" }] },
] as XMessage[];
for await (const event of client.stream({
messages,
...extra,
})) {
if (event.type === "response.start") {
log(`[stream] start: ${event.provider} ${event.model}`);
}
if (event.type === "reasoning.delta") {
reasoning += event.text;
log(`[stream] reasoning.delta: ${event.text}`);
}
if (event.type === "text.delta") {
text += event.text;
log(`[stream] text.delta: ${event.text}`);
}
if (event.type === "response.done") {
log(`[stream] done (finishReason=${event.finishReason ?? ""})`);
}
}
log(`[stream] reasoning 拼接长度: ${reasoning.length},正文: ${text}`);
messages.push({ role: "assistant", content: [{ type: "text", text: text }] });
log("--- generate() ---");
messages.push({ role: "user", content: [{ type: "text", text: "同上问题,再答一次,更短。" }] });
const result = await client.generate({
messages,
...extra,
});
if (result.reasoning) {
log(`[generate] reasoning (前 200 字): ${result.reasoning.slice(0, 200)}${result.reasoning.length > 200 ? "…" : ""}`);
} else {
log("[generate] reasoning: (本响应未返回 reasoning_content,属供应商行为)");
}
log(`[generate] text: ${result.text}`);
}

8
packages/example/src/xllm/env.ts

@ -1,4 +1,4 @@
import type { XProviderName, XToolErrorStrategy } from "@dm/xllm";
import type { XProviderName, XReasoningEffortInput, XToolErrorStrategy } from "@dm/xllm";
const env = (import.meta as unknown as { env?: Record<string, string | undefined> }).env ?? {};
@ -22,3 +22,9 @@ export const demoImageUrl =
export const hasPrimaryConfig = Boolean(primaryApiKey);
export const hasSecondaryConfig = Boolean(secondaryProvider && secondaryModel && secondaryApiKey);
/** 思考模式开关:请求体 `thinking.type`(DeepSeek 等);默认 enabled */
export const xllmThinkingType = (env.VITE_XLLM_THINKING as "enabled" | "disabled" | undefined) ?? "enabled";
/** 思考强度,映射为请求体 `reasoning_effort`;不填时示例内默认 high */
export const xllmReasoningEffort = env.VITE_XLLM_REASONING_EFFORT as XReasoningEffortInput | undefined;

20
packages/xllm/README.md

@ -6,6 +6,7 @@
- 流式输出(`for await...of`)
- OpenAI-Compatible 与 DeepSeek 供应商适配
- DeepSeek 思考模式:`thinking`、`reasoning_effort` 与流式 `reasoning.delta`
- 文本、多模态输入(图片 URL)、工具调用结构统一
## 快速开始
@ -65,3 +66,22 @@ for await (const event of xllm.streamWithTools(
if (event.type === "text.delta") process.stdout.write(event.text);
}
```
## DeepSeek 思考模式(Thinking)
`thinking` / `reasoningEffort` 仅适用于 **OpenAI Chat Completions 兼容体** 里采用这些字段名的网关(如 DeepSeek 文档)。其它厂商字段不同时,请用请求上的 **`providerExtras`** 合并任意 JSON 根字段,或扩展适配器。
请求里设置 `thinking``reasoningEffort`,会写入 Chat Completions 的 `thinking`、`reasoning_effort`(`low`/`medium`→`high`,`xhigh`→`max`)。流式下思考增量为 `reasoning.delta`,正文为 `text.delta`
```ts
for await (const event of xllm.stream({
messages: [{ role: "user", content: [{ type: "text", text: "…" }] }],
thinking: { type: "enabled" },
reasoningEffort: "high",
})) {
if (event.type === "reasoning.delta") process.stderr.write(event.text);
if (event.type === "text.delta") process.stdout.write(event.text);
}
```
详见 [docs/LLM-USAGE.md](./docs/LLM-USAGE.md) 第 5.1 节。

33
packages/xllm/docs/LLM-USAGE.md

@ -23,7 +23,7 @@ import { createXllm, XllmError } from "@dm/xllm";
### 2.2 类型导出(`export type`)
`XClientOptions`, `XRequest`, `XResponse`, `XStreamEvent`, `XMessage`, `XContentPart`, `XProviderName`, `XToolDefinition`, `XToolCall`, `XToolChoice`, `XToolExecutor`, `XToolExecutorMap`, `XToolErrorStrategy`, `XChatWithToolsOptions`, `XChatWithToolsResult`, `XUsage`
`XClientOptions`, `XRequest`, `XResponse`, `XStreamEvent`, `XMessage`, `XContentPart`, `XProviderName`, `XThinkingMode`, `XReasoningEffortInput`, `XToolDefinition`, `XToolCall`, `XToolChoice`, `XToolExecutor`, `XToolExecutorMap`, `XToolErrorStrategy`, `XChatWithToolsOptions`, `XChatWithToolsResult`, `XUsage`
---
@ -87,6 +87,28 @@ const xllm = createXllm(options?: XClientOptions): XllmClient;
---
## 5.1 思考模式(Thinking / CoT)— **非全供应商通用**
`thinking` / `reasoningEffort` **不是**抽象意义上的「全市场通用配置」:它们对应 **OpenAI Chat Completions 兼容 JSON** 里部分厂商采用的字段名(DeepSeek 官方文档中的 OpenAI 形态即如此)。**其它供应商若使用不同字段名、不同嵌套路径或不同 API**,有两种做法:
1. **`providerExtras`**(推荐先做):在 `XRequest.providerExtras` 里写入任意键值,库会在构建完标准字段后 **`Object.assign` 合并进请求体根对象**(**后合并者可覆盖**同名标准字段)。用于传入厂商文档要求的专有参数,而无需立刻新增适配器。
2. **新 `ProviderAdapter`**:协议差异大(路径、鉴权、流式格式都不同)时,在 `packages/xllm` 内实现并注册适配器。
与 DeepSeek 文档一致的字段映射如下(兼容体):
| `XRequest` 字段 | 请求体字段 | 说明 |
|-----------------|------------|------|
| `thinking?: { type: "enabled" \| "disabled" }` | `thinking` | 思考开关;不传则由服务端默认(一般为 enabled) |
| `reasoningEffort?: "low" \| "medium" \| "high" \| "max" \| "xhigh"` | `reasoning_effort` | 仅发送 **`high`** 或 **`max`**:`low` / `medium``high`,`xhigh` → `max` |
**流式**:若供应商在 SSE 的 `choices[].delta` 中返回 `reasoning_content`(或 `reasoning` 字符串),库会发出 `XStreamEvent`:`{ type: "reasoning.delta", text: string }`,与 `{ type: "text.delta", ... }` 区分。
**非流式**:若 `message` 中含 `reasoning_content``reasoning`,会填入 `XResponse.reasoning`;正文仍在 `XResponse.text`
`provider: "deepseek"``provider: "openai-compatible"` 均走同一适配器请求体逻辑;其它 **同形态** 网关若支持相同字段,亦可使用 `thinking` / `reasoningEffort`;否则请用 **`providerExtras`**。
---
## 6. 请求与消息模型
### 6.1 `XRequest`(核心字段)
@ -96,11 +118,13 @@ const xllm = createXllm(options?: XClientOptions): XllmClient;
- `toolChoice?: "auto" | "none" | { name: string }`
- `temperature?`, `topP?`, `maxTokens?`, `metadata?`
- 可覆盖:`provider?`, `model?`, `apiKey?`, `baseURL?`
- `thinking?`, `reasoningEffort?`:见 §5.1(非通用,部分兼容厂商)
- `providerExtras?`:合并进 Chat Completions 请求 JSON 根对象(见 §5.1)
- `stream?`:由 `generate`/`stream` 内部控制,调用方一般不必依赖此字段语义
### 6.2 `XMessage`
- `system` | `user` | `assistant`:`content: XContentPart[]`;`assistant` 可选 `toolCalls?: XToolCall[]`(**协议要求**:在 `role: "tool"` 之前,assistant 需带对应 `tool_calls`;使用 `chatWithTools` / `streamWithTools` 时由库自动写入)
- `system` | `user` | `assistant`:`content: XContentPart[]`;`assistant` 可选 `toolCalls?: XToolCall[]`(**协议要求**:在 `role: "tool"` 之前,assistant 需带对应 `tool_calls`;使用 `chatWithTools` / `streamWithTools` 时由库自动写入);`assistant` 还可选 **`reasoningContent?: string`**,序列化为 **`reasoning_content`**(思考模式 + 工具调用时,部分供应商要求后续轮完整回传)
- `tool`:**必须**包含 `toolCallId: string`,且与上一轮 assistant 的 `tool_calls[].id` 对应
### 6.3 `XContentPart`
@ -126,6 +150,7 @@ const xllm = createXllm(options?: XClientOptions): XllmClient;
|--------|------|
| `response.start` | 流开始,含 `provider`, `model`, `requestId?` |
| `text.delta` | 文本增量 |
| `reasoning.delta` | 思考链增量(如 `reasoning_content`) |
| `tool_call.delta` | 工具参数增量 |
| `tool_call.done` | 单个工具调用片段结束 |
| `response.usage` | 用量 |
@ -148,10 +173,11 @@ const xllm = createXllm(options?: XClientOptions): XllmClient;
- 额外选项:`maxRounds?`(默认 `5`),`toolErrorStrategy?`(默认 `"throw"`
- 返回:`{ response, rounds, toolCallsExecuted }`
- 行为:每轮 `generate`;若有 `toolCalls`,执行 `executors[name]`,将结果写入 tool 消息并追加历史,直到无工具调用或超出 `maxRounds`
- **思考模式 + 工具调用(DeepSeek)**:若本轮 `XResponse.reasoning` 有值(来自 `message.reasoning_content`),库会把同一全文写入历史 assistant 的 `reasoningContent`,下一轮请求序列化为 `reasoning_content`,满足「后续请求须完整回传」的要求。
### 8.3 `streamWithTools`(流式闭环)
- 每轮消费完整 `stream`;收集 `tool_call.done` 与拼接的 `assistantText`;执行工具后追加 `assistant`(含 `toolCalls`)与 `tool` 消息,再进入下一轮
- 每轮消费完整 `stream`;收集 `tool_call.done` 与拼接的 `assistantText`**同时拼接本轮全部 `reasoning.delta``reasoningContent`**;执行工具后追加 `assistant`(含 `toolCalls` 与可选 `reasoningContent`)与 `tool` 消息,再进入下一轮
- 事件仍原样 `yield` 给调用方
### 8.4 `XToolExecutor``XToolExecutorMap`
@ -226,6 +252,7 @@ const xllm = createXllm({
- [ ] 工具闭环是否优先使用 `chatWithTools` / `streamWithTools`,避免手写 `tool_calls` 顺序错误?
- [ ] 多模态是否确认目标模型支持,否则仅发 `text`
- [ ] 工具失败策略是否为生产环境显式选择 `toolErrorStrategy`
- [ ] 思考类参数是否确认与供应商文档一致?否则是否改用 `providerExtras`
---

4
packages/xllm/src/client/chat-with-tools.ts

@ -40,6 +40,9 @@ export const chatWithTools = async (
model: input.model,
apiKey: input.apiKey,
baseURL: input.baseURL,
thinking: input.thinking,
reasoningEffort: input.reasoningEffort,
providerExtras: input.providerExtras,
};
while (rounds < maxRounds) {
@ -57,6 +60,7 @@ export const chatWithTools = async (
role: "assistant",
content: [{ type: "text", text: response.text }],
toolCalls: response.toolCalls,
...(response.reasoning ? { reasoningContent: response.reasoning } : {}),
};
const toolMessages: XMessage[] = [];

11
packages/xllm/src/client/stream-with-tools.ts

@ -32,6 +32,7 @@ export const streamWithTools = async function* (
rounds += 1;
const toolCalls: XToolCall[] = [];
let assistantText = "";
let assistantReasoning = "";
for await (const event of stream(options, {
...input,
@ -41,6 +42,9 @@ export const streamWithTools = async function* (
if (event.type === "text.delta") {
assistantText += event.text;
}
if (event.type === "reasoning.delta") {
assistantReasoning += event.text;
}
if (event.type === "tool_call.done") {
toolCalls.push(event.toolCall);
}
@ -67,7 +71,12 @@ export const streamWithTools = async function* (
currentMessages = [
...currentMessages,
{ role: "assistant", content: [{ type: "text", text: assistantText }], toolCalls },
{
role: "assistant",
content: [{ type: "text", text: assistantText }],
toolCalls,
...(assistantReasoning ? { reasoningContent: assistantReasoning } : {}),
},
...toolMessages,
];
}

31
packages/xllm/src/core/types.ts

@ -11,6 +11,11 @@ export type XMessage =
role: "system" | "user" | "assistant";
content: XContentPart[];
toolCalls?: XToolCall[];
/**
* assistant DeepSeek `reasoning_content`
* `reasoning_content` assistant
*/
reasoningContent?: string;
}
| {
role: "tool";
@ -36,6 +41,15 @@ export type XToolErrorStrategy = "throw" | "return_tool_error_message" | "skip";
export type XToolChoice = "auto" | "none" | { name: string };
/** DeepSeek 等兼容接口:OpenAI 形态下的思考模式开关,对应请求体 `thinking`。 */
export type XThinkingMode = { type: "enabled" | "disabled" };
/**
* DeepSeek `reasoning_effort`
* low / medium highxhigh max
*/
export type XReasoningEffortInput = "low" | "medium" | "high" | "max" | "xhigh";
export interface XRequest {
messages: XMessage[];
tools?: XToolDefinition[];
@ -49,6 +63,20 @@ export interface XRequest {
model?: string;
apiKey?: string;
baseURL?: string;
/**
* **** OpenAI Chat Completions 使
* `thinking` DeepSeek `providerExtras`
*/
thinking?: XThinkingMode;
/**
* **** `reasoning_effort` `high` / `max`
* `providerExtras`
*/
reasoningEffort?: XReasoningEffortInput;
/**
* JSON ****
*/
providerExtras?: Record<string, unknown>;
}
export interface XUsage {
@ -59,6 +87,8 @@ export interface XUsage {
export interface XResponse {
text: string;
/** 思考链 / 推理过程全文(若供应商在 message 中返回 `reasoning_content` 等) */
reasoning?: string;
toolCalls: XToolCall[];
usage?: XUsage;
provider: XProviderName;
@ -69,6 +99,7 @@ export interface XResponse {
export type XStreamEvent =
| { type: "response.start"; provider: XProviderName; model: string; requestId?: string }
| { type: "reasoning.delta"; text: string }
| { type: "text.delta"; text: string }
| { type: "tool_call.delta"; id: string; name?: string; argumentsDelta?: string }
| { type: "tool_call.done"; toolCall: XToolCall }

105
packages/xllm/src/index.test.ts

@ -104,6 +104,7 @@ describe("xllm", () => {
{
message: {
content: "",
reasoning_content: "need weather tool for Shanghai",
tool_calls: [
{
id: "call_1",
@ -153,13 +154,20 @@ describe("xllm", () => {
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",
];
@ -167,8 +175,11 @@ describe("xllm", () => {
'data: {"id":"r2","model":"deepseek-chat","choices":[{"delta":{"content":"上海今天多云。"},"finish_reason":"stop"}]}\n\n',
"data: [DONE]\n\n",
];
const mockFetch: typeof fetch = async () => {
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,
@ -195,6 +206,8 @@ describe("xllm", () => {
}
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 () => {
@ -246,4 +259,94 @@ describe("xllm", () => {
expect(result.toolCallsExecuted).toBe(1);
expect(result.response.text).toContain("工具错误");
});
it("sends thinking and reasoning_effort 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" }] }],
thinking: { type: "disabled" },
reasoningEffort: "medium",
});
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" }] }],
reasoningEffort: "xhigh",
});
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);
});
});

2
packages/xllm/src/index.ts

@ -7,9 +7,11 @@ export type {
XContentPart,
XMessage,
XProviderName,
XReasoningEffortInput,
XRequest,
XResponse,
XStreamEvent,
XThinkingMode,
XToolCall,
XToolChoice,
XToolDefinition,

50
packages/xllm/src/providers/openai-compatible.adapter.ts

@ -3,6 +3,7 @@ import type {
XContentPart,
XMessage,
XProviderName,
XReasoningEffortInput,
XRequest,
XResponse,
XStreamEvent,
@ -37,7 +38,7 @@ const toProviderMessage = (message: XMessage): Record<string, unknown> => {
}
if (message.role === "assistant" && Array.isArray(message.toolCalls) && message.toolCalls.length > 0) {
return {
const out: Record<string, unknown> = {
role: "assistant",
content: toProviderContent(message.content),
tool_calls: message.toolCalls.map((toolCall) => ({
@ -49,12 +50,20 @@ const toProviderMessage = (message: XMessage): Record<string, unknown> => {
},
})),
};
if (message.reasoningContent) {
out.reasoning_content = message.reasoningContent;
}
return out;
}
return {
const base: Record<string, unknown> = {
role: message.role,
content: toProviderContent(message.content),
};
if (message.role === "assistant" && message.reasoningContent) {
base.reasoning_content = message.reasoningContent;
}
return base;
};
const toUsage = (usage: any): XUsage | undefined => {
@ -78,6 +87,21 @@ const mapToolChoice = (toolChoice: XRequest["toolChoice"]): unknown => {
return { type: "function", function: { name: toolChoice.name } };
};
/** DeepSeek:low/medium → high,xhigh → max,与官方兼容说明一致。 */
const normalizeReasoningEffortForProvider = (effort: XReasoningEffortInput): "high" | "max" => {
if (effort === "max" || effort === "xhigh") return "max";
return "high";
};
const extractReasoningText = (message: Record<string, unknown> | undefined): string | undefined => {
if (!message) return undefined;
const rc = message.reasoning_content;
if (typeof rc === "string" && rc.length > 0) return rc;
const r = message.reasoning;
if (typeof r === "string" && r.length > 0) return r;
return undefined;
};
export const openAICompatibleAdapter: ProviderAdapter = {
name: "openai-compatible",
@ -104,6 +128,17 @@ export const openAICompatibleAdapter: ProviderAdapter = {
body.tool_choice = mapToolChoice(input.toolChoice);
}
if (input.thinking) {
body.thinking = input.thinking;
}
if (input.reasoningEffort) {
body.reasoning_effort = normalizeReasoningEffortForProvider(input.reasoningEffort);
}
if (input.providerExtras) {
Object.assign(body, input.providerExtras);
}
return {
method: "POST",
url: `${config.baseURL || DEFAULT_OPENAI_COMPATIBLE_BASE_URL}/chat/completions`,
@ -121,8 +156,10 @@ export const openAICompatibleAdapter: ProviderAdapter = {
const choice = response?.choices?.[0] ?? {};
const message = choice?.message ?? {};
const msg = message as Record<string, unknown> | undefined;
return {
text: message?.content ?? "",
text: (message as { content?: string })?.content ?? "",
reasoning: extractReasoningText(msg),
toolCalls: Array.isArray(message?.tool_calls) ? message.tool_calls.map(toToolCall) : [],
usage: toUsage(response?.usage),
provider,
@ -150,6 +187,13 @@ export const openAICompatibleAdapter: ProviderAdapter = {
if (!choice) return events;
const delta = choice.delta ?? {};
if (typeof delta.reasoning_content === "string" && delta.reasoning_content.length > 0) {
events.push({ type: "reasoning.delta", text: delta.reasoning_content });
}
if (typeof (delta as { reasoning?: string }).reasoning === "string") {
const r = (delta as { reasoning: string }).reasoning;
if (r.length > 0) events.push({ type: "reasoning.delta", text: r });
}
if (typeof delta.content === "string" && delta.content.length > 0) {
events.push({ type: "text.delta", text: delta.content });
}

1
tsconfig.tsbuildinfo

File diff suppressed because one or more lines are too long
Loading…
Cancel
Save