Browse Source

fix: update TypeScript configuration and enhance provider handling

- Bump the `ignoreDeprecations` option in `tsconfig.json` and `tsconfig.node.json` from "5.0" to "6.0" to align with the latest TypeScript standards.
- Introduce a new `.env` file in the example package to manage environment variables for DeepSeek integration.
- Refactor the handling of `thinking` and `reasoningEffort` parameters to use `providerExtras` for better compatibility with DeepSeek-style requests.
- Update various demo files and README documentation to reflect changes in parameter handling and provide clearer usage examples.
main
谢亚昕 3 months ago
parent
commit
c866dceaf9
  1. 16
      packages/example/.env
  2. 3
      packages/example/src/xllm/demos/stream-with-tools.ts
  3. 17
      packages/example/src/xllm/demos/thinking.ts
  4. 7
      packages/xllm/README.md
  5. 216
      packages/xllm/docs/LLM-USAGE.md
  6. 73
      packages/xllm/src/client/chat-with-tools.ts
  7. 5
      packages/xllm/src/client/generate.ts
  8. 63
      packages/xllm/src/client/stream-with-tools.ts
  9. 24
      packages/xllm/src/client/stream.ts
  10. 97
      packages/xllm/src/client/tool-loop-runner.ts
  11. 2
      packages/xllm/src/client/tool-loop-shared.ts
  12. 7
      packages/xllm/src/core/errors.ts
  13. 21
      packages/xllm/src/core/types.ts
  14. 266
      packages/xllm/src/index.test.ts
  15. 3
      packages/xllm/src/index.ts
  16. 12
      packages/xllm/src/providers/deepseek.adapter.ts
  17. 48
      packages/xllm/src/providers/openai-compatible.adapter.ts
  18. 24
      packages/xllm/src/providers/registry.ts
  19. 6
      packages/xllm/src/providers/types.ts
  20. 15
      packages/xllm/src/runtime/config.ts
  21. 20
      packages/xllm/src/runtime/http.ts
  22. 30
      packages/xllm/src/runtime/sse.ts
  23. 2
      tsconfig.json
  24. 2
      tsconfig.node.json

16
packages/example/.env

@ -0,0 +1,16 @@
VITE_XLLM_PROVIDER=deepseek
VITE_XLLM_MODEL=deepseek-v4-flash
VITE_XLLM_API_KEY=sk-688d15ac28a640b6827f0e700d42df8d
VITE_XLLM_BASE_URL=https://api.deepseek.com
VITE_XLLM_PROVIDER_2=openai-compatible
VITE_XLLM_MODEL_2=deepseek-v4-flash
VITE_XLLM_API_KEY_2=sk-688d15ac28a640b6827f0e700d42df8d
VITE_XLLM_BASE_URL_2=https://api.deepseek.com
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

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

