feat(go-models): list LongCat models (#15241)

## Summary
- Add LongCat model-list support through the documented
OpenAI-compatible models endpoint.

## What changed
- Add the LongCat `models` URL suffix for `/openai/v1/models`.
- Implement `ListModels` for the LongCat Go driver.
- Delegate `CheckConnection` to the lightweight model-list request.
- Add focused regression coverage for successful, malformed, oversized,
and missing-key responses.

## Why
LongCat documents a models endpoint under the OpenAI-compatible API
surface, but the Go driver still returned `no such method` for model
listing and connection checks.

## Validation
- `go test ./internal/entity/models -run TestLongCat -count=1`
- `go test -race ./internal/entity/models -run TestLongCat -count=1`
- `go test ./internal/entity -count=1`
- `git diff --check`

## Notes
- Related to the broader Go model provider tracking in #14736, but this
PR only handles LongCat model listing.
- `go test ./internal/entity/models -count=1` is currently blocked by an
unrelated Astraflow test panic outside this LongCat change.

---------

Co-authored-by: Jin Hai <haijin.chn@gmail.com>
This commit is contained in:
oktofeesh
2026-05-26 04:58:53 -07:00
committed by GitHub
parent 557024e7d4
commit 22a3b8cdf9
3 changed files with 174 additions and 42 deletions

View File

@@ -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": [

View File

@@ -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

View File

@@ -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)
}
})
}
}