Fix: Refactor LLM response parser to separate tool calls and strip them from text content in openai_compat providers

This commit is contained in:
ManManavadaria 2026-02-19 18:29:03 +05:30
parent 12f0c4a6cf
commit 78319093c9
7 changed files with 787 additions and 267 deletions

View file

@ -7,6 +7,8 @@ import (
"fmt" "fmt"
"os/exec" "os/exec"
"strings" "strings"
"github.com/sipeed/picoclaw/pkg/providers/toolcall"
) )
// ClaudeCliProvider implements LLMProvider using the claude CLI as a subprocess. // ClaudeCliProvider implements LLMProvider using the claude CLI as a subprocess.
@ -173,28 +175,12 @@ func (p *ClaudeCliProvider) parseClaudeCliResponse(output string) (*LLMResponse,
// extractToolCalls delegates to the shared extractToolCallsFromText function. // extractToolCalls delegates to the shared extractToolCallsFromText function.
func (p *ClaudeCliProvider) extractToolCalls(text string) []ToolCall { func (p *ClaudeCliProvider) extractToolCalls(text string) []ToolCall {
return extractToolCallsFromText(text) return toolcall.ExtractToolCallsFromText(text)
} }
// stripToolCallsJSON delegates to the shared stripToolCallsFromText function. // stripToolCallsJSON delegates to the shared stripToolCallsFromText function.
func (p *ClaudeCliProvider) stripToolCallsJSON(text string) string { func (p *ClaudeCliProvider) stripToolCallsJSON(text string) string {
return stripToolCallsFromText(text) return toolcall.StripToolCallsFromText(text)
}
// findMatchingBrace finds the index after the closing brace matching the opening brace at pos.
func findMatchingBrace(text string, pos int) int {
depth := 0
for i := pos; i < len(text); i++ {
if text[i] == '{' {
depth++
} else if text[i] == '}' {
depth--
if depth == 0 {
return i + 1
}
}
}
return pos
} }
// claudeCliJSONResponse represents the JSON output from the claude CLI. // claudeCliJSONResponse represents the JSON output from the claude CLI.

View file

@ -824,158 +824,3 @@ func TestParseClaudeCliResponse_WhitespaceResult(t *testing.T) {
t.Errorf("Content = %q, want %q (should be trimmed)", resp.Content, "hello") t.Errorf("Content = %q, want %q (should be trimmed)", resp.Content, "hello")
} }
} }
// --- extractToolCalls tests ---
func TestExtractToolCalls_NoToolCalls(t *testing.T) {
p := NewClaudeCliProvider("/workspace")
got := p.extractToolCalls("Just a regular response.")
if len(got) != 0 {
t.Errorf("extractToolCalls() = %d, want 0", len(got))
}
}
func TestExtractToolCalls_WithToolCalls(t *testing.T) {
p := NewClaudeCliProvider("/workspace")
text := `Here's the result:
{"tool_calls":[{"id":"call_1","type":"function","function":{"name":"test","arguments":"{}"}}]}`
got := p.extractToolCalls(text)
if len(got) != 1 {
t.Fatalf("extractToolCalls() = %d, want 1", len(got))
}
if got[0].ID != "call_1" {
t.Errorf("ID = %q, want %q", got[0].ID, "call_1")
}
if got[0].Name != "test" {
t.Errorf("Name = %q, want %q", got[0].Name, "test")
}
if got[0].Type != "function" {
t.Errorf("Type = %q, want %q", got[0].Type, "function")
}
}
func TestExtractToolCalls_InvalidJSON(t *testing.T) {
p := NewClaudeCliProvider("/workspace")
got := p.extractToolCalls(`{"tool_calls":invalid}`)
if len(got) != 0 {
t.Errorf("extractToolCalls() with invalid JSON = %d, want 0", len(got))
}
}
func TestExtractToolCalls_MultipleToolCalls(t *testing.T) {
p := NewClaudeCliProvider("/workspace")
text := `{"tool_calls":[{"id":"call_1","type":"function","function":{"name":"read_file","arguments":"{\"path\":\"/tmp/test\"}"}},{"id":"call_2","type":"function","function":{"name":"write_file","arguments":"{\"path\":\"/tmp/out\",\"content\":\"hello\"}"}}]}`
got := p.extractToolCalls(text)
if len(got) != 2 {
t.Fatalf("extractToolCalls() = %d, want 2", len(got))
}
if got[0].Name != "read_file" {
t.Errorf("[0].Name = %q, want %q", got[0].Name, "read_file")
}
if got[1].Name != "write_file" {
t.Errorf("[1].Name = %q, want %q", got[1].Name, "write_file")
}
// Verify arguments were parsed
if got[0].Arguments["path"] != "/tmp/test" {
t.Errorf("[0].Arguments[path] = %v, want /tmp/test", got[0].Arguments["path"])
}
if got[1].Arguments["content"] != "hello" {
t.Errorf("[1].Arguments[content] = %v, want hello", got[1].Arguments["content"])
}
}
func TestExtractToolCalls_UnmatchedBrace(t *testing.T) {
p := NewClaudeCliProvider("/workspace")
got := p.extractToolCalls(`{"tool_calls":[{"id":"call_1"`)
if len(got) != 0 {
t.Errorf("extractToolCalls() with unmatched brace = %d, want 0", len(got))
}
}
func TestExtractToolCalls_ToolCallArgumentsParsing(t *testing.T) {
p := NewClaudeCliProvider("/workspace")
text := `{"tool_calls":[{"id":"c1","type":"function","function":{"name":"fn","arguments":"{\"num\":42,\"flag\":true,\"name\":\"test\"}"}}]}`
got := p.extractToolCalls(text)
if len(got) != 1 {
t.Fatalf("len = %d, want 1", len(got))
}
// Verify different argument types
if got[0].Arguments["num"] != float64(42) {
t.Errorf("Arguments[num] = %v (%T), want 42", got[0].Arguments["num"], got[0].Arguments["num"])
}
if got[0].Arguments["flag"] != true {
t.Errorf("Arguments[flag] = %v, want true", got[0].Arguments["flag"])
}
if got[0].Arguments["name"] != "test" {
t.Errorf("Arguments[name] = %v, want test", got[0].Arguments["name"])
}
// Verify raw arguments string is preserved in FunctionCall
if got[0].Function.Arguments == "" {
t.Error("Function.Arguments should contain raw JSON string")
}
}
// --- stripToolCallsJSON tests ---
func TestStripToolCallsJSON(t *testing.T) {
p := NewClaudeCliProvider("/workspace")
text := `Let me check the weather.
{"tool_calls":[{"id":"call_1","type":"function","function":{"name":"test","arguments":"{}"}}]}
Done.`
got := p.stripToolCallsJSON(text)
if strings.Contains(got, "tool_calls") {
t.Errorf("should remove tool_calls JSON, got %q", got)
}
if !strings.Contains(got, "Let me check the weather.") {
t.Errorf("should keep text before, got %q", got)
}
if !strings.Contains(got, "Done.") {
t.Errorf("should keep text after, got %q", got)
}
}
func TestStripToolCallsJSON_NoToolCalls(t *testing.T) {
p := NewClaudeCliProvider("/workspace")
text := "Just regular text."
got := p.stripToolCallsJSON(text)
if got != text {
t.Errorf("stripToolCallsJSON() = %q, want %q", got, text)
}
}
func TestStripToolCallsJSON_OnlyToolCalls(t *testing.T) {
p := NewClaudeCliProvider("/workspace")
text := `{"tool_calls":[{"id":"c1","type":"function","function":{"name":"fn","arguments":"{}"}}]}`
got := p.stripToolCallsJSON(text)
if got != "" {
t.Errorf("stripToolCallsJSON() = %q, want empty", got)
}
}
// --- findMatchingBrace tests ---
func TestFindMatchingBrace(t *testing.T) {
tests := []struct {
text string
pos int
want int
}{
{`{"a":1}`, 0, 7},
{`{"a":{"b":2}}`, 0, 13},
{`text {"a":1} more`, 5, 12},
{`{unclosed`, 0, 0}, // no match returns pos
{`{}`, 0, 2}, // empty object
{`{{{}}}`, 0, 6}, // deeply nested
{`{"a":"b{c}d"}`, 0, 13}, // braces in strings (simplified matcher)
}
for _, tt := range tests {
got := findMatchingBrace(tt.text, tt.pos)
if got != tt.want {
t.Errorf("findMatchingBrace(%q, %d) = %d, want %d", tt.text, tt.pos, got, tt.want)
}
}
}

