mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-14 16:16:48 +00:00
fix(admin): keyset export paging, rollup-free spend, consistent totals
Review pass over the preceding commits. - The activity export paged by OFFSET. The journals are append-only and the feed is newest-first, so rows written mid-export shift the window down and repeat rows already emitted. Pages by keyset on (created_at, feed, id) now. - top_token_users and the per-user breakdown counted run-level rollup rows. A scheduled run already has a row per LLM call, so its spend was billed twice -- invisible while the column was tokens, obvious once it was dollars. Both now exclude them, matching every other spend query. - total_cost was summed from the per-model split, which drops rows with no model_id and so undercounted the figure printed above the series. Summed from the series instead. - The CSV export encoded its detail cell without the fallback the NDJSON branch had, so a non-JSON-native value would have failed the stream. - The activity view reset pagination in an effect, which fetched the stale page against the new filters before fetching again. Reset in the setters. - The audit taxonomy is no longer re-exported from the helper module; the one caller that wanted it imports from where it lives.
This commit is contained in:
1 parent
f6f8af8cef
commit
c2d1893992
9 files changed
+113
-47
No files matched your search
@@ -43,7 +43,7 @@ _EXPORT_CHUNK = 1_000
|
|||||||
_MAX_SEARCH_LENGTH = 200
|
_MAX_SEARCH_LENGTH = 200
|
||||||
|
|
||||||
|
|
||||||
def _csv_list(name: str, allowed: tuple[str, ...]) -> Optional[list[str]]:
|
def _facet_list(name: str, allowed: tuple[str, ...]) -> Optional[list[str]]:
|
||||||
"""Parse a repeated/comma-separated query arg, dropping unknown values.
|
"""Parse a repeated/comma-separated query arg, dropping unknown values.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -96,8 +96,8 @@ def _search_arg() -> Optional[str]:
|
|||||||
def _filters() -> dict:
|
def _filters() -> dict:
|
||||||
"""The filter set shared by the feed and the export."""
|
"""The filter set shared by the feed and the export."""
|
||||||
return {
|
return {
|
||||||
"feeds": _csv_list("feed", _FEEDS),
|
"feeds": _facet_list("feed", _FEEDS),
|
||||||
"categories": _csv_list("category", ACTIVITY_CATEGORIES),
|
"categories": _facet_list("category", ACTIVITY_CATEGORIES),
|
||||||
"events": _event_list(),
|
"events": _event_list(),
|
||||||
"actor_id": request.args.get("actor_id") or None,
|
"actor_id": request.args.get("actor_id") or None,
|
||||||
"user_id": request.args.get("user_id") or None,
|
"user_id": request.args.get("user_id") or None,
|
||||||
@@ -216,7 +216,9 @@ def _csv_rows(filters: dict, limit: int) -> Iterator[str]:
|
|||||||
for row in _export_rows(filters, limit):
|
for row in _export_rows(filters, limit):
|
||||||
serialized = _serialize(row)
|
serialized = _serialize(row)
|
||||||
# ``detail`` is a JSON object; a CSV cell holds its compact encoding.
|
# ``detail`` is a JSON object; a CSV cell holds its compact encoding.
|
||||||
serialized["detail"] = json.dumps(serialized.get("detail") or {})
|
serialized["detail"] = json.dumps(
|
||||||
|
serialized.get("detail") or {}, default=str
|
||||||
|
)
|
||||||
writer.writerow(serialized)
|
writer.writerow(serialized)
|
||||||
yield _drain(buffer)
|
yield _drain(buffer)
|
||||||
|
|
||||||
|
|||||||
@@ -359,7 +359,9 @@ class AdminUsageResource(Resource):
|
|||||||
"group_by": group_by,
|
"group_by": group_by,
|
||||||
"series": series,
|
"series": series,
|
||||||
"total_tokens": int(total),
|
"total_tokens": int(total),
|
||||||
"total_cost": round(sum(row["cost"] for row in by_model), 4),
|
# Summed from the series, not from ``by_model``: the latter
|
||||||
|
# drops rows with no model_id, so it would undercount.
|
||||||
|
"total_cost": round(sum(row["cost"] for row in series), 4),
|
||||||
"by_model": by_model,
|
"by_model": by_model,
|
||||||
"latency": latency,
|
"latency": latency,
|
||||||
"top_users": top_users,
|
"top_users": top_users,
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ Two properties matter and are enforced here rather than at every call site:
|
|||||||
2. **Request context is optional.** Celery tasks (ingestion finishing, a
|
2. **Request context is optional.** Celery tasks (ingestion finishing, a
|
||||||
scheduled run) have no Flask request; the row simply records without an IP
|
scheduled run) have no Flask request; the row simply records without an IP
|
||||||
or user agent instead of raising.
|
or user agent instead of raising.
|
||||||
|
|
||||||
|
The event → category taxonomy the admin activity feed filters on lives in
|
||||||
|
``docsgpt/audit_events.py``, which the storage layer needs too.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -23,14 +26,10 @@ from typing import Any, Optional
|
|||||||
|
|
||||||
from flask import has_request_context, request
|
from flask import has_request_context, request
|
||||||
|
|
||||||
from docsgpt.audit_events import ACTIVITY_CATEGORIES, category_for
|
|
||||||
from docsgpt.storage.db.repositories.auth_events import AuthEventsRepository
|
from docsgpt.storage.db.repositories.auth_events import AuthEventsRepository
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Re-exported so route modules have one import for recording and classifying.
|
|
||||||
__all__ = ["ACTIVITY_CATEGORIES", "category_for", "record_event"]
|
|
||||||
|
|
||||||
|
|
||||||
def record_event(
|
def record_event(
|
||||||
conn,
|
conn,
|
||||||
|
|||||||
@@ -397,10 +397,6 @@ class UploadFile(Resource):
|
|||||||
return make_response(jsonify({"success": False}), 400)
|
return make_response(jsonify({"success": False}), 400)
|
||||||
# Predetermined id matches the dedup-claim row; loser GET sees same.
|
# Predetermined id matches the dedup-claim row; loser GET sees same.
|
||||||
response_task_id = predetermined_task_id or task.id
|
response_task_id = predetermined_task_id or task.id
|
||||||
# ``source_uuid`` was minted above and passed to the worker as
|
|
||||||
# ``source_id``; the worker uses it verbatim for every SSE event,
|
|
||||||
# so the frontend can correlate inbound ``source.ingest.*`` to
|
|
||||||
# this upload regardless of whether an idempotency key was set.
|
|
||||||
# Audited here, not in the worker: this is the user action. The
|
# Audited here, not in the worker: this is the user action. The
|
||||||
# ingest may still fail, which the source's own status records.
|
# ingest may still fail, which the source's own status records.
|
||||||
try:
|
try:
|
||||||
@@ -416,9 +412,15 @@ class UploadFile(Resource):
|
|||||||
)
|
)
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
current_app.logger.warning(
|
current_app.logger.warning(
|
||||||
"Could not audit upload for source %s: %s", source_uuid, err,
|
"Could not audit upload for source %s: %s",
|
||||||
|
source_uuid,
|
||||||
|
err,
|
||||||
exc_info=True,
|
exc_info=True,
|
||||||
)
|
)
|
||||||
|
# ``source_uuid`` was minted above and passed to the worker as
|
||||||
|
# ``source_id``; the worker uses it verbatim for every SSE event,
|
||||||
|
# so the frontend can correlate inbound ``source.ingest.*`` to
|
||||||
|
# this upload regardless of whether an idempotency key was set.
|
||||||
response_payload: dict = {
|
response_payload: dict = {
|
||||||
"success": True,
|
"success": True,
|
||||||
"task_id": response_task_id,
|
"task_id": response_task_id,
|
||||||
|
|||||||
@@ -46,6 +46,10 @@ ACTIVITY_COLUMNS = (
|
|||||||
# the category filter cannot match it.
|
# the category filter cannot match it.
|
||||||
_FEED_CATEGORIES = {"auth": None, "device": "device", "guardrail": "safety"}
|
_FEED_CATEGORIES = {"auth": None, "device": "device", "guardrail": "safety"}
|
||||||
|
|
||||||
|
# ``feed`` and ``id`` break ties so a page boundary is deterministic and the
|
||||||
|
# keyset cursor below has something total to compare against.
|
||||||
|
_ORDER_BY = "ORDER BY created_at DESC, feed DESC, id DESC"
|
||||||
|
|
||||||
_AUTH_BRANCH = f"""
|
_AUTH_BRANCH = f"""
|
||||||
SELECT 'auth' AS feed,
|
SELECT 'auth' AS feed,
|
||||||
id::text AS id,
|
id::text AS id,
|
||||||
@@ -158,14 +162,14 @@ class ActivityRepository:
|
|||||||
def _query(
|
def _query(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
feeds: Optional[Sequence[str]],
|
feeds: Optional[Sequence[str]] = None,
|
||||||
categories: Optional[Sequence[str]],
|
categories: Optional[Sequence[str]] = None,
|
||||||
events: Optional[Sequence[str]],
|
events: Optional[Sequence[str]] = None,
|
||||||
actor_id: Optional[str],
|
actor_id: Optional[str] = None,
|
||||||
user_id: Optional[str],
|
user_id: Optional[str] = None,
|
||||||
since: Optional[datetime],
|
since: Optional[datetime] = None,
|
||||||
until: Optional[datetime],
|
until: Optional[datetime] = None,
|
||||||
search: Optional[str],
|
search: Optional[str] = None,
|
||||||
) -> tuple[str, dict]:
|
) -> tuple[str, dict]:
|
||||||
selected = self._feeds_for(feeds, categories)
|
selected = self._feeds_for(feeds, categories)
|
||||||
if not selected:
|
if not selected:
|
||||||
@@ -247,11 +251,33 @@ class ActivityRepository:
|
|||||||
return []
|
return []
|
||||||
params.update({"limit": int(limit), "offset": int(offset)})
|
params.update({"limit": int(limit), "offset": int(offset)})
|
||||||
result = self._conn.execute(
|
result = self._conn.execute(
|
||||||
text(f"{sql} ORDER BY created_at DESC, feed, id DESC LIMIT :limit OFFSET :offset"),
|
text(f"{sql} {_ORDER_BY} LIMIT :limit OFFSET :offset"),
|
||||||
params,
|
params,
|
||||||
)
|
)
|
||||||
return [dict(row._mapping) for row in result.fetchall()]
|
return [dict(row._mapping) for row in result.fetchall()]
|
||||||
|
|
||||||
|
def _page_after(
|
||||||
|
self, cursor: Optional[tuple], limit: int, filters: dict
|
||||||
|
) -> list[dict]:
|
||||||
|
"""One keyset page strictly older than ``cursor``.
|
||||||
|
|
||||||
|
OFFSET paging would drift here: the feed is ordered newest-first and
|
||||||
|
the journals are append-only, so rows inserted mid-export push the
|
||||||
|
window down and repeat rows that were already emitted.
|
||||||
|
"""
|
||||||
|
sql, params = self._query(**filters)
|
||||||
|
if not sql:
|
||||||
|
return []
|
||||||
|
if cursor is not None:
|
||||||
|
sql += " AND " if "WHERE" in sql else " WHERE "
|
||||||
|
sql += "(created_at, feed, id) < (:cur_at, :cur_feed, :cur_id)"
|
||||||
|
params.update(
|
||||||
|
{"cur_at": cursor[0], "cur_feed": cursor[1], "cur_id": cursor[2]}
|
||||||
|
)
|
||||||
|
params["limit"] = int(limit)
|
||||||
|
result = self._conn.execute(text(f"{sql} {_ORDER_BY} LIMIT :limit"), params)
|
||||||
|
return [dict(row._mapping) for row in result.fetchall()]
|
||||||
|
|
||||||
def count(
|
def count(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -291,8 +317,9 @@ class ActivityRepository:
|
|||||||
) -> Iterator[dict]:
|
) -> Iterator[dict]:
|
||||||
"""Yield every matching row, newest first, for the export endpoint.
|
"""Yield every matching row, newest first, for the export endpoint.
|
||||||
|
|
||||||
Pages through the feed rather than materializing it, so an export of a
|
Pages by keyset, not offset: the journals are append-only and the feed
|
||||||
large instance streams instead of building the whole result in memory.
|
is newest-first, so rows written mid-export would otherwise shift the
|
||||||
|
window and repeat rows already emitted.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
chunk_size: Rows fetched per round trip.
|
chunk_size: Rows fetched per round trip.
|
||||||
@@ -304,17 +331,18 @@ class ActivityRepository:
|
|||||||
Row dicts carrying :data:`ACTIVITY_COLUMNS`.
|
Row dicts carrying :data:`ACTIVITY_COLUMNS`.
|
||||||
"""
|
"""
|
||||||
emitted = 0
|
emitted = 0
|
||||||
offset = 0
|
cursor: Optional[tuple] = None
|
||||||
while emitted < max_rows:
|
while emitted < max_rows:
|
||||||
page = self.list(
|
page = self._page_after(
|
||||||
limit=min(chunk_size, max_rows - emitted), offset=offset, **filters
|
cursor, min(chunk_size, max_rows - emitted), filters
|
||||||
)
|
)
|
||||||
if not page:
|
if not page:
|
||||||
return
|
return
|
||||||
for row in page:
|
for row in page:
|
||||||
yield row
|
yield row
|
||||||
|
last = page[-1]
|
||||||
|
cursor = (last["created_at"], last["feed"], last["id"])
|
||||||
emitted += len(page)
|
emitted += len(page)
|
||||||
offset += len(page)
|
|
||||||
|
|
||||||
def event_names(self) -> list[dict]:
|
def event_names(self) -> list[dict]:
|
||||||
"""Distinct ``(event, category)`` pairs across all three journals.
|
"""Distinct ``(event, category)`` pairs across all three journals.
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from typing import Optional
|
|||||||
from sqlalchemy import Connection, text
|
from sqlalchemy import Connection, text
|
||||||
|
|
||||||
from docsgpt.storage.db.base_repository import row_to_dict
|
from docsgpt.storage.db.base_repository import row_to_dict
|
||||||
|
from docsgpt.storage.db.repositories.token_usage import TokenUsageRepository
|
||||||
|
|
||||||
|
|
||||||
def _round_ms(value) -> Optional[int]:
|
def _round_ms(value) -> Optional[int]:
|
||||||
@@ -76,6 +77,10 @@ class AdminStatsRepository:
|
|||||||
Ordered by tokens, not cost: a cheap model can dominate token volume
|
Ordered by tokens, not cost: a cheap model can dominate token volume
|
||||||
while a costly one dominates the bill, and the operator wants to see
|
while a costly one dominates the bill, and the operator wants to see
|
||||||
both columns rather than have the ranking pick for them.
|
both columns rather than have the ranking pick for them.
|
||||||
|
|
||||||
|
Run-level rollup rows are excluded, matching every other spend query:
|
||||||
|
a scheduled run already has a row per LLM call, so counting the rollup
|
||||||
|
too would bill it twice.
|
||||||
"""
|
"""
|
||||||
result = self._conn.execute(
|
result = self._conn.execute(
|
||||||
text(
|
text(
|
||||||
@@ -85,12 +90,17 @@ class AdminStatsRepository:
|
|||||||
COALESCE(SUM(cost), 0) AS cost
|
COALESCE(SUM(cost), 0) AS cost
|
||||||
FROM token_usage
|
FROM token_usage
|
||||||
WHERE timestamp >= :since AND user_id IS NOT NULL
|
WHERE timestamp >= :since AND user_id IS NOT NULL
|
||||||
|
AND source <> ALL(:rollup_sources)
|
||||||
GROUP BY user_id
|
GROUP BY user_id
|
||||||
ORDER BY tokens DESC
|
ORDER BY tokens DESC
|
||||||
LIMIT :limit
|
LIMIT :limit
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
{"since": since, "limit": int(limit)},
|
{
|
||||||
|
"since": since,
|
||||||
|
"limit": int(limit),
|
||||||
|
"rollup_sources": list(TokenUsageRepository.ROLLUP_SOURCES),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
return [
|
return [
|
||||||
{"user_id": r[0], "tokens": int(r[1]), "cost": float(r[2])}
|
{"user_id": r[0], "tokens": int(r[1]), "cost": float(r[2])}
|
||||||
@@ -140,6 +150,9 @@ class AdminStatsRepository:
|
|||||||
cannot answer either question an operator actually has -- what is this
|
cannot answer either question an operator actually has -- what is this
|
||||||
person costing, and what is driving it.
|
person costing, and what is driving it.
|
||||||
|
|
||||||
|
Run-level rollup rows are excluded, so a scheduled run is counted once
|
||||||
|
(by its per-call rows), not twice.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
user_id: The billable user (auth ``sub``).
|
user_id: The billable user (auth ``sub``).
|
||||||
since: Inclusive window start.
|
since: Inclusive window start.
|
||||||
@@ -148,6 +161,7 @@ class AdminStatsRepository:
|
|||||||
Returns:
|
Returns:
|
||||||
``{"totals": {...}, "by_model": [...], "by_source": [...]}``.
|
``{"totals": {...}, "by_model": [...], "by_source": [...]}``.
|
||||||
"""
|
"""
|
||||||
|
rollups = list(TokenUsageRepository.ROLLUP_SOURCES)
|
||||||
totals = self._conn.execute(
|
totals = self._conn.execute(
|
||||||
text(
|
text(
|
||||||
"""
|
"""
|
||||||
@@ -156,9 +170,10 @@ class AdminStatsRepository:
|
|||||||
count(*) AS calls
|
count(*) AS calls
|
||||||
FROM token_usage
|
FROM token_usage
|
||||||
WHERE user_id = :u AND timestamp >= :since
|
WHERE user_id = :u AND timestamp >= :since
|
||||||
|
AND source <> ALL(:rollup_sources)
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
{"u": user_id, "since": since},
|
{"u": user_id, "since": since, "rollup_sources": rollups},
|
||||||
).one()
|
).one()
|
||||||
|
|
||||||
def _breakdown(key_expr: str) -> list[dict]:
|
def _breakdown(key_expr: str) -> list[dict]:
|
||||||
@@ -170,12 +185,18 @@ class AdminStatsRepository:
|
|||||||
COALESCE(SUM(cost), 0) AS cost
|
COALESCE(SUM(cost), 0) AS cost
|
||||||
FROM token_usage
|
FROM token_usage
|
||||||
WHERE user_id = :u AND timestamp >= :since
|
WHERE user_id = :u AND timestamp >= :since
|
||||||
|
AND source <> ALL(:rollup_sources)
|
||||||
GROUP BY key
|
GROUP BY key
|
||||||
ORDER BY tokens DESC, key
|
ORDER BY tokens DESC, key
|
||||||
LIMIT :limit
|
LIMIT :limit
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
{"u": user_id, "since": since, "limit": int(limit)},
|
{
|
||||||
|
"u": user_id,
|
||||||
|
"since": since,
|
||||||
|
"limit": int(limit),
|
||||||
|
"rollup_sources": rollups,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
return [
|
return [
|
||||||
{"key": r.key, "tokens": int(r.tokens), "cost": float(r.cost)}
|
{"key": r.key, "tokens": int(r.tokens), "cost": float(r.cost)}
|
||||||
|
|||||||
@@ -137,6 +137,18 @@ export default function Activity() {
|
|||||||
const [searchDraft, setSearchDraft] = useState('');
|
const [searchDraft, setSearchDraft] = useState('');
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Narrowing the feed invalidates whatever page you were on, so every filter
|
||||||
|
* setter resets it. Resetting in an effect instead would fetch the old page
|
||||||
|
* against the new filters first, then fetch again.
|
||||||
|
*/
|
||||||
|
const withPageReset =
|
||||||
|
<T,>(set: (value: T) => void) =>
|
||||||
|
(value: T) => {
|
||||||
|
setPage(1);
|
||||||
|
set(value);
|
||||||
|
};
|
||||||
|
|
||||||
const filters: ActivityFilters = useMemo(
|
const filters: ActivityFilters = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
category: categories.length ? categories : undefined,
|
category: categories.length ? categories : undefined,
|
||||||
@@ -180,11 +192,6 @@ export default function Activity() {
|
|||||||
load();
|
load();
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
// Any filter change invalidates the current page number.
|
|
||||||
useEffect(() => {
|
|
||||||
setPage(1);
|
|
||||||
}, [filters]);
|
|
||||||
|
|
||||||
const exportAs = async (format: 'csv' | 'ndjson') => {
|
const exportAs = async (format: 'csv' | 'ndjson') => {
|
||||||
setExporting(true);
|
setExporting(true);
|
||||||
try {
|
try {
|
||||||
@@ -225,7 +232,7 @@ export default function Activity() {
|
|||||||
const hasFilters =
|
const hasFilters =
|
||||||
categories.length > 0 || events.length > 0 || search !== '';
|
categories.length > 0 || events.length > 0 || search !== '';
|
||||||
|
|
||||||
const applySearch = () => setSearch(searchDraft.trim());
|
const applySearch = () => withPageReset(setSearch)(searchDraft.trim());
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
@@ -241,14 +248,14 @@ export default function Activity() {
|
|||||||
<MultiSelect
|
<MultiSelect
|
||||||
options={categoryOptions}
|
options={categoryOptions}
|
||||||
selected={categories}
|
selected={categories}
|
||||||
onChange={setCategories}
|
onChange={withPageReset(setCategories)}
|
||||||
placeholder="All categories"
|
placeholder="All categories"
|
||||||
className="w-48"
|
className="w-48"
|
||||||
/>
|
/>
|
||||||
<MultiSelect
|
<MultiSelect
|
||||||
options={eventOptions}
|
options={eventOptions}
|
||||||
selected={events}
|
selected={events}
|
||||||
onChange={setEvents}
|
onChange={withPageReset(setEvents)}
|
||||||
placeholder="All events"
|
placeholder="All events"
|
||||||
searchPlaceholder="Find an event"
|
searchPlaceholder="Find an event"
|
||||||
className="w-56"
|
className="w-56"
|
||||||
@@ -260,7 +267,7 @@ export default function Activity() {
|
|||||||
variant={range.days === rangeDays ? 'default' : 'outline'}
|
variant={range.days === rangeDays ? 'default' : 'outline'}
|
||||||
size="sm"
|
size="sm"
|
||||||
className="rounded-3xl"
|
className="rounded-3xl"
|
||||||
onClick={() => setRangeDays(range.days)}
|
onClick={() => withPageReset(setRangeDays)(range.days)}
|
||||||
>
|
>
|
||||||
{range.label}
|
{range.label}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -271,6 +278,7 @@ export default function Activity() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
setPage(1);
|
||||||
setCategories([]);
|
setCategories([]);
|
||||||
setEvents([]);
|
setEvents([]);
|
||||||
setSearchDraft('');
|
setSearchDraft('');
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ from unittest.mock import MagicMock, Mock, patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from docsgpt.api.audit import ACTIVITY_CATEGORIES, category_for, record_event
|
from docsgpt.api.audit import record_event
|
||||||
|
from docsgpt.audit_events import ACTIVITY_CATEGORIES, category_for
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|||||||
@@ -167,11 +167,11 @@ class TestPerRoundUsageRows:
|
|||||||
return messages, None
|
return messages, None
|
||||||
|
|
||||||
rows = []
|
rows = []
|
||||||
with patch.object(
|
|
||||||
usage_mod,
|
def _record(llm_, cu, *, duration_ms=None, ttft_ms=None):
|
||||||
"_persist_call_usage",
|
rows.append({**cu, "duration_ms": duration_ms, "ttft_ms": ttft_ms})
|
||||||
side_effect=lambda llm_, cu: rows.append(dict(cu)),
|
|
||||||
):
|
with patch.object(usage_mod, "_persist_call_usage", side_effect=_record):
|
||||||
with patch.object(handler, "handle_tool_calls", fake_tool_calls):
|
with patch.object(handler, "handle_tool_calls", fake_tool_calls):
|
||||||
first = llm.gen_stream(
|
first = llm.gen_stream(
|
||||||
model="m", messages=[{"role": "user", "content": "hi"}]
|
model="m", messages=[{"role": "user", "content": "hi"}]
|
||||||
@@ -185,6 +185,9 @@ class TestPerRoundUsageRows:
|
|||||||
counts = lambda row: {k: row[k] for k in ("prompt_tokens", "generated_tokens")} # noqa: E731
|
counts = lambda row: {k: row[k] for k in ("prompt_tokens", "generated_tokens")} # noqa: E731
|
||||||
assert counts(rows[0]) == {"prompt_tokens": 100, "generated_tokens": 10}
|
assert counts(rows[0]) == {"prompt_tokens": 100, "generated_tokens": 10}
|
||||||
assert counts(rows[1]) == {"prompt_tokens": 200, "generated_tokens": 20}
|
assert counts(rows[1]) == {"prompt_tokens": 200, "generated_tokens": 20}
|
||||||
|
# Each round is timed independently, and each streamed a chunk.
|
||||||
|
assert all(row["duration_ms"] is not None for row in rows)
|
||||||
|
assert all(row["ttft_ms"] is not None for row in rows)
|
||||||
|
|
||||||
|
|
||||||
class TestPreferProviderUsageClaim:
|
class TestPreferProviderUsageClaim:
|
||||||
|
|||||||
Reference in new issue
Block a user