Files
ai_coder 5c8c13891f
Build and deploy / build-deploy (push) Has been cancelled
feat: учёт token usage в checks с агрегатами и UI
- миграция checks: nullable input_tokens/output_tokens/total_tokens/usage_json
  (идемпотентно, старые строки остаются NULL; probe мигрирует сам — cron стартует раньше uvicorn)
- probe.py и /api/check фиксируют usage ДО валидации контента: токены
  сохраняются при пустом/сломанном ответе; таймауты и ответы без usage = NULL, не нули
- usage_json хранит сырой usage провайдера (OpenAI/Anthropic/camelCase форматы)
- /api/checks и сегментная история несут токены; новый /api/token_stats:
  rolling 24h + all time, покрытие known/unknown, разбивка по провайдерам
  (провайдер из models с fallback на префикс model_id)
- UI: статы «Токены 24h» и «Покрытие usage» в шапке, токены в тултипах
  сегментов и логе проверок
- статика через __file__-относительный путь (работает и в /app/app, и локально)
- тесты (30, реальные HTTP против мока OmniRoute): миграция/идемпотентность,
  NULL при missing/zero usage, usage при упавшем контенте, таймаут, агрегаты,
  оба пути записи (probe.run + /api/check)
2026-10-25 17:27:38 +00:00

71 lines
2.5 KiB
Python

"""extract_usage: разбор форматов usage разных провайдеров."""
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "app"))
from usage_utils import extract_usage
EMPTY = {"input_tokens": None, "output_tokens": None, "total_tokens": None, "usage_json": None}
def test_openai_shape():
u = extract_usage({"choices": [], "usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}})
assert u["input_tokens"] == 10
assert u["output_tokens"] == 5
assert u["total_tokens"] == 15
assert u["usage_json"] == '{"completion_tokens": 5, "prompt_tokens": 10, "total_tokens": 15}'
def test_anthropic_shape_total_computed():
u = extract_usage({"usage": {"input_tokens": 7, "output_tokens": 3}})
assert u["input_tokens"] == 7
assert u["output_tokens"] == 3
assert u["total_tokens"] == 10 # точная сумма отчётных значений, не оценка
def test_camel_case_shape():
u = extract_usage({"usage": {"promptTokens": 4, "completionTokens": 2, "totalTokens": 6}})
assert (u["input_tokens"], u["output_tokens"], u["total_tokens"]) == (4, 2, 6)
def test_missing_usage_all_none():
assert extract_usage({"choices": []}) == EMPTY
assert extract_usage({"usage": None}) == EMPTY
assert extract_usage({}) == EMPTY
def test_non_dict_resp():
assert extract_usage(None) == EMPTY
assert extract_usage("error") == EMPTY
def test_zero_usage_treated_as_unknown():
# полностью нулевой usage = провайдер не отдал данные → NULL, не нули
u = extract_usage({"usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}})
assert u == EMPTY
def test_string_encoded_usage():
u = extract_usage({"usage": '{"prompt_tokens": 8, "completion_tokens": 1, "total_tokens": 9}'})
assert (u["input_tokens"], u["output_tokens"], u["total_tokens"]) == (8, 1, 9)
def test_partial_total_only():
u = extract_usage({"usage": {"total_tokens": 42}})
assert u["input_tokens"] is None
assert u["output_tokens"] is None
assert u["total_tokens"] == 42
def test_garbage_values_ignored():
u = extract_usage({"usage": {"prompt_tokens": "много", "completion_tokens": True, "total_tokens": None}})
assert u == EMPTY
def test_usage_json_roundtrip():
raw = {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15, "extra": {"x": 1}}
u = extract_usage({"usage": raw})
import json
assert json.loads(u["usage_json"]) == raw