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:
<toolcall><shell>{"command":"ls"}</shell></toolcall>
When the response contains no standard tool_calls but includes a
<toolcall> 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 <toolcall> 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) <noreply@anthropic.com>
This commit is contained in:
parent
60d7ec20a5
commit
fad4d5f94d
2 changed files with 219 additions and 3 deletions
|
|
@ -10,6 +10,7 @@ import (
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -42,6 +43,18 @@ type Option func(*Provider)
|
||||||
|
|
||||||
const defaultRequestTimeout = common.DefaultRequestTimeout
|
const defaultRequestTimeout = common.DefaultRequestTimeout
|
||||||
|
|
||||||
|
// xmlToolCallPattern matches <toolcall>…</toolcall> 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:
|
||||||
|
//
|
||||||
|
// <toolcall><shell>{"command":"ls"}</shell></toolcall>
|
||||||
|
//
|
||||||
|
// The inner tag name is the tool name; its text content is a JSON argument object.
|
||||||
|
var (
|
||||||
|
xmlToolCallPattern = regexp.MustCompile(`(?is)<toolcall>\s*(.*?)\s*</toolcall>`)
|
||||||
|
xmlTagOpenPattern = regexp.MustCompile(`(?i)^<(\w+)>`)
|
||||||
|
)
|
||||||
|
|
||||||
func WithMaxTokensField(maxTokensField string) Option {
|
func WithMaxTokensField(maxTokensField string) Option {
|
||||||
return func(p *Provider) {
|
return func(p *Provider) {
|
||||||
p.maxTokensField = maxTokensField
|
p.maxTokensField = maxTokensField
|
||||||
|
|
@ -194,7 +207,12 @@ func (p *Provider) Chat(
|
||||||
return nil, common.HandleErrorResponse(resp, p.apiBase)
|
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).
|
// ChatStream implements streaming via OpenAI-compatible SSE (stream: true).
|
||||||
|
|
@ -378,12 +396,85 @@ func parseStreamResponse(
|
||||||
finishReason = "stop"
|
finishReason = "stop"
|
||||||
}
|
}
|
||||||
|
|
||||||
return &LLMResponse{
|
out := &LLMResponse{
|
||||||
Content: textContent.String(),
|
Content: textContent.String(),
|
||||||
ToolCalls: toolCalls,
|
ToolCalls: toolCalls,
|
||||||
FinishReason: finishReason,
|
FinishReason: finishReason,
|
||||||
Usage: usage,
|
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, "<toolcall>") && !strings.Contains(resp.Content, "<toolcall ") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
toolCalls, stripped := parseXMLToolCalls(resp.Content)
|
||||||
|
if len(toolCalls) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp.ToolCalls = toolCalls
|
||||||
|
resp.Content = stripped
|
||||||
|
resp.FinishReason = "tool_calls"
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseXMLToolCalls extracts tool calls from Hermes-style XML blocks and returns
|
||||||
|
// them along with the content with those blocks removed.
|
||||||
|
func parseXMLToolCalls(content string) ([]ToolCall, string) {
|
||||||
|
matches := xmlToolCallPattern.FindAllStringSubmatch(content, -1)
|
||||||
|
if len(matches) == 0 {
|
||||||
|
return nil, content
|
||||||
|
}
|
||||||
|
|
||||||
|
var toolCalls []ToolCall
|
||||||
|
for _, m := range matches {
|
||||||
|
inner := strings.TrimSpace(m[1])
|
||||||
|
// Extract opening tag name (Go regexp has no backreferences, so parse manually).
|
||||||
|
nm := xmlTagOpenPattern.FindStringSubmatch(inner)
|
||||||
|
if nm == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := nm[1]
|
||||||
|
// Content is between the opening and closing tag.
|
||||||
|
after := inner[len(nm[0]):]
|
||||||
|
closeTag := "</" + name + ">"
|
||||||
|
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 {
|
func normalizeModel(model, apiBase string) string {
|
||||||
|
|
|
||||||
|
|
@ -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": "<toolcall><shell>{\"command\":\"pwd && ls -la\"}</shell></toolcall>",
|
||||||
|
},
|
||||||
|
"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: "<toolcall><shell>{\"command\":\"ls\"}</shell></toolcall>",
|
||||||
|
wantCalls: 1, wantTool: "shell", wantArg: "command", wantVal: "ls",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with surrounding text",
|
||||||
|
content: "Let me check:\n<toolcall><list_dir>{\"path\":\"/tmp\"}</list_dir></toolcall>",
|
||||||
|
wantCalls: 1, wantTool: "list_dir", wantArg: "path", wantVal: "/tmp",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no toolcall tag — not parsed",
|
||||||
|
content: "<shell>{\"command\":\"ls\"}</shell>",
|
||||||
|
wantCalls: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid json inside — skipped",
|
||||||
|
content: "<toolcall><shell>not json</shell></toolcall>",
|
||||||
|
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) {
|
func TestProviderChat_ParsesReasoningContent(t *testing.T) {
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
resp := map[string]any{
|
resp := map[string]any{
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue