diff --git a/.gitignore b/.gitignore
index 75fec81..475b250 100644
--- a/.gitignore
+++ b/.gitignore
@@ -23,5 +23,8 @@ config.yaml
.claude/.prompts.md
.python-version
+# Playwright MCP
+.playwright-mcp/
+
# Log files (dual-write logging)
coding-proxy.log*
diff --git a/AGENTS.md b/AGENTS.md
index 20c1143..30d9d7a 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -52,6 +52,7 @@
1. **Python**: 严禁使用 pip/poetry,**必须**统一使用 `uv` 进行包管理与脚本执行(如 `uv run`);
2. **JavaScript/TypeScript**: 严禁使用 npm/yarn,**必须**统一使用 `pnpm` 进行包管理与脚本执行。
- **Database Management**: 谨慎操作,数据迁移、测试等操作严禁将现有数据删除,谨慎操作数据迁移的回滚,防止数据被清理。
+- **In-depth and close to the facts**:系统且全面地进行问题的分析,深入贴近事实,如有疑问,需先发问,不要乱做决定。
## Documentation Standards (文档规范)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6b974ed..0fb0f1d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,15 +4,27 @@
## [Unreleased]
-- fix(vendor-channels): 新增 zhipu 同 vendor 自清理通道,修复 GLM-5 自循环 400 + tool_results 偶发降级;
-- fix(vendor-channels): 修复 `_rewrite_srvtoolu_ids` 块顺序敏感性导致 inline tool_result 漏改名,进而 enforce 阶段 dict key 与 tool_use_ids 错位、anthropic 报 `tool_use ids without tool_result blocks immediately after` 的 cascade failover 问题(改为两遍扫描:先收集 id_map,再统一改写所有 tool_result.tool_use_id 引用);
-- fix(vendor-channels): `enforce_anthropic_tool_pairing` 增加全局 sanity check pass,主循环边角错位让 dangling tool_use 漏过校验时兜底合成 is_error 占位并打 `pairing_sanity_repaired` 标签,避免 anthropic 二次报错;
+## [v0.4.0](https://github.com/ThreeFish-AI/coding-proxy/releases/tag/v0.4.0) — 2026-05-01
-### Bug Fixes
+> [!IMPORTANT]
+>
+> **🚀 Session 级专属路由策略!**
+>
+> 给每个 Session 指定专属的 vendor,动态调节不同 vendors 间的 LLM 流量。
+
+
+
+### ✨ 核心亮点
+
+- feat(session-policy): 新增 Session 级专属路由策略 (#219)
+- feat(dashboard): 新增会话活动面板 (#222)
+
+### 🔧 更多特性
-- fix(vendor-channels): 新增 `anthropic → zhipu` 跨供应商转换通道,修复 Anthropic beta 功能(web search, computer use)产生的 `server_tool_use` 块导致 zhipu 400 错误的问题;
-- fix(error-classifier): 增强语义拒绝检测,识别 zhipu 等供应商返回的中文错误消息(如「API 调用参数有误」code=1210),确保正确触发故障转移;
-- fix(vendor-channels): `_remove_vendor_blocks` 增加空内容占位保护,防止内容块全部剥离后消息结构不合法。
+- refactor(logging): 移除已被 ModelCall 汇总行覆盖的冗余 DEBUG 日志 (#203)
+- style(dashboard): 加宽图表 tooltip 令模型名称与用量值单行显示 (#211)
+- fix(usage-parser): 补充 OpenAI/Gemini SSE 流式分支的 model_served 提取 (#214)
+- fix(usage-parser): 兼容 SSE chunk 中 usage 字段为 null 的极端格式 (#212)
## [v0.3.0](https://github.com/ThreeFish-AI/coding-proxy/releases/tag/v0.3.0) — 2026-04-20
diff --git a/assets/session-v0.4.0.png b/assets/session-v0.4.0.png
new file mode 100644
index 0000000..dd6ff36
Binary files /dev/null and b/assets/session-v0.4.0.png differ
diff --git a/docs/issue.md b/docs/issue.md
index 1fc6428..c8f9765 100644
--- a/docs/issue.md
+++ b/docs/issue.md
@@ -45,99 +45,3 @@ if "usage" in data: # 仅判断 key 存在
- 本仓库内 `parse_usage_from_chunk` 的 Gemini `usageMetadata` 分支 (line ~219) 已经使用 `isinstance(um, dict)` 防御, 不受影响, 可作为参考实现。
- 检查其他解析器 (如 routing / vendor adapter 层) 是否还有 `if "key" in data: v = data["key"]; v.get(...)` 这种模式, 必要时同步加固。
-
----
-
-## zhipu 自循环 400 + tool_results 偶发降级
-
-**问题描述**
-
-生产日志反复出现下述链路: 请求一开始命中 zhipu 主 tier, 但在含 `tool_results` 的多轮工具调用场景下偶发返回 400, 触发到 copilot 二级 tier。具体日志特征:
-
-```
-WARNING Tier zhipu likely format incompatibility (400 + tool_results), trying next tier without recording failure
-WARNING Tier zhipu semantic rejection (400), trying next tier without recording failure
-DEBUG Applied transition channel zhipu → copilot: rewritten_38_srvtoolu_ids, stripped_16_thinking_blocks, removed_3_cache_control_fields, misplaced_tool_result_relocated
-```
-
-zhipu → copilot 通道的 adaptations 列表暴露了上一轮 zhipu 响应中存在的非标准产物 (`srvtoolu_*` ID、自签 thinking、错位的 `tool_result`)。
-
-**表因**
-
-zhipu 自身偶发返回 400, 但错误体非 JSON 结构, 由 `_is_likely_request_format_error()` 判定为「格式不兼容」并跳过当前 tier。
-
-**根因**
-
-1. zhipu 是 `NativeAnthropicVendor` 薄透传供应商, **不做任何请求体预处理**。
-2. `executor._determine_source_vendor` 三条优先级路径均以 `source != target_name` 过滤掉了同 vendor 自转换。
-3. `VENDOR_TRANSITIONS` 注册表中无 `("zhipu", "zhipu")` 条目。
-
-后果: GLM-5 偶发产出非标准产物 (assistant 内联 `tool_result`、`server_tool_use_delta` 流式残块) 后, Claude Code 把这些产物原样回送下一轮请求时, **没有任何清洗发生**, 直接被转发到 zhipu 自身, 命中 zhipu 端的输入校验返回 400。
-
-**处理方式**
-
-- 在 `vendor_channels.py` 新增 `prepare_zhipu_self_cleanup` 函数, 仅修复 zhipu 自身拒绝的两类产物:
- 1. 剥离 `server_tool_use_delta` 流式残块
- 2. `enforce_anthropic_tool_pairing` 把 assistant 内联 `tool_result` 重定位到紧随 user 消息
-- 显式 **保留** zhipu 原生支持的特性: `srvtoolu_*` ID、`server_tool_use` 类型、自签 thinking signature、`cache_control` (cache_read 已在生产实证)、顶层 `thinking` 参数。
-- 在 `VENDOR_TRANSITIONS` 注册 `("zhipu", "zhipu") = prepare_zhipu_self_cleanup`。
-- 在 `executor._determine_source_vendor` 三条优先级路径中, 把「`source != target`」过滤替换为「通道已注册」门控 (`get_transition_channel(...) is not None`), 让自转换通道在显式注册时启用, 未注册时退化为原行为。
-
-**后续防范**
-
-- 新增 `NativeAnthropicVendor` 子类 (minimax / kimi / doubao / xiaomi / alibaba 等) 时, 若上游 vendor 偶发产出违反 Anthropic 规范的产物, 可按需注册同名自清理通道, executor 无需任何额外改动。
-- 同 vendor 自转换通道应**精确剪裁**: 仅修复 vendor 自身拒绝的产物, 不要套用跨 vendor 通道的全量清理 (会误伤 vendor 原生支持的特性, 如 cache_control 损失带来 cache_read miss)。
-
-**同类问题影响与处理注意事项**
-
-- `enforce_anthropic_tool_pairing` 仅识别 `tool_use` 类型 (不含 `server_tool_use`), 因为 `server_tool_use` 由 vendor 自身执行, 不需要客户端 `tool_result`。构造测试或类似清洗逻辑时需注意此差别。
-- `_is_likely_request_format_error()` 把「400 + tool_results + 非结构化错误体」一律标记为格式不兼容并跳过 tier 不计熔断器, 这层兜底虽能维持可用性但会**掩盖** vendor 自身的间歇性问题, 让根因更难发现。处理类似 400 偶发时, 应优先看 `Applied transition channel` 日志中的 adaptations 列表, 它能精确暴露上游响应中的非标准产物。
-
----
-
-## anthropic 报 messages.X tool_use 缺 tool_result (zhipu→anthropic 故障转移失败)
-
-**问题描述**
-
-zhipu 完成响应后, executor 故障转移至 anthropic 时反复失败 (HTTP 400):
-
-```
-DEBUG Applied transition channel zhipu → anthropic: rewritten_86_srvtoolu_ids, misplaced_tool_result_relocated, stripped_18_thinking_blocks
-WARNING anthropic stream error: status=400 ... messages.3: `tool_use` ids were found without `tool_result` blocks immediately after: toolu_normalized_3.
-INFO Failover: anthropic → zhipu (reason: HTTP 400)
-```
-
-adaptations 列表显示 `misplaced_tool_result_relocated` 但**没有** `orphaned_tool_use_repaired`, 即 enforce 单遍扫描视角下认为所有 tool_use 都已配对; 但 anthropic 仍报 messages.X 缺 tool_result, 导致请求级 cascade failover 反复回到 zhipu。
-
-**表因**
-
-`prepare_zhipu_to_anthropic` 链路输出的请求体中, 某个 assistant 的 `tool_use` 在紧邻的 user 消息中没有匹配的 `tool_result` 块。
-
-**根因**
-
-`_rewrite_srvtoolu_ids` 采用单遍正向扫描: 在同一次循环中一边收集 srvtoolu_* → toolu_normalized_* 的 id_map, 一边改写遇到的 `tool_result.tool_use_id`。GLM-5 流式偶发将 inline tool_result 输出在本消息 `server_tool_use` 之前 (block 顺序异常), 导致:
-
-1. 处理 inline tool_result 时, id_map 尚未填入对应 srvtoolu_* → 漏改名, inline 仍保留 `srvtoolu_X`
-2. 处理本消息 server_tool_use 时, 填入 id_map 并把 tool_use 改名为 `toolu_normalized_X`
-3. 进入 `enforce_anthropic_tool_pairing` 时:
- - A 步 extracted dict key = `srvtoolu_X` (inline 保留的旧 ID)
- - B 步 tool_use_ids = `[toolu_normalized_X]` (已改名)
- - F 步 `uid in extracted` 检查失败 (key 错位), 但若 next user 已含其他 stale tool_result 让 existing_result_ids "巧合" 命中, F 步会跳过 synth → 不触发 orphan 标签
- - 最终 anthropic 看到 messages.X 真的缺 toolu_normalized_X 的 tool_result → 400
-
-**处理方式**
-
-- `_rewrite_srvtoolu_ids` 改为**两遍扫描**: Pass 1 仅遍历 assistant 消息, 收集 id_map 并改写 tool_use 自身的 id 与 type; Pass 2 全量遍历所有消息 (含 user / 异常 assistant 内联), 统一改写所有 `tool_result.tool_use_id` 引用。彻底消除 block 顺序敏感性。
-- `enforce_anthropic_tool_pairing` 主循环结束后追加**全局 sanity check pass**: 重新遍历每条 assistant, 验证其 tool_use_ids 全部在 next user 的 tool_result 中存在; 发现遗漏直接合成 is_error 占位并打 `pairing_sanity_repaired` 标签。作为防御深度抵御未来其他主循环边角错位。
-- A 步对 `tool_use_id` 缺失的破损 inline tool_result 也计入 relocated_count (避免 silent drop 影响 adaptations 标签可观测性)。
-
-**后续防范**
-
-- 任何"按出现顺序填充字典 + 同遍引用查询"的两阶段操作都应警惕**顺序耦合**问题。两遍扫描 (collect → apply) 是消除此类 bug 的标准 pattern。
-- 关键校验函数应有**主循环 + 全局 sanity check** 的双层结构, 单层校验在边角场景下容易被绕过。
-- 处理 anthropic `tool_use ids without tool_result blocks immediately after` 类 cascade failover 时, **adaptations 标签能否复现日志**是定位 root cause 的强信号: 若 enforce 视角与 anthropic 视角不一致 (有 misplaced 但无 orphan, anthropic 仍报错), 必有上游 _rewrite / id 改写阶段的隐藏漏洞。
-
-**同类问题影响与处理注意事项**
-
-- 任何对 messages 进行 ID 重写的转换链 (如 `_rewrite_srvtoolu_ids`、`anthropic_to_openai`、`anthropic_to_gemini`) 都应使用两遍扫描或一次性收集后再批量改写, 以保证 block 顺序无关性。
-- enforce 类校验函数若依赖 dict key 与 list 元素的**等同性**, 必须先确保两者在同一参考系下 (改名前 vs 改名后); 否则错位会以 "看起来 OK 实际有漏" 的方式静默泄漏到下游。
diff --git a/pyproject.toml b/pyproject.toml
index 4cc7854..24630e1 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "coding-proxy"
-version = "0.3.1a3"
+version = "0.4.0"
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..."
readme = "README.md"
requires-python = ">=3.12"
diff --git a/src/coding/proxy/cli/__init__.py b/src/coding/proxy/cli/__init__.py
index 21cbe91..3b479fb 100644
--- a/src/coding/proxy/cli/__init__.py
+++ b/src/coding/proxy/cli/__init__.py
@@ -30,6 +30,10 @@
# 注册 Auth 子应用
app.add_typer(auth_app, name="auth")
+# 注册 Session 子应用
+session_app = typer.Typer(name="session", help="管理 Session-Vendor 运行时绑定")
+app.add_typer(session_app, name="session")
+
def _build_token_store(cfg_path: Path | None = None):
"""按配置解析 Token Store 路径并完成加载."""
@@ -264,6 +268,97 @@ def reset(
console.print("[red]代理服务未运行[/red]")
+# ── Session 子命令 ───────────────────────────────────────────────
+
+
+@session_app.command("bind")
+def session_bind(
+ key: str = typer.Option(..., "--key", "-k", help="Session key"),
+ vendor: str = typer.Option(
+ ..., "--vendor", "-v", help="绑定 vendor(逗号分隔多个)"
+ ),
+ port: int = typer.Option(3392, "--port", "-p", help="代理服务端口"),
+) -> None:
+ """为指定 Session 绑定 vendor 优先级."""
+ import httpx as _httpx
+
+ vendors = [v.strip() for v in vendor.split(",") if v.strip()]
+ try:
+ resp = _httpx.put(
+ f"http://127.0.0.1:{port}/api/session-vendor",
+ json={"session_key": key, "vendors": vendors},
+ timeout=5,
+ )
+ if resp.status_code == 200:
+ data = resp.json()
+ console.print(
+ f"[green]绑定成功:[/] session [cyan]{key[:16]}…[/cyan] → "
+ + " → ".join(data.get("vendors", vendors))
+ )
+ else:
+ try:
+ err = resp.json()
+ msg = err.get("error", {}).get("message", resp.text)
+ except Exception:
+ msg = resp.text
+ console.print(f"[red]绑定失败: {msg}[/red]")
+ except _httpx.ConnectError:
+ console.print("[red]代理服务未运行[/red]")
+
+
+@session_app.command("unbind")
+def session_unbind(
+ key: str = typer.Option(..., "--key", "-k", help="Session key"),
+ port: int = typer.Option(3392, "--port", "-p", help="代理服务端口"),
+) -> None:
+ """解除指定 Session 的 vendor 绑定."""
+ from urllib.parse import quote
+
+ import httpx as _httpx
+
+ try:
+ resp = _httpx.delete(
+ f"http://127.0.0.1:{port}/api/session-vendor/{quote(key, safe='')}",
+ timeout=5,
+ )
+ if resp.status_code == 200:
+ console.print(f"[green]已解除绑定:[/] session [cyan]{key[:16]}…[/cyan]")
+ elif resp.status_code == 404:
+ console.print(f"[yellow]未找到绑定:[/] session [cyan]{key[:16]}…[/cyan]")
+ else:
+ console.print(f"[red]解除失败: {resp.status_code} {resp.text}[/red]")
+ except _httpx.ConnectError:
+ console.print("[red]代理服务未运行[/red]")
+
+
+@session_app.command("list")
+def session_list(
+ port: int = typer.Option(3392, "--port", "-p", help="代理服务端口"),
+) -> None:
+ """列出所有运行时 Session-Vendor 绑定."""
+ import httpx as _httpx
+
+ try:
+ resp = _httpx.get(
+ f"http://127.0.0.1:{port}/api/session-vendor",
+ timeout=5,
+ )
+ if resp.status_code == 200:
+ data = resp.json()
+ bindings = data.get("bindings", [])
+ if not bindings:
+ console.print("[dim]当前无运行时绑定[/dim]")
+ return
+ for b in bindings:
+ key = b.get("session_key", "?")
+ vendors = b.get("vendors", [])
+ console.print(f" [cyan]{key[:24]}…[/cyan] → " + " → ".join(vendors))
+ else:
+ console.print(f"[red]查询失败: {resp.status_code} {resp.text}[/red]")
+ except _httpx.ConnectError:
+ console.print("[red]代理服务未运行[/red]")
+
+
def _resolve_config_path(config: str | Path | None = None) -> Path | None:
"""标准化配置路径输入."""
if config is None:
diff --git a/src/coding/proxy/config/config.default.yaml b/src/coding/proxy/config/config.default.yaml
index f511e8b..40808fd 100644
--- a/src/coding/proxy/config/config.default.yaml
+++ b/src/coding/proxy/config/config.default.yaml
@@ -644,3 +644,31 @@ native_api:
base_url: "https://api.anthropic.com"
timeout_ms: 300000
connect_timeout_ms: 15000
+
+# === Session 级别路由策略(可选)===
+#
+# 为特定 Session 或客户端类别定制 vendor 优先级顺序。
+# 匹配策略按定义顺序求值,首次匹配生效。
+#
+# 支持的匹配条件(OR 语义,满足任一即匹配):
+# session_keys: 精确匹配的 session key 列表
+# client_category: 按客户端类别匹配(⚠️ 预留字段,当前版本暂未生效,后续版本支持)
+#
+# tiers: 覆盖全局 tier 顺序的供应商优先级列表(未提及的 vendor 保持在末尾)
+#
+# 示例 1:为特定 session 绑定专属 vendor 组合
+# session_policies:
+# - name: "vip-session"
+# match:
+# session_keys: ["my-specific-session-id"]
+# tiers: ["anthropic", "copilot", "zhipu"]
+#
+# 示例 2:Claude Code 请求优先走 Copilot
+# session_policies:
+# - name: "claude-code-preferred"
+# match:
+# client_category: "cc"
+# tiers: ["copilot", "anthropic", "zhipu"]
+#
+# 未配置时(默认),所有 Session 使用全局 tiers 顺序。
+session_policies: []
diff --git a/src/coding/proxy/config/schema.py b/src/coding/proxy/config/schema.py
index 5441979..ee21ee7 100644
--- a/src/coding/proxy/config/schema.py
+++ b/src/coding/proxy/config/schema.py
@@ -44,6 +44,7 @@
# ── 子模块 re-export ────────────────────────────────────────────
from .server import DatabaseConfig, LoggingConfig, ServerConfig # noqa: F401
+from .session_policy import SessionPoliciesConfig # noqa: F401
from .vendors import ( # noqa: F401
AlibabaConfig,
AnthropicConfig,
@@ -152,11 +153,19 @@ class ProxyConfig(BaseModel):
"三个 provider 默认 enabled=False,显式启用才暴露 /api/{provider}/* 端点。"
),
)
+ # Session 级别路由策略
+ session_policies: SessionPoliciesConfig = Field(
+ default_factory=SessionPoliciesConfig,
+ description=(
+ "Session 级别的路由策略配置。"
+ "可为特定 Session 或客户端类别定制 vendor 优先级顺序。"
+ ),
+ )
@model_validator(mode="before")
@classmethod
def _migrate_legacy_fields(cls, data: Any) -> Any:
- """向后兼容迁移(legacy flat 格式 → vendors 列表格式).
+ """向后兼容迁移(legacy flat 格式 → vendors 列表格式)+ session_policies 规范化.
迁移规则:
1. ``anthropic`` / ``zhipu`` 字段名自动映射为 ``primary`` / ``fallback``
@@ -165,6 +174,12 @@ def _migrate_legacy_fields(cls, data: Any) -> Any:
if not isinstance(data, dict):
return data
+ # session_policies 规范化:YAML 中 session_policies: [] 解析为 list,
+ # 需转为 dict 包装以匹配 SessionPoliciesConfig 模型
+ sp = data.get("session_policies")
+ if isinstance(sp, list):
+ data["session_policies"] = {"policies": sp}
+
# 1. 字段别名迁移
if "anthropic" in data and "primary" not in data:
data["primary"] = data.pop("anthropic")
@@ -331,4 +346,6 @@ def compat_state_path(self) -> Path:
"AlibabaConfig",
# native api passthrough
"NativeApiConfig",
+ # session policy
+ "SessionPoliciesConfig",
]
diff --git a/src/coding/proxy/config/session_policy.py b/src/coding/proxy/config/session_policy.py
new file mode 100644
index 0000000..cb2c512
--- /dev/null
+++ b/src/coding/proxy/config/session_policy.py
@@ -0,0 +1,59 @@
+"""Session Policy 配置模型 — 为特定 Session 定制路由行为."""
+
+from __future__ import annotations
+
+from pydantic import BaseModel, Field
+
+
+class SessionPolicyMatch(BaseModel):
+ """策略匹配条件 — 满足任一条件即匹配(OR 语义)."""
+
+ session_keys: list[str] = Field(
+ default_factory=list,
+ description="精确匹配的 session key 列表",
+ )
+ client_category: str | None = Field(
+ default=None,
+ description=(
+ "按客户端类别匹配('cc' 或 'api')。"
+ "⚠️ 预留字段,当前路由执行链路未传入 client_category,"
+ "配置此条件不会生效。后续版本将支持。"
+ ),
+ )
+
+
+class SessionQuotaConfig(BaseModel):
+ """Per-session 资源配额(架构预留)."""
+
+ token_budget: int = Field(
+ default=0,
+ description="时间窗口内的 token 预算上限",
+ )
+ window_hours: float = Field(
+ default=24.0,
+ description="滚动时间窗口(小时)",
+ )
+
+
+class SessionPolicy(BaseModel):
+ """单条 Session 路由策略."""
+
+ name: str = Field(description="策略名称(用于日志与排障)")
+ match: SessionPolicyMatch = Field(description="匹配条件")
+ tiers: list[str] = Field(
+ default_factory=list,
+ description="覆盖全局 tier 顺序的供应商优先级列表",
+ )
+ quota: SessionQuotaConfig | None = Field(
+ default=None,
+ description="Per-session 资源配额(预留)",
+ )
+
+
+class SessionPoliciesConfig(BaseModel):
+ """顶层 Session 策略配置容器."""
+
+ policies: list[SessionPolicy] = Field(
+ default_factory=list,
+ description="Session 路由策略列表,按定义顺序求值,首次匹配生效",
+ )
diff --git a/src/coding/proxy/convert/anthropic_to_openai.py b/src/coding/proxy/convert/anthropic_to_openai.py
index 82baeba..81bd163 100644
--- a/src/coding/proxy/convert/anthropic_to_openai.py
+++ b/src/coding/proxy/convert/anthropic_to_openai.py
@@ -284,24 +284,34 @@ def _translate_assistant_message(message: dict[str, Any]) -> list[dict[str, Any]
final_text_parts = text_parts
if tool_uses:
+ tool_calls: list[dict[str, Any]] = []
+ for block in tool_uses:
+ raw_input = block.get("input")
+ if not isinstance(raw_input, dict):
+ logger.debug(
+ "copilot: tool_use id=%s name=%s has non-dict input (type=%s), "
+ "defaulting to empty dict",
+ block.get("id", ""),
+ block.get("name", ""),
+ type(raw_input).__name__,
+ )
+ raw_input = {}
+ tool_calls.append(
+ {
+ "id": block.get("id", ""),
+ "type": "function",
+ "function": {
+ "name": block.get("name", ""),
+ "arguments": json.dumps(raw_input, ensure_ascii=False),
+ },
+ }
+ )
return [
{
"role": "assistant",
"content": "\n\n".join(part for part in final_text_parts if part)
or None,
- "tool_calls": [
- {
- "id": block.get("id", ""),
- "type": "function",
- "function": {
- "name": block.get("name", ""),
- "arguments": json.dumps(
- block.get("input", {}), ensure_ascii=False
- ),
- },
- }
- for block in tool_uses
- ],
+ "tool_calls": tool_calls,
}
]
diff --git a/src/coding/proxy/convert/vendor_channels.py b/src/coding/proxy/convert/vendor_channels.py
index 0b1d511..bec46f7 100644
--- a/src/coding/proxy/convert/vendor_channels.py
+++ b/src/coding/proxy/convert/vendor_channels.py
@@ -10,8 +10,6 @@
zhipu → anthropic : prepare_zhipu_to_anthropic (剥离 thinking + tool pairing)
zhipu → copilot : prepare_zhipu_to_copilot (剥离 thinking + cache_control + tool pairing)
copilot → zhipu : prepare_copilot_to_zhipu (剥离 thinking + cache_control + 移除 thinking 参数 + tool pairing)
- zhipu → zhipu : prepare_zhipu_self_cleanup (剥离 server_tool_use_delta + tool pairing)
- anthropic → zhipu : prepare_anthropic_to_zhipu (剥离 server_tool_use + thinking + cache_control + 移除 thinking 参数 + tool pairing)
"""
from __future__ import annotations
@@ -110,10 +108,6 @@ def enforce_anthropic_tool_pairing(
此函数是一个**自包含的单遍处理**,不依赖 Phase 1 收集的 misplaced 信息。
- 最终在主循环之后执行一次幂等的全局 sanity check pass, 防御主循环的边角
- 错位 (如 inline tool_result 引用未在本消息出现的 tool_use_id, 导致 extracted
- 字典 key 与 tool_use_ids 集合错位) 让 dangling tool_use 漏过校验。
-
Args:
messages_list: 消息列表(就地修改)。
@@ -145,13 +139,10 @@ def enforce_anthropic_tool_pairing(
if tid:
extracted_tool_results[tid] = block
relocated_count += 1
- else:
- # 缺 tool_use_id 的破损 tool_result 也视作错位剥离
- relocated_count += 1
else:
retained_content.append(block)
- if extracted_tool_results or len(retained_content) != len(content):
+ if extracted_tool_results:
msg["content"] = retained_content
# B. 收集所有 tool_use ID
@@ -216,17 +207,10 @@ def enforce_anthropic_tool_pairing(
i += 1
- # G. 最终全局 sanity check pass (抽出为独立函数便于单测验证正向兜底路径).
- sanity_synthesized = _enforce_pairing_sanity_pass(messages_list)
-
if relocated_count:
adaptations.append("misplaced_tool_result_relocated")
- if synthesized_ids or sanity_synthesized:
- adaptations.append("orphaned_tool_use_repaired")
-
- # 主循环 F 段与 sanity G 段分别打日志, 避免 main=0/sanity=N 时把 sanity
- # 兜底误归因为主循环工作 (运维在线日志聚合时易混淆 cross-pass id-map drift).
if synthesized_ids:
+ adaptations.append("orphaned_tool_use_repaired")
logger.warning(
"Vendor degradation adaptation: synthesized %d tool_result block(s) "
"for orphaned tool_use to satisfy Anthropic pairing constraint. "
@@ -234,94 +218,10 @@ def enforce_anthropic_tool_pairing(
len(synthesized_ids),
", ".join(synthesized_ids),
)
- if sanity_synthesized:
- adaptations.append("pairing_sanity_repaired")
- logger.warning(
- "Pairing sanity check repaired %d dangling tool_use(s) missed by "
- "main pass (likely cross-pass id-map drift). Affected tool_use_ids: %s",
- len(sanity_synthesized),
- ", ".join(sanity_synthesized),
- )
return adaptations
-def _enforce_pairing_sanity_pass(messages_list: list[Any]) -> list[str]:
- """全局 sanity check pass: 防御主循环边角错位让 dangling tool_use 漏过.
-
- 例如: extracted dict key 与 _rewrite 后的 tool_use_ids 错位、user_msg
- 中已有 stale tool_result 让 F 步误判 existing 命中等场景。
-
- 扫描所有 assistant 消息, 验证每个 ``tool_use`` block ID 在紧随的 user 消息
- 中均存在对应 ``tool_result``; 漏掉的合成 ``is_error`` 占位。
-
- 抽取为独立函数的目的: 主循环 F 步在当前实现下能覆盖所有 dangling tool_use,
- 导致 sanity 实际兜底分支在公开 API 测试中无法被触发; 独立函数便于直接
- 构造「绕过主循环」的输入, 对兜底合成路径建立正向回归保护。
-
- Args:
- messages_list: 消息列表 (就地修改, 必要时插入空 user 消息).
-
- Returns:
- sanity 兜底合成的 tool_use_id 列表 (空表示主循环已完成所有配对).
- """
- sanity_synthesized: list[str] = []
- j = 0
- while j < len(messages_list):
- msg_j = messages_list[j]
- if not isinstance(msg_j, dict) or msg_j.get("role") != "assistant":
- j += 1
- continue
- content_j = msg_j.get("content")
- if not isinstance(content_j, list):
- j += 1
- continue
- tu_ids = [
- b["id"]
- for b in content_j
- if isinstance(b, dict) and b.get("type") == "tool_use" and b.get("id")
- ]
- if not tu_ids:
- j += 1
- continue
- next_j = j + 1
- if (
- next_j < len(messages_list)
- and isinstance(messages_list[next_j], dict)
- and messages_list[next_j].get("role") == "user"
- ):
- next_user = messages_list[next_j]
- else:
- next_user = {"role": "user", "content": []}
- messages_list.insert(next_j, next_user)
- nu_content = next_user.get("content")
- if isinstance(nu_content, str):
- next_user["content"] = [{"type": "text", "text": nu_content}]
- elif not isinstance(nu_content, list):
- next_user["content"] = []
- nu_result_ids = {
- b["tool_use_id"]
- for b in next_user["content"]
- if isinstance(b, dict)
- and b.get("type") == "tool_result"
- and b.get("tool_use_id")
- }
- for uid in tu_ids:
- if uid in nu_result_ids:
- continue
- next_user["content"].append(
- {
- "type": "tool_result",
- "tool_use_id": uid,
- "content": "",
- "is_error": True,
- }
- )
- sanity_synthesized.append(uid)
- j += 1
- return sanity_synthesized
-
-
def _strip_cache_control(body: dict[str, Any]) -> int:
"""从 system/messages/tools 中移除 cache_control 字段(就地).
@@ -384,13 +284,7 @@ def _remove_vendor_blocks(body: dict[str, Any], block_types: set[str]) -> int:
removed += 1
continue
new_content.append(block)
- if content != new_content:
- if not new_content:
- new_content = [{"type": "text", "text": "[vendor_block_removed]"}]
- logger.info(
- "Inserted placeholder text block after stripping "
- "vendor blocks to avoid empty message content",
- )
+ if removed:
message["content"] = new_content
return removed
@@ -400,12 +294,8 @@ def _rewrite_srvtoolu_ids(body: dict[str, Any]) -> tuple[int, dict[str, str]]:
Anthropic API 要求 tool_use 类型与 ``toolu_*`` 格式的 ID。Zhipu 的
``server_tool_use`` + ``srvtoolu_*`` 在上游 Anthropic 兼容端点可用,但无法
- 透传至其他供应商;同时还需重写所有 ``tool_result.tool_use_id`` 引用,
- 保持配对关系。
-
- 采用**两遍扫描**避免块顺序敏感性: GLM-5 偶发将 inline tool_result 输出在
- 本消息 tool_use 之前, 单遍扫描会因 id_map 尚未填入而漏改 inline tool_result
- 的 tool_use_id, 导致后续 enforce 步骤无法将其与 tool_use 配对。
+ 透传至其他供应商;同时还需重写紧随其后 user 消息中 ``tool_result.tool_use_id``
+ 引用,保持配对关系。
Returns:
(rewritten_count, id_map) — 重写次数与 {原 ID: 新 ID} 映射。
@@ -418,59 +308,45 @@ def next_id() -> str:
counter += 1
return f"toolu_normalized_{counter}"
- # Pass 1: 收集所有 assistant tool_use / server_tool_use 的 ID 映射
- # 不修改 tool_result, 仅建立 id_map; 同时改写 tool_use 自身的 id 与 type
for message in body.get("messages", []):
if not isinstance(message, dict):
continue
content = message.get("content")
if not isinstance(content, list):
continue
- if message.get("role") != "assistant":
- continue
+ role = message.get("role")
for block in content:
if not isinstance(block, dict):
continue
block_type = block.get("type")
block_id = block.get("id")
- if block_type not in {"tool_use", "server_tool_use"}:
- continue
- if isinstance(block_id, str) and _ANTHROPIC_SERVER_TOOL_USE_ID_RE.match(
- block_id
- ):
- new_id = next_id()
- id_map[block_id] = new_id
- block["id"] = new_id
- block["type"] = "tool_use"
- elif (
- isinstance(block_id, str)
- and block_id
- and not _ANTHROPIC_TOOL_USE_ID_RE.match(block_id)
- and block.get("name")
- ):
- # 非标准 ID(非 toolu_ / srvtoolu_),且具备 name 可改写
- new_id = next_id()
- id_map[block_id] = new_id
- block["id"] = new_id
- block["type"] = "tool_use"
- elif block_type == "server_tool_use" and isinstance(block_id, str):
- # 兜底: 类型是 server_tool_use 但 ID 已是标准 toolu_ 形式,仅纠正类型
- block["type"] = "tool_use"
-
- # Pass 2: 全量同步所有 tool_result.tool_use_id 引用 (含 user/assistant 内联)
- if id_map:
- for message in body.get("messages", []):
- if not isinstance(message, dict):
- continue
- content = message.get("content")
- if not isinstance(content, list):
- continue
- for block in content:
- if not isinstance(block, dict):
- continue
- if block.get("type") != "tool_result":
- continue
+ # Case A: assistant 消息里的 server_tool_use / srvtoolu_* → 改写
+ if role == "assistant" and block_type in {"tool_use", "server_tool_use"}:
+ if isinstance(block_id, str) and _ANTHROPIC_SERVER_TOOL_USE_ID_RE.match(
+ block_id
+ ):
+ new_id = next_id()
+ id_map[block_id] = new_id
+ block["id"] = new_id
+ block["type"] = "tool_use"
+ elif (
+ isinstance(block_id, str)
+ and block_id
+ and not _ANTHROPIC_TOOL_USE_ID_RE.match(block_id)
+ and block.get("name")
+ ):
+ # 非标准 ID(非 toolu_ / srvtoolu_),且具备 name 可改写
+ new_id = next_id()
+ id_map[block_id] = new_id
+ block["id"] = new_id
+ block["type"] = "tool_use"
+ elif block_type == "server_tool_use" and isinstance(block_id, str):
+ # 兜底: 类型是 server_tool_use 但 ID 已是标准 toolu_ 形式,仅纠正类型
+ block["type"] = "tool_use"
+
+ # Case B: user 消息里的 tool_result.tool_use_id 同步重写
+ if block_type == "tool_result":
tool_use_id = block.get("tool_use_id")
if isinstance(tool_use_id, str) and tool_use_id in id_map:
block["tool_use_id"] = id_map[tool_use_id]
@@ -482,9 +358,8 @@ def infer_source_vendor_from_body(body: dict[str, Any]) -> str | None:
"""从请求 body 内容推断源供应商(仅在无会话上下文时作为兜底).
启发式(按置信度排序):
- - 出现 ``srvtoolu_*`` 格式的 ID → zhipu
- - 出现 ``server_tool_use_delta`` 类型的 content block → zhipu
- - 出现 ``server_tool_use`` 块 + ``toolu_*`` ID → anthropic(beta 功能产物)
+ - 出现 ``srvtoolu_*`` 格式的 ``tool_use.id`` → zhipu
+ - 出现 ``server_tool_use`` / ``server_tool_use_delta`` 类型的 content block → zhipu
原则: 只读扫描不修改 body;无匹配返回 None(视作纯净无需跨供应商清洗)。
@@ -492,7 +367,7 @@ def infer_source_vendor_from_body(body: dict[str, Any]) -> str | None:
body: Anthropic Messages 请求体。
Returns:
- 推断的源供应商名称(``"zhipu"`` 或 ``"anthropic"``),无法推断返回 None。
+ 推断的源供应商名称(当前仅支持 ``"zhipu"``),无法推断返回 None。
"""
for message in body.get("messages", []):
if not isinstance(message, dict):
@@ -504,35 +379,18 @@ def infer_source_vendor_from_body(body: dict[str, Any]) -> str | None:
if not isinstance(block, dict):
continue
block_type = block.get("type")
- block_id = block.get("id")
- tool_use_id = block.get("tool_use_id")
-
- # Zhipu: server_tool_use_delta 是 zhipu 私有流式块(无歧义)
- if block_type == "server_tool_use_delta":
+ if block_type in _ZHIPU_SERVER_TOOL_USE_TYPES:
return "zhipu"
-
- # srvtoolu_* ID(无论 block type)→ zhipu
+ block_id = block.get("id")
if isinstance(block_id, str) and _ANTHROPIC_SERVER_TOOL_USE_ID_RE.match(
block_id
):
return "zhipu"
+ tool_use_id = block.get("tool_use_id")
if isinstance(tool_use_id, str) and _ANTHROPIC_SERVER_TOOL_USE_ID_RE.match(
tool_use_id
):
return "zhipu"
-
- # server_tool_use 块 + toolu_* ID → Anthropic beta 功能
- if (
- block_type == "server_tool_use"
- and isinstance(block_id, str)
- and _ANTHROPIC_TOOL_USE_ID_RE.match(block_id)
- ):
- return "anthropic"
-
- # server_tool_use 块 + 非 toolu_/srvtoolu_ ID → 按类型兜底归 zhipu
- if block_type == "server_tool_use":
- return "zhipu"
-
return None
@@ -580,61 +438,6 @@ def prepare_copilot_to_zhipu(
return prepared, adaptations
-# ── anthropic → zhipu 转换通道 ────────────────────────────────────
-
-# Anthropic beta 特有的 server_tool_use 块类型(web search, computer use 等).
-# 这些块在 Anthropic API 中有效,但 zhipu GLM-5 的兼容端点不支持。
-# 注意: 这与 zhipu 自己的 server_tool_use(使用 srvtoolu_* ID)是不同的概念,
-# 但它们共用同一个 type 名称 "server_tool_use"。
-_ANTHROPIC_BETA_BLOCK_TYPES = {"server_tool_use"}
-
-
-def prepare_anthropic_to_zhipu(
- body: dict[str, Any],
-) -> tuple[dict[str, Any], list[str]]:
- """anthropic → zhipu 转换: 清理 anthropic 产物以适配 GLM-5.
-
- Anthropic API 可能产生的非兼容产物:
- - ``server_tool_use`` blocks(web search / computer use 等 beta 功能)
- - ``thinking`` / ``redacted_thinking`` blocks(含 Anthropic 签发的 signature)
- - ``cache_control`` 字段
- - 顶层 ``thinking`` / ``extended_thinking`` 参数
-
- Returns:
- (prepared_body, adaptations) — adaptations 为应用的变换描述列表。
- """
- prepared = copy.deepcopy(body)
- adaptations: list[str] = []
-
- # Step 1: 剥离 anthropic 的 server_tool_use blocks(web search, computer use 等)
- removed_stu = _remove_vendor_blocks(prepared, _ANTHROPIC_BETA_BLOCK_TYPES)
- if removed_stu:
- adaptations.append(f"removed_{removed_stu}_server_tool_use_blocks")
-
- # Step 2: 剥离 thinking/redacted_thinking blocks
- stripped = strip_thinking_blocks(prepared)
- if stripped:
- adaptations.append(f"stripped_{stripped}_thinking_blocks")
-
- # Step 3: 移除 cache_control 字段
- removed_cc = _strip_cache_control(prepared)
- if removed_cc:
- adaptations.append(f"removed_{removed_cc}_cache_control_fields")
-
- # Step 4: 移除顶层 thinking/extended_thinking 参数(GLM-5 不支持)
- for param in ("thinking", "extended_thinking"):
- if param in prepared:
- del prepared[param]
- adaptations.append(f"removed_{param}_param")
-
- # Step 5: 强制 tool_use/tool_result 配对
- pairing_fixes = enforce_anthropic_tool_pairing(prepared.get("messages", []))
- if pairing_fixes:
- adaptations.extend(pairing_fixes)
-
- return prepared, adaptations
-
-
# ── zhipu → copilot 转换通道 ─────────────────────────────────────
@@ -736,54 +539,8 @@ def prepare_zhipu_to_anthropic(
return prepared, adaptations
-# ── zhipu → zhipu 自清理通道 ──────────────────────────────────────
-
-
-def prepare_zhipu_self_cleanup(
- body: dict[str, Any],
-) -> tuple[dict[str, Any], list[str]]:
- """zhipu → zhipu 自清理: 仅修复 zhipu 自身无法消化的产物.
-
- GLM-5 偶发地在 assistant 消息中输出 ``tool_result`` 块(违反 Anthropic 规范),
- 或在流式响应中暴露 ``server_tool_use_delta`` 私有块。当 Claude Code 将这些
- 产物原样回送下一轮请求时,zhipu 的 Anthropic 兼容端点会以 400 拒绝
- (表现为 "400 + tool_results" 偶发,进而触发到 copilot 的降级)。
-
- 本通道仅修复 zhipu 自身拒绝的两类产物,**保留** 所有 zhipu 原生支持的特性:
-
- - ✓ ``srvtoolu_*`` ID 与 ``server_tool_use`` 类型(zhipu 原生)
- - ✓ thinking blocks 的 zhipu 自签 signature
- - ✓ ``cache_control`` 字段(GLM Anthropic 端点支持,cache_read 已实证)
- - ✓ 顶层 ``thinking`` / ``extended_thinking`` 参数
-
- 清理操作(顺序、就地、幂等):
- 1. 剥离 ``server_tool_use_delta`` 流式残块
- 2. 强制 tool_use/tool_result 配对(关键: 把 assistant 内联的 tool_result
- 搬迁到紧随的 user 消息)
-
- Returns:
- (prepared_body, adaptations) — adaptations 为应用的变换描述列表。
- """
- prepared = copy.deepcopy(body)
- adaptations: list[str] = []
-
- # Step 1: 剥离 zhipu 私有流式块类型(input 中不应出现)
- removed_vendor_blocks = _remove_vendor_blocks(prepared, _ZHIPU_VENDOR_BLOCK_TYPES)
- if removed_vendor_blocks:
- adaptations.append(f"removed_{removed_vendor_blocks}_zhipu_vendor_blocks")
-
- # Step 2: 强制 tool_use/tool_result 配对
- pairing_fixes = enforce_anthropic_tool_pairing(prepared.get("messages", []))
- if pairing_fixes:
- adaptations.extend(pairing_fixes)
-
- return prepared, adaptations
-
-
# ── 注册所有转换通道 ──────────────────────────────────────────────
VENDOR_TRANSITIONS[("zhipu", "anthropic")] = prepare_zhipu_to_anthropic
VENDOR_TRANSITIONS[("zhipu", "copilot")] = prepare_zhipu_to_copilot
VENDOR_TRANSITIONS[("copilot", "zhipu")] = prepare_copilot_to_zhipu
-VENDOR_TRANSITIONS[("zhipu", "zhipu")] = prepare_zhipu_self_cleanup
-VENDOR_TRANSITIONS[("anthropic", "zhipu")] = prepare_anthropic_to_zhipu
diff --git a/src/coding/proxy/logging/db.py b/src/coding/proxy/logging/db.py
index 3c52e66..ffe9b2c 100644
--- a/src/coding/proxy/logging/db.py
+++ b/src/coding/proxy/logging/db.py
@@ -170,7 +170,8 @@ def _local_month_udf(ts_str: str) -> str:
client_category TEXT NOT NULL DEFAULT 'cc',
operation TEXT NOT NULL DEFAULT '',
endpoint TEXT NOT NULL DEFAULT '',
- extra_usage_json TEXT NOT NULL DEFAULT '{}'
+ extra_usage_json TEXT NOT NULL DEFAULT '{}',
+ session_key TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS usage_evidence (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -194,6 +195,7 @@ def _local_month_udf(ts_str: str) -> str:
CREATE INDEX IF NOT EXISTS idx_usage_vendor ON usage_log(vendor);
CREATE INDEX IF NOT EXISTS idx_usage_client_category ON usage_log(client_category);
CREATE INDEX IF NOT EXISTS idx_usage_operation ON usage_log(operation);
+CREATE INDEX IF NOT EXISTS idx_usage_session_key ON usage_log(session_key);
CREATE INDEX IF NOT EXISTS idx_usage_evidence_request_id ON usage_evidence(request_id);
CREATE INDEX IF NOT EXISTS idx_usage_evidence_vendor ON usage_evidence(vendor);
"""
@@ -247,6 +249,7 @@ async def init(self) -> None:
await self._migrate_rename_backend_to_vendor()
await self._migrate_add_failover_from()
await self._migrate_add_native_columns()
+ await self._migrate_add_session_key()
await self._db.executescript(_CREATE_INDEXES)
# 注册时区感知的日期函数:将 UTC 时间戳转为本地时间维度
await self._db.create_function("local_date", 1, _local_date_udf)
@@ -286,6 +289,18 @@ async def _migrate_add_native_columns(self) -> None:
await self._db.execute(f"ALTER TABLE usage_log ADD COLUMN {name} {ddl}")
logger.info("Migration: added %s column to usage_log", name)
+ async def _migrate_add_session_key(self) -> None:
+ """幂等迁移:为已有数据库添加 session_key 列."""
+ if not self._db:
+ return
+ cursor = await self._db.execute("PRAGMA table_info(usage_log)")
+ columns = {row["name"] for row in await cursor.fetchall()}
+ if "session_key" not in columns:
+ await self._db.execute(
+ "ALTER TABLE usage_log ADD COLUMN session_key TEXT NOT NULL DEFAULT ''"
+ )
+ logger.info("Migration: added session_key column to usage_log")
+
async def _migrate_rename_backend_to_vendor(self) -> None:
"""幂等迁移:重命名 backend 列为 vendor."""
if not self._db:
@@ -319,6 +334,7 @@ async def log(
operation: str = "",
endpoint: str = "",
extra_usage_json: str = "{}",
+ session_key: str = "",
) -> None:
if not self._db:
return
@@ -328,8 +344,8 @@ async def log(
input_tokens, output_tokens,
cache_creation_tokens, cache_read_tokens,
duration_ms, success, failover, failover_from, request_id,
- client_category, operation, endpoint, extra_usage_json)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
+ client_category, operation, endpoint, extra_usage_json, session_key)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
vendor,
model_requested,
@@ -347,6 +363,7 @@ async def log(
operation,
endpoint,
extra_usage_json,
+ session_key,
),
)
await self._db.commit()
@@ -573,6 +590,63 @@ async def query_window_total(
row = await cursor.fetchone()
return row["total"] if row else 0
+ async def query_recent_sessions(
+ self,
+ limit: int = 20,
+ hours: float = 24.0,
+ ) -> list[dict]:
+ """按 session_key 聚合近期活跃会话统计."""
+ if not self._db:
+ return []
+ cutoff_iso = _hours_ago_utc_iso(hours)
+ cursor = await self._db.execute(
+ """SELECT session_key,
+ MIN(ts) AS first_seen_ts,
+ MAX(ts) AS last_active_ts,
+ COUNT(*) AS total_requests,
+ SUM(input_tokens + output_tokens) AS total_tokens,
+ SUM(input_tokens) AS total_input,
+ SUM(output_tokens) AS total_output,
+ GROUP_CONCAT(DISTINCT model_served) AS models,
+ GROUP_CONCAT(DISTINCT vendor) AS vendors,
+ AVG(duration_ms) AS avg_duration_ms,
+ SUM(CASE WHEN success THEN 1 ELSE 0 END) * 100.0 / COUNT(*) AS success_rate,
+ GROUP_CONCAT(DISTINCT client_category) AS client_categories
+ FROM usage_log
+ WHERE session_key != '' AND ts >= ?
+ GROUP BY session_key
+ ORDER BY last_active_ts DESC
+ LIMIT ?""",
+ (cutoff_iso, limit),
+ )
+ rows = await cursor.fetchall()
+ return [dict(row) for row in rows]
+
+ async def query_session_profile(self, session_key: str) -> dict | None:
+ """查询单个会话的完整聚合数据."""
+ if not self._db:
+ return None
+ cursor = await self._db.execute(
+ """SELECT session_key,
+ MIN(ts) AS first_seen_ts,
+ MAX(ts) AS last_active_ts,
+ COUNT(*) AS total_requests,
+ SUM(input_tokens + output_tokens) AS total_tokens,
+ SUM(input_tokens) AS total_input,
+ SUM(output_tokens) AS total_output,
+ GROUP_CONCAT(DISTINCT model_served) AS models,
+ GROUP_CONCAT(DISTINCT vendor) AS vendors,
+ AVG(duration_ms) AS avg_duration_ms,
+ SUM(CASE WHEN success THEN 1 ELSE 0 END) * 100.0 / COUNT(*) AS success_rate,
+ GROUP_CONCAT(DISTINCT client_category) AS client_categories
+ FROM usage_log
+ WHERE session_key = ?
+ GROUP BY session_key""",
+ (session_key,),
+ )
+ row = await cursor.fetchone()
+ return dict(row) if row else None
+
async def close(self) -> None:
if self._db:
await self._db.close()
diff --git a/src/coding/proxy/routing/executor.py b/src/coding/proxy/routing/executor.py
index 2a27665..9d33ca9 100644
--- a/src/coding/proxy/routing/executor.py
+++ b/src/coding/proxy/routing/executor.py
@@ -31,6 +31,7 @@
parse_rate_limit_headers,
)
from .session_manager import RouteSessionManager
+from .session_policy import SessionPolicyResolver
from .tier import VendorTier
from .usage_parser import (
build_usage_evidence_records,
@@ -135,6 +136,10 @@ def _is_likely_request_format_error(
# 非结构化响应体(非 JSON)
if not trimmed.startswith("{") and len(trimmed) < 200:
return True
+ # 结构化 JSON 400 但含 tool_call 格式错误码 → 格式不兼容
+ # (如 Copilot 返回 {"error":{"code":"invalid_tool_call_format",...}})
+ if "invalid_tool_call_format" in trimmed:
+ return True
return False
@@ -207,12 +212,14 @@ def __init__(
usage_recorder: UsageRecorder,
session_manager: RouteSessionManager,
reauth_coordinator: Any | None = None,
+ session_policy_resolver: SessionPolicyResolver | None = None,
) -> None:
self._router = router
self._tiers = tiers
self._recorder = usage_recorder
self._session_mgr = session_manager
self._reauth_coordinator = reauth_coordinator
+ self._policy_resolver = session_policy_resolver or SessionPolicyResolver()
# Tier 名称 → OAuth provider 名称的映射
self._tier_provider_map: dict[str, str] = {
@@ -222,6 +229,30 @@ def __init__(
# ── 公开执行入口 ──────────────────────────────────────
+ def _resolve_effective_tiers(self, session_key: str) -> list[VendorTier]:
+ """根据 Session Policy 解析生效的 tier 顺序.
+
+ 策略指定的 vendor 按其顺序排列在头部,未提及的保持在末尾。
+ 无策略时返回全局默认顺序。
+ """
+ policy = self._policy_resolver.resolve(session_key)
+ if not policy or not policy.tiers:
+ return self._tiers
+
+ name_to_tier = {t.name: t for t in self._tiers}
+ ordered: list[VendorTier] = []
+ seen: set[str] = set()
+ for name in policy.tiers:
+ tier = name_to_tier.get(name)
+ if tier and name not in seen:
+ ordered.append(tier)
+ seen.add(name)
+ for tier in self._tiers:
+ if tier.name not in seen:
+ ordered.append(tier)
+ seen.add(tier.name)
+ return ordered
+
def _prepare_body_for_tier(
self,
body: dict[str, Any],
@@ -264,34 +295,38 @@ def _determine_source_vendor(
Priority 1: failed_tier_name(请求内故障转移,最可靠)。
Priority 2: session_record.provider_state 中有已注册转换的 vendor(跨请求)。
Priority 3: 从 body 内容推断(兜底首次请求无会话状态场景)。
-
- 同 vendor 自转换(source == target)仅在 ``VENDOR_TRANSITIONS`` 显式注册
- 了对应通道时启用(如 ``("zhipu","zhipu")`` 修复 zhipu 自身不接受的产物),
- 否则退化到无源行为。
"""
from ..convert.vendor_channels import (
get_transition_channel,
infer_source_vendor_from_body,
)
- # 请求内:刚失败的 tier 就是源
- # 同 vendor 自转换仅在显式注册通道时生效
- if failed_tier_name and (
- failed_tier_name != target_name
- or get_transition_channel(failed_tier_name, target_name) is not None
+ # 请求内:刚失败的 tier 就是源(仅当存在已注册的转换通道时)
+ # 修复:原逻辑仅检查 failed_tier != target 就无条件返回,
+ # 导致无注册通道的 failed_tier(如 copilot→anthropic)阻断降级到
+ # Priority 2/3,原始 body 中的 server_tool_use 等非标准块未被清理。
+ if (
+ failed_tier_name
+ and get_transition_channel(failed_tier_name, target_name) is not None
):
return failed_tier_name
- # 跨请求:从会话历史找有注册转换的源(含已注册自转换)
+ # 跨请求:从会话历史找有注册转换的源
if session_record is not None and session_record.provider_state:
for source in session_record.provider_state:
- if get_transition_channel(source, target_name):
+ if source != target_name and get_transition_channel(
+ source, target_name
+ ):
return source
- # 首次请求兜底:从 body 内容推断(识别 zhipu 产物等,含已注册自转换)
+ # 首次请求兜底:从 body 内容推断(识别 zhipu 产物等)
if body is not None:
inferred = infer_source_vendor_from_body(body)
- if inferred and get_transition_channel(inferred, target_name):
+ if (
+ inferred
+ and inferred != target_name
+ and get_transition_channel(inferred, target_name)
+ ):
return inferred
return None
@@ -302,7 +337,6 @@ async def execute_stream(
headers: dict[str, str],
) -> AsyncIterator[tuple[bytes, str]]:
"""路由流式请求,按优先级尝试各层级."""
- last_idx = len(self._tiers) - 1
last_exc: Exception | None = None
failed_tier_name: str | None = None
request_caps = build_request_capabilities(body)
@@ -312,8 +346,10 @@ async def execute_stream(
canonical_request.trace_id,
)
incompatible_reasons: list[str] = []
+ effective_tiers = self._resolve_effective_tiers(canonical_request.session_key)
+ last_idx = len(effective_tiers) - 1
- for i, tier in enumerate(self._tiers):
+ for i, tier in enumerate(effective_tiers):
is_last = i == last_idx
gate = await self._try_gate_tier(
@@ -396,6 +432,7 @@ async def execute_stream(
model_served=model_served,
request_id=info.request_id,
),
+ session_key=canonical_request.session_key,
)
self._router._active_vendor_name = tier.name # 更新活跃供应商
return
@@ -471,7 +508,6 @@ async def execute_message(
headers: dict[str, str],
) -> VendorResponse:
"""路由非流式请求,按优先级尝试各层级."""
- last_idx = len(self._tiers) - 1
start = time.monotonic()
failed_tier_name: str | None = None
request_caps = build_request_capabilities(body)
@@ -481,8 +517,10 @@ async def execute_message(
canonical_request.trace_id,
)
incompatible_reasons: list[str] = []
+ effective_tiers = self._resolve_effective_tiers(canonical_request.session_key)
+ last_idx = len(effective_tiers) - 1
- for i, tier in enumerate(self._tiers):
+ for i, tier in enumerate(effective_tiers):
is_last = i == last_idx
gate = await self._try_gate_tier(
@@ -537,6 +575,7 @@ async def execute_message(
model_served=model_served,
usage=resp.usage,
),
+ session_key=canonical_request.session_key,
)
self._router._active_vendor_name = tier.name # 更新活跃供应商
return resp
@@ -620,6 +659,7 @@ async def execute_message(
evidence_records=self._recorder.build_nonstream_evidence_records(
vendor=tier.name, model_served=model_served, usage=resp.usage
),
+ session_key=canonical_request.session_key,
)
return resp
diff --git a/src/coding/proxy/routing/router.py b/src/coding/proxy/routing/router.py
index 3a65cd6..32757a8 100644
--- a/src/coding/proxy/routing/router.py
+++ b/src/coding/proxy/routing/router.py
@@ -18,6 +18,7 @@
from .executor import _RouteExecutor
from .session_manager import RouteSessionManager
+from .session_policy import SessionPolicyResolver
from .tier import VendorTier
# 向后兼容别名
@@ -36,6 +37,7 @@ def __init__(
token_logger: TokenLogger | None = None,
reauth_coordinator: Any | None = None,
compat_session_store: CompatSessionStore | None = None,
+ session_policy_resolver: SessionPolicyResolver | None = None,
) -> None:
if not tiers:
raise ValueError("至少需要一个供应商层级")
@@ -53,6 +55,7 @@ def __init__(
usage_recorder=self._recorder,
session_manager=self._session_mgr,
reauth_coordinator=reauth_coordinator,
+ session_policy_resolver=session_policy_resolver,
)
def set_pricing_table(self, table: PricingTable) -> None:
diff --git a/src/coding/proxy/routing/session_policy.py b/src/coding/proxy/routing/session_policy.py
new file mode 100644
index 0000000..9102e41
--- /dev/null
+++ b/src/coding/proxy/routing/session_policy.py
@@ -0,0 +1,116 @@
+"""Session Policy 解析引擎 — 根据 session_key + client_category 解析适用的路由策略."""
+
+from __future__ import annotations
+
+import logging
+import threading
+
+from ..config.session_policy import SessionPolicy, SessionPolicyMatch
+
+logger = logging.getLogger(__name__)
+
+
+class SessionPolicyResolver:
+ """根据 session_key + client_category 解析适用的 SessionPolicy.
+
+ 设计要点:
+ - 启动时构建索引,运行时 O(1) 查找
+ - 精确匹配优先:session_key > client_category > 无策略
+ - 无侵入性:不匹配时返回 None,路由行为与现有一致
+ - 运行时可变:支持 API 动态 upsert/remove session → vendor 绑定
+ """
+
+ def __init__(self, policies: list[SessionPolicy] | None = None) -> None:
+ self._policies = policies or []
+ self._key_index: dict[str, SessionPolicy] = {}
+ self._category_index: dict[str, SessionPolicy] = {}
+ self._config_key_backup: dict[str, SessionPolicy] = {}
+ self._lock = threading.Lock()
+ self._build_index()
+
+ def _build_index(self) -> None:
+ """构建 session_key / client_category → SessionPolicy 的查找索引.
+
+ 按定义顺序遍历,首次出现的 key/category 获得最高优先级。
+ """
+ for policy in self._policies:
+ for key in policy.match.session_keys:
+ if key not in self._key_index:
+ self._key_index[key] = policy
+ if (
+ policy.match.client_category
+ and policy.match.client_category not in self._category_index
+ ):
+ self._category_index[policy.match.client_category] = policy
+
+ if self._key_index or self._category_index:
+ logger.info(
+ "SessionPolicyResolver initialized: %d key rules, %d category rules",
+ len(self._key_index),
+ len(self._category_index),
+ )
+
+ def resolve(
+ self, session_key: str, client_category: str = "cc"
+ ) -> SessionPolicy | None:
+ """返回匹配的策略,优先精确 session_key 匹配,其次 category 匹配.
+
+ 返回的 SessionPolicy 对象应为不可变引用;调用方不应修改其内部属性,
+ 否则在并发 upsert/remove 场景下可能产生竞态。
+ """
+ with self._lock:
+ policy = self._key_index.get(session_key)
+ if policy:
+ return policy
+ return self._category_index.get(client_category)
+
+ # ── 运行时 session → vendor 绑定 ──────────────────────────────
+
+ def upsert(self, session_key: str, tier_names: list[str]) -> SessionPolicy:
+ """为指定 session key 创建或替换运行时 vendor 绑定.
+
+ 运行时策略使用 ``runtime:`` 名称前缀,与配置文件驱动的策略区分。
+ """
+ policy = SessionPolicy(
+ name=f"runtime:{session_key}",
+ match=SessionPolicyMatch(session_keys=[session_key]),
+ tiers=tier_names,
+ )
+ with self._lock:
+ existing = self._key_index.get(session_key)
+ if existing and not existing.name.startswith("runtime:"):
+ self._config_key_backup[session_key] = existing
+ self._key_index[session_key] = policy
+ logger.info(
+ "Session vendor binding upserted: session_key=%s → %s",
+ session_key,
+ tier_names,
+ )
+ return policy
+
+ def remove(self, session_key: str) -> bool:
+ """删除指定 session key 的运行时 vendor 绑定.
+
+ Returns:
+ True 如果找到并删除了绑定,False 如果不存在。
+ """
+ with self._lock:
+ policy = self._key_index.get(session_key)
+ if policy is None or not policy.name.startswith("runtime:"):
+ return False
+ del self._key_index[session_key]
+ # 恢复被运行时绑定覆盖的配置策略
+ backup = self._config_key_backup.pop(session_key, None)
+ if backup is not None:
+ self._key_index[session_key] = backup
+ logger.info("Session vendor binding removed: session_key=%s", session_key)
+ return True
+
+ def list_runtime_bindings(self) -> list[dict[str, str | list[str]]]:
+ """返回所有运行时注入的绑定快照(仅 API 创建的,不含配置文件驱动的)."""
+ with self._lock:
+ return [
+ {"session_key": key, "vendors": policy.tiers}
+ for key, policy in self._key_index.items()
+ if policy.name.startswith("runtime:")
+ ]
diff --git a/src/coding/proxy/routing/usage_parser.py b/src/coding/proxy/routing/usage_parser.py
index e07b187..0d6509c 100644
--- a/src/coding/proxy/routing/usage_parser.py
+++ b/src/coding/proxy/routing/usage_parser.py
@@ -210,6 +210,9 @@ def parse_usage_from_chunk(
request_id=data.get("id"),
model_served=data.get("model"),
)
+ model_name = data.get("model")
+ if model_name:
+ usage["model_served"] = model_name
# Gemini SSE 格式: data.usageMetadata.{promptTokenCount, candidatesTokenCount, cachedContentTokenCount, thoughtsTokenCount, toolUsePromptTokenCount}
# Gemini 的流式响应在最后一帧(或每一帧)携带 usageMetadata;字段命名与
@@ -243,6 +246,9 @@ def parse_usage_from_chunk(
request_id=data.get("responseId") or data.get("id"),
model_served=data.get("modelVersion") or data.get("model"),
)
+ model_name = data.get("modelVersion") or data.get("model")
+ if model_name:
+ usage["model_served"] = model_name
# request_id fallback (OpenAI 格式下 id 在顶层, Gemini 顶层为 responseId)
if not usage.get("request_id"):
diff --git a/src/coding/proxy/routing/usage_recorder.py b/src/coding/proxy/routing/usage_recorder.py
index da66978..525a6c1 100644
--- a/src/coding/proxy/routing/usage_recorder.py
+++ b/src/coding/proxy/routing/usage_recorder.py
@@ -97,6 +97,7 @@ async def record(
operation: str = "",
endpoint: str = "",
extra_usage: dict[str, Any] | None = None,
+ session_key: str = "",
) -> None:
"""记录用量到 TokenLogger.
@@ -141,6 +142,7 @@ async def record(
operation=operation,
endpoint=endpoint,
extra_usage_json=extra_usage_json,
+ session_key=session_key,
)
if not evidence_records:
return
diff --git a/src/coding/proxy/server/app.py b/src/coding/proxy/server/app.py
index ec1f1e4..5ce8011 100644
--- a/src/coding/proxy/server/app.py
+++ b/src/coding/proxy/server/app.py
@@ -23,6 +23,7 @@
from ..logging.db import TokenLogger
from ..native_api import NativeProxyHandler
from ..routing.router import RequestRouter
+from ..routing.session_policy import SessionPolicyResolver
from ..routing.tier import VendorTier
from ..routing.usage_recorder import UsageRecorder
from ..vendors.antigravity import AntigravityVendor
@@ -155,7 +156,11 @@ def create_app(config: ProxyConfig | None = None) -> FastAPI:
)
router = RequestRouter(
- tiers, token_logger, reauth_coordinator, compat_session_store
+ tiers,
+ token_logger,
+ reauth_coordinator,
+ compat_session_store,
+ session_policy_resolver=SessionPolicyResolver(config.session_policies.policies),
)
app = FastAPI(title="coding-proxy", version=__version__, lifespan=lifespan)
diff --git a/src/coding/proxy/server/dashboard.py b/src/coding/proxy/server/dashboard.py
index d0afcd4..07bd6a3 100644
--- a/src/coding/proxy/server/dashboard.py
+++ b/src/coding/proxy/server/dashboard.py
@@ -159,7 +159,7 @@ def _build_favicon() -> bytes:
.kpi-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
- gap: 16px;
+ gap: 5px;
margin-bottom: 24px;
}
.kpi-card {
@@ -310,6 +310,7 @@ def _build_favicon() -> bytes:
}
.vendor-name { font-weight: 600; font-size: 14px; }
.vendor-badges { display: flex; gap: 5px; flex-wrap: wrap; align-items: center; }
+ .quota-group { display: flex; align-items: center; gap: 6px; }
.status-badge {
font-size: 11px; padding: 2px 7px;
border-radius: 10px;
@@ -319,7 +320,7 @@ def _build_favicon() -> bytes:
.sb-warn { background: rgba(210,153,34,.12); color: var(--accent-yellow); border: 1px solid rgba(210,153,34,.2); }
.sb-err { background: rgba(248,81,73,.12); color: var(--accent-red); border: 1px solid rgba(248,81,73,.2); }
.sb-info { background: rgba(88,166,255,.12); color: var(--accent-blue); border: 1px solid rgba(88,166,255,.2); }
- .quota-bar-wrap { flex: 1; margin: 0 10px; max-width: 100px; }
+ .quota-bar-wrap { flex: 1; min-width: 40px; max-width: 100px; }
.quota-bar-bg {
height: 4px; border-radius: 2px;
background: rgba(255,255,255,.06);
@@ -395,6 +396,98 @@ def _build_favicon() -> bytes:
color: var(--text-tertiary); font-size: 14px;
}
.empty-icon { font-size: 32px; margin-bottom: 8px; opacity: .5; }
+ /* ── Sessions Panel ── */
+ .sessions-card { grid-column: 1 / -1; animation-delay: .1s; }
+ .session-table-wrap { overflow: hidden; }
+ .session-table { width: 100%; border-collapse: collapse; font-size: 13px; table-layout: fixed; }
+ .session-table th {
+ position: sticky; top: 0; z-index: 1;
+ background: var(--bg-card); padding: 10px 12px;
+ text-align: left; font-weight: 600; font-size: 12px;
+ color: var(--text-secondary); text-transform: uppercase; letter-spacing: .5px;
+ border-bottom: 1px solid var(--border);
+ }
+ .session-table td { padding: 8px 12px; border-bottom: 1px solid var(--border-subtle); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+ .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-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; }
+ .copy-btn:hover { opacity: 1; color: var(--accent-blue); background: rgba(88,166,255,.1); }
+ .copy-btn.copied { color: var(--accent-green); opacity: 1; }
+ .session-meta { font-size: 10px; color: var(--text-tertiary); line-height: 1.2; margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+ .session-tag {
+ display: inline-block; font-size: 11px; padding: 2px 7px;
+ border-radius: 8px; margin: 1px 2px;
+ background: rgba(88,166,255,.08); border: 1px solid rgba(88,166,255,.15);
+ color: var(--text-secondary);
+ }
+ .session-tag-cc {
+ background: rgba(63,185,80,.08); border-color: rgba(63,185,80,.15);
+ }
+ .session-table td.cell-success { overflow: visible; text-overflow: clip; }
+ /* ── 展开行 ── */
+ .session-table tr.row-detail { display: none; }
+ .session-table tr.row-detail.open { display: table-row; }
+ .session-table tr.row-detail td { padding: 0; }
+ .detail-card {
+ padding: 16px 24px; margin: 6px 0;
+ background: linear-gradient(135deg, rgba(30,37,54,.95), rgba(22,28,40,.95));
+ border: 1px solid rgba(88,166,255,.15); border-radius: 12px;
+ font-size: 13px;
+ white-space: normal; overflow: hidden;
+ box-shadow: 0 4px 16px rgba(0,0,0,.3);
+ }
+ .detail-card .detail-item { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
+ .detail-card .detail-label { font-size: 11px; color: var(--text-tertiary); text-transform: uppercase; letter-spacing: .3px; }
+ .detail-card .detail-value { color: var(--text-primary); line-height: 1.4; word-break: break-all; overflow-wrap: break-word; }
+ .detail-identity-row {
+ display: flex; gap: 16px;
+ padding-bottom: 10px; margin-bottom: 10px;
+ border-bottom: 1px solid var(--border);
+ }
+ .detail-identity-row .detail-item { flex: 3 1 0; }
+ .detail-identity-row .detail-item:first-child { flex: 2 1 0; }
+ .detail-identity-row .detail-value { font-family: 'JetBrains Mono', monospace; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; word-break: normal; }
+ .detail-metrics-grid {
+ display: grid;
+ grid-template-columns: repeat(8, 1fr);
+ gap: 10px 16px;
+ }
+ .detail-inline-pair { display: flex; gap: 16px; }
+ .detail-inline-pair > div { flex: 1; display: flex; flex-direction: column; gap: 2px; min-width: 0; }
+ .session-table tbody tr[data-row]:not(.row-detail) { cursor: pointer; }
+ .success-bar { width: 56px; height: 4px; border-radius: 2px; background: rgba(255,255,255,.12); display: inline-block; vertical-align: middle; margin-left: 6px; }
+ .success-bar-fill { height: 100%; border-radius: 2px; display: block; }
+ /* ── Vendor Bind 选择器 ── */
+ .bind-select {
+ padding: 3px 6px; border-radius: 6px;
+ background: rgba(48,54,61,.6); border: 1px solid rgba(255,255,255,.1);
+ color: var(--text-secondary); font-size: 12px;
+ font-family: 'JetBrains Mono', monospace;
+ cursor: pointer; outline: none;
+ transition: all .2s ease;
+ max-width: 120px;
+ }
+ .bind-select:hover { border-color: rgba(88,166,255,.4); color: var(--text-primary); }
+ .bind-select:focus { border-color: rgba(88,166,255,.6); box-shadow: 0 0 0 2px rgba(88,166,255,.1); }
+ .bind-select option { background: var(--bg-card); color: var(--text-primary); }
+ /* ── 分页 ── */
+ .session-pagination {
+ display: flex; align-items: center; justify-content: space-between;
+ padding: 10px 12px; border-top: 1px solid var(--border-subtle);
+ font-size: 12px; color: var(--text-secondary);
+ }
+ .page-btn {
+ padding: 4px 10px; border-radius: 6px;
+ background: rgba(48,54,61,.4); border: 1px solid rgba(255,255,255,.08);
+ color: var(--text-secondary); font-size: 12px; cursor: pointer;
+ transition: all .15s ease;
+ }
+ .page-btn:hover:not(:disabled) { background: var(--bg-card-hover); color: var(--text-primary); border-color: rgba(88,166,255,.3); }
+ .page-btn:disabled { opacity: .35; cursor: default; }
+ .page-info { font-family: 'JetBrains Mono', monospace; font-size: 12px; }
/* ── 加载态 ── */
.loading { opacity: .4; pointer-events: none; }
/* ── 图表标签截断 ── */
@@ -435,6 +528,34 @@ def _build_favicon() -> bytes:
margin-top: 6px; padding-top: 6px; border-top: 1px solid var(--border-subtle);
font-weight: 500; font-size: 12px; color: var(--text-secondary);
}
+ /* ── Tabs ─────────────────────────────────────────────────── */
+ .tabs {
+ display: flex;
+ gap: 2px;
+ padding: 0;
+ }
+ .tab-btn {
+ appearance: none;
+ background: transparent;
+ border: 1px solid transparent;
+ color: var(--text-secondary);
+ cursor: pointer;
+ font-family: inherit;
+ font-size: 13px;
+ font-weight: 500;
+ padding: 4px 12px;
+ transition: color .15s ease, background .15s ease, border-color .15s ease;
+ border-radius: var(--radius-sm);
+ }
+ .tab-btn:hover { color: var(--text-primary); background: var(--bg-card-hover); }
+ .tab-btn.active {
+ color: var(--text-primary);
+ background: rgba(88,166,255,.1);
+ border-color: rgba(88,166,255,.2);
+ }
+ .tab-btn:focus-visible { outline: 2px solid var(--accent-blue); outline-offset: 2px; }
+ .tab-pane { display: none; }
+ .tab-pane.active { display: block; }
@@ -445,12 +566,18 @@ def _build_favicon() -> bytes:
v-.-.-
+
+
时间区间
@@ -540,6 +667,55 @@ def _build_favicon() -> bytes:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Session ID |
+ Last Active |
+ Requests |
+ Tokens |
+ Models |
+ Vendors |
+ Avg Latency |
+ Success |
+ Vendor Bind |
+ Client |
+
+
+
+ | Loading... |
+
+
+
+
+
+
@@ -580,7 +756,32 @@ def _build_favicon() -> bytes:
return String(n);
}
function fmtNum(n) { return n == null ? '–' : n.toLocaleString(); }
+function copyFromParent(btn) {
+ var text = btn.parentElement.getAttribute('data-key') || btn.parentElement.getAttribute('title') || '';
+ navigator.clipboard.writeText(text).then(function() {
+ btn.classList.add('copied');
+ btn.textContent = '✓';
+ setTimeout(function() { btn.classList.remove('copied'); btn.textContent = '⧉'; }, 1500);
+ });
+}
+function toggleRow(tr) {
+ var detail = tr.nextElementSibling;
+ if (!detail || !detail.classList.contains('row-detail')) return;
+ var wasOpen = detail.classList.contains('open');
+ // close all open rows first
+ document.querySelectorAll('.session-table tr.row-detail.open').forEach(function(r) { r.classList.remove('open'); });
+ if (!wasOpen) detail.classList.add('open');
+}
function isValidLabel(s) { return typeof s === 'string' && s !== 'undefined' && s !== 'null' && s.trim() !== ''; }
+function fmtDuration(ms) {
+ if (ms == null) return '–';
+ var s = ms / 1000;
+ if (s < 1) return Math.round(ms) + 'ms';
+ if (s < 60) return s.toFixed(1).replace(/\\.0$/, '') + 's';
+ var m = Math.floor(s / 60);
+ var sec = Math.round(s % 60);
+ return sec > 0 ? m + 'min ' + sec + 's' : m + 'min';
+}
function now() {
return new Date().toLocaleTimeString('zh-CN', {hour:'2-digit',minute:'2-digit',second:'2-digit'});
}
@@ -590,12 +791,15 @@ def _build_favicon() -> bytes:
// _API_VENDORS 需与后端 native_api/handler.py::_VENDOR_LABEL 对齐,
// 新增无 -native 后缀的 native vendor 时同步更新本集合。
const _API_VENDORS = new Set(['anthropic-native', 'openai', 'gemini']);
+function isApiVendor(v) { return _API_VENDORS.has(v); }
+function vendorShortName(v) {
+ if (!isValidLabel(v)) return v;
+ if (isApiVendor(v)) return v.endsWith('-native') ? v.slice(0, -'-native'.length) : v;
+ return v;
+}
function formatVendorLabel(v) {
if (!isValidLabel(v)) return v;
- if (_API_VENDORS.has(v)) {
- const name = v.endsWith('-native') ? v.slice(0, -'-native'.length) : v;
- return 'api | ' + name;
- }
+ if (isApiVendor(v)) return 'api | ' + vendorShortName(v);
return 'cc | ' + v;
}
@@ -886,10 +1090,11 @@ def _build_favicon() -> bytes:
if (!qg || qg.usage_percent == null) return '';
const pct = Math.round(qg.usage_percent);
const label = quotaWindowLabel(qg.window_hours);
- return `${label} ${pct}%` +
+ return `` +
+ `
${label} ${pct}%` +
`
`;
+ `
`;
}
function updateVendorStatus(status) {
@@ -1263,47 +1468,340 @@ def _build_favicon() -> bytes:
if (mt) mt.textContent = label + ' Token 用量(按 Vendor / 模型)';
}
-// ── 主刷新逻辑 ────────────────────────────────────────────
-let refreshing = false;
-async function refresh() {
- if (refreshing) return;
- refreshing = true;
- document.getElementById('refresh-time').textContent = '刷新中…';
+// ── Sessions Panel ──────────────────────────────────────────────
+function relativeTime(tsStr) {
+ if (!tsStr) return '–';
+ var d = new Date(tsStr.replace('Z', '+00:00'));
+ var diff = (Date.now() - d.getTime()) / 1000;
+ if (diff < 60) return 'just now';
+ if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
+ if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
+ return Math.floor(diff / 86400) + 'd ago';
+}
+function escapeHtml(s) {
+ if (!s) return '';
+ return s.replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"').replace(/'/g,''');
+}
+function truncateKey(key, maxLen) {
+ if (!key || key.length <= maxLen) return escapeHtml(key) || '–';
+ return escapeHtml(key.slice(0, maxLen - 3)) + '…';
+}
+function parseSessionKey(raw) {
+ try { var o = JSON.parse(raw); return { device_id: o.device_id||'', account_uuid: o.account_uuid||'', session_id: o.session_id||'' }; }
+ catch(e) { return { device_id:'', account_uuid:'', session_id: raw || '' }; }
+}
+function shortId(s, n) { return s ? (s.length <= n ? s : s.slice(0, n) + '…') : ''; }
+function successBarHtml(pct) {
+ if (pct == null) return '–';
+ var p = Math.round(pct);
+ var color = p >= 95 ? 'var(--accent-green)' : (p >= 80 ? 'var(--accent-yellow)' : 'var(--accent-red)');
+ return '' + p + '%' +
+ '';
+}
+function formatSessionTags(str, max) {
+ if (!str) return '–';
+ var list = str.split(',');
+ var html = list.slice(0, max).map(function(c) {
+ return '' + escapeHtml(c.trim()) + '';
+ }).join('');
+ if (list.length > max) {
+ var fullList = list.map(function(c) { return c.trim(); }).join(', ');
+ html += '+' + (list.length - max) + '';
+ }
+ return html;
+}
+function formatCategories(cats) {
+ if (!cats) return '–';
+ return cats.split(',').map(function(c) {
+ var t = c.trim();
+ var label = t === 'cc' ? 'Claude Code' : (t === 'api' ? 'API' : escapeHtml(t));
+ return '' + label + '';
+ }).join('');
+}
+function formatVendorTags(vendors) {
+ if (!vendors) return '–';
+ var list = vendors.split(',');
+ var max = 4;
+ var html = list.slice(0, max).map(function(v) {
+ var vt = v.trim();
+ var name = vendorShortName(vt);
+ var fullLabel = formatVendorLabel(vt);
+ var cls = isApiVendor(vt) ? 'session-tag' : 'session-tag session-tag-cc';
+ return '' + escapeHtml(name) + '';
+ }).join('');
+ if (list.length > max) {
+ var fullList = list.map(function(v) { return formatVendorLabel(v.trim()); }).join(', ');
+ html += '+' + (list.length - max) + '';
+ }
+ return html;
+}
+// ── Sessions Pagination State ──
+var allSessions = [];
+var sessionPage = 0;
+var sessionPageSize = 30;
+var sessionBindMap = {};
+var sessionAvailableVendors = [];
+
+async function updateSessions() {
try {
- const days = currentDays > 0 ? currentDays : 7;
- const [summary, timeline, status] = await Promise.all([
- fetchJSON('/api/dashboard/summary?days=' + days),
- fetchJSON('/api/dashboard/timeline?days=' + days),
+ var results = await Promise.allSettled([
+ fetchJSON('/api/dashboard/sessions?hours=24&limit=200'),
+ fetchJSON('/api/session-vendor'),
fetchJSON('/api/status'),
]);
+ if (results[0].status === 'rejected') throw results[0].reason;
+ var data = results[0].value;
+ var bindData = results[1].status === 'fulfilled' ? results[1].value : {bindings: []};
+ var statusData = results[2].status === 'fulfilled' ? results[2].value : {tiers: []};
+ allSessions = data.sessions || [];
+ sessionBindMap = {};
+ (bindData.bindings || []).forEach(function(b) { sessionBindMap[b.session_key] = b.vendors; });
+ sessionAvailableVendors = (statusData.tiers || []).map(function(t) { return t.name; });
+ sessionPage = 0;
+ renderSessionPage();
+ } catch (e) {
+ console.error('Sessions refresh error:', e);
+ }
+}
- if (summary.version) {
- document.getElementById('version-badge').textContent = 'v' + summary.version;
- }
+function renderSessionPage() {
+ var total = allSessions.length;
+ var totalPages = Math.max(1, Math.ceil(total / sessionPageSize));
+ if (sessionPage >= totalPages) sessionPage = totalPages - 1;
+ var start = sessionPage * sessionPageSize;
+ var page = allSessions.slice(start, start + sessionPageSize);
+ var tbody = document.getElementById('sessions-tbody');
- updateKPI(summary);
- updateVendorStatus(status);
- updateChartTitles(days);
+ if (!total) {
+ tbody.innerHTML = '📭 No session data |
';
+ } else {
+ tbody.innerHTML = page.map(function(s) {
+ var parsed = parseSessionKey(s.session_key);
+ var boundVendors = sessionBindMap[s.session_key];
+ var selectHtml = buildBindSelect(s.session_key, boundVendors, sessionAvailableVendors);
+ 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;
+ return '' +
+ '| ' +
+ ' ' +
+ '' + escapeHtml(parsed.session_id || s.session_key) + '' +
+ '' +
+ ' ' +
+ '' +
+ 'dev:' + escapeHtml(shortId(parsed.device_id, 8)) + ' · acct:' + escapeHtml(shortId(parsed.account_uuid, 8)) +
+ ' ' +
+ ' | ' +
+ '' + relativeTime(s.last_active_ts) + ' | ' +
+ '' + fmtNum(s.total_requests) + ' | ' +
+ '' + fmtTokens(s.total_tokens) + ' | ' +
+ '' + formatSessionTags(s.models, 3) + ' | ' +
+ '' + formatVendorTags(s.vendors) + ' | ' +
+ '' + fmtDuration(s.avg_duration_ms) + ' | ' +
+ '' + successBarHtml(s.success_rate) + ' | ' +
+ '' + selectHtml + ' | ' +
+ '' + formatCategories(s.client_categories) + ' | ' +
+ '
' +
+ '' +
+ ' ' +
+ ' Session ID ' + escapeHtml(parsed.session_id || s.session_key) + ' ' +
+ ' Device ' + (parsed.device_id ? escapeHtml(parsed.device_id) : '–') + ' ' +
+ ' Account ' + (parsed.account_uuid ? escapeHtml(parsed.account_uuid) : '–') + ' ' +
+ ' ' +
+ ' ' +
+ ' Last Active ' + relativeTime(s.last_active_ts) + ' ' +
+ ' Requests ' + fmtNum(s.total_requests) + ' ' +
+ ' Tokens ' + fmtTokens(s.total_tokens) + ' ' +
+ ' Models ' + (modelsFull.length ? modelsFull.map(function(m){return '' + escapeHtml(m) + '';}).join(' ') : '–') + ' ' +
+ ' Vendors ' + (vendorsFull.length ? vendorsFull.map(function(v){return '' + escapeHtml(v) + '';}).join(' ') : '–') + ' ' +
+ ' Avg Latency ' + fmtDuration(s.avg_duration_ms) + ' ' +
+ ' ' +
+ ' Success Rate ' + (sr != null ? sr + '%' : '–') + ' ' +
+ ' Client ' + escapeHtml(s.client_categories || '–') + ' ' +
+ ' ' +
+ ' ' +
+ ' |
';
+ }).join('');
+ }
- const rows = timeline.rows || [];
- const tierOrder = (status.tiers || []).map(t => t.name);
- buildTimeline(rows, tierOrder);
- buildVendorDist(rows, tierOrder);
- buildTokenTimeline(rows, tierOrder);
- buildModelTokenTimeline(rows);
+ document.getElementById('page-info').textContent = total + ' sessions';
+ document.getElementById('page-num').textContent = (sessionPage + 1) + ' / ' + totalPages;
+ document.getElementById('btn-prev').disabled = (sessionPage === 0);
+ document.getElementById('btn-next').disabled = (sessionPage >= totalPages - 1);
+}
- document.getElementById('refresh-time').textContent = '上次刷新: ' + now();
+function changePage(delta) {
+ var totalPages = Math.max(1, Math.ceil(allSessions.length / sessionPageSize));
+ sessionPage = Math.max(0, Math.min(totalPages - 1, sessionPage + delta));
+ renderSessionPage();
+}
+
+function buildBindSelect(sessionKey, boundVendors, availableVendors) {
+ var isBound = boundVendors && boundVendors.length > 0;
+ var multiBound = isBound && boundVendors.length > 1;
+ var selected = isBound ? boundVendors[0] : '';
+ var html = '';
+ return html;
+}
+
+async function handleBindChange(sel) {
+ var sessionKey = sel.getAttribute('data-session-key');
+ var vendor = sel.value;
+ var previousValue = sel.getAttribute('data-previous') || '';
+ try {
+ var resp;
+ if (vendor) {
+ resp = await fetch('/api/session-vendor', {
+ method: 'PUT',
+ headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify({session_key: sessionKey, vendors: [vendor]}),
+ });
+ } else {
+ resp = await fetch('/api/session-vendor/' + encodeURIComponent(sessionKey), {method: 'DELETE'});
+ }
+ if (!resp.ok) {
+ sel.value = previousValue;
+ console.error('Bind change rejected:', resp.status, await resp.text());
+ }
} catch (e) {
- console.error('Dashboard refresh error:', e);
- document.getElementById('refresh-time').textContent = '刷新失败 ' + now();
+ sel.value = previousValue;
+ console.error('Bind change failed:', e);
+ }
+}
+
+var sessionsTbody = document.getElementById('sessions-tbody');
+sessionsTbody.addEventListener('focus', function(e) {
+ if (e.target.classList.contains('bind-select')) {
+ e.target.setAttribute('data-previous', e.target.value);
+ }
+}, true);
+sessionsTbody.addEventListener('change', function(e) {
+ if (e.target.classList.contains('bind-select')) {
+ handleBindChange(e.target);
+ }
+});
+
+// ── 主刷新逻辑(按 Tab 分发) ──────────────────────────────
+let refreshing = false;
+let currentTab = 'overview';
+const tabLoaded = { overview: false, sessions: false };
+const TAB_LABELS = { overview: 'Overview', sessions: 'Sessions' };
+
+async function refreshOverview() {
+ const days = currentDays > 0 ? currentDays : 7;
+ const [summary, timeline, status] = await Promise.all([
+ fetchJSON('/api/dashboard/summary?days=' + days),
+ fetchJSON('/api/dashboard/timeline?days=' + days),
+ fetchJSON('/api/status'),
+ ]);
+
+ if (summary.version) {
+ document.getElementById('version-badge').textContent = 'v' + summary.version;
+ }
+
+ updateKPI(summary);
+ updateVendorStatus(status);
+ updateChartTitles(days);
+
+ const rows = timeline.rows || [];
+ const tierOrder = (status.tiers || []).map(t => t.name);
+ buildTimeline(rows, tierOrder);
+ buildVendorDist(rows, tierOrder);
+ buildTokenTimeline(rows, tierOrder);
+ buildModelTokenTimeline(rows);
+}
+
+async function refreshSessions() {
+ await updateSessions();
+}
+
+async function refresh() {
+ if (refreshing) return;
+ refreshing = true;
+ try {
+ // 循环:若 await 期间用户切到了尚未加载的另一页签,补一次刷新,避免 tabLoaded 错位。
+ while (true) {
+ const tab = currentTab;
+ document.getElementById('refresh-time').textContent = '刷新中…';
+ try {
+ if (tab === 'sessions') {
+ await refreshSessions();
+ } else {
+ await refreshOverview();
+ }
+ tabLoaded[tab] = true;
+ if (tab === currentTab) {
+ document.getElementById('refresh-time').textContent =
+ '上次刷新: ' + now() + '(' + TAB_LABELS[tab] + ')';
+ }
+ } catch (e) {
+ console.error('Dashboard refresh error:', e);
+ document.getElementById('refresh-time').textContent = '刷新失败 ' + now();
+ }
+ if (currentTab !== tab && !tabLoaded[currentTab]) continue;
+ break;
+ }
} finally {
refreshing = false;
}
}
-// 页面加载 + 每 30 秒自动刷新
-refresh();
-setInterval(refresh, 600000);
+// ── 页签切换(懒加载 + URL 同步) ─────────────────────────
+function syncTabUrl(name) {
+ try {
+ const url = new URL(window.location.href);
+ if (url.searchParams.get('tab') === name) return;
+ url.searchParams.set('tab', name);
+ window.history.replaceState({}, '', url);
+ } catch (e) { /* no-op */ }
+}
+
+function applyTabState(name) {
+ document.querySelectorAll('.tab-btn').forEach(function (b) {
+ const active = b.getAttribute('data-tab') === name;
+ b.classList.toggle('active', active);
+ b.setAttribute('aria-selected', active ? 'true' : 'false');
+ });
+ document.querySelectorAll('.tab-pane').forEach(function (p) {
+ p.classList.toggle('active', p.getAttribute('data-tab') === name);
+ });
+}
+
+function switchTab(name) {
+ if (name !== 'overview' && name !== 'sessions') name = 'overview';
+ if (name === currentTab) {
+ syncTabUrl(name);
+ return;
+ }
+ currentTab = name;
+ applyTabState(name);
+ syncTabUrl(name);
+ refresh();
+}
+
+// ── 初始化 ────────────────────────────────────────────────
+(function bootstrap() {
+ let initial = 'overview';
+ try {
+ const t = new URL(window.location.href).searchParams.get('tab');
+ if (t === 'sessions') initial = 'sessions';
+ } catch (e) { /* no-op */ }
+ currentTab = initial;
+ applyTabState(initial);
+ syncTabUrl(initial);
+ // Load version immediately regardless of active tab
+ fetchJSON('/api/dashboard/summary?days=7').then(function(s) {
+ if (s && s.version) document.getElementById('version-badge').textContent = 'v' + s.version;
+ }).catch(function(){});
+ refresh(); // 仅加载初始页签的数据
+ setInterval(refresh, 600000); // 每 10 分钟刷新当前页签
+})();