Skip to content

Commit 6d96c51

Browse files
committed
fix(models): warn on silent-empty truncation, track cost, fix flag
1 parent 0196d62 commit 6d96c51

5 files changed

Lines changed: 252 additions & 5 deletions

File tree

effgen/core/agent_generation.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -145,8 +145,10 @@ def _apply_structured_output(
145145
if outcome.success and outcome.json_str is not None:
146146
response.output = outcome.json_str
147147
response.metadata["structured_output"] = True
148-
# Preserve the historical flag for any consumer that checks it.
149-
response.metadata["structured_output_reprompted"] = outcome.attempts > 0
148+
# True only when a text reprompt actually ran — grammar/native
149+
# constraint attempts also count toward `attempts` but are not a
150+
# reprompt, so `attempts > 0` alone would mislabel them as one.
151+
response.metadata["structured_output_reprompted"] = outcome.method == "reprompt"
150152
if output_model is not None:
151153
response.metadata["parsed"] = self._parse_with_pydantic(
152154
output_model, outcome.parsed,
@@ -533,7 +535,9 @@ def _warn_reasoning_budget(self, max_tokens: int | None, structured_output: bool
533535
kind, hint = "tight_budget", (
534536
f"'{name}' is a reasoning model with max_tokens={max_tokens} — it can "
535537
f"spend the whole budget on hidden reasoning and return no visible "
536-
f"text. Consider max_tokens>={min_tokens}."
538+
f"text. Consider max_tokens>={min_tokens}. This can happen even at "
539+
f"temperature=0: the reasoning-token allocation is provider-controlled "
540+
f"and is not made deterministic by a fixed temperature."
537541
)
538542

539543
warn_key = (name, kind)

effgen/models/base.py

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,16 @@
88
from __future__ import annotations
99

1010
import functools
11+
import logging
1112
import time
1213
from abc import ABC, abstractmethod
1314
from collections.abc import Iterator
1415
from dataclasses import dataclass
1516
from enum import Enum
1617
from typing import Any, Literal
1718

19+
logger = logging.getLogger(__name__)
20+
1821

1922
class ModelType(Enum):
2023
"""Enumeration of supported model types."""
@@ -68,7 +71,15 @@ class GenerationConfig:
6871

6972
@dataclass
7073
class GenerationResult:
71-
"""Result from a generation call."""
74+
"""Result from a generation call.
75+
76+
On a reasoning model, ``text`` can be empty even on a normal (non-error)
77+
return: the output-token budget is spent on hidden reasoning before any
78+
visible token is emitted. This is reported as ``finish_reason == "length"``
79+
and ``metadata["truncated"] is True`` — check either before trusting an
80+
empty ``text``/``str(result)``, or call through :class:`~effgen.core.agent.Agent`
81+
instead, which already raises a clear error in that case.
82+
"""
7283
text: str
7384
tokens_used: int
7485
finish_reason: str
@@ -126,6 +137,53 @@ def _stamp_latency(result: Any, elapsed_s: float) -> Any:
126137
return result
127138

128139

140+
def _stamp_cost(model: "BaseModel", result: Any) -> None:
141+
"""Accumulate this call's cost onto the model instance's running total.
142+
143+
Populates ``metadata["total_cost"]`` via presence check, so an adapter that
144+
already tracks its own cumulative total (OpenAI, Gemini, Anthropic) keeps
145+
its own bookkeeping untouched; every other adapter gets the same
146+
cumulative-cost field for free, derived from the ``cost_usd`` it already
147+
reports per call. Skipped when the result carries no ``cost_usd`` (e.g.
148+
local engines that do not price calls).
149+
"""
150+
if not isinstance(result, GenerationResult):
151+
return
152+
meta = result.metadata
153+
if meta is None or "total_cost" in meta:
154+
return
155+
cost = meta.get("cost_usd")
156+
if cost is None:
157+
return
158+
model.total_cost = getattr(model, "total_cost", 0.0) + cost
159+
meta["total_cost"] = model.total_cost
160+
161+
162+
def _warn_if_silently_empty(model: "BaseModel", result: Any) -> None:
163+
"""Log a warning when a reasoning model's raw ``generate()`` call returns
164+
empty text because its output budget was spent on hidden reasoning before
165+
any visible token (``finish_reason == "length"``).
166+
167+
:class:`~effgen.core.agent.Agent` already detects and escalates this case;
168+
a caller using ``model.generate()`` directly has no such safety net, so an
169+
empty ``GenerationResult`` would otherwise look like a working call that
170+
produced nothing.
171+
"""
172+
if not isinstance(result, GenerationResult):
173+
return
174+
if result.text or result.finish_reason != "length":
175+
return
176+
from ._adapter_utils import needs_reasoning_headroom
177+
if not needs_reasoning_headroom(model):
178+
return
179+
logger.warning(
180+
"%s returned empty text after exhausting its token budget on internal "
181+
"reasoning (finish_reason='length'). Increase max_tokens, or check "
182+
"metadata['truncated'] before trusting an empty result.",
183+
getattr(model, "model_name", model.__class__.__name__),
184+
)
185+
186+
129187
def _preflight_budget_check(model: "BaseModel") -> None:
130188
"""Refuse to start a call when a configured budget is already at or over its cap.
131189
@@ -151,7 +209,10 @@ def wrapper(self, *args, **kwargs):
151209
_preflight_budget_check(self)
152210
start = time.perf_counter()
153211
result = func(self, *args, **kwargs)
154-
return _stamp_latency(result, time.perf_counter() - start)
212+
result = _stamp_latency(result, time.perf_counter() - start)
213+
_stamp_cost(self, result)
214+
_warn_if_silently_empty(self, result)
215+
return result
155216
wrapper.__effgen_timed__ = True
156217
return wrapper
157218

@@ -173,6 +234,8 @@ def wrapper(self, *args, **kwargs):
173234
if isinstance(results, list):
174235
for r in results:
175236
_stamp_latency(r, elapsed)
237+
_stamp_cost(self, r)
238+
_warn_if_silently_empty(self, r)
176239
return results
177240
wrapper.__effgen_timed__ = True
178241
return wrapper
@@ -379,6 +442,18 @@ def is_loaded(self) -> bool:
379442
"""
380443
return self._is_loaded
381444

445+
def get_total_cost(self) -> float:
446+
"""Cumulative cost (USD) charged to this model instance since it was
447+
created or since :meth:`reset_cost` was last called. Populated from
448+
each call's ``cost_usd``; local/unpriced engines stay at ``0.0``.
449+
"""
450+
return getattr(self, "total_cost", 0.0)
451+
452+
def reset_cost(self) -> None:
453+
"""Reset this instance's cumulative cost counter (see
454+
:meth:`get_total_cost`) back to zero."""
455+
self.total_cost = 0.0
456+
382457
def get_metadata(self) -> dict[str, Any]:
383458
"""
384459
Get model metadata and information.

tests/unit/test_agent.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,9 @@ def test_warn_reasoning_budget_fires_for_tight_pinned_budget(self, mock_model, c
200200
with caplog.at_level(logging.WARNING, logger="effgen.core.agent_generation"):
201201
agent.run("test", max_tokens=250)
202202
assert any("reasoning model" in r.message for r in caplog.records)
203+
# The hint also sets expectations about temperature=0 on a tight
204+
# budget: it does not make reasoning-budget exhaustion deterministic.
205+
assert any("temperature=0" in r.message for r in caplog.records)
203206

204207
def test_warn_reasoning_budget_silent_for_non_reasoning_model(self, mock_model, caplog):
205208
import logging

tests/unit/test_generation_latency.py

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
import time
1313
from collections.abc import Iterator
1414

15+
import pytest
16+
1517
from effgen.models.base import (
1618
BaseModel,
1719
GenerationConfig,
@@ -79,6 +81,61 @@ def generate(self, prompt, config=None, **kwargs):
7981
)
8082

8183

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+
82139
def test_generate_stamps_latency():
83140
r = _TinyEngine().generate("hi")
84141
assert "latency_ms" in r.metadata and "duration_s" in r.metadata
@@ -119,3 +176,96 @@ def test_wrapping_is_idempotent_across_subclassing():
119176
# wrapped (the marker prevents re-wrapping an already-timed method).
120177
assert getattr(_TinyEngine.generate, "__effgen_timed__", False) is True
121178
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+
)

tests/unit/test_structured_output_robustness.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,21 @@ def test_agent_surfaces_attempts_on_success():
264264
assert resp.metadata["structured_output"] is True
265265
assert resp.metadata["structured_output_attempts"] == 1
266266
assert resp.metadata["structured_output_method"] == "native"
267+
# A single native-JSON-mode call is not a reprompt: attempts > 0 alone
268+
# would mislabel this as a repair loop that never ran.
269+
assert resp.metadata["structured_output_reprompted"] is False
270+
271+
272+
def test_agent_reprompted_flag_true_only_for_the_reprompt_fallback():
273+
# No native JSON support (_PlainModel) forces the reprompt fallback; the
274+
# first reprompt attempt succeeds, so method == "reprompt" and the flag
275+
# accurately reports that a repair loop ran.
276+
agent = _agent_with(_PlainModel([VALID]))
277+
resp = agent._apply_structured_output(_response("some prose"), SCHEMA, None, "capital?")
278+
assert resp.success is True
279+
assert resp.metadata["structured_output_method"] == "reprompt"
280+
assert resp.metadata["structured_output_attempts"] == 1
281+
assert resp.metadata["structured_output_reprompted"] is True
267282

268283

269284
def test_agent_fast_path_zero_attempts_when_answer_already_valid():

0 commit comments

Comments
 (0)