fix(openai_compat): parse longcat markup tool calls
This commit is contained in:
parent
463a647a33
commit
b224647ab2
2 changed files with 213 additions and 3 deletions
|
|
@ -10,6 +10,7 @@ import (
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -42,6 +43,13 @@ type Option func(*Provider)
|
||||||
|
|
||||||
const defaultRequestTimeout = common.DefaultRequestTimeout
|
const defaultRequestTimeout = common.DefaultRequestTimeout
|
||||||
|
|
||||||
|
var (
|
||||||
|
longCatToolCallBlockPattern = regexp.MustCompile(`(?is)<longcat_tool_call>(.*?)</longcat_tool_call>`)
|
||||||
|
longCatArgPattern = regexp.MustCompile(
|
||||||
|
`(?is)<longcat_arg_key>\s*(.*?)\s*</longcat_arg_key>\s*<longcat_arg_value>\s*(.*?)\s*</longcat_arg_value>`,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
func WithMaxTokensField(maxTokensField string) Option {
|
func WithMaxTokensField(maxTokensField string) Option {
|
||||||
return func(p *Provider) {
|
return func(p *Provider) {
|
||||||
p.maxTokensField = maxTokensField
|
p.maxTokensField = maxTokensField
|
||||||
|
|
@ -194,7 +202,12 @@ func (p *Provider) Chat(
|
||||||
return nil, common.HandleErrorResponse(resp, p.apiBase)
|
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).
|
// 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 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.
|
// parseStreamResponse parses an OpenAI-compatible SSE stream.
|
||||||
|
|
@ -252,6 +265,7 @@ func parseStreamResponse(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
reader io.Reader,
|
reader io.Reader,
|
||||||
onChunk func(accumulated string),
|
onChunk func(accumulated string),
|
||||||
|
longCatFallback bool,
|
||||||
) (*LLMResponse, error) {
|
) (*LLMResponse, error) {
|
||||||
var textContent strings.Builder
|
var textContent strings.Builder
|
||||||
var finishReason string
|
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 == "" {
|
if finishReason == "" {
|
||||||
finishReason = "stop"
|
finishReason = "stop"
|
||||||
}
|
}
|
||||||
|
|
||||||
return &LLMResponse{
|
return &LLMResponse{
|
||||||
Content: textContent.String(),
|
Content: content,
|
||||||
ToolCalls: toolCalls,
|
ToolCalls: toolCalls,
|
||||||
FinishReason: finishReason,
|
FinishReason: finishReason,
|
||||||
Usage: usage,
|
Usage: usage,
|
||||||
}, nil
|
}, 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), "<longcat_arg_key>"); 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 {
|
func normalizeModel(model, apiBase string) string {
|
||||||
before, after, ok := strings.Cut(model, "/")
|
before, after, ok := strings.Cut(model, "/")
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|
@ -433,6 +527,15 @@ func isNativeSearchHost(apiBase string) bool {
|
||||||
return host == "api.openai.com" || strings.HasSuffix(host, ".openai.azure.com")
|
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
|
// supportsPromptCacheKey reports whether the given API base is known to
|
||||||
// support the prompt_cache_key request field. Currently only OpenAI's own
|
// support the prompt_cache_key request field. Currently only OpenAI's own
|
||||||
// API and Azure OpenAI support this. All other OpenAI-compatible providers
|
// API and Azure OpenAI support this. All other OpenAI-compatible providers
|
||||||
|
|
|
||||||
|
|
@ -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": `<longcat_tool_call>weather <longcat_arg_key>location</longcat_arg_key> <longcat_arg_value>New York, United States</longcat_arg_value></longcat_tool_call>`,
|
||||||
|
},
|
||||||
|
"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": `<longcat_tool_call>weather <longcat_arg_key>location</longcat_arg_key> <longcat_arg_value>Paris</longcat_arg_value></longcat_tool_call>`,
|
||||||
|
},
|
||||||
|
"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, "<longcat_tool_call>") {
|
||||||
|
t.Fatalf("Content should keep raw markup for non-longcat host, got %q", out.Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestProviderChat_ParsesReasoningContent(t *testing.T) {
|
func TestProviderChat_ParsesReasoningContent(t *testing.T) {
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
resp := map[string]any{
|
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":"<longcat_tool_call>weather <longcat_arg_key>location</longcat_arg_key> <longcat_arg_value>Tokyo</longcat_arg_value></longcat_tool_call>"}}]}`,
|
||||||
|
`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)
|
type roundTripperFunc func(*http.Request) (*http.Response, error)
|
||||||
|
|
||||||
func (f roundTripperFunc) RoundTrip(r *http.Request) (*http.Response, error) {
|
func (f roundTripperFunc) RoundTrip(r *http.Request) (*http.Response, error) {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue