unitysvc-core 0.2.27__tar.gz → 0.2.29__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.29}/PKG-INFO +1 -1
  2. {unitysvc_core-0.2.27 → unitysvc_core-0.2.29}/pyproject.toml +1 -1
  3. {unitysvc_core-0.2.27 → unitysvc_core-0.2.29}/src/unitysvc_core/models/__init__.py +4 -0
  4. {unitysvc_core-0.2.27 → unitysvc_core-0.2.29}/src/unitysvc_core/models/pricing.py +299 -0
  5. {unitysvc_core-0.2.27 → unitysvc_core-0.2.29/src/unitysvc_core.egg-info}/PKG-INFO +1 -1
  6. {unitysvc_core-0.2.27 → unitysvc_core-0.2.29}/src/unitysvc_core.egg-info/SOURCES.txt +1 -0
  7. unitysvc_core-0.2.29/tests/test_effective_price.py +311 -0
  8. {unitysvc_core-0.2.27 → unitysvc_core-0.2.29}/LICENSE +0 -0
  9. {unitysvc_core-0.2.27 → unitysvc_core-0.2.29}/README.md +0 -0
  10. {unitysvc_core-0.2.27 → unitysvc_core-0.2.29}/setup.cfg +0 -0
  11. {unitysvc_core-0.2.27 → unitysvc_core-0.2.29}/src/unitysvc_core/__init__.py +0 -0
  12. {unitysvc_core-0.2.27 → unitysvc_core-0.2.29}/src/unitysvc_core/models/base.py +0 -0
  13. {unitysvc_core-0.2.27 → unitysvc_core-0.2.29}/src/unitysvc_core/models/documents.py +0 -0
  14. {unitysvc_core-0.2.27 → unitysvc_core-0.2.29}/src/unitysvc_core/models/listing_data.py +0 -0
  15. {unitysvc_core-0.2.27 → unitysvc_core-0.2.29}/src/unitysvc_core/models/listing_v1.py +0 -0
  16. {unitysvc_core-0.2.27 → unitysvc_core-0.2.29}/src/unitysvc_core/models/offering_data.py +0 -0
  17. {unitysvc_core-0.2.27 → unitysvc_core-0.2.29}/src/unitysvc_core/models/offering_v1.py +0 -0
  18. {unitysvc_core-0.2.27 → unitysvc_core-0.2.29}/src/unitysvc_core/models/promotion_data.py +0 -0
  19. {unitysvc_core-0.2.27 → unitysvc_core-0.2.29}/src/unitysvc_core/models/provider_data.py +0 -0
  20. {unitysvc_core-0.2.27 → unitysvc_core-0.2.29}/src/unitysvc_core/models/provider_v1.py +0 -0
  21. {unitysvc_core-0.2.27 → unitysvc_core-0.2.29}/src/unitysvc_core/models/service.py +0 -0
  22. {unitysvc_core-0.2.27 → unitysvc_core-0.2.29}/src/unitysvc_core/models/service_group_data.py +0 -0
  23. {unitysvc_core-0.2.27 → unitysvc_core-0.2.29}/src/unitysvc_core/models/validators.py +0 -0
  24. {unitysvc_core-0.2.27 → unitysvc_core-0.2.29}/src/unitysvc_core/py.typed +0 -0
  25. {unitysvc_core-0.2.27 → unitysvc_core-0.2.29}/src/unitysvc_core/schema/base.json +0 -0
  26. {unitysvc_core-0.2.27 → unitysvc_core-0.2.29}/src/unitysvc_core/schema/listing_v1.json +0 -0
  27. {unitysvc_core-0.2.27 → unitysvc_core-0.2.29}/src/unitysvc_core/schema/offering_v1.json +0 -0
  28. {unitysvc_core-0.2.27 → unitysvc_core-0.2.29}/src/unitysvc_core/schema/promotion_v1.json +0 -0
  29. {unitysvc_core-0.2.27 → unitysvc_core-0.2.29}/src/unitysvc_core/schema/provider_v1.json +0 -0
  30. {unitysvc_core-0.2.27 → unitysvc_core-0.2.29}/src/unitysvc_core/schema/service_group_v1.json +0 -0
  31. {unitysvc_core-0.2.27 → unitysvc_core-0.2.29}/src/unitysvc_core/utils.py +0 -0
  32. {unitysvc_core-0.2.27 → unitysvc_core-0.2.29}/src/unitysvc_core/validator.py +0 -0
  33. {unitysvc_core-0.2.27 → unitysvc_core-0.2.29}/src/unitysvc_core.egg-info/dependency_links.txt +0 -0
  34. {unitysvc_core-0.2.27 → unitysvc_core-0.2.29}/src/unitysvc_core.egg-info/requires.txt +0 -0
  35. {unitysvc_core-0.2.27 → unitysvc_core-0.2.29}/src/unitysvc_core.egg-info/top_level.txt +0 -0
  36. {unitysvc_core-0.2.27 → unitysvc_core-0.2.29}/tests/test_access_interface_routing.py +0 -0
  37. {unitysvc_core-0.2.27 → unitysvc_core-0.2.29}/tests/test_hidden_path_skip.py +0 -0
  38. {unitysvc_core-0.2.27 → unitysvc_core-0.2.29}/tests/test_offering_data.py +0 -0
  39. {unitysvc_core-0.2.27 → unitysvc_core-0.2.29}/tests/test_pricing.py +0 -0
  40. {unitysvc_core-0.2.27 → unitysvc_core-0.2.29}/tests/test_provider_account_rate_limits.py +0 -0
  41. {unitysvc_core-0.2.27 → unitysvc_core-0.2.29}/tests/test_service_group.py +0 -0
  42. {unitysvc_core-0.2.27 → unitysvc_core-0.2.29}/tests/test_service_status.py +0 -0
  43. {unitysvc_core-0.2.27 → unitysvc_core-0.2.29}/tests/test_utils.py +0 -0
  44. {unitysvc_core-0.2.27 → unitysvc_core-0.2.29}/tests/test_validator.py +0 -0
  45. {unitysvc_core-0.2.27 → unitysvc_core-0.2.29}/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.29
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.29"
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,192 @@ 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
+
345
+ def _zero_rate(figure: EffectivePrice, description: str | None) -> EffectivePrice | None:
346
+ """A zero-rate price's effective price: free, or what the customer pays upstream.
347
+
348
+ A ``0`` with a description is a bring-your-own-key price: the customer pays
349
+ the upstream provider, at the rate the description states. A description
350
+ that states no parseable rate, or $0/$0 (what generators write when they
351
+ lack the provider's price), is unknown. A ``0`` with no description is
352
+ genuinely free.
353
+ """
354
+ if figure.amount != 0 or not description:
355
+ return figure
356
+ upstream = upstream_price_from_description(description)
357
+ if upstream is None or upstream == 0:
358
+ return None
359
+ return EffectivePrice(amount=upstream, unit=UNIT_TOKENS, source="upstream")
360
+
361
+
174
362
  # ============================================================================
