fix: ClaudeSDKError 占位符退化成 Exception 让计费护栏失效;测试套件恢复全绿

护栏隐患
  可选依赖未装时 ClaudeSDKError 被占位成 Exception("防 except 子句 NameError")。
  但它会进 _FALLBACK_ERRORS —— 决定哪些错误可降级到按 token 计费的 provider。
  退化成 Exception 后 isinstance(任何异常, ...) 恒为真,连刻意排除在外的
  _AuthError(订阅凭据失效)也被判成可降级,而这条护栏的全部意义就是不让
  token 过期变成悄悄烧 API 账单。改用独立的 _MissingSDKError 占位。

测试套件
  9 个用例会走到 SDK 自己的 API(ClaudeAgentOptions / create_sdk_mcp_server),
  没装依赖就 ImportError 报红;同文件另外 3 个早已用 skipif 处理同样情况,这 9 个
  只是漏加。长期红的代价是没人再看——本次排查前这批红被反复当成"缺依赖噪音"掠过,
  而其中两条正是上面那条计费护栏的测试。按文件既有约定加 requires_sdk 标记。

干净 clone(pip install -e . 不带 [agentsdk]):303 passed, 13 skipped, 0 failed
This commit is contained in:
Simon Lin
2026-08-06 20:59:51 +12:00
parent b0c39ea915
commit 4e181897dc
4 changed files with 67 additions and 2 deletions
+32
View File
@@ -6,6 +6,38 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
Breaking changes within the 0.x line are called out explicitly. Breaking changes within the 0.x line are called out explicitly.
## [0.5.4] — 2026-08-06
干净 clone 上 `pytest` 从 11 red 变成全绿,并修掉藏在其中的一个计费护栏隐患。
### 修复:`ClaudeSDKError` 占位符退化成 `Exception`,让计费护栏失效
可选依赖 `claude-agent-sdk` 没装时,模块把 `ClaudeSDKError` 占位成 **`Exception`**
(注释写的是"placeholder so `except` clauses never NameError")。但
`ClaudeSDKError` 会进 `_FALLBACK_ERRORS` —— 那个元组决定「哪些错误可以降级到按
token 计费的 provider」。一旦退化成 `Exception``isinstance(任何异常, ...)` 恒为真,
连**刻意排除在外**的 `_AuthError`(订阅凭据失效)也会被判成可降级。
而这条护栏存在的全部意义,就是不让"订阅 token 过期"变成"悄悄开始烧 API 账单"。
改用独立的 `_MissingSDKError` 占位:`except ClaudeSDKError` 一样不会 NameError
元组永远不会变成 catch-all,保护这条护栏的两条测试也不再依赖可选依赖是否安装。
### 修复:9 个用例在没装可选依赖时红着,而不是跳过
`tests/test_agent_sdk_provider.py` 里有 9 个用例会走到 SDK 自己的 API
`ClaudeAgentOptions` / `create_sdk_mcp_server`),没装依赖就报 ImportError。
同一文件里另外 3 个用例早已用 `skipif` 处理过同样情况——这 9 个只是漏加。
长期红的真实代价是**没人再看**:本次排查前,这 11 个红被反复当成"已知的缺依赖噪音"
掠过,而其中两条恰恰是上面那条计费护栏的测试。现在按文件既有约定统一加
`requires_sdk` 标记。
**干净 clone`pip install -e .` 不带 `[agentsdk]`)跑 `pytest`303 passed,
13 skipped, 0 failed。**
---
## [0.5.3] — 2026-08-06 ## [0.5.3] — 2026-08-06
港股/美股代码不再被当成 A 股静默查询([#43](https://github.com/simonlin1212/TradingAgents-astock/issues/43) 前置修复)。 港股/美股代码不再被当成 A 股静默查询([#43](https://github.com/simonlin1212/TradingAgents-astock/issues/43) 前置修复)。
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "tradingagents-astock" name = "tradingagents-astock"
version = "0.5.3" version = "0.5.4"
description = "A股多Agent投研框架 — 基于 TradingAgents 深度特化" description = "A股多Agent投研框架 — 基于 TradingAgents 深度特化"
readme = "README.md" readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"
+19
View File
@@ -31,6 +31,16 @@ class _Plan(BaseModel):
confidence: int confidence: int
# 需要真装可选依赖的用例:它们会走到 SDK 自己的 APIClaudeAgentOptions /
# create_sdk_mcp_server),光 mock `_query` 不够。没装就**跳过**,不要红着——
# 长期红的代价是没人再看,而本文件里恰恰有两条是保护「订阅凭据失效不得静默降级
# 到按 token 计费」的护栏(那两条已不再依赖 SDK,见 ClaudeSDKError 占位类型)。
requires_sdk = pytest.mark.skipif(
mod._sdk is None,
reason="需要可选依赖 claude-agent-sdkpip install -e '.[agentsdk]'",
)
@pytest.fixture @pytest.fixture
def oauth_env(monkeypatch): def oauth_env(monkeypatch):
"""Enable the provider cleanly: OAuth token present, API key absent.""" """Enable the provider cleanly: OAuth token present, API key absent."""
@@ -64,6 +74,7 @@ class _FakeLangChainTool:
# T-002 / F-002: adapter surface # T-002 / F-002: adapter surface
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@requires_sdk
def test_invoke_returns_aimessage(monkeypatch, oauth_env): def test_invoke_returns_aimessage(monkeypatch, oauth_env):
client = _client_with_query(monkeypatch, text="hello from max") client = _client_with_query(monkeypatch, text="hello from max")
llm = client.get_llm() llm = client.get_llm()
@@ -71,6 +82,7 @@ def test_invoke_returns_aimessage(monkeypatch, oauth_env):
assert result.content == "hello from max" assert result.content == "hello from max"
@requires_sdk
def test_structured_output_returns_pydantic(monkeypatch, oauth_env): def test_structured_output_returns_pydantic(monkeypatch, oauth_env):
client = _client_with_query( client = _client_with_query(
monkeypatch, structured={"decision": "buy", "confidence": 4} monkeypatch, structured={"decision": "buy", "confidence": 4}
@@ -81,6 +93,7 @@ def test_structured_output_returns_pydantic(monkeypatch, oauth_env):
assert plan.decision == "buy" and plan.confidence == 4 assert plan.decision == "buy" and plan.confidence == 4
@requires_sdk
def test_structured_output_parses_json_text_when_no_structured_field(monkeypatch, oauth_env): def test_structured_output_parses_json_text_when_no_structured_field(monkeypatch, oauth_env):
# SDK returned text (not structured_output) — adapter must parse the JSON. # SDK returned text (not structured_output) — adapter must parse the JSON.
text = 'noise before {"decision": "hold", "confidence": 2} noise after' text = 'noise before {"decision": "hold", "confidence": 2} noise after'
@@ -89,6 +102,7 @@ def test_structured_output_parses_json_text_when_no_structured_field(monkeypatch
assert plan.decision == "hold" and plan.confidence == 2 assert plan.decision == "hold" and plan.confidence == 2
@requires_sdk
def test_bind_tools_returns_runnable_and_final_report(monkeypatch, oauth_env): def test_bind_tools_returns_runnable_and_final_report(monkeypatch, oauth_env):
# bind_tools must return a Runnable (so `prompt | bound` composes) whose # bind_tools must return a Runnable (so `prompt | bound` composes) whose
# invoke runs the SDK tool loop and returns a final report with NO # invoke runs the SDK tool loop and returns a final report with NO
@@ -103,6 +117,7 @@ def test_bind_tools_returns_runnable_and_final_report(monkeypatch, oauth_env):
assert result.tool_calls == [] assert result.tool_calls == []
@requires_sdk
def test_bind_tools_falls_back_on_rate_limit(monkeypatch, oauth_env): def test_bind_tools_falls_back_on_rate_limit(monkeypatch, oauth_env):
# Subscription tool loop hits quota → fall back to the fallback provider's # Subscription tool loop hits quota → fall back to the fallback provider's
# bind_tools, which rejoins LangGraph's normal external ToolNode loop. # bind_tools, which rejoins LangGraph's normal external ToolNode loop.
@@ -194,6 +209,7 @@ def _install_stub_fallback(monkeypatch):
) )
@requires_sdk
def test_invoke_falls_back_on_rate_limit(monkeypatch, oauth_env): def test_invoke_falls_back_on_rate_limit(monkeypatch, oauth_env):
client = ClaudeAgentSDKClient( client = ClaudeAgentSDKClient(
"claude-opus-4-8", "claude-opus-4-8",
@@ -210,6 +226,7 @@ def test_invoke_falls_back_on_rate_limit(monkeypatch, oauth_env):
assert result.content == "served by fallback" assert result.content == "served by fallback"
@requires_sdk
def test_structured_falls_back_and_still_yields_pydantic(monkeypatch, oauth_env): def test_structured_falls_back_and_still_yields_pydantic(monkeypatch, oauth_env):
client = ClaudeAgentSDKClient( client = ClaudeAgentSDKClient(
"claude-opus-4-8", "claude-opus-4-8",
@@ -227,6 +244,7 @@ def test_structured_falls_back_and_still_yields_pydantic(monkeypatch, oauth_env)
assert plan.decision == "fallback-buy" assert plan.decision == "fallback-buy"
@requires_sdk
def test_no_fallback_spec_reraises(monkeypatch, oauth_env): def test_no_fallback_spec_reraises(monkeypatch, oauth_env):
client = ClaudeAgentSDKClient("claude-opus-4-8", fallback_spec=None) client = ClaudeAgentSDKClient("claude-opus-4-8", fallback_spec=None)
@@ -511,6 +529,7 @@ def test_auth_detection_ignores_ordinary_assistant_text():
assert _looks_like_auth_failure(_SyntheticAuth()) is True assert _looks_like_auth_failure(_SyntheticAuth()) is True
@requires_sdk
def test_sdk_subprocess_env_blanks_anthropic_api_key(monkeypatch): def test_sdk_subprocess_env_blanks_anthropic_api_key(monkeypatch):
"""ANTHROPIC_API_KEY 必须在子进程被置空(否则悄悄走 API 计费), """ANTHROPIC_API_KEY 必须在子进程被置空(否则悄悄走 API 计费),
但父进程要保留它好让 anthropic 仍能作为降级 provider""" 但父进程要保留它好让 anthropic 仍能作为降级 provider"""
@@ -63,7 +63,21 @@ except Exception as exc: # ImportError or any transitive import failure
ClaudeAgentOptions = None ClaudeAgentOptions = None
create_sdk_mcp_server = None create_sdk_mcp_server = None
_sdk_tool = None _sdk_tool = None
ClaudeSDKError = Exception # placeholder so `except` clauses never NameError
class _MissingSDKError(Exception):
"""SDK 未安装时 ClaudeSDKError 的占位类型。
这里**不能** `Exception` 本身占位`ClaudeSDKError` 会进
`_FALLBACK_ERRORS`而那个元组决定"哪些错误可以降级到按 token 计费的
provider"。一旦它退化成 `Exception``isinstance(任何异常, ...)` 恒为真,
`_AuthError`订阅凭据失效刻意排除在外也会被判成可降级这条护栏
存在的全部意义就是不让凭据过期变成悄悄开始计费
用独立类型占位后`except ClaudeSDKError` 一样不会 NameError而元组永远
不会变成 catch-all保护这条护栏的测试也就不再依赖可选依赖是否安装
"""
ClaudeSDKError = _MissingSDKError
_IMPORT_ERROR = exc _IMPORT_ERROR = exc