2026-02-01 17:28:27 +08:00
|
|
|
from unittest.mock import MagicMock
|
|
|
|
|
|
2026-01-31 19:11:25 +08:00
|
|
|
from src.models.database import DimensionCollector
|
|
|
|
|
from src.services.billing.dimension_collector_service import (
|
|
|
|
|
DimensionCollectInput,
|
|
|
|
|
DimensionCollectorRuntime,
|
2026-02-01 17:28:27 +08:00
|
|
|
DimensionCollectorService,
|
2026-01-31 19:11:25 +08:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestDimensionCollectorRuntime:
|
|
|
|
|
def test_priority_fallback(self) -> None:
|
|
|
|
|
runtime = DimensionCollectorRuntime()
|
|
|
|
|
collectors = [
|
|
|
|
|
DimensionCollector(
|
2026-02-01 17:28:27 +08:00
|
|
|
api_format="openai:chat",
|
2026-01-31 19:11:25 +08:00
|
|
|
task_type="chat",
|
|
|
|
|
dimension_name="input_tokens",
|
|
|
|
|
source_type="response",
|
|
|
|
|
source_path="usage.prompt_tokens",
|
|
|
|
|
value_type="int",
|
|
|
|
|
priority=10,
|
|
|
|
|
is_enabled=True,
|
|
|
|
|
),
|
|
|
|
|
DimensionCollector(
|
2026-02-01 17:28:27 +08:00
|
|
|
api_format="openai:chat",
|
2026-01-31 19:11:25 +08:00
|
|
|
task_type="chat",
|
|
|
|
|
dimension_name="input_tokens",
|
|
|
|
|
source_type="response",
|
|
|
|
|
source_path="usageMetadata.promptTokenCount",
|
|
|
|
|
value_type="int",
|
|
|
|
|
priority=5,
|
|
|
|
|
is_enabled=True,
|
|
|
|
|
),
|
|
|
|
|
]
|
|
|
|
|
dims = runtime.collect(
|
2026-02-03 18:48:39 +08:00
|
|
|
collectors=collectors, # type: ignore[arg-type]
|
2026-01-31 19:11:25 +08:00
|
|
|
inp=DimensionCollectInput(
|
|
|
|
|
response={"usageMetadata": {"promptTokenCount": 123}},
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
assert dims["input_tokens"] == 123
|
|
|
|
|
|
|
|
|
|
def test_transform_expression_value(self) -> None:
|
|
|
|
|
runtime = DimensionCollectorRuntime()
|
|
|
|
|
collectors = [
|
|
|
|
|
DimensionCollector(
|
2026-02-01 17:28:27 +08:00
|
|
|
api_format="gemini:video",
|
2026-01-31 19:11:25 +08:00
|
|
|
task_type="video",
|
|
|
|
|
dimension_name="file_size_mb",
|
|
|
|
|
source_type="metadata",
|
|
|
|
|
source_path="result.file_size_bytes",
|
|
|
|
|
transform_expression="value / 1024 / 1024",
|
|
|
|
|
value_type="float",
|
|
|
|
|
priority=0,
|
|
|
|
|
is_enabled=True,
|
|
|
|
|
)
|
|
|
|
|
]
|
|
|
|
|
dims = runtime.collect(
|
2026-02-03 18:48:39 +08:00
|
|
|
collectors=collectors, # type: ignore[arg-type]
|
2026-01-31 19:11:25 +08:00
|
|
|
inp=DimensionCollectInput(metadata={"result": {"file_size_bytes": 1048576}}),
|
|
|
|
|
)
|
|
|
|
|
assert abs(dims["file_size_mb"] - 1.0) < 1e-9
|
|
|
|
|
|
|
|
|
|
def test_computed_dimension(self) -> None:
|
|
|
|
|
runtime = DimensionCollectorRuntime()
|
|
|
|
|
collectors = [
|
|
|
|
|
DimensionCollector(
|
2026-02-01 17:28:27 +08:00
|
|
|
api_format="claude:chat",
|
2026-01-31 19:11:25 +08:00
|
|
|
task_type="chat",
|
|
|
|
|
dimension_name="input_tokens",
|
|
|
|
|
source_type="request",
|
|
|
|
|
source_path="usage.input_tokens",
|
|
|
|
|
value_type="int",
|
|
|
|
|
priority=0,
|
|
|
|
|
is_enabled=True,
|
|
|
|
|
),
|
|
|
|
|
DimensionCollector(
|
2026-02-01 17:28:27 +08:00
|
|
|
api_format="claude:chat",
|
2026-01-31 19:11:25 +08:00
|
|
|
task_type="chat",
|
|
|
|
|
dimension_name="cache_read_tokens",
|
|
|
|
|
source_type="request",
|
|
|
|
|
source_path="usage.cache_read_tokens",
|
|
|
|
|
value_type="int",
|
|
|
|
|
priority=0,
|
|
|
|
|
is_enabled=True,
|
|
|
|
|
),
|
|
|
|
|
DimensionCollector(
|
2026-02-01 17:28:27 +08:00
|
|
|
api_format="claude:chat",
|
2026-01-31 19:11:25 +08:00
|
|
|
task_type="chat",
|
|
|
|
|
dimension_name="total_input_tokens",
|
|
|
|
|
source_type="computed",
|
|
|
|
|
source_path=None,
|
|
|
|
|
transform_expression="input_tokens + cache_read_tokens",
|
|
|
|
|
value_type="int",
|
|
|
|
|
priority=0,
|
|
|
|
|
is_enabled=True,
|
|
|
|
|
),
|
|
|
|
|
]
|
|
|
|
|
dims = runtime.collect(
|
2026-02-03 18:48:39 +08:00
|
|
|
collectors=collectors, # type: ignore[arg-type]
|
2026-01-31 19:11:25 +08:00
|
|
|
inp=DimensionCollectInput(
|
|
|
|
|
request={"usage": {"input_tokens": 100, "cache_read_tokens": 20}}
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
assert dims["input_tokens"] == 100
|
|
|
|
|
assert dims["cache_read_tokens"] == 20
|
|
|
|
|
assert dims["total_input_tokens"] == 120
|
2026-02-01 17:28:27 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestDimensionCollectorService:
|
|
|
|
|
def test_video_fallback_merges_base_collectors(self) -> None:
|
2026-02-03 18:48:39 +08:00
|
|
|
from src.services.billing.cache import BillingCache
|
2026-02-01 17:28:27 +08:00
|
|
|
|
2026-02-03 18:48:39 +08:00
|
|
|
BillingCache.invalidate_all()
|
2026-02-01 17:28:27 +08:00
|
|
|
|
2026-02-03 18:48:39 +08:00
|
|
|
# code-only: openai:video should fall back to openai:chat video collectors shipped in code
|
|
|
|
|
db = MagicMock()
|
2026-02-01 17:28:27 +08:00
|
|
|
svc = DimensionCollectorService(db)
|
|
|
|
|
result = svc.list_enabled_collectors(api_format="openai:video", task_type="video")
|
|
|
|
|
|
2026-02-03 18:48:39 +08:00
|
|
|
assert "video_size_bytes" in [c.dimension_name for c in result]
|