175
363
  # Pricing Models - Discriminated Union for type-safe pricing validation
176
364
  # ============================================================================
@@ -207,6 +395,15 @@ class BasePriceData(BaseModel):
207
395
  description="URL to upstream provider's pricing page",
208
396
  )
209
397
 
398
+ def effective_price(self) -> EffectivePrice | None:
399
+ """What using this price realistically costs per unit, or None if unknown.
400
+
401
+ A comparison figure for sorting, filtering and routing; never billed.
402
+ Every pricing type defines it, so a composite price computes its figure
403
+ from its children. Unknown is never 0. Never raises.
404
+ """
405
+ return None
406
+
210
407
 
211
408
  class TokenPriceData(BasePriceData):
212
409
  """
@@ -294,6 +491,21 @@ class TokenPriceData(BasePriceData):
294
491
  """Token divisor based on the pricing type."""
295
492
  return {"one_million_tokens": 1_000_000, "one_thousand_tokens": 1_000, "one_token": 1}[self.type]
296
493
 
494
+ def effective_price(self) -> EffectivePrice | None:
495
+ """``(input + 4*output) / 5`` when priced separately, else ``price``; per 1M tokens.
496
+
497
+ A zero rate with a description is a bring-your-own-key price: see
498
+ ``_zero_rate``.
499
+ """
500
+ if self.input is not None and self.output is not None:
501
+ per_unit = _blend(Decimal(self.input), Decimal(self.output))
502
+ elif self.price is not None:
503
+ per_unit = Decimal(self.price)
504
+ else:
505
+ return None
506
+ figure = _per_normalized_unit(self.type, per_unit)
507
+ return None if figure is None else _zero_rate(figure, self.description)
508
+
297
509
  def calculate_cost(
298
510
  self,
299
511
  usage: UsageData,
@@ -361,6 +573,10 @@ class TimePriceData(BasePriceData):
361
573
  description="Price per one unit of the specified type",
362
574
  )
363
575
 
576
+ def effective_price(self) -> EffectivePrice | None:
577
+ """``price`` in the dimension's normalized unit."""
578
+ return _per_normalized_unit(self.type, Decimal(self.price))
579
+
364
580
  def calculate_cost(
365
581
  self,
366
582
  usage: UsageData,
@@ -402,6 +618,10 @@ class ImagePriceData(BasePriceData):
402
618
  description="Price per image",
403
619
  )
