Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 38 additions & 1 deletion src/coding/proxy/logging/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,14 @@ def _local_month_udf(ts_str: str) -> str:
);
"""

_CREATE_SESSION_META = """
CREATE TABLE IF NOT EXISTS session_meta (
session_key TEXT PRIMARY KEY,
title TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
"""

_CREATE_INDEXES = """
CREATE INDEX IF NOT EXISTS idx_usage_ts ON usage_log(ts);
CREATE INDEX IF NOT EXISTS idx_usage_vendor ON usage_log(vendor);
Expand Down Expand Up @@ -245,6 +253,7 @@ async def init(self) -> None:
self._db.row_factory = aiosqlite.Row
await self._db.execute("PRAGMA journal_mode=WAL")
await self._db.executescript(_CREATE_TABLES)
await self._db.executescript(_CREATE_SESSION_META)
# 迁移必须在建索引之前执行,确保 vendor 列已存在
await self._migrate_rename_backend_to_vendor()
await self._migrate_add_failover_from()
Expand Down Expand Up @@ -316,6 +325,28 @@ async def _migrate_rename_backend_to_vendor(self) -> None:
"Migration: renamed 'backend' column to 'vendor' in %s", table
)

async def set_session_title(self, session_key: str, title: str) -> None:
"""为新 session 设置标题(幂等,仅首次写入)."""
if not self._db or not title or not session_key:
return
await self._db.execute(
"INSERT OR IGNORE INTO session_meta (session_key, title) VALUES (?, ?)",
(session_key, title),
)
await self._db.commit()

async def get_session_titles(self, session_keys: list[str]) -> dict[str, str]:
"""批量查询 session 标题."""
if not self._db or not session_keys:
return {}
placeholders = ",".join("?" for _ in session_keys)
cursor = await self._db.execute(
f"SELECT session_key, title FROM session_meta WHERE session_key IN ({placeholders})",
session_keys,
)
rows = await cursor.fetchall()
return {row["session_key"]: row["title"] for row in rows}

