fix(apicompat): responses→chat 桥支持 custom 工具,修复 Codex exec 丢失

Codex 0.14x 的核心执行工具 exec 是 type=custom(freeform)工具,
responsesToolsToChatTools 丢弃所有非 function 工具,导致模型工具列表中
没有 exec、无法执行任何命令;SSE 序列化层 responsesItemWire 也缺少
custom_tool_call 分支,即使产出该类型 item,call_id/name/input 也会被
白名单剥离。

- custom 工具降级为单一 input:string 参数的 function 工具
- 回程(非流式+流式)按名字还原 custom_tool_call 项/事件
- 历史 custom_tool_call / custom_tool_call_output 项转换(此前被跳过)
- responsesItemWire 补 custom_tool_call 分支
- tools 全部被丢弃时不再转发 tool_choice(修复上游 400
  "'tool_choice' is only allowed when 'tools' are specified")

feat(apicompat): chat 桥补齐 tool_search/namespace/字符串工具声明

对齐参考实现(cc-switch transform_codex_chat.rs)的工具类型覆盖:
- tool_search → 固定 function 代理工具(query 必填/limit 可选),
  历史项 tool_search_call/tool_search_output 一并转换
- namespace → 子 function 工具摊平为 <namespace>__<name>,
  超 64 字符截断加 sha256 短哈希;历史项带 namespace 字段时同规则摊平
- tools: ["name"] 字符串简写按 custom 工具解析

fix(apicompat): 工具调用宣告延迟到类型可判定,兼容 name 迟到的上游

output_item.added 的 custom/function 判定依赖工具名。名字晚于首个分片
到达时,此前会按 function_call 宣告造成 added/done 项类型不一致。
改为存在 custom 工具且名字未到时延迟宣告,宣告时补发已累积的参数增量;
请求无 custom 工具时行为不变。

PR 标题与正文(可直接粘贴)

标题:fix(apicompat): responses→chat 桥丢弃 custom 工具导致 Codex 无法执行命令

正文:

## 问题

OpenAI APIKey 账号接入仅支持 /v1/chat/completions 的第三方上游
(DeepSeek/GLM/Kimi 等)时,Codex 0.14x 客户端文本对话正常,但无法执行
任何命令:exec 调用全部 "aborted",spawn_agent 派出的子智能体永远卡在
running,多轮后模型自述"环境无法执行文件系统命令"放弃。

## 根因

Codex 0.14x 的核心执行工具 exec 是 Responses API 的 custom(freeform)
工具,而非普通 function:

    {"type": "custom", "name": "exec", "format": {"type": "grammar", ...}}

两处缺陷叠加:

1. `responsesToolsToChatTools` 丢弃所有非 function 工具
   (chatcompletions_responses_bridge.go),模型工具列表中从未出现 exec;
2. SSE 序列化 `responsesItemWire` 按 item 类型白名单重组字段,无
   custom_tool_call 分支,call_id/name/input 会被剥离
   (responses_stream_event_wire.go)。

次生缺陷:工具全部被丢弃时 tool_choice 仍被转发,部分上游报 400
"'tool_choice' is only allowed when 'tools' are specified"。

## 改动

(三个提交的要点清单,见各提交说明)

设计与 cc-switch 的 Codex chat 转换层(transform_codex_chat.rs /
streaming_codex_chat.rs)对齐:custom 工具降级为 {"input": string}
function 工具;custom 调用的参数增量不可流式还原为自由文本,缓冲至
收尾一次性下发 custom_tool_call_input.delta/done。

## 测试

- 新增 17 个单元测试:请求工具转换、tool_choice 兜底、历史项转换、
  非流式/流式回程还原、wire 序列化字段完整性、name 迟到宣告、命名摊平
- `go test ./internal/pkg/apicompat/...` 与 service 相关测试全绿
- 真机验证:Codex CLI 0.142.5 → 本桥 → GLM/DeepSeek 系 chat-only 上游,
  exec 实际执行命令并多轮续作正常

## 影响面

仅影响 "APIKey 账号 + 判定不支持 Responses" 的 chat 直转路径:

- OAuth 账号与透传路径不经过本桥(透传为字节级原文转发)
- CC→Responses 反方向仅构造 ResponsesRequest,新增字段带 omitempty
  且不赋值,序列化输出不变
- 探测逻辑未动,路由判定不变
- 共享类型改动均为增量容错(新字段 omitempty;UnmarshalJSON 只新增
  字符串工具的解析成功路径)
