| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929 |
- """核心单元测试:跟踪器、计量器、来源、提示词、工具、并发、计费、存储。
- 这些用例全部离线可跑(不起服务、不连模型)。端到端的 HTTP 验证见
- ``scripts/http_e2e.py``(需要一个真实可用的模型密钥)。
- """
- from __future__ import annotations
- import json
- import pytest
- # ============================================================
- # 子智能体跟踪
- # ============================================================
- class TestSubagentTracker:
- def test_namespace_prefix_parsing(self):
- from api.subagent_tracker import parse_namespace
- assert parse_namespace(("tools:abc123",)) == "abc123"
- assert parse_namespace(["tools:x"]) == "x"
- assert parse_namespace("tools:y") == "y"
- def test_empty_namespace_is_main_agent(self):
- from api.subagent_tracker import parse_namespace
- assert parse_namespace(()) is None
- assert parse_namespace(None) is None
- assert parse_namespace([]) is None
- def test_control_segments_not_mistaken_for_ids(self):
- """带冒号/斜杠的控制段不能被当成 tool_call id。"""
- from api.subagent_tracker import parse_namespace
- assert parse_namespace(("model_request:1",)) is None
- assert parse_namespace(("a/b",)) is None
- def test_lifecycle_started_to_done(self):
- from api.subagent_tracker import SubagentTracker
- tracker = SubagentTracker()
- calls = [{"name": "task", "id": "c1", "args": {"subagent_type": "demo-searcher", "description": "查资料"}}]
- started = tracker.note_task_calls(calls)
- assert len(started) == 1
- assert started[0].subagent_type == "demo-searcher"
- assert started[0].status == "started"
- run = tracker.note_namespace(("tools:c1",))
- assert run is not None and run.status == "running" and run.tool_calls == 1
- done = tracker.note_result("c1", ok=True)
- assert done is not None and done.status == "done"
- assert done.duration_ms is not None
- def test_non_task_calls_ignored(self):
- from api.subagent_tracker import SubagentTracker
- tracker = SubagentTracker()
- assert tracker.note_task_calls([{"name": "calculate", "id": "c1", "args": {}}]) == []
- assert len(tracker) == 0
- def test_call_without_id_skipped(self):
- """没有 id 就无法关联后续子图事件,跳过而不是编一个假 id。"""
- from api.subagent_tracker import SubagentTracker
- tracker = SubagentTracker()
- assert tracker.note_task_calls([{"name": "task", "args": {"subagent_type": "x"}}]) == []
- assert len(tracker) == 0
- def test_unknown_namespace_does_not_invent_run(self):
- """表里没有的 id 不能凭空造一条 subagent_type=unknown 的记录。"""
- from api.subagent_tracker import SubagentTracker
- tracker = SubagentTracker()
- assert tracker.note_namespace(("tools:never-seen",)) is None
- assert len(tracker) == 0
- def test_finish_all_flushes_active(self):
- """流异常退出时不能让子智能体永远挂在 started,前端卡片会一直转。"""
- from api.subagent_tracker import SubagentTracker
- tracker = SubagentTracker()
- tracker.note_task_calls([
- {"name": "task", "id": "c1", "args": {"subagent_type": "a"}},
- {"name": "task", "id": "c2", "args": {"subagent_type": "b"}},
- ])
- tracker.note_result("c1", ok=True)
- flushed = tracker.finish_all()
- assert len(flushed) == 1 and flushed[0].call_id == "c2"
- assert not tracker.active()
- def test_duplicate_task_call_not_counted_twice(self):
- from api.subagent_tracker import SubagentTracker
- tracker = SubagentTracker()
- call = {"name": "task", "id": "c1", "args": {"subagent_type": "a"}}
- tracker.note_task_calls([call])
- tracker.note_task_calls([call])
- assert len(tracker) == 1
- # ============================================================
- # 上下文计量
- # ============================================================
- class _Msg:
- """最小的消息替身。"""
- def __init__(self, content="", usage_metadata=None, response_metadata=None,
- additional_kwargs=None, name=None) -> None:
- self.content = content
- self.usage_metadata = usage_metadata
- self.response_metadata = response_metadata or {}
- self.additional_kwargs = additional_kwargs or {}
- self.name = name
- class TestContextMeter:
- def test_extract_usage_from_langchain_metadata(self):
- from api.context_meter import extract_usage
- usage = extract_usage(_Msg(usage_metadata={
- "input_tokens": 100, "output_tokens": 20, "total_tokens": 120,
- "input_token_details": {"cache_read": 30},
- }))
- assert usage == {"input_tokens": 100, "output_tokens": 20, "cache_read_tokens": 30}
- def test_extract_usage_from_provider_raw(self):
- from api.context_meter import extract_usage
- usage = extract_usage(_Msg(response_metadata={
- "token_usage": {"prompt_tokens": 50, "completion_tokens": 5,
- "prompt_tokens_details": {"cached_tokens": 8}}
- }))
- assert usage == {"input_tokens": 50, "output_tokens": 5, "cache_read_tokens": 8}
- def test_missing_usage_returns_none_not_zero(self):
- """「用量为 0」与「没有用量信息」必须能区分,否则估算兜底会失效。"""
- from api.context_meter import extract_usage
- assert extract_usage(_Msg()) is None
- assert extract_usage(_Msg(usage_metadata={})) is None
- def test_accumulates_across_model_calls(self):
- from api.context_meter import ContextMeter
- meter = ContextMeter(window=1000)
- meter.note_message(_Msg(usage_metadata={"input_tokens": 100, "output_tokens": 10}))
- meter.note_message(_Msg(usage_metadata={"input_tokens": 150, "output_tokens": 20}))
- assert meter.input_tokens == 250
- assert meter.output_tokens == 30
- assert meter.model_calls == 2
- def test_context_tokens_is_last_input(self):
- """上下文占用取最近一次调用的 input,而不是累加值。"""
- from api.context_meter import ContextMeter
- meter = ContextMeter(window=1000)
- meter.note_message(_Msg(usage_metadata={"input_tokens": 100, "output_tokens": 10}))
- meter.note_message(_Msg(usage_metadata={"input_tokens": 180, "output_tokens": 10}))
- assert meter.context_tokens == 180
- assert meter.input_tokens == 280
- def test_source_is_provider_when_usage_present(self):
- from api.context_meter import ContextMeter
- meter = ContextMeter(window=1000)
- meter.note_message(_Msg(usage_metadata={"input_tokens": 5, "output_tokens": 5}))
- assert meter.usage_source == "provider"
- def test_estimate_marks_source_estimate(self):
- from api.context_meter import ContextMeter
- meter = ContextMeter(window=1000)
- meter.note_estimated(300)
- assert meter.usage_source == "estimate"
- assert meter.input_tokens == 300
- def test_estimate_ignored_when_real_usage_seen(self):
- """已有真实用量时估算值只会污染口径,丢弃。"""
- from api.context_meter import ContextMeter
- meter = ContextMeter(window=1000)
- meter.note_message(_Msg(usage_metadata={"input_tokens": 100, "output_tokens": 10}))
- meter.note_estimated(9999)
- assert meter.input_tokens == 100
- assert meter.usage_source == "provider"
- def test_compaction_detected_by_lc_source(self):
- from api.context_meter import ContextMeter
- meter = ContextMeter(window=1000)
- meter.note_message(_Msg(content="摘要", response_metadata={"lc_source": "summarization"}))
- assert meter.compacted and meter.compact_count == 1
- def test_compaction_detected_by_context_drop(self):
- """占用断崖式下跌也视为压缩。"""
- from api.context_meter import ContextMeter
- meter = ContextMeter(window=10000)
- meter.note_message(_Msg(usage_metadata={"input_tokens": 9000, "output_tokens": 10}))
- assert not meter.compacted
- meter.note_message(_Msg(usage_metadata={"input_tokens": 2000, "output_tokens": 10}))
- assert meter.compacted and meter.compact_count == 1
- def test_compaction_counted_once(self):
- """两个信号同时命中不能重复计数。"""
- from api.context_meter import ContextMeter
- meter = ContextMeter(window=10000)
- meter.note_message(_Msg(usage_metadata={"input_tokens": 9000, "output_tokens": 1}))
- meter.note_message(_Msg(usage_metadata={"input_tokens": 100, "output_tokens": 1}))
- meter.note_message(_Msg(content="x", response_metadata={"lc_source": "summarization"}))
- assert meter.compact_count == 1
- def test_archive_text_captured_on_compaction(self):
- from api.context_meter import ContextMeter
- meter = ContextMeter(window=1000)
- meter.note_text("被压缩掉的早期对话")
- meter.note_message(_Msg(content="摘要", response_metadata={"lc_source": "summarization"}))
- snapshot = meter.snapshot()
- assert snapshot.compacted and snapshot.archive_text == "被压缩掉的早期对话"
- def test_fill_ratio(self):
- from api.context_meter import ContextMeter
- meter = ContextMeter(window=1000)
- meter.note_message(_Msg(usage_metadata={"input_tokens": 850, "output_tokens": 0}))
- assert meter.fill_ratio() == pytest.approx(0.85)
- def test_estimate_tokens_positive_for_cjk(self):
- from api.context_meter import estimate_tokens
- assert estimate_tokens("矿井通风") > 0
- assert estimate_tokens("") == 0
- # ============================================================
- # 来源标注
- # ============================================================
- class TestSourceTracker:
- def test_extract_from_sourced_payload(self):
- from api.source_tracker import SourceTracker
- from tools.registry import source, sourced
- tracker = SourceTracker()
- payload = json.dumps(sourced(data=[1], sources=[source("T", "https://a.invalid/x", "摘要", 0.5)]))
- added = tracker.note_tool_result("search_demo_knowledge", payload)
- assert len(added) == 1 and added[0]["title"] == "T"
- def test_extract_from_dict_payload(self):
- from api.source_tracker import SourceTracker
- from tools.registry import source, sourced
- tracker = SourceTracker()
- tracker.note_tool_result("t", sourced(data=[], sources=[source("T", "https://a.invalid")]))
- assert tracker.count == 1
- def test_dedup_by_url(self):
- from api.source_tracker import SourceTracker
- from tools.registry import source, sourced
- tracker = SourceTracker()
- payload = sourced(data=[], sources=[source("T", "https://same.invalid"), source("T2", "https://same.invalid")])
- tracker.note_tool_result("t", payload)
- assert tracker.count == 1
- def test_records_without_title_or_url_dropped(self):
- """既无标题也无链接的来源无法核实,不展示空壳。"""
- from api.source_tracker import SourceTracker
- from tools.registry import sourced
- tracker = SourceTracker()
- tracker.note_tool_result("t", sourced(data=[], sources=[{"snippet": "只有摘要"}]))
- assert tracker.count == 0
- def test_plain_text_yields_nothing(self):
- from api.source_tracker import SourceTracker
- tracker = SourceTracker()
- tracker.note_tool_result("t", "这是一段普通文本,没有 sources")
- assert tracker.count == 0
- def test_classification_web_vs_kb(self):
- from api.source_tracker import SourceTracker
- from tools.registry import source, sourced
- tracker = SourceTracker()
- tracker.note_tool_result("t", sourced(data=[], sources=[
- source("网页", "https://news.example.com/a"),
- source("知识库", "https://kb.example.com/b"),
- ]))
- types = {i["title"]: i["type"] for i in tracker.build_citations()}
- assert types["网页"] == "web"
- assert types["知识库"] == "knowledge_base"
- def test_nested_payload_lookup(self):
- from api.source_tracker import SourceTracker
- from tools.registry import source
- tracker = SourceTracker()
- tracker.note_tool_result("t", {"data": {"sources": [source("深层", "https://d.invalid")]}})
- assert tracker.count == 1 and tracker.build_citations()[0]["title"] == "深层"
- def test_citations_sorted_by_score(self):
- from api.source_tracker import SourceTracker
- from tools.registry import source, sourced
- tracker = SourceTracker()
- tracker.note_tool_result("t", sourced(data=[], sources=[
- source("低", "https://a.invalid", score=0.1),
- source("高", "https://b.invalid", score=0.9),
- ]))
- assert [i["title"] for i in tracker.build_citations()] == ["高", "低"]
- def test_label_attached(self):
- from api.source_tracker import SourceTracker
- from tools.registry import source, sourced
- tracker = SourceTracker()
- tracker.note_tool_result("search_demo_knowledge",
- sourced(data=[], sources=[source("T", "https://a.invalid")]))
- item = tracker.build_citations()[0]
- assert item["tool"] == "search_demo_knowledge"
- assert item["tool_cn"] == "检索演示知识库"
- # ============================================================
- # 提示词与消息前缀
- # ============================================================
- class TestPrompts:
- def test_skill_prefix_extracted(self):
- from agents.prompts import extract_skill_prefix
- text, skill = extract_skill_prefix("#skill:demo-skill 帮我写一份简报")
- assert skill == "demo-skill" and text == "帮我写一份简报"
- def test_skill_prefix_only_at_start(self):
- """正文中间的 #skill: 是普通文本,不能当指令。"""
- from agents.prompts import extract_skill_prefix
- text, skill = extract_skill_prefix("请看这个 #skill:demo-skill 的用法")
- assert skill is None and text.startswith("请看这个")
- def test_skill_prefix_without_message(self):
- from agents.prompts import extract_skill_prefix
- text, skill = extract_skill_prefix("#skill:demo-skill")
- assert skill == "demo-skill" and text == ""
- def test_path_traversal_rejected(self):
- """技能名带路径分隔符不能当技能解析——否则能读任意文件。"""
- from agents.prompts import extract_skill_prefix
- for bad in ("#skill:../../etc/passwd x", "#skill:.hidden x", "#skill:a/b x"):
- _, skill = extract_skill_prefix(bad)
- assert skill is None, bad
- def test_session_ref_extracted(self):
- from agents.prompts import extract_session_ref
- text, ref = extract_session_ref("#session:abc123 接着上次说")
- assert ref == "abc123" and text == "接着上次说"
- def test_both_prefixes_stacked(self):
- from agents.prompts import extract_session_ref, extract_skill_prefix
- text, skill = extract_skill_prefix("#skill:demo-skill #session:abc 继续")
- text, ref = extract_session_ref(text)
- assert skill == "demo-skill" and ref == "abc" and text == "继续"
- def test_mode_instruction_differs(self):
- from agents.prompts import build_mode_instruction
- plan = build_mode_instruction("plan")
- full = build_mode_instruction("full")
- assert "plan" in plan and "计划" in plan
- assert "full" in full and plan != full
- def test_preference_instruction_empty_when_no_prefs(self):
- from agents.prompts import build_preference_instruction
- assert build_preference_instruction("") == ""
- def test_time_block_format(self):
- from agents.prompts import build_time_block
- block = build_time_block()
- assert "## 系统时间" in block and "当前时间" in block
- def test_system_prompt_includes_subagents(self):
- from agents.prompts import build_system_prompt
- prompt = build_system_prompt([
- {"name": "demo-searcher", "description": "检索专员"}
- ])
- assert "demo-searcher" in prompt
- assert "检索专员" in prompt
- def test_system_prompt_without_subagents(self):
- from agents.prompts import build_system_prompt
- prompt = build_system_prompt([])
- assert "子智能体" in prompt # 通用说明里提到,但不该有具体名单
- assert "可委派的子智能体" not in prompt
- # ============================================================
- # 工具
- # ============================================================
- class TestTools:
- def test_builtin_tools_registered(self):
- from tools import registry
- names = set(registry.get_tool_names())
- assert {"get_current_time", "search_demo_knowledge", "calculate",
- "request_plan_approval"} <= names
- def test_tools_are_basetool_instances(self):
- """子智能体的 tools 必须是 BaseTool(ToolNode 要读 .name)。"""
- from tools import registry
- for tool in registry.get_all_tools():
- assert hasattr(tool, "name") and hasattr(tool, "description")
- def test_get_tools_by_name_skips_unknown(self):
- from tools import registry
- tools = registry.get_tools_by_name(["calculate", "does-not-exist"])
- assert [t.name for t in tools] == ["calculate"]
- def test_duplicate_registration_raises(self):
- from tools.registry import register, reset_for_tests
- reset_for_tests()
- @register(group="X")
- def dup_tool() -> str:
- """d"""
- return ""
- with pytest.raises(ValueError, match="工具名冲突"):
- @register(group="Y")
- def dup_tool() -> str: # noqa: F811
- """d"""
- return ""
- reset_for_tests()
- from tools import registry as r
- r.load_builtin_tools()
- def test_calculate_basic(self):
- from tools.demo_tools import calculate
- assert calculate("(128000 * 0.85)")["result"] == 108800
- def test_calculate_integer_result_is_int(self):
- from tools.demo_tools import calculate
- assert isinstance(calculate("1+1")["result"], int)
- def test_calculate_rejects_code_execution(self):
- """表达式求值绝不能变成代码执行入口。"""
- from tools.demo_tools import calculate
- for evil in [
- "__import__('os').system('echo x')",
- "open('/etc/passwd').read()",
- "(lambda: 1)()",
- "1 if True else 2",
- "[x for x in range(3)]",
- ]:
- result = calculate(evil)
- assert "error" in result, evil
- def test_calculate_rejects_huge_power(self):
- from tools.demo_tools import calculate
- assert "error" in calculate("9 ** 9 ** 9")
- def test_calculate_zero_division(self):
- from tools.demo_tools import calculate
- assert "零" in calculate("1/0")["error"]
- def test_calculate_empty(self):
- from tools.demo_tools import calculate
- assert "error" in calculate("")
- def test_calculate_returns_error_not_raises(self):
- """工具失败要返回可读原因,抛异常会打断整轮工具调用。"""
- from tools.demo_tools import calculate
- result = calculate("1 +")
- assert isinstance(result, dict) and "error" in result
- def test_search_returns_sources_contract(self):
- from tools.demo_tools import search_demo_knowledge
- result = search_demo_knowledge("需风量")
- assert result["data"] and result["sources"]
- assert result["sources"][0]["url"]
- def test_search_empty_query_returns_error(self):
- from tools.demo_tools import search_demo_knowledge
- result = search_demo_knowledge(" ")
- assert result["error"] and result["sources"] == []
- def test_search_no_match(self):
- from tools.demo_tools import search_demo_knowledge
- result = search_demo_knowledge("不存在的关键词zzz")
- assert result["data"] == [] and result["sources"] == []
- def test_registry_describe_shape(self):
- from tools import registry
- described = registry.describe()
- assert described["count"] >= 4
- assert all(isinstance(v, list) for v in described["groups"].values())
- # ============================================================
- # 并发闸
- # ============================================================
- class TestConcurrency:
- def setup_method(self):
- from api import concurrency
- concurrency.reset_for_tests()
- def teardown_method(self):
- from api import concurrency
- concurrency.reset_for_tests()
- def test_acquire_and_release(self):
- from api import concurrency
- slot = concurrency.acquire_task_slot("u1")
- assert concurrency.stats()["global_active"] == 1
- assert slot.release() is True
- assert concurrency.stats()["global_active"] == 0
- def test_release_is_idempotent(self):
- """SSE 有多条退出路径,重复 release 不能把计数减成负数。"""
- from api import concurrency
- slot = concurrency.acquire_task_slot("u1")
- slot.release()
- slot.release()
- assert concurrency.stats()["global_active"] == 0
- def test_per_user_limit(self, monkeypatch):
- from api import concurrency
- monkeypatch.setattr(concurrency.config, "MAX_USER_TASKS", 1)
- concurrency.acquire_task_slot("u1")
- with pytest.raises(concurrency.TooManyTasksError) as exc:
- concurrency.acquire_task_slot("u1")
- assert exc.value.scope == "user"
- def test_global_limit(self, monkeypatch):
- from api import concurrency
- monkeypatch.setattr(concurrency.config, "MAX_CONCURRENT_TASKS", 1)
- monkeypatch.setattr(concurrency.config, "MAX_USER_TASKS", 10)
- concurrency.acquire_task_slot("u1")
- with pytest.raises(concurrency.TooManyTasksError) as exc:
- concurrency.acquire_task_slot("u2")
- assert exc.value.scope == "global"
- def test_different_users_independent(self, monkeypatch):
- from api import concurrency
- monkeypatch.setattr(concurrency.config, "MAX_USER_TASKS", 1)
- concurrency.acquire_task_slot("u1")
- concurrency.acquire_task_slot("u2")
- assert concurrency.stats()["global_active"] == 2
- # ============================================================
- # 计费
- # ============================================================
- class TestBilling:
- def test_cost_computation(self, pricing_ready):
- from billing import pricing
- from db import billing_store
- billing_store.set_pricing("m1", 0.001, 0.002)
- cost, currency = pricing.compute_cost("m1", 1000, 500)
- assert cost == pytest.approx(0.001 + 0.001)
- assert currency == "CNY"
- def test_unpriced_model_fails_closed(self, pricing_ready, monkeypatch):
- """静默按 0 计费等于白送额度,所以默认必须报错。"""
- from billing import pricing
- monkeypatch.setattr(pricing.config, "get_bool", lambda *a, **k: False)
- with pytest.raises(pricing.UnpricedModelError):
- pricing.compute_cost("unknown-model", 100, 100)
- def test_unpriced_model_allowed_when_configured(self, pricing_ready, monkeypatch):
- from billing import pricing
- monkeypatch.setattr(pricing.config, "get_bool", lambda *a, **k: True)
- cost, _ = pricing.compute_cost("unknown-model", 100, 100)
- assert cost == 0.0
- def test_quota_blocks_when_exhausted(self, pricing_ready, unique_user):
- from billing import guard
- from db import billing_store, db_conn
- day = db_conn.day_key()
- billing_store.set_quota_limit(unique_user, day, 100)
- billing_store.record_usage(unique_user, "s", "chat", "m", 100, 0, 0.0, day)
- with pytest.raises(guard.QuotaExceededError):
- guard.precheck(unique_user, day)
- def test_quota_zero_means_unlimited(self, pricing_ready, unique_user):
- from billing import guard
- from db import billing_store, db_conn
- day = db_conn.day_key()
- billing_store.set_quota_limit(unique_user, day, 0)
- billing_store.record_usage(unique_user, "s", "chat", "m", 10_000_000, 0, 0.0, day)
- guard.precheck(unique_user, day) # 不抛即通过
- def test_meter_settle_idempotent(self, pricing_ready, unique_user):
- from billing import guard
- from billing.meter import UsageMeter
- meter = UsageMeter(unique_user, "s1", "chat", "deepseek-flash")
- meter.add(1000, 100)
- first = meter.settle()
- second = meter.settle()
- assert first["settled"] is True
- assert second.get("duplicated") is True
- assert guard.snapshot(unique_user)["requests"] == 1
- def test_meter_no_usage_skips(self, pricing_ready, unique_user):
- from billing import guard
- from billing.meter import UsageMeter
- meter = UsageMeter(unique_user, "s1", "chat", "deepseek-flash")
- result = meter.settle()
- assert result["settled"] is False and result["reason"] == "no_usage"
- assert guard.snapshot(unique_user)["requests"] == 0
- def test_meter_source_downgrades_to_estimate(self, pricing_ready, unique_user):
- from billing.meter import UsageMeter
- meter = UsageMeter(unique_user, "s1", "chat", "deepseek-flash")
- meter.add(100, 10, "provider")
- meter.add(50, 5, "estimate")
- assert meter.usage_source == "estimate"
- def test_snapshot_has_total_tokens(self, pricing_ready, unique_user):
- """字段名与 billing_store.usage_for_day 对齐,消费方不用记两套名字。"""
- from billing import guard
- snapshot = guard.snapshot(unique_user)
- assert "total_tokens" in snapshot and "used_tokens" in snapshot
- def test_usage_day_bound_to_request_start(self, pricing_ready, unique_user):
- """跨零点的长请求不能被算到第二天。"""
- from billing import guard
- from billing.meter import UsageMeter
- from db import db_conn
- yesterday = "20200101"
- meter = UsageMeter(unique_user, "s1", "chat", "deepseek-flash", usage_day=yesterday)
- meter.add(100, 10)
- meter.settle()
- assert guard.snapshot(unique_user, yesterday)["requests"] == 1
- assert guard.snapshot(unique_user, db_conn.day_key())["requests"] == 0
- # ============================================================
- # 存储
- # ============================================================
- class TestStores:
- def test_message_ordering_stable_within_same_second(self):
- """同秒写入的多条消息顺序必须稳定——排序错乱会让补种的上下文角色错位。"""
- from db import chat_store
- sid = chat_store.create_session("u", "full")["id"]
- for i in range(6):
- chat_store.save_message(sid, "user", f"Q{i}")
- chat_store.save_message(sid, "assistant", f"A{i}")
- rows = chat_store.get_messages(sid)
- assert [r["msg_role"] for r in rows] == ["user", "assistant"] * 6
- assert [r["content"] for r in rows] == [
- c for i in range(6) for c in (f"Q{i}", f"A{i}")
- ]
- def test_get_messages_limit_returns_most_recent_in_order(self):
- from db import chat_store
- sid = chat_store.create_session("u", "full")["id"]
- for i in range(5):
- chat_store.save_message(sid, "user", f"M{i}")
- recent = chat_store.get_messages(sid, limit=3)
- assert [r["content"] for r in recent] == ["M2", "M3", "M4"]
- def test_message_json_fields_roundtrip(self):
- from db import chat_store
- sid = chat_store.create_session("u", "full")["id"]
- chat_store.save_message(
- sid, "assistant", "答",
- tool_calls=[{"name": "calculate", "args": {"expression": "1+1"}}],
- citations=[{"title": "T", "url": "u"}],
- )
- row = chat_store.get_messages(sid)[0]
- assert row["tool_calls"][0]["name"] == "calculate"
- assert row["citations"][0]["title"] == "T"
- def test_session_ownership_check(self):
- from db import chat_store
- sid = chat_store.create_session("alice", "full")["id"]
- assert chat_store.get_session_for_user(sid, "alice") is not None
- assert chat_store.get_session_for_user(sid, "bob") is None
- def test_delete_session_cascades(self):
- from db import chat_store
- sid = chat_store.create_session("u", "full")["id"]
- chat_store.save_message(sid, "user", "x")
- chat_store.save_context_usage(sid, 1, 10, 5)
- chat_store.delete_session(sid)
- assert chat_store.get_session(sid) is None
- assert chat_store.get_messages(sid) == []
- assert chat_store.get_context_usage(sid)["history"] == []
- def test_preference_upsert(self):
- from db import chat_store
- chat_store.save_preference("u", "单位", "米")
- chat_store.save_preference("u", "单位", "千米")
- prefs = {p["pref_key"]: p["pref_value"] for p in chat_store.list_preferences("u")}
- assert prefs == {"单位": "千米"}
- def test_preferences_as_text_empty(self):
- from db import chat_store
- assert chat_store.preferences_as_text("nobody") == ""
- def test_search_sessions_by_title(self):
- from db import chat_store
- chat_store.create_session("u", "full", "通风计算讨论")
- chat_store.create_session("u", "full", "其他话题")
- found = chat_store.search_sessions("u", "通风")
- assert len(found) == 1 and "通风" in found[0]["title"]
- def test_pinned_sessions_first(self):
- from db import chat_store
- a = chat_store.create_session("u", "full", "A")["id"]
- chat_store.create_session("u", "full", "B")
- chat_store.update_session_pin(a, True)
- assert chat_store.list_sessions("u")[0]["id"] == a
- def test_context_usage_aggregates(self):
- from db import chat_store
- sid = chat_store.create_session("u", "full")["id"]
- chat_store.save_context_usage(sid, 1, 100, 10, usage_source="provider")
- chat_store.save_context_usage(sid, 2, 200, 20, compacted=True, compact_count=1,
- usage_source="estimate")
- usage = chat_store.get_context_usage(sid)
- assert usage["compact_count"] == 1
- assert usage["latest"]["total_tokens"] == 220
- assert usage["latest"]["usage_source"] == "estimate"
- def test_schedule_crud_and_runs(self):
- from db import schedule_store
- schedule = schedule_store.create_schedule("u", "每日", "0 9 * * *", "汇报")
- sid = schedule["id"]
- assert schedule["enabled"] is True
- run_id = schedule_store.start_run(sid, "thread-1")
- schedule_store.finish_run(run_id, "success", output="ok")
- runs = schedule_store.list_runs(sid)
- assert len(runs) == 1 and runs[0]["status"] == "success"
- updated = schedule_store.update_schedule(sid, enabled=False, title="改名")
- assert updated["enabled"] is False and updated["title"] == "改名"
- assert schedule_store.delete_schedule(sid) is True
- assert schedule_store.get_schedule(sid) is None
- assert schedule_store.list_runs(sid) == []
- def test_schedule_update_ignores_unknown_fields(self):
- from db import schedule_store
- sid = schedule_store.create_schedule("u", "T", "0 9 * * *", "P")["id"]
- schedule_store.update_schedule(sid, title="新", 恶意字段="x")
- assert schedule_store.get_schedule(sid)["title"] == "新"
- def test_init_schema_idempotent(self):
- from db import db_conn
- db_conn.init_schema()
- db_conn.init_schema(force=True)
- assert db_conn.health()["status"] == "ok"
- # ============================================================
- # 模型工厂
- # ============================================================
- class TestModelFactory:
- def test_context_window_known(self):
- from agents import model_factory
- assert model_factory.context_window("deepseek-flash") == 128_000
- def test_context_limit_override_wins(self, monkeypatch):
- from agents import model_factory
- monkeypatch.setattr(model_factory.config, "CONTEXT_LIMIT", 5000)
- assert model_factory.context_window("deepseek-flash") == 5000
- def test_unknown_model_uses_fallback(self):
- from agents import model_factory
- assert model_factory.context_window("some-unknown-model-xyz") == \
- model_factory.FALLBACK_CONTEXT_WINDOW
- def test_thinking_levels_normalized(self):
- from agents import model_factory
- assert model_factory.normalize_thinking("HIGH") == "high"
- assert model_factory.normalize_thinking("bogus") == "off"
- assert model_factory.normalize_thinking(None) in model_factory.VALID_THINKING_LEVELS
- def test_off_sends_no_extra_body(self):
- """off 时什么都不发:不同 provider 关闭思考的字段名不一致,发错会被拒。"""
- from agents import model_factory
- assert model_factory._thinking_payload("off") is None
- assert model_factory._thinking_payload("high") is not None
- def test_build_model_declares_profile(self):
- """必须显式声明上下文窗口,否则摘要中间件首次触发时会报错。"""
- from agents import model_factory
- model = model_factory.build_model()
- assert model.profile and model.profile.get("max_input_tokens")
- # ============================================================
- # 中断恢复翻译
- # ============================================================
- class TestResumeTranslation:
- def _payload(self, **kw):
- from api.models import ResumeRequest
- base = {"session_id": "s", "status": "answered"}
- base.update(kw)
- return ResumeRequest(**base)
- def test_approve(self):
- from api.chat_routes import _to_official_resume
- assert _to_official_resume(self._payload(approved=True), 1) == \
- {"decisions": [{"type": "approve"}]}
- def test_reject(self):
- from api.chat_routes import _to_official_resume
- decisions = _to_official_resume(self._payload(approved=False), 1)["decisions"]
- assert decisions[0]["type"] == "reject" and decisions[0]["message"]
- def test_cancelled_becomes_reject(self):
- from api.chat_routes import _to_official_resume
- decisions = _to_official_resume(self._payload(status="cancelled"), 1)["decisions"]
- assert decisions[0]["type"] == "reject"
- def test_answers_become_respond(self):
- from api.chat_routes import _to_official_resume
- decisions = _to_official_resume(self._payload(answers=["甲", "乙"]), 1)["decisions"]
- assert decisions[0]["type"] == "respond" and "甲" in decisions[0]["message"]
- def test_multiple_pending_actions_get_matching_decisions(self):
- """官方要求 decisions 与 action_requests 数量严格一致。"""
- from api.chat_routes import _to_official_resume
- decisions = _to_official_resume(self._payload(approved=True), 3)["decisions"]
- assert len(decisions) == 3
- assert all(d["type"] == "approve" for d in decisions)
- def test_unknown_payload_defaults_to_safe_reject(self):
- """没有明确答复时宁可拒绝执行。"""
- from api.chat_routes import _to_official_resume
- decisions = _to_official_resume(self._payload(), 1)["decisions"]
- assert decisions[0]["type"] == "reject"
|