metergraph-core 0.6.4__tar.gz → 0.7.1__tar.gz
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.
- {metergraph_core-0.6.4/src/metergraph_core.egg-info → metergraph_core-0.7.1}/PKG-INFO +53 -1
- {metergraph_core-0.6.4 → metergraph_core-0.7.1}/README.md +52 -0
- {metergraph_core-0.6.4 → metergraph_core-0.7.1}/pyproject.toml +1 -1
- {metergraph_core-0.6.4 → metergraph_core-0.7.1}/src/metergraph_core/__init__.py +4 -0
- metergraph_core-0.7.1/src/metergraph_core/responses.py +357 -0
- {metergraph_core-0.6.4 → metergraph_core-0.7.1/src/metergraph_core.egg-info}/PKG-INFO +53 -1
- {metergraph_core-0.6.4 → metergraph_core-0.7.1}/src/metergraph_core.egg-info/SOURCES.txt +1 -0
- {metergraph_core-0.6.4 → metergraph_core-0.7.1}/MANIFEST.in +0 -0
- {metergraph_core-0.6.4 → metergraph_core-0.7.1}/setup.cfg +0 -0
- {metergraph_core-0.6.4 → metergraph_core-0.7.1}/src/metergraph_core/billing.py +0 -0
- {metergraph_core-0.6.4 → metergraph_core-0.7.1}/src/metergraph_core/catalog.py +0 -0
- {metergraph_core-0.6.4 → metergraph_core-0.7.1}/src/metergraph_core/catalog_delta.py +0 -0
- {metergraph_core-0.6.4 → metergraph_core-0.7.1}/src/metergraph_core/data/models.yaml +0 -0
- {metergraph_core-0.6.4 → metergraph_core-0.7.1}/src/metergraph_core/data/prices.yaml +0 -0
- {metergraph_core-0.6.4 → metergraph_core-0.7.1}/src/metergraph_core/data/sources.yaml +0 -0
- {metergraph_core-0.6.4 → metergraph_core-0.7.1}/src/metergraph_core/extractors.py +0 -0
- {metergraph_core-0.6.4 → metergraph_core-0.7.1}/src/metergraph_core/loader.py +0 -0
- {metergraph_core-0.6.4 → metergraph_core-0.7.1}/src/metergraph_core/models.py +0 -0
- {metergraph_core-0.6.4 → metergraph_core-0.7.1}/src/metergraph_core/refresh.py +0 -0
- {metergraph_core-0.6.4 → metergraph_core-0.7.1}/src/metergraph_core/retrieval.py +0 -0
- {metergraph_core-0.6.4 → metergraph_core-0.7.1}/src/metergraph_core/sources.py +0 -0
- {metergraph_core-0.6.4 → metergraph_core-0.7.1}/src/metergraph_core.egg-info/dependency_links.txt +0 -0
- {metergraph_core-0.6.4 → metergraph_core-0.7.1}/src/metergraph_core.egg-info/requires.txt +0 -0
- {metergraph_core-0.6.4 → metergraph_core-0.7.1}/src/metergraph_core.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: metergraph-core
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.1
|
|
4
4
|
Summary: Open, effective-dated LLM pricing catalog and deterministic cost calculator for popular models across major providers and gateways, including OpenAI, Anthropic, Google Gemini, AWS Bedrock, OpenRouter, Vercel AI Gateway, and more.
|
|
5
5
|
Author: Metergraph
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -200,6 +200,55 @@ A call after it ended resolves to that price, stays `priced`, and carries
|
|
|
200
200
|
applied `price.effective_to` says when it ended. A closed price followed by a
|
|
201
201
|
later one never extends, so a gap before a scheduled price stays `unpriced`.
|
|
202
202
|
|
|
203
|
+
### Costing a provider response
|
|
204
|
+
|
|
205
|
+
`cost_from_response` reads the usage out of a response you already have and
|
|
206
|
+
costs it, so you do not extract token counts yourself:
|
|
207
|
+
|
|
208
|
+
```python
|
|
209
|
+
from openai import OpenAI
|
|
210
|
+
from metergraph_core import cost_from_response
|
|
211
|
+
|
|
212
|
+
response = OpenAI().chat.completions.create(model="gpt-5.4-mini", messages=messages)
|
|
213
|
+
result = cost_from_response("openai", response)
|
|
214
|
+
print(result.status, result.model_id, result.cost, result.currency)
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
It accepts the object a provider SDK returned or the same response as JSON:
|
|
218
|
+
|
|
219
|
+
| Provider API | Example `provider` |
|
|
220
|
+
|---|---|
|
|
221
|
+
| OpenAI Chat Completions, and gateways that return its shape | `openai`, `openrouter`, `deepseek` |
|
|
222
|
+
| OpenAI Responses | `openai` |
|
|
223
|
+
| Anthropic Messages | `anthropic` |
|
|
224
|
+
| Google Gemini `generateContent` | `google` |
|
|
225
|
+
| Bedrock Converse (pass `model=`, the response does not name one) | `bedrock` |
|
|
226
|
+
|
|
227
|
+
`provider` names who served the call and selects its direct channel unless
|
|
228
|
+
`channel` is given. The response format is detected from its fields; pass
|
|
229
|
+
`format=`, one of `RESPONSE_FORMATS`, for a response too sparse to detect.
|
|
230
|
+
`response_format(response)` returns the detected format, or `None`. Only the
|
|
231
|
+
model, usage counts, service tier and creation time are read; prompt and
|
|
232
|
+
output content never are.
|
|
233
|
+
|
|
234
|
+
Cached tokens are billed once whichever convention the response uses. OpenAI
|
|
235
|
+
and Gemini count cached tokens inside the input total, so the total is
|
|
236
|
+
restated in the convention of the price row that bills it, including a
|
|
237
|
+
gateway row for another publisher's model. Gemini thinking tokens bill as
|
|
238
|
+
output.
|
|
239
|
+
|
|
240
|
+
The price in effect when the response was created applies, from `created`,
|
|
241
|
+
`created_at` or `createTime`; `at=` overrides it, and a response with no
|
|
242
|
+
creation time is priced now. `catalog=` costs against a catalog you loaded;
|
|
243
|
+
the default is the bundled one.
|
|
244
|
+
|
|
245
|
+
A batch service tier bills at batch rates. Pass `service_tier=` when the
|
|
246
|
+
response does not state its tier, such as an OpenAI batch output line. Any other tier besides standard,
|
|
247
|
+
such as `priority` or `flex`, returns `partial` with `service_tier` in
|
|
248
|
+
`missing_dimensions`, since the catalog carries no tier rates. Cost a streamed
|
|
249
|
+
call from its final response or final usage chunk. Fees outside token usage,
|
|
250
|
+
such as web search, are not included.
|
|
251
|
+
|
|
203
252
|
## Model registry
|
|
204
253
|
|
|
205
254
|
```python
|
|
@@ -462,10 +511,12 @@ from metergraph_core import (
|
|
|
462
511
|
PriceResult,
|
|
463
512
|
PriceRowKey,
|
|
464
513
|
PriceSource,
|
|
514
|
+
RESPONSE_FORMATS,
|
|
465
515
|
RefreshResult,
|
|
466
516
|
ResolvedPrice,
|
|
467
517
|
SourceRegistry,
|
|
468
518
|
apply_catalog_delta,
|
|
519
|
+
cost_from_response,
|
|
469
520
|
discovery_evidence,
|
|
470
521
|
extract,
|
|
471
522
|
load_catalog,
|
|
@@ -477,6 +528,7 @@ from metergraph_core import (
|
|
|
477
528
|
parse_catalog_delta,
|
|
478
529
|
plan_low_risk_refresh,
|
|
479
530
|
resolve_billing,
|
|
531
|
+
response_format,
|
|
480
532
|
structural_fingerprint,
|
|
481
533
|
validate_model_registry,
|
|
482
534
|
validate_source_registry,
|
|
@@ -170,6 +170,55 @@ A call after it ended resolves to that price, stays `priced`, and carries
|
|
|
170
170
|
applied `price.effective_to` says when it ended. A closed price followed by a
|
|
171
171
|
later one never extends, so a gap before a scheduled price stays `unpriced`.
|
|
172
172
|
|
|
173
|
+
### Costing a provider response
|
|
174
|
+
|
|
175
|
+
`cost_from_response` reads the usage out of a response you already have and
|
|
176
|
+
costs it, so you do not extract token counts yourself:
|
|
177
|
+
|
|
178
|
+
```python
|
|
179
|
+
from openai import OpenAI
|
|
180
|
+
from metergraph_core import cost_from_response
|
|
181
|
+
|
|
182
|
+
response = OpenAI().chat.completions.create(model="gpt-5.4-mini", messages=messages)
|
|
183
|
+
result = cost_from_response("openai", response)
|
|
184
|
+
print(result.status, result.model_id, result.cost, result.currency)
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
It accepts the object a provider SDK returned or the same response as JSON:
|
|
188
|
+
|
|
189
|
+
| Provider API | Example `provider` |
|
|
190
|
+
|---|---|
|
|
191
|
+
| OpenAI Chat Completions, and gateways that return its shape | `openai`, `openrouter`, `deepseek` |
|
|
192
|
+
| OpenAI Responses | `openai` |
|
|
193
|
+
| Anthropic Messages | `anthropic` |
|
|
194
|
+
| Google Gemini `generateContent` | `google` |
|
|
195
|
+
| Bedrock Converse (pass `model=`, the response does not name one) | `bedrock` |
|
|
196
|
+
|
|
197
|
+
`provider` names who served the call and selects its direct channel unless
|
|
198
|
+
`channel` is given. The response format is detected from its fields; pass
|
|
199
|
+
`format=`, one of `RESPONSE_FORMATS`, for a response too sparse to detect.
|
|
200
|
+
`response_format(response)` returns the detected format, or `None`. Only the
|
|
201
|
+
model, usage counts, service tier and creation time are read; prompt and
|
|
202
|
+
output content never are.
|
|
203
|
+
|
|
204
|
+
Cached tokens are billed once whichever convention the response uses. OpenAI
|
|
205
|
+
and Gemini count cached tokens inside the input total, so the total is
|
|
206
|
+
restated in the convention of the price row that bills it, including a
|
|
207
|
+
gateway row for another publisher's model. Gemini thinking tokens bill as
|
|
208
|
+
output.
|
|
209
|
+
|
|
210
|
+
The price in effect when the response was created applies, from `created`,
|
|
211
|
+
`created_at` or `createTime`; `at=` overrides it, and a response with no
|
|
212
|
+
creation time is priced now. `catalog=` costs against a catalog you loaded;
|
|
213
|
+
the default is the bundled one.
|
|
214
|
+
|
|
215
|
+
A batch service tier bills at batch rates. Pass `service_tier=` when the
|
|
216
|
+
response does not state its tier, such as an OpenAI batch output line. Any other tier besides standard,
|
|
217
|
+
such as `priority` or `flex`, returns `partial` with `service_tier` in
|
|
218
|
+
`missing_dimensions`, since the catalog carries no tier rates. Cost a streamed
|
|
219
|
+
call from its final response or final usage chunk. Fees outside token usage,
|
|
220
|
+
such as web search, are not included.
|
|
221
|
+
|
|
173
222
|
## Model registry
|
|
174
223
|
|
|
175
224
|
```python
|
|
@@ -432,10 +481,12 @@ from metergraph_core import (
|
|
|
432
481
|
PriceResult,
|
|
433
482
|
PriceRowKey,
|
|
434
483
|
PriceSource,
|
|
484
|
+
RESPONSE_FORMATS,
|
|
435
485
|
RefreshResult,
|
|
436
486
|
ResolvedPrice,
|
|
437
487
|
SourceRegistry,
|
|
438
488
|
apply_catalog_delta,
|
|
489
|
+
cost_from_response,
|
|
439
490
|
discovery_evidence,
|
|
440
491
|
extract,
|
|
441
492
|
load_catalog,
|
|
@@ -447,6 +498,7 @@ from metergraph_core import (
|
|
|
447
498
|
parse_catalog_delta,
|
|
448
499
|
plan_low_risk_refresh,
|
|
449
500
|
resolve_billing,
|
|
501
|
+
response_format,
|
|
450
502
|
structural_fingerprint,
|
|
451
503
|
validate_model_registry,
|
|
452
504
|
validate_source_registry,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "metergraph-core"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.7.1"
|
|
4
4
|
description = "Open, effective-dated LLM pricing catalog and deterministic cost calculator for popular models across major providers and gateways, including OpenAI, Anthropic, Google Gemini, AWS Bedrock, OpenRouter, Vercel AI Gateway, and more."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "Apache-2.0"
|
|
@@ -59,6 +59,7 @@ from .catalog_delta import (
|
|
|
59
59
|
apply_catalog_delta,
|
|
60
60
|
parse_catalog_delta,
|
|
61
61
|
)
|
|
62
|
+
from .responses import RESPONSE_FORMATS, cost_from_response, response_format
|
|
62
63
|
from .extractors import (
|
|
63
64
|
ExtractedRate,
|
|
64
65
|
Extraction,
|
|
@@ -133,6 +134,9 @@ __all__ = [
|
|
|
133
134
|
"RetrievalPrice",
|
|
134
135
|
"count_fetch_units",
|
|
135
136
|
"count_search_units",
|
|
137
|
+
"cost_from_response",
|
|
138
|
+
"response_format",
|
|
139
|
+
"RESPONSE_FORMATS",
|
|
136
140
|
"counts_cache_read_in_input",
|
|
137
141
|
"direct_channel_for_provider",
|
|
138
142
|
"normalize_provider",
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
"""Cost a provider response object as the caller received it.
|
|
2
|
+
|
|
3
|
+
Each reader copies only the fields a price depends on: model, usage counts,
|
|
4
|
+
service tier and creation time. Prompt, output and every other field are never
|
|
5
|
+
read, so nothing downstream sees content the response carried.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field, replace
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from functools import lru_cache
|
|
13
|
+
from typing import Any, Callable, Mapping
|
|
14
|
+
|
|
15
|
+
from .catalog import CostResult, ModelUsage, _coerce_datetime
|
|
16
|
+
from .loader import LoadedCatalog, load_catalog
|
|
17
|
+
|
|
18
|
+
_STANDARD_TIERS = frozenset({"default", "standard", "standard_only", "auto", "on_demand"})
|
|
19
|
+
_BATCH_TIER = "batch"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True, slots=True)
|
|
23
|
+
class _Facts:
|
|
24
|
+
model: str | None
|
|
25
|
+
usage: Mapping[str, int | None] = field(default_factory=dict)
|
|
26
|
+
# The input total already counts cached tokens, so it is restated in the
|
|
27
|
+
# convention of the price row that bills it.
|
|
28
|
+
input_includes_cache: bool = False
|
|
29
|
+
service_tier: str | None = None
|
|
30
|
+
at: datetime | None = None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _object(value: Any) -> Mapping[str, Any]:
|
|
34
|
+
return value if isinstance(value, Mapping) else {}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _text(value: Any) -> str | None:
|
|
38
|
+
if isinstance(value, str) and value.strip():
|
|
39
|
+
return value.strip()
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _count(value: Any) -> int | None:
|
|
44
|
+
"""A token count, or None for anything that is not a whole number."""
|
|
45
|
+
if isinstance(value, bool):
|
|
46
|
+
return None
|
|
47
|
+
if isinstance(value, int):
|
|
48
|
+
return value
|
|
49
|
+
if isinstance(value, float) and value.is_integer():
|
|
50
|
+
return int(value)
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _sum(*values: Any) -> int | None:
|
|
55
|
+
counts = [_count(value) for value in values]
|
|
56
|
+
if counts[0] is None:
|
|
57
|
+
return None
|
|
58
|
+
return sum(count or 0 for count in counts)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _first(mapping: Mapping[str, Any], *keys: str) -> Any:
|
|
62
|
+
"""The first present key: SDK objects dump snake_case, REST JSON is
|
|
63
|
+
camelCase."""
|
|
64
|
+
for key in keys:
|
|
65
|
+
if mapping.get(key) is not None:
|
|
66
|
+
return mapping[key]
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _epoch(value: Any) -> datetime | None:
|
|
71
|
+
seconds = _count(value)
|
|
72
|
+
if seconds is None:
|
|
73
|
+
return None
|
|
74
|
+
try:
|
|
75
|
+
return datetime.fromtimestamp(seconds, timezone.utc)
|
|
76
|
+
except (OverflowError, OSError, ValueError):
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _iso(value: Any) -> datetime | None:
|
|
81
|
+
if isinstance(value, datetime):
|
|
82
|
+
return value if value.tzinfo else value.replace(tzinfo=timezone.utc)
|
|
83
|
+
if not isinstance(value, str) or not value.strip():
|
|
84
|
+
return None
|
|
85
|
+
text = value.strip().replace("Z", "+00:00")
|
|
86
|
+
# Python 3.10 parses at most six fractional digits; Vertex sends nine.
|
|
87
|
+
head, dot, rest = text.partition(".")
|
|
88
|
+
if dot:
|
|
89
|
+
digits = len(rest) - len(rest.lstrip("0123456789"))
|
|
90
|
+
text = f"{head}.{rest[:min(digits, 6)]}{rest[digits:]}"
|
|
91
|
+
try:
|
|
92
|
+
parsed = datetime.fromisoformat(text)
|
|
93
|
+
except ValueError:
|
|
94
|
+
return None
|
|
95
|
+
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _anthropic_messages(response: Mapping[str, Any]) -> _Facts:
|
|
99
|
+
usage = _object(response.get("usage"))
|
|
100
|
+
creation = _object(usage.get("cache_creation"))
|
|
101
|
+
return _Facts(
|
|
102
|
+
model=_text(response.get("model")),
|
|
103
|
+
usage={
|
|
104
|
+
"input_tokens": _count(usage.get("input_tokens")),
|
|
105
|
+
"output_tokens": _count(usage.get("output_tokens")),
|
|
106
|
+
"cache_read_tokens": _count(usage.get("cache_read_input_tokens")),
|
|
107
|
+
"cache_write_tokens": _count(usage.get("cache_creation_input_tokens")),
|
|
108
|
+
"cache_write_5m_tokens": _count(creation.get("ephemeral_5m_input_tokens")),
|
|
109
|
+
"cache_write_1h_tokens": _count(creation.get("ephemeral_1h_input_tokens")),
|
|
110
|
+
},
|
|
111
|
+
service_tier=_text(usage.get("service_tier")),
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _openai_responses(response: Mapping[str, Any]) -> _Facts:
|
|
116
|
+
usage = _object(response.get("usage"))
|
|
117
|
+
details = _object(usage.get("input_tokens_details"))
|
|
118
|
+
return _Facts(
|
|
119
|
+
model=_text(response.get("model")),
|
|
120
|
+
usage={
|
|
121
|
+
"input_tokens": _count(usage.get("input_tokens")),
|
|
122
|
+
"output_tokens": _count(usage.get("output_tokens")),
|
|
123
|
+
"cache_read_tokens": _count(details.get("cached_tokens")),
|
|
124
|
+
# Gateways such as OpenRouter report cache writes beside reads.
|
|
125
|
+
"cache_write_tokens": _count(details.get("cache_write_tokens")),
|
|
126
|
+
},
|
|
127
|
+
input_includes_cache=True,
|
|
128
|
+
service_tier=_text(response.get("service_tier")),
|
|
129
|
+
at=_epoch(response.get("created_at")),
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _openai_chat_completions(response: Mapping[str, Any]) -> _Facts:
|
|
134
|
+
usage = _object(response.get("usage"))
|
|
135
|
+
details = _object(usage.get("prompt_tokens_details"))
|
|
136
|
+
return _Facts(
|
|
137
|
+
model=_text(response.get("model")),
|
|
138
|
+
usage={
|
|
139
|
+
"input_tokens": _count(usage.get("prompt_tokens")),
|
|
140
|
+
"output_tokens": _count(usage.get("completion_tokens")),
|
|
141
|
+
"cache_read_tokens": _count(details.get("cached_tokens")),
|
|
142
|
+
"cache_write_tokens": _count(details.get("cache_write_tokens")),
|
|
143
|
+
},
|
|
144
|
+
# Gateways relaying another publisher's model keep this shape, with
|
|
145
|
+
# cached tokens inside the prompt total.
|
|
146
|
+
input_includes_cache=True,
|
|
147
|
+
service_tier=_text(response.get("service_tier")),
|
|
148
|
+
at=_epoch(response.get("created")),
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _gemini(response: Mapping[str, Any]) -> _Facts:
|
|
153
|
+
usage = _object(_first(response, "usage_metadata", "usageMetadata"))
|
|
154
|
+
return _Facts(
|
|
155
|
+
model=_text(_first(response, "model_version", "modelVersion")),
|
|
156
|
+
usage={
|
|
157
|
+
# Tool-use prompt tokens bill as input; thinking tokens bill as
|
|
158
|
+
# output but are reported outside the candidates count.
|
|
159
|
+
"input_tokens": _sum(
|
|
160
|
+
_first(usage, "prompt_token_count", "promptTokenCount"),
|
|
161
|
+
_first(usage, "tool_use_prompt_token_count", "toolUsePromptTokenCount"),
|
|
162
|
+
),
|
|
163
|
+
"output_tokens": _sum(
|
|
164
|
+
_first(usage, "candidates_token_count", "candidatesTokenCount"),
|
|
165
|
+
_first(usage, "thoughts_token_count", "thoughtsTokenCount"),
|
|
166
|
+
),
|
|
167
|
+
"cache_read_tokens": _count(
|
|
168
|
+
_first(usage, "cached_content_token_count", "cachedContentTokenCount")
|
|
169
|
+
),
|
|
170
|
+
},
|
|
171
|
+
input_includes_cache=True,
|
|
172
|
+
at=_iso(_first(response, "create_time", "createTime")),
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _bedrock_converse(response: Mapping[str, Any]) -> _Facts:
|
|
177
|
+
usage = _object(response.get("usage"))
|
|
178
|
+
return _Facts(
|
|
179
|
+
# Converse responses do not name the model; the caller supplies it.
|
|
180
|
+
model=None,
|
|
181
|
+
usage={
|
|
182
|
+
"input_tokens": _count(usage.get("inputTokens")),
|
|
183
|
+
"output_tokens": _count(usage.get("outputTokens")),
|
|
184
|
+
"cache_read_tokens": _count(usage.get("cacheReadInputTokens")),
|
|
185
|
+
"cache_write_tokens": _count(usage.get("cacheWriteInputTokens")),
|
|
186
|
+
},
|
|
187
|
+
service_tier=_text(_object(response.get("serviceTier")).get("type")),
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
_READERS: dict[str, Callable[[Mapping[str, Any]], _Facts]] = {
|
|
192
|
+
"openai.chat_completions": _openai_chat_completions,
|
|
193
|
+
"openai.responses": _openai_responses,
|
|
194
|
+
"anthropic.messages": _anthropic_messages,
|
|
195
|
+
"gemini.generate_content": _gemini,
|
|
196
|
+
"bedrock.converse": _bedrock_converse,
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
# The response formats :func:`cost_from_response` reads.
|
|
200
|
+
RESPONSE_FORMATS = tuple(_READERS)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _detect(response: Mapping[str, Any]) -> str | None:
|
|
204
|
+
"""The format of this response, told apart by the fields each API
|
|
205
|
+
always returns."""
|
|
206
|
+
if _first(response, "usage_metadata", "usageMetadata") is not None:
|
|
207
|
+
return "gemini.generate_content"
|
|
208
|
+
usage = _object(response.get("usage"))
|
|
209
|
+
if "prompt_tokens" in usage:
|
|
210
|
+
return "openai.chat_completions"
|
|
211
|
+
if "inputTokens" in usage:
|
|
212
|
+
return "bedrock.converse"
|
|
213
|
+
if response.get("type") == "message" or "cache_read_input_tokens" in usage:
|
|
214
|
+
return "anthropic.messages"
|
|
215
|
+
if response.get("object") == "response" or "input_tokens_details" in usage:
|
|
216
|
+
return "openai.responses"
|
|
217
|
+
return None
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def response_format(response: Any) -> str | None:
|
|
221
|
+
"""The :data:`RESPONSE_FORMATS` entry ``response`` is in, or None when its
|
|
222
|
+
shape is not recognized."""
|
|
223
|
+
return _detect(_as_mapping(response))
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _as_mapping(response: Any) -> Mapping[str, Any]:
|
|
227
|
+
if isinstance(response, Mapping):
|
|
228
|
+
return response
|
|
229
|
+
dump = getattr(response, "model_dump", None)
|
|
230
|
+
if callable(dump):
|
|
231
|
+
# OpenAI, Anthropic and Google GenAI SDK objects are pydantic models.
|
|
232
|
+
return dump()
|
|
233
|
+
to_dict = getattr(response, "to_dict", None)
|
|
234
|
+
if callable(to_dict):
|
|
235
|
+
return to_dict()
|
|
236
|
+
raise TypeError(
|
|
237
|
+
"response must be a mapping or a provider SDK response object, "
|
|
238
|
+
f"not {type(response).__name__}"
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@lru_cache(maxsize=1)
|
|
243
|
+
def _bundled_catalog() -> LoadedCatalog:
|
|
244
|
+
return load_catalog(region="global")
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _native_input_tokens(
|
|
248
|
+
usage: Mapping[str, int | None], probe: Callable[..., CostResult]
|
|
249
|
+
) -> int | None:
|
|
250
|
+
"""An input total that includes cache reads and writes, restated in the
|
|
251
|
+
convention the priced row uses.
|
|
252
|
+
|
|
253
|
+
The row records whether its input total already counts cached tokens
|
|
254
|
+
(OpenAI's includes reads, Anthropic's includes neither). A response whose
|
|
255
|
+
total includes them, priced on a row that does not deduct them, would
|
|
256
|
+
otherwise bill every cached token twice. ``probe`` costs a usage on the
|
|
257
|
+
same model and channel.
|
|
258
|
+
"""
|
|
259
|
+
total = usage.get("input_tokens")
|
|
260
|
+
cache_read = usage.get("cache_read_tokens") or 0
|
|
261
|
+
cache_write = usage.get("cache_write_tokens") or 0
|
|
262
|
+
if total is None or cache_read < 0 or cache_write < 0 or not (cache_read or cache_write):
|
|
263
|
+
return total
|
|
264
|
+
# Cached tokens exceeding the total cannot have been included in it.
|
|
265
|
+
uncached = (
|
|
266
|
+
total - cache_read - cache_write
|
|
267
|
+
if cache_read + cache_write <= total
|
|
268
|
+
else total
|
|
269
|
+
)
|
|
270
|
+
# A zero-input probe reports which cached counts the row deducts from input.
|
|
271
|
+
reasons = probe(
|
|
272
|
+
input_tokens=0, output_tokens=0, cache_read_tokens=1, cache_write_tokens=1
|
|
273
|
+
).reasons
|
|
274
|
+
native = uncached
|
|
275
|
+
if "cache_read_exceeds_input" in reasons:
|
|
276
|
+
native += cache_read
|
|
277
|
+
if "cache_write_exceeds_input" in reasons:
|
|
278
|
+
native += cache_write
|
|
279
|
+
return native
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def cost_from_response(
|
|
283
|
+
provider: str,
|
|
284
|
+
response: Any,
|
|
285
|
+
*,
|
|
286
|
+
catalog: LoadedCatalog | None = None,
|
|
287
|
+
model: str | None = None,
|
|
288
|
+
at: Any = None,
|
|
289
|
+
channel: str | None = None,
|
|
290
|
+
region: str | None = None,
|
|
291
|
+
service_tier: str | None = None,
|
|
292
|
+
format: str | None = None,
|
|
293
|
+
) -> CostResult:
|
|
294
|
+
"""The cost of one provider response, from the usage it reports.
|
|
295
|
+
|
|
296
|
+
``response`` is the object the provider SDK returned, or its JSON as a
|
|
297
|
+
mapping: OpenAI Chat Completions or Responses, Anthropic Messages, Google
|
|
298
|
+
Gemini ``generateContent``, or Bedrock Converse. ``provider`` names who
|
|
299
|
+
served the call and selects its direct pricing channel unless ``channel``
|
|
300
|
+
is given. ``model`` overrides the response's model id and is required for
|
|
301
|
+
Bedrock Converse, whose responses do not name one. ``at`` defaults to the
|
|
302
|
+
response's creation time when it reports one, else now. ``catalog``
|
|
303
|
+
defaults to the catalog bundled with this package. ``format``, one of
|
|
304
|
+
:data:`RESPONSE_FORMATS`, names the response's format; without it the
|
|
305
|
+
format is detected from the response's fields.
|
|
306
|
+
|
|
307
|
+
A streamed call is costed from its final response or final usage chunk.
|
|
308
|
+
Fees outside token usage, such as web search, are not included.
|
|
309
|
+
``service_tier`` overrides the tier the response reports, for responses
|
|
310
|
+
that do not state it, such as an OpenAI batch output line. A tier other
|
|
311
|
+
than standard or batch turns the result ``partial`` with ``service_tier``
|
|
312
|
+
missing, since the catalog does not carry tier rates.
|
|
313
|
+
"""
|
|
314
|
+
data = _as_mapping(response)
|
|
315
|
+
if format is not None and format not in _READERS:
|
|
316
|
+
raise ValueError(f"format must be one of {', '.join(RESPONSE_FORMATS)}")
|
|
317
|
+
detected = format or _detect(data)
|
|
318
|
+
if detected is None:
|
|
319
|
+
raise ValueError("response carries no usage this function can read")
|
|
320
|
+
facts = _READERS[detected](data)
|
|
321
|
+
when = at if at is not None else facts.at or datetime.now(timezone.utc)
|
|
322
|
+
model_id = _text(model) or facts.model
|
|
323
|
+
tier = (_text(service_tier) or facts.service_tier or "").lower() or None
|
|
324
|
+
if model_id is None:
|
|
325
|
+
return CostResult(
|
|
326
|
+
None,
|
|
327
|
+
None,
|
|
328
|
+
None,
|
|
329
|
+
"unpriced",
|
|
330
|
+
("missing_model",),
|
|
331
|
+
effective_at=_coerce_datetime(when),
|
|
332
|
+
missing_dimensions=("model",),
|
|
333
|
+
)
|
|
334
|
+
loaded = catalog if catalog is not None else _bundled_catalog()
|
|
335
|
+
batch = tier == _BATCH_TIER
|
|
336
|
+
|
|
337
|
+
def cost(**usage: Any) -> CostResult:
|
|
338
|
+
return loaded.calculate_cost(
|
|
339
|
+
model=model_id,
|
|
340
|
+
usage=ModelUsage(**usage, batch=batch),
|
|
341
|
+
at=when,
|
|
342
|
+
channel=channel,
|
|
343
|
+
provider=provider,
|
|
344
|
+
region=region,
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
usage = dict(facts.usage)
|
|
348
|
+
if facts.input_includes_cache:
|
|
349
|
+
usage["input_tokens"] = _native_input_tokens(usage, cost)
|
|
350
|
+
result = cost(**usage)
|
|
351
|
+
if tier is None or tier in _STANDARD_TIERS or batch or result.status == "unpriced":
|
|
352
|
+
return result
|
|
353
|
+
return replace(
|
|
354
|
+
result,
|
|
355
|
+
status="partial",
|
|
356
|
+
missing_dimensions=tuple(dict.fromkeys((*result.missing_dimensions, "service_tier"))),
|
|
357
|
+
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: metergraph-core
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.1
|
|
4
4
|
Summary: Open, effective-dated LLM pricing catalog and deterministic cost calculator for popular models across major providers and gateways, including OpenAI, Anthropic, Google Gemini, AWS Bedrock, OpenRouter, Vercel AI Gateway, and more.
|
|
5
5
|
Author: Metergraph
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -200,6 +200,55 @@ A call after it ended resolves to that price, stays `priced`, and carries
|
|
|
200
200
|
applied `price.effective_to` says when it ended. A closed price followed by a
|
|
201
201
|
later one never extends, so a gap before a scheduled price stays `unpriced`.
|
|
202
202
|
|
|
203
|
+
### Costing a provider response
|
|
204
|
+
|
|
205
|
+
`cost_from_response` reads the usage out of a response you already have and
|
|
206
|
+
costs it, so you do not extract token counts yourself:
|
|
207
|
+
|
|
208
|
+
```python
|
|
209
|
+
from openai import OpenAI
|
|
210
|
+
from metergraph_core import cost_from_response
|
|
211
|
+
|
|
212
|
+
response = OpenAI().chat.completions.create(model="gpt-5.4-mini", messages=messages)
|
|
213
|
+
result = cost_from_response("openai", response)
|
|
214
|
+
print(result.status, result.model_id, result.cost, result.currency)
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
It accepts the object a provider SDK returned or the same response as JSON:
|
|
218
|
+
|
|
219
|
+
| Provider API | Example `provider` |
|
|
220
|
+
|---|---|
|
|
221
|
+
| OpenAI Chat Completions, and gateways that return its shape | `openai`, `openrouter`, `deepseek` |
|
|
222
|
+
| OpenAI Responses | `openai` |
|
|
223
|
+
| Anthropic Messages | `anthropic` |
|
|
224
|
+
| Google Gemini `generateContent` | `google` |
|
|
225
|
+
| Bedrock Converse (pass `model=`, the response does not name one) | `bedrock` |
|
|
226
|
+
|
|
227
|
+
`provider` names who served the call and selects its direct channel unless
|
|
228
|
+
`channel` is given. The response format is detected from its fields; pass
|
|
229
|
+
`format=`, one of `RESPONSE_FORMATS`, for a response too sparse to detect.
|
|
230
|
+
`response_format(response)` returns the detected format, or `None`. Only the
|
|
231
|
+
model, usage counts, service tier and creation time are read; prompt and
|
|
232
|
+
output content never are.
|
|
233
|
+
|
|
234
|
+
Cached tokens are billed once whichever convention the response uses. OpenAI
|
|
235
|
+
and Gemini count cached tokens inside the input total, so the total is
|
|
236
|
+
restated in the convention of the price row that bills it, including a
|
|
237
|
+
gateway row for another publisher's model. Gemini thinking tokens bill as
|
|
238
|
+
output.
|
|
239
|
+
|
|
240
|
+
The price in effect when the response was created applies, from `created`,
|
|
241
|
+
`created_at` or `createTime`; `at=` overrides it, and a response with no
|
|
242
|
+
creation time is priced now. `catalog=` costs against a catalog you loaded;
|
|
243
|
+
the default is the bundled one.
|
|
244
|
+
|
|
245
|
+
A batch service tier bills at batch rates. Pass `service_tier=` when the
|
|
246
|
+
response does not state its tier, such as an OpenAI batch output line. Any other tier besides standard,
|
|
247
|
+
such as `priority` or `flex`, returns `partial` with `service_tier` in
|
|
248
|
+
`missing_dimensions`, since the catalog carries no tier rates. Cost a streamed
|
|
249
|
+
call from its final response or final usage chunk. Fees outside token usage,
|
|
250
|
+
such as web search, are not included.
|
|
251
|
+
|
|
203
252
|
## Model registry
|
|
204
253
|
|
|
205
254
|
```python
|
|
@@ -462,10 +511,12 @@ from metergraph_core import (
|
|
|
462
511
|
PriceResult,
|
|
463
512
|
PriceRowKey,
|
|
464
513
|
PriceSource,
|
|
514
|
+
RESPONSE_FORMATS,
|
|
465
515
|
RefreshResult,
|
|
466
516
|
ResolvedPrice,
|
|
467
517
|
SourceRegistry,
|
|
468
518
|
apply_catalog_delta,
|
|
519
|
+
cost_from_response,
|
|
469
520
|
discovery_evidence,
|
|
470
521
|
extract,
|
|
471
522
|
load_catalog,
|
|
@@ -477,6 +528,7 @@ from metergraph_core import (
|
|
|
477
528
|
parse_catalog_delta,
|
|
478
529
|
plan_low_risk_refresh,
|
|
479
530
|
resolve_billing,
|
|
531
|
+
response_format,
|
|
480
532
|
structural_fingerprint,
|
|
481
533
|
validate_model_registry,
|
|
482
534
|
validate_source_registry,
|
|
@@ -9,6 +9,7 @@ src/metergraph_core/extractors.py
|
|
|
9
9
|
src/metergraph_core/loader.py
|
|
10
10
|
src/metergraph_core/models.py
|
|
11
11
|
src/metergraph_core/refresh.py
|
|
12
|
+
src/metergraph_core/responses.py
|
|
12
13
|
src/metergraph_core/retrieval.py
|
|
13
14
|
src/metergraph_core/sources.py
|
|
14
15
|
src/metergraph_core.egg-info/PKG-INFO
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{metergraph_core-0.6.4 → metergraph_core-0.7.1}/src/metergraph_core.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|