@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.
- package/README.ja.md +17 -1
- package/README.md +17 -1
- package/README.zh-CN.md +17 -1
- package/package.json +46 -3
- package/public/apple-touch-icon.png +0 -0
- package/public/icon-192.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/pi.svg +47 -4
- package/server/pi-reader.ts +750 -4
- package/src/App.tsx +2 -0
- package/src/components/layout/Sidebar.tsx +3 -5
- package/src/components/providers/ProvidersModelsPage.tsx +1171 -124
- package/src/components/settings/SettingsPage.tsx +74 -55
- package/src/components/subagents/SubagentsPage.tsx +502 -0
- package/src/data/builtin-providers.ts +15 -7
- package/src/data/model-catalog.ts +967 -0
- package/src/index.css +5 -1
- package/src/lib/config.ts +61 -0
- package/src/lib/translations/en.ts +91 -1
- package/src/lib/translations/ja.ts +91 -1
- package/src/lib/translations/zh-CN.ts +91 -1
- package/src/lib/translations/zh-TW.ts +91 -1
- package/src/main.tsx +9 -0
- package/src/store/config-store.ts +75 -28
- package/src/types/index.ts +52 -0
- package/tsconfig.json +1 -1
- package/vite.config.ts +58 -2
- package/tsconfig.tsbuildinfo +0 -1
|
@@ -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
|
-
|
|
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
|
|
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
|
-
<
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
{
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
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
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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
|
|
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
|
|
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
|
|
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={() =>
|
|
612
|
-
className="rounded-md px-2 py-1 text-xs text-
|
|
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={() =>
|
|
618
|
-
className="rounded-md px-2 py-1 text-xs text-gray-
|
|
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=
|
|
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
|
-
|
|
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
|
-
?
|
|
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
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
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
|
-
|
|
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:
|
|
812
|
-
maxTokens:
|
|
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
|
-
|
|
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) =>
|
|
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) =>
|
|
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 ??
|
|
847
|
-
onChange={(e) =>
|
|
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 ??
|
|
856
|
-
onChange={(e) =>
|
|
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-
|
|
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) =>
|
|
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 =
|
|
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 =
|
|
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
|
|
1192
|
-
|
|
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 &&
|
|
1225
|
-
const refs =
|
|
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">
|