适配opencode

This commit is contained in:
Sirius
2026-08-12 15:26:53 +08:00
parent 3b9454c787
commit cfece34a71
3 changed files with 65 additions and 4 deletions
+25 -3
View File
@@ -59,7 +59,8 @@ command-code-openai-bridge/
│ └── start.sh
└── examples/
├── node_client.mjs
── python_client.py
── python_client.py
└── opencode.json
```
## 安装
@@ -119,6 +120,26 @@ models:
Bridge 会直接响应浏览器 CORS 预检,包括 Copilot 和 OpenAI SDK 发送的自定义请求头及本机 Private Network Access。关闭 Copilot 的 CORS 绕过后,请求使用原生 `fetch`,可以读取 Bridge 返回的 SSE 流。将 `config.yaml` 中的 `stream_thinking` 设为 `true` 后,Bridge 会把实时思考事件作为 `<think>` 块发送,Copilot 将其显示为可折叠思考内容。思考中的 `Sources` 标记会被等效转义,避免 Copilot 把内部草稿误判为最终引用区并丢弃后续正文。
### OpenCode 自定义 provider
已在本机验证 **OpenCode 1.18.15** 通过 `@ai-sdk/openai-compatible` 自定义 provider 连接 Bridge,并完成真实工具循环(`glob` 单工具、并行 `glob`、流式 `tool_calls` 与工具结果后的文本轮次)。
1. 启动 Bridge(默认 `http://127.0.0.1:18000/v1`)。
2.`examples/opencode.json` 复制到项目根目录,或通过环境变量指向它:
```bash
export OPENCODE_CONFIG="/path/to/command-code-openai-bridge/examples/opencode.json"
```
3. OpenCode 仍需配置任意非空 API keyBridge 忽略 Authorization,但客户端要求填写)。示例配置使用 `options.apiKey: "local-bridge"`
4. 选择模型 `command-bridge/command-default`(与 `config.yaml` 中的 `models.command-default` 映射一致)。
OpenCode 1.18.15 实际发送的 Chat 请求包含 `stream: true``stream_options.include_usage: true``max_tokens`(默认约 32000)、`store: false``tools`(约 24 个内置工具)、`tool_choice: auto`。Bridge 接受 `store` 与采样/输出限制参数但不改变无状态行为;未映射参数通过 `X-Command-Code-Ignored-Parameters` 标出。工具 `parameters` 中的 JSON Schema `$schema`draft 2020-12)会在校验前剥离元数据字段,避免 AJV 拒绝 OpenCode 工具定义。
OpenCode 还会在会话标题生成等场景发送无 `tools` 的压缩/摘要请求(`stream: true`、较长 system 提示、无 `store``store: false`)。这些请求与主 agent 请求共用同一 provider。
已知限制:Bridge 不实现 OpenCode hosted tools、图片/音频输入、Computer Use 或 Responses `/v1/responses` 路径;OpenCode 自定义 provider 走 `/v1/chat/completions``temperature``max_tokens` 等采样参数不会传给 Command Code CLI。
## 启动、停止与重启
```bash
@@ -160,7 +181,7 @@ Chat 接口接受标准 function tools、`tool_choice` 和 `parallel_tool_calls`
`stream` 省略或设为 `false` 时返回普通 JSON。普通文本请求的 `stream: true` 会立即建立 SSE 连接并发送 assistant role 块,随后在 Command Code 的 `text_delta` 到达时立即发送对应 SSE chunk,最后发送 `finish_reason: "stop"`、可选 usage 块和 `[DONE]`。工具模式需要先解析和校验完整决策:最终文本会在校验后作为 content chunk 发送;工具调用会作为 `delta.tool_calls` 发送,并以 `finish_reason: "tool_calls"` 结束。`stream_thinking: true` 只作用于普通文本请求;该兼容格式不是原生 OpenAI reasoning item。传入 `stream_options.include_usage: true` 时,结束前返回 usage 块。
为兼容 Obsidian Copilot 和 LangChain OpenAI-format 客户端,Chat 接口还接受 `temperature``max_tokens``max_completion_tokens``top_p``frequency_penalty``presence_penalty``n: 1`。这些采样输出限制参数会按 OpenAI 取值范围严格校验。Command Code CLI 1.12.0 没有对应的 headless 参数,因此 Bridge 不会把它们伪装成已生效:服务终端会输出警告,HTTP 响应带 `X-Command-Code-Ignored-Parameters`。其他未实现字段仍返回 `400 unsupported_parameter`
为兼容 Obsidian Copilot、OpenCode 和 LangChain OpenAI-format 客户端,Chat 接口还接受 `temperature``max_tokens``max_completion_tokens``top_p``frequency_penalty``presence_penalty``n: 1``store`。这些采样输出限制与会话存储参数会按 OpenAI 取值范围严格校验。Command Code CLI 没有对应的 headless 参数,Chat Completions 也不保存跨请求会话;Bridge 不会把它们伪装成已生效:服务终端会输出警告,HTTP 响应带 `X-Command-Code-Ignored-Parameters`。其他未实现字段仍返回 `400 unsupported_parameter`
Chat Completions 和 Responses 共用 Command Code NDJSON 执行层。服务终端会在事件到达时立即显示状态、文本和 Command Code 自身的工具调用。普通文本 Chat 会把每个 `text_delta` 直接写入 SSE;外部 function calling 模式会缓存 `finalText`,避免内部 JSON 决策进入客户端正文。Responses 外部 function calling 同样缓存 `finalText`,工具轮次不向客户端输出 assistant 文本。
@@ -315,7 +336,7 @@ command-code \
## 本机实际检查结果
检查日期:Chat Completions 原有检查为 2026-08-04Responses 文本子集检查为 2026-08-05Chat function calling 协议检查为 2026-08-06Responses function calling 检查为 2026-08-12。没有编写测试用例。原有条目来自真实 CLI 和真实 HTTP/SDK 客户端;有限队列改动完成了类型检查、生产构建和协调器运行时冒烟检查,尚未重新运行真实 CLI 并发检查。Chat function calling 完成了内部协议冒烟检查、Obsidian Copilot 当前依赖 `@langchain/openai 1.2.2` 的双轮 wire compatibility 检查,以及真实 Command Code 与 OpenAI Node.js SDK 的双轮 HTTP/SSE 调用;尚未在 Obsidian UI 中运行完整检查。Responses function calling 完成了类型检查、生产构建、真实 HTTP 双轮检查、流式 function call SSE 检查和 OpenAI Node.js SDK 双轮检查。
检查日期:Chat Completions 原有检查为 2026-08-04Responses 文本子集检查为 2026-08-05Chat function calling 协议检查为 2026-08-06Responses function calling 检查为 2026-08-12OpenCode 1.18.15 Chat Completions E2E 检查为 2026-08-12。没有编写测试用例。原有条目来自真实 CLI 和真实 HTTP/SDK 客户端;有限队列改动完成了类型检查、生产构建和协调器运行时冒烟检查,尚未重新运行真实 CLI 并发检查。Chat function calling 完成了内部协议冒烟检查、Obsidian Copilot 当前依赖 `@langchain/openai 1.2.2` 的双轮 wire compatibility 检查,以及真实 Command Code 与 OpenAI Node.js SDK 的双轮 HTTP/SSE 调用;尚未在 Obsidian UI 中运行完整检查。Responses function calling 完成了类型检查、生产构建、真实 HTTP 双轮检查、流式 function call SSE 检查和 OpenAI Node.js SDK 双轮检查。OpenCode 1.18.15 完成了真实 `opencode run` 文本回复、`glob` 工具循环、并行 `glob`、流式 `tool_calls`/`stop` SSE、`store: false``max_tokens` 接受、工具 Schema `$schema` 兼容,以及客户端断开后的取消恢复检查。
- TypeScript 严格类型检查和生产构建通过。
- npm 生产依赖审计:0 个已知漏洞。
@@ -352,6 +373,7 @@ command-code \
- Responses 流式 function call 发送 `output_item.added``function_call_arguments.delta/done``output_item.done``completed`,无 `output_text.delta`
- OpenAI Node.js SDK 的 Responses 双轮检查:第一轮返回 `multiply` function call,第二轮消费 `function_call_output` 后返回最终文本。
- Responses 无效工具名、无效 arguments JSON、孤立 `function_call_output` 和 hosted tool 类型分别返回带 `param` 的 400 错误。
- OpenCode 1.18.15 自定义 provider`store: false``max_tokens: 32000` 返回 HTTP 200`stream: true` 工具轮次以 `finish_reason: tool_calls` 结束,文本轮次以 `stop` 结束;`glob` 单工具与并行 `glob` 完成完整工具循环;工具 `parameters.$schema`draft 2020-12)不再触发 400。
## 已知限制
+2
View File
@@ -97,6 +97,7 @@ export const chatCompletionRequestSchema = z.object({
tools: z.array(functionToolSchema).min(1).max(128).optional(),
tool_choice: toolChoiceSchema.optional(),
parallel_tool_calls: z.boolean().optional(),
store: z.boolean().optional().default(true),
}).strict().superRefine((request, context) => {
const toolNames = new Set<string>();
for (const [index, tool] of (request.tools ?? []).entries()) {
@@ -153,6 +154,7 @@ const ignoredCompatibilityParameters = [
"top_p",
"frequency_penalty",
"presence_penalty",
"store",
] as const;
export function ignoredChatCompatibilityParameters(request: ChatCompletionRequest): string[] {
+38 -1
View File
@@ -250,7 +250,44 @@ function normalizeArguments(
}
function createValidator(schema: Record<string, unknown>): ValidateFunction {
return new Ajv({ allErrors: true, strict: false }).compile(schema);
return new Ajv({ allErrors: true, strict: false }).compile(sanitizeToolParametersSchema(schema));
}
function sanitizeToolParametersSchema(schema: Record<string, unknown>): Record<string, unknown> {
const clone = structuredClone(schema);
stripJsonSchemaMeta(clone);
return clone;
}
function stripJsonSchemaMeta(value: unknown): void {
if (!isRecord(value)) return;
delete value.$schema;
delete value.$id;
delete value.$vocabulary;
for (const [key, child] of Object.entries(value)) {
if (key === "properties" && isRecord(child)) {
for (const property of Object.values(child)) stripJsonSchemaMeta(property);
continue;
}
if (key === "items") {
if (Array.isArray(child)) for (const item of child) stripJsonSchemaMeta(item);
else stripJsonSchemaMeta(child);
continue;
}
if ((key === "$defs" || key === "definitions") && isRecord(child)) {
for (const definition of Object.values(child)) stripJsonSchemaMeta(definition);
continue;
}
if ((key === "anyOf" || key === "oneOf" || key === "allOf" || key === "prefixItems") && Array.isArray(child)) {
for (const item of child) stripJsonSchemaMeta(item);
continue;
}
if (key === "not" || key === "if" || key === "then" || key === "else" || key === "contains") {
stripJsonSchemaMeta(child);
}
}
}
function effectiveToolChoice(request: FunctionToolDecisionRequest): FunctionToolChoice {