feat: Add support for a wider range of LLM provider types with corresponding UI and backend logic.

This commit is contained in:
viettranx committed 2026-02-27 18:22:07 +07:00
1 parent 444169860c
commit 197c15943c
9 files changed
+128 -25

No files matched your search

+2 -4
View File
@@ -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)
+42 -4
View File
@@ -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)
+10 -6
View File
@@ -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
}
}
-1
View File
@@ -118,4 +118,3 @@ func (s *PGProviderStore) decryptKey(apiKey, providerName string) string {
}
return apiKey
}
+31 -1
View File
@@ -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"`
+2 -2
View File
@@ -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);
@@ -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;
@@ -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
<DialogContent className="max-h-[85vh] flex flex-col">
<DialogHeader>
<DialogTitle>{isEdit ? "Edit Provider" : "Add Provider"}</DialogTitle>
<DialogDescription>Configure an LLM provider connection.</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4 overflow-y-auto min-h-0">
<div className="grid grid-cols-2 gap-4">
@@ -120,7 +133,16 @@ export function ProviderFormDialog({ open, onOpenChange, provider, onSubmit }: P
<div className="space-y-2">
<Label>Provider Type *</Label>
<Select value={providerType} onValueChange={setProviderType}>
<Select
value={providerType}
onValueChange={(v) => {
setProviderType(v);
if (!isEdit) {
const preset = PROVIDER_TYPES.find((t) => t.value === v);
if (preset?.apiBase) setApiBase(preset.apiBase);
}
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
@@ -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"}
/>
</div>
@@ -164,6 +186,9 @@ export function ProviderFormDialog({ open, onOpenChange, provider, onSubmit }: P
<Label htmlFor="enabled">Enabled</Label>
<Switch id="enabled" checked={enabled} onCheckedChange={setEnabled} />
</div>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={loading}>
@@ -17,6 +17,15 @@ import { usePagination } from "@/hooks/use-pagination";
const typeBadge: Record<string, { label: string; variant: "default" | "secondary" | "outline" }> = {
anthropic_native: { label: "Anthropic", variant: "default" },
openai_compat: { label: "OpenAI Compat", variant: "secondary" },
gemini_native: { label: "Gemini", variant: "secondary" },
openrouter: { label: "OpenRouter", variant: "secondary" },
groq: { label: "Groq", variant: "secondary" },
deepseek: { label: "DeepSeek", variant: "secondary" },
mistral: { label: "Mistral", variant: "secondary" },
xai: { label: "xAI", variant: "secondary" },
minimax_native: { label: "MiniMax", variant: "secondary" },
cohere: { label: "Cohere", variant: "secondary" },
perplexity: { label: "Perplexity", variant: "secondary" },
};
export function ProvidersPage() {