@tonyclaw/llm-inspector 1.14.3 → 1.14.4
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-5yrjXc3u.js +105 -0
- package/.output/public/assets/index-o0Ui96SM.css +1 -0
- package/.output/public/assets/{main-BcEfx6FM.js → main-CC0TDCAo.js} +1 -1
- package/.output/server/_libs/lucide-react.mjs +113 -105
- package/.output/server/_ssr/{index-BbJkYeb-.mjs → index-Cz6oxzsy.mjs} +237 -5
- package/.output/server/_ssr/index.mjs +2 -2
- package/.output/server/_ssr/{router-Cz7UcQ5N.mjs → router-Bl3OCdGC.mjs} +229 -33
- package/.output/server/_tanstack-start-manifest_v-Oekf1osO.mjs +4 -0
- package/.output/server/index.mjs +18 -18
- package/package.json +1 -1
- package/src/components/providers/ImportWizardDialog.tsx +281 -0
- package/src/components/providers/ProviderCard.tsx +2 -0
- package/src/components/providers/ProvidersPanel.tsx +22 -1
- package/src/proxy/providerImporters.ts +235 -0
- package/src/routes/api/providers.scan.ts +23 -0
- package/.output/public/assets/index-6F6Tf88s.js +0 -105
- package/.output/public/assets/index-CzrT_ZB_.css +0 -1
- package/.output/server/_tanstack-start-manifest_v-DxqMZv-B.mjs +0 -4
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { type JSX, useState, useCallback, useEffect } from "react";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "../ui/dialog";
|
|
4
|
+
import { Button } from "../ui/button";
|
|
5
|
+
import { Badge } from "../ui/badge";
|
|
6
|
+
import { Loader2, Download, AlertCircle } from "lucide-react";
|
|
7
|
+
|
|
8
|
+
const ExternalProviderSchema = z.object({
|
|
9
|
+
name: z.string(),
|
|
10
|
+
apiKey: z.string(),
|
|
11
|
+
format: z.enum(["anthropic", "openai"]),
|
|
12
|
+
anthropicBaseUrl: z.string(),
|
|
13
|
+
openaiBaseUrl: z.string(),
|
|
14
|
+
models: z.array(z.string()),
|
|
15
|
+
sourceTool: z.enum(["claude-code", "opencode"]),
|
|
16
|
+
alreadyExists: z.boolean(),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const ScanResponseSchema = z.object({
|
|
20
|
+
providers: z.array(ExternalProviderSchema),
|
|
21
|
+
warnings: z.array(z.string()).optional(),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const ImportResponseSchema = z.object({
|
|
25
|
+
success: z.boolean().optional(),
|
|
26
|
+
imported: z.number().optional(),
|
|
27
|
+
message: z.string().optional(),
|
|
28
|
+
errors: z.array(z.string()).optional(),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
type ImportWizardDialogProps = {
|
|
32
|
+
open: boolean;
|
|
33
|
+
onOpenChange: (open: boolean) => void;
|
|
34
|
+
onImportComplete: () => void;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export function ImportWizardDialog({
|
|
38
|
+
open,
|
|
39
|
+
onOpenChange,
|
|
40
|
+
onImportComplete,
|
|
41
|
+
}: ImportWizardDialogProps): JSX.Element {
|
|
42
|
+
const [scanning, setScanning] = useState(false);
|
|
43
|
+
const [scanError, setScanError] = useState<string | null>(null);
|
|
44
|
+
const [providers, setProviders] = useState<z.infer<typeof ExternalProviderSchema>[]>([]);
|
|
45
|
+
const [warnings, setWarnings] = useState<string[]>([]);
|
|
46
|
+
const [selected, setSelected] = useState<Set<number>>(new Set());
|
|
47
|
+
const [importing, setImporting] = useState(false);
|
|
48
|
+
const [importResult, setImportResult] = useState<string | null>(null);
|
|
49
|
+
const [importError, setImportError] = useState<string | null>(null);
|
|
50
|
+
|
|
51
|
+
const scan = useCallback(() => {
|
|
52
|
+
setScanning(true);
|
|
53
|
+
setScanError(null);
|
|
54
|
+
setWarnings([]);
|
|
55
|
+
setProviders([]);
|
|
56
|
+
setSelected(new Set());
|
|
57
|
+
setImportResult(null);
|
|
58
|
+
setImportError(null);
|
|
59
|
+
|
|
60
|
+
fetch("/api/providers/scan")
|
|
61
|
+
.then((res) => {
|
|
62
|
+
if (!res.ok) {
|
|
63
|
+
return res.text().then((text) => {
|
|
64
|
+
setScanError(`Scan failed (${res.status}): ${text}`);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
return res.json().then((data: unknown) => {
|
|
68
|
+
const parsed = ScanResponseSchema.safeParse(data);
|
|
69
|
+
if (!parsed.success) {
|
|
70
|
+
setScanError(`Invalid response: ${parsed.error.message}`);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
setProviders(parsed.data.providers);
|
|
74
|
+
setWarnings(parsed.data.warnings ?? []);
|
|
75
|
+
// Pre-select non-existing providers
|
|
76
|
+
const indices = new Set<number>();
|
|
77
|
+
parsed.data.providers.forEach((p, i) => {
|
|
78
|
+
if (!p.alreadyExists) indices.add(i);
|
|
79
|
+
});
|
|
80
|
+
setSelected(indices);
|
|
81
|
+
});
|
|
82
|
+
})
|
|
83
|
+
.catch((err: unknown) => {
|
|
84
|
+
setScanError(err instanceof Error ? err.message : String(err));
|
|
85
|
+
})
|
|
86
|
+
.finally(() => {
|
|
87
|
+
setScanning(false);
|
|
88
|
+
});
|
|
89
|
+
}, []);
|
|
90
|
+
|
|
91
|
+
// Auto-scan when dialog opens
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
if (open) {
|
|
94
|
+
scan();
|
|
95
|
+
}
|
|
96
|
+
}, [open, scan]);
|
|
97
|
+
|
|
98
|
+
const toggleProvider = useCallback((index: number) => {
|
|
99
|
+
setSelected((prev) => {
|
|
100
|
+
const next = new Set(prev);
|
|
101
|
+
if (next.has(index)) {
|
|
102
|
+
next.delete(index);
|
|
103
|
+
} else {
|
|
104
|
+
next.add(index);
|
|
105
|
+
}
|
|
106
|
+
return next;
|
|
107
|
+
});
|
|
108
|
+
}, []);
|
|
109
|
+
|
|
110
|
+
const importSelected = useCallback(() => {
|
|
111
|
+
const toImport = providers.filter((_, i) => selected.has(i));
|
|
112
|
+
if (toImport.length === 0) return;
|
|
113
|
+
|
|
114
|
+
setImporting(true);
|
|
115
|
+
setImportError(null);
|
|
116
|
+
setImportResult(null);
|
|
117
|
+
|
|
118
|
+
// Convert ExternalProvider[] to ProviderConfig shape for import
|
|
119
|
+
const now = new Date().toISOString();
|
|
120
|
+
const providersPayload = toImport.map((p) => ({
|
|
121
|
+
id: window.crypto.randomUUID(),
|
|
122
|
+
name: p.name,
|
|
123
|
+
apiKey: p.apiKey,
|
|
124
|
+
format: p.format,
|
|
125
|
+
anthropicBaseUrl: p.anthropicBaseUrl,
|
|
126
|
+
openaiBaseUrl: p.openaiBaseUrl,
|
|
127
|
+
models: p.models,
|
|
128
|
+
createdAt: now,
|
|
129
|
+
updatedAt: now,
|
|
130
|
+
}));
|
|
131
|
+
|
|
132
|
+
fetch("/api/providers/import", {
|
|
133
|
+
method: "POST",
|
|
134
|
+
headers: { "Content-Type": "application/json" },
|
|
135
|
+
body: JSON.stringify({ providers: providersPayload }),
|
|
136
|
+
})
|
|
137
|
+
.then((res) => res.json())
|
|
138
|
+
.then((data: unknown) => {
|
|
139
|
+
const parsed = ImportResponseSchema.safeParse(data);
|
|
140
|
+
if (!parsed.success) {
|
|
141
|
+
setImportError(`Invalid response: ${parsed.error.message}`);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const result = parsed.data;
|
|
145
|
+
if (
|
|
146
|
+
result.errors !== undefined &&
|
|
147
|
+
result.errors.length > 0 &&
|
|
148
|
+
result.message !== undefined
|
|
149
|
+
) {
|
|
150
|
+
setImportResult(result.message);
|
|
151
|
+
} else {
|
|
152
|
+
setImportResult(`Imported ${result.imported ?? toImport.length} provider(s)`);
|
|
153
|
+
}
|
|
154
|
+
onImportComplete();
|
|
155
|
+
})
|
|
156
|
+
.catch((err: unknown) => {
|
|
157
|
+
setImportError(err instanceof Error ? err.message : String(err));
|
|
158
|
+
})
|
|
159
|
+
.finally(() => {
|
|
160
|
+
setImporting(false);
|
|
161
|
+
});
|
|
162
|
+
}, [providers, selected, onImportComplete]);
|
|
163
|
+
|
|
164
|
+
const hasSelectable = providers.some((p) => !p.alreadyExists);
|
|
165
|
+
|
|
166
|
+
return (
|
|
167
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
168
|
+
<DialogContent className="max-w-xl max-h-[80vh] overflow-hidden flex flex-col">
|
|
169
|
+
<DialogHeader>
|
|
170
|
+
<DialogTitle>Import from External Tools</DialogTitle>
|
|
171
|
+
<p className="text-xs text-muted-foreground">
|
|
172
|
+
Detect provider configurations from Claude Code and OpenCode.
|
|
173
|
+
</p>
|
|
174
|
+
</DialogHeader>
|
|
175
|
+
|
|
176
|
+
<div className="flex-1 overflow-y-auto space-y-3">
|
|
177
|
+
{scanning && (
|
|
178
|
+
<div className="flex items-center justify-center py-8 gap-2 text-muted-foreground">
|
|
179
|
+
<Loader2 className="size-4 animate-spin" />
|
|
180
|
+
<span className="text-sm">Scanning for external providers...</span>
|
|
181
|
+
</div>
|
|
182
|
+
)}
|
|
183
|
+
|
|
184
|
+
{scanError !== null && (
|
|
185
|
+
<div className="flex items-center gap-2 text-destructive text-sm py-4">
|
|
186
|
+
<AlertCircle className="size-4" />
|
|
187
|
+
{scanError}
|
|
188
|
+
</div>
|
|
189
|
+
)}
|
|
190
|
+
|
|
191
|
+
{!scanning && scanError === null && providers.length === 0 && (
|
|
192
|
+
<p className="text-sm text-muted-foreground py-8 text-center">
|
|
193
|
+
No external provider configurations found.
|
|
194
|
+
<br />
|
|
195
|
+
<span className="text-xs">
|
|
196
|
+
Supported tools: Claude Code (~/.claude/settings.json), OpenCode
|
|
197
|
+
(~/.config/opencode/opencode.json)
|
|
198
|
+
</span>
|
|
199
|
+
</p>
|
|
200
|
+
)}
|
|
201
|
+
|
|
202
|
+
{!scanning &&
|
|
203
|
+
providers.map((p, i) => (
|
|
204
|
+
<label
|
|
205
|
+
key={`${p.sourceTool}-${p.name}`}
|
|
206
|
+
className="flex items-start gap-3 p-3 border rounded-md cursor-pointer hover:bg-muted/50 has-[:disabled]:opacity-50 has-[:disabled]:cursor-not-allowed"
|
|
207
|
+
>
|
|
208
|
+
<input
|
|
209
|
+
type="checkbox"
|
|
210
|
+
checked={selected.has(i)}
|
|
211
|
+
disabled={p.alreadyExists || importing}
|
|
212
|
+
onChange={() => toggleProvider(i)}
|
|
213
|
+
className="mt-0.5 size-4"
|
|
214
|
+
/>
|
|
215
|
+
<div className="flex-1 min-w-0">
|
|
216
|
+
<div className="flex items-center gap-2">
|
|
217
|
+
<span className="text-sm font-medium truncate">{p.name}</span>
|
|
218
|
+
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
|
|
219
|
+
{p.format}
|
|
220
|
+
</Badge>
|
|
221
|
+
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
|
|
222
|
+
{p.sourceTool}
|
|
223
|
+
</Badge>
|
|
224
|
+
{p.alreadyExists && (
|
|
225
|
+
<span className="text-[10px] text-muted-foreground">Already added</span>
|
|
226
|
+
)}
|
|
227
|
+
</div>
|
|
228
|
+
<div className="text-xs text-muted-foreground mt-1 truncate">
|
|
229
|
+
{p.models.slice(0, 4).join(", ")}
|
|
230
|
+
{p.models.length > 4 ? ` +${p.models.length - 4} more` : ""}
|
|
231
|
+
</div>
|
|
232
|
+
<div className="text-xs text-muted-foreground mt-0.5 truncate">
|
|
233
|
+
{p.format === "anthropic" ? p.anthropicBaseUrl : p.openaiBaseUrl}
|
|
234
|
+
</div>
|
|
235
|
+
<div className="text-xs text-muted-foreground mt-0.5 font-mono">
|
|
236
|
+
{p.apiKey.length > 8
|
|
237
|
+
? `${p.apiKey.slice(0, 4)}••••${p.apiKey.slice(-4)}`
|
|
238
|
+
: "••••"}
|
|
239
|
+
</div>
|
|
240
|
+
</div>
|
|
241
|
+
</label>
|
|
242
|
+
))}
|
|
243
|
+
|
|
244
|
+
{warnings.length > 0 && (
|
|
245
|
+
<div className="text-xs text-muted-foreground space-y-1 border-t pt-2">
|
|
246
|
+
{warnings.map((w, i) => (
|
|
247
|
+
<div key={i} className="flex items-center gap-1">
|
|
248
|
+
<AlertCircle className="size-3" />
|
|
249
|
+
{w}
|
|
250
|
+
</div>
|
|
251
|
+
))}
|
|
252
|
+
</div>
|
|
253
|
+
)}
|
|
254
|
+
|
|
255
|
+
{importResult !== null && (
|
|
256
|
+
<div className="text-sm text-green-500 border-t pt-2">{importResult}</div>
|
|
257
|
+
)}
|
|
258
|
+
|
|
259
|
+
{importError !== null && (
|
|
260
|
+
<div className="text-sm text-destructive border-t pt-2">{importError}</div>
|
|
261
|
+
)}
|
|
262
|
+
</div>
|
|
263
|
+
|
|
264
|
+
<div className="flex items-center justify-between pt-3 border-t">
|
|
265
|
+
<Button variant="outline" size="sm" onClick={scan} disabled={scanning}>
|
|
266
|
+
<Loader2 className={`size-3 mr-1 ${scanning ? "animate-spin" : ""}`} />
|
|
267
|
+
Rescan
|
|
268
|
+
</Button>
|
|
269
|
+
<Button
|
|
270
|
+
size="sm"
|
|
271
|
+
onClick={importSelected}
|
|
272
|
+
disabled={!hasSelectable || selected.size === 0 || importing}
|
|
273
|
+
>
|
|
274
|
+
<Download className="size-3 mr-1" />
|
|
275
|
+
{importing ? "Importing..." : `Import Selected (${selected.size})`}
|
|
276
|
+
</Button>
|
|
277
|
+
</div>
|
|
278
|
+
</DialogContent>
|
|
279
|
+
</Dialog>
|
|
280
|
+
);
|
|
281
|
+
}
|
|
@@ -400,6 +400,7 @@ export function ProviderCard({
|
|
|
400
400
|
size="sm"
|
|
401
401
|
onClick={() => onEdit(provider)}
|
|
402
402
|
className="text-xs h-7 gap-1"
|
|
403
|
+
disabled={isTesting ?? false}
|
|
403
404
|
>
|
|
404
405
|
<Pencil className="size-3" />
|
|
405
406
|
Edit
|
|
@@ -409,6 +410,7 @@ export function ProviderCard({
|
|
|
409
410
|
size="sm"
|
|
410
411
|
onClick={() => onDelete(provider.id)}
|
|
411
412
|
className="text-xs h-7 gap-1 text-destructive hover:text-destructive"
|
|
413
|
+
disabled={isTesting ?? false}
|
|
412
414
|
>
|
|
413
415
|
<Trash2 className="size-3" />
|
|
414
416
|
Delete
|
|
@@ -1,7 +1,8 @@
|
|
|
1
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
|
-
import { Plus, AlertCircle, Copy, Check, Download, Upload } from "lucide-react";
|
|
4
|
+
import { Plus, AlertCircle, Copy, Check, Download, Upload, Scan } from "lucide-react";
|
|
5
|
+
import { ImportWizardDialog } from "./ImportWizardDialog";
|
|
5
6
|
import { ProviderCard } from "./ProviderCard";
|
|
6
7
|
import { ProviderForm } from "./ProviderForm";
|
|
7
8
|
import { parseJsonResponse, readApiError } from "../../lib/apiClient";
|
|
@@ -85,6 +86,7 @@ export function ProvidersPanel({
|
|
|
85
86
|
const [configPath, setConfigPath] = useState<string | null>(null);
|
|
86
87
|
const [configPathCopied, setConfigPathCopied] = useState(false);
|
|
87
88
|
const [highlightedProviderId, setHighlightedProviderId] = useState<string | null>(null);
|
|
89
|
+
const [showImportWizard, setShowImportWizard] = useState(false);
|
|
88
90
|
const [sourceFilter, setSourceFilter] = useState<"all" | "personal" | "company">("all");
|
|
89
91
|
const listScrollRef = useRef<HTMLDivElement>(null);
|
|
90
92
|
const highlightTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
@@ -448,6 +450,15 @@ export function ProvidersPanel({
|
|
|
448
450
|
onChange={handleFileChange}
|
|
449
451
|
style={{ display: "none" }}
|
|
450
452
|
/>
|
|
453
|
+
<Button
|
|
454
|
+
variant="outline"
|
|
455
|
+
size="sm"
|
|
456
|
+
onClick={() => setShowImportWizard(true)}
|
|
457
|
+
className="gap-1"
|
|
458
|
+
>
|
|
459
|
+
<Scan className="size-3" />
|
|
460
|
+
Scan
|
|
461
|
+
</Button>
|
|
451
462
|
<Button onClick={() => setShowForm(true)} size="sm" className="gap-1">
|
|
452
463
|
<Plus className="size-4" />
|
|
453
464
|
Add Provider
|
|
@@ -533,6 +544,16 @@ export function ProvidersPanel({
|
|
|
533
544
|
</div>
|
|
534
545
|
</div>
|
|
535
546
|
)}
|
|
547
|
+
|
|
548
|
+
<ImportWizardDialog
|
|
549
|
+
open={showImportWizard}
|
|
550
|
+
onOpenChange={setShowImportWizard}
|
|
551
|
+
onImportComplete={() => {
|
|
552
|
+
if (onProvidersMutate !== undefined) {
|
|
553
|
+
void onProvidersMutate();
|
|
554
|
+
}
|
|
555
|
+
}}
|
|
556
|
+
/>
|
|
536
557
|
</div>
|
|
537
558
|
);
|
|
538
559
|
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { getProviders } from "./providers";
|
|
5
|
+
import { normalizeApiKey } from "./providers";
|
|
6
|
+
import type { ProviderConfig } from "../lib/providerContract";
|
|
7
|
+
|
|
8
|
+
export type ExternalProvider = {
|
|
9
|
+
name: string;
|
|
10
|
+
apiKey: string;
|
|
11
|
+
format: "anthropic" | "openai";
|
|
12
|
+
anthropicBaseUrl: string;
|
|
13
|
+
openaiBaseUrl: string;
|
|
14
|
+
models: string[];
|
|
15
|
+
sourceTool: "claude-code" | "opencode";
|
|
16
|
+
alreadyExists: boolean;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function isRecord(val: unknown): val is Record<string, unknown> {
|
|
20
|
+
return val !== null && typeof val === "object" && !Array.isArray(val);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function readJsonSafe(filePath: string): Record<string, unknown> | null {
|
|
24
|
+
try {
|
|
25
|
+
if (!existsSync(filePath)) return null;
|
|
26
|
+
const raw = readFileSync(filePath, "utf-8");
|
|
27
|
+
const parsed: unknown = JSON.parse(raw);
|
|
28
|
+
return isRecord(parsed) ? parsed : null;
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function getEnvValue(envObj: unknown, key: string): string | undefined {
|
|
35
|
+
if (!isRecord(envObj)) return undefined;
|
|
36
|
+
const val = envObj[key];
|
|
37
|
+
return typeof val === "string" ? val.trim() : undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function deriveNameFromUrl(url: string): string {
|
|
41
|
+
try {
|
|
42
|
+
const hostname = new URL(url).hostname;
|
|
43
|
+
const parts = hostname.split(".");
|
|
44
|
+
const domain = parts.length >= 2 ? (parts[parts.length - 2] ?? hostname) : hostname;
|
|
45
|
+
return domain.charAt(0).toUpperCase() + domain.slice(1);
|
|
46
|
+
} catch {
|
|
47
|
+
return "Imported Provider";
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ── Claude Code ──────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
function detectClaudeCodeProviders(): ExternalProvider[] {
|
|
54
|
+
const home = homedir();
|
|
55
|
+
const results: ExternalProvider[] = [];
|
|
56
|
+
|
|
57
|
+
// Read global + local settings, local takes precedence
|
|
58
|
+
const globalConfig = readJsonSafe(join(home, ".claude", "settings.json"));
|
|
59
|
+
const localConfig = readJsonSafe(join(home, ".claude", "settings.local.json"));
|
|
60
|
+
|
|
61
|
+
const mergedEnv: Record<string, unknown> = {};
|
|
62
|
+
if (isRecord(globalConfig)) {
|
|
63
|
+
const env = globalConfig["env"];
|
|
64
|
+
if (isRecord(env)) {
|
|
65
|
+
Object.assign(mergedEnv, env);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (isRecord(localConfig)) {
|
|
69
|
+
const env = localConfig["env"];
|
|
70
|
+
if (isRecord(env)) {
|
|
71
|
+
Object.assign(mergedEnv, env);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const baseUrl = getEnvValue(mergedEnv, "ANTHROPIC_BASE_URL");
|
|
76
|
+
const authToken =
|
|
77
|
+
getEnvValue(mergedEnv, "ANTHROPIC_AUTH_TOKEN") ?? getEnvValue(mergedEnv, "ANTHROPIC_API_KEY");
|
|
78
|
+
|
|
79
|
+
if (baseUrl === undefined || baseUrl === "" || authToken === undefined || authToken === "") {
|
|
80
|
+
return results;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Collect unique models
|
|
84
|
+
const modelSet = new Set<string>();
|
|
85
|
+
const modelKeys = [
|
|
86
|
+
"ANTHROPIC_MODEL",
|
|
87
|
+
"ANTHROPIC_DEFAULT_OPUS_MODEL",
|
|
88
|
+
"ANTHROPIC_DEFAULT_SONNET_MODEL",
|
|
89
|
+
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
|
|
90
|
+
];
|
|
91
|
+
for (const key of modelKeys) {
|
|
92
|
+
const val = getEnvValue(mergedEnv, key);
|
|
93
|
+
if (val !== undefined && val !== "") modelSet.add(val);
|
|
94
|
+
}
|
|
95
|
+
const models = modelSet.size > 0 ? [...modelSet] : ["default"];
|
|
96
|
+
|
|
97
|
+
results.push({
|
|
98
|
+
name: deriveNameFromUrl(baseUrl),
|
|
99
|
+
apiKey: normalizeApiKey(authToken),
|
|
100
|
+
format: "anthropic",
|
|
101
|
+
anthropicBaseUrl: baseUrl,
|
|
102
|
+
openaiBaseUrl: "",
|
|
103
|
+
models,
|
|
104
|
+
sourceTool: "claude-code",
|
|
105
|
+
alreadyExists: false, // filled in later
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
return results;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ── OpenCode ─────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
function detectOpenCodeProviders(): ExternalProvider[] {
|
|
114
|
+
const home = homedir();
|
|
115
|
+
const results: ExternalProvider[] = [];
|
|
116
|
+
|
|
117
|
+
const config = readJsonSafe(join(home, ".config", "opencode", "opencode.json"));
|
|
118
|
+
if (config === null) return results;
|
|
119
|
+
|
|
120
|
+
// Read auth.json (separate credentials file)
|
|
121
|
+
const auth = readJsonSafe(join(home, ".local", "share", "opencode", "auth.json"));
|
|
122
|
+
|
|
123
|
+
const providersVal = config["provider"];
|
|
124
|
+
if (!isRecord(providersVal)) {
|
|
125
|
+
return results;
|
|
126
|
+
}
|
|
127
|
+
const providers: Record<string, unknown> = providersVal;
|
|
128
|
+
|
|
129
|
+
for (const [providerId, providerObj] of Object.entries(providers)) {
|
|
130
|
+
if (!isRecord(providerObj)) continue;
|
|
131
|
+
|
|
132
|
+
const options = providerObj["options"];
|
|
133
|
+
const npm: unknown = providerObj["npm"];
|
|
134
|
+
const npmStr = typeof npm === "string" ? npm : "";
|
|
135
|
+
const format: "anthropic" | "openai" = npmStr === "@ai-sdk/anthropic" ? "anthropic" : "openai";
|
|
136
|
+
|
|
137
|
+
let baseURL = "";
|
|
138
|
+
let apiKey = "";
|
|
139
|
+
if (isRecord(options)) {
|
|
140
|
+
baseURL = typeof options["baseURL"] === "string" ? options["baseURL"].trim() : "";
|
|
141
|
+
apiKey = typeof options["apiKey"] === "string" ? options["apiKey"].trim() : "";
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Resolve apiKey: prefer inline, fallback to auth.json
|
|
145
|
+
if (apiKey === "" && isRecord(auth)) {
|
|
146
|
+
const cred = auth[providerId];
|
|
147
|
+
if (isRecord(cred)) {
|
|
148
|
+
const key: unknown = cred["key"];
|
|
149
|
+
if (typeof key === "string" && key !== "") apiKey = key;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (baseURL === "" || apiKey === "") continue;
|
|
154
|
+
|
|
155
|
+
// Collect models
|
|
156
|
+
const modelSet = new Set<string>();
|
|
157
|
+
const modelsObj = providerObj["models"];
|
|
158
|
+
|
|
159
|
+
if (isRecord(modelsObj)) {
|
|
160
|
+
for (const [modelId, modelDef] of Object.entries(modelsObj)) {
|
|
161
|
+
if (typeof modelDef === "string") {
|
|
162
|
+
modelSet.add(modelDef);
|
|
163
|
+
} else if (isRecord(modelDef)) {
|
|
164
|
+
const name: unknown = modelDef["name"];
|
|
165
|
+
modelSet.add(typeof name === "string" && name !== "" ? name : modelId);
|
|
166
|
+
} else {
|
|
167
|
+
modelSet.add(modelId);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
const models = modelSet.size > 0 ? [...modelSet] : ["default"];
|
|
172
|
+
|
|
173
|
+
results.push({
|
|
174
|
+
name: providerId,
|
|
175
|
+
apiKey: normalizeApiKey(apiKey),
|
|
176
|
+
format,
|
|
177
|
+
anthropicBaseUrl: format === "anthropic" ? baseURL : "",
|
|
178
|
+
openaiBaseUrl: format === "openai" ? baseURL : "",
|
|
179
|
+
models,
|
|
180
|
+
sourceTool: "opencode",
|
|
181
|
+
alreadyExists: false,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return results;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ── Combined scanner ─────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
export function scanExternalProviders(): { providers: ExternalProvider[]; warnings: string[] } {
|
|
191
|
+
const warnings: string[] = [];
|
|
192
|
+
|
|
193
|
+
let claudeProviders: ExternalProvider[] = [];
|
|
194
|
+
try {
|
|
195
|
+
claudeProviders = detectClaudeCodeProviders();
|
|
196
|
+
} catch (err) {
|
|
197
|
+
warnings.push(`Claude Code: ${err instanceof Error ? err.message : String(err)}`);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
let opencodeProviders: ExternalProvider[] = [];
|
|
201
|
+
try {
|
|
202
|
+
opencodeProviders = detectOpenCodeProviders();
|
|
203
|
+
} catch (err) {
|
|
204
|
+
warnings.push(`OpenCode: ${err instanceof Error ? err.message : String(err)}`);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const allProviders = [...claudeProviders, ...opencodeProviders];
|
|
208
|
+
|
|
209
|
+
// Filter out providers already using the localhost proxy (already switched)
|
|
210
|
+
const filteredProviders = allProviders.filter((p) => {
|
|
211
|
+
const url = p.anthropicBaseUrl || p.openaiBaseUrl;
|
|
212
|
+
return !/(?:localhost|127\.0\.0\.1)/.test(url);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// Mark already-existing providers (same apiKey + format-specific baseUrl)
|
|
216
|
+
let existing: ProviderConfig[] = [];
|
|
217
|
+
try {
|
|
218
|
+
existing = getProviders();
|
|
219
|
+
} catch {
|
|
220
|
+
// If we can't load existing providers, just skip the duplicate check
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
for (const ext of filteredProviders) {
|
|
224
|
+
ext.alreadyExists = existing.some((p) => {
|
|
225
|
+
if (p.apiKey !== ext.apiKey) return false;
|
|
226
|
+
// Match on the format-specific base URL (external providers only set one)
|
|
227
|
+
if (ext.format === "anthropic") {
|
|
228
|
+
return (p.anthropicBaseUrl ?? "") === ext.anthropicBaseUrl;
|
|
229
|
+
}
|
|
230
|
+
return (p.openaiBaseUrl ?? "") === ext.openaiBaseUrl;
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return { providers: filteredProviders, warnings };
|
|
235
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { createFileRoute } from "@tanstack/react-router";
|
|
2
|
+
import { scanExternalProviders } from "../../proxy/providerImporters";
|
|
3
|
+
|
|
4
|
+
export const Route = createFileRoute("/api/providers/scan")({
|
|
5
|
+
server: {
|
|
6
|
+
handlers: {
|
|
7
|
+
GET: () => {
|
|
8
|
+
try {
|
|
9
|
+
const result = scanExternalProviders();
|
|
10
|
+
return Response.json({
|
|
11
|
+
providers: result.providers,
|
|
12
|
+
warnings: result.warnings,
|
|
13
|
+
});
|
|
14
|
+
} catch (err) {
|
|
15
|
+
return Response.json(
|
|
16
|
+
{ error: `Scan failed: ${err instanceof Error ? err.message : String(err)}` },
|
|
17
|
+
{ status: 500 },
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
});
|