This commit is contained in:
Sirius
2026-08-05 15:36:36 +08:00
commit 39d1d8de6c
19 changed files with 2495 additions and 0 deletions
View File
+4
View File
@@ -0,0 +1,4 @@
node_modules/
dist/
*.log
.DS_Store
+90
View File
@@ -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`
+220
View File
@@ -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.jsv22.22.2,符合 Command Code 的 Node.js 22+ 要求。
- 安装方式:`npm install --global command-code@latest`
- 已安装 Command Codev1.10.0npm `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 tokenCLI 未提供时返回 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 文本解析。
+17
View File
@@ -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
View File
@@ -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
+17
View File
@@ -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);
+14
View File
@@ -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)
+1252
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -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"
}
}
+27
View File
@@ -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"
+6
View File
@@ -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
+283
View File
@@ -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(),
};
}
+43
View File
@@ -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),
};
}
+53
View File
@@ -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
View File
@@ -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 } };
}
+87
View File
@@ -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
View File
@@ -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;
},
};
}
+17
View File
@@ -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"]
}