@raingor/pi-web-switch 0.3.2 → 0.4.0

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.
@@ -1,10 +1,12 @@
1
- import { useEffect, useState } from "react";
1
+ import { useEffect, useMemo, useRef, useState } from "react";
2
2
  import { useConfigStore } from "@/store/config-store";
3
3
  import { useTranslation } from "@/lib/i18n";
4
4
  import { Badge } from "@/components/ui/Badge";
5
5
  import { Modal } from "@/components/ui/Modal";
6
- import { formatTokens, cn } from "@/lib/utils";
6
+ import { formatTokens, cn, formatCost, USD_TO_CNY } from "@/lib/utils";
7
+ import { useCurrency } from "@/lib/currency";
7
8
  import type { ApiType, CustomProviderConfig, Model, Provider } from "@/types";
9
+ import { searchCatalog, catalogToModel, guessModelMeta } from "@/data/model-catalog";
8
10
  import {
9
11
  Plus,
10
12
  Trash2,
@@ -22,11 +24,21 @@ import {
22
24
  Loader2,
23
25
  Zap,
24
26
  ClipboardPaste,
27
+ Download,
28
+ SquareCheck,
29
+ Copy,
30
+ ChevronDown,
31
+ ChevronUp,
32
+ Sparkles,
33
+ Mic,
34
+ Wand2,
25
35
  } from "lucide-react";
26
36
 
27
37
  const API_TYPES: { value: ApiType; label: string }[] = [
28
38
  { value: "openai-completions", label: "Chat Completions (/chat/completions)" },
29
39
  { value: "openai-responses", label: "OpenAI Responses" },
40
+ { value: "openai-codex-responses", label: "OpenAI Codex Responses" },
41
+ { value: "azure-openai-responses", label: "Azure OpenAI Responses" },
30
42
  { value: "anthropic-messages", label: "Anthropic Messages" },
31
43
  { value: "google-generative-ai", label: "Google Generative AI" },
32
44
  { value: "google-vertex", label: "Google Vertex AI" },
@@ -34,6 +46,19 @@ const API_TYPES: { value: ApiType; label: string }[] = [
34
46
  { value: "mistral-conversations", label: "Mistral" },
35
47
  ];
36
48
 
49
+ // Shape returned by /api/pi/provider-models (see server/pi-reader.ts FetchedModel)
50
+ interface FetchedModel {
51
+ id: string;
52
+ name?: string;
53
+ contextWindow?: number;
54
+ maxTokens?: number;
55
+ reasoning?: boolean;
56
+ vision?: boolean;
57
+ audio?: boolean;
58
+ cost?: { input: number; output: number; cacheRead?: number; cacheWrite?: number };
59
+ source?: string;
60
+ }
61
+
37
62
  function isValidHttpUrl(value: string): boolean {
38
63
  try {
39
64
  const u = new URL(value);
@@ -43,6 +68,11 @@ function isValidHttpUrl(value: string): boolean {
43
68
  }
44
69
  }
45
70
 
71
+ // Defaults for models without explicit limits: 256K context, 32K output
72
+ // (32K matches the common max-output ceiling of current mainstream models)
73
+ const DEFAULT_CONTEXT_WINDOW = 262144;
74
+ const DEFAULT_MAX_TOKENS = 32768;
75
+
46
76
  // Sanitize to a config-safe id: lowercase letters, digits and hyphens only
47
77
  function sanitizeProviderId(name: string): string {
48
78
  return name
@@ -54,6 +84,21 @@ function sanitizeProviderId(name: string): string {
54
84
  .replace(/^-|-$/g, "");
55
85
  }
56
86
 
87
+ // Non-ASCII names (e.g. 中文) sanitize to "" — fall back to the endpoint hostname
88
+ // so the provider still gets a valid id while keeping the original display name.
89
+ function deriveProviderId(name: string, baseUrl: string): string {
90
+ const fromName = sanitizeProviderId(name);
91
+ if (fromName || !name.trim()) return fromName;
92
+ try {
93
+ const host = new URL(baseUrl.trim()).hostname;
94
+ const skip = new Set(["api", "www", "app", "gateway", "open", "openapi", "platform"]);
95
+ const part = host.split(".").find((p) => p && !skip.has(p.toLowerCase()));
96
+ return sanitizeProviderId(part ?? "");
97
+ } catch {
98
+ return "";
99
+ }
100
+ }
101
+
57
102
  // ─── Freeform Import Parser ───────────────────────────────
58
103
  // Recognizes pasted text like:
59
104
  // tokenrouter baseurl:https://api.example.com/v1 key:sk-xxxx
@@ -98,7 +143,8 @@ function parseProviderImport(raw: string): ParsedImport {
98
143
  } else if (v.includes("/")) {
99
144
  pushModels(v);
100
145
  } else if (!out.name) {
101
- out.name = v;
146
+ // A label line like "百灵:" keeps a trailing colon — strip it
147
+ out.name = v.replace(/[::]\s*$/, "").trim();
102
148
  }
103
149
  }
104
150
  };
@@ -120,30 +166,107 @@ function parseProviderImport(raw: string): ParsedImport {
120
166
  return out;
121
167
  }
122
168
 
169
+ // ─── Enabled Models Panel (cross-provider) ────────────────
170
+ // Lists every enabled model across all providers. Shares settings.enabledModels
171
+ // as the single source of truth with the per-provider model rows, so toggling
172
+ // here stays in sync with the enable/disable state inside each provider.
173
+
174
+ function EnabledModelsPanel() {
175
+ const { t } = useTranslation();
176
+ const { allModels, settings, removeEnabledModel, updateSettings } = useConfigStore();
177
+ const enabledSet = new Set(settings?.enabledModels ?? []);
178
+ const enabledModels = allModels.filter((m) => enabledSet.has(`${m.providerId}/${m.id}`));
179
+
180
+ const disableAll = () => updateSettings({ enabledModels: [] });
181
+
182
+ return (
183
+ <div className="rounded-xl border border-gray-800 bg-gray-900/50 p-4">
184
+ <div className="flex items-center justify-between gap-3">
185
+ <div className="flex items-center gap-2">
186
+ <Zap className="h-4 w-4 text-emerald-400" />
187
+ <h2 className="text-sm font-semibold" style={{ color: "var(--page-text)" }}>
188
+ {t("providers_models.enabled_models_title")}
189
+ </h2>
190
+ <span className="rounded-full border border-gray-700 bg-gray-800 px-2 py-0.5 text-xs text-gray-400">
191
+ {enabledModels.length}
192
+ </span>
193
+ </div>
194
+ {enabledModels.length > 0 && (
195
+ <button
196
+ onClick={disableAll}
197
+ className="rounded-lg border border-gray-700 px-3 py-1 text-xs text-gray-300 transition-colors hover:bg-gray-800 hover:text-white"
198
+ >
199
+ {t("providers_models.disable_all")}
200
+ </button>
201
+ )}
202
+ </div>
203
+
204
+ {enabledModels.length === 0 ? (
205
+ <p className="mt-3 text-sm text-gray-500">{t("providers_models.no_enabled_models")}</p>
206
+ ) : (
207
+ <div className="mt-3 grid max-h-72 gap-1.5 overflow-y-auto pr-1 lg:grid-cols-2">
208
+ {enabledModels.map((m) => {
209
+ const ref = `${m.providerId}/${m.id}`;
210
+ return (
211
+ <div
212
+ key={ref}
213
+ className="flex items-center gap-2 rounded-lg border border-gray-700 bg-gray-900/70 px-3 py-2"
214
+ >
215
+ <button
216
+ onClick={() => removeEnabledModel(ref)}
217
+ title={t("models.enabled")}
218
+ className="relative inline-flex h-4 w-7 shrink-0 items-center rounded-full bg-emerald-500 transition-colors"
219
+ >
220
+ <span className="inline-block h-3 w-3 transform translate-x-3.5 rounded-full bg-white transition-transform" />
221
+ </button>
222
+ <Box className="h-4 w-4 shrink-0 text-gray-500" />
223
+ <span className="min-w-0 flex-1 truncate font-mono text-sm text-gray-200">
224
+ {m.name || m.id}
225
+ </span>
226
+ <span className="shrink-0 rounded-md border border-gray-600 bg-gray-800/50 px-2 py-0.5 text-xs text-gray-400">
227
+ {m.providerName}
228
+ </span>
229
+ </div>
230
+ );
231
+ })}
232
+ </div>
233
+ )}
234
+ </div>
235
+ );
236
+ }
237
+
123
238
  export function ProvidersModelsPage() {
124
239
  const { t } = useTranslation();
125
- const { allProviders, auth, removeCustomProvider } = useConfigStore();
240
+ const { allProviders, auth, modelsJson, removeCustomProvider } = useConfigStore();
241
+ const hasKey = (p: Provider) => p.hasAuth || !!p.apiKey || !!auth?.[p.id]?.key;
126
242
 
127
- const builtinProviders = allProviders.filter((p) => p.type === "builtin");
243
+ const builtinProviders = allProviders
244
+ .filter((p) => p.type === "builtin")
245
+ .sort((a, b) => {
246
+ const aKey = hasKey(a) ? 1 : 0;
247
+ const bKey = hasKey(b) ? 1 : 0;
248
+ if (aKey !== bKey) return bKey - aKey;
249
+ return a.name.localeCompare(b.name);
250
+ });
128
251
  const customProviders = allProviders.filter((p) => p.type === "custom");
129
252
 
130
253
  const [selectedId, setSelectedId] = useState<string | null>(null);
131
254
  const [adding, setAdding] = useState(false);
132
255
  const [importing, setImporting] = useState(false);
133
256
  const [importBump, setImportBump] = useState(0);
257
+ const [builtinExpanded, setBuiltinExpanded] = useState(false);
134
258
  const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
135
259
  const [deleteError, setDeleteError] = useState(false);
136
260
 
137
261
  // Keep a valid selection (default: first custom, else first builtin)
138
262
  const selected = allProviders.find((p) => p.id === selectedId) ?? null;
263
+ const visibleBuiltinProviders = builtinExpanded ? builtinProviders : builtinProviders.slice(0, 10);
139
264
  useEffect(() => {
140
265
  if (!selected && !adding && allProviders.length > 0) {
141
266
  setSelectedId(customProviders[0]?.id ?? allProviders[0]?.id ?? null);
142
267
  }
143
268
  }, [selected, adding, allProviders, customProviders]);
144
269
 
145
- const hasKey = (p: Provider) => p.hasAuth || !!p.apiKey || !!auth?.[p.id]?.key;
146
-
147
270
  const handleAddProvider = async (id: string, cfg: CustomProviderConfig): Promise<boolean> => {
148
271
  const ok = await useConfigStore.getState().addCustomProvider(id, cfg);
149
272
  if (ok) {
@@ -165,6 +288,40 @@ export function ProvidersModelsPage() {
165
288
  }
166
289
  };
167
290
 
291
+ const handleDuplicateProvider = async (id: string) => {
292
+ const existing = modelsJson?.providers[id];
293
+ // Resolve the source models: prefer models.json override, else the
294
+ // builtin provider's model list so duplicates keep their models.
295
+ const sourceProvider = allProviders.find((p) => p.id === id);
296
+ const sourceModels = existing?.models ?? sourceProvider?.models ?? [];
297
+ const suffix = "-copy";
298
+ let newId = sanitizeProviderId(id + suffix);
299
+ // Ensure uniqueness against existing provider ids
300
+ const taken = new Set(allProviders.map((p) => p.id));
301
+ let i = 2;
302
+ while (taken.has(newId)) newId = sanitizeProviderId(`${id}-copy${i++}`);
303
+ // Copy config but clear apiKey; carry models + headers + overrides
304
+ const cfg: CustomProviderConfig = {
305
+ name: `${sourceProvider?.name ?? id} (copy)`,
306
+ baseUrl: existing?.baseUrl ?? sourceProvider?.baseUrl,
307
+ api: existing?.api ?? sourceProvider?.api,
308
+ headers: existing?.headers,
309
+ compat: existing?.compat,
310
+ modelOverrides: existing?.modelOverrides,
311
+ models: sourceModels.map((m) => ({ ...m, enabled: true })),
312
+ };
313
+ const ok = await useConfigStore.getState().addCustomProvider(newId, cfg);
314
+ if (ok) {
315
+ // Enable the carried models in settings.enabledModels
316
+ const list = useConfigStore.getState().settings?.enabledModels ?? [];
317
+ const refs = sourceModels.map((m) => `${newId}/${m.id}`);
318
+ await useConfigStore.getState().updateSettings({
319
+ enabledModels: Array.from(new Set([...list, ...refs])),
320
+ });
321
+ setSelectedId(newId);
322
+ }
323
+ };
324
+
168
325
  return (
169
326
  <div className="space-y-6">
170
327
  <div>
@@ -176,28 +333,50 @@ export function ProvidersModelsPage() {
176
333
  </p>
177
334
  </div>
178
335
 
336
+ <EnabledModelsPanel />
337
+
179
338
  <div className="flex overflow-hidden rounded-xl border border-gray-800 bg-gray-900/50">
180
339
  {/* ─── Left: Provider List ─────────────────────── */}
181
340
  <div className="w-60 shrink-0 border-r border-gray-800 p-3">
182
341
  {builtinProviders.length > 0 && (
183
342
  <>
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>
343
+ <div className="flex items-center justify-between px-2 pb-2 pt-1">
344
+ <p className="text-xs font-medium uppercase tracking-wider text-gray-500">
345
+ {t("providers.builtin")} ({builtinProviders.length})
346
+ </p>
347
+ </div>
187
348
  <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
- />
349
+ {visibleBuiltinProviders.map((p) => (
350
+ <ProviderListItem
351
+ key={p.id}
352
+ provider={p}
353
+ active={!adding && selectedId === p.id}
354
+ hasKey={hasKey(p)}
355
+ onClick={() => {
356
+ setAdding(false);
357
+ setSelectedId(p.id);
358
+ }}
359
+ />
199
360
  ))}
200
361
  </div>
362
+ {builtinProviders.length > 10 && (
363
+ <button
364
+ onClick={() => setBuiltinExpanded(!builtinExpanded)}
365
+ className="mt-1 flex w-full items-center justify-center gap-1 rounded-lg py-1.5 text-xs text-gray-500 transition-colors hover:bg-gray-800/70 hover:text-gray-300"
366
+ >
367
+ {builtinExpanded ? (
368
+ <>
369
+ <ChevronUp className="h-3.5 w-3.5" />
370
+ {t("providers_models.collapse")}
371
+ </>
372
+ ) : (
373
+ <>
374
+ <ChevronDown className="h-3.5 w-3.5" />
375
+ {t("providers_models.expand", String(builtinProviders.length - 10))}
376
+ </>
377
+ )}
378
+ </button>
379
+ )}
201
380
  </>
202
381
  )}
203
382
 
@@ -250,6 +429,7 @@ export function ProvidersModelsPage() {
250
429
  key={`${selected.id}:${importBump}`}
251
430
  provider={selected}
252
431
  onDelete={() => setDeleteConfirm(selected.id)}
432
+ onDuplicate={() => handleDuplicateProvider(selected.id)}
253
433
  />
254
434
  ) : (
255
435
  <div className="flex h-40 items-center justify-center text-sm text-gray-500">
@@ -417,8 +597,9 @@ function TestConnectionButton({ baseUrl, apiKey }: { baseUrl: string; apiKey?: s
417
597
 
418
598
  // ─── Provider Detail Panel ────────────────────────────────
419
599
 
420
- function ProviderDetail({ provider, onDelete }: { provider: Provider; onDelete: () => void }) {
600
+ function ProviderDetail({ provider, onDelete, onDuplicate }: { provider: Provider; onDelete: () => void; onDuplicate: () => void }) {
421
601
  const { t } = useTranslation();
602
+ const { currency } = useCurrency();
422
603
  const {
423
604
  auth,
424
605
  settings,
@@ -433,9 +614,31 @@ function ProviderDetail({ provider, onDelete }: { provider: Provider; onDelete:
433
614
  removeEnabledModel,
434
615
  } = useConfigStore();
435
616
 
617
+ // Whether a model is currently enabled (source of truth: settings.enabledModels)
618
+ const enabledRefs = new Set(settings?.enabledModels ?? []);
619
+ const isModelEnabled = (modelId: string) => enabledRefs.has(`${provider.id}/${modelId}`);
620
+
621
+ // Toggle a single model's enabled state
622
+ const toggleModelEnabled = (modelId: string) => {
623
+ const ref = `${provider.id}/${modelId}`;
624
+ if (enabledRefs.has(ref)) removeEnabledModel(ref);
625
+ else addEnabledModel(ref);
626
+ };
627
+
628
+ // Enable/disable all models of this provider at once (batched)
629
+ const setAllModelsEnabled = async (on: boolean) => {
630
+ const list = settings?.enabledModels ?? [];
631
+ const refs = provider.models.map((m) => `${provider.id}/${m.id}`);
632
+ const set = new Set(list);
633
+ if (on) refs.forEach((r) => set.add(r));
634
+ else refs.forEach((r) => set.delete(r));
635
+ await updateSettings({ enabledModels: Array.from(set) });
636
+ };
637
+
436
638
  const isCustom = provider.type === "custom";
437
639
  const savedKey = provider.apiKey ?? auth?.[provider.id]?.key ?? "";
438
640
 
641
+ const [providerName, setProviderName] = useState(provider.name ?? "");
439
642
  const [baseUrl, setBaseUrl] = useState(provider.baseUrl ?? "");
440
643
  const [api, setApi] = useState<ApiType>(provider.api ?? "openai-completions");
441
644
  const [apiKey, setApiKey] = useState(savedKey);
@@ -444,12 +647,211 @@ function ProviderDetail({ provider, onDelete }: { provider: Provider; onDelete:
444
647
  const [showAddModel, setShowAddModel] = useState(false);
445
648
  const [deleteModel, setDeleteModel] = useState<Model | null>(null);
446
649
  const [modelQuery, setModelQuery] = useState("");
650
+ const [modelSort, setModelSort] = useState<"default" | "family" | "price-asc" | "price-desc">("default");
651
+ const [supportsDeveloperRole, setSupportsDeveloperRole] = useState(
652
+ provider.compat?.supportsDeveloperRole ?? false
653
+ );
447
654
  const [saveState, setSaveState] = useState<"idle" | "saving" | "saved" | "error">("idle");
448
655
 
449
- const urlInvalid = isCustom && baseUrl.trim() !== "" && !isValidHttpUrl(baseUrl.trim());
656
+ // ─── Quick add (inline, one-liner) ───
657
+ const [quickId, setQuickId] = useState("");
658
+ const [quickHint, setQuickHint] = useState<string | null>(null);
659
+ const [copiedParams, setCopiedParams] = useState<string | null>(null);
660
+
661
+ // ─── Fetch Models State ───
662
+ const [fetchOpen, setFetchOpen] = useState(false);
663
+ const [fetching, setFetching] = useState(false);
664
+ const [fetchError, setFetchError] = useState<string | null>(null);
665
+ const [fetchedModels, setFetchedModels] = useState<FetchedModel[]>([]);
666
+ const [fetchSelected, setFetchSelected] = useState<Set<string>>(new Set());
667
+ const [fetchImported, setFetchImported] = useState<number | null>(null);
668
+
669
+ // ─── Per-Model Test State ───
670
+ const [modelTests, setModelTests] = useState<Map<string, TestState>>(new Map());
671
+ const getModelTest = (modelId: string) => modelTests.get(modelId) ?? { status: "idle" };
672
+
673
+ const existingModelIds = new Set(provider.models.map((m) => m.id));
674
+ const availableModels = fetchedModels.filter((m) => !existingModelIds.has(m.id));
675
+ const isSelected = (id: string) => fetchSelected.has(id);
676
+ const allSelected = availableModels.length > 0 && availableModels.every((m) => isSelected(m.id));
677
+
678
+ const toggleSelect = (id: string) => {
679
+ setFetchSelected((prev) => {
680
+ const next = new Set(prev);
681
+ next.has(id) ? next.delete(id) : next.add(id);
682
+ return next;
683
+ });
684
+ };
685
+ const toggleAll = () => {
686
+ setFetchSelected((prev) => {
687
+ if (availableModels.every((m) => isSelected(m.id))) return new Set();
688
+ return new Set(availableModels.map((m) => m.id));
689
+ });
690
+ };
691
+
692
+ const fetchModels = async () => {
693
+ if (!baseUrl.trim() || !isValidHttpUrl(baseUrl.trim())) return;
694
+ setFetchOpen(true);
695
+ setFetching(true);
696
+ setFetchError(null);
697
+ setFetchedModels([]);
698
+ setFetchSelected(new Set());
699
+ setFetchImported(null);
700
+ try {
701
+ const res = await fetch("/api/pi/provider-models", {
702
+ method: "POST",
703
+ headers: { "Content-Type": "application/json" },
704
+ body: JSON.stringify({ baseUrl: baseUrl.trim(), apiKey, providerId: provider.id }),
705
+ });
706
+ const data = await res.json();
707
+ if (data.error) setFetchError(data.error);
708
+ else setFetchedModels(data.models ?? []);
709
+ } catch {
710
+ setFetchError("network error");
711
+ } finally {
712
+ setFetching(false);
713
+ }
714
+ };
715
+
716
+ const handleImportFetched = async () => {
717
+ const selected = availableModels.filter((m) => isSelected(m.id));
718
+ if (selected.length === 0) return;
719
+ selected.forEach((m) => {
720
+ const input: Model["input"] = ["text"];
721
+ if (m.vision) input.push("image");
722
+ if (m.audio) input.push("audio");
723
+ addModel(provider.id, {
724
+ id: m.id,
725
+ name: m.name,
726
+ reasoning: m.reasoning ?? false,
727
+ input,
728
+ contextWindow: m.contextWindow ?? DEFAULT_CONTEXT_WINDOW,
729
+ maxTokens: m.maxTokens ?? DEFAULT_MAX_TOKENS,
730
+ cost: m.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
731
+ } as Model);
732
+ });
733
+ // Enable in a single batched write — per-model addEnabledModel calls race
734
+ // on the same settings snapshot and overwrite each other.
735
+ const list = settings?.enabledModels ?? [];
736
+ const refs = selected.map((m) => `${provider.id}/${m.id}`);
737
+ await updateSettings({ enabledModels: Array.from(new Set([...list, ...refs])) });
738
+ setFetchImported(selected.length);
739
+ setTimeout(() => {
740
+ setFetchOpen(false); setFetchedModels([]); setFetchSelected(new Set());
741
+ setFetchImported(null); setFetchError(null);
742
+ }, 1500);
743
+ };
744
+
745
+ const handleTestModel = async (m: Model) => {
746
+ const modelId = m.id;
747
+ setModelTests((prev) => {
748
+ const next = new Map(prev);
749
+ next.set(modelId, { status: "testing" });
750
+ return next;
751
+ });
752
+ try {
753
+ const res = await fetch("/api/pi/model-test", {
754
+ method: "POST",
755
+ headers: { "Content-Type": "application/json" },
756
+ body: JSON.stringify({ baseUrl: baseUrl.trim(), modelId, apiKey, apiType: api ?? undefined }),
757
+ });
758
+ const data = await res.json();
759
+ setModelTests((prev) => {
760
+ const next = new Map(prev);
761
+ next.set(modelId, data.success
762
+ ? { status: "ok" as const, latencyMs: data.latencyMs ?? 0 }
763
+ : { status: "fail" as const, message: data.message ?? "unknown" });
764
+ return next;
765
+ });
766
+ } catch {
767
+ setModelTests((prev) => {
768
+ const next = new Map(prev);
769
+ next.set(modelId, { status: "fail" as const, message: "network error" });
770
+ return next;
771
+ });
772
+ }
773
+ };
774
+
775
+ // Test every model of the provider sequentially (reuses /api/pi/model-test).
776
+ const handleTestAll = async () => {
777
+ for (const m of provider.models) {
778
+ await handleTestModel(m);
779
+ }
780
+ };
781
+
782
+ // Quick-add id changes → live hint
783
+ useEffect(() => {
784
+ const id = quickId.trim();
785
+ if (!id) { setQuickHint(null); return; }
786
+ const g = guessModelMeta(id);
787
+ setQuickHint(
788
+ g.source === "catalog" && g.matched
789
+ ? t("models.detected_catalog", g.matched)
790
+ : g.source === "heuristic"
791
+ ? t("models.detected_heuristic")
792
+ : null
793
+ );
794
+ }, [quickId, t]);
795
+
796
+ const handleQuickAdd = async () => {
797
+ const id = quickId.trim();
798
+ if (!id) return;
799
+ // Don't duplicate
800
+ if (provider.models.some((m) => m.id === id)) {
801
+ setQuickId("");
802
+ setQuickHint(null);
803
+ return;
804
+ }
805
+ const g = guessModelMeta(id);
806
+ const cw = g.contextWindow ?? DEFAULT_CONTEXT_WINDOW;
807
+ const mt = g.contextWindow
808
+ ? (g.contextWindow >= 1_000_000 ? 65536 : g.contextWindow >= 200_000 ? 32768 : 8192)
809
+ : DEFAULT_MAX_TOKENS;
810
+ // Pull full cost/name from catalog if available
811
+ const entries = searchCatalog(id, 5);
812
+ const match = g.source === "catalog"
813
+ ? entries.find((e) => e.patterns.some((p) => id.toLowerCase().includes(p.toLowerCase())))
814
+ : undefined;
815
+ const model: Model = {
816
+ id,
817
+ name: match?.name,
818
+ reasoning: g.reasoning ?? false,
819
+ input: g.input ?? ["text"],
820
+ contextWindow: match?.contextWindow ?? cw,
821
+ maxTokens: match?.maxTokens ?? mt,
822
+ cost: match?.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
823
+ enabled: true,
824
+ };
825
+ addModel(provider.id, model);
826
+ // Enable
827
+ const list = settings?.enabledModels ?? [];
828
+ await updateSettings({ enabledModels: Array.from(new Set([...list, `${provider.id}/${id}`])) });
829
+ setQuickId("");
830
+ setQuickHint(null);
831
+ };
832
+
833
+ const handleCopyParams = async (m: Model) => {
834
+ const payload = {
835
+ reasoning: m.reasoning,
836
+ input: m.input,
837
+ contextWindow: m.contextWindow,
838
+ maxTokens: m.maxTokens,
839
+ cost: m.cost,
840
+ };
841
+ try {
842
+ await navigator.clipboard.writeText(JSON.stringify(payload, null, 2));
843
+ setCopiedParams(m.id);
844
+ setTimeout(() => setCopiedParams(null), 1500);
845
+ } catch {
846
+ // ignore
847
+ }
848
+ };
849
+
850
+ const urlInvalid = baseUrl.trim() !== "" && !isValidHttpUrl(baseUrl.trim());
450
851
 
451
852
  const dirty =
452
- (isCustom && (baseUrl !== (provider.baseUrl ?? "") || api !== (provider.api ?? "openai-completions"))) ||
853
+ (isCustom && (providerName !== (provider.name ?? "") || baseUrl !== (provider.baseUrl ?? "") || api !== (provider.api ?? "openai-completions") || supportsDeveloperRole !== (provider.compat?.supportsDeveloperRole ?? true))) ||
854
+ (!isCustom && (baseUrl !== (provider.baseUrl ?? "") || api !== (provider.api ?? "openai-completions"))) ||
453
855
  apiKey !== savedKey;
454
856
 
455
857
  const handleSave = async () => {
@@ -457,46 +859,61 @@ function ProviderDetail({ provider, onDelete }: { provider: Provider; onDelete:
457
859
  let ok = true;
458
860
  if (isCustom) {
459
861
  ok = await updateCustomProvider(provider.id, {
862
+ name: providerName || undefined,
460
863
  baseUrl: baseUrl || undefined,
461
864
  api,
462
865
  apiKey: apiKey || undefined,
866
+ compat: { supportsDeveloperRole },
463
867
  });
464
- } else if (apiKey !== savedKey) {
465
- ok = apiKey
466
- ? await setProviderAuth(provider.id, apiKey)
467
- : await removeProviderAuth(provider.id);
868
+ } else {
869
+ // Builtin providers: baseUrl / api are persisted as a models.json
870
+ // override (so the user can point them at a proxy/gateway), while the
871
+ // API key stays in auth.json (the original behavior) to avoid creating a
872
+ // duplicate standalone custom-provider card.
873
+ const cfgPatch: Partial<CustomProviderConfig> = {};
874
+ if (baseUrl !== (provider.baseUrl ?? "")) cfgPatch.baseUrl = baseUrl || undefined;
875
+ if (api !== (provider.api ?? "openai-completions")) cfgPatch.api = api;
876
+ if (Object.keys(cfgPatch).length > 0) {
877
+ ok = await updateCustomProvider(provider.id, cfgPatch);
878
+ }
879
+ if (apiKey !== savedKey) {
880
+ const authOk = apiKey
881
+ ? await setProviderAuth(provider.id, apiKey)
882
+ : await removeProviderAuth(provider.id);
883
+ ok = ok && authOk;
884
+ }
885
+ if (Object.keys(cfgPatch).length === 0 && apiKey === savedKey) ok = true;
468
886
  }
469
887
  setSaveState(ok ? "saved" : "error");
470
888
  if (ok) setTimeout(() => setSaveState("idle"), 2500);
471
889
  };
472
890
 
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
891
  const q = modelQuery.trim().toLowerCase();
494
- const visibleModels = q
892
+ const baseModels = q
495
893
  ? provider.models.filter(
496
894
  (m) => m.id.toLowerCase().includes(q) || (m.name ?? "").toLowerCase().includes(q)
497
895
  )
498
896
  : provider.models;
499
897
 
898
+ // Look up the catalog family for a model id (for sorting/grouping)
899
+ const familyOf = (id: string): string => {
900
+ const hit = searchCatalog(id, 1)[0];
901
+ return hit?.family ?? "—";
902
+ };
903
+
904
+ const visibleModels = useMemo(() => {
905
+ const arr = [...baseModels];
906
+ if (modelSort === "family") {
907
+ arr.sort((a, b) => familyOf(a.id).localeCompare(familyOf(b.id)));
908
+ } else if (modelSort === "price-asc" || modelSort === "price-desc") {
909
+ const price = (m: typeof arr[number]) => m.cost?.input ?? 0;
910
+ arr.sort((a, b) => price(a) - price(b));
911
+ if (modelSort === "price-desc") arr.reverse();
912
+ }
913
+ return arr;
914
+ // eslint-disable-next-line react-hooks/exhaustive-deps
915
+ }, [baseModels, modelSort]);
916
+
500
917
  return (
501
918
  <div className="space-y-5">
502
919
  {/* Header */}
@@ -506,6 +923,15 @@ function ProviderDetail({ provider, onDelete }: { provider: Provider; onDelete:
506
923
  {isCustom ? t("providers.custom") : t("providers.builtin")}
507
924
  </Badge>
508
925
  {savedKey && <Badge variant="success">{t("providers.configured")}</Badge>}
926
+ {isCustom && (
927
+ <button
928
+ onClick={onDuplicate}
929
+ className="rounded-lg p-2 text-gray-500 transition-colors hover:bg-blue-500/10 hover:text-blue-400"
930
+ title={t("providers.duplicate_provider")}
931
+ >
932
+ <Copy className="h-4 w-4" />
933
+ </button>
934
+ )}
509
935
  {isCustom && (
510
936
  <button
511
937
  onClick={onDelete}
@@ -517,23 +943,39 @@ function ProviderDetail({ provider, onDelete }: { provider: Provider; onDelete:
517
943
  )}
518
944
  </div>
519
945
 
946
+
947
+ {/* Name (custom only) */}
948
+ {isCustom && (
949
+ <div>
950
+ <label className="block text-sm text-gray-400">{t("providers_models.name")}</label>
951
+ <input
952
+ type="text"
953
+ value={providerName}
954
+ onChange={(e) => setProviderName(e.target.value)}
955
+ placeholder="My Provider"
956
+ className="mt-1.5 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2.5 text-sm text-white"
957
+ />
958
+ </div>
959
+ )}
520
960
  {/* Base URL */}
521
961
  <div>
522
962
  <label className="block text-sm text-gray-400">{t("providers.base_url")}</label>
523
963
  <input
524
964
  type="text"
525
965
  value={baseUrl}
526
- disabled={!isCustom}
527
966
  onChange={(e) => setBaseUrl(e.target.value)}
528
967
  placeholder="https://api.example.com/v1"
529
968
  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",
969
+ "mt-1.5 w-full rounded-lg border bg-gray-800 px-3 py-2.5 text-sm text-white",
531
970
  urlInvalid ? "border-red-500" : "border-gray-700"
532
971
  )}
533
972
  />
534
973
  {urlInvalid && (
535
974
  <p className="mt-1 text-xs text-red-400">{t("providers_models.invalid_url")}</p>
536
975
  )}
976
+ {!isCustom && !urlInvalid && baseUrl !== (provider.baseUrl ?? "") && (
977
+ <p className="mt-1 text-xs text-amber-400">{t("providers_models.baseurl_override")}</p>
978
+ )}
537
979
  </div>
538
980
 
539
981
  {/* API Type */}
@@ -541,9 +983,8 @@ function ProviderDetail({ provider, onDelete }: { provider: Provider; onDelete:
541
983
  <label className="block text-sm text-gray-400">{t("providers.api_type")}</label>
542
984
  <select
543
985
  value={api}
544
- disabled={!isCustom}
545
986
  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"
987
+ className="mt-1.5 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2.5 text-sm text-white"
547
988
  >
548
989
  {API_TYPES.map((a) => (
549
990
  <option key={a.value} value={a.value}>{a.label}</option>
@@ -569,6 +1010,26 @@ function ProviderDetail({ provider, onDelete }: { provider: Provider; onDelete:
569
1010
  {showKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
570
1011
  </button>
571
1012
  </div>
1013
+ {apiKey.trim().startsWith("$") && (
1014
+ <p className="mt-1 text-xs text-sky-400">
1015
+ {t("providers_models.api_key_env", apiKey.trim())}
1016
+ </p>
1017
+ )}
1018
+ </div>
1019
+
1020
+ {/* Developer Role Support */}
1021
+ <div className="flex items-center gap-2">
1022
+ <input
1023
+ id="supports-developer-role"
1024
+ type="checkbox"
1025
+ checked={supportsDeveloperRole}
1026
+ onChange={(e) => setSupportsDeveloperRole(e.target.checked)}
1027
+ className="rounded border-gray-600 bg-gray-800 text-blue-500"
1028
+ />
1029
+ <label htmlFor="supports-developer-role" className="text-sm text-gray-400">
1030
+ <span>{t("compat.supports_developer_role")}</span>
1031
+ <span className="ml-2 text-xs text-gray-500">{t("compat.supports_developer_role_desc")}</span>
1032
+ </label>
572
1033
  </div>
573
1034
 
574
1035
  {/* Save / Test / Feedback row */}
@@ -608,17 +1069,25 @@ function ProviderDetail({ provider, onDelete }: { provider: Provider; onDelete:
608
1069
  {provider.models.length > 0 && (
609
1070
  <div className="flex items-center gap-2">
610
1071
  <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"
1072
+ onClick={() => setAllModelsEnabled(true)}
1073
+ className="rounded-md border border-gray-700 px-2.5 py-1 text-xs text-gray-300 transition-colors hover:bg-gray-800 hover:text-white"
613
1074
  >
614
1075
  {t("providers_models.enable_all")}
615
1076
  </button>
616
1077
  <button
617
- onClick={() => setAllEnabled(false)}
618
- className="rounded-md px-2 py-1 text-xs text-gray-400 transition-colors hover:bg-gray-800"
1078
+ onClick={() => setAllModelsEnabled(false)}
1079
+ className="rounded-md border border-gray-700 px-2.5 py-1 text-xs text-gray-300 transition-colors hover:bg-gray-800 hover:text-white"
619
1080
  >
620
1081
  {t("providers_models.disable_all")}
621
1082
  </button>
1083
+ <button
1084
+ onClick={handleTestAll}
1085
+ title={t("providers_models.test_all")}
1086
+ className="rounded-md border border-gray-700 px-2.5 py-1 text-xs text-gray-300 transition-colors hover:bg-gray-800 hover:text-white"
1087
+ >
1088
+ <Zap className="mr-1 inline h-3 w-3" />
1089
+ {t("providers_models.test_all")}
1090
+ </button>
622
1091
  </div>
623
1092
  )}
624
1093
  </div>
@@ -636,6 +1105,22 @@ function ProviderDetail({ provider, onDelete }: { provider: Provider; onDelete:
636
1105
  </div>
637
1106
  )}
638
1107
 
1108
+ {provider.models.length > 1 && (
1109
+ <div className="mt-1.5 flex items-center gap-2">
1110
+ <label className="text-xs text-gray-500">{t("providers_models.sort_by")}</label>
1111
+ <select
1112
+ value={modelSort}
1113
+ onChange={(e) => setModelSort(e.target.value as typeof modelSort)}
1114
+ className="rounded-lg border border-gray-700 bg-gray-800 px-2 py-1.5 text-xs text-gray-200"
1115
+ >
1116
+ <option value="default">{t("providers_models.sort_default")}</option>
1117
+ <option value="family">{t("providers_models.sort_family")}</option>
1118
+ <option value="price-asc">{t("providers_models.sort_price_asc")}</option>
1119
+ <option value="price-desc">{t("providers_models.sort_price_desc")}</option>
1120
+ </select>
1121
+ </div>
1122
+ )}
1123
+
639
1124
  <div className="mt-1.5 space-y-2 rounded-lg border border-gray-800 p-3">
640
1125
  {provider.models.length === 0 && (
641
1126
  <p className="px-1 py-2 text-sm text-gray-500">{t("models.no_models")}</p>
@@ -643,14 +1128,28 @@ function ProviderDetail({ provider, onDelete }: { provider: Provider; onDelete:
643
1128
  {provider.models.length > 0 && visibleModels.length === 0 && (
644
1129
  <p className="px-1 py-2 text-sm text-gray-500">{t("models.no_models")}</p>
645
1130
  )}
646
- {visibleModels.map((m) => (
1131
+ {visibleModels.map((m) => {
1132
+ const enabled = isModelEnabled(m.id);
1133
+ return (
647
1134
  <div
648
1135
  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
- )}
1136
+ className="flex items-center gap-2 rounded-lg border border-gray-700 bg-gray-900/70 px-3 py-2.5"
653
1137
  >
1138
+ <button
1139
+ onClick={() => toggleModelEnabled(m.id)}
1140
+ title={enabled ? t("models.enabled") : t("models.disabled")}
1141
+ className={cn(
1142
+ "relative inline-flex h-4 w-7 shrink-0 items-center rounded-full transition-colors",
1143
+ enabled ? "bg-emerald-500" : "bg-gray-600"
1144
+ )}
1145
+ >
1146
+ <span
1147
+ className={cn(
1148
+ "inline-block h-3 w-3 transform rounded-full bg-white transition-transform",
1149
+ enabled ? "translate-x-3.5" : "translate-x-0.5"
1150
+ )}
1151
+ />
1152
+ </button>
654
1153
  <Box className="h-4 w-4 shrink-0 text-gray-500" />
655
1154
  <span className="min-w-0 flex-1 truncate font-mono text-sm text-gray-200">
656
1155
  {m.id}
@@ -665,25 +1164,76 @@ function ProviderDetail({ provider, onDelete }: { provider: Provider; onDelete:
665
1164
  <ImageIcon className="h-3.5 w-3.5 text-blue-400" />
666
1165
  </span>
667
1166
  )}
668
- <span className="rounded-md border border-gray-600 bg-gray-800/50 px-2 py-0.5 text-xs text-gray-400">
1167
+ {m.input?.includes("audio") && (
1168
+ <span title={t("models.audio_input")} className="flex shrink-0">
1169
+ <Mic className="h-3.5 w-3.5 text-emerald-400" />
1170
+ </span>
1171
+ )}
1172
+ <span
1173
+ className="rounded-md border border-gray-600 bg-gray-800/50 px-2 py-0.5 text-xs text-gray-400"
1174
+ title={
1175
+ m.cost
1176
+ ? `In ${formatCost(m.cost.input, currency)} / Out ${formatCost(m.cost.output, currency)} · CacheR ${formatCost(m.cost.cacheRead ?? 0, currency)} / CacheW ${formatCost(m.cost.cacheWrite ?? 0, currency)}`
1177
+ : undefined
1178
+ }
1179
+ >
669
1180
  {m.cost && (m.cost.input || m.cost.output)
670
- ? `$${m.cost.input}/${m.cost.output}`
1181
+ ? `${formatCost(m.cost.input, currency)}/${formatCost(m.cost.output, currency)}`
671
1182
  : t("models.free")}
672
1183
  </span>
673
1184
  <span className="rounded-md border border-gray-600 bg-gray-800/50 px-2 py-0.5 text-xs text-gray-400">
674
1185
  {m.contextWindow ? formatTokens(m.contextWindow) : "—"}
675
1186
  </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>
1187
+ {/* Test model */}
1188
+ {(() => {
1189
+ const test = getModelTest(m.id);
1190
+ if (test.status === "testing") return null;
1191
+ return (
1192
+ <button
1193
+ onClick={() => handleTestModel(m)}
1194
+ className="rounded-md p-1.5 text-gray-500 transition-colors hover:bg-gray-700 hover:text-gray-200"
1195
+ title={t("models.test_model")}
1196
+ >
1197
+ <Zap className="h-3.5 w-3.5" />
1198
+ </button>
1199
+ );
1200
+ })()}
1201
+ {/* Model test result */}
1202
+ {(() => {
1203
+ const test = getModelTest(m.id);
1204
+ if (test.status === "ok") return (
1205
+ <span className="flex items-center gap-1 text-xs text-emerald-400">
1206
+ <Check className="h-3.5 w-3.5" />
1207
+ {t("providers_models.test_ok", String(test.latencyMs))}
1208
+ </span>
1209
+ );
1210
+ if (test.status === "fail") return (
1211
+ <span className="flex items-center gap-1 text-xs text-red-400">
1212
+ <X className="h-3.5 w-3.5" />
1213
+ {t("providers_models.test_fail", test.message)}
1214
+ </span>
1215
+ );
1216
+ if (test.status === "testing") return (
1217
+ <span className="flex items-center gap-1 text-xs text-gray-400">
1218
+ <Loader2 className="h-3.5 w-3.5 animate-spin" />
1219
+ {t("providers_models.testing")}
1220
+ </span>
1221
+ );
1222
+ return null;
1223
+ })()}
1224
+ {copiedParams === m.id ? (
1225
+ <span className="flex items-center gap-1 px-1 text-xs text-emerald-400">
1226
+ <Check className="h-3.5 w-3.5" />
1227
+ </span>
1228
+ ) : (
1229
+ <button
1230
+ onClick={() => handleCopyParams(m)}
1231
+ className="rounded-md p-1.5 text-gray-500 transition-colors hover:bg-gray-700 hover:text-gray-200"
1232
+ title={t("models.copy_params")}
1233
+ >
1234
+ <Copy className="h-3.5 w-3.5" />
1235
+ </button>
1236
+ )}
687
1237
  <button
688
1238
  onClick={() => setEditModel(m)}
689
1239
  className="rounded-md p-1.5 text-gray-500 transition-colors hover:bg-gray-700 hover:text-gray-200"
@@ -699,17 +1249,157 @@ function ProviderDetail({ provider, onDelete }: { provider: Provider; onDelete:
699
1249
  <Trash2 className="h-3.5 w-3.5" />
700
1250
  </button>
701
1251
  </div>
702
- ))}
1252
+ );
1253
+ })}
1254
+ </div>
1255
+ {/* Quick-add input */}
1256
+ <div className="mt-3">
1257
+ <div className="flex gap-2">
1258
+ <div className="relative flex-1">
1259
+ <Wand2 className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-emerald-400" />
1260
+ <input
1261
+ type="text"
1262
+ value={quickId}
1263
+ onChange={(e) => setQuickId(e.target.value)}
1264
+ onKeyDown={(e) => {
1265
+ if (e.key === "Enter") { e.preventDefault(); handleQuickAdd(); }
1266
+ }}
1267
+ placeholder={t("models.quick_add_placeholder")}
1268
+ className="w-full rounded-lg border border-gray-700 bg-gray-800 py-2 pl-9 pr-3 text-sm text-white"
1269
+ />
1270
+ </div>
1271
+ <button
1272
+ onClick={handleQuickAdd}
1273
+ disabled={!quickId.trim()}
1274
+ className="flex items-center gap-1.5 rounded-lg px-3 py-2 text-sm font-medium text-white transition-colors disabled:opacity-50"
1275
+ style={{ backgroundColor: "#10b981" }}
1276
+ >
1277
+ <Plus className="h-4 w-4" />
1278
+ {t("models.quick_add")}
1279
+ </button>
1280
+ <button
1281
+ onClick={() => setShowAddModel(true)}
1282
+ className="flex items-center gap-1.5 rounded-lg border border-gray-700 px-3 py-2 text-sm text-gray-300 transition-colors hover:bg-gray-800"
1283
+ >
1284
+ {t("models.add_model")}
1285
+ </button>
1286
+ </div>
1287
+ {quickHint && (
1288
+ <p className="mt-1.5 flex items-center gap-1 text-[11px] text-emerald-400">
1289
+ <Wand2 className="h-3 w-3" /> {quickHint}
1290
+ </p>
1291
+ )}
1292
+ </div>
1293
+
1294
+ <div className="flex flex-wrap gap-2 mt-3">
1295
+ <button
1296
+ onClick={fetchModels}
1297
+ disabled={!baseUrl.trim() || !isValidHttpUrl(baseUrl.trim()) || fetching}
1298
+ title={
1299
+ !baseUrl.trim() || !isValidHttpUrl(baseUrl.trim())
1300
+ ? t("providers_models.fetch_no_endpoint")
1301
+ : t("providers_models.fetch_models")
1302
+ }
1303
+ className="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 disabled:cursor-not-allowed disabled:opacity-50"
1304
+ >
1305
+ {fetching ? <Loader2 className="h-4 w-4 animate-spin" /> : <Download className="h-4 w-4" />}
1306
+ {fetching ? t("providers_models.fetching") : t("providers_models.fetch_models")}
1307
+ </button>
703
1308
  </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
1309
  </div>
712
1310
 
1311
+
1312
+ {/* Fetch Models Modal */}
1313
+ <Modal
1314
+ open={fetchOpen}
1315
+ onClose={() => { setFetchOpen(false); setFetchedModels([]); setFetchSelected(new Set()); setFetchError(null); setFetchImported(null); }}
1316
+ title={t("providers_models.fetch_title")}
1317
+ size="lg"
1318
+ >
1319
+ <div className="space-y-4">
1320
+ <p className="text-sm text-gray-400">{t("providers_models.fetch_desc", provider.name)}</p>
1321
+ {fetching && (
1322
+ <div className="flex items-center gap-2 py-6 text-gray-400">
1323
+ <Loader2 className="h-5 w-5 animate-spin" />
1324
+ <span>{t("providers_models.fetching")}</span>
1325
+ </div>
1326
+ )}
1327
+ {fetchError && !fetching && (
1328
+ <div className="flex items-start gap-3 rounded-lg border border-red-500/30 bg-red-500/10 p-4">
1329
+ <X className="h-5 w-5 shrink-0 text-red-400 mt-0.5" />
1330
+ <p className="text-sm text-red-300">{t("providers_models.fetch_error", fetchError)}</p>
1331
+ </div>
1332
+ )}
1333
+ {!fetching && !fetchError && availableModels.length === 0 && fetchedModels.length === 0 && (
1334
+ <p className="text-sm text-gray-500">{t("providers_models.fetch_empty")}</p>
1335
+ )}
1336
+ {!fetching && !fetchError && availableModels.length > 0 && (
1337
+ <>
1338
+ <div className="flex items-center justify-between">
1339
+ <span className="text-sm text-gray-400">
1340
+ {t("providers_models.selected_count", String(fetchSelected.size), String(availableModels.length))}
1341
+ </span>
1342
+ <button onClick={toggleAll} className="rounded-md px-2 py-1 text-xs">
1343
+ {allSelected
1344
+ ? <span className="text-amber-400">{t("providers_models.deselect_all")}</span>
1345
+ : <span className="text-emerald-400">{t("providers_models.select_all")}</span>
1346
+ }
1347
+ </button>
1348
+ </div>
1349
+ <div className="max-h-72 overflow-y-auto space-y-1.5 rounded-lg border border-gray-800 p-3">
1350
+ {availableModels.map((m) => (
1351
+ <label key={m.id} className="flex items-center gap-3 rounded-lg px-2 py-2 transition-colors hover:bg-gray-800 cursor-pointer">
1352
+ <div className="flex h-5 w-5 shrink-0 items-center justify-center rounded border"
1353
+ style={{ backgroundColor: isSelected(m.id) ? "#3b82f6" : "transparent", borderColor: isSelected(m.id) ? "#3b82f6" : "#4b5563" }}
1354
+ >
1355
+ {isSelected(m.id) && <SquareCheck className="h-4 w-4 text-white" />}
1356
+ </div>
1357
+ <input type="checkbox" checked={isSelected(m.id)} onChange={() => toggleSelect(m.id)} className="sr-only" />
1358
+ <span className="min-w-0 flex-1 truncate font-mono text-sm text-gray-200">{m.id}</span>
1359
+ {m.reasoning && (
1360
+ <span className="flex shrink-0 items-center rounded border border-purple-500/40 bg-purple-500/10 px-1.5 py-0.5 text-[10px] text-purple-400">
1361
+ <Brain className="h-3 w-3" />
1362
+ </span>
1363
+ )}
1364
+ <span className={`flex shrink-0 items-center gap-1 rounded border px-1.5 py-0.5 text-[10px] ${m.vision ? "border-blue-500/40 bg-blue-500/10 text-blue-400" : "border-gray-700 bg-gray-800 text-gray-500"}`}>
1365
+ {m.vision ? <ImageIcon className="h-3 w-3" /> : <span className="h-3 w-3 inline-block" />}
1366
+ {m.vision ? t("providers_models.modality_vision") : t("providers_models.modality_text")}
1367
+ </span>
1368
+ {m.audio && (
1369
+ <span className="flex shrink-0 items-center rounded border border-emerald-500/40 bg-emerald-500/10 px-1.5 py-0.5 text-[10px] text-emerald-400">
1370
+ <Mic className="h-3 w-3" />
1371
+ </span>
1372
+ )}
1373
+ {m.contextWindow && <span className="rounded border border-gray-700 bg-gray-800 px-1.5 py-0.5 text-[10px] text-gray-500 font-mono">{formatTokens(m.contextWindow)}</span>}
1374
+ {m.maxTokens && <span className="rounded border border-gray-700 bg-gray-800 px-1.5 py-0.5 text-[10px] text-gray-500 font-mono">{formatTokens(m.maxTokens)}</span>}
1375
+ {m.cost && (m.cost.input || m.cost.output) ? (
1376
+ <span className="rounded border border-gray-700 bg-gray-800 px-1.5 py-0.5 text-[10px] text-gray-500 font-mono">${m.cost.input}/${m.cost.output}</span>
1377
+ ) : null}
1378
+ </label>
1379
+ ))}
1380
+ </div>
1381
+ </>
1382
+ )}
1383
+ <div className="flex items-center justify-end gap-3 pt-1">
1384
+ {fetchImported !== null && (
1385
+ <span className="flex items-center gap-1 text-sm text-emerald-400">
1386
+ <Check className="h-4 w-4" />
1387
+ {t("providers_models.import_done", String(fetchImported))}
1388
+ </span>
1389
+ )}
1390
+ <button onClick={() => { setFetchOpen(false); setFetchedModels([]); setFetchSelected(new Set()); setFetchError(null); setFetchImported(null); }}
1391
+ className="rounded-lg px-4 py-2 text-sm text-gray-400 hover:bg-gray-800">
1392
+ {t("models.cancel")}
1393
+ </button>
1394
+ <button onClick={handleImportFetched} disabled={fetching || fetchSelected.size === 0}
1395
+ className="flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-colors disabled:opacity-50"
1396
+ style={{ backgroundColor: "#3b82f6", color: "#ffffff" }}>
1397
+ {t("providers_models.confirm_import")}
1398
+ </button>
1399
+ </div>
1400
+ </div>
1401
+ </Modal>
1402
+
713
1403
  {/* Edit Model Modal */}
714
1404
  <Modal
715
1405
  open={!!editModel}
@@ -790,16 +1480,16 @@ function ProviderDetail({ provider, onDelete }: { provider: Provider; onDelete:
790
1480
 
791
1481
  // ─── Model Form (add & edit) ──────────────────────────────
792
1482
 
793
- function ModelForm({
794
- initial,
795
- onSubmit,
796
- onCancel,
797
- }: {
1483
+ interface ModelFormProps {
798
1484
  initial?: Model;
799
1485
  onSubmit: (form: Partial<Model>) => void;
800
1486
  onCancel: () => void;
801
- }) {
1487
+ }
1488
+
1489
+ function ModelForm({ initial, onSubmit, onCancel }: ModelFormProps) {
802
1490
  const { t } = useTranslation();
1491
+ const isEdit = !!initial;
1492
+
803
1493
  const [form, setForm] = useState<Partial<Model>>(
804
1494
  initial
805
1495
  ? { ...initial }
@@ -808,15 +1498,196 @@ function ModelForm({
808
1498
  name: "",
809
1499
  reasoning: false,
810
1500
  input: ["text"],
811
- contextWindow: 128000,
812
- maxTokens: 16384,
1501
+ contextWindow: DEFAULT_CONTEXT_WINDOW,
1502
+ maxTokens: DEFAULT_MAX_TOKENS,
813
1503
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
814
1504
  }
815
1505
  );
816
- const isEdit = !!initial;
1506
+
1507
+ // Which fields the user has manually changed (so auto-detect doesn't clobber them)
1508
+ const touchedRef = useRef<Set<string>>(new Set());
1509
+ const touch = (field: string) => { touchedRef.current.add(field); };
1510
+ const wasTouched = (field: string) => touchedRef.current.has(field);
1511
+
1512
+ // Auto-detect from model id when not editing and id changes
1513
+ const [detectHint, setDetectHint] = useState<string | null>(null);
1514
+ useEffect(() => {
1515
+ if (isEdit) return;
1516
+ const id = (form.id ?? "").trim();
1517
+ if (!id) { setDetectHint(null); return; }
1518
+
1519
+ const guess = guessModelMeta(id);
1520
+ if (guess.source === "default") { setDetectHint(null); return; }
1521
+
1522
+ setForm((prev) => {
1523
+ const next = { ...prev };
1524
+ if (guess.contextWindow && !wasTouched("contextWindow")) next.contextWindow = guess.contextWindow;
1525
+ if (!wasTouched("maxTokens")) {
1526
+ // Use reasonable maxTokens per context family when detected
1527
+ next.maxTokens = guess.contextWindow
1528
+ ? (guess.contextWindow >= 1_000_000 ? 65536 : guess.contextWindow >= 200_000 ? 32768 : 8192)
1529
+ : DEFAULT_MAX_TOKENS;
1530
+ }
1531
+ if (guess.reasoning !== undefined && !wasTouched("reasoning")) next.reasoning = guess.reasoning;
1532
+ if (guess.input && !wasTouched("input")) next.input = [...guess.input];
1533
+ // Catalog match → also set name + cost
1534
+ if (guess.source === "catalog") {
1535
+ // Find the matched catalog entry for name + cost
1536
+ const entries = searchCatalog(id, 5);
1537
+ const match = entries.find((e) => e.patterns.some((p) => id.toLowerCase().includes(p.toLowerCase())));
1538
+ if (match) {
1539
+ if (!wasTouched("name")) next.name = match.name;
1540
+ if (match.cost && !wasTouched("cost")) next.cost = { ...match.cost };
1541
+ }
1542
+ }
1543
+ return next;
1544
+ });
1545
+
1546
+ setDetectHint(
1547
+ guess.source === "catalog" && guess.matched
1548
+ ? t("models.detected_catalog", guess.matched)
1549
+ : t("models.detected_heuristic")
1550
+ );
1551
+ }, [form.id, isEdit, t]);
1552
+
1553
+ // ── Catalog picker (add-mode only) ──
1554
+ const [pickerOpen, setPickerOpen] = useState(false);
1555
+ const [pickerQuery, setPickerQuery] = useState("");
1556
+ const pickerResults = useMemo(
1557
+ () => searchCatalog(pickerQuery, 30),
1558
+ [pickerQuery]
1559
+ );
1560
+ const applyPreset = (entry: ReturnType<typeof searchCatalog>[number]) => {
1561
+ const preset = catalogToModel(entry);
1562
+ // Apply all fields as if they were default (no touch-marking)
1563
+ touchedRef.current = new Set();
1564
+ setForm((prev) => ({ ...prev, ...preset }));
1565
+ setPickerOpen(false);
1566
+ setDetectHint(t("models.detected_catalog", entry.name ?? entry.patterns[0] ?? ""));
1567
+ };
1568
+
1569
+ // Apply a quick template (Claude-style, GPT-style, Reasoning, Local small)
1570
+ const applyTemplate = (kind: "claude" | "gpt" | "reasoning" | "small") => {
1571
+ touchedRef.current = new Set();
1572
+ setForm((prev) => {
1573
+ switch (kind) {
1574
+ case "claude":
1575
+ return { ...prev, reasoning: true, input: ["text", "image"], contextWindow: 200_000, maxTokens: 8192 };
1576
+ case "gpt":
1577
+ return { ...prev, reasoning: false, input: ["text", "image"], contextWindow: 128_000, maxTokens: 16_384 };
1578
+ case "reasoning":
1579
+ return { ...prev, reasoning: true, input: ["text"], contextWindow: 128_000, maxTokens: 65_536 };
1580
+ case "small":
1581
+ return { ...prev, reasoning: false, input: ["text"], contextWindow: 32_768, maxTokens: 4096 };
1582
+ }
1583
+ });
1584
+ };
1585
+
1586
+ const setId = (v: string) => { touch("id"); setForm((p) => ({ ...p, id: v })); };
1587
+ const setName = (v: string) => { touch("name"); setForm((p) => ({ ...p, name: v })); };
1588
+ const setContextWindow = (v: number) => { touch("contextWindow"); setForm((p) => ({ ...p, contextWindow: v })); };
1589
+ const setMaxTokens = (v: number) => { touch("maxTokens"); setForm((p) => ({ ...p, maxTokens: v })); };
1590
+ const setReasoning = (v: boolean) => { touch("reasoning"); setForm((p) => ({ ...p, reasoning: v })); };
1591
+ const setImage = (v: boolean) => {
1592
+ touch("input");
1593
+ setForm((p) => ({ ...p, input: v ? ["text", "image"] : ["text"] }));
1594
+ };
1595
+ const setAudio = (v: boolean) => {
1596
+ touch("input");
1597
+ setForm((p) => {
1598
+ const hasText = p.input?.includes("text") ?? true;
1599
+ const hasImage = p.input?.includes("image") ?? false;
1600
+ const next: ("text" | "image" | "audio")[] = [];
1601
+ if (hasText) next.push("text");
1602
+ if (hasImage) next.push("image");
1603
+ if (v) next.push("audio");
1604
+ return { ...p, input: next };
1605
+ });
1606
+ };
1607
+ const setCostField = (field: keyof NonNullable<Model["cost"]>, v: number) => {
1608
+ touch("cost");
1609
+ setForm((p) => ({
1610
+ ...p,
1611
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, ...p.cost, [field]: v },
1612
+ }));
1613
+ };
817
1614
 
818
1615
  return (
819
1616
  <div className="space-y-4">
1617
+ {/* ── Catalog picker / templates (add-mode) ── */}
1618
+ {!isEdit && (
1619
+ <div className="rounded-lg border border-gray-800 bg-gray-900/40 p-3">
1620
+ <div className="flex items-center justify-between gap-2 flex-wrap">
1621
+ <button
1622
+ type="button"
1623
+ onClick={() => setPickerOpen((o) => !o)}
1624
+ className="flex items-center gap-1.5 rounded-md border border-gray-700 bg-gray-800 px-3 py-1.5 text-xs text-gray-300 hover:bg-gray-700"
1625
+ >
1626
+ <Sparkles className="h-3.5 w-3.5 text-amber-400" />
1627
+ {t("models.pick_preset")}
1628
+ </button>
1629
+ <div className="flex flex-wrap items-center gap-1.5">
1630
+ <span className="text-[10px] uppercase tracking-wide text-gray-500 mr-1">{t("models.manual_fill")}:</span>
1631
+ {([
1632
+ ["claude", "models.preset_claude"],
1633
+ ["gpt", "models.preset_gpt"],
1634
+ ["reasoning", "models.preset_reasoning"],
1635
+ ["small", "models.preset_small_local"],
1636
+ ] as const).map(([kind, key]) => (
1637
+ <button
1638
+ key={kind}
1639
+ type="button"
1640
+ onClick={() => applyTemplate(kind)}
1641
+ className="rounded-md border border-gray-700 bg-gray-800/70 px-2 py-1 text-[11px] text-gray-400 hover:bg-gray-700 hover:text-gray-200"
1642
+ >
1643
+ {t(key)}
1644
+ </button>
1645
+ ))}
1646
+ </div>
1647
+ </div>
1648
+
1649
+ {pickerOpen && (
1650
+ <div className="mt-3 space-y-2">
1651
+ <div className="relative">
1652
+ <Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-500" />
1653
+ <input
1654
+ autoFocus
1655
+ type="text"
1656
+ value={pickerQuery}
1657
+ onChange={(e) => setPickerQuery(e.target.value)}
1658
+ placeholder={t("models.pick_preset_placeholder")}
1659
+ className="w-full rounded-lg border border-gray-700 bg-gray-800 py-2 pl-9 pr-3 text-sm text-white"
1660
+ />
1661
+ </div>
1662
+ <div className="max-h-56 overflow-y-auto rounded-lg border border-gray-800">
1663
+ {pickerResults.length === 0 && (
1664
+ <p className="px-3 py-4 text-center text-xs text-gray-500">—</p>
1665
+ )}
1666
+ {pickerResults.map((e) => (
1667
+ <button
1668
+ key={e.patterns[0]}
1669
+ type="button"
1670
+ onClick={() => applyPreset(e)}
1671
+ className="flex w-full items-center gap-3 border-b border-gray-800 px-3 py-2 text-left hover:bg-gray-800 last:border-0"
1672
+ >
1673
+ <span className="min-w-0 flex-1">
1674
+ <span className="block truncate text-sm text-gray-200">{e.name}</span>
1675
+ <span className="block truncate font-mono text-[11px] text-gray-500">{e.patterns[0]}</span>
1676
+ </span>
1677
+ {e.reasoning && <Brain className="h-3.5 w-3.5 shrink-0 text-purple-400" aria-label="reasoning" />}
1678
+ {e.input?.includes("image") && <ImageIcon className="h-3.5 w-3.5 shrink-0 text-blue-400" aria-label="vision" />}
1679
+ <span className="shrink-0 rounded border border-gray-700 bg-gray-800 px-1.5 py-0.5 text-[10px] font-mono text-gray-500">
1680
+ {formatTokens(e.contextWindow)}
1681
+ </span>
1682
+ </button>
1683
+ ))}
1684
+ </div>
1685
+ </div>
1686
+ )}
1687
+ </div>
1688
+ )}
1689
+
1690
+ {/* ── Core fields ── */}
820
1691
  <div className="grid grid-cols-2 gap-4">
821
1692
  <div>
822
1693
  <label className="block text-xs font-medium text-gray-400">{t("models.model_id")} *</label>
@@ -824,17 +1695,23 @@ function ModelForm({
824
1695
  type="text"
825
1696
  value={form.id ?? ""}
826
1697
  disabled={isEdit}
827
- onChange={(e) => setForm({ ...form, id: e.target.value })}
1698
+ onChange={(e) => setId(e.target.value)}
828
1699
  placeholder="my-model-id"
829
1700
  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
1701
  />
1702
+ {!isEdit && detectHint && (
1703
+ <p className="mt-1 flex items-center gap-1 text-[11px] text-emerald-400">
1704
+ <Wand2 className="h-3 w-3" />
1705
+ {detectHint}
1706
+ </p>
1707
+ )}
831
1708
  </div>
832
1709
  <div>
833
1710
  <label className="block text-xs font-medium text-gray-400">{t("models.display_name")}</label>
834
1711
  <input
835
1712
  type="text"
836
1713
  value={form.name ?? ""}
837
- onChange={(e) => setForm({ ...form, name: e.target.value })}
1714
+ onChange={(e) => setName(e.target.value)}
838
1715
  placeholder="My Custom Model"
839
1716
  className="mt-1 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white"
840
1717
  />
@@ -843,8 +1720,8 @@ function ModelForm({
843
1720
  <label className="block text-xs font-medium text-gray-400">{t("models.context_window")}</label>
844
1721
  <input
845
1722
  type="number"
846
- value={form.contextWindow ?? 128000}
847
- onChange={(e) => setForm({ ...form, contextWindow: parseInt(e.target.value) || 128000 })}
1723
+ value={form.contextWindow ?? DEFAULT_CONTEXT_WINDOW}
1724
+ onChange={(e) => setContextWindow(parseInt(e.target.value) || DEFAULT_CONTEXT_WINDOW)}
848
1725
  className="mt-1 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white"
849
1726
  />
850
1727
  </div>
@@ -852,35 +1729,45 @@ function ModelForm({
852
1729
  <label className="block text-xs font-medium text-gray-400">{t("models.max_tokens")}</label>
853
1730
  <input
854
1731
  type="number"
855
- value={form.maxTokens ?? 16384}
856
- onChange={(e) => setForm({ ...form, maxTokens: parseInt(e.target.value) || 16384 })}
1732
+ value={form.maxTokens ?? DEFAULT_MAX_TOKENS}
1733
+ onChange={(e) => setMaxTokens(parseInt(e.target.value) || DEFAULT_MAX_TOKENS)}
857
1734
  className="mt-1 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white"
858
1735
  />
859
1736
  </div>
860
1737
  </div>
861
1738
 
862
1739
  {/* Capabilities */}
863
- <div className="flex flex-wrap gap-3">
1740
+ <div className="flex flex-wrap gap-4">
864
1741
  <label className="flex items-center gap-2 text-sm text-gray-300">
865
1742
  <input
866
1743
  type="checkbox"
867
1744
  checked={form.reasoning ?? false}
868
- onChange={(e) => setForm({ ...form, reasoning: e.target.checked })}
1745
+ onChange={(e) => setReasoning(e.target.checked)}
869
1746
  className="rounded border-gray-600 bg-gray-800 text-blue-500"
870
1747
  />
1748
+ <Brain className="h-3.5 w-3.5 text-purple-400" />
871
1749
  {t("models.reasoning")}
872
1750
  </label>
873
1751
  <label className="flex items-center gap-2 text-sm text-gray-300">
874
1752
  <input
875
1753
  type="checkbox"
876
1754
  checked={form.input?.includes("image") ?? false}
877
- onChange={(e) =>
878
- setForm({ ...form, input: e.target.checked ? ["text", "image"] : ["text"] })
879
- }
1755
+ onChange={(e) => setImage(e.target.checked)}
880
1756
  className="rounded border-gray-600 bg-gray-800 text-blue-500"
881
1757
  />
1758
+ <ImageIcon className="h-3.5 w-3.5 text-blue-400" />
882
1759
  {t("models.image_input")}
883
1760
  </label>
1761
+ <label className="flex items-center gap-2 text-sm text-gray-300">
1762
+ <input
1763
+ type="checkbox"
1764
+ checked={form.input?.includes("audio") ?? false}
1765
+ onChange={(e) => setAudio(e.target.checked)}
1766
+ className="rounded border-gray-600 bg-gray-800 text-blue-500"
1767
+ />
1768
+ <Mic className="h-3.5 w-3.5 text-emerald-400" />
1769
+ {t("models.audio_input")}
1770
+ </label>
884
1771
  </div>
885
1772
 
886
1773
  {/* Cost */}
@@ -899,22 +1786,17 @@ function ModelForm({
899
1786
  type="number"
900
1787
  step="0.01"
901
1788
  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
- }
1789
+ onChange={(e) => setCostField(field, parseFloat(e.target.value) || 0)}
912
1790
  className="mt-1 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white"
913
1791
  />
914
1792
  </div>
915
1793
  ))}
916
1794
  </div>
917
1795
 
1796
+ {!isEdit && (
1797
+ <p className="text-[11px] text-gray-500">{t("models.catalog_hint")}</p>
1798
+ )}
1799
+
918
1800
  <div className="flex justify-end gap-3 pt-2">
919
1801
  <button onClick={onCancel} className="rounded-lg px-4 py-2 text-sm text-gray-400 hover:bg-gray-800">
920
1802
  {t("models.cancel")}
@@ -952,7 +1834,7 @@ function AddProviderForm({
952
1834
  const [submitting, setSubmitting] = useState(false);
953
1835
  const [submitError, setSubmitError] = useState(false);
954
1836
 
955
- const id = sanitizeProviderId(name);
1837
+ const id = deriveProviderId(name, baseUrl);
956
1838
  const idExists = !!id && allProviders.some((p) => p.id === id);
957
1839
  const urlInvalid = baseUrl.trim() !== "" && !isValidHttpUrl(baseUrl.trim());
958
1840
 
@@ -961,6 +1843,7 @@ function AddProviderForm({
961
1843
  setSubmitting(true);
962
1844
  setSubmitError(false);
963
1845
  const ok = await onSubmit(id, {
1846
+ name: name.trim() || undefined,
964
1847
  baseUrl,
965
1848
  api,
966
1849
  apiKey: apiKey || undefined,
@@ -996,6 +1879,8 @@ function AddProviderForm({
996
1879
  <p className="mt-1 text-xs text-red-400">{t("providers_models.id_exists", id)}</p>
997
1880
  ) : id ? (
998
1881
  <p className="mt-1 text-xs text-gray-500">{t("providers_models.id_preview", id)}</p>
1882
+ ) : name.trim() ? (
1883
+ <p className="mt-1 text-xs text-red-400">{t("providers_models.id_invalid")}</p>
999
1884
  ) : null}
1000
1885
  </div>
1001
1886
 
@@ -1152,6 +2037,13 @@ function ImportProviderModal({
1152
2037
  const [submitting, setSubmitting] = useState(false);
1153
2038
  const [submitError, setSubmitError] = useState(false);
1154
2039
 
2040
+ // ─── Fetch models from the endpoint (one-shot import) ───
2041
+ const [fetching, setFetching] = useState(false);
2042
+ const [fetchErr, setFetchErr] = useState<string | null>(null);
2043
+ const [fetchDone, setFetchDone] = useState(false);
2044
+ const [fetchedModels, setFetchedModels] = useState<FetchedModel[]>([]);
2045
+ const [fetchSel, setFetchSel] = useState<Set<string>>(new Set());
2046
+
1155
2047
  // Re-parse on every paste/edit of the raw text; fields below stay editable
1156
2048
  const handleText = (value: string) => {
1157
2049
  setText(value);
@@ -1171,9 +2063,14 @@ function ImportProviderModal({
1171
2063
  setModelIds([]);
1172
2064
  setSubmitting(false);
1173
2065
  setSubmitError(false);
2066
+ setFetching(false);
2067
+ setFetchErr(null);
2068
+ setFetchDone(false);
2069
+ setFetchedModels([]);
2070
+ setFetchSel(new Set());
1174
2071
  };
1175
2072
 
1176
- const id = sanitizeProviderId(name);
2073
+ const id = deriveProviderId(name, baseUrl);
1177
2074
  const existing = allProviders.find((p) => p.id === id);
1178
2075
  const builtinConflict = existing?.type === "builtin";
1179
2076
  const mergeTarget = existing?.type === "custom" ? existing : null;
@@ -1183,19 +2080,94 @@ function ImportProviderModal({
1183
2080
  const canSubmit =
1184
2081
  !!id && (!!baseUrl.trim() || !!mergeTarget) && !urlInvalid && !builtinConflict && !submitting;
1185
2082
 
2083
+ // Fetched models not already covered by the parsed model chips
2084
+ const availableFetched = fetchedModels.filter((m) => !modelIds.includes(m.id));
2085
+ const allFetchedSelected =
2086
+ availableFetched.length > 0 && availableFetched.every((m) => fetchSel.has(m.id));
2087
+
2088
+ const handleFetchModels = async () => {
2089
+ if (!isValidHttpUrl(baseUrl.trim()) || fetching) return;
2090
+ setFetching(true);
2091
+ setFetchErr(null);
2092
+ setFetchDone(false);
2093
+ try {
2094
+ const res = await fetch("/api/pi/provider-models", {
2095
+ method: "POST",
2096
+ headers: { "Content-Type": "application/json" },
2097
+ body: JSON.stringify({ baseUrl: baseUrl.trim(), apiKey, providerId: id || undefined }),
2098
+ });
2099
+ const data = await res.json();
2100
+ if (data.error) {
2101
+ setFetchErr(data.error);
2102
+ } else {
2103
+ const models = (data.models ?? []) as FetchedModel[];
2104
+ setFetchedModels(models);
2105
+ // Select everything by default so import is a single click
2106
+ setFetchSel(new Set(models.map((m) => m.id)));
2107
+ }
2108
+ } catch {
2109
+ setFetchErr("network error");
2110
+ } finally {
2111
+ setFetching(false);
2112
+ setFetchDone(true);
2113
+ }
2114
+ };
2115
+
2116
+ const toggleFetched = (mid: string) => {
2117
+ setFetchSel((prev) => {
2118
+ const next = new Set(prev);
2119
+ next.has(mid) ? next.delete(mid) : next.add(mid);
2120
+ return next;
2121
+ });
2122
+ };
2123
+ const toggleAllFetched = () => {
2124
+ setFetchSel(allFetchedSelected ? new Set() : new Set(availableFetched.map((m) => m.id)));
2125
+ };
2126
+
1186
2127
  const handleImport = async () => {
1187
2128
  if (!canSubmit) return;
1188
2129
  setSubmitting(true);
1189
2130
  setSubmitError(false);
1190
2131
  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,
2132
+ const defaults = {
2133
+ input: ["text"] as Model["input"],
1197
2134
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
1198
- }));
2135
+ };
2136
+ const selectedFetched = availableFetched.filter((m) => fetchSel.has(m.id));
2137
+ const newModels: Model[] = [
2138
+ ...modelIds.map((mid) => ({
2139
+ id: mid,
2140
+ name: mid.split("/").pop() || mid,
2141
+ contextWindow: DEFAULT_CONTEXT_WINDOW,
2142
+ maxTokens: DEFAULT_MAX_TOKENS,
2143
+ ...defaults,
2144
+ })),
2145
+ // Fetched models carry real context/output/reasoning/cost when the endpoint provides them
2146
+ ...selectedFetched.map((m) => {
2147
+ const input: Model["input"] = ["text"];
2148
+ if (m.vision) input.push("image");
2149
+ if (m.audio) input.push("audio");
2150
+ const cost = m.cost
2151
+ ? {
2152
+ input: m.cost.input ?? 0,
2153
+ output: m.cost.output ?? 0,
2154
+ cacheRead: m.cost.cacheRead ?? 0,
2155
+ cacheWrite: m.cost.cacheWrite ?? 0,
2156
+ }
2157
+ : defaults.cost;
2158
+ return {
2159
+ ...defaults,
2160
+ id: m.id,
2161
+ name: m.name ?? (m.id.split("/").pop() || m.id),
2162
+ reasoning: m.reasoning ?? false,
2163
+ input,
2164
+ contextWindow: m.contextWindow ?? DEFAULT_CONTEXT_WINDOW,
2165
+ maxTokens: m.maxTokens ?? DEFAULT_MAX_TOKENS,
2166
+ cost,
2167
+ };
2168
+ }),
2169
+ ];
2170
+ const newIds = newModels.map((m) => m.id);
1199
2171
 
1200
2172
  let ok: boolean;
1201
2173
  if (mergeTarget) {
@@ -1213,6 +2185,7 @@ function ImportProviderModal({
1213
2185
  });
1214
2186
  } else {
1215
2187
  ok = await store.addCustomProvider(id, {
2188
+ name: name.trim() || undefined,
1216
2189
  baseUrl: baseUrl.trim(),
1217
2190
  api,
1218
2191
  apiKey: apiKey || undefined,
@@ -1221,8 +2194,8 @@ function ImportProviderModal({
1221
2194
  }
1222
2195
 
1223
2196
  // Imported models are enabled by default (settings.enabledModels refs)
1224
- if (ok && modelIds.length > 0) {
1225
- const refs = modelIds.map((m) => `${id}/${m}`);
2197
+ if (ok && newIds.length > 0) {
2198
+ const refs = newIds.map((m) => `${id}/${m}`);
1226
2199
  const list = store.settings?.enabledModels ?? [];
1227
2200
  await store.updateSettings({ enabledModels: Array.from(new Set([...list, ...refs])) });
1228
2201
  }
@@ -1284,6 +2257,8 @@ function ImportProviderModal({
1284
2257
  </p>
1285
2258
  ) : id ? (
1286
2259
  <p className="mt-1 text-xs text-gray-500">{t("providers_models.id_preview", id)}</p>
2260
+ ) : name.trim() ? (
2261
+ <p className="mt-1 text-xs text-red-400">{t("providers_models.id_invalid")}</p>
1287
2262
  ) : null}
1288
2263
  </div>
1289
2264
  <div>
@@ -1358,6 +2333,78 @@ function ImportProviderModal({
1358
2333
  </div>
1359
2334
  )}
1360
2335
 
2336
+ {/* Fetch models from the endpoint — one-shot: fetch, tick, import */}
2337
+ <div>
2338
+ <div className="flex flex-wrap items-center gap-3">
2339
+ <button
2340
+ onClick={handleFetchModels}
2341
+ disabled={!isValidHttpUrl(baseUrl.trim()) || fetching}
2342
+ title={
2343
+ !isValidHttpUrl(baseUrl.trim())
2344
+ ? t("providers_models.fetch_no_endpoint")
2345
+ : t("providers_models.fetch_models")
2346
+ }
2347
+ className="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 disabled:cursor-not-allowed disabled:opacity-50"
2348
+ >
2349
+ {fetching ? <Loader2 className="h-4 w-4 animate-spin" /> : <Download className="h-4 w-4" />}
2350
+ {fetching ? t("providers_models.fetching") : t("providers_models.fetch_models")}
2351
+ </button>
2352
+ {fetchErr && (
2353
+ <span className="text-xs text-red-400">{t("providers_models.fetch_error", fetchErr)}</span>
2354
+ )}
2355
+ {fetchDone && !fetchErr && fetchedModels.length === 0 && (
2356
+ <span className="text-xs text-amber-400">{t("providers_models.fetch_empty")}</span>
2357
+ )}
2358
+ </div>
2359
+ {availableFetched.length > 0 && (
2360
+ <div className="mt-2 rounded-lg border border-gray-700">
2361
+ <div className="flex items-center justify-between border-b border-gray-700 px-3 py-2">
2362
+ <span className="text-xs text-gray-400">
2363
+ {t("providers_models.selected_count", String(fetchSel.size), String(availableFetched.length))}
2364
+ </span>
2365
+ <button
2366
+ onClick={toggleAllFetched}
2367
+ className="text-xs text-blue-400 hover:text-blue-300"
2368
+ >
2369
+ {allFetchedSelected ? t("providers_models.deselect_all") : t("providers_models.select_all")}
2370
+ </button>
2371
+ </div>
2372
+ <div className="max-h-44 overflow-y-auto p-1.5">
2373
+ {availableFetched.map((m) => (
2374
+ <label
2375
+ key={m.id}
2376
+ className="flex cursor-pointer items-center gap-2.5 rounded-md px-2 py-1.5 hover:bg-gray-800"
2377
+ >
2378
+ <input
2379
+ type="checkbox"
2380
+ checked={fetchSel.has(m.id)}
2381
+ onChange={() => toggleFetched(m.id)}
2382
+ className="h-3.5 w-3.5 accent-blue-500"
2383
+ />
2384
+ <span className="truncate font-mono text-xs text-gray-200">{m.id}</span>
2385
+ <span className="ml-auto flex shrink-0 items-center gap-1">
2386
+ {m.reasoning && <Brain className="h-3 w-3 text-purple-400" />}
2387
+ {m.audio && <Mic className="h-3 w-3 text-emerald-400" />}
2388
+ <span className={`flex items-center gap-1 rounded border px-1 py-px text-[10px] ${m.vision ? "border-blue-500/40 bg-blue-500/10 text-blue-400" : "border-gray-700 bg-gray-800 text-gray-500"}`}>
2389
+ {m.vision && <ImageIcon className="h-2.5 w-2.5" />}
2390
+ {m.vision ? t("providers_models.modality_vision") : t("providers_models.modality_text")}
2391
+ </span>
2392
+ {m.contextWindow && (
2393
+ <span className="shrink-0 text-[10px] text-gray-500 font-mono">
2394
+ {formatTokens(m.contextWindow)}
2395
+ </span>
2396
+ )}
2397
+ {m.cost && (m.cost.input || m.cost.output) ? (
2398
+ <span className="shrink-0 text-[10px] text-gray-500 font-mono">${m.cost.input}/${m.cost.output}</span>
2399
+ ) : null}
2400
+ </span>
2401
+ </label>
2402
+ ))}
2403
+ </div>
2404
+ </div>
2405
+ )}
2406
+ </div>
2407
+
1361
2408
  <div className="flex items-center justify-end gap-3 pt-1">
1362
2409
  {submitError && (
1363
2410
  <span className="flex items-center gap-1 text-sm text-red-400">