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
8 changed files with 143 additions and 6 deletions
+1 -1
View File
@@ -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":
+30
View File
@@ -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" {
+1
View File
@@ -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"},
+14
View File
@@ -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",
+17 -4
View File
@@ -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
}
+66
View File
@@ -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",
+1 -1
View File
@@ -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)
+13
View File
@@ -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" {