coding-proxy 0.2.2__py3-none-any.whl → 0.2.3a2__py3-none-any.whl

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.
@@ -56,6 +56,18 @@ class QuotaGuard:
56
56
  """滑动窗口小时数(供基线加载使用)."""
57
57
  return self._window / 3600
58
58
 
59
+ @property
60
+ def _window_label(self) -> str:
61
+ """人类可读的窗口周期短标签."""
62
+ w = self._window
63
+ if w >= 86400 and w % 86400 == 0:
64
+ return f"{w // 86400}d"
65
+ if w >= 3600 and w % 3600 == 0:
66
+ return f"{w // 3600}h"
67
+ if w >= 60 and w % 60 == 0:
68
+ return f"{w // 60}m"
69
+ return f"{w}s"
70
+
59
71
  def can_use_primary(self) -> bool:
60
72
  """判断是否可以使用主后端."""
61
73
  if not self._enabled:
@@ -68,7 +80,8 @@ class QuotaGuard:
68
80
  ):
69
81
  self._transition_to(QuotaState.QUOTA_EXCEEDED)
70
82
  logger.warning(
71
- "Quota guard: WITHIN_QUOTA → EXCEEDED (%.1f%%)",
83
+ "Quota guard [%s]: WITHIN_QUOTA → EXCEEDED (%.1f%%)",
84
+ self._window_label,
72
85
  self._total / self._budget * 100,
73
86
  )
74
87
  return False
@@ -80,12 +93,18 @@ class QuotaGuard:
80
93
  and self._total < int(self._budget * self._threshold)
81
94
  ):
82
95
  self._transition_to(QuotaState.WITHIN_QUOTA)
83
- logger.info("Quota guard: EXCEEDED → WITHIN_QUOTA (usage dropped)")
96
+ logger.info(
97
+ "Quota guard [%s]: EXCEEDED → WITHIN_QUOTA (usage dropped)",
98
+ self._window_label,
99
+ )
84
100
  return True
85
101
  now = time.monotonic()
86
102
  if now - self._last_probe >= self._effective_probe_interval:
87
103
  self._last_probe = now
88
- logger.info("Quota guard: allowing probe request")
104
+ logger.info(
105
+ "Quota guard [%s]: allowing probe request",
106
+ self._window_label,
107
+ )
89
108
  return True
90
109
  return False
91
110
 
@@ -104,7 +123,10 @@ class QuotaGuard:
104
123
  with self._lock:
105
124
  if self._state == QuotaState.QUOTA_EXCEEDED:
106
125
  self._transition_to(QuotaState.WITHIN_QUOTA)
107
- logger.info("Quota guard: EXCEEDED → WITHIN_QUOTA (probe success)")
126
+ logger.info(
127
+ "Quota guard [%s]: EXCEEDED → WITHIN_QUOTA (probe success)",
128
+ self._window_label,
129
+ )
108
130
 
109
131
  def notify_cap_error(self, retry_after_seconds: float | None = None) -> None:
110
132
  """外部通知检测到用量上限错误.
@@ -125,7 +147,8 @@ class QuotaGuard:
125
147
  )
126
148
  self._cap_error_active = True
127
149
  logger.warning(
128
- "Quota guard: cap error detected → EXCEEDED (effective_probe=%ds)",
150
+ "Quota guard [%s]: cap error detected → EXCEEDED (effective_probe=%ds)",
151
+ self._window_label,
129
152
  int(self._effective_probe_interval),
130
153
  )
131
154
 
@@ -139,12 +162,17 @@ class QuotaGuard:
139
162
  self._total += total_tokens
140
163
  if vendor:
141
164
  logger.info(
142
- "Quota guard [%s]: loaded baseline %d tokens",
165
+ "Quota guard [%s/%s]: loaded baseline %d tokens",
143
166
  vendor,
167
+ self._window_label,
144
168
  total_tokens,
145
169
  )
146
170
  else:
147
- logger.info("Quota guard: loaded baseline %d tokens", total_tokens)
171
+ logger.info(
172
+ "Quota guard [%s]: loaded baseline %d tokens",
173
+ self._window_label,
174
+ total_tokens,
175
+ )
148
176
 
149
177
  def reset(self) -> None:
150
178
  """手动重置为 WITHIN_QUOTA 状态."""
@@ -152,7 +180,10 @@ class QuotaGuard:
152
180
  self._transition_to(QuotaState.WITHIN_QUOTA)
153
181
  self._entries.clear()
154
182
  self._total = 0
155
- logger.info("Quota guard: manually reset to WITHIN_QUOTA")
183
+ logger.info(
184
+ "Quota guard [%s]: manually reset to WITHIN_QUOTA",
185
+ self._window_label,
186
+ )
156
187
 
157
188
  def get_info(self) -> dict:
158
189
  """获取配额守卫状态信息."""
@@ -0,0 +1,916 @@
1
+ """Dashboard 路由 — 流量与用量可视化看板."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ from typing import Any
8
+
9
+ from fastapi import Request
10
+ from fastapi.responses import HTMLResponse, Response
11
+
12
+ from ..logging.db import TimePeriod
13
+
14
+
15
+ # ── Favicon (16×16, 蓝紫渐变) ────────────────────────────────────────────
16
+ def _build_favicon() -> bytes:
17
+ """程序化生成 16×16 ICO,蓝紫渐变与 Dashboard Logo 一致."""
18
+ import struct
19
+
20
+ width, height = 16, 16
21
+ pixel_rows: list[bytes] = []
22
+ for y in range(height - 1, -1, -1): # BMP bottom-up
23
+ row = bytearray()
24
+ for x in range(width):
25
+ t = (x + (height - 1 - y)) / (width + height - 2)
26
+ r = int(88 + (188 - 88) * t)
27
+ g = int(166 + (140 - 166) * t)
28
+ b = 255
29
+ row.extend([b, g, r, 255]) # BGRA
30
+ pixel_rows.append(bytes(row))
31
+
32
+ bmp_hdr = struct.pack(
33
+ "<IIIHHIIIIII", 40, width, height * 2, 1, 32, 0, 0, 0, 0, 0, 0
34
+ )
35
+ px_data = b"".join(pixel_rows)
36
+ mask_data = b"\x00\x00\x00\x00" * height
37
+ image_data = bmp_hdr + px_data + mask_data
38
+
39
+ ico_hdr = struct.pack("<HHH", 0, 1, 1)
40
+ dir_entry = struct.pack(
41
+ "<BBBBHHII", width, height, 0, 0, 1, 32, len(image_data), 22
42
+ )
43
+ return ico_hdr + dir_entry + image_data
44
+
45
+
46
+ _FAVICON_ICO: bytes = _build_favicon()
47
+
48
+ logger = logging.getLogger(__name__)
49
+
50
+ # ── HTML 模板 ──────────────────────────────────────────────────────────────
51
+
52
+ _DASHBOARD_HTML = """<!DOCTYPE html>
53
+ <html lang="zh-CN">
54
+ <head>
55
+ <meta charset="UTF-8" />
56
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
57
+ <title>Coding Proxy Dashboard</title>
58
+ <link rel="icon" type="image/x-icon" href="/favicon.ico" />
59
+ <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.min.js"></script>
60
+ <style>
61
+ :root {
62
+ --bg: #0d1117;
63
+ --bg-card: #161b22;
64
+ --bg-card-hover: #1c2128;
65
+ --border: #30363d;
66
+ --text-primary: #e6edf3;
67
+ --text-secondary: #8b949e;
68
+ --accent-blue: #58a6ff;
69
+ --accent-green: #3fb950;
70
+ --accent-yellow: #d29922;
71
+ --accent-red: #f85149;
72
+ --accent-purple: #bc8cff;
73
+ --accent-orange: #ffa657;
74
+ --radius: 8px;
75
+ --shadow: 0 1px 3px rgba(0,0,0,.4);
76
+ }
77
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
78
+ body {
79
+ background: var(--bg);
80
+ color: var(--text-primary);
81
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif;
82
+ font-size: 14px;
83
+ line-height: 1.5;
84
+ min-height: 100vh;
85
+ }
86
+ /* ── 头部 ── */
87
+ header {
88
+ background: var(--bg-card);
89
+ border-bottom: 1px solid var(--border);
90
+ padding: 14px 24px;
91
+ display: flex;
92
+ align-items: center;
93
+ justify-content: space-between;
94
+ position: sticky;
95
+ top: 0;
96
+ z-index: 100;
97
+ }
98
+ .header-left { display: flex; align-items: center; gap: 12px; }
99
+ .logo {
100
+ width: 28px; height: 28px;
101
+ background: linear-gradient(135deg, var(--accent-blue), var(--accent-purple));
102
+ border-radius: 6px;
103
+ display: flex; align-items: center; justify-content: center;
104
+ font-size: 16px; font-weight: bold; color: #fff;
105
+ }
106
+ h1 { font-size: 16px; font-weight: 600; color: var(--text-primary); }
107
+ .header-right { display: flex; align-items: center; gap: 12px; }
108
+ .badge {
109
+ font-size: 11px; padding: 2px 8px;
110
+ border-radius: 12px;
111
+ background: rgba(88,166,255,.15);
112
+ color: var(--accent-blue);
113
+ border: 1px solid rgba(88,166,255,.25);
114
+ }
115
+ .refresh-time { font-size: 12px; color: var(--text-secondary); }
116
+ .btn-refresh {
117
+ padding: 5px 12px; border-radius: var(--radius);
118
+ background: rgba(48,54,61,.6);
119
+ border: 1px solid var(--border);
120
+ color: var(--text-primary);
121
+ font-size: 12px; cursor: pointer;
122
+ transition: background .15s;
123
+ }
124
+ .btn-refresh:hover { background: var(--bg-card-hover); }
125
+ /* ── 主内容 ── */
126
+ main { padding: 20px 24px; max-width: 1400px; margin: 0 auto; }
127
+ /* ── KPI 卡片 ── */
128
+ .kpi-grid {
129
+ display: grid;
130
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
131
+ gap: 14px;
132
+ margin-bottom: 20px;
133
+ }
134
+ .kpi-card {
135
+ background: var(--bg-card);
136
+ border: 1px solid var(--border);
137
+ border-radius: var(--radius);
138
+ padding: 16px 20px;
139
+ box-shadow: var(--shadow);
140
+ transition: background .15s;
141
+ }
142
+ .kpi-card:hover { background: var(--bg-card-hover); }
143
+ .kpi-label { font-size: 12px; color: var(--text-secondary); margin-bottom: 6px; }
144
+ .kpi-value { font-size: 26px; font-weight: 700; line-height: 1.2; }
145
+ .kpi-sub { font-size: 12px; color: var(--text-secondary); margin-top: 4px; }
146
+ .kpi-delta { font-size: 12px; margin-top: 4px; }
147
+ .kpi-delta.up { color: var(--accent-green); }
148
+ .kpi-delta.down { color: var(--accent-red); }
149
+ .color-blue { color: var(--accent-blue); }
150
+ .color-green { color: var(--accent-green); }
151
+ .color-yellow { color: var(--accent-yellow); }
152
+ .color-red { color: var(--accent-red); }
153
+ .color-purple { color: var(--accent-purple); }
154
+ /* ── 图表网格 ── */
155
+ .charts-grid {
156
+ display: grid;
157
+ grid-template-columns: 1fr 2fr;
158
+ gap: 14px;
159
+ margin-bottom: 14px;
160
+ }
161
+ .charts-grid-3 {
162
+ display: grid;
163
+ grid-template-columns: 1fr 2fr;
164
+ gap: 14px;
165
+ margin-bottom: 14px;
166
+ }
167
+ @media (max-width: 900px) {
168
+ .charts-grid, .charts-grid-3 { grid-template-columns: 1fr; }
169
+ }
170
+ .card {
171
+ background: var(--bg-card);
172
+ border: 1px solid var(--border);
173
+ border-radius: var(--radius);
174
+ padding: 16px 20px;
175
+ box-shadow: var(--shadow);
176
+ }
177
+ .card-title {
178
+ font-size: 13px; font-weight: 600;
179
+ color: var(--text-secondary);
180
+ text-transform: uppercase;
181
+ letter-spacing: .5px;
182
+ margin-bottom: 14px;
183
+ display: flex; align-items: center; justify-content: space-between;
184
+ }
185
+ .chart-wrap { position: relative; height: 220px; }
186
+ .chart-wrap-lg { position: relative; height: 240px; }
187
+ /* ── 供应商状态 ── */
188
+ .vendor-list { display: flex; flex-direction: column; gap: 10px; }
189
+ .vendor-item {
190
+ display: flex; align-items: center; justify-content: space-between;
191
+ padding: 10px 12px;
192
+ background: rgba(255,255,255,.03);
193
+ border: 1px solid var(--border);
194
+ border-radius: 6px;
195
+ }
196
+ .vendor-name { font-weight: 600; font-size: 13px; min-width: 80px; }
197
+ .vendor-badges { display: flex; gap: 6px; flex-wrap: wrap; }
198
+ .status-badge {
199
+ font-size: 11px; padding: 2px 7px;
200
+ border-radius: 10px;
201
+ font-weight: 500;
202
+ }
203
+ .sb-ok { background: rgba(63,185,80,.15); color: var(--accent-green); border: 1px solid rgba(63,185,80,.25); }
204
+ .sb-warn { background: rgba(210,153,34,.15); color: var(--accent-yellow); border: 1px solid rgba(210,153,34,.25); }
205
+ .sb-err { background: rgba(248,81,73,.15); color: var(--accent-red); border: 1px solid rgba(248,81,73,.25); }
206
+ .sb-info { background: rgba(88,166,255,.15); color: var(--accent-blue); border: 1px solid rgba(88,166,255,.25); }
207
+ .quota-bar-wrap { flex: 1; margin: 0 12px; max-width: 120px; }
208
+ .quota-bar-bg {
209
+ height: 5px; border-radius: 3px;
210
+ background: rgba(255,255,255,.08);
211
+ overflow: hidden;
212
+ }
213
+ .quota-bar-fill { height: 100%; border-radius: 3px; transition: width .4s; }
214
+ .quota-pct { font-size: 11px; color: var(--text-secondary); margin-top: 2px; text-align: right; }
215
+ /* ── 故障转移表 ── */
216
+ .ft-table-wrap { overflow-x: auto; }
217
+ table { width: 100%; border-collapse: collapse; }
218
+ th {
219
+ text-align: left; font-size: 12px; color: var(--text-secondary);
220
+ font-weight: 500; padding: 6px 10px;
221
+ border-bottom: 1px solid var(--border);
222
+ }
223
+ td { padding: 8px 10px; font-size: 13px; border-bottom: 1px solid rgba(48,54,61,.5); }
224
+ tr:last-child td { border-bottom: none; }
225
+ tr:hover td { background: rgba(255,255,255,.02); }
226
+ .tag-vendor {
227
+ display: inline-block;
228
+ font-size: 11px; padding: 1px 7px;
229
+ border-radius: 10px;
230
+ background: rgba(188,140,255,.15);
231
+ color: var(--accent-purple);
232
+ border: 1px solid rgba(188,140,255,.25);
233
+ }
234
+ .arrow { color: var(--text-secondary); margin: 0 4px; }
235
+ /* ── 时间区间选择栏 ── */
236
+ .time-range-bar {
237
+ display: flex; align-items: center; gap: 8px;
238
+ margin-bottom: 16px; flex-wrap: wrap;
239
+ }
240
+ .time-range-label { font-size: 13px; color: var(--text-secondary); }
241
+ .range-btn {
242
+ padding: 4px 14px; border-radius: 14px;
243
+ background: rgba(48,54,61,.6);
244
+ border: 1px solid var(--border);
245
+ color: var(--text-secondary);
246
+ font-size: 12px; cursor: pointer;
247
+ transition: background .15s, color .15s, border-color .15s;
248
+ }
249
+ .range-btn:hover { background: var(--bg-card-hover); color: var(--text-primary); }
250
+ .range-btn.active {
251
+ background: rgba(88,166,255,.15);
252
+ border-color: rgba(88,166,255,.5);
253
+ color: var(--accent-blue);
254
+ }
255
+ .range-custom { display: none; align-items: center; gap: 6px; }
256
+ .range-custom.visible { display: flex; }
257
+ .range-date {
258
+ padding: 3px 8px; border-radius: var(--radius);
259
+ background: var(--bg-card); border: 1px solid var(--border);
260
+ color: var(--text-primary); font-size: 12px;
261
+ color-scheme: dark;
262
+ }
263
+ .range-sep { font-size: 12px; color: var(--text-secondary); }
264
+ /* ── 空态 ── */
265
+ .empty {
266
+ text-align: center; padding: 32px;
267
+ color: var(--text-secondary); font-size: 13px;
268
+ }
269
+ /* ── 加载态 ── */
270
+ .loading { opacity: .4; pointer-events: none; }
271
+ </style>
272
+ </head>
273
+ <body>
274
+ <header>
275
+ <div class="header-left">
276
+ <div class="logo">C</div>
277
+ <h1>Coding Proxy Dashboard</h1>
278
+ <span class="badge" id="version-badge">v-.-.-</span>
279
+ </div>
280
+ <div class="header-right">
281
+ <span class="refresh-time" id="refresh-time">正在加载…</span>
282
+ <button class="btn-refresh" onclick="refresh()">⟳ 刷新</button>
283
+ </div>
284
+ </header>
285
+
286
+ <main>
287
+ <!-- 时间区间选择器 -->
288
+ <div class="time-range-bar">
289
+ <span class="time-range-label">数据时间区间:</span>
290
+ <button class="range-btn active" onclick="setTimeRange(7, this)">最近一周</button>
291
+ <button class="range-btn" onclick="setTimeRange(30, this)">最近一月</button>
292
+ <button class="range-btn" onclick="setTimeRange(0, this)">自选区间</button>
293
+ <div class="range-custom" id="range-custom">
294
+ <input type="date" id="range-start" class="range-date" onchange="applyCustomRange()" />
295
+ <span class="range-sep">–</span>
296
+ <input type="date" id="range-end" class="range-date" onchange="applyCustomRange()" />
297
+ </div>
298
+ </div>
299
+
300
+ <!-- KPI 卡片 -->
301
+ <div class="kpi-grid" id="kpi-grid">
302
+ <div class="kpi-card"><div class="kpi-label">今日请求数</div><div class="kpi-value color-blue" id="kpi-req-today">–</div><div class="kpi-sub" id="kpi-req-week">本周 –</div></div>
303
+ <div class="kpi-card"><div class="kpi-label">今日 Token 总量</div><div class="kpi-value color-purple" id="kpi-tok-today">–</div><div class="kpi-sub" id="kpi-tok-week">本周 –</div></div>
304
+ <div class="kpi-card"><div class="kpi-label">今日输出 Token</div><div class="kpi-value color-green" id="kpi-out-today">–</div><div class="kpi-sub" id="kpi-out-week">本周 –</div></div>
305
+ <div class="kpi-card"><div class="kpi-label">今日费用估算</div><div class="kpi-value color-yellow" id="kpi-cost-today">–</div><div class="kpi-sub" id="kpi-cost-week">本周 –</div></div>
306
+ <div class="kpi-card"><div class="kpi-label">故障转移(今日)</div><div class="kpi-value color-red" id="kpi-fo-today">–</div><div class="kpi-sub" id="kpi-fo-week">本周 –</div></div>
307
+ <div class="kpi-card"><div class="kpi-label">平均延迟(今日)</div><div class="kpi-value" id="kpi-lat-today">–</div><div class="kpi-sub" id="kpi-lat-week">本周 –</div></div>
308
+ </div>
309
+
310
+ <!-- 供应商状态 + 趋势折线图 -->
311
+ <div class="charts-grid">
312
+ <div class="card">
313
+ <div class="card-title">供应商状态</div>
314
+ <div class="vendor-list" id="vendor-list">
315
+ <div class="empty">加载中…</div>
316
+ </div>
317
+ </div>
318
+ <div class="card">
319
+ <div class="card-title" id="title-timeline">近 7 天请求量趋势</div>
320
+ <div class="chart-wrap-lg">
321
+ <canvas id="chart-timeline"></canvas>
322
+ </div>
323
+ </div>
324
+ </div>
325
+
326
+ <!-- 供应商分布 + Token 量趋势 -->
327
+ <div class="charts-grid-3">
328
+ <div class="card">
329
+ <div class="card-title" id="title-vendor-dist">供应商请求分布(近 7 天)</div>
330
+ <div class="chart-wrap">
331
+ <canvas id="chart-vendor-dist"></canvas>
332
+ </div>
333
+ </div>
334
+ <div class="card">
335
+ <div class="card-title" id="title-token-timeline">近 7 天 Token 量趋势</div>
336
+ <div class="chart-wrap-lg">
337
+ <canvas id="chart-token-timeline"></canvas>
338
+ </div>
339
+ </div>
340
+ </div>
341
+
342
+ <!-- 故障转移明细表 -->
343
+ <div class="card">
344
+ <div class="card-title">故障转移明细</div>
345
+ <div class="ft-table-wrap">
346
+ <table>
347
+ <thead>
348
+ <tr>
349
+ <th>来源供应商</th>
350
+ <th>目标供应商</th>
351
+ <th>次数</th>
352
+ </tr>
353
+ </thead>
354
+ <tbody id="ft-tbody">
355
+ <tr><td colspan="3" class="empty">加载中…</td></tr>
356
+ </tbody>
357
+ </table>
358
+ </div>
359
+ </div>
360
+ </main>
361
+
362
+ <script>
363
+ // ── 颜色配置 ──────────────────────────────────────────────
364
+ const VENDOR_COLORS = [
365
+ '#58a6ff','#bc8cff','#3fb950','#ffa657','#f85149',
366
+ '#79c0ff','#d2a8ff','#56d364','#ffb77c','#ff7b72',
367
+ ];
368
+ const TOKEN_COLORS = {
369
+ input: '#58a6ff',
370
+ output: '#3fb950',
371
+ cache_creation: '#d29922',
372
+ cache_read: '#bc8cff',
373
+ };
374
+
375
+ // ── 工具函数 ──────────────────────────────────────────────
376
+ function fmtTokens(n) {
377
+ if (!n) return '0';
378
+ if (n >= 1e9) return (n/1e9).toFixed(2).replace(/\\.?0+$/,'') + 'B';
379
+ if (n >= 1e6) return (n/1e6).toFixed(2).replace(/\\.?0+$/,'') + 'M';
380
+ if (n >= 1e3) return (n/1e3).toFixed(1).replace(/\\.?0+$/,'') + 'K';
381
+ return String(n);
382
+ }
383
+ function fmtNum(n) { return n == null ? '–' : n.toLocaleString(); }
384
+ function now() {
385
+ return new Date().toLocaleTimeString('zh-CN', {hour:'2-digit',minute:'2-digit',second:'2-digit'});
386
+ }
387
+
388
+ // ── Chart.js 全局默认 ─────────────────────────────────────
389
+ Chart.defaults.color = '#8b949e';
390
+ Chart.defaults.borderColor = '#30363d';
391
+ Chart.defaults.font.family = '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
392
+ Chart.defaults.font.size = 12;
393
+
394
+ // ── 图表实例 ──────────────────────────────────────────────
395
+ let chartTimeline = null;
396
+ let chartVendorDist = null;
397
+ let chartTokenTimeline = null;
398
+
399
+ function destroyCharts() {
400
+ [chartTimeline, chartVendorDist, chartTokenTimeline].forEach(c => c && c.destroy());
401
+ chartTimeline = chartVendorDist = chartTokenTimeline = null;
402
+ }
403
+
404
+ // ── 数据拉取 ──────────────────────────────────────────────
405
+ async function fetchJSON(url) {
406
+ const r = await fetch(url);
407
+ if (!r.ok) throw new Error(r.status);
408
+ return r.json();
409
+ }
410
+
411
+ // ── KPI 更新 ──────────────────────────────────────────────
412
+ function updateKPI(summary) {
413
+ const t = summary.today, w = summary.week, m = summary.month;
414
+
415
+ document.getElementById('kpi-req-today').textContent = fmtNum(t.requests);
416
+ document.getElementById('kpi-req-week').textContent = '本周 ' + fmtNum(w.requests);
417
+
418
+ const tokT = t.tokens, tokW = w.tokens;
419
+ const totalT = tokT.input + tokT.output + tokT.cache_creation + tokT.cache_read;
420
+ const totalW = tokW.input + tokW.output + tokW.cache_creation + tokW.cache_read;
421
+ document.getElementById('kpi-tok-today').textContent = fmtTokens(totalT);
422
+ document.getElementById('kpi-tok-week').textContent = '本周 ' + fmtTokens(totalW);
423
+
424
+ document.getElementById('kpi-out-today').textContent = fmtTokens(tokT.output);
425
+ document.getElementById('kpi-out-week').textContent = '本周 ' + fmtTokens(tokW.output);
426
+
427
+ document.getElementById('kpi-cost-today').textContent = t.cost || '–';
428
+ document.getElementById('kpi-cost-week').textContent = '本周 ' + (w.cost || '–');
429
+
430
+ document.getElementById('kpi-fo-today').textContent = fmtNum(t.failovers);
431
+ document.getElementById('kpi-fo-week').textContent = '本周 ' + fmtNum(w.failovers);
432
+
433
+ document.getElementById('kpi-lat-today').textContent = t.avg_duration_ms ? t.avg_duration_ms + 'ms' : '–';
434
+ document.getElementById('kpi-lat-week').textContent = '本周 ' + (w.avg_duration_ms ? w.avg_duration_ms + 'ms' : '–');
435
+ }
436
+
437
+ // ── 供应商状态 ────────────────────────────────────────────
438
+ function cbStateClass(state) {
439
+ if (!state) return 'sb-ok';
440
+ const s = state.toUpperCase();
441
+ if (s === 'OPEN') return 'sb-err';
442
+ if (s === 'HALF_OPEN') return 'sb-warn';
443
+ return 'sb-ok';
444
+ }
445
+ function cbStateLabel(state) {
446
+ if (!state) return 'CLOSED';
447
+ const s = state.toUpperCase();
448
+ if (s === 'OPEN') return '熔断';
449
+ if (s === 'HALF_OPEN') return '半开';
450
+ return '正常';
451
+ }
452
+ function quotaClass(pct) {
453
+ if (pct >= 90) return 'sb-err';
454
+ if (pct >= 70) return 'sb-warn';
455
+ return 'sb-ok';
456
+ }
457
+ function quotaBarColor(pct) {
458
+ if (pct >= 90) return 'var(--accent-red)';
459
+ if (pct >= 70) return 'var(--accent-yellow)';
460
+ return 'var(--accent-green)';
461
+ }
462
+
463
+ function updateVendorStatus(status) {
464
+ const tiers = status.tiers || [];
465
+ const list = document.getElementById('vendor-list');
466
+ if (!tiers.length) {
467
+ list.innerHTML = '<div class="empty">无供应商数据</div>';
468
+ return;
469
+ }
470
+ list.innerHTML = tiers.map(tier => {
471
+ const cb = tier.circuit_breaker || {};
472
+ const qg = tier.quota_guard || {};
473
+ const wqg = tier.weekly_quota_guard || {};
474
+ const cbClass = cbStateClass(cb.state);
475
+ const cbLabel = cbStateLabel(cb.state);
476
+ const pct = qg.usage_percent != null ? Math.round(qg.usage_percent) : null;
477
+ const wpct = wqg.usage_percent != null ? Math.round(wqg.usage_percent) : null;
478
+
479
+ let quotaHTML = '';
480
+ if (pct != null) {
481
+ quotaHTML += `
482
+ <span class="status-badge ${quotaClass(pct)}">日配额 ${pct}%</span>
483
+ <div class="quota-bar-wrap">
484
+ <div class="quota-bar-bg"><div class="quota-bar-fill" style="width:${Math.min(pct,100)}%;background:${quotaBarColor(pct)}"></div></div>
485
+ </div>`;
486
+ }
487
+ if (wpct != null) {
488
+ quotaHTML += `<span class="status-badge ${quotaClass(wpct)}">周配额 ${wpct}%</span>`;
489
+ }
490
+
491
+ const rlInfo = tier.rate_limit || {};
492
+ const rlHtml = rlInfo.limited
493
+ ? `<span class="status-badge sb-warn">限速中</span>` : '';
494
+
495
+ return `<div class="vendor-item">
496
+ <span class="vendor-name">${tier.name}</span>
497
+ <div class="vendor-badges">
498
+ <span class="status-badge ${cbClass}">${cbLabel}${cb.failure_count ? ' ×'+cb.failure_count : ''}</span>
499
+ ${quotaHTML}
500
+ ${rlHtml}
501
+ </div>
502
+ </div>`;
503
+ }).join('');
504
+ }
505
+
506
+ // ── 时序折线图 ────────────────────────────────────────────
507
+ function buildTimeline(rows) {
508
+ // 按 vendor 分组,按 date 汇总
509
+ const vendorDateMap = {}; // vendor → {date → count}
510
+ const allDates = new Set();
511
+ for (const r of rows) {
512
+ const v = r.vendor, d = r.date;
513
+ if (!v || !d) continue;
514
+ if (!vendorDateMap[v]) vendorDateMap[v] = {};
515
+ vendorDateMap[v][d] = (vendorDateMap[v][d] || 0) + (r.total_requests || 0);
516
+ allDates.add(d);
517
+ }
518
+ const dates = [...allDates].sort();
519
+ const vendors = Object.keys(vendorDateMap).sort();
520
+
521
+ const datasets = vendors.map((v, i) => ({
522
+ label: v,
523
+ data: dates.map(d => vendorDateMap[v][d] || 0),
524
+ borderColor: VENDOR_COLORS[i % VENDOR_COLORS.length],
525
+ backgroundColor: VENDOR_COLORS[i % VENDOR_COLORS.length] + '22',
526
+ fill: true,
527
+ tension: .3,
528
+ pointRadius: 3,
529
+ pointHoverRadius: 5,
530
+ }));
531
+
532
+ if (chartTimeline) chartTimeline.destroy();
533
+ const ctx = document.getElementById('chart-timeline').getContext('2d');
534
+ chartTimeline = new Chart(ctx, {
535
+ type: 'line',
536
+ data: { labels: dates, datasets },
537
+ options: {
538
+ responsive: true, maintainAspectRatio: false,
539
+ interaction: { mode: 'index', intersect: false },
540
+ plugins: { legend: { position: 'bottom', labels: { boxWidth: 10, padding: 12 } } },
541
+ scales: {
542
+ x: { grid: { color: '#30363d' } },
543
+ y: { grid: { color: '#30363d' }, beginAtZero: true, ticks: { precision: 0 } },
544
+ },
545
+ },
546
+ });
547
+ }
548
+
549
+ // ── 供应商分布环形图 ──────────────────────────────────────
550
+ function buildVendorDist(rows) {
551
+ const vendorTotals = {};
552
+ for (const r of rows) {
553
+ const v = r.vendor;
554
+ if (!v) continue;
555
+ vendorTotals[v] = (vendorTotals[v] || 0) + (r.total_requests || 0);
556
+ }
557
+ const labels = Object.keys(vendorTotals).sort((a,b) => vendorTotals[b]-vendorTotals[a]);
558
+ const data = labels.map(v => vendorTotals[v]);
559
+
560
+ if (chartVendorDist) chartVendorDist.destroy();
561
+ const ctx = document.getElementById('chart-vendor-dist').getContext('2d');
562
+ if (!labels.length) {
563
+ ctx.canvas.parentElement.innerHTML = '<div class="empty">暂无数据</div>';
564
+ return;
565
+ }
566
+ chartVendorDist = new Chart(ctx, {
567
+ type: 'doughnut',
568
+ data: {
569
+ labels,
570
+ datasets: [{
571
+ data,
572
+ backgroundColor: labels.map((_,i) => VENDOR_COLORS[i % VENDOR_COLORS.length]),
573
+ borderWidth: 0,
574
+ hoverOffset: 6,
575
+ }],
576
+ },
577
+ options: {
578
+ responsive: true, maintainAspectRatio: false,
579
+ plugins: {
580
+ legend: { position: 'bottom', labels: { boxWidth: 10, padding: 10 } },
581
+ tooltip: { callbacks: { label: ctx => ` ${ctx.label}: ${ctx.raw} 次` } },
582
+ },
583
+ },
584
+ });
585
+ }
586
+
587
+ // ── Token 量趋势折线图 ────────────────────────────────────
588
+ function buildTokenTimeline(rows) {
589
+ // 按 vendor 分组,按 date 汇总 token 总量
590
+ const vendorDateMap = {}; // vendor → {date → total_tokens}
591
+ const allDates = new Set();
592
+ for (const r of rows) {
593
+ const v = r.vendor, d = r.date;
594
+ if (!v || !d) continue;
595
+ if (!vendorDateMap[v]) vendorDateMap[v] = {};
596
+ const total = (r.total_input || 0) + (r.total_output || 0)
597
+ + (r.total_cache_creation || 0) + (r.total_cache_read || 0);
598
+ vendorDateMap[v][d] = (vendorDateMap[v][d] || 0) + total;
599
+ allDates.add(d);
600
+ }
601
+ const dates = [...allDates].sort();
602
+ const vendors = Object.keys(vendorDateMap).sort();
603
+
604
+ if (chartTokenTimeline) chartTokenTimeline.destroy();
605
+ const ctx = document.getElementById('chart-token-timeline').getContext('2d');
606
+ if (!dates.length) {
607
+ ctx.canvas.parentElement.innerHTML = '<div class="empty">暂无数据</div>';
608
+ return;
609
+ }
610
+
611
+ const datasets = vendors.map((v, i) => ({
612
+ label: v,
613
+ data: dates.map(d => vendorDateMap[v][d] || 0),
614
+ borderColor: VENDOR_COLORS[i % VENDOR_COLORS.length],
615
+ backgroundColor: VENDOR_COLORS[i % VENDOR_COLORS.length] + '22',
616
+ fill: true,
617
+ tension: .3,
618
+ pointRadius: 3,
619
+ pointHoverRadius: 5,
620
+ }));
621
+
622
+ chartTokenTimeline = new Chart(ctx, {
623
+ type: 'line',
624
+ data: { labels: dates, datasets },
625
+ options: {
626
+ responsive: true, maintainAspectRatio: false,
627
+ interaction: { mode: 'index', intersect: false },
628
+ plugins: { legend: { position: 'bottom', labels: { boxWidth: 10, padding: 12 } } },
629
+ scales: {
630
+ x: { grid: { color: '#30363d' } },
631
+ y: {
632
+ grid: { color: '#30363d' }, beginAtZero: true,
633
+ ticks: { callback: v => fmtTokens(v) },
634
+ },
635
+ },
636
+ },
637
+ });
638
+ }
639
+
640
+ // ── 故障转移明细表 ────────────────────────────────────────
641
+ function updateFtTable(failoverStats) {
642
+ const tbody = document.getElementById('ft-tbody');
643
+ if (!failoverStats || !failoverStats.length) {
644
+ tbody.innerHTML = '<tr><td colspan="3" class="empty">暂无故障转移记录</td></tr>';
645
+ return;
646
+ }
647
+ tbody.innerHTML = failoverStats.map(r => `
648
+ <tr>
649
+ <td><span class="tag-vendor">${r.failover_from || 'unknown'}</span></td>
650
+ <td><span class="tag-vendor">${r.vendor || ''}</span></td>
651
+ <td>${fmtNum(r.count)}</td>
652
+ </tr>`).join('');
653
+ }
654
+
655
+ // ── 时间区间控制 ──────────────────────────────────────────
656
+ let currentDays = 7;
657
+
658
+ function setTimeRange(days, btn) {
659
+ currentDays = days;
660
+ document.querySelectorAll('.range-btn').forEach(b => b.classList.remove('active'));
661
+ if (btn) btn.classList.add('active');
662
+ const customEl = document.getElementById('range-custom');
663
+ if (days === 0) {
664
+ customEl.classList.add('visible');
665
+ // 初始化日期:默认今天往前 7 天
666
+ const today = new Date();
667
+ const weekAgo = new Date(today);
668
+ weekAgo.setDate(weekAgo.getDate() - 6);
669
+ document.getElementById('range-end').value = today.toISOString().slice(0, 10);
670
+ document.getElementById('range-start').value = weekAgo.toISOString().slice(0, 10);
671
+ applyCustomRange();
672
+ } else {
673
+ customEl.classList.remove('visible');
674
+ refresh();
675
+ }
676
+ }
677
+
678
+ function applyCustomRange() {
679
+ const s = document.getElementById('range-start').value;
680
+ const e = document.getElementById('range-end').value;
681
+ if (!s || !e) return;
682
+ const startMs = new Date(s).getTime();
683
+ const endMs = new Date(e).getTime();
684
+ if (endMs < startMs) return;
685
+ currentDays = Math.ceil((endMs - startMs) / 86400000) + 1;
686
+ refresh();
687
+ }
688
+
689
+ function rangeLabel() {
690
+ if (currentDays <= 7) return '近 7 天';
691
+ if (currentDays <= 30) return '近 30 天';
692
+ return '近 ' + currentDays + ' 天';
693
+ }
694
+
695
+ function updateChartTitles(days) {
696
+ const label = days <= 7 ? '近 7 天' : (days <= 30 ? '近 30 天' : '近 ' + days + ' 天');
697
+ const tl = document.getElementById('title-timeline');
698
+ const tt = document.getElementById('title-token-timeline');
699
+ const vd = document.getElementById('title-vendor-dist');
700
+ if (tl) tl.textContent = label + ' 请求量趋势';
701
+ if (tt) tt.textContent = label + ' Token 量趋势';
702
+ if (vd) vd.textContent = '供应商请求分布(' + label + ')';
703
+ }
704
+
705
+ // ── 主刷新逻辑 ────────────────────────────────────────────
706
+ let refreshing = false;
707
+ async function refresh() {
708
+ if (refreshing) return;
709
+ refreshing = true;
710
+ document.getElementById('refresh-time').textContent = '刷新中…';
711
+ try {
712
+ const days = currentDays > 0 ? currentDays : 7;
713
+ const [summary, timeline, status] = await Promise.all([
714
+ fetchJSON('/api/dashboard/summary'),
715
+ fetchJSON('/api/dashboard/timeline?days=' + days),
716
+ fetchJSON('/api/status'),
717
+ ]);
718
+
719
+ // 版本号
720
+ if (summary.version) {
721
+ document.getElementById('version-badge').textContent = 'v' + summary.version;
722
+ }
723
+
724
+ updateKPI(summary);
725
+ updateVendorStatus(status);
726
+ updateChartTitles(days);
727
+
728
+ const rows = timeline.rows || [];
729
+ buildTimeline(rows);
730
+ buildVendorDist(rows);
731
+ buildTokenTimeline(rows);
732
+
733
+ updateFtTable(summary.failover_stats || []);
734
+
735
+ document.getElementById('refresh-time').textContent = '上次刷新: ' + now();
736
+ } catch (e) {
737
+ console.error('Dashboard refresh error:', e);
738
+ document.getElementById('refresh-time').textContent = '刷新失败 ' + now();
739
+ } finally {
740
+ refreshing = false;
741
+ }
742
+ }
743
+
744
+ // 页面加载 + 每 30 秒自动刷新
745
+ refresh();
746
+ setInterval(refresh, 30000);
747
+ </script>
748
+ </body>
749
+ </html>
750
+ """
751
+
752
+
753
+ # ── 数据计算工具 ──────────────────────────────────────────────────────────
754
+
755
+
756
+ def _sum_rows(rows: list[dict]) -> dict:
757
+ """汇总一组查询行的关键指标."""
758
+ total_requests = 0
759
+ total_input = 0
760
+ total_output = 0
761
+ total_cache_creation = 0
762
+ total_cache_read = 0
763
+ total_failovers = 0
764
+ weighted_duration = 0.0
765
+
766
+ for row in rows:
767
+ req = row.get("total_requests") or 0
768
+ total_requests += req
769
+ total_input += row.get("total_input") or 0
770
+ total_output += row.get("total_output") or 0
771
+ total_cache_creation += row.get("total_cache_creation") or 0
772
+ total_cache_read += row.get("total_cache_read") or 0
773
+ total_failovers += row.get("total_failovers") or 0
774
+ weighted_duration += (row.get("avg_duration_ms") or 0) * req
775
+
776
+ avg_ms = int(weighted_duration / total_requests) if total_requests else 0
777
+ return {
778
+ "requests": total_requests,
779
+ "tokens": {
780
+ "input": total_input,
781
+ "output": total_output,
782
+ "cache_creation": total_cache_creation,
783
+ "cache_read": total_cache_read,
784
+ },
785
+ "failovers": total_failovers,
786
+ "avg_duration_ms": avg_ms,
787
+ }
788
+
789
+
790
+ def _compute_cost_str(rows: list[dict], pricing_table: Any) -> str:
791
+ """计算多行的总费用字符串."""
792
+ if pricing_table is None:
793
+ return "–"
794
+ cost_totals: dict = {}
795
+ for row in rows:
796
+ vendor = str(row.get("vendor") or "")
797
+ model = str(row.get("model_served") or "")
798
+ cv = pricing_table.compute_cost(
799
+ vendor,
800
+ model,
801
+ row.get("total_input") or 0,
802
+ row.get("total_output") or 0,
803
+ row.get("total_cache_creation") or 0,
804
+ row.get("total_cache_read") or 0,
805
+ )
806
+ if cv is not None:
807
+ cur = cv.currency
808
+ cost_totals[cur] = cost_totals.get(cur, 0.0) + cv.amount
809
+
810
+ if not cost_totals:
811
+ return "–"
812
+ return " + ".join(f"{cur.symbol}{amt:.4f}" for cur, amt in cost_totals.items())
813
+
814
+
815
+ # ── 路由注册 ──────────────────────────────────────────────────────────────
816
+
817
+
818
+ def register_dashboard_routes(app: Any) -> None:
819
+ """注册 Dashboard 相关路由."""
820
+ from .. import __version__
821
+
822
+ @app.get("/favicon.ico", include_in_schema=False)
823
+ async def favicon() -> Response:
824
+ """返回内嵌 favicon."""
825
+ return Response(content=_FAVICON_ICO, media_type="image/x-icon")
826
+
827
+ @app.get("/dashboard", response_class=HTMLResponse, include_in_schema=False)
828
+ async def dashboard() -> HTMLResponse:
829
+ """返回 Dashboard HTML 页面."""
830
+ return HTMLResponse(content=_DASHBOARD_HTML)
831
+
832
+ @app.get("/api/dashboard/summary")
833
+ async def dashboard_summary(request: Request) -> Response:
834
+ """返回 Dashboard 汇总数据(今日 / 本周 / 本月)."""
835
+ token_logger = getattr(request.app.state, "token_logger", None)
836
+ pricing_table = getattr(request.app.state, "pricing_table", None)
837
+
838
+ if token_logger is None:
839
+ return Response(
840
+ content=b'{"error":"token_logger not available"}',
841
+ status_code=503,
842
+ media_type="application/json",
843
+ )
844
+
845
+ try:
846
+ # 今日(最近 1 天)
847
+ today_rows = await token_logger.query_usage(period=TimePeriod.DAY, count=1)
848
+ # 本周(最近 7 天)
849
+ week_rows = await token_logger.query_usage(period=TimePeriod.DAY, count=7)
850
+ # 本月(最近 30 天)
851
+ month_rows = await token_logger.query_usage(period=TimePeriod.DAY, count=30)
852
+ # 故障转移(最近 7 天)
853
+ failover_stats = await token_logger.query_failover_stats(days=7)
854
+ except Exception as exc:
855
+ logger.error("dashboard_summary query error: %s", exc, exc_info=True)
856
+ return Response(
857
+ content=b'{"error":"query failed"}',
858
+ status_code=500,
859
+ media_type="application/json",
860
+ )
861
+
862
+ today = _sum_rows(today_rows)
863
+ week = _sum_rows(week_rows)
864
+ month = _sum_rows(month_rows)
865
+
866
+ today["cost"] = _compute_cost_str(today_rows, pricing_table)
867
+ week["cost"] = _compute_cost_str(week_rows, pricing_table)
868
+ month["cost"] = _compute_cost_str(month_rows, pricing_table)
869
+
870
+ result = {
871
+ "version": __version__,
872
+ "today": today,
873
+ "week": week,
874
+ "month": month,
875
+ "failover_stats": failover_stats,
876
+ }
877
+ return Response(
878
+ content=json.dumps(result, ensure_ascii=False).encode(),
879
+ status_code=200,
880
+ media_type="application/json",
881
+ )
882
+
883
+ @app.get("/api/dashboard/timeline")
884
+ async def dashboard_timeline(request: Request, days: int = 7) -> Response:
885
+ """返回按天分组的时序数据(用于图表绘制)."""
886
+ token_logger = getattr(request.app.state, "token_logger", None)
887
+
888
+ if token_logger is None:
889
+ return Response(
890
+ content=b'{"error":"token_logger not available"}',
891
+ status_code=503,
892
+ media_type="application/json",
893
+ )
894
+
895
+ days = max(1, min(days, 90)) # 限制范围 1~90 天
896
+
897
+ try:
898
+ rows = await token_logger.query_usage(period=TimePeriod.DAY, count=days)
899
+ except Exception as exc:
900
+ logger.error("dashboard_timeline query error: %s", exc, exc_info=True)
901
+ return Response(
902
+ content=b'{"error":"query failed"}',
903
+ status_code=500,
904
+ media_type="application/json",
905
+ )
906
+
907
+ result = {
908
+ "period": "day",
909
+ "count": days,
910
+ "rows": rows,
911
+ }
912
+ return Response(
913
+ content=json.dumps(result, ensure_ascii=False).encode(),
914
+ status_code=200,
915
+ media_type="application/json",
916
+ )
@@ -42,9 +42,10 @@ def normalize_anthropic_request(body: dict[str, Any]) -> NormalizationResult:
42
42
  处理策略:
