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
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
"""A tiny local fake upstream for offline AgentCap experiments.
|
|
2
|
+
|
|
3
|
+
Run it with::
|
|
4
|
+
|
|
5
|
+
python -m agentcap.devtools.fake_upstream --port 9000
|
|
6
|
+
|
|
7
|
+
and point AgentCap at it::
|
|
8
|
+
|
|
9
|
+
AGENTCAP_GEMINI_UPSTREAM=http://127.0.0.1:9000 \
|
|
10
|
+
agentcap run --cap 0.05 -- python my_agent.py
|
|
11
|
+
|
|
12
|
+
It imitates just enough of the OpenAI and Gemini wire formats to exercise
|
|
13
|
+
metering, streaming and output clamping:
|
|
14
|
+
|
|
15
|
+
* ``POST /v1/chat/completions`` (JSON, or SSE with a final usage chunk when
|
|
16
|
+
``stream_options.include_usage`` is set)
|
|
17
|
+
* ``POST /v1/responses`` (JSON with ``usage``)
|
|
18
|
+
* ``POST /v1beta/models/<model>:generateContent`` (JSON with ``usageMetadata``)
|
|
19
|
+
* ``POST /v1beta/models/<model>:streamGenerateContent`` (SSE when ``alt=sse``,
|
|
20
|
+
otherwise a JSON array; final ``usageMetadata``)
|
|
21
|
+
|
|
22
|
+
Prompt usage is realistic rather than fixed: ``max(1, ceil(text characters /
|
|
23
|
+
4))`` where the text characters are the concatenated ``content``, ``input``,
|
|
24
|
+
``prompt``, ``text``, ``system`` and tool-description strings in the request.
|
|
25
|
+
Output usage is ``min(requested output limit, 2000)`` tokens, so a clamped
|
|
26
|
+
request visibly produces a different bill. Every response carries
|
|
27
|
+
``x-fake-output-limit`` with the limit that was actually received (``none``
|
|
28
|
+
when the request had no output limit).
|
|
29
|
+
|
|
30
|
+
Request bodies and headers are never logged or stored.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import argparse
|
|
36
|
+
import json
|
|
37
|
+
import math
|
|
38
|
+
from typing import Any, AsyncIterator, Mapping
|
|
39
|
+
|
|
40
|
+
import uvicorn
|
|
41
|
+
from starlette.applications import Starlette
|
|
42
|
+
from starlette.requests import Request
|
|
43
|
+
from starlette.responses import JSONResponse, Response, StreamingResponse
|
|
44
|
+
from starlette.routing import Route
|
|
45
|
+
|
|
46
|
+
__all__ = ["create_app", "main"]
|
|
47
|
+
|
|
48
|
+
PROMPT_TOKENS_PER_CHAR = 4
|
|
49
|
+
MAX_OUTPUT_TOKENS = 2000
|
|
50
|
+
LIMIT_HEADER = "x-fake-output-limit"
|
|
51
|
+
SSE_MEDIA_TYPE = "text/event-stream"
|
|
52
|
+
|
|
53
|
+
# Keys whose string values are prompt text. Structural fields (``model``,
|
|
54
|
+
# ``role``, ``type`` ...) are deliberately excluded: they are not billed as
|
|
55
|
+
# prompt text by the real APIs.
|
|
56
|
+
_TEXT_KEYS = frozenset(
|
|
57
|
+
{
|
|
58
|
+
"text",
|
|
59
|
+
"content",
|
|
60
|
+
"input",
|
|
61
|
+
"prompt",
|
|
62
|
+
"system",
|
|
63
|
+
"system_instruction",
|
|
64
|
+
"systemInstruction",
|
|
65
|
+
"description",
|
|
66
|
+
}
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _text_chars(value: Any, *, is_text: bool = False) -> int:
|
|
71
|
+
"""Count characters of prompt text anywhere in a request body."""
|
|
72
|
+
|
|
73
|
+
if isinstance(value, str):
|
|
74
|
+
return len(value) if is_text else 0
|
|
75
|
+
if isinstance(value, Mapping):
|
|
76
|
+
total = 0
|
|
77
|
+
for key, child in value.items():
|
|
78
|
+
child_is_text = isinstance(key, str) and key in _TEXT_KEYS
|
|
79
|
+
total += _text_chars(child, is_text=child_is_text)
|
|
80
|
+
return total
|
|
81
|
+
if isinstance(value, (list, tuple)):
|
|
82
|
+
# A list under a text key may be a list of strings (embeddings input)
|
|
83
|
+
# or a list of message/part objects (whose own text keys are counted).
|
|
84
|
+
return sum(
|
|
85
|
+
_text_chars(child, is_text=is_text and isinstance(child, str))
|
|
86
|
+
for child in value
|
|
87
|
+
)
|
|
88
|
+
return 0
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _prompt_tokens(payload: Mapping[str, Any]) -> int:
|
|
92
|
+
return max(1, math.ceil(_text_chars(payload) / PROMPT_TOKENS_PER_CHAR))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _positive_int(value: Any) -> int | None:
|
|
96
|
+
if isinstance(value, bool) or value is None:
|
|
97
|
+
return None
|
|
98
|
+
if isinstance(value, int):
|
|
99
|
+
return value if value > 0 else None
|
|
100
|
+
if isinstance(value, float) and value.is_integer() and value > 0:
|
|
101
|
+
return int(value)
|
|
102
|
+
if isinstance(value, str) and value.strip().isdigit():
|
|
103
|
+
number = int(value.strip())
|
|
104
|
+
return number if number > 0 else None
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _mapping(value: Any) -> Mapping[str, Any]:
|
|
109
|
+
return value if isinstance(value, Mapping) else {}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _openai_chat_limit(payload: Mapping[str, Any]) -> int | None:
|
|
113
|
+
return _positive_int(
|
|
114
|
+
payload.get("max_completion_tokens", payload.get("max_tokens"))
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _openai_responses_limit(payload: Mapping[str, Any]) -> int | None:
|
|
119
|
+
return _positive_int(payload.get("max_output_tokens"))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _gemini_limit(payload: Mapping[str, Any]) -> int | None:
|
|
123
|
+
config = _mapping(payload.get("generationConfig"))
|
|
124
|
+
if not config:
|
|
125
|
+
config = _mapping(payload.get("generation_config"))
|
|
126
|
+
return _positive_int(
|
|
127
|
+
config.get("maxOutputTokens", config.get("max_output_tokens"))
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _completion_tokens(limit: int | None) -> int:
|
|
132
|
+
return min(limit, MAX_OUTPUT_TOKENS) if limit else MAX_OUTPUT_TOKENS
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _limit_header(limit: int | None) -> dict[str, str]:
|
|
136
|
+
return {LIMIT_HEADER: str(limit) if limit is not None else "none"}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _usage(limit: int | None, prompt_tokens: int) -> dict[str, int]:
|
|
140
|
+
output = _completion_tokens(limit)
|
|
141
|
+
return {
|
|
142
|
+
"prompt_tokens": prompt_tokens,
|
|
143
|
+
"completion_tokens": output,
|
|
144
|
+
"total_tokens": prompt_tokens + output,
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _gemini_usage(limit: int | None, prompt_tokens: int) -> dict[str, int]:
|
|
149
|
+
output = _completion_tokens(limit)
|
|
150
|
+
return {
|
|
151
|
+
"promptTokenCount": prompt_tokens,
|
|
152
|
+
"candidatesTokenCount": output,
|
|
153
|
+
"totalTokenCount": prompt_tokens + output,
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
async def _json_body(request: Request) -> Mapping[str, Any] | None:
|
|
158
|
+
try:
|
|
159
|
+
payload = json.loads(await request.body())
|
|
160
|
+
except (json.JSONDecodeError, UnicodeDecodeError, ValueError):
|
|
161
|
+
return None
|
|
162
|
+
return payload if isinstance(payload, Mapping) else None
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _invalid_body() -> JSONResponse:
|
|
166
|
+
return JSONResponse(
|
|
167
|
+
{"error": {"message": "fake upstream: body is not a JSON object"}},
|
|
168
|
+
status_code=400,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _not_found(what: str) -> JSONResponse:
|
|
173
|
+
return JSONResponse(
|
|
174
|
+
{"error": {"message": f"fake upstream: unsupported {what}"}},
|
|
175
|
+
status_code=404,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# ------------------------------------------------------------------- OpenAI
|
|
180
|
+
async def openai_chat(request: Request) -> Response:
|
|
181
|
+
payload = await _json_body(request)
|
|
182
|
+
if payload is None:
|
|
183
|
+
return _invalid_body()
|
|
184
|
+
limit = _openai_chat_limit(payload)
|
|
185
|
+
model = payload.get("model") if isinstance(payload.get("model"), str) else "fake-model"
|
|
186
|
+
headers = _limit_header(limit)
|
|
187
|
+
usage = _usage(limit, _prompt_tokens(payload))
|
|
188
|
+
|
|
189
|
+
if payload.get("stream") is not True:
|
|
190
|
+
body = {
|
|
191
|
+
"id": "chatcmpl-fake",
|
|
192
|
+
"object": "chat.completion",
|
|
193
|
+
"created": 0,
|
|
194
|
+
"model": model,
|
|
195
|
+
"choices": [
|
|
196
|
+
{
|
|
197
|
+
"index": 0,
|
|
198
|
+
"message": {"role": "assistant", "content": "fake response"},
|
|
199
|
+
"finish_reason": "stop",
|
|
200
|
+
}
|
|
201
|
+
],
|
|
202
|
+
"usage": usage,
|
|
203
|
+
}
|
|
204
|
+
return JSONResponse(body, headers=headers)
|
|
205
|
+
|
|
206
|
+
options = _mapping(payload.get("stream_options"))
|
|
207
|
+
include_usage = options.get("include_usage") is True
|
|
208
|
+
|
|
209
|
+
async def events() -> AsyncIterator[bytes]:
|
|
210
|
+
def send(data: Mapping[str, Any]) -> bytes:
|
|
211
|
+
return f"data: {json.dumps(data)}\n\n".encode("utf-8")
|
|
212
|
+
|
|
213
|
+
base = {
|
|
214
|
+
"id": "chatcmpl-fake",
|
|
215
|
+
"object": "chat.completion.chunk",
|
|
216
|
+
"created": 0,
|
|
217
|
+
"model": model,
|
|
218
|
+
}
|
|
219
|
+
yield send(
|
|
220
|
+
{
|
|
221
|
+
**base,
|
|
222
|
+
"choices": [
|
|
223
|
+
{
|
|
224
|
+
"index": 0,
|
|
225
|
+
"delta": {"role": "assistant", "content": "fake "},
|
|
226
|
+
"finish_reason": None,
|
|
227
|
+
}
|
|
228
|
+
],
|
|
229
|
+
}
|
|
230
|
+
)
|
|
231
|
+
yield send(
|
|
232
|
+
{
|
|
233
|
+
**base,
|
|
234
|
+
"choices": [
|
|
235
|
+
{
|
|
236
|
+
"index": 0,
|
|
237
|
+
"delta": {"content": "response"},
|
|
238
|
+
"finish_reason": None,
|
|
239
|
+
}
|
|
240
|
+
],
|
|
241
|
+
}
|
|
242
|
+
)
|
|
243
|
+
yield send(
|
|
244
|
+
{
|
|
245
|
+
**base,
|
|
246
|
+
"choices": [
|
|
247
|
+
{"index": 0, "delta": {}, "finish_reason": "stop"}
|
|
248
|
+
],
|
|
249
|
+
}
|
|
250
|
+
)
|
|
251
|
+
if include_usage:
|
|
252
|
+
yield send({**base, "choices": [], "usage": usage})
|
|
253
|
+
yield b"data: [DONE]\n\n"
|
|
254
|
+
|
|
255
|
+
return StreamingResponse(
|
|
256
|
+
events(),
|
|
257
|
+
media_type=SSE_MEDIA_TYPE,
|
|
258
|
+
headers={**headers, "cache-control": "no-cache"},
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
async def openai_responses(request: Request) -> Response:
|
|
263
|
+
payload = await _json_body(request)
|
|
264
|
+
if payload is None:
|
|
265
|
+
return _invalid_body()
|
|
266
|
+
limit = _openai_responses_limit(payload)
|
|
267
|
+
model = payload.get("model") if isinstance(payload.get("model"), str) else "fake-model"
|
|
268
|
+
usage = _usage(limit, _prompt_tokens(payload))
|
|
269
|
+
body = {
|
|
270
|
+
"id": "resp_fake",
|
|
271
|
+
"object": "response",
|
|
272
|
+
"created_at": 0,
|
|
273
|
+
"model": model,
|
|
274
|
+
"status": "completed",
|
|
275
|
+
"output": [
|
|
276
|
+
{
|
|
277
|
+
"type": "message",
|
|
278
|
+
"role": "assistant",
|
|
279
|
+
"content": [{"type": "output_text", "text": "fake response"}],
|
|
280
|
+
}
|
|
281
|
+
],
|
|
282
|
+
"usage": {
|
|
283
|
+
"input_tokens": usage["prompt_tokens"],
|
|
284
|
+
"output_tokens": usage["completion_tokens"],
|
|
285
|
+
"total_tokens": usage["total_tokens"],
|
|
286
|
+
},
|
|
287
|
+
}
|
|
288
|
+
return JSONResponse(body, headers=_limit_header(limit))
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
# ------------------------------------------------------------------- Gemini
|
|
292
|
+
def _gemini_chunks(limit: int | None, prompt_tokens: int) -> list[dict[str, Any]]:
|
|
293
|
+
return [
|
|
294
|
+
{
|
|
295
|
+
"candidates": [
|
|
296
|
+
{
|
|
297
|
+
"content": {
|
|
298
|
+
"role": "model",
|
|
299
|
+
"parts": [{"text": "fake "}],
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
]
|
|
303
|
+
},
|
|
304
|
+
{
|
|
305
|
+
"candidates": [
|
|
306
|
+
{
|
|
307
|
+
"content": {
|
|
308
|
+
"role": "model",
|
|
309
|
+
"parts": [{"text": "response"}],
|
|
310
|
+
},
|
|
311
|
+
"finishReason": "STOP",
|
|
312
|
+
}
|
|
313
|
+
],
|
|
314
|
+
"usageMetadata": _gemini_usage(limit, prompt_tokens),
|
|
315
|
+
},
|
|
316
|
+
]
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
async def gemini_model(request: Request) -> Response:
|
|
320
|
+
rest = request.path_params.get("rest", "")
|
|
321
|
+
action = rest.rsplit(":", 1)[-1] if ":" in rest else ""
|
|
322
|
+
payload = await _json_body(request)
|
|
323
|
+
if payload is None:
|
|
324
|
+
return _invalid_body()
|
|
325
|
+
limit = _gemini_limit(payload)
|
|
326
|
+
headers = _limit_header(limit)
|
|
327
|
+
prompt_tokens = _prompt_tokens(payload)
|
|
328
|
+
|
|
329
|
+
if action == "generateContent":
|
|
330
|
+
chunks = _gemini_chunks(limit, prompt_tokens)
|
|
331
|
+
final = chunks[-1]
|
|
332
|
+
return JSONResponse(
|
|
333
|
+
{
|
|
334
|
+
"candidates": final["candidates"],
|
|
335
|
+
"usageMetadata": final["usageMetadata"],
|
|
336
|
+
},
|
|
337
|
+
headers=headers,
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
if action == "streamGenerateContent":
|
|
341
|
+
chunks = _gemini_chunks(limit, prompt_tokens)
|
|
342
|
+
if request.query_params.get("alt") == "sse":
|
|
343
|
+
|
|
344
|
+
async def events() -> AsyncIterator[bytes]:
|
|
345
|
+
for chunk in chunks:
|
|
346
|
+
yield f"data: {json.dumps(chunk)}\n\n".encode("utf-8")
|
|
347
|
+
|
|
348
|
+
return StreamingResponse(
|
|
349
|
+
events(),
|
|
350
|
+
media_type=SSE_MEDIA_TYPE,
|
|
351
|
+
headers={**headers, "cache-control": "no-cache"},
|
|
352
|
+
)
|
|
353
|
+
return JSONResponse(chunks, headers=headers)
|
|
354
|
+
|
|
355
|
+
return _not_found("Gemini action")
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
# ------------------------------------------------------------------- routes
|
|
359
|
+
async def healthz(_request: Request) -> Response:
|
|
360
|
+
return JSONResponse({"status": "ok"})
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def create_app() -> Starlette:
|
|
364
|
+
"""Build the fake upstream ASGI app (no state, no logging)."""
|
|
365
|
+
|
|
366
|
+
return Starlette(
|
|
367
|
+
routes=[
|
|
368
|
+
Route("/healthz", healthz, methods=["GET"]),
|
|
369
|
+
Route(
|
|
370
|
+
"/v1/chat/completions",
|
|
371
|
+
openai_chat,
|
|
372
|
+
methods=["POST"],
|
|
373
|
+
),
|
|
374
|
+
Route("/v1/responses", openai_responses, methods=["POST"]),
|
|
375
|
+
Route(
|
|
376
|
+
"/v1beta/models/{rest:path}",
|
|
377
|
+
gemini_model,
|
|
378
|
+
methods=["POST"],
|
|
379
|
+
),
|
|
380
|
+
]
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
385
|
+
parser = argparse.ArgumentParser(
|
|
386
|
+
prog="python -m agentcap.devtools.fake_upstream",
|
|
387
|
+
description="Local fake OpenAI/Gemini upstream for AgentCap testing.",
|
|
388
|
+
)
|
|
389
|
+
parser.add_argument(
|
|
390
|
+
"--port", type=int, default=0, help="port to listen on (default: random)"
|
|
391
|
+
)
|
|
392
|
+
return parser
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def main(argv: list[str] | None = None) -> int:
|
|
396
|
+
args = _build_parser().parse_args(argv)
|
|
397
|
+
if args.port < 0 or args.port > 65535:
|
|
398
|
+
print("fake_upstream: --port must be between 0 and 65535")
|
|
399
|
+
return 2
|
|
400
|
+
# 127.0.0.1 only: never expose a fake upstream to the network.
|
|
401
|
+
uvicorn.run(
|
|
402
|
+
create_app(),
|
|
403
|
+
host="127.0.0.1",
|
|
404
|
+
port=args.port,
|
|
405
|
+
log_level="warning",
|
|
406
|
+
access_log=False,
|
|
407
|
+
)
|
|
408
|
+
return 0
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
if __name__ == "__main__":
|
|
412
|
+
raise SystemExit(main())
|
agentcap/estimate.py
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""Conservative token estimation and worst-case cost reservation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
from datetime import date, datetime
|
|
8
|
+
from decimal import Decimal, ROUND_CEILING
|
|
9
|
+
from typing import Any, Mapping
|
|
10
|
+
|
|
11
|
+
from .pricing import MICRO_USD, ModelPrice, effective_rates
|
|
12
|
+
|
|
13
|
+
_TEXT_FIELDS = ("messages", "input", "contents", "system", "tools")
|
|
14
|
+
_MEDIA_TOKENS = 2000
|
|
15
|
+
|
|
16
|
+
_OPENAI_MEDIA_TYPES = {"image_url", "input_image", "file", "input_file"}
|
|
17
|
+
_GEMINI_MEDIA_KEYS = ("inline_data", "inlineData", "file_data", "fileData")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _as_mapping(body: Mapping[str, Any] | dict[str, Any]) -> Mapping[str, Any]:
|
|
21
|
+
if not isinstance(body, Mapping):
|
|
22
|
+
raise TypeError("request body must be a mapping")
|
|
23
|
+
return body
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def count_media_parts(body: Mapping[str, Any]) -> int:
|
|
27
|
+
"""Count image/file parts (OpenAI and Gemini) anywhere in the body."""
|
|
28
|
+
|
|
29
|
+
body = _as_mapping(body)
|
|
30
|
+
|
|
31
|
+
def walk(value: Any) -> int:
|
|
32
|
+
count = 0
|
|
33
|
+
if isinstance(value, Mapping):
|
|
34
|
+
part_type = value.get("type")
|
|
35
|
+
is_openai_media = isinstance(part_type, str) and part_type in _OPENAI_MEDIA_TYPES
|
|
36
|
+
is_gemini_media = any(key in value for key in _GEMINI_MEDIA_KEYS)
|
|
37
|
+
# A part may carry both a type marker and a media payload; count once.
|
|
38
|
+
if is_openai_media or is_gemini_media:
|
|
39
|
+
count += 1
|
|
40
|
+
for child in value.values():
|
|
41
|
+
count += walk(child)
|
|
42
|
+
elif isinstance(value, (list, tuple)):
|
|
43
|
+
for child in value:
|
|
44
|
+
count += walk(child)
|
|
45
|
+
return count
|
|
46
|
+
|
|
47
|
+
return walk(body)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _relevant_text_chars(body: Mapping[str, Any]) -> int:
|
|
51
|
+
chars = 0
|
|
52
|
+
for field in _TEXT_FIELDS:
|
|
53
|
+
if field in body and body[field] is not None:
|
|
54
|
+
serialized = json.dumps(
|
|
55
|
+
body[field], ensure_ascii=False, sort_keys=True, separators=(",", ":")
|
|
56
|
+
)
|
|
57
|
+
chars += len(serialized)
|
|
58
|
+
return chars
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def estimate_input_tokens(body: Mapping[str, Any] | dict[str, Any]) -> int:
|
|
62
|
+
"""Estimate prompt tokens with a deliberately conservative formula.
|
|
63
|
+
|
|
64
|
+
``ceil(characters / 3) * 1.10 + 50`` plus a flat 2000 tokens for every
|
|
65
|
+
image or file part. Overestimating is safe: it only widens the
|
|
66
|
+
reservation, never the spend.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
body = _as_mapping(body)
|
|
70
|
+
base = math.ceil(_relevant_text_chars(body) / 3)
|
|
71
|
+
overhead = math.ceil(base * 1.10) + 50
|
|
72
|
+
return overhead + count_media_parts(body) * _MEDIA_TOKENS
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def apply_input_safety_factor(input_tokens: int, factor: Decimal) -> int:
|
|
76
|
+
"""Widen an input-token estimate for a reservation (rounded up).
|
|
77
|
+
|
|
78
|
+
The factor only pads the worst-case reservation; settlement always uses
|
|
79
|
+
the usage the provider reported. ``ProxyConfig`` requires a factor of at
|
|
80
|
+
least 1 so the reservation can only widen.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
if (
|
|
84
|
+
isinstance(input_tokens, bool)
|
|
85
|
+
or not isinstance(input_tokens, int)
|
|
86
|
+
or input_tokens < 0
|
|
87
|
+
):
|
|
88
|
+
raise ValueError("input_tokens must be a non-negative integer")
|
|
89
|
+
if isinstance(factor, bool) or not isinstance(factor, Decimal):
|
|
90
|
+
raise TypeError("factor must be a Decimal")
|
|
91
|
+
if not factor.is_finite() or factor < 0:
|
|
92
|
+
raise ValueError("factor must be a finite non-negative decimal")
|
|
93
|
+
return int(
|
|
94
|
+
(Decimal(input_tokens) * factor).to_integral_value(
|
|
95
|
+
rounding=ROUND_CEILING
|
|
96
|
+
)
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def cache_write_requested(body: Mapping[str, Any] | dict[str, Any]) -> bool:
|
|
101
|
+
"""True when the request can write to a prompt cache.
|
|
102
|
+
|
|
103
|
+
OpenAI-style cache retention is signalled by a top-level ``prompt_cache*``
|
|
104
|
+
key (e.g. ``prompt_cache_retention``); cache-write capable content parts
|
|
105
|
+
carry a ``cache_control`` key anywhere in the body.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
body = _as_mapping(body)
|
|
109
|
+
for key in body:
|
|
110
|
+
if isinstance(key, str) and key.startswith("prompt_cache"):
|
|
111
|
+
return True
|
|
112
|
+
|
|
113
|
+
def walk(value: Any) -> bool:
|
|
114
|
+
if isinstance(value, Mapping):
|
|
115
|
+
if "cache_control" in value:
|
|
116
|
+
return True
|
|
117
|
+
return any(walk(child) for child in value.values())
|
|
118
|
+
if isinstance(value, (list, tuple)):
|
|
119
|
+
return any(walk(child) for child in value)
|
|
120
|
+
return False
|
|
121
|
+
|
|
122
|
+
return walk(body)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _positive_int(value: Any) -> int | None:
|
|
126
|
+
if isinstance(value, bool) or value is None:
|
|
127
|
+
return None
|
|
128
|
+
if isinstance(value, int):
|
|
129
|
+
return value if value > 0 else None
|
|
130
|
+
if isinstance(value, float) and value.is_integer() and value > 0:
|
|
131
|
+
return int(value)
|
|
132
|
+
if isinstance(value, str):
|
|
133
|
+
stripped = value.strip()
|
|
134
|
+
if stripped.isdigit() and int(stripped) > 0:
|
|
135
|
+
return int(stripped)
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _normalise_provider(provider: str) -> str:
|
|
140
|
+
normalised = provider.strip().lower()
|
|
141
|
+
if normalised in {"openai", "open-ai"}:
|
|
142
|
+
return "openai"
|
|
143
|
+
if normalised in {"google", "gemini", "google-gemini", "google_genai"}:
|
|
144
|
+
return "gemini"
|
|
145
|
+
raise ValueError(f"Unsupported provider for token estimation: {provider!r}")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _openai_max_tokens(body: Mapping[str, Any]) -> int | None:
|
|
149
|
+
# Chat Completions uses max_completion_tokens (new) or max_tokens (old).
|
|
150
|
+
# Responses API uses max_output_tokens. Prefer the most explicit fields.
|
|
151
|
+
for field in ("max_completion_tokens", "max_output_tokens", "max_tokens"):
|
|
152
|
+
value = _positive_int(body.get(field))
|
|
153
|
+
if value is not None:
|
|
154
|
+
return value
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _gemini_max_tokens(body: Mapping[str, Any]) -> int | None:
|
|
159
|
+
candidates: list[Mapping[str, Any]] = []
|
|
160
|
+
for config_key in ("generationConfig", "generation_config"):
|
|
161
|
+
value = body.get(config_key)
|
|
162
|
+
if isinstance(value, Mapping):
|
|
163
|
+
candidates.append(value)
|
|
164
|
+
for config in candidates:
|
|
165
|
+
for field in ("maxOutputTokens", "max_output_tokens"):
|
|
166
|
+
value = _positive_int(config.get(field))
|
|
167
|
+
if value is not None:
|
|
168
|
+
return value
|
|
169
|
+
return None
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _openai_candidates(body: Mapping[str, Any]) -> int:
|
|
173
|
+
# ``n`` produces n completions; each may use the full output budget.
|
|
174
|
+
return _positive_int(body.get("n")) or 1
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _gemini_candidates(body: Mapping[str, Any]) -> int:
|
|
178
|
+
for config_key in ("generationConfig", "generation_config"):
|
|
179
|
+
value = body.get(config_key)
|
|
180
|
+
if isinstance(value, Mapping):
|
|
181
|
+
for field in ("candidateCount", "candidate_count"):
|
|
182
|
+
count = _positive_int(value.get(field))
|
|
183
|
+
if count is not None:
|
|
184
|
+
return count
|
|
185
|
+
return 1
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def explicit_output_tokens(provider: str, body: Mapping[str, Any]) -> int | None:
|
|
189
|
+
"""The output limit the client itself asked for, if any."""
|
|
190
|
+
|
|
191
|
+
body = _as_mapping(body)
|
|
192
|
+
if _normalise_provider(provider) == "openai":
|
|
193
|
+
return _openai_max_tokens(body)
|
|
194
|
+
return _gemini_max_tokens(body)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def candidate_count(provider: str, body: Mapping[str, Any]) -> int:
|
|
198
|
+
"""How many completions/candidates one request can produce (>= 1)."""
|
|
199
|
+
|
|
200
|
+
body = _as_mapping(body)
|
|
201
|
+
if _normalise_provider(provider) == "openai":
|
|
202
|
+
return _openai_candidates(body)
|
|
203
|
+
return _gemini_candidates(body)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def max_output_tokens(
|
|
207
|
+
provider: str, body: Mapping[str, Any], model_price: ModelPrice
|
|
208
|
+
) -> int:
|
|
209
|
+
"""Worst-case output tokens for a request, clamped to the model default."""
|
|
210
|
+
|
|
211
|
+
body = _as_mapping(body)
|
|
212
|
+
normalised = _normalise_provider(provider)
|
|
213
|
+
|
|
214
|
+
if normalised == "openai":
|
|
215
|
+
requested = _openai_max_tokens(body)
|
|
216
|
+
candidates = _openai_candidates(body)
|
|
217
|
+
else:
|
|
218
|
+
requested = _gemini_max_tokens(body)
|
|
219
|
+
candidates = _gemini_candidates(body)
|
|
220
|
+
|
|
221
|
+
per_candidate = requested if requested is not None else model_price.max_output_tokens
|
|
222
|
+
return per_candidate * candidates
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def reservation_usd(
|
|
226
|
+
model_price: ModelPrice,
|
|
227
|
+
input_tokens: int,
|
|
228
|
+
output_tokens: int,
|
|
229
|
+
now: datetime | date | None = None,
|
|
230
|
+
cache_write: bool = False,
|
|
231
|
+
) -> Decimal:
|
|
232
|
+
"""Worst-case USD reservation, rounded up to 6 decimals.
|
|
233
|
+
|
|
234
|
+
Cached prompt tokens are ignored here: the reservation assumes no cache
|
|
235
|
+
hits. A long-context prompt is reserved at the higher tier. When
|
|
236
|
+
``cache_write`` is true, input is reserved at
|
|
237
|
+
``max(input, cache_write)`` (conservative).
|
|
238
|
+
"""
|
|
239
|
+
|
|
240
|
+
if isinstance(input_tokens, bool) or not isinstance(input_tokens, int) or input_tokens < 0:
|
|
241
|
+
raise ValueError("input_tokens must be a non-negative integer")
|
|
242
|
+
if isinstance(output_tokens, bool) or not isinstance(output_tokens, int) or output_tokens < 0:
|
|
243
|
+
raise ValueError("output_tokens must be a non-negative integer")
|
|
244
|
+
|
|
245
|
+
input_rate, _cached_rate, output_rate = effective_rates(
|
|
246
|
+
model_price, input_tokens, now=now
|
|
247
|
+
)
|
|
248
|
+
if cache_write and model_price.cache_write_per_mtok is not None:
|
|
249
|
+
input_rate = max(input_rate, model_price.cache_write_per_mtok)
|
|
250
|
+
total = (
|
|
251
|
+
Decimal(input_tokens) * input_rate + Decimal(output_tokens) * output_rate
|
|
252
|
+
) / Decimal("1000000")
|
|
253
|
+
return total.quantize(MICRO_USD, rounding=ROUND_CEILING)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def estimate_request(
|
|
257
|
+
provider: str,
|
|
258
|
+
body: Mapping[str, Any],
|
|
259
|
+
model_price: ModelPrice,
|
|
260
|
+
now: datetime | date | None = None,
|
|
261
|
+
cache_write: bool | None = None,
|
|
262
|
+
) -> tuple[int, int, Decimal]:
|
|
263
|
+
"""Convenience helper: ``(input_tokens, output_tokens, reservation_usd)``."""
|
|
264
|
+
|
|
265
|
+
input_tokens = estimate_input_tokens(body)
|
|
266
|
+
output_tokens = max_output_tokens(provider, body, model_price)
|
|
267
|
+
if cache_write is None:
|
|
268
|
+
cache_write = cache_write_requested(body)
|
|
269
|
+
return input_tokens, output_tokens, reservation_usd(
|
|
270
|
+
model_price, input_tokens, output_tokens, now=now, cache_write=cache_write
|
|
271
|
+
)
|