xueyiming 1 неделя назад
Родитель
Сommit
50d6c52aa6

+ 90 - 0
alembic/versions/20260803_10_add_llm_usage_event.py

@@ -0,0 +1,90 @@
+"""add llm_usage_event for billing
+
+Revision ID: 20260803_10
+Revises: 20260803_09
+Create Date: 2026-08-03
+"""
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "20260803_10"
+down_revision: str | None = "20260803_09"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+    op.create_table(
+        "llm_usage_event",
+        sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
+        sa.Column(
+            "biz_dt",
+            sa.String(length=8),
+            nullable=False,
+            comment="业务日 YYYYMMDD(Asia/Shanghai)",
+        ),
+        sa.Column(
+            "provider",
+            sa.String(length=32),
+            nullable=False,
+            server_default="openrouter",
+            comment="提供商 openrouter / dashscope 等",
+        ),
+        sa.Column("model", sa.String(length=128), nullable=True, comment="模型名"),
+        sa.Column("agent_name", sa.String(length=128), nullable=True, comment="Agent 名称"),
+        sa.Column("run_id", sa.String(length=128), nullable=True, comment="Agent run id"),
+        sa.Column("iteration", sa.Integer(), nullable=True, comment="ReAct 迭代序号"),
+        sa.Column(
+            "prompt_tokens",
+            sa.Integer(),
+            nullable=False,
+            server_default="0",
+            comment="输入 token 数",
+        ),
+        sa.Column(
+            "completion_tokens",
+            sa.Integer(),
+            nullable=False,
+            server_default="0",
+            comment="输出 token 数",
+        ),
+        sa.Column(
+            "total_tokens",
+            sa.Integer(),
+            nullable=False,
+            server_default="0",
+            comment="总 token 数",
+        ),
+        sa.Column(
+            "cost_usd",
+            sa.Numeric(precision=14, scale=8),
+            nullable=True,
+            comment="本次调用费用(USD,优先取 provider 返回的 usage.cost)",
+        ),
+        sa.Column(
+            "created_at",
+            sa.DateTime(),
+            server_default=sa.text("CURRENT_TIMESTAMP"),
+            nullable=False,
+            comment="记录时间",
+        ),
+        sa.PrimaryKeyConstraint("id"),
+    )
+    op.create_index("idx_llm_usage_biz_dt", "llm_usage_event", ["biz_dt"])
+    op.create_index("idx_llm_usage_biz_agent", "llm_usage_event", ["biz_dt", "agent_name"])
+    op.create_index("idx_llm_usage_biz_model", "llm_usage_event", ["biz_dt", "model"])
+    op.create_index("idx_llm_usage_run_id", "llm_usage_event", ["run_id"])
+    op.create_index("idx_llm_usage_created", "llm_usage_event", ["created_at"])
+
+
+def downgrade() -> None:
+    op.drop_index("idx_llm_usage_created", table_name="llm_usage_event")
+    op.drop_index("idx_llm_usage_run_id", table_name="llm_usage_event")
+    op.drop_index("idx_llm_usage_biz_model", table_name="llm_usage_event")
+    op.drop_index("idx_llm_usage_biz_agent", table_name="llm_usage_event")
+    op.drop_index("idx_llm_usage_biz_dt", table_name="llm_usage_event")
+    op.drop_table("llm_usage_event")

+ 21 - 0
api/routers/llm_billing.py

@@ -0,0 +1,21 @@
+from __future__ import annotations
+
+from fastapi import APIRouter, Query
+
+from api.services.llm_billing import get_billing_overview
+
+router = APIRouter(prefix="/api/llm-billing", tags=["llm-billing"])
+
+
+@router.get("/overview")
+def llm_billing_overview(
+    start_dt: str | None = Query(default=None, pattern=r"^\d{8}$"),
+    end_dt: str | None = Query(default=None, pattern=r"^\d{8}$"),
+    recent_limit: int = Query(default=50, ge=1, le=200),
+) -> dict:
+    """Daily LLM cost overview with agent/model breakdowns."""
+    return get_billing_overview(
+        start_dt=start_dt,
+        end_dt=end_dt,
+        recent_limit=recent_limit,
+    )

+ 132 - 0
api/services/llm_billing.py

