@tonyclaw/llm-inspector 1.7.0 → 1.7.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.
@@ -65,7 +65,7 @@ function RootDocument({ children }) {
65
65
  ] })
66
66
  ] });
67
67
  }
68
- const $$splitComponentImporter = () => import("./index-ByCLZu7J.mjs");
68
+ const $$splitComponentImporter = () => import("./index-BZkxgx8f.mjs");
69
69
  const Route$d = createFileRoute("/")({
70
70
  component: lazyRouteComponent($$splitComponentImporter, "component")
71
71
  });
@@ -2074,6 +2074,77 @@ const Route$3 = createFileRoute("/api/config/paths")({
2074
2074
  }
2075
2075
  }
2076
2076
  });
2077
+ const ERROR_HINTS = {
2078
+ timeout: "The request took too long. Check your network connection or try again.",
2079
+ network_unreachable: "Cannot reach the server. Verify the base URL and check your firewall settings.",
2080
+ connection_refused: "Connection was refused. Make sure the provider server is running and the URL is correct.",
2081
+ auth_failed: "Authentication failed. Check your API key is correct and has not expired.",
2082
+ rate_limited: "Too many requests. Wait a moment and try again, or check your API quota.",
2083
+ server_error: "The provider server encountered an error. Check their status page and try again later.",
2084
+ invalid_response: "Received an unexpected response. The provider may be experiencing issues.",
2085
+ unknown: "An unexpected error occurred. Check the details and try again."
2086
+ };
2087
+ const MAX_ERROR_DETAILS_LENGTH = 200;
2088
+ function classifyError(error, responseStatus) {
2089
+ const errorStr = String(error);
2090
+ if (error instanceof Error && error.name === "AbortError") {
2091
+ return { type: "timeout", details: "Request was aborted due to timeout" };
2092
+ }
2093
+ if (errorStr.includes("timeout") || errorStr.includes("timed out") || errorStr.includes("Timeout")) {
2094
+ return { type: "timeout", details: truncateErrorDetails(errorStr) };
2095
+ }
2096
+ if (errorStr.includes("ECONNREFUSED") || errorStr.includes("connection refused")) {
2097
+ return { type: "connection_refused", details: truncateErrorDetails(errorStr) };
2098
+ }
2099
+ if (errorStr.includes("ENOTFOUND") || errorStr.includes("getaddrinfo") || errorStr.includes("DNS") || errorStr.includes("network") || errorStr.includes("fetch failed")) {
2100
+ return { type: "network_unreachable", details: truncateErrorDetails(errorStr) };
2101
+ }
2102
+ if (responseStatus !== void 0) {
2103
+ if (responseStatus === 401 || responseStatus === 403) {
2104
+ return { type: "auth_failed", details: `HTTP ${responseStatus}` };
2105
+ }
2106
+ if (responseStatus === 429) {
2107
+ return { type: "rate_limited", details: `HTTP ${responseStatus}` };
2108
+ }
2109
+ if (responseStatus >= 500 && responseStatus < 600) {
2110
+ return { type: "server_error", details: `HTTP ${responseStatus}` };
2111
+ }
2112
+ }
2113
+ return { type: "unknown", details: truncateErrorDetails(errorStr) };
2114
+ }
2115
+ function truncateErrorDetails(details) {
2116
+ if (details.length <= MAX_ERROR_DETAILS_LENGTH) {
2117
+ return details;
2118
+ }
2119
+ return details.slice(0, MAX_ERROR_DETAILS_LENGTH) + "...";
2120
+ }
2121
+ function createErrorResult(error, latencyMs, streaming, responseStatus) {
2122
+ const { type, details } = classifyError(error, responseStatus);
2123
+ return {
2124
+ success: false,
2125
+ error: {
2126
+ message: getErrorMessage(type),
2127
+ type,
2128
+ details,
2129
+ hint: ERROR_HINTS[type]
2130
+ },
2131
+ latencyMs,
2132
+ streaming
2133
+ };
2134
+ }
2135
+ function getErrorMessage(type) {
2136
+ const messages = {
2137
+ timeout: "Request timed out",
2138
+ network_unreachable: "Cannot reach host",
2139
+ connection_refused: "Connection refused",
2140
+ auth_failed: "Authentication failed",
2141
+ rate_limited: "Rate limited",
2142
+ server_error: "Provider server error",
2143
+ invalid_response: "Invalid response",
2144
+ unknown: "Unknown error"
2145
+ };
2146
+ return messages[type];
2147
+ }
2077
2148
  const AnthropicResponseSchema = object({
2078
2149
  id: string().optional(),
2079
2150
  type: string().optional(),
@@ -2106,6 +2177,7 @@ const OpenAIResponseSchema = object({
2106
2177
  })
2107
2178
  )
2108
2179
  });
