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/proxy.py ADDED
@@ -0,0 +1,1181 @@
1
+ """Local HTTP proxy that reserves spend before forwarding to OpenAI / Gemini.
2
+
3
+ Design rules that this module enforces:
4
+
5
+ * Money is always :class:`~decimal.Decimal`; every reservation is checked and
6
+ inserted atomically by the ledger before any upstream byte is sent.
7
+ * Fail closed: an unknown model, malformed body or unmetered endpoint blocks
8
+ the request (HTTP 403/400) instead of forwarding it unmetered.
9
+ * Credentials (``Authorization``, ``x-goog-api-key``, ``?key=``) are forwarded
10
+ untouched but never parsed, logged, stored or emitted in events.
11
+ * Failures to meter (client disconnect, missing usage block) settle the whole
12
+ reservation: over-charging a run is safe, under-charging is not.
13
+
14
+ Endpoint contract::
15
+
16
+ POST /openai/v1/chat/completions
17
+ POST /openai/v1/responses
18
+ POST /openai/v1/completions
19
+ POST /openai/v1/embeddings
20
+ POST /gemini/v1beta/models/<model>:generateContent
21
+ POST /gemini/v1beta/models/<model>:streamGenerateContent
22
+ GET /_agentcap/status
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import contextlib
28
+ import json
29
+ import re
30
+ from dataclasses import dataclass, field
31
+ from datetime import datetime, timezone
32
+ from decimal import ROUND_CEILING, Decimal
33
+ from typing import Any, AsyncIterator, Callable, Iterable, Mapping
34
+
35
+ import httpx
36
+ from starlette.applications import Starlette
37
+ from starlette.requests import Request
38
+ from starlette.responses import JSONResponse, Response, StreamingResponse
39
+ from starlette.routing import Route
40
+
41
+ from .clamp import MIN_CLAMP_OUTPUT, apply_output_clamp, fit_output
42
+ from .estimate import (
43
+ apply_input_safety_factor,
44
+ cache_write_requested,
45
+ estimate_input_tokens,
46
+ max_output_tokens,
47
+ reservation_usd,
48
+ )
49
+ from .ledger import CapExceeded, Caps, Ledger
50
+ from .license import FREE_FEATURES
51
+ from .loop import LoopDetector, request_hash
52
+ from .pricing import (
53
+ MICRO_USD,
54
+ ModelPrice,
55
+ PricingStore,
56
+ UnknownModelError,
57
+ UnknownServiceTierError,
58
+ cost as pricing_cost,
59
+ load_pricing,
60
+ )
61
+ from .usage import (
62
+ GeminiStreamUsage,
63
+ OpenAIStreamUsage,
64
+ Usage,
65
+ gemini_usage,
66
+ openai_chat_usage,
67
+ openai_responses_usage,
68
+ )
69
+
70
+ __all__ = ["ProxyConfig", "create_app"]
71
+
72
+ DEFAULT_OPENAI_UPSTREAM = "https://api.openai.com"
73
+ DEFAULT_GEMINI_UPSTREAM = "https://generativelanguage.googleapis.com"
74
+
75
+ _OPENAI_METERED = {
76
+ "v1/chat/completions": ("openai_chat", "chat"),
77
+ "v1/responses": ("openai_responses", "responses"),
78
+ "v1/completions": ("openai_legacy_completions", "chat"),
79
+ "v1/embeddings": ("openai_embeddings", "embeddings"),
80
+ }
81
+
82
+ # path looks like v1beta/models/gemini-2.5-flash:generateContent
83
+ _GEMINI_METERED = re.compile(
84
+ r"(?:^|/)models/(?P<model>[^/:]+):(?P<action>generateContent|streamGenerateContent)$"
85
+ )
86
+
87
+ _REQUEST_HEADER_DROP = frozenset({"host", "content-length", "accept-encoding"})
88
+ _RESPONSE_HEADER_DROP = frozenset(
89
+ {"content-length", "transfer-encoding", "content-encoding"}
90
+ )
91
+
92
+ _UNMETERED_MESSAGE = (
93
+ "agentcap: endpoint not metered; blocked (fail-closed). "
94
+ "Use --allow-unmetered to pass through."
95
+ )
96
+ _INVALID_BODY_MESSAGE = (
97
+ "agentcap: request body is not valid JSON; blocked (fail-closed)."
98
+ )
99
+
100
+ EventCallback = Callable[[dict[str, Any]], None]
101
+ KillCallback = Callable[..., Any]
102
+
103
+ _SERVICE_TIER_RE = re.compile(rb'"service_tier"\s*:\s*"([^"]+)"')
104
+
105
+
106
+ @dataclass(frozen=True)
107
+ class _Settlement:
108
+ """Everything needed to price a request's actual usage."""
109
+
110
+ price: ModelPrice
111
+ cache_write: bool = False
112
+ requested_tier: str | None = None
113
+ multiplier: Decimal = Decimal("1")
114
+ now: datetime | None = None
115
+
116
+
117
+ @dataclass(frozen=True)
118
+ class _Budget:
119
+ """The tightest active scope and how much room it still has."""
120
+
121
+ remaining: Decimal
122
+ scope: str
123
+ cap: Decimal
124
+ committed: Decimal
125
+
126
+
127
+ @dataclass
128
+ class ProxyConfig:
129
+ """Everything the proxy needs to meter one agent run."""
130
+
131
+ run_id: str
132
+ caps: Caps = field(default_factory=Caps)
133
+ features: Iterable[str] | None = None
134
+ loop_threshold: int = 5
135
+ loop_window_s: float = 120.0
136
+ openai_upstream: str = DEFAULT_OPENAI_UPSTREAM
137
+ gemini_upstream: str = DEFAULT_GEMINI_UPSTREAM
138
+ inject_openai_stream_usage: bool = True
139
+ clamp_output: bool = True
140
+ min_clamp_output: int = MIN_CLAMP_OUTPUT
141
+ #: Padding applied to estimated input tokens when reserving. Providers may
142
+ #: bill extra system prompts, tool schemas, images or tokenizer overhead
143
+ #: that the local estimate cannot see; a wider reservation keeps that
144
+ #: estimation error out of the budget. Settlement still uses real usage.
145
+ input_safety_factor: Decimal = Decimal("1.25")
146
+ allow_unmetered: bool = False
147
+ on_cap_exceeded: KillCallback | None = None
148
+ on_loop_detected: KillCallback | None = None
149
+ pricing: PricingStore | None = None
150
+ request_timeout_s: float = 600.0
151
+ clock: Callable[[], datetime] | None = None
152
+
153
+ def __post_init__(self) -> None:
154
+ if not isinstance(self.run_id, str) or not self.run_id:
155
+ raise ValueError("run_id must be a non-empty string")
156
+ if not isinstance(self.caps, Caps):
157
+ raise TypeError("caps must be a Caps instance")
158
+ if not isinstance(self.clamp_output, bool):
159
+ raise TypeError("clamp_output must be a bool")
160
+ if (
161
+ isinstance(self.min_clamp_output, bool)
162
+ or not isinstance(self.min_clamp_output, int)
163
+ or self.min_clamp_output <= 0
164
+ ):
165
+ raise ValueError("min_clamp_output must be a positive integer")
166
+ if isinstance(self.input_safety_factor, bool) or not isinstance(
167
+ self.input_safety_factor, Decimal
168
+ ):
169
+ raise TypeError("input_safety_factor must be a Decimal")
170
+ if (
171
+ not self.input_safety_factor.is_finite()
172
+ or self.input_safety_factor < 1
173
+ ):
174
+ raise ValueError("input_safety_factor must be a finite Decimal >= 1")
175
+ if self.features is None:
176
+ self.features = frozenset(FREE_FEATURES)
177
+ else:
178
+ self.features = frozenset(self.features)
179
+ if self.openai_upstream:
180
+ self.openai_upstream = self.openai_upstream.rstrip("/")
181
+ if self.gemini_upstream:
182
+ self.gemini_upstream = self.gemini_upstream.rstrip("/")
183
+
184
+ # Convenience for callers that only care about booleans.
185
+ def has(self, feature: str) -> bool:
186
+ return feature in self.features # type: ignore[operator]
187
+
188
+
189
+ def _money(value: Decimal) -> str:
190
+ """Format a USD amount the way users read it (``$5.00``)."""
191
+
192
+ return f"${value:.2f}"
193
+
194
+
195
+ def _optional_money(value: Decimal | None) -> str | None:
196
+ return None if value is None else str(value)
197
+
198
+
199
+ def _agentcap_error(
200
+ status_code: int,
201
+ message: str,
202
+ error_type: str,
203
+ *,
204
+ code: str | None = None,
205
+ headers: Mapping[str, str] | None = None,
206
+ ) -> JSONResponse:
207
+ error: dict[str, str] = {"message": message, "type": error_type}
208
+ if code is not None:
209
+ error["code"] = code
210
+ return JSONResponse(
211
+ {"error": error},
212
+ status_code=status_code,
213
+ headers=dict(headers or {}),
214
+ )
215
+
216
+
217
+ def _filter_query(request: Request) -> str:
218
+ query = request.url.query
219
+ return query.decode() if isinstance(query, bytes) else str(query or "")
220
+
221
+
222
+ def _parse_usage(
223
+ kind: str, payload: Any
224
+ ) -> Usage | None:
225
+ if not isinstance(payload, Mapping):
226
+ return None
227
+ if kind == "openai_responses":
228
+ return openai_responses_usage(payload)
229
+ if kind == "gemini":
230
+ return gemini_usage(payload)
231
+ # chat, legacy completions and embeddings all carry prompt/completion keys.
232
+ return openai_chat_usage(payload)
233
+
234
+
235
+ class _Proxy:
236
+ def __init__(
237
+ self,
238
+ config: ProxyConfig,
239
+ ledger: Ledger,
240
+ upstream_transport: httpx.AsyncBaseTransport | None = None,
241
+ events: EventCallback | None = None,
242
+ ) -> None:
243
+ if not isinstance(config, ProxyConfig):
244
+ raise TypeError("config must be a ProxyConfig")
245
+ if not isinstance(ledger, Ledger):
246
+ raise TypeError("ledger must be a Ledger")
247
+ self.config = config
248
+ self.ledger = ledger
249
+ self.events = events
250
+ self.pricing = config.pricing if config.pricing is not None else load_pricing()
251
+ self.loop: LoopDetector | None = None
252
+ if config.has("loop_detection"):
253
+ self.loop = LoopDetector(
254
+ threshold=config.loop_threshold, window_s=config.loop_window_s
255
+ )
256
+ self.blocked_count = 0
257
+ self.clamped_requests = 0
258
+ self.killed = False
259
+ self._thresholds_emitted: set[int] = set()
260
+ self._client = httpx.AsyncClient(
261
+ transport=upstream_transport,
262
+ timeout=httpx.Timeout(config.request_timeout_s, connect=10.0),
263
+ )
264
+
265
+ # ------------------------------------------------------------- app wiring
266
+ def build(self) -> Starlette:
267
+ @contextlib.asynccontextmanager
268
+ async def lifespan(_app: Starlette):
269
+ try:
270
+ yield
271
+ finally:
272
+ with contextlib.suppress(Exception):
273
+ await self._client.aclose()
274
+
275
+ methods = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]
276
+ app = Starlette(
277
+ routes=[
278
+ Route(
279
+ "/openai/{path:path}", self._handle_openai, methods=methods
280
+ ),
281
+ Route(
282
+ "/gemini/{path:path}", self._handle_gemini, methods=methods
283
+ ),
284
+ Route("/_agentcap/status", self._status, methods=["GET"]),
285
+ ],
286
+ lifespan=lifespan,
287
+ )
288
+ app.state.config = self.config
289
+ app.state.ledger = self.ledger
290
+ app.state.proxy = self
291
+ app.state.http_client = self._client
292
+ return app
293
+
294
+ # ---------------------------------------------------------------- events
295
+ def _emit(self, event: dict[str, Any]) -> None:
296
+ if self.events is None:
297
+ return
298
+ try:
299
+ self.events(event)
300
+ except Exception:
301
+ # Alerting must never take the proxy down.
302
+ pass
303
+
304
+ def _invoke(self, callback: KillCallback | None, payload: dict[str, Any]) -> None:
305
+ if callback is None:
306
+ return
307
+ try:
308
+ callback(payload)
309
+ except TypeError:
310
+ try:
311
+ callback()
312
+ except Exception:
313
+ pass
314
+ except Exception:
315
+ pass
316
+
317
+ def _trip_kill(self, callback: KillCallback | None, payload: dict[str, Any]) -> None:
318
+ self.killed = True
319
+ self._invoke(callback, payload)
320
+
321
+ def _kill_once(
322
+ self, callback: KillCallback | None, payload: dict[str, Any]
323
+ ) -> None:
324
+ """Fire a kill callback at most once per run (dedupe repeated rejects)."""
325
+
326
+ if self.killed:
327
+ return
328
+ self._trip_kill(callback, payload)
329
+
330
+ # ---------------------------------------------------------------- status
331
+ async def _status(self, request: Request) -> JSONResponse:
332
+ summary = self.ledger.summary(self.config.run_id)
333
+ payload = {
334
+ "run_id": self.config.run_id,
335
+ "spent_usd": str(summary["actual_usd"]),
336
+ "reserved_usd": str(summary["reserved_usd"]),
337
+ "caps": {
338
+ "run_usd": _optional_money(self.config.caps.run_usd),
339
+ "hour_usd": _optional_money(self.config.caps.hour_usd),
340
+ "day_usd": _optional_money(self.config.caps.day_usd),
341
+ },
342
+ "blocked_count": self.blocked_count,
343
+ "clamped_requests": self.clamped_requests,
344
+ "killed": self.killed,
345
+ }
346
+ return JSONResponse(payload)
347
+
348
+ # -------------------------------------------------------------- routing
349
+ async def _handle_openai(self, request: Request) -> Response:
350
+ return await self._handle(request, "openai")
351
+
352
+ async def _handle_gemini(self, request: Request) -> Response:
353
+ return await self._handle(request, "gemini")
354
+
355
+ async def _handle(self, request: Request, provider: str) -> Response:
356
+ path = request.path_params["path"]
357
+ body = await request.body()
358
+ decision = self._classify(provider, path, request.method)
359
+ if decision == "blocked":
360
+ self.blocked_count += 1
361
+ return _agentcap_error(403, _UNMETERED_MESSAGE, "agentcap_blocked")
362
+ if decision == "allowed_unmetered":
363
+ self._emit(
364
+ {
365
+ "type": "unmetered",
366
+ "provider": provider,
367
+ "method": request.method,
368
+ "path": path,
369
+ }
370
+ )
371
+ return await self._forward_raw(request, provider, path, body)
372
+ if decision is None:
373
+ return await self._forward_raw(request, provider, path, body)
374
+
375
+ kind, model, action = decision
376
+ return await self._metered(
377
+ request, provider, path, body, kind=kind, model=model, action=action
378
+ )
379
+
380
+ def _classify(
381
+ self, provider: str, path: str, method: str
382
+ ) -> tuple[str, str, str] | str | None:
383
+ """Return ``(kind, model, action)``, a passthrough marker, or None."""
384
+
385
+ if provider == "openai":
386
+ if method != "POST":
387
+ return None # GET /v1/models and friends are free passthroughs
388
+ entry = _OPENAI_METERED.get(path.strip("/"))
389
+ if entry is not None:
390
+ kind, action = entry
391
+ return (kind, "", action)
392
+ return "allowed_unmetered" if self.config.allow_unmetered else "blocked"
393
+
394
+ if method != "POST":
395
+ return None
396
+ match = _GEMINI_METERED.search(path)
397
+ if match is not None:
398
+ return ("gemini", match.group("model"), match.group("action"))
399
+ if path.rstrip("/").endswith(":countTokens"):
400
+ return None # token counting is free
401
+ return "allowed_unmetered" if self.config.allow_unmetered else "blocked"
402
+
403
+ # -------------------------------------------------------------- metered
404
+ def _effective_caps(self) -> Caps:
405
+ return Caps(
406
+ run_usd=self.config.caps.run_usd,
407
+ hour_usd=self.config.caps.hour_usd
408
+ if self.config.has("hour_cap")
409
+ else None,
410
+ day_usd=self.config.caps.day_usd if self.config.has("day_cap") else None,
411
+ )
412
+
413
+ def _budget_state(self, caps: Caps) -> _Budget | None:
414
+ """Remaining room in the tightest active scope (spent + reserved)."""
415
+
416
+ states: list[tuple[str, Decimal, Decimal]] = []
417
+ if caps.run_usd is not None:
418
+ states.append(
419
+ ("run", caps.run_usd, self.ledger.spent(self.config.run_id))
420
+ )
421
+ if caps.hour_usd is not None:
422
+ states.append(
423
+ ("hour", caps.hour_usd, self.ledger.window_spent(3600.0))
424
+ )
425
+ if caps.day_usd is not None:
426
+ states.append(
427
+ ("day", caps.day_usd, self.ledger.window_spent(86400.0))
428
+ )
429
+ if not states:
430
+ return None
431
+ scope, cap, committed = min(states, key=lambda item: item[1] - item[2])
432
+ return _Budget(
433
+ remaining=cap - committed, scope=scope, cap=cap, committed=committed
434
+ )
435
+
436
+ def _reject_cap_exceeded(
437
+ self,
438
+ budget: _Budget,
439
+ needed: Decimal,
440
+ model: str,
441
+ provider: str,
442
+ ) -> Response:
443
+ """Budget truly exhausted: reject and kill (at most once per run)."""
444
+
445
+ self.blocked_count += 1
446
+ payload = {
447
+ "scope": budget.scope,
448
+ "cap_usd": str(budget.cap),
449
+ "spent_usd": str(budget.committed),
450
+ "needed_usd": str(needed),
451
+ "model": model,
452
+ "provider": provider,
453
+ }
454
+ self._emit({"type": "cap_exceeded", **payload})
455
+ self._kill_once(
456
+ self.config.on_cap_exceeded,
457
+ {"type": "cap_exceeded", "reason": "cap_exceeded", **payload},
458
+ )
459
+ return _agentcap_error(
460
+ 429,
461
+ "agentcap: spend cap reached "
462
+ f"(scope={budget.scope}, cap={_money(budget.cap)}, "
463
+ f"spent={_money(budget.committed)}, needed={_money(needed)}). "
464
+ "Request NOT sent.",
465
+ "agentcap_cap_exceeded",
466
+ code="agentcap_cap_exceeded",
467
+ headers={"x-agentcap-blocked": "cap"},
468
+ )
469
+
470
+ def _reject_request_too_large(
471
+ self,
472
+ budget: _Budget,
473
+ needed: Decimal,
474
+ model: str,
475
+ provider: str,
476
+ ) -> Response:
477
+ """Clamping is off and one request is bigger than the remaining budget.
478
+
479
+ This is not a cap hit: the run may still be able to make cheaper
480
+ requests, so no kill callback fires.
481
+ """
482
+
483
+ self.blocked_count += 1
484
+ payload = {
485
+ "scope": budget.scope,
486
+ "cap_usd": str(budget.cap),
487
+ "spent_usd": str(budget.committed),
488
+ "needed_usd": str(needed),
489
+ "remaining_usd": str(budget.remaining),
490
+ "model": model,
491
+ "provider": provider,
492
+ }
493
+ self._emit({"type": "request_too_large", **payload})
494
+ return _agentcap_error(
495
+ 429,
496
+ "agentcap: request too large for the remaining budget "
497
+ f"(scope={budget.scope}, cap={_money(budget.cap)}, "
498
+ f"spent={_money(budget.committed)}, needed={_money(needed)}). "
499
+ "Request NOT sent. Raise the cap, set an output limit, or enable "
500
+ "output clamping.",
501
+ "agentcap_request_too_large",
502
+ code="agentcap_request_too_large",
503
+ headers={"x-agentcap-blocked": "size"},
504
+ )
505
+
506
+ def _lookup_price(self, provider: str, model: str) -> ModelPrice:
507
+ if provider == "gemini" and not model.startswith("google/"):
508
+ return self.pricing.get(f"google/{model}")
509
+ return self.pricing.get(model)
510
+
511
+ async def _metered(
512
+ self,
513
+ request: Request,
514
+ provider: str,
515
+ path: str,
516
+ body: bytes,
517
+ *,
518
+ kind: str,
519
+ model: str,
520
+ action: str,
521
+ ) -> Response:
522
+ try:
523
+ parsed = json.loads(body)
524
+ except (json.JSONDecodeError, UnicodeDecodeError, ValueError):
525
+ self.blocked_count += 1
526
+ return _agentcap_error(400, _INVALID_BODY_MESSAGE, "agentcap_invalid_body")
527
+ if not isinstance(parsed, dict):
528
+ self.blocked_count += 1
529
+ return _agentcap_error(400, _INVALID_BODY_MESSAGE, "agentcap_invalid_body")
530
+
531
+ if provider == "openai":
532
+ raw_model = parsed.get("model")
533
+ model = raw_model.strip() if isinstance(raw_model, str) else ""
534
+ if not model:
535
+ self.blocked_count += 1
536
+ return _unknown_model_error("")
537
+
538
+ try:
539
+ price = self._lookup_price(provider, model)
540
+ except UnknownModelError:
541
+ self.blocked_count += 1
542
+ return _unknown_model_error(model)
543
+
544
+ # Service tiers (OpenAI only). An unknown tier is fail-closed unless
545
+ # the operator explicitly opted out with --allow-unmetered.
546
+ requested_tier: str | None = None
547
+ tier_multiplier = Decimal("1")
548
+ if provider == "openai" and "service_tier" in parsed:
549
+ raw_tier = parsed.get("service_tier")
550
+ if not isinstance(raw_tier, str) or not raw_tier.strip():
551
+ if not self.config.allow_unmetered:
552
+ self.blocked_count += 1
553
+ return _unknown_service_tier_error(str(raw_tier))
554
+ raw_tier = None
555
+ requested_tier = raw_tier.strip() if isinstance(raw_tier, str) else None
556
+ if requested_tier is None:
557
+ tier_multiplier = Decimal("1")
558
+ else:
559
+ try:
560
+ tier_multiplier = self.pricing.service_tier_multiplier(
561
+ "openai", requested_tier
562
+ )
563
+ except UnknownServiceTierError:
564
+ if not self.config.allow_unmetered:
565
+ self.blocked_count += 1
566
+ return _unknown_service_tier_error(requested_tier)
567
+ tier_multiplier = Decimal("1")
568
+
569
+ now = self._now()
570
+ cache_write = cache_write_requested(parsed)
571
+
572
+ # Loop detection happens before money is reserved.
573
+ if self.loop is not None:
574
+ if self.loop.record_request(parsed):
575
+ self.blocked_count += 1
576
+ # ``on_loop_detected`` is the specific hook; a generic kill
577
+ # callback supplied as ``on_cap_exceeded`` also stops the run.
578
+ kill_callback = (
579
+ self.config.on_loop_detected or self.config.on_cap_exceeded
580
+ )
581
+ self._kill_once(
582
+ kill_callback,
583
+ {
584
+ "type": "loop_detected",
585
+ "reason": "loop_detected",
586
+ "model": model,
587
+ "threshold": self.config.loop_threshold,
588
+ "window_s": self.config.loop_window_s,
589
+ "request_hash": request_hash(parsed),
590
+ },
591
+ )
592
+ self._emit(
593
+ {
594
+ "type": "loop_detected",
595
+ "model": model,
596
+ "provider": provider,
597
+ "threshold": self.config.loop_threshold,
598
+ "window_s": self.config.loop_window_s,
599
+ "request_hash": request_hash(parsed),
600
+ }
601
+ )
602
+ return _agentcap_error(
603
+ 429,
604
+ "agentcap: identical request repeated "
605
+ f"{self.config.loop_threshold} times within "
606
+ f"{self.config.loop_window_s:g}s; blocked to stop a runaway "
607
+ "loop. Request NOT sent.",
608
+ "agentcap_loop_detected",
609
+ headers={"x-agentcap-blocked": "loop"},
610
+ )
611
+
612
+ # Worst-case reservation. Embeddings have no output tokens. The
613
+ # input estimate is padded by ``input_safety_factor`` because providers
614
+ # may bill system prompts, tool schemas, images or tokenizer overhead
615
+ # that the local estimate cannot see.
616
+ input_tokens = apply_input_safety_factor(
617
+ estimate_input_tokens(parsed), self.config.input_safety_factor
618
+ )
619
+ estimate_provider = "gemini" if provider == "gemini" else "openai"
620
+ if kind == "openai_embeddings":
621
+ output_tokens = 0
622
+ else:
623
+ output_tokens = max_output_tokens(estimate_provider, parsed, price)
624
+ reserved = reservation_usd(
625
+ price, input_tokens, output_tokens, now=now, cache_write=cache_write
626
+ )
627
+ if tier_multiplier != 1:
628
+ reserved = (reserved * tier_multiplier).quantize(
629
+ MICRO_USD, rounding=ROUND_CEILING
630
+ )
631
+ caps = self._effective_caps()
632
+ budget = self._budget_state(caps)
633
+
634
+ clamped_output: int | None = None
635
+ if budget is not None and reserved > budget.remaining:
636
+ # The worst case does not fit. The run is only out of budget when
637
+ # the tightest scope has nothing left; otherwise this is one
638
+ # oversized request and clamping (or a 429) can handle it.
639
+ if budget.remaining <= 0:
640
+ return self._reject_cap_exceeded(
641
+ budget, reserved, model, provider
642
+ )
643
+ if output_tokens <= 0:
644
+ # Input-only requests (embeddings) have no output limit to
645
+ # clamp, so this request cannot be made to fit. The run may
646
+ # still afford smaller requests: do not kill it.
647
+ return self._reject_request_too_large(
648
+ budget, reserved, model, provider
649
+ )
650
+ fit = fit_output(
651
+ price,
652
+ provider=estimate_provider,
653
+ body=parsed,
654
+ input_tokens=input_tokens,
655
+ remaining=budget.remaining,
656
+ now=now,
657
+ cache_write=cache_write,
658
+ tier_multiplier=tier_multiplier,
659
+ minimum=self.config.min_clamp_output,
660
+ )
661
+ if (
662
+ not self.config.clamp_output
663
+ or fit.allowed_output < self.config.min_clamp_output
664
+ ):
665
+ if not self.config.clamp_output:
666
+ return self._reject_request_too_large(
667
+ budget, reserved, model, provider
668
+ )
669
+ return self._reject_cap_exceeded(
670
+ budget, max(reserved, fit.reservation), model, provider
671
+ )
672
+ apply_output_clamp(provider, path, parsed, fit.allowed_output)
673
+ clamped_output = fit.allowed_output
674
+ reserved = fit.reservation
675
+
676
+ try:
677
+ reservation_id = self.ledger.reserve(
678
+ self.config.run_id,
679
+ "google" if provider == "gemini" else "openai",
680
+ model,
681
+ reserved,
682
+ caps,
683
+ )
684
+ except CapExceeded as exc:
685
+ self.blocked_count += 1
686
+ self._emit(
687
+ {
688
+ "type": "cap_exceeded",
689
+ "scope": exc.scope,
690
+ "cap_usd": str(exc.cap),
691
+ "spent_usd": str(exc.committed),
692
+ "needed_usd": str(exc.requested),
693
+ "model": model,
694
+ "provider": provider,
695
+ }
696
+ )
697
+ self._kill_once(
698
+ self.config.on_cap_exceeded,
699
+ {
700
+ "type": "cap_exceeded",
701
+ "reason": "cap_exceeded",
702
+ "scope": exc.scope,
703
+ "cap_usd": str(exc.cap),
704
+ "spent_usd": str(exc.committed),
705
+ "needed_usd": str(exc.requested),
706
+ },
707
+ )
708
+ return _agentcap_error(
709
+ 429,
710
+ "agentcap: spend cap reached "
711
+ f"(scope={exc.scope}, cap={_money(exc.cap)}, "
712
+ f"spent={_money(exc.committed)}, needed={_money(exc.requested)}). "
713
+ "Request NOT sent.",
714
+ "agentcap_cap_exceeded",
715
+ code="agentcap_cap_exceeded",
716
+ headers={"x-agentcap-blocked": "cap"},
717
+ )
718
+
719
+ if clamped_output is not None:
720
+ self.clamped_requests += 1
721
+ self._emit(
722
+ {
723
+ "type": "output_clamped",
724
+ "run_id": self.config.run_id,
725
+ "model": model,
726
+ "provider": provider,
727
+ "allowed_output_tokens": clamped_output,
728
+ }
729
+ )
730
+
731
+ # The only permitted body mutations: ask OpenAI for a final usage chunk
732
+ # and lower an output limit that does not fit the remaining budget.
733
+ forwarded = body
734
+ streaming = False
735
+ body_changed = clamped_output is not None
736
+ if provider == "openai":
737
+ streaming = parsed.get("stream") is True
738
+ if (
739
+ kind == "openai_chat"
740
+ and streaming
741
+ and self.config.inject_openai_stream_usage
742
+ ):
743
+ options = parsed.get("stream_options")
744
+ options = dict(options) if isinstance(options, Mapping) else {}
745
+ options["include_usage"] = True
746
+ parsed["stream_options"] = options
747
+ body_changed = True
748
+ else:
749
+ streaming = action == "streamGenerateContent"
750
+ if body_changed:
751
+ forwarded = json.dumps(parsed, separators=(",", ":")).encode("utf-8")
752
+
753
+ upstream_request = self._build_request(request, provider, path, forwarded)
754
+ settlement = _Settlement(
755
+ price=price,
756
+ cache_write=cache_write,
757
+ requested_tier=requested_tier,
758
+ multiplier=tier_multiplier,
759
+ now=now,
760
+ )
761
+
762
+ if streaming:
763
+ return await self._stream(
764
+ upstream_request,
765
+ reservation_id=reservation_id,
766
+ reserved=reserved,
767
+ settlement=settlement,
768
+ model=model,
769
+ kind=kind,
770
+ provider=provider,
771
+ )
772
+ return await self._non_stream(
773
+ upstream_request,
774
+ reservation_id=reservation_id,
775
+ reserved=reserved,
776
+ settlement=settlement,
777
+ model=model,
778
+ kind=kind,
779
+ provider=provider,
780
+ )
781
+
782
+ # ----------------------------------------------------------- forwarding
783
+ def _upstream_base(self, provider: str) -> str:
784
+ return (
785
+ self.config.openai_upstream
786
+ if provider == "openai"
787
+ else self.config.gemini_upstream
788
+ )
789
+
790
+ def _build_request(
791
+ self, request: Request, provider: str, path: str, body: bytes
792
+ ) -> httpx.Request:
793
+ base = self._upstream_base(provider)
794
+ url = f"{base}/{path.lstrip('/')}"
795
+ query = _filter_query(request)
796
+ if query:
797
+ url = f"{url}?{query}"
798
+ headers = [
799
+ (key, value)
800
+ for key, value in request.headers.items()
801
+ if key.lower() not in _REQUEST_HEADER_DROP
802
+ ]
803
+ # Force identity so response bodies can be parsed for usage.
804
+ headers.append(("accept-encoding", "identity"))
805
+ return self._client.build_request(
806
+ request.method, url, content=body, headers=headers
807
+ )
808
+
809
+ @staticmethod
810
+ def _response_headers(response: httpx.Response) -> list[tuple[str, str]]:
811
+ return [
812
+ (key, value)
813
+ for key, value in response.headers.multi_items()
814
+ if key.lower() not in _RESPONSE_HEADER_DROP
815
+ ]
816
+
817
+ async def _forward_raw(
818
+ self, request: Request, provider: str, path: str, body: bytes
819
+ ) -> Response:
820
+ upstream = self._build_request(request, provider, path, body)
821
+ try:
822
+ response = await self._client.send(upstream)
823
+ except httpx.HTTPError:
824
+ return _agentcap_error(
825
+ 502,
826
+ "agentcap: upstream request failed; nothing was metered.",
827
+ "agentcap_upstream_error",
828
+ )
829
+ try:
830
+ content = await response.aread()
831
+ except httpx.HTTPError:
832
+ return _agentcap_error(
833
+ 502,
834
+ "agentcap: upstream request failed; nothing was metered.",
835
+ "agentcap_upstream_error",
836
+ )
837
+ finally:
838
+ with contextlib.suppress(Exception):
839
+ await response.aclose()
840
+ return Response(
841
+ content=content,
842
+ status_code=response.status_code,
843
+ headers=dict(self._response_headers(response)),
844
+ )
845
+
846
+ async def _non_stream(
847
+ self,
848
+ upstream: httpx.Request,
849
+ *,
850
+ reservation_id: str,
851
+ reserved: Decimal,
852
+ settlement: _Settlement,
853
+ model: str,
854
+ kind: str,
855
+ provider: str,
856
+ ) -> Response:
857
+ response: httpx.Response | None = None
858
+ try:
859
+ response = await self._client.send(upstream)
860
+ content = await response.aread()
861
+ except httpx.HTTPError:
862
+ self._release_quietly(reservation_id)
863
+ self._emit(
864
+ {
865
+ "type": "upstream_error",
866
+ "model": model,
867
+ "provider": provider,
868
+ }
869
+ )
870
+ return _agentcap_error(
871
+ 502,
872
+ "agentcap: upstream request failed; reservation released.",
873
+ "agentcap_upstream_error",
874
+ )
875
+ finally:
876
+ if response is not None:
877
+ with contextlib.suppress(Exception):
878
+ await response.aclose()
879
+
880
+ assert response is not None
881
+ payload = _load_json(content)
882
+ usage = _parse_usage(kind, payload)
883
+ if usage is not None:
884
+ self._settle_actual(
885
+ reservation_id,
886
+ reserved,
887
+ settlement,
888
+ model,
889
+ provider,
890
+ usage,
891
+ response_tier=_service_tier(payload),
892
+ )
893
+ elif response.status_code >= 400:
894
+ self._release_quietly(reservation_id)
895
+ else:
896
+ self._settle_missing(reservation_id, reserved, model, provider)
897
+
898
+ return Response(
899
+ content=content,
900
+ status_code=response.status_code,
901
+ headers=dict(self._response_headers(response)),
902
+ )
903
+
904
+ async def _stream(
905
+ self,
906
+ upstream: httpx.Request,
907
+ *,
908
+ reservation_id: str,
909
+ reserved: Decimal,
910
+ settlement: _Settlement,
911
+ model: str,
912
+ kind: str,
913
+ provider: str,
914
+ ) -> Response:
915
+ try:
916
+ response = await self._client.send(upstream, stream=True)
917
+ except httpx.HTTPError:
918
+ self._release_quietly(reservation_id)
919
+ return _agentcap_error(
920
+ 502,
921
+ "agentcap: upstream request failed; reservation released.",
922
+ "agentcap_upstream_error",
923
+ )
924
+
925
+ if response.status_code >= 400:
926
+ # Errors are not streams; drain, release, and replay as a response.
927
+ try:
928
+ content = await response.aread()
929
+ except httpx.HTTPError:
930
+ self._release_quietly(reservation_id)
931
+ return _agentcap_error(
932
+ 502,
933
+ "agentcap: upstream request failed; reservation released.",
934
+ "agentcap_upstream_error",
935
+ )
936
+ finally:
937
+ with contextlib.suppress(Exception):
938
+ await response.aclose()
939
+ self._release_quietly(reservation_id)
940
+ return Response(
941
+ content=content,
942
+ status_code=response.status_code,
943
+ headers=dict(self._response_headers(response)),
944
+ )
945
+
946
+ parser = (
947
+ GeminiStreamUsage() if provider == "gemini" else OpenAIStreamUsage()
948
+ )
949
+ finalized = False
950
+ response_tier: str | None = None
951
+
952
+ async def body_stream() -> AsyncIterator[bytes]:
953
+ nonlocal finalized, response_tier
954
+ try:
955
+ async for chunk in response.aiter_bytes():
956
+ if provider == "openai" and response_tier is None:
957
+ match = _SERVICE_TIER_RE.search(chunk)
958
+ if match is not None:
959
+ response_tier = match.group(1).decode(
960
+ "utf-8", errors="replace"
961
+ )
962
+ parser.feed(chunk)
963
+ yield chunk
964
+ finally:
965
+ if not finalized:
966
+ finalized = True
967
+ usage = parser.finish()
968
+ if usage is not None:
969
+ self._settle_actual(
970
+ reservation_id,
971
+ reserved,
972
+ settlement,
973
+ model,
974
+ provider,
975
+ usage,
976
+ response_tier=response_tier,
977
+ )
978
+ else:
979
+ self._settle_missing(
980
+ reservation_id, reserved, model, provider
981
+ )
982
+ with contextlib.suppress(BaseException):
983
+ await response.aclose()
984
+
985
+ return StreamingResponse(
986
+ body_stream(),
987
+ status_code=response.status_code,
988
+ headers=dict(self._response_headers(response)),
989
+ )
990
+
991
+ # ------------------------------------------------------------ settlement
992
+ def _now(self) -> datetime:
993
+ if self.config.clock is not None:
994
+ value = self.config.clock()
995
+ if isinstance(value, datetime):
996
+ return value
997
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
998
+ return datetime.fromtimestamp(float(value), timezone.utc)
999
+ raise TypeError("ProxyConfig.clock must return a datetime or timestamp")
1000
+ return datetime.now(timezone.utc)
1001
+
1002
+ def _run_spent(self) -> str | None:
1003
+ try:
1004
+ return str(self.ledger.spent(self.config.run_id))
1005
+ except Exception:
1006
+ return None
1007
+
1008
+ def _release_quietly(self, reservation_id: str) -> None:
1009
+ try:
1010
+ self.ledger.release(reservation_id)
1011
+ except Exception:
1012
+ pass
1013
+
1014
+ def _settle_actual(
1015
+ self,
1016
+ reservation_id: str,
1017
+ reserved: Decimal,
1018
+ settlement: _Settlement,
1019
+ model: str,
1020
+ provider: str,
1021
+ usage: Usage,
1022
+ *,
1023
+ response_tier: str | None = None,
1024
+ ) -> None:
1025
+ try:
1026
+ actual = pricing_cost(
1027
+ settlement.price,
1028
+ usage.input_tokens,
1029
+ usage.cached_input_tokens,
1030
+ usage.output_tokens,
1031
+ now=settlement.now,
1032
+ cache_write=settlement.cache_write,
1033
+ )
1034
+ tier = response_tier or settlement.requested_tier
1035
+ if tier is not None:
1036
+ multiplier = settlement.multiplier
1037
+ try:
1038
+ multiplier = self.pricing.service_tier_multiplier(
1039
+ "openai", tier
1040
+ )
1041
+ except UnknownServiceTierError:
1042
+ # An unexpected tier in the response cannot be blocked any
1043
+ # more; charge with the requested (or default) multiplier.
1044
+ pass
1045
+ if multiplier != 1:
1046
+ actual = (actual * multiplier).quantize(
1047
+ MICRO_USD, rounding=ROUND_CEILING
1048
+ )
1049
+ overshoot = self.ledger.settle(reservation_id, actual)
1050
+ except Exception:
1051
+ # Leave the reservation in place (conservative) if settlement fails.
1052
+ return
1053
+
1054
+ self._emit(
1055
+ {
1056
+ "type": "request_settled",
1057
+ "model": model,
1058
+ "provider": provider,
1059
+ "cost_usd": str(actual),
1060
+ "reserved_usd": str(reserved),
1061
+ "run_spent_usd": self._run_spent(),
1062
+ "usage": usage.as_dict(),
1063
+ }
1064
+ )
1065
+ if overshoot:
1066
+ self._emit(
1067
+ {
1068
+ "type": "overshoot",
1069
+ "model": model,
1070
+ "provider": provider,
1071
+ "cost_usd": str(actual),
1072
+ "reserved_usd": str(reserved),
1073
+ }
1074
+ )
1075
+ self._check_thresholds()
1076
+
1077
+ def _settle_missing(
1078
+ self,
1079
+ reservation_id: str,
1080
+ reserved: Decimal,
1081
+ model: str,
1082
+ provider: str,
1083
+ ) -> None:
1084
+ try:
1085
+ self.ledger.settle(reservation_id, reserved)
1086
+ except Exception:
1087
+ return
1088
+ self._emit(
1089
+ {
1090
+ "type": "usage_missing",
1091
+ "model": model,
1092
+ "provider": provider,
1093
+ "charged_usd": str(reserved),
1094
+ }
1095
+ )
1096
+ self._emit(
1097
+ {
1098
+ "type": "request_settled",
1099
+ "model": model,
1100
+ "provider": provider,
1101
+ "cost_usd": str(reserved),
1102
+ "reserved_usd": str(reserved),
1103
+ "run_spent_usd": self._run_spent(),
1104
+ "usage": None,
1105
+ }
1106
+ )
1107
+ self._check_thresholds()
1108
+
1109
+ def _check_thresholds(self) -> None:
1110
+ cap = self.config.caps.run_usd
1111
+ if cap is None or cap <= 0:
1112
+ return
1113
+ spent = self.ledger.spent(self.config.run_id)
1114
+ for threshold in (50, 80, 100):
1115
+ if threshold in self._thresholds_emitted:
1116
+ continue
1117
+ if spent * 100 >= cap * threshold:
1118
+ self._thresholds_emitted.add(threshold)
1119
+ self._emit(
1120
+ {
1121
+ "type": "cap_threshold",
1122
+ "threshold": threshold,
1123
+ "spent_usd": str(spent),
1124
+ "cap_usd": str(cap),
1125
+ }
1126
+ )
1127
+
1128
+
1129
+ def _load_json(content: bytes) -> Any:
1130
+ try:
1131
+ return json.loads(content)
1132
+ except (json.JSONDecodeError, UnicodeDecodeError, ValueError):
1133
+ return None
1134
+
1135
+
1136
+ def _service_tier(payload: Any) -> str | None:
1137
+ if not isinstance(payload, Mapping):
1138
+ return None
1139
+ value = payload.get("service_tier")
1140
+ if isinstance(value, str) and value.strip():
1141
+ return value.strip()
1142
+ return None
1143
+
1144
+
1145
+ def _unknown_model_error(model: str) -> JSONResponse:
1146
+ label = model or "<missing>"
1147
+ return _agentcap_error(
1148
+ 403,
1149
+ f"agentcap: unknown model {label!r}; no price is configured, so the "
1150
+ "request was blocked (fail-closed). Add an override under "
1151
+ '"models" in ~/.config/agentcap/pricing.json (or set '
1152
+ "AGENTCAP_PRICING_FILE).",
1153
+ "agentcap_unknown_model",
1154
+ )
1155
+
1156
+
1157
+ def _unknown_service_tier_error(tier: str) -> JSONResponse:
1158
+ label = tier or "<missing>"
1159
+ return _agentcap_error(
1160
+ 403,
1161
+ f"agentcap: unknown service_tier {label!r}; no multiplier is "
1162
+ "configured, so the request was blocked (fail-closed). Use a known "
1163
+ "tier or --allow-unmetered to pass through.",
1164
+ "agentcap_unknown_service_tier",
1165
+ code="agentcap_unknown_service_tier",
1166
+ )
1167
+
1168
+
1169
+ def create_app(
1170
+ config: ProxyConfig,
1171
+ ledger: Ledger,
1172
+ upstream_transport: httpx.AsyncBaseTransport | None = None,
1173
+ events: EventCallback | None = None,
1174
+ ) -> Starlette:
1175
+ """Build the ASGI application.
1176
+
1177
+ ``upstream_transport`` is injectable so tests can use
1178
+ :class:`httpx.MockTransport` and never touch the network.
1179
+ """
1180
+
1181
+ return _Proxy(config, ledger, upstream_transport, events).build()