@@ -0,0 +1,132 @@
+"""LLM billing query service for the admin dashboard."""
+
+from __future__ import annotations
+
+from datetime import datetime, timedelta
+from typing import Any
+from zoneinfo import ZoneInfo
+
+from supply_infra.db.repositories.llm_usage_repo import LlmUsageRepository
+from supply_infra.db.session import get_session
+
+_SHANGHAI = ZoneInfo("Asia/Shanghai")
+
+
+def _today() -> datetime:
+    return datetime.now(_SHANGHAI)
+
+
+def _biz_dt(day: datetime) -> str:
+    return day.strftime("%Y%m%d")
+
+
+def _shift_biz_dt(biz_dt: str, days: int) -> str:
+    base = datetime.strptime(biz_dt, "%Y%m%d").replace(tzinfo=_SHANGHAI)
+    return _biz_dt(base + timedelta(days=days))
+
+
+def _format_biz_dt_label(biz_dt: str) -> str:
+    return f"{biz_dt[0:4]}-{biz_dt[4:6]}-{biz_dt[6:8]}"
+
+
+def _serialize_event(row: Any) -> dict[str, Any]:
+    return {
+        "id": row.id,
+        "biz_dt": row.biz_dt,
+        "provider": row.provider,
+        "model": row.model,
+        "agent_name": row.agent_name,
+        "run_id": row.run_id,
+        "iteration": row.iteration,
+        "prompt_tokens": row.prompt_tokens,
+        "completion_tokens": row.completion_tokens,
+        "total_tokens": row.total_tokens,
+        "cost_usd": float(row.cost_usd) if row.cost_usd is not None else None,
+        "created_at": row.created_at.isoformat(sep=" ", timespec="seconds")
+        if row.created_at
+        else None,
+    }
+
+
+def get_billing_overview(
+    *,
+    start_dt: str | None = None,
+    end_dt: str | None = None,
+    recent_limit: int = 50,
+) -> dict[str, Any]:
+    """Aggregate LLM cost for a date range with agent/model breakdowns."""
+    today = _today()
+    today_dt = _biz_dt(today)
+    end = end_dt or today_dt
+    start = start_dt or _shift_biz_dt(end, -29)
+
+    if start > end:
+        start, end = end, start
+
+    yesterday_dt = _shift_biz_dt(today_dt, -1)
+    week_start = _shift_biz_dt(today_dt, -6)
+
+    with get_session() as session:
+        repo = LlmUsageRepository(session)
+        daily = repo.summarize_daily(start_dt=start, end_dt=end)
+        by_agent = repo.breakdown_by_agent(start_dt=start, end_dt=end)
+        by_model = repo.breakdown_by_model(start_dt=start, end_dt=end)
+        recent = repo.list_recent_events(start_dt=start, end_dt=end, limit=recent_limit)
+
+        # Fill missing days so charts are continuous.
+        daily_map = {item["biz_dt"]: item for item in daily}
+        filled_daily: list[dict[str, Any]] = []
+        cursor = start
+        while cursor <= end:
+            item = daily_map.get(
+                cursor,
+                {
+                    "biz_dt": cursor,
+                    "call_count": 0,
+                    "prompt_tokens": 0,
+                    "completion_tokens": 0,
+                    "total_tokens": 0,
+                    "total_cost_usd": 0.0,
+                    "missing_cost_count": 0,
+                },
+            )
+            filled_daily.append({**item, "label": _format_biz_dt_label(cursor)})
+            cursor = _shift_biz_dt(cursor, 1)
+
+        range_cost = sum(item["total_cost_usd"] for item in filled_daily)
+        range_calls = sum(item["call_count"] for item in filled_daily)
+        range_tokens = sum(item["total_tokens"] for item in filled_daily)
+
+        today_item = daily_map.get(today_dt)
+        yesterday_item = daily_map.get(yesterday_dt)
+        week_cost = sum(
+            item["total_cost_usd"]
+            for item in filled_daily
+            if week_start <= item["biz_dt"] <= today_dt
+        )
+
+        return {
+            "timezone": "Asia/Shanghai",
+            "currency": "USD",
+            "range": {
+                "start_dt": start,
+                "end_dt": end,
+                "total_cost_usd": range_cost,
+                "call_count": range_calls,
+                "total_tokens": range_tokens,
+            },
+            "summary": {
+                "today_biz_dt": today_dt,
+                "today_cost_usd": float(today_item["total_cost_usd"]) if today_item else 0.0,
+                "today_call_count": int(today_item["call_count"]) if today_item else 0,
+                "yesterday_cost_usd": float(yesterday_item["total_cost_usd"])
+                if yesterday_item
+                else 0.0,
+                "week_cost_usd": week_cost,
+                "range_cost_usd": range_cost,
+            },
+            "daily": filled_daily,
+            "by_agent": by_agent,
+            "by_model": by_model,
+            "recent_events": [_serialize_event(row) for row in recent],
+        }

+ 37 - 0
supply_agent/logging/usage.py

