Skip to content

Commit 97e5bc9

Browse files
committed
fix(openai): address Codex review round 2 (PR #67)
Three P2 issues from the post-fix review pass: 1. **input_tokens double-counted on cache hit.** ``input_tokens`` is now the uncached portion (``prompt_tokens - cached_tokens``), matching the Responses/faux providers' accounting. Without this, cubebox cost aggregation counted the cached prefix twice (full-price input AND cache_read). 2. **on_payload couldn't disable stream_options.include_usage.** Some OpenAI-compatible backends reject ``stream_options`` entirely. Switch from unconditional override to ``setdefault``-style: only set ``include_usage=True`` when the caller hasn't already configured it. 3. **$ref resolution lost sibling metadata.** Pydantic emits ``$ref`` alongside ``description`` / ``default`` on Optional[Enum] fields (JSON Schema 2020-12 allows sibling keys). The previous code returned only the resolved $def, dropping the field-level description and default. Merge siblings onto the resolved schema; the $def's own values win on key collision. Tests: 6 new unit tests in test_openai_extras_and_schema.py — two per fix. openai.py coverage stays at 100%.
1 parent 202a137 commit 97e5bc9

2 files changed

Lines changed: 246 additions & 7 deletions

File tree

cubepi/providers/openai.py

Lines changed: 47 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,14 @@ async def _produce() -> None:
9999
kwargs["extra_body"] = {**self._extra_body, **kwargs["extra_body"]}
100100

101101
# Request per-stream usage so we can populate AssistantMessage.usage.
102-
kwargs.setdefault("stream_options", {})["include_usage"] = True
102+
# Only set ``include_usage`` if the caller hasn't already
103+
# configured it via on_payload — some OpenAI-compatible
104+
# backends reject ``stream_options`` entirely, so callers
105+
# need to be able to opt out by setting it to False (or
106+
# removing the key).
107+
so = kwargs.setdefault("stream_options", {})
108+
if "include_usage" not in so:
109+
so["include_usage"] = True
103110

104111
response = await self._client.chat.completions.create(**kwargs)
105112

@@ -139,15 +146,23 @@ async def _produce() -> None:
139146
# trailing chunk with no choices and usage populated).
140147
if getattr(chunk, "usage", None) is not None:
141148
u = chunk.usage
142-
partial.usage = Usage(
143-
input_tokens=getattr(u, "prompt_tokens", 0) or 0,
144-
output_tokens=getattr(u, "completion_tokens", 0) or 0,
145-
cache_read_tokens=getattr(
149+
prompt_tokens = getattr(u, "prompt_tokens", 0) or 0
150+
cached_tokens = (
151+
getattr(
146152
getattr(u, "prompt_tokens_details", None),
147153
"cached_tokens",
148154
0,
149155
)
150-
or 0,
156+
or 0
157+
)
158+
# ``input_tokens`` is the uncached prompt portion; the
159+
# cached prefix is reported separately. This matches
160+
# the Responses/faux providers' accounting so cost
161+
# aggregation across providers doesn't double-count.
162+
partial.usage = Usage(
163+
input_tokens=max(prompt_tokens - cached_tokens, 0),
164+
output_tokens=getattr(u, "completion_tokens", 0) or 0,
165+
cache_read_tokens=cached_tokens,
151166
)
152167

153168
if response_id is None and getattr(chunk, "id", None):
@@ -468,13 +483,38 @@ def _normalise_tool_schema(
468483
if "$ref" in schema:
469484
ref_name = schema["$ref"].split("/")[-1]
470485
if defs and ref_name in defs:
471-
return N(
486+
resolved = N(
472487
defs[ref_name],
473488
defs=defs,
474489
top=False,
475490
strip_title=not in_any_of,
476491
in_any_of=False,
477492
)
493+
# JSON Schema 2020-12 allows sibling keys alongside
494+
# $ref; Pydantic emits ``description`` / ``default`` /
495+
# validators on Optional[Enum] fields, etc. Merge them
496+
# onto the resolved definition so field-level metadata
497+
# isn't silently dropped when we inline.
498+
siblings = {
499+
k: v
500+
for k, v in schema.items()
501+
if k != "$ref" and not (k == "title" and not in_any_of)
502+
}
503+
if siblings and isinstance(resolved, dict):
504+
merged: dict[str, Any] = dict(resolved)
505+
for k, v in siblings.items():
506+
merged.setdefault(
507+
k,
508+
N(
509+
v,
510+
defs=defs,
511+
top=False,
512+
strip_title=not in_any_of,
513+
in_any_of=False,
514+
),
515+
)
516+
return merged
517+
return resolved
478518
# Unknown ref — leave as-is.
479519
return schema
480520

tests/providers/test_openai_extras_and_schema.py

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,3 +259,202 @@ def test_normalise_works_on_pydantic_generated_schema() -> None:
259259
# Property titles stripped.
260260
for prop in out["properties"].values():
261261
assert "title" not in prop
262+
263+
264+
# ---------------------------------------------------------------------------
265+
# Codex review #5 — input_tokens excludes cached_tokens (cache-hit accounting)
266+
# ---------------------------------------------------------------------------
267+
268+
269+
@pytest.mark.asyncio
270+
async def test_usage_subtracts_cached_tokens_from_input() -> None:
271+
"""``input_tokens`` is the uncached portion; cached prefix reported
272+
separately, matching Responses/faux accounting (avoids double-count
273+
in cubebox cost aggregation)."""
274+
usage_chunk = SimpleNamespace(
275+
id="x",
276+
choices=[],
277+
usage=SimpleNamespace(
278+
prompt_tokens=1000,
279+
completion_tokens=50,
280+
prompt_tokens_details=SimpleNamespace(cached_tokens=800),
281+
),
282+
)
283+
final = _make_chunk(finish_reason="stop")
284+
285+
with patch("openai.AsyncOpenAI") as mock_openai:
286+
client = MagicMock()
287+
mock_openai.return_value = client
288+
client.chat = MagicMock()
289+
client.chat.completions = MagicMock()
290+
291+
async def create(**_):
292+
return _async_iter([usage_chunk, final])
293+
294+
client.chat.completions.create = create
295+
296+
provider = OpenAIProvider(api_key="x")
297+
provider._client = client
298+
299+
ms = await provider.stream(
300+
_model(),
301+
[UserMessage(content=[TextContent(text="hi")])],
302+
)
303+
async for _ in ms:
304+
pass
305+
msg = await ms.result()
306+
307+
assert msg.usage.input_tokens == 200 # 1000 - 800
308+
assert msg.usage.cache_read_tokens == 800
309+
assert msg.usage.output_tokens == 50
310+
311+
312+
@pytest.mark.asyncio
313+
async def test_usage_no_cached_field_input_tokens_is_full_prompt() -> None:
314+
"""No prompt_tokens_details → cached_tokens=0, input_tokens=prompt_tokens."""
315+
usage_chunk = SimpleNamespace(
316+
id="x",
317+
choices=[],
318+
usage=SimpleNamespace(
319+
prompt_tokens=200,
320+
completion_tokens=10,
321+
prompt_tokens_details=None,
322+
),
323+
)
324+
final = _make_chunk(finish_reason="stop")
325+
326+
with patch("openai.AsyncOpenAI") as mock_openai:
327+
client = MagicMock()
328+
mock_openai.return_value = client
329+
client.chat = MagicMock()
330+
client.chat.completions = MagicMock()
331+
332+
async def create(**_):
333+
return _async_iter([usage_chunk, final])
334+
335+
client.chat.completions.create = create
336+
337+
provider = OpenAIProvider(api_key="x")
338+
provider._client = client
339+
340+
ms = await provider.stream(
341+
_model(),
342+
[UserMessage(content=[TextContent(text="hi")])],
343+
)
344+
async for _ in ms:
345+
pass
346+
msg = await ms.result()
347+
348+
assert msg.usage.input_tokens == 200
349+
assert msg.usage.cache_read_tokens == 0
350+
351+
352+
# ---------------------------------------------------------------------------
353+
# Codex review #6 — on_payload may opt out of stream_options.include_usage
354+
# ---------------------------------------------------------------------------
355+
356+
357+
@pytest.mark.asyncio
358+
async def test_on_payload_can_disable_include_usage() -> None:
359+
"""Caller-supplied ``stream_options.include_usage=False`` must survive
360+
the default-inject step."""
361+
captured: dict[str, Any] = {}
362+
363+
async def on_payload(payload, model):
364+
payload["stream_options"] = {"include_usage": False}
365+
return payload
366+
367+
with patch("openai.AsyncOpenAI") as mock_openai:
368+
client = MagicMock()
369+
mock_openai.return_value = client
370+
client.chat = MagicMock()
371+
client.chat.completions = MagicMock()
372+
373+
async def capture(**kwargs):
374+
captured.update(kwargs)
375+
return _async_iter([_make_chunk(finish_reason="stop")])
376+
377+
client.chat.completions.create = capture
378+
379+
provider = OpenAIProvider(api_key="x")
380+
provider._client = client
381+
382+
ms = await provider.stream(
383+
_model(),
384+
[UserMessage(content=[TextContent(text="hi")])],
385+
options=StreamOptions(on_payload=on_payload),
386+
)
387+
async for _ in ms:
388+
pass
389+
await ms.result()
390+
391+
assert captured["stream_options"] == {"include_usage": False}
392+
393+
394+
@pytest.mark.asyncio
395+
async def test_default_adds_include_usage_when_not_set() -> None:
396+
"""Without on_payload customization, default adds include_usage=True."""
397+
captured: dict[str, Any] = {}
398+
with patch("openai.AsyncOpenAI") as mock_openai:
399+
client = MagicMock()
400+
mock_openai.return_value = client
401+
client.chat = MagicMock()
402+
client.chat.completions = MagicMock()
403+
404+
async def capture(**kwargs):
405+
captured.update(kwargs)
406+
return _async_iter([_make_chunk(finish_reason="stop")])
407+
408+
client.chat.completions.create = capture
409+
410+
provider = OpenAIProvider(api_key="x")
411+
provider._client = client
412+
413+
ms = await provider.stream(
414+
_model(),
415+
[UserMessage(content=[TextContent(text="hi")])],
416+
)
417+
async for _ in ms:
418+
pass
419+
await ms.result()
420+
421+
assert captured["stream_options"] == {"include_usage": True}
422+
423+
424+
# ---------------------------------------------------------------------------
425+
# Codex review #7 — preserve sibling metadata when $ref-resolving
426+
# ---------------------------------------------------------------------------
427+
428+
429+
def test_normalise_ref_resolution_preserves_sibling_description() -> None:
430+
"""Pydantic emits ``$ref`` alongside ``description`` / ``default`` on
431+
Optional[Enum] fields; both must survive inlining."""
432+
schema = {
433+
"$defs": {"Color": {"title": "Color", "enum": ["r", "g"], "type": "string"}},
434+
"properties": {
435+
"c": {
436+
"$ref": "#/$defs/Color",
437+
"description": "primary colour",
438+
"default": "r",
439+
}
440+
},
441+
}
442+
out = OpenAIProvider._normalise_tool_schema(schema)
443+
resolved = out["properties"]["c"]
444+
assert resolved["enum"] == ["r", "g"]
445+
assert resolved["description"] == "primary colour"
446+
assert resolved["default"] == "r"
447+
448+
449+
def test_normalise_ref_resolution_resolved_def_wins_on_key_collision() -> None:
450+
"""If both $def and sibling supply the same key, the $def value wins
451+
(sibling is treated as additional metadata, not an override)."""
452+
schema = {
453+
"$defs": {"X": {"type": "string", "description": "from def"}},
454+
"properties": {
455+
"x": {"$ref": "#/$defs/X", "description": "from sibling"},
456+
},
457+
}
458+
out = OpenAIProvider._normalise_tool_schema(schema)
459+
# setdefault means the def's value sticks.
460+
assert out["properties"]["x"]["description"] == "from def"

0 commit comments

Comments
 (0)