oc_chat
Loading...
Searching...
No Matches
turn_cost.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/core/turn_cost.py
4
5"""
6Per-turn USD cost, computed in the render lane and honesty-gated — plan 188 T4,
7re-priced by :mod:`oc_pricing` (plan 202).
8
9Purpose
10-------
11The synthesis asks for a per-turn ``$`` line beside the token gauges.
12Everything needed already reaches the render lane: ``run_turn``
13accumulates the provider's own cache-hit / cache-miss / completion token
14split per iteration, and the **effective-dated, TOU-aware price table
15now ships as the standalone** :mod:`oc_pricing` **package** (plans
16206/210/211) — the one authoritative pricing source every Option C
17client imports. This module joins the two. No pricing data is invented
18here — if the package cannot price a sample *at the sample's instant*,
19there is no line.
20
21The governing rule (plan 188 T4): **no line is better than a false
22line.** Every gate below prefers silence over a plausible number. Plan
23202 adds the converse hazard: a misfiring timestamp would make EVERY
24sample unpriceable — a silent pricing blackout that reads as honest
25"unknown" while hiding real spend. The pricing instant therefore
26arrives as an explicit, required ``at`` captured once per sample at the
27send seam (:mod:`oc_chat.core.react_loop`'s ``_now_utc``), never read
28ambiently here.
29
30Responsibilities
31----------------
32- :class:`TurnCost` — the immutable result handed to presenters:
33 the figure plus the provenance that makes it auditable.
34- :func:`price_usage` — price ONE usage sample at ONE instant, or
35 ``None`` when any input makes the answer a guess.
36- :class:`TurnCostAccumulator` — fold a turn's samples exactly
37 (Decimal); one unpriceable sample suppresses the whole turn.
38
39Why per-sample rather than per-turn: a turn's iterations each carry
40their own model (``route.model``), their own cache split, and — now —
41their own pricing instant, and a sub-loop folds in usage of its own.
42Pricing each sample at the model AND tariff that actually produced it
43is exact; pricing the turn's summed tokens at one rate would silently
44mis-rate a routed turn or a turn straddling a tariff boundary.
45
46Diagnostics
47-----------
48Domain: OC_CHAT.MAIN
49Levels:
50 L2 — a turn suppressed as unpriceable.
51 L3 — per-sample gate rejections.
52 L4 — module-load coverage marker.
53
54Contracts
55---------
56- ``price_usage`` returns ``None`` — never 0.0 as a stand-in — when:
57 the package has no rate for (provider, model) in force at ``at``; the
58 reported cache split does not ACCOUNT for the prompt
59 (``hit + miss != prompt_tokens``, so the input side is unknown —
60 costing it all as a miss would overstate); there are no tokens at
61 all; or ``at`` is ``None``/naive (oc_pricing prices a time-dependent
62 row only from an aware instant — a guessed tariff would be a wrong
63 number).
64- Usage is reduced to :class:`oc_pricing.CanonicalUsage` DIRECTLY from
65 OC_Chat's own ``(hit, miss)`` extraction — NEVER via
66 ``oc_pricing.usage_from_deepseek``. That reducer reads only the
67 explicit ``prompt_cache_hit_tokens``/``prompt_cache_miss_tokens``
68 fields, but real DeepSeek-via-OpenAI-SDK usage reports cache via
69 ``prompt_tokens_details.cached_tokens`` — routing live usage through
70 it would price EVERY live turn to ``None`` (plan 202 decision 2).
71- A turn is priced only if EVERY sample priced. A partial total is a
72 wrong number, not a smaller one.
73- The turn total is accumulated as exact ``Decimal`` from
74 ``oc_pricing.breakdown_usd`` and quantized ONCE at :meth:`result` —
75 never a sum of per-sample floats. This exactness claim is the live
76 ``$`` line's alone; the event projection still folds the float
77 ``cost_usd`` published on the bus (plan 202 residual).
78- One ``at`` prices a whole sample; a turn straddling a TOU boundary
79 prices each sample at its own send instant (deliberate, observable
80 approximation — plan 202 residual).
81- Nothing here publishes an event or mutates session state.
82
83Dependencies
84------------
85- oc_diagnostics (structured debug logging via _dbg)
86- oc_pricing (the one authoritative effective-dated, TOU-aware table)
87"""
88from __future__ import annotations
89
90from dataclasses import dataclass
91from datetime import datetime
92from decimal import ROUND_HALF_EVEN, Context, Decimal, localcontext
93from typing import Optional
94
95from oc_diagnostics import _dbg
96
97import oc_pricing
98from oc_pricing import CanonicalUsage, RateQuote
99
100_dbg("OC_CHAT.MAIN", "turn_cost", "module", 4)
101
102
103#: Label used when a turn's samples span more than one model — the total is
104#: still exact (each sample was priced at its own rate) but no single model
105#: name would be true of it.
106MIXED_MODEL: str = "mixed"
107
108#: The pinned money context, mirroring oc_pricing's own: accumulation and
109#: the final quantize run under a COPY of this, never the ambient
110#: thread-local decimal context — a host that sets ``prec=4`` or traps
111#: ``Inexact`` can neither change a total nor crash the money path.
112_MONEY_CONTEXT = Context(prec=28, rounding=ROUND_HALF_EVEN)
113
114#: Quantum of the displayed figure: micro-dollar, matching the six decimal
115#: places the pre-202 ``round(total, 6)`` presented.
116_USD_QUANTUM = Decimal("0.000001")
117
118
119@dataclass(frozen=True, slots=True)
121 """A priced turn plus the provenance that makes the figure auditable."""
122
123 total_usd: float
124 """Modelled spend for the turn, in USD. Always an estimate, never a bill."""
125 model: str
126 # OCT-LINT: disable=no_hardcoded_secrets reason="Dataclass field docstring prose trips the Shannon entropy heuristic (4.57 bits/char); no secret material - this module carries published per-million-token rates via oc_pricing only"
127 """The model priced, or :data:`MIXED_MODEL` when the turn spanned several."""
128 provider: str
129 """The provider whose published rates were applied."""
130 as_of: str
131 """Vendor-verification date of the SELECTED rate version
132 (``RateQuote.verified_at``) — the figure is only as fresh as this."""
133 source: str
134 """Where the selected version's rates came from
135 (``RateQuote.source``), so a reader can re-check them."""
136
137
139 cache_hit_tokens: int,
140 cache_miss_tokens: int,
141 prompt_tokens: int,
142 completion_tokens: int,
143) -> Optional[CanonicalUsage]:
144 """Reduce one OC_Chat usage sample to a :class:`CanonicalUsage`, or ``None``.
145
146 The two fail-closed gates the DIRECT build must re-establish live here,
147 because bypassing ``usage_from_deepseek`` (deliberately — see the module
148 Contracts) also bypasses that reducer's own split gate:
149
150 - nothing happened (all-zero) → ``None``: preserves plan 188's
151 suppression — a ``$0.000000`` line for an empty sample is noise
152 presented as a price (oc_pricing would price it 0.0).
153 - ``hit + miss != prompt`` → ``None``: the input side is priced at two
154 different rates, so an unaccounted prompt cannot be costed — charging
155 the remainder at either rate would be a guess.
156
157 Completion maps whole to ``out``: DeepSeek bills chain-of-thought as
158 output (the table's rows carry ``reasoning: null``), which is why the
159 pre-202 distinct-reasoning-rate gate no longer exists.
160 """
161 func = "_canonical_sample"
162 hit = max(0, int(cache_hit_tokens or 0))
163 miss = max(0, int(cache_miss_tokens or 0))
164 prompt = max(0, int(prompt_tokens or 0))
165 completion = max(0, int(completion_tokens or 0))
166
167 if hit + miss == 0 and completion == 0:
168 return None # nothing happened worth costing
169
170 if hit + miss != prompt:
171 _dbg("OC_CHAT.MAIN", func,
172 f"cache split {hit}+{miss} does not account for prompt {prompt}", 3)
173 return None
174
175 return CanonicalUsage(
176 uncached_in=miss, cache_read=hit, cache_write=0, out=completion,
177 )
178
179
181 provider: str,
182 model: str,
183 *,
184 cache_hit_tokens: int,
185 cache_miss_tokens: int,
186 prompt_tokens: int,
187 completion_tokens: int,
188 at: Optional[datetime],
189) -> float | None:
190 """Price one usage sample in USD at instant ``at``, or ``None``.
191
192 ``at`` is required and must be the sample's ONE captured, tz-aware
193 instant (react_loop's ``_now_utc`` seam) — the tariff in force is a
194 function of it, and today's table is effective-dated AND time-of-use
195 banded, so ``at`` changes the number in production. A ``None`` or
196 naive ``at`` yields ``None`` (unpriceable), never a guessed tariff.
197
198 See the module Contracts for the exact gates. The caller must treat
199 ``None`` as "suppress the line", never as zero.
200 """
201 func = "price_usage"
202 usage = _canonical_sample(
203 cache_hit_tokens, cache_miss_tokens, prompt_tokens, completion_tokens,
204 )
205 if usage is None:
206 return None
207 usd = oc_pricing.cost_usd(usage, provider=provider, model=model, at=at)
208 if usd is None:
209 _dbg("OC_CHAT.MAIN", func,
210 f"no rate in force for {provider}/{model} at={at!r}", 3)
211 return usd
212
213
215 """Folds a turn's usage samples into one :class:`TurnCost`, or nothing.
216
217 Each :meth:`add` prices at the model AND instant that actually produced
218 the sample. The first sample that cannot be priced latches the turn
219 unpriceable — reporting the priced subset would understate the turn
220 while looking authoritative, which is the exact failure mode plan 188
221 T4 forbids.
222
223 The running total is an exact ``Decimal`` sum of per-sample
224 ``breakdown_usd(...)["total"]`` values, quantized once at
225 :meth:`result` — the 200-sample fold and the one-aggregate figure are
226 equal by construction (the live ``$`` line's exactness win; the event
227 projection is NOT covered by this claim).
228 """
229
230 __slots__ = ("_total", "_models", "_provider", "_priceable", "_samples",
231 "_last_quote")
232
233 def __init__(self) -> None:
234 self._total = Decimal(0)
235 self._models: set[str] = set()
236 self._provider = ""
237 self._priceable = True
238 self._samples = 0
239 self._last_quote: Optional[RateQuote] = None
240
241 def add(
242 self,
243 provider: str,
244 model: str,
245 *,
246 cache_hit_tokens: int,
247 cache_miss_tokens: int,
248 prompt_tokens: int,
249 completion_tokens: int,
250 at: Optional[datetime],
251 ) -> None:
252 """Fold one usage sample priced at ``at``. No-op once unpriceable."""
253 if not self._priceable:
254 return
255 usage = _canonical_sample(
256 cache_hit_tokens, cache_miss_tokens, prompt_tokens, completion_tokens,
257 )
258 if usage is None:
259 self._priceable = False
260 return
261 breakdown = oc_pricing.breakdown_usd(
262 usage, provider=provider, model=model, at=at,
263 )
264 if breakdown is None:
265 self._priceable = False
266 return
267 # Selection is a pure function of (provider, model, at) against the
268 # process-cached table, so this quote is the SAME version + band that
269 # just priced the breakdown; captured per-sample so a MIXED_MODEL
270 # turn carries the last-priced sample's provenance (plan 202 dec. 6).
271 self._last_quote = oc_pricing.rate_at(provider, model, at=at)
272 with localcontext(_MONEY_CONTEXT):
273 self._total += breakdown["total"]
274 self._models.add(model)
275 self._provider = provider or self._provider
276 self._samples += 1
277
278 def result(self) -> TurnCost | None:
279 """The turn's cost, or ``None`` when any part of it was unpriceable."""
280 func = "result"
281 if not self._priceable:
282 _dbg("OC_CHAT.MAIN", func, "turn not priceable; no cost line", 2)
283 return None
284 if self._samples == 0 or self._last_quote is None:
285 return None
286 quote = self._last_quote
287 model = next(iter(self._models)) if len(self._models) == 1 else MIXED_MODEL
288 with localcontext(_MONEY_CONTEXT):
289 total = float(self._total.quantize(_USD_QUANTUM))
290 return TurnCost(
291 total_usd=total,
292 model=model,
293 provider=self._provider,
294 as_of=quote.verified_at or "",
295 source=quote.source or "",
296 )
297
298
299__all__ = ["MIXED_MODEL", "TurnCost", "TurnCostAccumulator", "price_usage"]
None add(self, str provider, str model, *, int cache_hit_tokens, int cache_miss_tokens, int prompt_tokens, int completion_tokens, Optional[datetime] at)
Definition turn_cost.py:251
float|None price_usage(str provider, str model, *, int cache_hit_tokens, int cache_miss_tokens, int prompt_tokens, int completion_tokens, Optional[datetime] at)
Definition turn_cost.py:189
Optional[CanonicalUsage] _canonical_sample(int cache_hit_tokens, int cache_miss_tokens, int prompt_tokens, int completion_tokens)
Definition turn_cost.py:143