textmachine/eval/role_topology/buy.py

114 lines
6.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Единая касса пака: леджер с диска, ПРОЕКЦИОННЫЙ гард, однородная запись вызова.
⚠ Заведён 07.08 по адверсариальному ревью, которое нашло три разных дефекта одного класса:
* `repair_arm.CEILING_USD` и `route_arm.CEILING_USD` были ОБЪЯВЛЕНЫ и не использованы нигде —
потолок Ф2б $3.00 был декоративным, а счётчик трат локальным и перезапуск не переживал;
* гард `screen.call` сравнивал УЖЕ потраченное с порогом и ничего не знал о цене следующего
вызова: запас $0.03 при наблюдённом максимуме одного вызова $0.090 — потолок держался
случаем, а не механизмом;
* `bakeoff` считал потраченное по глобу `bo-` и не видел $1.02, ушедших на судейство.
Отсюда три правила, и все три живут ЗДЕСЬ, а не в каждом скрипте по-своему:
1. Потраченное читается С ДИСКА по всем префиксам фазы. Память процесса не источник истины.
2. Гард ПРОЕКЦИОННЫЙ: `spent + ожидаемая цена` против потолка, а не `spent` против порога.
Ожидание берётся из уже купленных вызовов той же роли (p95), при их отсутствии — из сметы.
3. Запись несёт `total_tokens`. Без него биллинг Gemini (`additive_total`, quirks 00 D22.3)
не пере-считывается даже постфактум — ровно это и случилось с прошлым прогоном.
"""
from __future__ import annotations
import json
import time
from pathlib import Path
from prices import cost
OUT = Path.home() / "books" / "role-topology"
OUT.mkdir(parents=True, exist_ok=True)
class Ledger:
"""Касса одной фазы. `prefixes` — глобы артефактов, из которых складывается потраченное."""
def __init__(self, name: str, ceiling: float, prefixes: tuple[str, ...],
default_expect: float = 0.05) -> None:
self.name, self.ceiling, self.prefixes = name, ceiling, prefixes
self.default_expect = default_expect
self._prices: list[float] = []
def spent(self) -> float:
tot, self._prices = 0.0, []
for pat in self.prefixes:
for f in OUT.glob(pat):
try:
r = json.loads(f.read_text(encoding="utf-8"))
except Exception: # noqa: BLE001, S112
continue
c = float(r.get("cost_usd") or 0.0)
tot += c
if c > 0:
self._prices.append(c)
return tot
def expect(self) -> float:
"""Ожидаемая цена следующего вызова: p95 уже купленного, иначе смета."""
if len(self._prices) < 3:
return self.default_expect
s = sorted(self._prices)
return s[min(len(s) - 1, int(round(0.95 * (len(s) - 1))))]
def afford(self) -> tuple[bool, str]:
sp = self.spent()
proj = sp + self.expect()
if proj > self.ceiling:
return False, (f"[СТОП {self.name}] потрачено ${sp:.6f} + ожидание ${self.expect():.6f} "
f"= ${proj:.6f} > потолок ${self.ceiling:.2f}")
return True, ""
def usage_of(u, ch) -> dict:
"""Токены вызова в однородном виде. `total_tokens` персистится ВСЕГДА (см. шапку)."""
cached = getattr(getattr(u, "prompt_tokens_details", None), "cached_tokens", 0) or 0
reasoning = getattr(getattr(u, "completion_tokens_details", None), "reasoning_tokens", 0) or 0
return dict(prompt_tokens=u.prompt_tokens or 0, cached_tokens=cached,
completion_tokens=u.completion_tokens or 0, reasoning_tokens=reasoning,
total_tokens=getattr(u, "total_tokens", 0) or 0,
reasoning_chars=len(getattr(ch.message, "reasoning_content", None) or ""))
def priced(model: str, us: dict) -> float:
return round(cost(model, us["prompt_tokens"], us["cached_tokens"], us["completion_tokens"],
us["reasoning_tokens"], us["total_tokens"]), 6)
def purchase(led: Ledger, tag: str, model: str, client, kw: dict, **extra) -> dict:
"""Один платный вызов под кассой. Кэш читается ПЕРВЫМ, гард — перед покупкой.
Порядок важен и выведен из двух прошлых ошибок: гард перед чтением кэша объявлял уже
купленную клетку незамеренной, а гард после покупки не защищал ничего.
"""
f = OUT / f"{tag}.json"
if f.exists():
return json.loads(f.read_text(encoding="utf-8"))
ok, why = led.afford()
if not ok:
print(why)
return dict(tag=tag, model=model, skipped=True, cost_usd=0.0, content="", finish="skipped")
t0 = time.time()
try:
r = client.chat.completions.create(**kw)
except Exception as e: # noqa: BLE001
# Провал ОДНОГО вызова — данные, а не повод ронять прогон. ⚠ Но запись отказа кладётся
# ОТДЕЛЬНЫМ тегом: прошлая редакция писала её под тем же именем, и транзиентный таймаут
# навсегда отравлял клетку — пере-запуск читал ошибку из кэша и вызов не повторял.
rec = dict(tag=tag, model=model, finish="error", error=f"{type(e).__name__}: {str(e)[:200]}",
content="", cost_usd=0.0, latency_s=round(time.time() - t0, 1), **extra)
(OUT / f"{tag}.ERROR.json").write_text(json.dumps(rec, ensure_ascii=False, indent=1),
encoding="utf-8")
return rec
ch, us = r.choices[0], usage_of(r.usage, r.choices[0])
rec = dict(tag=tag, model=model, model_returned=r.model, finish=ch.finish_reason,
content=ch.message.content or "", latency_s=round(time.time() - t0, 1),
cost_usd=priced(model, us), **us, **extra)
f.write_text(json.dumps(rec, ensure_ascii=False, indent=1), encoding="utf-8")
return rec