unitysvc-core 0.2.4__tar.gz → 0.2.7__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.4/src/unitysvc_core.egg-info → unitysvc_core-0.2.7}/PKG-INFO +1 -1
  2. {unitysvc_core-0.2.4 → unitysvc_core-0.2.7}/pyproject.toml +1 -1
  3. {unitysvc_core-0.2.4 → unitysvc_core-0.2.7}/src/unitysvc_core/models/__init__.py +2 -2
  4. {unitysvc_core-0.2.4 → unitysvc_core-0.2.7}/src/unitysvc_core/models/base.py +2 -2
  5. {unitysvc_core-0.2.4 → unitysvc_core-0.2.7}/src/unitysvc_core/models/offering_data.py +2 -2
  6. {unitysvc_core-0.2.4 → unitysvc_core-0.2.7}/src/unitysvc_core/models/offering_v1.py +15 -2
  7. {unitysvc_core-0.2.4 → unitysvc_core-0.2.7}/src/unitysvc_core/models/pricing.py +54 -54
  8. {unitysvc_core-0.2.4 → unitysvc_core-0.2.7}/src/unitysvc_core/models/service.py +12 -1
  9. {unitysvc_core-0.2.4 → unitysvc_core-0.2.7}/src/unitysvc_core/models/validators.py +61 -0
  10. {unitysvc_core-0.2.4 → unitysvc_core-0.2.7}/src/unitysvc_core/schema/listing_v1.json +74 -74
  11. {unitysvc_core-0.2.4 → unitysvc_core-0.2.7}/src/unitysvc_core/schema/offering_v1.json +76 -76
  12. {unitysvc_core-0.2.4 → unitysvc_core-0.2.7}/src/unitysvc_core/utils.py +77 -13
  13. {unitysvc_core-0.2.4 → unitysvc_core-0.2.7/src/unitysvc_core.egg-info}/PKG-INFO +1 -1
  14. {unitysvc_core-0.2.4 → unitysvc_core-0.2.7}/tests/test_pricing.py +38 -38
  15. {unitysvc_core-0.2.4 → unitysvc_core-0.2.7}/tests/test_utils.py +16 -18
  16. {unitysvc_core-0.2.4 → unitysvc_core-0.2.7}/tests/test_validators.py +40 -0
  17. {unitysvc_core-0.2.4 → unitysvc_core-0.2.7}/LICENSE +0 -0
  18. {unitysvc_core-0.2.4 → unitysvc_core-0.2.7}/README.md +0 -0
  19. {unitysvc_core-0.2.4 → unitysvc_core-0.2.7}/setup.cfg +0 -0
  20. {unitysvc_core-0.2.4 → unitysvc_core-0.2.7}/src/unitysvc_core/__init__.py +0 -0
  21. {unitysvc_core-0.2.4 → unitysvc_core-0.2.7}/src/unitysvc_core/models/documents.py +0 -0
  22. {unitysvc_core-0.2.4 → unitysvc_core-0.2.7}/src/unitysvc_core/models/listing_data.py +0 -0
  23. {unitysvc_core-0.2.4 → unitysvc_core-0.2.7}/src/unitysvc_core/models/listing_v1.py +0 -0
  24. {unitysvc_core-0.2.4 → unitysvc_core-0.2.7}/src/unitysvc_core/models/promotion_data.py +0 -0
  25. {unitysvc_core-0.2.4 → unitysvc_core-0.2.7}/src/unitysvc_core/models/provider_data.py +0 -0
  26. {unitysvc_core-0.2.4 → unitysvc_core-0.2.7}/src/unitysvc_core/models/provider_v1.py +0 -0
  27. {unitysvc_core-0.2.4 → unitysvc_core-0.2.7}/src/unitysvc_core/models/service_group_data.py +0 -0
  28. {unitysvc_core-0.2.4 → unitysvc_core-0.2.7}/src/unitysvc_core/py.typed +0 -0
  29. {unitysvc_core-0.2.4 → unitysvc_core-0.2.7}/src/unitysvc_core/schema/base.json +0 -0
  30. {unitysvc_core-0.2.4 → unitysvc_core-0.2.7}/src/unitysvc_core/schema/promotion_v1.json +0 -0
  31. {unitysvc_core-0.2.4 → unitysvc_core-0.2.7}/src/unitysvc_core/schema/provider_v1.json +0 -0
  32. {unitysvc_core-0.2.4 → unitysvc_core-0.2.7}/src/unitysvc_core/schema/service_group_v1.json +0 -0
  33. {unitysvc_core-0.2.4 → unitysvc_core-0.2.7}/src/unitysvc_core/validator.py +0 -0
  34. {unitysvc_core-0.2.4 → unitysvc_core-0.2.7}/src/unitysvc_core.egg-info/SOURCES.txt +0 -0
  35. {unitysvc_core-0.2.4 → unitysvc_core-0.2.7}/src/unitysvc_core.egg-info/dependency_links.txt +0 -0
  36. {unitysvc_core-0.2.4 → unitysvc_core-0.2.7}/src/unitysvc_core.egg-info/requires.txt +0 -0
  37. {unitysvc_core-0.2.4 → unitysvc_core-0.2.7}/src/unitysvc_core.egg-info/top_level.txt +0 -0
  38. {unitysvc_core-0.2.4 → unitysvc_core-0.2.7}/tests/test_service_group.py +0 -0
  39. {unitysvc_core-0.2.4 → unitysvc_core-0.2.7}/tests/test_service_status.py +0 -0
  40. {unitysvc_core-0.2.4 → unitysvc_core-0.2.7}/tests/test_validator.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: unitysvc-core
