This commit is contained in:
Administrator 2026-03-09 09:55:22 +08:00
parent 55fdc09d34
commit 6693e1f884
2 changed files with 81 additions and 4 deletions

View file

@ -302,10 +302,17 @@ func parseResponse(body []byte) (*LLMResponse, error) {
name = tc.Function.Name name = tc.Function.Name
if tc.Function.Arguments != "" { if tc.Function.Arguments != "" {
if err := json.Unmarshal([]byte(tc.Function.Arguments), &arguments); err != nil { if err := json.Unmarshal([]byte(tc.Function.Arguments), &arguments); err != nil {
// JSON is malformed (likely truncated due to max_tokens). Log and signal truncation. // JSON is malformed (likely truncated due to max_tokens or LLM stopping early).
log.Printf("openai_compat: failed to decode tool call arguments for %q: %v", name, err) // Try to repair it by appending closing characters.
truncated = true if repairedArgs, repairErr := repairJSON(tc.Function.Arguments); repairErr == nil {
continue // Skip this malformed tool call entirely 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 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")
}

View file

@ -513,3 +513,51 @@ func TestSerializeMessages_StripsSystemParts(t *testing.T) {
t.Fatal("system_parts should not appear in serialized output") 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")
}
}