ModelProvidersPage.tsx 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577
  1. import * as React from "react";
  2. import { useTranslation } from "react-i18next";
  3. import {
  4. KeyRound,
  5. Pencil,
  6. Plus,
  7. Search,
  8. Trash2,
  9. Unplug,
  10. Wifi,
  11. WifiOff,
  12. } from "lucide-react";
  13. import {
  14. createModelProvider,
  15. deleteModelProvider,
  16. discoverModels,
  17. listModelProviders,
  18. testModelProviderConnection,
  19. updateModelProvider,
  20. } from "@/api";
  21. import { ApiErrorState } from "@/components/shared/ApiErrorState";
  22. import { EmptyState } from "@/components/shared/EmptyState";
  23. import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
  24. import { MetricCard } from "@/components/shared/MetricCard";
  25. import { PageHeader } from "@/components/shared/PageHeader";
  26. import { StatusBadge } from "@/components/shared/StatusBadge";
  27. import { Button } from "@/components/ui/button";
  28. import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
  29. import { Dialog } from "@/components/ui/dialog";
  30. import { Input } from "@/components/ui/input";
  31. import { Select } from "@/components/ui/select";
  32. import { toast } from "@/components/ui/toaster";
  33. import {
  34. type DiscoveredModel,
  35. type ModelItem,
  36. type ModelProvider,
  37. type ModelProviderType,
  38. type ModelProviderUpdateRequest,
  39. type ModelType,
  40. } from "@/types";
  41. const PROVIDER_TYPE_OPTIONS = [
  42. { value: "openai", label: "OpenAI" },
  43. { value: "anthropic", label: "Anthropic" },
  44. { value: "deepseek", label: "DeepSeek" },
  45. { value: "azure_openai", label: "Azure OpenAI" },
  46. { value: "ollama", label: "Ollama" },
  47. { value: "custom", label: "Custom" },
  48. ];
  49. const DEFAULT_URLS = {
  50. openai: "https://api.openai.com/v1",
  51. anthropic: "https://api.anthropic.com",
  52. deepseek: "https://api.deepseek.com/v1",
  53. azure_openai: "https://<resource>.openai.azure.com",
  54. ollama: "http://localhost:11434",
  55. custom: "",
  56. } as const;
  57. function ModelTypeBadge({ type, compact }: { type: ModelType; compact?: boolean }) {
  58. const labels: Record<ModelType, string> = {
  59. chat: "Chat",
  60. reasoning: "Reasoning",
  61. embedding: "Embedding",
  62. image: "Image",
  63. audio: "Audio",
  64. video: "Video",
  65. rerank: "Rerank",
  66. moderation: "Moderation",
  67. other: "Other",
  68. };
  69. return (
  70. <span className={`rounded bg-muted px-1.5 py-0.5 text-xs ${compact ? "" : "text-muted-foreground"}`}>
  71. {labels[type] || type}
  72. </span>
  73. );
  74. }
  75. export function ModelProvidersPage() {
  76. const { t } = useTranslation();
  77. const [providers, setProviders] = React.useState<ModelProvider[]>([]);
  78. const [loading, setLoading] = React.useState(true);
  79. const [error, setError] = React.useState<string>();
  80. const [search, setSearch] = React.useState("");
  81. const [providerDialogOpen, setProviderDialogOpen] = React.useState(false);
  82. const [editingProvider, setEditingProvider] = React.useState<ModelProvider | null>(null);
  83. const load = React.useCallback(async () => {
  84. setLoading(true);
  85. setError(undefined);
  86. try {
  87. const provs = await listModelProviders();
  88. setProviders(provs);
  89. } catch (err) {
  90. setError(err instanceof Error ? err.message : t("errors.failedToLoad"));
  91. } finally {
  92. setLoading(false);
  93. }
  94. }, [t]);
  95. React.useEffect(() => {
  96. void load();
  97. }, [load]);
  98. async function removeProvider(id: string) {
  99. await deleteModelProvider(id);
  100. toast.success(t("modelProviders.providerDeleted"));
  101. void load();
  102. }
  103. async function toggleProviderStatus(provider: ModelProvider) {
  104. const next = provider.status === "active" ? "inactive" : "active";
  105. await updateModelProvider(provider.id, { status: next });
  106. void load();
  107. }
  108. async function testConnection(provider: ModelProvider) {
  109. try {
  110. const result = await testModelProviderConnection(provider.id);
  111. if (result.success) {
  112. toast.success(t("modelProviders.testSuccess", { latency: result.latency_ms }));
  113. } else {
  114. toast.error(`${t("modelProviders.testFailed")}: ${result.message}`);
  115. }
  116. } catch {
  117. toast.error(t("modelProviders.testFailed"));
  118. }
  119. }
  120. const filtered = providers.filter(
  121. (p) =>
  122. p.name.toLowerCase().includes(search.toLowerCase()) ||
  123. p.provider_type.toLowerCase().includes(search.toLowerCase())
  124. );
  125. if (loading) return <LoadingSpinner label={t("common.loading")} />;
  126. if (error) return <ApiErrorState message={error} onRetry={() => void load()} />;
  127. return (
  128. <div className="space-y-6">
  129. <PageHeader
  130. title={t("modelProviders.title")}
  131. description={t("modelProviders.description")}
  132. actions={
  133. <Button
  134. onClick={() => {
  135. setEditingProvider(null);
  136. setProviderDialogOpen(true);
  137. }}
  138. >
  139. <Plus className="h-4 w-4" />
  140. {t("modelProviders.addProvider")}
  141. </Button>
  142. }
  143. />
  144. <div className="grid gap-4 md:grid-cols-3">
  145. <MetricCard label={t("modelProviders.totalProviders")} value={providers.length} icon={Unplug} />
  146. <MetricCard
  147. label={t("modelProviders.activeProviders")}
  148. value={providers.filter((p) => p.status === "active").length}
  149. icon={Wifi}
  150. />
  151. <MetricCard
  152. label={t("modelProviders.totalModels")}
  153. value={providers.reduce((acc, p) => acc + p.models.filter((m) => m.enabled).length, 0)}
  154. icon={KeyRound}
  155. />
  156. </div>
  157. <Card>
  158. <CardHeader>
  159. <div className="flex items-center justify-between gap-4">
  160. <CardTitle>{t("modelProviders.providers")}</CardTitle>
  161. <div className="relative w-64">
  162. <Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
  163. <Input
  164. value={search}
  165. onChange={(e) => setSearch(e.target.value)}
  166. placeholder={t("modelProviders.searchProviders")}
  167. className="pl-9"
  168. />
  169. </div>
  170. </div>
  171. </CardHeader>
  172. <CardContent>
  173. {filtered.length ? (
  174. <div className="space-y-3">
  175. {filtered.map((provider) => (
  176. <div
  177. key={provider.id}
  178. className="flex items-start justify-between gap-4 rounded-lg border border-border p-4"
  179. >
  180. <div className="min-w-0 flex-1 space-y-2">
  181. <div className="flex items-center gap-2">
  182. <span className="font-medium">{provider.name}</span>
  183. <StatusBadge status={provider.status} />
  184. <span className="rounded bg-muted px-1.5 py-0.5 text-xs text-muted-foreground">
  185. {t(`modelProviders.${provider.provider_type}`)}
  186. </span>
  187. </div>
  188. <p className="truncate text-xs text-muted-foreground">{provider.base_url}</p>
  189. <div className="flex flex-wrap gap-1">
  190. {provider.models.filter((m) => m.enabled).map((m) => (
  191. <span
  192. key={m.model_id}
  193. className="flex items-center gap-1 rounded bg-primary/10 px-1.5 py-0.5 text-xs text-primary"
  194. >
  195. {m.display_name}
  196. {provider.default_model === m.model_id ? " *" : ""}
  197. <ModelTypeBadge type={m.model_type} compact />
  198. </span>
  199. ))}
  200. {provider.models.filter((m) => m.enabled).length === 0 && (
  201. <span className="text-xs text-muted-foreground">{t("modelProviders.noModels")}</span>
  202. )}
  203. </div>
  204. </div>
  205. <div className="flex shrink-0 items-center gap-1">
  206. <Button size="sm" variant="ghost" onClick={() => void testConnection(provider)} title={t("modelProviders.testConnection")}>
  207. <Wifi className="h-4 w-4" />
  208. </Button>
  209. <Button
  210. size="sm"
  211. variant="ghost"
  212. onClick={() => void toggleProviderStatus(provider)}
  213. title={t("modelProviders.toggleStatus")}
  214. >
  215. {provider.status === "active" ? <WifiOff className="h-4 w-4" /> : <Wifi className="h-4 w-4" />}
  216. </Button>
  217. <Button
  218. size="sm"
  219. variant="ghost"
  220. onClick={() => {
  221. setEditingProvider(provider);
  222. setProviderDialogOpen(true);
  223. }}
  224. title={t("modelProviders.editProvider")}
  225. >
  226. <Pencil className="h-4 w-4" />
  227. </Button>
  228. <Button
  229. size="sm"
  230. variant="ghost"
  231. onClick={() => {
  232. if (window.confirm(t("modelProviders.deleteConfirm"))) {
  233. void removeProvider(provider.id);
  234. }
  235. }}
  236. title={t("modelProviders.deleteProvider")}
  237. >
  238. <Trash2 className="h-4 w-4 text-destructive" />
  239. </Button>
  240. </div>
  241. </div>
  242. ))}
  243. </div>
  244. ) : (
  245. <EmptyState
  246. icon={Unplug}
  247. title={t("modelProviders.noProviders")}
  248. description={t("modelProviders.noProvidersDescription")}
  249. />
  250. )}
  251. </CardContent>
  252. </Card>
  253. <ProviderDialog
  254. open={providerDialogOpen}
  255. onOpenChange={setProviderDialogOpen}
  256. editing={editingProvider}
  257. onSaved={() => void load()}
  258. />
  259. </div>
  260. );
  261. }
  262. function ProviderDialog({
  263. open,
  264. onOpenChange,
  265. editing,
  266. onSaved,
  267. }: {
  268. open: boolean;
  269. onOpenChange: (open: boolean) => void;
  270. editing: ModelProvider | null;
  271. onSaved: () => void;
  272. }) {
  273. const { t } = useTranslation();
  274. const [name, setName] = React.useState("");
  275. const [providerType, setProviderType] = React.useState<ModelProviderType>("openai");
  276. const [baseUrl, setBaseUrl] = React.useState("");
  277. const [apiKey, setApiKey] = React.useState("");
  278. const [models, setModels] = React.useState<ModelItem[]>([]);
  279. const [defaultModel, setDefaultModel] = React.useState("");
  280. const [submitting, setSubmitting] = React.useState(false);
  281. const [discovering, setDiscovering] = React.useState(false);
  282. const [discovered, setDiscovered] = React.useState<DiscoveredModel[]>([]);
  283. const [discoverOpen, setDiscoverOpen] = React.useState(false);
  284. const [selectedDiscovered, setSelectedDiscovered] = React.useState<Set<string>>(new Set());
  285. const [typeFilter, setTypeFilter] = React.useState<string>("all");
  286. React.useEffect(() => {
  287. if (!open) {
  288. setDiscoverOpen(false);
  289. setDiscovered([]);
  290. setSelectedDiscovered(new Set());
  291. return;
  292. }
  293. if (editing) {
  294. setName(editing.name);
  295. setProviderType(editing.provider_type);
  296. setBaseUrl(editing.base_url);
  297. setApiKey("");
  298. setModels(editing.models.map((m) => ({ ...m })));
  299. setDefaultModel(editing.default_model ?? "");
  300. } else {
  301. setName("");
  302. setProviderType("openai");
  303. setBaseUrl(DEFAULT_URLS.openai);
  304. setApiKey("");
  305. setModels([]);
  306. setDefaultModel("");
  307. }
  308. }, [open, editing]);
  309. function handleTypeChange(newType: string) {
  310. const pt = newType as ModelProviderType;
  311. setProviderType(pt);
  312. setBaseUrl(DEFAULT_URLS[pt] ?? "");
  313. setModels([]);
  314. setDefaultModel("");
  315. setDiscovered([]);
  316. setDiscoverOpen(false);
  317. }
  318. function updateModel(index: number, patch: Partial<ModelItem>) {
  319. setModels((prev) => prev.map((m, i) => (i === index ? { ...m, ...patch } : m)));
  320. }
  321. function addModelRow() {
  322. setModels((prev) => [...prev, { model_id: "", display_name: "", model_type: "chat", enabled: true }]);
  323. }
  324. function removeModelRow(index: number) {
  325. setModels((prev) => prev.filter((_, i) => i !== index));
  326. }
  327. async function handleDiscover() {
  328. setDiscovering(true);
  329. setDiscovered([]);
  330. setSelectedDiscovered(new Set());
  331. try {
  332. const result = await discoverModels(
  333. editing ? { providerId: editing.id } : { providerType, baseUrl, apiKey },
  334. );
  335. setDiscovered(result.models);
  336. setDiscoverOpen(true);
  337. toast.success(t("modelProviders.discovered", { count: result.models.length }));
  338. } catch {
  339. toast.error(t("modelProviders.discoverFailed"));
  340. } finally {
  341. setDiscovering(false);
  342. }
  343. }
  344. function toggleDiscovered(modelId: string) {
  345. setSelectedDiscovered((prev) => {
  346. const next = new Set(prev);
  347. if (next.has(modelId)) next.delete(modelId);
  348. else next.add(modelId);
  349. return next;
  350. });
  351. }
  352. function toggleAllDiscovered() {
  353. const filteredDiscovered = typeFilter === "all" ? discovered : discovered.filter((m) => m.model_type === typeFilter);
  354. if (selectedDiscovered.size === filteredDiscovered.length) {
  355. setSelectedDiscovered(new Set());
  356. } else {
  357. setSelectedDiscovered(new Set(filteredDiscovered.map((m) => m.model_id)));
  358. }
  359. }
  360. const filteredDiscovered = typeFilter === "all" ? discovered : discovered.filter((m) => m.model_type === typeFilter);
  361. function applyDiscovered() {
  362. const picked = discovered.filter((m) => selectedDiscovered.has(m.model_id));
  363. const existingIds = new Set(models.map((m) => m.model_id));
  364. const newItems: ModelItem[] = picked
  365. .filter((m) => !existingIds.has(m.model_id))
  366. .map((m) => ({
  367. model_id: m.model_id,
  368. display_name: m.display_name,
  369. model_type: m.model_type,
  370. enabled: true,
  371. }));
  372. setModels((prev) => [...prev, ...newItems]);
  373. if (!defaultModel && newItems.length > 0) {
  374. setDefaultModel(newItems[0]!.model_id);
  375. }
  376. setDiscoverOpen(false);
  377. setDiscovered([]);
  378. setSelectedDiscovered(new Set());
  379. }
  380. async function submit(event: React.FormEvent) {
  381. event.preventDefault();
  382. setSubmitting(true);
  383. try {
  384. const validModels = models.filter((m) => m.model_id.trim());
  385. if (editing) {
  386. const patch: ModelProviderUpdateRequest = { name, base_url: baseUrl, models: validModels, default_model: defaultModel || null };
  387. if (apiKey) patch.api_key = apiKey;
  388. await updateModelProvider(editing.id, patch);
  389. toast.success(t("modelProviders.providerUpdated"));
  390. } else {
  391. await createModelProvider({
  392. name,
  393. provider_type: providerType,
  394. base_url: baseUrl,
  395. api_key: apiKey,
  396. models: validModels,
  397. default_model: defaultModel || null,
  398. });
  399. toast.success(t("modelProviders.providerCreated"));
  400. }
  401. onOpenChange(false);
  402. onSaved();
  403. } catch {
  404. toast.error(t("errors.failedToSave"));
  405. } finally {
  406. setSubmitting(false);
  407. }
  408. }
  409. const discoveredTypes = Array.from(new Set(discovered.map((m) => m.model_type)));
  410. return (
  411. <Dialog
  412. open={open}
  413. onOpenChange={onOpenChange}
  414. title={editing ? t("modelProviders.editProvider") : t("modelProviders.createProvider")}
  415. className="max-w-4xl"
  416. >
  417. <form className="space-y-4" onSubmit={submit}>
  418. <div className="grid gap-4 sm:grid-cols-2">
  419. <label className="block space-y-2 text-sm">
  420. <span className="text-muted-foreground">{t("modelProviders.providerName")}</span>
  421. <Input required value={name} onChange={(e) => setName(e.target.value)} />
  422. </label>
  423. <label className="block space-y-2 text-sm">
  424. <span className="text-muted-foreground">{t("modelProviders.providerType")}</span>
  425. <Select options={PROVIDER_TYPE_OPTIONS} value={providerType} onChange={(e) => handleTypeChange(e.target.value)} disabled={!!editing} />
  426. </label>
  427. </div>
  428. <div className="grid gap-4 sm:grid-cols-2">
  429. <label className="block space-y-2 text-sm">
  430. <span className="text-muted-foreground">{t("modelProviders.baseUrl")}</span>
  431. <Input required value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} />
  432. </label>
  433. <label className="block space-y-2 text-sm">
  434. <span className="text-muted-foreground">{t("modelProviders.apiKey")}</span>
  435. <Input type="password" value={apiKey} onChange={(e) => setApiKey(e.target.value)} placeholder={editing ? t("modelProviders.masked") : "sk-..."} />
  436. </label>
  437. </div>
  438. <div className="space-y-2">
  439. <div className="flex items-center justify-between">
  440. <span className="text-sm font-medium">{t("modelProviders.availableModels")} ({models.length})</span>
  441. <div className="flex gap-2">
  442. <Button type="button" size="sm" variant="outline" onClick={() => void handleDiscover()} disabled={discovering}>
  443. {discovering ? t("modelProviders.discovering") : t("modelProviders.discoverModels")}
  444. </Button>
  445. <Button type="button" size="sm" variant="outline" onClick={addModelRow}>
  446. {t("modelProviders.addModel")}
  447. </Button>
  448. </div>
  449. </div>
  450. {models.length > 0 && (
  451. <div className="max-h-48 space-y-2 overflow-auto rounded border p-2">
  452. {models.map((model, index) => (
  453. <div key={index} className="flex items-center gap-2">
  454. <Input
  455. placeholder={t("modelProviders.modelId")}
  456. value={model.model_id}
  457. onChange={(e) => updateModel(index, { model_id: e.target.value })}
  458. className="flex-1"
  459. />
  460. <Input
  461. placeholder={t("modelProviders.displayName")}
  462. value={model.display_name}
  463. onChange={(e) => updateModel(index, { display_name: e.target.value })}
  464. className="w-32"
  465. />
  466. <Select
  467. value={model.model_type}
  468. onChange={(e) => updateModel(index, { model_type: e.target.value as ModelType })}
  469. options={[
  470. { value: "chat", label: "Chat" },
  471. { value: "reasoning", label: "Reasoning" },
  472. { value: "embedding", label: "Embedding" },
  473. { value: "rerank", label: "Rerank" },
  474. ]}
  475. className="w-28"
  476. />
  477. <Button type="button" size="icon" variant="ghost" onClick={() => removeModelRow(index)}>
  478. <Trash2 className="h-4 w-4" />
  479. </Button>
  480. </div>
  481. ))}
  482. </div>
  483. )}
  484. </div>
  485. {discoverOpen && (
  486. <div className="space-y-2 rounded border p-4">
  487. <div className="flex items-center justify-between">
  488. <span className="text-sm font-medium">{t("modelProviders.discoveredModels")}</span>
  489. <div className="flex gap-2">
  490. <Select
  491. value={typeFilter}
  492. onChange={(e) => setTypeFilter(e.target.value)}
  493. options={[{ value: "all", label: t("modelProviders.allTypes") }, ...discoveredTypes.map((t) => ({ value: t, label: t }))]}
  494. className="w-32"
  495. />
  496. <Button type="button" size="sm" variant="outline" onClick={toggleAllDiscovered}>
  497. {selectedDiscovered.size === filteredDiscovered.length ? t("modelProviders.deselectAll") : t("modelProviders.selectAll")}
  498. </Button>
  499. </div>
  500. </div>
  501. <div className="max-h-60 space-y-1 overflow-auto">
  502. {filteredDiscovered.map((m) => (
  503. <button
  504. key={m.model_id}
  505. type="button"
  506. onClick={() => toggleDiscovered(m.model_id)}
  507. 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"}`}
  508. >
  509. <div className={`h-4 w-4 rounded border ${selectedDiscovered.has(m.model_id) ? "border-primary bg-primary" : "border-muted-foreground"}`} />
  510. <div className="flex-1">
  511. <span className="text-sm">{m.display_name}</span>
  512. <span className="ml-2 text-xs text-muted-foreground">{m.model_id}</span>
  513. </div>
  514. <ModelTypeBadge type={m.model_type} />
  515. {m.context_window && (
  516. <span className="text-xs text-muted-foreground">{m.context_window.toLocaleString()} tokens</span>
  517. )}
  518. </button>
  519. ))}
  520. {filteredDiscovered.length === 0 && (
  521. <p className="py-4 text-center text-sm text-muted-foreground">{t("modelProviders.noModelsDiscovered")}</p>
  522. )}
  523. </div>
  524. <div className="flex justify-end gap-2">
  525. <Button type="button" variant="ghost" onClick={() => setDiscoverOpen(false)}>
  526. {t("common.cancel")}
  527. </Button>
  528. <Button type="button" onClick={applyDiscovered} disabled={selectedDiscovered.size === 0}>
  529. {t("modelProviders.applySelected", { count: selectedDiscovered.size })}
  530. </Button>
  531. </div>
  532. </div>
  533. )}
  534. <div className="flex justify-end gap-2 pt-2">
  535. <Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
  536. {t("common.cancel")}
  537. </Button>
  538. <Button type="submit" disabled={submitting}>
  539. {submitting ? t("common.creating") : t("common.save")}
  540. </Button>
  541. </div>
  542. </form>
  543. </Dialog>
  544. );
  545. }