@tonyclaw/llm-inspector 1.8.0 → 1.9.1

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 (30) hide show
  1. package/.output/nitro.json +1 -1
  2. package/.output/public/assets/{index-DH3FOgcK.js → index-BmEH5jeO.js} +18 -18
  3. package/.output/public/assets/{index-BLVa7n9b.css → index-DdJSLfxK.css} +1 -1
  4. package/.output/public/assets/{main-Beo3LJDa.js → main-GVpFMVGE.js} +1 -1
  5. package/.output/server/_ssr/{index-HkueJ4Un.mjs → index-D0d6QxPt.mjs} +85 -31
  6. package/.output/server/_ssr/index.mjs +2 -2
  7. package/.output/server/_ssr/{router-DTswxb7l.mjs → router-D9uLXa9A.mjs} +287 -67
  8. package/.output/server/{_tanstack-start-manifest_v-DhUuivt-.mjs → _tanstack-start-manifest_v-ByfnNZV_.mjs} +1 -1
  9. package/.output/server/index.mjs +26 -26
  10. package/README.md +8 -209
  11. package/package.json +1 -1
  12. package/src/components/ProxyViewerContainer.tsx +10 -1
  13. package/src/components/providers/ProviderCard.tsx +19 -15
  14. package/src/components/providers/ProviderForm.tsx +21 -0
  15. package/src/components/proxy-viewer/LogEntry.tsx +7 -0
  16. package/src/components/proxy-viewer/ResponseView.tsx +32 -6
  17. package/src/components/proxy-viewer/StreamingChunkSequence.tsx +12 -3
  18. package/src/proxy/chunkStorage.ts +4 -6
  19. package/src/proxy/formats/anthropic/schemas.ts +9 -0
  20. package/src/proxy/formats/anthropic/stream.ts +11 -0
  21. package/src/proxy/formats/openai/stream.ts +15 -0
  22. package/src/proxy/handler.ts +44 -27
  23. package/src/proxy/logIndex.ts +73 -7
  24. package/src/proxy/logger.ts +62 -6
  25. package/src/proxy/providers.ts +5 -0
  26. package/src/proxy/schemas.ts +2 -0
  27. package/src/proxy/socketTracker.ts +90 -36
  28. package/src/proxy/store.ts +32 -18
  29. package/src/routes/api/providers.$providerId.ts +1 -0
  30. package/src/routes/api/providers.ts +2 -0
@@ -97,54 +97,54 @@ const headers = ((m) => function headersRouteRule(event) {
97
97
  }
98
98
  });
