From b224647ab260dab41d0eef1d8f131efe5afbed03 Mon Sep 17 00:00:00 2001 From: Alix-007 <267018309+Alix-007@users.noreply.github.com> Date: Thu, 26 Mar 2026 21:16:20 +0800 Subject: [PATCH] fix(openai_compat): parse longcat markup tool calls --- pkg/providers/openai_compat/provider.go | 109 ++++++++++++++++++- pkg/providers/openai_compat/provider_test.go | 107 ++++++++++++++++++ 2 files changed, 213 insertions(+), 3 deletions(-) diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go index 90bc683b8..bfc60a6b3 100644 --- a/pkg/providers/openai_compat/provider.go +++ b/pkg/providers/openai_compat/provider.go @@ -10,6 +10,7 @@ import ( "log" "net/http" "net/url" + "regexp" "strings" "time" @@ -42,6 +43,13 @@ type Option func(*Provider) const defaultRequestTimeout = common.DefaultRequestTimeout +var ( + longCatToolCallBlockPattern = regexp.MustCompile(`(?is)(.*?)`) + longCatArgPattern = regexp.MustCompile( + `(?is)\s*(.*?)\s*\s*\s*(.*?)\s*`, + ) +) + func WithMaxTokensField(maxTokensField string) Option { return func(p *Provider) { p.maxTokensField = maxTokensField @@ -194,7 +202,12 @@ func (p *Provider) Chat( return nil, common.HandleErrorResponse(resp, p.apiBase) } - return common.ReadAndParseResponse(resp, p.apiBase) + out, err := common.ReadAndParseResponse(resp, p.apiBase) + if err != nil { + return nil, err + } + applyLongCatToolCallFallback(out, p.apiBase) + return out, nil } // ChatStream implements streaming via OpenAI-compatible SSE (stream: true). @@ -244,7 +257,7 @@ func (p *Provider) ChatStream( return nil, common.HandleErrorResponse(resp, p.apiBase) } - return parseStreamResponse(ctx, resp.Body, onChunk) + return parseStreamResponse(ctx, resp.Body, onChunk, isLongCatAPIBase(p.apiBase)) } // parseStreamResponse parses an OpenAI-compatible SSE stream. @@ -252,6 +265,7 @@ func parseStreamResponse( ctx context.Context, reader io.Reader, onChunk func(accumulated string), + longCatFallback bool, ) (*LLMResponse, error) { var textContent strings.Builder var finishReason string @@ -374,18 +388,98 @@ func parseStreamResponse( }) } + content := textContent.String() + if longCatFallback && len(toolCalls) == 0 { + if parsedCalls, stripped := parseLongCatToolCalls(content); len(parsedCalls) > 0 { + toolCalls = parsedCalls + content = stripped + finishReason = "tool_calls" + } + } + if finishReason == "" { finishReason = "stop" } return &LLMResponse{ - Content: textContent.String(), + Content: content, ToolCalls: toolCalls, FinishReason: finishReason, Usage: usage, }, nil } +func applyLongCatToolCallFallback(resp *LLMResponse, apiBase string) { + if resp == nil || len(resp.ToolCalls) > 0 || !isLongCatAPIBase(apiBase) { + return + } + toolCalls, strippedContent := parseLongCatToolCalls(resp.Content) + if len(toolCalls) == 0 { + return + } + resp.ToolCalls = toolCalls + resp.Content = strippedContent + resp.FinishReason = "tool_calls" +} + +func parseLongCatToolCalls(content string) ([]ToolCall, string) { + matches := longCatToolCallBlockPattern.FindAllStringSubmatch(content, -1) + if len(matches) == 0 { + return nil, content + } + + toolCalls := make([]ToolCall, 0, len(matches)) + for _, match := range matches { + if len(match) < 2 { + continue + } + block := strings.TrimSpace(match[1]) + if block == "" { + continue + } + + toolNameRaw := block + if idx := strings.Index(strings.ToLower(block), ""); idx >= 0 { + toolNameRaw = block[:idx] + } + nameFields := strings.Fields(strings.TrimSpace(toolNameRaw)) + if len(nameFields) == 0 { + continue + } + name := nameFields[0] + + args := map[string]any{} + for _, argMatch := range longCatArgPattern.FindAllStringSubmatch(block, -1) { + if len(argMatch) < 3 { + continue + } + key := strings.TrimSpace(argMatch[1]) + value := strings.TrimSpace(argMatch[2]) + if key != "" { + args[key] = value + } + } + + argsJSON, _ := json.Marshal(args) + toolCalls = append(toolCalls, ToolCall{ + ID: fmt.Sprintf("longcat_call_%d", len(toolCalls)+1), + Type: "function", + Name: name, + Arguments: args, + Function: &FunctionCall{ + Name: name, + Arguments: string(argsJSON), + }, + }) + } + + if len(toolCalls) == 0 { + return nil, content + } + stripped := strings.TrimSpace(longCatToolCallBlockPattern.ReplaceAllString(content, "")) + return toolCalls, stripped +} + func normalizeModel(model, apiBase string) string { before, after, ok := strings.Cut(model, "/") if !ok { @@ -433,6 +527,15 @@ func isNativeSearchHost(apiBase string) bool { return host == "api.openai.com" || strings.HasSuffix(host, ".openai.azure.com") } +func isLongCatAPIBase(apiBase string) bool { + u, err := url.Parse(apiBase) + if err != nil { + return false + } + host := strings.ToLower(u.Hostname()) + return host == "longcat.chat" || strings.HasSuffix(host, ".longcat.chat") +} + // supportsPromptCacheKey reports whether the given API base is known to // support the prompt_cache_key request field. Currently only OpenAI's own // API and Azure OpenAI support this. All other OpenAI-compatible providers diff --git a/pkg/providers/openai_compat/provider_test.go b/pkg/providers/openai_compat/provider_test.go index ab632ccf3..6b90b093b 100644 --- a/pkg/providers/openai_compat/provider_test.go +++ b/pkg/providers/openai_compat/provider_test.go @@ -158,6 +158,83 @@ func TestProviderChat_ParsesToolCallsWithObjectArguments(t *testing.T) { } } +func TestProviderChat_ParsesLongCatMarkupToolCalls(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": `weather location New York, United States`, + }, + "finish_reason": "stop", + }, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + p := NewProvider("key", server.URL, "") + p.apiBase = "https://api.longcat.chat/openai" + p.httpClient = &http.Client{ + Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { + r.URL, _ = url.Parse(server.URL + r.URL.Path) + return http.DefaultTransport.RoundTrip(r) + }), + } + + out, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "longcat/LongCat-Flash-Thinking", 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 != "weather" { + t.Fatalf("ToolCalls[0].Name = %q, want weather", out.ToolCalls[0].Name) + } + if out.ToolCalls[0].Arguments["location"] != "New York, United States" { + t.Fatalf("location = %v, want New York, United States", out.ToolCalls[0].Arguments["location"]) + } + if out.Content != "" { + t.Fatalf("Content = %q, want empty after stripping longcat tool tag", out.Content) + } + if out.FinishReason != "tool_calls" { + t.Fatalf("FinishReason = %q, want tool_calls", out.FinishReason) + } +} + +func TestProviderChat_DoesNotParseLongCatMarkupForOtherHosts(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": `weather location Paris`, + }, + "finish_reason": "stop", + }, + }, + } + 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) != 0 { + t.Fatalf("len(ToolCalls) = %d, want 0 for non-longcat host", len(out.ToolCalls)) + } + if !strings.Contains(out.Content, "") { + t.Fatalf("Content should keep raw markup for non-longcat host, got %q", out.Content) + } +} + func TestProviderChat_ParsesReasoningContent(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { resp := map[string]any{ @@ -694,6 +771,36 @@ func TestProviderChat_ExtraBodyOverridesOptions(t *testing.T) { } } +func TestParseStreamResponse_LongCatMarkupFallback(t *testing.T) { + stream := strings.Join([]string{ + `data: {"choices":[{"delta":{"content":"Please check weather: "}}]}`, + `data: {"choices":[{"delta":{"content":"weather location Tokyo"}}]}`, + `data: {"choices":[{"finish_reason":"stop"}]}`, + `data: [DONE]`, + "", + }, "\n") + + out, err := parseStreamResponse(t.Context(), strings.NewReader(stream), nil, true) + if err != nil { + t.Fatalf("parseStreamResponse() error = %v", err) + } + if len(out.ToolCalls) != 1 { + t.Fatalf("len(ToolCalls) = %d, want 1", len(out.ToolCalls)) + } + if out.ToolCalls[0].Name != "weather" { + t.Fatalf("ToolCalls[0].Name = %q, want weather", out.ToolCalls[0].Name) + } + if out.ToolCalls[0].Arguments["location"] != "Tokyo" { + t.Fatalf("location = %v, want Tokyo", out.ToolCalls[0].Arguments["location"]) + } + if out.Content != "Please check weather:" { + t.Fatalf("Content = %q, want %q", out.Content, "Please check weather:") + } + if out.FinishReason != "tool_calls" { + t.Fatalf("FinishReason = %q, want tool_calls", out.FinishReason) + } +} + type roundTripperFunc func(*http.Request) (*http.Response, error) func (f roundTripperFunc) RoundTrip(r *http.Request) (*http.Response, error) {