mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
[499f9eb1] Token Usage & Cost Analytics — Full-Stack Instrumentation, Persistence, and Visualization (#90)
* [cd2bf666] feat(usage): add token usage types, API client, hooks, and UI components (#87) (#88) - Append 5 TypeScript interfaces to src/types/index.ts: TokenUsageSnapshot, AgentUsageRow, UsageSession, UsageTimePoint, ModelUsageSlice - Create src/lib/api/usage.ts: Axios singleton + isMockMode guards for getUsageSnapshot, getUsageTimeSeries, getAgentUsage, getUsageSessions, getModelUsage - Create src/hooks/use-usage.ts: usageKeys factory + useUsageSnapshot, useUsageTimeSeries, useAgentUsage, useUsageSessions, useModelUsage hooks - Create UsageOverviewPanel (dashboard/usage-overview-panel.tsx): 6 metric rows with Skeleton loading state; week-over-week trend arrow for cost - Update CommandCenter: Metrics+Alerts row expanded from 2-col to 3-col grid adding UsageOverviewPanel - Create src/components/metrics/ folder: UsageTimeSeriesChart (recharts stacked AreaChart with var(--chart-1/2/3)), ModelUsageDonut (PieChart), AgentUsageChart and TeamUsageChart (BarChart), SessionsTable (sortable columns + 10-row Prev/Next pagination) - Update Metrics page: Token Usage & Costs section with 5 rows (summary cards, time series+donut, agent+team bar charts, projection+cache efficiency, sessions table) - Add usage mini-bar to AgentCard: token count + cost + progress bar; AgentGrid and Agents page pass agentUsageMap through - Install recharts 3.8.1 - Export all new symbols through their barrel index.ts files Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev> * [10372f0f] Implement full token usage instrumentation: DB migration, SDK endpoints, orchestrator hooks, analytics API, WebSocket events, dashboard integration (#86) (#89) * [10372f0f] feat(token-usage): add Alembic migration 026 for token usage tables Create agent_spawn_sessions, token_usage_snapshots, and daily_usage_rollups tables with correct BIGINT columns, indexes, and unique constraint. Chain: 025_agentrole_prompter → 026_token_usage_tables. * [10372f0f] feat(token-usage): add ORM table classes for token usage instrumentation Add AgentSpawnSessionTable, TokenUsageSnapshotTable, DailyUsageRollupTable to db/tables.py. Import BigInteger and Date from SQLAlchemy. All columns match the migration schema with BIGINT token counts and proper indexes. * [10372f0f] feat(billing): add pricing module with calculate_cost() function Create roboco/billing/__init__.py and roboco/billing/pricing.py with calculate_cost() supporting Claude opus/sonnet/haiku models with input/output/cache pricing. Unknown models return 0.0 without raising. * [10372f0f] feat(sdk): add POST /usage/report and GET /usage/status endpoints to agent SDK Extend _SessionState with token counters. Add TokenReportRequest and TokenUsageStatus models. POST /usage/report additively accumulates token counts; GET /usage/status returns current session totals for sweeper polling. * [10372f0f] feat(orchestrator): add token usage instrumentation hooks - _launch_spawn() calls _record_spawn_session() after successful container spawn - stop_agent() calls _finalize_spawn_session() before container removal - _run_sweep() calls _sweep_token_snapshots() and _sweep_daily_rollup() each tick - New methods: _record_spawn_session, _finalize_spawn_session, _sweep_token_snapshots, _sweep_daily_rollup in TOKEN USAGE section * [10372f0f] feat(api): add token usage analytics API with 7 endpoints Create roboco/services/usage.py (UsageService) and roboco/api/routes/usage.py. Endpoints: GET /api/usage/summary, /time-series, /by-agent, /by-team, /by-model, /projection, /cache-efficiency. Register in app.py. * [10372f0f] feat(dashboard): add usage_summary field to CEO dashboard Add UsageSummary schema (tokens_today, cost_today_usd) to dashboard schemas. Add usage_summary: UsageSummary | None to CEOOverview. Update get_ceo_overview() to populate usage_summary from daily_usage_rollups. * [10372f0f] fix(billing/tests): remove dead except block in _sweep_daily_rollup, add unit tests for pricing.py and services/usage.py - Remove unreachable `except Exception as e` block in orchestrator.py _sweep_daily_rollup() (lines 3376-3381) which referenced undefined `agent_id` and was copy-pasted from _sweep_token_snapshots by mistake - Add tests/unit/billing/test_pricing.py: 31 tests covering opus/sonnet/ haiku tiers with all 4 token types, unknown model → 0.0, empty string → 0.0, and substring-match priority (longer fragment wins) - Add tests/unit/services/test_usage.py: 25 tests covering get_summary trend_pct edge cases (prev=0, both=0, prev>0), get_by_agent/team/model pct_of_total summing to 100%, get_projection formula (avg_daily×30), and get_cache_efficiency hit-rate and cost_saved arithmetic - pricing.py: 100% coverage; services/usage.py: 83% coverage (>80% target) * [10372f0f] fix(usage): include cache tokens in time-series total_tokens to fix AC9 consistency violation get_time_series() previously computed total_tokens as tokens_input + tokens_output only. get_summary() includes all 4 token types (input + output + cache_read + cache_write). AC9 requires both endpoints to agree on their totals for the same period. Fix: add tokens_cache_read and tokens_cache_write to the SELECT query in get_time_series() and include them in the total_tokens calculation. Also adds 4 new unit tests in TestGetTimeSeries covering: - total_tokens includes cache_read and cache_write (the AC9 guard) - zero cache tokens still produces correct total - empty result returns empty list - required fields are present in each point * [10372f0f] fix(usage): remove unused imports and include cache tokens in breakdown totals (AC10) - Remove import math (F401 — never used) - Remove text from sqlalchemy import (F401 — never used) - Remove unused local calculate_cost import inside get_cache_efficiency (F401) - Add tokens_cache_read and tokens_cache_write to SELECT in get_by_agent, get_by_team, and get_by_model; update grand_total and per-item total to include all 4 token types so totals match get_summary() (AC10 fix) - Update test mock rows to include explicit tokens_cache_read=0 and tokens_cache_write=0 so they work with the fixed code - Add new test cases: test_cache_tokens_included_in_total_tokens and test_pct_of_total_sums_to_100_with_cache_tokens for each breakdown class --------- Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev> * [44b9eb1f] feat(usage): align frontend API client, TS types, and chart components to real backend contract (#92) (#94) Update all usage-related frontend code to match the actual FastAPI backend response shapes and endpoint paths: - panel/src/lib/api/usage.ts: rewrite all 7 API functions to use correct endpoint paths (/usage/summary, /usage/by-agent, /usage/by-model, /usage/by-team, /usage/time-series, /usage/projection, /usage/cache-efficiency); send period query param (24h/7d/30d not hours); mock generators produce data matching real backend shapes exactly; getUsageSessions returns [] in prod (no /usage/sessions endpoint exists) - panel/src/types/index.ts: replace TokenUsageSnapshot with UsageSummary (tokens_input/tokens_output/total_cost_usd/trend_pct); update AgentUsageRow to use agent_slug/total_tokens/cost_usd/pct_of_total; add TeamUsageRow, UsageProjection, CacheEfficiencyResponse; update UsageTimePoint to use bucket field; update UsageSession to use agent_slug - panel/src/hooks/use-usage.ts: rewrite all hooks to match new API and types; add useTeamUsage, useUsageProjection, useCacheEfficiency hooks - panel/src/components/metrics/usage-time-series-chart.tsx: use bucket field (not timestamp) for axis labels - panel/src/components/metrics/agent-usage-chart.tsx: use agent_slug and total_tokens (not agent_name/tokens_today) - panel/src/components/metrics/team-usage-chart.tsx: rewrite to accept TeamUsageRow[] from API directly - panel/src/components/metrics/model-usage-donut.tsx: use total_tokens, cost_usd, pct_of_total (not tokens/cost/percentage) - panel/src/components/metrics/sessions-table.tsx: use agent_slug, sort keys updated - panel/src/components/dashboard/usage-overview-panel.tsx: use useUsageSummary with tokens_input/tokens_output/total_cost_usd/trend_pct - panel/src/app/(dashboard)/metrics/page.tsx: wire all new hooks, add TeamUsageChart, ProjectionCard, CacheEfficiencyCard with correct types - panel/src/app/(dashboard)/agents/page.tsx: key agentUsageMap by agent_slug - panel/src/components/agents/agent-card.tsx: use total_tokens and cost_usd Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev> * [2161b832] fix: SDK_PORT constant, stop_agent lock refactor, usage_session_id binding, rollup 7-day window (#93) (#95) - Add SDK_PORT = 9000 module-level constant to orchestrator.py; replace hardcoded 9000 in _sweep_budget_exceeded URL with SDK_PORT - Add UUID to TYPE_CHECKING imports to satisfy ruff F821 - Refactor stop_agent: call _finalize_spawn_session BEFORE acquiring self._lock so the SDK HTTP round-trip does not hold the lock - Add usage_session_id: UUID | None field to AgentInstance dataclass - Change _record_spawn_session to return UUID | None; wire return value back to instance.usage_session_id in _launch_spawn - Update _finalize_spawn_session to use WHERE id=usage_session_id for direct session row lookup when usage_session_id is not None - Add started_at >= (now_utc - 7 days) filter to _sweep_daily_rollup aggregate query to avoid re-aggregating all-time history each sweep Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev> * [2e0759e1] fix: pricing accuracy, import ordering, session-id binding, rollup cleanup, write-hook tests (#97) (#98) - pricing.py: correct claude-opus-4 prices (5/25/0.50/6.25 not 15/75/1.5/3.75) and haiku family prices (1/5/0.10/1.25 not 0.8/4/0.08/0.20); add Ollama zero-cost early-return; add structlog warning for unmatched model names - app.py: move usage_router import before routes.v1 block (ruff isort fix) - orchestrator.py _sweep_daily_rollup: remove unused calculate_cost import; add blank line between stdlib (uuid4) and third-party (sqlalchemy) imports - orchestrator.py _sweep_token_snapshots: prefer direct lookup by instance.usage_session_id; fall back to agent_slug heuristic only when None - tests: add test_sweep_daily_rollup_inserts_new_row and test_stop_agent_finalizes_before_lock to test_orchestrator_write_hooks.py - usage.py, routes/usage.py, stream_bus.py, test files: ruff format/lint fixes Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev> * Mypy compliance * fix(migrations,tests): linearize forked migration chain + correct ceo_reject coordination-root expectation The master merge brought in 026_completed_dependency_ids alongside the rework's 026_token_usage_tables — both off 025, forking the alembic head and breaking the enum-parity test. Rebase token-usage onto 026_completed_dependency_ids (linear chain, single head). Also: test_ceo_reject_routes_coordination_task_to_main_pm asserted the old NEEDS_REVISION behavior; the lifecycle fix correctly routes a coordination root to PENDING (Main PM's claim source). Update the assertion. --------- Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev> Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev> Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
co-authored by
Frontend Developer 1
Backend Developer 1
Renn F
parent
93c6ef8a57
commit
b3057628b0
@@ -0,0 +1,299 @@
|
||||
"""
|
||||
Unit tests for roboco.billing.pricing — calculate_cost().
|
||||
|
||||
Covers:
|
||||
- Each model tier (opus, sonnet, haiku) with all 4 token types.
|
||||
- Unknown model name returns 0.0 without raising.
|
||||
- Empty model string returns 0.0 without raising.
|
||||
- Substring match correctness: longer fragment wins
|
||||
(e.g. 'claude-sonnet-4-6' matches 'claude-sonnet-4' not bare 'sonnet').
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from roboco.billing.pricing import calculate_cost
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Named constants (ruff PLR2004: magic values in comparisons must be named).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Token counts
|
||||
_M = 1_000_000 # 1 million tokens
|
||||
|
||||
# Pricing — per-1M USD, matches the _PRICING table in pricing.py
|
||||
_OPUS_INPUT = 5.00
|
||||
_OPUS_OUTPUT = 25.00
|
||||
_OPUS_CACHE_READ = 0.50
|
||||
_OPUS_CACHE_WRITE = 6.25
|
||||
|
||||
_SONNET_INPUT = 3.00
|
||||
_SONNET_OUTPUT = 15.00
|
||||
_SONNET_CACHE_READ = 0.30
|
||||
_SONNET_CACHE_WRITE = 0.75
|
||||
|
||||
_HAIKU_INPUT = 1.00
|
||||
_HAIKU_OUTPUT = 5.00
|
||||
_HAIKU_CACHE_READ = 0.10
|
||||
_HAIKU_CACHE_WRITE = 1.25
|
||||
|
||||
_HAIKU3_INPUT = 0.25 # claude-haiku-3 is cheaper than haiku-3-5 / haiku-4
|
||||
|
||||
# Tolerance for floating-point comparisons
|
||||
_TOL = 1e-4
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Opus tier
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestOpusTier:
|
||||
"""claude-opus-4 family pricing."""
|
||||
|
||||
def test_input_only(self) -> None:
|
||||
cost = calculate_cost("claude-opus-4-5", tokens_input=_M, tokens_output=0)
|
||||
assert abs(cost - _OPUS_INPUT) < _TOL
|
||||
|
||||
def test_output_only(self) -> None:
|
||||
cost = calculate_cost("claude-opus-4-5", tokens_input=0, tokens_output=_M)
|
||||
assert abs(cost - _OPUS_OUTPUT) < _TOL
|
||||
|
||||
def test_cache_read_only(self) -> None:
|
||||
cost = calculate_cost(
|
||||
"claude-opus-4-5",
|
||||
tokens_input=0,
|
||||
tokens_output=0,
|
||||
tokens_cache_read=_M,
|
||||
)
|
||||
assert abs(cost - _OPUS_CACHE_READ) < _TOL
|
||||
|
||||
def test_cache_write_only(self) -> None:
|
||||
cost = calculate_cost(
|
||||
"claude-opus-4-5",
|
||||
tokens_input=0,
|
||||
tokens_output=0,
|
||||
tokens_cache_write=_M,
|
||||
)
|
||||
assert abs(cost - _OPUS_CACHE_WRITE) < _TOL
|
||||
|
||||
def test_all_token_types(self) -> None:
|
||||
cost = calculate_cost(
|
||||
"claude-opus-4-5",
|
||||
tokens_input=_M,
|
||||
tokens_output=_M,
|
||||
tokens_cache_read=_M,
|
||||
tokens_cache_write=_M,
|
||||
)
|
||||
expected = _OPUS_INPUT + _OPUS_OUTPUT + _OPUS_CACHE_READ + _OPUS_CACHE_WRITE
|
||||
assert abs(cost - expected) < _TOL
|
||||
|
||||
def test_short_alias(self) -> None:
|
||||
"""Bare 'opus' alias resolves to the opus tier."""
|
||||
cost = calculate_cost("opus", tokens_input=_M, tokens_output=0)
|
||||
assert abs(cost - _OPUS_INPUT) < _TOL
|
||||
|
||||
def test_returns_float(self) -> None:
|
||||
cost = calculate_cost("claude-opus-4", tokens_input=100, tokens_output=50)
|
||||
assert isinstance(cost, float)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sonnet tier
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSonnetTier:
|
||||
"""claude-sonnet-4 family pricing."""
|
||||
|
||||
def test_input_only(self) -> None:
|
||||
cost = calculate_cost("claude-sonnet-4-6", tokens_input=_M, tokens_output=0)
|
||||
assert abs(cost - _SONNET_INPUT) < _TOL
|
||||
|
||||
def test_output_only(self) -> None:
|
||||
cost = calculate_cost("claude-sonnet-4-6", tokens_input=0, tokens_output=_M)
|
||||
assert abs(cost - _SONNET_OUTPUT) < _TOL
|
||||
|
||||
def test_cache_read_only(self) -> None:
|
||||
cost = calculate_cost(
|
||||
"claude-sonnet-4-6",
|
||||
tokens_input=0,
|
||||
tokens_output=0,
|
||||
tokens_cache_read=_M,
|
||||
)
|
||||
assert abs(cost - _SONNET_CACHE_READ) < _TOL
|
||||
|
||||
def test_cache_write_only(self) -> None:
|
||||
cost = calculate_cost(
|
||||
"claude-sonnet-4-6",
|
||||
tokens_input=0,
|
||||
tokens_output=0,
|
||||
tokens_cache_write=_M,
|
||||
)
|
||||
assert abs(cost - _SONNET_CACHE_WRITE) < _TOL
|
||||
|
||||
def test_all_token_types(self) -> None:
|
||||
cost = calculate_cost(
|
||||
"claude-sonnet-4-6",
|
||||
tokens_input=_M,
|
||||
tokens_output=_M,
|
||||
tokens_cache_read=_M,
|
||||
tokens_cache_write=_M,
|
||||
)
|
||||
expected = (
|
||||
_SONNET_INPUT + _SONNET_OUTPUT + _SONNET_CACHE_READ + _SONNET_CACHE_WRITE
|
||||
)
|
||||
assert abs(cost - expected) < _TOL
|
||||
|
||||
def test_short_alias(self) -> None:
|
||||
"""Bare 'sonnet' alias resolves to the sonnet tier."""
|
||||
cost = calculate_cost("sonnet", tokens_input=_M, tokens_output=0)
|
||||
assert abs(cost - _SONNET_INPUT) < _TOL
|
||||
|
||||
def test_35_variant(self) -> None:
|
||||
"""claude-3-5-sonnet resolves to sonnet tier."""
|
||||
cost = calculate_cost(
|
||||
"claude-3-5-sonnet-20241022", tokens_input=_M, tokens_output=0
|
||||
)
|
||||
assert abs(cost - _SONNET_INPUT) < _TOL
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Haiku tier
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestHaikuTier:
|
||||
"""claude-haiku family pricing."""
|
||||
|
||||
def test_input_only(self) -> None:
|
||||
cost = calculate_cost("claude-haiku-4-5", tokens_input=_M, tokens_output=0)
|
||||
assert abs(cost - _HAIKU_INPUT) < _TOL
|
||||
|
||||
def test_output_only(self) -> None:
|
||||
cost = calculate_cost("claude-haiku-4-5", tokens_input=0, tokens_output=_M)
|
||||
assert abs(cost - _HAIKU_OUTPUT) < _TOL
|
||||
|
||||
def test_cache_read_only(self) -> None:
|
||||
cost = calculate_cost(
|
||||
"claude-haiku-4-5",
|
||||
tokens_input=0,
|
||||
tokens_output=0,
|
||||
tokens_cache_read=_M,
|
||||
)
|
||||
assert abs(cost - _HAIKU_CACHE_READ) < _TOL
|
||||
|
||||
def test_cache_write_only(self) -> None:
|
||||
cost = calculate_cost(
|
||||
"claude-haiku-4-5",
|
||||
tokens_input=0,
|
||||
tokens_output=0,
|
||||
tokens_cache_write=_M,
|
||||
)
|
||||
assert abs(cost - _HAIKU_CACHE_WRITE) < _TOL
|
||||
|
||||
def test_all_token_types(self) -> None:
|
||||
cost = calculate_cost(
|
||||
"claude-haiku-4-5",
|
||||
tokens_input=_M,
|
||||
tokens_output=_M,
|
||||
tokens_cache_read=_M,
|
||||
tokens_cache_write=_M,
|
||||
)
|
||||
expected = _HAIKU_INPUT + _HAIKU_OUTPUT + _HAIKU_CACHE_READ + _HAIKU_CACHE_WRITE
|
||||
assert abs(cost - expected) < _TOL
|
||||
|
||||
def test_short_alias(self) -> None:
|
||||
"""Bare 'haiku' alias resolves to the haiku tier."""
|
||||
cost = calculate_cost("haiku", tokens_input=_M, tokens_output=0)
|
||||
assert abs(cost - _HAIKU_INPUT) < _TOL
|
||||
|
||||
def test_haiku3_variant(self) -> None:
|
||||
"""claude-haiku-3 has lower pricing than haiku-3-5."""
|
||||
cost = calculate_cost("claude-haiku-3", tokens_input=_M, tokens_output=0)
|
||||
assert abs(cost - _HAIKU3_INPUT) < _TOL
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unknown / edge cases — must return 0.0 without raising
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestUnknownModels:
|
||||
def test_unknown_model_name_returns_zero(self) -> None:
|
||||
cost = calculate_cost("gpt-4o", tokens_input=_M, tokens_output=_M)
|
||||
assert cost == 0.0
|
||||
|
||||
def test_empty_string_returns_zero(self) -> None:
|
||||
cost = calculate_cost("", tokens_input=_M, tokens_output=_M)
|
||||
assert cost == 0.0
|
||||
|
||||
def test_gibberish_returns_zero(self) -> None:
|
||||
cost = calculate_cost(
|
||||
"totally-unknown-model-xyz", tokens_input=100, tokens_output=100
|
||||
)
|
||||
assert cost == 0.0
|
||||
|
||||
def test_zero_tokens_with_unknown_model_returns_zero(self) -> None:
|
||||
cost = calculate_cost("unknown", tokens_input=0, tokens_output=0)
|
||||
assert cost == 0.0
|
||||
|
||||
def test_does_not_raise_on_unknown_model(self) -> None:
|
||||
"""Must not raise regardless of token counts."""
|
||||
try:
|
||||
calculate_cost(
|
||||
"not-a-claude-model",
|
||||
tokens_input=999_999,
|
||||
tokens_output=999_999,
|
||||
)
|
||||
except Exception as exc:
|
||||
pytest.fail(f"calculate_cost raised unexpectedly: {exc}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Substring match correctness
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Named constants for the comparison floor/ceiling used in these tests.
|
||||
_ZERO_COST = 0.0
|
||||
_SONNET_CHEAPER_THAN_OPUS = True # structural assertion in the test below
|
||||
|
||||
|
||||
class TestSubstringMatchPriority:
|
||||
def test_claude_sonnet_4_resolves_non_zero(self) -> None:
|
||||
"""'claude-sonnet-4-6' must find a match (non-zero cost)."""
|
||||
cost = calculate_cost("claude-sonnet-4-6", tokens_input=_M, tokens_output=0)
|
||||
assert cost > _ZERO_COST
|
||||
|
||||
def test_haiku3_cheaper_than_haiku4(self) -> None:
|
||||
"""claude-haiku-3 is cheaper than claude-haiku-4 — longest-match wins."""
|
||||
haiku3_cost = calculate_cost("claude-haiku-3", tokens_input=_M, tokens_output=0)
|
||||
haiku4_cost = calculate_cost("claude-haiku-4", tokens_input=_M, tokens_output=0)
|
||||
# haiku-3 ($0.25/1M) < haiku-4 ($1.00/1M)
|
||||
assert haiku3_cost < haiku4_cost
|
||||
|
||||
def test_non_claude_model_returns_zero(self) -> None:
|
||||
"""A random non-Claude model must not match any Claude pricing entry."""
|
||||
non_opus_cost = calculate_cost("llama-3-70b", tokens_input=_M, tokens_output=0)
|
||||
assert non_opus_cost == _ZERO_COST
|
||||
|
||||
def test_opus_model_non_zero(self) -> None:
|
||||
"""Claude opus model resolves to non-zero cost."""
|
||||
opus_cost = calculate_cost("claude-opus-4", tokens_input=_M, tokens_output=0)
|
||||
assert opus_cost > _ZERO_COST
|
||||
|
||||
def test_zero_tokens_returns_zero_for_known_model(self) -> None:
|
||||
"""Known model with 0 tokens has 0 cost."""
|
||||
cost = calculate_cost("claude-opus-4", tokens_input=0, tokens_output=0)
|
||||
assert cost == _ZERO_COST
|
||||
|
||||
def test_case_insensitive_matching(self) -> None:
|
||||
"""Model name matching is case-insensitive."""
|
||||
lower_cost = calculate_cost(
|
||||
"claude-sonnet-4-6", tokens_input=1000, tokens_output=1000
|
||||
)
|
||||
upper_cost = calculate_cost(
|
||||
"CLAUDE-SONNET-4-6", tokens_input=1000, tokens_output=1000
|
||||
)
|
||||
assert lower_cost == upper_cost
|
||||
assert lower_cost > _ZERO_COST
|
||||
@@ -0,0 +1,543 @@
|
||||
"""
|
||||
Unit tests for orchestrator write-hooks:
|
||||
_finalize_spawn_session — closes the agent_spawn_sessions DB row on stop
|
||||
_sweep_token_snapshots — polls active agents and upserts token snapshots
|
||||
|
||||
These tests mock the httpx transport and the SQLAlchemy session factory so no
|
||||
real network or database is required.
|
||||
|
||||
Coverage:
|
||||
1. _finalize_spawn_session success — SDK returns token data → DB update
|
||||
carries those exact values to calculate_cost and the UPDATE statement.
|
||||
2. _finalize_spawn_session HTTP error — SDK unreachable → DB update proceeds
|
||||
with all-zero token counts (finalization must not raise).
|
||||
3. _sweep_token_snapshots active agent — non-zero tokens → snapshot row
|
||||
inserted and session row updated.
|
||||
4. _sweep_token_snapshots per-agent HTTP error — ConnectError on one agent
|
||||
is caught; the sweep continues and the next agent is still processed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import httpx
|
||||
from roboco.models.runtime import (
|
||||
AgentInstance,
|
||||
OrchestratorAgentConfig,
|
||||
OrchestratorAgentState,
|
||||
)
|
||||
from roboco.runtime.orchestrator import AgentOrchestrator
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module-level constants (ruff PLR2004: no magic values in comparisons)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_AGENT_ID = "be-dev-1"
|
||||
_AGENT_ID_2 = "be-dev-2"
|
||||
|
||||
# Token counts used in success-path assertions
|
||||
_TI = 111 # tokens_input
|
||||
_TO = 222 # tokens_output
|
||||
_TCR = 33 # tokens_cache_read
|
||||
_TCW = 44 # tokens_cache_write
|
||||
|
||||
# Token counts for the snapshot test
|
||||
_SNAP_TI = 50
|
||||
_SNAP_TO = 100
|
||||
_SNAP_TCR = 10
|
||||
_SNAP_TCW = 5
|
||||
|
||||
# Token counts for the loop-continues test (agent-2)
|
||||
_LOOP_TI = 25
|
||||
_LOOP_TO = 75
|
||||
|
||||
# Expected number of DB execute() calls for a normal finalize (SELECT + UPDATE)
|
||||
_FINALIZE_EXEC_CALLS = 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_orchestrator() -> AgentOrchestrator:
|
||||
"""Minimal AgentOrchestrator — no background tasks, no real DB."""
|
||||
return AgentOrchestrator(mcp_config_dir=Path("/tmp"), project_root=Path("/tmp"))
|
||||
|
||||
|
||||
def _make_instance(
|
||||
agent_id: str = _AGENT_ID,
|
||||
usage_session_id: UUID | None = None,
|
||||
) -> AgentInstance:
|
||||
"""Return an ACTIVE AgentInstance with a running container."""
|
||||
return AgentInstance(
|
||||
agent_id=agent_id,
|
||||
state=OrchestratorAgentState.ACTIVE,
|
||||
container_id="abc123def456",
|
||||
config=OrchestratorAgentConfig(
|
||||
agent_id=agent_id,
|
||||
blueprint_path=Path("/tmp/blueprint.md"),
|
||||
model="sonnet",
|
||||
),
|
||||
usage_session_id=usage_session_id,
|
||||
)
|
||||
|
||||
|
||||
def _mock_response(
|
||||
status: int = 200,
|
||||
json_data: dict[str, Any] | None = None,
|
||||
) -> MagicMock:
|
||||
"""Build a mock httpx.Response."""
|
||||
resp = MagicMock(spec=httpx.Response)
|
||||
resp.status_code = status
|
||||
resp.json = MagicMock(return_value=json_data or {})
|
||||
return resp
|
||||
|
||||
|
||||
def _make_db_factory(
|
||||
session_row: Any = None,
|
||||
add_list: list[Any] | None = None,
|
||||
execute_list: list[Any] | None = None,
|
||||
) -> Any:
|
||||
"""Return a callable that acts like get_session_factory().
|
||||
|
||||
The returned callable, when called with no arguments, returns an async
|
||||
context manager yielding a mock AsyncSession whose execute() returns a
|
||||
result whose scalar_one_or_none() returns *session_row*.
|
||||
"""
|
||||
|
||||
@asynccontextmanager
|
||||
async def _db_context() -> Any:
|
||||
db = MagicMock()
|
||||
|
||||
result = MagicMock()
|
||||
result.scalar_one_or_none = MagicMock(return_value=session_row)
|
||||
|
||||
async def _exec(stmt: Any) -> MagicMock:
|
||||
if execute_list is not None:
|
||||
execute_list.append(stmt)
|
||||
return result
|
||||
|
||||
db.execute = AsyncMock(side_effect=_exec)
|
||||
db.commit = AsyncMock()
|
||||
|
||||
def _add(obj: Any) -> None:
|
||||
if add_list is not None:
|
||||
add_list.append(obj)
|
||||
|
||||
db.add = _add if add_list is not None else MagicMock()
|
||||
yield db
|
||||
|
||||
return _db_context
|
||||
|
||||
|
||||
class _FakeHTTPClient:
|
||||
"""Drop-in replacement for ``httpx.AsyncClient`` in tests.
|
||||
|
||||
Accepts a *handler* callable ``(url: str) -> httpx.Response | raises``
|
||||
that is invoked by ``get()``. Supports the ``async with`` protocol.
|
||||
"""
|
||||
|
||||
def __init__(self, handler: Any, **_: Any) -> None:
|
||||
self._handler = handler
|
||||
|
||||
async def __aenter__(self) -> _FakeHTTPClient:
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_: Any) -> None:
|
||||
pass
|
||||
|
||||
async def get(self, url: str, **_: Any) -> Any:
|
||||
return self._handler(url)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _finalize_spawn_session — success path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_finalize_spawn_session_success_calls_calculate_cost() -> None:
|
||||
"""Token values returned by the SDK /usage/status are passed to calculate_cost.
|
||||
|
||||
This verifies the full data-flow: SDK response → token vars → cost calc.
|
||||
"""
|
||||
orch = _make_orchestrator()
|
||||
session_uuid = uuid4()
|
||||
orch._instances[_AGENT_ID] = _make_instance(usage_session_id=session_uuid)
|
||||
|
||||
token_data = {
|
||||
"tokens_input": _TI,
|
||||
"tokens_output": _TO,
|
||||
"tokens_cache_read": _TCR,
|
||||
"tokens_cache_write": _TCW,
|
||||
}
|
||||
|
||||
def _handler(_url: str) -> Any:
|
||||
return _mock_response(200, token_data)
|
||||
|
||||
session_row = MagicMock()
|
||||
session_row.id = session_uuid
|
||||
db_factory = _make_db_factory(session_row=session_row)
|
||||
|
||||
def _client_cls(**_kw: Any) -> _FakeHTTPClient:
|
||||
return _FakeHTTPClient(_handler)
|
||||
|
||||
with (
|
||||
patch("roboco.runtime.orchestrator.httpx.AsyncClient", _client_cls),
|
||||
patch("roboco.db.base.get_session_factory", return_value=db_factory),
|
||||
patch("roboco.billing.pricing.calculate_cost", return_value=0.001) as mock_cost,
|
||||
):
|
||||
await orch._finalize_spawn_session(_AGENT_ID, exit_reason="stopped")
|
||||
|
||||
mock_cost.assert_called_once_with(
|
||||
model="sonnet",
|
||||
tokens_input=_TI,
|
||||
tokens_output=_TO,
|
||||
tokens_cache_read=_TCR,
|
||||
tokens_cache_write=_TCW,
|
||||
)
|
||||
|
||||
|
||||
async def test_finalize_spawn_session_success_executes_select_and_update() -> None:
|
||||
"""When a session row exists the function calls execute() twice: SELECT + UPDATE."""
|
||||
orch = _make_orchestrator()
|
||||
session_uuid = uuid4()
|
||||
orch._instances[_AGENT_ID] = _make_instance(usage_session_id=session_uuid)
|
||||
|
||||
def _handler(_url: str) -> Any:
|
||||
return _mock_response(
|
||||
200,
|
||||
{
|
||||
"tokens_input": 10,
|
||||
"tokens_output": 20,
|
||||
"tokens_cache_read": 0,
|
||||
"tokens_cache_write": 0,
|
||||
},
|
||||
)
|
||||
|
||||
session_row = MagicMock()
|
||||
session_row.id = session_uuid
|
||||
execute_calls: list[Any] = []
|
||||
db_factory = _make_db_factory(session_row=session_row, execute_list=execute_calls)
|
||||
|
||||
def _client_cls(**_kw: Any) -> _FakeHTTPClient:
|
||||
return _FakeHTTPClient(_handler)
|
||||
|
||||
with (
|
||||
patch("roboco.runtime.orchestrator.httpx.AsyncClient", _client_cls),
|
||||
patch("roboco.db.base.get_session_factory", return_value=db_factory),
|
||||
patch("roboco.billing.pricing.calculate_cost", return_value=0.0),
|
||||
):
|
||||
await orch._finalize_spawn_session(_AGENT_ID, exit_reason="completed")
|
||||
|
||||
# SELECT (find the row) + UPDATE (write the values) = 2 execute() calls
|
||||
assert len(execute_calls) == _FINALIZE_EXEC_CALLS
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _finalize_spawn_session — HTTP-error path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_finalize_spawn_session_http_error_uses_zero_tokens() -> None:
|
||||
"""When the SDK endpoint is unreachable, finalization uses zero tokens.
|
||||
|
||||
The function must not raise; cost must be calculated with all-zero counts.
|
||||
"""
|
||||
orch = _make_orchestrator()
|
||||
session_uuid = uuid4()
|
||||
orch._instances[_AGENT_ID] = _make_instance(usage_session_id=session_uuid)
|
||||
|
||||
def _boom(_url: str) -> Any:
|
||||
raise httpx.ConnectError("container not reachable")
|
||||
|
||||
session_row = MagicMock()
|
||||
session_row.id = session_uuid
|
||||
db_factory = _make_db_factory(session_row=session_row)
|
||||
|
||||
def _client_cls(**_kw: Any) -> _FakeHTTPClient:
|
||||
return _FakeHTTPClient(_boom)
|
||||
|
||||
with (
|
||||
patch("roboco.runtime.orchestrator.httpx.AsyncClient", _client_cls),
|
||||
patch("roboco.db.base.get_session_factory", return_value=db_factory),
|
||||
patch("roboco.billing.pricing.calculate_cost", return_value=0.0) as mock_cost,
|
||||
):
|
||||
# Must not raise even though the SDK is unreachable
|
||||
await orch._finalize_spawn_session(_AGENT_ID, exit_reason="stopped")
|
||||
|
||||
mock_cost.assert_called_once_with(
|
||||
model="sonnet",
|
||||
tokens_input=0,
|
||||
tokens_output=0,
|
||||
tokens_cache_read=0,
|
||||
tokens_cache_write=0,
|
||||
)
|
||||
|
||||
|
||||
async def test_finalize_spawn_session_non_200_uses_zero_tokens() -> None:
|
||||
"""A non-200 SDK response results in zero-token finalization, no exception."""
|
||||
orch = _make_orchestrator()
|
||||
session_uuid = uuid4()
|
||||
orch._instances[_AGENT_ID] = _make_instance(usage_session_id=session_uuid)
|
||||
|
||||
def _handler(_url: str) -> Any:
|
||||
return _mock_response(503)
|
||||
|
||||
session_row = MagicMock()
|
||||
session_row.id = session_uuid
|
||||
db_factory = _make_db_factory(session_row=session_row)
|
||||
|
||||
def _client_cls(**_kw: Any) -> _FakeHTTPClient:
|
||||
return _FakeHTTPClient(_handler)
|
||||
|
||||
with (
|
||||
patch("roboco.runtime.orchestrator.httpx.AsyncClient", _client_cls),
|
||||
patch("roboco.db.base.get_session_factory", return_value=db_factory),
|
||||
patch("roboco.billing.pricing.calculate_cost", return_value=0.0) as mock_cost,
|
||||
):
|
||||
await orch._finalize_spawn_session(_AGENT_ID, exit_reason="stopped")
|
||||
|
||||
mock_cost.assert_called_once_with(
|
||||
model="sonnet",
|
||||
tokens_input=0,
|
||||
tokens_output=0,
|
||||
tokens_cache_read=0,
|
||||
tokens_cache_write=0,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _sweep_token_snapshots — active agent
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_sweep_token_snapshots_inserts_snapshot_for_active_agent() -> None:
|
||||
"""An active agent with non-zero tokens gets a snapshot row added to the DB."""
|
||||
orch = _make_orchestrator()
|
||||
instance = _make_instance(_AGENT_ID)
|
||||
instance.state = OrchestratorAgentState.ACTIVE
|
||||
orch._instances[_AGENT_ID] = instance
|
||||
|
||||
token_data = {
|
||||
"tokens_input": _SNAP_TI,
|
||||
"tokens_output": _SNAP_TO,
|
||||
"tokens_cache_read": _SNAP_TCR,
|
||||
"tokens_cache_write": _SNAP_TCW,
|
||||
}
|
||||
|
||||
def _handler(_url: str) -> Any:
|
||||
return _mock_response(200, token_data)
|
||||
|
||||
session_row = MagicMock()
|
||||
session_row.id = uuid4()
|
||||
added: list[Any] = []
|
||||
db_factory = _make_db_factory(session_row=session_row, add_list=added)
|
||||
|
||||
def _client_cls(**_kw: Any) -> _FakeHTTPClient:
|
||||
return _FakeHTTPClient(_handler)
|
||||
|
||||
with (
|
||||
patch("roboco.runtime.orchestrator.httpx.AsyncClient", _client_cls),
|
||||
patch("roboco.db.base.get_session_factory", return_value=db_factory),
|
||||
):
|
||||
await orch._sweep_token_snapshots()
|
||||
|
||||
# Exactly one snapshot row must have been passed to db.add()
|
||||
assert len(added) == 1
|
||||
snap = added[0]
|
||||
assert snap.tokens_input == _SNAP_TI
|
||||
assert snap.tokens_output == _SNAP_TO
|
||||
assert snap.tokens_cache_read == _SNAP_TCR
|
||||
assert snap.tokens_cache_write == _SNAP_TCW
|
||||
|
||||
|
||||
async def test_sweep_token_snapshots_skips_zero_token_agents() -> None:
|
||||
"""An agent whose SDK reports all-zero tokens is skipped (no DB writes)."""
|
||||
orch = _make_orchestrator()
|
||||
instance = _make_instance(_AGENT_ID)
|
||||
instance.state = OrchestratorAgentState.ACTIVE
|
||||
orch._instances[_AGENT_ID] = instance
|
||||
|
||||
def _handler(_url: str) -> Any:
|
||||
return _mock_response(
|
||||
200,
|
||||
{
|
||||
"tokens_input": 0,
|
||||
"tokens_output": 0,
|
||||
"tokens_cache_read": 0,
|
||||
"tokens_cache_write": 0,
|
||||
},
|
||||
)
|
||||
|
||||
added: list[Any] = []
|
||||
db_factory = _make_db_factory(add_list=added)
|
||||
|
||||
def _client_cls(**_kw: Any) -> _FakeHTTPClient:
|
||||
return _FakeHTTPClient(_handler)
|
||||
|
||||
with (
|
||||
patch("roboco.runtime.orchestrator.httpx.AsyncClient", _client_cls),
|
||||
patch("roboco.db.base.get_session_factory", return_value=db_factory),
|
||||
):
|
||||
await orch._sweep_token_snapshots()
|
||||
|
||||
assert added == []
|
||||
|
||||
|
||||
async def test_sweep_token_snapshots_per_agent_error_does_not_abort_loop() -> None:
|
||||
"""A ConnectError for one agent is caught; the next agent is still processed."""
|
||||
orch = _make_orchestrator()
|
||||
|
||||
# Agent 1: HTTP error
|
||||
inst1 = _make_instance(_AGENT_ID)
|
||||
inst1.state = OrchestratorAgentState.ACTIVE
|
||||
orch._instances[_AGENT_ID] = inst1
|
||||
|
||||
# Agent 2: success with non-zero tokens
|
||||
inst2 = _make_instance(_AGENT_ID_2)
|
||||
inst2.state = OrchestratorAgentState.ACTIVE
|
||||
orch._instances[_AGENT_ID_2] = inst2
|
||||
|
||||
def _handler(url: str) -> Any:
|
||||
if _AGENT_ID in url and _AGENT_ID_2 not in url:
|
||||
raise httpx.ConnectError("agent-1 unreachable")
|
||||
return _mock_response(
|
||||
200,
|
||||
{
|
||||
"tokens_input": _LOOP_TI,
|
||||
"tokens_output": _LOOP_TO,
|
||||
"tokens_cache_read": 0,
|
||||
"tokens_cache_write": 0,
|
||||
},
|
||||
)
|
||||
|
||||
session_row = MagicMock()
|
||||
session_row.id = uuid4()
|
||||
added: list[Any] = []
|
||||
db_factory = _make_db_factory(session_row=session_row, add_list=added)
|
||||
|
||||
def _client_cls(**_kw: Any) -> _FakeHTTPClient:
|
||||
return _FakeHTTPClient(_handler)
|
||||
|
||||
with (
|
||||
patch("roboco.runtime.orchestrator.httpx.AsyncClient", _client_cls),
|
||||
patch("roboco.db.base.get_session_factory", return_value=db_factory),
|
||||
):
|
||||
await orch._sweep_token_snapshots()
|
||||
|
||||
# Only agent-2's snapshot should be present; agent-1's error was caught.
|
||||
assert len(added) == 1
|
||||
assert added[0].tokens_input == _LOOP_TI
|
||||
assert added[0].tokens_output == _LOOP_TO
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _sweep_daily_rollup — inserts new row when none exists
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Token counts for the rollup test
|
||||
_ROLLUP_TI = 200
|
||||
_ROLLUP_TO = 300
|
||||
_ROLLUP_TCR = 20
|
||||
_ROLLUP_TCW = 10
|
||||
|
||||
|
||||
async def test_sweep_daily_rollup_inserts_new_row() -> None:
|
||||
"""When no existing DailyUsageRollupTable row exists, db.add() is called
|
||||
with the correct aggregated token values."""
|
||||
orch = _make_orchestrator()
|
||||
|
||||
# Build a fake aggregate result row
|
||||
agg_row = MagicMock()
|
||||
agg_row.date = "2026-06-10"
|
||||
agg_row.agent_slug = _AGENT_ID
|
||||
agg_row.team = "backend"
|
||||
agg_row.model = "sonnet"
|
||||
agg_row.tokens_input = _ROLLUP_TI
|
||||
agg_row.tokens_output = _ROLLUP_TO
|
||||
agg_row.tokens_cache_read = _ROLLUP_TCR
|
||||
agg_row.tokens_cache_write = _ROLLUP_TCW
|
||||
agg_row.total_cost_usd = 0.0
|
||||
agg_row.session_count = 1
|
||||
|
||||
added: list[Any] = []
|
||||
call_count = 0
|
||||
|
||||
@asynccontextmanager
|
||||
async def _db_context() -> Any:
|
||||
nonlocal call_count
|
||||
|
||||
db = MagicMock()
|
||||
db.commit = AsyncMock()
|
||||
|
||||
def _add(obj: Any) -> None:
|
||||
added.append(obj)
|
||||
|
||||
db.add = _add
|
||||
|
||||
async def _exec(_stmt: Any) -> MagicMock:
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
result = MagicMock()
|
||||
if call_count == 1:
|
||||
# First call: aggregate SELECT — return one agg_row via fetchall()
|
||||
result.fetchall = MagicMock(return_value=[agg_row])
|
||||
result.scalar_one_or_none = MagicMock(return_value=None)
|
||||
else:
|
||||
# Second call: lookup SELECT for existing row — return None
|
||||
result.fetchall = MagicMock(return_value=[])
|
||||
result.scalar_one_or_none = MagicMock(return_value=None)
|
||||
return result
|
||||
|
||||
db.execute = AsyncMock(side_effect=_exec)
|
||||
yield db
|
||||
|
||||
with patch("roboco.db.base.get_session_factory", return_value=_db_context):
|
||||
await orch._sweep_daily_rollup()
|
||||
|
||||
# Exactly one new DailyUsageRollupTable row must have been added
|
||||
assert len(added) == 1
|
||||
row = added[0]
|
||||
assert row.tokens_input == _ROLLUP_TI
|
||||
assert row.tokens_output == _ROLLUP_TO
|
||||
assert row.tokens_cache_read == _ROLLUP_TCR
|
||||
assert row.tokens_cache_write == _ROLLUP_TCW
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# stop_agent — _finalize_spawn_session is awaited before acquiring the lock
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_stop_agent_finalizes_before_lock() -> None:
|
||||
"""stop_agent awaits _finalize_spawn_session when the instance has a
|
||||
running container_id (the finalization must happen before the lock)."""
|
||||
orch = _make_orchestrator()
|
||||
instance = _make_instance(_AGENT_ID)
|
||||
instance.container_id = "abc123def456" # non-None → finalize must be called
|
||||
orch._instances[_AGENT_ID] = instance
|
||||
|
||||
finalized: list[str] = []
|
||||
|
||||
async def _fake_finalize(agent_id: str, exit_reason: str = "stopped") -> None: # noqa: ARG001
|
||||
finalized.append(agent_id)
|
||||
|
||||
# Stub out the Docker subprocess so stop_agent doesn't actually run Docker
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.wait = AsyncMock()
|
||||
|
||||
with (
|
||||
patch.object(orch, "_finalize_spawn_session", side_effect=_fake_finalize),
|
||||
patch("asyncio.create_subprocess_exec", AsyncMock(return_value=mock_proc)),
|
||||
patch.object(orch, "_remove_container", AsyncMock()),
|
||||
):
|
||||
await orch.stop_agent(_AGENT_ID, graceful=True)
|
||||
|
||||
# _finalize_spawn_session must have been called exactly once with our agent id
|
||||
assert finalized == [_AGENT_ID]
|
||||
@@ -0,0 +1,761 @@
|
||||
"""
|
||||
Unit tests for roboco.services.usage — UsageService analytics methods.
|
||||
|
||||
These tests mock the SQLAlchemy AsyncSession.execute() boundary and
|
||||
verify the arithmetic / logic of each analytics method:
|
||||
|
||||
- get_summary: trend_pct edge cases (prev=0, curr=0, both=0, prev>0)
|
||||
- get_by_agent/team/model: pct_of_total sums to 100%
|
||||
- get_projection: projected_monthly = avg_daily * 30
|
||||
- get_cache_efficiency: cache_hit_rate and cost_saved arithmetic
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from roboco.services.usage import UsageService
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Named constants (ruff PLR2004: magic values in comparisons must be named).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Tolerance for floating-point arithmetic comparisons.
|
||||
_TOL = 0.001
|
||||
# Tolerance for percentage-sum assertions (rounding in pct_of_total).
|
||||
_PCT_TOL = 0.1
|
||||
|
||||
# token count helpers
|
||||
_ZERO = 0
|
||||
_M = 1_000_000
|
||||
|
||||
# Expected values for projection tests
|
||||
_COST_7D = 70.0
|
||||
_EXPECTED_AVG_DAILY = 10.0 # 70 / 7
|
||||
_EXPECTED_MONTHLY = 300.0 # 10 * 30
|
||||
_DAYS_BASIS = 7
|
||||
|
||||
# Expected values for cache efficiency tests
|
||||
_CACHE_READ_TOKENS = 400
|
||||
_INPUT_TOKENS = 600
|
||||
_EXPECTED_HIT_RATE = 0.4 # 400 / (600 + 400)
|
||||
_FULL_INPUT_PRICE = 3.00 # sonnet baseline USD/1M
|
||||
_CACHE_READ_PRICE = 0.30
|
||||
_EXPECTED_COST_SAVED = _FULL_INPUT_PRICE - _CACHE_READ_PRICE # = 2.70 per 1M
|
||||
|
||||
# Expected trend_pct values
|
||||
_TREND_NONE = 0.0
|
||||
_TREND_NEW = 100.0 # curr > 0, prev == 0
|
||||
_TREND_DOUBLED = 200.0 # curr / prev = 3.0x → +200 %
|
||||
_TREND_HALVED = -50.0 # curr / prev = 0.5x → -50 %
|
||||
|
||||
# Expected total_tokens when cache tokens are included
|
||||
_TOTAL_WITH_CACHE = 300 # 100+100+50+50
|
||||
|
||||
# pct_of_total checks
|
||||
_FULL_PCT = 100.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_row(**kwargs: object) -> MagicMock:
|
||||
"""Return a MagicMock that mimics a SQLAlchemy Row with named attributes."""
|
||||
row = MagicMock()
|
||||
for k, v in kwargs.items():
|
||||
setattr(row, k, v)
|
||||
return row
|
||||
|
||||
|
||||
def _result_one(row: MagicMock) -> MagicMock:
|
||||
"""Return a mock execute() result whose .one() returns `row`."""
|
||||
result = MagicMock()
|
||||
result.one = MagicMock(return_value=row)
|
||||
return result
|
||||
|
||||
|
||||
def _result_fetchall(rows: list[MagicMock]) -> MagicMock:
|
||||
"""Return a mock execute() result whose .fetchall() returns `rows`."""
|
||||
result = MagicMock()
|
||||
result.fetchall = MagicMock(return_value=rows)
|
||||
return result
|
||||
|
||||
|
||||
def _service_with_execute(*return_values: object) -> UsageService:
|
||||
"""Build a UsageService whose session.execute() returns the provided
|
||||
values in sequence (one per call)."""
|
||||
session = MagicMock()
|
||||
session.execute = AsyncMock(side_effect=list(return_values))
|
||||
return UsageService(session)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_summary — trend_pct arithmetic
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetSummaryTrendPct:
|
||||
@pytest.mark.asyncio
|
||||
async def test_both_zero_returns_zero_trend(self) -> None:
|
||||
"""When current and previous totals are both 0, trend_pct must be 0.0."""
|
||||
current_row = _make_row(
|
||||
tokens_input=_ZERO,
|
||||
tokens_output=_ZERO,
|
||||
tokens_cache_read=_ZERO,
|
||||
tokens_cache_write=_ZERO,
|
||||
total_cost_usd=0.0,
|
||||
)
|
||||
prev_row = _make_row(total=_ZERO)
|
||||
svc = _service_with_execute(_result_one(current_row), _result_one(prev_row))
|
||||
result = await svc.get_summary("24h")
|
||||
assert result["trend_pct"] == _TREND_NONE
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prev_zero_curr_positive_returns_100(self) -> None:
|
||||
"""When prev period is 0 but current is positive, trend_pct = 100.0."""
|
||||
current_row = _make_row(
|
||||
tokens_input=500,
|
||||
tokens_output=500,
|
||||
tokens_cache_read=_ZERO,
|
||||
tokens_cache_write=_ZERO,
|
||||
total_cost_usd=0.01,
|
||||
)
|
||||
prev_row = _make_row(total=_ZERO)
|
||||
svc = _service_with_execute(_result_one(current_row), _result_one(prev_row))
|
||||
result = await svc.get_summary("24h")
|
||||
assert result["trend_pct"] == _TREND_NEW
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_positive_trend_calculation(self) -> None:
|
||||
"""trend_pct = (current - previous) / previous * 100 when prev > 0.
|
||||
|
||||
current = 1500 input + 1500 output = 3000; prev = 1000
|
||||
→ (3000 - 1000) / 1000 * 100 = 200.0
|
||||
"""
|
||||
current_row = _make_row(
|
||||
tokens_input=1500,
|
||||
tokens_output=1500,
|
||||
tokens_cache_read=_ZERO,
|
||||
tokens_cache_write=_ZERO,
|
||||
total_cost_usd=0.1,
|
||||
)
|
||||
prev_row = _make_row(total=1000)
|
||||
svc = _service_with_execute(_result_one(current_row), _result_one(prev_row))
|
||||
result = await svc.get_summary("24h")
|
||||
assert abs(result["trend_pct"] - _TREND_DOUBLED) < _TOL
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_negative_trend_calculation(self) -> None:
|
||||
"""Negative trend when usage drops.
|
||||
|
||||
current = 250 + 250 = 500; prev = 1000
|
||||
→ (500 - 1000) / 1000 * 100 = -50.0
|
||||
"""
|
||||
current_row = _make_row(
|
||||
tokens_input=250,
|
||||
tokens_output=250,
|
||||
tokens_cache_read=_ZERO,
|
||||
tokens_cache_write=_ZERO,
|
||||
total_cost_usd=0.01,
|
||||
)
|
||||
prev_row = _make_row(total=1000)
|
||||
svc = _service_with_execute(_result_one(current_row), _result_one(prev_row))
|
||||
result = await svc.get_summary("24h")
|
||||
assert abs(result["trend_pct"] - _TREND_HALVED) < _TOL
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_tokens_included_in_total(self) -> None:
|
||||
"""total_tokens includes cache_read and cache_write tokens."""
|
||||
current_row = _make_row(
|
||||
tokens_input=100,
|
||||
tokens_output=100,
|
||||
tokens_cache_read=50,
|
||||
tokens_cache_write=50,
|
||||
total_cost_usd=0.005,
|
||||
)
|
||||
prev_row = _make_row(total=_ZERO)
|
||||
svc = _service_with_execute(_result_one(current_row), _result_one(prev_row))
|
||||
result = await svc.get_summary("24h")
|
||||
assert result["total_tokens"] == _TOTAL_WITH_CACHE
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_summary_contains_required_fields(self) -> None:
|
||||
"""Response dict must include all required summary fields."""
|
||||
current_row = _make_row(
|
||||
tokens_input=_ZERO,
|
||||
tokens_output=_ZERO,
|
||||
tokens_cache_read=_ZERO,
|
||||
tokens_cache_write=_ZERO,
|
||||
total_cost_usd=0.0,
|
||||
)
|
||||
prev_row = _make_row(total=_ZERO)
|
||||
svc = _service_with_execute(_result_one(current_row), _result_one(prev_row))
|
||||
result = await svc.get_summary("24h")
|
||||
for field in ("tokens_input", "tokens_output", "total_cost_usd", "trend_pct"):
|
||||
assert field in result, f"Missing field: {field}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_time_series — total_tokens includes all 4 token types (summary consistency)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Named constants for time-series tests
|
||||
_TS_INPUT = 100
|
||||
_TS_OUTPUT = 200
|
||||
_TS_CACHE_READ = 50
|
||||
_TS_CACHE_WRITE = 30
|
||||
# total = 100 + 200 + 50 + 30 = 380
|
||||
_TS_TOTAL_WITH_CACHE = 380
|
||||
# Without cache tokens (the old wrong formula): 100 + 200 = 300
|
||||
_TS_TOTAL_WITHOUT_CACHE = 300
|
||||
|
||||
|
||||
class TestGetTimeSeries:
|
||||
@pytest.mark.asyncio
|
||||
async def test_total_tokens_includes_cache_read_and_write(self) -> None:
|
||||
"""total_tokens in each time-series point must include cache tokens.
|
||||
|
||||
This is the time-series / summary consistency requirement: time-series
|
||||
total_tokens must sum to the same value as get_summary()'s total_tokens
|
||||
for the same period. The old implementation used ti + to_ (without
|
||||
cache), which violated this constraint whenever cache tokens were non-zero.
|
||||
"""
|
||||
|
||||
bucket_dt = datetime.datetime(2026, 6, 9, 12, 0, 0, tzinfo=datetime.UTC)
|
||||
row = _make_row(
|
||||
bucket=bucket_dt,
|
||||
tokens_input=_TS_INPUT,
|
||||
tokens_output=_TS_OUTPUT,
|
||||
tokens_cache_read=_TS_CACHE_READ,
|
||||
tokens_cache_write=_TS_CACHE_WRITE,
|
||||
cost_usd=0.01,
|
||||
)
|
||||
svc = _service_with_execute(_result_fetchall([row]))
|
||||
result = await svc.get_time_series("24h")
|
||||
assert len(result) == 1
|
||||
assert result[0]["total_tokens"] == _TS_TOTAL_WITH_CACHE
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_total_tokens_without_cache_still_correct(self) -> None:
|
||||
"""When cache tokens are zero, total_tokens == tokens_input + tokens_output."""
|
||||
|
||||
bucket_dt = datetime.datetime(2026, 6, 9, 12, 0, 0, tzinfo=datetime.UTC)
|
||||
row = _make_row(
|
||||
bucket=bucket_dt,
|
||||
tokens_input=_TS_INPUT,
|
||||
tokens_output=_TS_OUTPUT,
|
||||
tokens_cache_read=_ZERO,
|
||||
tokens_cache_write=_ZERO,
|
||||
cost_usd=0.01,
|
||||
)
|
||||
svc = _service_with_execute(_result_fetchall([row]))
|
||||
result = await svc.get_time_series("24h")
|
||||
assert result[0]["total_tokens"] == _TS_INPUT + _TS_OUTPUT
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_result_returns_empty_list(self) -> None:
|
||||
svc = _service_with_execute(_result_fetchall([]))
|
||||
result = await svc.get_time_series("24h")
|
||||
assert result == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_point_contains_required_fields(self) -> None:
|
||||
"""Each time-series point must have bucket, tokens_input, tokens_output,
|
||||
total_tokens, and cost_usd fields."""
|
||||
|
||||
bucket_dt = datetime.datetime(2026, 6, 9, 12, 0, 0, tzinfo=datetime.UTC)
|
||||
row = _make_row(
|
||||
bucket=bucket_dt,
|
||||
tokens_input=100,
|
||||
tokens_output=100,
|
||||
tokens_cache_read=_ZERO,
|
||||
tokens_cache_write=_ZERO,
|
||||
cost_usd=0.01,
|
||||
)
|
||||
svc = _service_with_execute(_result_fetchall([row]))
|
||||
result = await svc.get_time_series("24h")
|
||||
assert len(result) == 1
|
||||
point = result[0]
|
||||
for field in (
|
||||
"bucket",
|
||||
"tokens_input",
|
||||
"tokens_output",
|
||||
"total_tokens",
|
||||
"cost_usd",
|
||||
):
|
||||
assert field in point, f"Missing field: {field}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_by_agent — pct_of_total sums to 100%
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetByAgent:
|
||||
@pytest.mark.asyncio
|
||||
async def test_pct_of_total_sums_to_100(self) -> None:
|
||||
rows = [
|
||||
_make_row(
|
||||
agent_slug="be-dev-1",
|
||||
tokens_input=600,
|
||||
tokens_output=400,
|
||||
tokens_cache_read=0,
|
||||
tokens_cache_write=0,
|
||||
cost_usd=0.05,
|
||||
),
|
||||
_make_row(
|
||||
agent_slug="be-dev-2",
|
||||
tokens_input=300,
|
||||
tokens_output=200,
|
||||
tokens_cache_read=0,
|
||||
tokens_cache_write=0,
|
||||
cost_usd=0.02,
|
||||
),
|
||||
_make_row(
|
||||
agent_slug="be-qa",
|
||||
tokens_input=100,
|
||||
tokens_output=100,
|
||||
tokens_cache_read=0,
|
||||
tokens_cache_write=0,
|
||||
cost_usd=0.01,
|
||||
),
|
||||
]
|
||||
svc = _service_with_execute(_result_fetchall(rows))
|
||||
result = await svc.get_by_agent("24h")
|
||||
total_pct = sum(item["pct_of_total"] for item in result)
|
||||
assert abs(total_pct - _FULL_PCT) < _PCT_TOL
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_result_returns_empty_list(self) -> None:
|
||||
svc = _service_with_execute(_result_fetchall([]))
|
||||
result = await svc.get_by_agent("24h")
|
||||
assert result == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_single_agent_has_100_pct(self) -> None:
|
||||
rows = [
|
||||
_make_row(
|
||||
agent_slug="be-dev-1",
|
||||
tokens_input=1000,
|
||||
tokens_output=500,
|
||||
tokens_cache_read=0,
|
||||
tokens_cache_write=0,
|
||||
cost_usd=0.1,
|
||||
)
|
||||
]
|
||||
svc = _service_with_execute(_result_fetchall(rows))
|
||||
result = await svc.get_by_agent("24h")
|
||||
assert len(result) == 1
|
||||
assert result[_ZERO]["pct_of_total"] == _FULL_PCT
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_result_contains_agent_slug_field(self) -> None:
|
||||
rows = [
|
||||
_make_row(
|
||||
agent_slug="be-dev-1",
|
||||
tokens_input=100,
|
||||
tokens_output=100,
|
||||
tokens_cache_read=0,
|
||||
tokens_cache_write=0,
|
||||
cost_usd=0.01,
|
||||
)
|
||||
]
|
||||
svc = _service_with_execute(_result_fetchall(rows))
|
||||
result = await svc.get_by_agent()
|
||||
assert result[_ZERO]["agent_slug"] == "be-dev-1"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_tokens_included_in_total_tokens(self) -> None:
|
||||
"""total_tokens must include cache_read and cache_write.
|
||||
|
||||
Without the fix, total would be 500+300=800 (input+output only).
|
||||
With the fix, total = 500+300+100+100 = 1000.
|
||||
"""
|
||||
_cache_read = 100
|
||||
_cache_write = 100
|
||||
_expected_total = 500 + 300 + _cache_read + _cache_write # 1000
|
||||
rows = [
|
||||
_make_row(
|
||||
agent_slug="be-dev-1",
|
||||
tokens_input=500,
|
||||
tokens_output=300,
|
||||
tokens_cache_read=_cache_read,
|
||||
tokens_cache_write=_cache_write,
|
||||
cost_usd=0.05,
|
||||
)
|
||||
]
|
||||
svc = _service_with_execute(_result_fetchall(rows))
|
||||
result = await svc.get_by_agent("24h")
|
||||
assert result[_ZERO]["total_tokens"] == _expected_total
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pct_of_total_sums_to_100_with_cache_tokens(self) -> None:
|
||||
"""pct_of_total still sums to 100% when agents have cache tokens."""
|
||||
rows = [
|
||||
_make_row(
|
||||
agent_slug="be-dev-1",
|
||||
tokens_input=400,
|
||||
tokens_output=200,
|
||||
tokens_cache_read=150,
|
||||
tokens_cache_write=50,
|
||||
cost_usd=0.05,
|
||||
),
|
||||
_make_row(
|
||||
agent_slug="be-dev-2",
|
||||
tokens_input=200,
|
||||
tokens_output=100,
|
||||
tokens_cache_read=75,
|
||||
tokens_cache_write=25,
|
||||
cost_usd=0.02,
|
||||
),
|
||||
]
|
||||
svc = _service_with_execute(_result_fetchall(rows))
|
||||
result = await svc.get_by_agent("24h")
|
||||
total_pct = sum(item["pct_of_total"] for item in result)
|
||||
assert abs(total_pct - _FULL_PCT) < _PCT_TOL
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_by_team — pct_of_total sums to 100%
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetByTeam:
|
||||
@pytest.mark.asyncio
|
||||
async def test_pct_of_total_sums_to_100(self) -> None:
|
||||
rows = [
|
||||
_make_row(
|
||||
team="backend",
|
||||
tokens_input=700,
|
||||
tokens_output=300,
|
||||
tokens_cache_read=0,
|
||||
tokens_cache_write=0,
|
||||
cost_usd=0.05,
|
||||
),
|
||||
_make_row(
|
||||
team="frontend",
|
||||
tokens_input=200,
|
||||
tokens_output=200,
|
||||
tokens_cache_read=0,
|
||||
tokens_cache_write=0,
|
||||
cost_usd=0.02,
|
||||
),
|
||||
_make_row(
|
||||
team="uxui",
|
||||
tokens_input=100,
|
||||
tokens_output=100,
|
||||
tokens_cache_read=0,
|
||||
tokens_cache_write=0,
|
||||
cost_usd=0.01,
|
||||
),
|
||||
]
|
||||
svc = _service_with_execute(_result_fetchall(rows))
|
||||
result = await svc.get_by_team("24h")
|
||||
total_pct = sum(item["pct_of_total"] for item in result)
|
||||
assert abs(total_pct - _FULL_PCT) < _PCT_TOL
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_result_contains_team_field(self) -> None:
|
||||
rows = [
|
||||
_make_row(
|
||||
team="backend",
|
||||
tokens_input=100,
|
||||
tokens_output=100,
|
||||
tokens_cache_read=0,
|
||||
tokens_cache_write=0,
|
||||
cost_usd=0.01,
|
||||
)
|
||||
]
|
||||
svc = _service_with_execute(_result_fetchall(rows))
|
||||
result = await svc.get_by_team()
|
||||
assert result[_ZERO]["team"] == "backend"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_tokens_included_in_total_tokens(self) -> None:
|
||||
"""total_tokens must include cache_read and cache_write."""
|
||||
_cache_read = 200
|
||||
_cache_write = 100
|
||||
_expected_total = 700 + 300 + _cache_read + _cache_write # 1300
|
||||
rows = [
|
||||
_make_row(
|
||||
team="backend",
|
||||
tokens_input=700,
|
||||
tokens_output=300,
|
||||
tokens_cache_read=_cache_read,
|
||||
tokens_cache_write=_cache_write,
|
||||
cost_usd=0.05,
|
||||
)
|
||||
]
|
||||
svc = _service_with_execute(_result_fetchall(rows))
|
||||
result = await svc.get_by_team("24h")
|
||||
assert result[_ZERO]["total_tokens"] == _expected_total
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pct_of_total_sums_to_100_with_cache_tokens(self) -> None:
|
||||
"""pct_of_total still sums to 100% when teams have cache tokens."""
|
||||
rows = [
|
||||
_make_row(
|
||||
team="backend",
|
||||
tokens_input=600,
|
||||
tokens_output=200,
|
||||
tokens_cache_read=120,
|
||||
tokens_cache_write=80,
|
||||
cost_usd=0.05,
|
||||
),
|
||||
_make_row(
|
||||
team="frontend",
|
||||
tokens_input=300,
|
||||
tokens_output=100,
|
||||
tokens_cache_read=60,
|
||||
tokens_cache_write=40,
|
||||
cost_usd=0.02,
|
||||
),
|
||||
]
|
||||
svc = _service_with_execute(_result_fetchall(rows))
|
||||
result = await svc.get_by_team("24h")
|
||||
total_pct = sum(item["pct_of_total"] for item in result)
|
||||
assert abs(total_pct - _FULL_PCT) < _PCT_TOL
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_by_model — pct_of_total sums to 100%
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetByModel:
|
||||
@pytest.mark.asyncio
|
||||
async def test_pct_of_total_sums_to_100(self) -> None:
|
||||
rows = [
|
||||
_make_row(
|
||||
model="claude-sonnet-4-6",
|
||||
tokens_input=600,
|
||||
tokens_output=600,
|
||||
tokens_cache_read=0,
|
||||
tokens_cache_write=0,
|
||||
cost_usd=0.1,
|
||||
),
|
||||
_make_row(
|
||||
model="claude-haiku-4-5",
|
||||
tokens_input=300,
|
||||
tokens_output=300,
|
||||
tokens_cache_read=0,
|
||||
tokens_cache_write=0,
|
||||
cost_usd=0.02,
|
||||
),
|
||||
_make_row(
|
||||
model="claude-opus-4-5",
|
||||
tokens_input=100,
|
||||
tokens_output=100,
|
||||
tokens_cache_read=0,
|
||||
tokens_cache_write=0,
|
||||
cost_usd=0.04,
|
||||
),
|
||||
]
|
||||
svc = _service_with_execute(_result_fetchall(rows))
|
||||
result = await svc.get_by_model("24h")
|
||||
total_pct = sum(item["pct_of_total"] for item in result)
|
||||
assert abs(total_pct - _FULL_PCT) < _PCT_TOL
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_result_contains_model_field(self) -> None:
|
||||
rows = [
|
||||
_make_row(
|
||||
model="claude-sonnet-4-6",
|
||||
tokens_input=100,
|
||||
tokens_output=100,
|
||||
tokens_cache_read=0,
|
||||
tokens_cache_write=0,
|
||||
cost_usd=0.01,
|
||||
)
|
||||
]
|
||||
svc = _service_with_execute(_result_fetchall(rows))
|
||||
result = await svc.get_by_model()
|
||||
assert result[_ZERO]["model"] == "claude-sonnet-4-6"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_tokens_included_in_total_tokens(self) -> None:
|
||||
"""total_tokens must include cache_read and cache_write."""
|
||||
_cache_read = 300
|
||||
_cache_write = 100
|
||||
_expected_total = 600 + 600 + _cache_read + _cache_write # 1600
|
||||
rows = [
|
||||
_make_row(
|
||||
model="claude-sonnet-4-6",
|
||||
tokens_input=600,
|
||||
tokens_output=600,
|
||||
tokens_cache_read=_cache_read,
|
||||
tokens_cache_write=_cache_write,
|
||||
cost_usd=0.1,
|
||||
)
|
||||
]
|
||||
svc = _service_with_execute(_result_fetchall(rows))
|
||||
result = await svc.get_by_model("24h")
|
||||
assert result[_ZERO]["total_tokens"] == _expected_total
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pct_of_total_sums_to_100_with_cache_tokens(self) -> None:
|
||||
"""pct_of_total still sums to 100% when models have cache tokens."""
|
||||
rows = [
|
||||
_make_row(
|
||||
model="claude-sonnet-4-6",
|
||||
tokens_input=500,
|
||||
tokens_output=500,
|
||||
tokens_cache_read=200,
|
||||
tokens_cache_write=100,
|
||||
cost_usd=0.1,
|
||||
),
|
||||
_make_row(
|
||||
model="claude-haiku-4-5",
|
||||
tokens_input=250,
|
||||
tokens_output=250,
|
||||
tokens_cache_read=100,
|
||||
tokens_cache_write=50,
|
||||
cost_usd=0.02,
|
||||
),
|
||||
]
|
||||
svc = _service_with_execute(_result_fetchall(rows))
|
||||
result = await svc.get_by_model("24h")
|
||||
total_pct = sum(item["pct_of_total"] for item in result)
|
||||
assert abs(total_pct - _FULL_PCT) < _PCT_TOL
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_projection — formula: projected_monthly = (total_7d / 7) * 30
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetProjection:
|
||||
@pytest.mark.asyncio
|
||||
async def test_projection_formula_30_day_extrapolation(self) -> None:
|
||||
"""projected_monthly_cost_usd = (total_cost_7d / 7) * 30."""
|
||||
row = _make_row(total_cost_7d=_COST_7D, session_count=10)
|
||||
svc = _service_with_execute(_result_one(row))
|
||||
result = await svc.get_projection()
|
||||
assert abs(result["projected_monthly_cost_usd"] - _EXPECTED_MONTHLY) < _TOL
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_zero_cost_7d_gives_zero_projection(self) -> None:
|
||||
row = _make_row(total_cost_7d=0.0, session_count=_ZERO)
|
||||
svc = _service_with_execute(_result_one(row))
|
||||
result = await svc.get_projection()
|
||||
assert result["projected_monthly_cost_usd"] == 0.0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_avg_daily_cost_equals_total_over_7(self) -> None:
|
||||
"""avg_daily = total_7d / 7."""
|
||||
row = _make_row(total_cost_7d=21.0, session_count=5)
|
||||
svc = _service_with_execute(_result_one(row))
|
||||
result = await svc.get_projection()
|
||||
# 21 / 7 = 3.0 avg daily cost
|
||||
_avg_daily_21 = 3.0
|
||||
assert abs(result["avg_daily_cost_usd"] - _avg_daily_21) < _TOL
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_projection_contains_required_fields(self) -> None:
|
||||
row = _make_row(total_cost_7d=7.0, session_count=3)
|
||||
svc = _service_with_execute(_result_one(row))
|
||||
result = await svc.get_projection()
|
||||
for field in (
|
||||
"total_cost_7d",
|
||||
"avg_daily_cost_usd",
|
||||
"projected_monthly_cost_usd",
|
||||
"basis_days",
|
||||
):
|
||||
assert field in result, f"Missing field: {field}"
|
||||
assert result["basis_days"] == _DAYS_BASIS
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_cache_efficiency — hit rate and cost_saved arithmetic
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetCacheEfficiency:
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_hit_rate_formula(self) -> None:
|
||||
"""cache_hit_rate = cache_read / (input + cache_read).
|
||||
|
||||
400 cache reads out of 400+600 total = 0.4
|
||||
"""
|
||||
row = _make_row(
|
||||
tokens_input=_INPUT_TOKENS,
|
||||
tokens_output=_ZERO,
|
||||
tokens_cache_read=_CACHE_READ_TOKENS,
|
||||
tokens_cache_write=_ZERO,
|
||||
)
|
||||
svc = _service_with_execute(_result_one(row))
|
||||
result = await svc.get_cache_efficiency("24h")
|
||||
assert abs(result["cache_hit_rate"] - _EXPECTED_HIT_RATE) < _TOL
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_zero_input_tokens_gives_zero_hit_rate(self) -> None:
|
||||
"""When no input or cache_read tokens, hit rate is 0.0."""
|
||||
row = _make_row(
|
||||
tokens_input=_ZERO,
|
||||
tokens_output=_ZERO,
|
||||
tokens_cache_read=_ZERO,
|
||||
tokens_cache_write=_ZERO,
|
||||
)
|
||||
svc = _service_with_execute(_result_one(row))
|
||||
result = await svc.get_cache_efficiency("24h")
|
||||
assert result["cache_hit_rate"] == 0.0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_cache_hit_gives_rate_of_1(self) -> None:
|
||||
"""When all input-like tokens are cache reads, hit rate = 1.0."""
|
||||
_full_rate = 1.0
|
||||
row = _make_row(
|
||||
tokens_input=_ZERO,
|
||||
tokens_output=_ZERO,
|
||||
tokens_cache_read=1000,
|
||||
tokens_cache_write=_ZERO,
|
||||
)
|
||||
svc = _service_with_execute(_result_one(row))
|
||||
result = await svc.get_cache_efficiency("24h")
|
||||
assert abs(result["cache_hit_rate"] - _full_rate) < _TOL
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cost_saved_arithmetic(self) -> None:
|
||||
"""cost_saved = cache_read * (full_input_price - cache_read_price) / 1M.
|
||||
|
||||
Sonnet baseline: full=$3.00/1M, cache_read=$0.30/1M.
|
||||
For 1M cache-read tokens: saved = 3.00 - 0.30 = 2.70.
|
||||
"""
|
||||
row = _make_row(
|
||||
tokens_input=_ZERO,
|
||||
tokens_output=_ZERO,
|
||||
tokens_cache_read=_M,
|
||||
tokens_cache_write=_ZERO,
|
||||
)
|
||||
svc = _service_with_execute(_result_one(row))
|
||||
result = await svc.get_cache_efficiency("24h")
|
||||
assert abs(result["cost_saved_by_cache_usd"] - _EXPECTED_COST_SAVED) < _TOL
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_zero_cache_reads_gives_zero_savings(self) -> None:
|
||||
row = _make_row(
|
||||
tokens_input=1000,
|
||||
tokens_output=500,
|
||||
tokens_cache_read=_ZERO,
|
||||
tokens_cache_write=_ZERO,
|
||||
)
|
||||
svc = _service_with_execute(_result_one(row))
|
||||
result = await svc.get_cache_efficiency("24h")
|
||||
assert result["cost_saved_by_cache_usd"] == 0.0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_efficiency_contains_required_fields(self) -> None:
|
||||
row = _make_row(
|
||||
tokens_input=_ZERO,
|
||||
tokens_output=_ZERO,
|
||||
tokens_cache_read=_ZERO,
|
||||
tokens_cache_write=_ZERO,
|
||||
)
|
||||
svc = _service_with_execute(_result_one(row))
|
||||
result = await svc.get_cache_efficiency("24h")
|
||||
for field in ("cache_hit_rate", "cost_saved_by_cache_usd"):
|
||||
assert field in result, f"Missing field: {field}"
|
||||
Reference in New Issue
Block a user