metergraph-core 0.4.0__tar.gz → 0.5.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 (23) hide show
  1. {metergraph_core-0.4.0/src/metergraph_core.egg-info → metergraph_core-0.5.0}/PKG-INFO +21 -8
  2. {metergraph_core-0.4.0 → metergraph_core-0.5.0}/README.md +20 -7
  3. {metergraph_core-0.4.0 → metergraph_core-0.5.0}/pyproject.toml +1 -1
  4. {metergraph_core-0.4.0 → metergraph_core-0.5.0}/src/metergraph_core/__init__.py +2 -2
  5. {metergraph_core-0.4.0 → metergraph_core-0.5.0}/src/metergraph_core/catalog.py +1 -0
  6. {metergraph_core-0.4.0 → metergraph_core-0.5.0}/src/metergraph_core/catalog_delta.py +212 -17
  7. {metergraph_core-0.4.0 → metergraph_core-0.5.0}/src/metergraph_core/data/prices.yaml +6198 -3
  8. {metergraph_core-0.4.0 → metergraph_core-0.5.0}/src/metergraph_core/data/sources.yaml +20 -0
  9. {metergraph_core-0.4.0 → metergraph_core-0.5.0}/src/metergraph_core/extractors.py +375 -1
  10. {metergraph_core-0.4.0 → metergraph_core-0.5.0}/src/metergraph_core/refresh.py +340 -5
  11. {metergraph_core-0.4.0 → metergraph_core-0.5.0/src/metergraph_core.egg-info}/PKG-INFO +21 -8
  12. {metergraph_core-0.4.0 → metergraph_core-0.5.0}/MANIFEST.in +0 -0
  13. {metergraph_core-0.4.0 → metergraph_core-0.5.0}/setup.cfg +0 -0
  14. {metergraph_core-0.4.0 → metergraph_core-0.5.0}/src/metergraph_core/billing.py +0 -0
  15. {metergraph_core-0.4.0 → metergraph_core-0.5.0}/src/metergraph_core/data/models.yaml +0 -0
  16. {metergraph_core-0.4.0 → metergraph_core-0.5.0}/src/metergraph_core/loader.py +0 -0
  17. {metergraph_core-0.4.0 → metergraph_core-0.5.0}/src/metergraph_core/models.py +0 -0
  18. {metergraph_core-0.4.0 → metergraph_core-0.5.0}/src/metergraph_core/retrieval.py +0 -0
  19. {metergraph_core-0.4.0 → metergraph_core-0.5.0}/src/metergraph_core/sources.py +0 -0
  20. {metergraph_core-0.4.0 → metergraph_core-0.5.0}/src/metergraph_core.egg-info/SOURCES.txt +0 -0
  21. {metergraph_core-0.4.0 → metergraph_core-0.5.0}/src/metergraph_core.egg-info/dependency_links.txt +0 -0
  22. {metergraph_core-0.4.0 → metergraph_core-0.5.0}/src/metergraph_core.egg-info/requires.txt +0 -0
  23. {metergraph_core-0.4.0 → metergraph_core-0.5.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.4.0
3
+ Version: 0.5.0
4
4
  Summary: Reusable MeterGraph catalog and deterministic billing engine
5
5
  License-Expression: Apache-2.0
6
6
  Requires-Python: >=3.10
@@ -229,7 +229,7 @@ A pricing delta proposes changes to a catalog's price rows. It is a change
229
229
  artifact to review and apply, never a catalog to serve.
230
230
 
231
231
  ```yaml
232
- schema_version: 1
232
+ schema_version: 2
233
233
  kind: pricing_delta
234
234
  provenance:
235
235
  observations: [openai-api-pricing@2026-10-20]
@@ -326,12 +326,12 @@ only the labels, headers and key paths that parser relies on, so a price change
326
326
  or a newly listed model keeps the fingerprint and a format change alters it.
327
327
  Extractors never fetch. The package adds no browser or LLM dependency.
328
328
 
329
- ## Refresh and discovery scripts
329
+ ## Pricing maintenance scripts
330
330
 
331
- Two repository scripts run the registry against live sources. Neither holds
332
- credentials, applies a delta, writes the catalog, publishes, or touches GitHub.
331
+ The repository scripts run the registry against live sources without credentials
332
+ or GitHub access.
333
333
 
334
- Neither ever emits, stores or redistributes raw fetched page content. Their
334
+ The fetch-capable scripts never emit, store or redistribute raw page content. Their
335
335
  reports and evidence files hold only structural fingerprints, normalized
336
336
  extracted values (row labels as slugs, parsed amounts, numeric candidates),
337
337
  candidates, operations and short non-verbatim diagnostics. No output may name
@@ -354,7 +354,7 @@ an escalation, and 1 on error.
354
354
  behind a refresh report's escalations (`--escalations`), and new-source content
355
355
  from a file or stdin (`--new-source`, never a URL). It prints evidence for each:
356
356
  candidate and prior values, the reason for review, normalized page facts, and a
357
- proposed delta for changed catalog rows. An optional adapter
357
+ proposed delta for supported catalog changes. An optional adapter
358
358
  (`--llm-adapter module:callable`) is called as
359
359
  `propose(source, content, current_rows)` and returns delta operations. Its
360
360
  output is kept as review evidence only after it passes schema validation and
@@ -362,7 +362,20 @@ encodes as strict JSON. Adapter failures and rejected items are reported with
362
362
  stable codes, never with the adapter's own text. Everything discovery emits
363
363
  requires review.
364
364
 
365
- Both read `<source-id>.*` files from `--content-dir` instead of fetching. The
365
+ For structured gateway feeds, discovery also proposes `add_model` and
366
+ `add_alias` operations when an exact model identity is safe to extend. Free,
367
+ conditional, tiered, regional and unsupported-charge rows remain exclusions.
368
+ After review, `scripts/apply_price_delta.py --reviewed` writes additive operations
369
+ to a new catalog path while preserving existing comments. It rejects in-place
370
+ writes and history-changing operations, then verifies the rendered catalog
371
+ against the pure delta result.
372
+
373
+ Apply overlapping gateway sources sequentially. Run discovery for the next
374
+ source against the prior candidate so a shared new model becomes an alias and
375
+ price on one canonical entry instead of a conflicting second `add_model`.
376
+
377
+ The fetch-capable scripts read `<source-id>.*` files from `--content-dir` instead
378
+ of fetching. The
366
379
  tests use one synthetic fixture per low-risk source, plus OpenAI's, in
367
380
  `tests/fixtures/sources/`, reproducing only the structure its parser reads;
368
381
  the generic discovery parsers are tested on small inline synthetic pages.
@@ -217,7 +217,7 @@ A pricing delta proposes changes to a catalog's price rows. It is a change
217
217
  artifact to review and apply, never a catalog to serve.
218
218
 
219
219
  ```yaml
220
- schema_version: 1
220
+ schema_version: 2
221
221
  kind: pricing_delta
222
222
  provenance:
223
223
  observations: [openai-api-pricing@2026-10-20]
@@ -314,12 +314,12 @@ only the labels, headers and key paths that parser relies on, so a price change
314
314
  or a newly listed model keeps the fingerprint and a format change alters it.
315
315
  Extractors never fetch. The package adds no browser or LLM dependency.
316
316
 
317
- ## Refresh and discovery scripts
317
+ ## Pricing maintenance scripts
318
318
 
319
- Two repository scripts run the registry against live sources. Neither holds
320
- credentials, applies a delta, writes the catalog, publishes, or touches GitHub.
319
+ The repository scripts run the registry against live sources without credentials
320
+ or GitHub access.
321
321
 
322
- Neither ever emits, stores or redistributes raw fetched page content. Their
322
+ The fetch-capable scripts never emit, store or redistribute raw page content. Their
323
323
  reports and evidence files hold only structural fingerprints, normalized
324
324
  extracted values (row labels as slugs, parsed amounts, numeric candidates),
325
325
  candidates, operations and short non-verbatim diagnostics. No output may name
@@ -342,7 +342,7 @@ an escalation, and 1 on error.
342
342
  behind a refresh report's escalations (`--escalations`), and new-source content
343
343
  from a file or stdin (`--new-source`, never a URL). It prints evidence for each:
344
344
  candidate and prior values, the reason for review, normalized page facts, and a
345
- proposed delta for changed catalog rows. An optional adapter
345
+ proposed delta for supported catalog changes. An optional adapter
346
346
  (`--llm-adapter module:callable`) is called as
347
347
  `propose(source, content, current_rows)` and returns delta operations. Its
348
348
  output is kept as review evidence only after it passes schema validation and
@@ -350,7 +350,20 @@ encodes as strict JSON. Adapter failures and rejected items are reported with
350
350
  stable codes, never with the adapter's own text. Everything discovery emits
351
351
  requires review.
352
352
 
353
- Both read `<source-id>.*` files from `--content-dir` instead of fetching. The
353
+ For structured gateway feeds, discovery also proposes `add_model` and
354
+ `add_alias` operations when an exact model identity is safe to extend. Free,
355
+ conditional, tiered, regional and unsupported-charge rows remain exclusions.
356
+ After review, `scripts/apply_price_delta.py --reviewed` writes additive operations
357
+ to a new catalog path while preserving existing comments. It rejects in-place
358
+ writes and history-changing operations, then verifies the rendered catalog
359
+ against the pure delta result.
360
+
361
+ Apply overlapping gateway sources sequentially. Run discovery for the next
362
+ source against the prior candidate so a shared new model becomes an alias and
363
+ price on one canonical entry instead of a conflicting second `add_model`.
364
+
365
+ The fetch-capable scripts read `<source-id>.*` files from `--content-dir` instead
366
+ of fetching. The
354
367
  tests use one synthetic fixture per low-risk source, plus OpenAI's, in
355
368
  `tests/fixtures/sources/`, reproducing only the structure its parser reads;
356
369
  the generic discovery parsers are tested on small inline synthetic pages.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "metergraph-core"
3
- version = "0.4.0"
3
+ version = "0.5.0"
4
4
  description = "Reusable MeterGraph catalog and deterministic billing engine"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -50,7 +50,7 @@ from .retrieval import (
50
50
  RetrievalPrice,
51
51
  )
52
52
  from .catalog_delta import (
53
- PRICING_DELTA_SCHEMA_VERSION,
53
+ CATALOG_DELTA_SCHEMA_VERSION,
54
54
  CatalogDelta,
55
55
  CatalogDeltaConflict,
56
56
  CatalogDeltaError,
@@ -85,6 +85,7 @@ from .sources import (
85
85
 
86
86
  __all__ = [
87
87
  "CatalogDelta",
88
+ "CATALOG_DELTA_SCHEMA_VERSION",
88
89
  "CatalogDeltaConflict",
89
90
  "CatalogDeltaError",
90
91
  "DeltaConflict",
@@ -93,7 +94,6 @@ __all__ = [
93
94
  "Extraction",
94
95
  "LowRiskPolicy",
95
96
  "Observation",
96
- "PRICING_DELTA_SCHEMA_VERSION",
97
97
  "PriceRowKey",
98
98
  "PriceSource",
99
99
  "RefreshResult",
@@ -30,6 +30,7 @@ _DIRECT_CHANNEL_BY_PROVIDER = {
30
30
  "openai": "openai-api",
31
31
  "google": "google-api",
32
32
  "vercel": "vercel-ai-gateway",
33
+ "openrouter": "openrouter-api",
33
34
  "vertex-ai": "google-vertex-ai",
34
35
  "fireworks": "fireworks-api",
35
36
  "bedrock": "aws-bedrock",
@@ -1,9 +1,9 @@
1
- """Pricing deltas: proposed changes to the price rows of a catalog document.
1
+ """Pricing deltas: proposed changes to a catalog document.
2
2
 
3
3
  A delta never serves prices. It names each target row by its identity
4
4
  ``(canonical_id, channel, region, effective_from)``, carries the row's
5
5
  expected current state as a precondition, and carries the new
6
- effective-dated value. Schema version 1 covers price rows on models the
6
+ effective-dated value. The current schema supports price rows on models the
7
7
  catalog already has:
8
8
 
9
9
  - ``add_price`` appends a row; ``expect`` is null because no such row may
@@ -12,6 +12,11 @@ catalog already has:
12
12
  and appends the new row. History is never rewritten.
13
13
  - ``close_price`` closes the open row without a replacement.
14
14
 
15
+ It also supports reviewed catalog expansion:
16
+
17
+ - ``add_model`` appends one complete model entry.
18
+ - ``add_alias`` appends one alias to an existing model.
19
+
15
20
  ``provenance`` references the observations and evidence the delta came
16
21
  from.
17
22
  """
@@ -26,9 +31,11 @@ from typing import Any, Mapping
26
31
 
27
32
  from .loader import CatalogError, _canonical_json, load_catalog_document
28
33
 
29
- PRICING_DELTA_SCHEMA_VERSION = 1
34
+ CATALOG_DELTA_SCHEMA_VERSION = 2
30
35
  PRICING_DELTA_KIND = "pricing_delta"
31
- OPERATIONS = ("add_price", "replace_price", "close_price")
36
+ PRICE_OPERATIONS = ("add_price", "replace_price", "close_price")
37
+ CATALOG_OPERATIONS = ("add_model", "add_alias")
38
+ OPERATIONS = PRICE_OPERATIONS + CATALOG_OPERATIONS
32
39
 
33
40
  RATE_FIELDS = (
34
41
  "input_per_mtok",
@@ -58,13 +65,29 @@ class PriceRowKey:
58
65
  effective_from: str
59
66
 
60
67
 
68
+ @dataclass(frozen=True, slots=True)
69
+ class ModelKey:
70
+ canonical_id: str
71
+
72
+
73
+ @dataclass(frozen=True, slots=True)
74
+ class AliasKey:
75
+ canonical_id: str
76
+ provider: str
77
+ alias: str
78
+ channel: str
79
+
80
+
81
+ OperationKey = PriceRowKey | ModelKey | AliasKey
82
+
83
+
61
84
  @dataclass(frozen=True, slots=True)
62
85
  class DeltaConflict:
63
86
  """One operation whose precondition does not hold against the document."""
64
87
 
65
88
  index: int
66
89
  op: str
67
- key: PriceRowKey
90
+ key: OperationKey
68
91
  reason: str
69
92
  detail: str = ""
70
93
 
@@ -75,8 +98,7 @@ class CatalogDeltaConflict(CatalogDeltaError):
75
98
  def __init__(self, conflicts: tuple[DeltaConflict, ...]) -> None:
76
99
  self.conflicts = conflicts
77
100
  summary = "; ".join(
78
- f"operation {c.index} {c.op} {c.key.canonical_id} "
79
- f"{c.key.channel}/{c.key.region}@{c.key.effective_from}: {c.reason}"
101
+ f"operation {c.index} {c.op} {_key_label(c.key)}: {c.reason}"
80
102
  + (f" ({c.detail})" if c.detail else "")
81
103
  for c in conflicts
82
104
  )
@@ -87,13 +109,17 @@ class CatalogDeltaConflict(CatalogDeltaError):
87
109
  class DeltaOperation:
88
110
  index: int
89
111
  op: str
90
- key: PriceRowKey
112
+ key: OperationKey
91
113
  # The exact current row for replace/close; None for add.
92
114
  expect: Mapping[str, Any] | None
93
115
  # The new row for add/replace; None for close.
94
116
  price: Mapping[str, Any] | None
95
117
  # The closing date for close_price.
96
118
  effective_to: str | None
119
+ # The complete entry for add_model.
120
+ model: Mapping[str, Any] | None = None
121
+ # The alias row for add_alias.
122
+ alias: Mapping[str, Any] | None = None
97
123
 
98
124
 
99
125
  @dataclass(frozen=True, slots=True)
@@ -161,6 +187,31 @@ def _parse_key(raw: Any, label: str) -> PriceRowKey:
161
187
  )
162
188
 
163
189
 
190
+ def _parse_model_key(raw: Any, label: str) -> ModelKey:
191
+ if not isinstance(raw, Mapping):
192
+ raise CatalogDeltaError(f"{label}: key must be a mapping")
193
+ if set(raw) != {"canonical_id"} or _text(raw.get("canonical_id")) is None:
194
+ raise CatalogDeltaError(f"{label}: add_model key needs only canonical_id")
195
+ return ModelKey(_text(raw["canonical_id"]))
196
+
197
+
198
+ def _parse_alias_key(raw: Any, label: str) -> AliasKey:
199
+ if not isinstance(raw, Mapping):
200
+ raise CatalogDeltaError(f"{label}: key must be a mapping")
201
+ fields = ("canonical_id", "provider", "alias", "channel")
202
+ for field in fields:
203
+ if _text(raw.get(field)) is None:
204
+ raise CatalogDeltaError(f"{label}: key needs {field}")
205
+ if set(raw) != set(fields):
206
+ raise CatalogDeltaError(f"{label}: add_alias key has unknown fields")
207
+ return AliasKey(
208
+ _text(raw["canonical_id"]),
209
+ _text(raw["provider"]).lower(),
210
+ _text(raw["alias"]).lower(),
211
+ _text(raw["channel"]),
212
+ )
213
+
214
+
164
215
  def _decimal_string(value: Any) -> bool:
165
216
  if not isinstance(value, str):
166
217
  return False
@@ -206,6 +257,62 @@ def _parse_price(raw: Any, key: PriceRowKey, label: str, *, same_date: bool) ->
206
257
  return price
207
258
 
208
259
 
260
+ def _probe_document(model: Mapping[str, Any]) -> None:
261
+ load_catalog_document(
262
+ {
263
+ "version": "probe",
264
+ "currency": "USD",
265
+ "pricing_verified_at": "2026-10-20",
266
+ "models": [copy.deepcopy(dict(model))],
267
+ }
268
+ )
269
+
270
+
271
+ def _parse_model(raw: Any, key: ModelKey, label: str) -> dict[str, Any]:
272
+ if not isinstance(raw, Mapping):
273
+ raise CatalogDeltaError(f"{label}: invalid model: entry must be a mapping")
274
+ model = copy.deepcopy(dict(raw))
275
+ if _text(model.get("canonical_id")) != key.canonical_id:
276
+ raise CatalogDeltaError(f"{label}: model canonical_id must match key")
277
+ if _text(model.get("publisher")) is None:
278
+ raise CatalogDeltaError(f"{label}: invalid model: entry needs publisher")
279
+ if not isinstance(model.get("aliases"), list) or not model["aliases"]:
280
+ raise CatalogDeltaError(f"{label}: invalid model: entry needs aliases")
281
+ if not isinstance(model.get("prices"), list) or not model["prices"]:
282
+ raise CatalogDeltaError(f"{label}: invalid model: entry needs prices")
283
+ try:
284
+ _probe_document(model)
285
+ except CatalogError as exc:
286
+ raise CatalogDeltaError(f"{label}: invalid model: {exc}") from exc
287
+ return model
288
+
289
+
290
+ def _parse_alias(raw: Any, key: AliasKey, label: str) -> dict[str, Any]:
291
+ if not isinstance(raw, Mapping):
292
+ raise CatalogDeltaError(f"{label}: alias must be a mapping")
293
+ alias = copy.deepcopy(dict(raw))
294
+ for field in ("provider", "alias", "channel"):
295
+ value = _text(alias.get(field))
296
+ if value is None:
297
+ raise CatalogDeltaError(f"{label}: alias needs {field}")
298
+ normalized = value.lower() if field in ("provider", "alias") else value
299
+ if normalized != getattr(key, field):
300
+ raise CatalogDeltaError(f"{label}: alias {field} must match key")
301
+ alias[field] = normalized
302
+ try:
303
+ _probe_document(
304
+ {
305
+ "canonical_id": key.canonical_id,
306
+ "publisher": "probe",
307
+ "aliases": [alias],
308
+ "prices": [],
309
+ }
310
+ )
311
+ except CatalogError as exc:
312
+ raise CatalogDeltaError(f"{label}: invalid alias: {exc}") from exc
313
+ return alias
314
+
315
+
209
316
  def _parse_operation(index: int, raw: Any) -> DeltaOperation:
210
317
  label = f"operation {index}"
211
318
  if not isinstance(raw, Mapping):
@@ -213,16 +320,31 @@ def _parse_operation(index: int, raw: Any) -> DeltaOperation:
213
320
  op = raw.get("op")
214
321
  if op not in OPERATIONS:
215
322
  raise CatalogDeltaError(f"{label}: unknown op {op!r}")
216
- key = _parse_key(raw.get("key"), label)
323
+ if op == "add_model":
324
+ key: OperationKey = _parse_model_key(raw.get("key"), label)
325
+ elif op == "add_alias":
326
+ key = _parse_alias_key(raw.get("key"), label)
327
+ else:
328
+ key = _parse_key(raw.get("key"), label)
217
329
  expect = raw.get("expect")
218
- if op == "add_price":
330
+ if op in ("add_price", "add_model", "add_alias"):
219
331
  if "expect" not in raw or expect is not None:
220
- raise CatalogDeltaError(f"{label}: add_price expect must be null")
332
+ raise CatalogDeltaError(f"{label}: {op} expect must be null")
221
333
  elif not isinstance(expect, Mapping):
222
334
  raise CatalogDeltaError(f"{label}: {op} expect must be the full current row")
223
335
  price = None
224
336
  effective_to = None
225
- if op == "close_price":
337
+ model = None
338
+ alias = None
339
+ if op == "add_model":
340
+ if "price" in raw or "alias" in raw or "effective_to" in raw:
341
+ raise CatalogDeltaError(f"{label}: add_model takes only model")
342
+ model = _parse_model(raw.get("model"), key, label)
343
+ elif op == "add_alias":
344
+ if "price" in raw or "model" in raw or "effective_to" in raw:
345
+ raise CatalogDeltaError(f"{label}: add_alias takes only alias")
346
+ alias = _parse_alias(raw.get("alias"), key, label)
347
+ elif op == "close_price":
226
348
  if "price" in raw:
227
349
  raise CatalogDeltaError(f"{label}: close_price takes no price")
228
350
  effective_to = _iso_date(raw.get("effective_to"))
@@ -243,16 +365,18 @@ def _parse_operation(index: int, raw: Any) -> DeltaOperation:
243
365
  expect=copy.deepcopy(dict(expect)) if expect is not None else None,
244
366
  price=price,
245
367
  effective_to=effective_to,
368
+ model=model,
369
+ alias=alias,
246
370
  )
247
371
 
248
372
 
249
373
  def parse_catalog_delta(delta_document: Mapping[str, Any]) -> CatalogDelta:
250
- """Validate a delta document against schema version 1 without applying
374
+ """Validate a delta document without applying
251
375
  it. Raises :class:`CatalogDeltaError` naming the first malformed part."""
252
376
  if not isinstance(delta_document, Mapping):
253
377
  raise CatalogDeltaError("delta document must be a mapping")
254
378
  version = delta_document.get("schema_version")
255
- if version != PRICING_DELTA_SCHEMA_VERSION:
379
+ if version != CATALOG_DELTA_SCHEMA_VERSION:
256
380
  raise CatalogDeltaError(f"unsupported delta schema_version {version}")
257
381
  if delta_document.get("kind") != PRICING_DELTA_KIND:
258
382
  raise CatalogDeltaError(f"delta kind must be {PRICING_DELTA_KIND}")
@@ -267,12 +391,14 @@ def parse_catalog_delta(delta_document: Mapping[str, Any]) -> CatalogDelta:
267
391
  if not isinstance(raw_operations, list):
268
392
  raise CatalogDeltaError("delta operations must be a list")
269
393
  operations: list[DeltaOperation] = []
270
- seen: set[PriceRowKey] = set()
394
+ seen: set[tuple[str, OperationKey]] = set()
271
395
  for index, raw in enumerate(raw_operations):
272
396
  operation = _parse_operation(index, raw)
273
- if operation.key in seen:
397
+ family = "price" if operation.op in PRICE_OPERATIONS else operation.op
398
+ target = (family, operation.key)
399
+ if target in seen:
274
400
  raise CatalogDeltaError(f"operation {index}: duplicate target")
275
- seen.add(operation.key)
401
+ seen.add(target)
276
402
  operations.append(operation)
277
403
  return CatalogDelta(
278
404
  schema_version=version,
@@ -322,8 +448,66 @@ def _locate(
322
448
  return models[0], rows, None
323
449
 
324
450
 
451
+ def _model_indexes(document: Mapping[str, Any], canonical_id: str) -> list[int]:
452
+ return [
453
+ index
454
+ for index, entry in enumerate(document.get("models") or [])
455
+ if isinstance(entry, Mapping) and entry.get("canonical_id") == canonical_id
456
+ ]
457
+
458
+
459
+ def _aliases(document: Mapping[str, Any]):
460
+ for entry in document.get("models") or []:
461
+ if not isinstance(entry, Mapping):
462
+ continue
463
+ canonical_id = str(entry.get("canonical_id") or "")
464
+ for alias in entry.get("aliases") or []:
465
+ if not isinstance(alias, Mapping):
466
+ continue
467
+ provider = _text(alias.get("provider"))
468
+ name = _text(alias.get("alias"))
469
+ if provider and name:
470
+ yield canonical_id, provider.lower(), name.lower()
471
+
472
+
473
+ def _key_label(key: OperationKey) -> str:
474
+ if isinstance(key, PriceRowKey):
475
+ return f"{key.canonical_id} {key.channel}/{key.region}@{key.effective_from}"
476
+ if isinstance(key, AliasKey):
477
+ return f"{key.canonical_id} {key.provider}:{key.alias}@{key.channel}"
478
+ return key.canonical_id
479
+
480
+
325
481
  def _precondition(document: Mapping[str, Any], operation: DeltaOperation) -> str | None:
326
482
  """Why ``operation`` does not apply to ``document``, or None when it does."""
483
+ if operation.op == "add_model":
484
+ key = operation.key
485
+ if _model_indexes(document, key.canonical_id):
486
+ return "model_exists"
487
+ known = {(provider, name) for _, provider, name in _aliases(document)}
488
+ for alias in operation.model.get("aliases") or []:
489
+ pair = (
490
+ str(alias.get("provider") or "").strip().lower(),
491
+ str(alias.get("alias") or "").strip().lower(),
492
+ )
493
+ if pair in known:
494
+ return "alias_taken"
495
+ return None
496
+ if operation.op == "add_alias":
497
+ key = operation.key
498
+ models = _model_indexes(document, key.canonical_id)
499
+ if not models:
500
+ return "unknown_model"
501
+ if len(models) > 1:
502
+ return "duplicate_model"
503
+ for canonical_id, provider, name in _aliases(document):
504
+ if (provider, name) == (key.provider, key.alias):
505
+ return (
506
+ "alias_exists"
507
+ if canonical_id == key.canonical_id
508
+ else "alias_conflict"
509
+ )
510
+ return None
327
511
  model_index, rows, reason = _locate(document, operation)
328
512
  if reason is not None:
329
513
  return reason
@@ -389,6 +573,17 @@ def apply_catalog_delta(
389
573
 
390
574
  result = copy.deepcopy(dict(document))
391
575
  for operation in delta.operations:
576
+ if operation.op == "add_model":
577
+ result.setdefault("models", []).append(
578
+ copy.deepcopy(dict(operation.model))
579
+ )
580
+ continue
581
+ if operation.op == "add_alias":
582
+ [model_index] = _model_indexes(result, operation.key.canonical_id)
583
+ result["models"][model_index].setdefault("aliases", []).append(
584
+ copy.deepcopy(dict(operation.alias))
585
+ )
586
+ continue
392
587
  model_index, rows, _ = _locate(result, operation)
393
588
  prices = result["models"][model_index].setdefault("prices", [])
394
589
  if operation.op == "add_price":