@@ -0,0 +1,37 @@
+"""Pluggable hook for persisting LLM usage / cost after each completion.
+
+Keeps the agent framework free of infrastructure (DB) dependencies.
+Infra registers a recorder via ``set_llm_usage_recorder``.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Callable
+from typing import Any
+
+LlmUsageRecorder = Callable[[dict[str, Any]], None]
+
+_recorder: LlmUsageRecorder | None = None
+
+
+def set_llm_usage_recorder(recorder: LlmUsageRecorder | None) -> None:
+    """Install or clear the LLM usage recorder callback."""
+    global _recorder
+    _recorder = recorder
+
+
+def get_llm_usage_recorder() -> LlmUsageRecorder | None:
+    return _recorder
+
+
+def record_llm_usage(payload: dict[str, Any]) -> None:
+    """Invoke the registered recorder, if any. Never raises to callers."""
+    if _recorder is None:
+        return
+    try:
+        _recorder(payload)
+    except Exception:
+        # Billing must not break agent runs.
+        import logging
+
+        logging.getLogger(__name__).exception("LLM usage recorder failed")

+ 80 - 0
supply_infra/db/models/llm_usage_event.py

@@ -0,0 +1,80 @@
+from __future__ import annotations
+
+from datetime import datetime
+from decimal import Decimal
+
+from sqlalchemy import (
+    BigInteger,
+    DateTime,
+    Index,
+    Integer,
+    Numeric,
+    String,
+    func,
+)
+from sqlalchemy.orm import Mapped, mapped_column
+
+from supply_infra.db.base import Base
+
+
+class LlmUsageEvent(Base):
+    """单次 LLM 调用的用量与费用明细。"""
+
+    __tablename__ = "llm_usage_event"
+    __table_args__ = (
+        Index("idx_llm_usage_biz_dt", "biz_dt"),
+        Index("idx_llm_usage_biz_agent", "biz_dt", "agent_name"),
+        Index("idx_llm_usage_biz_model", "biz_dt", "model"),
+        Index("idx_llm_usage_run_id", "run_id"),
+        Index("idx_llm_usage_created", "created_at"),
+    )
+
+    id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
+    biz_dt: Mapped[str] = mapped_column(
+        String(8),
+        nullable=False,
+        comment="业务日 YYYYMMDD(Asia/Shanghai)",
+    )
+    provider: Mapped[str] = mapped_column(
+        String(32),
+        nullable=False,
+        default="openrouter",
+        comment="提供商 openrouter / dashscope 等",
+    )
+    model: Mapped[str | None] = mapped_column(String(128), nullable=True, comment="模型名")
+    agent_name: Mapped[str | None] = mapped_column(
+        String(128),
+        nullable=True,
+        comment="Agent 名称",
+    )
+    run_id: Mapped[str | None] = mapped_column(String(128), nullable=True, comment="Agent run id")
+    iteration: Mapped[int | None] = mapped_column(Integer, nullable=True, comment="ReAct 迭代序号")
+    prompt_tokens: Mapped[int] = mapped_column(
+        Integer,
+        nullable=False,
+        default=0,
+        comment="输入 token 数",
+    )
+    completion_tokens: Mapped[int] = mapped_column(
+        Integer,
+        nullable=False,
+        default=0,
+        comment="输出 token 数",
+    )
+    total_tokens: Mapped[int] = mapped_column(
+        Integer,
+        nullable=False,
+        default=0,
+        comment="总 token 数",
+    )
+    cost_usd: Mapped[Decimal | None] = mapped_column(
+        Numeric(14, 8),
+        nullable=True,
+        comment="本次调用费用(USD,优先取 provider 返回的 usage.cost)",
+    )
+    created_at: Mapped[datetime] = mapped_column(
+        DateTime,
+        nullable=False,
+        server_default=func.now(),
+        comment="记录时间",
+    )

+ 165 - 0
supply_infra/db/repositories/llm_usage_repo.py

@@ -0,0 +1,165 @@
+from __future__ import annotations
+
+from decimal import Decimal
+from typing import Any
+
+from sqlalchemy import case, func, select
+
+from supply_infra.db.models.llm_usage_event import LlmUsageEvent
+from supply_infra.db.repositories.base import BaseRepository
+
+
+class LlmUsageRepository(BaseRepository[LlmUsageEvent]):
+    model = LlmUsageEvent
+
+    def create_event(
+        self,
+        *,
+        biz_dt: str,
+        provider: str,
+        model: str | None,
+        agent_name: str | None,
+        run_id: str | None,
+        iteration: int | None,
+        prompt_tokens: int,
+        completion_tokens: int,
+        total_tokens: int,
+        cost_usd: Decimal | None,
+    ) -> LlmUsageEvent:
+        entity = LlmUsageEvent(
+            biz_dt=biz_dt,
+            provider=provider,
+            model=model,
+            agent_name=agent_name,
+            run_id=run_id,
+            iteration=iteration,
+            prompt_tokens=prompt_tokens,
+            completion_tokens=completion_tokens,
+            total_tokens=total_tokens,
+            cost_usd=cost_usd,
+        )
+        return self.add(entity)
+
+    def summarize_daily(
+        self,
+        *,
+        start_dt: str,
+        end_dt: str,
+    ) -> list[dict[str, Any]]:
+        """按业务日汇总费用与调用量。"""
+        cost_sum = func.coalesce(func.sum(LlmUsageEvent.cost_usd), 0)
+        stmt = (
+            select(
+                LlmUsageEvent.biz_dt,
+                func.count(LlmUsageEvent.id).label("call_count"),
+                func.coalesce(func.sum(LlmUsageEvent.prompt_tokens), 0).label("prompt_tokens"),
+                func.coalesce(func.sum(LlmUsageEvent.completion_tokens), 0).label(
+                    "completion_tokens"
+                ),
+                func.coalesce(func.sum(LlmUsageEvent.total_tokens), 0).label("total_tokens"),
+                cost_sum.label("total_cost_usd"),
+                func.sum(case((LlmUsageEvent.cost_usd.is_(None), 1), else_=0)).label(
+                    "missing_cost_count"
+                ),
+            )
+            .where(
+                LlmUsageEvent.biz_dt >= start_dt,
+                LlmUsageEvent.biz_dt <= end_dt,
+            )
+            .group_by(LlmUsageEvent.biz_dt)
+            .order_by(LlmUsageEvent.biz_dt.asc())
+        )
+        rows = self.session.execute(stmt).all()
+        return [
+            {
+                "biz_dt": row.biz_dt,
+                "call_count": int(row.call_count or 0),
+                "prompt_tokens": int(row.prompt_tokens or 0),
+                "completion_tokens": int(row.completion_tokens or 0),
+                "total_tokens": int(row.total_tokens or 0),
+                "total_cost_usd": float(row.total_cost_usd or 0),
+                "missing_cost_count": int(row.missing_cost_count or 0),
+            }
+            for row in rows
+        ]
+
+    def breakdown_by_agent(
+        self,
+        *,
+        start_dt: str,
+        end_dt: str,
+    ) -> list[dict[str, Any]]:
+        cost_sum = func.coalesce(func.sum(LlmUsageEvent.cost_usd), 0)
+        agent_label = func.coalesce(LlmUsageEvent.agent_name, "unknown")
+        stmt = (
+            select(
+                agent_label.label("agent_name"),
+                func.count(LlmUsageEvent.id).label("call_count"),
+                func.coalesce(func.sum(LlmUsageEvent.total_tokens), 0).label("total_tokens"),
+                cost_sum.label("total_cost_usd"),
+            )
+            .where(
+                LlmUsageEvent.biz_dt >= start_dt,
+                LlmUsageEvent.biz_dt <= end_dt,
+            )
+            .group_by(agent_label)
+            .order_by(cost_sum.desc(), func.count(LlmUsageEvent.id).desc())
+        )
+        rows = self.session.execute(stmt).all()
+        return [
+            {
+                "agent_name": row.agent_name,
+                "call_count": int(row.call_count or 0),
+                "total_tokens": int(row.total_tokens or 0),
+                "total_cost_usd": float(row.total_cost_usd or 0),
+            }
+            for row in rows
+        ]
+
+    def breakdown_by_model(
+        self,
+        *,
+        start_dt: str,
+        end_dt: str,
+    ) -> list[dict[str, Any]]:
+        cost_sum = func.coalesce(func.sum(LlmUsageEvent.cost_usd), 0)
+        model_label = func.coalesce(LlmUsageEvent.model, "unknown")
+        stmt = (
+            select(
+                model_label.label("model"),
+                func.count(LlmUsageEvent.id).label("call_count"),
+                func.coalesce(func.sum(LlmUsageEvent.total_tokens), 0).label("total_tokens"),
+                cost_sum.label("total_cost_usd"),
+            )
+            .where(
+                LlmUsageEvent.biz_dt >= start_dt,
+                LlmUsageEvent.biz_dt <= end_dt,
+            )
+            .group_by(model_label)
+            .order_by(cost_sum.desc(), func.count(LlmUsageEvent.id).desc())
+        )
+        rows = self.session.execute(stmt).all()
+        return [
+            {
+                "model": row.model,
+                "call_count": int(row.call_count or 0),
+                "total_tokens": int(row.total_tokens or 0),
+                "total_cost_usd": float(row.total_cost_usd or 0),
+            }
+            for row in rows
+        ]
+
+    def list_recent_events(
+        self,
+        *,
+        start_dt: str | None = None,
+        end_dt: str | None = None,
+        limit: int = 50,
+    ) -> list[LlmUsageEvent]:
+        stmt = select(LlmUsageEvent)
+        if start_dt:
+            stmt = stmt.where(LlmUsageEvent.biz_dt >= start_dt)
+        if end_dt:
+            stmt = stmt.where(LlmUsageEvent.biz_dt <= end_dt)
+        stmt = stmt.order_by(LlmUsageEvent.id.desc()).limit(limit)
+        return list(self.session.scalars(stmt).all())

+ 5 - 0
supply_infra/llm_billing/__init__.py

@@ -0,0 +1,5 @@
+"""LLM billing persistence helpers."""
+
+from supply_infra.llm_billing.recorder import record_llm_usage_to_db
+
+__all__ = ["record_llm_usage_to_db"]

+ 80 - 0
supply_infra/llm_billing/recorder.py

@@ -0,0 +1,80 @@
+"""Persist LLM usage events into MySQL for billing dashboards."""
+
+from __future__ import annotations
+
+import logging
+from datetime import datetime
+from decimal import Decimal, InvalidOperation
+from typing import Any
+from zoneinfo import ZoneInfo
+
+from supply_infra.db.repositories.llm_usage_repo import LlmUsageRepository
+from supply_infra.db.session import get_session
+
+logger = logging.getLogger(__name__)
+_SHANGHAI = ZoneInfo("Asia/Shanghai")
+
+
+def _today_biz_dt() -> str:
+    return datetime.now(_SHANGHAI).strftime("%Y%m%d")
+
+
+def _as_int(value: Any) -> int:
+    if value is None:
+        return 0
+    try:
+        return int(value)
+    except (TypeError, ValueError):
+        return 0
+
+
+def _as_cost(value: Any) -> Decimal | None:
+    if value is None:
+        return None
+    try:
+        return Decimal(str(value))
+    except (InvalidOperation, ValueError, TypeError):
+        return None
+
+
+def record_llm_usage_to_db(payload: dict[str, Any]) -> None:
+    """Write one LLM call's usage/cost into ``llm_usage_event``."""
+    usage = payload.get("usage") or {}
+    prompt_tokens = _as_int(usage.get("prompt_tokens"))
+    completion_tokens = _as_int(usage.get("completion_tokens"))
+    total_tokens = _as_int(usage.get("total_tokens"))
+    if total_tokens <= 0:
+        total_tokens = prompt_tokens + completion_tokens
+
+    cost_usd = _as_cost(usage.get("cost"))
+    if cost_usd is None and isinstance(usage.get("cost_details"), dict):
+        # Some OpenRouter responses nest cost under cost_details.upstream_inference_cost
+        details = usage["cost_details"]
+        cost_usd = _as_cost(details.get("upstream_inference_cost") or details.get("total_cost"))
+
+    # Skip empty records (e.g. stream path without usage).
+    if prompt_tokens == 0 and completion_tokens == 0 and total_tokens == 0 and cost_usd is None:
+        return
+
+    biz_dt = str(payload.get("biz_dt") or _today_biz_dt())
+    with get_session() as session:
+        repo = LlmUsageRepository(session)
+        repo.create_event(
+            biz_dt=biz_dt,
+            provider=str(payload.get("provider") or "openrouter"),
+            model=(str(payload["model"]) if payload.get("model") else None),
+            agent_name=(str(payload["agent_name"]) if payload.get("agent_name") else None),
+            run_id=(str(payload["run_id"]) if payload.get("run_id") else None),
+            iteration=_as_int(payload.get("iteration")) if payload.get("iteration") is not None else None,
+            prompt_tokens=prompt_tokens,
+            completion_tokens=completion_tokens,
+            total_tokens=total_tokens,
+            cost_usd=cost_usd,
+        )
+    logger.debug(
+        "recorded llm usage run_id=%s model=%s cost_usd=%s tokens=%s",
+        payload.get("run_id"),
+        payload.get("model"),
+        cost_usd,
+        total_tokens,
+    )

