133 lines
7.0 KiB
Python
133 lines
7.0 KiB
Python
"""Application-observed usage per actual HTTP attempt; never an account bill."""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
from contextlib import closing
|
|
from contextvars import ContextVar
|
|
from datetime import datetime, timezone
|
|
from uuid import uuid4
|
|
|
|
from app.database.db import connect
|
|
|
|
METRICS = ("input_tokens", "output_tokens", "total_tokens", "cache_hit_tokens", "cache_miss_tokens", "cache_write_tokens", "reasoning_tokens")
|
|
logger = logging.getLogger(__name__)
|
|
usage_context = ContextVar("usage_context", default=None)
|
|
|
|
|
|
def connection():
|
|
conn = connect()
|
|
conn.execute("""CREATE TABLE IF NOT EXISTS model_usage (
|
|
attempt_id TEXT PRIMARY KEY, provider_id TEXT NOT NULL, model TEXT NOT NULL,
|
|
capability TEXT NOT NULL, source TEXT NOT NULL, started_at TEXT NOT NULL,
|
|
completed INTEGER NOT NULL, counters_json TEXT NOT NULL, raw_json TEXT NOT NULL)""")
|
|
conn.execute("CREATE INDEX IF NOT EXISTS usage_time_provider ON model_usage(started_at,provider_id,model)")
|
|
columns = {row[1] for row in conn.execute("PRAGMA table_info(model_usage)")}
|
|
for column in ("request_id", "run_id"):
|
|
if column not in columns:
|
|
conn.execute(f"ALTER TABLE model_usage ADD COLUMN {column} TEXT")
|
|
return conn
|
|
|
|
|
|
def numeric_leaves(value, prefix=""):
|
|
"""Keep known numerical counters only; vendor usage objects may contain arbitrary text."""
|
|
result = {}
|
|
if not isinstance(value, dict):
|
|
return result
|
|
allowed = {"prompt_tokens", "completion_tokens", "input_tokens", "output_tokens", "total_tokens", "cached_tokens",
|
|
"cache_read_input_tokens", "cache_creation_input_tokens", "prompt_cache_hit_tokens", "prompt_cache_miss_tokens",
|
|
"reasoning_tokens", "prompt_eval_count", "eval_count"}
|
|
for key, item in value.items():
|
|
path = f"{prefix}.{key}" if prefix else key
|
|
if key in allowed and type(item) is int and 0 <= item <= 2 ** 53:
|
|
result[path] = item
|
|
elif key in {"prompt_tokens_details", "completion_tokens_details", "input_tokens_details", "output_tokens_details"}:
|
|
result.update(numeric_leaves(item, path))
|
|
return result
|
|
|
|
|
|
class UsageAttempt:
|
|
def __init__(self, provider_id, model, protocol, capability="chat", source="api"):
|
|
self.attempt_id = uuid4().hex
|
|
self.provider_id, self.model, self.protocol = provider_id, model, protocol
|
|
self.capability, self.source = capability, source
|
|
self.started_at = datetime.now(timezone.utc).isoformat()
|
|
self.raw = {}
|
|
self.completed = False
|
|
context = usage_context.get() or {}
|
|
self.request_id = context.get("request_id") or uuid4().hex
|
|
self.run_id = context.get("run_id")
|
|
|
|
def observe(self, data):
|
|
if not isinstance(data, dict):
|
|
return
|
|
values = [data.get("usage"), (data.get("message") or {}).get("usage") if isinstance(data.get("message"), dict) else None,
|
|
(data.get("response") or {}).get("usage") if isinstance(data.get("response"), dict) else None]
|
|
if self.protocol == "ollama":
|
|
values.append(data)
|
|
for value in values:
|
|
for key, count in numeric_leaves(value).items():
|
|
self.raw[key] = max(self.raw.get(key, 0), count)
|
|
if data.get("type") in {"[DONE]", "response.completed", "message_stop"} or data.get("done") is True:
|
|
self.completed = True
|
|
|
|
def counters(self):
|
|
raw = self.raw
|
|
def first(*names):
|
|
return next((raw[name] for name in names if name in raw), None)
|
|
inputs = first("input_tokens", "prompt_tokens", "prompt_eval_count")
|
|
outputs = first("output_tokens", "completion_tokens", "eval_count")
|
|
hit = first("cache_read_input_tokens", "prompt_cache_hit_tokens", "input_tokens_details.cached_tokens", "prompt_tokens_details.cached_tokens")
|
|
write = first("cache_creation_input_tokens")
|
|
miss = first("prompt_cache_miss_tokens")
|
|
if self.protocol == "anthropic_messages":
|
|
miss = inputs
|
|
inputs = inputs + hit + write if inputs is not None and hit is not None and write is not None else None
|
|
elif miss is None and inputs is not None and hit is not None and 0 <= hit <= inputs:
|
|
miss = inputs - hit
|
|
if hit is not None and inputs is not None and hit > inputs:
|
|
hit, miss = None, None
|
|
return dict(input_tokens=inputs, output_tokens=outputs,
|
|
total_tokens=inputs + outputs if inputs is not None and outputs is not None else first("total_tokens"),
|
|
cache_hit_tokens=hit, cache_miss_tokens=miss, cache_write_tokens=write,
|
|
reasoning_tokens=first("output_tokens_details.reasoning_tokens", "completion_tokens_details.reasoning_tokens"))
|
|
|
|
def persist(self):
|
|
try:
|
|
with closing(connection()) as conn:
|
|
conn.execute("INSERT OR REPLACE INTO model_usage VALUES (?,?,?,?,?,?,?,?,?,?,?)", (
|
|
self.attempt_id, self.provider_id, self.model, self.capability, self.source, self.started_at,
|
|
int(self.completed), json.dumps(self.counters()), json.dumps(self.raw), self.request_id, self.run_id))
|
|
except Exception:
|
|
logger.warning("Usage persistence failed; model response remains available")
|
|
|
|
|
|
def aggregate(start, end, provider_id=None, model=None, source=None):
|
|
query = "SELECT counters_json,completed FROM model_usage WHERE started_at>=? AND started_at<?"
|
|
args = [start.astimezone(timezone.utc).isoformat(), end.astimezone(timezone.utc).isoformat()]
|
|
for column, value in (("provider_id", provider_id), ("model", model), ("source", source)):
|
|
if value:
|
|
query += f" AND {column}=?"
|
|
args.append(value)
|
|
with closing(connection()) as conn:
|
|
rows = conn.execute(query, args).fetchall()
|
|
options = conn.execute("SELECT DISTINCT provider_id,model,source FROM model_usage ORDER BY provider_id,model").fetchall()
|
|
totals = {key: None for key in METRICS}
|
|
coverage = {key: 0 for key in METRICS}
|
|
hits, eligible_input, cache_requests = 0, 0, 0
|
|
for row in rows:
|
|
counts = json.loads(row[0])
|
|
for key in METRICS:
|
|
if counts.get(key) is not None:
|
|
totals[key] = (totals[key] or 0) + counts[key]
|
|
coverage[key] += 1
|
|
if counts.get("cache_hit_tokens") is not None and counts.get("cache_miss_tokens") is not None:
|
|
hits += counts["cache_hit_tokens"]
|
|
eligible_input += counts["input_tokens"] if counts.get("input_tokens") is not None else counts["cache_hit_tokens"] + counts["cache_miss_tokens"]
|
|
cache_requests += 1
|
|
return {"totals": totals, "coverage": coverage, "request_count": len(rows),
|
|
"complete_requests": sum(row[1] for row in rows), "cache_covered_requests": cache_requests,
|
|
"cache_hit_rate": hits / eligible_input if eligible_input else None,
|
|
"options": [dict(row) for row in options], "start": start, "end": end,
|
|
"scope": "application_observed_usage"}
|