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