适配chat结构
This commit is contained in:
@@ -92,7 +92,7 @@ curl http://127.0.0.1:18000/v1/chat/completions \
|
|||||||
<!-- gitnexus:start -->
|
<!-- gitnexus:start -->
|
||||||
# GitNexus — Code Intelligence
|
# GitNexus — Code Intelligence
|
||||||
|
|
||||||
This project is indexed by GitNexus as **command-code-openai-bridge** (320 symbols, 917 relationships, 27 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** (330 symbols, 941 relationships, 28 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.
|
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||||
|
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ export OPENCODE_CONFIG="/path/to/command-code-openai-bridge/examples/opencode.js
|
|||||||
3. OpenCode 仍需配置任意非空 API key(Bridge 忽略 Authorization,但客户端要求填写)。示例配置使用 `options.apiKey: "local-bridge"`。
|
3. OpenCode 仍需配置任意非空 API key(Bridge 忽略 Authorization,但客户端要求填写)。示例配置使用 `options.apiKey: "local-bridge"`。
|
||||||
4. 选择模型 `command-bridge/command-default`(与 `config.yaml` 中的 `models.command-default` 映射一致)。
|
4. 选择模型 `command-bridge/command-default`(与 `config.yaml` 中的 `models.command-default` 映射一致)。
|
||||||
|
|
||||||
OpenCode 1.18.15 实际发送的 Chat 请求包含 `stream: true`、`stream_options.include_usage: true`、`max_tokens`(默认约 32000)、`store: false`、`tools`(约 24 个内置工具)、`tool_choice: auto`。Bridge 接受 `store` 与采样/输出限制参数但不改变无状态行为;未映射参数通过 `X-Command-Code-Ignored-Parameters` 标出。工具 `parameters` 中的 JSON Schema `$schema`(draft 2020-12)会在校验前剥离元数据字段,避免 AJV 拒绝 OpenCode 工具定义。
|
OpenCode 1.18.15 实际发送的 Chat 请求包含 `stream: true`、`stream_options.include_usage: true`、`max_tokens`(默认约 32000)、`store: false`、`tools`(约 24 个内置工具)、`tool_choice: auto`。Bridge 接受 `store: false` 并保持 Chat 无状态;采样和输出限制参数虽然在协议层接受,但未映射参数会通过 `X-Command-Code-Ignored-Parameters` 标出。工具 `parameters` 中的 JSON Schema `$schema`(draft 2020-12)会在校验前剥离元数据字段,避免 AJV 拒绝 OpenCode 工具定义。
|
||||||
|
|
||||||
OpenCode 还会在会话标题生成、子 agent 和后台任务等场景发起独立请求。这些请求与主 agent 请求共用同一 provider;将 `max_concurrent_requests` 设为大于 `1` 可让它们与多个会话并行执行。
|
OpenCode 还会在会话标题生成、子 agent 和后台任务等场景发起独立请求。这些请求与主 agent 请求共用同一 provider;将 `max_concurrent_requests` 设为大于 `1` 可让它们与多个会话并行执行。
|
||||||
|
|
||||||
@@ -180,11 +180,11 @@ Bearer Token 会被忽略。所有生成接口只接受文本内容。图片、
|
|||||||
|
|
||||||
Chat 接口接受标准 function tools、`tool_choice` 和 `parallel_tool_calls`。Bridge 把工具定义、完整消息历史和工具结果交给 Command Code 决定下一步,校验返回的工具名与 arguments JSON Schema;第一次不合格时在原请求总截止时间内执行一次修复。工具由 API 客户端执行,Bridge 不执行客户端工具,也不直接访问 Obsidian vault。该流程适用于 Obsidian Copilot 的 `localSearch`、`readNote`、`getFileTree`、`writeFile` 和 `editFile`,也适用于其他标准 function tools。协议流程参考 [OpenAI Function calling](https://developers.openai.com/api/docs/guides/function-calling)。
|
Chat 接口接受标准 function tools、`tool_choice` 和 `parallel_tool_calls`。Bridge 把工具定义、完整消息历史和工具结果交给 Command Code 决定下一步,校验返回的工具名与 arguments JSON Schema;第一次不合格时在原请求总截止时间内执行一次修复。工具由 API 客户端执行,Bridge 不执行客户端工具,也不直接访问 Obsidian vault。该流程适用于 Obsidian Copilot 的 `localSearch`、`readNote`、`getFileTree`、`writeFile` 和 `editFile`,也适用于其他标准 function tools。协议流程参考 [OpenAI Function calling](https://developers.openai.com/api/docs/guides/function-calling)。
|
||||||
|
|
||||||
`stream` 省略或设为 `false` 时返回普通 JSON。普通文本请求的 `stream: true` 会立即建立 SSE 连接并发送 assistant role 块,随后在 Command Code 的 `text_delta` 到达时立即发送对应 SSE chunk,最后发送 `finish_reason: "stop"`、可选 usage 块和 `[DONE]`。工具模式需要先解析和校验完整决策:最终文本会在校验后作为 content chunk 发送;工具调用会作为 `delta.tool_calls` 发送,并以 `finish_reason: "tool_calls"` 结束。`stream_thinking: true` 只作用于普通文本请求;该兼容格式不是原生 OpenAI reasoning item。传入 `stream_options.include_usage: true` 时,结束前返回 usage 块。
|
`stream` 省略或设为 `false` 时返回普通 JSON。普通文本请求的 `stream: true` 会立即建立 SSE 连接并发送 assistant role 块,随后在 Command Code 的 `text_delta` 到达时立即发送对应 SSE chunk,最后发送 `finish_reason: "stop"`、可选 usage 块和 `[DONE]`。工具模式需要先解析和校验完整决策:最终文本会在校验后作为 content chunk 发送;工具调用会作为 `delta.tool_calls` 发送,并以 `finish_reason: "tool_calls"` 结束。Chat structured output 会缓存完整正文,只有 JSON 通过校验或单次修复后才发送 content;连续失败会发送 `invalid_structured_output` 错误和 `[DONE]`,不会先发出无法收回的无效 JSON。`stream_thinking: true` 只作用于非结构化普通文本请求;该兼容格式不是原生 OpenAI reasoning item。传入 `stream_options.include_usage: true` 时,结束前返回 usage 块。
|
||||||
|
|
||||||
为兼容 Obsidian Copilot、OpenCode 和 LangChain OpenAI-format 客户端,Chat 接口还接受 `temperature`、`max_tokens`、`max_completion_tokens`、`top_p`、`frequency_penalty`、`presence_penalty`、`n: 1` 和 `store`。这些采样、输出限制与会话存储参数会按 OpenAI 取值范围严格校验。Command Code CLI 没有对应的 headless 参数,Chat Completions 也不保存跨请求会话;Bridge 不会把它们伪装成已生效:服务终端会输出警告,HTTP 响应带 `X-Command-Code-Ignored-Parameters`。其他未实现字段仍返回 `400 unsupported_parameter`。
|
为兼容 Obsidian Copilot、OpenCode 和 LangChain OpenAI-format 客户端,Chat 接口还接受 `temperature`、`max_tokens`、`max_completion_tokens`、`top_p`、`frequency_penalty`、`presence_penalty`、`n: 1` 和 `store: false`。`n: 1` 是 Bridge 实际支持的单候选协议行为;`store: false` 保持每次请求使用 `--no-session` 的无状态行为,不创建服务端 Chat session。Command Code 1.18.0 官方 `--help` 没有 temperature、top-p、frequency/presence penalty 或输出 token 上限的 headless 参数,因此前六个采样/输出限制字段只在协议层接受和校验,不会传给 CLI,也不会用提示词伪造。只要请求显式提供其中任一字段,服务终端会输出警告,HTTP 响应的 `X-Command-Code-Ignored-Parameters` 会准确列出未实际应用的字段;浏览器可通过 CORS exposed header 读取。其他未实现字段仍返回 `400 unsupported_parameter`。
|
||||||
|
|
||||||
Chat Completions 和 Responses 共用 Command Code NDJSON 执行层。服务终端会在事件到达时立即显示状态、文本和 Command Code 自身的工具调用。普通文本 Chat 会把每个 `text_delta` 直接写入 SSE;外部 function calling 模式会缓存 `finalText`,避免内部 JSON 决策进入客户端正文。Responses 外部 function calling 同样缓存 `finalText`,工具轮次不向客户端输出 assistant 文本。
|
Chat Completions 和 Responses 共用 Command Code NDJSON 执行层。服务终端会在事件到达时立即显示状态、文本和 Command Code 自身的工具调用。普通文本 Chat 会把每个 `text_delta` 直接写入 SSE;外部 function calling 和 structured output 模式会缓存 `finalText`,避免内部决策或未校验 JSON 进入客户端正文。`response_format` 只约束最终文本:需要调用工具的轮次先按 tools 协议返回,客户端提交工具结果后的最终文本轮次再执行格式校验。Responses 外部 function calling 同样缓存 `finalText`,工具轮次不向客户端输出 assistant 文本。
|
||||||
|
|
||||||
### Responses
|
### Responses
|
||||||
|
|
||||||
@@ -241,7 +241,30 @@ input items 查询返回标准 `{ object: "list", data, first_id, last_id, has_m
|
|||||||
|
|
||||||
### 结构化输出
|
### 结构化输出
|
||||||
|
|
||||||
支持:
|
Chat Completions 支持标准 `response_format`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "response_format": { "type": "text" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "response_format": { "type": "json_object" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"response_format": {
|
||||||
|
"type": "json_schema",
|
||||||
|
"json_schema": {
|
||||||
|
"name": "result",
|
||||||
|
"strict": true,
|
||||||
|
"schema": { "type": "object" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Responses 支持对应的 `text.format`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{ "text": { "format": { "type": "text" } } }
|
{ "text": { "format": { "type": "text" } } }
|
||||||
@@ -264,9 +287,9 @@ input items 查询返回标准 `{ object: "list", data, first_id, last_id, has_m
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Bridge 把格式要求作为独立内部指令发送给 Command Code。最终文本必须能解析为 JSON;`json_object` 要求顶层对象,`json_schema` 使用 Ajv 校验。第一次失败后会在同一 AbortSignal 和原请求总截止时间内顺序执行一次修复,usage 是两次真实用量之和。第二次仍失败时返回 `status: "incomplete"` 和 `incomplete_details.reason: "structured_output_validation_failed"`。
|
Bridge 把格式要求作为独立内部指令发送给 Command Code。最终文本必须能解析为 JSON;`json_object` 要求顶层对象,`json_schema` 使用 Ajv 校验。第一次失败后会在同一 AbortSignal 和原请求总截止时间内顺序执行一次修复,usage 是各次真实用量之和。Chat 第二次仍失败时,非流式请求返回 HTTP 502 `invalid_structured_output`,流式请求发送同 code 的 SSE 错误;Responses 则返回 `status: "incomplete"` 和 `incomplete_details.reason: "structured_output_validation_failed"`。
|
||||||
|
|
||||||
这是 Bridge 层的提示、解析、校验和单次修复约束,不具备底层模型原生 Responses Structured Outputs 的解码级保证。
|
这是 Bridge 层的提示、JSON 解析、Ajv 校验和单次修复约束,不是 Command Code 或底层模型提供的解码级原生 structured outputs。
|
||||||
|
|
||||||
官方协议依据:
|
官方协议依据:
|
||||||
|
|
||||||
@@ -382,11 +405,11 @@ command-code \
|
|||||||
- 没有原始 Command Code TUI、颜色布局、动画和键盘交互。
|
- 没有原始 Command Code TUI、颜色布局、动画和键盘交互。
|
||||||
- headless 无法在服务终端进行批准、拒绝、选项选择或文字回答;`ask_user_question` 不能由等待中的 HTTP 客户端处理。
|
- headless 无法在服务终端进行批准、拒绝、选项选择或文字回答;`ask_user_question` 不能由等待中的 HTTP 客户端处理。
|
||||||
- 终端事件渲染由本项目完成,格式接近日志,无法等同原始 TUI。
|
- 终端事件渲染由本项目完成,格式接近日志,无法等同原始 TUI。
|
||||||
- 普通文本 Chat Completions 会实时转发 Command Code 文本 delta;外部 function calling 和 Responses 结构化输出需要等待 Bridge 校验完成。
|
- 普通文本 Chat Completions 会实时转发 Command Code 文本 delta;外部 function calling 以及 Chat/Responses 结构化输出需要等待 Bridge 校验完成。
|
||||||
- Chat Completions 和 Responses 都只实现外部 function tools,不实现图片、音频、文件输入、Computer Use、web search 等 hosted tools 或 MCP hosted tool。非 function 工具类型返回 400 `unsupported_parameter`。Responses 不实现原生 reasoning item、加密 reasoning 或隐藏思维过程。
|
- Chat Completions 和 Responses 都只实现外部 function tools,不实现图片、音频、文件输入、Computer Use、web search 等 hosted tools 或 MCP hosted tool。非 function 工具类型返回 400 `unsupported_parameter`。Responses 不实现原生 reasoning item、加密 reasoning 或隐藏思维过程。
|
||||||
- Chat 与 Responses 的外部 function calling 都是 Bridge 通过提示协议、JSON 解析、工具参数 Schema 校验和一次修复实现的兼容层;Command Code CLI 没有公开原生 function calling 输出接口,因此连续两次输出不合格时返回 HTTP 502 `invalid_tool_decision`。
|
- Chat 与 Responses 的外部 function calling 都是 Bridge 通过提示协议、JSON 解析、工具参数 Schema 校验和一次修复实现的兼容层;Command Code CLI 没有公开原生 function calling 输出接口,因此连续两次输出不合格时返回 HTTP 502 `invalid_tool_decision`。
|
||||||
- Responses 的本地文件存储只供本 Bridge 使用,没有跨进程锁或多实例一致性保证;并发请求应避免同时更新同一条 Response 链。
|
- Responses 的本地文件存储只供本 Bridge 使用,没有跨进程锁或多实例一致性保证;并发请求应避免同时更新同一条 Response 链。
|
||||||
- Responses Structured Outputs 是 Bridge 层约束,底层 Command Code 模型仍可能连续两次输出不合格 JSON;此时状态为 incomplete。
|
- Chat 和 Responses Structured Outputs 都是 Bridge 层约束,底层 Command Code 模型仍可能连续两次输出不合格 JSON;Chat 此时返回 `invalid_structured_output`,Responses 状态为 incomplete。
|
||||||
- `usage` 使用 Command Code 最终结果提供的真实 input/output token;Responses 缺失时返回 `null`,Chat Completions 为兼容旧行为返回 0。
|
- `usage` 使用 Command Code 最终结果提供的真实 input/output token;Responses 缺失时返回 `null`,Chat Completions 为兼容旧行为返回 0。
|
||||||
- 长文本受 HTTP 请求体上限和 Command Code 模型上下文上限共同限制,不会由桥接服务自行截断。
|
- 长文本受 HTTP 请求体上限和 Command Code 模型上下文上限共同限制,不会由桥接服务自行截断。
|
||||||
- v1.10.0 的大输入会让 `run_end.nextState` 重复完整提示词。实测约 96 KiB 中文输入时,CLI 退出前可能截断该大事件并丢掉紧随其后的 compact `result` 行。桥接服务会忽略冗余 `run_end`,优先使用 `result.finalText`;若退出码为 0 且 result 缺失,只使用最后一个已完整结束、无工具调用的结构化 turn 文本和真实 turn usage,不从 TUI 文本解析。
|
- v1.10.0 的大输入会让 `run_end.nextState` 重复完整提示词。实测约 96 KiB 中文输入时,CLI 退出前可能截断该大事件并丢掉紧随其后的 compact `result` 行。桥接服务会忽略冗余 `run_end`,优先使用 `result.finalText`;若退出码为 0 且 result 缺失,只使用最后一个已完整结束、无工具调用的结构化 turn 文本和真实 turn usage,不从 TUI 文本解析。
|
||||||
|
|||||||
+90
-2
@@ -80,6 +80,29 @@ const toolChoiceSchema = z.union([
|
|||||||
}).strict(),
|
}).strict(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const chatTextResponseFormatSchema = z.object({
|
||||||
|
type: z.literal("text"),
|
||||||
|
}).strict();
|
||||||
|
|
||||||
|
const chatJsonObjectResponseFormatSchema = z.object({
|
||||||
|
type: z.literal("json_object"),
|
||||||
|
}).strict();
|
||||||
|
|
||||||
|
const chatJsonSchemaResponseFormatSchema = z.object({
|
||||||
|
type: z.literal("json_schema"),
|
||||||
|
json_schema: z.object({
|
||||||
|
name: z.string().min(1).max(64),
|
||||||
|
strict: z.boolean().optional(),
|
||||||
|
schema: z.union([z.record(z.unknown()), z.boolean()]),
|
||||||
|
}).strict(),
|
||||||
|
}).strict();
|
||||||
|
|
||||||
|
const chatResponseFormatSchema = z.discriminatedUnion("type", [
|
||||||
|
chatTextResponseFormatSchema,
|
||||||
|
chatJsonObjectResponseFormatSchema,
|
||||||
|
chatJsonSchemaResponseFormatSchema,
|
||||||
|
]);
|
||||||
|
|
||||||
export const chatCompletionRequestSchema = z.object({
|
export const chatCompletionRequestSchema = z.object({
|
||||||
model: z.string().min(1),
|
model: z.string().min(1),
|
||||||
messages: z.array(messageSchema).min(1),
|
messages: z.array(messageSchema).min(1),
|
||||||
@@ -97,7 +120,8 @@ export const chatCompletionRequestSchema = z.object({
|
|||||||
tools: z.array(functionToolSchema).min(1).max(128).optional(),
|
tools: z.array(functionToolSchema).min(1).max(128).optional(),
|
||||||
tool_choice: toolChoiceSchema.optional(),
|
tool_choice: toolChoiceSchema.optional(),
|
||||||
parallel_tool_calls: z.boolean().optional(),
|
parallel_tool_calls: z.boolean().optional(),
|
||||||
store: z.boolean().optional().default(true),
|
response_format: chatResponseFormatSchema.optional().default({ type: "text" }),
|
||||||
|
store: z.literal(false).optional(),
|
||||||
}).strict().superRefine((request, context) => {
|
}).strict().superRefine((request, context) => {
|
||||||
const toolNames = new Set<string>();
|
const toolNames = new Set<string>();
|
||||||
for (const [index, tool] of (request.tools ?? []).entries()) {
|
for (const [index, tool] of (request.tools ?? []).entries()) {
|
||||||
@@ -133,6 +157,16 @@ export const chatCompletionRequestSchema = z.object({
|
|||||||
|
|
||||||
export type ChatCompletionRequest = z.infer<typeof chatCompletionRequestSchema>;
|
export type ChatCompletionRequest = z.infer<typeof chatCompletionRequestSchema>;
|
||||||
|
|
||||||
|
export type ChatResponseTextFormat =
|
||||||
|
| { type: "text" }
|
||||||
|
| { type: "json_object" }
|
||||||
|
| {
|
||||||
|
type: "json_schema";
|
||||||
|
name: string;
|
||||||
|
strict: boolean;
|
||||||
|
schema: Record<string, unknown> | boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export interface CommandUsage {
|
export interface CommandUsage {
|
||||||
inputTokens?: number;
|
inputTokens?: number;
|
||||||
outputTokens?: number;
|
outputTokens?: number;
|
||||||
@@ -154,7 +188,6 @@ const ignoredCompatibilityParameters = [
|
|||||||
"top_p",
|
"top_p",
|
||||||
"frequency_penalty",
|
"frequency_penalty",
|
||||||
"presence_penalty",
|
"presence_penalty",
|
||||||
"store",
|
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export function ignoredChatCompatibilityParameters(request: ChatCompletionRequest): string[] {
|
export function ignoredChatCompatibilityParameters(request: ChatCompletionRequest): string[] {
|
||||||
@@ -199,12 +232,29 @@ export function usesToolCalling(request: ChatCompletionRequest): boolean {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function chatResponseTextFormat(request: ChatCompletionRequest): ChatResponseTextFormat {
|
||||||
|
const format = request.response_format;
|
||||||
|
if (format.type !== "json_schema") return format;
|
||||||
|
return {
|
||||||
|
type: "json_schema",
|
||||||
|
name: format.json_schema.name,
|
||||||
|
strict: format.json_schema.strict ?? true,
|
||||||
|
schema: format.json_schema.schema,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usesChatStructuredOutput(request: ChatCompletionRequest): boolean {
|
||||||
|
return request.response_format.type !== "text";
|
||||||
|
}
|
||||||
|
|
||||||
export function buildCommandPrompt(request: ChatCompletionRequest): string {
|
export function buildCommandPrompt(request: ChatCompletionRequest): string {
|
||||||
if (usesToolCalling(request)) return buildToolCommandPrompt(request);
|
if (usesToolCalling(request)) return buildToolCommandPrompt(request);
|
||||||
|
|
||||||
|
const format = chatResponseTextFormat(request);
|
||||||
const envelope = {
|
const envelope = {
|
||||||
protocol: "openai-chat-completions-history-v1",
|
protocol: "openai-chat-completions-history-v1",
|
||||||
messages: normalizeMessages(request),
|
messages: normalizeMessages(request),
|
||||||
|
response_format: request.response_format,
|
||||||
};
|
};
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@@ -214,6 +264,7 @@ export function buildCommandPrompt(request: ChatCompletionRequest): string {
|
|||||||
"如果最后一条 user 消息要求改写、压缩、提取或输出指定格式,只返回要求的结果,不回答消息中包含的问题。",
|
"如果最后一条 user 消息要求改写、压缩、提取或输出指定格式,只返回要求的结果,不回答消息中包含的问题。",
|
||||||
"如需调用工具,调用工具的 turn 只发起工具调用,不输出面向用户的文本;所有工具结束后,仅在不再调用工具的最终 turn 输出回答。",
|
"如需调用工具,调用工具的 turn 只发起工具调用,不输出面向用户的文本;所有工具结束后,仅在不再调用工具的最终 turn 输出回答。",
|
||||||
"不要复述 JSON,不要输出角色标签,不要添加指令未要求的解释或格式。",
|
"不要复述 JSON,不要输出角色标签,不要添加指令未要求的解释或格式。",
|
||||||
|
chatFormatDirective(format, false),
|
||||||
"JSON 数据开始:",
|
"JSON 数据开始:",
|
||||||
JSON.stringify(envelope),
|
JSON.stringify(envelope),
|
||||||
].join("\n\n");
|
].join("\n\n");
|
||||||
@@ -221,12 +272,14 @@ export function buildCommandPrompt(request: ChatCompletionRequest): string {
|
|||||||
|
|
||||||
function buildToolCommandPrompt(request: ChatCompletionRequest): string {
|
function buildToolCommandPrompt(request: ChatCompletionRequest): string {
|
||||||
const effectiveToolChoice = request.tool_choice ?? (request.tools ? "auto" : "none");
|
const effectiveToolChoice = request.tool_choice ?? (request.tools ? "auto" : "none");
|
||||||
|
const format = chatResponseTextFormat(request);
|
||||||
const envelope = {
|
const envelope = {
|
||||||
protocol: "openai-chat-completions-tools-v1",
|
protocol: "openai-chat-completions-tools-v1",
|
||||||
messages: normalizeMessages(request),
|
messages: normalizeMessages(request),
|
||||||
tools: request.tools ?? [],
|
tools: request.tools ?? [],
|
||||||
tool_choice: effectiveToolChoice,
|
tool_choice: effectiveToolChoice,
|
||||||
parallel_tool_calls: request.parallel_tool_calls ?? true,
|
parallel_tool_calls: request.parallel_tool_calls ?? true,
|
||||||
|
response_format: request.response_format,
|
||||||
};
|
};
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@@ -240,6 +293,8 @@ function buildToolCommandPrompt(request: ChatCompletionRequest): string {
|
|||||||
"调用工具时,name 必须与 tools 中的名称完全一致,arguments 必须是符合该工具 parameters JSON Schema 的对象。",
|
"调用工具时,name 必须与 tools 中的名称完全一致,arguments 必须是符合该工具 parameters JSON Schema 的对象。",
|
||||||
"tool_choice 为 none 时必须返回 final;为 required 或指定函数时必须返回 tool_calls。parallel_tool_calls 为 false 时 calls 只能有一项。",
|
"tool_choice 为 none 时必须返回 final;为 required 或指定函数时必须返回 tool_calls。parallel_tool_calls 为 false 时 calls 只能有一项。",
|
||||||
"需要工具提供信息或执行动作时返回 tool_calls 并立即结束;已有足够信息时返回 final。不要在工具调用轮次生成面向用户的正文。",
|
"需要工具提供信息或执行动作时返回 tool_calls 并立即结束;已有足够信息时返回 final。不要在工具调用轮次生成面向用户的正文。",
|
||||||
|
"response_format 只约束 final.content;返回 tool_calls 时不应用正文格式校验。",
|
||||||
|
chatFormatDirective(format, true),
|
||||||
"JSON 数据开始:",
|
"JSON 数据开始:",
|
||||||
JSON.stringify(envelope),
|
JSON.stringify(envelope),
|
||||||
].join("\n\n");
|
].join("\n\n");
|
||||||
@@ -262,6 +317,39 @@ export function buildToolDecisionRepairPrompt(
|
|||||||
].join("\n\n");
|
].join("\n\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildToolStructuredOutputRepairPrompt(
|
||||||
|
originalPrompt: string,
|
||||||
|
request: ChatCompletionRequest,
|
||||||
|
invalidOutput: string,
|
||||||
|
validationErrors: string[],
|
||||||
|
): string {
|
||||||
|
return [
|
||||||
|
"这是同一 Chat Completions 请求的唯一一次结构化正文修复步骤。",
|
||||||
|
"上一轮已经决定返回 final;不得改为 tool_calls,也不得调用或模拟任何工具。",
|
||||||
|
"保持原始答案语义,只修复 final.content 中的 JSON 语法和 Schema 违规。",
|
||||||
|
"只返回 {\"type\":\"final\",\"content\":\"修复后的完整正文\"},禁止 Markdown 代码围栏、前后说明和额外字段。",
|
||||||
|
chatFormatDirective(chatResponseTextFormat(request), true),
|
||||||
|
"校验错误:",
|
||||||
|
JSON.stringify(validationErrors),
|
||||||
|
"不合格的 final.content:",
|
||||||
|
JSON.stringify(invalidOutput),
|
||||||
|
"原始任务:",
|
||||||
|
originalPrompt,
|
||||||
|
].join("\n\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function chatFormatDirective(format: ChatResponseTextFormat, toolMode: boolean): string {
|
||||||
|
const target = toolMode ? "final.content 字符串的内容" : "最终输出";
|
||||||
|
if (format.type === "text") return `内部输出格式要求:${target}为纯文本。`;
|
||||||
|
if (format.type === "json_object") {
|
||||||
|
return `内部输出格式要求:${target}必须且只能是一个合法 JSON 对象;禁止 Markdown 代码围栏、前后说明和非 JSON 文本。`;
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
`内部输出格式要求:${target}必须且只能是一个符合 JSON Schema '${format.name}' 的合法 JSON 值;禁止 Markdown 代码围栏、前后说明和非 JSON 文本。`,
|
||||||
|
`JSON Schema:${JSON.stringify(format.schema)}`,
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
export function completionResponse(model: string, content: string, usage?: CommandUsage) {
|
export function completionResponse(model: string, content: string, usage?: CommandUsage) {
|
||||||
const promptTokens = usage?.inputTokens ?? 0;
|
const promptTokens = usage?.inputTokens ?? 0;
|
||||||
const completionTokens = usage?.outputTokens ?? 0;
|
const completionTokens = usage?.outputTokens ?? 0;
|
||||||
|
|||||||
+5
-5
@@ -291,7 +291,7 @@ interface StoredResponse {
|
|||||||
input_items: ResponseInputItem[];
|
input_items: ResponseInputItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface StructuredValidation {
|
export interface StructuredValidation {
|
||||||
validate: (text: string) => { valid: boolean; errors: string[] };
|
validate: (text: string) => { valid: boolean; errors: string[] };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1107,14 +1107,14 @@ function buildResponsesPrompt(
|
|||||||
].join("\n\n");
|
].join("\n\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildRepairPrompt(
|
export function buildRepairPrompt(
|
||||||
originalPrompt: string,
|
originalPrompt: string,
|
||||||
format: ResponseTextFormat,
|
format: ResponseTextFormat,
|
||||||
invalidOutput: string,
|
invalidOutput: string,
|
||||||
validationErrors: string[],
|
validationErrors: string[],
|
||||||
): string {
|
): string {
|
||||||
return [
|
return [
|
||||||
"这是同一 Responses 请求的唯一一次结构化输出修复步骤。",
|
"这是同一请求的唯一一次结构化输出修复步骤。",
|
||||||
"保留原始答案的语义,只修复 JSON 语法和 Schema 违规;只返回修复后的 JSON,禁止代码围栏和解释。",
|
"保留原始答案的语义,只修复 JSON 语法和 Schema 违规;只返回修复后的 JSON,禁止代码围栏和解释。",
|
||||||
formatDirective(format),
|
formatDirective(format),
|
||||||
"校验错误:",
|
"校验错误:",
|
||||||
@@ -1320,7 +1320,7 @@ async function executeAttempt(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createStructuredValidation(format: ResponseTextFormat): StructuredValidation | undefined {
|
export function createStructuredValidation(format: ResponseTextFormat): StructuredValidation | undefined {
|
||||||
if (format.type === "text") return undefined;
|
if (format.type === "text") return undefined;
|
||||||
if (format.type === "json_object") {
|
if (format.type === "json_object") {
|
||||||
return {
|
return {
|
||||||
@@ -1761,7 +1761,7 @@ function toResponseUsage(usage?: CommandUsage): ResponseUsage | null {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function addUsage(left?: CommandUsage, right?: CommandUsage): CommandUsage | undefined {
|
export function addUsage(left?: CommandUsage, right?: CommandUsage): CommandUsage | undefined {
|
||||||
if (!left && !right) return undefined;
|
if (!left && !right) return undefined;
|
||||||
return {
|
return {
|
||||||
inputTokens: (left?.inputTokens ?? 0) + (right?.inputTokens ?? 0),
|
inputTokens: (left?.inputTokens ?? 0) + (right?.inputTokens ?? 0),
|
||||||
|
|||||||
+136
-21
@@ -12,17 +12,26 @@ import {
|
|||||||
import {
|
import {
|
||||||
buildCommandPrompt,
|
buildCommandPrompt,
|
||||||
buildToolDecisionRepairPrompt,
|
buildToolDecisionRepairPrompt,
|
||||||
|
buildToolStructuredOutputRepairPrompt,
|
||||||
|
chatResponseTextFormat,
|
||||||
chatCompletionRequestSchema,
|
chatCompletionRequestSchema,
|
||||||
completionResponse,
|
completionResponse,
|
||||||
ignoredChatCompatibilityParameters,
|
ignoredChatCompatibilityParameters,
|
||||||
openAIError,
|
openAIError,
|
||||||
toolCallsCompletionResponse,
|
toolCallsCompletionResponse,
|
||||||
|
usesChatStructuredOutput,
|
||||||
usesToolCalling,
|
usesToolCalling,
|
||||||
type ChatCompletionRequest,
|
type ChatCompletionRequest,
|
||||||
type CommandUsage,
|
|
||||||
} from "./openai.js";
|
} from "./openai.js";
|
||||||
import { RequestCoordinator } from "./request-coordinator.js";
|
import { RequestCoordinator } from "./request-coordinator.js";
|
||||||
import { registerResponseRoutes } from "./responses.js";
|
import {
|
||||||
|
addUsage,
|
||||||
|
buildRepairPrompt,
|
||||||
|
createStructuredValidation,
|
||||||
|
registerResponseRoutes,
|
||||||
|
type ResponseTextFormat,
|
||||||
|
type StructuredValidation,
|
||||||
|
} from "./responses.js";
|
||||||
import {
|
import {
|
||||||
parseToolDecision,
|
parseToolDecision,
|
||||||
ToolDecisionError,
|
ToolDecisionError,
|
||||||
@@ -58,6 +67,7 @@ export async function createServer(config: BridgeConfig): Promise<BridgeServer>
|
|||||||
);
|
);
|
||||||
reply.raw.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
|
reply.raw.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
|
||||||
reply.raw.setHeader("Access-Control-Max-Age", "600");
|
reply.raw.setHeader("Access-Control-Max-Age", "600");
|
||||||
|
reply.raw.setHeader("Access-Control-Expose-Headers", "X-Command-Code-Ignored-Parameters");
|
||||||
reply.raw.setHeader(
|
reply.raw.setHeader(
|
||||||
"Vary",
|
"Vary",
|
||||||
"Origin, Access-Control-Request-Method, Access-Control-Request-Headers, Access-Control-Request-Private-Network",
|
"Origin, Access-Control-Request-Method, Access-Control-Request-Headers, Access-Control-Request-Private-Network",
|
||||||
@@ -126,7 +136,7 @@ export async function createServer(config: BridgeConfig): Promise<BridgeServer>
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let parsed;
|
let parsed: ChatCompletionRequest;
|
||||||
try {
|
try {
|
||||||
parsed = chatCompletionRequestSchema.parse(request.body);
|
parsed = chatCompletionRequestSchema.parse(request.body);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -149,6 +159,20 @@ export async function createServer(config: BridgeConfig): Promise<BridgeServer>
|
|||||||
return reply.status(400).send(openAIError(message, "invalid_request"));
|
return reply.status(400).send(openAIError(message, "invalid_request"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const responseFormat: ResponseTextFormat = chatResponseTextFormat(parsed);
|
||||||
|
let structuredValidation: StructuredValidation | undefined;
|
||||||
|
try {
|
||||||
|
structuredValidation = createStructuredValidation(responseFormat);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Invalid JSON Schema";
|
||||||
|
return reply.status(400).send(openAIError(
|
||||||
|
message,
|
||||||
|
"invalid_json_schema",
|
||||||
|
"invalid_request_error",
|
||||||
|
"response_format.json_schema.schema",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
const model = config.models[parsed.model];
|
const model = config.models[parsed.model];
|
||||||
if (!model) {
|
if (!model) {
|
||||||
return reply.status(404).send(openAIError(
|
return reply.status(404).send(openAIError(
|
||||||
@@ -196,7 +220,13 @@ export async function createServer(config: BridgeConfig): Promise<BridgeServer>
|
|||||||
|
|
||||||
let writer: ChatCompletionSseWriter | undefined;
|
let writer: ChatCompletionSseWriter | undefined;
|
||||||
try {
|
try {
|
||||||
if (parsed.stream) writer = startChatCompletionSse(reply, parsed.model, config.stream_thinking);
|
if (parsed.stream) {
|
||||||
|
writer = startChatCompletionSse(
|
||||||
|
reply,
|
||||||
|
parsed.model,
|
||||||
|
config.stream_thinking && !usesChatStructuredOutput(parsed),
|
||||||
|
);
|
||||||
|
}
|
||||||
if (request.raw.aborted || reply.raw.destroyed) cancelOnDisconnect();
|
if (request.raw.aborted || reply.raw.destroyed) cancelOnDisconnect();
|
||||||
acquired = await lease.ready;
|
acquired = await lease.ready;
|
||||||
if (!acquired) {
|
if (!acquired) {
|
||||||
@@ -212,6 +242,8 @@ export async function createServer(config: BridgeConfig): Promise<BridgeServer>
|
|||||||
signal: abortController.signal,
|
signal: abortController.signal,
|
||||||
deadline: Date.now() + config.timeout_seconds * 1000,
|
deadline: Date.now() + config.timeout_seconds * 1000,
|
||||||
toolMode,
|
toolMode,
|
||||||
|
format: responseFormat,
|
||||||
|
...(structuredValidation ? { validation: structuredValidation } : {}),
|
||||||
...(writer ? { writer } : {}),
|
...(writer ? { writer } : {}),
|
||||||
});
|
});
|
||||||
const { result, decision } = execution;
|
const { result, decision } = execution;
|
||||||
@@ -281,6 +313,21 @@ export async function createServer(config: BridgeConfig): Promise<BridgeServer>
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (error instanceof ChatStructuredOutputError) {
|
||||||
|
process.stderr.write(`\nChat 结构化输出连续两次校验失败:${error.errors.join("; ")}\n`);
|
||||||
|
const payload = openAIError(
|
||||||
|
"Command Code returned invalid structured output after one repair attempt",
|
||||||
|
"invalid_structured_output",
|
||||||
|
"server_error",
|
||||||
|
"response_format",
|
||||||
|
);
|
||||||
|
if (writer) {
|
||||||
|
writer.error(payload);
|
||||||
|
return reply;
|
||||||
|
}
|
||||||
|
return reply.status(502).send(payload);
|
||||||
|
}
|
||||||
|
|
||||||
const code = error instanceof CommandCodeError ? error.exitCode : undefined;
|
const code = error instanceof CommandCodeError ? error.exitCode : undefined;
|
||||||
const status = code === 5 ? 429 : code === 10 ? 402 : 502;
|
const status = code === 5 ? 429 : code === 10 ? 402 : 502;
|
||||||
const errorCode = code === 5
|
const errorCode = code === 5
|
||||||
@@ -323,7 +370,7 @@ interface ChatExecution {
|
|||||||
decision?: ToolDecision;
|
decision?: ToolDecision;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function executeChatTurn(options: {
|
interface ChatTurnOptions {
|
||||||
config: BridgeConfig;
|
config: BridgeConfig;
|
||||||
cliModel: string;
|
cliModel: string;
|
||||||
effort: string;
|
effort: string;
|
||||||
@@ -331,9 +378,21 @@ async function executeChatTurn(options: {
|
|||||||
signal: AbortSignal;
|
signal: AbortSignal;
|
||||||
deadline: number;
|
deadline: number;
|
||||||
toolMode: boolean;
|
toolMode: boolean;
|
||||||
|
format: ResponseTextFormat;
|
||||||
|
validation?: StructuredValidation;
|
||||||
writer?: ChatCompletionSseWriter;
|
writer?: ChatCompletionSseWriter;
|
||||||
}): Promise<ChatExecution> {
|
}
|
||||||
|
|
||||||
|
class ChatStructuredOutputError extends Error {
|
||||||
|
constructor(readonly errors: string[]) {
|
||||||
|
super(errors.join("; "));
|
||||||
|
this.name = "ChatStructuredOutputError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeChatTurn(options: ChatTurnOptions): Promise<ChatExecution> {
|
||||||
const originalPrompt = buildCommandPrompt(options.request);
|
const originalPrompt = buildCommandPrompt(options.request);
|
||||||
|
const constrained = options.toolMode || options.validation !== undefined;
|
||||||
const first = await runCommandCode(
|
const first = await runCommandCode(
|
||||||
options.config,
|
options.config,
|
||||||
options.cliModel,
|
options.cliModel,
|
||||||
@@ -342,15 +401,38 @@ async function executeChatTurn(options: {
|
|||||||
options.signal,
|
options.signal,
|
||||||
{
|
{
|
||||||
timeoutMs: options.deadline - Date.now(),
|
timeoutMs: options.deadline - Date.now(),
|
||||||
...(!options.toolMode && options.writer
|
...(!constrained && options.writer
|
||||||
? { onEvent: (event: Record<string, unknown>) => options.writer?.commandEvent(event) }
|
? { onEvent: (event: Record<string, unknown>) => options.writer?.commandEvent(event) }
|
||||||
: {}),
|
: {}),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (!options.toolMode) return { result: first };
|
if (!options.toolMode) {
|
||||||
|
if (!options.validation) return { result: first };
|
||||||
|
const firstValidation = options.validation.validate(first.finalText);
|
||||||
|
if (firstValidation.valid) return { result: first };
|
||||||
|
|
||||||
|
process.stderr.write(`\nChat 结构化输出校验失败,正在修复:${firstValidation.errors.join("; ")}\n`);
|
||||||
|
const second = await runCommandCode(
|
||||||
|
options.config,
|
||||||
|
options.cliModel,
|
||||||
|
options.effort,
|
||||||
|
buildRepairPrompt(
|
||||||
|
originalPrompt,
|
||||||
|
options.format,
|
||||||
|
first.finalText,
|
||||||
|
firstValidation.errors,
|
||||||
|
),
|
||||||
|
options.signal,
|
||||||
|
{ timeoutMs: options.deadline - Date.now() },
|
||||||
|
);
|
||||||
|
const secondValidation = options.validation.validate(second.finalText);
|
||||||
|
if (!secondValidation.valid) throw new ChatStructuredOutputError(secondValidation.errors);
|
||||||
|
return { result: combineCommandResults(first, second) };
|
||||||
|
}
|
||||||
|
|
||||||
|
let execution: ChatExecution;
|
||||||
try {
|
try {
|
||||||
return {
|
execution = {
|
||||||
result: first,
|
result: first,
|
||||||
decision: parseToolDecision(first.finalText, options.request),
|
decision: parseToolDecision(first.finalText, options.request),
|
||||||
};
|
};
|
||||||
@@ -371,23 +453,56 @@ async function executeChatTurn(options: {
|
|||||||
options.signal,
|
options.signal,
|
||||||
{ timeoutMs: options.deadline - Date.now() },
|
{ timeoutMs: options.deadline - Date.now() },
|
||||||
);
|
);
|
||||||
const usage = addUsage(first.usage, second.usage);
|
execution = {
|
||||||
const result: CommandResult = {
|
result: combineCommandResults(first, second),
|
||||||
finalText: second.finalText,
|
|
||||||
durationMs: first.durationMs + second.durationMs,
|
|
||||||
...(usage ? { usage } : {}),
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
result,
|
|
||||||
decision: parseToolDecision(second.finalText, options.request),
|
decision: parseToolDecision(second.finalText, options.request),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (execution.decision?.type !== "final" || !options.validation) return execution;
|
||||||
|
const firstValidation = options.validation.validate(execution.decision.content);
|
||||||
|
if (firstValidation.valid) return execution;
|
||||||
|
|
||||||
|
process.stderr.write(`\nChat 最终工具正文结构化校验失败,正在修复:${firstValidation.errors.join("; ")}\n`);
|
||||||
|
const repair = await runCommandCode(
|
||||||
|
options.config,
|
||||||
|
options.cliModel,
|
||||||
|
options.effort,
|
||||||
|
buildToolStructuredOutputRepairPrompt(
|
||||||
|
originalPrompt,
|
||||||
|
options.request,
|
||||||
|
execution.decision.content,
|
||||||
|
firstValidation.errors,
|
||||||
|
),
|
||||||
|
options.signal,
|
||||||
|
{ timeoutMs: options.deadline - Date.now() },
|
||||||
|
);
|
||||||
|
const result = combineCommandResults(execution.result, repair);
|
||||||
|
let decision: ToolDecision;
|
||||||
|
try {
|
||||||
|
decision = parseToolDecision(repair.finalText, options.request);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ToolDecisionError) {
|
||||||
|
throw new ChatStructuredOutputError([
|
||||||
|
"Structured output repair did not return a valid final decision",
|
||||||
|
...error.errors,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
if (decision.type !== "final") {
|
||||||
|
throw new ChatStructuredOutputError(["Structured output repair returned tool_calls instead of final"]);
|
||||||
|
}
|
||||||
|
const repairedValidation = options.validation.validate(decision.content);
|
||||||
|
if (!repairedValidation.valid) throw new ChatStructuredOutputError(repairedValidation.errors);
|
||||||
|
return { result, decision };
|
||||||
}
|
}
|
||||||
|
|
||||||
function addUsage(left?: CommandUsage, right?: CommandUsage): CommandUsage | undefined {
|
function combineCommandResults(left: CommandResult, right: CommandResult): CommandResult {
|
||||||
if (!left && !right) return undefined;
|
const usage = addUsage(left.usage, right.usage);
|
||||||
return {
|
return {
|
||||||
inputTokens: (left?.inputTokens ?? 0) + (right?.inputTokens ?? 0),
|
finalText: right.finalText,
|
||||||
outputTokens: (left?.outputTokens ?? 0) + (right?.outputTokens ?? 0),
|
durationMs: left.durationMs + right.durationMs,
|
||||||
|
...(usage ? { usage } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user