@tonyclaw/llm-inspector 1.7.9 → 1.9.0

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.
Files changed (41) hide show
  1. package/.output/nitro.json +1 -1
  2. package/.output/public/assets/index-DdJSLfxK.css +1 -0
  3. package/.output/public/assets/index-DyKLPMPn.js +97 -0
  4. package/.output/public/assets/main-Cu0oTDfX.js +17 -0
  5. package/.output/server/_libs/dequal.mjs +27 -0
  6. package/.output/server/_libs/swr.mjs +938 -0
  7. package/.output/server/_libs/use-sync-external-store.mjs +64 -1
  8. package/.output/server/_ssr/{index-CAIDMqNv.mjs → index-COIATcfa.mjs} +186 -88
  9. package/.output/server/_ssr/index.mjs +2 -2
  10. package/.output/server/_ssr/{router-CsCLdrXq.mjs → router-CwmgKXBJ.mjs} +259 -74
  11. package/.output/server/{_tanstack-start-manifest_v-BF6ge6dS.mjs → _tanstack-start-manifest_v-C7hQOzvX.mjs} +1 -1
  12. package/.output/server/index.mjs +23 -23
  13. package/README.md +8 -209
  14. package/package.json +2 -1
  15. package/src/components/ProxyViewer.tsx +2 -0
  16. package/src/components/ProxyViewerContainer.tsx +10 -1
  17. package/src/components/providers/ProviderCard.tsx +57 -48
  18. package/src/components/providers/ProviderForm.tsx +21 -0
  19. package/src/components/providers/ProviderLogo.tsx +6 -1
  20. package/src/components/providers/ProvidersPanel.tsx +29 -34
  21. package/src/components/providers/SettingsDialog.tsx +5 -3
  22. package/src/components/proxy-viewer/LogEntry.tsx +7 -0
  23. package/src/components/proxy-viewer/ResponseView.tsx +32 -6
  24. package/src/lib/useProviders.ts +30 -0
  25. package/src/proxy/chunkStorage.ts +4 -6
  26. package/src/proxy/formats/anthropic/schemas.ts +9 -0
  27. package/src/proxy/formats/anthropic/stream.ts +11 -0
  28. package/src/proxy/formats/openai/stream.ts +15 -0
  29. package/src/proxy/handler.ts +34 -27
  30. package/src/proxy/logIndex.ts +52 -7
  31. package/src/proxy/logger.ts +60 -10
  32. package/src/proxy/providers.ts +5 -0
  33. package/src/proxy/schemas.ts +2 -0
  34. package/src/proxy/socketTracker.ts +90 -36
  35. package/src/proxy/store.ts +24 -14
  36. package/src/routes/__root.tsx +4 -1
  37. package/src/routes/api/providers.$providerId.ts +1 -0
  38. package/src/routes/api/providers.ts +2 -0
  39. package/.output/public/assets/index-B3RwBPLW.css +0 -1
  40. package/.output/public/assets/index-CB8ZIeEk.js +0 -97
  41. package/.output/public/assets/main-BrU8NdGQ.js +0 -17
@@ -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-BrU8NdGQ.js"], "assets": [] }, "/": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/index.tsx", "assets": [], "preloads": ["/assets/index-CB8ZIeEk.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/providers/export", "/api/providers/import"] }, "/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/providers/export": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/providers.export.ts" }, "/api/providers/import": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/providers.import.ts" }, "/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-BrU8NdGQ.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-Cu0oTDfX.js"], "assets": [] }, "/": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/index.tsx", "assets": [], "preloads": ["/assets/index-DyKLPMPn.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/providers/export", "/api/providers/import"] }, "/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/providers/export": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/providers.export.ts" }, "/api/providers/import": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/providers.import.ts" }, "/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-Cu0oTDfX.js" });
2
2
  export {
3
3
  tsrStartManifest
4
4
  };
@@ -97,54 +97,54 @@ const headers = ((m) => function headersRouteRule(event) {
97
97
  }
98
98
  });
