diff --git a/internal/entity/models/longcat.go b/internal/entity/models/longcat.go index 339917a4cf..28cee88fc7 100644 --- a/internal/entity/models/longcat.go +++ b/internal/entity/models/longcat.go @@ -52,8 +52,7 @@ func (l *LongCatModel) Name() string { } // LongCatChatResponse mirrors the OpenAI-compatible response returned by -// LongCat's POST /openai/v1/chat/completions endpoint. Usage is always -// present on a successful response. +// LongCat's POST /openai/v1/chat/completions endpoint. type LongCatChatResponse struct { ID string `json:"id"` Object string `json:"object"` @@ -70,9 +69,12 @@ type LongCatChatResponse struct { } `json:"message"` } `json:"choices"` Usage struct { - PromptTokens int `json:"prompt_tokens"` - CompletionTokens int `json:"completion_tokens"` - TotalTokens int `json:"total_tokens"` + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` + CompletionTokensDetails struct { + ReasoningTokens int `json:"reasoning_tokens"` + } `json:"completion_tokens_details"` } `json:"usage"` } @@ -139,17 +141,13 @@ func (l *LongCatModel) ChatWithMessages(ctx context.Context, modelName string, m choice := &result.Choices[0] content := choice.Message.Content // LongCat-Flash-Thinking may emit all output as reasoning_content - // with content left null/empty. That is a valid response — only - // reject when there is neither content, reasoning, nor tool calls. + // with content left empty. That is a valid response — only reject when + // there is neither content, reasoning, nor tool calls. if content == "" && choice.Message.ReasoningContent == "" && len(choice.Message.ToolCalls) == 0 { return chatResponseParts{}, fmt.Errorf("invalid content format") } - // LongCat 2.0 returns the chain-of-thought in a - // `reasoning_content` field on the message (OpenAI o-series shape, - // also used by kimi-k2.6 and DeepSeek-R1). The field is typically - // prefixed with a leading newline — trim it so callers see clean - // reasoning. Absent or empty means no reasoning was emitted. + // reasoning_content is typically prefixed with a leading newline. reasonContent := choice.Message.ReasoningContent if reasonContent != "" && reasonContent[0] == '\n' { reasonContent = reasonContent[1:] @@ -241,6 +239,9 @@ func (l *LongCatModel) ChatStreamlyWithSender(ctx context.Context, modelName str } if found { applyStreamUsage(chatModelConfig, modelUsage, tokenUsage) + // Aggregate counts only — the full event carries content/reasoning_content. + common.Info(fmt.Sprintf("longcat: usage prompt=%d completion=%d total=%d", + tokenUsage.PromptTokens, tokenUsage.CompletionTokens, tokenUsage.TotalTokens)) } choices, ok := event["choices"].([]interface{}) diff --git a/internal/entity/models/longcat_test.go b/internal/entity/models/longcat_test.go index c37033241f..128dc17064 100644 --- a/internal/entity/models/longcat_test.go +++ b/internal/entity/models/longcat_test.go @@ -176,9 +176,9 @@ func TestLongCatChatExtractsReasoningContent(t *testing.T) { } } -// TestLongCatChatParsesUsage verifies that the typed LongCat response -// parser extracts the OpenAI-compatible usage block into ChatResponse.Usage. -// LongCat always returns usage on success, so the field must be populated. +// TestLongCatChatParsesUsage verifies that the typed response parser +// extracts the OpenAI-compatible usage block, including the nested +// completion_tokens_details.reasoning_tokens from LongCat's thinking mode. func TestLongCatChatParsesUsage(t *testing.T) { ctx := t.Context() srv := newLongCatServer(t, "/openai/v1/chat/completions", func(t *testing.T, _ *http.Request, body map[string]interface{}, w http.ResponseWriter) { @@ -199,6 +199,9 @@ func TestLongCatChatParsesUsage(t *testing.T) { "prompt_tokens": 20, "completion_tokens": 15, "total_tokens": 35, + "completion_tokens_details": map[string]interface{}{ + "reasoning_tokens": 78, + }, }, }) }) @@ -280,46 +283,6 @@ func TestLongCatChatAcceptsReasoningOnlyResponse(t *testing.T) { } } -// TestLongCatStreamRequestsIncludeUsage verifies that the streaming driver -// asks LongCat for aggregate token usage via stream_options.include_usage, -// and that the usage event populates chatConfig.UsageResult. -func TestLongCatStreamRequestsIncludeUsage(t *testing.T) { - ctx := t.Context() - srv := newLongCatServer(t, "/openai/v1/chat/completions", func(t *testing.T, _ *http.Request, body map[string]interface{}, w http.ResponseWriter) { - streamOpts, ok := body["stream_options"].(map[string]interface{}) - if !ok { - t.Errorf("stream_options missing: %v", body) - return - } - if inc, _ := streamOpts["include_usage"].(bool); !inc { - t.Errorf("stream_options.include_usage=%v, want true", streamOpts["include_usage"]) - } - w.Header().Set("Content-Type", "text/event-stream") - _, _ = io.WriteString(w, - `data: {"choices":[{"index":0,"delta":{"content":"hi"}}]}`+"\n"+ - `data: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"completion_tokens":2,"total_tokens":12}}`+"\n"+ - `data: [DONE]`+"\n") - }) - defer srv.Close() - - m := newLongCatForTest(srv.URL) - apiKey := "test-key" - chatConfig := &ChatConfig{} - err := m.ChatStreamlyWithSender(ctx, "LongCat-2.0", - []Message{{Role: "user", Content: "x"}}, - &APIConfig{ApiKey: &apiKey}, chatConfig, nil, - func(*string, *string) error { return nil }) - if err != nil { - t.Fatalf("stream: %v", err) - } - if chatConfig.UsageResult == nil { - t.Fatal("UsageResult must be non-nil after stream with usage event") - } - if chatConfig.UsageResult.PromptTokens != 10 || chatConfig.UsageResult.CompletionTokens != 2 || chatConfig.UsageResult.TotalTokens != 12 { - t.Errorf("UsageResult=%#v, want prompt=10 completion=2 total=12", chatConfig.UsageResult) - } -} - // TestLongCatChatDropsUndocumentedFields guards against re-introducing // stop / reasoning_effort / response_format / tools etc. The LongCat // docs only list model, messages, stream, max_tokens, temperature, @@ -366,6 +329,86 @@ func TestLongCatChatDropsUndocumentedFields(t *testing.T) { } } +// TestLongCatStreamRequestsIncludeUsage verifies that the streaming driver +// requests aggregate usage via stream_options.include_usage and populates +// chatConfig.UsageResult from the final usage event. +func TestLongCatStreamRequestsIncludeUsage(t *testing.T) { + ctx := t.Context() + srv := newLongCatServer(t, "/openai/v1/chat/completions", func(t *testing.T, _ *http.Request, body map[string]interface{}, w http.ResponseWriter) { + streamOpts, ok := body["stream_options"].(map[string]interface{}) + if !ok { + t.Errorf("stream_options missing: %v", body) + return + } + if inc, _ := streamOpts["include_usage"].(bool); !inc { + t.Errorf("stream_options.include_usage=%v, want true", streamOpts["include_usage"]) + } + w.Header().Set("Content-Type", "text/event-stream") + _, _ = io.WriteString(w, + `data: {"choices":[{"index":0,"delta":{"content":"hi"}}]}`+"\n"+ + `data: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"completion_tokens":2,"total_tokens":12}}`+"\n"+ + `data: [DONE]`+"\n") + }) + defer srv.Close() + + m := newLongCatForTest(srv.URL) + apiKey := "test-key" + chatConfig := &ChatConfig{} + err := m.ChatStreamlyWithSender(ctx, "LongCat-2.0", + []Message{{Role: "user", Content: "x"}}, + &APIConfig{ApiKey: &apiKey}, chatConfig, nil, + func(*string, *string) error { return nil }) + if err != nil { + t.Fatalf("stream: %v", err) + } + if chatConfig.UsageResult == nil { + t.Fatal("UsageResult must be non-nil after stream with usage event") + } + if chatConfig.UsageResult.PromptTokens != 10 || chatConfig.UsageResult.CompletionTokens != 2 || chatConfig.UsageResult.TotalTokens != 12 { + t.Errorf("UsageResult=%#v, want prompt=10 completion=2 total=12", chatConfig.UsageResult) + } +} + +// TestLongCatStreamParsesReasoningTokens verifies that a streaming usage +// event carrying completion_tokens_details.reasoning_tokens populates +// chatConfig.UsageResult. +func TestLongCatStreamParsesReasoningTokens(t *testing.T) { + ctx := t.Context() + srv := newLongCatServer(t, "/openai/v1/chat/completions", func(t *testing.T, _ *http.Request, body map[string]interface{}, w http.ResponseWriter) { + streamOpts, ok := body["stream_options"].(map[string]interface{}) + if !ok { + t.Errorf("stream_options missing: %v", body) + return + } + if inc, _ := streamOpts["include_usage"].(bool); !inc { + t.Errorf("stream_options.include_usage=%v, want true", streamOpts["include_usage"]) + } + w.Header().Set("Content-Type", "text/event-stream") + _, _ = io.WriteString(w, + `data: {"choices":[{"index":0,"delta":{"content":"hi"}}]}`+"\n"+ + `data: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"completion_tokens":8,"total_tokens":18,"completion_tokens_details":{"reasoning_tokens":5}}}`+"\n"+ + `data: [DONE]`+"\n") + }) + defer srv.Close() + + m := newLongCatForTest(srv.URL) + apiKey := "test-key" + chatConfig := &ChatConfig{} + err := m.ChatStreamlyWithSender(ctx, "LongCat-2.0", + []Message{{Role: "user", Content: "x"}}, + &APIConfig{ApiKey: &apiKey}, chatConfig, nil, + func(*string, *string) error { return nil }) + if err != nil { + t.Fatalf("stream: %v", err) + } + if chatConfig.UsageResult == nil { + t.Fatal("UsageResult must be non-nil after stream with usage event") + } + if chatConfig.UsageResult.PromptTokens != 10 || chatConfig.UsageResult.CompletionTokens != 8 || chatConfig.UsageResult.TotalTokens != 18 { + t.Errorf("UsageResult=%#v, want prompt=10 completion=8 total=18", chatConfig.UsageResult) + } +} + func TestLongCatChatRequiresAPIKey(t *testing.T) { ctx := t.Context() m := newLongCatForTest("http://unused")