diff --git a/CHANGELOG.md b/CHANGELOG.md index ab0a3768..6da76b58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ ## [Unreleased] +### 新增 + +- Token 统计支持日期范围筛选(RangePicker 预设 + 自定义起止)并导出 Excel 用量明细(#154) +- Token 用量 Excel 含专家名称、中英列表头,以及按天/专家/模型汇总 sheet 与图表 +- Token 用量 Excel 按报表规范打磨:冻结表头、千分位、合计公式行、筛选与仪表盘配色柱图 + ### 修复 - 共享专家的技能列表现在会在聊天输入框中加载,非所有者可查看并选择专家已配置的技能 diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx index 217894fd..60f7c00a 100644 --- a/dashboard/src/App.tsx +++ b/dashboard/src/App.tsx @@ -2,6 +2,8 @@ import { createGlobalStyle } from "antd-style"; import { ConfigProvider, theme as antdTheme } from "antd"; import zhCN from "antd/locale/zh_CN"; import enUS from "antd/locale/en_US"; +import dayjs from "dayjs"; +import "dayjs/locale/zh-cn"; import { useEffect } from "react"; import DesktopWindowControls from "./components/DesktopWindowControls"; import { @@ -47,9 +49,10 @@ function ThemedApp() { const brandTokens = brandTokensFor(palette, isDark, customColor); // Make antd built-ins (Popconfirm OK/Cancel, Modal default footer, Empty, // Pagination, DatePicker, Table… ) follow the current UI language. - const antdLocale = i18n.language?.toLowerCase().startsWith("zh") - ? zhCN - : enUS; + // DatePicker month/weekday labels come from dayjs — keep it in sync too. + const isZh = i18n.language?.toLowerCase().startsWith("zh") ?? false; + const antdLocale = isZh ? zhCN : enUS; + dayjs.locale(isZh ? "zh-cn" : "en"); useUnauthorizedRedirect(); diff --git a/dashboard/src/locales/en.json b/dashboard/src/locales/en.json index 3aafe548..2ed47656 100644 --- a/dashboard/src/locales/en.json +++ b/dashboard/src/locales/en.json @@ -2203,7 +2203,13 @@ "last7d": "Last 7 Days", "last30d": "Last 30 Days", "allTime": "All Time", - "customRange": "Custom Date", + "thisMonth": "This month", + "lastMonth": "Last month", + "rangeStart": "Start date", + "rangeEnd": "End date", + "exportExcel": "Export Excel", + "exportFailed": "Export failed", + "exportFilenamePrefix": "token-usage", "summary": "Summary", "byDay": "By day", "byExpert": "By expert", diff --git a/dashboard/src/locales/zh.json b/dashboard/src/locales/zh.json index 6f0cebf9..3fe765ea 100644 --- a/dashboard/src/locales/zh.json +++ b/dashboard/src/locales/zh.json @@ -2203,7 +2203,13 @@ "last7d": "近 7 天", "last30d": "近 30 天", "allTime": "全部", - "customRange": "自定义日期", + "thisMonth": "本月", + "lastMonth": "上月", + "rangeStart": "开始日期", + "rangeEnd": "结束日期", + "exportExcel": "导出 Excel", + "exportFailed": "导出失败", + "exportFilenamePrefix": "Token用量", "summary": "汇总", "byDay": "按天", "byExpert": "按专家", diff --git a/dashboard/src/pages/Control/TokenUsage/index.tsx b/dashboard/src/pages/Control/TokenUsage/index.tsx index c85d6c4b..8181798f 100644 --- a/dashboard/src/pages/Control/TokenUsage/index.tsx +++ b/dashboard/src/pages/Control/TokenUsage/index.tsx @@ -8,7 +8,18 @@ */ import { useCallback, useEffect, useMemo, useState } from "react"; -import { Alert, Card, Select, Spin, Table, Empty, Tag, Segmented } from "antd"; +import { + Alert, + Button, + Card, + DatePicker, + Empty, + Select, + Segmented, + Spin, + Table, + Tag, +} from "antd"; import { BarChart, Bar, @@ -22,15 +33,18 @@ import { Cell, Legend, } from "recharts"; +import dayjs, { type Dayjs } from "dayjs"; +import { Download } from "lucide-react"; import { useTranslation } from "react-i18next"; import PageShell from "../../../layouts/PageShell"; import { useIsMobile } from "../../../hooks/useIsMobile"; import { UsageStats, type UsageStatItem } from "./UsageStats"; import { useUserRole } from "../../../hooks/useUserRole"; -import { request } from "../../../api/request"; +import { request, requestBlob } from "../../../api/request"; import { useAgent } from "../../../context/AgentContext"; import { useTheme } from "../../../context/ThemeContext"; import { brandPrimary } from "../../../styles/themePalettes"; +import { message } from "../../../utils/antdMessage"; import styles from "./index.module.less"; interface UsageUserOption { @@ -416,6 +430,59 @@ function fetchSummary( return request(`/usage/summary?${params}`); } +function usageExportPath( + windowKey: string, + agentFilter: string | "all", + userFilter: number | "all" | null, +): string { + const params = new URLSearchParams({ window: windowKey }); + if (agentFilter !== "all") { + params.set("agent_id", agentFilter); + } + if (userFilter === "all") { + return `/admin/usage/export.xlsx?${params}`; + } + if (typeof userFilter === "number") { + params.set("user_id", String(userFilter)); + return `/admin/usage/export.xlsx?${params}`; + } + return `/usage/export.xlsx?${params}`; +} + +/** ``{title}_{YYYY-MM-DD-YYYY-MM-DD}.xlsx`` — strip ``range:`` / ``day:`` kind tags. */ +function usageExportFilename(prefix: string, windowKey: string): string { + const rangeMatch = /^range:(\d{4}-\d{2}-\d{2}):(\d{4}-\d{2}-\d{2})$/.exec( + windowKey, + ); + if (rangeMatch) { + return `${prefix}_${rangeMatch[1]}-${rangeMatch[2]}.xlsx`; + } + const dayMatch = /^day:(\d{4}-\d{2}-\d{2})$/.exec(windowKey); + if (dayMatch) { + return `${prefix}_${dayMatch[1]}.xlsx`; + } + const monthMatch = /^month:(\d{4}-\d{2})$/.exec(windowKey); + if (monthMatch) { + return `${prefix}_${monthMatch[1]}.xlsx`; + } + const period = + windowKey.replace(/[^A-Za-z0-9._-]+/g, "_").replace(/^_|_$/g, "") || + "usage"; + return `${prefix}_${period}.xlsx`; +} + +const { RangePicker } = DatePicker; + +function rangeToWindowKey(range: [Dayjs, Dayjs] | null): string { + if (range === null) return "all"; + const [start, end] = range; + return `range:${start.format("YYYY-MM-DD")}:${end.format("YYYY-MM-DD")}`; +} + +function defaultLast30dRange(): [Dayjs, Dayjs] { + return [dayjs().add(-29, "day").startOf("day"), dayjs().endOf("day")]; +} + function DonutCard({ title, data, @@ -765,11 +832,14 @@ export default function TokenUsagePage() { const role = useUserRole(); const isAdmin = role === "admin"; const isMobile = useIsMobile(); - const [windowKey, setWindowKey] = useState("last_30d"); + const [dateRange, setDateRange] = useState<[Dayjs, Dayjs] | null>(() => + defaultLast30dRange(), + ); const [view, setView] = useState("summary"); const [agentFilter, setAgentFilter] = useState("all"); const [userFilter, setUserFilter] = useState("all"); const [users, setUsers] = useState([]); + const [exporting, setExporting] = useState(false); const [totals, setTotals] = useState(null); const [dimBuckets, setDimBuckets] = useState([]); @@ -791,13 +861,46 @@ export default function TokenUsagePage() { // Admin-only: null while role unknown so we don't hit /admin before ready. const adminUserFilter: number | "all" | null = isAdmin ? userFilter : null; - const windowOptions = useMemo( + const windowKey = useMemo(() => rangeToWindowKey(dateRange), [dateRange]); + + const rangePresets = useMemo( () => [ - { value: "today", label: t("tokenUsage.today") }, - { value: "yesterday", label: t("tokenUsage.yesterday") }, - { value: "last_7d", label: t("tokenUsage.last7d") }, - { value: "last_30d", label: t("tokenUsage.last30d") }, - { value: "all", label: t("tokenUsage.allTime") }, + { + label: t("tokenUsage.today"), + value: [dayjs().startOf("day"), dayjs().endOf("day")] as [Dayjs, Dayjs], + }, + { + label: t("tokenUsage.yesterday"), + value: [ + dayjs().add(-1, "day").startOf("day"), + dayjs().add(-1, "day").endOf("day"), + ] as [Dayjs, Dayjs], + }, + { + label: t("tokenUsage.last7d"), + value: [ + dayjs().add(-6, "day").startOf("day"), + dayjs().endOf("day"), + ] as [Dayjs, Dayjs], + }, + { + label: t("tokenUsage.last30d"), + value: defaultLast30dRange(), + }, + { + label: t("tokenUsage.thisMonth"), + value: [dayjs().startOf("month"), dayjs().endOf("day")] as [ + Dayjs, + Dayjs, + ], + }, + { + label: t("tokenUsage.lastMonth"), + value: [ + dayjs().add(-1, "month").startOf("month"), + dayjs().add(-1, "month").endOf("month"), + ] as [Dayjs, Dayjs], + }, ], [t], ); @@ -892,6 +995,29 @@ export default function TokenUsagePage() { void refresh(); }, [refresh]); + const onExportExcel = useCallback(async () => { + if (role === null) return; + setExporting(true); + try { + const blob = await requestBlob( + usageExportPath(windowKey, agentFilter, adminUserFilter), + ); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = usageExportFilename( + t("tokenUsage.exportFilenamePrefix"), + windowKey, + ); + link.click(); + URL.revokeObjectURL(url); + } catch { + message.error(t("tokenUsage.exportFailed")); + } finally { + setExporting(false); + } + }, [windowKey, agentFilter, adminUserFilter, role, t]); + return ( )} - + diff --git a/src/octop/api/routers/usage.py b/src/octop/api/routers/usage.py index 46b00bff..0197316b 100644 --- a/src/octop/api/routers/usage.py +++ b/src/octop/api/routers/usage.py @@ -3,24 +3,41 @@ GET /api/usage/summary → caller's own roll-up GET /api/usage/summary?as_user=N → admin scope; another user's roll-up GET /api/usage/summary?agent_id=X → scope to one agent (must be visible) + GET /api/usage/export.xlsx → Excel detail for the same scope GET /api/admin/usage/summary → global roll-up (admin only) + GET /api/admin/usage/export.xlsx → Excel detail (admin only) Query params: - window = today | yesterday | last_7d | last_30d | all (default last_30d) + window = today | yesterday | last_7d | last_30d | all + | day:YYYY-MM-DD | month:YYYY-MM + | range:YYYY-MM-DD:YYYY-MM-DD (default last_30d) granularity = total | by_day | by_agent | by_model (default by_day) """ from __future__ import annotations +import re +from io import BytesIO from typing import Any, cast -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, Request +from fastapi.responses import StreamingResponse +from octop.api.common.content_disposition import content_disposition from octop.api.deps import current_user, get_server +from octop.i18n import tr from octop.infra.errors import ErrorCode, OctopError +from octop.infra.usage.xlsx_export import build_usage_xlsx +from octop.infra.utils.locale import normalize_locale, resolve_request_locale router = APIRouter() +_SAFE_FILENAME_RE = re.compile(r"[^A-Za-z0-9._-]+") +_XLSX_MEDIA = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" +_DAY_WINDOW_RE = re.compile(r"^day:(\d{4}-\d{2}-\d{2})$") +_MONTH_WINDOW_RE = re.compile(r"^month:(\d{4}-\d{2})$") +_RANGE_WINDOW_RE = re.compile(r"^range:(\d{4}-\d{2}-\d{2}):(\d{4}-\d{2}-\d{2})$") + def _resolve_user_scope( *, @@ -41,6 +58,139 @@ def _resolve_user_scope( return int(target.id) +def _server_timezone(server: Any) -> str: + config = getattr(server, "config", None) + tz = getattr(config, "default_timezone", None) if config is not None else None + return str(tz or "UTC") + + +def _summary_or_raise( + *, + server: Any, + user_id: int | None, + agent_id: str | None, + window: str, + granularity: str, +) -> dict[str, Any]: + try: + return cast( + "dict[str, Any]", + server.services.usage_repo.summary( + user_id=user_id, + agent_id=agent_id, + window=window, + granularity=granularity, + timezone=_server_timezone(server), + ), + ) + except ValueError as exc: + raise OctopError(ErrorCode.INTERNAL_ERROR, str(exc), status=400) from exc + + +def _assert_agent_exists(server: Any, agent_id: str | None) -> None: + if agent_id is None: + return + assert server.app_runtime is not None + if server.app_runtime.agent_registry.get_row(agent_id) is None: + raise OctopError(ErrorCode.AGENT_NOT_FOUND, f"agent {agent_id!r} not found") + + +def _agent_name_map(server: Any) -> dict[str, str]: + rows = server.services.agent_repo.list_all(include_disabled=True) + return {str(row.agent_id): str(row.name or row.agent_id) for row in rows} + + +def _username_map(server: Any) -> dict[int, str]: + out: dict[int, str] = {} + users = server.user_manager.list_all(include_disabled=True) + for user in users: + uid = getattr(user, "id", None) + if uid is None: + continue + name = getattr(user, "username", None) or getattr(user, "display_name", None) or "" + out[int(uid)] = str(name) + return out + + +def _export_filename(window: str, *, locale: str) -> str: + """``{title}_{YYYY-MM-DD-YYYY-MM-DD}.xlsx`` (no ``range`` / preset labels).""" + prefix = tr("usage_export.filename_prefix", normalize_locale(locale)) + range_match = _RANGE_WINDOW_RE.fullmatch(window) + if range_match is not None: + period = f"{range_match.group(1)}-{range_match.group(2)}" + else: + day_match = _DAY_WINDOW_RE.fullmatch(window) + if day_match is not None: + period = day_match.group(1) + else: + month_match = _MONTH_WINDOW_RE.fullmatch(window) + if month_match is not None: + period = month_match.group(1) + else: + period = _SAFE_FILENAME_RE.sub("_", window).strip("._") or "usage" + return f"{prefix}_{period}.xlsx" + + +def _export_response( + *, + request: Request, + server: Any, + user_id: int | None, + agent_id: str | None, + window: str, +) -> StreamingResponse: + timezone = _server_timezone(server) + locale = resolve_request_locale(request) + try: + rows = server.services.usage_repo.list_detail( + user_id=user_id, + agent_id=agent_id, + window=window, + timezone=timezone, + ) + except ValueError as exc: + raise OctopError(ErrorCode.INTERNAL_ERROR, str(exc), status=400) from exc + + by_day = _summary_or_raise( + server=server, + user_id=user_id, + agent_id=agent_id, + window=window, + granularity="by_day", + )["buckets"] + by_agent = _summary_or_raise( + server=server, + user_id=user_id, + agent_id=agent_id, + window=window, + granularity="by_agent", + )["buckets"] + by_model = _summary_or_raise( + server=server, + user_id=user_id, + agent_id=agent_id, + window=window, + granularity="by_model", + )["buckets"] + + payload = build_usage_xlsx( + rows=rows, + by_day=by_day, + by_agent=by_agent, + by_model=by_model, + agent_names=_agent_name_map(server), + usernames=_username_map(server), + timezone=timezone, + locale=locale, + ) + filename = _export_filename(window, locale=locale) + return StreamingResponse( + BytesIO(payload), + media_type=_XLSX_MEDIA, + headers={"Content-Disposition": content_disposition(filename)}, + ) + + @router.get("/usage/summary") async def user_summary( window: str = "last_30d", @@ -51,19 +201,37 @@ async def user_summary( server: Any = Depends(get_server), ) -> dict[str, Any]: user_id = _resolve_user_scope(user=user, as_user=as_user, server=server) - if agent_id is not None: - # Verify the agent exists in the registry - assert server.app_runtime is not None - if server.app_runtime.agent_registry.get_row(agent_id) is None: - raise OctopError(ErrorCode.AGENT_NOT_FOUND, f"agent {agent_id!r} not found") - return cast( - "dict[str, Any]", - server.services.usage_repo.summary( - user_id=user_id, - agent_id=agent_id, - window=window, - granularity=granularity, - ), + _assert_agent_exists(server, agent_id) + return _summary_or_raise( + server=server, + user_id=user_id, + agent_id=agent_id, + window=window, + granularity=granularity, + ) + + +@router.get( + "/usage/export.xlsx", + summary="Export usage_log detail as Excel", + response_class=StreamingResponse, +) +async def user_export( + request: Request, + window: str = "last_30d", + agent_id: str | None = None, + as_user: int | None = None, + user: Any = Depends(current_user), + server: Any = Depends(get_server), +) -> StreamingResponse: + user_id = _resolve_user_scope(user=user, as_user=as_user, server=server) + _assert_agent_exists(server, agent_id) + return _export_response( + request=request, + server=server, + user_id=user_id, + agent_id=agent_id, + window=window, ) @@ -85,12 +253,34 @@ async def admin_summary( the scope; absent both fields mean *all rows*. Admin only.""" if not user.is_admin: raise OctopError(ErrorCode.FORBIDDEN, "admin required") - return cast( - "dict[str, Any]", - server.services.usage_repo.summary( - user_id=user_id, - agent_id=agent_id, - window=window, - granularity=granularity, - ), + return _summary_or_raise( + server=server, + user_id=user_id, + agent_id=agent_id, + window=window, + granularity=granularity, + ) + + +@admin_router.get( + "/usage/export.xlsx", + summary="Export global usage_log detail as Excel", + response_class=StreamingResponse, +) +async def admin_export( + request: Request, + window: str = "last_30d", + user_id: int | None = None, + agent_id: str | None = None, + user: Any = Depends(current_user), + server: Any = Depends(get_server), +) -> StreamingResponse: + if not user.is_admin: + raise OctopError(ErrorCode.FORBIDDEN, "admin required") + return _export_response( + request=request, + server=server, + user_id=user_id, + agent_id=agent_id, + window=window, ) diff --git a/src/octop/i18n/en.json b/src/octop/i18n/en.json index bfc59a0a..0dc8bb0e 100644 --- a/src/octop/i18n/en.json +++ b/src/octop/i18n/en.json @@ -649,5 +649,36 @@ "docker_restart_failed": "Failed to restart Docker (no systemd/service manager, or the service did not come back). The mirror config takes effect after the next Docker restart — start it manually if needed (systemctl restart docker or service docker restart).", "handoff_message": "Please complete this step on the device: {reason}", "handoff_default": "manual action required on the device" + }, + "usage_export": { + "sheet_detail": "Detail", + "sheet_by_day": "By day", + "sheet_by_agent": "By expert", + "sheet_by_model": "By model", + "chart_daily": "Daily tokens", + "chart_by_agent": "Tokens by expert", + "chart_by_model": "Tokens by model", + "col_time": "Time", + "col_user_id": "User ID", + "col_username": "Username", + "col_agent_id": "Expert ID", + "col_agent_name": "Expert name", + "col_thread_id": "Thread ID", + "col_model": "Model", + "col_input_tokens": "Input tokens", + "col_uncached_input_tokens": "Uncached input", + "col_cache_read_tokens": "Cache read", + "col_cache_write_tokens": "Cache write", + "col_output_tokens": "Output tokens", + "col_reasoning_tokens": "Reasoning tokens", + "col_total_tokens": "Total tokens", + "col_model_calls": "Model calls", + "col_source": "Source", + "col_date": "Date", + "col_turns": "Turns", + "col_key": "Key", + "row_total": "Total", + "axis_tokens": "Tokens", + "filename_prefix": "token-usage" } } diff --git a/src/octop/i18n/zh.json b/src/octop/i18n/zh.json index 4fbaf172..b6b18eff 100644 --- a/src/octop/i18n/zh.json +++ b/src/octop/i18n/zh.json @@ -649,5 +649,36 @@ "docker_restart_failed": "Docker 重启失败(可能无 systemd/service 管理器,或服务未能恢复)。镜像加速配置将在下次 Docker 重启后生效——必要时请手动启动(systemctl restart docker 或 service docker restart)。", "handoff_message": "请在设备上完成此步骤:{reason}", "handoff_default": "需要在设备上手动操作" + }, + "usage_export": { + "sheet_detail": "明细", + "sheet_by_day": "按天", + "sheet_by_agent": "按专家", + "sheet_by_model": "按模型", + "chart_daily": "每日 Token", + "chart_by_agent": "按专家 Token", + "chart_by_model": "按模型 Token", + "col_time": "时间", + "col_user_id": "用户 ID", + "col_username": "用户名", + "col_agent_id": "专家 ID", + "col_agent_name": "专家名称", + "col_thread_id": "会话 ID", + "col_model": "模型", + "col_input_tokens": "输入 tokens", + "col_uncached_input_tokens": "非缓存输入", + "col_cache_read_tokens": "缓存读取", + "col_cache_write_tokens": "缓存写入", + "col_output_tokens": "输出 tokens", + "col_reasoning_tokens": "推理 tokens", + "col_total_tokens": "总 tokens", + "col_model_calls": "模型调用次数", + "col_source": "来源", + "col_date": "日期", + "col_turns": "轮次", + "col_key": "键", + "row_total": "合计", + "axis_tokens": "Token 数", + "filename_prefix": "Token用量" } } diff --git a/src/octop/infra/db/repos/usage.py b/src/octop/infra/db/repos/usage.py index 5f568926..037247b8 100644 --- a/src/octop/infra/db/repos/usage.py +++ b/src/octop/infra/db/repos/usage.py @@ -2,9 +2,12 @@ from __future__ import annotations +import re import time from dataclasses import dataclass +from datetime import datetime, timedelta from typing import Any +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from octop.infra.db.pool import DatabasePool from octop.infra.db.repos._base import ( @@ -14,6 +17,12 @@ sql_unix_day_bucket, ) +_DAY_S = 86_400 +_DAY_WINDOW_RE = re.compile(r"^day:(\d{4}-\d{2}-\d{2})$") +_MONTH_WINDOW_RE = re.compile(r"^month:(\d{4}-\d{2})$") +_RANGE_WINDOW_RE = re.compile(r"^range:(\d{4}-\d{2}-\d{2}):(\d{4}-\d{2}-\d{2})$") +DETAIL_EXPORT_LIMIT = 50_000 + @dataclass(frozen=True) class UsageRow: @@ -54,32 +63,77 @@ def from_row(cls, r: DbRow) -> UsageRow: ) -# Time-window aliases used by the API. Each maps to ``(start_seconds, end_seconds)`` -# tuples computed against ``time.time()`` at query time. Buckets that span -# calendar months/days respect the server's local timezone — finnie's API -# also accepts a ``tz`` override but for octop's MVP the server's TZ is the -# only reference; that's fine for self-hosted single-tenant deploys. -_DAY_S = 86_400 +def _zoneinfo(timezone: str) -> ZoneInfo: + try: + return ZoneInfo(timezone) + except ZoneInfoNotFoundError: + return ZoneInfo("UTC") + + +def resolve_usage_window( + window: str, + *, + timezone: str = "UTC", + now: int | None = None, +) -> tuple[int, int]: + """Map a window alias to ``[start, end)`` unix seconds in *timezone*. + + Supported: + today | yesterday | last_7d | last_30d | all + day:YYYY-MM-DD | month:YYYY-MM + range:YYYY-MM-DD:YYYY-MM-DD (inclusive calendar days) + """ + tz = _zoneinfo(timezone) + now_ts_val = int(time.time() if now is None else now) + now_dt = datetime.fromtimestamp(now_ts_val, tz=tz) + end_open = now_ts_val + 1 + day_match = _DAY_WINDOW_RE.fullmatch(window) + if day_match is not None: + day = datetime.strptime(day_match.group(1), "%Y-%m-%d").date() + start_dt = datetime(day.year, day.month, day.day, tzinfo=tz) + end_dt = start_dt + timedelta(days=1) + return int(start_dt.timestamp()), int(end_dt.timestamp()) + + month_match = _MONTH_WINDOW_RE.fullmatch(window) + if month_match is not None: + year_s, month_s = month_match.group(1).split("-") + year, month = int(year_s), int(month_s) + start_dt = datetime(year, month, 1, tzinfo=tz) + if month == 12: + end_dt = datetime(year + 1, 1, 1, tzinfo=tz) + else: + end_dt = datetime(year, month + 1, 1, tzinfo=tz) + return int(start_dt.timestamp()), int(end_dt.timestamp()) + + range_match = _RANGE_WINDOW_RE.fullmatch(window) + if range_match is not None: + start_day = datetime.strptime(range_match.group(1), "%Y-%m-%d").date() + end_day = datetime.strptime(range_match.group(2), "%Y-%m-%d").date() + if end_day < start_day: + raise ValueError(f"invalid usage window: {window!r}") + start_dt = datetime(start_day.year, start_day.month, start_day.day, tzinfo=tz) + end_dt = datetime(end_day.year, end_day.month, end_day.day, tzinfo=tz) + timedelta(days=1) + return int(start_dt.timestamp()), int(end_dt.timestamp()) + + if window.startswith("day:") or window.startswith("month:") or window.startswith("range:"): + raise ValueError(f"invalid usage window: {window!r}") -def _resolve_window(window: str) -> tuple[int, int]: - now = int(time.time()) - end = now + 1 if window == "today": - # Midnight today (server local TZ) - start = now - (now % _DAY_S) - return start, end + start_dt = now_dt.replace(hour=0, minute=0, second=0, microsecond=0) + return int(start_dt.timestamp()), end_open if window == "yesterday": - start_today = now - (now % _DAY_S) - return start_today - _DAY_S, start_today + today_start = now_dt.replace(hour=0, minute=0, second=0, microsecond=0) + start_dt = today_start - timedelta(days=1) + return int(start_dt.timestamp()), int(today_start.timestamp()) if window == "last_7d": - return now - 7 * _DAY_S, end + return now_ts_val - 7 * _DAY_S, end_open if window == "last_30d": - return now - 30 * _DAY_S, end + return now_ts_val - 30 * _DAY_S, end_open if window == "all": - return 0, end + return 0, end_open # Default: last_30d - return now - 30 * _DAY_S, end + return now_ts_val - 30 * _DAY_S, end_open class UsageRepo: @@ -139,6 +193,25 @@ def record( # --- read / aggregate ------------------------------------------------ + def _scope_filter( + self, + *, + user_id: int | None, + agent_id: str | None, + window: str, + timezone: str, + ) -> tuple[str, list[Any], int, int]: + start, end = resolve_usage_window(window, timezone=timezone) + where: list[str] = ["ts >= ?", "ts < ?"] + params: list[Any] = [start, end] + if user_id is not None: + where.append("user_id = ?") + params.append(user_id) + if agent_id is not None: + where.append("agent_id = ?") + params.append(agent_id) + return " AND ".join(where), params, start, end + def summary( self, *, @@ -146,23 +219,19 @@ def summary( agent_id: str | None = None, window: str = "last_30d", granularity: str = "by_day", + timezone: str = "UTC", ) -> dict[str, Any]: """Aggregate usage rows, optionally filtered to one user/agent. ``user_id=None`` and ``agent_id=None`` returns global totals (admin scope); otherwise rows are scoped accordingly. """ - start, end = _resolve_window(window) - - where: list[str] = ["ts >= ?", "ts < ?"] - params: list[Any] = [start, end] - if user_id is not None: - where.append("user_id = ?") - params.append(user_id) - if agent_id is not None: - where.append("agent_id = ?") - params.append(agent_id) - where_sql = " AND ".join(where) + where_sql, params, start, end = self._scope_filter( + user_id=user_id, + agent_id=agent_id, + window=window, + timezone=timezone, + ) # Roll-up totals with self._db.connect() as conn: @@ -326,6 +395,54 @@ def summary( "buckets": buckets, } + def list_detail( + self, + *, + user_id: int | None = None, + agent_id: str | None = None, + window: str = "last_30d", + timezone: str = "UTC", + limit: int = DETAIL_EXPORT_LIMIT, + ) -> list[UsageRow]: + """Return usage_log rows for Excel export (oldest → newest). + + When ``limit`` truncates, keep the *newest* ``limit`` rows, then + return them in ascending time order for the sheet. + """ + where_sql, params, _start, _end = self._scope_filter( + user_id=user_id, + agent_id=agent_id, + window=window, + timezone=timezone, + ) + cap = max(1, min(int(limit), DETAIL_EXPORT_LIMIT)) + with self._db.connect() as conn: + rows = conn.execute( + f""" + SELECT + id, ts, agent_id, user_id, thread_id, model, + input_tokens, uncached_input_tokens, + cache_read_tokens, cache_write_tokens, + output_tokens, reasoning_tokens, total_tokens, + model_calls, source + FROM ( + SELECT + id, ts, agent_id, user_id, thread_id, model, + input_tokens, uncached_input_tokens, + cache_read_tokens, cache_write_tokens, + output_tokens, reasoning_tokens, total_tokens, + model_calls, source + FROM usage_log + WHERE {where_sql} + ORDER BY ts DESC, id DESC + LIMIT ? + ) AS recent + ORDER BY ts ASC, id ASC + """, + [*params, cap], + ).fetchall() + return [UsageRow.from_row(r) for r in rows] + def thread_totals(self, *, agent_id: str, thread_id: str) -> dict[str, int]: """Aggregate token usage for a single thread.""" with self._db.connect() as conn: diff --git a/src/octop/infra/usage/__init__.py b/src/octop/infra/usage/__init__.py new file mode 100644 index 00000000..fd4b3ed5 --- /dev/null +++ b/src/octop/infra/usage/__init__.py @@ -0,0 +1 @@ +"""Token usage package — export helpers live beside ledger access.""" diff --git a/src/octop/infra/usage/xlsx_export.py b/src/octop/infra/usage/xlsx_export.py new file mode 100644 index 00000000..14d13ce5 --- /dev/null +++ b/src/octop/infra/usage/xlsx_export.py @@ -0,0 +1,563 @@ +"""Build localized Excel workbooks for token usage export. + +Formatting follows the MiniMax XLSX skill conventions adapted for openpyxl +(runtime API export): bold headers, thousands separators, freeze panes, +auto-filter, TOTAL rows as Excel formulas (blank spacer above so Excel +Sort/Filter does not move them), and readable IO charts. +""" + +from __future__ import annotations + +from datetime import datetime +from io import BytesIO +from typing import Any +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + +from octop.i18n import tr +from octop.infra.db.repos.usage import UsageRow +from octop.infra.utils.locale import Locale, normalize_locale + +# Match Token Usage dashboard IO colors. +_COLOR_INPUT = "4F6EF7" +_COLOR_OUTPUT = "A06EF7" +_HEADER_FILL = "F3F4F6" +_ZEBRA_FILL = "FAFAFB" +_INT_FORMAT = "#,##0" + + +def _zoneinfo(timezone: str) -> ZoneInfo: + try: + return ZoneInfo(timezone) + except ZoneInfoNotFoundError: + return ZoneInfo("UTC") + + +def _label(locale: Locale, key: str) -> str: + return tr(f"usage_export.{key}", locale) + + +def _font(*, bold: bool = False, size: int = 11, color: str | None = None) -> Any: + from openpyxl.styles import Font + + kwargs: dict[str, Any] = {"bold": bold, "size": size, "name": "Calibri"} + if color is not None: + kwargs["color"] = color + return Font(**kwargs) + + +def _fill(rgb: str) -> Any: + from openpyxl.styles import PatternFill + + return PatternFill(fill_type="solid", fgColor=rgb) + + +def _thin_border() -> Any: + from openpyxl.styles import Border, Side + + side = Side(style="thin", color="D1D5DB") + return Border(left=side, right=side, top=side, bottom=side) + + +def _top_medium_border() -> Any: + from openpyxl.styles import Border, Side + + thin = Side(style="thin", color="D1D5DB") + medium = Side(style="medium", color="6B7280") + return Border(left=thin, right=thin, top=medium, bottom=thin) + + +def _style_header_row(ws: Any, ncols: int) -> None: + from openpyxl.styles import Alignment + + header_font = _font(bold=True, size=11) + header_fill = _fill(_HEADER_FILL) + border = _thin_border() + for col in range(1, ncols + 1): + cell = ws.cell(1, col) + cell.font = header_font + cell.fill = header_fill + cell.border = border + cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True) + ws.row_dimensions[1].height = 22 + ws.freeze_panes = "A2" + + +def _style_data_cells( + ws: Any, + *, + start_row: int, + end_row: int, + ncols: int, + int_cols: set[int], +) -> None: + from openpyxl.styles import Alignment + + border = _thin_border() + zebra = _fill(_ZEBRA_FILL) + body_font = _font() + for row in range(start_row, end_row + 1): + for col in range(1, ncols + 1): + cell = ws.cell(row, col) + cell.font = body_font + cell.border = border + if row % 2 == 0: + cell.fill = zebra + if col in int_cols and isinstance(cell.value, (int, float)): + cell.number_format = _INT_FORMAT + cell.alignment = Alignment(horizontal="right") + + +def _append_total_row( + ws: Any, + *, + locale: Locale, + label_col: int, + sum_cols: list[int], + data_start: int, + data_end: int, + ncols: int, +) -> int: + """Append a TOTAL row with SUM formulas. Returns the total row index. + + Leaves one blank row between the last data row and TOTAL so Excel's + Sort / AutoFilter contiguous-region detection does not pull TOTAL + into the sortable block. + """ + from openpyxl.styles import Alignment + from openpyxl.utils import get_column_letter + + # Blank spacer: data_end + 1 stays empty on purpose. + total_row = data_end + 2 + for col in range(1, ncols + 1): + cell = ws.cell(total_row, col) + cell.font = _font(bold=True) + cell.border = _top_medium_border() + if col == label_col: + cell.value = _label(locale, "row_total") + cell.alignment = Alignment(horizontal="left") + elif col in sum_cols and data_end >= data_start: + letter = get_column_letter(col) + cell.value = f"=SUM({letter}{data_start}:{letter}{data_end})" + cell.number_format = _INT_FORMAT + cell.alignment = Alignment(horizontal="right") + return total_row + + +def _autosize_columns(ws: Any, ncols: int, *, max_width: int = 36) -> None: + from openpyxl.utils import get_column_letter + + for col in range(1, ncols + 1): + width = 10 + for row in ws.iter_rows(min_col=col, max_col=col, max_row=min(ws.max_row, 200)): + value = row[0].value + if value is None: + continue + width = max(width, min(max_width, len(str(value)) + 2)) + ws.column_dimensions[get_column_letter(col)].width = width + + +def _format_local_time(ts: int, *, tz: ZoneInfo) -> str: + """Format unix seconds as ``YYYY-MM-DD HH:mm:ss`` in *tz* (no offset suffix).""" + return datetime.fromtimestamp(ts, tz=tz).strftime("%Y-%m-%d %H:%M:%S") + + +def _hide_major_gridlines(axis: Any) -> None: + """Hide major gridlines (Excel still draws defaults if the element is omitted).""" + from openpyxl.chart.axis import ChartLines + from openpyxl.chart.shapes import GraphicalProperties + from openpyxl.drawing.line import LineProperties + + axis.majorGridlines = ChartLines() + axis.majorGridlines.spPr = GraphicalProperties(ln=LineProperties(noFill=True)) + + +def _soft_axis_line(axis: Any) -> None: + from openpyxl.chart.shapes import GraphicalProperties + from openpyxl.drawing.line import LineProperties + + axis.spPr = GraphicalProperties(ln=LineProperties(solidFill="9CA3AF", w=9000)) + + +def _polish_category_axis(axis: Any) -> None: + axis.axPos = "b" + axis.delete = False + axis.majorTickMark = "out" + axis.minorTickMark = "none" + axis.tickLblPos = "nextTo" + axis.majorGridlines = None + _soft_axis_line(axis) + + +def _polish_value_axis(axis: Any, *, title: str | None = None) -> None: + axis.axPos = "l" + axis.delete = False + axis.numFmt = _INT_FORMAT + axis.majorTickMark = "out" + axis.minorTickMark = "none" + axis.tickLblPos = "nextTo" + _hide_major_gridlines(axis) + _soft_axis_line(axis) + if title: + axis.title = title + + +def _styled_chart_title(text: str) -> Any: + """Build a larger chart title with a reserved top band. + + Excel chart-title paragraph centering is unreliable; keep a readable + native title (size/bold) without sheet-cell workarounds. + """ + from openpyxl.chart.layout import Layout, ManualLayout + from openpyxl.chart.title import Title + from openpyxl.drawing.text import ( + CharacterProperties, + Paragraph, + ParagraphProperties, + RegularTextRun, + RichTextProperties, + ) + + title = Title() + title.tx.rich.bodyPr = RichTextProperties(anchor="ctr", anchorCtr=True) + run_props = CharacterProperties(sz=1800, b=True) # 18pt bold + para_props = ParagraphProperties(algn="ctr", defRPr=run_props) + title.tx.rich.paragraphs = [ + Paragraph(pPr=para_props, r=[RegularTextRun(t=text, rPr=run_props)]) + ] + title.overlay = False + title.layout = Layout( + manualLayout=ManualLayout( + xMode="edge", + yMode="edge", + x=0.05, + y=0.01, + w=0.9, + h=0.12, + ) + ) + return title + + +def _place_chart_chrome(chart: Any, *, title: str) -> None: + """Put the title in a reserved top band; keep legend outside the plot.""" + from openpyxl.chart.layout import Layout, ManualLayout + + chart.title = _styled_chart_title(title) + if chart.legend is not None: + chart.legend.overlay = False + chart.layout = Layout( + manualLayout=ManualLayout( + xMode="edge", + yMode="edge", + x=0.08, + y=0.16, + w=0.72, + h=0.74, + ) + ) + + +def _add_io_chart( + ws: Any, + *, + title: str, + categories_col: int, + data_min_col: int, + data_max_col: int, + n_rows: int, + anchor: str, + kind: str, + value_axis_title: str | None = None, +) -> None: + """Render input/output comparison charts. + + ``kind``: + - ``line`` — daily trend + - ``col`` — vertical clustered columns (experts / models) + + Legend sits on the right so category labels stay visible; gridlines are + suppressed; axes use explicit tick marks and number formats. The title + sits in a reserved top band (``overlay=False`` + plot layout gap). + """ + if n_rows < 1: + return + from openpyxl.chart import BarChart, LineChart, Reference + from openpyxl.chart.marker import Marker + + data = Reference( + ws, + min_col=data_min_col, + min_row=1, + max_col=data_max_col, + max_row=n_rows + 1, + ) + cats = Reference(ws, min_col=categories_col, min_row=2, max_row=n_rows + 1) + palette = (_COLOR_INPUT, _COLOR_OUTPUT) + + if kind == "line": + chart: Any = LineChart() + chart.legend.position = "r" + chart.add_data(data, titles_from_data=True) + chart.set_categories(cats) + for idx, series in enumerate(chart.series): + color = palette[idx % len(palette)] + series.graphicalProperties.line.solidFill = color + series.graphicalProperties.line.width = 25000 + series.marker = Marker(symbol="circle", size=5) + series.marker.graphicalProperties.solidFill = color + series.marker.graphicalProperties.line.solidFill = color + else: + chart = BarChart() + chart.type = "col" + chart.grouping = "clustered" + chart.overlap = 0 + chart.gapWidth = 80 if n_rows <= 4 else 140 + chart.legend.position = "r" + chart.add_data(data, titles_from_data=True) + chart.set_categories(cats) + for idx, series in enumerate(chart.series): + series.graphicalProperties.solidFill = palette[idx % len(palette)] + + _place_chart_chrome(chart, title=title) + _polish_category_axis(chart.x_axis) + _polish_value_axis(chart.y_axis, title=value_axis_title) + chart.width = 18 + chart.height = max(11, min(15, 9 + n_rows * 0.35)) + ws.add_chart(chart, anchor) + + +def build_usage_xlsx( + *, + rows: list[UsageRow], + by_day: list[dict[str, Any]], + by_agent: list[dict[str, Any]], + by_model: list[dict[str, Any]], + agent_names: dict[str, str], + usernames: dict[int, str], + timezone: str, + locale: str, +) -> bytes: + """Build a multi-sheet workbook: detail + by-day/agent/model with charts.""" + from openpyxl import Workbook + + loc = normalize_locale(locale) + tz = _zoneinfo(timezone) + wb = Workbook() + + # --- Detail --- + detail = wb.active + detail.title = _label(loc, "sheet_detail") + detail_headers = [ + f"{_label(loc, 'col_time')} ({timezone})", + _label(loc, "col_user_id"), + _label(loc, "col_username"), + _label(loc, "col_agent_id"), + _label(loc, "col_agent_name"), + _label(loc, "col_thread_id"), + _label(loc, "col_model"), + _label(loc, "col_input_tokens"), + _label(loc, "col_uncached_input_tokens"), + _label(loc, "col_cache_read_tokens"), + _label(loc, "col_cache_write_tokens"), + _label(loc, "col_output_tokens"), + _label(loc, "col_reasoning_tokens"), + _label(loc, "col_total_tokens"), + _label(loc, "col_model_calls"), + _label(loc, "col_source"), + ] + detail.append(detail_headers) + detail_int_cols = {8, 9, 10, 11, 12, 13, 14, 15} + for row in rows: + detail.append( + [ + _format_local_time(row.ts, tz=tz), + row.user_id, + usernames.get(row.user_id, ""), + row.agent_id, + agent_names.get(row.agent_id, row.agent_id), + row.thread_id, + row.model, + row.input_tokens, + row.uncached_input_tokens, + row.cache_read_tokens, + row.cache_write_tokens, + row.output_tokens, + row.reasoning_tokens, + row.total_tokens, + row.model_calls, + row.source, + ] + ) + ncols = len(detail_headers) + _style_header_row(detail, ncols) + if rows: + _style_data_cells( + detail, + start_row=2, + end_row=1 + len(rows), + ncols=ncols, + int_cols=detail_int_cols, + ) + _append_total_row( + detail, + locale=loc, + label_col=1, + sum_cols=[8, 9, 10, 11, 12, 13, 14, 15], + data_start=2, + data_end=1 + len(rows), + ncols=ncols, + ) + from openpyxl.utils import get_column_letter + + last_data_row = max(1, 1 + len(rows)) + detail.auto_filter.ref = f"A1:{get_column_letter(ncols)}{last_data_row}" + _autosize_columns(detail, ncols) + + def _write_category_sheet( + *, + title_key: str, + chart_title_key: str, + headers: list[str], + records: list[list[Any]], + label_col: int, + sum_cols: list[int], + chart_cat_col: int, + chart_data_min: int, + chart_data_max: int, + chart_anchor: str, + chart_kind: str, + ) -> None: + sheet = wb.create_sheet(_label(loc, title_key)) + sheet.append(headers) + for record in records: + sheet.append(record) + n = len(records) + ncols_local = len(headers) + int_cols = set(sum_cols) + _style_header_row(sheet, ncols_local) + if n: + _style_data_cells( + sheet, + start_row=2, + end_row=1 + n, + ncols=ncols_local, + int_cols=int_cols, + ) + _append_total_row( + sheet, + locale=loc, + label_col=label_col, + sum_cols=sum_cols, + data_start=2, + data_end=1 + n, + ncols=ncols_local, + ) + _autosize_columns(sheet, ncols_local) + _add_io_chart( + sheet, + title=_label(loc, chart_title_key), + categories_col=chart_cat_col, + data_min_col=chart_data_min, + data_max_col=chart_data_max, + n_rows=n, + anchor=chart_anchor, + kind=chart_kind, + value_axis_title=_label(loc, "axis_tokens"), + ) + + # --- By day (ascending for chart) --- + day_rows = list(reversed(by_day)) + _write_category_sheet( + title_key="sheet_by_day", + chart_title_key="chart_daily", + headers=[ + _label(loc, "col_date"), + _label(loc, "col_input_tokens"), + _label(loc, "col_output_tokens"), + _label(loc, "col_total_tokens"), + _label(loc, "col_turns"), + ], + records=[ + [ + str(bucket.get("label") or bucket.get("key") or ""), + int(bucket.get("input_tokens") or 0), + int(bucket.get("output_tokens") or 0), + int(bucket.get("total_tokens") or 0), + int(bucket.get("turns") or 0), + ] + for bucket in day_rows + ], + label_col=1, + sum_cols=[2, 3, 4, 5], + chart_cat_col=1, + chart_data_min=2, + chart_data_max=3, + chart_anchor="G3", + chart_kind="line", + ) + + # --- By agent --- + _write_category_sheet( + title_key="sheet_by_agent", + chart_title_key="chart_by_agent", + headers=[ + _label(loc, "col_agent_name"), + _label(loc, "col_agent_id"), + _label(loc, "col_input_tokens"), + _label(loc, "col_output_tokens"), + _label(loc, "col_total_tokens"), + _label(loc, "col_turns"), + ], + records=[ + [ + agent_names.get(str(bucket.get("key") or ""), str(bucket.get("key") or "")), + str(bucket.get("key") or ""), + int(bucket.get("input_tokens") or 0), + int(bucket.get("output_tokens") or 0), + int(bucket.get("total_tokens") or 0), + int(bucket.get("turns") or 0), + ] + for bucket in by_agent + ], + label_col=1, + sum_cols=[3, 4, 5, 6], + chart_cat_col=1, + chart_data_min=3, + chart_data_max=4, + chart_anchor="H3", + chart_kind="col", + ) + + # --- By model --- + _write_category_sheet( + title_key="sheet_by_model", + chart_title_key="chart_by_model", + headers=[ + _label(loc, "col_model"), + _label(loc, "col_input_tokens"), + _label(loc, "col_output_tokens"), + _label(loc, "col_total_tokens"), + _label(loc, "col_turns"), + ], + records=[ + [ + str(bucket.get("label") or bucket.get("key") or ""), + int(bucket.get("input_tokens") or 0), + int(bucket.get("output_tokens") or 0), + int(bucket.get("total_tokens") or 0), + int(bucket.get("turns") or 0), + ] + for bucket in by_model + ], + label_col=1, + sum_cols=[2, 3, 4, 5], + chart_cat_col=1, + chart_data_min=2, + chart_data_max=3, + chart_anchor="G3", + chart_kind="col", + ) + + buf = BytesIO() + wb.save(buf) + return buf.getvalue() diff --git a/tests/integration/test_usage_api.py b/tests/integration/test_usage_api.py index d20c3414..6cb4df02 100644 --- a/tests/integration/test_usage_api.py +++ b/tests/integration/test_usage_api.py @@ -178,6 +178,361 @@ async def test_admin_summary_requires_admin(env: Any) -> None: assert r.status_code == 403 +async def test_summary_day_window_filters_rows(env: Any) -> None: + from datetime import datetime + from zoneinfo import ZoneInfo + + c, srv, _admin_auth, alice_auth, ctx = env + repo = srv.services.usage_repo + tz = ZoneInfo("Asia/Shanghai") + day_ts = int(datetime(2026, 9, 7, 12, 0, tzinfo=tz).timestamp()) + other_ts = int(datetime(2026, 9, 8, 12, 0, tzinfo=tz).timestamp()) + repo.record( + agent_id="y", + user_id=ctx["alice_id"], + input_tokens=10, + output_tokens=5, + ts=day_ts, + ) + repo.record( + agent_id="y", + user_id=ctx["alice_id"], + input_tokens=100, + output_tokens=50, + ts=other_ts, + ) + r = await c.get( + "/api/usage/summary?granularity=total&window=day:2026-09-07", + headers=alice_auth, + ) + assert r.status_code == 200 + assert r.json()["total_tokens"] == 15 + assert r.json()["turns"] == 1 + + +async def test_user_export_xlsx(env: Any) -> None: + from io import BytesIO + + from openpyxl import load_workbook + + c, srv, _admin_auth, alice_auth, ctx = env + repo = srv.services.usage_repo + # Give the seeded agent a display name for the Excel column. + with srv.services.db.connect() as conn: + conn.execute( + "UPDATE agents SET name = ? WHERE agent_id = ?", + ("Export Expert", "y"), + ) + repo.record( + agent_id="y", + user_id=ctx["alice_id"], + thread_id="t-export", + model="openai:gpt-4o-mini", + input_tokens=11, + output_tokens=7, + ts=1_700_000_100, + ) + repo.record( + agent_id="y", + user_id=ctx["alice_id"], + thread_id="t-export", + model="openai:gpt-4o-mini", + input_tokens=3, + output_tokens=1, + ts=1_700_000_000, + ) + r = await c.get( + "/api/usage/export.xlsx?window=all", + headers={**alice_auth, "Accept-Language": "zh"}, + ) + assert r.status_code == 200 + assert "spreadsheetml" in r.headers.get("content-type", "") + disposition = r.headers.get("content-disposition", "") + assert "filename*" in disposition + assert "_all.xlsx" in disposition + wb = load_workbook(BytesIO(r.content)) + assert "明细" in wb.sheetnames + assert "按天" in wb.sheetnames + assert "按专家" in wb.sheetnames + assert "按模型" in wb.sheetnames + detail = wb["明细"] + headers = [cell.value for cell in detail[1]] + assert "专家名称" in headers + name_col = headers.index("专家名称") + 1 + assert any(row[name_col - 1].value == "Export Expert" for row in detail.iter_rows(min_row=2)) + time_col = headers.index(next(h for h in headers if h and h.startswith("时间"))) + 1 + # TOTAL sits two rows below the last data row (blank spacer in between) + # so Excel Sort does not treat 合计 as part of the contiguous data block. + assert detail.cell(detail.max_row, 1).value == "合计" + assert all(detail.cell(detail.max_row - 1, col).value is None for col in range(1, 5)) + assert detail.auto_filter.ref == f"A1:P{detail.max_row - 2}" + time_vals = [ + row[time_col - 1].value + for row in detail.iter_rows(min_row=2, max_row=detail.max_row - 2) + if row[time_col - 1].value + ] + assert len(time_vals) >= 2 + assert time_vals == sorted(time_vals) + assert time_vals[0] < time_vals[-1] + assert all( + isinstance(v, str) and len(v) == 19 and v[4] == "-" and v[10] == " " and "+" not in v + for v in time_vals + ) + assert wb["按天"]._charts + assert wb["按专家"]._charts + assert wb["按模型"]._charts + + +async def test_admin_export_requires_admin(env: Any) -> None: + c, _srv, _admin_auth, alice_auth, _ctx = env + r = await c.get("/api/admin/usage/export.xlsx", headers=alice_auth) + assert r.status_code == 403 + + +async def test_invalid_day_window_returns_400(env: Any) -> None: + c, _srv, _admin_auth, alice_auth, _ctx = env + r = await c.get( + "/api/usage/summary?window=day:2026-9-7", + headers=alice_auth, + ) + assert r.status_code == 400 + + +async def test_admin_filters_by_user_agent_and_windows(env: Any) -> None: + """Admin user/agent/window filters must isolate seeded multi-user rows.""" + from datetime import datetime + from io import BytesIO + from zoneinfo import ZoneInfo + + from openpyxl import load_workbook + + from tests.support.auth import create_user, resolve_user_id + + c, srv, admin_auth, alice_auth, ctx = env + bob_auth = await create_user(c, admin_auth, username="usage_bob") + bob_id = await resolve_user_id(c, admin_auth, "usage_bob") + alice_id = ctx["alice_id"] + _seed_usage_agents(srv, ["agt-alice", "agt-bob"], user_id=alice_id) + + repo = srv.services.usage_repo + tz = ZoneInfo("Asia/Shanghai") + day_a = int(datetime(2026, 8, 20, 12, 0, tzinfo=tz).timestamp()) + day_b = int(datetime(2026, 8, 25, 12, 0, tzinfo=tz).timestamp()) + day_c = int(datetime(2026, 9, 3, 12, 0, tzinfo=tz).timestamp()) + + repo.record( + agent_id="agt-alice", + user_id=alice_id, + model="model-a", + input_tokens=100, + output_tokens=10, + ts=day_a, + ) + repo.record( + agent_id="agt-alice", + user_id=alice_id, + model="model-a", + input_tokens=200, + output_tokens=20, + ts=day_b, + ) + repo.record( + agent_id="agt-bob", + user_id=bob_id, + model="model-b", + input_tokens=50, + output_tokens=5, + ts=day_b, + ) + repo.record( + agent_id="agt-bob", + user_id=bob_id, + model="model-b", + input_tokens=1000, + output_tokens=100, + ts=day_c, + ) + + r = await c.get( + "/api/admin/usage/summary?granularity=total&window=all", + headers=admin_auth, + ) + assert r.status_code == 200 + assert r.json()["total_tokens"] == 100 + 10 + 200 + 20 + 50 + 5 + 1000 + 100 + assert r.json()["turns"] == 4 + + r = await c.get( + f"/api/admin/usage/summary?granularity=total&window=all&user_id={alice_id}", + headers=admin_auth, + ) + assert r.status_code == 200 + assert r.json()["total_tokens"] == 330 + assert r.json()["turns"] == 2 + + r = await c.get( + f"/api/admin/usage/summary?granularity=total&window=all&user_id={bob_id}", + headers=admin_auth, + ) + assert r.status_code == 200 + assert r.json()["total_tokens"] == 1155 + assert r.json()["turns"] == 2 + + r = await c.get( + "/api/admin/usage/summary?granularity=total&window=all&agent_id=agt-bob", + headers=admin_auth, + ) + assert r.status_code == 200 + assert r.json()["total_tokens"] == 1155 + + r = await c.get( + f"/api/admin/usage/summary?granularity=total&window=all" + f"&user_id={bob_id}&agent_id=agt-alice", + headers=admin_auth, + ) + assert r.status_code == 200 + assert r.json()["total_tokens"] == 0 + assert r.json()["turns"] == 0 + + r = await c.get( + f"/api/admin/usage/summary?granularity=total&window=day:2026-08-25&user_id={alice_id}", + headers=admin_auth, + ) + assert r.status_code == 200 + assert r.json()["total_tokens"] == 220 + assert r.json()["turns"] == 1 + + r = await c.get( + "/api/admin/usage/summary?granularity=total&window=month:2026-09", + headers=admin_auth, + ) + assert r.status_code == 200 + assert r.json()["total_tokens"] == 1100 + assert r.json()["turns"] == 1 + + r = await c.get( + "/api/admin/usage/summary?granularity=total&window=range:2026-08-20:2026-08-25", + headers=admin_auth, + ) + assert r.status_code == 200 + assert r.json()["total_tokens"] == 100 + 10 + 200 + 20 + 50 + 5 + assert r.json()["turns"] == 3 + + r = await c.get( + f"/api/usage/summary?granularity=total&window=all&as_user={bob_id}", + headers=alice_auth, + ) + assert r.status_code == 403 + + r = await c.get( + "/api/usage/summary?granularity=total&window=all", + headers=bob_auth, + ) + assert r.status_code == 200 + assert r.json()["total_tokens"] == 1155 + + r = await c.get( + f"/api/admin/usage/export.xlsx?window=all&user_id={bob_id}", + headers={**admin_auth, "Accept-Language": "zh"}, + ) + assert r.status_code == 200 + wb = load_workbook(BytesIO(r.content)) + detail = wb["明细"] + headers = [cell.value for cell in detail[1]] + uid_col = headers.index("用户 ID") + user_ids = { + row[uid_col].value + for row in detail.iter_rows(min_row=2, max_row=detail.max_row - 2) + if row[uid_col].value is not None + } + assert user_ids == {bob_id} + + +async def test_summary_agent_id_filter_for_user(env: Any) -> None: + c, srv, _admin_auth, alice_auth, ctx = env + repo = srv.services.usage_repo + repo.record(agent_id="agt-a", user_id=ctx["alice_id"], input_tokens=10, output_tokens=5) + repo.record(agent_id="agt-b", user_id=ctx["alice_id"], input_tokens=100, output_tokens=50) + + r = await c.get( + "/api/usage/summary?granularity=total&window=all&agent_id=agt-a", + headers=alice_auth, + ) + assert r.status_code == 200 + assert r.json()["total_tokens"] == 15 + assert r.json()["turns"] == 1 + + r = await c.get( + "/api/usage/summary?granularity=by_agent&window=all&agent_id=agt-b", + headers=alice_auth, + ) + assert r.status_code == 200 + buckets = r.json()["buckets"] + assert len(buckets) == 1 + assert buckets[0]["key"] == "agt-b" + assert buckets[0]["total_tokens"] == 150 + + +async def test_window_presets_today_yesterday_rolling(env: Any) -> None: + """Preset windows isolate rows when resolved against a pinned ``now``.""" + from datetime import datetime + from zoneinfo import ZoneInfo + + from octop.infra.db.repos.usage import resolve_usage_window + + c, srv, admin_auth, _alice_auth, ctx = env + repo = srv.services.usage_repo + tz = ZoneInfo("Asia/Shanghai") + now_ts = int(datetime(2026, 9, 7, 15, 0, tzinfo=tz).timestamp()) + today_ts = int(datetime(2026, 9, 7, 10, 0, tzinfo=tz).timestamp()) + yesterday_ts = int(datetime(2026, 9, 6, 10, 0, tzinfo=tz).timestamp()) + week_ago_ts = int(datetime(2026, 8, 30, 10, 0, tzinfo=tz).timestamp()) + old_ts = int(datetime(2026, 7, 1, 10, 0, tzinfo=tz).timestamp()) + + for ts, tokens in ( + (today_ts, 10), + (yesterday_ts, 20), + (week_ago_ts, 40), + (old_ts, 80), + ): + repo.record( + agent_id="y", + user_id=ctx["alice_id"], + input_tokens=tokens, + output_tokens=0, + ts=ts, + ) + + for window, expected_tokens in ( + ("today", 10), + ("yesterday", 20), + # Rolling 7*86400s from 2026-09-07 15:00 → starts 2026-08-31 15:00; + # the Aug 30 row is outside that window. + ("last_7d", 10 + 20), + ("last_30d", 10 + 20 + 40), + ("all", 10 + 20 + 40 + 80), + ): + start, end = resolve_usage_window(window, timezone="Asia/Shanghai", now=now_ts) + rows = [ + row + for row in repo.list_detail( + user_id=ctx["alice_id"], + window="all", + timezone="Asia/Shanghai", + ) + if start <= row.ts < end + ] + assert sum(row.total_tokens for row in rows) == expected_tokens, window + + # Calendar windows via admin API (independent of wall-clock ``now``). + r = await c.get( + "/api/admin/usage/summary?granularity=total&window=day:2026-09-06" + f"&user_id={ctx['alice_id']}", + headers=admin_auth, + ) + assert r.status_code == 200 + assert r.json()["total_tokens"] == 20 + + # --- chunk extraction in chat router --------------------------------------- diff --git a/tests/unit/db/test_usage_window.py b/tests/unit/db/test_usage_window.py new file mode 100644 index 00000000..dff66e4e --- /dev/null +++ b/tests/unit/db/test_usage_window.py @@ -0,0 +1,70 @@ +"""Unit tests for usage window resolution (server timezone day/month).""" + +from __future__ import annotations + +from datetime import datetime +from zoneinfo import ZoneInfo + +import pytest + +from octop.infra.db.repos.usage import resolve_usage_window + +TZ = "Asia/Shanghai" + + +def _ts(year: int, month: int, day: int, hour: int = 0, minute: int = 0) -> int: + return int(datetime(year, month, day, hour, minute, tzinfo=ZoneInfo(TZ)).timestamp()) + + +def test_resolve_day_window_shanghai() -> None: + start, end = resolve_usage_window("day:2026-09-07", timezone=TZ) + assert start == _ts(2026, 9, 7) + assert end == _ts(2026, 9, 8) + + +def test_resolve_month_window_shanghai() -> None: + start, end = resolve_usage_window("month:2026-09", timezone=TZ) + assert start == _ts(2026, 9, 1) + assert end == _ts(2026, 10, 1) + + +def test_resolve_month_december_rolls_year() -> None: + start, end = resolve_usage_window("month:2025-12", timezone=TZ) + assert start == _ts(2025, 12, 1) + assert end == _ts(2026, 1, 1) + + +def test_resolve_today_uses_local_midnight() -> None: + # 2026-09-07 15:30 CST + now = _ts(2026, 9, 7, 15, 30) + start, end = resolve_usage_window("today", timezone=TZ, now=now) + assert start == _ts(2026, 9, 7) + assert end == now + 1 + + +def test_resolve_yesterday_local() -> None: + now = _ts(2026, 9, 7, 10, 0) + start, end = resolve_usage_window("yesterday", timezone=TZ, now=now) + assert start == _ts(2026, 9, 6) + assert end == _ts(2026, 9, 7) + + +def test_resolve_invalid_day_raises() -> None: + with pytest.raises(ValueError, match="invalid usage window"): + resolve_usage_window("day:2026-9-7", timezone=TZ) + + +def test_resolve_invalid_month_raises() -> None: + with pytest.raises(ValueError, match="invalid usage window"): + resolve_usage_window("month:2026-9", timezone=TZ) + + +def test_resolve_range_inclusive_days() -> None: + start, end = resolve_usage_window("range:2026-09-01:2026-09-07", timezone=TZ) + assert start == _ts(2026, 9, 1) + assert end == _ts(2026, 9, 8) + + +def test_resolve_range_inverted_raises() -> None: + with pytest.raises(ValueError, match="invalid usage window"): + resolve_usage_window("range:2026-09-08:2026-09-07", timezone=TZ)