3
- Version: 0.2.4
3
+ Version: 0.2.7
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.4"
7
+ version = "0.2.7"
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" }]
@@ -33,6 +33,7 @@ from .offering_v1 import OfferingV1
33
33
  from .pricing import (
34
34
  AddPriceData,
35
35
  BasePriceData,
36
+ ChannelPriceData,
36
37
  ConstantPriceData,
37
38
  CountPriceData,
38
39
  DataPriceData,
@@ -43,7 +44,6 @@ from .pricing import (
43
44
  ImagePriceData,
44
45
  MaxPriceData,
45
46
  MinPriceData,
46
- ModePriceData,
47
47
  MultiplyPriceData,
48
48
  PercentageStr,
49
49
  PriceStr,
@@ -159,7 +159,7 @@ __all__ = [
159
159
  "MultiplyPriceData",
160
160
  "MaxPriceData",
161
161
  "MinPriceData",
162
- "ModePriceData",
162
+ "ChannelPriceData",
163
163
  "FirstPriceData",
164
164
  "TieredPriceData",
165
165
  "GraduatedPriceData",
@@ -216,8 +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
+ # Channel-based pricing: select a sub-price by the request's resolved channel
220
+ channel = "channel" # Per-channel pricing keyed by channel name (e.g. managed, byok)
221
221
 
222
222
 
223
223
  class QuotaResetCycleEnum(StrEnum):
@@ -91,10 +91,10 @@ class ServiceOfferingData(BaseModel):
91
91
  description="Payout pricing: How to calculate seller payout",
92
92
  )
93
93
 
94
- # Access interfaces (keyed by name)
94
+ # Access channels (keyed by channel name)
95
95
  upstream_access_config: dict[str, dict[str, Any]] | None = Field(
96
96
  default=None,
97
- description="Upstream access interfaces, keyed by name",
97
+ description="Upstream access channels, keyed by channel name",
98
98
  )
99
99
 
100
100
  # Documents (keyed by title)
@@ -7,7 +7,7 @@ from .documents import DocumentData
7
7
  from .offering_data import ServiceOfferingData
8
8
  from .pricing import Pricing
9
9
  from .service import UpstreamAccessConfigData
10
- from .validators import validate_service_identifier
10
+ from .validators import validate_channel_name, validate_service_identifier
11
11
 
12
12
 
13
13
  class OfferingV1(ServiceOfferingData):
@@ -46,7 +46,7 @@ class OfferingV1(ServiceOfferingData):
46
46
 
47
47
  # Override with typed models for file validation
48
48
  upstream_access_config: dict[str, UpstreamAccessConfigData] = Field( # type: ignore[assignment]
49
- description="Upstream access interfaces, keyed by name",
49
+ description="Upstream access channels, keyed by channel name",
50
50
  )
51
51
 
52
52
  documents: dict[str, DocumentData] | None = Field( # type: ignore[assignment]
@@ -69,3 +69,16 @@ class OfferingV1(ServiceOfferingData):
69
69
  ``validate_service_identifier`` for full rules.
70
70
  """
71
71
  return validate_service_identifier(v, "service")
72
+
73
+ @field_validator("upstream_access_config")
74
+ @classmethod
75
+ def validate_channel_names(cls, v: dict[str, UpstreamAccessConfigData]) -> dict[str, UpstreamAccessConfigData]:
76
+ """Validate every channel name (key of ``upstream_access_config``).
77
+
78
+ Channel names are selected via the ``<name>@<channel>`` identifier
79
+ suffix, so each must follow the channel-name grammar and must not
80
+ contain ``@``. See ``validate_channel_name``.
81
+ """
82
+ for channel_name in v:
83
+ validate_channel_name(channel_name)
84
+ return v
@@ -280,7 +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
+ channel: str | None = None,
284
284
  ) -> Decimal:
285
285
  """Calculate cost for token-based pricing.
286
286
 
@@ -347,7 +347,7 @@ class TimePriceData(BasePriceData):
347
347
  usage: UsageData,
348
348
  customer_charge: Decimal | None = None,
349
349
  request_count: int | None = None,
350
- mode: str | None = None,
350
+ channel: str | None = None,
351
351
  ) -> Decimal:
352
352
  """Calculate cost for time-based pricing.
353
353
 
@@ -388,7 +388,7 @@ class ImagePriceData(BasePriceData):
388
388
  usage: UsageData,
389
389
  customer_charge: Decimal | None = None,
390
390
  request_count: int | None = None,
391
- mode: str | None = None,
391
+ channel: str | None = None,
392
392
  ) -> Decimal:
393
393
  """Calculate cost for image-based pricing.
394
394
 
@@ -425,7 +425,7 @@ class StepPriceData(BasePriceData):
425
425
  usage: UsageData,
426
426
  customer_charge: Decimal | None = None,
427
427
  request_count: int | None = None,
428
- mode: str | None = None,
428
+ channel: str | None = None,
429
429
  ) -> Decimal:
430
430
  """Calculate cost for step-based pricing.
431
431
 
@@ -465,7 +465,7 @@ class DataPriceData(BasePriceData):
465
465
  usage: UsageData,
466
466
  customer_charge: Decimal | None = None,
467
467
  request_count: int | None = None,
468
- mode: str | None = None,
468
+ channel: str | None = None,
469
469
  ) -> Decimal:
470
470
  """Calculate cost for data-volume pricing.
471
471
 
@@ -509,7 +509,7 @@ class CountPriceData(BasePriceData):
509
509
  usage: UsageData,
510
510
  customer_charge: Decimal | None = None,
511
511
  request_count: int | None = None,
512
- mode: str | None = None,
512
+ channel: str | None = None,
513
513
  ) -> Decimal:
