4040 "session_sort_key" ,
4141 "shutdown_output_tokens" ,
4242 "total_output_tokens" ,
43+ "parse_token_int" ,
4344]
4445
4546# ---------------------------------------------------------------------------
@@ -208,6 +209,36 @@ class ToolRequest(BaseModel):
208209 type : str = ""
209210
210211
212+ def parse_token_int (raw : object ) -> int | None :
213+ """Parse a raw ``outputTokens`` value into a positive ``int``, or ``None``.
214+
215+ Centralises the token-validation rules shared by
216+ :meth:`AssistantMessageData._sanitize_non_numeric_tokens` (Pydantic
217+ boundary) and :func:`~copilot_usage.parser._extract_output_tokens`
218+ (parser fast path).
219+
220+ Rules:
221+
222+ - ``bool`` / ``str`` → ``None`` (invalid, not coerced)
223+ - non-whole ``float`` → ``None``
224+ - zero or negative ``int`` / ``float`` → ``None``
225+ - positive whole-number ``float`` → coerced to ``int``
226+ - positive ``int`` → returned as-is
227+ - any other type → ``None``
228+ """
229+ if isinstance (raw , (bool , str )):
230+ return None
231+ if isinstance (raw , float ):
232+ if not raw .is_integer ():
233+ return None
234+ tokens = int (raw )
235+ elif isinstance (raw , int ):
236+ tokens = raw
237+ else :
238+ return None
239+ return tokens if tokens > 0 else None
240+
241+
211242class AssistantMessageData (BaseModel ):
212243 """Payload for ``assistant.message`` events."""
213244
@@ -221,25 +252,20 @@ class AssistantMessageData(BaseModel):
221252 def _sanitize_non_numeric_tokens (cls , v : object ) -> object :
222253 """Map non-positive, non-numeric, and non-whole-float token counts to ``0``.
223254
224- JSON ``true``/``false``, numeric strings like ``"100"``,
225- non-positive numeric values, and non-integer floats (e.g. ``1.5``)
226- are not valid token counts. Returning ``0`` preserves parsing of
227- the rest of the assistant message payload while preventing these
228- values from being lax-coerced into token counts.
229-
230- This aligns with ``_extract_output_tokens`` in the parser fast path:
231- both paths agree that only positive whole-number values contribute
232- tokens.
255+ Delegates to :func:`parse_token_int` for the actual validation
256+ logic. ``None`` (JSON ``null``) and types the helper recognises
257+ (``bool``, ``str``, ``int``, ``float``) are mapped to ``0`` when
258+ they don't represent a positive whole-number count, so that
259+ Pydantic's downstream ``int`` coercion always succeeds. Unknown
260+ types are passed through so that Pydantic can raise its own
261+ ``ValidationError``.
233262 """
234- if isinstance (v , (bool , str )):
235- return 0
236- if isinstance (v , float ):
237- if not v .is_integer () or v <= 0 :
238- return 0
239- return int (v )
240- if isinstance (v , int ) and v <= 0 :
263+ if v is None :
241264 return 0
242- return v
265+ if not isinstance (v , (bool , str , int , float )):
266+ return v
267+ result = parse_token_int (v )
268+ return result if result is not None else 0
243269
244270 reasoningText : str | None = None
245271 reasoningOpaque : str | None = None
0 commit comments