diff --git a/pkg/providers/factory_provider.go b/pkg/providers/factory_provider.go index 667c6da6..7c03daab 100644 --- a/pkg/providers/factory_provider.go +++ b/pkg/providers/factory_provider.go @@ -191,7 +191,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err } return finalizeProviderFromConfig(provider, modelID, cfg) - case "litellm", "lmstudio", "openrouter", "groq", "zhipu", "nvidia", "venice", + case "litellm", "lmstudio", "gpt4free", "openrouter", "groq", "zhipu", "nvidia", "venice", "ollama", "moonshot", "shengsuanyun", "siliconflow", "deepseek", "cerebras", "vivgrid", "volcengine", "vllm", "qwen-portal", "qwen-intl", "qwen-us", "mistral", "avian", "longcat", "modelscope", "novita", "alibaba-coding", "zai", "mimo": diff --git a/pkg/providers/factory_provider_test.go b/pkg/providers/factory_provider_test.go index 09dbf8b4..2b734007 100644 --- a/pkg/providers/factory_provider_test.go +++ b/pkg/providers/factory_provider_test.go @@ -210,6 +210,7 @@ func TestCreateProviderFromConfig_DefaultAPIBase(t *testing.T) { {"deepseek", "deepseek"}, {"ollama", "ollama"}, {"lmstudio", "lmstudio"}, + {"gpt4free", "gpt4free"}, {"longcat", "longcat"}, {"modelscope", "modelscope"}, {"mimo", "mimo"}, @@ -248,6 +249,15 @@ func TestGetDefaultAPIBase_LMStudio(t *testing.T) { } } +func TestGetDefaultAPIBase_GPT4Free(t *testing.T) { + if got := getDefaultAPIBase("gpt4free"); got != "http://localhost:1337/v1" { + t.Fatalf("getDefaultAPIBase(%q) = %q, want %q", "gpt4free", got, "http://localhost:1337/v1") + } + if got := getDefaultAPIBase("g4f"); got != "http://localhost:1337/v1" { + t.Fatalf("getDefaultAPIBase(%q) = %q, want %q", "g4f", got, "http://localhost:1337/v1") + } +} + func TestGetDefaultAPIBase_Venice(t *testing.T) { if got := getDefaultAPIBase("venice"); got != "https://api.venice.ai/api/v1" { t.Fatalf("getDefaultAPIBase(%q) = %q, want %q", "venice", got, "https://api.venice.ai/api/v1") @@ -330,6 +340,13 @@ func TestCreateProviderFromConfig_LocalProviders(t *testing.T) { apiKey: "", wantModelID: "Qwen/Qwen3-8B", }, + { + name: "GPT4Free without API key", + modelName: "test-gpt4free", + model: "gpt4free/gpt-4o-mini", + apiKey: "", + wantModelID: "gpt-4o-mini", + }, } for _, tt := range tests { @@ -1010,6 +1027,19 @@ func TestModelProviderOptions(t *testing.T) { } else if !option.EmptyAPIKeyAllowed { t.Fatal("lmstudio should allow empty API keys") } + if option, ok := seen["gpt4free"]; !ok { + t.Fatal("gpt4free option missing") + } else { + if option.DefaultAPIBase != "http://localhost:1337/v1" { + t.Fatalf("gpt4free default_api_base = %q, want %q", option.DefaultAPIBase, "http://localhost:1337/v1") + } + if !option.EmptyAPIKeyAllowed { + t.Fatal("gpt4free should allow empty API keys") + } + if !option.SupportsFetch { + t.Fatal("gpt4free should support upstream model listing") + } + } if option, ok := seen["siliconflow"]; !ok { t.Fatal("siliconflow option missing") } else if option.DefaultAPIBase != "https://api.siliconflow.cn/v1" { diff --git a/pkg/providers/model_ref_test.go b/pkg/providers/model_ref_test.go index 80ea8bb6..57438a44 100644 --- a/pkg/providers/model_ref_test.go +++ b/pkg/providers/model_ref_test.go @@ -76,6 +76,7 @@ func TestNormalizeProvider(t *testing.T) { {"claudecli", "claude-cli"}, {"codexcli", "codex-cli"}, {"copilot", "github-copilot"}, + {"g4f", "gpt4free"}, // Alibaba Coding Plan aliases {"alibaba-coding", "alibaba-coding"}, {"coding-plan", "alibaba-coding"}, diff --git a/pkg/providers/provider_metadata.go b/pkg/providers/provider_metadata.go index 52d27dad..7b9c9e0e 100644 --- a/pkg/providers/provider_metadata.go +++ b/pkg/providers/provider_metadata.go @@ -361,6 +361,20 @@ var modelProviderOptionsByName = map[string]ModelProviderOption{ Priority: 48, httpAPI: true, }, + "gpt4free": { + ID: "gpt4free", + DisplayName: "GPT4Free", + Domain: "g4f.dev", + DefaultAPIBase: "http://localhost:1337/v1", + EmptyAPIKeyAllowed: true, + CreateAllowed: true, + DefaultModelAllowed: true, + SupportsFetch: true, + Local: true, + Priority: 47.5, + Aliases: []string{"g4f"}, + httpAPI: true, + }, "elevenlabs": { ID: "elevenlabs", DisplayName: "ElevenLabs ASR", diff --git a/web/backend/api/model_status.go b/web/backend/api/model_status.go index 435664d7..03666ae9 100644 --- a/web/backend/api/model_status.go +++ b/web/backend/api/model_status.go @@ -218,7 +218,7 @@ func runLocalModelProbe(m *config.ModelConfig) bool { switch protocol { case "ollama": return probeOllamaModelFunc(apiBase, modelID) - case "vllm", "lmstudio": + case "vllm", "lmstudio", "gpt4free": return probeOpenAICompatibleModelFunc(apiBase, modelID, m.APIKey()) case "github-copilot": return probeTCPServiceFunc(apiBase) @@ -563,16 +563,29 @@ func probeOpenAICompatibleModel(apiBase, modelID, apiKey string) bool { return false } - var resp struct { + var respEnvelope struct { Data []struct { ID string `json:"id"` } `json:"data"` } - if err := getJSON(strings.TrimRight(strings.TrimSpace(apiBase), "/")+"/models", &resp, apiKey); err != nil { + fetchURL := strings.TrimRight(strings.TrimSpace(apiBase), "/") + "/models" + if err := getJSON(fetchURL, &respEnvelope, apiKey); err == nil { + for _, model := range respEnvelope.Data { + if strings.EqualFold(strings.TrimSpace(model.ID), modelID) { + return true + } + } return false } - for _, model := range resp.Data { + var respArray []struct { + ID string `json:"id"` + } + if err := getJSON(fetchURL, &respArray, apiKey); err != nil { + return false + } + + for _, model := range respArray { if strings.EqualFold(strings.TrimSpace(model.ID), modelID) { return true } diff --git a/web/backend/api/model_status_test.go b/web/backend/api/model_status_test.go index d5463a85..7a8155e4 100644 --- a/web/backend/api/model_status_test.go +++ b/web/backend/api/model_status_test.go @@ -1,6 +1,7 @@ package api import ( + "fmt" "net/http" "net/http/httptest" "sync" @@ -39,6 +40,21 @@ func TestProbeLocalModelAvailability_OpenAICompatibleIncludesAPIKey(t *testing.T } } +func TestProbeOpenAICompatibleModel_SupportsBareArrayModels(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/models" { + t.Fatalf("path = %q, want %q", r.URL.Path, "/v1/models") + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `[{"id":"gpt-4o-mini"},{"id":"deepseek-r1"}]`) + })) + defer srv.Close() + + if !probeOpenAICompatibleModel(srv.URL+"/v1", "gpt-4o-mini", "") { + t.Fatal("probeOpenAICompatibleModel() = false, want true for bare array /models response") + } +} + func TestRequiresRuntimeProbe_LMStudio(t *testing.T) { if !requiresRuntimeProbe(&config.ModelConfig{ Model: "lmstudio/openai/gpt-oss-20b", @@ -54,6 +70,21 @@ func TestRequiresRuntimeProbe_LMStudio(t *testing.T) { } } +func TestRequiresRuntimeProbe_GPT4Free(t *testing.T) { + if !requiresRuntimeProbe(&config.ModelConfig{ + Model: "gpt4free/gpt-4o-mini", + }) { + t.Fatal("requiresRuntimeProbe(gpt4free with default base) = false, want true") + } + + if requiresRuntimeProbe(&config.ModelConfig{ + Model: "gpt4free/gpt-4o-mini", + APIBase: "https://g4f.space/v1", + }) { + t.Fatal("requiresRuntimeProbe(gpt4free with remote base) = true, want false") + } +} + func TestModelProbeAPIBase_LMStudioDefault(t *testing.T) { got := modelProbeAPIBase(&config.ModelConfig{Model: "lmstudio/openai/gpt-oss-20b"}) if got != "http://localhost:1234/v1" { @@ -61,6 +92,13 @@ func TestModelProbeAPIBase_LMStudioDefault(t *testing.T) { } } +func TestModelProbeAPIBase_GPT4FreeDefault(t *testing.T) { + got := modelProbeAPIBase(&config.ModelConfig{Model: "gpt4free/gpt-4o-mini"}) + if got != "http://localhost:1337/v1" { + t.Fatalf("modelProbeAPIBase(gpt4free) = %q, want %q", got, "http://localhost:1337/v1") + } +} + func TestProbeLocalModelAvailability_LMStudioUsesOpenAICompatibleProbe(t *testing.T) { originalProbe := probeOpenAICompatibleModelFunc defer func() { probeOpenAICompatibleModelFunc = originalProbe }() @@ -89,6 +127,34 @@ func TestProbeLocalModelAvailability_LMStudioUsesOpenAICompatibleProbe(t *testin } } +func TestProbeLocalModelAvailability_GPT4FreeUsesOpenAICompatibleProbe(t *testing.T) { + originalProbe := probeOpenAICompatibleModelFunc + defer func() { probeOpenAICompatibleModelFunc = originalProbe }() + + called := false + probeOpenAICompatibleModelFunc = func(apiBase, modelID, apiKey string) bool { + called = true + if apiBase != "http://localhost:1337/v1" { + t.Fatalf("apiBase = %q, want %q", apiBase, "http://localhost:1337/v1") + } + if modelID != "gpt-4o-mini" { + t.Fatalf("modelID = %q, want %q", modelID, "gpt-4o-mini") + } + if apiKey != "" { + t.Fatalf("apiKey = %q, want empty", apiKey) + } + return true + } + + model := &config.ModelConfig{Model: "gpt4free/gpt-4o-mini"} + if !probeLocalModelAvailability(model) { + t.Fatal("probeLocalModelAvailability(gpt4free) = false, want true") + } + if !called { + t.Fatal("probeOpenAICompatibleModelFunc was not called for gpt4free") + } +} + func TestModelProbeCacheKey_DifferentAPIKeysProduceDifferentKeys(t *testing.T) { base := &config.ModelConfig{ ModelName: "local-vllm", diff --git a/web/backend/api/models.go b/web/backend/api/models.go index 18bd457b..be594e3e 100644 --- a/web/backend/api/models.go +++ b/web/backend/api/models.go @@ -1030,7 +1030,7 @@ func probeModelConnectivity(m *config.ModelConfig) bool { switch protocol { case "ollama": return probeOllamaModel(apiBase, modelID) - case "vllm", "lmstudio": + case "vllm", "lmstudio", "gpt4free": return probeOpenAICompatibleModel(apiBase, modelID, m.APIKey()) case "github-copilot": return probeTCPService(apiBase) diff --git a/web/backend/api/models_test.go b/web/backend/api/models_test.go index 22b02c22..f734d214 100644 --- a/web/backend/api/models_test.go +++ b/web/backend/api/models_test.go @@ -1939,6 +1939,19 @@ func TestHandleListModels_ReturnsProviderOptionsWithoutPersistingLegacyMigration } else if !option.EmptyAPIKeyAllowed { t.Fatal("lmstudio should allow empty api keys") } + if option, ok := optionsByID["gpt4free"]; !ok { + t.Fatal("gpt4free provider option missing") + } else { + if option.DefaultAPIBase != "http://localhost:1337/v1" { + t.Fatalf("gpt4free default_api_base = %q, want %q", option.DefaultAPIBase, "http://localhost:1337/v1") + } + if !option.EmptyAPIKeyAllowed { + t.Fatal("gpt4free should allow empty api keys") + } + if !option.SupportsFetch { + t.Fatal("gpt4free provider option should report supports_fetch") + } + } if option, ok := optionsByID["siliconflow"]; !ok { t.Fatal("siliconflow provider option missing") } else if option.DefaultAPIBase != "https://api.siliconflow.cn/v1" {