@raingor/pi-web-switch 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1387 @@
1
+ import { useEffect, useState } from "react";
2
+ import { useConfigStore } from "@/store/config-store";
3
+ import { useTranslation } from "@/lib/i18n";
4
+ import { Badge } from "@/components/ui/Badge";
5
+ import { Modal } from "@/components/ui/Modal";
6
+ import { formatTokens, cn } from "@/lib/utils";
7
+ import type { ApiType, CustomProviderConfig, Model, Provider } from "@/types";
8
+ import {
9
+ Plus,
10
+ Trash2,
11
+ Edit3,
12
+ Eye,
13
+ EyeOff,
14
+ Server,
15
+ Shield,
16
+ Box,
17
+ Brain,
18
+ Image as ImageIcon,
19
+ Search,
20
+ Check,
21
+ X,
22
+ Loader2,
23
+ Zap,
24
+ ClipboardPaste,
25
+ } from "lucide-react";
26
+
27
+ const API_TYPES: { value: ApiType; label: string }[] = [
28
+ { value: "openai-completions", label: "Chat Completions (/chat/completions)" },
29
+ { value: "openai-responses", label: "OpenAI Responses" },
30
+ { value: "anthropic-messages", label: "Anthropic Messages" },
31
+ { value: "google-generative-ai", label: "Google Generative AI" },
32
+ { value: "google-vertex", label: "Google Vertex AI" },
33
+ { value: "bedrock-converse-stream", label: "AWS Bedrock" },
34
+ { value: "mistral-conversations", label: "Mistral" },
35
+ ];
36
+
37
+ function isValidHttpUrl(value: string): boolean {
38
+ try {
39
+ const u = new URL(value);
40
+ return u.protocol === "http:" || u.protocol === "https:";
41
+ } catch {
42
+ return false;
43
+ }
44
+ }
45
+
46
+ // Sanitize to a config-safe id: lowercase letters, digits and hyphens only
47
+ function sanitizeProviderId(name: string): string {
48
+ return name
49
+ .trim()
50
+ .toLowerCase()
51
+ .replace(/\s+/g, "-")
52
+ .replace(/[^a-z0-9-]/g, "")
53
+ .replace(/-+/g, "-")
54
+ .replace(/^-|-$/g, "");
55
+ }
56
+
57
+ // ─── Freeform Import Parser ───────────────────────────────
58
+ // Recognizes pasted text like:
59
+ // tokenrouter baseurl:https://api.example.com/v1 key:sk-xxxx
60
+ // modelid:vendor/model-a, vendor/model-b
61
+ // Labels accept half/full-width colons; unlabeled tokens fall back to
62
+ // heuristics (URL → baseUrl, sk-… → apiKey, foo/bar → model id).
63
+
64
+ interface ParsedImport {
65
+ name: string;
66
+ baseUrl: string;
67
+ apiKey: string;
68
+ modelIds: string[];
69
+ }
70
+
71
+ const IMPORT_LABEL_RE =
72
+ /(?<![\w/.\-])(apikey|api_key|api-key|key|token|secret|密钥|金鑰|baseurl|base_url|base-url|url|endpoint|地址|接口|provider|name|名称|名稱|供应商|供應商|model_ids?|modelids?|models?|模型)\s*[::](?!\/\/)/gi;
73
+
74
+ function importField(label: string): "name" | "baseUrl" | "apiKey" | "models" {
75
+ const l = label.toLowerCase();
76
+ if (/^(apikey|api_key|api-key|key|token|secret|密钥|金鑰)$/.test(l)) return "apiKey";
77
+ if (/^(baseurl|base_url|base-url|url|endpoint|地址|接口)$/.test(l)) return "baseUrl";
78
+ if (/^(provider|name|名称|名稱|供应商|供應商)$/.test(l)) return "name";
79
+ return "models";
80
+ }
81
+
82
+ function parseProviderImport(raw: string): ParsedImport {
83
+ const out: ParsedImport = { name: "", baseUrl: "", apiKey: "", modelIds: [] };
84
+ const pushModels = (value: string) => {
85
+ for (const part of value.split(/[\s,,;;]+/)) {
86
+ const v = part.trim();
87
+ if (v && !out.modelIds.includes(v)) out.modelIds.push(v);
88
+ }
89
+ };
90
+ const assignFree = (text: string) => {
91
+ for (const token of text.split(/\s+/)) {
92
+ const v = token.replace(/[,,;;]+$/, "");
93
+ if (!v) continue;
94
+ if (/^https?:\/\//i.test(v)) {
95
+ if (!out.baseUrl) out.baseUrl = v;
96
+ } else if (/^sk-\S{8,}$/i.test(v) || /^[A-Za-z0-9_-]{32,}$/.test(v)) {
97
+ if (!out.apiKey) out.apiKey = v;
98
+ } else if (v.includes("/")) {
99
+ pushModels(v);
100
+ } else if (!out.name) {
101
+ out.name = v;
102
+ }
103
+ }
104
+ };
105
+ for (const line of raw.split(/\r?\n/)) {
106
+ if (!line.trim()) continue;
107
+ const matches = [...line.matchAll(IMPORT_LABEL_RE)];
108
+ const head = (matches.length ? line.slice(0, matches[0]!.index) : line).trim();
109
+ if (head) assignFree(head);
110
+ matches.forEach((m, i) => {
111
+ const start = m.index! + m[0].length;
112
+ const end = i + 1 < matches.length ? matches[i + 1]!.index! : line.length;
113
+ const value = line.slice(start, end).trim().replace(/[,,;;]+$/, "");
114
+ if (!value) return;
115
+ const field = importField(m[1] ?? "");
116
+ if (field === "models") pushModels(value);
117
+ else if (!out[field]) out[field] = value;
118
+ });
119
+ }
120
+ return out;
121
+ }
122
+
123
+ export function ProvidersModelsPage() {
124
+ const { t } = useTranslation();
125
+ const { allProviders, auth, removeCustomProvider } = useConfigStore();
126
+
127
+ const builtinProviders = allProviders.filter((p) => p.type === "builtin");
128
+ const customProviders = allProviders.filter((p) => p.type === "custom");
129
+
130
+ const [selectedId, setSelectedId] = useState<string | null>(null);
131
+ const [adding, setAdding] = useState(false);
132
+ const [importing, setImporting] = useState(false);
133
+ const [importBump, setImportBump] = useState(0);
134
+ const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
135
+ const [deleteError, setDeleteError] = useState(false);
136
+
137
+ // Keep a valid selection (default: first custom, else first builtin)
138
+ const selected = allProviders.find((p) => p.id === selectedId) ?? null;
139
+ useEffect(() => {
140
+ if (!selected && !adding && allProviders.length > 0) {
141
+ setSelectedId(customProviders[0]?.id ?? allProviders[0]?.id ?? null);
142
+ }
143
+ }, [selected, adding, allProviders, customProviders]);
144
+
145
+ const hasKey = (p: Provider) => p.hasAuth || !!p.apiKey || !!auth?.[p.id]?.key;
146
+
147
+ const handleAddProvider = async (id: string, cfg: CustomProviderConfig): Promise<boolean> => {
148
+ const ok = await useConfigStore.getState().addCustomProvider(id, cfg);
149
+ if (ok) {
150
+ setAdding(false);
151
+ setSelectedId(id);
152
+ }
153
+ return ok;
154
+ };
155
+
156
+ const handleDeleteProvider = async () => {
157
+ if (!deleteConfirm) return;
158
+ setDeleteError(false);
159
+ const ok = await removeCustomProvider(deleteConfirm);
160
+ if (ok) {
161
+ if (selectedId === deleteConfirm) setSelectedId(null);
162
+ setDeleteConfirm(null);
163
+ } else {
164
+ setDeleteError(true);
165
+ }
166
+ };
167
+
168
+ return (
169
+ <div className="space-y-6">
170
+ <div>
171
+ <h1 className="text-2xl font-bold" style={{ color: "var(--page-text)" }}>
172
+ {t("nav.providers_models")}
173
+ </h1>
174
+ <p className="mt-1 text-sm" style={{ color: "var(--muted-text)" }}>
175
+ {t("providers_models.subtitle")}
176
+ </p>
177
+ </div>
178
+
179
+ <div className="flex overflow-hidden rounded-xl border border-gray-800 bg-gray-900/50">
180
+ {/* ─── Left: Provider List ─────────────────────── */}
181
+ <div className="w-60 shrink-0 border-r border-gray-800 p-3">
182
+ {builtinProviders.length > 0 && (
183
+ <>
184
+ <p className="px-2 pb-2 pt-1 text-xs font-medium uppercase tracking-wider text-gray-500">
185
+ {t("providers.builtin")}
186
+ </p>
187
+ <div className="space-y-0.5">
188
+ {builtinProviders.map((p) => (
189
+ <ProviderListItem
190
+ key={p.id}
191
+ provider={p}
192
+ active={!adding && selectedId === p.id}
193
+ hasKey={hasKey(p)}
194
+ onClick={() => {
195
+ setAdding(false);
196
+ setSelectedId(p.id);
197
+ }}
198
+ />
199
+ ))}
200
+ </div>
201
+ </>
202
+ )}
203
+
204
+ <p className="px-2 pb-2 pt-4 text-xs font-medium uppercase tracking-wider text-gray-500">
205
+ {t("providers_models.custom_providers")}
206
+ </p>
207
+ <div className="space-y-0.5">
208
+ {customProviders.map((p) => (
209
+ <ProviderListItem
210
+ key={p.id}
211
+ provider={p}
212
+ active={!adding && selectedId === p.id}
213
+ hasKey={hasKey(p)}
214
+ onClick={() => {
215
+ setAdding(false);
216
+ setSelectedId(p.id);
217
+ }}
218
+ />
219
+ ))}
220
+ </div>
221
+
222
+ <button
223
+ onClick={() => setAdding(true)}
224
+ className={cn(
225
+ "mt-2 flex w-full items-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium transition-colors",
226
+ adding
227
+ ? "border-gray-600 bg-gray-800 text-white"
228
+ : "border-gray-700 text-gray-300 hover:bg-gray-800 hover:text-white"
229
+ )}
230
+ >
231
+ <Plus className="h-4 w-4" />
232
+ {t("providers.add_provider")}
233
+ </button>
234
+
235
+ <button
236
+ onClick={() => setImporting(true)}
237
+ className="mt-2 flex w-full items-center gap-2 rounded-lg border border-gray-700 px-3 py-2 text-sm font-medium text-gray-300 transition-colors hover:bg-gray-800 hover:text-white"
238
+ >
239
+ <ClipboardPaste className="h-4 w-4" />
240
+ {t("providers_models.import")}
241
+ </button>
242
+ </div>
243
+
244
+ {/* ─── Right: Provider Detail / Add Form ───────── */}
245
+ <div className="min-w-0 flex-1 p-6">
246
+ {adding ? (
247
+ <AddProviderForm onSubmit={handleAddProvider} onCancel={() => setAdding(false)} />
248
+ ) : selected ? (
249
+ <ProviderDetail
250
+ key={`${selected.id}:${importBump}`}
251
+ provider={selected}
252
+ onDelete={() => setDeleteConfirm(selected.id)}
253
+ />
254
+ ) : (
255
+ <div className="flex h-40 items-center justify-center text-sm text-gray-500">
256
+ {t("providers_models.select_hint")}
257
+ </div>
258
+ )}
259
+ </div>
260
+ </div>
261
+
262
+ {/* Import Provider Modal */}
263
+ <ImportProviderModal
264
+ open={importing}
265
+ onClose={() => setImporting(false)}
266
+ onImported={(id) => {
267
+ setImporting(false);
268
+ setAdding(false);
269
+ setSelectedId(id);
270
+ setImportBump((n) => n + 1);
271
+ }}
272
+ />
273
+
274
+ {/* Delete Provider Confirmation Modal */}
275
+ <Modal
276
+ open={!!deleteConfirm}
277
+ onClose={() => { setDeleteConfirm(null); setDeleteError(false); }}
278
+ title={t("providers.delete_provider")}
279
+ >
280
+ <div className="space-y-4">
281
+ <div className="flex items-start gap-3">
282
+ <Trash2 className="h-5 w-5 shrink-0 mt-0.5 text-red-400" />
283
+ <div>
284
+ <p className="text-sm text-gray-200">
285
+ <strong>{allProviders.find((p) => p.id === deleteConfirm)?.name}</strong>
286
+ {" — "}
287
+ {t("providers.delete_confirm")}
288
+ </p>
289
+ <p className="text-xs mt-2 text-gray-500">
290
+ {t("providers_models.delete_provider_note")}
291
+ </p>
292
+ {deleteError && (
293
+ <p className="text-xs mt-2 text-red-400">{t("providers_models.save_failed")}</p>
294
+ )}
295
+ </div>
296
+ </div>
297
+ <div className="flex justify-end gap-3">
298
+ <button
299
+ onClick={() => { setDeleteConfirm(null); setDeleteError(false); }}
300
+ className="rounded-lg px-4 py-2 text-sm text-gray-400 hover:bg-gray-800"
301
+ >
302
+ {t("models.cancel")}
303
+ </button>
304
+ <button
305
+ onClick={handleDeleteProvider}
306
+ className="flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium text-white"
307
+ style={{ backgroundColor: "#dc2626" }}
308
+ >
309
+ <Trash2 className="h-4 w-4" />
310
+ {t("providers.delete_provider")}
311
+ </button>
312
+ </div>
313
+ </div>
314
+ </Modal>
315
+ </div>
316
+ );
317
+ }
318
+
319
+ // ─── Provider List Item ───────────────────────────────────
320
+
321
+ function ProviderListItem({
322
+ provider,
323
+ active,
324
+ hasKey,
325
+ onClick,
326
+ }: {
327
+ provider: Provider;
328
+ active: boolean;
329
+ hasKey: boolean;
330
+ onClick: () => void;
331
+ }) {
332
+ return (
333
+ <button
334
+ onClick={onClick}
335
+ className={cn(
336
+ "flex w-full items-center gap-2 rounded-lg border px-3 py-2 text-left text-sm transition-colors",
337
+ active
338
+ ? "border-gray-600 bg-gray-800 text-white"
339
+ : "border-transparent text-gray-300 hover:bg-gray-800/60"
340
+ )}
341
+ >
342
+ {provider.type === "custom" ? (
343
+ <Server className="h-4 w-4 shrink-0 text-blue-400" />
344
+ ) : (
345
+ <Shield className="h-4 w-4 shrink-0 text-emerald-400" />
346
+ )}
347
+ <span className="min-w-0 flex-1 truncate">{provider.name}</span>
348
+ <span
349
+ className={cn("h-2 w-2 shrink-0 rounded-full", hasKey ? "bg-emerald-400" : "bg-gray-600")}
350
+ />
351
+ </button>
352
+ );
353
+ }
354
+
355
+ // ─── Connection Test Button ───────────────────────────────
356
+
357
+ type TestState =
358
+ | { status: "idle" }
359
+ | { status: "testing" }
360
+ | { status: "ok"; latencyMs: number }
361
+ | { status: "fail"; message: string };
362
+
363
+ function TestConnectionButton({ baseUrl, apiKey }: { baseUrl: string; apiKey?: string }) {
364
+ const { t } = useTranslation();
365
+ const [test, setTest] = useState<TestState>({ status: "idle" });
366
+
367
+ const runTest = async () => {
368
+ setTest({ status: "testing" });
369
+ try {
370
+ const res = await fetch("/api/pi/provider-test", {
371
+ method: "POST",
372
+ headers: { "Content-Type": "application/json" },
373
+ body: JSON.stringify({ baseUrl: baseUrl.trim(), apiKey: apiKey || undefined }),
374
+ });
375
+ const data = await res.json();
376
+ if (data.success) {
377
+ setTest({ status: "ok", latencyMs: data.latencyMs ?? 0 });
378
+ } else {
379
+ setTest({ status: "fail", message: data.message ?? "unknown" });
380
+ }
381
+ } catch {
382
+ setTest({ status: "fail", message: "network error" });
383
+ }
384
+ };
385
+
386
+ const disabled = !baseUrl.trim() || !isValidHttpUrl(baseUrl.trim()) || test.status === "testing";
387
+
388
+ return (
389
+ <div className="flex items-center gap-3">
390
+ <button
391
+ onClick={runTest}
392
+ disabled={disabled}
393
+ className="flex items-center gap-2 rounded-lg border border-gray-700 px-3 py-1.5 text-sm text-gray-300 transition-colors hover:bg-gray-800 disabled:cursor-not-allowed disabled:opacity-50"
394
+ >
395
+ {test.status === "testing" ? (
396
+ <Loader2 className="h-4 w-4 animate-spin" />
397
+ ) : (
398
+ <Zap className="h-4 w-4" />
399
+ )}
400
+ {test.status === "testing" ? t("providers_models.testing") : t("providers_models.test_connection")}
401
+ </button>
402
+ {test.status === "ok" && (
403
+ <span className="flex items-center gap-1 text-sm text-emerald-400">
404
+ <Check className="h-4 w-4" />
405
+ {t("providers_models.test_ok", String(test.latencyMs))}
406
+ </span>
407
+ )}
408
+ {test.status === "fail" && (
409
+ <span className="flex items-center gap-1 text-sm text-red-400">
410
+ <X className="h-4 w-4" />
411
+ {t("providers_models.test_fail", test.message)}
412
+ </span>
413
+ )}
414
+ </div>
415
+ );
416
+ }
417
+
418
+ // ─── Provider Detail Panel ────────────────────────────────
419
+
420
+ function ProviderDetail({ provider, onDelete }: { provider: Provider; onDelete: () => void }) {
421
+ const { t } = useTranslation();
422
+ const {
423
+ auth,
424
+ settings,
425
+ updateSettings,
426
+ updateCustomProvider,
427
+ setProviderAuth,
428
+ removeProviderAuth,
429
+ addModel,
430
+ updateModel,
431
+ removeModel,
432
+ addEnabledModel,
433
+ removeEnabledModel,
434
+ } = useConfigStore();
435
+
436
+ const isCustom = provider.type === "custom";
437
+ const savedKey = provider.apiKey ?? auth?.[provider.id]?.key ?? "";
438
+
439
+ const [baseUrl, setBaseUrl] = useState(provider.baseUrl ?? "");
440
+ const [api, setApi] = useState<ApiType>(provider.api ?? "openai-completions");
441
+ const [apiKey, setApiKey] = useState(savedKey);
442
+ const [showKey, setShowKey] = useState(false);
443
+ const [editModel, setEditModel] = useState<Model | null>(null);
444
+ const [showAddModel, setShowAddModel] = useState(false);
445
+ const [deleteModel, setDeleteModel] = useState<Model | null>(null);
446
+ const [modelQuery, setModelQuery] = useState("");
447
+ const [saveState, setSaveState] = useState<"idle" | "saving" | "saved" | "error">("idle");
448
+
449
+ const urlInvalid = isCustom && baseUrl.trim() !== "" && !isValidHttpUrl(baseUrl.trim());
450
+
451
+ const dirty =
452
+ (isCustom && (baseUrl !== (provider.baseUrl ?? "") || api !== (provider.api ?? "openai-completions"))) ||
453
+ apiKey !== savedKey;
454
+
455
+ const handleSave = async () => {
456
+ setSaveState("saving");
457
+ let ok = true;
458
+ if (isCustom) {
459
+ ok = await updateCustomProvider(provider.id, {
460
+ baseUrl: baseUrl || undefined,
461
+ api,
462
+ apiKey: apiKey || undefined,
463
+ });
464
+ } else if (apiKey !== savedKey) {
465
+ ok = apiKey
466
+ ? await setProviderAuth(provider.id, apiKey)
467
+ : await removeProviderAuth(provider.id);
468
+ }
469
+ setSaveState(ok ? "saved" : "error");
470
+ if (ok) setTimeout(() => setSaveState("idle"), 2500);
471
+ };
472
+
473
+ // Enabled state lives in settings.enabledModels ("provider/model" refs) —
474
+ // the same source pi reads, so toggling here matches the CLI behavior.
475
+ const isEnabled = (modelId: string) =>
476
+ settings?.enabledModels?.includes(`${provider.id}/${modelId}`) ?? false;
477
+
478
+ const handleToggle = (modelId: string) => {
479
+ const ref = `${provider.id}/${modelId}`;
480
+ if (isEnabled(modelId)) removeEnabledModel(ref);
481
+ else addEnabledModel(ref);
482
+ };
483
+
484
+ const setAllEnabled = (enable: boolean) => {
485
+ const list = settings?.enabledModels ?? [];
486
+ const refs = provider.models.map((m) => `${provider.id}/${m.id}`);
487
+ const next = enable
488
+ ? Array.from(new Set([...list, ...refs]))
489
+ : list.filter((r) => !refs.includes(r));
490
+ updateSettings({ enabledModels: next });
491
+ };
492
+
493
+ const q = modelQuery.trim().toLowerCase();
494
+ const visibleModels = q
495
+ ? provider.models.filter(
496
+ (m) => m.id.toLowerCase().includes(q) || (m.name ?? "").toLowerCase().includes(q)
497
+ )
498
+ : provider.models;
499
+
500
+ return (
501
+ <div className="space-y-5">
502
+ {/* Header */}
503
+ <div className="flex items-center gap-3">
504
+ <h2 className="text-lg font-semibold text-white">{provider.name}</h2>
505
+ <Badge variant={isCustom ? "default" : "info"}>
506
+ {isCustom ? t("providers.custom") : t("providers.builtin")}
507
+ </Badge>
508
+ {savedKey && <Badge variant="success">{t("providers.configured")}</Badge>}
509
+ {isCustom && (
510
+ <button
511
+ onClick={onDelete}
512
+ className="ml-auto rounded-lg p-2 text-gray-500 transition-colors hover:bg-red-500/10 hover:text-red-400"
513
+ title={t("providers.delete_provider")}
514
+ >
515
+ <Trash2 className="h-4 w-4" />
516
+ </button>
517
+ )}
518
+ </div>
519
+
520
+ {/* Base URL */}
521
+ <div>
522
+ <label className="block text-sm text-gray-400">{t("providers.base_url")}</label>
523
+ <input
524
+ type="text"
525
+ value={baseUrl}
526
+ disabled={!isCustom}
527
+ onChange={(e) => setBaseUrl(e.target.value)}
528
+ placeholder="https://api.example.com/v1"
529
+ className={cn(
530
+ "mt-1.5 w-full rounded-lg border bg-gray-800 px-3 py-2.5 text-sm text-white disabled:opacity-50",
531
+ urlInvalid ? "border-red-500" : "border-gray-700"
532
+ )}
533
+ />
534
+ {urlInvalid && (
535
+ <p className="mt-1 text-xs text-red-400">{t("providers_models.invalid_url")}</p>
536
+ )}
537
+ </div>
538
+
539
+ {/* API Type */}
540
+ <div>
541
+ <label className="block text-sm text-gray-400">{t("providers.api_type")}</label>
542
+ <select
543
+ value={api}
544
+ disabled={!isCustom}
545
+ onChange={(e) => setApi(e.target.value as ApiType)}
546
+ className="mt-1.5 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2.5 text-sm text-white disabled:opacity-50"
547
+ >
548
+ {API_TYPES.map((a) => (
549
+ <option key={a.value} value={a.value}>{a.label}</option>
550
+ ))}
551
+ </select>
552
+ </div>
553
+
554
+ {/* API Key */}
555
+ <div>
556
+ <label className="block text-sm text-gray-400">{t("providers.api_key")}</label>
557
+ <div className="relative mt-1.5">
558
+ <input
559
+ type={showKey ? "text" : "password"}
560
+ value={apiKey}
561
+ onChange={(e) => setApiKey(e.target.value)}
562
+ placeholder="sk-... or $MY_API_KEY"
563
+ className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2.5 pr-10 text-sm text-white"
564
+ />
565
+ <button
566
+ onClick={() => setShowKey(!showKey)}
567
+ className="absolute right-2 top-1/2 -translate-y-1/2 rounded-md p-1.5 text-gray-500 hover:text-gray-300"
568
+ >
569
+ {showKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
570
+ </button>
571
+ </div>
572
+ </div>
573
+
574
+ {/* Save / Test / Feedback row */}
575
+ <div className="flex flex-wrap items-center gap-3">
576
+ {dirty && (
577
+ <button
578
+ onClick={handleSave}
579
+ disabled={saveState === "saving" || urlInvalid}
580
+ className="flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-colors disabled:opacity-50"
581
+ style={{ backgroundColor: "#3b82f6", color: "#ffffff" }}
582
+ >
583
+ {saveState === "saving" && <Loader2 className="h-4 w-4 animate-spin" />}
584
+ {t("models.save")}
585
+ </button>
586
+ )}
587
+ {saveState === "saved" && (
588
+ <span className="flex items-center gap-1 text-sm text-emerald-400">
589
+ <Check className="h-4 w-4" />
590
+ {t("providers_models.saved")}
591
+ </span>
592
+ )}
593
+ {saveState === "error" && (
594
+ <span className="flex items-center gap-1 text-sm text-red-400">
595
+ <X className="h-4 w-4" />
596
+ {t("providers_models.save_failed")}
597
+ </span>
598
+ )}
599
+ {isCustom && baseUrl.trim() !== "" && (
600
+ <TestConnectionButton baseUrl={baseUrl} apiKey={apiKey} />
601
+ )}
602
+ </div>
603
+
604
+ {/* Model List */}
605
+ <div>
606
+ <div className="flex items-center justify-between gap-3">
607
+ <label className="block text-sm text-gray-400">{t("providers_models.model_list")}</label>
608
+ {provider.models.length > 0 && (
609
+ <div className="flex items-center gap-2">
610
+ <button
611
+ onClick={() => setAllEnabled(true)}
612
+ className="rounded-md px-2 py-1 text-xs text-emerald-400 transition-colors hover:bg-emerald-500/10"
613
+ >
614
+ {t("providers_models.enable_all")}
615
+ </button>
616
+ <button
617
+ onClick={() => setAllEnabled(false)}
618
+ className="rounded-md px-2 py-1 text-xs text-gray-400 transition-colors hover:bg-gray-800"
619
+ >
620
+ {t("providers_models.disable_all")}
621
+ </button>
622
+ </div>
623
+ )}
624
+ </div>
625
+
626
+ {provider.models.length > 5 && (
627
+ <div className="relative mt-1.5">
628
+ <Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-500" />
629
+ <input
630
+ type="text"
631
+ value={modelQuery}
632
+ onChange={(e) => setModelQuery(e.target.value)}
633
+ placeholder={t("models.search_placeholder")}
634
+ className="w-full rounded-lg border border-gray-700 bg-gray-800 py-2 pl-9 pr-3 text-sm text-white"
635
+ />
636
+ </div>
637
+ )}
638
+
639
+ <div className="mt-1.5 space-y-2 rounded-lg border border-gray-800 p-3">
640
+ {provider.models.length === 0 && (
641
+ <p className="px-1 py-2 text-sm text-gray-500">{t("models.no_models")}</p>
642
+ )}
643
+ {provider.models.length > 0 && visibleModels.length === 0 && (
644
+ <p className="px-1 py-2 text-sm text-gray-500">{t("models.no_models")}</p>
645
+ )}
646
+ {visibleModels.map((m) => (
647
+ <div
648
+ key={m.id}
649
+ className={cn(
650
+ "flex items-center gap-2 rounded-lg border border-gray-700 bg-gray-900/70 px-3 py-2.5",
651
+ !isEnabled(m.id) && "opacity-50"
652
+ )}
653
+ >
654
+ <Box className="h-4 w-4 shrink-0 text-gray-500" />
655
+ <span className="min-w-0 flex-1 truncate font-mono text-sm text-gray-200">
656
+ {m.id}
657
+ </span>
658
+ {m.reasoning && (
659
+ <span title={t("models.reasoning")} className="flex shrink-0">
660
+ <Brain className="h-3.5 w-3.5 text-purple-400" />
661
+ </span>
662
+ )}
663
+ {m.input?.includes("image") && (
664
+ <span title={t("models.image_input")} className="flex shrink-0">
665
+ <ImageIcon className="h-3.5 w-3.5 text-blue-400" />
666
+ </span>
667
+ )}
668
+ <span className="rounded-md border border-gray-600 bg-gray-800/50 px-2 py-0.5 text-xs text-gray-400">
669
+ {m.cost && (m.cost.input || m.cost.output)
670
+ ? `$${m.cost.input}/${m.cost.output}`
671
+ : t("models.free")}
672
+ </span>
673
+ <span className="rounded-md border border-gray-600 bg-gray-800/50 px-2 py-0.5 text-xs text-gray-400">
674
+ {m.contextWindow ? formatTokens(m.contextWindow) : "—"}
675
+ </span>
676
+ <button
677
+ onClick={() => handleToggle(m.id)}
678
+ className={cn(
679
+ "rounded-md px-2 py-1 text-xs transition-colors",
680
+ isEnabled(m.id)
681
+ ? "text-emerald-400 hover:bg-emerald-500/10"
682
+ : "text-gray-500 hover:bg-gray-700"
683
+ )}
684
+ >
685
+ {isEnabled(m.id) ? t("models.enabled") : t("models.disabled")}
686
+ </button>
687
+ <button
688
+ onClick={() => setEditModel(m)}
689
+ className="rounded-md p-1.5 text-gray-500 transition-colors hover:bg-gray-700 hover:text-gray-200"
690
+ title={t("models.edit_model")}
691
+ >
692
+ <Edit3 className="h-3.5 w-3.5" />
693
+ </button>
694
+ <button
695
+ onClick={() => setDeleteModel(m)}
696
+ className="rounded-md p-1.5 text-gray-500 transition-colors hover:bg-red-500/10 hover:text-red-400"
697
+ title={t("models.delete_model")}
698
+ >
699
+ <Trash2 className="h-3.5 w-3.5" />
700
+ </button>
701
+ </div>
702
+ ))}
703
+ </div>
704
+ <button
705
+ onClick={() => setShowAddModel(true)}
706
+ className="mt-3 flex items-center gap-2 rounded-lg border border-gray-700 px-4 py-2 text-sm text-gray-300 transition-colors hover:bg-gray-800"
707
+ >
708
+ <Plus className="h-4 w-4" />
709
+ {t("models.add_model")}
710
+ </button>
711
+ </div>
712
+
713
+ {/* Edit Model Modal */}
714
+ <Modal
715
+ open={!!editModel}
716
+ onClose={() => setEditModel(null)}
717
+ title={`${t("models.edit_model")}: ${editModel?.name || editModel?.id}`}
718
+ size="lg"
719
+ >
720
+ {editModel && (
721
+ <ModelForm
722
+ initial={editModel}
723
+ onSubmit={(form) => {
724
+ updateModel(provider.id, editModel.id, form);
725
+ setEditModel(null);
726
+ }}
727
+ onCancel={() => setEditModel(null)}
728
+ />
729
+ )}
730
+ </Modal>
731
+
732
+ {/* Add Model Modal */}
733
+ <Modal
734
+ open={showAddModel}
735
+ onClose={() => setShowAddModel(false)}
736
+ title={`${t("models.add_model")} — ${provider.name}`}
737
+ size="lg"
738
+ >
739
+ <ModelForm
740
+ onSubmit={(form) => {
741
+ if (!form.id) return;
742
+ addModel(provider.id, form as Model);
743
+ setShowAddModel(false);
744
+ }}
745
+ onCancel={() => setShowAddModel(false)}
746
+ />
747
+ </Modal>
748
+
749
+ {/* Delete Model Confirmation Modal */}
750
+ <Modal
751
+ open={!!deleteModel}
752
+ onClose={() => setDeleteModel(null)}
753
+ title={t("models.delete_model")}
754
+ >
755
+ <div className="space-y-4">
756
+ <div className="flex items-start gap-3">
757
+ <Trash2 className="h-5 w-5 shrink-0 mt-0.5 text-red-400" />
758
+ <p className="text-sm text-gray-200">
759
+ <strong className="font-mono">{deleteModel?.id}</strong>
760
+ {" — "}
761
+ {t("models.delete_confirm")}
762
+ </p>
763
+ </div>
764
+ <div className="flex justify-end gap-3">
765
+ <button
766
+ onClick={() => setDeleteModel(null)}
767
+ className="rounded-lg px-4 py-2 text-sm text-gray-400 hover:bg-gray-800"
768
+ >
769
+ {t("models.cancel")}
770
+ </button>
771
+ <button
772
+ onClick={() => {
773
+ if (deleteModel) {
774
+ removeModel(provider.id, deleteModel.id);
775
+ setDeleteModel(null);
776
+ }
777
+ }}
778
+ className="flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium text-white"
779
+ style={{ backgroundColor: "#dc2626" }}
780
+ >
781
+ <Trash2 className="h-4 w-4" />
782
+ {t("models.delete_model")}
783
+ </button>
784
+ </div>
785
+ </div>
786
+ </Modal>
787
+ </div>
788
+ );
789
+ }
790
+
791
+ // ─── Model Form (add & edit) ──────────────────────────────
792
+
793
+ function ModelForm({
794
+ initial,
795
+ onSubmit,
796
+ onCancel,
797
+ }: {
798
+ initial?: Model;
799
+ onSubmit: (form: Partial<Model>) => void;
800
+ onCancel: () => void;
801
+ }) {
802
+ const { t } = useTranslation();
803
+ const [form, setForm] = useState<Partial<Model>>(
804
+ initial
805
+ ? { ...initial }
806
+ : {
807
+ id: "",
808
+ name: "",
809
+ reasoning: false,
810
+ input: ["text"],
811
+ contextWindow: 128000,
812
+ maxTokens: 16384,
813
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
814
+ }
815
+ );
816
+ const isEdit = !!initial;
817
+
818
+ return (
819
+ <div className="space-y-4">
820
+ <div className="grid grid-cols-2 gap-4">
821
+ <div>
822
+ <label className="block text-xs font-medium text-gray-400">{t("models.model_id")} *</label>
823
+ <input
824
+ type="text"
825
+ value={form.id ?? ""}
826
+ disabled={isEdit}
827
+ onChange={(e) => setForm({ ...form, id: e.target.value })}
828
+ placeholder="my-model-id"
829
+ className="mt-1 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white disabled:opacity-50"
830
+ />
831
+ </div>
832
+ <div>
833
+ <label className="block text-xs font-medium text-gray-400">{t("models.display_name")}</label>
834
+ <input
835
+ type="text"
836
+ value={form.name ?? ""}
837
+ onChange={(e) => setForm({ ...form, name: e.target.value })}
838
+ placeholder="My Custom Model"
839
+ className="mt-1 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white"
840
+ />
841
+ </div>
842
+ <div>
843
+ <label className="block text-xs font-medium text-gray-400">{t("models.context_window")}</label>
844
+ <input
845
+ type="number"
846
+ value={form.contextWindow ?? 128000}
847
+ onChange={(e) => setForm({ ...form, contextWindow: parseInt(e.target.value) || 128000 })}
848
+ className="mt-1 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white"
849
+ />
850
+ </div>
851
+ <div>
852
+ <label className="block text-xs font-medium text-gray-400">{t("models.max_tokens")}</label>
853
+ <input
854
+ type="number"
855
+ value={form.maxTokens ?? 16384}
856
+ onChange={(e) => setForm({ ...form, maxTokens: parseInt(e.target.value) || 16384 })}
857
+ className="mt-1 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white"
858
+ />
859
+ </div>
860
+ </div>
861
+
862
+ {/* Capabilities */}
863
+ <div className="flex flex-wrap gap-3">
864
+ <label className="flex items-center gap-2 text-sm text-gray-300">
865
+ <input
866
+ type="checkbox"
867
+ checked={form.reasoning ?? false}
868
+ onChange={(e) => setForm({ ...form, reasoning: e.target.checked })}
869
+ className="rounded border-gray-600 bg-gray-800 text-blue-500"
870
+ />
871
+ {t("models.reasoning")}
872
+ </label>
873
+ <label className="flex items-center gap-2 text-sm text-gray-300">
874
+ <input
875
+ type="checkbox"
876
+ checked={form.input?.includes("image") ?? false}
877
+ onChange={(e) =>
878
+ setForm({ ...form, input: e.target.checked ? ["text", "image"] : ["text"] })
879
+ }
880
+ className="rounded border-gray-600 bg-gray-800 text-blue-500"
881
+ />
882
+ {t("models.image_input")}
883
+ </label>
884
+ </div>
885
+
886
+ {/* Cost */}
887
+ <div className="grid grid-cols-4 gap-3">
888
+ {(
889
+ [
890
+ ["input", "models.cost_input"],
891
+ ["output", "models.cost_output"],
892
+ ["cacheRead", "models.cost_cache_read"],
893
+ ["cacheWrite", "models.cost_cache_write"],
894
+ ] as const
895
+ ).map(([field, labelKey]) => (
896
+ <div key={field}>
897
+ <label className="block text-xs text-gray-500">{t(labelKey)} $/M</label>
898
+ <input
899
+ type="number"
900
+ step="0.01"
901
+ value={form.cost?.[field] ?? 0}
902
+ onChange={(e) =>
903
+ setForm({
904
+ ...form,
905
+ cost: {
906
+ input: 0, output: 0, cacheRead: 0, cacheWrite: 0,
907
+ ...form.cost,
908
+ [field]: parseFloat(e.target.value) || 0,
909
+ },
910
+ })
911
+ }
912
+ className="mt-1 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white"
913
+ />
914
+ </div>
915
+ ))}
916
+ </div>
917
+
918
+ <div className="flex justify-end gap-3 pt-2">
919
+ <button onClick={onCancel} className="rounded-lg px-4 py-2 text-sm text-gray-400 hover:bg-gray-800">
920
+ {t("models.cancel")}
921
+ </button>
922
+ <button
923
+ onClick={() => onSubmit(form)}
924
+ disabled={!form.id}
925
+ className="rounded-lg px-4 py-2 text-sm font-medium transition-colors disabled:opacity-50"
926
+ style={{ backgroundColor: "#3b82f6", color: "#ffffff" }}
927
+ >
928
+ {isEdit ? t("models.save") : t("models.add_model")}
929
+ </button>
930
+ </div>
931
+ </div>
932
+ );
933
+ }
934
+
935
+ // ─── Add Provider Form ────────────────────────────────────
936
+
937
+ function AddProviderForm({
938
+ onSubmit,
939
+ onCancel,
940
+ }: {
941
+ onSubmit: (id: string, cfg: CustomProviderConfig) => Promise<boolean>;
942
+ onCancel: () => void;
943
+ }) {
944
+ const { t } = useTranslation();
945
+ const { allProviders } = useConfigStore();
946
+ const [name, setName] = useState("");
947
+ const [baseUrl, setBaseUrl] = useState("");
948
+ const [apiKey, setApiKey] = useState("");
949
+ const [api, setApi] = useState<ApiType>("openai-completions");
950
+ const [models, setModels] = useState<Model[]>([]);
951
+ const [showAddModel, setShowAddModel] = useState(false);
952
+ const [submitting, setSubmitting] = useState(false);
953
+ const [submitError, setSubmitError] = useState(false);
954
+
955
+ const id = sanitizeProviderId(name);
956
+ const idExists = !!id && allProviders.some((p) => p.id === id);
957
+ const urlInvalid = baseUrl.trim() !== "" && !isValidHttpUrl(baseUrl.trim());
958
+
959
+ const handleSubmit = async () => {
960
+ if (!id || !baseUrl || idExists || urlInvalid) return;
961
+ setSubmitting(true);
962
+ setSubmitError(false);
963
+ const ok = await onSubmit(id, {
964
+ baseUrl,
965
+ api,
966
+ apiKey: apiKey || undefined,
967
+ models,
968
+ });
969
+ setSubmitting(false);
970
+ if (!ok) setSubmitError(true);
971
+ };
972
+
973
+ return (
974
+ <div className="space-y-5">
975
+ <div>
976
+ <h2 className="text-lg font-semibold text-white">
977
+ {t("providers_models.add_provider_title")}
978
+ </h2>
979
+ <p className="mt-1 text-sm text-gray-500">{t("providers_models.add_provider_desc")}</p>
980
+ </div>
981
+
982
+ {/* Name */}
983
+ <div>
984
+ <label className="block text-sm text-gray-400">{t("providers_models.name")}</label>
985
+ <input
986
+ type="text"
987
+ value={name}
988
+ onChange={(e) => setName(e.target.value)}
989
+ placeholder={t("providers_models.name_placeholder")}
990
+ className={cn(
991
+ "mt-1.5 w-full rounded-lg border bg-gray-800 px-3 py-2.5 text-sm text-white",
992
+ idExists ? "border-red-500" : "border-gray-700"
993
+ )}
994
+ />
995
+ {idExists ? (
996
+ <p className="mt-1 text-xs text-red-400">{t("providers_models.id_exists", id)}</p>
997
+ ) : id ? (
998
+ <p className="mt-1 text-xs text-gray-500">{t("providers_models.id_preview", id)}</p>
999
+ ) : null}
1000
+ </div>
1001
+
1002
+ {/* Base URL */}
1003
+ <div>
1004
+ <label className="block text-sm text-gray-400">{t("providers.base_url")}</label>
1005
+ <input
1006
+ type="text"
1007
+ value={baseUrl}
1008
+ onChange={(e) => setBaseUrl(e.target.value)}
1009
+ placeholder="https://api.example.com/v1"
1010
+ className={cn(
1011
+ "mt-1.5 w-full rounded-lg border bg-gray-800 px-3 py-2.5 text-sm text-white",
1012
+ urlInvalid ? "border-red-500" : "border-gray-700"
1013
+ )}
1014
+ />
1015
+ {urlInvalid && (
1016
+ <p className="mt-1 text-xs text-red-400">{t("providers_models.invalid_url")}</p>
1017
+ )}
1018
+ </div>
1019
+
1020
+ {/* API Key */}
1021
+ <div>
1022
+ <label className="block text-sm text-gray-400">{t("providers.api_key")}</label>
1023
+ <input
1024
+ type="password"
1025
+ value={apiKey}
1026
+ onChange={(e) => setApiKey(e.target.value)}
1027
+ placeholder="sk-... or $MY_API_KEY"
1028
+ className="mt-1.5 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2.5 text-sm text-white"
1029
+ />
1030
+ </div>
1031
+
1032
+ {/* API Type */}
1033
+ <div>
1034
+ <label className="block text-sm text-gray-400">{t("providers.api_type")}</label>
1035
+ <select
1036
+ value={api}
1037
+ onChange={(e) => setApi(e.target.value as ApiType)}
1038
+ className="mt-1.5 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2.5 text-sm text-white"
1039
+ >
1040
+ {API_TYPES.map((a) => (
1041
+ <option key={a.value} value={a.value}>{a.label}</option>
1042
+ ))}
1043
+ </select>
1044
+ </div>
1045
+
1046
+ {/* Connection test with the values entered above */}
1047
+ {baseUrl.trim() !== "" && !urlInvalid && (
1048
+ <TestConnectionButton baseUrl={baseUrl} apiKey={apiKey} />
1049
+ )}
1050
+
1051
+ {/* Initial Model List */}
1052
+ <div>
1053
+ <label className="block text-sm text-gray-400">{t("providers_models.model_list")}</label>
1054
+ {models.length > 0 && (
1055
+ <div className="mt-1.5 space-y-2">
1056
+ {models.map((m) => (
1057
+ <div
1058
+ key={m.id}
1059
+ className="flex items-center gap-2 rounded-lg border border-gray-700 bg-gray-900/70 px-3 py-2.5"
1060
+ >
1061
+ <Box className="h-4 w-4 shrink-0 text-gray-500" />
1062
+ <span className="min-w-0 flex-1 truncate font-mono text-sm text-gray-200">
1063
+ {m.id}
1064
+ </span>
1065
+ <span className="rounded-md border border-gray-600 bg-gray-800/50 px-2 py-0.5 text-xs text-gray-400">
1066
+ {m.contextWindow ? formatTokens(m.contextWindow) : "—"}
1067
+ </span>
1068
+ <button
1069
+ onClick={() => setModels(models.filter((x) => x.id !== m.id))}
1070
+ className="rounded-md p-1.5 text-gray-500 transition-colors hover:bg-red-500/10 hover:text-red-400"
1071
+ title={t("models.delete_model")}
1072
+ >
1073
+ <Trash2 className="h-3.5 w-3.5" />
1074
+ </button>
1075
+ </div>
1076
+ ))}
1077
+ </div>
1078
+ )}
1079
+ <button
1080
+ onClick={() => setShowAddModel(true)}
1081
+ className="mt-2 flex items-center gap-2 rounded-lg border border-gray-700 px-4 py-2 text-sm text-gray-300 transition-colors hover:bg-gray-800"
1082
+ >
1083
+ <Plus className="h-4 w-4" />
1084
+ {t("models.add_model")}
1085
+ </button>
1086
+ </div>
1087
+
1088
+ <div className="flex items-center gap-3">
1089
+ <button
1090
+ onClick={handleSubmit}
1091
+ disabled={!id || !baseUrl || idExists || urlInvalid || submitting}
1092
+ className="flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-colors disabled:opacity-50"
1093
+ style={{ backgroundColor: "#3b82f6", color: "#ffffff" }}
1094
+ >
1095
+ {submitting && <Loader2 className="h-4 w-4 animate-spin" />}
1096
+ {t("providers.add_provider")}
1097
+ </button>
1098
+ <button
1099
+ onClick={onCancel}
1100
+ className="rounded-lg px-4 py-2 text-sm text-gray-400 hover:bg-gray-800"
1101
+ >
1102
+ {t("models.cancel")}
1103
+ </button>
1104
+ {submitError && (
1105
+ <span className="flex items-center gap-1 text-sm text-red-400">
1106
+ <X className="h-4 w-4" />
1107
+ {t("providers_models.save_failed")}
1108
+ </span>
1109
+ )}
1110
+ </div>
1111
+
1112
+ {/* Add Initial Model Modal */}
1113
+ <Modal
1114
+ open={showAddModel}
1115
+ onClose={() => setShowAddModel(false)}
1116
+ title={t("models.add_model")}
1117
+ size="lg"
1118
+ >
1119
+ <ModelForm
1120
+ onSubmit={(form) => {
1121
+ if (!form.id) return;
1122
+ setModels([...models.filter((x) => x.id !== form.id), form as Model]);
1123
+ setShowAddModel(false);
1124
+ }}
1125
+ onCancel={() => setShowAddModel(false)}
1126
+ />
1127
+ </Modal>
1128
+ </div>
1129
+ );
1130
+ }
1131
+
1132
+ // ─── Import Provider Modal ─────────────────────────────────
1133
+
1134
+ function ImportProviderModal({
1135
+ open,
1136
+ onClose,
1137
+ onImported,
1138
+ }: {
1139
+ open: boolean;
1140
+ onClose: () => void;
1141
+ onImported: (id: string) => void;
1142
+ }) {
1143
+ const { t } = useTranslation();
1144
+ const { allProviders } = useConfigStore();
1145
+
1146
+ const [text, setText] = useState("");
1147
+ const [name, setName] = useState("");
1148
+ const [baseUrl, setBaseUrl] = useState("");
1149
+ const [apiKey, setApiKey] = useState("");
1150
+ const [api, setApi] = useState<ApiType>("openai-completions");
1151
+ const [modelIds, setModelIds] = useState<string[]>([]);
1152
+ const [submitting, setSubmitting] = useState(false);
1153
+ const [submitError, setSubmitError] = useState(false);
1154
+
1155
+ // Re-parse on every paste/edit of the raw text; fields below stay editable
1156
+ const handleText = (value: string) => {
1157
+ setText(value);
1158
+ const parsed = parseProviderImport(value);
1159
+ setName(parsed.name);
1160
+ setBaseUrl(parsed.baseUrl);
1161
+ setApiKey(parsed.apiKey);
1162
+ setModelIds(parsed.modelIds);
1163
+ };
1164
+
1165
+ const reset = () => {
1166
+ setText("");
1167
+ setName("");
1168
+ setBaseUrl("");
1169
+ setApiKey("");
1170
+ setApi("openai-completions");
1171
+ setModelIds([]);
1172
+ setSubmitting(false);
1173
+ setSubmitError(false);
1174
+ };
1175
+
1176
+ const id = sanitizeProviderId(name);
1177
+ const existing = allProviders.find((p) => p.id === id);
1178
+ const builtinConflict = existing?.type === "builtin";
1179
+ const mergeTarget = existing?.type === "custom" ? existing : null;
1180
+ const urlInvalid = baseUrl.trim() !== "" && !isValidHttpUrl(baseUrl.trim());
1181
+ const parsedEmpty =
1182
+ text.trim() !== "" && !name && !baseUrl && !apiKey && modelIds.length === 0;
1183
+ const canSubmit =
1184
+ !!id && (!!baseUrl.trim() || !!mergeTarget) && !urlInvalid && !builtinConflict && !submitting;
1185
+
1186
+ const handleImport = async () => {
1187
+ if (!canSubmit) return;
1188
+ setSubmitting(true);
1189
+ setSubmitError(false);
1190
+ const store = useConfigStore.getState();
1191
+ const newModels: Model[] = modelIds.map((mid) => ({
1192
+ id: mid,
1193
+ name: mid.split("/").pop() || mid,
1194
+ input: ["text"],
1195
+ contextWindow: 128000,
1196
+ maxTokens: 16384,
1197
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
1198
+ }));
1199
+
1200
+ let ok: boolean;
1201
+ if (mergeTarget) {
1202
+ // Merge into the existing custom provider (dedupe models by id)
1203
+ const existingCfg = store.modelsJson?.providers[id];
1204
+ const existingModels = existingCfg?.models ?? [];
1205
+ const merged = [
1206
+ ...existingModels,
1207
+ ...newModels.filter((m) => !existingModels.some((e) => e.id === m.id)),
1208
+ ];
1209
+ ok = await store.updateCustomProvider(id, {
1210
+ baseUrl: baseUrl.trim() || existingCfg?.baseUrl,
1211
+ apiKey: apiKey || existingCfg?.apiKey,
1212
+ models: merged,
1213
+ });
1214
+ } else {
1215
+ ok = await store.addCustomProvider(id, {
1216
+ baseUrl: baseUrl.trim(),
1217
+ api,
1218
+ apiKey: apiKey || undefined,
1219
+ models: newModels,
1220
+ });
1221
+ }
1222
+
1223
+ // Imported models are enabled by default (settings.enabledModels refs)
1224
+ if (ok && modelIds.length > 0) {
1225
+ const refs = modelIds.map((m) => `${id}/${m}`);
1226
+ const list = store.settings?.enabledModels ?? [];
1227
+ await store.updateSettings({ enabledModels: Array.from(new Set([...list, ...refs])) });
1228
+ }
1229
+
1230
+ setSubmitting(false);
1231
+ if (ok) {
1232
+ reset();
1233
+ onImported(id);
1234
+ } else {
1235
+ setSubmitError(true);
1236
+ }
1237
+ };
1238
+
1239
+ return (
1240
+ <Modal
1241
+ open={open}
1242
+ onClose={() => { reset(); onClose(); }}
1243
+ title={t("providers_models.import_title")}
1244
+ size="lg"
1245
+ >
1246
+ <div className="space-y-4">
1247
+ <p className="text-sm text-gray-500">{t("providers_models.import_desc")}</p>
1248
+
1249
+ {/* Paste area */}
1250
+ <textarea
1251
+ value={text}
1252
+ onChange={(e) => handleText(e.target.value)}
1253
+ rows={4}
1254
+ placeholder={t("providers_models.import_placeholder")}
1255
+ className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2.5 font-mono text-sm text-white placeholder:text-gray-600"
1256
+ />
1257
+ {parsedEmpty && (
1258
+ <p className="text-xs text-amber-400">{t("providers_models.import_empty")}</p>
1259
+ )}
1260
+
1261
+ {/* Parsed preview (editable) */}
1262
+ <div className="grid grid-cols-2 gap-4">
1263
+ <div>
1264
+ <label className="block text-xs font-medium text-gray-400">
1265
+ {t("providers_models.name")}
1266
+ </label>
1267
+ <input
1268
+ type="text"
1269
+ value={name}
1270
+ onChange={(e) => setName(e.target.value)}
1271
+ placeholder={t("providers_models.name_placeholder")}
1272
+ className={cn(
1273
+ "mt-1 w-full rounded-lg border bg-gray-800 px-3 py-2 text-sm text-white",
1274
+ builtinConflict ? "border-red-500" : "border-gray-700"
1275
+ )}
1276
+ />
1277
+ {builtinConflict ? (
1278
+ <p className="mt-1 text-xs text-red-400">
1279
+ {t("providers_models.import_builtin_conflict", id)}
1280
+ </p>
1281
+ ) : mergeTarget ? (
1282
+ <p className="mt-1 text-xs text-amber-400">
1283
+ {t("providers_models.import_merge", id)}
1284
+ </p>
1285
+ ) : id ? (
1286
+ <p className="mt-1 text-xs text-gray-500">{t("providers_models.id_preview", id)}</p>
1287
+ ) : null}
1288
+ </div>
1289
+ <div>
1290
+ <label className="block text-xs font-medium text-gray-400">
1291
+ {t("providers.api_type")}
1292
+ </label>
1293
+ <select
1294
+ value={api}
1295
+ onChange={(e) => setApi(e.target.value as ApiType)}
1296
+ className="mt-1 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white"
1297
+ >
1298
+ {API_TYPES.map((a) => (
1299
+ <option key={a.value} value={a.value}>{a.label}</option>
1300
+ ))}
1301
+ </select>
1302
+ </div>
1303
+ <div>
1304
+ <label className="block text-xs font-medium text-gray-400">
1305
+ {t("providers.base_url")}
1306
+ </label>
1307
+ <input
1308
+ type="text"
1309
+ value={baseUrl}
1310
+ onChange={(e) => setBaseUrl(e.target.value)}
1311
+ placeholder="https://api.example.com/v1"
1312
+ className={cn(
1313
+ "mt-1 w-full rounded-lg border bg-gray-800 px-3 py-2 text-sm text-white",
1314
+ urlInvalid ? "border-red-500" : "border-gray-700"
1315
+ )}
1316
+ />
1317
+ {urlInvalid && (
1318
+ <p className="mt-1 text-xs text-red-400">{t("providers_models.invalid_url")}</p>
1319
+ )}
1320
+ </div>
1321
+ <div>
1322
+ <label className="block text-xs font-medium text-gray-400">
1323
+ {t("providers.api_key")}
1324
+ </label>
1325
+ <input
1326
+ type="password"
1327
+ value={apiKey}
1328
+ onChange={(e) => setApiKey(e.target.value)}
1329
+ placeholder="sk-... or $MY_API_KEY"
1330
+ className="mt-1 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white"
1331
+ />
1332
+ </div>
1333
+ </div>
1334
+
1335
+ {/* Detected models */}
1336
+ {modelIds.length > 0 && (
1337
+ <div>
1338
+ <label className="block text-xs font-medium text-gray-400">
1339
+ {t("providers_models.model_list")}
1340
+ </label>
1341
+ <div className="mt-1.5 flex flex-wrap gap-2">
1342
+ {modelIds.map((mid) => (
1343
+ <span
1344
+ key={mid}
1345
+ className="flex items-center gap-1.5 rounded-md border border-gray-700 bg-gray-800 px-2 py-1 font-mono text-xs text-gray-200"
1346
+ >
1347
+ <Box className="h-3 w-3 text-gray-500" />
1348
+ {mid}
1349
+ <button
1350
+ onClick={() => setModelIds(modelIds.filter((x) => x !== mid))}
1351
+ className="text-gray-500 hover:text-red-400"
1352
+ >
1353
+ <X className="h-3 w-3" />
1354
+ </button>
1355
+ </span>
1356
+ ))}
1357
+ </div>
1358
+ </div>
1359
+ )}
1360
+
1361
+ <div className="flex items-center justify-end gap-3 pt-1">
1362
+ {submitError && (
1363
+ <span className="flex items-center gap-1 text-sm text-red-400">
1364
+ <X className="h-4 w-4" />
1365
+ {t("providers_models.save_failed")}
1366
+ </span>
1367
+ )}
1368
+ <button
1369
+ onClick={() => { reset(); onClose(); }}
1370
+ className="rounded-lg px-4 py-2 text-sm text-gray-400 hover:bg-gray-800"
1371
+ >
1372
+ {t("models.cancel")}
1373
+ </button>
1374
+ <button
1375
+ onClick={handleImport}
1376
+ disabled={!canSubmit}
1377
+ className="flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-colors disabled:opacity-50"
1378
+ style={{ backgroundColor: "#3b82f6", color: "#ffffff" }}
1379
+ >
1380
+ {submitting && <Loader2 className="h-4 w-4 animate-spin" />}
1381
+ {t("providers_models.import")}
1382
+ </button>
1383
+ </div>
1384
+ </div>
1385
+ </Modal>
1386
+ );
1387
+ }