mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 06:17:41 +00:00
feat: Add support for a wider range of LLM provider types with corresponding UI and backend logic.
This commit is contained in:
1 parent
444169860c
commit
197c15943c
9 files changed
+128
-25
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,4 +118,3 @@ func (s *PGProviderStore) decryptKey(apiKey, providerName string) string {
|
||||
}
|
||||
return apiKey
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in new issue
Block a user