unitysvc-core 0.2.27__tar.gz → 0.2.28__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 (45) hide show
  1. {unitysvc_core-0.2.27/src/unitysvc_core.egg-info → unitysvc_core-0.2.28}/PKG-INFO +1 -1
  2. {unitysvc_core-0.2.27 → unitysvc_core-0.2.28}/pyproject.toml +1 -1
  3. {unitysvc_core-0.2.27 → unitysvc_core-0.2.28}/src/unitysvc_core/models/__init__.py +4 -0
  4. {unitysvc_core-0.2.27 → unitysvc_core-0.2.28}/src/unitysvc_core/models/pricing.py +286 -0
  5. {unitysvc_core-0.2.27 → unitysvc_core-0.2.28/src/unitysvc_core.egg-info}/PKG-INFO +1 -1
  6. {unitysvc_core-0.2.27 → unitysvc_core-0.2.28}/src/unitysvc_core.egg-info/SOURCES.txt +1 -0
  7. unitysvc_core-0.2.28/tests/test_effective_price.py +284 -0
  8. {unitysvc_core-0.2.27 → unitysvc_core-0.2.28}/LICENSE +0 -0
  9. {unitysvc_core-0.2.27 → unitysvc_core-0.2.28}/README.md +0 -0
  10. {unitysvc_core-0.2.27 → unitysvc_core-0.2.28}/setup.cfg +0 -0
  11. {unitysvc_core-0.2.27 → unitysvc_core-0.2.28}/src/unitysvc_core/__init__.py +0 -0
  12. {unitysvc_core-0.2.27 → unitysvc_core-0.2.28}/src/unitysvc_core/models/base.py +0 -0
  13. {unitysvc_core-0.2.27 → unitysvc_core-0.2.28}/src/unitysvc_core/models/documents.py +0 -0
  14. {unitysvc_core-0.2.27 → unitysvc_core-0.2.28}/src/unitysvc_core/models/listing_data.py +0 -0
  15. {unitysvc_core-0.2.27 → unitysvc_core-0.2.28}/src/unitysvc_core/models/listing_v1.py +0 -0
  16. {unitysvc_core-0.2.27 → unitysvc_core-0.2.28}/src/unitysvc_core/models/offering_data.py +0 -0
  17. {unitysvc_core-0.2.27 → unitysvc_core-0.2.28}/src/unitysvc_core/models/offering_v1.py +0 -0
  18. {unitysvc_core-0.2.27 → unitysvc_core-0.2.28}/src/unitysvc_core/models/promotion_data.py +0 -0
  19. {unitysvc_core-0.2.27 → unitysvc_core-0.2.28}/src/unitysvc_core/models/provider_data.py +0 -0
  20. {unitysvc_core-0.2.27 → unitysvc_core-0.2.28}/src/unitysvc_core/models/provider_v1.py +0 -0
  21. {unitysvc_core-0.2.27 → unitysvc_core-0.2.28}/src/unitysvc_core/models/service.py +0 -0
  22. {unitysvc_core-0.2.27 → unitysvc_core-0.2.28}/src/unitysvc_core/models/service_group_data.py +0 -0
  23. {unitysvc_core-0.2.27 → unitysvc_core-0.2.28}/src/unitysvc_core/models/validators.py +0 -0
  24. {unitysvc_core-0.2.27 → unitysvc_core-0.2.28}/src/unitysvc_core/py.typed +0 -0
  25. {unitysvc_core-0.2.27 → unitysvc_core-0.2.28}/src/unitysvc_core/schema/base.json +0 -0
  26. {unitysvc_core-0.2.27 → unitysvc_core-0.2.28}/src/unitysvc_core/schema/listing_v1.json +0 -0
  27. {unitysvc_core-0.2.27 → unitysvc_core-0.2.28}/src/unitysvc_core/schema/offering_v1.json +0 -0
  28. {unitysvc_core-0.2.27 → unitysvc_core-0.2.28}/src/unitysvc_core/schema/promotion_v1.json +0 -0
  29. {unitysvc_core-0.2.27 → unitysvc_core-0.2.28}/src/unitysvc_core/schema/provider_v1.json +0 -0
  30. {unitysvc_core-0.2.27 → unitysvc_core-0.2.28}/src/unitysvc_core/schema/service_group_v1.json +0 -0
  31. {unitysvc_core-0.2.27 → unitysvc_core-0.2.28}/src/unitysvc_core/utils.py +0 -0
  32. {unitysvc_core-0.2.27 → unitysvc_core-0.2.28}/src/unitysvc_core/validator.py +0 -0
  33. {unitysvc_core-0.2.27 → unitysvc_core-0.2.28}/src/unitysvc_core.egg-info/dependency_links.txt +0 -0
  34. {unitysvc_core-0.2.27 → unitysvc_core-0.2.28}/src/unitysvc_core.egg-info/requires.txt +0 -0
  35. {unitysvc_core-0.2.27 → unitysvc_core-0.2.28}/src/unitysvc_core.egg-info/top_level.txt +0 -0
  36. {unitysvc_core-0.2.27 → unitysvc_core-0.2.28}/tests/test_access_interface_routing.py +0 -0
  37. {unitysvc_core-0.2.27 → unitysvc_core-0.2.28}/tests/test_hidden_path_skip.py +0 -0
  38. {unitysvc_core-0.2.27 → unitysvc_core-0.2.28}/tests/test_offering_data.py +0 -0
  39. {unitysvc_core-0.2.27 → unitysvc_core-0.2.28}/tests/test_pricing.py +0 -0
  40. {unitysvc_core-0.2.27 → unitysvc_core-0.2.28}/tests/test_provider_account_rate_limits.py +0 -0
  41. {unitysvc_core-0.2.27 → unitysvc_core-0.2.28}/tests/test_service_group.py +0 -0
  42. {unitysvc_core-0.2.27 → unitysvc_core-0.2.28}/tests/test_service_status.py +0 -0
  43. {unitysvc_core-0.2.27 → unitysvc_core-0.2.28}/tests/test_utils.py +0 -0
  44. {unitysvc_core-0.2.27 → unitysvc_core-0.2.28}/tests/test_validator.py +0 -0
  45. {unitysvc_core-0.2.27 → unitysvc_core-0.2.28}/tests/test_validators.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: unitysvc-core
3
- Version: 0.2.27
3
+ Version: 0.2.28
4
4
  Summary: Shared data models and validation helpers for UnitySVC packages
5
5
  Author-email: Bo Peng <bo.peng@unitysvc.com>
6
6
  Maintainer-email: Bo Peng <bo.peng@unitysvc.com>
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "unitysvc-core"
7
- version = "0.2.27"
7
+ version = "0.2.28"
8
8
  description = "Shared data models and validation helpers for UnitySVC packages"
9
9
  readme = "README.md"
10
10
  authors = [{ name = "Bo Peng", email = "bo.peng@unitysvc.com" }]
@@ -38,6 +38,7 @@ from .pricing import (
38
38
  ConstantPriceData,
39
39
  CountPriceData,
40
40
  DataPriceData,
41
+ EffectivePrice,
41
42
  ExprPriceData,
42
43
  FirstPriceData,
43
44
  GraduatedPriceData,
@@ -56,6 +57,7 @@ from .pricing import (
56
57
  TimePriceData,
57
58
  TokenPriceData,
58
59
  UsageData,
60
+ upstream_price_from_description,
59
61
  validate_pricing,
60
62
  )
61
63
  from .promotion_data import (
@@ -146,6 +148,8 @@ __all__ = [
146
148
  "Pricing",
147
149
  "validate_pricing",
148
150
  "BasePriceData",
151
+ "EffectivePrice",
152
+ "upstream_price_from_description",
149
153
  # Pricing — simple types
150
154
  "TokenPriceData",
151
155
  "TimePriceData",
@@ -13,6 +13,8 @@ from __future__ import annotations
13
13
 
14
14
  import ast
15
15
  import operator
16
+ import re
17
+ from collections.abc import Callable, Iterable
16
18
  from decimal import Decimal, InvalidOperation
17
19
  from typing import Annotated, Any, Literal
18
20
 
@@ -171,6 +173,175 @@ class UsageData(BaseModel):
171
173
  one_million: float | None = None
172
174
 
173
175
 
176
+ # ============================================================================
177
+ # Effective price — a per-unit comparison figure, never billed
178
+ # ============================================================================
179
+
180
+ #: Normalized units an effective price is expressed in. Each dimension has one,
181
+ #: so figures of the same dimension always compare directly.
182
+ UNIT_TOKENS = "one_million_tokens"
183
+ UNIT_REQUEST = "request"
184
+ UNIT_TIME = "one_hour"
185
+ UNIT_DATA = "one_gigabyte"
186
+ UNIT_CHARACTERS = "one_million_characters"
187
+ UNIT_IMAGE = "image"
188
+ UNIT_STEP = "step"
189
+
190
+ #: Each measured equivalence group's normalized unit (counts compare per request).
191
+ _GROUP_UNIT = {
192
+ "tokens": UNIT_TOKENS,
193
+ "time": UNIT_TIME,
194
+ "data": UNIT_DATA,
195
+ "characters": UNIT_CHARACTERS,
196
+ }
197
+
198
+ #: When figures in different units must be reduced to one (``max``, ``min``,
199
+ #: ``tiered``, ``channel``), the first unit present in this order wins; any
200
+ #: other unit follows alphabetically.
201
+ _UNIT_PRIORITY = (UNIT_TOKENS, UNIT_REQUEST)
202
+
203
+ #: Output tokens weigh 4x input: typical chat traffic, and the platform's
204
+ #: long-standing blend convention.
205
+ _OUTPUT_WEIGHT = Decimal(4)
206
+
207
+
208
+ class EffectivePrice(BaseModel):
209
+ """What using a service realistically costs per unit, for comparison.
210
+
211
+ A best-effort estimate for sorting, filtering and routing — never billed.
212
+ ``price`` (when set) is what is charged; this is computed from the pricing
213
+ object by ``effective_price()`` and never written back into it.
214
+ """
215
+
216
+ model_config = ConfigDict(extra="forbid", frozen=True)
217
+
218
+ amount: Decimal = Field(description="Cost per one ``unit``")
219
+ unit: str = Field(
220
+ description="Normalized unit: 'one_million_tokens', 'request', 'one_hour', 'one_gigabyte', "
221
+ "'one_million_characters', 'image' or 'step'",
222
+ )
223
+ source: Literal["rate", "upstream"] = Field(
224
+ default="rate",
225
+ description="'rate': from the price itself. 'upstream': a $0 price whose description states what the "
226
+ "customer pays the upstream provider (bring-your-own-key).",
227
+ )
228
+
229
+
230
+ def _blend(tokens_in: Decimal, tokens_out: Decimal) -> Decimal:
231
+ return (tokens_in + _OUTPUT_WEIGHT * tokens_out) / (1 + _OUTPUT_WEIGHT)
232
+
233
+
234
+ def _unit_rank(unit: str) -> tuple[int, str]:
235
+ return (_UNIT_PRIORITY.index(unit), "") if unit in _UNIT_PRIORITY else (len(_UNIT_PRIORITY), unit)
236
+
237
+
238
+ def _child_effective_price(price_data: Any) -> EffectivePrice | None:
239
+ """The effective price of a nested pricing dict; None if it is unknown or invalid."""
240
+ if not isinstance(price_data, dict):
241
+ return None
242
+ try:
243
+ return validate_pricing(price_data).effective_price()
244
+ except Exception:
245
+ return None
246
+
247
+
248
+ def _pick(
249
+ figures: Iterable[EffectivePrice | None],
250
+ choose: Callable[[Iterable[EffectivePrice]], EffectivePrice],
251
+ ) -> EffectivePrice | None:
252
+ """Reduce known figures to one, comparing only within the dominant unit."""
253
+ known = [f for f in figures if f is not None]
254
+ if not known:
255
+ return None
256
+ unit = min((f.unit for f in known), key=_unit_rank)
257
+ return choose(f for f in known if f.unit == unit)
258
+
259
+
260
+ def _largest(figures: Iterable[EffectivePrice | None]) -> EffectivePrice | None:
261
+ return _pick(figures, lambda fs: max(fs, key=lambda f: f.amount))
262
+
263
+
264
+ def _smallest(figures: Iterable[EffectivePrice | None]) -> EffectivePrice | None:
265
+ return _pick(figures, lambda fs: min(fs, key=lambda f: f.amount))
266
+
267
+
268
+ def _rate(amount: Decimal, unit: str) -> EffectivePrice:
269
+ return EffectivePrice(amount=amount, unit=unit)
270
+
271
+
272
+ def _per_normalized_unit(metric: str, unit_price: Decimal) -> EffectivePrice | None:
273
+ """Express a price per one ``metric`` in the metric dimension's normalized unit.
274
+
275
+ ``metric`` is a pricing type or a ``based_on`` name: an equivalence-group
276
+ unit, a token counter, ``request_count``, or a gateway byte counter. Any
277
+ other metric (``customer_charge``, an expression) has no unit: None.
278
+ """
279
+ if metric in ("input_tokens", "output_tokens", "cached_input_tokens", "total_tokens"):
280
+ metric = "one_token"
281
+ elif metric in ("bytes_in", "bytes_out", "bytes_total"):
282
+ metric = "one_byte"
283
+ if metric == "request_count":
284
+ return _rate(unit_price, UNIT_REQUEST)
285
+ if metric == "images_generated":
286
+ return _rate(unit_price, UNIT_IMAGE)
287
+ if metric not in _UNIT_TO_GROUP:
288
+ return None
289
+ group, factor = _UNIT_TO_GROUP[metric]
290
+ if group == "count":
291
+ # One count is one call, event or message: compared per request.
292
+ return _rate(unit_price / factor, UNIT_REQUEST)
293
+ target = _GROUP_UNIT[group]
294
+ return _rate(unit_price * EQUIVALENCE_GROUPS[group][target] / factor, target)
295
+
296
+
297
+ _DOLLARS = r"\$\s*\d+(?:\.\d+)?"
298
+ # "$1.4 / $4.4 per 1M input/output tokens", "$1.518/$4.554 / 1M input/output
299
+ # tokens", "$4 / $20 / $0.2 per 1M input/output/cached tokens",
300
+ # "$3.45/$0.345/$17.25 / 1M in/cached/out tokens", "$0.50 / 1M tokens".
301
+ _UPSTREAM_TOKEN_PRICE = re.compile(
302
+ rf"(?P<amounts>{_DOLLARS}(?:\s*/\s*{_DOLLARS})*)\s*(?:/|per)\s*1\s*(?P<scale>[MK])\s+"
303
+ r"(?:(?P<labels>[a-z_]+(?:\s*/\s*[a-z_]+)*)\s+)?tokens\b",
304
+ re.IGNORECASE,
305
+ )
306
+ _UPSTREAM_LABELS = {
307
+ "input": "in",
308
+ "in": "in",
309
+ "output": "out",
310
+ "out": "out",
311
+ "cached": "cached",
312
+ "cache": "cached",
313
+ "cached_input": "cached",
314
+ }
315
+
316
+
317
+ def upstream_price_from_description(text: str | None) -> Decimal | None:
318
+ """The upstream price per 1M tokens a description states, blended; or None.
319
+
320
+ Bring-your-own-key prices are ``0`` (we bill nothing) and their generators
321
+ write what the provider charges into the description, e.g. "billed by
322
+ Hugging Face directly at $1.4 / $4.4 per 1M input/output tokens".
323
+
324
+ Fails closed: text not in a known shape (labels that don't match the
325
+ amounts, no input/output pair, no token unit) is None, never 0.
326
+ """
327
+ if not text:
328
+ return None
329
+ match = _UPSTREAM_TOKEN_PRICE.search(text)
330
+ if match is None:
331
+ return None
332
+ amounts = [Decimal(a) for a in re.findall(r"\d+(?:\.\d+)?", match["amounts"])]
333
+ scale = Decimal(1000) if match["scale"].upper() == "K" else Decimal(1)
334
+ if not match["labels"]:
335
+ return amounts[0] * scale if len(amounts) == 1 else None
336
+ labels = [_UPSTREAM_LABELS.get(label.strip().lower()) for label in match["labels"].split("/")]
337
+ if len(labels) != len(amounts) or None in labels:
338
+ return None
339
+ by_label = dict(zip(labels, amounts, strict=True))
340
+ if "in" not in by_label or "out" not in by_label:
341
+ return None
342
+ return _blend(by_label["in"], by_label["out"]) * scale
343
+
344
+
174
345
  # ============================================================================
175
346
  # Pricing Models - Discriminated Union for type-safe pricing validation
176
347
  # ============================================================================
@@ -207,6 +378,15 @@ class BasePriceData(BaseModel):
207
378
  description="URL to upstream provider's pricing page",
208
379
  )
209
380
 
381
+ def effective_price(self) -> EffectivePrice | None:
382
+ """What using this price realistically costs per unit, or None if unknown.
383
+
384
+ A comparison figure for sorting, filtering and routing; never billed.
385
+ Every pricing type defines it, so a composite price computes its figure
386
+ from its children. Unknown is never 0. Never raises.
387
+ """
388
+ return None
389
+
210
390
 
211
391
  class TokenPriceData(BasePriceData):
212
392
  """
@@ -294,6 +474,16 @@ class TokenPriceData(BasePriceData):
294
474
  """Token divisor based on the pricing type."""
295
475
  return {"one_million_tokens": 1_000_000, "one_thousand_tokens": 1_000, "one_token": 1}[self.type]
296
476
 
477
+ def effective_price(self) -> EffectivePrice | None:
478
+ """``(input + 4*output) / 5`` when priced separately, else ``price``; per 1M tokens."""
479
+ if self.input is not None and self.output is not None:
480
+ per_unit = _blend(Decimal(self.input), Decimal(self.output))
481
+ elif self.price is not None:
482
+ per_unit = Decimal(self.price)
483
+ else:
484
+ return None
485
+ return _per_normalized_unit(self.type, per_unit)
486
+
297
487
  def calculate_cost(
298
488
  self,
299
489
  usage: UsageData,
@@ -361,6 +551,10 @@ class TimePriceData(BasePriceData):
361
551
  description="Price per one unit of the specified type",
362
552
  )
363
553
 
554
+ def effective_price(self) -> EffectivePrice | None:
555
+ """``price`` in the dimension's normalized unit."""
556
+ return _per_normalized_unit(self.type, Decimal(self.price))
557
+
364
558
  def calculate_cost(
365
559
  self,
366
560
  usage: UsageData,
@@ -402,6 +596,10 @@ class ImagePriceData(BasePriceData):
402
596
  description="Price per image",
403
597
  )
404
598
 
599
+ def effective_price(self) -> EffectivePrice | None:
600
+ """``price`` per image."""
601
+ return _rate(Decimal(self.price), UNIT_IMAGE)
602
+
405
603
  def calculate_cost(
406
604
  self,
407
605
  usage: UsageData,
@@ -442,6 +640,10 @@ class StepPriceData(BasePriceData):
442
640
  description="Price per step/iteration",
443
641
  )
444
642
 
643
+ def effective_price(self) -> EffectivePrice | None:
644
+ """``price`` per step."""
645
+ return _rate(Decimal(self.price), UNIT_STEP)
646
+
445
647
  def calculate_cost(
446
648
  self,
447
649
  usage: UsageData,
@@ -495,6 +697,10 @@ class DataPriceData(BasePriceData):
495
697
  description="Price per one unit of the specified type",
496
698
  )
497
699
 
700
+ def effective_price(self) -> EffectivePrice | None:
701
+ """``price`` in the dimension's normalized unit."""
702
+ return _per_normalized_unit(self.type, Decimal(self.price))
703
+
498
704
  def calculate_cost(
499
705
  self,
500
706
  usage: UsageData,
@@ -556,6 +762,10 @@ class CountPriceData(BasePriceData):
556
762
  description="Price per one unit of the specified type",
557
763
  )
558
764
 
765
+ def effective_price(self) -> EffectivePrice | None:
766
+ """``price`` in the dimension's normalized unit."""
767
+ return _per_normalized_unit(self.type, Decimal(self.price))
768
+
559
769
  def calculate_cost(
560
770
  self,
561
771
  usage: UsageData,
@@ -602,6 +812,10 @@ class CharacterPriceData(BasePriceData):
602
812
  description="Price per one unit of the specified type",
603
813
  )
604
814
 
815
+ def effective_price(self) -> EffectivePrice | None:
816
+ """``price`` in the dimension's normalized unit."""
817
+ return _per_normalized_unit(self.type, Decimal(self.price))
818
+
605
819
  def calculate_cost(
606
820
  self,
607
821
  usage: UsageData,
@@ -670,6 +884,10 @@ class RevenueSharePriceData(BasePriceData):
670
884
  description="Percentage of customer charge that goes to the seller (0-100)",
671
885
  )
672
886
 
887
+ def effective_price(self) -> EffectivePrice | None:
888
+ """A share of the customer charge has no per-unit rate: unknown."""
889
+ return None
890
+
673
891
  def calculate_cost(
674
892
  self,
675
893
  usage: UsageData,
@@ -710,6 +928,23 @@ class ConstantPriceData(BasePriceData):
710
928
  description="Fixed price (positive for charge, negative for discount)",
711
929
  )
712
930
 
931
+ def effective_price(self) -> EffectivePrice | None:
932
+ """``price`` per request.
933
+
934
+ A ``0`` with a description is a bring-your-own-key price: the customer
935
+ pays the upstream provider, at the rate the description states. A
936
+ description that states no parseable rate, or $0/$0 (what generators
937
+ write when they lack the provider's price), is unknown. A ``0`` with no
938
+ description is genuinely free.
939
+ """
940
+ amount = Decimal(self.price)
941
+ if amount != 0 or not self.description:
942
+ return _rate(amount, UNIT_REQUEST)
943
+ upstream = upstream_price_from_description(self.description)
944
+ if upstream is None or upstream == 0:
945
+ return None
946
+ return EffectivePrice(amount=upstream, unit=UNIT_TOKENS, source="upstream")
947
+
713
948
  def calculate_cost(
714
949
  self,
715
950
  usage: UsageData,
@@ -783,6 +1018,15 @@ class AddPriceData(BasePriceData):
783
1018
  self.price = _extract_nominal_price(self.prices[0])
784
1019
  return self
785
1020
 
1021
+ def effective_price(self) -> EffectivePrice | None:
1022
+ """The sum of the children; unknown if any child is unknown or the units differ."""
1023
+ figures = [_child_effective_price(p) for p in self.prices]
1024
+ known = [f for f in figures if f is not None]
1025
+ if len(known) != len(figures) or len({f.unit for f in known}) != 1:
1026
+ return None
1027
+ source: Literal["rate", "upstream"] = "upstream" if any(f.source == "upstream" for f in known) else "rate"
1028
+ return EffectivePrice(amount=sum((f.amount for f in known), Decimal(0)), unit=known[0].unit, source=source)
1029
+
786
1030
  def calculate_cost(
787
1031
  self,
788
1032
  usage: UsageData,
@@ -843,6 +1087,13 @@ class MultiplyPriceData(BasePriceData):
843
1087
  self.price = base_price
844
1088
  return self
845
1089
 
1090
+ def effective_price(self) -> EffectivePrice | None:
1091
+ """The base's figure times ``factor``."""
1092
+ base = _child_effective_price(self.base)
1093
+ if base is None:
1094
+ return None
1095
+ return base.model_copy(update={"amount": base.amount * Decimal(self.factor)})
1096
+
846
1097
  def calculate_cost(
847
1098
  self,
848
1099
  usage: UsageData,
@@ -897,6 +1148,10 @@ class MaxPriceData(BasePriceData):
897
1148
  self.price = _extract_nominal_price(self.prices[0])
898
1149
  return self
899
1150
 
1151
+ def effective_price(self) -> EffectivePrice | None:
1152
+ """The largest child, as billed."""
1153
+ return _largest(_child_effective_price(p) for p in self.prices)
1154
+
900
1155
  def calculate_cost(
901
1156
  self,
902
1157
  usage: UsageData,
@@ -955,6 +1210,10 @@ class MinPriceData(BasePriceData):
955
1210
  self.price = _extract_nominal_price(self.prices[0])
956
1211
  return self
957
1212
 
1213
+ def effective_price(self) -> EffectivePrice | None:
1214
+ """The smallest child, as billed."""
1215
+ return _smallest(_child_effective_price(p) for p in self.prices)
1216
+
958
1217
  def calculate_cost(
959
1218
  self,
960
1219
  usage: UsageData,
@@ -1012,6 +1271,14 @@ class FirstPriceData(BasePriceData):
1012
1271
  self.price = _extract_nominal_price(self.prices[0])
1013
1272
  return self
1014
1273
 
1274
+ def effective_price(self) -> EffectivePrice | None:
1275
+ """The first child with a known figure, mirroring the billing order."""
1276
+ for price_data in self.prices:
1277
+ figure = _child_effective_price(price_data)
1278
+ if figure is not None:
1279
+ return figure
1280
+ return None
1281
+
1015
1282
  def calculate_cost(
1016
1283
  self,
1017
1284
  usage: UsageData,
@@ -1246,6 +1513,10 @@ class ExprPriceData(BasePriceData):
1246
1513
  description="Arithmetic expression using usage metrics (e.g., 'input_tokens / 1000000 * 2.5')",
1247
1514
  )
1248
1515
 
1516
+ def effective_price(self) -> EffectivePrice | None:
1517
+ """An arbitrary expression has no per-unit rate: unknown."""
1518
+ return None
1519
+
1249
1520
  def calculate_cost(
1250
1521
  self,
1251
1522
  usage: UsageData,
@@ -1320,6 +1591,10 @@ class TieredPriceData(BasePriceData):
1320
1591
  self.price = _extract_nominal_price(first_tier.price)
1321
1592
  return self
1322
1593
 
1594
+ def effective_price(self) -> EffectivePrice | None:
1595
+ """The largest tier's figure: the worst case a customer can pay."""
1596
+ return _largest(_child_effective_price(tier.price) for tier in self.tiers)
1597
+
1323
1598
  def calculate_cost(
1324
1599
  self,
1325
1600
  usage: UsageData,
@@ -1403,6 +1678,13 @@ class GraduatedPriceData(BasePriceData):
1403
1678
  self.price = self.tiers[0].unit_price
1404
1679
  return self
1405
1680
 
1681
+ def effective_price(self) -> EffectivePrice | None:
1682
+ """The largest ``unit_price`` (the worst case), per ``based_on``'s normalized unit.
1683
+
1684
+ Unknown when ``based_on`` has no unit (``customer_charge``, an expression).
1685
+ """
1686
+ return _per_normalized_unit(self.based_on, max(Decimal(tier.unit_price) for tier in self.tiers))
1687
+
1406
1688
  def calculate_cost(
1407
1689
  self,
1408
1690
  usage: UsageData,
@@ -1526,6 +1808,10 @@ class ChannelPriceData(BasePriceData):
1526
1808
  self.description = f"{lo} - {hi}"
1527
1809
  return self
1528
1810
 
1811
+ def effective_price(self) -> EffectivePrice | None:
1812
+ """The largest figure among all channels: the worst case a customer can pay."""
1813
+ return _largest(_child_effective_price(p) for p in self.channels.values())
1814
+
1529
1815
  def calculate_cost(
1530
1816
  self,
1531
1817
  usage: UsageData,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: unitysvc-core
3
- Version: 0.2.27
3
+ Version: 0.2.28
4
4
  Summary: Shared data models and validation helpers for UnitySVC packages
5
5
  Author-email: Bo Peng <bo.peng@unitysvc.com>
6
6
  Maintainer-email: Bo Peng <bo.peng@unitysvc.com>
@@ -31,6 +31,7 @@ src/unitysvc_core/schema/promotion_v1.json
31
31
  src/unitysvc_core/schema/provider_v1.json
32
32
  src/unitysvc_core/schema/service_group_v1.json
33
33
  tests/test_access_interface_routing.py
34
+ tests/test_effective_price.py
34
35
  tests/test_hidden_path_skip.py
35
36
  tests/test_offering_data.py
36
37
  tests/test_pricing.py
@@ -0,0 +1,284 @@
1
+ """Tests for ``effective_price()``: the per-unit comparison figure every pricing type defines."""
2
+
3
+ import typing
4
+ from decimal import Decimal
5
+ from typing import Any
6
+
7
+ import pytest
8
+
9
+ from unitysvc_core.models.pricing import (
10
+ BasePriceData,
11
+ EffectivePrice,
12
+ Pricing,
13
+ upstream_price_from_description,
14
+ validate_pricing,
15
+ )
16
+
17
+
18
+ def ep(data: dict[str, Any]) -> EffectivePrice | None:
19
+ return validate_pricing(data).effective_price()
20
+
21
+
22
+ def rate(amount: str, unit: str, source: str = "rate") -> EffectivePrice:
23
+ return EffectivePrice(amount=Decimal(amount), unit=unit, source=source) # type: ignore[arg-type]
24
+
25
+
26
+ TOKENS = {"type": "one_million_tokens", "input": "1", "output": "2"} # blend 1.8
27
+ CHEAP_TOKENS = {"type": "one_million_tokens", "price": "0.5"}
28
+ PER_REQUEST = {"type": "constant", "price": "0.01"}
29
+ HF_UPSTREAM = "billed by Hugging Face directly at $1.4 / $4.4 per 1M input/output tokens"
30
+ BYOK = {"type": "constant", "price": "0", "description": HF_UPSTREAM}
31
+ NO_AMOUNT = {"type": "constant", "price": "0", "description": "Bring your own key"}
32
+
33
+
34
+ def test_every_pricing_type_defines_its_own_effective_price() -> None:
35
+ members = typing.get_args(typing.get_args(Pricing)[0])
36
+ assert len(members) == 18
37
+ for cls in members:
38
+ assert "effective_price" in vars(cls), f"{cls.__name__} does not define effective_price()"
39
+ assert BasePriceData().effective_price() is None
40
+
41
+
42
+ class TestSimpleTypes:
43
+ def test_token_split_is_blended(self) -> None:
44
+ assert ep({"type": "one_million_tokens", "input": "1.4", "output": "4.4"}) == rate("3.8", "one_million_tokens")
45
+
46
+ def test_token_split_ignores_cached_input(self) -> None:
47
+ figure = ep({"type": "one_million_tokens", "input": "1", "cached_input": "0.1", "output": "2"})
48
+ assert figure == rate("1.8", "one_million_tokens")
49
+
50
+ def test_token_unified(self) -> None:
51
+ assert ep(CHEAP_TOKENS) == rate("0.5", "one_million_tokens")
52
+
53
+ @pytest.mark.parametrize(
54
+ ("kind", "price", "per_million"),
55
+ [("one_thousand_tokens", "0.002", "2"), ("one_token", "0.000003", "3")],
56
+ )
57
+ def test_token_scales_normalize_to_per_million(self, kind: str, price: str, per_million: str) -> None:
58
+ assert ep({"type": kind, "price": price}) == rate(per_million, "one_million_tokens")
59
+
60
+ def test_count_is_per_request(self) -> None:
61
+ assert ep({"type": "one_thousand", "price": "10"}) == rate("0.01", "request")
62
+ assert ep({"type": "one_million", "price": "2"}) == rate("0.000002", "request")
63
+
64
+ @pytest.mark.parametrize(
65
+ ("kind", "price", "per_hour"),
66
+ [
67
+ ("one_second", "0.001", "3.6"),
68
+ ("one_minute", "0.006", "0.36"),
69
+ ("one_hour", "2", "2"),
70
+ ("one_day", "24", "1"),
71
+ ],
72
+ )
73
+ def test_time_normalizes_to_per_hour(self, kind: str, price: str, per_hour: str) -> None:
74
+ assert ep({"type": kind, "price": price}) == rate(per_hour, "one_hour")
75
+
76
+ def test_data_normalizes_to_per_gigabyte(self) -> None:
77
+ assert ep({"type": "one_megabyte", "price": "0.001"}) == rate("1.024", "one_gigabyte")
78
+ assert ep({"type": "one_gigabyte", "price": "0.09"}) == rate("0.09", "one_gigabyte")
79
+
80
+ def test_characters_normalize_to_per_million(self) -> None:
81
+ assert ep({"type": "one_thousand_characters", "price": "0.015"}) == rate("15", "one_million_characters")
82
+
83
+ def test_image_and_step(self) -> None:
84
+ assert ep({"type": "image", "price": "0.04"}) == rate("0.04", "image")
85
+ assert ep({"type": "step", "price": "0.001"}) == rate("0.001", "step")
86
+
87
+ def test_constant_is_per_request(self) -> None:
88
+ assert ep(PER_REQUEST) == rate("0.01", "request")
89
+
90
+ def test_revenue_share_and_expr_are_unknown(self) -> None:
91
+ assert ep({"type": "revenue_share", "percentage": "70"}) is None
92
+ assert ep({"type": "expr", "expr": "input_tokens / 1000000 * 2.5"}) is None
93
+
94
+
95
+ class TestByokConstant:
96
+ def test_zero_with_upstream_description(self) -> None:
97
+ assert ep(BYOK) == rate("3.8", "one_million_tokens", "upstream")
98
+
99
+ def test_zero_without_description_is_free(self) -> None:
100
+ assert ep({"type": "constant", "price": "0"}) == rate("0", "request")
101
+
102
+ def test_zero_with_unparseable_description_is_unknown(self) -> None:
103
+ assert ep(NO_AMOUNT) is None
104
+
105
+ def test_zero_zero_placeholder_is_unknown(self) -> None:
106
+ text = "billed directly at $0 / $0 per 1M input/output tokens"
107
+ placeholder = {"type": "constant", "price": "0", "description": text}
108
+ assert ep(placeholder) is None
109
+
110
+ def test_nonzero_ignores_description(self) -> None:
111
+ priced = {"type": "constant", "price": "0.02", "description": "$1 / $2 per 1M input/output tokens"}
112
+ assert ep(priced) == rate("0.02", "request")
113
+
114
+
115
+ class TestUpstreamPriceFromDescription:
116
+ @pytest.mark.parametrize(
117
+ ("text", "expected"),
118
+ [
119
+ ("billed by Hugging Face directly at $1.4 / $4.4 per 1M input/output tokens", "3.8"),
120
+ ("$1.518/$4.554 / 1M input/output tokens", "3.9468"),
121
+ ("$4 / $20 / $0.2 per 1M input/output/cached tokens", "16.8"),
122
+ ("$3.45/$0.345/$17.25 / 1M in/cached/out tokens", "14.49"),
123
+ ("$0.50 / 1M tokens", "0.50"),
124
+ ("$0.001 / $0.002 per 1K input/output tokens", "1.8"),
125
+ ],
126
+ )
127
+ def test_known_shapes(self, text: str, expected: str) -> None:
128
+ assert upstream_price_from_description(text) == Decimal(expected)
129
+
130
+ @pytest.mark.parametrize(
131
+ "text",
132
+ [
133
+ None,
134
+ "",
135
+ "Bring your own OpenAI key",
136
+ "$1 / $2 per 1M input/output/cached tokens", # three labels, two amounts
137
+ "$1 / $2 per 1M input/cached tokens", # no output
138
+ "$0.01 per message", # not tokens
139
+ "$1 / $2 per 1M tokens", # two amounts, no labels
140
+ ],
141
+ )
142
+ def test_unknown_shapes_fail_closed(self, text: str | None) -> None:
143
+ assert upstream_price_from_description(text) is None
144
+
145
+
146
+ class TestComposites:
147
+ def test_add_sums_one_unit(self) -> None:
148
+ figure = ep({"type": "add", "prices": [PER_REQUEST, {"type": "constant", "price": "0.005"}]})
149
+ assert figure == rate("0.015", "request")
150
+
151
+ def test_add_with_mixed_units_is_unknown(self) -> None:
152
+ assert ep({"type": "add", "prices": [TOKENS, PER_REQUEST]}) is None
153
+
154
+ def test_add_with_unknown_child_is_unknown(self) -> None:
155
+ assert ep({"type": "add", "prices": [PER_REQUEST, {"type": "revenue_share", "percentage": "10"}]}) is None
156
+
157
+ def test_multiply_scales_base(self) -> None:
158
+ assert ep({"type": "multiply", "factor": "0.5", "base": TOKENS}) == rate("0.9", "one_million_tokens")
159
+
160
+ def test_multiply_keeps_upstream_source(self) -> None:
161
+ assert ep({"type": "multiply", "factor": "2", "base": BYOK}) == rate("7.6", "one_million_tokens", "upstream")
162
+
163
+ def test_max_and_min(self) -> None:
164
+ prices = [TOKENS, CHEAP_TOKENS]
165
+ assert ep({"type": "max", "prices": prices}) == rate("1.8", "one_million_tokens")
166
+ assert ep({"type": "min", "prices": prices}) == rate("0.5", "one_million_tokens")
167
+
168
+ def test_mixed_units_compare_within_dominant_unit(self) -> None:
169
+ # Tokens outrank requests, which outrank every other unit.
170
+ prices = [{"type": "one_second", "price": "100"}, PER_REQUEST, CHEAP_TOKENS]
171
+ assert ep({"type": "max", "prices": prices}) == rate("0.5", "one_million_tokens")
172
+ prices = [{"type": "one_second", "price": "100"}, PER_REQUEST]
173
+ assert ep({"type": "max", "prices": prices}) == rate("0.01", "request")
174
+
175
+ def test_first_takes_first_known(self) -> None:
176
+ prices = [{"type": "revenue_share", "percentage": "10"}, PER_REQUEST, TOKENS]
177
+ assert ep({"type": "first", "prices": prices}) == rate("0.01", "request")
178
+
179
+ def test_invalid_child_is_skipped_not_raised(self) -> None:
180
+ assert ep({"type": "max", "prices": [{"type": "nonsense"}, PER_REQUEST]}) == rate("0.01", "request")
181
+
182
+
183
+ class TestWorstCase:
184
+ def test_tiered_takes_largest_tier_not_first(self) -> None:
185
+ tiered = {
186
+ "type": "tiered",
187
+ "based_on": "request_count",
188
+ "tiers": [
189
+ {"up_to": 1000, "price": CHEAP_TOKENS},
190
+ {"up_to": None, "price": TOKENS},
191
+ ],
192
+ }
193
+ assert ep(tiered) == rate("1.8", "one_million_tokens")
194
+
195
+ @pytest.mark.parametrize(
196
+ ("based_on", "unit_prices", "expected"),
197
+ [
198
+ ("request_count", ["0.005", "0.01", "0.008"], rate("0.01", "request")),
199
+ ("input_tokens", ["0.000001", "0.000002"], rate("2", "one_million_tokens")),
200
+ ("one_thousand_tokens", ["0.003"], rate("3", "one_million_tokens")),
201
+ ("one_minute", ["0.01"], rate("0.6", "one_hour")),
202
+ ("bytes_out", ["0.000000001"], rate("1.073741824", "one_gigabyte")),
203
+ ("images_generated", ["0.04"], rate("0.04", "image")),
204
+ ("count", ["0.002"], rate("0.002", "request")),
205
+ ],
206
+ )
207
+ def test_graduated_takes_largest_unit_price(
208
+ self, based_on: str, unit_prices: list[str], expected: EffectivePrice
209
+ ) -> None:
210
+ tiers: list[dict[str, Any]] = [{"up_to": (i + 1) * 1000, "unit_price": p} for i, p in enumerate(unit_prices)]
211
+ tiers[-1]["up_to"] = None
212
+ assert ep({"type": "graduated", "based_on": based_on, "tiers": tiers}) == expected
213
+
214
+ @pytest.mark.parametrize("based_on", ["customer_charge", "input_tokens + output_tokens"])
215
+ def test_graduated_without_unit_is_unknown(self, based_on: str) -> None:
216
+ graduated = {"type": "graduated", "based_on": based_on, "tiers": [{"up_to": None, "unit_price": "0.1"}]}
217
+ assert ep(graduated) is None
218
+
219
+ def test_channel_takes_largest_not_default(self) -> None:
220
+ channel = {
221
+ "type": "channel",
222
+ "default": "byok",
223
+ "channels": {"byok": {"type": "constant", "price": "0"}, "plus": PER_REQUEST},
224
+ }
225
+ assert ep(channel) == rate("0.01", "request")
226
+
227
+ def test_channel_byok_upstream_beats_cheaper_managed(self) -> None:
228
+ channel = {"type": "channel", "default": "managed", "channels": {"managed": TOKENS, "byok": BYOK}}
229
+ assert ep(channel) == rate("3.8", "one_million_tokens", "upstream")
230
+
231
+ def test_channel_ignores_unknown_channels(self) -> None:
232
+ channel = {
233
+ "type": "channel",
234
+ "default": "managed",
235
+ "channels": {"managed": TOKENS, "byok": NO_AMOUNT},
236
+ }
237
+ assert ep(channel) == rate("1.8", "one_million_tokens")
238
+
239
+ def test_all_unknown_is_unknown(self) -> None:
240
+ channel = {
241
+ "type": "channel",
242
+ "default": "byok",
243
+ "channels": {"byok": NO_AMOUNT},
244
+ }
245
+ assert ep(channel) is None
246
+
247
+ def test_nested_composites_resolve_recursively(self) -> None:
248
+ nested = {
249
+ "type": "channel",
250
+ "default": "managed",
251
+ "channels": {
252
+ "managed": {
253
+ "type": "tiered",
254
+ "based_on": "request_count",
255
+ "tiers": [
256
+ {"up_to": 100, "price": {"type": "multiply", "factor": "2", "base": TOKENS}},
257
+ {"up_to": None, "price": CHEAP_TOKENS},
258
+ ],
259
+ },
260
+ "byok": {"type": "constant", "price": "0"},
261
+ },
262
+ }
263
+ assert ep(nested) == rate("3.6", "one_million_tokens")
264
+
265
+
266
+ def test_does_not_read_the_stored_nominal() -> None:
267
+ # The auto-filled composite ``price`` (first tier) is not the effective price.
268
+ tiered = validate_pricing(
269
+ {
270
+ "type": "tiered",
271
+ "based_on": "request_count",
272
+ "tiers": [{"up_to": 10, "price": CHEAP_TOKENS}, {"up_to": None, "price": TOKENS}],
273
+ }
274
+ )
275
+ assert tiered.price == "0.5"
276
+ assert tiered.effective_price() == rate("1.8", "one_million_tokens")
277
+
278
+
279
+ def test_effective_price_serializes_compactly() -> None:
280
+ assert rate("3.8", "one_million_tokens", "upstream").model_dump(mode="json") == {
281
+ "amount": "3.8",
282
+ "unit": "one_million_tokens",
283
+ "source": "upstream",
284
+ }
File without changes
File without changes
File without changes