unitysvc-core 0.2.2__tar.gz → 0.2.4__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 (40) hide show
  1. {unitysvc_core-0.2.2/src/unitysvc_core.egg-info → unitysvc_core-0.2.4}/PKG-INFO +1 -1
  2. {unitysvc_core-0.2.2 → unitysvc_core-0.2.4}/pyproject.toml +1 -1
  3. {unitysvc_core-0.2.2 → unitysvc_core-0.2.4}/src/unitysvc_core/models/__init__.py +4 -2
  4. {unitysvc_core-0.2.2 → unitysvc_core-0.2.4}/src/unitysvc_core/models/base.py +2 -0
  5. {unitysvc_core-0.2.2 → unitysvc_core-0.2.4}/src/unitysvc_core/models/listing_data.py +2 -2
  6. {unitysvc_core-0.2.2 → unitysvc_core-0.2.4}/src/unitysvc_core/models/pricing.py +102 -7
  7. {unitysvc_core-0.2.2 → unitysvc_core-0.2.4}/src/unitysvc_core/models/service.py +16 -15
  8. {unitysvc_core-0.2.2 → unitysvc_core-0.2.4}/src/unitysvc_core/schema/listing_v1.json +76 -0
  9. {unitysvc_core-0.2.2 → unitysvc_core-0.2.4}/src/unitysvc_core/schema/offering_v1.json +76 -0
  10. {unitysvc_core-0.2.2 → unitysvc_core-0.2.4/src/unitysvc_core.egg-info}/PKG-INFO +1 -1
  11. {unitysvc_core-0.2.2 → unitysvc_core-0.2.4}/src/unitysvc_core.egg-info/SOURCES.txt +1 -1
  12. {unitysvc_core-0.2.2 → unitysvc_core-0.2.4}/tests/test_pricing.py +121 -0
  13. unitysvc_core-0.2.2/tests/test_service_data.py → unitysvc_core-0.2.4/tests/test_service_status.py +6 -6
  14. {unitysvc_core-0.2.2 → unitysvc_core-0.2.4}/LICENSE +0 -0
  15. {unitysvc_core-0.2.2 → unitysvc_core-0.2.4}/README.md +0 -0
  16. {unitysvc_core-0.2.2 → unitysvc_core-0.2.4}/setup.cfg +0 -0
  17. {unitysvc_core-0.2.2 → unitysvc_core-0.2.4}/src/unitysvc_core/__init__.py +0 -0
  18. {unitysvc_core-0.2.2 → unitysvc_core-0.2.4}/src/unitysvc_core/models/documents.py +0 -0
  19. {unitysvc_core-0.2.2 → unitysvc_core-0.2.4}/src/unitysvc_core/models/listing_v1.py +0 -0
  20. {unitysvc_core-0.2.2 → unitysvc_core-0.2.4}/src/unitysvc_core/models/offering_data.py +0 -0
  21. {unitysvc_core-0.2.2 → unitysvc_core-0.2.4}/src/unitysvc_core/models/offering_v1.py +0 -0
  22. {unitysvc_core-0.2.2 → unitysvc_core-0.2.4}/src/unitysvc_core/models/promotion_data.py +0 -0
  23. {unitysvc_core-0.2.2 → unitysvc_core-0.2.4}/src/unitysvc_core/models/provider_data.py +0 -0
  24. {unitysvc_core-0.2.2 → unitysvc_core-0.2.4}/src/unitysvc_core/models/provider_v1.py +0 -0
  25. {unitysvc_core-0.2.2 → unitysvc_core-0.2.4}/src/unitysvc_core/models/service_group_data.py +0 -0
  26. {unitysvc_core-0.2.2 → unitysvc_core-0.2.4}/src/unitysvc_core/models/validators.py +0 -0
  27. {unitysvc_core-0.2.2 → unitysvc_core-0.2.4}/src/unitysvc_core/py.typed +0 -0
  28. {unitysvc_core-0.2.2 → unitysvc_core-0.2.4}/src/unitysvc_core/schema/base.json +0 -0
  29. {unitysvc_core-0.2.2 → unitysvc_core-0.2.4}/src/unitysvc_core/schema/promotion_v1.json +0 -0
  30. {unitysvc_core-0.2.2 → unitysvc_core-0.2.4}/src/unitysvc_core/schema/provider_v1.json +0 -0
  31. {unitysvc_core-0.2.2 → unitysvc_core-0.2.4}/src/unitysvc_core/schema/service_group_v1.json +0 -0
  32. {unitysvc_core-0.2.2 → unitysvc_core-0.2.4}/src/unitysvc_core/utils.py +0 -0
  33. {unitysvc_core-0.2.2 → unitysvc_core-0.2.4}/src/unitysvc_core/validator.py +0 -0
  34. {unitysvc_core-0.2.2 → unitysvc_core-0.2.4}/src/unitysvc_core.egg-info/dependency_links.txt +0 -0
  35. {unitysvc_core-0.2.2 → unitysvc_core-0.2.4}/src/unitysvc_core.egg-info/requires.txt +0 -0
  36. {unitysvc_core-0.2.2 → unitysvc_core-0.2.4}/src/unitysvc_core.egg-info/top_level.txt +0 -0
  37. {unitysvc_core-0.2.2 → unitysvc_core-0.2.4}/tests/test_service_group.py +0 -0
  38. {unitysvc_core-0.2.2 → unitysvc_core-0.2.4}/tests/test_utils.py +0 -0
  39. {unitysvc_core-0.2.2 → unitysvc_core-0.2.4}/tests/test_validator.py +0 -0
  40. {unitysvc_core-0.2.2 → unitysvc_core-0.2.4}/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.2
