@tonyclaw/llm-inspector 1.14.1 → 1.14.3
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/.output/nitro.json +1 -1
- package/.output/public/assets/index-6F6Tf88s.js +105 -0
- package/.output/public/assets/index-CzrT_ZB_.css +1 -0
- package/.output/public/assets/{main-BzK2SzIB.js → main-BcEfx6FM.js} +3 -3
- package/.output/server/_ssr/{index-Cso39vJc.mjs → index-BbJkYeb-.mjs} +212 -116
- package/.output/server/_ssr/index.mjs +2 -2
- package/.output/server/_ssr/{router-B8X3GXM2.mjs → router-Cz7UcQ5N.mjs} +539 -286
- package/.output/server/{_tanstack-start-manifest_v-vO4aM6jK.mjs → _tanstack-start-manifest_v-DxqMZv-B.mjs} +1 -1
- package/.output/server/index.mjs +20 -20
- package/package.json +1 -1
- package/src/components/providers/ProviderCard.tsx +111 -6
- package/src/components/providers/ProviderForm.tsx +125 -39
- package/src/components/providers/ProvidersPanel.tsx +16 -7
- package/src/components/providers/SettingsDialog.tsx +1 -1
- package/src/lib/providerContract.ts +1 -0
- package/src/lib/providerTestContract.ts +8 -0
- package/src/proxy/providers.ts +119 -21
- package/src/routes/api/providers.$providerId.test.log.ts +293 -0
- package/src/routes/api/providers.$providerId.ts +2 -1
- package/src/routes/api/providers.ts +3 -2
- package/styles/globals.css +121 -121
- package/.output/public/assets/index-DEUddp_2.css +0 -1
- package/.output/public/assets/index-ax85pt2A.js +0 -105
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const tsrStartManifest = () => ({ "routes": { "__root__": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/__root.tsx", "children": ["/", "/api/config", "/api/health", "/api/logs", "/api/mcp", "/api/models", "/api/providers", "/api/sessions", "/proxy/$"], "preloads": ["/assets/main-
|
|
1
|
+
const tsrStartManifest = () => ({ "routes": { "__root__": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/__root.tsx", "children": ["/", "/api/config", "/api/health", "/api/logs", "/api/mcp", "/api/models", "/api/providers", "/api/sessions", "/proxy/$"], "preloads": ["/assets/main-BcEfx6FM.js"], "assets": [] }, "/": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/index.tsx", "assets": [], "preloads": ["/assets/index-6F6Tf88s.js"] }, "/api/config": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/config.ts", "children": ["/api/config/paths"] }, "/api/health": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/health.ts" }, "/api/logs": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/logs.ts", "children": ["/api/logs/$id", "/api/logs/stream"] }, "/api/mcp": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/mcp.ts" }, "/api/models": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/models.ts" }, "/api/providers": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/providers.ts", "children": ["/api/providers/$providerId", "/api/providers/export", "/api/providers/import"] }, "/api/sessions": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/sessions.ts" }, "/proxy/$": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/proxy/$.ts" }, "/api/config/paths": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/config.paths.ts" }, "/api/logs/$id": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/logs.$id.ts", "children": ["/api/logs/$id/chunks", "/api/logs/$id/replay"] }, "/api/logs/stream": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/logs.stream.ts" }, "/api/providers/$providerId": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/providers.$providerId.ts", "children": ["/api/providers/$providerId/test"] }, "/api/providers/export": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/providers.export.ts" }, "/api/providers/import": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/providers.import.ts" }, "/api/logs/$id/chunks": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/logs.$id.chunks.ts" }, "/api/logs/$id/replay": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/logs.$id.replay.ts" }, "/api/providers/$providerId/test": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/providers.$providerId.test.ts", "children": ["/api/providers/$providerId/test/log"] }, "/api/providers/$providerId/test/log": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/providers.$providerId.test.log.ts" } }, "clientEntry": "/assets/main-BcEfx6FM.js" });
|
|
2
2
|
export {
|
|
3
3
|
tsrStartManifest
|
|
4
4
|
};
|
package/.output/server/index.mjs
CHANGED
|
@@ -38,51 +38,51 @@ const assets = {
|
|
|
38
38
|
"/assets/alibaba-TTwafVwX.svg": {
|
|
39
39
|
"type": "image/svg+xml",
|
|
40
40
|
"etag": '"171b-6dyV5K8QjiaY35sN9qNprh9zDIs"',
|
|
41
|
-
"mtime": "2026-06-
|
|
41
|
+
"mtime": "2026-06-11T02:41:57.154Z",
|
|
42
42
|
"size": 5915,
|
|
43
43
|
"path": "../public/assets/alibaba-TTwafVwX.svg"
|
|
44
44
|
},
|
|
45
|
+
"/assets/index-CzrT_ZB_.css": {
|
|
46
|
+
"type": "text/css; charset=utf-8",
|
|
47
|
+
"etag": '"14427-WRw7wI3HmgJe0YWxs6QA9QvDsgk"',
|
|
48
|
+
"mtime": "2026-06-11T02:41:57.155Z",
|
|
49
|
+
"size": 82983,
|
|
50
|
+
"path": "../public/assets/index-CzrT_ZB_.css"
|
|
51
|
+
},
|
|
45
52
|
"/assets/minimax-BPMzvuL-.jpeg": {
|
|
46
53
|
"type": "image/jpeg",
|
|
47
54
|
"etag": '"1b06-IwivU89ko5UTMUM1/t7hn4sQK9A"',
|
|
48
|
-
"mtime": "2026-06-
|
|
55
|
+
"mtime": "2026-06-11T02:41:57.154Z",
|
|
49
56
|
"size": 6918,
|
|
50
57
|
"path": "../public/assets/minimax-BPMzvuL-.jpeg"
|
|
51
58
|
},
|
|
52
|
-
"/assets/
|
|
53
|
-
"type": "text/css; charset=utf-8",
|
|
54
|
-
"etag": '"14241-4GSN9oIh2t/a3HzVlfCpnfXP+qI"',
|
|
55
|
-
"mtime": "2026-06-10T10:39:07.175Z",
|
|
56
|
-
"size": 82497,
|
|
57
|
-
"path": "../public/assets/index-DEUddp_2.css"
|
|
58
|
-
},
|
|
59
|
-
"/assets/main-BzK2SzIB.js": {
|
|
59
|
+
"/assets/main-BcEfx6FM.js": {
|
|
60
60
|
"type": "text/javascript; charset=utf-8",
|
|
61
|
-
"etag": '"50599-
|
|
62
|
-
"mtime": "2026-06-
|
|
61
|
+
"etag": '"50599-Zn3MOTxDKfi6edBd1hMuk0XMH8M"',
|
|
62
|
+
"mtime": "2026-06-11T02:41:57.155Z",
|
|
63
63
|
"size": 329113,
|
|
64
|
-
"path": "../public/assets/main-
|
|
64
|
+
"path": "../public/assets/main-BcEfx6FM.js"
|
|
65
65
|
},
|
|
66
66
|
"/assets/zhipuai-BPNAnxo-.svg": {
|
|
67
67
|
"type": "image/svg+xml",
|
|
68
68
|
"etag": '"2bf8-hNaLCTi89nOFCsIIfWpP/jrfo0s"',
|
|
69
|
-
"mtime": "2026-06-
|
|
69
|
+
"mtime": "2026-06-11T02:41:57.154Z",
|
|
70
70
|
"size": 11256,
|
|
71
71
|
"path": "../public/assets/zhipuai-BPNAnxo-.svg"
|
|
72
72
|
},
|
|
73
73
|
"/assets/qwen-CONDcHqt.png": {
|
|
74
74
|
"type": "image/png",
|
|
75
75
|
"etag": '"572c3-cdJAPaHdOvFCGzuaQjagdgOu6XE"',
|
|
76
|
-
"mtime": "2026-06-
|
|
76
|
+
"mtime": "2026-06-11T02:41:57.155Z",
|
|
77
77
|
"size": 357059,
|
|
78
78
|
"path": "../public/assets/qwen-CONDcHqt.png"
|
|
79
79
|
},
|
|
80
|
-
"/assets/index-
|
|
80
|
+
"/assets/index-6F6Tf88s.js": {
|
|
81
81
|
"type": "text/javascript; charset=utf-8",
|
|
82
|
-
"etag": '"
|
|
83
|
-
"mtime": "2026-06-
|
|
84
|
-
"size":
|
|
85
|
-
"path": "../public/assets/index-
|
|
82
|
+
"etag": '"92434-5hMgYz5Gl8U1bzvC5OsYy/vakXU"',
|
|
83
|
+
"mtime": "2026-06-11T02:41:57.155Z",
|
|
84
|
+
"size": 599092,
|
|
85
|
+
"path": "../public/assets/index-6F6Tf88s.js"
|
|
86
86
|
}
|
|
87
87
|
};
|
|
88
88
|
function readAsset(id) {
|
package/package.json
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type JSX, type ReactNode, useState } from "react";
|
|
1
|
+
import { type JSX, type ReactNode, useCallback, useRef, useState } from "react";
|
|
2
2
|
import { Button } from "../ui/button";
|
|
3
3
|
import {
|
|
4
4
|
Eye,
|
|
@@ -169,6 +169,20 @@ function TestStatus({ result }: { result: ProviderTestState }): JSX.Element {
|
|
|
169
169
|
);
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
+
function formatTimeAgo(isoString: string): string {
|
|
173
|
+
const now = Date.now();
|
|
174
|
+
const then = new Date(isoString).getTime();
|
|
175
|
+
const diffMs = now - then;
|
|
176
|
+
const diffSec = Math.floor(diffMs / 1000);
|
|
177
|
+
if (diffSec < 60) return "just now";
|
|
178
|
+
const diffMin = Math.floor(diffSec / 60);
|
|
179
|
+
if (diffMin < 60) return `${diffMin}m ago`;
|
|
180
|
+
const diffHr = Math.floor(diffMin / 60);
|
|
181
|
+
if (diffHr < 24) return `${diffHr}h ago`;
|
|
182
|
+
const diffDay = Math.floor(diffHr / 24);
|
|
183
|
+
return `${diffDay}d ago`;
|
|
184
|
+
}
|
|
185
|
+
|
|
172
186
|
export function ProviderCard({
|
|
173
187
|
provider,
|
|
174
188
|
testResults,
|
|
@@ -181,6 +195,32 @@ export function ProviderCard({
|
|
|
181
195
|
}: ProviderCardProps): JSX.Element {
|
|
182
196
|
const [showApiKey, setShowApiKey] = useState(false);
|
|
183
197
|
const [copied, setCopied] = useState(false);
|
|
198
|
+
const [showModelResults, setShowModelResults] = useState(false);
|
|
199
|
+
const hasLoggedRef = useRef<string | null>(null);
|
|
200
|
+
const lastTestedAtRef = useRef<string | undefined>(undefined);
|
|
201
|
+
|
|
202
|
+
// Reset log state when new test results arrive
|
|
203
|
+
if (testResults?.testedAt !== undefined && testResults.testedAt !== lastTestedAtRef.current) {
|
|
204
|
+
lastTestedAtRef.current = testResults.testedAt;
|
|
205
|
+
hasLoggedRef.current = null;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Call log API when user expands model test results for the first time
|
|
209
|
+
const handleToggleModelResults = useCallback(() => {
|
|
210
|
+
setShowModelResults((v) => {
|
|
211
|
+
const next = !v;
|
|
212
|
+
if (next && hasLoggedRef.current === null && testResults?.models !== undefined) {
|
|
213
|
+
hasLoggedRef.current = testResults.testedAt ?? "";
|
|
214
|
+
// Fire-and-forget: commit test results to dashboard log
|
|
215
|
+
void fetch(`/api/providers/${provider.id}/test/log`, {
|
|
216
|
+
method: "POST",
|
|
217
|
+
headers: { "Content-Type": "application/json" },
|
|
218
|
+
body: JSON.stringify(testResults),
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
return next;
|
|
222
|
+
});
|
|
223
|
+
}, [provider.id, testResults]);
|
|
184
224
|
|
|
185
225
|
function handleCopy() {
|
|
186
226
|
navigator.clipboard.writeText(provider.apiKey).catch(() => {});
|
|
@@ -201,11 +241,7 @@ export function ProviderCard({
|
|
|
201
241
|
>
|
|
202
242
|
<div className="flex items-start justify-between gap-2">
|
|
203
243
|
<div className="flex items-center gap-2 min-w-0">
|
|
204
|
-
<span className="font-medium truncate">
|
|
205
|
-
{provider.model !== undefined && provider.model !== ""
|
|
206
|
-
? `${provider.model} (${provider.name})`
|
|
207
|
-
: provider.name}
|
|
208
|
-
</span>
|
|
244
|
+
<span className="font-medium truncate">{provider.name}</span>
|
|
209
245
|
{provider.source === "company" && (
|
|
210
246
|
<span className="text-xs px-1.5 py-0.5 rounded bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400 shrink-0">
|
|
211
247
|
公司
|
|
@@ -231,6 +267,16 @@ export function ProviderCard({
|
|
|
231
267
|
)}
|
|
232
268
|
</div>
|
|
233
269
|
|
|
270
|
+
{provider.models !== undefined && provider.models.length > 0 && (
|
|
271
|
+
<div className="flex flex-wrap gap-1">
|
|
272
|
+
{provider.models.map((m) => (
|
|
273
|
+
<span key={m} className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
|
|
274
|
+
{m}
|
|
275
|
+
</span>
|
|
276
|
+
))}
|
|
277
|
+
</div>
|
|
278
|
+
)}
|
|
279
|
+
|
|
234
280
|
<div className="flex items-center gap-2">
|
|
235
281
|
<code className="text-xs text-muted-foreground bg-muted px-2 py-1 rounded flex-1 truncate">
|
|
236
282
|
{showApiKey ? provider.apiKey : maskApiKey(provider.apiKey)}
|
|
@@ -273,6 +319,65 @@ export function ProviderCard({
|
|
|
273
319
|
</div>
|
|
274
320
|
)}
|
|
275
321
|
|
|
322
|
+
{testResults?.testedAt !== undefined && (
|
|
323
|
+
<div className="text-xs text-muted-foreground flex items-center gap-1">
|
|
324
|
+
<Clock className="size-3" />
|
|
325
|
+
<span>Tested {formatTimeAgo(testResults.testedAt)}</span>
|
|
326
|
+
</div>
|
|
327
|
+
)}
|
|
328
|
+
{testResults?.models !== undefined && Object.keys(testResults.models).length > 0 && (
|
|
329
|
+
<div className="border-t pt-2">
|
|
330
|
+
<button
|
|
331
|
+
type="button"
|
|
332
|
+
onClick={handleToggleModelResults}
|
|
333
|
+
className="text-xs text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1"
|
|
334
|
+
>
|
|
335
|
+
<span className="font-mono">{showModelResults ? "▾" : "▸"}</span>
|
|
336
|
+
Model Test Results ({Object.keys(testResults.models).length})
|
|
337
|
+
</button>
|
|
338
|
+
{showModelResults && (
|
|
339
|
+
<div className="mt-2 overflow-x-auto">
|
|
340
|
+
<table className="w-full text-xs border-collapse">
|
|
341
|
+
<thead>
|
|
342
|
+
<tr className="border-b border-border">
|
|
343
|
+
<th className="text-left py-1 px-2 font-medium text-muted-foreground">Model</th>
|
|
344
|
+
{provider.anthropicBaseUrl !== undefined &&
|
|
345
|
+
provider.anthropicBaseUrl !== "" && (
|
|
346
|
+
<th className="text-left py-1 px-2 font-medium text-muted-foreground">
|
|
347
|
+
Anthropic
|
|
348
|
+
</th>
|
|
349
|
+
)}
|
|
350
|
+
{provider.openaiBaseUrl !== undefined && provider.openaiBaseUrl !== "" && (
|
|
351
|
+
<th className="text-left py-1 px-2 font-medium text-muted-foreground">
|
|
352
|
+
OpenAI
|
|
353
|
+
</th>
|
|
354
|
+
)}
|
|
355
|
+
</tr>
|
|
356
|
+
</thead>
|
|
357
|
+
<tbody>
|
|
358
|
+
{Object.entries(testResults.models).map(([modelName, modelResult]) => (
|
|
359
|
+
<tr key={modelName} className="border-b border-border/50 last:border-0">
|
|
360
|
+
<td className="py-1 px-2 font-medium">{modelName}</td>
|
|
361
|
+
{provider.anthropicBaseUrl !== undefined &&
|
|
362
|
+
provider.anthropicBaseUrl !== "" && (
|
|
363
|
+
<td className="py-1 px-2">
|
|
364
|
+
<TestStatus result={modelResult.anthropic.nonStreaming} />
|
|
365
|
+
</td>
|
|
366
|
+
)}
|
|
367
|
+
{provider.openaiBaseUrl !== undefined && provider.openaiBaseUrl !== "" && (
|
|
368
|
+
<td className="py-1 px-2">
|
|
369
|
+
<TestStatus result={modelResult.openai.nonStreaming} />
|
|
370
|
+
</td>
|
|
371
|
+
)}
|
|
372
|
+
</tr>
|
|
373
|
+
))}
|
|
374
|
+
</tbody>
|
|
375
|
+
</table>
|
|
376
|
+
</div>
|
|
377
|
+
)}
|
|
378
|
+
</div>
|
|
379
|
+
)}
|
|
380
|
+
|
|
276
381
|
<div className="flex gap-2 pt-1 border-t">
|
|
277
382
|
{onTest !== undefined && (
|
|
278
383
|
<Button
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { type JSX, useState, useEffect } from "react";
|
|
1
|
+
import { type JSX, useState, useEffect, useRef } from "react";
|
|
2
2
|
import { Button } from "../ui/button";
|
|
3
|
-
import { Eye, EyeOff, Copy, Check } from "lucide-react";
|
|
3
|
+
import { Eye, EyeOff, Copy, Check, ChevronDown } from "lucide-react";
|
|
4
4
|
import type { ProviderConfig } from "../../proxy/providers";
|
|
5
5
|
import { maskApiKey } from "../../lib/mask";
|
|
6
6
|
|
|
@@ -44,7 +44,7 @@ type ProviderFormProps = {
|
|
|
44
44
|
onSubmit: (data: {
|
|
45
45
|
name: string;
|
|
46
46
|
apiKey: string;
|
|
47
|
-
|
|
47
|
+
models: string[];
|
|
48
48
|
anthropicBaseUrl?: string;
|
|
49
49
|
openaiBaseUrl?: string;
|
|
50
50
|
apiDocsUrl?: string;
|
|
@@ -54,11 +54,14 @@ type ProviderFormProps = {
|
|
|
54
54
|
};
|
|
55
55
|
|
|
56
56
|
export function ProviderForm({ provider, onSubmit, onCancel }: ProviderFormProps): JSX.Element {
|
|
57
|
-
const [name, setName] = useState(provider?.name ?? "
|
|
57
|
+
const [name, setName] = useState(provider?.name ?? "");
|
|
58
58
|
const [apiKey, setApiKey] = useState(provider?.apiKey ?? "");
|
|
59
59
|
const [showApiKey, setShowApiKey] = useState(false);
|
|
60
60
|
const [copied, setCopied] = useState(false);
|
|
61
|
-
const
|
|
61
|
+
const initialModels = provider?.models;
|
|
62
|
+
const [models, setModels] = useState<string[]>(
|
|
63
|
+
initialModels !== undefined && initialModels.length > 0 ? initialModels : [""],
|
|
64
|
+
);
|
|
62
65
|
const [activeTab, setActiveTab] = useState<"anthropic" | "openai">("anthropic");
|
|
63
66
|
const [anthropicBaseUrl, setAnthropicBaseUrl] = useState(provider?.anthropicBaseUrl ?? "");
|
|
64
67
|
const [openaiBaseUrl, setOpenaiBaseUrl] = useState(provider?.openaiBaseUrl ?? "");
|
|
@@ -66,6 +69,23 @@ export function ProviderForm({ provider, onSubmit, onCancel }: ProviderFormProps
|
|
|
66
69
|
const [source, setSource] = useState<"company" | "personal" | undefined>(provider?.source);
|
|
67
70
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
|
68
71
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
72
|
+
const [openModelDropdown, setOpenModelDropdown] = useState<number | null>(null);
|
|
73
|
+
const modelRowRefs = useRef<(HTMLDivElement | null)[]>([]);
|
|
74
|
+
|
|
75
|
+
// Close model dropdown when clicking outside
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
if (openModelDropdown === null) return;
|
|
78
|
+
const index = openModelDropdown;
|
|
79
|
+
function handleClick(e: MouseEvent) {
|
|
80
|
+
if (!(e.target instanceof Node)) return;
|
|
81
|
+
const ref = modelRowRefs.current[index];
|
|
82
|
+
if (ref !== null && ref !== undefined && !ref.contains(e.target)) {
|
|
83
|
+
setOpenModelDropdown(null);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
document.addEventListener("mousedown", handleClick);
|
|
87
|
+
return () => document.removeEventListener("mousedown", handleClick);
|
|
88
|
+
}, [openModelDropdown]);
|
|
69
89
|
|
|
70
90
|
// Track if URL fields have been manually edited (to avoid overriding user edits)
|
|
71
91
|
const [manualAnthropicUrlOverride, setManualAnthropicUrlOverride] = useState(false);
|
|
@@ -86,7 +106,7 @@ export function ProviderForm({ provider, onSubmit, onCancel }: ProviderFormProps
|
|
|
86
106
|
if (provider) {
|
|
87
107
|
setName(provider.name);
|
|
88
108
|
setApiKey(provider.apiKey);
|
|
89
|
-
|
|
109
|
+
setModels((provider.models?.length ?? 0) > 0 ? provider.models : [""]);
|
|
90
110
|
setAnthropicBaseUrl(provider.anthropicBaseUrl ?? "");
|
|
91
111
|
setOpenaiBaseUrl(provider.openaiBaseUrl ?? "");
|
|
92
112
|
setApiDocsUrl(provider.apiDocsUrl ?? "");
|
|
@@ -110,12 +130,12 @@ export function ProviderForm({ provider, onSubmit, onCancel }: ProviderFormProps
|
|
|
110
130
|
setApiDocsUrl(preset.apiDocsUrl);
|
|
111
131
|
}
|
|
112
132
|
// For MiniMax, auto-select the first model if not already set
|
|
113
|
-
if (keyword === "minimax" &&
|
|
114
|
-
|
|
133
|
+
if (keyword === "minimax" && models.length === 1 && models[0] === "") {
|
|
134
|
+
setModels([MINIMAX_MODELS[0] ?? ""]);
|
|
115
135
|
}
|
|
116
136
|
// For Alibaba, auto-select the first model if not already set
|
|
117
|
-
if (keyword === "alibaba" &&
|
|
118
|
-
|
|
137
|
+
if (keyword === "alibaba" && models.length === 1 && models[0] === "") {
|
|
138
|
+
setModels([ALIBABA_MODELS[0] ?? ""]);
|
|
119
139
|
}
|
|
120
140
|
break;
|
|
121
141
|
}
|
|
@@ -130,8 +150,8 @@ export function ProviderForm({ provider, onSubmit, onCancel }: ProviderFormProps
|
|
|
130
150
|
if (!apiKey.trim()) {
|
|
131
151
|
newErrors.apiKey = "API key is required";
|
|
132
152
|
}
|
|
133
|
-
if (!
|
|
134
|
-
newErrors.
|
|
153
|
+
if (models.length === 0 || models.every((m) => !m.trim())) {
|
|
154
|
+
newErrors.models = "At least one model is required";
|
|
135
155
|
}
|
|
136
156
|
if (anthropicBaseUrl.trim() && !isValidUrl(anthropicBaseUrl.trim())) {
|
|
137
157
|
newErrors.anthropicBaseUrl = "Invalid URL format";
|
|
@@ -163,7 +183,7 @@ export function ProviderForm({ provider, onSubmit, onCancel }: ProviderFormProps
|
|
|
163
183
|
onSubmit({
|
|
164
184
|
name: name.trim(),
|
|
165
185
|
apiKey: apiKey.trim(),
|
|
166
|
-
|
|
186
|
+
models: models.map((m) => m.trim()).filter((m) => m !== ""),
|
|
167
187
|
anthropicBaseUrl: anthropicBaseUrl.trim() || undefined,
|
|
168
188
|
openaiBaseUrl: openaiBaseUrl.trim() || undefined,
|
|
169
189
|
apiDocsUrl: apiDocsUrl.trim() || undefined,
|
|
@@ -185,7 +205,7 @@ export function ProviderForm({ provider, onSubmit, onCancel }: ProviderFormProps
|
|
|
185
205
|
type="text"
|
|
186
206
|
value={name}
|
|
187
207
|
onChange={(e) => setName(e.target.value)}
|
|
188
|
-
placeholder="
|
|
208
|
+
placeholder="Provider Name"
|
|
189
209
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:border-ring focus-visible:outline-ring focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50"
|
|
190
210
|
/>
|
|
191
211
|
{errors.name !== undefined && <p className="text-xs text-destructive">{errors.name}</p>}
|
|
@@ -254,33 +274,99 @@ export function ProviderForm({ provider, onSubmit, onCancel }: ProviderFormProps
|
|
|
254
274
|
</div>
|
|
255
275
|
|
|
256
276
|
<div className="space-y-2">
|
|
257
|
-
<label
|
|
258
|
-
|
|
277
|
+
<label className="text-sm font-medium">
|
|
278
|
+
Models <span className="text-destructive">*</span>
|
|
259
279
|
</label>
|
|
260
|
-
{
|
|
261
|
-
<
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
280
|
+
{models.map((m, i) => (
|
|
281
|
+
<div
|
|
282
|
+
key={i}
|
|
283
|
+
ref={(el) => {
|
|
284
|
+
modelRowRefs.current[i] = el;
|
|
285
|
+
}}
|
|
286
|
+
className="flex items-center gap-2"
|
|
266
287
|
>
|
|
267
|
-
|
|
268
|
-
<
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
288
|
+
<div className="relative flex-1">
|
|
289
|
+
<input
|
|
290
|
+
type="text"
|
|
291
|
+
value={m}
|
|
292
|
+
onChange={(e) => {
|
|
293
|
+
setModels((prev) => {
|
|
294
|
+
const next = [...prev];
|
|
295
|
+
next[i] = e.target.value;
|
|
296
|
+
return next;
|
|
297
|
+
});
|
|
298
|
+
}}
|
|
299
|
+
placeholder={isMiniMax || isAlibaba ? "Type or select a model..." : "Model name"}
|
|
300
|
+
className="w-full rounded-md border border-input bg-background px-4 py-3 pr-8 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:border-ring focus-visible:outline-ring focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50"
|
|
301
|
+
/>
|
|
302
|
+
{(isMiniMax || isAlibaba) && (
|
|
303
|
+
<>
|
|
304
|
+
<button
|
|
305
|
+
type="button"
|
|
306
|
+
onClick={() => setOpenModelDropdown(openModelDropdown === i ? null : i)}
|
|
307
|
+
className="absolute right-1 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors p-1 z-10"
|
|
308
|
+
aria-label="Show model suggestions"
|
|
309
|
+
>
|
|
310
|
+
<ChevronDown className="size-4" />
|
|
311
|
+
</button>
|
|
312
|
+
{openModelDropdown === i && (
|
|
313
|
+
<div className="absolute left-0 right-0 top-full mt-1 z-50 bg-popover border border-border rounded-md shadow-md max-h-48 overflow-y-auto">
|
|
314
|
+
{(isMiniMax ? MINIMAX_MODELS : ALIBABA_MODELS).map((opt) => (
|
|
315
|
+
<button
|
|
316
|
+
key={opt}
|
|
317
|
+
type="button"
|
|
318
|
+
onClick={() => {
|
|
319
|
+
setModels((prev) => {
|
|
320
|
+
const next = [...prev];
|
|
321
|
+
next[i] = opt;
|
|
322
|
+
return next;
|
|
323
|
+
});
|
|
324
|
+
setOpenModelDropdown(null);
|
|
325
|
+
}}
|
|
326
|
+
className="w-full text-left px-3 py-2 text-sm hover:bg-muted transition-colors"
|
|
327
|
+
>
|
|
328
|
+
{opt}
|
|
329
|
+
</button>
|
|
330
|
+
))}
|
|
331
|
+
</div>
|
|
332
|
+
)}
|
|
333
|
+
</>
|
|
334
|
+
)}
|
|
335
|
+
</div>
|
|
336
|
+
{models.length > 1 && (
|
|
337
|
+
<button
|
|
338
|
+
type="button"
|
|
339
|
+
onClick={() => setModels((prev) => prev.filter((_, idx) => idx !== i))}
|
|
340
|
+
className="text-muted-foreground hover:text-destructive transition-colors p-1 shrink-0"
|
|
341
|
+
aria-label="Remove model"
|
|
342
|
+
>
|
|
343
|
+
<svg
|
|
344
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
345
|
+
width="16"
|
|
346
|
+
height="16"
|
|
347
|
+
viewBox="0 0 24 24"
|
|
348
|
+
fill="none"
|
|
349
|
+
stroke="currentColor"
|
|
350
|
+
strokeWidth="2"
|
|
351
|
+
strokeLinecap="round"
|
|
352
|
+
strokeLinejoin="round"
|
|
353
|
+
>
|
|
354
|
+
<path d="M3 6h18" />
|
|
355
|
+
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
|
|
356
|
+
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
|
|
357
|
+
</svg>
|
|
358
|
+
</button>
|
|
359
|
+
)}
|
|
360
|
+
</div>
|
|
361
|
+
))}
|
|
362
|
+
<button
|
|
363
|
+
type="button"
|
|
364
|
+
onClick={() => setModels((prev) => [...prev, ""])}
|
|
365
|
+
className="text-xs text-primary hover:underline flex items-center gap-1"
|
|
366
|
+
>
|
|
367
|
+
+ Add Model
|
|
368
|
+
</button>
|
|
369
|
+
{errors.models !== undefined && <p className="text-xs text-destructive">{errors.models}</p>}
|
|
284
370
|
</div>
|
|
285
371
|
|
|
286
372
|
<div className="space-y-2">
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type JSX, useState, useEffect, useCallback, useRef } from "react";
|
|
1
|
+
import { type JSX, useState, useEffect, useCallback, useMemo, useRef } from "react";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { Button } from "../ui/button";
|
|
4
4
|
import { Plus, AlertCircle, Copy, Check, Download, Upload } from "lucide-react";
|
|
@@ -32,7 +32,7 @@ const NETWORK_ERROR_MESSAGE = "Network error: could not reach the server. Is the
|
|
|
32
32
|
type ProviderFormData = {
|
|
33
33
|
name: string;
|
|
34
34
|
apiKey: string;
|
|
35
|
-
|
|
35
|
+
models: string[];
|
|
36
36
|
anthropicBaseUrl?: string;
|
|
37
37
|
openaiBaseUrl?: string;
|
|
38
38
|
apiDocsUrl?: string;
|
|
@@ -43,7 +43,7 @@ function createProviderPayload(data: ProviderFormData) {
|
|
|
43
43
|
return {
|
|
44
44
|
name: data.name,
|
|
45
45
|
apiKey: data.apiKey,
|
|
46
|
-
|
|
46
|
+
models: data.models,
|
|
47
47
|
anthropicBaseUrl: (data.anthropicBaseUrl?.length ?? 0) > 0 ? data.anthropicBaseUrl : undefined,
|
|
48
48
|
openaiBaseUrl: (data.openaiBaseUrl?.length ?? 0) > 0 ? data.openaiBaseUrl : undefined,
|
|
49
49
|
apiDocsUrl: (data.apiDocsUrl?.length ?? 0) > 0 ? data.apiDocsUrl : undefined,
|
|
@@ -91,8 +91,17 @@ export function ProvidersPanel({
|
|
|
91
91
|
|
|
92
92
|
// Use external state if provided (from SWR), otherwise use internal state
|
|
93
93
|
const providers = externalProviders ?? [];
|
|
94
|
-
const filteredProviders =
|
|
95
|
-
|
|
94
|
+
const filteredProviders = useMemo(() => {
|
|
95
|
+
const filtered =
|
|
96
|
+
sourceFilter === "all" ? providers : providers.filter((p) => p.source === sourceFilter);
|
|
97
|
+
if (sourceFilter === "all") {
|
|
98
|
+
return [...filtered].sort((a, b) => {
|
|
99
|
+
const order: Record<string, number> = { personal: 0, company: 1 };
|
|
100
|
+
return (order[a.source ?? ""] ?? 2) - (order[b.source ?? ""] ?? 2);
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
return filtered;
|
|
104
|
+
}, [providers, sourceFilter]);
|
|
96
105
|
const testResults = externalTestResults ?? internalTestResults;
|
|
97
106
|
const testingProviders = externalTestingProviders ?? internalTestingProviders;
|
|
98
107
|
const testingTimeLeft = externalTestingTimeLeft ?? internalTestingTimeLeft;
|
|
@@ -285,7 +294,7 @@ export function ProvidersPanel({
|
|
|
285
294
|
// Only run connection test when critical fields (apiKey, model, base URLs) changed
|
|
286
295
|
const criticalFieldsChanged =
|
|
287
296
|
(data.apiKey ?? "") !== (editingProvider.apiKey ?? "") ||
|
|
288
|
-
(data.
|
|
297
|
+
JSON.stringify(data.models) !== JSON.stringify(editingProvider.models) ||
|
|
289
298
|
(data.anthropicBaseUrl ?? "") !== (editingProvider.anthropicBaseUrl ?? "") ||
|
|
290
299
|
(data.openaiBaseUrl ?? "") !== (editingProvider.openaiBaseUrl ?? "");
|
|
291
300
|
if (criticalFieldsChanged) {
|
|
@@ -392,7 +401,7 @@ export function ProvidersPanel({
|
|
|
392
401
|
if (showForm || editingProvider) {
|
|
393
402
|
return (
|
|
394
403
|
<div className="space-y-4">
|
|
395
|
-
<div className="flex items-center justify-between">
|
|
404
|
+
<div className="flex items-center justify-between sticky top-0 bg-background z-10 pb-2">
|
|
396
405
|
<h3 className="text-lg font-medium">
|
|
397
406
|
{editingProvider ? "Edit Provider" : "Add New Provider"}
|
|
398
407
|
</h3>
|
|
@@ -69,7 +69,7 @@ export function SettingsDialog(): JSX.Element {
|
|
|
69
69
|
<TabsTrigger value="proxy">Proxy</TabsTrigger>
|
|
70
70
|
</TabsList>
|
|
71
71
|
|
|
72
|
-
<div className="mt-4 overflow-y-auto flex-1">
|
|
72
|
+
<div className="mt-4 overflow-y-auto flex-1 pr-3">
|
|
73
73
|
<TabsContent value="providers">
|
|
74
74
|
<ProvidersPanel
|
|
75
75
|
externalProviders={providers}
|
|
@@ -11,6 +11,7 @@ export const ProviderConfigSchema = z.object({
|
|
|
11
11
|
name: z.string(),
|
|
12
12
|
apiKey: z.string(),
|
|
13
13
|
model: z.string().optional(),
|
|
14
|
+
models: z.array(z.string()).min(1),
|
|
14
15
|
format: z.enum(["anthropic", "openai"]).optional(),
|
|
15
16
|
baseUrl: z.string().optional(),
|
|
16
17
|
anthropicBaseUrl: z.string().optional(),
|
|
@@ -57,15 +57,23 @@ const ProviderFormatTestResultsSchema = z.object({
|
|
|
57
57
|
streaming: ProviderTestStateSchema,
|
|
58
58
|
});
|
|
59
59
|
|
|
60
|
+
const ModelTestResultsSchema = z.object({
|
|
61
|
+
anthropic: ProviderFormatTestResultsSchema,
|
|
62
|
+
openai: ProviderFormatTestResultsSchema,
|
|
63
|
+
});
|
|
64
|
+
|
|
60
65
|
export const ProviderTestResultsSchema = z.object({
|
|
61
66
|
anthropic: ProviderFormatTestResultsSchema,
|
|
62
67
|
openai: ProviderFormatTestResultsSchema,
|
|
68
|
+
models: z.record(z.string(), ModelTestResultsSchema).optional(),
|
|
69
|
+
testedAt: z.string().optional(),
|
|
63
70
|
});
|
|
64
71
|
|
|
65
72
|
export type ProviderTestErrorType = z.infer<typeof ProviderTestErrorTypeSchema>;
|
|
66
73
|
export type ProviderTestResult = z.infer<typeof ProviderTestResultSchema>;
|
|
67
74
|
export type ProviderTestState = z.infer<typeof ProviderTestStateSchema>;
|
|
68
75
|
export type ProviderTestResults = z.infer<typeof ProviderTestResultsSchema>;
|
|
76
|
+
export type ModelTestResults = z.infer<typeof ModelTestResultsSchema>;
|
|
69
77
|
|
|
70
78
|
export function createPendingProviderTestResults(): ProviderTestResults {
|
|
71
79
|
return {
|