unitysvc-core 0.2.25__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.25/src/unitysvc_core.egg-info → unitysvc_core-0.2.27}/PKG-INFO +1 -1
- {unitysvc_core-0.2.25 → unitysvc_core-0.2.27}/pyproject.toml +1 -1
- {unitysvc_core-0.2.25 → unitysvc_core-0.2.27}/src/unitysvc_core/models/__init__.py +4 -4
- {unitysvc_core-0.2.25 → unitysvc_core-0.2.27}/src/unitysvc_core/models/base.py +34 -16
- {unitysvc_core-0.2.25 → unitysvc_core-0.2.27}/src/unitysvc_core/models/offering_data.py +1 -2
- {unitysvc_core-0.2.25 → unitysvc_core-0.2.27}/src/unitysvc_core/models/offering_v1.py +0 -8
- {unitysvc_core-0.2.25 → unitysvc_core-0.2.27}/src/unitysvc_core/models/pricing.py +116 -3
- {unitysvc_core-0.2.25 → unitysvc_core-0.2.27}/src/unitysvc_core/models/provider_data.py +8 -5
- {unitysvc_core-0.2.25 → unitysvc_core-0.2.27}/src/unitysvc_core/models/service.py +5 -5
- {unitysvc_core-0.2.25 → unitysvc_core-0.2.27}/src/unitysvc_core/schema/listing_v1.json +70 -1
- {unitysvc_core-0.2.25 → unitysvc_core-0.2.27}/src/unitysvc_core/schema/offering_v1.json +70 -1
- {unitysvc_core-0.2.25 → unitysvc_core-0.2.27}/src/unitysvc_core/schema/provider_v1.json +21 -27
- {unitysvc_core-0.2.25 → unitysvc_core-0.2.27/src/unitysvc_core.egg-info}/PKG-INFO +1 -1
- {unitysvc_core-0.2.25 → 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.25 → unitysvc_core-0.2.27}/tests/test_pricing.py +151 -0
- {unitysvc_core-0.2.25 → unitysvc_core-0.2.27}/tests/test_provider_account_rate_limits.py +59 -1
- {unitysvc_core-0.2.25 → unitysvc_core-0.2.27}/LICENSE +0 -0
- {unitysvc_core-0.2.25 → unitysvc_core-0.2.27}/README.md +0 -0
- {unitysvc_core-0.2.25 → unitysvc_core-0.2.27}/setup.cfg +0 -0
- {unitysvc_core-0.2.25 → unitysvc_core-0.2.27}/src/unitysvc_core/__init__.py +0 -0
- {unitysvc_core-0.2.25 → unitysvc_core-0.2.27}/src/unitysvc_core/models/documents.py +0 -0
- {unitysvc_core-0.2.25 → unitysvc_core-0.2.27}/src/unitysvc_core/models/listing_data.py +0 -0
- {unitysvc_core-0.2.25 → unitysvc_core-0.2.27}/src/unitysvc_core/models/listing_v1.py +0 -0
- {unitysvc_core-0.2.25 → unitysvc_core-0.2.27}/src/unitysvc_core/models/promotion_data.py +0 -0
- {unitysvc_core-0.2.25 → unitysvc_core-0.2.27}/src/unitysvc_core/models/provider_v1.py +0 -0
- {unitysvc_core-0.2.25 → unitysvc_core-0.2.27}/src/unitysvc_core/models/service_group_data.py +0 -0
- {unitysvc_core-0.2.25 → unitysvc_core-0.2.27}/src/unitysvc_core/models/validators.py +0 -0
- {unitysvc_core-0.2.25 → unitysvc_core-0.2.27}/src/unitysvc_core/py.typed +0 -0
- {unitysvc_core-0.2.25 → unitysvc_core-0.2.27}/src/unitysvc_core/schema/base.json +0 -0
- {unitysvc_core-0.2.25 → unitysvc_core-0.2.27}/src/unitysvc_core/schema/promotion_v1.json +0 -0
- {unitysvc_core-0.2.25 → unitysvc_core-0.2.27}/src/unitysvc_core/schema/service_group_v1.json +0 -0
- {unitysvc_core-0.2.25 → unitysvc_core-0.2.27}/src/unitysvc_core/utils.py +0 -0
- {unitysvc_core-0.2.25 → unitysvc_core-0.2.27}/src/unitysvc_core/validator.py +0 -0
- {unitysvc_core-0.2.25 → unitysvc_core-0.2.27}/src/unitysvc_core.egg-info/dependency_links.txt +0 -0
- {unitysvc_core-0.2.25 → unitysvc_core-0.2.27}/src/unitysvc_core.egg-info/requires.txt +0 -0
- {unitysvc_core-0.2.25 → unitysvc_core-0.2.27}/src/unitysvc_core.egg-info/top_level.txt +0 -0
- {unitysvc_core-0.2.25 → unitysvc_core-0.2.27}/tests/test_access_interface_routing.py +0 -0
- {unitysvc_core-0.2.25 → unitysvc_core-0.2.27}/tests/test_hidden_path_skip.py +0 -0
- {unitysvc_core-0.2.25 → unitysvc_core-0.2.27}/tests/test_service_group.py +0 -0
- {unitysvc_core-0.2.25 → unitysvc_core-0.2.27}/tests/test_service_status.py +0 -0
- {unitysvc_core-0.2.25 → unitysvc_core-0.2.27}/tests/test_utils.py +0 -0
- {unitysvc_core-0.2.25 → unitysvc_core-0.2.27}/tests/test_validator.py +0 -0
- {unitysvc_core-0.2.25 → 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" }]
|
|
@@ -17,13 +17,13 @@ from .base import (
|
|
|
17
17
|
PricingTypeEnum,
|
|
18
18
|
ProviderStatusEnum,
|
|
19
19
|
QuotaResetCycleEnum,
|
|
20
|
-
|
|
20
|
+
RateLimitUnit,
|
|
21
21
|
RequestTransformEnum,
|
|
22
22
|
SellerTypeEnum,
|
|
23
23
|
ServiceGroupStatusEnum,
|
|
24
24
|
ServiceTypeEnum,
|
|
25
25
|
ServiceVisibilityEnum,
|
|
26
|
-
|
|
26
|
+
TimeWindow,
|
|
27
27
|
UpstreamStatusEnum, # Backwards compatibility alias for OfferingStatusEnum
|
|
28
28
|
)
|
|
29
29
|
from .documents import DocumentData
|
|
@@ -130,14 +130,14 @@ __all__ = [
|
|
|
130
130
|
"PricingTypeEnum",
|
|
131
131
|
"ProviderStatusEnum",
|
|
132
132
|
"QuotaResetCycleEnum",
|
|
133
|
-
"
|
|
133
|
+
"RateLimitUnit",
|
|
134
134
|
"RequestTransformEnum",
|
|
135
135
|
"ROUTABLE_GROUP_TYPES",
|
|
136
136
|
"SellerTypeEnum",
|
|
137
137
|
"ServiceGroupStatusEnum",
|
|
138
138
|
"ServiceTypeEnum",
|
|
139
139
|
"ServiceVisibilityEnum",
|
|
140
|
-
"
|
|
140
|
+
"TimeWindow",
|
|
141
141
|
"UpstreamStatusEnum", # Backwards compatibility alias for OfferingStatusEnum
|
|
142
142
|
# Pricing — primitives
|
|
143
143
|
"PriceStr",
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Enum types and basic definitions shared across the data models.
|
|
2
2
|
|
|
3
|
-
This module contains only enums
|
|
3
|
+
This module contains only enums, closed-value type aliases, and simple
|
|
4
|
+
constants. Data classes with
|
|
4
5
|
behavior (pricing, documents, service constraints, etc.) and validation
|
|
5
6
|
functions live in their own modules:
|
|
6
7
|
|
|
@@ -13,6 +14,7 @@ functions live in their own modules:
|
|
|
13
14
|
from __future__ import annotations
|
|
14
15
|
|
|
15
16
|
from enum import StrEnum
|
|
17
|
+
from typing import Literal
|
|
16
18
|
|
|
17
19
|
|
|
18
20
|
class AccessMethodEnum(StrEnum):
|
|
@@ -234,13 +236,37 @@ class QuotaResetCycleEnum(StrEnum):
|
|
|
234
236
|
yearly = "yearly"
|
|
235
237
|
|
|
236
238
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
239
|
+
# Rate-limit unit and time window: Literals, not StrEnums.
|
|
240
|
+
#
|
|
241
|
+
# Both are used by ``ProviderAccountRateLimit``, which is nested inside
|
|
242
|
+
# ``ProviderData`` — a model the unitysvc backend serves in BOTH directions (a
|
|
243
|
+
# request-body field on ``POST /seller/services``, a response field on
|
|
244
|
+
# ``GET /seller/services/{id}``).
|
|
245
|
+
#
|
|
246
|
+
# An enum one level down makes pydantic treat the *outer* model's validation and
|
|
247
|
+
# serialization schemas as distinct, so FastAPI emits an ``X-Input``/``X-Output``
|
|
248
|
+
# pair — both halves carrying the same ``title``. Python SDK codegen names a
|
|
249
|
+
# top-level component from its title, so the halves collide on one class name:
|
|
250
|
+
# the generator drops the second and silently removes every schema that
|
|
251
|
+
# ``$ref``s it. That is how ``ServiceDetailResponse`` vanished from
|
|
252
|
+
# unitysvc-sellers 0.3.7+, leaving ``client.services.get()`` returning raw dicts
|
|
253
|
+
# (unitysvc/unitysvc-sellers#205).
|
|
254
|
+
#
|
|
255
|
+
# An enum *directly* on a served model is fine, and the rendered JSON schema is
|
|
256
|
+
# identical either way — the split is an artifact of how the two modes are
|
|
257
|
+
# deduplicated, not a real difference. A Literal sidesteps it and keeps the
|
|
258
|
+
# values just as closed: same ``enum`` in the JSON schema, same rejection of an
|
|
259
|
+
# unlisted value, and a string-literal union in every generated client.
|
|
260
|
+
RateLimitUnit = Literal[
|
|
261
|
+
"requests",
|
|
262
|
+
"tokens",
|
|
263
|
+
"input_tokens",
|
|
264
|
+
"output_tokens",
|
|
265
|
+
"bytes",
|
|
266
|
+
"concurrent",
|
|
267
|
+
]
|
|
268
|
+
|
|
269
|
+
TimeWindow = Literal["second", "minute", "hour", "day", "month"]
|
|
244
270
|
|
|
245
271
|
|
|
246
272
|
class RequestTransformEnum(StrEnum):
|
|
@@ -317,14 +343,6 @@ class ServiceVisibilityEnum(StrEnum):
|
|
|
317
343
|
private = "private"
|
|
318
344
|
|
|
319
345
|
|
|
320
|
-
class TimeWindowEnum(StrEnum):
|
|
321
|
-
second = "second"
|
|
322
|
-
minute = "minute"
|
|
323
|
-
hour = "hour"
|
|
324
|
-
day = "day"
|
|
325
|
-
month = "month"
|
|
326
|
-
|
|
327
|
-
|
|
328
346
|
class OfferingStatusEnum(StrEnum):
|
|
329
347
|
"""
|
|
330
348
|
Status values that sellers can set for service offerings.
|
|
@@ -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
|
|
@@ -13,7 +13,7 @@ from typing import Any
|
|
|
13
13
|
|
|
14
14
|
from pydantic import BaseModel, ConfigDict, EmailStr, Field, HttpUrl, model_validator
|
|
15
15
|
|
|
16
|
-
from .base import ProviderStatusEnum,
|
|
16
|
+
from .base import ProviderStatusEnum, RateLimitUnit, TimeWindow
|
|
17
17
|
|
|
18
18
|
|
|
19
19
|
class ProviderAccountRateLimit(BaseModel):
|
|
@@ -48,9 +48,12 @@ class ProviderAccountRateLimit(BaseModel):
|
|
|
48
48
|
|
|
49
49
|
limit: int = Field(gt=0, description="Maximum allowed — in flight for `concurrent`, per window otherwise")
|
|
50
50
|
|
|
51
|
-
|
|
51
|
+
# RateLimitUnit / TimeWindow are Literals rather than enums, because an enum
|
|
52
|
+
# nested this deep splits every model that carries one. See base.py for the
|
|
53
|
+
# mechanism — it is not a style preference.
|
|
54
|
+
unit: RateLimitUnit = Field(description="What is being limited (requests, tokens, concurrent, …)")
|
|
52
55
|
|
|
53
|
-
window:
|
|
56
|
+
window: TimeWindow | None = Field(
|
|
54
57
|
default=None,
|
|
55
58
|
description="Time window. Omitted for `concurrent`, which is a gauge rather than a counter.",
|
|
56
59
|
)
|
|
@@ -72,11 +75,11 @@ class ProviderAccountRateLimit(BaseModel):
|
|
|
72
75
|
it. Requiring the field to match the unit stops a limit being authored
|
|
73
76
|
that the enforcement layer cannot honour as written.
|
|
74
77
|
"""
|
|
75
|
-
if self.unit
|
|
78
|
+
if self.unit == "concurrent":
|
|
76
79
|
if self.window is not None:
|
|
77
80
|
raise ValueError("`concurrent` is an in-flight gauge and takes no window; omit it")
|
|
78
81
|
elif self.window is None:
|
|
79
|
-
raise ValueError(f"`{self.unit
|
|
82
|
+
raise ValueError(f"`{self.unit}` is counted over a window; set `window`")
|
|
80
83
|
return self
|
|
81
84
|
|
|
82
85
|
|
|
@@ -14,8 +14,8 @@ from .base import (
|
|
|
14
14
|
ContentFilterEnum,
|
|
15
15
|
OveragePolicyEnum,
|
|
16
16
|
QuotaResetCycleEnum,
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
RateLimitUnit,
|
|
18
|
+
TimeWindow,
|
|
19
19
|
)
|
|
20
20
|
|
|
21
21
|
|
|
@@ -26,8 +26,8 @@ class RateLimit(BaseModel):
|
|
|
26
26
|
|
|
27
27
|
# Core rate limit definition
|
|
28
28
|
limit: int = Field(description="Maximum allowed in the time window")
|
|
29
|
-
unit:
|
|
30
|
-
window:
|
|
29
|
+
unit: RateLimitUnit = Field(description="What is being limited")
|
|
30
|
+
window: TimeWindow = Field(description="Time window for the limit")
|
|
31
31
|
|
|
32
32
|
# Optional additional info
|
|
33
33
|
description: str | None = Field(default=None, max_length=255, description="Human-readable description")
|
|
@@ -52,7 +52,7 @@ class ServiceConstraints(BaseModel):
|
|
|
52
52
|
# Usage Quotas & Billing
|
|
53
53
|
monthly_quota: int | None = Field(default=None, description="Monthly usage quota (requests, tokens, etc.)")
|
|
54
54
|
daily_quota: int | None = Field(default=None, description="Daily usage quota (requests, tokens, etc.)")
|
|
55
|
-
quota_unit:
|
|
55
|
+
quota_unit: RateLimitUnit | None = Field(default=None, description="Unit for quota limits")
|
|
56
56
|
quota_reset_cycle: QuotaResetCycleEnum | None = Field(default=None, description="How often quotas reset")
|
|
57
57
|
overage_policy: OveragePolicyEnum | None = Field(default=None, description="What happens when quota is exceeded")
|
|
58
58
|
|
|
@@ -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
|
},
|
|
@@ -187,20 +187,37 @@
|
|
|
187
187
|
"type": "string"
|
|
188
188
|
},
|
|
189
189
|
"unit": {
|
|
190
|
-
"
|
|
191
|
-
"
|
|
190
|
+
"description": "What is being limited (requests, tokens, concurrent, …)",
|
|
191
|
+
"enum": [
|
|
192
|
+
"requests",
|
|
193
|
+
"tokens",
|
|
194
|
+
"input_tokens",
|
|
195
|
+
"output_tokens",
|
|
196
|
+
"bytes",
|
|
197
|
+
"concurrent"
|
|
198
|
+
],
|
|
199
|
+
"title": "Unit",
|
|
200
|
+
"type": "string"
|
|
192
201
|
},
|
|
193
202
|
"window": {
|
|
194
203
|
"anyOf": [
|
|
195
204
|
{
|
|
196
|
-
"
|
|
205
|
+
"enum": [
|
|
206
|
+
"second",
|
|
207
|
+
"minute",
|
|
208
|
+
"hour",
|
|
209
|
+
"day",
|
|
210
|
+
"month"
|
|
211
|
+
],
|
|
212
|
+
"type": "string"
|
|
197
213
|
},
|
|
198
214
|
{
|
|
199
215
|
"type": "null"
|
|
200
216
|
}
|
|
201
217
|
],
|
|
202
218
|
"default": null,
|
|
203
|
-
"description": "Time window. Omitted for `concurrent`, which is a gauge rather than a counter."
|
|
219
|
+
"description": "Time window. Omitted for `concurrent`, which is a gauge rather than a counter.",
|
|
220
|
+
"title": "Window"
|
|
204
221
|
}
|
|
205
222
|
},
|
|
206
223
|
"required": [
|
|
@@ -221,18 +238,6 @@
|
|
|
221
238
|
"title": "ProviderStatusEnum",
|
|
222
239
|
"type": "string"
|
|
223
240
|
},
|
|
224
|
-
"RateLimitUnitEnum": {
|
|
225
|
-
"enum": [
|
|
226
|
-
"requests",
|
|
227
|
-
"tokens",
|
|
228
|
-
"input_tokens",
|
|
229
|
-
"output_tokens",
|
|
230
|
-
"bytes",
|
|
231
|
-
"concurrent"
|
|
232
|
-
],
|
|
233
|
-
"title": "RateLimitUnitEnum",
|
|
234
|
-
"type": "string"
|
|
235
|
-
},
|
|
236
241
|
"ServicesPopulator": {
|
|
237
242
|
"additionalProperties": false,
|
|
238
243
|
"description": "Configuration for automatically populating service data.",
|
|
@@ -289,17 +294,6 @@
|
|
|
289
294
|
},
|
|
290
295
|
"title": "ServicesPopulator",
|
|
291
296
|
"type": "object"
|
|
292
|
-
},
|
|
293
|
-
"TimeWindowEnum": {
|
|
294
|
-
"enum": [
|
|
295
|
-
"second",
|
|
296
|
-
"minute",
|
|
297
|
-
"hour",
|
|
298
|
-
"day",
|
|
299
|
-
"month"
|
|
300
|
-
],
|
|
301
|
-
"title": "TimeWindowEnum",
|
|
302
|
-
"type": "string"
|
|
303
297
|
}
|
|
304
298
|
},
|
|
305
299
|
"additionalProperties": false,
|
|
@@ -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
|
|
|
@@ -7,10 +7,13 @@ can state an individual customer's share, which depends on who is active at
|
|
|
7
7
|
request time.
|
|
8
8
|
"""
|
|
9
9
|
|
|
10
|
+
from typing import get_args
|
|
11
|
+
|
|
10
12
|
import pytest
|
|
11
13
|
from pydantic import ValidationError
|
|
12
14
|
|
|
13
15
|
from unitysvc_core.models import ProviderAccountRateLimit, ProviderV1
|
|
16
|
+
from unitysvc_core.models.base import RateLimitUnit, TimeWindow
|
|
14
17
|
|
|
15
18
|
BASE = {
|
|
16
19
|
"name": "fireworks",
|
|
@@ -84,8 +87,63 @@ def test_several_dimensions_coexist() -> None:
|
|
|
84
87
|
{"name": "fireworks_input_tokens", "limit": 60000, "unit": "input_tokens", "window": "minute"},
|
|
85
88
|
],
|
|
86
89
|
)
|
|
87
|
-
assert [rl.unit
|
|
90
|
+
assert [rl.unit for rl in provider.rate_limits] == [
|
|
88
91
|
"concurrent",
|
|
89
92
|
"requests",
|
|
90
93
|
"input_tokens",
|
|
91
94
|
]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_the_value_sets_are_the_documented_ones() -> None:
|
|
98
|
+
"""``RateLimitUnit``/``TimeWindow`` are the only definition of these sets.
|
|
99
|
+
|
|
100
|
+
They replaced ``RateLimitUnitEnum``/``TimeWindowEnum`` (see base.py for why
|
|
101
|
+
a Literal and not an enum). Pinning the members here makes a silent
|
|
102
|
+
widening or narrowing fail rather than quietly changing what a provider may
|
|
103
|
+
author and what every generated client will accept.
|
|
104
|
+
"""
|
|
105
|
+
assert set(get_args(RateLimitUnit)) == {
|
|
106
|
+
"requests",
|
|
107
|
+
"tokens",
|
|
108
|
+
"input_tokens",
|
|
109
|
+
"output_tokens",
|
|
110
|
+
"bytes",
|
|
111
|
+
"concurrent",
|
|
112
|
+
}
|
|
113
|
+
assert set(get_args(TimeWindow)) == {"second", "minute", "hour", "day", "month"}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_the_nested_model_pulls_in_no_named_subschema() -> None:
|
|
117
|
+
"""The condition that broke Python SDK codegen — kept from coming back.
|
|
118
|
+
|
|
119
|
+
An enum (or any named sub-schema) one level down makes pydantic treat the
|
|
120
|
+
*outer* model's validation and serialization schemas as distinct. FastAPI
|
|
121
|
+
then splits every model that carries this one into an ``X-Input``/
|
|
122
|
+
``X-Output`` pair whose halves share a title, and Python SDK codegen drops
|
|
123
|
+
one of them plus everything referencing it — silently. ``ProviderData`` is
|
|
124
|
+
such a carrier: it is a request body on ``POST /seller/services`` and a
|
|
125
|
+
response field on ``GET /seller/services/{id}``.
|
|
126
|
+
"""
|
|
127
|
+
assert ProviderAccountRateLimit.model_json_schema().get("$defs", {}) == {}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_the_value_set_is_still_closed() -> None:
|
|
131
|
+
with pytest.raises(ValidationError):
|
|
132
|
+
ProviderAccountRateLimit(name="fireworks_typo", limit=10, unit="concurent")
|
|
133
|
+
with pytest.raises(ValidationError):
|
|
134
|
+
ProviderAccountRateLimit(name="fireworks_rpm", limit=10, unit="requests", window="fortnight")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def test_values_read_back_as_plain_strings() -> None:
|
|
138
|
+
"""The one behaviour change from dropping the enums.
|
|
139
|
+
|
|
140
|
+
``.unit``/``.window`` are ``str``, not enum members, so ``.unit.value`` no
|
|
141
|
+
longer works — ``.unit`` does. Consumers already saw strings over the wire
|
|
142
|
+
and in ``model_dump(mode="json")``; only in-process attribute access
|
|
143
|
+
differs.
|
|
144
|
+
"""
|
|
145
|
+
limit = ProviderAccountRateLimit(name="fireworks_rpm", limit=600, unit="requests", window="minute")
|
|
146
|
+
|
|
147
|
+
assert limit.unit == "requests"
|
|
148
|
+
assert type(limit.unit) is str
|
|
149
|
+
assert limit.model_dump(mode="json")["window"] == "minute"
|
|
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.25 → 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
|
{unitysvc_core-0.2.25 → 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.25 → 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
|