+ 74 - 0
tests/supply_agent/test_llm_usage_hook.py

@@ -0,0 +1,74 @@
+"""LLM usage recording hook stays free of supply_infra at import time."""
+
+from __future__ import annotations
+
+import importlib
+from pathlib import Path
+
+from supply_agent.logging.logger import AgentLogger
+from supply_agent.logging.usage import (
+    get_llm_usage_recorder,
+    record_llm_usage,
+    set_llm_usage_recorder,
+)
+from supply_agent.types import Message, Role
+
+
+def test_usage_module_does_not_import_supply_infra() -> None:
+    source = importlib.util.find_spec("supply_agent.logging.usage")
+    assert source is not None and source.origin is not None
+    text = Path(source.origin).read_text(encoding="utf-8")
+    assert "import supply_infra" not in text
+    assert "from supply_infra" not in text
+
+
+def test_record_llm_usage_is_noop_without_recorder() -> None:
+    previous = get_llm_usage_recorder()
+    try:
+        set_llm_usage_recorder(None)
+        record_llm_usage({"usage": {"cost": 0.01}})
+    finally:
+        set_llm_usage_recorder(previous)
+
+
+def test_log_llm_output_invokes_usage_recorder(tmp_path: Path) -> None:
+    previous = get_llm_usage_recorder()
+    seen: list[dict] = []
+
+    def _recorder(payload: dict) -> None:
+        seen.append(payload)
+
+    try:
+        set_llm_usage_recorder(_recorder)
+        logger = AgentLogger(tmp_path, enabled=True)
+        logger.start_run("hello", model="google/gemini-2.5-flash", agent_name="demo_agent")
+
+        class _Usage:
+            def model_dump(self) -> dict:
+                return {
+                    "prompt_tokens": 10,
+                    "completion_tokens": 5,
+                    "total_tokens": 15,
+                    "cost": 0.0012,
+                }
+
+        class _Response:
+            model = "google/gemini-2.5-flash"
+            usage = _Usage()
+
+            def model_dump(self) -> dict:
+                return {"model": self.model, "usage": self.usage.model_dump()}
+
+        logger.log_llm_output(
+            1,
+            Message(role=Role.ASSISTANT, content="ok"),
+            raw_response=_Response(),
+            model="google/gemini-2.5-flash",
+        )
+        assert len(seen) == 1
+        assert seen[0]["agent_name"] == "demo_agent"
+        assert seen[0]["model"] == "google/gemini-2.5-flash"
+        assert seen[0]["usage"]["cost"] == 0.0012
+        assert seen[0]["usage"]["total_tokens"] == 15
+    finally:
+        set_llm_usage_recorder(previous)

