feat: Implement dynamic model selection for agents using a new backend API and a reusable combobox component.

This commit is contained in:
viettranx
2026-02-23 19:17:34 +07:00
parent 3d63e419a5
commit abaaf8fc83
8 changed files with 339 additions and 21 deletions
+144
View File
@@ -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
}
+2
View File
@@ -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 {
+1 -1
View File
@@ -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"
}
+107
View File
@@ -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<HTMLDivElement>(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<HTMLInputElement>) => {
const val = e.target.value;
setSearch(val);
onChange(val);
if (!open && options.length > 0) setOpen(true);
};
return (
<div ref={containerRef} className={cn("relative", className)}>
<input
value={search}
onChange={handleInputChange}
onFocus={() => 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 && (
<ChevronDownIcon
className="text-muted-foreground absolute top-1/2 right-2.5 size-4 -translate-y-1/2 cursor-pointer opacity-50"
onClick={() => setOpen(!open)}
/>
)}
{open && filtered.length > 0 && (
<div className="bg-popover text-popover-foreground absolute top-full left-0 z-50 mt-1 max-h-60 w-full overflow-y-auto rounded-md border p-1 shadow-md">
{filtered.map((o) => (
<button
key={o.value}
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => handleSelect(o.value)}
className="hover:bg-accent hover:text-accent-foreground relative flex w-full cursor-default items-center rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none"
>
<span className="truncate">{o.label || o.value}</span>
{o.value === value && (
<CheckIcon className="absolute right-2 size-4" />
)}
</button>
))}
</div>
)}
</div>
);
}
@@ -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 (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -92,7 +106,7 @@ export function AgentCreateDialog({ open, onOpenChange, onCreate }: AgentCreateD
<div className="space-y-2">
<Label>Provider</Label>
{enabledProviders.length > 0 ? (
<Select value={provider} onValueChange={setProvider}>
<Select value={provider} onValueChange={handleProviderChange}>
<SelectTrigger>
<SelectValue placeholder="Select provider" />
</SelectTrigger>
@@ -114,10 +128,11 @@ export function AgentCreateDialog({ open, onOpenChange, onCreate }: AgentCreateD
</div>
<div className="space-y-2">
<Label>Model</Label>
<Input
<Combobox
value={model}
onChange={(e) => 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"}
/>
</div>
</div>
@@ -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) {
<div className="space-y-2">
<Label>Provider</Label>
{enabledProviders.length > 0 ? (
<Select value={provider} onValueChange={setProvider}>
<Select value={provider} onValueChange={(v) => { setProvider(v); setModel(""); }}>
<SelectTrigger>
<SelectValue placeholder="Select provider" />
</SelectTrigger>
@@ -157,11 +165,11 @@ export function AgentGeneralTab({ agent, onUpdate }: AgentGeneralTabProps) {
</div>
<div className="space-y-2">
<Label htmlFor="model">Model</Label>
<Input
id="model"
<Combobox
value={model}
onChange={(e) => 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"}
/>
</div>
</div>
+10 -8
View File
@@ -96,14 +96,16 @@ export function AgentsPage() {
/>
))}
</div>
<Pagination
page={pagination.page}
pageSize={pagination.pageSize}
total={pagination.total}
totalPages={pagination.totalPages}
onPageChange={setPage}
onPageSizeChange={setPageSize}
/>
<div className="mt-4">
<Pagination
page={pagination.page}
pageSize={pagination.pageSize}
total={pagination.total}
totalPages={pagination.totalPages}
onPageChange={setPage}
onPageSizeChange={setPageSize}
/>
</div>
</>
)}
</div>
@@ -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<ModelInfo[]>([]);
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 };
}