514
514
  """Calculate cost for count-scaled pricing.
515
515
 
@@ -572,7 +572,7 @@ class RevenueSharePriceData(BasePriceData):
572
572
  usage: UsageData,
573
573
  customer_charge: Decimal | None = None,
574
574
  request_count: int | None = None,
575
- mode: str | None = None,
575
+ channel: str | None = None,
576
576
  ) -> Decimal:
577
577
  """Calculate cost for revenue share pricing.
578
578
 
@@ -612,7 +612,7 @@ class ConstantPriceData(BasePriceData):
612
612
  usage: UsageData,
613
613
  customer_charge: Decimal | None = None,
614
614
  request_count: int | None = None,
615
- mode: str | None = None,
615
+ channel: str | None = None,
616
616
  ) -> Decimal:
617
617
  """Return the constant price regardless of usage.
618
618
 
@@ -685,7 +685,7 @@ class AddPriceData(BasePriceData):
685
685
  usage: UsageData,
686
686
  customer_charge: Decimal | None = None,
687
687
  request_count: int | None = None,
688
- mode: str | None = None,
688
+ channel: str | None = None,
689
689
  ) -> Decimal:
690
690
  """Calculate total cost by summing all price components.
691
691
 
@@ -700,7 +700,7 @@ class AddPriceData(BasePriceData):
700
700
  total = Decimal("0")
