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.
Files changed (44) hide show
  1. {unitysvc_core-0.2.25/src/unitysvc_core.egg-info → unitysvc_core-0.2.27}/PKG-INFO +1 -1
  2. {unitysvc_core-0.2.25 → unitysvc_core-0.2.27}/pyproject.toml +1 -1
  3. {unitysvc_core-0.2.25 → unitysvc_core-0.2.27}/src/unitysvc_core/models/__init__.py +4 -4
  4. {unitysvc_core-0.2.25 → unitysvc_core-0.2.27}/src/unitysvc_core/models/base.py +34 -16
  5. {unitysvc_core-0.2.25 → unitysvc_core-0.2.27}/src/unitysvc_core/models/offering_data.py +1 -2
  6. {unitysvc_core-0.2.25 → unitysvc_core-0.2.27}/src/unitysvc_core/models/offering_v1.py +0 -8
  7. {unitysvc_core-0.2.25 → unitysvc_core-0.2.27}/src/unitysvc_core/models/pricing.py +116 -3
  8. {unitysvc_core-0.2.25 → unitysvc_core-0.2.27}/src/unitysvc_core/models/provider_data.py +8 -5
  9. {unitysvc_core-0.2.25 → unitysvc_core-0.2.27}/src/unitysvc_core/models/service.py +5 -5
  10. {unitysvc_core-0.2.25 → unitysvc_core-0.2.27}/src/unitysvc_core/schema/listing_v1.json +70 -1
  11. {unitysvc_core-0.2.25 → unitysvc_core-0.2.27}/src/unitysvc_core/schema/offering_v1.json +70 -1
  12. {unitysvc_core-0.2.25 → unitysvc_core-0.2.27}/src/unitysvc_core/schema/provider_v1.json +21 -27
  13. {unitysvc_core-0.2.25 → unitysvc_core-0.2.27/src/unitysvc_core.egg-info}/PKG-INFO +1 -1
  14. {unitysvc_core-0.2.25 → unitysvc_core-0.2.27}/src/unitysvc_core.egg-info/SOURCES.txt +1 -0
  15. unitysvc_core-0.2.27/tests/test_offering_data.py +14 -0
  16. {unitysvc_core-0.2.25 → unitysvc_core-0.2.27}/tests/test_pricing.py +151 -0
  17. {unitysvc_core-0.2.25 → unitysvc_core-0.2.27}/tests/test_provider_account_rate_limits.py +59 -1
  18. {unitysvc_core-0.2.25 → unitysvc_core-0.2.27}/LICENSE +0 -0
  19. {unitysvc_core-0.2.25 → unitysvc_core-0.2.27}/README.md +0 -0
  20. {unitysvc_core-0.2.25 → unitysvc_core-0.2.27}/setup.cfg +0 -0
  21. {unitysvc_core-0.2.25 → unitysvc_core-0.2.27}/src/unitysvc_core/__init__.py +0 -0
  22. {unitysvc_core-0.2.25 → unitysvc_core-0.2.27}/src/unitysvc_core/models/documents.py +0 -0
  23. {unitysvc_core-0.2.25 → unitysvc_core-0.2.27}/src/unitysvc_core/models/listing_data.py +0 -0
  24. {unitysvc_core-0.2.25 → unitysvc_core-0.2.27}/src/unitysvc_core/models/listing_v1.py +0 -0
  25. {unitysvc_core-0.2.25 → unitysvc_core-0.2.27}/src/unitysvc_core/models/promotion_data.py +0 -0
  26. {unitysvc_core-0.2.25 → unitysvc_core-0.2.27}/src/unitysvc_core/models/provider_v1.py +0 -0
  27. {unitysvc_core-0.2.25 → unitysvc_core-0.2.27}/src/unitysvc_core/models/service_group_data.py +0 -0
  28. {unitysvc_core-0.2.25 → unitysvc_core-0.2.27}/src/unitysvc_core/models/validators.py +0 -0
  29. {unitysvc_core-0.2.25 → unitysvc_core-0.2.27}/src/unitysvc_core/py.typed +0 -0
  30. {unitysvc_core-0.2.25 → unitysvc_core-0.2.27}/src/unitysvc_core/schema/base.json +0 -0
  31. {unitysvc_core-0.2.25 → unitysvc_core-0.2.27}/src/unitysvc_core/schema/promotion_v1.json +0 -0
  32. {unitysvc_core-0.2.25 → unitysvc_core-0.2.27}/src/unitysvc_core/schema/service_group_v1.json +0 -0
  33. {unitysvc_core-0.2.25 → unitysvc_core-0.2.27}/src/unitysvc_core/utils.py +0 -0
  34. {unitysvc_core-0.2.25 → unitysvc_core-0.2.27}/src/unitysvc_core/validator.py +0 -0
  35. {unitysvc_core-0.2.25 → unitysvc_core-0.2.27}/src/unitysvc_core.egg-info/dependency_links.txt +0 -0
  36. {unitysvc_core-0.2.25 → unitysvc_core-0.2.27}/src/unitysvc_core.egg-info/requires.txt +0 -0
  37. {unitysvc_core-0.2.25 → unitysvc_core-0.2.27}/src/unitysvc_core.egg-info/top_level.txt +0 -0
  38. {unitysvc_core-0.2.25 → unitysvc_core-0.2.27}/tests/test_access_interface_routing.py +0 -0
  39. {unitysvc_core-0.2.25 → unitysvc_core-0.2.27}/tests/test_hidden_path_skip.py +0 -0
  40. {unitysvc_core-0.2.25 → unitysvc_core-0.2.27}/tests/test_service_group.py +0 -0
  41. {unitysvc_core-0.2.25 → unitysvc_core-0.2.27}/tests/test_service_status.py +0 -0
  42. {unitysvc_core-0.2.25 → unitysvc_core-0.2.27}/tests/test_utils.py +0 -0
  43. {unitysvc_core-0.2.25 → unitysvc_core-0.2.27}/tests/test_validator.py +0 -0
  44. {unitysvc_core-0.2.25 → unitysvc_core-0.2.27}/tests/test_validators.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: unitysvc-core