99
99
  const assets = {
100
- "/assets/index-B3RwBPLW.css": {
101
- "type": "text/css; charset=utf-8",
102
- "etag": '"10c74-aXacU4DRFVsUwcC5jHnjoPRSlTA"',
103
- "mtime": "2026-06-04T00:12:14.535Z",
104
- "size": 68724,
105
- "path": "../public/assets/index-B3RwBPLW.css"
106
- },
107
100
  "/assets/alibaba-TTwafVwX.svg": {
108
101
  "type": "image/svg+xml",
109
102
  "etag": '"171b-6dyV5K8QjiaY35sN9qNprh9zDIs"',
110
- "mtime": "2026-06-04T00:12:14.535Z",
103
+ "mtime": "2026-06-04T03:52:13.283Z",
111
104
  "size": 5915,
112
105
  "path": "../public/assets/alibaba-TTwafVwX.svg"
113
106
  },
107
+ "/assets/index-DdJSLfxK.css": {
108
+ "type": "text/css; charset=utf-8",
109
+ "etag": '"10da0-LYeZ5d/vwqh4bAnuP/9hr6Wka6g"',
110
+ "mtime": "2026-06-04T03:52:13.283Z",
111
+ "size": 69024,
112
+ "path": "../public/assets/index-DdJSLfxK.css"
113
+ },
114
114
  "/assets/minimax-BPMzvuL-.jpeg": {
115
115
  "type": "image/jpeg",
116
116
  "etag": '"1b06-IwivU89ko5UTMUM1/t7hn4sQK9A"',
117
- "mtime": "2026-06-04T00:12:14.537Z",
117
+ "mtime": "2026-06-04T03:52:13.278Z",
118
118
  "size": 6918,
119
119
  "path": "../public/assets/minimax-BPMzvuL-.jpeg"
120
120
  },
121
- "/assets/main-BrU8NdGQ.js": {
122
- "type": "text/javascript; charset=utf-8",
123
- "etag": '"4db57-Aw3zM46XAKLPcPLc9vLSzFsuUjI"',
124
- "mtime": "2026-06-04T00:12:14.537Z",
125
- "size": 318295,
126
- "path": "../public/assets/main-BrU8NdGQ.js"
127
- },
128
121
  "/assets/zhipuai-BPNAnxo-.svg": {
129
122
  "type": "image/svg+xml",
130
123
  "etag": '"2bf8-hNaLCTi89nOFCsIIfWpP/jrfo0s"',
131
- "mtime": "2026-06-04T00:12:14.535Z",
124
+ "mtime": "2026-06-04T03:52:13.283Z",
132
125
  "size": 11256,
133
126
  "path": "../public/assets/zhipuai-BPNAnxo-.svg"
134
127
  },
128
+ "/assets/main-Cu0oTDfX.js": {
129
+ "type": "text/javascript; charset=utf-8",
130
+ "etag": '"50591-/K5L0AnXTcumA7dHm7kmA5HXlCE"',
131
+ "mtime": "2026-06-04T03:52:13.283Z",
132
+ "size": 329105,
133
+ "path": "../public/assets/main-Cu0oTDfX.js"
134
+ },
135
135
  "/assets/qwen-CONDcHqt.png": {
136
136
  "type": "image/png",
137
137
  "etag": '"572c3-cdJAPaHdOvFCGzuaQjagdgOu6XE"',
138
- "mtime": "2026-06-04T00:12:14.535Z",
138
+ "mtime": "2026-06-04T03:52:13.283Z",
139
139
  "size": 357059,
140
140
  "path": "../public/assets/qwen-CONDcHqt.png"
141
141
  },
142
- "/assets/index-CB8ZIeEk.js": {
142
+ "/assets/index-DyKLPMPn.js": {
143
143
  "type": "text/javascript; charset=utf-8",
144
- "etag": '"840e2-X78/yEmQlQ7ABa9KL5bfVykPAUY"',
145
- "mtime": "2026-06-04T00:12:14.537Z",
146
- "size": 540898,
147
- "path": "../public/assets/index-CB8ZIeEk.js"
144
+ "etag": '"84a31-iF3/oGIdY77bqBWoGeM9Ctiyids"',
145
+ "mtime": "2026-06-04T03:52:13.284Z",
146
+ "size": 543281,
147
+ "path": "../public/assets/index-DyKLPMPn.js"
148
148
  }
149
149
  };
150
150
  function readAsset(id) {
package/README.md CHANGED
@@ -10,63 +10,22 @@ llm-inspector 是一个 LLM API 透明代理,能够捕获 AI 编程工具(Cl
10
10
 
11
11
  ---
12
12
 
13
- ## 核心特性 / Features
13
+ ## 文档目录
14
14
 
15
- ### 请求重放调试 / Request Replay
16
-
17
- ![Replay](docs/Replay.png)
18
-
19
- 回放任意历史请求,快速复现问题。支持流式/非流式两种模式。
20
-
21
- ### 实时 SSE 流更新 / Real-Time SSE Streaming
22
-
23
- SSE 流式响应实时推送,无需轮询,即时查看每个数据块。
24
-
25
- ### 健康检查 / Health Check
26
-
27
- `GET /api/health` 端点,支持 Docker Compose 健康检查和容器编排。
28
-
29
- ### 支持多种 AI 编程工具 / Multiple AI Coding Tools
30
-
31
- ```bash
32
- # Claude Code
33
- ANTHROPIC_BASE_URL=http://localhost:25947/proxy claude
34
-
35
- # OpenCode
36
- LLM_BASE_URL=http://localhost:25947/proxy opencode
37
-
38
- # Cursor, Cody 等 / etc.
39
- ANTHROPIC_BASE_URL=http://localhost:25947/proxy <your-tool>
40
- ```
41
-
42
- ### 支持双 API 格式 / Dual API Format Support
43
-
44
- | API 格式 | 端点 | 工具 |
45
- |----------|------|------|
46
- | Anthropic | `/v1/messages` | Claude Code 等 |
47
- | OpenAI | `/v1/chat/completions` | OpenAI、DeepSeek 等 |
48
-
49
- ### 多 LLM 提供商支持 / Multi-Provider Support
50
-
51
- | 提供商 | 模型前缀 | API 格式 |
52
- |--------|---------|----------|
53
- | Anthropic | `claude-*` | Anthropic |
54
- | OpenAI | `gpt-*`, `o1-*`, `o3-*` | OpenAI |
55
- | DeepSeek | `deepseek-*` | OpenAI |
56
- | MiniMax | `MiniMax-*` | Anthropic/OpenAI |
57
- | Qwen | `qwen-*` | OpenAI |
58
- | ZhipuAI | `glm-*` | OpenAI |
15
+ - [特性 (Features)](docs/Features.md) - 核心功能介绍
16
+ - [安装 (Installation)](docs/Installation.md) - 安装指南
17
+ - [使用 (Usage)](docs/Usage.md) - 详细使用说明
18
+ - [架构 (Architecture)](docs/Architecture.md) - 系统架构
19
+ - [开发 (Development)](docs/Development.md) - 开发指南
59
20
 
60
21
  ---
61
22
 
62
- ## 快速开始 / Quick Start
23
+ ## 快速开始
63
24
 
64
- ### npm 安装(推荐)
25
+ ### npm 安装
65
26
 
66
27
  ```bash
67
28
  npm install -g @tonyclaw/llm-inspector
68
-
69
- # 启动
70
29
  llm-inspector
71
30
  ```
72
31
 
@@ -79,170 +38,10 @@ docker-compose up -d
79
38
  ### 源码运行
80
39
 
81
40
  ```bash
82
- # 安装依赖
83
41
  bun install
84
-
85
- # 启动开发服务器
86
42
  bun run dev
87
43
  ```
88
44
 
89
- 设置环境变量并启动 AI 编程工具:
90
-
91
- ```bash
92
- # Claude Code
93
- ANTHROPIC_BASE_URL=http://localhost:25947/proxy claude
94
-
95
- # OpenCode
96
- LLM_BASE_URL=http://localhost:25947/proxy opencode
97
- ```
98
-
99
45
  打开浏览器访问 http://localhost:25947 查看实时捕获的请求。
100
46
 
101
47
  ![Proxy Hello](docs/Proxy%20Hello.png)
102
-
103
- ---
104
-
105
- ## Docker 部署 / Docker Deployment
106
-
107
- ```bash
108
- # 构建并启动
109
- docker-compose up -d
110
-
111
- # 查看日志
112
- docker-compose logs -f
113
-
114
- # 停止
115
- docker-compose down
116
- ```
117
-
118
- 容器默认暴露 `25947` 端口,包含健康检查,可直接接入 Docker Compose 或 Kubernetes。
119
-
120
- ---
121
-
122
- ## 实时仪表盘 / Real-Time Dashboard
123
-
124
- ### 日志列表 / Log List
125
-
126
- 虚拟滚动列表展示每个被代理的请求,每条记录显示:
127
-
128
- - **模型标签** — 如 `claude-sonnet-4-20250514`
129
- - **API 格式** — Anthropic(橙色)/ OpenAI(蓝色)
130
- - **响应状态** — 绿色 2xx / 黄色 4xx / 红色 5xx
131
- - **Token 用量** — 输入 / 输出,自动 K/M 格式化
132
- - **流式标识** — 区分流式与非流式响应
133
-
134
- ### 解析响应视图 / Parsed Response View
135
-
136
- **Anthropic 响应:**
137
- - 模型、停止原因、Token 用量
138
- - 内容块分类渲染:文本(Markdown)、思考(可折叠紫色块)、工具调用(可折叠蓝色块)
139
-
140
- **OpenAI 响应:**
141
- - 模型、结束原因、Token 用量
142
- - 推理内容(紫色块)、文本、函数调用
143
-
144
- ### SSE 流式数据 / SSE Streaming
145
-
146
- ![Full Mode](docs/FULL.png)
147
-
148
- 流式响应显示:
149
- - **SSE 数据块面板** — 懒加载,按索引分组
150
- - **JSON 树查看器** — 类型颜色编码(字符串绿/数字黄/布尔蓝)
151
- - **重建响应** — 从 SSE 片段重建完整 JSON
152
-
153
- ### 提供商测试 / Provider Testing
154
-
155
- ![Provider Test](docs/Provider%20Test.png)
156
-
157
- 内置连接测试功能,同时测试非流式和流式连接,测试结果直接显示在日志列表中。
158
-
159
- ---
160
-
161
- ## 架构 / Architecture
162
-
163
- ```
164
- AI Coding Tool → llm-inspector (:25947/proxy/*) → LLM Provider
165
-
166
- Web UI (:25947)
167
- ```
168
-
169
- | 模块 | 路径 | 职责 |
170
- |------|------|------|
171
- | 代理处理器 | `src/proxy/handler.ts` | HTTP 代理、请求/响应捕获、SSE 流式 |
172
- | 格式处理器 | `src/proxy/formats/` | Anthropic/OpenAI 格式抽象 |
173
- | 提供商管理 | `src/proxy/providers.ts` | 多提供商配置存储 |
174
- | 格式协议 | `src/proxy/formats/anthropic/` `src/proxy/formats/openai/` | 格式特定解析器 |
175
- | 客户端追踪 | `src/proxy/socketTracker.ts` | PID、CWD、项目名 |
176
- | 日志存储 | `src/proxy/logger.ts`, `store.ts` | 内存缓冲区 + 磁盘持久化 |
177
-
178
- ---
179
-
180
- ## 命令行 / CLI
181
-
182
- ```bash
183
- llm-inspector # 启动服务
184
- llm-inspector --port 3000 # 指定端口
185
- llm-inspector --no-open # 禁止自动打开浏览器
186
- ```
187
-
188
- | 参数 | 默认值 | 说明 |
189
- |------|--------|------|
190
- | `--port`, `-p` | `25947` | 监听端口 |
191
- | `--open` | `true` | 启动时自动打开浏览器 |
192
-
193
- ### 环境变量
194
-
195
- | 变量 | 默认值 | 说明 |
196
- |------|--------|------|
197
- | `PORT` | `25947` | 服务器端口 |
198
- | `LOG_DIR` | `~/.llm-inspector/logs/` | 日志存储目录 |
199
- | `LOG_RETENTION_DAYS` | `7` | 日志保留天数 |
200
- | `CHUNKS_DIR` | `~/.llm-inspector/chunks/` | SSE 数据块存储 |
201
-
202
- ---
203
-
204
- ## 直接测试 / Direct Testing
205
-
206
- 不通过 AI 编程工具,直接用 curl 测试提供商连接:
207
-
208
- ```bash
209
- # Anthropic
210
- curl http://localhost:25947/proxy/v1/messages \
211
- -H 'content-type: application/json' \
212
- -d '{"model":"claude-sonnet-4-20250514","max_tokens":100,"messages":[{"role":"user","content":"hello"}]}'
213
-
214
- # OpenAI
215
- curl http://localhost:25947/proxy/v1/chat/completions \
216
- -H 'content-type: application/json' \
217
- -d '{"model":"gpt-4o-mini","max_tokens":100,"messages":[{"role":"user","content":"hello"}]}'
218
- ```
219
-
220
- ---
221
-
222
- ## 开发 / Development
223
-
224
- ```bash
225
- # 类型检查、Lint、格式化
226
- bun run check
227
-
228
- # 生产构建
229
- bun run build
230
- ```
231
-
232
- ---
233
-
234
- ## 发布 / Publishing
235
-
236
- ```bash
237
- # 1. 更新版本号
238
- git commit -m "chore: bump version"
239
- git tag v<version>
240
-
241
- # 2. 推送代码和标签
242
- git push origin main --tags
243
-
244
- # 3. 发布到 npm
245
- npm publish --access public
246
- ```
247
-
248
- 包名:`@tonyclaw/llm-inspector`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tonyclaw/llm-inspector",
3
- "version": "1.7.9",
3
+ "version": "1.9.0",
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",
@@ -61,6 +61,7 @@
61
61
  "react": "^19",
62
62
  "react-dom": "^19",
63
63
  "react-markdown": "^10.1.0",
64
+ "swr": "^2.4.1",
64
65
  "tailwind-merge": "^3.4.0",
65
66
  "tw-animate-css": "^1.4.0",
66
67
  "zod": "^4.3.6"
@@ -3,6 +3,7 @@ import { useVirtualizer } from "@tanstack/react-virtual";
3
3
  import { Download, LayoutGrid, List } from "lucide-react";
4
4
  import type { CapturedLog } from "../proxy/schemas";
5
5
  import { exportLogsAsZip } from "../lib/export-logs";
6
+ import packageJson from "../../package.json";
6
7
  import {
7
8
  ConversationGroup,
8
9
  groupLogsByConversation,
@@ -142,6 +143,7 @@ export function ProxyViewer({
142
143
  {/* Header */}
143
144
  <div className="flex items-center gap-4 mb-4 px-6 pt-6">
144
145
  <h1 className="text-lg font-bold flex-1">LLM Proxy Inspector</h1>
146
+ <span className="text-muted-foreground text-xs font-mono">v{packageJson.version}</span>
145
147
  <div className="flex items-center border border-border rounded-md overflow-hidden">
146
148
  <button
147
149
  type="button"
@@ -63,6 +63,7 @@ export function ProxyViewerContainer(): JSX.Element {
63
63
  const [viewMode, setViewMode] = useState<"simple" | "full">("simple");
64
64
  const [error, setError] = useState<string | null>(null);
65
65
  const eventSourceRef = useRef<EventSource | null>(null);
66
+ const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
66
67
 
67
68
  const fetchSessionsAndModels = useCallback(async () => {
68
69
  try {
@@ -152,8 +153,12 @@ export function ProxyViewerContainer(): JSX.Element {
152
153
  es.onerror = () => {
153
154
  setError("SSE connection lost, reconnecting...");
154
155
  es.close();
156
+ // Clear any existing reconnect timeout
157
+ if (reconnectTimeoutRef.current !== null) {
158
+ clearTimeout(reconnectTimeoutRef.current);
159
+ }
155
160
  // Reconnect after 3 seconds
156
- setTimeout(connectSSE, 3000);
161
+ reconnectTimeoutRef.current = setTimeout(connectSSE, 3000);
157
162
  };
158
163
 
159
164
  void fetchSessionsAndModels();
@@ -166,6 +171,10 @@ export function ProxyViewerContainer(): JSX.Element {
166
171
  eventSourceRef.current.close();
167
172
  eventSourceRef.current = null;
168
173
  }
174
+ if (reconnectTimeoutRef.current !== null) {
175
+ clearTimeout(reconnectTimeoutRef.current);
176
+ reconnectTimeoutRef.current = null;
177
+ }
169
178
  };
170
179
  }, [connectSSE]);
171
180
 
@@ -20,7 +20,7 @@ import {
20
20
  } from "lucide-react";
21
21
  import type { ProviderConfig } from "../../proxy/providers";
22
22
 
23
- // Known provider documentation links
23
+ // Known provider documentation links (fallback when apiDocsUrl is not configured)
24
24
  const KNOWN_PROVIDER_DOCS: Record<string, string> = {
25
25
  deepseek: "https://api-docs.deepseek.com/zh-cn/",
26
26
  };
@@ -49,9 +49,11 @@ type TestResult = {
49
49
 
50
50
  type NotConfigured = { notConfigured: true };
51
51
 
52
+ type Testing = { testing: true };
53
+
52
54
  type StreamingTestResults = {
53
- nonStreaming: TestResult | NotConfigured;
54
- streaming: TestResult | NotConfigured;
55
+ nonStreaming: TestResult | NotConfigured | Testing;
56
+ streaming: TestResult | NotConfigured | Testing;
55
57
  };
56
58
 
57
59
  type TestResults = {
@@ -74,10 +76,17 @@ function maskApiKey(apiKey: string): string {
74
76
  return apiKey.slice(0, 4) + "••••••••" + apiKey.slice(-4);
75
77
  }
76
78
 
77
- function hasSuccessField(result: TestResult | NotConfigured): result is TestResult {
79
+ function hasSuccessField(result: TestResult | NotConfigured | Testing): result is TestResult {
78
80
  return Object.prototype.hasOwnProperty.call(result, "success");
79
81
  }
80
82
 
83
+ // Using Object.prototype.hasOwnProperty.call to avoid 'in' operator
84
+ function isNotConfiguredState(
85
+ result: TestResult | NotConfigured | Testing,
86
+ ): result is NotConfigured {
87
+ return Object.prototype.hasOwnProperty.call(result, "notConfigured");
88
+ }
89
+
81
90
  function getErrorIcon(type: ErrorType): JSX.Element {
82
91
  const iconProps = { className: "size-3", strokeWidth: 2 };
83
92
  switch (type) {
@@ -102,24 +111,28 @@ function getErrorIcon(type: ErrorType): JSX.Element {
102
111
  }
103
112
  }
104
113
 
105
- function TestStatus({
106
- result,
107
- isTesting,
108
- }: {
109
- result: TestResult | NotConfigured;
110
- isTesting?: boolean;
111
- }): JSX.Element {
114
+ function TestStatus({ result }: { result: TestResult | NotConfigured | Testing }): JSX.Element {
112
115
  if (!hasSuccessField(result)) {
116
+ // Not TestResult - check if NotConfigured or Testing
117
+ if (isNotConfiguredState(result)) {
118
+ return (
119
+ <div className="flex items-center gap-1 text-xs text-muted-foreground shrink-0">
120
+ <Minus className="size-3" />
121
+ <span>Not configured</span>
122
+ </div>
123
+ );
124
+ }
125
+ // Must be Testing state
113
126
  return (
114
- <div className="flex items-center gap-1 text-xs text-muted-foreground">
115
- <Minus className="size-3" />
116
- <span>Not configured</span>
127
+ <div className="flex items-center gap-1 text-xs text-muted-foreground shrink-0">
128
+ <RotateCw className="size-3 animate-spin" />
129
+ <span>Testing...</span>
117
130
  </div>
118
131
  );
119
132
  }
120
133
  if (result.success) {
121
134
  return (
122
- <div className="flex items-center gap-1 text-xs text-green-600">
135
+ <div className="flex items-center gap-1 text-xs text-green-600 shrink-0">
123
136
  <CheckCircle className="size-3" />
124
137
  <span>Connected</span>
125
138
  </div>
@@ -131,20 +144,16 @@ function TestStatus({
131
144
  const errorHint = error?.hint;
132
145
  const errorType = error?.type ?? "unknown";
133
146
 
147
+ // Combine message and hint in a single line for consistent layout
148
+ const fullMessage = errorHint !== undefined ? `${errorMessage} — ${errorHint}` : errorMessage;
149
+
134
150
  return (
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
- )}
151
+ <div
152
+ className="flex items-center gap-1 text-xs text-red-600 shrink-0 max-w-[200px]"
153
+ title={error?.details ?? fullMessage}
154
+ >
155
+ {getErrorIcon(errorType)}
156
+ <span className="truncate">{errorMessage}</span>
148
157
  </div>
149
158
  );
150
159
  }
@@ -160,6 +169,13 @@ export function ProviderCard({
160
169
  }: ProviderCardProps): JSX.Element {
161
170
  const [showApiKey, setShowApiKey] = useState(false);
162
171
 
172
+ // Get docs URL: use provider.apiDocsUrl if configured, otherwise check KNOWN_PROVIDER_DOCS
173
+ const docsUrl =
174
+ provider.apiDocsUrl ??
175
+ Object.entries(KNOWN_PROVIDER_DOCS).find(([keyword]) =>
176
+ provider.name.toLowerCase().includes(keyword),
177
+ )?.[1];
178
+
163
179
  return (
164
180
  <div className="border rounded-lg p-4 flex flex-col gap-3 bg-card">
165
181
  <div className="flex items-start justify-between gap-2">
@@ -170,20 +186,17 @@ export function ProviderCard({
170
186
  : provider.name}
171
187
  </span>
172
188
  </div>
173
- {Object.entries(KNOWN_PROVIDER_DOCS).map(([keyword, url]) =>
174
- provider.name.toLowerCase().includes(keyword) ? (
175
- <a
176
- key={keyword}
177
- href={url}
178
- target="_blank"
179
- rel="noopener noreferrer"
180
- className="text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1 text-xs"
181
- title="View API documentation"
182
- >
183
- <ExternalLink className="size-3" />
184
- <span className="sr-only">Docs</span>
185
- </a>
186
- ) : null,
189
+ {docsUrl !== undefined && (
190
+ <a
191
+ href={docsUrl}
192
+ target="_blank"
193
+ rel="noopener noreferrer"
194
+ className="text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1 text-xs"
195
+ title="View API documentation"
196
+ >
197
+ <ExternalLink className="size-3" />
198
+ <span className="sr-only">Docs</span>
199
+ </a>
187
200
  )}
188
201
  </div>
189
202
 
@@ -207,9 +220,7 @@ export function ProviderCard({
207
220
  <span className="font-medium">Anthropic:</span>{" "}
208
221
  <span className="truncate">{provider.anthropicBaseUrl}</span>
209
222
  </div>
210
- {testResults && (
211
- <TestStatus result={testResults.anthropic.nonStreaming} isTesting={isTesting} />
212
- )}
223
+ {testResults && <TestStatus result={testResults.anthropic.nonStreaming} />}
213
224
  </div>
214
225
  )}
215
226
 
@@ -219,9 +230,7 @@ export function ProviderCard({
219
230
  <span className="font-medium">OpenAI:</span>{" "}
220
231
  <span className="truncate">{provider.openaiBaseUrl}</span>
221
232
  </div>
222
- {testResults && (
223
- <TestStatus result={testResults.openai.nonStreaming} isTesting={isTesting} />
224
- )}
233
+ {testResults && <TestStatus result={testResults.openai.nonStreaming} />}
225
234
  </div>
226
235
  )}
227
236
 
@@ -41,6 +41,7 @@ type ProviderFormProps = {
41
41
  model?: string;
42
42
  format: "anthropic" | "openai";
43
43
  baseUrl?: string;
44
+ apiDocsUrl?: string;
44
45
  }) => void;
45
46
  onCancel: () => void;
46
47
  };
@@ -51,6 +52,7 @@ export function ProviderForm({ provider, onSubmit, onCancel }: ProviderFormProps
51
52
  const [model, setModel] = useState(provider?.model ?? "");
52
53
  const [format, setFormat] = useState<"anthropic" | "openai">(provider?.format ?? "anthropic");
53
54
  const [baseUrl, setBaseUrl] = useState(provider?.baseUrl ?? "");
55
+ const [apiDocsUrl, setApiDocsUrl] = useState(provider?.apiDocsUrl ?? "");
54
56
  const [errors, setErrors] = useState<Record<string, string>>({});
55
57
  const [isSubmitting, setIsSubmitting] = useState(false);
56
58
 
@@ -69,6 +71,7 @@ export function ProviderForm({ provider, onSubmit, onCancel }: ProviderFormProps
69
71
  setModel(provider.model ?? "");
70
72
  setFormat(provider.format ?? "anthropic");
71
73
  setBaseUrl(provider.baseUrl ?? "");
74
+ setApiDocsUrl(provider.apiDocsUrl ?? "");
72
75
  setManualBaseUrlOverride(false);
73
76
  }
74
77
  }, [provider]);
@@ -135,6 +138,7 @@ export function ProviderForm({ provider, onSubmit, onCancel }: ProviderFormProps
135
138
  model: model.trim() || undefined,
136
139
  format,
137
140
  baseUrl: baseUrl.trim() || undefined,
141
+ apiDocsUrl: apiDocsUrl.trim() || undefined,
138
142
  });
139
143
  } finally {
140
144
  setIsSubmitting(false);
@@ -246,6 +250,23 @@ export function ProviderForm({ provider, onSubmit, onCancel }: ProviderFormProps
246
250
  <p className="text-xs text-muted-foreground">Base URL for the provider API.</p>
247
251
  </div>
248
252
 
253
+ <div className="space-y-2">
254
+ <label htmlFor="provider-api-docs-url" className="text-sm font-medium">
255
+ API Docs URL
256
+ </label>
257
+ <input
258
+ id="provider-api-docs-url"
259
+ type="text"
260
+ value={apiDocsUrl}
261
+ onChange={(e) => setApiDocsUrl(e.target.value)}
262
+ placeholder="https://api.example.com/docs"
263
+ 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"
264
+ />
265
+ <p className="text-xs text-muted-foreground">
266
+ Optional API documentation URL. If not set, uses known provider docs.
267
+ </p>
268
+ </div>
269
+
249
270
  <div className="flex gap-2 justify-end pt-2">
250
271
  <Button type="button" variant="outline" onClick={onCancel} disabled={isSubmitting}>
251
272
  Cancel
@@ -50,7 +50,12 @@ const AnthropicLogo = React.memo(
50
50
 
51
51
  const OpenAILogo = React.memo(
52
52
  ({ className }: { className?: string }): JSX.Element => (
53
- <img src={OpenAILogoSvg} alt="OpenAI" className={className} style={sizeStyle} />
53
+ <img
54
+ src={OpenAILogoSvg}
55
+ alt="OpenAI"
56
+ className={className}
57
+ style={{ ...sizeStyle, background: "white", borderRadius: "4px" }}
58
+ />
54
59
  ),
55
60
  );
56
61