2180
+ const TEST_TIMEOUT_MS = 3e4;
2109
2181
  async function testEndpoint(baseUrl, apiKey, path2, model, isOpenAI) {
2110
2182
  const startTime = Date.now();
2111
2183
  const body = JSON.stringify({
@@ -2113,6 +2185,8 @@ async function testEndpoint(baseUrl, apiKey, path2, model, isOpenAI) {
2113
2185
  messages: [{ role: "user", content: "say hello and briefly introduce yourself" }],
2114
2186
  max_tokens: 1024
2115
2187
  });
2188
+ const controller = new AbortController();
2189
+ const timeoutId = setTimeout(() => controller.abort(), TEST_TIMEOUT_MS);
2116
2190
  try {
2117
2191
  const url = `${baseUrl}${path2}`;
2118
2192
  const response = await fetch(url, {
@@ -2121,17 +2195,20 @@ async function testEndpoint(baseUrl, apiKey, path2, model, isOpenAI) {
2121
2195
  "Content-Type": "application/json",
2122
2196
  Authorization: `Bearer ${apiKey}`
2123
2197
  },
2124
- body
2198
+ body,
2199
+ signal: controller.signal
2125
2200
  });
2201
+ clearTimeout(timeoutId);
2126
2202
  const latencyMs = Date.now() - startTime;
2127
- const responseText = await response.text();
2128
2203
  if (!response.ok) {
2129
- return {
2130
- success: false,
2131
- error: `HTTP ${response.status}: ${responseText.slice(0, 200)}`,
2132
- latencyMs
2133
- };
2204
+ return createErrorResult(
2205
+ `HTTP ${response.status}: ${response.statusText}`,
2206
+ latencyMs,
2207
+ false,
2208
+ response.status
2209
+ );
2134
2210
  }
2211
+ const responseText = await response.text();
2135
2212
  try {
2136
2213
  if (isOpenAI) {
2137
2214
  const json = OpenAIResponseSchema.parse(JSON.parse(responseText));
@@ -2181,11 +2258,8 @@ async function testEndpoint(baseUrl, apiKey, path2, model, isOpenAI) {
2181
2258
  };
2182
2259
  }
2183
2260
  } catch (err) {
2184
- return {
2185
- success: false,
2186
- error: String(err),
2187
- latencyMs: Date.now() - startTime
2188
- };
2261
+ clearTimeout(timeoutId);
2262
+ return createErrorResult(err, Date.now() - startTime, false);
2189
2263
  }
2190
2264
  }
2191
2265
  async function testStreamingEndpoint(baseUrl, apiKey, path2, model, isOpenAI) {
@@ -2196,6 +2270,8 @@ async function testStreamingEndpoint(baseUrl, apiKey, path2, model, isOpenAI) {
2196
2270
  max_tokens: 256,
2197
2271
  stream: true
2198
2272
  });
2273
+ const controller = new AbortController();
2274
+ const timeoutId = setTimeout(() => controller.abort(), TEST_TIMEOUT_MS);
2199
2275
  try {
2200
2276
  const url = `${baseUrl}${path2}`;
2201
2277
  const response = await fetch(url, {
@@ -2204,24 +2280,29 @@ async function testStreamingEndpoint(baseUrl, apiKey, path2, model, isOpenAI) {
2204
2280
  "Content-Type": "application/json",
2205
2281
  Authorization: `Bearer ${apiKey}`
2206
2282
  },
2207
- body
2283
+ body,
2284
+ signal: controller.signal
2208
2285
  });
2286
+ clearTimeout(timeoutId);
2209
2287
  const latencyMs = Date.now() - startTime;
2210
2288
  if (!response.ok) {
2211
- const responseText = await response.text();
2212
- return {
2213
- success: false,
2214
- error: `HTTP ${response.status}: ${responseText.slice(0, 200)}`,
2289
+ return createErrorResult(
2290
+ `HTTP ${response.status}: ${response.statusText}`,
2215
2291
  latencyMs,
2216
- streaming: true
2217
- };
2292
+ true,
2293
+ response.status
2294
+ );
2218
2295
  }
2219
2296
  const chunks = [];
2220
2297
  const reader = response.body?.getReader();
2221
2298
  if (!reader) {
2222
2299
  return {
2223
2300
  success: false,
2224
- error: "No response body",
2301
+ error: {
2302
+ message: "No response body",
2303
+ type: "invalid_response",
2304
+ hint: ERROR_HINTS.invalid_response
2305
+ },
2225
2306
  latencyMs,
2226
2307
  streaming: true
2227
2308
  };
@@ -2234,12 +2315,7 @@ async function testStreamingEndpoint(baseUrl, apiKey, path2, model, isOpenAI) {
2234
2315
  chunks.push(decoder.decode(value, { stream: true }));
2235
2316
  }
2236
2317
  } catch (readErr) {
2237
- return {
2238
- success: false,
2239
- error: `Stream read error: ${readErr}`,
2240
- latencyMs,
2241
- streaming: true
2242
- };
2318
+ return createErrorResult(`Stream read error: ${readErr}`, latencyMs, true);
2243
2319
  }
2244
2320
  const fullResponse = chunks.join("");
2245
2321
  const mockLog = {
@@ -2320,12 +2396,8 @@ async function testStreamingEndpoint(baseUrl, apiKey, path2, model, isOpenAI) {
2320
2396
  };
2321
2397
  }
2322
2398
  } catch (err) {
2323
- return {
2324
- success: false,
2325
- error: String(err),
2326
- latencyMs: Date.now() - startTime,
2327
- streaming: true
2328
- };
2399
+ clearTimeout(timeoutId);
2400
+ return createErrorResult(err, Date.now() - startTime, true);
2329
2401
  }
2330
2402
  }
2331
2403
  function createTestLogEntry(providerName, path2, body, upstreamUrl, result, isTest) {
@@ -2346,7 +2418,7 @@ function createTestLogEntry(providerName, path2, body, upstreamUrl, result, isTe
2346
2418
  userAgent: "provider-test",
2347
2419
  origin: null,
2348
2420
  upstreamUrl,
2349
- error: result.success ? null : result.error,
2421
+ error: result.success ? null : result.error?.message ?? String(result.error),
2350
2422
  isTest: true,
2351
2423
  providerName
2352
2424
  };
@@ -1,4 +1,4 @@
1
- const tsrStartManifest = () => ({ "routes": { "__root__": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/__root.tsx", "children": ["/", "/api/health", "/api/logs", "/api/models", "/api/providers", "/api/sessions", "/proxy/$", "/api/config/paths"], "preloads": ["/assets/main-Cp8AM0Pa.js"], "assets": [] }, "/": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/index.tsx", "assets": [], "preloads": ["/assets/index-s4lwsWvq.js"] }, "/api/health": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/health.ts" }, "/api/logs": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/logs.ts", "children": ["/api/logs/$id", "/api/logs/stream"] }, "/api/models": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/models.ts" }, "/api/providers": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/providers.ts", "children": ["/api/providers/$providerId"] }, "/api/sessions": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/sessions.ts" }, "/proxy/$": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/proxy/$.ts" }, "/api/config/paths": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/config.paths.ts" }, "/api/logs/$id": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/logs.$id.ts", "children": ["/api/logs/$id/chunks", "/api/logs/$id/replay"] }, "/api/logs/stream": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/logs.stream.ts" }, "/api/providers/$providerId": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/providers.$providerId.ts", "children": ["/api/providers/$providerId/test"] }, "/api/logs/$id/chunks": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/logs.$id.chunks.ts" }, "/api/logs/$id/replay": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/logs.$id.replay.ts" }, "/api/providers/$providerId/test": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/providers.$providerId.test.ts" } }, "clientEntry": "/assets/main-Cp8AM0Pa.js" });
1
+ const tsrStartManifest = () => ({ "routes": { "__root__": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/__root.tsx", "children": ["/", "/api/health", "/api/logs", "/api/models", "/api/providers", "/api/sessions", "/proxy/$", "/api/config/paths"], "preloads": ["/assets/main-CpIX1ZHy.js"], "assets": [] }, "/": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/index.tsx", "assets": [], "preloads": ["/assets/index-Bf_WGooQ.js"] }, "/api/health": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/health.ts" }, "/api/logs": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/logs.ts", "children": ["/api/logs/$id", "/api/logs/stream"] }, "/api/models": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/models.ts" }, "/api/providers": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/providers.ts", "children": ["/api/providers/$providerId"] }, "/api/sessions": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/sessions.ts" }, "/proxy/$": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/proxy/$.ts" }, "/api/config/paths": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/config.paths.ts" }, "/api/logs/$id": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/logs.$id.ts", "children": ["/api/logs/$id/chunks", "/api/logs/$id/replay"] }, "/api/logs/stream": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/logs.stream.ts" }, "/api/providers/$providerId": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/providers.$providerId.ts", "children": ["/api/providers/$providerId/test"] }, "/api/logs/$id/chunks": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/logs.$id.chunks.ts" }, "/api/logs/$id/replay": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/logs.$id.replay.ts" }, "/api/providers/$providerId/test": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/providers.$providerId.test.ts" } }, "clientEntry": "/assets/main-CpIX1ZHy.js" });
2
2
  export {
3
3
  tsrStartManifest
4
4
  };
