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/__init__.py +7 -0
- agentcap/alerts.py +312 -0
- agentcap/clamp.py +174 -0
- agentcap/cli.py +373 -0
- agentcap/data/pricing.json +383 -0
- agentcap/devtools/__init__.py +1 -0
- agentcap/devtools/fake_upstream.py +412 -0
- agentcap/estimate.py +271 -0
- agentcap/ledger.py +387 -0
- agentcap/license.py +634 -0
- agentcap/loop.py +118 -0
- agentcap/pricing.py +620 -0
- agentcap/proxy.py +1181 -0
- agentcap/runner.py +456 -0
- agentcap/upstreams.py +132 -0
- agentcap/usage.py +428 -0
- agentcap_cli-0.1.0.dist-info/METADATA +304 -0
- agentcap_cli-0.1.0.dist-info/RECORD +21 -0
- agentcap_cli-0.1.0.dist-info/WHEEL +4 -0
- agentcap_cli-0.1.0.dist-info/entry_points.txt +2 -0
- agentcap_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|