OpenClaw Channel 插件开发实战:从零写一个自定义模型接入插件(2026)

15 阅读1分钟

上周我们团队把内部的 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 和认证方式的事。