metergraph-core 0.2.27__tar.gz → 0.2.30__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: metergraph-core
3
- Version: 0.2.27
3
+ Version: 0.2.30
4
4
  Summary: Reusable MeterGraph catalog and deterministic billing engine
5
5
  License-Expression: Apache-2.0
6
6
  Requires-Python: >=3.10
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "metergraph-core"
3
- version = "0.2.27"
3
+ version = "0.2.30"
4
4
  description = "Reusable MeterGraph catalog and deterministic billing engine"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -6,11 +6,49 @@ from typing import Any, Mapping
6
6
 
7
7
  from .catalog import CostResult
8
8
 
9
- _OPENROUTER = "openrouter"
10
- _CHAT_COMPLETIONS = "chat.completions"
11
- _OPENROUTER_COST_SOURCE = "openrouter.usage.cost"
12
- _OPENROUTER_UPSTREAM_COST_SOURCE = (
13
- "openrouter.usage.cost_details.upstream_inference_cost"
9
+ @dataclass(frozen=True, slots=True)
10
+ class _QualifiedSource:
11
+ """A gateway whose reported amount this module will bill from.
12
+
13
+ Three things have to line up before an amount is believed, because the field
14
+ carrying one is open: a customer's own client can write to it.
15
+
16
+ * The **gateway**, so an amount is attributed to a system we have checked.
17
+ * The **endpoint**, because a gateway prices its endpoints differently and a
18
+ figure is only interpretable alongside the one that produced it.
19
+ * The **source**, naming the field the amount was read from, so a row cannot
20
+ qualify by claiming a gateway name alone.
21
+
22
+ A gateway absent from this table is not distrusted so much as unverified:
23
+ its amount is still recorded, and the catalog still prices the call, so the
24
+ two can be compared before it is added here.
25
+ """
26
+
27
+ gateway: str
28
+ endpoints: frozenset[str]
29
+ cost_source: str
30
+ upstream_cost_source: str | None = None
31
+
32
+
33
+ # Each entry reconciles against the providers' own published rates, including
34
+ # the per-request and per-query charges a token rate cannot express. Portkey was
35
+ # checked across a 46,241-call export: its figure matches published rates for
36
+ # Anthropic, xAI and Perplexity to the cent, and for OpenAI once the per-search
37
+ # charge is counted -- 20,590 of 20,590 rows, exactly.
38
+ _QUALIFIED_SOURCES = (
39
+ _QualifiedSource(
40
+ gateway="openrouter",
41
+ endpoints=frozenset({"chat.completions"}),
42
+ cost_source="openrouter.usage.cost",
43
+ upstream_cost_source=(
44
+ "openrouter.usage.cost_details.upstream_inference_cost"
45
+ ),
46
+ ),
47
+ _QualifiedSource(
48
+ gateway="portkey",
49
+ endpoints=frozenset({"chat.completions", "responses"}),
50
+ cost_source="portkey.cost",
51
+ ),
14
52
  )
15
53
 
16
54
 
@@ -82,11 +120,12 @@ def normalize_gateway_evidence(row: Mapping[str, Any]) -> GatewayBillingEvidence
82
120
  )
83
121
 
84
122
 
85
- def _is_openrouter_chat_completions(evidence: GatewayBillingEvidence) -> bool:
86
- return (
87
- evidence.gateway == _OPENROUTER
88
- and evidence.endpoint == _CHAT_COMPLETIONS
89
- )
123
+ def _qualified_source(evidence: GatewayBillingEvidence) -> _QualifiedSource | None:
124
+ """The entry this row's gateway and endpoint match, if any."""
125
+ for source in _QUALIFIED_SOURCES:
126
+ if evidence.gateway == source.gateway and evidence.endpoint in source.endpoints:
127
+ return source
128
+ return None
90
129
 
91
130
 
