metergraph-core 0.6.1__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.1/src/metergraph_core.egg-info → metergraph_core-0.7.0}/PKG-INFO +48 -1
  2. {metergraph_core-0.6.1 → metergraph_core-0.7.0}/README.md +47 -0
  3. {metergraph_core-0.6.1 → metergraph_core-0.7.0}/pyproject.toml +1 -1
  4. {metergraph_core-0.6.1 → metergraph_core-0.7.0}/src/metergraph_core/__init__.py +2 -0
  5. {metergraph_core-0.6.1 → metergraph_core-0.7.0}/src/metergraph_core/catalog.py +3 -1
  6. {metergraph_core-0.6.1 → metergraph_core-0.7.0}/src/metergraph_core/data/prices.yaml +71 -8
  7. {metergraph_core-0.6.1 → metergraph_core-0.7.0}/src/metergraph_core/extractors.py +2 -1
  8. metergraph_core-0.7.0/src/metergraph_core/responses.py +325 -0
  9. {metergraph_core-0.6.1 → metergraph_core-0.7.0}/src/metergraph_core/retrieval.py +2 -2
  10. {metergraph_core-0.6.1 → metergraph_core-0.7.0/src/metergraph_core.egg-info}/PKG-INFO +48 -1
  11. {metergraph_core-0.6.1 → metergraph_core-0.7.0}/src/metergraph_core.egg-info/SOURCES.txt +1 -0
  12. {metergraph_core-0.6.1 → metergraph_core-0.7.0}/MANIFEST.in +0 -0
  13. {metergraph_core-0.6.1 → metergraph_core-0.7.0}/setup.cfg +0 -0
  14. {metergraph_core-0.6.1 → metergraph_core-0.7.0}/src/metergraph_core/billing.py +0 -0
  15. {metergraph_core-0.6.1 → metergraph_core-0.7.0}/src/metergraph_core/catalog_delta.py +0 -0
  16. {metergraph_core-0.6.1 → metergraph_core-0.7.0}/src/metergraph_core/data/models.yaml +0 -0
  17. {metergraph_core-0.6.1 → metergraph_core-0.7.0}/src/metergraph_core/data/sources.yaml +0 -0
  18. {metergraph_core-0.6.1 → metergraph_core-0.7.0}/src/metergraph_core/loader.py +0 -0
  19. {metergraph_core-0.6.1 → metergraph_core-0.7.0}/src/metergraph_core/models.py +0 -0
  20. {metergraph_core-0.6.1 → metergraph_core-0.7.0}/src/metergraph_core/refresh.py +0 -0
  21. {metergraph_core-0.6.1 → metergraph_core-0.7.0}/src/metergraph_core/sources.py +0 -0
  22. {metergraph_core-0.6.1 → metergraph_core-0.7.0}/src/metergraph_core.egg-info/dependency_links.txt +0 -0
  23. {metergraph_core-0.6.1 → metergraph_core-0.7.0}/src/metergraph_core.egg-info/requires.txt +0 -0
  24. {metergraph_core-0.6.1 → 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.1
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.1"
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",
@@ -522,7 +522,9 @@ def _price_tokens(
522
522
  prompt_count += cache_read_count
523
523
  if not rules.get("input_includes_cache_write"):
524
524
  prompt_count += cache_write_count
525
- if threshold is not None and prompt_count > threshold:
525
+ # Some providers publish no long-context batch rate, so batch stays flat.
526
+ applies = not batch or long_context.get("applies_to_batch", True)
527
+ if applies and threshold is not None and prompt_count > threshold:
526
528
  input_multiplier = _decimal(long_context.get("input_multiplier")) or Decimal("1")
527
529
  output_multiplier = _decimal(
528
530
  long_context.get("output_multiplier")
@@ -6,9 +6,9 @@
6
6
  #
7
7
  # Community updates welcome: add an alias or price entry with its provider
8
8
  # source_url and open a PR. CI validates structure and date overlaps.
9
- version: "2026-10-21"
9
+ version: "2026-10-23"
10
10
  currency: USD
11
- pricing_verified_at: "2026-10-21"
11
+ pricing_verified_at: "2026-10-23"
12
12
  models:
13
13
  - canonical_id: openai/gpt-6-sol
14
14
  publisher: openai
@@ -1824,9 +1824,12 @@ models:
1824
1824
  - {provider: google, alias: gemini-2.5-flash-image, channel: google-api}
1825
1825
  - {provider: google, alias: models/gemini-2.5-flash-image, channel: google-api}
1826
1826
  prices:
1827
+ # Google shut this model down on 2026-10-02 and now states its output
1828
+ # price per image, which no token rate can express.
1827
1829
  - channel: google-api
1828
1830
  region: global
1829
1831
  effective_from: "2025-10-02"
1832
+ effective_to: "2026-10-02"
1830
1833
  input_per_mtok: 0.30
1831
1834
  output_per_mtok: 30.00
1832
1835
  source_url: https://ai.google.dev/gemini-api/docs/pricing
@@ -2497,6 +2500,7 @@ models:
2497
2500
  - channel: perplexity-api
2498
2501
  region: global
2499
2502
  effective_from: "2025-04-18"
2503
+ effective_to: "2026-10-23"
2500
2504
  input_per_mtok: 1.00
2501
2505
  output_per_mtok: 1.00
2502
2506
  rules:
@@ -2729,6 +2733,9 @@ models:
2729
2733
  cache_read_per_mtok: 0.075
2730
2734
  source_url: https://ai-gateway.vercel.sh/v1/models
2731
2735
 
2736
+ # The pricing page stopped listing this name when Google deprecated it on
2737
+ # 2026-10-08 and began routing it to gemini-3.8-flash, whose rates these
2738
+ # rows already match.
2732
2739
  - channel: google-api
2733
2740
  region: global
2734
2741
  effective_from: "2026-08-13"
@@ -3654,6 +3661,15 @@ models:
3654
3661
  rules:
3655
3662
  long_context: {threshold: 200000, input_multiplier: 2.0, output_multiplier: 2.0}
3656
3663
  source_url: https://docs.x.ai/developers/release-notes
3664
+ - channel: xai-api
3665
+ region: us
3666
+ effective_from: "2026-08-12"
3667
+ input_per_mtok: 2.2
3668
+ output_per_mtok: 6.6
3669
+ cache_read_per_mtok: 0.55
3670
+ rules:
3671
+ long_context: {threshold: 200000, input_multiplier: 2.0, output_multiplier: 2.0}
3672
+ source_url: https://docs.x.ai/developers/advanced-api-usage/regions
3657
3673
  - canonical_id: xai/grok-4.7
3658
3674
  publisher: xai
3659
3675
  aliases:
@@ -4015,13 +4031,30 @@ models:
4015
4031
  - {provider: google, alias: gemini-omni-flash-preview, channel: google-api}
4016
4032
  - {provider: google, alias: models/gemini-omni-flash-preview, channel: google-api}
4017
4033
  prices:
4034
+ # Google shuts this preview endpoint down on 2026-10-22; its traffic moves
4035
+ # to google/gemini-omni-1.1-flash.
4018
4036
  - channel: google-api
4019
4037
  region: global
4020
4038
  effective_from: "2026-05-19"
4039
+ effective_to: "2026-10-22"
4021
4040
  input_per_mtok: 1.50
4022
4041
  output_per_mtok: 9.00
4023
4042
  source_url: https://ai.google.dev/gemini-api/docs/pricing
4024
4043
 
4044
+ - canonical_id: google/gemini-omni-1.1-flash
4045
+ publisher: google
4046
+ aliases:
4047
+ - {provider: google, alias: gemini-omni-1.1-flash, channel: google-api}
4048
+ - {provider: google, alias: models/gemini-omni-1.1-flash, channel: google-api}
4049
+ prices:
4050
+ # Uses the video output rate: video is this model's only output modality.
4051
+ - channel: google-api
4052
+ region: global
4053
+ effective_from: "2026-08-27"
4054
+ input_per_mtok: 1.50
4055
+ output_per_mtok: 17.50
4056
+ source_url: https://ai.google.dev/gemini-api/docs/pricing
4057
+
4025
4058
  # Retrieval prices: USD per 1,000 counted operations (completed searches, tool
4026
4059
  # calls, executed grounding queries), billed on the provider's direct channel.
4027
4060
  # These sit alongside the model-token catalog above but are priced per unit, not
@@ -4618,9 +4651,12 @@ models:
4618
4651
  - {provider: litellm, alias: claude-opus-5-fast, channel: anthropic-api}
4619
4652
  - {provider: unknown, alias: claude-opus-5-fast, channel: anthropic-api}
4620
4653
  prices:
4654
+ # Anthropic's own pricing and model-lifecycle pages name no
4655
+ # claude-opus-5-fast, so only the gateway offering continues.
4621
4656
  - channel: anthropic-api
4622
4657
  region: global
4623
4658
  effective_from: "2026-07-24"
4659
+ effective_to: "2026-10-22"
4624
4660
  input_per_mtok: 10
4625
4661
  output_per_mtok: 50
4626
4662
  source_url: https://ai-gateway.vercel.sh/v1/models
@@ -4921,7 +4957,9 @@ models:
4921
4957
  output_per_mtok: 180
4922
4958
  batch_input_per_mtok: 15
4923
4959
  batch_output_per_mtok: 90
4924
- source_url: https://ai-gateway.vercel.sh/v1/models
4960
+ rules:
4961
+ long_context: {threshold: 272000, input_multiplier: 2, output_multiplier: 1.5}
4962
+ source_url: https://developers.openai.com/api/docs/models/gpt-5.4-pro
4925
4963
  - channel: vercel-ai-gateway
4926
4964
  region: global
4927
4965
  effective_from: "2026-10-02"
@@ -4951,7 +4989,9 @@ models:
4951
4989
  cache_read_per_mtok: 0.5
4952
4990
  batch_input_per_mtok: 2.5
4953
4991
  batch_output_per_mtok: 15
4954
- source_url: https://ai-gateway.vercel.sh/v1/models
4992
+ rules:
4993
+ long_context: {threshold: 272000, input_multiplier: 2, output_multiplier: 1.5}
4994
+ source_url: https://developers.openai.com/api/docs/models/gpt-5.5
4955
4995
  - channel: vercel-ai-gateway
4956
4996
  region: global
4957
4997
  effective_from: "2026-04-24"
@@ -4983,6 +5023,7 @@ models:
4983
5023
  - {provider: unknown, alias: gpt-5.5-pro, channel: openai-api}
4984
5024
  - {provider: unknown, alias: gpt-5.5-pro-2026-04-23, channel: openai-api}
4985
5025
  prices:
5026
+ # OpenAI lists no long-context batch rate, so batch stays flat.
4986
5027
  - channel: openai-api
4987
5028
  region: global
4988
5029
  effective_from: "2026-04-24"
@@ -4990,7 +5031,9 @@ models:
4990
5031
  output_per_mtok: 180
4991
5032
  batch_input_per_mtok: 15
4992
5033
  batch_output_per_mtok: 90
4993
- source_url: https://ai-gateway.vercel.sh/v1/models
5034
+ rules:
5035
+ long_context: {threshold: 272000, input_multiplier: 2, output_multiplier: 1.5, applies_to_batch: false}
5036
+ source_url: https://developers.openai.com/api/docs/pricing
4994
5037
  - channel: vercel-ai-gateway
4995
5038
  region: global
4996
5039
  effective_from: "2026-04-24"
@@ -8026,22 +8069,40 @@ models:
8026
8069
  - channel: vercel-ai-gateway
8027
8070
  region: global
8028
8071
  effective_from: "2026-10-20"
8072
+ effective_to: "2026-10-21"
8029
8073
  input_per_mtok: 0.14
8030
8074
  output_per_mtok: 0.28
8031
8075
  cache_read_per_mtok: 0.0028
8032
8076
  rules:
8033
8077
  varies_by_provider: true
8034
8078
  source_url: https://ai-gateway.vercel.sh/v1/models
8079
+ - channel: vercel-ai-gateway
8080
+ region: global
8081
+ effective_from: "2026-10-21"
8082
+ input_per_mtok: 0.119
8083
+ output_per_mtok: 0.238
8084
+ cache_read_per_mtok: 0.0025
8085
+ source_url: https://ai-gateway.vercel.sh/v1/models
8035
8086
 
8036
8087
  - channel: openrouter-api
8037
8088
  region: global
8038
8089
  effective_from: "2026-10-20"
8090
+ effective_to: "2026-10-21"
8039
8091
  input_per_mtok: 0.14
8040
8092
  output_per_mtok: 0.28
8041
8093
  cache_read_per_mtok: 0.0028
8042
8094
  rules:
8043
8095
  varies_by_provider: true
8044
8096
  source_url: https://openrouter.ai/api/v1/models
8097
+ - channel: openrouter-api
8098
+ region: global
8099
+ effective_from: "2026-10-21"
8100
+ input_per_mtok: 0.119
8101
+ output_per_mtok: 0.238
8102
+ cache_read_per_mtok: 0.00255
8103
+ rules:
8104
+ varies_by_provider: true
8105
+ source_url: https://openrouter.ai/api/v1/models
8045
8106
  - canonical_id: xiaomi/mimo-v2.5-pro
8046
8107
  publisher: xiaomi
8047
8108
  aliases:
@@ -11014,15 +11075,15 @@ retrieval:
11014
11075
  per_1k_usd: 14.00
11015
11076
  effective_from: "2026-01-05"
11016
11077
  source_url: https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing
11017
- # Sonar charges a request fee on top of tokens, by search context size;
11018
- # low is the default. The sonar token row keeps `uncaptured_fees` until
11019
- # callers price requests alongside tokens.
11078
+ # Historical Sonar request fees end before the new API-specific rates.
11079
+ # The current Sonar fee is unverified, so its token row stays partial.
11020
11080
  - channel: perplexity-api
11021
11081
  operation: search_request_low
11022
11082
  region: global
11023
11083
  unit: request
11024
11084
  per_1k_usd: 5.00
11025
11085
  effective_from: "2025-04-18"
11086
+ effective_to: "2026-10-23"
11026
11087
  source_url: https://docs.perplexity.ai/docs/getting-started/pricing
11027
11088
  - channel: perplexity-api
11028
11089
  operation: search_request_medium
@@ -11030,6 +11091,7 @@ retrieval:
11030
11091
  unit: request
11031
11092
  per_1k_usd: 8.00
11032
11093
  effective_from: "2025-04-18"
11094
+ effective_to: "2026-10-23"
11033
11095
  source_url: https://docs.perplexity.ai/docs/getting-started/pricing
11034
11096
  - channel: perplexity-api
11035
11097
  operation: search_request_high
@@ -11037,6 +11099,7 @@ retrieval:
11037
11099
  unit: request
11038
11100
  per_1k_usd: 12.00
11039
11101
  effective_from: "2025-04-18"
11102
+ effective_to: "2026-10-23"
11040
11103
  source_url: https://docs.perplexity.ai/docs/getting-started/pricing
11041
11104
 
11042
11105
  # Gateways whose reported amount billing declines for one publisher's models
@@ -247,7 +247,8 @@ def parse_deepseek(html_text: str, *, duplicates: list[str] | None = None) -> Ta
247
247
  section = ""
248
248
  for rows_ in page_tables(html_text):
249
249
  for cells in rows_:
250
- if cells and cells[0].upper().startswith("MODEL") and len(cells) > 1:
250
+ # MODEL VERSION is data, not a header.
251
+ if cells and cells[0].strip().upper() == "MODEL" and len(cells) > 1:
251
252
  columns = [deepseek_name(c) for c in cells[1:]]
252
253
  for column in columns:
253
254
  table[column] = {}
@@ -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
+ )
@@ -61,8 +61,8 @@ SEARCH_OPERATION_BY_CHANNEL: Mapping[str, str] = MappingProxyType({
61
61
  "anthropic-api": "web_search",
62
62
  "google-api": "google_search_grounding",
63
63
  "google-vertex-ai": "google_search_grounding",
64
- # Perplexity bills a request fee by search context size, which no
65
- # capture path records; the low tier is the floor every request pays.
64
+ # Historical Sonar request fees use this operation; no current fee is
65
+ # assigned until its API and billing unit can be verified.
66
66
  "perplexity-api": "search_request_low",
67
67
  })
68
68
  FETCH_OPERATION_BY_CHANNEL: Mapping[str, str] = MappingProxyType({
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: metergraph-core
3
- Version: 0.6.1
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