Fix: Refactor LLM response parser to separate tool calls and strip them from text content in openai_compat providers
This commit is contained in:
parent
12f0c4a6cf
commit
78319093c9
7 changed files with 787 additions and 267 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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{
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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:])
|
|
||||||
}
|
|
||||||
145
pkg/providers/toolcall/tool_call_extract.go
Normal file
145
pkg/providers/toolcall/tool_call_extract.go
Normal 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"`)
|
||||||
|
}
|
||||||
583
pkg/providers/toolcall/tool_call_extract_test.go
Normal file
583
pkg/providers/toolcall/tool_call_extract_test.go
Normal 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"])
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue