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) {