@tonyclaw/llm-inspector 1.14.0 → 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,6 +1,8 @@
1
1
  import { type JSX, useState, useEffect } from "react";
2
2
  import { Button } from "../ui/button";
3
+ import { Eye, EyeOff, Copy, Check } from "lucide-react";
3
4
  import type { ProviderConfig } from "../../proxy/providers";
5
+ import { maskApiKey } from "../../lib/mask";
4
6
 
5
7
  // Known provider presets - maps provider name keywords to their API URLs
6
8
  const KNOWN_PROVIDER_PRESETS: Record<
@@ -42,10 +44,11 @@ type ProviderFormProps = {
42
44
  onSubmit: (data: {
43
45
  name: string;
44
46
  apiKey: string;
45
- model?: string;
46
- format: "anthropic" | "openai";
47
- baseUrl?: string;
47
+ models: string[];
48
+ anthropicBaseUrl?: string;
49
+ openaiBaseUrl?: string;
48
50
  apiDocsUrl?: string;
51
+ source?: "company" | "personal";
49
52
  }) => void;
50
53
  onCancel: () => void;
51
54
  };
@@ -53,30 +56,46 @@ type ProviderFormProps = {
53
56
  export function ProviderForm({ provider, onSubmit, onCancel }: ProviderFormProps): JSX.Element {
54
57
  const [name, setName] = useState(provider?.name ?? "Provider Name");
55
58
  const [apiKey, setApiKey] = useState(provider?.apiKey ?? "");
56
- const [model, setModel] = useState(provider?.model ?? "");
57
- const [format, setFormat] = useState<"anthropic" | "openai">(provider?.format ?? "anthropic");
58
- const [baseUrl, setBaseUrl] = useState(provider?.baseUrl ?? "");
59
+ const [showApiKey, setShowApiKey] = useState(false);
60
+ const [copied, setCopied] = useState(false);
61
+ const initialModels = provider?.models;
62
+ const [models, setModels] = useState<string[]>(
63
+ initialModels !== undefined && initialModels.length > 0 ? initialModels : [""],
64
+ );
65
+ const [activeTab, setActiveTab] = useState<"anthropic" | "openai">("anthropic");
66
+ const [anthropicBaseUrl, setAnthropicBaseUrl] = useState(provider?.anthropicBaseUrl ?? "");
67
+ const [openaiBaseUrl, setOpenaiBaseUrl] = useState(provider?.openaiBaseUrl ?? "");
59
68
  const [apiDocsUrl, setApiDocsUrl] = useState(provider?.apiDocsUrl ?? "");
69
+ const [source, setSource] = useState<"company" | "personal" | undefined>(provider?.source);
60
70
  const [errors, setErrors] = useState<Record<string, string>>({});
61
71
  const [isSubmitting, setIsSubmitting] = useState(false);
62
72
 
63
- // Track if URL field has been manually edited (to avoid overriding user edits)
64
- const [manualBaseUrlOverride, setManualBaseUrlOverride] = useState(false);
73
+ // Track if URL fields have been manually edited (to avoid overriding user edits)
74
+ const [manualAnthropicUrlOverride, setManualAnthropicUrlOverride] = useState(false);
75
+ const [manualOpenaiUrlOverride, setManualOpenaiUrlOverride] = useState(false);
65
76
 
66
77
  // Check if MiniMax is detected
67
78
  const isMiniMax = name.toLowerCase().includes("minimax");
68
79
  // Check if Alibaba is detected
69
80
  const isAlibaba = name.toLowerCase().includes("alibaba");
70
81
 
82
+ function handleCopy() {
83
+ navigator.clipboard.writeText(apiKey).catch(() => {});
84
+ setCopied(true);
85
+ setTimeout(() => setCopied(false), 2000);
86
+ }
87
+
71
88
  useEffect(() => {
72
89
  if (provider) {
73
90
  setName(provider.name);
74
91
  setApiKey(provider.apiKey);
75
- setModel(provider.model ?? "");
76
- setFormat(provider.format ?? "anthropic");
77
- setBaseUrl(provider.baseUrl ?? "");
92
+ setModels((provider.models?.length ?? 0) > 0 ? provider.models : [""]);
93
+ setAnthropicBaseUrl(provider.anthropicBaseUrl ?? "");
94
+ setOpenaiBaseUrl(provider.openaiBaseUrl ?? "");
78
95
  setApiDocsUrl(provider.apiDocsUrl ?? "");
79
- setManualBaseUrlOverride(false);
96
+ setSource(provider.source);
97
+ setManualAnthropicUrlOverride(false);
98
+ setManualOpenaiUrlOverride(false);
80
99
  }
81
100
  }, [provider]);
82
101
 
@@ -85,20 +104,21 @@ export function ProviderForm({ provider, onSubmit, onCancel }: ProviderFormProps
85
104
  const lowerName = name.toLowerCase();
86
105
  for (const [keyword, preset] of Object.entries(KNOWN_PROVIDER_PRESETS)) {
87
106
  if (lowerName.includes(keyword)) {
88
- if (!manualBaseUrlOverride) {
89
- setFormat(preset.format);
90
- setBaseUrl(preset.baseUrl);
107
+ if (preset.format === "anthropic" && !manualAnthropicUrlOverride) {
108
+ setAnthropicBaseUrl(preset.baseUrl);
109
+ } else if (preset.format === "openai" && !manualOpenaiUrlOverride) {
110
+ setOpenaiBaseUrl(preset.baseUrl);
91
111
  }
92
112
  if (preset.apiDocsUrl !== undefined && !apiDocsUrl) {
93
113
  setApiDocsUrl(preset.apiDocsUrl);
94
114
  }
95
115
  // For MiniMax, auto-select the first model if not already set
96
- if (keyword === "minimax" && !model) {
97
- setModel(MINIMAX_MODELS[0] ?? "");
116
+ if (keyword === "minimax" && models.length === 1 && models[0] === "") {
117
+ setModels([MINIMAX_MODELS[0] ?? ""]);
98
118
  }
99
119
  // For Alibaba, auto-select the first model if not already set
100
- if (keyword === "alibaba" && !model) {
101
- setModel(ALIBABA_MODELS[0] ?? "");
120
+ if (keyword === "alibaba" && models.length === 1 && models[0] === "") {
121
+ setModels([ALIBABA_MODELS[0] ?? ""]);
102
122
  }
103
123
  break;
104
124
  }
@@ -113,13 +133,17 @@ export function ProviderForm({ provider, onSubmit, onCancel }: ProviderFormProps
113
133
  if (!apiKey.trim()) {
114
134
  newErrors.apiKey = "API key is required";
115
135
  }
116
- if (!model.trim()) {
117
- newErrors.model = "Model is required";
136
+ if (models.length === 0 || models.every((m) => !m.trim())) {
137
+ newErrors.models = "At least one model is required";
138
+ }
139
+ if (anthropicBaseUrl.trim() && !isValidUrl(anthropicBaseUrl.trim())) {
140
+ newErrors.anthropicBaseUrl = "Invalid URL format";
118
141
  }
119
- if (!baseUrl.trim()) {
120
- newErrors.baseUrl = "Base URL is required";
121
- } else if (!isValidUrl(baseUrl.trim())) {
122
- newErrors.baseUrl = "Invalid URL format";
142
+ if (openaiBaseUrl.trim() && !isValidUrl(openaiBaseUrl.trim())) {
143
+ newErrors.openaiBaseUrl = "Invalid URL format";
144
+ }
145
+ if (!anthropicBaseUrl.trim() && !openaiBaseUrl.trim()) {
146
+ newErrors.format = "At least one format URL (Anthropic or OpenAI) is required";
123
147
  }
124
148
  setErrors(newErrors);
125
149
  return Object.keys(newErrors).length === 0;
@@ -142,10 +166,11 @@ export function ProviderForm({ provider, onSubmit, onCancel }: ProviderFormProps
142
166
  onSubmit({
143
167
  name: name.trim(),
144
168
  apiKey: apiKey.trim(),
145
- model: model.trim() || undefined,
146
- format,
147
- baseUrl: baseUrl.trim() || undefined,
169
+ models: models.map((m) => m.trim()).filter((m) => m !== ""),
170
+ anthropicBaseUrl: anthropicBaseUrl.trim() || undefined,
171
+ openaiBaseUrl: openaiBaseUrl.trim() || undefined,
148
172
  apiDocsUrl: apiDocsUrl.trim() || undefined,
173
+ source,
149
174
  });
150
175
  } finally {
151
176
  setIsSubmitting(false);
@@ -170,93 +195,210 @@ export function ProviderForm({ provider, onSubmit, onCancel }: ProviderFormProps
170
195
  </div>
171
196
 
172
197
  <div className="space-y-2">
173
- <label htmlFor="provider-apikey" className="text-sm font-medium">
174
- API Key <span className="text-destructive">*</span>
198
+ <label htmlFor="provider-source" className="text-sm font-medium">
199
+ Token Source
175
200
  </label>
176
- <input
177
- id="provider-apikey"
178
- type="password"
179
- value={apiKey}
180
- onChange={(e) => setApiKey(e.target.value)}
181
- placeholder="sk-ant-..."
182
- 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"
183
- />
184
- {errors.apiKey !== undefined && <p className="text-xs text-destructive">{errors.apiKey}</p>}
201
+ <select
202
+ id="provider-source"
203
+ value={source ?? ""}
204
+ onChange={(e) =>
205
+ setSource(
206
+ e.target.value === "company" || e.target.value === "personal"
207
+ ? e.target.value
208
+ : undefined,
209
+ )
210
+ }
211
+ 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"
212
+ >
213
+ <option value="">—</option>
214
+ <option value="personal">个人 (Personal)</option>
215
+ <option value="company">公司 (Company)</option>
216
+ </select>
217
+ <p className="text-xs text-muted-foreground">
218
+ Label whether this API key is company-provided or personal.
219
+ </p>
185
220
  </div>
186
221
 
187
222
  <div className="space-y-2">
188
- <label htmlFor="provider-model" className="text-sm font-medium">
189
- Model <span className="text-destructive">*</span>
223
+ <label htmlFor="provider-apikey" className="text-sm font-medium">
224
+ API Key <span className="text-destructive">*</span>
190
225
  </label>
191
- {isMiniMax || isAlibaba ? (
192
- <select
193
- id="provider-model"
194
- value={model}
195
- onChange={(e) => setModel(e.target.value)}
196
- 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"
197
- >
198
- {(isMiniMax ? MINIMAX_MODELS : ALIBABA_MODELS).map((m) => (
199
- <option key={m} value={m}>
200
- {m}
201
- </option>
202
- ))}
203
- </select>
204
- ) : (
226
+ <div className="flex items-center gap-2">
205
227
  <input
206
- id="provider-model"
228
+ id="provider-apikey"
207
229
  type="text"
208
- value={model}
209
- onChange={(e) => setModel(e.target.value)}
210
- placeholder=""
211
- 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"
230
+ value={showApiKey || apiKey.length === 0 ? apiKey : maskApiKey(apiKey)}
231
+ onChange={(e) => setApiKey(e.target.value)}
232
+ onFocus={() => {
233
+ if (!showApiKey && apiKey.length > 0) setShowApiKey(true);
234
+ }}
235
+ placeholder="sk-ant-..."
236
+ readOnly={!showApiKey && apiKey.length > 0}
237
+ className="flex-1 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"
212
238
  />
213
- )}
214
- {errors.model !== undefined && <p className="text-xs text-destructive">{errors.model}</p>}
239
+ <button
240
+ type="button"
241
+ onClick={() => setShowApiKey((s) => !s)}
242
+ className="text-muted-foreground hover:text-foreground transition-colors p-1 shrink-0"
243
+ aria-label={showApiKey ? "Hide API key" : "Show API key"}
244
+ >
245
+ {showApiKey ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
246
+ </button>
247
+ <button
248
+ type="button"
249
+ onClick={handleCopy}
250
+ className="text-muted-foreground hover:text-foreground transition-colors p-1 shrink-0"
251
+ aria-label="Copy API key"
252
+ >
253
+ {copied ? <Check className="size-4 text-green-500" /> : <Copy className="size-4" />}
254
+ </button>
255
+ </div>
256
+ {errors.apiKey !== undefined && <p className="text-xs text-destructive">{errors.apiKey}</p>}
215
257
  </div>
216
258
 
217
259
  <div className="space-y-2">
218
- <label htmlFor="provider-format" className="text-sm font-medium">
219
- API Format <span className="text-destructive">*</span>
260
+ <label className="text-sm font-medium">
261
+ Models <span className="text-destructive">*</span>
220
262
  </label>
221
- <select
222
- id="provider-format"
223
- value={format}
224
- onChange={(e) => setFormat(e.target.value === "anthropic" ? "anthropic" : "openai")}
225
- 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"
263
+ {(isMiniMax || isAlibaba) && (
264
+ <datalist id="model-suggestions">
265
+ {(isMiniMax ? MINIMAX_MODELS : ALIBABA_MODELS).map((opt) => (
266
+ <option key={opt} value={opt} />
267
+ ))}
268
+ </datalist>
269
+ )}
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"
226
316
  >
227
- <option value="anthropic">Anthropic (/v1/messages)</option>
228
- <option value="openai">OpenAI (/v1/chat/completions)</option>
229
- </select>
230
- <p className="text-xs text-muted-foreground">
231
- {format === "anthropic"
232
- ? "Use Anthropic format for /v1/messages endpoint"
233
- : "Use OpenAI format for /v1/chat/completions endpoint"}
234
- </p>
317
+ + Add Model
318
+ </button>
319
+ {errors.models !== undefined && <p className="text-xs text-destructive">{errors.models}</p>}
235
320
  </div>
236
321
 
237
322
  <div className="space-y-2">
238
- <label htmlFor="provider-base-url" className="text-sm font-medium">
239
- Base URL <span className="text-destructive">*</span>
240
- </label>
241
- <input
242
- id="provider-base-url"
243
- type="text"
244
- value={baseUrl}
245
- onChange={(e) => {
246
- setManualBaseUrlOverride(true);
247
- setBaseUrl(e.target.value);
248
- }}
249
- placeholder={
250
- format === "anthropic" ? "https://api.anthropic.com" : "https://api.openai.com/v1"
251
- }
252
- 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"
253
- />
254
- {errors.baseUrl !== undefined && (
255
- <p className="text-xs text-destructive">{errors.baseUrl}</p>
256
- )}
257
- <p className="text-xs text-muted-foreground">Base URL for the provider API.</p>
323
+ <div className="flex gap-1 border-b border-border">
324
+ <button
325
+ type="button"
326
+ onClick={() => setActiveTab("anthropic")}
327
+ className={`px-3 py-2 text-sm font-medium border-b-2 transition-colors ${
328
+ activeTab === "anthropic"
329
+ ? "border-primary text-primary"
330
+ : "border-transparent text-muted-foreground hover:text-foreground"
331
+ }`}
332
+ >
333
+ Anthropic Format
334
+ </button>
335
+ <button
336
+ type="button"
337
+ onClick={() => setActiveTab("openai")}
338
+ className={`px-3 py-2 text-sm font-medium border-b-2 transition-colors ${
339
+ activeTab === "openai"
340
+ ? "border-primary text-primary"
341
+ : "border-transparent text-muted-foreground hover:text-foreground"
342
+ }`}
343
+ >
344
+ OpenAI Format
345
+ </button>
346
+ </div>
347
+ {errors.format !== undefined && <p className="text-xs text-destructive">{errors.format}</p>}
258
348
  </div>
259
349
 
350
+ {activeTab === "anthropic" && (
351
+ <div className="space-y-2">
352
+ <label htmlFor="provider-anthropic-base-url" className="text-sm font-medium">
353
+ Anthropic Base URL
354
+ </label>
355
+ <input
356
+ id="provider-anthropic-base-url"
357
+ type="text"
358
+ value={anthropicBaseUrl}
359
+ onChange={(e) => {
360
+ setManualAnthropicUrlOverride(true);
361
+ setAnthropicBaseUrl(e.target.value);
362
+ }}
363
+ placeholder="https://api.anthropic.com"
364
+ 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"
365
+ />
366
+ {errors.anthropicBaseUrl !== undefined && (
367
+ <p className="text-xs text-destructive">{errors.anthropicBaseUrl}</p>
368
+ )}
369
+ <p className="text-xs text-muted-foreground">
370
+ Anthropic-compatible endpoint URL. Leave empty if this provider does not support
371
+ Anthropic format.
372
+ </p>
373
+ </div>
374
+ )}
375
+
376
+ {activeTab === "openai" && (
377
+ <div className="space-y-2">
378
+ <label htmlFor="provider-openai-base-url" className="text-sm font-medium">
379
+ OpenAI Base URL
380
+ </label>
381
+ <input
382
+ id="provider-openai-base-url"
383
+ type="text"
384
+ value={openaiBaseUrl}
385
+ onChange={(e) => {
386
+ setManualOpenaiUrlOverride(true);
387
+ setOpenaiBaseUrl(e.target.value);
388
+ }}
389
+ placeholder="https://api.openai.com/v1"
390
+ 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"
391
+ />
392
+ {errors.openaiBaseUrl !== undefined && (
393
+ <p className="text-xs text-destructive">{errors.openaiBaseUrl}</p>
394
+ )}
395
+ <p className="text-xs text-muted-foreground">
396
+ OpenAI-compatible endpoint URL. Leave empty if this provider does not support OpenAI
397
+ format.
398
+ </p>
399
+ </div>
400
+ )}
401
+
260
402
  <div className="space-y-2">
261
403
  <label htmlFor="provider-api-docs-url" className="text-sm font-medium">
262
404
  API Docs URL
@@ -274,7 +416,7 @@ export function ProviderForm({ provider, onSubmit, onCancel }: ProviderFormProps
274
416
  </p>
275
417
  </div>
276
418
 
277
- <div className="flex gap-2 justify-end pt-2">
419
+ <div className="sticky bottom-0 bg-card border-t flex gap-2 justify-end pt-2 pb-2">
278
420
  <Button type="button" variant="outline" onClick={onCancel} disabled={isSubmitting}>
279
421
  Cancel
280
422
  </Button>
@@ -32,19 +32,22 @@ 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;
36
- format: "anthropic" | "openai";
37
- baseUrl?: string;
35
+ models: string[];
36
+ anthropicBaseUrl?: string;
37
+ openaiBaseUrl?: string;
38
+ apiDocsUrl?: string;
39
+ source?: "company" | "personal";
38
40
  };
39
41
 
40
42
  function createProviderPayload(data: ProviderFormData) {
41
43
  return {
42
44
  name: data.name,
43
45
  apiKey: data.apiKey,
44
- model: data.model,
45
- format: data.format,
46
- anthropicBaseUrl: data.format === "anthropic" ? data.baseUrl : undefined,
47
- openaiBaseUrl: data.format === "openai" ? data.baseUrl : undefined,
46
+ models: data.models,
47
+ anthropicBaseUrl: (data.anthropicBaseUrl?.length ?? 0) > 0 ? data.anthropicBaseUrl : undefined,
48
+ openaiBaseUrl: (data.openaiBaseUrl?.length ?? 0) > 0 ? data.openaiBaseUrl : undefined,
49
+ apiDocsUrl: (data.apiDocsUrl?.length ?? 0) > 0 ? data.apiDocsUrl : undefined,
50
+ source: data.source,
48
51
  };
49
52
  }
50
53
 
@@ -82,11 +85,14 @@ export function ProvidersPanel({
82
85
  const [configPath, setConfigPath] = useState<string | null>(null);
83
86
  const [configPathCopied, setConfigPathCopied] = useState(false);
84
87
  const [highlightedProviderId, setHighlightedProviderId] = useState<string | null>(null);
88
+ const [sourceFilter, setSourceFilter] = useState<"all" | "personal" | "company">("all");
85
89
  const listScrollRef = useRef<HTMLDivElement>(null);
86
90
  const highlightTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
87
91
 
88
92
  // Use external state if provided (from SWR), otherwise use internal state
89
93
  const providers = externalProviders ?? [];
94
+ const filteredProviders =
95
+ sourceFilter === "all" ? providers : providers.filter((p) => p.source === sourceFilter);
90
96
  const testResults = externalTestResults ?? internalTestResults;
91
97
  const testingProviders = externalTestingProviders ?? internalTestingProviders;
92
98
  const testingTimeLeft = externalTestingTimeLeft ?? internalTestingTimeLeft;
@@ -238,54 +244,56 @@ export function ProvidersPanel({
238
244
 
239
245
  function handleAddProvider(data: ProviderFormData): void {
240
246
  void (async () => {
241
- let res: Response;
242
247
  try {
243
- res = await fetch("/api/providers", {
248
+ const res = await fetch("/api/providers", {
244
249
  method: "POST",
245
250
  headers: { "Content-Type": "application/json" },
246
251
  body: JSON.stringify(createProviderPayload(data)),
247
252
  });
253
+ if (!res.ok) {
254
+ setError(await readApiError(res, "Failed to add provider"));
255
+ return;
256
+ }
257
+ const newProvider = await parseJsonResponse(res, ProviderConfigSchema);
258
+ setShowForm(false);
259
+ triggerHighlight(newProvider.id);
260
+ refreshProviders();
261
+ await runTest(newProvider.id);
248
262
  } catch {
249
263
  setError(NETWORK_ERROR_MESSAGE);
250
- return;
251
264
  }
252
- if (!res.ok) {
253
- setError(await readApiError(res, "Failed to add provider"));
254
- return;
255
- }
256
- const newProvider = await parseJsonResponse(res, ProviderConfigSchema);
257
- setShowForm(false);
258
- // Trigger highlight, then kick off async revalidation and test in parallel
259
- triggerHighlight(newProvider.id);
260
- refreshProviders();
261
- await runTest(newProvider.id);
262
265
  })();
263
266
  }
264
267
 
265
268
  function handleUpdateProvider(data: ProviderFormData): void {
266
269
  if (!editingProvider) return;
267
270
  void (async () => {
268
- let res: Response;
269
271
  try {
270
- res = await fetch(`/api/providers/${editingProvider.id}`, {
272
+ const res = await fetch(`/api/providers/${editingProvider.id}`, {
271
273
  method: "PUT",
272
274
  headers: { "Content-Type": "application/json" },
273
275
  body: JSON.stringify(createProviderPayload(data)),
274
276
  });
277
+ if (!res.ok) {
278
+ setError(await readApiError(res, "Failed to update provider"));
279
+ return;
280
+ }
281
+ const updated = await parseJsonResponse(res, ProviderConfigSchema);
282
+ setEditingProvider(undefined);
283
+ triggerHighlight(updated.id);
284
+ refreshProviders();
285
+ // Only run connection test when critical fields (apiKey, model, base URLs) changed
286
+ const criticalFieldsChanged =
287
+ (data.apiKey ?? "") !== (editingProvider.apiKey ?? "") ||
288
+ JSON.stringify(data.models) !== JSON.stringify(editingProvider.models) ||
289
+ (data.anthropicBaseUrl ?? "") !== (editingProvider.anthropicBaseUrl ?? "") ||
290
+ (data.openaiBaseUrl ?? "") !== (editingProvider.openaiBaseUrl ?? "");
291
+ if (criticalFieldsChanged) {
292
+ await runTest(updated.id);
293
+ }
275
294
  } catch {
276
295
  setError(NETWORK_ERROR_MESSAGE);
277
- return;
278
296
  }
279
- if (!res.ok) {
280
- setError(await readApiError(res, "Failed to update provider"));
281
- return;
282
- }
283
- const updated = await parseJsonResponse(res, ProviderConfigSchema);
284
- setEditingProvider(undefined);
285
- // Trigger highlight, then kick off async revalidation and test in parallel
286
- triggerHighlight(updated.id);
287
- refreshProviders();
288
- await runTest(updated.id);
289
297
  })();
290
298
  }
291
299
 
@@ -480,22 +488,40 @@ export function ProvidersPanel({
480
488
  </Button>
481
489
  </div>
482
490
  ) : (
483
- <div ref={listScrollRef} className="space-y-3">
484
- {providers.map((provider) => (
485
- <ProviderCard
486
- key={provider.id}
487
- provider={provider}
488
- testResults={testResults[provider.id]}
489
- isTesting={testingProviders.has(provider.id)}
490
- testingTimeLeft={testingTimeLeft[provider.id]}
491
- isHighlighted={provider.id === highlightedProviderId}
492
- onEdit={(p) => setEditingProvider(p)}
493
- onDelete={handleDeleteProvider}
494
- onTest={(id: string) => {
495
- void runTest(id);
496
- }}
497
- />
498
- ))}
491
+ <div className="space-y-3">
492
+ <div className="flex gap-1 border-b border-border">
493
+ {(["all", "personal", "company"] as const).map((tab) => (
494
+ <button
495
+ key={tab}
496
+ type="button"
497
+ onClick={() => setSourceFilter(tab)}
498
+ className={`px-3 py-2 text-sm font-medium border-b-2 transition-colors ${
499
+ sourceFilter === tab
500
+ ? "border-primary text-primary"
501
+ : "border-transparent text-muted-foreground hover:text-foreground"
502
+ }`}
503
+ >
504
+ {tab === "all" ? "All" : tab === "personal" ? "Personal" : "Company"}
505
+ </button>
506
+ ))}
507
+ </div>
508
+ <div ref={listScrollRef} className="space-y-3">
509
+ {filteredProviders.map((provider) => (
510
+ <ProviderCard
511
+ key={provider.id}
512
+ provider={provider}
513
+ testResults={testResults[provider.id]}
514
+ isTesting={testingProviders.has(provider.id)}
515
+ testingTimeLeft={testingTimeLeft[provider.id]}
516
+ isHighlighted={provider.id === highlightedProviderId}
517
+ onEdit={(p) => setEditingProvider(p)}
518
+ onDelete={handleDeleteProvider}
519
+ onTest={(id: string) => {
520
+ void runTest(id);
521
+ }}
522
+ />
523
+ ))}
524
+ </div>
499
525
  </div>
500
526
  )}
501
527
  </div>
@@ -0,0 +1,4 @@
1
+ export function maskApiKey(apiKey: string): string {
2
+ if (apiKey.length <= 8) return "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022";
3
+ return apiKey.slice(0, 4) + "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" + apiKey.slice(-4);
4
+ }
@@ -11,12 +11,14 @@ 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(),
17
18
  openaiBaseUrl: z.string().optional(),
18
19
  authHeader: z.enum(["bearer", "x-api-key"]).optional().default("bearer"),
19
20
  apiDocsUrl: z.string().optional(),
21
+ source: z.enum(["company", "personal"]).optional(),
20
22
  createdAt: z.string(),
21
23
  updatedAt: z.string(),
22
24
  });