@raingor/pi-web-switch 0.8.0 → 0.8.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.
package/dist/index.html CHANGED
@@ -9,8 +9,8 @@
9
9
  <link rel="manifest" href="./manifest.webmanifest" />
10
10
  <meta name="theme-color" content="#05090d" />
11
11
  <meta name="description" content="Web UI for pi coding agent — configuration management, session browser, and usage dashboard" />
12
- <script type="module" crossorigin src="./assets/main-DJ0fZLIg.js"></script>
13
- <link rel="stylesheet" crossorigin href="./assets/main-K50Fa8uc.css">
12
+ <script type="module" crossorigin src="./assets/main-C-vDjj0-.js"></script>
13
+ <link rel="stylesheet" crossorigin href="./assets/main-Cqzld-td.css">
14
14
  </head>
15
15
  <body>
16
16
  <div id="root"></div>
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@raingor/pi-web-switch",
3
3
  "private": false,
4
- "version": "0.8.0",
4
+ "version": "0.8.2",
5
5
  "type": "module",
6
6
  "main": "dist/index.html",
7
7
  "description": "Web UI for pi coding agent — live configuration management, session browser, and memory viewer",
@@ -1954,8 +1954,8 @@ export interface UpdateCheckResult {
1954
1954
  checkedAt: number;
1955
1955
  }
1956
1956
 
