@tonyclaw/llm-inspector 1.8.0 → 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.
- package/.output/nitro.json +1 -1
- package/.output/public/assets/{index-BLVa7n9b.css → index-DdJSLfxK.css} +1 -1
- package/.output/public/assets/{index-DH3FOgcK.js → index-DyKLPMPn.js} +18 -18
- package/.output/public/assets/{main-Beo3LJDa.js → main-Cu0oTDfX.js} +1 -1
- package/.output/server/_ssr/{index-HkueJ4Un.mjs → index-COIATcfa.mjs} +74 -28
- package/.output/server/_ssr/index.mjs +2 -2
- package/.output/server/_ssr/{router-DTswxb7l.mjs → router-CwmgKXBJ.mjs} +236 -65
- package/.output/server/{_tanstack-start-manifest_v-DhUuivt-.mjs → _tanstack-start-manifest_v-C7hQOzvX.mjs} +1 -1
- package/.output/server/index.mjs +23 -23
- package/README.md +8 -209
- package/package.json +1 -1
- package/src/components/ProxyViewerContainer.tsx +10 -1
- package/src/components/providers/ProviderCard.tsx +19 -15
- package/src/components/providers/ProviderForm.tsx +21 -0
- package/src/components/proxy-viewer/LogEntry.tsx +7 -0
- package/src/components/proxy-viewer/ResponseView.tsx +32 -6
- package/src/proxy/chunkStorage.ts +4 -6
- package/src/proxy/formats/anthropic/schemas.ts +9 -0
- package/src/proxy/formats/anthropic/stream.ts +11 -0
- package/src/proxy/formats/openai/stream.ts +15 -0
- package/src/proxy/handler.ts +34 -27
- package/src/proxy/logIndex.ts +52 -7
- package/src/proxy/logger.ts +60 -10
- package/src/proxy/providers.ts +5 -0
- package/src/proxy/schemas.ts +2 -0
- package/src/proxy/socketTracker.ts +90 -36
- package/src/proxy/store.ts +24 -14
- package/src/routes/api/providers.$providerId.ts +1 -0
- package/src/routes/api/providers.ts +2 -0
package/README.md
CHANGED
|
@@ -10,63 +10,22 @@ llm-inspector 是一个 LLM API 透明代理,能够捕获 AI 编程工具(Cl
|
|
|
10
10
|
|
|
11
11
|
---
|
|
12
12
|
|
|
13
|
-
##
|
|
13
|
+
## 文档目录
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
## 快速开始
|
|
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
|

|
|
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
|
-

|
|
147
|
-
|
|
148
|
-
流式响应显示:
|
|
149
|
-
- **SSE 数据块面板** — 懒加载,按索引分组
|
|
150
|
-
- **JSON 树查看器** — 类型颜色编码(字符串绿/数字黄/布尔蓝)
|
|
151
|
-
- **重建响应** — 从 SSE 片段重建完整 JSON
|
|
152
|
-
|
|
153
|
-
### 提供商测试 / Provider Testing
|
|
154
|
-
|
|
155
|
-

|
|
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
|
@@ -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
|
-
{
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
>
|
|
192
|
-
|
|
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
|
|
111
|
+
const isHttpError = responseStatus !== null && responseStatus >= 400;
|
|
109
112
|
|
|
110
|
-
if (
|
|
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
|
}
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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>;
|
|
@@ -133,6 +133,17 @@ export function extractAnthropicStream(
|
|
|
133
133
|
stopSequence = data.delta.stop_sequence ?? null;
|
|
134
134
|
outputTokens = data.usage.output_tokens;
|
|
135
135
|
log.outputTokens = outputTokens;
|
|
136
|
+
// Cache tokens may also be present in message_delta usage
|
|
137
|
+
if (data.usage.cache_creation_input_tokens !== undefined) {
|
|
138
|
+
log.cacheCreationInputTokens = data.usage.cache_creation_input_tokens;
|
|
139
|
+
}
|
|
140
|
+
if (data.usage.cache_read_input_tokens !== undefined) {
|
|
141
|
+
log.cacheReadInputTokens = data.usage.cache_read_input_tokens;
|
|
142
|
+
}
|
|
143
|
+
break;
|
|
144
|
+
case "error":
|
|
145
|
+
// Store error info on the log for display
|
|
146
|
+
log.error = data.error.message;
|
|
136
147
|
break;
|
|
137
148
|
case "content_block_stop":
|
|
138
149
|
case "message_stop":
|
|
@@ -42,6 +42,21 @@ export function extractOpenAIStream(
|
|
|
42
42
|
|
|
43
43
|
try {
|
|
44
44
|
const parsed: unknown = JSON.parse(dataStr);
|
|
45
|
+
// Check for error object in SSE stream before trying to parse as chunk
|
|
46
|
+
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
|
|
47
|
+
const errorDesc = Object.getOwnPropertyDescriptor(parsed, "error");
|
|
48
|
+
if (
|
|
49
|
+
errorDesc !== undefined &&
|
|
50
|
+
typeof errorDesc.value === "object" &&
|
|
51
|
+
errorDesc.value !== null
|
|
52
|
+
) {
|
|
53
|
+
const msgDesc = Object.getOwnPropertyDescriptor(errorDesc.value, "message");
|
|
54
|
+
if (msgDesc !== undefined && typeof msgDesc.value === "string") {
|
|
55
|
+
log.error = msgDesc.value;
|
|
56
|
+
}
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
45
60
|
const chunkResult = OpenAISSERawChunkSchema.safeParse(parsed);
|
|
46
61
|
if (!chunkResult.success) continue;
|
|
47
62
|
const chunk = chunkResult.data;
|