diff --git a/cmd/doctor.go b/cmd/doctor.go index 6034457f..559da07b 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 b266fb51..7455b159 100644 --- a/cmd/gateway_providers.go +++ b/cmd/gateway_providers.go @@ -54,6 +54,17 @@ func registerProviders(registry *providers.Registry, cfg *config.Config, modelRe slog.Info("registered provider", "name", "atlascloud") } + 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") @@ -451,6 +462,14 @@ func registerProvidersFromDB(registry *providers.Registry, provStore store.Provi prov := providers.NewAIMLAPIProvider(p.Name, p.APIKey, p.APIBase) prov.WithProviderType(p.ProviderType) 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: base, model := openAIProviderDefaults(p.ProviderType, p.APIBase) prov := providers.NewOpenAIProvider(p.Name, p.APIKey, base, model) 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 9bfdb27a..dda40b8f 100644 --- a/cmd/onboard_managed.go +++ b/cmd/onboard_managed.go @@ -29,6 +29,7 @@ func testPostgresConnection(dsn string) error { // UI discoverability. Users can later enable and configure them via the dashboard. var defaultPlaceholderProviders = []store.LLMProviderData{ {Name: "aimlapi", DisplayName: "AI/ML API", ProviderType: store.ProviderAIMLAPI, APIBase: providers.AIMLAPIDefaultAPIBase, Enabled: false}, + {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 33c0b76d..d6007736 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" ) @@ -124,6 +125,7 @@ func runProvidersAdd() { {"Anthropic", "anthropic"}, {"OpenAI", "openai"}, {"Atlas Cloud", "atlascloud"}, + {"API Route", "api_route"}, {"OpenRouter", "openrouter"}, {"DashScope (Alibaba)", "dashscope"}, {"OpenAI-compatible", "openai_compat"}, @@ -151,7 +153,7 @@ func runProvidersAdd() { // Step 4: Base URL (pre-fill per type, editable) defaultURL := defaultBaseURL(providerType) baseURL := "" - if providerType == "openai_compat" || providerType == "atlascloud" { + if providerType == "openai_compat" || providerType == "atlascloud" || providerType == "api_route" { baseURL, err = promptString("Base URL", "e.g. https://api.example.com/v1", defaultURL) if err != nil { fmt.Println("Cancelled.") @@ -313,6 +315,8 @@ func defaultBaseURL(providerType string) string { return "https://api.openai.com/v1" case "atlascloud": return "https://api.atlascloud.ai/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 73ed6e80..516c9da0 100644 --- a/cmd/setup_provider.go +++ b/cmd/setup_provider.go @@ -48,6 +48,7 @@ func addProvider() { {"Anthropic", "anthropic"}, {"OpenAI", "openai"}, {"Atlas Cloud", "atlascloud"}, + {"API Route", "api_route"}, {"OpenRouter", "openrouter"}, {"DashScope (Alibaba)", "dashscope"}, {"OpenAI-compatible", "openai_compat"}, @@ -69,10 +70,12 @@ func addProvider() { } baseURL := "" - if providerType == "openai_compat" || providerType == "atlascloud" { + if providerType == "openai_compat" || providerType == "atlascloud" || providerType == "api_route" { defaultURL := "" if providerType == "atlascloud" { defaultURL = "https://api.atlascloud.ai/v1" + } else 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 { diff --git a/docs/02-providers.md b/docs/02-providers.md index 162f6a99..c0204bfd 100644 --- a/docs/02-providers.md +++ b/docs/02-providers.md @@ -97,6 +97,7 @@ Supported price units: input, output, cache read, cache write, reasoning, reques |----------|----------|---------------|-------| | openai | `https://api.openai.com/v1` | `gpt-4o` | | | atlascloud | `https://api.atlascloud.ai/v1` | `qwen/qwen3.5-flash` | Atlas Cloud OpenAI-compatible LLM endpoint | +| 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` | | @@ -113,6 +114,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 6a7334bf..ffb04316 100644 --- a/internal/config/config_channels.go +++ b/internal/config/config_channels.go @@ -256,6 +256,7 @@ type ProvidersConfig struct { Anthropic ProviderConfig `json:"anthropic"` OpenAI ProviderConfig `json:"openai"` AtlasCloud ProviderConfig `json:"atlascloud"` // Atlas Cloud (OpenAI-compatible endpoint) + APIRoute ProviderConfig `json:"api_route"` // API Route (OpenAI-compatible endpoint) OpenRouter ProviderConfig `json:"openrouter"` Groq ProviderConfig `json:"groq"` Gemini ProviderConfig `json:"gemini"` @@ -334,6 +335,8 @@ func (p *ProvidersConfig) APIBaseForType(providerType string) string { return p.OpenAI.APIBase case "atlascloud": return p.AtlasCloud.APIBase + case "api_route": + return p.APIRoute.APIBase case "openrouter": return p.OpenRouter.APIBase case "groq": @@ -382,6 +385,7 @@ func (c *Config) HasAnyProvider() bool { return p.Anthropic.APIKey != "" || p.OpenAI.APIKey != "" || p.AtlasCloud.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 379ad88d..f70253ce 100644 --- a/internal/config/config_load.go +++ b/internal/config/config_load.go @@ -161,6 +161,8 @@ func (c *Config) applyEnvOverrides() { envStr("GOCLAW_OPENAI_BASE_URL", &c.Providers.OpenAI.APIBase) envStr("GOCLAW_ATLASCLOUD_API_KEY", &c.Providers.AtlasCloud.APIKey) envStr("GOCLAW_ATLASCLOUD_BASE_URL", &c.Providers.AtlasCloud.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 592c176e..c4e62efe 100644 --- a/internal/config/config_load_test.go +++ b/internal/config/config_load_test.go @@ -391,6 +391,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 ab35376c..2caa3730 100644 --- a/internal/config/config_secrets.go +++ b/internal/config/config_secrets.go @@ -24,6 +24,7 @@ func (c *Config) MaskedCopy() *Config { maskNonEmpty(&cp.Providers.Anthropic.APIKey) maskNonEmpty(&cp.Providers.OpenAI.APIKey) maskNonEmpty(&cp.Providers.AtlasCloud.APIKey) + maskNonEmpty(&cp.Providers.APIRoute.APIKey) maskNonEmpty(&cp.Providers.OpenRouter.APIKey) maskNonEmpty(&cp.Providers.Groq.APIKey) maskNonEmpty(&cp.Providers.DeepSeek.APIKey) @@ -74,6 +75,7 @@ func (c *Config) StripSecrets() { c.Providers.Anthropic.APIKey = "" c.Providers.OpenAI.APIKey = "" c.Providers.AtlasCloud.APIKey = "" + c.Providers.APIRoute.APIKey = "" c.Providers.OpenRouter.APIKey = "" c.Providers.Groq.APIKey = "" c.Providers.DeepSeek.APIKey = "" @@ -129,6 +131,7 @@ func (c *Config) StripMaskedSecrets() { stripIfMasked(&c.Providers.Anthropic.APIKey) stripIfMasked(&c.Providers.OpenAI.APIKey) stripIfMasked(&c.Providers.AtlasCloud.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 7dd0f29d..7bfaad85 100644 --- a/internal/http/openapi_spec.json +++ b/internal/http/openapi_spec.json @@ -10660,7 +10660,7 @@ "properties": { "name": { "type": "string", "description": "Unique provider slug" }, "display_name": { "type": "string" }, - "provider_type": { "type": "string", "enum": ["aimlapi", "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": ["aimlapi", "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 6175db7c..153fc39c 100644 --- a/internal/http/provider_models.go +++ b/internal/http/provider_models.go @@ -144,6 +144,8 @@ func openAIModelsAPIBase(providerType, apiBase string) string { switch providerType { case store.ProviderAtlasCloud: return store.AtlasCloudDefaultAPIBase + 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 ba7f6b58..8d771f3b 100644 --- a/internal/http/providers.go +++ b/internal/http/providers.go @@ -298,6 +298,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 73be871d..f9e7086a 100644 --- a/internal/store/provider_store.go +++ b/internal/store/provider_store.go @@ -37,6 +37,7 @@ const ( ProviderVertex = "vertex" // Google Cloud Vertex AI (OAuth2 service account + ADC) ProviderKimiCoding = "kimi_coding" // Moonshot Kimi Coding (OpenAI-compat, requires fixed User-Agent) ProviderAtlasCloud = "atlascloud" // Atlas Cloud (OpenAI-compatible endpoint) + ProviderAPIRoute = "api_route" // API Route (OpenAI-compatible endpoint) // MiniMax defaults. MiniMaxDefaultAPIBase = "https://api.minimax.io/v1" @@ -66,6 +67,10 @@ const ( // Atlas Cloud defaults. AtlasCloudDefaultAPIBase = "https://api.atlascloud.ai/v1" AtlasCloudDefaultModel = "qwen/qwen3.5-flash" + + // 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 @@ -102,6 +107,7 @@ var ValidProviderTypes = map[string]bool{ ProviderVertex: true, ProviderKimiCoding: true, ProviderAtlasCloud: 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 d59a7516..3e6d60ef 100644 --- a/ui/desktop/frontend/src/constants/providers.ts +++ b/ui/desktop/frontend/src/constants/providers.ts @@ -7,6 +7,7 @@ export interface ProviderTypeInfo { export const PROVIDER_TYPES: ProviderTypeInfo[] = [ { value: 'aimlapi', label: 'AI/ML API', apiBase: 'https://api.aimlapi.com/v1', needsKey: true }, + { 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 0d5647a3..3bef9d65 100644 --- a/ui/web/src/constants/providers.ts +++ b/ui/web/src/constants/providers.ts @@ -13,6 +13,7 @@ export const DEFAULT_CODEX_OAUTH_ALIAS = "openai-codex"; export const PROVIDER_TYPES: ProviderTypeInfo[] = [ { value: "aimlapi", label: "AI/ML API", apiBase: "https://api.aimlapi.com/v1", placeholder: "" }, + { 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 d9ce5303..28bfeec9 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" },