Skip to content

Commit a189dbd

Browse files
committed
merge(conflict): 解决 PR #247 与 feature/1.x.x 的合并冲突;
- pyproject.toml: 版本号取上游 0.4.1a8 - tests/test_router_executor.py: 保留两侧新增的 import 与测试类(TestBuildSemanticRejectionDiagnostic + TestSanitizeUserText + TestExtractSessionTitle) - uv.lock: 同步版本号并重新生成 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) Co-Authored-By: Aurelius Huang<threefish.ai@gmail.com>
2 parents 751a922 + b0d688e commit a189dbd

8 files changed

Lines changed: 381 additions & 26 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "coding-proxy"
3-
version = "0.4.1a6"
3+
version = "0.4.1a8"
44
description = "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..."
55
readme = "README.md"
66
requires-python = ">=3.12"

src/coding/proxy/logging/db.py

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,14 @@ def _local_month_udf(ts_str: str) -> str:
190190
);
191191
"""
192192

193+
_CREATE_SESSION_META = """
194+
CREATE TABLE IF NOT EXISTS session_meta (
195+
session_key TEXT PRIMARY KEY,
196+
title TEXT NOT NULL DEFAULT '',
197+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
198+
);
199+
"""
200+
193201
_CREATE_INDEXES = """
194202
CREATE INDEX IF NOT EXISTS idx_usage_ts ON usage_log(ts);
195203
CREATE INDEX IF NOT EXISTS idx_usage_vendor ON usage_log(vendor);
@@ -245,6 +253,7 @@ async def init(self) -> None:
245253
self._db.row_factory = aiosqlite.Row
246254
await self._db.execute("PRAGMA journal_mode=WAL")
247255
await self._db.executescript(_CREATE_TABLES)
256+
await self._db.executescript(_CREATE_SESSION_META)
248257
# 迁移必须在建索引之前执行,确保 vendor 列已存在
249258
await self._migrate_rename_backend_to_vendor()
250259
await self._migrate_add_failover_from()
@@ -316,6 +325,28 @@ async def _migrate_rename_backend_to_vendor(self) -> None:
316325
"Migration: renamed 'backend' column to 'vendor' in %s", table
317326
)
318327

