diff --git a/conf/models/new-api.json b/conf/models/new-api.json new file mode 100644 index 0000000000..8d70409166 --- /dev/null +++ b/conf/models/new-api.json @@ -0,0 +1,13 @@ +{ + "name": "New API", + "url_suffix": { + "chat": "chat/completions", + "models": "models", + "embedding": "embeddings", + "rerank": "rerank", + "tts": "audio/speech", + "asr": "audio/transcriptions" + }, + "url_hint": "http://host.docker.internal:3000/v1", + "class": "local" +} diff --git a/internal/entity/models/factory.go b/internal/entity/models/factory.go index 6cc75a1603..c79574f247 100644 --- a/internal/entity/models/factory.go +++ b/internal/entity/models/factory.go @@ -194,6 +194,8 @@ func (f *ModelFactory) CreateModelDriver(providerName string, baseURL map[string return NewOpperModel(baseURL, urlSuffix), nil case "empiriolabs ai": return NewEmpirioLabsModel(baseURL, urlSuffix), nil + case "new api": + return NewNewAPIModel(baseURL, urlSuffix), nil default: return NewDummyModel(baseURL, urlSuffix), nil } diff --git a/internal/entity/models/new_api.go b/internal/entity/models/new_api.go new file mode 100644 index 0000000000..bb5488b18a --- /dev/null +++ b/internal/entity/models/new_api.go @@ -0,0 +1,43 @@ +// +// Copyright 2026 The InfiniFlow Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +package models + +// NewAPIModel drives New API (https://github.com/QuantumNous/new-api) and +// compatible aggregation gateways such as one-api. These gateways expose every +// upstream model behind a single OpenAI-compatible endpoint, so the embedded +// OpenAIAPICompatibleModel covers chat, embedding, rerank, TTS and ASR calls as +// well as dynamic model discovery via /models. +type NewAPIModel struct { + *OpenAIAPICompatibleModel +} + +// NewNewAPIModel creates a New API driver. +func NewNewAPIModel(baseURL map[string]string, urlSuffix URLSuffix) *NewAPIModel { + return &NewAPIModel{ + OpenAIAPICompatibleModel: NewOpenAIAPICompatibleModel(baseURL, urlSuffix), + } +} + +// Name returns the driver name. +func (m *NewAPIModel) Name() string { + return "New API" +} + +// NewInstance returns a driver bound to the given base URL. +func (m *NewAPIModel) NewInstance(baseURL map[string]string) ModelDriver { + return NewNewAPIModel(baseURL, m.baseModel.URLSuffix) +} diff --git a/internal/entity/models/new_api_test.go b/internal/entity/models/new_api_test.go new file mode 100644 index 0000000000..4fe3749586 --- /dev/null +++ b/internal/entity/models/new_api_test.go @@ -0,0 +1,143 @@ +package models + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "ragflow/internal/common" + "testing" +) + +var newAPITestAPIKey = "sk-newapi-test" + +func newNewAPIForTest(baseURL string) *NewAPIModel { + return NewNewAPIModel( + map[string]string{"default": baseURL}, + URLSuffix{Chat: "chat/completions", Models: "models", Embedding: "embeddings", Rerank: "rerank"}, + ) +} + +func TestNewAPIName(t *testing.T) { + if got := newNewAPIForTest("http://unused").Name(); got != "New API" { + t.Errorf("Name()=%q", got) + } +} + +func TestNewAPIFactory(t *testing.T) { + driver, err := NewModelFactory().CreateModelDriver("new api", map[string]string{"default": "http://unused"}, URLSuffix{}) + if err != nil { + t.Fatalf("CreateModelDriver: %v", err) + } + if _, ok := driver.(*NewAPIModel); !ok { + t.Fatalf("driver type=%T, want *NewAPIModel", driver) + } + if _, ok := driver.NewInstance(map[string]string{"default": "http://other"}).(*NewAPIModel); !ok { + t.Fatal("NewInstance did not return *NewAPIModel") + } +} + +// New API is an aggregation gateway, so it must receive the tenant's key as a +// bearer token on every call. +func TestNewAPIChatSendsAPIKey(t *testing.T) { + withSSRFBypass(t) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/chat/completions" { + t.Errorf("path=%s", r.URL.Path) + } + if got := r.Header.Get("Authorization"); got != "Bearer sk-newapi-test" { + t.Errorf("Authorization=%q", got) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "chat-newapi", + "choices": []map[string]any{{"message": map[string]any{"content": "pong"}}}, + "usage": map[string]any{"prompt_tokens": 3, "completion_tokens": 5, "total_tokens": 8}, + }) + })) + defer srv.Close() + + usage := &common.ModelUsage{} + resp, err := newNewAPIForTest(srv.URL).ChatWithMessages( + t.Context(), + "gpt-4o", + []Message{{Role: "user", Content: "ping"}}, + &APIConfig{ApiKey: &newAPITestAPIKey}, + nil, + usage, + ) + if err != nil { + t.Fatalf("ChatWithMessages: %v", err) + } + if *resp.Answer != "pong" { + t.Errorf("Answer=%q", *resp.Answer) + } + assertModelUsage(t, usage, 3, 5, 8) +} + +// New API exposes whatever models its upstreams relay, so RAGFlow discovers them +// dynamically through /models rather than from a shipped catalogue. +func TestNewAPIListModelsAndCheckConnection(t *testing.T) { + withSSRFBypass(t) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/models" { + t.Errorf("%s %s", r.Method, r.URL.Path) + } + if got := r.Header.Get("Authorization"); got != "Bearer sk-newapi-test" { + t.Errorf("Authorization=%q", got) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": []map[string]any{ + {"id": "gpt-4o"}, + {"id": "text-embedding-3-large"}, + }, + }) + })) + defer srv.Close() + + model := newNewAPIForTest(srv.URL) + list, err := model.ListModels(t.Context(), &APIConfig{ApiKey: &newAPITestAPIKey}) + if err != nil { + t.Fatalf("ListModels: %v", err) + } + if joinModelNames(list, ",") != "gpt-4o,text-embedding-3-large" { + t.Errorf("models=%v", list) + } + if err := model.CheckConnection(t.Context(), &APIConfig{ApiKey: &newAPITestAPIKey}); err != nil { + t.Fatalf("CheckConnection: %v", err) + } +} + +// The shipped config must resolve to the New API driver and expose the OpenAI +// endpoint suffixes for every model type the gateway relays. Unlike a hosted +// provider with a fixed catalogue, New API ships no static models and advertises +// a url_hint so the operator points it at their own gateway. +func TestNewAPIProviderConfig(t *testing.T) { + dir, restore := setupProviderTestDir(t, "new-api.json") + defer restore() + + if err := InitProviderManager(dir); err != nil { + t.Fatalf("InitProviderManager: %v", err) + } + + provider := GetProviderManager().FindProvider("New API") + if provider == nil { + t.Fatal("New API provider not found") + } + if _, ok := provider.ModelDriver.(*NewAPIModel); !ok { + t.Fatalf("ModelDriver=%T, want *models.NewAPIModel", provider.ModelDriver) + } + if provider.URLSuffix.Chat != "chat/completions" || + provider.URLSuffix.Models != "models" || + provider.URLSuffix.Embedding != "embeddings" || + provider.URLSuffix.Rerank != "rerank" { + t.Errorf("URLSuffix=%+v", provider.URLSuffix) + } + if provider.URLSuffix.TTS != "audio/speech" || provider.URLSuffix.ASR != "audio/transcriptions" { + t.Errorf("audio URLSuffix=%+v", provider.URLSuffix) + } + if provider.URLHint == "" { + t.Error("New API should advertise a url_hint for the operator's gateway") + } + if len(provider.Models) != 0 { + t.Errorf("New API ships %d static models, want 0 (dynamic discovery)", len(provider.Models)) + } +}