From fad4d5f94d081006c358ecf99f1225ee2605445c Mon Sep 17 00:00:00 2001 From: Dmitrii Mukhutdinov Date: Fri, 27 Mar 2026 19:34:20 -0300 Subject: [PATCH] fix(openai_compat): parse Hermes-style XML tool calls as fallback Models such as mimo-v2-pro (Qwen/Hermes-derived) emit tool calls in a lightweight XML format instead of OpenAI function-call JSON: {"command":"ls"} When the response contains no standard tool_calls but includes a block, parse it: the inner tag name becomes the tool name and its text content is decoded as a JSON argument object. The fallback is content-gated (triggers only on presence) and skipped when standard tool_calls are already present, so it does not affect well-behaved providers. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- pkg/providers/openai_compat/provider.go | 97 +++++++++++++- pkg/providers/openai_compat/provider_test.go | 125 +++++++++++++++++++ 2 files changed, 219 insertions(+), 3 deletions(-) diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go index 90bc683b8..c266e8d93 100644 --- a/pkg/providers/openai_compat/provider.go +++ b/pkg/providers/openai_compat/provider.go @@ -10,6 +10,7 @@ import ( "log" "net/http" "net/url" + "regexp" "strings" "time" @@ -42,6 +43,18 @@ type Option func(*Provider) const defaultRequestTimeout = common.DefaultRequestTimeout +// xmlToolCallPattern matches blocks used by models such as +// Qwen/Hermes-derived ones (e.g. mimo-v2-pro) that do not emit OpenAI function-call +// JSON but instead produce a lightweight XML format: +// +// {"command":"ls"} +// +// The inner tag name is the tool name; its text content is a JSON argument object. +var ( + xmlToolCallPattern = regexp.MustCompile(`(?is)\s*(.*?)\s*`) + xmlTagOpenPattern = regexp.MustCompile(`(?i)^<(\w+)>`) +) + func WithMaxTokensField(maxTokensField string) Option { return func(p *Provider) { p.maxTokensField = maxTokensField @@ -194,7 +207,12 @@ func (p *Provider) Chat( return nil, common.HandleErrorResponse(resp, p.apiBase) } - return common.ReadAndParseResponse(resp, p.apiBase) + out, err := common.ReadAndParseResponse(resp, p.apiBase) + if err != nil { + return nil, err + } + applyXMLToolCallFallback(out) + return out, nil } // ChatStream implements streaming via OpenAI-compatible SSE (stream: true). @@ -378,12 +396,85 @@ func parseStreamResponse( finishReason = "stop" } - return &LLMResponse{ + out := &LLMResponse{ Content: textContent.String(), ToolCalls: toolCalls, FinishReason: finishReason, Usage: usage, - }, nil + } + applyXMLToolCallFallback(out) + return out, nil +} + +// applyXMLToolCallFallback detects and parses Hermes-style XML tool calls from +// response content when no standard OpenAI tool_calls were returned. It mutates +// resp in place: extracted calls are moved to ToolCalls, matched text is stripped +// from Content, and FinishReason is set to "tool_calls". +func applyXMLToolCallFallback(resp *LLMResponse) { + if resp == nil || len(resp.ToolCalls) > 0 { + return + } + if !strings.Contains(resp.Content, "") && !strings.Contains(resp.Content, "" + closeIdx := strings.LastIndex(strings.ToLower(after), strings.ToLower(closeTag)) + argsStr := after + if closeIdx >= 0 { + argsStr = after[:closeIdx] + } + argsStr = strings.TrimSpace(argsStr) + + var args map[string]any + if err := json.Unmarshal([]byte(argsStr), &args); err != nil { + continue + } + argsJSON, _ := json.Marshal(args) + toolCalls = append(toolCalls, ToolCall{ + ID: fmt.Sprintf("call_%d", len(toolCalls)+1), + Type: "function", + Name: name, + Arguments: args, + Function: &FunctionCall{ + Name: name, + Arguments: string(argsJSON), + }, + }) + } + + if len(toolCalls) == 0 { + return nil, content + } + stripped := strings.TrimSpace(xmlToolCallPattern.ReplaceAllString(content, "")) + return toolCalls, stripped } func normalizeModel(model, apiBase string) string { diff --git a/pkg/providers/openai_compat/provider_test.go b/pkg/providers/openai_compat/provider_test.go index ab632ccf3..7d083e293 100644 --- a/pkg/providers/openai_compat/provider_test.go +++ b/pkg/providers/openai_compat/provider_test.go @@ -158,6 +158,131 @@ func TestProviderChat_ParsesToolCallsWithObjectArguments(t *testing.T) { } } +func TestProviderChat_ParsesXMLToolCalls(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := map[string]any{ + "choices": []map[string]any{ + { + "message": map[string]any{ + "content": "{\"command\":\"pwd && ls -la\"}", + }, + "finish_reason": "stop", + }, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + p := NewProvider("key", server.URL, "") + out, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "ls please"}}, nil, "mimo-v2-pro", nil) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + if len(out.ToolCalls) != 1 { + t.Fatalf("len(ToolCalls) = %d, want 1", len(out.ToolCalls)) + } + if out.ToolCalls[0].Name != "shell" { + t.Fatalf("ToolCalls[0].Name = %q, want shell", out.ToolCalls[0].Name) + } + if out.ToolCalls[0].Arguments["command"] != "pwd && ls -la" { + t.Fatalf("command = %v, want pwd && ls -la", out.ToolCalls[0].Arguments["command"]) + } + if out.Content != "" { + t.Fatalf("Content = %q, want empty after stripping toolcall block", out.Content) + } + if out.FinishReason != "tool_calls" { + t.Fatalf("FinishReason = %q, want tool_calls", out.FinishReason) + } +} + +func TestProviderChat_XMLToolCallsNotParsedWhenStandardCallsPresent(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := map[string]any{ + "choices": []map[string]any{ + { + "message": map[string]any{ + "content": "", + "tool_calls": []map[string]any{ + { + "id": "call_1", + "type": "function", + "function": map[string]any{ + "name": "shell", + "arguments": `{"command":"ls"}`, + }, + }, + }, + }, + "finish_reason": "tool_calls", + }, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + p := NewProvider("key", server.URL, "") + out, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "ls"}}, nil, "gpt-4o", nil) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + if len(out.ToolCalls) != 1 || out.ToolCalls[0].Name != "shell" { + t.Fatalf("unexpected ToolCalls: %v", out.ToolCalls) + } +} + +func TestParseXMLToolCalls(t *testing.T) { + tests := []struct { + name string + content string + wantCalls int + wantTool string + wantArg string + wantVal string + }{ + { + name: "simple shell call", + content: "{\"command\":\"ls\"}", + wantCalls: 1, wantTool: "shell", wantArg: "command", wantVal: "ls", + }, + { + name: "with surrounding text", + content: "Let me check:\n{\"path\":\"/tmp\"}", + wantCalls: 1, wantTool: "list_dir", wantArg: "path", wantVal: "/tmp", + }, + { + name: "no toolcall tag — not parsed", + content: "{\"command\":\"ls\"}", + wantCalls: 0, + }, + { + name: "invalid json inside — skipped", + content: "not json", + wantCalls: 0, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + calls, _ := parseXMLToolCalls(tc.content) + if len(calls) != tc.wantCalls { + t.Fatalf("len(calls) = %d, want %d", len(calls), tc.wantCalls) + } + if tc.wantCalls > 0 { + if calls[0].Name != tc.wantTool { + t.Fatalf("Name = %q, want %q", calls[0].Name, tc.wantTool) + } + if calls[0].Arguments[tc.wantArg] != tc.wantVal { + t.Fatalf("arg %q = %v, want %q", tc.wantArg, calls[0].Arguments[tc.wantArg], tc.wantVal) + } + } + }) + } +} + func TestProviderChat_ParsesReasoningContent(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { resp := map[string]any{