@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.
@@ -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-BzK2SzIB.js"], "assets": [] }, "/": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/index.tsx", "assets": [], "preloads": ["/assets/index-ax85pt2A.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" } }, "clientEntry": "/assets/main-BzK2SzIB.js" });
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
  };
@@ -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-10T10:39:07.175Z",
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-10T10:39:07.172Z",
55
+ "mtime": "2026-06-11T02:41:57.154Z",
49
56
  "size": 6918,
50
57
  "path": "../public/assets/minimax-BPMzvuL-.jpeg"
51
58
  },
52
- "/assets/index-DEUddp_2.css": {
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-SX7gD+cBJGiUJBAZdsWQTyO8AMw"',
62
- "mtime": "2026-06-10T10:39:07.175Z",
61
+ "etag": '"50599-Zn3MOTxDKfi6edBd1hMuk0XMH8M"',
62
+ "mtime": "2026-06-11T02:41:57.155Z",
63
63
  "size": 329113,
64
- "path": "../public/assets/main-BzK2SzIB.js"
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-10T10:39:07.175Z",
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-10T10:39:07.175Z",
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-ax85pt2A.js": {
80
+ "/assets/index-6F6Tf88s.js": {
81
81
  "type": "text/javascript; charset=utf-8",
82
- "etag": '"91290-BbX996HkZZYEbnE1RQsvxosxLnk"',
83
- "mtime": "2026-06-10T10:39:07.175Z",
84
- "size": 594576,
85
- "path": "../public/assets/index-ax85pt2A.js"
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@tonyclaw/llm-inspector",
3
- "version": "1.14.1",
3
+ "version": "1.14.3",
4
4
  "type": "module",
5
5
  "description": "LLM API proxy inspector — captures and displays requests/responses from AI coding tools in a web UI",
6
6
  "license": "MIT",
@@ -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
- model?: string;
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 ?? "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 [model, setModel] = useState(provider?.model ?? "");
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
- setModel(provider.model ?? "");
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" && !model) {
114
- setModel(MINIMAX_MODELS[0] ?? "");
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" && !model) {
118
- setModel(ALIBABA_MODELS[0] ?? "");
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 (!model.trim()) {
134
- newErrors.model = "Model is required";
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
- model: model.trim() || undefined,
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="Model Name"
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 htmlFor="provider-model" className="text-sm font-medium">
258
- Model <span className="text-destructive">*</span>
277
+ <label className="text-sm font-medium">
278
+ Models <span className="text-destructive">*</span>
259
279
  </label>
260
- {isMiniMax || isAlibaba ? (
261
- <select
262
- id="provider-model"
263
- value={model}
264
- onChange={(e) => setModel(e.target.value)}
265
- className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:border-ring focus-visible:outline-ring focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50"
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
- {(isMiniMax ? MINIMAX_MODELS : ALIBABA_MODELS).map((m) => (
268
- <option key={m} value={m}>
269
- {m}
270
- </option>
271
- ))}
272
- </select>
273
- ) : (
274
- <input
275
- id="provider-model"
276
- type="text"
277
- value={model}
278
- onChange={(e) => setModel(e.target.value)}
279
- placeholder=""
280
- 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"
281
- />
282
- )}
283
- {errors.model !== undefined && <p className="text-xs text-destructive">{errors.model}</p>}
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
- model?: string;
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
- model: data.model,
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
- sourceFilter === "all" ? providers : providers.filter((p) => p.source === sourceFilter);
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.model ?? "") !== (editingProvider.model ?? "") ||
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 {