适配图片
This commit is contained in:
@@ -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 URL;Responses 还支持 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 实时转发文本 delta,function calling 在完整决策校验后发送工具调用 SSE。
|
- 接受 `stream: true`;普通 Chat 实时转发文本 delta,function 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 文本 delta;function calling 会等待完整决策通过 JSON 与参数 Schema 校验后发送 `tool_calls`。Responses 只实现文本子集。图片、音频、文件输入、Computer Use、托管工具和服务端 Chat 会话均未实现。
|
普通 Chat SSE 实时转发 Command Code 文本 delta;function 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 |
|
||||||
|
|||||||
@@ -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.js:v22.22.2,符合 Command Code 的 Node.js 22+ 要求。
|
- Node.js:v22.22.2,符合 Command Code 的 Node.js 22+ 要求。
|
||||||
- 安装方式:`npm install --global command-code@latest`。
|
- 已安装 Command Code:v1.18.0;可执行文件是全局 npm 包的 `dist/index.mjs`。
|
||||||
- 已安装 Command Code:v1.10.0,npm `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-04;Responses 文本子集检查为 2026-08-05;Chat function calling 协议检查为 2026-08-06;Responses 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-04;Responses 文本子集检查为 2026-08-05;Chat function calling 协议检查为 2026-08-06;Responses 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 URL;Responses 还支持受限 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 token;Responses 缺失时返回 `null`,Chat Completions 为兼容旧行为返回 0。
|
- `usage` 使用 Command Code 最终结果提供的真实 input/output token;Responses 缺失时返回 `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 文本解析。
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user