701
701
  for price_data in self.prices:
702
702
  component = validate_pricing(price_data)
703
- total += component.calculate_cost(usage, customer_charge, request_count, mode)
703
+ total += component.calculate_cost(usage, customer_charge, request_count, channel)
704
704
  return total
705
705
 
706
706
 
@@ -745,7 +745,7 @@ class MultiplyPriceData(BasePriceData):
745
745
  usage: UsageData,
746
746
  customer_charge: Decimal | None = None,
747
747
  request_count: int | None = None,
748
- mode: str | None = None,
748
+ channel: str | None = None,
749
749
  ) -> Decimal:
750
750
  """Calculate cost by multiplying base price by factor.
751
751
 
@@ -758,7 +758,7 @@ class MultiplyPriceData(BasePriceData):
758
758
  Base cost multiplied by factor
759
759
  """
760
760
  base_pricing = validate_pricing(self.base)
761
- base_cost = base_pricing.calculate_cost(usage, customer_charge, request_count, mode)
761
+ base_cost = base_pricing.calculate_cost(usage, customer_charge, request_count, channel)
762
762
  return base_cost * Decimal(self.factor)
763
763
 
764
764
 
@@ -799,7 +799,7 @@ class MaxPriceData(BasePriceData):
799
799
  usage: UsageData,
800
800
  customer_charge: Decimal | None = None,
801
801
  request_count: int | None = None,
802
- mode: str | None = None,
802
+ channel: str | None = None,
803
803
  ) -> Decimal:
804
804
  """Return the highest cost among applicable children.
805
805
 
@@ -810,7 +810,7 @@ class MaxPriceData(BasePriceData):
810
810
  for price_data in self.prices:
811
811
  try:
812
812
  component = validate_pricing(price_data)
813
- costs.append(component.calculate_cost(usage, customer_charge, request_count, mode))
813
+ costs.append(component.calculate_cost(usage, customer_charge, request_count, channel))
814
814
  except ValueError:
815
815
  continue
816
816
  if not costs:
@@ -857,7 +857,7 @@ class MinPriceData(BasePriceData):
857
857
  usage: UsageData,
858
858
  customer_charge: Decimal | None = None,
859
859
  request_count: int | None = None,
860
- mode: str | None = None,
860
+ channel: str | None = None,
861
861
  ) -> Decimal:
862
862
  """Return the lowest cost among applicable children.
863
863
 
@@ -868,7 +868,7 @@ class MinPriceData(BasePriceData):
868
868
  for price_data in self.prices:
869
869
  try:
870
870
  component = validate_pricing(price_data)
871
- costs.append(component.calculate_cost(usage, customer_charge, request_count, mode))
871
+ costs.append(component.calculate_cost(usage, customer_charge, request_count, channel))
872
872
  except ValueError:
873
873
  continue
874
874
  if not costs:
@@ -914,7 +914,7 @@ class FirstPriceData(BasePriceData):
914
914
  usage: UsageData,
915
915
  customer_charge: Decimal | None = None,
916
916
  request_count: int | None = None,
917
- mode: str | None = None,
917
+ channel: str | None = None,
918
918
  ) -> Decimal:
