diff --git a/README.md b/README.md index 9045875..fb883b0 100644 --- a/README.md +++ b/README.md @@ -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 会把实时思考事件作为 `` 块发送,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 key(Bridge 忽略 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-04;Responses 文本子集检查为 2026-08-05;Chat function calling 协议检查为 2026-08-06;Responses 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-04;Responses 文本子集检查为 2026-08-05;Chat function calling 协议检查为 2026-08-06;Responses function calling 检查为 2026-08-12;OpenCode 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。 ## 已知限制 diff --git a/src/openai.ts b/src/openai.ts index 031a565..81ac089 100644 --- a/src/openai.ts +++ b/src/openai.ts @@ -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(); 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[] { diff --git a/src/tool-calling.ts b/src/tool-calling.ts index cbbb4cb..3023e86 100644 --- a/src/tool-calling.ts +++ b/src/tool-calling.ts @@ -250,7 +250,44 @@ function normalizeArguments( } function createValidator(schema: Record): ValidateFunction { - return new Ajv({ allErrors: true, strict: false }).compile(schema); + return new Ajv({ allErrors: true, strict: false }).compile(sanitizeToolParametersSchema(schema)); +} + +function sanitizeToolParametersSchema(schema: Record): Record { + 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 {