test_token_budget.py 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174
  1. import asyncio
  2. import json
  3. from datetime import timedelta
  4. from pathlib import Path
  5. from uuid import UUID, uuid4
  6. import pytest
  7. from langchain_core.messages import HumanMessage
  8. from sqlalchemy import func, select
  9. from test_after_sales import scenario # noqa: F401
  10. from zhifu.core.errors import BusinessError
  11. from zhifu.modules.after_sales.models import BusinessEvent
  12. from zhifu.modules.agent_runtime.budget import change_entry, reserve
  13. from zhifu.modules.agent_runtime.budget_callback import BudgetCallback
  14. from zhifu.modules.agent_runtime.models import TokenAccount, TokenEntry
  15. from zhifu.modules.identity.auth import now
  16. from zhifu.operations.token_budget import BudgetChange, entries, inspect
  17. pytestmark = pytest.mark.integration
  18. def sample():
  19. # 回放受限真实采样的 usage,只验证账本事务,不模拟模型响应。
  20. document = json.loads(Path("docs/verification/token-budget-sample.json").read_text("utf-8"))
  21. return document["samples"][0]
  22. async def set_limit(context, limit, version=0):
  23. return await inspect(
  24. context["app"].state.sessions,
  25. context["tenant"].bytes,
  26. change=BudgetChange(
  27. expected_version=version,
  28. token_limit=limit,
  29. operator="integration",
  30. reason="预算事务验收",
  31. ),
  32. )
  33. async def test_concurrent_reservation_and_duplicate_settlement(scenario): # noqa: F811
  34. sessions, tenant = scenario["app"].state.sessions, scenario["tenant"].bytes
  35. observed = sample()
  36. amount = observed["usage"]["total_tokens"]
  37. await set_limit(scenario, amount * 2)
  38. ids = [uuid4().bytes for _ in range(8)]
  39. results = await asyncio.gather(
  40. *(
  41. reserve(sessions, tenant, identifier, "agent", uuid4().bytes, observed["model"], amount)
  42. for identifier in ids
  43. ),
  44. return_exceptions=True,
  45. )
  46. assert sum(result is None for result in results) == 2
  47. assert all(
  48. result is None
  49. or (isinstance(result, BusinessError) and result.code == "TOKEN_BUDGET_EXCEEDED")
  50. for result in results
  51. )
  52. accepted = [
  53. identifier for identifier, result in zip(ids, results, strict=True) if result is None
  54. ]
  55. await asyncio.gather(
  56. *(change_entry(sessions, tenant, accepted[0], usage=observed["usage"]) for _ in range(6))
  57. )
  58. report = await inspect(sessions, tenant)
  59. assert report["consumed"] == amount and report["reserved"] == amount
  60. with pytest.raises(BusinessError, match="标识已存在"):
  61. await reserve(
  62. sessions, tenant, accepted[0], "agent", uuid4().bytes, observed["model"], amount
  63. )
  64. async with sessions() as session:
  65. count = await session.scalar(
  66. select(func.count())
  67. .select_from(BusinessEvent)
  68. .where(
  69. BusinessEvent.aggregate_id == accepted[0],
  70. BusinessEvent.event_type == "token.settled",
  71. )
  72. )
  73. assert count == 1
  74. async def test_unknown_release_late_usage_and_tenant_isolation(scenario): # noqa: F811
  75. sessions, tenant = scenario["app"].state.sessions, scenario["tenant"].bytes
  76. observed = sample()
  77. identifier = uuid4().bytes
  78. await set_limit(scenario, 10000)
  79. await reserve(sessions, tenant, identifier, "memory", uuid4().bytes, observed["model"], 5000)
  80. assert await change_entry(sessions, tenant, identifier) == "UNKNOWN"
  81. assert (await inspect(sessions, tenant))["reserved"] == 5000
  82. other = (await scenario["call"]("GET", "/openapi/v1/auth/me", "foreign")).json()
  83. with pytest.raises(BusinessError) as error:
  84. await change_entry(
  85. sessions, UUID(other["tenant_id"]).bytes, identifier, usage=observed["usage"]
  86. )
  87. assert error.value.status == 404
  88. with pytest.raises(BusinessError, match="版本"):
  89. await change_entry(
  90. sessions,
  91. tenant,
  92. identifier,
  93. release_reason="确认未计费",
  94. operator="integration",
  95. expected_version=1,
  96. )
  97. await change_entry(
  98. sessions,
  99. tenant,
  100. identifier,
  101. release_reason="确认未计费",
  102. operator="integration",
  103. expected_version=2,
  104. )
  105. assert (await inspect(sessions, tenant))["reserved"] == 0
  106. await set_limit(scenario, 0, 1)
  107. await change_entry(sessions, tenant, identifier, usage=observed["usage"])
  108. assert (await inspect(sessions, tenant))["available"] == -observed["usage"]["total_tokens"]
  109. async def test_configuration_conflict_and_historical_window(scenario): # noqa: F811
  110. sessions, tenant = scenario["app"].state.sessions, scenario["tenant"].bytes
  111. old = now().date() - timedelta(days=1)
  112. empty = await inspect(sessions, tenant, old)
  113. assert empty["recorded"] is False and empty["token_limit"] is None
  114. changes = await asyncio.gather(
  115. set_limit(scenario, 10000), set_limit(scenario, 20000), return_exceptions=True
  116. )
  117. assert sum(isinstance(result, BusinessError) for result in changes) == 1
  118. observed = sample()
  119. identifier = uuid4().bytes
  120. await reserve(sessions, tenant, identifier, "agent", uuid4().bytes, observed["model"], 100)
  121. # 使用真实数据库创建前一日期账户,验证迟到 usage 仍结算在原预占日期。
  122. async with sessions.begin() as session:
  123. session.add(
  124. TokenAccount(tenant_id=tenant, period=old, token_limit=100, reserved=100, consumed=0)
  125. )
  126. await session.flush()
  127. current = await session.get(TokenAccount, (tenant, now().date()))
  128. current.reserved = 0
  129. row = await session.get(TokenEntry, identifier)
  130. row.period = old
  131. await change_entry(sessions, tenant, identifier, usage=observed["usage"])
  132. assert (await inspect(sessions, tenant, old))["consumed"] == observed["usage"]["total_tokens"]
  133. assert (await inspect(sessions, tenant))["consumed"] == 0
  134. page = await entries(sessions, tenant, limit=1)
  135. assert len(page["items"]) == 1 and page["next_cursor"] is None
  136. async def test_budget_callback_rejects_before_external_request(scenario): # noqa: F811
  137. sessions, tenant = scenario["app"].state.sessions, scenario["tenant"].bytes
  138. await set_limit(scenario, 0)
  139. callback = BudgetCallback(sessions, tenant, "agent", uuid4().bytes, sample()["model"], 128)
  140. assert callback.raise_error and callback.run_inline
  141. with pytest.raises(BusinessError) as error:
  142. await callback.on_chat_model_start({}, [[HumanMessage(content="你好")]], run_id=uuid4())
  143. assert error.value.code == "TOKEN_BUDGET_EXCEEDED"
  144. assert (await entries(sessions, tenant))["items"] == []
  145. async def test_conflicting_usage_keeps_original_settlement(scenario): # noqa: F811
  146. sessions, tenant = scenario["app"].state.sessions, scenario["tenant"].bytes
  147. observed = sample()
  148. identifier = uuid4().bytes
  149. await reserve(sessions, tenant, identifier, "agent", uuid4().bytes, observed["model"], 10000)
  150. await change_entry(sessions, tenant, identifier, usage=observed["usage"])
  151. conflict = dict(observed["usage"])
  152. conflict["output_tokens"] += 1
  153. conflict["total_tokens"] += 1
  154. with pytest.raises(BusinessError) as error:
  155. await change_entry(sessions, tenant, identifier, usage=conflict)
  156. assert error.value.code == "TOKEN_USAGE_CONFLICT"
  157. assert (await inspect(sessions, tenant))["consumed"] == observed["usage"]["total_tokens"]