provider_limits_model_acceptance.py 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103
  1. import asyncio
  2. import json
  3. import os
  4. from pathlib import Path
  5. from time import perf_counter
  6. from uuid import UUID, uuid4
  7. import pytest
  8. from langchain_core.messages import message_to_dict
  9. from langchain_openai import ChatOpenAI
  10. from openai import APITimeoutError, DefaultAsyncHttpxClient, DefaultHttpxClient
  11. from sqlalchemy import select
  12. from test_after_sales import scenario # noqa: F401
  13. from zhifu.core.model_parameters import chat_parameters
  14. from zhifu.modules.agent_runtime.budget_callback import CallAllowance
  15. from zhifu.modules.agent_runtime.config import AgentSettings
  16. from zhifu.modules.agent_runtime.models import AgentRun, ModelAttempt, TokenEntry
  17. from zhifu.modules.agent_runtime.worker import execute_one
  18. def reserve_sample():
  19. # 本次授权独立保存,已有额度文件阻止重复付费执行;失败也保留实际请求证据。
  20. with Path(".work/provider-limits-allowance-20260922.json").open("x", encoding="utf-8") as file:
  21. json.dump({"request_limit": 2, "output_limit": 128, "sdk_retries": 0}, file)
  22. async def test_provider_output_limit_and_actual_request_timeout(scenario): # noqa: F811
  23. settings = AgentSettings(max_output_tokens=128, model_timeout=30)
  24. sessions, state = scenario["app"].state.sessions, scenario["app"].state.state
  25. report_path = Path(".work/provider-limits-20260922.json")
  26. await asyncio.to_thread(reserve_sample)
  27. allowance, started = CallAllowance(2), perf_counter()
  28. report = {"request_limit": 2, "max_output_tokens": 128, "sdk_retries": 0,
  29. "total_timeout_seconds": 90, "status": "INCOMPLETE"}
  30. run_id = None
  31. try:
  32. async with asyncio.timeout(90):
  33. # 直接使用与Agent和记忆相同的SDK参数,验证供应商在输出上限结束生成。
  34. client = ChatOpenAI(
  35. model=settings.model_name, base_url=settings.model_url,
  36. api_key=os.environ[settings.credential_env], temperature=0,
  37. timeout=30, max_retries=0, cache=False, use_responses_api=False,
  38. **chat_parameters(settings.model_url, 128, settings.model_extra_body),
  39. http_client=DefaultHttpxClient(timeout=30),
  40. http_async_client=DefaultAsyncHttpxClient(timeout=30),
  41. )
  42. try:
  43. allowance.take()
  44. response = await client.ainvoke(
  45. "请依次列出从1到1000的每个整数,每个整数单独一行,不要省略,也不要其他说明。")
  46. report["output_sample"] = message_to_dict(response)
  47. assert response.usage_metadata
  48. assert response.usage_metadata["output_tokens"] <= 128
  49. assert response.response_metadata["finish_reason"] == "length"
  50. finally:
  51. await client.root_async_client.close()
  52. client.root_client.close()
  53. key = str(uuid4())
  54. body = {"store_id": str(scenario["store"]), "message": "你好,请简短介绍售后服务。"}
  55. queued = await scenario["call"]("POST", "/openapi/v1/agent/runs", payload=body, key=key)
  56. assert queued.status_code == 202, queued.text
  57. run_id = UUID(queued.json()["run_id"]).bytes
  58. # 正常供应商请求使用很短的真实期限,不生成错误响应或替换网络客户端。
  59. # 期限包含预算登记;供应商是否已收到请求未知时保留预占和UNKNOWN。
  60. async with sessions.begin() as session:
  61. run = await session.get(AgentRun, run_id)
  62. run.release_snapshot = {**run.release_snapshot, "max_output_tokens": 128,
  63. "model_timeout": 0.25}
  64. with pytest.raises((TimeoutError, APITimeoutError)) as failure:
  65. await execute_one(sessions, state, settings, allowance=allowance, run_ids=[run_id])
  66. report["timeout_error"] = type(failure.value).__name__
  67. repeated = await scenario["call"](
  68. "POST", "/openapi/v1/agent/runs", payload=body, key=key)
  69. assert repeated.json()["run_id"] == str(UUID(bytes=run_id))
  70. assert not await execute_one(
  71. sessions, state, settings, allowance=allowance, run_ids=[run_id])
  72. async with sessions() as session:
  73. run = await session.get(AgentRun, run_id)
  74. attempts = (await session.scalars(select(ModelAttempt).where(
  75. ModelAttempt.run_id == run_id))).all()
  76. entries = (await session.scalars(select(TokenEntry).where(
  77. TokenEntry.tenant_id == scenario["tenant"].bytes))).all()
  78. assert run.status == "FAILED"
  79. assert len(attempts) == 1 and attempts[0].status == "UNKNOWN"
  80. assert len(entries) == 1 and entries[0].status == "UNKNOWN"
  81. assert entries[0].reserved > 0 and entries[0].usage is None
  82. report["timeout_run"] = {
  83. "run_id": str(UUID(bytes=run_id)), "status": run.status,
  84. "error_code": run.error_code, "attempt_status": attempts[0].status,
  85. "token_status": entries[0].status, "reserved_tokens": entries[0].reserved,
  86. "usage": entries[0].usage, "request_timeout_seconds": 0.25,
  87. "supplier_delivery": "UNKNOWN",
  88. }
  89. assert allowance.started == 2
  90. report["status"] = "PASSED"
  91. finally:
  92. report["actual_attempts"] = allowance.started
  93. report["elapsed_seconds"] = round(perf_counter() - started, 3)
  94. report["scope"] = "SDK真实截断1次、Agent真实超时尝试1次;无供应商账单与限流结论"
  95. await asyncio.to_thread(report_path.write_text,
  96. json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")