交易流水对账明细 | AI引擎计价规则
This commit is contained in:
@@ -0,0 +1,275 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from copy import deepcopy
|
||||
from typing import Any, Mapping
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
|
||||
def safe_json_dict(value: Any) -> dict[str, Any]:
|
||||
"""Best-effort JSON object conversion without leaking parse failures into billing."""
|
||||
if isinstance(value, Mapping):
|
||||
return deepcopy(dict(value))
|
||||
if isinstance(value, str) and value.strip():
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
return deepcopy(dict(parsed)) if isinstance(parsed, Mapping) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
return {}
|
||||
|
||||
|
||||
def safe_int(value: Any, default: int = 0) -> int:
|
||||
try:
|
||||
if value in (None, ""):
|
||||
return default
|
||||
return int(float(value))
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def safe_float(value: Any, default: float = 0.0) -> float:
|
||||
try:
|
||||
if value in (None, ""):
|
||||
return default
|
||||
return float(value)
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def safe_bool(value: Any, default: bool = False) -> bool:
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if value in (None, ""):
|
||||
return default
|
||||
if isinstance(value, (int, float)):
|
||||
return value != 0
|
||||
text = str(value).strip().lower()
|
||||
if text in {"1", "true", "yes", "on", "enabled"}:
|
||||
return True
|
||||
if text in {"0", "false", "no", "off", "disabled"}:
|
||||
return False
|
||||
return default
|
||||
|
||||
|
||||
def parse_size(value: Any) -> tuple[int, int]:
|
||||
text = str(value or "").lower().replace("×", "x")
|
||||
match = re.search(r"(\d{2,5})\s*x\s*(\d{2,5})", text)
|
||||
if not match:
|
||||
return 0, 0
|
||||
return int(match.group(1)), int(match.group(2))
|
||||
|
||||
|
||||
def _extract_usage(data: Mapping[str, Any]) -> dict[str, Any]:
|
||||
candidates = [
|
||||
data.get("usage"),
|
||||
(data.get("data") or {}).get("usage") if isinstance(data.get("data"), Mapping) else None,
|
||||
(data.get("result") or {}).get("usage") if isinstance(data.get("result"), Mapping) else None,
|
||||
]
|
||||
for value in candidates:
|
||||
if isinstance(value, Mapping):
|
||||
return deepcopy(dict(value))
|
||||
return {}
|
||||
|
||||
|
||||
def normalize_text_pricing_usage(
|
||||
raw_usage: Mapping[str, Any] | None,
|
||||
*,
|
||||
base: Mapping[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Normalize Ark/OpenAI text usage and retain cache/audio dimensions."""
|
||||
raw = deepcopy(dict(raw_usage or {}))
|
||||
result = deepcopy(dict(base or {}))
|
||||
|
||||
input_tokens = safe_int(result.get("input_tokens"), safe_int(raw.get("input_tokens"), safe_int(raw.get("prompt_tokens"))))
|
||||
output_tokens = safe_int(result.get("output_tokens"), safe_int(raw.get("output_tokens"), safe_int(raw.get("completion_tokens"))))
|
||||
total_tokens = safe_int(result.get("total_tokens"), safe_int(raw.get("total_tokens"), input_tokens + output_tokens))
|
||||
|
||||
details: dict[str, Any] = {}
|
||||
for key in ("prompt_tokens_details", "input_tokens_details"):
|
||||
value = raw.get(key)
|
||||
if isinstance(value, Mapping):
|
||||
details.update(deepcopy(dict(value)))
|
||||
|
||||
cached_input_tokens = safe_int(
|
||||
result.get("cached_input_tokens"),
|
||||
safe_int(raw.get("cached_input_tokens"), safe_int(details.get("cached_tokens"), safe_int(details.get("cache_read_tokens")))),
|
||||
)
|
||||
audio_input_tokens = safe_int(
|
||||
result.get("audio_input_tokens"),
|
||||
safe_int(raw.get("audio_input_tokens"), safe_int(details.get("audio_tokens"))),
|
||||
)
|
||||
cached_audio_input_tokens = safe_int(
|
||||
result.get("cached_audio_input_tokens"),
|
||||
safe_int(raw.get("cached_audio_input_tokens"), safe_int(details.get("cached_audio_tokens"))),
|
||||
)
|
||||
|
||||
result.update(
|
||||
{
|
||||
"input_tokens": max(0, input_tokens),
|
||||
"output_tokens": max(0, output_tokens),
|
||||
"total_tokens": max(0, total_tokens),
|
||||
"context_tokens": max(0, safe_int(result.get("context_tokens"), input_tokens)),
|
||||
"cached_input_tokens": max(0, min(input_tokens, cached_input_tokens)),
|
||||
"audio_input_tokens": max(0, min(input_tokens, audio_input_tokens)),
|
||||
"cached_audio_input_tokens": max(0, min(input_tokens, audio_input_tokens, cached_audio_input_tokens)),
|
||||
"cache_storage_tokens": max(0, safe_int(result.get("cache_storage_tokens"), safe_int(raw.get("cache_storage_tokens")))),
|
||||
"cache_storage_duration_hours": max(
|
||||
0.0,
|
||||
safe_float(result.get("cache_storage_duration_hours"), safe_float(raw.get("cache_storage_duration_hours"))),
|
||||
),
|
||||
"provider_usage_primary": safe_bool(result.get("provider_usage_primary"), True),
|
||||
"usage_source": result.get("usage_source") or "provider",
|
||||
}
|
||||
)
|
||||
if details:
|
||||
result["provider_input_token_details"] = details
|
||||
return result
|
||||
|
||||
|
||||
def extract_image_output_items(provider_response: Any) -> list[dict[str, Any]]:
|
||||
"""Only parse explicit synchronous image output items; never recurse through arbitrary URLs."""
|
||||
data = safe_json_dict(provider_response)
|
||||
raw_items = data.get("data")
|
||||
if isinstance(raw_items, Mapping):
|
||||
raw_items = raw_items.get("items") or raw_items.get("data")
|
||||
if not isinstance(raw_items, list):
|
||||
raw_items = (data.get("result") or {}).get("data") if isinstance(data.get("result"), Mapping) else None
|
||||
if not isinstance(raw_items, list):
|
||||
return []
|
||||
|
||||
items: list[dict[str, Any]] = []
|
||||
for index, raw in enumerate(raw_items):
|
||||
if not isinstance(raw, Mapping):
|
||||
continue
|
||||
url = raw.get("url") or raw.get("image_url")
|
||||
width = safe_int(raw.get("width"))
|
||||
height = safe_int(raw.get("height"))
|
||||
if width <= 0 or height <= 0:
|
||||
width, height = parse_size(raw.get("size"))
|
||||
item = {
|
||||
"index": index,
|
||||
"url": str(url) if url else None,
|
||||
"width": width or None,
|
||||
"height": height or None,
|
||||
"pixels": width * height if width > 0 and height > 0 else None,
|
||||
"size": raw.get("size"),
|
||||
"size_source": "provider_response" if width > 0 and height > 0 else "unavailable",
|
||||
}
|
||||
# A valid provider output may omit a URL in rare response formats, but it must
|
||||
# still be represented for generated-count and pixel-tier accounting.
|
||||
items.append({key: value for key, value in item.items() if value is not None})
|
||||
return items
|
||||
|
||||
|
||||
def sanitize_output_items(items: list[Mapping[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Remove volatile signed URLs before persisting pricing/generation snapshots."""
|
||||
sanitized: list[dict[str, Any]] = []
|
||||
for raw in items:
|
||||
item = deepcopy(dict(raw))
|
||||
url = str(item.pop("url", "") or "").strip()
|
||||
if url:
|
||||
try:
|
||||
parts = urlsplit(url)
|
||||
normalized = (
|
||||
urlunsplit((parts.scheme.lower(), parts.netloc.lower(), parts.path, "", ""))
|
||||
if parts.scheme and parts.netloc
|
||||
else url.split("?", 1)[0].split("#", 1)[0]
|
||||
)
|
||||
except Exception:
|
||||
normalized = url.split("?", 1)[0].split("#", 1)[0]
|
||||
item["url_sha256"] = hashlib.sha256(normalized.encode("utf-8")).hexdigest()
|
||||
sanitized.append(item)
|
||||
return sanitized
|
||||
|
||||
|
||||
def normalize_provider_media_usage(
|
||||
provider_response: Any,
|
||||
*,
|
||||
gen_type: str,
|
||||
fallback_total_tokens: int = 0,
|
||||
request_image_px: str | None = None,
|
||||
requested_output_count: int = 1,
|
||||
provider_input_image_count: int = 0,
|
||||
) -> dict[str, Any]:
|
||||
"""Normalize Volcengine synchronous-image or asynchronous-video response usage."""
|
||||
data = safe_json_dict(provider_response)
|
||||
raw_usage = _extract_usage(data)
|
||||
input_tokens = safe_int(raw_usage.get("input_tokens"), safe_int(raw_usage.get("prompt_tokens")))
|
||||
output_tokens = safe_int(
|
||||
raw_usage.get("output_tokens"),
|
||||
safe_int(raw_usage.get("completion_tokens"), safe_int(raw_usage.get("generated_tokens"))),
|
||||
)
|
||||
total_tokens = safe_int(raw_usage.get("total_tokens"), fallback_total_tokens)
|
||||
if total_tokens <= 0:
|
||||
total_tokens = input_tokens + output_tokens
|
||||
if output_tokens <= 0 and total_tokens > input_tokens:
|
||||
output_tokens = total_tokens - input_tokens
|
||||
|
||||
result: dict[str, Any] = deepcopy(dict(raw_usage))
|
||||
result.update(
|
||||
{
|
||||
"input_tokens": max(0, input_tokens),
|
||||
"output_tokens": max(0, output_tokens),
|
||||
"total_tokens": max(0, total_tokens),
|
||||
"requested_output_count": max(1, requested_output_count),
|
||||
"provider_usage_primary": True,
|
||||
}
|
||||
)
|
||||
|
||||
pricing_meta = data.get("pricing_meta") if isinstance(data.get("pricing_meta"), Mapping) else {}
|
||||
if gen_type == "image":
|
||||
output_items = extract_image_output_items(data)
|
||||
fallback_width, fallback_height = parse_size(request_image_px)
|
||||
for item in output_items:
|
||||
if not item.get("width") and fallback_width > 0 and fallback_height > 0:
|
||||
item.update(
|
||||
{
|
||||
"width": fallback_width,
|
||||
"height": fallback_height,
|
||||
"pixels": fallback_width * fallback_height,
|
||||
"size_source": "request_explicit",
|
||||
}
|
||||
)
|
||||
output_items = sanitize_output_items(output_items)
|
||||
provider_count = safe_int(
|
||||
pricing_meta.get("provider_input_image_count"),
|
||||
safe_int(data.get("provider_input_image_count"), provider_input_image_count),
|
||||
)
|
||||
generated = len(output_items) or safe_int(raw_usage.get("generated_images"))
|
||||
provider_billed = safe_int(
|
||||
raw_usage.get("billed_images"),
|
||||
safe_int(pricing_meta.get("provider_billed_count"), max(0, generated)),
|
||||
)
|
||||
result.update(
|
||||
{
|
||||
"provider_input_image_count": max(0, provider_count),
|
||||
"input_image_count": max(0, provider_count),
|
||||
"output_items": output_items,
|
||||
"successful_output_count": max(0, generated),
|
||||
"provider_billed_count": max(0, provider_billed),
|
||||
"usage_source": "provider_response",
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
width = safe_int(raw_usage.get("width"), safe_int(data.get("width")))
|
||||
height = safe_int(raw_usage.get("height"), safe_int(data.get("height")))
|
||||
if width <= 0 or height <= 0:
|
||||
width, height = parse_size(raw_usage.get("size") or data.get("size"))
|
||||
result.update(
|
||||
{
|
||||
"output_width": width,
|
||||
"output_height": height,
|
||||
"dimension_source": "provider_response" if width > 0 and height > 0 else "unavailable",
|
||||
"fps": safe_float(raw_usage.get("fps"), safe_float(data.get("fps"))),
|
||||
"resolution": str(raw_usage.get("resolution") or data.get("resolution") or "").lower(),
|
||||
"aspect_ratio": str(raw_usage.get("aspect_ratio") or data.get("aspect_ratio") or data.get("ratio") or ""),
|
||||
"generate_audio": safe_bool(raw_usage.get("generate_audio"), safe_bool(data.get("generate_audio"))),
|
||||
"inference_mode": str(raw_usage.get("inference_mode") or data.get("service_tier") or "online").lower(),
|
||||
"usage_source": "provider" if total_tokens > 0 else "provider_response",
|
||||
}
|
||||
)
|
||||
return result
|
||||
Reference in New Issue
Block a user