diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f5da9b..7243189 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Write each change in both `### English` and `### 中文` under `## Unreleased`. ### English +- Restore Responses function namespaces in JSON and SSE output, and preserve qualified tool identities when replaying calls or selecting a function. - Preserve Qoder user images and image-bearing tool results, emitting tool-result images after their complete ordered tool batch. - Bridge Responses custom tools through function calls, restoring custom output/events and replaying tool results. Format rules are descriptive, not grammar-enforced; custom input events are emitted after argument collection. @@ -22,6 +23,7 @@ Write each change in both `### English` and `### 中文` under `## Unreleased`. ### 中文 +- Responses 的 JSON 和 SSE 输出会还原 function 的命名空间,历史调用回放与指定函数选择也会保留完整工具身份。 - 保留 Qoder 用户消息及工具结果中的图片,并在完整、有序的工具结果批次之后发送工具图片。 - 通过 function 调用桥接 Responses custom 工具,还原 custom 输出与事件并回放工具结果。格式规则仅作为描述传递,不强制执行语法约束;custom 输入事件在参数收集后发送。 diff --git a/internal/api/compat.go b/internal/api/compat.go index 7c0f351..890b208 100644 --- a/internal/api/compat.go +++ b/internal/api/compat.go @@ -144,7 +144,9 @@ func (s *Server) handleResponses(w http.ResponseWriter, r *http.Request) { CachedTokens: result.CachedTokens, UsageSource: result.UsageSource, Credits: result.Credits, ConsumedCredits: result.ConsumedCredits, Model: result.Model, }, nil, result.AttemptCount) - writeJSON(w, http.StatusOK, responsesResponse(execution.requestID, firstNonEmpty(result.Model, execution.publicModel), result.Content, result.Reasoning, decodeOpenAIToolCalls(result.ToolCalls), result.PromptTokens, result.CompletionTokens)) + response := responsesResponse(execution.requestID, firstNonEmpty(result.Model, execution.publicModel), result.Content, result.Reasoning, decodeOpenAIToolCalls(result.ToolCalls), result.PromptTokens, result.CompletionTokens) + translate.RestoreResponseToolNames(response, execution.request.ResponseToolNames) + writeJSON(w, http.StatusOK, response) } func (s *Server) handleResponsesStream(w http.ResponseWriter, r *http.Request, execution compatibilityExecution) { @@ -162,7 +164,7 @@ func (s *Server) handleResponsesStream(w http.ResponseWriter, r *http.Request, e flusher.Flush() } writer := compatibilityStreamWriter(w) - stats, relayErr := relayResponsesStream(writer, upstream.Response.Body, execution.requestID, firstNonEmpty(execution.publicModel, execution.request.Model)) + stats, relayErr := relayResponsesStreamWithNames(writer, upstream.Response.Body, execution.requestID, firstNonEmpty(execution.publicModel, execution.request.Model), execution.request.ResponseToolNames) status := streamRequestStatus(relayErr) if r.Context().Err() != nil || errors.Is(relayErr, context.Canceled) || errors.Is(relayErr, context.DeadlineExceeded) { status = accounts.RequestStatusCanceled @@ -708,9 +710,11 @@ func relayAnthropicStream(writer io.Writer, body io.Reader, requestID, model str type responsesEventWriter struct { writer io.Writer sequenceNumber int + toolNames map[string]translate.ResponseToolName } func (w *responsesEventWriter) write(event string, payload any) error { + translate.RestoreResponseToolNames(payload, w.toolNames) if object, ok := payload.(map[string]any); ok { object["sequence_number"] = w.sequenceNumber w.sequenceNumber++ @@ -719,7 +723,11 @@ func (w *responsesEventWriter) write(event string, payload any) error { } func relayResponsesStream(writer io.Writer, body io.Reader, requestID, model string) (streamRelayStats, error) { - eventWriter := responsesEventWriter{writer: writer} + return relayResponsesStreamWithNames(writer, body, requestID, model, nil) +} + +func relayResponsesStreamWithNames(writer io.Writer, body io.Reader, requestID, model string, names map[string]translate.ResponseToolName) (streamRelayStats, error) { + eventWriter := responsesEventWriter{writer: writer, toolNames: names} responseID := "resp_" + requestID created := time.Now().Unix() inProgress := map[string]any{"id": responseID, "object": "response", "created_at": created, "status": "in_progress", "model": model, "output": []any{}} diff --git a/internal/api/compat_test.go b/internal/api/compat_test.go index 4b03017..be203c2 100644 --- a/internal/api/compat_test.go +++ b/internal/api/compat_test.go @@ -19,6 +19,114 @@ import ( "github.com/caigee-cmd/cli2api/internal/translate" ) +func TestResponsesNamespaceHandlerRoundTrip(t *testing.T) { + for _, stream := range []bool{false, true} { + t.Run(fmt.Sprint(stream), func(t *testing.T) { + calls := 0 + server, closeServer := newCompatibilityServer(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if calls == 2 { + messages := body["messages"].([]any) + found := false + for _, raw := range messages { + message := raw.(map[string]any) + if tools, ok := message["tool_calls"].([]any); ok { + for _, rawCall := range tools { + call := rawCall.(map[string]any) + if call["id"] == "call_probe" && call["function"].(map[string]any)["name"] == "mcp__fastctx__glob" { + found = true + } + } + } + } + if !found { + t.Errorf("history name/id missing: %v", messages) + } + _, _ = io.WriteString(w, `{"choices":[{"message":{"content":"ROUNDTRIP_OK"},"finish_reason":"stop"}]}`) + return + } + if stream { + w.Header().Set("Content-Type", "text/event-stream") + _, _ = io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_probe\",\"function\":{\"name\":\"mcp__fastctx__glob\",\"arguments\":\"{\"}}]}}]}\n\ndata: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"}\"}}]},\"finish_reason\":\"tool_calls\"}]}\n\ndata: [DONE]\n\n") + } else { + _, _ = io.WriteString(w, `{"choices":[{"message":{"tool_calls":[{"id":"call_probe","type":"function","function":{"name":"mcp__fastctx__glob","arguments":"{}"}}]},"finish_reason":"tool_calls"}]}`) + } + }) + defer closeServer() + tools := []any{map[string]any{"type": "namespace", "name": "mcp__fastctx", "tools": []any{map[string]any{"type": "function", "name": "glob", "parameters": map[string]any{"type": "object", "properties": map[string]any{}}}}}} + request := map[string]any{"model": "qoder/glm-5.2", "input": "find", "tools": tools, "stream": stream} + send := func() *httptest.ResponseRecorder { + data, _ := json.Marshal(request) + recorder := httptest.NewRecorder() + server.handleResponses(recorder, httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(string(data)))) + if recorder.Code != 200 { + t.Fatalf("status=%d %s", recorder.Code, recorder.Body.String()) + } + return recorder + } + recorder := send() + var completed map[string]any + check := func(item map[string]any) { + if item["name"] != "glob" || item["namespace"] != "mcp__fastctx" || item["call_id"] != "call_probe" { + t.Fatalf("wrong tool identity: %v", item) + } + } + if stream { + seen := map[string]bool{} + for _, line := range strings.Split(recorder.Body.String(), "\n") { + if !strings.HasPrefix(line, "data: ") { + continue + } + var event map[string]any + if err := json.Unmarshal([]byte(strings.TrimPrefix(line, "data: ")), &event); err != nil { + t.Fatal(err) + } + typ, _ := event["type"].(string) + if item, ok := event["item"].(map[string]any); ok && item["type"] == "function_call" { + check(item) + seen[typ] = true + } + if typ == "response.function_call_arguments.delta" || typ == "response.function_call_arguments.done" { + check(event) + seen[typ] = true + } + if typ == "response.completed" { + completed = event["response"].(map[string]any) + } + } + for _, typ := range []string{"response.output_item.added", "response.output_item.done", "response.function_call_arguments.delta", "response.function_call_arguments.done"} { + if !seen[typ] { + t.Fatalf("missing %s", typ) + } + } + } else if err := json.Unmarshal(recorder.Body.Bytes(), &completed); err != nil { + t.Fatal(err) + } + if completed == nil { + t.Fatal("no completed response") + } + output := completed["output"].([]any) + item := output[len(output)-1].(map[string]any) + check(item) + if item["arguments"] != "{}" { + t.Fatal(item) + } + request["stream"] = false + request["input"] = []any{map[string]any{"role": "user", "content": "find"}, item, map[string]any{"type": "function_call_output", "call_id": "call_probe", "output": "found"}} + if result := send(); !strings.Contains(result.Body.String(), "ROUNDTRIP_OK") { + t.Fatal(result.Body.String()) + } + if calls != 2 { + t.Fatalf("upstream calls=%d", calls) + } + }) + } +} + func TestCompatibilityStreamsPreserveTypedReadError(t *testing.T) { failover := false want := &providers.Error{ diff --git a/internal/translate/compat.go b/internal/translate/compat.go index 09bb4bc..c02a14a 100644 --- a/internal/translate/compat.go +++ b/internal/translate/compat.go @@ -6,6 +6,32 @@ import ( "strings" ) +// RestoreResponseToolNames only visits protocol containers, never user content +// or tool arguments. The same operation serves JSON responses and SSE events. +func RestoreResponseToolNames(value any, names map[string]ResponseToolName) { + if len(names) == 0 { + return + } + switch item := value.(type) { + case []any: + for _, child := range item { + RestoreResponseToolNames(child, names) + } + case map[string]any: + typ, _ := item["type"].(string) + if typ == "function_call" || typ == "response.function_call_arguments.delta" || typ == "response.function_call_arguments.done" { + name, _ := item["name"].(string) + if identity, ok := names[name]; ok && item["namespace"] == nil { + item["name"] = identity.Name + item["namespace"] = identity.Namespace + } + } + for _, key := range []string{"response", "output", "item"} { + RestoreResponseToolNames(item[key], names) + } + } +} + // AnthropicMessagesRequest is the supported subset of Anthropic's Messages API. type AnthropicMessagesRequest struct { Model string `json:"model"` @@ -129,6 +155,12 @@ func TranslateAnthropicMessages(request AnthropicMessagesRequest) (ChatRequest, if effort := anthropicReasoningEffort(request.OutputConfig); len(effort) > 0 { chat.ReasoningEffort = effort } + } + chat.ToolChoice = sanitizeToolChoice(chat.Tools, toolChoice) + chat.ParallelToolCalls = anthropicParallelToolCalls(request.ToolChoice) + if effort := anthropicReasoningEffort(request.OutputConfig); len(effort) > 0 { + chat.ReasoningEffort = effort + } if err := validateToolChoice(chat.Tools, chat.ToolChoice); err != nil { return ChatRequest{}, err } @@ -171,7 +203,12 @@ func TranslateResponses(request ResponsesRequest) (ChatRequest, error) { if err != nil { return ChatRequest{}, err } - tools, err := translateResponsesTools(mergeJSONArray(request.Tools, additionalTools)) + mergedTools := mergeJSONArray(request.Tools, additionalTools) + chat.ResponseToolNames, err = responseToolNames(mergedTools) + if err != nil { + return ChatRequest{}, err + } + tools, err := translateResponsesTools(mergedTools) if err != nil { return ChatRequest{}, err } @@ -483,6 +520,9 @@ func translateResponsesInput(raw json.RawMessage) ([]ChatMessage, error) { return nil, fmt.Errorf("input[%d] file inputs are not supported by the Qoder upstream", itemIndex) case "function_call": name := rawMapString(source, "name") + if namespace := rawMapString(source, "namespace"); namespace != "" { + name = qualifyNamespaceToolName(namespace, name) + } callID := firstRawMapString(source, "call_id", "id") if name == "" || callID == "" { return nil, fmt.Errorf("input[%d] function_call requires name and call_id", itemIndex) @@ -622,6 +662,14 @@ func translateResponsesToolChoice(raw json.RawMessage) (json.RawMessage, error) default: return nil, fmt.Errorf("tool_choice type %q is not supported", rawMapString(source, "type")) } + name := rawMapString(source, "name") + if name == "" { + return nil, fmt.Errorf("tool_choice.name required") + } + if namespace := rawMapString(source, "namespace"); namespace != "" { + name = qualifyNamespaceToolName(namespace, name) + } + return json.Marshal(map[string]any{"type": "function", "function": map[string]string{"name": name}}) } func responseReasoningEffort(raw json.RawMessage) json.RawMessage { diff --git a/internal/translate/openai.go b/internal/translate/openai.go index d0801f2..56d3f0c 100644 --- a/internal/translate/openai.go +++ b/internal/translate/openai.go @@ -6,6 +6,8 @@ import ( ) type ChatRequest struct { + ResponseToolNames map[string]ResponseToolName `json:"-"` + Model string `json:"model"` Messages []ChatMessage `json:"messages"` Stream bool `json:"stream"` diff --git a/internal/translate/tools.go b/internal/translate/tools.go index b09d825..bf31e8d 100644 --- a/internal/translate/tools.go +++ b/internal/translate/tools.go @@ -8,6 +8,59 @@ import ( const defaultToolParameters = `{"type":"object","properties":{}}` +// ResponseToolName is request-local metadata; it must never reach the provider. +type ResponseToolName struct { + Namespace string + Name string +} + +// responseToolNames records actual declarations, rather than guessing identity +// by splitting names (both namespaces and tool names can contain underscores). +func responseToolNames(raw json.RawMessage) (map[string]ResponseToolName, error) { + if emptyJSON(raw) { + return nil, nil + } + var items []json.RawMessage + if err := json.Unmarshal(raw, &items); err != nil { + return nil, err + } + all := map[string]ResponseToolName{} + names := map[string]ResponseToolName{} + register := func(flat string, identity ResponseToolName) error { + if flat == "" { + return nil + } + if previous, exists := all[flat]; exists && previous != identity { + return fmt.Errorf("tool name collision for %q", flat) + } + all[flat] = identity + return nil + } + for _, rawItem := range items { + var item map[string]json.RawMessage + if json.Unmarshal(rawItem, &item) != nil { + continue + } + typ := strings.ToLower(strings.TrimSpace(rawMapString(item, "type"))) + switch typ { + case "namespace": + for _, tool := range expandNamespaceToolItems(rawItem, strings.TrimSpace(rawMapString(item, "name"))) { + if err := register(tool.name, tool.identity); err != nil { + return nil, err + } + if tool.identity.Namespace != "" { + names[tool.name] = tool.identity + } + } + case "function", "": + name, _, _ := toolFields(item) + flat := name + if err := register(flat, ResponseToolName{Name: name}); err != nil { + return nil, err + } + } + } + return names, nil const ( customToolMarker = "__codex_custom__" customToolParameters = `{"type":"object","properties":{"input":{"type":"string","description":"Raw freeform input for the custom tool."}},"required":["input"],"additionalProperties":false}` @@ -129,6 +182,7 @@ func NormalizeOpenAITools(raw json.RawMessage) (json.RawMessage, error) { type normalizedTool struct { name string + identity ResponseToolName description string parameters json.RawMessage custom bool @@ -165,11 +219,12 @@ func expandNamespaceToolItems(raw json.RawMessage, namespace string) []normalize continue } name, description, parameters := toolFields(probe) + identity := ResponseToolName{Namespace: namespace, Name: name} name = qualifyNamespaceToolName(namespace, name) if name == "" { continue } - out = append(out, normalizedTool{name: name, description: description, parameters: parameters}) + out = append(out, normalizedTool{name: name, identity: identity, description: description, parameters: parameters}) } return out } diff --git a/internal/translate/tools_test.go b/internal/translate/tools_test.go index 86a537a..2ae61ce 100644 --- a/internal/translate/tools_test.go +++ b/internal/translate/tools_test.go @@ -6,6 +6,118 @@ import ( "testing" ) +const namespaceTestTools = `[{"type":"namespace","name":"mcp__fastctx","tools":[{"type":"function","name":"glob","parameters":{"type":"object"}}]}]` + +func TestResponsesNamespaceMappingMatchesNormalization(t *testing.T) { + for _, raw := range []string{ + `[{"type":" NAMESPACE ","name":" mcp__fastctx ","tools":[{"type":" FUNCTION ","function":{"name":"glob"}}]}]`, + `[{"type":"namespace","name":"mcp__fastctx","tools":null}]`, + `[{"type":"namespace","name":"mcp__fastctx","tools":{}}]`, + } { + names, err := responseToolNames(json.RawMessage(raw)) + if err != nil { + t.Fatal(err) + } + normalized, err := NormalizeOpenAITools(json.RawMessage(raw)) + if err != nil { + t.Fatal(err) + } + var tools []struct { + Function struct { + Name string `json:"name"` + } `json:"function"` + } + if len(normalized) > 0 { + if err := json.Unmarshal(normalized, &tools); err != nil { + t.Fatal(err) + } + } + if len(names) != len(tools) { + t.Fatalf("mapping=%v tools=%s", names, normalized) + } + for _, tool := range tools { + if names[tool.Function.Name] != (ResponseToolName{Namespace: "mcp__fastctx", Name: "glob"}) { + t.Fatal(names) + } + } + } +} + +func TestResponsesNamespaceHistoryAndChoice(t *testing.T) { + var source ResponsesRequest + if err := json.Unmarshal([]byte(`{"model":"test","input":[{"role":"user","content":"find"},{"type":"function_call","namespace":"mcp__fastctx","name":"glob","call_id":"call_1","arguments":"{}"},{"type":"function_call_output","call_id":"call_1","output":"found"}],"tool_choice":{"type":"function","namespace":"mcp__fastctx","name":"glob"}}`), &source); err != nil { + t.Fatal(err) + } + source.Tools = json.RawMessage(namespaceTestTools) + chat, err := TranslateResponses(source) + if err != nil { + t.Fatal(err) + } + if chat.ResponseToolNames["mcp__fastctx__glob"] != (ResponseToolName{Namespace: "mcp__fastctx", Name: "glob"}) { + t.Fatal(chat.ResponseToolNames) + } + if !strings.Contains(string(chat.Messages[1].ToolCalls), `"name":"mcp__fastctx__glob"`) { + t.Fatal(string(chat.Messages[1].ToolCalls)) + } + if !strings.Contains(string(chat.ToolChoice), `"name":"mcp__fastctx__glob"`) { + t.Fatal(string(chat.ToolChoice)) + } + if chat.Messages[2].ToolCallID != "call_1" { + t.Fatal(chat.Messages) + } + encoded, _ := json.Marshal(chat) + if strings.Contains(string(encoded), "ResponseToolNames") || strings.Contains(string(encoded), `"Namespace"`) { + t.Fatal("internal metadata leaked") + } +} + +func TestResponsesNamespaceAdditionalTools(t *testing.T) { + var source ResponsesRequest + if err := json.Unmarshal([]byte(`{"model":"test","input":[{"role":"user","content":"find"},{"type":"additional_tools","tools":`+namespaceTestTools+`}]}`), &source); err != nil { + t.Fatal(err) + } + chat, err := TranslateResponses(source) + if err != nil { + t.Fatal(err) + } + if len(chat.ResponseToolNames) != 1 { + t.Fatalf("additional tools mapping=%v", chat.ResponseToolNames) + } +} + +func TestResponsesNamespaceCollision(t *testing.T) { + for _, extra := range []string{ + `{"type":"function","name":"mcp__fastctx__glob"}`, + `{"type":"namespace","name":"other","tools":[{"type":"function","name":"mcp__fastctx__glob"}]}`, + } { + raw := strings.TrimSuffix(namespaceTestTools, "]") + "," + extra + "]" + if _, err := responseToolNames(json.RawMessage(raw)); err == nil { + t.Fatalf("expected collision: %s", raw) + } + } +} + +func TestRestoreNamespaceUsesDeclarationsOnly(t *testing.T) { + names, err := responseToolNames(json.RawMessage(`[{"type":"namespace","name":"one","tools":[{"type":"function","name":"lookup"}]},{"type":"namespace","name":"two","tools":[{"type":"function","name":"lookup"}]},{"type":"function","name":"mcp__flat__glob"}]`)) + if err != nil { + t.Fatal(err) + } + for _, name := range []string{"one__lookup", "two__lookup", "mcp__flat__glob"} { + item := map[string]any{"type": "function_call", "name": name, "arguments": `{"name":"one__lookup"}`} + RestoreResponseToolNames(item, names) + if identity, ok := names[name]; ok { + if item["name"] != identity.Name || item["namespace"] != identity.Namespace { + t.Fatal(item) + } + } else if item["name"] != name || item["namespace"] != nil { + t.Fatal(item) + } + if item["arguments"] != `{"name":"one__lookup"}` { + t.Fatal("arguments changed") + } + } +} + func TestNormalizeOpenAIToolsExpandsNamespaceAndDropsHostedShells(t *testing.T) { raw := json.RawMessage(`[ {"type":"function","name":"lookup","description":"lookup","parameters":{"type":"object"}},