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/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()
|