diff --git a/conf/models/longcat.json b/conf/models/longcat.json index ec3cf06302..9588a9a544 100644 --- a/conf/models/longcat.json +++ b/conf/models/longcat.json @@ -4,7 +4,8 @@ "default": "https://api.longcat.chat" }, "url_suffix": { - "chat": "openai/v1/chat/completions" + "chat": "openai/v1/chat/completions", + "models": "openai/v1/models" }, "class": "longcat", "models": [ diff --git a/internal/entity/models/longcat.go b/internal/entity/models/longcat.go index e35dea1966..1f9c17571d 100644 --- a/internal/entity/models/longcat.go +++ b/internal/entity/models/longcat.go @@ -31,12 +31,12 @@ import ( // LongCatModel implements ModelDriver for LongCat (Meituan). // // LongCat exposes an OpenAI-compatible chat completions endpoint at -// https://api.longcat.chat/openai/v1/chat/completions. The official -// docs (https://longcat.chat/platform/docs/APIDocs.html) only describe -// the chat-completions surface — no /models, /embeddings, /rerank, -// /audio, or /ocr endpoints are advertised. The wire shape matches the -// OpenAI convention: response/delta carry reasoning_content alongside -// content for thinking models. +// https://api.longcat.chat/openai/v1/chat/completions and lists models at +// https://api.longcat.chat/openai/v1/models. The official docs +// (https://longcat.chat/platform/docs/APIDocs.html) do not advertise separate +// /embeddings, /rerank, /audio, or /ocr endpoints. The wire shape matches the +// OpenAI convention: response/delta carry reasoning_content alongside content +// for thinking models. // // Documented request fields are limited to: model, messages, stream, // max_tokens, temperature, top_p. Sending other OpenAI-style fields @@ -404,23 +404,83 @@ func (l *LongCatModel) ChatStreamlyWithSender(modelName string, messages []Messa return nil } -// ListModels is not exposed by the LongCat platform. The official -// docs at https://longcat.chat/platform/docs/APIDocs.html only -// document /openai/v1/chat/completions and /anthropic/v1/messages; -// no /models endpoint exists. The shipped catalog lives in -// conf/models/longcat.json; this driver method does not invent a -// fake one. -func (l *LongCatModel) ListModels(apiConfig *APIConfig) ([]string, error) { - return nil, fmt.Errorf("%s, no such method", l.Name()) +type longCatModelInfo struct { + ID string `json:"id"` +} + +type longCatListModelsResponse struct { + Data []longCatModelInfo `json:"data"` + Error interface{} `json:"error"` +} + +const longCatMaxListModelsResponseBytes = 1 << 20 + +func (l *LongCatModel) ListModels(apiConfig *APIConfig) ([]string, error) { + if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { + return nil, fmt.Errorf("api key is required") + } + + region := "default" + if apiConfig.Region != nil && *apiConfig.Region != "" { + region = *apiConfig.Region + } + + baseURL, err := l.baseURLForRegion(region) + if err != nil { + return nil, err + } + url := fmt.Sprintf("%s/%s", baseURL, l.URLSuffix.Models) + + ctx, cancel := context.WithTimeout(context.Background(), nonStreamCallTimeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) + + resp, err := l.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, longCatMaxListModelsResponseBytes+1)) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + if len(body) > longCatMaxListModelsResponseBytes { + return nil, fmt.Errorf("longcat: models response exceeds %d bytes", longCatMaxListModelsResponseBytes) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body)) + } + + var result longCatListModelsResponse + if err = json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + if result.Error != nil { + return nil, fmt.Errorf("longcat: upstream error: %v", result.Error) + } + if result.Data == nil { + return nil, fmt.Errorf("invalid models list format") + } + + models := make([]string, 0, len(result.Data)) + for _, model := range result.Data { + if model.ID != "" { + models = append(models, model.ID) + } + } + return models, nil } -// CheckConnection is not exposed by the LongCat platform. With no -// documented /models or /health endpoint, there is no cheap way to -// verify the API key without burning a real chat completion against -// a tenant's quota. Return the documented sentinel rather than -// pretend. func (l *LongCatModel) CheckConnection(apiConfig *APIConfig) error { - return fmt.Errorf("%s, no such method", l.Name()) + _, err := l.ListModels(apiConfig) + return err } // Embed is not exposed by the LongCat API. The /v1/embeddings endpoint diff --git a/internal/entity/models/longcat_test.go b/internal/entity/models/longcat_test.go index 14870f8f69..d5d02717b8 100644 --- a/internal/entity/models/longcat_test.go +++ b/internal/entity/models/longcat_test.go @@ -9,7 +9,7 @@ import ( "testing" ) -func newLongCatServer(t *testing.T, expectedPath string, handler func(t *testing.T, body map[string]interface{}, w http.ResponseWriter)) *httptest.Server { +func newLongCatServer(t *testing.T, expectedPath string, handler func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter)) *httptest.Server { t.Helper() return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != expectedPath { @@ -37,17 +37,17 @@ func newLongCatServer(t *testing.T, expectedPath string, handler func(t *testing t.Errorf("unmarshal: %v\nraw=%s", err, string(raw)) return } - handler(t, body, w) + handler(t, r, body, w) return } - handler(t, nil, w) + handler(t, r, nil, w) })) } func newLongCatForTest(baseURL string) *LongCatModel { return NewLongCatModel( map[string]string{"default": baseURL}, - URLSuffix{Chat: "openai/v1/chat/completions"}, + URLSuffix{Chat: "openai/v1/chat/completions", Models: "openai/v1/models"}, ) } @@ -86,7 +86,7 @@ func TestLongCatName(t *testing.T) { } func TestLongCatChatHappyPath(t *testing.T) { - srv := newLongCatServer(t, "/openai/v1/chat/completions", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) { + srv := newLongCatServer(t, "/openai/v1/chat/completions", func(t *testing.T, _ *http.Request, body map[string]interface{}, w http.ResponseWriter) { if body["model"] != "LongCat-Flash-Chat" { t.Errorf("model=%v", body["model"]) } @@ -125,7 +125,7 @@ func TestLongCatChatExtractsReasoningContent(t *testing.T) { // message.reasoning_content (OpenAI o-series shape). Live-probed // against api.longcat.chat; the fixture mimics the actual response // shape captured there. - srv := newLongCatServer(t, "/openai/v1/chat/completions", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) { + srv := newLongCatServer(t, "/openai/v1/chat/completions", func(t *testing.T, _ *http.Request, body map[string]interface{}, w http.ResponseWriter) { if body["model"] != "LongCat-Flash-Thinking" { t.Errorf("model=%v", body["model"]) } @@ -166,7 +166,7 @@ func TestLongCatChatExtractsReasoningContent(t *testing.T) { // top_p — anything else is undocumented and must not be sent, since // the maintainer specifically flagged this on PR #14809. func TestLongCatChatDropsUndocumentedFields(t *testing.T) { - srv := newLongCatServer(t, "/openai/v1/chat/completions", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) { + srv := newLongCatServer(t, "/openai/v1/chat/completions", func(t *testing.T, _ *http.Request, body map[string]interface{}, w http.ResponseWriter) { for _, k := range []string{"stop", "reasoning_effort", "response_format", "tools", "tool_choice", "presence_penalty", "frequency_penalty", "n", "logprobs"} { if _, present := body[k]; present { t.Errorf("undocumented field %q must not be sent: %v", k, body[k]) @@ -223,7 +223,7 @@ func TestLongCatChatRequiresMessages(t *testing.T) { } func TestLongCatChatRejectsHTTPError(t *testing.T) { - srv := newLongCatServer(t, "/openai/v1/chat/completions", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) { + srv := newLongCatServer(t, "/openai/v1/chat/completions", func(t *testing.T, _ *http.Request, body map[string]interface{}, w http.ResponseWriter) { w.WriteHeader(http.StatusUnauthorized) _, _ = w.Write([]byte(`{"error":"unauthorized"}`)) }) @@ -406,23 +406,94 @@ func TestLongCatStreamSurfacesUpstreamError(t *testing.T) { } } -// LongCat does not document /models or /health endpoints, so per -// maintainer guidance ListModels and CheckConnection both return the -// "no such method" sentinel rather than inventing fake catalogs or -// burning chat completions for connection checks. -func TestLongCatListModelsReturnsNoSuchMethod(t *testing.T) { +func TestLongCatListModelsAndCheckConnection(t *testing.T) { + var requests int + srv := newLongCatServer(t, "/openai/v1/models", func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) { + requests++ + if body != nil { + t.Errorf("GET /models should not send a JSON body: %v", body) + } + if r.ContentLength > 0 { + t.Errorf("GET /models should not send request body with ContentLength=%d", r.ContentLength) + } + raw, err := io.ReadAll(r.Body) + if err != nil { + t.Errorf("read GET body: %v", err) + return + } + if len(raw) > 0 { + t.Errorf("GET /models should not send request body: %q", string(raw)) + } + if requests == 1 { + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "object": "list", + "data": []map[string]interface{}{ + {"id": "LongCat-Flash-Chat", "object": "model"}, + {"id": "LongCat-Flash-Thinking-2601", "object": "model"}, + {"id": "", "object": "model"}, + }, + }) + return + } + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "object": "list", + "data": []map[string]interface{}{ + {"id": "LongCat-Flash-Chat", "object": "model"}, + }, + }) + }) + defer srv.Close() + apiKey := "test-key" - _, err := newLongCatForTest("http://unused").ListModels(&APIConfig{ApiKey: &apiKey}) - if err == nil || !strings.Contains(err.Error(), "no such method") { - t.Errorf("ListModels: want 'no such method', got %v", err) + models, err := newLongCatForTest(srv.URL).ListModels(&APIConfig{ApiKey: &apiKey}) + if err != nil { + t.Fatalf("ListModels: %v", err) + } + if got := strings.Join(models, ","); got != "LongCat-Flash-Chat,LongCat-Flash-Thinking-2601" { + t.Errorf("models=%q", got) + } + if err := newLongCatForTest(srv.URL).CheckConnection(&APIConfig{ApiKey: &apiKey}); err != nil { + t.Fatalf("CheckConnection: %v", err) } } -func TestLongCatCheckConnectionReturnsNoSuchMethod(t *testing.T) { - apiKey := "test-key" - err := newLongCatForTest("http://unused").CheckConnection(&APIConfig{ApiKey: &apiKey}) - if err == nil || !strings.Contains(err.Error(), "no such method") { - t.Errorf("CheckConnection: want 'no such method', got %v", err) +func TestLongCatListModelsRejectsInvalidResponses(t *testing.T) { + for name, response := range map[string]string{ + "missing data": `{}`, + "null data": `{"data":null}`, + "too large": strings.Repeat(" ", longCatMaxListModelsResponseBytes+1), + } { + t.Run(name, func(t *testing.T) { + srv := newLongCatServer(t, "/openai/v1/models", func(t *testing.T, _ *http.Request, body map[string]interface{}, w http.ResponseWriter) { + _, _ = io.WriteString(w, response) + }) + defer srv.Close() + + apiKey := "test-key" + _, err := newLongCatForTest(srv.URL).ListModels(&APIConfig{ApiKey: &apiKey}) + if err == nil { + t.Fatalf("expected error") + } + }) + } +} + +func TestLongCatListModelsRequiresAPIKey(t *testing.T) { + for name, cfg := range map[string]*APIConfig{ + "nil config": nil, + "nil key": {}, + "empty key": {ApiKey: new(string)}, + } { + t.Run(name, func(t *testing.T) { + _, err := newLongCatForTest("http://unused").ListModels(cfg) + if err == nil || !strings.Contains(err.Error(), "api key is required") { + t.Errorf("expected api-key error, got %v", err) + } + err = newLongCatForTest("http://unused").CheckConnection(cfg) + if err == nil || !strings.Contains(err.Error(), "api key is required") { + t.Errorf("CheckConnection expected api-key error, got %v", err) + } + }) } }