Skip to content

Commit 48e7a55

Browse files
llm: enforce Usage non-negative constraint per spec §6
Spec §6 documents `Usage` token counts as non-negative integers, but the fields were plain `int | None` and silently accepted negatives. Add `Field(ge=0)` so a malformed wire response surfaces as `provider_invalid_response` (the `_parse_response` Usage build wraps the resulting `ValidationError`, matching the existing `AssistantMessage` pattern).
1 parent 40c8da9 commit 48e7a55

3 files changed

Lines changed: 69 additions & 14 deletions

File tree

src/openarmature/llm/openai.py

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
from typing import Any, cast
4343

4444
import httpx
45+
from pydantic import ValidationError
4546

4647
from .errors import (
4748
ProviderAuthentication,
@@ -273,17 +274,23 @@ def _parse_response(self, payload: dict[str, Any]) -> Response:
273274
raise ProviderInvalidResponse(f"could not parse assistant message: {exc}") from exc
274275

275276
# Usage is optional — build a Usage with all-None fields if
276-
# the provider didn't report it.
277+
# the provider didn't report it. Per spec §6, token counts MUST
278+
# be non-negative integers; a wire response that violates that
279+
# surfaces as ``provider_invalid_response`` rather than
280+
# silently passing through.
277281
usage_wire_raw = payload.get("usage")
278-
if isinstance(usage_wire_raw, dict):
279-
usage_wire = cast("dict[str, Any]", usage_wire_raw)
280-
usage = Usage(
281-
prompt_tokens=usage_wire.get("prompt_tokens"),
282-
completion_tokens=usage_wire.get("completion_tokens"),
283-
total_tokens=usage_wire.get("total_tokens"),
284-
)
285-
else:
286-
usage = Usage(prompt_tokens=None, completion_tokens=None, total_tokens=None)
282+
try:
283+
if isinstance(usage_wire_raw, dict):
284+
usage_wire = cast("dict[str, Any]", usage_wire_raw)
285+
usage = Usage(
286+
prompt_tokens=usage_wire.get("prompt_tokens"),
287+
completion_tokens=usage_wire.get("completion_tokens"),
288+
total_tokens=usage_wire.get("total_tokens"),
289+
)
290+
else:
291+
usage = Usage(prompt_tokens=None, completion_tokens=None, total_tokens=None)
292+
except ValidationError as exc:
293+
raise ProviderInvalidResponse(f"invalid usage record: {exc}") from exc
287294

288295
return Response(
289296
message=assistant_msg,

src/openarmature/llm/response.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818

1919
from typing import Any, Literal
2020

21-
from pydantic import BaseModel, ConfigDict
21+
from pydantic import BaseModel, ConfigDict, Field
2222

2323
from .messages import AssistantMessage
2424

@@ -38,9 +38,9 @@ class Usage(BaseModel):
3838

3939
model_config = ConfigDict(extra="forbid")
4040

41-
prompt_tokens: int | None
42-
completion_tokens: int | None
43-
total_tokens: int | None
41+
prompt_tokens: int | None = Field(ge=0)
42+
completion_tokens: int | None = Field(ge=0)
43+
total_tokens: int | None = Field(ge=0)
4444

4545

4646
class Response(BaseModel):

tests/unit/test_llm_provider.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
Tool,
3939
ToolCall,
4040
ToolMessage,
41+
Usage,
4142
UserMessage,
4243
validate_message_list,
4344
validate_tools,
@@ -434,3 +435,50 @@ def _ok(_req: httpx.Request) -> httpx.Response:
434435
assert actual_t == expected_t
435436
finally:
436437
await provider.aclose()
438+
439+
440+
# ---------------------------------------------------------------------------
441+
# Usage non-negative constraint (spec §6: "non-negative integer or None")
442+
# ---------------------------------------------------------------------------
443+
444+
445+
def test_usage_negative_token_count_rejected_at_construction() -> None:
446+
with pytest.raises(ValidationError):
447+
Usage(prompt_tokens=-1, completion_tokens=0, total_tokens=0)
448+
449+
450+
async def test_complete_negative_usage_surfaces_as_invalid_response() -> None:
451+
"""A wire response carrying a negative token count MUST surface as
452+
``provider_invalid_response`` rather than silently passing through —
453+
spec §6 token counts are non-negative integers."""
454+
455+
def _bad(_req: httpx.Request) -> httpx.Response:
456+
return httpx.Response(
457+
200,
458+
json={
459+
"id": "x",
460+
"object": "chat.completion",
461+
"created": 0,
462+
"model": "m",
463+
"choices": [
464+
{
465+
"index": 0,
466+
"message": {"role": "assistant", "content": "ok"},
467+
"finish_reason": "stop",
468+
}
469+
],
470+
"usage": {"prompt_tokens": -5, "completion_tokens": 1, "total_tokens": 1},
471+
},
472+
)
473+
474+
provider = OpenAIProvider(
475+
base_url="http://test",
476+
model="m",
477+
api_key="k",
478+
transport=httpx.MockTransport(_bad),
479+
)
480+
try:
481+
with pytest.raises(ProviderInvalidResponse, match="invalid usage record"):
482+
await provider.complete([UserMessage(content="hi")])
483+
finally:
484+
await provider.aclose()

0 commit comments

Comments
 (0)