From abaaf8fc83f77fbeb1066cd7edeffe6ff7b4f9a6 Mon Sep 17 00:00:00 2001 From: viettranx Date: Mon, 23 Feb 2026 19:17:34 +0700 Subject: [PATCH] feat: Implement dynamic model selection for agents using a new backend API and a reusable combobox component. --- internal/http/provider_models.go | 144 ++++++++++++++++++ internal/http/providers.go | 2 + ui/web/package.json | 2 +- ui/web/src/components/ui/combobox.tsx | 107 +++++++++++++ .../src/pages/agents/agent-create-dialog.tsx | 27 +++- .../agents/agent-detail/agent-general-tab.tsx | 20 ++- ui/web/src/pages/agents/agents-page.tsx | 18 ++- .../providers/hooks/use-provider-models.ts | 40 +++++ 8 files changed, 339 insertions(+), 21 deletions(-) create mode 100644 internal/http/provider_models.go create mode 100644 ui/web/src/components/ui/combobox.tsx create mode 100644 ui/web/src/pages/providers/hooks/use-provider-models.ts diff --git a/internal/http/provider_models.go b/internal/http/provider_models.go new file mode 100644 index 00000000..ec06bc2e --- /dev/null +++ b/internal/http/provider_models.go @@ -0,0 +1,144 @@ +package http + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "strings" + "time" + + "github.com/google/uuid" +) + +// ModelInfo is a normalized model entry returned by the list-models endpoint. +type ModelInfo struct { + ID string `json:"id"` + Name string `json:"name,omitempty"` +} + +// handleListProviderModels proxies to the upstream provider API to list +// available models for the given provider. +// +// GET /v1/providers/{id}/models +func (h *ProvidersHandler) handleListProviderModels(w http.ResponseWriter, r *http.Request) { + id, err := uuid.Parse(r.PathValue("id")) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid provider ID"}) + return + } + + p, err := h.store.GetProvider(r.Context(), id) + if err != nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "provider not found"}) + return + } + + if p.APIKey == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "provider has no API key configured"}) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) + defer cancel() + + var models []ModelInfo + + switch p.ProviderType { + case "anthropic_native": + models, err = fetchAnthropicModels(ctx, p.APIKey) + case "openai_compat": + 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 { + slog.Warn("providers.models", "provider", p.Name, "error", err) + // Return empty list instead of error — provider may not support /models + writeJSON(w, http.StatusOK, map[string]interface{}{"models": []ModelInfo{}}) + return + } + + writeJSON(w, http.StatusOK, map[string]interface{}{"models": models}) +} + +// fetchAnthropicModels calls the Anthropic models API. +func fetchAnthropicModels(ctx context.Context, apiKey string) ([]ModelInfo, error) { + req, err := http.NewRequestWithContext(ctx, "GET", "https://api.anthropic.com/v1/models", nil) + if err != nil { + return nil, err + } + req.Header.Set("x-api-key", apiKey) + req.Header.Set("anthropic-version", "2023-06-01") + + 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("anthropic API returned %d: %s", resp.StatusCode, string(body)) + } + + var result struct { + Data []struct { + ID string `json:"id"` + DisplayName string `json:"display_name"` + } `json:"data"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("failed to decode anthropic response: %w", err) + } + + models := make([]ModelInfo, 0, len(result.Data)) + for _, m := range result.Data { + models = append(models, ModelInfo{ID: m.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) + 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 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + return nil, fmt.Errorf("provider API returned %d: %s", resp.StatusCode, string(body)) + } + + var result struct { + Data []struct { + ID string `json:"id"` + OwnedBy string `json:"owned_by"` + } `json:"data"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("failed to decode provider response: %w", err) + } + + models := make([]ModelInfo, 0, len(result.Data)) + for _, m := range result.Data { + models = append(models, ModelInfo{ID: m.ID, Name: m.OwnedBy}) + } + return models, nil +} diff --git a/internal/http/providers.go b/internal/http/providers.go index 89d4afa4..645eb97a 100644 --- a/internal/http/providers.go +++ b/internal/http/providers.go @@ -30,6 +30,8 @@ func (h *ProvidersHandler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("PUT /v1/providers/{id}", h.auth(h.handleUpdateProvider)) mux.HandleFunc("DELETE /v1/providers/{id}", h.auth(h.handleDeleteProvider)) + // Model listing (proxied to upstream provider API) + mux.HandleFunc("GET /v1/providers/{id}/models", h.auth(h.handleListProviderModels)) } func (h *ProvidersHandler) auth(next http.HandlerFunc) http.HandlerFunc { diff --git a/ui/web/package.json b/ui/web/package.json index e4bc10f2..199af9ee 100644 --- a/ui/web/package.json +++ b/ui/web/package.json @@ -36,5 +36,5 @@ "vite": "^6.0.0", "ws": "^8.19.0" }, - "packageManager": "pnpm@10.28.2+sha512.41872f037ad22f7348e3b1debbaf7e867cfd448f2726d9cf74c08f19507c31d2c8e7a11525b983febc2df640b5438dee6023ebb1f84ed43cc2d654d2bc326264" + "packageManager": "pnpm@10.30.1+sha512.3590e550d5384caa39bd5c7c739f72270234b2f6059e13018f975c313b1eb9fefcc09714048765d4d9efe961382c312e624572c0420762bdc5d5940cdf9be73a" } diff --git a/ui/web/src/components/ui/combobox.tsx b/ui/web/src/components/ui/combobox.tsx new file mode 100644 index 00000000..cec88cb5 --- /dev/null +++ b/ui/web/src/components/ui/combobox.tsx @@ -0,0 +1,107 @@ +import * as React from "react"; +import { ChevronDownIcon, CheckIcon } from "lucide-react"; +import { cn } from "@/lib/utils"; + +export interface ComboboxOption { + value: string; + label?: string; +} + +interface ComboboxProps { + value: string; + onChange: (value: string) => void; + options: ComboboxOption[]; + placeholder?: string; + className?: string; +} + +export function Combobox({ + value, + onChange, + options, + placeholder, + className, +}: ComboboxProps) { + const [open, setOpen] = React.useState(false); + const [search, setSearch] = React.useState(""); + const containerRef = React.useRef(null); + + // Sync search text when value changes externally + React.useEffect(() => { + setSearch(value); + }, [value]); + + // Close on outside click + React.useEffect(() => { + if (!open) return; + const handler = (e: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setOpen(false); + } + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, [open]); + + const filtered = React.useMemo(() => { + if (!search) return options; + const q = search.toLowerCase(); + return options.filter( + (o) => + o.value.toLowerCase().includes(q) || + (o.label && o.label.toLowerCase().includes(q)), + ); + }, [options, search]); + + const handleSelect = (val: string) => { + onChange(val); + setSearch(val); + setOpen(false); + }; + + const handleInputChange = (e: React.ChangeEvent) => { + const val = e.target.value; + setSearch(val); + onChange(val); + if (!open && options.length > 0) setOpen(true); + }; + + return ( +
+ options.length > 0 && setOpen(true)} + placeholder={placeholder} + className={cn( + "border-input placeholder:text-muted-foreground dark:bg-input/30 h-9 w-full rounded-md border bg-transparent px-3 py-1 pr-8 text-sm shadow-xs outline-none transition-[color,box-shadow]", + "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", + )} + /> + {options.length > 0 && ( + setOpen(!open)} + /> + )} + {open && filtered.length > 0 && ( +
+ {filtered.map((o) => ( + + ))} +
+ )} +
+ ); +} diff --git a/ui/web/src/pages/agents/agent-create-dialog.tsx b/ui/web/src/pages/agents/agent-create-dialog.tsx index 2b8a34f4..b41edbf1 100644 --- a/ui/web/src/pages/agents/agent-create-dialog.tsx +++ b/ui/web/src/pages/agents/agent-create-dialog.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useMemo } from "react"; import { Dialog, DialogContent, @@ -16,9 +16,11 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { Combobox } from "@/components/ui/combobox"; import type { AgentData } from "@/types/agent"; import { slugify, isValidSlug } from "@/lib/slug"; import { useProviders } from "@/pages/providers/hooks/use-providers"; +import { useProviderModels } from "@/pages/providers/hooks/use-provider-models"; interface AgentCreateDialogProps { open: boolean; @@ -35,6 +37,15 @@ export function AgentCreateDialog({ open, onOpenChange, onCreate }: AgentCreateD const [agentType, setAgentType] = useState<"open" | "predefined">("open"); const [loading, setLoading] = useState(false); + const enabledProviders = providers.filter((p) => p.enabled); + + // Look up provider ID from selected provider name for model fetching + const selectedProviderId = useMemo( + () => enabledProviders.find((p) => p.name === provider)?.id, + [enabledProviders, provider], + ); + const { models, loading: modelsLoading } = useProviderModels(selectedProviderId); + const handleCreate = async () => { if (!agentKey.trim()) return; setLoading(true); @@ -60,7 +71,10 @@ export function AgentCreateDialog({ open, onOpenChange, onCreate }: AgentCreateD } }; - const enabledProviders = providers.filter((p) => p.enabled); + const handleProviderChange = (value: string) => { + setProvider(value); + setModel(""); + }; return ( @@ -92,7 +106,7 @@ export function AgentCreateDialog({ open, onOpenChange, onCreate }: AgentCreateD
{enabledProviders.length > 0 ? ( - @@ -114,10 +128,11 @@ export function AgentCreateDialog({ open, onOpenChange, onCreate }: AgentCreateD
- setModel(e.target.value)} - placeholder="anthropic/claude-sonnet-4-5-20250929" + onChange={setModel} + options={models.map((m) => ({ value: m.id, label: m.name }))} + placeholder={modelsLoading ? "Loading models..." : "Enter or select model"} />
diff --git a/ui/web/src/pages/agents/agent-detail/agent-general-tab.tsx b/ui/web/src/pages/agents/agent-detail/agent-general-tab.tsx index 3c240cab..9a600d59 100644 --- a/ui/web/src/pages/agents/agent-detail/agent-general-tab.tsx +++ b/ui/web/src/pages/agents/agent-detail/agent-general-tab.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useMemo } from "react"; import { Save, Copy, Check, AlertCircle } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -12,7 +12,9 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { Combobox } from "@/components/ui/combobox"; import { useProviders } from "@/pages/providers/hooks/use-providers"; +import { useProviderModels } from "@/pages/providers/hooks/use-provider-models"; import type { AgentData } from "@/types/agent"; interface AgentGeneralTabProps { @@ -27,6 +29,12 @@ export function AgentGeneralTab({ agent, onUpdate }: AgentGeneralTabProps) { const [displayName, setDisplayName] = useState(agent.display_name ?? ""); const [provider, setProvider] = useState(agent.provider); const [model, setModel] = useState(agent.model); + + const selectedProviderId = useMemo( + () => enabledProviders.find((p) => p.name === provider)?.id, + [enabledProviders, provider], + ); + const { models, loading: modelsLoading } = useProviderModels(selectedProviderId); const [contextWindow, setContextWindow] = useState(agent.context_window); const [maxToolIterations, setMaxToolIterations] = useState(agent.max_tool_iterations); const [workspace, setWorkspace] = useState(agent.workspace); @@ -135,7 +143,7 @@ export function AgentGeneralTab({ agent, onUpdate }: AgentGeneralTabProps) {
{enabledProviders.length > 0 ? ( - { setProvider(v); setModel(""); }}> @@ -157,11 +165,11 @@ export function AgentGeneralTab({ agent, onUpdate }: AgentGeneralTabProps) {
- setModel(e.target.value)} - placeholder="anthropic/claude-sonnet-4-5-20250929" + onChange={setModel} + options={models.map((m) => ({ value: m.id, label: m.name }))} + placeholder={modelsLoading ? "Loading models..." : "Enter or select model"} />
diff --git a/ui/web/src/pages/agents/agents-page.tsx b/ui/web/src/pages/agents/agents-page.tsx index fe7bb937..94c32199 100644 --- a/ui/web/src/pages/agents/agents-page.tsx +++ b/ui/web/src/pages/agents/agents-page.tsx @@ -96,14 +96,16 @@ export function AgentsPage() { /> ))} - +
+ +
)} diff --git a/ui/web/src/pages/providers/hooks/use-provider-models.ts b/ui/web/src/pages/providers/hooks/use-provider-models.ts new file mode 100644 index 00000000..439bfed6 --- /dev/null +++ b/ui/web/src/pages/providers/hooks/use-provider-models.ts @@ -0,0 +1,40 @@ +import { useState, useEffect, useCallback } from "react"; +import { useHttp } from "@/hooks/use-ws"; + +export interface ModelInfo { + id: string; + name?: string; +} + +export function useProviderModels(providerId: string | undefined) { + const http = useHttp(); + const [models, setModels] = useState([]); + const [loading, setLoading] = useState(false); + + const load = useCallback( + async (id: string) => { + setLoading(true); + try { + const res = await http.get<{ models: ModelInfo[] }>( + `/v1/providers/${id}/models`, + ); + setModels(res.models ?? []); + } catch { + setModels([]); + } finally { + setLoading(false); + } + }, + [http], + ); + + useEffect(() => { + if (!providerId) { + setModels([]); + return; + } + load(providerId); + }, [providerId, load]); + + return { models, loading }; +}