diff --git a/cmd/gateway_providers.go b/cmd/gateway_providers.go index 6af61593..3bd1469a 100644 --- a/cmd/gateway_providers.go +++ b/cmd/gateway_providers.go @@ -3,7 +3,6 @@ package cmd import ( "context" "log/slog" - "strings" "github.com/nextlevelbuilder/goclaw/internal/config" "github.com/nextlevelbuilder/goclaw/internal/providers" @@ -81,12 +80,11 @@ func registerProvidersFromDB(registry *providers.Registry, provStore store.Provi if !p.Enabled || p.APIKey == "" { continue } - if p.ProviderType == "anthropic_native" { + if p.ProviderType == store.ProviderAnthropicNative { registry.Register(providers.NewAnthropicProvider(p.APIKey)) } else { prov := providers.NewOpenAIProvider(p.Name, p.APIKey, p.APIBase, "") - // MiniMax native API uses a different chat path for vision support. - if p.Name == "minimax" && strings.Contains(p.APIBase, "minimax.io") { + if p.ProviderType == store.ProviderMiniMax { prov.WithChatPath("/text/chatcompletion_v2") } registry.Register(prov) diff --git a/internal/http/provider_models.go b/internal/http/provider_models.go index ec06bc2e..2fc0a1d7 100644 --- a/internal/http/provider_models.go +++ b/internal/http/provider_models.go @@ -49,15 +49,15 @@ func (h *ProvidersHandler) handleListProviderModels(w http.ResponseWriter, r *ht switch p.ProviderType { case "anthropic_native": models, err = fetchAnthropicModels(ctx, p.APIKey) - case "openai_compat": + case "gemini_native": + models, err = fetchGeminiModels(ctx, p.APIKey) + default: + // All other types use OpenAI-compatible /models endpoint apiBase := strings.TrimRight(p.APIBase, "/") if apiBase == "" { apiBase = "https://api.openai.com/v1" } models, err = fetchOpenAIModels(ctx, apiBase, p.APIKey) - default: - writeJSON(w, http.StatusBadRequest, map[string]string{"error": fmt.Sprintf("unsupported provider type: %s", p.ProviderType)}) - return } if err != nil { @@ -107,6 +107,44 @@ func fetchAnthropicModels(ctx context.Context, apiKey string) ([]ModelInfo, erro return models, nil } +// fetchGeminiModels calls the Google Gemini models API. +// Gemini uses a different format: GET /v1beta/models?key=API_KEY +func fetchGeminiModels(ctx context.Context, apiKey string) ([]ModelInfo, error) { + req, err := http.NewRequestWithContext(ctx, "GET", "https://generativelanguage.googleapis.com/v1beta/models?key="+apiKey, nil) + if err != nil { + return nil, err + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + return nil, fmt.Errorf("gemini API returned %d: %s", resp.StatusCode, string(body)) + } + + var result struct { + Models []struct { + Name string `json:"name"` // e.g. "models/gemini-2.0-flash" + DisplayName string `json:"displayName"` // e.g. "Gemini 2.0 Flash" + } `json:"models"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("failed to decode gemini response: %w", err) + } + + models := make([]ModelInfo, 0, len(result.Models)) + for _, m := range result.Models { + // Strip "models/" prefix to get the usable model ID + id := strings.TrimPrefix(m.Name, "models/") + models = append(models, ModelInfo{ID: id, Name: m.DisplayName}) + } + return models, nil +} + // fetchOpenAIModels calls an OpenAI-compatible /models endpoint. func fetchOpenAIModels(ctx context.Context, apiBase, apiKey string) ([]ModelInfo, error) { req, err := http.NewRequestWithContext(ctx, "GET", apiBase+"/models", nil) diff --git a/internal/http/providers.go b/internal/http/providers.go index 2dac589c..378d5b17 100644 --- a/internal/http/providers.go +++ b/internal/http/providers.go @@ -64,10 +64,14 @@ func (h *ProvidersHandler) registerInMemory(p *store.LLMProviderData) { if h.providerReg == nil || !p.Enabled || p.APIKey == "" { return } - if p.ProviderType == "anthropic_native" { + if p.ProviderType == store.ProviderAnthropicNative { h.providerReg.Register(providers.NewAnthropicProvider(p.APIKey)) } else { - h.providerReg.Register(providers.NewOpenAIProvider(p.Name, p.APIKey, p.APIBase, "")) + prov := providers.NewOpenAIProvider(p.Name, p.APIKey, p.APIBase, "") + if p.ProviderType == store.ProviderMiniMax { + prov.WithChatPath("/text/chatcompletion_v2") + } + h.providerReg.Register(prov) } } @@ -103,8 +107,8 @@ func (h *ProvidersHandler) handleCreateProvider(w http.ResponseWriter, r *http.R writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name must be a valid slug (lowercase letters, numbers, hyphens only)"}) return } - if p.ProviderType != "anthropic_native" && p.ProviderType != "openai_compat" { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "provider_type must be 'anthropic_native' or 'openai_compat'"}) + if !store.ValidProviderTypes[p.ProviderType] { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "unsupported provider_type"}) return } @@ -161,8 +165,8 @@ func (h *ProvidersHandler) handleUpdateProvider(w http.ResponseWriter, r *http.R // Validate provider_type if being updated if pt, ok := updates["provider_type"]; ok { - if s, _ := pt.(string); s != "anthropic_native" && s != "openai_compat" { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "provider_type must be 'anthropic_native' or 'openai_compat'"}) + if s, _ := pt.(string); !store.ValidProviderTypes[s] { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "unsupported provider_type"}) return } } diff --git a/internal/store/pg/providers.go b/internal/store/pg/providers.go index 7133ad74..b99d3747 100644 --- a/internal/store/pg/providers.go +++ b/internal/store/pg/providers.go @@ -118,4 +118,3 @@ func (s *PGProviderStore) decryptKey(apiKey, providerName string) string { } return apiKey } - diff --git a/internal/store/provider_store.go b/internal/store/provider_store.go index cd32c23e..d2e44658 100644 --- a/internal/store/provider_store.go +++ b/internal/store/provider_store.go @@ -6,12 +6,42 @@ import ( "github.com/google/uuid" ) +// Provider type constants. +const ( + ProviderAnthropicNative = "anthropic_native" + ProviderOpenAICompat = "openai_compat" + ProviderGeminiNative = "gemini_native" + ProviderOpenRouter = "openrouter" + ProviderGroq = "groq" + ProviderDeepSeek = "deepseek" + ProviderMistral = "mistral" + ProviderXAI = "xai" + ProviderMiniMax = "minimax_native" + ProviderCohere = "cohere" + ProviderPerplexity = "perplexity" +) + +// ValidProviderTypes lists all accepted provider_type values. +var ValidProviderTypes = map[string]bool{ + ProviderAnthropicNative: true, + ProviderOpenAICompat: true, + ProviderGeminiNative: true, + ProviderOpenRouter: true, + ProviderGroq: true, + ProviderDeepSeek: true, + ProviderMistral: true, + ProviderXAI: true, + ProviderMiniMax: true, + ProviderCohere: true, + ProviderPerplexity: true, +} + // LLMProviderData represents an LLM provider configuration. type LLMProviderData struct { BaseModel Name string `json:"name"` DisplayName string `json:"display_name,omitempty"` - ProviderType string `json:"provider_type"` // "anthropic_native", "openai_compat" + ProviderType string `json:"provider_type"` APIBase string `json:"api_base,omitempty"` APIKey string `json:"api_key,omitempty"` Enabled bool `json:"enabled"` diff --git a/ui/web/src/index.css b/ui/web/src/index.css index ab43c1c4..bb7d5a49 100644 --- a/ui/web/src/index.css +++ b/ui/web/src/index.css @@ -58,8 +58,8 @@ --muted-foreground: oklch(0.650 0.030 256); --accent: oklch(0.300 0.055 270); --accent-foreground: oklch(0.984 0.003 247.858); - --destructive: oklch(0.396 0.141 25.723); - --destructive-foreground: oklch(0.637 0.237 25.331); + --destructive: oklch(0.65 0.23 25); + --destructive-foreground: oklch(0.98 0.02 25); --border: oklch(0.320 0.025 260); --input: oklch(0.250 0.035 260); --ring: oklch(0.585 0.233 277.117); diff --git a/ui/web/src/pages/providers/hooks/use-providers.ts b/ui/web/src/pages/providers/hooks/use-providers.ts index cbbf7ffa..aae1f94e 100644 --- a/ui/web/src/pages/providers/hooks/use-providers.ts +++ b/ui/web/src/pages/providers/hooks/use-providers.ts @@ -5,7 +5,7 @@ export interface ProviderData { id: string; name: string; display_name: string; - provider_type: "anthropic_native" | "openai_compat"; + provider_type: string; api_base: string; api_key: string; // masked "***" from server enabled: boolean; diff --git a/ui/web/src/pages/providers/provider-form-dialog.tsx b/ui/web/src/pages/providers/provider-form-dialog.tsx index e3ece71b..20e139ed 100644 --- a/ui/web/src/pages/providers/provider-form-dialog.tsx +++ b/ui/web/src/pages/providers/provider-form-dialog.tsx @@ -5,6 +5,7 @@ import { DialogHeader, DialogTitle, DialogFooter, + DialogDescription, } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -28,8 +29,17 @@ interface ProviderFormDialogProps { } const PROVIDER_TYPES = [ - { value: "anthropic_native", label: "Anthropic Native" }, - { value: "openai_compat", label: "OpenAI Compatible" }, + { 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" }, + { value: "gemini_native", label: "Google Gemini", apiBase: "https://generativelanguage.googleapis.com/v1beta/openai", placeholder: "" }, + { value: "openrouter", label: "OpenRouter", apiBase: "https://openrouter.ai/api/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: "" }, + { value: "xai", label: "xAI (Grok)", apiBase: "https://api.x.ai/v1", placeholder: "" }, + { value: "minimax_native", label: "MiniMax (Native)", apiBase: "https://api.minimax.io/v1", placeholder: "" }, + { value: "cohere", label: "Cohere", apiBase: "https://api.cohere.ai/compatibility/v1", placeholder: "" }, + { value: "perplexity", label: "Perplexity", apiBase: "https://api.perplexity.ai", placeholder: "" }, ]; export function ProviderFormDialog({ open, onOpenChange, provider, onSubmit }: ProviderFormDialogProps) { @@ -41,9 +51,11 @@ export function ProviderFormDialog({ open, onOpenChange, provider, onSubmit }: P const [apiKey, setApiKey] = useState(""); const [enabled, setEnabled] = useState(true); const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); useEffect(() => { if (open) { + setError(""); if (provider) { setName(provider.name); setDisplayName(provider.display_name || ""); @@ -81,8 +93,8 @@ export function ProviderFormDialog({ open, onOpenChange, provider, onSubmit }: P await onSubmit(data); onOpenChange(false); - } catch { - // error handled upstream + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to save provider"); } finally { setLoading(false); } @@ -93,6 +105,7 @@ export function ProviderFormDialog({ open, onOpenChange, provider, onSubmit }: P {isEdit ? "Edit Provider" : "Add Provider"} + Configure an LLM provider connection.
@@ -120,7 +133,16 @@ export function ProviderFormDialog({ open, onOpenChange, provider, onSubmit }: P
- { + setProviderType(v); + if (!isEdit) { + const preset = PROVIDER_TYPES.find((t) => t.value === v); + if (preset?.apiBase) setApiBase(preset.apiBase); + } + }} + > @@ -140,7 +162,7 @@ export function ProviderFormDialog({ open, onOpenChange, provider, onSubmit }: P id="apiBase" value={apiBase} onChange={(e) => setApiBase(e.target.value)} - placeholder={providerType === "anthropic_native" ? "https://api.anthropic.com" : "https://openrouter.ai/api"} + placeholder={PROVIDER_TYPES.find((t) => t.value === providerType)?.placeholder || PROVIDER_TYPES.find((t) => t.value === providerType)?.apiBase || "https://api.example.com/v1"} />
@@ -164,6 +186,9 @@ export function ProviderFormDialog({ open, onOpenChange, provider, onSubmit }: P
+ {error && ( +

{error}

+ )}