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.
- {unitysvc_core-0.2.4/src/unitysvc_core.egg-info → unitysvc_core-0.2.7}/PKG-INFO +1 -1
- {unitysvc_core-0.2.4 → unitysvc_core-0.2.7}/pyproject.toml +1 -1
- {unitysvc_core-0.2.4 → unitysvc_core-0.2.7}/src/unitysvc_core/models/__init__.py +2 -2
- {unitysvc_core-0.2.4 → unitysvc_core-0.2.7}/src/unitysvc_core/models/base.py +2 -2
- {unitysvc_core-0.2.4 → unitysvc_core-0.2.7}/src/unitysvc_core/models/offering_data.py +2 -2
- {unitysvc_core-0.2.4 → unitysvc_core-0.2.7}/src/unitysvc_core/models/offering_v1.py +15 -2
- {unitysvc_core-0.2.4 → unitysvc_core-0.2.7}/src/unitysvc_core/models/pricing.py +54 -54
- {unitysvc_core-0.2.4 → unitysvc_core-0.2.7}/src/unitysvc_core/models/service.py +12 -1
- {unitysvc_core-0.2.4 → unitysvc_core-0.2.7}/src/unitysvc_core/models/validators.py +61 -0
- {unitysvc_core-0.2.4 → unitysvc_core-0.2.7}/src/unitysvc_core/schema/listing_v1.json +74 -74
- {unitysvc_core-0.2.4 → unitysvc_core-0.2.7}/src/unitysvc_core/schema/offering_v1.json +76 -76
- {unitysvc_core-0.2.4 → unitysvc_core-0.2.7}/src/unitysvc_core/utils.py +77 -13
- {unitysvc_core-0.2.4 → unitysvc_core-0.2.7/src/unitysvc_core.egg-info}/PKG-INFO +1 -1
- {unitysvc_core-0.2.4 → unitysvc_core-0.2.7}/tests/test_pricing.py +38 -38
- {unitysvc_core-0.2.4 → unitysvc_core-0.2.7}/tests/test_utils.py +16 -18
- {unitysvc_core-0.2.4 → unitysvc_core-0.2.7}/tests/test_validators.py +40 -0
- {unitysvc_core-0.2.4 → unitysvc_core-0.2.7}/LICENSE +0 -0
- {unitysvc_core-0.2.4 → unitysvc_core-0.2.7}/README.md +0 -0
- {unitysvc_core-0.2.4 → unitysvc_core-0.2.7}/setup.cfg +0 -0
- {unitysvc_core-0.2.4 → unitysvc_core-0.2.7}/src/unitysvc_core/__init__.py +0 -0
- {unitysvc_core-0.2.4 → unitysvc_core-0.2.7}/src/unitysvc_core/models/documents.py +0 -0
- {unitysvc_core-0.2.4 → unitysvc_core-0.2.7}/src/unitysvc_core/models/listing_data.py +0 -0
- {unitysvc_core-0.2.4 → unitysvc_core-0.2.7}/src/unitysvc_core/models/listing_v1.py +0 -0
- {unitysvc_core-0.2.4 → unitysvc_core-0.2.7}/src/unitysvc_core/models/promotion_data.py +0 -0
- {unitysvc_core-0.2.4 → unitysvc_core-0.2.7}/src/unitysvc_core/models/provider_data.py +0 -0
- {unitysvc_core-0.2.4 → unitysvc_core-0.2.7}/src/unitysvc_core/models/provider_v1.py +0 -0
- {unitysvc_core-0.2.4 → unitysvc_core-0.2.7}/src/unitysvc_core/models/service_group_data.py +0 -0
- {unitysvc_core-0.2.4 → unitysvc_core-0.2.7}/src/unitysvc_core/py.typed +0 -0
- {unitysvc_core-0.2.4 → unitysvc_core-0.2.7}/src/unitysvc_core/schema/base.json +0 -0
- {unitysvc_core-0.2.4 → unitysvc_core-0.2.7}/src/unitysvc_core/schema/promotion_v1.json +0 -0
- {unitysvc_core-0.2.4 → unitysvc_core-0.2.7}/src/unitysvc_core/schema/provider_v1.json +0 -0
- {unitysvc_core-0.2.4 → unitysvc_core-0.2.7}/src/unitysvc_core/schema/service_group_v1.json +0 -0
- {unitysvc_core-0.2.4 → unitysvc_core-0.2.7}/src/unitysvc_core/validator.py +0 -0
- {unitysvc_core-0.2.4 → unitysvc_core-0.2.7}/src/unitysvc_core.egg-info/SOURCES.txt +0 -0
- {unitysvc_core-0.2.4 → unitysvc_core-0.2.7}/src/unitysvc_core.egg-info/dependency_links.txt +0 -0
- {unitysvc_core-0.2.4 → unitysvc_core-0.2.7}/src/unitysvc_core.egg-info/requires.txt +0 -0
- {unitysvc_core-0.2.4 → unitysvc_core-0.2.7}/src/unitysvc_core.egg-info/top_level.txt +0 -0
- {unitysvc_core-0.2.4 → unitysvc_core-0.2.7}/tests/test_service_group.py +0 -0
- {unitysvc_core-0.2.4 → unitysvc_core-0.2.7}/tests/test_service_status.py +0 -0
- {unitysvc_core-0.2.4 → unitysvc_core-0.2.7}/tests/test_validator.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "unitysvc-core"
|
|
7
|
-
version = "0.2.
|
|
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
|
-
"
|
|
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
|
-
#
|
|
220
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
1336
|
+
class ChannelPriceData(BasePriceData):
|
|
1337
1337
|
"""
|
|
1338
|
-
|
|
1338
|
+
Channel-based pricing that selects a sub-price by the request's resolved channel.
|
|
1339
1339
|
|
|
1340
|
-
The active
|
|
1341
|
-
``upstream_access_config`` a request routes through
|
|
1342
|
-
``"byok"``, ``"byoe"``, …) — and threads it into
|
|
1343
|
-
``calculate_cost(...,
|
|
1344
|
-
evaluated; when the
|
|
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 ``
|
|
1348
|
-
plain price behaves identically for all
|
|
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 ``
|
|
1352
|
-
or even nested
|
|
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": "
|
|
1357
|
+
"type": "channel",
|
|
1358
1358
|
"default": "managed",
|
|
1359
|
-
"
|
|
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["
|
|
1366
|
+
type: Literal["channel"] = "channel"
|
|
1367
1367
|
|
|
1368
1368
|
default: str = Field(
|
|
1369
|
-
description="
|
|
1370
|
-
"Must be one of the keys in `
|
|
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
|
-
|
|
1374
|
-
description="Per-
|
|
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) ->
|
|
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.
|
|
1383
|
-
raise ValueError(f"default
|
|
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.
|
|
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
|
-
|
|
1393
|
+
channel: str | None = None,
|
|
1394
1394
|
) -> Decimal:
|
|
1395
|
-
"""Evaluate the price for the resolved ``
|
|
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
|
|
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
|
-
|
|
1401
|
+
channel: The resolved channel; unknown/``None`` falls back to ``default``.
|
|
1402
1402
|
|
|
1403
1403
|
Returns:
|
|
1404
|
-
Cost from the selected
|
|
1404
|
+
Cost from the selected channel's pricing.
|
|
1405
1405
|
"""
|
|
1406
|
-
selected = self.
|
|
1406
|
+
selected = self.channels.get(channel) if channel is not None else None
|
|
1407
1407
|
if selected is None:
|
|
1408
|
-
selected = self.
|
|
1408
|
+
selected = self.channels[self.default]
|
|
1409
1409
|
pricing = validate_pricing(selected)
|
|
1410
|
-
return pricing.calculate_cost(usage, customer_charge, request_count,
|
|
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
|
-
|
|
|
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
|
-
|
|
|
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
|
-
"""
|
|
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
|