92
131
  def resolve_billing(
@@ -95,18 +134,18 @@ def resolve_billing(
95
134
  ) -> BillingDecision:
96
135
  """Select effective cost without combining independent reported amounts."""
97
136
 
98
- qualified_openrouter = _is_openrouter_chat_completions(evidence)
137
+ qualified = _qualified_source(evidence)
99
138
  reported_cost = (
100
139
  evidence.reported_cost_usd
101
- if qualified_openrouter
102
- and evidence.reported_cost_source == _OPENROUTER_COST_SOURCE
140
+ if qualified is not None
141
+ and evidence.reported_cost_source == qualified.cost_source
103
142
  else None
104
143
  )
105
144
  upstream_cost = (
106
145
  evidence.reported_upstream_cost_usd
107
- if qualified_openrouter
108
- and evidence.reported_upstream_cost_source
109
- == _OPENROUTER_UPSTREAM_COST_SOURCE
146
+ if qualified is not None
147
+ and qualified.upstream_cost_source is not None
148
+ and evidence.reported_upstream_cost_source == qualified.upstream_cost_source
110
149
  else None
111
150
  )
112
151
 
@@ -196,10 +196,76 @@ def _tokens(value: Any) -> int | None:
196
196
  return result if result >= 0 else None
197
197
 
198
198
 
199
+ def _off_peak_multiplier(rules: Mapping[str, Any], at: datetime) -> Decimal:
200
+ """The discount a provider applies outside its published peak hours.
201
+
202
+ A row states the peak rate, which is the provider's list price, and the
203
+ window it applies in. A call outside that window is scaled by
204
+ ``multiplier``; a row with no window is time-independent.
205
+ """
206
+ discount = rules.get("off_peak_discount") or {}
207
+ windows = discount.get("peak_hours_utc") or ()
208
+ multiplier = _decimal(discount.get("multiplier"))
209
+ if not windows or multiplier is None:
210
+ return Decimal("1")
211
+ moment = at.astimezone(timezone.utc)
212
+ if discount.get("peak_weekdays_only") and moment.weekday() >= 5:
213
+ return multiplier
214
+ for window in windows:
215
+ try:
216
+ start, end = window
217
+ except (TypeError, ValueError):
218
+ continue
219
+ if _tokens(start) is None or _tokens(end) is None:
220
+ continue
221
+ if int(start) <= moment.hour < int(end):
222
+ return Decimal("1")
223
+ return multiplier
224
+
225
+ # A count that was never given and a count that was given but cannot be read are
226
+ # different facts with opposite billing consequences for cached tokens: absent
227
+ # means there was no cache to discount, unusable means we do not know. `_tokens`
228
+ # answers both with None, which is right for a count a caller may legitimately
229
+ # omit and wrong for one it supplied.
230
+ _UNUSABLE = object()
231
+
232
+
233
+ def _token_count(value: Any) -> Any:
234
+ """A usable token count, ``None`` when none was given, or ``_UNUSABLE`` when
235
+ one was given that cannot be read as a count."""
236
+ if value is None:
237
+ return None
238
+ if isinstance(value, bool):
239
+ return _UNUSABLE
240
+ try:
241
+ result = int(value)
242
+ except (ValueError, TypeError, OverflowError):
243
+ return _UNUSABLE
244
+ return result if result >= 0 else _UNUSABLE
245
+
246
+
247
+ def _cached_count(value: Any, name: str, reasons: list[str]) -> int | None:
248
+ """The count to bill for one cached-token field, recording why when the
249
+ value supplied cannot be used.
250
+
251
+ Returns ``None`` only when nothing was given, so a caller that omits a field
252
+ keeps the behaviour it had. An unusable value bills as nothing cached, which
253
+ is the same arithmetic as before, but it now says so: the reason turns the
254
+ result ``partial``, and a caller that trusts only a fully priced figure stops
255
+ reading a silent over-bill as a price.
256
+ """
257
+ count = _token_count(value)
258
+ if count is _UNUSABLE:
259
+ reasons.append(f"unusable_{name}")
260
+ return 0
261
+ return count
262
+
263
+
199
264
  def _price_tokens(
200
265
  price: Price,
201
266
  rules: Mapping[str, Any],
202
267
  *,
268
+ at: datetime,
203
269
  input_tokens: Any,
204
270
  output_tokens: Any,
205
271
  cache_read_tokens: Any,
@@ -214,11 +280,19 @@ def _price_tokens(
214
280
  reasons: list[str] = []
215
281
  input_count = _tokens(input_tokens)
216
282
  output_count = _tokens(output_tokens)
217
- cache_read_count = _tokens(cache_read_tokens) or 0
218
- cache_write_5m_count = _tokens(cache_write_5m_tokens)
283
+ cache_read_count = _cached_count(cache_read_tokens, "cache_read_tokens", reasons) or 0
284
+ cache_write_5m_count = _cached_count(
285
+ cache_write_5m_tokens, "cache_write_5m_tokens", reasons
286
+ )
219
287
  if cache_write_5m_count is None:
220
- cache_write_5m_count = _tokens(cache_write_tokens) or 0
221
- cache_write_1h_count = _tokens(cache_write_1h_tokens) or 0
288
+ # Only an absent split falls back to the aggregate. A stated split we
289
+ # cannot read is not an invitation to substitute a different field.
290
+ cache_write_5m_count = (
291
+ _cached_count(cache_write_tokens, "cache_write_tokens", reasons) or 0
292
+ )
293
+ cache_write_1h_count = (
294
+ _cached_count(cache_write_1h_tokens, "cache_write_1h_tokens", reasons) or 0
295
+ )
222
296
  cache_write_count = cache_write_5m_count + cache_write_1h_count
223
297
  if input_count is None:
224
298
  reasons.append("missing_input_tokens")
@@ -261,6 +335,11 @@ def _price_tokens(
261
335
  output_multiplier = _decimal(
262
336
  long_context.get("output_multiplier")
263
337
  ) or Decimal("1")
338
+ # Applies to every rate on the row, cache included, and composes with a
339
+ # long-context tier rather than replacing it.
340
+ off_peak = _off_peak_multiplier(rules, at)
341
+ input_multiplier *= off_peak
342
+ output_multiplier *= off_peak
264
343
 
265
344
  cost = Decimal("0")
266
345
  if input_rate is None:
@@ -439,6 +518,7 @@ class CatalogSnapshot:
439
518
  cost, reasons = _price_tokens(
440
519
  price,
441
520
  {**price.rules, **alias.rules},
521
+ at=_coerce_datetime(at),
442
522
  input_tokens=input_tokens,
443
523
  output_tokens=output_tokens,
444
524
  cache_read_tokens=cache_read_tokens,
@@ -486,6 +566,7 @@ class CatalogSnapshot:
486
566
  cost, reasons = _price_tokens(
487
567
  resolved.price,
488
568
  resolved.rules,
569
+ at=when,
489
570
  input_tokens=input_tokens,
490
571
  output_tokens=output_tokens,
491
572
  cache_read_tokens=cache_read_tokens,
@@ -1235,12 +1235,28 @@ models:
1235
1235
  - channel: deepseek-api
1236
1236
  region: global
1237
1237
  effective_from: "2026-04-24"
1238
+ effective_to: "2026-09-10T04:00:00+00:00"
1238
1239
  input_per_mtok: 0.14
1239
1240
  output_per_mtok: 0.28
1240
1241
  cache_read_per_mtok: 0.0028
1241
1242
  rules:
1242
1243
  input_includes_cache_read: true
1243
1244
  source_url: https://api-docs.deepseek.com/quick_start/pricing/
1245
+ # This name is retired but still accepted, and billed at the Flash rate.
1246
+ - channel: deepseek-api
1247
+ region: global
1248
+ effective_from: "2026-09-10T04:00:00+00:00"
1249
+ input_per_mtok: 0.30
1250
+ output_per_mtok: 1.20
1251
+ cache_read_per_mtok: 0.006
1252
+ rules:
1253
+ input_includes_cache_read: true
1254
+ # Peak rate; halved outside the window. Footnote (3) on the source.
1255
+ off_peak_discount:
1256
+ multiplier: 0.5
1257
+ peak_hours_utc: [[1, 4], [6, 10]]
1258
+ peak_weekdays_only: true
1259
+ source_url: https://api-docs.deepseek.com/quick_start/pricing/
1244
1260
  - channel: vercel-ai-gateway
1245
1261
  region: global
1246
1262
  effective_from: "2026-04-24"
@@ -1274,12 +1290,27 @@ models:
1274
1290
  - channel: deepseek-api
1275
1291
  region: global
1276
1292
  effective_from: "2026-04-24"
1293
+ effective_to: "2026-09-10T04:00:00+00:00"
1277
1294
  input_per_mtok: 0.435
1278
1295
  output_per_mtok: 0.87
1279
1296
  cache_read_per_mtok: 0.003625
1280
1297
  rules:
1281
1298
  input_includes_cache_read: true
1282
1299
  source_url: https://api-docs.deepseek.com/quick_start/pricing/
1300
+ - channel: deepseek-api
1301
+ region: global
1302
+ effective_from: "2026-09-10T04:00:00+00:00"
1303
+ input_per_mtok: 1.32
1304
+ output_per_mtok: 3.96
1305
+ cache_read_per_mtok: 0.044
1306
+ rules:
1307
+ input_includes_cache_read: true
1308
+ # Peak rate; halved outside the window. Footnote (3) on the source.
1309
+ off_peak_discount:
1310
+ multiplier: 0.5
1311
+ peak_hours_utc: [[1, 4], [6, 10]]
1312
+ peak_weekdays_only: true
1313
+ source_url: https://api-docs.deepseek.com/quick_start/pricing/
1283
1314
  - channel: vercel-ai-gateway
1284
1315
  region: global
1285
1316
  effective_from: "2026-04-24"
@@ -1581,11 +1612,28 @@ models:
1581
1612
  - canonical_id: deepseek/deepseek-v4.1-flash
1582
1613
  publisher: deepseek
1583
1614
  aliases:
1615
+ - {provider: deepseek, alias: deepseek-flash, channel: deepseek-api}
1616
+ - {provider: litellm, alias: deepseek-flash, channel: deepseek-api}
1617
+ - {provider: unknown, alias: deepseek-flash, channel: deepseek-api}
1584
1618
  - provider: deepseek
1585
1619
  alias: deepseek/deepseek-v4.1-flash
1586
1620
  channel: vercel-ai-gateway
1587
1621
  rules: {input_includes_cache_read: true}
1588
1622
  prices:
1623
+ - channel: deepseek-api
1624
+ region: global
1625
+ effective_from: "2026-09-10T04:00:00+00:00"
1626
+ input_per_mtok: 0.30
1627
+ output_per_mtok: 1.20
1628
+ cache_read_per_mtok: 0.006
1629
+ rules:
1630
+ input_includes_cache_read: true
1631
+ # Peak rate; halved outside the window. Footnote (3) on the source.
1632
+ off_peak_discount:
1633
+ multiplier: 0.5
1634
+ peak_hours_utc: [[1, 4], [6, 10]]
1635
+ peak_weekdays_only: true
1636
+ source_url: https://api-docs.deepseek.com/quick_start/pricing/
1589
1637
  - channel: vercel-ai-gateway
1590
1638
  region: global
1591
1639
  effective_from: "2026-09-08"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: metergraph-core
3
- Version: 0.2.27
3
+ Version: 0.2.30
4
4
  Summary: Reusable MeterGraph catalog and deterministic billing engine
5
5
  License-Expression: Apache-2.0
6
6
  Requires-Python: >=3.10