unitysvc-core 0.2.26__tar.gz → 0.2.28__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 (45) hide show
  1. {unitysvc_core-0.2.26/src/unitysvc_core.egg-info → unitysvc_core-0.2.28}/PKG-INFO +1 -1
  2. {unitysvc_core-0.2.26 → unitysvc_core-0.2.28}/pyproject.toml +1 -1
  3. {unitysvc_core-0.2.26 → unitysvc_core-0.2.28}/src/unitysvc_core/models/__init__.py +4 -0
  4. {unitysvc_core-0.2.26 → unitysvc_core-0.2.28}/src/unitysvc_core/models/offering_data.py +1 -2
  5. {unitysvc_core-0.2.26 → unitysvc_core-0.2.28}/src/unitysvc_core/models/offering_v1.py +0 -8
  6. {unitysvc_core-0.2.26 → unitysvc_core-0.2.28}/src/unitysvc_core/models/pricing.py +402 -3
  7. {unitysvc_core-0.2.26 → unitysvc_core-0.2.28}/src/unitysvc_core/schema/listing_v1.json +70 -1
  8. {unitysvc_core-0.2.26 → unitysvc_core-0.2.28}/src/unitysvc_core/schema/offering_v1.json +70 -1
  9. {unitysvc_core-0.2.26 → unitysvc_core-0.2.28/src/unitysvc_core.egg-info}/PKG-INFO +1 -1
  10. {unitysvc_core-0.2.26 → unitysvc_core-0.2.28}/src/unitysvc_core.egg-info/SOURCES.txt +2 -0
  11. unitysvc_core-0.2.28/tests/test_effective_price.py +284 -0
  12. unitysvc_core-0.2.28/tests/test_offering_data.py +14 -0
  13. {unitysvc_core-0.2.26 → unitysvc_core-0.2.28}/tests/test_pricing.py +151 -0
  14. {unitysvc_core-0.2.26 → unitysvc_core-0.2.28}/LICENSE +0 -0
  15. {unitysvc_core-0.2.26 → unitysvc_core-0.2.28}/README.md +0 -0
  16. {unitysvc_core-0.2.26 → unitysvc_core-0.2.28}/setup.cfg +0 -0
  17. {unitysvc_core-0.2.26 → unitysvc_core-0.2.28}/src/unitysvc_core/__init__.py +0 -0
  18. {unitysvc_core-0.2.26 → unitysvc_core-0.2.28}/src/unitysvc_core/models/base.py +0 -0
  19. {unitysvc_core-0.2.26 → unitysvc_core-0.2.28}/src/unitysvc_core/models/documents.py +0 -0
  20. {unitysvc_core-0.2.26 → unitysvc_core-0.2.28}/src/unitysvc_core/models/listing_data.py +0 -0
  21. {unitysvc_core-0.2.26 → unitysvc_core-0.2.28}/src/unitysvc_core/models/listing_v1.py +0 -0
  22. {unitysvc_core-0.2.26 → unitysvc_core-0.2.28}/src/unitysvc_core/models/promotion_data.py +0 -0
  23. {unitysvc_core-0.2.26 → unitysvc_core-0.2.28}/src/unitysvc_core/models/provider_data.py +0 -0
  24. {unitysvc_core-0.2.26 → unitysvc_core-0.2.28}/src/unitysvc_core/models/provider_v1.py +0 -0
  25. {unitysvc_core-0.2.26 → unitysvc_core-0.2.28}/src/unitysvc_core/models/service.py +0 -0
  26. {unitysvc_core-0.2.26 → unitysvc_core-0.2.28}/src/unitysvc_core/models/service_group_data.py +0 -0
  27. {unitysvc_core-0.2.26 → unitysvc_core-0.2.28}/src/unitysvc_core/models/validators.py +0 -0
  28. {unitysvc_core-0.2.26 → unitysvc_core-0.2.28}/src/unitysvc_core/py.typed +0 -0
  29. {unitysvc_core-0.2.26 → unitysvc_core-0.2.28}/src/unitysvc_core/schema/base.json +0 -0
  30. {unitysvc_core-0.2.26 → unitysvc_core-0.2.28}/src/unitysvc_core/schema/promotion_v1.json +0 -0
  31. {unitysvc_core-0.2.26 → unitysvc_core-0.2.28}/src/unitysvc_core/schema/provider_v1.json +0 -0
  32. {unitysvc_core-0.2.26 → unitysvc_core-0.2.28}/src/unitysvc_core/schema/service_group_v1.json +0 -0
  33. {unitysvc_core-0.2.26 → unitysvc_core-0.2.28}/src/unitysvc_core/utils.py +0 -0
  34. {unitysvc_core-0.2.26 → unitysvc_core-0.2.28}/src/unitysvc_core/validator.py +0 -0
  35. {unitysvc_core-0.2.26 → unitysvc_core-0.2.28}/src/unitysvc_core.egg-info/dependency_links.txt +0 -0
  36. {unitysvc_core-0.2.26 → unitysvc_core-0.2.28}/src/unitysvc_core.egg-info/requires.txt +0 -0
  37. {unitysvc_core-0.2.26 → unitysvc_core-0.2.28}/src/unitysvc_core.egg-info/top_level.txt +0 -0
  38. {unitysvc_core-0.2.26 → unitysvc_core-0.2.28}/tests/test_access_interface_routing.py +0 -0
  39. {unitysvc_core-0.2.26 → unitysvc_core-0.2.28}/tests/test_hidden_path_skip.py +0 -0
  40. {unitysvc_core-0.2.26 → unitysvc_core-0.2.28}/tests/test_provider_account_rate_limits.py +0 -0
  41. {unitysvc_core-0.2.26 → unitysvc_core-0.2.28}/tests/test_service_group.py +0 -0
  42. {unitysvc_core-0.2.26 → unitysvc_core-0.2.28}/tests/test_service_status.py +0 -0
  43. {unitysvc_core-0.2.26 → unitysvc_core-0.2.28}/tests/test_utils.py +0 -0
  44. {unitysvc_core-0.2.26 → unitysvc_core-0.2.28}/tests/test_validator.py +0 -0
  45. {unitysvc_core-0.2.26 → unitysvc_core-0.2.28}/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.26
