From d67e1b09091f5b4790956d4bc1db9704b5dfe081 Mon Sep 17 00:00:00 2001 From: DennyHo0917 Date: Sun, 6 Sep 2026 19:05:35 +0800 Subject: [PATCH] feat(provider): add API Route OpenAI-compatible provider --- cmd/doctor.go | 2 + cmd/gateway_providers.go | 19 +++++++ cmd/gateway_providers_api_route_test.go | 53 +++++++++++++++++++ cmd/onboard_managed.go | 1 + cmd/providers_cmd.go | 6 ++- cmd/setup_provider.go | 9 +++- docs/02-providers.md | 22 ++++++++ internal/config/config_channels.go | 4 ++ internal/config/config_load.go | 2 + internal/config/config_load_test.go | 27 ++++++++++ internal/config/config_secrets.go | 3 ++ internal/http/openapi_spec.json | 2 +- internal/http/provider_models.go | 2 + internal/http/providers.go | 5 ++ internal/store/provider_store.go | 6 +++ .../frontend/src/constants/providers.ts | 1 + ui/web/src/constants/providers.ts | 1 + .../config/sections/providers-section.tsx | 1 + 18 files changed, 162 insertions(+), 4 deletions(-) create mode 100644 cmd/gateway_providers_api_route_test.go diff --git a/cmd/doctor.go b/cmd/doctor.go index 8edeaa1d..387ad759 100644 --- a/cmd/doctor.go +++ b/cmd/doctor.go @@ -98,10 +98,12 @@ func runDoctor() { checkDBProviders(db) checkProvider("Anthropic (env)", cfg.Providers.Anthropic.APIKey) checkProvider("OpenAI (env)", cfg.Providers.OpenAI.APIKey) + checkProvider("API Route (env)", cfg.Providers.APIRoute.APIKey) checkProvider("OpenRouter (env)", cfg.Providers.OpenRouter.APIKey) } else { checkProvider("Anthropic", cfg.Providers.Anthropic.APIKey) checkProvider("OpenAI", cfg.Providers.OpenAI.APIKey) + checkProvider("API Route", cfg.Providers.APIRoute.APIKey) checkProvider("OpenRouter", cfg.Providers.OpenRouter.APIKey) checkProvider("Gemini", cfg.Providers.Gemini.APIKey) checkProvider("Groq", cfg.Providers.Groq.APIKey) diff --git a/cmd/gateway_providers.go b/cmd/gateway_providers.go index 26a34099..3b3db769 100644 --- a/cmd/gateway_providers.go +++ b/cmd/gateway_providers.go @@ -43,6 +43,17 @@ func registerProviders(registry *providers.Registry, cfg *config.Config, modelRe slog.Info("registered provider", "name", "openai") } + if cfg.Providers.APIRoute.APIKey != "" { + base := cfg.Providers.APIRoute.APIBase + if base == "" { + base = store.APIRouteDefaultAPIBase + } + prov := providers.NewOpenAIProvider("api_route", cfg.Providers.APIRoute.APIKey, base, store.APIRouteDefaultModel) + prov.WithProviderType(store.ProviderAPIRoute) + registry.Register(prov) + slog.Info("registered provider", "name", "api_route") + } + if cfg.Providers.OpenRouter.APIKey != "" { orProv := providers.NewOpenAIProvider("openrouter", cfg.Providers.OpenRouter.APIKey, "https://openrouter.ai/api/v1", "anthropic/claude-sonnet-4-5-20250929") orProv.WithSiteInfo("https://goclaw.sh", "GoClaw") @@ -410,6 +421,14 @@ func registerProvidersFromDB(registry *providers.Registry, provStore store.Provi "User-Agent": store.KimiCodingRequiredUserAgent, }) registry.RegisterForTenant(p.TenantID, prov) + case store.ProviderAPIRoute: + base := p.APIBase + if base == "" { + base = store.APIRouteDefaultAPIBase + } + prov := providers.NewOpenAIProvider(p.Name, p.APIKey, base, store.APIRouteDefaultModel) + prov.WithProviderType(p.ProviderType) + registry.RegisterForTenant(p.TenantID, prov) default: prov := providers.NewOpenAIProvider(p.Name, p.APIKey, p.APIBase, "") prov.WithProviderType(p.ProviderType) diff --git a/cmd/gateway_providers_api_route_test.go b/cmd/gateway_providers_api_route_test.go new file mode 100644 index 00000000..6a163d64 --- /dev/null +++ b/cmd/gateway_providers_api_route_test.go @@ -0,0 +1,53 @@ +package cmd + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/nextlevelbuilder/goclaw/internal/config" + "github.com/nextlevelbuilder/goclaw/internal/providers" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +func TestRegisterProvidersAPIRouteDefaultsAndAuth(t *testing.T) { + var gotPath, gotAuth string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotAuth = r.Header.Get("Authorization") + _ = json.NewEncoder(w).Encode(map[string]any{ + "choices": []map[string]any{{ + "message": map[string]string{"content": "ok"}, + "finish_reason": "stop", + }}, + }) + })) + t.Cleanup(server.Close) + + cfg := &config.Config{} + cfg.Providers.APIRoute.APIKey = "api-route-key" + cfg.Providers.APIRoute.APIBase = server.URL + registry := providers.NewRegistry(nil) + registerProviders(registry, cfg, providers.NewInMemoryRegistry()) + + p, err := registry.GetForTenant(providers.MasterTenantID, "api_route") + if err != nil { + t.Fatalf("GetForTenant() error = %v", err) + } + if p.DefaultModel() != store.APIRouteDefaultModel { + t.Fatalf("DefaultModel() = %q, want %q", p.DefaultModel(), store.APIRouteDefaultModel) + } + if _, err := p.Chat(context.Background(), providers.ChatRequest{ + Messages: []providers.Message{{Role: "user", Content: "hi"}}, + }); err != nil { + t.Fatalf("Chat() error = %v", err) + } + if gotPath != "/chat/completions" { + t.Fatalf("request path = %q, want /chat/completions", gotPath) + } + if gotAuth != "Bearer api-route-key" { + t.Fatalf("Authorization = %q, want Bearer api-route-key", gotAuth) + } +} diff --git a/cmd/onboard_managed.go b/cmd/onboard_managed.go index 88ab8fd4..7a333dd6 100644 --- a/cmd/onboard_managed.go +++ b/cmd/onboard_managed.go @@ -27,6 +27,7 @@ func testPostgresConnection(dsn string) error { // defaultPlaceholderProviders defines disabled placeholder providers seeded for // UI discoverability. Users can later enable and configure them via the dashboard. var defaultPlaceholderProviders = []store.LLMProviderData{ + {Name: "api_route", DisplayName: "API Route", ProviderType: store.ProviderAPIRoute, APIBase: store.APIRouteDefaultAPIBase, Enabled: false}, {Name: "openrouter", DisplayName: "OpenRouter", ProviderType: store.ProviderOpenRouter, APIBase: "https://openrouter.ai/api/v1", Enabled: false}, {Name: "synthetic", DisplayName: "Synthetic", ProviderType: store.ProviderOpenAICompat, APIBase: "https://api.synthetic.new/openai/v1", Enabled: false}, {Name: "alicloud-api", DisplayName: "AliCloud API", ProviderType: store.ProviderDashScope, APIBase: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", Enabled: false}, diff --git a/cmd/providers_cmd.go b/cmd/providers_cmd.go index 4ad8581e..b7e4bb53 100644 --- a/cmd/providers_cmd.go +++ b/cmd/providers_cmd.go @@ -7,6 +7,7 @@ import ( "os" "text/tabwriter" + "github.com/nextlevelbuilder/goclaw/internal/store" "github.com/spf13/cobra" ) @@ -123,6 +124,7 @@ func runProvidersAdd() { typeOptions := []SelectOption[string]{ {"Anthropic", "anthropic"}, {"OpenAI", "openai"}, + {"API Route", "api_route"}, {"OpenRouter", "openrouter"}, {"DashScope (Alibaba)", "dashscope"}, {"OpenAI-compatible", "openai-compat"}, @@ -150,7 +152,7 @@ func runProvidersAdd() { // Step 4: Base URL (pre-fill per type, editable) defaultURL := defaultBaseURL(providerType) baseURL := "" - if providerType == "openai-compat" { + if providerType == "openai-compat" || providerType == "api_route" { baseURL, err = promptString("Base URL", "e.g. https://api.example.com/v1", defaultURL) if err != nil { fmt.Println("Cancelled.") @@ -310,6 +312,8 @@ func defaultBaseURL(providerType string) string { return "https://api.anthropic.com" case "openai": return "https://api.openai.com/v1" + case "api_route": + return store.APIRouteDefaultAPIBase case "openrouter": return "https://openrouter.ai/api/v1" case "dashscope": diff --git a/cmd/setup_provider.go b/cmd/setup_provider.go index 0e9ffbee..054efc50 100644 --- a/cmd/setup_provider.go +++ b/cmd/setup_provider.go @@ -47,6 +47,7 @@ func addProvider() { typeOptions := []SelectOption[string]{ {"Anthropic", "anthropic"}, {"OpenAI", "openai"}, + {"API Route", "api_route"}, {"OpenRouter", "openrouter"}, {"DashScope (Alibaba)", "dashscope"}, {"OpenAI-compatible", "openai-compat"}, @@ -68,8 +69,12 @@ func addProvider() { } baseURL := "" - if providerType == "openai-compat" { - baseURL, err = promptString("Base URL", "e.g. https://api.example.com/v1", "") + if providerType == "openai-compat" || providerType == "api_route" { + defaultURL := "" + if providerType == "api_route" { + defaultURL = "https://global.api-route.com/v1" + } + baseURL, err = promptString("Base URL", "e.g. https://api.example.com/v1", defaultURL) if err != nil { return } diff --git a/docs/02-providers.md b/docs/02-providers.md index 1a1885a9..dfad91fa 100644 --- a/docs/02-providers.md +++ b/docs/02-providers.md @@ -95,6 +95,7 @@ Supported price units: input, output, cache read, cache write, reasoning, reques | Provider | API Base | Default Model | Notes | |----------|----------|---------------|-------| | openai | `https://api.openai.com/v1` | `gpt-4o` | | +| api_route | `https://global.api-route.com/v1` | `gpt-5.4-mini` | API Route branded OpenAI-compatible endpoint | | openrouter | `https://openrouter.ai/api/v1` | `anthropic/claude-sonnet-4-5-20250929` | Model must contain `/` | | groq | `https://api.groq.com/openai/v1` | `llama-3.3-70b-versatile` | | | deepseek | `https://api.deepseek.com/v1` | `deepseek-chat` | | @@ -111,6 +112,27 @@ Supported price units: input, output, cache read, cache write, reasoning, reques | byteplus | `https://ark.ap-southeast.bytepluses.com/api/v3` | `seed-2-0-lite-260228` | Seed 2.0 models | | byteplus_coding | `https://ark.ap-southeast.bytepluses.com/api/coding/v3` | `seed-2-0-lite-260228` | Seed 2.0 Coding Plan | +### API Route setup + +Set `GOCLAW_API_ROUTE_API_KEY` in the environment, or add the key through the setup wizard. The base URL and model below are defaults and can be overridden: + +```json5 +{ + providers: { + api_route: { + api_key: "your-api-route-key", + api_base: "https://global.api-route.com/v1" + } + }, + agents: { + defaults: { + provider: "api_route", + model: "gpt-5.4-mini" + } + } +} +``` + --- ## 3. Call Flow diff --git a/internal/config/config_channels.go b/internal/config/config_channels.go index 52585c63..81f5856c 100644 --- a/internal/config/config_channels.go +++ b/internal/config/config_channels.go @@ -247,6 +247,7 @@ type FeishuConfig struct { type ProvidersConfig struct { Anthropic ProviderConfig `json:"anthropic"` OpenAI ProviderConfig `json:"openai"` + APIRoute ProviderConfig `json:"api_route"` // API Route (OpenAI-compatible endpoint) OpenRouter ProviderConfig `json:"openrouter"` Groq ProviderConfig `json:"groq"` Gemini ProviderConfig `json:"gemini"` @@ -319,6 +320,8 @@ func (p *ProvidersConfig) APIBaseForType(providerType string) string { return p.Anthropic.APIBase case "openai", "openai_compat": return p.OpenAI.APIBase + case "api_route": + return p.APIRoute.APIBase case "openrouter": return p.OpenRouter.APIBase case "groq": @@ -366,6 +369,7 @@ func (c *Config) HasAnyProvider() bool { p := c.Providers return p.Anthropic.APIKey != "" || p.OpenAI.APIKey != "" || + p.APIRoute.APIKey != "" || p.OpenRouter.APIKey != "" || p.Groq.APIKey != "" || p.Gemini.APIKey != "" || diff --git a/internal/config/config_load.go b/internal/config/config_load.go index e215bad3..e05e9cd2 100644 --- a/internal/config/config_load.go +++ b/internal/config/config_load.go @@ -156,6 +156,8 @@ func (c *Config) applyEnvOverrides() { envStr("GOCLAW_ANTHROPIC_BASE_URL", &c.Providers.Anthropic.APIBase) envStr("GOCLAW_OPENAI_API_KEY", &c.Providers.OpenAI.APIKey) envStr("GOCLAW_OPENAI_BASE_URL", &c.Providers.OpenAI.APIBase) + envStr("GOCLAW_API_ROUTE_API_KEY", &c.Providers.APIRoute.APIKey) + envStr("GOCLAW_API_ROUTE_BASE_URL", &c.Providers.APIRoute.APIBase) envStr("GOCLAW_OPENROUTER_API_KEY", &c.Providers.OpenRouter.APIKey) envStr("GOCLAW_GROQ_API_KEY", &c.Providers.Groq.APIKey) envStr("GOCLAW_DEEPSEEK_API_KEY", &c.Providers.DeepSeek.APIKey) diff --git a/internal/config/config_load_test.go b/internal/config/config_load_test.go index 912c04bc..5568d28b 100644 --- a/internal/config/config_load_test.go +++ b/internal/config/config_load_test.go @@ -296,6 +296,33 @@ func TestLoad_EnvVarAPIKeys(t *testing.T) { } } +func TestLoad_APIRouteProviderFromFileAndEnv(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.json5") + if err := os.WriteFile(cfgPath, []byte(`{ + "providers": { + "api_route": { + "api_key": "file-key", + "api_base": "https://example.com/v1" + } + } + }`), 0644); err != nil { + t.Fatalf("write config: %v", err) + } + + t.Setenv("GOCLAW_API_ROUTE_API_KEY", "env-key") + cfg, err := Load(cfgPath) + if err != nil { + t.Fatalf("load error: %v", err) + } + if cfg.Providers.APIRoute.APIKey != "env-key" { + t.Fatalf("API key = %q, want GOCLAW_API_ROUTE_API_KEY override", cfg.Providers.APIRoute.APIKey) + } + if cfg.Providers.APIRoute.APIBase != "https://example.com/v1" { + t.Fatalf("API base = %q, want file value", cfg.Providers.APIRoute.APIBase) + } +} + // --- Allowed origins from JSON5 --- func TestLoad_AllowedOrigins_JSON5(t *testing.T) { diff --git a/internal/config/config_secrets.go b/internal/config/config_secrets.go index 99e6a8a0..307ef23b 100644 --- a/internal/config/config_secrets.go +++ b/internal/config/config_secrets.go @@ -23,6 +23,7 @@ func (c *Config) MaskedCopy() *Config { // Mask provider API keys maskNonEmpty(&cp.Providers.Anthropic.APIKey) maskNonEmpty(&cp.Providers.OpenAI.APIKey) + maskNonEmpty(&cp.Providers.APIRoute.APIKey) maskNonEmpty(&cp.Providers.OpenRouter.APIKey) maskNonEmpty(&cp.Providers.Groq.APIKey) maskNonEmpty(&cp.Providers.DeepSeek.APIKey) @@ -71,6 +72,7 @@ func (c *Config) StripSecrets() { // Provider API keys c.Providers.Anthropic.APIKey = "" c.Providers.OpenAI.APIKey = "" + c.Providers.APIRoute.APIKey = "" c.Providers.OpenRouter.APIKey = "" c.Providers.Groq.APIKey = "" c.Providers.DeepSeek.APIKey = "" @@ -124,6 +126,7 @@ func (c *Config) StripMaskedSecrets() { // Provider API keys stripIfMasked(&c.Providers.Anthropic.APIKey) stripIfMasked(&c.Providers.OpenAI.APIKey) + stripIfMasked(&c.Providers.APIRoute.APIKey) stripIfMasked(&c.Providers.OpenRouter.APIKey) stripIfMasked(&c.Providers.Groq.APIKey) stripIfMasked(&c.Providers.DeepSeek.APIKey) diff --git a/internal/http/openapi_spec.json b/internal/http/openapi_spec.json index 784119e1..e315fd5c 100644 --- a/internal/http/openapi_spec.json +++ b/internal/http/openapi_spec.json @@ -1209,7 +1209,7 @@ "properties": { "name": { "type": "string", "description": "Unique provider slug" }, "display_name": { "type": "string" }, - "provider_type": { "type": "string", "enum": ["anthropic_native", "openai_compat", "gemini_native", "openrouter", "groq", "deepseek", "mistral", "xai", "minimax_native", "cohere", "perplexity", "dashscope", "bailian", "chatgpt_oauth", "claude_cli", "yescale", "zai", "zai_coding", "ollama", "ollama_cloud", "acp"] }, + "provider_type": { "type": "string", "enum": ["api_route", "anthropic_native", "openai_compat", "gemini_native", "openrouter", "groq", "deepseek", "mistral", "xai", "minimax_native", "cohere", "perplexity", "dashscope", "bailian", "chatgpt_oauth", "claude_cli", "yescale", "zai", "zai_coding", "ollama", "ollama_cloud", "acp"] }, "api_base": { "type": "string" }, "api_key": { "type": "string" }, "enabled": { "type": "boolean", "default": true }, diff --git a/internal/http/provider_models.go b/internal/http/provider_models.go index 2ab10072..85a0adf6 100644 --- a/internal/http/provider_models.go +++ b/internal/http/provider_models.go @@ -129,6 +129,8 @@ func openAIModelsAPIBase(providerType, apiBase string) string { return base } switch providerType { + case store.ProviderAPIRoute: + return store.APIRouteDefaultAPIBase case store.ProviderKimiCoding: return store.KimiCodingDefaultAPIBase default: diff --git a/internal/http/providers.go b/internal/http/providers.go index f4a76b17..f7772a5c 100644 --- a/internal/http/providers.go +++ b/internal/http/providers.go @@ -284,6 +284,11 @@ func (h *ProvidersHandler) registerInMemory(p *store.LLMProviderData) providerRu } apiBase := h.resolveAPIBase(p) switch p.ProviderType { + case store.ProviderAPIRoute: + if apiBase == "" { + apiBase = store.APIRouteDefaultAPIBase + } + h.providerReg.RegisterForTenant(p.TenantID, providers.NewOpenAIProvider(p.Name, p.APIKey, apiBase, store.APIRouteDefaultModel).WithProviderType(p.ProviderType)) case store.ProviderChatGPTOAuth: ts := oauth.NewDBTokenSource(h.store, h.secretStore, p.Name).WithTenantID(p.TenantID) codex := providers.NewCodexProvider(p.Name, ts, apiBase, "") diff --git a/internal/store/provider_store.go b/internal/store/provider_store.go index 1a0d08a5..9e81824b 100644 --- a/internal/store/provider_store.go +++ b/internal/store/provider_store.go @@ -35,6 +35,7 @@ const ( ProviderBytePlusCoding = "byteplus_coding" // BytePlus ModelArk Coding Plan ProviderVertex = "vertex" // Google Cloud Vertex AI (OAuth2 service account + ADC) ProviderKimiCoding = "kimi_coding" // Moonshot Kimi Coding (OpenAI-compat, requires fixed User-Agent) + ProviderAPIRoute = "api_route" // API Route (OpenAI-compatible endpoint) // Novita AI defaults. NovitaDefaultAPIBase = "https://api.novita.ai/openai" @@ -51,6 +52,10 @@ const ( KimiCodingDefaultAPIBase = "https://api.kimi.com/coding/v1" KimiCodingDefaultModel = "kimi-k2-turbo-preview" KimiCodingRequiredUserAgent = "claude-code/0.1.0" + + // API Route defaults. + APIRouteDefaultAPIBase = "https://global.api-route.com/v1" + APIRouteDefaultModel = "gpt-5.4-mini" ) // Vertex AI constants live in internal/providers/vertex.go to avoid a store→providers import cycle @@ -85,6 +90,7 @@ var ValidProviderTypes = map[string]bool{ ProviderBytePlusCoding: true, ProviderVertex: true, ProviderKimiCoding: true, + ProviderAPIRoute: true, } // VertexProviderSettings holds Vertex-specific config stored in llm_providers.settings JSONB. diff --git a/ui/desktop/frontend/src/constants/providers.ts b/ui/desktop/frontend/src/constants/providers.ts index 8cc17084..474bdc1a 100644 --- a/ui/desktop/frontend/src/constants/providers.ts +++ b/ui/desktop/frontend/src/constants/providers.ts @@ -6,6 +6,7 @@ export interface ProviderTypeInfo { } export const PROVIDER_TYPES: ProviderTypeInfo[] = [ + { value: 'api_route', label: 'API Route', apiBase: 'https://global.api-route.com/v1', needsKey: true }, { value: 'anthropic_native', label: 'Anthropic (Native)', apiBase: '', needsKey: true }, { value: 'openai_compat', label: 'OpenAI Compatible', apiBase: '', needsKey: true }, { value: 'gemini_native', label: 'Google Gemini', apiBase: 'https://generativelanguage.googleapis.com/v1beta/openai', needsKey: true }, diff --git a/ui/web/src/constants/providers.ts b/ui/web/src/constants/providers.ts index d82aba46..9d85c7e6 100644 --- a/ui/web/src/constants/providers.ts +++ b/ui/web/src/constants/providers.ts @@ -12,6 +12,7 @@ type ProviderAliasSource = string | { name?: string | null }; export const DEFAULT_CODEX_OAUTH_ALIAS = "openai-codex"; export const PROVIDER_TYPES: ProviderTypeInfo[] = [ + { value: "api_route", label: "API Route", apiBase: "https://global.api-route.com/v1", placeholder: "" }, { value: "chatgpt_oauth", label: "ChatGPT Subscription (OAuth)", apiBase: "", placeholder: "" }, { value: "anthropic_native", label: "Anthropic (Native)", apiBase: "", placeholder: "https://api.anthropic.com" }, { value: "openai_compat", label: "OpenAI Compatible", apiBase: "", placeholder: "https://api.openai.com/v1" }, diff --git a/ui/web/src/pages/config/sections/providers-section.tsx b/ui/web/src/pages/config/sections/providers-section.tsx index 372ccc43..1e9fc07b 100644 --- a/ui/web/src/pages/config/sections/providers-section.tsx +++ b/ui/web/src/pages/config/sections/providers-section.tsx @@ -17,6 +17,7 @@ type ProvidersData = Record; const KNOWN_PROVIDERS = [ { key: "anthropic", label: "Anthropic", envKey: "GOCLAW_ANTHROPIC_API_KEY" }, { key: "openai", label: "OpenAI", envKey: "GOCLAW_OPENAI_API_KEY" }, + { key: "api_route", label: "API Route", envKey: "GOCLAW_API_ROUTE_API_KEY" }, { key: "openrouter", label: "OpenRouter", envKey: "GOCLAW_OPENROUTER_API_KEY" }, { key: "groq", label: "Groq", envKey: "GOCLAW_GROQ_API_KEY" }, { key: "gemini", label: "Gemini", envKey: "GOCLAW_GEMINI_API_KEY" },