+ 18 - 0
web/src/api/llmBilling.ts

@@ -0,0 +1,18 @@
+import type { LlmBillingOverview } from '../types/llmBilling'
+
+export async function fetchLlmBillingOverview(params: {
+  startDt?: string
+  endDt?: string
+  recentLimit?: number
+} = {}): Promise<LlmBillingOverview> {
+  const query = new URLSearchParams()
+  if (params.startDt) query.set('start_dt', params.startDt)
+  if (params.endDt) query.set('end_dt', params.endDt)
+  if (params.recentLimit) query.set('recent_limit', String(params.recentLimit))
+  const suffix = query.toString() ? `?${query.toString()}` : ''
+  const res = await fetch(`/api/llm-billing/overview${suffix}`)
+  if (!res.ok) {
+    throw new Error(`加载 LLM 费用失败: ${res.status} ${res.statusText}`)
+  }
+  return res.json()
+}

+ 63 - 0
web/src/types/llmBilling.ts

@@ -0,0 +1,63 @@
+export interface LlmDailyCost {
+  biz_dt: string
+  label: string
+  call_count: number
+  prompt_tokens: number
+  completion_tokens: number
+  total_tokens: number
+  total_cost_usd: number
+  missing_cost_count: number
+}
+
+export interface LlmAgentBreakdown {
+  agent_name: string
+  call_count: number
+  total_tokens: number
+  total_cost_usd: number
+}
+
+export interface LlmModelBreakdown {
+  model: string
+  call_count: number
+  total_tokens: number
+  total_cost_usd: number
+}
+
+export interface LlmUsageEvent {
+  id: number
+  biz_dt: string
+  provider: string
+  model: string | null
+  agent_name: string | null
+  run_id: string | null
+  iteration: number | null
+  prompt_tokens: number
+  completion_tokens: number
+  total_tokens: number
+  cost_usd: number | null
+  created_at: string | null
+}
+
+export interface LlmBillingOverview {
+  timezone: string
+  currency: string
+  range: {
+    start_dt: string
+    end_dt: string
+    total_cost_usd: number
+    call_count: number
+    total_tokens: number
+  }
+  summary: {
+    today_biz_dt: string
+    today_cost_usd: number
+    today_call_count: number
+    yesterday_cost_usd: number
+    week_cost_usd: number
+    range_cost_usd: number
+  }
+  daily: LlmDailyCost[]
+  by_agent: LlmAgentBreakdown[]
+  by_model: LlmModelBreakdown[]
+  recent_events: LlmUsageEvent[]
+}

