metergraph-core 0.8.0__tar.gz → 0.8.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {metergraph_core-0.8.0/src/metergraph_core.egg-info → metergraph_core-0.8.2}/PKG-INFO +41 -1
- {metergraph_core-0.8.0 → metergraph_core-0.8.2}/README.md +40 -0
- {metergraph_core-0.8.0 → metergraph_core-0.8.2}/pyproject.toml +1 -1
- {metergraph_core-0.8.0 → metergraph_core-0.8.2}/src/metergraph_core/__init__.py +2 -0
- {metergraph_core-0.8.0 → metergraph_core-0.8.2}/src/metergraph_core/catalog.py +190 -0
- {metergraph_core-0.8.0 → metergraph_core-0.8.2}/src/metergraph_core/catalog_delta.py +157 -17
- {metergraph_core-0.8.0 → metergraph_core-0.8.2}/src/metergraph_core/data/prices.yaml +15 -4
- {metergraph_core-0.8.0 → metergraph_core-0.8.2}/src/metergraph_core/loader.py +12 -1
- {metergraph_core-0.8.0 → metergraph_core-0.8.2}/src/metergraph_core/refresh.py +56 -14
- {metergraph_core-0.8.0 → metergraph_core-0.8.2/src/metergraph_core.egg-info}/PKG-INFO +41 -1
- {metergraph_core-0.8.0 → metergraph_core-0.8.2}/MANIFEST.in +0 -0
- {metergraph_core-0.8.0 → metergraph_core-0.8.2}/setup.cfg +0 -0
- {metergraph_core-0.8.0 → metergraph_core-0.8.2}/src/metergraph_core/billing.py +0 -0
- {metergraph_core-0.8.0 → metergraph_core-0.8.2}/src/metergraph_core/data/models.yaml +0 -0
- {metergraph_core-0.8.0 → metergraph_core-0.8.2}/src/metergraph_core/data/sources.yaml +0 -0
- {metergraph_core-0.8.0 → metergraph_core-0.8.2}/src/metergraph_core/extractors.py +0 -0
- {metergraph_core-0.8.0 → metergraph_core-0.8.2}/src/metergraph_core/models.py +0 -0
- {metergraph_core-0.8.0 → metergraph_core-0.8.2}/src/metergraph_core/responses.py +0 -0
- {metergraph_core-0.8.0 → metergraph_core-0.8.2}/src/metergraph_core/retrieval.py +0 -0
- {metergraph_core-0.8.0 → metergraph_core-0.8.2}/src/metergraph_core/sources.py +0 -0
- {metergraph_core-0.8.0 → metergraph_core-0.8.2}/src/metergraph_core.egg-info/SOURCES.txt +0 -0
- {metergraph_core-0.8.0 → metergraph_core-0.8.2}/src/metergraph_core.egg-info/dependency_links.txt +0 -0
- {metergraph_core-0.8.0 → metergraph_core-0.8.2}/src/metergraph_core.egg-info/requires.txt +0 -0
- {metergraph_core-0.8.0 → metergraph_core-0.8.2}/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.8.
|
|
3
|
+
Version: 0.8.2
|
|
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
|
|
@@ -210,6 +210,11 @@ A call after it ended resolves to that price, stays `priced`, and carries
|
|
|
210
210
|
applied `price.effective_to` says when it ended. A closed price followed by a
|
|
211
211
|
later one never extends, so a gap before a scheduled price stays `unpriced`.
|
|
212
212
|
|
|
213
|
+
An `unpriced` result whose cause is a value you passed, such as an unknown
|
|
214
|
+
channel, provider or model, says why in `warnings`. Each `PricingWarning` has
|
|
215
|
+
a stable `code` and a `message` that may suggest the value you meant. Match on
|
|
216
|
+
`code`; the message text may change.
|
|
217
|
+
|
|
213
218
|
### Costing a provider response
|
|
214
219
|
|
|
215
220
|
`cost_from_response` reads the usage out of a response you already have and
|
|
@@ -351,6 +356,41 @@ effective_from)` on a model the catalog already has:
|
|
|
351
356
|
start on a later UTC date than the row it replaces, for the same reason.
|
|
352
357
|
- `close_price` sets `effective_to` on the open row without a replacement.
|
|
353
358
|
|
|
359
|
+
Catalog operations change which models exist and what calls they match:
|
|
360
|
+
|
|
361
|
+
- `add_model` appends one complete model entry.
|
|
362
|
+
- `add_alias` appends one alias to an existing model. A spelling another
|
|
363
|
+
model already holds is a conflict only while both aliases are in effect.
|
|
364
|
+
- `close_alias` sets `effective_to` on an open alias, named by
|
|
365
|
+
`(canonical_id, provider, alias, channel)`; `expect` is the exact current
|
|
366
|
+
alias. An alias with dates must cite its source, so a close carries
|
|
367
|
+
`source_url` when the alias has none and must not when it has one.
|
|
368
|
+
|
|
369
|
+
A `close_alias`, `close_price`, `add_alias` and `add_price` together move a
|
|
370
|
+
spelling from a duplicate entry to the model it duplicates from a date. Calls
|
|
371
|
+
before that date keep the old model and price, calls from it resolve to the
|
|
372
|
+
surviving model, and the old entry stays for history with nothing in effect:
|
|
373
|
+
|
|
374
|
+
```yaml
|
|
375
|
+
- op: close_alias
|
|
376
|
+
key: {canonical_id: example-host/model-a-instruct, provider: openrouter,
|
|
377
|
+
alias: example-host/model-a-instruct, channel: openrouter-api}
|
|
378
|
+
expect: {provider: openrouter, alias: example-host/model-a-instruct, channel: openrouter-api}
|
|
379
|
+
effective_to: "2026-10-20"
|
|
380
|
+
source_url: "https://..."
|
|
381
|
+
- op: close_price # the duplicate's open openrouter-api row
|
|
382
|
+
- op: add_alias
|
|
383
|
+
key: {canonical_id: example/model-a, provider: openrouter,
|
|
384
|
+
alias: example-host/model-a-instruct, channel: openrouter-api}
|
|
385
|
+
expect: null
|
|
386
|
+
alias: {provider: openrouter, alias: example-host/model-a-instruct,
|
|
387
|
+
channel: openrouter-api, effective_from: "2026-10-20", source_url: "https://..."}
|
|
388
|
+
- op: add_price # the same rate on example/model-a from 2026-10-20
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
Gateway discovery matches a spelling only through aliases in effect on the
|
|
392
|
+
observation date, so it follows a moved spelling to its new model.
|
|
393
|
+
|
|
354
394
|
A price id ends with the UTC date of its row's start. If two rows for one
|
|
355
395
|
model, channel and region would still share an id (possible in a hand-edited
|
|
356
396
|
catalog), each that does not start at UTC midnight also names its UTC start
|
|
@@ -180,6 +180,11 @@ A call after it ended resolves to that price, stays `priced`, and carries
|
|
|
180
180
|
applied `price.effective_to` says when it ended. A closed price followed by a
|
|
181
181
|
later one never extends, so a gap before a scheduled price stays `unpriced`.
|
|
182
182
|
|
|
183
|
+
An `unpriced` result whose cause is a value you passed, such as an unknown
|
|
184
|
+
channel, provider or model, says why in `warnings`. Each `PricingWarning` has
|
|
185
|
+
a stable `code` and a `message` that may suggest the value you meant. Match on
|
|
186
|
+
`code`; the message text may change.
|
|
187
|
+
|
|
183
188
|
### Costing a provider response
|
|
184
189
|
|
|
185
190
|
`cost_from_response` reads the usage out of a response you already have and
|
|
@@ -321,6 +326,41 @@ effective_from)` on a model the catalog already has:
|
|
|
321
326
|
start on a later UTC date than the row it replaces, for the same reason.
|
|
322
327
|
- `close_price` sets `effective_to` on the open row without a replacement.
|
|
323
328
|
|
|
329
|
+
Catalog operations change which models exist and what calls they match:
|
|
330
|
+
|
|
331
|
+
- `add_model` appends one complete model entry.
|
|
332
|
+
- `add_alias` appends one alias to an existing model. A spelling another
|
|
333
|
+
model already holds is a conflict only while both aliases are in effect.
|
|
334
|
+
- `close_alias` sets `effective_to` on an open alias, named by
|
|
335
|
+
`(canonical_id, provider, alias, channel)`; `expect` is the exact current
|
|
336
|
+
alias. An alias with dates must cite its source, so a close carries
|
|
337
|
+
`source_url` when the alias has none and must not when it has one.
|
|
338
|
+
|
|
339
|
+
A `close_alias`, `close_price`, `add_alias` and `add_price` together move a
|
|
340
|
+
spelling from a duplicate entry to the model it duplicates from a date. Calls
|
|
341
|
+
before that date keep the old model and price, calls from it resolve to the
|
|
342
|
+
surviving model, and the old entry stays for history with nothing in effect:
|
|
343
|
+
|
|
344
|
+
```yaml
|
|
345
|
+
- op: close_alias
|
|
346
|
+
key: {canonical_id: example-host/model-a-instruct, provider: openrouter,
|
|
347
|
+
alias: example-host/model-a-instruct, channel: openrouter-api}
|
|
348
|
+
expect: {provider: openrouter, alias: example-host/model-a-instruct, channel: openrouter-api}
|
|
349
|
+
effective_to: "2026-10-20"
|
|
350
|
+
source_url: "https://..."
|
|
351
|
+
- op: close_price # the duplicate's open openrouter-api row
|
|
352
|
+
- op: add_alias
|
|
353
|
+
key: {canonical_id: example/model-a, provider: openrouter,
|
|
354
|
+
alias: example-host/model-a-instruct, channel: openrouter-api}
|
|
355
|
+
expect: null
|
|
356
|
+
alias: {provider: openrouter, alias: example-host/model-a-instruct,
|
|
357
|
+
channel: openrouter-api, effective_from: "2026-10-20", source_url: "https://..."}
|
|
358
|
+
- op: add_price # the same rate on example/model-a from 2026-10-20
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
Gateway discovery matches a spelling only through aliases in effect on the
|
|
362
|
+
observation date, so it follows a moved spelling to its new model.
|
|
363
|
+
|
|
324
364
|
A price id ends with the UTC date of its row's start. If two rows for one
|
|
325
365
|
model, channel and region would still share an id (possible in a hand-edited
|
|
326
366
|
catalog), each that does not start at UTC midnight also names its UTC start
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "metergraph-core"
|
|
3
|
-
version = "0.8.
|
|
3
|
+
version = "0.8.2"
|
|
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"
|
|
@@ -8,6 +8,7 @@ from .catalog import (
|
|
|
8
8
|
ModelUsage,
|
|
9
9
|
Price,
|
|
10
10
|
PriceResult,
|
|
11
|
+
PricingWarning,
|
|
11
12
|
ResolvedPrice,
|
|
12
13
|
counts_cache_read_in_input,
|
|
13
14
|
direct_channel_for_provider,
|
|
@@ -128,6 +129,7 @@ __all__ = [
|
|
|
128
129
|
"GatewayBillingEvidence",
|
|
129
130
|
"Price",
|
|
130
131
|
"PriceResult",
|
|
132
|
+
"PricingWarning",
|
|
131
133
|
"ResolvedPrice",
|
|
132
134
|
"RetrievalCatalog",
|
|
133
135
|
"RetrievalCostResult",
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""Effective-dated model catalog and deterministic token-cost enrichment."""
|
|
2
2
|
|
|
3
|
+
import difflib
|
|
3
4
|
import re
|
|
4
5
|
from dataclasses import dataclass
|
|
5
6
|
from datetime import date, datetime, timezone
|
|
@@ -86,6 +87,16 @@ _GLOBAL_REGION = "global"
|
|
|
86
87
|
# Reason for a result priced at a price that ended and was not replaced.
|
|
87
88
|
PRICE_ENDED = "price_ended"
|
|
88
89
|
|
|
90
|
+
# Warning codes: why a result is unpriced, when the cause is a value the
|
|
91
|
+
# caller sent. The codes are stable; the messages may change.
|
|
92
|
+
UNKNOWN_CHANNEL = "unknown_channel"
|
|
93
|
+
UNKNOWN_PROVIDER = "unknown_provider"
|
|
94
|
+
UNKNOWN_MODEL = "unknown_model"
|
|
95
|
+
MODEL_NOT_ON_CHANNEL = "model_not_on_channel"
|
|
96
|
+
MODEL_NOT_IN_EFFECT = "model_not_in_effect"
|
|
97
|
+
MODEL_NOT_IN_REGION = "model_not_in_region"
|
|
98
|
+
PRICE_NOT_IN_EFFECT = "price_not_in_effect"
|
|
99
|
+
|
|
89
100
|
|
|
90
101
|
def _bedrock_fallback_ids(model_key: str) -> tuple[tuple[str, bool], ...]:
|
|
91
102
|
"""Candidate ids for an unmatched Bedrock model, most specific first.
|
|
@@ -301,6 +312,9 @@ class CostResult:
|
|
|
301
312
|
# Inputs the caller did not supply that the figure depends on: "region",
|
|
302
313
|
# "channel", "input_tokens", "output_tokens".
|
|
303
314
|
missing_dimensions: tuple[str, ...] = ()
|
|
315
|
+
# Why an unpriced result is unpriced, when the cause is a value the
|
|
316
|
+
# caller sent. Empty otherwise.
|
|
317
|
+
warnings: tuple["PricingWarning", ...] = ()
|
|
304
318
|
|
|
305
319
|
@property
|
|
306
320
|
def model_id(self) -> str | None:
|
|
@@ -343,6 +357,30 @@ class PriceResult:
|
|
|
343
357
|
missing_dimensions: tuple[str, ...] = ()
|
|
344
358
|
# Notes that do not make the result partial, such as ``price_ended``.
|
|
345
359
|
reasons: tuple[str, ...] = ()
|
|
360
|
+
# Why an unpriced result is unpriced, when the cause is a value the
|
|
361
|
+
# caller sent. Empty otherwise.
|
|
362
|
+
warnings: tuple["PricingWarning", ...] = ()
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
@dataclass(frozen=True, slots=True)
|
|
366
|
+
class PricingWarning:
|
|
367
|
+
"""Why a result is unpriced, when the cause is a value the caller sent:
|
|
368
|
+
a channel or provider the catalog cannot bill on, a model name it does
|
|
369
|
+
not know, or a model the channel does not sell at that time. ``code`` is
|
|
370
|
+
stable; ``message`` names the value and, when one is close, the value
|
|
371
|
+
the caller probably meant."""
|
|
372
|
+
|
|
373
|
+
code: str
|
|
374
|
+
message: str
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _did_you_mean(
|
|
378
|
+
value: str, options: Any, guess: str | None = None, cutoff: float = 0.6
|
|
379
|
+
) -> str:
|
|
380
|
+
options = sorted(options)
|
|
381
|
+
close = difflib.get_close_matches(value, options, 1, cutoff)
|
|
382
|
+
match = guess if guess in options else next(iter(close), None)
|
|
383
|
+
return f' Did you mean "{match}"?' if match else ""
|
|
346
384
|
|
|
347
385
|
|
|
348
386
|
@dataclass(frozen=True, slots=True)
|
|
@@ -713,6 +751,17 @@ class CatalogSnapshot:
|
|
|
713
751
|
)
|
|
714
752
|
for candidates in self._prices.values():
|
|
715
753
|
candidates.sort(key=lambda price: price.effective_from, reverse=True)
|
|
754
|
+
# For explaining an unpriced result: the channels some price row
|
|
755
|
+
# bills on, and each model name with the channels that sell it under
|
|
756
|
+
# that name, an alias there and a price there, at any time.
|
|
757
|
+
priced = {(model_id, channel.lower()) for model_id, channel in self._prices}
|
|
758
|
+
self._priced_channels = frozenset(channel for _, channel in priced)
|
|
759
|
+
sold: dict[str, set[str]] = {}
|
|
760
|
+
for (model, channel), candidates in self._deployment_aliases.items():
|
|
761
|
+
found = sold.setdefault(model, set())
|
|
762
|
+
if any((alias.model_id, channel) in priced for alias in candidates):
|
|
763
|
+
found.add(channel)
|
|
764
|
+
self._sold = {model: frozenset(channels) for model, channels in sold.items()}
|
|
716
765
|
# Every named region a price or pin exists for: the regions an omitted
|
|
717
766
|
# query region could have meant.
|
|
718
767
|
self._regions = tuple(sorted(
|
|
@@ -894,6 +943,147 @@ class CatalogSnapshot:
|
|
|
894
943
|
return resolved, ("region",)
|
|
895
944
|
return resolved, ()
|
|
896
945
|
|
|
946
|
+
def _explain(
|
|
947
|
+
self, *, model: Any, channel: Any, provider: Any, at: Any, region: Any
|
|
948
|
+
) -> tuple[PricingWarning, ...]:
|
|
949
|
+
"""Why :meth:`_lookup` found no price, when the cause is a value the
|
|
950
|
+
caller sent. Each check follows the step of the lookup it explains:
|
|
951
|
+
the channel, or the provider's direct channel, then the model name on
|
|
952
|
+
that channel, then the alias and price windows at ``at``."""
|
|
953
|
+
channel_key = str(channel or "").strip().lower()
|
|
954
|
+
if channel_key:
|
|
955
|
+
if channel_key not in self._priced_channels:
|
|
956
|
+
# A provider's name, with or without "-api", points at its
|
|
957
|
+
# direct channel.
|
|
958
|
+
guess = direct_channel_for_provider(
|
|
959
|
+
channel_key
|
|
960
|
+
) or direct_channel_for_provider(channel_key.removesuffix("-api"))
|
|
961
|
+
return (PricingWarning(
|
|
962
|
+
UNKNOWN_CHANNEL,
|
|
963
|
+
f'channel "{str(channel).strip()}" is not a channel the catalog prices.'
|
|
964
|
+
f"{_did_you_mean(channel_key, self._priced_channels, guess)}",
|
|
965
|
+
),)
|
|
966
|
+
else:
|
|
967
|
+
if provider is None or not str(provider).strip():
|
|
968
|
+
return ()
|
|
969
|
+
channel_key = direct_channel_for_provider(provider)
|
|
970
|
+
if channel_key is None:
|
|
971
|
+
providers = set(_DIRECT_CHANNEL_BY_PROVIDER) | {
|
|
972
|
+
spelling for spelling, name in _PROVIDER_ALIASES.items()
|
|
973
|
+
if name in _DIRECT_CHANNEL_BY_PROVIDER
|
|
974
|
+
}
|
|
975
|
+
return (PricingWarning(
|
|
976
|
+
UNKNOWN_PROVIDER,
|
|
977
|
+
f'provider "{str(provider).strip()}" has no billing channel of its own.'
|
|
978
|
+
f"{_did_you_mean(str(provider).strip().lower(), providers)} If a gateway or "
|
|
979
|
+
"proxy relayed the call, name the provider that served it or the channel "
|
|
980
|
+
"that billed it.",
|
|
981
|
+
),)
|
|
982
|
+
model_key = str(model or "").strip().lower()
|
|
983
|
+
if not model_key:
|
|
984
|
+
return ()
|
|
985
|
+
# Bedrock fallback ids, under the rules _resolve applies: dropping
|
|
986
|
+
# "global." is valid only on aws-bedrock, and never across a price
|
|
987
|
+
# region.
|
|
988
|
+
keys, dropped, forbidden = [model_key], set(), []
|
|
989
|
+
if channel_key in _BEDROCK_CHANNELS:
|
|
990
|
+
for candidate, dropped_global in _bedrock_fallback_ids(model_key):
|
|
991
|
+
if dropped_global and channel_key != _BEDROCK_CHANNEL:
|
|
992
|
+
forbidden.append(candidate)
|
|
993
|
+
continue
|
|
994
|
+
keys.append(candidate)
|
|
995
|
+
if dropped_global:
|
|
996
|
+
dropped.add(candidate)
|
|
997
|
+
name = str(model).strip()
|
|
998
|
+
if not any(key in self._sold for key in keys):
|
|
999
|
+
# A "global." id on a geo channel points at the bare id that
|
|
1000
|
+
# channel sells.
|
|
1001
|
+
guess = next(
|
|
1002
|
+
(key for key in forbidden if channel_key in self._sold.get(key, ())), None
|
|
1003
|
+
)
|
|
1004
|
+
return (PricingWarning(
|
|
1005
|
+
UNKNOWN_MODEL,
|
|
1006
|
+
f'model "{name}" is not in the catalog.'
|
|
1007
|
+
f"{_did_you_mean(model_key, self._sold, guess, cutoff=0.8)}",
|
|
1008
|
+
),)
|
|
1009
|
+
candidates = tuple(
|
|
1010
|
+
alias
|
|
1011
|
+
for key in keys
|
|
1012
|
+
for alias in self._deployment_aliases.get((key, channel_key), ())
|
|
1013
|
+
if key not in dropped or not _crosses_price_region(alias)
|
|
1014
|
+
)
|
|
1015
|
+
sold = sorted({channel for key in keys for channel in self._sold.get(key, ())})
|
|
1016
|
+
if not candidates or channel_key not in sold:
|
|
1017
|
+
where = (
|
|
1018
|
+
f"It is priced on {', '.join(sold)}."
|
|
1019
|
+
if sold
|
|
1020
|
+
else "No channel prices it under that name."
|
|
1021
|
+
)
|
|
1022
|
+
return (PricingWarning(
|
|
1023
|
+
MODEL_NOT_ON_CHANNEL,
|
|
1024
|
+
f'model "{name}" has no price on channel "{channel_key}". {where}',
|
|
1025
|
+
),)
|
|
1026
|
+
when = _coerce_datetime(at)
|
|
1027
|
+
stated = str(region or "").strip().lower() or None
|
|
1028
|
+
alias = self._alias_for(candidates, when, stated)
|
|
1029
|
+
if alias is None:
|
|
1030
|
+
return (self._unresolved(name, channel_key, candidates, when, stated),)
|
|
1031
|
+
price, _ = self._price_for(alias, when, stated)
|
|
1032
|
+
if price is None:
|
|
1033
|
+
scope = f' in region "{stated}"' if stated else ""
|
|
1034
|
+
return (PricingWarning(
|
|
1035
|
+
PRICE_NOT_IN_EFFECT,
|
|
1036
|
+
f'model "{name}" has no price on channel "{channel_key}"{scope} in effect at '
|
|
1037
|
+
f"{when.isoformat()}.",
|
|
1038
|
+
),)
|
|
1039
|
+
return ()
|
|
1040
|
+
|
|
1041
|
+
def _unresolved(
|
|
1042
|
+
self,
|
|
1043
|
+
name: str,
|
|
1044
|
+
channel: str,
|
|
1045
|
+
candidates: tuple[Alias, ...],
|
|
1046
|
+
when: datetime,
|
|
1047
|
+
region: str | None,
|
|
1048
|
+
) -> PricingWarning:
|
|
1049
|
+
"""Why no alias for ``name`` on ``channel`` applies at ``when``: its
|
|
1050
|
+
windows close before or open after it, or the aliases in effect are
|
|
1051
|
+
pinned to other regions."""
|
|
1052
|
+
current = [
|
|
1053
|
+
alias for alias in candidates
|
|
1054
|
+
if (alias.effective_from is None or alias.effective_from <= when)
|
|
1055
|
+
and (alias.effective_to is None or when < alias.effective_to)
|
|
1056
|
+
]
|
|
1057
|
+
if current:
|
|
1058
|
+
regions = sorted({pin for alias in current for pin in alias.price_region})
|
|
1059
|
+
asked = region or self._region
|
|
1060
|
+
return PricingWarning(
|
|
1061
|
+
MODEL_NOT_IN_REGION,
|
|
1062
|
+
f'model "{name}" resolves on channel "{channel}" only in '
|
|
1063
|
+
f"{', '.join(regions)}, not {asked}.",
|
|
1064
|
+
)
|
|
1065
|
+
ended = [alias.effective_to for alias in candidates
|
|
1066
|
+
if alias.effective_to is not None and alias.effective_to <= when]
|
|
1067
|
+
starts = [alias.effective_from for alias in candidates
|
|
1068
|
+
if alias.effective_from is not None and when < alias.effective_from]
|
|
1069
|
+
if ended and starts:
|
|
1070
|
+
when_text = (
|
|
1071
|
+
f"between {max(ended).date().isoformat()} and "
|
|
1072
|
+
f"{min(starts).date().isoformat()}"
|
|
1073
|
+
)
|
|
1074
|
+
message = f'model "{name}" does not resolve on channel "{channel}" {when_text}.'
|
|
1075
|
+
elif ended:
|
|
1076
|
+
message = (
|
|
1077
|
+
f'model "{name}" stopped resolving on channel "{channel}" on '
|
|
1078
|
+
f"{max(ended).date().isoformat()}."
|
|
1079
|
+
)
|
|
1080
|
+
else:
|
|
1081
|
+
message = (
|
|
1082
|
+
f'model "{name}" resolves on channel "{channel}" from '
|
|
1083
|
+
f"{min(starts).date().isoformat()}."
|
|
1084
|
+
)
|
|
1085
|
+
return PricingWarning(MODEL_NOT_IN_EFFECT, message)
|
|
1086
|
+
|
|
897
1087
|
def cost(
|
|
898
1088
|
self,
|
|
899
1089
|
*,
|
|
@@ -15,7 +15,15 @@ catalog already has:
|
|
|
15
15
|
It also supports reviewed catalog expansion:
|
|
16
16
|
|
|
17
17
|
- ``add_model`` appends one complete model entry.
|
|
18
|
-
- ``add_alias`` appends one alias to an existing model.
|
|
18
|
+
- ``add_alias`` appends one alias to an existing model. A spelling another
|
|
19
|
+
model holds conflicts only while both aliases are in effect, so a spelling
|
|
20
|
+
can move to another model from a date.
|
|
21
|
+
- ``close_alias`` sets ``effective_to`` on the open alias its ``expect``
|
|
22
|
+
names, so a spelling closed and reopened on one model keeps its earlier
|
|
23
|
+
window. With an
|
|
24
|
+
``add_alias`` for the same spelling on another model in one delta, it
|
|
25
|
+
moves that spelling: calls before the date keep the old model, calls from
|
|
26
|
+
it resolve to the new one.
|
|
19
27
|
|
|
20
28
|
``provenance`` references the observations and evidence the delta came
|
|
21
29
|
from.
|
|
@@ -34,7 +42,7 @@ from .loader import CatalogError, _canonical_json, load_catalog_document
|
|
|
34
42
|
CATALOG_DELTA_SCHEMA_VERSION = 2
|
|
35
43
|
PRICING_DELTA_KIND = "pricing_delta"
|
|
36
44
|
PRICE_OPERATIONS = ("add_price", "replace_price", "close_price")
|
|
37
|
-
CATALOG_OPERATIONS = ("add_model", "add_alias")
|
|
45
|
+
CATALOG_OPERATIONS = ("add_model", "add_alias", "close_alias")
|
|
38
46
|
OPERATIONS = PRICE_OPERATIONS + CATALOG_OPERATIONS
|
|
39
47
|
|
|
40
48
|
RATE_FIELDS = (
|
|
@@ -116,12 +124,14 @@ class DeltaOperation:
|
|
|
116
124
|
expect: Mapping[str, Any] | None
|
|
117
125
|
# The new row for add/replace; None for close.
|
|
118
126
|
price: Mapping[str, Any] | None
|
|
119
|
-
# The closing date for close_price.
|
|
127
|
+
# The closing date for close_price and close_alias.
|
|
120
128
|
effective_to: str | None
|
|
121
129
|
# The complete entry for add_model.
|
|
122
130
|
model: Mapping[str, Any] | None = None
|
|
123
131
|
# The alias row for add_alias.
|
|
124
132
|
alias: Mapping[str, Any] | None = None
|
|
133
|
+
# The source a close_alias cites, for an alias that cites none.
|
|
134
|
+
source_url: str | None = None
|
|
125
135
|
|
|
126
136
|
|
|
127
137
|
@dataclass(frozen=True, slots=True)
|
|
@@ -197,7 +207,7 @@ def _parse_model_key(raw: Any, label: str) -> ModelKey:
|
|
|
197
207
|
return ModelKey(_text(raw["canonical_id"]))
|
|
198
208
|
|
|
199
209
|
|
|
200
|
-
def _parse_alias_key(raw: Any, label: str) -> AliasKey:
|
|
210
|
+
def _parse_alias_key(raw: Any, label: str, op: str) -> AliasKey:
|
|
201
211
|
if not isinstance(raw, Mapping):
|
|
202
212
|
raise CatalogDeltaError(f"{label}: key must be a mapping")
|
|
203
213
|
fields = ("canonical_id", "provider", "alias", "channel")
|
|
@@ -205,7 +215,7 @@ def _parse_alias_key(raw: Any, label: str) -> AliasKey:
|
|
|
205
215
|
if _text(raw.get(field)) is None:
|
|
206
216
|
raise CatalogDeltaError(f"{label}: key needs {field}")
|
|
207
217
|
if set(raw) != set(fields):
|
|
208
|
-
raise CatalogDeltaError(f"{label}:
|
|
218
|
+
raise CatalogDeltaError(f"{label}: {op} key has unknown fields")
|
|
209
219
|
return AliasKey(
|
|
210
220
|
_text(raw["canonical_id"]),
|
|
211
221
|
_text(raw["provider"]).lower(),
|
|
@@ -324,8 +334,8 @@ def _parse_operation(index: int, raw: Any) -> DeltaOperation:
|
|
|
324
334
|
raise CatalogDeltaError(f"{label}: unknown op {op!r}")
|
|
325
335
|
if op == "add_model":
|
|
326
336
|
key: OperationKey = _parse_model_key(raw.get("key"), label)
|
|
327
|
-
elif op
|
|
328
|
-
key = _parse_alias_key(raw.get("key"), label)
|
|
337
|
+
elif op in ("add_alias", "close_alias"):
|
|
338
|
+
key = _parse_alias_key(raw.get("key"), label, op)
|
|
329
339
|
else:
|
|
330
340
|
key = _parse_key(raw.get("key"), label)
|
|
331
341
|
expect = raw.get("expect")
|
|
@@ -338,6 +348,7 @@ def _parse_operation(index: int, raw: Any) -> DeltaOperation:
|
|
|
338
348
|
effective_to = None
|
|
339
349
|
model = None
|
|
340
350
|
alias = None
|
|
351
|
+
source_url = None
|
|
341
352
|
if op == "add_model":
|
|
342
353
|
if "price" in raw or "alias" in raw or "effective_to" in raw:
|
|
343
354
|
raise CatalogDeltaError(f"{label}: add_model takes only model")
|
|
@@ -346,6 +357,18 @@ def _parse_operation(index: int, raw: Any) -> DeltaOperation:
|
|
|
346
357
|
if "price" in raw or "model" in raw or "effective_to" in raw:
|
|
347
358
|
raise CatalogDeltaError(f"{label}: add_alias takes only alias")
|
|
348
359
|
alias = _parse_alias(raw.get("alias"), key, label)
|
|
360
|
+
elif op == "close_alias":
|
|
361
|
+
if set(raw) - {"op", "key", "expect", "effective_to", "source_url"}:
|
|
362
|
+
raise CatalogDeltaError(
|
|
363
|
+
f"{label}: close_alias takes only effective_to and source_url"
|
|
364
|
+
)
|
|
365
|
+
effective_to = _iso_date(raw.get("effective_to"))
|
|
366
|
+
if effective_to is None:
|
|
367
|
+
raise CatalogDeltaError(f"{label}: invalid effective_to")
|
|
368
|
+
if "source_url" in raw:
|
|
369
|
+
source_url = _text(raw.get("source_url"))
|
|
370
|
+
if source_url is None:
|
|
371
|
+
raise CatalogDeltaError(f"{label}: invalid source_url")
|
|
349
372
|
elif op == "close_price":
|
|
350
373
|
if "price" in raw:
|
|
351
374
|
raise CatalogDeltaError(f"{label}: close_price takes no price")
|
|
@@ -369,6 +392,7 @@ def _parse_operation(index: int, raw: Any) -> DeltaOperation:
|
|
|
369
392
|
effective_to=effective_to,
|
|
370
393
|
model=model,
|
|
371
394
|
alias=alias,
|
|
395
|
+
source_url=source_url,
|
|
372
396
|
)
|
|
373
397
|
|
|
374
398
|
|
|
@@ -480,8 +504,53 @@ def _key_label(key: OperationKey) -> str:
|
|
|
480
504
|
return key.canonical_id
|
|
481
505
|
|
|
482
506
|
|
|
483
|
-
def
|
|
484
|
-
|
|
507
|
+
def _alias_rows(
|
|
508
|
+
entry: Mapping[str, Any], key: AliasKey
|
|
509
|
+
) -> list[Mapping[str, Any]]:
|
|
510
|
+
return [
|
|
511
|
+
alias
|
|
512
|
+
for alias in entry.get("aliases") or []
|
|
513
|
+
if isinstance(alias, Mapping)
|
|
514
|
+
and (_text(alias.get("provider")) or "").lower() == key.provider
|
|
515
|
+
and (_text(alias.get("alias")) or "").lower() == key.alias
|
|
516
|
+
and _text(alias.get("channel")) == key.channel
|
|
517
|
+
]
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def _expected_aliases(
|
|
521
|
+
entry: Mapping[str, Any], operation: DeltaOperation
|
|
522
|
+
) -> list[Mapping[str, Any]]:
|
|
523
|
+
"""The rows under a close_alias key that equal its ``expect``. A key can
|
|
524
|
+
hold several windows of one spelling; ``expect`` names the one to end."""
|
|
525
|
+
expected = _canonical_json(operation.expect)
|
|
526
|
+
return [
|
|
527
|
+
alias
|
|
528
|
+
for alias in _alias_rows(entry, operation.key)
|
|
529
|
+
if _canonical_json(alias) == expected
|
|
530
|
+
]
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def _windows_overlap(
|
|
534
|
+
left_from: Any, left_to: Any, right_from: Any, right_to: Any
|
|
535
|
+
) -> bool:
|
|
536
|
+
"""Whether two alias windows share an instant; a missing bound is open,
|
|
537
|
+
as the catalog loader reads it."""
|
|
538
|
+
left_from, left_to = _instant(left_from), _instant(left_to)
|
|
539
|
+
right_from, right_to = _instant(right_from), _instant(right_to)
|
|
540
|
+
return (left_to is None or right_from is None or right_from < left_to) and (
|
|
541
|
+
right_to is None or left_from is None or left_from < right_to
|
|
542
|
+
)
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def _precondition(
|
|
546
|
+
document: Mapping[str, Any],
|
|
547
|
+
operation: DeltaOperation,
|
|
548
|
+
closing: Mapping[AliasKey, str] | None = None,
|
|
549
|
+
) -> str | None:
|
|
550
|
+
"""Why ``operation`` does not apply to ``document``, or None when it does.
|
|
551
|
+
|
|
552
|
+
``closing`` maps the key of each alias a close_alias in the same delta
|
|
553
|
+
ends to its ``effective_to``, so an alias can move in one delta."""
|
|
485
554
|
if operation.op == "add_model":
|
|
486
555
|
key = operation.key
|
|
487
556
|
if _model_indexes(document, key.canonical_id):
|
|
@@ -502,13 +571,69 @@ def _precondition(document: Mapping[str, Any], operation: DeltaOperation) -> str
|
|
|
502
571
|
return "unknown_model"
|
|
503
572
|
if len(models) > 1:
|
|
504
573
|
return "duplicate_model"
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
574
|
+
closing = closing or {}
|
|
575
|
+
new = operation.alias or {}
|
|
576
|
+
for entry in document.get("models") or []:
|
|
577
|
+
if not isinstance(entry, Mapping):
|
|
578
|
+
continue
|
|
579
|
+
canonical_id = str(entry.get("canonical_id") or "")
|
|
580
|
+
for alias in entry.get("aliases") or []:
|
|
581
|
+
if not isinstance(alias, Mapping):
|
|
582
|
+
continue
|
|
583
|
+
provider = (_text(alias.get("provider")) or "").lower()
|
|
584
|
+
name = (_text(alias.get("alias")) or "").lower()
|
|
585
|
+
if (provider, name) != (key.provider, key.alias):
|
|
586
|
+
continue
|
|
587
|
+
ends = alias.get("effective_to")
|
|
588
|
+
if ends is None:
|
|
589
|
+
ends = closing.get(
|
|
590
|
+
AliasKey(
|
|
591
|
+
canonical_id,
|
|
592
|
+
provider,
|
|
593
|
+
name,
|
|
594
|
+
_text(alias.get("channel")) or "",
|
|
595
|
+
)
|
|
596
|
+
)
|
|
597
|
+
if _windows_overlap(
|
|
598
|
+
alias.get("effective_from"),
|
|
599
|
+
ends,
|
|
600
|
+
new.get("effective_from"),
|
|
601
|
+
new.get("effective_to"),
|
|
602
|
+
):
|
|
603
|
+
return (
|
|
604
|
+
"alias_exists"
|
|
605
|
+
if canonical_id == key.canonical_id
|
|
606
|
+
else "alias_conflict"
|
|
607
|
+
)
|
|
608
|
+
return None
|
|
609
|
+
if operation.op == "close_alias":
|
|
610
|
+
key = operation.key
|
|
611
|
+
models = _model_indexes(document, key.canonical_id)
|
|
612
|
+
if not models:
|
|
613
|
+
return "unknown_model"
|
|
614
|
+
if len(models) > 1:
|
|
615
|
+
return "duplicate_model"
|
|
616
|
+
entry = document["models"][models[0]]
|
|
617
|
+
if not _alias_rows(entry, key):
|
|
618
|
+
return "alias_missing"
|
|
619
|
+
rows = _expected_aliases(entry, operation)
|
|
620
|
+
if not rows:
|
|
621
|
+
return "expect_mismatch"
|
|
622
|
+
if len(rows) > 1:
|
|
623
|
+
return "duplicate_alias"
|
|
624
|
+
row = rows[0]
|
|
625
|
+
if row.get("effective_to") is not None:
|
|
626
|
+
return "alias_not_open"
|
|
627
|
+
starts = _instant(row.get("effective_from"))
|
|
628
|
+
if starts is not None and _instant(operation.effective_to) <= starts:
|
|
629
|
+
return "not_after_start"
|
|
630
|
+
# A dated alias must cite its source, so a close supplies one for an
|
|
631
|
+
# alias that has none, and never replaces one it has.
|
|
632
|
+
if _text(row.get("source_url")) is None:
|
|
633
|
+
if operation.source_url is None:
|
|
634
|
+
return "source_url_needed"
|
|
635
|
+
elif operation.source_url is not None:
|
|
636
|
+
return "source_url_exists"
|
|
512
637
|
return None
|
|
513
638
|
model_index, rows, reason = _locate(document, operation)
|
|
514
639
|
if reason is not None:
|
|
@@ -565,10 +690,15 @@ def apply_catalog_delta(
|
|
|
565
690
|
delta = parse_catalog_delta(delta_document)
|
|
566
691
|
if not isinstance(document, Mapping):
|
|
567
692
|
raise CatalogDeltaError("catalog document must be a mapping")
|
|
693
|
+
closing = {
|
|
694
|
+
operation.key: operation.effective_to
|
|
695
|
+
for operation in delta.operations
|
|
696
|
+
if operation.op == "close_alias"
|
|
697
|
+
}
|
|
568
698
|
conflicts = tuple(
|
|
569
699
|
DeltaConflict(operation.index, operation.op, operation.key, reason)
|
|
570
700
|
for operation in delta.operations
|
|
571
|
-
if (reason := _precondition(document, operation)) is not None
|
|
701
|
+
if (reason := _precondition(document, operation, closing)) is not None
|
|
572
702
|
)
|
|
573
703
|
if conflicts:
|
|
574
704
|
raise CatalogDeltaConflict(conflicts)
|
|
@@ -586,6 +716,16 @@ def apply_catalog_delta(
|
|
|
586
716
|
copy.deepcopy(dict(operation.alias))
|
|
587
717
|
)
|
|
588
718
|
continue
|
|
719
|
+
if operation.op == "close_alias":
|
|
720
|
+
[model_index] = _model_indexes(result, operation.key.canonical_id)
|
|
721
|
+
# The first equal row is the one checked: an alias this delta
|
|
722
|
+
# adds under the same key starts after this one ends, so it never
|
|
723
|
+
# equals ``expect``.
|
|
724
|
+
alias = _expected_aliases(result["models"][model_index], operation)[0]
|
|
725
|
+
alias["effective_to"] = operation.effective_to
|
|
726
|
+
if operation.source_url is not None:
|
|
727
|
+
alias["source_url"] = operation.source_url
|
|
728
|
+
continue
|
|
589
729
|
model_index, rows, _ = _locate(result, operation)
|
|
590
730
|
prices = result["models"][model_index].setdefault("prices", [])
|
|
591
731
|
if operation.op == "add_price":
|
|
@@ -4985,7 +4985,7 @@ models:
|
|
|
4985
4985
|
cache_read_per_mtok: 0.125
|
|
4986
4986
|
batch_input_per_mtok: 0.625
|
|
4987
4987
|
batch_output_per_mtok: 5
|
|
4988
|
-
source_url: https://
|
|
4988
|
+
source_url: https://developers.openai.com/api/docs/models/gpt-5
|
|
4989
4989
|
- channel: vercel-ai-gateway
|
|
4990
4990
|
region: global
|
|
4991
4991
|
effective_from: "2026-10-02"
|
|
@@ -5010,7 +5010,7 @@ models:
|
|
|
5010
5010
|
input_per_mtok: 1.75
|
|
5011
5011
|
output_per_mtok: 14
|
|
5012
5012
|
cache_read_per_mtok: 0.175
|
|
5013
|
-
source_url: https://
|
|
5013
|
+
source_url: https://developers.openai.com/api/docs/models/gpt-5.3-codex
|
|
5014
5014
|
- channel: vercel-ai-gateway
|
|
5015
5015
|
region: global
|
|
5016
5016
|
effective_from: "2026-10-02"
|
|
@@ -5141,11 +5141,22 @@ models:
|
|
|
5141
5141
|
- channel: openai-api
|
|
5142
5142
|
region: global
|
|
5143
5143
|
effective_from: "2026-07-09"
|
|
5144
|
+
effective_to: "2026-10-27"
|
|
5144
5145
|
input_per_mtok: 12.5
|
|
5145
5146
|
output_per_mtok: 75
|
|
5146
5147
|
cache_read_per_mtok: 1.25
|
|
5147
5148
|
cache_write_5m_per_mtok: 15.625
|
|
5148
|
-
source_url: https://
|
|
5149
|
+
source_url: https://developers.openai.com/api/docs/models/gpt-5.6-cyber
|
|
5150
|
+
- channel: openai-api
|
|
5151
|
+
region: global
|
|
5152
|
+
effective_from: "2026-10-27"
|
|
5153
|
+
input_per_mtok: 12.5
|
|
5154
|
+
output_per_mtok: 75
|
|
5155
|
+
cache_read_per_mtok: 1.25
|
|
5156
|
+
cache_write_5m_per_mtok: 15.625
|
|
5157
|
+
rules:
|
|
5158
|
+
long_context: {threshold: 272000, threshold_counts_cached_tokens: true, input_multiplier: 2, output_multiplier: 1.5}
|
|
5159
|
+
source_url: https://developers.openai.com/api/docs/models/gpt-5.6-cyber
|
|
5149
5160
|
- canonical_id: openai/o4-mini
|
|
5150
5161
|
publisher: openai
|
|
5151
5162
|
aliases:
|
|
@@ -5163,7 +5174,7 @@ models:
|
|
|
5163
5174
|
cache_read_per_mtok: 0.275
|
|
5164
5175
|
batch_input_per_mtok: 0.55
|
|
5165
5176
|
batch_output_per_mtok: 2.2
|
|
5166
|
-
source_url: https://
|
|
5177
|
+
source_url: https://developers.openai.com/api/docs/models/o4-mini
|
|
5167
5178
|
- channel: vercel-ai-gateway
|
|
5168
5179
|
region: global
|
|
5169
5180
|
effective_from: "2025-04-16"
|
|
@@ -153,7 +153,8 @@ class LoadedCatalog:
|
|
|
153
153
|
model=model, channel=channel, provider=provider, at=when, region=region
|
|
154
154
|
)
|
|
155
155
|
if resolved is None:
|
|
156
|
-
|
|
156
|
+
warnings = self._explain(model, channel, provider, when, region, missing)
|
|
157
|
+
return PriceResult("unpriced", None, None, when, missing, warnings=warnings)
|
|
157
158
|
return PriceResult(
|
|
158
159
|
"partial" if missing else "priced",
|
|
159
160
|
resolved.canonical_model,
|
|
@@ -163,6 +164,15 @@ class LoadedCatalog:
|
|
|
163
164
|
(PRICE_ENDED,) if resolved.ended else (),
|
|
164
165
|
)
|
|
165
166
|
|
|
167
|
+
def _explain(self, model, channel, provider, at, region, missing):
|
|
168
|
+
"""Warnings for an unpriced result. A missing region already says
|
|
169
|
+
why, so it gets none."""
|
|
170
|
+
if "region" in missing:
|
|
171
|
+
return ()
|
|
172
|
+
return self.snapshot._explain(
|
|
173
|
+
model=model, channel=channel, provider=provider, at=at, region=region
|
|
174
|
+
)
|
|
175
|
+
|
|
166
176
|
def calculate_cost(
|
|
167
177
|
self,
|
|
168
178
|
*,
|
|
@@ -190,6 +200,7 @@ class LoadedCatalog:
|
|
|
190
200
|
reasons or ("unknown_deployment",),
|
|
191
201
|
effective_at=when,
|
|
192
202
|
missing_dimensions=missing,
|
|
203
|
+
warnings=self._explain(model, channel, provider, when, region, missing),
|
|
193
204
|
)
|
|
194
205
|
return _cost_result(resolved, at=when, usage=usage, missing=missing)
|
|
195
206
|
|
|
@@ -619,6 +619,8 @@ def _gateway_price(
|
|
|
619
619
|
|
|
620
620
|
# Reviewed gateway spellings for publishers the catalog already names.
|
|
621
621
|
_GATEWAY_PUBLISHER_PREFIXES = {
|
|
622
|
+
"bytedance-seed": "bytedance",
|
|
623
|
+
"meta-llama": "meta",
|
|
622
624
|
"mistralai": "mistral",
|
|
623
625
|
"qwen": "alibaba",
|
|
624
626
|
"z-ai": "zai",
|
|
@@ -642,8 +644,48 @@ def _identity_shape(model_key: str) -> tuple[str, str]:
|
|
|
642
644
|
return prefix, "".join(character for character in suffix if character.isalnum())
|
|
643
645
|
|
|
644
646
|
|
|
647
|
+
def _current_identity(
|
|
648
|
+
entry: Mapping[str, Any], day: date
|
|
649
|
+
) -> tuple[str | None, list[Mapping[str, Any]]]:
|
|
650
|
+
"""``entry``'s canonical id and its aliases in effect on ``day``.
|
|
651
|
+
|
|
652
|
+
An alias that has ended no longer names the model, so a spelling moved to
|
|
653
|
+
another model matches only there. The canonical id is left out when it is
|
|
654
|
+
also one of the model's alias spellings and none of those is in effect,
|
|
655
|
+
as ``_names`` treats the canonical tail."""
|
|
656
|
+
aliases = [a for a in entry.get("aliases") or [] if isinstance(a, Mapping)]
|
|
657
|
+
current = [a for a in aliases if _in_effect(a, day, dated=False)]
|
|
658
|
+
canonical_id = str(entry.get("canonical_id") or "").strip().lower()
|
|
659
|
+
spelled = {str(a.get("alias") or "").strip().lower() for a in aliases}
|
|
660
|
+
active = {str(a.get("alias") or "").strip().lower() for a in current}
|
|
661
|
+
if canonical_id in spelled and canonical_id not in active:
|
|
662
|
+
return None, current
|
|
663
|
+
return canonical_id, current
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def _channel_models(
|
|
667
|
+
document: Mapping[str, Any], source: PriceSource, model_key: str, day: date
|
|
668
|
+
) -> list[Mapping[str, Any]]:
|
|
669
|
+
"""Models with an alias in effect on ``day`` that spells ``model_key`` on
|
|
670
|
+
the source's own channel. That alias is the reviewed mapping for this
|
|
671
|
+
gateway, so it decides even while another channel still gives the
|
|
672
|
+
spelling to a different model."""
|
|
673
|
+
provider, wanted = source.provider.lower(), model_key.strip().lower()
|
|
674
|
+
return [
|
|
675
|
+
entry
|
|
676
|
+
for entry in document.get("models") or []
|
|
677
|
+
if isinstance(entry, Mapping)
|
|
678
|
+
and any(
|
|
679
|
+
str(alias.get("provider") or "").strip().lower() == provider
|
|
680
|
+
and str(alias.get("alias") or "").strip().lower() == wanted
|
|
681
|
+
and alias.get("channel") == source.channel
|
|
682
|
+
for alias in _current_identity(entry, day)[1]
|
|
683
|
+
)
|
|
684
|
+
]
|
|
685
|
+
|
|
686
|
+
|
|
645
687
|
def _exact_models(
|
|
646
|
-
document: Mapping[str, Any], model_key: str
|
|
688
|
+
document: Mapping[str, Any], model_key: str, day: date
|
|
647
689
|
) -> list[Mapping[str, Any]]:
|
|
648
690
|
publisher, _, _ = _catalog_gateway_identity(model_key).partition("/")
|
|
649
691
|
wanted = {
|
|
@@ -654,17 +696,15 @@ def _exact_models(
|
|
|
654
696
|
for entry in document.get("models") or []:
|
|
655
697
|
if not isinstance(entry, Mapping):
|
|
656
698
|
continue
|
|
657
|
-
|
|
699
|
+
canonical_id, aliases = _current_identity(entry, day)
|
|
700
|
+
names = {canonical_id} if canonical_id else set()
|
|
658
701
|
names.update(
|
|
659
|
-
str(alias.get("alias") or "").strip().lower()
|
|
660
|
-
for alias in entry.get("aliases") or []
|
|
661
|
-
if isinstance(alias, Mapping)
|
|
702
|
+
str(alias.get("alias") or "").strip().lower() for alias in aliases
|
|
662
703
|
)
|
|
663
704
|
names.update(
|
|
664
705
|
f"{publisher}/{str(alias.get('alias') or '').strip().lower()}"
|
|
665
|
-
for alias in
|
|
666
|
-
if
|
|
667
|
-
and _catalog_publisher(alias.get("provider")) == publisher
|
|
706
|
+
for alias in aliases
|
|
707
|
+
if _catalog_publisher(alias.get("provider")) == publisher
|
|
668
708
|
)
|
|
669
709
|
if wanted & names:
|
|
670
710
|
found.append(entry)
|
|
@@ -672,21 +712,21 @@ def _exact_models(
|
|
|
672
712
|
|
|
673
713
|
|
|
674
714
|
def _near_models(
|
|
675
|
-
document: Mapping[str, Any], model_key: str
|
|
715
|
+
document: Mapping[str, Any], model_key: str, day: date
|
|
676
716
|
) -> list[Mapping[str, Any]]:
|
|
677
717
|
wanted = _identity_shape(model_key)
|
|
678
718
|
found = []
|
|
679
719
|
for entry in document.get("models") or []:
|
|
680
720
|
if not isinstance(entry, Mapping):
|
|
681
721
|
continue
|
|
682
|
-
|
|
722
|
+
canonical_id, aliases = _current_identity(entry, day)
|
|
723
|
+
shapes = {_identity_shape(canonical_id)} if canonical_id else set()
|
|
683
724
|
shapes.update(
|
|
684
725
|
_identity_shape(
|
|
685
726
|
f"{_catalog_publisher(alias.get('provider'))}/"
|
|
686
727
|
f"{str(alias.get('alias') or '').strip().lower()}"
|
|
687
728
|
)
|
|
688
|
-
for alias in
|
|
689
|
-
if isinstance(alias, Mapping)
|
|
729
|
+
for alias in aliases
|
|
690
730
|
)
|
|
691
731
|
if wanted in shapes:
|
|
692
732
|
found.append(entry)
|
|
@@ -703,9 +743,11 @@ def _gateway_additions(
|
|
|
703
743
|
operations: list[dict[str, Any]] = []
|
|
704
744
|
identity_exclusions: list[dict[str, Any]] = []
|
|
705
745
|
for rate in extraction.rates:
|
|
706
|
-
matches =
|
|
746
|
+
matches = _channel_models(
|
|
747
|
+
document, source, rate.model_key, observed_on
|
|
748
|
+
) or _exact_models(document, rate.model_key, observed_on)
|
|
707
749
|
if not matches:
|
|
708
|
-
near = _near_models(document, rate.model_key)
|
|
750
|
+
near = _near_models(document, rate.model_key, observed_on)
|
|
709
751
|
if near:
|
|
710
752
|
identity_exclusions.append(
|
|
711
753
|
{
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: metergraph-core
|
|
3
|
-
Version: 0.8.
|
|
3
|
+
Version: 0.8.2
|
|
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
|
|
@@ -210,6 +210,11 @@ A call after it ended resolves to that price, stays `priced`, and carries
|
|
|
210
210
|
applied `price.effective_to` says when it ended. A closed price followed by a
|
|
211
211
|
later one never extends, so a gap before a scheduled price stays `unpriced`.
|
|
212
212
|
|
|
213
|
+
An `unpriced` result whose cause is a value you passed, such as an unknown
|
|
214
|
+
channel, provider or model, says why in `warnings`. Each `PricingWarning` has
|
|
215
|
+
a stable `code` and a `message` that may suggest the value you meant. Match on
|
|
216
|
+
`code`; the message text may change.
|
|
217
|
+
|
|
213
218
|
### Costing a provider response
|
|
214
219
|
|
|
215
220
|
`cost_from_response` reads the usage out of a response you already have and
|
|
@@ -351,6 +356,41 @@ effective_from)` on a model the catalog already has:
|
|
|
351
356
|
start on a later UTC date than the row it replaces, for the same reason.
|
|
352
357
|
- `close_price` sets `effective_to` on the open row without a replacement.
|
|
353
358
|
|
|
359
|
+
Catalog operations change which models exist and what calls they match:
|
|
360
|
+
|
|
361
|
+
- `add_model` appends one complete model entry.
|
|
362
|
+
- `add_alias` appends one alias to an existing model. A spelling another
|
|
363
|
+
model already holds is a conflict only while both aliases are in effect.
|
|
364
|
+
- `close_alias` sets `effective_to` on an open alias, named by
|
|
365
|
+
`(canonical_id, provider, alias, channel)`; `expect` is the exact current
|
|
366
|
+
alias. An alias with dates must cite its source, so a close carries
|
|
367
|
+
`source_url` when the alias has none and must not when it has one.
|
|
368
|
+
|
|
369
|
+
A `close_alias`, `close_price`, `add_alias` and `add_price` together move a
|
|
370
|
+
spelling from a duplicate entry to the model it duplicates from a date. Calls
|
|
371
|
+
before that date keep the old model and price, calls from it resolve to the
|
|
372
|
+
surviving model, and the old entry stays for history with nothing in effect:
|
|
373
|
+
|
|
374
|
+
```yaml
|
|
375
|
+
- op: close_alias
|
|
376
|
+
key: {canonical_id: example-host/model-a-instruct, provider: openrouter,
|
|
377
|
+
alias: example-host/model-a-instruct, channel: openrouter-api}
|
|
378
|
+
expect: {provider: openrouter, alias: example-host/model-a-instruct, channel: openrouter-api}
|
|
379
|
+
effective_to: "2026-10-20"
|
|
380
|
+
source_url: "https://..."
|
|
381
|
+
- op: close_price # the duplicate's open openrouter-api row
|
|
382
|
+
- op: add_alias
|
|
383
|
+
key: {canonical_id: example/model-a, provider: openrouter,
|
|
384
|
+
alias: example-host/model-a-instruct, channel: openrouter-api}
|
|
385
|
+
expect: null
|
|
386
|
+
alias: {provider: openrouter, alias: example-host/model-a-instruct,
|
|
387
|
+
channel: openrouter-api, effective_from: "2026-10-20", source_url: "https://..."}
|
|
388
|
+
- op: add_price # the same rate on example/model-a from 2026-10-20
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
Gateway discovery matches a spelling only through aliases in effect on the
|
|
392
|
+
observation date, so it follows a moved spelling to its new model.
|
|
393
|
+
|
|
354
394
|
A price id ends with the UTC date of its row's start. If two rows for one
|
|
355
395
|
model, channel and region would still share an id (possible in a hand-edited
|
|
356
396
|
catalog), each that does not start at UTC midnight also names its UTC start
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{metergraph_core-0.8.0 → metergraph_core-0.8.2}/src/metergraph_core.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|