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.
Files changed (24) hide show
  1. {metergraph_core-0.6.4/src/metergraph_core.egg-info → metergraph_core-0.7.0}/PKG-INFO +48 -1
  2. {metergraph_core-0.6.4 → metergraph_core-0.7.0}/README.md +47 -0
  3. {metergraph_core-0.6.4 → metergraph_core-0.7.0}/pyproject.toml +1 -1
  4. {metergraph_core-0.6.4 → metergraph_core-0.7.0}/src/metergraph_core/__init__.py +2 -0
  5. metergraph_core-0.7.0/src/metergraph_core/responses.py +325 -0
  6. {metergraph_core-0.6.4 → metergraph_core-0.7.0/src/metergraph_core.egg-info}/PKG-INFO +48 -1
  7. {metergraph_core-0.6.4 → metergraph_core-0.7.0}/src/metergraph_core.egg-info/SOURCES.txt +1 -0
  8. {metergraph_core-0.6.4 → metergraph_core-0.7.0}/MANIFEST.in +0 -0
  9. {metergraph_core-0.6.4 → metergraph_core-0.7.0}/setup.cfg +0 -0
  10. {metergraph_core-0.6.4 → metergraph_core-0.7.0}/src/metergraph_core/billing.py +0 -0
  11. {metergraph_core-0.6.4 → metergraph_core-0.7.0}/src/metergraph_core/catalog.py +0 -0
  12. {metergraph_core-0.6.4 → metergraph_core-0.7.0}/src/metergraph_core/catalog_delta.py +0 -0
  13. {metergraph_core-0.6.4 → metergraph_core-0.7.0}/src/metergraph_core/data/models.yaml +0 -0
  14. {metergraph_core-0.6.4 → metergraph_core-0.7.0}/src/metergraph_core/data/prices.yaml +0 -0
  15. {metergraph_core-0.6.4 → metergraph_core-0.7.0}/src/metergraph_core/data/sources.yaml +0 -0
  16. {metergraph_core-0.6.4 → metergraph_core-0.7.0}/src/metergraph_core/extractors.py +0 -0
  17. {metergraph_core-0.6.4 → metergraph_core-0.7.0}/src/metergraph_core/loader.py +0 -0
  18. {metergraph_core-0.6.4 → metergraph_core-0.7.0}/src/metergraph_core/models.py +0 -0
  19. {metergraph_core-0.6.4 → metergraph_core-0.7.0}/src/metergraph_core/refresh.py +0 -0
  20. {metergraph_core-0.6.4 → metergraph_core-0.7.0}/src/metergraph_core/retrieval.py +0 -0
  21. {metergraph_core-0.6.4 → metergraph_core-0.7.0}/src/metergraph_core/sources.py +0 -0
  22. {metergraph_core-0.6.4 → metergraph_core-0.7.0}/src/metergraph_core.egg-info/dependency_links.txt +0 -0
  23. {metergraph_core-0.6.4 → metergraph_core-0.7.0}/src/metergraph_core.egg-info/requires.txt +0 -0
  24. {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.6.4
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.6.4"
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.6.4
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