From 804fe9230594a5f0b324e94e0aabe0388b725c38 Mon Sep 17 00:00:00 2001 From: Aurelius Huang Date: Wed, 27 May 2026 14:08:51 +0800 Subject: [PATCH 01/42] =?UTF-8?q?feat(dashboard):=20Model=20Calling=20?= =?UTF-8?q?=E5=AE=9E=E6=97=B6=E7=9B=91=E6=8E=A7=E6=89=A9=E5=B1=95=E8=87=B3?= =?UTF-8?q?=E5=85=A8=20vendor=EF=BC=8C=E4=BF=AE=E5=A4=8D=E6=8E=92=E9=98=9F?= =?UTF-8?q?=E9=98=9F=E5=88=97=E9=95=BF=E5=BA=A6=E4=B8=8D=E5=8F=AF=E8=A7=81?= =?UTF-8?q?=20(#253)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(concurrency): 统一并发抽象,支持 monitor / limited 双模式; 将 ModelConcurrencyLimiter 重构为 ModelConcurrencyController,新增 monitor 模式(config=None,仅计数不限流)与既有 limited 模式(Zhipu 等限流场景)。 - _ConcurrencySlot 增加 limit=None 分支,fast path 直接 in_use++; - 新增 track() async ctx mgr 与 mode 属性,便于 executor 层 async with 包裹; - 新增 _peak_samples 滑窗,get_diagnostics 输出最近 10s pending 峰值; - BaseVendor 注入默认 monitor controller,新增 track_in_flight() / update_concurrency() 默认实现;get_diagnostics 自动合入 concurrency 字段; - ZhipuVendor 删除内部 _maybe_acquire_concurrency_slot,统一委托 BaseVendor + executor 层包裹,slot 跨 429 重试自然持有; - 保留 ModelConcurrencyLimiter 别名向后兼容。 🤖 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 * feat(dashboard): Model Calling 实时监控扩展至全 vendor / 全 model; 将 Model Calling 卡片从仅监控 Zhipu 扩展到所有 vendor 的所有模型(仅 CC 场景)。 - executor: 在 execute_stream / execute_message 调用 vendor.send_message[_stream] 前用 async with vendor.track_in_flight(mapped_model) 包裹,覆盖所有 vendor (包括完全覆写 send 方法的 Copilot / Antigravity); - dashboard: updateModelCalling 按 mode 字段区分渲染: - limited 模式(Zhipu)保留进度条 + limit 可编辑 + ⏳ pending 徽章; - monitor 模式(其他 vendor)仅显示 in_use 计数徽章,无可编辑控件; - 新增 mc-badge-peak「🕘 曾排队 N」灰色余晖徽章,覆盖 pending=0 但 peak_pending_recent>0 的场景; - 轮询间隔由 5000ms 缩短至 1500ms,提升瞬时排队可观测性; - routes: /api/concurrency PUT 在 monitor-only vendor 上返回 422(区别于参数 非法的 400),错误消息明确指示不支持调整。 🤖 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 * test(concurrency): 适配并发抽象重构并补齐 monitor / executor 测试覆盖; - test_zhipu_concurrency: 适配 Controller 重构(导入别名 + diagnostics 字段 断言更新),新增 test_peak_pending_recent_tracking 验证余晖记录; ZhipuVendor 测试通过 _send_with_tracking / _stream_with_tracking 辅助函数 模拟 executor 层 track_in_flight 包裹,行为等价; - test_concurrency_monitor(新增): 覆盖 monitor 模式 acquire 不阻塞、 pending 恒为 0、set_limit 抛 ValueError、100 并发峰值正确等场景; BaseVendor.track_in_flight 默认行为(空 model 名 no-op、非空委托 controller); - test_executor_in_flight_tracking(新增): 用探针式 fake controller 验证 executor 在调用 send_message[_stream] 前进入 track 上下文, 调用结束(含异常)后正确退出; - test_router_executor: _mock_vendor 新增 track_in_flight 字段(nullcontext) 以适配 executor 调用约定。 🤖 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 * docs(changelog): 记录 Model Calling 监控扩展至全 vendor 的变更条目; - 监控扩展:所有 vendor 在 CC 场景下都呈现 in_use 实时计数(monitor 模式); - 限流收敛:Zhipu 保留 limit + FIFO 排队(limited 模式),其他 vendor 仅观察; - 队列可见性:新增 peak_pending_recent 余晖追踪 + 轮询间隔由 5s 缩短至 1.5s; - 抽象统一:ModelConcurrencyLimiter → ModelConcurrencyController(保留别名)。 🤖 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 --- CHANGELOG.md | 5 + src/coding/proxy/routing/executor.py | 24 ++- src/coding/proxy/server/dashboard.py | 51 +++-- src/coding/proxy/server/routes.py | 13 +- src/coding/proxy/vendors/base.py | 25 +++ src/coding/proxy/vendors/concurrency.py | 185 ++++++++++++----- src/coding/proxy/vendors/zhipu.py | 144 ++++--------- tests/test_concurrency_monitor.py | 158 +++++++++++++++ tests/test_executor_in_flight_tracking.py | 233 +++++++++++++++++++++ tests/test_router_executor.py | 5 + tests/test_zhipu_concurrency.py | 236 +++++++++++++++------- 11 files changed, 825 insertions(+), 254 deletions(-) create mode 100644 tests/test_concurrency_monitor.py create mode 100644 tests/test_executor_in_flight_tracking.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f745ec1..086302b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ ## [Unreleased] +- feat(dashboard): Model Calling 实时监控扩展至全 vendor / 全 model(仅 CC 场景),其他 vendor 在 monitor 模式下仅计数不限流,Zhipu 保留 limited 模式 + FIFO 排队; +- feat(concurrency): 新增 `peak_pending_recent` 最近 10s 排队峰值追踪,瞬时排队释放后前端仍可见"曾排队 N" 余晖徽章; +- perf(dashboard): Model Calling 轮询间隔由 5000ms 缩短至 1500ms,提升瞬时排队可观测性; +- refactor(vendors): `ModelConcurrencyLimiter` 重构为 `ModelConcurrencyController`,统一 monitor / limited 双模式抽象(保留旧名别名);并发控制由 vendor 内部迁移至 executor 层 `track_in_flight` 包裹,行为对所有 vendor 一致; + ## [v0.5.0](https://github.com/ThreeFish-AI/coding-proxy/releases/tag/v0.5.0) - 2026-05-27 > [!IMPORTANT] diff --git a/src/coding/proxy/routing/executor.py b/src/coding/proxy/routing/executor.py index 4c37f02..df63e74 100644 --- a/src/coding/proxy/routing/executor.py +++ b/src/coding/proxy/routing/executor.py @@ -689,15 +689,17 @@ async def execute_stream( tier.name, failed_tier_name, session_record, body ) body_for_tier = self._prepare_body_for_tier(body, tier, source_vendor) - async for chunk in tier.vendor.send_message_stream( - body_for_tier, headers - ): - parse_usage_from_chunk( - chunk, - usage, - vendor_label=_VENDOR_PROTOCOL_LABEL_MAP.get(tier.name), - ) - yield chunk, tier.name + _mapped_model = tier.vendor.map_model(body.get("model", "")) + async with tier.vendor.track_in_flight(_mapped_model): + async for chunk in tier.vendor.send_message_stream( + body_for_tier, headers + ): + parse_usage_from_chunk( + chunk, + usage, + vendor_label=_VENDOR_PROTOCOL_LABEL_MAP.get(tier.name), + ) + yield chunk, tier.name info = self._recorder.build_usage_info(usage) if has_missing_input_usage_signals(info): @@ -863,7 +865,9 @@ async def execute_message( tier.name, failed_tier_name, session_record, body ) body_for_tier = self._prepare_body_for_tier(body, tier, source_vendor) - resp = await tier.vendor.send_message(body_for_tier, headers) + _mapped_model = tier.vendor.map_model(body.get("model", "")) + async with tier.vendor.track_in_flight(_mapped_model): + resp = await tier.vendor.send_message(body_for_tier, headers) if resp.status_code < 400: duration = int((time.monotonic() - start) * 1000) diff --git a/src/coding/proxy/server/dashboard.py b/src/coding/proxy/server/dashboard.py index 75dd812..156c098 100644 --- a/src/coding/proxy/server/dashboard.py +++ b/src/coding/proxy/server/dashboard.py @@ -629,6 +629,10 @@ def _build_favicon() -> bytes: background: rgba(251,146,60,.15); color: #fb923c; } + .mc-badge-peak { + background: rgba(148,163,184,.12); + color: #94a3b8; + } .mc-badge-active { background: rgba(74,222,128,.12); color: #4ade80; @@ -1282,10 +1286,12 @@ def _build_favicon() -> bytes: models.push({ vendor: tier.name, model: model, - limit: d.limit || 0, + mode: d.mode || 'limited', + limit: d.limit, in_use: d.in_use || 0, - available: d.available || 0, + available: d.available, pending: d.pending || 0, + peak_pending_recent: d.peak_pending_recent || 0, }); } } @@ -1298,18 +1304,33 @@ def _build_favicon() -> bytes: var html = '
'; for (var k = 0; k < models.length; k++) { var m = models[k]; - var pct = m.limit > 0 ? Math.round((m.in_use / m.limit) * 100) : 0; - var barClass = pct <= 50 ? 'mc-low' : (pct <= 80 ? 'mc-mid' : 'mc-high'); - - html += '
' - + '' + escapeHtml(m.vendor + '/' + m.model) + '' - + '
' - + '
' - + '' + m.in_use - + '/' + m.limit + '' - + (m.pending > 0 ? '⏳ ' + m.pending + '' : '') - + '
' - + '
'; + + if (m.mode === 'monitor') { + // monitor 模式:纯计数徽章,无 limit/进度条 + html += '
' + + '' + escapeHtml(m.vendor + '/' + m.model) + '' + + '
' + + '
' + + '' + m.in_use + '' + + '
' + + '
'; + } else { + // limited 模式:保留现有渲染(进度条 + limit 编辑) + var limit = m.limit || 0; + var pct = limit > 0 ? Math.round((m.in_use / limit) * 100) : 0; + var barClass = pct <= 50 ? 'mc-low' : (pct <= 80 ? 'mc-mid' : 'mc-high'); + + html += '
' + + '' + escapeHtml(m.vendor + '/' + m.model) + '' + + '
' + + '
' + + '' + m.in_use + + '/' + limit + '' + + (m.pending > 0 ? '⏳ ' + m.pending + '' : '') + + (m.pending === 0 && m.peak_pending_recent > 0 ? '🕘 曾排队 ' + m.peak_pending_recent + '' : '') + + '
' + + '
'; + } } html += '
'; wrap.innerHTML = html; @@ -1325,7 +1346,7 @@ def _build_favicon() -> bytes: }).catch(function() {}); } tick(); - _mcTimer = setInterval(tick, 5000); + _mcTimer = setInterval(tick, 1500); } function stopModelCallingPoll() { if (_mcTimer) { clearInterval(_mcTimer); _mcTimer = null; } diff --git a/src/coding/proxy/server/routes.py b/src/coding/proxy/server/routes.py index 7c13d2f..5ed90dc 100644 --- a/src/coding/proxy/server/routes.py +++ b/src/coding/proxy/server/routes.py @@ -254,16 +254,15 @@ async def update_concurrency(request: Request) -> Response: for tier in router.tiers: if tier.name == tier_name: vendor = tier.vendor - update_fn = getattr(vendor, "update_concurrency", None) - if update_fn is None: + try: + vendor.update_concurrency(model, limit) + except ValueError as exc: return json_error_response( - 400, + 422, error_type="invalid_request_error", - message=f"vendor '{tier_name}' does not support concurrency", + message=str(exc), ) - try: - update_fn(model, limit) - except (ValueError, AttributeError) as exc: + except AttributeError as exc: return json_error_response( 400, error_type="invalid_request_error", message=str(exc) ) diff --git a/src/coding/proxy/vendors/base.py b/src/coding/proxy/vendors/base.py index 0c08248..d1434bc 100644 --- a/src/coding/proxy/vendors/base.py +++ b/src/coding/proxy/vendors/base.py @@ -44,6 +44,7 @@ ) from ..compat.session_store import CompatSessionRecord from ..config.schema import FailoverConfig +from .concurrency import ModelConcurrencyController logger = logging.getLogger(__name__) @@ -63,6 +64,8 @@ def __init__( self._client: httpx.AsyncClient | None = None self._compat_trace: CompatibilityTrace | None = None self._compat_session_record: CompatSessionRecord | None = None + # 默认 monitor 模式(仅计数不限流);子类可覆盖为 limited 模式 + self._concurrency_controller = ModelConcurrencyController(None) def _get_client(self) -> httpx.AsyncClient: if self._client is None or self._client.is_closed: @@ -246,8 +249,30 @@ def get_diagnostics(self) -> dict[str, Any]: diagnostics: dict[str, Any] = {} if self._compat_trace is not None: diagnostics["compat"] = self._compat_trace.to_dict() + concurrency = self._concurrency_controller.get_diagnostics() + if concurrency: + diagnostics["concurrency"] = concurrency return diagnostics + def track_in_flight(self, mapped_model: str): + """返回用于追踪在途请求的异步上下文管理器. + + 空 model name 时返回 no-op context(防御性处理)。 + """ + if not mapped_model: + from contextlib import nullcontext + + return nullcontext() + return self._concurrency_controller.track(mapped_model) + + def update_concurrency(self, model: str, limit: int) -> None: + """运行时更新指定模型的并发限制. + + 默认实现委托给 ``_concurrency_controller.set_limit``。 + monitor 模式下抛 ``ValueError``。 + """ + self._concurrency_controller.set_limit(model, limit) + def should_trigger_failover( self, status_code: int, body: dict[str, Any] | None ) -> bool: diff --git a/src/coding/proxy/vendors/concurrency.py b/src/coding/proxy/vendors/concurrency.py index 7944bdd..a5e5763 100644 --- a/src/coding/proxy/vendors/concurrency.py +++ b/src/coding/proxy/vendors/concurrency.py @@ -1,78 +1,122 @@ -"""每模型并发限制器 — 支持运行时动态调整的公平排队. +"""统一并发控制器 — 支持监控 (monitor) 与限流 (limited) 双模式. -为每个映射后的模型(如 ``glm-5v-turbo``)独立维护一个 ``_ConcurrencySlot`, -确保同一时间点该模型的并行请求数不超过配置的上限。当所有槽位被占满时, -新请求按 FIFO 顺序排队等待,直到有槽位释放。 +为每个映射后的模型(如 ``glm-5v-turbo``)独立维护一个 ``_ConcurrencySlot``, +根据模式提供不同语义: + + **monitor 模式** (config=None) + - 仅计数 ``in_use``,不做排队与限流 + - ``pending`` 恒为 0,``available`` / ``limit`` 为 None + - 所有 vendor 默认使用此模式 + + **limited 模式** (config 非 None) + - ``in_use`` 不超过 ``limit`` 时立即获取,超限时 FIFO 排队 + - ``pending`` 反映当前排队数,``peak_pending_recent`` 记录最近 10s 峰值 + - 由 ZhipuVendor 等需限流的 vendor 启用 设计要点: - **惰性创建**:仅在首次请求到达时才为该模型创建 Slot,避免冷启动开销 - - **FIFO 公平**:``asyncio.Event`` + while 循环天然满足 FIFO 排队语义 + - **FIFO 公平**:``asyncio.Event`` + while 循环天然满足 FIFO 排队语义(limited 模式) - **动态调整**:支持运行时修改 per-model limit,无需重启进程 - **按映射后模型名键控**:与上游真实承载能力对齐,而非按客户端请求名 + - **峰值余晖**:记录 ``peak_pending_recent`` 使瞬时排队可观测 """ from __future__ import annotations import asyncio import logging +import time +from collections import deque +from contextlib import asynccontextmanager +from typing import Any, Literal from ..config.vendors import ZhipuConcurrencyConfig logger = logging.getLogger(__name__) +# peak_pending_recent 滑窗宽度(秒) +_PEAK_WINDOW_SECONDS = 10.0 + class _ConcurrencySlot: - """支持动态 limit 的并发槽位. + """支持双模式的并发槽位. - 使用 ``asyncio.Event`` 作为等待/通知原语,在 ``acquire`` 中 await 等待, - 在 ``release`` / ``set_limit`` 中唤醒。``set_limit`` 修改上限后立即唤醒 - 所有等待者,由它们重新判断是否可获得槽位。 + ``limit=None`` (monitor) 时 acquire 走 fast path,仅计数。 + ``limit>0`` (limited) 时在满槽位后 FIFO 排队等待。 """ - def __init__(self, limit: int) -> None: + def __init__(self, limit: int | None) -> None: self._limit = limit self._in_use: int = 0 self._pending: int = 0 self._wake = asyncio.Event() self._wake.set() + # peak_pending_recent 追踪:存储 (timestamp, pending_value) 元组 + self._peak_samples: deque[tuple[float, int]] = deque() - async def acquire(self) -> _ConcurrencySlot: - """获取一个并发槽位,必要时阻塞排队. + async def acquire(self) -> None: + """获取一个并发槽位. - 返回 ``self``,调用方在请求完成后调用 ``release()``。 + monitor 模式 (limit=None):仅 in_use++,永不排队。 + limited 模式 (limit>0):满槽时阻塞等待。 """ - # Fast path + # monitor 模式:仅计数 + if self._limit is None: + self._in_use += 1 + return + + # limited — fast path if self._in_use < self._limit: self._in_use += 1 - return self - # Slow path — 等待槽位释放 + return + + # limited — slow path: FIFO 排队 self._pending += 1 + self._observe_peak() try: while True: self._wake.clear() await self._wake.wait() if self._in_use < self._limit: self._in_use += 1 - return self + return finally: self._pending -= 1 def release(self) -> None: """释放一个并发槽位.""" self._in_use = max(0, self._in_use - 1) - self._wake.set() + if self._limit is not None: + self._wake.set() def set_limit(self, new_limit: int) -> None: """动态调整并发上限. - 增大 limit 时立即唤醒等待者;缩小时已持有的槽位不受影响, - 新 limit 在后续 acquire 中自然生效。 + 仅 limited 模式有效;monitor 模式调用抛 ValueError。 """ + if self._limit is None: + msg = "Cannot set limit on monitor-only slot" + raise ValueError(msg) self._limit = new_limit self._wake.set() + def _observe_peak(self) -> None: + """记录当前 pending 值作为峰值采样点.""" + now = time.monotonic() + self._peak_samples.append((now, self._pending)) + + def _get_peak_pending_recent(self) -> int: + """获取最近窗口内的 peak pending 值.""" + cutoff = time.monotonic() - _PEAK_WINDOW_SECONDS + # 剔除过期采样 + while self._peak_samples and self._peak_samples[0][0] < cutoff: + self._peak_samples.popleft() + if not self._peak_samples: + return 0 + return max(v for _, v in self._peak_samples) + @property - def limit(self) -> int: + def limit(self) -> int | None: return self._limit @property @@ -80,59 +124,93 @@ def in_use(self) -> int: return self._in_use @property - def available(self) -> int: + def available(self) -> int | None: + if self._limit is None: + return None return max(0, self._limit - self._in_use) @property def pending(self) -> int: return self._pending + @property + def peak_pending_recent(self) -> int: + return self._get_peak_pending_recent() -class ModelConcurrencyLimiter: - """按模型名提供独立并发槽位的限制器. + +class ModelConcurrencyController: + """按模型名提供独立并发槽位的控制器. 用法:: - limiter = ModelConcurrencyLimiter(config) - slot = await limiter.acquire("glm-5v-turbo") - try: + # monitor 模式(默认) + ctrl = ModelConcurrencyController(None) + async with ctrl.track("model-a"): ... # 执行请求 - finally: - slot.release() + + # limited 模式(Zhipu 等) + ctrl = ModelConcurrencyController(config) + async with ctrl.track("glm-5v-turbo"): + ... # 满槽时排队等待 """ - def __init__(self, config: ZhipuConcurrencyConfig) -> None: + def __init__(self, config: ZhipuConcurrencyConfig | None) -> None: self._config = config self._slots: dict[str, _ConcurrencySlot] = {} + @property + def mode(self) -> Literal["monitor", "limited"]: + """当前控制器模式.""" + return "limited" if self._config is not None else "monitor" + def _get_or_create_slot(self, model: str) -> _ConcurrencySlot: """获取(或惰性创建)指定模型的并发槽位.""" slot = self._slots.get(model) if slot is None: - limit = self._config.get_limit(model) + if self._config is not None: + limit = self._config.get_limit(model) + else: + limit = None slot = _ConcurrencySlot(limit) self._slots[model] = slot - logger.debug( - "ModelConcurrencyLimiter: created slot model=%s limit=%d", - model, - limit, - ) + if self._config is not None: + logger.debug( + "ModelConcurrencyController: created slot mode=limited " + "model=%s limit=%d", + model, + limit, + ) + else: + logger.debug( + "ModelConcurrencyController: created slot mode=monitor model=%s", + model, + ) return slot - async def acquire(self, model: str) -> _ConcurrencySlot: - """获取指定模型的并发槽位,必要时阻塞排队. + @asynccontextmanager + async def track(self, model: str): + """异步上下文管理器:获取 → 执行 → 释放. + + 用法:: - 返回已获取的 Slot 实例,调用方负责在请求完成后调用 ``release()``。 + async with controller.track("glm-5v-turbo"): + await vendor.send_message(...) """ slot = self._get_or_create_slot(model) await slot.acquire() - return slot + try: + yield + finally: + slot.release() def set_limit(self, model: str, new_limit: int) -> None: """运行时修改指定模型的并发上限. - 同时更新 config.models 以确保后续惰性创建使用新值。 + 仅 limited 模式支持;monitor 模式抛 ValueError。 """ + if self._config is None: + msg = f"vendor is monitor-only; cannot update limit for model '{model}'" + raise ValueError(msg) slot = self._slots.get(model) if slot is None: slot = _ConcurrencySlot(new_limit) @@ -141,22 +219,33 @@ def set_limit(self, model: str, new_limit: int) -> None: slot.set_limit(new_limit) self._config.models[model] = new_limit logger.info( - "ModelConcurrencyLimiter: updated limit model=%s new_limit=%d", + "ModelConcurrencyController: updated limit model=%s new_limit=%d", model, new_limit, ) - def get_diagnostics(self) -> dict[str, dict[str, int]]: + def get_diagnostics(self) -> dict[str, dict[str, Any]]: """返回每个模型的并发状态快照(用于可观测性).""" - snapshot: dict[str, dict[str, int]] = {} + snapshot: dict[str, dict[str, Any]] = {} + mode = self.mode for model, slot in self._slots.items(): - snapshot[model] = { - "limit": slot.limit, + entry: dict[str, Any] = { + "mode": mode, "in_use": slot.in_use, - "available": slot.available, "pending": slot.pending, + "peak_pending_recent": slot.peak_pending_recent, } + if mode == "limited": + entry["limit"] = slot.limit + entry["available"] = slot.available + else: + entry["limit"] = None + entry["available"] = None + snapshot[model] = entry return snapshot -__all__ = ["ModelConcurrencyLimiter"] +# 向后兼容别名 +ModelConcurrencyLimiter = ModelConcurrencyController + +__all__ = ["ModelConcurrencyController", "ModelConcurrencyLimiter"] diff --git a/src/coding/proxy/vendors/zhipu.py b/src/coding/proxy/vendors/zhipu.py index 64407ba..ef36f57 100644 --- a/src/coding/proxy/vendors/zhipu.py +++ b/src/coding/proxy/vendors/zhipu.py @@ -17,6 +17,11 @@ - max_attempt = 5(1 初始 + 4 重试) - 指数退避 + Full Jitter(1s → 2s → 4s → 8s) - 优先尊重 server retry-after header + +并发限流由 BaseVendor._concurrency_controller 统一管控 +(limited 模式),在 executor 层通过 ``track_in_flight`` 触发, +slot 跨 429 重试自然持有(executor 的 async with 包裹整个 +send_message/send_message_stream 调用链)。 """ from __future__ import annotations @@ -37,7 +42,7 @@ ) from ..routing.retry import RetryConfig, calculate_delay from .base import VendorResponse -from .concurrency import ModelConcurrencyLimiter +from .concurrency import ModelConcurrencyController from .native_anthropic import NativeAnthropicVendor logger = logging.getLogger(__name__) @@ -59,6 +64,7 @@ class ZhipuVendor(NativeAnthropicVendor): 仅替换模型名和认证头,其余原样透传。 429 Rate Limit 时自动重试(指数退避),降低 failover 频率。 + 并发限流由 BaseVendor._concurrency_controller 统一管控。 """ _vendor_name = "zhipu" @@ -72,12 +78,8 @@ def __init__( ) -> None: super().__init__(config, model_mapper, failover_config) self._rl_retry = _RATE_LIMIT_RETRY - # 每模型并发限制器(config.concurrency 为 None 时禁用) - self._concurrency_limiter: ModelConcurrencyLimiter | None = ( - ModelConcurrencyLimiter(config.concurrency) - if config.concurrency is not None - else None - ) + # 覆盖 BaseVendor 默认的 monitor 模式为 limited 模式 + self._concurrency_controller = ModelConcurrencyController(config.concurrency) # ── 首选 tier 参数兼容转换 ──────────────────────────────── @@ -129,24 +131,7 @@ async def send_message( request_body: dict[str, Any], headers: dict[str, str], ) -> VendorResponse: - """非流式请求,429 时自动重试. - - 在 429 重试循环外层套上每模型并发槽位获取,确保同一时间点同一模型的 - 在途请求数不超过配置上限;超过时新请求 FIFO 排队等待。 - """ - sem = await self._maybe_acquire_concurrency_slot(request_body) - try: - return await self._send_message_with_retry(request_body, headers) - finally: - if sem is not None: - sem.release() - - async def _send_message_with_retry( - self, - request_body: dict[str, Any], - headers: dict[str, str], - ) -> VendorResponse: - """原 send_message 主体逻辑(不含并发控制).""" + """非流式请求,429 时自动重试.""" max_attempts = self._rl_retry.max_attempts for attempt in range(max_attempts): @@ -186,87 +171,42 @@ async def send_message_stream( 安全性:429 在 BaseVendor.send_message_stream 中于 status code 检查阶段即 raise(在任何 chunk yield 之前), 因此重试不会导致已发出数据不一致。 - - 在 429 重试循环外层套上每模型并发槽位获取,确保流式请求与非流式请求 - 共用同一信号量,统一限制同一模型的总在途并发数。 """ - sem = await self._maybe_acquire_concurrency_slot(request_body) max_attempts = self._rl_retry.max_attempts - try: - for attempt in range(max_attempts): - try: - # 429 在 status code 检查阶段即 raise(在任何 chunk 之前), - # 因此 __anext__ 安全:要么拿到首个 chunk,要么抛异常。 - ait = super().send_message_stream(request_body, headers) - head = await ait.__anext__() - except StopAsyncIteration: - return - except httpx.HTTPStatusError as exc: - if exc.response is None or exc.response.status_code != 429: - raise - if attempt == max_attempts - 1: - logger.warning( - "Zhipu 429 stream rate limit exhausted after %d attempts", - max_attempts, - ) - raise - - delay = self._compute_retry_delay_from_response( - exc.response, attempt - ) - logger.info( - "Zhipu 429 stream rate limit, retry %d/%d in %.1fms", - attempt + 1, - max_attempts - 1, - delay, - ) - await asyncio.sleep(delay / 1000.0) - continue - - # yield 在 try/except 之外,避免捕获外部 athrow 的异常 - yield head - async for chunk in ait: - yield chunk + for attempt in range(max_attempts): + try: + # 429 在 status code 检查阶段即 raise(在任何 chunk 之前), + # 因此 __anext__ 安全:要么拿到首个 chunk,要么抛异常。 + ait = super().send_message_stream(request_body, headers) + head = await ait.__anext__() + except StopAsyncIteration: return - finally: - if sem is not None: - sem.release() - - # ── 并发控制 ──────────────────────────────────────────── - - async def _maybe_acquire_concurrency_slot( - self, - request_body: dict[str, Any], - ) -> asyncio.Semaphore | None: - """按映射后模型名获取并发槽位;未配置 concurrency 时返回 None. - - ``map_model()`` 是纯同步字典查找,在 Semaphore 等待前调用是安全的, - 且能确保排队键与上游真实承载模型对齐。 - """ - if self._concurrency_limiter is None: - return None - raw_model = request_body.get("model", "") if request_body else "" - mapped_model = self.map_model(raw_model) if raw_model else "" - if not mapped_model: - return None - return await self._concurrency_limiter.acquire(mapped_model) - - # ── 诊断信息 ───────────────────────────────────────────── - - def get_diagnostics(self) -> dict[str, Any]: - """返回供应商运行时诊断信息,包含每模型并发状态.""" - diagnostics = super().get_diagnostics() - if self._concurrency_limiter is not None: - diagnostics["concurrency"] = self._concurrency_limiter.get_diagnostics() - return diagnostics - - def update_concurrency(self, model: str, limit: int) -> None: - """运行时更新指定模型的并发限制.""" - if self._concurrency_limiter is None: - msg = "Concurrency limiter is not enabled for this vendor" - raise ValueError(msg) - self._concurrency_limiter.set_limit(model, limit) + except httpx.HTTPStatusError as exc: + if exc.response is None or exc.response.status_code != 429: + raise + if attempt == max_attempts - 1: + logger.warning( + "Zhipu 429 stream rate limit exhausted after %d attempts", + max_attempts, + ) + raise + + delay = self._compute_retry_delay_from_response(exc.response, attempt) + logger.info( + "Zhipu 429 stream rate limit, retry %d/%d in %.1fms", + attempt + 1, + max_attempts - 1, + delay, + ) + await asyncio.sleep(delay / 1000.0) + continue + + # yield 在 try/except 之外,避免捕获外部 athrow 的异常 + yield head + async for chunk in ait: + yield chunk + return # ── 延迟计算 ──────────────────────────────────────────── diff --git a/tests/test_concurrency_monitor.py b/tests/test_concurrency_monitor.py new file mode 100644 index 0000000..4fce021 --- /dev/null +++ b/tests/test_concurrency_monitor.py @@ -0,0 +1,158 @@ +"""ModelConcurrencyController monitor 模式专项测试. + +验证 ``config=None`` 时的纯计数行为: + - acquire 不阻塞,无 limit / available + - pending 永远为 0 + - set_limit 抛 ValueError + - 100 并发 in_use 峰值正确 + - get_diagnostics 输出 mode="monitor" + limit/available=None +""" + +from __future__ import annotations + +import asyncio + +import pytest + +from coding.proxy.vendors.concurrency import ModelConcurrencyController + + +class TestMonitorMode: + """monitor 模式(config=None)基础行为.""" + + def test_mode_property(self) -> None: + ctrl = ModelConcurrencyController(None) + assert ctrl.mode == "monitor" + + @pytest.mark.asyncio + async def test_acquire_never_blocks(self) -> None: + """monitor 模式下任意数量并发都立即获取槽位.""" + ctrl = ModelConcurrencyController(None) + slot = ctrl._get_or_create_slot("model-x") + # 即使触发多次 acquire 也不阻塞 + for _ in range(10): + await slot.acquire() + assert slot.in_use == 10 + assert slot.pending == 0 + + @pytest.mark.asyncio + async def test_100_concurrent_acquires_no_queue(self) -> None: + """100 并发请求全部立即拿到槽位,无排队.""" + ctrl = ModelConcurrencyController(None) + + gate = asyncio.Event() + max_in_use = 0 + + async def hold(model: str) -> None: + nonlocal max_in_use + async with ctrl.track(model): + slot = ctrl._get_or_create_slot(model) + max_in_use = max(max_in_use, slot.in_use) + await gate.wait() + + tasks = [asyncio.create_task(hold("test-model")) for _ in range(100)] + # 等所有任务进入 track + await asyncio.sleep(0.1) + slot = ctrl._get_or_create_slot("test-model") + assert slot.in_use == 100, "monitor 模式应允许全部并行" + assert slot.pending == 0, "monitor 模式 pending 应恒为 0" + gate.set() + await asyncio.gather(*tasks) + # 释放后归零 + assert slot.in_use == 0 + + @pytest.mark.asyncio + async def test_release_after_track(self) -> None: + """track 上下文退出后 in_use 归零.""" + ctrl = ModelConcurrencyController(None) + slot = ctrl._get_or_create_slot("m") + async with ctrl.track("m"): + assert slot.in_use == 1 + assert slot.in_use == 0 + + def test_set_limit_raises_in_monitor(self) -> None: + """monitor 模式下 set_limit 抛 ValueError.""" + ctrl = ModelConcurrencyController(None) + with pytest.raises(ValueError, match="monitor-only"): + ctrl.set_limit("m", 5) + + def test_diagnostics_monitor_shape(self) -> None: + """monitor 模式 get_diagnostics 输出 mode + limit/available=None.""" + ctrl = ModelConcurrencyController(None) + ctrl._get_or_create_slot("m") + snap = ctrl.get_diagnostics() + assert snap["m"]["mode"] == "monitor" + assert snap["m"]["limit"] is None + assert snap["m"]["available"] is None + assert snap["m"]["in_use"] == 0 + assert snap["m"]["pending"] == 0 + assert snap["m"]["peak_pending_recent"] == 0 + + @pytest.mark.asyncio + async def test_pending_never_increases_in_monitor(self) -> None: + """monitor 模式即使触发大量并发,pending 永远不增.""" + ctrl = ModelConcurrencyController(None) + gate = asyncio.Event() + + async def hold() -> None: + async with ctrl.track("m"): + await gate.wait() + + tasks = [asyncio.create_task(hold()) for _ in range(20)] + await asyncio.sleep(0.05) + slot = ctrl._get_or_create_slot("m") + assert slot.pending == 0 + assert slot.in_use == 20 + gate.set() + await asyncio.gather(*tasks) + + +class TestBaseVendorTrackInFlight: + """BaseVendor.track_in_flight 默认 monitor 行为.""" + + def test_empty_model_returns_noop(self) -> None: + """空 model name 返回 no-op 上下文,不影响 controller 状态.""" + from coding.proxy.config.schema import AnthropicConfig, FailoverConfig + from coding.proxy.vendors.anthropic import AnthropicVendor + + vendor = AnthropicVendor(AnthropicConfig(), FailoverConfig()) + ctx = vendor.track_in_flight("") + # nullcontext 是同步上下文管理器,应有 __enter__/__exit__ + # 我们不进入它,只验证不抛错 + assert ctx is not None + + @pytest.mark.asyncio + async def test_track_in_flight_increments_in_use(self) -> None: + """非空 model name → controller.track 进入,in_use 自增.""" + from coding.proxy.config.schema import AnthropicConfig, FailoverConfig + from coding.proxy.vendors.anthropic import AnthropicVendor + + vendor = AnthropicVendor(AnthropicConfig(), FailoverConfig()) + async with vendor.track_in_flight("claude-test"): + slot = vendor._concurrency_controller._get_or_create_slot("claude-test") + assert slot.in_use == 1 + # 退出后归零 + slot = vendor._concurrency_controller._get_or_create_slot("claude-test") + assert slot.in_use == 0 + + def test_update_concurrency_default_is_monitor_only(self) -> None: + """BaseVendor 默认 monitor → update_concurrency 抛 ValueError.""" + from coding.proxy.config.schema import AnthropicConfig, FailoverConfig + from coding.proxy.vendors.anthropic import AnthropicVendor + + vendor = AnthropicVendor(AnthropicConfig(), FailoverConfig()) + with pytest.raises(ValueError, match="monitor-only"): + vendor.update_concurrency("m", 5) + + def test_get_diagnostics_includes_concurrency_after_use(self) -> None: + """track_in_flight 用过后 get_diagnostics 输出 concurrency 字段.""" + from coding.proxy.config.schema import AnthropicConfig, FailoverConfig + from coding.proxy.vendors.anthropic import AnthropicVendor + + vendor = AnthropicVendor(AnthropicConfig(), FailoverConfig()) + # 触发 slot 创建 + vendor._concurrency_controller._get_or_create_slot("claude-test") + diag = vendor.get_diagnostics() + assert "concurrency" in diag + assert "claude-test" in diag["concurrency"] + assert diag["concurrency"]["claude-test"]["mode"] == "monitor" diff --git a/tests/test_executor_in_flight_tracking.py b/tests/test_executor_in_flight_tracking.py new file mode 100644 index 0000000..9b57e39 --- /dev/null +++ b/tests/test_executor_in_flight_tracking.py @@ -0,0 +1,233 @@ +"""Executor 层 track_in_flight 包裹行为验证. + +验证 ``_RouteExecutor`` 在调用 ``vendor.send_message[_stream]`` 前 +进入 ``vendor.track_in_flight(mapped_model)`` 上下文,在调用结束(包括异常) +后正确退出(释放槽位)。 +""" + +from __future__ import annotations + +from contextlib import asynccontextmanager +from unittest.mock import AsyncMock, MagicMock + +import httpx +import pytest + +from coding.proxy.compat.canonical import ( + CompatibilityDecision, + CompatibilityStatus, +) +from coding.proxy.routing.executor import _RouteExecutor +from coding.proxy.routing.session_manager import RouteSessionManager +from coding.proxy.routing.tier import VendorTier +from coding.proxy.routing.usage_recorder import UsageRecorder +from coding.proxy.vendors.base import ( + BaseVendor, + RequestCapabilities, + UsageInfo, + VendorCapabilities, + VendorResponse, +) + + +class _TrackingProbe: + """共享状态:记录 track_in_flight enter/exit 时序与 send 调用顺序.""" + + def __init__(self) -> None: + self.events: list[str] = [] + self.in_flight: int = 0 + self.peak_in_flight: int = 0 + + def track_factory(self, vendor_name: str): + @asynccontextmanager + async def _track(mapped_model: str): + self.events.append(f"enter:{vendor_name}:{mapped_model}") + self.in_flight += 1 + self.peak_in_flight = max(self.peak_in_flight, self.in_flight) + try: + yield + finally: + self.in_flight -= 1 + self.events.append(f"exit:{vendor_name}:{mapped_model}") + + def _factory(mapped_model: str): + return _track(mapped_model) + + return _factory + + +def _mock_vendor_with_probe( + probe: _TrackingProbe, name: str = "test", **caps_kwargs +) -> BaseVendor: + """创建带 track_in_flight 探针的 mock vendor.""" + vendor = MagicMock(spec=BaseVendor) + vendor.get_name.return_value = name + vendor.map_model.return_value = f"{name}-mapped" + caps = VendorCapabilities(**caps_kwargs) + vendor.get_capabilities.return_value = caps + vendor.get_compatibility_profile.return_value = MagicMock() + vendor.make_compatibility_decision.return_value = CompatibilityDecision( + status=CompatibilityStatus.NATIVE, + ) + vendor.get_compat_trace.return_value = None + + def _supports_request(_caps: RequestCapabilities): + return True, [] + + vendor.supports_request.side_effect = _supports_request + vendor.check_health = AsyncMock(return_value=True) + vendor.close = AsyncMock() + vendor.set_compat_context = MagicMock() + + # 关键:track_in_flight 委托给 probe + vendor.track_in_flight = MagicMock(side_effect=probe.track_factory(name)) + + # send_message 默认返回成功 + async def _send_message(_body, _headers): + probe.events.append(f"send:{name}") + return VendorResponse( + status_code=200, + raw_body=b"{}", + usage=UsageInfo(input_tokens=1, output_tokens=1), + ) + + vendor.send_message = AsyncMock(side_effect=_send_message) + + # send_message_stream 默认产出空流 + async def _send_stream(_body, _headers): + probe.events.append(f"stream_start:{name}") + yield b'data: {"type":"message_start"}\n\n' + probe.events.append(f"stream_end:{name}") + + vendor.send_message_stream = MagicMock(side_effect=_send_stream) + return vendor + + +def _make_executor(vendor: BaseVendor) -> _RouteExecutor: + tier = VendorTier(vendor=vendor) + return _RouteExecutor( + router=MagicMock(), + tiers=[tier], + usage_recorder=UsageRecorder(), + session_manager=RouteSessionManager(), + ) + + +class TestExecuteMessageInFlightTracking: + """非流式调用的 track_in_flight 包裹行为.""" + + @pytest.mark.asyncio + async def test_track_enter_before_send_exit_after(self): + """success path: enter → send → exit 顺序.""" + probe = _TrackingProbe() + vendor = _mock_vendor_with_probe(probe, name="kimi") + exec_inst = _make_executor(vendor) + + resp = await exec_inst.execute_message({"model": "claude-test"}, {}) + assert resp.status_code == 200 + + assert probe.events == [ + "enter:kimi:kimi-mapped", + "send:kimi", + "exit:kimi:kimi-mapped", + ] + vendor.track_in_flight.assert_called_once_with("kimi-mapped") + + @pytest.mark.asyncio + async def test_track_exits_on_http_error(self): + """异常路径:track_in_flight 仍执行 exit.""" + probe = _TrackingProbe() + vendor = _mock_vendor_with_probe(probe, name="kimi") + vendor.send_message = AsyncMock(side_effect=httpx.ConnectError("down")) + exec_inst = _make_executor(vendor) + + with pytest.raises(httpx.ConnectError): + await exec_inst.execute_message({"model": "claude-test"}, {}) + + # enter 与 exit 都应被记录(finally 触发) + assert "enter:kimi:kimi-mapped" in probe.events + assert "exit:kimi:kimi-mapped" in probe.events + # exit 应在 enter 之后 + assert probe.events.index("exit:kimi:kimi-mapped") > probe.events.index( + "enter:kimi:kimi-mapped" + ) + + @pytest.mark.asyncio + async def test_concurrent_message_calls_track_each(self): + """多并发请求每个都触发 enter/exit;in_flight 峰值正确.""" + import asyncio + + probe = _TrackingProbe() + vendor = _mock_vendor_with_probe(probe, name="copilot") + + async def slow_send(_body, _headers): + probe.events.append("send:copilot") + await asyncio.sleep(0.05) + return VendorResponse( + status_code=200, + raw_body=b"{}", + usage=UsageInfo(input_tokens=1, output_tokens=1), + ) + + vendor.send_message = AsyncMock(side_effect=slow_send) + exec_inst = _make_executor(vendor) + + tasks = [ + asyncio.create_task(exec_inst.execute_message({"model": "claude-test"}, {})) + for _ in range(5) + ] + results = await asyncio.gather(*tasks) + assert all(r.status_code == 200 for r in results) + # 5 个 enter + 5 个 send + 5 个 exit + assert sum(1 for e in probe.events if e.startswith("enter:")) == 5 + assert sum(1 for e in probe.events if e.startswith("exit:")) == 5 + # 并发期间 in_flight 峰值应达到 5(monitor 模式不限流) + assert probe.peak_in_flight == 5 + + +class TestExecuteStreamInFlightTracking: + """流式调用的 track_in_flight 包裹行为.""" + + @pytest.mark.asyncio + async def test_stream_track_enter_exit(self): + """成功流式:enter → stream → exit 顺序.""" + probe = _TrackingProbe() + vendor = _mock_vendor_with_probe(probe, name="doubao") + exec_inst = _make_executor(vendor) + + chunks = [] + async for chunk, name in exec_inst.execute_stream({"model": "claude-test"}, {}): + chunks.append((chunk, name)) + + assert len(chunks) >= 1 + # 应包含 enter:doubao:doubao-mapped 与 exit:doubao:doubao-mapped + assert "enter:doubao:doubao-mapped" in probe.events + assert "exit:doubao:doubao-mapped" in probe.events + # exit 在 stream_end 之后 + assert probe.events.index("exit:doubao:doubao-mapped") > probe.events.index( + "stream_end:doubao" + ) + + @pytest.mark.asyncio + async def test_stream_track_exits_on_error(self): + """流式异常退出时 track exit 仍触发.""" + probe = _TrackingProbe() + vendor = _mock_vendor_with_probe(probe, name="minimax") + + async def error_stream(_body, _headers): + yield b'data: {"type":"message_start"}\n\n' + raise httpx.HTTPStatusError( + "500", + request=httpx.Request("POST", "https://example.com"), + response=httpx.Response(500), + ) + + vendor.send_message_stream = MagicMock(side_effect=error_stream) + exec_inst = _make_executor(vendor) + + with pytest.raises(httpx.HTTPStatusError): + async for _ in exec_inst.execute_stream({"model": "claude-test"}, {}): + pass + + assert "enter:minimax:minimax-mapped" in probe.events + assert "exit:minimax:minimax-mapped" in probe.events diff --git a/tests/test_router_executor.py b/tests/test_router_executor.py index 9506e67..070ca95 100644 --- a/tests/test_router_executor.py +++ b/tests/test_router_executor.py @@ -90,6 +90,11 @@ def _supports_request(request_caps: RequestCapabilities): vendor.check_health = AsyncMock(return_value=True) vendor.close = AsyncMock() vendor.set_compat_context = MagicMock() + + # track_in_flight 返回 nullcontext(不影响执行流,仅满足 async with 协议) + from contextlib import nullcontext + + vendor.track_in_flight = MagicMock(return_value=nullcontext()) return vendor diff --git a/tests/test_zhipu_concurrency.py b/tests/test_zhipu_concurrency.py index 7566b24..d7be1ca 100644 --- a/tests/test_zhipu_concurrency.py +++ b/tests/test_zhipu_concurrency.py @@ -1,13 +1,16 @@ """Zhipu 每模型并发限制专项测试. -验证 ``ModelConcurrencyLimiter`` 与 ``ZhipuVendor`` 集成后的并发控制行为: +验证 ``ModelConcurrencyController`` 与 ``ZhipuVendor`` 集成后的并发控制行为: - 默认 ``concurrency.default=3`` 时同一模型最多 3 个并发 - 超出上限时按 FIFO 排队,槽位释放后才唤醒 - 不同模型彼此独立,互不阻塞 - - 异常路径下 Semaphore 仍能释放,避免泄漏 - - 流式请求与非流式请求共享同一信号量 + - 异常路径下槽位仍能释放,避免泄漏 + - 流式请求与非流式请求共享同一槽位 - 与 429 重试机制兼容(重试期间持续占用槽位) - - ``concurrency=None`` 时禁用限制(向后兼容) + +注意:并发限流由 BaseVendor._concurrency_controller 统一管控, +executor 层通过 ``vendor.track_in_flight(mapped_model)`` 上下文管理器 +包裹 send_message[_stream] 调用。本测试用同样的包裹模拟 executor 语义。 """ from __future__ import annotations @@ -25,7 +28,10 @@ ZhipuConfig, ) from coding.proxy.routing.model_mapper import ModelMapper -from coding.proxy.vendors.concurrency import ModelConcurrencyLimiter +from coding.proxy.vendors.concurrency import ( + ModelConcurrencyController, + ModelConcurrencyLimiter, +) from coding.proxy.vendors.native_anthropic import NativeAnthropicVendor from coding.proxy.vendors.zhipu import ZhipuVendor @@ -69,6 +75,29 @@ def _make_vendor( return ZhipuVendor(ZhipuConfig(**cfg_kwargs), _make_mapper()) +async def _send_with_tracking( + vendor: ZhipuVendor, + body: dict, + headers: dict, +): + """模拟 executor 行为:track_in_flight 包裹 send_message 调用.""" + mapped = vendor.map_model(body.get("model", "")) + async with vendor.track_in_flight(mapped): + return await vendor.send_message(body, headers) + + +async def _stream_with_tracking( + vendor: ZhipuVendor, + body: dict, + headers: dict, +): + """模拟 executor 行为:track_in_flight 包裹 send_message_stream 调用.""" + mapped = vendor.map_model(body.get("model", "")) + async with vendor.track_in_flight(mapped): + async for chunk in vendor.send_message_stream(body, headers): + yield chunk + + def _make_200_response() -> httpx.Response: body = json.dumps( { @@ -132,72 +161,128 @@ def test_zhipu_config_default_concurrency(self) -> None: assert cfg.concurrency.default == 3 -# ─── ModelConcurrencyLimiter 单元测试 ────────────────────── +# ─── ModelConcurrencyController 单元测试(limited 模式)───── + +class TestModelConcurrencyControllerLimited: + """ModelConcurrencyController limited 模式基础行为.""" -class TestModelConcurrencyLimiter: - """ModelConcurrencyLimiter 基础行为.""" + def test_alias_compatibility(self) -> None: + """ModelConcurrencyLimiter 别名指向 ModelConcurrencyController.""" + assert ModelConcurrencyLimiter is ModelConcurrencyController + + def test_mode_is_limited(self) -> None: + ctrl = ModelConcurrencyController(ZhipuConcurrencyConfig(default=2)) + assert ctrl.mode == "limited" @pytest.mark.asyncio - async def test_lazy_semaphore_creation(self) -> None: - limiter = ModelConcurrencyLimiter(ZhipuConcurrencyConfig(default=2)) - slot_a = limiter._get_or_create_slot("model-a") - slot_b = limiter._get_or_create_slot("model-b") + async def test_lazy_slot_creation(self) -> None: + ctrl = ModelConcurrencyController(ZhipuConcurrencyConfig(default=2)) + slot_a = ctrl._get_or_create_slot("model-a") + slot_b = ctrl._get_or_create_slot("model-b") # 不同模型独立 slot assert slot_a is not slot_b # 相同模型复用 slot - assert limiter._get_or_create_slot("model-a") is slot_a + assert ctrl._get_or_create_slot("model-a") is slot_a @pytest.mark.asyncio - async def test_acquire_blocks_when_full(self) -> None: - limiter = ModelConcurrencyLimiter(ZhipuConcurrencyConfig(default=2)) + async def test_track_blocks_when_full(self) -> None: + ctrl = ModelConcurrencyController(ZhipuConcurrencyConfig(default=2)) + + # 通过 acquire 占满 2 个槽位 + slot = ctrl._get_or_create_slot("glm-5.1") + await slot.acquire() + await slot.acquire() + assert slot.in_use == 2 - # 占满 2 个槽位 - sem1 = await limiter.acquire("glm-5.1") - sem2 = await limiter.acquire("glm-5.1") - assert sem1 is sem2 # 同一 semaphore + # 第 3 次 track 必须阻塞 + async def third(): + async with ctrl.track("glm-5.1"): + pass - # 第 3 次 acquire 必须阻塞 - task = asyncio.create_task(limiter.acquire("glm-5.1")) + task = asyncio.create_task(third()) await asyncio.sleep(0.05) assert not task.done(), "第三个请求应在排队等待" + # 排队时 pending 应递增 + assert slot.pending == 1 # 释放一个槽位后,等待者被唤醒 - sem1.release() + slot.release() await asyncio.sleep(0.05) assert task.done() - (await task).release() - sem2.release() + slot.release() @pytest.mark.asyncio async def test_per_model_independent(self) -> None: - limiter = ModelConcurrencyLimiter( + ctrl = ModelConcurrencyController( ZhipuConcurrencyConfig(default=1, models={"glm-5.1": 1}) ) - # 占满 glm-5.1 - sem_51 = await limiter.acquire("glm-5.1") - # glm-5v-turbo 仍可立即获取 - sem_5v = await asyncio.wait_for(limiter.acquire("glm-5v-turbo"), timeout=0.5) - assert sem_51 is not sem_5v - sem_51.release() - sem_5v.release() - def test_diagnostics_snapshot(self) -> None: - limiter = ModelConcurrencyLimiter(ZhipuConcurrencyConfig(default=3)) + async def hold(model: str, event: asyncio.Event) -> None: + async with ctrl.track(model): + await event.wait() + + gate_51 = asyncio.Event() + gate_5v = asyncio.Event() + t51 = asyncio.create_task(hold("glm-5.1", gate_51)) + await asyncio.sleep(0.02) + # glm-5v-turbo 仍可立即获取 + t5v = asyncio.create_task(hold("glm-5v-turbo", gate_5v)) + await asyncio.sleep(0.02) + # 两个任务都尚未结束(都在 await event) + assert not t51.done() + assert not t5v.done() + gate_51.set() + gate_5v.set() + await asyncio.gather(t51, t5v) + + def test_diagnostics_snapshot_limited(self) -> None: + ctrl = ModelConcurrencyController(ZhipuConcurrencyConfig(default=3)) # 触发 slot 创建 - limiter._get_or_create_slot("glm-5.1") - snap = limiter.get_diagnostics() + ctrl._get_or_create_slot("glm-5.1") + snap = ctrl.get_diagnostics() assert "glm-5.1" in snap + assert snap["glm-5.1"]["mode"] == "limited" assert snap["glm-5.1"]["limit"] == 3 assert snap["glm-5.1"]["available"] == 3 assert snap["glm-5.1"]["in_use"] == 0 + assert snap["glm-5.1"]["pending"] == 0 + assert "peak_pending_recent" in snap["glm-5.1"] + + @pytest.mark.asyncio + async def test_peak_pending_recent_tracking(self) -> None: + """触发排队时记录 peak,释放后仍可读到余晖.""" + ctrl = ModelConcurrencyController(ZhipuConcurrencyConfig(default=1)) + slot = ctrl._get_or_create_slot("glm-5.1") + + # 占满 + await slot.acquire() + # 触发两个排队 + t1 = asyncio.create_task(slot.acquire()) + t2 = asyncio.create_task(slot.acquire()) + await asyncio.sleep(0.05) + assert slot.pending == 2 + snap = ctrl.get_diagnostics() + assert snap["glm-5.1"]["peak_pending_recent"] == 2 + + # 释放并完成所有任务 + slot.release() + await t1 + slot.release() + await t2 + slot.release() + + # pending 已归零,但 peak_pending_recent 仍记得最近的峰值 + snap2 = ctrl.get_diagnostics() + assert snap2["glm-5.1"]["pending"] == 0 + assert snap2["glm-5.1"]["peak_pending_recent"] == 2 # ─── ZhipuVendor 集成测试:非流式 ──────────────────────────── class TestZhipuVendorNonStreamConcurrency: - """非流式 send_message 的并发限制行为.""" + """非流式 send_message 的并发限制行为(通过 track_in_flight 包裹).""" @pytest.mark.asyncio async def test_limits_parallel_requests(self) -> None: @@ -223,7 +308,8 @@ async def mock_post(*_, **__) -> httpx.Response: tasks = [ asyncio.create_task( - vendor.send_message( + _send_with_tracking( + vendor, {"model": "claude-opus-4-6", "messages": []}, {}, ) @@ -265,15 +351,17 @@ async def mock_post(*_args, **kwargs) -> httpx.Response: mock_client.return_value = client # claude-opus → glm-5.1, claude-sonnet → glm-5v-turbo, - # 分属两个独立信号量,应同时执行 + # 分属两个独立槽位,应同时执行 task_opus = asyncio.create_task( - vendor.send_message( + _send_with_tracking( + vendor, {"model": "claude-opus-4-6", "messages": []}, {}, ) ) task_sonnet = asyncio.create_task( - vendor.send_message( + _send_with_tracking( + vendor, {"model": "claude-sonnet-4-6", "messages": []}, {}, ) @@ -289,8 +377,8 @@ async def mock_post(*_args, **kwargs) -> httpx.Response: await asyncio.gather(task_opus, task_sonnet) @pytest.mark.asyncio - async def test_semaphore_released_on_exception(self) -> None: - """上游抛异常时 Semaphore 仍应释放,后续请求不阻塞.""" + async def test_slot_released_on_exception(self) -> None: + """上游抛异常时槽位仍应释放,后续请求不阻塞.""" vendor = _make_vendor(ZhipuConcurrencyConfig(default=1)) call_count = 0 @@ -307,14 +395,16 @@ async def mock_post(*_, **__) -> httpx.Response: mock_client.return_value = client with pytest.raises(RuntimeError): - await vendor.send_message( + await _send_with_tracking( + vendor, {"model": "claude-opus-4-6", "messages": []}, {}, ) # 槽位应已释放,第二次请求可正常完成 resp = await asyncio.wait_for( - vendor.send_message( + _send_with_tracking( + vendor, {"model": "claude-opus-4-6", "messages": []}, {}, ), @@ -343,7 +433,8 @@ async def mock_post(*_, **__) -> httpx.Response: client.post = mock_post mock_client.return_value = client - resp = await vendor.send_message( + resp = await _send_with_tracking( + vendor, {"model": "claude-opus-4-6", "messages": []}, {}, ) @@ -351,13 +442,13 @@ async def mock_post(*_, **__) -> httpx.Response: assert call_count == 3 # 两次 429 + 一次成功,且共用同一槽位 @pytest.mark.asyncio - async def test_no_concurrency_when_config_is_none(self) -> None: - """concurrency=None 时禁用并发限制,行为与旧版完全一致.""" - # 强制构造一个 concurrency=None 的 ZhipuConfig(绕过默认工厂) - cfg = ZhipuConfig(api_key="key") - cfg = cfg.model_copy(update={"concurrency": None}) - vendor = ZhipuVendor(cfg, _make_mapper()) - assert vendor._concurrency_limiter is None + async def test_monitor_mode_no_throttling(self) -> None: + """BaseVendor 默认 monitor 模式:高并发不限流,仅计数.""" + # 显式构造一个 monitor 模式的 zhipu vendor(用于验证 BaseVendor 默认行为等价) + vendor = _make_vendor(ZhipuConcurrencyConfig(default=20)) + from coding.proxy.vendors.concurrency import ModelConcurrencyController as MCC + + vendor._concurrency_controller = MCC(None) gate = asyncio.Event() active = 0 @@ -378,7 +469,8 @@ async def mock_post(*_, **__) -> httpx.Response: tasks = [ asyncio.create_task( - vendor.send_message( + _send_with_tracking( + vendor, {"model": "claude-opus-4-6", "messages": []}, {}, ) @@ -390,7 +482,7 @@ async def mock_post(*_, **__) -> httpx.Response: break await asyncio.sleep(0.01) - assert peak == 5, "无并发限制时应全部并行" + assert peak == 5, "monitor 模式应允许全部并行" gate.set() await asyncio.gather(*tasks) @@ -399,7 +491,7 @@ async def mock_post(*_, **__) -> httpx.Response: class TestZhipuVendorStreamConcurrency: - """流式 send_message_stream 的并发限制行为.""" + """流式 send_message_stream 的并发限制行为(通过 track_in_flight 包裹).""" @pytest.mark.asyncio async def test_stream_limits_parallel_requests(self) -> None: @@ -421,8 +513,8 @@ async def fake_stream(self, _body, _headers): # noqa: ARG001 async def consume(model: str) -> int: chunks: list[bytes] = [] - async for chunk in vendor.send_message_stream( - {"model": model, "messages": []}, {} + async for chunk in _stream_with_tracking( + vendor, {"model": model, "messages": []}, {} ): chunks.append(chunk) return len(chunks) @@ -453,15 +545,14 @@ async def fake_stream(self, _body, _headers): # noqa: ARG001 # 连续两次流式请求都能完成(说明槽位被释放) for _ in range(2): chunks = [] - async for chunk in vendor.send_message_stream( - {"model": "claude-opus-4-6", "messages": []}, {} + async for chunk in _stream_with_tracking( + vendor, {"model": "claude-opus-4-6", "messages": []}, {} ): chunks.append(chunk) assert len(chunks) == 2 # 确认 slot 当前完全可用 - assert vendor._concurrency_limiter is not None - slot = vendor._concurrency_limiter._get_or_create_slot("glm-5.1") + slot = vendor._concurrency_controller._get_or_create_slot("glm-5.1") assert slot.available == 1 @pytest.mark.asyncio @@ -485,22 +576,22 @@ async def fake_stream(self, _body, _headers): # noqa: ARG001 with patch.object(NativeAnthropicVendor, "send_message_stream", fake_stream): with pytest.raises(httpx.HTTPStatusError): - async for _ in vendor.send_message_stream( - {"model": "claude-opus-4-6", "messages": []}, {} + async for _ in _stream_with_tracking( + vendor, {"model": "claude-opus-4-6", "messages": []}, {} ): pass # 槽位应已释放,第二次请求可正常推进 chunks = [] - async for chunk in vendor.send_message_stream( - {"model": "claude-opus-4-6", "messages": []}, {} + async for chunk in _stream_with_tracking( + vendor, {"model": "claude-opus-4-6", "messages": []}, {} ): chunks.append(chunk) assert chunks == [b'data: {"type":"message_start"}\n\n'] @pytest.mark.asyncio - async def test_stream_and_nonstream_share_semaphore(self) -> None: - """流式与非流式请求共用同一信号量(按映射后模型分组).""" + async def test_stream_and_nonstream_share_slot(self) -> None: + """流式与非流式请求共用同一槽位(按映射后模型分组).""" vendor = _make_vendor(ZhipuConcurrencyConfig(default=1)) gate = asyncio.Event() active = 0 @@ -530,8 +621,8 @@ async def mock_post(*_, **__) -> httpx.Response: # 启动流式请求并等待它占用槽位 async def consume_stream() -> None: - async for _ in vendor.send_message_stream( - {"model": "claude-opus-4-6", "messages": []}, {} + async for _ in _stream_with_tracking( + vendor, {"model": "claude-opus-4-6", "messages": []}, {} ): pass @@ -542,9 +633,10 @@ async def consume_stream() -> None: await asyncio.sleep(0.01) assert active == 1 - # 非流式请求应被同一信号量阻塞 + # 非流式请求应被同一槽位阻塞 nonstream_task = asyncio.create_task( - vendor.send_message( + _send_with_tracking( + vendor, {"model": "claude-opus-4-6", "messages": []}, {}, ) From 01ea020ee91118ccc852df2070cc9bc4bd7de6a5 Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Wed, 27 May 2026 17:50:00 +0800 Subject: [PATCH 02/42] =?UTF-8?q?build(version):=20=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E5=8F=B7=E5=8D=87=E7=BA=A7=E8=87=B3=200.5.1a1;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 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 --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 14dcba1..bb274e5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "coding-proxy" -version = "0.5.0" +version = "0.5.1a1" 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/uv.lock b/uv.lock index d04ad46..f36659f 100644 --- a/uv.lock +++ b/uv.lock @@ -74,7 +74,7 @@ wheels = [ [[package]] name = "coding-proxy" -version = "0.5.0" +version = "0.5.1a1" source = { editable = "." } dependencies = [ { name = "aiosqlite" }, From 47f4851cd26750b70c0ef79e8be77c44b743a953 Mon Sep 17 00:00:00 2001 From: Aurelius Huang Date: Wed, 27 May 2026 19:51:24 +0800 Subject: [PATCH 03/42] =?UTF-8?q?style(dashboard):=20=E6=94=B6=E7=AA=84?= =?UTF-8?q?=E5=B9=B6=E7=BB=9F=E4=B8=80=E4=BB=AA=E8=A1=A8=E6=9D=BF=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=E9=97=B4=E5=9E=82=E7=9B=B4=E9=97=B4=E8=B7=9D=E8=87=B3?= =?UTF-8?q?=2012px;=20(#254)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 CSS 变量 --gap-section: 12px 作为模块间距单一事实源 - 将 .time-range-bar / .kpi-grid / .model-calling-card / .charts-grid / .charts-grid-2 / .model-token-card 的 margin-bottom 统一指向该变量 - 消除底部 Token 用量卡片的 inline style,改用语义化 class 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) --- src/coding/proxy/server/dashboard.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/coding/proxy/server/dashboard.py b/src/coding/proxy/server/dashboard.py index 156c098..a04e1f9 100644 --- a/src/coding/proxy/server/dashboard.py +++ b/src/coding/proxy/server/dashboard.py @@ -89,6 +89,7 @@ def _build_favicon() -> bytes: --shadow-md: 0 8px 24px rgba(0,0,0,.3); --glow-blue: 0 0 0 1px rgba(88,166,255,.1), 0 8px 32px rgba(88,166,255,.04); --gradient-primary: linear-gradient(135deg, #667eea, #764ba2); + --gap-section: 12px; } @keyframes fadeInUp { from { opacity: 0; transform: translateY(10px); } @@ -160,7 +161,7 @@ def _build_favicon() -> bytes: display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 5px; - margin-bottom: 24px; + margin-bottom: var(--gap-section); } .kpi-card { background: rgba(18,22,30,.7); @@ -214,13 +215,13 @@ def _build_favicon() -> bytes: display: grid; grid-template-columns: 1fr 2fr; gap: 16px; - margin-bottom: 16px; + margin-bottom: var(--gap-section); } .charts-grid-2 { display: grid; grid-template-columns: 1fr 2fr; gap: 16px; - margin-bottom: 16px; + margin-bottom: var(--gap-section); } .charts-grid > .card, .charts-grid-2 > .card { @@ -356,7 +357,7 @@ def _build_favicon() -> bytes: /* ── 时间区间选择栏 ── */ .time-range-bar { display: flex; align-items: center; gap: 8px; - margin-bottom: 24px; flex-wrap: wrap; + margin-bottom: var(--gap-section); flex-wrap: wrap; padding: 8px 16px; background: rgba(18,22,30,.5); border: 1px solid rgba(255,255,255,.04); @@ -560,7 +561,10 @@ def _build_favicon() -> bytes: /* ── Model Calling 实时状态 ────────────────────────── */ .model-calling-card { - margin-bottom: 5px; + margin-bottom: var(--gap-section); + } + .model-token-card { + margin-bottom: var(--gap-section); } .mc-empty { text-align: center; @@ -791,7 +795,7 @@ def _build_favicon() -> bytes: -
+
近 7 天 Token 用量(按 Vendor / 模型)
From 1fa8f20b89ca2ac30dcad5e4a9734b7833f31a62 Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Wed, 27 May 2026 20:38:20 +0800 Subject: [PATCH 04/42] =?UTF-8?q?build(version):=20=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E5=8F=B7=E5=8D=87=E7=BA=A7=E8=87=B3=200.5.1a2;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 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 --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index bb274e5..f795c11 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "coding-proxy" -version = "0.5.1a1" +version = "0.5.1a2" 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/uv.lock b/uv.lock index f36659f..cad3e19 100644 --- a/uv.lock +++ b/uv.lock @@ -74,7 +74,7 @@ wheels = [ [[package]] name = "coding-proxy" -version = "0.5.1a1" +version = "0.5.1a2" source = { editable = "." } dependencies = [ { name = "aiosqlite" }, From 288b14b2262ef8334e0e42370d159a901f624dfc Mon Sep 17 00:00:00 2001 From: Aurelius Huang Date: Wed, 27 May 2026 22:55:27 +0800 Subject: [PATCH 05/42] =?UTF-8?q?feat(vendor-channels):=20=E8=BF=87?= =?UTF-8?q?=E6=B8=A1=E7=AE=A1=E7=BA=BF=E8=AF=8A=E6=96=AD=E5=BF=AB=E7=85=A7?= =?UTF-8?q?=E4=B8=8E=20Anthropic=20=E9=85=8D=E5=AF=B9=E8=87=AA=E6=A3=80=20?= =?UTF-8?q?(#255)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * diag(executor): 为语义拒绝路径增加请求体可疑参数诊断日志; 在 execute_message 和 execute_stream 的 semantic rejection 日志中 附加请求体参数快照(thinking/extended_thinking/reasoning_effort 顶层参数、 会话历史中 thinking blocks 数量、cache_control 存在情况、模型名、消息数), 用于定位 zhipu glm-4.7 [1210] 参数校验拒绝的具体祸根参数。 🤖 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 * diag(executor): 扩展语义拒绝诊断日志覆盖范围至 system/tools/sampling/metadata/content_types 等维度 PR #244 部署后的诊断日志反转了原推断:失败请求均不含 thinking/cache_control, 说明祸根在更细粒度的参数。扩展 _build_semantic_rejection_diagnostic 函数: 新增维度(仅存在时输出): - system 形态(string/blocks + cache_control 计数) - tools 数量 + tool_choice 形态 - 采样参数(max_tokens/temperature/top_p/top_k/stop_sequences) - stream / metadata_keys - messages.content 类型分布(含 string content) - 请求体字节数估算(json.dumps) 新增 14 个单元测试(TestBuildSemanticRejectionDiagnostic)覆盖各字段组合 与真实失败请求形态。所有测试通过(1478 passed)。 * docs(agents): 记录 zhipu [1210] 诊断阶段进展(证据反转 + Step 1 v2) * build(version): 版本号升级至 0.4.1a6; * fix(zhipu): 兼容转换 thinking.type=adaptive 为 GLM 支持的 enabled 格式,修复 [1210] 语义拒绝 基于 2026-05-26 16:30–16:31 日志证据(8 次连续拒绝均含 thinking.type=adaptive), 在 ZhipuVendor._prepare_request 中实现兼容转换: - adaptive → enabled(budget=16000):保留 thinking 能力,使用 GLM 原生确认支持的格式 - 新增 _build_zhipu_request_snapshot 诊断快照(成功/失败统一格式,可 diff 对比) - 扩展语义拒绝日志错误体截断(200→500 字符),保留完整字段级诊断 - metadata 暂不处理,待进一步诊断确认兼容性 Co-Authored-By: Claude Opus 4.7 (1M context) * fix(executor): 删除旧版重复定义的 _build_semantic_rejection_diagnostic 函数 Step 1 v2 扩展版本与 Step 1 旧版本同名重复定义,Python 运行时后者覆盖前者 不报错但旧版成为死代码。删除旧版仅保留扩展版本。 🤖 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 * feat(vendor-channels): 新增过渡管线诊断快照与 Anthropic 配对自检; 在 prepare_zhipu_to_anthropic 管线中新增两个辅助函数: 1. _dump_message_digest: 输出各阶段消息结构摘要(DEBUG 级别), 用于过渡管线变换前后的可观测性诊断 2. _validate_anthropic_pairing: 独立的 tool_use/tool_result 配对 自检(纯检测,不修改),定位 enforce/sanity 未覆盖的边界 case 🤖 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 --------- Co-authored-by: Claude Opus 4.7 (1M context) --- src/coding/proxy/convert/vendor_channels.py | 126 ++++++++++- tests/test_vendor_channels.py | 218 ++++++++++++++++++++ 2 files changed, 343 insertions(+), 1 deletion(-) diff --git a/src/coding/proxy/convert/vendor_channels.py b/src/coding/proxy/convert/vendor_channels.py index 456a9b3..030f7a3 100644 --- a/src/coding/proxy/convert/vendor_channels.py +++ b/src/coding/proxy/convert/vendor_channels.py @@ -52,6 +52,117 @@ def get_transition_channel( # ── 共享辅助函数 ────────────────────────────────────────────── +def _dump_message_digest( + messages: list[dict[str, Any]], + *, + max_messages: int = 10, + label: str = "", +) -> None: + """输出前 N 条消息的结构摘要(role + content_type_counts),用于过渡管线诊断. + + 仅在 DEBUG 级别输出,且仅在消息数 > 0 时才输出,避免噪声。 + """ + if not messages or not logger.isEnabledFor(logging.DEBUG): + return + parts: list[str] = [f"[{label}]" if label else ""] + limit = min(len(messages), max_messages) + for idx in range(limit): + msg = messages[idx] + role = msg.get("role", "?") if isinstance(msg, dict) else "?" + content = msg.get("content") if isinstance(msg, dict) else None + if isinstance(content, list): + type_counts: dict[str, int] = {} + for b in content: + if isinstance(b, dict): + t = b.get("type", "?") + type_counts[t] = type_counts.get(t, 0) + 1 + else: + type_counts["raw"] = type_counts.get("raw", 0) + 1 + counts_str = ",".join(f"{t}:{c}" for t, c in sorted(type_counts.items())) + elif isinstance(content, str): + counts_str = f"str({len(content)})" + else: + counts_str = "empty" + parts.append(f"{idx}:{role}[{counts_str}]") + if len(messages) > max_messages: + parts.append(f"...+{len(messages) - max_messages}more") + logger.debug("Transition digest %s", " ".join(parts)) + + +def _validate_anthropic_pairing( + messages: list[dict[str, Any]], + *, + context: str = "", +) -> list[str]: + """独立的 Anthropic tool_use/tool_result 配对自检(过渡管线末端执行). + + 与 ``_enforce_pairing_sanity_pass`` 不同,此函数: + - 不修改消息列表(纯检测) + - 针对每个 assistant + tool_use,精确记录下一条 user 消息中匹配/缺失的 ID + - 发现不一致时输出 WARNING 级别日志含 message index 与具体 ID + + Returns: + 检测到的问题描述列表(空列表表示全部通过)。 + """ + issues: list[str] = [] + for i, msg in enumerate(messages): + if not isinstance(msg, dict) or msg.get("role") != "assistant": + continue + content = msg.get("content") + if not isinstance(content, list): + continue + tool_use_ids = [ + b["id"] + for b in content + if isinstance(b, dict) and b.get("type") == "tool_use" and b.get("id") + ] + if not tool_use_ids: + continue + + next_idx = i + 1 + if next_idx >= len(messages): + issues.append(f"messages[{i}]: assistant with tool_uses at end of list") + continue + + next_msg = messages[next_idx] + if not isinstance(next_msg, dict) or next_msg.get("role") != "user": + issues.append( + f"messages[{i}]: next messages[{next_idx}] is not user " + f"(role={next_msg.get('role') if isinstance(next_msg, dict) else '?'})" + ) + continue + + user_content = next_msg.get("content") + if not isinstance(user_content, list): + user_content = [] + + result_ids = { + b["tool_use_id"] + for b in user_content + if isinstance(b, dict) + and b.get("type") == "tool_result" + and isinstance(b.get("tool_use_id"), str) + } + + missing = [uid for uid in tool_use_ids if uid not in result_ids] + if missing: + issue = ( + f"messages[{i}]: {len(missing)}/{len(tool_use_ids)} tool_use(s) " + f"without tool_result in messages[{next_idx}]: {missing[:5]}" + ) + issues.append(issue) + + if issues: + prefix = f"[{context}] " if context else "" + logger.warning( + "Anthropic pairing validation: %s%d issue(s) found: %s", + prefix, + len(issues), + "; ".join(issues), + ) + return issues + + def strip_thinking_blocks(body: dict[str, Any]) -> int: """从 assistant 消息中移除 thinking/redacted_thinking 块(就地). @@ -678,6 +789,7 @@ def prepare_zhipu_to_anthropic( 2. 改写 ``srvtoolu_*`` ID 与 ``server_tool_use`` 类型为标准 Anthropic 形式 3. 强制 tool_use/tool_result 配对(单遍正向扫描) 4. 剥离 thinking blocks(signature 无效) + 5. 独立的 Anthropic 兼容性自检(纯检测,不修改,定位 enforce/sanity 未覆盖的边界 case) 所有变换均为幂等操作,安全地在已清理的请求体上重复执行。 @@ -686,6 +798,10 @@ def prepare_zhipu_to_anthropic( """ prepared = copy.deepcopy(body) adaptations: list[str] = [] + msgs = prepared.get("messages", []) + + # ── 过渡管线诊断:变换前快照 ── + _dump_message_digest(msgs, label="zhipu→anthropic.before") # Step 1: 剥离 zhipu 私有 content block 类型(如 server_tool_use_delta) removed_vendor_blocks = _remove_vendor_blocks(prepared, _ZHIPU_VENDOR_BLOCK_TYPES) @@ -696,16 +812,24 @@ def prepare_zhipu_to_anthropic( rewritten, _ = _rewrite_srvtoolu_ids(prepared) if rewritten: adaptations.append(f"rewritten_{rewritten}_srvtoolu_ids") + _dump_message_digest(msgs, label="zhipu→anthropic.after_rewrite") # Step 3: 强制 tool_use/tool_result 配对 - pairing_fixes = enforce_anthropic_tool_pairing(prepared.get("messages", [])) + pairing_fixes = enforce_anthropic_tool_pairing(msgs) if pairing_fixes: adaptations.extend(pairing_fixes) + _dump_message_digest(msgs, label="zhipu→anthropic.after_enforce") # Step 4: 剥离 thinking blocks(zhipu signature 无效) stripped = strip_thinking_blocks(prepared) if stripped: adaptations.append(f"stripped_{stripped}_thinking_blocks") + _dump_message_digest(msgs, label="zhipu→anthropic.after_strip") + + # Step 5: 独立的 Anthropic 兼容性自检(纯检测,不修改) + validation_issues = _validate_anthropic_pairing(msgs, context="zhipu→anthropic") + if validation_issues: + adaptations.append("anthropic_pairing_validation_issues") return prepared, adaptations diff --git a/tests/test_vendor_channels.py b/tests/test_vendor_channels.py index f9c9bb5..d99a4c6 100644 --- a/tests/test_vendor_channels.py +++ b/tests/test_vendor_channels.py @@ -2225,3 +2225,221 @@ def test_preserves_supported_params(self): assert result["stream"] is True assert result["metadata"] == {"user_id": "test"} assert adaptations == [] + + +class TestDumpMessageDigest: + """``_dump_message_digest`` 诊断快照函数测试.""" + + def test_outputs_nothing_on_empty_messages(self, caplog): + import logging + + from coding.proxy.convert.vendor_channels import _dump_message_digest + + with caplog.at_level( + logging.DEBUG, logger="coding.proxy.convert.vendor_channels" + ): + _dump_message_digest([], label="test") + assert "Transition digest" not in caplog.text + + def test_outputs_structure_for_first_n_messages(self, caplog): + import logging + + from coding.proxy.convert.vendor_channels import _dump_message_digest + + messages = [ + {"role": "user", "content": [{"type": "text", "text": "hi"}]}, + { + "role": "assistant", + "content": [ + {"type": "tool_use", "id": "toolu_1", "name": "bash", "input": {}}, + ], + }, + { + "role": "user", + "content": [ + {"type": "tool_result", "tool_use_id": "toolu_1", "content": "ok"}, + ], + }, + ] + with caplog.at_level( + logging.DEBUG, logger="coding.proxy.convert.vendor_channels" + ): + _dump_message_digest(messages, label="test") + assert "test" in caplog.text + assert "0:user" in caplog.text + assert "1:assistant" in caplog.text + assert "tool_use:1" in caplog.text + + +class TestValidateAnthropicPairing: + """``_validate_anthropic_pairing`` 独立配对自检测试.""" + + def test_no_issues_for_correct_pairing(self): + from coding.proxy.convert.vendor_channels import _validate_anthropic_pairing + + messages = [ + {"role": "user", "content": "go"}, + { + "role": "assistant", + "content": [ + {"type": "tool_use", "id": "toolu_1", "name": "bash", "input": {}}, + ], + }, + { + "role": "user", + "content": [ + {"type": "tool_result", "tool_use_id": "toolu_1", "content": "ok"}, + ], + }, + ] + issues = _validate_anthropic_pairing(messages) + assert issues == [] + + def test_detects_missing_tool_result(self): + from coding.proxy.convert.vendor_channels import _validate_anthropic_pairing + + messages = [ + {"role": "user", "content": "go"}, + { + "role": "assistant", + "content": [ + {"type": "tool_use", "id": "toolu_1", "name": "bash", "input": {}}, + ], + }, + {"role": "user", "content": [{"type": "text", "text": "no result"}]}, + ] + issues = _validate_anthropic_pairing(messages) + assert len(issues) == 1 + assert "toolu_1" in issues[0] + assert "messages[1]" in issues[0] + + def test_detects_non_user_after_assistant_with_tool_use(self): + from coding.proxy.convert.vendor_channels import _validate_anthropic_pairing + + messages = [ + {"role": "user", "content": "go"}, + { + "role": "assistant", + "content": [ + {"type": "tool_use", "id": "toolu_1", "name": "bash", "input": {}}, + ], + }, + { + "role": "assistant", + "content": [{"type": "text", "text": "another assistant"}], + }, + ] + issues = _validate_anthropic_pairing(messages) + assert len(issues) == 1 + assert "not user" in issues[0] + + def test_detects_assistant_with_tool_use_at_end_of_list(self): + from coding.proxy.convert.vendor_channels import _validate_anthropic_pairing + + messages = [ + {"role": "user", "content": "go"}, + { + "role": "assistant", + "content": [ + {"type": "tool_use", "id": "toolu_1", "name": "bash", "input": {}}, + ], + }, + ] + issues = _validate_anthropic_pairing(messages) + assert len(issues) == 1 + assert "end of list" in issues[0] + + def test_partial_missing_only_reports_missing_ids(self): + from coding.proxy.convert.vendor_channels import _validate_anthropic_pairing + + messages = [ + {"role": "user", "content": "go"}, + { + "role": "assistant", + "content": [ + {"type": "tool_use", "id": "toolu_1", "name": "bash", "input": {}}, + {"type": "tool_use", "id": "toolu_2", "name": "read", "input": {}}, + ], + }, + { + "role": "user", + "content": [ + {"type": "tool_result", "tool_use_id": "toolu_1", "content": "ok"}, + ], + }, + ] + issues = _validate_anthropic_pairing(messages) + assert len(issues) == 1 + assert "toolu_2" in issues[0] + assert "1/2" in issues[0] + + def test_integration_with_zhipu_to_anthropic_channel(self): + """验证 prepare_zhipu_to_anthropic 在末端执行自检且 adaptations 包含标签.""" + from coding.proxy.convert.vendor_channels import prepare_zhipu_to_anthropic + + body = { + "model": "claude-opus-4-7", + "messages": [ + {"role": "user", "content": "go"}, + { + "role": "assistant", + "content": [ + { + "type": "server_tool_use", + "id": "srvtoolu_01", + "name": "bash", + "input": {}, + }, + ], + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "srvtoolu_01", + "content": "ok", + }, + ], + }, + ], + } + result, adaptations = prepare_zhipu_to_anthropic(body) + # 自检通过,不应包含 validation_issues 标签 + assert "anthropic_pairing_validation_issues" not in adaptations + + def test_integration_detects_enforce_missed_issue(self): + """构造一个理论上 enforce 可能遗漏的场景,验证自检能捕获. + + 场景:两条连续 assistant 消息,第一条的 tool_result 被第二条的 + existing_result_ids"冒领"(相同 ID 碰撞场景的模拟)。 + 虽然当前 enforce 实现下不太可能自然产生此场景,但自检应能捕获。 + """ + from coding.proxy.convert.vendor_channels import ( + _validate_anthropic_pairing, + ) + + # 手动构造一个 enforce 后仍存在配对缺陷的 body + messages = [ + {"role": "user", "content": "go"}, + { + "role": "assistant", + "content": [ + {"type": "tool_use", "id": "toolu_x", "name": "bash", "input": {}}, + ], + }, + { + "role": "user", + "content": [ + # tool_result 缺失 tolu_x,但有不相关的 tool_result + { + "type": "tool_result", + "tool_use_id": "toolu_other", + "content": "wrong", + }, + ], + }, + ] + issues = _validate_anthropic_pairing(messages) + assert len(issues) == 1 + assert "toolu_x" in issues[0] From e84242dca83efa2e004f68984b127f993f1fac99 Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Thu, 28 May 2026 13:52:59 +0800 Subject: [PATCH 06/42] =?UTF-8?q?chore(docs):=20=E8=BF=81=E7=A7=BB=20Agent?= =?UTF-8?q?=20=E5=8D=8F=E4=BD=9C=E6=96=87=E6=A1=A3=E8=87=B3=20docs/.agents?= =?UTF-8?q?/=20=E9=9A=90=E8=97=8F=E7=9B=AE=E5=BD=95;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 docs/agents/ 目录重命名为 docs/.agents/,同步更新 AGENTS.md 中所有相关引用链接。 🤖 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 --- AGENTS.md | 8 ++++---- docs/{agents => .agents}/browser-validation.md | 0 docs/{agents => .agents}/issue.md | 0 docs/{agents => .agents}/knowledge-map.md | 0 docs/{agents => .agents}/reference-specifications.md | 0 5 files changed, 4 insertions(+), 4 deletions(-) rename docs/{agents => .agents}/browser-validation.md (100%) rename docs/{agents => .agents}/issue.md (100%) rename docs/{agents => .agents}/knowledge-map.md (100%) rename docs/{agents => .agents}/reference-specifications.md (100%) diff --git a/AGENTS.md b/AGENTS.md index ea86087..f9fe87a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,15 +39,15 @@ 3. **Link Validity**: 确保所有引用的 URL 可访问且具备明确的上下文价值; 4. **Testing**: 统一在 tests/ 下维护测试用例,区分单元测试(unit)和集成测试(integration),所有测试的本地运行总时间控制在 3 min 以内; 5. **Pre-commit Hooks**: 首次克隆仓库使用 `uv run pre-commit install` 激活本地 Git hooks,使 Ruff lint(含 auto-fix)、Ruff format 及通用代码卫生检查在每次 commit 前自动运行。若 hooks 自动修复了问题,提交会被中断,执行 `git add -p` 审阅修复内容后重新提交即可; - 6. **Issue**: 在 [issue.md](docs/agents/issue.md) 中维护你处理过的 Issue 摘要(问题描述、表因根因、处理方式、后续防范、同类问题影响与处理注意事项等),便于同类问题的跨上下文处理;注意识别相同 Issue,不要同 Issue 多处维护; + 6. **Issue**: 在 [issue.md](docs/.agents/issue.md) 中维护你处理过的 Issue 摘要(问题描述、表因根因、处理方式、后续防范、同类问题影响与处理注意事项等),便于同类问题的跨上下文处理;注意识别相同 Issue,不要同 Issue 多处维护; - **Package Management Standardization (包管理规范)**: 1. **Python**: 严禁使用 pip/poetry,**必须**统一使用 `uv` 进行包管理与脚本执行(如 `uv run`); 2. **JavaScript/TypeScript**: 严禁使用 npm/yarn,**必须**统一使用 `pnpm` 进行包管理与脚本执行; - **Database Management**: 谨慎操作,数据迁移、测试等操作严禁将现有数据删除,谨慎操作数据迁移的回滚,防止数据被清理。 - **In-depth and close to the facts**:系统且全面地进行问题的分析,深入贴近事实,如有疑问,需先发问,不要乱做决定。 -- **Browser Validation Protocol (浏览器验证准则)**:Agent 不得自行完成、绕过或模拟任何 OAuth / SSO 认证流程,所有登录态均来源于用户已认证的 Chrome 主 profile(真实用户登录态)。完整协议(连通性自检、凭证管理、E2E 集成、实机回归等)详见 [浏览器验证协议](./docs/agents/browser-validation.md); +- **Browser Validation Protocol (浏览器验证准则)**:Agent 不得自行完成、绕过或模拟任何 OAuth / SSO 认证流程,所有登录态均来源于用户已认证的 Chrome 主 profile(真实用户登录态)。完整协议(连通性自检、凭证管理、E2E 集成、实机回归等)详见 [浏览器验证协议](./docs/.agents/browser-validation.md); 1. **安全红线**:禁止在 Sandbox 浏览器中跳转 Google 同意屏;禁止以模拟用户或第三方账号替代真实登录态;禁止要求用户在 chat 中粘贴密码、Cookie 或验证码; -- **Knowledge Map (知识索引)**:项目所有文档索引统一维护在 [知识索引](./docs/agents/knowledge-map.md),并在文档目录变更时即时同步跟新; +- **Knowledge Map (知识索引)**:项目所有文档索引统一维护在 [知识索引](./docs/.agents/knowledge-map.md),并在文档目录变更时即时同步跟新; - **Documentation Standards (文档规范)**: 1. **Visual Documentation (图文并茂)**: 对于复杂逻辑,优先 **Mermaid Visualization Norms (Mermaid 可视化规范)**,构建”图文并茂”的直观文档; - **色彩语义与兼容性**:为图表节点配置具备语义辨识度的色彩,并确保在深色模式(Dark Mode)下具有极高的对比度与清晰度; @@ -55,4 +55,4 @@ 2. **语言叙事**:用语精准,叙事完备,行文专业,聚焦核心,篇幅精炼,形象具体,体现真实作用与用户吸引性,字数恰当; 3. **Direct Hyperlinking (直接跳转)**: 在文档中提及 Repo 内其他资源(文档/代码)时,**必须**构建可跳转的相对路径链接(如 `[Doc Name](./path.md)`),严禁使用”死文本”引用,以降低信息检索熵; 4. **实操截图**:文档需要引入必要的浏览器实操截图时,需自行通过默认浏览器打开相关页面,通过实操现场截图并保留到文档路径进行文档引用; -- **Reference Specifications (IEEE)**:为保障工程决策的可追溯性与学术严谨性,核心引用需遵循 [reference-specifications.md](docs/agents/reference-specifications.md)IEEE 标准引用格式; +- **Reference Specifications (IEEE)**:为保障工程决策的可追溯性与学术严谨性,核心引用需遵循 [reference-specifications.md](docs/.agents/reference-specifications.md)IEEE 标准引用格式; diff --git a/docs/agents/browser-validation.md b/docs/.agents/browser-validation.md similarity index 100% rename from docs/agents/browser-validation.md rename to docs/.agents/browser-validation.md diff --git a/docs/agents/issue.md b/docs/.agents/issue.md similarity index 100% rename from docs/agents/issue.md rename to docs/.agents/issue.md diff --git a/docs/agents/knowledge-map.md b/docs/.agents/knowledge-map.md similarity index 100% rename from docs/agents/knowledge-map.md rename to docs/.agents/knowledge-map.md diff --git a/docs/agents/reference-specifications.md b/docs/.agents/reference-specifications.md similarity index 100% rename from docs/agents/reference-specifications.md rename to docs/.agents/reference-specifications.md From 36a7537d35e825ffa308287ce3f3b69ba0c9ec43 Mon Sep 17 00:00:00 2001 From: Aurelius Huang Date: Thu, 28 May 2026 17:17:02 +0800 Subject: [PATCH 07/42] =?UTF-8?q?feat(routes):=20GET=20/=20=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E9=87=8D=E5=AE=9A=E5=90=91=E8=87=B3=20/dashboard=20(#?= =?UTF-8?q?256)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(routes): GET / 自动重定向至 /dashboard; 将根路径的 HEAD 与 GET handler 拆分:HEAD / 保持 200 响应(Claude Code 连通性探针依赖), GET / 改为 307 Temporary Redirect 至 /dashboard,提升浏览器访问体验。 同步更新单元测试与 E2E 测试的断言。 🤖 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 * perf(dashboard): 模型调用状态轮询间隔从 1.5s 放宽至 10s 以降低后端负载; 🤖 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 --- src/coding/proxy/server/dashboard.py | 2 +- src/coding/proxy/server/routes.py | 10 +++++++--- tests/e2e/test_e2e_http.py | 7 +++++-- tests/test_app_routes.py | 9 +++++---- 4 files changed, 18 insertions(+), 10 deletions(-) diff --git a/src/coding/proxy/server/dashboard.py b/src/coding/proxy/server/dashboard.py index a04e1f9..4c96ef0 100644 --- a/src/coding/proxy/server/dashboard.py +++ b/src/coding/proxy/server/dashboard.py @@ -1350,7 +1350,7 @@ def _build_favicon() -> bytes: }).catch(function() {}); } tick(); - _mcTimer = setInterval(tick, 1500); + _mcTimer = setInterval(tick, 10000); } function stopModelCallingPoll() { if (_mcTimer) { clearInterval(_mcTimer); _mcTimer = null; } diff --git a/src/coding/proxy/server/routes.py b/src/coding/proxy/server/routes.py index 5ed90dc..8b47fd0 100644 --- a/src/coding/proxy/server/routes.py +++ b/src/coding/proxy/server/routes.py @@ -8,7 +8,7 @@ import httpx from fastapi import Request, Response -from fastapi.responses import StreamingResponse +from fastapi.responses import RedirectResponse, StreamingResponse from ..vendors.base import NoCompatibleVendorError @@ -197,11 +197,15 @@ async def health() -> dict: return {"status": "ok"} @app.head("/") - @app.get("/") - async def root() -> Response: + async def root_head() -> Response: """根路径连通性探测 — Claude Code 在建连前发送 HEAD / 作为 health probe.""" return Response(status_code=200) + @app.get("/") + async def root_get() -> RedirectResponse: + """GET / 重定向到 Dashboard.""" + return RedirectResponse(url="/dashboard", status_code=307) + def register_status_route(app: Any, router: Any) -> None: """注册状态查询路由.""" diff --git a/tests/e2e/test_e2e_http.py b/tests/e2e/test_e2e_http.py index fe84db5..442577d 100644 --- a/tests/e2e/test_e2e_http.py +++ b/tests/e2e/test_e2e_http.py @@ -188,13 +188,16 @@ async def test_http_health_probe(e2e_client: object) -> None: ) get_resp = await e2e_client.get("/") - assert get_resp.status_code == 200, f"GET / 预期 200,实际 {get_resp.status_code}" + assert get_resp.status_code == 307, f"GET / 预期 307,实际 {get_resp.status_code}" + assert get_resp.headers["location"] == "/dashboard" health_resp = await e2e_client.get("/health") assert health_resp.status_code == 200 assert health_resp.json() == {"status": "ok"} - print("\n[E2E] HTTP health probe 成功: HEAD /=200, GET /=200, /health=ok") + print( + "\n[E2E] HTTP health probe 成功: HEAD /=200, GET /=307→/dashboard, /health=ok" + ) @pytest.mark.e2e diff --git a/tests/test_app_routes.py b/tests/test_app_routes.py index 4c460e3..39aabe6 100644 --- a/tests/test_app_routes.py +++ b/tests/test_app_routes.py @@ -35,11 +35,12 @@ def test_head_root_returns_200(): assert resp.status_code == 200 -def test_get_root_returns_200(): - """GET / 返回 200.""" +def test_get_root_redirects_to_dashboard(): + """GET / 重定向到 /dashboard.""" with _make_app() as client: - resp = client.get("/") - assert resp.status_code == 200 + resp = client.get("/", follow_redirects=False) + assert resp.status_code == 307 + assert resp.headers["location"] == "/dashboard" # ── count_tokens 透传 ──────────────────────────────────────── From 7c631e1f9d4337a5d4e9d6e6ad7783c5ced9411c Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Sat, 30 May 2026 22:10:32 +0800 Subject: [PATCH 08/42] =?UTF-8?q?feat(pricing):=20=E6=96=B0=E5=A2=9E=20cla?= =?UTF-8?q?ude-opus-4-8=20=E6=A8=A1=E5=9E=8B=E5=AE=9A=E4=BB=B7=E9=85=8D?= =?UTF-8?q?=E7=BD=AE;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 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 --- src/coding/proxy/config/config.default.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/coding/proxy/config/config.default.yaml b/src/coding/proxy/config/config.default.yaml index d945125..23b9dc0 100644 --- a/src/coding/proxy/config/config.default.yaml +++ b/src/coding/proxy/config/config.default.yaml @@ -339,6 +339,12 @@ model_mapping: # 未配置定价的模型在统计中 Cost 列显示 "-" pricing: # ── Anthropic Claude ── + - vendor: anthropic + model: claude-opus-4-8 + input_cost_per_mtok: $5.0 + output_cost_per_mtok: $25.0 + cache_write_cost_per_mtok: $6.25 + cache_read_cost_per_mtok: $0.50 - vendor: anthropic model: claude-opus-4-7 input_cost_per_mtok: $5.0 From bb9712e70d87392a618edaa45066dc63d2e200a9 Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Sat, 30 May 2026 22:36:12 +0800 Subject: [PATCH 09/42] =?UTF-8?q?build(version):=20=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E5=8F=B7=E5=8D=87=E7=BA=A7=E8=87=B3=200.5.1a3;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 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 --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f795c11..19a477c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "coding-proxy" -version = "0.5.1a2" +version = "0.5.1a3" 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/uv.lock b/uv.lock index cad3e19..645a353 100644 --- a/uv.lock +++ b/uv.lock @@ -74,7 +74,7 @@ wheels = [ [[package]] name = "coding-proxy" -version = "0.5.1a2" +version = "0.5.1a3" source = { editable = "." } dependencies = [ { name = "aiosqlite" }, From c1be4ed40844be5748bc67cd5ceb1af153cbcda5 Mon Sep 17 00:00:00 2001 From: Aurelius Huang Date: Sun, 31 May 2026 10:05:04 +0800 Subject: [PATCH 10/42] =?UTF-8?q?fix(session-title):=20=E5=89=A5=E7=A6=BB?= =?UTF-8?q?=20=20=E6=A0=87=E7=AD=BE=E5=89=8D=E7=BC=80=E5=B9=B6?= =?UTF-8?q?=E6=94=BE=E5=A4=A7=E6=A0=87=E9=A2=98=E5=AD=98=E5=82=A8=E9=95=BF?= =?UTF-8?q?=E5=BA=A6=E8=87=B3=20600=20=E5=AD=97=E7=AC=A6;=20(#257)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端: _NOISE_TAG_PATTERN 新增 session 标签, 避免标题以 ... 开头; - 后端: _SESSION_TITLE_MAX_LEN 从 30 放大至 600 (20x), 提升标题信息量; - 前端: 展开详情行中 Title 独立为 .detail-title-row, 独占全宽行显示; - 前端: 新增 -webkit-line-clamp: 3 多行截断样式, 超出部分以 ... 省略; - 测试: 补充 标签剥离用例, 修复截断长度测试适配新上限; 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) --- src/coding/proxy/routing/executor.py | 4 ++-- src/coding/proxy/server/dashboard.py | 19 ++++++++++++++++++- tests/test_router_executor.py | 11 ++++++++++- 3 files changed, 30 insertions(+), 4 deletions(-) diff --git a/src/coding/proxy/routing/executor.py b/src/coding/proxy/routing/executor.py index df63e74..328f10e 100644 --- a/src/coding/proxy/routing/executor.py +++ b/src/coding/proxy/routing/executor.py @@ -54,7 +54,7 @@ logger = logging.getLogger(__name__) -_SESSION_TITLE_MAX_LEN = 30 +_SESSION_TITLE_MAX_LEN = 600 # Claude Code 注入的"噪声"标签 — 系统级上下文,不应进入 Session 标题。 # 这些标签由 CC harness 在首个 user 消息 content 中拼接,高度同质, @@ -63,7 +63,7 @@ r"<(?Psystem-reminder|user-preferences|" r"local-command-stdout|local-command-stderr|" r"bash-input|bash-stdout|bash-stderr|" - r"ide_selection|stdin|system_instruction)\b[^>]*>" + r"ide_selection|stdin|system_instruction|session)\b[^>]*>" r".*?", flags=re.DOTALL | re.IGNORECASE, ) diff --git a/src/coding/proxy/server/dashboard.py b/src/coding/proxy/server/dashboard.py index 4c96ef0..cda7991 100644 --- a/src/coding/proxy/server/dashboard.py +++ b/src/coding/proxy/server/dashboard.py @@ -444,6 +444,21 @@ def _build_favicon() -> bytes: .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-title-row { + padding-bottom: 10px; margin-bottom: 10px; + border-bottom: 1px solid var(--border); + } + .detail-title-row .detail-value { + white-space: normal; + word-break: break-word; + overflow-wrap: break-word; + line-height: 1.5; + max-height: 4.5em; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + } .detail-identity-row { display: flex; gap: 16px; padding-bottom: 10px; margin-bottom: 10px; @@ -1903,9 +1918,11 @@ def _build_favicon() -> bytes: '' + formatCategories(s.client_categories) + '' + '' + '
' + + '
' + + '
Title
' + (sessionTitle ? escapeHtml(sessionTitle) : '–') + '
' + + '
' + '
' + '
Session ID
' + escapeHtml(parsed.session_id || s.session_key) + '
' + - '
Title
' + (sessionTitle ? escapeHtml(sessionTitle) : '–') + '
' + '
Device
' + (parsed.device_id ? escapeHtml(parsed.device_id) : '–') + '
' + '
Account
' + (parsed.account_uuid ? escapeHtml(parsed.account_uuid) : '–') + '
' + '
' + diff --git a/tests/test_router_executor.py b/tests/test_router_executor.py index 070ca95..e10bac6 100644 --- a/tests/test_router_executor.py +++ b/tests/test_router_executor.py @@ -2249,6 +2249,15 @@ def test_strips_local_command_output(self): raw = "build ok构建后的下一步问题" assert _sanitize_user_text(raw) == "构建后的下一步问题" + def test_strips_session_tag(self): + """```` 标签应被完整剥离,不残留在标题中.""" + raw = "session metadata用户真实输入文本" + assert _sanitize_user_text(raw) == "用户真实输入文本" + + def test_strips_session_tag_multiline(self): + raw = "\nline1\nline2\n真实标题" + assert _sanitize_user_text(raw) == "真实标题" + class TestExtractSessionTitle: """``_extract_session_title`` — 端到端从 CanonicalRequest 抽取标题.""" @@ -2258,7 +2267,7 @@ def _build_request(messages: list[dict]): return build_canonical_request({"model": "test", "messages": messages}, {}) def test_truncates_to_max_len(self): - long_text = "用户输入文本" * 20 + long_text = "用户输入文本" * 200 req = self._build_request([{"role": "user", "content": long_text}]) title = _extract_session_title(req) assert len(title) == _SESSION_TITLE_MAX_LEN From 01e8d2a92248ae87e0b554b582e34c07b22a2307 Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Sun, 31 May 2026 12:41:43 +0800 Subject: [PATCH 11/42] =?UTF-8?q?build(version):=20=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E5=8F=B7=E5=8D=87=E7=BA=A7=E8=87=B3=200.5.1a4;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 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 --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 19a477c..24d91c5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "coding-proxy" -version = "0.5.1a3" +version = "0.5.1a4" 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/uv.lock b/uv.lock index 645a353..1d3470e 100644 --- a/uv.lock +++ b/uv.lock @@ -74,7 +74,7 @@ wheels = [ [[package]] name = "coding-proxy" -version = "0.5.1a3" +version = "0.5.1a4" source = { editable = "." } dependencies = [ { name = "aiosqlite" }, From 344728d96703b6cedf93ffab8bfd9c2f809cbb7e Mon Sep 17 00:00:00 2001 From: Aurelius Huang Date: Wed, 3 Jun 2026 22:28:23 +0800 Subject: [PATCH 12/42] =?UTF-8?q?feat(session-title):=20=E5=A4=9A=E5=B1=82?= =?UTF-8?q?=E7=BA=A7=E5=9B=9E=E9=80=80=E6=A0=87=E9=A2=98=E6=8F=90=E5=8F=96?= =?UTF-8?q?=E4=B8=8E=E5=BB=B6=E8=BF=9F=E8=A1=A5=E5=86=99=EF=BC=8C=E6=B6=88?= =?UTF-8?q?=E9=99=A4=20Dashboard=20=E7=A9=BA=E6=A0=87=E9=A2=98;=20(#258)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 重构 _extract_session_title 为 4 级逐级回退策略: Level 1: user TEXT 噪声剥离(原有逻辑不变) Level 2: TOOL_RESULT 文本截取 → '[Tool output] ' Level 3: IMAGE 计数 → '[1 Image]' / '[N Images]' Level 4: 元数据兜底 → '[Tool call] Bash, Read' / '[Session] model' - 噪声标签白名单新增 artifactMetadata、thinking - 新增 update_empty_session_title 延迟补写机制, 非首次请求也可为空标题 session 回填 - 补充 30 个测试用例覆盖全部回退级别与新噪声标签 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) --- src/coding/proxy/logging/db.py | 14 + src/coding/proxy/routing/executor.py | 92 ++++++- src/coding/proxy/routing/usage_recorder.py | 5 + tests/test_router_executor.py | 292 ++++++++++++++++++++- 4 files changed, 391 insertions(+), 12 deletions(-) diff --git a/src/coding/proxy/logging/db.py b/src/coding/proxy/logging/db.py index 8470966..79cd9e2 100644 --- a/src/coding/proxy/logging/db.py +++ b/src/coding/proxy/logging/db.py @@ -335,6 +335,20 @@ async def set_session_title(self, session_key: str, title: str) -> None: ) await self._db.commit() + async def update_empty_session_title(self, session_key: str, title: str) -> None: + """为标题为空的 session 补写标题(幂等,仅覆盖空标题行). + + 使用 ``AND title = ''`` 条件确保不覆盖已有标题, + 即使行已存在但标题为空也会被更新。 + """ + if not self._db or not title or not session_key: + return + await self._db.execute( + "UPDATE session_meta SET title = ? WHERE session_key = ? AND title = ''", + (title, session_key), + ) + 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: diff --git a/src/coding/proxy/routing/executor.py b/src/coding/proxy/routing/executor.py index 328f10e..f900a2d 100644 --- a/src/coding/proxy/routing/executor.py +++ b/src/coding/proxy/routing/executor.py @@ -50,11 +50,13 @@ CompatibilityStatus, build_canonical_request, ) -from ..model.compat import CanonicalRequest +from ..model.compat import CanonicalMessagePart, CanonicalRequest logger = logging.getLogger(__name__) _SESSION_TITLE_MAX_LEN = 600 +# 回退标题截取长度 — 工具结果等非用户直接输入的摘要上限。 +_FALLBACK_TITLE_MAX_LEN = 80 # Claude Code 注入的"噪声"标签 — 系统级上下文,不应进入 Session 标题。 # 这些标签由 CC harness 在首个 user 消息 content 中拼接,高度同质, @@ -63,7 +65,8 @@ r"<(?Psystem-reminder|user-preferences|" r"local-command-stdout|local-command-stderr|" r"bash-input|bash-stdout|bash-stderr|" - r"ide_selection|stdin|system_instruction|session)\b[^>]*>" + r"ide_selection|stdin|system_instruction|session|" + r"artifactMetadata|thinking)\b[^>]*>" r".*?", flags=re.DOTALL | re.IGNORECASE, ) @@ -109,13 +112,19 @@ def _sanitize_user_text(raw: str) -> str: return re.sub(r"\s+", " ", cleaned).strip() -def _extract_session_title(request: CanonicalRequest) -> str: - """从规范化请求中提取首个用户消息文本作为 session 标题。 +# ── Session 标题提取: 多层级回退策略 ────────────────────────────── +# +# Level 1: user TEXT → 噪声剥离 → 首条非空文本 (原有逻辑) +# Level 2: user TOOL_RESULT → text 截取 → "[Tool output] " +# Level 3: user IMAGE → 计数 → "[1 Image]" / "[N Images]" +# Level 4: 请求元数据 → tool_names / model → "[Tool call] Bash, Read" +# / "[Session] claude-opus-4-8" +# ───────────────────────────────────────────────────────────────── - 跳过 Claude Code 注入的系统级 XML 块(system-reminder、user-preferences 等), - 确保标题反映用户真实输入而非高同质化的系统模板。 - """ - for part in request.messages: + +def _extract_title_from_user_text(messages: list[CanonicalMessagePart]) -> str: + """Level 1: 从 user TEXT 部分提取经噪声剥离后的首条非空文本.""" + for part in messages: if part.role != "user" or part.type != CanonicalPartType.TEXT: continue cleaned = _sanitize_user_text(part.text) @@ -124,6 +133,59 @@ def _extract_session_title(request: CanonicalRequest) -> str: return "" +def _extract_title_from_tool_results(messages: list[CanonicalMessagePart]) -> str: + """Level 2: 从 user TOOL_RESULT 部分截取文本摘要.""" + for part in messages: + if part.role != "user" or part.type != CanonicalPartType.TOOL_RESULT: + continue + if not part.text: + continue + cleaned = _sanitize_user_text(part.text) + if cleaned: + snippet = cleaned[:_FALLBACK_TITLE_MAX_LEN] + return f"[Tool output] {snippet}" + return "" + + +def _extract_title_from_images(messages: list[CanonicalMessagePart]) -> str: + """Level 3: 统计 user IMAGE 部分数量,生成图片描述标题.""" + count = sum( + 1 for p in messages if p.role == "user" and p.type == CanonicalPartType.IMAGE + ) + if count == 0: + return "" + return f"[{count} Image{'s' if count > 1 else ''}]" + + +def _extract_title_from_metadata(request: CanonicalRequest) -> str: + """Level 4: 从请求元数据 (tool_names / model) 合成兜底标题.""" + if request.tool_names: + names = ", ".join(request.tool_names[:3]) + return f"[Tool call] {names}" + if request.model: + return f"[Session] {request.model}" + return "" + + +def _extract_session_title(request: CanonicalRequest) -> str: + """从规范化请求中提取 session 标题 — 多层级回退策略。 + + 依次尝试: user TEXT 噪声剥离 → TOOL_RESULT 摘要 → IMAGE 计数 → 元数据兜底。 + 任意级别命中即返回,确保 Dashboard 尽可能展示有辨识度的标题。 + """ + messages = request.messages + for extractor in ( + _extract_title_from_user_text, + _extract_title_from_tool_results, + _extract_title_from_images, + ): + title = extractor(messages) + if title: + return title[:_SESSION_TITLE_MAX_LEN] + # Level 4 依赖 request 元数据,签名不同 + return _extract_title_from_metadata(request)[:_SESSION_TITLE_MAX_LEN] + + def _build_semantic_rejection_diagnostic(body: dict[str, Any]) -> str: """构建语义拒绝的请求体诊断上下文. @@ -663,6 +725,13 @@ async def execute_stream( await self._recorder.set_session_title( canonical_request.session_key, title ) + else: + # 延迟标题补写: 若 session 尚无标题,尝试从当前请求中提取并回写。 + title = _extract_session_title(canonical_request) + if title: + await self._recorder.update_empty_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 @@ -842,6 +911,13 @@ async def execute_message( await self._recorder.set_session_title( canonical_request.session_key, title ) + else: + # 延迟标题补写: 若 session 尚无标题,尝试从当前请求中提取并回写。 + title = _extract_session_title(canonical_request) + if title: + await self._recorder.update_empty_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 diff --git a/src/coding/proxy/routing/usage_recorder.py b/src/coding/proxy/routing/usage_recorder.py index 8887c09..e83a8fe 100644 --- a/src/coding/proxy/routing/usage_recorder.py +++ b/src/coding/proxy/routing/usage_recorder.py @@ -33,6 +33,11 @@ async def set_session_title(self, session_key: str, title: str) -> None: if self._token_logger: await self._token_logger.set_session_title(session_key, title) + async def update_empty_session_title(self, session_key: str, title: str) -> None: + """为标题为空的 session 补写标题(委托给 TokenLogger).""" + if self._token_logger: + await self._token_logger.update_empty_session_title(session_key, title) + # ── 用量信息构建 ────────────────────────────────────── @staticmethod diff --git a/tests/test_router_executor.py b/tests/test_router_executor.py index e10bac6..2c0d093 100644 --- a/tests/test_router_executor.py +++ b/tests/test_router_executor.py @@ -20,10 +20,15 @@ build_canonical_request, ) from coding.proxy.routing.executor import ( + _FALLBACK_TITLE_MAX_LEN, _SESSION_TITLE_MAX_LEN, _VENDOR_PROTOCOL_LABEL_MAP, _build_semantic_rejection_diagnostic, _extract_session_title, + _extract_title_from_images, + _extract_title_from_metadata, + _extract_title_from_tool_results, + _extract_title_from_user_text, _has_tool_results, _is_likely_request_format_error, _log_vendor_response_error, @@ -2258,6 +2263,20 @@ def test_strips_session_tag_multiline(self): raw = "\nline1\nline2\n真实标题" assert _sanitize_user_text(raw) == "真实标题" + def test_strips_artifact_metadata_tag(self): + """```` 标签应被完整剥离.""" + raw = "artifact context用户文本" + assert _sanitize_user_text(raw) == "用户文本" + + def test_strips_thinking_tag(self): + """```` 标签应被完整剥离.""" + raw = "内部推理过程用户实际提问" + assert _sanitize_user_text(raw) == "用户实际提问" + + def test_strips_thinking_tag_multiline(self): + raw = "\nline1\nline2\n清理后文本" + assert _sanitize_user_text(raw) == "清理后文本" + class TestExtractSessionTitle: """``_extract_session_title`` — 端到端从 CanonicalRequest 抽取标题.""" @@ -2304,14 +2323,16 @@ def test_extracts_slash_command(self): req = self._build_request([{"role": "user", "content": raw}]) assert _extract_session_title(req) == "/commit feat: 新增标题清洗" - def test_returns_empty_when_only_noise(self): + def test_returns_metadata_fallback_when_only_noise(self): + """纯噪声文本回退到 Level 4 元数据兜底(使用 model 名称).""" raw = "纯噪声" req = self._build_request([{"role": "user", "content": raw}]) - assert _extract_session_title(req) == "" + assert _extract_session_title(req) == "[Session] test" - def test_returns_empty_for_no_user_messages(self): + def test_returns_metadata_fallback_for_no_user_messages(self): + """无 user 消息时回退到 Level 4 元数据兜底.""" req = self._build_request([{"role": "assistant", "content": "你好"}]) - assert _extract_session_title(req) == "" + assert _extract_session_title(req) == "[Session] test" def test_skips_noise_only_part_to_find_real_input(self): """首个 user text part 全噪声时,fallback 到下一个非空 user part.""" @@ -2338,3 +2359,266 @@ def test_skips_assistant_role(self): ] req = self._build_request(messages) assert _extract_session_title(req) == "新的用户问题" + + +# ═══════════════════════════════════════════════════════════════════ +# 多层级回退标题提取测试 +# ═══════════════════════════════════════════════════════════════════ + + +class TestExtractTitleFromUserText: + """Level 1 辅助函数 ``_extract_title_from_user_text``.""" + + def test_returns_first_non_empty_user_text(self): + from coding.proxy.model.compat import CanonicalMessagePart, CanonicalPartType + + msgs = [ + CanonicalMessagePart( + type=CanonicalPartType.TEXT, role="user", text="用户输入" + ), + ] + assert _extract_title_from_user_text(msgs) == "用户输入" + + def test_skips_assistant_text(self): + from coding.proxy.model.compat import CanonicalMessagePart, CanonicalPartType + + msgs = [ + CanonicalMessagePart( + type=CanonicalPartType.TEXT, role="assistant", text="助手回复" + ), + CanonicalMessagePart( + type=CanonicalPartType.TEXT, role="user", text="用户问题" + ), + ] + assert _extract_title_from_user_text(msgs) == "用户问题" + + def test_returns_empty_for_noise_only(self): + from coding.proxy.model.compat import CanonicalMessagePart, CanonicalPartType + + msgs = [ + CanonicalMessagePart( + type=CanonicalPartType.TEXT, + role="user", + text="纯噪声", + ), + ] + assert _extract_title_from_user_text(msgs) == "" + + +class TestExtractTitleFromToolResults: + """Level 2 辅助函数 ``_extract_title_from_tool_results``.""" + + def test_extracts_tool_result_text(self): + from coding.proxy.model.compat import CanonicalMessagePart, CanonicalPartType + + msgs = [ + CanonicalMessagePart( + type=CanonicalPartType.TOOL_RESULT, + role="user", + text="file contents here", + ), + ] + title = _extract_title_from_tool_results(msgs) + assert title == "[Tool output] file contents here" + + def test_skips_empty_tool_result(self): + from coding.proxy.model.compat import CanonicalMessagePart, CanonicalPartType + + msgs = [ + CanonicalMessagePart( + type=CanonicalPartType.TOOL_RESULT, role="user", text="" + ), + ] + assert _extract_title_from_tool_results(msgs) == "" + + def test_truncates_long_tool_result(self): + from coding.proxy.model.compat import CanonicalMessagePart, CanonicalPartType + + long_text = "A" * 200 + msgs = [ + CanonicalMessagePart( + type=CanonicalPartType.TOOL_RESULT, role="user", text=long_text + ), + ] + title = _extract_title_from_tool_results(msgs) + assert title.startswith("[Tool output] ") + assert len(title) <= len("[Tool output] ") + _FALLBACK_TITLE_MAX_LEN + + def test_sanitizes_noise_in_tool_result(self): + from coding.proxy.model.compat import CanonicalMessagePart, CanonicalPartType + + msgs = [ + CanonicalMessagePart( + type=CanonicalPartType.TOOL_RESULT, + role="user", + text="noiseclean output", + ), + ] + title = _extract_title_from_tool_results(msgs) + assert title == "[Tool output] clean output" + + def test_returns_empty_when_all_noise(self): + from coding.proxy.model.compat import CanonicalMessagePart, CanonicalPartType + + msgs = [ + CanonicalMessagePart( + type=CanonicalPartType.TOOL_RESULT, + role="user", + text="纯噪声", + ), + ] + assert _extract_title_from_tool_results(msgs) == "" + + +class TestExtractTitleFromImages: + """Level 3 辅助函数 ``_extract_title_from_images``.""" + + def test_single_image(self): + from coding.proxy.model.compat import CanonicalMessagePart, CanonicalPartType + + msgs = [ + CanonicalMessagePart(type=CanonicalPartType.IMAGE, role="user"), + ] + assert _extract_title_from_images(msgs) == "[1 Image]" + + def test_multiple_images(self): + from coding.proxy.model.compat import CanonicalMessagePart, CanonicalPartType + + msgs = [ + CanonicalMessagePart(type=CanonicalPartType.IMAGE, role="user"), + CanonicalMessagePart(type=CanonicalPartType.IMAGE, role="user"), + CanonicalMessagePart(type=CanonicalPartType.IMAGE, role="user"), + ] + assert _extract_title_from_images(msgs) == "[3 Images]" + + def test_no_images(self): + from coding.proxy.model.compat import CanonicalMessagePart, CanonicalPartType + + msgs = [ + CanonicalMessagePart(type=CanonicalPartType.TEXT, role="user", text="文本"), + ] + assert _extract_title_from_images(msgs) == "" + + def test_skips_assistant_images(self): + from coding.proxy.model.compat import CanonicalMessagePart, CanonicalPartType + + msgs = [ + CanonicalMessagePart(type=CanonicalPartType.IMAGE, role="assistant"), + ] + assert _extract_title_from_images(msgs) == "" + + +class TestExtractTitleFromMetadata: + """Level 4 辅助函数 ``_extract_title_from_metadata``.""" + + @staticmethod + def _build_request_with_meta(tool_names: list[str] | None = None, model: str = ""): + body: dict = {"model": model, "messages": []} + if tool_names: + body["tools"] = [{"name": n} for n in tool_names] + return build_canonical_request(body, {}) + + def test_uses_tool_names(self): + req = self._build_request_with_meta( + tool_names=["Bash", "Read", "Edit"], model="claude-opus-4-8" + ) + assert _extract_title_from_metadata(req) == "[Tool call] Bash, Read, Edit" + + def test_limits_to_three_tool_names(self): + req = self._build_request_with_meta( + tool_names=["Bash", "Read", "Edit", "Write", "Grep"], model="test" + ) + assert _extract_title_from_metadata(req) == "[Tool call] Bash, Read, Edit" + + def test_uses_model_when_no_tools(self): + req = self._build_request_with_meta(tool_names=[], model="claude-sonnet-4-6") + assert _extract_title_from_metadata(req) == "[Session] claude-sonnet-4-6" + + def test_returns_empty_when_nothing(self): + req = self._build_request_with_meta(tool_names=[], model="") + assert _extract_title_from_metadata(req) == "" + + +class TestExtractSessionTitleFallback: + """``_extract_session_title`` 多层级回退集成测试.""" + + @staticmethod + def _build_request(messages: list[dict], **extra): + body: dict = {"model": "test-model", "messages": messages, **extra} + return build_canonical_request(body, {}) + + def test_level1_takes_priority(self): + """Level 1 命中时不回退到 Level 2.""" + messages = [ + { + "role": "user", + "content": [ + {"type": "text", "text": "用户真实问题"}, + { + "type": "tool_result", + "tool_use_id": "tu_1", + "content": "工具输出", + }, + ], + } + ] + req = self._build_request(messages) + assert _extract_session_title(req) == "用户真实问题" + + def test_level2_when_no_text(self): + """无 user TEXT 时,回退到 Level 2 TOOL_RESULT.""" + messages = [ + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "tu_1", + "content": [{"type": "text", "text": "文件内容摘要"}], + }, + ], + } + ] + req = self._build_request(messages) + assert _extract_session_title(req) == "[Tool output] 文件内容摘要" + + def test_level3_when_only_images(self): + """无 TEXT 和 TOOL_RESULT 时,回退到 Level 3 IMAGE.""" + messages = [ + { + "role": "user", + "content": [ + { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/png", + "data": "abc", + }, + }, + ], + } + ] + req = self._build_request(messages) + assert _extract_session_title(req) == "[1 Image]" + + def test_level4_uses_tool_names(self): + """所有消息级别均无内容时,回退到 Level 4 元数据.""" + req = self._build_request([], tools=[{"name": "Bash"}, {"name": "Read"}]) + assert _extract_session_title(req) == "[Tool call] Bash, Read" + + def test_level4_uses_model_name(self): + """无 tools 时,Level 4 使用 model 名称.""" + req = self._build_request([]) + assert _extract_session_title(req) == "[Session] test-model" + + def test_fallback_cascade_full(self): + """Level 1 全噪声 → Level 2 全噪声 → Level 3 无图 → Level 4 模型名.""" + messages = [ + { + "role": "user", + "content": "纯噪声", + }, + ] + req = self._build_request(messages) + assert _extract_session_title(req) == "[Session] test-model" From 08d821fee4784a98633c081f874034653150f0a6 Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Wed, 3 Jun 2026 22:37:36 +0800 Subject: [PATCH 13/42] =?UTF-8?q?build(version):=20=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E5=8F=B7=E5=8D=87=E7=BA=A7=E8=87=B3=200.5.1a5;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 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 --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 24d91c5..3cbe740 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "coding-proxy" -version = "0.5.1a4" +version = "0.5.1a5" 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/uv.lock b/uv.lock index 1d3470e..44ea70d 100644 --- a/uv.lock +++ b/uv.lock @@ -74,7 +74,7 @@ wheels = [ [[package]] name = "coding-proxy" -version = "0.5.1a4" +version = "0.5.1a5" source = { editable = "." } dependencies = [ { name = "aiosqlite" }, From 63a47df11151247f34eb5b8b1ea6dbadff0b92df Mon Sep 17 00:00:00 2001 From: Aurelius Huang Date: Thu, 4 Jun 2026 11:42:32 +0800 Subject: [PATCH 14/42] =?UTF-8?q?fix(session-title):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=20=20=E6=A0=87=E7=AD=BE=E5=8C=85=E8=A3=B9=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E6=96=87=E6=9C=AC=E5=AF=BC=E8=87=B4=E6=A0=87=E9=A2=98?= =?UTF-8?q?=E5=85=A8=E9=83=A8=E5=9B=9E=E9=80=80=E5=88=B0=E5=85=83=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E7=9A=84=E9=97=AE=E9=A2=98;=20(#259)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 当用户实际输入被 标签完整包裹时,首轮噪声剥离会将 整个 块连同用户文本一并删除,导致 Level 1 始终返回空。 新增二次回退机制:若首轮结果为空,仅去除 外壳标签, 保留内部文本后重新剥离噪声,确保用户文本可见。 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) --- src/coding/proxy/routing/executor.py | 31 +++++++++++++++++++++---- tests/test_router_executor.py | 34 ++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 4 deletions(-) diff --git a/src/coding/proxy/routing/executor.py b/src/coding/proxy/routing/executor.py index f900a2d..20d9c51 100644 --- a/src/coding/proxy/routing/executor.py +++ b/src/coding/proxy/routing/executor.py @@ -71,6 +71,11 @@ flags=re.DOTALL | re.IGNORECASE, ) +# 标签需要特殊处理:当用户文本在 标签内部时, +# 完整块剥离会连同用户文本一起删除。此模式仅去除外壳标签(保留内容), +# 用于首轮完整剥离结果为空时的二次回退提取。 +_SESSION_TAG_WRAPPER = re.compile(r"]*>", flags=re.IGNORECASE) + # Slash command 子标签:用于识别 /commit、/review 等命令式调用, # 合成"命令 + 参数"式标题。 _CMD_NAME_PATTERN = re.compile(r"(.*?)", flags=re.DOTALL) @@ -80,6 +85,9 @@ r".*?", flags=re.DOTALL ) +# 空白折叠 +_WHITESPACE_PATTERN = re.compile(r"\s+") + def _sanitize_user_text(raw: str) -> str: """剔除 Claude Code 注入的系统级 XML 块,还原真实用户输入。 @@ -88,8 +96,10 @@ def _sanitize_user_text(raw: str) -> str: 1. Slash command 优先识别 — 若检测到 ,合成"命令 + 参数" 式标题(因为残留文本通常为空,直接取标签内容更有意义)。 2. 通用噪声剥离 — 移除已知白名单内的 system-reminder 等标签。 - 3. 残留 command-* 包裹清除 — 兜底去除 command-message 等次要标签。 - 4. 前后空白归一化 — 折叠连续空白为单空格,便于 30 字截断。 + 3. 二次回退 — 若首轮剥离后为空,说明用户文本可能在 + 标签内部;此时仅去除外壳标签,保留内部文本再做噪声剥离。 + 4. 残留 command-* 包裹清除 — 兜底去除 command-message 等次要标签。 + 5. 前后空白归一化 — 折叠连续空白为单空格。 """ if not raw: return "" @@ -107,9 +117,22 @@ def _sanitize_user_text(raw: str) -> str: # 阶段二: 通用噪声剥离 cleaned = _NOISE_TAG_PATTERN.sub("", raw) cleaned = _CMD_WRAPPER_PATTERN.sub("", cleaned) + cleaned = _WHITESPACE_PATTERN.sub(" ", cleaned).strip() + if cleaned: + return cleaned + + # 阶段三: 二次回退 + # 当首轮全部剥离为空时,用户文本很可能被 标签完整包裹。 + # 此时不去除 块,而是仅剥掉外壳标签,保留内部文本后重新剥离。 + if " 标签包裹用户文本的二次回退 ── + + def test_session_tag_wrapping_user_text(self): + """当 标签包裹用户文本时,二次回退应提取内部文本. + + 注: session 元数据可能残留在标题前部,但用户文本现在可见, + 远优于完全回退到 '[Session] model_name'. + """ + raw = "session metadata\n用户真实提问内容" + result = _sanitize_user_text(raw) + assert "用户真实提问内容" in result + + def test_session_tag_wrapping_with_inner_noise(self): + """ 内部混合噪声标签时,二次回退应正确剥离噪声.""" + raw = ( + "session_key: abc\n" + "噪声内容" + "用户真实输入" + "" + ) + result = _sanitize_user_text(raw) + assert "用户真实输入" in result + assert "噪声内容" not in result + + def test_session_tag_prefix_still_works(self): + """用户文本在 标签之后(原有行为)仍正确.""" + raw = "metadata用户文本在外部" + assert _sanitize_user_text(raw) == "用户文本在外部" + + def test_all_noise_inside_session_tag(self): + """ 内部全是噪声时,二次回退仍返回空.""" + raw = "纯噪声" + assert _sanitize_user_text(raw) == "" + class TestExtractSessionTitle: """``_extract_session_title`` — 端到端从 CanonicalRequest 抽取标题.""" From a778271559f79dda06b3bd38bf4d08bfcfa7ad26 Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Thu, 4 Jun 2026 12:25:18 +0800 Subject: [PATCH 15/42] =?UTF-8?q?build(version):=20=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E5=8F=B7=E5=8D=87=E7=BA=A7=E8=87=B3=200.5.1a6;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 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 --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 3cbe740..90ffc66 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "coding-proxy" -version = "0.5.1a5" +version = "0.5.1a6" 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/uv.lock b/uv.lock index 44ea70d..90b7a7f 100644 --- a/uv.lock +++ b/uv.lock @@ -74,7 +74,7 @@ wheels = [ [[package]] name = "coding-proxy" -version = "0.5.1a5" +version = "0.5.1a6" source = { editable = "." } dependencies = [ { name = "aiosqlite" }, From 728d86adbac105de826ca9db944efcdac2060177 Mon Sep 17 00:00:00 2001 From: Aurelius Huang Date: Sun, 7 Jun 2026 09:55:56 +0800 Subject: [PATCH 16/42] =?UTF-8?q?feat(session-routing):=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E5=9F=BA=E4=BA=8E=20Session=20=E6=A0=87=E9=A2=98?= =?UTF-8?q?=E5=89=8D=E7=BC=80=E7=9A=84=E5=8F=AF=E9=85=8D=E7=BD=AE=20Vendor?= =?UTF-8?q?=20=E8=87=AA=E5=8A=A8=E7=BB=91=E5=AE=9A;=20(#260)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 当 Session 标题以配置前缀(默认「# 目标」)开头时,自动将该 Session 绑定到指定 vendor(默认 zhipu),不走默认 vendor 路由。前缀与目标 vendor 均通过 YAML 配置(session_policies.title_vendor_bindings), 支持多条规则、按顺序匹配首次命中生效、置空列表即禁用。 实现复用现有 SessionPolicyResolver.upsert() 运行时绑定机制,在标题 提取后、tier 解析前注入策略,仅对新 Session 首请求生效以避免覆盖手动 绑定。新增启动期 vendor 存在性校验(未知 vendor 告警而非硬失败)与 prefix/vendor 非空约束(杜绝空前缀全量误绑定)。 - config/session_policy.py: 新增 TitleVendorBinding 模型 + 配置字段 - config/config.default.yaml: 默认「# 目标」→ zhipu 绑定 - routing/executor.py: _apply_title_based_policy 方法、启动校验、双路径注入 - routing/router.py + server/app.py: 配置透传 - tests: 新增 17 个用例(前缀匹配/边缘/校验/流式与非流式端到端) 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) --- src/coding/proxy/config/config.default.yaml | 9 +- src/coding/proxy/config/schema.py | 3 +- src/coding/proxy/config/session_policy.py | 24 ++ src/coding/proxy/routing/executor.py | 51 +++- src/coding/proxy/routing/router.py | 3 + src/coding/proxy/server/app.py | 1 + tests/test_router_executor.py | 290 ++++++++++++++++++++ 7 files changed, 378 insertions(+), 3 deletions(-) diff --git a/src/coding/proxy/config/config.default.yaml b/src/coding/proxy/config/config.default.yaml index 23b9dc0..f91b73e 100644 --- a/src/coding/proxy/config/config.default.yaml +++ b/src/coding/proxy/config/config.default.yaml @@ -685,4 +685,11 @@ native_api: # tiers: ["copilot", "anthropic", "zhipu"] # # 未配置时(默认),所有 Session 使用全局 tiers 顺序。 -session_policies: [] +session_policies: + policies: [] + # 标题前缀 → 供应商自动绑定。 + # 当 Session 标题以指定前缀开头时,自动将该 Session 绑定到对应供应商。 + # 匹配规则按列表顺序求值,首次匹配生效。 + title_vendor_bindings: + - prefix: "# 目标" + vendor: "zhipu" diff --git a/src/coding/proxy/config/schema.py b/src/coding/proxy/config/schema.py index 40e5428..ec8ac5f 100644 --- a/src/coding/proxy/config/schema.py +++ b/src/coding/proxy/config/schema.py @@ -44,7 +44,7 @@ # ── 子模块 re-export ──────────────────────────────────────────── from .server import DatabaseConfig, LoggingConfig, ServerConfig # noqa: F401 -from .session_policy import SessionPoliciesConfig # noqa: F401 +from .session_policy import SessionPoliciesConfig, TitleVendorBinding # noqa: F401 from .vendors import ( # noqa: F401 AlibabaConfig, AnthropicConfig, @@ -350,4 +350,5 @@ def compat_state_path(self) -> Path: "NativeApiConfig", # session policy "SessionPoliciesConfig", + "TitleVendorBinding", ] diff --git a/src/coding/proxy/config/session_policy.py b/src/coding/proxy/config/session_policy.py index cb2c512..c25da7a 100644 --- a/src/coding/proxy/config/session_policy.py +++ b/src/coding/proxy/config/session_policy.py @@ -50,6 +50,22 @@ class SessionPolicy(BaseModel): ) +class TitleVendorBinding(BaseModel): + """标题前缀 → 供应商自动绑定规则.""" + + prefix: str = Field( + min_length=1, + description=( + "标题前缀匹配模式(大小写敏感的 startswith 匹配)。" + "禁止空字符串——空前缀会匹配所有标题,导致全量误绑定。" + ), + ) + vendor: str = Field( + min_length=1, + description="匹配后绑定的目标供应商名称", + ) + + class SessionPoliciesConfig(BaseModel): """顶层 Session 策略配置容器.""" @@ -57,3 +73,11 @@ class SessionPoliciesConfig(BaseModel): default_factory=list, description="Session 路由策略列表,按定义顺序求值,首次匹配生效", ) + title_vendor_bindings: list[TitleVendorBinding] = Field( + default_factory=list, + description=( + "标题前缀 → 供应商自动绑定规则。" + "当 Session 标题以指定前缀开头时,自动绑定到对应供应商。" + "匹配规则按列表顺序求值,首次匹配生效。" + ), + ) diff --git a/src/coding/proxy/routing/executor.py b/src/coding/proxy/routing/executor.py index 20d9c51..9ade9dd 100644 --- a/src/coding/proxy/routing/executor.py +++ b/src/coding/proxy/routing/executor.py @@ -11,10 +11,13 @@ import re import time from collections.abc import AsyncIterator -from typing import Any +from typing import TYPE_CHECKING, Any import httpx +if TYPE_CHECKING: + from ..config.session_policy import TitleVendorBinding + from ..vendors.base import ( NoCompatibleVendorError, RequestCapabilities, @@ -610,6 +613,7 @@ def __init__( session_manager: RouteSessionManager, reauth_coordinator: Any | None = None, session_policy_resolver: SessionPolicyResolver | None = None, + title_vendor_bindings: list[TitleVendorBinding] | None = None, ) -> None: self._router = router self._tiers = tiers @@ -617,6 +621,8 @@ def __init__( self._session_mgr = session_manager self._reauth_coordinator = reauth_coordinator self._policy_resolver = session_policy_resolver or SessionPolicyResolver() + self._title_vendor_bindings = title_vendor_bindings or [] + self._validate_title_vendor_bindings() # Tier 名称 → OAuth provider 名称的映射 self._tier_provider_map: dict[str, str] = { @@ -624,6 +630,26 @@ def __init__( "antigravity": "google", } + def _validate_title_vendor_bindings(self) -> None: + """启动期校验标题绑定引用的 vendor 均存在,缺失则告警. + + 与手动绑定 API(拒绝未知 vendor)的语义对齐:此处不硬失败, + 仅记录警告——避免单条误配置阻断整个代理启动;运行时 + `_resolve_effective_tiers` 会静默跳过未知 vendor 回退默认顺序。 + """ + if not self._title_vendor_bindings: + return + valid = {t.name for t in self._tiers} + for binding in self._title_vendor_bindings: + if binding.vendor not in valid: + logger.warning( + "title_vendor_bindings 引用了未知 vendor %r(前缀 %r);" + "可用 vendor: %s。该绑定将在运行时被静默跳过。", + binding.vendor, + binding.prefix, + sorted(valid), + ) + # ── 公开执行入口 ────────────────────────────────────── def _resolve_effective_tiers(self, session_key: str) -> list[VendorTier]: @@ -650,6 +676,27 @@ def _resolve_effective_tiers(self, session_key: str) -> list[VendorTier]: seen.add(tier.name) return ordered + def _apply_title_based_policy(self, session_key: str, title: str) -> None: + """根据 Session 标题前缀自动绑定供应商. + + 当标题以预配置的前缀开头时,通过 SessionPolicyResolver.upsert() + 将该 Session 绑定到指定供应商,后续请求无需再走默认路由。 + + 仅在新 Session 首次提取标题时调用,避免覆盖手动绑定的策略。 + """ + if not title or not self._title_vendor_bindings: + return + for binding in self._title_vendor_bindings: + if title.startswith(binding.prefix): + self._policy_resolver.upsert(session_key, [binding.vendor]) + logger.info( + "Session title prefix %r matched → auto-bind to %s (session=%s)", + binding.prefix, + binding.vendor, + session_key[:12], + ) + return + def _prepare_body_for_tier( self, body: dict[str, Any], @@ -748,6 +795,7 @@ async def execute_stream( await self._recorder.set_session_title( canonical_request.session_key, title ) + self._apply_title_based_policy(canonical_request.session_key, title) else: # 延迟标题补写: 若 session 尚无标题,尝试从当前请求中提取并回写。 title = _extract_session_title(canonical_request) @@ -934,6 +982,7 @@ async def execute_message( await self._recorder.set_session_title( canonical_request.session_key, title ) + self._apply_title_based_policy(canonical_request.session_key, title) else: # 延迟标题补写: 若 session 尚无标题,尝试从当前请求中提取并回写。 title = _extract_session_title(canonical_request) diff --git a/src/coding/proxy/routing/router.py b/src/coding/proxy/routing/router.py index 32757a8..a9b5379 100644 --- a/src/coding/proxy/routing/router.py +++ b/src/coding/proxy/routing/router.py @@ -14,6 +14,7 @@ from typing import TYPE_CHECKING, Any if TYPE_CHECKING: + from ..config.session_policy import TitleVendorBinding from ..pricing import PricingTable from .executor import _RouteExecutor @@ -38,6 +39,7 @@ def __init__( reauth_coordinator: Any | None = None, compat_session_store: CompatSessionStore | None = None, session_policy_resolver: SessionPolicyResolver | None = None, + title_vendor_bindings: list[TitleVendorBinding] | None = None, ) -> None: if not tiers: raise ValueError("至少需要一个供应商层级") @@ -56,6 +58,7 @@ def __init__( session_manager=self._session_mgr, reauth_coordinator=reauth_coordinator, session_policy_resolver=session_policy_resolver, + title_vendor_bindings=title_vendor_bindings, ) def set_pricing_table(self, table: PricingTable) -> None: diff --git a/src/coding/proxy/server/app.py b/src/coding/proxy/server/app.py index 5ce8011..396b89f 100644 --- a/src/coding/proxy/server/app.py +++ b/src/coding/proxy/server/app.py @@ -161,6 +161,7 @@ def create_app(config: ProxyConfig | None = None) -> FastAPI: reauth_coordinator, compat_session_store, session_policy_resolver=SessionPolicyResolver(config.session_policies.policies), + title_vendor_bindings=config.session_policies.title_vendor_bindings, ) app = FastAPI(title="coding-proxy", version=__version__, lifespan=lifespan) diff --git a/tests/test_router_executor.py b/tests/test_router_executor.py index d8455cd..2e5c13e 100644 --- a/tests/test_router_executor.py +++ b/tests/test_router_executor.py @@ -19,6 +19,7 @@ CompatibilityStatus, build_canonical_request, ) +from coding.proxy.config.session_policy import TitleVendorBinding from coding.proxy.routing.executor import ( _FALLBACK_TITLE_MAX_LEN, _SESSION_TITLE_MAX_LEN, @@ -36,6 +37,7 @@ _sanitize_user_text, ) from coding.proxy.routing.session_manager import RouteSessionManager +from coding.proxy.routing.session_policy import SessionPolicyResolver from coding.proxy.routing.tier import VendorTier from coding.proxy.routing.usage_recorder import UsageRecorder from coding.proxy.vendors.base import ( @@ -133,6 +135,19 @@ def _executor(tiers: list[VendorTier] | None = None, **kwargs) -> _RouteExecutor ) +def _stub_session_manager(is_new: bool = True) -> MagicMock: + """构造返回指定 is_new 的 session manager stub. + + 默认 RouteSessionManager(无 store) 的 get_or_create_record 恒返回 + is_new=False;测试新 session 路径需显式 stub 返回 is_new=True。 + """ + mgr = MagicMock(spec=RouteSessionManager) + mgr.get_or_create_record = AsyncMock(return_value=(None, is_new)) + mgr.apply_compat_context = MagicMock() + mgr.persist_session = AsyncMock() + return mgr + + # ── _VENDOR_PROTOCOL_LABEL_MAP ─────────────────────────── @@ -2656,3 +2671,278 @@ def test_fallback_cascade_full(self): ] req = self._build_request(messages) assert _extract_session_title(req) == "[Session] test-model" + + +class TestApplyTitleBasedPolicy: + """``_apply_title_based_policy`` 标题前缀自动绑定测试.""" + + def test_prefix_match_triggers_upsert(self): + """标题以配置前缀开头 → 触发 upsert 绑定到目标 vendor.""" + resolver = SessionPolicyResolver() + executor = _executor( + session_policy_resolver=resolver, + title_vendor_bindings=[TitleVendorBinding(prefix="# 目标", vendor="zhipu")], + ) + executor._apply_title_based_policy("sess-1", "# 目标 (Goal) 实现功能 X") + policy = resolver.resolve("sess-1") + assert policy is not None + assert policy.tiers == ["zhipu"] + assert policy.name == "runtime:sess-1" + + def test_prefix_match_without_parenthesis(self): + """前缀匹配不要求括号后缀,纯 '# 目标' 开头即命中.""" + resolver = SessionPolicyResolver() + executor = _executor( + session_policy_resolver=resolver, + title_vendor_bindings=[TitleVendorBinding(prefix="# 目标", vendor="zhipu")], + ) + executor._apply_title_based_policy("sess-2", "# 目标 详细计划") + policy = resolver.resolve("sess-2") + assert policy is not None + assert policy.tiers == ["zhipu"] + + def test_non_matching_title_no_binding(self): + """非匹配标题 → 不创建绑定.""" + resolver = SessionPolicyResolver() + executor = _executor( + session_policy_resolver=resolver, + title_vendor_bindings=[TitleVendorBinding(prefix="# 目标", vendor="zhipu")], + ) + executor._apply_title_based_policy("sess-3", "普通会话标题") + assert resolver.resolve("sess-3") is None + + def test_empty_title_no_binding(self): + """空标题 → 提前返回,不创建绑定.""" + resolver = SessionPolicyResolver() + executor = _executor( + session_policy_resolver=resolver, + title_vendor_bindings=[TitleVendorBinding(prefix="# 目标", vendor="zhipu")], + ) + executor._apply_title_based_policy("sess-4", "") + assert resolver.resolve("sess-4") is None + + def test_no_bindings_configured_no_binding(self): + """未配置任何绑定规则 → 提前返回,等效禁用.""" + resolver = SessionPolicyResolver() + executor = _executor( + session_policy_resolver=resolver, + title_vendor_bindings=[], + ) + executor._apply_title_based_policy("sess-5", "# 目标 任意标题") + assert resolver.resolve("sess-5") is None + + def test_prefix_in_middle_no_match(self): + """前缀出现在标题中间 → startswith 不匹配,不绑定.""" + resolver = SessionPolicyResolver() + executor = _executor( + session_policy_resolver=resolver, + title_vendor_bindings=[TitleVendorBinding(prefix="# 目标", vendor="zhipu")], + ) + executor._apply_title_based_policy("sess-6", "前缀 # 目标 在中间") + assert resolver.resolve("sess-6") is None + + def test_multiple_bindings_first_match_wins(self): + """多条规则按顺序匹配,首次命中生效.""" + resolver = SessionPolicyResolver() + executor = _executor( + session_policy_resolver=resolver, + title_vendor_bindings=[ + TitleVendorBinding(prefix="# 目标", vendor="zhipu"), + TitleVendorBinding(prefix="# Review", vendor="anthropic"), + ], + ) + executor._apply_title_based_policy("sess-7", "# Review 代码审查") + policy = resolver.resolve("sess-7") + assert policy is not None + assert policy.tiers == ["anthropic"] + + def test_bound_tier_promoted_to_front(self): + """绑定后 _resolve_effective_tiers 将目标 vendor 提升至首位.""" + resolver = SessionPolicyResolver() + tiers = [ + _make_tier(_mock_vendor("anthropic")), + _make_tier(_mock_vendor("zhipu")), + ] + executor = _executor( + tiers=tiers, + session_policy_resolver=resolver, + title_vendor_bindings=[TitleVendorBinding(prefix="# 目标", vendor="zhipu")], + ) + executor._apply_title_based_policy("sess-8", "# 目标 实现 X") + effective = executor._resolve_effective_tiers("sess-8") + assert effective[0].name == "zhipu" + # 未提及的 vendor 仍保留在末尾 + assert {t.name for t in effective} == {"zhipu", "anthropic"} + + def test_non_matching_session_uses_default_order(self): + """非匹配 session 的 tier 顺序保持全局默认.""" + resolver = SessionPolicyResolver() + tiers = [ + _make_tier(_mock_vendor("anthropic")), + _make_tier(_mock_vendor("zhipu")), + ] + executor = _executor( + tiers=tiers, + session_policy_resolver=resolver, + title_vendor_bindings=[TitleVendorBinding(prefix="# 目标", vendor="zhipu")], + ) + executor._apply_title_based_policy("sess-9", "普通标题") + effective = executor._resolve_effective_tiers("sess-9") + assert [t.name for t in effective] == ["anthropic", "zhipu"] + + def test_nonexistent_vendor_skipped_in_resolution(self): + """绑定不存在的 vendor → upsert 成功但 tier 解析跳过该 vendor.""" + resolver = SessionPolicyResolver() + tiers = [ + _make_tier(_mock_vendor("anthropic")), + _make_tier(_mock_vendor("zhipu")), + ] + executor = _executor( + tiers=tiers, + session_policy_resolver=resolver, + title_vendor_bindings=[ + TitleVendorBinding(prefix="# 目标", vendor="nonexistent") + ], + ) + executor._apply_title_based_policy("sess-10", "# 目标 X") + effective = executor._resolve_effective_tiers("sess-10") + # 不存在的 vendor 被跳过,回退到全局默认顺序 + assert [t.name for t in effective] == ["anthropic", "zhipu"] + + @pytest.mark.asyncio + async def test_execute_message_end_to_end_binding(self): + """端到端: 新 session 首请求标题命中前缀 → 创建绑定并路由到 zhipu.""" + resolver = SessionPolicyResolver() + tiers = [ + _make_tier(_mock_vendor("anthropic")), + _make_tier(_mock_vendor("zhipu")), + ] + executor = _executor( + tiers=tiers, + session_mgr=_stub_session_manager(is_new=True), + session_policy_resolver=resolver, + title_vendor_bindings=[TitleVendorBinding(prefix="# 目标", vendor="zhipu")], + ) + body = { + "model": "test", + "metadata": {"user_id": "session-abc"}, + "messages": [ + {"role": "user", "content": [{"type": "text", "text": "# 目标 实现 X"}]} + ], + } + resp = await executor.execute_message(body, {}) + assert resp.status_code == 200 + # 从 body 解析出的 session_key 应已建立运行时绑定 + canonical = build_canonical_request(body, {}) + policy = resolver.resolve(canonical.session_key) + assert policy is not None + assert policy.tiers == ["zhipu"] + + @pytest.mark.asyncio + async def test_execute_message_existing_session_no_binding(self): + """端到端: 已存在 session(is_new=False) 不触发标题绑定.""" + resolver = SessionPolicyResolver() + tiers = [ + _make_tier(_mock_vendor("anthropic")), + _make_tier(_mock_vendor("zhipu")), + ] + executor = _executor( + tiers=tiers, + session_mgr=_stub_session_manager(is_new=False), + session_policy_resolver=resolver, + title_vendor_bindings=[TitleVendorBinding(prefix="# 目标", vendor="zhipu")], + ) + body = { + "model": "test", + "metadata": {"user_id": "session-existing"}, + "messages": [ + {"role": "user", "content": [{"type": "text", "text": "# 目标 实现 X"}]} + ], + } + await executor.execute_message(body, {}) + canonical = build_canonical_request(body, {}) + # is_new=False → 不调用 _apply_title_based_policy,无运行时绑定 + assert resolver.resolve(canonical.session_key) is None + + @pytest.mark.asyncio + async def test_execute_stream_end_to_end_binding(self): + """端到端(流式): 新 session 首请求标题命中前缀 → 创建绑定.""" + resolver = SessionPolicyResolver() + zhipu_vendor = _mock_vendor("zhipu") + zhipu_vendor.send_message_stream = MagicMock( + return_value=_async_chunks([b'{"type":"message_stop"}']) + ) + tiers = [ + _make_tier(_mock_vendor("anthropic")), + _make_tier(zhipu_vendor), + ] + executor = _executor( + tiers=tiers, + session_mgr=_stub_session_manager(is_new=True), + session_policy_resolver=resolver, + title_vendor_bindings=[TitleVendorBinding(prefix="# 目标", vendor="zhipu")], + ) + body = { + "model": "test", + "metadata": {"user_id": "session-stream"}, + "messages": [ + { + "role": "user", + "content": [{"type": "text", "text": "# 目标 流式任务"}], + } + ], + } + chunks = [chunk async for chunk, _ in executor.execute_stream(body, {})] + assert chunks # 有数据返回 + canonical = build_canonical_request(body, {}) + policy = resolver.resolve(canonical.session_key) + assert policy is not None + assert policy.tiers == ["zhipu"] + + def test_empty_prefix_rejected_by_validation(self): + """空 prefix 在模型校验阶段即被拒绝,杜绝全量误绑定.""" + import pydantic + + with pytest.raises(pydantic.ValidationError): + TitleVendorBinding(prefix="", vendor="zhipu") + + def test_empty_vendor_rejected_by_validation(self): + """空 vendor 在模型校验阶段即被拒绝.""" + import pydantic + + with pytest.raises(pydantic.ValidationError): + TitleVendorBinding(prefix="# 目标", vendor="") + + def test_unknown_vendor_warns_at_startup(self, caplog): + """构造时引用未知 vendor → 记录启动告警.""" + import logging as _logging + + tiers = [_make_tier(_mock_vendor("anthropic"))] + with caplog.at_level(_logging.WARNING, logger="coding.proxy.routing.executor"): + _executor( + tiers=tiers, + session_policy_resolver=SessionPolicyResolver(), + title_vendor_bindings=[ + TitleVendorBinding(prefix="# 目标", vendor="nonexistent") + ], + ) + warnings = [r for r in caplog.records if r.levelno == _logging.WARNING] + assert any("nonexistent" in r.message for r in warnings) + + def test_known_vendor_no_startup_warning(self, caplog): + """构造时引用合法 vendor → 不产生告警.""" + import logging as _logging + + tiers = [_make_tier(_mock_vendor("zhipu"))] + with caplog.at_level(_logging.WARNING, logger="coding.proxy.routing.executor"): + _executor( + tiers=tiers, + session_policy_resolver=SessionPolicyResolver(), + title_vendor_bindings=[ + TitleVendorBinding(prefix="# 目标", vendor="zhipu") + ], + ) + binding_warnings = [ + r for r in caplog.records if "title_vendor_bindings" in r.message + ] + assert not binding_warnings From 26f7bf14e6362107cf096f6126dba8afaa64bfac Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Sun, 7 Jun 2026 10:53:38 +0800 Subject: [PATCH 17/42] =?UTF-8?q?build(version):=20=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E5=8F=B7=E5=8D=87=E7=BA=A7=E8=87=B3=200.5.1a7;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 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 --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 90ffc66..d505aff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "coding-proxy" -version = "0.5.1a6" +version = "0.5.1a7" 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/uv.lock b/uv.lock index 90b7a7f..ca2e132 100644 --- a/uv.lock +++ b/uv.lock @@ -74,7 +74,7 @@ wheels = [ [[package]] name = "coding-proxy" -version = "0.5.1a6" +version = "0.5.1a7" source = { editable = "." } dependencies = [ { name = "aiosqlite" }, From 66319a88226c3412f3b92773ab0c733a41ddddd3 Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Sat, 13 Jun 2026 19:17:29 +0800 Subject: [PATCH 18/42] =?UTF-8?q?feat(model-mapping):=20=E8=B0=83=E6=95=B4?= =?UTF-8?q?=E5=A4=9A=20Vendor=20=E6=A8=A1=E5=9E=8B=E6=98=A0=E5=B0=84?= =?UTF-8?q?=E4=BC=98=E5=85=88=E7=BA=A7=E5=B9=B6=E6=9B=B4=E6=96=B0=20GLM=20?= =?UTF-8?q?=E6=98=A0=E5=B0=84=E4=B8=8E=E5=AE=9A=E4=BB=B7;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 统一将 claude-opus-.* 映射规则前置于 claude-sonnet-.*,使 Opus 优先匹配 - 智谱 GLM 映射升级:opus→glm-5.2、sonnet→glm-5.1、haiku→glm-5.1 - 新增 claude-fable-5 与 glm-5.2 定价,并按新优先级重排 GLM 定价条目 🤖 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 --- src/coding/proxy/config/config.default.yaml | 79 ++++++++++++--------- 1 file changed, 45 insertions(+), 34 deletions(-) diff --git a/src/coding/proxy/config/config.default.yaml b/src/coding/proxy/config/config.default.yaml index f91b73e..f67c542 100644 --- a/src/coding/proxy/config/config.default.yaml +++ b/src/coding/proxy/config/config.default.yaml @@ -228,51 +228,51 @@ failover: # === 模型映射 === model_mapping: # GitHub Copilot 可用模型(通过 /api/copilot/models 诊断获取) - - pattern: "claude-sonnet-.*" - vendors: ["copilot"] - target: "claude-sonnet-4.6" - is_regex: true - pattern: "claude-opus-.*" vendors: ["copilot"] target: "claude-opus-4.6" is_regex: true + - pattern: "claude-sonnet-.*" + vendors: ["copilot"] + target: "claude-sonnet-4.6" + is_regex: true - pattern: "claude-haiku-.*" vendors: ["copilot"] target: "claude-haiku-4.5" is_regex: true # Google Antigravity 模型映射 - - pattern: "claude-sonnet-.*" - vendors: ["antigravity"] - target: "Claude Sonnet 4.6" - is_regex: true - pattern: "claude-opus-.*" vendors: ["antigravity"] target: "Claude Opus 4.6 Thinking" is_regex: true + - pattern: "claude-sonnet-.*" + vendors: ["antigravity"] + target: "Claude Sonnet 4.6" + is_regex: true - pattern: "claude-haiku-.*" vendors: ["antigravity"] target: "Gemini 3 Flash" is_regex: true # 智谱 GLM 映射(官方原生 Anthropic 兼容端点) # glm-5v-turbo 完整承载 Claude Code 满血能力(tool calling / thinking / 多模态) - - pattern: "claude-sonnet-.*" + - pattern: "claude-opus-.*" vendors: ["zhipu"] - target: "glm-5v-turbo" + target: "glm-5.2" is_regex: true - - pattern: "claude-opus-.*" + - pattern: "claude-sonnet-.*" vendors: ["zhipu"] target: "glm-5.1" is_regex: true - pattern: "claude-haiku-.*" vendors: ["zhipu"] - target: "glm-4.5-air" + target: "glm-5.1" is_regex: true # MiniMax 模型映射(原生 Anthropic 兼容端点) - - pattern: "claude-sonnet-.*" + - pattern: "claude-opus-.*" vendors: ["minimax"] target: "minimax-m2.7" is_regex: true - - pattern: "claude-opus-.*" + - pattern: "claude-sonnet-.*" vendors: ["minimax"] target: "minimax-m2.7" is_regex: true @@ -281,11 +281,11 @@ model_mapping: target: "minimax-m2.7" is_regex: true # 阿里 Qwen 模型映射(原生 Anthropic 兼容端点) - - pattern: "claude-sonnet-.*" + - pattern: "claude-opus-.*" vendors: ["alibaba"] target: "qwen3.6-plus" is_regex: true - - pattern: "claude-opus-.*" + - pattern: "claude-sonnet-.*" vendors: ["alibaba"] target: "qwen3.6-plus" is_regex: true @@ -294,11 +294,11 @@ model_mapping: target: "qwen3.6-plus" is_regex: true # 小米 MiMo 模型映射(原生 Anthropic 兼容端点) - - pattern: "claude-sonnet-.*" + - pattern: "claude-opus-.*" vendors: ["xiaomi"] target: "mimo-v2-pro" is_regex: true - - pattern: "claude-opus-.*" + - pattern: "claude-sonnet-.*" vendors: ["xiaomi"] target: "mimo-v2-pro" is_regex: true @@ -307,11 +307,11 @@ model_mapping: target: "mimo-v2-pro" is_regex: true # Kimi 模型映射(原生 Anthropic 兼容端点) - - pattern: "claude-sonnet-.*" + - pattern: "claude-opus-.*" vendors: ["kimi"] target: "kimi-k2.5" is_regex: true - - pattern: "claude-opus-.*" + - pattern: "claude-sonnet-.*" vendors: ["kimi"] target: "kimi-k2.5" is_regex: true @@ -339,6 +339,12 @@ model_mapping: # 未配置定价的模型在统计中 Cost 列显示 "-" pricing: # ── Anthropic Claude ── + - vendor: anthropic + model: claude-fable-5 + input_cost_per_mtok: $10.0 + output_cost_per_mtok: $50.0 + cache_write_cost_per_mtok: $12.50 + cache_read_cost_per_mtok: $1.00 - vendor: anthropic model: claude-opus-4-8 input_cost_per_mtok: $5.0 @@ -527,20 +533,10 @@ pricing: cache_read_cost_per_mtok: $0.05 # ── 智谱 GLM ── - vendor: zhipu - model: glm-4.5-air # 待区分长短上下文定价 - input_cost_per_mtok: ¥0.80 - output_cost_per_mtok: ¥2.00 - cache_read_cost_per_mtok: ¥0.16 - - vendor: zhipu - model: glm-4.7 # 待区分长短上下文定价 - input_cost_per_mtok: ¥2.00 - output_cost_per_mtok: ¥8.00 - cache_read_cost_per_mtok: ¥0.40 - - vendor: zhipu - model: glm-5v-turbo # 待区分长短上下文定价 - input_cost_per_mtok: ¥5.00 - output_cost_per_mtok: ¥22.00 - cache_read_cost_per_mtok: ¥1.20 + model: glm-5.2 # 待区分长短上下文定价 + input_cost_per_mtok: ¥6.00 + output_cost_per_mtok: ¥24.00 + cache_read_cost_per_mtok: ¥1.30 - vendor: zhipu model: glm-5.1 # 待区分长短上下文定价 input_cost_per_mtok: ¥6.00 @@ -556,6 +552,21 @@ pricing: input_cost_per_mtok: ¥4.00 output_cost_per_mtok: ¥18.00 cache_read_cost_per_mtok: ¥1.00 + - vendor: zhipu + model: glm-5v-turbo # 待区分长短上下文定价 + input_cost_per_mtok: ¥5.00 + output_cost_per_mtok: ¥22.00 + cache_read_cost_per_mtok: ¥1.20 + - vendor: zhipu + model: glm-4.7 # 待区分长短上下文定价 + input_cost_per_mtok: ¥2.00 + output_cost_per_mtok: ¥8.00 + cache_read_cost_per_mtok: ¥0.40 + - vendor: zhipu + model: glm-4.5-air # 待区分长短上下文定价 + input_cost_per_mtok: ¥0.80 + output_cost_per_mtok: ¥2.00 + cache_read_cost_per_mtok: ¥0.16 # ── MiniMax ── - vendor: minimax model: minimax-m2.7 From 6257cbe202bcfa02e29a08625bc98305ac649205 Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Sat, 13 Jun 2026 19:17:35 +0800 Subject: [PATCH 19/42] =?UTF-8?q?build(version):=20=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E5=8F=B7=E5=8D=87=E7=BA=A7=E8=87=B3=200.5.1a8;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 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 --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index d505aff..84817de 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "coding-proxy" -version = "0.5.1a7" +version = "0.5.1a8" 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/uv.lock b/uv.lock index ca2e132..6696d97 100644 --- a/uv.lock +++ b/uv.lock @@ -74,7 +74,7 @@ wheels = [ [[package]] name = "coding-proxy" -version = "0.5.1a7" +version = "0.5.1a8" source = { editable = "." } dependencies = [ { name = "aiosqlite" }, From 9ef03fb7aaa56afdd0ddb42db541fb20e59abcc4 Mon Sep 17 00:00:00 2001 From: Aurelius Huang Date: Mon, 15 Jun 2026 16:14:34 +0800 Subject: [PATCH 20/42] =?UTF-8?q?feat(zhipu):=20=E5=B0=86=20529=20?= =?UTF-8?q?=E8=BF=87=E8=BD=BD=E5=BC=82=E5=B8=B8=E7=BA=B3=E5=85=A5=E6=8C=87?= =?UTF-8?q?=E6=95=B0=E9=80=80=E9=81=BF=E9=87=8D=E8=AF=95=E8=8C=83=E7=95=B4?= =?UTF-8?q?;=20(#261)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zhipu vendor 原仅对 429 限流做就地指数退避重试,529(Overloaded 并发过载)直接交由上层 failover。现扩展内部重试以涵盖 529,与 429 共用同一退避策略(max=5、1s→2s→4s→8s、Full Jitter、优先尊重 server retry-after),降低请求失败率与不必要的 failover。 - 新增 _BACKOFF_RETRY_STATUS={429,529} 作为可重试状态码单一事实源 - 非流式/流式判定改用集合,日志带入真实状态码 - 修复流式延迟计算忽略 529 retry-after 的缺陷,并去重延迟逻辑 - 对称补全 529 非流式/流式重试与 retry-after 回归测试 - 同步 design-patterns.md §3.12 Vendor 级重试说明 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) --- docs/arch/design-patterns.md | 2 + src/coding/proxy/vendors/zhipu.py | 76 +++++++------ tests/test_zhipu.py | 181 ++++++++++++++++++++++++++++++ 3 files changed, 225 insertions(+), 34 deletions(-) diff --git a/docs/arch/design-patterns.md b/docs/arch/design-patterns.md index 0b8c31b..25b7c20 100644 --- a/docs/arch/design-patterns.md +++ b/docs/arch/design-patterns.md @@ -541,6 +541,8 @@ $$ > **参数默认值**:参见 [配置参考 -- RetryConfig](./config-reference.md#elastic-params) +**Vendor 级应用(Zhipu 429/529 内部退避重试)**:[`vendors/zhipu.py`](../../src/coding/proxy/vendors/zhipu.py) 复用 `RetryConfig` / `calculate_delay()`,在 `send_message` / `send_message_stream` 内对 **429 Rate Limit(限流)** 与 **529 Overloaded(并发过载)** 两类服务端瞬态过载信号做就地指数退避重试(共用 `_BACKOFF_RETRY_STATUS = {429, 529}` 作单一事实源,max=5、1s→2s→4s→8s、Full Jitter,优先尊重 server `retry-after`)。耗尽重试后将状态码原样返回,交由上层 `should_trigger_failover`(§3.6 VendorTier)与 [CircuitBreaker](#circuit-breaker) 处理,从而降低 failover 频率。 + --- ## 3.13 Rate Limit Deadline Tracking(速率限制截止追踪) diff --git a/src/coding/proxy/vendors/zhipu.py b/src/coding/proxy/vendors/zhipu.py index ef36f57..feb8c4b 100644 --- a/src/coding/proxy/vendors/zhipu.py +++ b/src/coding/proxy/vendors/zhipu.py @@ -1,4 +1,4 @@ -"""智谱 GLM 供应商 — 原生 Anthropic 兼容端点代理(兼容转换 + 429 重试). +"""智谱 GLM 供应商 — 原生 Anthropic 兼容端点代理(兼容转换 + 429/529 重试). 官方端点 (https://open.bigmodel.cn/api/anthropic) 支持大部分 Anthropic Messages API 协议,本模块做以下适配: @@ -13,7 +13,8 @@ - reasoning_effort 参数:静默忽略 - metadata 字段:暂不处理(待进一步诊断确认兼容性) -额外提供 429 Rate Limit 专用重试挽回机制: +额外提供 429/529 专用重试挽回机制: + - 429 Rate Limit(限流)与 529 Overloaded(并发过载)共用同一退避策略 - max_attempt = 5(1 初始 + 4 重试) - 指数退避 + Full Jitter(1s → 2s → 4s → 8s) - 优先尊重 server retry-after header @@ -56,14 +57,20 @@ jitter=True, ) +# 触发指数退避重试的瞬态状态码(单一事实源) +# 429 Rate Limit(限流);529 Overloaded(并发过载) +# 二者均为服务端瞬态过载信号,适用同一退避挽回策略。 +_BACKOFF_RETRY_STATUS = frozenset({429, 529}) + class ZhipuVendor(NativeAnthropicVendor): - """智谱 GLM 原生 Anthropic 兼容端点供应商(薄透传 + 429 重试挽回). + """智谱 GLM 原生 Anthropic 兼容端点供应商(薄透传 + 429/529 重试挽回). 通过官方 /api/anthropic 端点转发请求, 仅替换模型名和认证头,其余原样透传。 - 429 Rate Limit 时自动重试(指数退避),降低 failover 频率。 + 429 Rate Limit(限流)/ 529 Overloaded(并发过载)时自动重试 + (指数退避),降低 failover 频率。 并发限流由 BaseVendor._concurrency_controller 统一管控。 """ @@ -124,24 +131,25 @@ async def _prepare_request( return body, new_headers - # ── 非流式:429 重试 ──────────────────────────────────── + # ── 非流式:429/529 重试 ──────────────────────────────── async def send_message( self, request_body: dict[str, Any], headers: dict[str, str], ) -> VendorResponse: - """非流式请求,429 时自动重试.""" + """非流式请求,429/529 时自动重试.""" max_attempts = self._rl_retry.max_attempts for attempt in range(max_attempts): resp = await super().send_message(request_body, headers) - if resp.status_code != 429: + if resp.status_code not in _BACKOFF_RETRY_STATUS: return resp if attempt == max_attempts - 1: logger.warning( - "Zhipu 429 rate limit exhausted after %d attempts", + "Zhipu %d overload exhausted after %d attempts", + resp.status_code, max_attempts, ) return resp @@ -150,7 +158,8 @@ async def send_message( resp.response_headers, attempt ) logger.info( - "Zhipu 429 rate limit, retry %d/%d in %.1fms", + "Zhipu %d overload, retry %d/%d in %.1fms", + resp.status_code, attempt + 1, max_attempts - 1, delay, @@ -159,16 +168,16 @@ async def send_message( return resp # pragma: no cover - # ── 流式:429 重试 ────────────────────────────────────── + # ── 流式:429/529 重试 ────────────────────────────────── async def send_message_stream( self, request_body: dict[str, Any], headers: dict[str, str], ) -> AsyncIterator[bytes]: - """流式请求,429 时自动重试. + """流式请求,429/529 时自动重试. - 安全性:429 在 BaseVendor.send_message_stream 中于 + 安全性:429/529 在 BaseVendor.send_message_stream 中于 status code 检查阶段即 raise(在任何 chunk yield 之前), 因此重试不会导致已发出数据不一致。 """ @@ -176,25 +185,33 @@ async def send_message_stream( for attempt in range(max_attempts): try: - # 429 在 status code 检查阶段即 raise(在任何 chunk 之前), + # 429/529 在 status code 检查阶段即 raise(在任何 chunk 之前), # 因此 __anext__ 安全:要么拿到首个 chunk,要么抛异常。 ait = super().send_message_stream(request_body, headers) head = await ait.__anext__() except StopAsyncIteration: return except httpx.HTTPStatusError as exc: - if exc.response is None or exc.response.status_code != 429: + if ( + exc.response is None + or exc.response.status_code not in _BACKOFF_RETRY_STATUS + ): raise + status_code = exc.response.status_code if attempt == max_attempts - 1: logger.warning( - "Zhipu 429 stream rate limit exhausted after %d attempts", + "Zhipu %d stream overload exhausted after %d attempts", + status_code, max_attempts, ) raise - delay = self._compute_retry_delay_from_response(exc.response, attempt) + delay = self._compute_retry_delay_from_headers( + exc.response.headers, attempt + ) logger.info( - "Zhipu 429 stream rate limit, retry %d/%d in %.1fms", + "Zhipu %d stream overload, retry %d/%d in %.1fms", + status_code, attempt + 1, max_attempts - 1, delay, @@ -215,24 +232,15 @@ def _compute_retry_delay_from_headers( headers: dict[str, str] | None, attempt: int, ) -> float: - """计算重试延迟(毫秒),优先使用 server retry-after.""" - rl_info = parse_rate_limit_headers(headers, 429, None) - server_delay_s = compute_effective_retry_seconds(rl_info) - if server_delay_s is not None: - return min(server_delay_s * 1000, self._rl_retry.max_delay_ms) - return calculate_delay(attempt, self._rl_retry) + """计算重试延迟(毫秒),优先使用 server retry-after. - def _compute_retry_delay_from_response( - self, - response: httpx.Response, - attempt: int, - ) -> float: - """计算重试延迟(毫秒),从 httpx.Response 提取 header.""" - rl_info = parse_rate_limit_headers( - response.headers, - response.status_code, - response.text[:500] if response.text else None, - ) + 非流式与流式 429/529 重试共用此方法(单一延迟逻辑)。 + 以"限流退避"语义(429)解析 header:``parse_rate_limit_headers`` + 仅对 429/403 解析 retry-after,故此处固定传 429, + 使 529 也能尊重 server retry-after,与 429 行为一致。 + 无 server 信号时回退到指数退避 + Full Jitter。 + """ + rl_info = parse_rate_limit_headers(headers, 429, None) server_delay_s = compute_effective_retry_seconds(rl_info) if server_delay_s is not None: return min(server_delay_s * 1000, self._rl_retry.max_delay_ms) diff --git a/tests/test_zhipu.py b/tests/test_zhipu.py index aa05b21..9e4523d 100644 --- a/tests/test_zhipu.py +++ b/tests/test_zhipu.py @@ -356,6 +356,20 @@ def _make_429_response( ) +def _make_529_response( + headers: dict[str, str] | None = None, +) -> httpx.Response: + """构造 529 HTTP 响应(Overloaded / 并发过载).""" + return httpx.Response( + status_code=529, + content=b'{"error":{"type":"overloaded_error","message":"Overloaded"}}', + headers=headers or {}, + request=httpx.Request( + "POST", "https://open.bigmodel.cn/api/anthropic/v1/messages" + ), + ) + + def _make_200_response() -> httpx.Response: """构造 200 HTTP 响应.""" body = json.dumps( @@ -666,3 +680,170 @@ async def test_missing_api_key_skips_retry(self): {}, ) assert resp.status_code == 401 + + # ── 529 Overloaded(并发过载)重试,行为与 429 一致 ────── + + @pytest.mark.asyncio + async def test_nonstream_529_retries_and_succeeds(self): + """非流式 529 两次后 200,重试成功.""" + vendor = _make_zhipu_vendor() + call_count = 0 + + async def mock_post(*args, **kwargs): + nonlocal call_count + call_count += 1 + if call_count <= 2: + return _make_529_response() + return _make_200_response() + + with ( + patch.object(vendor, "_get_client") as mock_client, + patch("asyncio.sleep", new_callable=AsyncMock), + ): + client = AsyncMock() + client.post = mock_post + mock_client.return_value = client + + resp = await vendor.send_message( + {"model": "claude-sonnet-4-20250514", "messages": []}, + {}, + ) + + assert resp.status_code == 200 + assert call_count == 3 + + @pytest.mark.asyncio + async def test_nonstream_529_exhausted_retries(self): + """非流式连续 5 次 529,耗尽重试后返回 529.""" + vendor = _make_zhipu_vendor() + call_count = 0 + + async def mock_post(*args, **kwargs): + nonlocal call_count + call_count += 1 + return _make_529_response() + + with ( + patch.object(vendor, "_get_client") as mock_client, + patch("asyncio.sleep", new_callable=AsyncMock), + ): + client = AsyncMock() + client.post = mock_post + mock_client.return_value = client + + resp = await vendor.send_message( + {"model": "claude-sonnet-4-20250514", "messages": []}, + {}, + ) + + assert resp.status_code == 529 + assert call_count == 5 + + @pytest.mark.asyncio + async def test_stream_529_retries_and_succeeds(self): + """流式 529 两次后成功.""" + call_count = 0 + + async def fake_stream(self, body, headers): + nonlocal call_count + call_count += 1 + if call_count <= 2: + resp = _make_529_response() + raise httpx.HTTPStatusError( + "529", + request=resp.request, + response=resp, + ) + yield b'data: {"type":"content_block_start"}\n\n' + yield b'data: {"type":"content_block_delta"}\n\n' + + vendor = _make_zhipu_vendor() + chunks = [] + with ( + patch.object(NativeAnthropicVendor, "send_message_stream", fake_stream), + patch("asyncio.sleep", new_callable=AsyncMock), + ): + async for chunk in vendor.send_message_stream( + {"model": "claude-sonnet-4-20250514", "messages": []}, + {}, + ): + chunks.append(chunk) + + assert len(chunks) == 2 + assert call_count == 3 + + @pytest.mark.asyncio + async def test_stream_529_exhausted_retries_raises(self): + """流式连续 529,耗尽重试后 raise.""" + call_count = 0 + + async def fake_stream(self, body, headers): + nonlocal call_count + call_count += 1 + resp = _make_529_response() + raise httpx.HTTPStatusError( + "529", + request=resp.request, + response=resp, + ) + yield # 使函数成为 async generator(不可达,仅影响类型) + + vendor = _make_zhipu_vendor() + with ( + patch.object(NativeAnthropicVendor, "send_message_stream", fake_stream), + patch("asyncio.sleep", new_callable=AsyncMock), + pytest.raises(httpx.HTTPStatusError) as exc_info, + ): + async for _ in vendor.send_message_stream( + {"model": "claude-sonnet-4-20250514", "messages": []}, + {}, + ): + pass + + assert exc_info.value.response.status_code == 529 + assert call_count == 5 + + @pytest.mark.asyncio + async def test_stream_529_respects_retry_after(self): + """流式 529 响应含 retry-after 时使用 server 建议延迟. + + 回归保障:修复前流式延迟计算把真实状态码 529 传给 + parse_rate_limit_headers(仅对 429/403 解析),导致 529 忽略 + retry-after 而回退指数退避(首次最多 1s)。修复后固定按 429 + 语义解析,529 与 429 一样尊重 server retry-after。 + """ + call_count = 0 + sleep_delays = [] + + async def fake_stream(self, body, headers): + nonlocal call_count + call_count += 1 + if call_count == 1: + resp = _make_529_response(headers={"retry-after": "2"}) + raise httpx.HTTPStatusError( + "529", + request=resp.request, + response=resp, + ) + yield b'data: {"type":"content_block_start"}\n\n' + + async def mock_sleep(delay): + sleep_delays.append(delay) + + vendor = _make_zhipu_vendor() + chunks = [] + with ( + patch.object(NativeAnthropicVendor, "send_message_stream", fake_stream), + patch("asyncio.sleep", side_effect=mock_sleep), + ): + async for chunk in vendor.send_message_stream( + {"model": "claude-sonnet-4-20250514", "messages": []}, + {}, + ): + chunks.append(chunk) + + assert len(chunks) == 1 + assert call_count == 2 + assert len(sleep_delays) == 1 + # retry-after=2 → 2 * 1.1 = 2.2s(>1s 指数退避首跳,证明用了 server 信号) + assert 2.0 <= sleep_delays[0] <= 2.2 From a247a9c1ce2e87afafbaca2e4ee84a566eba7a24 Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Mon, 15 Jun 2026 17:18:45 +0800 Subject: [PATCH 21/42] =?UTF-8?q?build(version):=20=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E5=8F=B7=E5=8D=87=E7=BA=A7=E5=8F=AA=200.5.1a9;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 84817de..c668fda 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "coding-proxy" -version = "0.5.1a8" +version = "0.5.1a9" 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/uv.lock b/uv.lock index 6696d97..44ef69d 100644 --- a/uv.lock +++ b/uv.lock @@ -74,7 +74,7 @@ wheels = [ [[package]] name = "coding-proxy" -version = "0.5.1a8" +version = "0.5.1a9" source = { editable = "." } dependencies = [ { name = "aiosqlite" }, From 7cdfc47e2f5276db82ae84a7eb1a82a52ef80805 Mon Sep 17 00:00:00 2001 From: Aurelius Huang Date: Thu, 18 Jun 2026 15:52:41 +0800 Subject: [PATCH 22/42] =?UTF-8?q?feat(dashboard):=20=E5=B0=86=20favicon=20?= =?UTF-8?q?=E4=B8=8E=20logo=20=E5=93=81=E7=89=8C=E5=9B=BE=E6=A0=87?= =?UTF-8?q?=E6=9B=BF=E6=8D=A2=E4=B8=BA=20Tabler=20terminal-2;=20(#262)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 /favicon.svg 路由(蓝紫渐变方块 + 白色 terminal-2 笔划),作为现代浏览器主选 favicon - 重绘 /favicon.ico 为 32×32 terminal-2 光栅图(旧浏览器 / Safari 回退),沿用圆角透明 - 页面 logo 由字母「C」改为内联白色 terminal-2 SVG,沿用渐变方块容器 - 抽取 _TERMINAL2_PATHS 笔划常量作为单一事实源,供 favicon 与 logo 共享 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) --- src/coding/proxy/server/dashboard.py | 119 +++++++++++++++++++++++---- 1 file changed, 102 insertions(+), 17 deletions(-) diff --git a/src/coding/proxy/server/dashboard.py b/src/coding/proxy/server/dashboard.py index cda7991..75c99da 100644 --- a/src/coding/proxy/server/dashboard.py +++ b/src/coding/proxy/server/dashboard.py @@ -11,28 +11,106 @@ from ..logging.db import TimePeriod +# ── 品牌图标:Tabler terminal-2 核心笔划(> 与 _),不含外框 ────────────── +# 外框由「蓝紫渐变圆角方块」容器承载(favicon SVG 的 与页面 .logo 的 CSS +# 渐变),保留 Tabler 原始外框笔划会双重描边、边缘发糊,故仅取 > 与 _ 两条。 +# 作为单一事实源,同时供 favicon SVG(f-string)与页面 logo({{TERMINAL2}} 模板替换)消费。 +_TERMINAL2_PATHS = '' + + +# ── Favicon (SVG, 现代浏览器主选) ────────────────────────────────────────── +def _build_favicon_svg() -> str: + """生成 24×24 SVG favicon:品牌渐变圆角方块 + 白色 terminal-2 笔划. + + 独立 SVG 文档无 CSS 上下文,``currentColor`` 不可靠,故笔划硬编码 ``#ffffff``。 + """ + return ( + '' + '' + '' + '' + "" + '' + f'{_TERMINAL2_PATHS}' + "" + ) + -# ── Favicon (16×16, 蓝紫渐变) ──────────────────────────────────────────── +# ── Favicon (ICO, 32×32 光栅回退) ───────────────────────────────────────── def _build_favicon() -> bytes: - """程序化生成 16×16 ICO,蓝紫渐变与 Dashboard Logo 一致.""" + """程序化生成 32×32 ICO:品牌渐变圆角方块 + 白色 terminal-2 笔划(旧浏览器/Safari 回退). + + 项目无 Pillow/cairosvg 等图像库,故以纯 Python 按像素光栅化:渐变圆角方块背景 + + 白色 ``>`` 折线与 ``_`` 下划线。沿用原实现「全零 AND-mask + per-pixel alpha」 + 的圆角透明方案(现代渲染器按 alpha 通道处理透明)。 + """ + import math import struct - width, height = 16, 16 + width = height = 32 + radius = 8 + scale = width / 24.0 # SVG 24 单位 -> 像素 + + r0, g0, b0 = 0x66, 0x7E, 0xEA # #667eea + r1, g1, b1 = 0x76, 0x4B, 0xA2 # #764ba2 + + def inside(x: int, y: int) -> bool: + """满铺圆角方块(四角半径 radius)包含判定.""" + cx = min(x, width - 1 - x) + cy = min(y, height - 1 - y) + if cx >= radius or cy >= radius: + return True + return (radius - cx) ** 2 + (radius - cy) ** 2 <= radius * radius + + # 像素缓冲:[B, G, R, A],默认全透明 + buf: list[list[list[int]]] = [ + [[0, 0, 0, 0] for _ in range(width)] for _ in range(height) + ] + + # 1) 对角渐变圆角背景(左上 #667eea -> 右下 #764ba2) + for y in range(height): + for x in range(width): + if not inside(x, y): + continue + t = (x + y) / (width + height - 2) + buf[y][x] = [ + int(b0 + (b1 - b0) * t), + int(g0 + (g1 - g0) * t), + int(r0 + (r1 - r0) * t), + 255, + ] + + def stamp(px: float, py: float) -> None: + """以 (px, py) 为中心盖 ~3px 白色方块(笔划加粗,保证小尺寸可辨识).""" + ix, iy = int(round(px)), int(round(py)) + for oy in (-1, 0, 1): + for ox in (-1, 0, 1): + nx, ny = ix + ox, iy + oy + if 0 <= nx < width and 0 <= ny < height: + buf[ny][nx] = [255, 255, 255, 255] + + def line(x0: float, y0: float, x1: float, y1: float) -> None: + """稠密采样 + stamp 形成白色粗线段.""" + dist = math.hypot(x1 - x0, y1 - y0) + steps = max(1, int(dist * 3)) + for i in range(steps + 1): + s = i / steps + stamp(x0 + (x1 - x0) * s, y0 + (y1 - y0) * s) + + # 2) terminal-2 笔划(SVG 24 单位 -> 像素) + # > 折线 (8,9)->(11,12)->(8,15);_ 下划线 (13,15)->(16,15) + line(8 * scale, 9 * scale, 11 * scale, 12 * scale) + line(11 * scale, 12 * scale, 8 * scale, 15 * scale) + line(13 * scale, 15 * scale, 16 * scale, 15 * scale) + + # 3) 打包 ICO(BMP bottom-up;全零 AND-mask,信任 per-pixel alpha 实现圆角透明) pixel_rows: list[bytes] = [] - cx, cy = width / 2.0, height / 2.0 for y in range(height - 1, -1, -1): # BMP bottom-up row = bytearray() for x in range(width): - dx = x - cx + 0.5 - dy = y - cy + 0.5 - if dx * dx + dy * dy > (width / 2.0) ** 2: - row.extend([0, 0, 0, 0]) # 圆外透明 - else: - t = (x + (height - 1 - y)) / (width + height - 2) - r = int(88 + (188 - 88) * t) - g = int(166 + (140 - 166) * t) - b = 255 - row.extend([b, g, r, 255]) # BGRA + b, g, r, a = buf[y][x] + row.extend([b, g, r, a]) # BGRA pixel_rows.append(bytes(row)) bmp_hdr = struct.pack( @@ -50,6 +128,7 @@ def _build_favicon() -> bytes: _FAVICON_ICO: bytes = _build_favicon() +_FAVICON_SVG: str = _build_favicon_svg() logger = logging.getLogger(__name__) @@ -61,6 +140,7 @@ def _build_favicon() -> bytes: Coding Proxy Dashboard + @@ -127,9 +207,9 @@ def _build_favicon() -> bytes: background: var(--gradient-primary); border-radius: 10px; display: flex; align-items: center; justify-content: center; - font-size: 15px; font-weight: 700; color: #fff; box-shadow: 0 4px 12px rgba(102,126,234,.25); } + .logo svg { width: 20px; height: 20px; display: block; } h1 { font-size: 18px; font-weight: 600; color: var(--text-primary); letter-spacing: -.3px; } .header-right { display: flex; align-items: center; gap: 12px; } .badge { @@ -705,7 +785,7 @@ def _build_favicon() -> bytes:
- +

Coding Proxy Dashboard

v-.-.-
@@ -2127,7 +2207,7 @@ def _build_favicon() -> bytes: -""" +""".replace("{{TERMINAL2}}", _TERMINAL2_PATHS) # ── 数据计算工具 ────────────────────────────────────────────────────────── @@ -2204,6 +2284,11 @@ async def favicon() -> Response: """返回内嵌 favicon.""" return Response(content=_FAVICON_ICO, media_type="image/x-icon") + @app.get("/favicon.svg", include_in_schema=False) + async def favicon_svg() -> Response: + """返回 SVG favicon(现代浏览器优先).""" + return Response(content=_FAVICON_SVG, media_type="image/svg+xml") + @app.get("/dashboard", response_class=HTMLResponse, include_in_schema=False) async def dashboard() -> HTMLResponse: """返回 Dashboard HTML 页面.""" From 96ca026e448374069df71c30a9d399a1b79eb57c Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Thu, 18 Jun 2026 16:04:08 +0800 Subject: [PATCH 23/42] =?UTF-8?q?build(version):=20=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E5=8F=B7=E5=8D=87=E7=BA=A7=E8=87=B3=200.5.1;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 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 --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c668fda..6fe3849 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "coding-proxy" -version = "0.5.1a9" +version = "0.5.1" 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/uv.lock b/uv.lock index 44ef69d..bbf9849 100644 --- a/uv.lock +++ b/uv.lock @@ -74,7 +74,7 @@ wheels = [ [[package]] name = "coding-proxy" -version = "0.5.1a9" +version = "0.5.1" source = { editable = "." } dependencies = [ { name = "aiosqlite" }, From 0fe07bebd70cd45099c2bd7d9328c34a9011c6f9 Mon Sep 17 00:00:00 2001 From: Aurelius Huang Date: Tue, 30 Jun 2026 19:34:07 +0800 Subject: [PATCH 24/42] =?UTF-8?q?fix(zhipu):=20=E5=B0=86=20429/529=20?= =?UTF-8?q?=E5=85=9C=E5=BA=95=E9=80=80=E9=81=BF=E6=8A=96=E5=8A=A8=E4=BB=8E?= =?UTF-8?q?=20Full=20Jitter=20=E6=94=B9=E4=B8=BA=20Equal=20Jitter;=20(#263?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复 529 过载重试延迟非单调问题(实测 418.8→1857.7→961.6→3769.7ms, 非递增)。根因为 calculate_delay 的 Full Jitter(random.uniform(0, ceiling)) 本质非单调,且 529 通常无 Retry-After 头而落入该兜底分支。改为 Equal Jitter (temp/2 + random(0, temp/2))后区间为 [500,1000]→[1000,2000]→[2000,4000] →[4000,8000],单调非递减;429/529 共用退避路径同步受益,retry-after 优先级不变。 - 新增 tests/test_retry.py 独立单元测试(calculate_delay 此前零覆盖) - 新增 test_zhipu.py::test_529_equal_jitter_delay_in_expected_band - 同步更新 retry.py / zhipu.py docstring、CHANGELOG、issue.md 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) --- CHANGELOG.md | 1 + docs/.agents/issue.md | 43 ++++++++++++++ src/coding/proxy/routing/retry.py | 17 ++++-- src/coding/proxy/vendors/zhipu.py | 4 +- tests/test_retry.py | 98 +++++++++++++++++++++++++++++++ tests/test_zhipu.py | 42 +++++++++++++ 6 files changed, 199 insertions(+), 6 deletions(-) create mode 100644 tests/test_retry.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 086302b..3c3d16a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ## [Unreleased] +- fix(zhipu): 将 429/529 兜底退避抖动从 Full Jitter 改为 Equal Jitter(`[0, ceiling]` → `[ceiling/2, ceiling]`),修复 529 过载重试延迟非单调(实测 418.8→1857.7→961.6→3769.7ms)问题,重试延迟呈单调非递减指数形态;429 与 529 共用退避路径同步受益,server `retry-after` 优先级不变; - feat(dashboard): Model Calling 实时监控扩展至全 vendor / 全 model(仅 CC 场景),其他 vendor 在 monitor 模式下仅计数不限流,Zhipu 保留 limited 模式 + FIFO 排队; - feat(concurrency): 新增 `peak_pending_recent` 最近 10s 排队峰值追踪,瞬时排队释放后前端仍可见"曾排队 N" 余晖徽章; - perf(dashboard): Model Calling 轮询间隔由 5000ms 缩短至 1500ms,提升瞬时排队可观测性; diff --git a/docs/.agents/issue.md b/docs/.agents/issue.md index c202b8a..1d14cb6 100644 --- a/docs/.agents/issue.md +++ b/docs/.agents/issue.md @@ -4,6 +4,49 @@ --- +## Zhipu 529 过载重试退避非单调(对齐 429 指数退避语义) + +**问题描述** + +cc 调用 zhipu 返回 529 过载触发重试时,延迟序列呈非单调形态(实测 `418.8ms → 1857.7ms → 961.6ms → 3769.7ms`,第 3 次反而短于第 2 次),不像 429 那样呈现干净的指数退避。 + +**表因** + +`src/coding/proxy/routing/retry.py::calculate_delay` 的兜底抖动为 **Full Jitter**:`delay = random.uniform(0, ceiling)`,每次延迟是 `[0, ceiling]` 区间均匀随机值,本质非单调。实测值逐项精确匹配 `attempt 0/1/2/3` 的 `uniform(0,1000)/(0,2000)/(0,4000)/(0,8000)`。 + +**根因** + +关键认知修正:429 与 529 在代码层面**早已共用同一退避路径** `ZhipuVendor._compute_retry_delay_from_headers`(`vendors/zhipu.py:230-247`),并非"两套规则"。感知差异来自两个因素叠加: + +1. **服务端响应头不对称**:429(限流)响应通常携带 `Retry-After` 头 → 走确定性 server-guided 路径(`retry_after * 1.1`),看起来"干净递增";529(过载)响应通常**不携带**该头 → 落入 Full Jitter 兜底分支。 +2. **Full Jitter 本身非单调**:即使 429 也一样,只是 429 多数情况有 `Retry-After` 遮蔽了该问题。 + +故真正修复对象是共享的抖动策略,而非给 529 单独加逻辑。 + +**处理方式** + +将 `calculate_delay` 从 Full Jitter 改为 **Equal Jitter**(AWS M. Brooker, "Exponential Backoff And Jitter," 2015): + +``` +temp = min(initial * backoff^attempt, max) +delay = temp/2 + random.uniform(0, temp/2) # [temp/2, temp] +``` + +Zhipu 配置下区间变为 `[500,1000] → [1000,2000] → [2000,4000] → [4000,8000]`,相邻区间仅边界相切,延迟几乎必然单调非递减;保留抖动以防惊群;429/529 同步受益;`retry-after` 优先级链不动。同步更新 `retry.py` / `zhipu.py` 共 4 处 "Full Jitter" docstring,新增 `tests/test_retry.py` 独立单元测试与 `test_zhipu.py::test_529_equal_jitter_delay_in_expected_band`。 + +**后续防范** + +- **单调性是配置相关的弱保证**:依赖 `backoff_multiplier >= 2.0` 且未触及 `max_delay_ms` 封顶。封顶后(`initial*2^attempt >= max`)各 attempt 区间退化为同一 `[max/2, max]`,单调性丧失。当前 Zhipu `max_retries=4` 触及不到,但未来调参须注意。函数 docstring 已写明此契约边界。 +- **诚实权衡(Brooker 反方观点)**:Brooker 2015 分布式吞吐基准显示 Full Jitter 优于 Equal Jitter。但本场景是单代理(CC)低并发过载恢复,诉求是可解释性(日志递增可预测)而非分布式吞吐最优,CC 并发量级远未达"海量",差异在噪声内。若未来观测到并发过载下的惊群回归,应优先要求 server 提供 `retry-after` 或评估 Decorrelated Jitter,而非回退 Full Jitter。 + +**同类问题影响与处理注意事项** + +- **双 `RetryConfig` 死代码(重要遗留债)**:仓库存在两个 `RetryConfig`——`routing/retry.py:25`(活跃 dataclass,仅 Zhipu 用)与 `config/resiliency.py:15`(Pydantic 死代码,`config/routing.py:285` 注册为 `retry` 字段但 `VendorTier.retry_config` 从未赋值,全仓库 `grep "retry_config="` 为空)。`docs/arch/routing.md:22` 与 `config-reference.md:95` 引用了不同的 `RetryConfig`,构成认知陷阱。本次修复**未触碰**死代码(超出范围),后续应单独清理。这也是本次"为何选方案 A(直改 `calculate_delay`)而非方案 B(加可配置 jitter_strategy 字段)"的决定性理由——方案 B 会扩大 schema 与运行时的裂缝。 +- **`calculate_delay` 唯一消费者确证**:经全仓库 grep,仅 `vendors/zhipu.py:247` 调用(`routing/__init__.py` 仅 re-export,`tier.retry_config` 字段未激活)。修改其抖动策略爆炸半径仅限 Zhipu,安全。 +- **测试缺口已补**:修复前 `calculate_delay` 无任何独立单元测试(仅通过 zhipu 集成测试间接覆盖,且那些测试要么禁用 jitter、要么走 retry-after 路径),现已新建 `tests/test_retry.py`。 + +--- + ## streaming usage parse failed: 'NoneType' object has no attribute 'get' **问题描述** diff --git a/src/coding/proxy/routing/retry.py b/src/coding/proxy/routing/retry.py index c407cc1..af4971c 100644 --- a/src/coding/proxy/routing/retry.py +++ b/src/coding/proxy/routing/retry.py @@ -1,4 +1,4 @@ -"""传输层重试策略 — 指数退避与 Full Jitter. +"""传输层重试策略 — 指数退避与 Equal Jitter. 与 Circuit Breaker 正交互: - Retry 处理瞬态网络抖动(秒级恢复) @@ -68,15 +68,24 @@ def is_retryable_status(status_code: int) -> bool: def calculate_delay(attempt: int, cfg: RetryConfig) -> float: - """计算第 N 次重试的延迟(毫秒),含指数退避和 Full Jitter. + """计算第 N 次重试的延迟(毫秒),含指数退避和 Equal Jitter. + + Equal Jitter 策略: temp = min(initial * backoff^attempt, max); + delay = temp/2 + random(0, temp/2),落在 [temp/2, temp]。 + 相较 Full Jitter (random(0, temp)),Equal Jitter 保留一半固定基线, + 使相邻重试的延迟区间仅边界相切,呈现单调非递减的指数形态, + 同时保留抖动以防惊群。 + + 契约边界:单调非递减依赖 ``backoff_multiplier >= 2.0`` 且未触及 + ``max_delay_ms`` 封顶;封顶后各 attempt 区间退化为同一 [max/2, max] + (当前 Zhipu ``max_retries=4`` 触及不到该边界)。 - Full Jitter 策略: delay = random(0, min(initial * backoff^attempt, max)) 参考: AWS "Exponential Backoff And Jitter" (Marc Brooker, 2015) """ delay = cfg.initial_delay_ms * (cfg.backoff_multiplier**attempt) delay = min(delay, cfg.max_delay_ms) if cfg.jitter: - delay = random.uniform(0, delay) + delay = delay / 2 + random.uniform(0, delay / 2) return delay diff --git a/src/coding/proxy/vendors/zhipu.py b/src/coding/proxy/vendors/zhipu.py index feb8c4b..ca6270a 100644 --- a/src/coding/proxy/vendors/zhipu.py +++ b/src/coding/proxy/vendors/zhipu.py @@ -16,7 +16,7 @@ 额外提供 429/529 专用重试挽回机制: - 429 Rate Limit(限流)与 529 Overloaded(并发过载)共用同一退避策略 - max_attempt = 5(1 初始 + 4 重试) - - 指数退避 + Full Jitter(1s → 2s → 4s → 8s) + - 指数退避 + Equal Jitter(区间 0.5–1s → 1–2s → 2–4s → 4–8s) - 优先尊重 server retry-after header 并发限流由 BaseVendor._concurrency_controller 统一管控 @@ -238,7 +238,7 @@ def _compute_retry_delay_from_headers( 以"限流退避"语义(429)解析 header:``parse_rate_limit_headers`` 仅对 429/403 解析 retry-after,故此处固定传 429, 使 529 也能尊重 server retry-after,与 429 行为一致。 - 无 server 信号时回退到指数退避 + Full Jitter。 + 无 server 信号时回退到指数退避 + Equal Jitter。 """ rl_info = parse_rate_limit_headers(headers, 429, None) server_delay_s = compute_effective_retry_seconds(rl_info) diff --git a/tests/test_retry.py b/tests/test_retry.py new file mode 100644 index 0000000..dc38c06 --- /dev/null +++ b/tests/test_retry.py @@ -0,0 +1,98 @@ +"""routing.retry 模块单元测试 — calculate_delay(指数退避 + Equal Jitter).""" + +import random + +from coding.proxy.routing.retry import RetryConfig, calculate_delay + +# Equal Jitter 蒙特卡洛采样次数(纯计算,开销可忽略) +_SAMPLES = 500 + + +def _cfg(**overrides) -> RetryConfig: + """构造测试用 RetryConfig(Zhipu 实际配置为默认基线).""" + defaults = dict( + max_retries=4, + initial_delay_ms=1000, + max_delay_ms=30000, + backoff_multiplier=2.0, + jitter=True, + ) + defaults.update(overrides) + return RetryConfig(**defaults) + + +# --- 无抖动:精确指数(回归基线)--- + + +def test_calculate_delay_no_jitter_exact_exponential(): + """jitter=False 时延迟为纯指数 1000 → 2000 → 4000 → 8000 ms.""" + cfg = _cfg(jitter=False) + assert calculate_delay(0, cfg) == 1000.0 + assert calculate_delay(1, cfg) == 2000.0 + assert calculate_delay(2, cfg) == 4000.0 + assert calculate_delay(3, cfg) == 8000.0 + + +# --- Equal Jitter:区间边界 --- + + +def test_calculate_delay_equal_jitter_bounds(): + """Equal Jitter 落在 [temp/2, temp]:[500,1000]/[1000,2000]/[2000,4000]/[4000,8000].""" + cfg = _cfg(jitter=True) + bounds = [(500.0, 1000.0), (1000.0, 2000.0), (2000.0, 4000.0), (4000.0, 8000.0)] + for attempt, (low, high) in enumerate(bounds): + for _ in range(_SAMPLES): + delay = calculate_delay(attempt, cfg) + assert low <= delay <= high, ( + f"attempt={attempt} delay={delay} 越界 [{low}, {high}]" + ) + + +def test_calculate_delay_capped_at_max(): + """触及 max_delay_ms 封顶后,区间退化为 [max/2, max] = [15000, 30000].""" + cfg = _cfg(jitter=True) + # attempt=5: 1000 * 2^5 = 32000 > max=30000 → temp=30000 + for _ in range(_SAMPLES): + delay = calculate_delay(5, cfg) + assert 15000.0 <= delay <= 30000.0 + + +# --- 单调非递减(multiplier=2.0 下数学保证)--- + + +def test_calculate_delay_monotonic_non_decreasing(): + """相邻重试延迟单调非递减(multiplier>=2.0 且未封顶时成立). + + 数学保证:delay[i] ∈ [temp_i/2, temp_i],delay[i+1] ∈ [temp_i, 2·temp_i], + 故 delay[i] <= temp_i <= delay[i+1] 恒成立(用 <= 而非 <,体现边界相切)。 + """ + cfg = _cfg(jitter=True) + for _ in range(_SAMPLES): + delays = [calculate_delay(a, cfg) for a in range(4)] + for i in range(len(delays) - 1): + assert delays[i] <= delays[i + 1], ( + f"非单调:delays={delays}(index {i} > {i + 1})" + ) + + +# --- 健壮性:极小 initial 不报错 --- + + +def test_calculate_delay_tiny_initial_no_error(): + """initial_delay_ms 极小值时不抛异常,仍落在合法区间.""" + cfg = _cfg(initial_delay_ms=1, jitter=True) + delay = calculate_delay(0, cfg) # temp=1 → [0.5, 1.0] + assert 0.5 <= delay <= 1.0 + + +# --- 可复现性:固定 random seed --- + + +def test_calculate_delay_reproducible_with_seed(): + """固定 random seed 后延迟序列可复现(验证 jitter 可观测、可调试).""" + cfg = _cfg(jitter=True) + random.seed(42) + first = [calculate_delay(a, cfg) for a in range(4)] + random.seed(42) + second = [calculate_delay(a, cfg) for a in range(4)] + assert first == second diff --git a/tests/test_zhipu.py b/tests/test_zhipu.py index 9e4523d..cf44e7b 100644 --- a/tests/test_zhipu.py +++ b/tests/test_zhipu.py @@ -847,3 +847,45 @@ async def mock_sleep(delay): assert len(sleep_delays) == 1 # retry-after=2 → 2 * 1.1 = 2.2s(>1s 指数退避首跳,证明用了 server 信号) assert 2.0 <= sleep_delays[0] <= 2.2 + + @pytest.mark.asyncio + async def test_529_equal_jitter_delay_in_expected_band(self): + """非流式 529 无 retry-after 时,Equal Jitter 首跳落在 [0.5, 1.0]s。 + + 回归保障:Full Jitter 时首跳为 uniform(0, 1000ms),可能接近 0ms + (用户报告的 418.8ms 落在 (0, 1000] 全区间,且整体序列非单调)。 + Equal Jitter 后区间收窄为 [500, 1000]ms,下界抬升至 500ms, + 呈现单调非递减的指数退避形态,429/529 同步受益(共用 calculate_delay)。 + """ + vendor = _make_zhipu_vendor() + sleep_delays = [] + + async def mock_sleep(delay): + sleep_delays.append(delay) + + call_count = 0 + + async def mock_post(*args, **kwargs): + nonlocal call_count + call_count += 1 + if call_count == 1: + return _make_529_response() # 无 retry-after + return _make_200_response() + + with ( + patch.object(vendor, "_get_client") as mock_client, + patch("asyncio.sleep", side_effect=mock_sleep), + ): + client = AsyncMock() + client.post = mock_post + mock_client.return_value = client + + resp = await vendor.send_message( + {"model": "claude-sonnet-4-20250514", "messages": []}, + {}, + ) + + assert resp.status_code == 200 + assert len(sleep_delays) == 1 + # Equal Jitter: attempt 0 → temp=1000ms → [500, 1000]ms → sleep([0.5, 1.0]) + assert 0.5 <= sleep_delays[0] <= 1.0 From 0c330d5fcf6a4dd1dd1e512f5cff42258fd5988a Mon Sep 17 00:00:00 2001 From: Aurelius Huang Date: Tue, 30 Jun 2026 19:58:21 +0800 Subject: [PATCH 25/42] =?UTF-8?q?build(version):=20=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E5=8F=B7=E5=8D=87=E7=BA=A7=E8=87=B3=200.5.2a1;=20(#264)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 同步更新 CHANGELOG.md,新增 [v0.5.2a1] 条目(含 529 Equal Jitter fix 与补记的 v0.5.1 遗漏改动)。 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) --- CHANGELOG.md | 7 ++++++- pyproject.toml | 2 +- uv.lock | 2 +- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c3d16a..89eca00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,12 +4,17 @@ ## [Unreleased] -- fix(zhipu): 将 429/529 兜底退避抖动从 Full Jitter 改为 Equal Jitter(`[0, ceiling]` → `[ceiling/2, ceiling]`),修复 529 过载重试延迟非单调(实测 418.8→1857.7→961.6→3769.7ms)问题,重试延迟呈单调非递减指数形态;429 与 529 共用退避路径同步受益,server `retry-after` 优先级不变; +## [v0.5.2a1](https://github.com/ThreeFish-AI/coding-proxy/releases/tag/v0.5.2a1) - 2026-06-30 + +- fix(zhipu): 将 429/529 兜底退避抖动从 Full Jitter 改为 Equal Jitter(`[0, ceiling]` → `[ceiling/2, ceiling]`),修复 529 过载重试延迟非单调(实测 418.8→1857.7→961.6→3769.7ms)问题,重试延迟呈单调非递减指数形态;429 与 529 共用退避路径同步受益,server `retry-after` 优先级不变 (#263); - feat(dashboard): Model Calling 实时监控扩展至全 vendor / 全 model(仅 CC 场景),其他 vendor 在 monitor 模式下仅计数不限流,Zhipu 保留 limited 模式 + FIFO 排队; - feat(concurrency): 新增 `peak_pending_recent` 最近 10s 排队峰值追踪,瞬时排队释放后前端仍可见"曾排队 N" 余晖徽章; - perf(dashboard): Model Calling 轮询间隔由 5000ms 缩短至 1500ms,提升瞬时排队可观测性; - refactor(vendors): `ModelConcurrencyLimiter` 重构为 `ModelConcurrencyController`,统一 monitor / limited 双模式抽象(保留旧名别名);并发控制由 vendor 内部迁移至 executor 层 `track_in_flight` 包裹,行为对所有 vendor 一致; +> [!NOTE] +> 本版另补记 v0.5.1 Release Note 遗漏的 4 项并发与监控改动(feat/perf/refactor,代码已随 v0.5.1 上线,文档此前未记录)。 + ## [v0.5.0](https://github.com/ThreeFish-AI/coding-proxy/releases/tag/v0.5.0) - 2026-05-27 > [!IMPORTANT] diff --git a/pyproject.toml b/pyproject.toml index 6fe3849..728eb9a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "coding-proxy" -version = "0.5.1" +version = "0.5.2a1" 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/uv.lock b/uv.lock index bbf9849..2a75c27 100644 --- a/uv.lock +++ b/uv.lock @@ -74,7 +74,7 @@ wheels = [ [[package]] name = "coding-proxy" -version = "0.5.1" +version = "0.5.2a1" source = { editable = "." } dependencies = [ { name = "aiosqlite" }, From 85f527869797b6cfc3c1094e047e0563fb9c0536 Mon Sep 17 00:00:00 2001 From: Aurelius Huang Date: Thu, 2 Jul 2026 17:27:38 +0800 Subject: [PATCH 26/42] =?UTF-8?q?feat(session):=20=E6=96=B0=E5=A2=9E=20Ses?= =?UTF-8?q?sion=20=E6=A0=87=E9=A2=98=E8=B1=81=E5=85=8D=E5=89=8D=E7=BC=80?= =?UTF-8?q?=EF=BC=8C=E8=BF=87=E6=BB=A4=E6=B3=A8=E5=85=A5=E5=BC=8F=20Prompt?= =?UTF-8?q?=20=E5=80=99=E9=80=89;=20(#265)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在 session_policies 配置下新增可维护的 title_exempt_prefixes 名单:首条 user TEXT 经噪声剥离后若以任一豁免前缀开头则跳过,继续向后查找合适的 title 候选(典型如 "Write the title in the language..." 注入指令)。豁免 仅作用于 Level 1,Level 2/3/4 回退不受影响;经 _RouteExecutor 实例方法 注入,field_validator 与构造期双重归一化防空串 startswith 误豁免。 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) --- src/coding/proxy/config/config.default.yaml | 7 + src/coding/proxy/config/session_policy.py | 33 ++- src/coding/proxy/routing/executor.py | 64 ++++- src/coding/proxy/routing/router.py | 2 + src/coding/proxy/server/app.py | 1 + tests/test_router_executor.py | 245 ++++++++++++++++++++ tests/test_session_aware.py | 24 ++ 7 files changed, 365 insertions(+), 11 deletions(-) diff --git a/src/coding/proxy/config/config.default.yaml b/src/coding/proxy/config/config.default.yaml index f67c542..52950e0 100644 --- a/src/coding/proxy/config/config.default.yaml +++ b/src/coding/proxy/config/config.default.yaml @@ -704,3 +704,10 @@ session_policies: title_vendor_bindings: - prefix: "# 目标" vendor: "zhipu" + # Session 标题豁免前缀:user 输入(经噪声剥离清洗后)以任一前缀开头则跳过, + # 继续向后查找 title 候选。用于过滤注入式 Prompt(如示例语言指令), + # 避免其被误用作 Session 标题。大小写敏感 startswith。 + # ⚠️ 自定义 yaml 中该字段为整体替换(loader 的 list 合并约定,非追加合并); + # 写空列表即关闭豁免。加载时会自动 strip + 去空 + 去重。 + title_exempt_prefixes: + - "Write the title in the language" diff --git a/src/coding/proxy/config/session_policy.py b/src/coding/proxy/config/session_policy.py index c25da7a..7217f73 100644 --- a/src/coding/proxy/config/session_policy.py +++ b/src/coding/proxy/config/session_policy.py @@ -2,7 +2,7 @@ from __future__ import annotations -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator class SessionPolicyMatch(BaseModel): @@ -81,3 +81,34 @@ class SessionPoliciesConfig(BaseModel): "匹配规则按列表顺序求值,首次匹配生效。" ), ) + title_exempt_prefixes: list[str] = Field( + default_factory=list, + description=( + "Session 标题豁免前缀名单。当首条 user TEXT 输入(经噪声剥离清洗后)" + "以任一前缀开头时,跳过该输入、继续向后查找合适的 title 候选。" + "用于过滤注入式 Prompt(如示例语言指令),避免其被误用作 Session 标题。" + "大小写敏感的 startswith 匹配,与 title_vendor_bindings 语义一致。" + "加载时会自动 strip + 去空 + 去重;空字符串前缀会被丢弃" + "(防空串 startswith 恒真导致全量误豁免)。" + ), + ) + + @field_validator("title_exempt_prefixes", mode="after") + @classmethod + def _normalize_exempt_prefixes(cls, value: list[str]) -> list[str]: + """归一化豁免前缀:strip + 去空 + 去重保序. + + 空字符串前缀必须丢弃——``"".startswith`` 恒真会豁免一切用户输入, + 导致 Level 1 标题提取永久失效。 + """ + seen: set[str] = set() + result: list[str] = [] + for item in value: + if not isinstance(item, str): + continue + cleaned = item.strip() + if not cleaned or cleaned in seen: + continue + seen.add(cleaned) + result.append(cleaned) + return result diff --git a/src/coding/proxy/routing/executor.py b/src/coding/proxy/routing/executor.py index 9ade9dd..0569126 100644 --- a/src/coding/proxy/routing/executor.py +++ b/src/coding/proxy/routing/executor.py @@ -148,14 +148,30 @@ def _sanitize_user_text(raw: str) -> str: # ───────────────────────────────────────────────────────────────── -def _extract_title_from_user_text(messages: list[CanonicalMessagePart]) -> str: - """Level 1: 从 user TEXT 部分提取经噪声剥离后的首条非空文本.""" +def _extract_title_from_user_text( + messages: list[CanonicalMessagePart], + exempt_prefixes: list[str] | None = None, +) -> str: + """Level 1: 从 user TEXT 部分提取经噪声剥离后的首条非空文本. + + ``exempt_prefixes`` 非空时,清洗后文本以任一前缀开头则跳过该输入、继续向后 + 查找——用于过滤注入式 Prompt,避免其被误用作 Session 标题。匹配作用于 + ``_sanitize_user_text`` 清洗后的纯文本(而非带 XML 标签的原始 raw)。 + """ for part in messages: if part.role != "user" or part.type != CanonicalPartType.TEXT: continue cleaned = _sanitize_user_text(part.text) - if cleaned: - return cleaned[:_SESSION_TITLE_MAX_LEN] + if not cleaned: + continue + if exempt_prefixes and any(cleaned.startswith(p) for p in exempt_prefixes): + logger.debug( + "Title candidate skipped by exempt prefix %r (snippet=%r)", + next(p for p in exempt_prefixes if cleaned.startswith(p)), + cleaned[:60], + ) + continue + return cleaned[:_SESSION_TITLE_MAX_LEN] return "" @@ -193,15 +209,24 @@ def _extract_title_from_metadata(request: CanonicalRequest) -> str: return "" -def _extract_session_title(request: CanonicalRequest) -> str: +def _extract_session_title( + request: CanonicalRequest, + exempt_prefixes: list[str] | None = None, +) -> str: """从规范化请求中提取 session 标题 — 多层级回退策略。 依次尝试: user TEXT 噪声剥离 → TOOL_RESULT 摘要 → IMAGE 计数 → 元数据兜底。 任意级别命中即返回,确保 Dashboard 尽可能展示有辨识度的标题。 + + ``exempt_prefixes`` 仅作用于 Level 1(用户直接输入的 TEXT);Level 2/3/4 + 为非用户直接输入的回退来源,不参与豁免。默认 ``None`` 时行为与历史一致。 """ messages = request.messages + # Level 1 需要豁免前缀;单独调用以传入参数,避免污染 L2/L3 的统一签名。 + title = _extract_title_from_user_text(messages, exempt_prefixes) + if title: + return title[:_SESSION_TITLE_MAX_LEN] for extractor in ( - _extract_title_from_user_text, _extract_title_from_tool_results, _extract_title_from_images, ): @@ -614,6 +639,7 @@ def __init__( reauth_coordinator: Any | None = None, session_policy_resolver: SessionPolicyResolver | None = None, title_vendor_bindings: list[TitleVendorBinding] | None = None, + title_exempt_prefixes: list[str] | None = None, ) -> None: self._router = router self._tiers = tiers @@ -622,6 +648,13 @@ def __init__( self._reauth_coordinator = reauth_coordinator self._policy_resolver = session_policy_resolver or SessionPolicyResolver() self._title_vendor_bindings = title_vendor_bindings or [] + # 豁免前缀名单:构造期二次归一化(strip + 去空),双重防御配置层 field_validator。 + # 即便绕过配置层直接构造 executor,也能避免空串 startswith 恒真与纯空白失配。 + self._title_exempt_prefixes: list[str] = [ + stripped + for p in (title_exempt_prefixes or []) + if isinstance(p, str) and (stripped := p.strip()) + ] self._validate_title_vendor_bindings() # Tier 名称 → OAuth provider 名称的映射 @@ -676,6 +709,17 @@ def _resolve_effective_tiers(self, session_key: str) -> list[VendorTier]: seen.add(tier.name) return ordered + def _extract_session_title(self, request: CanonicalRequest) -> str: + """封装模块级标题提取,注入实例持有的豁免前缀名单. + + 将 ``self._title_exempt_prefixes`` 透传给模块级 ``_extract_session_title``, + 使 Level 1 能跳过以豁免前缀开头的注入式 Prompt。4 处标题提取调用点统一 + 经此方法,避免重复透传(零透传污染)。 + """ + return _extract_session_title( + request, exempt_prefixes=self._title_exempt_prefixes + ) + def _apply_title_based_policy(self, session_key: str, title: str) -> None: """根据 Session 标题前缀自动绑定供应商. @@ -790,7 +834,7 @@ async def execute_stream( canonical_request.trace_id, ) if is_new_session: - title = _extract_session_title(canonical_request) + title = self._extract_session_title(canonical_request) if title: await self._recorder.set_session_title( canonical_request.session_key, title @@ -798,7 +842,7 @@ async def execute_stream( self._apply_title_based_policy(canonical_request.session_key, title) else: # 延迟标题补写: 若 session 尚无标题,尝试从当前请求中提取并回写。 - title = _extract_session_title(canonical_request) + title = self._extract_session_title(canonical_request) if title: await self._recorder.update_empty_session_title( canonical_request.session_key, title @@ -977,7 +1021,7 @@ async def execute_message( canonical_request.trace_id, ) if is_new_session: - title = _extract_session_title(canonical_request) + title = self._extract_session_title(canonical_request) if title: await self._recorder.set_session_title( canonical_request.session_key, title @@ -985,7 +1029,7 @@ async def execute_message( self._apply_title_based_policy(canonical_request.session_key, title) else: # 延迟标题补写: 若 session 尚无标题,尝试从当前请求中提取并回写。 - title = _extract_session_title(canonical_request) + title = self._extract_session_title(canonical_request) if title: await self._recorder.update_empty_session_title( canonical_request.session_key, title diff --git a/src/coding/proxy/routing/router.py b/src/coding/proxy/routing/router.py index a9b5379..3ea162c 100644 --- a/src/coding/proxy/routing/router.py +++ b/src/coding/proxy/routing/router.py @@ -40,6 +40,7 @@ def __init__( compat_session_store: CompatSessionStore | None = None, session_policy_resolver: SessionPolicyResolver | None = None, title_vendor_bindings: list[TitleVendorBinding] | None = None, + title_exempt_prefixes: list[str] | None = None, ) -> None: if not tiers: raise ValueError("至少需要一个供应商层级") @@ -59,6 +60,7 @@ def __init__( reauth_coordinator=reauth_coordinator, session_policy_resolver=session_policy_resolver, title_vendor_bindings=title_vendor_bindings, + title_exempt_prefixes=title_exempt_prefixes, ) def set_pricing_table(self, table: PricingTable) -> None: diff --git a/src/coding/proxy/server/app.py b/src/coding/proxy/server/app.py index 396b89f..cf8aad4 100644 --- a/src/coding/proxy/server/app.py +++ b/src/coding/proxy/server/app.py @@ -162,6 +162,7 @@ def create_app(config: ProxyConfig | None = None) -> FastAPI: compat_session_store, session_policy_resolver=SessionPolicyResolver(config.session_policies.policies), title_vendor_bindings=config.session_policies.title_vendor_bindings, + title_exempt_prefixes=config.session_policies.title_exempt_prefixes, ) app = FastAPI(title="coding-proxy", version=__version__, lifespan=lifespan) diff --git a/tests/test_router_executor.py b/tests/test_router_executor.py index 2e5c13e..4077271 100644 --- a/tests/test_router_executor.py +++ b/tests/test_router_executor.py @@ -2409,6 +2409,80 @@ def test_skips_assistant_role(self): req = self._build_request(messages) assert _extract_session_title(req) == "新的用户问题" + # ── 豁免前缀端到端(exempt_prefixes 透传至 Level 1)── + + def test_exempt_prefix_falls_through_to_next_user_text(self): + """端到端:L1 首条命中豁免 → 取第二条 user text 作为标题.""" + messages = [ + {"role": "user", "content": "Write the title in the language ..."}, + {"role": "user", "content": "端到端业务标题"}, + ] + req = self._build_request(messages) + assert ( + _extract_session_title( + req, exempt_prefixes=["Write the title in the language"] + ) + == "端到端业务标题" + ) + + def test_exempt_l1_falls_back_to_l2_tool_result(self): + """L1 命中豁免且无其他 user text → 回退 Level 2 tool_result.""" + messages = [ + {"role": "user", "content": "Write the title in the language ..."}, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "tu_1", + "content": [{"type": "text", "text": "文件内容摘要"}], + } + ], + }, + ] + req = self._build_request(messages) + title = _extract_session_title( + req, exempt_prefixes=["Write the title in the language"] + ) + assert title == "[Tool output] 文件内容摘要" + + def test_exempt_l1_falls_back_to_l4_metadata(self): + """L1 命中豁免且无 L2/L3 → 回退 Level 4 元数据兜底.""" + messages = [{"role": "user", "content": "Write the title in the language ..."}] + req = self._build_request(messages) + assert ( + _extract_session_title( + req, exempt_prefixes=["Write the title in the language"] + ) + == "[Session] test" + ) + + def test_exempt_does_not_affect_l2(self): + """豁免仅作用于 L1:tool_result 文本以豁免前缀开头,L2 仍正常提取.""" + messages = [ + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "tu_1", + "content": [ + { + "type": "text", + "text": "Write the title in the language ...", + } + ], + } + ], + }, + ] + req = self._build_request(messages) + title = _extract_session_title( + req, exempt_prefixes=["Write the title in the language"] + ) + # L2 不受豁免影响,正常提取并加 [Tool output] 前缀 + assert title == "[Tool output] Write the title in the language ..." + # ═══════════════════════════════════════════════════════════════════ # 多层级回退标题提取测试 @@ -2453,6 +2527,141 @@ def test_returns_empty_for_noise_only(self): ] assert _extract_title_from_user_text(msgs) == "" + # ── 豁免前缀(exempt_prefixes)—— 过滤注入式 Prompt ── + + def test_exempt_prefix_skips_first_falls_through_to_second(self): + """豁免前缀命中首条 user text → 跳过、返回第二条.""" + from coding.proxy.model.compat import CanonicalMessagePart, CanonicalPartType + + msgs = [ + CanonicalMessagePart( + type=CanonicalPartType.TEXT, + role="user", + text="Write the title in the language the user wrote in, regardless.", + ), + CanonicalMessagePart( + type=CanonicalPartType.TEXT, role="user", text="帮我重构 executor" + ), + ] + assert ( + _extract_title_from_user_text( + msgs, exempt_prefixes=["Write the title in the language"] + ) + == "帮我重构 executor" + ) + + def test_exempt_prefix_case_sensitive(self): + """大小写敏感:小写前缀不命中大写开头的输入.""" + from coding.proxy.model.compat import CanonicalMessagePart, CanonicalPartType + + msgs = [ + CanonicalMessagePart( + type=CanonicalPartType.TEXT, + role="user", + text="Write the title in the language the user wrote in.", + ), + ] + # 前缀小写、输入首字母大写 → 不命中,正常返回 + assert ( + _extract_title_from_user_text(msgs, exempt_prefixes=["write the title"]) + == "Write the title in the language the user wrote in." + ) + + def test_exempt_no_param_backward_compatible(self): + """不传 exempt_prefixes → 行为与历史一致(首条非空即返回).""" + from coding.proxy.model.compat import CanonicalMessagePart, CanonicalPartType + + msgs = [ + CanonicalMessagePart( + type=CanonicalPartType.TEXT, + role="user", + text="Write the title in the language the user wrote in.", + ), + ] + assert _extract_title_from_user_text(msgs) == ( + "Write the title in the language the user wrote in." + ) + + def test_exempt_empty_list_no_op(self): + """传空列表 → 等价于不豁免.""" + from coding.proxy.model.compat import CanonicalMessagePart, CanonicalPartType + + msgs = [ + CanonicalMessagePart( + type=CanonicalPartType.TEXT, + role="user", + text="Write the title in the language the user wrote in.", + ), + ] + assert _extract_title_from_user_text(msgs, exempt_prefixes=[]) == ( + "Write the title in the language the user wrote in." + ) + + def test_exempt_all_user_inputs_skipped_returns_empty(self): + """全部 user text 命中豁免 → 返回空字符串.""" + from coding.proxy.model.compat import CanonicalMessagePart, CanonicalPartType + + msgs = [ + CanonicalMessagePart( + type=CanonicalPartType.TEXT, + role="user", + text="Write the title in the language variant A", + ), + CanonicalMessagePart( + type=CanonicalPartType.TEXT, + role="user", + text="Write the title in the language variant B", + ), + ] + assert ( + _extract_title_from_user_text( + msgs, exempt_prefixes=["Write the title in the language"] + ) + == "" + ) + + def test_exempt_matches_cleaned_not_raw(self): + """豁免判断作用于清洗后文本:raw 含 system-reminder 包裹、清洗后命中前缀 → 跳过.""" + from coding.proxy.model.compat import CanonicalMessagePart, CanonicalPartType + + raw = ( + "注入的系统上下文" + "Write the title in the language the user wrote in." + ) + msgs = [ + CanonicalMessagePart(type=CanonicalPartType.TEXT, role="user", text=raw), + CanonicalMessagePart( + type=CanonicalPartType.TEXT, role="user", text="真实业务问题" + ), + ] + assert ( + _extract_title_from_user_text( + msgs, exempt_prefixes=["Write the title in the language"] + ) + == "真实业务问题" + ) + + def test_exempt_multi_prefix_any_match(self): + """多前缀:命中任一即跳过.""" + from coding.proxy.model.compat import CanonicalMessagePart, CanonicalPartType + + msgs = [ + CanonicalMessagePart( + type=CanonicalPartType.TEXT, + role="user", + text="IGNORE_PREFIX_A something", + ), + CanonicalMessagePart( + type=CanonicalPartType.TEXT, role="user", text="第二输入" + ), + ] + assert ( + _extract_title_from_user_text( + msgs, exempt_prefixes=["IGNORE_PREFIX_A", "IGNORE_PREFIX_B"] + ) + == "第二输入" + ) + class TestExtractTitleFromToolResults: """Level 2 辅助函数 ``_extract_title_from_tool_results``.""" @@ -2946,3 +3155,39 @@ def test_known_vendor_no_startup_warning(self, caplog): r for r in caplog.records if "title_vendor_bindings" in r.message ] assert not binding_warnings + + +class TestExemptPrefixInjection: + """``_RouteExecutor._extract_session_title`` 实例方法注入豁免前缀名单.""" + + @staticmethod + def _build_request(messages: list[dict]): + return build_canonical_request({"model": "test", "messages": messages}, {}) + + def test_executor_injects_exempt_prefixes_to_l1(self): + """executor 持有的豁免前缀经实例方法注入,L1 跳过注入式 Prompt.""" + req = self._build_request( + [ + {"role": "user", "content": "Write the title in the language ..."}, + {"role": "user", "content": "注入后的真实标题"}, + ] + ) + executor = _executor(title_exempt_prefixes=["Write the title in the language"]) + assert executor._extract_session_title(req) == "注入后的真实标题" + + def test_executor_without_exempt_prefixes_backward_compatible(self): + """未配置豁免前缀 → 实例方法行为与模块级默认一致(不跳过).""" + req = self._build_request( + [{"role": "user", "content": "Write the title in the language ..."}] + ) + executor = _executor() # 不传 title_exempt_prefixes + assert executor._extract_session_title(req) == ( + "Write the title in the language ..." + ) + + def test_executor_filters_empty_string_prefix_at_construction(self): + """构造期过滤空串:即便传入空串/纯空白也不会豁免一切.""" + req = self._build_request([{"role": "user", "content": "正常标题"}]) + executor = _executor(title_exempt_prefixes=["", " ", "\t"]) + assert executor._title_exempt_prefixes == [] + assert executor._extract_session_title(req) == "正常标题" diff --git a/tests/test_session_aware.py b/tests/test_session_aware.py index 29518e5..9f3441a 100644 --- a/tests/test_session_aware.py +++ b/tests/test_session_aware.py @@ -462,6 +462,30 @@ def test_config_parse(): assert config.policies[0].tiers == ["anthropic", "copilot"] +def test_config_exempt_prefixes_default_empty(): + """未配置时默认空列表.""" + config = SessionPoliciesConfig() + assert config.title_exempt_prefixes == [] + + +def test_config_exempt_prefixes_strips_whitespace(): + """加载时 strip 每个前缀的前后空白.""" + config = SessionPoliciesConfig(title_exempt_prefixes=[" Write ", "\tRead\t"]) + assert config.title_exempt_prefixes == ["Write", "Read"] + + +def test_config_exempt_prefixes_drops_empty_string(): + """空字符串/纯空白前缀必须丢弃——防空串 startswith 恒真导致全量误豁免.""" + config = SessionPoliciesConfig(title_exempt_prefixes=["", " ", "Write"]) + assert config.title_exempt_prefixes == ["Write"] + + +def test_config_exempt_prefixes_dedupes_preserves_order(): + """去重并保留首次出现顺序.""" + config = SessionPoliciesConfig(title_exempt_prefixes=["a", "b", "a", "b", "c"]) + assert config.title_exempt_prefixes == ["a", "b", "c"] + + # ── 9. SessionPolicyResolver 运行时可变性 ──────────────────────── From b285ce3c0ec69d697b866bb08afbd9079cb2c4c1 Mon Sep 17 00:00:00 2001 From: Aurelius Huang Date: Thu, 2 Jul 2026 17:47:50 +0800 Subject: [PATCH 27/42] =?UTF-8?q?build(version):=20=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E5=8F=B7=E5=8D=87=E7=BA=A7=E8=87=B3=200.5.2a2;=20(#266)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 同步更新 CHANGELOG.md,新增 [v0.5.2a2] 条目(feat(session) Session 标题豁免前缀,#265)。 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) --- CHANGELOG.md | 4 ++++ pyproject.toml | 2 +- uv.lock | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 89eca00..4949760 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ ## [Unreleased] +## [v0.5.2a2](https://github.com/ThreeFish-AI/coding-proxy/releases/tag/v0.5.2a2) - 2026-07-02 + +- feat(session): 新增 Session 标题豁免前缀配置 `title_exempt_prefixes`,过滤注入式 Prompt(典型如 "Write the title in the language the user wrote in...")被误用为 Session 标题;首条 user 输入经噪声剥离后命中豁免前缀则跳过、继续向后查找 title 候选,Level 2/3/4 回退不受影响 (#265); + ## [v0.5.2a1](https://github.com/ThreeFish-AI/coding-proxy/releases/tag/v0.5.2a1) - 2026-06-30 - fix(zhipu): 将 429/529 兜底退避抖动从 Full Jitter 改为 Equal Jitter(`[0, ceiling]` → `[ceiling/2, ceiling]`),修复 529 过载重试延迟非单调(实测 418.8→1857.7→961.6→3769.7ms)问题,重试延迟呈单调非递减指数形态;429 与 529 共用退避路径同步受益,server `retry-after` 优先级不变 (#263); diff --git a/pyproject.toml b/pyproject.toml index 728eb9a..cb17f6f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "coding-proxy" -version = "0.5.2a1" +version = "0.5.2a2" 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/uv.lock b/uv.lock index 2a75c27..dfea353 100644 --- a/uv.lock +++ b/uv.lock @@ -74,7 +74,7 @@ wheels = [ [[package]] name = "coding-proxy" -version = "0.5.2a1" +version = "0.5.2a2" source = { editable = "." } dependencies = [ { name = "aiosqlite" }, From fcaad7ae74624060b9f97f0f0d314df444bd009e Mon Sep 17 00:00:00 2001 From: Aurelius Huang Date: Thu, 2 Jul 2026 23:45:40 +0800 Subject: [PATCH 28/42] =?UTF-8?q?fix(session):=20=E6=A0=87=E9=A2=98?= =?UTF-8?q?=E8=B1=81=E5=85=8D=E5=89=8D=E7=BC=80=E6=89=A9=E5=B1=95=E8=87=B3?= =?UTF-8?q?=E5=85=A8=E5=B1=82=E7=BA=A7=E5=85=9C=E5=BA=95=E6=A0=87=E9=A2=98?= =?UTF-8?q?;=20(#267)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/.agents/issue.md | 31 +++++ src/coding/proxy/config/config.default.yaml | 8 +- src/coding/proxy/config/session_policy.py | 9 +- src/coding/proxy/routing/executor.py | 40 +++++-- tests/test_router_executor.py | 122 +++++++++++++++++++- 5 files changed, 193 insertions(+), 17 deletions(-) diff --git a/docs/.agents/issue.md b/docs/.agents/issue.md index 1d14cb6..82c76f1 100644 --- a/docs/.agents/issue.md +++ b/docs/.agents/issue.md @@ -4,6 +4,37 @@ --- +## Session 标题豁免前缀对 `[Session]` 兜底标题不生效(豁免仅 Level 1 生效) + +**问题描述** + +用户在 `session_policies.title_exempt_prefixes` 配置中加入 `"[Session]"`,期望过滤掉 `[Session] claude-opus-4-8` 这类不预期标题(此前已用同机制成功豁免 `Write the title in the language ...` 注入式 Prompt)。但配置后无效——`[Session] claude-opus-4-8` 仍被写入 Session 标题。 + +**表因** + +`[Session] claude-opus-4-8` **不是用户输入**,而是标题提取 Level 4 元数据兜底(`_extract_title_from_metadata`,`f"[Session] {request.model}"`)的产物。用户误以为它来自 user 文本,故尝试用 `title_exempt_prefixes` 豁免。 + +**根因** + +`_extract_session_title`(`routing/executor.py`)采用 4 层回退,但 `exempt_prefixes` **仅在 Level 1**(`_extract_title_from_user_text`)消费——这是原始设计:v0.5.2a1 #265 引入该配置时仅覆盖 L1,docstring 明写「Level 2/3/4 不参与豁免」。Level 2/3/4 的合成标题(`[Tool output]` / `[N Image]` / `[Tool call]` / `[Session]`)完全不查询豁免名单。故用户配的 `"[Session]"` 只进到 L1,对 L4 兜底标题毫无作用——这是「配了却不生效」的根本原因,**非配置错误**。 + +**处理方式** + +在 `_extract_session_title` 编排层引入 `_is_exempt` 闭包,对 L1/L2/L3/L4 每层候选统一做大小写敏感 `startswith` 拦截,命中则向下一层回退、全豁免返回空串。返回空串触发调用方 `if title:` 跳过写库,标题保持空,待后续请求带真实 user 文本时经 `update_empty_session_title` 回填。L1 内部「逐条消息跳过」语义不变,外层闭包作一致性兜底;并补入参空串前缀防御。**默认行为零影响**(不添加前缀则不豁免);**不**把合成前缀塞进默认 config,避免误杀正常 `[Tool output]` 标题。新增 8 个回归测试(含 mock recorder 验证写库跳过)。 + +**后续防范** + +- **新增合成兜底标题须纳入豁免链路**:未来若在 L2/L3/L4 增加新的 `f"[Xxx] ..."` 合成标题,编排层的 `_is_exempt` 自动覆盖(无需改子函数),但应在 `config.default.yaml` 注释里补充示例前缀,提示用户可豁免。 +- **空串前缀恒真陷阱**:`"".startswith` 恒真会豁免一切。配置层 field_validator、构造器、模块函数入参已三重过滤,任何新增的 startswith 豁免/绑定逻辑都须保留空串防御。 + +**同类问题影响与处理注意事项** + +- **`[Session] ` 触发条件**:请求无可提取的 user TEXT / TOOL_RESULT / IMAGE / tool_names,只剩 model 字段(典型如首条消息全是 `` 噪声、或仅 assistant 消息的续接请求)。排查此类标题应优先确认请求体内容,而非怀疑配置。 +- **豁免语义边界**:豁免 =「跳过该候选、继续向下一层回退」,非「替换为别的标题」。L4 是最后一层,被豁免只能落到空串;依赖标题触发 `_apply_title_based_policy` 供应商自动绑定的场景,空标题不会触发绑定(预期副作用,语义自洽)。 +- **不要把合成前缀写进默认配置**:`[Tool output]` 等对用户有信息量,默认豁免会造成正常工具结果摘要标题消失的回归。 + +--- + ## Zhipu 529 过载重试退避非单调(对齐 429 指数退避语义) **问题描述** diff --git a/src/coding/proxy/config/config.default.yaml b/src/coding/proxy/config/config.default.yaml index 52950e0..1504871 100644 --- a/src/coding/proxy/config/config.default.yaml +++ b/src/coding/proxy/config/config.default.yaml @@ -704,9 +704,11 @@ session_policies: title_vendor_bindings: - prefix: "# 目标" vendor: "zhipu" - # Session 标题豁免前缀:user 输入(经噪声剥离清洗后)以任一前缀开头则跳过, - # 继续向后查找 title 候选。用于过滤注入式 Prompt(如示例语言指令), - # 避免其被误用作 Session 标题。大小写敏感 startswith。 + # Session 标题豁免前缀:标题提取每一层(user 文本 / 工具结果 / 图片 / 元数据兜底) + # 产出的候选以任一前缀开头则跳过,继续向下一层回退。既用于过滤注入式 Prompt + # (如示例语言指令),也可豁免无信息量的合成兜底标题。大小写敏感 startswith。 + # 例:添加 "[Session]" 可让 [Session] 这类兜底标题不再写入, + # session 标题保持空,待后续真实输入经延迟补写回填。 # ⚠️ 自定义 yaml 中该字段为整体替换(loader 的 list 合并约定,非追加合并); # 写空列表即关闭豁免。加载时会自动 strip + 去空 + 去重。 title_exempt_prefixes: diff --git a/src/coding/proxy/config/session_policy.py b/src/coding/proxy/config/session_policy.py index 7217f73..82aca1a 100644 --- a/src/coding/proxy/config/session_policy.py +++ b/src/coding/proxy/config/session_policy.py @@ -84,9 +84,12 @@ class SessionPoliciesConfig(BaseModel): title_exempt_prefixes: list[str] = Field( default_factory=list, description=( - "Session 标题豁免前缀名单。当首条 user TEXT 输入(经噪声剥离清洗后)" - "以任一前缀开头时,跳过该输入、继续向后查找合适的 title 候选。" - "用于过滤注入式 Prompt(如示例语言指令),避免其被误用作 Session 标题。" + "Session 标题豁免前缀名单。标题提取的每一层(user TEXT 噪声剥离、" + "TOOL_RESULT 摘要、IMAGE 计数、元数据兜底)产出的候选,若以任一前缀" + "开头,则视为豁免、继续向下一层回退;全部层级均被豁免时返回空串," + "调用方据此跳过写库,session 标题保持空待后续真实输入回填。" + "既用于过滤注入式 Prompt(如示例语言指令),也可豁免无信息量的合成" + "兜底标题(如 [Session]、[Tool call]、[Tool output])。" "大小写敏感的 startswith 匹配,与 title_vendor_bindings 语义一致。" "加载时会自动 strip + 去空 + 去重;空字符串前缀会被丢弃" "(防空串 startswith 恒真导致全量误豁免)。" diff --git a/src/coding/proxy/routing/executor.py b/src/coding/proxy/routing/executor.py index 0569126..ea652ed 100644 --- a/src/coding/proxy/routing/executor.py +++ b/src/coding/proxy/routing/executor.py @@ -216,25 +216,45 @@ def _extract_session_title( """从规范化请求中提取 session 标题 — 多层级回退策略。 依次尝试: user TEXT 噪声剥离 → TOOL_RESULT 摘要 → IMAGE 计数 → 元数据兜底。 - 任意级别命中即返回,确保 Dashboard 尽可能展示有辨识度的标题。 - - ``exempt_prefixes`` 仅作用于 Level 1(用户直接输入的 TEXT);Level 2/3/4 - 为非用户直接输入的回退来源,不参与豁免。默认 ``None`` 时行为与历史一致。 + 任一层级产出候选且未被豁免即返回,确保 Dashboard 尽可能展示有辨识度的标题。 + + ``exempt_prefixes`` 对所有层级统一生效:任一层产出的候选标题若以任一前缀 + 开头,则视为豁免、继续向下一层回退。Level 1 内部仍保留「逐条消息跳过」 + 语义(清洗后文本以豁免前缀开头则跳过该条、继续查找下一条 user TEXT); + 本函数在编排层对每层最终候选再做一次 ``_is_exempt`` 拦截,使 Level 2/3/4 + 的合成标题(如 ``[Tool output]``、``[N Image(s)]``、``[Tool call]``、 + ``[Session]``)也能被用户显式豁免。全部层级均被豁免时返回空串,调用方 + ``if title:`` 据此跳过写库,session 标题保持空,待后续请求经 + ``update_empty_session_title`` 回填有意义标题。默认 ``None`` 时行为与历史一致。 """ + # 防御性过滤空串前缀:"" startswith 恒真会误豁免一切。入参虽经配置层与 + # 构造器两层归一化,模块级直接调用(如测试)仍可能传入 [""],此处兜底。 + prefixes = [p for p in (exempt_prefixes or []) if p] + + def _is_exempt(candidate: str) -> bool: + """候选标题是否命中任一豁免前缀(大小写敏感 startswith)。""" + return bool(prefixes) and any(candidate.startswith(p) for p in prefixes) + messages = request.messages - # Level 1 需要豁免前缀;单独调用以传入参数,避免污染 L2/L3 的统一签名。 - title = _extract_title_from_user_text(messages, exempt_prefixes) - if title: + # Level 1: 内部已对每条 user TEXT 做豁免跳过;外层 _is_exempt 对其最终候选 + # 再做一次拦截(正常路径下冗余,作为 _sanitize_user_text 一致性兜底)。 + title = _extract_title_from_user_text(messages, exempt_prefixes=prefixes) + if title and not _is_exempt(title): return title[:_SESSION_TITLE_MAX_LEN] + # Level 2 / Level 3: 候选为合成标题,统一经 _is_exempt 拦截后回退。 for extractor in ( _extract_title_from_tool_results, _extract_title_from_images, ): title = extractor(messages) - if title: + if title and not _is_exempt(title): return title[:_SESSION_TITLE_MAX_LEN] - # Level 4 依赖 request 元数据,签名不同 - return _extract_title_from_metadata(request)[:_SESSION_TITLE_MAX_LEN] + # Level 4: 签名不同(依赖 request 元数据)。被豁免则落到末尾返回空串, + # 使调用方 if title: 跳过写库、保留空标题待后续回填。 + title = _extract_title_from_metadata(request) + if title and not _is_exempt(title): + return title[:_SESSION_TITLE_MAX_LEN] + return "" def _build_semantic_rejection_diagnostic(body: dict[str, Any]) -> str: diff --git a/tests/test_router_executor.py b/tests/test_router_executor.py index 4077271..837edcc 100644 --- a/tests/test_router_executor.py +++ b/tests/test_router_executor.py @@ -9,7 +9,7 @@ from __future__ import annotations -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock, MagicMock, patch import httpx import pytest @@ -2483,6 +2483,97 @@ def test_exempt_does_not_affect_l2(self): # L2 不受豁免影响,正常提取并加 [Tool output] 前缀 assert title == "[Tool output] Write the title in the language ..." + # ── 豁免前缀对合成兜底标题(Level 2/3/4)统一生效 ── + + def test_exempt_l4_session_title_returns_empty(self): + """L4 兜底 [Session] 标题命中豁免前缀 → 回退耗尽 → 返回空串.""" + req = self._build_request( + [{"role": "user", "content": "纯噪声"}] + ) + # L1 噪声剥离为空 → L4 产出 "[Session] test" → 命中 "[Session]" 豁免 + assert _extract_session_title(req, exempt_prefixes=["[Session]"]) == "" + + def test_exempt_l4_tool_call_title_returns_empty(self): + """L4 兜底 [Tool call] 标题命中豁免前缀 → 返回空串.""" + req = build_canonical_request( + {"model": "test", "messages": [], "tools": [{"name": "Bash"}]}, {} + ) + assert _extract_session_title(req, exempt_prefixes=["[Tool call]"]) == "" + + def test_exempt_l4_unmatched_keeps_original(self): + """豁免前缀不命中 L4 实际标题时维持原行为(不误豁免).""" + req = build_canonical_request( + {"model": "test", "messages": [], "tools": [{"name": "Bash"}]}, {} + ) + # L4 产出 "[Tool call] Bash",豁免前缀 "[Session]" 不命中 → 原样返回 + assert ( + _extract_session_title(req, exempt_prefixes=["[Session]"]) + == "[Tool call] Bash" + ) + + def test_exempt_l2_tool_output_falls_through_to_l4(self): + """L2 合成标题 [Tool output] 命中豁免 → 跳过 L2、回退到 L4.""" + messages = [ + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "tu_1", + "content": [{"type": "text", "text": "文件内容摘要"}], + } + ], + }, + ] + req = self._build_request(messages) + # L1 无 user TEXT → L2 "[Tool output] 文件内容摘要" 命中豁免 → 跳过 → + # L3 无图 → L4 "[Session] test"(未命中豁免,正常返回) + assert ( + _extract_session_title(req, exempt_prefixes=["[Tool output]"]) + == "[Session] test" + ) + + def test_exempt_l3_image_falls_through_to_l4(self): + """L3 合成标题 [1 Image] 命中豁免 → 跳过 L3、回退到 L4.""" + messages = [ + { + "role": "user", + "content": [ + { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/png", + "data": "abc", + }, + } + ], + } + ] + req = self._build_request(messages) + assert ( + _extract_session_title(req, exempt_prefixes=["[1 Image]"]) + == "[Session] test" + ) + + def test_exempt_all_synthetic_layers_returns_empty(self): + """四层合成标题全部进豁免名单 → 全部被拦截 → 返回空串(永久空标题可接受).""" + req = self._build_request( + [{"role": "user", "content": "纯噪声"}] + ) + assert ( + _extract_session_title( + req, + exempt_prefixes=[ + "[Tool output]", + "[1 Image]", + "[Tool call]", + "[Session]", + ], + ) + == "" + ) + # ═══════════════════════════════════════════════════════════════════ # 多层级回退标题提取测试 @@ -3191,3 +3282,32 @@ def test_executor_filters_empty_string_prefix_at_construction(self): executor = _executor(title_exempt_prefixes=["", " ", "\t"]) assert executor._title_exempt_prefixes == [] assert executor._extract_session_title(req) == "正常标题" + + def test_executor_exempt_l4_returns_empty(self): + """executor 持有 [Session] 豁免 → L4 兜底标题被豁免 → 实例方法返回空串.""" + req = self._build_request( + [{"role": "user", "content": "纯噪声"}] + ) + executor = _executor(title_exempt_prefixes=["[Session]"]) + # L1 噪声剥离为空 → L4 "[Session] test" 命中豁免 → 返回 "" + assert executor._extract_session_title(req) == "" + + @pytest.mark.asyncio + async def test_execute_skips_title_write_when_l4_exempt(self): + """端到端: L4 兜底 [Session] 标题命中豁免 → 标题为空 → set_session_title 不被调用.""" + recorder = UsageRecorder() + executor = _executor( + recorder=recorder, + session_mgr=_stub_session_manager(is_new=True), + title_exempt_prefixes=["[Session]"], + ) + body = { + "model": "claude-opus-4-8", + "metadata": {"user_id": "session-l4"}, + "messages": [ + {"role": "user", "content": "纯噪声"} + ], + } + with patch.object(recorder, "set_session_title", new=AsyncMock()) as spy: + await executor.execute_message(body, {}) + spy.assert_not_called() From cfc3884bc65b039551dd6fac9c4b6a71ce88d7d1 Mon Sep 17 00:00:00 2001 From: Aurelius Huang Date: Thu, 2 Jul 2026 23:47:42 +0800 Subject: [PATCH 29/42] =?UTF-8?q?build(version):=20=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E5=8F=B7=E5=8D=87=E7=BA=A7=E8=87=B3=200.5.2a3;=20(#268)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 4 ++++ pyproject.toml | 2 +- uv.lock | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4949760..4e4ac4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ ## [Unreleased] +## [v0.5.2a3](https://github.com/ThreeFish-AI/coding-proxy/releases/tag/v0.5.2a3) - 2026-07-02 + +- fix(session): 标题豁免前缀扩展至全层级兜底标题,修复 `[Session] ` 等 Level 4 合成标题无法豁免(原 `title_exempt_prefixes` 仅 Level 1 生效);改为在 `_extract_session_title` 编排层对 L1/L2/L3/L4 候选统一拦截,命中回退、全豁免返回空串待后续真实输入回填,默认行为零影响 (#267); + ## [v0.5.2a2](https://github.com/ThreeFish-AI/coding-proxy/releases/tag/v0.5.2a2) - 2026-07-02 - feat(session): 新增 Session 标题豁免前缀配置 `title_exempt_prefixes`,过滤注入式 Prompt(典型如 "Write the title in the language the user wrote in...")被误用为 Session 标题;首条 user 输入经噪声剥离后命中豁免前缀则跳过、继续向后查找 title 候选,Level 2/3/4 回退不受影响 (#265); diff --git a/pyproject.toml b/pyproject.toml index cb17f6f..3ddf155 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "coding-proxy" -version = "0.5.2a2" +version = "0.5.2a3" 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/uv.lock b/uv.lock index dfea353..8f1b9dd 100644 --- a/uv.lock +++ b/uv.lock @@ -74,7 +74,7 @@ wheels = [ [[package]] name = "coding-proxy" -version = "0.5.2a2" +version = "0.5.2a3" source = { editable = "." } dependencies = [ { name = "aiosqlite" }, From c27c6355fe7b10516368e7ff9076e7adf569045d Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Fri, 3 Jul 2026 09:52:30 +0800 Subject: [PATCH 30/42] =?UTF-8?q?feat(session):=20=E6=89=A9=E5=85=85?= =?UTF-8?q?=E6=A0=87=E9=A2=98=E8=B1=81=E5=85=8D=E5=89=8D=E7=BC=80=EF=BC=8C?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=20[Session]=20=E8=BF=87=E6=BB=A4=E6=B3=A8?= =?UTF-8?q?=E5=85=A5=E5=BC=8F=20Prompt=20=E5=80=99=E9=80=89;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 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 --- src/coding/proxy/config/config.default.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/src/coding/proxy/config/config.default.yaml b/src/coding/proxy/config/config.default.yaml index 1504871..a546522 100644 --- a/src/coding/proxy/config/config.default.yaml +++ b/src/coding/proxy/config/config.default.yaml @@ -713,3 +713,4 @@ session_policies: # 写空列表即关闭豁免。加载时会自动 strip + 去空 + 去重。 title_exempt_prefixes: - "Write the title in the language" + - "[Session]" From 7f62f01aa5431e4cc51fa2f7f6446809e71f16e5 Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Fri, 3 Jul 2026 16:28:40 +0800 Subject: [PATCH 31/42] =?UTF-8?q?chore(pricing):=20=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E6=99=BA=E8=B0=B1=20GLM-5.2=20=E6=A8=A1=E5=9E=8B=E5=AE=9A?= =?UTF-8?q?=E4=BB=B7=E9=85=8D=E7=BD=AE;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 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 --- src/coding/proxy/config/config.default.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/coding/proxy/config/config.default.yaml b/src/coding/proxy/config/config.default.yaml index a546522..e1bd596 100644 --- a/src/coding/proxy/config/config.default.yaml +++ b/src/coding/proxy/config/config.default.yaml @@ -534,9 +534,9 @@ pricing: # ── 智谱 GLM ── - vendor: zhipu model: glm-5.2 # 待区分长短上下文定价 - input_cost_per_mtok: ¥6.00 - output_cost_per_mtok: ¥24.00 - cache_read_cost_per_mtok: ¥1.30 + input_cost_per_mtok: ¥8.00 + output_cost_per_mtok: ¥28.00 + cache_read_cost_per_mtok: ¥2.00 - vendor: zhipu model: glm-5.1 # 待区分长短上下文定价 input_cost_per_mtok: ¥6.00 From 18f25218cf9ebf7890c74fc4cf65f0847789de25 Mon Sep 17 00:00:00 2001 From: Aurelius Huang Date: Fri, 3 Jul 2026 22:32:11 +0800 Subject: [PATCH 32/42] =?UTF-8?q?feat(dashboard):=20=E4=BE=9B=E5=BA=94?= =?UTF-8?q?=E5=95=86=E9=98=9F=E5=88=97=E6=94=AF=E6=8C=81=E6=8B=96=E6=8B=BD?= =?UTF-8?q?=E8=B0=83=E6=95=B4=E4=BC=98=E5=85=88=E7=BA=A7=E4=B8=94=E4=B8=8D?= =?UTF-8?q?=E9=87=8D=E7=BD=AE=E5=BD=93=E6=97=A5=E9=85=8D=E9=A2=9D;=20(#269?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 PUT /api/tier-order 纯重排序端点:复用 router.reorder_tiers,不触碰熔断器/配额守卫/rate limit,与 /api/reset 解耦 - 供应商状态列表基于 HTML5 DnD 在 #vendor-list 上做事件委托,渲染拖拽手柄与 draggable 属性 - 拖拽完成后乐观重排 DOM 并 PUT 新顺序;失败回滚至服务端真实顺序;拖拽/POST 未决期间跳过重渲染 - 新增 7 个 /api/tier-order 测试,覆盖成功、校验失败及配额守卫/熔断器保留断言 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) --- src/coding/proxy/server/dashboard.py | 128 ++++++++++++++++++++++++++- src/coding/proxy/server/routes.py | 48 ++++++++++ tests/test_app_routes.py | 122 +++++++++++++++++++++++++ 3 files changed, 297 insertions(+), 1 deletion(-) diff --git a/src/coding/proxy/server/dashboard.py b/src/coding/proxy/server/dashboard.py index 75c99da..6ce7f2c 100644 --- a/src/coding/proxy/server/dashboard.py +++ b/src/coding/proxy/server/dashboard.py @@ -411,6 +411,20 @@ def line(x0: float, y0: float, x1: float, y1: float) -> None: height: 100%; border-radius: 2px; transition: width .6s cubic-bezier(.4,0,.2,1); } + /* ── 供应商状态:拖拽排序 ── */ + .vendor-item[draggable="true"] { cursor: grab; } + .vendor-item[draggable="true"]:active { cursor: grabbing; } + .vendor-item.dragging { opacity: .4; } + .drag-handle { + display: flex; align-items: center; flex-shrink: 0; + color: var(--text-tertiary); font-size: 15px; line-height: 1; + cursor: grab; user-select: none; padding: 0 2px; + opacity: 0; transition: opacity .2s ease; + } + .vendor-item:hover .drag-handle { opacity: .55; } + .drag-handle:hover { opacity: 1 !important; color: var(--text-secondary); } + .vendor-item.drag-over-before { box-shadow: inset 0 2px 0 0 var(--accent-blue); } + .vendor-item.drag-over-after { box-shadow: inset 0 -2px 0 0 var(--accent-blue); } /* ── 故障转移表 ── */ .ft-table-wrap { overflow-x: auto; } table { width: 100%; border-collapse: collapse; } @@ -1332,12 +1346,15 @@ def line(x0: float, y0: float, x1: float, y1: float) -> None: } function updateVendorStatus(status) { + // 拖拽进行中 / 重排序 POST 未决时跳过重渲染,避免抢占正在操作的 DOM + if (_tierDrag.active || _tierDrag.inFlight) return; const tiers = status.tiers || []; const list = document.getElementById('vendor-list'); if (!tiers.length) { list.innerHTML = '
🔌
无供应商数据
'; return; } + const draggable = tiers.length >= 2; list.innerHTML = tiers.map(tier => { const cb = tier.circuit_breaker || {}; const cbClass = cbStateClass(cb.state); @@ -1351,8 +1368,12 @@ def line(x0: float, y0: float, x1: float, y1: float) -> None: const rlInfo = tier.rate_limit || {}; const rlHtml = rlInfo.limited ? `限速中` : ''; - return `
+ const dragAttrs = ` data-vendor="${tier.name}"` + (draggable ? ` draggable="true"` : ''); + const handle = draggable ? `
` : ''; + + return `
+ ${handle}
${initial}
${tier.name}
@@ -1365,6 +1386,110 @@ def line(x0: float, y0: float, x1: float, y1: float) -> None: }).join(''); } +// ── 供应商状态:拖拽调整优先级(运行时重排,不重置配额) ──── +const _tierDrag = { active: false, srcName: null, inFlight: false }; + +function _tierDragClearIndicators() { + document.querySelectorAll('#vendor-list .drag-over-before, #vendor-list .drag-over-after') + .forEach(function(el) { el.classList.remove('drag-over-before', 'drag-over-after'); }); +} + +function _tierDragPosition(item, clientY) { + // 鼠标相对目标行中点的位置 → 插入到上方或下方 + const rect = item.getBoundingClientRect(); + return clientY < rect.top + rect.height / 2 ? 'before' : 'after'; +} + +function initTierDrag() { + // 事件委托绑定在静态容器 #vendor-list 上一次;子节点重渲染后仍生效 + const list = document.getElementById('vendor-list'); + if (!list || list.dataset.dndBound === '1') return; + list.dataset.dndBound = '1'; + + list.addEventListener('dragstart', function(e) { + const item = e.target && e.target.closest && e.target.closest('.vendor-item'); + if (!item || !item.draggable) return; + _tierDrag.active = true; + _tierDrag.srcName = item.dataset.vendor; + item.classList.add('dragging'); + if (e.dataTransfer) { + e.dataTransfer.effectAllowed = 'move'; + try { e.dataTransfer.setData('text/plain', _tierDrag.srcName || ''); } catch (_) {} + } + }); + + list.addEventListener('dragover', function(e) { + if (!_tierDrag.active) return; + e.preventDefault(); // 允许 drop(无论是否命中某一行) + if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'; + const item = e.target && e.target.closest && e.target.closest('.vendor-item'); + _tierDragClearIndicators(); + if (!item || item.dataset.vendor === _tierDrag.srcName) return; + item.classList.add(_tierDragPosition(item, e.clientY) === 'before' ? 'drag-over-before' : 'drag-over-after'); + }); + + list.addEventListener('drop', function(e) { + if (!_tierDrag.active) return; + e.preventDefault(); + const listEl = document.getElementById('vendor-list'); + const dragged = listEl.querySelector('.vendor-item.dragging'); + if (!dragged) return; + let target = e.target && e.target.closest && e.target.closest('.vendor-item'); + if (!target) { + // 落在容器空白处 → 移到末尾 + if (listEl.lastElementChild !== dragged) listEl.appendChild(dragged); + } else if (target !== dragged) { + const ref = _tierDragPosition(target, e.clientY) === 'before' ? target : target.nextElementSibling; + if (ref !== dragged) listEl.insertBefore(dragged, ref); + } + _tierDragClearIndicators(); + const order = Array.from(listEl.children) + .filter(function(el) { return el.classList.contains('vendor-item'); }) + .map(function(el) { return el.dataset.vendor; }) + .filter(Boolean); + persistTierOrder(order); + }); + + list.addEventListener('dragend', function() { + _tierDrag.active = false; + _tierDrag.srcName = null; + _tierDragClearIndicators(); + document.querySelectorAll('#vendor-list .dragging') + .forEach(function(el) { el.classList.remove('dragging'); }); + }); +} + +function persistTierOrder(names) { + if (_tierDrag.inFlight) return; // 防并发:上一次未决则忽略 + if (!Array.isArray(names) || names.length < 2) return; + _tierDrag.inFlight = true; + fetch('/api/tier-order', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ vendors: names }) + }).then(function(res) { + _tierDrag.inFlight = false; // 先清守卫,允许随后的 refreshOverview 重渲染 + if (res.ok) { + // 服务端已更新顺序;刷新以同步请求趋势图等的排序 + if (!_tierDrag.active) refreshOverview().catch(function() {}); + } else { + console.error('tier-order rejected:', res.status); + _tierRevertToList(); + } + }).catch(function(e) { + _tierDrag.inFlight = false; + console.error('tier-order failed:', e); + _tierRevertToList(); + }); +} + +function _tierRevertToList() { + // 回滚到服务端真实顺序(PUT 失败 → 服务端顺序未变) + fetchJSON('/api/status').then(function(status) { + updateVendorStatus(status); + }).catch(function() {}); +} + // ── Model Calling 实时状态 ──────────────────────────────── function updateModelCalling(status) { var wrap = document.getElementById('model-calling-wrap'); @@ -2200,6 +2325,7 @@ def line(x0: float, y0: float, x1: float, y1: float) -> None: fetchJSON('/api/dashboard/summary?days=7').then(function(s) { if (s && s.version) document.getElementById('version-badge').textContent = 'v' + s.version; }).catch(function(){}); + initTierDrag(); // 绑定供应商列表拖拽(事件委托,幂等) refresh(); // 仅加载初始页签的数据 setInterval(refresh, 600000); // 每 10 分钟刷新当前页签 if (initial === 'overview') startModelCallingPoll(); diff --git a/src/coding/proxy/server/routes.py b/src/coding/proxy/server/routes.py index 8b47fd0..6b91651 100644 --- a/src/coding/proxy/server/routes.py +++ b/src/coding/proxy/server/routes.py @@ -386,6 +386,53 @@ async def reset_circuit(request: Request) -> Response: ) +def register_tier_order_route(app: Any, router: Any) -> None: + """注册运行时 N-tier 链路顺序调整路由(纯重排序,不重置熔断器/配额守卫).""" + + @app.put("/api/tier-order") + async def update_tier_order(request: Request) -> Response: + """替换整个 N-tier 链路顺序. + + JSON body: ``{"vendors": ["v1", "v2", ...]}``(需覆盖所有当前启用的 vendor)。 + 与 ``/api/reset`` 的重排序语义一致,但**不会**重置熔断器/配额守卫/rate limit, + 以保留当日用量统计(供 Web 拖拽调整优先级使用)。 + """ + try: + body = await request.json() + except Exception: + return json_error_response( + 400, error_type="invalid_request_error", message="body must be JSON" + ) + if not isinstance(body, dict): + return json_error_response( + 400, + error_type="invalid_request_error", + message="body must be a JSON object", + ) + raw = body.get("vendors") + if not isinstance(raw, list) or not raw: + return json_error_response( + 400, + error_type="invalid_request_error", + message="requires non-empty 'vendors' list", + ) + vendor_names = [str(v) for v in raw] + try: + router.reorder_tiers(vendor_names) + except ValueError as exc: + return json_error_response( + 400, error_type="invalid_request_error", message=str(exc) + ) + return Response( + content=json.dumps( + {"ok": True, "tier_order": router.get_vendor_names()}, + ensure_ascii=False, + ).encode(), + status_code=200, + media_type="application/json", + ) + + def register_session_vendor_routes(app: Any, router: Any) -> None: """注册 Session-Vendor 运行时绑定路由.""" @@ -518,6 +565,7 @@ def register_all_routes( register_concurrency_route(app, router) register_copilot_routes(app, router) register_admin_routes(app, router) + register_tier_order_route(app, router) register_session_vendor_routes(app, router) if reauth_coordinator: register_reauth_routes(app, reauth_coordinator) diff --git a/tests/test_app_routes.py b/tests/test_app_routes.py index 39aabe6..5e61a27 100644 --- a/tests/test_app_routes.py +++ b/tests/test_app_routes.py @@ -1091,3 +1091,125 @@ def test_reorder_tiers_shared_reference(): # Executor 的列表也改变了(因为是同一个对象) assert [t.name for t in executor_tiers] == ["c", "a", "b"] assert router.get_vendor_names() == ["c", "a", "b"] + + +# ── /api/tier-order 纯重排序测试(不重置配额/熔断器/rate limit)────────────── + + +def test_tier_order_reorder_success(): + """PUT /api/tier-order → 精确替换链路顺序,返回最新 tier_order.""" + app = _make_reorder_app() + with TestClient(app) as client: + resp = client.put( + "/api/tier-order", json={"vendors": ["copilot", "anthropic", "zhipu"]} + ) + assert resp.status_code == 200 + data = resp.json() + assert data["ok"] is True + assert data["tier_order"] == ["copilot", "anthropic", "zhipu"] + assert app.state.router.get_vendor_names() == ["copilot", "anthropic", "zhipu"] + + +def test_tier_order_unknown_vendor_returns_400(): + """未知 vendor 名称 → 400.""" + app = _make_reorder_app() + with TestClient(app) as client: + resp = client.put( + "/api/tier-order", + json={"vendors": ["nonexist", "anthropic", "zhipu", "copilot"]}, + ) + assert resp.status_code == 400 + assert "未知 vendor" in resp.json()["error"]["message"] + + +def test_tier_order_duplicate_returns_400(): + """重复 vendor 名称 → 400.""" + app = _make_reorder_app() + with TestClient(app) as client: + resp = client.put( + "/api/tier-order", + json={"vendors": ["anthropic", "anthropic", "zhipu", "copilot"]}, + ) + assert resp.status_code == 400 + assert "重复" in resp.json()["error"]["message"] + + +def test_tier_order_incomplete_returns_400(): + """不完整的 vendor 列表(缺少现有 tier)→ 400.""" + app = _make_reorder_app() + with TestClient(app) as client: + resp = client.put("/api/tier-order", json={"vendors": ["anthropic", "zhipu"]}) + assert resp.status_code == 400 + assert "缺少 vendor" in resp.json()["error"]["message"] + + +def test_tier_order_invalid_body_returns_400(): + """非法 body(非 JSON / 缺 vendors / 非列表 / 空列表)→ 400.""" + app = _make_reorder_app() + with TestClient(app) as client: + # 非 JSON + resp = client.put( + "/api/tier-order", + content=b"not-json", + headers={"content-type": "application/json"}, + ) + assert resp.status_code == 400 + # body 非 dict + resp = client.put("/api/tier-order", json=["anthropic", "zhipu", "copilot"]) + assert resp.status_code == 400 + # 缺 vendors + resp = client.put("/api/tier-order", json={"foo": "bar"}) + assert resp.status_code == 400 + # vendors 非列表 + resp = client.put("/api/tier-order", json={"vendors": "zhipu"}) + assert resp.status_code == 400 + # vendors 空列表 + resp = client.put("/api/tier-order", json={"vendors": []}) + assert resp.status_code == 400 + + +def test_tier_order_preserves_quota_guard(): + """核心:重排序不重置配额守卫的滑动窗口用量(保留当日统计).""" + app = _make_reorder_app() + router = app.state.router + # 选用首个 tier,手动启用其配额守卫并写入用量 + tier = router.tiers[0] + qg = tier.quota_guard + assert qg is not None + qg._enabled = True + qg._budget = 100_000 + qg.record_usage(40_000) + before = qg.get_info() + assert before["window_usage_tokens"] == 40_000 + assert before["usage_percent"] == 40.0 + + with TestClient(app) as client: + resp = client.put( + "/api/tier-order", json={"vendors": ["zhipu", "copilot", "anthropic"]} + ) + assert resp.status_code == 200 + + # reorder_tiers 仅重排引用、不重建对象 → 同一 QuotaGuard 实例用量保持 + after = qg.get_info() + assert after["window_usage_tokens"] == 40_000 + assert after["usage_percent"] == 40.0 + + +def test_tier_order_preserves_circuit_breaker_and_rate_limit(): + """重排序不重置熔断器与 rate limit(与 /api/reset 行为相反).""" + app = _make_reorder_app() + router = app.state.router + target = router.tiers[0] + target.record_failure(retry_after_seconds=300) + target._rate_limit_deadline = 999_999.0 + assert not target.can_execute() + + with TestClient(app) as client: + resp = client.put( + "/api/tier-order", json={"vendors": ["zhipu", "anthropic", "copilot"]} + ) + assert resp.status_code == 200 + + same = next(t for t in router.tiers if t.name == target.name) + assert not same.can_execute() # 仍熔断 + assert same.is_rate_limited # 仍限速 From c8606ebbdb4750f206d3181697db1fa7ade9dea0 Mon Sep 17 00:00:00 2001 From: Aurelius Huang Date: Fri, 3 Jul 2026 22:43:47 +0800 Subject: [PATCH 33/42] =?UTF-8?q?build(version):=20=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E5=8F=B7=E5=8D=87=E7=BA=A7=E8=87=B3=200.5.2a4;=20(#270)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- uv.lock | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e4ac4d..8fa5494 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ ## [Unreleased] +## [v0.5.2a4](https://github.com/ThreeFish-AI/coding-proxy/releases/tag/v0.5.2a4) - 2026-07-03 + +- feat(dashboard): 供应商状态模块支持拖拽调整供应商优先级,新增纯重排序端点 `PUT /api/tier-order`(复用 `router.reorder_tiers`,不重置熔断器/配额守卫/rate limit),拖拽后配额与用量统计保持当日值不变;与 `reset -v` 同源、运行时内存生效,失败自动回滚至服务端真实顺序 (#269); +- feat(session): 扩充标题豁免前缀,新增 `[Session]` 过滤注入式 Prompt 候选; +- chore(pricing): 更新智谱 GLM-5.2 模型定价配置; + ## [v0.5.2a3](https://github.com/ThreeFish-AI/coding-proxy/releases/tag/v0.5.2a3) - 2026-07-02 - fix(session): 标题豁免前缀扩展至全层级兜底标题,修复 `[Session] ` 等 Level 4 合成标题无法豁免(原 `title_exempt_prefixes` 仅 Level 1 生效);改为在 `_extract_session_title` 编排层对 L1/L2/L3/L4 候选统一拦截,命中回退、全豁免返回空串待后续真实输入回填,默认行为零影响 (#267); diff --git a/pyproject.toml b/pyproject.toml index 3ddf155..fdf117b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "coding-proxy" -version = "0.5.2a3" +version = "0.5.2a4" 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/uv.lock b/uv.lock index 8f1b9dd..6b84a5e 100644 --- a/uv.lock +++ b/uv.lock @@ -74,7 +74,7 @@ wheels = [ [[package]] name = "coding-proxy" -version = "0.5.2a3" +version = "0.5.2a4" source = { editable = "." } dependencies = [ { name = "aiosqlite" }, From 4ce13568c66b35b1dfac4c3dac830807ac88a5d8 Mon Sep 17 00:00:00 2001 From: Aurelius Huang Date: Sat, 4 Jul 2026 00:14:10 +0800 Subject: [PATCH 34/42] =?UTF-8?q?fix(dashboard):=20=E4=BE=9B=E5=BA=94?= =?UTF-8?q?=E5=95=86=E9=98=9F=E5=88=97=E6=8B=96=E6=8B=BD=E6=94=B9=E7=94=A8?= =?UTF-8?q?=20Pointer=20Events=EF=BC=8C=E4=BF=AE=E5=A4=8D=E6=97=A0?= =?UTF-8?q?=E6=B3=95=E6=8B=96=E5=8A=A8=E5=B9=B6=E5=AE=9E=E6=9C=BA=E9=AA=8C?= =?UTF-8?q?=E8=AF=81=20cc=20=E4=BC=98=E5=85=88=E7=BA=A7=E5=AE=9E=E6=97=B6?= =?UTF-8?q?=E7=94=9F=E6=95=88;=20(#271)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 根因:原生 HTML5 DnD 机制脆弱(触屏不支持、跨浏览器易「拿不起来」)。经活体探针确认前端设置(draggable/data-vendor/事件委托/PUT 端点)与后端(单一全局 router 切片赋值、executor 共享引用)均无缺陷,#269 仅补后端单测、拖拽从未做浏览器验证 —— 唯机制本身不可靠。 - 交互:以 #vendor-list 上的 pointerdown/move/up/cancel 事件委托替换 dragstart/dragover/drop/dragend;手柄发起 + setPointerCapture + 4px 阈值 + 乐观实时重排;复用既有 _tierDrag 状态机、persistTierOrder(PUT /api/tier-order) 与失败回滚,顺序未变则跳过 PUT。 - 样式:手柄增加 touch-action:none(触屏可拖),移除失效的 [draggable] 与 drag-over-* 规则。 - 加固:/dashboard 响应加 Cache-Control:no-cache,避免浏览器缓存旧内联脚本掩盖前端修复。 - 测试:新增前端守卫断言(交付含 pointer 事件 + PUT、不含原生 draggable),补齐 #269 缺失的前端验证;test_app_routes.py 42 项全绿。 - 实机验证:独立端口拖拽使 DOM 与 /api/status 顺序同步翻转、PUT 200、无控制台报错,证明重排实时改变 cc 运行时优先级。 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) --- src/coding/proxy/server/dashboard.py | 168 ++++++++++++++++----------- tests/test_app_routes.py | 22 ++++ 2 files changed, 125 insertions(+), 65 deletions(-) diff --git a/src/coding/proxy/server/dashboard.py b/src/coding/proxy/server/dashboard.py index 6ce7f2c..c53ab77 100644 --- a/src/coding/proxy/server/dashboard.py +++ b/src/coding/proxy/server/dashboard.py @@ -411,20 +411,17 @@ def line(x0: float, y0: float, x1: float, y1: float) -> None: height: 100%; border-radius: 2px; transition: width .6s cubic-bezier(.4,0,.2,1); } - /* ── 供应商状态:拖拽排序 ── */ - .vendor-item[draggable="true"] { cursor: grab; } - .vendor-item[draggable="true"]:active { cursor: grabbing; } + /* ── 供应商状态:拖拽排序(Pointer Events,手柄发起) ── */ .vendor-item.dragging { opacity: .4; } .drag-handle { display: flex; align-items: center; flex-shrink: 0; color: var(--text-tertiary); font-size: 15px; line-height: 1; - cursor: grab; user-select: none; padding: 0 2px; + cursor: grab; user-select: none; touch-action: none; padding: 0 2px; opacity: 0; transition: opacity .2s ease; } + .drag-handle:active { cursor: grabbing; } .vendor-item:hover .drag-handle { opacity: .55; } .drag-handle:hover { opacity: 1 !important; color: var(--text-secondary); } - .vendor-item.drag-over-before { box-shadow: inset 0 2px 0 0 var(--accent-blue); } - .vendor-item.drag-over-after { box-shadow: inset 0 -2px 0 0 var(--accent-blue); } /* ── 故障转移表 ── */ .ft-table-wrap { overflow-x: auto; } table { width: 100%; border-collapse: collapse; } @@ -1354,7 +1351,7 @@ def line(x0: float, y0: float, x1: float, y1: float) -> None: list.innerHTML = '
🔌
无供应商数据
'; return; } - const draggable = tiers.length >= 2; + const reorderable = tiers.length >= 2; list.innerHTML = tiers.map(tier => { const cb = tier.circuit_breaker || {}; const cbClass = cbStateClass(cb.state); @@ -1368,8 +1365,9 @@ def line(x0: float, y0: float, x1: float, y1: float) -> None: const rlInfo = tier.rate_limit || {}; const rlHtml = rlInfo.limited ? `限速中` : ''; - const dragAttrs = ` data-vendor="${tier.name}"` + (draggable ? ` draggable="true"` : ''); - const handle = draggable ? `
` : ''; + // Pointer Events 重排:仅需 data-vendor 定位;手柄作为拖拽发起点(无原生 draggable) + const dragAttrs = ` data-vendor="${tier.name}"`; + const handle = reorderable ? `
` : ''; return `
@@ -1386,76 +1384,111 @@ def line(x0: float, y0: float, x1: float, y1: float) -> None: }).join(''); } -// ── 供应商状态:拖拽调整优先级(运行时重排,不重置配额) ──── -const _tierDrag = { active: false, srcName: null, inFlight: false }; +// ── 供应商状态:拖拽调整优先级(Pointer Events 重排,运行时生效,不重置配额) ──── +// 采用 Pointer Events 而非原生 HTML5 DnD:后者触屏不支持、跨浏览器易「拿不起来」, +// 是业界公认脆弱的重排序方案(故 SortableJS / dnd-kit 等均改用指针事件)。 +const _tierDrag = { + active: false, // 已越过阈值、进入拖拽 + srcName: null, // 被拖拽 vendor 名 + srcEl: null, // 被拖拽行 + pointerId: null, // 捕获的指针 ID + startY: 0, // pointerdown 起点 Y + origOrder: null, // 拖拽开始时的顺序快照(无变化则跳过 PUT) + inFlight: false, // PUT 未决 +}; +const _TIER_DRAG_THRESHOLD = 4; // px,越过才判定为拖拽(否则视作点击) -function _tierDragClearIndicators() { - document.querySelectorAll('#vendor-list .drag-over-before, #vendor-list .drag-over-after') - .forEach(function(el) { el.classList.remove('drag-over-before', 'drag-over-after'); }); +function _tierCurrentOrder(listEl) { + return Array.from(listEl.querySelectorAll('.vendor-item')) + .map(function(el) { return el.dataset.vendor; }) + .filter(Boolean); } -function _tierDragPosition(item, clientY) { - // 鼠标相对目标行中点的位置 → 插入到上方或下方 - const rect = item.getBoundingClientRect(); - return clientY < rect.top + rect.height / 2 ? 'before' : 'after'; +// 依据指针 Y 与各兄弟行中点比较,即时(乐观)重排 DOM +function _tierDragMoveTo(listEl, clientY) { + const dragged = _tierDrag.srcEl; + if (!dragged) return; + const siblings = Array.from(listEl.querySelectorAll('.vendor-item')) + .filter(function(el) { return el !== dragged; }); + for (var i = 0; i < siblings.length; i++) { + const rect = siblings[i].getBoundingClientRect(); + if (clientY < rect.top + rect.height / 2) { + if (dragged !== siblings[i] && dragged.nextElementSibling !== siblings[i]) { + listEl.insertBefore(dragged, siblings[i]); + } + return; + } + } + if (listEl.lastElementChild !== dragged) listEl.appendChild(dragged); // 落到末尾 +} + +function _tierDragReset() { + if (_tierDrag.srcEl) { + if (_tierDrag.pointerId != null) { + try { _tierDrag.srcEl.releasePointerCapture(_tierDrag.pointerId); } catch (_) {} + } + _tierDrag.srcEl.classList.remove('dragging'); + } + _tierDrag.active = false; + _tierDrag.srcName = null; + _tierDrag.srcEl = null; + _tierDrag.pointerId = null; + _tierDrag.startY = 0; + _tierDrag.origOrder = null; } function initTierDrag() { - // 事件委托绑定在静态容器 #vendor-list 上一次;子节点重渲染后仍生效 + // 指针事件委托绑定在静态容器 #vendor-list 上一次;子节点重渲染后仍生效 const list = document.getElementById('vendor-list'); if (!list || list.dataset.dndBound === '1') return; list.dataset.dndBound = '1'; - list.addEventListener('dragstart', function(e) { - const item = e.target && e.target.closest && e.target.closest('.vendor-item'); - if (!item || !item.draggable) return; - _tierDrag.active = true; + list.addEventListener('pointerdown', function(e) { + if (e.button != null && e.button !== 0) return; // 仅主指针/左键 + const handle = e.target && e.target.closest && e.target.closest('.drag-handle'); + if (!handle) return; // 仅从手柄发起,避免误触与文本选择 + const item = handle.closest('.vendor-item'); + if (!item || !item.dataset.vendor) return; + if (list.querySelectorAll('.vendor-item').length < 2) return; + _tierDrag.srcEl = item; _tierDrag.srcName = item.dataset.vendor; - item.classList.add('dragging'); - if (e.dataTransfer) { - e.dataTransfer.effectAllowed = 'move'; - try { e.dataTransfer.setData('text/plain', _tierDrag.srcName || ''); } catch (_) {} - } - }); - - list.addEventListener('dragover', function(e) { - if (!_tierDrag.active) return; - e.preventDefault(); // 允许 drop(无论是否命中某一行) - if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'; - const item = e.target && e.target.closest && e.target.closest('.vendor-item'); - _tierDragClearIndicators(); - if (!item || item.dataset.vendor === _tierDrag.srcName) return; - item.classList.add(_tierDragPosition(item, e.clientY) === 'before' ? 'drag-over-before' : 'drag-over-after'); + _tierDrag.pointerId = e.pointerId; + _tierDrag.startY = e.clientY; + try { item.setPointerCapture(e.pointerId); } catch (_) {} // 捕获后 DOM 重排不丢事件 + e.preventDefault(); // 阻止文本选择 }); - list.addEventListener('drop', function(e) { - if (!_tierDrag.active) return; + list.addEventListener('pointermove', function(e) { + if (!_tierDrag.srcEl || e.pointerId !== _tierDrag.pointerId) return; + if (!_tierDrag.active) { + if (Math.abs(e.clientY - _tierDrag.startY) < _TIER_DRAG_THRESHOLD) return; + _tierDrag.active = true; // 越阈值 → 正式进入拖拽 + _tierDrag.origOrder = _tierCurrentOrder(list); + _tierDrag.srcEl.classList.add('dragging'); + } e.preventDefault(); - const listEl = document.getElementById('vendor-list'); - const dragged = listEl.querySelector('.vendor-item.dragging'); - if (!dragged) return; - let target = e.target && e.target.closest && e.target.closest('.vendor-item'); - if (!target) { - // 落在容器空白处 → 移到末尾 - if (listEl.lastElementChild !== dragged) listEl.appendChild(dragged); - } else if (target !== dragged) { - const ref = _tierDragPosition(target, e.clientY) === 'before' ? target : target.nextElementSibling; - if (ref !== dragged) listEl.insertBefore(dragged, ref); - } - _tierDragClearIndicators(); - const order = Array.from(listEl.children) - .filter(function(el) { return el.classList.contains('vendor-item'); }) - .map(function(el) { return el.dataset.vendor; }) - .filter(Boolean); - persistTierOrder(order); + _tierDragMoveTo(list, e.clientY); }); - list.addEventListener('dragend', function() { - _tierDrag.active = false; - _tierDrag.srcName = null; - _tierDragClearIndicators(); - document.querySelectorAll('#vendor-list .dragging') - .forEach(function(el) { el.classList.remove('dragging'); }); + function _tierDragFinish(commit) { + if (!_tierDrag.srcEl) return; + const wasActive = _tierDrag.active; + const order = _tierCurrentOrder(list); + const orig = _tierDrag.origOrder; + _tierDragReset(); + if (!wasActive) return; // 仅点击手柄未拖动 → 空操作 + if (!commit) { _tierRevertToList(); return; } // 取消 → 回滚服务端真实顺序 + if (orig && order.join(',') === orig.join(',')) return; // 顺序未变 → 跳过 PUT + persistTierOrder(order); // 提交新顺序(PUT + 失败回滚) + } + + list.addEventListener('pointerup', function(e) { + if (!_tierDrag.srcEl || e.pointerId !== _tierDrag.pointerId) return; + _tierDragFinish(true); + }); + list.addEventListener('pointercancel', function(e) { + if (!_tierDrag.srcEl || e.pointerId !== _tierDrag.pointerId) return; + _tierDragFinish(false); }); } @@ -2417,8 +2450,13 @@ async def favicon_svg() -> Response: @app.get("/dashboard", response_class=HTMLResponse, include_in_schema=False) async def dashboard() -> HTMLResponse: - """返回 Dashboard HTML 页面.""" - return HTMLResponse(content=_DASHBOARD_HTML) + """返回 Dashboard HTML 页面. + + 内联 JS/CSS 随版本变化,禁用缓存以免浏览器留存旧内联脚本掩盖前端修复。 + """ + return HTMLResponse( + content=_DASHBOARD_HTML, headers={"Cache-Control": "no-cache"} + ) @app.get("/api/dashboard/summary") async def dashboard_summary(request: Request, days: int = 7) -> Response: diff --git a/tests/test_app_routes.py b/tests/test_app_routes.py index 5e61a27..5c0aa53 100644 --- a/tests/test_app_routes.py +++ b/tests/test_app_routes.py @@ -1213,3 +1213,25 @@ def test_tier_order_preserves_circuit_breaker_and_rate_limit(): same = next(t for t in router.tiers if t.name == target.name) assert not same.can_execute() # 仍熔断 assert same.is_rate_limited # 仍限速 + + +def test_dashboard_serves_pointer_drag_reorder_and_no_cache(): + """Dashboard 供应商拖拽已由脆弱的原生 HTML5 DnD 改为 Pointer Events,并禁用页面缓存. + + #269 仅覆盖后端 /api/tier-order,前端拖拽机制无任何断言(拖拽从未被验证)。 + 此处补一条轻量守卫:确保重排序 UI 机制(指针事件 + PUT /api/tier-order)随页面交付、 + 不回退到原生 DnD,且响应带 no-cache 以免浏览器留存旧内联脚本掩盖前端修复。 + """ + with _make_app() as client: + resp = client.get("/dashboard") + assert resp.status_code == 200 + assert resp.headers.get("cache-control") == "no-cache" + html = resp.text + # Pointer Events 重排机制在位 + assert "initTierDrag" in html + assert "pointerdown" in html + assert "setPointerCapture" in html + assert "/api/tier-order" in html + # 已移除脆弱的原生 HTML5 DnD(不再渲染 draggable 属性 / 监听 dragstart) + assert 'draggable="true"' not in html + assert "addEventListener('dragstart'" not in html From f4b7ef29b8a78c16b57ed9d4a07c317f17777223 Mon Sep 17 00:00:00 2001 From: Aurelius Huang Date: Sat, 4 Jul 2026 00:53:13 +0800 Subject: [PATCH 35/42] =?UTF-8?q?style(branding):=20=E9=87=8D=E8=AE=BE?= =?UTF-8?q?=E8=AE=A1=20logo/favicon=20=E4=B8=BA=E6=B7=A1=E8=89=B2=E7=A3=A8?= =?UTF-8?q?=E7=A0=82=E5=8D=A1=E7=89=87=20+=20=E5=93=81=E7=89=8C=E6=B8=90?= =?UTF-8?q?=E5=8F=98=E7=AC=94=E5=88=92;=20(#272)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将品牌图标由「深紫渐变实心块 + 白色 >_ 笔划」反转为「#f5f6fb 淡色磨砂卡片 + 品牌紫渐变(#667eea→#764ba2) >_ 笔划 + 细描边环」,使图标轮廓成为视觉主角, 更简约、小尺寸下更清晰(Linear/Vercel 风),强化 Coding 意向。 - 新增 _LOGO_DEFS 共享品牌渐变 ,作为 favicon SVG 与页面 logo 单一事实源 - _build_favicon_svg:浅底 + 品牌描边环 + url(#cpBrand) 笔划 - _build_favicon (ICO):浅底光栅 + brand_at 按对角 t 计算品牌渐变笔划 - .logo CSS:浅底 + 品牌描边环 + 辉光调淡;内联 SVG 注入 {{LOGO_DEFS}} - 保留 --gradient-primary 与 .vendor-avatar 不动(最小干预) 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) --- src/coding/proxy/server/dashboard.py | 83 +++++++++++++++++----------- 1 file changed, 51 insertions(+), 32 deletions(-) diff --git a/src/coding/proxy/server/dashboard.py b/src/coding/proxy/server/dashboard.py index c53ab77..3168492 100644 --- a/src/coding/proxy/server/dashboard.py +++ b/src/coding/proxy/server/dashboard.py @@ -12,26 +12,36 @@ from ..logging.db import TimePeriod # ── 品牌图标:Tabler terminal-2 核心笔划(> 与 _),不含外框 ────────────── -# 外框由「蓝紫渐变圆角方块」容器承载(favicon SVG 的 与页面 .logo 的 CSS -# 渐变),保留 Tabler 原始外框笔划会双重描边、边缘发糊,故仅取 > 与 _ 两条。 -# 作为单一事实源,同时供 favicon SVG(f-string)与页面 logo({{TERMINAL2}} 模板替换)消费。 +# 容器为「淡色磨砂卡片」(#f5f6fb 浅底 + 品牌紫描边环),> 与 _ 笔划改用品牌渐变 +# (#667eea -> #764ba2)——色彩反转:品牌紫由背景迁移至图标笔划,轮廓成为视觉主角, +# 更简约、小尺寸下更清晰(Linear/Vercel 风)。保留 Tabler 原始外框笔划会双重描边、 +# 边缘发糊,故仅取 > 与 _ 两条。 +# 单一事实源:_TERMINAL2_PATHS 与 _LOGO_DEFS 同时供 favicon SVG(f-string)与页面 +# logo({{TERMINAL2}} / {{LOGO_DEFS}} 模板替换)消费。 +_BRAND_FROM, _BRAND_TO = "#667eea", "#764ba2" +_LOGO_DEFS = ( + '' + f'' + f'' + "" +) _TERMINAL2_PATHS = '' # ── Favicon (SVG, 现代浏览器主选) ────────────────────────────────────────── def _build_favicon_svg() -> str: - """生成 24×24 SVG favicon:品牌渐变圆角方块 + 白色 terminal-2 笔划. + """生成 24×24 SVG favicon:淡色磨砂卡片 + 品牌渐变 terminal-2 笔划. - 独立 SVG 文档无 CSS 上下文,``currentColor`` 不可靠,故笔划硬编码 ``#ffffff``。 + 容器为 ``#f5f6fb`` 浅底圆角方块 + 品牌紫描边环;``>`` 与 ``_`` 笔划由白色改为品牌 + 渐变(``url(#cpBrand)``),使图标轮廓成为主角。独立 SVG 文档无 CSS 上下文, + ``currentColor`` 不可靠,故颜色硬编码;```` 复用 ``_LOGO_DEFS`` 单一事实源。 """ return ( '' - '' - '' - '' - "" - '' - f'' + f'{_TERMINAL2_PATHS}' "" ) @@ -39,11 +49,12 @@ def _build_favicon_svg() -> str: # ── Favicon (ICO, 32×32 光栅回退) ───────────────────────────────────────── def _build_favicon() -> bytes: - """程序化生成 32×32 ICO:品牌渐变圆角方块 + 白色 terminal-2 笔划(旧浏览器/Safari 回退). + """程序化生成 32×32 ICO:淡色磨砂卡片 + 品牌渐变 terminal-2 笔划(旧浏览器/Safari 回退). - 项目无 Pillow/cairosvg 等图像库,故以纯 Python 按像素光栅化:渐变圆角方块背景 - + 白色 ``>`` 折线与 ``_`` 下划线。沿用原实现「全零 AND-mask + per-pixel alpha」 - 的圆角透明方案(现代渲染器按 alpha 通道处理透明)。 + 项目无 Pillow/cairosvg 等图像库,故以纯 Python 按像素光栅化:``#f5f6fb`` 浅底圆角 + 方块 + 品牌渐变(#667eea -> #764ba2)``>`` 折线与 ``_`` 下划线。沿用原实现「全零 + AND-mask + per-pixel alpha」的圆角透明方案(现代渲染器按 alpha 通道处理透明)。 + 32px 下细描边环易锯齿,故 ICO 仅保留浅底 + 渐变笔划,精修外观由 SVG 承载。 """ import math import struct @@ -52,8 +63,11 @@ def _build_favicon() -> bytes: radius = 8 scale = width / 24.0 # SVG 24 单位 -> 像素 - r0, g0, b0 = 0x66, 0x7E, 0xEA # #667eea - r1, g1, b1 = 0x76, 0x4B, 0xA2 # #764ba2 + # 图标笔划品牌渐变:#667eea -> #764ba2(与 _LOGO_DEFS 同源) + r0, g0, b0 = 0x66, 0x7E, 0xEA + r1, g1, b1 = 0x76, 0x4B, 0xA2 + # 容器底色:#f5f6fb(近白、极浅冷蓝紫) + tile_r, tile_g, tile_b = 0xF5, 0xF6, 0xFB def inside(x: int, y: int) -> bool: """满铺圆角方块(四角半径 radius)包含判定.""" @@ -68,30 +82,34 @@ def inside(x: int, y: int) -> bool: [[0, 0, 0, 0] for _ in range(width)] for _ in range(height) ] - # 1) 对角渐变圆角背景(左上 #667eea -> 右下 #764ba2) + # 1) 浅底圆角背景(扁平 #f5f6fb) for y in range(height): for x in range(width): if not inside(x, y): continue - t = (x + y) / (width + height - 2) - buf[y][x] = [ - int(b0 + (b1 - b0) * t), - int(g0 + (g1 - g0) * t), - int(r0 + (r1 - r0) * t), - 255, - ] + buf[y][x] = [tile_b, tile_g, tile_r, 255] + + def brand_at(px: float, py: float) -> tuple[int, int, int]: + """按对角参数 t 返回 (px,py) 处的品牌渐变 RGB(与 SVG url(#cpBrand) 同向).""" + t = (px + py) / (width + height - 2) + return ( + int(r0 + (r1 - r0) * t), + int(g0 + (g1 - g0) * t), + int(b0 + (b1 - b0) * t), + ) def stamp(px: float, py: float) -> None: - """以 (px, py) 为中心盖 ~3px 白色方块(笔划加粗,保证小尺寸可辨识).""" + """以 (px, py) 为中心盖 ~3px 品牌渐变方块(笔划加粗,保证小尺寸可辨识).""" ix, iy = int(round(px)), int(round(py)) + cr, cg, cb = brand_at(px, py) for oy in (-1, 0, 1): for ox in (-1, 0, 1): nx, ny = ix + ox, iy + oy if 0 <= nx < width and 0 <= ny < height: - buf[ny][nx] = [255, 255, 255, 255] + buf[ny][nx] = [cb, cg, cr, 255] def line(x0: float, y0: float, x1: float, y1: float) -> None: - """稠密采样 + stamp 形成白色粗线段.""" + """稠密采样 + stamp 形成品牌渐变粗线段.""" dist = math.hypot(x1 - x0, y1 - y0) steps = max(1, int(dist * 3)) for i in range(steps + 1): @@ -204,10 +222,11 @@ def line(x0: float, y0: float, x1: float, y1: float) -> None: .header-left { display: flex; align-items: center; gap: 12px; } .logo { width: 32px; height: 32px; - background: var(--gradient-primary); + background: #f5f6fb; + border: 1px solid rgba(102,126,234,.18); border-radius: 10px; display: flex; align-items: center; justify-content: center; - box-shadow: 0 4px 12px rgba(102,126,234,.25); + box-shadow: 0 4px 12px rgba(102,126,234,.18); } .logo svg { width: 20px; height: 20px; display: block; } h1 { font-size: 18px; font-weight: 600; color: var(--text-primary); letter-spacing: -.3px; } @@ -796,7 +815,7 @@ def line(x0: float, y0: float, x1: float, y1: float) -> None:
- +

Coding Proxy Dashboard

v-.-.-
@@ -2366,7 +2385,7 @@ def line(x0: float, y0: float, x1: float, y1: float) -> None: -""".replace("{{TERMINAL2}}", _TERMINAL2_PATHS) +""".replace("{{TERMINAL2}}", _TERMINAL2_PATHS).replace("{{LOGO_DEFS}}", _LOGO_DEFS) # ── 数据计算工具 ────────────────────────────────────────────────────────── From b07c08f806c816dd0b984880105c139870b7aebb Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Sat, 4 Jul 2026 01:09:17 +0800 Subject: [PATCH 36/42] =?UTF-8?q?build(version):=20=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E5=8F=B7=E5=8D=87=E7=BA=A7=E8=87=B3=200.5.2a5;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 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 --- CHANGELOG.md | 5 +++++ pyproject.toml | 2 +- uv.lock | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fa5494..436f1c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ ## [Unreleased] +## [v0.5.2a5](https://github.com/ThreeFish-AI/coding-proxy/releases/tag/v0.5.2a5) - 2026-07-04 + +- fix(dashboard): 供应商队列拖拽由原生 HTML5 DnD 改为 Pointer Events 实现,修复 v0.5.2a4 引入的拖拽排序功能在浏览器中无法拖动的问题;引入 4px 拖拽阈值、乐观实时重排与指针捕获机制,同步支持触屏设备;拖拽完成后顺序立即影响 cc 运行时供应商优先级调度(实机验证);为 `/dashboard` 响应增加 `Cache-Control: no-cache`,防止浏览器缓存旧内联脚本;补齐前端守卫测试断言 (#271); +- style(branding): 重设计 logo/favicon 为 `#f5f6fb` 淡色磨砂卡片 + 品牌渐变(`#667eea` → `#764ba2`)`>_` 笔划 + 半透明细描边环,轮廓成为视觉主角、小尺寸下更清晰(Linear/Vercel 风);抽取 `_LOGO_DEFS` 作为品牌渐变 `` 单一事实源,统一供 SVG favicon、ICO 回退与页面 logo 消费 (#272); + ## [v0.5.2a4](https://github.com/ThreeFish-AI/coding-proxy/releases/tag/v0.5.2a4) - 2026-07-03 - feat(dashboard): 供应商状态模块支持拖拽调整供应商优先级,新增纯重排序端点 `PUT /api/tier-order`(复用 `router.reorder_tiers`,不重置熔断器/配额守卫/rate limit),拖拽后配额与用量统计保持当日值不变;与 `reset -v` 同源、运行时内存生效,失败自动回滚至服务端真实顺序 (#269); diff --git a/pyproject.toml b/pyproject.toml index fdf117b..742b338 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "coding-proxy" -version = "0.5.2a4" +version = "0.5.2a5" 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/uv.lock b/uv.lock index 6b84a5e..b9197bf 100644 --- a/uv.lock +++ b/uv.lock @@ -74,7 +74,7 @@ wheels = [ [[package]] name = "coding-proxy" -version = "0.5.2a4" +version = "0.5.2a5" source = { editable = "." } dependencies = [ { name = "aiosqlite" }, From ac456237bc65e62afa9103da9c22afc871ab1535 Mon Sep 17 00:00:00 2001 From: Aurelius Huang Date: Sat, 4 Jul 2026 10:22:38 +0800 Subject: [PATCH 37/42] =?UTF-8?q?style(branding):=20logo/favicon=20?= =?UTF-8?q?=E5=AE=B9=E5=99=A8=E7=8E=BB=E7=92=83=E9=9B=BE=E5=8D=A1=E5=8C=96?= =?UTF-8?q?=E3=80=81>=5F=20=E7=AC=94=E5=88=92=E5=8A=A0=E7=B2=97=E6=8F=90?= =?UTF-8?q?=E4=BA=AE=EF=BC=88=E9=86=92=E7=9B=AE=E5=BA=A6=E4=BC=98=E5=8C=96?= =?UTF-8?q?=EF=BC=89=20(#274)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * style(branding): logo/favicon 容器改玻璃雾卡、笔划加粗提亮以提升醒目度; 延续 #272 的品牌图标设计,针对「图标依旧不够醒目」反馈做容器透明化与笔划强化: - 容器透明化:favicon rect 与 .logo 背景由实色 #f5f6fb 改为半透明白雾卡 (SVG hex8 #ffffff14/#ffffff38、.logo rgba(.06/.12)),与深色毛玻璃 header (backdrop-filter)语言统一,消除「亮白贴片」压制感;box-shadow 改双层 (黑投影补边界 + inset 顶部玻璃高光) - 笔划醒目化:>_ terminal 笔划 stroke-width 2→2.5;chevron 左移、下划线右移并 延长(M6/M13 l5)撑开间距至 4 单位避免粘连、下划线加长至 5 防止小尺寸退化为圆点 - 深端提亮:品牌渐变 #764ba2→#8b5fd0,深底对比达 WCAG 图形 3:1 - .logo 笔划新增 drop-shadow 柔和辉光;favicon 16px 小尺寸保持锐利不加 glow - ICO 回退同步:深端 RGB 0x8B5FD0、底色 alpha 255→0xB0、笔划坐标 - favicon 跨浅/深浏览器 tab 双主题均可辨识(实机验证) - 保留 --gradient-primary 与 .vendor-avatar 不动(最小干预) 🤖 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 * style(branding): logo/favicon 改通透青绿圆环 + prompt >_ 笔划(方案 d05); 按用户选定方案 d05 重设计品牌图标,替换前版玻璃雾卡: - 通透青绿圆环:全透明底 + 青绿渐变(#22d3ee->#2dd4bf)细圆环 (stroke 0.5,仅原 1/3、通透若隐) + 饱和青绿渐变 >_ 笔划(stroke 2.5), 极细环与饱和笔划形成权重对比、>_ 为视觉主角;青绿承载终端/代码语义 - 基础笔划改用 Tabler prompt(chevron M5 7l5 5l-5 5 + 下划线 M13 17l6 0), 下划线长 6 单位、不会小尺寸点化 - 修复下划线消失:渐变改 gradientUnits="userSpaceOnUse" 绝对坐标, 规避纯水平下划线(包围盒高度 0)在默认 objectBoundingBox 渐变下不着色 - 单一事实源:_LOGO_DEFS / _PROMPT_PATHS 统一供 SVG favicon、ICO 回退 (省略 0.5px 细环、保留饱和笔划)与页面 logo 消费 - .logo 容器改全透明、SVG 内嵌圆环 + 青绿 drop-shadow 辉光 - 实机验证:独立端口启动新代码,浏览器确认 favicon/logo 青绿 >_ 完整、 下划线在位、圆环通透(不干扰既有 3392 实例) 🤖 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 --- CHANGELOG.md | 2 + src/coding/proxy/server/dashboard.py | 105 +++++++++++---------------- 2 files changed, 46 insertions(+), 61 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 436f1c5..5574892 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ## [Unreleased] +- style(branding): 重设计 logo/favicon 为「通透青绿圆环 + Tabler prompt `>_` 笔划」(方案 d05):全透明底 + 青绿渐变(`#22d3ee` → `#2dd4bf`)细圆环(stroke 0.5,仅原 1/3、通透若隐)+ 饱和青绿渐变 `>_`(stroke 2.5),极细环与饱和笔划形成权重对比、`>_` 为视觉主角,青绿承载终端/代码语义;基础笔划改用 Tabler prompt(chevron `M5 7l5 5l-5 5` + 下划线 `M13 17l6 0`,下划线长 6 单位、不会小尺寸点化);渐变改 `gradientUnits="userSpaceOnUse"` 绝对坐标,修复纯水平下划线(包围盒高度 0)在默认 objectBoundingBox 渐变下不着色而「消失」的问题;`_LOGO_DEFS` / `_PROMPT_PATHS` 单一事实源统一供 SVG favicon、ICO 回退(省略 0.5px 细环、保留饱和笔划)与页面 logo 消费;`.logo` 容器改全透明、SVG 内嵌圆环 + 青绿 drop-shadow 辉光(实机验证); + ## [v0.5.2a5](https://github.com/ThreeFish-AI/coding-proxy/releases/tag/v0.5.2a5) - 2026-07-04 - fix(dashboard): 供应商队列拖拽由原生 HTML5 DnD 改为 Pointer Events 实现,修复 v0.5.2a4 引入的拖拽排序功能在浏览器中无法拖动的问题;引入 4px 拖拽阈值、乐观实时重排与指针捕获机制,同步支持触屏设备;拖拽完成后顺序立即影响 cc 运行时供应商优先级调度(实机验证);为 `/dashboard` 响应增加 `Cache-Control: no-cache`,防止浏览器缓存旧内联脚本;补齐前端守卫测试断言 (#271); diff --git a/src/coding/proxy/server/dashboard.py b/src/coding/proxy/server/dashboard.py index 3168492..b40515d 100644 --- a/src/coding/proxy/server/dashboard.py +++ b/src/coding/proxy/server/dashboard.py @@ -11,86 +11,73 @@ from ..logging.db import TimePeriod -# ── 品牌图标:Tabler terminal-2 核心笔划(> 与 _),不含外框 ────────────── -# 容器为「淡色磨砂卡片」(#f5f6fb 浅底 + 品牌紫描边环),> 与 _ 笔划改用品牌渐变 -# (#667eea -> #764ba2)——色彩反转:品牌紫由背景迁移至图标笔划,轮廓成为视觉主角, -# 更简约、小尺寸下更清晰(Linear/Vercel 风)。保留 Tabler 原始外框笔划会双重描边、 -# 边缘发糊,故仅取 > 与 _ 两条。 -# 单一事实源:_TERMINAL2_PATHS 与 _LOGO_DEFS 同时供 favicon SVG(f-string)与页面 -# logo({{TERMINAL2}} / {{LOGO_DEFS}} 模板替换)消费。 -_BRAND_FROM, _BRAND_TO = "#667eea", "#764ba2" +# ── 品牌图标:Tabler prompt 核心笔划(> 与 _)+ 通透青绿圆环 ────────────── +# 设计(方案 d05):全透明底 + 青绿渐变细圆环(stroke 0.5,仅原 1/3,通透若隐)+ +# 饱和青绿渐变 >_ 笔划(stroke 2.5)。极细环与饱和笔划形成权重对比,>_ 为绝对主角; +# 青绿(#22d3ee -> #2dd4bf)承载终端/代码语义,通透明快。基础笔划取自 Tabler prompt +# (chevron M5 7l5 5l-5 5 + 下划线 M13 17l6 0,下划线长 6 单位、不会小尺寸点化)。 +# 渐变用 gradientUnits="userSpaceOnUse" + 绝对坐标:纯水平下划线包围盒高度为 0,若用 +# 默认 objectBoundingBox 渐变会映射失效而不着色(下划线消失),userSpaceOnUse 规避。 +# 单一事实源:_PROMPT_PATHS 与 _LOGO_DEFS 同时供 favicon SVG(f-string)与页面 +# logo({{PROMPT}} / {{LOGO_DEFS}} 模板替换)消费。 +_BRAND_FROM, _BRAND_TO = "#22d3ee", "#2dd4bf" _LOGO_DEFS = ( - '' + '' f'' f'' "" ) -_TERMINAL2_PATHS = '' +_PROMPT_PATHS = '' # ── Favicon (SVG, 现代浏览器主选) ────────────────────────────────────────── def _build_favicon_svg() -> str: - """生成 24×24 SVG favicon:淡色磨砂卡片 + 品牌渐变 terminal-2 笔划. + """生成 24×24 SVG favicon:通透青绿圆环 + 品牌渐变 prompt 笔划(方案 d05). - 容器为 ``#f5f6fb`` 浅底圆角方块 + 品牌紫描边环;``>`` 与 ``_`` 笔划由白色改为品牌 - 渐变(``url(#cpBrand)``),使图标轮廓成为主角。独立 SVG 文档无 CSS 上下文, - ``currentColor`` 不可靠,故颜色硬编码;```` 复用 ``_LOGO_DEFS`` 单一事实源。 + 全透明底 + 青绿渐变细圆环(``r=11``、``stroke-width=0.5``、``stroke-opacity=.55``, + 通透若隐)+ 饱和青绿渐变 ``>`` 与 ``_`` 笔划(``stroke-width=2.5``)。极细环与饱和 + 笔划形成权重对比,``>_`` 为视觉主角。独立 SVG 文档无 CSS 上下文,``currentColor`` + 不可靠,故渐变复用 ``_LOGO_DEFS``(userSpaceOnUse,规避水平下划线不着色)单一事实源。 """ return ( '' f"{_LOGO_DEFS}" - '' - f'{_TERMINAL2_PATHS}' + '' + f'{_PROMPT_PATHS}' "" ) # ── Favicon (ICO, 32×32 光栅回退) ───────────────────────────────────────── def _build_favicon() -> bytes: - """程序化生成 32×32 ICO:淡色磨砂卡片 + 品牌渐变 terminal-2 笔划(旧浏览器/Safari 回退). + """程序化生成 32×32 ICO:全透明底 + 青绿渐变 prompt 笔划(旧浏览器/Safari 回退). - 项目无 Pillow/cairosvg 等图像库,故以纯 Python 按像素光栅化:``#f5f6fb`` 浅底圆角 - 方块 + 品牌渐变(#667eea -> #764ba2)``>`` 折线与 ``_`` 下划线。沿用原实现「全零 - AND-mask + per-pixel alpha」的圆角透明方案(现代渲染器按 alpha 通道处理透明)。 - 32px 下细描边环易锯齿,故 ICO 仅保留浅底 + 渐变笔划,精修外观由 SVG 承载。 + 项目无 Pillow/cairosvg 等图像库,故以纯 Python 按像素光栅化:全透明底 + 青绿渐变 + (#22d3ee -> #2dd4bf)``>`` 折线与 ``_`` 下划线。沿用「全零 AND-mask + per-pixel + alpha」透明方案(现代渲染器按 alpha 通道处理透明)。方案 d05 的细圆环(0.5px)在 + 32px 下不足 1 像素、无法渲染,故 ICO 省略圆环、仅保留饱和 ``>_`` 笔划(符合「图标 + 为主角、环极弱」的设计意图),完整外观由 SVG 主版承载。 """ import math import struct width = height = 32 - radius = 8 scale = width / 24.0 # SVG 24 单位 -> 像素 - # 图标笔划品牌渐变:#667eea -> #764ba2(与 _LOGO_DEFS 同源) - r0, g0, b0 = 0x66, 0x7E, 0xEA - r1, g1, b1 = 0x76, 0x4B, 0xA2 - # 容器底色:#f5f6fb(近白、极浅冷蓝紫) - tile_r, tile_g, tile_b = 0xF5, 0xF6, 0xFB - - def inside(x: int, y: int) -> bool: - """满铺圆角方块(四角半径 radius)包含判定.""" - cx = min(x, width - 1 - x) - cy = min(y, height - 1 - y) - if cx >= radius or cy >= radius: - return True - return (radius - cx) ** 2 + (radius - cy) ** 2 <= radius * radius - - # 像素缓冲:[B, G, R, A],默认全透明 + # 图标笔划青绿渐变:#22d3ee -> #2dd4bf(与 _LOGO_DEFS 同源) + r0, g0, b0 = 0x22, 0xD3, 0xEE + r1, g1, b1 = 0x2D, 0xD4, 0xBF + + # 像素缓冲:[B, G, R, A],默认全透明(全透底,无容器填充) buf: list[list[list[int]]] = [ [[0, 0, 0, 0] for _ in range(width)] for _ in range(height) ] - # 1) 浅底圆角背景(扁平 #f5f6fb) - for y in range(height): - for x in range(width): - if not inside(x, y): - continue - buf[y][x] = [tile_b, tile_g, tile_r, 255] - def brand_at(px: float, py: float) -> tuple[int, int, int]: - """按对角参数 t 返回 (px,py) 处的品牌渐变 RGB(与 SVG url(#cpBrand) 同向).""" + """按对角参数 t 返回 (px,py) 处的青绿渐变 RGB(与 SVG url(#cpBrand) 同向).""" t = (px + py) / (width + height - 2) return ( int(r0 + (r1 - r0) * t), @@ -99,7 +86,7 @@ def brand_at(px: float, py: float) -> tuple[int, int, int]: ) def stamp(px: float, py: float) -> None: - """以 (px, py) 为中心盖 ~3px 品牌渐变方块(笔划加粗,保证小尺寸可辨识).""" + """以 (px, py) 为中心盖 ~3px 青绿渐变方块(笔划加粗,保证小尺寸可辨识).""" ix, iy = int(round(px)), int(round(py)) cr, cg, cb = brand_at(px, py) for oy in (-1, 0, 1): @@ -109,18 +96,18 @@ def stamp(px: float, py: float) -> None: buf[ny][nx] = [cb, cg, cr, 255] def line(x0: float, y0: float, x1: float, y1: float) -> None: - """稠密采样 + stamp 形成品牌渐变粗线段.""" + """稠密采样 + stamp 形成青绿渐变粗线段.""" dist = math.hypot(x1 - x0, y1 - y0) steps = max(1, int(dist * 3)) for i in range(steps + 1): s = i / steps stamp(x0 + (x1 - x0) * s, y0 + (y1 - y0) * s) - # 2) terminal-2 笔划(SVG 24 单位 -> 像素) - # > 折线 (8,9)->(11,12)->(8,15);_ 下划线 (13,15)->(16,15) - line(8 * scale, 9 * scale, 11 * scale, 12 * scale) - line(11 * scale, 12 * scale, 8 * scale, 15 * scale) - line(13 * scale, 15 * scale, 16 * scale, 15 * scale) + # prompt 笔划(SVG 24 单位 -> 像素) + # > 折线 (5,7)->(10,12)->(5,17);_ 下划线 (13,17)->(19,17) + line(5 * scale, 7 * scale, 10 * scale, 12 * scale) + line(10 * scale, 12 * scale, 5 * scale, 17 * scale) + line(13 * scale, 17 * scale, 19 * scale, 17 * scale) # 3) 打包 ICO(BMP bottom-up;全零 AND-mask,信任 per-pixel alpha 实现圆角透明) pixel_rows: list[bytes] = [] @@ -222,13 +209,9 @@ def line(x0: float, y0: float, x1: float, y1: float) -> None: .header-left { display: flex; align-items: center; gap: 12px; } .logo { width: 32px; height: 32px; - background: #f5f6fb; - border: 1px solid rgba(102,126,234,.18); - border-radius: 10px; display: flex; align-items: center; justify-content: center; - box-shadow: 0 4px 12px rgba(102,126,234,.18); } - .logo svg { width: 20px; height: 20px; display: block; } + .logo svg { width: 28px; height: 28px; display: block; filter: drop-shadow(0 0 3px rgba(45,212,191,.3)); } h1 { font-size: 18px; font-weight: 600; color: var(--text-primary); letter-spacing: -.3px; } .header-right { display: flex; align-items: center; gap: 12px; } .badge { @@ -815,7 +798,7 @@ def line(x0: float, y0: float, x1: float, y1: float) -> None:
- +

Coding Proxy Dashboard

v-.-.-
@@ -2385,7 +2368,7 @@ def line(x0: float, y0: float, x1: float, y1: float) -> None: -""".replace("{{TERMINAL2}}", _TERMINAL2_PATHS).replace("{{LOGO_DEFS}}", _LOGO_DEFS) +""".replace("{{PROMPT}}", _PROMPT_PATHS).replace("{{LOGO_DEFS}}", _LOGO_DEFS) # ── 数据计算工具 ────────────────────────────────────────────────────────── From 9a8daea33b7043f210e643b28dbaedef7e6116ae Mon Sep 17 00:00:00 2001 From: Aurelius Huang Date: Sat, 4 Jul 2026 10:48:20 +0800 Subject: [PATCH 38/42] =?UTF-8?q?build(version):=20=E5=8F=91=E5=B8=83=20v0?= =?UTF-8?q?.5.2a6=20=E2=80=94=20=E5=93=81=E7=89=8C=E5=9B=BE=E6=A0=87=20d05?= =?UTF-8?q?=20=E9=80=9A=E9=80=8F=E9=9D=92=E7=BB=BF=E5=9C=86=E7=8E=AF=20+?= =?UTF-8?q?=20prompt=20>=5F=20=E7=AC=94=E5=88=92=20(#275)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 品牌图标迭代至方案 d05(通透青绿圆环 + Tabler prompt >_),修复水平下划线渐变消失(userSpaceOnUse),版本号升级至 0.5.2a6,CHANGELOG 定版。 --- CHANGELOG.md | 5 ++++- pyproject.toml | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5574892..f862587 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,10 @@ ## [Unreleased] -- style(branding): 重设计 logo/favicon 为「通透青绿圆环 + Tabler prompt `>_` 笔划」(方案 d05):全透明底 + 青绿渐变(`#22d3ee` → `#2dd4bf`)细圆环(stroke 0.5,仅原 1/3、通透若隐)+ 饱和青绿渐变 `>_`(stroke 2.5),极细环与饱和笔划形成权重对比、`>_` 为视觉主角,青绿承载终端/代码语义;基础笔划改用 Tabler prompt(chevron `M5 7l5 5l-5 5` + 下划线 `M13 17l6 0`,下划线长 6 单位、不会小尺寸点化);渐变改 `gradientUnits="userSpaceOnUse"` 绝对坐标,修复纯水平下划线(包围盒高度 0)在默认 objectBoundingBox 渐变下不着色而「消失」的问题;`_LOGO_DEFS` / `_PROMPT_PATHS` 单一事实源统一供 SVG favicon、ICO 回退(省略 0.5px 细环、保留饱和笔划)与页面 logo 消费;`.logo` 容器改全透明、SVG 内嵌圆环 + 青绿 drop-shadow 辉光(实机验证); +## [v0.5.2a6](https://github.com/ThreeFish-AI/coding-proxy/releases/tag/v0.5.2a6) - 2026-07-04 + +- style(branding): 品牌图标(logo/favicon)迭代至「通透青绿圆环 + Tabler prompt `>_` 笔划」(方案 d05):全透明底 + 青绿渐变(`#22d3ee` → `#2dd4bf`)细圆环(stroke 0.5,仅原 1/3、通透若隐)+ 饱和青绿渐变 `>_`(stroke 2.5),极细环与饱和笔划形成权重对比、`>_` 为视觉主角,青绿承载终端/代码语义;基础笔划改用 Tabler prompt(chevron `M5 7l5 5l-5 5` + 下划线 `M13 17l6 0`,下划线长 6 单位、不会小尺寸点化);渐变改 `gradientUnits="userSpaceOnUse"` 绝对坐标,修复纯水平下划线(包围盒高度 0)在默认 objectBoundingBox 渐变下不着色而「消失」的问题;`_LOGO_DEFS` / `_PROMPT_PATHS` 单一事实源统一供 SVG favicon、ICO 回退(省略 0.5px 细环、保留饱和笔划)与页面 logo 消费;`.logo` 容器改全透明、SVG 内嵌圆环 + 青绿 drop-shadow 辉光(实机验证)(#274); +- style(branding): 品牌图标醒目度前置优化(同主题演进过程,已并入本版):容器由「淡色磨砂卡片」改为「玻璃雾卡」(半透明白 + hairline 描边环)与深色毛玻璃 header 语言统一、消除「亮白贴片」压制感,`>_` 笔划加粗 stroke-width 2→2.5 并撑开间距避免粘连、防止下划线小尺寸退化为圆点 (#274); ## [v0.5.2a5](https://github.com/ThreeFish-AI/coding-proxy/releases/tag/v0.5.2a5) - 2026-07-04 diff --git a/pyproject.toml b/pyproject.toml index 742b338..d352c01 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "coding-proxy" -version = "0.5.2a5" +version = "0.5.2a6" 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" From 54a3448ac66d8f84734ae90627468d705c2eea61 Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Sat, 4 Jul 2026 11:50:01 +0800 Subject: [PATCH 39/42] =?UTF-8?q?docs(readme):=20=E9=A1=B5=E8=84=9A?= =?UTF-8?q?=E8=A1=A5=E5=85=85=E4=BD=9C=E8=80=85=E4=B8=BB=E9=A1=B5=E4=B8=8E?= =?UTF-8?q?=20Apache=202.0=20=E5=BC=80=E6=BA=90=E5=8D=8F=E8=AE=AE=E8=B6=85?= =?UTF-8?q?=E9=93=BE=E6=8E=A5;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 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 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 6cb7211..c681398 100644 --- a/README.md +++ b/README.md @@ -189,5 +189,5 @@ During our chaotic yet rewarding exploration of engineering practices, we were h ---
- Built with 🧠, ❤️, and an absurd amount of coffee by ThreeFish-AI + Built with 🧠, ❤️, and an absurd amount of coffee by ThreeFish-AI · Released under the Apache License 2.0.
From 2677b5e033a6b5706d6b2698969dcd2e76edc9e3 Mon Sep 17 00:00:00 2001 From: Aurelius Huang Date: Sat, 4 Jul 2026 12:25:26 +0800 Subject: [PATCH 40/42] =?UTF-8?q?style(dashboard):=20KPI=20=E5=8D=A1?= =?UTF-8?q?=E7=89=87=E5=9B=BA=E5=AE=9A=206=20=E5=88=97=E9=98=B2=E6=8A=98?= =?UTF-8?q?=E8=A1=8C=20+=20=E5=85=A8=E9=A1=B5=20UI/UX=20=E5=8A=A0=E5=9B=BA?= =?UTF-8?q?;=20(#276)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 布局: .kpi-grid 由 auto-fit(minmax 200px) 改为 repeat(6, minmax(0,1fr)), 使 6 张 KPI 卡在桌面/笔记本/平板横屏(≥1024px)恒定单行不折行,杜绝横向溢出; gap 5px 归一为 --gap-section(12px)。 - 排版: .kpi-value 采用 clamp(20px,2.2vw,32px) 平滑缩放,中宽度不裁剪数值; 费用卡 #kpi-cost-today 允许在 " + " 处换行,双币种始终完整可见。 - 响应式: 新增 ≤1023px→3 列、≤480px→2 列 优雅降级; header 加 flex-wrap 修复窄屏横向溢出。 - 可访问性: 为 .range-btn/.btn-refresh/.page-btn/.copy-btn 补 :focus-visible 键盘焦点环; 新增 prefers-reduced-motion 降级动效。 - 缺陷修复: 补定义 --bg-secondary / --text-muted 两个未定义 CSS 变量 (消除 Model Calling 行透明背景回退); JetBrains Mono 补载 700 字重, 修正 .kpi-value 合成粗体。 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) --- src/coding/proxy/server/dashboard.py | 43 ++++++++++++++++++++++++---- 1 file changed, 37 insertions(+), 6 deletions(-) diff --git a/src/coding/proxy/server/dashboard.py b/src/coding/proxy/server/dashboard.py index b40515d..2694440 100644 --- a/src/coding/proxy/server/dashboard.py +++ b/src/coding/proxy/server/dashboard.py @@ -149,18 +149,20 @@ def line(x0: float, y0: float, x1: float, y1: float) -> None: - + From f8bc9b95f835efba92ae1753d0a6fd5958faa5dd Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Sat, 4 Jul 2026 12:30:56 +0800 Subject: [PATCH 41/42] =?UTF-8?q?build(version):=20=E5=8F=91=E5=B8=83=20v0?= =?UTF-8?q?.5.2a7=20=E2=80=94=20Dashboard=20KPI=20=E5=8D=A1=E7=89=87?= =?UTF-8?q?=E5=9B=BA=E5=AE=9A=206=20=E5=88=97=E9=98=B2=E6=8A=98=E8=A1=8C?= =?UTF-8?q?=20+=20=E5=85=A8=E9=A1=B5=20UI/UX=20=E5=8A=A0=E5=9B=BA;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 版本号升级至 0.5.2a7,CHANGELOG 定版。本版聚焦 Dashboard Overview 页 6 张 KPI 卡片防折行(repeat(6, minmax(0,1fr)) + clamp 排版 + 响应式降级),附带 键盘 :focus-visible / prefers-reduced-motion 可访问性加固、两个未定义 CSS 变量修复与 JetBrains Mono 700 字重补载,并纳入 README 页脚作者主页与 Apache 2.0 协议超链接。 🤖 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 --- CHANGELOG.md | 7 +++++++ pyproject.toml | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f862587..8947065 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ ## [Unreleased] +## [v0.5.2a7](https://github.com/ThreeFish-AI/coding-proxy/releases/tag/v0.5.2a7) - 2026-07-04 + +- style(dashboard): Overview 页 6 张 KPI 卡片(今日请求数 / Token 总量 / 输出 Token / 费用估算 / 故障转移 / 平均延迟)固定单行不折行——`.kpi-grid` 由 `repeat(auto-fit, minmax(200px, 1fr))`(视口 < ~1289px 即减列换行)改为 `repeat(6, minmax(0, 1fr))`,`minmax(0,…)` 覆盖 grid 默认 `min-width:auto` 杜绝内容撑列引发的横向溢出,桌面/笔记本/平板横屏(≥1024px)恒定 6 列单行;`gap` 由 off-grid 的 `5px` 归一为 `--gap-section`(12px);`.kpi-value` 采用 `clamp(20px, 2.2vw, 32px)` 平滑缩放使中宽度不裁剪数值,费用卡 `#kpi-cost-today` 允许在 `" + "` 处换行使双币种始终完整可见;新增 ≤1023px→3 列、≤480px→2 列 优雅降级,`header` 补 `flex-wrap` 修复窄屏横向溢出(Playwright 8 档宽度实测:1440/1280/1024px 均 6 列单行、无横向滚动、无数值裁剪)(#276); +- a11y(dashboard): 为 `.range-btn` / `.btn-refresh` / `.page-btn` / `.copy-btn` 补 `:focus-visible` 键盘焦点环,统一交互元素键盘可达性;新增 `@media (prefers-reduced-motion: reduce)` 降级 `fadeInUp` 动画与 hover 过渡,照顾前庭敏感用户 (#276); +- fix(dashboard): 补定义 `--bg-secondary` / `--text-muted` 两个此前**未定义**的 CSS 变量(消除 `.mc-model-row` / `.mc-empty` 背景与文字色回退瑕疵);Google Fonts 补载 JetBrains Mono `700` 字重,修正 `.kpi-value` 合成粗体渲染 (#276); +- docs(readme): 页脚补充作者主页(ThreeFish-AI)与 Apache License 2.0 开源协议超链接; + ## [v0.5.2a6](https://github.com/ThreeFish-AI/coding-proxy/releases/tag/v0.5.2a6) - 2026-07-04 - style(branding): 品牌图标(logo/favicon)迭代至「通透青绿圆环 + Tabler prompt `>_` 笔划」(方案 d05):全透明底 + 青绿渐变(`#22d3ee` → `#2dd4bf`)细圆环(stroke 0.5,仅原 1/3、通透若隐)+ 饱和青绿渐变 `>_`(stroke 2.5),极细环与饱和笔划形成权重对比、`>_` 为视觉主角,青绿承载终端/代码语义;基础笔划改用 Tabler prompt(chevron `M5 7l5 5l-5 5` + 下划线 `M13 17l6 0`,下划线长 6 单位、不会小尺寸点化);渐变改 `gradientUnits="userSpaceOnUse"` 绝对坐标,修复纯水平下划线(包围盒高度 0)在默认 objectBoundingBox 渐变下不着色而「消失」的问题;`_LOGO_DEFS` / `_PROMPT_PATHS` 单一事实源统一供 SVG favicon、ICO 回退(省略 0.5px 细环、保留饱和笔划)与页面 logo 消费;`.logo` 容器改全透明、SVG 内嵌圆环 + 青绿 drop-shadow 辉光(实机验证)(#274); diff --git a/pyproject.toml b/pyproject.toml index d352c01..2d15fd1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "coding-proxy" -version = "0.5.2a6" +version = "0.5.2a7" 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" From 947fcbc4b87e1007bbff8a2eee982b29e90c2262 Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Sat, 4 Jul 2026 14:43:46 +0800 Subject: [PATCH 42/42] =?UTF-8?q?docs(readme):=20=E7=94=A8=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE=E5=93=81=E7=89=8C=E5=9B=BE=E6=A0=87=E6=9B=BF=E6=8D=A2?= =?UTF-8?q?=20README=20H1=20=E7=9A=84=20=E2=9A=A1=20=E5=8D=A0=E4=BD=8D?= =?UTF-8?q?=E7=AC=A6;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 assets/logo.svg:从 favicon 单一事实源 _FAVICON_SVG(dashboard.py)派生, 确保与线上 /favicon.svg 逐字符一致(青绿渐变 >_ prompt 笔划 + 通透圆环); - README.md 与 docs/zh-CN/README.md 双语 H1 以 引用该图标,采用 width/height=32 + align="absmiddle" 适配 GitHub 净化器(保尺寸生效、与文案垂直居中); - 架构图 Mermaid 子图标签由 "⚡ coding-proxy" 改为纯文字 "coding-proxy",与去 emoji 的 H1 统一。 🤖 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 --- README.md | 4 ++-- assets/logo.svg | 1 + docs/zh-CN/README.md | 4 ++-- 3 files changed, 5 insertions(+), 4 deletions(-) create mode 100644 assets/logo.svg diff --git a/README.md b/README.md index c681398..0bfb995 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@
-# ⚡ coding-proxy +# coding-proxy coding-proxy **A High-Availability, Transparent, and Smart Multi-Vendor Proxy for Claude Code** @@ -125,7 +125,7 @@ graph RL Client["💻
Client (Claude Code)"]:::client - subgraph CodingProxy["⚡ coding-proxy"] + subgraph CodingProxy["coding-proxy"] direction RL Router["RequestRouter
routing/router.py"]:::router diff --git a/assets/logo.svg b/assets/logo.svg new file mode 100644 index 0000000..1a7b64e --- /dev/null +++ b/assets/logo.svg @@ -0,0 +1 @@ + diff --git a/docs/zh-CN/README.md b/docs/zh-CN/README.md index 4b32986..c8fda53 100644 --- a/docs/zh-CN/README.md +++ b/docs/zh-CN/README.md @@ -2,7 +2,7 @@
-# ⚡ coding-proxy +# coding-proxy coding-proxy **面向 Claude Code 的多供应商高可用透明智能代理** @@ -125,7 +125,7 @@ graph RL Client["💻
Client (Claude Code)"]:::client - subgraph CodingProxy["⚡ coding-proxy"] + subgraph CodingProxy["coding-proxy"] direction RL Router["RequestRouter
routing/router.py"]:::router