metergraph-core 0.6.4__tar.gz → 0.7.0__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.0}/PKG-INFO +48 -1
- {metergraph_core-0.6.4 → metergraph_core-0.7.0}/README.md +47 -0
- {metergraph_core-0.6.4 → metergraph_core-0.7.0}/pyproject.toml +1 -1
- {metergraph_core-0.6.4 → metergraph_core-0.7.0}/src/metergraph_core/__init__.py +2 -0
- metergraph_core-0.7.0/src/metergraph_core/responses.py +325 -0
- {metergraph_core-0.6.4 → metergraph_core-0.7.0/src/metergraph_core.egg-info}/PKG-INFO +48 -1
- {metergraph_core-0.6.4 → metergraph_core-0.7.0}/src/metergraph_core.egg-info/SOURCES.txt +1 -0
- {metergraph_core-0.6.4 → metergraph_core-0.7.0}/MANIFEST.in +0 -0
- {metergraph_core-0.6.4 → metergraph_core-0.7.0}/setup.cfg +0 -0
- {metergraph_core-0.6.4 → metergraph_core-0.7.0}/src/metergraph_core/billing.py +0 -0
- {metergraph_core-0.6.4 → metergraph_core-0.7.0}/src/metergraph_core/catalog.py +0 -0
- {metergraph_core-0.6.4 → metergraph_core-0.7.0}/src/metergraph_core/catalog_delta.py +0 -0
- {metergraph_core-0.6.4 → metergraph_core-0.7.0}/src/metergraph_core/data/models.yaml +0 -0
- {metergraph_core-0.6.4 → metergraph_core-0.7.0}/src/metergraph_core/data/prices.yaml +0 -0
- {metergraph_core-0.6.4 → metergraph_core-0.7.0}/src/metergraph_core/data/sources.yaml +0 -0
- {metergraph_core-0.6.4 → metergraph_core-0.7.0}/src/metergraph_core/extractors.py +0 -0
- {metergraph_core-0.6.4 → metergraph_core-0.7.0}/src/metergraph_core/loader.py +0 -0
- {metergraph_core-0.6.4 → metergraph_core-0.7.0}/src/metergraph_core/models.py +0 -0
- {metergraph_core-0.6.4 → metergraph_core-0.7.0}/src/metergraph_core/refresh.py +0 -0
- {metergraph_core-0.6.4 → metergraph_core-0.7.0}/src/metergraph_core/retrieval.py +0 -0
- {metergraph_core-0.6.4 → metergraph_core-0.7.0}/src/metergraph_core/sources.py +0 -0
- {metergraph_core-0.6.4 → metergraph_core-0.7.0}/src/metergraph_core.egg-info/dependency_links.txt +0 -0
- {metergraph_core-0.6.4 → metergraph_core-0.7.0}/src/metergraph_core.egg-info/requires.txt +0 -0
- {metergraph_core-0.6.4 → metergraph_core-0.7.0}/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.0
|
|
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,52 @@ 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 shape is detected from its fields. Only the
|
|
229
|
+
model, usage counts, service tier and creation time are read; prompt and
|
|
230
|
+
output content never are.
|
|
231
|
+
|
|
232
|
+
Cached tokens are billed once whichever convention the response uses. OpenAI
|
|
233
|
+
and Gemini count cached tokens inside the input total, so the total is
|
|
234
|
+
restated in the convention of the price row that bills it, including a
|
|
235
|
+
gateway row for another publisher's model. Gemini thinking tokens bill as
|
|
236
|
+
output.
|
|
237
|
+
|
|
238
|
+
The price in effect when the response was created applies, from `created`,
|
|
239
|
+
`created_at` or `createTime`; `at=` overrides it, and a response with no
|
|
240
|
+
creation time is priced now. `catalog=` costs against a catalog you loaded;
|
|
241
|
+
the default is the bundled one.
|
|
242
|
+
|
|
243
|
+
A batch service tier bills at batch rates. Any other tier besides standard,
|
|
244
|
+
such as `priority` or `flex`, returns `partial` with `service_tier` in
|
|
245
|
+
`missing_dimensions`, since the catalog carries no tier rates. Cost a streamed
|
|
246
|
+
call from its final response or final usage chunk. Fees outside token usage,
|
|
247
|
+
such as web search, are not included.
|
|
248
|
+
|
|
203
249
|
## Model registry
|
|
204
250
|
|
|
205
251
|
```python
|
|
@@ -466,6 +512,7 @@ from metergraph_core import (
|
|
|
466
512
|
ResolvedPrice,
|
|
467
513
|
SourceRegistry,
|
|
468
514
|
apply_catalog_delta,
|
|
515
|
+
cost_from_response,
|
|
469
516
|
discovery_evidence,
|
|
470
517
|
extract,
|
|
471
518
|
load_catalog,
|
|
@@ -170,6 +170,52 @@ 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 shape is detected from its fields. Only the
|
|
199
|
+
model, usage counts, service tier and creation time are read; prompt and
|
|
200
|
+
output content never are.
|
|
201
|
+
|
|
202
|
+
Cached tokens are billed once whichever convention the response uses. OpenAI
|
|
203
|
+
and Gemini count cached tokens inside the input total, so the total is
|
|
204
|
+
restated in the convention of the price row that bills it, including a
|
|
205
|
+
gateway row for another publisher's model. Gemini thinking tokens bill as
|
|
206
|
+
output.
|
|
207
|
+
|
|
208
|
+
The price in effect when the response was created applies, from `created`,
|
|
209
|
+
`created_at` or `createTime`; `at=` overrides it, and a response with no
|
|
210
|
+
creation time is priced now. `catalog=` costs against a catalog you loaded;
|
|
211
|
+
the default is the bundled one.
|
|
212
|
+
|
|
213
|
+
A batch service tier bills at batch rates. Any other tier besides standard,
|
|
214
|
+
such as `priority` or `flex`, returns `partial` with `service_tier` in
|
|
215
|
+
`missing_dimensions`, since the catalog carries no tier rates. Cost a streamed
|
|
216
|
+
call from its final response or final usage chunk. Fees outside token usage,
|
|
217
|
+
such as web search, are not included.
|
|
218
|
+
|
|
173
219
|
## Model registry
|
|
174
220
|
|
|
175
221
|
```python
|
|
@@ -436,6 +482,7 @@ from metergraph_core import (
|
|
|
436
482
|
ResolvedPrice,
|
|
437
483
|
SourceRegistry,
|
|
438
484
|
apply_catalog_delta,
|
|
485
|
+
cost_from_response,
|
|
439
486
|
discovery_evidence,
|
|
440
487
|
extract,
|
|
441
488
|
load_catalog,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "metergraph-core"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.7.0"
|
|
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 cost_from_response
|
|
62
63
|
from .extractors import (
|
|
63
64
|
ExtractedRate,
|
|
64
65
|
Extraction,
|
|
@@ -133,6 +134,7 @@ __all__ = [
|
|
|
133
134
|
"RetrievalPrice",
|
|
134
135
|
"count_fetch_units",
|
|
135
136
|
"count_search_units",
|
|
137
|
+
"cost_from_response",
|
|
136
138
|
"counts_cache_read_in_input",
|
|
137
139
|
"direct_channel_for_provider",
|
|
138
140
|
"normalize_provider",
|
|
@@ -0,0 +1,325 @@
|
|
|
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
|
|
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
|
+
def _reader(response: Mapping[str, Any]) -> Callable[[Mapping[str, Any]], _Facts] | None:
|
|
192
|
+
"""The reader for this response's shape, told apart by the fields each
|
|
193
|
+
API always returns."""
|
|
194
|
+
if _first(response, "usage_metadata", "usageMetadata") is not None:
|
|
195
|
+
return _gemini
|
|
196
|
+
usage = _object(response.get("usage"))
|
|
197
|
+
if "prompt_tokens" in usage:
|
|
198
|
+
return _openai_chat_completions
|
|
199
|
+
if "inputTokens" in usage:
|
|
200
|
+
return _bedrock_converse
|
|
201
|
+
if response.get("type") == "message" or "cache_read_input_tokens" in usage:
|
|
202
|
+
return _anthropic_messages
|
|
203
|
+
if response.get("object") == "response" or "input_tokens_details" in usage:
|
|
204
|
+
return _openai_responses
|
|
205
|
+
return None
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _as_mapping(response: Any) -> Mapping[str, Any]:
|
|
209
|
+
if isinstance(response, Mapping):
|
|
210
|
+
return response
|
|
211
|
+
dump = getattr(response, "model_dump", None)
|
|
212
|
+
if callable(dump):
|
|
213
|
+
# OpenAI, Anthropic and Google GenAI SDK objects are pydantic models.
|
|
214
|
+
return dump()
|
|
215
|
+
to_dict = getattr(response, "to_dict", None)
|
|
216
|
+
if callable(to_dict):
|
|
217
|
+
return to_dict()
|
|
218
|
+
raise TypeError(
|
|
219
|
+
"response must be a mapping or a provider SDK response object, "
|
|
220
|
+
f"not {type(response).__name__}"
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@lru_cache(maxsize=1)
|
|
225
|
+
def _bundled_catalog() -> LoadedCatalog:
|
|
226
|
+
return load_catalog(region="global")
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _native_input_tokens(
|
|
230
|
+
usage: Mapping[str, int | None], probe: Callable[..., CostResult]
|
|
231
|
+
) -> int | None:
|
|
232
|
+
"""An input total that includes cache reads and writes, restated in the
|
|
233
|
+
convention the priced row uses.
|
|
234
|
+
|
|
235
|
+
The row records whether its input total already counts cached tokens
|
|
236
|
+
(OpenAI's includes reads, Anthropic's includes neither). A response whose
|
|
237
|
+
total includes them, priced on a row that does not deduct them, would
|
|
238
|
+
otherwise bill every cached token twice. ``probe`` costs a usage on the
|
|
239
|
+
same model and channel.
|
|
240
|
+
"""
|
|
241
|
+
total = usage.get("input_tokens")
|
|
242
|
+
cache_read = usage.get("cache_read_tokens") or 0
|
|
243
|
+
cache_write = usage.get("cache_write_tokens") or 0
|
|
244
|
+
if total is None or cache_read < 0 or cache_write < 0 or not (cache_read or cache_write):
|
|
245
|
+
return total
|
|
246
|
+
# Cached tokens exceeding the total cannot have been included in it.
|
|
247
|
+
uncached = (
|
|
248
|
+
total - cache_read - cache_write
|
|
249
|
+
if cache_read + cache_write <= total
|
|
250
|
+
else total
|
|
251
|
+
)
|
|
252
|
+
# A zero-input probe reports which cached counts the row deducts from input.
|
|
253
|
+
reasons = probe(
|
|
254
|
+
input_tokens=0, output_tokens=0, cache_read_tokens=1, cache_write_tokens=1
|
|
255
|
+
).reasons
|
|
256
|
+
native = uncached
|
|
257
|
+
if "cache_read_exceeds_input" in reasons:
|
|
258
|
+
native += cache_read
|
|
259
|
+
if "cache_write_exceeds_input" in reasons:
|
|
260
|
+
native += cache_write
|
|
261
|
+
return native
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def cost_from_response(
|
|
265
|
+
provider: str,
|
|
266
|
+
response: Any,
|
|
267
|
+
*,
|
|
268
|
+
catalog: LoadedCatalog | None = None,
|
|
269
|
+
model: str | None = None,
|
|
270
|
+
at: Any = None,
|
|
271
|
+
channel: str | None = None,
|
|
272
|
+
region: str | None = None,
|
|
273
|
+
) -> CostResult:
|
|
274
|
+
"""The cost of one provider response, from the usage it reports.
|
|
275
|
+
|
|
276
|
+
``response`` is the object the provider SDK returned, or its JSON as a
|
|
277
|
+
mapping: OpenAI Chat Completions or Responses, Anthropic Messages, Google
|
|
278
|
+
Gemini ``generateContent``, or Bedrock Converse. ``provider`` names who
|
|
279
|
+
served the call and selects its direct pricing channel unless ``channel``
|
|
280
|
+
is given. ``model`` overrides the response's model id and is required for
|
|
281
|
+
Bedrock Converse, whose responses do not name one. ``at`` defaults to the
|
|
282
|
+
response's creation time when it reports one, else now. ``catalog``
|
|
283
|
+
defaults to the catalog bundled with this package.
|
|
284
|
+
|
|
285
|
+
A streamed call is costed from its final response or final usage chunk.
|
|
286
|
+
Fees outside token usage, such as web search, are not included. A service
|
|
287
|
+
tier other than standard or batch turns the result ``partial`` with
|
|
288
|
+
``service_tier`` missing, since the catalog does not carry tier rates.
|
|
289
|
+
"""
|
|
290
|
+
data = _as_mapping(response)
|
|
291
|
+
reader = _reader(data)
|
|
292
|
+
if reader is None:
|
|
293
|
+
raise ValueError("response carries no usage this function can read")
|
|
294
|
+
facts = reader(data)
|
|
295
|
+
when = at if at is not None else facts.at or datetime.now(timezone.utc)
|
|
296
|
+
model_id = _text(model) or facts.model
|
|
297
|
+
tier = (facts.service_tier or "").lower() or None
|
|
298
|
+
if model_id is None:
|
|
299
|
+
return CostResult(
|
|
300
|
+
None, None, None, "unpriced", ("missing_model",), missing_dimensions=("model",)
|
|
301
|
+
)
|
|
302
|
+
loaded = catalog if catalog is not None else _bundled_catalog()
|
|
303
|
+
batch = tier == _BATCH_TIER
|
|
304
|
+
|
|
305
|
+
def cost(**usage: Any) -> CostResult:
|
|
306
|
+
return loaded.calculate_cost(
|
|
307
|
+
model=model_id,
|
|
308
|
+
usage=ModelUsage(**usage, batch=batch),
|
|
309
|
+
at=when,
|
|
310
|
+
channel=channel,
|
|
311
|
+
provider=provider,
|
|
312
|
+
region=region,
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
usage = dict(facts.usage)
|
|
316
|
+
if facts.input_includes_cache:
|
|
317
|
+
usage["input_tokens"] = _native_input_tokens(usage, cost)
|
|
318
|
+
result = cost(**usage)
|
|
319
|
+
if tier is None or tier in _STANDARD_TIERS or batch or result.status == "unpriced":
|
|
320
|
+
return result
|
|
321
|
+
return replace(
|
|
322
|
+
result,
|
|
323
|
+
status="partial",
|
|
324
|
+
missing_dimensions=tuple(dict.fromkeys((*result.missing_dimensions, "service_tier"))),
|
|
325
|
+
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: metergraph-core
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.0
|
|
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,52 @@ 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 shape is detected from its fields. Only the
|
|
229
|
+
model, usage counts, service tier and creation time are read; prompt and
|
|
230
|
+
output content never are.
|
|
231
|
+
|
|
232
|
+
Cached tokens are billed once whichever convention the response uses. OpenAI
|
|
233
|
+
and Gemini count cached tokens inside the input total, so the total is
|
|
234
|
+
restated in the convention of the price row that bills it, including a
|
|
235
|
+
gateway row for another publisher's model. Gemini thinking tokens bill as
|
|
236
|
+
output.
|
|
237
|
+
|
|
238
|
+
The price in effect when the response was created applies, from `created`,
|
|
239
|
+
`created_at` or `createTime`; `at=` overrides it, and a response with no
|
|
240
|
+
creation time is priced now. `catalog=` costs against a catalog you loaded;
|
|
241
|
+
the default is the bundled one.
|
|
242
|
+
|
|
243
|
+
A batch service tier bills at batch rates. Any other tier besides standard,
|
|
244
|
+
such as `priority` or `flex`, returns `partial` with `service_tier` in
|
|
245
|
+
`missing_dimensions`, since the catalog carries no tier rates. Cost a streamed
|
|
246
|
+
call from its final response or final usage chunk. Fees outside token usage,
|
|
247
|
+
such as web search, are not included.
|
|
248
|
+
|
|
203
249
|
## Model registry
|
|
204
250
|
|
|
205
251
|
```python
|
|
@@ -466,6 +512,7 @@ from metergraph_core import (
|
|
|
466
512
|
ResolvedPrice,
|
|
467
513
|
SourceRegistry,
|
|
468
514
|
apply_catalog_delta,
|
|
515
|
+
cost_from_response,
|
|
469
516
|
discovery_evidence,
|
|
470
517
|
extract,
|
|
471
518
|
load_catalog,
|
|
@@ -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.0}/src/metergraph_core.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|