mirror of
https://github.com/simonlin1212/TradingAgents-astock.git
synced 2026-08-31 01:23:38 +00:00
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:
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -31,6 +31,16 @@ class _Plan(BaseModel):
|
|||||||
confidence: int
|
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
|
@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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user