@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.
- package/.output/nitro.json +1 -1
- package/.output/public/assets/index-DDrUlr6L.js +105 -0
- package/.output/public/assets/index-DOG5AdQ9.css +1 -0
- package/.output/public/assets/{main-C1k6vRnH.js → main-BElVT2p3.js} +1 -1
- package/.output/server/_libs/lucide-react.mjs +3 -3
- package/.output/server/_ssr/{index-AxruZp16.mjs → index-DmLit8Ad.mjs} +364 -212
- package/.output/server/_ssr/index.mjs +2 -2
- package/.output/server/_ssr/{router-DtleGqN8.mjs → router-BoeSXWHG.mjs} +555 -293
- package/.output/server/{_tanstack-start-manifest_v-B1WAHWIa.mjs → _tanstack-start-manifest_v-B6idtbmL.mjs} +1 -1
- package/.output/server/index.mjs +27 -27
- package/package.json +1 -1
- package/src/components/providers/ProviderCard.tsx +139 -11
- package/src/components/providers/ProviderForm.tsx +240 -98
- package/src/components/providers/ProvidersPanel.tsx +75 -49
- package/src/lib/mask.ts +4 -0
- package/src/lib/providerContract.ts +2 -0
- package/src/lib/providerTestContract.ts +8 -0
- package/src/proxy/providers.ts +132 -22
- package/src/routes/api/providers.$providerId.test.log.ts +293 -0
- package/src/routes/api/providers.$providerId.ts +3 -1
- package/src/routes/api/providers.ts +9 -6
- package/.output/public/assets/index-B5q3Llgm.css +0 -1
- package/.output/public/assets/index-C6tbslcs.js +0 -105
|
@@ -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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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 [
|
|
57
|
-
const [
|
|
58
|
-
const
|
|
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
|
|
64
|
-
const [
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
92
|
+
setModels((provider.models?.length ?? 0) > 0 ? provider.models : [""]);
|
|
93
|
+
setAnthropicBaseUrl(provider.anthropicBaseUrl ?? "");
|
|
94
|
+
setOpenaiBaseUrl(provider.openaiBaseUrl ?? "");
|
|
78
95
|
setApiDocsUrl(provider.apiDocsUrl ?? "");
|
|
79
|
-
|
|
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 (!
|
|
89
|
-
|
|
90
|
-
|
|
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" &&
|
|
97
|
-
|
|
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" &&
|
|
101
|
-
|
|
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 (!
|
|
117
|
-
newErrors.
|
|
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 (!
|
|
120
|
-
newErrors.
|
|
121
|
-
}
|
|
122
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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-
|
|
174
|
-
|
|
198
|
+
<label htmlFor="provider-source" className="text-sm font-medium">
|
|
199
|
+
Token Source
|
|
175
200
|
</label>
|
|
176
|
-
<
|
|
177
|
-
id="provider-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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-
|
|
189
|
-
|
|
223
|
+
<label htmlFor="provider-apikey" className="text-sm font-medium">
|
|
224
|
+
API Key <span className="text-destructive">*</span>
|
|
190
225
|
</label>
|
|
191
|
-
|
|
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-
|
|
228
|
+
id="provider-apikey"
|
|
207
229
|
type="text"
|
|
208
|
-
value={
|
|
209
|
-
onChange={(e) =>
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
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
|
|
219
|
-
|
|
260
|
+
<label className="text-sm font-medium">
|
|
261
|
+
Models <span className="text-destructive">*</span>
|
|
220
262
|
</label>
|
|
221
|
-
|
|
222
|
-
id="
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
</
|
|
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
|
-
<
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
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>
|
package/src/lib/mask.ts
ADDED
|
@@ -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
|
});
|