@@ -100,51 +100,51 @@ const assets = {
100
100
  "/assets/alibaba-TTwafVwX.svg": {
101
101
  "type": "image/svg+xml",
102
102
  "etag": '"171b-6dyV5K8QjiaY35sN9qNprh9zDIs"',
103
- "mtime": "2026-06-03T08:20:44.008Z",
103
+ "mtime": "2026-06-03T09:30:18.865Z",
104
104
  "size": 5915,
105
105
  "path": "../public/assets/alibaba-TTwafVwX.svg"
106
106
  },
107
107
  "/assets/index-B3RwBPLW.css": {
108
108
  "type": "text/css; charset=utf-8",
109
109
  "etag": '"10c74-aXacU4DRFVsUwcC5jHnjoPRSlTA"',
110
- "mtime": "2026-06-03T08:20:44.011Z",
110
+ "mtime": "2026-06-03T09:30:18.865Z",
111
111
  "size": 68724,
112
112
  "path": "../public/assets/index-B3RwBPLW.css"
113
113
  },
114
114
  "/assets/minimax-BPMzvuL-.jpeg": {
115
115
  "type": "image/jpeg",
116
116
  "etag": '"1b06-IwivU89ko5UTMUM1/t7hn4sQK9A"',
117
- "mtime": "2026-06-03T08:20:44.010Z",
117
+ "mtime": "2026-06-03T09:30:18.865Z",
118
118
  "size": 6918,
119
119
  "path": "../public/assets/minimax-BPMzvuL-.jpeg"
120
120
  },
121
121
  "/assets/zhipuai-BPNAnxo-.svg": {
122
122
  "type": "image/svg+xml",
123
123
  "etag": '"2bf8-hNaLCTi89nOFCsIIfWpP/jrfo0s"',
124
- "mtime": "2026-06-03T08:20:44.010Z",
124
+ "mtime": "2026-06-03T09:30:18.862Z",
125
125
  "size": 11256,
126
126
  "path": "../public/assets/zhipuai-BPNAnxo-.svg"
127
127
  },
128
- "/assets/main-Cp8AM0Pa.js": {
128
+ "/assets/main-CpIX1ZHy.js": {
129
129
  "type": "text/javascript; charset=utf-8",
130
- "etag": '"4db57-FpqlPRLq9OHoaAFCL2NIXtZbW5c"',
131
- "mtime": "2026-06-03T08:20:44.011Z",
130
+ "etag": '"4db57-PIyiLXQGvlFJuizUFXRhGOYXJwY"',
131
+ "mtime": "2026-06-03T09:30:18.865Z",
132
132
  "size": 318295,
133
- "path": "../public/assets/main-Cp8AM0Pa.js"
133
+ "path": "../public/assets/main-CpIX1ZHy.js"
134
134
  },
135
135
  "/assets/qwen-CONDcHqt.png": {
136
136
  "type": "image/png",
137
137
  "etag": '"572c3-cdJAPaHdOvFCGzuaQjagdgOu6XE"',
138
- "mtime": "2026-06-03T08:20:44.010Z",
138
+ "mtime": "2026-06-03T09:30:18.865Z",
139
139
  "size": 357059,
140
140
  "path": "../public/assets/qwen-CONDcHqt.png"
141
141
  },
142
- "/assets/index-s4lwsWvq.js": {
142
+ "/assets/index-Bf_WGooQ.js": {
143
143
  "type": "text/javascript; charset=utf-8",
144
- "etag": '"828c8-LEW/XL92J2/5lU4VKALlH7aVpaA"',
145
- "mtime": "2026-06-03T08:20:44.011Z",
146
- "size": 534728,
147
- "path": "../public/assets/index-s4lwsWvq.js"
144
+ "etag": '"831df-gmdpd1CCnM4IdaxHIs9uyMgWFaY"',
145
+ "mtime": "2026-06-03T09:30:18.865Z",
146
+ "size": 537055,
147
+ "path": "../public/assets/index-Bf_WGooQ.js"
148
148
  }
149
149
  };
150
150
  function readAsset(id) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tonyclaw/llm-inspector",
3
- "version": "1.7.0",
3
+ "version": "1.7.2",
4
4
  "type": "module",
5
5
  "description": "LLM API proxy inspector — captures and displays requests/responses from AI coding tools in a web UI",
6
6
  "license": "MIT",
package/src/cli.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
- import { spawn } from "node:child_process";
2
+ import { spawn, execSync } from "node:child_process";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { dirname, join } from "node:path";
5
+ import { existsSync } from "node:fs";
5
6
 
6
7
  const __filename = fileURLToPath(import.meta.url);
7
8
  const __dirname = dirname(__filename);
@@ -35,8 +36,78 @@ for (let i = 0; i < args.length; i++) {
35
36
  }
36
37
  }
37
38
 
39
+ /**
40
+ * Check if a port is in use and kill the process using it
41
+ */
42
+ function killProcessOnPort(targetPort: number): void {
43
+ const platform = process.platform;
44
+
45
+ try {
46
+ let pids: number[] = [];
47
+
48
+ if (platform === "win32") {
49
+ // Windows: use netstat to find PID, then taskkill
50
+ const output = execSync(`netstat -ano | findstr :${targetPort}`, {
51
+ encoding: "utf8",
52
+ timeout: 5000,
53
+ });
54
+ const lines = output.trim().split("\n");
55
+ for (const line of lines) {
56
+ const parts = line.trim().split(/\s+/);
57
+ if (parts.length >= 5) {
58
+ const localAddress = parts[1] ?? "";
59
+ const pidStr = parts[4] ?? "";
60
+ if (localAddress !== "" && pidStr !== "" && localAddress.includes(`:${targetPort}`)) {
61
+ const pid = parseInt(pidStr, 10);
62
+ if (!isNaN(pid) && pid > 0) {
63
+ pids.push(pid);
64
+ }
65
+ }
66
+ }
67
+ }
68
+ // Remove duplicates
69
+ pids = [...new Set(pids)];
70
+
71
+ for (const pid of pids) {
72
+ try {
73
+ console.log(`Killing process ${pid} on port ${port}...`);
74
+ execSync(`taskkill /PID ${pid} /F`, { encoding: "utf8", timeout: 5000 });
75
+ } catch {
76
+ // Process may have already exited
77
+ }
78
+ }
79
+ } else {
80
+ // Unix-like: use lsof
81
+ const output = execSync(`lsof -ti:${targetPort}`, { encoding: "utf8", timeout: 5000 });
82
+ const lines = output.trim().split("\n");
83
+ for (const line of lines) {
84
+ const pid = parseInt(line.trim(), 10);
85
+ if (!isNaN(pid) && pid > 0) {
86
+ pids.push(pid);
87
+ }
88
+ }
89
+ // Remove duplicates
90
+ pids = [...new Set(pids)];
91
+
92
+ for (const pid of pids) {
93
+ try {
94
+ console.log(`Killing process ${pid} on port ${targetPort}...`);
95
+ execSync(`kill -9 ${pid}`, { encoding: "utf8", timeout: 5000 });
96
+ } catch {
97
+ // Process may have already exited
98
+ }
99
+ }
100
+ }
101
+ } catch {
102
+ // No process found on port, which is fine
103
+ }
104
+ }
105
+
38
106
  process.env["PORT"] = String(port);
39
107
 
108
+ // Kill any existing process on the port
109
+ killProcessOnPort(port);
110
+
40
111
  const url = `http://localhost:${port}`;
41
112
 
42
113
  console.log(`Server running at ${url}`);
@@ -10,6 +10,14 @@ import {
10
10
  XCircle,
11
11
  Minus,
12
12
  ExternalLink,
13
+ AlertCircle,
14
+ Wifi,
15
+ WifiOff,
16
+ Lock,
17
+ Gauge,
18
+ Server,
19
+ HelpCircle,
20
+ Clock,
13
21
  } from "lucide-react";
14
22
  import type { ProviderConfig } from "../../proxy/providers";
15
23
 
@@ -18,9 +26,26 @@ const KNOWN_PROVIDER_DOCS: Record<string, string> = {
18
26
  deepseek: "https://api-docs.deepseek.com/zh-cn/",
19
27
  };
20
28
 
29
+ type ErrorType =
30
+ | "timeout"
31
+ | "network_unreachable"
32
+ | "connection_refused"
33
+ | "auth_failed"
34
+ | "rate_limited"
35
+ | "server_error"
36
+ | "invalid_response"
37
+ | "unknown";
38
+
39
+ type EnhancedError = {
40
+ message: string;
41
+ type: ErrorType;
42
+ details?: string;
43
+ hint?: string;
44
+ };
45
+
21
46
  type TestResult = {
22
47
  success: boolean;
23
- error?: string;
48
+ error?: EnhancedError;
24
49
  };
25
50
 
26
51
  type NotConfigured = { notConfigured: true };
@@ -53,7 +78,37 @@ function hasSuccessField(result: TestResult | NotConfigured): result is TestResu
53
78
  return Object.prototype.hasOwnProperty.call(result, "success");
54
79
  }