async def log(
self,
vendor: str,
Expand Down Expand Up @@ -621,7 +652,13 @@ async def query_recent_sessions(
(cutoff_iso, limit),
)
rows = await cursor.fetchall()
return [dict(row) for row in rows]
sessions = [dict(row) for row in rows]
if sessions:
keys = [s["session_key"] for s in sessions]
titles = await self.get_session_titles(keys)
for s in sessions:
s["title"] = titles.get(s["session_key"], "")
return sessions

async def query_session_profile(self, session_key: str) -> dict | None:
"""查询单个会话的完整聚合数据."""
Expand Down
37 changes: 34 additions & 3 deletions src/coding/proxy/routing/executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,29 @@
# 向后兼容别名
BackendResponse = VendorResponse
NoCompatibleBackendError = NoCompatibleVendorError
from ..compat.canonical import CompatibilityStatus, build_canonical_request
from ..compat.canonical import (
CanonicalPartType,
CompatibilityStatus,
build_canonical_request,
)
from ..model.compat import CanonicalRequest

logger = logging.getLogger(__name__)

_SESSION_TITLE_MAX_LEN = 30


def _extract_session_title(request: CanonicalRequest) -> str:
"""从规范化请求中提取首个用户消息文本作为 session 标题."""
for part in request.messages:
if (
part.role == "user"
and part.type == CanonicalPartType.TEXT
and part.text.strip()
):
return part.text.strip()[:_SESSION_TITLE_MAX_LEN]
return ""


def _build_semantic_rejection_diagnostic(body: dict[str, Any]) -> str:
"""构建语义拒绝的请求体诊断上下文.
Expand Down Expand Up @@ -393,10 +412,16 @@ async def execute_stream(
failed_tier_name: str | None = None
request_caps = build_request_capabilities(body)
canonical_request = build_canonical_request(body, headers)
session_record = await self._session_mgr.get_or_create_record(
session_record, is_new_session = await self._session_mgr.get_or_create_record(
canonical_request.session_key,
canonical_request.trace_id,
)
if is_new_session:
title = _extract_session_title(canonical_request)
if title:
await self._recorder.set_session_title(
canonical_request.session_key, title
)
incompatible_reasons: list[str] = []
effective_tiers = self._resolve_effective_tiers(canonical_request.session_key)
last_idx = len(effective_tiers) - 1
Expand Down Expand Up @@ -564,10 +589,16 @@ async def execute_message(
failed_tier_name: str | None = None
request_caps = build_request_capabilities(body)
canonical_request = build_canonical_request(body, headers)
session_record = await self._session_mgr.get_or_create_record(
session_record, is_new_session = await self._session_mgr.get_or_create_record(
canonical_request.session_key,
canonical_request.trace_id,
)
if is_new_session:
title = _extract_session_title(canonical_request)
if title:
await self._recorder.set_session_title(
canonical_request.session_key, title
)
incompatible_reasons: list[str] = []
effective_tiers = self._resolve_effective_tiers(canonical_request.session_key)
last_idx = len(effective_tiers) - 1
Expand Down
13 changes: 9 additions & 4 deletions src/coding/proxy/routing/session_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,18 @@ def __init__(self, compat_session_store: CompatSessionStore | None = None) -> No

async def get_or_create_record(
self, session_key: str, trace_id: str
) -> CompatSessionRecord | None:
) -> tuple[CompatSessionRecord | None, bool]:
"""获取或创建兼容性会话记录.

Returns:
(record, is_new) — is_new 为 True 表示本次创建的新会话。
"""
if self._store is None:
return None
return None, False
record = await self._store.get(session_key)
if record is not None:
return record
return CompatSessionRecord(session_key=session_key, trace_id=trace_id)
return record, False
return CompatSessionRecord(session_key=session_key, trace_id=trace_id), True

def apply_compat_context(
self,
Expand Down
5 changes: 5 additions & 0 deletions src/coding/proxy/routing/usage_recorder.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ def __init__(
def set_pricing_table(self, table: PricingTable) -> None:
self._pricing_table = table

async def set_session_title(self, session_key: str, title: str) -> None:
"""为新 session 设置标题(委托给 TokenLogger)."""
if self._token_logger:
await self._token_logger.set_session_title(session_key, title)

# ── 用量信息构建 ──────────────────────────────────────

@staticmethod
Expand Down
28 changes: 17 additions & 11 deletions src/coding/proxy/server/dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,7 @@ def _build_favicon() -> bytes:
.session-table td.cell-tags { white-space: normal; overflow: visible; text-overflow: clip; line-height: 1.8; vertical-align: middle; }
.session-table tr:hover td { background: var(--bg-card-hover); }
.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; }
.session-table .session-title { font-size: 12px; color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 0; }
.session-id { display: flex; align-items: center; gap: 4px; }
.session-id-text { overflow: hidden; text-overflow: ellipsis; }
.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; }
Expand Down Expand Up @@ -676,20 +677,22 @@ def _build_favicon() -> bytes:
<div class="session-table-wrap" id="sessions-table-wrap">
<table class="session-table">
<colgroup>
<col style="width:12%">
<col style="width:7%">
<col style="width:10%">
<col style="width:15%">
<col style="width:6%">
<col style="width:5%">
<col style="width:5%">
<col style="width:15%">
<col style="width:10%">
<col style="width:6%">
<col style="width:17%">
<col style="width:12%">
<col style="width:7%">
<col style="width:9%">
<col style="width:12%">
<col style="width:12%">
<col style="width:8%">
<col style="width:10%">
<col style="width:10%">
</colgroup>
<thead>
<tr>
<th>Session ID</th>
<th>Title</th>
<th>Last Active</th>
<th>Requests</th>
<th>Tokens</th>
Expand All @@ -702,7 +705,7 @@ def _build_favicon() -> bytes:
</tr>
</thead>
<tbody id="sessions-tbody">
<tr><td colspan="10" class="empty">Loading...</td></tr>
<tr><td colspan="11" class="empty">Loading...</td></tr>
</tbody>
</table>
<div class="session-pagination" id="session-pagination">
Expand Down Expand Up @@ -1573,7 +1576,7 @@ def _build_favicon() -> bytes:
var tbody = document.getElementById('sessions-tbody');

if (!total) {
tbody.innerHTML = '<tr><td colspan="10" class="empty"><div class="empty-icon">📭</div>No session data</td></tr>';
tbody.innerHTML = '<tr><td colspan="11" class="empty"><div class="empty-icon">📭</div>No session data</td></tr>';
} else {
tbody.innerHTML = page.map(function(s) {
var parsed = parseSessionKey(s.session_key);
Expand All @@ -1582,6 +1585,7 @@ def _build_favicon() -> bytes:
var modelsFull = (s.models || '').split(',').map(function(c){return c.trim();});
var vendorsFull = (s.vendors || '').split(',').map(function(v){return formatVendorLabel(v.trim());});
var sr = s.success_rate != null ? Math.round(s.success_rate) : null;
var sessionTitle = s.title || '';
return '<tr data-row onclick="toggleRow(this)">' +
'<td class="session-key" onclick="event.stopPropagation()">' +
'<div class="session-id" data-key="' + escapeHtml(s.session_key) + '" title="' + escapeHtml(s.session_key) + '">' +
Expand All @@ -1592,6 +1596,7 @@ def _build_favicon() -> bytes:
'dev:' + escapeHtml(shortId(parsed.device_id, 8)) + ' · acct:' + escapeHtml(shortId(parsed.account_uuid, 8)) +
'</div>' +
'</td>' +
'<td class="session-title" title="' + escapeHtml(sessionTitle) + '">' + (sessionTitle ? escapeHtml(sessionTitle) : '–') + '</td>' +
'<td>' + relativeTime(s.last_active_ts) + '</td>' +
'<td style="font-family:JetBrains Mono,monospace">' + fmtNum(s.total_requests) + '</td>' +
'<td style="font-family:JetBrains Mono,monospace">' + fmtTokens(s.total_tokens) + '</td>' +
Expand All @@ -1602,9 +1607,10 @@ def _build_favicon() -> bytes:
'<td onclick="event.stopPropagation()">' + selectHtml + '</td>' +
'<td>' + formatCategories(s.client_categories) + '</td>' +
'</tr>' +
'<tr class="row-detail"><td colspan="10"><div class="detail-card">' +
'<tr class="row-detail"><td colspan="11"><div class="detail-card">' +
'<div class="detail-identity-row">' +
'<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>' +
'<div class="detail-item"><div class="detail-label">Title</div><div class="detail-value">' + (sessionTitle ? escapeHtml(sessionTitle) : '–') + '</div></div>' +
'<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>' +
'<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>' +
'</div>' +
Expand Down
11 changes: 6 additions & 5 deletions tests/test_router_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,7 @@ async def test_eligible_when_all_checks_pass(self):
headers = {}
caps = RequestCapabilities()
req = build_canonical_request(body, headers)
session_record = await exec_inst._session_mgr.get_or_create_record(
session_record, _is_new = await exec_inst._session_mgr.get_or_create_record(
req.session_key, req.trace_id
)
reasons: list[str] = []
Expand All @@ -246,7 +246,7 @@ async def test_skip_when_capability_unsupported(self):
body = {"model": "test"}
headers = {}
req = build_canonical_request(body, headers)
session_record = await exec_inst._session_mgr.get_or_create_record(
session_record, _is_new = await exec_inst._session_mgr.get_or_create_record(
req.session_key, req.trace_id
)
reasons: list[str] = []
Expand Down Expand Up @@ -275,7 +275,7 @@ async def test_skip_when_unsafe_compatibility(self):
body = {"model": "test", "thinking": {"type": "enabled"}}
headers = {}
req = build_canonical_request(body, headers)
session_record = await exec_inst._session_mgr.get_or_create_record(
session_record, _is_new = await exec_inst._session_mgr.get_or_create_record(
req.session_key, req.trace_id
)
reasons: list[str] = []
Expand Down Expand Up @@ -651,9 +651,10 @@ class TestRouteSessionManagerIntegration:
@pytest.mark.asyncio
async def test_get_or_create_without_store(self):
mgr = RouteSessionManager(compat_session_store=None)
record = await mgr.get_or_create_record("sk_test", "trace_1")
# 无 store 时返回 None(由 executor 层面处理空 record 场景)
record, is_new = await mgr.get_or_create_record("sk_test", "trace_1")
# 无 store 时返回 (None, False)
assert record is None
assert is_new is False

@pytest.mark.asyncio
async def test_persist_session_without_store_is_noop(self):
Expand Down
Loading