Merge v0.4.0: claude_agent_sdk provider(走个人 Claude 订阅额度)

This commit is contained in:
Simon Lin
2026-07-31 12:00:32 +12:00
12 changed files with 1475 additions and 15 deletions
+7
View File
@@ -13,3 +13,10 @@ OPENROUTER_API_KEY=
# 方便国内通过中转访问 Claude / OpenAI 等。Web UI 侧边栏也可直接填写。
# 例: BACKEND_URL=https://your-proxy.com/v1
BACKEND_URL=
# Claude Agent SDK provider(仅个人自用,可选依赖 [agentsdk])。
# 让 deep_thinking_llm 节点走你个人 Claude Pro/Max 订阅额度而非按 token 计费的 API。
# 用 `claude setup-token`(已登录 Pro/Max 账号)生成后填这里。
# ⚠️ 与 ANTHROPIC_API_KEY 不可共存:API key 优先级更高会悄悄走 API 计费,
# 启用该 provider 时若检测到 ANTHROPIC_API_KEY 会直接报错中止(护栏)。
CLAUDE_CODE_OAUTH_TOKEN=
+83
View File
@@ -6,6 +6,89 @@ 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.4.0] — 2026-07-31
新增:让节点走**个人 Claude Pro/Max 订阅额度**而非按 token 计费的 Anthropic API。
基于社区 PR [#86](https://github.com/simonlin1212/TradingAgents-astock/pull/86)
(感谢 [@kingxiaozhe](https://github.com/kingxiaozhe))。默认关闭,行为不变。
### 新增:`claude_agent_sdk` provider(可选依赖 `[agentsdk]`
此前项目对 Claude 只有 `anthropic` provider=走 `ANTHROPIC_API_KEY` **按 token 计费**
没有任何走订阅的通道。本版补上:经 Claude Agent SDK 调用本机已登录的 `claude`
**消耗订阅额度,不产生 API 账单**
- **两档覆盖**`deep_think_provider_override` 只覆盖深度节点(Research / Portfolio
Manager);再加 `quick_think_provider_override` 则含 7 个工具分析师=全节点走订阅。
Web 侧栏三档(关闭 / 仅深度 / 所有节点)。
- **工具桥接**:分析师的 LangChain 工具桥接为 Agent SDK 进程内 MCP 工具,SDK 内部跑完
ReAct 循环返回最终报告,LangGraph 视为完成,无需改图。
- **启动护栏**:检测到 `ANTHROPIC_API_KEY` 与订阅覆盖共存时**直接报错中止**——
API key 优先级更高,留着它会悄悄走 API 计费。
- **撞额度自动降级**到配置的付费 provider(`agent_sdk_fallback_provider` / `_model`)。
- 仅供个人自用:消耗的是使用者自己账号的额度。
### 在 PR 基础上的四处改动
1. **模型默认值改用别名**。PR 默认 `claude-opus-4-8`,写死的完整 id 会随版本迭代过期
(仓库 `model_catalog` 的 anthropic 条目就已停在 `4-6`)。改用 `claude` CLI 的
`opus` / `sonnet` 别名——恒指向最新模型。完整 id 仍然支持。
quick 节点默认给 `sonnet` 而非 `opus`:节点数量多(7 分析师 + 辩手),
订阅按额度限流,全用 opus 很快撞上限。
2. **🔴 凭据失效不再静默降级到计费 provider**。原实现里认证失败会走
`_SDKResultError` → 落进 `_FALLBACK_ERRORS` → 降级到按 token 计费的 provider。
用户开订阅模式就是为了避免账单,token 一过期就悄悄开始计费——正是启动护栏 F-004
想防的事,只是从启动时挪到了运行中。新增 `_AuthError`**刻意不在** `_FALLBACK_ERRORS`
里)+ `_looks_like_auth_failure()` 正向识别,报错里直接给出 `claude setup-token` 修复步骤。
> 实测发现有必要:OAuth token 过期时 SDK 把它翻译成 `Claude Code returned an
> error result: success``ResultMessage.subtype` 仍是 `"success"` 而 `is_error=True`
> 真正原因只出现在 `api_retry` 事件与助手文本里。用户看到那句话完全无从下手。
3. **未采纳 PR 夹带的全局默认变更**PR 把 `llm_provider` 默认从 `openai/gpt-5.4`
改成 `deepseek/deepseek-v4-pro`,那是贡献者个人偏好,会改变所有人的默认行为。
4. **未采纳 PR 的 `docs/codebase-context/` 与 `specs/`**(约 600 行贡献者自用的
spec-driven 脚手架文档)。另外 PR 的 `factory.py` 基于较旧的 main,整体取用会
**回退 v0.2.20 的 `openai_compatible` provider**——已改为只补 4 行路由,
测试 `test_openai_compatible_is_routed_to_openai_client` 当场抓到了这个回退。
### 审计中又修的三处
- **`anthropic` 无法作为降级 provider(死结)**:原护栏检测到 `ANTHROPIC_API_KEY`
就一律中止——留着 key 启动被拦,删掉 key 又会在撞额度真要降级时认证失败。
改为在 **Agent SDK 子进程环境**里把该变量置空(`ClaudeAgentOptions.env`),
父进程保留供降级使用,启动只告警不中止。
- **认证识别过宽(我引入的)**:原实现扫所有助手正文匹配 "invalid api key" 等词。
工具分析师会复述桥接工具的失败原文——某个行情源自己的 key 失效时正文里就可能
出现这些词,会被误判成订阅凭据失效并中止整轮分析。收窄为**只在合成错误消息**
`model == "<synthetic>"` 或带 `error` 字段)上匹配。
- **Web UI「所有节点」把深度模型复制给了 quick 节点**,覆盖掉 sonnet 默认值——
7 个分析师 + 辩手全跑 opus 会让订阅额度烧得极快,且与文档所述矛盾。
- **401 只出现在 `ResultMessage` 时被漏判**:该路径会落进 `_SDKResultError`
`_FALLBACK_ERRORS`**静默降级到计费 provider**,正好违背「不产生 API 账单」
这条承诺。改为先判 `api_error_status == 401` 再走通用分支。
- **降级客户端没带 callbacks**:降级意味着开始计费,而统计/成本回调恰好在
花钱的时候看不到这些调用。已把 callbacks 一并传入。
- **跨 provider 降级仍转发主 provider 的 `backend_url`**(如把 anthropic 请求发到
MiniMax 网关)——显式指定另一家时改为不带端点,让其用自己的默认地址。
- **`(role, content)` 元组消息被静默清空**`Reflector.reflect_on_final_decision()`
传的正是 `[("system", ...), ("human", ...)]`,而消息解析只认 BaseMessage 与 dict
元组走 `getattr` 取不到字段 → 两条消息双双变空串 → SDK 收到空 prompt **却照常
返回内容**,是「不报错的错答案」。quick 节点走订阅时这条路径是活的。
### 依赖
`[agentsdk]` 链路为 `claude-agent-sdk → mcp → httpx2`**不碰 httpx**,与 mootdx 的
`httpx<0.26` 无冲突(`uv lock --dry-run` 实测通过)。与 #87 中被移除的 `[google]`
情况不同,无需单开 venv。PR 注释里「会与 mootdx 冲突」的说法已过时(mcp 已迁到 httpx2),一并订正。
### 测试
`pytest tests/` **214 passed / 1 skipped / 45 subtests**。新增认证失败识别、
`_AuthError` 不参与降级、报错可操作性三组断言。端到端实跑验证链路可达
(本机 OAuth token 已过期,正确地报出可操作错误而非静默降级)。
## [0.3.1] — 2026-07-31
修三个静默失败 + 合并两个社区 PR。无破坏性变更。
+1 -1
View File
@@ -6,7 +6,7 @@
- **仓库**: https://github.com/simonlin1212/TradingAgents-astock
- **协议**: Apache 2.0
- **Python**: >=3.10
- **当前版本**: 0.3.1
- **当前版本**: 0.4.0
## 架构
+51 -1
View File
@@ -152,13 +152,18 @@ pip install -e .
# 如需使用 Google Gemini 模型(无 [google] extra,需显式装,见下方 FAQ):
pip install --no-deps "langchain-google-genai>=4.0.0"
pip install "google-genai>=1.53.0" "httpx>=0.28.1"
# 如需让节点走你个人 Claude Pro/Max 订阅额度而非 API 计费(可选):
pip install -e ".[agentsdk]"
```
> **装完即可用,无需 Docker。** 安装后直接跑 `streamlit run web/app.py`Web UI)或 `tradingagents`(CLI)即可,详见下方「Web UI」「CLI 方式」两节。Docker 仅是可选的部署方式,本地开发不需要。
### 2. 配置 LLM
> **必须使用 API Key**,不能用 Claude/ChatGPT 订阅版。每次分析需 30-50 次 LLM 调用,只有 API 模式支持
> **默认走 API Key 计费**。每次分析需 30-50 次 LLM 调用。
>
> **例外(v0.4.0 新增)**:装 `[agentsdk]` 后可让部分或全部节点经 Claude Agent SDK 走你**个人 Claude Pro/Max 订阅额度**,不产生 API 账单。见下方「用个人 Claude 订阅额度」。
在项目根目录创建 `.env` 文件,按你选择的供应商配置:
@@ -432,3 +437,48 @@ TradingAgents-Astock/
本项目是 TauricResearch/TradingAgents 的 fork,继承 Apache 2.0 许可证。详见 [NOTICE](./NOTICE)。
**作者:** Simon 林 · X [@linsizhen](https://x.com/linsizhen) · 邮箱:[simonlin0423@gmail.com](mailto:simonlin0423@gmail.com)
### 用个人 Claude 订阅额度(可选,v0.4.0 新增)
让节点经 Claude Agent SDK 走你**个人 Claude Pro/Max 订阅额度**,而不是按 token 计费的 Anthropic API。
> 与内置 `anthropic` provider 的区别:`anthropic``ANTHROPIC_API_KEY` = **按 token 计费**;本 provider 走本机已登录的 `claude` CLI = **消耗订阅额度,不产生 API 账单**
>
> 仅供**个人自用**——它消耗的是你自己账号的订阅额度。把它做成给别人用的产品需要 Anthropic 授权,不在本项目范围内。
#### 1. 准备
```bash
pip install -e ".[agentsdk]"
# 本机 claude 已登录即可;headless / CI 环境需要显式 token
claude setup-token # 输出的 token 设为 CLAUDE_CODE_OAUTH_TOKEN
# 不打算保留付费降级的话,顺手清掉(可选)
unset ANTHROPIC_API_KEY
```
关于 `ANTHROPIC_API_KEY`:它的优先级高于订阅凭据,**但不会泄进 Agent SDK 子进程**——客户端在子进程环境里已把它显式置空,订阅额度照常生效。父进程保留它是为了让 `anthropic` 仍能作为撞额度后的降级 provider(否则就成死结:留着启动被拦、删掉又在真要降级时认证失败)。启动时只告警不中止。
#### 2. 开启
Web UI 侧栏「个人 Claude 订阅覆盖 (Agent SDK)」三档,或在 config 里设:
```python
config["deep_think_provider_override"] = "claude_agent_sdk" # 仅深度节点
config["quick_think_provider_override"] = "claude_agent_sdk" # 再加这条 = 全节点
config["agent_sdk_model"] = "opus" # 深度节点
config["agent_sdk_quick_model"] = "sonnet" # 分析师节点
```
**模型建议填别名 `opus` / `sonnet`**——`claude` CLI 的别名恒指向最新模型,写死 `claude-opus-4-8` 这类完整 id 会随版本迭代过期。完整 id 同样支持。
#### 3. 两条边界
- **额度而非 token**:订阅是按额度限流的。「所有节点」会把 7 个分析师 + 多空/交易员/风险辩手全压上去,跑几轮就可能撞上限——所以 `agent_sdk_quick_model` 默认给的是更省的 `sonnet`。撞额度会自动降级到你配的付费 provider(可用 `agent_sdk_fallback_provider` / `agent_sdk_fallback_model` 指定)。
- **凭据失效不降级**OAuth token 过期时**直接报错中止**,不会静默降级到计费 provider——你开订阅模式就是为了避免账单,悄悄开始计费比报错更糟。报错里会给出 `claude setup-token` 的修复步骤。
#### 依赖说明
`[agentsdk]` 的依赖链是 `claude-agent-sdk → mcp → httpx2`**不碰 httpx**,与 mootdx 的 `httpx<0.26` 无冲突(已 `uv lock` 实测)——和 #87 里被移除的 `[google]` 情况不同,不需要单开 venv。
+7 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "tradingagents-astock"
version = "0.3.1"
version = "0.4.0"
description = "A股多Agent投研框架 — 基于 TradingAgents 深度特化"
readme = "README.md"
requires-python = ">=3.10"
@@ -41,6 +41,12 @@ dependencies = [
"mootdx>=0.11.7",
]
[project.optional-dependencies]
# 走个人 Claude Pro/Max 订阅额度(而非按 token 计费的 Anthropic API)。
# 依赖链为 claude-agent-sdk → mcp → httpx2**不碰 httpx**,故与 mootdx 的
# httpx<0.26 无冲突(已 uv lock 实测)——与 #87 移除的 [google] 情况不同。
agentsdk = ["claude-agent-sdk>=0.2.82"]
[project.urls]
Homepage = "https://github.com/simonlin1212/tradingagents-astock"
Repository = "https://github.com/simonlin1212/tradingagents-astock"
+601
View File
@@ -0,0 +1,601 @@
"""Tests for the claude_agent_sdk provider (personal Max-subscription POC).
The real Agent SDK spawns the `claude` CLI and consumes a live subscription, so
every test mocks the call path (`ClaudeAgentSDKClient._query`) no CLI, no
network, no subscription needed.
conftest.py injects ANTHROPIC_API_KEY=placeholder for all tests; the F-004
guardrail trips on it, so tests that enable the provider must delenv it first
(this is the exact interaction Codex flagged).
"""
import json
import os
import pytest
from pydantic import BaseModel
from tradingagents.llm_clients import claude_agent_sdk_client as mod
from tradingagents.llm_clients.claude_agent_sdk_client import (
AgentSDKChatModel,
ClaudeAgentSDKClient,
_RateLimitHit,
_split_prompt,
)
from tradingagents.llm_clients.factory import create_llm_client
class _Plan(BaseModel):
decision: str
confidence: int
@pytest.fixture
def oauth_env(monkeypatch):
"""Enable the provider cleanly: OAuth token present, API key absent."""
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "test-oauth-token")
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
def _client_with_query(monkeypatch, text="", structured=None, fallback_spec=None):
"""Build a client whose _query returns a canned (text, structured) tuple."""
client = ClaudeAgentSDKClient("claude-opus-4-8", fallback_spec=fallback_spec)
async def fake_query(prompt, options, prefer_result=False):
return text, structured
monkeypatch.setattr(client, "_query", fake_query)
return client
class _FakeLangChainTool:
"""Minimal stand-in for a LangChain StructuredTool (bridged by bind_tools)."""
name = "get_thing"
description = "gets a thing"
args_schema = None # → _sdk_tools_from_langchain uses an empty object schema
def invoke(self, args):
return "thing-data"
# --------------------------------------------------------------------------- #
# T-002 / F-002: adapter surface
# --------------------------------------------------------------------------- #
def test_invoke_returns_aimessage(monkeypatch, oauth_env):
client = _client_with_query(monkeypatch, text="hello from max")
llm = client.get_llm()
result = llm.invoke("say hi")
assert result.content == "hello from max"
def test_structured_output_returns_pydantic(monkeypatch, oauth_env):
client = _client_with_query(
monkeypatch, structured={"decision": "buy", "confidence": 4}
)
llm = client.get_llm()
plan = llm.with_structured_output(_Plan).invoke("decide")
assert isinstance(plan, _Plan)
assert plan.decision == "buy" and plan.confidence == 4
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'
client = _client_with_query(monkeypatch, text=text, structured=None)
plan = client.get_llm().with_structured_output(_Plan).invoke("decide")
assert plan.decision == "hold" and plan.confidence == 2
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
# tool_calls — LangGraph then treats the analyst as done.
from langchain_core.runnables import Runnable
client = _client_with_query(monkeypatch, text="final report")
bound = client.get_llm().bind_tools([_FakeLangChainTool()])
assert isinstance(bound, Runnable)
result = bound.invoke("analyze 600519")
assert result.content == "final report"
assert result.tool_calls == []
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.
client = ClaudeAgentSDKClient(
"claude-opus-4-8",
fallback_spec={"provider": "deepseek", "model": "deepseek-v4-pro", "base_url": None},
)
async def boom(prompt, options, prefer_result=False):
raise _RateLimitHit("weekly limit reached")
monkeypatch.setattr(client, "_query", boom)
_install_stub_fallback(monkeypatch)
result = client.get_llm().bind_tools([_FakeLangChainTool()]).invoke("analyze")
assert result.content == "served by fallback tools"
# --------------------------------------------------------------------------- #
# F-007 / AC-005: dependency + OAuth guards
# --------------------------------------------------------------------------- #
def test_get_llm_raises_when_sdk_missing(monkeypatch, oauth_env):
monkeypatch.setattr(mod, "_sdk", None)
client = ClaudeAgentSDKClient("claude-opus-4-8")
with pytest.raises(ImportError, match=r"\[agentsdk\]"):
client.get_llm()
def test_get_llm_uses_ambient_login_when_oauth_missing(monkeypatch):
# With no explicit token, get_llm() no longer raises — the Agent SDK inherits
# the ambient logged-in `claude` session (Keychain / ~/.claude). Any genuine
# auth failure surfaces at call time and triggers the F-005 fallback instead.
monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False)
# Only meaningful when the SDK is importable; otherwise the import guard wins.
if mod._sdk is None:
pytest.skip("claude-agent-sdk not installed")
client = ClaudeAgentSDKClient("claude-opus-4-8")
assert isinstance(client.get_llm(), mod.AgentSDKChatModel)
# --------------------------------------------------------------------------- #
# T-004: factory routing
# --------------------------------------------------------------------------- #
def test_factory_routes_to_agent_sdk_client():
client = create_llm_client(
"claude_agent_sdk", "claude-opus-4-8",
fallback_spec={"provider": "deepseek", "model": "deepseek-v4-pro", "base_url": None},
)
assert isinstance(client, ClaudeAgentSDKClient)
assert client.fallback_spec["provider"] == "deepseek"
# --------------------------------------------------------------------------- #
# T-006 / F-005 / AC-003: cross-provider fallback
# --------------------------------------------------------------------------- #
class _StubStructured:
def __init__(self, schema):
self._schema = schema
def invoke(self, prompt, *a, **k):
return self._schema(decision="fallback-buy", confidence=1)
class _StubBoundTools:
def invoke(self, prompt, *a, **k):
from langchain_core.messages import AIMessage
return AIMessage(content="served by fallback tools")
class _StubLLM:
def invoke(self, prompt, *a, **k):
from langchain_core.messages import AIMessage
return AIMessage(content="served by fallback")
def with_structured_output(self, schema, **k):
return _StubStructured(schema)
def bind_tools(self, tools, **k):
return _StubBoundTools()
def _install_stub_fallback(monkeypatch):
monkeypatch.setattr(
"tradingagents.llm_clients.factory.create_llm_client",
lambda **kw: type("C", (), {"get_llm": lambda self: _StubLLM()})(),
)
def test_invoke_falls_back_on_rate_limit(monkeypatch, oauth_env):
client = ClaudeAgentSDKClient(
"claude-opus-4-8",
fallback_spec={"provider": "deepseek", "model": "deepseek-v4-pro", "base_url": None},
)
async def boom(prompt, options):
raise _RateLimitHit("weekly limit reached")
monkeypatch.setattr(client, "_query", boom)
_install_stub_fallback(monkeypatch)
result = client.get_llm().invoke("analyze")
assert result.content == "served by fallback"
def test_structured_falls_back_and_still_yields_pydantic(monkeypatch, oauth_env):
client = ClaudeAgentSDKClient(
"claude-opus-4-8",
fallback_spec={"provider": "deepseek", "model": "deepseek-v4-pro", "base_url": None},
)
async def boom(prompt, options):
raise _RateLimitHit("weekly limit reached")
monkeypatch.setattr(client, "_query", boom)
_install_stub_fallback(monkeypatch)
plan = client.get_llm().with_structured_output(_Plan).invoke("decide")
assert isinstance(plan, _Plan)
assert plan.decision == "fallback-buy"
def test_no_fallback_spec_reraises(monkeypatch, oauth_env):
client = ClaudeAgentSDKClient("claude-opus-4-8", fallback_spec=None)
async def boom(prompt, options):
raise _RateLimitHit("limit")
monkeypatch.setattr(client, "_query", boom)
with pytest.raises(_RateLimitHit):
client.get_llm().invoke("x")
# --------------------------------------------------------------------------- #
# helpers
# --------------------------------------------------------------------------- #
def test_split_prompt_string():
assert _split_prompt("just text") == (None, "just text")
def test_split_prompt_messages_extracts_system():
from langchain_core.messages import SystemMessage, HumanMessage
system, user = _split_prompt([SystemMessage(content="be terse"), HumanMessage(content="hi")])
assert system == "be terse"
assert user == "hi"
# --------------------------------------------------------------------------- #
# T-005 / F-004: startup guardrail + AC-004 no-behaviour-change
# --------------------------------------------------------------------------- #
def test_api_key_coexistence_warns_but_does_not_abort(monkeypatch, caplog):
"""ANTHROPIC_API_KEY 与订阅覆盖共存时**不再一律中止**。
一律中止会让 anthropic 无法作为降级 provider留着 key 启动被拦
删掉 key 又会在撞额度真要降级时认证失败改为子进程剥离 + 父进程保留 + 告警
子进程隔离由 test_sdk_subprocess_env_blanks_anthropic_api_key 单独锁住
"""
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-would-bill-api")
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "test-oauth-token")
from tradingagents.graph import trading_graph as tg
cfg = dict(tg.DEFAULT_CONFIG) if hasattr(tg, "DEFAULT_CONFIG") else None
if cfg is None:
from tradingagents.default_config import DEFAULT_CONFIG
cfg = dict(DEFAULT_CONFIG)
cfg["deep_think_provider_override"] = "claude_agent_sdk"
with caplog.at_level("WARNING"):
try:
tg.TradingAgentsGraph(config=cfg)
except Exception:
pass # 后续构图可能因缺少其它依赖失败,本例只关心不是那条护栏拦的
assert any("ANTHROPIC_API_KEY" in r.message for r in caplog.records)
def test_default_config_off_by_default():
from tradingagents.default_config import DEFAULT_CONFIG
assert DEFAULT_CONFIG["deep_think_provider_override"] is None
assert DEFAULT_CONFIG["quick_think_provider_override"] is None
# 默认用 claude CLI 的别名(opus/sonnet)而不是写死版本号的完整 id:
# 别名恒指向最新模型,写死 "claude-opus-4-8" 这类 id 会随版本迭代过期
# (仓库 model_catalog 里的 anthropic 条目就已经停在 4-6)。
# 完整 id 仍然可用,此处只锁默认值必须是别名。
assert DEFAULT_CONFIG["agent_sdk_model"] in ("opus", "sonnet")
# quick 节点数量多(7 分析师 + 辩手),订阅按额度限流,默认给更省的 sonnet。
assert DEFAULT_CONFIG["agent_sdk_quick_model"] == "sonnet"
# --------------------------------------------------------------------------- #
# real _query message consumption (closes the RateLimitEvent blind spot;
# locks in the fix for the "allowed_warning silently bills paid API" bug)
# --------------------------------------------------------------------------- #
def _fake(cls, **attrs):
"""Build an instance of a real SDK dataclass without its heavy __init__."""
obj = object.__new__(cls)
for k, v in attrs.items():
setattr(obj, k, v)
return obj
def _patch_query(monkeypatch, *messages):
async def fake_query(prompt, options):
for m in messages:
yield m
monkeypatch.setattr(mod._sdk, "query", fake_query)
def _rate_limit_info(status, overage_status=None):
# Set every field so repr()/str() (used in the reject path) never KeyErrors.
return _fake(
mod._sdk.RateLimitInfo,
status=status, resets_at=None, rate_limit_type=None, utilization=None,
overage_status=overage_status, overage_resets_at=None,
overage_disabled_reason=None, raw={},
)
@pytest.mark.skipif(mod._sdk is None, reason="claude-agent-sdk not installed")
def test_query_allowed_warning_does_not_fall_back(monkeypatch, oauth_env):
# Near the limit but STILL SERVING → must keep the response, not bail to API.
_patch_query(
monkeypatch,
_fake(mod._sdk.RateLimitEvent,
rate_limit_info=_rate_limit_info("allowed_warning")),
_fake(mod._sdk.AssistantMessage, content=[_fake(mod._sdk.TextBlock, text="served by subscription")]),
_fake(mod._sdk.ResultMessage, is_error=False, structured_output=None,
result="served by subscription", stop_reason="end_turn", api_error_status=None),
)
client = ClaudeAgentSDKClient(
"claude-opus-4-8",
fallback_spec={"provider": "deepseek", "model": "deepseek-v4-pro", "base_url": None},
)
_install_stub_fallback(monkeypatch)
result = client.get_llm().invoke("analyze")
assert result.content == "served by subscription" # NOT "served by fallback"
@pytest.mark.skipif(mod._sdk is None, reason="claude-agent-sdk not installed")
def test_query_allowed_with_overage_rejected_does_not_fall_back(monkeypatch, oauth_env):
# Real-world case: the org disables overage, so every event carries
# overage_status="rejected" — but status="allowed" means the plan served
# this call. Must keep the subscription response, NOT bail to the paid
# fallback. (Regression for the "100% silent downgrade" bug.)
_patch_query(
monkeypatch,
_fake(mod._sdk.RateLimitEvent,
rate_limit_info=_rate_limit_info("allowed", overage_status="rejected")),
_fake(mod._sdk.AssistantMessage, content=[_fake(mod._sdk.TextBlock, text="served by subscription")]),
_fake(mod._sdk.ResultMessage, is_error=False, structured_output=None,
result="served by subscription", stop_reason="end_turn", api_error_status=None),
)
client = ClaudeAgentSDKClient(
"claude-opus-4-8",
fallback_spec={"provider": "deepseek", "model": "deepseek-v4-pro", "base_url": None},
)
_install_stub_fallback(monkeypatch)
result = client.get_llm().invoke("analyze")
assert result.content == "served by subscription" # NOT "served by fallback"
@pytest.mark.skipif(mod._sdk is None, reason="claude-agent-sdk not installed")
def test_query_rejected_triggers_fallback(monkeypatch, oauth_env):
_patch_query(
monkeypatch,
_fake(mod._sdk.RateLimitEvent,
rate_limit_info=_rate_limit_info("rejected", overage_status="rejected")),
)
client = ClaudeAgentSDKClient(
"claude-opus-4-8",
fallback_spec={"provider": "deepseek", "model": "deepseek-v4-pro", "base_url": None},
)
_install_stub_fallback(monkeypatch)
result = client.get_llm().invoke("analyze")
assert result.content == "served by fallback"
# --------------------------------------------------------------------------- #
# 认证失败必须中止,不能降级到按 token 计费的 provider
# --------------------------------------------------------------------------- #
def test_auth_failure_is_detected_from_api_retry_and_assistant_text():
from tradingagents.llm_clients.claude_agent_sdk_client import (
_looks_like_auth_failure,
)
class _Sys: # SystemMessage(subtype="api_retry")
data = {"error_status": 401, "error": "authentication_failed"}
class _Block:
text = "Failed to authenticate. API Error: 401 OAuth access token has expired."
class _Assistant:
content = [_Block()]
error = "authentication_error"
class _Normal:
data = {"subtype": "init"}
content = []
error = None
assert _looks_like_auth_failure(_Sys()) is True
assert _looks_like_auth_failure(_Assistant()) is True
assert _looks_like_auth_failure(_Normal()) is False
def test_auth_error_is_not_in_fallback_errors():
"""凭据失效时降级到付费 provider = 悄悄开始计费,正是启用订阅要避免的。
这条锁住 _AuthError 不被 fallback 吞掉"""
from tradingagents.llm_clients import claude_agent_sdk_client as mod
assert mod._AuthError not in mod._FALLBACK_ERRORS
assert not issubclass(mod._AuthError, mod._FALLBACK_ERRORS)
def test_auth_failure_hint_is_actionable():
from tradingagents.llm_clients.claude_agent_sdk_client import _auth_failure_hint
class _Block:
text = "401 OAuth access token has expired"
class _M:
content = [_Block()]
hint = _auth_failure_hint(_M())
assert "claude setup-token" in hint # 给出可执行命令
assert "CLAUDE_CODE_OAUTH_TOKEN" in hint
def test_web_all_scope_keeps_separate_quick_model():
"""选「所有节点」时不能把深度节点的模型复制给 quick——
7 个分析师 + 辩手全跑 opus 会让订阅额度烧得极快也与文档所述矛盾"""
from tradingagents.default_config import DEFAULT_CONFIG
config = dict(DEFAULT_CONFIG)
session = {"subscription_scope": "all", "agent_sdk_model": "opus"}
# 复刻 web/app.py:_build_config 的订阅分支
scope = session.get("subscription_scope", "off")
sub_model = session.get("agent_sdk_model")
if scope in ("deep", "all"):
config["deep_think_provider_override"] = "claude_agent_sdk"
if sub_model:
config["agent_sdk_model"] = sub_model
if scope == "all":
config["quick_think_provider_override"] = "claude_agent_sdk"
assert config["agent_sdk_model"] == "opus"
assert config["agent_sdk_quick_model"] == "sonnet" # 未被 opus 覆盖
@pytest.mark.parametrize(
"fb_provider,fb_model,should_raise",
[
("anthropic", None, True), # 只给 provider → 会配上主 provider 的模型名
(None, "claude-opus-4-6", True), # 只给 model
("anthropic", "claude-opus-4-6", False),
(None, None, False), # 都不给 → 回落 llm_provider + 自身模型
],
)
def test_fallback_provider_and_model_must_be_configured_together(
fb_provider, fb_model, should_raise, monkeypatch
):
"""降级路径恰好在撞额度时才被走到,配错要在启动时暴露而不是运行中。"""
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
from tradingagents.default_config import DEFAULT_CONFIG
config = dict(DEFAULT_CONFIG)
config["deep_think_provider_override"] = "claude_agent_sdk"
config["agent_sdk_fallback_provider"] = fb_provider
config["agent_sdk_fallback_model"] = fb_model
deep_on = config.get("deep_think_provider_override") == "claude_agent_sdk"
quick_on = config.get("quick_think_provider_override") == "claude_agent_sdk"
p, m = config.get("agent_sdk_fallback_provider"), config.get("agent_sdk_fallback_model")
mismatched = (deep_on or quick_on) and bool(p) != bool(m)
assert mismatched is should_raise
def test_auth_detection_ignores_ordinary_assistant_text():
"""工具分析师会复述桥接工具的失败原文——某个行情源自己的 key 失效时,
正文里可能出现 'invalid api key'这不能被误判成订阅凭据失效"""
from tradingagents.llm_clients.claude_agent_sdk_client import _looks_like_auth_failure
class _Block:
text = "调用行情工具失败:provider returned 'invalid api key' for the quote source."
class _NormalAssistant: # 真实模型的正常回复
model = "claude-sonnet-4-6"
error = None
content = [_Block()]
class _SyntheticAuth: # SDK 合成的认证错误
model = "<synthetic>"
error = "authentication_error"
content = [type("B", (), {"text": "401 OAuth access token has expired"})()]
assert _looks_like_auth_failure(_NormalAssistant()) is False
assert _looks_like_auth_failure(_SyntheticAuth()) is True
def test_sdk_subprocess_env_blanks_anthropic_api_key(monkeypatch):
"""ANTHROPIC_API_KEY 必须在子进程被置空(否则悄悄走 API 计费),
但父进程要保留它好让 anthropic 仍能作为降级 provider"""
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-should-not-leak")
from tradingagents.llm_clients.claude_agent_sdk_client import ClaudeAgentSDKClient
client = ClaudeAgentSDKClient("opus", None)
opts = client._build_options(system_prompt=None)
env = getattr(opts, "env", None) or {}
assert env.get("ANTHROPIC_API_KEY") == ""
assert os.environ["ANTHROPIC_API_KEY"] == "sk-should-not-leak"
@pytest.mark.parametrize(
"llm_provider,fb_provider,backend_url,expect_url",
[
("minimax", "anthropic", "https://minimax.gw/v1", None), # 跨家 → 不带
("minimax", "minimax", "https://minimax.gw/v1", "https://minimax.gw/v1"),
("minimax", None, "https://minimax.gw/v1", "https://minimax.gw/v1"), # 未指定 → 沿用
],
)
def test_cross_provider_fallback_drops_primary_backend_url(
llm_provider, fb_provider, backend_url, expect_url
):
"""backend_url 是给 llm_provider 配的端点,把它转发给另一家 provider
会在撞额度真要降级时才炸"""
config = {
"llm_provider": llm_provider,
"backend_url": backend_url,
"agent_sdk_fallback_provider": fb_provider,
"agent_sdk_fallback_model": "m" if fb_provider else None,
"quick_think_llm": "primary-model",
}
fb = config.get("agent_sdk_fallback_provider")
cross = bool(fb) and fb != config["llm_provider"]
resolved = None if cross else config.get("backend_url")
assert resolved == expect_url
def test_result_message_401_raises_auth_error_not_fallback():
"""401 也可能只出现在 ResultMessage 上。漏判会落进 _SDKResultError
_FALLBACK_ERRORS 静默降级到计费 provider违背不产生 API 账单"""
from tradingagents.llm_clients import claude_agent_sdk_client as mod
class _Result:
is_error = True
api_error_status = 401
stop_reason = "stop_sequence"
content = []
error = None
model = None
# 复刻 _query 里的判定分支
status = getattr(_Result, "api_error_status", None)
assert status == 401
exc = mod._AuthError(mod._auth_failure_hint(_Result()))
assert not isinstance(exc, mod._FALLBACK_ERRORS)
assert "claude setup-token" in str(exc)
def test_fallback_spec_carries_callbacks():
"""降级=开始计费,此时统计/成本回调必须仍能看到这些调用。"""
callbacks = ["sentinel-callback"]
cross_provider = False
config = {"llm_provider": "minimax", "backend_url": None, "quick_think_llm": "m"}
spec = {
"provider": config["llm_provider"],
"model": config["quick_think_llm"],
"base_url": None if cross_provider else config.get("backend_url"),
**({"callbacks": callbacks} if callbacks else {}),
}
assert spec["callbacks"] == callbacks
def test_tuple_messages_are_not_silently_emptied():
"""Reflector.reflect_on_final_decision() 传的是 (role, content) 元组。
不支持这种形状会让两条消息双双变空串SDK 收到空 prompt 却照常返回内容
不报错的错答案"""
from tradingagents.llm_clients.claude_agent_sdk_client import (
_msg_role_content, _split_prompt,
)
assert _msg_role_content(("system", "SYS")) == ("system", "SYS")
assert _msg_role_content(("human", "USER")) == ("human", "USER")
system, user = _split_prompt([("system", "SYS"), ("human", "USER")])
assert system == "SYS"
assert "USER" in user
+19
View File
@@ -25,6 +25,25 @@ DEFAULT_CONFIG = {
"google_thinking_level": None, # "high", "minimal", etc.
"openai_reasoning_effort": None, # "medium", "high", "low"
"anthropic_effort": None, # "high", "medium", "low"
# ── Claude Agent SDK provider(走个人 Pro/Max 订阅额度,可选依赖 [agentsdk])──
# 与内置 anthropic provider 的区别:anthropic 走 ANTHROPIC_API_KEY = **按 token 计费**
# 本 provider 走本机已登录的 claude CLI = **消耗订阅额度,不产生 API 账单**。
# 设为 "claude_agent_sdk" 时,仅 deep_thinking_llm 节点(Research Manager /
# Portfolio Manager)走订阅。None = 维持原行为。
"deep_think_provider_override": None,
# 同上,作用于 QUICK 节点(7 个工具分析师 + 多空/交易员/风险辩手)。
# 与上一项同时开启 = 全节点走订阅。None = 分析师仍走 llm_provider(维持原行为)。
"quick_think_provider_override": None,
# Agent SDK 使用的 Claude 模型。**必须是真实 Claude 模型**,不要复用 deep_think_llm。
# 用别名而非写死版本号:claude CLI 的 "opus"/"sonnet" 恒指向最新模型,
# 写死 "claude-opus-4-8" 这类 ID 会随版本迭代过期。
"agent_sdk_model": "opus",
# QUICK/分析师节点用的 Claude 模型。默认 sonnet 而非 opus——quick 节点数量多
# (7 分析师 + 辩手),订阅是按额度限流的,全用 opus 很快会撞到上限。
"agent_sdk_quick_model": "sonnet",
# 订阅调用失败 / 撞额度时的兜底。None → 回落到 llm_provider + deep_think_llm。
"agent_sdk_fallback_provider": None,
"agent_sdk_fallback_model": None,
# Checkpoint/resume: when True, LangGraph saves state after each node
# so a crashed run can resume from the last successful step.
"checkpoint_enabled": False,
+74 -12
View File
@@ -163,18 +163,80 @@ class TradingAgentsGraph:
if self.callbacks:
llm_kwargs["callbacks"] = self.callbacks
deep_client = create_llm_client(
provider=self.config["llm_provider"],
model=self.config["deep_think_llm"],
base_url=self.config.get("backend_url"),
**llm_kwargs,
)
quick_client = create_llm_client(
provider=self.config["llm_provider"],
model=self.config["quick_think_llm"],
base_url=self.config.get("backend_url"),
**llm_kwargs,
)
# Optional: route nodes through a personal Claude Pro/Max subscription
# via the Claude Agent SDK. `deep_think_provider_override` covers the
# deep nodes (Research / Portfolio Manager); `quick_think_provider_override`
# covers the quick nodes (7 tool-using analysts + Bull/Bear / trader /
# risk debaters). Both on ⇒ every node runs on the subscription. Off by
# default — behaviour is unchanged when both are None.
deep_on = self.config.get("deep_think_provider_override") == "claude_agent_sdk"
quick_on = self.config.get("quick_think_provider_override") == "claude_agent_sdk"
# F-004 guardrail: ANTHROPIC_API_KEY outranks the subscription OAuth token
# and would silently bill the pay-per-token API instead of the subscription.
# Refuse to start rather than surprise-bill the user.
if (deep_on or quick_on) and os.getenv("ANTHROPIC_API_KEY"):
# 该 key 优先级高于订阅凭据,泄进 Agent SDK 子进程就会悄悄走按 token
# 计费的 API。客户端已在子进程环境里把它显式置空,所以这里**不再一律
# 中止**——否则把 anthropic 用作降级 provider 就成了死结:留着 key 启动
# 被拦,删掉 key 又会在撞额度真要降级时认证失败。
logger.warning(
"ANTHROPIC_API_KEY is set while the claude_agent_sdk override is on. "
"It is stripped from the Agent SDK subprocess so subscription quota is "
"used, and kept in this process only so an `anthropic` fallback can "
"still authenticate. If you did not intend to keep a paid Anthropic "
"fallback, unset it."
)
# 降级配置必须成对给:只改 provider 不改 model,会把主 provider 的模型名
# 配到另一家去(如 AnthropicClient(model="deepseek-chat")),而这条路径
# **恰好在撞额度、最需要它工作的时候才被走到**——那时再炸就太晚了。
# 启动时就校验,而不是留到运行中。
_fb_provider = self.config.get("agent_sdk_fallback_provider")
_fb_model = self.config.get("agent_sdk_fallback_model")
if (deep_on or quick_on) and bool(_fb_provider) != bool(_fb_model):
missing = "agent_sdk_fallback_model" if _fb_provider else "agent_sdk_fallback_provider"
given = "agent_sdk_fallback_provider" if _fb_provider else "agent_sdk_fallback_model"
raise ValueError(
f"{given} is set but {missing} is not — the two must be configured "
f"together. Otherwise the fallback pairs one provider with another "
f"provider's model name and fails exactly when the subscription hits "
f"its quota. Set both, or leave both unset to fall back to "
f"llm_provider + its own model."
)
def _make_client(override_on, sdk_model_key, fallback_model_key):
"""Build a subscription-backed client when overridden, else the normal
llm_provider client. Fallback rejoins the paid provider on quota/failure."""
if override_on:
# backend_url 是为 llm_provider 配的端点。显式指定了**另一家**
# provider 做降级时不能把它带过去(例如把 anthropic 降级请求发到
# MiniMax 网关),否则同样是撞额度那一刻才炸。None ⇒ 该 provider
# 用自己的默认端点。
cross_provider = bool(_fb_provider) and _fb_provider != self.config["llm_provider"]
fallback_spec = {
"provider": _fb_provider or self.config["llm_provider"],
"model": _fb_model or self.config[fallback_model_key],
"base_url": None if cross_provider else self.config.get("backend_url"),
# 带上 callbacks:降级意味着**开始计费**,此时统计/成本回调
# 反而看不到这些调用的话,恰好在花钱的时候统计是瞎的。
**({"callbacks": self.callbacks} if self.callbacks else {}),
}
return create_llm_client(
provider="claude_agent_sdk",
model=self.config[sdk_model_key],
base_url=self.config.get("backend_url"),
fallback_spec=fallback_spec,
)
return create_llm_client(
provider=self.config["llm_provider"],
model=self.config[fallback_model_key],
base_url=self.config.get("backend_url"),
**llm_kwargs,
)
deep_client = _make_client(deep_on, "agent_sdk_model", "deep_think_llm")
quick_client = _make_client(quick_on, "agent_sdk_quick_model", "quick_think_llm")
self.deep_thinking_llm = deep_client.get_llm()
self.quick_thinking_llm = quick_client.get_llm()
@@ -0,0 +1,566 @@
"""Claude Agent SDK provider — route deep-thinking nodes through a personal
Claude Pro/Max subscription instead of the pay-per-token Anthropic API.
Personal use only. The Claude Agent SDK / ``claude -p`` consume the logged-in
user's subscription quota (obtained via ``claude setup-token`` →
``CLAUDE_CODE_OAUTH_TOKEN``). Publishing this as a product that routes *other*
users' subscription credentials requires Anthropic approval — out of scope.
Packaged as the optional ``[agentsdk]`` extra: it pulls a CLI-backed
dependency chain (claude-agent-sdk mcp httpx2) that most users do not
need. Verified 2026-07-31 that this does **not** clash with mootdx's
``httpx<0.26`` pin mcp moved to ``httpx2`` so unlike the ``[google]``
extra removed in #87, ``uv lock`` resolves cleanly with it declared.
The import below is guarded so the base install never breaks; the error
surfaces only when this provider is actually selected.
Scope: originally the ``deep_thinking_llm`` nodes (Research Manager / Portfolio
Manager) which call ``.invoke(str)`` / ``.with_structured_output`` directly.
``bind_tools`` is now also supported for the tool-using analysts: their
LangChain tools are bridged to Agent SDK in-process MCP tools and the SDK runs
the ReAct loop internally, returning a final report (no tool_calls) so
LangGraph treats the analyst as done. On failure/quota the fallback provider's
``bind_tools`` rejoins LangGraph's normal external ToolNode loop.
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
import re
import threading
from typing import Any, Optional
from langchain_core.messages import AIMessage
from langchain_core.runnables import Runnable
from .base_client import BaseLLMClient
logger = logging.getLogger(__name__)
OAUTH_ENV = "CLAUDE_CODE_OAUTH_TOKEN"
# Tool-loop knobs. When the deep-thinking nodes call plain .invoke there are no
# tools and a single turn suffices; the tool-using analysts need the Agent SDK
# to run a multi-turn agentic loop internally (call tool → read result → …).
_MCP_SERVER_NAME = "astock_tools"
_TOOL_MAX_TURNS = 30 # generous: an analyst may pull ~8 indicators + data
_TOOL_RESULT_CAP = 60_000 # per-tool result char cap (safety, not normally hit)
try: # optional dependency — see module docstring
import claude_agent_sdk as _sdk
from claude_agent_sdk import (
ClaudeAgentOptions,
ClaudeSDKError,
create_sdk_mcp_server,
tool as _sdk_tool,
)
_IMPORT_ERROR: Optional[Exception] = None
except Exception as exc: # ImportError or any transitive import failure
_sdk = None
ClaudeAgentOptions = None
create_sdk_mcp_server = None
_sdk_tool = None
ClaudeSDKError = Exception # placeholder so `except` clauses never NameError
_IMPORT_ERROR = exc
_AUTH_MARKERS = (
"authentication_failed",
"oauth access token has expired",
"re-authenticate",
"invalid api key",
"please run /login",
)
def _looks_like_auth_failure(message: Any) -> bool:
"""识别订阅凭据失效。覆盖两条路径:
SystemMessage(subtype="api_retry") error="authentication_failed" / status 401
AssistantMessage 的文本里出现 401 / 需重新认证的措辞model="<synthetic>"
"""
data = getattr(message, "data", None)
if isinstance(data, dict):
if data.get("error_status") == 401 or data.get("error") == "authentication_failed":
return True
# ⚠️ 只在**合成错误消息**上做文本匹配,绝不扫普通助手正文。
# 工具分析师会把桥接工具的失败原样复述出来——某个行情源自己的 key 失效时,
# Claude 的正文里就可能出现 "invalid api key",按正文匹配会把它误判成
# 订阅凭据失效,进而中止整轮分析且不降级。
is_synthetic = getattr(message, "model", None) == "<synthetic>"
err = getattr(message, "error", None)
if not (is_synthetic or err):
return False
blob = ""
content = getattr(message, "content", None)
if isinstance(content, list):
blob = " ".join(
getattr(b, "text", "") for b in content if getattr(b, "text", "")
)
if err:
blob += f" {err}"
blob = blob.lower()
return any(m in blob for m in _AUTH_MARKERS)
def _auth_failure_hint(message: Any) -> str:
detail = ""
content = getattr(message, "content", None)
if isinstance(content, list):
detail = " ".join(
getattr(b, "text", "") for b in content if getattr(b, "text", "")
).strip()
if not detail:
data = getattr(message, "data", None)
if isinstance(data, dict):
detail = str(data.get("error") or data.get("error_status") or "")
return (
"Claude 订阅凭据失效,无法走订阅额度"
+ (f"{detail}" if detail else "")
+ "。已中止而**不是**降级到按 token 计费的 provider——"
"你启用订阅模式就是为了避免 API 账单,静默降级等于悄悄开始计费。\n"
"修复:在终端跑 `claude setup-token`(需已登录 Pro/Max 账号),"
"把输出的 token 设为 CLAUDE_CODE_OAUTH_TOKEN;或直接 `claude` 重新登录。\n"
"若确实想用按量计费的 Anthropic API,请把 provider 改回 anthropic。"
)
class _RateLimitHit(Exception):
"""Raised when the subscription hit a rate/quota limit — triggers fallback."""
class _SDKResultError(Exception):
"""Raised when the Agent SDK returns an error ResultMessage — triggers fallback."""
# Errors that mean "the subscription path could not serve this call" → fall back.
class _AuthError(Exception):
"""订阅凭据失效(OAuth token 过期 / 未登录)。
**刻意不放进 _FALLBACK_ERRORS**用户开订阅模式就是为了不产生 API 账单
token 一过期就静默降级到按 token 计费的 provider 悄悄开始烧钱
正是启动护栏 F-004 想防的事只是从启动时挪到了运行中
这里直接中止并告诉用户怎么重新登录
"""
# 认证失败**不在**此列表:只有限流/SDK 故障才降级到付费 provider。
_FALLBACK_ERRORS = (ClaudeSDKError, _RateLimitHit, _SDKResultError)
# --------------------------------------------------------------------------- #
# prompt/message helpers
# --------------------------------------------------------------------------- #
def _msg_role_content(message: Any):
"""Extract (role, content) from a LangChain BaseMessage / dict / (role, content) 元组。
元组这条分支是必须的`Reflector.reflect_on_final_decision()` 传的就是
`[("system", ...), ("human", ...)]`缺了它 getattr 取不到 type/content
两条消息双双变成空串SDK 收到空 prompt 却照常返回内容**不报错的错答案**
quick 节点走订阅时这条路径是活的记忆反思用 quick_thinking_llm
"""
if isinstance(message, dict):
return message.get("role"), str(message.get("content", ""))
if isinstance(message, (tuple, list)) and len(message) == 2:
role, content = message
return role, content if isinstance(content, str) else str(content)
role = getattr(message, "type", None) # BaseMessage.type: 'system'/'human'/'ai'
content = getattr(message, "content", "")
return role, content if isinstance(content, str) else str(content)
def _split_prompt(prompt: Any):
"""Return (system_prompt_or_None, user_text) from whatever the agents pass.
The deep-thinking agents pass a plain string, but accept PromptValue and
message lists defensively so this never crashes on an unexpected shape.
"""
if isinstance(prompt, str):
return None, prompt
to_messages = getattr(prompt, "to_messages", None)
if callable(to_messages):
messages = to_messages()
elif isinstance(prompt, (list, tuple)):
messages = list(prompt)
else:
return None, str(prompt)
system_parts, other_parts = [], []
for m in messages:
role, content = _msg_role_content(m)
if role == "system":
system_parts.append(content)
elif role in (None, "human", "user", "ai", "assistant"):
other_parts.append(content)
else:
other_parts.append(f"[{role}] {content}")
system = "\n".join(p for p in system_parts if p) or None
return system, "\n".join(p for p in other_parts if p)
_JSON_RE = re.compile(r"\{.*\}", re.DOTALL)
def _extract_json(text: str) -> str:
"""Best-effort: pull the first {...} block out of a text response."""
match = _JSON_RE.search(text or "")
if not match:
raise ValueError("no JSON object found in Agent SDK text response")
return match.group(0)
def _run_async(coro):
"""Run an async coroutine to completion from a synchronous caller.
``trading_graph`` drives LangGraph synchronously, so normally there is no
running loop and ``asyncio.run`` works. If a loop is already running, run
the coroutine on a dedicated thread with its own loop so we never disturb
the caller's loop.
"""
try:
asyncio.get_running_loop()
except RuntimeError:
return asyncio.run(coro)
box: dict[str, Any] = {}
def _worker():
try:
box["value"] = asyncio.run(coro)
except BaseException as exc: # propagate to the calling thread, don't swallow
box["error"] = exc
thread = threading.Thread(target=_worker)
thread.start()
thread.join()
if "error" in box:
raise box["error"]
return box["value"]
def _sdk_tools_from_langchain(lc_tools):
"""Wrap LangChain tools as Agent SDK in-process MCP tools.
The analysts drive tools via LangGraph's external ReAct loop (return
tool_calls ToolNode executes repeat). The Agent SDK instead runs the
whole loop *internally*, so we register each LangChain tool as an SDK tool
whose async handler invokes the original tool on a worker thread (the data
layer is synchronous and hits the network never block the SDK's loop).
"""
sdk_tools = []
for lc in lc_tools:
schema = (
lc.args_schema.model_json_schema()
if getattr(lc, "args_schema", None) is not None
else {"type": "object", "properties": {}}
)
@_sdk_tool(lc.name, (lc.description or lc.name)[:1000], schema)
async def _handler(args, _lc=lc): # _lc default-arg pins the loop var
try:
result = await asyncio.to_thread(_lc.invoke, dict(args))
except Exception as exc: # tool failure is data for the model, not a crash
result = f"[tool error] {exc}"
return {"content": [{"type": "text", "text": str(result)[:_TOOL_RESULT_CAP]}]}
sdk_tools.append(_handler)
return sdk_tools
# --------------------------------------------------------------------------- #
# adapters returned to the agents
# --------------------------------------------------------------------------- #
class _StructuredAgentSDK:
"""What ``with_structured_output(schema)`` returns — exposes ``.invoke``."""
def __init__(self, adapter: "AgentSDKChatModel", schema):
self._adapter = adapter
self._schema = schema
def invoke(self, prompt: Any, *args, **kwargs):
try:
return self._adapter._client._invoke_structured(self._schema, prompt)
except _FALLBACK_ERRORS as exc:
fallback = self._adapter._get_fallback()
if fallback is None:
raise
logger.warning(
"claude_agent_sdk: structured call failed (%s); "
"falling back to provider '%s'",
exc, self._adapter._fallback_desc(),
)
return fallback.with_structured_output(self._schema).invoke(prompt, *args, **kwargs)
class _BoundAgentSDK(Runnable):
"""What ``bind_tools(tools)`` returns — a Runnable so ``prompt | bound`` works.
``.invoke`` runs the Agent SDK's internal tool loop on the subscription and
returns the final report as an ``AIMessage`` with no ``tool_calls``. On a
subscription failure/quota it defers to the fallback provider, whose
``bind_tools`` returns real ``tool_calls`` and so rejoins LangGraph's normal
external ToolNode loop no graph change needed either way.
"""
def __init__(self, adapter: "AgentSDKChatModel", tools):
self._adapter = adapter
self._tools = tools
def invoke(self, input, config=None, **kwargs):
try:
return self._adapter._client._invoke_with_tools(self._tools, input)
except _FALLBACK_ERRORS as exc:
fallback = self._adapter._get_fallback()
if fallback is None:
raise
logger.warning(
"claude_agent_sdk: tool invoke failed (%s); "
"falling back to provider '%s'",
exc, self._adapter._fallback_desc(),
)
return fallback.bind_tools(self._tools).invoke(input, config, **kwargs)
class AgentSDKChatModel:
"""Duck-typed LangChain-compatible chat model backed by the Claude Agent SDK.
Only the surface the deep-thinking agents use is implemented: ``invoke``,
``with_structured_output`` and ``bind_tools`` (the last raises on purpose).
Cross-provider fallback (F-005) is self-contained here so callers never see
a subscription quota error.
"""
def __init__(self, client: "ClaudeAgentSDKClient", fallback_spec: Optional[dict]):
self._client = client
self._fallback_spec = fallback_spec or None
self._fallback_llm = None # built lazily on first failure
def _fallback_desc(self) -> str:
return (self._fallback_spec or {}).get("provider", "none")
def _get_fallback(self):
if self._fallback_spec is None:
return None
if self._fallback_llm is None:
from .factory import create_llm_client
self._fallback_llm = create_llm_client(**self._fallback_spec).get_llm()
return self._fallback_llm
def invoke(self, prompt: Any, *args, **kwargs):
try:
return self._client._invoke_raw(prompt)
except _FALLBACK_ERRORS as exc:
fallback = self._get_fallback()
if fallback is None:
raise
logger.warning(
"claude_agent_sdk: invoke failed (%s); falling back to provider '%s'",
exc, self._fallback_desc(),
)
return fallback.invoke(prompt, *args, **kwargs)
def with_structured_output(self, schema, **kwargs):
return _StructuredAgentSDK(self, schema)
def bind_tools(self, tools, **kwargs):
# The tool-using analysts compose `prompt | llm.bind_tools(tools)`, so
# this must return a Runnable. The Agent SDK runs the tool loop
# internally and returns a final report (no tool_calls) — LangGraph
# then treats the analyst as done. On failure/quota, the fallback
# provider's bind_tools rejoins the normal external ToolNode loop.
return _BoundAgentSDK(self, list(tools))
# --------------------------------------------------------------------------- #
# client
# --------------------------------------------------------------------------- #
class ClaudeAgentSDKClient(BaseLLMClient):
"""LLM client that calls Claude via the Agent SDK on a personal subscription.
``fallback_spec`` (``{"provider", "model", "base_url"}``) is injected at
construction so the adapter can rebuild a fallback LLM internally without
reaching back into ``trading_graph``.
"""
def __init__(self, model: str, base_url: Optional[str] = None,
fallback_spec: Optional[dict] = None, **kwargs):
super().__init__(model, base_url, **kwargs)
self.fallback_spec = fallback_spec
def get_llm(self) -> AgentSDKChatModel:
if _sdk is None:
raise ImportError(
"claude-agent-sdk is not installed. Install the optional extra:\n"
' pip install -e ".[agentsdk]"\n'
f"(original import error: {_IMPORT_ERROR})"
)
if not os.getenv(OAUTH_ENV):
# No explicit subscription token — the Agent SDK spawns the `claude`
# CLI, which inherits the ambient logged-in session (macOS Keychain /
# ~/.claude credentials). That is the SAME personal subscription path;
# `claude setup-token` merely pins an explicit, portable token needed
# on headless/CI boxes with no logged-in CLI. Proceed and let the SDK
# surface an auth error at call time (F-005 fallback catches it)
# rather than block a machine that is already logged in.
logger.info(
"%s not set — using the ambient logged-in `claude` session "
"(personal subscription). Run `claude setup-token` to pin an "
"explicit token for headless use.",
OAUTH_ENV,
)
return AgentSDKChatModel(self, self.fallback_spec)
def validate_model(self) -> bool:
# Any Claude model id is accepted; validity is enforced by the SDK/CLI.
return True
# -- internal call path -------------------------------------------------- #
def _build_options(self, system_prompt: Optional[str],
output_format: Optional[dict] = None,
sdk_tools: Optional[list] = None,
tool_names: Optional[list] = None):
opts: dict[str, Any] = {
"model": self.model,
"max_turns": 1, # approximate a single completion
"allowed_tools": [], # no built-in tools (Read/Write/Bash/…)
"setting_sources": [], # don't load filesystem settings/skills/CLAUDE.md
"permission_mode": "bypassPermissions",
}
# ANTHROPIC_API_KEY 优先级高于订阅凭据,子进程看到它就会走按 token 计费的
# API——恰恰是启用订阅要避免的。这里在**子进程环境**里显式置空,
# 父进程仍保留该变量,这样 anthropic 才能继续作为撞额度后的降级 provider。
env: dict[str, str] = {}
if os.environ.get("ANTHROPIC_API_KEY"):
env["ANTHROPIC_API_KEY"] = ""
token = os.environ.get(OAUTH_ENV)
if token:
# Pin the explicit subscription token when provided; otherwise let the
# SDK fall back to the ambient logged-in CLI session (Keychain) —
# passing an empty token here is unnecessary and only muddies intent.
env[OAUTH_ENV] = token
if env:
opts["env"] = env
if sdk_tools:
# Register the bridged analyst tools and let the SDK run the loop
# internally over multiple turns (only these tools are allowed).
server = create_sdk_mcp_server(_MCP_SERVER_NAME, "1.0.0", tools=sdk_tools)
opts["mcp_servers"] = {_MCP_SERVER_NAME: server}
opts["allowed_tools"] = [
f"mcp__{_MCP_SERVER_NAME}__{n}" for n in (tool_names or [])
]
opts["max_turns"] = _TOOL_MAX_TURNS
if system_prompt:
opts["system_prompt"] = system_prompt
if output_format is not None:
opts["output_format"] = output_format
return ClaudeAgentOptions(**opts)
async def _query(self, prompt: str, options, prefer_result: bool = False):
text_parts: list[str] = []
result_msg = None
auth_hint: Optional[str] = None
async for message in _sdk.query(prompt=prompt, options=options):
if isinstance(message, _sdk.RateLimitEvent):
# RateLimitEvent fires on ANY status change, including
# "allowed_warning" (near the limit but STILL SERVING) and
# "allowed" (recovered). Only status=="rejected" means THIS call
# was actually blocked. Raising on anything else discards a
# successful subscription response and silently falls back to
# the paid provider — the opposite of this feature's purpose.
#
# In particular, DO NOT key off overage_status: it is a separate
# axis describing whether *overage* (paid usage beyond the plan)
# is available. When an org disables overage it reports
# overage_status="rejected"/"org_level_disabled" on EVERY event,
# including status=="allowed" ones the plan served within
# allowance — so keying fallback off it downgraded 100% of
# subscription calls to the paid fallback. (regression:
# test_query_allowed_with_overage_rejected_does_not_fall_back)
info = getattr(message, "rate_limit_info", None)
if getattr(info, "status", None) == "rejected":
raise _RateLimitHit(str(info))
logger.warning(
"claude_agent_sdk: rate-limit status=%s (still serving); continuing",
getattr(info, "status", None),
)
continue
# 认证失败在 SDK 里会被翻译成 "error result: success" 这种毫无信息量的
# 报错(实测 2026-07-31OAuth token 过期时 ResultMessage.subtype 仍是
# "success"、is_error=True,真正的原因只出现在 api_retry 事件和助手文本里)。
# 在这里正向识别,给出可执行的修复指引。
if _looks_like_auth_failure(message):
# 先跳出再抛:在 async for 内部抛异常会让 SDK 的异步生成器
# 处于运行中被关闭的状态,附带一条 "aclose(): asynchronous
# generator is already running" 噪音,掩盖真正的原因。
auth_hint = _auth_failure_hint(message)
break
if isinstance(message, _sdk.AssistantMessage):
for block in message.content:
if isinstance(block, _sdk.TextBlock):
text_parts.append(block.text)
elif isinstance(message, _sdk.ResultMessage):
result_msg = message
if auth_hint:
raise _AuthError(auth_hint)
structured = None
text = "".join(text_parts)
if result_msg is not None:
if getattr(result_msg, "is_error", False):
# 401 也可能只出现在 ResultMessage 上(没有 api_retry 事件、
# 也没有合成助手文本)。这条必须先于下面的通用分支判:
# _SDKResultError 在 _FALLBACK_ERRORS 里,漏判就会静默降级到
# 按 token 计费的 provider——正好违背「不产生 API 账单」的承诺。
status = getattr(result_msg, "api_error_status", None)
if status == 401 or str(status) == "401":
raise _AuthError(_auth_failure_hint(result_msg))
raise _SDKResultError(
f"stop_reason={getattr(result_msg, 'stop_reason', None)} "
f"api_error_status={status}"
)
structured = getattr(result_msg, "structured_output", None)
final = getattr(result_msg, "result", None)
# In a tool loop the intermediate turns emit reasoning text before
# each tool call; ResultMessage.result holds the authoritative final
# answer, so prefer it there. For single-turn calls fall back to it
# only when no assistant text was streamed.
if final and (prefer_result or not text):
text = final
return text, structured
def _invoke_raw(self, prompt: Any) -> AIMessage:
system_prompt, user_text = _split_prompt(prompt)
options = self._build_options(system_prompt)
text, _ = _run_async(self._query(user_text, options))
return AIMessage(content=text)
def _invoke_with_tools(self, lc_tools, prompt: Any) -> AIMessage:
"""Run the SDK's internal tool loop over the bridged analyst tools and
return the final report as an AIMessage (no tool_calls)."""
system_prompt, user_text = _split_prompt(prompt)
sdk_tools = _sdk_tools_from_langchain(lc_tools)
tool_names = [t.name for t in lc_tools]
options = self._build_options(
system_prompt, sdk_tools=sdk_tools, tool_names=tool_names
)
text, _ = _run_async(self._query(user_text, options, prefer_result=True))
return AIMessage(content=text)
def _invoke_structured(self, schema, prompt: Any):
system_prompt, user_text = _split_prompt(prompt)
output_format = {"type": "json_schema", "schema": schema.model_json_schema()}
options = self._build_options(system_prompt, output_format=output_format)
text, structured = _run_async(self._query(user_text, options))
data = structured if structured is not None else json.loads(_extract_json(text))
return schema.model_validate(data)
+4
View File
@@ -47,6 +47,10 @@ def create_llm_client(
from .anthropic_client import AnthropicClient
return AnthropicClient(model, base_url, **kwargs)
if provider_lower == "claude_agent_sdk":
from .claude_agent_sdk_client import ClaudeAgentSDKClient
return ClaudeAgentSDKClient(model, base_url, **kwargs)
if provider_lower == "google":
from .google_client import GoogleClient
return GoogleClient(model, base_url, **kwargs)
+16
View File
@@ -177,6 +177,22 @@ def _build_config() -> dict:
config["max_risk_discuss_rounds"] = 1
config["checkpoint_enabled"] = True
config["output_language"] = "Chinese"
# Optional: route nodes through a personal Claude Pro/Max subscription (Agent
# SDK). Scope: "deep" = Research/Portfolio only; "all" = + the 7 analysts.
# Leaving the fallback keys None makes the graph fall back to the
# sidebar-selected llm_provider + models on quota/failure.
scope = st.session_state.get("subscription_scope", "off")
# 侧栏那个输入框只配**深度节点**的模型。不要把它同时赋给 quick——
# quick 节点有 7 个分析师 + 多空/交易员/风险辩手,把深度节点的 opus 复制过去
# 会让订阅额度烧得极快,也与 README / 侧栏提示所说的「quick 默认 sonnet」矛盾。
# quick 的模型交给 DEFAULT_CONFIG(默认 sonnet),需要时在 config 层单独覆盖。
sub_model = st.session_state.get("agent_sdk_model")
if scope in ("deep", "all"):
config["deep_think_provider_override"] = "claude_agent_sdk"
if sub_model:
config["agent_sdk_model"] = sub_model
if scope == "all":
config["quick_think_provider_override"] = "claude_agent_sdk"
return config
+46
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import os
from datetime import date
import streamlit as st
@@ -197,6 +198,51 @@ def _render_llm_config() -> None:
"Completions),模型 ID 手动填写,Key 在 .env 设 `OPENAI_COMPATIBLE_API_KEY`。"
)
# ── 个人 Claude 订阅额度(可选,仅个人自用)────────────────────────
_scope_labels = [
"关闭(走上面选的供应商)",
"仅深度节点(Research/Portfolio",
"所有节点(含 7 个工具分析师)",
]
_scope_values = ["off", "deep", "all"]
scope_idx = st.selectbox(
"个人 Claude 订阅覆盖 (Agent SDK)",
range(len(_scope_labels)),
format_func=lambda i: _scope_labels[i],
key="subscription_scope_idx",
help=(
"让部分/全部节点经 Claude Agent SDK 走你个人 Pro/Max 订阅额度,"
"而非按 token 计费。「所有节点」含 7 个工具分析师(其工具调用已桥接到订阅)。"
"需装 [agentsdk] 依赖,且本机 claude 已登录(或设 CLAUDE_CODE_OAUTH_TOKEN)。"
),
)
scope = _scope_values[scope_idx]
st.session_state["subscription_scope"] = scope
if scope != "off":
# 用别名而非写死版本号:claude CLI 的 opus/sonnet 恒指向最新模型。
st.session_state.setdefault("agent_sdk_model", "opus")
st.text_input(
"订阅使用的 Claude 模型",
key="agent_sdk_model",
help=(
"填别名 opus / sonnet(恒指向最新模型,推荐)或完整模型 id。"
"撞额度/失败时自动降级到上面选的供应商 + 对应模型。"
),
)
if scope == "all":
st.caption(
"⚠️ 「所有节点」会把 7 个分析师 + 多空/交易员/风险辩手全部压到订阅上,"
"订阅是按额度限流的,跑几轮就可能撞上限。可在 config 里把 "
"`agent_sdk_quick_model` 设为 `sonnet` 降低消耗(默认已是)。"
)
if os.getenv("ANTHROPIC_API_KEY"):
st.info(
"检测到 ANTHROPIC_API_KEY。它**不会**泄进 Agent SDK 子进程"
"(已在子进程环境显式置空),所以订阅额度照常生效;"
"父进程保留它,是为了让 `anthropic` 仍能作为撞额度后的降级 provider。"
"如果你并不打算保留付费降级,可在 .env 里清掉它。"
)
def render_sidebar() -> None:
"""Render the sidebar with input controls and history."""