+ 557 - 0
web/src/views/LlmBillingView.vue

@@ -0,0 +1,557 @@
+<script setup lang="ts">
+import { computed, onMounted, ref } from 'vue'
+import { fetchLlmBillingOverview } from '../api/llmBilling'
+import type { LlmBillingOverview } from '../types/llmBilling'
+
+const overview = ref<LlmBillingOverview | null>(null)
+const loading = ref(true)
+const error = ref('')
+const rangeDays = ref<7 | 14 | 30>(30)
+
+function todayBizDt(): string {
+  return new Intl.DateTimeFormat('sv-SE', {
+    timeZone: 'Asia/Shanghai',
+    year: 'numeric',
+    month: '2-digit',
+    day: '2-digit',
+  })
+    .format(new Date())
+    .replaceAll('-', '')
+}
+
+function shiftBizDt(bizDt: string, days: number): string {
+  const y = Number(bizDt.slice(0, 4))
+  const m = Number(bizDt.slice(4, 6))
+  const d = Number(bizDt.slice(6, 8))
+  const date = new Date(Date.UTC(y, m - 1, d))
+  date.setUTCDate(date.getUTCDate() + days)
+  const yy = date.getUTCFullYear()
+  const mm = String(date.getUTCMonth() + 1).padStart(2, '0')
+  const dd = String(date.getUTCDate()).padStart(2, '0')
+  return `${yy}${mm}${dd}`
+}
+
+async function load() {
+  loading.value = true
+  error.value = ''
+  try {
+    const endDt = todayBizDt()
+    const startDt = shiftBizDt(endDt, -(rangeDays.value - 1))
+    overview.value = await fetchLlmBillingOverview({
+      startDt,
+      endDt,
+      recentLimit: 40,
+    })
+  } catch (cause) {
+    error.value = cause instanceof Error ? cause.message : String(cause)
+  } finally {
+    loading.value = false
+  }
+}
+
+onMounted(load)
+
+const maxDailyCost = computed(() => {
+  const values = overview.value?.daily.map((d) => d.total_cost_usd) ?? []
+  return Math.max(...values, 0.000001)
+})
+
+const maxAgentCost = computed(() => {
+  const values = overview.value?.by_agent.map((d) => d.total_cost_usd) ?? []
+  return Math.max(...values, 0.000001)
+})
+
+const maxModelCost = computed(() => {
+  const values = overview.value?.by_model.map((d) => d.total_cost_usd) ?? []
+  return Math.max(...values, 0.000001)
+})
+
+function formatUsd(value: number | null | undefined, digits = 4): string {
+  if (value == null || Number.isNaN(value)) return '—'
+  if (value === 0) return '$0'
+  if (value < 0.0001) return `$${value.toExponential(2)}`
+  return `$${value.toFixed(digits)}`
+}
+
+function formatTokens(value: number): string {
+  if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(2)}M`
+  if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`
+  return String(value)
+}
+
+function shortModel(model: string): string {
+  const parts = model.split('/')
+  return parts[parts.length - 1] || model
+}
+
+function barHeight(cost: number, max: number): string {
+  const pct = Math.max(3, (cost / max) * 100)
+  return `${pct}%`
+}
+
+function barWidth(cost: number, max: number): string {
+  return `${Math.max(2, (cost / max) * 100)}%`
+}
+</script>
+
+<template>
+  <main class="billing-page">
+    <header class="page-header">
+      <div>
+        <div class="eyebrow"><span /> LLM BILLING</div>
+        <h1>LLM 费用看板</h1>
+        <p>按天汇总全部 Agent 的 OpenRouter 调用费用(Asia/Shanghai)。</p>
+      </div>
+      <div class="range-switch" role="group" aria-label="时间范围">
+        <button
+          v-for="days in [7, 14, 30] as const"
+          :key="days"
+          type="button"
+          :class="{ active: rangeDays === days }"
+          :disabled="loading"
+          @click="rangeDays = days; load()"
+        >
+          近 {{ days }} 天
+        </button>
+      </div>
+    </header>
+
+    <p v-if="error" class="error">{{ error }}</p>
+    <p v-else-if="loading && !overview" class="loading">加载中…</p>
+
+    <template v-if="overview">
+      <section class="summary-grid" aria-label="费用摘要">
+        <article class="summary-card accent">
+          <span>今日费用</span>
+          <strong>{{ formatUsd(overview.summary.today_cost_usd) }}</strong>
+          <small>{{ overview.summary.today_call_count }} 次调用 · {{ overview.summary.today_biz_dt }}</small>
+        </article>
+        <article class="summary-card">
+          <span>昨日费用</span>
+          <strong>{{ formatUsd(overview.summary.yesterday_cost_usd) }}</strong>
+          <small>对比今日趋势</small>
+        </article>
+        <article class="summary-card">
+          <span>近 7 天</span>
+          <strong>{{ formatUsd(overview.summary.week_cost_usd) }}</strong>
+          <small>滚动一周合计</small>
+        </article>
+        <article class="summary-card">
+          <span>区间合计</span>
+          <strong>{{ formatUsd(overview.range.total_cost_usd) }}</strong>
+          <small>
+            {{ formatTokens(overview.range.total_tokens) }} tokens ·
+            {{ overview.range.call_count }} 次
+          </small>
+        </article>
+      </section>
+
+      <section class="panel chart-panel">
+        <div class="panel-head">
+          <div>
+            <h2>每日费用</h2>
+            <p>{{ overview.range.start_dt }} — {{ overview.range.end_dt }}</p>
+          </div>
+          <span class="currency-tag">USD</span>
+        </div>
+        <div class="bar-chart" role="img" :aria-label="`近 ${rangeDays} 天每日 LLM 费用柱状图`">
+          <div
+            v-for="day in overview.daily"
+            :key="day.biz_dt"
+            class="bar-col"
+            :title="`${day.label}: ${formatUsd(day.total_cost_usd)} · ${day.call_count} 次`"
+          >
+            <span class="bar-value">{{ day.total_cost_usd > 0 ? formatUsd(day.total_cost_usd, 3) : '' }}</span>
+            <div class="bar-track">
+              <div
+                class="bar-fill"
+                :class="{ empty: day.total_cost_usd <= 0 }"
+                :style="{ height: day.total_cost_usd > 0 ? barHeight(day.total_cost_usd, maxDailyCost) : '2px' }"
+              />
+            </div>
+            <span class="bar-label">{{ day.label.slice(5) }}</span>
+          </div>
+        </div>
+      </section>
+
+      <section class="split-panels">
+        <article class="panel">
+          <div class="panel-head">
+            <h2>按 Agent</h2>
+            <p>区间费用占比</p>
+          </div>
+          <ul v-if="overview.by_agent.length" class="breakdown-list">
+            <li v-for="item in overview.by_agent" :key="item.agent_name">
+              <div class="row-meta">
+                <strong>{{ item.agent_name }}</strong>
+                <span>{{ formatUsd(item.total_cost_usd) }}</span>
+              </div>
+              <div class="h-track">
+                <div class="h-fill agent" :style="{ width: barWidth(item.total_cost_usd, maxAgentCost) }" />
+              </div>
+              <small>{{ item.call_count }} 次 · {{ formatTokens(item.total_tokens) }} tokens</small>
+            </li>
+          </ul>
+          <p v-else class="empty">暂无 Agent 调用记录</p>
+        </article>
+
+        <article class="panel">
+          <div class="panel-head">
+            <h2>按模型</h2>
+            <p>区间费用占比</p>
+          </div>
+          <ul v-if="overview.by_model.length" class="breakdown-list">
+            <li v-for="item in overview.by_model" :key="item.model">
+              <div class="row-meta">
+                <strong :title="item.model">{{ shortModel(item.model) }}</strong>
+                <span>{{ formatUsd(item.total_cost_usd) }}</span>
+              </div>
+              <div class="h-track">
+                <div class="h-fill model" :style="{ width: barWidth(item.total_cost_usd, maxModelCost) }" />
+              </div>
+              <small>{{ item.call_count }} 次 · {{ formatTokens(item.total_tokens) }} tokens</small>
+            </li>
+          </ul>
+          <p v-else class="empty">暂无模型调用记录</p>
+        </article>
+      </section>
+
+      <section class="panel">
+        <div class="panel-head">
+          <div>
+            <h2>最近调用</h2>
+            <p>按写入时间倒序</p>
+          </div>
+        </div>
+        <div class="table-wrap">
+          <table>
+            <thead>
+              <tr>
+                <th>时间</th>
+                <th>Agent</th>
+                <th>模型</th>
+                <th>Tokens</th>
+                <th>费用</th>
+              </tr>
+            </thead>
+            <tbody>
+              <tr v-for="event in overview.recent_events" :key="event.id">
+                <td>{{ event.created_at || '—' }}</td>
+                <td>{{ event.agent_name || '—' }}</td>
+                <td :title="event.model || undefined">{{ event.model ? shortModel(event.model) : '—' }}</td>
+                <td>{{ formatTokens(event.total_tokens) }}</td>
+                <td>{{ formatUsd(event.cost_usd) }}</td>
+              </tr>
+              <tr v-if="!overview.recent_events.length">
+                <td colspan="5" class="empty-cell">暂无调用明细。Agent 下次调用 LLM 后将自动写入。</td>
+              </tr>
+            </tbody>
+          </table>
+        </div>
+      </section>
+    </template>
+  </main>
+</template>
+
+<style scoped>
+.billing-page {
+  --ink: #172033;
+  --muted: #6c7484;
+  --line: #e7e9ef;
+  --panel: #ffffff;
+  --accent: #1f6bff;
+  --accent-soft: #eaf1ff;
+  max-width: 1240px;
+  margin: 0 auto;
+  padding: 32px 24px 72px;
+  color: var(--ink);
+}
+
+.page-header {
+  display: flex;
+  align-items: flex-start;
+  justify-content: space-between;
+  gap: 24px;
+  margin-bottom: 28px;
+}
+
+.eyebrow {
+  display: inline-flex;
+  align-items: center;
+  gap: 8px;
+  font-size: 12px;
+  letter-spacing: 0.14em;
+  color: var(--muted);
+}
+
+.eyebrow span {
+  width: 8px;
+  height: 8px;
+  border-radius: 999px;
+  background: var(--accent);
+}
+
+h1 {
+  margin: 8px 0 6px;
+  font-size: 28px;
+  letter-spacing: -0.02em;
+}
+
+.page-header p,
+.panel-head p {
+  margin: 0;
+  color: var(--muted);
+}
+
+.range-switch {
+  display: flex;
+  gap: 8px;
+  flex-wrap: wrap;
+}
+
+.range-switch button {
+  border: 1px solid #cbd2df;
+  background: #fff;
+  border-radius: 999px;
+  padding: 8px 14px;
+  cursor: pointer;
+  color: #44506a;
+}
+
+.range-switch button.active {
+  background: #1e2638;
+  border-color: #1e2638;
+  color: #fff;
+}
+
+.summary-grid {
+  display: grid;
+  grid-template-columns: repeat(4, minmax(0, 1fr));
+  gap: 14px;
+  margin-bottom: 18px;
+}
+
+.summary-card,
+.panel {
+  border: 1px solid var(--line);
+  border-radius: 16px;
+  background: var(--panel);
+}
+
+.summary-card {
+  padding: 18px 18px 16px;
+  display: grid;
+  gap: 6px;
+}
+
+.summary-card span,
+.summary-card small {
+  color: var(--muted);
+  font-size: 13px;
+}
+
+.summary-card strong {
+  font-size: 28px;
+  letter-spacing: -0.03em;
+}
+
+.summary-card.accent {
+  background: linear-gradient(160deg, #f7f9ff, var(--accent-soft));
+  border-color: #d7e3ff;
+}
+
+.summary-card.accent strong {
+  color: #1848b8;
+}
+
+.panel {
+  padding: 20px;
+  margin-bottom: 18px;
+}
+
+.panel-head {
+  display: flex;
+  justify-content: space-between;
+  align-items: flex-start;
+  gap: 12px;
+  margin-bottom: 18px;
+}
+
+.panel-head h2 {
+  margin: 0 0 4px;
+  font-size: 18px;
+}
+
+.currency-tag {
+  font-size: 12px;
+  letter-spacing: 0.08em;
+  color: #5b6b88;
+  background: #f3f5f9;
+  border-radius: 999px;
+  padding: 6px 10px;
+}
+
+.bar-chart {
+  display: grid;
+  grid-auto-flow: column;
+  grid-auto-columns: minmax(28px, 1fr);
+  gap: 8px;
+  align-items: end;
+  min-height: 220px;
+  overflow-x: auto;
+  padding-bottom: 4px;
+}
+
+.bar-col {
+  display: grid;
+  grid-template-rows: auto 1fr auto;
+  gap: 6px;
+  min-height: 200px;
+  justify-items: center;
+}
+
+.bar-value {
+  font-size: 10px;
+  color: #70809c;
+  min-height: 14px;
+  white-space: nowrap;
+}
+
+.bar-track {
+  width: 100%;
+  max-width: 36px;
+  height: 160px;
+  display: flex;
+  align-items: flex-end;
+  background: #f5f7fb;
+  border-radius: 10px 10px 4px 4px;
+  overflow: hidden;
+}
+
+.bar-fill {
+  width: 100%;
+  border-radius: 10px 10px 4px 4px;
+  background: linear-gradient(180deg, #4d84ff, #1f6bff);
+}
+
+.bar-fill.empty {
+  background: #d9dee8;
+}
+
+.bar-label {
+  font-size: 11px;
+  color: var(--muted);
+}
+
+.split-panels {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: 18px;
+  margin-bottom: 18px;
+}
+
+.split-panels .panel {
+  margin-bottom: 0;
+}
+
+.breakdown-list {
+  list-style: none;
+  margin: 0;
+  padding: 0;
+  display: grid;
+  gap: 14px;
+}
+
+.row-meta {
+  display: flex;
+  justify-content: space-between;
+  gap: 12px;
+  margin-bottom: 6px;
+}
+
+.row-meta strong {
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.h-track {
+  height: 8px;
+  border-radius: 999px;
+  background: #eef1f6;
+  overflow: hidden;
+}
+
+.h-fill {
+  height: 100%;
+  border-radius: 999px;
+}
+
+.h-fill.agent {
+  background: linear-gradient(90deg, #4d84ff, #1f6bff);
+}
+
+.h-fill.model {
+  background: linear-gradient(90deg, #36c2a5, #159a7c);
+}
+
+.breakdown-list small,
+.empty,
+.loading {
+  color: var(--muted);
+}
+
+.table-wrap {
+  overflow-x: auto;
+}
+
+table {
+  width: 100%;
+  border-collapse: collapse;
+  font-size: 14px;
+}
+
+th,
+td {
+  text-align: left;
+  padding: 10px 8px;
+  border-bottom: 1px solid var(--line);
+  white-space: nowrap;
+}
+
+th {
+  color: var(--muted);
+  font-weight: 600;
+  font-size: 12px;
+  letter-spacing: 0.04em;
+}
+
+.empty-cell {
+  text-align: center;
+  color: var(--muted);
+  padding: 28px 8px;
+}
+
+.error {
+  color: #b42335;
+  margin-bottom: 16px;
+}
+
+@media (max-width: 900px) {
+  .summary-grid,
+  .split-panels {
+    grid-template-columns: 1fr 1fr;
+  }
+
+  .page-header {
+    flex-direction: column;
+  }
+}
+
+@media (max-width: 640px) {
+  .summary-grid,
+  .split-panels {
+    grid-template-columns: 1fr;
+  }
+}
+</style>