From a9ae393d317c6907b3f3c85fcc3768755e0dde8f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E8=94=A1=E5=8F=8A?= <522caiji@gmail.com>
Date: Thu, 17 Sep 2026 15:52:00 +0800
Subject: [PATCH 1/3] feat(translate): expand namespace tools and carry
reasoning in compat adapters
Add NormalizeOpenAITools to expand Codex/Desktop namespace tool wrappers
into flat OpenAI function tools, qualify nested names as namespace__name,
and drop hosted shells (mcp / web_search). Wire it into the Responses
translator, the Trae payload normalizer, and the WorkBuddy payload
normalizer so all three accept the same tool shapes.
Responses input handling now replays prior model turns as a single
assistant message: reasoning items are kept pending and attached to the
next assistant message or function_call as reasoning_content, and
adjacent assistant items are merged (content, reasoning_content,
tool_calls). function_call_output accepts content-block arrays and lifts
input_image blocks into a following user message.
WorkBuddy keeps reasoning-only assistant turns and adds a
reasoning_content field to ChatMessage so thinking-mode history survives
normalization.
---
internal/providers/trae/client_test.go | 25 +++
internal/providers/trae/payload.go | 14 +-
internal/providers/workbuddy/client_test.go | 46 +++++
internal/providers/workbuddy/payload.go | 34 +++-
internal/translate/compat.go | 192 +++++++++++++++---
internal/translate/openai.go | 11 +-
internal/translate/tools.go | 142 ++++++++++++++
internal/translate/tools_test.go | 203 ++++++++++++++++++++
8 files changed, 631 insertions(+), 36 deletions(-)
create mode 100644 internal/translate/tools.go
create mode 100644 internal/translate/tools_test.go
diff --git a/internal/providers/trae/client_test.go b/internal/providers/trae/client_test.go
index 7651b71..a06258a 100644
--- a/internal/providers/trae/client_test.go
+++ b/internal/providers/trae/client_test.go
@@ -652,6 +652,31 @@ func TestPrepareBodyForcesSoloShape(t *testing.T) {
}
}
+func TestPrepareBodyExpandsNamespaceTools(t *testing.T) {
+ out := PrepareBody([]byte(`{"model":"glm-5.3","messages":[{"role":"user","content":"hi"}],"tools":[
+ {"type":"namespace","name":"mcp__computer-use","tools":[
+ {"type":"function","name":"left_click","parameters":{"type":"object","properties":{"x":{"type":"number"}}}}
+ ]},
+ {"type":"mcp","server_label":"computer-use"}
+ ]}`))
+ var body map[string]any
+ if err := json.Unmarshal(out, &body); err != nil {
+ t.Fatal(err)
+ }
+ tools, _ := body["tools"].([]any)
+ if len(tools) != 1 {
+ t.Fatalf("tools=%v", body["tools"])
+ }
+ tool, _ := tools[0].(map[string]any)
+ fn, _ := tool["function"].(map[string]any)
+ if fn["name"] != "mcp__computer-use__left_click" {
+ t.Fatalf("name=%v", fn["name"])
+ }
+ if _, ok := fn["parameters"].(string); !ok {
+ t.Fatalf("parameters should remain Trae JSON string: %v", fn["parameters"])
+ }
+}
+
func TestProbeReadyWithCredential(t *testing.T) {
client, store := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("probe should not hit network when credential is fresh: %s", r.URL.Path)
diff --git a/internal/providers/trae/payload.go b/internal/providers/trae/payload.go
index 38218dc..7f6ca21 100644
--- a/internal/providers/trae/payload.go
+++ b/internal/providers/trae/payload.go
@@ -3,6 +3,8 @@ package trae
import (
"encoding/json"
"strings"
+
+ "github.com/caigee-cmd/cli2api/internal/translate"
)
// PrepareBody rewrites an OpenAI chat body into Trae Solo llm_utils_chat form.
@@ -132,15 +134,20 @@ func normalizeTools(obj map[string]any) {
delete(obj, "tools")
return
}
- list, ok := raw.([]any)
- if !ok {
+ encoded, err := json.Marshal(raw)
+ if err != nil {
return
}
- if len(list) == 0 {
+ normalized, err := translate.NormalizeOpenAITools(encoded)
+ if err != nil || len(normalized) == 0 {
delete(obj, "tools")
delete(obj, "tool_choice")
return
}
+ var list []any
+ if err := json.Unmarshal(normalized, &list); err != nil {
+ return
+ }
out := make([]any, 0, len(list))
for _, item := range list {
t, ok := item.(map[string]any)
@@ -162,6 +169,7 @@ func normalizeTools(obj map[string]any) {
}
if len(out) == 0 {
delete(obj, "tools")
+ delete(obj, "tool_choice")
return
}
obj["tools"] = out
diff --git a/internal/providers/workbuddy/client_test.go b/internal/providers/workbuddy/client_test.go
index 10f9a38..5c3e5ed 100644
--- a/internal/providers/workbuddy/client_test.go
+++ b/internal/providers/workbuddy/client_test.go
@@ -1153,6 +1153,24 @@ func TestPrepareBodyNormalizesEmptyMessageContent(t *testing.T) {
}
}
+func TestPrepareBodyKeepsReasoningOnlyAssistant(t *testing.T) {
+ out := PrepareBody([]byte(`{"model":"m","messages":[{"role":"assistant","content":"","reasoning_content":"think"},{"role":"user","content":"hi"}]}`))
+ var body map[string]any
+ if err := json.Unmarshal(out, &body); err != nil {
+ t.Fatal(err)
+ }
+ found := false
+ for _, item := range body["messages"].([]any) {
+ message, _ := item.(map[string]any)
+ if messageRole(message) == "assistant" && stringField(message, "reasoning_content") == "think" {
+ found = true
+ }
+ }
+ if !found {
+ t.Fatalf("reasoning-only assistant dropped: %v", body["messages"])
+ }
+}
+
func TestPrepareBodyRepairsInterruptedToolSequence(t *testing.T) {
out := PrepareBody([]byte(`{"model":"m","messages":[
{"role":"user","content":"look this up"},
@@ -1241,6 +1259,34 @@ func TestPrepareBodyDropsNullAndEmptyTools(t *testing.T) {
}
}
+func TestPrepareBodyExpandsNamespaceTools(t *testing.T) {
+ out := PrepareBody([]byte(`{"model":"m","tools":[
+ {"type":"namespace","name":"mcp__computer-use","tools":[
+ {"type":"function","name":"left_click","parameters":{"type":"object"}}
+ ]},
+ {"type":"web_search"},
+ {"type":"function","function":{"name":"lookup","parameters":{"type":"object"}}}
+ ]}`))
+ var body map[string]any
+ if err := json.Unmarshal(out, &body); err != nil {
+ t.Fatal(err)
+ }
+ tools, _ := body["tools"].([]any)
+ if len(tools) != 2 {
+ t.Fatalf("tools=%v", body["tools"])
+ }
+ names := make([]string, 0, len(tools))
+ for _, item := range tools {
+ tool, _ := item.(map[string]any)
+ fn, _ := tool["function"].(map[string]any)
+ name, _ := fn["name"].(string)
+ names = append(names, name)
+ }
+ if names[0] != "mcp__computer-use__left_click" || names[1] != "lookup" {
+ t.Fatalf("names=%v", names)
+ }
+}
+
func TestCatalogHeadersUseDesktopUAOnlyForGlobal(t *testing.T) {
global := http.Header{}
SetCatalogHeaders(global, Credential{AccessToken: "at", UID: "u1", Domain: "www.workbuddy.ai"})
diff --git a/internal/providers/workbuddy/payload.go b/internal/providers/workbuddy/payload.go
index 4eb82df..a31a5ec 100644
--- a/internal/providers/workbuddy/payload.go
+++ b/internal/providers/workbuddy/payload.go
@@ -3,6 +3,8 @@ package workbuddy
import (
"encoding/json"
"strings"
+
+ "github.com/caigee-cmd/cli2api/internal/translate"
)
// PrepareBody forces streaming and normalizes tool_choice to the string form
@@ -17,6 +19,7 @@ func PrepareBody(src []byte) []byte {
}
body["stream"] = true
normalizeToolChoice(body)
+ normalizeTools(body)
dropEmptyTools(body)
repairToolSequence(body)
normalizeEmptyMessageContent(body)
@@ -28,6 +31,31 @@ func PrepareBody(src []byte) []byte {
return out
}
+func normalizeTools(body map[string]any) {
+ raw, ok := body["tools"]
+ if !ok || raw == nil {
+ return
+ }
+ encoded, err := json.Marshal(raw)
+ if err != nil {
+ return
+ }
+ normalized, err := translate.NormalizeOpenAITools(encoded)
+ if err != nil {
+ return
+ }
+ if len(normalized) == 0 {
+ delete(body, "tools")
+ delete(body, "tool_choice")
+ return
+ }
+ var tools []any
+ if err := json.Unmarshal(normalized, &tools); err != nil {
+ return
+ }
+ body["tools"] = tools
+}
+
func normalizeEmptyMessageContent(body map[string]any) {
raw, ok := body["messages"]
if !ok {
@@ -44,7 +72,7 @@ func normalizeEmptyMessageContent(body map[string]any) {
kept = append(kept, item)
continue
}
- if messageContentEmpty(message) && !hasToolCalls(message) && !isToolResult(message) {
+ if messageContentEmpty(message) && !hasToolCalls(message) && !hasReasoningContent(message) && !isToolResult(message) {
continue
}
kept = append(kept, item)
@@ -77,6 +105,10 @@ func hasToolCalls(message map[string]any) bool {
return true
}
+func hasReasoningContent(message map[string]any) bool {
+ return strings.TrimSpace(stringField(message, "reasoning_content")) != ""
+}
+
func isToolResult(message map[string]any) bool {
return messageRole(message) == "tool" || strings.TrimSpace(stringField(message, "tool_call_id")) != ""
}
diff --git a/internal/translate/compat.go b/internal/translate/compat.go
index b90614d..e1c743c 100644
--- a/internal/translate/compat.go
+++ b/internal/translate/compat.go
@@ -375,6 +375,57 @@ func translateResponsesInput(raw json.RawMessage) ([]ChatMessage, error) {
return nil, fmt.Errorf("input must be a string or an array")
}
messages := make([]ChatMessage, 0, len(items))
+ pendingReasoning := ""
+ appendAssistant := func(message ChatMessage) {
+ if pendingReasoning != "" {
+ if message.ReasoningContent != "" {
+ message.ReasoningContent = pendingReasoning + "\n" + message.ReasoningContent
+ } else {
+ message.ReasoningContent = pendingReasoning
+ }
+ pendingReasoning = ""
+ }
+ // Responses replays one model turn as adjacent items (reasoning,
+ // assistant message, function_call*). WorkBuddy thinking mode needs a
+ // single assistant message that keeps reasoning_content with any
+ // tool_calls from that same turn.
+ if n := len(messages); n > 0 && messages[n-1].Role == "assistant" {
+ prev := &messages[n-1]
+ if message.ReasoningContent != "" {
+ if prev.ReasoningContent != "" {
+ prev.ReasoningContent += "\n" + message.ReasoningContent
+ } else {
+ prev.ReasoningContent = message.ReasoningContent
+ }
+ }
+ if contentPresent(message.Content) && !contentPresent(prev.Content) {
+ prev.Content = message.Content
+ } else if contentPresent(message.Content) && contentPresent(prev.Content) {
+ prev.Content = mergeAssistantContent(prev.Content, message.Content)
+ }
+ if len(message.ToolCalls) > 0 {
+ prev.ToolCalls = mergeToolCallJSON(prev.ToolCalls, message.ToolCalls)
+ }
+ return
+ }
+ messages = append(messages, message)
+ }
+ flushPendingReasoning := func() {
+ if pendingReasoning == "" {
+ return
+ }
+ if n := len(messages); n > 0 && messages[n-1].Role == "assistant" {
+ if messages[n-1].ReasoningContent != "" {
+ messages[n-1].ReasoningContent += "\n" + pendingReasoning
+ } else {
+ messages[n-1].ReasoningContent = pendingReasoning
+ }
+ pendingReasoning = ""
+ return
+ }
+ messages = append(messages, ChatMessage{Role: "assistant", Content: "", ReasoningContent: pendingReasoning})
+ pendingReasoning = ""
+ }
for itemIndex, item := range items {
var source map[string]json.RawMessage
if err := json.Unmarshal(item, &source); err != nil {
@@ -391,17 +442,26 @@ func translateResponsesInput(raw json.RawMessage) ([]ChatMessage, error) {
if err != nil {
return nil, fmt.Errorf("input[%d].content: %w", itemIndex, err)
}
- messages = append(messages, ChatMessage{Role: role, Content: content})
+ if role == "assistant" {
+ appendAssistant(ChatMessage{Role: role, Content: content})
+ } else {
+ flushPendingReasoning()
+ messages = append(messages, ChatMessage{Role: role, Content: content})
+ }
case "function_call_output":
callID := rawMapString(source, "call_id")
if callID == "" {
return nil, fmt.Errorf("input[%d].call_id required", itemIndex)
}
- content, err := compatibilityText(rawMapJSON(source, "output"), map[string]bool{"input_text": true, "output_text": true, "text": true})
+ content, images, err := responsesFunctionCallOutput(rawMapJSON(source, "output"))
if err != nil {
return nil, fmt.Errorf("input[%d].output: %w", itemIndex, err)
}
+ flushPendingReasoning()
messages = append(messages, ChatMessage{Role: "tool", ToolCallID: callID, Content: content})
+ if len(images) > 0 {
+ messages = append(messages, ChatMessage{Role: "user", Content: images})
+ }
case "input_file":
return nil, fmt.Errorf("input[%d] file inputs are not supported by the Qoder upstream", itemIndex)
case "function_call":
@@ -420,45 +480,89 @@ func translateResponsesInput(raw json.RawMessage) ([]ChatMessage, error) {
if !json.Valid(arguments) {
return nil, fmt.Errorf("input[%d].arguments must be valid JSON", itemIndex)
}
- messages = append(messages, ChatMessage{Role: "assistant", Content: "", ToolCalls: marshalToolCalls([]compatibilityToolCall{{ID: callID, Name: name, Arguments: arguments}})})
+ appendAssistant(ChatMessage{Role: "assistant", Content: "", ToolCalls: marshalToolCalls([]compatibilityToolCall{{ID: callID, Name: name, Arguments: arguments}})})
+ case "reasoning":
+ // Codex/Desktop replays prior Responses reasoning items. WorkBuddy
+ // thinking mode requires that text back on the assistant turn as
+ // reasoning_content, so keep it pending until the next assistant
+ // message or function_call.
+ text, err := responsesReasoningText(source)
+ if err != nil {
+ return nil, fmt.Errorf("input[%d]: %w", itemIndex, err)
+ }
+ if text == "" {
+ continue
+ }
+ if pendingReasoning != "" {
+ pendingReasoning += "\n" + text
+ } else {
+ pendingReasoning = text
+ }
case "additional_tools":
continue
default:
return nil, fmt.Errorf("input[%d] type %q is not supported", itemIndex, itemType)
}
}
+ flushPendingReasoning()
return messages, nil
}
-func translateResponsesTools(raw json.RawMessage) (json.RawMessage, error) {
- if emptyJSON(raw) {
- return nil, nil
+func responsesReasoningText(source map[string]json.RawMessage) (string, error) {
+ if text, err := compatibilityText(rawMapJSON(source, "summary"), map[string]bool{"summary_text": true, "text": true}); err == nil && text != "" {
+ return text, nil
+ } else if err != nil && !emptyJSON(rawMapJSON(source, "summary")) {
+ return "", err
}
- var source []map[string]json.RawMessage
- if err := json.Unmarshal(raw, &source); err != nil {
- return nil, fmt.Errorf("tools must be an array")
+ if text, err := compatibilityText(rawMapJSON(source, "content"), map[string]bool{"reasoning_text": true, "summary_text": true, "text": true, "output_text": true}); err == nil && text != "" {
+ return text, nil
+ } else if err != nil && !emptyJSON(rawMapJSON(source, "content")) {
+ return "", err
}
- tools := make([]map[string]any, 0, len(source))
- for toolIndex, tool := range source {
- if toolType := rawMapString(tool, "type"); toolType != "function" {
- return nil, fmt.Errorf("tools[%d] type %q is not supported", toolIndex, toolType)
- }
- name := rawMapString(tool, "name")
- if name == "" {
- return nil, fmt.Errorf("tools[%d].name required", toolIndex)
+ return "", nil
+}
+
+func responsesFunctionCallOutput(raw json.RawMessage) (string, []any, error) {
+ if text, ok := rawJSONString(raw); ok {
+ return text, nil, nil
+ }
+ var blocks []json.RawMessage
+ if err := json.Unmarshal(raw, &blocks); err != nil {
+ return "", nil, fmt.Errorf("must be a string or an array of content blocks")
+ }
+ texts := make([]string, 0, len(blocks))
+ images := make([]any, 0)
+ for index, block := range blocks {
+ var source map[string]json.RawMessage
+ if err := json.Unmarshal(block, &source); err != nil {
+ return "", nil, fmt.Errorf("content[%d] must be an object", index)
}
- parameters := rawMapJSON(tool, "parameters")
- if len(parameters) == 0 {
- parameters = json.RawMessage(`{"type":"object","properties":{}}`)
+ switch rawMapString(source, "type") {
+ case "input_text", "output_text", "text":
+ texts = append(texts, rawMapContentString(source, "text"))
+ case "input_image", "image_url":
+ imageURL := rawMapString(source, "image_url")
+ if imageURL == "" {
+ var nested map[string]json.RawMessage
+ if json.Unmarshal(source["image_url"], &nested) == nil {
+ imageURL = rawMapString(nested, "url")
+ }
+ }
+ if imageURL == "" {
+ return "", nil, fmt.Errorf("content[%d] image URL required", index)
+ }
+ images = append(images, map[string]any{"type": "image_url", "image_url": map[string]string{"url": imageURL}})
+ case "input_file", "file":
+ return "", nil, fmt.Errorf("content[%d] file inputs are not supported by the Qoder upstream", index)
+ default:
+ return "", nil, fmt.Errorf("content[%d] type %q is not supported", index, rawMapString(source, "type"))
}
- tools = append(tools, map[string]any{
- "type": "function",
- "function": map[string]any{
- "name": name, "description": rawMapString(tool, "description"), "parameters": json.RawMessage(parameters),
- },
- })
}
- return json.Marshal(tools)
+ return strings.Join(texts, "\n"), images, nil
+}
+
+func translateResponsesTools(raw json.RawMessage) (json.RawMessage, error) {
+ return NormalizeOpenAITools(raw)
}
func translateResponsesToolChoice(raw json.RawMessage) (json.RawMessage, error) {
@@ -806,6 +910,40 @@ func marshalToolCalls(calls []compatibilityToolCall) json.RawMessage {
return result
}
+func mergeToolCallJSON(left, right json.RawMessage) json.RawMessage {
+ if len(left) == 0 || string(left) == "null" {
+ return append(json.RawMessage(nil), right...)
+ }
+ if len(right) == 0 || string(right) == "null" {
+ return append(json.RawMessage(nil), left...)
+ }
+ var leftCalls, rightCalls []json.RawMessage
+ if json.Unmarshal(left, &leftCalls) != nil || json.Unmarshal(right, &rightCalls) != nil {
+ return append(json.RawMessage(nil), right...)
+ }
+ merged := append(append([]json.RawMessage{}, leftCalls...), rightCalls...)
+ encoded, err := json.Marshal(merged)
+ if err != nil {
+ return append(json.RawMessage(nil), right...)
+ }
+ return encoded
+}
+
+func mergeAssistantContent(left, right any) any {
+ leftText := strings.TrimSpace(ContentToString(left))
+ rightText := strings.TrimSpace(ContentToString(right))
+ switch {
+ case leftText == "":
+ return right
+ case rightText == "":
+ return left
+ case leftText == rightText:
+ return left
+ default:
+ return leftText + "\n" + rightText
+ }
+}
+
func rawJSONString(raw json.RawMessage) (string, bool) {
var value string
if json.Unmarshal(raw, &value) != nil {
diff --git a/internal/translate/openai.go b/internal/translate/openai.go
index b8965a7..d0801f2 100644
--- a/internal/translate/openai.go
+++ b/internal/translate/openai.go
@@ -30,11 +30,12 @@ type ChatRequest struct {
}
type ChatMessage struct {
- Role string `json:"role"`
- Content any `json:"content"`
- Name string `json:"name,omitempty"`
- ToolCallID string `json:"tool_call_id,omitempty"`
- ToolCalls json.RawMessage `json:"tool_calls,omitempty"`
+ Role string `json:"role"`
+ Content any `json:"content"`
+ Name string `json:"name,omitempty"`
+ ToolCallID string `json:"tool_call_id,omitempty"`
+ ToolCalls json.RawMessage `json:"tool_calls,omitempty"`
+ ReasoningContent string `json:"reasoning_content,omitempty"`
}
// DropSystemMessages removes caller system/developer messages from a chat
diff --git a/internal/translate/tools.go b/internal/translate/tools.go
new file mode 100644
index 0000000..ddaddd8
--- /dev/null
+++ b/internal/translate/tools.go
@@ -0,0 +1,142 @@
+package translate
+
+import (
+ "encoding/json"
+ "fmt"
+ "strings"
+)
+
+const defaultToolParameters = `{"type":"object","properties":{}}`
+
+// NormalizeOpenAITools expands Codex/Desktop namespace wrappers into plain
+// OpenAI function tools and drops hosted shells such as mcp / web_search.
+// Nested tools may be Responses-flat or Chat Completions shaped. Short nested
+// names are qualified as namespace__name; already-qualified mcp__* names stay
+// unchanged. This does not apply Devin's mcp__ upstream aliasing.
+func NormalizeOpenAITools(raw json.RawMessage) (json.RawMessage, error) {
+ if emptyJSON(raw) {
+ return nil, nil
+ }
+ var items []json.RawMessage
+ if err := json.Unmarshal(raw, &items); err != nil {
+ return nil, fmt.Errorf("tools must be an array")
+ }
+ out := make([]map[string]any, 0, len(items))
+ seen := map[string]struct{}{}
+ appendTool := func(name, description string, parameters json.RawMessage) {
+ name = strings.TrimSpace(name)
+ if name == "" {
+ return
+ }
+ if _, ok := seen[name]; ok {
+ return
+ }
+ seen[name] = struct{}{}
+ if len(parameters) == 0 || string(parameters) == "null" {
+ parameters = json.RawMessage(defaultToolParameters)
+ }
+ out = append(out, map[string]any{
+ "type": "function",
+ "function": map[string]any{
+ "name": name,
+ "description": description,
+ "parameters": json.RawMessage(parameters),
+ },
+ })
+ }
+ for _, item := range items {
+ var probe map[string]json.RawMessage
+ if err := json.Unmarshal(item, &probe); err != nil {
+ continue
+ }
+ typ := strings.ToLower(strings.TrimSpace(rawMapString(probe, "type")))
+ switch typ {
+ case "namespace":
+ for _, nested := range expandNamespaceToolItems(item, strings.TrimSpace(rawMapString(probe, "name"))) {
+ appendTool(nested.name, nested.description, nested.parameters)
+ }
+ case "mcp", "web_search", "web_search_preview":
+ continue
+ case "function", "":
+ name, description, parameters := toolFields(probe)
+ appendTool(name, description, parameters)
+ default:
+ continue
+ }
+ }
+ if len(out) == 0 {
+ return nil, nil
+ }
+ return json.Marshal(out)
+}
+
+type normalizedTool struct {
+ name string
+ description string
+ parameters json.RawMessage
+}
+
+func expandNamespaceToolItems(raw json.RawMessage, namespace string) []normalizedTool {
+ var wrapper struct {
+ Tools []json.RawMessage `json:"tools"`
+ }
+ if json.Unmarshal(raw, &wrapper) != nil || len(wrapper.Tools) == 0 {
+ return nil
+ }
+ out := make([]normalizedTool, 0, len(wrapper.Tools))
+ for _, item := range wrapper.Tools {
+ var probe map[string]json.RawMessage
+ if json.Unmarshal(item, &probe) != nil {
+ continue
+ }
+ typ := strings.ToLower(strings.TrimSpace(rawMapString(probe, "type")))
+ if typ != "" && typ != "function" {
+ continue
+ }
+ name, description, parameters := toolFields(probe)
+ name = qualifyNamespaceToolName(namespace, name)
+ if name == "" {
+ continue
+ }
+ out = append(out, normalizedTool{name: name, description: description, parameters: parameters})
+ }
+ return out
+}
+
+func toolFields(source map[string]json.RawMessage) (name, description string, parameters json.RawMessage) {
+ var function map[string]json.RawMessage
+ if raw, ok := rawMapJSONValue(source, "function"); ok {
+ _ = json.Unmarshal(raw, &function)
+ }
+ name = firstNonEmptyTrimmed(rawMapString(function, "name"), rawMapString(source, "name"))
+ description = firstNonEmptyTrimmed(rawMapString(function, "description"), rawMapString(source, "description"))
+ parameters = rawMapJSON(function, "parameters")
+ if len(parameters) == 0 {
+ parameters = rawMapJSON(source, "parameters")
+ }
+ return name, description, parameters
+}
+
+func qualifyNamespaceToolName(namespace, name string) string {
+ name = strings.TrimSpace(name)
+ if name == "" {
+ return ""
+ }
+ if strings.Contains(name, "__") || strings.HasPrefix(strings.ToLower(name), "mcp__") {
+ return name
+ }
+ namespace = strings.TrimSpace(namespace)
+ if namespace == "" {
+ return name
+ }
+ return strings.TrimRight(namespace, "_") + "__" + strings.TrimLeft(name, "_")
+}
+
+func firstNonEmptyTrimmed(values ...string) string {
+ for _, value := range values {
+ if trimmed := strings.TrimSpace(value); trimmed != "" {
+ return trimmed
+ }
+ }
+ return ""
+}
diff --git a/internal/translate/tools_test.go b/internal/translate/tools_test.go
new file mode 100644
index 0000000..7ef6178
--- /dev/null
+++ b/internal/translate/tools_test.go
@@ -0,0 +1,203 @@
+package translate
+
+import (
+ "encoding/json"
+ "testing"
+)
+
+func TestNormalizeOpenAIToolsExpandsNamespaceAndDropsHostedShells(t *testing.T) {
+ raw := json.RawMessage(`[
+ {"type":"function","name":"lookup","description":"lookup","parameters":{"type":"object"}},
+ {"type":"namespace","name":"mcp__computer-use","tools":[
+ {"type":"function","name":"left_click","description":"click","parameters":{"type":"object","properties":{"x":{"type":"number"}}}},
+ {"type":"function","function":{"name":"mcp__computer-use__type","description":"type","parameters":{"type":"object"}}}
+ ]},
+ {"type":"mcp","server_label":"computer-use"},
+ {"type":"web_search"},
+ {"type":"web_search_preview"},
+ {"type":"namespace","name":"mcp__empty","tools":[]},
+ {"type":"custom","name":"ignored"}
+ ]`)
+ got, err := NormalizeOpenAITools(raw)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var tools []struct {
+ Type string `json:"type"`
+ Function struct {
+ Name string `json:"name"`
+ Description string `json:"description"`
+ Parameters json.RawMessage `json:"parameters"`
+ } `json:"function"`
+ }
+ if err := json.Unmarshal(got, &tools); err != nil {
+ t.Fatal(err)
+ }
+ want := []string{"lookup", "mcp__computer-use__left_click", "mcp__computer-use__type"}
+ if len(tools) != len(want) {
+ t.Fatalf("tools=%v want %v", toolNames(tools), want)
+ }
+ for i, name := range want {
+ if tools[i].Type != "function" || tools[i].Function.Name != name {
+ t.Fatalf("tools[%d]=%#v want %q", i, tools[i], name)
+ }
+ }
+ if string(tools[1].Function.Parameters) == "" || string(tools[1].Function.Parameters) == "null" {
+ t.Fatalf("left_click parameters missing: %s", tools[1].Function.Parameters)
+ }
+}
+
+func TestTranslateResponsesAcceptsNamespaceTools(t *testing.T) {
+ chat, err := TranslateResponses(ResponsesRequest{
+ Model: "workbuddy/glm-5.2",
+ Input: json.RawMessage(`"hi"`),
+ Tools: json.RawMessage(`[
+ {"type":"namespace","name":"mcp__computer-use","tools":[
+ {"type":"function","name":"left_click","parameters":{"type":"object"}}
+ ]},
+ {"type":"web_search"}
+ ]`),
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ var tools []struct {
+ Function struct {
+ Name string `json:"name"`
+ } `json:"function"`
+ }
+ if err := json.Unmarshal(chat.Tools, &tools); err != nil {
+ t.Fatal(err)
+ }
+ if len(tools) != 1 || tools[0].Function.Name != "mcp__computer-use__left_click" {
+ t.Fatalf("tools=%s", chat.Tools)
+ }
+}
+
+func TestTranslateResponsesAttachesReasoningToAssistant(t *testing.T) {
+ chat, err := TranslateResponses(ResponsesRequest{
+ Model: "workbuddy/glm-5.2",
+ Input: json.RawMessage(`[
+ {"type":"message","role":"user","content":[{"type":"input_text","text":"hi"}]},
+ {"type":"reasoning","id":"rs_1","status":"completed","summary":[{"type":"summary_text","text":"think"}]},
+ {"type":"message","role":"assistant","content":[{"type":"output_text","text":"hello"}]},
+ {"type":"message","role":"user","content":[{"type":"input_text","text":"again"}]}
+ ]`),
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(chat.Messages) != 3 {
+ t.Fatalf("messages=%#v", chat.Messages)
+ }
+ if chat.Messages[0].Role != "user" || ContentToString(chat.Messages[0].Content) != "hi" {
+ t.Fatalf("first=%#v", chat.Messages[0])
+ }
+ if chat.Messages[1].Role != "assistant" || ContentToString(chat.Messages[1].Content) != "hello" || chat.Messages[1].ReasoningContent != "think" {
+ t.Fatalf("second=%#v", chat.Messages[1])
+ }
+ if chat.Messages[2].Role != "user" || ContentToString(chat.Messages[2].Content) != "again" {
+ t.Fatalf("third=%#v", chat.Messages[2])
+ }
+}
+
+func TestTranslateResponsesAttachesReasoningToFunctionCall(t *testing.T) {
+ chat, err := TranslateResponses(ResponsesRequest{
+ Model: "deepseek-v4.1-flash",
+ Input: json.RawMessage(`[
+ {"type":"message","role":"user","content":[{"type":"input_text","text":"hi"}]},
+ {"type":"reasoning","id":"rs_1","summary":[{"type":"summary_text","text":"need tool"}]},
+ {"type":"function_call","call_id":"call_1","name":"lookup","arguments":"{}"}
+ ]`),
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(chat.Messages) != 2 {
+ t.Fatalf("messages=%#v", chat.Messages)
+ }
+ if chat.Messages[1].Role != "assistant" || chat.Messages[1].ReasoningContent != "need tool" || len(chat.Messages[1].ToolCalls) == 0 {
+ t.Fatalf("assistant=%#v", chat.Messages[1])
+ }
+}
+
+func TestTranslateResponsesLiftsFunctionCallOutputImages(t *testing.T) {
+ chat, err := TranslateResponses(ResponsesRequest{
+ Model: "deepseek-v4.1-flash",
+ Input: json.RawMessage(`[
+ {"type":"message","role":"user","content":[{"type":"input_text","text":"look"}]},
+ {"type":"function_call","call_id":"call_1","name":"screenshot","arguments":"{}"},
+ {"type":"function_call_output","call_id":"call_1","output":[
+ {"type":"input_text","text":"captured"},
+ {"type":"input_image","image_url":"data:image/png;base64,AAAA"}
+ ]}
+ ]`),
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(chat.Messages) != 4 {
+ t.Fatalf("messages=%#v", chat.Messages)
+ }
+ if chat.Messages[2].Role != "tool" || ContentToString(chat.Messages[2].Content) != "captured" || chat.Messages[2].ToolCallID != "call_1" {
+ t.Fatalf("tool=%#v", chat.Messages[2])
+ }
+ if chat.Messages[3].Role != "user" {
+ t.Fatalf("image lift=%#v", chat.Messages[3])
+ }
+ parts, ok := chat.Messages[3].Content.([]any)
+ if !ok || len(parts) != 1 {
+ t.Fatalf("image content=%#v", chat.Messages[3].Content)
+ }
+ image, ok := parts[0].(map[string]any)
+ if !ok || image["type"] != "image_url" {
+ t.Fatalf("image part=%#v", parts[0])
+ }
+}
+
+func TestTranslateResponsesMergesReasoningMessageAndFunctionCall(t *testing.T) {
+ chat, err := TranslateResponses(ResponsesRequest{
+ Model: "deepseek-v4.1-flash",
+ Input: json.RawMessage(`[
+ {"type":"message","role":"user","content":[{"type":"input_text","text":"hi"}]},
+ {"type":"reasoning","id":"rs_1","summary":[{"type":"summary_text","text":"plan tool use"}]},
+ {"type":"message","role":"assistant","content":[{"type":"output_text","text":"checking"}]},
+ {"type":"function_call","call_id":"call_1","name":"lookup","arguments":"{\"q\":\"x\"}"},
+ {"type":"function_call_output","call_id":"call_1","output":"result"},
+ {"type":"reasoning","id":"rs_2","summary":[{"type":"summary_text","text":"answer now"}]},
+ {"type":"message","role":"assistant","content":[{"type":"output_text","text":"done"}]}
+ ]`),
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(chat.Messages) != 4 {
+ t.Fatalf("messages=%#v", chat.Messages)
+ }
+ first := chat.Messages[1]
+ if first.Role != "assistant" || first.ReasoningContent != "plan tool use" || ContentToString(first.Content) != "checking" || len(first.ToolCalls) == 0 {
+ t.Fatalf("tool turn=%#v tools=%s", first, first.ToolCalls)
+ }
+ if chat.Messages[2].Role != "tool" {
+ t.Fatalf("tool result=%#v", chat.Messages[2])
+ }
+ second := chat.Messages[3]
+ if second.Role != "assistant" || second.ReasoningContent != "answer now" || ContentToString(second.Content) != "done" {
+ t.Fatalf("final=%#v", second)
+ }
+}
+
+func toolNames(tools []struct {
+ Type string `json:"type"`
+ Function struct {
+ Name string `json:"name"`
+ Description string `json:"description"`
+ Parameters json.RawMessage `json:"parameters"`
+ } `json:"function"`
+}) []string {
+ names := make([]string, 0, len(tools))
+ for _, tool := range tools {
+ names = append(names, tool.Function.Name)
+ }
+ return names
+}
From d6d9c9df139212a8099992db4451c62e752524f5 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E8=94=A1=E5=8F=8A?= <522caiji@gmail.com>
Date: Thu, 17 Sep 2026 15:52:05 +0800
Subject: [PATCH 2/3] docs(agents): map architecture and routing docs, keep
them local
AGENTS.md now lists every doc with a read-when column and points at
docs/ARCHITECTURE.md and docs/REQUEST.md for backend/routing work. Both
files stay out of the repo, so add them to .gitignore alongside the
other local-only docs.
---
.gitignore | 2 ++
AGENTS.md | 33 ++++++++++++++++++++++-----------
2 files changed, 24 insertions(+), 11 deletions(-)
diff --git a/.gitignore b/.gitignore
index d964059..a55f5d8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,6 +8,8 @@ docs/PRIVATE_DEPLOYMENT.md
docs/private/
docs/capture-notes.md
docs/DEVELOPMENT.md
+docs/REQUEST.md
+docs/ARCHITECTURE.md
docs/DESIGN.md
docs/PLAN.md
docs/PROVIDERS.md
diff --git a/AGENTS.md b/AGENTS.md
index 17f7307..f7d7539 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -2,17 +2,28 @@
Go + Node service that turns a local Qoder CLI login into an OpenAI-compatible API.
-Keep writing-code docs in these files only:
-
-| File | What belongs there |
-|------|--------------------|
-| `AGENTS.md` | Hard rules for agents. Short. |
-| `docs/DESIGN.md` | Architecture, login, routing, console, design system. |
-| `docs/PLAN.md` | Current milestone checklist. |
-| `docs/PROVIDERS.md` | Future account-provider design. Not a current milestone. |
-| `docs/PROVIDERS_TRAE_SOLO.md` | Trae CN Solo in-process adapter survey. Not a current milestone. |
-
-Do not add new `TODO.md`, `NOTES.md`, or extra plan files. Extra design docs are `docs/PROVIDERS.md` (WorkBuddy / Qoder CN / future account types) and `docs/PROVIDERS_TRAE_SOLO.md` (Trae CN Solo). `docs/PROVIDERS_TRAE.md` is superseded; do not implement from it. User-facing install stays in `README.md` (Chinese) / `README_EN.md` (English). Local protocol facts stay in ignored `docs/capture-notes.md`. Host ops stay gitignored in `docs/PRIVATE_DEPLOYMENT.md`.
+## Docs
+
+Read this file first. Every other doc carries YAML frontmatter
+(`id / title / scope / status / read-when / summary / related / last-updated`);
+use `read-when` to decide whether to open it.
+
+| File | Read when | What belongs there |
+|------|-----------|--------------------|
+| `AGENTS.md` | always | Hard rules for agents. Short. |
+| `docs/ARCHITECTURE.md` | backend, protocol adapters, login, account routing, migrations, console IA, managed update | Architecture, runtime, login, routing contract, console IA |
+| `docs/DESIGN.md` | any console UI work | Frontend design system: tokens, radii, type, HeroUI picks, copy |
+| `docs/REQUEST.md` | routing, failover, cooldown, session affinity, error taxonomy | Per-request pick / failover / cooldown contract |
+| `docs/PLAN.md` | before starting a milestone | Current milestone checklist |
+| `docs/PROVIDERS.md` | adding / designing a provider | Future account-provider design. Not a current milestone. |
+| `docs/PROVIDERS_TRAE_SOLO.md` | Trae CN Solo work | Trae CN Solo in-process adapter survey. Not a current milestone. |
+| `docs/DEVELOPMENT.md` | build / test / release | Local build loop and maintainer release workflow |
+| `docs/capture-notes.md` (ignored) | protocol facts | Local redacted protocol facts |
+| `docs/PRIVATE_DEPLOYMENT.md` (ignored) | host ops | Host ops runbook |
+
+Keep these files only. Do not add new `TODO.md`, `NOTES.md`, or extra plan files.
+`docs/PROVIDERS_TRAE.md` is superseded; do not implement from it. User-facing
+install stays in `README.md` (Chinese) / `README_EN.md` (English).
## Do
From 0d2c2b32cba40cb018aef449d4f898ec88606928 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E8=94=A1=E5=8F=8A?= <522caiji@gmail.com>
Date: Thu, 17 Sep 2026 15:52:08 +0800
Subject: [PATCH 3/3] docs: de-duplicate and align zh/en READMEs
Drop repeated blocks (account-level runtime, cross-platform, managed
update, provider lists) and merge Community/Contributing on the English
side so both READMEs share the same section order and length. Move
WorkBuddy keepalive to Features where the Chinese README already had it.
---
README.md | 40 +++++++++++++---------------------------
README_EN.md | 36 +++++++++++-------------------------
2 files changed, 24 insertions(+), 52 deletions(-)
diff --git a/README.md b/README.md
index d9ef03b..a45a1b0 100644
--- a/README.md
+++ b/README.md
@@ -19,18 +19,17 @@
## 功能
-- **OpenAI / Anthropic 兼容代理**:`/v1/chat/completions`、`/v1/responses`、`/v1/messages`、`/v1/models`;支持流式/非流式、文本与函数工具调用;图片能力取决于 provider(当前 Qoder 支持,WorkBuddy / Trae 不支持);文件输入会明确拒绝。`messages` / `responses` 当前为无状态适配层,不支持服务端会话或上游专属工具。
-- **多渠道账号池**:Qoder 国际版 / 国内版、WorkBuddy 国际版 / 国内版、Trae 国内版 Solo,以及实验性 Devin;地域隔离、账号固定、并发限制、冷却与同族故障切换
-- **代理出口**:支持统一 HTTP(S) 代理,也支持账号级覆盖;账号可用 `direct` / `none` 显式直连。SOCKS5 仅支持 WorkBuddy / Trae / Devin 的账号级代理,Qoder 账号级代理只支持 HTTP(S)
-- **账号级常驻运行时**:Qoder 账号使用独立 Node 进程、HOME 与 WASM 上下文;WorkBuddy / Trae / Devin 使用进程内 HTTP/SSE(或 Connect)适配器。登录态、云端连接和账号隔离由各 provider 的运行时负责
+- **OpenAI / Anthropic 兼容代理**:`/v1/chat/completions`、`/v1/responses`、`/v1/messages`、`/v1/models`;支持流式/非流式、文本与函数工具调用。图片能力取决于 provider(当前 Qoder 支持,WorkBuddy / Trae 不支持),文件输入会明确拒绝。`messages` / `responses` 为无状态适配层,不支持服务端会话或上游专属工具
+- **多渠道账号池**:地域隔离、账号固定、并发限制、冷却与同族故障切换
+- **代理出口**:统一 HTTP(S) 代理,支持账号级覆盖,可用 `direct` / `none` 显式直连。SOCKS5 仅支持 WorkBuddy / Trae / Devin 的账号级代理,Qoder 账号级只支持 HTTP(S)
- **按 provider 支持多种登录方式**:浏览器 Device Flow OAuth、PAT,以及适用 provider 的凭证导入/导出
-- **Web 控制台**:账号、模型、接入、请求历史与运行时日志,明暗主题
-- **部署与运维**:Docker Compose 单容器、安全托管更新(升级前快照、失败自动回滚、直接最新稳定版、可回滚最近三个稳定版)、默认只监听 `127.0.0.1`
-- **跨平台**:`linux/amd64` / `linux/arm64` 镜像;macOS、Windows 通过 Docker Desktop 运行
+- **Web 控制台**:账号、模型、接入、请求历史与运行时日志;请求历史可按账号过滤,并查看状态、延迟、Token 与用量统计;明暗主题
+- **账号保活**:WorkBuddy 每日签到与 token 保活(账号级开关,默认关闭;控制台可立即签到 / 刷新积分)
+- **部署与运维**:Docker Compose 单容器、安全托管更新(升级前快照、失败自动回滚、直接最新稳定版、可回滚最近三个稳定版)、默认只监听 `127.0.0.1`;提供 `linux/amd64` / `linux/arm64` 镜像,macOS、Windows 通过 Docker Desktop 运行
## 快速开始
-**强烈建议用 Docker 部署。** 发布镜像、控制台托管更新(升级前快照、失败回滚、直接最新稳定版)都按单容器 Compose 安装来设计;从源码直接跑 Go / Node 不在这条更新路径上。
+**强烈建议用 Docker 部署。** 发布镜像与控制台托管更新都按单容器 Compose 安装设计;从源码直接跑 Go / Node 不在这条更新路径上。
依赖:Docker(macOS / Windows 用 Docker Desktop,Linux 用 Docker Engine + Compose),以及一个你自己控制的 Qoder、WorkBuddy、Trae 或实验性 Devin 账号。Windows 的 Docker Desktop 必须切换到 Linux containers。
@@ -51,7 +50,7 @@ Base URL: http://127.0.0.1:3010/v1
API Key: <首次启动时生成的 Key>
```
-不指定账号时,调度器自动选择可用账号;需要固定账号时加请求头 `X-Qoder-Account: acc_...`(历史命名,适用于所有 provider)。除 Chat Completions 外,也可使用 Anthropic `POST /v1/messages` 与 OpenAI `POST /v1/responses`;两者要求请求携带完整对话,不支持 `previous_response_id` / `conversation` 服务端续接。同一段对话默认按首条用户消息(含纯图片)粘到同一个账号;也可显式设置 `X-CLI2API-Session`。curl / PowerShell 示例见 [部署说明](deploy/README.md)。
+不指定账号时,调度器自动选择可用账号;需要固定账号时加请求头 `X-Qoder-Account: acc_...`(历史命名,适用于所有 provider)。同一段对话默认按首条用户消息(含纯图片)粘到同一个账号,也可显式设置 `X-CLI2API-Session`。curl / PowerShell 示例见 [部署说明](deploy/README.md)。
## 工作方式
@@ -67,14 +66,12 @@ API Key: <首次启动时生成的 Key>