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.
Files changed (37) hide show
  1. unitysvc_services/__init__.py +4 -0
  2. unitysvc_services/api.py +421 -0
  3. unitysvc_services/cli.py +23 -0
  4. unitysvc_services/format_data.py +140 -0
  5. unitysvc_services/interactive_prompt.py +1132 -0
  6. unitysvc_services/list.py +216 -0
  7. unitysvc_services/models/__init__.py +71 -0
  8. unitysvc_services/models/base.py +1375 -0
  9. unitysvc_services/models/listing_data.py +118 -0
  10. unitysvc_services/models/listing_v1.py +56 -0
  11. unitysvc_services/models/provider_data.py +79 -0
  12. unitysvc_services/models/provider_v1.py +54 -0
  13. unitysvc_services/models/seller_data.py +120 -0
  14. unitysvc_services/models/seller_v1.py +42 -0
  15. unitysvc_services/models/service_data.py +114 -0
  16. unitysvc_services/models/service_v1.py +81 -0
  17. unitysvc_services/populate.py +207 -0
  18. unitysvc_services/publisher.py +1628 -0
  19. unitysvc_services/py.typed +0 -0
  20. unitysvc_services/query.py +688 -0
  21. unitysvc_services/scaffold.py +1103 -0
  22. unitysvc_services/schema/base.json +777 -0
  23. unitysvc_services/schema/listing_v1.json +1286 -0
  24. unitysvc_services/schema/provider_v1.json +952 -0
  25. unitysvc_services/schema/seller_v1.json +379 -0
  26. unitysvc_services/schema/service_v1.json +1306 -0
  27. unitysvc_services/test.py +965 -0
  28. unitysvc_services/unpublisher.py +505 -0
  29. unitysvc_services/update.py +287 -0
  30. unitysvc_services/utils.py +533 -0
  31. unitysvc_services/validator.py +731 -0
  32. unitysvc_services-0.1.24.dist-info/METADATA +184 -0
  33. unitysvc_services-0.1.24.dist-info/RECORD +37 -0
  34. unitysvc_services-0.1.24.dist-info/WHEEL +5 -0
  35. unitysvc_services-0.1.24.dist-info/entry_points.txt +3 -0
  36. unitysvc_services-0.1.24.dist-info/licenses/LICENSE +21 -0
  37. 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