diff --git a/cmd/doctor.go b/cmd/doctor.go index 559da07b..aa916058 100644 --- a/cmd/doctor.go +++ b/cmd/doctor.go @@ -99,11 +99,13 @@ func runDoctor() { checkProvider("Anthropic (env)", cfg.Providers.Anthropic.APIKey) checkProvider("OpenAI (env)", cfg.Providers.OpenAI.APIKey) checkProvider("API Route (env)", cfg.Providers.APIRoute.APIKey) + checkProvider("Requesty (env)", cfg.Providers.Requesty.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("Requesty", cfg.Providers.Requesty.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 7455b159..a792dcee 100644 --- a/cmd/gateway_providers.go +++ b/cmd/gateway_providers.go @@ -65,6 +65,18 @@ func registerProviders(registry *providers.Registry, cfg *config.Config, modelRe slog.Info("registered provider", "name", "api_route") } + if cfg.Providers.Requesty.APIKey != "" { + base := cfg.Providers.Requesty.APIBase + if base == "" { + base = store.RequestyDefaultAPIBase + } + prov := providers.NewOpenAIProvider("requesty", cfg.Providers.Requesty.APIKey, base, store.RequestyDefaultModel) + prov.WithProviderType(store.ProviderRequesty) + prov.WithSiteInfo("https://goclaw.sh", "GoClaw") + registry.Register(prov) + slog.Info("registered provider", "name", "requesty") + } + 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") @@ -475,7 +487,7 @@ func registerProvidersFromDB(registry *providers.Registry, provStore store.Provi prov := providers.NewOpenAIProvider(p.Name, p.APIKey, base, model) prov.WithProviderType(p.ProviderType) prov.WithThinkingEnabled(store.ParseThinkingEnabled(p.Settings)) - if p.ProviderType == store.ProviderOpenRouter { + if p.ProviderType == store.ProviderOpenRouter || p.ProviderType == store.ProviderRequesty { prov.WithSiteInfo("https://goclaw.sh", "GoClaw") } registry.RegisterForTenant(p.TenantID, prov) @@ -496,6 +508,11 @@ func openAIProviderDefaults(providerType, apiBase string) (string, string) { apiBase = store.AtlasCloudDefaultAPIBase } return apiBase, store.AtlasCloudDefaultModel + case store.ProviderRequesty: + if apiBase == "" { + apiBase = store.RequestyDefaultAPIBase + } + return apiBase, store.RequestyDefaultModel default: return apiBase, "" } diff --git a/cmd/gateway_providers_requesty_test.go b/cmd/gateway_providers_requesty_test.go new file mode 100644 index 00000000..e022b6d6 --- /dev/null +++ b/cmd/gateway_providers_requesty_test.go @@ -0,0 +1,78 @@ +package cmd + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/config" + "github.com/nextlevelbuilder/goclaw/internal/providers" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +func TestRegisterProvidersRequestyDefaultsAndAuth(t *testing.T) { + var gotPath, gotAuth, gotTitle string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotAuth = r.Header.Get("Authorization") + gotTitle = r.Header.Get("X-Title") + _ = 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.Requesty.APIKey = "requesty-key" + cfg.Providers.Requesty.APIBase = server.URL + registry := providers.NewRegistry(nil) + registerProviders(registry, cfg, providers.NewInMemoryRegistry()) + + p, err := registry.GetForTenant(providers.MasterTenantID, "requesty") + if err != nil { + t.Fatalf("GetForTenant() error = %v", err) + } + if p.DefaultModel() != store.RequestyDefaultModel { + t.Fatalf("DefaultModel() = %q, want %q", p.DefaultModel(), store.RequestyDefaultModel) + } + 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 requesty-key" { + t.Fatalf("Authorization = %q, want Bearer requesty-key", gotAuth) + } + if gotTitle != "GoClaw" { + t.Fatalf("X-Title = %q, want GoClaw", gotTitle) + } +} + +func TestRegisterProvidersFromDBUsesRequestyDefaults(t *testing.T) { + tenantID := uuid.New() + providerStore := gatewayProvidersStoreStub{ + providers: []store.LLMProviderData{{ + BaseModel: store.BaseModel{ID: uuid.New()}, + TenantID: tenantID, + Name: "db-requesty", + ProviderType: store.ProviderRequesty, + APIKey: "requesty-key", + Enabled: true, + }}, + } + + registry := providers.NewRegistry(nil) + registerProvidersFromDB(registry, providerStore, nil, "", "", nil, &config.Config{}, providers.NewInMemoryRegistry()) + + assertProviderDefault(t, registry, tenantID, "db-requesty", store.RequestyDefaultModel, store.RequestyDefaultAPIBase) +} diff --git a/cmd/onboard_managed.go b/cmd/onboard_managed.go index dda40b8f..414fec9e 100644 --- a/cmd/onboard_managed.go +++ b/cmd/onboard_managed.go @@ -30,6 +30,7 @@ func testPostgresConnection(dsn string) error { 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: "requesty", DisplayName: "Requesty", ProviderType: store.ProviderRequesty, APIBase: store.RequestyDefaultAPIBase, 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 d6007736..e3ffb291 100644 --- a/cmd/providers_cmd.go +++ b/cmd/providers_cmd.go @@ -126,6 +126,7 @@ func runProvidersAdd() { {"OpenAI", "openai"}, {"Atlas Cloud", "atlascloud"}, {"API Route", "api_route"}, + {"Requesty", "requesty"}, {"OpenRouter", "openrouter"}, {"DashScope (Alibaba)", "dashscope"}, {"OpenAI-compatible", "openai_compat"}, @@ -153,7 +154,7 @@ func runProvidersAdd() { // Step 4: Base URL (pre-fill per type, editable) defaultURL := defaultBaseURL(providerType) baseURL := "" - if providerType == "openai_compat" || providerType == "atlascloud" || providerType == "api_route" { + if providerType == "openai_compat" || providerType == "atlascloud" || providerType == "api_route" || providerType == "requesty" { baseURL, err = promptString("Base URL", "e.g. https://api.example.com/v1", defaultURL) if err != nil { fmt.Println("Cancelled.") @@ -317,6 +318,8 @@ func defaultBaseURL(providerType string) string { return "https://api.atlascloud.ai/v1" case "api_route": return store.APIRouteDefaultAPIBase + case "requesty": + return store.RequestyDefaultAPIBase case "openrouter": return "https://openrouter.ai/api/v1" case "dashscope": diff --git a/cmd/setup_provider.go b/cmd/setup_provider.go index 516c9da0..d535cf12 100644 --- a/cmd/setup_provider.go +++ b/cmd/setup_provider.go @@ -49,6 +49,7 @@ func addProvider() { {"OpenAI", "openai"}, {"Atlas Cloud", "atlascloud"}, {"API Route", "api_route"}, + {"Requesty", "requesty"}, {"OpenRouter", "openrouter"}, {"DashScope (Alibaba)", "dashscope"}, {"OpenAI-compatible", "openai_compat"}, @@ -70,12 +71,14 @@ func addProvider() { } baseURL := "" - if providerType == "openai_compat" || providerType == "atlascloud" || providerType == "api_route" { + if providerType == "openai_compat" || providerType == "atlascloud" || providerType == "api_route" || providerType == "requesty" { defaultURL := "" if providerType == "atlascloud" { defaultURL = "https://api.atlascloud.ai/v1" } else if providerType == "api_route" { defaultURL = "https://global.api-route.com/v1" + } else if providerType == "requesty" { + defaultURL = "https://router.requesty.ai/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 c0204bfd..40b92d41 100644 --- a/docs/02-providers.md +++ b/docs/02-providers.md @@ -99,6 +99,7 @@ Supported price units: input, output, cache read, cache write, reasoning, reques | 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 `/` | +| requesty | `https://router.requesty.ai/v1` | `openai/gpt-4o-mini` | Requesty router; regional bases such as `https://router.eu.requesty.ai/v1` | | groq | `https://api.groq.com/openai/v1` | `llama-3.3-70b-versatile` | | | deepseek | `https://api.deepseek.com/v1` | `deepseek-chat` | | | gemini | `https://generativelanguage.googleapis.com/v1beta/openai` | `gemini-2.0-flash` | Skips empty content fields | @@ -135,6 +136,27 @@ Set `GOCLAW_API_ROUTE_API_KEY` in the environment, or add the key through the se } ``` +### Requesty setup + +Set `GOCLAW_REQUESTY_API_KEY` in the environment (keys are created at https://app.requesty.ai/api-keys), or add the key through the setup wizard. Set `GOCLAW_REQUESTY_BASE_URL` or `api_base` to use a regional endpoint, for example `https://router.eu.requesty.ai/v1` for EU routing. Models use `vendor/model` ids such as `anthropic/claude-sonnet-4-5`, or managed policy ids such as `claude-sonnet-4-5`; the provider models list shows both. + +```json5 +{ + providers: { + requesty: { + api_key: "rqsty-...", + api_base: "https://router.requesty.ai/v1" + } + }, + agents: { + defaults: { + provider: "requesty", + model: "openai/gpt-4o-mini" + } + } +} +``` + --- ## 3. Call Flow diff --git a/internal/config/config_channels.go b/internal/config/config_channels.go index ffb04316..6caef432 100644 --- a/internal/config/config_channels.go +++ b/internal/config/config_channels.go @@ -257,6 +257,7 @@ type ProvidersConfig struct { OpenAI ProviderConfig `json:"openai"` AtlasCloud ProviderConfig `json:"atlascloud"` // Atlas Cloud (OpenAI-compatible endpoint) APIRoute ProviderConfig `json:"api_route"` // API Route (OpenAI-compatible endpoint) + Requesty ProviderConfig `json:"requesty"` // Requesty (OpenAI-compatible router) OpenRouter ProviderConfig `json:"openrouter"` Groq ProviderConfig `json:"groq"` Gemini ProviderConfig `json:"gemini"` @@ -337,6 +338,8 @@ func (p *ProvidersConfig) APIBaseForType(providerType string) string { return p.AtlasCloud.APIBase case "api_route": return p.APIRoute.APIBase + case "requesty": + return p.Requesty.APIBase case "openrouter": return p.OpenRouter.APIBase case "groq": @@ -386,6 +389,7 @@ func (c *Config) HasAnyProvider() bool { p.OpenAI.APIKey != "" || p.AtlasCloud.APIKey != "" || p.APIRoute.APIKey != "" || + p.Requesty.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 f70253ce..c3ea42ac 100644 --- a/internal/config/config_load.go +++ b/internal/config/config_load.go @@ -163,6 +163,8 @@ func (c *Config) applyEnvOverrides() { 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_REQUESTY_API_KEY", &c.Providers.Requesty.APIKey) + envStr("GOCLAW_REQUESTY_BASE_URL", &c.Providers.Requesty.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 c4e62efe..7ea54da8 100644 --- a/internal/config/config_load_test.go +++ b/internal/config/config_load_test.go @@ -418,6 +418,33 @@ func TestLoad_APIRouteProviderFromFileAndEnv(t *testing.T) { } } +func TestLoad_RequestyProviderFromFileAndEnv(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.json5") + if err := os.WriteFile(cfgPath, []byte(`{ + "providers": { + "requesty": { + "api_key": "file-key" + } + } + }`), 0644); err != nil { + t.Fatalf("write config: %v", err) + } + + t.Setenv("GOCLAW_REQUESTY_API_KEY", "env-key") + t.Setenv("GOCLAW_REQUESTY_BASE_URL", "https://router.eu.requesty.ai/v1") + cfg, err := Load(cfgPath) + if err != nil { + t.Fatalf("load error: %v", err) + } + if cfg.Providers.Requesty.APIKey != "env-key" { + t.Fatalf("API key = %q, want GOCLAW_REQUESTY_API_KEY override", cfg.Providers.Requesty.APIKey) + } + if cfg.Providers.Requesty.APIBase != "https://router.eu.requesty.ai/v1" { + t.Fatalf("API base = %q, want GOCLAW_REQUESTY_BASE_URL override", cfg.Providers.Requesty.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 2caa3730..381cb2c7 100644 --- a/internal/config/config_secrets.go +++ b/internal/config/config_secrets.go @@ -25,6 +25,7 @@ func (c *Config) MaskedCopy() *Config { maskNonEmpty(&cp.Providers.OpenAI.APIKey) maskNonEmpty(&cp.Providers.AtlasCloud.APIKey) maskNonEmpty(&cp.Providers.APIRoute.APIKey) + maskNonEmpty(&cp.Providers.Requesty.APIKey) maskNonEmpty(&cp.Providers.OpenRouter.APIKey) maskNonEmpty(&cp.Providers.Groq.APIKey) maskNonEmpty(&cp.Providers.DeepSeek.APIKey) @@ -76,6 +77,7 @@ func (c *Config) StripSecrets() { c.Providers.OpenAI.APIKey = "" c.Providers.AtlasCloud.APIKey = "" c.Providers.APIRoute.APIKey = "" + c.Providers.Requesty.APIKey = "" c.Providers.OpenRouter.APIKey = "" c.Providers.Groq.APIKey = "" c.Providers.DeepSeek.APIKey = "" @@ -132,6 +134,7 @@ func (c *Config) StripMaskedSecrets() { stripIfMasked(&c.Providers.OpenAI.APIKey) stripIfMasked(&c.Providers.AtlasCloud.APIKey) stripIfMasked(&c.Providers.APIRoute.APIKey) + stripIfMasked(&c.Providers.Requesty.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 7bfaad85..a49bf3aa 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", "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"] }, + "provider_type": { "type": "string", "enum": ["aimlapi", "api_route", "anthropic_native", "openai_compat", "gemini_native", "openrouter", "requesty", "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 153fc39c..26bdbe43 100644 --- a/internal/http/provider_models.go +++ b/internal/http/provider_models.go @@ -111,6 +111,8 @@ func (h *ProvidersHandler) handleListProviderModels(w http.ResponseWriter, r *ht models = zaiModels() case store.ProviderAIMLAPI: models = aimlapiModels() + case store.ProviderRequesty: + models, err = fetchRequestyModels(ctx, openAIModelsAPIBase(p.ProviderType, h.resolveAPIBase(p)), p.APIKey) default: // All other types use OpenAI-compatible /models endpoint apiBase := openAIModelsAPIBase(p.ProviderType, h.resolveAPIBase(p)) @@ -146,6 +148,8 @@ func openAIModelsAPIBase(providerType, apiBase string) string { return store.AtlasCloudDefaultAPIBase case store.ProviderAPIRoute: return store.APIRouteDefaultAPIBase + case store.ProviderRequesty: + return store.RequestyDefaultAPIBase case store.ProviderKimiCoding: return store.KimiCodingDefaultAPIBase default: diff --git a/internal/http/provider_models_fetch.go b/internal/http/provider_models_fetch.go index 678ffb16..3c36ad30 100644 --- a/internal/http/provider_models_fetch.go +++ b/internal/http/provider_models_fetch.go @@ -129,6 +129,64 @@ func fetchOpenAIModels(ctx context.Context, apiBase, apiKey string, extraHeaders return models, nil } +// fetchRequestyModels lists Requesty managed policies (GET /models/managed, +// curated ids such as "claude-sonnet-4-5") followed by the key's model catalog +// from GET /models ("vendor/model" ids). The managed list is best effort; the +// catalog call validates the key, so its error is returned. +func fetchRequestyModels(ctx context.Context, apiBase, apiKey string) ([]ModelInfo, error) { + catalog, err := fetchOpenAIModels(ctx, apiBase, apiKey, nil) + if err != nil { + return nil, err + } + managed, err := fetchRequestyManagedModels(ctx, apiBase, apiKey) + if err != nil { + return catalog, nil + } + seen := make(map[string]bool, len(managed)+len(catalog)) + models := make([]ModelInfo, 0, len(managed)+len(catalog)) + for _, m := range append(managed, catalog...) { + if seen[m.ID] { + continue + } + seen[m.ID] = true + models = append(models, m) + } + return models, nil +} + +func fetchRequestyManagedModels(ctx context.Context, apiBase, apiKey string) ([]ModelInfo, error) { + req, err := http.NewRequestWithContext(ctx, "GET", apiBase+"/models/managed", nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+apiKey) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("requesty managed models returned %d", resp.StatusCode) + } + + var result struct { + Data []struct { + ID string `json:"id"` + } `json:"data"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("failed to decode requesty managed models: %w", err) + } + + models := make([]ModelInfo, 0, len(result.Data)) + for _, m := range result.Data { + models = append(models, ModelInfo{ID: m.ID, Name: m.ID}) + } + return models, nil +} + // fetchOllamaModels calls Ollama's native /api/tags endpoint to get model metadata // including parameter size, quantization level, and model family. // The api_base may include a /v1 suffix (from issue #654 normalization) — strip it diff --git a/internal/http/provider_models_test.go b/internal/http/provider_models_test.go index 75252799..19d117d0 100644 --- a/internal/http/provider_models_test.go +++ b/internal/http/provider_models_test.go @@ -397,6 +397,67 @@ func TestOpenAIModelsAPIBaseDefaultsKimiCoding(t *testing.T) { } } +func TestProvidersHandlerListProviderModelsRequestyMergesManagedAndCatalog(t *testing.T) { + token := setupProvidersAdminToken(t) + var capturedAuth string + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedAuth = r.Header.Get("Authorization") + var ids []string + switch r.URL.Path { + case "/models/managed": + ids = []string{"claude-sonnet-4-5", "gpt-5-mini@eu"} + case "/models": + ids = []string{"openai/gpt-4o-mini", "claude-sonnet-4-5"} + default: + http.NotFound(w, r) + return + } + data := make([]map[string]string, 0, len(ids)) + for _, id := range ids { + data = append(data, map[string]string{"id": id}) + } + _ = json.NewEncoder(w).Encode(map[string]any{"object": "list", "data": data}) + })) + t.Cleanup(upstream.Close) + + providerStore := newMockProviderStore() + provider := &store.LLMProviderData{ + BaseModel: store.BaseModel{ID: uuid.New()}, + Name: "requesty", + ProviderType: store.ProviderRequesty, + APIBase: upstream.URL, + APIKey: "requesty-key", + Enabled: true, + } + if err := providerStore.CreateProvider(t.Context(), provider); err != nil { + t.Fatalf("CreateProvider() error = %v", err) + } + + handler := NewProvidersHandler(providerStore, newMockSecretsStore(), nil, "") + mux := http.NewServeMux() + handler.RegisterRoutes(mux) + + result := providerModelsRequest(t, mux, provider.ID, token) + if capturedAuth != "Bearer requesty-key" { + t.Fatalf("Authorization = %q, want Bearer requesty-key", capturedAuth) + } + want := []string{"claude-sonnet-4-5", "gpt-5-mini@eu", "openai/gpt-4o-mini"} + if len(result.Models) != len(want) { + t.Fatalf("models = %#v, want %v", result.Models, want) + } + for index, model := range result.Models { + if model.ID != want[index] { + t.Errorf("models[%d].ID = %q, want %q", index, model.ID, want[index]) + } + } +} + +func TestOpenAIModelsAPIBaseDefaultsRequesty(t *testing.T) { + if got := openAIModelsAPIBase(store.ProviderRequesty, ""); got != store.RequestyDefaultAPIBase { + t.Fatalf("Requesty default api base = %q, want %q", got, store.RequestyDefaultAPIBase) + } +} + func TestProvidersHandlerListProviderModelsAIMLAPIUsesCuratedCatalog(t *testing.T) { token := setupProvidersAdminToken(t) providerStore := newMockProviderStore() diff --git a/internal/http/providers.go b/internal/http/providers.go index 8d771f3b..49040753 100644 --- a/internal/http/providers.go +++ b/internal/http/providers.go @@ -410,6 +410,11 @@ func openAIProviderDefaults(providerType, apiBase string) (string, string) { apiBase = store.AtlasCloudDefaultAPIBase } return apiBase, store.AtlasCloudDefaultModel + case store.ProviderRequesty: + if apiBase == "" { + apiBase = store.RequestyDefaultAPIBase + } + return apiBase, store.RequestyDefaultModel default: return apiBase, "" } diff --git a/internal/store/provider_store.go b/internal/store/provider_store.go index f9e7086a..2c3cc41f 100644 --- a/internal/store/provider_store.go +++ b/internal/store/provider_store.go @@ -38,6 +38,7 @@ const ( 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) + ProviderRequesty = "requesty" // Requesty (OpenAI-compatible router) // MiniMax defaults. MiniMaxDefaultAPIBase = "https://api.minimax.io/v1" @@ -71,6 +72,11 @@ const ( // API Route defaults. APIRouteDefaultAPIBase = "https://global.api-route.com/v1" APIRouteDefaultModel = "gpt-5.4-mini" + + // Requesty defaults. Regional endpoints (e.g. https://router.eu.requesty.ai/v1) + // can be set through api_base. + RequestyDefaultAPIBase = "https://router.requesty.ai/v1" + RequestyDefaultModel = "openai/gpt-4o-mini" ) // Vertex AI constants live in internal/providers/vertex.go to avoid a store→providers import cycle @@ -108,6 +114,7 @@ var ValidProviderTypes = map[string]bool{ ProviderKimiCoding: true, ProviderAtlasCloud: true, ProviderAPIRoute: true, + ProviderRequesty: 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 3e6d60ef..613a9748 100644 --- a/ui/desktop/frontend/src/constants/providers.ts +++ b/ui/desktop/frontend/src/constants/providers.ts @@ -13,6 +13,7 @@ export const PROVIDER_TYPES: ProviderTypeInfo[] = [ { value: 'gemini_native', label: 'Google Gemini', apiBase: 'https://generativelanguage.googleapis.com/v1beta/openai', needsKey: true }, { value: 'vertex', label: 'Google Vertex AI', apiBase: '', needsKey: false }, { value: 'openrouter', label: 'OpenRouter', apiBase: 'https://openrouter.ai/api/v1', needsKey: true }, + { value: 'requesty', label: 'Requesty', apiBase: 'https://router.requesty.ai/v1', needsKey: true }, { value: 'groq', label: 'Groq', apiBase: 'https://api.groq.com/openai/v1', needsKey: true }, { value: 'deepseek', label: 'DeepSeek', apiBase: 'https://api.deepseek.com/v1', needsKey: true }, { value: 'mistral', label: 'Mistral AI', apiBase: 'https://api.mistral.ai/v1', needsKey: true }, diff --git a/ui/web/src/constants/providers.ts b/ui/web/src/constants/providers.ts index 3bef9d65..fa7de871 100644 --- a/ui/web/src/constants/providers.ts +++ b/ui/web/src/constants/providers.ts @@ -20,6 +20,7 @@ export const PROVIDER_TYPES: ProviderTypeInfo[] = [ { value: "gemini_native", label: "Google Gemini", apiBase: "https://generativelanguage.googleapis.com/v1beta/openai", placeholder: "" }, { value: "vertex", label: "Google Vertex AI", apiBase: "", placeholder: "Auto-computed from project_id + region (settings)" }, { value: "openrouter", label: "OpenRouter", apiBase: "https://openrouter.ai/api/v1", placeholder: "" }, + { value: "requesty", label: "Requesty", apiBase: "https://router.requesty.ai/v1", placeholder: "" }, { value: "groq", label: "Groq", apiBase: "https://api.groq.com/openai/v1", placeholder: "" }, { value: "deepseek", label: "DeepSeek", apiBase: "https://api.deepseek.com/v1", placeholder: "" }, { value: "mistral", label: "Mistral AI", apiBase: "https://api.mistral.ai/v1", placeholder: "" }, diff --git a/ui/web/src/pages/config/sections/providers-section.tsx b/ui/web/src/pages/config/sections/providers-section.tsx index 28bfeec9..309ac3c1 100644 --- a/ui/web/src/pages/config/sections/providers-section.tsx +++ b/ui/web/src/pages/config/sections/providers-section.tsx @@ -19,6 +19,7 @@ const KNOWN_PROVIDERS = [ { 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: "requesty", label: "Requesty", envKey: "GOCLAW_REQUESTY_API_KEY" }, { key: "groq", label: "Groq", envKey: "GOCLAW_GROQ_API_KEY" }, { key: "gemini", label: "Gemini", envKey: "GOCLAW_GEMINI_API_KEY" }, { key: "deepseek", label: "DeepSeek", envKey: "GOCLAW_DEEPSEEK_API_KEY" },