diff --git a/packages/example/.env b/packages/example/.env new file mode 100644 index 0000000..fb2b3df --- /dev/null +++ b/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 diff --git a/packages/example/src/xllm/demos/stream-with-tools.ts b/packages/example/src/xllm/demos/stream-with-tools.ts index 41d8107..40e8aeb 100644 --- a/packages/example/src/xllm/demos/stream-with-tools.ts +++ b/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) => { diff --git a/packages/example/src/xllm/demos/thinking.ts b/packages/example/src/xllm/demos/thinking.ts index e8078f0..06f839c 100644 --- a/packages/example/src/xllm/demos/thinking.ts +++ b/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; } { 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 { 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 = ""; diff --git a/packages/xllm/README.md b/packages/xllm/README.md index f5a3e3a..b85d4a5 100644 --- a/packages/xllm/README.md +++ b/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); diff --git a/packages/xllm/docs/LLM-USAGE.md b/packages/xllm/docs/LLM-USAGE.md index 9876291..fc1a03b 100644 --- a/packages/xllm/docs/LLM-USAGE.md +++ b/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`:合并进 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; ## 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`: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; +} +``` + +### 11.3 `ProviderHttpRequest` + +```ts +interface ProviderHttpRequest { + url: string; + method: "POST"; + headers: Record; + body: Record; +} +``` + +### 11.4 `StreamState` + +```ts +interface StreamState { + started: boolean; + doneEmitted: boolean; + toolCallsByIndex: Map; +} +``` + +适配器在 `fromProviderStreamChunk` 中通过 `state` 跟踪流状态:`started` 标记是否已发出 `response.start`,`doneEmitted` 防止重复发出 `response.done`,`toolCallsByIndex` 用于拼接跨 chunk 的工具调用参数。 + +### 11.5 钩子:`onBeforeRequest` / `onAfterResponse` + +- **`onBeforeRequest`**:在 `fetch` 调用前触发,接收即将发送的 `ProviderHttpRequest`,返回修改后的请求。典型用途:请求签名、注入认证 header、添加追踪 ID。 +- **`onAfterResponse`**:在收到 HTTP `Response` 后触发,可读取状态码/头信息做日志或指标采集,也可返回一个新的 `Response` 替换原始响应。支持异步(`Promise`)。 + +### 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 时)? --- diff --git a/packages/xllm/src/client/chat-with-tools.ts b/packages/xllm/src/client/chat-with-tools.ts index 28cc856..e3069b2 100644 --- a/packages/xllm/src/client/chat-with-tools.ts +++ b/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 Promise | unknown>, ): Promise => { - 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 = { - 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); }; diff --git a/packages/xllm/src/client/generate.ts b/packages/xllm/src/client/generate.ts index be83f7a..fcd9475 100644 --- a/packages/xllm/src/client/generate.ts +++ b/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 }; }; diff --git a/packages/xllm/src/client/stream-with-tools.ts b/packages/xllm/src/client/stream-with-tools.ts index 5dee2de..8ab1aef 100644 --- a/packages/xllm/src/client/stream-with-tools.ts +++ b/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 Promise | unknown>, ): AsyncGenerator { - 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); }; diff --git a/packages/xllm/src/client/stream.ts b/packages/xllm/src/client/stream.ts index f1403f6..68c568f 100644 --- a/packages/xllm/src/client/stream.ts +++ b/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; + } } } }; diff --git a/packages/xllm/src/client/tool-loop-runner.ts b/packages/xllm/src/client/tool-loop-runner.ts new file mode 100644 index 0000000..6328d10 --- /dev/null +++ b/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 => ({ + 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, + }); +} diff --git a/packages/xllm/src/client/tool-loop-shared.ts b/packages/xllm/src/client/tool-loop-shared.ts index 2797048..ff6a27c 100644 --- a/packages/xllm/src/client/tool-loop-shared.ts +++ b/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 => diff --git a/packages/xllm/src/core/errors.ts b/packages/xllm/src/core/errors.ts index 3de828f..553f7d5 100644 --- a/packages/xllm/src/core/errors.ts +++ b/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; public constructor(params: { code: XllmErrorCode; @@ -19,6 +22,7 @@ export class XllmError extends Error { statusCode?: number; requestId?: string; raw?: unknown; + responseHeaders?: Record; }) { 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; } } diff --git a/packages/xllm/src/core/types.ts b/packages/xllm/src/core/types.ts index 75da46c..c49457b 100644 --- a/packages/xllm/src/core/types.ts +++ b/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; } diff --git a/packages/xllm/src/index.test.ts b/packages/xllm/src/index.test.ts index 32ddf68..2c14c32 100644 --- a/packages/xllm/src/index.test.ts +++ b/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): 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 => { @@ -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> = []; 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 = {}; + const mockFetch: typeof fetch = async (_input, init) => { + body = typeof init?.body === "string" ? JSON.parse(init.body) : {}; + return toJsonResponse({ + choices: [{ message: { content: "ok" }, finish_reason: "stop" }], + }); + }; + const client = createXllm({ provider: "openai-compatible", apiKey: "test", fetch: mockFetch }); + await client.generate({ + messages: [{ role: "user", content: "hi" }], + }); + expect(body).not.toHaveProperty("temperature"); + expect(body).not.toHaveProperty("top_p"); + expect(body).not.toHaveProperty("max_tokens"); + expect(body).not.toHaveProperty("metadata"); + }); + + // --- 错误路径测试 --- + + it("throws AUTH_ERROR on 401 response", async () => { + const mockFetch: typeof fetch = async () => + toJsonResponse({ error: { message: "Invalid API key" } }, 401, { "x-request-id": "req_123" }); + const client = createXllm({ provider: "openai-compatible", apiKey: "bad", fetch: mockFetch }); + try { + await client.generate({ messages: [{ role: "user", content: "hi" }] }); + expect.unreachable("Should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(XllmError); + expect((error as XllmError).code).toBe("AUTH_ERROR"); + expect((error as XllmError).statusCode).toBe(401); + expect((error as XllmError).responseHeaders?.["x-request-id"]).toBe("req_123"); + } + }); + + it("throws RATE_LIMIT on 429 response", async () => { + const mockFetch: typeof fetch = async () => + toJsonResponse({ error: { message: "Rate limit exceeded" } }, 429); + const client = createXllm({ provider: "deepseek", apiKey: "test", fetch: mockFetch }); + try { + await client.generate({ messages: [{ role: "user", content: "hi" }] }); + expect.unreachable("Should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(XllmError); + expect((error as XllmError).code).toBe("RATE_LIMIT"); + } + }); + + it("throws NETWORK_ERROR when fetch throws", async () => { + const mockFetch: typeof fetch = async () => { + throw new TypeError("fetch failed"); + }; + const client = createXllm({ provider: "openai-compatible", apiKey: "test", fetch: mockFetch }); + try { + await client.generate({ messages: [{ role: "user", content: "hi" }] }); + expect.unreachable("Should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(XllmError); + expect((error as XllmError).code).toBe("NETWORK_ERROR"); + } + }); + + it("throws AUTH_ERROR when apiKey is missing", async () => { + const client = createXllm({ provider: "openai-compatible" }); + try { + await client.generate({ messages: [{ role: "user", content: "hi" }] }); + expect.unreachable("Should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(XllmError); + expect((error as XllmError).code).toBe("AUTH_ERROR"); + } + }); + + it("throws when maxRounds is exceeded in chatWithTools", async () => { + const mockFetch: typeof fetch = async () => + toJsonResponse({ + choices: [ + { + message: { + content: "", + tool_calls: [{ id: "c1", function: { name: "loop_tool", arguments: "{}" } }], + }, + finish_reason: "tool_calls", + }, + ], + }); + const client = createXllm({ provider: "deepseek", apiKey: "test", fetch: mockFetch }); + try { + await client.chatWithTools( + { messages: [{ role: "user", content: "hi" }], tools: [{ name: "loop_tool" }], maxRounds: 1 }, + { loop_tool: () => "ok" }, + ); + expect.unreachable("Should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(XllmError); + expect((error as XllmError).code).toBe("PROVIDER_ERROR"); + expect((error as XllmError).message).toContain("maxRounds"); + } + }); + + it("throws INVALID_REQUEST when maxRounds < 1", async () => { + const client = createXllm({ provider: "deepseek", apiKey: "test", fetch: async () => new Response() }); + try { + await client.chatWithTools( + { messages: [{ role: "user", content: "hi" }], tools: [], maxRounds: 0 }, + {}, + ); + expect.unreachable("Should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(XllmError); + expect((error as XllmError).code).toBe("INVALID_REQUEST"); + } + }); + + it("skips tool call when strategy is skip and no executor found", async () => { + let call = 0; + const mockFetch: typeof fetch = async () => { + call += 1; + if (call === 1) { + return toJsonResponse({ + choices: [ + { + message: { + content: "", + tool_calls: [{ id: "c1", function: { name: "unknown_tool", arguments: "{}" } }], + }, + finish_reason: "tool_calls", + }, + ], + }); + } + return toJsonResponse({ + choices: [{ message: { content: "done" }, finish_reason: "stop" }], + }); + }; + const client = createXllm({ provider: "deepseek", apiKey: "test", fetch: mockFetch }); + const result = await client.chatWithTools( + { + messages: [{ role: "user", content: "hi" }], + tools: [{ name: "unknown_tool" }], + toolErrorStrategy: "skip", + }, + {}, + ); + expect(result.toolCallsExecuted).toBe(0); + expect(result.response.text).toBe("done"); + }); + + it("throws for unknown provider", async () => { + const client = createXllm({ provider: "unknown-provider" as any, apiKey: "test" }); + try { + await client.generate({ messages: [{ role: "user", content: "hi" }] }); + expect.unreachable("Should have thrown"); + } catch (error) { + expect((error as Error).message).toContain("No adapter registered"); + } + }); + + it("registerAdapter allows adding custom provider", async () => { + const customAdapter: ProviderAdapter = { + name: "custom-test", + toProviderRequest: () => ({ + method: "POST", + url: "https://custom.example.com/v1/chat/completions", + headers: { "content-type": "application/json", authorization: "Bearer test" }, + body: { model: "custom-model", messages: [], stream: false }, + }), + fromProviderResponse: () => ({ + text: "custom response", + toolCalls: [], + provider: "custom-test", + model: "custom-model", + }), + fromProviderStreamChunk: () => [], + normalizeError: (err: any) => + new XllmError({ code: "PROVIDER_ERROR", message: err?.message ?? "error", provider: "custom-test" }), + }; + registerAdapter(customAdapter); + + const mockFetch: typeof fetch = async () => + toJsonResponse({ + choices: [{ message: { content: "custom response" }, finish_reason: "stop" }], + }); + const client = createXllm({ provider: "custom-test", apiKey: "test", fetch: mockFetch }); + const result = await client.generate({ messages: [{ role: "user", content: "hi" }] }); + expect(result.text).toBe("custom response"); + expect(result.provider).toBe("custom-test"); + }); + + it("with() creates derived client with overrides", async () => { + const mockFetch: typeof fetch = async () => + toJsonResponse({ + choices: [{ message: { content: "derived" }, finish_reason: "stop" }], + }); + const base = createXllm({ provider: "openai-compatible", apiKey: "test", fetch: mockFetch }); + const derived = base.with({ model: "gpt-4o" }); + const result = await derived.generate({ messages: [{ role: "user", content: "hi" }] }); + expect(result.text).toBe("derived"); + }); + + it("onBeforeRequest hook modifies request", async () => { + let capturedHeaders: Record = {}; + const mockFetch: typeof fetch = async (_input, init) => { + capturedHeaders = Object.fromEntries(new Headers((init as RequestInit)?.headers as Headers)); + return toJsonResponse({ + choices: [{ message: { content: "ok" }, finish_reason: "stop" }], + }); + }; + + const customAdapter: ProviderAdapter = { + name: "hook-test", + toProviderRequest: () => ({ + method: "POST", + url: "https://hook.example.com/v1/chat/completions", + headers: { "content-type": "application/json", authorization: "Bearer test" }, + body: { model: "hook-model", messages: [], stream: false }, + }), + fromProviderResponse: () => ({ + text: "ok", + toolCalls: [], + provider: "hook-test", + model: "hook-model", + }), + fromProviderStreamChunk: () => [], + normalizeError: (err: any) => + new XllmError({ code: "PROVIDER_ERROR", message: err?.message ?? "error", provider: "hook-test" }), + onBeforeRequest(request) { + return { ...request, headers: { ...request.headers, "x-custom-signature": "signed" } }; + }, + }; + registerAdapter(customAdapter); + + const client = createXllm({ provider: "hook-test", apiKey: "test", fetch: mockFetch }); + await client.generate({ messages: [{ role: "user", content: "hi" }] }); + expect(capturedHeaders["x-custom-signature"]).toBe("signed"); + }); }); diff --git a/packages/xllm/src/index.ts b/packages/xllm/src/index.ts index f29f5b5..4240efc 100644 --- a/packages/xllm/src/index.ts +++ b/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"; diff --git a/packages/xllm/src/providers/deepseek.adapter.ts b/packages/xllm/src/providers/deepseek.adapter.ts index 012a198..9666e31 100644 --- a/packages/xllm/src/providers/deepseek.adapter.ts +++ b/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, }); }, }; diff --git a/packages/xllm/src/providers/openai-compatible.adapter.ts b/packages/xllm/src/providers/openai-compatible.adapter.ts index c78fb5d..20440ad 100644 --- a/packages/xllm/src/providers/openai-compatible.adapter.ts +++ b/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> => { - if (parts.length === 1 && parts[0]?.type === "text") { - return parts[0].text; +const toProviderContent = (content: XContentPart[] | string): string | Array> => { + 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 | 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 | 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, }); }, }; diff --git a/packages/xllm/src/providers/registry.ts b/packages/xllm/src/providers/registry.ts index 42d6900..0f37807 100644 --- a/packages/xllm/src/providers/registry.ts +++ b/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 = { - "openai-compatible": openAICompatibleAdapter, - deepseek: deepseekAdapter, +const adapterByName = new Map(); + +// 注册内置 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()]; diff --git a/packages/xllm/src/providers/types.ts b/packages/xllm/src/providers/types.ts index 708722d..801d141 100644 --- a/packages/xllm/src/providers/types.ts +++ b/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; } diff --git a/packages/xllm/src/runtime/config.ts b/packages/xllm/src/runtime/config.ts index 4bd0f32..0325679 100644 --- a/packages/xllm/src/runtime/config.ts +++ b/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 } | 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 = { "openai-compatible": "gpt-4o-mini", deepseek: "deepseek-chat", -} as const; +}; -const defaultBaseUrlByProvider = { +export const defaultBaseUrlByProvider: Record = { "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 ?? diff --git a/packages/xllm/src/runtime/http.ts b/packages/xllm/src/runtime/http.ts index 9eebee9..35869ad 100644 --- a/packages/xllm/src/runtime/http.ts +++ b/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 => { + 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 = {}; + 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, }); }; diff --git a/packages/xllm/src/runtime/sse.ts b/packages/xllm/src/runtime/sse.ts index c56b039..359cde5 100644 --- a/packages/xllm/src/runtime/sse.ts +++ b/packages/xllm/src/runtime/sse.ts @@ -1,4 +1,9 @@ -export async function* parseSSE(stream: ReadableStream): AsyncGenerator { +export interface SSEFrame { + event?: string; + data: string; +} + +export async function* parseSSE(stream: ReadableStream): AsyncGenerator { const reader = stream.getReader(); const decoder = new TextDecoder(); let buffer = ""; @@ -15,14 +20,21 @@ export async function* parseSSE(stream: ReadableStream): 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 }; } } } diff --git a/tsconfig.json b/tsconfig.json index 60c1111..000eee7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,7 @@ { "extends": "./tsconfig.base.json", "compilerOptions": { - "ignoreDeprecations": "5.0", + "ignoreDeprecations": "6.0", "composite": true, "lib": [ "ES2022", diff --git a/tsconfig.node.json b/tsconfig.node.json index 69c898f..59af162 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -1,7 +1,7 @@ { "extends": "./tsconfig.base.json", "compilerOptions": { - "ignoreDeprecations": "5.0", + "ignoreDeprecations": "6.0", "composite": true, "types": [ "node"