| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577 |
- import * as React from "react";
- import { useTranslation } from "react-i18next";
- import {
- KeyRound,
- Pencil,
- Plus,
- Search,
- Trash2,
- Unplug,
- Wifi,
- WifiOff,
- } from "lucide-react";
- import {
- createModelProvider,
- deleteModelProvider,
- discoverModels,
- listModelProviders,
- testModelProviderConnection,
- updateModelProvider,
- } from "@/api";
- import { ApiErrorState } from "@/components/shared/ApiErrorState";
- import { EmptyState } from "@/components/shared/EmptyState";
- import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
- import { MetricCard } from "@/components/shared/MetricCard";
- import { PageHeader } from "@/components/shared/PageHeader";
- import { StatusBadge } from "@/components/shared/StatusBadge";
- import { Button } from "@/components/ui/button";
- import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
- import { Dialog } from "@/components/ui/dialog";
- import { Input } from "@/components/ui/input";
- import { Select } from "@/components/ui/select";
- import { toast } from "@/components/ui/toaster";
- import {
- type DiscoveredModel,
- type ModelItem,
- type ModelProvider,
- type ModelProviderType,
- type ModelProviderUpdateRequest,
- type ModelType,
- } from "@/types";
- const PROVIDER_TYPE_OPTIONS = [
- { value: "openai", label: "OpenAI" },
- { value: "anthropic", label: "Anthropic" },
- { value: "deepseek", label: "DeepSeek" },
- { value: "azure_openai", label: "Azure OpenAI" },
- { value: "ollama", label: "Ollama" },
- { value: "custom", label: "Custom" },
- ];
- const DEFAULT_URLS = {
- openai: "https://api.openai.com/v1",
- anthropic: "https://api.anthropic.com",
- deepseek: "https://api.deepseek.com/v1",
- azure_openai: "https://<resource>.openai.azure.com",
- ollama: "http://localhost:11434",
- custom: "",
- } as const;
- function ModelTypeBadge({ type, compact }: { type: ModelType; compact?: boolean }) {
- const labels: Record<ModelType, string> = {
- chat: "Chat",
- reasoning: "Reasoning",
- embedding: "Embedding",
- image: "Image",
- audio: "Audio",
- video: "Video",
- rerank: "Rerank",
- moderation: "Moderation",
- other: "Other",
- };
- return (
- <span className={`rounded bg-muted px-1.5 py-0.5 text-xs ${compact ? "" : "text-muted-foreground"}`}>
- {labels[type] || type}
- </span>
- );
- }
- export function ModelProvidersPage() {
- const { t } = useTranslation();
- const [providers, setProviders] = React.useState<ModelProvider[]>([]);
- const [loading, setLoading] = React.useState(true);
- const [error, setError] = React.useState<string>();
- const [search, setSearch] = React.useState("");
- const [providerDialogOpen, setProviderDialogOpen] = React.useState(false);
- const [editingProvider, setEditingProvider] = React.useState<ModelProvider | null>(null);
- const load = React.useCallback(async () => {
- setLoading(true);
- setError(undefined);
- try {
- const provs = await listModelProviders();
- setProviders(provs);
- } catch (err) {
- setError(err instanceof Error ? err.message : t("errors.failedToLoad"));
- } finally {
- setLoading(false);
- }
- }, [t]);
- React.useEffect(() => {
- void load();
- }, [load]);
- async function removeProvider(id: string) {
- await deleteModelProvider(id);
- toast.success(t("modelProviders.providerDeleted"));
- void load();
- }
- async function toggleProviderStatus(provider: ModelProvider) {
- const next = provider.status === "active" ? "inactive" : "active";
- await updateModelProvider(provider.id, { status: next });
- void load();
- }
- async function testConnection(provider: ModelProvider) {
- try {
- const result = await testModelProviderConnection(provider.id);
- if (result.success) {
- toast.success(t("modelProviders.testSuccess", { latency: result.latency_ms }));
- } else {
- toast.error(`${t("modelProviders.testFailed")}: ${result.message}`);
- }
- } catch {
- toast.error(t("modelProviders.testFailed"));
- }
- }
- const filtered = providers.filter(
- (p) =>
- p.name.toLowerCase().includes(search.toLowerCase()) ||
- p.provider_type.toLowerCase().includes(search.toLowerCase())
- );
- if (loading) return <LoadingSpinner label={t("common.loading")} />;
- if (error) return <ApiErrorState message={error} onRetry={() => void load()} />;
- return (
- <div className="space-y-6">
- <PageHeader
- title={t("modelProviders.title")}
- description={t("modelProviders.description")}
- actions={
- <Button
- onClick={() => {
- setEditingProvider(null);
- setProviderDialogOpen(true);
- }}
- >
- <Plus className="h-4 w-4" />
- {t("modelProviders.addProvider")}
- </Button>
- }
- />
- <div className="grid gap-4 md:grid-cols-3">
- <MetricCard label={t("modelProviders.totalProviders")} value={providers.length} icon={Unplug} />
- <MetricCard
- label={t("modelProviders.activeProviders")}
- value={providers.filter((p) => p.status === "active").length}
- icon={Wifi}
- />
- <MetricCard
- label={t("modelProviders.totalModels")}
- value={providers.reduce((acc, p) => acc + p.models.filter((m) => m.enabled).length, 0)}
- icon={KeyRound}
- />
- </div>
- <Card>
- <CardHeader>
- <div className="flex items-center justify-between gap-4">
- <CardTitle>{t("modelProviders.providers")}</CardTitle>
- <div className="relative w-64">
- <Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
- <Input
- value={search}
- onChange={(e) => setSearch(e.target.value)}
- placeholder={t("modelProviders.searchProviders")}
- className="pl-9"
- />
- </div>
- </div>
- </CardHeader>
- <CardContent>
- {filtered.length ? (
- <div className="space-y-3">
- {filtered.map((provider) => (
- <div
- key={provider.id}
- className="flex items-start justify-between gap-4 rounded-lg border border-border p-4"
- >
- <div className="min-w-0 flex-1 space-y-2">
- <div className="flex items-center gap-2">
- <span className="font-medium">{provider.name}</span>
- <StatusBadge status={provider.status} />
- <span className="rounded bg-muted px-1.5 py-0.5 text-xs text-muted-foreground">
- {t(`modelProviders.${provider.provider_type}`)}
- </span>
- </div>
- <p className="truncate text-xs text-muted-foreground">{provider.base_url}</p>
- <div className="flex flex-wrap gap-1">
- {provider.models.filter((m) => m.enabled).map((m) => (
- <span
- key={m.model_id}
- className="flex items-center gap-1 rounded bg-primary/10 px-1.5 py-0.5 text-xs text-primary"
- >
- {m.display_name}
- {provider.default_model === m.model_id ? " *" : ""}
- <ModelTypeBadge type={m.model_type} compact />
- </span>
- ))}
- {provider.models.filter((m) => m.enabled).length === 0 && (
- <span className="text-xs text-muted-foreground">{t("modelProviders.noModels")}</span>
- )}
- </div>
- </div>
- <div className="flex shrink-0 items-center gap-1">
- <Button size="sm" variant="ghost" onClick={() => void testConnection(provider)} title={t("modelProviders.testConnection")}>
- <Wifi className="h-4 w-4" />
- </Button>
- <Button
- size="sm"
- variant="ghost"
- onClick={() => void toggleProviderStatus(provider)}
- title={t("modelProviders.toggleStatus")}
- >
- {provider.status === "active" ? <WifiOff className="h-4 w-4" /> : <Wifi className="h-4 w-4" />}
- </Button>
- <Button
- size="sm"
- variant="ghost"
- onClick={() => {
- setEditingProvider(provider);
- setProviderDialogOpen(true);
- }}
- title={t("modelProviders.editProvider")}
- >
- <Pencil className="h-4 w-4" />
- </Button>
- <Button
- size="sm"
- variant="ghost"
- onClick={() => {
- if (window.confirm(t("modelProviders.deleteConfirm"))) {
- void removeProvider(provider.id);
- }
- }}
- title={t("modelProviders.deleteProvider")}
- >
- <Trash2 className="h-4 w-4 text-destructive" />
- </Button>
- </div>
- </div>
- ))}
- </div>
- ) : (
- <EmptyState
- icon={Unplug}
- title={t("modelProviders.noProviders")}
- description={t("modelProviders.noProvidersDescription")}
- />
- )}
- </CardContent>
- </Card>
- <ProviderDialog
- open={providerDialogOpen}
- onOpenChange={setProviderDialogOpen}
- editing={editingProvider}
- onSaved={() => void load()}
- />
- </div>
- );
- }
- function ProviderDialog({
- open,
- onOpenChange,
- editing,
- onSaved,
- }: {
- open: boolean;
- onOpenChange: (open: boolean) => void;
- editing: ModelProvider | null;
- onSaved: () => void;
- }) {
- const { t } = useTranslation();
- const [name, setName] = React.useState("");
- const [providerType, setProviderType] = React.useState<ModelProviderType>("openai");
- const [baseUrl, setBaseUrl] = React.useState("");
- const [apiKey, setApiKey] = React.useState("");
- const [models, setModels] = React.useState<ModelItem[]>([]);
- const [defaultModel, setDefaultModel] = React.useState("");
- const [submitting, setSubmitting] = React.useState(false);
- const [discovering, setDiscovering] = React.useState(false);
- const [discovered, setDiscovered] = React.useState<DiscoveredModel[]>([]);
- const [discoverOpen, setDiscoverOpen] = React.useState(false);
- const [selectedDiscovered, setSelectedDiscovered] = React.useState<Set<string>>(new Set());
- const [typeFilter, setTypeFilter] = React.useState<string>("all");
- React.useEffect(() => {
- if (!open) {
- setDiscoverOpen(false);
- setDiscovered([]);
- setSelectedDiscovered(new Set());
- return;
- }
- if (editing) {
- setName(editing.name);
- setProviderType(editing.provider_type);
- setBaseUrl(editing.base_url);
- setApiKey("");
- setModels(editing.models.map((m) => ({ ...m })));
- setDefaultModel(editing.default_model ?? "");
- } else {
- setName("");
- setProviderType("openai");
- setBaseUrl(DEFAULT_URLS.openai);
- setApiKey("");
- setModels([]);
- setDefaultModel("");
- }
- }, [open, editing]);
- function handleTypeChange(newType: string) {
- const pt = newType as ModelProviderType;
- setProviderType(pt);
- setBaseUrl(DEFAULT_URLS[pt] ?? "");
- setModels([]);
- setDefaultModel("");
- setDiscovered([]);
- setDiscoverOpen(false);
- }
- function updateModel(index: number, patch: Partial<ModelItem>) {
- setModels((prev) => prev.map((m, i) => (i === index ? { ...m, ...patch } : m)));
- }
- function addModelRow() {
- setModels((prev) => [...prev, { model_id: "", display_name: "", model_type: "chat", enabled: true }]);
- }
- function removeModelRow(index: number) {
- setModels((prev) => prev.filter((_, i) => i !== index));
- }
- async function handleDiscover() {
- setDiscovering(true);
- setDiscovered([]);
- setSelectedDiscovered(new Set());
- try {
- const result = await discoverModels(
- editing ? { providerId: editing.id } : { providerType, baseUrl, apiKey },
- );
- setDiscovered(result.models);
- setDiscoverOpen(true);
- toast.success(t("modelProviders.discovered", { count: result.models.length }));
- } catch {
- toast.error(t("modelProviders.discoverFailed"));
- } finally {
- setDiscovering(false);
- }
- }
- function toggleDiscovered(modelId: string) {
- setSelectedDiscovered((prev) => {
- const next = new Set(prev);
- if (next.has(modelId)) next.delete(modelId);
- else next.add(modelId);
- return next;
- });
- }
- function toggleAllDiscovered() {
- const filteredDiscovered = typeFilter === "all" ? discovered : discovered.filter((m) => m.model_type === typeFilter);
- if (selectedDiscovered.size === filteredDiscovered.length) {
- setSelectedDiscovered(new Set());
- } else {
- setSelectedDiscovered(new Set(filteredDiscovered.map((m) => m.model_id)));
- }
- }
- const filteredDiscovered = typeFilter === "all" ? discovered : discovered.filter((m) => m.model_type === typeFilter);
- function applyDiscovered() {
- const picked = discovered.filter((m) => selectedDiscovered.has(m.model_id));
- const existingIds = new Set(models.map((m) => m.model_id));
- const newItems: ModelItem[] = picked
- .filter((m) => !existingIds.has(m.model_id))
- .map((m) => ({
- model_id: m.model_id,
- display_name: m.display_name,
- model_type: m.model_type,
- enabled: true,
- }));
- setModels((prev) => [...prev, ...newItems]);
- if (!defaultModel && newItems.length > 0) {
- setDefaultModel(newItems[0]!.model_id);
- }
- setDiscoverOpen(false);
- setDiscovered([]);
- setSelectedDiscovered(new Set());
- }
- async function submit(event: React.FormEvent) {
- event.preventDefault();
- setSubmitting(true);
- try {
- const validModels = models.filter((m) => m.model_id.trim());
- if (editing) {
- const patch: ModelProviderUpdateRequest = { name, base_url: baseUrl, models: validModels, default_model: defaultModel || null };
- if (apiKey) patch.api_key = apiKey;
- await updateModelProvider(editing.id, patch);
- toast.success(t("modelProviders.providerUpdated"));
- } else {
- await createModelProvider({
- name,
- provider_type: providerType,
- base_url: baseUrl,
- api_key: apiKey,
- models: validModels,
- default_model: defaultModel || null,
- });
- toast.success(t("modelProviders.providerCreated"));
- }
- onOpenChange(false);
- onSaved();
- } catch {
- toast.error(t("errors.failedToSave"));
- } finally {
- setSubmitting(false);
- }
- }
- const discoveredTypes = Array.from(new Set(discovered.map((m) => m.model_type)));
- return (
- <Dialog
- open={open}
- onOpenChange={onOpenChange}
- title={editing ? t("modelProviders.editProvider") : t("modelProviders.createProvider")}
- className="max-w-4xl"
- >
- <form className="space-y-4" onSubmit={submit}>
- <div className="grid gap-4 sm:grid-cols-2">
- <label className="block space-y-2 text-sm">
- <span className="text-muted-foreground">{t("modelProviders.providerName")}</span>
- <Input required value={name} onChange={(e) => setName(e.target.value)} />
- </label>
- <label className="block space-y-2 text-sm">
- <span className="text-muted-foreground">{t("modelProviders.providerType")}</span>
- <Select options={PROVIDER_TYPE_OPTIONS} value={providerType} onChange={(e) => handleTypeChange(e.target.value)} disabled={!!editing} />
- </label>
- </div>
- <div className="grid gap-4 sm:grid-cols-2">
- <label className="block space-y-2 text-sm">
- <span className="text-muted-foreground">{t("modelProviders.baseUrl")}</span>
- <Input required value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} />
- </label>
- <label className="block space-y-2 text-sm">
- <span className="text-muted-foreground">{t("modelProviders.apiKey")}</span>
- <Input type="password" value={apiKey} onChange={(e) => setApiKey(e.target.value)} placeholder={editing ? t("modelProviders.masked") : "sk-..."} />
- </label>
- </div>
- <div className="space-y-2">
- <div className="flex items-center justify-between">
- <span className="text-sm font-medium">{t("modelProviders.availableModels")} ({models.length})</span>
- <div className="flex gap-2">
- <Button type="button" size="sm" variant="outline" onClick={() => void handleDiscover()} disabled={discovering}>
- {discovering ? t("modelProviders.discovering") : t("modelProviders.discoverModels")}
- </Button>
- <Button type="button" size="sm" variant="outline" onClick={addModelRow}>
- {t("modelProviders.addModel")}
- </Button>
- </div>
- </div>
- {models.length > 0 && (
- <div className="max-h-48 space-y-2 overflow-auto rounded border p-2">
- {models.map((model, index) => (
- <div key={index} className="flex items-center gap-2">
- <Input
- placeholder={t("modelProviders.modelId")}
- value={model.model_id}
- onChange={(e) => updateModel(index, { model_id: e.target.value })}
- className="flex-1"
- />
- <Input
- placeholder={t("modelProviders.displayName")}
- value={model.display_name}
- onChange={(e) => updateModel(index, { display_name: e.target.value })}
- className="w-32"
- />
- <Select
- value={model.model_type}
- onChange={(e) => updateModel(index, { model_type: e.target.value as ModelType })}
- options={[
- { value: "chat", label: "Chat" },
- { value: "reasoning", label: "Reasoning" },
- { value: "embedding", label: "Embedding" },
- { value: "rerank", label: "Rerank" },
- ]}
- className="w-28"
- />
- <Button type="button" size="icon" variant="ghost" onClick={() => removeModelRow(index)}>
- <Trash2 className="h-4 w-4" />
- </Button>
- </div>
- ))}
- </div>
- )}
- </div>
- {discoverOpen && (
- <div className="space-y-2 rounded border p-4">
- <div className="flex items-center justify-between">
- <span className="text-sm font-medium">{t("modelProviders.discoveredModels")}</span>
- <div className="flex gap-2">
- <Select
- value={typeFilter}
- onChange={(e) => setTypeFilter(e.target.value)}
- options={[{ value: "all", label: t("modelProviders.allTypes") }, ...discoveredTypes.map((t) => ({ value: t, label: t }))]}
- className="w-32"
- />
- <Button type="button" size="sm" variant="outline" onClick={toggleAllDiscovered}>
- {selectedDiscovered.size === filteredDiscovered.length ? t("modelProviders.deselectAll") : t("modelProviders.selectAll")}
- </Button>
- </div>
- </div>
- <div className="max-h-60 space-y-1 overflow-auto">
- {filteredDiscovered.map((m) => (
- <button
- key={m.model_id}
- type="button"
- onClick={() => toggleDiscovered(m.model_id)}
- className={`flex w-full items-center gap-2 rounded p-2 text-left transition ${selectedDiscovered.has(m.model_id) ? "bg-primary/10" : "hover:bg-muted"}`}
- >
- <div className={`h-4 w-4 rounded border ${selectedDiscovered.has(m.model_id) ? "border-primary bg-primary" : "border-muted-foreground"}`} />
- <div className="flex-1">
- <span className="text-sm">{m.display_name}</span>
- <span className="ml-2 text-xs text-muted-foreground">{m.model_id}</span>
- </div>
- <ModelTypeBadge type={m.model_type} />
- {m.context_window && (
- <span className="text-xs text-muted-foreground">{m.context_window.toLocaleString()} tokens</span>
- )}
- </button>
- ))}
- {filteredDiscovered.length === 0 && (
- <p className="py-4 text-center text-sm text-muted-foreground">{t("modelProviders.noModelsDiscovered")}</p>
- )}
- </div>
- <div className="flex justify-end gap-2">
- <Button type="button" variant="ghost" onClick={() => setDiscoverOpen(false)}>
- {t("common.cancel")}
- </Button>
- <Button type="button" onClick={applyDiscovered} disabled={selectedDiscovered.size === 0}>
- {t("modelProviders.applySelected", { count: selectedDiscovered.size })}
- </Button>
- </div>
- </div>
- )}
- <div className="flex justify-end gap-2 pt-2">
- <Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
- {t("common.cancel")}
- </Button>
- <Button type="submit" disabled={submitting}>
- {submitting ? t("common.creating") : t("common.save")}
- </Button>
- </div>
- </form>
- </Dialog>
- );
- }
|