404
620
 
621
+ def effective_price(self) -> EffectivePrice | None:
622
+ """``price`` per image."""
623
+ return _rate(Decimal(self.price), UNIT_IMAGE)
624
+
405
625
  def calculate_cost(
406
626
  self,
407
627
  usage: UsageData,
@@ -442,6 +662,10 @@ class StepPriceData(BasePriceData):
442
662
  description="Price per step/iteration",
443
663
  )
444
664
 
665
+ def effective_price(self) -> EffectivePrice | None:
666
+ """``price`` per step."""
667
+ return _rate(Decimal(self.price), UNIT_STEP)
668
+
445
669
  def calculate_cost(
446
670
  self,
447
671
  usage: UsageData,
@@ -495,6 +719,10 @@ class DataPriceData(BasePriceData):
495
719
  description="Price per one unit of the specified type",
496
720
  )
497
721
 
722
+ def effective_price(self) -> EffectivePrice | None:
723
+ """``price`` in the dimension's normalized unit."""
724
+ return _per_normalized_unit(self.type, Decimal(self.price))
725
+
498
726
  def calculate_cost(
499
727
  self,
500
728
  usage: UsageData,
@@ -556,6 +784,10 @@ class CountPriceData(BasePriceData):
556
784
  description="Price per one unit of the specified type",
557
785
  )
558
786
 
787
+ def effective_price(self) -> EffectivePrice | None:
788
+ """``price`` in the dimension's normalized unit."""
789
+ return _per_normalized_unit(self.type, Decimal(self.price))
790
+
559
791
  def calculate_cost(
560
792
  self,
561
793
  usage: UsageData,
@@ -602,6 +834,10 @@ class CharacterPriceData(BasePriceData):
602
834
  description="Price per one unit of the specified type",
603
835
  )
604
836
 
837
+ def effective_price(self) -> EffectivePrice | None:
838
+ """``price`` in the dimension's normalized unit."""
839
+ return _per_normalized_unit(self.type, Decimal(self.price))
840
+
605
841
  def calculate_cost(
606
842
  self,
607
843
  usage: UsageData,
@@ -670,6 +906,10 @@ class RevenueSharePriceData(BasePriceData):
670
906
  description="Percentage of customer charge that goes to the seller (0-100)",
671
907
  )
672
908
 
909
+ def effective_price(self) -> EffectivePrice | None:
910
+ """A share of the customer charge has no per-unit rate: unknown."""
911
+ return None
912
+
673
913
  def calculate_cost(
674
914
  self,
675
915
  usage: UsageData,
@@ -710,6 +950,14 @@ class ConstantPriceData(BasePriceData):
710
950
  description="Fixed price (positive for charge, negative for discount)",
711
951
  )
712
952
 