3
+ Version: 0.2.28
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.26"
7
+ version = "0.2.28"
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" }]
@@ -38,6 +38,7 @@ from .pricing import (
38
38
  ConstantPriceData,
39
39
  CountPriceData,
40
40
  DataPriceData,
41
+ EffectivePrice,
41
42
  ExprPriceData,
42
43
  FirstPriceData,
43
44
  GraduatedPriceData,
@@ -56,6 +57,7 @@ from .pricing import (
56
57
  TimePriceData,
57
58
  TokenPriceData,
58
59
  UsageData,
60
+ upstream_price_from_description,
59
61
  validate_pricing,
60
62
  )
61
63
  from .promotion_data import (
@@ -146,6 +148,8 @@ __all__ = [
146
148
  "Pricing",
147
149
  "validate_pricing",
148
150
  "BasePriceData",
151
+ "EffectivePrice",
152
+ "upstream_price_from_description",
149
153
  # Pricing — simple types
150
154
  "TokenPriceData",
151
155
  "TimePriceData",
@@ -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,
@@ -13,6 +13,8 @@ from __future__ import annotations
13
13
 
14
14
  import ast
15
15
  import operator
16
+ import re
17
+ from collections.abc import Callable, Iterable
16
18
  from decimal import Decimal, InvalidOperation
17
19
  from typing import Annotated, Any, Literal
18
20
 
@@ -110,6 +112,7 @@ class UsageData(BaseModel):
110
112
  - **tokens**: one_token, one_thousand_tokens, one_million_tokens
111
113
  - **data**: one_byte, one_kilobyte, one_megabyte, one_gigabyte
112
114
  - **count**: count, one_thousand, one_million
115
+ - **characters**: characters, one_character, one_thousand_characters, one_million_characters
113
116
 
114
117
  Only one field per group should be set per usage instance.
115
118
 
@@ -145,6 +148,24 @@ class UsageData(BaseModel):
145
148
  one_megabyte: float | None = None
146
149
  one_gigabyte: float | None = None
147
150
 
151
+ # Names the GATEWAY actually ships (usage_event.lua), kept as aliases so they
152
+ # survive ``extra="ignore"``. ``images_generated`` and ``duration_seconds``
153
+ # join their equivalence groups below; the byte counters deliberately do
154
+ # NOT — see DataPriceData.based_on.
155
+ images_generated: int | None = None
156
+ duration_seconds: float | None = None
157
+ bytes_in: int | None = None
158
+ bytes_out: int | None = None
159
+
160
+ # Character-based usage (text-to-speech and similar).
161
+ # ``characters`` is what providers actually report — DashScope's TTS answers
162
+ # ``usage: {"characters": N}`` — and is the group's base unit, the way
163
+ # ``seconds`` is for time.
164
+ characters: int | None = None
165
+ one_character: int | None = None
166
+ one_thousand_characters: float | None = None
167
+ one_million_characters: float | None = None
168
+
148
169
  # Count-based usage (images, steps, requests)
149
170
  # count + scaled variants form an equivalence group.
150
171
  count: int | None = None
@@ -152,6 +173,175 @@ class UsageData(BaseModel):
152
173
  one_million: float | None = None
153
174
 
154
175
 
176
+ # ============================================================================
177
+ # Effective price — a per-unit comparison figure, never billed
178
+ # ============================================================================
179
+
180
+ #: Normalized units an effective price is expressed in. Each dimension has one,
181
+ #: so figures of the same dimension always compare directly.
182
+ UNIT_TOKENS = "one_million_tokens"
183
+ UNIT_REQUEST = "request"
184
+ UNIT_TIME = "one_hour"
185
+ UNIT_DATA = "one_gigabyte"
186
+ UNIT_CHARACTERS = "one_million_characters"
187
+ UNIT_IMAGE = "image"
188
+ UNIT_STEP = "step"
189
+
190
+ #: Each measured equivalence group's normalized unit (counts compare per request).
191
+ _GROUP_UNIT = {
192
+ "tokens": UNIT_TOKENS,
193
+ "time": UNIT_TIME,
194
+ "data": UNIT_DATA,
195
+ "characters": UNIT_CHARACTERS,
196
+ }
197
+
198
+ #: When figures in different units must be reduced to one (``max``, ``min``,
199
+ #: ``tiered``, ``channel``), the first unit present in this order wins; any
200
+ #: other unit follows alphabetically.
201
+ _UNIT_PRIORITY = (UNIT_TOKENS, UNIT_REQUEST)
202
+
203
+ #: Output tokens weigh 4x input: typical chat traffic, and the platform's
204
+ #: long-standing blend convention.
205
+ _OUTPUT_WEIGHT = Decimal(4)
206
+
207
+
208
+ class EffectivePrice(BaseModel):
209
+ """What using a service realistically costs per unit, for comparison.
210
+
211
+ A best-effort estimate for sorting, filtering and routing — never billed.
212
+ ``price`` (when set) is what is charged; this is computed from the pricing
213
+ object by ``effective_price()`` and never written back into it.
214
+ """
215
+
216
+ model_config = ConfigDict(extra="forbid", frozen=True)
217
+
218
+ amount: Decimal = Field(description="Cost per one ``unit``")
219
+ unit: str = Field(
220
+ description="Normalized unit: 'one_million_tokens', 'request', 'one_hour', 'one_gigabyte', "
221
+ "'one_million_characters', 'image' or 'step'",
222
+ )
223
+ source: Literal["rate", "upstream"] = Field(
224
+ default="rate",
225
+ description="'rate': from the price itself. 'upstream': a $0 price whose description states what the "
226
+ "customer pays the upstream provider (bring-your-own-key).",
227
+ )
228
+
229
+
230
+ def _blend(tokens_in: Decimal, tokens_out: Decimal) -> Decimal:
231
+ return (tokens_in + _OUTPUT_WEIGHT * tokens_out) / (1 + _OUTPUT_WEIGHT)
232
+
233
+
234
+ def _unit_rank(unit: str) -> tuple[int, str]:
235
+ return (_UNIT_PRIORITY.index(unit), "") if unit in _UNIT_PRIORITY else (len(_UNIT_PRIORITY), unit)
236
+
237
+
238
+ def _child_effective_price(price_data: Any) -> EffectivePrice | None:
239
+ """The effective price of a nested pricing dict; None if it is unknown or invalid."""
240
+ if not isinstance(price_data, dict):
241
+ return None
242
+ try:
243
+ return validate_pricing(price_data).effective_price()
244
+ except Exception:
245
+ return None
246
+
247
+
248
+ def _pick(
249
+ figures: Iterable[EffectivePrice | None],
250
+ choose: Callable[[Iterable[EffectivePrice]], EffectivePrice],
251
+ ) -> EffectivePrice | None:
252
+ """Reduce known figures to one, comparing only within the dominant unit."""
253
+ known = [f for f in figures if f is not None]
254
+ if not known:
255
+ return None
256
+ unit = min((f.unit for f in known), key=_unit_rank)
257
+ return choose(f for f in known if f.unit == unit)
258
+
259
+
260
+ def _largest(figures: Iterable[EffectivePrice | None]) -> EffectivePrice | None:
261
+ return _pick(figures, lambda fs: max(fs, key=lambda f: f.amount))
262
+
263
+
264
+ def _smallest(figures: Iterable[EffectivePrice | None]) -> EffectivePrice | None:
265
+ return _pick(figures, lambda fs: min(fs, key=lambda f: f.amount))
266
+
267
+
268
+ def _rate(amount: Decimal, unit: str) -> EffectivePrice:
269
+ return EffectivePrice(amount=amount, unit=unit)
270
+
271
+
272
+ def _per_normalized_unit(metric: str, unit_price: Decimal) -> EffectivePrice | None:
273
+ """Express a price per one ``metric`` in the metric dimension's normalized unit.
274
+
275
+ ``metric`` is a pricing type or a ``based_on`` name: an equivalence-group
276
+ unit, a token counter, ``request_count``, or a gateway byte counter. Any
277
+ other metric (``customer_charge``, an expression) has no unit: None.
278
+ """
279
+ if metric in ("input_tokens", "output_tokens", "cached_input_tokens", "total_tokens"):
280
+ metric = "one_token"
281
+ elif metric in ("bytes_in", "bytes_out", "bytes_total"):
282
+ metric = "one_byte"
283
+ if metric == "request_count":
284
+ return _rate(unit_price, UNIT_REQUEST)
285
+ if metric == "images_generated":
286
+ return _rate(unit_price, UNIT_IMAGE)
287
+ if metric not in _UNIT_TO_GROUP:
288
+ return None
289
+ group, factor = _UNIT_TO_GROUP[metric]
290
+ if group == "count":
291
+ # One count is one call, event or message: compared per request.
292
+ return _rate(unit_price / factor, UNIT_REQUEST)
293
+ target = _GROUP_UNIT[group]
294
+ return _rate(unit_price * EQUIVALENCE_GROUPS[group][target] / factor, target)
295
+
296
+
297
+ _DOLLARS = r"\$\s*\d+(?:\.\d+)?"
298
+ # "$1.4 / $4.4 per 1M input/output tokens", "$1.518/$4.554 / 1M input/output
299
+ # tokens", "$4 / $20 / $0.2 per 1M input/output/cached tokens",
300
+ # "$3.45/$0.345/$17.25 / 1M in/cached/out tokens", "$0.50 / 1M tokens".
301
+ _UPSTREAM_TOKEN_PRICE = re.compile(
302
+ rf"(?P<amounts>{_DOLLARS}(?:\s*/\s*{_DOLLARS})*)\s*(?:/|per)\s*1\s*(?P<scale>[MK])\s+"
303
+ r"(?:(?P<labels>[a-z_]+(?:\s*/\s*[a-z_]+)*)\s+)?tokens\b",
304
+ re.IGNORECASE,
305
+ )
306
+ _UPSTREAM_LABELS = {
307
+ "input": "in",
308
+ "in": "in",
309
+ "output": "out",
310
+ "out": "out",
311
+ "cached": "cached",
312
+ "cache": "cached",
313
+ "cached_input": "cached",
314
+ }
315
+
316
+
317
+ def upstream_price_from_description(text: str | None) -> Decimal | None:
318
+ """The upstream price per 1M tokens a description states, blended; or None.
319
+
320
+ Bring-your-own-key prices are ``0`` (we bill nothing) and their generators
321
+ write what the provider charges into the description, e.g. "billed by
322
+ Hugging Face directly at $1.4 / $4.4 per 1M input/output tokens".
323
+
324
+ Fails closed: text not in a known shape (labels that don't match the
325
+ amounts, no input/output pair, no token unit) is None, never 0.
326
+ """
327
+ if not text:
328
+ return None
329
+ match = _UPSTREAM_TOKEN_PRICE.search(text)
330
+ if match is None:
331
+ return None
332
+ amounts = [Decimal(a) for a in re.findall(r"\d+(?:\.\d+)?", match["amounts"])]
333
+ scale = Decimal(1000) if match["scale"].upper() == "K" else Decimal(1)
334
+ if not match["labels"]:
335
+ return amounts[0] * scale if len(amounts) == 1 else None
336
+ labels = [_UPSTREAM_LABELS.get(label.strip().lower()) for label in match["labels"].split("/")]
337
+ if len(labels) != len(amounts) or None in labels:
338
+ return None
339
+ by_label = dict(zip(labels, amounts, strict=True))
340
+ if "in" not in by_label or "out" not in by_label:
341
+ return None
342
+ return _blend(by_label["in"], by_label["out"]) * scale
343
+
344
+
155
345
  # ============================================================================
156
346
  # Pricing Models - Discriminated Union for type-safe pricing validation
157
347
  # ============================================================================
@@ -188,6 +378,15 @@ class BasePriceData(BaseModel):
188
378
  description="URL to upstream provider's pricing page",
189
379
  )
190
380
 
381
+ def effective_price(self) -> EffectivePrice | None:
382
+ """What using this price realistically costs per unit, or None if unknown.
383
+
384
+ A comparison figure for sorting, filtering and routing; never billed.
385
+ Every pricing type defines it, so a composite price computes its figure
386
+ from its children. Unknown is never 0. Never raises.
387
+ """
388
+ return None
389
+
191
390
 
192
391
  class TokenPriceData(BasePriceData):
193
392
  """
@@ -275,6 +474,16 @@ class TokenPriceData(BasePriceData):
275
474
  """Token divisor based on the pricing type."""
276
475
  return {"one_million_tokens": 1_000_000, "one_thousand_tokens": 1_000, "one_token": 1}[self.type]
277
476
 
477
+ def effective_price(self) -> EffectivePrice | None:
478
+ """``(input + 4*output) / 5`` when priced separately, else ``price``; per 1M tokens."""
479
+ if self.input is not None and self.output is not None:
480
+ per_unit = _blend(Decimal(self.input), Decimal(self.output))
481
+ elif self.price is not None:
482
+ per_unit = Decimal(self.price)
483
+ else:
484
+ return None
485
+ return _per_normalized_unit(self.type, per_unit)
486
+
278
487
  def calculate_cost(
279
488
  self,
280
489
  usage: UsageData,
@@ -342,6 +551,10 @@ class TimePriceData(BasePriceData):
342
551
  description="Price per one unit of the specified type",
343
552
  )
344
553
 
554
+ def effective_price(self) -> EffectivePrice | None:
555
+ """``price`` in the dimension's normalized unit."""
556
+ return _per_normalized_unit(self.type, Decimal(self.price))
557
+
345
558
  def calculate_cost(
346
559
  self,
347
560
  usage: UsageData,
@@ -383,6 +596,10 @@ class ImagePriceData(BasePriceData):
383
596
  description="Price per image",
384
597
  )
385
598
 
599
+ def effective_price(self) -> EffectivePrice | None:
600
+ """``price`` per image."""
601
+ return _rate(Decimal(self.price), UNIT_IMAGE)
602
+
386
603
  def calculate_cost(
387
604
  self,
388
605
  usage: UsageData,
@@ -400,10 +617,13 @@ class ImagePriceData(BasePriceData):
400
617
  Returns:
401
618
  Calculated cost based on image count
402
619
  """
403
- if usage.count is None:
620
+ # Via the equivalence group, so the gateway's ``images_generated`` counts
621
+ # as well as an explicit ``count``.
622
+ units = _resolve_equivalent_metric("count", usage)
623
+ if units is None:
404
624
  raise ValueError("Image pricing requires 'count' in usage data")
405
625
 
406
- return Decimal(self.price) * usage.count
626
+ return Decimal(self.price) * units
407
627
 
408
628
 
409
629
  class StepPriceData(BasePriceData):
@@ -420,6 +640,10 @@ class StepPriceData(BasePriceData):
420
640
  description="Price per step/iteration",
421
641
  )
422
642
 
643
+ def effective_price(self) -> EffectivePrice | None:
644
+ """``price`` per step."""
645
+ return _rate(Decimal(self.price), UNIT_STEP)
646
+
423
647
  def calculate_cost(
424
648
  self,
425
649
  usage: UsageData,
@@ -452,14 +676,31 @@ class DataPriceData(BasePriceData):
452
676
 
453
677
  Usage can be provided in **any** data unit — cross-unit conversion is
454
678
  handled automatically via equivalence groups.
679
+
680
+ The gateway ships ``bytes_in`` and ``bytes_out`` on every request. Those are
681
+ two directions of one dimension, so they are deliberately NOT both in the
682
+ data equivalence group: a group lookup returns whichever field the dict
683
+ happens to yield first, which would make the billed direction depend on
684
+ iteration order. ``based_on`` names the direction instead, so it is a
685
+ property of the price rather than an accident of the payload.
455
686
  """
456
687
 
457
688
  type: Literal["one_byte", "one_kilobyte", "one_megabyte", "one_gigabyte"] = "one_megabyte"
458
689
 
690
+ based_on: Literal["bytes_out", "bytes_in", "bytes_total"] = Field(
691
+ default="bytes_out",
692
+ description="Which byte counter to price. Defaults to egress, the "
693
+ "conventional billable direction; 'bytes_total' sums both.",
694
+ )
695
+
459
696
  price: PriceStr = Field(
460
697
  description="Price per one unit of the specified type",
461
698
  )
462
699
 
700
+ def effective_price(self) -> EffectivePrice | None:
701
+ """``price`` in the dimension's normalized unit."""
702
+ return _per_normalized_unit(self.type, Decimal(self.price))
703
+
463
704
  def calculate_cost(
464
705
  self,
465
706
  usage: UsageData,
@@ -477,7 +718,24 @@ class DataPriceData(BasePriceData):
477
718
  Returns:
478
719
  Calculated cost based on data usage
479
720
  """
480
- units = _resolve_equivalent_metric(self.type, usage)
721
+ # The named byte counter wins when present — it is what the gateway
722
+ # actually sends. Fall back to the canonical one_byte..one_gigabyte
723
+ # units so a caller supplying those directly is unaffected.
724
+ raw_bytes: Decimal | None = None
725
+ if self.based_on == "bytes_total":
726
+ if usage.bytes_in is not None or usage.bytes_out is not None:
727
+ raw_bytes = Decimal(usage.bytes_in or 0) + Decimal(usage.bytes_out or 0)
728
+ else:
729
+ value = getattr(usage, self.based_on, None)
730
+ if value is not None:
731
+ raw_bytes = Decimal(str(value))
732
+
733
+ units: Decimal | None
734
+ if raw_bytes is not None:
735
+ units = raw_bytes / EQUIVALENCE_GROUPS["data"][self.type]
736
+ else:
737
+ units = _resolve_equivalent_metric(self.type, usage)
738
+
481
739
  if units is None:
482
740
  raise ValueError(f"Data pricing ({self.type}) requires a data field in usage data")
483
741
 
@@ -504,6 +762,10 @@ class CountPriceData(BasePriceData):
504
762
  description="Price per one unit of the specified type",
505
763
  )
506
764
 
765
+ def effective_price(self) -> EffectivePrice | None:
766
+ """``price`` in the dimension's normalized unit."""
767
+ return _per_normalized_unit(self.type, Decimal(self.price))
768
+
507
769
  def calculate_cost(
508
770
  self,
509
771
  usage: UsageData,
@@ -528,6 +790,61 @@ class CountPriceData(BasePriceData):
528
790
  return Decimal(self.price) * units
529
791
 
530
792
 
793
+ class CharacterPriceData(BasePriceData):
794
+ """
795
+ Price data for character-scaled pricing (text-to-speech and similar).
796
+
797
+ Supported types: ``one_character``, ``one_thousand_characters``,
798
+ ``one_million_characters``. The ``characters`` equivalence-group field is
799
+ the base unit, and it is the name providers actually report — DashScope's
800
+ TTS answers ``usage: {"characters": N}``.
801
+
802
+ Usage may be given in any unit of the group; conversion is automatic.
803
+
804
+ Separate from ``CountPriceData`` on purpose: characters and counts are
805
+ different dimensions, and collapsing them would let "per 1M characters" and
806
+ "per 1M images" resolve against each other's usage.
807
+ """
808
+
809
+ type: Literal["one_character", "one_thousand_characters", "one_million_characters"] = "one_million_characters"
810
+
811
+ price: PriceStr = Field(
812
+ description="Price per one unit of the specified type",
813
+ )
814
+
815
+ def effective_price(self) -> EffectivePrice | None:
816
+ """``price`` in the dimension's normalized unit."""
817
+ return _per_normalized_unit(self.type, Decimal(self.price))
818
+
819
+ def calculate_cost(
820
+ self,
821
+ usage: UsageData,
822
+ customer_charge: Decimal | None = None,
823
+ request_count: int | None = None,
824
+ channel: str | None = None,
825
+ ) -> Decimal:
826
+ """Calculate cost for character-scaled pricing.
827
+
828
+ Args:
829
+ usage: Usage data with any character field
830
+ customer_charge: Not used (ignored)
831
+ request_count: Not used (ignored)
832
+
833
+ Returns:
834
+ Calculated cost based on character usage
835
+
836
+ Raises:
837
+ ValueError: if no character field is populated. Raising rather than
838
+ treating absence as zero is deliberate — a silent zero here is a
839
+ charge of nothing for work actually done.
840
+ """
841
+ units = _resolve_equivalent_metric(self.type, usage)
842
+ if units is None:
843
+ raise ValueError(f"Character pricing ({self.type}) requires a character field in usage data")
844
+
845
+ return Decimal(self.price) * units
846
+
847
+
531
848
  def _validate_percentage_string(v: Any) -> str:
532
849
  """Validate that percentage values are strings representing valid decimals in range 0-100."""
533
850
  # First use the standard price validation
@@ -567,6 +884,10 @@ class RevenueSharePriceData(BasePriceData):
567
884
  description="Percentage of customer charge that goes to the seller (0-100)",
568
885
  )
569
886
 
887
+ def effective_price(self) -> EffectivePrice | None:
888
+ """A share of the customer charge has no per-unit rate: unknown."""
889
+ return None
890
+
570
891
  def calculate_cost(
571
892
  self,
572
893
  usage: UsageData,
@@ -607,6 +928,23 @@ class ConstantPriceData(BasePriceData):
607
928
  description="Fixed price (positive for charge, negative for discount)",
608
929
  )
609
930
 
931
+ def effective_price(self) -> EffectivePrice | None:
932
+ """``price`` per request.
933
+
934
+ A ``0`` with a description is a bring-your-own-key price: the customer
935
+ pays the upstream provider, at the rate the description states. A
936
+ description that states no parseable rate, or $0/$0 (what generators
937
+ write when they lack the provider's price), is unknown. A ``0`` with no
938
+ description is genuinely free.
939
+ """
940
+ amount = Decimal(self.price)
941
+ if amount != 0 or not self.description:
942
+ return _rate(amount, UNIT_REQUEST)
943
+ upstream = upstream_price_from_description(self.description)
944
+ if upstream is None or upstream == 0:
945
+ return None
946
+ return EffectivePrice(amount=upstream, unit=UNIT_TOKENS, source="upstream")
947
+
610
948
  def calculate_cost(
611
949
  self,
612
950
  usage: UsageData,
@@ -680,6 +1018,15 @@ class AddPriceData(BasePriceData):
680
1018
  self.price = _extract_nominal_price(self.prices[0])
681
1019
  return self
682
1020
 
1021
+ def effective_price(self) -> EffectivePrice | None:
1022
+ """The sum of the children; unknown if any child is unknown or the units differ."""
1023
+ figures = [_child_effective_price(p) for p in self.prices]
1024
+ known = [f for f in figures if f is not None]
1025
+ if len(known) != len(figures) or len({f.unit for f in known}) != 1:
1026
+ return None
1027
+ source: Literal["rate", "upstream"] = "upstream" if any(f.source == "upstream" for f in known) else "rate"
1028
+ return EffectivePrice(amount=sum((f.amount for f in known), Decimal(0)), unit=known[0].unit, source=source)
1029
+
683
1030
  def calculate_cost(
684
1031
  self,
685
1032
  usage: UsageData,
@@ -740,6 +1087,13 @@ class MultiplyPriceData(BasePriceData):
740
1087
  self.price = base_price
741
1088
  return self
742
1089
 
1090
+ def effective_price(self) -> EffectivePrice | None:
1091
+ """The base's figure times ``factor``."""
1092
+ base = _child_effective_price(self.base)
1093
+ if base is None:
1094
+ return None
1095
+ return base.model_copy(update={"amount": base.amount * Decimal(self.factor)})
1096
+
743
1097
  def calculate_cost(
744
1098
  self,
745
1099
  usage: UsageData,
@@ -794,6 +1148,10 @@ class MaxPriceData(BasePriceData):
794
1148
  self.price = _extract_nominal_price(self.prices[0])
795
1149
  return self
796
1150
 
1151
+ def effective_price(self) -> EffectivePrice | None:
1152
+ """The largest child, as billed."""
1153
+ return _largest(_child_effective_price(p) for p in self.prices)
1154
+
797
1155
  def calculate_cost(
798
1156
  self,
799
1157
  usage: UsageData,
@@ -852,6 +1210,10 @@ class MinPriceData(BasePriceData):
852
1210
  self.price = _extract_nominal_price(self.prices[0])
853
1211
  return self
854
1212
 
1213
+ def effective_price(self) -> EffectivePrice | None:
1214
+ """The smallest child, as billed."""
1215
+ return _smallest(_child_effective_price(p) for p in self.prices)
1216
+
855
1217
  def calculate_cost(
856
1218
  self,
857
1219
  usage: UsageData,
@@ -909,6 +1271,14 @@ class FirstPriceData(BasePriceData):
909
1271
  self.price = _extract_nominal_price(self.prices[0])
910
1272
  return self
911
1273
 
1274
+ def effective_price(self) -> EffectivePrice | None:
1275
+ """The first child with a known figure, mirroring the billing order."""
1276
+ for price_data in self.prices:
1277
+ figure = _child_effective_price(price_data)
1278
+ if figure is not None:
1279
+ return figure
1280
+ return None
1281
+
912
1282
  def calculate_cost(
913
1283
  self,
914
1284
  usage: UsageData,
@@ -940,6 +1310,7 @@ class FirstPriceData(BasePriceData):
940
1310
  EQUIVALENCE_GROUPS: dict[str, dict[str, Decimal]] = {
941
1311
  "time": {
942
1312
  "seconds": Decimal(1),
1313
+ "duration_seconds": Decimal(1), # what usage_event.lua ships
943
1314
  "one_second": Decimal(1),
944
1315
  "one_minute": Decimal(60),
945
1316
  "one_hour": Decimal(3600),
@@ -959,9 +1330,16 @@ EQUIVALENCE_GROUPS: dict[str, dict[str, Decimal]] = {
959
1330
  },
960
1331
  "count": {
961
1332
  "count": Decimal(1),
1333
+ "images_generated": Decimal(1), # what usage_event.lua ships
962
1334
  "one_thousand": Decimal(1000),
963
1335
  "one_million": Decimal(1_000_000),
964
1336
  },
1337
+ "characters": {
1338
+ "characters": Decimal(1),
1339
+ "one_character": Decimal(1),
1340
+ "one_thousand_characters": Decimal(1000),
1341
+ "one_million_characters": Decimal(1_000_000),
1342
+ },
965
1343
  }
966
1344
 
967
1345
  # Reverse lookup: unit_name → (group_name, base_factor)
@@ -1135,6 +1513,10 @@ class ExprPriceData(BasePriceData):
1135
1513
  description="Arithmetic expression using usage metrics (e.g., 'input_tokens / 1000000 * 2.5')",
1136
1514
  )
1137
1515
 
1516
+ def effective_price(self) -> EffectivePrice | None:
1517
+ """An arbitrary expression has no per-unit rate: unknown."""
1518
+ return None
1519
+
1138
1520
  def calculate_cost(
1139
1521
  self,
1140
1522
  usage: UsageData,
@@ -1209,6 +1591,10 @@ class TieredPriceData(BasePriceData):
1209
1591
  self.price = _extract_nominal_price(first_tier.price)
1210
1592
  return self
1211
1593
 
1594
+ def effective_price(self) -> EffectivePrice | None:
1595
+ """The largest tier's figure: the worst case a customer can pay."""
1596
+ return _largest(_child_effective_price(tier.price) for tier in self.tiers)
1597
+
1212
1598
  def calculate_cost(
1213
1599
  self,
1214
1600
  usage: UsageData,
@@ -1292,6 +1678,13 @@ class GraduatedPriceData(BasePriceData):
1292
1678
  self.price = self.tiers[0].unit_price
1293
1679
  return self
1294
1680
 
1681
+ def effective_price(self) -> EffectivePrice | None:
1682
+ """The largest ``unit_price`` (the worst case), per ``based_on``'s normalized unit.
1683
+
1684
+ Unknown when ``based_on`` has no unit (``customer_charge``, an expression).
1685
+ """
1686
+ return _per_normalized_unit(self.based_on, max(Decimal(tier.unit_price) for tier in self.tiers))
1687
+
1295
1688
  def calculate_cost(
1296
1689
  self,
1297
1690
  usage: UsageData,
@@ -1415,6 +1808,10 @@ class ChannelPriceData(BasePriceData):
1415
1808
  self.description = f"{lo} - {hi}"
1416
1809
  return self
1417
1810
 
1811
+ def effective_price(self) -> EffectivePrice | None:
1812
+ """The largest figure among all channels: the worst case a customer can pay."""
1813
+ return _largest(_child_effective_price(p) for p in self.channels.values())
1814
+
1418
1815
  def calculate_cost(
1419
1816
  self,
1420
1817
  usage: UsageData,
@@ -1448,6 +1845,7 @@ Pricing = Annotated[
1448
1845
  | TimePriceData
1449
1846
  | DataPriceData
1450
1847
  | CountPriceData
1848
+ | CharacterPriceData
1451
1849
  | ImagePriceData
1452
1850
  | StepPriceData
1453
1851
  | RevenueSharePriceData
@@ -1472,6 +1870,7 @@ def validate_pricing(
1472
1870
  | TimePriceData
1473
1871
  | DataPriceData
1474
1872
  | CountPriceData
1873
+ | CharacterPriceData
1475
1874
  | ImagePriceData
1476
1875
  | StepPriceData
1477
1876
  | RevenueSharePriceData