diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go index 71879f6e1..27094fe34 100644 --- a/pkg/providers/openai_compat/provider.go +++ b/pkg/providers/openai_compat/provider.go @@ -302,10 +302,17 @@ func parseResponse(body []byte) (*LLMResponse, error) { name = tc.Function.Name if tc.Function.Arguments != "" { if err := json.Unmarshal([]byte(tc.Function.Arguments), &arguments); err != nil { - // JSON is malformed (likely truncated due to max_tokens). Log and signal truncation. - log.Printf("openai_compat: failed to decode tool call arguments for %q: %v", name, err) - truncated = true - continue // Skip this malformed tool call entirely + // JSON is malformed (likely truncated due to max_tokens or LLM stopping early). + // Try to repair it by appending closing characters. + if repairedArgs, repairErr := repairJSON(tc.Function.Arguments); repairErr == nil { + arguments = repairedArgs + log.Printf("openai_compat: recovered tool call arguments for %q (auto-repaired)", name) + } else { + // JSON is too malformed to repair. Log and signal truncation. + log.Printf("openai_compat: failed to decode tool call arguments for %q: %v", name, err) + truncated = true + continue // Skip this malformed tool call entirely + } } } } @@ -458,3 +465,25 @@ func asFloat(v any) (float64, bool) { return 0, false } } + +// repairJSON attempts to fix commonly truncated JSON objects by appending closing characters. +func repairJSON(s string) (map[string]any, error) { + var result map[string]any + + // Fast path: try closing suffixes for flat JSON objects + suffixes := []string{ + "}", + "\"}", + "\"}}", + "\"}]}", + "]}", + } + + for _, suffix := range suffixes { + if err := json.Unmarshal([]byte(s+suffix), &result); err == nil { + return result, nil + } + } + + return nil, fmt.Errorf("failed to repair json") +} diff --git a/pkg/providers/openai_compat/provider_test.go b/pkg/providers/openai_compat/provider_test.go index 174bcf00d..36da6d7f6 100644 --- a/pkg/providers/openai_compat/provider_test.go +++ b/pkg/providers/openai_compat/provider_test.go @@ -513,3 +513,51 @@ func TestSerializeMessages_StripsSystemParts(t *testing.T) { t.Fatal("system_parts should not appear in serialized output") } } + +func TestProviderChat_RepairsTruncatedToolCall(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": "read_file", + "arguments": "{\"path\": \"/my/file.txt\"", // missing } + }, + }, + }, + }, + "finish_reason": "length", + }, + }, + } + 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: "hi"}}, nil, "gpt-4o", 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 != "read_file" { + t.Fatalf("ToolCalls[0].Name = %q, want %q", out.ToolCalls[0].Name, "read_file") + } + if out.ToolCalls[0].Arguments["path"] != "/my/file.txt" { + t.Fatalf("ToolCalls[0].Arguments[path] = %v, want /my/file.txt", out.ToolCalls[0].Arguments["path"]) + } + // Even though it was repaired, the finish reason should still be truncated because the LLM originally returned length or we truncated it? + // Actually, if finish_reason was "length", parseResponse will set finishReason to "truncated" anyway. + if out.FinishReason != "truncated" { + t.Fatalf("FinishReason = %q, want %q", out.FinishReason, "truncated") + } +}