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{