3
+ Version: 0.2.4
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.2"
7
+ version = "0.2.4"
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" }]
@@ -43,6 +43,7 @@ from .pricing import (
43
43
  ImagePriceData,
44
44
  MaxPriceData,
45
45
  MinPriceData,
46
+ ModePriceData,
46
47
  MultiplyPriceData,
47
48
  PercentageStr,
48
49
  PriceStr,
@@ -70,7 +71,7 @@ from .service import (
70
71
  AccessInterfaceData,
71
72
  RateLimit,
72
73
  ServiceConstraints,
73
- ServiceData,
74
+ ServiceStatus,
74
75
  UpstreamAccessConfigData,
75
76
  )
76
77
  from .service_group_data import (
@@ -110,7 +111,7 @@ __all__ = [
110
111
  "UpstreamAccessConfigData",
111
112
  "RateLimit",
112
113
  "ServiceConstraints",
113
- "ServiceData",
114
+ "ServiceStatus",
114
115
  # Enums
115
116
  "AccessMethodEnum",
116
117
  "AuthMethodEnum",
@@ -158,6 +159,7 @@ __all__ = [
158
159
  "MultiplyPriceData",
159
160
  "MaxPriceData",
160
161
  "MinPriceData",
162
+ "ModePriceData",
161
163
  "FirstPriceData",
162
164
  "TieredPriceData",
163
165
  "GraduatedPriceData",
@@ -216,6 +216,8 @@ class PricingTypeEnum(StrEnum):
216
216
  graduated = "graduated" # Graduated tiers (each tier's units at that rate)
217
217
  # Expression-based pricing (payout_price only)
218
218
  expr = "expr" # Arbitrary expression using usage metrics
219
+ # Mode-based pricing: select a sub-price by the request's resolved mode
220
+ mode = "mode" # Per-mode pricing keyed by mode name (e.g. managed, byok)
219
221
 
220
222
 
221
223
  class QuotaResetCycleEnum(StrEnum):
@@ -44,8 +44,8 @@ class ServiceListingData(BaseModel):
44
44
 
45
45
  # NOTE: a listing carries no service_id. Service identity is backend-
46
46
  # assigned provenance, not authored listing content — it lives in
47
- # ``service.json`` and travels as the top-level ``ServiceDataInput.service_id``
48
- # field on upload/revision, never embedded in the listing.
47
+ # ``service.json`` (the ``ServiceStatus`` sidecar) and travels separately
48
+ # on upload/revision, never embedded in the listing.
49
49
 
50
50
  # Listing identification
51
51
  name: str | None = Field(
@@ -280,6 +280,7 @@ class TokenPriceData(BasePriceData):
280
280
  usage: UsageData,
281
281
  customer_charge: Decimal | None = None,
282
282
  request_count: int | None = None,
283
+ mode: str | None = None,
283
284
  ) -> Decimal:
284
285
  """Calculate cost for token-based pricing.
285
286
 
@@ -346,6 +347,7 @@ class TimePriceData(BasePriceData):
346
347
  usage: UsageData,
347
348
  customer_charge: Decimal | None = None,
348
349
  request_count: int | None = None,
350
+ mode: str | None = None,
349
351
  ) -> Decimal:
350
352
  """Calculate cost for time-based pricing.
351
353
 
@@ -386,6 +388,7 @@ class ImagePriceData(BasePriceData):
386
388
  usage: UsageData,
387
389
  customer_charge: Decimal | None = None,
388
390
  request_count: int | None = None,
391
+ mode: str | None = None,
389
392
  ) -> Decimal:
390
393
  """Calculate cost for image-based pricing.
391
394
 
@@ -422,6 +425,7 @@ class StepPriceData(BasePriceData):
422
425
  usage: UsageData,
423
426
  customer_charge: Decimal | None = None,
424
427
  request_count: int | None = None,
428
+ mode: str | None = None,
425
429
  ) -> Decimal:
426
430
  """Calculate cost for step-based pricing.
427
431
 
@@ -461,6 +465,7 @@ class DataPriceData(BasePriceData):
461
465
  usage: UsageData,
462
466
  customer_charge: Decimal | None = None,
463
467
  request_count: int | None = None,
468
+ mode: str | None = None,
464
469
  ) -> Decimal:
465
470
  """Calculate cost for data-volume pricing.
466
471
 
@@ -504,6 +509,7 @@ class CountPriceData(BasePriceData):
504
509
  usage: UsageData,
505
510
  customer_charge: Decimal | None = None,
506
511
  request_count: int | None = None,
512
+ mode: str | None = None,
507
513
  ) -> Decimal:
508
514
  """Calculate cost for count-scaled pricing.
509
515
 
@@ -566,6 +572,7 @@ class RevenueSharePriceData(BasePriceData):
566
572
  usage: UsageData,
567
573
  customer_charge: Decimal | None = None,
568
574
  request_count: int | None = None,
575
+ mode: str | None = None,
569
576
  ) -> Decimal:
570
577
  """Calculate cost for revenue share pricing.
571
578
 
@@ -605,6 +612,7 @@ class ConstantPriceData(BasePriceData):
605
612
  usage: UsageData,
606
613
  customer_charge: Decimal | None = None,
607
614
  request_count: int | None = None,
615
+ mode: str | None = None,
608
616
  ) -> Decimal:
609
617
  """Return the constant price regardless of usage.
610
618
 
@@ -677,6 +685,7 @@ class AddPriceData(BasePriceData):
677
685
  usage: UsageData,
678
686
  customer_charge: Decimal | None = None,
679
687
  request_count: int | None = None,
688
+ mode: str | None = None,
680
689
  ) -> Decimal:
681
690
  """Calculate total cost by summing all price components.
682
691
 
@@ -691,7 +700,7 @@ class AddPriceData(BasePriceData):
691
700
  total = Decimal("0")
692
701
  for price_data in self.prices:
693
702
  component = validate_pricing(price_data)
694
- total += component.calculate_cost(usage, customer_charge, request_count)
703
+ total += component.calculate_cost(usage, customer_charge, request_count, mode)
695
704
  return total
696
705
 
697
706
 
@@ -736,6 +745,7 @@ class MultiplyPriceData(BasePriceData):
736
745
  usage: UsageData,
737
746
  customer_charge: Decimal | None = None,
738
747
  request_count: int | None = None,
748
+ mode: str | None = None,
739
749
  ) -> Decimal:
740
750
  """Calculate cost by multiplying base price by factor.
741
751
 
@@ -748,7 +758,7 @@ class MultiplyPriceData(BasePriceData):
748
758
  Base cost multiplied by factor
749
759
  """
750
760
  base_pricing = validate_pricing(self.base)
751
- base_cost = base_pricing.calculate_cost(usage, customer_charge, request_count)
761
+ base_cost = base_pricing.calculate_cost(usage, customer_charge, request_count, mode)
752
762
  return base_cost * Decimal(self.factor)
753
763
 
754
764
 
@@ -789,6 +799,7 @@ class MaxPriceData(BasePriceData):
789
799
  usage: UsageData,
790
800
  customer_charge: Decimal | None = None,
791
801
  request_count: int | None = None,
802
+ mode: str | None = None,
792
803
  ) -> Decimal:
793
804
  """Return the highest cost among applicable children.
794
805
 
@@ -799,7 +810,7 @@ class MaxPriceData(BasePriceData):
799
810
  for price_data in self.prices:
800
811
  try:
801
812
  component = validate_pricing(price_data)
802
- costs.append(component.calculate_cost(usage, customer_charge, request_count))
813
+ costs.append(component.calculate_cost(usage, customer_charge, request_count, mode))
803
814
  except ValueError:
804
815
  continue
805
816
  if not costs:
@@ -846,6 +857,7 @@ class MinPriceData(BasePriceData):
846
857
  usage: UsageData,
847
858
  customer_charge: Decimal | None = None,
848
859
  request_count: int | None = None,
860
+ mode: str | None = None,
849
861
  ) -> Decimal:
850
862
  """Return the lowest cost among applicable children.
851
863
 
@@ -856,7 +868,7 @@ class MinPriceData(BasePriceData):
856
868
  for price_data in self.prices:
857
869
  try:
858
870
  component = validate_pricing(price_data)
859
- costs.append(component.calculate_cost(usage, customer_charge, request_count))
871
+ costs.append(component.calculate_cost(usage, customer_charge, request_count, mode))
860
872
  except ValueError:
861
873
  continue
862
874
  if not costs:
@@ -902,6 +914,7 @@ class FirstPriceData(BasePriceData):
902
914
  usage: UsageData,
903
915
  customer_charge: Decimal | None = None,
904
916
  request_count: int | None = None,
917
+ mode: str | None = None,
905
918
  ) -> Decimal:
906
919
  """Return the cost from the first child that can handle the usage.
907
920
 
@@ -912,7 +925,7 @@ class FirstPriceData(BasePriceData):
912
925
  for price_data in self.prices:
913
926
  try:
914
927
  component = validate_pricing(price_data)
915
- return component.calculate_cost(usage, customer_charge, request_count)
928
+ return component.calculate_cost(usage, customer_charge, request_count, mode)
916
929
  except ValueError:
917
930
  continue
918
931
  raise ValueError("No child pricing could handle the provided usage data")
@@ -1127,6 +1140,7 @@ class ExprPriceData(BasePriceData):
1127
1140
  usage: UsageData,
1128
1141
  customer_charge: Decimal | None = None,
1129
1142
  request_count: int | None = None,
1143
+ mode: str | None = None,
1130
1144
  ) -> Decimal:
1131
1145
  """Calculate cost by evaluating the expression with usage data.
1132
1146
 
@@ -1200,6 +1214,7 @@ class TieredPriceData(BasePriceData):
1200
1214
  usage: UsageData,
1201
1215
  customer_charge: Decimal | None = None,
1202
1216
  request_count: int | None = None,
1217
+ mode: str | None = None,
1203
1218
  ) -> Decimal:
1204
1219
  """Calculate cost based on which tier the usage falls into.
1205
1220
 
@@ -1217,7 +1232,7 @@ class TieredPriceData(BasePriceData):
1217
1232
  for tier in self.tiers:
1218
1233
  if tier.up_to is None or metric_value <= tier.up_to:
1219
1234
  tier_pricing = validate_pricing(tier.price)
1220
- return tier_pricing.calculate_cost(usage, customer_charge, request_count)
1235
+ return tier_pricing.calculate_cost(usage, customer_charge, request_count, mode)
1221
1236
 
1222
1237
  # Should not reach here if tiers are properly configured
1223
1238
  raise ValueError("No matching tier found")
@@ -1282,6 +1297,7 @@ class GraduatedPriceData(BasePriceData):
1282
1297
  usage: UsageData,
1283
1298
  customer_charge: Decimal | None = None,
1284
1299
  request_count: int | None = None,
1300
+ mode: str | None = None,
1285
1301
  ) -> Decimal:
1286
1302
  """Calculate cost with graduated pricing across tiers.
1287
1303
 
@@ -1317,6 +1333,83 @@ class GraduatedPriceData(BasePriceData):
1317
1333
  return total_cost
1318
1334
 
1319
1335
 
1336
+ class ModePriceData(BasePriceData):
1337
+ """
1338
+ Mode-based pricing that selects a sub-price by the request's resolved mode.
1339
+
1340
+ The active mode is decided elsewhere — e.g. the gateway picks which keyed
1341
+ ``upstream_access_config`` a request routes through (``"managed"``,
1342
+ ``"byok"``, ``"byoe"``, …) — and threads it into
1343
+ ``calculate_cost(..., mode=...)``. The matching entry in ``modes`` is
1344
+ evaluated; when the mode is unknown or unset, the ``default`` mode is used.
1345
+
1346
+ There is no special-casing on the consumer side: every pricing type accepts
1347
+ an optional ``mode`` and a mode-independent price simply ignores it, so a
1348
+ plain price behaves identically for all modes. Mode-dependent pricing is
1349
+ just this one extra optional pricing type.
1350
+
1351
+ Each ``modes`` value is itself any pricing object (flat, composite, tiered,
1352
+ or even nested mode-based), validated lazily — mirroring how tiered pricing
1353
+ holds per-tier prices.
1354
+
1355
+ Example:
1356
+ {
1357
+ "type": "mode",
1358
+ "default": "managed",
1359
+ "modes": {
1360
+ "managed": {"type": "one_million_tokens", "input": "0.50", "output": "1.50"},
1361
+ "byok": {"type": "constant", "price": "0"}
1362
+ }
1363
+ }
1364
+ """
1365
+
1366
+ type: Literal["mode"] = "mode"
1367
+
1368
+ default: str = Field(
1369
+ description="Mode key evaluated when the request's mode is unknown or unset. "
1370
+ "Must be one of the keys in `modes`.",
1371
+ )
1372
+
1373
+ modes: dict[str, dict[str, Any]] = Field(
1374
+ description="Per-mode pricing keyed by mode name (e.g. 'managed', 'byok'). "
1375
+ "Each value is a nested pricing object of any type.",
1376
+ min_length=1,
1377
+ )
1378
+
1379
+ @model_validator(mode="after")
1380
+ def _validate_default_and_price(self) -> ModePriceData:
1381
+ """Require ``default`` to exist and auto-compute the nominal price from it."""
1382
+ if self.default not in self.modes:
1383
+ raise ValueError(f"default mode {self.default!r} is not defined in modes ({sorted(self.modes)})")
1384
+ if self.price is None:
1385
+ self.price = _extract_nominal_price(self.modes[self.default])
1386
+ return self
1387
+
1388
+ def calculate_cost(
1389
+ self,
1390
+ usage: UsageData,
1391
+ customer_charge: Decimal | None = None,
1392
+ request_count: int | None = None,
1393
+ mode: str | None = None,
1394
+ ) -> Decimal:
1395
+ """Evaluate the price for the resolved ``mode`` (falling back to ``default``).
1396
+
1397
+ Args:
1398
+ usage: Usage data passed to the selected mode's price.
1399
+ customer_charge: Customer charge passed through.
1400
+ request_count: Request count passed through.
1401
+ mode: The resolved mode; unknown/``None`` falls back to ``default``.
1402
+
1403
+ Returns:
1404
+ Cost from the selected mode's pricing.
1405
+ """
1406
+ selected = self.modes.get(mode) if mode is not None else None
1407
+ if selected is None:
1408
+ selected = self.modes[self.default]
1409
+ pricing = validate_pricing(selected)
1410
+ return pricing.calculate_cost(usage, customer_charge, request_count, mode)
1411
+
1412
+
1320
1413
  # Discriminated union of all pricing types
1321
1414
  # This is the type used for payout_price and list_price fields
1322
1415
  # Note: ExprPriceData should only be used for payout_price
@@ -1336,7 +1429,8 @@ Pricing = Annotated[
1336
1429
  | FirstPriceData
1337
1430
  | TieredPriceData
1338
1431
  | GraduatedPriceData
1339
- | ExprPriceData,
1432
+ | ExprPriceData
1433
+ | ModePriceData,
1340
1434
  Field(discriminator="type"),
1341
1435
  ]
1342
1436
 
@@ -1360,6 +1454,7 @@ def validate_pricing(
1360
1454
  | TieredPriceData
1361
1455
  | GraduatedPriceData
1362
1456
  | ExprPriceData
1457
+ | ModePriceData
1363
1458
  ):
1364
1459
  """
