From 57876248e23724ef2fcbecac38a520fe0963aa7b Mon Sep 17 00:00:00 2001 From: LC Date: Mon, 18 May 2026 10:16:09 +0800 Subject: [PATCH] feat(provider): add SiliconFlow provider support (#2885) --- pkg/providers/factory_provider.go | 3 +- pkg/providers/factory_provider_test.go | 38 ++++++++++++ pkg/providers/openai_compat/provider.go | 31 +++++----- pkg/providers/openai_compat/provider_test.go | 16 +++++ web/backend/api/models.go | 3 +- web/backend/api/models_test.go | 62 +++++++++++++++++++ .../components/models/provider-registry.ts | 11 ++++ 7 files changed, 147 insertions(+), 17 deletions(-) diff --git a/pkg/providers/factory_provider.go b/pkg/providers/factory_provider.go index e9e0e6e98..034c80508 100644 --- a/pkg/providers/factory_provider.go +++ b/pkg/providers/factory_provider.go @@ -37,6 +37,7 @@ var protocolMetaByName = map[string]protocolMeta{ "ollama": {defaultAPIBase: "http://localhost:11434/v1", emptyAPIKeyAllowed: true}, "moonshot": {defaultAPIBase: "https://api.moonshot.cn/v1"}, "shengsuanyun": {defaultAPIBase: "https://router.shengsuanyun.com/api/v1"}, + "siliconflow": {defaultAPIBase: "https://api.siliconflow.cn/v1"}, "deepseek": {defaultAPIBase: "https://api.deepseek.com/v1"}, "cerebras": {defaultAPIBase: "https://api.cerebras.ai/v1"}, "vivgrid": {defaultAPIBase: "https://api.vivgrid.com/v1"}, @@ -239,7 +240,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err return finalizeProviderFromConfig(provider, modelID, cfg) case "litellm", "lmstudio", "openrouter", "groq", "zhipu", "nvidia", "venice", - "ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras", + "ollama", "moonshot", "shengsuanyun", "siliconflow", "deepseek", "cerebras", "vivgrid", "volcengine", "vllm", "qwen", "qwen-portal", "qwen-intl", "qwen-international", "dashscope-intl", "qwen-us", "dashscope-us", "mistral", "avian", "longcat", "modelscope", "novita", "coding-plan", "alibaba-coding", "qwen-coding", "zai", "mimo": diff --git a/pkg/providers/factory_provider_test.go b/pkg/providers/factory_provider_test.go index eb9b3d600..69903711f 100644 --- a/pkg/providers/factory_provider_test.go +++ b/pkg/providers/factory_provider_test.go @@ -204,6 +204,7 @@ func TestCreateProviderFromConfig_DefaultAPIBase(t *testing.T) { {"openrouter", "openrouter"}, {"cerebras", "cerebras"}, {"vivgrid", "vivgrid"}, + {"siliconflow", "siliconflow"}, {"qwen", "qwen"}, {"vllm", "vllm"}, {"deepseek", "deepseek"}, @@ -253,6 +254,12 @@ func TestGetDefaultAPIBase_Venice(t *testing.T) { } } +func TestGetDefaultAPIBase_SiliconFlow(t *testing.T) { + if got := getDefaultAPIBase("siliconflow"); got != "https://api.siliconflow.cn/v1" { + t.Fatalf("getDefaultAPIBase(%q) = %q, want %q", "siliconflow", got, "https://api.siliconflow.cn/v1") + } +} + func TestCreateProviderFromConfig_LiteLLM(t *testing.T) { cfg := &config.ModelConfig{ ModelName: "test-litellm", @@ -477,6 +484,28 @@ func TestCreateProviderFromConfig_Venice(t *testing.T) { } } +func TestCreateProviderFromConfig_SiliconFlow(t *testing.T) { + cfg := &config.ModelConfig{ + ModelName: "test-siliconflow", + Model: "siliconflow/deepseek-ai/DeepSeek-V3", + } + cfg.SetAPIKey("test-key") + + provider, modelID, err := CreateProviderFromConfig(cfg) + if err != nil { + t.Fatalf("CreateProviderFromConfig() error = %v", err) + } + if provider == nil { + t.Fatal("CreateProviderFromConfig() returned nil provider") + } + if modelID != "deepseek-ai/DeepSeek-V3" { + t.Errorf("modelID = %q, want %q", modelID, "deepseek-ai/DeepSeek-V3") + } + if _, ok := provider.(*HTTPProvider); !ok { + t.Fatalf("expected *HTTPProvider, got %T", provider) + } +} + func TestGetDefaultAPIBase_Mimo(t *testing.T) { if got := getDefaultAPIBase("mimo"); got != "https://api.xiaomimimo.com/v1" { t.Fatalf("getDefaultAPIBase(%q) = %q, want %q", "mimo", got, "https://api.xiaomimimo.com/v1") @@ -974,6 +1003,15 @@ func TestModelProviderOptions(t *testing.T) { } else if !option.EmptyAPIKeyAllowed { t.Fatal("lmstudio should allow empty API keys") } + if option, ok := seen["siliconflow"]; !ok { + t.Fatal("siliconflow option missing") + } else if option.DefaultAPIBase != "https://api.siliconflow.cn/v1" { + t.Fatalf( + "siliconflow default_api_base = %q, want %q", + option.DefaultAPIBase, + "https://api.siliconflow.cn/v1", + ) + } if option, ok := seen["anthropic"]; !ok { t.Fatal("anthropic option missing") } else if option.DefaultAPIBase != "https://api.anthropic.com/v1" { diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go index 59b655104..c154544cc 100644 --- a/pkg/providers/openai_compat/provider.go +++ b/pkg/providers/openai_compat/provider.go @@ -48,21 +48,22 @@ type Option func(*Provider) const defaultRequestTimeout = common.DefaultRequestTimeout var stripModelPrefixProviders = map[string]struct{}{ - "litellm": {}, - "venice": {}, - "moonshot": {}, - "nvidia": {}, - "groq": {}, - "ollama": {}, - "deepseek": {}, - "google": {}, - "openrouter": {}, - "zhipu": {}, - "mistral": {}, - "vivgrid": {}, - "minimax": {}, - "novita": {}, - "lmstudio": {}, + "litellm": {}, + "venice": {}, + "moonshot": {}, + "nvidia": {}, + "groq": {}, + "ollama": {}, + "deepseek": {}, + "google": {}, + "openrouter": {}, + "siliconflow": {}, + "zhipu": {}, + "mistral": {}, + "vivgrid": {}, + "minimax": {}, + "novita": {}, + "lmstudio": {}, } func WithMaxTokensField(maxTokensField string) Option { diff --git a/pkg/providers/openai_compat/provider_test.go b/pkg/providers/openai_compat/provider_test.go index 0f1568c2f..5caf30397 100644 --- a/pkg/providers/openai_compat/provider_test.go +++ b/pkg/providers/openai_compat/provider_test.go @@ -931,6 +931,11 @@ func TestProviderChat_StripsKnownProviderPrefixes(t *testing.T) { input: "vivgrid/auto", wantModel: "auto", }, + { + name: "strips siliconflow prefix and keeps nested model", + input: "siliconflow/deepseek-ai/DeepSeek-V3", + wantModel: "deepseek-ai/DeepSeek-V3", + }, { name: "strips novita prefix deepseek model", input: "novita/deepseek/deepseek-v3.2", @@ -1041,6 +1046,16 @@ func TestNormalizeModel_UsesAPIBase(t *testing.T) { if got := normalizeModel("vivgrid/auto", "https://api.vivgrid.com/v1"); got != "auto" { t.Fatalf("normalizeModel(vivgrid auto) = %q, want %q", got, "auto") } + if got := normalizeModel( + "siliconflow/deepseek-ai/DeepSeek-V3", + "https://api.siliconflow.cn/v1", + ); got != "deepseek-ai/DeepSeek-V3" { + t.Fatalf( + "normalizeModel(siliconflow) = %q, want %q", + got, + "deepseek-ai/DeepSeek-V3", + ) + } if got := normalizeModel( "novita/deepseek/deepseek-v3.2", "https://api.novita.ai/openai", @@ -1606,6 +1621,7 @@ func TestProviderChat_PromptCacheKeyOmittedForNonOpenAI(t *testing.T) { {"gemini", "https://generativelanguage.googleapis.com/v1beta"}, {"deepseek", "https://api.deepseek.com/v1"}, {"groq", "https://api.groq.com/openai/v1"}, + {"siliconflow", "https://api.siliconflow.cn/v1"}, {"minimax", "https://api.minimaxi.com/v1"}, {"ollama_local", "http://localhost:11434/v1"}, } diff --git a/web/backend/api/models.go b/web/backend/api/models.go index 33124f46f..dd14c54a4 100644 --- a/web/backend/api/models.go +++ b/web/backend/api/models.go @@ -25,7 +25,8 @@ var fetchableProviders = map[string]bool{ "volcengine": true, "zhipu": true, "groq": true, "mistral": true, "nvidia": true, "cerebras": true, "venice": true, "shengsuanyun": true, "vivgrid": true, - "minimax": true, "longcat": true, "modelscope": true, + "siliconflow": true, + "minimax": true, "longcat": true, "modelscope": true, "mimo": true, "avian": true, "zai": true, "novita": true, "litellm": true, "vllm": true, "lmstudio": true, "ollama": true, } diff --git a/web/backend/api/models_test.go b/web/backend/api/models_test.go index 71b6b279c..ad6b30284 100644 --- a/web/backend/api/models_test.go +++ b/web/backend/api/models_test.go @@ -1819,6 +1819,15 @@ func TestHandleListModels_ReturnsProviderOptionsWithoutPersistingLegacyMigration } else if !option.EmptyAPIKeyAllowed { t.Fatal("lmstudio should allow empty api keys") } + if option, ok := optionsByID["siliconflow"]; !ok { + t.Fatal("siliconflow provider option missing") + } else if option.DefaultAPIBase != "https://api.siliconflow.cn/v1" { + t.Fatalf( + "siliconflow default_api_base = %q, want %q", + option.DefaultAPIBase, + "https://api.siliconflow.cn/v1", + ) + } if option, ok := optionsByID["bedrock"]; !ok { t.Fatal("bedrock provider option missing") } else if !option.CreateAllowed { @@ -2391,3 +2400,56 @@ func TestFetchOpenAICompatibleModels_NoAuthHeaderWhenKeyEmpty(t *testing.T) { t.Fatalf("Authorization = %q, want empty", gotAuth) } } + +func TestHandleFetchModels_SiliconFlowUsesOpenAICompatibleEndpoint(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + var gotPath string + var gotAuth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotAuth = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"data":[{"id":"deepseek-ai/DeepSeek-V3","owned_by":"siliconflow"}]}`) + })) + defer srv.Close() + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/models/fetch", bytes.NewBufferString(fmt.Sprintf(`{ + "provider":"siliconflow", + "api_key":"sk-siliconflow", + "api_base":"%s" + }`, srv.URL))) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + if gotPath != "/models" { + t.Fatalf("path = %q, want %q", gotPath, "/models") + } + if gotAuth != "Bearer sk-siliconflow" { + t.Fatalf("Authorization = %q, want %q", gotAuth, "Bearer sk-siliconflow") + } + + var resp struct { + Models []upstreamModel `json:"models"` + Total int `json:"total"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if resp.Total != 1 || len(resp.Models) != 1 { + t.Fatalf("response = %+v, want one fetched model", resp) + } + if resp.Models[0].ID != "deepseek-ai/DeepSeek-V3" { + t.Fatalf("model id = %q, want %q", resp.Models[0].ID, "deepseek-ai/DeepSeek-V3") + } +} diff --git a/web/frontend/src/components/models/provider-registry.ts b/web/frontend/src/components/models/provider-registry.ts index cc18ccde6..f434a5ef5 100644 --- a/web/frontend/src/components/models/provider-registry.ts +++ b/web/frontend/src/components/models/provider-registry.ts @@ -289,6 +289,17 @@ export const PROVIDERS: ProviderDefinition[] = [ priority: 44, supportsFetch: true, }, + { + key: "siliconflow", + label: "SiliconFlow", + labelZh: "硅基流动", + domain: "siliconflow.cn", + defaultApiBase: "https://api.siliconflow.cn/v1", + requiresApiKey: true, + isLocal: false, + priority: 43.5, + supportsFetch: true, + }, { key: "vivgrid", label: "Vivgrid",