mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-05 10:58:34 +08:00
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:
@@ -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": [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user