diff --git a/conf/llm_factories.json b/conf/llm_factories.json index 762e9b24e0..a823997a3f 100644 --- a/conf/llm_factories.json +++ b/conf/llm_factories.json @@ -3508,6 +3508,14 @@ "llm": [], "url": "https://api.opper.ai/v3/compat" }, + { + "name": "EmpirioLabs AI", + "logo": "", + "tags": "LLM,TEXT EMBEDDING,TEXT RE-RANK,IMAGE2TEXT", + "status": "1", + "llm": [], + "url": "https://api.empiriolabs.ai/v1" + }, { "name": "StepFun", "logo": "", diff --git a/conf/model.md b/conf/model.md index 09ee84fb9b..b896501b08 100644 --- a/conf/model.md +++ b/conf/model.md @@ -179,6 +179,7 @@ The following providers are **aggregators** — they host models from multiple u | **CometAPI** | Multi-provider aggregator | | **DeepInfra** | Open-source model hosting | | **Demon Route** | OpenAI-compatible router | +| **EmpirioLabs AI** | AI inference provider | | **FuturMix** | Multi-provider aggregator | | **GiteeAI** | Chinese aggregator | | **GreenPT** | GLM-based models | diff --git a/conf/models/empiriolabs.json b/conf/models/empiriolabs.json new file mode 100644 index 0000000000..3cda0f3cb3 --- /dev/null +++ b/conf/models/empiriolabs.json @@ -0,0 +1,111 @@ +{ + "name": "EmpirioLabs AI", + "url": { + "default": "https://api.empiriolabs.ai/v1" + }, + "url_suffix": { + "chat": "chat/completions", + "models": "models", + "embedding": "embeddings", + "rerank": "reranks" + }, + "class": "empiriolabs", + "models": [ + { + "name": "qwen3-8-flash", + "context_length": 1000000, + "max_output": 131072, + "model_types": [ + "chat", + "vision" + ], + "tools": { + "support": true + } + }, + { + "name": "qwen3-7-max", + "context_length": 1000000, + "max_output": 65536, + "model_types": [ + "chat" + ], + "tools": { + "support": true + } + }, + { + "name": "deepseek-v4-pro", + "context_length": 1000000, + "max_output": 393216, + "model_types": [ + "chat" + ], + "tools": { + "support": true + } + }, + { + "name": "deepseek-v4-1-flash", + "context_length": 1000000, + "max_output": 393216, + "model_types": [ + "chat", + "vision" + ], + "tools": { + "support": true + } + }, + { + "name": "glm-5-3", + "context_length": 1000000, + "max_output": 131072, + "model_types": [ + "chat" + ], + "tools": { + "support": true + } + }, + { + "name": "kimi-k2-7-code", + "context_length": 256000, + "max_output": 131072, + "model_types": [ + "chat", + "vision" + ], + "tools": { + "support": true + } + }, + { + "name": "minimax-m3", + "context_length": 1000000, + "max_output": 524288, + "model_types": [ + "chat", + "vision" + ], + "tools": { + "support": true + } + }, + { + "name": "text-embedding-v4", + "max_tokens": 8192, + "model_types": [ + "embedding" + ], + "max_batch_size": 10 + }, + { + "name": "qwen3-rerank", + "max_tokens": 4000, + "model_types": [ + "rerank" + ] + } + ] +} diff --git a/internal/entity/models/empiriolabs.go b/internal/entity/models/empiriolabs.go new file mode 100644 index 0000000000..3bb1cd248c --- /dev/null +++ b/internal/entity/models/empiriolabs.go @@ -0,0 +1,42 @@ +// +// 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 + +// EmpirioLabsModel drives EmpirioLabs AI (https://api.empiriolabs.ai/v1), +// whose chat, embedding, rerank and model-list endpoints follow the +// OpenAI-compatible shapes, so the embedded OpenAIAPICompatibleModel covers +// every call. +type EmpirioLabsModel struct { + *OpenAIAPICompatibleModel +} + +// NewEmpirioLabsModel creates an EmpirioLabs AI driver. +func NewEmpirioLabsModel(baseURL map[string]string, urlSuffix URLSuffix) *EmpirioLabsModel { + return &EmpirioLabsModel{ + OpenAIAPICompatibleModel: NewOpenAIAPICompatibleModel(baseURL, urlSuffix), + } +} + +// Name returns the driver name. +func (m *EmpirioLabsModel) Name() string { + return "empiriolabs" +} + +// NewInstance returns a driver bound to the given base URL. +func (m *EmpirioLabsModel) NewInstance(baseURL map[string]string) ModelDriver { + return NewEmpirioLabsModel(baseURL, m.baseModel.URLSuffix) +} diff --git a/internal/entity/models/empiriolabs_test.go b/internal/entity/models/empiriolabs_test.go new file mode 100644 index 0000000000..bd01a01094 --- /dev/null +++ b/internal/entity/models/empiriolabs_test.go @@ -0,0 +1,228 @@ +// +// 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 + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "ragflow/internal/common" + "testing" +) + +// newEmpirioLabsForTest creates an EmpirioLabsModel instance for testing. +func newEmpirioLabsForTest(baseURL string) *EmpirioLabsModel { + return NewEmpirioLabsModel( + map[string]string{"default": baseURL}, + URLSuffix{Chat: "chat/completions", Models: "models", Embedding: "embeddings", Rerank: "reranks"}, + ) +} + +// TestEmpirioLabsName tests the driver name. +func TestEmpirioLabsName(t *testing.T) { + if got := newEmpirioLabsForTest("http://unused").Name(); got != "empiriolabs" { + t.Errorf("Name()=%q", got) + } +} + +// TestEmpirioLabsFactory tests creating the driver from factory. +func TestEmpirioLabsFactory(t *testing.T) { + driver, err := NewModelFactory().CreateModelDriver("EmpirioLabs AI", map[string]string{"default": "http://unused"}, URLSuffix{}) + if err != nil { + t.Fatalf("CreateModelDriver: %v", err) + } + if _, ok := driver.(*EmpirioLabsModel); !ok { + t.Fatalf("driver type=%T, want *EmpirioLabsModel", driver) + } + if _, ok := driver.NewInstance(map[string]string{"default": "http://other"}).(*EmpirioLabsModel); !ok { + t.Fatal("NewInstance did not return *EmpirioLabsModel") + } +} + +// The shipped config must resolve to the EmpirioLabs AI driver. CreateModelDriver +// dispatches on strings.ToLower(name), so the "EmpirioLabs AI" provider name has +// to match the "empiriolabs ai" case; anything else silently falls through to +// DummyModel. The rerank endpoint is "reranks" (plural) and the embeddings +// endpoint takes at most 10 inputs per request, so both are pinned here. +func TestEmpirioLabsProviderConfig(t *testing.T) { + dir, restore := setupProviderTestDir(t, "empiriolabs.json") + defer restore() + + if err := InitProviderManager(dir); err != nil { + t.Fatalf("InitProviderManager: %v", err) + } + + provider := GetProviderManager().FindProvider("EmpirioLabs AI") + if provider == nil { + t.Fatal("EmpirioLabs AI provider not found") + } + if _, ok := provider.ModelDriver.(*EmpirioLabsModel); !ok { + t.Fatalf("ModelDriver=%T, want *models.EmpirioLabsModel", provider.ModelDriver) + } + if provider.URLSuffix.Rerank != "reranks" { + t.Errorf("rerank url_suffix=%q, want reranks", provider.URLSuffix.Rerank) + } + for _, model := range provider.Models { + if model.ModelTypeMap["embedding"] && (model.MaxBatchSize == nil || *model.MaxBatchSize > 10) { + t.Errorf("embedding model %s must declare max_batch_size <= 10", model.Name) + } + } +} + +// TestEmpirioLabsChatSendsAPIKey tests chat completion authentication. +func TestEmpirioLabsChatSendsAPIKey(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-gw-test" { + t.Errorf("Authorization=%q", got) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "chat-empiriolabs", + "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 := newEmpirioLabsForTest(srv.URL).ChatWithMessages( + t.Context(), + "qwen3-8-flash", + []Message{{Role: "user", Content: "ping"}}, + &APIConfig{ApiKey: &testAPIKey}, + 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) +} + +// TestEmpirioLabsEmbed tests embedding requests. +func TestEmpirioLabsEmbed(t *testing.T) { + withSSRFBypass(t) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/embeddings" { + t.Errorf("path=%s", r.URL.Path) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": []map[string]any{{"embedding": []float64{0.1, 0.2}, "index": 0}}, + "usage": map[string]any{"prompt_tokens": 7, "total_tokens": 7}, + }) + })) + defer srv.Close() + + modelName := "text-embedding-v4" + embeddings, err := newEmpirioLabsForTest(srv.URL).Embed( + t.Context(), + &modelName, + EmbedRequest{Texts: []string{"document"}}, + &APIConfig{ApiKey: &testAPIKey}, + nil, + &common.ModelUsage{}, + ) + if err != nil { + t.Fatalf("Embed: %v", err) + } + if len(embeddings) != 1 || len(embeddings[0].Embedding) != 2 { + t.Fatalf("embeddings=%#v", embeddings) + } +} + +// TestEmpirioLabsRerank tests rerank requests against the plural reranks endpoint. +func TestEmpirioLabsRerank(t *testing.T) { + withSSRFBypass(t) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/reranks" { + t.Errorf("%s %s", r.Method, r.URL.Path) + } + if got := r.Header.Get("Authorization"); got != "Bearer sk-gw-test" { + t.Errorf("Authorization=%q", got) + } + var body struct { + Model string `json:"model"` + Query string `json:"query"` + Documents []string `json:"documents"` + TopN int `json:"top_n"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Errorf("decode request: %v", err) + } + if body.Model != "qwen3-rerank" || body.Query != "query" || len(body.Documents) != 2 || body.TopN != 2 { + t.Errorf("body=%+v", body) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "results": []map[string]any{ + {"index": 1, "relevance_score": 0.9}, + {"index": 0, "relevance_score": 0.2}, + }, + }) + })) + defer srv.Close() + + modelName := "qwen3-rerank" + resp, err := newEmpirioLabsForTest(srv.URL).Rerank( + t.Context(), + &modelName, + RerankRequest{Query: "query", Documents: []string{"first", "second"}}, + &APIConfig{ApiKey: &testAPIKey}, + &RerankConfig{}, + &common.ModelUsage{}, + ) + if err != nil { + t.Fatalf("Rerank: %v", err) + } + if len(resp.Data) != 2 || resp.Data[0].Index != 1 || resp.Data[0].RelevanceScore != 0.9 { + t.Fatalf("results=%#v", resp.Data) + } +} + +// TestEmpirioLabsListModelsAndCheckConnection tests model listing and connectivity check. +func TestEmpirioLabsListModelsAndCheckConnection(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) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": []map[string]any{ + {"id": "qwen3-8-flash"}, + {"id": "text-embedding-v4"}, + }, + }) + })) + defer srv.Close() + + model := newEmpirioLabsForTest(srv.URL) + list, err := model.ListModels(t.Context(), &APIConfig{ApiKey: &testAPIKey}) + if err != nil { + t.Fatalf("ListModels: %v", err) + } + if joinModelNames(list, ",") != "qwen3-8-flash,text-embedding-v4" { + t.Errorf("models=%v", list) + } + if err := model.CheckConnection(t.Context(), &APIConfig{ApiKey: &testAPIKey}); err != nil { + t.Fatalf("CheckConnection: %v", err) + } +} diff --git a/internal/entity/models/factory.go b/internal/entity/models/factory.go index c007e635ca..2166aadc87 100644 --- a/internal/entity/models/factory.go +++ b/internal/entity/models/factory.go @@ -188,6 +188,8 @@ func (f *ModelFactory) CreateModelDriver(providerName string, baseURL map[string return NewApiRouteModel(baseURL, urlSuffix), nil case "opper": return NewOpperModel(baseURL, urlSuffix), nil + case "empiriolabs ai": + return NewEmpirioLabsModel(baseURL, urlSuffix), nil default: return NewDummyModel(baseURL, urlSuffix), nil } diff --git a/web/src/assets/svg/llm/empiriolabs.svg b/web/src/assets/svg/llm/empiriolabs.svg new file mode 100644 index 0000000000..39055e1942 --- /dev/null +++ b/web/src/assets/svg/llm/empiriolabs.svg @@ -0,0 +1,3 @@ + + + diff --git a/web/src/components/svg-icon.tsx b/web/src/components/svg-icon.tsx index b6502b4920..a15e474f7a 100644 --- a/web/src/components/svg-icon.tsx +++ b/web/src/components/svg-icon.tsx @@ -119,6 +119,7 @@ const svgIcons = [ LLMFactory.CheaperInference, LLMFactory.Opper, LLMFactory.TrustedRouter, + LLMFactory.EmpirioLabs, ]; export const LlmIcon = ({ diff --git a/web/src/constants/llm.ts b/web/src/constants/llm.ts index b49aa1c84b..e3d32d6a88 100644 --- a/web/src/constants/llm.ts +++ b/web/src/constants/llm.ts @@ -113,6 +113,7 @@ export enum LLMFactory { Opper = 'Opper', DemonRoute = 'Demon Route', TrustedRouter = 'TrustedRouter', + EmpirioLabs = 'EmpirioLabs AI', } // Please lowercase the file name @@ -208,6 +209,7 @@ export const IconMap = { [LLMFactory.CheaperInference]: 'cheaperinference', [LLMFactory.Opper]: 'opper', [LLMFactory.TrustedRouter]: 'trustedrouter', + [LLMFactory.EmpirioLabs]: 'empiriolabs', }; export const ModelTypeToField: Record = { @@ -238,6 +240,8 @@ export const APIMapUrl = { [LLMFactory.CheaperInference]: 'https://cheaperinference.com', [LLMFactory.Opper]: 'https://platform.opper.ai', [LLMFactory.TrustedRouter]: 'https://trustedrouter.com/keys', + [LLMFactory.EmpirioLabs]: + 'https://platform.empiriolabs.ai/dashboard/api-keys', [LLMFactory.MWS]: 'https://mws.ru/docs/cloud-platform/gpt/general/inference-text.html', [LLMFactory.Anthropic]: 'https://console.anthropic.com/settings/keys',