This commit is contained in:
xlplbo
2026-07-07 16:23:44 +08:00
parent db9d92ba8f
commit 75fb3c41c2
6 changed files with 812 additions and 57 deletions
@@ -1,6 +1,8 @@
package apicompat
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"strings"
@@ -35,13 +37,30 @@ func ResponsesToChatCompletionsRequest(req *ResponsesRequest) (*ChatCompletionsR
if len(req.Tools) > 0 {
out.Tools = responsesToolsToChatTools(req.Tools)
}
if len(req.ToolChoice) > 0 {
// tools 全部被丢弃(如仅含 web_search/image_generation 等服务端工具)时不再转发
// tool_choice:上游会拒绝 "'tool_choice' is only allowed when 'tools' are specified"。
if len(out.Tools) > 0 && len(req.ToolChoice) > 0 {
out.ToolChoice = responsesToolChoiceToChatToolChoice(req.ToolChoice)
}
return out, nil
}
// CustomToolNames 收集 Responses 请求中 custom/freeform 工具的名字。chat 桥回程时
// 需要据此把模型对这些工具的调用还原为 custom_tool_call 项(codex 只按该类型路由)。
func CustomToolNames(tools []ResponsesTool) map[string]bool {
var out map[string]bool
for _, tool := range tools {
if tool.Type == "custom" && tool.Name != "" {
if out == nil {
out = make(map[string]bool)
}
out[tool.Name] = true
}
}
return out
}
// responsesInputToChatMessages converts a Responses request's instructions +
// input[] into Chat Completions messages. It is a three-stage pipeline:
//
@@ -129,33 +148,68 @@ func buildChatMessagesFromItems(messages []ChatMessage, rawItems []json.RawMessa
if strings.TrimSpace(arguments) == "" {
arguments = "{}"
}
name := rawString(item["name"])
// namespace 子工具的历史调用带 namespace 字段,需与请求方向的摊平
// 命名(namespaceChildrenToChatTools)保持一致。
if ns := rawString(item["namespace"]); ns != "" {
name = flattenNamespaceToolName(ns, name)
}
toolCall := ChatToolCall{
ID: rawString(item["call_id"]),
Type: "function",
Function: ChatFunctionCall{
Name: name,
Arguments: arguments,
},
}
messages = appendAssistantToolCall(messages, toolCall, pendingReasoning)
pendingReasoning = ""
continue
case "tool_search_call":
// tool_search 调用的 arguments 是 JSON 对象(如 {"query": ...}),
// 原文即为降级 function 调用的 arguments 字符串。
arguments := strings.TrimSpace(string(bytesTrimSpace(item["arguments"])))
if s := rawString(item["arguments"]); s != "" {
arguments = s
}
if arguments == "" || arguments == "null" {
arguments = "{}"
}
toolCall := ChatToolCall{
ID: rawString(item["call_id"]),
Type: "function",
Function: ChatFunctionCall{
Name: toolSearchProxyName,
Arguments: arguments,
},
}
messages = appendAssistantToolCall(messages, toolCall, pendingReasoning)
pendingReasoning = ""
continue
case "custom_tool_call":
// custom/freeform 工具的历史调用:input 自由文本包进降级 function 工具
// 的 {"input": ...} 参数,与请求方向的工具降级(customToolInputSchema)
// 保持一致,模型才能把历史与当前工具定义对上。
arguments, _ := json.Marshal(map[string]string{"input": rawString(item["input"])})
toolCall := ChatToolCall{
ID: rawString(item["call_id"]),
Type: "function",
Function: ChatFunctionCall{
Name: rawString(item["name"]),
Arguments: arguments,
Arguments: string(arguments),
},
}
// Parallel tool calls arrive as consecutive function_call items and
// must share one assistant message; the matching tool replies then
// follow it. Merge into the immediately preceding assistant message.
if n := len(messages); n > 0 && messages[n-1].Role == "assistant" {
messages[n-1].ToolCalls = append(messages[n-1].ToolCalls, toolCall)
if messages[n-1].ReasoningContent == "" {
messages[n-1].ReasoningContent = pendingReasoning
}
} else {
messages = append(messages, ChatMessage{
Role: "assistant",
ToolCalls: []ChatToolCall{toolCall},
ReasoningContent: pendingReasoning,
})
}
messages = appendAssistantToolCall(messages, toolCall, pendingReasoning)
pendingReasoning = ""
continue
case "function_call_output":
content, _ := json.Marshal(rawString(item["output"]))
case "function_call_output", "custom_tool_call_output", "tool_search_output":
outputRaw := bytesTrimSpace(item["output"])
outputText := rawString(outputRaw)
if outputText == "" && len(outputRaw) > 0 && string(outputRaw) != "null" && string(outputRaw) != `""` {
// 对象/数组形式的输出(如 tool_search 的结果列表)整体字符串化。
outputText = string(outputRaw)
}
content, _ := json.Marshal(outputText)
messages = append(messages, ChatMessage{
Role: "tool",
ToolCallID: rawString(item["call_id"]),
@@ -180,9 +234,9 @@ func buildChatMessagesFromItems(messages []ChatMessage, rawItems []json.RawMessa
// Only genuine message items become chat messages. Codex emits other
// Responses item types with no Chat equivalent (web_search_call,
// local_shell_call, custom tool calls, file_search_call, ...). Converting
// them via the generic path would insert a spurious message between an
// assistant tool_calls message and its tool reply, which DeepSeek rejects
// local_shell_call, file_search_call, ...). Converting them via the
// generic path would insert a spurious message between an assistant
// tool_calls message and its tool reply, which DeepSeek rejects
// ("insufficient tool messages following tool_calls message"). Skip them.
if itemType != "" && itemType != "message" {
pendingReasoning = ""
@@ -209,6 +263,25 @@ func buildChatMessagesFromItems(messages []ChatMessage, rawItems []json.RawMessa
return messages, nil
}
// appendAssistantToolCall merges a tool call into the chat message list.
// Parallel tool calls arrive as consecutive *_call items and must share one
// assistant message; the matching tool replies then follow it. Merge into the
// immediately preceding assistant message.
func appendAssistantToolCall(messages []ChatMessage, toolCall ChatToolCall, pendingReasoning string) []ChatMessage {
if n := len(messages); n > 0 && messages[n-1].Role == "assistant" {
messages[n-1].ToolCalls = append(messages[n-1].ToolCalls, toolCall)
if messages[n-1].ReasoningContent == "" {
messages[n-1].ReasoningContent = pendingReasoning
}
return messages
}
return append(messages, ChatMessage{
Role: "assistant",
ToolCalls: []ChatToolCall{toolCall},
ReasoningContent: pendingReasoning,
})
}
// normalizeChatMessages is the single place that enforces the tool-call
// invariant the DeepSeek / OpenAI Chat Completions schema requires: an assistant
// message with tool_calls must be immediately followed by one tool message per
@@ -423,31 +496,123 @@ func chatContentFromSingleResponsesPart(partType string, part map[string]json.Ra
}
}
// customToolInputSchema 是 custom/freeform 工具降级为 function 工具时的参数 schema。
// chat 协议无法表达 custom 工具的自由文本输入(及其 grammar 约束),退化为单一
// input 字符串参数;回程时再从 arguments 的 input 字段还原(见
// extractCustomToolCallInput)。
const customToolInputSchema = `{"type":"object","properties":{"input":{"type":"string","description":"The raw input for this tool, passed through verbatim."}},"required":["input"]}`
func responsesToolsToChatTools(tools []ResponsesTool) []ChatTool {
out := make([]ChatTool, 0, len(tools))
for _, tool := range tools {
if tool.Type != "function" {
switch tool.Type {
case "function":
out = append(out, ChatTool{
Type: "function",
Function: &ChatFunction{
Name: tool.Name,
Description: tool.Description,
Parameters: tool.Parameters,
Strict: tool.Strict,
},
})
case "custom":
// codex 0.14x 的核心执行工具 exec 即为 custom 类型;丢弃它会让模型
// 无法执行任何命令,必须降级为 function 工具透传。
out = append(out, ChatTool{
Type: "function",
Function: &ChatFunction{
Name: tool.Name,
Description: tool.Description,
Parameters: json.RawMessage(customToolInputSchema),
},
})
case "tool_search":
out = append(out, toolSearchProxyChatTool())
case "namespace":
out = append(out, namespaceChildrenToChatTools(tool)...)
}
// 其余类型(web_search、image_generation 等服务端工具)在 chat 上游没有
// 对应能力,维持丢弃。
}
return out
}
// toolSearchProxyName 是 tool_search 服务端工具降级后的 function 工具名。模型对
// 它的调用以同名 function_call 原样回传,由 codex 端路由。
const toolSearchProxyName = "tool_search"
const toolSearchProxySchema = `{"type":"object","properties":{"query":{"type":"string","description":"Search query for tools or connectors to load."},"limit":{"type":"integer","description":"Maximum number of tool groups to return."}},"required":["query"]}`
func toolSearchProxyChatTool() ChatTool {
return ChatTool{
Type: "function",
Function: &ChatFunction{
Name: toolSearchProxyName,
Description: "Search and load Codex tools, plugins, connectors, and MCP namespaces for the current task.",
Parameters: json.RawMessage(toolSearchProxySchema),
},
}
}
// namespaceChildrenToChatTools 将 namespace 工具的子 function 工具摊平为顶层
// function 工具,名字加 "<namespace>__" 前缀。
func namespaceChildrenToChatTools(tool ResponsesTool) []ChatTool {
if tool.Name == "" {
return nil
}
children := tool.Tools
if len(children) == 0 {
children = tool.Children
}
var out []ChatTool
for _, child := range children {
if child.Type != "function" || child.Name == "" {
continue
}
out = append(out, ChatTool{
Type: "function",
Function: &ChatFunction{
Name: tool.Name,
Description: tool.Description,
Parameters: tool.Parameters,
Strict: tool.Strict,
Name: flattenNamespaceToolName(tool.Name, child.Name),
Description: child.Description,
Parameters: child.Parameters,
Strict: child.Strict,
},
})
}
return out
}
// chatToolNameMaxLen 是 Chat Completions function 工具名的通用长度上限。
const chatToolNameMaxLen = 64
// flattenNamespaceToolName 生成 namespace 子工具的摊平名;超长时截断并追加
// sha256 短哈希保证唯一性。
func flattenNamespaceToolName(namespace, name string) string {
full := namespace + "__" + name
if len(full) <= chatToolNameMaxLen {
return full
}
sum := sha256.Sum256([]byte(full))
suffix := "__" + hex.EncodeToString(sum[:4])
prefixLen := chatToolNameMaxLen - len(suffix)
var prefix strings.Builder
for _, ch := range full {
if prefix.Len()+len(string(ch)) > prefixLen {
break
}
_, _ = prefix.WriteRune(ch)
}
return prefix.String() + suffix
}
func responsesToolChoiceToChatToolChoice(raw json.RawMessage) json.RawMessage {
var choice map[string]json.RawMessage
if err := json.Unmarshal(raw, &choice); err != nil {
return raw
}
if rawString(choice["type"]) != "function" {
// custom 工具已降级为 function 工具,指向它的 tool_choice 同样按 function 转换。
if t := rawString(choice["type"]); t != "function" && t != "custom" {
return raw
}
name := rawString(choice["name"])
@@ -469,9 +634,35 @@ func responsesToolChoiceToChatToolChoice(raw json.RawMessage) json.RawMessage {
return out
}
// extractCustomToolCallInput 从降级 function 调用的 arguments 中还原 custom 工具的
// 自由文本输入:优先取 {"input": "..."} 的 input 字段;模型未按 schema 输出时原样
// 回传,交由客户端校验、模型重试。
func extractCustomToolCallInput(arguments string) string {
trimmed := strings.TrimSpace(arguments)
if trimmed == "" {
return ""
}
var obj map[string]json.RawMessage
if err := json.Unmarshal([]byte(trimmed), &obj); err != nil {
return trimmed
}
if raw, ok := obj["input"]; ok {
var s string
if err := json.Unmarshal(raw, &s); err == nil {
return s
}
return trimmed
}
if len(obj) == 0 {
return ""
}
return trimmed
}
// ChatCompletionsResponseToResponses converts a non-streaming Chat Completions
// response into a Responses API response.
func ChatCompletionsResponseToResponses(resp *ChatCompletionsResponse, model string) *ResponsesResponse {
// response into a Responses API response. customTools 是客户端请求中 custom 工具
// 的名字集合(见 CustomToolNames),命中的调用会还原为 custom_tool_call 项。
func ChatCompletionsResponseToResponses(resp *ChatCompletionsResponse, model string, customTools map[string]bool) *ResponsesResponse {
id := ""
if resp != nil {
id = resp.ID
@@ -496,7 +687,7 @@ func ChatCompletionsResponseToResponses(resp *ChatCompletionsResponse, model str
if len(resp.Choices) > 0 {
choice := resp.Choices[0]
out.Output = chatMessageToResponsesOutput(choice.Message)
out.Output = chatMessageToResponsesOutput(choice.Message, customTools)
if choice.FinishReason == "length" {
out.Status = "incomplete"
out.IncompleteDetails = &ResponsesIncompleteDetails{Reason: "max_output_tokens"}
@@ -511,7 +702,7 @@ func ChatCompletionsResponseToResponses(resp *ChatCompletionsResponse, model str
return out
}
func chatMessageToResponsesOutput(message ChatMessage) []ResponsesOutput {
func chatMessageToResponsesOutput(message ChatMessage, customTools map[string]bool) []ResponsesOutput {
var outputs []ResponsesOutput
if message.ReasoningContent != "" {
outputs = append(outputs, ResponsesOutput{
@@ -546,6 +737,17 @@ func chatMessageToResponsesOutput(message ChatMessage) []ResponsesOutput {
if strings.TrimSpace(arguments) == "" {
arguments = "{}"
}
if customTools[toolCall.Function.Name] {
outputs = append(outputs, ResponsesOutput{
Type: "custom_tool_call",
ID: generateItemID(),
CallID: toolCall.ID,
Name: toolCall.Function.Name,
Input: extractCustomToolCallInput(arguments),
Status: "completed",
})
continue
}
outputs = append(outputs, ResponsesOutput{
Type: "function_call",
ID: generateItemID(),
@@ -650,6 +852,19 @@ type ChatCompletionsToResponsesStreamState struct {
ToolItemIDs map[int]string
ToolOutputIndex map[int]int
// CustomTools 是客户端请求中 custom/freeform 工具的名字集合(见
// CustomToolNames)。命中的调用按 custom_tool_call 生命周期下发,codex 才能
// 路由回它注册的 custom 工具。
CustomTools map[string]bool
// toolIsCustom 记录每个工具调用宣告时的类型判定,保证 added/done 事件的
// 项类型一致。
toolIsCustom map[int]bool
// toolAnnounced 记录 output_item.added 是否已发出。存在 custom 工具且名字
// 尚未到达时延迟宣告,待名字可判定类型后再补发(见 announceChatToolItem)。
toolAnnounced map[int]bool
FinishReason string
Usage *ResponsesUsage
}
@@ -663,6 +878,8 @@ func NewChatCompletionsToResponsesStreamState(model string) *ChatCompletionsToRe
ToolCalls: make(map[int]*ChatToolCall),
ToolItemIDs: make(map[int]string),
ToolOutputIndex: make(map[int]int),
toolIsCustom: make(map[int]bool),
toolAnnounced: make(map[int]bool),
}
}
@@ -746,19 +963,8 @@ func ChatCompletionsChunkToResponsesEvents(
copyCall.Function.Arguments = ""
state.ToolCalls[idx] = &copyCall
stored = &copyCall
itemID := generateItemID()
state.ToolItemIDs[idx] = itemID
state.ToolItemIDs[idx] = generateItemID()
state.ToolOutputIndex[idx] = state.allocOutputIndex()
events = append(events, chatToResponsesEvent(state, "response.output_item.added", &ResponsesStreamEvent{
OutputIndex: state.ToolOutputIndex[idx],
Item: &ResponsesOutput{
Type: "function_call",
ID: itemID,
CallID: stored.ID,
Name: stored.Function.Name,
Status: "in_progress",
},
}))
} else {
if toolCall.ID != "" {
stored.ID = toolCall.ID
@@ -767,15 +973,21 @@ func ChatCompletionsChunkToResponsesEvents(
stored.Function.Name = toolCall.Function.Name
}
}
events = append(events, announceChatToolItem(state, idx, stored, false)...)
if toolCall.Function.Arguments != "" {
stored.Function.Arguments += toolCall.Function.Arguments
events = append(events, chatToResponsesEvent(state, "response.function_call_arguments.delta", &ResponsesStreamEvent{
OutputIndex: state.ToolOutputIndex[idx],
ItemID: state.ToolItemIDs[idx],
Delta: toolCall.Function.Arguments,
CallID: stored.ID,
Name: stored.Function.Name,
}))
// 未宣告(名字未到)时仅累积,宣告时统一补发;custom 调用的
// arguments 是包裹 input 的 JSON 片段,无法增量还原为自由文本
// 输入,缓冲整份 arguments 收尾时一次性下发(见 closeChatToolItems)。
if state.toolAnnounced[idx] && !state.toolIsCustom[idx] {
events = append(events, chatToResponsesEvent(state, "response.function_call_arguments.delta", &ResponsesStreamEvent{
OutputIndex: state.ToolOutputIndex[idx],
ItemID: state.ToolItemIDs[idx],
Delta: toolCall.Function.Arguments,
CallID: stored.ID,
Name: stored.Function.Name,
}))
}
}
}
if choice.FinishReason != nil && *choice.FinishReason != "" {
@@ -986,6 +1198,51 @@ func ensureChatToResponsesTextPart(state *ChatCompletionsToResponsesStreamState)
})}
}
// announceChatToolItem 在类型可判定时发出工具调用的 output_item.added。custom
// 工具的判定依赖名字:名字未到且请求里存在 custom 工具时延迟宣告,避免 added/done
// 的项类型不一致;force 用于流收尾,名字始终未到时按 function_call 兜底。
func announceChatToolItem(
state *ChatCompletionsToResponsesStreamState,
idx int,
stored *ChatToolCall,
force bool,
) []ResponsesStreamEvent {
if state.toolAnnounced[idx] {
return nil
}
if !force && stored.Function.Name == "" && len(state.CustomTools) > 0 {
return nil
}
state.toolAnnounced[idx] = true
isCustom := state.CustomTools[stored.Function.Name]
state.toolIsCustom[idx] = isCustom
itemType := "function_call"
if isCustom {
itemType = "custom_tool_call"
}
events := []ResponsesStreamEvent{chatToResponsesEvent(state, "response.output_item.added", &ResponsesStreamEvent{
OutputIndex: state.ToolOutputIndex[idx],
Item: &ResponsesOutput{
Type: itemType,
ID: state.ToolItemIDs[idx],
CallID: stored.ID,
Name: stored.Function.Name,
Status: "in_progress",
},
})}
// 迟到宣告时补发已累积的参数增量(custom 工具的输入收尾统一下发,不补发)。
if !isCustom && stored.Function.Arguments != "" {
events = append(events, chatToResponsesEvent(state, "response.function_call_arguments.delta", &ResponsesStreamEvent{
OutputIndex: state.ToolOutputIndex[idx],
ItemID: state.ToolItemIDs[idx],
Delta: stored.Function.Arguments,
CallID: stored.ID,
Name: stored.Function.Name,
}))
}
return events
}
// closeChatToolItems emits function_call_arguments.done + output_item.done for
// every tool call opened during the stream, carrying the full call_id/name/
// arguments so codex can deserialize and execute the call. Mirrors cc-switch's
@@ -1004,11 +1261,46 @@ func closeChatToolItems(state *ChatCompletionsToResponsesStreamState) []Response
if !opened {
continue
}
// 名字始终未到导致尚未宣告的调用,收尾前按最终名字兜底宣告。
events = append(events, announceChatToolItem(state, i, toolCall, true)...)
arguments := toolCall.Function.Arguments
if strings.TrimSpace(arguments) == "" {
arguments = "{}"
}
outputIndex := state.ToolOutputIndex[i]
if state.toolIsCustom[i] {
// custom 调用按 custom_tool_call 生命周期收尾:input 在此处一次性下发
// (流中不产出增量,见 ChatCompletionsChunkToResponsesEvents)。
input := extractCustomToolCallInput(arguments)
if input != "" {
events = append(events, chatToResponsesEvent(state, "response.custom_tool_call_input.delta", &ResponsesStreamEvent{
OutputIndex: outputIndex,
ItemID: itemID,
Delta: input,
}))
}
events = append(events,
chatToResponsesEvent(state, "response.custom_tool_call_input.done", &ResponsesStreamEvent{
OutputIndex: outputIndex,
ItemID: itemID,
CallID: toolCall.ID,
Name: toolCall.Function.Name,
Input: input,
}),
chatToResponsesEvent(state, "response.output_item.done", &ResponsesStreamEvent{
OutputIndex: outputIndex,
Item: &ResponsesOutput{
Type: "custom_tool_call",
ID: itemID,
CallID: toolCall.ID,
Name: toolCall.Function.Name,
Input: input,
Status: "completed",
},
}),
)
continue
}
events = append(events,
chatToResponsesEvent(state, "response.function_call_arguments.done", &ResponsesStreamEvent{
OutputIndex: outputIndex,
@@ -1066,6 +1358,17 @@ func (state *ChatCompletionsToResponsesStreamState) chatOutput() []ResponsesOutp
if strings.TrimSpace(arguments) == "" {
arguments = "{}"
}
if state.toolIsCustom[i] {
outputs = append(outputs, ResponsesOutput{
Type: "custom_tool_call",
ID: generateItemID(),
CallID: toolCall.ID,
Name: toolCall.Function.Name,
Input: extractCustomToolCallInput(arguments),
Status: "completed",
})
continue
}
outputs = append(outputs, ResponsesOutput{
Type: "function_call",
ID: generateItemID(),
@@ -0,0 +1,414 @@
package apicompat
// custom/freeform 工具(如 Codex 0.14x 的 exec)在 responses→chat 桥上的双向转换。
// 背景:Codex 的核心命令执行工具 exec 是 type=custom(输入为自由文本),此前被
// responsesToolsToChatTools 丢弃,导致模型工具列表中没有 exec、无法执行任何命令。
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestResponsesToChatCompletionsRequest_CustomToolBecomesFunctionTool(t *testing.T) {
req := &ResponsesRequest{
Model: "glm-5.2",
Input: json.RawMessage(`"run dir"`),
Tools: []ResponsesTool{
{Type: "custom", Name: "exec", Description: "Run JavaScript code"},
{Type: "function", Name: "wait", Parameters: json.RawMessage(`{"type":"object","properties":{}}`)},
},
}
out, err := ResponsesToChatCompletionsRequest(req)
require.NoError(t, err)
require.Len(t, out.Tools, 2)
assert.Equal(t, "function", out.Tools[0].Type)
assert.Equal(t, "exec", out.Tools[0].Function.Name)
assert.Equal(t, "Run JavaScript code", out.Tools[0].Function.Description)
assert.JSONEq(t, customToolInputSchema, string(out.Tools[0].Function.Parameters))
assert.Equal(t, "wait", out.Tools[1].Function.Name)
}
func TestResponsesToChatCompletionsRequest_DropsToolChoiceWhenNoConvertibleTools(t *testing.T) {
req := &ResponsesRequest{
Model: "glm-5.2",
Input: json.RawMessage(`"hi"`),
Tools: []ResponsesTool{
{Type: "web_search"},
{Type: "image_generation"},
},
ToolChoice: json.RawMessage(`"auto"`),
}
out, err := ResponsesToChatCompletionsRequest(req)
require.NoError(t, err)
assert.Empty(t, out.Tools)
assert.Empty(t, out.ToolChoice, "tools 为空时转发 tool_choice 会被上游 400 拒绝")
}
func TestResponsesToChatCompletionsRequest_CustomToolChoiceMapsToFunctionChoice(t *testing.T) {
req := &ResponsesRequest{
Model: "glm-5.2",
Input: json.RawMessage(`"run dir"`),
Tools: []ResponsesTool{{Type: "custom", Name: "exec"}},
ToolChoice: json.RawMessage(`{"type":"custom","name":"exec"}`),
}
out, err := ResponsesToChatCompletionsRequest(req)
require.NoError(t, err)
assert.JSONEq(t, `{"type":"function","function":{"name":"exec"}}`, string(out.ToolChoice))
}
func TestResponsesInputToChatMessages_CustomToolCallHistory(t *testing.T) {
input := json.RawMessage(`[
{"role":"user","content":"list files"},
{"type":"custom_tool_call","call_id":"call_1","name":"exec","input":"dir"},
{"type":"custom_tool_call_output","call_id":"call_1","output":"main.go"}
]`)
messages, err := responsesInputToChatMessages("", input)
require.NoError(t, err)
require.Len(t, messages, 3)
assert.Equal(t, []string{"user", "assistant", "tool"}, chatMessageRoles(messages))
require.Len(t, messages[1].ToolCalls, 1)
toolCall := messages[1].ToolCalls[0]
assert.Equal(t, "call_1", toolCall.ID)
assert.Equal(t, "exec", toolCall.Function.Name)
assert.JSONEq(t, `{"input":"dir"}`, toolCall.Function.Arguments)
assert.Equal(t, "call_1", messages[2].ToolCallID)
assert.JSONEq(t, `"main.go"`, string(messages[2].Content))
}
func TestChatCompletionsResponseToResponses_CustomToolCallOutputItem(t *testing.T) {
resp := &ChatCompletionsResponse{
ID: "cc-1",
Choices: []ChatChoice{{
Message: ChatMessage{
Role: "assistant",
ToolCalls: []ChatToolCall{
{ID: "call_1", Function: ChatFunctionCall{Name: "exec", Arguments: `{"input": "dir"}`}},
{ID: "call_2", Function: ChatFunctionCall{Name: "wait", Arguments: `{"cell_id": 3}`}},
},
},
}},
}
out := ChatCompletionsResponseToResponses(resp, "glm-5.2", map[string]bool{"exec": true})
require.Len(t, out.Output, 2)
assert.Equal(t, "custom_tool_call", out.Output[0].Type)
assert.Equal(t, "call_1", out.Output[0].CallID)
assert.Equal(t, "exec", out.Output[0].Name)
assert.Equal(t, "dir", out.Output[0].Input)
assert.Empty(t, out.Output[0].Arguments)
assert.Equal(t, "function_call", out.Output[1].Type)
assert.Equal(t, "wait", out.Output[1].Name)
assert.Equal(t, `{"cell_id": 3}`, out.Output[1].Arguments)
}
func TestExtractCustomToolCallInput_FallsBackToRawArguments(t *testing.T) {
assert.Equal(t, "dir", extractCustomToolCallInput(`{"input": "dir"}`))
assert.Equal(t, "console.log(1)", extractCustomToolCallInput(`console.log(1)`))
assert.Equal(t, `{"other": "x"}`, extractCustomToolCallInput(`{"other": "x"}`))
assert.Equal(t, "", extractCustomToolCallInput(`{}`))
assert.Equal(t, "", extractCustomToolCallInput(""))
}
func TestChatCompletionsChunkToResponsesEvents_CustomToolCallStream(t *testing.T) {
state := NewChatCompletionsToResponsesStreamState("glm-5.2")
state.CustomTools = map[string]bool{"exec": true}
idx := 0
chunk := &ChatCompletionsChunk{
ID: "cc-1",
Choices: []ChatChunkChoice{{
Delta: ChatDelta{
ToolCalls: []ChatToolCall{{
Index: &idx,
ID: "call_1",
Function: ChatFunctionCall{Name: "exec", Arguments: `{"input": "dir"}`},
}},
},
}},
}
events := ChatCompletionsChunkToResponsesEvents(chunk, state)
events = append(events, FinalizeChatCompletionsResponsesStream(state)...)
var added, inputDone, itemDone *ResponsesStreamEvent
for i := range events {
evt := &events[i]
switch evt.Type {
case "response.output_item.added":
if evt.Item != nil && evt.Item.Type != "message" && evt.Item.Type != "reasoning" {
added = evt
}
case "response.custom_tool_call_input.done":
inputDone = evt
case "response.output_item.done":
if evt.Item != nil && evt.Item.Type == "custom_tool_call" {
itemDone = evt
}
case "response.function_call_arguments.delta", "response.function_call_arguments.done":
t.Fatalf("custom 工具调用不应产出 function_call 参数事件: %s", evt.Type)
}
}
require.NotNil(t, added, "缺少 custom_tool_call 的 output_item.added")
assert.Equal(t, "custom_tool_call", added.Item.Type)
assert.Equal(t, "exec", added.Item.Name)
require.NotNil(t, inputDone, "缺少 response.custom_tool_call_input.done")
assert.Equal(t, "dir", inputDone.Input)
assert.Equal(t, "call_1", inputDone.CallID)
require.NotNil(t, itemDone, "缺少 custom_tool_call 的 output_item.done")
assert.Equal(t, "call_1", itemDone.Item.CallID)
assert.Equal(t, "exec", itemDone.Item.Name)
assert.Equal(t, "dir", itemDone.Item.Input)
assert.Empty(t, itemDone.Item.Arguments)
// response.completed 的 output 数组同样携带 custom_tool_call 项。
final := events[len(events)-1]
require.Equal(t, "response.completed", final.Type)
require.NotNil(t, final.Response)
foundCustom := false
for _, item := range final.Response.Output {
if item.Type == "custom_tool_call" {
foundCustom = true
assert.Equal(t, "exec", item.Name)
assert.Equal(t, "dir", item.Input)
}
}
assert.True(t, foundCustom, "response.completed 缺少 custom_tool_call 输出项")
}
func TestResponsesToChatCompletionsRequest_ToolSearchToolBecomesProxyFunction(t *testing.T) {
req := &ResponsesRequest{
Model: "glm-5.2",
Input: json.RawMessage(`"hi"`),
Tools: []ResponsesTool{{Type: "tool_search"}},
}
out, err := ResponsesToChatCompletionsRequest(req)
require.NoError(t, err)
require.Len(t, out.Tools, 1)
assert.Equal(t, "function", out.Tools[0].Type)
assert.Equal(t, "tool_search", out.Tools[0].Function.Name)
assert.Contains(t, string(out.Tools[0].Function.Parameters), `"query"`)
}
func TestResponsesToChatCompletionsRequest_NamespaceToolFlattensChildren(t *testing.T) {
req := &ResponsesRequest{
Model: "glm-5.2",
Input: json.RawMessage(`"hi"`),
Tools: []ResponsesTool{{
Type: "namespace",
Name: "gmail",
Tools: []ResponsesTool{
{Type: "function", Name: "send", Description: "Send mail", Parameters: json.RawMessage(`{"type":"object","properties":{}}`)},
{Type: "custom", Name: "ignored_child"},
},
}},
}
out, err := ResponsesToChatCompletionsRequest(req)
require.NoError(t, err)
require.Len(t, out.Tools, 1, "namespace 子工具中仅 function 类型被摊平")
assert.Equal(t, "gmail__send", out.Tools[0].Function.Name)
assert.Equal(t, "Send mail", out.Tools[0].Function.Description)
}
func TestResponsesToolsParsing_StringToolBecomesCustom(t *testing.T) {
var req ResponsesRequest
require.NoError(t, json.Unmarshal([]byte(`{"model":"glm-5.2","input":"hi","tools":["exec",{"type":"function","name":"wait"}]}`), &req))
require.Len(t, req.Tools, 2)
assert.Equal(t, "custom", req.Tools[0].Type)
assert.Equal(t, "exec", req.Tools[0].Name)
assert.Equal(t, "function", req.Tools[1].Type)
assert.True(t, CustomToolNames(req.Tools)["exec"])
}
func TestFlattenNamespaceToolName_CapsAt64WithHashSuffix(t *testing.T) {
assert.Equal(t, "gmail__send", flattenNamespaceToolName("gmail", "send"))
long := flattenNamespaceToolName("very_long_namespace_prefix_for_testing_purposes", "and_a_rather_long_tool_name_too")
assert.LessOrEqual(t, len(long), 64)
assert.Contains(t, long, "__")
// 同输入结果稳定
assert.Equal(t, long, flattenNamespaceToolName("very_long_namespace_prefix_for_testing_purposes", "and_a_rather_long_tool_name_too"))
}
func TestResponsesInputToChatMessages_ToolSearchCallHistory(t *testing.T) {
input := json.RawMessage(`[
{"role":"user","content":"find tools"},
{"type":"tool_search_call","call_id":"call_s","arguments":{"query":"gmail"}},
{"type":"tool_search_output","call_id":"call_s","output":{"groups":["gmail"]}}
]`)
messages, err := responsesInputToChatMessages("", input)
require.NoError(t, err)
require.Len(t, messages, 3)
require.Len(t, messages[1].ToolCalls, 1)
assert.Equal(t, "tool_search", messages[1].ToolCalls[0].Function.Name)
assert.JSONEq(t, `{"query":"gmail"}`, messages[1].ToolCalls[0].Function.Arguments)
assert.Equal(t, "tool", messages[2].Role)
assert.Equal(t, "call_s", messages[2].ToolCallID)
assert.JSONEq(t, `"{\"groups\":[\"gmail\"]}"`, string(messages[2].Content))
}
func TestResponsesInputToChatMessages_NamespacedFunctionCallHistory(t *testing.T) {
input := json.RawMessage(`[
{"type":"function_call","call_id":"call_n","name":"send","namespace":"gmail","arguments":"{\"to\":\"a\"}"},
{"type":"function_call_output","call_id":"call_n","output":"ok"}
]`)
messages, err := responsesInputToChatMessages("", input)
require.NoError(t, err)
require.Len(t, messages, 2)
require.Len(t, messages[0].ToolCalls, 1)
assert.Equal(t, "gmail__send", messages[0].ToolCalls[0].Function.Name)
}
func TestChatCompletionsChunkToResponsesEvents_CustomToolNameArrivesLate(t *testing.T) {
state := NewChatCompletionsToResponsesStreamState("glm-5.2")
state.CustomTools = map[string]bool{"exec": true}
idx := 0
chunk1 := &ChatCompletionsChunk{Choices: []ChatChunkChoice{{Delta: ChatDelta{
ToolCalls: []ChatToolCall{{Index: &idx, ID: "call_1", Function: ChatFunctionCall{Arguments: `{"inp`}}},
}}}}
chunk2 := &ChatCompletionsChunk{Choices: []ChatChunkChoice{{Delta: ChatDelta{
ToolCalls: []ChatToolCall{{Index: &idx, Function: ChatFunctionCall{Name: "exec", Arguments: `ut": "dir"}`}}},
}}}}
var events []ResponsesStreamEvent
events = append(events, ChatCompletionsChunkToResponsesEvents(chunk1, state)...)
events = append(events, ChatCompletionsChunkToResponsesEvents(chunk2, state)...)
events = append(events, FinalizeChatCompletionsResponsesStream(state)...)
addedCount := 0
for _, evt := range events {
switch evt.Type {
case "response.output_item.added":
if evt.Item != nil && evt.Item.Type != "reasoning" && evt.Item.Type != "message" {
addedCount++
assert.Equal(t, "custom_tool_call", evt.Item.Type, "迟到的名字命中 custom 工具时按 custom_tool_call 宣告")
assert.Equal(t, "exec", evt.Item.Name)
}
case "response.function_call_arguments.delta", "response.function_call_arguments.done":
t.Fatalf("custom 调用不应产出 function 参数事件: %s", evt.Type)
case "response.custom_tool_call_input.done":
assert.Equal(t, "dir", evt.Input)
}
}
assert.Equal(t, 1, addedCount, "工具调用只宣告一次")
}
func TestChatCompletionsChunkToResponsesEvents_FunctionToolNameArrivesLate(t *testing.T) {
state := NewChatCompletionsToResponsesStreamState("glm-5.2")
state.CustomTools = map[string]bool{"exec": true}
idx := 0
chunk1 := &ChatCompletionsChunk{Choices: []ChatChunkChoice{{Delta: ChatDelta{
ToolCalls: []ChatToolCall{{Index: &idx, ID: "call_9", Function: ChatFunctionCall{Arguments: `{"cell`}}},
}}}}
chunk2 := &ChatCompletionsChunk{Choices: []ChatChunkChoice{{Delta: ChatDelta{
ToolCalls: []ChatToolCall{{Index: &idx, Function: ChatFunctionCall{Name: "wait", Arguments: `_id": 3}`}}},
}}}}
var events []ResponsesStreamEvent
events = append(events, ChatCompletionsChunkToResponsesEvents(chunk1, state)...)
events = append(events, ChatCompletionsChunkToResponsesEvents(chunk2, state)...)
events = append(events, FinalizeChatCompletionsResponsesStream(state)...)
deltas := ""
argsDone := ""
for _, evt := range events {
switch evt.Type {
case "response.function_call_arguments.delta":
deltas += evt.Delta
case "response.function_call_arguments.done":
argsDone = evt.Arguments
case "response.custom_tool_call_input.done":
t.Fatal("function 调用不应产出 custom 事件")
}
}
assert.Equal(t, `{"cell_id": 3}`, deltas, "宣告前累积的参数需在宣告时补发")
assert.Equal(t, `{"cell_id": 3}`, argsDone)
}
// 序列化层(MarshalJSON → responsesItemWire)单独走白名单重组,事件结构体上的字段
// 齐全不代表落到 SSE 线上的 JSON 齐全,必须在 wire 层再断言一次。
func TestResponsesEventToSSE_CustomToolCallItemCarriesAllFields(t *testing.T) {
evt := ResponsesStreamEvent{
Type: "response.output_item.done",
OutputIndex: 1,
Item: &ResponsesOutput{
Type: "custom_tool_call",
ID: "item_1",
CallID: "call_1",
Name: "exec",
Input: "dir",
Status: "completed",
},
}
sse, err := ResponsesEventToSSE(evt)
require.NoError(t, err)
assert.Contains(t, sse, `"call_id":"call_1"`)
assert.Contains(t, sse, `"name":"exec"`)
assert.Contains(t, sse, `"input":"dir"`)
assert.Contains(t, sse, `"type":"custom_tool_call"`)
}
func TestChatCompletionsChunkToResponsesEvents_FunctionToolStreamUnaffected(t *testing.T) {
state := NewChatCompletionsToResponsesStreamState("glm-5.2")
state.CustomTools = map[string]bool{"exec": true}
idx := 0
chunk := &ChatCompletionsChunk{
Choices: []ChatChunkChoice{{
Delta: ChatDelta{
ToolCalls: []ChatToolCall{{
Index: &idx,
ID: "call_9",
Function: ChatFunctionCall{Name: "wait", Arguments: `{"cell_id": 3}`},
}},
},
}},
}
events := ChatCompletionsChunkToResponsesEvents(chunk, state)
events = append(events, FinalizeChatCompletionsResponsesStream(state)...)
sawArgsDelta := false
for _, evt := range events {
if evt.Type == "response.function_call_arguments.delta" {
sawArgsDelta = true
}
if evt.Type == "response.custom_tool_call_input.done" {
t.Fatal("function 工具不应产出 custom_tool_call 事件")
}
}
assert.True(t, sawArgsDelta, "function 工具应保持原有参数增量事件")
}
@@ -362,7 +362,7 @@ func TestChatCompletionsResponseToResponses_DeepSeekReasoningOnlyFallsBackToMess
}},
}
out := ChatCompletionsResponseToResponses(resp, "deepseek-reasoner")
out := ChatCompletionsResponseToResponses(resp, "deepseek-reasoner", nil)
require.Len(t, out.Output, 2)
require.Equal(t, "reasoning", out.Output[0].Type)
@@ -396,7 +396,7 @@ func TestChatCompletionsResponseToResponses_DeepSeekReasoningToolCallDoesNotFall
}},
}
out := ChatCompletionsResponseToResponses(resp, "deepseek-reasoner")
out := ChatCompletionsResponseToResponses(resp, "deepseek-reasoner", nil)
require.Len(t, out.Output, 2)
require.Equal(t, "reasoning", out.Output[0].Type)
@@ -167,6 +167,12 @@ func responsesItemWire(item *ResponsesOutput) map[string]any {
m["call_id"] = item.CallID
m["name"] = item.Name
m["arguments"] = item.Arguments
case "custom_tool_call":
// custom/freeform 工具调用(如 codex 的 exec):input 为自由文本。缺少
// call_id/name 时 codex 无法路由该调用(表现为 unsupported call)。
m["call_id"] = item.CallID
m["name"] = item.Name
m["input"] = item.Input
}
return m
}
+27 -1
View File
@@ -248,11 +248,31 @@ type ResponsesContentPart struct {
// ResponsesTool describes a tool in the Responses API.
type ResponsesTool struct {
Type string `json:"type"` // "function" | "web_search" | "local_shell" etc.
Type string `json:"type"` // "function" | "custom" | "web_search" | "local_shell" etc.
Name string `json:"name,omitempty"`
Description string `json:"description,omitempty"`
Parameters json.RawMessage `json:"parameters,omitempty"`
Strict *bool `json:"strict,omitempty"`
// type=namespace 的子工具列表(tools 与 children 二选一,语义相同)。
Tools []ResponsesTool `json:"tools,omitempty"`
Children []ResponsesTool `json:"children,omitempty"`
}
// UnmarshalJSON 容忍字符串形式的工具声明:codex 会以 "name" 简写声明 custom 工具,
func (t *ResponsesTool) UnmarshalJSON(data []byte) error {
var name string
if err := json.Unmarshal(data, &name); err == nil {
*t = ResponsesTool{Type: "custom", Name: name}
return nil
}
type alias ResponsesTool
var a alias
if err := json.Unmarshal(data, &a); err != nil {
return err
}
*t = ResponsesTool(a)
return nil
}
// ResponsesResponse is the non-streaming response from POST /v1/responses.
@@ -301,6 +321,9 @@ type ResponsesOutput struct {
Name string `json:"name,omitempty"`
Arguments string `json:"arguments,omitempty"`
// type=custom_tool_call(custom/freeform 工具,input 为自由文本)
Input string `json:"input,omitempty"`
// type=web_search_call
Action *WebSearchAction `json:"action,omitempty"`
}
@@ -402,6 +425,9 @@ type ResponsesStreamEvent struct {
Name string `json:"name,omitempty"`
Arguments string `json:"arguments,omitempty"`
// response.custom_tool_call_input.done
Input string `json:"input,omitempty"`
// response.reasoning_summary_text.delta / done
// Reuses Text/Delta fields above, SummaryIndex identifies which summary part
SummaryIndex int `json:"summary_index,omitempty"`
@@ -53,6 +53,9 @@ func (s *OpenAIGatewayService) forwardResponsesViaRawChatCompletions(
clientStream := responsesReq.Stream
reasoningEffort := extractOpenAIReasoningEffortFromBody(body, originalModel)
serviceTier := extractOpenAIServiceTierFromBody(body)
// custom 工具(如 codex 的 exec)降级为 function 工具转发,回程需按名字还原为
// custom_tool_call 项,先记下名字集合。
customTools := apicompat.CustomToolNames(responsesReq.Tools)
chatReq, err := apicompat.ResponsesToChatCompletionsRequest(&responsesReq)
if err != nil {
@@ -191,15 +194,16 @@ func (s *OpenAIGatewayService) forwardResponsesViaRawChatCompletions(
}
if clientStream {
return s.streamChatCompletionsAsResponses(c, resp, originalModel, billingModel, upstreamModel, reasoningEffort, serviceTier, startTime)
return s.streamChatCompletionsAsResponses(c, resp, originalModel, customTools, billingModel, upstreamModel, reasoningEffort, serviceTier, startTime)
}
return s.bufferChatCompletionsAsResponses(c, resp, originalModel, billingModel, upstreamModel, reasoningEffort, serviceTier, startTime)
return s.bufferChatCompletionsAsResponses(c, resp, originalModel, customTools, billingModel, upstreamModel, reasoningEffort, serviceTier, startTime)
}
func (s *OpenAIGatewayService) bufferChatCompletionsAsResponses(
c *gin.Context,
resp *http.Response,
originalModel string,
customTools map[string]bool,
billingModel string,
upstreamModel string,
reasoningEffort *string,
@@ -230,7 +234,7 @@ func (s *OpenAIGatewayService) bufferChatCompletionsAsResponses(
})
return nil, fmt.Errorf("parse chat completions response: %w", err)
}
responsesResp := apicompat.ChatCompletionsResponseToResponses(&ccResp, originalModel)
responsesResp := apicompat.ChatCompletionsResponseToResponses(&ccResp, originalModel, customTools)
usage := OpenAIUsage{}
if parsed, ok := extractOpenAIUsageFromJSONBytes(respBody); ok {
@@ -259,6 +263,7 @@ func (s *OpenAIGatewayService) streamChatCompletionsAsResponses(
c *gin.Context,
resp *http.Response,
originalModel string,
customTools map[string]bool,
billingModel string,
upstreamModel string,
reasoningEffort *string,
@@ -283,6 +288,7 @@ func (s *OpenAIGatewayService) streamChatCompletionsAsResponses(
}
state := apicompat.NewChatCompletionsToResponsesStreamState(originalModel)
state.CustomTools = customTools
var usage OpenAIUsage
var firstTokenMs *int
clientDisconnected := false