上周我们团队把内部的 AI 工具链从 Cherry Studio 迁到了 OpenClaw,主要是看中它的插件机制——能自己写 Channel 把任意 API 源接进来。但说实话,官方文档写得挺粗糙的,我折腾了差不多两天才把第一个 Channel 插件跑通。这篇把完整过程记下来,省得后面的人再踩一遍。
OpenClaw 的 Channel 插件本质上是一个遵循特定接口规范的 TypeScript 模块,负责把 OpenClaw 内部的统一消息格式转换成目标 API 的请求格式,再把响应转回来。可以理解为一个双向适配器。整个开发流程大概是:初始化插件模板 → 实现 ChannelProvider 接口 → 本地调试 → 打包注册。下面一步步来。
先说结论
| 环节 | 耗时 | 难度 |
|---|---|---|
| 环境搭建 + 模板初始化 | 15 分钟 | 简单 |
| 实现 ChannelProvider 核心接口 | 2-3 小时 | 中等,主要是理解消息转换 |
| Streaming 支持 | 1-2 小时 | 需要处理 SSE 解析 |
| 本地联调 | 30 分钟 | 简单但容易忘记配环境变量 |
| 打包发布到 OpenClaw 实例 | 10 分钟 | 简单 |
核心卡点在第二步和第三步。接口不复杂,但消息格式的边界情况挺多的(比如多模态消息、function calling 的参数透传),后面会重点讲。
环境准备
OpenClaw 插件用 TypeScript 写,构建工具是 tsup。你需要:
- Node.js >= 18(我用的 20.12)
- pnpm >= 8
- OpenClaw CLI >= 0.4.2(
pnpm add -g @openclaw/cli)
先初始化一个插件项目:
openclaw plugin init my-channel --type channel
cd my-channel
pnpm install
跑完之后目录结构长这样:
my-channel/
├── src/
│ ├── index.ts # 插件入口
│ ├── provider.ts # ChannelProvider 实现
│ └── types.ts # 类型定义
├── manifest.json # 插件元信息
├── tsconfig.json
└── package.json
manifest.json 里有几个字段要改:
{
"name": "my-custom-channel",
"version": "0.1.0",
"type": "channel",
"displayName": "My Custom API Channel",
"description": "接入自定义 OpenAI 兼容 API",
"entry": "dist/index.js",
"settings": [
{
"key": "apiKey",
"label": "API Key",
"type": "secret",
"required": true
},
{
"key": "baseUrl",
"label": "API Base URL",
"type": "string",
"required": true,
"default": "https://api.ofox.ai/v1"
}
]
}
settings 数组定义了用户在 OpenClaw 管理界面里能配置的参数,会以表单形式展示。type: "secret" 的字段会做脱敏处理。
实现 ChannelProvider 核心接口
这是整个插件的核心。打开 src/provider.ts,你需要实现 ChannelProvider 接口的三个方法:
graph TD
A[OpenClaw 核心] -->|统一消息格式| B[你的 Channel 插件]
B -->|listModels| C[返回可用模型列表]
B -->|chat| D[非流式对话请求]
B -->|chatStream| E[流式对话请求]
D -->|转换响应格式| A
E -->|SSE 逐 chunk 返回| A
先看完整代码,然后逐块解释:
// src/provider.ts
import type {
ChannelProvider,
ChatRequest,
ChatResponse,
ChatStreamCallback,
ModelInfo,
PluginSettings,
} from "@openclaw/plugin-sdk";
export class MyChannelProvider implements ChannelProvider {
private apiKey: string;
private baseUrl: string;
constructor(settings: PluginSettings) {
this.apiKey = settings.get("apiKey") as string;
this.baseUrl = settings.get("baseUrl") as string;
if (!this.apiKey) {
throw new Error("API Key is required");
}
}
async listModels(): Promise<ModelInfo[]> {
const res = await fetch(`${this.baseUrl}/models`, {
headers: { Authorization: `Bearer ${this.apiKey}` },
});
if (!res.ok) {
// 这里踩过坑:有些 API 返回 401 时 body 不是 JSON
const text = await res.text();
throw new Error(`Failed to list models: ${res.status} - ${text}`);
}
const data = await res.json();
return data.data.map((m: any) => ({
id: m.id,
name: m.id,
contextLength: m.context_window ?? 128000,
supportVision: m.id.includes("vision") || m.id.includes("gpt-5"),
supportFunctionCall: true,
}));
}
async chat(request: ChatRequest): Promise<ChatResponse> {
const body = this.buildRequestBody(request, false);
const res = await fetch(`${this.baseUrl}/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${this.apiKey}`,
},
body: JSON.stringify(body),
});
if (!res.ok) {
const errText = await res.text();
throw new Error(`Chat failed: ${res.status} - ${errText}`);
}
const data = await res.json();
return {
content: data.choices[0]?.message?.content ?? "",
role: "assistant",
usage: {
promptTokens: data.usage?.prompt_tokens ?? 0,
completionTokens: data.usage?.completion_tokens ?? 0,
},
toolCalls: data.choices[0]?.message?.tool_calls ?? undefined,
};
}
async chatStream(
request: ChatRequest,
callback: ChatStreamCallback
): Promise<void> {
const body = this.buildRequestBody(request, true);
const res = await fetch(`${this.baseUrl}/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${this.apiKey}`,
},
body: JSON.stringify(body),
});
if (!res.ok) {
const errText = await res.text();
throw new Error(`Stream failed: ${res.status} - ${errText}`);
}
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
const payload = line.slice(6).trim();
if (payload === "[DONE]") {
callback({ type: "done" });
return;
}
try {
const chunk = JSON.parse(payload);
const delta = chunk.choices?.[0]?.delta;
if (delta?.content) {
callback({ type: "content", content: delta.content });
}
if (delta?.tool_calls) {
callback({ type: "toolCall", toolCalls: delta.tool_calls });
}
} catch (e) {
// 偶尔会收到不完整的 JSON,跳过就行
// 实测大概每 500 次请求遇到 1 次
}
}
}
}
private buildRequestBody(request: ChatRequest, stream: boolean) {
return {
model: request.model,
messages: request.messages.map((msg) => ({
role: msg.role,
content: msg.content,
...(msg.toolCalls ? { tool_calls: msg.toolCalls } : {}),
...(msg.toolCallId ? { tool_call_id: msg.toolCallId } : {}),
})),
stream,
temperature: request.temperature ?? 0.7,
max_tokens: request.maxTokens ?? 4096,
...(request.tools?.length ? { tools: request.tools } : {}),
};
}
}
代码看着长,逻辑其实很直白:listModels 拉模型列表,chat 做非流式请求,chatStream 处理 SSE 流。buildRequestBody 是个工具方法,把 OpenClaw 的内部格式转成 OpenAI 兼容格式。
踩坑记录
说几个我实际遇到的问题。
坑 1:SSE buffer 拆分不完整
一开始我用 \n\n 做分隔符(标准 SSE 是双换行分隔),结果有些 API 返回的 chunk 里只有单个 \n。改成按 \n 拆分再过滤空行之后就好了。这个 bug 花了我大概一个半小时,因为它不是每次都复现——只有当 TCP 包刚好在 \n\n 中间断开的时候才会出问题。
坑 2:listModels 的 401 错误
测试的时候拿了一个过期的 Key,报了这个:
Error: Failed to list models: 401 - {"error":{"message":"Invalid API key provided: sk-xxxx...xxxx. You can find your API key at https://platform.openai.com/account/api-keys.","type":"invalid_request_error","param":null,"code":"invalid_api_key"}}
这个好解决,但注意有些 API 的 401 返回体不是 JSON 而是纯文本 HTML(比如某些反代服务会返回 nginx 的默认 401 页面),所以我在错误处理里用了 res.text() 而不是 res.json()。
坑 3:tool_calls 字段的透传
OpenClaw 内部用驼峰命名(toolCalls),但 OpenAI 兼容 API 用蛇形(tool_calls)。我一开始忘了在 buildRequestBody 里做转换,function calling 一直报 400 Bad Request:
{"error":{"message":"Invalid parameter: messages[2] has an invalid 'tool_calls' field.","type":"invalid_request_error"}}
查了半天才发现是命名格式的问题。camelCase 和 snake_case 的转换在 JS/TS 生态里永远是个坑。
坑 4:manifest.json 的 entry 路径
entry 字段必须指向构建后的文件路径。我一开始写成 src/index.ts,加载插件的时候直接报 Cannot find module。改成 dist/index.js 就好了。低级错误,但确实浪费了十几分钟。
本地调试
OpenClaw CLI 提供了一个 dev 命令可以热加载插件:
# 先构建
pnpm build
# 启动开发服务器,会自动注册插件
openclaw dev --plugin ./dist/index.js
然后在浏览器里打开 http://localhost:3210,进 Settings → Channels,你应该能看到自己的插件出现了。填入 API Key 和 Base URL,点 Test Connection。
我测试的时候 Base URL 填的是 https://api.ofox.ai/v1——OpenRouter、ofox.ai 这类聚合平台走 OpenAI 兼容协议,不用额外改代码,改个 URL 就行。如果你要接的是 Anthropic 原生协议或者其他非 OpenAI 兼容的 API,那 buildRequestBody 和响应解析都得重写,这又是另一个话题了。
调试的时候有个小技巧:在 chatStream 的 callback 里加个 console.log 打印每个 chunk,能很直观地看到流式返回的节奏。我发现不同模型的 chunk 频率差异挺大的,Claude Sonnet 4.6 大概 30-50ms 一个 chunk,GPT-5.5 快一点在 20-40ms 左右。
插件入口文件
差点忘了说 src/index.ts,这个文件很短:
// src/index.ts
import type { PluginDefinition } from "@openclaw/plugin-sdk";
import { MyChannelProvider } from "./provider";
const plugin: PluginDefinition = {
createProvider: (settings) => new MyChannelProvider(settings),
};
export default plugin;
就是导出一个工厂函数,OpenClaw 加载插件时会调用 createProvider 并传入用户配置的 settings。
打包发布
本地测通之后,打包成 .ocpkg 文件:
openclaw plugin pack
# 输出: my-custom-channel-0.1.0.ocpkg
然后在 OpenClaw 管理界面 → Plugins → Upload 上传这个文件就行。如果是团队自部署的 OpenClaw 实例,也可以把 .ocpkg 放到插件目录里让它自动加载。
小结
Channel 插件的开发门槛不高,核心就是实现三个方法。真正花时间的是处理各种边界情况——SSE 解析的 buffer 问题、字段命名转换、不同 API 的错误响应格式差异。我目前还没找到特别优雅的方式来统一处理所有 OpenAI 兼容 API 的细微差异(比如有些 API 的 usage 字段在流式模式下不返回),如果你有好的方案欢迎评论区聊。
整个流程跑通之后,后续加新的 API 源基本就是改改 URL 和认证方式的事。