diff --git a/conf/llm_factories.json b/conf/llm_factories.json index c83a075713..fc656450c4 100644 --- a/conf/llm_factories.json +++ b/conf/llm_factories.json @@ -3524,6 +3524,14 @@ "llm": [], "url": "https://api.empiriolabs.ai/v1" }, + { + "name": "Runcrate", + "logo": "", + "tags": "LLM,TEXT EMBEDDING,IMAGE2TEXT", + "status": "1", + "llm": [], + "url": "https://api.runcrate.ai/v1" + }, { "name": "StepFun", "logo": "", diff --git a/conf/model.md b/conf/model.md index 7715986566..a5191abcd0 100644 --- a/conf/model.md +++ b/conf/model.md @@ -193,6 +193,7 @@ The following providers are **aggregators** — they host models from multiple u | **PPIO** | Edge AI platform | | **Qiniu** | Chinese cloud platform | | **Replicate** | Open-source model hosting | +| **Runcrate** | GPU cloud with an OpenAI-compatible inference API for open models | | **SiliconFlow** | Chinese aggregator | | **TogetherAI** | Open-source model hosting | | **TokenHub** | Multi-provider aggregator | diff --git a/conf/models/runcrate.json b/conf/models/runcrate.json new file mode 100644 index 0000000000..62c2cce7ec --- /dev/null +++ b/conf/models/runcrate.json @@ -0,0 +1,114 @@ +{ + "name": "Runcrate", + "url": { + "default": "https://api.runcrate.ai/v1" + }, + "url_suffix": { + "chat": "chat/completions", + "models": "models", + "embedding": "embeddings" + }, + "class": "runcrate", + "models": [ + { + "name": "deepseek-ai/DeepSeek-V4.1-Flash", + "context_length": 1048576, + "model_types": [ + "chat", + "vision" + ], + "tools": { + "support": true + } + }, + { + "name": "deepseek-ai/DeepSeek-V4.1-Flash-1017", + "context_length": 1048576, + "model_types": [ + "chat", + "vision" + ], + "tools": { + "support": true + } + }, + { + "name": "zai-org/GLM-5.3", + "context_length": 1048576, + "model_types": [ + "chat" + ], + "tools": { + "support": true + } + }, + { + "name": "zai-org/GLM-5.3-Flash", + "context_length": 1048576, + "model_types": [ + "chat", + "vision" + ], + "tools": { + "support": true + } + }, + { + "name": "Qwen/Qwen3.8-Flash", + "context_length": 1000000, + "model_types": [ + "chat" + ], + "tools": { + "support": true + } + }, + { + "name": "openai/gpt-oss-120b", + "context_length": 131072, + "model_types": [ + "chat" + ], + "tools": { + "support": true + } + }, + { + "name": "moonshotai/Kimi-K2.6", + "context_length": 262144, + "max_output": 16384, + "model_types": [ + "chat", + "vision" + ], + "tools": { + "support": true + } + }, + { + "name": "Qwen/Qwen3-Coder-480B-A35B-Instruct-Turbo", + "context_length": 262144, + "max_output": 65536, + "model_types": [ + "chat" + ], + "tools": { + "support": true + } + }, + { + "name": "Qwen/Qwen3-Embedding-8B", + "max_tokens": 32768, + "model_types": [ + "embedding" + ] + }, + { + "name": "BAAI/bge-m3", + "max_tokens": 8192, + "model_types": [ + "embedding" + ] + } + ] +} diff --git a/internal/entity/models/factory.go b/internal/entity/models/factory.go index 87758ad80b..7dcda49b28 100644 --- a/internal/entity/models/factory.go +++ b/internal/entity/models/factory.go @@ -199,6 +199,8 @@ func (f *ModelFactory) CreateModelDriver(providerName string, baseURL map[string return NewOpperModel(baseURL, urlSuffix), nil case "empiriolabs ai": return NewEmpirioLabsModel(baseURL, urlSuffix), nil + case "runcrate": + return NewRuncrateModel(baseURL, urlSuffix), nil case "new api": return NewNewAPIModel(baseURL, urlSuffix), nil default: diff --git a/internal/entity/models/runcrate.go b/internal/entity/models/runcrate.go new file mode 100644 index 0000000000..3f059235f0 --- /dev/null +++ b/internal/entity/models/runcrate.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 + +// RuncrateModel drives Runcrate (https://api.runcrate.ai/v1), a GPU cloud +// whose inference API serves open models through OpenAI-compatible chat, +// embedding and model-list endpoints, so the embedded +// OpenAIAPICompatibleModel covers every call. +type RuncrateModel struct { + *OpenAIAPICompatibleModel +} + +// NewRuncrateModel creates a Runcrate driver. +func NewRuncrateModel(baseURL map[string]string, urlSuffix URLSuffix) *RuncrateModel { + return &RuncrateModel{ + OpenAIAPICompatibleModel: NewOpenAIAPICompatibleModel(baseURL, urlSuffix), + } +} + +// Name returns the driver name. +func (m *RuncrateModel) Name() string { + return "runcrate" +} + +// NewInstance returns a driver bound to the given base URL. +func (m *RuncrateModel) NewInstance(baseURL map[string]string) ModelDriver { + return NewRuncrateModel(baseURL, m.baseModel.URLSuffix) +} diff --git a/internal/entity/models/runcrate_test.go b/internal/entity/models/runcrate_test.go new file mode 100644 index 0000000000..6421eaff34 --- /dev/null +++ b/internal/entity/models/runcrate_test.go @@ -0,0 +1,181 @@ +// +// 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" +) + +// newRuncrateForTest creates a RuncrateModel instance for testing. +func newRuncrateForTest(baseURL string) *RuncrateModel { + return NewRuncrateModel( + map[string]string{"default": baseURL}, + URLSuffix{Chat: "chat/completions", Models: "models", Embedding: "embeddings"}, + ) +} + +// TestRuncrateName tests the driver name. +func TestRuncrateName(t *testing.T) { + if got := newRuncrateForTest("http://unused").Name(); got != "runcrate" { + t.Errorf("Name()=%q", got) + } +} + +// TestRuncrateFactory tests creating the driver from factory. +func TestRuncrateFactory(t *testing.T) { + driver, err := NewModelFactory().CreateModelDriver("runcrate", map[string]string{"default": "http://unused"}, URLSuffix{}) + if err != nil { + t.Fatalf("CreateModelDriver: %v", err) + } + if _, ok := driver.(*RuncrateModel); !ok { + t.Fatalf("driver type=%T, want *RuncrateModel", driver) + } + if _, ok := driver.NewInstance(map[string]string{"default": "http://other"}).(*RuncrateModel); !ok { + t.Fatal("NewInstance did not return *RuncrateModel") + } +} + +// The shipped config must resolve to the Runcrate driver. CreateModelDriver +// dispatches on strings.ToLower(name), so the "Runcrate" provider name has +// to match the "runcrate" case; anything else silently falls through to +// DummyModel. +func TestRuncrateProviderConfig(t *testing.T) { + dir, restore := setupProviderTestDir(t, "runcrate.json") + defer restore() + + if err := InitProviderManager(dir); err != nil { + t.Fatalf("InitProviderManager: %v", err) + } + + provider := GetProviderManager().FindProvider("Runcrate") + if provider == nil { + t.Fatal("Runcrate provider not found") + } + if _, ok := provider.ModelDriver.(*RuncrateModel); !ok { + t.Fatalf("ModelDriver=%T, want *models.RuncrateModel", provider.ModelDriver) + } +} + +// TestRuncrateChatSendsAPIKey tests chat completion authentication and that +// the organization-prefixed model id reaches the API unchanged. +func TestRuncrateChatSendsAPIKey(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) + } + var body struct { + Model string `json:"model"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Errorf("decode request: %v", err) + } + if body.Model != "deepseek-ai/DeepSeek-V4.1-Flash" { + t.Errorf("model=%q", body.Model) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "chat-runcrate", + "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 := newRuncrateForTest(srv.URL).ChatWithMessages( + t.Context(), + "deepseek-ai/DeepSeek-V4.1-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) +} + +// TestRuncrateEmbed tests embedding requests. +func TestRuncrateEmbed(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 := "Qwen/Qwen3-Embedding-8B" + embeddings, err := newRuncrateForTest(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) + } +} + +// TestRuncrateListModelsAndCheckConnection tests model listing and connectivity check. +func TestRuncrateListModelsAndCheckConnection(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": "deepseek-ai/DeepSeek-V4.1-Flash"}, + {"id": "Qwen/Qwen3-Embedding-8B"}, + }, + }) + })) + defer srv.Close() + + model := newRuncrateForTest(srv.URL) + list, err := model.ListModels(t.Context(), &APIConfig{ApiKey: &testAPIKey}) + if err != nil { + t.Fatalf("ListModels: %v", err) + } + if joinModelNames(list, ",") != "deepseek-ai/DeepSeek-V4.1-Flash,Qwen/Qwen3-Embedding-8B" { + t.Errorf("models=%v", list) + } + if err := model.CheckConnection(t.Context(), &APIConfig{ApiKey: &testAPIKey}); err != nil { + t.Fatalf("CheckConnection: %v", err) + } +} diff --git a/web/src/assets/svg/llm/runcrate.svg b/web/src/assets/svg/llm/runcrate.svg new file mode 100644 index 0000000000..dd2dff8681 --- /dev/null +++ b/web/src/assets/svg/llm/runcrate.svg @@ -0,0 +1 @@ + diff --git a/web/src/components/svg-icon.tsx b/web/src/components/svg-icon.tsx index 7898e1ab17..0775a9601c 100644 --- a/web/src/components/svg-icon.tsx +++ b/web/src/components/svg-icon.tsx @@ -121,6 +121,7 @@ const svgIcons = [ LLMFactory.TrustedRouter, LLMFactory.EmpirioLabs, LLMFactory.AIStupidLevel, + LLMFactory.Runcrate, ]; export const LlmIcon = ({ diff --git a/web/src/constants/llm.ts b/web/src/constants/llm.ts index 77e28d369c..42402aeed4 100644 --- a/web/src/constants/llm.ts +++ b/web/src/constants/llm.ts @@ -116,6 +116,7 @@ export enum LLMFactory { TrustedRouter = 'TrustedRouter', EmpirioLabs = 'EmpirioLabs AI', AIStupidLevel = 'AI Stupid Level', + Runcrate = 'Runcrate', } // Please lowercase the file name @@ -214,6 +215,7 @@ export const IconMap = { [LLMFactory.TrustedRouter]: 'trustedrouter', [LLMFactory.EmpirioLabs]: 'empiriolabs', [LLMFactory.AIStupidLevel]: 'aistupidlevel', + [LLMFactory.Runcrate]: 'runcrate', }; /** @@ -264,6 +266,8 @@ export const APIMapUrl = { [LLMFactory.EmpirioLabs]: 'https://platform.empiriolabs.ai/dashboard/api-keys', [LLMFactory.AIStupidLevel]: 'https://aistupidlevel.info/router/keys', + [LLMFactory.Runcrate]: + 'https://www.runcrate.ai/dashboard/api-keys?utm_source=ragflow&utm_medium=directory&utm_campaign=gpu_pricing', [LLMFactory.MWS]: 'https://mws.ru/docs/cloud-platform/gpt/general/inference-text.html', [LLMFactory.Anthropic]: 'https://console.anthropic.com/settings/keys',