|
12 | 12 | import time |
13 | 13 | from collections.abc import Iterator |
14 | 14 |
|
| 15 | +import pytest |
| 16 | + |
15 | 17 | from effgen.models.base import ( |
16 | 18 | BaseModel, |
17 | 19 | GenerationConfig, |
@@ -79,6 +81,61 @@ def generate(self, prompt, config=None, **kwargs): |
79 | 81 | ) |
80 | 82 |
|
81 | 83 |
|
| 84 | +class _PricedEngine(_TinyEngine): |
| 85 | + """An engine that reports a per-call ``cost_usd`` but no cumulative total — |
| 86 | + mirrors adapters (Groq, Cerebras, Together, Fireworks, Replicate) that price |
| 87 | + each call without tracking a running total themselves.""" |
| 88 | + |
| 89 | + def __init__(self, cost_usd: float): |
| 90 | + super().__init__() |
| 91 | + self._cost_usd = cost_usd |
| 92 | + |
| 93 | + def generate(self, prompt, config=None, **kwargs): |
| 94 | + return GenerationResult( |
| 95 | + text="x", tokens_used=1, finish_reason="stop", model_name=self.model_name, |
| 96 | + metadata={"cost_usd": self._cost_usd}, |
| 97 | + ) |
| 98 | + |
| 99 | + |
| 100 | +class _SelfTrackedCostEngine(_TinyEngine): |
| 101 | + """An engine that already tracks its own cumulative ``total_cost`` (like the |
| 102 | + OpenAI/Gemini/Anthropic adapters) — the generic accumulator must not touch it.""" |
| 103 | + |
| 104 | + def __init__(self): |
| 105 | + super().__init__() |
| 106 | + self.total_cost = 0.0 |
| 107 | + |
| 108 | + def generate(self, prompt, config=None, **kwargs): |
| 109 | + cost = 0.01 |
| 110 | + self.total_cost += cost |
| 111 | + return GenerationResult( |
| 112 | + text="x", tokens_used=1, finish_reason="stop", model_name=self.model_name, |
| 113 | + metadata={"cost_usd": cost, "total_cost": self.total_cost}, |
| 114 | + ) |
| 115 | + |
| 116 | + |
| 117 | +class _ReasoningEngine(_TinyEngine): |
| 118 | + """An engine named like a reasoning model that can return empty text when |
| 119 | + ``finish_reason == "length"`` — mimics gpt-5*/o-series exhausting the token |
| 120 | + budget on hidden reasoning before any visible token.""" |
| 121 | + |
| 122 | + def __init__(self, *, empty: bool): |
| 123 | + super().__init__() |
| 124 | + self.model_name = "gpt-5-nano" |
| 125 | + self._empty = empty |
| 126 | + |
| 127 | + def generate(self, prompt, config=None, **kwargs): |
| 128 | + if self._empty: |
| 129 | + return GenerationResult( |
| 130 | + text="", tokens_used=500, finish_reason="length", |
| 131 | + model_name=self.model_name, metadata={}, |
| 132 | + ) |
| 133 | + return GenerationResult( |
| 134 | + text="hi", tokens_used=5, finish_reason="stop", |
| 135 | + model_name=self.model_name, metadata={}, |
| 136 | + ) |
| 137 | + |
| 138 | + |
82 | 139 | def test_generate_stamps_latency(): |
83 | 140 | r = _TinyEngine().generate("hi") |
84 | 141 | assert "latency_ms" in r.metadata and "duration_s" in r.metadata |
@@ -119,3 +176,96 @@ def test_wrapping_is_idempotent_across_subclassing(): |
119 | 176 | # wrapped (the marker prevents re-wrapping an already-timed method). |
120 | 177 | assert getattr(_TinyEngine.generate, "__effgen_timed__", False) is True |
121 | 178 | assert getattr(_SelfTimedEngine.generate, "__effgen_timed__", False) is True |
| 179 | + |
| 180 | + |
| 181 | +# --------------------------------------------------------------------------- # |
| 182 | +# cumulative total_cost, extended to every adapter (not just OpenAI/Gemini/ |
| 183 | +# Anthropic, which already tracked it themselves) |
| 184 | +# --------------------------------------------------------------------------- # |
| 185 | + |
| 186 | + |
| 187 | +def test_generate_accumulates_total_cost_across_calls(): |
| 188 | + engine = _PricedEngine(cost_usd=0.001) |
| 189 | + r1 = engine.generate("a") |
| 190 | + r2 = engine.generate("b") |
| 191 | + assert r1.metadata["total_cost"] == 0.001 |
| 192 | + assert r2.metadata["total_cost"] == pytest.approx(0.002) |
| 193 | + assert engine.get_total_cost() == pytest.approx(0.002) |
| 194 | + |
| 195 | + |
| 196 | +def test_reset_cost_zeroes_the_running_total(): |
| 197 | + engine = _PricedEngine(cost_usd=0.001) |
| 198 | + engine.generate("a") |
| 199 | + engine.reset_cost() |
| 200 | + assert engine.get_total_cost() == 0.0 |
| 201 | + r = engine.generate("b") |
| 202 | + assert r.metadata["total_cost"] == pytest.approx(0.001) |
| 203 | + |
| 204 | + |
| 205 | +def test_no_total_cost_when_engine_reports_no_cost(): |
| 206 | + # A local/unpriced engine has no cost_usd, so no fabricated total_cost key. |
| 207 | + r = _TinyEngine().generate("hi") |
| 208 | + assert "cost_usd" not in r.metadata |
| 209 | + assert "total_cost" not in r.metadata |
| 210 | + |
| 211 | + |
| 212 | +def test_self_tracked_total_cost_is_not_double_counted(): |
| 213 | + # An adapter that already stamps its own cumulative total_cost (OpenAI, |
| 214 | + # Gemini, Anthropic) keeps exactly its own number — the generic accumulator |
| 215 | + # skips a result that already carries the key. |
| 216 | + engine = _SelfTrackedCostEngine() |
| 217 | + r1 = engine.generate("a") |
| 218 | + r2 = engine.generate("b") |
| 219 | + assert r1.metadata["total_cost"] == pytest.approx(0.01) |
| 220 | + assert r2.metadata["total_cost"] == pytest.approx(0.02) |
| 221 | + |
| 222 | + |
| 223 | +def test_generate_batch_accumulates_total_cost_per_item(): |
| 224 | + class _PricedBatchEngine(_PricedEngine): |
| 225 | + def generate_batch(self, prompts, config=None, **kwargs): |
| 226 | + return [self.generate(p) for p in prompts] |
| 227 | + |
| 228 | + engine = _PricedBatchEngine(cost_usd=0.001) |
| 229 | + results = engine.generate_batch(["a", "b", "c"]) |
| 230 | + assert [r.metadata["total_cost"] for r in results] == [ |
| 231 | + pytest.approx(0.001), pytest.approx(0.002), pytest.approx(0.003), |
| 232 | + ] |
| 233 | + |
| 234 | + |
| 235 | +# --------------------------------------------------------------------------- # |
| 236 | +# a silent-empty raw generate() on a reasoning model logs a visible warning |
| 237 | +# --------------------------------------------------------------------------- # |
| 238 | + |
| 239 | + |
| 240 | +def test_reasoning_model_empty_truncated_result_logs_warning(caplog): |
| 241 | + import logging |
| 242 | + |
| 243 | + with caplog.at_level(logging.WARNING, logger="effgen.models.base"): |
| 244 | + r = _ReasoningEngine(empty=True).generate("say hi") |
| 245 | + assert r.text == "" |
| 246 | + assert any( |
| 247 | + "exhausting its token budget on internal reasoning" in rec.message |
| 248 | + for rec in caplog.records |
| 249 | + ) |
| 250 | + |
| 251 | + |
| 252 | +def test_reasoning_model_non_empty_result_does_not_warn(caplog): |
| 253 | + import logging |
| 254 | + |
| 255 | + with caplog.at_level(logging.WARNING, logger="effgen.models.base"): |
| 256 | + r = _ReasoningEngine(empty=False).generate("say hi") |
| 257 | + assert r.text == "hi" |
| 258 | + assert not any( |
| 259 | + "exhausting its token budget" in rec.message for rec in caplog.records |
| 260 | + ) |
| 261 | + |
| 262 | + |
| 263 | +def test_non_reasoning_model_empty_truncated_result_does_not_warn(caplog): |
| 264 | + import logging |
| 265 | + |
| 266 | + with caplog.at_level(logging.WARNING, logger="effgen.models.base"): |
| 267 | + r = _LengthTruncatedEngine().generate("hi") |
| 268 | + assert r.text == "" |
| 269 | + assert not any( |
| 270 | + "exhausting its token budget" in rec.message for rec in caplog.records |
| 271 | + ) |
0 commit comments