feat(provider): add API Route OpenAI-compatible provider

This commit is contained in:
DennyHo0917 committed 2026-09-06 19:05:35 +08:00
1 parent 8ecd3c029f
commit d67e1b0909
18 files changed
+162 -4

No files matched your search

+2
View File
@@ -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)
+19
View File
@@ -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)
+53
View File
@@ -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)
}
}
+1
View File
@@ -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},
+5 -1
View File
@@ -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":
+7 -2
View File
@@ -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
}
+22
View File
@@ -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
+4
View File
@@ -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 != "" ||
+2
View File
@@ -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)
+27
View File
@@ -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) {
+3
View File
@@ -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)
+1 -1
View File
@@ -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 },
+2
View File
@@ -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:
+5
View File
@@ -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, "")
+6
View File
@@ -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.
@@ -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 },
+1
View File
@@ -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" },
@@ -17,6 +17,7 @@ type ProvidersData = Record<string, ProviderEntry>;
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" },