@tonyclaw/llm-inspector 1.14.2 → 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.
@@ -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
+ });
@@ -1,121 +1,121 @@
1
- @import "tailwindcss";
2
- @import "tw-animate-css";
3
- @plugin "@tailwindcss/typography";
4
-
5
- @custom-variant dark (&:is(.dark *));
6
-
7
- @theme inline {
8
- --color-background: var(--background);
9
- --color-foreground: var(--foreground);
10
- --color-sidebar-ring: var(--sidebar-ring);
11
- --color-sidebar-border: var(--sidebar-border);
12
- --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
13
- --color-sidebar-accent: var(--sidebar-accent);
14
- --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
15
- --color-sidebar-primary: var(--sidebar-primary);
16
- --color-sidebar-foreground: var(--sidebar-foreground);
17
- --color-sidebar: var(--sidebar);
18
- --color-chart-5: var(--chart-5);
19
- --color-chart-4: var(--chart-4);
20
- --color-chart-3: var(--chart-3);
21
- --color-chart-2: var(--chart-2);
22
- --color-chart-1: var(--chart-1);
23
- --color-ring: var(--ring);
24
- --color-input: var(--input);
25
- --color-border: var(--border);
26
- --color-destructive: var(--destructive);
27
- --color-accent-foreground: var(--accent-foreground);
28
- --color-accent: var(--accent);
29
- --color-muted-foreground: var(--muted-foreground);
30
- --color-muted: var(--muted);
31
- --color-secondary-foreground: var(--secondary-foreground);
32
- --color-secondary: var(--secondary);
33
- --color-primary-foreground: var(--primary-foreground);
34
- --color-primary: var(--primary);
35
- --color-popover-foreground: var(--popover-foreground);
36
- --color-popover: var(--popover);
37
- --color-card-foreground: var(--card-foreground);
38
- --color-card: var(--card);
39
- --radius-sm: calc(var(--radius) - 4px);
40
- --radius-md: calc(var(--radius) - 2px);
41
- --radius-lg: var(--radius);
42
- --radius-xl: calc(var(--radius) + 4px);
43
- }
44
-
45
- :root {
46
- --radius: 0.625rem;
47
- --background: oklch(1 0 0);
48
- --foreground: oklch(0.145 0 0);
49
- --card: oklch(1 0 0);
50
- --card-foreground: oklch(0.145 0 0);
51
- --popover: oklch(1 0 0);
52
- --popover-foreground: oklch(0.145 0 0);
53
- --primary: oklch(0.205 0 0);
54
- --primary-foreground: oklch(0.985 0 0);
55
- --secondary: oklch(0.97 0 0);
56
- --secondary-foreground: oklch(0.205 0 0);
57
- --muted: oklch(0.97 0 0);
58
- --muted-foreground: oklch(0.556 0 0);
59
- --accent: oklch(0.97 0 0);
60
- --accent-foreground: oklch(0.205 0 0);
61
- --destructive: oklch(0.577 0.245 27.325);
62
- --border: oklch(0.922 0 0);
63
- --input: oklch(0.922 0 0);
64
- --ring: oklch(0.708 0 0);
65
- --chart-1: oklch(0.646 0.222 41.116);
66
- --chart-2: oklch(0.6 0.118 184.704);
67
- --chart-3: oklch(0.398 0.07 227.392);
68
- --chart-4: oklch(0.828 0.189 84.429);
69
- --chart-5: oklch(0.769 0.188 70.08);
70
- --sidebar: oklch(0.985 0 0);
71
- --sidebar-foreground: oklch(0.145 0 0);
72
- --sidebar-primary: oklch(0.205 0 0);
73
- --sidebar-primary-foreground: oklch(0.985 0 0);
74
- --sidebar-accent: oklch(0.97 0 0);
75
- --sidebar-accent-foreground: oklch(0.205 0 0);
76
- --sidebar-border: oklch(0.922 0 0);
77
- --sidebar-ring: oklch(0.708 0 0);
78
- }
79
-
80
- .dark {
81
- --background: oklch(0.145 0 0);
82
- --foreground: oklch(0.985 0 0);
83
- --card: oklch(0.205 0 0);
84
- --card-foreground: oklch(0.985 0 0);
85
- --popover: oklch(0.205 0 0);
86
- --popover-foreground: oklch(0.985 0 0);
87
- --primary: oklch(0.922 0 0);
88
- --primary-foreground: oklch(0.205 0 0);
89
- --secondary: oklch(0.269 0 0);
90
- --secondary-foreground: oklch(0.985 0 0);
91
- --muted: oklch(0.269 0 0);
92
- --muted-foreground: oklch(0.708 0 0);
93
- --accent: oklch(0.269 0 0);
94
- --accent-foreground: oklch(0.985 0 0);
95
- --destructive: oklch(0.704 0.191 22.216);
96
- --border: oklch(1 0 0 / 10%);
97
- --input: oklch(1 0 0 / 15%);
98
- --ring: oklch(0.556 0 0);
99
- --chart-1: oklch(0.488 0.243 264.376);
100
- --chart-2: oklch(0.696 0.17 162.48);
101
- --chart-3: oklch(0.769 0.188 70.08);
102
- --chart-4: oklch(0.627 0.265 303.9);
103
- --chart-5: oklch(0.645 0.246 16.439);
104
- --sidebar: oklch(0.205 0 0);
105
- --sidebar-foreground: oklch(0.985 0 0);
106
- --sidebar-primary: oklch(0.488 0.243 264.376);
107
- --sidebar-primary-foreground: oklch(0.985 0 0);
108
- --sidebar-accent: oklch(0.269 0 0);
109
- --sidebar-accent-foreground: oklch(0.985 0 0);
110
- --sidebar-border: oklch(1 0 0 / 10%);
111
- --sidebar-ring: oklch(0.556 0 0);
112
- }
113
-
114
- @layer base {
115
- * {
116
- @apply border-border outline-ring/50;
117
- }
118
- body {
119
- @apply bg-background text-foreground;
120
- }
121
- }
1
+ @import "tailwindcss";
2
+ @import "tw-animate-css";
3
+ @plugin "@tailwindcss/typography";
4
+
5
+ @custom-variant dark (&:is(.dark *));
6
+
7
+ @theme inline {
8
+ --color-background: var(--background);
9
+ --color-foreground: var(--foreground);
10
+ --color-sidebar-ring: var(--sidebar-ring);
11
+ --color-sidebar-border: var(--sidebar-border);
12
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
13
+ --color-sidebar-accent: var(--sidebar-accent);
14
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
15
+ --color-sidebar-primary: var(--sidebar-primary);
16
+ --color-sidebar-foreground: var(--sidebar-foreground);
17
+ --color-sidebar: var(--sidebar);
18
+ --color-chart-5: var(--chart-5);
19
+ --color-chart-4: var(--chart-4);
20
+ --color-chart-3: var(--chart-3);
21
+ --color-chart-2: var(--chart-2);
22
+ --color-chart-1: var(--chart-1);
23
+ --color-ring: var(--ring);
24
+ --color-input: var(--input);
25
+ --color-border: var(--border);
26
+ --color-destructive: var(--destructive);
27
+ --color-accent-foreground: var(--accent-foreground);
28
+ --color-accent: var(--accent);
29
+ --color-muted-foreground: var(--muted-foreground);
30
+ --color-muted: var(--muted);
31
+ --color-secondary-foreground: var(--secondary-foreground);
32
+ --color-secondary: var(--secondary);
33
+ --color-primary-foreground: var(--primary-foreground);
34
+ --color-primary: var(--primary);
35
+ --color-popover-foreground: var(--popover-foreground);
36
+ --color-popover: var(--popover);
37
+ --color-card-foreground: var(--card-foreground);
38
+ --color-card: var(--card);
39
+ --radius-sm: calc(var(--radius) - 4px);
40
+ --radius-md: calc(var(--radius) - 2px);
41
+ --radius-lg: var(--radius);
42
+ --radius-xl: calc(var(--radius) + 4px);
43
+ }
44
+
45
+ :root {
46
+ --radius: 0.625rem;
47
+ --background: oklch(1 0 0);
48
+ --foreground: oklch(0.145 0 0);
49
+ --card: oklch(1 0 0);
50
+ --card-foreground: oklch(0.145 0 0);
51
+ --popover: oklch(1 0 0);
52
+ --popover-foreground: oklch(0.145 0 0);
53
+ --primary: oklch(0.205 0 0);
54
+ --primary-foreground: oklch(0.985 0 0);
55
+ --secondary: oklch(0.97 0 0);
56
+ --secondary-foreground: oklch(0.205 0 0);
57
+ --muted: oklch(0.97 0 0);
58
+ --muted-foreground: oklch(0.556 0 0);
59
+ --accent: oklch(0.97 0 0);
60
+ --accent-foreground: oklch(0.205 0 0);
61
+ --destructive: oklch(0.577 0.245 27.325);
62
+ --border: oklch(0.922 0 0);
63
+ --input: oklch(0.922 0 0);
64
+ --ring: oklch(0.708 0 0);
65
+ --chart-1: oklch(0.646 0.222 41.116);
66
+ --chart-2: oklch(0.6 0.118 184.704);
67
+ --chart-3: oklch(0.398 0.07 227.392);
68
+ --chart-4: oklch(0.828 0.189 84.429);
69
+ --chart-5: oklch(0.769 0.188 70.08);
70
+ --sidebar: oklch(0.985 0 0);
71
+ --sidebar-foreground: oklch(0.145 0 0);
72
+ --sidebar-primary: oklch(0.205 0 0);
73
+ --sidebar-primary-foreground: oklch(0.985 0 0);
74
+ --sidebar-accent: oklch(0.97 0 0);
75
+ --sidebar-accent-foreground: oklch(0.205 0 0);
76
+ --sidebar-border: oklch(0.922 0 0);
77
+ --sidebar-ring: oklch(0.708 0 0);
78
+ }
79
+
80
+ .dark {
81
+ --background: oklch(0.145 0 0);
82
+ --foreground: oklch(0.985 0 0);
83
+ --card: oklch(0.205 0 0);
84
+ --card-foreground: oklch(0.985 0 0);
85
+ --popover: oklch(0.205 0 0);
86
+ --popover-foreground: oklch(0.985 0 0);
87
+ --primary: oklch(0.922 0 0);
88
+ --primary-foreground: oklch(0.205 0 0);
89
+ --secondary: oklch(0.269 0 0);
90
+ --secondary-foreground: oklch(0.985 0 0);
91
+ --muted: oklch(0.269 0 0);
92
+ --muted-foreground: oklch(0.708 0 0);
93
+ --accent: oklch(0.269 0 0);
94
+ --accent-foreground: oklch(0.985 0 0);
95
+ --destructive: oklch(0.704 0.191 22.216);
96
+ --border: oklch(1 0 0 / 10%);
97
+ --input: oklch(1 0 0 / 15%);
98
+ --ring: oklch(0.556 0 0);
99
+ --chart-1: oklch(0.488 0.243 264.376);
100
+ --chart-2: oklch(0.696 0.17 162.48);
101
+ --chart-3: oklch(0.769 0.188 70.08);
102
+ --chart-4: oklch(0.627 0.265 303.9);
103
+ --chart-5: oklch(0.645 0.246 16.439);
104
+ --sidebar: oklch(0.205 0 0);
105
+ --sidebar-foreground: oklch(0.985 0 0);
106
+ --sidebar-primary: oklch(0.488 0.243 264.376);
107
+ --sidebar-primary-foreground: oklch(0.985 0 0);
108
+ --sidebar-accent: oklch(0.269 0 0);
109
+ --sidebar-accent-foreground: oklch(0.985 0 0);
110
+ --sidebar-border: oklch(1 0 0 / 10%);
111
+ --sidebar-ring: oklch(0.556 0 0);
112
+ }
113
+
114
+ @layer base {
115
+ * {
116
+ @apply border-border outline-ring/50;
117
+ }
118
+ body {
119
+ @apply bg-background text-foreground;
120
+ }
121
+ }