Você não pode selecionar mais de 25 tópicos Os tópicos devem começar com uma letra ou um número, podem incluir traços ('-') e podem ter até 35 caracteres.
 
 
 

227 linhas
8.0 KiB

  1. #!/usr/bin/env python3
  2. """
  3. Smoke test for video pricing table billing.
  4. Required environment:
  5. NEW_API_BASE_URL=http://127.0.0.1:3000
  6. NEW_API_ADMIN_USERNAME=root
  7. NEW_API_ADMIN_PASSWORD=123456
  8. NEW_API_TOKEN=sk-...
  9. Optional model overrides:
  10. VIDEO_PRICING_HAILUO_MODEL=hailuo-video
  11. VIDEO_PRICING_SEEDANCE_MODEL=seedance-2
  12. The server must already have usable channels for the selected models. For the
  13. per_1m_tokens case, point the Seedance/Doubao channel at a mock upstream that
  14. returns a completed task with usage.total_tokens.
  15. """
  16. from __future__ import annotations
  17. import json
  18. import os
  19. import sys
  20. import time
  21. import urllib.error
  22. import urllib.parse
  23. import urllib.request
  24. from http.cookiejar import CookieJar
  25. BASE_URL = os.getenv("NEW_API_BASE_URL", "http://127.0.0.1:3000").rstrip("/")
  26. ADMIN_USERNAME = os.getenv("NEW_API_ADMIN_USERNAME", "root")
  27. ADMIN_PASSWORD = os.getenv("NEW_API_ADMIN_PASSWORD", "123456")
  28. API_TOKEN = os.getenv("NEW_API_TOKEN", "")
  29. HAILUO_MODEL = os.getenv("VIDEO_PRICING_HAILUO_MODEL", "hailuo-video")
  30. SEEDANCE_MODEL = os.getenv("VIDEO_PRICING_SEEDANCE_MODEL", "seedance-2")
  31. QUOTA_PER_UNIT = int(os.getenv("VIDEO_PRICING_QUOTA_PER_UNIT", "500000"))
  32. GROUP_RATIO = float(os.getenv("VIDEO_PRICING_GROUP_RATIO", "1"))
  33. class Client:
  34. def __init__(self) -> None:
  35. self.cookies = CookieJar()
  36. self.opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(self.cookies))
  37. def request(self, method: str, path: str, body=None, token: str | None = None, expected: int | None = None):
  38. url = f"{BASE_URL}{path}"
  39. data = None
  40. headers = {"Content-Type": "application/json"}
  41. if body is not None:
  42. data = json.dumps(body).encode("utf-8")
  43. if token:
  44. headers["Authorization"] = f"Bearer {token}"
  45. req = urllib.request.Request(url, data=data, headers=headers, method=method)
  46. try:
  47. with self.opener.open(req, timeout=30) as resp:
  48. payload = read_json(resp)
  49. if expected is not None and resp.status != expected:
  50. raise AssertionError(f"{method} {path}: expected {expected}, got {resp.status}: {payload}")
  51. return resp.status, payload
  52. except urllib.error.HTTPError as err:
  53. payload = read_json(err)
  54. if expected is not None and err.code == expected:
  55. return err.code, payload
  56. raise AssertionError(f"{method} {path}: HTTP {err.code}: {payload}") from err
  57. def read_json(resp):
  58. raw = resp.read().decode("utf-8", errors="replace")
  59. if not raw:
  60. return None
  61. try:
  62. return json.loads(raw)
  63. except json.JSONDecodeError:
  64. return raw
  65. def assert_success(payload, context: str):
  66. if not isinstance(payload, dict) or payload.get("success") is not True:
  67. raise AssertionError(f"{context} failed: {payload}")
  68. return payload.get("data")
  69. def usage(client: Client) -> dict:
  70. _, payload = client.request("GET", "/api/usage/token/", token=API_TOKEN, expected=200)
  71. if not payload.get("code"):
  72. raise AssertionError(f"usage failed: {payload}")
  73. return payload["data"]
  74. def used_quota(client: Client) -> int:
  75. return int(usage(client)["total_used"])
  76. def login(client: Client) -> None:
  77. _, payload = client.request(
  78. "POST",
  79. "/api/user/login",
  80. {"username": ADMIN_USERNAME, "password": ADMIN_PASSWORD},
  81. expected=200,
  82. )
  83. assert_success(payload, "admin login")
  84. def put_model_pricing(client: Client, model: str, config: dict) -> None:
  85. _, payload = client.request("PUT", f"/api/option/model_pricing/{urllib.parse.quote(model, safe='')}", config, expected=200)
  86. assert_success(payload, f"put pricing {model}")
  87. def delete_model_pricing(client: Client, model: str) -> None:
  88. _, payload = client.request("DELETE", f"/api/option/model_pricing/{urllib.parse.quote(model, safe='')}", expected=200)
  89. assert_success(payload, f"delete pricing {model}")
  90. def submit_video(client: Client, body: dict, expected: int = 200):
  91. _, payload = client.request("POST", "/v1/videos", body, token=API_TOKEN, expected=expected)
  92. return payload
  93. def fetch_video(client: Client, task_id: str):
  94. _, payload = client.request("GET", f"/v1/videos/{urllib.parse.quote(task_id, safe='')}", token=API_TOKEN, expected=200)
  95. return payload
  96. def hailuo_config() -> dict:
  97. return {
  98. "schema_version": 1,
  99. "scope": "model",
  100. "billing_unit": "per_call",
  101. "preconsume_strategy": "exact",
  102. "dimensions": [
  103. {"key": "resolution", "source": "request.resolution", "type": "string", "default": None},
  104. {"key": "duration", "source": "request.duration", "type": "number", "default": None},
  105. ],
  106. "table": [
  107. {"resolution": "768P", "duration": 10, "price": 0.04, "source": "manual"},
  108. ],
  109. "fallback": {"strategy": "reject"},
  110. }
  111. def seedance_config() -> dict:
  112. return {
  113. "schema_version": 1,
  114. "scope": "model",
  115. "billing_unit": "per_1m_tokens",
  116. "preconsume_strategy": "minimum",
  117. "dimensions": [
  118. {"key": "resolution", "source": "request.resolution", "type": "string", "default": None},
  119. ],
  120. "table": [
  121. {"resolution": "720P", "price": 0.5, "source": "manual"},
  122. ],
  123. "fallback": {"strategy": "reject"},
  124. }
  125. def expected_quota(price_usd: float, tokens: int | None = None) -> int:
  126. units = tokens if tokens is not None else 1
  127. divisor = 1_000_000 if tokens is not None else 1
  128. quota = int(float(units) * price_usd / divisor * QUOTA_PER_UNIT * GROUP_RATIO)
  129. if tokens and quota <= 0 and price_usd > 0 and GROUP_RATIO > 0:
  130. return 1
  131. return quota
  132. def run() -> None:
  133. if not API_TOKEN:
  134. raise RuntimeError("NEW_API_TOKEN is required")
  135. client = Client()
  136. login(client)
  137. try:
  138. put_model_pricing(client, HAILUO_MODEL, hailuo_config())
  139. before = used_quota(client)
  140. payload = submit_video(
  141. client,
  142. {"model": HAILUO_MODEL, "prompt": "pricing smoke", "resolution": "768P", "duration": 10},
  143. )
  144. after = used_quota(client)
  145. assert after - before == expected_quota(0.04), f"Hailuo quota mismatch: before={before}, after={after}, response={payload}"
  146. before_reject = used_quota(client)
  147. rejected = submit_video(
  148. client,
  149. {"model": HAILUO_MODEL, "prompt": "pricing smoke", "resolution": "1080P", "duration": 10},
  150. expected=400,
  151. )
  152. after_reject = used_quota(client)
  153. assert after_reject == before_reject, f"reject changed quota: {before_reject} -> {after_reject}, response={rejected}"
  154. put_model_pricing(client, SEEDANCE_MODEL, seedance_config())
  155. before_usage = used_quota(client)
  156. seedance_submit = submit_video(
  157. client,
  158. {"model": SEEDANCE_MODEL, "prompt": "pricing smoke", "resolution": "720P", "duration": 5},
  159. )
  160. task_id = seedance_submit.get("id") or seedance_submit.get("task_id") or seedance_submit.get("data", {}).get("id")
  161. if not task_id:
  162. raise AssertionError(f"Seedance submit did not return task id: {seedance_submit}")
  163. total_tokens = int(os.getenv("VIDEO_PRICING_EXPECTED_TOTAL_TOKENS", "1000"))
  164. for _ in range(12):
  165. fetched = fetch_video(client, task_id)
  166. status = fetched.get("status") or fetched.get("data", {}).get("status")
  167. if status in {"succeeded", "completed", "success"}:
  168. break
  169. time.sleep(1)
  170. after_usage = used_quota(client)
  171. delta = after_usage - before_usage
  172. assert delta == expected_quota(0.5, total_tokens), f"Seedance usage quota mismatch: delta={delta}, response={fetched}"
  173. finally:
  174. delete_model_pricing(client, HAILUO_MODEL)
  175. delete_model_pricing(client, SEEDANCE_MODEL)
  176. if __name__ == "__main__":
  177. try:
  178. run()
  179. except Exception as exc:
  180. print(f"FAIL: {exc}", file=sys.stderr)
  181. sys.exit(1)
  182. print("PASS: video pricing smoke completed")