"""LLM 客户端与配置单元测试(ai/llm.py,v1.29)。
覆盖:配置文件读写、环境变量兜底、Provider 预设补齐、api_key 脱敏、
未配置 key 的友好报错、openai/anthropic 两种协议的请求组装与响应解析
(HTTP 层 monkeypatch,零真实网络调用)。
"""
from __future__ import annotations
import asyncio
import pytest
from easy_tdx.ai import llm as llm_mod
from easy_tdx.ai.llm import (
PROVIDER_PRESETS,
LlmClient,
LlmConfig,
LlmError,
load_config,
mask_key,
resolve_config,
save_config,
)
@pytest.fixture()
def config_dir(tmp_path, monkeypatch):
monkeypatch.setenv("EASY_TDX_CONFIG_DIR", str(tmp_path))
# 清掉可能存在的兜底环境变量,保证用例间互不干扰
for var in ("LLM_PROVIDER", "LLM_API_KEY", "LLM_BASE_URL", "LLM_MODEL"):
monkeypatch.delenv(var, raising=False)
return tmp_path
class TestConfigFile:
def test_default_when_no_file(self, config_dir):
cfg = load_config()
assert cfg.provider == "deepseek" and cfg.api_key == ""
def test_save_and_load_roundtrip(self, config_dir):
save_config(LlmConfig(provider="zhipu", api_key="sk-test1234567890", model="glm-4.6"))
cfg = load_config()
assert cfg.provider == "zhipu"
assert cfg.api_key == "sk-test1234567890"
assert cfg.model == "glm-4.6"
def test_corrupt_file_returns_default(self, config_dir):
(config_dir / "llm.json").write_text("{not json", encoding="utf-8")
assert load_config().provider == "deepseek" # 不抛异常
def test_env_fills_missing_fields(self, config_dir, monkeypatch):
monkeypatch.setenv("LLM_PROVIDER", "kimi")
monkeypatch.setenv("LLM_API_KEY", "sk-env-key-123456")
cfg = load_config()
assert cfg.provider == "kimi" and cfg.api_key == "sk-env-key-123456"
def test_file_overrides_env(self, config_dir, monkeypatch):
monkeypatch.setenv("LLM_API_KEY", "sk-env")
save_config(LlmConfig(provider="deepseek", api_key="sk-file-12345678"))
assert load_config().api_key == "sk-file-12345678"
class TestResolve:
def test_preset_fills_url_and_model(self, config_dir):
save_config(LlmConfig(provider="qwen"))
r = resolve_config()
assert r.api_url == "https://dashscope.aliyuncs.com/compatible-mode/v1"
assert r.model == "qwen-plus"
def test_explicit_values_win(self, config_dir):
save_config(LlmConfig(provider="deepseek", api_url="http://gw.local/v1", model="my-model"))
r = resolve_config()
assert r.api_url == "http://gw.local/v1" and r.model == "my-model"
def test_custom_requires_url_and_model(self, config_dir):
with pytest.raises(ValueError, match="不完整"):
resolve_config(LlmConfig(provider="custom"))
class TestMaskKey:
def test_mask(self):
assert mask_key("") == ""
assert mask_key("short") == "*****"
assert mask_key("sk-abcdef1234567890") == "sk-***7890"
class TestClient:
def test_missing_key_friendly_error(self, config_dir):
client = LlmClient(LlmConfig(provider="deepseek", api_key=""))
with pytest.raises(LlmError, match="API Key"):
asyncio.run(client.chat("hi"))
def test_ollama_needs_no_key(self, config_dir, monkeypatch):
captured = {}
def fake_post(url, headers, payload, timeout):
captured.update(url=url, headers=headers, payload=payload)
return {"choices": [{"message": {"content": "OK"}}]}
monkeypatch.setattr(llm_mod, "_post_json", fake_post)
client = LlmClient(LlmConfig(provider="ollama", timeout=5))
reply = asyncio.run(client.chat("ping"))
assert reply == "OK"
assert captured["url"].startswith("http://localhost:11434/v1/chat/completions")
assert "Authorization" not in captured["headers"] # 免 key 不带鉴权头
def test_openai_style_request_and_parse(self, config_dir, monkeypatch):
captured = {}
def fake_post(url, headers, payload, timeout):
captured.update(url=url, headers=headers, payload=payload)
return {"choices": [{"message": {"content": "解读完成"}}]}
monkeypatch.setattr(llm_mod, "_post_json", fake_post)
client = LlmClient(LlmConfig(provider="zhipu", api_key="sk-zhipu-123456789"))
reply = asyncio.run(client.chat("报告…", system_prompt="SYS"))
assert reply == "解读完成"
assert captured["url"] == "https://open.bigmodel.cn/api/paas/v4/chat/completions"
assert captured["headers"]["Authorization"] == "Bearer sk-zhipu-123456789"
msgs = captured["payload"]["messages"]
assert msgs[0] == {"role": "system", "content": "SYS"}
assert msgs[1]["content"] == "报告…"
assert captured["payload"]["model"] == "glm-4-flash"
def test_anthropic_style_request_and_parse(self, config_dir, monkeypatch):
captured = {}
def fake_post(url, headers, payload, timeout):
captured.update(url=url, headers=headers, payload=payload)
return {"content": [{"type": "text", "text": "Claude 回复"}]}
monkeypatch.setattr(llm_mod, "_post_json", fake_post)
client = LlmClient(LlmConfig(provider="claude", api_key="sk-ant-123456789"))
reply = asyncio.run(client.chat("hi"))
assert reply == "Claude 回复"
assert captured["url"] == "https://api.anthropic.com/v1/messages"
assert captured["headers"]["x-api-key"] == "sk-ant-123456789"
assert captured["headers"]["anthropic-version"] == "2023-06-01"
assert captured["payload"]["system"] # system 走顶层字段而非 messages
def test_http_error_wrapped(self, config_dir, monkeypatch):
"""_post_json 把 HTTPError(带响应体)包装成带状态码的 LlmError。"""
import io
import urllib.error
def fake_urlopen(req, timeout):
body = io.BytesIO(b'{"error":"bad key"}')
raise urllib.error.HTTPError(req.full_url, 401, "Unauthorized", hdrs=None, fp=body)
monkeypatch.setattr(llm_mod.urllib.request, "urlopen", fake_urlopen)
with pytest.raises(LlmError, match="401") as ei:
llm_mod._post_json("https://x/v1/chat/completions", {}, {"m": 1}, 5.0)
assert ei.value.status == 401
assert "bad key" in str(ei.value)
def test_test_endpoint_reports_failure(self, config_dir):
client = LlmClient(LlmConfig(provider="deepseek", api_key=""))
result = asyncio.run(client.test())
assert result["ok"] is False and "API Key" in result["error"]
class TestApiUrlSchemeGuard:
"""api_url SSRF 防线:仅允许 http/https、禁止携带 userinfo。
背景:_post_json 用 urllib 直连用户可配的 api_url,无 scheme 白名单时
``file:///...`` 可读本地文件(llm.json 内含明文 key,且格式异常分支会
回显响应前 300 字节)、``ftp://`` 与内网 http 可被当跳板。
"""
def test_file_scheme_rejected(self):
with pytest.raises(ValueError, match="http"):
resolve_config(
LlmConfig(provider="custom", api_url="file:///C:/Users/x/llm.json", model="m")
)
def test_ftp_scheme_rejected(self):
with pytest.raises(ValueError, match="http"):
resolve_config(LlmConfig(provider="custom", api_url="ftp://internal-host/x", model="m"))
def test_missing_scheme_rejected(self):
with pytest.raises(ValueError, match="http"):
resolve_config(LlmConfig(provider="custom", api_url="api.deepseek.com/v1", model="m"))
def test_userinfo_rejected(self):
with pytest.raises(ValueError, match="user:pass"):
resolve_config(
LlmConfig(provider="custom", api_url="https://user:pass@api.x.com/v1", model="m")
)
def test_http_https_case_insensitive_allowed(self):
r = resolve_config(LlmConfig(provider="custom", api_url="HTTPS://Api.X.com/v1", model="m"))
assert r.api_url == "HTTPS://Api.X.com/v1"
r2 = resolve_config(
LlmConfig(provider="custom", api_url="http://gw.local:8000/v1", model="m")
)
assert r2.api_url == "http://gw.local:8000/v1"
def test_preset_urls_still_resolve(self, config_dir):
save_config(LlmConfig(provider="deepseek", api_key="sk-x-1234567890"))
r = resolve_config()
assert r.api_url == "https://api.deepseek.com/v1"
class TestHttpPostHardening:
"""HTTP 层加固:错误不回显原始 body、响应体大小上限。"""
def _raise_http_error(self, body: bytes, code: int = 401):
import io
import urllib.error
def fake_urlopen(req, timeout):
raise urllib.error.HTTPError(
req.full_url, code, "Unauthorized", hdrs=None, fp=io.BytesIO(body)
)
return fake_urlopen
def test_http_error_extracts_provider_message_only(self, monkeypatch):
"""错误响应只回显 provider 的 error.message,不回显原始 body 其他内容。"""
import json as _json
body = _json.dumps(
{"error": {"message": "Invalid API key", "internal_hint": "SECRET-STACK"}}
).encode()
monkeypatch.setattr(llm_mod.urllib.request, "urlopen", self._raise_http_error(body))
with pytest.raises(LlmError) as ei:
llm_mod._post_json("https://x/v1/chat/completions", {}, {"m": 1}, 5.0)
assert ei.value.status == 401
assert "Invalid API key" in str(ei.value)
assert "SECRET-STACK" not in str(ei.value)
def test_http_error_non_json_body_is_generic(self, monkeypatch):
"""非 JSON 错误页不给原始内容,只给通用 HTTP 状态描述。"""
body = b"
gateway exploded with internal detail
"
monkeypatch.setattr(llm_mod.urllib.request, "urlopen", self._raise_http_error(body))
with pytest.raises(LlmError) as ei:
llm_mod._post_json("https://x/v1/chat/completions", {}, {"m": 1}, 5.0)
assert "gateway exploded" not in str(ei.value)
assert "401" in str(ei.value)
def test_http_error_string_error_field_still_shown(self, monkeypatch):
"""error 为字符串的网关(如 {"error":"bad key"})仍展示该消息。"""
monkeypatch.setattr(
llm_mod.urllib.request, "urlopen", self._raise_http_error(b'{"error":"bad key"}')
)
with pytest.raises(LlmError, match="bad key") as ei:
llm_mod._post_json("https://x/v1/chat/completions", {}, {"m": 1}, 5.0)
assert ei.value.status == 401
def test_response_body_size_capped(self, config_dir, monkeypatch):
"""超过 2MB 的响应体中止解析(防异常网关撑爆内存),报可操作错误。"""
class _FakeResp:
def __init__(self, payload: bytes) -> None:
self._buf = payload
def read(self, n: int = -1) -> bytes:
if n < 0:
data, self._buf = self._buf, b""
return data
data, self._buf = self._buf[:n], self._buf[n:]
return data
def __enter__(self) -> _FakeResp:
return self
def __exit__(self, *exc: object) -> bool:
return False
big = b"x" * (llm_mod._MAX_RESPONSE_BYTES + 1)
monkeypatch.setattr(llm_mod.urllib.request, "urlopen", lambda req, timeout: _FakeResp(big))
with pytest.raises(LlmError, match="过大|上限"):
llm_mod._post_json("https://x/v1/chat/completions", {}, {"m": 1}, 5.0)
def test_normal_response_within_cap_parses(self, config_dir, monkeypatch):
class _FakeResp:
def __init__(self, payload: bytes) -> None:
self._buf = payload
def read(self, n: int = -1) -> bytes:
if n < 0:
data, self._buf = self._buf, b""
return data
data, self._buf = self._buf[:n], self._buf[n:]
return data
def __enter__(self) -> _FakeResp:
return self
def __exit__(self, *exc: object) -> bool:
return False
payload = b'{"choices": [{"message": {"content": "OK"}}]}'
monkeypatch.setattr(
llm_mod.urllib.request, "urlopen", lambda req, timeout: _FakeResp(payload)
)
data = llm_mod._post_json("https://x/v1/chat/completions", {}, {"m": 1}, 5.0)
assert data["choices"][0]["message"]["content"] == "OK"
class TestSaveConfigAtomic:
def test_replace_failure_preserves_old_file(self, config_dir, monkeypatch):
"""os.replace 失败(磁盘满等)时旧配置原样保留,不留临时文件。"""
save_config(LlmConfig(provider="deepseek", api_key="sk-old-1234567890"))
def boom(src, dst):
raise OSError("disk full")
monkeypatch.setattr(llm_mod.os, "replace", boom)
with pytest.raises(OSError):
save_config(LlmConfig(provider="kimi", api_key="sk-new-9999999999"))
assert load_config().api_key == "sk-old-1234567890" # 旧配置未被破坏
leftovers = [p.name for p in config_dir.iterdir() if p.name != "llm.json"]
assert leftovers == [] # 失败的临时文件已清理
class TestLoadConfigFieldDefense:
"""手工编辑 llm.json 的脏字段不得打挂 load_config(全部 /llm/* 依赖它)。"""
def test_null_fields_fall_back_to_defaults(self, config_dir):
import json as _json
(config_dir / "llm.json").write_text(
_json.dumps(
{
"provider": None,
"api_url": None,
"api_key": None,
"model": None,
"temperature": None,
"max_tokens": None,
"timeout": None,
"system_prompt": None,
}
),
encoding="utf-8",
)
cfg = load_config() # 旧码:float(None) TypeError
assert cfg.provider == "deepseek"
assert cfg.api_url == "" and cfg.api_key == "" and cfg.model == ""
assert cfg.temperature == 0.3
assert cfg.max_tokens == 16000
assert cfg.timeout == 180.0
assert cfg.system_prompt == LlmConfig.system_prompt
def test_wrong_types_fall_back_with_warning(self, config_dir, caplog):
import json as _json
(config_dir / "llm.json").write_text(
_json.dumps(
{
"temperature": "abc",
"max_tokens": "fast",
"timeout": [],
"provider": 123,
"system_prompt": 456,
}
),
encoding="utf-8",
)
with caplog.at_level("WARNING", logger="easy_tdx.ai.llm"):
cfg = load_config() # 旧码:float("abc") ValueError
assert cfg.temperature == 0.3
assert cfg.max_tokens == 16000
assert cfg.timeout == 180.0
assert cfg.provider == "deepseek" # 非字符串 provider 回退默认
assert cfg.system_prompt == LlmConfig.system_prompt
assert any("temperature" in r.message for r in caplog.records)
def test_non_finite_and_out_of_range_fall_back(self, config_dir):
import json as _json
(config_dir / "llm.json").write_text(
_json.dumps({"temperature": 1e999, "timeout": -5, "max_tokens": 0}), # 1e999→inf
encoding="utf-8",
)
cfg = load_config() # 旧码:inf temperature 会一路写进请求 payload
assert cfg.temperature == 0.3
assert cfg.timeout == 180.0
assert cfg.max_tokens == 16000
def test_string_numbers_leniently_coerced(self, config_dir):
import json as _json
(config_dir / "llm.json").write_text(
_json.dumps({"temperature": "0.7", "max_tokens": "8192.9", "timeout": "60"}),
encoding="utf-8",
)
cfg = load_config()
assert cfg.temperature == 0.7
assert cfg.max_tokens == 8192
assert cfg.timeout == 60.0
class TestAnthropicRobustness:
"""anthropic 协议与 openai 口径对齐:绝不静默返回空正文。"""
def _client(self) -> LlmClient:
return LlmClient(LlmConfig(provider="claude", api_key="sk-ant-123456789"))
def test_thinking_only_blocks_raise_actionable(self, config_dir, monkeypatch):
"""仅 thinking 块(max_tokens 被思考耗尽)→ 可操作错误,而非空串成功。"""
def fake_post(url, headers, payload, timeout):
return {
"content": [{"type": "thinking", "thinking": "思考" * 200}],
"stop_reason": "max_tokens",
}
monkeypatch.setattr(llm_mod, "_post_json", fake_post)
with pytest.raises(LlmError, match="思考链"):
asyncio.run(self._client().chat("hi"))
def test_content_as_plain_string_accepted(self, config_dir, monkeypatch):
"""部分网关把 content 放字符串而非块列表——正常取正文。"""
def fake_post(url, headers, payload, timeout):
return {"content": "纯字符串回复"}
monkeypatch.setattr(llm_mod, "_post_json", fake_post)
assert asyncio.run(self._client().chat("hi")) == "纯字符串回复"
def test_mixed_blocks_text_extracted(self, config_dir, monkeypatch):
def fake_post(url, headers, payload, timeout):
return {
"content": [
{"type": "thinking", "thinking": "思考"},
{"type": "text", "text": "正文"},
],
"stop_reason": "end_turn",
}
monkeypatch.setattr(llm_mod, "_post_json", fake_post)
assert asyncio.run(self._client().chat("hi")) == "正文"
def test_missing_content_raises_llm_error(self, config_dir, monkeypatch):
"""content 缺失 → LlmError(旧码 AttributeError 裸 500)。"""
def fake_post(url, headers, payload, timeout):
return {"stop_reason": "end_turn"}
monkeypatch.setattr(llm_mod, "_post_json", fake_post)
with pytest.raises(LlmError, match="格式异常"):
asyncio.run(self._client().chat("hi"))
def test_empty_blocks_generic_error_without_raw_echo(self, config_dir, monkeypatch):
def fake_post(url, headers, payload, timeout):
return {"content": [{"type": "tool_use", "id": "tool_1", "secret": "S3CR3T"}]}
monkeypatch.setattr(llm_mod, "_post_json", fake_post)
with pytest.raises(LlmError, match="content 为空") as ei:
asyncio.run(self._client().chat("hi"))
assert "S3CR3T" not in str(ei.value) # 不回显原始响应体
def test_empty_string_content_with_max_tokens_stop(self, config_dir, monkeypatch):
def fake_post(url, headers, payload, timeout):
return {"content": "", "stop_reason": "max_tokens"}
monkeypatch.setattr(llm_mod, "_post_json", fake_post)
with pytest.raises(LlmError, match="截断"):
asyncio.run(self._client().chat("hi"))
def test_provider_presets_cover_major_vendors():
vendors = [
"deepseek",
"qwen",
"zhipu",
"kimi",
"minimax",
"openai",
"claude",
"ollama",
"custom",
]
for pid in vendors:
assert pid in PROVIDER_PRESETS, pid
assert PROVIDER_PRESETS["claude"].api_style == "anthropic"
assert PROVIDER_PRESETS["ollama"].needs_key is False
assert PROVIDER_PRESETS["zhipu"].base_url.startswith("https://open.bigmodel.cn")
class TestTimeoutSemantics:
def test_default_timeout_is_generous(self):
"""默认超时 ≥120s:非流式接口需等模型生成完整段回复(大报告 1-3 分钟)。"""
assert LlmConfig().timeout >= 120
def test_read_timeout_actionable_message(self, monkeypatch):
"""读超时单独成类报错,文案给出「调大超时」动作而非裸异常。"""
def fake_urlopen(req, timeout):
raise TimeoutError("The read operation timed out")
monkeypatch.setattr(llm_mod.urllib.request, "urlopen", fake_urlopen)
with pytest.raises(LlmError, match="请求超时(180s"):
llm_mod._post_json("https://x/v1/chat/completions", {}, {"m": 1}, 180.0)
def test_connect_timeout_via_urlerror(self, monkeypatch):
"""连接期超时(URLError.reason=TimeoutError)同样走超时文案。"""
import urllib.error
def fake_urlopen(req, timeout):
raise urllib.error.URLError(TimeoutError("timed out"))
monkeypatch.setattr(llm_mod.urllib.request, "urlopen", fake_urlopen)
with pytest.raises(LlmError, match="请求超时"):
llm_mod._post_json("https://x/v1/chat/completions", {}, {"m": 1}, 30.0)
class TestAsyncChatTask:
"""POST /llm/chat/async + GET /llm/chat/tasks/{id} 的提交-轮询闭环。"""
@pytest.fixture(autouse=True)
def _fresh_history_store(self, config_dir):
"""每个用例用独立的 llm_history.db(模块级单例绑定了首个用例的临时目录)。"""
import easy_tdx.web.llm_history_store as hs
hs._store = None
yield
hs._store = None
def test_submit_and_poll_done(self, config_dir, monkeypatch):
import time
from fastapi.testclient import TestClient
from easy_tdx.web.app import _create_app
def fake_chat(self, prompt, system_prompt=None):
async def _slow():
await asyncio.sleep(0.05)
return f"解读:{prompt[:8]}"
return _slow()
monkeypatch.setattr(LlmClient, "chat", fake_chat)
app = _create_app(enable_mac=False, enable_ui=False)
with TestClient(app) as c:
r = c.post("/api/v1/llm/chat/async", json={"prompt": "整份回测报告…" * 10})
assert r.status_code == 202, r.text
task_id = r.json()["task_id"]
assert r.json()["status"] in ("pending", "running")
state = None
for _ in range(50):
state = c.get(f"/api/v1/llm/chat/tasks/{task_id}").json()
if state["status"] in ("done", "failed"):
break
time.sleep(0.05)
assert state["status"] == "done", state
assert state["result"]["reply"].startswith("解读:")
assert state["result"]["elapsed"] >= 0.0
def test_task_failure_surfaces_error(self, config_dir, monkeypatch):
import time
from fastapi.testclient import TestClient
from easy_tdx.web.app import _create_app
def fake_chat(self, prompt, system_prompt=None):
async def _boom():
raise LlmError("请求超时(180s 内无响应)")
return _boom()
monkeypatch.setattr(LlmClient, "chat", fake_chat)
app = _create_app(enable_mac=False, enable_ui=False)
with TestClient(app) as c:
task_id = c.post("/api/v1/llm/chat/async", json={"prompt": "x"}).json()["task_id"]
state = None
for _ in range(50):
state = c.get(f"/api/v1/llm/chat/tasks/{task_id}").json()
if state["status"] in ("done", "failed"):
break
time.sleep(0.05)
assert state["status"] == "failed"
assert "请求超时" in state["error"]
def test_unknown_task_rejected(self, config_dir):
"""未知 task → 400(与 GET /backtest/tasks/{id} 的 ValueError 约定一致)。"""
from fastapi.testclient import TestClient
from easy_tdx.web.app import _create_app
app = _create_app(enable_mac=False, enable_ui=False)
with TestClient(app) as c:
r = c.get("/api/v1/llm/chat/tasks/nonexistent")
assert r.status_code == 400
assert "未知任务" in r.json()["detail"]
def test_async_success_records_history(self, config_dir, monkeypatch):
"""异步解读成功 → 自动落历史库(含策略上下文),供历史页查询。"""
import time as _time
from fastapi.testclient import TestClient
from easy_tdx.web.app import _create_app
async def fake_chat(self, prompt, system_prompt=None):
return "解读正文"
monkeypatch.setattr(LlmClient, "chat", fake_chat)
app = _create_app(enable_mac=False, enable_ui=False)
with TestClient(app) as c:
ctx = {
"strategy": "ma_cross",
"strategy_label": "双均线交叉",
"symbol": "600519",
"category": "DAY",
"params": {"fast": 5, "slow": 20},
"start_date": "2024-01-01",
"end_date": "2025-01-01",
}
tid = c.post("/api/v1/llm/chat/async", json={"prompt": "报告", "context": ctx}).json()[
"task_id"
]
for _ in range(50):
st = c.get(f"/api/v1/llm/chat/tasks/{tid}").json()
if st["status"] in ("done", "failed"):
break
_time.sleep(0.05)
assert st["status"] == "done", st
hist = c.get("/api/v1/llm/history").json()
assert hist["count"] >= 1
item = hist["items"][0]
assert item["reply"] == "解读正文"
assert item["strategy"] == "ma_cross" and item["symbol"] == "600519"
assert item["params"] == {"fast": 5, "slow": 20}
# 删除一条
r = c.delete(f"/api/v1/llm/history/{item['id']}")
assert r.json()["ok"] is True
assert c.get("/api/v1/llm/history").json()["count"] == hist["count"] - 1
def test_async_failure_not_recorded(self, config_dir, monkeypatch):
"""解读失败 → 不落历史(历史只归档成功解读)。"""
import time as _time
from fastapi.testclient import TestClient
from easy_tdx.web.app import _create_app
async def fake_chat(self, prompt, system_prompt=None):
raise LlmError("boom")
monkeypatch.setattr(LlmClient, "chat", fake_chat)
app = _create_app(enable_mac=False, enable_ui=False)
with TestClient(app) as c:
tid = c.post("/api/v1/llm/chat/async", json={"prompt": "x"}).json()["task_id"]
for _ in range(50):
st = c.get(f"/api/v1/llm/chat/tasks/{tid}").json()
if st["status"] in ("done", "failed"):
break
_time.sleep(0.05)
assert st["status"] == "failed"
assert c.get("/api/v1/llm/history").json()["count"] == 0
def test_submit_rejects_incomplete_config(self, config_dir):
"""custom 未填 url/model:提交期即 400(不等任务跑起来才失败)。"""
from fastapi.testclient import TestClient
from easy_tdx.web.app import _create_app
app = _create_app(enable_mac=False, enable_ui=False)
with TestClient(app) as c:
r = c.post(
"/api/v1/llm/chat/async",
json={"prompt": "x", "override": {"provider": "custom"}},
)
assert r.status_code == 400
assert "不完整" in r.json()["detail"]
class TestThinkingModelBlankContent:
"""思考型模型正文空白(reasoning_content 耗尽 max_tokens)的防御。
v1.29.1 实测:GLM-5.x 思考链计入 max_tokens,预算耗尽时 content 为
空白——truthy 但渲染为空(状态条报成功、正文空白)。解析层必须把
这类响应转成可操作的错误,绝不返回空白字符串。
"""
def _client(self, max_tokens: int = 4000) -> LlmClient:
return LlmClient(
LlmConfig(
provider="zhipu",
api_key="sk-x-1234567890",
model="glm-5.3-flash",
max_tokens=max_tokens,
)
)
def test_normal_content_wins_over_reasoning(self):
msg = {"content": "正文", "reasoning_content": "思考…", "role": "assistant"}
assert self._client()._extract_reply_openai(msg, "stop") == "正文"
def test_blank_content_with_reasoning_raises_actionable(self):
msg = {"content": " ", "reasoning_content": "思考" * 500, "role": "assistant"}
with pytest.raises(LlmError, match="思考链.*4000.*16000"):
self._client()._extract_reply_openai(msg, "length")
def test_null_content_with_reasoning(self):
msg = {"content": None, "reasoning_content": "思考", "role": "assistant"}
with pytest.raises(LlmError, match="思考链"):
self._client()._extract_reply_openai(msg, "length")
def test_blank_content_without_reasoning(self):
with pytest.raises(LlmError, match="content 为空"):
self._client()._extract_reply_openai({"content": ""}, "stop")
def test_length_finish_without_content(self):
with pytest.raises(LlmError, match="截断"):
self._client()._extract_reply_openai({"content": ""}, "length")
def test_whitespace_reply_rejected_end_to_end(self, config_dir, monkeypatch):
"""端到端:伪 HTTP 返回空白正文 → chat() 抛错(任务态 failed 而非 done 空回复)。"""
def fake_post(url, headers, payload, timeout):
blank = chr(10) + " " + chr(10)
return {
"choices": [
{
"message": {"content": blank, "reasoning_content": "r"},
"finish_reason": "length",
}
]
}
monkeypatch.setattr(llm_mod, "_post_json", fake_post)
with pytest.raises(LlmError, match="思考链"):
asyncio.run(self._client().chat("报告"))
def test_default_max_tokens_generous_for_thinking(self):
assert LlmConfig().max_tokens >= 16000