919
919
  """Return the cost from the first child that can handle the usage.
920
920
 
@@ -925,7 +925,7 @@ class FirstPriceData(BasePriceData):
925
925
  for price_data in self.prices:
926
926
  try:
927
927
  component = validate_pricing(price_data)
928
- return component.calculate_cost(usage, customer_charge, request_count, mode)
928
+ return component.calculate_cost(usage, customer_charge, request_count, channel)
929
929
  except ValueError:
930
930
  continue
931
931
  raise ValueError("No child pricing could handle the provided usage data")
@@ -1140,7 +1140,7 @@ class ExprPriceData(BasePriceData):
1140
1140
  usage: UsageData,
1141
1141
  customer_charge: Decimal | None = None,
1142
1142
  request_count: int | None = None,
1143
- mode: str | None = None,
1143
+ channel: str | None = None,
1144
1144
  ) -> Decimal:
1145
1145
  """Calculate cost by evaluating the expression with usage data.
1146
1146
 
@@ -1214,7 +1214,7 @@ class TieredPriceData(BasePriceData):
1214
1214
  usage: UsageData,
1215
1215
  customer_charge: Decimal | None = None,
1216
1216
  request_count: int | None = None,
1217
- mode: str | None = None,
1217
+ channel: str | None = None,
1218
1218
  ) -> Decimal:
1219
1219
  """Calculate cost based on which tier the usage falls into.
1220
1220
 
@@ -1232,7 +1232,7 @@ class TieredPriceData(BasePriceData):
1232
1232
  for tier in self.tiers:
1233
1233
  if tier.up_to is None or metric_value <= tier.up_to:
1234
1234
  tier_pricing = validate_pricing(tier.price)
1235
- return tier_pricing.calculate_cost(usage, customer_charge, request_count, mode)
1235
+ return tier_pricing.calculate_cost(usage, customer_charge, request_count, channel)
1236
1236
 
1237
1237
  # Should not reach here if tiers are properly configured
1238
1238
  raise ValueError("No matching tier found")
@@ -1297,7 +1297,7 @@ class GraduatedPriceData(BasePriceData):
1297
1297
  usage: UsageData,
1298
1298
  customer_charge: Decimal | None = None,
1299
1299
  request_count: int | None = None,
1300
- mode: str | None = None,
1300
+ channel: str | None = None,
1301
1301
  ) -> Decimal:
1302
1302
  """Calculate cost with graduated pricing across tiers.
1303
1303
 
@@ -1333,56 +1333,56 @@ class GraduatedPriceData(BasePriceData):
1333
1333
  return total_cost
1334
1334
 
1335
1335
 
1336
- class ModePriceData(BasePriceData):
1336
+ class ChannelPriceData(BasePriceData):
1337
1337
  """
1338
- Mode-based pricing that selects a sub-price by the request's resolved mode.
1338
+ Channel-based pricing that selects a sub-price by the request's resolved channel.
1339
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.
1340
+ The active channel is decided elsewhere — e.g. the gateway picks which keyed
1341
+ ``upstream_access_config`` (upstream access channel) a request routes through
1342
+ (``"managed"``, ``"byok"``, ``"byoe"``, …) — and threads it into
1343
+ ``calculate_cost(..., channel=...)``. The matching entry in ``channels`` is
1344
+ evaluated; when the channel is unknown or unset, the ``default`` channel is used.
1345
1345
 
1346
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
1347
+ an optional ``channel`` and a channel-independent price simply ignores it, so a
1348
+ plain price behaves identically for all channels. Channel-dependent pricing is
1349
1349
  just this one extra optional pricing type.
1350
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
1351
+ Each ``channels`` value is itself any pricing object (flat, composite, tiered,
1352
+ or even nested channel-based), validated lazily — mirroring how tiered pricing
1353
1353
  holds per-tier prices.
1354
1354
 
1355
1355
  Example:
1356
1356
  {
1357
- "type": "mode",
1357
+ "type": "channel",
1358
1358
  "default": "managed",
1359
- "modes": {
1359
+ "channels": {
1360
1360
  "managed": {"type": "one_million_tokens", "input": "0.50", "output": "1.50"},
1361
1361
  "byok": {"type": "constant", "price": "0"}
1362
1362
  }
1363
1363
  }
1364
1364
  """
1365
1365
 
1366
- type: Literal["mode"] = "mode"
1366
+ type: Literal["channel"] = "channel"
1367
1367
 
