Skip to content

Commit 71e913f

Browse files
authored
fix(routes): 修复 count_tokens 路由对 target_vendor.name 的错误属性访问; (#235)
根因: BaseVendor 仅暴露抽象方法 get_name(), 所有 Vendor 子类均无 name 实例属性. routes.py:153/160 错误访问 target_vendor.name 导致 AttributeError 返回 500. 间歇性原因: 仅当 infer_source_vendor_from_body() 从请求体推断出非空 source (含 zhipu 私有产物 srvtoolu_* / server_tool_use) 时才走错误分支, 因此日志中 200/500 共存. 变更要点: - routes.py 两处 .name 改为 .get_name() 并提取局部变量 target_name 复用, 避免重复方法调用与日志/调用点不一致风险 - 新增回归测试 test_count_tokens_triggers_zhipu_to_target_channel: 注入 server_tool_use + srvtoolu_* 触发 zhipu→anthropic 通道, 断言 200 且 debug 日志含 channel adaptation, 弥补此前 6 个 count_tokens 测试均未触达该分支的盲区 - docs/issue.md 追加档案, 总结根因/防范/同类问题影响 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist)
1 parent 1be3817 commit 71e913f

3 files changed

Lines changed: 115 additions & 2 deletions

File tree

‎docs/issue.md‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,3 +45,45 @@ if "usage" in data: # 仅判断 key 存在
4545

4646
- 本仓库内 `parse_usage_from_chunk` 的 Gemini `usageMetadata` 分支 (line ~219) 已经使用 `isinstance(um, dict)` 防御, 不受影响, 可作为参考实现。
4747
- 检查其他解析器 (如 routing / vendor adapter 层) 是否还有 `if "key" in data: v = data["key"]; v.get(...)` 这种模式, 必要时同步加固。
48+
49+
---
50+
51+
## count_tokens 路由 `AttributeError: 'ZhipuVendor' object has no attribute 'name'`
52+
53+
**问题描述**
54+
55+
后台日志反复出现 `POST /v1/messages/count_tokens?beta=true 500 Internal Server Error`,并伴随:
56+
57+
```
58+
File ".../coding/proxy/server/routes.py", line 153, in count_tokens
59+
channel_fn = get_transition_channel(source, target_vendor.name)
60+
AttributeError: 'ZhipuVendor' object has no attribute 'name'
61+
```
62+
63+
同一时间窗口内大量请求 200 OK、少量请求 500,呈"间歇性"故障特征。
64+
65+
**表因**
66+
67+
`src/coding/proxy/server/routes.py` 的 `count_tokens` 在 153 / 160 两处访问 `target_vendor.name`,触发 `AttributeError` 被 ASGI 中间件捕获返回 500。
68+
69+
**根因**
70+
71+
`BaseVendor` 仅暴露**抽象方法** `get_name() -> str`(`src/coding/proxy/vendors/base.py:75-77`),所有派生类(`AnthropicVendor`、`ZhipuVendor`、`CopilotVendor`、`MinimaxVendor`、`DoubaoVendor`、`KimiVendor` 等)均通过 `_vendor_name` 类属性配合 `get_name()` 返回名称 —— **并无 `name` 实例属性**。该错误访问在 lint/类型检查阶段无告警(因 `BaseVendor` 未在类型系统中约束 `name` 字段),仅在运行时触发。
72+
73+
间歇性原因:第 152 行 `if source:` 是守卫;`source` 由 `infer_source_vendor_from_body(body)`(`src/coding/proxy/convert/vendor_channels.py:357-394`)从请求体启发式推断,仅当出现 zhipu 私有产物(`srvtoolu_*` 形式的 `tool_use.id` 或 `server_tool_use` / `server_tool_use_delta` 类型 content block)时返回 `"zhipu"`,否则 `None`。纯净的首轮 count_tokens 请求 `source is None` 自然绕过 153 行,因此 200/500 共存。
74+
75+
**处理方式**
76+
77+
1. `routes.py:153,160` 将 `target_vendor.name` 改为 `target_vendor.get_name()`,并将结果提取到局部变量 `target_name` 复用,避免重复方法调用与日志/调用点不一致风险。
78+
2. `tests/test_app_routes.py` 新增 `test_count_tokens_triggers_zhipu_to_target_channel`:通过注入 `server_tool_use` + `srvtoolu_*` 让 `infer_source_vendor_from_body` 返回 `"zhipu"`,断言返回 200 且 debug 日志含 `"count_tokens channel zhipu → anthropic"`,证明通道被实际触发。此前 6 个 count_tokens 测试的请求体都是纯净的、未触达该分支,是 bug 长期漏过的根因。
79+
80+
**后续防范**
81+
82+
- 跨模块引用 Vendor 实例字段时,**统一通过 `BaseVendor` 暴露的方法**(`get_name()`、`map_model()` 等),避免直接访问派生类未定义的"假属性"。
83+
- 长期演进可考虑在 `BaseVendor` 增加 `@property name` 指向 `get_name()`,将契约前移到类型系统由 mypy / pyright 拦截 —— 该重构属"演进式设计"范畴,不在本次最小干预范围内。
84+
- 测试覆盖原则:路由层涉及"内容感知"分支(如 `infer_source_vendor_from_body`)时,至少补一个让分支命中的最小用例,避免守卫掩盖代码缺陷。
85+
86+
**同类问题影响与处理注意事项**
87+
88+
- 已 `grep -rn "vendor\.name\b" src/` 全仓扫描,确认 `target_vendor.name | vendor.name` 误用仅 routes.py 的这两处,已随本次修复一并消除。`/v1/messages` 主链路在 executor 中调用 `tier.name`(`Tier` 对象的合法 dataclass 属性),与 vendor 实例 `name` 无关,不受影响。
89+
- 若未来新增 Vendor 子类,仍只需实现 `get_name()` 抽象方法;外部调用方应遵循同一契约,本档案的修复模式可作为参考。

