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/__init__.py
ADDED
agentcap/alerts.py
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
"""Alert fan-out for spend and safety events.
|
|
2
|
+
|
|
3
|
+
Every alert goes to the terminal (stderr, red). Pro users can additionally
|
|
4
|
+
receive webhook and Telegram notifications; those are delivered from a
|
|
5
|
+
background thread so a slow endpoint never blocks the proxy.
|
|
6
|
+
|
|
7
|
+
Payloads are built from a strict field whitelist and pass through
|
|
8
|
+
:func:`redact` before leaving the process, so prompts, response text and
|
|
9
|
+
credentials can never be included.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import sys
|
|
17
|
+
import threading
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
from typing import Any, Callable, Iterable, Mapping, TextIO
|
|
20
|
+
|
|
21
|
+
import httpx
|
|
22
|
+
|
|
23
|
+
RED = "\x1b[31m"
|
|
24
|
+
YELLOW = "\x1b[33m"
|
|
25
|
+
RESET = "\x1b[0m"
|
|
26
|
+
|
|
27
|
+
TERMINAL_EVENTS = frozenset(
|
|
28
|
+
{
|
|
29
|
+
"cap_threshold",
|
|
30
|
+
"cap_exceeded",
|
|
31
|
+
"loop_detected",
|
|
32
|
+
"usage_missing",
|
|
33
|
+
"overshoot",
|
|
34
|
+
"output_clamped",
|
|
35
|
+
"request_too_large",
|
|
36
|
+
}
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# Alerts that must never be repeated for one run (a run has one kill and one
|
|
40
|
+
# budget-clamp notice, no matter how many later requests are rejected).
|
|
41
|
+
ONCE_EVENTS = frozenset({"cap_exceeded", "loop_detected", "output_clamped"})
|
|
42
|
+
|
|
43
|
+
# Only these fields may appear in an alert payload. Anything else is dropped.
|
|
44
|
+
ALLOWED_FIELDS = frozenset(
|
|
45
|
+
{
|
|
46
|
+
"type",
|
|
47
|
+
"reason",
|
|
48
|
+
"scope",
|
|
49
|
+
"cap_usd",
|
|
50
|
+
"spent_usd",
|
|
51
|
+
"needed_usd",
|
|
52
|
+
"cost_usd",
|
|
53
|
+
"reserved_usd",
|
|
54
|
+
"run_spent_usd",
|
|
55
|
+
"charged_usd",
|
|
56
|
+
"model",
|
|
57
|
+
"provider",
|
|
58
|
+
"threshold",
|
|
59
|
+
"window_s",
|
|
60
|
+
"request_hash",
|
|
61
|
+
"run_id",
|
|
62
|
+
"allowed_output_tokens",
|
|
63
|
+
"remaining_usd",
|
|
64
|
+
}
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
_SECRET_PATTERNS = (
|
|
68
|
+
re.compile(r"sk-[A-Za-z0-9_\-]{4,}"),
|
|
69
|
+
re.compile(r"(?i)bearer\s+[A-Za-z0-9._\-]+"),
|
|
70
|
+
re.compile(r"(?i)(x-goog-api-key|api[_-]?key|authorization)\s*[:=]\s*\S+"),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def redact(text: str) -> str:
|
|
75
|
+
"""Scrub credential-shaped substrings from a string."""
|
|
76
|
+
|
|
77
|
+
if not isinstance(text, str):
|
|
78
|
+
return text
|
|
79
|
+
result = text
|
|
80
|
+
for pattern in _SECRET_PATTERNS:
|
|
81
|
+
result = pattern.sub("[redacted]", result)
|
|
82
|
+
return result
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def sanitize(payload: Mapping[str, Any]) -> dict[str, Any]:
|
|
86
|
+
"""Keep only whitelisted fields and redact every string value."""
|
|
87
|
+
|
|
88
|
+
clean: dict[str, Any] = {}
|
|
89
|
+
for key, value in payload.items():
|
|
90
|
+
if key not in ALLOWED_FIELDS:
|
|
91
|
+
continue
|
|
92
|
+
if isinstance(value, str):
|
|
93
|
+
clean[key] = redact(value)
|
|
94
|
+
elif isinstance(value, (int, float, bool)) or value is None:
|
|
95
|
+
clean[key] = value
|
|
96
|
+
else:
|
|
97
|
+
clean[key] = redact(str(value))
|
|
98
|
+
return clean
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def format_alert(event: Mapping[str, Any]) -> str:
|
|
102
|
+
"""One-line, human-readable terminal message for an event."""
|
|
103
|
+
|
|
104
|
+
kind = str(event.get("type", "event"))
|
|
105
|
+
run = event.get("run_id")
|
|
106
|
+
prefix = "agentcap" + (f" [{redact(str(run))}]" if run else "")
|
|
107
|
+
|
|
108
|
+
if kind == "cap_threshold":
|
|
109
|
+
threshold = event.get("threshold", "?")
|
|
110
|
+
return (
|
|
111
|
+
f"{prefix}: {threshold}% of the ${event.get('cap_usd', '?')} run cap "
|
|
112
|
+
f"used (${event.get('spent_usd', '?')})."
|
|
113
|
+
)
|
|
114
|
+
if kind == "cap_exceeded":
|
|
115
|
+
return (
|
|
116
|
+
f"{prefix}: RUN CAP HIT (scope={event.get('scope', 'run')}, "
|
|
117
|
+
f"cap=${event.get('cap_usd', '?')}, spent=${event.get('spent_usd', '?')}). "
|
|
118
|
+
"Stopping the agent."
|
|
119
|
+
)
|
|
120
|
+
if kind == "loop_detected":
|
|
121
|
+
return (
|
|
122
|
+
f"{prefix}: runaway loop detected (identical request "
|
|
123
|
+
f"{event.get('threshold', '?')} times within "
|
|
124
|
+
f"{event.get('window_s', '?')}s). Stopping the agent."
|
|
125
|
+
)
|
|
126
|
+
if kind == "usage_missing":
|
|
127
|
+
return (
|
|
128
|
+
f"{prefix}: upstream returned no usage; charged the full "
|
|
129
|
+
f"reservation (${event.get('charged_usd', '?')}) to stay conservative."
|
|
130
|
+
)
|
|
131
|
+
if kind == "overshoot":
|
|
132
|
+
return (
|
|
133
|
+
f"{prefix}: a request settled above its reservation "
|
|
134
|
+
f"(${event.get('cost_usd', '?')} > ${event.get('reserved_usd', '?')}); "
|
|
135
|
+
"the cap is still enforced on the next request."
|
|
136
|
+
)
|
|
137
|
+
if kind == "output_clamped":
|
|
138
|
+
return (
|
|
139
|
+
f"{prefix}: budget low, limiting output to "
|
|
140
|
+
f"{event.get('allowed_output_tokens', '?')} tokens per request"
|
|
141
|
+
)
|
|
142
|
+
if kind == "request_too_large":
|
|
143
|
+
return (
|
|
144
|
+
f"{prefix}: a request needs ${event.get('needed_usd', '?')} but only "
|
|
145
|
+
f"${event.get('remaining_usd', event.get('cap_usd', '?'))} of budget "
|
|
146
|
+
"remains; not sent. Raise the cap or enable output clamping."
|
|
147
|
+
)
|
|
148
|
+
return f"{prefix}: {kind}"
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class AlertDispatcher:
|
|
152
|
+
"""Terminal + optional webhook/Telegram alert fan-out.
|
|
153
|
+
|
|
154
|
+
``transport`` is an ``httpx`` transport, injected in tests so no real
|
|
155
|
+
network call is ever made.
|
|
156
|
+
"""
|
|
157
|
+
|
|
158
|
+
def __init__(
|
|
159
|
+
self,
|
|
160
|
+
*,
|
|
161
|
+
features: Iterable[str] | None = None,
|
|
162
|
+
webhook_url: str | None = None,
|
|
163
|
+
telegram_token: str | None = None,
|
|
164
|
+
telegram_chat_id: str | None = None,
|
|
165
|
+
transport: httpx.BaseTransport | None = None,
|
|
166
|
+
stderr: TextIO | None = None,
|
|
167
|
+
clock: Callable[[], datetime] | None = None,
|
|
168
|
+
background: bool = True,
|
|
169
|
+
timeout_s: float = 5.0,
|
|
170
|
+
) -> None:
|
|
171
|
+
self.features = frozenset(features or ())
|
|
172
|
+
self.webhook_url = webhook_url or None
|
|
173
|
+
self.telegram_token = telegram_token or None
|
|
174
|
+
self.telegram_chat_id = telegram_chat_id or None
|
|
175
|
+
self.transport = transport
|
|
176
|
+
self.stderr = stderr if stderr is not None else sys.stderr
|
|
177
|
+
self.clock = clock or (lambda: datetime.now(timezone.utc))
|
|
178
|
+
self.background = background
|
|
179
|
+
self.timeout_s = float(timeout_s)
|
|
180
|
+
self._threads: list[threading.Thread] = []
|
|
181
|
+
self._lock = threading.Lock()
|
|
182
|
+
self._seen_once: set[tuple[str, object]] = set()
|
|
183
|
+
|
|
184
|
+
# --------------------------------------------------------------- sending
|
|
185
|
+
def __call__(self, event: Mapping[str, Any]) -> None:
|
|
186
|
+
self.emit(event)
|
|
187
|
+
|
|
188
|
+
def emit(self, event: Mapping[str, Any]) -> None:
|
|
189
|
+
if not isinstance(event, Mapping):
|
|
190
|
+
return
|
|
191
|
+
kind = str(event.get("type", ""))
|
|
192
|
+
if kind not in TERMINAL_EVENTS:
|
|
193
|
+
# Per-request accounting events stay local; only alerts fan out.
|
|
194
|
+
return
|
|
195
|
+
if kind in ONCE_EVENTS:
|
|
196
|
+
key = (kind, event.get("run_id"))
|
|
197
|
+
with self._lock:
|
|
198
|
+
if key in self._seen_once:
|
|
199
|
+
return
|
|
200
|
+
self._seen_once.add(key)
|
|
201
|
+
message = format_alert(event)
|
|
202
|
+
color = YELLOW if kind in {"output_clamped", "request_too_large"} else RED
|
|
203
|
+
self._terminal(message, color=color)
|
|
204
|
+
|
|
205
|
+
clean = sanitize(event)
|
|
206
|
+
clean.setdefault("run_id", None)
|
|
207
|
+
payload = {
|
|
208
|
+
"source": "agentcap",
|
|
209
|
+
"sent_at": self.clock().isoformat(),
|
|
210
|
+
"event": clean,
|
|
211
|
+
"message": redact(message),
|
|
212
|
+
}
|
|
213
|
+
if self.webhook_url and "webhook_alerts" in self.features:
|
|
214
|
+
self._deliver(self._post_webhook, payload)
|
|
215
|
+
if (
|
|
216
|
+
self.telegram_token
|
|
217
|
+
and self.telegram_chat_id
|
|
218
|
+
and "telegram_alerts" in self.features
|
|
219
|
+
):
|
|
220
|
+
self._deliver(self._post_telegram, payload)
|
|
221
|
+
|
|
222
|
+
def _terminal(self, message: str, *, color: str = RED) -> None:
|
|
223
|
+
try:
|
|
224
|
+
print(f"{color}{redact(message)}{RESET}", file=self.stderr, flush=True)
|
|
225
|
+
except Exception:
|
|
226
|
+
pass
|
|
227
|
+
|
|
228
|
+
def _deliver(self, sender: Callable[[dict[str, Any]], None], payload: dict[str, Any]) -> None:
|
|
229
|
+
if not self.background:
|
|
230
|
+
sender(payload)
|
|
231
|
+
return
|
|
232
|
+
thread = threading.Thread(target=sender, args=(payload,), daemon=True)
|
|
233
|
+
with self._lock:
|
|
234
|
+
self._threads.append(thread)
|
|
235
|
+
thread.start()
|
|
236
|
+
|
|
237
|
+
def wait(self, timeout: float | None = 5.0) -> None:
|
|
238
|
+
"""Join outstanding delivery threads (used by tests and shutdown)."""
|
|
239
|
+
|
|
240
|
+
with self._lock:
|
|
241
|
+
threads = list(self._threads)
|
|
242
|
+
self._threads.clear()
|
|
243
|
+
for thread in threads:
|
|
244
|
+
thread.join(timeout)
|
|
245
|
+
|
|
246
|
+
def _post_webhook(self, payload: dict[str, Any]) -> None:
|
|
247
|
+
try:
|
|
248
|
+
with httpx.Client(
|
|
249
|
+
transport=self.transport, timeout=self.timeout_s, trust_env=False
|
|
250
|
+
) as client:
|
|
251
|
+
response = client.post(self.webhook_url, json=payload)
|
|
252
|
+
response.raise_for_status()
|
|
253
|
+
except Exception as exc:
|
|
254
|
+
# Never echo the URL or exception text: it may contain a token.
|
|
255
|
+
self._warn("webhook", exc)
|
|
256
|
+
|
|
257
|
+
def _post_telegram(self, payload: dict[str, Any]) -> None:
|
|
258
|
+
try:
|
|
259
|
+
text = payload.get("message") or "agentcap alert"
|
|
260
|
+
url = f"https://api.telegram.org/bot{self.telegram_token}/sendMessage"
|
|
261
|
+
with httpx.Client(
|
|
262
|
+
transport=self.transport, timeout=self.timeout_s, trust_env=False
|
|
263
|
+
) as client:
|
|
264
|
+
response = client.post(
|
|
265
|
+
url,
|
|
266
|
+
json={"chat_id": self.telegram_chat_id, "text": text},
|
|
267
|
+
)
|
|
268
|
+
response.raise_for_status()
|
|
269
|
+
except Exception as exc:
|
|
270
|
+
self._warn("telegram", exc)
|
|
271
|
+
|
|
272
|
+
def _warn(self, channel: str, exc: BaseException) -> None:
|
|
273
|
+
try:
|
|
274
|
+
print(
|
|
275
|
+
f"agentcap: alert delivery failed ({channel}: "
|
|
276
|
+
f"{type(exc).__name__}); continuing.",
|
|
277
|
+
file=self.stderr,
|
|
278
|
+
flush=True,
|
|
279
|
+
)
|
|
280
|
+
except Exception:
|
|
281
|
+
pass
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def dispatcher_from_env(
|
|
285
|
+
*,
|
|
286
|
+
features: Iterable[str] | None = None,
|
|
287
|
+
transport: httpx.BaseTransport | None = None,
|
|
288
|
+
stderr: TextIO | None = None,
|
|
289
|
+
background: bool = True,
|
|
290
|
+
) -> AlertDispatcher:
|
|
291
|
+
"""Build a dispatcher from ``AGENTCAP_WEBHOOK_URL`` / Telegram env vars."""
|
|
292
|
+
|
|
293
|
+
return AlertDispatcher(
|
|
294
|
+
features=features,
|
|
295
|
+
webhook_url=os.environ.get("AGENTCAP_WEBHOOK_URL"),
|
|
296
|
+
telegram_token=os.environ.get("AGENTCAP_TELEGRAM_BOT_TOKEN"),
|
|
297
|
+
telegram_chat_id=os.environ.get("AGENTCAP_TELEGRAM_CHAT_ID"),
|
|
298
|
+
transport=transport,
|
|
299
|
+
stderr=stderr,
|
|
300
|
+
background=background,
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
__all__ = [
|
|
305
|
+
"AlertDispatcher",
|
|
306
|
+
"ALLOWED_FIELDS",
|
|
307
|
+
"TERMINAL_EVENTS",
|
|
308
|
+
"dispatcher_from_env",
|
|
309
|
+
"format_alert",
|
|
310
|
+
"redact",
|
|
311
|
+
"sanitize",
|
|
312
|
+
]
|
agentcap/clamp.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Budget-fit output clamping.
|
|
2
|
+
|
|
3
|
+
Most agents never set an output limit, so a full worst-case reservation
|
|
4
|
+
(``max_output_tokens`` of the model) is much larger than the actual request.
|
|
5
|
+
With a small cap that reservation alone would cross the cap and kill a run that
|
|
6
|
+
has spent nothing yet.
|
|
7
|
+
|
|
8
|
+
This module computes how many output tokens actually fit in the remaining
|
|
9
|
+
budget and rewrites *only* the request's output limit. It never raises an
|
|
10
|
+
existing limit, never touches prompts and never changes settlement (which still
|
|
11
|
+
uses real usage).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from decimal import Decimal, ROUND_CEILING, ROUND_FLOOR
|
|
18
|
+
from typing import Any, Mapping
|
|
19
|
+
|
|
20
|
+
from .estimate import candidate_count, explicit_output_tokens
|
|
21
|
+
from .pricing import MICRO_USD, ONE_MILLION, ModelPrice, effective_rates
|
|
22
|
+
|
|
23
|
+
#: Clamping below this many tokens is pointless: treat the budget as exhausted.
|
|
24
|
+
MIN_CLAMP_OUTPUT = 256
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class OutputFit:
|
|
29
|
+
"""A clamped output allowance and the exact reservation it implies."""
|
|
30
|
+
|
|
31
|
+
allowed_output: int
|
|
32
|
+
input_cost: Decimal
|
|
33
|
+
reservation: Decimal
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _positive_int(value: Any) -> int | None:
|
|
37
|
+
if isinstance(value, bool) or value is None:
|
|
38
|
+
return None
|
|
39
|
+
if isinstance(value, int):
|
|
40
|
+
return value if value > 0 else None
|
|
41
|
+
if isinstance(value, float) and value.is_integer() and value > 0:
|
|
42
|
+
return int(value)
|
|
43
|
+
if isinstance(value, str) and value.strip().isdigit() and int(value.strip()) > 0:
|
|
44
|
+
return int(value.strip())
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def fit_output(
|
|
49
|
+
model_price: ModelPrice,
|
|
50
|
+
*,
|
|
51
|
+
provider: str,
|
|
52
|
+
body: Mapping[str, Any],
|
|
53
|
+
input_tokens: int,
|
|
54
|
+
remaining: Decimal,
|
|
55
|
+
now: Any = None,
|
|
56
|
+
cache_write: bool = False,
|
|
57
|
+
tier_multiplier: Decimal = Decimal("1"),
|
|
58
|
+
minimum: int = MIN_CLAMP_OUTPUT,
|
|
59
|
+
) -> OutputFit:
|
|
60
|
+
"""Largest per-candidate output limit whose reservation fits ``remaining``.
|
|
61
|
+
|
|
62
|
+
The returned ``allowed_output`` can be below ``minimum`` (or zero); the
|
|
63
|
+
caller decides whether that is still worth sending.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
if remaining <= 0:
|
|
67
|
+
return OutputFit(0, Decimal("0"), Decimal("0"))
|
|
68
|
+
|
|
69
|
+
input_rate, _cached_rate, output_rate = effective_rates(
|
|
70
|
+
model_price, input_tokens, now=now
|
|
71
|
+
)
|
|
72
|
+
if cache_write and model_price.cache_write_per_mtok is not None:
|
|
73
|
+
input_rate = max(input_rate, model_price.cache_write_per_mtok)
|
|
74
|
+
|
|
75
|
+
input_cost = (
|
|
76
|
+
Decimal(input_tokens) * input_rate / ONE_MILLION
|
|
77
|
+
).quantize(MICRO_USD, rounding=ROUND_CEILING)
|
|
78
|
+
if tier_multiplier != 1:
|
|
79
|
+
input_cost = (input_cost * tier_multiplier).quantize(
|
|
80
|
+
MICRO_USD, rounding=ROUND_CEILING
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
output_per_token = output_rate * tier_multiplier / ONE_MILLION
|
|
84
|
+
candidates = candidate_count(provider, body)
|
|
85
|
+
if output_per_token <= 0:
|
|
86
|
+
return OutputFit(0, input_cost, input_cost)
|
|
87
|
+
|
|
88
|
+
budget_for_output = remaining - input_cost
|
|
89
|
+
if budget_for_output <= 0:
|
|
90
|
+
allowed = 0
|
|
91
|
+
else:
|
|
92
|
+
raw = budget_for_output / (output_per_token * Decimal(candidates))
|
|
93
|
+
allowed = int(raw.to_integral_value(rounding=ROUND_FLOOR))
|
|
94
|
+
if allowed < 0:
|
|
95
|
+
allowed = 0
|
|
96
|
+
|
|
97
|
+
existing = explicit_output_tokens(provider, body)
|
|
98
|
+
if existing is not None:
|
|
99
|
+
# Never raise a limit the client already set (math should prevent it).
|
|
100
|
+
allowed = min(allowed, existing)
|
|
101
|
+
|
|
102
|
+
def _reservation(value: int) -> Decimal:
|
|
103
|
+
total = input_cost + (
|
|
104
|
+
Decimal(value * candidates) * output_rate * tier_multiplier / ONE_MILLION
|
|
105
|
+
)
|
|
106
|
+
return total.quantize(MICRO_USD, rounding=ROUND_CEILING)
|
|
107
|
+
|
|
108
|
+
reservation = _reservation(allowed)
|
|
109
|
+
# Ceiling rounding can push the reservation one micro-dollar over; lower the
|
|
110
|
+
# allowance until it genuinely fits.
|
|
111
|
+
while reservation > remaining and allowed > 0:
|
|
112
|
+
allowed -= 1
|
|
113
|
+
reservation = _reservation(allowed)
|
|
114
|
+
|
|
115
|
+
return OutputFit(allowed, input_cost, reservation)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def apply_output_clamp(
|
|
119
|
+
provider: str,
|
|
120
|
+
path: str,
|
|
121
|
+
body: dict[str, Any],
|
|
122
|
+
allowed_output: int,
|
|
123
|
+
) -> None:
|
|
124
|
+
"""Lower the request's output limit to ``allowed_output`` in place.
|
|
125
|
+
|
|
126
|
+
* OpenAI chat: ``max_completion_tokens`` (or the legacy ``max_tokens`` when
|
|
127
|
+
the body already used it; never both).
|
|
128
|
+
* OpenAI responses: ``max_output_tokens``.
|
|
129
|
+
* Gemini: ``generationConfig.maxOutputTokens`` (created when missing).
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
allowed = _positive_int(allowed_output)
|
|
133
|
+
if allowed is None:
|
|
134
|
+
return
|
|
135
|
+
|
|
136
|
+
if provider == "gemini":
|
|
137
|
+
config = body.get("generationConfig")
|
|
138
|
+
config = dict(config) if isinstance(config, Mapping) else {}
|
|
139
|
+
current = _positive_int(
|
|
140
|
+
config.get("maxOutputTokens", config.get("max_output_tokens"))
|
|
141
|
+
)
|
|
142
|
+
config["maxOutputTokens"] = min(allowed, current) if current else allowed
|
|
143
|
+
body["generationConfig"] = config
|
|
144
|
+
return
|
|
145
|
+
|
|
146
|
+
normalised = path.strip("/")
|
|
147
|
+
if normalised == "v1/responses":
|
|
148
|
+
current = _positive_int(body.get("max_output_tokens"))
|
|
149
|
+
body["max_output_tokens"] = min(allowed, current) if current else allowed
|
|
150
|
+
return
|
|
151
|
+
|
|
152
|
+
# Chat Completions (and the legacy /v1/completions shape).
|
|
153
|
+
if normalised == "v1/completions":
|
|
154
|
+
current = _positive_int(body.get("max_tokens"))
|
|
155
|
+
body["max_tokens"] = min(allowed, current) if current else allowed
|
|
156
|
+
return
|
|
157
|
+
legacy = _positive_int(body.get("max_tokens"))
|
|
158
|
+
modern = _positive_int(body.get("max_completion_tokens"))
|
|
159
|
+
if legacy is not None:
|
|
160
|
+
body["max_tokens"] = min(allowed, legacy)
|
|
161
|
+
if modern is not None:
|
|
162
|
+
# Both fields already existed; lower both so neither stays above
|
|
163
|
+
# the reservation. A new second field is never added.
|
|
164
|
+
body["max_completion_tokens"] = min(allowed, modern)
|
|
165
|
+
return
|
|
166
|
+
body["max_completion_tokens"] = min(allowed, modern) if modern else allowed
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
__all__ = [
|
|
170
|
+
"MIN_CLAMP_OUTPUT",
|
|
171
|
+
"OutputFit",
|
|
172
|
+
"apply_output_clamp",
|
|
173
|
+
"fit_output",
|
|
174
|
+
]
|