fix(image-gen): every token-billed image backend records session usage (chat path, Image API, OpenAI gpt-image)

Widen the salvaged Image API hunk (#114340) to the whole class:

- plugins/image_gen/_common.py: record_token_usage() — one helper feeding the
  aux accounting chokepoint (agent.aux_accounting.record_aux_usage) with task
  "image_generation", the billing provider and the priced model id. Dict and
  SDK usage objects alike; a body without tokens is a no-op, as is a call
  outside a turn.
- plugins/image_gen/openrouter: the /chat/completions path (the DEFAULT model
  chain — openai/gpt-5.4-image-2, google/gemini-3-pro-image — is chat-only and
  token-billed) now records too; the Image API path uses the shared helper
  (the contributor's local _record_image_api_usage is folded into it) and both
  pass base_url so pricing resolves the route. Task renamed image_gen ->
  image_generation to match the other aux task names.
- plugins/image_gen/openai: gpt-image bills per text/image token; record the
  Images API usage block under the API model (gpt-image-2), not the Hermes
  quality-tier label.
- FAL, xAI, Krea, DeepInfra, Meta, openai-codex return no token usage and stay
  unrecorded (nothing to bill per token).
- tests: the contributor's two tests folded into one invariant parametrized
  over chat / Image API / no-usage control; one OpenAI invariant.
- docs: image-generation.md "How It Works Internally" gains the accounting step.

Fixes #114324
This commit is contained in:
teknium1
2026-09-18 00:52:21 -07:00
committed by Teknium
parent 98ad0d8148
commit 6babdc96b8
6 changed files with 80 additions and 63 deletions

View File

@@ -172,6 +172,34 @@ class TestGenerate:
# gpt-image-2 rejects response_format — we must NOT send it.
assert "response_format" not in call_kwargs
def test_token_usage_reaches_session_accounting(self, provider):
"""gpt-image bills per token: the Images API ``usage`` block lands as one
``image_generation`` row keyed on the API model, not the Hermes tier label."""
from agent import aux_accounting
recorded = []
class _DB:
def record_auxiliary_usage(self, *args, **kwargs):
recorded.append((args, kwargs))
response = _fake_response(b64=_b64_png())
response.usage = SimpleNamespace(input_tokens=23, output_tokens=1056, total_tokens=1079)
fake_client = MagicMock()
fake_client.images.generate.return_value = response
token = aux_accounting.set_accounting_context(_DB(), "sess-1")
try:
with _patched_openai(fake_client):
result = provider.generate("a cat", aspect_ratio="landscape")
finally:
aux_accounting.reset_accounting_context(token)
assert result["success"] is True
((session_id, task), kwargs), = recorded
assert (session_id, task) == ("sess-1", "image_generation")
assert (kwargs["model"], kwargs["billing_provider"]) == ("gpt-image-2", "openai")
assert (kwargs["input_tokens"], kwargs["output_tokens"]) == (23, 1056)
@pytest.mark.parametrize("api_model,quality", [
("gpt-image-2", quality) for quality in ("low", "medium", "high")
] + [

View File

@@ -703,58 +703,48 @@ class TestImageApiSurface:
assert result["exact_aspect_ratio"] == "9:16"
assert result["image"] == "/tmp/i.png"
def test_token_billed_call_records_session_usage(self):
"""#114324: an OpenRouter token-billed call must reach session_model_usage."""
_USAGE = {"prompt_tokens": 1000, "completion_tokens": 128, "total_tokens": 1128}
@pytest.mark.parametrize("surface, model, usage", [
("chat", "openai/gpt-5.4-image-2", _USAGE), # default chain: token-billed via /chat/completions
("images", "krea/krea-2-medium", _USAGE), # curated Image API model
("images", "krea/krea-2-medium", None), # flat-fee body without usage: no write
])
def test_token_usage_reaches_session_accounting(self, surface, model, usage):
"""A response carrying token usage records one ``image_generation`` row on the ambient
session; a body without usage records nothing."""
from agent import aux_accounting
recorded = []
class _DB:
def record_auxiliary_usage(self, *args, **kwargs):
recorded.append(kwargs)
recorded.append((args, kwargs))
if surface == "chat":
response = _mock_chat_response([_PNG_DATA_URI])
response.json.return_value["usage"] = dict(usage)
else:
response = _mock_image_api_response(usage=usage)
token = aux_accounting.set_accounting_context(_DB(), "sess-1")
try:
with patch(_RUNTIME, return_value=_runtime_ok()), \
patch("requests.post", return_value=_mock_image_api_response(
usage={"total_tokens": 1128, "prompt_tokens": 1000,
"completion_tokens": 128})), \
patch("requests.post", return_value=response), \
patch("plugins.image_gen.openrouter.save_b64_image", return_value=Path("/tmp/i.png")):
result = _openrouter_image_api().generate(
prompt="p", aspect_ratio="portrait", model="krea/krea-2-medium"
)
result = _openrouter_image_api().generate(prompt="p", aspect_ratio="portrait", model=model)
finally:
aux_accounting.reset_accounting_context(token)
assert result["success"] is True
assert len(recorded) == 1
assert recorded[0]["input_tokens"] == 1000
assert recorded[0]["output_tokens"] == 128
assert recorded[0]["model"] == "krea/krea-2-medium"
if usage is None:
assert recorded == []
return
((session_id, task), kwargs), = recorded
assert (session_id, task) == ("sess-1", "image_generation")
assert kwargs["model"] == model
assert kwargs["billing_provider"] == "openrouter"
assert (kwargs["input_tokens"], kwargs["output_tokens"]) == (1000, 128)
def test_untokened_call_records_nothing(self):
"""No token usage in the body: no session write (flat-fee image models)."""
from agent import aux_accounting
recorded = []
class _DB:
def record_auxiliary_usage(self, *args, **kwargs):
recorded.append(kwargs)
token = aux_accounting.set_accounting_context(_DB(), "sess-1")
try:
with patch(_RUNTIME, return_value=_runtime_ok()), \
patch("requests.post", return_value=_mock_image_api_response()), \
patch("plugins.image_gen.openrouter.save_b64_image", return_value=Path("/tmp/i.png")):
result = _openrouter_image_api().generate(
prompt="p", aspect_ratio="portrait", model="krea/krea-2-medium"
)
finally:
aux_accounting.reset_accounting_context(token)
assert result["success"] is True
assert recorded == []
def test_multiple_images_land_in_additional_images(self):
entries = [