From 19e5abc97169ee811838eda720a828385bd0ea44 Mon Sep 17 00:00:00 2001 From: jimmyplup Date: Tue, 3 Nov 2026 04:33:46 +0200 Subject: [PATCH] feat: add Synap as a model provider (#21818) --- conf/llm_factories.json | 8 + conf/model.md | 1 + conf/models/synap.json | 69 ++++++++ internal/entity/models/factory.go | 2 + internal/entity/models/synap.go | 42 +++++ internal/entity/models/synap_test.go | 153 ++++++++++++++++++ web/src/assets/svg/llm/synap.svg | 1 + web/src/components/svg-icon.tsx | 1 + web/src/constants/llm.ts | 3 + .../provider-schema/constants.ts | 1 + 10 files changed, 281 insertions(+) create mode 100644 conf/models/synap.json create mode 100644 internal/entity/models/synap.go create mode 100644 internal/entity/models/synap_test.go create mode 100644 web/src/assets/svg/llm/synap.svg diff --git a/conf/llm_factories.json b/conf/llm_factories.json index c77467af40..1b7fc8b04c 100644 --- a/conf/llm_factories.json +++ b/conf/llm_factories.json @@ -3511,6 +3511,14 @@ "llm": [], "url": "https://inference.runanywhere.ai/v1" }, + { + "name": "Synap", + "logo": "", + "tags": "LLM,IMAGE2TEXT", + "status": "1", + "llm": [], + "url": "https://pool.linkrra.com/v1" + }, { "name": "StepFun", "logo": "", diff --git a/conf/model.md b/conf/model.md index 5d7add1517..45d63da504 100644 --- a/conf/model.md +++ b/conf/model.md @@ -199,6 +199,7 @@ The following providers are **aggregators** — they host models from multiple u | **Replicate** | Open-source model hosting | | **Runcrate** | GPU cloud with an OpenAI-compatible inference API for open models | | **SiliconFlow** | Chinese aggregator | +| **Synap** | Linkrra's OpenAI-compatible router for open-weight models served on self-operated GPUs | | **TogetherAI** | Open-source model hosting | | **TokenHub** | Multi-provider aggregator | | **TrustedRouter** | OpenAI-compatible router | diff --git a/conf/models/synap.json b/conf/models/synap.json new file mode 100644 index 0000000000..75eccd5f8b --- /dev/null +++ b/conf/models/synap.json @@ -0,0 +1,69 @@ +{ + "name": "Synap", + "url": { + "default": "https://pool.linkrra.com/v1" + }, + "url_suffix": { + "chat": "chat/completions", + "models": "models" + }, + "class": "synap", + "models": [ + { + "name": "gpt-oss-20b", + "context_length": 131072, + "max_output": 131072, + "model_types": [ + "chat" + ], + "tools": { + "support": true + } + }, + { + "name": "gemma-4-26b-a4b-it", + "context_length": 131072, + "max_output": 131072, + "model_types": [ + "chat" + ], + "tools": { + "support": true + } + }, + { + "name": "qwen3-coder-30b", + "context_length": 98304, + "max_output": 98304, + "model_types": [ + "chat" + ], + "tools": { + "support": true + } + }, + { + "name": "qwen/qwen3-30b-a3b", + "context_length": 98304, + "max_output": 98304, + "model_types": [ + "chat" + ], + "tools": { + "support": true + } + }, + { + "name": "qwen/qwen3-vl-30b-a3b-instruct", + "context_length": 65536, + "max_output": 65536, + "model_types": [ + "chat", + "vision" + ], + "tools": { + "support": true + } + } + ] +} diff --git a/internal/entity/models/factory.go b/internal/entity/models/factory.go index 30f9fb066b..598516793e 100644 --- a/internal/entity/models/factory.go +++ b/internal/entity/models/factory.go @@ -217,6 +217,8 @@ func (f *ModelFactory) CreateModelDriver(providerName string, baseURL map[string return NewHostingerRouterModel(baseURL, urlSuffix), nil case "wally": return NewWallyModel(baseURL, urlSuffix), nil + case "synap": + return NewSynapModel(baseURL, urlSuffix), nil default: return NewDummyModel(baseURL, urlSuffix), nil } diff --git a/internal/entity/models/synap.go b/internal/entity/models/synap.go new file mode 100644 index 0000000000..1edc8fead2 --- /dev/null +++ b/internal/entity/models/synap.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 + +// SynapModel drives Synap (https://pool.linkrra.com/v1), which serves +// open-weight models on self-operated GPUs through OpenAI-compatible chat +// and model-list endpoints, so the embedded OpenAIAPICompatibleModel covers +// every call. +type SynapModel struct { + *OpenAIAPICompatibleModel +} + +// NewSynapModel creates a Synap driver. +func NewSynapModel(baseURL map[string]string, urlSuffix URLSuffix) *SynapModel { + return &SynapModel{ + OpenAIAPICompatibleModel: NewOpenAIAPICompatibleModel(baseURL, urlSuffix), + } +} + +// Name returns the driver name. +func (m *SynapModel) Name() string { + return "synap" +} + +// NewInstance returns a driver bound to the given base URL. +func (m *SynapModel) NewInstance(baseURL map[string]string) ModelDriver { + return NewSynapModel(baseURL, m.baseModel.URLSuffix) +} diff --git a/internal/entity/models/synap_test.go b/internal/entity/models/synap_test.go new file mode 100644 index 0000000000..505dfaf284 --- /dev/null +++ b/internal/entity/models/synap_test.go @@ -0,0 +1,153 @@ +// +// 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" +) + +// newSynapForTest creates a SynapModel instance for testing. +func newSynapForTest(baseURL string) *SynapModel { + return NewSynapModel( + map[string]string{"default": baseURL}, + URLSuffix{Chat: "chat/completions", Models: "models"}, + ) +} + +// TestSynapName tests the driver name. +func TestSynapName(t *testing.T) { + if got := newSynapForTest("http://unused").Name(); got != "synap" { + t.Errorf("Name()=%q", got) + } +} + +// TestSynapFactory tests creating the driver from factory. +func TestSynapFactory(t *testing.T) { + driver, err := NewModelFactory().CreateModelDriver("synap", map[string]string{"default": "http://unused"}, URLSuffix{}) + if err != nil { + t.Fatalf("CreateModelDriver: %v", err) + } + if _, ok := driver.(*SynapModel); !ok { + t.Fatalf("driver type=%T, want *SynapModel", driver) + } + if _, ok := driver.NewInstance(map[string]string{"default": "http://other"}).(*SynapModel); !ok { + t.Fatal("NewInstance did not return *SynapModel") + } +} + +// The shipped config must resolve to the Synap driver. CreateModelDriver +// dispatches on strings.ToLower(name), so the "Synap" provider name has +// to match the "synap" case; anything else silently falls through to +// DummyModel. +func TestSynapProviderConfig(t *testing.T) { + dir, restore := setupProviderTestDir(t, "synap.json") + defer restore() + + if err := InitProviderManager(dir); err != nil { + t.Fatalf("InitProviderManager: %v", err) + } + + provider := GetProviderManager().FindProvider("Synap") + if provider == nil { + t.Fatal("Synap provider not found") + } + if _, ok := provider.ModelDriver.(*SynapModel); !ok { + t.Fatalf("ModelDriver=%T, want *models.SynapModel", provider.ModelDriver) + } +} + +// TestSynapChatSendsAPIKey tests chat completion authentication and that +// the model id reaches the API unchanged. +func TestSynapChatSendsAPIKey(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 != "qwen3-coder-30b" { + t.Errorf("model=%q", body.Model) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "chat-synap", + "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 := newSynapForTest(srv.URL).ChatWithMessages( + t.Context(), + "qwen3-coder-30b", + []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) +} + +// TestSynapListModelsAndCheckConnection tests model listing and connectivity check. +func TestSynapListModelsAndCheckConnection(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-gw-test" { + t.Errorf("Authorization=%q", got) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": []map[string]any{ + {"id": "qwen3-coder-30b"}, + {"id": "gpt-oss-20b"}, + }, + }) + })) + defer srv.Close() + + model := newSynapForTest(srv.URL) + list, err := model.ListModels(t.Context(), &APIConfig{ApiKey: &testAPIKey}) + if err != nil { + t.Fatalf("ListModels: %v", err) + } + if joinModelNames(list, ",") != "qwen3-coder-30b,gpt-oss-20b" { + 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/synap.svg b/web/src/assets/svg/llm/synap.svg new file mode 100644 index 0000000000..e2c7929dc9 --- /dev/null +++ b/web/src/assets/svg/llm/synap.svg @@ -0,0 +1 @@ + diff --git a/web/src/components/svg-icon.tsx b/web/src/components/svg-icon.tsx index 38a2a31e45..856faa4169 100644 --- a/web/src/components/svg-icon.tsx +++ b/web/src/components/svg-icon.tsx @@ -128,6 +128,7 @@ const svgIcons = [ LLMFactory.HostingerRouter, LLMFactory.Cheapaimodel, LLMFactory.Wally, + LLMFactory.Synap, ]; export const LlmIcon = ({ diff --git a/web/src/constants/llm.ts b/web/src/constants/llm.ts index 810b17dbef..960084a160 100644 --- a/web/src/constants/llm.ts +++ b/web/src/constants/llm.ts @@ -124,6 +124,7 @@ export enum LLMFactory { SystemOne = 'SystemOne', HostingerRouter = 'Hostinger Router', Wally = 'Wally', + Synap = 'Synap', } // Please lowercase the file name @@ -229,6 +230,7 @@ export const IconMap = { [LLMFactory.HostingerRouter]: 'hostinger-router', [LLMFactory.Cheapaimodel]: 'cheapaimodel', [LLMFactory.Wally]: 'wally', + [LLMFactory.Synap]: 'synap', }; /** @@ -285,6 +287,7 @@ export const APIMapUrl = { [LLMFactory.Cheapaimodel]: 'https://console.cheapaimodel.ai/keys?utm_source=ragflow', [LLMFactory.Wally]: 'https://console.runanywhere.ai', + [LLMFactory.Synap]: 'https://synap.linkrra.com/pricing', [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 54110a513a..e7a0a1b9d8 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 @@ -49,6 +49,7 @@ export const LIST_MODEL_PROVIDERS = new Set([ LLMFactory.RAGcon, LLMFactory.SoMark, LLMFactory.HostingerRouter, + LLMFactory.Synap, // LLMFactory.HuggingFace, // LLMFactory.GoogleCloud,