agentcap-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
agentcap/usage.py ADDED
@@ -0,0 +1,428 @@
1
+ """Pure token-usage parsers for OpenAI and Gemini wire formats.
2
+
3
+ Nothing in this module performs I/O: every function takes already-received
4
+ JSON or bytes and returns a :class:`Usage` value (or ``None`` when the payload
5
+ carries no usage block). That keeps the accounting rules easy to test and
6
+ impossible to accidentally couple to HTTP.
7
+
8
+ Token conventions (matching the providers):
9
+
10
+ * OpenAI chat: ``prompt_tokens`` includes cached tokens; ``cached_tokens`` is
11
+ billed at the cached rate; ``completion_tokens`` includes reasoning tokens.
12
+ * OpenAI responses: ``input_tokens`` / ``output_tokens`` with
13
+ ``input_tokens_details.cached_tokens``.
14
+ * Gemini: ``promptTokenCount`` includes ``cachedContentTokenCount``;
15
+ ``thoughtsTokenCount`` is billed as output and added to
16
+ ``candidatesTokenCount``.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import codecs
22
+ import json
23
+ from dataclasses import dataclass
24
+ from typing import Any, Iterable, Iterator, Mapping
25
+
26
+ __all__ = [
27
+ "Usage",
28
+ "SSEDecoder",
29
+ "OpenAIStreamUsage",
30
+ "GeminiStreamUsage",
31
+ "openai_chat_usage",
32
+ "openai_responses_usage",
33
+ "gemini_usage",
34
+ "parse_openai_chat_stream",
35
+ "parse_openai_responses_stream",
36
+ "parse_gemini_stream",
37
+ "parse_openai_chat_usage",
38
+ "parse_openai_responses_usage",
39
+ "parse_gemini_usage",
40
+ ]
41
+
42
+
43
+ def _token(value: Any) -> int:
44
+ """Coerce a token count to a non-negative int; unknown values become 0."""
45
+
46
+ if isinstance(value, bool) or value is None:
47
+ return 0
48
+ if isinstance(value, int):
49
+ return value if value > 0 else 0
50
+ if isinstance(value, float) and value.is_integer():
51
+ return int(value) if value > 0 else 0
52
+ if isinstance(value, str):
53
+ stripped = value.strip()
54
+ if stripped.isdigit():
55
+ return int(stripped)
56
+ return 0
57
+
58
+
59
+ def _mapping(value: Any) -> Mapping[str, Any] | None:
60
+ return value if isinstance(value, Mapping) else None
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class Usage:
65
+ """Normalised usage: ``input`` includes cached tokens."""
66
+
67
+ input_tokens: int
68
+ cached_input_tokens: int = 0
69
+ output_tokens: int = 0
70
+
71
+ def __post_init__(self) -> None:
72
+ for name in ("input_tokens", "cached_input_tokens", "output_tokens"):
73
+ value = getattr(self, name)
74
+ if isinstance(value, bool) or not isinstance(value, int) or value < 0:
75
+ raise ValueError(f"{name} must be a non-negative integer")
76
+
77
+ @property
78
+ def uncached_input_tokens(self) -> int:
79
+ return max(self.input_tokens - self.cached_input_tokens, 0)
80
+
81
+ def as_dict(self) -> dict[str, int]:
82
+ return {
83
+ "input_tokens": self.input_tokens,
84
+ "cached_input_tokens": self.cached_input_tokens,
85
+ "output_tokens": self.output_tokens,
86
+ }
87
+
88
+
89
+ # --------------------------------------------------------------------- OpenAI
90
+
91
+
92
+ def openai_chat_usage(payload: Mapping[str, Any] | None) -> Usage | None:
93
+ """Usage from an OpenAI Chat Completions (or legacy Completions) payload."""
94
+
95
+ payload = _mapping(payload)
96
+ if payload is None:
97
+ return None
98
+ usage = _mapping(payload.get("usage"))
99
+ if usage is None:
100
+ return None
101
+ if not any(
102
+ key in usage
103
+ for key in ("prompt_tokens", "completion_tokens", "prompt_tokens_details")
104
+ ):
105
+ return None
106
+
107
+ cached = 0
108
+ details = _mapping(usage.get("prompt_tokens_details"))
109
+ if details is not None:
110
+ cached = _token(details.get("cached_tokens"))
111
+
112
+ return Usage(
113
+ input_tokens=_token(usage.get("prompt_tokens")),
114
+ cached_input_tokens=cached,
115
+ output_tokens=_token(usage.get("completion_tokens")),
116
+ )
117
+
118
+
119
+ def openai_responses_usage(payload: Mapping[str, Any] | None) -> Usage | None:
120
+ """Usage from an OpenAI Responses API payload (or ``response.completed``)."""
121
+
122
+ payload = _mapping(payload)
123
+ if payload is None:
124
+ return None
125
+ usage = _mapping(payload.get("usage"))
126
+ if usage is None:
127
+ return None
128
+ if not any(
129
+ key in usage
130
+ for key in ("input_tokens", "output_tokens", "input_tokens_details")
131
+ ):
132
+ return None
133
+
134
+ cached = 0
135
+ details = _mapping(usage.get("input_tokens_details"))
136
+ if details is not None:
137
+ cached = _token(details.get("cached_tokens"))
138
+
139
+ return Usage(
140
+ input_tokens=_token(usage.get("input_tokens")),
141
+ cached_input_tokens=cached,
142
+ output_tokens=_token(usage.get("output_tokens")),
143
+ )
144
+
145
+
146
+ # --------------------------------------------------------------------- Gemini
147
+
148
+
149
+ def gemini_usage(payload: Mapping[str, Any] | None) -> Usage | None:
150
+ """Usage from Gemini ``usageMetadata`` (stream chunks are cumulative)."""
151
+
152
+ payload = _mapping(payload)
153
+ if payload is None:
154
+ return None
155
+ metadata = _mapping(payload.get("usageMetadata"))
156
+ if metadata is None:
157
+ # Accept snake_case for robustness; Google emits camelCase.
158
+ metadata = _mapping(payload.get("usage_metadata"))
159
+ if metadata is None:
160
+ return None
161
+ if not any(
162
+ key in metadata
163
+ for key in (
164
+ "promptTokenCount",
165
+ "candidatesTokenCount",
166
+ "thoughtsTokenCount",
167
+ "cachedContentTokenCount",
168
+ "prompt_token_count",
169
+ )
170
+ ):
171
+ return None
172
+
173
+ def pick(*names: str) -> int:
174
+ for name in names:
175
+ if name in metadata:
176
+ return _token(metadata.get(name))
177
+ return 0
178
+
179
+ candidates = pick("candidatesTokenCount", "candidates_token_count")
180
+ thoughts = pick("thoughtsTokenCount", "thoughts_token_count")
181
+ return Usage(
182
+ input_tokens=pick("promptTokenCount", "prompt_token_count"),
183
+ cached_input_tokens=pick("cachedContentTokenCount", "cached_content_token_count"),
184
+ output_tokens=candidates + thoughts,
185
+ )
186
+
187
+
188
+ # ------------------------------------------------------------------ SSE helper
189
+
190
+
191
+ def _event_data(raw: str) -> str | None:
192
+ """Extract the ``data:`` payload from one raw SSE event block."""
193
+
194
+ lines: list[str] = []
195
+ for line in raw.split("\n"):
196
+ if line.startswith("data:"):
197
+ value = line[len("data:"):]
198
+ if value.startswith(" "):
199
+ value = value[1:]
200
+ lines.append(value)
201
+ if not lines:
202
+ return None
203
+ return "\n".join(lines)
204
+
205
+
206
+ class SSEDecoder:
207
+ """Incremental ``text/event-stream`` decoder safe across chunk splits."""
208
+
209
+ def __init__(self) -> None:
210
+ self._decoder = codecs.getincrementaldecoder("utf-8")("replace")
211
+ self._buffer = ""
212
+
213
+ def feed(self, chunk: bytes | str) -> list[str]:
214
+ if isinstance(chunk, bytes):
215
+ self._buffer += self._decoder.decode(chunk)
216
+ else:
217
+ self._buffer += chunk
218
+ self._buffer = self._buffer.replace("\r\n", "\n")
219
+ return self._drain(final=False)
220
+
221
+ def finish(self) -> list[str]:
222
+ self._buffer += self._decoder.decode(b"", final=True)
223
+ self._buffer = self._buffer.replace("\r\n", "\n")
224
+ return self._drain(final=True)
225
+
226
+ def _drain(self, *, final: bool) -> list[str]:
227
+ events: list[str] = []
228
+ while True:
229
+ index = self._buffer.find("\n\n")
230
+ if index < 0:
231
+ break
232
+ raw = self._buffer[:index]
233
+ self._buffer = self._buffer[index + 2:]
234
+ data = _event_data(raw)
235
+ if data is not None:
236
+ events.append(data)
237
+ if final and self._buffer.strip():
238
+ data = _event_data(self._buffer)
239
+ self._buffer = ""
240
+ if data is not None:
241
+ events.append(data)
242
+ return events
243
+
244
+
245
+ def _sse_payloads(chunks: Iterable[bytes | str]) -> Iterator[str]:
246
+ decoder = SSEDecoder()
247
+ for chunk in chunks:
248
+ yield from decoder.feed(chunk)
249
+ yield from decoder.finish()
250
+
251
+
252
+ class OpenAIStreamUsage:
253
+ """Accumulate usage from OpenAI SSE streams (chat and responses)."""
254
+
255
+ def __init__(self) -> None:
256
+ self._decoder = SSEDecoder()
257
+ self._usage: Usage | None = None
258
+ self._finished = False
259
+
260
+ @property
261
+ def usage(self) -> Usage | None:
262
+ return self._usage
263
+
264
+ def feed(self, chunk: bytes | str) -> Usage | None:
265
+ for data in self._decoder.feed(chunk):
266
+ self._consume(data)
267
+ return self._usage
268
+
269
+ def finish(self) -> Usage | None:
270
+ if not self._finished:
271
+ self._finished = True
272
+ for data in self._decoder.finish():
273
+ self._consume(data)
274
+ return self._usage
275
+
276
+ def _consume(self, data: str) -> None:
277
+ text = data.strip()
278
+ if not text or text == "[DONE]":
279
+ return
280
+ try:
281
+ payload: Any = json.loads(text)
282
+ except (json.JSONDecodeError, ValueError):
283
+ return
284
+ payload = _mapping(payload)
285
+ if payload is None:
286
+ return
287
+
288
+ # Responses API: the terminal event carries response.usage.
289
+ if payload.get("type") == "response.completed":
290
+ usage = openai_responses_usage(_mapping(payload.get("response")))
291
+ if usage is not None:
292
+ self._usage = usage
293
+ return
294
+
295
+ # Chat Completions: the final chunk has empty choices plus usage.
296
+ usage = openai_chat_usage(payload)
297
+ if usage is not None:
298
+ self._usage = usage
299
+
300
+
301
+ def _iter_json_objects(text: str) -> Iterator[str]:
302
+ """Yield top-level ``{...}`` objects, tolerating ``[`` and ``,`` wrappers."""
303
+
304
+ depth = 0
305
+ start = -1
306
+ in_string = False
307
+ escaped = False
308
+ for index, char in enumerate(text):
309
+ if in_string:
310
+ if escaped:
311
+ escaped = False
312
+ elif char == "\\":
313
+ escaped = True
314
+ elif char == '"':
315
+ in_string = False
316
+ continue
317
+ if char == '"':
318
+ in_string = True
319
+ elif char == "{":
320
+ if depth == 0:
321
+ start = index
322
+ depth += 1
323
+ elif char == "}":
324
+ if depth > 0:
325
+ depth -= 1
326
+ if depth == 0 and start >= 0:
327
+ yield text[start:index + 1]
328
+ start = -1
329
+
330
+
331
+ class GeminiStreamUsage:
332
+ """Accumulate the last ``usageMetadata`` from either Gemini stream format.
333
+
334
+ Gemini emits SSE when ``alt=sse`` is set and a plain JSON array otherwise.
335
+ The array body is parsed once complete; leading ``[`` / ``,`` separators and
336
+ chunk splits at arbitrary byte boundaries are tolerated.
337
+ """
338
+
339
+ def __init__(self) -> None:
340
+ self._decoder = codecs.getincrementaldecoder("utf-8")("replace")
341
+ self._mode: str | None = None
342
+ self._sse = SSEDecoder()
343
+ self._text = ""
344
+ self._usage: Usage | None = None
345
+ self._finished = False
346
+
347
+ @property
348
+ def usage(self) -> Usage | None:
349
+ return self._usage
350
+
351
+ def feed(self, chunk: bytes | str) -> Usage | None:
352
+ text = chunk if isinstance(chunk, str) else self._decoder.decode(chunk)
353
+ if self._mode is None:
354
+ stripped = text.lstrip()
355
+ if stripped:
356
+ if stripped.startswith("data:") or stripped.startswith(":"):
357
+ self._mode = "sse"
358
+ else:
359
+ # JSON array, a lone object, or comma separated objects.
360
+ self._mode = "json"
361
+ if self._mode == "sse":
362
+ for data in self._sse.feed(text):
363
+ self._consume(data)
364
+ else:
365
+ self._text += text
366
+ return self._usage
367
+
368
+ def finish(self) -> Usage | None:
369
+ if self._finished:
370
+ return self._usage
371
+ self._finished = True
372
+ if self._mode == "sse":
373
+ for data in self._sse.finish():
374
+ self._consume(data)
375
+ else:
376
+ self._text += self._decoder.decode(b"", final=True)
377
+ self._scan_json(self._text)
378
+ return self._usage
379
+
380
+ def _scan_json(self, text: str) -> None:
381
+ for raw in _iter_json_objects(text):
382
+ try:
383
+ payload = json.loads(raw)
384
+ except (json.JSONDecodeError, ValueError):
385
+ continue
386
+ self._consume_payload(payload)
387
+
388
+ def _consume(self, data: str) -> None:
389
+ stripped = data.strip()
390
+ if not stripped or stripped == "[DONE]":
391
+ return
392
+ try:
393
+ payload = json.loads(stripped)
394
+ except (json.JSONDecodeError, ValueError):
395
+ return
396
+ self._consume_payload(payload)
397
+
398
+ def _consume_payload(self, payload: Any) -> None:
399
+ usage = gemini_usage(_mapping(payload))
400
+ if usage is not None:
401
+ self._usage = usage
402
+
403
+
404
+ # -------------------------------------------------------- convenience wrappers
405
+
406
+
407
+ def parse_openai_chat_stream(chunks: Iterable[bytes | str]) -> Usage | None:
408
+ parser = OpenAIStreamUsage()
409
+ for chunk in chunks:
410
+ parser.feed(chunk)
411
+ return parser.finish()
412
+
413
+
414
+ def parse_openai_responses_stream(chunks: Iterable[bytes | str]) -> Usage | None:
415
+ return parse_openai_chat_stream(chunks)
416
+
417
+
418
+ def parse_gemini_stream(chunks: Iterable[bytes | str]) -> Usage | None:
419
+ parser = GeminiStreamUsage()
420
+ for chunk in chunks:
421
+ parser.feed(chunk)
422
+ return parser.finish()
423
+
424
+
425
+ # Descriptive aliases for embedders that prefer the "parse_*_usage" spelling.
426
+ parse_openai_chat_usage = openai_chat_usage
427
+ parse_openai_responses_usage = openai_responses_usage
428
+ parse_gemini_usage = gemini_usage