billing_retry_model_acceptance.py 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115
  1. import asyncio
  2. import json
  3. from pathlib import Path
  4. from time import perf_counter
  5. from uuid import UUID, uuid4
  6. import pytest
  7. from openai import APIStatusError
  8. from sqlalchemy import select
  9. from test_after_sales import scenario # noqa: F401
  10. from test_money_budget import PRICE, approve, budget, setup
  11. from zhifu.modules.agent_runtime.budget import change_entry
  12. from zhifu.modules.agent_runtime.budget_callback import CallAllowance
  13. from zhifu.modules.agent_runtime.config import AgentSettings
  14. from zhifu.modules.agent_runtime.models import AgentRun, ModelAttempt, TokenEntry
  15. from zhifu.modules.agent_runtime.worker import execute_one
  16. from zhifu.modules.billing.models import MoneyEntry
  17. from zhifu.operations.token_budget import inspect
  18. def reserve_sample():
  19. with Path(".work/long-billing-model-allowance.json").open("x", encoding="utf-8") as stream:
  20. json.dump({"request_limit": 3, "sdk_retries": 0, "max_output_tokens": 128}, stream)
  21. async def test_provider_error_and_explicit_retry_accounting(scenario): # noqa: F811
  22. sessions, state = scenario["app"].state.sessions, scenario["app"].state.state
  23. settings = AgentSettings(max_output_tokens=128, model_timeout=30)
  24. await setup(scenario)
  25. await budget(scenario, 1000000)
  26. invalid_model = "zhifu-unavailable-" + uuid4().hex
  27. for name in (invalid_model, settings.model_name):
  28. await approve(scenario, {**PRICE, "model": name, "provider": settings.model_url})
  29. for role in ("agent", "memory"):
  30. async for _ in state.scan_iter(match=f"monitor:worker:{role}:*"):
  31. raise RuntimeError("采样要求其他生成工作进程停止")
  32. body = {"store_id": str(scenario["store"]), "message": "你好,请仅回复一句问候。"}
  33. key = str(uuid4())
  34. response = await scenario["call"]("POST", "/openapi/v1/agent/runs", payload=body, key=key)
  35. assert response.status_code == 202, response.text
  36. failed_id = UUID(response.json()["run_id"]).bytes
  37. # 在独立测试租户设置无效模型配置,真实供应商返回配置错误,全部计入请求上限。
  38. async with sessions.begin() as session:
  39. run = await session.get(AgentRun, failed_id)
  40. run.model_name = invalid_model
  41. run.release_snapshot = {**run.release_snapshot, "max_output_tokens": 128,
  42. "model_timeout": 30, "model_name": invalid_model}
  43. await asyncio.to_thread(reserve_sample)
  44. allowance, started = CallAllowance(3), perf_counter()
  45. http_status, provider_request_id, success_id = None, None, None
  46. try:
  47. async with asyncio.timeout(100):
  48. with pytest.raises(APIStatusError) as failure:
  49. await execute_one(sessions, state, settings, allowance=allowance,
  50. run_ids=[failed_id])
  51. http_status = failure.value.status_code
  52. provider_request_id = failure.value.request_id
  53. assert 400 <= http_status < 500
  54. repeated = await scenario["call"](
  55. "POST", "/openapi/v1/agent/runs", payload=body, key=key)
  56. assert repeated.json()["run_id"] == str(UUID(bytes=failed_id))
  57. assert not await execute_one(sessions, state, settings, allowance=allowance,
  58. run_ids=[failed_id])
  59. # 配置恢复后由客户使用新请求标识明确重试,旧失败记录和未知预占继续保留。
  60. retry = await scenario["call"]("POST", "/openapi/v1/agent/runs", payload=body)
  61. assert retry.status_code == 202, retry.text
  62. success_id = UUID(retry.json()["run_id"]).bytes
  63. async with sessions.begin() as session:
  64. run = await session.get(AgentRun, success_id)
  65. run.release_snapshot = {**run.release_snapshot, "max_output_tokens": 128,
  66. "model_timeout": 30}
  67. assert await execute_one(sessions, state, settings, allowance=allowance,
  68. run_ids=[success_id])
  69. async with sessions() as session:
  70. failed = await session.get(AgentRun, failed_id)
  71. success = await session.get(AgentRun, success_id)
  72. assert failed.status == "FAILED" and success.status == "COMPLETED"
  73. rows = (await session.scalars(select(TokenEntry).where(
  74. TokenEntry.tenant_id == scenario["tenant"].bytes))).all()
  75. assert len(rows) == allowance.started <= 3
  76. unknown = [row for row in rows if row.status == "UNKNOWN"]
  77. settled = [row for row in rows if row.status == "SETTLED"]
  78. assert len(unknown) == 1 and settled
  79. for row in rows:
  80. money = await session.get(MoneyEntry, row.id)
  81. assert money.status == row.status
  82. before = await inspect(sessions, scenario["tenant"].bytes)
  83. assert before["reserved"] == unknown[0].reserved
  84. assert before["consumed"] == sum(row.usage["total_tokens"] for row in settled)
  85. for row in settled:
  86. await change_entry(sessions, row.tenant_id, row.id, usage=row.usage)
  87. assert await inspect(sessions, scenario["tenant"].bytes) == before
  88. finally:
  89. async with sessions() as session:
  90. attempts = (await session.scalars(select(ModelAttempt).where(
  91. ModelAttempt.run_id.in_([value for value in (failed_id, success_id) if value])
  92. ))).all()
  93. entries = (await session.scalars(select(TokenEntry).where(
  94. TokenEntry.tenant_id == scenario["tenant"].bytes))).all()
  95. report = {
  96. "request_limit": 3, "actual_calls": allowance.started,
  97. "max_output_tokens": 128, "sdk_retries": 0,
  98. "timeout_seconds": 100, "seconds": perf_counter() - started,
  99. "provider_error_status": http_status, "provider_request_id": provider_request_id,
  100. "failed_run": str(UUID(bytes=failed_id)),
  101. "retry_run": str(UUID(bytes=success_id)) if success_id else None,
  102. "attempts": [{"run_id": str(UUID(bytes=row.run_id)),
  103. "status": row.status, "usage": row.usage} for row in attempts],
  104. "token_entries": [{"status": row.status, "reserved": row.reserved,
  105. "usage": row.usage} for row in entries],
  106. "price_basis": "事务测试价格;未核对供应商金额",
  107. }
  108. await asyncio.to_thread(Path(".work/billing-retry-model.json").write_text,
  109. json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")