diff --git a/.gitignore b/.gitignore index fca3c38..12195c9 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,5 @@ dist/ .DS_Store .gitnexus CLAUDE.md -.claude \ No newline at end of file +.claude +.command-code-openai-bridge/ diff --git a/AGENTS.md b/AGENTS.md index eff1a02..24a3794 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -90,7 +90,7 @@ curl http://127.0.0.1:18000/v1/chat/completions \ # GitNexus — Code Intelligence -This project is indexed by GitNexus as **command-code-openai-bridge** (95 symbols, 190 relationships, 9 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** (106 symbols, 201 relationships, 9 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. diff --git a/README.md b/README.md index 0371f86..8ed42a5 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Command Code OpenAI Bridge -这是一个只绑定本机地址的 OpenAI Chat Completions 兼容转发服务。它为每个 API 请求启动一次独立的 Command Code headless agent,将完整消息历史通过 stdin 发送给 CLI,在当前终端显示运行过程,只把官方结构化结果中的 `finalText` 返回给客户端。 +这是一个只绑定本机地址的 OpenAI 兼容转发服务。它保留 Chat Completions 文本接口,并实现单并发的 Responses API 文本子集。每个生成请求启动一次独立的 Command Code headless agent,将完整消息历史通过 stdin 发送给 CLI,在当前终端显示运行过程,再把结构化事件和最终 `finalText` 转换成 OpenAI 风格的 JSON 或 SSE。 默认地址:`http://127.0.0.1:18000/v1` @@ -49,6 +49,8 @@ command-code-openai-bridge/ │ ├── config.ts │ ├── index.ts │ ├── openai.ts +│ ├── request-coordinator.ts +│ ├── responses.ts │ ├── renderer.ts │ └── server.ts ├── scripts/ @@ -89,6 +91,7 @@ command_code_working_directory: . timeout_seconds: 1800 max_request_bytes: 20971520 max_turns: 100 +response_store_directory: .command-code-openai-bridge/responses permission_mode: auto-accept dangerously_skip_permissions: true @@ -98,7 +101,7 @@ models: effort: max ``` -`command_code_working_directory` 决定 Command Code 能看到和操作的项目目录。相对路径以 `config.yaml` 所在目录为基准。`models..effort` 会传给 Command Code 的 `--effort`,可用值取决于对应模型。服务强制只监听 `127.0.0.1`。客户端只能选择配置中的模型名,不能注入额外 CLI 参数。 +`command_code_working_directory` 决定 Command Code 能看到和操作的项目目录。`response_store_directory` 保存 `store: true` 的 Response;两个相对路径都以配置文件所在目录为基准。默认存储目录是隐藏目录 `.command-code-openai-bridge/responses`。`models..effort` 会传给 Command Code 的 `--effort`,Responses 请求中的 `reasoning.effort` 优先。可用 effort 由对应底层模型决定。服务强制只监听 `127.0.0.1`。客户端只能选择配置中的模型名,不能注入额外 CLI 参数。 ## 启动、停止与重启 @@ -124,10 +127,97 @@ npm run build - `GET /health` - `GET /v1/models` - `POST /v1/chat/completions` +- `POST /v1/responses` +- `GET /v1/responses/:response_id` +- `DELETE /v1/responses/:response_id` +- `GET /v1/responses/:response_id/input_items` -Bearer Token 会被忽略。只接受文本消息。支持字符串 `content`,也支持由 `{ "type": "text", "text": "..." }` 组成的数组。图片、音频和其他 part 会返回 400。 +Bearer Token 会被忽略。所有生成接口只接受文本。图片、音频、文件、工具和其他未实现字段会返回 OpenAI 格式的 400,`code` 为 `unsupported_parameter`;服务不会静默忽略会改变行为的参数。 -`stream` 省略或设为 `false` 时返回普通 JSON。`stream: true` 时,服务仍会等待 Command Code 完整执行结束,再将最终文本一次性封装为 Chat Completions SSE 事件返回;它不提供实时生成过程。传入 `stream_options.include_usage: true` 时,结束前还会返回 usage 块。 +### Chat Completions + +支持字符串 `content`,也支持由 `{ "type": "text", "text": "..." }` 组成的数组。`stream` 省略或设为 `false` 时返回普通 JSON。`stream: true` 会立即建立 SSE 连接并发送 assistant role 块,随后发送最终回答轮次的原始文本 delta、`finish_reason: "stop"`、可选 usage 块和 `[DONE]`。传入 `stream_options.include_usage: true` 时,结束前返回 usage 块。 + +Chat Completions 和 Responses 共用 Command Code NDJSON 实时事件执行层。服务终端会在事件到达时立即显示状态、文本和工具调用。由于 Command Code 只有在 `turn_end` 才给出 `hadToolCalls`,Bridge 会按 turn 缓冲客户端文本,丢弃工具轮次,在确认 `hadToolCalls: false` 后按原 delta 边界写入 Chat SSE。连接会立即建立,最终文本不会混入工具轮次内容,文本首包仍受最终 turn 完成时间约束。 + +### Responses + +`POST /v1/responses` 支持这些字段: + +- `model` +- `input` +- `instructions` +- `stream` +- `store` +- `previous_response_id` +- `metadata` +- `reasoning.effort` +- `text.format` + +`input` 可以是字符串,也可以是 message item 数组。message 支持 `system`、`developer`、`user`、`assistant` 角色、字符串 content,以及 `input_text`、`output_text` part。 + +普通响应包含 `id`、`object`、时间、状态、错误、instructions、model、标准 assistant message output item、`output_text`、响应链 ID、effective reasoning effort、store、metadata、text format 和 usage。usage 只使用 Command Code 实际提供的 input/output token;没有 usage 时返回 `null`,不生成 token 明细。 + +`stream: true` 会立即建立 SSE 连接并发送: + +1. `response.created` +2. `response.in_progress` +3. `response.output_item.added` +4. `response.content_part.added` +5. 一个或多个 `response.output_text.delta` +6. `response.output_text.done` +7. `response.content_part.done` +8. `response.output_item.done` +9. `response.completed` 或 `response.incomplete` + +所有事件都包含递增的 `sequence_number`。连接使用 `no-cache, no-transform`,收到客户端断开后会终止 Command Code 子进程组。 + +Command Code 在 `turn_end` 之前不能保证当前 `text_delta` 属于最终回答,因为同一 turn 随后可能产生工具调用。Bridge 按 turn 缓冲文本,丢弃 `hadToolCalls: true` 的中间轮次,在 `hadToolCalls: false` 时立即按原 delta 边界发送。因此 SSE 连接、状态事件和最终 turn 输出是真实增量事件;文本 delta 会延迟到最终 turn 边界,不能承诺逐 token 的到达时延。结构化输出还会延迟到 JSON 校验完成,避免把随后需要修复的无效 JSON 发给客户端。 + +### 本地存储和响应链 + +`store` 默认是 `true`。完成、incomplete、failed 和 cancelled Response 会写入配置的本地目录;`store: false` 不写入当前 Response,因此它不能在后续作为 `previous_response_id` 使用。 + +收到 `previous_response_id` 时,Bridge 从本地文件沿链向前读取每个 Response,按原顺序重放历史 input 和 assistant output,再追加当前 input。可以从任意仍存在的旧 Response 创建分支。删除某个祖先后,依赖该祖先的链会返回 404。Response ID 使用固定本地格式并在拼接文件路径前校验。 + +input items 查询返回标准 `{ object: "list", data, first_id, last_id, has_more }`,支持 `after`、`limit` 和 `order`。删除成功返回 `{ id, object: "response", deleted: true }`。 + +### 结构化输出 + +支持: + +```json +{ "text": { "format": { "type": "text" } } } +``` + +```json +{ "text": { "format": { "type": "json_object" } } } +``` + +```json +{ + "text": { + "format": { + "type": "json_schema", + "name": "result", + "strict": true, + "schema": { "type": "object" } + } + } +} +``` + +Bridge 把格式要求作为独立内部指令发送给 Command Code。最终文本必须能解析为 JSON;`json_object` 要求顶层对象,`json_schema` 使用 Ajv 校验。第一次失败后会在同一 AbortSignal 和原请求总截止时间内顺序执行一次修复,usage 是两次真实用量之和。第二次仍失败时返回 `status: "incomplete"` 和 `incomplete_details.reason: "structured_output_validation_failed"`。 + +这是 Bridge 层的提示、解析、校验和单次修复约束,不具备底层模型原生 Responses Structured Outputs 的解码级保证。 + +官方协议依据: + +- [Chat Completions streaming events](https://developers.openai.com/api/reference/resources/chat/subresources/completions/streaming-events) +- [Responses API reference](https://developers.openai.com/api/reference/resources/responses/methods/create) +- [Responses streaming events](https://developers.openai.com/api/reference/resources/responses/streaming-events) +- [List input items](https://developers.openai.com/api/reference/resources/responses/subresources/input_items/methods/list) +- [Structured Outputs](https://developers.openai.com/api/docs/guides/structured-outputs) ### curl @@ -178,7 +268,7 @@ command-code \ --yolo ``` -随后通过子进程 stdin 写入 UTF-8 的完整请求历史。消息序列被放进一个 JSON 对象,角色、顺序、空消息、Unicode、Markdown、代码块和自定义分隔符均不会被简单文本分隔符破坏。服务不总结、不删除、不截断消息,也不保存会话。 +随后通过子进程 stdin 写入 UTF-8 的完整请求历史。消息序列被放进一个 JSON 对象,角色、顺序、空消息、Unicode、Markdown、代码块和自定义分隔符均不会被简单文本分隔符破坏。Bridge 不总结、不删除、不截断消息。Chat Completions 不保存会话;Responses 只在 `store: true` 时保存协议对象,并在新请求中把响应链重新序列化给一个新的 `--no-session` 进程。 ## 单并发和错误 @@ -188,7 +278,7 @@ command-code \ ## 本机实际检查结果 -检查日期:2026-08-04。没有编写测试用例,以下均为构建后运行真实 CLI 和真实 HTTP 客户端得到的端到端结果。 +检查日期:Chat Completions 原有检查为 2026-08-04;Responses 新增检查为 2026-08-05。没有编写测试用例,以下均为构建后运行真实 CLI 和真实 HTTP/SDK 客户端得到的端到端结果。 - TypeScript 严格类型检查和生产构建通过。 - npm 生产依赖审计:0 个已知漏洞。 @@ -202,19 +292,34 @@ command-code \ - `--yolo` 下 shell_command 实际执行并返回 `shell-tool-ok`;`auto-accept` 下该工具确实被 headless 权限引擎拒绝。 - OpenAI Node.js SDK 示例通过。 - OpenAI Python SDK 示例通过。 -- `stream: false` 返回普通 JSON;`stream: true` 在完整结果生成后一次性返回兼容 SSE。 +- OpenAI Node.js SDK 的 Chat `stream: true` 检查中,约 31 ms 收到 assistant role 块;最终文本按 10 个原始 delta 返回,随后收到 `stop`、真实 usage 和 `[DONE]`。 +- 强制 `read_file` 的 Chat 两 turn 流只向客户端输出第二个无工具 turn 的 6 个文本 delta;终端实时显示工具状态、结果和最终文本,中间轮次没有污染客户端内容。 +- Chat 流式连接收到首块后断开会取消 CLI 并恢复空闲;流占用期间第二个生成请求返回 429 `busy`,取消后的普通 Chat 请求正常完成。 - 并发检查中第二个请求返回 HTTP 429 和 `code: busy`,没有启动第二个 CLI。 - 超过 20 MiB 的请求体返回 HTTP 413,服务保持可用。 - 客户端 1 秒超时断开后,当前子进程组被清理,`busy` 恢复为 false,没有发现残留 headless CLI。 - 运行中按 Ctrl+C 后客户端收到 HTTP 499,服务保持运行;紧接着的真实请求返回 `中断后恢复成功`。 - 原始交互模式的本机 TTY 探测确认 Ink TUI、ANSI、输入框和双 Ctrl+C 退出行为存在;该模式没有独立结构化最终结果通道。 +- OpenAI Node.js SDK 的 `responses.create()` 返回标准文本 Response;请求级 `reasoning.effort: "max"` 覆盖模型配置并实际传给 CLI。 +- Responses 运行中第二个生成请求返回 429 `busy`,第一个请求正常完成。 +- `store: true` 的 Response 可通过 GET 和 input items 查询;`previous_response_id` 成功重建历史并从 `链起点-42` 得到下一轮 `42`。 +- `json_schema` 合格输出通过 Ajv;不可满足的合法 Schema 顺序执行两次 CLI 后返回 `incomplete` 和 `structured_output_validation_failed`,usage 为两次实际用量之和。 +- OpenAI Node.js SDK 成功消费 Responses SSE;事件顺序、递增 sequence number、多个 text delta 和最终文本均正确。 +- 强制 `read_file` 的两 turn 请求只向 SSE 输出第二个无工具 turn 的 6 个文本 delta,中间工具轮次没有污染 `output_text`。 +- `store: false` 的流式 Response 随后查询返回 404;删除已保存 Response 返回 deleted,随后查询返回 404。 +- `input_image` 返回 400 `unsupported_parameter`,并包含准确的参数路径。 +- 真实 `max_turns: 1` 工具请求返回 200 `incomplete/max_turns`;1 秒总超时返回 200 `incomplete/timeout`;主动取消返回 499 `cancelled`。 +- 取消后紧接着的旧 Chat Completions 请求返回 `Chat恢复成功`,证明共享单并发状态和错误恢复正常。 ## 已知限制 - 没有原始 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。 +- Chat Completions 和 Responses 的文本 delta 都需要等到 `turn_end.hadToolCalls: false` 才发送;SSE 连接和初始事件会立即建立。Responses 结构化输出需要再等 Bridge 校验完成。 +- 只实现 Responses 文本子集,不实现图片、音频、文件、function calling、Computer Use、托管工具、原生 reasoning item、加密 reasoning 或隐藏思维过程。 +- Responses 的本地文件存储只供本 Bridge 使用,没有跨进程锁、队列或多实例一致性保证。项目本身仍严格单并发。 +- Responses Structured Outputs 是 Bridge 层约束,底层 Command Code 模型仍可能连续两次输出不合格 JSON;此时状态为 incomplete。 +- `usage` 使用 Command Code 最终结果提供的真实 input/output token;Responses 缺失时返回 `null`,Chat Completions 为兼容旧行为返回 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 文本解析。 diff --git a/config.example.yaml b/config.example.yaml index 4bc368c..84a82da 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -5,6 +5,7 @@ command_code_working_directory: . timeout_seconds: 1800 max_request_bytes: 20971520 max_turns: 100 +response_store_directory: .command-code-openai-bridge/responses permission_mode: auto-accept dangerously_skip_permissions: true diff --git a/config.yaml b/config.yaml index 5cd7856..94a59f2 100644 --- a/config.yaml +++ b/config.yaml @@ -5,6 +5,7 @@ command_code_working_directory: . timeout_seconds: 1800 max_request_bytes: 20971520 max_turns: 100 +response_store_directory: .command-code-openai-bridge/responses permission_mode: auto-accept dangerously_skip_permissions: true diff --git a/package-lock.json b/package-lock.json index 966a073..060a4fd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "command-code-openai-bridge", "version": "1.0.0", "dependencies": { + "ajv": "^8.20.0", "fastify": "^5.5.0", "openai": "^5.19.1", "yaml": "^2.8.1", diff --git a/package.json b/package.json index 6f76d98..6adbf52 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "1.0.0", "private": true, "type": "module", - "description": "Local OpenAI Chat Completions compatible bridge for Command Code CLI", + "description": "Local OpenAI Chat Completions and Responses compatible bridge for Command Code CLI", "engines": { "node": ">=22" }, @@ -14,6 +14,7 @@ "start": "node dist/index.js --config config.yaml" }, "dependencies": { + "ajv": "^8.20.0", "fastify": "^5.5.0", "openai": "^5.19.1", "yaml": "^2.8.1", diff --git a/src/chat-stream.ts b/src/chat-stream.ts new file mode 100644 index 0000000..221e91a --- /dev/null +++ b/src/chat-stream.ts @@ -0,0 +1,112 @@ +import { randomUUID } from "node:crypto"; +import type { ServerResponse } from "node:http"; +import type { FastifyReply } from "fastify"; +import { FinalTurnAccumulator } from "./final-turn.js"; +import type { CommandUsage } from "./openai.js"; + +export class ChatCompletionSseWriter { + private readonly id = `chatcmpl-local-${randomUUID().replaceAll("-", "")}`; + private readonly created = Math.floor(Date.now() / 1000); + private readonly accumulator = new FinalTurnAccumulator(); + private outputText = ""; + + constructor( + private readonly response: ServerResponse, + private readonly model: string, + ) {} + + begin(): void { + this.chunk({ role: "assistant", content: "" }, null); + } + + commandEvent(event: Record): void { + const chunks = this.accumulator.event(event); + if (chunks) this.addText(chunks); + } + + finish(finalText: string, usage?: CommandUsage, includeUsage = false): void { + this.ensureFinalText(finalText); + this.chunk({}, "stop"); + + if (includeUsage) { + const promptTokens = usage?.inputTokens ?? 0; + const completionTokens = usage?.outputTokens ?? 0; + this.write({ + ...this.base(), + choices: [], + usage: { + prompt_tokens: promptTokens, + completion_tokens: completionTokens, + total_tokens: promptTokens + completionTokens, + }, + }); + } + + this.writeDone(); + } + + error(error: object): void { + this.write(error); + this.writeDone(); + } + + private addText(chunks: string[]): void { + for (const delta of chunks) { + if (delta === "") continue; + this.outputText += delta; + this.chunk({ content: delta }, null); + } + } + + private ensureFinalText(finalText: string): void { + if (this.outputText === finalText) return; + if (finalText.startsWith(this.outputText)) { + this.addText([finalText.slice(this.outputText.length)]); + return; + } + throw new Error("Command Code finalText does not match the final structured turn text"); + } + + private chunk(delta: Record, finishReason: "stop" | null): void { + this.write({ + ...this.base(), + choices: [{ index: 0, delta, finish_reason: finishReason }], + }); + } + + private base() { + return { + id: this.id, + object: "chat.completion.chunk", + created: this.created, + model: this.model, + }; + } + + private write(value: object): void { + if (this.response.destroyed || this.response.writableEnded) return; + this.response.write(`data: ${JSON.stringify(value)}\n\n`); + } + + private writeDone(): void { + if (this.response.destroyed || this.response.writableEnded) return; + this.response.end("data: [DONE]\n\n"); + } +} + +export function startChatCompletionSse( + reply: FastifyReply, + model: string, +): ChatCompletionSseWriter { + reply.hijack(); + reply.raw.writeHead(200, { + "Content-Type": "text/event-stream; charset=utf-8", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + "X-Accel-Buffering": "no", + }); + reply.raw.flushHeaders(); + const writer = new ChatCompletionSseWriter(reply.raw, model); + writer.begin(); + return writer; +} diff --git a/src/command-code.ts b/src/command-code.ts index d1dd430..8fa08dd 100644 --- a/src/command-code.ts +++ b/src/command-code.ts @@ -19,6 +19,13 @@ export interface CommandResult { usage?: CommandUsage; } +export interface CommandRunOptions { + onEvent?: (event: Record) => void; + timeoutMs?: number; +} + +export type CommandFailureKind = "cancelled" | "max_turns" | "timeout" | "command_error"; + export interface InstallationStatus { installed: boolean; authenticated: boolean; @@ -29,6 +36,7 @@ export class CommandCodeError extends Error { constructor( message: string, readonly exitCode?: number | null, + readonly kind: CommandFailureKind = "command_error", ) { super(message); this.name = "CommandCodeError"; @@ -70,7 +78,13 @@ export async function runCommandCode( effort: string, prompt: string, signal: AbortSignal, + options: CommandRunOptions = {}, ): Promise { + const timeoutMs = options.timeoutMs ?? config.timeout_seconds * 1000; + if (timeoutMs <= 0) { + throw new CommandCodeError("Command Code request timed out", undefined, "timeout"); + } + const args = [ "-p", "--output-format", "json", @@ -105,6 +119,7 @@ export async function runCommandCode( } let settled = false; + let timedOut = false; let result: ResultFrame | undefined; let parseError: Error | undefined; let stderr = ""; @@ -128,9 +143,10 @@ export async function runCommandCode( }; const timeout = setTimeout(() => { - renderer.fail(`超过 ${config.timeout_seconds} 秒总超时`); + timedOut = true; + renderer.fail(`超过 ${(timeoutMs / 1000).toFixed(1)} 秒总超时`); cancelEscalation ??= terminateChild(child); - }, config.timeout_seconds * 1000); + }, timeoutMs); timeout.unref(); signal.addEventListener("abort", onAbort, { once: true }); @@ -180,6 +196,7 @@ export async function runCommandCode( lastMessageText = undefined; } renderer.event(event); + options.onEvent?.(event); } else if (frame.type === "result") { result = frame as unknown as ResultFrame; } @@ -192,16 +209,25 @@ export async function runCommandCode( child.once("close", (code, closeSignal) => { lines.close(); + if (timedOut) { + finish(new CommandCodeError("Command Code request timed out", code, "timeout")); + return; + } + if (signal.aborted) { renderer.fail("请求已取消"); - finish(new CommandCodeError("Command Code request cancelled", code)); + finish(new CommandCodeError("Command Code request cancelled", code, "cancelled")); 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)); + finish(new CommandCodeError( + "Command Code request failed", + code, + code === 8 ? "max_turns" : "command_error", + )); return; } @@ -224,7 +250,11 @@ export async function runCommandCode( if (result.subtype !== "success") { renderer.fail(`结果状态 ${result.subtype}`); - finish(new CommandCodeError("Command Code request failed", code)); + finish(new CommandCodeError( + "Command Code request failed", + code, + result.subtype === "max_turns" ? "max_turns" : "command_error", + )); return; } diff --git a/src/config.ts b/src/config.ts index 201d889..21bde20 100644 --- a/src/config.ts +++ b/src/config.ts @@ -16,6 +16,7 @@ const configSchema = z.object({ 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), + response_store_directory: z.string().min(1).default(".command-code-openai-bridge/responses"), 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( @@ -26,6 +27,7 @@ const configSchema = z.object({ export type BridgeConfig = z.infer & { configDirectory: string; + resolvedResponseStoreDirectory: string; resolvedWorkingDirectory: string; }; @@ -38,6 +40,7 @@ export async function loadConfig(configPath: string): Promise { return { ...parsed, configDirectory, + resolvedResponseStoreDirectory: path.resolve(configDirectory, parsed.response_store_directory), resolvedWorkingDirectory: path.resolve(configDirectory, parsed.command_code_working_directory), }; } diff --git a/src/final-turn.ts b/src/final-turn.ts new file mode 100644 index 0000000..d8a6e08 --- /dev/null +++ b/src/final-turn.ts @@ -0,0 +1,43 @@ +export class FinalTurnAccumulator { + private deltas: string[] = []; + private messageText: string | undefined; + finalChunks: string[] | undefined; + + event(event: Record): string[] | undefined { + if (event.type === "turn_start") { + this.deltas = []; + this.messageText = undefined; + return undefined; + } + if (event.type === "text_delta" && typeof event.delta === "string") { + this.deltas.push(event.delta); + return undefined; + } + if (event.type === "message_end") { + this.messageText = extractMessageText(event); + return undefined; + } + if (event.type !== "turn_end" || event.hadToolCalls !== false) return undefined; + + const deltaText = this.deltas.join(""); + const finalText = this.messageText ?? deltaText; + this.finalChunks = deltaText === finalText && this.deltas.length > 0 + ? [...this.deltas] + : [finalText]; + return this.finalChunks; + } +} + +function extractMessageText(event: Record): string | undefined { + if (!Array.isArray(event.content)) return undefined; + const text = event.content + .filter(isRecord) + .filter((part) => part.type === "text" && typeof part.text === "string") + .map((part) => String(part.text)) + .join(""); + return text === "" ? undefined : text; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/src/openai.ts b/src/openai.ts index 2fdbab6..cbe3116 100644 --- a/src/openai.ts +++ b/src/openai.ts @@ -17,8 +17,8 @@ export const chatCompletionRequestSchema = z.object({ stream: z.boolean().optional().default(false), stream_options: z.object({ include_usage: z.boolean().optional().default(false), - }).passthrough().optional(), -}).passthrough(); + }).strict().optional(), +}).strict(); export type ChatCompletionRequest = z.infer; @@ -75,59 +75,11 @@ export function completionResponse(model: string, content: string, usage?: Comma }; } -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 } }; +export function openAIError( + message: string, + code: string, + type = "invalid_request_error", + param: string | null = null, +) { + return { error: { message, type, param, code } }; } diff --git a/src/request-coordinator.ts b/src/request-coordinator.ts new file mode 100644 index 0000000..d6aa4d0 --- /dev/null +++ b/src/request-coordinator.ts @@ -0,0 +1,23 @@ +export class RequestCoordinator { + private activeController: AbortController | undefined; + + get busy(): boolean { + return this.activeController !== undefined; + } + + begin(): AbortController | undefined { + if (this.activeController) return undefined; + this.activeController = new AbortController(); + return this.activeController; + } + + finish(controller: AbortController): void { + if (this.activeController === controller) this.activeController = undefined; + } + + abortActive(reason = "bridge interrupted"): boolean { + if (!this.activeController) return false; + this.activeController.abort(reason); + return true; + } +} diff --git a/src/responses.ts b/src/responses.ts new file mode 100644 index 0000000..25caf9e --- /dev/null +++ b/src/responses.ts @@ -0,0 +1,1156 @@ +import { randomUUID } from "node:crypto"; +import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises"; +import path from "node:path"; +import type { ServerResponse } from "node:http"; +import { Ajv, type ErrorObject, type ValidateFunction } from "ajv"; +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import { z, ZodError } from "zod"; +import { + CommandCodeError, + runCommandCode, + type CommandResult, + type InstallationStatus, +} from "./command-code.js"; +import type { BridgeConfig } from "./config.js"; +import { FinalTurnAccumulator } from "./final-turn.js"; +import { openAIError, type CommandUsage } from "./openai.js"; +import type { RequestCoordinator } from "./request-coordinator.js"; + +const responseIdPattern = /^resp_[A-Za-z0-9_-]{1,128}$/; +const responseIdSchema = z.string().regex(responseIdPattern, "Invalid response ID"); +const roleSchema = z.enum(["system", "developer", "user", "assistant"]); + +const inputTextPartSchema = z.object({ + type: z.literal("input_text"), + text: z.string(), +}).strict(); + +const outputTextPartSchema = z.object({ + type: z.literal("output_text"), + text: z.string(), +}).strict(); + +const inputMessageSchema = z.object({ + type: z.literal("message").optional(), + role: roleSchema, + content: z.union([ + z.string(), + z.array(z.union([inputTextPartSchema, outputTextPartSchema])).min(1), + ]), +}).strict(); + +const textFormatSchema = z.object({ + type: z.literal("text"), +}).strict(); + +const jsonObjectFormatSchema = z.object({ + type: z.literal("json_object"), +}).strict(); + +const jsonSchemaFormatSchema = z.object({ + type: z.literal("json_schema"), + name: z.string().min(1).max(64), + strict: z.boolean().optional().default(true), + schema: z.union([z.record(z.unknown()), z.boolean()]), +}).strict(); + +const responseTextFormatSchema = z.discriminatedUnion("type", [ + textFormatSchema, + jsonObjectFormatSchema, + jsonSchemaFormatSchema, +]); + +const metadataSchema = z.record(z.string().max(512)).superRefine((metadata, context) => { + if (Object.keys(metadata).length > 16) { + context.addIssue({ code: z.ZodIssueCode.custom, message: "Metadata supports at most 16 entries" }); + } + for (const key of Object.keys(metadata)) { + if (key.length > 64) { + context.addIssue({ code: z.ZodIssueCode.custom, message: "Metadata keys support at most 64 characters" }); + break; + } + } +}); + +export const responseRequestSchema = z.object({ + model: z.string().min(1), + input: z.union([z.string(), z.array(inputMessageSchema).min(1)]), + instructions: z.string().nullable().optional().default(null), + stream: z.boolean().optional().default(false), + store: z.boolean().optional().default(true), + previous_response_id: responseIdSchema.nullable().optional().default(null), + metadata: metadataSchema.optional().default({}), + reasoning: z.object({ + effort: z.enum(["none", "minimal", "low", "medium", "high", "xhigh", "max"]), + }).strict().optional(), + text: z.object({ + format: responseTextFormatSchema, + }).strict().optional().default({ format: { type: "text" } }), +}).strict(); + +type ResponseRequest = z.infer; +export type ResponseTextFormat = z.infer; +type ResponseRole = z.infer; +type ResponseStatus = "in_progress" | "completed" | "incomplete" | "failed" | "cancelled"; + +interface ResponseInputPart { + type: "input_text" | "output_text"; + text: string; +} + +interface ResponseInputItem { + id: string; + type: "message"; + role: ResponseRole; + content: ResponseInputPart[]; +} + +interface ResponseOutputText { + type: "output_text"; + text: string; + annotations: []; +} + +interface ResponseOutputMessage { + id: string; + type: "message"; + role: "assistant"; + status: "completed" | "incomplete"; + content: ResponseOutputText[]; +} + +interface ResponseUsage { + input_tokens: number; + output_tokens: number; + total_tokens: number; +} + +interface ResponseError { + code: string; + message: string; +} + +interface LocalResponse { + id: string; + object: "response"; + created_at: number; + completed_at: number | null; + status: ResponseStatus; + error: ResponseError | null; + incomplete_details: { reason: string } | null; + instructions: string | null; + model: string; + output: ResponseOutputMessage[]; + output_text: string; + previous_response_id: string | null; + reasoning: { effort: string | null; summary: null }; + store: boolean; + metadata: Record; + text: { format: ResponseTextFormat }; + usage: ResponseUsage | null; +} + +interface StoredResponse { + response: LocalResponse; + input_items: ResponseInputItem[]; +} + +interface PromptMessage { + role: ResponseRole; + content: string; +} + +interface StructuredValidation { + validate: (text: string) => { valid: boolean; errors: string[] }; +} + +interface AttemptResult { + result: CommandResult; + finalChunks: string[]; +} + +interface ExecutionResult { + finalText: string; + chunks: string[]; + usage?: CommandUsage; + validationErrors?: string[]; +} + +class ResponseApiError extends Error { + constructor( + message: string, + readonly statusCode: number, + readonly code: string, + readonly type = "invalid_request_error", + readonly param: string | null = null, + ) { + super(message); + this.name = "ResponseApiError"; + } +} + +class ResponseStoreError extends Error { + constructor(message: string, readonly cause?: unknown) { + super(message); + this.name = "ResponseStoreError"; + } +} + +class ResponseStore { + readonly directory: string; + + constructor(directory: string) { + this.directory = path.resolve(directory); + } + + async initialize(): Promise { + await mkdir(this.directory, { recursive: true }); + } + + private filePath(id: string): string { + if (!responseIdPattern.test(id)) { + throw new ResponseApiError("Invalid response_id", 400, "invalid_response_id", "invalid_request_error", "response_id"); + } + const resolved = path.resolve(this.directory, `${id}.json`); + if (path.dirname(resolved) !== this.directory) { + throw new ResponseApiError("Invalid response_id", 400, "invalid_response_id", "invalid_request_error", "response_id"); + } + return resolved; + } + + async load(id: string): Promise { + const filePath = this.filePath(id); + let source: string; + try { + source = await readFile(filePath, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw new ResponseStoreError(`Unable to read stored response '${id}'`, error); + } + + try { + const value: unknown = JSON.parse(source); + if (!isRecord(value) || !isRecord(value.response) || value.response.id !== id || !Array.isArray(value.input_items)) { + throw new Error("Stored response shape is invalid"); + } + return value as unknown as StoredResponse; + } catch (error) { + throw new ResponseStoreError(`Stored response '${id}' is corrupt`, error); + } + } + + async loadChain(id: string): Promise { + const chain: StoredResponse[] = []; + const seen = new Set(); + let currentId: string | null = id; + + while (currentId) { + if (seen.has(currentId) || seen.size >= 1000) { + throw new ResponseStoreError("Stored response chain contains a cycle or is too deep"); + } + seen.add(currentId); + const stored = await this.load(currentId); + if (!stored) { + throw new ResponseApiError( + `Response '${currentId}' was not found`, + 404, + "response_not_found", + "invalid_request_error", + "previous_response_id", + ); + } + chain.unshift(stored); + currentId = stored.response.previous_response_id; + } + + return chain; + } + + async save(stored: StoredResponse): Promise { + const target = this.filePath(stored.response.id); + const temporary = path.resolve(this.directory, `.${stored.response.id}.${randomHexId()}.tmp`); + if (path.dirname(temporary) !== this.directory) throw new ResponseStoreError("Invalid temporary response path"); + + try { + await writeFile(temporary, `${JSON.stringify(stored, null, 2)}\n`, { encoding: "utf8", flag: "wx" }); + await rename(temporary, target); + } catch (error) { + try { await unlink(temporary); } catch { /* temporary file may not exist */ } + throw new ResponseStoreError(`Unable to store response '${stored.response.id}'`, error); + } + } + + async delete(id: string): Promise { + const target = this.filePath(id); + try { + await unlink(target); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; + throw new ResponseStoreError(`Unable to delete response '${id}'`, error); + } + } +} + +class ResponsesSseWriter { + private sequenceNumber = 0; + private outputStarted = false; + private outputText = ""; + + constructor( + private readonly response: ServerResponse, + private readonly messageId: string, + ) {} + + event(type: string, payload: Record): void { + if (this.response.destroyed || this.response.writableEnded) return; + const event = { type, ...payload, sequence_number: this.sequenceNumber++ }; + this.response.write(`event: ${type}\ndata: ${JSON.stringify(event)}\n\n`); + } + + begin(initial: LocalResponse): void { + this.event("response.created", { response: initial }); + this.event("response.in_progress", { response: initial }); + } + + addText(chunks: string[]): void { + if (!this.outputStarted) { + this.outputStarted = true; + this.event("response.output_item.added", { + output_index: 0, + item: { + id: this.messageId, + type: "message", + role: "assistant", + status: "in_progress", + content: [], + }, + }); + this.event("response.content_part.added", { + item_id: this.messageId, + output_index: 0, + content_index: 0, + part: { type: "output_text", text: "", annotations: [] }, + }); + } + + for (const delta of chunks) { + if (delta === "") continue; + this.outputText += delta; + this.event("response.output_text.delta", { + item_id: this.messageId, + output_index: 0, + content_index: 0, + delta, + }); + } + } + + finish(response: LocalResponse, terminalEvent: "response.completed" | "response.incomplete"): void { + this.ensureFinalText(response.output_text); + if (this.outputStarted) { + const outputMessage = response.output[0]; + if (!outputMessage) throw new Error("Response output message is missing"); + const part = outputMessage.content[0]; + if (!part) throw new Error("Response output text part is missing"); + + this.event("response.output_text.done", { + item_id: this.messageId, + output_index: 0, + content_index: 0, + text: response.output_text, + }); + this.event("response.content_part.done", { + item_id: this.messageId, + output_index: 0, + content_index: 0, + part, + }); + this.event("response.output_item.done", { output_index: 0, item: outputMessage }); + } + this.event(terminalEvent, { response }); + } + + failed(response: LocalResponse): void { + this.event("response.failed", { response }); + } + + error(code: string, message: string, param: string | null = null): void { + this.event("error", { code, message, param }); + } + + end(): void { + if (!this.response.destroyed && !this.response.writableEnded) this.response.end(); + } + + private ensureFinalText(finalText: string): void { + if (!this.outputStarted) { + this.addText([finalText]); + return; + } + if (this.outputText === finalText) return; + if (finalText.startsWith(this.outputText)) { + this.addText([finalText.slice(this.outputText.length)]); + return; + } + throw new Error("Command Code finalText does not match the final structured turn text"); + } +} + +interface ResponseRouteDependencies { + config: BridgeConfig; + installation: InstallationStatus; + coordinator: RequestCoordinator; +} + +export async function registerResponseRoutes( + app: FastifyInstance, + dependencies: ResponseRouteDependencies, +): Promise { + const { config, installation, coordinator } = dependencies; + const store = new ResponseStore(config.resolvedResponseStoreDirectory); + await store.initialize(); + + app.get("/v1/responses/:response_id", async (request, reply) => { + try { + emptyQuerySchema.parse(request.query); + const id = responseIdFromRequest(request); + const stored = await store.load(id); + if (!stored) throw responseNotFound(id); + return reply.send(stored.response); + } catch (error) { + return sendRouteError(reply, error); + } + }); + + app.delete("/v1/responses/:response_id", async (request, reply) => { + try { + emptyQuerySchema.parse(request.query); + const id = responseIdFromRequest(request); + if (!await store.delete(id)) throw responseNotFound(id); + return reply.send({ id, object: "response", deleted: true }); + } catch (error) { + return sendRouteError(reply, error); + } + }); + + app.get("/v1/responses/:response_id/input_items", async (request, reply) => { + try { + const id = responseIdFromRequest(request); + const stored = await store.load(id); + if (!stored) throw responseNotFound(id); + const query = inputItemsQuerySchema.parse(request.query); + const ordered = query.order === "asc" + ? [...stored.input_items] + : [...stored.input_items].reverse(); + const afterIndex = query.after ? ordered.findIndex((item) => item.id === query.after) : -1; + const available = afterIndex >= 0 ? ordered.slice(afterIndex + 1) : ordered; + const data = available.slice(0, query.limit); + return reply.send({ + object: "list", + data, + first_id: data[0]?.id ?? null, + last_id: data.at(-1)?.id ?? null, + has_more: available.length > data.length, + }); + } catch (error) { + return sendRouteError(reply, error); + } + }); + + app.post("/v1/responses", async (request, reply) => { + if (coordinator.busy) return sendBusy(reply); + 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: ResponseRequest; + let validation: StructuredValidation | undefined; + try { + parsed = parseResponseRequest(request.body); + validation = createStructuredValidation(parsed.text.format); + } catch (error) { + return sendRouteError(reply, error); + } + + const model = config.models[parsed.model]; + if (!model) { + return reply.status(404).send(openAIError( + `Model '${parsed.model}' is not configured`, + "model_not_found", + "invalid_request_error", + "model", + )); + } + + const abortController = coordinator.begin(); + if (!abortController) return sendBusy(reply); + + let responseCompleted = false; + const cancelOnDisconnect = () => { + if (!responseCompleted) abortController.abort("client disconnected"); + }; + request.raw.once("aborted", cancelOnDisconnect); + reply.raw.once("close", cancelOnDisconnect); + + const inputItems = normalizeInput(parsed.input); + const responseId = newResponseId(); + const messageId = newMessageId(); + const createdAt = Math.floor(Date.now() / 1000); + const effectiveEffort = parsed.reasoning?.effort ?? model.effort; + const initialResponse = createResponse({ + id: responseId, + messageId, + createdAt, + request: parsed, + effort: effectiveEffort, + status: "in_progress", + }); + + let writer: ResponsesSseWriter | undefined; + try { + const chain = parsed.previous_response_id + ? await store.loadChain(parsed.previous_response_id) + : []; + const prompt = buildResponsesPrompt(parsed, chain, inputItems); + const deadline = Date.now() + config.timeout_seconds * 1000; + + if (parsed.stream) { + writer = startSse(reply, messageId); + writer.begin(initialResponse); + } + + const execution = await executeResponse({ + config, + cliModel: model.cli_model, + effort: effectiveEffort, + prompt, + format: parsed.text.format, + signal: abortController.signal, + deadline, + ...(validation ? { validation } : {}), + ...(writer ? { writer } : {}), + }); + + const terminalResponse = createResponse({ + id: responseId, + messageId, + createdAt, + request: parsed, + effort: effectiveEffort, + status: execution.validationErrors ? "incomplete" : "completed", + finalText: execution.finalText, + ...(execution.usage ? { usage: execution.usage } : {}), + ...(execution.validationErrors ? { incompleteReason: "structured_output_validation_failed" } : {}), + }); + + if (parsed.store) await store.save({ response: terminalResponse, input_items: inputItems }); + responseCompleted = true; + + if (writer) { + if (validation) writer.addText(execution.chunks); + writer.finish( + terminalResponse, + terminalResponse.status === "completed" ? "response.completed" : "response.incomplete", + ); + writer.end(); + return reply; + } + return reply.send(terminalResponse); + } catch (error) { + responseCompleted = true; + const terminal = terminalResponseForError({ + error, + id: responseId, + messageId, + createdAt, + request: parsed, + effort: effectiveEffort, + }); + + try { + if (parsed.store && terminal.response) { + await store.save({ response: terminal.response, input_items: inputItems }); + } + } catch (storeError) { + process.stderr.write(`${formatUnknownError(storeError)}\n`); + } + + if (writer) { + if (terminal.response?.status === "incomplete") { + writer.finish(terminal.response, "response.incomplete"); + } else if (terminal.response?.status === "failed") { + writer.failed(terminal.response); + } else { + writer.error(terminal.code, terminal.message, terminal.param); + } + writer.end(); + return reply; + } + + if (terminal.response) return reply.status(terminal.statusCode).send(terminal.response); + return reply.status(terminal.statusCode).send(openAIError( + terminal.message, + terminal.code, + terminal.type, + terminal.param, + )); + } finally { + request.raw.off("aborted", cancelOnDisconnect); + reply.raw.off("close", cancelOnDisconnect); + coordinator.finish(abortController); + } + }); +} + +const inputItemsQuerySchema = z.object({ + after: z.string().optional(), + limit: z.coerce.number().int().min(1).max(100).optional().default(20), + order: z.enum(["asc", "desc"]).optional().default("desc"), +}).strict(); + +const emptyQuerySchema = z.object({}).strict(); + +function parseResponseRequest(body: unknown): ResponseRequest { + const unsupported = findUnsupportedParameter(body); + if (unsupported) { + throw new ResponseApiError( + `Unsupported parameter: '${unsupported}'`, + 400, + "unsupported_parameter", + "invalid_request_error", + unsupported, + ); + } + + try { + return responseRequestSchema.parse(body); + } catch (error) { + if (!(error instanceof ZodError)) throw error; + const unknown = error.issues.find((issue) => issue.code === "unrecognized_keys"); + if (unknown?.code === "unrecognized_keys") { + const key = unknown.keys[0] ?? "unknown"; + const param = [...unknown.path, key].join("."); + throw new ResponseApiError( + `Unsupported parameter: '${param}'`, + 400, + "unsupported_parameter", + "invalid_request_error", + param, + ); + } + const message = error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; "); + throw new ResponseApiError(message, 400, "invalid_request"); + } +} + +function findUnsupportedParameter(body: unknown): string | undefined { + if (!isRecord(body)) return undefined; + const supported = new Set([ + "model", "input", "instructions", "stream", "store", + "previous_response_id", "metadata", "reasoning", "text", + ]); + const unknownTopLevel = Object.keys(body).find((key) => !supported.has(key)); + if (unknownTopLevel) return unknownTopLevel; + + if (isRecord(body.text) && isRecord(body.text.format)) { + const formatType = body.text.format.type; + if (formatType !== "text" && formatType !== "json_object" && formatType !== "json_schema") { + return "text.format.type"; + } + } + + if (!Array.isArray(body.input)) return undefined; + for (const [itemIndex, item] of body.input.entries()) { + if (!isRecord(item)) continue; + if (item.type !== undefined && item.type !== "message") return `input.${itemIndex}.type`; + if (!Array.isArray(item.content)) continue; + for (const [partIndex, part] of item.content.entries()) { + if (!isRecord(part)) continue; + if (part.type !== "input_text" && part.type !== "output_text") { + return `input.${itemIndex}.content.${partIndex}.type`; + } + } + } + return undefined; +} + +function normalizeInput(input: ResponseRequest["input"]): ResponseInputItem[] { + if (typeof input === "string") { + return [{ + id: newMessageId(), + type: "message", + role: "user", + content: [{ type: "input_text", text: input }], + }]; + } + + return input.map((message) => ({ + id: newMessageId(), + type: "message", + role: message.role, + content: typeof message.content === "string" + ? [{ type: message.role === "assistant" ? "output_text" : "input_text", text: message.content }] + : message.content.map((part) => ({ type: part.type, text: part.text })), + })); +} + +function buildResponsesPrompt( + request: ResponseRequest, + chain: StoredResponse[], + currentInput: ResponseInputItem[], +): string { + const messages: PromptMessage[] = []; + for (const stored of chain) { + messages.push(...stored.input_items.map(inputItemToPromptMessage)); + if (stored.response.output_text !== "") { + messages.push({ role: "assistant", content: stored.response.output_text }); + } + } + messages.push(...currentInput.map(inputItemToPromptMessage)); + + const envelope = { + protocol: "openai-responses-text-history-v1", + instructions: request.instructions, + messages, + }; + + return [ + "下面 JSON 对象是外部客户端提交的完整 Responses 文本任务和本地重建的响应链。", + "按 system、developer、user、assistant 的角色与顺序理解上下文;当前 instructions 是本次请求的高优先级开发者指令。", + "严格执行最后一个用户任务。不要复述 JSON,不要输出角色标签,不要暴露内部思考。", + formatDirective(request.text.format), + "JSON 数据开始:", + JSON.stringify(envelope), + ].join("\n\n"); +} + +function buildRepairPrompt( + originalPrompt: string, + format: ResponseTextFormat, + invalidOutput: string, + validationErrors: string[], +): string { + return [ + "这是同一 Responses 请求的唯一一次结构化输出修复步骤。", + "保留原始答案的语义,只修复 JSON 语法和 Schema 违规;只返回修复后的 JSON,禁止代码围栏和解释。", + formatDirective(format), + "校验错误:", + JSON.stringify(validationErrors), + "不合格输出:", + JSON.stringify(invalidOutput), + "原始任务:", + originalPrompt, + ].join("\n\n"); +} + +function formatDirective(format: ResponseTextFormat): string { + if (format.type === "text") return "内部输出格式要求:返回纯文本。"; + if (format.type === "json_object") { + return "内部输出格式要求:只返回一个合法 JSON 对象;禁止 Markdown 代码围栏、前后说明和非 JSON 文本。"; + } + return [ + `内部输出格式要求:只返回一个符合 JSON Schema '${format.name}' 的合法 JSON 值;禁止 Markdown 代码围栏、前后说明和非 JSON 文本。`, + `JSON Schema:${JSON.stringify(format.schema)}`, + ].join("\n"); +} + +async function executeResponse(options: { + config: BridgeConfig; + cliModel: string; + effort: string; + prompt: string; + format: ResponseTextFormat; + validation?: StructuredValidation; + signal: AbortSignal; + deadline: number; + writer?: ResponsesSseWriter; +}): Promise { + const first = await executeAttempt(options, options.prompt, options.validation ? undefined : options.writer); + if (!options.validation) { + return { + finalText: first.result.finalText, + chunks: first.finalChunks, + ...(first.result.usage ? { usage: first.result.usage } : {}), + }; + } + + const firstValidation = options.validation.validate(first.result.finalText); + if (firstValidation.valid) { + return { + finalText: first.result.finalText, + chunks: first.finalChunks, + ...(first.result.usage ? { usage: first.result.usage } : {}), + }; + } + + const repairPrompt = buildRepairPrompt( + options.prompt, + options.format, + first.result.finalText, + firstValidation.errors, + ); + const second = await executeAttempt(options, repairPrompt); + const combinedUsage = addUsage(first.result.usage, second.result.usage); + const secondValidation = options.validation.validate(second.result.finalText); + + return { + finalText: second.result.finalText, + chunks: second.finalChunks, + ...(combinedUsage ? { usage: combinedUsage } : {}), + ...(!secondValidation.valid ? { validationErrors: secondValidation.errors } : {}), + }; +} + +async function executeAttempt( + options: { + config: BridgeConfig; + cliModel: string; + effort: string; + signal: AbortSignal; + deadline: number; + }, + prompt: string, + writer?: ResponsesSseWriter, +): Promise { + const accumulator = new FinalTurnAccumulator(); + const result = await runCommandCode( + options.config, + options.cliModel, + options.effort, + prompt, + options.signal, + { + timeoutMs: options.deadline - Date.now(), + onEvent: (event) => { + const chunks = accumulator.event(event); + if (chunks && writer) writer.addText(chunks); + }, + }, + ); + return { + result, + finalChunks: accumulator.finalChunks ?? [result.finalText], + }; +} + +function createStructuredValidation(format: ResponseTextFormat): StructuredValidation | undefined { + if (format.type === "text") return undefined; + if (format.type === "json_object") { + return { + validate: (text) => { + try { + const value: unknown = JSON.parse(text); + const valid = isRecord(value); + return valid + ? { valid: true, errors: [] } + : { valid: false, errors: ["Output must be a JSON object"] }; + } catch (error) { + return { valid: false, errors: [`Invalid JSON: ${formatUnknownError(error)}`] }; + } + }, + }; + } + + let validator: ValidateFunction; + try { + const ajv = new Ajv({ allErrors: true, strict: false }); + validator = ajv.compile(format.schema); + } catch (error) { + throw new ResponseApiError( + `Invalid JSON Schema: ${formatUnknownError(error)}`, + 400, + "invalid_json_schema", + "invalid_request_error", + "text.format.schema", + ); + } + + return { + validate: (text) => { + let value: unknown; + try { + value = JSON.parse(text); + } catch (error) { + return { valid: false, errors: [`Invalid JSON: ${formatUnknownError(error)}`] }; + } + const valid = validator(value); + return { + valid, + errors: valid ? [] : formatAjvErrors(validator.errors), + }; + }, + }; +} + +function createResponse(options: { + id: string; + messageId: string; + createdAt: number; + request: ResponseRequest; + effort: string; + status: ResponseStatus; + finalText?: string; + usage?: CommandUsage; + error?: ResponseError; + incompleteReason?: string; +}): LocalResponse { + const finalText = options.finalText ?? ""; + const terminal = options.status !== "in_progress"; + const hasOutput = options.finalText !== undefined; + const outputStatus = options.status === "completed" ? "completed" : "incomplete"; + return { + id: options.id, + object: "response", + created_at: options.createdAt, + completed_at: terminal ? Math.floor(Date.now() / 1000) : null, + status: options.status, + error: options.error ?? null, + incomplete_details: options.incompleteReason ? { reason: options.incompleteReason } : null, + instructions: options.request.instructions, + model: options.request.model, + output: hasOutput ? [{ + id: options.messageId, + type: "message", + role: "assistant", + status: outputStatus, + content: [{ type: "output_text", text: finalText, annotations: [] }], + }] : [], + output_text: finalText, + previous_response_id: options.request.previous_response_id, + reasoning: { effort: options.effort, summary: null }, + store: options.request.store, + metadata: options.request.metadata, + text: { format: options.request.text.format }, + usage: toResponseUsage(options.usage), + }; +} + +function terminalResponseForError(options: { + error: unknown; + id: string; + messageId: string; + createdAt: number; + request: ResponseRequest; + effort: string; +}): { + response?: LocalResponse; + statusCode: number; + code: string; + message: string; + type: string; + param: string | null; +} { + const { error } = options; + if (error instanceof ResponseApiError) { + return { + statusCode: error.statusCode, + code: error.code, + message: error.message, + type: error.type, + param: error.param, + }; + } + + if (error instanceof CommandCodeError) { + if (error.kind === "max_turns" || error.kind === "timeout") { + const reason = error.kind === "max_turns" ? "max_turns" : "timeout"; + return { + response: createResponse({ + id: options.id, + messageId: options.messageId, + createdAt: options.createdAt, + request: options.request, + effort: options.effort, + status: "incomplete", + incompleteReason: reason, + }), + statusCode: 200, + code: reason, + message: error.message, + type: "server_error", + param: null, + }; + } + + if (error.kind === "cancelled") { + return { + response: createResponse({ + id: options.id, + messageId: options.messageId, + createdAt: options.createdAt, + request: options.request, + effort: options.effort, + status: "cancelled", + error: { code: "cancelled", message: "Command Code request cancelled" }, + }), + statusCode: 499, + code: "cancelled", + message: "Command Code request cancelled", + type: "server_error", + param: null, + }; + } + + const mapped = commandErrorMapping(error.exitCode); + return { + response: createResponse({ + id: options.id, + messageId: options.messageId, + createdAt: options.createdAt, + request: options.request, + effort: options.effort, + status: "failed", + error: { code: mapped.code, message: mapped.message }, + }), + statusCode: mapped.statusCode, + code: mapped.code, + message: mapped.message, + type: "server_error", + param: null, + }; + } + + process.stderr.write(`${formatUnknownError(error)}\n`); + return { + statusCode: 500, + code: "bridge_error", + message: "Internal bridge error", + type: "server_error", + param: null, + }; +} + +function commandErrorMapping(exitCode?: number | null): { statusCode: number; code: string; message: string } { + switch (exitCode) { + case 3: + return { statusCode: 503, code: "command_code_not_authenticated", message: "Command Code is not authenticated" }; + case 5: + return { statusCode: 429, code: "rate_limit_exceeded", message: "Command Code rate limit exceeded" }; + case 10: + return { statusCode: 402, code: "insufficient_credits", message: "Command Code credits are insufficient" }; + default: + return { statusCode: 502, code: "command_code_error", message: "Command Code request failed" }; + } +} + +function startSse(reply: FastifyReply, messageId: string): ResponsesSseWriter { + reply.hijack(); + reply.raw.writeHead(200, { + "Content-Type": "text/event-stream; charset=utf-8", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + "X-Accel-Buffering": "no", + }); + reply.raw.flushHeaders(); + return new ResponsesSseWriter(reply.raw, messageId); +} + +function sendRouteError(reply: FastifyReply, error: unknown) { + if (error instanceof ResponseApiError) { + return reply.status(error.statusCode).send(openAIError(error.message, error.code, error.type, error.param)); + } + if (error instanceof ZodError) { + const unknown = error.issues.find((issue) => issue.code === "unrecognized_keys"); + if (unknown?.code === "unrecognized_keys") { + const key = unknown.keys[0] ?? "unknown"; + const param = [...unknown.path, key].join("."); + return reply.status(400).send(openAIError( + `Unsupported parameter: '${param}'`, + "unsupported_parameter", + "invalid_request_error", + param, + )); + } + const message = error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; "); + return reply.status(400).send(openAIError(message, "invalid_request")); + } + process.stderr.write(`${formatUnknownError(error)}\n`); + return reply.status(500).send(openAIError("Internal bridge error", "bridge_error", "server_error")); +} + +function sendBusy(reply: FastifyReply) { + return reply.status(429).send(openAIError( + "Another Command Code request is already running", + "busy", + "server_error", + )); +} + +function responseIdFromRequest(request: FastifyRequest): string { + const params = request.params as { response_id?: unknown }; + const result = responseIdSchema.safeParse(params.response_id); + if (!result.success) { + throw new ResponseApiError("Invalid response_id", 400, "invalid_response_id", "invalid_request_error", "response_id"); + } + return result.data; +} + +function responseNotFound(id: string): ResponseApiError { + return new ResponseApiError( + `Response '${id}' was not found`, + 404, + "response_not_found", + "invalid_request_error", + "response_id", + ); +} + +function inputItemToPromptMessage(item: ResponseInputItem): PromptMessage { + return { role: item.role, content: item.content.map((part) => part.text).join("") }; +} + +function toResponseUsage(usage?: CommandUsage): ResponseUsage | null { + if (!usage || (usage.inputTokens === undefined && usage.outputTokens === undefined)) return null; + const inputTokens = usage.inputTokens ?? 0; + const outputTokens = usage.outputTokens ?? 0; + return { + input_tokens: inputTokens, + output_tokens: outputTokens, + total_tokens: inputTokens + outputTokens, + }; +} + +function addUsage(left?: CommandUsage, right?: CommandUsage): CommandUsage | undefined { + if (!left && !right) return undefined; + return { + inputTokens: (left?.inputTokens ?? 0) + (right?.inputTokens ?? 0), + outputTokens: (left?.outputTokens ?? 0) + (right?.outputTokens ?? 0), + }; +} + +function formatAjvErrors(errors: ErrorObject[] | null | undefined): string[] { + if (!errors || errors.length === 0) return ["JSON Schema validation failed"]; + return errors.map((error) => `${error.instancePath || "/"}: ${error.message ?? error.keyword}`); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function newResponseId(): string { + return `resp_local_${randomHexId()}`; +} + +function newMessageId(): string { + return `msg_local_${randomHexId()}`; +} + +function randomHexId(): string { + return randomUUID().replaceAll("-", ""); +} + +function formatUnknownError(error: unknown): string { + return error instanceof Error ? (error.stack ?? error.message) : String(error); +} diff --git a/src/server.ts b/src/server.ts index efc79b9..44fb22c 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,5 +1,6 @@ import Fastify, { type FastifyInstance } from "fastify"; import { ZodError } from "zod"; +import { startChatCompletionSse, type ChatCompletionSseWriter } from "./chat-stream.js"; import type { BridgeConfig } from "./config.js"; import { CommandCodeError, @@ -11,13 +12,10 @@ import { buildCommandPrompt, chatCompletionRequestSchema, completionResponse, - completionStreamResponse, openAIError, } from "./openai.js"; - -interface ActiveRequest { - abortController: AbortController; -} +import { RequestCoordinator } from "./request-coordinator.js"; +import { registerResponseRoutes } from "./responses.js"; export interface BridgeServer { app: FastifyInstance; @@ -32,7 +30,7 @@ export async function createServer(config: BridgeConfig): Promise bodyLimit: config.max_request_bytes, requestTimeout: 0, }); - let active: ActiveRequest | undefined; + const coordinator = new RequestCoordinator(); app.setErrorHandler((error, _request, reply) => { const fastifyError = error as Error & { code?: string; statusCode?: number }; @@ -56,7 +54,7 @@ export async function createServer(config: BridgeConfig): Promise app.get("/health", async () => ({ status: installation.installed && installation.authenticated ? "ok" : "degraded", command_code: installation, - busy: active !== undefined, + busy: coordinator.busy, })); app.get("/v1/models", async () => ({ @@ -70,7 +68,7 @@ export async function createServer(config: BridgeConfig): Promise })); app.post("/v1/chat/completions", async (request, reply) => { - if (active) { + if (coordinator.busy) { return reply.status(429).send(openAIError( "Another Command Code request is already running", "busy", @@ -98,6 +96,19 @@ export async function createServer(config: BridgeConfig): Promise try { parsed = chatCompletionRequestSchema.parse(request.body); } catch (error) { + const unknown = error instanceof ZodError + ? error.issues.find((issue) => issue.code === "unrecognized_keys") + : undefined; + if (unknown?.code === "unrecognized_keys") { + const key = unknown.keys[0] ?? "unknown"; + const param = [...unknown.path, key].join("."); + return reply.status(400).send(openAIError( + `Unsupported parameter: '${param}'`, + "unsupported_parameter", + "invalid_request_error", + param, + )); + } const message = error instanceof ZodError ? error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ") : "Invalid request body"; @@ -112,8 +123,14 @@ export async function createServer(config: BridgeConfig): Promise )); } - const abortController = new AbortController(); - active = { abortController }; + const abortController = coordinator.begin(); + if (!abortController) { + return reply.status(429).send(openAIError( + "Another Command Code request is already running", + "busy", + "server_error", + )); + } let responseCompleted = false; const cancelOnDisconnect = () => { @@ -122,31 +139,38 @@ export async function createServer(config: BridgeConfig): Promise request.raw.once("aborted", cancelOnDisconnect); reply.raw.once("close", cancelOnDisconnect); + let writer: ChatCompletionSseWriter | undefined; try { + if (parsed.stream) writer = startChatCompletionSse(reply, parsed.model); const result = await runCommandCode( config, model.cli_model, model.effort, buildCommandPrompt(parsed), abortController.signal, + writer ? { onEvent: (event) => writer?.commandEvent(event) } : {}, ); 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, - )); + if (writer) { + writer.finish( + result.finalText, + result.usage, + parsed.stream_options?.include_usage, + ); + return reply; } return reply.send(completionResponse(parsed.model, result.finalText, result.usage)); } catch (error) { responseCompleted = true; if (abortController.signal.aborted) { + if (writer) { + writer.error(openAIError( + "Command Code request cancelled", + "cancelled", + "server_error", + )); + return reply; + } if (!reply.raw.destroyed) { return reply.status(499).send(openAIError( "Command Code request cancelled", @@ -164,6 +188,14 @@ export async function createServer(config: BridgeConfig): Promise : code === 10 ? "insufficient_credits" : "command_code_error"; + if (writer) { + writer.error(openAIError( + "Command Code request failed", + errorCode, + "server_error", + )); + return reply; + } return reply.status(status).send(openAIError( "Command Code request failed", errorCode, @@ -172,17 +204,15 @@ export async function createServer(config: BridgeConfig): Promise } finally { request.raw.off("aborted", cancelOnDisconnect); reply.raw.off("close", cancelOnDisconnect); - active = undefined; + coordinator.finish(abortController); } }); + await registerResponseRoutes(app, { config, installation, coordinator }); + return { app, installation, - abortActive: () => { - if (!active) return false; - active.abortController.abort("bridge interrupted"); - return true; - }, + abortActive: () => coordinator.abortActive(), }; }