unitysvc-core 0.2.26__tar.gz → 0.2.27__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.26/src/unitysvc_core.egg-info → unitysvc_core-0.2.27}/PKG-INFO +1 -1
- {unitysvc_core-0.2.26 → unitysvc_core-0.2.27}/pyproject.toml +1 -1
- {unitysvc_core-0.2.26 → unitysvc_core-0.2.27}/src/unitysvc_core/models/offering_data.py +1 -2
- {unitysvc_core-0.2.26 → unitysvc_core-0.2.27}/src/unitysvc_core/models/offering_v1.py +0 -8
- {unitysvc_core-0.2.26 → unitysvc_core-0.2.27}/src/unitysvc_core/models/pricing.py +116 -3
- {unitysvc_core-0.2.26 → unitysvc_core-0.2.27}/src/unitysvc_core/schema/listing_v1.json +70 -1
- {unitysvc_core-0.2.26 → unitysvc_core-0.2.27}/src/unitysvc_core/schema/offering_v1.json +70 -1
- {unitysvc_core-0.2.26 → unitysvc_core-0.2.27/src/unitysvc_core.egg-info}/PKG-INFO +1 -1
- {unitysvc_core-0.2.26 → unitysvc_core-0.2.27}/src/unitysvc_core.egg-info/SOURCES.txt +1 -0
- unitysvc_core-0.2.27/tests/test_offering_data.py +14 -0
- {unitysvc_core-0.2.26 → unitysvc_core-0.2.27}/tests/test_pricing.py +151 -0
- {unitysvc_core-0.2.26 → unitysvc_core-0.2.27}/LICENSE +0 -0
- {unitysvc_core-0.2.26 → unitysvc_core-0.2.27}/README.md +0 -0
- {unitysvc_core-0.2.26 → unitysvc_core-0.2.27}/setup.cfg +0 -0
- {unitysvc_core-0.2.26 → unitysvc_core-0.2.27}/src/unitysvc_core/__init__.py +0 -0
- {unitysvc_core-0.2.26 → unitysvc_core-0.2.27}/src/unitysvc_core/models/__init__.py +0 -0
- {unitysvc_core-0.2.26 → unitysvc_core-0.2.27}/src/unitysvc_core/models/base.py +0 -0
- {unitysvc_core-0.2.26 → unitysvc_core-0.2.27}/src/unitysvc_core/models/documents.py +0 -0
- {unitysvc_core-0.2.26 → unitysvc_core-0.2.27}/src/unitysvc_core/models/listing_data.py +0 -0
- {unitysvc_core-0.2.26 → unitysvc_core-0.2.27}/src/unitysvc_core/models/listing_v1.py +0 -0
- {unitysvc_core-0.2.26 → unitysvc_core-0.2.27}/src/unitysvc_core/models/promotion_data.py +0 -0
- {unitysvc_core-0.2.26 → unitysvc_core-0.2.27}/src/unitysvc_core/models/provider_data.py +0 -0
- {unitysvc_core-0.2.26 → unitysvc_core-0.2.27}/src/unitysvc_core/models/provider_v1.py +0 -0
- {unitysvc_core-0.2.26 → unitysvc_core-0.2.27}/src/unitysvc_core/models/service.py +0 -0
- {unitysvc_core-0.2.26 → unitysvc_core-0.2.27}/src/unitysvc_core/models/service_group_data.py +0 -0
- {unitysvc_core-0.2.26 → unitysvc_core-0.2.27}/src/unitysvc_core/models/validators.py +0 -0
- {unitysvc_core-0.2.26 → unitysvc_core-0.2.27}/src/unitysvc_core/py.typed +0 -0
- {unitysvc_core-0.2.26 → unitysvc_core-0.2.27}/src/unitysvc_core/schema/base.json +0 -0
- {unitysvc_core-0.2.26 → unitysvc_core-0.2.27}/src/unitysvc_core/schema/promotion_v1.json +0 -0
- {unitysvc_core-0.2.26 → unitysvc_core-0.2.27}/src/unitysvc_core/schema/provider_v1.json +0 -0
- {unitysvc_core-0.2.26 → unitysvc_core-0.2.27}/src/unitysvc_core/schema/service_group_v1.json +0 -0
- {unitysvc_core-0.2.26 → unitysvc_core-0.2.27}/src/unitysvc_core/utils.py +0 -0
- {unitysvc_core-0.2.26 → unitysvc_core-0.2.27}/src/unitysvc_core/validator.py +0 -0
- {unitysvc_core-0.2.26 → unitysvc_core-0.2.27}/src/unitysvc_core.egg-info/dependency_links.txt +0 -0
- {unitysvc_core-0.2.26 → unitysvc_core-0.2.27}/src/unitysvc_core.egg-info/requires.txt +0 -0
- {unitysvc_core-0.2.26 → unitysvc_core-0.2.27}/src/unitysvc_core.egg-info/top_level.txt +0 -0
- {unitysvc_core-0.2.26 → unitysvc_core-0.2.27}/tests/test_access_interface_routing.py +0 -0
- {unitysvc_core-0.2.26 → unitysvc_core-0.2.27}/tests/test_hidden_path_skip.py +0 -0
- {unitysvc_core-0.2.26 → unitysvc_core-0.2.27}/tests/test_provider_account_rate_limits.py +0 -0
- {unitysvc_core-0.2.26 → unitysvc_core-0.2.27}/tests/test_service_group.py +0 -0
- {unitysvc_core-0.2.26 → unitysvc_core-0.2.27}/tests/test_service_status.py +0 -0
- {unitysvc_core-0.2.26 → unitysvc_core-0.2.27}/tests/test_utils.py +0 -0
- {unitysvc_core-0.2.26 → unitysvc_core-0.2.27}/tests/test_validator.py +0 -0
- {unitysvc_core-0.2.26 → unitysvc_core-0.2.27}/tests/test_validators.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.27"
|
|
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" }]
|
|
@@ -29,14 +29,6 @@ class OfferingV1(ServiceOfferingData):
|
|
|
29
29
|
# File-specific fields for validation
|
|
30
30
|
time_created: datetime
|
|
31
31
|
|
|
32
|
-
# Override to make required in file validation (base has Optional for API
|
|
33
|
-
# flexibility). ``summary`` needs no override — it is required on the base
|
|
34
|
-
# itself: the teaser replaces the retired two-mode description convention
|
|
35
|
-
# (unitysvc/unitysvc#1838), and the description is free-form long-form text.
|
|
36
|
-
description: str = Field( # type: ignore[assignment]
|
|
37
|
-
description="Service description (long-form; the summary carries the teaser)",
|
|
38
|
-
)
|
|
39
|
-
|
|
40
32
|
# Static information (optional — not all service types have meaningful details)
|
|
41
33
|
details: dict[str, Any] = Field( # type: ignore[assignment]
|
|
42
34
|
default_factory=dict,
|
|
@@ -110,6 +110,7 @@ class UsageData(BaseModel):
|
|
|
110
110
|
- **tokens**: one_token, one_thousand_tokens, one_million_tokens
|
|
111
111
|
- **data**: one_byte, one_kilobyte, one_megabyte, one_gigabyte
|
|
112
112
|
- **count**: count, one_thousand, one_million
|
|
113
|
+
- **characters**: characters, one_character, one_thousand_characters, one_million_characters
|
|
113
114
|
|
|
114
115
|
Only one field per group should be set per usage instance.
|
|
115
116
|
|
|
@@ -145,6 +146,24 @@ class UsageData(BaseModel):
|
|
|
145
146
|
one_megabyte: float | None = None
|
|
146
147
|
one_gigabyte: float | None = None
|
|
147
148
|
|
|
149
|
+
# Names the GATEWAY actually ships (usage_event.lua), kept as aliases so they
|
|
150
|
+
# survive ``extra="ignore"``. ``images_generated`` and ``duration_seconds``
|
|
151
|
+
# join their equivalence groups below; the byte counters deliberately do
|
|
152
|
+
# NOT — see DataPriceData.based_on.
|
|
153
|
+
images_generated: int | None = None
|
|
154
|
+
duration_seconds: float | None = None
|
|
155
|
+
bytes_in: int | None = None
|
|
156
|
+
bytes_out: int | None = None
|
|
157
|
+
|
|
158
|
+
# Character-based usage (text-to-speech and similar).
|
|
159
|
+
# ``characters`` is what providers actually report — DashScope's TTS answers
|
|
160
|
+
# ``usage: {"characters": N}`` — and is the group's base unit, the way
|
|
161
|
+
# ``seconds`` is for time.
|
|
162
|
+
characters: int | None = None
|
|
163
|
+
one_character: int | None = None
|
|
164
|
+
one_thousand_characters: float | None = None
|
|
165
|
+
one_million_characters: float | None = None
|
|
166
|
+
|
|
148
167
|
# Count-based usage (images, steps, requests)
|
|
149
168
|
# count + scaled variants form an equivalence group.
|
|
150
169
|
count: int | None = None
|
|
@@ -400,10 +419,13 @@ class ImagePriceData(BasePriceData):
|
|
|
400
419
|
Returns:
|
|
401
420
|
Calculated cost based on image count
|
|
402
421
|
"""
|
|
403
|
-
|
|
422
|
+
# Via the equivalence group, so the gateway's ``images_generated`` counts
|
|
423
|
+
# as well as an explicit ``count``.
|
|
424
|
+
units = _resolve_equivalent_metric("count", usage)
|
|
425
|
+
if units is None:
|
|
404
426
|
raise ValueError("Image pricing requires 'count' in usage data")
|
|
405
427
|
|
|
406
|
-
return Decimal(self.price) *
|
|
428
|
+
return Decimal(self.price) * units
|
|
407
429
|
|
|
408
430
|
|
|
409
431
|
class StepPriceData(BasePriceData):
|
|
@@ -452,10 +474,23 @@ class DataPriceData(BasePriceData):
|
|
|
452
474
|
|
|
453
475
|
Usage can be provided in **any** data unit — cross-unit conversion is
|
|
454
476
|
handled automatically via equivalence groups.
|
|
477
|
+
|
|
478
|
+
The gateway ships ``bytes_in`` and ``bytes_out`` on every request. Those are
|
|
479
|
+
two directions of one dimension, so they are deliberately NOT both in the
|
|
480
|
+
data equivalence group: a group lookup returns whichever field the dict
|
|
481
|
+
happens to yield first, which would make the billed direction depend on
|
|
482
|
+
iteration order. ``based_on`` names the direction instead, so it is a
|
|
483
|
+
property of the price rather than an accident of the payload.
|
|
455
484
|
"""
|
|
456
485
|
|
|
457
486
|
type: Literal["one_byte", "one_kilobyte", "one_megabyte", "one_gigabyte"] = "one_megabyte"
|
|
458
487
|
|
|
488
|
+
based_on: Literal["bytes_out", "bytes_in", "bytes_total"] = Field(
|
|
489
|
+
default="bytes_out",
|
|
490
|
+
description="Which byte counter to price. Defaults to egress, the "
|
|
491
|
+
"conventional billable direction; 'bytes_total' sums both.",
|
|
492
|
+
)
|
|
493
|
+
|
|
459
494
|
price: PriceStr = Field(
|
|
460
495
|
description="Price per one unit of the specified type",
|
|
461
496
|
)
|
|
@@ -477,7 +512,24 @@ class DataPriceData(BasePriceData):
|
|
|
477
512
|
Returns:
|
|
478
513
|
Calculated cost based on data usage
|
|
479
514
|
"""
|
|
480
|
-
|
|
515
|
+
# The named byte counter wins when present — it is what the gateway
|
|
516
|
+
# actually sends. Fall back to the canonical one_byte..one_gigabyte
|
|
517
|
+
# units so a caller supplying those directly is unaffected.
|
|
518
|
+
raw_bytes: Decimal | None = None
|
|
519
|
+
if self.based_on == "bytes_total":
|
|
520
|
+
if usage.bytes_in is not None or usage.bytes_out is not None:
|
|
521
|
+
raw_bytes = Decimal(usage.bytes_in or 0) + Decimal(usage.bytes_out or 0)
|
|
522
|
+
else:
|
|
523
|
+
value = getattr(usage, self.based_on, None)
|
|
524
|
+
if value is not None:
|
|
525
|
+
raw_bytes = Decimal(str(value))
|
|
526
|
+
|
|
527
|
+
units: Decimal | None
|
|
528
|
+
if raw_bytes is not None:
|
|
529
|
+
units = raw_bytes / EQUIVALENCE_GROUPS["data"][self.type]
|
|
530
|
+
else:
|
|
531
|
+
units = _resolve_equivalent_metric(self.type, usage)
|
|
532
|
+
|
|
481
533
|
if units is None:
|
|
482
534
|
raise ValueError(f"Data pricing ({self.type}) requires a data field in usage data")
|
|
483
535
|
|
|
@@ -528,6 +580,57 @@ class CountPriceData(BasePriceData):
|
|
|
528
580
|
return Decimal(self.price) * units
|
|
529
581
|
|
|
530
582
|
|
|
583
|
+
class CharacterPriceData(BasePriceData):
|
|
584
|
+
"""
|
|
585
|
+
Price data for character-scaled pricing (text-to-speech and similar).
|
|
586
|
+
|
|
587
|
+
Supported types: ``one_character``, ``one_thousand_characters``,
|
|
588
|
+
``one_million_characters``. The ``characters`` equivalence-group field is
|
|
589
|
+
the base unit, and it is the name providers actually report — DashScope's
|
|
590
|
+
TTS answers ``usage: {"characters": N}``.
|
|
591
|
+
|
|
592
|
+
Usage may be given in any unit of the group; conversion is automatic.
|
|
593
|
+
|
|
594
|
+
Separate from ``CountPriceData`` on purpose: characters and counts are
|
|
595
|
+
different dimensions, and collapsing them would let "per 1M characters" and
|
|
596
|
+
"per 1M images" resolve against each other's usage.
|
|
597
|
+
"""
|
|
598
|
+
|
|
599
|
+
type: Literal["one_character", "one_thousand_characters", "one_million_characters"] = "one_million_characters"
|
|
600
|
+
|
|
601
|
+
price: PriceStr = Field(
|
|
602
|
+
description="Price per one unit of the specified type",
|
|
603
|
+
)
|
|
604
|
+
|
|
605
|
+
def calculate_cost(
|
|
606
|
+
self,
|
|
607
|
+
usage: UsageData,
|
|
608
|
+
customer_charge: Decimal | None = None,
|
|
609
|
+
request_count: int | None = None,
|
|
610
|
+
channel: str | None = None,
|
|
611
|
+
) -> Decimal:
|
|
612
|
+
"""Calculate cost for character-scaled pricing.
|
|
613
|
+
|
|
614
|
+
Args:
|
|
615
|
+
usage: Usage data with any character field
|
|
616
|
+
customer_charge: Not used (ignored)
|
|
617
|
+
request_count: Not used (ignored)
|
|
618
|
+
|
|
619
|
+
Returns:
|
|
620
|
+
Calculated cost based on character usage
|
|
621
|
+
|
|
622
|
+
Raises:
|
|
623
|
+
ValueError: if no character field is populated. Raising rather than
|
|
624
|
+
treating absence as zero is deliberate — a silent zero here is a
|
|
625
|
+
charge of nothing for work actually done.
|
|
626
|
+
"""
|
|
627
|
+
units = _resolve_equivalent_metric(self.type, usage)
|
|
628
|
+
if units is None:
|
|
629
|
+
raise ValueError(f"Character pricing ({self.type}) requires a character field in usage data")
|
|
630
|
+
|
|
631
|
+
return Decimal(self.price) * units
|
|
632
|
+
|
|
633
|
+
|
|
531
634
|
def _validate_percentage_string(v: Any) -> str:
|
|
532
635
|
"""Validate that percentage values are strings representing valid decimals in range 0-100."""
|
|
533
636
|
# First use the standard price validation
|
|
@@ -940,6 +1043,7 @@ class FirstPriceData(BasePriceData):
|
|
|
940
1043
|
EQUIVALENCE_GROUPS: dict[str, dict[str, Decimal]] = {
|
|
941
1044
|
"time": {
|
|
942
1045
|
"seconds": Decimal(1),
|
|
1046
|
+
"duration_seconds": Decimal(1), # what usage_event.lua ships
|
|
943
1047
|
"one_second": Decimal(1),
|
|
944
1048
|
"one_minute": Decimal(60),
|
|
945
1049
|
"one_hour": Decimal(3600),
|
|
@@ -959,9 +1063,16 @@ EQUIVALENCE_GROUPS: dict[str, dict[str, Decimal]] = {
|
|
|
959
1063
|
},
|
|
960
1064
|
"count": {
|
|
961
1065
|
"count": Decimal(1),
|
|
1066
|
+
"images_generated": Decimal(1), # what usage_event.lua ships
|
|
962
1067
|
"one_thousand": Decimal(1000),
|
|
963
1068
|
"one_million": Decimal(1_000_000),
|
|
964
1069
|
},
|
|
1070
|
+
"characters": {
|
|
1071
|
+
"characters": Decimal(1),
|
|
1072
|
+
"one_character": Decimal(1),
|
|
1073
|
+
"one_thousand_characters": Decimal(1000),
|
|
1074
|
+
"one_million_characters": Decimal(1_000_000),
|
|
1075
|
+
},
|
|
965
1076
|
}
|
|
966
1077
|
|
|
967
1078
|
# Reverse lookup: unit_name → (group_name, base_factor)
|
|
@@ -1448,6 +1559,7 @@ Pricing = Annotated[
|
|
|
1448
1559
|
| TimePriceData
|
|
1449
1560
|
| DataPriceData
|
|
1450
1561
|
| CountPriceData
|
|
1562
|
+
| CharacterPriceData
|
|
1451
1563
|
| ImagePriceData
|
|
1452
1564
|
| StepPriceData
|
|
1453
1565
|
| RevenueSharePriceData
|
|
@@ -1472,6 +1584,7 @@ def validate_pricing(
|
|
|
1472
1584
|
| TimePriceData
|
|
1473
1585
|
| DataPriceData
|
|
1474
1586
|
| CountPriceData
|
|
1587
|
+
| CharacterPriceData
|
|
1475
1588
|
| ImagePriceData
|
|
1476
1589
|
| StepPriceData
|
|
1477
1590
|
| RevenueSharePriceData
|
|
@@ -217,6 +217,58 @@
|
|
|
217
217
|
"title": "ChannelPriceData",
|
|
218
218
|
"type": "object"
|
|
219
219
|
},
|
|
220
|
+
"CharacterPriceData": {
|
|
221
|
+
"additionalProperties": false,
|
|
222
|
+
"description": "Price data for character-scaled pricing (text-to-speech and similar).\n\nSupported types: ``one_character``, ``one_thousand_characters``,\n``one_million_characters``. The ``characters`` equivalence-group field is\nthe base unit, and it is the name providers actually report — DashScope's\nTTS answers ``usage: {\"characters\": N}``.\n\nUsage may be given in any unit of the group; conversion is automatic.\n\nSeparate from ``CountPriceData`` on purpose: characters and counts are\ndifferent dimensions, and collapsing them would let \"per 1M characters\" and\n\"per 1M images\" resolve against each other's usage.",
|
|
223
|
+
"properties": {
|
|
224
|
+
"description": {
|
|
225
|
+
"anyOf": [
|
|
226
|
+
{
|
|
227
|
+
"type": "string"
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
"type": "null"
|
|
231
|
+
}
|
|
232
|
+
],
|
|
233
|
+
"default": null,
|
|
234
|
+
"description": "Human-readable description of the pricing model",
|
|
235
|
+
"title": "Description"
|
|
236
|
+
},
|
|
237
|
+
"price": {
|
|
238
|
+
"description": "Price per one unit of the specified type",
|
|
239
|
+
"title": "Price",
|
|
240
|
+
"type": "string"
|
|
241
|
+
},
|
|
242
|
+
"reference": {
|
|
243
|
+
"anyOf": [
|
|
244
|
+
{
|
|
245
|
+
"type": "string"
|
|
246
|
+
},
|
|
247
|
+
{
|
|
248
|
+
"type": "null"
|
|
249
|
+
}
|
|
250
|
+
],
|
|
251
|
+
"default": null,
|
|
252
|
+
"description": "URL to upstream provider's pricing page",
|
|
253
|
+
"title": "Reference"
|
|
254
|
+
},
|
|
255
|
+
"type": {
|
|
256
|
+
"default": "one_million_characters",
|
|
257
|
+
"enum": [
|
|
258
|
+
"one_character",
|
|
259
|
+
"one_thousand_characters",
|
|
260
|
+
"one_million_characters"
|
|
261
|
+
],
|
|
262
|
+
"title": "Type",
|
|
263
|
+
"type": "string"
|
|
264
|
+
}
|
|
265
|
+
},
|
|
266
|
+
"required": [
|
|
267
|
+
"price"
|
|
268
|
+
],
|
|
269
|
+
"title": "CharacterPriceData",
|
|
270
|
+
"type": "object"
|
|
271
|
+
},
|
|
220
272
|
"ConstantPriceData": {
|
|
221
273
|
"additionalProperties": false,
|
|
222
274
|
"description": "Price data for a constant/fixed price.\n\nUsed for fixed fees, discounts, or adjustments that don't depend on usage.\nPrice can be positive (charge) or negative (discount/credit).",
|
|
@@ -341,8 +393,19 @@
|
|
|
341
393
|
},
|
|
342
394
|
"DataPriceData": {
|
|
343
395
|
"additionalProperties": false,
|
|
344
|
-
"description": "Price data for data-volume pricing (bandwidth, storage, transfer).\n\nSupported types: ``one_byte``, ``one_kilobyte``, ``one_megabyte``,\n``one_gigabyte``. Uses binary units (1 KB = 1024 bytes).\n\nUsage can be provided in **any** data unit — cross-unit conversion is\nhandled automatically via equivalence groups.",
|
|
396
|
+
"description": "Price data for data-volume pricing (bandwidth, storage, transfer).\n\nSupported types: ``one_byte``, ``one_kilobyte``, ``one_megabyte``,\n``one_gigabyte``. Uses binary units (1 KB = 1024 bytes).\n\nUsage can be provided in **any** data unit — cross-unit conversion is\nhandled automatically via equivalence groups.\n\nThe gateway ships ``bytes_in`` and ``bytes_out`` on every request. Those are\ntwo directions of one dimension, so they are deliberately NOT both in the\ndata equivalence group: a group lookup returns whichever field the dict\nhappens to yield first, which would make the billed direction depend on\niteration order. ``based_on`` names the direction instead, so it is a\nproperty of the price rather than an accident of the payload.",
|
|
345
397
|
"properties": {
|
|
398
|
+
"based_on": {
|
|
399
|
+
"default": "bytes_out",
|
|
400
|
+
"description": "Which byte counter to price. Defaults to egress, the conventional billable direction; 'bytes_total' sums both.",
|
|
401
|
+
"enum": [
|
|
402
|
+
"bytes_out",
|
|
403
|
+
"bytes_in",
|
|
404
|
+
"bytes_total"
|
|
405
|
+
],
|
|
406
|
+
"title": "Based On",
|
|
407
|
+
"type": "string"
|
|
408
|
+
},
|
|
346
409
|
"description": {
|
|
347
410
|
"anyOf": [
|
|
348
411
|
{
|
|
@@ -1447,17 +1510,20 @@
|
|
|
1447
1510
|
"min": "#/$defs/MinPriceData",
|
|
1448
1511
|
"multiply": "#/$defs/MultiplyPriceData",
|
|
1449
1512
|
"one_byte": "#/$defs/DataPriceData",
|
|
1513
|
+
"one_character": "#/$defs/CharacterPriceData",
|
|
1450
1514
|
"one_day": "#/$defs/TimePriceData",
|
|
1451
1515
|
"one_gigabyte": "#/$defs/DataPriceData",
|
|
1452
1516
|
"one_hour": "#/$defs/TimePriceData",
|
|
1453
1517
|
"one_kilobyte": "#/$defs/DataPriceData",
|
|
1454
1518
|
"one_megabyte": "#/$defs/DataPriceData",
|
|
1455
1519
|
"one_million": "#/$defs/CountPriceData",
|
|
1520
|
+
"one_million_characters": "#/$defs/CharacterPriceData",
|
|
1456
1521
|
"one_million_tokens": "#/$defs/TokenPriceData",
|
|
1457
1522
|
"one_minute": "#/$defs/TimePriceData",
|
|
1458
1523
|
"one_month": "#/$defs/TimePriceData",
|
|
1459
1524
|
"one_second": "#/$defs/TimePriceData",
|
|
1460
1525
|
"one_thousand": "#/$defs/CountPriceData",
|
|
1526
|
+
"one_thousand_characters": "#/$defs/CharacterPriceData",
|
|
1461
1527
|
"one_thousand_tokens": "#/$defs/TokenPriceData",
|
|
1462
1528
|
"one_token": "#/$defs/TokenPriceData",
|
|
1463
1529
|
"revenue_share": "#/$defs/RevenueSharePriceData",
|
|
@@ -1479,6 +1545,9 @@
|
|
|
1479
1545
|
{
|
|
1480
1546
|
"$ref": "#/$defs/CountPriceData"
|
|
1481
1547
|
},
|
|
1548
|
+
{
|
|
1549
|
+
"$ref": "#/$defs/CharacterPriceData"
|
|
1550
|
+
},
|
|
1482
1551
|
{
|
|
1483
1552
|
"$ref": "#/$defs/ImagePriceData"
|
|
1484
1553
|
},
|
|
@@ -138,6 +138,58 @@
|
|
|
138
138
|
"title": "ChannelPriceData",
|
|
139
139
|
"type": "object"
|
|
140
140
|
},
|
|
141
|
+
"CharacterPriceData": {
|
|
142
|
+
"additionalProperties": false,
|
|
143
|
+
"description": "Price data for character-scaled pricing (text-to-speech and similar).\n\nSupported types: ``one_character``, ``one_thousand_characters``,\n``one_million_characters``. The ``characters`` equivalence-group field is\nthe base unit, and it is the name providers actually report — DashScope's\nTTS answers ``usage: {\"characters\": N}``.\n\nUsage may be given in any unit of the group; conversion is automatic.\n\nSeparate from ``CountPriceData`` on purpose: characters and counts are\ndifferent dimensions, and collapsing them would let \"per 1M characters\" and\n\"per 1M images\" resolve against each other's usage.",
|
|
144
|
+
"properties": {
|
|
145
|
+
"description": {
|
|
146
|
+
"anyOf": [
|
|
147
|
+
{
|
|
148
|
+
"type": "string"
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
"type": "null"
|
|
152
|
+
}
|
|
153
|
+
],
|
|
154
|
+
"default": null,
|
|
155
|
+
"description": "Human-readable description of the pricing model",
|
|
156
|
+
"title": "Description"
|
|
157
|
+
},
|
|
158
|
+
"price": {
|
|
159
|
+
"description": "Price per one unit of the specified type",
|
|
160
|
+
"title": "Price",
|
|
161
|
+
"type": "string"
|
|
162
|
+
},
|
|
163
|
+
"reference": {
|
|
164
|
+
"anyOf": [
|
|
165
|
+
{
|
|
166
|
+
"type": "string"
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
"type": "null"
|
|
170
|
+
}
|
|
171
|
+
],
|
|
172
|
+
"default": null,
|
|
173
|
+
"description": "URL to upstream provider's pricing page",
|
|
174
|
+
"title": "Reference"
|
|
175
|
+
},
|
|
176
|
+
"type": {
|
|
177
|
+
"default": "one_million_characters",
|
|
178
|
+
"enum": [
|
|
179
|
+
"one_character",
|
|
180
|
+
"one_thousand_characters",
|
|
181
|
+
"one_million_characters"
|
|
182
|
+
],
|
|
183
|
+
"title": "Type",
|
|
184
|
+
"type": "string"
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
"required": [
|
|
188
|
+
"price"
|
|
189
|
+
],
|
|
190
|
+
"title": "CharacterPriceData",
|
|
191
|
+
"type": "object"
|
|
192
|
+
},
|
|
141
193
|
"ConstantPriceData": {
|
|
142
194
|
"additionalProperties": false,
|
|
143
195
|
"description": "Price data for a constant/fixed price.\n\nUsed for fixed fees, discounts, or adjustments that don't depend on usage.\nPrice can be positive (charge) or negative (discount/credit).",
|
|
@@ -262,8 +314,19 @@
|
|
|
262
314
|
},
|
|
263
315
|
"DataPriceData": {
|
|
264
316
|
"additionalProperties": false,
|
|
265
|
-
"description": "Price data for data-volume pricing (bandwidth, storage, transfer).\n\nSupported types: ``one_byte``, ``one_kilobyte``, ``one_megabyte``,\n``one_gigabyte``. Uses binary units (1 KB = 1024 bytes).\n\nUsage can be provided in **any** data unit — cross-unit conversion is\nhandled automatically via equivalence groups.",
|
|
317
|
+
"description": "Price data for data-volume pricing (bandwidth, storage, transfer).\n\nSupported types: ``one_byte``, ``one_kilobyte``, ``one_megabyte``,\n``one_gigabyte``. Uses binary units (1 KB = 1024 bytes).\n\nUsage can be provided in **any** data unit — cross-unit conversion is\nhandled automatically via equivalence groups.\n\nThe gateway ships ``bytes_in`` and ``bytes_out`` on every request. Those are\ntwo directions of one dimension, so they are deliberately NOT both in the\ndata equivalence group: a group lookup returns whichever field the dict\nhappens to yield first, which would make the billed direction depend on\niteration order. ``based_on`` names the direction instead, so it is a\nproperty of the price rather than an accident of the payload.",
|
|
266
318
|
"properties": {
|
|
319
|
+
"based_on": {
|
|
320
|
+
"default": "bytes_out",
|
|
321
|
+
"description": "Which byte counter to price. Defaults to egress, the conventional billable direction; 'bytes_total' sums both.",
|
|
322
|
+
"enum": [
|
|
323
|
+
"bytes_out",
|
|
324
|
+
"bytes_in",
|
|
325
|
+
"bytes_total"
|
|
326
|
+
],
|
|
327
|
+
"title": "Based On",
|
|
328
|
+
"type": "string"
|
|
329
|
+
},
|
|
267
330
|
"description": {
|
|
268
331
|
"anyOf": [
|
|
269
332
|
{
|
|
@@ -1432,17 +1495,20 @@
|
|
|
1432
1495
|
"min": "#/$defs/MinPriceData",
|
|
1433
1496
|
"multiply": "#/$defs/MultiplyPriceData",
|
|
1434
1497
|
"one_byte": "#/$defs/DataPriceData",
|
|
1498
|
+
"one_character": "#/$defs/CharacterPriceData",
|
|
1435
1499
|
"one_day": "#/$defs/TimePriceData",
|
|
1436
1500
|
"one_gigabyte": "#/$defs/DataPriceData",
|
|
1437
1501
|
"one_hour": "#/$defs/TimePriceData",
|
|
1438
1502
|
"one_kilobyte": "#/$defs/DataPriceData",
|
|
1439
1503
|
"one_megabyte": "#/$defs/DataPriceData",
|
|
1440
1504
|
"one_million": "#/$defs/CountPriceData",
|
|
1505
|
+
"one_million_characters": "#/$defs/CharacterPriceData",
|
|
1441
1506
|
"one_million_tokens": "#/$defs/TokenPriceData",
|
|
1442
1507
|
"one_minute": "#/$defs/TimePriceData",
|
|
1443
1508
|
"one_month": "#/$defs/TimePriceData",
|
|
1444
1509
|
"one_second": "#/$defs/TimePriceData",
|
|
1445
1510
|
"one_thousand": "#/$defs/CountPriceData",
|
|
1511
|
+
"one_thousand_characters": "#/$defs/CharacterPriceData",
|
|
1446
1512
|
"one_thousand_tokens": "#/$defs/TokenPriceData",
|
|
1447
1513
|
"one_token": "#/$defs/TokenPriceData",
|
|
1448
1514
|
"revenue_share": "#/$defs/RevenueSharePriceData",
|
|
@@ -1464,6 +1530,9 @@
|
|
|
1464
1530
|
{
|
|
1465
1531
|
"$ref": "#/$defs/CountPriceData"
|
|
1466
1532
|
},
|
|
1533
|
+
{
|
|
1534
|
+
"$ref": "#/$defs/CharacterPriceData"
|
|
1535
|
+
},
|
|
1467
1536
|
{
|
|
1468
1537
|
"$ref": "#/$defs/ImagePriceData"
|
|
1469
1538
|
},
|
|
@@ -32,6 +32,7 @@ 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
34
|
tests/test_hidden_path_skip.py
|
|
35
|
+
tests/test_offering_data.py
|
|
35
36
|
tests/test_pricing.py
|
|
36
37
|
tests/test_provider_account_rate_limits.py
|
|
37
38
|
tests/test_service_group.py
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Validation tests for shared service-offering data."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from pydantic import ValidationError
|
|
5
|
+
|
|
6
|
+
from unitysvc_core.models import ServiceOfferingData
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_service_offering_requires_description() -> None:
|
|
10
|
+
with pytest.raises(ValidationError) as exc_info:
|
|
11
|
+
ServiceOfferingData(name="missing-description", summary="No description")
|
|
12
|
+
|
|
13
|
+
assert exc_info.value.errors()[0]["loc"] == ("description",)
|
|
14
|
+
assert exc_info.value.errors()[0]["type"] == "missing"
|
|
@@ -246,6 +246,157 @@ class TestDataPriceData:
|
|
|
246
246
|
pricing.calculate_cost(usage)
|
|
247
247
|
|
|
248
248
|
|
|
249
|
+
class TestCharacterPriceData:
|
|
250
|
+
"""Character-scaled pricing (text-to-speech and similar).
|
|
251
|
+
|
|
252
|
+
Providers that bill per character report it as ``usage: {"characters": N}``
|
|
253
|
+
— DashScope's TTS does, for example. Before this unit existed the value was
|
|
254
|
+
dropped at the ``UsageData`` boundary (``extra="ignore"``), so a service
|
|
255
|
+
could pass its ``min_expected_metrics`` floor, which reads the raw metrics
|
|
256
|
+
dict, and still be charged nothing.
|
|
257
|
+
"""
|
|
258
|
+
|
|
259
|
+
def test_per_million_characters(self) -> None:
|
|
260
|
+
pricing = validate_pricing({"type": "one_million_characters", "price": "30.00"})
|
|
261
|
+
usage = UsageData(characters=1_000_000)
|
|
262
|
+
|
|
263
|
+
assert pricing.calculate_cost(usage) == Decimal("30.00")
|
|
264
|
+
|
|
265
|
+
def test_per_thousand_characters(self) -> None:
|
|
266
|
+
pricing = validate_pricing({"type": "one_thousand_characters", "price": "0.05"})
|
|
267
|
+
usage = UsageData(characters=2000)
|
|
268
|
+
|
|
269
|
+
assert pricing.calculate_cost(usage) == Decimal("0.10")
|
|
270
|
+
|
|
271
|
+
def test_per_character(self) -> None:
|
|
272
|
+
pricing = validate_pricing({"type": "one_character", "price": "0.001"})
|
|
273
|
+
usage = UsageData(characters=44)
|
|
274
|
+
|
|
275
|
+
assert pricing.calculate_cost(usage) == Decimal("0.044")
|
|
276
|
+
|
|
277
|
+
def test_cross_unit_conversion(self) -> None:
|
|
278
|
+
"""A provider reporting raw characters prices against any scaled unit."""
|
|
279
|
+
pricing = validate_pricing({"type": "one_million_characters", "price": "30.00"})
|
|
280
|
+
|
|
281
|
+
# 44 characters at $30 per 1M
|
|
282
|
+
assert pricing.calculate_cost(UsageData(characters=44)) == Decimal("0.00132")
|
|
283
|
+
|
|
284
|
+
def test_scaled_usage_field_also_works(self) -> None:
|
|
285
|
+
pricing = validate_pricing({"type": "one_character", "price": "0.001"})
|
|
286
|
+
|
|
287
|
+
assert pricing.calculate_cost(UsageData(one_thousand_characters=2)) == Decimal("2")
|
|
288
|
+
|
|
289
|
+
def test_characters_survive_construction(self) -> None:
|
|
290
|
+
"""The regression this unit exists for.
|
|
291
|
+
|
|
292
|
+
``UsageData`` is ``extra="ignore"``, so before the field existed
|
|
293
|
+
``UsageData(characters=44)`` silently produced an empty object and any
|
|
294
|
+
charge computed from it was zero.
|
|
295
|
+
"""
|
|
296
|
+
usage = UsageData(**{"characters": 44, "input_tokens": 13})
|
|
297
|
+
|
|
298
|
+
assert usage.characters == 44
|
|
299
|
+
assert usage.input_tokens == 13
|
|
300
|
+
|
|
301
|
+
def test_missing_usage_is_an_error_not_a_zero(self) -> None:
|
|
302
|
+
"""Never bill zero for absent usage — raise so the caller notices."""
|
|
303
|
+
pricing = validate_pricing({"type": "one_million_characters", "price": "30.00"})
|
|
304
|
+
|
|
305
|
+
with pytest.raises(ValueError, match="[Cc]haracter"):
|
|
306
|
+
pricing.calculate_cost(UsageData())
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
class TestGatewayEmittedMetricNames:
|
|
310
|
+
"""The names the gateway actually ships must survive into UsageData.
|
|
311
|
+
|
|
312
|
+
``UsageData`` is ``extra="ignore"``, and the gateway's metric names had
|
|
313
|
+
drifted from the pricing model's field names. Measured against what
|
|
314
|
+
``usage_event.lua`` writes: ``images_generated``, ``duration_seconds``,
|
|
315
|
+
``bytes_in`` and ``bytes_out`` were all dropped, so image-, time- and
|
|
316
|
+
data-priced services could not be billed at all.
|
|
317
|
+
|
|
318
|
+
Latent rather than live — only ``one_million_tokens``, ``constant`` and
|
|
319
|
+
``revenue_share`` are in use across the seller repos today — but it blocks
|
|
320
|
+
exactly the modalities being onboarded.
|
|
321
|
+
"""
|
|
322
|
+
|
|
323
|
+
def test_images_generated_prices_as_a_count(self) -> None:
|
|
324
|
+
"""The gateway sends images_generated; ImagePriceData resolves count."""
|
|
325
|
+
pricing = validate_pricing({"type": "image", "price": "0.04"})
|
|
326
|
+
usage = UsageData(**{"images_generated": 4, "bytes_in": 100, "bytes_out": 2000})
|
|
327
|
+
|
|
328
|
+
assert pricing.calculate_cost(usage) == Decimal("0.16")
|
|
329
|
+
|
|
330
|
+
def test_duration_seconds_prices_as_time(self) -> None:
|
|
331
|
+
pricing = validate_pricing({"type": "one_second", "price": "0.01"})
|
|
332
|
+
usage = UsageData(**{"duration_seconds": 12.5})
|
|
333
|
+
|
|
334
|
+
assert pricing.calculate_cost(usage) == Decimal("0.125")
|
|
335
|
+
|
|
336
|
+
def test_duration_seconds_converts_across_the_time_group(self) -> None:
|
|
337
|
+
pricing = validate_pricing({"type": "one_minute", "price": "0.60"})
|
|
338
|
+
usage = UsageData(**{"duration_seconds": 120})
|
|
339
|
+
|
|
340
|
+
assert pricing.calculate_cost(usage) == Decimal("1.20")
|
|
341
|
+
|
|
342
|
+
def test_data_pricing_defaults_to_egress(self) -> None:
|
|
343
|
+
pricing = validate_pricing({"type": "one_megabyte", "price": "0.10"})
|
|
344
|
+
usage = UsageData(**{"bytes_in": 1_048_576, "bytes_out": 5_242_880})
|
|
345
|
+
|
|
346
|
+
# 5 MiB egress at $0.10/MiB — NOT the 1 MiB ingress.
|
|
347
|
+
assert pricing.calculate_cost(usage) == Decimal("0.50")
|
|
348
|
+
|
|
349
|
+
def test_data_pricing_direction_is_explicit_not_incidental(self) -> None:
|
|
350
|
+
"""The reason bytes_in/bytes_out are not both in the equivalence group.
|
|
351
|
+
|
|
352
|
+
With both populated, a group lookup would return whichever the dict
|
|
353
|
+
happened to yield first. The direction must come from the price.
|
|
354
|
+
"""
|
|
355
|
+
usage = UsageData(**{"bytes_in": 1_048_576, "bytes_out": 5_242_880})
|
|
356
|
+
|
|
357
|
+
egress = validate_pricing({"type": "one_megabyte", "price": "0.10", "based_on": "bytes_out"})
|
|
358
|
+
ingress = validate_pricing({"type": "one_megabyte", "price": "0.10", "based_on": "bytes_in"})
|
|
359
|
+
both = validate_pricing({"type": "one_megabyte", "price": "0.10", "based_on": "bytes_total"})
|
|
360
|
+
|
|
361
|
+
assert egress.calculate_cost(usage) == Decimal("0.50")
|
|
362
|
+
assert ingress.calculate_cost(usage) == Decimal("0.10")
|
|
363
|
+
assert both.calculate_cost(usage) == Decimal("0.60")
|
|
364
|
+
|
|
365
|
+
def test_explicit_data_units_still_work(self) -> None:
|
|
366
|
+
"""Back-compat: a caller supplying the canonical unit is unaffected."""
|
|
367
|
+
pricing = validate_pricing({"type": "one_megabyte", "price": "0.10"})
|
|
368
|
+
|
|
369
|
+
assert pricing.calculate_cost(UsageData(one_megabyte=3)) == Decimal("0.30")
|
|
370
|
+
|
|
371
|
+
def test_a_full_gateway_payload_survives(self) -> None:
|
|
372
|
+
"""Every name usage_event.lua can ship, in one object."""
|
|
373
|
+
usage = UsageData(
|
|
374
|
+
**{
|
|
375
|
+
"input_tokens": 13,
|
|
376
|
+
"output_tokens": 10,
|
|
377
|
+
"total_tokens": 23,
|
|
378
|
+
"images_generated": 2,
|
|
379
|
+
"duration_seconds": 1.5,
|
|
380
|
+
"characters": 44,
|
|
381
|
+
"bytes_in": 100,
|
|
382
|
+
"bytes_out": 2000,
|
|
383
|
+
}
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
kept = usage.model_dump(exclude_none=True)
|
|
387
|
+
for name in (
|
|
388
|
+
"input_tokens",
|
|
389
|
+
"output_tokens",
|
|
390
|
+
"total_tokens",
|
|
391
|
+
"images_generated",
|
|
392
|
+
"duration_seconds",
|
|
393
|
+
"characters",
|
|
394
|
+
"bytes_in",
|
|
395
|
+
"bytes_out",
|
|
396
|
+
):
|
|
397
|
+
assert name in kept, f"{name} was dropped by UsageData"
|
|
398
|
+
|
|
399
|
+
|
|
249
400
|
class TestCountPriceData:
|
|
250
401
|
"""Tests for count-scaled pricing."""
|
|
251
402
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{unitysvc_core-0.2.26 → unitysvc_core-0.2.27}/src/unitysvc_core/models/service_group_data.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{unitysvc_core-0.2.26 → unitysvc_core-0.2.27}/src/unitysvc_core/schema/service_group_v1.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{unitysvc_core-0.2.26 → unitysvc_core-0.2.27}/src/unitysvc_core.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|