99
99
  const assets = {
100
+ "/assets/index-DdJSLfxK.css": {
101
+ "type": "text/css; charset=utf-8",
102
+ "etag": '"10da0-LYeZ5d/vwqh4bAnuP/9hr6Wka6g"',
103
+ "mtime": "2026-06-04T06:33:11.914Z",
104
+ "size": 69024,
105
+ "path": "../public/assets/index-DdJSLfxK.css"
106
+ },
100
107
  "/assets/alibaba-TTwafVwX.svg": {
101
108
  "type": "image/svg+xml",
102
109
  "etag": '"171b-6dyV5K8QjiaY35sN9qNprh9zDIs"',
103
- "mtime": "2026-06-04T01:19:08.282Z",
110
+ "mtime": "2026-06-04T06:33:11.914Z",
104
111
  "size": 5915,
105
112
  "path": "../public/assets/alibaba-TTwafVwX.svg"
106
113
  },
114
+ "/assets/qwen-CONDcHqt.png": {
115
+ "type": "image/png",
116
+ "etag": '"572c3-cdJAPaHdOvFCGzuaQjagdgOu6XE"',
117
+ "mtime": "2026-06-04T06:33:11.914Z",
118
+ "size": 357059,
119
+ "path": "../public/assets/qwen-CONDcHqt.png"
120
+ },
107
121
  "/assets/minimax-BPMzvuL-.jpeg": {
108
122
  "type": "image/jpeg",
109
123
  "etag": '"1b06-IwivU89ko5UTMUM1/t7hn4sQK9A"',
110
- "mtime": "2026-06-04T01:19:08.282Z",
124
+ "mtime": "2026-06-04T06:33:11.914Z",
111
125
  "size": 6918,
112
126
  "path": "../public/assets/minimax-BPMzvuL-.jpeg"
113
127
  },
114
- "/assets/main-Beo3LJDa.js": {
128
+ "/assets/main-GVpFMVGE.js": {
115
129
  "type": "text/javascript; charset=utf-8",
116
- "etag": '"50591-/XG/Oh/RlDy7LRgwa0Uqfltq6fM"',
117
- "mtime": "2026-06-04T01:19:08.282Z",
130
+ "etag": '"50591-hoJGkyrSE5hjQqC3+NP6Sje9YUs"',
131
+ "mtime": "2026-06-04T06:33:11.914Z",
118
132
  "size": 329105,
119
- "path": "../public/assets/main-Beo3LJDa.js"
133
+ "path": "../public/assets/main-GVpFMVGE.js"
120
134
  },
121
135
  "/assets/zhipuai-BPNAnxo-.svg": {
122
136
  "type": "image/svg+xml",
123
137
  "etag": '"2bf8-hNaLCTi89nOFCsIIfWpP/jrfo0s"',
124
- "mtime": "2026-06-04T01:19:08.282Z",
138
+ "mtime": "2026-06-04T06:33:11.914Z",
125
139
  "size": 11256,
126
140
  "path": "../public/assets/zhipuai-BPNAnxo-.svg"
127
141
  },
128
- "/assets/qwen-CONDcHqt.png": {
129
- "type": "image/png",
130
- "etag": '"572c3-cdJAPaHdOvFCGzuaQjagdgOu6XE"',
131
- "mtime": "2026-06-04T01:19:08.282Z",
132
- "size": 357059,
133
- "path": "../public/assets/qwen-CONDcHqt.png"
134
- },
135
- "/assets/index-BLVa7n9b.css": {
136
- "type": "text/css; charset=utf-8",
137
- "etag": '"10ce0-rnZGppItQl8rOmyih342MZyZcI0"',
138
- "mtime": "2026-06-04T01:19:08.282Z",
139
- "size": 68832,
140
- "path": "../public/assets/index-BLVa7n9b.css"
141
- },
142
- "/assets/index-DH3FOgcK.js": {
142
+ "/assets/index-BmEH5jeO.js": {
143
143
  "type": "text/javascript; charset=utf-8",
144
- "etag": '"8421c-gZ3NyuThEQLW3CYFr8ZxAzJ1zq0"',
145
- "mtime": "2026-06-04T01:19:08.283Z",
146
- "size": 541212,
147
- "path": "../public/assets/index-DH3FOgcK.js"
144
+ "etag": '"84a50-Thjv3JmUhqN6aR4NbGQypCxkmgY"',
145
+ "mtime": "2026-06-04T06:33:11.914Z",
146
+ "size": 543312,
147
+ "path": "../public/assets/index-BmEH5jeO.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.8.0",
3
+ "version": "1.9.1",
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",
@@ -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
  };
@@ -169,6 +169,13 @@ export function ProviderCard({
169
169
  }: ProviderCardProps): JSX.Element {
170
170
  const [showApiKey, setShowApiKey] = useState(false);
171
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
+
172
179
  return (
173
180
  <div className="border rounded-lg p-4 flex flex-col gap-3 bg-card">
174
181
  <div className="flex items-start justify-between gap-2">
@@ -179,20 +186,17 @@ export function ProviderCard({
179
186
  : provider.name}
180
187
  </span>
181
188
  </div>
182
- {Object.entries(KNOWN_PROVIDER_DOCS).map(([keyword, url]) =>
183
- provider.name.toLowerCase().includes(keyword) ? (
184
- <a
185
- key={keyword}
186
- href={url}
187
- target="_blank"
188
- rel="noopener noreferrer"
189
- className="text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1 text-xs"
190
- title="View API documentation"
191
- >
192
- <ExternalLink className="size-3" />
193
- <span className="sr-only">Docs</span>
194
- </a>
195
- ) : 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>
196
200
  )}
197
201
  </div>
198
202
 
@@ -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
@@ -182,6 +182,12 @@ export const LogEntry = memo(function LogEntry({
182
182
 
183
183
  <TabsContent value="raw">
184
184
  <div className="px-4 py-3 space-y-3">
185
+ {log.error !== undefined && log.error !== null && (
186
+ <div className="rounded border border-destructive/50 bg-destructive/10 p-3 text-xs">
187
+ <div className="font-semibold text-destructive mb-1">SSE Error</div>
188
+ <div className="text-muted-foreground font-mono">{log.error}</div>
189
+ </div>
190
+ )}
185
191
  <div className="flex justify-end">
186
192
  <CopyButton
187
193
  text={log.responseText}
@@ -215,6 +221,7 @@ export const LogEntry = memo(function LogEntry({
215
221
  cacheCreationInputTokens={log.cacheCreationInputTokens}
216
222
  cacheReadInputTokens={log.cacheReadInputTokens}
217
223
  apiFormat={log.apiFormat}
224
+ error={log.error}
218
225
  />
219
226
  </div>
220
227
  </TabsContent>
@@ -19,6 +19,8 @@ export type ResponseViewProps = {
19
19
  cacheCreationInputTokens?: number | null;
20
20
  cacheReadInputTokens?: number | null;
21
21
  apiFormat?: "anthropic" | "openai" | "unknown";
22
+ /** SSE error message from streaming response */
23
+ error?: string | null;
22
24
  };
23
25
 
24
26
  function getStatusClasses(category: StatusCategory): string {
@@ -95,8 +97,9 @@ export function ResponseView({
95
97
  cacheCreationInputTokens,
96
98
  cacheReadInputTokens,
97
99
  apiFormat,
100
+ error,
98
101
  }: ResponseViewProps): JSX.Element {
99
- if (responseText === null) {
102
+ if (responseText === null && error === undefined) {
100
103
  return (
101
104
  <div className="flex items-center gap-2 py-3">
102
105
  <StatusIndicator status={responseStatus} />
@@ -105,18 +108,41 @@ export function ResponseView({
105
108
  );
106
109
  }
107
110
 
108
- const isError = responseStatus !== null && responseStatus >= 400;
111
+ const isHttpError = responseStatus !== null && responseStatus >= 400;
109
112
 
110
- if (isError) {
113
+ if (isHttpError) {
111
114
  return (
112
115
  <div className="space-y-2">
113
116
  <StatusIndicator status={responseStatus} />
114
- <ErrorResponseView text={responseText} />
117
+ <ErrorResponseView text={responseText ?? ""} />
118
+ {error !== undefined && error !== null && (
119
+ <div className="rounded border border-destructive/50 bg-destructive/10 p-3 text-xs">
120
+ <div className="font-semibold text-destructive mb-1">SSE Error</div>
121
+ <div className="text-muted-foreground font-mono">{error}</div>
122
+ </div>
123
+ )}
124
+ </div>
125
+ );
126
+ }
127
+
128
+ if (error !== undefined && error !== null) {
129
+ return (
130
+ <div className="space-y-2">
131
+ <StatusIndicator status={responseStatus} />
132
+ <div className="rounded border border-destructive/50 bg-destructive/10 p-3 text-xs">
133
+ <div className="font-semibold text-destructive mb-1">SSE Error</div>
134
+ <div className="text-muted-foreground font-mono">{error}</div>
135
+ </div>
136
+ {responseText !== null && (
137
+ <div className="mt-2">
138
+ <ErrorResponseView text={responseText} />
139
+ </div>
140
+ )}
115
141
  </div>
116
142
  );
117
143
  }
118
144
 
119
- const parsed = parseResponse(responseText, apiFormat);
145
+ const parsed = responseText !== null ? parseResponse(responseText, apiFormat) : null;
120
146
 
121
147
  if (parsed !== null) {
122
148
  return (
@@ -155,7 +181,7 @@ export function ResponseView({
155
181
  </span>
156
182
  )}
157
183
  </div>
158
- <MarkdownFallbackView text={responseText} />
184
+ <MarkdownFallbackView text={responseText ?? ""} />
159
185
  </div>
160
186
  );
161
187
  }
@@ -31,6 +31,7 @@ export function StreamingChunkSequence({
31
31
  useEffect(() => {
32
32
  if (!containerExpanded || chunkState.status !== "idle") return;
33
33
 
34
+ let cancelled = false;
34
35
  setChunkState({ status: "loading" });
35
36
 
36
37
  fetch(`/api/logs/${logId}/chunks`)
@@ -41,12 +42,20 @@ export function StreamingChunkSequence({
41
42
  return res.json();
42
43
  })
43
44
  .then((data: { chunks: StreamingChunk[]; truncated?: boolean }) => {
44
- setChunkState({ status: "success", chunks: data.chunks });
45
+ if (!cancelled) {
46
+ setChunkState({ status: "success", chunks: data.chunks });
47
+ }
45
48
  })
46
49
  .catch(() => {
47
- setChunkState({ status: "error", message: "Chunk data unavailable" });
50
+ if (!cancelled) {
51
+ setChunkState({ status: "error", message: "Chunk data unavailable" });
52
+ }
48
53
  });
49
- }, [containerExpanded, logId, chunkState.status]);
54
+
55
+ return () => {
56
+ cancelled = true;
57
+ };
58
+ }, [containerExpanded, logId]);
50
59
 
51
60
  const groups = useMemo<ChunkGroup[]>(() => {
52
61
  if (chunkState.status !== "success") return [];
@@ -8,6 +8,7 @@ import {
8
8
  copyFileSync,
9
9
  } from "node:fs";
10
10
  import { join, isAbsolute } from "node:path";
11
+ import { logger } from "./logger";
11
12
  import { z } from "zod";
12
13
  import { JsonValueSchema, type StreamingChunk } from "./schemas";
13
14
 
@@ -63,8 +64,7 @@ export function writeChunks(logId: number, chunks: StreamingChunk[], truncated?:
63
64
  try {
64
65
  mkdirSync(dir, { recursive: true });
65
66
  } catch (err) {
66
- // eslint-disable-next-line no-console
67
- console.error("[chunkStorage] Failed to create chunks directory:", err);
67
+ logger.error("[chunkStorage] Failed to create chunks directory:", String(err));
68
68
  }
69
69
 
70
70
  const data: StreamingChunksData = { chunks, truncated };
@@ -73,8 +73,7 @@ export function writeChunks(logId: number, chunks: StreamingChunk[], truncated?:
73
73
  try {
74
74
  writeFileSync(tempPath, JSON.stringify(data), "utf-8");
75
75
  } catch (err) {
76
- // eslint-disable-next-line no-console
77
- console.error("[chunkStorage] Failed to write chunks temp file:", err);
76
+ logger.error("[chunkStorage] Failed to write chunks temp file:", String(err));
78
77
  return targetPath;
79
78
  }
80
79
 
@@ -89,8 +88,7 @@ export function writeChunks(logId: number, chunks: StreamingChunk[], truncated?:
89
88
  copyFileSync(tempPath, targetPath);
90
89
  unlinkSync(tempPath);
91
90
  } catch (copyErr) {
92
- // eslint-disable-next-line no-console
93
- console.error("[chunkStorage] Failed to copy chunks file:", copyErr);
91
+ logger.error("[chunkStorage] Failed to copy chunks file:", String(copyErr));
94
92
  }
95
93
  }
96
94
 
@@ -196,6 +196,14 @@ const SsePingEvent = z.object({
196
196
  type: z.literal("ping"),
197
197
  });
198
198
 
199
+ const SseErrorEvent = z.object({
200
+ type: z.literal("error"),
201
+ error: z.object({
202
+ type: z.string(),
203
+ message: z.string(),
204
+ }),
205
+ });
206
+
199
207
  export const SseEventSchema = z.discriminatedUnion("type", [
200
208
  SseMessageStartEvent,
201
209
  SseContentBlockStartEvent,
@@ -204,6 +212,7 @@ export const SseEventSchema = z.discriminatedUnion("type", [
204
212
  SseMessageDeltaEvent,
205
213
  SseMessageStopEvent,
206
214
  SsePingEvent,
215
+ SseErrorEvent,
207
216
  ]);
208
217
 
209
218
  export type AnthropicRequest = z.infer<typeof AnthropicRequestSchema>;