3
- Version: 0.2.25
3
+ Version: 0.2.27
4
4
  Summary: Shared data models and validation helpers for UnitySVC packages
5
5
  Author-email: Bo Peng <bo.peng@unitysvc.com>
6
6
  Maintainer-email: Bo Peng <bo.peng@unitysvc.com>
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "unitysvc-core"
7
- version = "0.2.25"
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
- RateLimitUnitEnum,
20
+ RateLimitUnit,
21
21
  RequestTransformEnum,
22
22
  SellerTypeEnum,
23
23
  ServiceGroupStatusEnum,
24
24
  ServiceTypeEnum,
25
25
  ServiceVisibilityEnum,
26
- TimeWindowEnum,
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
- "RateLimitUnitEnum",
133
+ "RateLimitUnit",
134
134
  "RequestTransformEnum",
135
135
  "ROUTABLE_GROUP_TYPES",
136
136
  "SellerTypeEnum",
137
137
  "ServiceGroupStatusEnum",
138
138
  "ServiceTypeEnum",
139
139
  "ServiceVisibilityEnum",
140
- "TimeWindowEnum",
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 and simple constants. Data classes with
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
- class RateLimitUnitEnum(StrEnum):
238
- requests = "requests"
239
- tokens = "tokens"
240
- input_tokens = "input_tokens"
241
- output_tokens = "output_tokens"
242
- bytes = "bytes"
243
- concurrent = "concurrent"
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.
@@ -74,8 +74,7 @@ class ServiceOfferingData(BaseModel):
74
74
  ),
75
75
  )
76
76
 
77
- description: str | None = Field(
78
- default=None,
77
+ description: str = Field(
79
78
  description="Service description (long-form; the summary carries the teaser)",
80
79
  )
81
80
 
@@ -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
- if usage.count is None:
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) * usage.count
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
- units = _resolve_equivalent_metric(self.type, usage)
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, RateLimitUnitEnum, TimeWindowEnum
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
- unit: RateLimitUnitEnum = Field(description="What is being limited (requests, tokens, concurrent, …)")
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: TimeWindowEnum | None = Field(
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 is RateLimitUnitEnum.concurrent:
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.value}` is counted over a window; set `window`")
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
- RateLimitUnitEnum,
18
- TimeWindowEnum,
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: RateLimitUnitEnum = Field(description="What is being limited")
30
- window: TimeWindowEnum = Field(description="Time window for the limit")
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: RateLimitUnitEnum | None = Field(default=None, description="Unit for quota limits")
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
- "$ref": "#/$defs/RateLimitUnitEnum",
191
- "description": "What is being limited (requests, tokens, concurrent, …)"
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
- "$ref": "#/$defs/TimeWindowEnum"
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,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: unitysvc-core
3
- Version: 0.2.25
3
+ Version: 0.2.27
4
4
  Summary: Shared data models and validation helpers for UnitySVC packages
5
5
  Author-email: Bo Peng <bo.peng@unitysvc.com>
6
6
  Maintainer-email: Bo Peng <bo.peng@unitysvc.com>
@@ -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.value for rl in provider.rate_limits] == [
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