View file

@ -8,6 +8,8 @@ import (
"fmt" "fmt"
"os/exec" "os/exec"
"strings" "strings"
"github.com/sipeed/picoclaw/pkg/providers/toolcall"
) )
// CodexCliProvider implements LLMProvider by wrapping the codex CLI as a subprocess. // CodexCliProvider implements LLMProvider by wrapping the codex CLI as a subprocess.
@ -234,12 +236,12 @@ func (p *CodexCliProvider) parseJSONLEvents(output string) (*LLMResponse, error)
content := strings.Join(contentParts, "\n") content := strings.Join(contentParts, "\n")
// Extract tool calls from response text (same pattern as ClaudeCliProvider) // Extract tool calls from response text (same pattern as ClaudeCliProvider)
toolCalls := extractToolCallsFromText(content) toolCalls := toolcall.ExtractToolCallsFromText(content)
finishReason := "stop" finishReason := "stop"
if len(toolCalls) > 0 { if len(toolCalls) > 0 {
finishReason = "tool_calls" finishReason = "tool_calls"
content = stripToolCallsFromText(content) content = toolcall.StripToolCallsFromText(content)
} }
return &LLMResponse{ return &LLMResponse{

View file

@ -13,6 +13,7 @@ import (
"time" "time"
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes" "github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
"github.com/sipeed/picoclaw/pkg/providers/toolcall"
) )
type ToolCall = protocoltypes.ToolCall type ToolCall = protocoltypes.ToolCall
@ -152,36 +153,66 @@ func parseResponse(body []byte) (*LLMResponse, error) {
} }
choice := apiResponse.Choices[0] choice := apiResponse.Choices[0]
toolCalls := make([]ToolCall, 0, len(choice.Message.ToolCalls))
for _, tc := range choice.Message.ToolCalls {
arguments := make(map[string]interface{})
name := ""
if tc.Function != nil { // Parse structured tool calls from the standard tool_calls array
name = tc.Function.Name toolCalls := toolcall.ParseStructuredToolCalls(choice.Message.ToolCalls)
if tc.Function.Arguments != "" {
if err := json.Unmarshal([]byte(tc.Function.Arguments), &arguments); err != nil {
log.Printf("openai_compat: failed to decode tool call arguments for %q: %v", name, err)
arguments["raw"] = tc.Function.Arguments
}
}
}
toolCalls = append(toolCalls, ToolCall{ // Extract any additional tool calls from the content text (tool calls in text content)
ID: tc.ID, content := choice.Message.Content
Name: name, textToolCalls := toolcall.ExtractToolCallsFromText(content)
Arguments: arguments,
}) toolCalls = mergeToolCalls(toolCalls, textToolCalls)
if len(textToolCalls) > 0 {
content = toolcall.StripToolCallsFromText(content)
} }
// Determine finish reason based on tool calls
finishReason := determineFinishReason(choice.FinishReason, len(toolCalls))
return &LLMResponse{ return &LLMResponse{
Content: choice.Message.Content, Content: strings.TrimSpace(content),
ToolCalls: toolCalls, ToolCalls: toolCalls,
FinishReason: choice.FinishReason, FinishReason: finishReason,
Usage: apiResponse.Usage, Usage: apiResponse.Usage,
}, nil }, nil
} }
// mergeToolCalls merges embedded tool calls with structured tool calls.
func mergeToolCalls(structured []ToolCall, embedded []ToolCall) []ToolCall {
if len(embedded) == 0 {
return structured
}
existingIDs := make(map[string]bool, len(structured))
for _, tc := range structured {
existingIDs[tc.ID] = true
}
// Append embedded tool calls that don't already exist
for _, etc := range embedded {
if !existingIDs[etc.ID] {
structured = append(structured, etc)
}
}
return structured
}
// determineFinishReason determines the appropriate finish reason based on the API response
// and whether tool calls are present.
func determineFinishReason(apiFinishReason string, toolCallCount int) string {
if toolCallCount > 0 && apiFinishReason == "stop" {
return "tool_calls"
}
if apiFinishReason != "" {
return apiFinishReason
}
return "stop"
}
func normalizeModel(model, apiBase string) string { func normalizeModel(model, apiBase string) string {
idx := strings.Index(model, "/") idx := strings.Index(model, "/")
if idx == -1 { if idx == -1 {

View file

@ -1,72 +0,0 @@
package providers
import (
"encoding/json"
"strings"
)
// extractToolCallsFromText parses tool call JSON from response text.
// Both ClaudeCliProvider and CodexCliProvider use this to extract
// tool calls that the model outputs in its response text.
func extractToolCallsFromText(text string) []ToolCall {
start := strings.Index(text, `{"tool_calls"`)
if start == -1 {
return nil
}
end := findMatchingBrace(text, start)
if end == start {
return nil
}
jsonStr := text[start:end]
var wrapper struct {
ToolCalls []struct {
ID string `json:"id"`
Type string `json:"type"`
Function struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
} `json:"tool_calls"`
}
if err := json.Unmarshal([]byte(jsonStr), &wrapper); err != nil {
return nil
}
var result []ToolCall
for _, tc := range wrapper.ToolCalls {
var args map[string]interface{}
json.Unmarshal([]byte(tc.Function.Arguments), &args)
result = append(result, ToolCall{
ID: tc.ID,
Type: tc.Type,
Name: tc.Function.Name,
Arguments: args,
Function: &FunctionCall{
Name: tc.Function.Name,
Arguments: tc.Function.Arguments,
},
})
}
return result
}
// stripToolCallsFromText removes tool call JSON from response text.
func stripToolCallsFromText(text string) string {
start := strings.Index(text, `{"tool_calls"`)
if start == -1 {
return text
}
end := findMatchingBrace(text, start)
if end == start {
return text
}
return strings.TrimSpace(text[:start] + text[end:])
}

View file

@ -0,0 +1,145 @@
package toolcall
import (
"encoding/json"
"log"
"strings"
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
)
// parseStructuredToolCalls converts API response tool calls to the internal ToolCall format.
func ParseStructuredToolCalls(apiToolCalls []struct {
ID string `json:"id"`
Type string `json:"type"`
Function *struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
}) []protocoltypes.ToolCall {
if len(apiToolCalls) == 0 {
return nil
}
toolCalls := make([]protocoltypes.ToolCall, 0, len(apiToolCalls))
for _, tc := range apiToolCalls {
if tc.Function == nil {
continue
}
arguments := ParseToolCallArguments(tc.Function.Arguments)
toolCalls = append(toolCalls, protocoltypes.ToolCall{
ID: tc.ID,
Type: tc.Type,
Name: tc.Function.Name,
Arguments: arguments,
Function: &protocoltypes.FunctionCall{
Name: tc.Function.Name,
Arguments: tc.Function.Arguments,
},
})
}
return toolCalls
}
// parseToolCallArguments parses JSON arguments string into a map.
func ParseToolCallArguments(argsStr string) map[string]interface{} {
if argsStr == "" {
return make(map[string]interface{})
}
var arguments map[string]interface{}
if err := json.Unmarshal([]byte(argsStr), &arguments); err != nil {
log.Printf("openai_compat: failed to decode tool call arguments: %v", err)
return map[string]interface{}{"raw": argsStr}
}
return arguments
}
// extractToolCallsFromText parses tool call JSON from response text.
// This handles cases where models embed tool calls in the content field.
func ExtractToolCallsFromText(text string) []protocoltypes.ToolCall {
start := strings.Index(text, `{"tool_calls"`)
if start == -1 {
return nil
}
end := FindMatchingBrace(text, start)
if end == start {
return nil
}
jsonStr := text[start:end]
var wrapper struct {
ToolCalls []struct {
ID string `json:"id"`
Type string `json:"type"`
Function struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
} `json:"tool_calls"`
}
if err := json.Unmarshal([]byte(jsonStr), &wrapper); err != nil {
return nil
}
var result []protocoltypes.ToolCall
for _, tc := range wrapper.ToolCalls {
args := ParseToolCallArguments(tc.Function.Arguments)
result = append(result, protocoltypes.ToolCall{
ID: tc.ID,
Type: tc.Type,
Name: tc.Function.Name,
Arguments: args,
Function: &protocoltypes.FunctionCall{
Name: tc.Function.Name,
Arguments: tc.Function.Arguments,
},
})
}
return result
}
// FindMatchingBrace finds the index after the closing brace matching the opening brace at pos.
func FindMatchingBrace(text string, pos int) int {
depth := 0
for i := pos; i < len(text); i++ {
if text[i] == '{' {
depth++
} else if text[i] == '}' {
depth--
if depth == 0 {
return i + 1
}
}
}
return pos
}
// stripJSONObject removes a JSON object starting with the specified pattern from text.
func StripJSONObject(text, pattern string) string {
start := strings.Index(text, pattern)
if start == -1 {
return text
}
end := FindMatchingBrace(text, start)
if end == start {
return text
}
return strings.TrimSpace(text[:start] + text[end:])
}
// StripToolCallsFromText removes tool call JSON from response text.
func StripToolCallsFromText(text string) string {
return StripJSONObject(text, `{"tool_calls"`)
}

View file

@ -0,0 +1,583 @@
package toolcall
import (
"testing"
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
)
// --- FindMatchingBrace tests ---
func TestFindMatchingBrace(t *testing.T) {
tests := []struct {
text string
pos int
want int
}{
{`{"a":1}`, 0, 7},
{`{"a":{"b":2}}`, 0, 13},
{`text {"a":1} more`, 5, 12},
{`{unclosed`, 0, 0}, // no match returns pos
{`{}`, 0, 2}, // empty object
{`{{{}}}`, 0, 6}, // deeply nested
{`{"a":"b{c}d"}`, 0, 13}, // braces in strings (simplified matcher)
}
for _, tt := range tests {
got := FindMatchingBrace(tt.text, tt.pos)
if got != tt.want {
t.Errorf("FindMatchingBrace(%q, %d) = %d, want %d", tt.text, tt.pos, got, tt.want)
}
}
}
// --- StripJSONObject tests ---
func TestStripJSONObject(t *testing.T) {
tests := []struct {
name string
text string
pattern string
want string
}{
{
name: "removes matching object",
text: `before {"tool_calls":[]} after`,
pattern: `{"tool_calls"`,
want: `before after`,
},
{
name: "pattern not found returns original",
text: "no match here",
pattern: `{"tool_calls"`,
want: "no match here",
},
{
name: "unmatched brace returns original",
text: `{"tool_calls"`,
pattern: `{"tool_calls"`,
want: `{"tool_calls"`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := StripJSONObject(tt.text, tt.pattern)
if got != tt.want {
t.Errorf("StripJSONObject() = %q, want %q", got, tt.want)
}
})
}
}
// --- StripToolCallsFromText tests ---
func TestStripToolCallsFromText(t *testing.T) {
tests := []struct {
name string
text string
want string
}{
{
name: "removes tool_calls JSON",
text: `Let me check.` + "\n" + `{"tool_calls":[{"id":"c1","type":"function","function":{"name":"fn","arguments":"{}"}}]}`,
want: "Let me check.",
},
{
name: "no tool_calls returns original",
text: "Just regular text.",
want: "Just regular text.",
},
{
name: "only tool_calls returns empty",
text: `{"tool_calls":[{"id":"c1","type":"function","function":{"name":"fn","arguments":"{}"}}]}`,
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := StripToolCallsFromText(tt.text)
if got != tt.want {
t.Errorf("StripToolCallsFromText() = %q, want %q", got, tt.want)
}
})
}
}
// --- ParseToolCallArguments tests ---
func TestParseToolCallArguments(t *testing.T) {
tests := []struct {
name string
argsStr string
want map[string]interface{}
}{
{
name: "empty string returns empty map",
argsStr: "",
want: make(map[string]interface{}),
},
{
name: "valid JSON object",
argsStr: `{"location":"Tokyo","unit":"celsius"}`,
want: map[string]interface{}{
"location": "Tokyo",
"unit": "celsius",
},
},
{
name: "valid JSON with nested objects",
argsStr: `{"query":{"text":"hello","lang":"en"}}`,
want: map[string]interface{}{
"query": map[string]interface{}{
"text": "hello",
"lang": "en",
},
},
},
{
name: "valid JSON with arrays",
argsStr: `{"items":["a","b","c"],"count":3}`,
want: map[string]interface{}{
"items": []interface{}{"a", "b", "c"},
"count": float64(3),
},
},
{
name: "valid JSON with numbers",
argsStr: `{"temperature":72.5,"humidity":60}`,
want: map[string]interface{}{
"temperature": 72.5,
"humidity": float64(60),
},
},
{
name: "valid JSON with booleans",
argsStr: `{"enabled":true,"active":false}`,
want: map[string]interface{}{
"enabled": true,
"active": false,
},
},
{
name: "invalid JSON returns raw string in map",
argsStr: `{invalid json}`,
want: map[string]interface{}{
"raw": `{invalid json}`,
},
},
{
name: "malformed JSON returns raw string",
argsStr: `{"key":value}`,
want: map[string]interface{}{
"raw": `{"key":value}`,
},
},
{
name: "empty JSON object",
argsStr: `{}`,
want: make(map[string]interface{}),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ParseToolCallArguments(tt.argsStr)
if len(got) != len(tt.want) {
t.Errorf("ParseToolCallArguments() length = %d, want %d", len(got), len(tt.want))
return
}
for key, wantVal := range tt.want {
gotVal, ok := got[key]
if !ok {
t.Errorf("ParseToolCallArguments() missing key %q", key)
continue
}
if key == "raw" {
// For raw error cases, just check it exists
if _, ok := gotVal.(string); !ok {
t.Errorf("ParseToolCallArguments() raw value is not string")
}
} else {
// For other cases, do deep comparison
if gotVal != wantVal {
// Handle nested maps
if gotMap, ok := gotVal.(map[string]interface{}); ok {
if wantMap, ok := wantVal.(map[string]interface{}); ok {
if len(gotMap) != len(wantMap) {
t.Errorf("ParseToolCallArguments()[%q] nested map length = %d, want %d", key, len(gotMap), len(wantMap))
}
continue
}
}
t.Errorf("ParseToolCallArguments()[%q] = %v, want %v", key, gotVal, wantVal)
}
}
}
})
}
}
// --- ExtractToolCallsFromText tests ---
func TestExtractToolCallsFromText(t *testing.T) {
tests := []struct {
name string
text string
want []protocoltypes.ToolCall
wantLen int
checkIDs bool
}{
{
name: "no tool_calls returns nil",
text: "Just regular text without tool calls",
want: nil,
wantLen: 0,
},
{
name: "text with single tool call",
text: `Let me check.` + "\n" + `{"tool_calls":[{"id":"call_1","type":"function","function":{"name":"get_weather","arguments":"{\"location\":\"Tokyo\"}"}}]}`,
wantLen: 1,
checkIDs: true,
},
{
name: "text with multiple tool calls",
text: `{"tool_calls":[{"id":"call_1","type":"function","function":{"name":"read_file","arguments":"{\"path\":\"/tmp/test.txt\"}"}},{"id":"call_2","type":"function","function":{"name":"write_file","arguments":"{\"path\":\"/tmp/out.txt\",\"content\":\"hello\"}"}}]}`,
wantLen: 2,
checkIDs: true,
},
{
name: "tool_calls with empty arguments",
text: `{"tool_calls":[{"id":"call_1","type":"function","function":{"name":"no_args","arguments":"{}"}}]}`,
wantLen: 1,
checkIDs: true,
},
{
name: "tool_calls with nested JSON arguments",
text: `{"tool_calls":[{"id":"call_1","type":"function","function":{"name":"complex","arguments":"{\"query\":{\"text\":\"hello\",\"lang\":\"en\"},\"options\":{\"case\":\"lower\"}}"}}]}`,
wantLen: 1,
checkIDs: true,
},
{
name: "malformed JSON returns nil",
text: `{"tool_calls":[invalid json]}`,
want: nil,
wantLen: 0,
},
{
name: "unclosed brace returns nil",
text: `{"tool_calls":[{"id":"call_1"`,
want: nil,
wantLen: 0,
},
{
name: "empty tool_calls array",
text: `{"tool_calls":[]}`,
want: nil,
wantLen: 0,
},
{
name: "text before and after tool_calls",
text: `Before text.` + "\n" + `{"tool_calls":[{"id":"call_1","type":"function","function":{"name":"test","arguments":"{}"}}]}` + "\n" + `After text.`,
wantLen: 1,
checkIDs: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ExtractToolCallsFromText(tt.text)
if tt.want == nil && got != nil {
t.Errorf("ExtractToolCallsFromText() = %v, want nil", got)
return
}
if len(got) != tt.wantLen {
t.Errorf("ExtractToolCallsFromText() length = %d, want %d", len(got), tt.wantLen)
return
}
if tt.checkIDs && len(got) > 0 {
// Verify structure of first tool call
tc := got[0]
if tc.ID == "" {
t.Error("ExtractToolCallsFromText() tool call ID is empty")
}
if tc.Name == "" {
t.Error("ExtractToolCallsFromText() tool call Name is empty")
}
if tc.Function == nil {
t.Error("ExtractToolCallsFromText() tool call Function is nil")
} else {
if tc.Function.Name == "" {
t.Error("ExtractToolCallsFromText() Function.Name is empty")
}
if tc.Function.Arguments == "" {
t.Error("ExtractToolCallsFromText() Function.Arguments is empty")
}
}
if tc.Arguments == nil {
t.Error("ExtractToolCallsFromText() Arguments map is nil")
}
}
})
}
}
func TestExtractToolCallsFromText_Detailed(t *testing.T) {
text := `Let me check the weather.` + "\n" + `{"tool_calls":[{"id":"call_123","type":"function","function":{"name":"get_weather","arguments":"{\"location\":\"Tokyo\",\"unit\":\"celsius\"}"}}]}`
toolCalls := ExtractToolCallsFromText(text)
if len(toolCalls) != 1 {
t.Fatalf("ExtractToolCallsFromText() length = %d, want 1", len(toolCalls))
}
tc := toolCalls[0]
if tc.ID != "call_123" {
t.Errorf("ToolCall.ID = %q, want %q", tc.ID, "call_123")
}
if tc.Type != "function" {
t.Errorf("ToolCall.Type = %q, want %q", tc.Type, "function")
}
if tc.Name != "get_weather" {
t.Errorf("ToolCall.Name = %q, want %q", tc.Name, "get_weather")
}
if tc.Function == nil {
t.Fatal("ToolCall.Function is nil")
}
if tc.Function.Name != "get_weather" {
t.Errorf("Function.Name = %q, want %q", tc.Function.Name, "get_weather")
}
if tc.Function.Arguments != `{"location":"Tokyo","unit":"celsius"}` {
t.Errorf("Function.Arguments = %q, want %q", tc.Function.Arguments, `{"location":"Tokyo","unit":"celsius"}`)
}
if tc.Arguments["location"] != "Tokyo" {
t.Errorf("Arguments[location] = %v, want Tokyo", tc.Arguments["location"])
}
if tc.Arguments["unit"] != "celsius" {
t.Errorf("Arguments[unit] = %v, want celsius", tc.Arguments["unit"])
}
}
// --- ParseStructuredToolCalls tests ---
func TestParseStructuredToolCalls(t *testing.T) {
tests := []struct {
name string
apiToolCalls []struct {
ID string `json:"id"`
Type string `json:"type"`
Function *struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
}
wantLen int
}{
{
name: "empty array returns nil",
apiToolCalls: []struct {
ID string `json:"id"`
Type string `json:"type"`
Function *struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
}{},
wantLen: 0,
},
{
name: "single tool call",
apiToolCalls: []struct {
ID string `json:"id"`
Type string `json:"type"`
Function *struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
}{
{
ID: "call_1",
Type: "function",
Function: &struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
}{
Name: "get_weather",
Arguments: `{"location":"NYC"}`,
},
},
},
wantLen: 1,
},
{
name: "multiple tool calls",
apiToolCalls: []struct {
ID string `json:"id"`
Type string `json:"type"`
Function *struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
}{
{
ID: "call_1",
Type: "function",
Function: &struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
}{
Name: "read_file",
Arguments: `{"path":"/tmp/a.txt"}`,
},
},
{
ID: "call_2",
Type: "function",
Function: &struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
}{
Name: "write_file",
Arguments: `{"path":"/tmp/b.txt","content":"hello"}`,
},
},
},
wantLen: 2,
},
{
name: "nil function is skipped",
apiToolCalls: []struct {
ID string `json:"id"`
Type string `json:"type"`
Function *struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
}{
{
ID: "call_1",
Type: "function",
Function: nil,
},
{
ID: "call_2",
Type: "function",
Function: &struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
}{
Name: "valid_call",
Arguments: `{}`,
},
},
},
wantLen: 1,
},
{
name: "empty arguments string",
apiToolCalls: []struct {
ID string `json:"id"`
Type string `json:"type"`
Function *struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
}{
{
ID: "call_1",
Type: "function",
Function: &struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
}{
Name: "no_args",
Arguments: "",
},
},
},
wantLen: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ParseStructuredToolCalls(tt.apiToolCalls)
if tt.wantLen == 0 {
if len(got) > 0 {
t.Errorf("ParseStructuredToolCalls() = %v, want nil or empty", got)
}
return
}
if len(got) != tt.wantLen {
t.Errorf("ParseStructuredToolCalls() length = %d, want %d", len(got), tt.wantLen)
return
}
// Verify structure
for i, tc := range got {
if tc.ID == "" {
t.Errorf("ParseStructuredToolCalls()[%d].ID is empty", i)
}
if tc.Name == "" {
t.Errorf("ParseStructuredToolCalls()[%d].Name is empty", i)
}
if tc.Function == nil {
t.Errorf("ParseStructuredToolCalls()[%d].Function is nil", i)
}
if tc.Arguments == nil {
t.Errorf("ParseStructuredToolCalls()[%d].Arguments is nil", i)
}
}
})
}
}
func TestParseStructuredToolCalls_Detailed(t *testing.T) {
apiToolCalls := []struct {
ID string `json:"id"`
Type string `json:"type"`
Function *struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
}{
{
ID: "call_abc",
Type: "function",
Function: &struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
}{
Name: "get_weather",
Arguments: `{"city":"SF","unit":"fahrenheit"}`,
},
},
}
toolCalls := ParseStructuredToolCalls(apiToolCalls)
if len(toolCalls) != 1 {
t.Fatalf("ParseStructuredToolCalls() length = %d, want 1", len(toolCalls))
}
tc := toolCalls[0]
if tc.ID != "call_abc" {
t.Errorf("ToolCall.ID = %q, want %q", tc.ID, "call_abc")
}
if tc.Type != "function" {
t.Errorf("ToolCall.Type = %q, want %q", tc.Type, "function")
}
if tc.Name != "get_weather" {
t.Errorf("ToolCall.Name = %q, want %q", tc.Name, "get_weather")
}
if tc.Function == nil {
t.Fatal("ToolCall.Function is nil")
}
if tc.Function.Name != "get_weather" {
t.Errorf("Function.Name = %q, want %q", tc.Function.Name, "get_weather")
}
if tc.Function.Arguments != `{"city":"SF","unit":"fahrenheit"}` {
t.Errorf("Function.Arguments = %q, want %q", tc.Function.Arguments, `{"city":"SF","unit":"fahrenheit"}`)
}
if tc.Arguments["city"] != "SF" {
t.Errorf("Arguments[city] = %v, want SF", tc.Arguments["city"])
}
if tc.Arguments["unit"] != "fahrenheit" {
t.Errorf("Arguments[unit] = %v, want fahrenheit", tc.Arguments["unit"])
}
}