‎src/coding/proxy/server/routes.py‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -150,14 +150,15 @@ async def count_tokens(request: Request) -> Response:
150150

151151
source = infer_source_vendor_from_body(body)
152152
if source:
153-
channel_fn = get_transition_channel(source, target_vendor.name)
153+
target_name = target_vendor.get_name()
154+
channel_fn = get_transition_channel(source, target_name)
154155
if channel_fn is not None:
155156
body, adaptations = channel_fn(body)
156157
if adaptations:
157158
logger.debug(
158159
"count_tokens channel %s → %s: %s",
159160
source,
160-
target_vendor.name,
161+
target_name,
161162
", ".join(adaptations),
162163
)
163164

‎tests/test_app_routes.py‎

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,76 @@ def test_count_tokens_falls_back_to_tiers0_on_cold_start():
286286
assert resp.json()["input_tokens"] == 88
287287

288288

289+
def test_count_tokens_triggers_zhipu_to_target_channel(caplog):
290+
"""count_tokens 请求体含 zhipu 私有产物时,应触发跨供应商通道并返回 200.
291+
292+
回归测试:routes.py 历史上错误访问 target_vendor.name(BaseVendor 仅暴露 get_name()
293+
方法,并无 name 属性),当 infer_source_vendor_from_body() 推断出非空 source 时
294+
会抛 AttributeError 返回 500。本用例通过注入 zhipu 私有产物(srvtoolu_* id 与
295+
server_tool_use 块)触发该路径,断言 200 且 adaptations 日志被打印。
296+
"""
297+
config = ProxyConfig(
298+
tiers=[
299+
{"vendor": "anthropic", "enabled": True, "api_key": "sk-ant-test"},
300+
],
301+
database={"path": "/tmp/test-count-tokens-zhipu-channel.db"},
302+
)
303+
app = create_app(config)
304+
305+
mock_response = MagicMock()
306+
mock_response.content = b'{"input_tokens": 99}'
307+
mock_response.status_code = 200
308+
309+
body_with_zhipu_artifact = {
310+
"model": "claude-sonnet-4-20250514",
311+
"messages": [
312+
{"role": "user", "content": "Hello"},
313+
{
314+
"role": "assistant",
315+
"content": [
316+
{
317+
"type": "server_tool_use",
318+
"id": "srvtoolu_abc123",
319+
"name": "web_search",
320+
"input": {"query": "test"},
321+
},
322+
],
323+
},
324+
{
325+
"role": "user",
326+
"content": [
327+
{
328+
"type": "tool_result",
329+
"tool_use_id": "srvtoolu_abc123",
330+
"content": "result",
331+
},
332+
],
333+
},
334+
],
335+
}
336+
337+
with TestClient(app) as client:
338+
with patch.object(
339+
httpx.AsyncClient,
340+
"post",
341+
new_callable=AsyncMock,
342+
return_value=mock_response,
343+
):
344+
with caplog.at_level(logging.DEBUG, logger="coding.proxy.server.routes"):
345+
resp = client.post(
346+
"/v1/messages/count_tokens?beta=true",
347+
json=body_with_zhipu_artifact,
348+
headers={"authorization": "Bearer sk-test"},
349+
)
350+
assert resp.status_code == 200
351+
assert resp.json()["input_tokens"] == 99
352+
# 通道被实际触发的证据:debug 日志含 "count_tokens channel zhipu → anthropic"
353+
assert any(
354+
"count_tokens channel zhipu" in record.message
355+
for record in caplog.records
356+
), "expected zhipu→anthropic channel adaptation log"
357+
358+
289359
def test_status_exposes_vendor_diagnostics():
290360
"""状态接口暴露供应商诊断信息,便于排查凭证交换异常."""
291361
config = ProxyConfig(

0 commit comments

Comments
 (0)