328+
async def set_session_title(self, session_key: str, title: str) -> None:
329+
"""为新 session 设置标题(幂等,仅首次写入)."""
330+
if not self._db or not title or not session_key:
331+
return
332+
await self._db.execute(
333+
"INSERT OR IGNORE INTO session_meta (session_key, title) VALUES (?, ?)",
334+
(session_key, title),
335+
)
336+
await self._db.commit()
337+
338+
async def get_session_titles(self, session_keys: list[str]) -> dict[str, str]:
339+
"""批量查询 session 标题."""
340+
if not self._db or not session_keys:
341+
return {}
342+
placeholders = ",".join("?" for _ in session_keys)
343+
cursor = await self._db.execute(
344+
f"SELECT session_key, title FROM session_meta WHERE session_key IN ({placeholders})",
345+
session_keys,
346+
)
347+
rows = await cursor.fetchall()
348+
return {row["session_key"]: row["title"] for row in rows}
349+
319350
async def log(
320351
self,
321352
vendor: str,
@@ -621,7 +652,13 @@ async def query_recent_sessions(
621652
(cutoff_iso, limit),
622653
)
623654
rows = await cursor.fetchall()
624-
return [dict(row) for row in rows]
655+
sessions = [dict(row) for row in rows]
656+
if sessions:
657+
keys = [s["session_key"] for s in sessions]
658+
titles = await self.get_session_titles(keys)
659+
for s in sessions:
660+
s["title"] = titles.get(s["session_key"], "")
661+
return sessions
625662

626663
async def query_session_profile(self, session_key: str) -> dict | None:
627664
"""查询单个会话的完整聚合数据."""

src/coding/proxy/routing/executor.py

Lines changed: 142 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import json
1010
import logging
11+
import re
1112
import time
1213
from collections.abc import AsyncIterator
1314
from typing import Any
@@ -44,10 +45,136 @@
4445
# 向后兼容别名
4546
BackendResponse = VendorResponse
4647
NoCompatibleBackendError = NoCompatibleVendorError
47-
from ..compat.canonical import CompatibilityStatus, build_canonical_request
48+
from ..compat.canonical import (
49+
CanonicalPartType,
50+
CompatibilityStatus,
51+
build_canonical_request,
52+
)
53+
from ..model.compat import CanonicalRequest
4854

4955
logger = logging.getLogger(__name__)
5056

57+
_SESSION_TITLE_MAX_LEN = 30
58+
59+
# Claude Code 注入的"噪声"标签 — 系统级上下文,不应进入 Session 标题。
60+
# 这些标签由 CC harness 在首个 user 消息 content 中拼接,高度同质,
61+
# 直接用作标题会导致跨会话标题无差异化,丧失辨识度。
62+
_NOISE_TAG_PATTERN = re.compile(
63+
r"<(?P<tag>system-reminder|user-preferences|"
64+
r"local-command-stdout|local-command-stderr|"
65+
r"bash-input|bash-stdout|bash-stderr|"
66+
r"ide_selection|stdin|system_instruction)\b[^>]*>"
67+
r".*?</(?P=tag)>",
68+
flags=re.DOTALL | re.IGNORECASE,
69+
)
70+
71+
# Slash command 子标签:用于识别 /commit、/review 等命令式调用,
72+
# 合成"命令 + 参数"式标题。
73+
_CMD_NAME_PATTERN = re.compile(r"<command-name>(.*?)</command-name>", flags=re.DOTALL)
74+
_CMD_ARGS_PATTERN = re.compile(r"<command-args>(.*?)</command-args>", flags=re.DOTALL)
75+
# 残留 command-* 包裹标签清除(command-message/command-stdout 等次要标签)。
76+
_CMD_WRAPPER_PATTERN = re.compile(
77+
r"<command-[\w-]+>.*?</command-[\w-]+>", flags=re.DOTALL
78+
)
79+
80+
81+
def _sanitize_user_text(raw: str) -> str:
82+
"""剔除 Claude Code 注入的系统级 XML 块,还原真实用户输入。
83+
84+
处理顺序:
85+
1. Slash command 优先识别 — 若检测到 <command-name>,合成"命令 + 参数"
86+
式标题(因为残留文本通常为空,直接取标签内容更有意义)。
87+
2. 通用噪声剥离 — 移除已知白名单内的 system-reminder 等标签。
88+
3. 残留 command-* 包裹清除 — 兜底去除 command-message 等次要标签。
89+
4. 前后空白归一化 — 折叠连续空白为单空格,便于 30 字截断。
90+
"""
91+
if not raw:
92+
return ""
93+
94+
# 阶段一: slash command 短路
95+
cmd = _CMD_NAME_PATTERN.search(raw)
96+
if cmd:
97+
name = cmd.group(1).strip()
98+
args_match = _CMD_ARGS_PATTERN.search(raw)
99+
args = args_match.group(1).strip() if args_match else ""
100+
composed = f"{name} {args}".strip() if args else name
101+
if composed:
102+
return composed
103+
104+
# 阶段二: 通用噪声剥离
105+
cleaned = _NOISE_TAG_PATTERN.sub("", raw)
106+
cleaned = _CMD_WRAPPER_PATTERN.sub("", cleaned)
107+
108+
# 阶段三: 空白折叠
109+
return re.sub(r"\s+", " ", cleaned).strip()
110+
111+
112+
def _extract_session_title(request: CanonicalRequest) -> str:
113+
"""从规范化请求中提取首个用户消息文本作为 session 标题。
114+
115+
跳过 Claude Code 注入的系统级 XML 块(system-reminder、user-preferences 等),
116+
确保标题反映用户真实输入而非高同质化的系统模板。
117+
"""
118+
for part in request.messages:
119+
if part.role != "user" or part.type != CanonicalPartType.TEXT:
120+
continue
121+
cleaned = _sanitize_user_text(part.text)
122+
if cleaned:
123+
return cleaned[:_SESSION_TITLE_MAX_LEN]
124+
return ""
125+
126+
127+
def _build_semantic_rejection_diagnostic(body: dict[str, Any]) -> str:
128+
"""构建语义拒绝的请求体诊断上下文.
129+
130+
在 semantic rejection 日志中附加请求体的可疑参数快照,
131+
用于定位供应商参数校验失败的具体祸根参数。
132+
"""
133+
parts: list[str] = []
134+
# 顶层不兼容参数
135+
for key in ("thinking", "extended_thinking", "reasoning_effort"):
136+
if key in body:
137+
val = body[key]
138+
parts.append(f"{key}={val!r:.80}")
139+
# 会话历史中的 thinking blocks
140+
thinking_count = 0
141+
for msg in body.get("messages", []):
142+
content = msg.get("content")
143+
if not isinstance(content, list):
144+
continue
145+
for block in content:
146+
if isinstance(block, dict) and block.get("type") in (
147+
"thinking",
148+
"redacted_thinking",
149+
):
150+
thinking_count += 1
151+
if thinking_count:
152+
parts.append(f"thinking_blocks_in_history={thinking_count}")
153+
# cache_control 存在检测
154+
has_cc = False
155+
for section in (
156+
body.get("system", []) if isinstance(body.get("system"), list) else [],
157+
*(
158+
m.get("content", [])
159+
for m in body.get("messages", [])
160+
if isinstance(m.get("content"), list)
161+
),
162+
body.get("tools", []),
163+
):
164+
if isinstance(section, list):
165+
for item in section:
166+
if isinstance(item, dict) and "cache_control" in item:
167+
has_cc = True
168+
break
169+
if has_cc:
170+
break
171+
if has_cc:
172+
parts.append("cache_control_fields=present")
173+
# 模型 + 消息数
174+
parts.append(f"model={body.get('model', 'N/A')}")
175+
parts.append(f"messages={len(body.get('messages', []))}")
176+
return f" [{', '.join(parts)}]" if parts else ""
177+
51178

52179
def _build_semantic_rejection_diagnostic(body: dict[str, Any]) -> str:
53180
"""构建语义拒绝的请求体诊断上下文.
@@ -460,10 +587,16 @@ async def execute_stream(
460587
failed_tier_name: str | None = None
461588
request_caps = build_request_capabilities(body)
462589
canonical_request = build_canonical_request(body, headers)
463-
session_record = await self._session_mgr.get_or_create_record(
590+
session_record, is_new_session = await self._session_mgr.get_or_create_record(
464591
canonical_request.session_key,
465592
canonical_request.trace_id,
466593
)
594+
if is_new_session:
595+
title = _extract_session_title(canonical_request)
596+
if title:
597+
await self._recorder.set_session_title(
598+
canonical_request.session_key, title
599+
)
467600
incompatible_reasons: list[str] = []
468601
effective_tiers = self._resolve_effective_tiers(canonical_request.session_key)
469602
last_idx = len(effective_tiers) - 1
@@ -631,10 +764,16 @@ async def execute_message(
631764
failed_tier_name: str | None = None
632765
request_caps = build_request_capabilities(body)
633766
canonical_request = build_canonical_request(body, headers)
634-
session_record = await self._session_mgr.get_or_create_record(
767+
session_record, is_new_session = await self._session_mgr.get_or_create_record(
635768
canonical_request.session_key,
636769
canonical_request.trace_id,
637770
)
771+
if is_new_session:
772+
title = _extract_session_title(canonical_request)
773+
if title:
774+
await self._recorder.set_session_title(
775+
canonical_request.session_key, title
776+
)
638777
incompatible_reasons: list[str] = []
639778
effective_tiers = self._resolve_effective_tiers(canonical_request.session_key)
640779
last_idx = len(effective_tiers) - 1

src/coding/proxy/routing/session_manager.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,18 @@ def __init__(self, compat_session_store: CompatSessionStore | None = None) -> No
1919

2020
async def get_or_create_record(
2121
self, session_key: str, trace_id: str
22-
) -> CompatSessionRecord | None:
22+
) -> tuple[CompatSessionRecord | None, bool]:
23+
"""获取或创建兼容性会话记录.
24+
25+
Returns:
26+
(record, is_new) — is_new 为 True 表示本次创建的新会话。
27+
"""
2328
if self._store is None:
24-
return None
29+
return None, False
2530
record = await self._store.get(session_key)
2631
if record is not None:
27-
return record
28-
return CompatSessionRecord(session_key=session_key, trace_id=trace_id)
32+
return record, False
33+
return CompatSessionRecord(session_key=session_key, trace_id=trace_id), True
2934

3035
def apply_compat_context(
3136
self,

src/coding/proxy/routing/usage_recorder.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@ def __init__(
2828
def set_pricing_table(self, table: PricingTable) -> None:
2929
self._pricing_table = table
3030

31+
async def set_session_title(self, session_key: str, title: str) -> None:
32+
"""为新 session 设置标题(委托给 TokenLogger)."""
33+
if self._token_logger:
34+
await self._token_logger.set_session_title(session_key, title)
35+
3136
# ── 用量信息构建 ──────────────────────────────────────
3237

3338
@staticmethod

src/coding/proxy/server/dashboard.py

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -411,6 +411,7 @@ def _build_favicon() -> bytes:
411411
.session-table td.cell-tags { white-space: normal; overflow: visible; text-overflow: clip; line-height: 1.8; vertical-align: middle; }
412412
.session-table tr:hover td { background: var(--bg-card-hover); }
413413
.session-table .session-key { font-family: 'JetBrains Mono', monospace; font-size: 12px; color: var(--accent-blue); cursor: default; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
414+
.session-table .session-title { font-size: 12px; color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 0; }
414415
.session-id { display: flex; align-items: center; gap: 4px; }
415416
.session-id-text { overflow: hidden; text-overflow: ellipsis; }
416417
.copy-btn { background: none; border: none; color: var(--text-tertiary); cursor: pointer; padding: 2px; border-radius: 4px; font-size: 12px; line-height: 1; opacity: .5; flex-shrink: 0; }
@@ -676,20 +677,22 @@ def _build_favicon() -> bytes:
676677
<div class="session-table-wrap" id="sessions-table-wrap">
677678
<table class="session-table">
678679
<colgroup>
679-
<col style="width:12%">
680-
<col style="width:7%">
680+
<col style="width:10%">
681+
<col style="width:15%">
681682
<col style="width:6%">
683+
<col style="width:5%">
684+
<col style="width:5%">
685+
<col style="width:15%">
686+
<col style="width:10%">
682687
<col style="width:6%">
683-
<col style="width:17%">
684-
<col style="width:12%">
685-
<col style="width:7%">
686-
<col style="width:9%">
687-
<col style="width:12%">
688-
<col style="width:12%">
688+
<col style="width:8%">
689+
<col style="width:10%">
690+
<col style="width:10%">
689691
</colgroup>
690692
<thead>
691693
<tr>
692694
<th>Session ID</th>
695+
<th>Title</th>
693696
<th>Last Active</th>
694697
<th>Requests</th>
695698
<th>Tokens</th>
@@ -702,7 +705,7 @@ def _build_favicon() -> bytes:
702705
</tr>
703706
</thead>
704707
<tbody id="sessions-tbody">
705-
<tr><td colspan="10" class="empty">Loading...</td></tr>
708+
<tr><td colspan="11" class="empty">Loading...</td></tr>
706709
</tbody>
707710
</table>
708711
<div class="session-pagination" id="session-pagination">
@@ -1573,7 +1576,7 @@ def _build_favicon() -> bytes:
15731576
var tbody = document.getElementById('sessions-tbody');
15741577
15751578
if (!total) {
1576-
tbody.innerHTML = '<tr><td colspan="10" class="empty"><div class="empty-icon">📭</div>No session data</td></tr>';
1579+
tbody.innerHTML = '<tr><td colspan="11" class="empty"><div class="empty-icon">📭</div>No session data</td></tr>';
15771580
} else {
15781581
tbody.innerHTML = page.map(function(s) {
15791582
var parsed = parseSessionKey(s.session_key);
@@ -1582,6 +1585,7 @@ def _build_favicon() -> bytes:
15821585
var modelsFull = (s.models || '').split(',').map(function(c){return c.trim();});
15831586
var vendorsFull = (s.vendors || '').split(',').map(function(v){return formatVendorLabel(v.trim());});
15841587
var sr = s.success_rate != null ? Math.round(s.success_rate) : null;
1588+
var sessionTitle = s.title || '';
15851589
return '<tr data-row onclick="toggleRow(this)">' +
15861590
'<td class="session-key" onclick="event.stopPropagation()">' +
15871591
'<div class="session-id" data-key="' + escapeHtml(s.session_key) + '" title="' + escapeHtml(s.session_key) + '">' +
@@ -1592,6 +1596,7 @@ def _build_favicon() -> bytes:
15921596
'dev:' + escapeHtml(shortId(parsed.device_id, 8)) + ' · acct:' + escapeHtml(shortId(parsed.account_uuid, 8)) +
15931597
'</div>' +
15941598
'</td>' +
1599+
'<td class="session-title" title="' + escapeHtml(sessionTitle) + '">' + (sessionTitle ? escapeHtml(sessionTitle) : '–') + '</td>' +
15951600
'<td>' + relativeTime(s.last_active_ts) + '</td>' +
15961601
'<td style="font-family:JetBrains Mono,monospace">' + fmtNum(s.total_requests) + '</td>' +
15971602
'<td style="font-family:JetBrains Mono,monospace">' + fmtTokens(s.total_tokens) + '</td>' +
@@ -1602,9 +1607,10 @@ def _build_favicon() -> bytes:
16021607
'<td onclick="event.stopPropagation()">' + selectHtml + '</td>' +
16031608
'<td>' + formatCategories(s.client_categories) + '</td>' +
16041609
'</tr>' +
1605-
'<tr class="row-detail"><td colspan="10"><div class="detail-card">' +
1610+
'<tr class="row-detail"><td colspan="11"><div class="detail-card">' +
16061611
'<div class="detail-identity-row">' +
16071612
'<div class="detail-item"><div class="detail-label">Session ID</div><div class="detail-value" title="' + escapeHtml(s.session_key) + '">' + escapeHtml(parsed.session_id || s.session_key) + '</div></div>' +
1613+
'<div class="detail-item"><div class="detail-label">Title</div><div class="detail-value">' + (sessionTitle ? escapeHtml(sessionTitle) : '–') + '</div></div>' +
16081614
'<div class="detail-item"><div class="detail-label">Device</div><div class="detail-value" title="' + escapeHtml(parsed.device_id || '') + '">' + (parsed.device_id ? escapeHtml(parsed.device_id) : '–') + '</div></div>' +
16091615
'<div class="detail-item"><div class="detail-label">Account</div><div class="detail-value" title="' + escapeHtml(parsed.account_uuid || '') + '">' + (parsed.account_uuid ? escapeHtml(parsed.account_uuid) : '–') + '</div></div>' +
16101616
'</div>' +

0 commit comments

Comments
 (0)