token_budget_model_acceptance.py 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121
  1. import asyncio
  2. import json
  3. from pathlib import Path
  4. from uuid import UUID
  5. from sqlalchemy import select
  6. from test_after_sales import scenario # noqa: F401
  7. from zhifu.modules.agent_runtime.budget_callback import CallAllowance
  8. from zhifu.modules.agent_runtime.config import AgentSettings
  9. from zhifu.modules.agent_runtime.models import AgentRun, ModelAttempt, TokenEntry
  10. from zhifu.modules.agent_runtime.runtime import build_graph
  11. from zhifu.modules.agent_runtime.worker import claim, execute_one
  12. from zhifu.modules.memory.worker import claim as claim_memory
  13. from zhifu.modules.memory.worker import process
  14. from zhifu.operations.token_budget import BudgetChange, entries, inspect
  15. async def test_bounded_actual_usage_enters_budget(scenario): # noqa: F811
  16. """最多三次供应商请求,Agent 与记忆提取共用次数控制,不进行完整付费压测。"""
  17. sessions, state = scenario["app"].state.sessions, scenario["app"].state.state
  18. for role in ("agent", "memory"):
  19. async for _ in state.scan_iter(match=f"monitor:worker:{role}:*"):
  20. raise RuntimeError("请停止生成模型工作进程并等待心跳过期")
  21. settings = AgentSettings(max_output_tokens=128)
  22. allowance = CallAllowance(3)
  23. call = scenario["call"]
  24. async with asyncio.timeout(100):
  25. response = await call(
  26. "POST",
  27. "/openapi/v1/agent/runs",
  28. payload={"store_id": str(scenario["store"]), "message": "你好,请仅回复一句问候。"},
  29. )
  30. assert response.status_code == 202, response.text
  31. run_id, token = await claim(sessions, settings.tenant_concurrency)
  32. await build_graph(sessions, settings, allowance=allowance).ainvoke(
  33. {"run_id": run_id, "token": token, "next_node": "model"}
  34. )
  35. response = await call(
  36. "PUT",
  37. "/openapi/v1/me/memory/consent",
  38. payload={"expected_revision": 0, "enabled": True},
  39. )
  40. assert response.status_code == 200
  41. response = await call(
  42. "POST", "/openapi/v1/conversations", payload={"store_id": str(scenario["store"])}
  43. )
  44. conversation_id = response.json()["conversation_id"]
  45. response = await call(
  46. "POST",
  47. f"/openapi/v1/conversations/{conversation_id}/messages",
  48. payload={"text": "以后请一直用中文简短回复。", "expected_control_version": 1},
  49. )
  50. assert response.status_code == 201
  51. pending = response.json()["payload"]["run_id"]
  52. assert (await call("POST", f"/openapi/v1/agent/runs/{pending}/cancel")).status_code == 200
  53. job, previous = await claim_memory(sessions)
  54. await process(sessions, job, previous, settings, allowance=allowance)
  55. async with sessions() as session:
  56. rows = (
  57. await session.scalars(
  58. select(TokenEntry).where(TokenEntry.tenant_id == scenario["tenant"].bytes)
  59. )
  60. ).all()
  61. assert {row.kind for row in rows} == {"agent", "memory"}
  62. assert len(rows) == allowance.started <= 3
  63. assert all(row.status == "SETTLED" and row.usage["total_tokens"] > 0 for row in rows)
  64. samples = [
  65. {
  66. "attempt": str(UUID(bytes=row.id)),
  67. "kind": row.kind,
  68. "model": row.model_name,
  69. "reserved": row.reserved,
  70. "usage": row.usage,
  71. }
  72. for row in rows
  73. ]
  74. budget = await inspect(sessions, scenario["tenant"].bytes)
  75. assert budget["reserved"] == 0
  76. assert budget["consumed"] == sum(item["usage"]["total_tokens"] for item in samples)
  77. await asyncio.to_thread(
  78. Path("docs/verification/token-budget-sample.json").write_text,
  79. json.dumps(
  80. {"model_requests": allowance.started, "samples": samples, "budget": budget},
  81. ensure_ascii=False,
  82. indent=2,
  83. )
  84. + "\n",
  85. encoding="utf-8",
  86. )
  87. async def test_zero_budget_worker_returns_without_sending(scenario): # noqa: F811
  88. sessions = scenario["app"].state.sessions
  89. tenant = scenario["tenant"].bytes
  90. await inspect(
  91. sessions,
  92. tenant,
  93. change=BudgetChange(
  94. expected_version=0, token_limit=0, operator="acceptance", reason="验证调用前预算拒绝"
  95. ),
  96. )
  97. response = await scenario["call"](
  98. "POST",
  99. "/openapi/v1/agent/runs",
  100. payload={"store_id": str(scenario["store"]), "message": "你好,请仅回复一句问候。"},
  101. )
  102. assert response.status_code == 202
  103. run_id = UUID(response.json()["run_id"]).bytes
  104. assert await execute_one(
  105. sessions,
  106. scenario["app"].state.state,
  107. AgentSettings(max_output_tokens=128),
  108. allowance=CallAllowance(1),
  109. )
  110. async with sessions() as session:
  111. run = await session.get(AgentRun, run_id)
  112. assert run.status == "FAILED" and run.error_code == "TOKEN_BUDGET_EXCEEDED"
  113. attempt = await session.scalar(select(ModelAttempt).where(ModelAttempt.run_id == run_id))
  114. assert attempt.status == "NOT_SENT"
  115. assert (await entries(sessions, tenant))["items"] == []