init
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
.DS_Store
|
||||
@@ -0,0 +1,90 @@
|
||||
# Command Code OpenAI Bridge
|
||||
|
||||
Command Code OpenAI Bridge 是一个仅在本机运行的 OpenAI Chat Completions 兼容服务。它把 OpenAI 客户端发送的文本对话转交给 Command Code CLI 执行,并将最终 assistant 回复以标准 Chat Completions 格式返回。
|
||||
|
||||
这个项目没有git,所以也不需要使用GitNexus相关的skill
|
||||
|
||||
默认 API 地址:`http://127.0.0.1:18000/v1`
|
||||
|
||||
## 工作方式
|
||||
|
||||
每个请求都会启动一个独立的 Command Code headless agent:
|
||||
|
||||
1. 接收并校验 OpenAI Chat Completions 请求。
|
||||
2. 保留消息的角色、顺序和完整文本,将全部会话历史序列化后通过 stdin 传给 Command Code。
|
||||
3. 在启动服务的终端中显示模型状态、turn、工具调用、工具结果、stderr、最终回答和耗时。
|
||||
4. 从 Command Code 的 NDJSON 结构化结果中提取 `finalText`。
|
||||
5. 只把最终回复和可用的 token usage 返回给 API 客户端。
|
||||
|
||||
服务不保存跨请求会话。每次调用都使用 `--no-session`,对话历史由客户端维护并在请求中完整提供。
|
||||
|
||||
## 主要能力
|
||||
|
||||
- 提供 `GET /health`、`GET /v1/models` 和 `POST /v1/chat/completions`。
|
||||
- 支持字符串形式的 `content` 和 OpenAI 文本 part 数组。
|
||||
- 接受 `stream: true`,但 Command Code 调用仍为非流式;完整结果生成后再一次性封装为 SSE 事件返回。
|
||||
- 支持中文、Unicode、Markdown、代码块和较长文本。
|
||||
- 模型名称通过 YAML 配置映射到实际 Command Code 模型。
|
||||
- 请求内容通过 stdin 传输,不受命令行参数长度限制。
|
||||
- 同时只运行一个 Command Code 请求;忙碌时返回 HTTP 429。
|
||||
- 客户端断开、请求取消、总超时和 Ctrl+C 会终止当前子进程组。
|
||||
- 单次 Command Code 失败后,HTTP 服务可继续处理后续请求。
|
||||
- Authorization 请求头会被忽略,服务强制绑定 `127.0.0.1`。
|
||||
|
||||
当前只支持文本 Chat Completions。`stream: true` 是客户端兼容层,不会实时输出 Command Code 的生成过程。图片、音频、function calling、Responses API 和服务端会话均未实现。
|
||||
|
||||
## 技术实现
|
||||
|
||||
- Node.js 22+
|
||||
- TypeScript
|
||||
- Fastify
|
||||
- Zod
|
||||
- YAML
|
||||
- Command Code CLI 官方 headless 模式:`-p --output-format json`
|
||||
|
||||
项目使用普通子进程读取 Command Code 的 NDJSON 事件。终端输出由本项目渲染,可显示主要运行事件,但不包含 Command Code 原始 Ink TUI、动画和键盘交互。启用 `dangerously_skip_permissions` 时会向 CLI 传入 `--yolo`,只应在可信工作目录中使用。
|
||||
|
||||
## 项目结构
|
||||
|
||||
```text
|
||||
src/
|
||||
├── index.ts # 程序入口、服务启动和信号处理
|
||||
├── server.ts # HTTP 路由、单请求状态和错误处理
|
||||
├── openai.ts # 请求校验、消息转换和响应生成
|
||||
├── command-code.ts # CLI 检查、子进程管理和最终结果提取
|
||||
├── renderer.ts # Command Code 事件的终端显示
|
||||
└── config.ts # YAML 配置读取与校验
|
||||
|
||||
scripts/
|
||||
├── install.sh # 安装依赖并构建项目
|
||||
└── start.sh # 启动服务
|
||||
|
||||
examples/
|
||||
├── python_client.py # OpenAI Python SDK 示例
|
||||
└── node_client.mjs # OpenAI Node.js SDK 示例
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
./scripts/install.sh
|
||||
command-code login
|
||||
command-code status --json
|
||||
./scripts/start.sh
|
||||
```
|
||||
|
||||
默认配置位于 `config.yaml`,配置示例位于 `config.example.yaml`。常用配置包括监听端口、Command Code 可执行文件、工作目录、总超时、请求体上限、最大 turn 数、权限模式和模型映射。
|
||||
|
||||
调用示例:
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:18000/v1/chat/completions \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"model": "command-default",
|
||||
"messages": [{"role": "user", "content": "用一句话解释递归。"}],
|
||||
"stream": false
|
||||
}'
|
||||
```
|
||||
|
||||
更完整的安装、配置、接口说明、运行行为、检查结果和已知限制见 `README.md`。
|
||||
@@ -0,0 +1,220 @@
|
||||
# Command Code OpenAI Bridge
|
||||
|
||||
这是一个只绑定本机地址的 OpenAI Chat Completions 兼容转发服务。它为每个 API 请求启动一次独立的 Command Code headless agent,将完整消息历史通过 stdin 发送给 CLI,在当前终端显示运行过程,只把官方结构化结果中的 `finalText` 返回给客户端。
|
||||
|
||||
默认地址:`http://127.0.0.1:18000/v1`
|
||||
|
||||
## 当前机器上的调查结论
|
||||
|
||||
- 系统:macOS 26.4.1 arm64。
|
||||
- Node.js:v22.22.2,符合 Command Code 的 Node.js 22+ 要求。
|
||||
- 安装方式:`npm install --global command-code@latest`。
|
||||
- 已安装 Command Code:v1.10.0,npm `latest` 也是 v1.10.0。
|
||||
- 可执行文件:`command-code`;macOS/Linux 短名为 `cmd`,Windows 短名为 `cmdc`。
|
||||
- 登录:`command-code login`;检查:`command-code status --json`;本机已登录。
|
||||
- npm 包没有 `exports` 字段,`main` 指向会直接启动 CLI 的 `dist/cli.mjs`,包尾直接解析命令行,没有公开、稳定的可嵌入 API。
|
||||
- 官方 headless 调用:`command-code -p --output-format json`。不传查询参数时会从 stdin 读取。
|
||||
- JSON 输出是 NDJSON:运行中输出 `AgentEvent`,正常情况下最后输出唯一的 `result` 行;最终回答位于 `finalText`,usage 和耗时也在该行。
|
||||
- `--no-session` 让每次请求只使用内存会话,不写入或恢复跨请求 session。
|
||||
- 官方 headless 模式明确不提供键盘、问题回答或权限批准等交互。原始 TTY/TUI 与可靠的独立 `finalText` 目前不能同时获得。
|
||||
- 官方退出码:0 成功;1 常规错误;3 未登录;4 权限拒绝;5 限流;6 网络错误;7 服务端错误;8 达到 turn 上限;9 无回复;10 余额不足;130 被信号中断。
|
||||
|
||||
官方资料:
|
||||
|
||||
- [Quickstart](https://commandcode.ai/docs/quickstart)
|
||||
- [CLI Reference](https://commandcode.ai/docs/reference/cli)
|
||||
- [Headless Mode](https://commandcode.ai/docs/headless)
|
||||
- [Permissions](https://commandcode.ai/docs/core-concepts/permissions)
|
||||
|
||||
## 为什么使用普通子进程与结构化事件
|
||||
|
||||
本项目使用官方 headless 子进程和 NDJSON,不使用 PTY,也不解析 TUI 文本。
|
||||
|
||||
原因是 v1.10.0 没有公开嵌入 API,交互式 TUI 没有独立的结构化最终结果通道。headless 的最后一行提供稳定 `finalText`,能保证工具参数、工具结果、状态、stderr、思考事件和 ANSI 内容不会混入 API 回复。
|
||||
|
||||
终端会显示:请求开始、模型、turn 状态、工具事件、工具参数、工具结果、stderr、最终回答和耗时。终端不会显示原始 Ink TUI、动画、键盘快捷键、人工权限批准、人工问题回答和详细内部思考文本。
|
||||
|
||||
本机 v1.10.0 实测表明,headless 即使设为 `auto-accept`,shell 仍会被拒绝。默认 `dangerously_skip_permissions: true`,因此实际传入 `--yolo`,让文件写入和命令工具能继续执行。它会绕过所有权限确认,只应对可信工作目录使用。若要只读,把 `dangerously_skip_permissions` 改为 `false`,同时把 `permission_mode` 改成 `plan`。
|
||||
|
||||
## 项目结构
|
||||
|
||||
```text
|
||||
command-code-openai-bridge/
|
||||
├── config.yaml
|
||||
├── config.example.yaml
|
||||
├── package.json
|
||||
├── tsconfig.json
|
||||
├── src/
|
||||
│ ├── command-code.ts
|
||||
│ ├── config.ts
|
||||
│ ├── index.ts
|
||||
│ ├── openai.ts
|
||||
│ ├── renderer.ts
|
||||
│ └── server.ts
|
||||
├── scripts/
|
||||
│ ├── install.sh
|
||||
│ └── start.sh
|
||||
└── examples/
|
||||
├── node_client.mjs
|
||||
└── python_client.py
|
||||
```
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
cd /Users/zen/Documents/Codex/2026-08-04/files-mentioned-by-the-user-command/outputs/command-code-openai-bridge
|
||||
./scripts/install.sh
|
||||
```
|
||||
|
||||
脚本会检查 Node.js 版本、按需安装 Command Code、安装项目依赖并构建。
|
||||
|
||||
如未登录:
|
||||
|
||||
```bash
|
||||
command-code login
|
||||
command-code status --json
|
||||
```
|
||||
|
||||
`command-code login` 会打开浏览器,也可以粘贴从 Command Code Studio 创建的 API key。
|
||||
|
||||
## 配置
|
||||
|
||||
编辑 `config.yaml`:
|
||||
|
||||
```yaml
|
||||
host: 127.0.0.1
|
||||
port: 18000
|
||||
command_code_executable: command-code
|
||||
command_code_working_directory: .
|
||||
timeout_seconds: 1800
|
||||
max_request_bytes: 20971520
|
||||
max_turns: 100
|
||||
permission_mode: auto-accept
|
||||
dangerously_skip_permissions: true
|
||||
|
||||
models:
|
||||
command-default:
|
||||
cli_model: deepseek/deepseek-v4-flash
|
||||
effort: max
|
||||
```
|
||||
|
||||
`command_code_working_directory` 决定 Command Code 能看到和操作的项目目录。相对路径以 `config.yaml` 所在目录为基准。`models.<name>.effort` 会传给 Command Code 的 `--effort`,可用值取决于对应模型。服务强制只监听 `127.0.0.1`。客户端只能选择配置中的模型名,不能注入额外 CLI 参数。
|
||||
|
||||
## 启动、停止与重启
|
||||
|
||||
```bash
|
||||
./scripts/start.sh
|
||||
```
|
||||
|
||||
`scripts/start.sh` 只运行已经生成的 `dist/index.js`,不会自动编译 TypeScript。修改 `src/` 中的源码后,必须先重新编译,再重启服务,新代码才会生效:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
./scripts/start.sh
|
||||
```
|
||||
|
||||
首次执行 `scripts/install.sh` 时会自动安装依赖并完成一次编译。
|
||||
|
||||
空闲时按 Ctrl+C 停止。请求运行时第一次 Ctrl+C 取消当前 Command Code 子进程组并保留服务;请求结束后再按 Ctrl+C 停止服务。重启就是再次执行启动脚本。
|
||||
|
||||
客户端断开连接也会先给整个 Command Code 子进程组发送 SIGINT,随后按需升级为 SIGTERM 和 SIGKILL。总超时由 `timeout_seconds` 控制。
|
||||
|
||||
## API
|
||||
|
||||
- `GET /health`
|
||||
- `GET /v1/models`
|
||||
- `POST /v1/chat/completions`
|
||||
|
||||
Bearer Token 会被忽略。只接受文本消息。支持字符串 `content`,也支持由 `{ "type": "text", "text": "..." }` 组成的数组。图片、音频和其他 part 会返回 400。
|
||||
|
||||
`stream` 省略或设为 `false` 时返回普通 JSON。`stream: true` 时,服务仍会等待 Command Code 完整执行结束,再将最终文本一次性封装为 Chat Completions SSE 事件返回;它不提供实时生成过程。传入 `stream_options.include_usage: true` 时,结束前还会返回 usage 块。
|
||||
|
||||
### curl
|
||||
|
||||
```bash
|
||||
curl -sS http://127.0.0.1:18000/v1/chat/completions \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H 'Authorization: Bearer ignored' \
|
||||
-d '{
|
||||
"model": "command-default",
|
||||
"messages": [
|
||||
{"role": "user", "content": "用一句话解释递归。"}
|
||||
],
|
||||
"stream": false
|
||||
}'
|
||||
```
|
||||
|
||||
### OpenAI Python SDK
|
||||
|
||||
```bash
|
||||
python3 -m venv .venv
|
||||
.venv/bin/pip install openai
|
||||
.venv/bin/python examples/python_client.py
|
||||
```
|
||||
|
||||
### OpenAI Node.js SDK
|
||||
|
||||
项目依赖已包含 `openai`:
|
||||
|
||||
```bash
|
||||
node examples/node_client.mjs
|
||||
```
|
||||
|
||||
## 实际 Command Code 命令
|
||||
|
||||
每次请求实际执行以下固定参数,完整提示词不进入命令行:
|
||||
|
||||
```bash
|
||||
command-code \
|
||||
-p \
|
||||
--output-format json \
|
||||
--no-session \
|
||||
--skip-onboarding \
|
||||
--no-auto-update \
|
||||
--trust \
|
||||
--max-turns 100 \
|
||||
--model deepseek/deepseek-v4-flash \
|
||||
--effort max \
|
||||
--yolo
|
||||
```
|
||||
|
||||
随后通过子进程 stdin 写入 UTF-8 的完整请求历史。消息序列被放进一个 JSON 对象,角色、顺序、空消息、Unicode、Markdown、代码块和自定义分隔符均不会被简单文本分隔符破坏。服务不总结、不删除、不截断消息,也不保存会话。
|
||||
|
||||
## 单并发和错误
|
||||
|
||||
同一时间只允许一个 Command Code 实例。第二个请求直接返回 HTTP 429 和 `code: busy`。请求体超过 `max_request_bytes` 返回 413。未知模型和非文本内容返回 4xx。CLI 的详细错误留在服务终端,客户端只收到简洁的 OpenAI 格式错误。
|
||||
|
||||
一次 CLI 异常不会结束 HTTP 服务,后续请求仍可继续。
|
||||
|
||||
## 本机实际检查结果
|
||||
|
||||
检查日期:2026-08-04。没有编写测试用例,以下均为构建后运行真实 CLI 和真实 HTTP 客户端得到的端到端结果。
|
||||
|
||||
- TypeScript 严格类型检查和生产构建通过。
|
||||
- npm 生产依赖审计:0 个已知漏洞。
|
||||
- `/health` 返回 Command Code v1.10.0、已登录、空闲。
|
||||
- `/v1/models` 返回两个本地映射。
|
||||
- curl 中文请求返回 HTTP 200,客户端只收到 `中文接口成功` 和标准 completion 字段。
|
||||
- 字符串 content 与 text part 数组均通过;非文本 part 返回 HTTP 400。
|
||||
- system、user、assistant、user 完整历史检查返回了历史 assistant 中的 `蓝鲸-42`。
|
||||
- 约 96 KiB 的中文消息通过 stdin 完整传入并返回 `长文本回退成功`,没有经过命令行参数。
|
||||
- read_file 工具调用、参数和文件结果显示在服务终端,API 只返回 package name。
|
||||
- `--yolo` 下 shell_command 实际执行并返回 `shell-tool-ok`;`auto-accept` 下该工具确实被 headless 权限引擎拒绝。
|
||||
- OpenAI Node.js SDK 示例通过。
|
||||
- OpenAI Python SDK 示例通过。
|
||||
- `stream: false` 返回普通 JSON;`stream: true` 在完整结果生成后一次性返回兼容 SSE。
|
||||
- 并发检查中第二个请求返回 HTTP 429 和 `code: busy`,没有启动第二个 CLI。
|
||||
- 超过 20 MiB 的请求体返回 HTTP 413,服务保持可用。
|
||||
- 客户端 1 秒超时断开后,当前子进程组被清理,`busy` 恢复为 false,没有发现残留 headless CLI。
|
||||
- 运行中按 Ctrl+C 后客户端收到 HTTP 499,服务保持运行;紧接着的真实请求返回 `中断后恢复成功`。
|
||||
- 原始交互模式的本机 TTY 探测确认 Ink TUI、ANSI、输入框和双 Ctrl+C 退出行为存在;该模式没有独立结构化最终结果通道。
|
||||
|
||||
## 已知限制
|
||||
|
||||
- 没有原始 Command Code TUI、颜色布局、动画和键盘交互。
|
||||
- headless 无法在服务终端进行批准、拒绝、选项选择或文字回答;`ask_user_question` 不能由等待中的 HTTP 客户端处理。
|
||||
- 终端事件渲染由本项目完成,格式接近日志,无法等同原始 TUI。
|
||||
- API 只实现 Chat Completions 文本范围;`stream: true` 是最终结果的 SSE 兼容封装,不是实时生成。不实现 Responses API、图片、音频、function calling 或服务端会话。
|
||||
- `usage` 使用 Command Code 最终结果提供的真实 input/output token;CLI 未提供时返回 0。
|
||||
- 长文本受 HTTP 请求体上限和 Command Code 模型上下文上限共同限制,不会由桥接服务自行截断。
|
||||
- v1.10.0 的大输入会让 `run_end.nextState` 重复完整提示词。实测约 96 KiB 中文输入时,CLI 退出前可能截断该大事件并丢掉紧随其后的 compact `result` 行。桥接服务会忽略冗余 `run_end`,优先使用 `result.finalText`;若退出码为 0 且 result 缺失,只使用最后一个已完整结束、无工具调用的结构化 turn 文本和真实 turn usage,不从 TUI 文本解析。
|
||||
@@ -0,0 +1,17 @@
|
||||
host: 127.0.0.1
|
||||
port: 18000
|
||||
command_code_executable: command-code
|
||||
command_code_working_directory: .
|
||||
timeout_seconds: 1800
|
||||
max_request_bytes: 20971520
|
||||
max_turns: 100
|
||||
permission_mode: auto-accept
|
||||
dangerously_skip_permissions: true
|
||||
|
||||
models:
|
||||
command-default:
|
||||
cli_model: deepseek/deepseek-v4-flash
|
||||
effort: max
|
||||
command-fast:
|
||||
cli_model: deepseek/deepseek-v4-flash
|
||||
effort: low
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
host: 127.0.0.1
|
||||
port: 18000
|
||||
command_code_executable: command-code
|
||||
command_code_working_directory: .
|
||||
timeout_seconds: 1800
|
||||
max_request_bytes: 20971520
|
||||
max_turns: 100
|
||||
permission_mode: auto-accept
|
||||
dangerously_skip_permissions: true
|
||||
|
||||
models:
|
||||
command-default:
|
||||
cli_model: deepseek/deepseek-v4-flash
|
||||
effort: high
|
||||
command-pro:
|
||||
cli_model: deepseek/deepseek-v4-pro
|
||||
effort: max
|
||||
@@ -0,0 +1,17 @@
|
||||
import OpenAI from "openai";
|
||||
|
||||
const client = new OpenAI({
|
||||
baseURL: "http://127.0.0.1:18000/v1",
|
||||
apiKey: "ignored",
|
||||
});
|
||||
|
||||
const response = await client.chat.completions.create({
|
||||
model: "command-default",
|
||||
messages: [
|
||||
{ role: "system", content: "回答要简洁。" },
|
||||
{ role: "user", content: "用一句话解释递归。" },
|
||||
],
|
||||
stream: false,
|
||||
});
|
||||
|
||||
console.log(response.choices[0].message.content);
|
||||
@@ -0,0 +1,14 @@
|
||||
from openai import OpenAI
|
||||
|
||||
client = OpenAI(base_url="http://127.0.0.1:18000/v1", api_key="ignored")
|
||||
|
||||
response = client.chat.completions.create(
|
||||
model="command-default",
|
||||
messages=[
|
||||
{"role": "system", "content": "回答要简洁。"},
|
||||
{"role": "user", "content": "用一句话解释递归。"},
|
||||
],
|
||||
stream=False,
|
||||
)
|
||||
|
||||
print(response.choices[0].message.content)
|
||||
Generated
+1252
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "command-code-openai-bridge",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Local OpenAI Chat Completions compatible bridge for Command Code CLI",
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"check": "tsc -p tsconfig.json --noEmit",
|
||||
"dev": "tsx src/index.ts --config config.yaml",
|
||||
"start": "node dist/index.js --config config.yaml"
|
||||
},
|
||||
"dependencies": {
|
||||
"fastify": "^5.5.0",
|
||||
"openai": "^5.19.1",
|
||||
"yaml": "^2.8.1",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.3.0",
|
||||
"tsx": "^4.20.5",
|
||||
"typescript": "^5.9.2"
|
||||
}
|
||||
}
|
||||
Executable
+27
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
project_dir=$(cd "$(dirname "$0")/.." && pwd)
|
||||
cd "$project_dir"
|
||||
|
||||
if ! command -v node >/dev/null 2>&1; then
|
||||
echo "需要 Node.js 22 或更高版本。" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
node_major=$(node -p 'Number(process.versions.node.split(".")[0])')
|
||||
if [ "$node_major" -lt 22 ]; then
|
||||
echo "当前 Node.js 版本过低:$(node --version),需要 22 或更高版本。" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v command-code >/dev/null 2>&1; then
|
||||
npm install --global command-code@latest
|
||||
fi
|
||||
|
||||
npm install
|
||||
npm run build
|
||||
|
||||
echo "安装完成。"
|
||||
echo "如未登录,请运行:command-code login"
|
||||
echo "登录状态:command-code status --json"
|
||||
Executable
+6
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
project_dir=$(cd "$(dirname "$0")/.." && pwd)
|
||||
cd "$project_dir"
|
||||
exec node dist/index.js --config config.yaml
|
||||
@@ -0,0 +1,283 @@
|
||||
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import { createInterface } from "node:readline";
|
||||
import type { BridgeConfig } from "./config.js";
|
||||
import type { CommandUsage } from "./openai.js";
|
||||
import { TerminalRenderer } from "./renderer.js";
|
||||
|
||||
interface ResultFrame {
|
||||
type: "result";
|
||||
subtype: "success" | "error" | "max_turns" | string;
|
||||
finalText: string;
|
||||
durationMs: number;
|
||||
usage?: CommandUsage;
|
||||
error?: unknown;
|
||||
}
|
||||
|
||||
export interface CommandResult {
|
||||
finalText: string;
|
||||
durationMs: number;
|
||||
usage?: CommandUsage;
|
||||
}
|
||||
|
||||
export interface InstallationStatus {
|
||||
installed: boolean;
|
||||
authenticated: boolean;
|
||||
version?: string;
|
||||
}
|
||||
|
||||
export class CommandCodeError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly exitCode?: number | null,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "CommandCodeError";
|
||||
}
|
||||
}
|
||||
|
||||
function signalProcessGroup(child: ChildProcessWithoutNullStreams, signal: NodeJS.Signals): void {
|
||||
if (child.pid === undefined) return;
|
||||
try {
|
||||
if (process.platform === "win32") child.kill(signal);
|
||||
else process.kill(-child.pid, signal);
|
||||
} catch {
|
||||
try { child.kill(signal); } catch { /* process already exited */ }
|
||||
}
|
||||
}
|
||||
|
||||
function terminateChild(child: ChildProcessWithoutNullStreams): () => void {
|
||||
signalProcessGroup(child, "SIGINT");
|
||||
const termTimer = setTimeout(() => signalProcessGroup(child, "SIGTERM"), 2_000);
|
||||
const killTimer = setTimeout(() => signalProcessGroup(child, "SIGKILL"), 7_000);
|
||||
termTimer.unref();
|
||||
killTimer.unref();
|
||||
return () => {
|
||||
clearTimeout(termTimer);
|
||||
clearTimeout(killTimer);
|
||||
};
|
||||
}
|
||||
|
||||
function parseFrame(line: string): Record<string, unknown> | undefined {
|
||||
if (line.trim() === "") return undefined;
|
||||
const value: unknown = JSON.parse(line);
|
||||
if (typeof value !== "object" || value === null) throw new Error("NDJSON line is not an object");
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export async function runCommandCode(
|
||||
config: BridgeConfig,
|
||||
cliModel: string,
|
||||
effort: string,
|
||||
prompt: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<CommandResult> {
|
||||
const args = [
|
||||
"-p",
|
||||
"--output-format", "json",
|
||||
"--no-session",
|
||||
"--skip-onboarding",
|
||||
"--no-auto-update",
|
||||
"--trust",
|
||||
"--max-turns", String(config.max_turns),
|
||||
"--model", cliModel,
|
||||
"--effort", effort,
|
||||
];
|
||||
|
||||
if (config.dangerously_skip_permissions) args.push("--yolo");
|
||||
else args.push("--permission-mode", config.permission_mode);
|
||||
|
||||
const renderer = new TerminalRenderer();
|
||||
renderer.begin(cliModel, effort);
|
||||
|
||||
return new Promise<CommandResult>((resolve, reject) => {
|
||||
const startedAt = Date.now();
|
||||
let child: ChildProcessWithoutNullStreams;
|
||||
try {
|
||||
child = spawn(config.command_code_executable, args, {
|
||||
cwd: config.resolvedWorkingDirectory,
|
||||
env: process.env,
|
||||
detached: process.platform !== "win32",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
} catch (error) {
|
||||
reject(new CommandCodeError(`CLI 启动失败:${String(error)}`));
|
||||
return;
|
||||
}
|
||||
|
||||
let settled = false;
|
||||
let result: ResultFrame | undefined;
|
||||
let parseError: Error | undefined;
|
||||
let stderr = "";
|
||||
let cancelEscalation: (() => void) | undefined;
|
||||
let lastMessageText: string | undefined;
|
||||
let finalTurnText: string | undefined;
|
||||
const eventUsage: Required<CommandUsage> = { inputTokens: 0, outputTokens: 0 };
|
||||
|
||||
const finish = (error?: Error, value?: CommandResult) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
cancelEscalation?.();
|
||||
if (error) reject(error);
|
||||
else resolve(value!);
|
||||
};
|
||||
|
||||
const onAbort = () => {
|
||||
cancelEscalation ??= terminateChild(child);
|
||||
};
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
renderer.fail(`超过 ${config.timeout_seconds} 秒总超时`);
|
||||
cancelEscalation ??= terminateChild(child);
|
||||
}, config.timeout_seconds * 1000);
|
||||
timeout.unref();
|
||||
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
if (signal.aborted) onAbort();
|
||||
|
||||
child.once("error", (error) => {
|
||||
renderer.fail(error.message);
|
||||
finish(new CommandCodeError(`CLI 启动失败:${error.message}`));
|
||||
});
|
||||
|
||||
child.stderr.setEncoding("utf8");
|
||||
child.stderr.on("data", (chunk: string) => {
|
||||
stderr += chunk;
|
||||
if (stderr.length > 64 * 1024) stderr = stderr.slice(-64 * 1024);
|
||||
renderer.stderr(chunk);
|
||||
});
|
||||
|
||||
child.stdout.setEncoding("utf8");
|
||||
const lines = createInterface({ input: child.stdout, crlfDelay: Infinity });
|
||||
lines.on("line", (line) => {
|
||||
try {
|
||||
// run_end carries nextState, which repeats the complete prompt and can be very large.
|
||||
// The following result line is the authoritative compact result.
|
||||
if (line.startsWith('{"type":"event","event":{"type":"run_end"')) return;
|
||||
const frame = parseFrame(line);
|
||||
if (!frame) return;
|
||||
if (frame.type === "event" && typeof frame.event === "object" && frame.event !== null) {
|
||||
const event = frame.event as Record<string, unknown>;
|
||||
if (event.type === "message_end" && Array.isArray(event.content)) {
|
||||
const textParts = event.content
|
||||
.filter((part): part is { type: string; text?: unknown } => (
|
||||
typeof part === "object" && part !== null && "type" in part
|
||||
))
|
||||
.filter((part) => part.type === "text" && typeof part.text === "string")
|
||||
.map((part) => String(part.text));
|
||||
lastMessageText = textParts.length > 0 ? textParts.join("") : undefined;
|
||||
}
|
||||
if (event.type === "turn_end") {
|
||||
if (event.hadToolCalls === false && lastMessageText !== undefined) {
|
||||
finalTurnText = lastMessageText;
|
||||
}
|
||||
if (typeof event.usage === "object" && event.usage !== null) {
|
||||
const usage = event.usage as CommandUsage;
|
||||
eventUsage.inputTokens += usage.inputTokens ?? 0;
|
||||
eventUsage.outputTokens += usage.outputTokens ?? 0;
|
||||
}
|
||||
lastMessageText = undefined;
|
||||
}
|
||||
renderer.event(event);
|
||||
} else if (frame.type === "result") {
|
||||
result = frame as unknown as ResultFrame;
|
||||
}
|
||||
} catch (error) {
|
||||
parseError = error instanceof Error ? error : new Error(String(error));
|
||||
process.stderr.write(`\n无法解析 Command Code NDJSON:${line.slice(0, 500)}\n`);
|
||||
}
|
||||
});
|
||||
|
||||
child.once("close", (code, closeSignal) => {
|
||||
lines.close();
|
||||
|
||||
if (signal.aborted) {
|
||||
renderer.fail("请求已取消");
|
||||
finish(new CommandCodeError("Command Code request cancelled", code));
|
||||
return;
|
||||
}
|
||||
|
||||
if (code !== 0) {
|
||||
const detail = stderr.trim().split("\n").slice(-3).join(" | ");
|
||||
renderer.fail(`退出码 ${String(code)}${closeSignal ? `,信号 ${closeSignal}` : ""}${detail ? `:${detail}` : ""}`);
|
||||
finish(new CommandCodeError("Command Code request failed", code));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
if (finalTurnText !== undefined) {
|
||||
const durationMs = Date.now() - startedAt;
|
||||
process.stderr.write("\n警告:Command Code 未输出最终 result 行,已使用最后一个完整结构化 turn 的文本。\n");
|
||||
renderer.finish(durationMs);
|
||||
finish(undefined, {
|
||||
finalText: finalTurnText,
|
||||
durationMs,
|
||||
usage: eventUsage,
|
||||
});
|
||||
return;
|
||||
}
|
||||
renderer.fail(parseError ? `结构化事件解析失败:${parseError.message}` : "没有收到最终 result 行");
|
||||
finish(new CommandCodeError("Unable to identify final Command Code response", code));
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.subtype !== "success") {
|
||||
renderer.fail(`结果状态 ${result.subtype}`);
|
||||
finish(new CommandCodeError("Command Code request failed", code));
|
||||
return;
|
||||
}
|
||||
|
||||
renderer.finish(result.durationMs);
|
||||
finish(undefined, {
|
||||
finalText: result.finalText,
|
||||
durationMs: result.durationMs,
|
||||
...(result.usage ? { usage: result.usage } : {}),
|
||||
});
|
||||
});
|
||||
|
||||
child.stdin.on("error", (error: NodeJS.ErrnoException) => {
|
||||
if (error.code !== "EPIPE") renderer.fail(`stdin 错误:${error.message}`);
|
||||
});
|
||||
child.stdin.end(prompt, "utf8");
|
||||
});
|
||||
}
|
||||
|
||||
function capture(executable: string, args: string[], timeoutMs = 10_000): Promise<{ code: number | null; stdout: string }> {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(executable, args, { stdio: ["ignore", "pipe", "ignore"] });
|
||||
let stdout = "";
|
||||
const timer = setTimeout(() => child.kill("SIGKILL"), timeoutMs);
|
||||
timer.unref();
|
||||
child.stdout.setEncoding("utf8");
|
||||
child.stdout.on("data", (chunk: string) => { stdout += chunk; });
|
||||
child.on("error", () => {
|
||||
clearTimeout(timer);
|
||||
resolve({ code: null, stdout: "" });
|
||||
});
|
||||
child.on("close", (code) => {
|
||||
clearTimeout(timer);
|
||||
resolve({ code, stdout });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function inspectInstallation(executable: string): Promise<InstallationStatus> {
|
||||
const versionResult = await capture(executable, ["--version"]);
|
||||
if (versionResult.code !== 0) return { installed: false, authenticated: false };
|
||||
|
||||
const statusResult = await capture(executable, ["status", "--json"]);
|
||||
let authenticated = false;
|
||||
try {
|
||||
const status = JSON.parse(statusResult.stdout) as { authenticated?: boolean };
|
||||
authenticated = statusResult.code === 0 && status.authenticated === true;
|
||||
} catch {
|
||||
authenticated = false;
|
||||
}
|
||||
|
||||
return {
|
||||
installed: true,
|
||||
authenticated,
|
||||
version: versionResult.stdout.trim(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import YAML from "yaml";
|
||||
import { z } from "zod";
|
||||
|
||||
const modelSchema = z.object({
|
||||
cli_model: z.string().min(1),
|
||||
effort: z.string().min(1),
|
||||
});
|
||||
|
||||
const configSchema = z.object({
|
||||
host: z.literal("127.0.0.1").default("127.0.0.1"),
|
||||
port: z.number().int().min(1).max(65535).default(18000),
|
||||
command_code_executable: z.string().min(1).default("command-code"),
|
||||
command_code_working_directory: z.string().min(1).default("."),
|
||||
timeout_seconds: z.number().int().positive().default(1800),
|
||||
max_request_bytes: z.number().int().positive().default(20 * 1024 * 1024),
|
||||
max_turns: z.number().int().positive().default(100),
|
||||
permission_mode: z.enum(["default", "standard", "plan", "auto-accept", "dont-ask"]).default("auto-accept"),
|
||||
dangerously_skip_permissions: z.boolean().default(false),
|
||||
models: z.record(z.string().min(1), modelSchema).refine(
|
||||
(models) => Object.keys(models).length > 0,
|
||||
"At least one model mapping is required",
|
||||
),
|
||||
});
|
||||
|
||||
export type BridgeConfig = z.infer<typeof configSchema> & {
|
||||
configDirectory: string;
|
||||
resolvedWorkingDirectory: string;
|
||||
};
|
||||
|
||||
export async function loadConfig(configPath: string): Promise<BridgeConfig> {
|
||||
const absolutePath = path.resolve(configPath);
|
||||
const source = await readFile(absolutePath, "utf8");
|
||||
const parsed = configSchema.parse(YAML.parse(source));
|
||||
const configDirectory = path.dirname(absolutePath);
|
||||
|
||||
return {
|
||||
...parsed,
|
||||
configDirectory,
|
||||
resolvedWorkingDirectory: path.resolve(configDirectory, parsed.command_code_working_directory),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env node
|
||||
import path from "node:path";
|
||||
import { loadConfig } from "./config.js";
|
||||
import { createServer } from "./server.js";
|
||||
|
||||
function configPathFromArgs(args: string[]): string {
|
||||
const index = args.indexOf("--config");
|
||||
if (index === -1) return "config.yaml";
|
||||
const value = args[index + 1];
|
||||
if (!value) throw new Error("--config requires a file path");
|
||||
return value;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const configPath = path.resolve(configPathFromArgs(process.argv.slice(2)));
|
||||
const config = await loadConfig(configPath);
|
||||
const bridge = await createServer(config);
|
||||
|
||||
if (!bridge.installation.installed) {
|
||||
process.stderr.write(`警告:找不到 ${config.command_code_executable},API 将返回 503。\n`);
|
||||
} else if (!bridge.installation.authenticated) {
|
||||
process.stderr.write("警告:Command Code 未登录。运行 command-code login 后重启服务。\n");
|
||||
}
|
||||
|
||||
await bridge.app.listen({ host: config.host, port: config.port });
|
||||
process.stdout.write(`Command Code OpenAI Bridge 已启动\n`);
|
||||
process.stdout.write(`API: http://${config.host}:${config.port}/v1\n`);
|
||||
process.stdout.write(`工作目录: ${config.resolvedWorkingDirectory}\n`);
|
||||
process.stdout.write(`Command Code: ${bridge.installation.version ?? "unavailable"}\n`);
|
||||
process.stdout.write(`权限模式: ${config.dangerously_skip_permissions ? "yolo" : config.permission_mode}\n`);
|
||||
process.stdout.write("空闲时按 Ctrl+C 停止;请求运行中第一次 Ctrl+C 只取消当前请求。\n");
|
||||
|
||||
let shuttingDown = false;
|
||||
const shutdown = async (signal: NodeJS.Signals) => {
|
||||
if (bridge.abortActive()) {
|
||||
process.stderr.write(`\n收到 ${signal},正在取消当前 Command Code 请求。\n`);
|
||||
return;
|
||||
}
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
process.stdout.write(`\n收到 ${signal},正在停止服务。\n`);
|
||||
await bridge.app.close();
|
||||
process.exitCode = 0;
|
||||
};
|
||||
|
||||
process.on("SIGINT", () => { void shutdown("SIGINT"); });
|
||||
process.on("SIGTERM", () => { void shutdown("SIGTERM"); });
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
process.stderr.write(`${error instanceof Error ? error.stack : String(error)}\n`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { z } from "zod";
|
||||
|
||||
const textPartSchema = z.object({
|
||||
type: z.literal("text"),
|
||||
text: z.string(),
|
||||
}).strict();
|
||||
|
||||
const messageSchema = z.object({
|
||||
role: z.enum(["system", "user", "assistant"]),
|
||||
content: z.union([z.string(), z.array(textPartSchema)]),
|
||||
}).strict();
|
||||
|
||||
export const chatCompletionRequestSchema = z.object({
|
||||
model: z.string().min(1),
|
||||
messages: z.array(messageSchema).min(1),
|
||||
stream: z.boolean().optional().default(false),
|
||||
stream_options: z.object({
|
||||
include_usage: z.boolean().optional().default(false),
|
||||
}).passthrough().optional(),
|
||||
}).passthrough();
|
||||
|
||||
export type ChatCompletionRequest = z.infer<typeof chatCompletionRequestSchema>;
|
||||
|
||||
export interface CommandUsage {
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
}
|
||||
|
||||
export function normalizeMessages(request: ChatCompletionRequest) {
|
||||
return request.messages.map((message) => ({
|
||||
role: message.role,
|
||||
content: typeof message.content === "string"
|
||||
? message.content
|
||||
: message.content.map((part) => part.text).join(""),
|
||||
}));
|
||||
}
|
||||
|
||||
export function buildCommandPrompt(request: ChatCompletionRequest): string {
|
||||
const envelope = {
|
||||
protocol: "openai-chat-completions-history-v1",
|
||||
messages: normalizeMessages(request),
|
||||
};
|
||||
|
||||
return [
|
||||
"下面 JSON 对象的 messages 数组是外部客户端提交的完整任务。",
|
||||
"严格执行 system 消息和最后一条 user 消息中的具体指令,并结合此前消息理解上下文。",
|
||||
"调用方可能要求改写问题、生成检索词、提取数据、分类或输出特定格式。这些属于内部处理任务,也必须严格执行。",
|
||||
"如果最后一条 user 消息要求改写、压缩、提取或输出指定格式,只返回要求的结果,不回答消息中包含的问题。",
|
||||
"不要复述 JSON,不要输出角色标签,不要添加指令未要求的解释或格式。",
|
||||
"JSON 数据开始:",
|
||||
JSON.stringify(envelope),
|
||||
].join("\n\n");
|
||||
}
|
||||
|
||||
export function completionResponse(model: string, content: string, usage?: CommandUsage) {
|
||||
const promptTokens = usage?.inputTokens ?? 0;
|
||||
const completionTokens = usage?.outputTokens ?? 0;
|
||||
|
||||
return {
|
||||
id: `chatcmpl-local-${randomUUID().replaceAll("-", "")}`,
|
||||
object: "chat.completion",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model,
|
||||
choices: [{
|
||||
index: 0,
|
||||
message: { role: "assistant", content },
|
||||
finish_reason: "stop",
|
||||
}],
|
||||
usage: {
|
||||
prompt_tokens: promptTokens,
|
||||
completion_tokens: completionTokens,
|
||||
total_tokens: promptTokens + completionTokens,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function completionStreamResponse(
|
||||
model: string,
|
||||
content: string,
|
||||
usage?: CommandUsage,
|
||||
includeUsage = false,
|
||||
): string {
|
||||
const id = `chatcmpl-local-${randomUUID().replaceAll("-", "")}`;
|
||||
const created = Math.floor(Date.now() / 1000);
|
||||
const base = { id, object: "chat.completion.chunk", created, model };
|
||||
const chunks: object[] = [
|
||||
{
|
||||
...base,
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: { role: "assistant", content: "" },
|
||||
finish_reason: null,
|
||||
}],
|
||||
},
|
||||
{
|
||||
...base,
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: { content },
|
||||
finish_reason: null,
|
||||
}],
|
||||
},
|
||||
{
|
||||
...base,
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: {},
|
||||
finish_reason: "stop",
|
||||
}],
|
||||
},
|
||||
];
|
||||
|
||||
if (includeUsage) {
|
||||
const promptTokens = usage?.inputTokens ?? 0;
|
||||
const completionTokens = usage?.outputTokens ?? 0;
|
||||
chunks.push({
|
||||
...base,
|
||||
choices: [],
|
||||
usage: {
|
||||
prompt_tokens: promptTokens,
|
||||
completion_tokens: completionTokens,
|
||||
total_tokens: promptTokens + completionTokens,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return `${chunks.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`).join("")}data: [DONE]\n\n`;
|
||||
}
|
||||
|
||||
export function openAIError(message: string, code: string, type = "invalid_request_error") {
|
||||
return { error: { message, type, code } };
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { inspect } from "node:util";
|
||||
|
||||
const tty = process.stdout.isTTY;
|
||||
const color = (code: number, text: string) => tty ? `\u001b[${code}m${text}\u001b[0m` : text;
|
||||
const cyan = (text: string) => color(36, text);
|
||||
const green = (text: string) => color(32, text);
|
||||
const yellow = (text: string) => color(33, text);
|
||||
const dim = (text: string) => color(2, text);
|
||||
|
||||
function details(event: Record<string, unknown>): string {
|
||||
const copy = { ...event };
|
||||
delete copy.type;
|
||||
return inspect(copy, { colors: tty, depth: 8, compact: false, breakLength: 120 });
|
||||
}
|
||||
|
||||
export class TerminalRenderer {
|
||||
private answerStarted = false;
|
||||
private thinkingShown = false;
|
||||
|
||||
begin(model: string, effort: string): void {
|
||||
this.answerStarted = false;
|
||||
this.thinkingShown = false;
|
||||
process.stdout.write(`\n${cyan("━━━━━━━━ Command Code 请求 ━━━━━━━━")}\n`);
|
||||
process.stdout.write(`${dim("模型")} ${model}\n`);
|
||||
process.stdout.write(`${dim("思考深度")} ${effort}\n`);
|
||||
}
|
||||
|
||||
event(event: Record<string, unknown>): void {
|
||||
const type = typeof event.type === "string" ? event.type : "unknown";
|
||||
|
||||
switch (type) {
|
||||
case "run_start":
|
||||
process.stdout.write(`${dim("会话")} ${String(event.sessionId ?? "-")}\n`);
|
||||
return;
|
||||
case "turn_start":
|
||||
process.stdout.write(`${cyan(`\n[Turn ${String(event.turnNumber ?? "?")}]`)}\n`);
|
||||
return;
|
||||
case "model_request_start":
|
||||
process.stdout.write(`${dim("模型请求开始")} ${String(event.model ?? "")}\n`);
|
||||
return;
|
||||
case "thinking_start":
|
||||
if (!this.thinkingShown) {
|
||||
process.stdout.write(`${dim("思考中…")}\n`);
|
||||
this.thinkingShown = true;
|
||||
}
|
||||
return;
|
||||
case "thinking_delta":
|
||||
case "thinking_end":
|
||||
case "message_update":
|
||||
case "model_trace":
|
||||
case "run_end":
|
||||
return;
|
||||
case "text_delta": {
|
||||
if (!this.answerStarted) {
|
||||
process.stdout.write(`${green("\n最终回答:")}\n`);
|
||||
this.answerStarted = true;
|
||||
}
|
||||
process.stdout.write(String(event.delta ?? ""));
|
||||
return;
|
||||
}
|
||||
case "model_request_end":
|
||||
process.stdout.write(`\n${dim(`模型请求结束 · ${String(event.stopReason ?? "unknown")}`)}\n`);
|
||||
return;
|
||||
case "turn_end":
|
||||
process.stdout.write(`${dim(`Turn ${String(event.turnNumber ?? "?")} 结束`)}\n`);
|
||||
return;
|
||||
default:
|
||||
if (type.includes("tool") || type.includes("permission") || type.includes("question")) {
|
||||
process.stdout.write(`${yellow(`\n[${type}]`)}\n${details(event)}\n`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stderr(chunk: string): void {
|
||||
process.stderr.write(chunk);
|
||||
}
|
||||
|
||||
finish(durationMs: number): void {
|
||||
if (this.answerStarted) process.stdout.write("\n");
|
||||
process.stdout.write(`${dim(`完成 · ${(durationMs / 1000).toFixed(2)}s`)}\n`);
|
||||
process.stdout.write(`${cyan("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")}\n\n`);
|
||||
}
|
||||
|
||||
fail(message: string): void {
|
||||
process.stderr.write(`${color(31, `\nCommand Code 失败:${message}`)}\n`);
|
||||
}
|
||||
}
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
import Fastify, { type FastifyInstance } from "fastify";
|
||||
import { ZodError } from "zod";
|
||||
import type { BridgeConfig } from "./config.js";
|
||||
import {
|
||||
CommandCodeError,
|
||||
inspectInstallation,
|
||||
runCommandCode,
|
||||
type InstallationStatus,
|
||||
} from "./command-code.js";
|
||||
import {
|
||||
buildCommandPrompt,
|
||||
chatCompletionRequestSchema,
|
||||
completionResponse,
|
||||
completionStreamResponse,
|
||||
openAIError,
|
||||
} from "./openai.js";
|
||||
|
||||
interface ActiveRequest {
|
||||
abortController: AbortController;
|
||||
}
|
||||
|
||||
export interface BridgeServer {
|
||||
app: FastifyInstance;
|
||||
installation: InstallationStatus;
|
||||
abortActive: () => boolean;
|
||||
}
|
||||
|
||||
export async function createServer(config: BridgeConfig): Promise<BridgeServer> {
|
||||
const installation = await inspectInstallation(config.command_code_executable);
|
||||
const app = Fastify({
|
||||
logger: false,
|
||||
bodyLimit: config.max_request_bytes,
|
||||
requestTimeout: 0,
|
||||
});
|
||||
let active: ActiveRequest | undefined;
|
||||
|
||||
app.setErrorHandler((error, _request, reply) => {
|
||||
const fastifyError = error as Error & { code?: string; statusCode?: number };
|
||||
if (fastifyError.code === "FST_ERR_CTP_BODY_TOO_LARGE") {
|
||||
reply.status(413).send(openAIError(
|
||||
`Request body exceeds max_request_bytes (${config.max_request_bytes})`,
|
||||
"request_too_large",
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
const errorText = error instanceof Error ? (error.stack ?? error.message) : String(error);
|
||||
process.stderr.write(`${errorText}\n`);
|
||||
reply.status(fastifyError.statusCode ?? 500).send(openAIError(
|
||||
"Internal bridge error",
|
||||
"bridge_error",
|
||||
"server_error",
|
||||
));
|
||||
});
|
||||
|
||||
app.get("/health", async () => ({
|
||||
status: installation.installed && installation.authenticated ? "ok" : "degraded",
|
||||
command_code: installation,
|
||||
busy: active !== undefined,
|
||||
}));
|
||||
|
||||
app.get("/v1/models", async () => ({
|
||||
object: "list",
|
||||
data: Object.keys(config.models).map((id) => ({
|
||||
id,
|
||||
object: "model",
|
||||
created: 0,
|
||||
owned_by: "command-code-local",
|
||||
})),
|
||||
}));
|
||||
|
||||
app.post("/v1/chat/completions", async (request, reply) => {
|
||||
if (active) {
|
||||
return reply.status(429).send(openAIError(
|
||||
"Another Command Code request is already running",
|
||||
"busy",
|
||||
"server_error",
|
||||
));
|
||||
}
|
||||
|
||||
if (!installation.installed) {
|
||||
return reply.status(503).send(openAIError(
|
||||
"Command Code CLI is not installed",
|
||||
"command_code_not_installed",
|
||||
"server_error",
|
||||
));
|
||||
}
|
||||
|
||||
if (!installation.authenticated) {
|
||||
return reply.status(503).send(openAIError(
|
||||
"Command Code is not authenticated; run command-code login",
|
||||
"command_code_not_authenticated",
|
||||
"server_error",
|
||||
));
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = chatCompletionRequestSchema.parse(request.body);
|
||||
} catch (error) {
|
||||
const message = error instanceof ZodError
|
||||
? error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ")
|
||||
: "Invalid request body";
|
||||
return reply.status(400).send(openAIError(message, "invalid_request"));
|
||||
}
|
||||
|
||||
const model = config.models[parsed.model];
|
||||
if (!model) {
|
||||
return reply.status(404).send(openAIError(
|
||||
`Model '${parsed.model}' is not configured`,
|
||||
"model_not_found",
|
||||
));
|
||||
}
|
||||
|
||||
const abortController = new AbortController();
|
||||
active = { abortController };
|
||||
let responseCompleted = false;
|
||||
|
||||
const cancelOnDisconnect = () => {
|
||||
if (!responseCompleted) abortController.abort("client disconnected");
|
||||
};
|
||||
request.raw.once("aborted", cancelOnDisconnect);
|
||||
reply.raw.once("close", cancelOnDisconnect);
|
||||
|
||||
try {
|
||||
const result = await runCommandCode(
|
||||
config,
|
||||
model.cli_model,
|
||||
model.effort,
|
||||
buildCommandPrompt(parsed),
|
||||
abortController.signal,
|
||||
);
|
||||
responseCompleted = true;
|
||||
if (parsed.stream) {
|
||||
return reply
|
||||
.type("text/event-stream; charset=utf-8")
|
||||
.header("Cache-Control", "no-cache")
|
||||
.header("Connection", "keep-alive")
|
||||
.send(completionStreamResponse(
|
||||
parsed.model,
|
||||
result.finalText,
|
||||
result.usage,
|
||||
parsed.stream_options?.include_usage,
|
||||
));
|
||||
}
|
||||
return reply.send(completionResponse(parsed.model, result.finalText, result.usage));
|
||||
} catch (error) {
|
||||
responseCompleted = true;
|
||||
if (abortController.signal.aborted) {
|
||||
if (!reply.raw.destroyed) {
|
||||
return reply.status(499).send(openAIError(
|
||||
"Command Code request cancelled",
|
||||
"cancelled",
|
||||
"server_error",
|
||||
));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const code = error instanceof CommandCodeError ? error.exitCode : undefined;
|
||||
const status = code === 5 ? 429 : code === 10 ? 402 : 502;
|
||||
const errorCode = code === 5
|
||||
? "rate_limit_exceeded"
|
||||
: code === 10
|
||||
? "insufficient_credits"
|
||||
: "command_code_error";
|
||||
return reply.status(status).send(openAIError(
|
||||
"Command Code request failed",
|
||||
errorCode,
|
||||
"server_error",
|
||||
));
|
||||
} finally {
|
||||
request.raw.off("aborted", cancelOnDisconnect);
|
||||
reply.raw.off("close", cancelOnDisconnect);
|
||||
active = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
app,
|
||||
installation,
|
||||
abortActive: () => {
|
||||
if (!active) return false;
|
||||
active.abortController.abort("bridge interrupted");
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2023",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user