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/pricing.py
ADDED
|
@@ -0,0 +1,620 @@
|
|
|
1
|
+
"""Model pricing lookup and cost math.
|
|
2
|
+
|
|
3
|
+
Prices are stored as decimal strings so that no money value ever travels
|
|
4
|
+
through ``float``. Lookup is exact first and then longest-prefix, which makes
|
|
5
|
+
dated model ids (``openai/gpt-4o-2024-08-06``) resolve to their family entry
|
|
6
|
+
without letting ``gpt-4o`` shadow ``gpt-4o-mini`` or ``gpt-5`` shadow
|
|
7
|
+
``gpt-5.4-mini``.
|
|
8
|
+
|
|
9
|
+
Optional features:
|
|
10
|
+
|
|
11
|
+
* ``schedule`` — dated price changes (Google announced future increases).
|
|
12
|
+
* ``long_context`` — a higher rate above a prompt-size threshold.
|
|
13
|
+
* ``cache_write_per_mtok`` — conservative rate for cache-write capable
|
|
14
|
+
requests (``prompt_cache*`` / ``cache_control``).
|
|
15
|
+
* ``service_tiers`` — OpenAI service-tier multipliers.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import copy
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from datetime import date, datetime, timezone
|
|
25
|
+
from decimal import Decimal, InvalidOperation
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any, Mapping
|
|
28
|
+
|
|
29
|
+
ONE_MILLION = Decimal("1000000")
|
|
30
|
+
MICRO_USD = Decimal("0.000001")
|
|
31
|
+
|
|
32
|
+
BUNDLED_PRICING_PATH = Path(__file__).resolve().parent / "data" / "pricing.json"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class PricingError(Exception):
|
|
36
|
+
"""Base class for pricing problems."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class InvalidPricingError(PricingError):
|
|
40
|
+
"""A pricing document is malformed or incomplete."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class UnknownModelError(PricingError):
|
|
44
|
+
"""No pricing entry (exact or prefix) exists for a model id."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, model: str) -> None:
|
|
47
|
+
self.model = model
|
|
48
|
+
super().__init__(
|
|
49
|
+
f"Unknown model {model!r}: no pricing entry found. "
|
|
50
|
+
"Add one to the pricing override file (AGENTCAP_PRICING_FILE) "
|
|
51
|
+
"before sending requests."
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class UnknownServiceTierError(PricingError):
|
|
56
|
+
"""A service tier has no configured multiplier (fail closed)."""
|
|
57
|
+
|
|
58
|
+
def __init__(self, provider: str, tier: str) -> None:
|
|
59
|
+
self.provider = provider
|
|
60
|
+
self.tier = tier
|
|
61
|
+
super().__init__(
|
|
62
|
+
f"Unknown service tier {tier!r} for provider {provider!r}; "
|
|
63
|
+
"refusing to guess a multiplier."
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True)
|
|
68
|
+
class LongContextPrice:
|
|
69
|
+
threshold_tokens: int
|
|
70
|
+
input_per_mtok: Decimal
|
|
71
|
+
output_per_mtok: Decimal
|
|
72
|
+
cached_input_per_mtok: Decimal | None = None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(frozen=True)
|
|
76
|
+
class ScheduleEntry:
|
|
77
|
+
"""A full price triple that applies from ``effective_from`` (UTC date)."""
|
|
78
|
+
|
|
79
|
+
effective_from: date
|
|
80
|
+
input_per_mtok: Decimal
|
|
81
|
+
cached_input_per_mtok: Decimal
|
|
82
|
+
output_per_mtok: Decimal
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(frozen=True)
|
|
86
|
+
class ModelPrice:
|
|
87
|
+
key: str
|
|
88
|
+
provider: str
|
|
89
|
+
model: str
|
|
90
|
+
input_per_mtok: Decimal
|
|
91
|
+
cached_input_per_mtok: Decimal
|
|
92
|
+
output_per_mtok: Decimal
|
|
93
|
+
max_output_tokens: int
|
|
94
|
+
source: str = ""
|
|
95
|
+
verified: bool = False
|
|
96
|
+
verified_at: str = ""
|
|
97
|
+
long_context: LongContextPrice | None = None
|
|
98
|
+
schedule: tuple[ScheduleEntry, ...] = ()
|
|
99
|
+
cache_write_per_mtok: Decimal | None = None
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def is_verified(self) -> bool:
|
|
103
|
+
return bool(self.verified)
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def verified_date(self) -> str:
|
|
107
|
+
"""The verified-on date, or an empty string for legacy bool entries."""
|
|
108
|
+
|
|
109
|
+
return self.verified_at
|
|
110
|
+
|
|
111
|
+
def rates_at(
|
|
112
|
+
self, now: datetime | date | None = None
|
|
113
|
+
) -> tuple[Decimal, Decimal, Decimal]:
|
|
114
|
+
"""Base (input, cached, output) rates in effect at ``now``."""
|
|
115
|
+
|
|
116
|
+
rates = (
|
|
117
|
+
self.input_per_mtok,
|
|
118
|
+
self.cached_input_per_mtok,
|
|
119
|
+
self.output_per_mtok,
|
|
120
|
+
)
|
|
121
|
+
if not self.schedule:
|
|
122
|
+
return rates
|
|
123
|
+
current = _utc_date(now)
|
|
124
|
+
applicable: ScheduleEntry | None = None
|
|
125
|
+
for entry in self.schedule:
|
|
126
|
+
if entry.effective_from <= current:
|
|
127
|
+
applicable = entry
|
|
128
|
+
if applicable is None:
|
|
129
|
+
return rates
|
|
130
|
+
return (
|
|
131
|
+
applicable.input_per_mtok,
|
|
132
|
+
applicable.cached_input_per_mtok,
|
|
133
|
+
applicable.output_per_mtok,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _utc_date(value: datetime | date | None) -> date:
|
|
138
|
+
if value is None:
|
|
139
|
+
return datetime.now(timezone.utc).date()
|
|
140
|
+
if isinstance(value, datetime):
|
|
141
|
+
if value.tzinfo is None:
|
|
142
|
+
value = value.replace(tzinfo=timezone.utc)
|
|
143
|
+
return value.astimezone(timezone.utc).date()
|
|
144
|
+
if isinstance(value, date):
|
|
145
|
+
return value
|
|
146
|
+
raise TypeError("now must be a datetime, date or None")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _to_decimal(value: Any, *, field: str, key: str) -> Decimal:
|
|
150
|
+
if isinstance(value, Decimal):
|
|
151
|
+
result = value
|
|
152
|
+
elif isinstance(value, bool):
|
|
153
|
+
raise InvalidPricingError(f"{key}: {field} must be a decimal string, got bool")
|
|
154
|
+
else:
|
|
155
|
+
try:
|
|
156
|
+
result = Decimal(str(value))
|
|
157
|
+
except (InvalidOperation, ValueError, TypeError) as exc:
|
|
158
|
+
raise InvalidPricingError(
|
|
159
|
+
f"{key}: {field} is not a valid decimal: {value!r}"
|
|
160
|
+
) from exc
|
|
161
|
+
if not result.is_finite() or result < 0:
|
|
162
|
+
raise InvalidPricingError(f"{key}: {field} must be a finite non-negative number")
|
|
163
|
+
return result
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _to_positive_int(value: Any, *, field: str, key: str) -> int:
|
|
167
|
+
if isinstance(value, bool):
|
|
168
|
+
raise InvalidPricingError(f"{key}: {field} must be a positive integer")
|
|
169
|
+
try:
|
|
170
|
+
result = int(value)
|
|
171
|
+
except (TypeError, ValueError) as exc:
|
|
172
|
+
raise InvalidPricingError(
|
|
173
|
+
f"{key}: {field} must be a positive integer, got {value!r}"
|
|
174
|
+
) from exc
|
|
175
|
+
if result <= 0:
|
|
176
|
+
raise InvalidPricingError(f"{key}: {field} must be a positive integer")
|
|
177
|
+
return result
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _parse_verified(raw: Any, *, key: str) -> tuple[bool, str]:
|
|
181
|
+
"""Accept a bool legacy flag or a date string (``YYYY-MM-DD``)."""
|
|
182
|
+
|
|
183
|
+
if raw is None:
|
|
184
|
+
return False, ""
|
|
185
|
+
if isinstance(raw, bool):
|
|
186
|
+
return raw, ""
|
|
187
|
+
if isinstance(raw, str):
|
|
188
|
+
text = raw.strip()
|
|
189
|
+
if not text or text.lower() == "false":
|
|
190
|
+
return False, ""
|
|
191
|
+
if text.lower() == "true":
|
|
192
|
+
return True, ""
|
|
193
|
+
try:
|
|
194
|
+
date.fromisoformat(text)
|
|
195
|
+
except ValueError as exc:
|
|
196
|
+
raise InvalidPricingError(
|
|
197
|
+
f"{key}: verified must be a bool or a YYYY-MM-DD date, got {raw!r}"
|
|
198
|
+
) from exc
|
|
199
|
+
return True, text
|
|
200
|
+
raise InvalidPricingError(
|
|
201
|
+
f"{key}: verified must be a bool or a YYYY-MM-DD date, got {type(raw).__name__}"
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _parse_long_context(raw: Any, *, key: str) -> LongContextPrice | None:
|
|
206
|
+
if raw is None:
|
|
207
|
+
return None
|
|
208
|
+
if not isinstance(raw, Mapping):
|
|
209
|
+
raise InvalidPricingError(f"{key}: long_context must be an object")
|
|
210
|
+
cached = raw.get("cached_input_per_mtok")
|
|
211
|
+
return LongContextPrice(
|
|
212
|
+
threshold_tokens=_to_positive_int(
|
|
213
|
+
raw.get("threshold_tokens"), field="long_context.threshold_tokens", key=key
|
|
214
|
+
),
|
|
215
|
+
input_per_mtok=_to_decimal(
|
|
216
|
+
raw.get("input_per_mtok"), field="long_context.input_per_mtok", key=key
|
|
217
|
+
),
|
|
218
|
+
output_per_mtok=_to_decimal(
|
|
219
|
+
raw.get("output_per_mtok"), field="long_context.output_per_mtok", key=key
|
|
220
|
+
),
|
|
221
|
+
cached_input_per_mtok=(
|
|
222
|
+
None
|
|
223
|
+
if cached is None
|
|
224
|
+
else _to_decimal(
|
|
225
|
+
cached, field="long_context.cached_input_per_mtok", key=key
|
|
226
|
+
)
|
|
227
|
+
),
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _parse_schedule(raw: Any, *, key: str) -> tuple[ScheduleEntry, ...]:
|
|
232
|
+
if raw is None:
|
|
233
|
+
return ()
|
|
234
|
+
if not isinstance(raw, list):
|
|
235
|
+
raise InvalidPricingError(f"{key}: schedule must be a list")
|
|
236
|
+
entries: list[ScheduleEntry] = []
|
|
237
|
+
for index, item in enumerate(raw):
|
|
238
|
+
field = f"schedule[{index}]"
|
|
239
|
+
if not isinstance(item, Mapping):
|
|
240
|
+
raise InvalidPricingError(f"{key}: {field} must be an object")
|
|
241
|
+
raw_date = item.get("effective_from")
|
|
242
|
+
if not isinstance(raw_date, str):
|
|
243
|
+
raise InvalidPricingError(
|
|
244
|
+
f"{key}: {field}.effective_from must be a YYYY-MM-DD string"
|
|
245
|
+
)
|
|
246
|
+
try:
|
|
247
|
+
effective = date.fromisoformat(raw_date)
|
|
248
|
+
except ValueError as exc:
|
|
249
|
+
raise InvalidPricingError(
|
|
250
|
+
f"{key}: {field}.effective_from is not a valid date: {raw_date!r}"
|
|
251
|
+
) from exc
|
|
252
|
+
entries.append(
|
|
253
|
+
ScheduleEntry(
|
|
254
|
+
effective_from=effective,
|
|
255
|
+
input_per_mtok=_to_decimal(
|
|
256
|
+
item.get("input_per_mtok"),
|
|
257
|
+
field=f"{field}.input_per_mtok",
|
|
258
|
+
key=key,
|
|
259
|
+
),
|
|
260
|
+
cached_input_per_mtok=_to_decimal(
|
|
261
|
+
item.get("cached_input_per_mtok"),
|
|
262
|
+
field=f"{field}.cached_input_per_mtok",
|
|
263
|
+
key=key,
|
|
264
|
+
),
|
|
265
|
+
output_per_mtok=_to_decimal(
|
|
266
|
+
item.get("output_per_mtok"),
|
|
267
|
+
field=f"{field}.output_per_mtok",
|
|
268
|
+
key=key,
|
|
269
|
+
),
|
|
270
|
+
)
|
|
271
|
+
)
|
|
272
|
+
return tuple(sorted(entries, key=lambda entry: entry.effective_from))
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _parse_model_price(key: str, raw: Any) -> ModelPrice:
|
|
276
|
+
if not isinstance(raw, Mapping):
|
|
277
|
+
raise InvalidPricingError(f"{key}: entry must be an object")
|
|
278
|
+
if "/" not in key or not key.split("/", 1)[1]:
|
|
279
|
+
raise InvalidPricingError(
|
|
280
|
+
f"{key!r}: model keys must use the '<provider>/<model>' form"
|
|
281
|
+
)
|
|
282
|
+
required = (
|
|
283
|
+
"input_per_mtok",
|
|
284
|
+
"cached_input_per_mtok",
|
|
285
|
+
"output_per_mtok",
|
|
286
|
+
"max_output_tokens",
|
|
287
|
+
)
|
|
288
|
+
missing = [field for field in required if field not in raw]
|
|
289
|
+
if missing:
|
|
290
|
+
raise InvalidPricingError(
|
|
291
|
+
f"{key}: missing required field(s): {', '.join(missing)}"
|
|
292
|
+
)
|
|
293
|
+
verified, verified_at = _parse_verified(raw.get("verified", False), key=key)
|
|
294
|
+
cache_write = raw.get("cache_write_per_mtok")
|
|
295
|
+
provider, model = key.split("/", 1)
|
|
296
|
+
return ModelPrice(
|
|
297
|
+
key=key,
|
|
298
|
+
provider=provider,
|
|
299
|
+
model=model,
|
|
300
|
+
input_per_mtok=_to_decimal(raw["input_per_mtok"], field="input_per_mtok", key=key),
|
|
301
|
+
cached_input_per_mtok=_to_decimal(
|
|
302
|
+
raw["cached_input_per_mtok"], field="cached_input_per_mtok", key=key
|
|
303
|
+
),
|
|
304
|
+
output_per_mtok=_to_decimal(
|
|
305
|
+
raw["output_per_mtok"], field="output_per_mtok", key=key
|
|
306
|
+
),
|
|
307
|
+
max_output_tokens=_to_positive_int(
|
|
308
|
+
raw["max_output_tokens"], field="max_output_tokens", key=key
|
|
309
|
+
),
|
|
310
|
+
source=str(raw.get("source", "")),
|
|
311
|
+
verified=verified,
|
|
312
|
+
verified_at=verified_at,
|
|
313
|
+
long_context=_parse_long_context(raw.get("long_context"), key=key),
|
|
314
|
+
schedule=_parse_schedule(raw.get("schedule"), key=key),
|
|
315
|
+
cache_write_per_mtok=(
|
|
316
|
+
_to_decimal(cache_write, field="cache_write_per_mtok", key=key)
|
|
317
|
+
if cache_write is not None
|
|
318
|
+
else None
|
|
319
|
+
),
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _parse_service_tiers(raw: Any) -> dict[str, dict[str, Decimal]]:
|
|
324
|
+
if raw is None:
|
|
325
|
+
return {}
|
|
326
|
+
if not isinstance(raw, Mapping):
|
|
327
|
+
raise InvalidPricingError("service_tiers must be an object")
|
|
328
|
+
result: dict[str, dict[str, Decimal]] = {}
|
|
329
|
+
for provider, tiers in raw.items():
|
|
330
|
+
if not isinstance(tiers, Mapping):
|
|
331
|
+
raise InvalidPricingError(
|
|
332
|
+
f"service_tiers.{provider}: must be an object of tier -> multiplier"
|
|
333
|
+
)
|
|
334
|
+
parsed: dict[str, Decimal] = {}
|
|
335
|
+
for tier, multiplier in tiers.items():
|
|
336
|
+
if isinstance(multiplier, bool) or not isinstance(multiplier, (str, int, Decimal)):
|
|
337
|
+
raise InvalidPricingError(
|
|
338
|
+
f"service_tiers.{provider}.{tier}: multiplier must be a decimal"
|
|
339
|
+
)
|
|
340
|
+
parsed[str(tier)] = _to_decimal(
|
|
341
|
+
multiplier, field=f"service_tiers.{provider}.{tier}", key=provider
|
|
342
|
+
)
|
|
343
|
+
result[str(provider)] = parsed
|
|
344
|
+
return result
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _merge_documents(base: Mapping[str, Any], override: Mapping[str, Any]) -> dict[str, Any]:
|
|
348
|
+
merged: dict[str, Any] = copy.deepcopy(dict(base))
|
|
349
|
+
for field in (
|
|
350
|
+
"version",
|
|
351
|
+
"updated",
|
|
352
|
+
"verified",
|
|
353
|
+
"note",
|
|
354
|
+
"sources",
|
|
355
|
+
"service_tiers",
|
|
356
|
+
):
|
|
357
|
+
if field in override:
|
|
358
|
+
merged[field] = override[field]
|
|
359
|
+
|
|
360
|
+
# Merge service-tier maps provider by provider so an override can add or
|
|
361
|
+
# change one tier without dropping the rest.
|
|
362
|
+
if "service_tiers" in base and "service_tiers" in override:
|
|
363
|
+
merged_tiers: dict[str, Any] = copy.deepcopy(dict(base["service_tiers"]))
|
|
364
|
+
for provider, tiers in (override["service_tiers"] or {}).items():
|
|
365
|
+
if isinstance(merged_tiers.get(provider), Mapping) and isinstance(
|
|
366
|
+
tiers, Mapping
|
|
367
|
+
):
|
|
368
|
+
merged_tiers[provider] = {**merged_tiers[provider], **tiers}
|
|
369
|
+
else:
|
|
370
|
+
merged_tiers[provider] = copy.deepcopy(tiers)
|
|
371
|
+
merged["service_tiers"] = merged_tiers
|
|
372
|
+
|
|
373
|
+
models: dict[str, Any] = dict(merged.get("models") or {})
|
|
374
|
+
for key, raw in (override.get("models") or {}).items():
|
|
375
|
+
if not isinstance(raw, Mapping):
|
|
376
|
+
raise InvalidPricingError(f"{key}: override entry must be an object")
|
|
377
|
+
if key in models and isinstance(models[key], Mapping):
|
|
378
|
+
entry = {**models[key], **raw}
|
|
379
|
+
base_long = models[key].get("long_context")
|
|
380
|
+
if (
|
|
381
|
+
"long_context" in raw
|
|
382
|
+
and isinstance(raw["long_context"], Mapping)
|
|
383
|
+
and isinstance(base_long, Mapping)
|
|
384
|
+
):
|
|
385
|
+
entry["long_context"] = {**base_long, **raw["long_context"]}
|
|
386
|
+
models[key] = entry
|
|
387
|
+
else:
|
|
388
|
+
models[key] = dict(raw)
|
|
389
|
+
merged["models"] = models
|
|
390
|
+
return merged
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _read_json(path: Path) -> Any:
|
|
394
|
+
try:
|
|
395
|
+
with path.open("r", encoding="utf-8") as handle:
|
|
396
|
+
return json.load(handle)
|
|
397
|
+
except FileNotFoundError:
|
|
398
|
+
raise
|
|
399
|
+
except json.JSONDecodeError as exc:
|
|
400
|
+
raise InvalidPricingError(f"{path}: invalid JSON: {exc}") from exc
|
|
401
|
+
except OSError as exc:
|
|
402
|
+
raise InvalidPricingError(f"{path}: cannot read pricing file: {exc}") from exc
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _is_prefix_boundary(rest: str) -> bool:
|
|
406
|
+
"""True when ``rest`` is a legal suffix after a model family prefix.
|
|
407
|
+
|
|
408
|
+
Allowed: nothing at all (exact family), ``@...`` / ``:...`` (provider
|
|
409
|
+
qualifiers) and ``-YYYY...`` date snapshots. A plain ``-mini`` or a bare
|
|
410
|
+
digit is NOT a boundary, so ``gpt-5`` never swallows ``gpt-5.4-mini`` or
|
|
411
|
+
``gpt-50``.
|
|
412
|
+
"""
|
|
413
|
+
|
|
414
|
+
if rest == "":
|
|
415
|
+
return True
|
|
416
|
+
if rest[0] in "@:":
|
|
417
|
+
return True
|
|
418
|
+
return rest[0] == "-" and len(rest) > 1 and rest[1].isdigit()
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
class PricingStore:
|
|
422
|
+
"""Immutable price table with exact + longest-prefix lookup."""
|
|
423
|
+
|
|
424
|
+
def __init__(
|
|
425
|
+
self,
|
|
426
|
+
models: Mapping[str, ModelPrice],
|
|
427
|
+
*,
|
|
428
|
+
version: str = "",
|
|
429
|
+
updated: str = "",
|
|
430
|
+
verified: bool = False,
|
|
431
|
+
note: str = "",
|
|
432
|
+
sources: Mapping[str, str] | None = None,
|
|
433
|
+
service_tiers: Mapping[str, Mapping[str, Decimal]] | None = None,
|
|
434
|
+
) -> None:
|
|
435
|
+
self._models: dict[str, ModelPrice] = dict(models)
|
|
436
|
+
self.version = version
|
|
437
|
+
self.updated = updated
|
|
438
|
+
self.verified = verified
|
|
439
|
+
self.note = note
|
|
440
|
+
self.sources: dict[str, str] = dict(sources or {})
|
|
441
|
+
self.service_tiers: dict[str, dict[str, Decimal]] = {
|
|
442
|
+
provider: dict(tiers)
|
|
443
|
+
for provider, tiers in (service_tiers or {}).items()
|
|
444
|
+
}
|
|
445
|
+
self._prefix_order = sorted(self._models, key=len, reverse=True)
|
|
446
|
+
|
|
447
|
+
@classmethod
|
|
448
|
+
def from_document(cls, document: Mapping[str, Any]) -> "PricingStore":
|
|
449
|
+
if not isinstance(document, Mapping):
|
|
450
|
+
raise InvalidPricingError("pricing document must be a JSON object")
|
|
451
|
+
raw_models = document.get("models")
|
|
452
|
+
if not isinstance(raw_models, Mapping) or not raw_models:
|
|
453
|
+
raise InvalidPricingError("pricing document must contain a non-empty 'models' object")
|
|
454
|
+
models = {
|
|
455
|
+
str(key): _parse_model_price(str(key), raw)
|
|
456
|
+
for key, raw in raw_models.items()
|
|
457
|
+
}
|
|
458
|
+
sources = document.get("sources") or {}
|
|
459
|
+
if not isinstance(sources, Mapping):
|
|
460
|
+
raise InvalidPricingError("'sources' must be an object")
|
|
461
|
+
return cls(
|
|
462
|
+
models,
|
|
463
|
+
version=str(document.get("version", "")),
|
|
464
|
+
updated=str(document.get("updated", "")),
|
|
465
|
+
verified=bool(document.get("verified", False)),
|
|
466
|
+
note=str(document.get("note", "")),
|
|
467
|
+
sources={str(key): str(value) for key, value in sources.items()},
|
|
468
|
+
service_tiers=_parse_service_tiers(document.get("service_tiers")),
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
def __len__(self) -> int:
|
|
472
|
+
return len(self._models)
|
|
473
|
+
|
|
474
|
+
def keys(self) -> list[str]:
|
|
475
|
+
return list(self._models)
|
|
476
|
+
|
|
477
|
+
def service_tier_multiplier(self, provider: str, tier: str) -> Decimal:
|
|
478
|
+
tiers = self.service_tiers.get(provider)
|
|
479
|
+
if not tiers or tier not in tiers:
|
|
480
|
+
raise UnknownServiceTierError(provider, tier)
|
|
481
|
+
return tiers[tier]
|
|
482
|
+
|
|
483
|
+
def get(self, model: str) -> ModelPrice:
|
|
484
|
+
if not isinstance(model, str) or not model.strip():
|
|
485
|
+
raise UnknownModelError(str(model))
|
|
486
|
+
query = model.strip()
|
|
487
|
+
variants = [query]
|
|
488
|
+
if query.startswith("models/"):
|
|
489
|
+
variants.append(query[len("models/"):])
|
|
490
|
+
if "/models/" in query:
|
|
491
|
+
variants.append(query.replace("/models/", "/", 1))
|
|
492
|
+
|
|
493
|
+
for candidate in variants:
|
|
494
|
+
exact = self._models.get(candidate)
|
|
495
|
+
if exact is not None:
|
|
496
|
+
return exact
|
|
497
|
+
|
|
498
|
+
# Longest matching prefix wins. A prefix is only accepted when the
|
|
499
|
+
# remaining suffix is a legal boundary (see ``_is_prefix_boundary``).
|
|
500
|
+
best: ModelPrice | None = None
|
|
501
|
+
best_len = -1
|
|
502
|
+
for key in self._prefix_order:
|
|
503
|
+
price = self._models[key]
|
|
504
|
+
for candidate in variants:
|
|
505
|
+
for prefix in (key, price.model):
|
|
506
|
+
if not candidate.startswith(prefix):
|
|
507
|
+
continue
|
|
508
|
+
if not _is_prefix_boundary(candidate[len(prefix):]):
|
|
509
|
+
continue
|
|
510
|
+
if len(prefix) > best_len:
|
|
511
|
+
best = price
|
|
512
|
+
best_len = len(prefix)
|
|
513
|
+
|
|
514
|
+
if best is not None:
|
|
515
|
+
return best
|
|
516
|
+
|
|
517
|
+
raise UnknownModelError(model)
|
|
518
|
+
|
|
519
|
+
# Friendly aliases for the lookup operation.
|
|
520
|
+
def lookup(self, model: str) -> ModelPrice:
|
|
521
|
+
return self.get(model)
|
|
522
|
+
|
|
523
|
+
def price_for(self, model: str) -> ModelPrice:
|
|
524
|
+
return self.get(model)
|
|
525
|
+
|
|
526
|
+
@classmethod
|
|
527
|
+
def load(cls, path: str | os.PathLike[str] | None = None) -> "PricingStore":
|
|
528
|
+
return load_pricing(path)
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def default_override_path() -> Path:
|
|
532
|
+
return Path.home() / ".config" / "agentcap" / "pricing.json"
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def load_pricing(path: str | os.PathLike[str] | None = None) -> PricingStore:
|
|
536
|
+
"""Load bundled prices, merged with an optional user override file.
|
|
537
|
+
|
|
538
|
+
Resolution order: explicit ``path`` argument, ``AGENTCAP_PRICING_FILE``,
|
|
539
|
+
then ``~/.config/agentcap/pricing.json``. A missing override file is not an
|
|
540
|
+
error; a malformed one is.
|
|
541
|
+
"""
|
|
542
|
+
|
|
543
|
+
base_document = _read_json(BUNDLED_PRICING_PATH)
|
|
544
|
+
|
|
545
|
+
if path is not None:
|
|
546
|
+
override_path = Path(path)
|
|
547
|
+
else:
|
|
548
|
+
env_path = os.environ.get("AGENTCAP_PRICING_FILE")
|
|
549
|
+
override_path = Path(env_path) if env_path else default_override_path()
|
|
550
|
+
|
|
551
|
+
if override_path.exists():
|
|
552
|
+
override_document = _read_json(override_path)
|
|
553
|
+
if not isinstance(override_document, Mapping):
|
|
554
|
+
raise InvalidPricingError(f"{override_path}: override must be a JSON object")
|
|
555
|
+
base_document = _merge_documents(base_document, override_document)
|
|
556
|
+
|
|
557
|
+
return PricingStore.from_document(base_document)
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def effective_rates(
|
|
561
|
+
model_price: ModelPrice,
|
|
562
|
+
input_tokens: int,
|
|
563
|
+
now: datetime | date | None = None,
|
|
564
|
+
) -> tuple[Decimal, Decimal, Decimal]:
|
|
565
|
+
"""Return (input, cached_input, output) rates for the applicable tier."""
|
|
566
|
+
|
|
567
|
+
input_rate, cached_rate, output_rate = model_price.rates_at(now)
|
|
568
|
+
long_context = model_price.long_context
|
|
569
|
+
if long_context is not None and input_tokens > long_context.threshold_tokens:
|
|
570
|
+
return (
|
|
571
|
+
long_context.input_per_mtok,
|
|
572
|
+
(
|
|
573
|
+
long_context.cached_input_per_mtok
|
|
574
|
+
if long_context.cached_input_per_mtok is not None
|
|
575
|
+
else cached_rate
|
|
576
|
+
),
|
|
577
|
+
long_context.output_per_mtok,
|
|
578
|
+
)
|
|
579
|
+
return input_rate, cached_rate, output_rate
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def cost(
|
|
583
|
+
model_price: ModelPrice,
|
|
584
|
+
input_tokens: int,
|
|
585
|
+
cached_input_tokens: int = 0,
|
|
586
|
+
output_tokens: int = 0,
|
|
587
|
+
now: datetime | date | None = None,
|
|
588
|
+
cache_write: bool = False,
|
|
589
|
+
) -> Decimal:
|
|
590
|
+
"""Cost in USD for a completed request.
|
|
591
|
+
|
|
592
|
+
``input_tokens`` includes cached tokens; ``cached_input_tokens`` is billed
|
|
593
|
+
at the discounted cached rate. When the prompt crosses a long-context
|
|
594
|
+
threshold the whole request is billed at that tier's rates. When
|
|
595
|
+
``cache_write`` is true, non-cached input is billed at
|
|
596
|
+
``max(input, cache_write)``.
|
|
597
|
+
"""
|
|
598
|
+
|
|
599
|
+
for name, value in (
|
|
600
|
+
("input_tokens", input_tokens),
|
|
601
|
+
("cached_input_tokens", cached_input_tokens),
|
|
602
|
+
("output_tokens", output_tokens),
|
|
603
|
+
):
|
|
604
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
|
605
|
+
raise ValueError(f"{name} must be a non-negative integer")
|
|
606
|
+
|
|
607
|
+
cached = min(cached_input_tokens, input_tokens)
|
|
608
|
+
uncached = input_tokens - cached
|
|
609
|
+
input_rate, cached_rate, output_rate = effective_rates(
|
|
610
|
+
model_price, input_tokens, now=now
|
|
611
|
+
)
|
|
612
|
+
if cache_write and model_price.cache_write_per_mtok is not None:
|
|
613
|
+
input_rate = max(input_rate, model_price.cache_write_per_mtok)
|
|
614
|
+
|
|
615
|
+
total = (
|
|
616
|
+
Decimal(uncached) * input_rate
|
|
617
|
+
+ Decimal(cached) * cached_rate
|
|
618
|
+
+ Decimal(output_tokens) * output_rate
|
|
619
|
+
) / ONE_MILLION
|
|
620
|
+
return total
|