@tonyclaw/llm-inspector 1.14.1 → 1.14.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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-BElVT2p3.js"], "assets": [] }, "/": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/index.tsx", "assets": [], "preloads": ["/assets/index-DDrUlr6L.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-BElVT2p3.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-10T13:27:43.020Z",
42
42
  "size": 5915,
43
43
  "path": "../public/assets/alibaba-TTwafVwX.svg"
44
44
  },
45
45
  "/assets/minimax-BPMzvuL-.jpeg": {
46
46
  "type": "image/jpeg",
47
47
  "etag": '"1b06-IwivU89ko5UTMUM1/t7hn4sQK9A"',
48
- "mtime": "2026-06-10T10:39:07.172Z",
48
+ "mtime": "2026-06-10T13:27:43.023Z",
49
49
  "size": 6918,
50
50
  "path": "../public/assets/minimax-BPMzvuL-.jpeg"
51
51
  },
52
- "/assets/index-DEUddp_2.css": {
52
+ "/assets/index-DOG5AdQ9.css": {
53
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": {
60
- "type": "text/javascript; charset=utf-8",
61
- "etag": '"50599-SX7gD+cBJGiUJBAZdsWQTyO8AMw"',
62
- "mtime": "2026-06-10T10:39:07.175Z",
63
- "size": 329113,
64
- "path": "../public/assets/main-BzK2SzIB.js"
54
+ "etag": '"142da-qXGGlxiu6GXrwGw4lzJEDsR446o"',
55
+ "mtime": "2026-06-10T13:27:43.023Z",
56
+ "size": 82650,
57
+ "path": "../public/assets/index-DOG5AdQ9.css"
65
58
  },
66
59
  "/assets/zhipuai-BPNAnxo-.svg": {
67
60
  "type": "image/svg+xml",
68
61
  "etag": '"2bf8-hNaLCTi89nOFCsIIfWpP/jrfo0s"',
69
- "mtime": "2026-06-10T10:39:07.175Z",
62
+ "mtime": "2026-06-10T13:27:43.023Z",
70
63
  "size": 11256,
71
64
  "path": "../public/assets/zhipuai-BPNAnxo-.svg"
72
65
  },
73
66
  "/assets/qwen-CONDcHqt.png": {
74
67
  "type": "image/png",
75
68
  "etag": '"572c3-cdJAPaHdOvFCGzuaQjagdgOu6XE"',
76
- "mtime": "2026-06-10T10:39:07.175Z",
69
+ "mtime": "2026-06-10T13:27:43.023Z",
77
70
  "size": 357059,
78
71
  "path": "../public/assets/qwen-CONDcHqt.png"
79
72
  },
80
- "/assets/index-ax85pt2A.js": {
73
+ "/assets/main-BElVT2p3.js": {
74
+ "type": "text/javascript; charset=utf-8",
75
+ "etag": '"50599-c0Re5vg5+KI0J9dHMI2SM+wdp9A"',
76
+ "mtime": "2026-06-10T13:27:43.023Z",
77
+ "size": 329113,
78
+ "path": "../public/assets/main-BElVT2p3.js"
79
+ },
80
+ "/assets/index-DDrUlr6L.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": '"91fb4-sGd/w9UW0TGYuvQ5WfgvmwcNukI"',
83
+ "mtime": "2026-06-10T13:27:43.024Z",
84
+ "size": 597940,
85
+ "path": "../public/assets/index-DDrUlr6L.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.2",
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
@@ -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;
@@ -58,7 +58,10 @@ export function ProviderForm({ provider, onSubmit, onCancel }: ProviderFormProps
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 ?? "");
@@ -86,7 +89,7 @@ export function ProviderForm({ provider, onSubmit, onCancel }: ProviderFormProps
86
89
  if (provider) {
87
90
  setName(provider.name);
88
91
  setApiKey(provider.apiKey);
89
- setModel(provider.model ?? "");
92
+ setModels((provider.models?.length ?? 0) > 0 ? provider.models : [""]);
90
93
  setAnthropicBaseUrl(provider.anthropicBaseUrl ?? "");
91
94
  setOpenaiBaseUrl(provider.openaiBaseUrl ?? "");
92
95
  setApiDocsUrl(provider.apiDocsUrl ?? "");
@@ -110,12 +113,12 @@ export function ProviderForm({ provider, onSubmit, onCancel }: ProviderFormProps
110
113
  setApiDocsUrl(preset.apiDocsUrl);
111
114
  }
112
115
  // For MiniMax, auto-select the first model if not already set
113
- if (keyword === "minimax" && !model) {
114
- setModel(MINIMAX_MODELS[0] ?? "");
116
+ if (keyword === "minimax" && models.length === 1 && models[0] === "") {
117
+ setModels([MINIMAX_MODELS[0] ?? ""]);
115
118
  }
116
119
  // For Alibaba, auto-select the first model if not already set
117
- if (keyword === "alibaba" && !model) {
118
- setModel(ALIBABA_MODELS[0] ?? "");
120
+ if (keyword === "alibaba" && models.length === 1 && models[0] === "") {
121
+ setModels([ALIBABA_MODELS[0] ?? ""]);
119
122
  }
120
123
  break;
121
124
  }
@@ -130,8 +133,8 @@ export function ProviderForm({ provider, onSubmit, onCancel }: ProviderFormProps
130
133
  if (!apiKey.trim()) {
131
134
  newErrors.apiKey = "API key is required";
132
135
  }
133
- if (!model.trim()) {
134
- newErrors.model = "Model is required";
136
+ if (models.length === 0 || models.every((m) => !m.trim())) {
137
+ newErrors.models = "At least one model is required";
135
138
  }
136
139
  if (anthropicBaseUrl.trim() && !isValidUrl(anthropicBaseUrl.trim())) {
137
140
  newErrors.anthropicBaseUrl = "Invalid URL format";
@@ -163,7 +166,7 @@ export function ProviderForm({ provider, onSubmit, onCancel }: ProviderFormProps
163
166
  onSubmit({
164
167
  name: name.trim(),
165
168
  apiKey: apiKey.trim(),
166
- model: model.trim() || undefined,
169
+ models: models.map((m) => m.trim()).filter((m) => m !== ""),
167
170
  anthropicBaseUrl: anthropicBaseUrl.trim() || undefined,
168
171
  openaiBaseUrl: openaiBaseUrl.trim() || undefined,
169
172
  apiDocsUrl: apiDocsUrl.trim() || undefined,
@@ -254,33 +257,66 @@ export function ProviderForm({ provider, onSubmit, onCancel }: ProviderFormProps
254
257
  </div>
255
258
 
256
259
  <div className="space-y-2">
257
- <label htmlFor="provider-model" className="text-sm font-medium">
258
- Model <span className="text-destructive">*</span>
260
+ <label className="text-sm font-medium">
261
+ Models <span className="text-destructive">*</span>
259
262
  </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"
266
- >
267
- {(isMiniMax ? MINIMAX_MODELS : ALIBABA_MODELS).map((m) => (
268
- <option key={m} value={m}>
269
- {m}
270
- </option>
263
+ {(isMiniMax || isAlibaba) && (
264
+ <datalist id="model-suggestions">
265
+ {(isMiniMax ? MINIMAX_MODELS : ALIBABA_MODELS).map((opt) => (
266
+ <option key={opt} value={opt} />
271
267
  ))}
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
- />
268
+ </datalist>
282
269
  )}
283
- {errors.model !== undefined && <p className="text-xs text-destructive">{errors.model}</p>}
270
+ {models.map((m, i) => (
271
+ <div key={i} className="flex items-center gap-2">
272
+ <input
273
+ type="text"
274
+ value={m}
275
+ onChange={(e) => {
276
+ setModels((prev) => {
277
+ const next = [...prev];
278
+ next[i] = e.target.value;
279
+ return next;
280
+ });
281
+ }}
282
+ placeholder={isMiniMax || isAlibaba ? "Type or select a model..." : "Model name"}
283
+ list={isMiniMax || isAlibaba ? "model-suggestions" : undefined}
284
+ className="flex-1 rounded-md border border-input bg-background px-4 py-3 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"
285
+ />
286
+ {models.length > 1 && (
287
+ <button
288
+ type="button"
289
+ onClick={() => setModels((prev) => prev.filter((_, idx) => idx !== i))}
290
+ className="text-muted-foreground hover:text-destructive transition-colors p-1 shrink-0"
291
+ aria-label="Remove model"
292
+ >
293
+ <svg
294
+ xmlns="http://www.w3.org/2000/svg"
295
+ width="16"
296
+ height="16"
297
+ viewBox="0 0 24 24"
298
+ fill="none"
299
+ stroke="currentColor"
300
+ strokeWidth="2"
301
+ strokeLinecap="round"
302
+ strokeLinejoin="round"
303
+ >
304
+ <path d="M3 6h18" />
305
+ <path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
306
+ <path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
307
+ </svg>
308
+ </button>
309
+ )}
310
+ </div>
311
+ ))}
312
+ <button
313
+ type="button"
314
+ onClick={() => setModels((prev) => [...prev, ""])}
315
+ className="text-xs text-primary hover:underline flex items-center gap-1"
316
+ >
317
+ + Add Model
318
+ </button>
319
+ {errors.models !== undefined && <p className="text-xs text-destructive">{errors.models}</p>}
284
320
  </div>
285
321
 
286
322
  <div className="space-y-2">
@@ -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,
@@ -285,7 +285,7 @@ export function ProvidersPanel({
285
285
  // Only run connection test when critical fields (apiKey, model, base URLs) changed
286
286
  const criticalFieldsChanged =
287
287
  (data.apiKey ?? "") !== (editingProvider.apiKey ?? "") ||
288
- (data.model ?? "") !== (editingProvider.model ?? "") ||
288
+ JSON.stringify(data.models) !== JSON.stringify(editingProvider.models) ||
289
289
  (data.anthropicBaseUrl ?? "") !== (editingProvider.anthropicBaseUrl ?? "") ||
290
290
  (data.openaiBaseUrl ?? "") !== (editingProvider.openaiBaseUrl ?? "");
291
291
  if (criticalFieldsChanged) {
@@ -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 {