feat(provider): add gpt4free openai-compatible provider (#2909)

This commit is contained in:
LC 2026-05-21 16:08:46 +08:00 committed by GitHub
parent f55d7a0598
commit 5bbebb5fc8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 143 additions and 6 deletions

View file

@ -191,7 +191,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
} }
return finalizeProviderFromConfig(provider, modelID, cfg) 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", "ollama", "moonshot", "shengsuanyun", "siliconflow", "deepseek", "cerebras",
"vivgrid", "volcengine", "vllm", "qwen-portal", "qwen-intl", "qwen-us", "mistral", "vivgrid", "volcengine", "vllm", "qwen-portal", "qwen-intl", "qwen-us", "mistral",
"avian", "longcat", "modelscope", "novita", "alibaba-coding", "zai", "mimo": "avian", "longcat", "modelscope", "novita", "alibaba-coding", "zai", "mimo":

View file

@ -210,6 +210,7 @@ func TestCreateProviderFromConfig_DefaultAPIBase(t *testing.T) {
{"deepseek", "deepseek"}, {"deepseek", "deepseek"},
{"ollama", "ollama"}, {"ollama", "ollama"},
{"lmstudio", "lmstudio"}, {"lmstudio", "lmstudio"},
{"gpt4free", "gpt4free"},
{"longcat", "longcat"}, {"longcat", "longcat"},
{"modelscope", "modelscope"}, {"modelscope", "modelscope"},
{"mimo", "mimo"}, {"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) { func TestGetDefaultAPIBase_Venice(t *testing.T) {
if got := getDefaultAPIBase("venice"); got != "https://api.venice.ai/api/v1" { 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") 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: "", apiKey: "",
wantModelID: "Qwen/Qwen3-8B", 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 { for _, tt := range tests {
@ -1010,6 +1027,19 @@ func TestModelProviderOptions(t *testing.T) {
} else if !option.EmptyAPIKeyAllowed { } else if !option.EmptyAPIKeyAllowed {
t.Fatal("lmstudio should allow empty API keys") 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 { if option, ok := seen["siliconflow"]; !ok {
t.Fatal("siliconflow option missing") t.Fatal("siliconflow option missing")
} else if option.DefaultAPIBase != "https://api.siliconflow.cn/v1" { } else if option.DefaultAPIBase != "https://api.siliconflow.cn/v1" {

View file

@ -76,6 +76,7 @@ func TestNormalizeProvider(t *testing.T) {
{"claudecli", "claude-cli"}, {"claudecli", "claude-cli"},
{"codexcli", "codex-cli"}, {"codexcli", "codex-cli"},
{"copilot", "github-copilot"}, {"copilot", "github-copilot"},
{"g4f", "gpt4free"},
// Alibaba Coding Plan aliases // Alibaba Coding Plan aliases
{"alibaba-coding", "alibaba-coding"}, {"alibaba-coding", "alibaba-coding"},
{"coding-plan", "alibaba-coding"}, {"coding-plan", "alibaba-coding"},

View file

@ -361,6 +361,20 @@ var modelProviderOptionsByName = map[string]ModelProviderOption{
Priority: 48, Priority: 48,
httpAPI: true, 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": { "elevenlabs": {
ID: "elevenlabs", ID: "elevenlabs",
DisplayName: "ElevenLabs ASR", DisplayName: "ElevenLabs ASR",

View file

@ -218,7 +218,7 @@ func runLocalModelProbe(m *config.ModelConfig) bool {
switch protocol { switch protocol {
case "ollama": case "ollama":
return probeOllamaModelFunc(apiBase, modelID) return probeOllamaModelFunc(apiBase, modelID)
case "vllm", "lmstudio": case "vllm", "lmstudio", "gpt4free":
return probeOpenAICompatibleModelFunc(apiBase, modelID, m.APIKey()) return probeOpenAICompatibleModelFunc(apiBase, modelID, m.APIKey())
case "github-copilot": case "github-copilot":
return probeTCPServiceFunc(apiBase) return probeTCPServiceFunc(apiBase)
@ -563,16 +563,29 @@ func probeOpenAICompatibleModel(apiBase, modelID, apiKey string) bool {
return false return false
} }
var resp struct { var respEnvelope struct {
Data []struct { Data []struct {
ID string `json:"id"` ID string `json:"id"`
} `json:"data"` } `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 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) { if strings.EqualFold(strings.TrimSpace(model.ID), modelID) {
return true return true
} }

View file

@ -1,6 +1,7 @@
package api package api
import ( import (
"fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"sync" "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) { func TestRequiresRuntimeProbe_LMStudio(t *testing.T) {
if !requiresRuntimeProbe(&config.ModelConfig{ if !requiresRuntimeProbe(&config.ModelConfig{
Model: "lmstudio/openai/gpt-oss-20b", 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) { func TestModelProbeAPIBase_LMStudioDefault(t *testing.T) {
got := modelProbeAPIBase(&config.ModelConfig{Model: "lmstudio/openai/gpt-oss-20b"}) got := modelProbeAPIBase(&config.ModelConfig{Model: "lmstudio/openai/gpt-oss-20b"})
if got != "http://localhost:1234/v1" { 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) { func TestProbeLocalModelAvailability_LMStudioUsesOpenAICompatibleProbe(t *testing.T) {
originalProbe := probeOpenAICompatibleModelFunc originalProbe := probeOpenAICompatibleModelFunc
defer func() { probeOpenAICompatibleModelFunc = originalProbe }() 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) { func TestModelProbeCacheKey_DifferentAPIKeysProduceDifferentKeys(t *testing.T) {
base := &config.ModelConfig{ base := &config.ModelConfig{
ModelName: "local-vllm", ModelName: "local-vllm",

View file

@ -1030,7 +1030,7 @@ func probeModelConnectivity(m *config.ModelConfig) bool {
switch protocol { switch protocol {
case "ollama": case "ollama":
return probeOllamaModel(apiBase, modelID) return probeOllamaModel(apiBase, modelID)
case "vllm", "lmstudio": case "vllm", "lmstudio", "gpt4free":
return probeOpenAICompatibleModel(apiBase, modelID, m.APIKey()) return probeOpenAICompatibleModel(apiBase, modelID, m.APIKey())
case "github-copilot": case "github-copilot":
return probeTCPService(apiBase) return probeTCPService(apiBase)

View file

@ -1939,6 +1939,19 @@ func TestHandleListModels_ReturnsProviderOptionsWithoutPersistingLegacyMigration
} else if !option.EmptyAPIKeyAllowed { } else if !option.EmptyAPIKeyAllowed {
t.Fatal("lmstudio should allow empty api keys") 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 { if option, ok := optionsByID["siliconflow"]; !ok {
t.Fatal("siliconflow provider option missing") t.Fatal("siliconflow provider option missing")
} else if option.DefaultAPIBase != "https://api.siliconflow.cn/v1" { } else if option.DefaultAPIBase != "https://api.siliconflow.cn/v1" {