43
43
  1. 移除供应商私有块(如 server_tool_use_delta)
44
44
  2. 重写无效/非标准的 tool_use / tool_result ID
45
- 3. **剥离错位的 tool_result 块**:Anthropic API 要求 ``tool_result`` 只能出现在
45
+ 3. **重定位错位的 tool_result 块**:Anthropic API 要求 ``tool_result`` 只能出现在
46
46
  ``user`` 消息中。当检测到非 user 消息中存在 ``tool_result`` 时,
47
- 直接剥离以防止上游返回 ``400 invalid_request_error`` 导致全链路降级失败。
47
+ 将其重定位到紧邻的下一个 user 消息中,以保持 ``tool_use`` / ``tool_result``
48
+ 配对关系,防止上游返回 ``400 invalid_request_error``。
48
49
  """
49
50
  normalized = copy.deepcopy(body)
50
51
  adaptations: list[str] = []
@@ -57,8 +58,9 @@ def normalize_anthropic_request(body: dict[str, Any]) -> NormalizationResult:
57
58
  normalized_counter += 1
58
59
  return f"toolu_normalized_{normalized_counter}"
59
60
 
60
- # 收集本轮被剥离的 misplaced tool_result 信息(用于汇总日志)
61
- stripped_misplaced: list[
61
+ # 收集本轮被重定位的 misplaced tool_result 块及日志信息
62
+ relocated_results: list[tuple[int, dict[str, Any]]] = [] # (source_msg_idx, block)
63
+ relocated_log_info: list[
62
64
  tuple[str, int, int, str]
63
65
  ] = [] # (role, msg_idx, blk_idx, tool_use_id)
64
66
 
@@ -138,18 +140,25 @@ def normalize_anthropic_request(body: dict[str, Any]) -> NormalizationResult:
138
140
  return None
139
141
  return normalized_block
140
142
 
141
- # tool_result 出现在非 user 消息中(如 assistant)—— 收集信息,稍后汇总日志。
143
+ # tool_result 出现在非 user 消息中(如 assistant)—— 重定位到紧邻的 user 消息。
142
144
  # 典型触发场景:跨供应商降级时(如 Zhipu GLM → Anthropic),
143
145
  # GLM-5 在 assistant 响应中同时包含 tool_use 和 tool_result 内容块,
144
146
  # Claude Code 将此响应当作对话历史存储后,tool_result 出现在 assistant 角色消息中。
145
- # Anthropic API 严格要求 tool_result 只能出现在 user 消息中,因此必须剥离。
146
- adaptations.append("misplaced_tool_result_stripped")
147
- stripped_misplaced.append(
147
+ # 直接剥离会导致 tool_use 成为孤儿块(无配对 tool_result),触发上游 400 错误。
148
+ # 因此改为重定位:将 tool_result 移至紧邻的下一个 user 消息中。
149
+ normalized_block = dict(block)
150
+ tool_use_id = normalized_block.get("tool_use_id")
151
+ if isinstance(tool_use_id, str) and tool_use_id in tool_id_map:
152
+ normalized_block["tool_use_id"] = tool_id_map[tool_use_id]
153
+ adaptations.append("tool_result_tool_use_id_rewritten")
154
+ adaptations.append("misplaced_tool_result_relocated")
155
+ relocated_results.append((message_index, normalized_block))
156
+ relocated_log_info.append(
148
157
  (
149
158
  message_role,
150
159
  message_index,
151
160
  block_index,
152
- block.get("tool_use_id", "N/A"),
161
+ normalized_block.get("tool_use_id", "N/A"),
153
162
  )
154
163
  )
155
164
  return None
@@ -175,11 +184,40 @@ def normalize_anthropic_request(body: dict[str, Any]) -> NormalizationResult:
175
184
  new_content.append(normalized_block)
176
185
  message["content"] = new_content
177
186
 
178
- # ── 汇总日志:misplaced tool_result 剥离 ──────────────────
179
- # 将逐块的 WARNING 合并为单条日志,并对同一 tool_use_id 跨请求去重:
180
- # 首次出现 WARNING(含因果上下文),后续 → DEBUG。
181
- if stripped_misplaced:
182
- _emit_misplaced_tool_result_summary(stripped_misplaced)
187
+ # ── 重定位 misplaced tool_result 到紧邻的 user 消息 ──────────
188
+ # 按源消息索引降序处理,避免插入新消息时索引偏移。
189
+ messages_list = normalized.get("messages", [])
190
+ for source_idx, result_block in sorted(
191
+ relocated_results, key=lambda x: x[0], reverse=True
192
+ ):
193
+ target_user_idx = None
194
+ for j in range(source_idx + 1, len(messages_list)):
195
+ if (
196
+ isinstance(messages_list[j], dict)
197
+ and messages_list[j].get("role") == "user"
198
+ ):
199
+ target_user_idx = j
200
+ break
201
+ if target_user_idx is not None:
202
+ # 追加到已有 user 消息的 content 末尾
203
+ target_content = messages_list[target_user_idx].get("content")
204
+ if isinstance(target_content, list):
205
+ target_content.append(result_block)
206
+ else:
207
+ messages_list[target_user_idx]["content"] = [result_block]
208
+ else:
209
+ # 无后续 user 消息:插入一条合成 user 消息
210
+ messages_list.insert(
211
+ source_idx + 1,
212
+ {
213
+ "role": "user",
214
+ "content": [result_block],
215
+ },
216
+ )
217
+
218
+ # ── 汇总日志:misplaced tool_result 重定位 ──────────────────
219
+ if relocated_log_info:
220
+ _emit_misplaced_tool_result_summary(relocated_log_info)
183
221
 
184
222
  return NormalizationResult(
185
223
  body=normalized,
@@ -191,10 +229,10 @@ def normalize_anthropic_request(body: dict[str, Any]) -> NormalizationResult:
191
229
  def _emit_misplaced_tool_result_summary(
192
230
  stripped: list[tuple[str, int, int, str]],
193
231
  ) -> None:
194
- """为被剥离的 misplaced tool_result 输出汇总日志.
232
+ """为被重定位的 misplaced tool_result 输出汇总日志.
195
233
 
196
234
  策略:
197
- - 将同一次请求中的多个剥离事件合并为单条日志
235
+ - 将同一次请求中的多个重定位事件合并为单条日志
198
236
  - 首次出现的 tool_use_id → WARNING(含完整因果上下文)
199
237
  - 同一 tool_use_id 在后续请求中再次出现 → DEBUG(避免日志噪声)
200
238
 
@@ -228,13 +266,13 @@ def _emit_misplaced_tool_result_summary(
228
266
  )
229
267
  new_count = sum(1 for _, _, _, tid in stripped if tid in new_id_set)
230
268
  logger.warning(
231
- "Vendor degradation adaptation: stripped %d misplaced tool_result block(s) "
232
- "from non-user message(s). Cause: cross-vendor conversation history contains "
233
- "tool_result blocks in assistant messages (typical when GLM-5 includes tool "
234
- "results inline in responses). Anthropic API strictly requires tool_result "
235
- "only in user messages, so these blocks are stripped to prevent 400 "
236
- "invalid_request_error. Affected: %s. Subsequent occurrences of these "
237
- "tool_use_ids will be logged at DEBUG level.",
269
+ "Vendor degradation adaptation: relocated %d misplaced tool_result block(s) "
270
+ "from non-user message(s) to adjacent user message(s). Cause: cross-vendor "
271
+ "conversation history contains tool_result blocks in assistant messages "
272
+ "(typical when GLM-5 includes tool results inline in responses). Anthropic "
273
+ "API requires tool_result only in user messages, so these blocks are "
274
+ "relocated to maintain tool_use/tool_result pairing. Affected: %s. "
275
+ "Subsequent occurrences of these tool_use_ids will be logged at DEBUG level.",
238
276
  new_count,
239
277
  positions,
240
278
  )
@@ -243,7 +281,7 @@ def _emit_misplaced_tool_result_summary(
243
281
  # 已报告过的:DEBUG
244
282
  known_count = sum(1 for _, _, _, tid in stripped if tid in known_id_set)
245
283
  logger.debug(
246
- "Normalization: stripped %d previously reported misplaced tool_result "
284
+ "Normalization: relocated %d previously reported misplaced tool_result "
247
285
  "block(s) (tool_use_ids: %s)",
248
286
  known_count,
249
287
  ", ".join(sorted(known_id_set)),
@@ -353,3 +353,7 @@ def register_all_routes(
353
353
  register_admin_routes(app, router)
354
354
  if reauth_coordinator:
355
355
  register_reauth_routes(app, reauth_coordinator)
356
+
357
+ from .dashboard import register_dashboard_routes
358
+
359
+ register_dashboard_routes(app)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: coding-proxy
3
- Version: 0.2.2
3
+ Version: 0.2.3a2
4
4
  Summary: A High-Availability, Transparent, and Smart Multi-Vendor Proxy for Claude Code. Support Claude Plans, GitHub Copilot, Google Antigravity, ZAI/GLM, MiniMax, Qwen, Xiaomi, Kimi, Doubao...
5
5
  Project-URL: Source Code, https://github.com/ThreeFish-AI/coding-proxy
6
6
  Project-URL: User Guide, https://github.com/ThreeFish-AI/coding-proxy/blob/master/docs/user-guide.md
@@ -46,7 +46,7 @@ coding/proxy/routing/circuit_breaker.py,sha256=hOyoY_RWB5dTVbipyuiWfAelnva3cZRLb
46
46
  coding/proxy/routing/error_classifier.py,sha256=oDxp69sCb-pdfe1hWce29A9tsN3jAbhqfl2fRECBtCo,3747
47
47
  coding/proxy/routing/executor.py,sha256=vnXmmaWw0w49HhKsNine37Hnye04NSZ0vc9ghMzqLdQ,30268
48
48
  coding/proxy/routing/model_mapper.py,sha256=b72CGRdusLAwoq7p8-8TWAv9-Zv8BozssiZC8XnNTx4,3574
49
- coding/proxy/routing/quota_guard.py,sha256=TRJs3mjdRMc_u0HCw-hZn--osha_m11dEtAUxOO2P5A,6806
49
+ coding/proxy/routing/quota_guard.py,sha256=EX4ROzBxKeK_o850mGNY1c21n96f-UxifZeUJD_yJQs,7740
50
50
  coding/proxy/routing/rate_limit.py,sha256=i2cCqtbmXrP6wjRUTWXLP4DdYwVj0C4o59QdYGkPQj0,5122
51
51
  coding/proxy/routing/retry.py,sha256=KEYVToBkBOtXdqY7He70RrtVWFqIpnOQf2VVksdLrlk,2386
52
52
  coding/proxy/routing/router.py,sha256=x5x6cjL5BOnRXZZuzK3GCnNtaMVjw75xw2OQ-DoO9_s,5830
@@ -56,10 +56,11 @@ coding/proxy/routing/usage_parser.py,sha256=j4G0ArFduQ2D4Yeuad94DVlwdc-JvSh7SJLV
56
56
  coding/proxy/routing/usage_recorder.py,sha256=pObOrX2yIITTiyojl1fJcqO0yWWpbP4KqsJvFdmlt04,6273
57
57
  coding/proxy/server/__init__.py,sha256=KeH7mEu36v9v27m3VgeSxSeFz9sLTJrxS_EVATJ7Vks,20
58
58
  coding/proxy/server/app.py,sha256=kRGgb772dZu8200LPnn7Nt0IU5oagcbBf_Vc5ynxxzE,5599
59
+ coding/proxy/server/dashboard.py,sha256=r2_4-7Jn4hpYhfU7G-3W8FGOgTaRKWQj3PsN8SFajcE,34859
59
60
  coding/proxy/server/factory.py,sha256=w8VFvxoogw9K9sO8MlT6bIP7xM7mR6sCohrZle9y_Gg,9985
60
- coding/proxy/server/request_normalizer.py,sha256=F_mgxxuA3i7cN1CEWlto7Mm9VT_QG2QNspiUWeYVBpM,10511
61
+ coding/proxy/server/request_normalizer.py,sha256=XUqpZP42_DJmsoX9aMFVk7oLWDeG3UgXdmCv0A76FN4,12334
61
62
  coding/proxy/server/responses.py,sha256=i0ugnLRNOdRYGHEWxkwsxR35ChmdMQsSaD8AjRluTn4,2167
62
- coding/proxy/server/routes.py,sha256=_CkeOEcnMe21RcLZ-raxgknUbdclV-Uf9f_B2Xz9_a8,13325
63
+ coding/proxy/server/routes.py,sha256=jFp42r5e5GY4Ny4XoXAhzH700Db0VaiTcahgpnRturU,13415
63
64
  coding/proxy/streaming/__init__.py,sha256=0al5TC-zJt8Bz0CibTmd8WPza-Cs-qcuZWvT2fKPqHI,23
64
65
  coding/proxy/streaming/anthropic_compat.py,sha256=cycYiJ-glN88OmfLTR7FroHRethONlEcK0PT78KqGbA,21202
65
66
  coding/proxy/vendors/__init__.py,sha256=fehSpQ2-kUlZY6N8cNMGVJYMXspQmi3V4e0FgC4Ay4k,1011
@@ -79,8 +80,8 @@ coding/proxy/vendors/native_anthropic.py,sha256=SxtM71PDci0gqLqiwCrFnT410SnSoD7F
79
80
  coding/proxy/vendors/token_manager.py,sha256=s10t4Com0jNnKGkPyJ_HpG5SjHrCEJvfArEOAaPKA_k,4189
80
81
  coding/proxy/vendors/xiaomi.py,sha256=E-GcmJBZh7GOtDFonxZmlf0hKRhrlrXzL0IxHFRYcRo,860
81
82
  coding/proxy/vendors/zhipu.py,sha256=3j_rqNFu1CX-B5ugtrL6Y1OeWSy9yiqsVa9Bi1ssaAA,1062
82
- coding_proxy-0.2.2.dist-info/METADATA,sha256=4eYTzmULjk3sHv87lB4NnGv4RQ2I_VNADAeLVJGB9mQ,10817
83
- coding_proxy-0.2.2.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
84
- coding_proxy-0.2.2.dist-info/entry_points.txt,sha256=moIVzt5ho0Wk9B47LOo2SEAbhzuDDHWi-EfM30U0XBg,54
85
- coding_proxy-0.2.2.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
86
- coding_proxy-0.2.2.dist-info/RECORD,,
83
+ coding_proxy-0.2.3a2.dist-info/METADATA,sha256=YINKvqEA_wFWpR0rK9CHjf96doxPiIDI9WMeS_URVSY,10819
84
+ coding_proxy-0.2.3a2.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
85
+ coding_proxy-0.2.3a2.dist-info/entry_points.txt,sha256=moIVzt5ho0Wk9B47LOo2SEAbhzuDDHWi-EfM30U0XBg,54
86
+ coding_proxy-0.2.3a2.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
87
+ coding_proxy-0.2.3a2.dist-info/RECORD,,