适配图片

This commit is contained in:
Sirius
2026-08-12 16:23:52 +08:00
parent f65ac095bb
commit 995392e526
12 changed files with 821 additions and 116 deletions
+17 -71
View File
@@ -1,6 +1,6 @@
# Command Code OpenAI Bridge # Command Code OpenAI Bridge
Command Code OpenAI Bridge 是一个仅在本机运行的 OpenAI 兼容服务。它把 Chat Completions 文本对话与 function calling 决策转交给 Command Code CLI 执行,并提供 Responses API 文本子集 Command Code OpenAI Bridge 是一个仅在本机运行的 OpenAI 兼容服务。它把 Chat Completions/Responses 文本、受限图片与纯文本文件输入及 function calling 决策转交给 Command Code CLI 执行。
默认 API 地址:`http://127.0.0.1:18000/v1` 默认 API 地址:`http://127.0.0.1:18000/v1`
@@ -9,7 +9,7 @@ Command Code OpenAI Bridge 是一个仅在本机运行的 OpenAI 兼容服务。
每个请求都会启动一个独立的 Command Code headless agent 每个请求都会启动一个独立的 Command Code headless agent
1. 接收并校验 OpenAI Chat Completions 请求。 1. 接收并校验 OpenAI Chat Completions 请求。
2. 保留消息的角色、顺序和完整文本,将全部会话历史序列化后通过 stdin 传给 Command Code。 2. 保留消息的角色、顺序和完整文本,将会话历史通过 stdin 传给 Command Code;附件经校验后写入请求专用临时文件并以 `@path` 交给真实 `read_file`
3. 在启动服务的终端中显示模型状态、turn、工具调用、工具结果、stderr、最终回答和耗时。 3. 在启动服务的终端中显示模型状态、turn、工具调用、工具结果、stderr、最终回答和耗时。
4. 从 Command Code 的 NDJSON 结构化结果中提取 `finalText` 4. 从 Command Code 的 NDJSON 结构化结果中提取 `finalText`
5. 把最终回复、外部 function tool calls 和可用的 token usage 返回给 API 客户端。 5. 把最终回复、外部 function tool calls 和可用的 token usage 返回给 API 客户端。
@@ -20,6 +20,7 @@ Command Code OpenAI Bridge 是一个仅在本机运行的 OpenAI 兼容服务。
- 提供 `GET /health``GET /v1/models``POST /v1/chat/completions` - 提供 `GET /health``GET /v1/models``POST /v1/chat/completions`
- 支持字符串形式的 `content` 和 OpenAI 文本 part 数组。 - 支持字符串形式的 `content` 和 OpenAI 文本 part 数组。
- Chat 支持图片 data URLResponses 还支持 UTF-8 `input_file.file_data`。远程 URL、本地路径、PDF 和其他二进制拒绝。
- Chat Completions 支持标准 function tools、工具选择、assistant tool calls 和 tool 结果消息;工具由客户端执行。 - Chat Completions 支持标准 function tools、工具选择、assistant tool calls 和 tool 结果消息;工具由客户端执行。
- 接受 `stream: true`;普通 Chat 实时转发文本 deltafunction calling 在完整决策校验后发送工具调用 SSE。 - 接受 `stream: true`;普通 Chat 实时转发文本 deltafunction calling 在完整决策校验后发送工具调用 SSE。
- 支持中文、Unicode、Markdown、代码块和较长文本。 - 支持中文、Unicode、Markdown、代码块和较长文本。
@@ -30,7 +31,7 @@ Command Code OpenAI Bridge 是一个仅在本机运行的 OpenAI 兼容服务。
- 单次 Command Code 失败后,HTTP 服务可继续处理后续请求。 - 单次 Command Code 失败后,HTTP 服务可继续处理后续请求。
- Authorization 请求头会被忽略,服务强制绑定 `127.0.0.1` - Authorization 请求头会被忽略,服务强制绑定 `127.0.0.1`
当前只接受文本消息和文本输入。普通 Chat SSE 实时转发 Command Code 文本 deltafunction calling 会等待完整决策通过 JSON 与参数 Schema 校验后发送 `tool_calls`Responses 只实现文本子集。图片、音频、文件输入、Computer Use、托管工具和服务端 Chat 会话均未实现。 普通 Chat SSE 实时转发 Command Code 文本 deltafunction calling 会等待完整决策通过 JSON 与参数 Schema 校验后发送 `tool_calls`图片只接受 PNG/JPEG/GIF/WebP base64 data URL,并要求模型映射显式声明 `supports_image_input: true`。文件只接受 Responses UTF-8 `file_data`。音频、视频、PDF/Office、任意二进制、Computer Use、托管工具和服务端 Chat 会话均未实现。
## 技术实现 ## 技术实现
@@ -50,6 +51,7 @@ src/
├── index.ts # 程序入口、服务启动和信号处理 ├── index.ts # 程序入口、服务启动和信号处理
├── server.ts # HTTP 路由、共享并发协调和错误处理 ├── server.ts # HTTP 路由、共享并发协调和错误处理
├── openai.ts # 请求校验、消息转换和响应生成 ├── openai.ts # 请求校验、消息转换和响应生成
├── attachments.ts # data URL/base64 校验、临时文件和清理
├── command-code.ts # CLI 检查、子进程管理和最终结果提取 ├── command-code.ts # CLI 检查、子进程管理和最终结果提取
├── tool-calling.ts # 外部 function calling 决策解析与参数校验 ├── tool-calling.ts # 外部 function calling 决策解析与参数校验
├── renderer.ts # Command Code 事件的终端显示 ├── renderer.ts # Command Code 事件的终端显示
@@ -61,7 +63,8 @@ scripts/
examples/ examples/
├── python_client.py # OpenAI Python SDK 示例 ├── python_client.py # OpenAI Python SDK 示例
── node_client.mjs # OpenAI Node.js SDK 示例 ── node_client.mjs # OpenAI Node.js SDK 示例
└── opencode.json # OpenCode provider 与模型模态示例
``` ```
## 快速开始 ## 快速开始
@@ -92,56 +95,25 @@ curl http://127.0.0.1:18000/v1/chat/completions \
<!-- gitnexus:start --> <!-- gitnexus:start -->
# GitNexus — Code Intelligence # GitNexus — Code Intelligence
This project is indexed by GitNexus as **command-code-openai-bridge** (330 symbols, 941 relationships, 28 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. This project is indexed by GitNexus as **command-code-openai-bridge** (461 symbols, 1291 relationships, 29 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. > Index stale? Run `node .gitnexus/run.cjs analyze` from the project root — it auto-selects an available runner. No `.gitnexus/run.cjs` yet? `npx gitnexus analyze` (npm 11 crash → `npm i -g gitnexus`; #1939).
## Always Do ## Always Do
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user. - **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows. - **MUST run `detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows. For regression review, compare against the default branch: `detect_changes({scope: "compare", base_ref: "main"})`.
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits. - **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance. - When exploring unfamiliar code, use `query({search_query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`. - When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `context({name: "symbolName"})`.
- For security review, `explain({target: "fileOrSymbol"})` lists taint findings (source→sink flows; needs `analyze --pdg`).
## When Debugging
1. `gitnexus_query({query: "<error or symptom>"})` — find execution flows related to the issue
2. `gitnexus_context({name: "<suspect function>"})` — see all callers, callees, and process participation
3. `READ gitnexus://repo/command-code-openai-bridge/process/{processName}` — trace the full execution flow step by step
4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed
## When Refactoring
- **Renaming**: MUST use `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` first. Review the preview — graph edits are safe, text_search edits need manual review. Then run with `dry_run: false`.
- **Extracting/Splitting**: MUST run `gitnexus_context({name: "target"})` to see all incoming/outgoing refs, then `gitnexus_impact({target: "target", direction: "upstream"})` to find all external callers before moving code.
- After any refactor: run `gitnexus_detect_changes({scope: "all"})` to verify only expected files changed.
## Never Do ## Never Do
- NEVER edit a function, class, or method without first running `gitnexus_impact` on it. - NEVER edit a function, class, or method without first running `impact` on it.
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis. - NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph. - NEVER rename symbols with find-and-replace — use `rename` which understands the call graph.
- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope. - NEVER commit changes without running `detect_changes()` to check affected scope.
## Tools Quick Reference
| Tool | When to use | Command |
|------|-------------|---------|
| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` |
| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` |
| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` |
| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` |
| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` |
| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` |
## Impact Risk Levels
| Depth | Meaning | Action |
|-------|---------|--------|
| d=1 | WILL BREAK — direct callers/importers | MUST update these |
| d=2 | LIKELY AFFECTED — indirect deps | Should test |
| d=3 | MAY NEED TESTING — transitive | Test if critical path |
## Resources ## Resources
@@ -152,32 +124,6 @@ This project is indexed by GitNexus as **command-code-openai-bridge** (330 symbo
| `gitnexus://repo/command-code-openai-bridge/processes` | All execution flows | | `gitnexus://repo/command-code-openai-bridge/processes` | All execution flows |
| `gitnexus://repo/command-code-openai-bridge/process/{name}` | Step-by-step execution trace | | `gitnexus://repo/command-code-openai-bridge/process/{name}` | Step-by-step execution trace |
## Self-Check Before Finishing
Before completing any code modification task, verify:
1. `gitnexus_impact` was run for all modified symbols
2. No HIGH/CRITICAL risk warnings were ignored
3. `gitnexus_detect_changes()` confirms changes match expected scope
4. All d=1 (WILL BREAK) dependents were updated
## Keeping the Index Fresh
After committing code changes, the GitNexus index becomes stale. Re-run analyze to update it:
```bash
npx gitnexus analyze
```
If the index previously included embeddings, preserve them by adding `--embeddings`:
```bash
npx gitnexus analyze --embeddings
```
To check whether embeddings exist, inspect `.gitnexus/meta.json` — the `stats.embeddings` field shows the count (0 means no embeddings). **Running analyze without `--embeddings` will delete any previously generated embeddings.**
> Claude Code users: A PostToolUse hook handles this automatically after `git commit` and `git merge`.
## CLI ## CLI
| Task | Read this skill file | | Task | Read this skill file |
+55 -25
View File
@@ -1,40 +1,42 @@
# Command Code OpenAI Bridge # Command Code OpenAI Bridge
这是一个只绑定本机地址的 OpenAI 兼容转发服务。它实现 Chat Completions 与 Responses API 的文本对话和外部 function calling。每个生成请求启动一次独立的 Command Code headless agent,将完整消息历史通过 stdin 发送给 CLI,在当前终端显示运行过程,再把结构化事件和最终 `finalText` 转换成 OpenAI 风格的 JSON 或 SSE。 这是一个只绑定本机地址的 OpenAI 兼容转发服务。它实现 Chat Completions 与 Responses API 的文本对话、受限图片/纯文本文件输入和外部 function calling。每个生成请求启动一次独立的 Command Code headless agent,将完整消息历史通过 stdin 发送给 CLI,把附件安全落为请求专用临时文件并通过真实 `read_file` 路径交给 Command Code,再把结构化事件和最终 `finalText` 转换成 OpenAI 风格的 JSON 或 SSE。
默认地址:`http://127.0.0.1:18000/v1` 默认地址:`http://127.0.0.1:18000/v1`
## 当前机器上的调查结论 ## 当前机器上的调查结论
- 系统:macOS 26.4.1 arm64。
- Node.jsv22.22.2,符合 Command Code 的 Node.js 22+ 要求。 - Node.jsv22.22.2,符合 Command Code 的 Node.js 22+ 要求。
- 安装方式:`npm install --global command-code@latest` - 安装 Command Code:v1.18.0;可执行文件是全局 npm 包的 `dist/index.mjs`
- 已安装 Command Codev1.10.0npm `latest` 也是 v1.10.0 - npm 包没有公开、稳定的可嵌入输入 API。官方 headless 调用仍是 `command-code -p --output-format json`
- 可执行文件:`command-code`macOS/Linux 短名为 `cmd`Windows 短名为 `cmdc` - `--help` 没有 `--image``--attachment`、文件 URL、二进制 stdin 或 JSON stdin envelope。`-p` 的参数或 piped stdin 都只是查询文本
- 登录:`command-code login`;检查:`command-code status --json`;本机已登录 - 官方文档同时明确支持在提示中用 `@path/to/file` 引用工作区文件;`read_file` 会把图片真正交给原生视觉模型或 VISION 工具。普通二进制只返回 MIME 提示,PDF 当前只提示使用 `pdftotext`,并不是 PDF 内容输入
- npm 包没有 `exports` 字段,`main` 指向会直接启动 CLI 的 `dist/cli.mjs`,包尾直接解析命令行,没有公开、稳定的可嵌入 API - 真实 headless 检查使用一张四象限 PNG。`moonshotai/kimi-k2.7-code``gpt-5.6-luna` 都在 NDJSON 中实际调用 `read_file`,并正确返回 `Red, Green, Blue, Yellow`。这证明本地图片路径在 `-p --output-format json` 下会被底层模型理解,不只是把文件名写进 prompt
- 官方 headless 调用:`command-code -p --output-format json`。不传查询参数时会从 stdin 读取 - 因此 Bridge 只把经过 MIME、魔数、大小和 UTF-8 校验的数据落为工作区内临时文件,再传入 `@` 路径。它不把 base64 塞进 prompt,也不做 OCR/PDF 摘要降级
- 远程 HTTP(S)、`file://`、任意本地路径和 `file_id` 均不接受。Bridge 完全不为附件发起网络请求,从策略上消除 SSRF、重定向和 URL token 泄漏。
- JSON 输出是 NDJSON:运行中输出 `AgentEvent`,正常情况下最后输出唯一的 `result` 行;最终回答位于 `finalText`usage 和耗时也在该行。 - JSON 输出是 NDJSON:运行中输出 `AgentEvent`,正常情况下最后输出唯一的 `result` 行;最终回答位于 `finalText`usage 和耗时也在该行。
- `--no-session` 让每次请求只使用内存会话,不写入或恢复跨请求 session。 - `--no-session` 让每次请求只使用内存会话,不写入或恢复跨请求 Command Code session。
- 官方 headless 模式明确不提供键盘、问题回答或权限批准等交互。原始 TTY/TUI 与可靠的独立 `finalText` 目前不能同时获得。 - 官方 headless 模式明确不提供键盘、问题回答或权限批准等交互。原始 TTY/TUI 与可靠的独立 `finalText` 目前不能同时获得。
- 官方退出码:0 成功;1 常规错误;3 未登录;4 权限拒绝;5 限流;6 网络错误;7 服务端错误;8 达到 turn 上限;9 无回复;10 余额不足;130 被信号中断。
官方资料: 官方资料:
- [Quickstart](https://commandcode.ai/docs/quickstart) - [Quickstart](https://commandcode.ai/docs/quickstart)
- [CLI Reference](https://commandcode.ai/docs/reference/cli) - [CLI Reference](https://commandcode.ai/docs/reference/cli)
- [Headless Mode](https://commandcode.ai/docs/headless) - [Headless Mode](https://commandcode.ai/docs/headless)
- [Common Workflows:文件和图片路径](https://commandcode.ai/docs/workflows)
- [Vision](https://commandcode.ai/docs/vision)
- [The Read Tool](https://commandcode.ai/docs/harness-engineering/read-tool)
- [Permissions](https://commandcode.ai/docs/core-concepts/permissions) - [Permissions](https://commandcode.ai/docs/core-concepts/permissions)
## 为什么使用普通子进程与结构化事件 ## 为什么使用普通子进程与结构化事件
本项目使用官方 headless 子进程和 NDJSON,不使用 PTY,也不解析 TUI 文本。 本项目使用官方 headless 子进程和 NDJSON,不使用 PTY,也不解析 TUI 文本。
原因是 v1.10.0 没有公开嵌入 API,交互式 TUI 没有独立的结构化最终结果通道。headless 的最后一行提供稳定 `finalText`,能保证工具参数、工具结果、状态、stderr、思考事件和 ANSI 内容不会混入 API 回复。 原因是 v1.18.0 没有公开嵌入 API,交互式 TUI 没有独立的结构化最终结果通道。headless 的最后一行提供稳定 `finalText`,能保证工具参数、工具结果、状态、stderr、思考事件和 ANSI 内容不会混入 API 回复。
终端会显示:请求开始、模型、turn 状态、工具事件、工具参数、工具结果、stderr、最终回答和耗时。每个 turn 结束时还会显示本轮和服务启动以来的累计 input/output/total token;服务退出时再输出一次累计,重启后从零开始。终端不会显示原始 Ink TUI、动画、键盘快捷键、人工权限批准、人工问题回答和详细内部思考文本。 终端会显示:请求开始、模型、turn 状态、工具事件、工具参数、工具结果、stderr、最终回答和耗时。每个 turn 结束时还会显示本轮和服务启动以来的累计 input/output/total token;服务退出时再输出一次累计,重启后从零开始。终端不会显示原始 Ink TUI、动画、键盘快捷键、人工权限批准、人工问题回答和详细内部思考文本。
本机 v1.10.0 实测表明,headless 即使设为 `auto-accept`shell 仍会被拒绝。默认 `dangerously_skip_permissions: true`,因此实际传入 `--yolo`,让文件写入和命令工具能继续执行。它会绕过所有权限确认,只应对可信工作目录使用。若要只读,把 `dangerously_skip_permissions` 改为 `false`,同时把 `permission_mode` 改成 `plan` 默认 `dangerously_skip_permissions: true`,因此实际传入 `--yolo`,让文件写入和命令工具能继续执行。它会绕过所有权限确认,只应对可信工作目录使用。若要只读,把 `dangerously_skip_permissions` 改为 `false`,同时把 `permission_mode` 改成 `plan``read_file` 在只读模式仍可读取 Bridge 创建的附件
## 项目结构 ## 项目结构
@@ -104,9 +106,16 @@ models:
command-default: command-default:
cli_model: deepseek/deepseek-v4-flash cli_model: deepseek/deepseek-v4-flash
effort: max effort: max
supports_image_input: false
command-vision:
cli_model: gpt-5.6-luna
effort: max
supports_image_input: true
``` ```
`command_code_working_directory` 决定 Command Code 能看到和操作的项目目录。`response_store_directory` 保存 `store: true` 的 Response;两个相对路径都以配置文件所在目录为基准。默认存储目录是隐藏目录 `.command-code-openai-bridge/responses``max_concurrent_requests` 是同时运行的 Command Code 请求上限,范围 `1..64`,默认 `1``max_queue_size` 是等待执行的生成请求上限,不包含正在运行的请求;设为 `0` 可恢复并发槽满时立即返回 429 的行为。`stream_thinking` 控制 Chat Completions 流是否把 Command Code 思考事件包装成 `<think>` 块;默认关闭。`models.<name>.effort` 会传给 Command Code 的 `--effort`Responses 请求中的 `reasoning.effort` 优先。可用 effort 由对应底层模型决定。服务强制只监听 `127.0.0.1`。客户端只能选择配置中的模型名,不能注入额外 CLI 参数。 `command_code_working_directory` 决定 Command Code 能看到和操作的项目目录。`response_store_directory` 保存 `store: true` 的 Response;两个相对路径都以配置文件所在目录为基准。默认存储目录是隐藏目录 `.command-code-openai-bridge/responses``max_concurrent_requests` 是同时运行的 Command Code 请求上限,范围 `1..64`,默认 `1``max_queue_size` 是等待执行的生成请求上限,不包含正在运行的请求;设为 `0` 可恢复并发槽满时立即返回 429 的行为。`stream_thinking` 控制 Chat Completions 流是否把 Command Code 思考事件包装成 `<think>` 块;默认关闭。`models.<name>.effort` 会传给 Command Code 的 `--effort`Responses 请求中的 `reasoning.effort` 优先。可用 effort 由对应底层模型决定。
`models.<name>.supports_image_input` 默认 `false`。只有对该实际 CLI 模型完成图片路径检查后才能设为 `true`;图片请求选中未声明的模型时返回 400,不会假装模型看到了图片。`GET /v1/models` 同时返回每个映射的 `capabilities.input`。服务强制只监听 `127.0.0.1`,客户端只能选择配置中的模型名,不能注入额外 CLI 参数。
### Obsidian Copilot 流式输出 ### Obsidian Copilot 流式输出
@@ -139,7 +148,9 @@ OpenCode 1.18.15 实际发送的 Chat 请求包含 `stream: true`、`stream_opti
OpenCode 还会在会话标题生成、子 agent 和后台任务等场景发起独立请求。这些请求与主 agent 请求共用同一 provider;将 `max_concurrent_requests` 设为大于 `1` 可让它们与多个会话并行执行。 OpenCode 还会在会话标题生成、子 agent 和后台任务等场景发起独立请求。这些请求与主 agent 请求共用同一 provider;将 `max_concurrent_requests` 设为大于 `1` 可让它们与多个会话并行执行。
已知限制:Bridge 不实现 OpenCode hosted tools、图片/音频输入、Computer Use 或 Responses `/v1/responses` 路径;OpenCode 自定义 provider 走 `/v1/chat/completions``temperature``max_tokens` 等采样参数不会传给 Command Code CLI 示例把 `command-default` 明确声明为仅文本,把已经通过真实 CLI 检查且在 `config.yaml` 中启用的 `command-luna` 声明为 `modalities.input: ["text", "image"]`。OpenCode 只有看到该字段才会把图片转换成 Chat `image_url`;不要给 `supports_image_input: false` 的映射添加 `image`。OpenCode 会把用户图片规范化为 data URL,Bridge 不接受远程图片 URL
已知限制:Bridge 不实现 OpenCode hosted tools、音频、视频、PDF attachment、Computer Use 或托管 File Search/Code Interpreter/Hosted Shell。OpenCode 自定义 provider 走 `/v1/chat/completions`Responses 路径由 OpenAI SDK 等客户端使用。`temperature``max_tokens` 等采样参数不会传给 Command Code CLI。
## 启动、停止与重启 ## 启动、停止与重启
@@ -172,11 +183,28 @@ npm run build
`GET /health` 额外返回 `active_requests``max_concurrent_requests``queue_length``queue_capacity``busy` 作为兼容字段保留,等价于 `active_requests > 0` `GET /health` 额外返回 `active_requests``max_concurrent_requests``queue_length``queue_capacity``busy` 作为兼容字段保留,等价于 `active_requests > 0`
Bearer Token 会被忽略。所有生成接口只接受文本内容。图片、音频、文件输入和其他未实现字段会返回 OpenAI 格式的 400,`code``unsupported_parameter`服务不会静默忽略会改变行为的参数。 Bearer Token 会被忽略。服务不会静默忽略会改变行为的参数。
### 输入能力表
| 输入 | Chat Completions | Responses | 实际传给 Command Code |
| --- | --- | --- | --- |
| 文本 | `content` 字符串或 `text` part | 字符串、`input_text``output_text` | UTF-8 stdin prompt |
| 图片 | `image_url.url` 的 base64 data URL | `input_image.image_url` 的 base64 data URL | 校验后写入临时 PNG/JPEG/GIF/WebP,传 `@path`,由 `read_file`/视觉模型读取 |
| 纯文本文件 | 不支持 API 附件;OpenCode `read` 工具结果仍是普通文本消息 | `input_file.file_data`,必须带 filename | 校验 UTF-8 后写入临时文本文件,传 `@path`,由 `read_file` 读取 |
| HTTP(S)、`file://`、本地路径 | 拒绝 | 拒绝 | 不联网、不读取客户端指定路径 |
| PDF、Office、任意二进制 | 拒绝 | 拒绝 | 不做伪 OCR、伪摘要或二进制转文本 |
| 音频、视频、Computer Use、托管工具 | 拒绝 | 拒绝 | 未实现 |
附件最多 16 个;单张图片解码后最多 10 MiB,单个 UTF-8 文本文件最多 5 MiB,合计最多 15 MiB,并继续受 `max_request_bytes` 限制。图片 MIME 白名单为 `image/png``image/jpeg``image/gif``image/webp`,声明 MIME 必须与魔数一致。临时文件权限为 `0600`,位于 `.command-code-openai-bridge/inputs` 的请求专用目录,并在成功、错误、取消或超时后从 `finally` 清理。
附件原始 data URL、base64 和 `read_file` 返回的原始文件内容不会进入 Command Code prompt、桥接终端日志或 Responses 本地存储。终端 renderer 会把附件 `read_file` 的完整 tool result 替换为脱敏标记,并递归遮蔽其他事件中的 data URL、base64、凭据和 URL 查询参数;NDJSON 解析错误也不回显原始行。模型按用户要求生成的最终回答仍会照常显示。`store: true` 只保存文本/工具历史;附件 part 被省略,因此后续 `previous_response_id` 不会伪装成仍能读取旧附件。需要再次使用时必须重新提交。
### Chat Completions ### Chat Completions
支持 `system``developer``user``assistant``tool` 消息。文本 `content` 可以是字符串,也可以是由 `{ "type": "text", "text": "..." }` 组成的数组;assistant 工具轮次支持 `content: null``tool_calls`tool 消息支持 `tool_call_id` 和可选 `name` 支持 `system``developer``user``assistant``tool` 消息。文本 `content` 可以是字符串,也可以是由 `{ "type": "text", "text": "..." }` 组成的数组;user 消息还可包含标准 `{ "type": "image_url", "image_url": { "url": "data:image/png;base64,..." } }`assistant 工具轮次支持 `content: null``tool_calls`tool 消息支持 `tool_call_id` 和可选 `name`
`image_url` 只接受 base64 data URL;远程 URL、`file://` 和本地路径返回 `400 unsupported_parameter``param` 精确指向 `messages.<i>.content.<j>.image_url.url``detail` 只能省略或为 `auto`,因为 CLI 没有 low/high 映射。所选模型必须配置 `supports_image_input: true`
Chat 接口接受标准 function tools、`tool_choice``parallel_tool_calls`。Bridge 把工具定义、完整消息历史和工具结果交给 Command Code 决定下一步,校验返回的工具名与 arguments JSON Schema;第一次不合格时在原请求总截止时间内执行一次修复。工具由 API 客户端执行,Bridge 不执行客户端工具,也不直接访问 Obsidian vault。该流程适用于 Obsidian Copilot 的 `localSearch``readNote``getFileTree``writeFile``editFile`,也适用于其他标准 function tools。协议流程参考 [OpenAI Function calling](https://developers.openai.com/api/docs/guides/function-calling)。 Chat 接口接受标准 function tools、`tool_choice``parallel_tool_calls`。Bridge 把工具定义、完整消息历史和工具结果交给 Command Code 决定下一步,校验返回的工具名与 arguments JSON Schema;第一次不合格时在原请求总截止时间内执行一次修复。工具由 API 客户端执行,Bridge 不执行客户端工具,也不直接访问 Obsidian vault。该流程适用于 Obsidian Copilot 的 `localSearch``readNote``getFileTree``writeFile``editFile`,也适用于其他标准 function tools。协议流程参考 [OpenAI Function calling](https://developers.openai.com/api/docs/guides/function-calling)。
@@ -203,7 +231,9 @@ Chat Completions 和 Responses 共用 Command Code NDJSON 执行层。服务终
- `tool_choice` - `tool_choice`
- `parallel_tool_calls` - `parallel_tool_calls`
`input` 可以是字符串,也可以是 item 数组。message 支持 `system``developer``user``assistant` 角色、字符串 content,以及 `input_text``output_text` part。工具轮次还支持 `function_call``function_call_output` item,分别携带 `call_id`、工具名、`arguments` 和工具执行结果 `output` `input` 可以是字符串,也可以是 item 数组。message 支持 `system``developer``user``assistant` 角色、字符串 content,以及 `input_text``output_text``input_image``input_file` part。工具轮次还支持 `function_call``function_call_output` item,分别携带 `call_id`、工具名、`arguments` 和工具执行结果 `output`
`input_image.image_url` 与 Chat 一样只接受图片 data URL`file_id`、远程 URL、`file://`、本地路径和 low/high detail 不支持。`input_file` 只接受 `file_data``filename`,其中 `file_data` 可以是严格 base64 或带受支持文本 MIME 的 base64 data URL;解码结果必须是无 NUL 的 UTF-8 文本。`file_url``file_id`、PDF、Office 和其他二进制会返回带准确字段路径的 400。
Responses 接受标准 function tools、`tool_choice``parallel_tool_calls`。Bridge 把工具定义、完整历史(含先前 response 的完整 output items)和工具结果交给 Command Code 决定下一步,校验返回的工具名与 arguments JSON Schema;第一次不合格时在原请求总截止时间内执行一次修复。工具由 API 客户端执行,Bridge 不执行客户端工具,也不会让 Command Code 用自身文件、终端、网络等工具替代外部 tools。该流程适用于 OpenCode 等使用 `@ai-sdk/openai` 或 OpenAI Node.js SDK 的 Responses 客户端。 Responses 接受标准 function tools、`tool_choice``parallel_tool_calls`。Bridge 把工具定义、完整历史(含先前 response 的完整 output items)和工具结果交给 Command Code 决定下一步,校验返回的工具名与 arguments JSON Schema;第一次不合格时在原请求总截止时间内执行一次修复。工具由 API 客户端执行,Bridge 不执行客户端工具,也不会让 Command Code 用自身文件、终端、网络等工具替代外部 tools。该流程适用于 OpenCode 等使用 `@ai-sdk/openai` 或 OpenAI Node.js SDK 的 Responses 客户端。
@@ -348,27 +378,27 @@ command-code \
--yolo --yolo
``` ```
随后通过子进程 stdin 写入 UTF-8 的完整请求历史。消息序列被放进一个 JSON 对象,角色、顺序、空消息、Unicode、Markdown、代码块和自定义分隔符均不会被简单文本分隔符破坏。Bridge 不总结、不删除、不截断消息。Chat Completions 不保存会话;Responses 只在 `store: true` 时保存协议对象,并在新请求中把响应链重新序列化给一个新的 `--no-session` 进程。 随后通过子进程 stdin 写入 UTF-8 的完整文本历史和不含原始数据的附件路径区块。消息序列被放进一个 JSON 对象,角色、顺序、空消息、Unicode、Markdown、代码块和自定义分隔符均不会被简单文本分隔符破坏。附件 data URL/base64 不进入该对象。Chat Completions 不保存会话;Responses 只在 `store: true` 时保存去除附件数据后的协议对象,并在新请求中把响应链重新序列化给一个新的 `--no-session` 进程。
## 有限队列和错误 ## 有限队列和错误
同一时间最多运行 `max_concurrent_requests` 个 Command Code 实例,默认值 `1` 保持原来的单并发行为。其余 Chat Completions 和 Responses 生成请求共享同一条 FIFO 等待队列;任一执行槽释放后都会按队首顺序补齐空闲槽。默认最多等待 8 个请求,队列已满时返回 HTTP 429 和 `code: queue_full`。流式请求进入队列后会立即建立 SSE 连接;非流式请求保持等待。客户端在排队期间断开会立即移出队列,活动请求取消并结束后会推进队首请求。`timeout_seconds` 从请求取得执行位置后开始计算。 同一时间最多运行 `max_concurrent_requests` 个 Command Code 实例,默认值 `1` 保持原来的单并发行为。其余 Chat Completions 和 Responses 生成请求共享同一条 FIFO 等待队列;任一执行槽释放后都会按队首顺序补齐空闲槽。默认最多等待 8 个请求,队列已满时返回 HTTP 429 和 `code: queue_full`。流式请求进入队列后会立即建立 SSE 连接;非流式请求保持等待。客户端在排队期间断开会立即移出队列,活动请求取消并结束后会推进队首请求。`timeout_seconds` 从请求取得执行位置后开始计算。
请求体超过 `max_request_bytes` 返回 413。未知模型和非文本内容返回 4xx。CLI 的详细错误留在服务终端,客户端只收到简洁的 OpenAI 格式错误。 请求体超过 `max_request_bytes` 返回 413。未知模型、未声明图片能力、无效 base64、MIME/魔数不一致、超限附件和不支持的输入来源返回 4xx,并带准确 `param`。CLI 的详细错误留在服务终端,客户端只收到简洁的 OpenAI 格式错误。
一次 CLI 异常不会结束 HTTP 服务,后续请求仍可继续。 一次 CLI 异常不会结束 HTTP 服务,后续请求仍可继续。
## 本机实际检查结果 ## 本机实际检查结果
检查日期:Chat Completions 原有检查为 2026-08-04Responses 文本子集检查为 2026-08-05Chat function calling 协议检查为 2026-08-06Responses function calling、OpenCode 1.18.15 Chat Completions E2E可配置并发检查为 2026-08-12。没有编写测试用例。原有条目来自真实 CLI 和真实 HTTP/SDK 客户端;可配置并发改动完成类型检查、生产构建和真实 Command Code 多进程 HTTP 冒烟检查。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` 兼容,以及客户端断开后的取消恢复检查。 检查日期:Chat Completions 原有检查为 2026-08-04Responses 文本子集检查为 2026-08-05Chat function calling 协议检查为 2026-08-06Responses function calling、OpenCode 1.18.15 Chat Completions E2E可配置并发和 Command Code 1.18.0 非文本输入检查为 2026-08-12。没有编写测试用例。非文本输入先由真实 CLI 图片路径检查确认,再完成类型检查、生产构建和 Bridge HTTP 冒烟检查。
- TypeScript 严格类型检查和生产构建通过。 - TypeScript 严格类型检查和生产构建通过。
- npm 生产依赖审计:0 个已知漏洞。 - npm 生产依赖审计:0 个已知漏洞。
- `/health` 返回 Command Code v1.10.0、已登录、空闲。 - `/health` 返回 Command Code v1.18.0、已登录、空闲。
- `max_concurrent_requests: 2` 时实际观察到 2 个 Command Code CLI 子进程并行运行,后续请求保持 FIFO;取消排队请求会移出队列,取消活动请求后队首请求会被推进,最终健康状态恢复为 `active_requests: 0``queue_length: 0` - `max_concurrent_requests: 2` 时实际观察到 2 个 Command Code CLI 子进程并行运行,后续请求保持 FIFO;取消排队请求会移出队列,取消活动请求后队首请求会被推进,最终健康状态恢复为 `active_requests: 0``queue_length: 0`
- `/v1/models` 返回两个本地映射。 - `/v1/models` 返回两个本地映射。
- curl 中文请求返回 HTTP 200,客户端只收到 `中文接口成功` 和标准 completion 字段。 - curl 中文请求返回 HTTP 200,客户端只收到 `中文接口成功` 和标准 completion 字段。
- 字符串 content 与 text part 数组均通过;非文本 part 返回 HTTP 400。 - 字符串 content 与 text part 数组均通过;不支持的非文本 part 返回 HTTP 400。
- system、user、assistant、user 完整历史检查返回了历史 assistant 中的 `蓝鲸-42` - system、user、assistant、user 完整历史检查返回了历史 assistant 中的 `蓝鲸-42`
- 约 96 KiB 的中文消息通过 stdin 完整传入并返回 `长文本回退成功`,没有经过命令行参数。 - 约 96 KiB 的中文消息通过 stdin 完整传入并返回 `长文本回退成功`,没有经过命令行参数。
- read_file 工具调用、参数和文件结果显示在服务终端,API 只返回 package name。 - read_file 工具调用、参数和文件结果显示在服务终端,API 只返回 package name。
@@ -391,7 +421,7 @@ command-code \
- OpenAI Node.js SDK 成功消费 Responses SSE;事件顺序、递增 sequence number、多个 text delta 和最终文本均正确。 - OpenAI Node.js SDK 成功消费 Responses SSE;事件顺序、递增 sequence number、多个 text delta 和最终文本均正确。
- 强制 `read_file` 的两 turn 请求只向 SSE 输出第二个无工具 turn 的 6 个文本 delta,中间工具轮次没有污染 `output_text` - 强制 `read_file` 的两 turn 请求只向 SSE 输出第二个无工具 turn 的 6 个文本 delta,中间工具轮次没有污染 `output_text`
- `store: false` 的流式 Response 随后查询返回 404;删除已保存 Response 返回 deleted,随后查询返回 404。 - `store: false` 的流式 Response 随后查询返回 404;删除已保存 Response 返回 deleted,随后查询返回 404。
- `input_image` 返回 400 `unsupported_parameter`,并包含准确的参数路径 - 真实 `-p --output-format json` 图片路径检查中,Kimi K2.7 Code 和 GPT-5.6 Luna 都调用 `read_file` 并正确识别四象限颜色
- 真实 `max_turns: 1` 工具请求返回 200 `incomplete/max_turns`1 秒总超时返回 200 `incomplete/timeout`;主动取消返回 499 `cancelled` - 真实 `max_turns: 1` 工具请求返回 200 `incomplete/max_turns`1 秒总超时返回 200 `incomplete/timeout`;主动取消返回 499 `cancelled`
- 取消后紧接着的旧 Chat Completions 请求返回 `Chat恢复成功`,证明共享执行状态和错误恢复正常。 - 取消后紧接着的旧 Chat Completions 请求返回 `Chat恢复成功`,证明共享执行状态和错误恢复正常。
- Responses 第一轮 `tool_choice: required` 返回 `function_call``output_text` 为空;第二轮提交 `function_call_output``previous_response_id` 后返回最终 message 文本。 - Responses 第一轮 `tool_choice: required` 返回 `function_call``output_text` 为空;第二轮提交 `function_call_output``previous_response_id` 后返回最终 message 文本。
@@ -406,10 +436,10 @@ command-code \
- headless 无法在服务终端进行批准、拒绝、选项选择或文字回答;`ask_user_question` 不能由等待中的 HTTP 客户端处理。 - headless 无法在服务终端进行批准、拒绝、选项选择或文字回答;`ask_user_question` 不能由等待中的 HTTP 客户端处理。
- 终端事件渲染由本项目完成,格式接近日志,无法等同原始 TUI。 - 终端事件渲染由本项目完成,格式接近日志,无法等同原始 TUI。
- 普通文本 Chat Completions 会实时转发 Command Code 文本 delta;外部 function calling 以及 Chat/Responses 结构化输出需要等待 Bridge 校验完成。 - 普通文本 Chat Completions 会实时转发 Command Code 文本 delta;外部 function calling 以及 Chat/Responses 结构化输出需要等待 Bridge 校验完成。
- Chat Completions 和 Responses 都只实现外部 function tools,不实现图片、音频、文件输入、Computer Use、web search 等 hosted tools 或 MCP hosted tool。非 function 工具类型返回 400 `unsupported_parameter`。Responses 不实现原生 reasoning item、加密 reasoning 或隐藏思维过程。 - Chat Completions 支持受限图片 data URLResponses 还支持受限 UTF-8 `input_file.file_data`。不实现远程/本地路径附件、PDF/Office/任意二进制、音频、视频、Computer Use、web search 等 hosted tools 或 MCP hosted tool。非 function 工具类型返回 400 `unsupported_parameter`。Responses 不实现原生 reasoning item、加密 reasoning 或隐藏思维过程。
- Chat 与 Responses 的外部 function calling 都是 Bridge 通过提示协议、JSON 解析、工具参数 Schema 校验和一次修复实现的兼容层;Command Code CLI 没有公开原生 function calling 输出接口,因此连续两次输出不合格时返回 HTTP 502 `invalid_tool_decision` - Chat 与 Responses 的外部 function calling 都是 Bridge 通过提示协议、JSON 解析、工具参数 Schema 校验和一次修复实现的兼容层;Command Code CLI 没有公开原生 function calling 输出接口,因此连续两次输出不合格时返回 HTTP 502 `invalid_tool_decision`
- Responses 的本地文件存储只供本 Bridge 使用,没有跨进程锁或多实例一致性保证;并发请求应避免同时更新同一条 Response 链。 - Responses 的本地文件存储只供本 Bridge 使用,没有跨进程锁或多实例一致性保证;并发请求应避免同时更新同一条 Response 链。
- Chat 和 Responses Structured Outputs 都是 Bridge 层约束,底层 Command Code 模型仍可能连续两次输出不合格 JSON;Chat 此时返回 `invalid_structured_output`Responses 状态为 incomplete。 - Chat 和 Responses Structured Outputs 都是 Bridge 层约束,底层 Command Code 模型仍可能连续两次输出不合格 JSON;Chat 此时返回 `invalid_structured_output`Responses 状态为 incomplete。
- `usage` 使用 Command Code 最终结果提供的真实 input/output tokenResponses 缺失时返回 `null`Chat Completions 为兼容旧行为返回 0。 - `usage` 使用 Command Code 最终结果提供的真实 input/output tokenResponses 缺失时返回 `null`Chat Completions 为兼容旧行为返回 0。
- 长文本受 HTTP 请求体上限和 Command Code 模型上下文上限共同限制,不会由桥接服务自行截断。 - 长文本受 HTTP 请求体上限和 Command Code 模型上下文上限共同限制,不会由桥接服务自行截断。
- v1.10.0 的大输入会让 `run_end.nextState` 重复完整提示词。实测约 96 KiB 中文输入时,CLI 退出前可能截断该大事件并丢掉紧随其后的 compact `result` 行。桥接服务会忽略冗余 `run_end`,优先使用 `result.finalText`;若退出码为 0 且 result 缺失,只使用最后一个已完整结束、无工具调用的结构化 turn 文本和真实 turn usage,不从 TUI 文本解析。 - 大输入会让 `run_end.nextState` 重复完整提示词。实测约 96 KiB 中文输入时,CLI 退出前可能截断该大事件并丢掉紧随其后的 compact `result` 行。桥接服务会忽略冗余 `run_end`,优先使用 `result.finalText`;若退出码为 0 且 result 缺失,只使用最后一个已完整结束、无工具调用的结构化 turn 文本和真实 turn usage,不从 TUI 文本解析。
+7
View File
@@ -17,6 +17,13 @@ models:
command-default: command-default:
cli_model: deepseek/deepseek-v4-flash cli_model: deepseek/deepseek-v4-flash
effort: max effort: max
# 只有已通过真实 CLI 图像路径检查的映射才能设为 true。
supports_image_input: false
command-fast: command-fast:
cli_model: deepseek/deepseek-v4-flash cli_model: deepseek/deepseek-v4-flash
effort: low effort: low
supports_image_input: false
command-vision:
cli_model: gpt-5.6-luna
effort: max
supports_image_input: true
+5 -1
View File
@@ -17,12 +17,16 @@ models:
command-default: command-default:
cli_model: deepseek/deepseek-v4-flash cli_model: deepseek/deepseek-v4-flash
effort: max effort: max
supports_image_input: false
command-flash: command-flash:
cli_model: deepseek/deepseek-v4-flash cli_model: deepseek/deepseek-v4-flash
effort: max effort: max
supports_image_input: false
command-pro: command-pro:
cli_model: deepseek/deepseek-v4-pro cli_model: deepseek/deepseek-v4-pro
effort: max effort: max
supports_image_input: false
command-luna: command-luna:
cli_model: gpt-5.6-luna cli_model: gpt-5.6-luna
effort: max effort: max
supports_image_input: true
+29
View File
@@ -0,0 +1,29 @@
{
"$schema": "https://opencode.ai/config.json",
"provider": {
"command-bridge": {
"npm": "@ai-sdk/openai-compatible",
"name": "Command Code Bridge",
"options": {
"baseURL": "http://127.0.0.1:18000/v1",
"apiKey": "local-bridge"
},
"models": {
"command-default": {
"name": "Command Code Default",
"modalities": {
"input": ["text"],
"output": ["text"]
}
},
"command-luna": {
"name": "Command Code Luna (image data URLs)",
"modalities": {
"input": ["text", "image"],
"output": ["text"]
}
}
}
}
}
}
+363
View File
@@ -0,0 +1,363 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import path from "node:path";
export const MAX_ATTACHMENTS = 16;
export const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
export const MAX_TEXT_FILE_BYTES = 5 * 1024 * 1024;
export const MAX_TOTAL_ATTACHMENT_BYTES = 15 * 1024 * 1024;
const imageExtensions = new Map<string, string>([
["image/png", "png"],
["image/jpeg", "jpg"],
["image/gif", "gif"],
["image/webp", "webp"],
]);
const textMimeExtensions = new Map<string, string>([
["text/plain", "txt"],
["text/markdown", "md"],
["text/csv", "csv"],
["text/tab-separated-values", "tsv"],
["text/html", "html"],
["text/xml", "xml"],
["text/yaml", "yaml"],
["text/javascript", "js"],
["application/json", "json"],
["application/ld+json", "json"],
["application/xml", "xml"],
["application/yaml", "yaml"],
["application/x-yaml", "yaml"],
["application/javascript", "js"],
["application/sql", "sql"],
]);
const textExtensionMimes = new Map<string, string>([
["txt", "text/plain"],
["md", "text/markdown"],
["markdown", "text/markdown"],
["csv", "text/csv"],
["tsv", "text/tab-separated-values"],
["html", "text/html"],
["htm", "text/html"],
["xml", "application/xml"],
["json", "application/json"],
["jsonl", "application/json"],
["yaml", "application/yaml"],
["yml", "application/yaml"],
["toml", "text/plain"],
["ini", "text/plain"],
["cfg", "text/plain"],
["conf", "text/plain"],
["log", "text/plain"],
["env", "text/plain"],
["js", "application/javascript"],
["mjs", "application/javascript"],
["cjs", "application/javascript"],
["jsx", "text/plain"],
["ts", "text/plain"],
["tsx", "text/plain"],
["py", "text/plain"],
["rb", "text/plain"],
["go", "text/plain"],
["rs", "text/plain"],
["java", "text/plain"],
["c", "text/plain"],
["h", "text/plain"],
["cpp", "text/plain"],
["hpp", "text/plain"],
["cs", "text/plain"],
["php", "text/plain"],
["sh", "text/plain"],
["bash", "text/plain"],
["zsh", "text/plain"],
["sql", "application/sql"],
]);
export type AttachmentErrorCode =
| "invalid_attachment"
| "attachment_too_large"
| "unsupported_media_type"
| "unsupported_parameter";
export class AttachmentInputError extends Error {
constructor(
message: string,
readonly code: AttachmentErrorCode,
readonly param: string,
) {
super(message);
this.name = "AttachmentInputError";
}
}
export interface PendingAttachment {
param: string;
kind: "image" | "text_file";
bytes: Buffer;
mimeType: string;
extension: string;
filename?: string;
}
export interface AttachmentReference {
param: string;
kind: "image" | "text_file";
mimeType: string;
pathToken: string;
filename?: string;
}
export interface MaterializedAttachments {
references: ReadonlyMap<string, AttachmentReference>;
cleanup: () => Promise<void>;
}
interface ParsedDataUrl {
mimeType: string;
bytes: Buffer;
}
export function decodeImageDataUrl(value: string, param: string): PendingAttachment {
if (!value.startsWith("data:")) {
throw new AttachmentInputError(
"Remote and local-path image URLs are unsupported; use a base64 image data URL",
"unsupported_parameter",
param,
);
}
const parsed = parseBase64DataUrl(value, param);
if (!imageExtensions.has(parsed.mimeType)) {
throw new AttachmentInputError(
"Supported image MIME types are image/png, image/jpeg, image/gif, and image/webp",
"unsupported_media_type",
param,
);
}
ensureSize(parsed.bytes, MAX_IMAGE_BYTES, param, "Image");
const detectedMime = detectImageMime(parsed.bytes);
if (!detectedMime || detectedMime !== parsed.mimeType) {
throw new AttachmentInputError(
"Image data does not match its declared MIME type",
"unsupported_media_type",
param,
);
}
return {
param,
kind: "image",
bytes: parsed.bytes,
mimeType: detectedMime,
extension: imageExtensions.get(detectedMime)!,
};
}
export function decodeTextFileData(
value: string,
filename: string,
param: string,
): PendingAttachment {
const safeFilename = path.basename(filename);
const fileExtension = path.extname(safeFilename).slice(1).toLowerCase();
let mimeType: string;
let bytes: Buffer;
if (value.startsWith("data:")) {
const parsed = parseBase64DataUrl(value, param);
mimeType = parsed.mimeType;
bytes = parsed.bytes;
if (!isTextMime(mimeType)) {
throw new AttachmentInputError(
"input_file only supports UTF-8 plain-text, source-code, JSON, XML, YAML, CSV, Markdown, and HTML data",
"unsupported_media_type",
param,
);
}
} else {
mimeType = textExtensionMimes.get(fileExtension) ?? "";
if (mimeType === "") {
throw new AttachmentInputError(
"Raw base64 input_file data requires a recognized plain-text filename extension",
"unsupported_media_type",
param,
);
}
bytes = decodeBase64(value, param);
}
ensureSize(bytes, MAX_TEXT_FILE_BYTES, param, "Text file");
try {
const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
if (text.includes("\0")) throw new Error("NUL byte");
} catch {
throw new AttachmentInputError(
"input_file data must be valid UTF-8 text without NUL bytes",
"unsupported_media_type",
param,
);
}
const extension = textExtensionMimes.has(fileExtension)
? fileExtension
: textMimeExtensions.get(mimeType) ?? "txt";
return {
param,
kind: "text_file",
bytes,
mimeType,
extension,
filename: safeFilename,
};
}
export function validateAttachmentBatch(
attachments: PendingAttachment[],
param = "input",
): void {
if (attachments.length > MAX_ATTACHMENTS) {
throw new AttachmentInputError(
`At most ${MAX_ATTACHMENTS} attachments are supported per request`,
"invalid_attachment",
param,
);
}
const totalBytes = attachments.reduce((total, attachment) => total + attachment.bytes.length, 0);
if (totalBytes > MAX_TOTAL_ATTACHMENT_BYTES) {
throw new AttachmentInputError(
`Total decoded attachment data exceeds ${MAX_TOTAL_ATTACHMENT_BYTES} bytes`,
"attachment_too_large",
param,
);
}
}
export async function materializeAttachments(
workingDirectory: string,
attachments: PendingAttachment[],
): Promise<MaterializedAttachments> {
if (attachments.length === 0) {
return { references: new Map(), cleanup: async () => {} };
}
const root = path.join(workingDirectory, ".command-code-openai-bridge", "inputs");
await mkdir(root, { recursive: true, mode: 0o700 });
const directory = await mkdtemp(path.join(root, "request-"));
const references = new Map<string, AttachmentReference>();
let cleaned = false;
try {
for (const [index, attachment] of attachments.entries()) {
const filePath = path.join(directory, `${index}.${attachment.extension}`);
await writeFile(filePath, attachment.bytes, { flag: "wx", mode: 0o600 });
const relative = path.relative(workingDirectory, filePath);
if (relative.startsWith("..") || path.isAbsolute(relative)) {
throw new Error("Attachment path escaped the Command Code workspace");
}
references.set(attachment.param, {
param: attachment.param,
kind: attachment.kind,
mimeType: attachment.mimeType,
pathToken: `@${relative.split(path.sep).join("/")}`,
...(attachment.filename ? { filename: attachment.filename } : {}),
});
}
} catch (error) {
await rm(directory, { recursive: true, force: true });
throw error;
}
return {
references,
cleanup: async () => {
if (cleaned) return;
cleaned = true;
await rm(directory, { recursive: true, force: true });
},
};
}
export function attachmentPromptBlock(
references: ReadonlyMap<string, AttachmentReference>,
): string | undefined {
if (references.size === 0) return undefined;
return [
"API 附件已安全解码为当前请求专用的本地临时文件;原始 base64 或 URL 不在提示中。",
"这些路径是用户输入的一部分。需要理解附件时,必须用 read_file 读取对应路径;不得猜测附件内容,也不得读取未列出的文件。",
...[...references.values()].map((reference) => {
const label = reference.filename ? `,原文件名 ${JSON.stringify(reference.filename)}` : "";
return `- ${reference.param}${reference.kind}${reference.mimeType}${label}):${reference.pathToken}`;
}),
].join("\n");
}
function parseBase64DataUrl(value: string, param: string): ParsedDataUrl {
const comma = value.indexOf(",");
if (comma < 0) {
throw new AttachmentInputError("Malformed data URL", "invalid_attachment", param);
}
const metadata = value.slice(5, comma);
const segments = metadata.split(";");
const mimeType = (segments.shift() ?? "").toLowerCase();
const parameters = segments.map((segment) => segment.toLowerCase());
if (mimeType === "" || !parameters.includes("base64")) {
throw new AttachmentInputError(
"Attachment data URLs must include an explicit MIME type and ;base64",
"invalid_attachment",
param,
);
}
const unsupportedParameter = parameters.find((segment) => (
segment !== "base64" && segment !== "charset=utf-8" && segment !== "charset=utf8"
));
if (unsupportedParameter) {
throw new AttachmentInputError("Unsupported data URL parameter", "invalid_attachment", param);
}
return {
mimeType,
bytes: decodeBase64(value.slice(comma + 1), param),
};
}
function decodeBase64(value: string, param: string): Buffer {
if (
value === ""
|| value.length % 4 !== 0
|| !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)
) {
throw new AttachmentInputError("Attachment data is not valid base64", "invalid_attachment", param);
}
return Buffer.from(value, "base64");
}
function ensureSize(bytes: Buffer, maximum: number, param: string, label: string): void {
if (bytes.length > maximum) {
throw new AttachmentInputError(
`${label} exceeds the ${maximum}-byte decoded size limit`,
"attachment_too_large",
param,
);
}
}
function detectImageMime(bytes: Buffer): string | undefined {
if (bytes.length >= 8 && bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) {
return "image/png";
}
if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
return "image/jpeg";
}
const header = bytes.subarray(0, 6).toString("ascii");
if (header === "GIF87a" || header === "GIF89a") return "image/gif";
if (
bytes.length >= 12
&& bytes.subarray(0, 4).toString("ascii") === "RIFF"
&& bytes.subarray(8, 12).toString("ascii") === "WEBP"
) return "image/webp";
return undefined;
}
function isTextMime(mimeType: string): boolean {
return textMimeExtensions.has(mimeType);
}
+3 -1
View File
@@ -224,7 +224,9 @@ export async function runCommandCode(
} }
} catch (error) { } catch (error) {
parseError = error instanceof Error ? error : new Error(String(error)); parseError = error instanceof Error ? error : new Error(String(error));
process.stderr.write(`\n无法解析 Command Code NDJSON${line.slice(0, 500)}\n`); process.stderr.write(
`\n无法解析 Command Code NDJSON${line.length} 字符):${parseError.name}\n`,
);
} }
}); });
+1
View File
@@ -6,6 +6,7 @@ import { z } from "zod";
const modelSchema = z.object({ const modelSchema = z.object({
cli_model: z.string().min(1), cli_model: z.string().min(1),
effort: z.string().min(1), effort: z.string().min(1),
supports_image_input: z.boolean().default(false),
}); });
const configSchema = z.object({ const configSchema = z.object({
+75 -10
View File
@@ -1,12 +1,32 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { z } from "zod"; import { z } from "zod";
import {
attachmentPromptBlock,
AttachmentInputError,
decodeImageDataUrl,
validateAttachmentBatch,
type AttachmentReference,
type PendingAttachment,
} from "./attachments.js";
const textPartSchema = z.object({ const textPartSchema = z.object({
type: z.literal("text"), type: z.literal("text"),
text: z.string(), text: z.string(),
}).strict(); }).strict();
const imageUrlPartSchema = z.object({
type: z.literal("image_url"),
image_url: z.object({
url: z.string().min(1),
detail: z.enum(["auto", "low", "high"]).optional(),
}).strict(),
}).strict();
const messageContentSchema = z.union([z.string(), z.array(textPartSchema)]); const messageContentSchema = z.union([z.string(), z.array(textPartSchema)]);
const userMessageContentSchema = z.union([
z.string(),
z.array(z.discriminatedUnion("type", [textPartSchema, imageUrlPartSchema])).min(1),
]);
const messageNameSchema = z.string().min(1).max(64); const messageNameSchema = z.string().min(1).max(64);
const functionNameSchema = z.string().min(1).max(64).regex(/^[A-Za-z0-9_-]+$/); const functionNameSchema = z.string().min(1).max(64).regex(/^[A-Za-z0-9_-]+$/);
@@ -33,7 +53,7 @@ const developerMessageSchema = z.object({
const userMessageSchema = z.object({ const userMessageSchema = z.object({
role: z.literal("user"), role: z.literal("user"),
content: messageContentSchema, content: userMessageContentSchema,
name: messageNameSchema.optional(), name: messageNameSchema.optional(),
}).strict(); }).strict();
@@ -194,13 +214,45 @@ export function ignoredChatCompatibilityParameters(request: ChatCompletionReques
return ignoredCompatibilityParameters.filter((key) => request[key] !== undefined); return ignoredCompatibilityParameters.filter((key) => request[key] !== undefined);
} }
export function normalizeMessages(request: ChatCompletionRequest) { export function chatInputAttachments(request: ChatCompletionRequest): PendingAttachment[] {
return request.messages.map((message) => { const attachments: PendingAttachment[] = [];
for (const [messageIndex, message] of request.messages.entries()) {
if (message.role !== "user" || typeof message.content === "string") continue;
for (const [partIndex, part] of message.content.entries()) {
if (part.type !== "image_url") continue;
if (part.image_url.detail !== undefined && part.image_url.detail !== "auto") {
throw new AttachmentInputError(
"Command Code does not expose image detail controls; omit detail or use 'auto'",
"unsupported_parameter",
`messages.${messageIndex}.content.${partIndex}.image_url.detail`,
);
}
attachments.push(decodeImageDataUrl(
part.image_url.url,
`messages.${messageIndex}.content.${partIndex}.image_url.url`,
));
}
}
validateAttachmentBatch(attachments, "messages");
return attachments;
}
export function normalizeMessages(
request: ChatCompletionRequest,
attachmentReferences: ReadonlyMap<string, AttachmentReference> = new Map(),
) {
return request.messages.map((message, messageIndex) => {
const content = message.content === null const content = message.content === null
? null ? null
: typeof message.content === "string" : typeof message.content === "string"
? message.content ? message.content
: message.content.map((part) => part.text).join(""); : message.content.map((part, partIndex) => {
if (part.type === "text") return part.text;
const param = `messages.${messageIndex}.content.${partIndex}.image_url.url`;
const reference = attachmentReferences.get(param);
if (!reference) throw new Error(`Missing materialized attachment for ${param}`);
return `\n[图片输入:${reference.pathToken}]\n`;
}).join("");
if (message.role === "assistant") { if (message.role === "assistant") {
return { return {
@@ -247,15 +299,19 @@ export function usesChatStructuredOutput(request: ChatCompletionRequest): boolea
return request.response_format.type !== "text"; return request.response_format.type !== "text";
} }
export function buildCommandPrompt(request: ChatCompletionRequest): string { export function buildCommandPrompt(
if (usesToolCalling(request)) return buildToolCommandPrompt(request); request: ChatCompletionRequest,
attachmentReferences: ReadonlyMap<string, AttachmentReference> = new Map(),
): string {
if (usesToolCalling(request)) return buildToolCommandPrompt(request, attachmentReferences);
const format = chatResponseTextFormat(request); const format = chatResponseTextFormat(request);
const envelope = { const envelope = {
protocol: "openai-chat-completions-history-v1", protocol: "openai-chat-completions-history-v1",
messages: normalizeMessages(request), messages: normalizeMessages(request, attachmentReferences),
response_format: request.response_format, response_format: request.response_format,
}; };
const attachments = attachmentPromptBlock(attachmentReferences);
return [ return [
"下面 JSON 对象的 messages 数组是外部客户端提交的完整任务。", "下面 JSON 对象的 messages 数组是外部客户端提交的完整任务。",
@@ -265,27 +321,35 @@ export function buildCommandPrompt(request: ChatCompletionRequest): string {
"如需调用工具,调用工具的 turn 只发起工具调用,不输出面向用户的文本;所有工具结束后,仅在不再调用工具的最终 turn 输出回答。", "如需调用工具,调用工具的 turn 只发起工具调用,不输出面向用户的文本;所有工具结束后,仅在不再调用工具的最终 turn 输出回答。",
"不要复述 JSON,不要输出角色标签,不要添加指令未要求的解释或格式。", "不要复述 JSON,不要输出角色标签,不要添加指令未要求的解释或格式。",
chatFormatDirective(format, false), chatFormatDirective(format, false),
...(attachments ? [attachments] : []),
"JSON 数据开始:", "JSON 数据开始:",
JSON.stringify(envelope), JSON.stringify(envelope),
].join("\n\n"); ].join("\n\n");
} }
function buildToolCommandPrompt(request: ChatCompletionRequest): string { function buildToolCommandPrompt(
request: ChatCompletionRequest,
attachmentReferences: ReadonlyMap<string, AttachmentReference>,
): string {
const effectiveToolChoice = request.tool_choice ?? (request.tools ? "auto" : "none"); const effectiveToolChoice = request.tool_choice ?? (request.tools ? "auto" : "none");
const format = chatResponseTextFormat(request); const format = chatResponseTextFormat(request);
const envelope = { const envelope = {
protocol: "openai-chat-completions-tools-v1", protocol: "openai-chat-completions-tools-v1",
messages: normalizeMessages(request), messages: normalizeMessages(request, attachmentReferences),
tools: request.tools ?? [], tools: request.tools ?? [],
tool_choice: effectiveToolChoice, tool_choice: effectiveToolChoice,
parallel_tool_calls: request.parallel_tool_calls ?? true, parallel_tool_calls: request.parallel_tool_calls ?? true,
response_format: request.response_format, response_format: request.response_format,
}; };
const attachments = attachmentPromptBlock(attachmentReferences);
const toolBoundary = attachments
? "除读取下方 API 附件临时路径所必需的 read_file 外,不要使用 Command Code 自身工具替代外部 tools;附件读取只是在消费用户输入。"
: "不要使用 Command Code 自身的文件、终端、搜索、网络或其他工具替代外部 tools;只依据 messages、tools 和工具结果作出决定。";
return [ return [
"下面 JSON 对象是外部客户端提交的一次 OpenAI Chat Completions 工具调用任务。", "下面 JSON 对象是外部客户端提交的一次 OpenAI Chat Completions 工具调用任务。",
"你只负责决定当前这一轮应该调用外部工具还是返回最终回答。外部工具由客户端执行,你不能模拟工具结果。", "你只负责决定当前这一轮应该调用外部工具还是返回最终回答。外部工具由客户端执行,你不能模拟工具结果。",
"不要使用 Command Code 自身的文件、终端、搜索、网络或其他工具替代外部 tools;只依据 messages、tools 和工具结果作出决定。", toolBoundary,
"严格执行 messages 中的 system、developer 和 user 指令,并结合 assistant.tool_calls 与 tool 消息理解完整历史。", "严格执行 messages 中的 system、developer 和 user 指令,并结合 assistant.tool_calls 与 tool 消息理解完整历史。",
"只返回以下两个 JSON 对象之一,禁止 Markdown 代码围栏、前后说明和额外字段:", "只返回以下两个 JSON 对象之一,禁止 Markdown 代码围栏、前后说明和额外字段:",
"最终回答:{\"type\":\"final\",\"content\":\"面向用户的完整回答\"}", "最终回答:{\"type\":\"final\",\"content\":\"面向用户的完整回答\"}",
@@ -295,6 +359,7 @@ function buildToolCommandPrompt(request: ChatCompletionRequest): string {
"需要工具提供信息或执行动作时返回 tool_calls 并立即结束;已有足够信息时返回 final。不要在工具调用轮次生成面向用户的正文。", "需要工具提供信息或执行动作时返回 tool_calls 并立即结束;已有足够信息时返回 final。不要在工具调用轮次生成面向用户的正文。",
"response_format 只约束 final.content;返回 tool_calls 时不应用正文格式校验。", "response_format 只约束 final.content;返回 tool_calls 时不应用正文格式校验。",
chatFormatDirective(format, true), chatFormatDirective(format, true),
...(attachments ? [attachments] : []),
"JSON 数据开始:", "JSON 数据开始:",
JSON.stringify(envelope), JSON.stringify(envelope),
].join("\n\n"); ].join("\n\n");
+61 -2
View File
@@ -43,18 +43,56 @@ export function printCumulativeUsage(): void {
} }
function details(event: Record<string, unknown>): string { function details(event: Record<string, unknown>): string {
const copy = { ...event }; const copy = redactForTerminal(event) as Record<string, unknown>;
delete copy.type; delete copy.type;
return inspect(copy, { colors: tty, depth: 8, compact: false, breakLength: 120 }); return inspect(copy, { colors: tty, depth: 8, compact: false, breakLength: 120 });
} }
function redactForTerminal(value: unknown, key = ""): unknown {
if (typeof value === "string") return redactString(value, key);
if (Array.isArray(value)) return value.map((item) => redactForTerminal(item));
if (typeof value !== "object" || value === null) return value;
const redacted: Record<string, unknown> = {};
for (const [childKey, childValue] of Object.entries(value)) {
if (/authorization|api[-_]?key|access[-_]?token|secret|password|cookie/i.test(childKey)) {
redacted[childKey] = "[REDACTED]";
continue;
}
redacted[childKey] = redactForTerminal(childValue, childKey);
}
return redacted;
}
function redactString(value: string, key: string): string {
if (value.startsWith("data:")) return `[REDACTED data URL: ${value.length} characters]`;
if (
value.length >= 128
&& value.length % 4 === 0
&& /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)
) return `[REDACTED base64: ${value.length} characters]`;
if (/url|uri/i.test(key)) {
try {
const url = new URL(value);
if (url.search || url.hash || url.username || url.password) {
return `${url.protocol}//${url.host}${url.pathname}[REDACTED query/credentials]`;
}
} catch {
// Not an absolute URL.
}
}
return value;
}
export class TerminalRenderer { export class TerminalRenderer {
private answerStarted = false; private answerStarted = false;
private thinkingShown = false; private thinkingShown = false;
private readonly attachmentToolCalls = new Set<string>();
begin(model: string, effort: string, proxy?: string): void { begin(model: string, effort: string, proxy?: string): void {
this.answerStarted = false; this.answerStarted = false;
this.thinkingShown = false; this.thinkingShown = false;
this.attachmentToolCalls.clear();
process.stdout.write(`\n${cyan("━━━━━━━━ Command Code 请求 ━━━━━━━━")}\n`); process.stdout.write(`\n${cyan("━━━━━━━━ Command Code 请求 ━━━━━━━━")}\n`);
process.stdout.write(`${dim("模型")} ${model}\n`); process.stdout.write(`${dim("模型")} ${model}\n`);
process.stdout.write(`${dim("思考深度")} ${effort}\n`); process.stdout.write(`${dim("思考深度")} ${effort}\n`);
@@ -63,6 +101,10 @@ export class TerminalRenderer {
event(event: Record<string, unknown>): void { event(event: Record<string, unknown>): void {
const type = typeof event.type === "string" ? event.type : "unknown"; const type = typeof event.type === "string" ? event.type : "unknown";
const toolCallId = typeof event.toolCallId === "string" ? event.toolCallId : undefined;
if (toolCallId && eventReferencesApiAttachment(event)) {
this.attachmentToolCalls.add(toolCallId);
}
switch (type) { switch (type) {
case "run_start": case "run_start":
@@ -110,7 +152,13 @@ export class TerminalRenderer {
} }
default: default:
if (type.includes("tool") || type.includes("permission") || type.includes("question")) { if (type.includes("tool") || type.includes("permission") || type.includes("question")) {
process.stdout.write(`${yellow(`\n[${type}]`)}\n${details(event)}\n`); const displayedEvent = toolCallId && this.attachmentToolCalls.has(toolCallId) && type === "tool_completed"
? { ...event, result: "[REDACTED API attachment content]" }
: event;
process.stdout.write(`${yellow(`\n[${type}]`)}\n${details(displayedEvent)}\n`);
if (toolCallId && (type === "tool_completed" || type === "tool_failed")) {
this.attachmentToolCalls.delete(toolCallId);
}
} }
} }
} }
@@ -129,3 +177,14 @@ export class TerminalRenderer {
process.stderr.write(`${color(31, `\nCommand Code 失败:${message}`)}\n`); process.stderr.write(`${color(31, `\nCommand Code 失败:${message}`)}\n`);
} }
} }
function eventReferencesApiAttachment(event: Record<string, unknown>): boolean {
if (typeof event.input !== "object" || event.input === null || Array.isArray(event.input)) return false;
const input = event.input as Record<string, unknown>;
const filePath = typeof input.file_path === "string"
? input.file_path
: typeof input.absolute_path === "string"
? input.absolute_path
: undefined;
return filePath?.replaceAll("\\", "/").includes("/.command-code-openai-bridge/inputs/request-") ?? false;
}
+158 -5
View File
@@ -5,6 +5,17 @@ import type { ServerResponse } from "node:http";
import { Ajv, type ErrorObject, type ValidateFunction } from "ajv"; import { Ajv, type ErrorObject, type ValidateFunction } from "ajv";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z, ZodError } from "zod"; import { z, ZodError } from "zod";
import {
attachmentPromptBlock,
AttachmentInputError,
decodeImageDataUrl,
decodeTextFileData,
materializeAttachments,
validateAttachmentBatch,
type AttachmentReference,
type MaterializedAttachments,
type PendingAttachment,
} from "./attachments.js";
import { import {
CommandCodeError, CommandCodeError,
runCommandCode, runCommandCode,
@@ -43,12 +54,55 @@ const outputTextPartSchema = z.object({
text: z.string(), text: z.string(),
}).strict(); }).strict();
const inputImagePartSchema = z.object({
type: z.literal("input_image"),
image_url: z.string().min(1).optional(),
file_id: z.string().min(1).optional(),
detail: z.enum(["auto", "low", "high"]).optional(),
}).strict().superRefine((part, context) => {
if ((part.image_url === undefined) === (part.file_id === undefined)) {
context.addIssue({
code: z.ZodIssueCode.custom,
message: "input_image requires exactly one of image_url or file_id",
});
}
});
const inputFilePartSchema = z.object({
type: z.literal("input_file"),
file_data: z.string().min(1).optional(),
file_url: z.string().min(1).optional(),
file_id: z.string().min(1).optional(),
filename: z.string().min(1).max(255).optional(),
}).strict().superRefine((part, context) => {
const sourceCount = [part.file_data, part.file_url, part.file_id]
.filter((source) => source !== undefined).length;
if (sourceCount !== 1) {
context.addIssue({
code: z.ZodIssueCode.custom,
message: "input_file requires exactly one of file_data, file_url, or file_id",
});
}
if (part.file_data !== undefined && part.filename === undefined) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ["filename"],
message: "filename is required with file_data",
});
}
});
const inputMessageSchema = z.object({ const inputMessageSchema = z.object({
type: z.literal("message").optional(), type: z.literal("message").optional(),
role: roleSchema, role: roleSchema,
content: z.union([ content: z.union([
z.string(), z.string(),
z.array(z.union([inputTextPartSchema, outputTextPartSchema])).min(1), z.array(z.union([
inputTextPartSchema,
outputTextPartSchema,
inputImagePartSchema,
inputFilePartSchema,
])).min(1),
]), ]),
}).strict(); }).strict();
@@ -771,10 +825,12 @@ export async function registerResponseRoutes(
let parsed: ResponseRequest; let parsed: ResponseRequest;
let validation: StructuredValidation | undefined; let validation: StructuredValidation | undefined;
let pendingAttachments: PendingAttachment[];
try { try {
parsed = parseResponseRequest(request.body); parsed = parseResponseRequest(request.body);
validation = createStructuredValidation(parsed.text.format); validation = createStructuredValidation(parsed.text.format);
validateResponseToolRequest(parsed); validateResponseToolRequest(parsed);
pendingAttachments = responseInputAttachments(parsed);
} catch (error) { } catch (error) {
return sendRouteError(reply, error); return sendRouteError(reply, error);
} }
@@ -788,6 +844,14 @@ export async function registerResponseRoutes(
"model", "model",
)); ));
} }
const firstImage = pendingAttachments.find((attachment) => attachment.kind === "image");
if (firstImage && !model.supports_image_input) {
return sendRouteError(reply, new AttachmentInputError(
`Model '${parsed.model}' is not configured for image input`,
"unsupported_parameter",
firstImage.param,
));
}
const lease = coordinator.begin(); const lease = coordinator.begin();
if (!lease) return sendBusy(reply); if (!lease) return sendBusy(reply);
@@ -795,6 +859,7 @@ export async function registerResponseRoutes(
let responseCompleted = false; let responseCompleted = false;
let acquired = false; let acquired = false;
let materialized: MaterializedAttachments | undefined;
const cancelOnDisconnect = () => { const cancelOnDisconnect = () => {
if (!responseCompleted) coordinator.cancel(abortController, "client disconnected"); if (!responseCompleted) coordinator.cancel(abortController, "client disconnected");
}; };
@@ -827,12 +892,16 @@ export async function registerResponseRoutes(
responseCompleted = true; responseCompleted = true;
return reply; return reply;
} }
materialized = await materializeAttachments(
config.resolvedWorkingDirectory,
pendingAttachments,
);
const chain = parsed.previous_response_id const chain = parsed.previous_response_id
? await store.loadChain(parsed.previous_response_id) ? await store.loadChain(parsed.previous_response_id)
: []; : [];
validateResponseFunctionHistory(parsed, chain, inputItems); validateResponseFunctionHistory(parsed, chain, inputItems);
const prompt = buildResponsesPrompt(parsed, chain, inputItems); const prompt = buildResponsesPrompt(parsed, chain, inputItems, materialized.references);
const deadline = Date.now() + config.timeout_seconds * 1000; const deadline = Date.now() + config.timeout_seconds * 1000;
const toolRequest = responseFunctionToolDecisionRequest(parsed); const toolRequest = responseFunctionToolDecisionRequest(parsed);
@@ -918,6 +987,11 @@ export async function registerResponseRoutes(
} finally { } finally {
request.raw.off("aborted", cancelOnDisconnect); request.raw.off("aborted", cancelOnDisconnect);
reply.raw.off("close", cancelOnDisconnect); reply.raw.off("close", cancelOnDisconnect);
try {
await materialized?.cleanup();
} catch {
process.stderr.write("警告:无法清理本次请求的临时附件目录。\n");
}
if (!acquired) coordinator.cancel(abortController, "request ended before execution"); if (!acquired) coordinator.cancel(abortController, "request ended before execution");
coordinator.finish(abortController); coordinator.finish(abortController);
} }
@@ -1006,7 +1080,12 @@ function findUnsupportedParameter(body: unknown): string | undefined {
if (!Array.isArray(item.content)) continue; if (!Array.isArray(item.content)) continue;
for (const [partIndex, part] of item.content.entries()) { for (const [partIndex, part] of item.content.entries()) {
if (!isRecord(part)) continue; if (!isRecord(part)) continue;
if (part.type !== "input_text" && part.type !== "output_text") { if (
part.type !== "input_text"
&& part.type !== "output_text"
&& part.type !== "input_image"
&& part.type !== "input_file"
) {
return `input.${itemIndex}.content.${partIndex}.type`; return `input.${itemIndex}.content.${partIndex}.type`;
} }
} }
@@ -1014,6 +1093,61 @@ function findUnsupportedParameter(body: unknown): string | undefined {
return undefined; return undefined;
} }
function responseInputAttachments(request: ResponseRequest): PendingAttachment[] {
if (typeof request.input === "string") return [];
const attachments: PendingAttachment[] = [];
for (const [itemIndex, item] of request.input.entries()) {
if (item.type === "function_call" || item.type === "function_call_output") continue;
if (typeof item.content === "string") continue;
for (const [partIndex, part] of item.content.entries()) {
const baseParam = `input.${itemIndex}.content.${partIndex}`;
if (part.type === "input_image") {
if (part.file_id !== undefined) {
throw new AttachmentInputError(
"file_id image inputs require an OpenAI Files service, which this bridge does not implement",
"unsupported_parameter",
`${baseParam}.file_id`,
);
}
if (part.detail !== undefined && part.detail !== "auto") {
throw new AttachmentInputError(
"Command Code does not expose image detail controls; omit detail or use 'auto'",
"unsupported_parameter",
`${baseParam}.detail`,
);
}
attachments.push(decodeImageDataUrl(
part.image_url!,
`${baseParam}.image_url`,
));
continue;
}
if (part.type !== "input_file") continue;
if (part.file_url !== undefined) {
throw new AttachmentInputError(
"Remote file URLs are not fetched; provide UTF-8 text through file_data",
"unsupported_parameter",
`${baseParam}.file_url`,
);
}
if (part.file_id !== undefined) {
throw new AttachmentInputError(
"file_id inputs require an OpenAI Files service, which this bridge does not implement",
"unsupported_parameter",
`${baseParam}.file_id`,
);
}
attachments.push(decodeTextFileData(
part.file_data!,
part.filename!,
`${baseParam}.file_data`,
));
}
}
validateAttachmentBatch(attachments);
return attachments;
}
function normalizeInput(input: ResponseRequest["input"]): ResponseInputItem[] { function normalizeInput(input: ResponseRequest["input"]): ResponseInputItem[] {
if (typeof input === "string") { if (typeof input === "string") {
return [{ return [{
@@ -1050,7 +1184,11 @@ function normalizeInput(input: ResponseRequest["input"]): ResponseInputItem[] {
role: item.role, role: item.role,
content: typeof item.content === "string" content: typeof item.content === "string"
? [{ type: item.role === "assistant" ? "output_text" : "input_text", text: item.content }] ? [{ type: item.role === "assistant" ? "output_text" : "input_text", text: item.content }]
: item.content.map((part) => ({ type: part.type, text: part.text })), : item.content.flatMap((part) => (
part.type === "input_text" || part.type === "output_text"
? [{ type: part.type, text: part.text }]
: []
)),
}; };
}); });
} }
@@ -1059,6 +1197,7 @@ function buildResponsesPrompt(
request: ResponseRequest, request: ResponseRequest,
chain: StoredResponse[], chain: StoredResponse[],
currentInput: ResponseInputItem[], currentInput: ResponseInputItem[],
attachmentReferences: ReadonlyMap<string, AttachmentReference>,
): string { ): string {
const history: Array<ResponseInputItem | ResponseOutputItem> = []; const history: Array<ResponseInputItem | ResponseOutputItem> = [];
for (const stored of chain) { for (const stored of chain) {
@@ -1076,12 +1215,16 @@ function buildResponsesPrompt(
tool_choice: effectiveResponseToolChoice(request), tool_choice: effectiveResponseToolChoice(request),
parallel_tool_calls: request.parallel_tool_calls ?? true, parallel_tool_calls: request.parallel_tool_calls ?? true,
}; };
const attachments = attachmentPromptBlock(attachmentReferences);
if (usesResponseToolCalling(request, history)) { if (usesResponseToolCalling(request, history)) {
const toolBoundary = attachments
? "除读取下方 API 附件临时路径所必需的 read_file 外,不要使用 Command Code 自身工具替代外部 tools;附件读取只是在消费用户输入。"
: "不要使用 Command Code 自身的文件、终端、搜索、网络或其他工具替代外部 tools;只依据 input、tools 和 function_call_output 作出决定。";
return [ return [
"下面 JSON 对象是外部客户端提交的一次 OpenAI Responses 工具调用任务,input 包含完整历史。", "下面 JSON 对象是外部客户端提交的一次 OpenAI Responses 工具调用任务,input 包含完整历史。",
"你只负责决定当前这一轮应该调用外部工具还是返回最终回答。外部工具由客户端执行,你不能模拟工具结果。", "你只负责决定当前这一轮应该调用外部工具还是返回最终回答。外部工具由客户端执行,你不能模拟工具结果。",
"不要使用 Command Code 自身的文件、终端、搜索、网络或其他工具替代外部 tools;只依据 input、tools 和 function_call_output 作出决定。", toolBoundary,
"严格执行 input 中的 system、developer、user 指令和当前 instructions,并结合 message、function_call、function_call_output 及其他已有 output item 理解完整历史。", "严格执行 input 中的 system、developer、user 指令和当前 instructions,并结合 message、function_call、function_call_output 及其他已有 output item 理解完整历史。",
"历史 reasoning item 只包含此前公开的 summary 文本记录,不是可恢复的内部推理状态;仅把 summary 当作普通历史上下文。", "历史 reasoning item 只包含此前公开的 summary 文本记录,不是可恢复的内部推理状态;仅把 summary 当作普通历史上下文。",
"只返回以下两个 JSON 对象之一,禁止 Markdown 代码围栏、前后说明和额外字段:", "只返回以下两个 JSON 对象之一,禁止 Markdown 代码围栏、前后说明和额外字段:",
@@ -1091,6 +1234,7 @@ function buildResponsesPrompt(
"tool_choice 为 none 时必须返回 final;为 required 或指定函数时必须返回 tool_calls。parallel_tool_calls 为 false 时 calls 只能有一项。", "tool_choice 为 none 时必须返回 final;为 required 或指定函数时必须返回 tool_calls。parallel_tool_calls 为 false 时 calls 只能有一项。",
"需要工具提供信息或执行动作时返回 tool_calls 并立即结束;已有 function_call_output 且信息足够时返回 final。工具调用轮次禁止生成面向用户的正文。", "需要工具提供信息或执行动作时返回 tool_calls 并立即结束;已有 function_call_output 且信息足够时返回 final。工具调用轮次禁止生成面向用户的正文。",
formatDirective(request.text.format), formatDirective(request.text.format),
...(attachments ? [attachments] : []),
"JSON 数据开始:", "JSON 数据开始:",
JSON.stringify(envelope), JSON.stringify(envelope),
].join("\n\n"); ].join("\n\n");
@@ -1102,6 +1246,7 @@ function buildResponsesPrompt(
"历史 reasoning item 只包含此前公开的 summary 文本记录,不是可恢复的内部推理状态;仅把 summary 当作普通历史上下文。", "历史 reasoning item 只包含此前公开的 summary 文本记录,不是可恢复的内部推理状态;仅把 summary 当作普通历史上下文。",
"严格执行最后一个用户任务。不要复述 JSON,不要输出角色标签,不要暴露内部思考。", "严格执行最后一个用户任务。不要复述 JSON,不要输出角色标签,不要暴露内部思考。",
formatDirective(request.text.format), formatDirective(request.text.format),
...(attachments ? [attachments] : []),
"JSON 数据开始:", "JSON 数据开始:",
JSON.stringify(envelope), JSON.stringify(envelope),
].join("\n\n"); ].join("\n\n");
@@ -1553,6 +1698,14 @@ function startSse(reply: FastifyReply, messageId: string): ResponsesSseWriter {
} }
function sendRouteError(reply: FastifyReply, error: unknown) { function sendRouteError(reply: FastifyReply, error: unknown) {
if (error instanceof AttachmentInputError) {
return reply.status(400).send(openAIError(
error.message,
error.code,
"invalid_request_error",
error.param,
));
}
if (error instanceof ResponseApiError) { if (error instanceof ResponseApiError) {
return reply.status(error.statusCode).send(openAIError(error.message, error.code, error.type, error.param)); return reply.status(error.statusCode).send(openAIError(error.message, error.code, error.type, error.param));
} }
+47 -1
View File
@@ -1,5 +1,12 @@
import Fastify, { type FastifyInstance } from "fastify"; import Fastify, { type FastifyInstance } from "fastify";
import { ZodError } from "zod"; import { ZodError } from "zod";
import {
AttachmentInputError,
materializeAttachments,
type AttachmentReference,
type MaterializedAttachments,
type PendingAttachment,
} from "./attachments.js";
import { startChatCompletionSse, type ChatCompletionSseWriter } from "./chat-stream.js"; import { startChatCompletionSse, type ChatCompletionSseWriter } from "./chat-stream.js";
import type { BridgeConfig } from "./config.js"; import type { BridgeConfig } from "./config.js";
import { import {
@@ -15,6 +22,7 @@ import {
buildToolStructuredOutputRepairPrompt, buildToolStructuredOutputRepairPrompt,
chatResponseTextFormat, chatResponseTextFormat,
chatCompletionRequestSchema, chatCompletionRequestSchema,
chatInputAttachments,
completionResponse, completionResponse,
ignoredChatCompatibilityParameters, ignoredChatCompatibilityParameters,
openAIError, openAIError,
@@ -116,6 +124,10 @@ export async function createServer(config: BridgeConfig): Promise<BridgeServer>
object: "model", object: "model",
created: 0, created: 0,
owned_by: "command-code-local", owned_by: "command-code-local",
capabilities: {
input: config.models[id]!.supports_image_input ? ["text", "image"] : ["text"],
output: ["text"],
},
})), })),
})); }));
@@ -181,6 +193,28 @@ export async function createServer(config: BridgeConfig): Promise<BridgeServer>
)); ));
} }
let pendingAttachments: PendingAttachment[];
try {
pendingAttachments = chatInputAttachments(parsed);
if (pendingAttachments.length > 0 && !model.supports_image_input) {
throw new AttachmentInputError(
`Model '${parsed.model}' is not configured for image input`,
"unsupported_parameter",
pendingAttachments[0]!.param,
);
}
} catch (error) {
if (error instanceof AttachmentInputError) {
return reply.status(400).send(openAIError(
error.message,
error.code,
"invalid_request_error",
error.param,
));
}
throw error;
}
const toolSchemaErrors = validateToolSchemas(parsed); const toolSchemaErrors = validateToolSchemas(parsed);
if (toolSchemaErrors.length > 0) { if (toolSchemaErrors.length > 0) {
return reply.status(400).send(openAIError( return reply.status(400).send(openAIError(
@@ -211,6 +245,7 @@ export async function createServer(config: BridgeConfig): Promise<BridgeServer>
const { controller: abortController } = lease; const { controller: abortController } = lease;
let responseCompleted = false; let responseCompleted = false;
let acquired = false; let acquired = false;
let materialized: MaterializedAttachments | undefined;
const cancelOnDisconnect = () => { const cancelOnDisconnect = () => {
if (!responseCompleted) coordinator.cancel(abortController, "client disconnected"); if (!responseCompleted) coordinator.cancel(abortController, "client disconnected");
@@ -233,6 +268,10 @@ export async function createServer(config: BridgeConfig): Promise<BridgeServer>
responseCompleted = true; responseCompleted = true;
return reply; return reply;
} }
materialized = await materializeAttachments(
config.resolvedWorkingDirectory,
pendingAttachments,
);
const toolMode = usesToolCalling(parsed); const toolMode = usesToolCalling(parsed);
const execution = await executeChatTurn({ const execution = await executeChatTurn({
config, config,
@@ -243,6 +282,7 @@ export async function createServer(config: BridgeConfig): Promise<BridgeServer>
deadline: Date.now() + config.timeout_seconds * 1000, deadline: Date.now() + config.timeout_seconds * 1000,
toolMode, toolMode,
format: responseFormat, format: responseFormat,
attachmentReferences: materialized.references,
...(structuredValidation ? { validation: structuredValidation } : {}), ...(structuredValidation ? { validation: structuredValidation } : {}),
...(writer ? { writer } : {}), ...(writer ? { writer } : {}),
}); });
@@ -351,6 +391,11 @@ export async function createServer(config: BridgeConfig): Promise<BridgeServer>
} finally { } finally {
request.raw.off("aborted", cancelOnDisconnect); request.raw.off("aborted", cancelOnDisconnect);
reply.raw.off("close", cancelOnDisconnect); reply.raw.off("close", cancelOnDisconnect);
try {
await materialized?.cleanup();
} catch {
process.stderr.write("警告:无法清理本次请求的临时附件目录。\n");
}
if (!acquired) coordinator.cancel(abortController, "request ended before execution"); if (!acquired) coordinator.cancel(abortController, "request ended before execution");
coordinator.finish(abortController); coordinator.finish(abortController);
} }
@@ -379,6 +424,7 @@ interface ChatTurnOptions {
deadline: number; deadline: number;
toolMode: boolean; toolMode: boolean;
format: ResponseTextFormat; format: ResponseTextFormat;
attachmentReferences: ReadonlyMap<string, AttachmentReference>;
validation?: StructuredValidation; validation?: StructuredValidation;
writer?: ChatCompletionSseWriter; writer?: ChatCompletionSseWriter;
} }
@@ -391,7 +437,7 @@ class ChatStructuredOutputError extends Error {
} }
async function executeChatTurn(options: ChatTurnOptions): Promise<ChatExecution> { async function executeChatTurn(options: ChatTurnOptions): Promise<ChatExecution> {
const originalPrompt = buildCommandPrompt(options.request); const originalPrompt = buildCommandPrompt(options.request, options.attachmentReferences);
const constrained = options.toolMode || options.validation !== undefined; const constrained = options.toolMode || options.validation !== undefined;
const first = await runCommandCode( const first = await runCommandCode(
options.config, options.config,