@raingor/pi-web-switch 0.7.1 → 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 +2 -2
- package/package.json +1 -1
- package/server/pi-reader.ts +77 -6
- package/src/components/providers/ProvidersModelsPage.tsx +266 -108
- package/src/components/settings/PackageBrowser.tsx +222 -0
- package/src/components/settings/SettingsPage.tsx +120 -60
- package/src/components/speedtest/ModelSpeedTestPage.tsx +138 -4
- package/src/components/subagents/SubagentsPage.tsx +30 -21
- package/src/data/changelog.ts +15 -0
- package/src/data/recommended-packages.ts +20 -0
- package/src/index.css +1 -2
- package/src/lib/translations/en.ts +52 -8
- package/src/lib/translations/ja.ts +52 -8
- package/src/lib/translations/zh-CN.ts +52 -8
- package/src/lib/translations/zh-TW.ts +52 -8
- package/src/store/config-store.ts +3 -1
- package/src/types/index.ts +14 -0
- package/vite.config.ts +11 -0
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-
|
|
13
|
-
<link rel="stylesheet" crossorigin href="./assets/main-
|
|
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
package/server/pi-reader.ts
CHANGED
|
@@ -1954,8 +1954,8 @@ export interface UpdateCheckResult {
|
|
|
1954
1954
|
checkedAt: number;
|
|
1955
1955
|
}
|
|
1956
1956
|
|
|
1957
|
-
/** Discover the
|
|
1958
|
-
function
|
|
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
|
-
*
|
|
2135
|
-
*
|
|
2136
|
-
*
|
|
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
|
}
|
|
@@ -2754,6 +2781,50 @@ export function readSubagents(): SubagentsData {
|
|
|
2754
2781
|
};
|
|
2755
2782
|
}
|
|
2756
2783
|
|
|
2784
|
+
// ─── Package Search (npm registry) ─────────────────────────
|
|
2785
|
+
// pi packages are npm packages tagged for pi. The pi.dev/packages catalog is
|
|
2786
|
+
// SSR-only (no public JSON API — /api/packages returns 501), so we query the
|
|
2787
|
+
// public npm registry search endpoint directly, which supports fuzzy text and
|
|
2788
|
+
// returns name/description/downloads.
|
|
2789
|
+
|
|
2790
|
+
export interface PackageSearchResult {
|
|
2791
|
+
name: string;
|
|
2792
|
+
description: string;
|
|
2793
|
+
version: string;
|
|
2794
|
+
downloads: number;
|
|
2795
|
+
link: string;
|
|
2796
|
+
}
|
|
2797
|
+
|
|
2798
|
+
export async function searchPackages(query: string): Promise<PackageSearchResult[]> {
|
|
2799
|
+
const q = query.trim();
|
|
2800
|
+
// Bias the search toward pi extensions. When the user types nothing we still
|
|
2801
|
+
// surface the most popular pi packages.
|
|
2802
|
+
const text = q ? `${q} pi` : "pi-extension";
|
|
2803
|
+
const url = `https://registry.npmjs.org/-/v1/search?text=${encodeURIComponent(text)}&size=40`;
|
|
2804
|
+
try {
|
|
2805
|
+
const res = await fetchExternal(url, { signal: AbortSignal.timeout(REGISTRY_TIMEOUT_MS) });
|
|
2806
|
+
if (!res.ok) return [];
|
|
2807
|
+
const data = (await res.json()) as {
|
|
2808
|
+
objects?: { package?: { name?: string; description?: string; version?: string; links?: { npm?: string } }; searchScore?: number }[];
|
|
2809
|
+
};
|
|
2810
|
+
const rows = (data.objects ?? [])
|
|
2811
|
+
.map((o) => o.package)
|
|
2812
|
+
.filter((p): p is NonNullable<typeof p> => !!p?.name)
|
|
2813
|
+
// Keep pi-related packages only (name or description mentions pi).
|
|
2814
|
+
.filter((p) => /(^|[@/-])pi([-/]|$)|pi coding|pi extension|pi agent/i.test(`${p.name} ${p.description ?? ""}`))
|
|
2815
|
+
.map((p) => ({
|
|
2816
|
+
name: p.name!,
|
|
2817
|
+
description: p.description ?? "",
|
|
2818
|
+
version: p.version ?? "",
|
|
2819
|
+
downloads: 0,
|
|
2820
|
+
link: p.links?.npm ?? `https://www.npmjs.com/package/${p.name}`,
|
|
2821
|
+
}));
|
|
2822
|
+
return rows;
|
|
2823
|
+
} catch {
|
|
2824
|
+
return [];
|
|
2825
|
+
}
|
|
2826
|
+
}
|
|
2827
|
+
|
|
2757
2828
|
const AGENT_NAME_RE = /^[\w.-]+\.md$/;
|
|
2758
2829
|
|
|
2759
2830
|
/**
|
|
@@ -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
|
-
//
|
|
702
|
-
//
|
|
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 default — the 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:
|
|
900
|
+
enabled: false,
|
|
803
901
|
};
|
|
804
902
|
addModel(provider.id, model);
|
|
805
|
-
//
|
|
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
|
|
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
|
-
|
|
984
|
-
<
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
className="
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
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
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
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={
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
2411
|
+
apiKey: seedKey?.key,
|
|
2412
|
+
apiKeys: seedKey ? [seedKey] : undefined,
|
|
2413
|
+
activeKeyId: seedKey?.id,
|
|
2256
2414
|
models: newModels,
|
|
2257
2415
|
});
|
|
2258
2416
|
}
|