1368
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`.",
1369
+ description="Channel name evaluated when the request's channel is unknown or unset. "
1370
+ "Must be one of the keys in `channels`.",
1371
1371
  )
1372
1372
 
1373
- modes: dict[str, dict[str, Any]] = Field(
1374
- description="Per-mode pricing keyed by mode name (e.g. 'managed', 'byok'). "
1373
+ channels: dict[str, dict[str, Any]] = Field(
1374
+ description="Per-channel pricing keyed by channel name (e.g. 'managed', 'byok'). "
1375
1375
  "Each value is a nested pricing object of any type.",
1376
1376
  min_length=1,
1377
1377
  )
1378
1378
 
1379
1379
  @model_validator(mode="after")
1380
- def _validate_default_and_price(self) -> ModePriceData:
1380
+ def _validate_default_and_price(self) -> ChannelPriceData:
1381
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)})")
1382
+ if self.default not in self.channels:
1383
+ raise ValueError(f"default channel {self.default!r} is not defined in channels ({sorted(self.channels)})")
1384
1384
  if self.price is None:
1385
- self.price = _extract_nominal_price(self.modes[self.default])
1385
+ self.price = _extract_nominal_price(self.channels[self.default])
1386
1386
  return self
1387
1387
 
1388
1388
  def calculate_cost(
@@ -1390,24 +1390,24 @@ class ModePriceData(BasePriceData):
1390
1390
  usage: UsageData,
1391
1391
  customer_charge: Decimal | None = None,
1392
1392
  request_count: int | None = None,
1393
- mode: str | None = None,
1393
+ channel: str | None = None,
1394
1394
  ) -> Decimal:
1395
- """Evaluate the price for the resolved ``mode`` (falling back to ``default``).
1395
+ """Evaluate the price for the resolved ``channel`` (falling back to ``default``).
1396
1396
 
1397
1397
  Args:
1398
- usage: Usage data passed to the selected mode's price.
1398
+ usage: Usage data passed to the selected channel's price.
1399
1399
  customer_charge: Customer charge passed through.
1400
1400
  request_count: Request count passed through.
1401
- mode: The resolved mode; unknown/``None`` falls back to ``default``.
1401
+ channel: The resolved channel; unknown/``None`` falls back to ``default``.
1402
1402
 
1403
1403
  Returns:
1404
- Cost from the selected mode's pricing.
1404
+ Cost from the selected channel's pricing.
1405
1405
  """
1406
- selected = self.modes.get(mode) if mode is not None else None
1406
+ selected = self.channels.get(channel) if channel is not None else None
1407
1407
  if selected is None:
1408
- selected = self.modes[self.default]
1408
+ selected = self.channels[self.default]
1409
1409
  pricing = validate_pricing(selected)
1410
- return pricing.calculate_cost(usage, customer_charge, request_count, mode)
1410
+ return pricing.calculate_cost(usage, customer_charge, request_count, channel)
1411
1411
 
1412
1412
 
1413
1413
  # Discriminated union of all pricing types
@@ -1430,7 +1430,7 @@ Pricing = Annotated[
1430
1430
  | TieredPriceData
1431
1431
  | GraduatedPriceData
1432
1432
  | ExprPriceData
1433
- | ModePriceData,
1433
+ | ChannelPriceData,
1434
1434
  Field(discriminator="type"),
1435
1435
  ]
1436
1436
 
@@ -1454,7 +1454,7 @@ def validate_pricing(
1454
1454
  | TieredPriceData
1455
1455
  | GraduatedPriceData
1456
1456
  | ExprPriceData
1457
- | ModePriceData
1457
+ | ChannelPriceData
1458
1458
  ):
1459
1459
  """
1460
1460
  Validate pricing dict and return the appropriate typed model.
@@ -116,7 +116,18 @@ class AccessInterfaceData(BaseModel):
116
116
 
117
117
 
118
118
  class UpstreamAccessConfigData(AccessInterfaceData):
119
- """Upstream (seller-facing) access config data.
119
+ """One upstream access channel — a named entry in ``upstream_access_config``.
120
+
121
+ Each channel is a complete way for the gateway to reach the upstream: a wire
122
+ protocol (``access_method``), endpoint (``base_url``), credential (``api_key``),
123
+ routing key, and rate-limit/quality restrictions. A service may declare several
124
+ channels (keyed by free-form channel name, e.g. ``"managed"``, ``"byok"``,
125
+ ``"managed-eu"``); the gateway selects one per request.
126
+
127
+ The channel's *type* is derived from its credential/endpoint provenance:
128
+ ``managed`` (seller's key, ``${ secrets.* }``), ``byok`` (customer's key,
129
+ ``${ customer_secrets.* }``), or ``byoe`` (customer's key + customer-templated
130
+ ``base_url``).
120
131
 
121
132
  Extends AccessInterfaceData with extra="allow" to support protocol-specific
122
133
  configuration fields (e.g., S3 bucket/region, SMTP host/port) that the
@@ -191,6 +191,67 @@ def validate_service_identifier(name: str, entity_type: str) -> str:
191
191
  return name
192
192
 
193
193
 
194
+ def validate_channel_name(name: str, entity_type: str = "channel") -> str:
195
+ """Validate an upstream access-channel name.
196
+
197
+ A channel name is the key of an entry in ``upstream_access_config`` (and
198
+ the keys of channel-based pricing). It is also the value selected by the
199
+ ``@<channel>`` suffix of a service identifier (``<name>@<channel>``), so it
200
+ must satisfy the same per-segment grammar as a variant tag and, critically,
201
+ must **not** contain ``@`` — that character is the channel-selector
202
+ delimiter and would make the identifier ambiguous.
203
+
204
+ Rules:
205
+
206
+ - Non-empty.
207
+ - No ``@`` (reserved as the ``<name>@<channel>`` selector delimiter).
208
+ - Each ``/``-separated segment uses only letters, digits, ``.``, ``-``,
209
+ ``_`` and must start with an alphanumeric character. (Single-character
210
+ channel names are allowed, matching variant-tag rules — only bare
211
+ *service* names carry the ≥2-char primitive-prefix restriction.)
212
+
213
+ Args:
214
+ name: The channel name to validate.
215
+ entity_type: Label used in error messages (default ``"channel"``).
216
+
217
+ Returns:
218
+ The validated channel name (unchanged if valid).
219
+
220
+ Raises:
221
+ ValueError: If the channel name is empty, contains ``@``, or has an
222
+ invalid segment.
223
+
224
+ Examples:
225
+ Valid: ``managed``, ``byok``, ``byoe``, ``gateway``, ``apprise``,
226
+ ``eu-west``, ``p``.
227
+
228
+ Invalid: ``""`` (empty), ``byok@eu`` (contains ``@``),
229
+ ``-byok`` (must start alphanumeric), ``by//ok`` (empty segment).
230
+ """
231
+ if not name:
232
+ raise ValueError(f"Invalid {entity_type} name: name cannot be empty")
233
+
234
+ if "@" in name:
235
+ raise ValueError(
236
+ f"Invalid {entity_type} name '{name}': '@' is not allowed — it is the "
237
+ f"channel-selector delimiter in '<name>@<channel>' identifiers."
238
+ )
239
+
240
+ for segment in name.split("/"):
241
+ if not segment:
242
+ raise ValueError(
243
+ f"Invalid {entity_type} name '{name}': empty segment (consecutive or leading/trailing '/')."
244
+ )
245
+ if not _SERVICE_NAME_SEGMENT_RE.match(segment):
246
+ raise ValueError(
247
+ f"Invalid {entity_type} name '{name}': segment '{segment}' "
248
+ f"has invalid characters (allowed: letters, digits, '.', '-', "
249
+ f"'_'; must start with an alphanumeric character)."
250
+ )
251
+
252
+ return name
253
+
254
+
194
255
  SUPPORTED_SERVICE_OPTIONS: dict[str, type | tuple[type, ...]] = {
195
256
  "enrollment": dict, # Per-enrollment config: {scope, limit, limit_per_customer, limit_per_user}
196
257
  "routing_vars": dict, # Seller-managed operational variables for template resolution at request time