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.
@@ -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
+ )