From fdf9db6fcdd1d51e61bb5a0ffcc126ccf487d4f9 Mon Sep 17 00:00:00 2001 From: ricardasbitinas-jpg Date: Thu, 29 Oct 2026 10:47:41 +0200 Subject: [PATCH] feat: add Hostinger Router as a model provider (#21419) --- conf/llm_factories.json | 8 + conf/model.md | 1 + conf/models/hostinger_router.json | 83 ++++++ internal/entity/models/factory.go | 2 + internal/entity/models/hostinger_router.go | 70 +++++ .../entity/models/hostinger_router_test.go | 244 ++++++++++++++++++ web/src/assets/svg/llm/hostinger-router.svg | 3 + web/src/components/svg-icon.tsx | 1 + web/src/constants/llm.ts | 3 + .../provider-schema/constants.ts | 1 + 10 files changed, 416 insertions(+) create mode 100644 conf/models/hostinger_router.json create mode 100644 internal/entity/models/hostinger_router.go create mode 100644 internal/entity/models/hostinger_router_test.go create mode 100644 web/src/assets/svg/llm/hostinger-router.svg diff --git a/conf/llm_factories.json b/conf/llm_factories.json index 333784b731..cf36574a03 100644 --- a/conf/llm_factories.json +++ b/conf/llm_factories.json @@ -3487,6 +3487,14 @@ "llm": [], "url": "https://obscurify.ai/v1" }, + { + "name": "Hostinger Router", + "logo": "", + "tags": "LLM,TEXT EMBEDDING,IMAGE2TEXT", + "status": "1", + "llm": [], + "url": "https://router.hostinger.com/v1" + }, { "name": "StepFun", "logo": "", diff --git a/conf/model.md b/conf/model.md index 01212b7da1..37b5c0613c 100644 --- a/conf/model.md +++ b/conf/model.md @@ -184,6 +184,7 @@ The following providers are **aggregators** — they host models from multiple u | **FuturMix** | Multi-provider aggregator | | **GiteeAI** | Chinese aggregator | | **GreenPT** | GLM-based models | +| **Hostinger Router** | OpenAI-compatible router with EU/US regional model variants | | **Huawei Cloud** | Hosts GLM, Kimi, etc. | | **JieKouAI** | Multi-provider aggregator | | **LongCat** | Meituan's model platform | diff --git a/conf/models/hostinger_router.json b/conf/models/hostinger_router.json new file mode 100644 index 0000000000..51f8104df6 --- /dev/null +++ b/conf/models/hostinger_router.json @@ -0,0 +1,83 @@ +{ + "name": "Hostinger Router", + "url": { + "default": "https://router.hostinger.com/v1" + }, + "url_suffix": { + "chat": "chat/completions", + "models": "models", + "embedding": "embeddings" + }, + "class": "hostinger_router", + "models": [ + { + "name": "claude-sonnet-5", + "context_length": 1000000, + "max_output": 128000, + "model_types": [ + "chat", + "vision" + ], + "tools": { + "support": true + } + }, + { + "name": "claude-opus-5", + "context_length": 1000000, + "max_output": 128000, + "model_types": [ + "chat", + "vision" + ], + "tools": { + "support": true + } + }, + { + "name": "gpt-5-mini", + "context_length": 400000, + "max_output": 128000, + "model_types": [ + "chat", + "vision" + ], + "tools": { + "support": true + } + }, + { + "name": "gpt-5-mini-eu", + "context_length": 400000, + "max_output": 128000, + "model_types": [ + "chat", + "vision" + ], + "tools": { + "support": true + } + }, + { + "name": "gemini-3.5-flash", + "context_length": 1048576, + "max_output": 65536, + "model_types": [ + "chat", + "vision" + ], + "tools": { + "support": true + } + }, + { + "name": "text-embedding-3-small", + "max_tokens": 8191, + "max_dimension": 1536, + "max_batch_size": 2048, + "model_types": [ + "embedding" + ] + } + ] +} diff --git a/internal/entity/models/factory.go b/internal/entity/models/factory.go index f816cc65b9..a34f1bcc36 100644 --- a/internal/entity/models/factory.go +++ b/internal/entity/models/factory.go @@ -209,6 +209,8 @@ func (f *ModelFactory) CreateModelDriver(providerName string, baseURL map[string return NewObscurifyModel(baseURL, urlSuffix), nil case "new api": return NewNewAPIModel(baseURL, urlSuffix), nil + case "hostinger router": + return NewHostingerRouterModel(baseURL, urlSuffix), nil default: return NewDummyModel(baseURL, urlSuffix), nil } diff --git a/internal/entity/models/hostinger_router.go b/internal/entity/models/hostinger_router.go new file mode 100644 index 0000000000..0cc3984bed --- /dev/null +++ b/internal/entity/models/hostinger_router.go @@ -0,0 +1,70 @@ +// +// 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 "context" + +const hostingerRouterProviderName = "Hostinger Router" + +// HostingerRouterModel drives Hostinger Router, an OpenAI-compatible LLM router +// (https://router.hostinger.com/v1) that serves models from many providers +// under unprefixed ids such as "claude-sonnet-5" and regional variants such as +// "gpt-5-mini-eu", so the embedded OpenAIAPICompatibleModel covers every call. +type HostingerRouterModel struct { + *OpenAIAPICompatibleModel +} + +// NewHostingerRouterModel creates a Hostinger Router driver. +func NewHostingerRouterModel(baseURL map[string]string, urlSuffix URLSuffix) *HostingerRouterModel { + return &HostingerRouterModel{ + OpenAIAPICompatibleModel: NewOpenAIAPICompatibleModel(baseURL, urlSuffix), + } +} + +// Name returns the driver name. +func (m *HostingerRouterModel) Name() string { + return "hostinger_router" +} + +// NewInstance returns a driver bound to the given base URL. +func (m *HostingerRouterModel) NewInstance(baseURL map[string]string) ModelDriver { + return NewHostingerRouterModel(baseURL, m.baseModel.URLSuffix) +} + +// ListModels lists the router's models. The OpenAI-compatible listing infers +// types from model names, which reports vision models as chat-only, so models +// in the Hostinger Router catalogue keep the catalogue's types. +func (m *HostingerRouterModel) ListModels(ctx context.Context, apiConfig *APIConfig) ([]ListModelResponse, error) { + models, err := m.OpenAIAPICompatibleModel.ListModels(ctx, apiConfig) + if err != nil { + return nil, err + } + pm := GetProviderManager() + if pm == nil { + return models, nil + } + provider := pm.FindProvider(hostingerRouterProviderName) + if provider == nil { + return models, nil + } + for i := range models { + if entry := pm.FindModel(provider, models[i].Name); entry != nil && len(entry.ModelTypes) > 0 { + models[i].ModelTypes = append([]string(nil), entry.ModelTypes...) + } + } + return models, nil +} diff --git a/internal/entity/models/hostinger_router_test.go b/internal/entity/models/hostinger_router_test.go new file mode 100644 index 0000000000..4a9c63ca09 --- /dev/null +++ b/internal/entity/models/hostinger_router_test.go @@ -0,0 +1,244 @@ +// +// 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" +) + +// newHostingerRouterForTest creates a HostingerRouterModel instance for testing. +func newHostingerRouterForTest(baseURL string) *HostingerRouterModel { + return NewHostingerRouterModel( + map[string]string{"default": baseURL}, + URLSuffix{Chat: "chat/completions", Models: "models", Embedding: "embeddings"}, + ) +} + +// TestHostingerRouterName tests the driver name. +func TestHostingerRouterName(t *testing.T) { + if got := newHostingerRouterForTest("http://unused").Name(); got != "hostinger_router" { + t.Errorf("Name()=%q", got) + } +} + +// TestHostingerRouterFactory tests creating the driver from factory. +func TestHostingerRouterFactory(t *testing.T) { + driver, err := NewModelFactory().CreateModelDriver("Hostinger Router", map[string]string{"default": "http://unused"}, URLSuffix{}) + if err != nil { + t.Fatalf("CreateModelDriver: %v", err) + } + if _, ok := driver.(*HostingerRouterModel); !ok { + t.Fatalf("driver type=%T, want *HostingerRouterModel", driver) + } + if _, ok := driver.NewInstance(map[string]string{"default": "http://other"}).(*HostingerRouterModel); !ok { + t.Fatal("NewInstance did not return *HostingerRouterModel") + } +} + +// The shipped config must resolve to the Hostinger Router driver. +// CreateModelDriver dispatches on strings.ToLower(name), so the +// "Hostinger Router" provider name has to match the "hostinger router" case; +// anything else silently falls through to DummyModel. +func TestHostingerRouterProviderConfig(t *testing.T) { + dir, restore := setupProviderTestDir(t, "hostinger_router.json") + defer restore() + + if err := InitProviderManager(dir); err != nil { + t.Fatalf("InitProviderManager: %v", err) + } + + provider := GetProviderManager().FindProvider("Hostinger Router") + if provider == nil { + t.Fatal("Hostinger Router provider not found") + } + if _, ok := provider.ModelDriver.(*HostingerRouterModel); !ok { + t.Fatalf("ModelDriver=%T, want *models.HostingerRouterModel", provider.ModelDriver) + } + for _, model := range provider.Models { + if model.ContextLength != nil && model.MaxOutput != nil && *model.MaxOutput > *model.ContextLength { + t.Errorf("%s max_output %d exceeds context_length %d", model.Name, *model.MaxOutput, *model.ContextLength) + } + } +} + +// TestHostingerRouterChatSendsAPIKey tests chat completion authentication and +// that the unprefixed regional model id reaches the API unchanged. +func TestHostingerRouterChatSendsAPIKey(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 != "gpt-5-mini-eu" { + t.Errorf("model=%q", body.Model) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "chat-hostinger-router", + "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 := newHostingerRouterForTest(srv.URL).ChatWithMessages( + t.Context(), + "gpt-5-mini-eu", + []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) +} + +// TestHostingerRouterEmbed tests embedding requests. +func TestHostingerRouterEmbed(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) + } + 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 != "text-embedding-3-small" { + t.Errorf("model=%q", body.Model) + } + _ = 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-3-small" + embeddings, err := newHostingerRouterForTest(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) + } +} + +// TestHostingerRouterListModelsAndCheckConnection tests model listing and connectivity check. +func TestHostingerRouterListModelsAndCheckConnection(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": "claude-sonnet-5"}, + {"id": "text-embedding-3-small"}, + }, + }) + })) + defer srv.Close() + + model := newHostingerRouterForTest(srv.URL) + list, err := model.ListModels(t.Context(), &APIConfig{ApiKey: &testAPIKey}) + if err != nil { + t.Fatalf("ListModels: %v", err) + } + if joinModelNames(list, ",") != "claude-sonnet-5,text-embedding-3-small" { + t.Errorf("models=%v", list) + } + if err := model.CheckConnection(t.Context(), &APIConfig{ApiKey: &testAPIKey}); err != nil { + t.Fatalf("CheckConnection: %v", err) + } +} + +// TestHostingerRouterListModelsKeepsCatalogueTypes tests that listed models in +// the Hostinger Router catalogue keep its types, so vision models are not +// reported as chat-only, while other models keep name-inferred types. +func TestHostingerRouterListModelsKeepsCatalogueTypes(t *testing.T) { + withSSRFBypass(t) + prev := providerManager + defer func() { providerManager = prev }() + dir, restore := setupProviderTestDir(t, "hostinger_router.json") + defer restore() + if err := InitProviderManager(dir); err != nil { + t.Fatalf("InitProviderManager: %v", err) + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": []map[string]any{ + {"id": "claude-sonnet-5"}, + {"id": "gpt-5-mini-eu"}, + {"id": "text-embedding-3-small"}, + {"id": "glm-5.2"}, + }, + }) + })) + defer srv.Close() + + list, err := newHostingerRouterForTest(srv.URL).ListModels(t.Context(), &APIConfig{ApiKey: &testAPIKey}) + if err != nil { + t.Fatalf("ListModels: %v", err) + } + got := map[string][]string{} + for _, m := range list { + got[m.Name] = m.ModelTypes + } + for _, name := range []string{"claude-sonnet-5", "gpt-5-mini-eu"} { + if !containsModelType(got[name], "vision") || !containsModelType(got[name], "chat") { + t.Errorf("%s types=%v, want chat and vision", name, got[name]) + } + } + if !containsModelType(got["text-embedding-3-small"], "embedding") { + t.Errorf("text-embedding-3-small types=%v, want embedding", got["text-embedding-3-small"]) + } + if !containsModelType(got["glm-5.2"], "chat") { + t.Errorf("glm-5.2 types=%v, want name-inferred chat", got["glm-5.2"]) + } +} diff --git a/web/src/assets/svg/llm/hostinger-router.svg b/web/src/assets/svg/llm/hostinger-router.svg new file mode 100644 index 0000000000..d338ec3c16 --- /dev/null +++ b/web/src/assets/svg/llm/hostinger-router.svg @@ -0,0 +1,3 @@ + + + diff --git a/web/src/components/svg-icon.tsx b/web/src/components/svg-icon.tsx index 22c7a6bdad..83667908c8 100644 --- a/web/src/components/svg-icon.tsx +++ b/web/src/components/svg-icon.tsx @@ -125,6 +125,7 @@ const svgIcons = [ LLMFactory.WorldRouter, LLMFactory.CloudGPU, LLMFactory.Obscurify, + LLMFactory.HostingerRouter, ]; export const LlmIcon = ({ diff --git a/web/src/constants/llm.ts b/web/src/constants/llm.ts index 98c50782ee..d150ef07fb 100644 --- a/web/src/constants/llm.ts +++ b/web/src/constants/llm.ts @@ -121,6 +121,7 @@ export enum LLMFactory { CloudGPU = 'CloudGPU', Obscurify = 'Obscurify.ai', SystemOne = 'SystemOne', + HostingerRouter = 'Hostinger Router', } // Please lowercase the file name @@ -223,6 +224,7 @@ export const IconMap = { [LLMFactory.WorldRouter]: 'worldrouter', [LLMFactory.CloudGPU]: 'cloudgpu', [LLMFactory.Obscurify]: 'obscurify', + [LLMFactory.HostingerRouter]: 'hostinger-router', }; /** @@ -275,6 +277,7 @@ export const APIMapUrl = { 'https://worldrouter.ai/dashboard/api-keys?utm_source=ragflow&utm_medium=provider&utm_campaign=integration', [LLMFactory.CloudGPU]: 'https://cloudgpu.app/api/console', [LLMFactory.Obscurify]: 'https://obscurify.ai/account', + [LLMFactory.HostingerRouter]: 'https://hpanel.hostinger.com/ai-router', [LLMFactory.MWS]: 'https://mws.ru/docs/cloud-platform/gpt/general/inference-text.html', [LLMFactory.Anthropic]: 'https://console.anthropic.com/settings/keys', diff --git a/web/src/pages/user-setting/setting-model/provider-schema/constants.ts b/web/src/pages/user-setting/setting-model/provider-schema/constants.ts index 2c51665a47..54110a513a 100644 --- a/web/src/pages/user-setting/setting-model/provider-schema/constants.ts +++ b/web/src/pages/user-setting/setting-model/provider-schema/constants.ts @@ -48,6 +48,7 @@ export const LIST_MODEL_PROVIDERS = new Set([ LLMFactory.NewAPI, LLMFactory.RAGcon, LLMFactory.SoMark, + LLMFactory.HostingerRouter, // LLMFactory.HuggingFace, // LLMFactory.GoogleCloud,