assistant/backend/prospecting/usage.py

340 lines
14 KiB
Python

"""Task-local provider usage, recorded before output validation.
Only token counters and bounded request metadata are persisted. This is an
observation ledger, not a balance, a spending limit, or a provider invoice.
"""
from __future__ import annotations
from contextlib import contextmanager
from contextvars import ContextVar
from dataclasses import dataclass
from datetime import datetime
import json
import logging
import os
from pathlib import Path
import re
import threading
import uuid
from zoneinfo import ZoneInfo
from backend.orchestrator.progress_doc import task_doc_dir
log = logging.getLogger(__name__)
_scope: ContextVar[tuple[str | None, str]] = ContextVar("provider_usage_scope", default=(None, "unknown"))
_locks: dict[str, threading.RLock] = {}
_locks_guard = threading.Lock()
_storage_errors: set[str] = set()
_PHASES = {"intent", "browser", "compression", "analysis", "analysis_incremental", "review", "unknown"}
_STATUSES = {"succeeded", "schema_error", "truncated", "invalid_response", "http_error", "connection_error", "cancelled"}
_TOKEN_FIELDS = ("input_tokens", "output_tokens", "total_tokens", "cache_hit_tokens", "cache_miss_tokens")
_FINISH_REASONS = {"stop", "length", "tool_calls", "content_filter", "end_turn", "max_tokens"}
def _now() -> str:
return datetime.now(ZoneInfo("Asia/Shanghai")).isoformat(timespec="milliseconds")
def _phase(value: str | None) -> str:
return value if isinstance(value, str) and value in _PHASES else "unknown"
def _model(value) -> str:
return value if isinstance(value, str) and re.fullmatch(r"[A-Za-z0-9._:/-]{1,100}", value) else "unknown"
def _directory(task_id: str) -> Path:
if not isinstance(task_id, str) or not re.fullmatch(r"[A-Za-z0-9_-]{1,128}", task_id):
raise ValueError("任务用量记录标识无效")
directory = task_doc_dir(task_id)
directory.mkdir(parents=True, exist_ok=True)
return directory
@contextmanager
def _locked(directory: Path):
key = str(directory.resolve())
with _locks_guard:
lock = _locks.setdefault(key, threading.RLock())
with lock:
with (directory / "model-usage.lock").open("a+b") as stream:
if os.name == "nt":
import msvcrt
stream.seek(0, 2)
if stream.tell() == 0:
stream.write(b"\0")
stream.flush()
stream.seek(0)
msvcrt.locking(stream.fileno(), msvcrt.LK_LOCK, 1)
else:
import fcntl
fcntl.flock(stream.fileno(), fcntl.LOCK_EX)
try:
yield
finally:
if os.name == "nt":
stream.seek(0)
msvcrt.locking(stream.fileno(), msvcrt.LK_UNLCK, 1)
else:
fcntl.flock(stream.fileno(), fcntl.LOCK_UN)
def _clean_event(raw) -> dict:
"""Reject valid-JSON corruption as well as malformed JSON.
Reconstruct the whitelisted event instead of handing any on-disk metadata
through to an API response, including a forged timestamp or nested value.
"""
if not isinstance(raw, dict) or raw.get("kind") not in {"task_start", "started", "response", "finished"}:
raise ValueError("invalid usage event")
at = raw.get("at")
if not isinstance(at, str) or len(at) > 48:
raise ValueError("invalid usage event time")
timestamp = datetime.fromisoformat(at)
if timestamp.tzinfo is None or timestamp.utcoffset() is None:
raise ValueError("usage time must have a timezone")
clean = {"event_id": raw.get("event_id"), "kind": raw["kind"], "at": timestamp.isoformat(timespec="milliseconds")}
if raw["kind"] == "task_start":
if raw.get("event_id") != "task-start" or set(raw) != {"event_id", "kind", "at"}:
raise ValueError("invalid task usage origin")
return clean
cid = raw.get("call_id")
if not isinstance(cid, str) or not re.fullmatch(r"[a-f0-9]{32}", cid) or raw.get("event_id") != cid + ":" + raw["kind"]:
raise ValueError("invalid usage call identity")
phase, model = raw.get("phase"), raw.get("model")
if not isinstance(phase, str) or phase not in _PHASES or _model(model) != model:
raise ValueError("invalid usage call metadata")
clean.update(call_id=cid, phase=phase, model=model)
allowed = {"event_id", "kind", "at", "call_id", "phase", "model"}
if raw["kind"] == "response":
counters = raw.get("usage")
reason = raw.get("finish_reason")
if not isinstance(counters, dict) or set(counters) != set(_TOKEN_FIELDS):
raise ValueError("invalid usage counters")
if any(value is not None and _counter(value) is None for value in counters.values()):
raise ValueError("invalid usage counter value")
if reason is not None and (not isinstance(reason, str) or reason not in _FINISH_REASONS):
raise ValueError("invalid usage finish reason")
clean.update(usage={key: counters[key] for key in _TOKEN_FIELDS}, finish_reason=reason)
allowed.update({"usage", "finish_reason"})
elif raw["kind"] == "finished":
status, code = raw.get("status"), raw.get("http_status")
if not isinstance(status, str) or status not in _STATUSES:
raise ValueError("invalid usage completion status")
if code is not None and (type(code) is not int or not 100 <= code <= 599):
raise ValueError("invalid usage HTTP status")
clean.update(status=status, http_status=code)
allowed.update({"status", "http_status"})
if set(raw) != allowed:
raise ValueError("unexpected usage event fields")
return clean
def _read_events(directory: Path) -> tuple[list[dict], bool]:
path = directory / "model-usage.jsonl"
if not path.exists():
return [], False
events, damaged = [], False
with path.open(encoding="utf-8", errors="replace") as stream:
for line in stream:
if not line.strip():
continue
try:
event = _clean_event(json.loads(line))
events.append(event)
except (ValueError, TypeError):
damaged = True
return events, damaged
def _write_event(directory: Path, event: dict) -> None:
path = directory / "model-usage.jsonl"
# A process can die during one append. Preserve that fragment and separate
# future records, rather than joining two invalid lines.
with path.open("a+b") as stream:
stream.seek(0, 2)
size = stream.tell()
if size:
stream.seek(-1, 2)
if stream.read(1) != b"\n":
stream.seek(0, 2)
stream.write(b"\n")
stream.seek(0, 2)
stream.write((json.dumps(event, ensure_ascii=False, separators=(",", ":")) + "\n").encode())
stream.flush()
os.fsync(stream.fileno())
def _append(task_id: str, event: dict) -> None:
"""Idempotent append under thread and process locks, without task rewrites."""
try:
directory = _directory(task_id)
with _locked(directory):
events, damaged = _read_events(directory)
if damaged:
_storage_errors.add(task_id)
if any(row["event_id"] == event["event_id"] for row in events):
return
_write_event(directory, event)
except (OSError, ValueError):
# Metering cannot trigger a paid retry or turn an already received
# browser action into a fresh request. The UI explicitly marks gaps.
_storage_errors.add(task_id)
log.warning("模型用量记录未能完整保存;该任务用量将标为不完整")
@contextmanager
def usage_scope(task_id: str | None = None, phase: str | None = None):
"""Bind a task, or override just phase while inheriting the current task."""
previous_task, previous_phase = _scope.get()
token = _scope.set((previous_task if task_id is None else task_id,
previous_phase if phase is None else _phase(phase)))
try:
yield
finally:
_scope.reset(token)
def current_scope() -> tuple[str | None, str]:
return _scope.get()
def initialize_task(task_id: str) -> None:
"""Call only for a newly created task, before its first model request."""
try:
directory = _directory(task_id)
with _locked(directory):
events, damaged = _read_events(directory)
if damaged or events:
# Initializing an old task must not claim its old calls were
# observed. Existing records and gaps remain authoritative.
return
_write_event(directory, {"event_id": "task-start", "kind": "task_start", "at": _now()})
except (OSError, ValueError):
_storage_errors.add(task_id)
@dataclass(frozen=True)
class UsageCall:
task_id: str
call_id: str
phase: str
model: str
def start_call(model: str, *, phase: str | None = None) -> UsageCall | None:
task_id, active_phase = _scope.get()
if task_id is None:
return None
call = UsageCall(task_id, uuid.uuid4().hex, _phase(phase or active_phase), _model(model))
_append(task_id, {"event_id": call.call_id + ":started", "kind": "started", "call_id": call.call_id,
"phase": call.phase, "model": call.model, "at": _now()})
return call
def _get(value, key):
return value.get(key) if isinstance(value, dict) else getattr(value, key, None)
def _counter(value) -> int | None:
return value if type(value) is int and value >= 0 else None
def normalize_usage(raw) -> dict[str, int | None]:
"""Keep only actual counters; absent information stays unknown."""
prompt = _counter(_get(raw, "prompt_tokens"))
output = _counter(_get(raw, "completion_tokens"))
if prompt is None:
prompt = _counter(_get(raw, "input_tokens"))
if output is None:
output = _counter(_get(raw, "output_tokens"))
cached = _counter(_get(raw, "prompt_cache_hit_tokens"))
if cached is None:
cached = _counter(_get(raw, "prompt_cached_tokens"))
if cached is None:
details = _get(raw, "prompt_tokens_details") or _get(raw, "input_tokens_details")
cached = _counter(_get(details, "cached_tokens"))
missed = _counter(_get(raw, "prompt_cache_miss_tokens"))
if cached is not None and prompt is not None and cached > prompt:
cached = None
if missed is not None and prompt is not None and missed > prompt:
missed = None
# Cached + uncached is an exact partition of the supplied input count.
if missed is None and prompt is not None and cached is not None:
missed = prompt - cached
return {"input_tokens": prompt, "output_tokens": output,
"total_tokens": _counter(_get(raw, "total_tokens")),
"cache_hit_tokens": cached, "cache_miss_tokens": missed}
def record_response(call: UsageCall | None, usage, *, model: str | None = None, finish_reason: str | None = None) -> None:
if call is None:
return
reason = finish_reason if isinstance(finish_reason, str) and finish_reason in _FINISH_REASONS else None
_append(call.task_id, {"event_id": call.call_id + ":response", "kind": "response", "call_id": call.call_id,
"phase": call.phase, "model": _model(model or call.model), "at": _now(),
"finish_reason": reason, "usage": normalize_usage(usage)})
def finish_call(call: UsageCall | None, status: str, *, http_status: int | None = None) -> None:
if call is None:
return
_append(call.task_id, {"event_id": call.call_id + ":finished", "kind": "finished", "call_id": call.call_id,
"phase": call.phase, "model": call.model, "at": _now(),
"status": status if status in _STATUSES else "invalid_response",
"http_status": http_status if type(http_status) is int and 100 <= http_status <= 599 else None})
def _aggregate(rows: list[dict]) -> dict:
stats = {"requests": len(rows), **{key: None for key in _TOKEN_FIELDS},
"unknown_usage_requests": 0, "incomplete_usage_requests": 0,
"parse_failed_requests": 0, "failed_requests": 0, "pending_requests": 0}
for row in rows:
usage = row.get("usage") or {}
for key in _TOKEN_FIELDS:
value = _counter(usage.get(key))
if value is not None:
stats[key] = (stats[key] or 0) + value
if not any(_counter(usage.get(key)) is not None for key in _TOKEN_FIELDS):
stats["unknown_usage_requests"] += 1
if any(_counter(usage.get(key)) is None for key in ("input_tokens", "output_tokens", "total_tokens")):
stats["incomplete_usage_requests"] += 1
status = row.get("status") if isinstance(row.get("status"), str) else None
if status in {"schema_error", "invalid_response", "truncated"}:
stats["parse_failed_requests"] += 1
if status and status != "succeeded":
stats["failed_requests"] += 1
if not status:
stats["pending_requests"] += 1
return stats
def snapshot(task_id: str) -> dict:
"""Sum known counters without presenting missing history as zero cost."""
events, damaged = [], False
try:
directory = _directory(task_id)
with _locked(directory):
events, damaged = _read_events(directory)
except (OSError, ValueError):
damaged = True
rows: dict[str, dict] = {}
for event in events:
cid = event.get("call_id")
if not isinstance(cid, str) or not re.fullmatch(r"[a-f0-9]{32}", cid):
continue
row = rows.setdefault(cid, {"phase": _phase(event.get("phase")), "model": _model(event.get("model"))})
if event.get("kind") == "response":
row["model"] = _model(event.get("model"))
row["usage"] = event.get("usage") if isinstance(event.get("usage"), dict) else {}
elif event.get("kind") == "finished":
row["status"] = event.get("status")
values = list(rows.values())
phases = sorted({row["phase"] for row in values})
return {"version": 1, **_aggregate(values), "coverage": "recorded_calls_only",
"legacy_incomplete": not bool(events and events[0].get("kind") == "task_start"),
"ledger_error": damaged or task_id in _storage_errors,
"recorded_since": next((row.get("at") for row in events if row.get("kind") == "started"), None),
"models": sorted({row["model"] for row in values}),
"phases": [{"phase": phase, **_aggregate([row for row in values if row["phase"] == phase])} for phase in phases]}