| @@ -0,0 +1,226 @@ | |||
| #!/usr/bin/env python3 | |||
| """ | |||
| Smoke test for video pricing table billing. | |||
| Required environment: | |||
| NEW_API_BASE_URL=http://127.0.0.1:3000 | |||
| NEW_API_ADMIN_USERNAME=root | |||
| NEW_API_ADMIN_PASSWORD=123456 | |||
| NEW_API_TOKEN=sk-... | |||
| Optional model overrides: | |||
| VIDEO_PRICING_HAILUO_MODEL=hailuo-video | |||
| VIDEO_PRICING_SEEDANCE_MODEL=seedance-2 | |||
| The server must already have usable channels for the selected models. For the | |||
| per_1m_tokens case, point the Seedance/Doubao channel at a mock upstream that | |||
| returns a completed task with usage.total_tokens. | |||
| """ | |||
| from __future__ import annotations | |||
| import json | |||
| import os | |||
| import sys | |||
| import time | |||
| import urllib.error | |||
| import urllib.parse | |||
| import urllib.request | |||
| from http.cookiejar import CookieJar | |||
| BASE_URL = os.getenv("NEW_API_BASE_URL", "http://127.0.0.1:3000").rstrip("/") | |||
| ADMIN_USERNAME = os.getenv("NEW_API_ADMIN_USERNAME", "root") | |||
| ADMIN_PASSWORD = os.getenv("NEW_API_ADMIN_PASSWORD", "123456") | |||
| API_TOKEN = os.getenv("NEW_API_TOKEN", "") | |||
| HAILUO_MODEL = os.getenv("VIDEO_PRICING_HAILUO_MODEL", "hailuo-video") | |||
| SEEDANCE_MODEL = os.getenv("VIDEO_PRICING_SEEDANCE_MODEL", "seedance-2") | |||
| QUOTA_PER_UNIT = int(os.getenv("VIDEO_PRICING_QUOTA_PER_UNIT", "500000")) | |||
| GROUP_RATIO = float(os.getenv("VIDEO_PRICING_GROUP_RATIO", "1")) | |||
| class Client: | |||
| def __init__(self) -> None: | |||
| self.cookies = CookieJar() | |||
| self.opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(self.cookies)) | |||
| def request(self, method: str, path: str, body=None, token: str | None = None, expected: int | None = None): | |||
| url = f"{BASE_URL}{path}" | |||
| data = None | |||
| headers = {"Content-Type": "application/json"} | |||
| if body is not None: | |||
| data = json.dumps(body).encode("utf-8") | |||
| if token: | |||
| headers["Authorization"] = f"Bearer {token}" | |||
| req = urllib.request.Request(url, data=data, headers=headers, method=method) | |||
| try: | |||
| with self.opener.open(req, timeout=30) as resp: | |||
| payload = read_json(resp) | |||
| if expected is not None and resp.status != expected: | |||
| raise AssertionError(f"{method} {path}: expected {expected}, got {resp.status}: {payload}") | |||
| return resp.status, payload | |||
| except urllib.error.HTTPError as err: | |||
| payload = read_json(err) | |||
| if expected is not None and err.code == expected: | |||
| return err.code, payload | |||
| raise AssertionError(f"{method} {path}: HTTP {err.code}: {payload}") from err | |||
| def read_json(resp): | |||
| raw = resp.read().decode("utf-8", errors="replace") | |||
| if not raw: | |||
| return None | |||
| try: | |||
| return json.loads(raw) | |||
| except json.JSONDecodeError: | |||
| return raw | |||
| def assert_success(payload, context: str): | |||
| if not isinstance(payload, dict) or payload.get("success") is not True: | |||
| raise AssertionError(f"{context} failed: {payload}") | |||
| return payload.get("data") | |||
| def usage(client: Client) -> dict: | |||
| _, payload = client.request("GET", "/api/usage/token/", token=API_TOKEN, expected=200) | |||
| if not payload.get("code"): | |||
| raise AssertionError(f"usage failed: {payload}") | |||
| return payload["data"] | |||
| def used_quota(client: Client) -> int: | |||
| return int(usage(client)["total_used"]) | |||
| def login(client: Client) -> None: | |||
| _, payload = client.request( | |||
| "POST", | |||
| "/api/user/login", | |||
| {"username": ADMIN_USERNAME, "password": ADMIN_PASSWORD}, | |||
| expected=200, | |||
| ) | |||
| assert_success(payload, "admin login") | |||
| def put_model_pricing(client: Client, model: str, config: dict) -> None: | |||
| _, payload = client.request("PUT", f"/api/option/model_pricing/{urllib.parse.quote(model, safe='')}", config, expected=200) | |||
| assert_success(payload, f"put pricing {model}") | |||
| def delete_model_pricing(client: Client, model: str) -> None: | |||
| _, payload = client.request("DELETE", f"/api/option/model_pricing/{urllib.parse.quote(model, safe='')}", expected=200) | |||
| assert_success(payload, f"delete pricing {model}") | |||
| def submit_video(client: Client, body: dict, expected: int = 200): | |||
| _, payload = client.request("POST", "/v1/videos", body, token=API_TOKEN, expected=expected) | |||
| return payload | |||
| def fetch_video(client: Client, task_id: str): | |||
| _, payload = client.request("GET", f"/v1/videos/{urllib.parse.quote(task_id, safe='')}", token=API_TOKEN, expected=200) | |||
| return payload | |||
| def hailuo_config() -> dict: | |||
| return { | |||
| "schema_version": 1, | |||
| "scope": "model", | |||
| "billing_unit": "per_call", | |||
| "preconsume_strategy": "exact", | |||
| "dimensions": [ | |||
| {"key": "resolution", "source": "request.resolution", "type": "string", "default": None}, | |||
| {"key": "duration", "source": "request.duration", "type": "number", "default": None}, | |||
| ], | |||
| "table": [ | |||
| {"resolution": "768P", "duration": 10, "price": 0.04, "source": "manual"}, | |||
| ], | |||
| "fallback": {"strategy": "reject"}, | |||
| } | |||
| def seedance_config() -> dict: | |||
| return { | |||
| "schema_version": 1, | |||
| "scope": "model", | |||
| "billing_unit": "per_1m_tokens", | |||
| "preconsume_strategy": "minimum", | |||
| "dimensions": [ | |||
| {"key": "resolution", "source": "request.resolution", "type": "string", "default": None}, | |||
| ], | |||
| "table": [ | |||
| {"resolution": "720P", "price": 0.5, "source": "manual"}, | |||
| ], | |||
| "fallback": {"strategy": "reject"}, | |||
| } | |||
| def expected_quota(price_usd: float, tokens: int | None = None) -> int: | |||
| units = tokens if tokens is not None else 1 | |||
| divisor = 1_000_000 if tokens is not None else 1 | |||
| quota = int(float(units) * price_usd / divisor * QUOTA_PER_UNIT * GROUP_RATIO) | |||
| if tokens and quota <= 0 and price_usd > 0 and GROUP_RATIO > 0: | |||
| return 1 | |||
| return quota | |||
| def run() -> None: | |||
| if not API_TOKEN: | |||
| raise RuntimeError("NEW_API_TOKEN is required") | |||
| client = Client() | |||
| login(client) | |||
| try: | |||
| put_model_pricing(client, HAILUO_MODEL, hailuo_config()) | |||
| before = used_quota(client) | |||
| payload = submit_video( | |||
| client, | |||
| {"model": HAILUO_MODEL, "prompt": "pricing smoke", "resolution": "768P", "duration": 10}, | |||
| ) | |||
| after = used_quota(client) | |||
| assert after - before == expected_quota(0.04), f"Hailuo quota mismatch: before={before}, after={after}, response={payload}" | |||
| before_reject = used_quota(client) | |||
| rejected = submit_video( | |||
| client, | |||
| {"model": HAILUO_MODEL, "prompt": "pricing smoke", "resolution": "1080P", "duration": 10}, | |||
| expected=400, | |||
| ) | |||
| after_reject = used_quota(client) | |||
| assert after_reject == before_reject, f"reject changed quota: {before_reject} -> {after_reject}, response={rejected}" | |||
| put_model_pricing(client, SEEDANCE_MODEL, seedance_config()) | |||
| before_usage = used_quota(client) | |||
| seedance_submit = submit_video( | |||
| client, | |||
| {"model": SEEDANCE_MODEL, "prompt": "pricing smoke", "resolution": "720P", "duration": 5}, | |||
| ) | |||
| task_id = seedance_submit.get("id") or seedance_submit.get("task_id") or seedance_submit.get("data", {}).get("id") | |||
| if not task_id: | |||
| raise AssertionError(f"Seedance submit did not return task id: {seedance_submit}") | |||
| total_tokens = int(os.getenv("VIDEO_PRICING_EXPECTED_TOTAL_TOKENS", "1000")) | |||
| for _ in range(12): | |||
| fetched = fetch_video(client, task_id) | |||
| status = fetched.get("status") or fetched.get("data", {}).get("status") | |||
| if status in {"succeeded", "completed", "success"}: | |||
| break | |||
| time.sleep(1) | |||
| after_usage = used_quota(client) | |||
| delta = after_usage - before_usage | |||
| assert delta == expected_quota(0.5, total_tokens), f"Seedance usage quota mismatch: delta={delta}, response={fetched}" | |||
| finally: | |||
| delete_model_pricing(client, HAILUO_MODEL) | |||
| delete_model_pricing(client, SEEDANCE_MODEL) | |||
| if __name__ == "__main__": | |||
| try: | |||
| run() | |||
| except Exception as exc: | |||
| print(f"FAIL: {exc}", file=sys.stderr) | |||
| sys.exit(1) | |||
| print("PASS: video pricing smoke completed") | |||