From fd213ef6da427737e2aa1048fc5f133ed5e37c74 Mon Sep 17 00:00:00 2001 From: pdfinn Date: Thu, 19 Mar 2026 17:40:16 +0700 Subject: [PATCH] feat(openai): fallback text tool-call parser for non-Anthropic models When Ollama/Qwen models generate tool calls as text instead of structured API responses, parse , , and <|tool_call|> formats from the content and promote them to proper STOP:tool_use/TOOL: wire format. Validates tool names against definitions, rejects hallucinated tools. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/llm/openai_client.go | 121 ++++++++++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) diff --git a/internal/llm/openai_client.go b/internal/llm/openai_client.go index 8377fea2ae7d3c4115e8a3b04f999baf2c037b9b..2456562aafcea23f5e13bd495b7e7fc83466d6d7 100644 --- a/internal/llm/openai_client.go +++ b/internal/llm/openai_client.go @@ -15,6 +15,8 @@ import ( "fmt" "io" "net/http" + "os" + "regexp" "strings" "sync" "time" @@ -734,6 +736,18 @@ func (c *OpenAIClient) AskWithRequest(ctx context.Context, req AskRequest) (AskR return AskResponse{Response: responseText, Tokens: totalTokens}, nil } + // Fallback: if the model generated tool calls as text instead of structured API calls, + // parse them from the content and treat them as proper tool calls. + if len(toolCalls) == 0 && responseText != "" && len(req.ToolDefs) > 0 { + remaining, extracted := extractTextToolCalls(responseText, req.ToolDefs) + if len(extracted) > 0 { + fmt.Fprintf(os.Stderr, "llm9p: fallback text tool-call parser extracted %d tool call(s) from content\n", len(extracted)) + toolCalls = extracted + responseText = strings.TrimSpace(remaining) + finishReason = openai.FinishReasonToolCalls + } + } + // Tool mode: format STOP: response and build structured JSON for history var textParts []string var toolCallEntries []struct{ id, name, args string } @@ -779,6 +793,113 @@ func (c *OpenAIClient) AskWithRequest(ctx context.Context, req AskRequest) (AskR return AskResponse{Response: sb.String(), StructuredJSON: structuredJSON, Tokens: totalTokens}, nil } +// extractTextToolCalls scans content for tool calls encoded as text (common when +// Ollama/Qwen models don't use the structured tool_calls API). It recognises three +// formats: +// +// 1. \n\nvalue\n\n +// 2. \n{"name": "toolname", "arguments": {...}}\n +// 3. <|tool_call|>\n{"name": "toolname", "arguments": {...}}\n<|/tool_call|> +// +// Returns the remaining non-tool-call text and a slice of synthetic openai.ToolCall. +func extractTextToolCalls(content string, toolDefs []ToolDef) (string, []openai.ToolCall) { + // Fast path: skip regex work when no markers are present. + if !strings.Contains(content, "") && + !strings.Contains(content, "<|tool_call|>") { + return content, nil + } + + // Build set of valid tool names for validation. + validTools := make(map[string]bool, len(toolDefs)) + for _, td := range toolDefs { + validTools[td.Name] = true + } + + var toolCalls []openai.ToolCall + remaining := content + + // --- Format 1: \n\nvalue\n\n --- + reFn := regexp.MustCompile(`(?s)]+)>\s*]+)>\s*(.*?)\s*\s*`) + remaining = reFn.ReplaceAllStringFunc(remaining, func(match string) string { + m := reFn.FindStringSubmatch(match) + if m == nil { + return match + } + name := strings.TrimSpace(m[1]) + paramName := strings.TrimSpace(m[2]) + paramValue := strings.TrimSpace(m[3]) + if !validTools[name] { + return match // leave hallucinated tool calls as-is + } + argsJSON, _ := json.Marshal(map[string]string{paramName: paramValue}) + idx := len(toolCalls) + toolCalls = append(toolCalls, openai.ToolCall{ + Index: &idx, + ID: fmt.Sprintf("fallback_%d", idx), + Type: openai.ToolTypeFunction, + Function: openai.FunctionCall{ + Name: name, + Arguments: string(argsJSON), + }, + }) + return "" + }) + + // --- Format 2 & 3: or <|tool_call|> wrapping JSON --- + reTC := regexp.MustCompile(`(?s)(?:<\|tool_call\|>|)\s*(\{.*?\})\s*(?:|<\|/tool_call\|>)`) + remaining = reTC.ReplaceAllStringFunc(remaining, func(match string) string { + m := reTC.FindStringSubmatch(match) + if m == nil { + return match + } + raw := strings.TrimSpace(m[1]) + + var parsed struct { + Name string `json:"name"` + Arguments json.RawMessage `json:"arguments"` + } + if err := json.Unmarshal([]byte(raw), &parsed); err != nil { + return match + } + if !validTools[parsed.Name] { + return match + } + + // Normalize arguments: if it's already a JSON object/string, keep as-is; + // if it's a raw string, marshal it so it becomes a valid JSON string. + argsStr := string(parsed.Arguments) + if len(argsStr) == 0 { + argsStr = "{}" + } else if argsStr[0] != '{' && argsStr[0] != '"' { + // Shouldn't normally happen, but handle gracefully. + argsStr = "{}" + } else if argsStr[0] == '{' { + // Already an object — use as-is. + } else { + // It's a JSON string — the server sent arguments as a string. + var s string + if err := json.Unmarshal(parsed.Arguments, &s); err == nil { + argsStr = s + } + } + + idx := len(toolCalls) + toolCalls = append(toolCalls, openai.ToolCall{ + Index: &idx, + ID: fmt.Sprintf("fallback_%d", idx), + Type: openai.ToolTypeFunction, + Function: openai.FunctionCall{ + Name: parsed.Name, + Arguments: argsStr, + }, + }) + return "" + }) + + return remaining, toolCalls +} + // buildOpenAITools converts ToolDef slice to OpenAI SDK tool params. func buildOpenAITools(defs []ToolDef) []openai.Tool { tools := make([]openai.Tool, 0, len(defs))