1957
- /** Discover the installed pi version: PI_BINARY env → PATH → known locations. */
1958
- function getPiVersion(): string | null {
1957
+ /** Discover the pi executable: PI_BINARY env → PATH → known global-install locations. */
1958
+ function resolvePiBinary(): { bin: string; version: string } | null {
1959
1959
  const home = homedir();
1960
1960
  const candidates = [
1961
1961
  process.env.PI_BINARY,
@@ -1971,7 +1971,7 @@ function getPiVersion(): string | null {
1971
1971
  const out = spawnSync(bin, ["--version"], { encoding: "utf8", timeout: 15000 });
1972
1972
  if (out.status === 0) {
1973
1973
  const v = out.stdout.trim();
1974
- if (v) return v;
1974
+ if (v) return { bin, version: v };
1975
1975
  }
1976
1976
  } catch {
1977
1977
  // try next candidate
@@ -1980,6 +1980,11 @@ function getPiVersion(): string | null {
1980
1980
  return null;
1981
1981
  }
1982
1982
 
1983
+ /** Installed pi version, or null when no pi executable could be found. */
1984
+ function getPiVersion(): string | null {
1985
+ return resolvePiBinary()?.version ?? null;
1986
+ }
1987
+
1983
1988
  function readJsonFile<T>(filePath: string): T | null {
1984
1989
  try {
1985
1990
  return JSON.parse(readFileSync(filePath, "utf-8")) as T;
@@ -2131,15 +2136,37 @@ export interface ApplyUpdateResult {
2131
2136
  }
2132
2137
 
2133
2138
  /**
2134
- * One-click update: npm install <name>@latest inside ~/.pi/agent/npm.
2135
- * Only packages already installed there are accepted (pi core is excluded —
2136
- * its install method is unknown, so it must be updated via its installer).
2139
+ * Update pi core itself via `pi update`.
2140
+ *
2141
+ * pi core is not installed under ~/.pi/agent/npm, so `npm install` there would
2142
+ * be wrong. `pi update` with no target updates pi only — deliberately without
2143
+ * `--extensions`, which would instead update the packages and leave pi alone.
2144
+ */
2145
+ function applyPiCoreUpdate(): ApplyUpdateResult {
2146
+ const name = PI_CORE_PACKAGE;
2147
+ const pi = resolvePiBinary();
2148
+ if (!pi) return { name, success: false, message: "pi executable not found" };
2149
+ try {
2150
+ const out = spawnSync(pi.bin, ["update"], { encoding: "utf8", timeout: 300000 });
2151
+ if (out.status === 0) return { name, success: true };
2152
+ const stderr = (out.stderr || out.stdout || "").trim().split("\n").slice(-3).join(" ");
2153
+ return { name, success: false, message: stderr || `pi update exited with ${out.status}` };
2154
+ } catch (e) {
2155
+ return { name, success: false, message: String(e) };
2156
+ }
2157
+ }
2158
+
2159
+ /**
2160
+ * One-click update. Extensions use `npm install <name>@latest` inside
2161
+ * ~/.pi/agent/npm; pi core is routed to `pi update` instead, since it lives
2162
+ * outside that directory and has its own updater.
2137
2163
  */
2138
2164
  export function applyExtensionUpdates(names: string[]): ApplyUpdateResult[] {
2139
2165
  const dir = join(PI_DIR, "npm");
2140
2166
  const installed = new Set(listInstalledExtensions().map((e) => e.name));
2141
2167
 
2142
2168
  return names.map((name) => {
2169
+ if (name === PI_CORE_PACKAGE) return applyPiCoreUpdate();
2143
2170
  if (!installed.has(name)) {
2144
2171
  return { name, success: false, message: "not an installed extension" };
2145
2172
  }
@@ -5,7 +5,7 @@ import { Badge } from "@/components/ui/Badge";
5
5
  import { Modal } from "@/components/ui/Modal";
6
6
  import { formatTokens, cn, formatCost, USD_TO_CNY } from "@/lib/utils";
7
7
  import { useCurrency } from "@/lib/currency";
8
- import type { ApiType, CustomProviderConfig, Model, Provider } from "@/types";
8
+ import type { ApiType, CustomProviderConfig, Model, Provider, ProviderApiKey } from "@/types";
9
9
  import { MODEL_CATALOG, searchCatalog, catalogToModel, catalogEntryId, findCatalogEntry, guessModelMeta } from "@/data/model-catalog";
10
10
  import {
11
11
  Plus,
@@ -106,6 +106,38 @@ function deriveProviderId(name: string, baseUrl: string): string {
106
106
  }
107
107
  }
108
108
 
109
+ // ─── API Key Pool ─────────────────────────────────────────
110
+ // pi only reads a provider's single `apiKey`, so the extra keys live alongside
111
+ // it in models.json as `apiKeys` + `activeKeyId`, and `apiKey` is kept in sync
112
+ // with whichever entry is active. Providers saved before this feature existed
113
+ // only have `apiKey`, so they get adopted into a one-entry pool on read.
114
+
115
+ function newKeyId(): string {
116
+ return `k${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
117
+ }
118
+
119
+ function normalizeKeyPool(
120
+ pool: ProviderApiKey[] | undefined,
121
+ legacyKey: string | undefined
122
+ ): ProviderApiKey[] {
123
+ const list = (pool ?? []).filter((k) => k && typeof k.key === "string" && k.key !== "");
124
+ if (list.length > 0) {
125
+ // Adopt a key that was edited directly in models.json outside the pool.
126
+ if (legacyKey && !list.some((k) => k.key === legacyKey)) {
127
+ return [...list, { id: newKeyId(), key: legacyKey }];
128
+ }
129
+ return list;
130
+ }
131
+ return legacyKey ? [{ id: newKeyId(), key: legacyKey }] : [];
132
+ }
133
+
134
+ // Mask a key for display: keep enough on both ends to tell keys apart.
135
+ function maskKey(key: string): string {
136
+ if (key.startsWith("$")) return key; // env var reference — not a secret
137
+ if (key.length <= 12) return `${key.slice(0, 3)}…`;
138
+ return `${key.slice(0, 7)}…${key.slice(-4)}`;
139
+ }
140
+
109
141
  // ─── Freeform Import Parser ───────────────────────────────
110
142
  // Recognizes pasted text like:
111
143
  // tokenrouter baseurl:https://api.example.com/v1 key:sk-xxxx
@@ -601,16 +633,28 @@ function ProviderDetail({ provider, onDelete, onDuplicate, onRenamed }: { provid
601
633
  const isCustom = provider.type === "custom";
602
634
  const savedKey = provider.apiKey ?? auth?.[provider.id]?.key ?? "";
603
635
 
636
+ // Custom providers keep a pool of keys in models.json (`apiKeys`) plus an
637
+ // `activeKeyId` pointer; `apiKey` always mirrors the active one because that
638
+ // is the only field pi itself reads.
639
+ const savedKeys = useMemo(
640
+ () => normalizeKeyPool(provider.apiKeys, provider.apiKey),
641
+ [provider.apiKeys, provider.apiKey]
642
+ );
643
+ const savedActiveKeyId =
644
+ savedKeys.find((k) => k.id === provider.activeKeyId)?.id ?? savedKeys[0]?.id ?? "";
645
+
604
646
  const [providerName, setProviderName] = useState(provider.name ?? "");
605
647
  const [baseUrl, setBaseUrl] = useState(provider.baseUrl ?? "");
606
648
  const [api, setApi] = useState<ApiType>(provider.api ?? "openai-completions");
607
649
  const [apiKey, setApiKey] = useState(savedKey);
650
+ const [keys, setKeys] = useState<ProviderApiKey[]>(savedKeys);
651
+ const [activeKeyId, setActiveKeyId] = useState(savedActiveKeyId);
652
+ const [newKeyValue, setNewKeyValue] = useState("");
653
+ const [revealedKeys, setRevealedKeys] = useState<Set<string>>(new Set());
608
654
  const [showKey, setShowKey] = useState(false);
609
655
  const [editModel, setEditModel] = useState<Model | null>(null);
610
656
  const [showAddModel, setShowAddModel] = useState(false);
611
657
  const [deleteModel, setDeleteModel] = useState<Model | null>(null);
612
- const [modelQuery, setModelQuery] = useState("");
613
- const [modelSort, setModelSort] = useState<"default" | "family" | "price-asc" | "price-desc">("default");
614
658
  const [supportsDeveloperRole, setSupportsDeveloperRole] = useState(
615
659
  provider.compat?.supportsDeveloperRole ?? false
616
660
  );
@@ -618,6 +662,65 @@ function ProviderDetail({ provider, onDelete, onDuplicate, onRenamed }: { provid
618
662
  provider.compat?.supportsFinishReason ?? true
619
663
  );
620
664
  const [saveState, setSaveState] = useState<"idle" | "saving" | "saved" | "error">("idle");
665
+ const [keyError, setKeyError] = useState<string | null>(null);
666
+
667
+ // Key actually used for outbound calls (test connection, fetch models).
668
+ const activeKey = keys.find((k) => k.id === activeKeyId)?.key ?? "";
669
+ const effectiveKey = isCustom ? activeKey : apiKey;
670
+
671
+ // Persist the pool immediately: `apiKey` mirrors the active entry so pi keeps
672
+ // working, and switching keys should not need a separate Save click.
673
+ const persistKeys = async (nextKeys: ProviderApiKey[], nextActiveId: string) => {
674
+ const active = nextKeys.find((k) => k.id === nextActiveId) ?? nextKeys[0];
675
+ setKeys(nextKeys);
676
+ setActiveKeyId(active?.id ?? "");
677
+ setKeyError(null);
678
+ const ok = await updateCustomProvider(provider.id, {
679
+ apiKeys: nextKeys.length > 0 ? nextKeys : undefined,
680
+ activeKeyId: active?.id,
681
+ apiKey: active?.key,
682
+ });
683
+ if (!ok) setKeyError(t("providers_models.save_failed"));
684
+ return ok;
685
+ };
686
+
687
+ const handleAddKey = async () => {
688
+ const value = newKeyValue.trim();
689
+ if (!value) return;
690
+ if (keys.some((k) => k.key === value)) {
691
+ setKeyError(t("providers_models.api_key_duplicate"));
692
+ return;
693
+ }
694
+ const entry: ProviderApiKey = { id: newKeyId(), key: value };
695
+ // First key added becomes active automatically.
696
+ const nextActive = keys.length === 0 ? entry.id : activeKeyId;
697
+ const ok = await persistKeys([...keys, entry], nextActive);
698
+ if (ok) setNewKeyValue("");
699
+ };
700
+
701
+ const handleRemoveKey = async (id: string) => {
702
+ const next = keys.filter((k) => k.id !== id);
703
+ // Removing the active key promotes the first remaining one.
704
+ const nextActive = id === activeKeyId ? next[0]?.id ?? "" : activeKeyId;
705
+ await persistKeys(next, nextActive);
706
+ setRevealedKeys((prev) => {
707
+ const s = new Set(prev);
708
+ s.delete(id);
709
+ return s;
710
+ });
711
+ };
712
+
713
+ const handleActivateKey = async (id: string) => {
714
+ if (id === activeKeyId) return;
715
+ await persistKeys(keys, id);
716
+ };
717
+
718
+ const toggleReveal = (id: string) =>
719
+ setRevealedKeys((prev) => {
720
+ const s = new Set(prev);
721
+ s.has(id) ? s.delete(id) : s.add(id);
722
+ return s;
723
+ });
621
724
 
622
725
  // ─── Quick add (inline, one-liner) ───
623
726
  const [quickId, setQuickId] = useState("");
@@ -638,8 +741,6 @@ function ProviderDetail({ provider, onDelete, onDuplicate, onRenamed }: { provid
638
741
 
639
742
  const existingModelIds = new Set(provider.models.map((m) => m.id));
640
743
  const availableModels = fetchedModels.filter((m) => !existingModelIds.has(m.id));
641
- const [searchQuery, setSearchQuery] = useState("");
642
- const filteredModels = availableModels.filter((m) => m.id.toLowerCase().includes(searchQuery.toLowerCase()));
643
744
  const isSelected = (id: string) => fetchSelected.has(id);
644
745
  const allSelected = availableModels.length > 0 && availableModels.every((m) => isSelected(m.id));
645
746
 
@@ -669,7 +770,7 @@ function ProviderDetail({ provider, onDelete, onDuplicate, onRenamed }: { provid
669
770
  const res = await fetch("/api/pi/provider-models", {
670
771
  method: "POST",
671
772
  headers: { "Content-Type": "application/json" },
672
- body: JSON.stringify({ baseUrl: baseUrl.trim(), apiKey, providerId: provider.id }),
773
+ body: JSON.stringify({ baseUrl: baseUrl.trim(), apiKey: effectiveKey, providerId: provider.id }),
673
774
  });
674
775
  const data = await res.json();
675
776
  if (data.error) setFetchError(data.error);
@@ -698,11 +799,8 @@ function ProviderDetail({ provider, onDelete, onDuplicate, onRenamed }: { provid
698
799
  cost: m.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
699
800
  } as Model);
700
801
  });
701
- // Enable in a single batched writeper-model addEnabledModel calls race
702
- // on the same settings snapshot and overwrite each other.
703
- const list = settings?.enabledModels ?? [];
704
- const refs = selected.map((m) => `${provider.id}/${m.id}`);
705
- await updateSettings({ enabledModels: Array.from(new Set([...list, ...refs])) });
802
+ // Models are added disabled by defaultthe user enables them via the
803
+ // per-model toggle. (No write to settings.enabledModels here.)
706
804
  setFetchImported(selected.length);
707
805
  setTimeout(() => {
708
806
  setFetchOpen(false); setFetchedModels([]); setFetchSelected(new Set());
@@ -721,7 +819,7 @@ function ProviderDetail({ provider, onDelete, onDuplicate, onRenamed }: { provid
721
819
  const res = await fetch("/api/pi/model-test", {
722
820
  method: "POST",
723
821
  headers: { "Content-Type": "application/json" },
724
- body: JSON.stringify({ baseUrl: baseUrl.trim(), modelId, apiKey, apiType: api ?? undefined }),
822
+ body: JSON.stringify({ baseUrl: baseUrl.trim(), modelId, apiKey: effectiveKey, apiType: api ?? undefined }),
725
823
  });
726
824
  const data = await res.json();
727
825
  setModelTests((prev) => {
@@ -799,12 +897,10 @@ function ProviderDetail({ provider, onDelete, onDuplicate, onRenamed }: { provid
799
897
  contextWindow: cw,
800
898
  maxTokens: mt,
801
899
  cost: match?.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
802
- enabled: true,
900
+ enabled: false,
803
901
  };
804
902
  addModel(provider.id, model);
805
- // Enable
806
- const list = settings?.enabledModels ?? [];
807
- await updateSettings({ enabledModels: Array.from(new Set([...list, `${provider.id}/${id}`])) });
903
+ // Added disabled by default — the user enables it via the toggle.
808
904
  setQuickId("");
809
905
  setQuickHint(null);
810
906
  };
@@ -830,18 +926,18 @@ function ProviderDetail({ provider, onDelete, onDuplicate, onRenamed }: { provid
830
926
 
831
927
  const dirty =
832
928
  (isCustom && (providerName !== (provider.name ?? "") || baseUrl !== (provider.baseUrl ?? "") || api !== (provider.api ?? "openai-completions") || supportsDeveloperRole !== (provider.compat?.supportsDeveloperRole ?? true) || supportsFinishReason !== (provider.compat?.supportsFinishReason ?? true))) ||
833
- (!isCustom && (baseUrl !== (provider.baseUrl ?? "") || api !== (provider.api ?? "openai-completions"))) ||
834
- apiKey !== savedKey;
929
+ (!isCustom && (baseUrl !== (provider.baseUrl ?? "") || api !== (provider.api ?? "openai-completions") || apiKey !== savedKey));
835
930
 
836
931
  const handleSave = async () => {
837
932
  setSaveState("saving");
838
933
  let ok = true;
839
934
  if (isCustom) {
935
+ // apiKey / apiKeys are owned by the key pool below and already persisted,
936
+ // so they are deliberately left out of this patch.
840
937
  const cfgPatch = {
841
938
  name: providerName || undefined,
842
939
  baseUrl: baseUrl || undefined,
843
940
  api,
844
- apiKey: apiKey || undefined,
845
941
  compat: { ...provider.compat, supportsDeveloperRole, supportsFinishReason },
846
942
  };
847
943
  // pi's model picker shows the provider key, not the display name, so
@@ -878,28 +974,7 @@ function ProviderDetail({ provider, onDelete, onDuplicate, onRenamed }: { provid
878
974
  if (ok) setTimeout(() => setSaveState("idle"), 2500);
879
975
  };
880
976
 
881
- const q = modelQuery.trim().toLowerCase();
882
- const baseModels = q
883
- ? provider.models.filter(
884
- (m) => m.id.toLowerCase().includes(q) || (m.name ?? "").toLowerCase().includes(q)
885
- )
886
- : provider.models;
887
-
888
- // Look up the catalog family for a model id (for sorting/grouping)
889
- const familyOf = (id: string): string => findCatalogEntry(id)?.family ?? "—";
890
-
891
- const visibleModels = useMemo(() => {
892
- const arr = [...baseModels];
893
- if (modelSort === "family") {
894
- arr.sort((a, b) => familyOf(a.id).localeCompare(familyOf(b.id)));
895
- } else if (modelSort === "price-asc" || modelSort === "price-desc") {
896
- const price = (m: typeof arr[number]) => m.cost?.input ?? 0;
897
- arr.sort((a, b) => price(a) - price(b));
898
- if (modelSort === "price-desc") arr.reverse();
899
- }
900
- return arr;
901
- // eslint-disable-next-line react-hooks/exhaustive-deps
902
- }, [baseModels, modelSort]);
977
+ const visibleModels = provider.models;
903
978
 
904
979
  return (
905
980
  <div className="space-y-5">
@@ -979,30 +1054,133 @@ function ProviderDetail({ provider, onDelete, onDuplicate, onRenamed }: { provid
979
1054
  </select>
980
1055
  </div>
981
1056
 
982
- {/* API Key */}
983
- <div>
984
- <label className="block text-sm text-gray-400">{t("providers.api_key")}</label>
985
- <div className="relative mt-1.5">
986
- <input
987
- type={showKey ? "text" : "password"}
988
- value={apiKey}
989
- onChange={(e) => setApiKey(e.target.value)}
990
- placeholder="sk-... or $MY_API_KEY"
991
- className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2.5 pr-10 text-sm text-white"
992
- />
993
- <button
994
- onClick={() => setShowKey(!showKey)}
995
- className="absolute right-2 top-1/2 -translate-y-1/2 rounded-md p-1.5 text-gray-500 hover:text-gray-300"
996
- >
997
- {showKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
998
- </button>
1057
+ {/* API Key — custom providers get a switchable pool, builtins a single key */}
1058
+ {isCustom ? (
1059
+ <div>
1060
+ <div className="flex items-center justify-between gap-3">
1061
+ <label className="block text-sm text-gray-400">{t("providers.api_key")}</label>
1062
+ {keys.length > 1 && (
1063
+ <span className="text-xs text-gray-500">
1064
+ {t("providers_models.api_key_count", String(keys.length))}
1065
+ </span>
1066
+ )}
1067
+ </div>
1068
+
1069
+ {keys.length === 0 && (
1070
+ <p className="mt-1.5 text-xs text-gray-500">{t("providers_models.api_key_empty")}</p>
1071
+ )}
1072
+
1073
+ {keys.length > 0 && (
1074
+ <div className="mt-1.5 space-y-1.5">
1075
+ {keys.map((k) => {
1076
+ const isActive = k.id === activeKeyId;
1077
+ const revealed = revealedKeys.has(k.id);
1078
+ return (
1079
+ <div
1080
+ key={k.id}
1081
+ className={cn(
1082
+ "flex items-center gap-2 rounded-lg border px-3 py-2",
1083
+ isActive ? "border-blue-500/60 bg-blue-500/5" : "border-gray-700 bg-gray-800"
1084
+ )}
1085
+ >
1086
+ <input
1087
+ type="radio"
1088
+ name={`active-key-${provider.id}`}
1089
+ checked={isActive}
1090
+ onChange={() => handleActivateKey(k.id)}
1091
+ className="text-blue-500"
1092
+ title={t("providers_models.api_key_use")}
1093
+ />
1094
+ <code className="min-w-0 flex-1 truncate font-mono text-xs text-gray-200">
1095
+ {revealed ? k.key : maskKey(k.key)}
1096
+ </code>
1097
+ {isActive && <Badge variant="success">{t("providers_models.api_key_active")}</Badge>}
1098
+ <button
1099
+ onClick={() => toggleReveal(k.id)}
1100
+ className="rounded-md p-1.5 text-gray-500 hover:text-gray-300"
1101
+ title={revealed ? t("providers_models.api_key_hide") : t("providers_models.api_key_show")}
1102
+ >
1103
+ {revealed ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
1104
+ </button>
1105
+ <button
1106
+ onClick={() => handleRemoveKey(k.id)}
1107
+ className="rounded-md p-1.5 text-gray-500 hover:text-red-400"
1108
+ title={t("providers_models.api_key_delete")}
1109
+ >
1110
+ <Trash2 className="h-4 w-4" />
1111
+ </button>
1112
+ </div>
1113
+ );
1114
+ })}
1115
+ </div>
1116
+ )}
1117
+
1118
+ <div className="mt-2 flex items-center gap-2">
1119
+ <input
1120
+ type={showKey ? "text" : "password"}
1121
+ value={newKeyValue}
1122
+ onChange={(e) => {
1123
+ setNewKeyValue(e.target.value);
1124
+ setKeyError(null);
1125
+ }}
1126
+ onKeyDown={(e) => {
1127
+ if (e.key === "Enter") handleAddKey();
1128
+ }}
1129
+ placeholder="sk-... or $MY_API_KEY"
1130
+ className="min-w-0 flex-1 rounded-lg border border-gray-700 bg-gray-800 px-3 py-2.5 text-sm text-white"
1131
+ />
1132
+ <button
1133
+ onClick={() => setShowKey(!showKey)}
1134
+ className="rounded-md p-2 text-gray-500 hover:text-gray-300"
1135
+ title={showKey ? t("providers_models.api_key_hide") : t("providers_models.api_key_show")}
1136
+ >
1137
+ {showKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
1138
+ </button>
1139
+ <button
1140
+ onClick={handleAddKey}
1141
+ disabled={newKeyValue.trim() === ""}
1142
+ className="flex shrink-0 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 hover:text-white disabled:opacity-40"
1143
+ >
1144
+ <Plus className="h-4 w-4" />
1145
+ {t("providers_models.api_key_add")}
1146
+ </button>
1147
+ </div>
1148
+
1149
+ {keyError && <p className="mt-1 text-xs text-red-400">{keyError}</p>}
1150
+ {newKeyValue.trim().startsWith("$") && (
1151
+ <p className="mt-1 text-xs text-sky-400">
1152
+ {t("providers_models.api_key_env", newKeyValue.trim())}
1153
+ </p>
1154
+ )}
1155
+ {keys.length > 1 && (
1156
+ <p className="mt-1 text-xs text-gray-500">{t("providers_models.api_key_switch_hint")}</p>
1157
+ )}
999
1158
  </div>
1000
- {apiKey.trim().startsWith("$") && (
1001
- <p className="mt-1 text-xs text-sky-400">
1002
- {t("providers_models.api_key_env", apiKey.trim())}
1003
- </p>
1004
- )}
1005
- </div>
1159
+ ) : (
1160
+ <div>
1161
+ <label className="block text-sm text-gray-400">{t("providers.api_key")}</label>
1162
+ <div className="relative mt-1.5">
1163
+ <input
1164
+ type={showKey ? "text" : "password"}
1165
+ value={apiKey}
1166
+ onChange={(e) => setApiKey(e.target.value)}
1167
+ placeholder="sk-... or $MY_API_KEY"
1168
+ className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2.5 pr-10 text-sm text-white"
1169
+ />
1170
+ <button
1171
+ onClick={() => setShowKey(!showKey)}
1172
+ className="absolute right-2 top-1/2 -translate-y-1/2 rounded-md p-1.5 text-gray-500 hover:text-gray-300"
1173
+ >
1174
+ {showKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
1175
+ </button>
1176
+ </div>
1177
+ {apiKey.trim().startsWith("$") && (
1178
+ <p className="mt-1 text-xs text-sky-400">
1179
+ {t("providers_models.api_key_env", apiKey.trim())}
1180
+ </p>
1181
+ )}
1182
+ </div>
1183
+ )}
1006
1184
 
1007
1185
  {/* Developer Role Support */}
1008
1186
  <div className="provider-compat-row flex items-center gap-2">
@@ -1060,7 +1238,7 @@ function ProviderDetail({ provider, onDelete, onDuplicate, onRenamed }: { provid
1060
1238
  </span>
1061
1239
  )}
1062
1240
  {isCustom && baseUrl.trim() !== "" && (
1063
- <TestConnectionButton baseUrl={baseUrl} apiKey={apiKey} />
1241
+ <TestConnectionButton baseUrl={baseUrl} apiKey={effectiveKey} />
1064
1242
  )}
1065
1243
  </div>
1066
1244
 
@@ -1094,35 +1272,6 @@ function ProviderDetail({ provider, onDelete, onDuplicate, onRenamed }: { provid
1094
1272
  )}
1095
1273
  </div>
1096
1274
 
1097
- {provider.models.length > 5 && (
1098
- <div className="relative mt-1.5">
1099
- <Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-500" />
1100
- <input
1101
- type="text"
1102
- value={modelQuery}
1103
- onChange={(e) => setModelQuery(e.target.value)}
1104
- placeholder={t("models.search_placeholder")}
1105
- className="w-full rounded-lg border border-gray-700 bg-gray-800 py-2 pl-9 pr-3 text-sm text-white"
1106
- />
1107
- </div>
1108
- )}
1109
-
1110
- {provider.models.length > 1 && (
1111
- <div className="mt-1.5 flex items-center gap-2">
1112
- <label className="text-xs text-gray-500">{t("providers_models.sort_by")}</label>
1113
- <select
1114
- value={modelSort}
1115
- onChange={(e) => setModelSort(e.target.value as typeof modelSort)}
1116
- className="rounded-lg border border-gray-700 bg-gray-800 px-2 py-1.5 text-xs text-gray-200"
1117
- >
1118
- <option value="default">{t("providers_models.sort_default")}</option>
1119
- <option value="family">{t("providers_models.sort_family")}</option>
1120
- <option value="price-asc">{t("providers_models.sort_price_asc")}</option>
1121
- <option value="price-desc">{t("providers_models.sort_price_desc")}</option>
1122
- </select>
1123
- </div>
1124
- )}
1125
-
1126
1275
  <div className="mt-1.5 space-y-2 rounded-lg border border-gray-800 p-3">
1127
1276
  {provider.models.length === 0 && (
1128
1277
  <p className="px-1 py-2 text-sm text-gray-500">{t("models.no_models")}</p>
@@ -1349,17 +1498,7 @@ function ProviderDetail({ provider, onDelete, onDuplicate, onRenamed }: { provid
1349
1498
  </button>
1350
1499
  </div>
1351
1500
  <div className="max-h-72 overflow-y-auto space-y-1.5 rounded-lg border border-gray-800 p-3">
1352
- <div className="relative mt-2">
1353
- <Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-500" />
1354
- <input
1355
- type="text"
1356
- placeholder="Search models..."
1357
- value={searchQuery}
1358
- onChange={(e) => setSearchQuery(e.target.value)}
1359
- className="w-full rounded-lg border border-gray-700 bg-gray-800 py-2 pl-9 pr-3 text-sm text-white"
1360
- />
1361
- </div>
1362
- {filteredModels.map((m) => (
1501
+ {availableModels.map((m) => (
1363
1502
  <div key={m.id} className="flex items-center gap-3 rounded-lg px-2 py-2 transition-colors hover:bg-gray-800 cursor-pointer" onClick={() => toggleSelect(m.id)}>
1364
1503
  <div className="flex h-5 w-5 shrink-0 items-center justify-center rounded border"
1365
1504
  style={{ backgroundColor: isSelected(m.id) ? "#3b82f6" : "transparent", borderColor: isSelected(m.id) ? "#3b82f6" : "#4b5563" }}
@@ -1906,11 +2045,15 @@ function AddProviderForm({
1906
2045
  if (!id || !baseUrl || idExists || urlInvalid) return;
1907
2046
  setSubmitting(true);
1908
2047
  setSubmitError(false);
2048
+ // Seed the key pool so the detail panel can switch keys right away.
2049
+ const seedKey = apiKey.trim() ? { id: newKeyId(), key: apiKey.trim() } : null;
1909
2050
  const ok = await onSubmit(id, {
1910
2051
  name: name.trim() || undefined,
1911
2052
  baseUrl,
1912
2053
  api,
1913
- apiKey: apiKey || undefined,
2054
+ apiKey: seedKey?.key,
2055
+ apiKeys: seedKey ? [seedKey] : undefined,
2056
+ activeKeyId: seedKey?.id,
1914
2057
  models,
1915
2058
  });
1916
2059
  setSubmitting(false);
@@ -2242,17 +2385,32 @@ function ImportProviderModal({
2242
2385
  ...existingModels,
2243
2386
  ...newModels.filter((m) => !existingModels.some((e) => e.id === m.id)),
2244
2387
  ];
2388
+ // An imported key joins the existing pool instead of replacing it; the
2389
+ // active key only changes when the provider had none.
2390
+ const pool = normalizeKeyPool(existingCfg?.apiKeys, existingCfg?.apiKey);
2391
+ const incoming = apiKey.trim();
2392
+ const nextPool =
2393
+ incoming && !pool.some((k) => k.key === incoming)
2394
+ ? [...pool, { id: newKeyId(), key: incoming }]
2395
+ : pool;
2396
+ const activeId =
2397
+ nextPool.find((k) => k.id === existingCfg?.activeKeyId)?.id ?? nextPool[0]?.id;
2245
2398
  ok = await store.updateCustomProvider(id, {
2246
2399
  baseUrl: baseUrl.trim() || existingCfg?.baseUrl,
2247
- apiKey: apiKey || existingCfg?.apiKey,
2400
+ apiKeys: nextPool.length > 0 ? nextPool : undefined,
2401
+ activeKeyId: activeId,
2402
+ apiKey: nextPool.find((k) => k.id === activeId)?.key,
2248
2403
  models: merged,
2249
2404
  });
2250
2405
  } else {
2406
+ const seedKey = apiKey.trim() ? { id: newKeyId(), key: apiKey.trim() } : null;
2251
2407
  ok = await store.addCustomProvider(id, {
2252
2408
  name: name.trim() || undefined,
2253
2409
  baseUrl: baseUrl.trim(),
2254
2410
  api,
2255
- apiKey: apiKey || undefined,
2411
+ apiKey: seedKey?.key,
2412
+ apiKeys: seedKey ? [seedKey] : undefined,
2413
+ activeKeyId: seedKey?.id,
2256
2414
  models: newModels,
2257
2415
  });
2258
2416
  }