@ -13,8 +13,7 @@ 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",
providerExtras: { thinking: { type: "enabled" }, reasoning_effort: "high" },
},
{
get_weather: (args) => {

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

@ -1,17 +1,18 @@
import type { XMessage, XProviderName, XReasoningEffortInput } from "@dm/xllm";
import type { XMessage, XProviderName } from "@dm/xllm";
import { xllmReasoningEffort, xllmThinkingType } from "../env";
import type { DemoLog, XllmClient } from "./types";
function thinkingRequestOptions(provider: XProviderName): {
thinking?: { type: "enabled" | "disabled" };
reasoningEffort?: XReasoningEffortInput;
providerExtras?: Record<string, unknown>;
} {
if (provider !== "deepseek") {
return {};
}
return {
thinking: { type: xllmThinkingType },
reasoningEffort: xllmReasoningEffort ?? "high",
providerExtras: {
thinking: { type: xllmThinkingType },
reasoning_effort: xllmReasoningEffort ?? "high",
},
};
}
@ -22,16 +23,16 @@ export async function runThinkingDemo(
): Promise<void> {
log("========== DeepSeek 思考模式(thinking + reasoning_effort)==========");
const extra = thinkingRequestOptions(ctx.provider);
if (Object.keys(extra).length === 0) {
if (!extra.providerExtras) {
log(
`[thinking] 当前默认 provider 为「${ctx.provider}」,非 deepseek 时不自动附带 thinking 参数(避免部分网关拒绝未知字段)。将 VITE_XLLM_PROVIDER 设为 deepseek 后再试,或只看下方说明。`,
);
log("[thinking] 库内字段:thinking + reasoningEffort → 请求体 thinking、reasoning_effort;流式事件 reasoning.delta。");
log("[thinking] 库内字段:providerExtras.thinking + providerExtras.reasoning_effort → 请求体 thinking、reasoning_effort;流式事件 reasoning.delta。");
return;
}
log(
`[thinking] 请求参数: thinking=${JSON.stringify(extra.thinking)}, reasoningEffort=${extra.reasoningEffort ?? "high"}`,
`[thinking] 请求参数: thinking=${JSON.stringify((extra.providerExtras as any).thinking)}, reasoning_effort=${(extra.providerExtras as any).reasoning_effort ?? "high"}`,
);
let reasoning = "";

7
packages/xllm/README.md

@ -69,15 +69,14 @@ for await (const event of xllm.streamWithTools(
## DeepSeek 思考模式(Thinking)
`thinking` / `reasoningEffort` 仅适用于 **OpenAI Chat Completions 兼容体** 里采用这些字段名的网关(如 DeepSeek 文档)。其它厂商字段不同时,请用请求上的 **`providerExtras`** 合并任意 JSON 根字段,或扩展适配器。
`thinking` / `reasoning_effort` 仅适用于 **OpenAI Chat Completions 兼容体** 里采用这些字段名的网关(如 DeepSeek 文档)。其它厂商字段不同时,请用请求上的 **`providerExtras`** 合并任意 JSON 根字段,或扩展适配器。
请求里设置 `thinking``reasoningEffort`,会写入 Chat Completions 的 `thinking`、`reasoning_effort`(`low`/`medium`→`high`,`xhigh`→`max`)。流式下思考增量为 `reasoning.delta`,正文为 `text.delta`
通过 `providerExtras` 设置 `thinking``reasoning_effort`,会合并进 Chat Completions 请求体。流式下思考增量为 `reasoning.delta`,正文为 `text.delta`
```ts
for await (const event of xllm.stream({
messages: [{ role: "user", content: [{ type: "text", text: "…" }] }],
thinking: { type: "enabled" },
reasoningEffort: "high",
providerExtras: { thinking: { type: "enabled" }, reasoning_effort: "high" },
})) {
if (event.type === "reasoning.delta") process.stderr.write(event.text);
if (event.type === "text.delta") process.stdout.write(event.text);

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

@ -18,12 +18,14 @@
### 2.1 运行时导出
```ts
import { createXllm, XllmError } from "@dm/xllm";
import { createXllm, XllmError, registerAdapter, getRegisteredProviderNames } from "@dm/xllm";
```
### 2.2 类型导出(`export type`)
`XClientOptions`, `XRequest`, `XResponse`, `XStreamEvent`, `XMessage`, `XContentPart`, `XProviderName`, `XThinkingMode`, `XReasoningEffortInput`, `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`, `ProviderAdapter`, `ProviderHttpRequest`, `StreamState`, `SSEFrame`
> **注意**:`XThinkingMode` 与 `XReasoningEffortInput` 仍作为类型导出供参考,但不再是 `XRequest` 的直接字段——请通过 `providerExtras` 传入(见 §5.1)。
---
@ -76,6 +78,8 @@ const xllm = createXllm(options?: XClientOptions): XllmClient;
## 5. 供应商(`XProviderName`)
`XProviderName` 的类型为 `string`(不再为字面量联合),以支持通过 `registerAdapter()` 动态注册任意供应商。
当前内置:
- `"openai-compatible"`:OpenAI Chat Completions 兼容 HTTP 形态(`/chat/completions`)
@ -83,29 +87,70 @@ const xllm = createXllm(options?: XClientOptions): XllmClient;
**接入其它「OpenAI 兼容」网关**:不必新增枚举;使用 `provider: "openai-compatible"`,并设置 `baseURL`(及可选 `headers`)指向该网关。
**非兼容协议**:须在库内新增 `ProviderAdapter` 并注册(见第 11 节)。
**非兼容协议**:可通过 `registerAdapter()` 在运行时注册自定义 `ProviderAdapter`(见 §11),无需修改库源码。
### 5.0 运行时注册 API
```ts
import { registerAdapter, getRegisteredProviderNames } from "@dm/xllm";
```
| 函数 | 签名 | 用途 |
|------|------|------|
| `registerAdapter` | `(adapter: ProviderAdapter) => void` | 注册或覆盖一个供应商适配器;后续 `generate`/`stream` 等调用即可使用该 `adapter.name` 作为 `provider` |
| `getRegisteredProviderNames` | `() => string[]` | 返回当前已注册的所有供应商名称(含内置) |
**示例**:
```ts
import { registerAdapter, createXllm } from "@dm/xllm";
import { myCustomAdapter } from "./my-custom-adapter";
registerAdapter(myCustomAdapter);
const xllm = createXllm({ provider: myCustomAdapter.name });
const res = await xllm.generate({ messages: [...] });
```
---
## 5.1 思考模式(Thinking / CoT)— **非全供应商通用**
`thinking` / `reasoningEffort` **不是**抽象意义上的「全市场通用配置」:它们对应 **OpenAI Chat Completions 兼容 JSON** 里部分厂商采用的字段名(DeepSeek 官方文档中的 OpenAI 形态即如此)。**其它供应商若使用不同字段名、不同嵌套路径或不同 API**,有两种做法:
`thinking` / `reasoning_effort` **不是**抽象意义上的「全市场通用配置」:它们对应 **OpenAI Chat Completions 兼容 JSON** 里部分厂商采用的字段名(DeepSeek 官方文档中的 OpenAI 形态即如此)。**其它供应商若使用不同字段名、不同嵌套路径或不同 API**,有两种做法:
1. **`providerExtras`**(推荐先做):在 `XRequest.providerExtras` 里写入任意键值,库会在构建完标准字段后 **`Object.assign` 合并进请求体根对象**(**后合并者可覆盖**同名标准字段)。用于传入厂商文档要求的专有参数,而无需立刻新增适配器。
2. **新 `ProviderAdapter`**:协议差异大(路径、鉴权、流式格式都不同)时,在 `packages/xllm` 内实现并注册适配器。
2. **新 `ProviderAdapter`**:协议差异大(路径、鉴权、流式格式都不同)时,通过 `registerAdapter()` 注册自定义适配器(见 §11),无需修改库源码。
> **迁移说明**:`thinking` 和 `reasoningEffort` **不再是 `XRequest` 的直接字段**。请改用 `providerExtras` 传入。`XThinkingMode` 和 `XReasoningEffortInput` 类型仍导出供参考,但仅用于构造 `providerExtras` 的值。
与 DeepSeek 文档一致的字段映射如下(兼容体):
| `XRequest` 字段 | 请求体字段 | 说明 |
|-----------------|------------|------|
| `thinking?: { type: "enabled" \| "disabled" }` | `thinking` | 思考开关;不传则由服务端默认(一般为 enabled) |
| `reasoningEffort?: "low" \| "medium" \| "high" \| "max" \| "xhigh"` | `reasoning_effort` | 仅发送 **`high`** 或 **`max`**:`low` / `medium``high`,`xhigh` → `max` |
| `providerExtras` 字段 | 请求体字段 | 说明 |
|-----------------------|------------|------|
| `thinking: { type: "enabled" \| "disabled" }` | `thinking` | 思考开关;不传则由服务端默认(一般为 enabled) |
| `reasoning_effort: "low" \| "medium" \| "high" \| "max" \| "xhigh"` | `reasoning_effort` | 仅发送 **`high`** 或 **`max`**:`low` / `medium``high`,`xhigh` → `max`。注意 `providerExtras` 中使用 **snake_case**(`reasoning_effort`),因为 `providerExtras` 直接映射到供应商请求体 |
**使用示例**:
```ts
// 旧写法(已移除):
// { messages, thinking: { type: "enabled" }, reasoningEffort: "high" }
// 新写法:
const res = await xllm.generate({
messages,
providerExtras: {
thinking: { type: "enabled" },
reasoning_effort: "high",
},
});
```
**流式**:若供应商在 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`**。
`provider: "deepseek"``provider: "openai-compatible"` 均走同一适配器请求体逻辑;其它 **同形态** 网关若支持相同字段,亦可使用 `providerExtras` 传入 `thinking` / `reasoning_effort`
---
@ -118,14 +163,15 @@ 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)
- `providerExtras?: Record<string, unknown>`:合并进 Chat Completions 请求 JSON 根对象(见 §5.1)。思考模式参数 `thinking` / `reasoning_effort` 等供应商专有字段均通过此字段传入
- `stream?`:由 `generate`/`stream` 内部控制,调用方一般不必依赖此字段语义
> **已移除**:`thinking` 和 `reasoningEffort` 不再是 `XRequest` 的直接字段。请使用 `providerExtras: { thinking: ..., reasoning_effort: ... }` 代替。
### 6.2 `XMessage`
- `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` 对应
- `system` | `user` | `assistant`:`content: XContentPart[] | string`(纯文本场景可直接传 `string`,等价于 `[{ type: "text", text: "..." }]`;`assistant` 可选 `toolCalls?: XToolCall[]`(**协议要求**:在 `role: "tool"` 之前,assistant 需带对应 `tool_calls`;使用 `chatWithTools` / `streamWithTools` 时由库自动写入);`assistant` 还可选 **`reasoningContent?: string`**,序列化为 **`reasoning_content`**(思考模式 + 工具调用时,部分供应商要求后续轮完整回传)
- `tool`:**必须**包含 `toolCallId: string`,且与上一轮 assistant 的 `tool_calls[].id` 对应;`content` 同样接受 `XContentPart[] | string`
### 6.3 `XContentPart`
@ -158,6 +204,22 @@ const xllm = createXllm(options?: XClientOptions): XllmClient;
**消费建议**:直出场景主要处理 `text.delta`;工具场景监听 `tool_call.done`;结束可用 `response.done`(注意可能与适配器在 `finish_reason``[DONE]` 路径上的去重逻辑配合,避免重复收尾业务状态)。
### 7.3 `SSEFrame`(底层 SSE 帧)
```ts
interface SSEFrame {
event?: string;
data: string;
}
```
`SSEFrame` 是库内部 SSE 解析器产出的原始帧类型。每个 SSE 事件被解析为一个 `SSEFrame`
- `event`:SSE 事件类型(如 `message`、`error` 等);若服务端未发送 `event:` 行则为 `undefined`
- `data`:SSE `data:` 字段的完整内容(多行 `data:` 会以 `\n` 拼接)
此类型通过 `parseSSE()` 生成器产出,供 `ProviderAdapter.fromProviderStreamChunk` 消费。大多数使用者无需直接操作 `SSEFrame`,但它在自定义适配器中可用于解析非标准 SSE 格式。
---
## 8. 工具调用
@ -201,10 +263,23 @@ type XToolExecutorMap = Record<string, XToolExecutor>;
## 9. 错误:`XllmError`
- `code`: `AUTH_ERROR` | `RATE_LIMIT` | `NETWORK_ERROR` | `INVALID_REQUEST` | `PROVIDER_ERROR`
- `code`: `AUTH_ERROR` | `RATE_LIMIT` | `NETWORK_ERROR` | `INVALID_REQUEST` | `PROVIDER_ERROR` | `TIMEOUT` | `CANCELLED`
- `provider`, `message`, 可选 `statusCode`, `requestId`, `raw`
- `responseHeaders?: Record<string, string>`:HTTP 响应头(非 2xx 响应时可用,便于读取 `x-request-id` 等诊断信息)
- HTTP 非 2xx 时由适配器 `normalizeError` 映射
### 9.1 错误码说明
| 错误码 | 触发场景 |
|--------|----------|
| `AUTH_ERROR` | API Key 无效或缺失(HTTP 401/403) |
| `RATE_LIMIT` | 请求频率超限(HTTP 429) |
| `NETWORK_ERROR` | 网络不可达、DNS 失败等(无 `fetch` 时同样抛此码) |
| `INVALID_REQUEST` | 请求参数不合法(HTTP 400) |
| `PROVIDER_ERROR` | 供应商侧 5xx 或其它未归类错误 |
| `TIMEOUT` | 请求超时(由调用方或适配器 AbortController 触发) |
| `CANCELLED` | 请求被主动取消(AbortSignal 已触发) |
---
## 10. 自定义 `fetch` 与测试
@ -220,15 +295,105 @@ const xllm = createXllm({
---
## 11. 新增内置供应商(需改库)
## 11. 自定义供应商适配器
### 11.1 运行时注册(无需改库)
通过 `registerAdapter()` 可在运行时注册自定义 `ProviderAdapter`,无需修改库源码:
```ts
import { registerAdapter, getRegisteredProviderNames, createXllm } from "@dm/xllm";
import type { ProviderAdapter } from "@dm/xllm";
const myAdapter: ProviderAdapter = {
name: "my-provider",
toProviderRequest(input, config, stream) { /* ... */ },
fromProviderResponse(raw, provider) { /* ... */ },
fromProviderStreamChunk(chunkData, state) { /* ... */ },
normalizeError(error) { /* ... */ },
};
registerAdapter(myAdapter);
// getRegisteredProviderNames() → [..., "my-provider"]
const xllm = createXllm({ provider: "my-provider" });
```
### 11.2 `ProviderAdapter` 接口
```ts
interface ProviderAdapter {
/** 适配器名称,即 `XProviderName` 值 */
name: XProviderName;
/** 将 XRequest 转换为供应商 HTTP 请求 */
toProviderRequest(
input: XRequest,
config: ResolvedConfig,
stream: boolean,
): ProviderHttpRequest;
/** 将供应商原始响应转换为 XResponse */
fromProviderResponse(raw: unknown, provider: XProviderName): XResponse;
/** 将供应商 SSE chunk 转换为 XStreamEvent[](一个 chunk 可产出多个事件) */
fromProviderStreamChunk(chunkData: unknown, state: StreamState): XStreamEvent[];
/** 将供应商错误转换为 XllmError */
normalizeError(error: unknown): XllmError;
/** 请求发送前的钩子,可修改请求(如签名、注入 header)。可选。 */
onBeforeRequest?(
request: ProviderHttpRequest,
config: ResolvedConfig,
): ProviderHttpRequest;
/** 收到响应后的钩子,可用于日志、指标采集。返回值可替换响应。可选。 */
onAfterResponse?(
response: Response,
config: ResolvedConfig,
): Response | Promise<Response>;
}
```
### 11.3 `ProviderHttpRequest`
```ts
interface ProviderHttpRequest {
url: string;
method: "POST";
headers: Record<string, string>;
body: Record<string, unknown>;
}
```
### 11.4 `StreamState`
```ts
interface StreamState {
started: boolean;
doneEmitted: boolean;
toolCallsByIndex: Map<number, XToolCall>;
}
```
适配器在 `fromProviderStreamChunk` 中通过 `state` 跟踪流状态:`started` 标记是否已发出 `response.start`,`doneEmitted` 防止重复发出 `response.done`,`toolCallsByIndex` 用于拼接跨 chunk 的工具调用参数。
### 11.5 钩子:`onBeforeRequest` / `onAfterResponse`
- **`onBeforeRequest`**:在 `fetch` 调用前触发,接收即将发送的 `ProviderHttpRequest`,返回修改后的请求。典型用途:请求签名、注入认证 header、添加追踪 ID。
- **`onAfterResponse`**:在收到 HTTP `Response` 后触发,可读取状态码/头信息做日志或指标采集,也可返回一个新的 `Response` 替换原始响应。支持异步(`Promise<Response>`)。
### 11.6 新增内置供应商(需改库)
若需将适配器作为库的内置支持,步骤如下:
1. 扩展 `XProviderName`(`packages/xllm/src/core/types.ts`)
2. 实现 `ProviderAdapter`(`packages/xllm/src/providers/types.ts`)
3. 在 `registry.ts` 注册
4. 在 `runtime/config.ts` 补充默认 `model` / `baseURL` / `resolveApiKey` 分支
5. 增加 `index.test.ts` 用 mock `fetch` 覆盖
1. 实现 `ProviderAdapter`(`packages/xllm/src/providers/` 下新增文件)
2. 在 `registry.ts` 中注册内置实例
3. 在 `runtime/config.ts` 补充默认 `model` / `baseURL` / `resolveApiKey` 分支
4. 增加 `index.test.ts` 用 mock `fetch` 覆盖
**OpenAI 兼容第三方**:通常只需 `openai-compatible` + `baseURL`,无需新枚举。
**OpenAI 兼容第三方**:通常只需 `openai-compatible` + `baseURL`,无需新适配器
---
@ -240,7 +405,7 @@ const xllm = createXllm({
| 客户端 | `src/client/create-xllm.ts`, `generate.ts`, `stream.ts`, `chat-with-tools.ts`, `stream-with-tools.ts`, `tool-loop-shared.ts` |
| 类型 | `src/core/types.ts`, `src/core/errors.ts` |
| 配置 | `src/runtime/config.ts`, `http.ts`, `sse.ts` |
| 适配器 | `src/providers/openai-compatible.adapter.ts`, `deepseek.adapter.ts`, `registry.ts` |
| 适配器 | `src/providers/openai-compatible.adapter.ts`, `deepseek.adapter.ts`, `registry.ts`, `types.ts` |
---
@ -252,7 +417,10 @@ const xllm = createXllm({
- [ ] 工具闭环是否优先使用 `chatWithTools` / `streamWithTools`,避免手写 `tool_calls` 顺序错误?
- [ ] 多模态是否确认目标模型支持,否则仅发 `text`
- [ ] 工具失败策略是否为生产环境显式选择 `toolErrorStrategy`
- [ ] 思考类参数是否确认与供应商文档一致?否则是否改用 `providerExtras`
- [ ] 思考类参数(`thinking` / `reasoning_effort`)是否通过 `providerExtras` 传入,而非作为 `XRequest` 直接字段?
- [ ] `providerExtras` 中的键名是否使用 **snake_case**(如 `reasoning_effort`),因为它们直接映射到供应商请求体?
- [ ] 自定义供应商是否通过 `registerAdapter()` 注册,而非修改库源码?
- [ ] 是否处理了 `TIMEOUT``CANCELLED` 错误码(如使用 AbortController 时)?
---

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

@ -1,51 +1,30 @@
import { XllmError } from "../core/errors";
import type {
XChatWithToolsOptions,
XChatWithToolsResult,
XClientOptions,
XMessage,
XRequest,
XToolExecutorMap,
} from "../core/types";
import { generate } from "./generate";
import { runToolCall } from "./tool-loop-shared";
import {
buildAssistantMessage,
buildRequestBase,
createToolLoopConfig,
executeToolCalls,
throwIfMaxRoundsExceeded,
} from "./tool-loop-runner";
export const chatWithTools = async (
options: XClientOptions,
input: XChatWithToolsOptions,
executors: XToolExecutorMap,
executors: Record<string, (args: unknown, toolCall: import("../core/types").XToolCall) => Promise<unknown> | unknown>,
): Promise<XChatWithToolsResult> => {
const maxRounds = input.maxRounds ?? 5;
const strategy = input.toolErrorStrategy ?? "throw";
if (maxRounds < 1) {
throw new XllmError({
code: "INVALID_REQUEST",
message: "maxRounds must be >= 1",
provider: input.provider ?? options.provider ?? "openai-compatible",
});
}
const config = createToolLoopConfig(input, options);
const requestBase = buildRequestBase(input, false);
let rounds = 0;
let toolCallsExecuted = 0;
let currentMessages = [...input.messages];
const requestBase: Omit<XRequest, "messages"> = {
tools: input.tools,
toolChoice: input.toolChoice,
stream: false,
temperature: input.temperature,
topP: input.topP,
maxTokens: input.maxTokens,
metadata: input.metadata,
provider: input.provider,
model: input.model,
apiKey: input.apiKey,
baseURL: input.baseURL,
thinking: input.thinking,
reasoningEffort: input.reasoningEffort,
providerExtras: input.providerExtras,
};
while (rounds < maxRounds) {
while (rounds < config.maxRounds) {
rounds += 1;
const response = await generate(options, {
...requestBase,
@ -56,29 +35,17 @@ export const chatWithTools = async (
return { response, rounds, toolCallsExecuted };
}
const assistantMessage: XMessage = {
role: "assistant",
content: [{ type: "text", text: response.text }],
toolCalls: response.toolCalls,
...(response.reasoning ? { reasoningContent: response.reasoning } : {}),
};
const toolMessages: XMessage[] = [];
for (const toolCall of response.toolCalls) {
const toolRunResult = await runToolCall(response.provider, toolCall, executors, strategy);
if (!toolRunResult.handled || !toolRunResult.message) {
continue;
}
toolMessages.push(toolRunResult.message);
toolCallsExecuted += 1;
}
const assistantMessage = buildAssistantMessage(response.text, response.toolCalls, response.reasoning);
const { toolMessages, toolCallsExecuted: executed } = await executeToolCalls(
config.provider,
response.toolCalls,
executors,
config.strategy,
);
toolCallsExecuted += executed;
currentMessages = [...currentMessages, assistantMessage, ...toolMessages];
}
throw new XllmError({
code: "PROVIDER_ERROR",
message: `Tool loop exceeded maxRounds=${maxRounds}`,
provider: input.provider ?? options.provider ?? "openai-compatible",
});
throwIfMaxRoundsExceeded(config);
};

5
packages/xllm/src/client/generate.ts

@ -7,11 +7,12 @@ export const generate = async (options: XClientOptions, request: XRequest): Prom
const resolved = resolveConfig(request, options);
const adapter = getProviderAdapter(resolved.provider);
const providerRequest = adapter.toProviderRequest(request, resolved, false);
const response = await postJSON(resolved.fetchImpl, providerRequest, adapter);
const response = await postJSON(resolved.fetchImpl, providerRequest, adapter, resolved);
await throwForBadStatus(response, adapter);
const payload = await response.json();
const maybeResponse = adapter.onAfterResponse ? await adapter.onAfterResponse(response, resolved) : response;
const payload = await maybeResponse.json();
const normalized = adapter.fromProviderResponse(payload, resolved.provider);
return { ...normalized, provider: resolved.provider, model: normalized.model || resolved.model };
};

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

@ -1,42 +1,37 @@
import { XllmError } from "../core/errors";
import type {
XChatWithToolsOptions,
XClientOptions,
XMessage,
XStreamEvent,
XToolExecutorMap,
XToolCall,
} from "../core/types";
import { stream } from "./stream";
import type { XToolCall } from "../core/types";
import { runToolCall } from "./tool-loop-shared";
import {
buildAssistantMessage,
buildRequestBase,
createToolLoopConfig,
executeToolCalls,
throwIfMaxRoundsExceeded,
} from "./tool-loop-runner";
export const streamWithTools = async function* (
options: XClientOptions,
input: XChatWithToolsOptions,
executors: XToolExecutorMap,
executors: Record<string, (args: unknown, toolCall: import("../core/types").XToolCall) => Promise<unknown> | unknown>,
): AsyncGenerator<XStreamEvent> {
const maxRounds = input.maxRounds ?? 5;
const strategy = input.toolErrorStrategy ?? "throw";
if (maxRounds < 1) {
throw new XllmError({
code: "INVALID_REQUEST",
message: "maxRounds must be >= 1",
provider: input.provider ?? options.provider ?? "openai-compatible",
});
}
const config = createToolLoopConfig(input, options);
const requestBase = buildRequestBase(input, true);
let rounds = 0;
let currentMessages = [...input.messages];
while (rounds < maxRounds) {
while (rounds < config.maxRounds) {
rounds += 1;
const toolCalls: XToolCall[] = [];
let assistantText = "";
let assistantReasoning = "";
for await (const event of stream(options, {
...input,
stream: true,
...requestBase,
messages: currentMessages,
})) {
if (event.type === "text.delta") {
@ -55,35 +50,11 @@ export const streamWithTools = async function* (
return;
}
const toolMessages: XMessage[] = [];
for (const toolCall of toolCalls) {
const toolRunResult = await runToolCall(
input.provider ?? options.provider ?? "openai-compatible",
toolCall,
executors,
strategy,
);
if (!toolRunResult.handled || !toolRunResult.message) {
continue;
}
toolMessages.push(toolRunResult.message);
}
const { toolMessages } = await executeToolCalls(config.provider, toolCalls, executors, config.strategy);
currentMessages = [
...currentMessages,
{
role: "assistant",
content: [{ type: "text", text: assistantText }],
toolCalls,
...(assistantReasoning ? { reasoningContent: assistantReasoning } : {}),
},
...toolMessages,
];
const assistantMessage = buildAssistantMessage(assistantText, toolCalls, assistantReasoning);
currentMessages = [...currentMessages, assistantMessage, ...toolMessages];
}
throw new XllmError({
code: "PROVIDER_ERROR",
message: `Tool stream loop exceeded maxRounds=${maxRounds}`,
provider: input.provider ?? options.provider ?? "openai-compatible",
});
throwIfMaxRoundsExceeded(config);
};

24
packages/xllm/src/client/stream.ts

@ -13,11 +13,13 @@ export const stream = async function* (
const resolved = resolveConfig(request, options);
const adapter = getProviderAdapter(resolved.provider);
const providerRequest = adapter.toProviderRequest(request, resolved, true);
const response = await postJSON(resolved.fetchImpl, providerRequest, adapter);
const response = await postJSON(resolved.fetchImpl, providerRequest, adapter, resolved);
await throwForBadStatus(response, adapter);
if (!response.body) {
const finalResponse = adapter.onAfterResponse ? await adapter.onAfterResponse(response, resolved) : response;
if (!finalResponse.body) {
throw new XllmError({
code: "NETWORK_ERROR",
message: "Response body is empty in streaming mode",
@ -26,8 +28,8 @@ export const stream = async function* (
}
const streamState: StreamState = { started: false, doneEmitted: false, toolCallsByIndex: new Map() };
for await (const data of parseSSE(response.body)) {
if (data === "[DONE]") {
for await (const frame of parseSSE(finalResponse.body)) {
if (frame.data === "[DONE]") {
if (!streamState.started) {
yield { type: "response.start", provider: resolved.provider, model: resolved.model };
}
@ -38,16 +40,26 @@ export const stream = async function* (
break;
}
// 忽略非数据事件(如 event: ping)
if (frame.event && frame.event !== "message" && frame.event !== "delta") {
continue;
}
let payload: unknown;
try {
payload = JSON.parse(data);
payload = JSON.parse(frame.data);
} catch {
continue;
}
const events = adapter.fromProviderStreamChunk(payload, streamState);
for (const event of events) {
yield event;
// 注入实际 provider(adapter 不再硬编码 provider 名)
if (event.type === "response.start") {
yield { ...event, provider: resolved.provider };
} else {
yield event;
}
}
}
};

97
packages/xllm/src/client/tool-loop-runner.ts

@ -0,0 +1,97 @@
import { XllmError } from "../core/errors";
import type {
XChatWithToolsOptions,
XClientOptions,
XMessage,
XRequest,
XToolCall,
XToolErrorStrategy,
XToolExecutorMap,
} from "../core/types";
import { resolveConfig } from "../runtime/config";
import { runToolCall } from "./tool-loop-shared";
export interface ToolLoopConfig {
provider: string;
maxRounds: number;
strategy: XToolErrorStrategy;
}
export interface ToolLoopAccumulator {
rounds: number;
toolCallsExecuted: number;
currentMessages: XMessage[];
}
export const createToolLoopConfig = (
input: XChatWithToolsOptions,
options: XClientOptions,
): ToolLoopConfig => {
const resolved = resolveConfig(input, options);
const maxRounds = input.maxRounds ?? 5;
const strategy = input.toolErrorStrategy ?? "throw";
if (maxRounds < 1) {
throw new XllmError({
code: "INVALID_REQUEST",
message: "maxRounds must be >= 1",
provider: resolved.provider,
});
}
return { provider: resolved.provider, maxRounds, strategy };
};
export const buildRequestBase = (
input: XChatWithToolsOptions,
stream: boolean,
): Omit<XRequest, "messages"> => ({
tools: input.tools,
toolChoice: input.toolChoice,
stream,
temperature: input.temperature,
topP: input.topP,
maxTokens: input.maxTokens,
metadata: input.metadata,
provider: input.provider,
model: input.model,
apiKey: input.apiKey,
baseURL: input.baseURL,
providerExtras: input.providerExtras,
});
export const buildAssistantMessage = (
text: string,
toolCalls: XToolCall[],
reasoning?: string,
): XMessage => ({
role: "assistant",
content: text,
toolCalls,
...(reasoning ? { reasoningContent: reasoning } : {}),
});
export const executeToolCalls = async (
provider: string,
toolCalls: XToolCall[],
executors: XToolExecutorMap,
strategy: XToolErrorStrategy,
): Promise<{ toolMessages: XMessage[]; toolCallsExecuted: number }> => {
const toolMessages: XMessage[] = [];
let toolCallsExecuted = 0;
for (const toolCall of toolCalls) {
const toolRunResult = await runToolCall(provider, toolCall, executors, strategy);
if (!toolRunResult.handled || !toolRunResult.message) {
continue;
}
toolMessages.push(toolRunResult.message);
toolCallsExecuted += 1;
}
return { toolMessages, toolCallsExecuted };
};
export function throwIfMaxRoundsExceeded(config: ToolLoopConfig): never {
throw new XllmError({
code: "PROVIDER_ERROR",
message: `Tool loop exceeded maxRounds=${config.maxRounds}`,
provider: config.provider,
});
}

2
packages/xllm/src/client/tool-loop-shared.ts

@ -18,7 +18,7 @@ export const parseToolArguments = (toolCall: XToolCall): unknown => {
export const asToolMessage = (toolCall: XToolCall, value: unknown): XMessage => ({
role: "tool",
toolCallId: toolCall.id,
content: [{ type: "text", text: typeof value === "string" ? value : JSON.stringify(value) }],
content: typeof value === "string" ? value : JSON.stringify(value),
});
const toolErrorAsMessage = (toolCall: XToolCall, error: unknown): XMessage =>

7
packages/xllm/src/core/errors.ts

@ -3,7 +3,9 @@ export type XllmErrorCode =
| "RATE_LIMIT"
| "NETWORK_ERROR"
| "INVALID_REQUEST"
| "PROVIDER_ERROR";
| "PROVIDER_ERROR"
| "TIMEOUT"
| "CANCELLED";
export class XllmError extends Error {
public readonly code: XllmErrorCode;
@ -11,6 +13,7 @@ export class XllmError extends Error {
public readonly statusCode?: number;
public readonly requestId?: string;
public readonly raw?: unknown;
public readonly responseHeaders?: Record<string, string>;
public constructor(params: {
code: XllmErrorCode;
@ -19,6 +22,7 @@ export class XllmError extends Error {
statusCode?: number;
requestId?: string;
raw?: unknown;
responseHeaders?: Record<string, string>;
}) {
super(params.message);
this.name = "XllmError";
@ -27,5 +31,6 @@ export class XllmError extends Error {
this.statusCode = params.statusCode;
this.requestId = params.requestId;
this.raw = params.raw;
this.responseHeaders = params.responseHeaders;
}
}

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

@ -1,4 +1,4 @@
export type XProviderName = "openai-compatible" | "deepseek";
export type XProviderName = string;
export type XMessageRole = "system" | "user" | "assistant" | "tool";
@ -9,7 +9,7 @@ export type XContentPart = XTextPart | XImagePart;
export type XMessage =
| {
role: "system" | "user" | "assistant";
content: XContentPart[];
content: XContentPart[] | string;
toolCalls?: XToolCall[];
/**
* assistant DeepSeek `reasoning_content`
@ -19,7 +19,7 @@ export type XMessage =
}
| {
role: "tool";
content: XContentPart[];
content: XContentPart[] | string;
toolCallId: string;
};
@ -64,17 +64,12 @@ export interface XRequest {
apiKey?: string;
baseURL?: string;
/**
* **** OpenAI Chat Completions 使
* `thinking` DeepSeek `providerExtras`
*/
thinking?: XThinkingMode;
/**
* **** `reasoning_effort` `high` / `max`
* `providerExtras`
*/
reasoningEffort?: XReasoningEffortInput;
/**
* JSON ****
*
* DeepSeek
* ```ts
* providerExtras: { thinking: { type: "enabled" }, reasoning_effort: "high" }
* ```
*/
providerExtras?: Record<string, unknown>;
}

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

@ -1,10 +1,11 @@
import { describe, expect, it } from "vitest";
import { createXllm, type XStreamEvent } from "./index";
import { createXllm, XllmError, registerAdapter, type XStreamEvent } from "./index";
import type { ProviderAdapter } from "./providers/types";
const toJsonResponse = (payload: unknown): Response =>
const toJsonResponse = (payload: unknown, status = 200, headers?: Record<string, string>): Response =>
new Response(JSON.stringify(payload), {
status: 200,
headers: { "content-type": "application/json" },
status,
headers: { "content-type": "application/json", ...headers },
});
const toSSEBody = (frames: string[]): ReadableStream<Uint8Array> => {
@ -53,6 +54,19 @@ describe("xllm", () => {
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',
@ -260,7 +274,7 @@ describe("xllm", () => {
expect(result.response.text).toContain("工具错误");
});
it("sends thinking and reasoning_effort for DeepSeek-style bodies", async () => {
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) : {});
@ -272,14 +286,13 @@ describe("xllm", () => {
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",
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" }] }],
reasoningEffort: "xhigh",
providerExtras: { reasoning_effort: "max" },
});
expect(bodies[1]?.reasoning_effort).toBe("max");
});
@ -349,4 +362,241 @@ describe("xllm", () => {
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");
});
});

3
packages/xllm/src/index.ts

@ -1,5 +1,6 @@
export { createXllm } from "./client/create-xllm";
export { XllmError } from "./core/errors";
export { registerAdapter, getRegisteredProviderNames } from "./providers/registry";
export type {
XChatWithToolsOptions,
XChatWithToolsResult,
@ -20,3 +21,5 @@ export type {
XToolExecutorMap,
XUsage,
} from "./core/types";
export type { ProviderAdapter, ProviderHttpRequest, StreamState } from "./providers/types";
export type { SSEFrame } from "./runtime/sse";

12
packages/xllm/src/providers/deepseek.adapter.ts

@ -1,8 +1,7 @@
import { XllmError } from "../core/errors";
import type { ProviderAdapter } from "./types";
import { openAICompatibleAdapter } from "./openai-compatible.adapter";
const DEFAULT_DEEPSEEK_BASE_URL = "https://api.deepseek.com";
import { defaultBaseUrlByProvider } from "../runtime/config";
export const deepseekAdapter: ProviderAdapter = {
name: "deepseek",
@ -12,7 +11,7 @@ export const deepseekAdapter: ProviderAdapter = {
input,
{
...config,
baseURL: config.baseURL || DEFAULT_DEEPSEEK_BASE_URL,
baseURL: config.baseURL ?? defaultBaseUrlByProvider["deepseek"],
},
stream,
);
@ -24,11 +23,7 @@ export const deepseekAdapter: ProviderAdapter = {
},
fromProviderStreamChunk(chunkData, state) {
const events = openAICompatibleAdapter.fromProviderStreamChunk(chunkData, state);
return events.map((event) => {
if (event.type !== "response.start") return event;
return { ...event, provider: "deepseek" };
});
return openAICompatibleAdapter.fromProviderStreamChunk(chunkData, state);
},
normalizeError(error) {
@ -40,6 +35,7 @@ export const deepseekAdapter: ProviderAdapter = {
statusCode: baseError.statusCode,
requestId: baseError.requestId,
raw: baseError.raw,
responseHeaders: baseError.responseHeaders,
});
},
};

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

@ -3,7 +3,6 @@ import type {
XContentPart,
XMessage,
XProviderName,
XReasoningEffortInput,
XRequest,
XResponse,
XStreamEvent,
@ -11,15 +10,15 @@ import type {
XUsage,
} from "../core/types";
import type { ProviderAdapter } from "./types";
import { defaultBaseUrlByProvider } from "../runtime/config";
const DEFAULT_OPENAI_COMPATIBLE_BASE_URL = "https://api.openai.com/v1";
const toProviderContent = (parts: XContentPart[]): string | Array<Record<string, unknown>> => {
if (parts.length === 1 && parts[0]?.type === "text") {
return parts[0].text;
const toProviderContent = (content: XContentPart[] | string): string | Array<Record<string, unknown>> => {
if (typeof content === "string") return content;
if (content.length === 1 && content[0]?.type === "text") {
return content[0].text;
}
return parts.map((part) => {
return content.map((part) => {
if (part.type === "text") {
return { type: "text", text: part.text };
}
@ -75,8 +74,10 @@ const toUsage = (usage: any): XUsage | undefined => {
};
};
let toolCallFallbackIndex = 0;
const toToolCall = (toolCall: any): XToolCall => ({
id: toolCall.id ?? `tool_call_${Math.random().toString(36).slice(2)}`,
id: toolCall.id ?? `tool_call_fallback_${++toolCallFallbackIndex}`,
name: toolCall.function?.name ?? "",
arguments: toolCall.function?.arguments ?? "",
});
@ -87,12 +88,6 @@ 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;
@ -110,12 +105,13 @@ export const openAICompatibleAdapter: ProviderAdapter = {
model: config.model,
messages: input.messages.map(toProviderMessage),
stream,
temperature: input.temperature,
top_p: input.topP,
max_tokens: input.maxTokens,
metadata: input.metadata,
};
if (input.temperature !== undefined) body.temperature = input.temperature;
if (input.topP !== undefined) body.top_p = input.topP;
if (input.maxTokens !== undefined) body.max_tokens = input.maxTokens;
if (input.metadata !== undefined) body.metadata = input.metadata;
if (input.tools && input.tools.length > 0) {
body.tools = input.tools.map((tool) => ({
type: "function",
@ -128,20 +124,13 @@ 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`,
url: `${config.baseURL || defaultBaseUrlByProvider["openai-compatible"]}/chat/completions`,
headers: {
"content-type": "application/json",
authorization: `Bearer ${config.apiKey}`,
@ -177,7 +166,7 @@ export const openAICompatibleAdapter: ProviderAdapter = {
state.started = true;
events.push({
type: "response.start",
provider: "openai-compatible",
provider: "", // 由 stream.ts 在 yield 时注入实际 provider
model: payload?.model ?? "",
requestId: payload?.id,
});
@ -249,6 +238,7 @@ export const openAICompatibleAdapter: ProviderAdapter = {
const err = error as any;
const statusCode = err?.statusCode ?? err?.status;
const message = err?.message ?? "Provider request failed";
const responseHeaders = err?.responseHeaders as Record<string, string> | undefined;
if (statusCode === 401 || statusCode === 403) {
return new XllmError({
code: "AUTH_ERROR",
@ -256,6 +246,7 @@ export const openAICompatibleAdapter: ProviderAdapter = {
provider: "openai-compatible",
statusCode,
raw: err,
responseHeaders,
});
}
if (statusCode === 429) {
@ -265,6 +256,7 @@ export const openAICompatibleAdapter: ProviderAdapter = {
provider: "openai-compatible",
statusCode,
raw: err,
responseHeaders,
});
}
if (statusCode && statusCode >= 400 && statusCode < 500) {
@ -274,6 +266,7 @@ export const openAICompatibleAdapter: ProviderAdapter = {
provider: "openai-compatible",
statusCode,
raw: err,
responseHeaders,
});
}
return new XllmError({
@ -282,6 +275,7 @@ export const openAICompatibleAdapter: ProviderAdapter = {
provider: "openai-compatible",
statusCode,
raw: err,
responseHeaders,
});
},
};

24
packages/xllm/src/providers/registry.ts

@ -3,9 +3,25 @@ import { deepseekAdapter } from "./deepseek.adapter";
import { openAICompatibleAdapter } from "./openai-compatible.adapter";
import type { ProviderAdapter } from "./types";
const adapterByName: Record<XProviderName, ProviderAdapter> = {
"openai-compatible": openAICompatibleAdapter,
deepseek: deepseekAdapter,
const adapterByName = new Map<XProviderName, ProviderAdapter>();
// 注册内置 adapter
adapterByName.set("openai-compatible", openAICompatibleAdapter);
adapterByName.set("deepseek", deepseekAdapter);
export const getProviderAdapter = (provider: XProviderName): ProviderAdapter => {
const adapter = adapterByName.get(provider);
if (!adapter) {
throw new Error(
`No adapter registered for provider "${provider}". ` +
`Use registerAdapter() to add one, or choose from: [${[...adapterByName.keys()].join(", ")}]`,
);
}
return adapter;
};
export const registerAdapter = (adapter: ProviderAdapter): void => {
adapterByName.set(adapter.name, adapter);
};
export const getProviderAdapter = (provider: XProviderName): ProviderAdapter => adapterByName[provider];
export const getRegisteredProviderNames = (): string[] => [...adapterByName.keys()];

6
packages/xllm/src/providers/types.ts

@ -27,4 +27,10 @@ export interface ProviderAdapter {
fromProviderResponse(raw: unknown, provider: XProviderName): XResponse;
fromProviderStreamChunk(chunkData: unknown, state: StreamState): XStreamEvent[];
normalizeError(error: unknown): XllmError;
/** 请求发送前的钩子,可修改请求(如签名、注入 header)。 */
onBeforeRequest?(request: ProviderHttpRequest, config: ResolvedConfig): ProviderHttpRequest;
/** 收到响应后的钩子,可用于日志、指标采集。返回值可替换响应。 */
onAfterResponse?(response: Response, config: ResolvedConfig): Response | Promise<Response>;
}

15
packages/xllm/src/runtime/config.ts

@ -1,6 +1,11 @@
import { XllmError } from "../core/errors";
import type { ResolvedConfig, XClientOptions, XRequest } from "../core/types";
declare global {
// eslint-disable-next-line no-var
var process: { env: Record<string, string | undefined> } | undefined;
}
const env = (key: string): string | undefined => {
if (typeof globalThis.process !== "undefined" && globalThis.process?.env) {
return globalThis.process.env[key];
@ -8,17 +13,17 @@ const env = (key: string): string | undefined => {
return undefined;
};
const defaultModelByProvider = {
const defaultModelByProvider: Record<string, string> = {
"openai-compatible": "gpt-4o-mini",
deepseek: "deepseek-chat",
} as const;
};
const defaultBaseUrlByProvider = {
export const defaultBaseUrlByProvider: Record<string, string> = {
"openai-compatible": "https://api.openai.com/v1",
deepseek: "https://api.deepseek.com",
} as const;
};
const resolveApiKey = (provider: "openai-compatible" | "deepseek", request?: XRequest, options?: XClientOptions) => {
const resolveApiKey = (provider: string, request?: XRequest, options?: XClientOptions) => {
const value =
request?.apiKey ??
options?.apiKey ??

20
packages/xllm/src/runtime/http.ts

@ -1,5 +1,6 @@
import { XllmError } from "../core/errors";
import type { ProviderAdapter, ProviderHttpRequest } from "../providers/types";
import type { ResolvedConfig } from "../core/types";
const toNetworkError = (error: unknown, provider: string): XllmError => {
if (error instanceof XllmError) return error;
@ -16,12 +17,16 @@ export const postJSON = async (
fetchImpl: typeof fetch,
request: ProviderHttpRequest,
adapter: ProviderAdapter,
config: ResolvedConfig,
): Promise<Response> => {
const finalRequest = adapter.onBeforeRequest
? adapter.onBeforeRequest(request, config)
: request;
try {
return await fetchImpl(request.url, {
method: request.method,
headers: request.headers,
body: JSON.stringify(request.body),
return await fetchImpl(finalRequest.url, {
method: finalRequest.method,
headers: finalRequest.headers,
body: JSON.stringify(finalRequest.body),
});
} catch (error) {
throw toNetworkError(error, adapter.name);
@ -37,9 +42,16 @@ export const throwForBadStatus = async (response: Response, adapter: ProviderAda
} catch {
raw = textBody;
}
const responseHeaders: Record<string, string> = {};
response.headers.forEach((value, key) => {
responseHeaders[key] = value;
});
throw adapter.normalizeError({
statusCode: response.status,
message: (raw as any)?.error?.message ?? `HTTP ${response.status}`,
raw,
responseHeaders,
});
};

30
packages/xllm/src/runtime/sse.ts

@ -1,4 +1,9 @@
export async function* parseSSE(stream: ReadableStream<Uint8Array>): AsyncGenerator<string> {
export interface SSEFrame {
event?: string;
data: string;
}
export async function* parseSSE(stream: ReadableStream<Uint8Array>): AsyncGenerator<SSEFrame> {
const reader = stream.getReader();
const decoder = new TextDecoder();
let buffer = "";
@ -15,14 +20,21 @@ export async function* parseSSE(stream: ReadableStream<Uint8Array>): AsyncGenera
buffer = buffer.slice(separatorIndex + 2);
separatorIndex = buffer.indexOf("\n\n");
const lines = frame
.split("\n")
.map((line) => line.trim())
.filter((line) => line.startsWith("data:"));
for (const line of lines) {
const payload = line.slice(5).trim();
if (!payload) continue;
yield payload;
let event: string | undefined;
const dataLines: string[] = [];
for (const line of frame.split("\n")) {
const trimmed = line.trim();
if (trimmed.startsWith("event:")) {
event = trimmed.slice(6).trim();
} else if (trimmed.startsWith("data:")) {
const payload = trimmed.slice(5).trim();
if (payload) dataLines.push(payload);
}
}
for (const data of dataLines) {
yield { event, data };
}
}
}

2
tsconfig.json

@ -1,7 +1,7 @@
{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"ignoreDeprecations": "5.0",
"ignoreDeprecations": "6.0",
"composite": true,
"lib": [
"ES2022",

2
tsconfig.node.json

@ -1,7 +1,7 @@
{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"ignoreDeprecations": "5.0",
"ignoreDeprecations": "6.0",
"composite": true,
"types": [
"node"

Loading…
Cancel
Save