From 4e181897dce47c3fe7a2965bf8b48a685b51bb94 Mon Sep 17 00:00:00 2001 From: Simon Lin Date: Thu, 6 Aug 2026 20:59:51 +1200 Subject: [PATCH] =?UTF-8?q?fix:=20ClaudeSDKError=20=E5=8D=A0=E4=BD=8D?= =?UTF-8?q?=E7=AC=A6=E9=80=80=E5=8C=96=E6=88=90=20Exception=20=E8=AE=A9?= =?UTF-8?q?=E8=AE=A1=E8=B4=B9=E6=8A=A4=E6=A0=8F=E5=A4=B1=E6=95=88=EF=BC=9B?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E5=A5=97=E4=BB=B6=E6=81=A2=E5=A4=8D=E5=85=A8?= =?UTF-8?q?=E7=BB=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 护栏隐患 可选依赖未装时 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 --- CHANGELOG.md | 32 +++++++++++++++++++ pyproject.toml | 2 +- tests/test_agent_sdk_provider.py | 19 +++++++++++ .../llm_clients/claude_agent_sdk_client.py | 16 +++++++++- 4 files changed, 67 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b5e645..61cf7bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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). 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 港股/美股代码不再被当成 A 股静默查询([#43](https://github.com/simonlin1212/TradingAgents-astock/issues/43) 前置修复)。 diff --git a/pyproject.toml b/pyproject.toml index 763d3a7..a1d4d8c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "tradingagents-astock" -version = "0.5.3" +version = "0.5.4" description = "A股多Agent投研框架 — 基于 TradingAgents 深度特化" readme = "README.md" requires-python = ">=3.10" diff --git a/tests/test_agent_sdk_provider.py b/tests/test_agent_sdk_provider.py index a145c29..844fd4f 100644 --- a/tests/test_agent_sdk_provider.py +++ b/tests/test_agent_sdk_provider.py @@ -31,6 +31,16 @@ class _Plan(BaseModel): confidence: int +# 需要真装可选依赖的用例:它们会走到 SDK 自己的 API(ClaudeAgentOptions / +# create_sdk_mcp_server),光 mock `_query` 不够。没装就**跳过**,不要红着—— +# 长期红的代价是没人再看,而本文件里恰恰有两条是保护「订阅凭据失效不得静默降级 +# 到按 token 计费」的护栏(那两条已不再依赖 SDK,见 ClaudeSDKError 占位类型)。 +requires_sdk = pytest.mark.skipif( + mod._sdk is None, + reason="需要可选依赖 claude-agent-sdk:pip install -e '.[agentsdk]'", +) + + @pytest.fixture def oauth_env(monkeypatch): """Enable the provider cleanly: OAuth token present, API key absent.""" @@ -64,6 +74,7 @@ class _FakeLangChainTool: # T-002 / F-002: adapter surface # --------------------------------------------------------------------------- # +@requires_sdk def test_invoke_returns_aimessage(monkeypatch, oauth_env): client = _client_with_query(monkeypatch, text="hello from max") llm = client.get_llm() @@ -71,6 +82,7 @@ def test_invoke_returns_aimessage(monkeypatch, oauth_env): assert result.content == "hello from max" +@requires_sdk def test_structured_output_returns_pydantic(monkeypatch, oauth_env): client = _client_with_query( 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 +@requires_sdk 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. 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 +@requires_sdk def test_bind_tools_returns_runnable_and_final_report(monkeypatch, oauth_env): # bind_tools must return a Runnable (so `prompt | bound` composes) whose # 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 == [] +@requires_sdk def test_bind_tools_falls_back_on_rate_limit(monkeypatch, oauth_env): # Subscription tool loop hits quota → fall back to the fallback provider's # 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): client = ClaudeAgentSDKClient( "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" +@requires_sdk def test_structured_falls_back_and_still_yields_pydantic(monkeypatch, oauth_env): client = ClaudeAgentSDKClient( "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" +@requires_sdk def test_no_fallback_spec_reraises(monkeypatch, oauth_env): 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 +@requires_sdk def test_sdk_subprocess_env_blanks_anthropic_api_key(monkeypatch): """ANTHROPIC_API_KEY 必须在子进程被置空(否则悄悄走 API 计费), 但父进程要保留它,好让 anthropic 仍能作为降级 provider。""" diff --git a/tradingagents/llm_clients/claude_agent_sdk_client.py b/tradingagents/llm_clients/claude_agent_sdk_client.py index a688962..98dd610 100644 --- a/tradingagents/llm_clients/claude_agent_sdk_client.py +++ b/tradingagents/llm_clients/claude_agent_sdk_client.py @@ -63,7 +63,21 @@ except Exception as exc: # ImportError or any transitive import failure ClaudeAgentOptions = None create_sdk_mcp_server = 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