1365
1460
  Validate pricing dict and return the appropriate typed model.
@@ -144,21 +144,22 @@ class UpstreamAccessConfigData(AccessInterfaceData):
144
144
  )
145
145
 
146
146
 
147
- class ServiceData(BaseModel):
148
- """Backend-assigned service identity record (the ``service.json`` file).
149
-
150
- A service's ``provider_data`` / ``offering_data`` / ``listing_data`` are
151
- authored by the seller; this is the *other* half — the record the backend
152
- materializes once a service exists. It is the **round-trip** structure:
153
- the ingest task returns it, the seller stores it in ``service.json`` beside
154
- the spec files, and it travels back as the top-level ``service_data`` field
155
- on the next upload/revision so the backend can match the upload to the
156
- existing service.
157
-
158
- Of these fields, only ``service_id`` is consumed on the way *in* (it
159
- targets revise/replace vs create); the rest are populated on the way *out*
160
- and are informational provenance for the seller. ``extra="ignore"`` keeps
161
- unknown keys from breaking either direction.
147
+ class ServiceStatus(BaseModel):
148
+ """Backend-assigned service **status / identity sidecar** (the ``service.json``
149
+ file) — *not* the authored service data.
150
+
151
+ A service's ``provider_data`` / ``offering_data`` / ``listing_data`` are the
152
+ authored service data; this is the *other*, backend-owned half — the status
153
+ record the backend materializes once a service exists. It is the
154
+ **round-trip** sidecar: the ingest task returns it, the seller stores it in
155
+ ``service.json`` beside the spec files, and replays it on the next
156
+ upload/revision so the backend can match the upload to the existing service.
157
+
158
+ Of these fields, ``service_id`` and ``template_instance_id`` are consumed on
159
+ the way *in* (they declare which service / which template the publish
160
+ targets); the rest are populated on the way *out* as informational status /
161
+ provenance for the seller. ``extra="ignore"`` keeps unknown keys from
162
+ breaking either direction.
162
163
  """
163
164
 
164
165
  model_config = ConfigDict(extra="ignore")
@@ -964,6 +964,78 @@
964
964
  "title": "MinPriceData",
965
965
  "type": "object"
966
966
  },
967
+ "ModePriceData": {
968
+ "additionalProperties": false,
969
+ "description": "Mode-based pricing that selects a sub-price by the request's resolved mode.\n\nThe active mode is decided elsewhere — e.g. the gateway picks which keyed\n``upstream_access_config`` a request routes through (``\"managed\"``,\n``\"byok\"``, ``\"byoe\"``, …) — and threads it into\n``calculate_cost(..., mode=...)``. The matching entry in ``modes`` is\nevaluated; when the mode is unknown or unset, the ``default`` mode is used.\n\nThere is no special-casing on the consumer side: every pricing type accepts\nan optional ``mode`` and a mode-independent price simply ignores it, so a\nplain price behaves identically for all modes. Mode-dependent pricing is\njust this one extra optional pricing type.\n\nEach ``modes`` value is itself any pricing object (flat, composite, tiered,\nor even nested mode-based), validated lazily — mirroring how tiered pricing\nholds per-tier prices.\n\nExample:\n {\n \"type\": \"mode\",\n \"default\": \"managed\",\n \"modes\": {\n \"managed\": {\"type\": \"one_million_tokens\", \"input\": \"0.50\", \"output\": \"1.50\"},\n \"byok\": {\"type\": \"constant\", \"price\": \"0\"}\n }\n }",
970
+ "properties": {
971
+ "default": {
972
+ "description": "Mode key evaluated when the request's mode is unknown or unset. Must be one of the keys in `modes`.",
973
+ "title": "Default",
974
+ "type": "string"
975
+ },
976
+ "description": {
977
+ "anyOf": [
978
+ {
979
+ "type": "string"
980
+ },
981
+ {
982
+ "type": "null"
983
+ }
984
+ ],
985
+ "default": null,
986
+ "description": "Human-readable description of the pricing model",
987
+ "title": "Description"
988
+ },
989
+ "modes": {
990
+ "additionalProperties": {
991
+ "additionalProperties": true,
992
+ "type": "object"
993
+ },
994
+ "description": "Per-mode pricing keyed by mode name (e.g. 'managed', 'byok'). Each value is a nested pricing object of any type.",
995
+ "minProperties": 1,
996
+ "title": "Modes",
997
+ "type": "object"
998
+ },
999
+ "price": {
1000
+ "anyOf": [
1001
+ {
1002
+ "type": "string"
1003
+ },
1004
+ {
1005
+ "type": "null"
1006
+ }
1007
+ ],
1008
+ "default": null,
1009
+ "description": "Summary price for marketplace comparison and sorting. For simple pricing types this is the billing rate. For composite types (add, multiply, tiered, graduated) this is a seller-provided nominal value for marketplace display.",
1010
+ "title": "Price"
1011
+ },
1012
+ "reference": {
1013
+ "anyOf": [
1014
+ {
1015
+ "type": "string"
1016
+ },
1017
+ {
1018
+ "type": "null"
1019
+ }
1020
+ ],
1021
+ "default": null,
1022
+ "description": "URL to upstream provider's pricing page",
1023
+ "title": "Reference"
1024
+ },
1025
+ "type": {
1026
+ "const": "mode",
1027
+ "default": "mode",
1028
+ "title": "Type",
1029
+ "type": "string"
1030
+ }
1031
+ },
1032
+ "required": [
1033
+ "default",
1034
+ "modes"
1035
+ ],
1036
+ "title": "ModePriceData",
1037
+ "type": "object"
1038
+ },
967
1039
  "MultiplyPriceData": {
968
1040
  "additionalProperties": false,
969
1041
  "description": "Composite pricing that multiplies a base price by a factor.\n\nUseful for applying percentage-based adjustments to a base price.\n\nExample:\n {\n \"type\": \"multiply\",\n \"factor\": \"0.70\",\n \"base\": {\"type\": \"one_million_tokens\", \"input\": \"0.50\", \"output\": \"1.50\"}\n }",
@@ -1867,6 +1939,7 @@
1867
1939
  "image": "#/$defs/ImagePriceData",
1868
1940
  "max": "#/$defs/MaxPriceData",
1869
1941
  "min": "#/$defs/MinPriceData",
1942
+ "mode": "#/$defs/ModePriceData",
1870
1943
  "multiply": "#/$defs/MultiplyPriceData",
1871
1944
  "one_byte": "#/$defs/DataPriceData",
1872
1945
  "one_day": "#/$defs/TimePriceData",
@@ -1936,6 +2009,9 @@
1936
2009
  },
1937
2010
  {
1938
2011
  "$ref": "#/$defs/ExprPriceData"
2012
+ },
2013
+ {
2014
+ "$ref": "#/$defs/ModePriceData"
1939
2015
  }
1940
2016
  ]
1941
2017
  },
@@ -834,6 +834,78 @@
834
834
  "title": "MinPriceData",
835
835
  "type": "object"
836
836
  },
837
+ "ModePriceData": {
838
+ "additionalProperties": false,
839
+ "description": "Mode-based pricing that selects a sub-price by the request's resolved mode.\n\nThe active mode is decided elsewhere — e.g. the gateway picks which keyed\n``upstream_access_config`` a request routes through (``\"managed\"``,\n``\"byok\"``, ``\"byoe\"``, …) — and threads it into\n``calculate_cost(..., mode=...)``. The matching entry in ``modes`` is\nevaluated; when the mode is unknown or unset, the ``default`` mode is used.\n\nThere is no special-casing on the consumer side: every pricing type accepts\nan optional ``mode`` and a mode-independent price simply ignores it, so a\nplain price behaves identically for all modes. Mode-dependent pricing is\njust this one extra optional pricing type.\n\nEach ``modes`` value is itself any pricing object (flat, composite, tiered,\nor even nested mode-based), validated lazily — mirroring how tiered pricing\nholds per-tier prices.\n\nExample:\n {\n \"type\": \"mode\",\n \"default\": \"managed\",\n \"modes\": {\n \"managed\": {\"type\": \"one_million_tokens\", \"input\": \"0.50\", \"output\": \"1.50\"},\n \"byok\": {\"type\": \"constant\", \"price\": \"0\"}\n }\n }",
840
+ "properties": {
841
+ "default": {
842
+ "description": "Mode key evaluated when the request's mode is unknown or unset. Must be one of the keys in `modes`.",
843
+ "title": "Default",
844
+ "type": "string"
845
+ },
846
+ "description": {
847
+ "anyOf": [
848
+ {
849
+ "type": "string"
850
+ },
851
+ {
852
+ "type": "null"
853
+ }
854
+ ],
855
+ "default": null,
856
+ "description": "Human-readable description of the pricing model",
857
+ "title": "Description"
858
+ },
859
+ "modes": {
860
+ "additionalProperties": {
861
+ "additionalProperties": true,
862
+ "type": "object"
863
+ },
864
+ "description": "Per-mode pricing keyed by mode name (e.g. 'managed', 'byok'). Each value is a nested pricing object of any type.",
865
+ "minProperties": 1,
866
+ "title": "Modes",
867
+ "type": "object"
868
+ },
869
+ "price": {
870
+ "anyOf": [
871
+ {
872
+ "type": "string"
873
+ },
874
+ {
875
+ "type": "null"
876
+ }
877
+ ],
878
+ "default": null,
879
+ "description": "Summary price for marketplace comparison and sorting. For simple pricing types this is the billing rate. For composite types (add, multiply, tiered, graduated) this is a seller-provided nominal value for marketplace display.",
880
+ "title": "Price"
881
+ },
882
+ "reference": {
883
+ "anyOf": [
884
+ {
885
+ "type": "string"
886
+ },
887
+ {
888
+ "type": "null"
889
+ }
890
+ ],
891
+ "default": null,
892
+ "description": "URL to upstream provider's pricing page",
893
+ "title": "Reference"
894
+ },
895
+ "type": {
896
+ "const": "mode",
897
+ "default": "mode",
898
+ "title": "Type",
899
+ "type": "string"
900
+ }
901
+ },
902
+ "required": [
903
+ "default",
904
+ "modes"
905
+ ],
906
+ "title": "ModePriceData",
907
+ "type": "object"
908
+ },
837
909
  "MultiplyPriceData": {
838
910
  "additionalProperties": false,
839
911
  "description": "Composite pricing that multiplies a base price by a factor.\n\nUseful for applying percentage-based adjustments to a base price.\n\nExample:\n {\n \"type\": \"multiply\",\n \"factor\": \"0.70\",\n \"base\": {\"type\": \"one_million_tokens\", \"input\": \"0.50\", \"output\": \"1.50\"}\n }",
@@ -1949,6 +2021,7 @@
1949
2021
  "image": "#/$defs/ImagePriceData",
1950
2022
  "max": "#/$defs/MaxPriceData",
1951
2023
  "min": "#/$defs/MinPriceData",
2024
+ "mode": "#/$defs/ModePriceData",
1952
2025
  "multiply": "#/$defs/MultiplyPriceData",
1953
2026
  "one_byte": "#/$defs/DataPriceData",
1954
2027
  "one_day": "#/$defs/TimePriceData",
@@ -2018,6 +2091,9 @@
2018
2091
  },
2019
2092
  {
2020
2093
  "$ref": "#/$defs/ExprPriceData"
2094
+ },
2095
+ {
2096
+ "$ref": "#/$defs/ModePriceData"
2021
2097
  }
2022
2098
  ]
2023
2099
  },
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: unitysvc-core
3
- Version: 0.2.2
3
+ Version: 0.2.4
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,8 +31,8 @@ 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_pricing.py
34
- tests/test_service_data.py
35
34
  tests/test_service_group.py
35
+ tests/test_service_status.py
36
36
  tests/test_utils.py
37
37
  tests/test_validator.py
38
38
  tests/test_validators.py
@@ -1806,3 +1806,124 @@ class TestNominalPriceAutoCompute:
1806
1806
  )
1807
1807
  # Explicit price on the child is preserved
1808
1808
  assert pricing.price == "9.00"
1809
+
1810
+
1811
+ class TestModePriceData:
1812
+ """Tests for mode-based pricing (select a sub-price by resolved mode)."""
1813
+
1814
+ def _managed_byok(self) -> dict:
1815
+ """Canonical managed+byok: managed is paid, byok is free.
1816
+
1817
+ Uses ``constant`` sub-prices so the tests exercise mode *selection*
1818
+ independently of any usage-metric math.
1819
+ """
1820
+ return {
1821
+ "type": "mode",
1822
+ "default": "managed",
1823
+ "modes": {
1824
+ "managed": {"type": "constant", "price": "6.00"},
1825
+ "byok": {"type": "constant", "price": "0"},
1826
+ },
1827
+ }
1828
+
1829
+ def test_selects_matching_mode(self) -> None:
1830
+ """The resolved mode picks that mode's price."""
1831
+ pricing = validate_pricing(self._managed_byok())
1832
+ usage = UsageData()
1833
+
1834
+ assert pricing.calculate_cost(usage, mode="managed") == Decimal("6.00")
1835
+ assert pricing.calculate_cost(usage, mode="byok") == Decimal("0")
1836
+
1837
+ def test_unknown_mode_falls_back_to_default(self) -> None:
1838
+ """An unrecognised mode evaluates the default mode."""
1839
+ pricing = validate_pricing(self._managed_byok())
1840
+
1841
+ assert pricing.calculate_cost(UsageData(), mode="nonexistent") == Decimal("6.00")
1842
+
1843
+ def test_none_mode_falls_back_to_default(self) -> None:
1844
+ """No mode (the common case for non-mode-aware callers) uses the default."""
1845
+ pricing = validate_pricing(self._managed_byok())
1846
+
1847
+ assert pricing.calculate_cost(UsageData()) == Decimal("6.00")
1848
+
1849
+ def test_non_mode_price_ignores_mode(self) -> None:
1850
+ """A plain (mode-independent) price yields the same cost for every mode."""
1851
+ pricing = validate_pricing({"type": "constant", "price": "6.00"})
1852
+ usage = UsageData()
1853
+
1854
+ base = pricing.calculate_cost(usage)
1855
+ assert pricing.calculate_cost(usage, mode="byok") == base
1856
+ assert pricing.calculate_cost(usage, mode="managed") == base
1857
+ assert base == Decimal("6.00")
1858
+
1859
+ def test_default_must_exist(self) -> None:
1860
+ """`default` must name one of the modes."""
1861
+ with pytest.raises(ValueError, match="default mode"):
1862
+ validate_pricing(
1863
+ {
1864
+ "type": "mode",
1865
+ "default": "missing",
1866
+ "modes": {"managed": {"type": "constant", "price": "1"}},
1867
+ }
1868
+ )
1869
+
1870
+ def test_nominal_price_from_default_mode(self) -> None:
1871
+ """The summary price auto-computes from the default mode when unset."""
1872
+ pricing = validate_pricing(self._managed_byok())
1873
+ assert pricing.price == "6.00" # managed (default) summary price
1874
+
1875
+ def test_explicit_nominal_price_preserved(self) -> None:
1876
+ """A seller-provided summary price is not overwritten."""
1877
+ data = self._managed_byok()
1878
+ data["price"] = "1.50"
1879
+ pricing = validate_pricing(data)
1880
+ assert pricing.price == "1.50"
1881
+
1882
+ def test_mode_threads_through_composite(self) -> None:
1883
+ """`mode` propagates into nested children (e.g. a mode price inside `add`)."""
1884
+ pricing = validate_pricing(
1885
+ {
1886
+ "type": "add",
1887
+ "prices": [
1888
+ {
1889
+ "type": "mode",
1890
+ "default": "managed",
1891
+ "modes": {
1892
+ "managed": {"type": "constant", "price": "10"},
1893
+ "byok": {"type": "constant", "price": "0"},
1894
+ },
1895
+ },
1896
+ {"type": "constant", "price": "1.00"}, # flat platform fee
1897
+ ],
1898
+ }
1899
+ )
1900
+ usage = UsageData()
1901
+
1902
+ # byok: 0 (mode price) + 1.00 (fee); managed: 10 + 1.00
1903
+ assert pricing.calculate_cost(usage, mode="byok") == Decimal("1.00")
1904
+ assert pricing.calculate_cost(usage, mode="managed") == Decimal("11.00")
1905
+
1906
+ def test_mode_price_can_nest_tiered(self) -> None:
1907
+ """A mode's pricing may itself be any pricing type (here: tiered)."""
1908
+ pricing = validate_pricing(
1909
+ {
1910
+ "type": "mode",
1911
+ "default": "managed",
1912
+ "modes": {
1913
+ "managed": {
1914
+ "type": "tiered",
1915
+ "based_on": "request_count",
1916
+ "tiers": [
1917
+ {"up_to": 100, "price": {"type": "constant", "price": "5"}},
1918
+ {"up_to": None, "price": {"type": "constant", "price": "50"}},
1919
+ ],
1920
+ },
1921
+ "byok": {"type": "constant", "price": "0"},
1922
+ },
1923
+ }
1924
+ )
1925
+ usage = UsageData()
1926
+
1927
+ assert pricing.calculate_cost(usage, request_count=10, mode="managed") == Decimal("5")
1928
+ assert pricing.calculate_cost(usage, request_count=10_000, mode="managed") == Decimal("50")
1929
+ assert pricing.calculate_cost(usage, request_count=10_000, mode="byok") == Decimal("0")
@@ -1,30 +1,30 @@
1
- """Tests for the ServiceData model (the service.json provenance structure)."""
1
+ """Tests for the ServiceStatus model (the service.json provenance structure)."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
5
  from datetime import datetime
6
6
  from uuid import UUID
7
7
 
8
- from unitysvc_core.models import ServiceData
8
+ from unitysvc_core.models import ServiceStatus
9
9
 
10
10
  _SID = "2c1f9e7a-0000-0000-0000-000000000000"
11
11
 
12
12
 
13
13
  def test_empty_service_data_has_no_identity() -> None:
14
- sd = ServiceData()
14
+ sd = ServiceStatus()
15
15
  assert sd.service_id is None
16
16
  assert sd.time_created is None
17
17
 
18
18
 
19
19
  def test_service_id_coerced_to_uuid() -> None:
20
- sd = ServiceData.model_validate({"service_id": _SID})
20
+ sd = ServiceStatus.model_validate({"service_id": _SID})
21
21
  assert sd.service_id == UUID(_SID)
22
22
 
23
23
 
24
24
  def test_time_created_and_extra_fields() -> None:
25
25
  # time_created is a recognized provenance field; unknown extras are
26
26
  # tolerated (extra="ignore") rather than rejected.
27
- sd = ServiceData.model_validate({"service_id": _SID, "time_created": "2026-01-01T00:00:00", "note": "x"})
27
+ sd = ServiceStatus.model_validate({"service_id": _SID, "time_created": "2026-01-01T00:00:00", "note": "x"})
28
28
  assert sd.time_created == datetime(2026, 1, 1, 0, 0)
29
29
  assert "note" not in sd.model_dump()
30
30
 
@@ -32,7 +32,7 @@ def test_time_created_and_extra_fields() -> None:
32
32
  def test_full_identity_record_round_trips() -> None:
33
33
  # The shape the ingest task returns and the seller persists to service.json.
34
34
  rev = "11111111-0000-0000-0000-000000000000"
35
- sd = ServiceData.model_validate(
35
+ sd = ServiceStatus.model_validate(
36
36
  {
37
37
  "service_id": _SID,
38
38
  "revision_of": rev,
File without changes
File without changes
File without changes