55
80
 
56
- function TestStatus({ result }: { result: TestResult | NotConfigured }): JSX.Element {
81
+ function getErrorIcon(type: ErrorType): JSX.Element {
82
+ const iconProps = { className: "size-3", strokeWidth: 2 };
83
+ switch (type) {
84
+ case "timeout":
85
+ return <Clock {...iconProps} />;
86
+ case "network_unreachable":
87
+ return <WifiOff {...iconProps} />;
88
+ case "connection_refused":
89
+ return <Wifi {...iconProps} />;
90
+ case "auth_failed":
91
+ return <Lock {...iconProps} />;
92
+ case "rate_limited":
93
+ return <Gauge {...iconProps} />;
94
+ case "server_error":
95
+ return <Server {...iconProps} />;
96
+ case "invalid_response":
97
+ return <HelpCircle {...iconProps} />;
98
+ case "unknown":
99
+ return <AlertCircle {...iconProps} />;
100
+ default:
101
+ return <AlertCircle {...iconProps} />;
102
+ }
103
+ }
104
+
105
+ function TestStatus({
106
+ result,
107
+ isTesting,
108
+ }: {
109
+ result: TestResult | NotConfigured;
110
+ isTesting?: boolean;
111
+ }): JSX.Element {
57
112
  if (!hasSuccessField(result)) {
58
113
  return (
59
114
  <div className="flex items-center gap-1 text-xs text-muted-foreground">
@@ -70,10 +125,26 @@ function TestStatus({ result }: { result: TestResult | NotConfigured }): JSX.Ele
70
125
  </div>
71
126
  );
72
127
  }
128
+
129
+ const error = result.error;
130
+ const errorMessage = error?.message ?? "Connection failed";
131
+ const errorHint = error?.hint;
132
+ const errorType = error?.type ?? "unknown";
133
+
73
134
  return (
74
- <div className="flex items-center gap-1 text-xs text-red-600" title={result.error}>
75
- <XCircle className="size-3" />
76
- <span className="truncate">{result.error}</span>
135
+ <div className="flex flex-col gap-1 min-w-0">
136
+ <div
137
+ className="flex items-center gap-1 text-xs text-red-600 min-w-0"
138
+ title={error?.details ?? errorMessage}
139
+ >
140
+ {getErrorIcon(errorType)}
141
+ <span className="truncate">{errorMessage}</span>
142
+ </div>
143
+ {errorHint !== undefined && (
144
+ <div className="text-xs text-muted-foreground pl-4 truncate" title={errorHint}>
145
+ {errorHint}
146
+ </div>
147
+ )}
77
148
  </div>
78
149
  );
79
150
  }
@@ -135,7 +206,9 @@ export function ProviderCard({
135
206
  <span className="font-medium">Anthropic:</span>{" "}
136
207
  <span className="truncate">{provider.anthropicBaseUrl}</span>
137
208
  </div>
138
- {testResults && <TestStatus result={testResults.anthropic.nonStreaming} />}
209
+ {testResults && (
210
+ <TestStatus result={testResults.anthropic.nonStreaming} isTesting={isTesting} />
211
+ )}
139
212
  </div>
140
213
  )}
141
214
 
@@ -145,7 +218,9 @@ export function ProviderCard({
145
218
  <span className="font-medium">OpenAI:</span>{" "}
146
219
  <span className="truncate">{provider.openaiBaseUrl}</span>
147
220
  </div>
148
- {testResults && <TestStatus result={testResults.openai.nonStreaming} />}
221
+ {testResults && (
222
+ <TestStatus result={testResults.openai.nonStreaming} isTesting={isTesting} />
223
+ )}
149
224
  </div>
150
225
  )}
151
226
 
@@ -9,9 +9,24 @@ type ConfigPathsResponse = {
9
9
  providerConfig: string;
10
10
  };
11
11
 
12
+ type EnhancedError = {
13
+ message: string;
14
+ type:
15
+ | "timeout"
16
+ | "network_unreachable"
17
+ | "connection_refused"
18
+ | "auth_failed"
19
+ | "rate_limited"
20
+ | "server_error"
21
+ | "invalid_response"
22
+ | "unknown";
23
+ details?: string;
24
+ hint?: string;
25
+ };
26
+
12
27
  type TestResult = {
13
28
  success: boolean;
14
- error?: string;
29
+ error?: EnhancedError;
15
30
  };
16
31
 
17
32
  type NotConfigured = { notConfigured: true };
@@ -88,14 +103,23 @@ export function ProvidersPanel(): JSX.Element {
88
103
  name: string;
89
104
  apiKey: string;
90
105
  model?: string;
91
- anthropicBaseUrl?: string;
92
- openaiBaseUrl?: string;
106
+ format: "anthropic" | "openai";
107
+ baseUrl?: string;
93
108
  }): void {
94
109
  void (async () => {
110
+ // Convert baseUrl to format-specific URL
111
+ const payload = {
112
+ name: data.name,
113
+ apiKey: data.apiKey,
114
+ model: data.model,
115
+ format: data.format,
116
+ anthropicBaseUrl: data.format === "anthropic" ? data.baseUrl : undefined,
117
+ openaiBaseUrl: data.format === "openai" ? data.baseUrl : undefined,
118
+ };
95
119
  const res = await fetch("/api/providers", {
96
120
  method: "POST",
97
121
  headers: { "Content-Type": "application/json" },
98
- body: JSON.stringify(data),
122
+ body: JSON.stringify(payload),
99
123
  });
100
124
  if (!res.ok) {
101
125
  // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
@@ -116,15 +140,24 @@ export function ProvidersPanel(): JSX.Element {
116
140
  name: string;
117
141
  apiKey: string;
118
142
  model?: string;
119
- anthropicBaseUrl?: string;
120
- openaiBaseUrl?: string;
143
+ format: "anthropic" | "openai";
144
+ baseUrl?: string;
121
145
  }): void {
122
146
  if (!editingProvider) return;
123
147
  void (async () => {
148
+ // Convert baseUrl to format-specific URL
149
+ const payload = {
150
+ name: data.name,
151
+ apiKey: data.apiKey,
152
+ model: data.model,
153
+ format: data.format,
154
+ anthropicBaseUrl: data.format === "anthropic" ? data.baseUrl : undefined,
155
+ openaiBaseUrl: data.format === "openai" ? data.baseUrl : undefined,
156
+ };
124
157
  const res = await fetch(`/api/providers/${editingProvider.id}`, {
125
158
  method: "PUT",
126
159
  headers: { "Content-Type": "application/json" },
127
- body: JSON.stringify(data),
160
+ body: JSON.stringify(payload),
128
161
  });
129
162
  if (!res.ok) {
130
163
  // eslint-disable-next-line @typescript-eslint/consistent-type-assertions