unitysvc-services 0.1.24__py3-none-any.whl
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_services/__init__.py +4 -0
- unitysvc_services/api.py +421 -0
- unitysvc_services/cli.py +23 -0
- unitysvc_services/format_data.py +140 -0
- unitysvc_services/interactive_prompt.py +1132 -0
- unitysvc_services/list.py +216 -0
- unitysvc_services/models/__init__.py +71 -0
- unitysvc_services/models/base.py +1375 -0
- unitysvc_services/models/listing_data.py +118 -0
- unitysvc_services/models/listing_v1.py +56 -0
- unitysvc_services/models/provider_data.py +79 -0
- unitysvc_services/models/provider_v1.py +54 -0
- unitysvc_services/models/seller_data.py +120 -0
- unitysvc_services/models/seller_v1.py +42 -0
- unitysvc_services/models/service_data.py +114 -0
- unitysvc_services/models/service_v1.py +81 -0
- unitysvc_services/populate.py +207 -0
- unitysvc_services/publisher.py +1628 -0
- unitysvc_services/py.typed +0 -0
- unitysvc_services/query.py +688 -0
- unitysvc_services/scaffold.py +1103 -0
- unitysvc_services/schema/base.json +777 -0
- unitysvc_services/schema/listing_v1.json +1286 -0
- unitysvc_services/schema/provider_v1.json +952 -0
- unitysvc_services/schema/seller_v1.json +379 -0
- unitysvc_services/schema/service_v1.json +1306 -0
- unitysvc_services/test.py +965 -0
- unitysvc_services/unpublisher.py +505 -0
- unitysvc_services/update.py +287 -0
- unitysvc_services/utils.py +533 -0
- unitysvc_services/validator.py +731 -0
- unitysvc_services-0.1.24.dist-info/METADATA +184 -0
- unitysvc_services-0.1.24.dist-info/RECORD +37 -0
- unitysvc_services-0.1.24.dist-info/WHEEL +5 -0
- unitysvc_services-0.1.24.dist-info/entry_points.txt +3 -0
- unitysvc_services-0.1.24.dist-info/licenses/LICENSE +21 -0
- unitysvc_services-0.1.24.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1375 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import operator
|
|
5
|
+
import re
|
|
6
|
+
from decimal import Decimal, InvalidOperation
|
|
7
|
+
from enum import StrEnum
|
|
8
|
+
from typing import Annotated, Any, Literal
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
11
|
+
from pydantic.functional_validators import BeforeValidator
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _validate_price_string(v: Any) -> str:
|
|
15
|
+
"""Validate that price values are strings representing valid non-negative decimal numbers.
|
|
16
|
+
|
|
17
|
+
This prevents floating-point precision issues where values like 2.0
|
|
18
|
+
might become 1.9999999 when saved/loaded. Prices are stored as strings
|
|
19
|
+
and converted to Decimal only when calculations are needed.
|
|
20
|
+
"""
|
|
21
|
+
if isinstance(v, float):
|
|
22
|
+
raise ValueError(
|
|
23
|
+
f"Price value must be a string (e.g., '0.50'), not a float ({v}). Floats can cause precision issues."
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
# Convert int to string first
|
|
27
|
+
if isinstance(v, int):
|
|
28
|
+
v = str(v)
|
|
29
|
+
|
|
30
|
+
if not isinstance(v, str):
|
|
31
|
+
raise ValueError(f"Price value must be a string, got {type(v).__name__}")
|
|
32
|
+
|
|
33
|
+
# Validate it's a valid decimal number and non-negative
|
|
34
|
+
try:
|
|
35
|
+
decimal_val = Decimal(v)
|
|
36
|
+
except InvalidOperation:
|
|
37
|
+
raise ValueError(f"Price value '{v}' is not a valid decimal number")
|
|
38
|
+
|
|
39
|
+
if decimal_val < 0:
|
|
40
|
+
raise ValueError(f"Price value must be non-negative, got '{v}'")
|
|
41
|
+
|
|
42
|
+
return v
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# Price string type that only accepts strings/ints, not floats
|
|
46
|
+
PriceStr = Annotated[str, BeforeValidator(_validate_price_string)]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _validate_amount_string(v: Any) -> str:
|
|
50
|
+
"""Validate that amount values are strings representing valid decimal numbers.
|
|
51
|
+
|
|
52
|
+
Similar to _validate_price_string but allows negative values for
|
|
53
|
+
discounts, fees, and adjustments.
|
|
54
|
+
"""
|
|
55
|
+
if isinstance(v, float):
|
|
56
|
+
raise ValueError(
|
|
57
|
+
f"Amount value must be a string (e.g., '-5.00'), not a float ({v}). Floats can cause precision issues."
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
# Convert int to string first
|
|
61
|
+
if isinstance(v, int):
|
|
62
|
+
v = str(v)
|
|
63
|
+
|
|
64
|
+
if not isinstance(v, str):
|
|
65
|
+
raise ValueError(f"Amount value must be a string, got {type(v).__name__}")
|
|
66
|
+
|
|
67
|
+
# Validate it's a valid decimal number (can be negative)
|
|
68
|
+
try:
|
|
69
|
+
Decimal(v)
|
|
70
|
+
except InvalidOperation:
|
|
71
|
+
raise ValueError(f"Amount value '{v}' is not a valid decimal number")
|
|
72
|
+
|
|
73
|
+
return v
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# Amount string type that allows negative values (for fees, discounts)
|
|
77
|
+
AmountStr = Annotated[str, BeforeValidator(_validate_amount_string)]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ============================================================================
|
|
81
|
+
# Usage Data for cost calculation
|
|
82
|
+
# ============================================================================
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class UsageData(BaseModel):
|
|
86
|
+
"""
|
|
87
|
+
Usage data for cost calculation.
|
|
88
|
+
|
|
89
|
+
Different pricing types require different usage fields:
|
|
90
|
+
- one_million_tokens: input_tokens, output_tokens (or total_tokens)
|
|
91
|
+
- one_second: seconds
|
|
92
|
+
- image: count
|
|
93
|
+
- step: count
|
|
94
|
+
|
|
95
|
+
Extra fields are ignored, so you can pass **usage_info directly.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
model_config = ConfigDict(extra="ignore")
|
|
99
|
+
|
|
100
|
+
# Token-based usage (for LLMs)
|
|
101
|
+
input_tokens: int | None = None
|
|
102
|
+
output_tokens: int | None = None
|
|
103
|
+
total_tokens: int | None = None # Alternative to input/output for unified pricing
|
|
104
|
+
|
|
105
|
+
# Time-based usage
|
|
106
|
+
seconds: float | None = None
|
|
107
|
+
|
|
108
|
+
# Count-based usage (images, steps, requests)
|
|
109
|
+
count: int | None = None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class AccessMethodEnum(StrEnum):
|
|
113
|
+
http = "http"
|
|
114
|
+
websocket = "websocket"
|
|
115
|
+
grpc = "grpc"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class CurrencyEnum(StrEnum):
|
|
119
|
+
"""Supported currency codes for pricing."""
|
|
120
|
+
|
|
121
|
+
# Traditional currencies
|
|
122
|
+
USD = "USD" # US Dollar
|
|
123
|
+
EUR = "EUR" # Euro
|
|
124
|
+
GBP = "GBP" # British Pound
|
|
125
|
+
JPY = "JPY" # Japanese Yen
|
|
126
|
+
CNY = "CNY" # Chinese Yuan
|
|
127
|
+
CAD = "CAD" # Canadian Dollar
|
|
128
|
+
AUD = "AUD" # Australian Dollar
|
|
129
|
+
CHF = "CHF" # Swiss Franc
|
|
130
|
+
INR = "INR" # Indian Rupee
|
|
131
|
+
KRW = "KRW" # Korean Won
|
|
132
|
+
|
|
133
|
+
# Cryptocurrencies
|
|
134
|
+
BTC = "BTC" # Bitcoin
|
|
135
|
+
ETH = "ETH" # Ethereum
|
|
136
|
+
USDT = "USDT" # Tether
|
|
137
|
+
USDC = "USDC" # USD Coin
|
|
138
|
+
TAO = "TAO" # Bittensor TAO
|
|
139
|
+
|
|
140
|
+
# Credits/Points (for platforms that use credits)
|
|
141
|
+
CREDITS = "CREDITS" # Generic credits system
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class AuthMethodEnum(StrEnum):
|
|
145
|
+
api_key = "api_key"
|
|
146
|
+
oauth = "oauth"
|
|
147
|
+
jwt = "jwt"
|
|
148
|
+
bearer_token = "bearer_token"
|
|
149
|
+
basic_auth = "basic_auth"
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class ContentFilterEnum(StrEnum):
|
|
153
|
+
adult = "adult"
|
|
154
|
+
violence = "violence"
|
|
155
|
+
hate_speech = "hate_speech"
|
|
156
|
+
profanity = "profanity"
|
|
157
|
+
pii = "pii" # Personally Identifiable Information
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class DocumentContextEnum(StrEnum):
|
|
161
|
+
access_interface = "access_interface" # Documents belong to AccessInterface
|
|
162
|
+
service_definition = "service_definition" # Documents belong to ServiceDefinition
|
|
163
|
+
service_offering = "service_offering" # Documents belong to ServiceOffering
|
|
164
|
+
service_listing = "service_listing" # Documents belong to ServiceListing
|
|
165
|
+
user = "user" # can be for seller, subscriber, consumer
|
|
166
|
+
# Backend-specific contexts
|
|
167
|
+
seller = "seller" # Documents belong to Seller
|
|
168
|
+
provider = "provider" # Documents belong to Provider
|
|
169
|
+
#
|
|
170
|
+
customer_statement = "customer_statement"
|
|
171
|
+
seller_invoice = "seller_invoice"
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class DocumentCategoryEnum(StrEnum):
|
|
175
|
+
getting_started = "getting_started"
|
|
176
|
+
api_reference = "api_reference"
|
|
177
|
+
tutorial = "tutorial"
|
|
178
|
+
code_example = "code_example"
|
|
179
|
+
code_example_output = "code_example_output"
|
|
180
|
+
use_case = "use_case"
|
|
181
|
+
troubleshooting = "troubleshooting"
|
|
182
|
+
changelog = "changelog"
|
|
183
|
+
best_practice = "best_practice"
|
|
184
|
+
specification = "specification"
|
|
185
|
+
service_level_agreement = "service_level_agreement"
|
|
186
|
+
terms_of_service = "terms_of_service"
|
|
187
|
+
statement = "statement"
|
|
188
|
+
invoice = "invoice"
|
|
189
|
+
logo = "logo"
|
|
190
|
+
avatar = "avatar"
|
|
191
|
+
other = "other"
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class MimeTypeEnum(StrEnum):
|
|
195
|
+
markdown = "markdown"
|
|
196
|
+
python = "python"
|
|
197
|
+
javascript = "javascript"
|
|
198
|
+
bash = "bash"
|
|
199
|
+
html = "html"
|
|
200
|
+
text = "text"
|
|
201
|
+
pdf = "pdf"
|
|
202
|
+
jpeg = "jpeg"
|
|
203
|
+
png = "png"
|
|
204
|
+
svg = "svg"
|
|
205
|
+
url = "url"
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
class InterfaceContextTypeEnum(StrEnum):
|
|
209
|
+
service_offering = "service_offering" # Pricing from upstream provider
|
|
210
|
+
service_listing = "service_listing" # Pricing shown to end users
|
|
211
|
+
service_subscription = "service_subscription" # User's subscription to a service
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
class SellerTypeEnum(StrEnum):
|
|
215
|
+
individual = "individual"
|
|
216
|
+
organization = "organization"
|
|
217
|
+
partnership = "partnership"
|
|
218
|
+
corporation = "corporation"
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class ListingStatusEnum(StrEnum):
|
|
222
|
+
"""
|
|
223
|
+
Listing status values that sellers can set locally.
|
|
224
|
+
|
|
225
|
+
Seller-accessible statuses:
|
|
226
|
+
- draft: Listing is being worked on, skipped during publish (won't be sent to backend)
|
|
227
|
+
- ready: Listing is complete and ready for admin review/testing
|
|
228
|
+
- deprecated: Seller marks service as retired/replaced
|
|
229
|
+
|
|
230
|
+
Note: Admin-managed workflow statuses (upstream_ready, downstream_ready, in_service)
|
|
231
|
+
are set by the backend admin after testing and validation. These are not included in this
|
|
232
|
+
enum since sellers cannot set them through the CLI tool.
|
|
233
|
+
"""
|
|
234
|
+
|
|
235
|
+
# Still being worked on - skip during publish
|
|
236
|
+
draft = "draft"
|
|
237
|
+
# Ready for admin review and testing
|
|
238
|
+
ready = "ready"
|
|
239
|
+
# No longer offered
|
|
240
|
+
deprecated = "deprecated"
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
class OveragePolicyEnum(StrEnum):
|
|
244
|
+
block = "block" # Block requests when quota exceeded
|
|
245
|
+
throttle = "throttle" # Reduce rate when quota exceeded
|
|
246
|
+
charge = "charge" # Allow with additional charges
|
|
247
|
+
queue = "queue" # Queue requests until quota resets
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
class PricingTypeEnum(StrEnum):
|
|
251
|
+
"""
|
|
252
|
+
Pricing type determines the structure and calculation method.
|
|
253
|
+
The type is stored as the 'type' field in the pricing object.
|
|
254
|
+
"""
|
|
255
|
+
|
|
256
|
+
# Basic pricing types
|
|
257
|
+
one_million_tokens = "one_million_tokens"
|
|
258
|
+
one_second = "one_second"
|
|
259
|
+
image = "image"
|
|
260
|
+
step = "step"
|
|
261
|
+
# Seller-only: seller receives a percentage of what customer pays
|
|
262
|
+
revenue_share = "revenue_share"
|
|
263
|
+
# Composite pricing types
|
|
264
|
+
constant = "constant" # Fixed amount (fee or discount)
|
|
265
|
+
add = "add" # Sum of multiple prices
|
|
266
|
+
multiply = "multiply" # Base price multiplied by factor
|
|
267
|
+
# Tiered pricing types
|
|
268
|
+
tiered = "tiered" # Volume-based tiers (all units at one tier's price)
|
|
269
|
+
graduated = "graduated" # Graduated tiers (each tier's units at that rate)
|
|
270
|
+
# Expression-based pricing (seller_price only)
|
|
271
|
+
expr = "expr" # Arbitrary expression using usage metrics
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
# ============================================================================
|
|
275
|
+
# Pricing Models - Discriminated Union for type-safe pricing validation
|
|
276
|
+
# ============================================================================
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
class BasePriceData(BaseModel):
|
|
280
|
+
"""Base class for all price data types.
|
|
281
|
+
|
|
282
|
+
All pricing types include:
|
|
283
|
+
- type: Discriminator field for the pricing type
|
|
284
|
+
- description: Optional human-readable description
|
|
285
|
+
- reference: Optional URL to upstream pricing page
|
|
286
|
+
"""
|
|
287
|
+
|
|
288
|
+
model_config = ConfigDict(extra="forbid")
|
|
289
|
+
|
|
290
|
+
description: str | None = Field(
|
|
291
|
+
default=None,
|
|
292
|
+
description="Human-readable description of the pricing model",
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
reference: str | None = Field(
|
|
296
|
+
default=None,
|
|
297
|
+
description="URL to upstream provider's pricing page",
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
class TokenPriceData(BasePriceData):
|
|
302
|
+
"""
|
|
303
|
+
Price data for token-based pricing (LLMs).
|
|
304
|
+
Supports either unified pricing or separate input/output pricing.
|
|
305
|
+
|
|
306
|
+
Price values use Decimal for precision. In JSON/TOML, specify as strings
|
|
307
|
+
(e.g., "0.50") to avoid floating-point precision issues.
|
|
308
|
+
"""
|
|
309
|
+
|
|
310
|
+
type: Literal["one_million_tokens"] = "one_million_tokens"
|
|
311
|
+
|
|
312
|
+
# Option 1: Unified price for all tokens
|
|
313
|
+
price: PriceStr | None = Field(
|
|
314
|
+
default=None,
|
|
315
|
+
description="Unified price per million tokens (used when input/output are the same)",
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
# Option 2: Separate input/output pricing
|
|
319
|
+
input: PriceStr | None = Field(
|
|
320
|
+
default=None,
|
|
321
|
+
description="Price per million input tokens",
|
|
322
|
+
)
|
|
323
|
+
output: PriceStr | None = Field(
|
|
324
|
+
default=None,
|
|
325
|
+
description="Price per million output tokens",
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
@model_validator(mode="after")
|
|
329
|
+
def validate_price_fields(self) -> TokenPriceData:
|
|
330
|
+
"""Ensure either unified price or input/output pair is provided."""
|
|
331
|
+
has_unified = self.price is not None
|
|
332
|
+
has_input_output = self.input is not None or self.output is not None
|
|
333
|
+
|
|
334
|
+
if has_unified and has_input_output:
|
|
335
|
+
raise ValueError(
|
|
336
|
+
"Cannot specify both 'price' and 'input'/'output'. "
|
|
337
|
+
"Use 'price' for unified pricing or 'input'/'output' for separate pricing."
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
if not has_unified and not has_input_output:
|
|
341
|
+
raise ValueError("Must specify either 'price' (unified) or 'input'/'output' (separate pricing).")
|
|
342
|
+
|
|
343
|
+
if has_input_output and (self.input is None or self.output is None):
|
|
344
|
+
raise ValueError("Both 'input' and 'output' must be specified for separate pricing.")
|
|
345
|
+
|
|
346
|
+
return self
|
|
347
|
+
|
|
348
|
+
def calculate_cost(
|
|
349
|
+
self,
|
|
350
|
+
usage: UsageData,
|
|
351
|
+
customer_charge: Decimal | None = None,
|
|
352
|
+
request_count: int | None = None,
|
|
353
|
+
) -> Decimal:
|
|
354
|
+
"""Calculate cost for token-based pricing.
|
|
355
|
+
|
|
356
|
+
Args:
|
|
357
|
+
usage: Usage data with token counts
|
|
358
|
+
customer_charge: Not used for token pricing (ignored)
|
|
359
|
+
request_count: Number of requests (ignored for token pricing)
|
|
360
|
+
|
|
361
|
+
Returns:
|
|
362
|
+
Calculated cost based on token usage
|
|
363
|
+
"""
|
|
364
|
+
input_tokens = usage.input_tokens or 0
|
|
365
|
+
output_tokens = usage.output_tokens or 0
|
|
366
|
+
|
|
367
|
+
if usage.total_tokens is not None and usage.input_tokens is None:
|
|
368
|
+
input_tokens = usage.total_tokens
|
|
369
|
+
output_tokens = 0
|
|
370
|
+
|
|
371
|
+
if self.input is not None and self.output is not None:
|
|
372
|
+
input_cost = Decimal(self.input) * input_tokens / 1_000_000
|
|
373
|
+
output_cost = Decimal(self.output) * output_tokens / 1_000_000
|
|
374
|
+
else:
|
|
375
|
+
price = Decimal(self.price) # type: ignore[arg-type]
|
|
376
|
+
input_cost = price * input_tokens / 1_000_000
|
|
377
|
+
output_cost = price * output_tokens / 1_000_000
|
|
378
|
+
|
|
379
|
+
return input_cost + output_cost
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
class TimePriceData(BasePriceData):
|
|
383
|
+
"""
|
|
384
|
+
Price data for time-based pricing (audio/video processing, compute time).
|
|
385
|
+
|
|
386
|
+
Price values use Decimal for precision. In JSON/TOML, specify as strings
|
|
387
|
+
(e.g., "0.006") to avoid floating-point precision issues.
|
|
388
|
+
"""
|
|
389
|
+
|
|
390
|
+
type: Literal["one_second"] = "one_second"
|
|
391
|
+
|
|
392
|
+
price: PriceStr = Field(
|
|
393
|
+
description="Price per second of usage",
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
def calculate_cost(
|
|
397
|
+
self,
|
|
398
|
+
usage: UsageData,
|
|
399
|
+
customer_charge: Decimal | None = None,
|
|
400
|
+
request_count: int | None = None,
|
|
401
|
+
) -> Decimal:
|
|
402
|
+
"""Calculate cost for time-based pricing.
|
|
403
|
+
|
|
404
|
+
Args:
|
|
405
|
+
usage: Usage data with seconds
|
|
406
|
+
customer_charge: Not used for time pricing (ignored)
|
|
407
|
+
request_count: Number of requests (ignored for time pricing)
|
|
408
|
+
|
|
409
|
+
Returns:
|
|
410
|
+
Calculated cost based on time usage
|
|
411
|
+
"""
|
|
412
|
+
if usage.seconds is None:
|
|
413
|
+
raise ValueError("Time-based pricing requires 'seconds' in usage data")
|
|
414
|
+
|
|
415
|
+
return Decimal(self.price) * Decimal(str(usage.seconds))
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
class ImagePriceData(BasePriceData):
|
|
419
|
+
"""
|
|
420
|
+
Price data for per-image pricing (image generation, processing).
|
|
421
|
+
|
|
422
|
+
Price values use Decimal for precision. In JSON/TOML, specify as strings
|
|
423
|
+
(e.g., "0.04") to avoid floating-point precision issues.
|
|
424
|
+
"""
|
|
425
|
+
|
|
426
|
+
type: Literal["image"] = "image"
|
|
427
|
+
|
|
428
|
+
price: PriceStr = Field(
|
|
429
|
+
description="Price per image",
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
def calculate_cost(
|
|
433
|
+
self,
|
|
434
|
+
usage: UsageData,
|
|
435
|
+
customer_charge: Decimal | None = None,
|
|
436
|
+
request_count: int | None = None,
|
|
437
|
+
) -> Decimal:
|
|
438
|
+
"""Calculate cost for image-based pricing.
|
|
439
|
+
|
|
440
|
+
Args:
|
|
441
|
+
usage: Usage data with count
|
|
442
|
+
customer_charge: Not used for image pricing (ignored)
|
|
443
|
+
request_count: Number of requests (ignored for image pricing)
|
|
444
|
+
|
|
445
|
+
Returns:
|
|
446
|
+
Calculated cost based on image count
|
|
447
|
+
"""
|
|
448
|
+
if usage.count is None:
|
|
449
|
+
raise ValueError("Image pricing requires 'count' in usage data")
|
|
450
|
+
|
|
451
|
+
return Decimal(self.price) * usage.count
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
class StepPriceData(BasePriceData):
|
|
455
|
+
"""
|
|
456
|
+
Price data for per-step pricing (diffusion steps, iterations).
|
|
457
|
+
|
|
458
|
+
Price values use Decimal for precision. In JSON/TOML, specify as strings
|
|
459
|
+
(e.g., "0.001") to avoid floating-point precision issues.
|
|
460
|
+
"""
|
|
461
|
+
|
|
462
|
+
type: Literal["step"] = "step"
|
|
463
|
+
|
|
464
|
+
price: PriceStr = Field(
|
|
465
|
+
description="Price per step/iteration",
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
def calculate_cost(
|
|
469
|
+
self,
|
|
470
|
+
usage: UsageData,
|
|
471
|
+
customer_charge: Decimal | None = None,
|
|
472
|
+
request_count: int | None = None,
|
|
473
|
+
) -> Decimal:
|
|
474
|
+
"""Calculate cost for step-based pricing.
|
|
475
|
+
|
|
476
|
+
Args:
|
|
477
|
+
usage: Usage data with count
|
|
478
|
+
customer_charge: Not used for step pricing (ignored)
|
|
479
|
+
request_count: Number of requests (ignored for step pricing)
|
|
480
|
+
|
|
481
|
+
Returns:
|
|
482
|
+
Calculated cost based on step count
|
|
483
|
+
"""
|
|
484
|
+
if usage.count is None:
|
|
485
|
+
raise ValueError("Step pricing requires 'count' in usage data")
|
|
486
|
+
|
|
487
|
+
return Decimal(self.price) * usage.count
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _validate_percentage_string(v: Any) -> str:
|
|
491
|
+
"""Validate that percentage values are strings representing valid decimals in range 0-100."""
|
|
492
|
+
# First use the standard price validation
|
|
493
|
+
v = _validate_price_string(v)
|
|
494
|
+
|
|
495
|
+
# Then check the 0-100 range
|
|
496
|
+
decimal_val = Decimal(v)
|
|
497
|
+
if decimal_val > 100:
|
|
498
|
+
raise ValueError(f"Percentage must be between 0 and 100, got '{v}'")
|
|
499
|
+
|
|
500
|
+
return v
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
# Percentage string type for revenue share (0-100 range)
|
|
504
|
+
PercentageStr = Annotated[str, BeforeValidator(_validate_percentage_string)]
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
class RevenueSharePriceData(BasePriceData):
|
|
508
|
+
"""
|
|
509
|
+
Price data for revenue share pricing (seller_price only).
|
|
510
|
+
|
|
511
|
+
This pricing type is used exclusively for seller_price when the seller
|
|
512
|
+
receives a percentage of what the customer pays. It cannot be used for
|
|
513
|
+
customer_price since the customer price must be a concrete amount.
|
|
514
|
+
|
|
515
|
+
The percentage represents the seller's share of the customer charge.
|
|
516
|
+
For example, if percentage is "70" and the customer pays $10, the seller
|
|
517
|
+
receives $7.
|
|
518
|
+
|
|
519
|
+
Percentage values must be strings (e.g., "70.00") to avoid floating-point
|
|
520
|
+
precision issues.
|
|
521
|
+
"""
|
|
522
|
+
|
|
523
|
+
type: Literal["revenue_share"] = "revenue_share"
|
|
524
|
+
|
|
525
|
+
percentage: PercentageStr = Field(
|
|
526
|
+
description="Percentage of customer charge that goes to the seller (0-100)",
|
|
527
|
+
)
|
|
528
|
+
|
|
529
|
+
def calculate_cost(
|
|
530
|
+
self,
|
|
531
|
+
usage: UsageData,
|
|
532
|
+
customer_charge: Decimal | None = None,
|
|
533
|
+
request_count: int | None = None,
|
|
534
|
+
) -> Decimal:
|
|
535
|
+
"""Calculate cost for revenue share pricing.
|
|
536
|
+
|
|
537
|
+
Args:
|
|
538
|
+
usage: Usage data (not used for revenue share, but kept for consistent API)
|
|
539
|
+
customer_charge: Total amount charged to customer (required)
|
|
540
|
+
request_count: Number of requests (ignored for revenue share)
|
|
541
|
+
|
|
542
|
+
Returns:
|
|
543
|
+
Seller's share of the customer charge
|
|
544
|
+
|
|
545
|
+
Raises:
|
|
546
|
+
ValueError: If customer_charge is not provided
|
|
547
|
+
"""
|
|
548
|
+
if customer_charge is None:
|
|
549
|
+
raise ValueError("Revenue share pricing requires 'customer_charge'")
|
|
550
|
+
|
|
551
|
+
return customer_charge * Decimal(self.percentage) / Decimal("100")
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
class ConstantPriceData(BasePriceData):
|
|
555
|
+
"""
|
|
556
|
+
Price data for a constant/fixed amount.
|
|
557
|
+
|
|
558
|
+
Used for fixed fees, discounts, or adjustments that don't depend on usage.
|
|
559
|
+
Amount can be positive (charge) or negative (discount/credit).
|
|
560
|
+
"""
|
|
561
|
+
|
|
562
|
+
type: Literal["constant"] = "constant"
|
|
563
|
+
|
|
564
|
+
amount: AmountStr = Field(
|
|
565
|
+
description="Fixed amount (positive for charge, negative for discount)",
|
|
566
|
+
)
|
|
567
|
+
|
|
568
|
+
def calculate_cost(
|
|
569
|
+
self,
|
|
570
|
+
usage: UsageData,
|
|
571
|
+
customer_charge: Decimal | None = None,
|
|
572
|
+
request_count: int | None = None,
|
|
573
|
+
) -> Decimal:
|
|
574
|
+
"""Return the constant amount regardless of usage.
|
|
575
|
+
|
|
576
|
+
Args:
|
|
577
|
+
usage: Usage data (ignored for constant pricing)
|
|
578
|
+
customer_charge: Customer charge (ignored for constant pricing)
|
|
579
|
+
request_count: Number of requests (ignored for constant pricing)
|
|
580
|
+
|
|
581
|
+
Returns:
|
|
582
|
+
The fixed amount
|
|
583
|
+
"""
|
|
584
|
+
return Decimal(self.amount)
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
# Forward reference for nested pricing - will be resolved after Pricing is defined
|
|
588
|
+
class AddPriceData(BasePriceData):
|
|
589
|
+
"""
|
|
590
|
+
Composite pricing that sums multiple price components.
|
|
591
|
+
|
|
592
|
+
Allows combining different pricing types, e.g., base token cost + fixed fee.
|
|
593
|
+
|
|
594
|
+
Example:
|
|
595
|
+
{
|
|
596
|
+
"type": "add",
|
|
597
|
+
"prices": [
|
|
598
|
+
{"type": "one_million_tokens", "input": "0.50", "output": "1.50"},
|
|
599
|
+
{"type": "constant", "amount": "-5.00", "description": "Platform fee"}
|
|
600
|
+
]
|
|
601
|
+
}
|
|
602
|
+
"""
|
|
603
|
+
|
|
604
|
+
type: Literal["add"] = "add"
|
|
605
|
+
|
|
606
|
+
prices: list[dict[str, Any]] = Field(
|
|
607
|
+
description="List of pricing components to sum together",
|
|
608
|
+
min_length=1,
|
|
609
|
+
)
|
|
610
|
+
|
|
611
|
+
def calculate_cost(
|
|
612
|
+
self,
|
|
613
|
+
usage: UsageData,
|
|
614
|
+
customer_charge: Decimal | None = None,
|
|
615
|
+
request_count: int | None = None,
|
|
616
|
+
) -> Decimal:
|
|
617
|
+
"""Calculate total cost by summing all price components.
|
|
618
|
+
|
|
619
|
+
Args:
|
|
620
|
+
usage: Usage data passed to each component
|
|
621
|
+
customer_charge: Customer charge passed to each component
|
|
622
|
+
request_count: Number of requests passed to each component
|
|
623
|
+
|
|
624
|
+
Returns:
|
|
625
|
+
Sum of all component costs
|
|
626
|
+
"""
|
|
627
|
+
total = Decimal("0")
|
|
628
|
+
for price_data in self.prices:
|
|
629
|
+
component = validate_pricing(price_data)
|
|
630
|
+
total += component.calculate_cost(usage, customer_charge, request_count)
|
|
631
|
+
return total
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
class MultiplyPriceData(BasePriceData):
|
|
635
|
+
"""
|
|
636
|
+
Composite pricing that multiplies a base price by a factor.
|
|
637
|
+
|
|
638
|
+
Useful for applying percentage-based adjustments to a base price.
|
|
639
|
+
|
|
640
|
+
Example:
|
|
641
|
+
{
|
|
642
|
+
"type": "multiply",
|
|
643
|
+
"factor": "0.70",
|
|
644
|
+
"base": {"type": "one_million_tokens", "input": "0.50", "output": "1.50"}
|
|
645
|
+
}
|
|
646
|
+
"""
|
|
647
|
+
|
|
648
|
+
type: Literal["multiply"] = "multiply"
|
|
649
|
+
|
|
650
|
+
factor: PriceStr = Field(
|
|
651
|
+
description="Multiplication factor (e.g., '0.70' for 70%)",
|
|
652
|
+
)
|
|
653
|
+
|
|
654
|
+
base: dict[str, Any] = Field(
|
|
655
|
+
description="Base pricing to multiply",
|
|
656
|
+
)
|
|
657
|
+
|
|
658
|
+
def calculate_cost(
|
|
659
|
+
self,
|
|
660
|
+
usage: UsageData,
|
|
661
|
+
customer_charge: Decimal | None = None,
|
|
662
|
+
request_count: int | None = None,
|
|
663
|
+
) -> Decimal:
|
|
664
|
+
"""Calculate cost by multiplying base price by factor.
|
|
665
|
+
|
|
666
|
+
Args:
|
|
667
|
+
usage: Usage data passed to base component
|
|
668
|
+
customer_charge: Customer charge passed to base component
|
|
669
|
+
request_count: Number of requests passed to base component
|
|
670
|
+
|
|
671
|
+
Returns:
|
|
672
|
+
Base cost multiplied by factor
|
|
673
|
+
"""
|
|
674
|
+
base_pricing = validate_pricing(self.base)
|
|
675
|
+
base_cost = base_pricing.calculate_cost(usage, customer_charge, request_count)
|
|
676
|
+
return base_cost * Decimal(self.factor)
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def _get_metric_value(
|
|
680
|
+
based_on: str,
|
|
681
|
+
usage: UsageData,
|
|
682
|
+
customer_charge: Decimal | None,
|
|
683
|
+
request_count: int | None,
|
|
684
|
+
) -> Decimal:
|
|
685
|
+
"""Get the value of a metric by name.
|
|
686
|
+
|
|
687
|
+
Args:
|
|
688
|
+
based_on: Name of the metric (e.g., 'request_count', 'customer_charge', or any UsageData field)
|
|
689
|
+
usage: Usage data object
|
|
690
|
+
customer_charge: Customer charge value
|
|
691
|
+
request_count: Request count value
|
|
692
|
+
|
|
693
|
+
Returns:
|
|
694
|
+
The metric value as Decimal
|
|
695
|
+
"""
|
|
696
|
+
# Check special parameters first
|
|
697
|
+
if based_on == "request_count":
|
|
698
|
+
return Decimal(request_count or 0)
|
|
699
|
+
elif based_on == "customer_charge":
|
|
700
|
+
return customer_charge or Decimal("0")
|
|
701
|
+
|
|
702
|
+
# Try to get from UsageData fields
|
|
703
|
+
if hasattr(usage, based_on):
|
|
704
|
+
value = getattr(usage, based_on)
|
|
705
|
+
if value is not None:
|
|
706
|
+
return Decimal(str(value))
|
|
707
|
+
|
|
708
|
+
# Build context with all available metrics
|
|
709
|
+
context: dict[str, Decimal] = {
|
|
710
|
+
"request_count": Decimal(request_count or 0),
|
|
711
|
+
"customer_charge": customer_charge or Decimal("0"),
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
# Add all UsageData fields
|
|
715
|
+
for field_name in UsageData.model_fields:
|
|
716
|
+
value = getattr(usage, field_name)
|
|
717
|
+
context[field_name] = Decimal(str(value)) if value is not None else Decimal("0")
|
|
718
|
+
|
|
719
|
+
try:
|
|
720
|
+
tree = ast.parse(based_on, mode="eval")
|
|
721
|
+
except SyntaxError as e:
|
|
722
|
+
raise ValueError(f"Invalid expression syntax: {based_on}") from e
|
|
723
|
+
|
|
724
|
+
binary_ops: dict[type[ast.operator], Any] = {
|
|
725
|
+
ast.Add: operator.add,
|
|
726
|
+
ast.Sub: operator.sub,
|
|
727
|
+
ast.Mult: operator.mul,
|
|
728
|
+
ast.Div: operator.truediv,
|
|
729
|
+
}
|
|
730
|
+
unary_ops: dict[type[ast.unaryop], Any] = {
|
|
731
|
+
ast.USub: operator.neg,
|
|
732
|
+
ast.UAdd: operator.pos,
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
def safe_eval(node: ast.expr) -> Decimal:
|
|
736
|
+
if isinstance(node, ast.Constant):
|
|
737
|
+
if isinstance(node.value, int | float):
|
|
738
|
+
return Decimal(str(node.value))
|
|
739
|
+
raise ValueError(f"Unsupported constant type: {type(node.value)}")
|
|
740
|
+
elif isinstance(node, ast.Name):
|
|
741
|
+
if node.id not in context:
|
|
742
|
+
raise ValueError(f"Unknown metric: {node.id}")
|
|
743
|
+
return context[node.id]
|
|
744
|
+
elif isinstance(node, ast.BinOp):
|
|
745
|
+
bin_op_type = type(node.op)
|
|
746
|
+
if bin_op_type not in binary_ops:
|
|
747
|
+
raise ValueError(f"Unsupported operator: {bin_op_type.__name__}")
|
|
748
|
+
return binary_ops[bin_op_type](safe_eval(node.left), safe_eval(node.right))
|
|
749
|
+
elif isinstance(node, ast.UnaryOp):
|
|
750
|
+
unary_op_type = type(node.op)
|
|
751
|
+
if unary_op_type not in unary_ops:
|
|
752
|
+
raise ValueError(f"Unsupported unary operator: {unary_op_type.__name__}")
|
|
753
|
+
return unary_ops[unary_op_type](safe_eval(node.operand))
|
|
754
|
+
else:
|
|
755
|
+
raise ValueError(f"Unsupported expression type: {type(node).__name__}")
|
|
756
|
+
|
|
757
|
+
return safe_eval(tree.body)
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
class ExprPriceData(BasePriceData):
|
|
761
|
+
"""
|
|
762
|
+
Expression-based pricing that evaluates an arithmetic expression using usage metrics.
|
|
763
|
+
|
|
764
|
+
**IMPORTANT: This pricing type should only be used for `seller_price`.**
|
|
765
|
+
It is NOT suitable for `customer_price` because:
|
|
766
|
+
1. Customer pricing should be predictable and transparent
|
|
767
|
+
2. Expression-based pricing can lead to confusing or unexpected charges
|
|
768
|
+
3. Customers should be able to easily calculate their costs before using a service
|
|
769
|
+
|
|
770
|
+
For seller pricing, expressions are useful when the cost from an upstream provider
|
|
771
|
+
involves complex calculations that can't be expressed with basic pricing types.
|
|
772
|
+
|
|
773
|
+
The expression can use any available metrics and arithmetic operators (+, -, *, /).
|
|
774
|
+
|
|
775
|
+
Available metrics:
|
|
776
|
+
- input_tokens, output_tokens, total_tokens (token counts)
|
|
777
|
+
- seconds (time-based usage)
|
|
778
|
+
- count (images, steps, etc.)
|
|
779
|
+
- request_count (number of API requests)
|
|
780
|
+
- customer_charge (what the customer paid, for revenue share calculations)
|
|
781
|
+
|
|
782
|
+
Example:
|
|
783
|
+
{
|
|
784
|
+
"type": "expr",
|
|
785
|
+
"expr": "input_tokens / 1000000 * 0.50 + output_tokens / 1000000 * 1.50"
|
|
786
|
+
}
|
|
787
|
+
"""
|
|
788
|
+
|
|
789
|
+
type: Literal["expr"] = "expr"
|
|
790
|
+
|
|
791
|
+
expr: str = Field(
|
|
792
|
+
description="Arithmetic expression using usage metrics (e.g., 'input_tokens / 1000000 * 2.5')",
|
|
793
|
+
)
|
|
794
|
+
|
|
795
|
+
def calculate_cost(
|
|
796
|
+
self,
|
|
797
|
+
usage: UsageData,
|
|
798
|
+
customer_charge: Decimal | None = None,
|
|
799
|
+
request_count: int | None = None,
|
|
800
|
+
) -> Decimal:
|
|
801
|
+
"""Calculate cost by evaluating the expression with usage data.
|
|
802
|
+
|
|
803
|
+
Args:
|
|
804
|
+
usage: Usage data providing metric values
|
|
805
|
+
customer_charge: Customer charge value (available as 'customer_charge' in expression)
|
|
806
|
+
request_count: Number of requests (available as 'request_count' in expression)
|
|
807
|
+
|
|
808
|
+
Returns:
|
|
809
|
+
The result of evaluating the expression
|
|
810
|
+
"""
|
|
811
|
+
return _get_metric_value(self.expr, usage, customer_charge, request_count)
|
|
812
|
+
|
|
813
|
+
|
|
814
|
+
class PriceTier(BaseModel):
|
|
815
|
+
"""A single tier in tiered pricing."""
|
|
816
|
+
|
|
817
|
+
model_config = ConfigDict(extra="forbid")
|
|
818
|
+
|
|
819
|
+
up_to: int | None = Field(
|
|
820
|
+
description="Upper limit for this tier (None for unlimited)",
|
|
821
|
+
)
|
|
822
|
+
price: dict[str, Any] = Field(
|
|
823
|
+
description="Price configuration for this tier",
|
|
824
|
+
)
|
|
825
|
+
|
|
826
|
+
|
|
827
|
+
class TieredPriceData(BasePriceData):
|
|
828
|
+
"""
|
|
829
|
+
Volume-based tiered pricing where the tier determines price for ALL units.
|
|
830
|
+
|
|
831
|
+
The tier is determined by the `based_on` metric, and ALL units are priced
|
|
832
|
+
at that tier's rate. `based_on` can be 'request_count', 'customer_charge',
|
|
833
|
+
or any field from UsageData (e.g., 'input_tokens', 'seconds', 'count').
|
|
834
|
+
|
|
835
|
+
Example (volume pricing - all units at same rate):
|
|
836
|
+
{
|
|
837
|
+
"type": "tiered",
|
|
838
|
+
"based_on": "request_count",
|
|
839
|
+
"tiers": [
|
|
840
|
+
{"up_to": 1000, "price": {"type": "constant", "amount": "10.00"}},
|
|
841
|
+
{"up_to": 10000, "price": {"type": "constant", "amount": "80.00"}},
|
|
842
|
+
{"up_to": null, "price": {"type": "constant", "amount": "500.00"}}
|
|
843
|
+
]
|
|
844
|
+
}
|
|
845
|
+
If request_count is 5000, the price is $80.00 (falls in 1001-10000 tier).
|
|
846
|
+
"""
|
|
847
|
+
|
|
848
|
+
type: Literal["tiered"] = "tiered"
|
|
849
|
+
|
|
850
|
+
based_on: str = Field(
|
|
851
|
+
description="Metric for tier selection: 'request_count', 'customer_charge', or UsageData field",
|
|
852
|
+
)
|
|
853
|
+
|
|
854
|
+
tiers: list[PriceTier] = Field(
|
|
855
|
+
description="List of tiers, ordered by up_to (ascending). Last tier should have up_to=null.",
|
|
856
|
+
min_length=1,
|
|
857
|
+
)
|
|
858
|
+
|
|
859
|
+
def calculate_cost(
|
|
860
|
+
self,
|
|
861
|
+
usage: UsageData,
|
|
862
|
+
customer_charge: Decimal | None = None,
|
|
863
|
+
request_count: int | None = None,
|
|
864
|
+
) -> Decimal:
|
|
865
|
+
"""Calculate cost based on which tier the usage falls into.
|
|
866
|
+
|
|
867
|
+
Args:
|
|
868
|
+
usage: Usage data
|
|
869
|
+
customer_charge: Customer charge (used if based_on="customer_charge")
|
|
870
|
+
request_count: Number of requests (used if based_on="request_count")
|
|
871
|
+
|
|
872
|
+
Returns:
|
|
873
|
+
Cost from the matching tier's price
|
|
874
|
+
"""
|
|
875
|
+
metric_value = _get_metric_value(self.based_on, usage, customer_charge, request_count)
|
|
876
|
+
|
|
877
|
+
# Find the matching tier
|
|
878
|
+
for tier in self.tiers:
|
|
879
|
+
if tier.up_to is None or metric_value <= tier.up_to:
|
|
880
|
+
tier_pricing = validate_pricing(tier.price)
|
|
881
|
+
return tier_pricing.calculate_cost(usage, customer_charge, request_count)
|
|
882
|
+
|
|
883
|
+
# Should not reach here if tiers are properly configured
|
|
884
|
+
raise ValueError("No matching tier found")
|
|
885
|
+
|
|
886
|
+
|
|
887
|
+
class GraduatedTier(BaseModel):
|
|
888
|
+
"""A single tier in graduated pricing with per-unit price."""
|
|
889
|
+
|
|
890
|
+
model_config = ConfigDict(extra="forbid")
|
|
891
|
+
|
|
892
|
+
up_to: int | None = Field(
|
|
893
|
+
description="Upper limit for this tier (None for unlimited)",
|
|
894
|
+
)
|
|
895
|
+
unit_price: PriceStr = Field(
|
|
896
|
+
description="Price per unit in this tier",
|
|
897
|
+
)
|
|
898
|
+
|
|
899
|
+
|
|
900
|
+
class GraduatedPriceData(BasePriceData):
|
|
901
|
+
"""
|
|
902
|
+
Graduated tiered pricing where each tier's units are priced at that tier's rate.
|
|
903
|
+
|
|
904
|
+
Like AWS pricing - first N units at price A, next M units at price B, etc.
|
|
905
|
+
`based_on` can be 'request_count', 'customer_charge', or any UsageData field.
|
|
906
|
+
|
|
907
|
+
Example (graduated pricing - different rates per tier):
|
|
908
|
+
{
|
|
909
|
+
"type": "graduated",
|
|
910
|
+
"based_on": "request_count",
|
|
911
|
+
"tiers": [
|
|
912
|
+
{"up_to": 1000, "unit_price": "0.01"},
|
|
913
|
+
{"up_to": 10000, "unit_price": "0.008"},
|
|
914
|
+
{"up_to": null, "unit_price": "0.005"}
|
|
915
|
+
]
|
|
916
|
+
}
|
|
917
|
+
If request_count is 5000:
|
|
918
|
+
- First 1000 at $0.01 = $10.00
|
|
919
|
+
- Next 4000 at $0.008 = $32.00
|
|
920
|
+
- Total = $42.00
|
|
921
|
+
"""
|
|
922
|
+
|
|
923
|
+
type: Literal["graduated"] = "graduated"
|
|
924
|
+
|
|
925
|
+
based_on: str = Field(
|
|
926
|
+
description="Metric for graduated calc: 'request_count', 'customer_charge', or UsageData field",
|
|
927
|
+
)
|
|
928
|
+
|
|
929
|
+
tiers: list[GraduatedTier] = Field(
|
|
930
|
+
description="List of tiers, ordered by up_to (ascending). Last tier should have up_to=null.",
|
|
931
|
+
min_length=1,
|
|
932
|
+
)
|
|
933
|
+
|
|
934
|
+
def calculate_cost(
|
|
935
|
+
self,
|
|
936
|
+
usage: UsageData,
|
|
937
|
+
customer_charge: Decimal | None = None,
|
|
938
|
+
request_count: int | None = None,
|
|
939
|
+
) -> Decimal:
|
|
940
|
+
"""Calculate cost with graduated pricing across tiers.
|
|
941
|
+
|
|
942
|
+
Args:
|
|
943
|
+
usage: Usage data
|
|
944
|
+
customer_charge: Customer charge (used if based_on="customer_charge")
|
|
945
|
+
request_count: Number of requests (used if based_on="request_count")
|
|
946
|
+
|
|
947
|
+
Returns:
|
|
948
|
+
Total cost summed across all applicable tiers
|
|
949
|
+
"""
|
|
950
|
+
metric_value = _get_metric_value(self.based_on, usage, customer_charge, request_count)
|
|
951
|
+
total_cost = Decimal("0")
|
|
952
|
+
remaining = metric_value
|
|
953
|
+
previous_limit = Decimal("0")
|
|
954
|
+
|
|
955
|
+
for tier in self.tiers:
|
|
956
|
+
if remaining <= 0:
|
|
957
|
+
break
|
|
958
|
+
|
|
959
|
+
# Calculate units in this tier
|
|
960
|
+
if tier.up_to is None:
|
|
961
|
+
units_in_tier = remaining
|
|
962
|
+
else:
|
|
963
|
+
tier_size = Decimal(tier.up_to) - previous_limit
|
|
964
|
+
units_in_tier = min(remaining, tier_size)
|
|
965
|
+
|
|
966
|
+
# Add cost for this tier
|
|
967
|
+
total_cost += units_in_tier * Decimal(tier.unit_price)
|
|
968
|
+
remaining -= units_in_tier
|
|
969
|
+
previous_limit = Decimal(tier.up_to) if tier.up_to else previous_limit
|
|
970
|
+
|
|
971
|
+
return total_cost
|
|
972
|
+
|
|
973
|
+
|
|
974
|
+
# Discriminated union of all pricing types
|
|
975
|
+
# This is the type used for seller_price and customer_price fields
|
|
976
|
+
# Note: ExprPriceData should only be used for seller_price
|
|
977
|
+
Pricing = Annotated[
|
|
978
|
+
TokenPriceData
|
|
979
|
+
| TimePriceData
|
|
980
|
+
| ImagePriceData
|
|
981
|
+
| StepPriceData
|
|
982
|
+
| RevenueSharePriceData
|
|
983
|
+
| ConstantPriceData
|
|
984
|
+
| AddPriceData
|
|
985
|
+
| MultiplyPriceData
|
|
986
|
+
| TieredPriceData
|
|
987
|
+
| GraduatedPriceData
|
|
988
|
+
| ExprPriceData,
|
|
989
|
+
Field(discriminator="type"),
|
|
990
|
+
]
|
|
991
|
+
|
|
992
|
+
|
|
993
|
+
def validate_pricing(
|
|
994
|
+
data: dict[str, Any],
|
|
995
|
+
) -> (
|
|
996
|
+
TokenPriceData
|
|
997
|
+
| TimePriceData
|
|
998
|
+
| ImagePriceData
|
|
999
|
+
| StepPriceData
|
|
1000
|
+
| RevenueSharePriceData
|
|
1001
|
+
| ConstantPriceData
|
|
1002
|
+
| AddPriceData
|
|
1003
|
+
| MultiplyPriceData
|
|
1004
|
+
| TieredPriceData
|
|
1005
|
+
| GraduatedPriceData
|
|
1006
|
+
| ExprPriceData
|
|
1007
|
+
):
|
|
1008
|
+
"""
|
|
1009
|
+
Validate pricing dict and return the appropriate typed model.
|
|
1010
|
+
|
|
1011
|
+
Args:
|
|
1012
|
+
data: Dictionary containing pricing data with 'type' field
|
|
1013
|
+
|
|
1014
|
+
Returns:
|
|
1015
|
+
Validated Pricing model instance
|
|
1016
|
+
|
|
1017
|
+
Raises:
|
|
1018
|
+
ValueError: If validation fails
|
|
1019
|
+
|
|
1020
|
+
Example:
|
|
1021
|
+
>>> data = {"type": "one_million_tokens", "input": "0.5", "output": "1.5"}
|
|
1022
|
+
>>> validated = validate_pricing(data)
|
|
1023
|
+
>>> print(validated.input) # "0.5"
|
|
1024
|
+
"""
|
|
1025
|
+
from pydantic import TypeAdapter
|
|
1026
|
+
|
|
1027
|
+
adapter: TypeAdapter[TokenPriceData | TimePriceData | ImagePriceData | StepPriceData | RevenueSharePriceData] = (
|
|
1028
|
+
TypeAdapter(Pricing)
|
|
1029
|
+
)
|
|
1030
|
+
return adapter.validate_python(data)
|
|
1031
|
+
|
|
1032
|
+
|
|
1033
|
+
class QuotaResetCycleEnum(StrEnum):
|
|
1034
|
+
daily = "daily"
|
|
1035
|
+
weekly = "weekly"
|
|
1036
|
+
monthly = "monthly"
|
|
1037
|
+
yearly = "yearly"
|
|
1038
|
+
|
|
1039
|
+
|
|
1040
|
+
class RateLimitUnitEnum(StrEnum):
|
|
1041
|
+
requests = "requests"
|
|
1042
|
+
tokens = "tokens"
|
|
1043
|
+
input_tokens = "input_tokens"
|
|
1044
|
+
output_tokens = "output_tokens"
|
|
1045
|
+
bytes = "bytes"
|
|
1046
|
+
concurrent = "concurrent"
|
|
1047
|
+
|
|
1048
|
+
|
|
1049
|
+
class RequestTransformEnum(StrEnum):
|
|
1050
|
+
# https://docs.api7.ai/hub/proxy-rewrite
|
|
1051
|
+
proxy_rewrite = "proxy_rewrite"
|
|
1052
|
+
# https://docs.api7.ai/hub/body-transformer
|
|
1053
|
+
body_transformer = "body_transformer"
|
|
1054
|
+
|
|
1055
|
+
|
|
1056
|
+
class ServiceTypeEnum(StrEnum):
|
|
1057
|
+
llm = "llm"
|
|
1058
|
+
# generate embedding from texts
|
|
1059
|
+
embedding = "embedding"
|
|
1060
|
+
# generation of images from prompts
|
|
1061
|
+
image_generation = "image_generation"
|
|
1062
|
+
# streaming trancription needs websocket connection forwarding, and cannot
|
|
1063
|
+
# be provided for now.
|
|
1064
|
+
streaming_transcription = "streaming_transcription"
|
|
1065
|
+
# prerecorded transcription
|
|
1066
|
+
prerecorded_transcription = "prerecorded_transcription"
|
|
1067
|
+
# prerecorded translation
|
|
1068
|
+
prerecorded_translation = "prerecorded_translation"
|
|
1069
|
+
# describe images
|
|
1070
|
+
vision_language_model = "vision_language_model"
|
|
1071
|
+
#
|
|
1072
|
+
speech_to_text = "speech_to_text"
|
|
1073
|
+
#
|
|
1074
|
+
text_to_speech = "text_to_speech"
|
|
1075
|
+
#
|
|
1076
|
+
video_generation = "video_generation"
|
|
1077
|
+
#
|
|
1078
|
+
text_to_image = "text_to_image"
|
|
1079
|
+
#
|
|
1080
|
+
undetermined = "undetermined"
|
|
1081
|
+
#
|
|
1082
|
+
text_to_3d = "text_to_3d"
|
|
1083
|
+
|
|
1084
|
+
|
|
1085
|
+
class TagEnum(StrEnum):
|
|
1086
|
+
"""
|
|
1087
|
+
Allowed enums, currently not enforced.
|
|
1088
|
+
"""
|
|
1089
|
+
|
|
1090
|
+
# Service requires users to provide their own API key for access.
|
|
1091
|
+
byop = "byop"
|
|
1092
|
+
|
|
1093
|
+
|
|
1094
|
+
class TimeWindowEnum(StrEnum):
|
|
1095
|
+
second = "second"
|
|
1096
|
+
minute = "minute"
|
|
1097
|
+
hour = "hour"
|
|
1098
|
+
day = "day"
|
|
1099
|
+
month = "month"
|
|
1100
|
+
|
|
1101
|
+
|
|
1102
|
+
class UpstreamStatusEnum(StrEnum):
|
|
1103
|
+
# uploading (not ready)
|
|
1104
|
+
uploading = "uploading"
|
|
1105
|
+
# upstream is ready to be used
|
|
1106
|
+
ready = "ready"
|
|
1107
|
+
# service is deprecated from upstream
|
|
1108
|
+
deprecated = "deprecated"
|
|
1109
|
+
|
|
1110
|
+
|
|
1111
|
+
class ProviderStatusEnum(StrEnum):
|
|
1112
|
+
"""Provider status enum."""
|
|
1113
|
+
|
|
1114
|
+
active = "active"
|
|
1115
|
+
pending = "pending"
|
|
1116
|
+
disabled = "disabled"
|
|
1117
|
+
draft = "draft" # Provider information is incomplete, skip during publish
|
|
1118
|
+
|
|
1119
|
+
|
|
1120
|
+
class SellerStatusEnum(StrEnum):
|
|
1121
|
+
"""Seller status enum."""
|
|
1122
|
+
|
|
1123
|
+
active = "active"
|
|
1124
|
+
pending = "pending"
|
|
1125
|
+
disabled = "disabled"
|
|
1126
|
+
draft = "draft" # Seller information is incomplete, skip during publish
|
|
1127
|
+
|
|
1128
|
+
|
|
1129
|
+
class Document(BaseModel):
|
|
1130
|
+
model_config = ConfigDict(extra="forbid")
|
|
1131
|
+
|
|
1132
|
+
# fields that will be stored in backend database
|
|
1133
|
+
#
|
|
1134
|
+
title: str = Field(min_length=5, max_length=255, description="Document title")
|
|
1135
|
+
description: str | None = Field(default=None, max_length=500, description="Document description")
|
|
1136
|
+
mime_type: MimeTypeEnum = Field(description="Document MIME type")
|
|
1137
|
+
version: str | None = Field(default=None, max_length=50, description="Document version")
|
|
1138
|
+
category: DocumentCategoryEnum = Field(description="Document category for organization and filtering")
|
|
1139
|
+
meta: dict[str, Any] | None = Field(
|
|
1140
|
+
default=None,
|
|
1141
|
+
description="JSON containing operation stats",
|
|
1142
|
+
)
|
|
1143
|
+
file_path: str | None = Field(
|
|
1144
|
+
default=None,
|
|
1145
|
+
max_length=1000,
|
|
1146
|
+
description="Path to file to upload (mutually exclusive with external_url)",
|
|
1147
|
+
)
|
|
1148
|
+
external_url: str | None = Field(
|
|
1149
|
+
default=None,
|
|
1150
|
+
max_length=1000,
|
|
1151
|
+
description="External URL for the document (mutually exclusive with object_key)",
|
|
1152
|
+
)
|
|
1153
|
+
sort_order: int = Field(default=0, description="Sort order within category")
|
|
1154
|
+
is_active: bool = Field(default=True, description="Whether document is active")
|
|
1155
|
+
is_public: bool = Field(
|
|
1156
|
+
default=False,
|
|
1157
|
+
description="Whether document is publicly accessible without authentication",
|
|
1158
|
+
)
|
|
1159
|
+
|
|
1160
|
+
|
|
1161
|
+
class RateLimit(BaseModel):
|
|
1162
|
+
"""Store rate limiting rules for services."""
|
|
1163
|
+
|
|
1164
|
+
model_config = ConfigDict(extra="forbid")
|
|
1165
|
+
|
|
1166
|
+
# Core rate limit definition
|
|
1167
|
+
limit: int = Field(description="Maximum allowed in the time window")
|
|
1168
|
+
unit: RateLimitUnitEnum = Field(description="What is being limited")
|
|
1169
|
+
window: TimeWindowEnum = Field(description="Time window for the limit")
|
|
1170
|
+
|
|
1171
|
+
# Optional additional info
|
|
1172
|
+
description: str | None = Field(default=None, max_length=255, description="Human-readable description")
|
|
1173
|
+
burst_limit: int | None = Field(default=None, description="Short-term burst allowance")
|
|
1174
|
+
|
|
1175
|
+
# Status
|
|
1176
|
+
is_active: bool = Field(default=True, description="Whether rate limit is active")
|
|
1177
|
+
|
|
1178
|
+
|
|
1179
|
+
class ServiceConstraints(BaseModel):
|
|
1180
|
+
model_config = ConfigDict(extra="forbid")
|
|
1181
|
+
|
|
1182
|
+
# Usage Quotas & Billing
|
|
1183
|
+
monthly_quota: int | None = Field(default=None, description="Monthly usage quota (requests, tokens, etc.)")
|
|
1184
|
+
daily_quota: int | None = Field(default=None, description="Daily usage quota (requests, tokens, etc.)")
|
|
1185
|
+
quota_unit: RateLimitUnitEnum | None = Field(default=None, description="Unit for quota limits")
|
|
1186
|
+
quota_reset_cycle: QuotaResetCycleEnum | None = Field(default=None, description="How often quotas reset")
|
|
1187
|
+
overage_policy: OveragePolicyEnum | None = Field(default=None, description="What happens when quota is exceeded")
|
|
1188
|
+
|
|
1189
|
+
# Authentication & Security
|
|
1190
|
+
auth_methods: list[AuthMethodEnum] | None = Field(default=None, description="Supported authentication methods")
|
|
1191
|
+
ip_whitelist_required: bool | None = Field(default=None, description="Whether IP whitelisting is required")
|
|
1192
|
+
tls_version_min: str | None = Field(default=None, description="Minimum TLS version required")
|
|
1193
|
+
|
|
1194
|
+
# Request/Response Constraints
|
|
1195
|
+
max_request_size_bytes: int | None = Field(default=None, description="Maximum request payload size in bytes")
|
|
1196
|
+
max_response_size_bytes: int | None = Field(default=None, description="Maximum response payload size in bytes")
|
|
1197
|
+
timeout_seconds: int | None = Field(default=None, description="Request timeout in seconds")
|
|
1198
|
+
max_batch_size: int | None = Field(default=None, description="Maximum number of items in batch requests")
|
|
1199
|
+
|
|
1200
|
+
# Content & Model Restrictions
|
|
1201
|
+
content_filters: list[ContentFilterEnum] | None = Field(
|
|
1202
|
+
default=None, description="Active content filtering policies"
|
|
1203
|
+
)
|
|
1204
|
+
input_languages: list[str] | None = Field(default=None, description="Supported input languages (ISO 639-1 codes)")
|
|
1205
|
+
output_languages: list[str] | None = Field(default=None, description="Supported output languages (ISO 639-1 codes)")
|
|
1206
|
+
max_context_length: int | None = Field(default=None, description="Maximum context length in tokens")
|
|
1207
|
+
region_restrictions: list[str] | None = Field(
|
|
1208
|
+
default=None, description="Geographic restrictions (ISO country codes)"
|
|
1209
|
+
)
|
|
1210
|
+
|
|
1211
|
+
# Availability & SLA
|
|
1212
|
+
uptime_sla_percent: float | None = Field(default=None, description="Uptime SLA percentage (e.g., 99.9)")
|
|
1213
|
+
response_time_sla_ms: int | None = Field(default=None, description="Response time SLA in milliseconds")
|
|
1214
|
+
maintenance_windows: list[str] | None = Field(default=None, description="Scheduled maintenance windows")
|
|
1215
|
+
|
|
1216
|
+
# Concurrency & Connection Limits
|
|
1217
|
+
max_concurrent_requests: int | None = Field(default=None, description="Maximum concurrent requests allowed")
|
|
1218
|
+
connection_timeout_seconds: int | None = Field(default=None, description="Connection timeout in seconds")
|
|
1219
|
+
max_connections_per_ip: int | None = Field(default=None, description="Maximum connections per IP address")
|
|
1220
|
+
|
|
1221
|
+
|
|
1222
|
+
class AccessInterface(BaseModel):
|
|
1223
|
+
model_config = ConfigDict(extra="allow")
|
|
1224
|
+
|
|
1225
|
+
access_method: AccessMethodEnum = Field(default=AccessMethodEnum.http, description="Type of access method")
|
|
1226
|
+
|
|
1227
|
+
base_url: str = Field(max_length=500, description="Base URL for api access")
|
|
1228
|
+
|
|
1229
|
+
api_key: str | None = Field(default=None, max_length=2000, description="API key if required")
|
|
1230
|
+
|
|
1231
|
+
name: str | None = Field(default=None, max_length=100, description="Interface name")
|
|
1232
|
+
|
|
1233
|
+
description: str | None = Field(default=None, max_length=500, description="Interface description")
|
|
1234
|
+
|
|
1235
|
+
request_transformer: dict[RequestTransformEnum, dict[str, Any]] | None = Field(
|
|
1236
|
+
default=None, description="Request transformation configuration"
|
|
1237
|
+
)
|
|
1238
|
+
|
|
1239
|
+
routing_key: dict[str, Any] | None = Field(
|
|
1240
|
+
default=None,
|
|
1241
|
+
description="Request routing key for matching (e.g., {'model': 'gpt-4'})",
|
|
1242
|
+
)
|
|
1243
|
+
|
|
1244
|
+
documents: list[Document] | None = Field(
|
|
1245
|
+
default=None, description="List of documents associated with the interface"
|
|
1246
|
+
)
|
|
1247
|
+
|
|
1248
|
+
rate_limits: list[RateLimit] | None = Field(
|
|
1249
|
+
default=None,
|
|
1250
|
+
description="Rate limit",
|
|
1251
|
+
)
|
|
1252
|
+
constraint: ServiceConstraints | None = Field(default=None, description="Service constraints and conditions")
|
|
1253
|
+
is_active: bool = Field(default=True, description="Whether interface is active")
|
|
1254
|
+
is_primary: bool = Field(default=False, description="Whether this is the primary interface")
|
|
1255
|
+
sort_order: int = Field(default=0, description="Display order")
|
|
1256
|
+
|
|
1257
|
+
|
|
1258
|
+
def validate_name(name: str, entity_type: str, display_name: str | None = None, *, allow_slash: bool = False) -> str:
|
|
1259
|
+
"""
|
|
1260
|
+
Validate that a name field uses valid identifiers.
|
|
1261
|
+
|
|
1262
|
+
Name format rules:
|
|
1263
|
+
- Only letters (upper/lowercase), numbers, dots, dashes, and underscores allowed
|
|
1264
|
+
- If allow_slash=True, slashes are also allowed for hierarchical names
|
|
1265
|
+
- Must start and end with alphanumeric characters (not special characters)
|
|
1266
|
+
- Cannot have consecutive slashes (when allow_slash=True)
|
|
1267
|
+
- Cannot be empty
|
|
1268
|
+
|
|
1269
|
+
Args:
|
|
1270
|
+
name: The name value to validate
|
|
1271
|
+
entity_type: Type of entity (provider, seller, service, listing) for error messages
|
|
1272
|
+
display_name: Optional display name to suggest a valid name from
|
|
1273
|
+
allow_slash: Whether to allow slashes for hierarchical names (default: False)
|
|
1274
|
+
|
|
1275
|
+
Returns:
|
|
1276
|
+
The validated name (unchanged if valid)
|
|
1277
|
+
|
|
1278
|
+
Raises:
|
|
1279
|
+
ValueError: If the name doesn't match the required pattern
|
|
1280
|
+
|
|
1281
|
+
Examples:
|
|
1282
|
+
Without slashes (providers, sellers):
|
|
1283
|
+
- name='amazon-bedrock' or name='Amazon-Bedrock'
|
|
1284
|
+
- name='fireworks.ai' or name='Fireworks.ai'
|
|
1285
|
+
- name='llama-3.1' or name='Llama-3.1'
|
|
1286
|
+
|
|
1287
|
+
With slashes (services, listings):
|
|
1288
|
+
- name='gpt-4' or name='GPT-4'
|
|
1289
|
+
- name='models/gpt-4' or name='models/GPT-4'
|
|
1290
|
+
- name='black-forest-labs/FLUX.1-dev'
|
|
1291
|
+
- name='api/v1/completion'
|
|
1292
|
+
"""
|
|
1293
|
+
# Build pattern based on allow_slash parameter
|
|
1294
|
+
if allow_slash:
|
|
1295
|
+
# Pattern: starts with alphanumeric, can contain alphanumeric/dot/dash/underscore/slash, ends with alphanumeric
|
|
1296
|
+
name_pattern = r"^[a-zA-Z0-9]([a-zA-Z0-9._/-]*[a-zA-Z0-9])?$"
|
|
1297
|
+
allowed_chars = "letters, numbers, dots, dashes, underscores, and slashes"
|
|
1298
|
+
else:
|
|
1299
|
+
# Pattern: starts with alphanumeric, can contain alphanumeric/dot/dash/underscore, ends with alphanumeric
|
|
1300
|
+
name_pattern = r"^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?$"
|
|
1301
|
+
allowed_chars = "letters, numbers, dots, dashes, and underscores"
|
|
1302
|
+
|
|
1303
|
+
# Check for consecutive slashes if slashes are allowed
|
|
1304
|
+
if allow_slash and "//" in name:
|
|
1305
|
+
raise ValueError(f"Invalid {entity_type} name '{name}'. Name cannot contain consecutive slashes.")
|
|
1306
|
+
|
|
1307
|
+
if not re.match(name_pattern, name):
|
|
1308
|
+
# Build helpful error message
|
|
1309
|
+
error_msg = (
|
|
1310
|
+
f"Invalid {entity_type} name '{name}'. "
|
|
1311
|
+
f"Name must contain only {allowed_chars}. "
|
|
1312
|
+
f"It must start and end with an alphanumeric character.\n"
|
|
1313
|
+
)
|
|
1314
|
+
|
|
1315
|
+
# Suggest a valid name based on display_name if available
|
|
1316
|
+
if display_name:
|
|
1317
|
+
suggested_name = suggest_valid_name(display_name, allow_slash=allow_slash)
|
|
1318
|
+
if suggested_name and suggested_name != name:
|
|
1319
|
+
error_msg += f" Suggestion: Set name='{suggested_name}' and display_name='{display_name}'\n"
|
|
1320
|
+
|
|
1321
|
+
# Add appropriate examples based on allow_slash
|
|
1322
|
+
if allow_slash:
|
|
1323
|
+
error_msg += (
|
|
1324
|
+
" Examples:\n"
|
|
1325
|
+
" - name='gpt-4' or name='GPT-4'\n"
|
|
1326
|
+
" - name='models/gpt-4' or name='models/GPT-4'\n"
|
|
1327
|
+
" - name='black-forest-labs/FLUX.1-dev'\n"
|
|
1328
|
+
" - name='api/v1/completion'"
|
|
1329
|
+
)
|
|
1330
|
+
else:
|
|
1331
|
+
error_msg += (
|
|
1332
|
+
" Note: Use 'display_name' field for brand names with spaces and special characters.\n"
|
|
1333
|
+
" Examples:\n"
|
|
1334
|
+
" - name='amazon-bedrock' or name='Amazon-Bedrock'\n"
|
|
1335
|
+
" - name='fireworks.ai' or name='Fireworks.ai'\n"
|
|
1336
|
+
" - name='llama-3.1' or name='Llama-3.1'"
|
|
1337
|
+
)
|
|
1338
|
+
|
|
1339
|
+
raise ValueError(error_msg)
|
|
1340
|
+
|
|
1341
|
+
return name
|
|
1342
|
+
|
|
1343
|
+
|
|
1344
|
+
def suggest_valid_name(display_name: str, *, allow_slash: bool = False) -> str:
|
|
1345
|
+
"""
|
|
1346
|
+
Suggest a valid name based on a display name.
|
|
1347
|
+
|
|
1348
|
+
Replaces invalid characters with hyphens and ensures it follows the naming rules.
|
|
1349
|
+
Preserves the original case.
|
|
1350
|
+
|
|
1351
|
+
Args:
|
|
1352
|
+
display_name: The display name to convert
|
|
1353
|
+
allow_slash: Whether to allow slashes for hierarchical names (default: False)
|
|
1354
|
+
|
|
1355
|
+
Returns:
|
|
1356
|
+
A suggested valid name
|
|
1357
|
+
"""
|
|
1358
|
+
if allow_slash:
|
|
1359
|
+
# Replace characters that aren't alphanumeric, dot, dash, underscore, or slash with hyphens
|
|
1360
|
+
suggested = re.sub(r"[^a-zA-Z0-9._/-]+", "-", display_name)
|
|
1361
|
+
# Remove leading/trailing special characters
|
|
1362
|
+
suggested = suggested.strip("._/-")
|
|
1363
|
+
# Collapse multiple consecutive dashes
|
|
1364
|
+
suggested = re.sub(r"-+", "-", suggested)
|
|
1365
|
+
# Remove consecutive slashes
|
|
1366
|
+
suggested = re.sub(r"/+", "/", suggested)
|
|
1367
|
+
else:
|
|
1368
|
+
# Replace characters that aren't alphanumeric, dot, dash, or underscore with hyphens
|
|
1369
|
+
suggested = re.sub(r"[^a-zA-Z0-9._-]+", "-", display_name)
|
|
1370
|
+
# Remove leading/trailing dots, dashes, or underscores
|
|
1371
|
+
suggested = suggested.strip("._-")
|
|
1372
|
+
# Collapse multiple consecutive dashes
|
|
1373
|
+
suggested = re.sub(r"-+", "-", suggested)
|
|
1374
|
+
|
|
1375
|
+
return suggested
|