mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-11 03:13:24 +00:00
feat(provider): add Requesty OpenAI-compatible provider (#1579)
Register Requesty (https://router.requesty.ai/v1) next to OpenRouter on the existing OpenAI-compatible transport: config and env vars (GOCLAW_REQUESTY_API_KEY, GOCLAW_REQUESTY_BASE_URL), secret masking, DB and in-memory registration, CLI setup, doctor, placeholder provider, OpenAPI enum, Web/Desktop provider lists and docs. The models list merges Requesty managed policies (GET /models/managed) with the key's catalog from GET /models.
This commit is contained in:
1 parent
b2b348a396
commit
32eb2328f3
20 files changed
+304
-4
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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, ""
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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},
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 != "" ||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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, ""
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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: "" },
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in new issue
Block a user