953
+ def effective_price(self) -> EffectivePrice | None:
954
+ """``price`` per request.
955
+
956
+ A ``0`` with a description is a bring-your-own-key price: see
957
+ ``_zero_rate``.
958
+ """
959
+ return _zero_rate(_rate(Decimal(self.price), UNIT_REQUEST), self.description)
960
+
713
961
  def calculate_cost(
714
962
  self,
715
963
  usage: UsageData,
@@ -783,6 +1031,15 @@ class AddPriceData(BasePriceData):
783
1031
  self.price = _extract_nominal_price(self.prices[0])
784
1032
  return self
785
1033
 
1034
+ def effective_price(self) -> EffectivePrice | None:
1035
+ """The sum of the children; unknown if any child is unknown or the units differ."""
1036
+ figures = [_child_effective_price(p) for p in self.prices]
1037
+ known = [f for f in figures if f is not None]
1038
+ if len(known) != len(figures) or len({f.unit for f in known}) != 1:
1039
+ return None
1040
+ source: Literal["rate", "upstream"] = "upstream" if any(f.source == "upstream" for f in known) else "rate"
1041
+ return EffectivePrice(amount=sum((f.amount for f in known), Decimal(0)), unit=known[0].unit, source=source)
1042
+
786
1043
  def calculate_cost(
787
1044
  self,
788
1045
  usage: UsageData,
@@ -843,6 +1100,13 @@ class MultiplyPriceData(BasePriceData):
843
1100
  self.price = base_price
844
1101
  return self
845
1102
 
1103
+ def effective_price(self) -> EffectivePrice | None:
1104
+ """The base's figure times ``factor``."""
1105
+ base = _child_effective_price(self.base)
1106
+ if base is None:
1107
+ return None
1108
+ return base.model_copy(update={"amount": base.amount * Decimal(self.factor)})
1109
+
846
1110
  def calculate_cost(
847
1111
  self,
848
1112
  usage: UsageData,
@@ -897,6 +1161,10 @@ class MaxPriceData(BasePriceData):
897
1161
  self.price = _extract_nominal_price(self.prices[0])
898
1162
  return self
899
1163
 
1164
+ def effective_price(self) -> EffectivePrice | None:
1165
+ """The largest child, as billed."""
1166
+ return _largest(_child_effective_price(p) for p in self.prices)
1167
+
900
1168
  def calculate_cost(
901
1169
  self,
902
1170
  usage: UsageData,
@@ -955,6 +1223,10 @@ class MinPriceData(BasePriceData):
955
1223
  self.price = _extract_nominal_price(self.prices[0])
956
1224
  return self
957
1225
 
1226
+ def effective_price(self) -> EffectivePrice | None:
1227
+ """The smallest child, as billed."""
1228
+ return _smallest(_child_effective_price(p) for p in self.prices)
1229
+
958
1230
  def calculate_cost(
959
1231
  self,
960
1232
  usage: UsageData,
@@ -1012,6 +1284,14 @@ class FirstPriceData(BasePriceData):
1012
1284
  self.price = _extract_nominal_price(self.prices[0])
1013
1285
  return self
1014
1286
 
1287
+ def effective_price(self) -> EffectivePrice | None:
1288
+ """The first child with a known figure, mirroring the billing order."""
1289
+ for price_data in self.prices:
1290
+ figure = _child_effective_price(price_data)
1291
+ if figure is not None:
1292
+ return figure
1293
+ return None
1294
+
1015
1295
  def calculate_cost(
1016
1296
  self,
1017
1297
  usage: UsageData,
@@ -1246,6 +1526,10 @@ class ExprPriceData(BasePriceData):
1246
1526
  description="Arithmetic expression using usage metrics (e.g., 'input_tokens / 1000000 * 2.5')",
1247
1527
  )
1248
1528
 
1529
+ def effective_price(self) -> EffectivePrice | None:
1530
+ """An arbitrary expression has no per-unit rate: unknown."""
1531
+ return None
1532
+
1249
1533
  def calculate_cost(
1250
1534
  self,
1251
1535
  usage: UsageData,
@@ -1320,6 +1604,10 @@ class TieredPriceData(BasePriceData):
1320
1604
  self.price = _extract_nominal_price(first_tier.price)
1321
1605
  return self
1322
1606
 
1607
+ def effective_price(self) -> EffectivePrice | None:
1608
+ """The largest tier's figure: the worst case a customer can pay."""
1609
+ return _largest(_child_effective_price(tier.price) for tier in self.tiers)
1610
+
1323
1611
  def calculate_cost(
1324
1612
  self,
1325
1613
  usage: UsageData,
@@ -1403,6 +1691,13 @@ class GraduatedPriceData(BasePriceData):
1403
1691
  self.price = self.tiers[0].unit_price
1404
1692
  return self
1405
1693
 
1694
+ def effective_price(self) -> EffectivePrice | None:
1695
+ """The largest ``unit_price`` (the worst case), per ``based_on``'s normalized unit.
1696
+
1697
+ Unknown when ``based_on`` has no unit (``customer_charge``, an expression).
1698
+ """
1699
+ return _per_normalized_unit(self.based_on, max(Decimal(tier.unit_price) for tier in self.tiers))
1700
+
1406
1701
  def calculate_cost(
1407
1702
  self,
1408
1703
  usage: UsageData,
@@ -1526,6 +1821,10 @@ class ChannelPriceData(BasePriceData):
1526
1821
  self.description = f"{lo} - {hi}"
1527
1822
  return self
1528
1823
 
1824
+ def effective_price(self) -> EffectivePrice | None:
1825
+ """The largest figure among all channels: the worst case a customer can pay."""
1826
+ return _largest(_child_effective_price(p) for p in self.channels.values())
1827
+
1529
1828
  def calculate_cost(
1530
1829
  self,
1531
1830
  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.29
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,311 @@
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 TestByokZeroRateTokens:
116
+ """Generators also write BYOK as a zero token rate, e.g. Mistral models."""
117
+
118
+ MISTRAL = (
119
+ "Free ~ BYOK | usage is billed by Mistral AI directly at $1.5 / $7.5 / $0.15 per 1M input/output/cached tokens"
120
+ )
121
+
122
+ def test_zero_split_rate_reads_description(self) -> None:
123
+ byok = {"type": "one_million_tokens", "input": "0", "output": "0", "cached_input": "0"}
124
+ figure = ep({**byok, "description": self.MISTRAL})
125
+ assert figure == rate("6.3", "one_million_tokens", "upstream")
126
+
127
+ def test_zero_unified_rate_reads_description(self) -> None:
128
+ figure = ep({"type": "one_thousand_tokens", "price": "0", "description": HF_UPSTREAM})
129
+ assert figure == rate("3.8", "one_million_tokens", "upstream")
130
+
131
+ def test_zero_rate_without_description_is_free(self) -> None:
132
+ assert ep({"type": "one_million_tokens", "input": "0", "output": "0"}) == rate("0", "one_million_tokens")
133
+
134
+ def test_zero_rate_with_unparseable_description_is_unknown(self) -> None:
135
+ assert ep({"type": "one_million_tokens", "price": "0", "description": "Bring your own key"}) is None
136
+
137
+ def test_nonzero_rate_ignores_description(self) -> None:
138
+ priced = {**TOKENS, "description": self.MISTRAL}
139
+ assert ep(priced) == rate("1.8", "one_million_tokens")
140
+
141
+
142
+ class TestUpstreamPriceFromDescription:
143
+ @pytest.mark.parametrize(
144
+ ("text", "expected"),
145
+ [
146
+ ("billed by Hugging Face directly at $1.4 / $4.4 per 1M input/output tokens", "3.8"),
147
+ ("$1.518/$4.554 / 1M input/output tokens", "3.9468"),
148
+ ("$4 / $20 / $0.2 per 1M input/output/cached tokens", "16.8"),
149
+ ("$3.45/$0.345/$17.25 / 1M in/cached/out tokens", "14.49"),
150
+ ("$0.50 / 1M tokens", "0.50"),
151
+ ("$0.001 / $0.002 per 1K input/output tokens", "1.8"),
152
+ ],
153
+ )
154
+ def test_known_shapes(self, text: str, expected: str) -> None:
155
+ assert upstream_price_from_description(text) == Decimal(expected)
156
+
157
+ @pytest.mark.parametrize(
158
+ "text",
159
+ [
160
+ None,
161
+ "",
162
+ "Bring your own OpenAI key",
163
+ "$1 / $2 per 1M input/output/cached tokens", # three labels, two amounts
164
+ "$1 / $2 per 1M input/cached tokens", # no output
165
+ "$0.01 per message", # not tokens
166
+ "$1 / $2 per 1M tokens", # two amounts, no labels
167
+ ],
168
+ )
169
+ def test_unknown_shapes_fail_closed(self, text: str | None) -> None:
170
+ assert upstream_price_from_description(text) is None
171
+
172
+
173
+ class TestComposites:
174
+ def test_add_sums_one_unit(self) -> None:
175
+ figure = ep({"type": "add", "prices": [PER_REQUEST, {"type": "constant", "price": "0.005"}]})
176
+ assert figure == rate("0.015", "request")
177
+
178
+ def test_add_with_mixed_units_is_unknown(self) -> None:
179
+ assert ep({"type": "add", "prices": [TOKENS, PER_REQUEST]}) is None
180
+
181
+ def test_add_with_unknown_child_is_unknown(self) -> None:
182
+ assert ep({"type": "add", "prices": [PER_REQUEST, {"type": "revenue_share", "percentage": "10"}]}) is None
183
+
184
+ def test_multiply_scales_base(self) -> None:
185
+ assert ep({"type": "multiply", "factor": "0.5", "base": TOKENS}) == rate("0.9", "one_million_tokens")
186
+
187
+ def test_multiply_keeps_upstream_source(self) -> None:
188
+ assert ep({"type": "multiply", "factor": "2", "base": BYOK}) == rate("7.6", "one_million_tokens", "upstream")
189
+
190
+ def test_max_and_min(self) -> None:
191
+ prices = [TOKENS, CHEAP_TOKENS]
192
+ assert ep({"type": "max", "prices": prices}) == rate("1.8", "one_million_tokens")
193
+ assert ep({"type": "min", "prices": prices}) == rate("0.5", "one_million_tokens")
194
+
195
+ def test_mixed_units_compare_within_dominant_unit(self) -> None:
196
+ # Tokens outrank requests, which outrank every other unit.
197
+ prices = [{"type": "one_second", "price": "100"}, PER_REQUEST, CHEAP_TOKENS]
198
+ assert ep({"type": "max", "prices": prices}) == rate("0.5", "one_million_tokens")
199
+ prices = [{"type": "one_second", "price": "100"}, PER_REQUEST]
200
+ assert ep({"type": "max", "prices": prices}) == rate("0.01", "request")
201
+
202
+ def test_first_takes_first_known(self) -> None:
203
+ prices = [{"type": "revenue_share", "percentage": "10"}, PER_REQUEST, TOKENS]
204
+ assert ep({"type": "first", "prices": prices}) == rate("0.01", "request")
205
+
206
+ def test_invalid_child_is_skipped_not_raised(self) -> None:
207
+ assert ep({"type": "max", "prices": [{"type": "nonsense"}, PER_REQUEST]}) == rate("0.01", "request")
208
+
209
+
210
+ class TestWorstCase:
211
+ def test_tiered_takes_largest_tier_not_first(self) -> None:
212
+ tiered = {
213
+ "type": "tiered",
214
+ "based_on": "request_count",
215
+ "tiers": [
216
+ {"up_to": 1000, "price": CHEAP_TOKENS},
217
+ {"up_to": None, "price": TOKENS},
218
+ ],
219
+ }
220
+ assert ep(tiered) == rate("1.8", "one_million_tokens")
221
+
222
+ @pytest.mark.parametrize(
223
+ ("based_on", "unit_prices", "expected"),
224
+ [
225
+ ("request_count", ["0.005", "0.01", "0.008"], rate("0.01", "request")),
226
+ ("input_tokens", ["0.000001", "0.000002"], rate("2", "one_million_tokens")),
227
+ ("one_thousand_tokens", ["0.003"], rate("3", "one_million_tokens")),
228
+ ("one_minute", ["0.01"], rate("0.6", "one_hour")),
229
+ ("bytes_out", ["0.000000001"], rate("1.073741824", "one_gigabyte")),
230
+ ("images_generated", ["0.04"], rate("0.04", "image")),
231
+ ("count", ["0.002"], rate("0.002", "request")),
232
+ ],
233
+ )
234
+ def test_graduated_takes_largest_unit_price(
235
+ self, based_on: str, unit_prices: list[str], expected: EffectivePrice
236
+ ) -> None:
237
+ tiers: list[dict[str, Any]] = [{"up_to": (i + 1) * 1000, "unit_price": p} for i, p in enumerate(unit_prices)]
238
+ tiers[-1]["up_to"] = None
239
+ assert ep({"type": "graduated", "based_on": based_on, "tiers": tiers}) == expected
240
+
241
+ @pytest.mark.parametrize("based_on", ["customer_charge", "input_tokens + output_tokens"])
242
+ def test_graduated_without_unit_is_unknown(self, based_on: str) -> None:
243
+ graduated = {"type": "graduated", "based_on": based_on, "tiers": [{"up_to": None, "unit_price": "0.1"}]}
244
+ assert ep(graduated) is None
245
+
246
+ def test_channel_takes_largest_not_default(self) -> None:
247
+ channel = {
248
+ "type": "channel",
249
+ "default": "byok",
250
+ "channels": {"byok": {"type": "constant", "price": "0"}, "plus": PER_REQUEST},
251
+ }
252
+ assert ep(channel) == rate("0.01", "request")
253
+
254
+ def test_channel_byok_upstream_beats_cheaper_managed(self) -> None:
255
+ channel = {"type": "channel", "default": "managed", "channels": {"managed": TOKENS, "byok": BYOK}}
256
+ assert ep(channel) == rate("3.8", "one_million_tokens", "upstream")
257
+
258
+ def test_channel_ignores_unknown_channels(self) -> None:
259
+ channel = {
260
+ "type": "channel",
261
+ "default": "managed",
262
+ "channels": {"managed": TOKENS, "byok": NO_AMOUNT},
263
+ }
264
+ assert ep(channel) == rate("1.8", "one_million_tokens")
265
+
266
+ def test_all_unknown_is_unknown(self) -> None:
267
+ channel = {
268
+ "type": "channel",
269
+ "default": "byok",
270
+ "channels": {"byok": NO_AMOUNT},
271
+ }
272
+ assert ep(channel) is None
273
+
274
+ def test_nested_composites_resolve_recursively(self) -> None:
275
+ nested = {
276
+ "type": "channel",
277
+ "default": "managed",
278
+ "channels": {
279
+ "managed": {
280
+ "type": "tiered",
281
+ "based_on": "request_count",
282
+ "tiers": [
283
+ {"up_to": 100, "price": {"type": "multiply", "factor": "2", "base": TOKENS}},
284
+ {"up_to": None, "price": CHEAP_TOKENS},
285
+ ],
286
+ },
287
+ "byok": {"type": "constant", "price": "0"},
288
+ },
289
+ }
290
+ assert ep(nested) == rate("3.6", "one_million_tokens")
291
+
292
+
293
+ def test_does_not_read_the_stored_nominal() -> None:
294
+ # The auto-filled composite ``price`` (first tier) is not the effective price.
295
+ tiered = validate_pricing(
296
+ {
297
+ "type": "tiered",
298
+ "based_on": "request_count",
299
+ "tiers": [{"up_to": 10, "price": CHEAP_TOKENS}, {"up_to": None, "price": TOKENS}],
300
+ }
301
+ )
302
+ assert tiered.price == "0.5"
303
+ assert tiered.effective_price() == rate("1.8", "one_million_tokens")
304
+
305
+
306
+ def test_effective_price_serializes_compactly() -> None:
307
+ assert rate("3.8", "one_million_tokens", "upstream").model_dump(mode="json") == {
308
+ "amount": "3.8",
309
+ "unit": "one_million_tokens",
310
+ "source": "upstream",
311
+ }
File without changes
File without changes
File without changes