unitysvc-services 0.1.0__py3-none-any.whl → 0.1.4__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.
@@ -0,0 +1,278 @@
1
+ """Base API client for UnitySVC with automatic curl fallback.
2
+
3
+ This module provides the base class for all UnitySVC API clients with
4
+ automatic network fallback from httpx to curl for systems with network
5
+ restrictions (e.g., macOS with conda Python).
6
+ """
7
+
8
+ import asyncio
9
+ import json
10
+ import os
11
+ from typing import Any
12
+ from urllib.parse import urlencode
13
+
14
+ import httpx
15
+
16
+
17
+ class UnitySvcAPI:
18
+ """Base class for UnitySVC API clients with automatic curl fallback.
19
+
20
+ Provides async HTTP GET/POST methods that try httpx first for performance,
21
+ then automatically fall back to curl if network restrictions are detected
22
+ (e.g., macOS with conda Python).
23
+
24
+ This base class can be used by:
25
+ - ServiceDataQuery (query/read operations)
26
+ - ServiceDataPublisher (publish/write operations)
27
+ - AdminQuery (administrative operations)
28
+ """
29
+
30
+ def __init__(self) -> None:
31
+ """Initialize API client from environment variables.
32
+
33
+ Raises:
34
+ ValueError: If required environment variables are not set
35
+ """
36
+ self.base_url = os.environ.get("UNITYSVC_BASE_URL")
37
+ if not self.base_url:
38
+ raise ValueError("UNITYSVC_BASE_URL environment variable not set")
39
+
40
+ self.api_key = os.environ.get("UNITYSVC_API_KEY")
41
+ if not self.api_key:
42
+ raise ValueError("UNITYSVC_API_KEY environment variable not set")
43
+
44
+ self.base_url = self.base_url.rstrip("/")
45
+ self.use_curl_fallback = False
46
+ self.client = httpx.AsyncClient(
47
+ headers={
48
+ "X-API-Key": self.api_key,
49
+ "Content-Type": "application/json",
50
+ },
51
+ timeout=30.0,
52
+ )
53
+
54
+ async def _make_request_curl(self, endpoint: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
55
+ """Make HTTP GET request using curl fallback (async).
56
+
57
+ Args:
58
+ endpoint: API endpoint path (e.g., "/publish/sellers")
59
+ params: Query parameters
60
+
61
+ Returns:
62
+ JSON response as dictionary
63
+
64
+ Raises:
65
+ RuntimeError: If curl command fails or returns non-200 status
66
+ """
67
+ url = f"{self.base_url}{endpoint}"
68
+ if params:
69
+ url = f"{url}?{urlencode(params)}"
70
+
71
+ cmd = [
72
+ "curl",
73
+ "-s", # Silent mode
74
+ "-f", # Fail on HTTP errors
75
+ "-H",
76
+ f"X-API-Key: {self.api_key}",
77
+ "-H",
78
+ "Accept: application/json",
79
+ url,
80
+ ]
81
+
82
+ try:
83
+ proc = await asyncio.create_subprocess_exec(
84
+ *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
85
+ )
86
+ stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=30.0)
87
+
88
+ if proc.returncode != 0:
89
+ error_msg = stderr.decode().strip() if stderr else "Unknown error"
90
+ raise RuntimeError(f"HTTP request failed: {error_msg}")
91
+
92
+ output = stdout.decode().strip()
93
+ return json.loads(output)
94
+ except TimeoutError:
95
+ raise RuntimeError("Request timed out after 30 seconds")
96
+ except json.JSONDecodeError as e:
97
+ raise RuntimeError(f"Invalid JSON response: {e}")
98
+
99
+ async def _make_post_request_curl(
100
+ self, endpoint: str, json_data: dict[str, Any] | None = None, params: dict[str, Any] | None = None
101
+ ) -> dict[str, Any]:
102
+ """Make HTTP POST request using curl fallback (async).
103
+
104
+ Args:
105
+ endpoint: API endpoint path (e.g., "/admin/subscriptions")
106
+ json_data: JSON body data
107
+ params: Query parameters
108
+
109
+ Returns:
110
+ JSON response as dictionary
111
+
112
+ Raises:
113
+ RuntimeError: If curl command fails or returns non-200 status
114
+ """
115
+ url = f"{self.base_url}{endpoint}"
116
+ if params:
117
+ url = f"{url}?{urlencode(params)}"
118
+
119
+ cmd = [
120
+ "curl",
121
+ "-s", # Silent mode
122
+ "-f", # Fail on HTTP errors
123
+ "-X",
124
+ "POST",
125
+ "-H",
126
+ f"X-API-Key: {self.api_key}",
127
+ "-H",
128
+ "Content-Type: application/json",
129
+ "-H",
130
+ "Accept: application/json",
131
+ ]
132
+
133
+ if json_data:
134
+ cmd.extend(["-d", json.dumps(json_data)])
135
+
136
+ cmd.append(url)
137
+
138
+ try:
139
+ proc = await asyncio.create_subprocess_exec(
140
+ *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
141
+ )
142
+ stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=30.0)
143
+
144
+ if proc.returncode != 0:
145
+ error_msg = stderr.decode().strip() if stderr else "Unknown error"
146
+ raise RuntimeError(f"HTTP request failed: {error_msg}")
147
+
148
+ output = stdout.decode().strip()
149
+ return json.loads(output)
150
+ except TimeoutError:
151
+ raise RuntimeError("Request timed out after 30 seconds")
152
+ except json.JSONDecodeError as e:
153
+ raise RuntimeError(f"Invalid JSON response: {e}")
154
+
155
+ async def get(self, endpoint: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
156
+ """Make a GET request to the backend API with automatic curl fallback.
157
+
158
+ Public async utility method for making GET requests. Tries httpx first for performance,
159
+ automatically falls back to curl if network restrictions are detected (e.g., macOS
160
+ with conda Python).
161
+
162
+ Args:
163
+ endpoint: API endpoint path (e.g., "/publish/sellers", "/admin/documents")
164
+ params: Query parameters
165
+
166
+ Returns:
167
+ JSON response as dictionary
168
+
169
+ Raises:
170
+ RuntimeError: If both httpx and curl fail
171
+ """
172
+ # If we already know curl is needed, use it directly
173
+ if self.use_curl_fallback:
174
+ return await self._make_request_curl(endpoint, params)
175
+
176
+ # Try httpx first
177
+ try:
178
+ response = await self.client.get(f"{self.base_url}{endpoint}", params=params)
179
+ response.raise_for_status()
180
+ return response.json()
181
+ except (httpx.ConnectError, OSError):
182
+ # Connection failed - likely network restrictions
183
+ # Fall back to curl and remember this for future requests
184
+ self.use_curl_fallback = True
185
+ return await self._make_request_curl(endpoint, params)
186
+
187
+ async def post(
188
+ self, endpoint: str, json_data: dict[str, Any] | None = None, params: dict[str, Any] | None = None
189
+ ) -> dict[str, Any]:
190
+ """Make a POST request to the backend API with automatic curl fallback.
191
+
192
+ Public async utility method for making POST requests. Tries httpx first for performance,
193
+ automatically falls back to curl if network restrictions are detected (e.g., macOS
194
+ with conda Python).
195
+
196
+ Args:
197
+ endpoint: API endpoint path (e.g., "/admin/subscriptions")
198
+ json_data: JSON body data
199
+ params: Query parameters
200
+
201
+ Returns:
202
+ JSON response as dictionary
203
+
204
+ Raises:
205
+ RuntimeError: If both httpx and curl fail
206
+ """
207
+ # If we already know curl is needed, use it directly
208
+ if self.use_curl_fallback:
209
+ return await self._make_post_request_curl(endpoint, json_data, params)
210
+
211
+ # Try httpx first
212
+ try:
213
+ response = await self.client.post(f"{self.base_url}{endpoint}", json=json_data, params=params)
214
+ response.raise_for_status()
215
+ return response.json()
216
+ except (httpx.ConnectError, OSError):
217
+ # Connection failed - likely network restrictions
218
+ # Fall back to curl and remember this for future requests
219
+ self.use_curl_fallback = True
220
+ return await self._make_post_request_curl(endpoint, json_data, params)
221
+
222
+ async def check_task(self, task_id: str, poll_interval: float = 2.0, timeout: float = 300.0) -> dict[str, Any]:
223
+ """Check and wait for task completion (async version).
224
+
225
+ Utility function to poll a Celery task until it completes or times out.
226
+ Uses the async HTTP client with curl fallback.
227
+
228
+ Args:
229
+ task_id: Celery task ID to poll
230
+ poll_interval: Seconds between status checks (default: 2.0)
231
+ timeout: Maximum seconds to wait (default: 300.0)
232
+
233
+ Returns:
234
+ Task result dictionary
235
+
236
+ Raises:
237
+ ValueError: If task fails or times out
238
+ """
239
+ import time
240
+
241
+ start_time = time.time()
242
+
243
+ while True:
244
+ elapsed = time.time() - start_time
245
+ if elapsed > timeout:
246
+ raise ValueError(f"Task {task_id} timed out after {timeout}s")
247
+
248
+ # Check task status using get() with automatic curl fallback
249
+ try:
250
+ status = await self.get(f"/tasks/{task_id}")
251
+ except Exception:
252
+ # Network error while checking status - retry
253
+ await asyncio.sleep(poll_interval)
254
+ continue
255
+
256
+ state = status.get("state", "PENDING")
257
+
258
+ # Check if task is complete
259
+ if status.get("status") == "completed" or state == "SUCCESS":
260
+ return status.get("result", {})
261
+ elif status.get("status") == "failed" or state == "FAILURE":
262
+ error = status.get("error", "Unknown error")
263
+ raise ValueError(f"Task {task_id} failed: {error}")
264
+
265
+ # Still processing - wait and retry
266
+ await asyncio.sleep(poll_interval)
267
+
268
+ async def aclose(self):
269
+ """Close the HTTP client."""
270
+ await self.client.aclose()
271
+
272
+ async def __aenter__(self):
273
+ """Async context manager entry."""
274
+ return self
275
+
276
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
277
+ """Async context manager exit."""
278
+ await self.aclose()
@@ -1,6 +1,5 @@
1
1
  """Format command - format data files."""
2
2
 
3
- import os
4
3
  from pathlib import Path
5
4
 
6
5
  import typer
@@ -14,7 +13,7 @@ console = Console()
14
13
  def format_data(
15
14
  data_dir: Path | None = typer.Argument(
16
15
  None,
17
- help="Directory containing data files to format (default: ./data or UNITYSVC_DATA_DIR env var)",
16
+ help="Directory containing data files to format (default: current directory)",
18
17
  ),
19
18
  check_only: bool = typer.Option(
20
19
  False,
@@ -35,11 +34,7 @@ def format_data(
35
34
 
36
35
  # Set data directory
37
36
  if data_dir is None:
38
- data_dir_str = os.getenv("UNITYSVC_DATA_DIR")
39
- if data_dir_str:
40
- data_dir = Path(data_dir_str)
41
- else:
42
- data_dir = Path.cwd() / "data"
37
+ data_dir = Path.cwd()
43
38
 
44
39
  if not data_dir.is_absolute():
45
40
  data_dir = Path.cwd() / data_dir
unitysvc_services/list.py CHANGED
@@ -1,6 +1,5 @@
1
1
  """List command group - list local data files."""
2
2
 
3
- import os
4
3
  from pathlib import Path
5
4
 
6
5
  import typer
@@ -21,25 +20,19 @@ console = Console()
21
20
  def list_providers(
22
21
  data_dir: Path | None = typer.Argument(
23
22
  None,
24
- help="Directory containing provider files (default: ./data or UNITYSVC_DATA_DIR env var)",
23
+ help="Directory containing provider files (default: current directory)",
25
24
  ),
26
25
  ):
27
26
  """List all provider files found in the data directory."""
28
27
  # Set data directory
29
28
  if data_dir is None:
30
- data_dir_str = os.getenv("UNITYSVC_DATA_DIR")
31
- if data_dir_str:
32
- data_dir = Path(data_dir_str)
33
- else:
34
- data_dir = Path.cwd() / "data"
29
+ data_dir = Path.cwd()
35
30
 
36
31
  if not data_dir.is_absolute():
37
32
  data_dir = Path.cwd() / data_dir
38
33
 
39
34
  if not data_dir.exists():
40
- console.print(
41
- f"[red]✗[/red] Data directory not found: {data_dir}", style="bold red"
42
- )
35
+ console.print(f"[red]✗[/red] Data directory not found: {data_dir}", style="bold red")
43
36
  raise typer.Exit(code=1)
44
37
 
45
38
  console.print(f"[blue]Searching for providers in:[/blue] {data_dir}\n")
@@ -72,25 +65,19 @@ def list_providers(
72
65
  def list_sellers(
73
66
  data_dir: Path | None = typer.Argument(
74
67
  None,
75
- help="Directory containing seller files (default: ./data or UNITYSVC_DATA_DIR env var)",
68
+ help="Directory containing seller files (default: current directory)",
76
69
  ),
77
70
  ):
78
71
  """List all seller files found in the data directory."""
79
72
  # Set data directory
80
73
  if data_dir is None:
81
- data_dir_str = os.getenv("UNITYSVC_DATA_DIR")
82
- if data_dir_str:
83
- data_dir = Path(data_dir_str)
84
- else:
85
- data_dir = Path.cwd() / "data"
74
+ data_dir = Path.cwd()
86
75
 
87
76
  if not data_dir.is_absolute():
88
77
  data_dir = Path.cwd() / data_dir
89
78
 
90
79
  if not data_dir.exists():
91
- console.print(
92
- f"[red]✗[/red] Data directory not found: {data_dir}", style="bold red"
93
- )
80
+ console.print(f"[red]✗[/red] Data directory not found: {data_dir}", style="bold red")
94
81
  raise typer.Exit(code=1)
95
82
 
96
83
  console.print(f"[blue]Searching for sellers in:[/blue] {data_dir}\n")
@@ -123,25 +110,19 @@ def list_sellers(
123
110
  def list_offerings(
124
111
  data_dir: Path | None = typer.Argument(
125
112
  None,
126
- help="Directory containing service files (default: ./data or UNITYSVC_DATA_DIR env var)",
113
+ help="Directory containing service files (default: current directory)",
127
114
  ),
128
115
  ):
129
116
  """List all service offering files found in the data directory."""
130
117
  # Set data directory
131
118
  if data_dir is None:
132
- data_dir_str = os.getenv("UNITYSVC_DATA_DIR")
133
- if data_dir_str:
134
- data_dir = Path(data_dir_str)
135
- else:
136
- data_dir = Path.cwd() / "data"
119
+ data_dir = Path.cwd()
137
120
 
138
121
  if not data_dir.is_absolute():
139
122
  data_dir = Path.cwd() / data_dir
140
123
 
141
124
  if not data_dir.exists():
142
- console.print(
143
- f"[red]✗[/red] Data directory not found: {data_dir}", style="bold red"
144
- )
125
+ console.print(f"[red]✗[/red] Data directory not found: {data_dir}", style="bold red")
145
126
  raise typer.Exit(code=1)
146
127
 
147
128
  console.print(f"[blue]Searching for service offerings in:[/blue] {data_dir}\n")
@@ -172,34 +153,26 @@ def list_offerings(
172
153
  )
173
154
 
174
155
  console.print(table)
175
- console.print(
176
- f"\n[green]Total:[/green] {len(service_files)} service offering file(s)"
177
- )
156
+ console.print(f"\n[green]Total:[/green] {len(service_files)} service offering file(s)")
178
157
 
179
158
 
180
159
  @app.command("listings")
181
160
  def list_listings(
182
161
  data_dir: Path | None = typer.Argument(
183
162
  None,
184
- help="Directory containing listing files (default: ./data or UNITYSVC_DATA_DIR env var)",
163
+ help="Directory containing listing files (default: current directory)",
185
164
  ),
186
165
  ):
187
166
  """List all service listing files found in the data directory."""
188
167
  # Set data directory
189
168
  if data_dir is None:
190
- data_dir_str = os.getenv("UNITYSVC_DATA_DIR")
191
- if data_dir_str:
192
- data_dir = Path(data_dir_str)
193
- else:
194
- data_dir = Path.cwd() / "data"
169
+ data_dir = Path.cwd()
195
170
 
196
171
  if not data_dir.is_absolute():
197
172
  data_dir = Path.cwd() / data_dir
198
173
 
199
174
  if not data_dir.exists():
200
- console.print(
201
- f"[red]✗[/red] Data directory not found: {data_dir}", style="bold red"
202
- )
175
+ console.print(f"[red]✗[/red] Data directory not found: {data_dir}", style="bold red")
203
176
  raise typer.Exit(code=1)
204
177
 
205
178
  console.print(f"[blue]Searching for service listings in:[/blue] {data_dir}\n")
@@ -240,6 +213,4 @@ def list_listings(
240
213
  )
241
214
 
242
215
  console.print(table)
243
- console.print(
244
- f"\n[green]Total:[/green] {len(listing_files)} service listing file(s)"
245
- )
216
+ console.print(f"\n[green]Total:[/green] {len(listing_files)} service listing file(s)")
@@ -1,3 +1,4 @@
1
+ import re
1
2
  from enum import StrEnum
2
3
  from typing import Any
3
4
 
@@ -206,6 +207,24 @@ class UpstreamStatusEnum(StrEnum):
206
207
  deprecated = "deprecated"
207
208
 
208
209
 
210
+ class ProviderStatusEnum(StrEnum):
211
+ """Provider status enum."""
212
+
213
+ active = "active"
214
+ pending = "pending"
215
+ disabled = "disabled"
216
+ incomplete = "incomplete" # Provider information is incomplete
217
+
218
+
219
+ class SellerStatusEnum(StrEnum):
220
+ """Seller status enum."""
221
+
222
+ active = "active"
223
+ pending = "pending"
224
+ disabled = "disabled"
225
+ incomplete = "incomplete" # Seller information is incomplete
226
+
227
+
209
228
  class Document(BaseModel):
210
229
  model_config = ConfigDict(extra="forbid")
211
230
 
@@ -350,3 +369,123 @@ class Pricing(BaseModel):
350
369
 
351
370
  # Optional reference to upstream pricing
352
371
  reference: str | None = Field(default=None, description="Reference URL to upstream pricing")
372
+
373
+
374
+ def validate_name(name: str, entity_type: str, display_name: str | None = None, *, allow_slash: bool = False) -> str:
375
+ """
376
+ Validate that a name field uses valid identifiers.
377
+
378
+ Name format rules:
379
+ - Only letters (upper/lowercase), numbers, dots, dashes, and underscores allowed
380
+ - If allow_slash=True, slashes are also allowed for hierarchical names
381
+ - Must start and end with alphanumeric characters (not special characters)
382
+ - Cannot have consecutive slashes (when allow_slash=True)
383
+ - Cannot be empty
384
+
385
+ Args:
386
+ name: The name value to validate
387
+ entity_type: Type of entity (provider, seller, service, listing) for error messages
388
+ display_name: Optional display name to suggest a valid name from
389
+ allow_slash: Whether to allow slashes for hierarchical names (default: False)
390
+
391
+ Returns:
392
+ The validated name (unchanged if valid)
393
+
394
+ Raises:
395
+ ValueError: If the name doesn't match the required pattern
396
+
397
+ Examples:
398
+ Without slashes (providers, sellers):
399
+ - name='amazon-bedrock' or name='Amazon-Bedrock'
400
+ - name='fireworks.ai' or name='Fireworks.ai'
401
+ - name='llama-3.1' or name='Llama-3.1'
402
+
403
+ With slashes (services, listings):
404
+ - name='gpt-4' or name='GPT-4'
405
+ - name='models/gpt-4' or name='models/GPT-4'
406
+ - name='black-forest-labs/FLUX.1-dev'
407
+ - name='api/v1/completion'
408
+ """
409
+ # Build pattern based on allow_slash parameter
410
+ if allow_slash:
411
+ # Pattern: starts with alphanumeric, can contain alphanumeric/dot/dash/underscore/slash, ends with alphanumeric
412
+ name_pattern = r"^[a-zA-Z0-9]([a-zA-Z0-9._/-]*[a-zA-Z0-9])?$"
413
+ allowed_chars = "letters, numbers, dots, dashes, underscores, and slashes"
414
+ else:
415
+ # Pattern: starts with alphanumeric, can contain alphanumeric/dot/dash/underscore, ends with alphanumeric
416
+ name_pattern = r"^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?$"
417
+ allowed_chars = "letters, numbers, dots, dashes, and underscores"
418
+
419
+ # Check for consecutive slashes if slashes are allowed
420
+ if allow_slash and "//" in name:
421
+ raise ValueError(f"Invalid {entity_type} name '{name}'. Name cannot contain consecutive slashes.")
422
+
423
+ if not re.match(name_pattern, name):
424
+ # Build helpful error message
425
+ error_msg = (
426
+ f"Invalid {entity_type} name '{name}'. "
427
+ f"Name must contain only {allowed_chars}. "
428
+ f"It must start and end with an alphanumeric character.\n"
429
+ )
430
+
431
+ # Suggest a valid name based on display_name if available
432
+ if display_name:
433
+ suggested_name = suggest_valid_name(display_name, allow_slash=allow_slash)
434
+ if suggested_name and suggested_name != name:
435
+ error_msg += f" Suggestion: Set name='{suggested_name}' and display_name='{display_name}'\n"
436
+
437
+ # Add appropriate examples based on allow_slash
438
+ if allow_slash:
439
+ error_msg += (
440
+ " Examples:\n"
441
+ " - name='gpt-4' or name='GPT-4'\n"
442
+ " - name='models/gpt-4' or name='models/GPT-4'\n"
443
+ " - name='black-forest-labs/FLUX.1-dev'\n"
444
+ " - name='api/v1/completion'"
445
+ )
446
+ else:
447
+ error_msg += (
448
+ " Note: Use 'display_name' field for brand names with spaces and special characters.\n"
449
+ " Examples:\n"
450
+ " - name='amazon-bedrock' or name='Amazon-Bedrock'\n"
451
+ " - name='fireworks.ai' or name='Fireworks.ai'\n"
452
+ " - name='llama-3.1' or name='Llama-3.1'"
453
+ )
454
+
455
+ raise ValueError(error_msg)
456
+
457
+ return name
458
+
459
+
460
+ def suggest_valid_name(display_name: str, *, allow_slash: bool = False) -> str:
461
+ """
462
+ Suggest a valid name based on a display name.
463
+
464
+ Replaces invalid characters with hyphens and ensures it follows the naming rules.
465
+ Preserves the original case.
466
+
467
+ Args:
468
+ display_name: The display name to convert
469
+ allow_slash: Whether to allow slashes for hierarchical names (default: False)
470
+
471
+ Returns:
472
+ A suggested valid name
473
+ """
474
+ if allow_slash:
475
+ # Replace characters that aren't alphanumeric, dot, dash, underscore, or slash with hyphens
476
+ suggested = re.sub(r"[^a-zA-Z0-9._/-]+", "-", display_name)
477
+ # Remove leading/trailing special characters
478
+ suggested = suggested.strip("._/-")
479
+ # Collapse multiple consecutive dashes
480
+ suggested = re.sub(r"-+", "-", suggested)
481
+ # Remove consecutive slashes
482
+ suggested = re.sub(r"/+", "/", suggested)
483
+ else:
484
+ # Replace characters that aren't alphanumeric, dot, dash, or underscore with hyphens
485
+ suggested = re.sub(r"[^a-zA-Z0-9._-]+", "-", display_name)
486
+ # Remove leading/trailing dots, dashes, or underscores
487
+ suggested = suggested.strip("._-")
488
+ # Collapse multiple consecutive dashes
489
+ suggested = re.sub(r"-+", "-", suggested)
490
+
491
+ return suggested
@@ -1,13 +1,14 @@
1
1
  from datetime import datetime
2
2
  from typing import Any
3
3
 
4
- from pydantic import BaseModel, ConfigDict, Field
4
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
5
5
 
6
6
  from unitysvc_services.models.base import (
7
7
  AccessInterface,
8
8
  Document,
9
9
  ListingStatusEnum,
10
10
  Pricing,
11
+ validate_name,
11
12
  )
12
13
 
13
14
 
@@ -30,8 +31,19 @@ class ListingV1(BaseModel):
30
31
  ),
31
32
  )
32
33
 
33
- seller_name: str = Field(
34
- description="Name of the seller offering this service listing"
34
+ seller_name: str | None = Field(default=None, description="Name of the seller offering this service listing")
35
+
36
+ name: str | None = Field(
37
+ default=None,
38
+ max_length=255,
39
+ description="Name identifier for the service listing, default to filename",
40
+ )
41
+
42
+ # Display name for UI (human-readable listing name)
43
+ display_name: str | None = Field(
44
+ default=None,
45
+ max_length=200,
46
+ description="Human-readable listing name (e.g., 'Premium GPT-4 Access', 'Enterprise AI Services')",
35
47
  )
36
48
 
37
49
  # unique name for each provider, usually following upstream naming convention
@@ -70,3 +82,11 @@ class ListingV1(BaseModel):
70
82
  user_parameters_ui_schema: dict[str, Any] | None = Field(
71
83
  default=None, description="Dictionary of user parameters UI schema"
72
84
  )
85
+
86
+ @field_validator("name")
87
+ @classmethod
88
+ def validate_name_format(cls, v: str | None) -> str | None:
89
+ """Validate that listing name uses valid identifiers (allows slashes for hierarchical names)."""
90
+ if v is None:
91
+ return v
92
+ return validate_name(v, "listing", allow_slash=True)
@@ -1,9 +1,9 @@
1
1
  from datetime import datetime
2
2
  from typing import Any
3
3
 
4
- from pydantic import BaseModel, ConfigDict, EmailStr, Field, HttpUrl
4
+ from pydantic import BaseModel, ConfigDict, EmailStr, Field, HttpUrl, field_validator
5
5
 
6
- from unitysvc_services.models.base import AccessInterface, Document
6
+ from unitysvc_services.models.base import AccessInterface, Document, ProviderStatusEnum, validate_name
7
7
 
8
8
 
9
9
  class ProviderV1(BaseModel):
@@ -26,6 +26,13 @@ class ProviderV1(BaseModel):
26
26
  # name of the provider should be the same as directory name
27
27
  name: str
28
28
 
29
+ # Display name for UI (human-readable brand name)
30
+ display_name: str | None = Field(
31
+ default=None,
32
+ max_length=200,
33
+ description="Human-readable provider name (e.g., 'Amazon Bedrock', 'Fireworks.ai')",
34
+ )
35
+
29
36
  # this field is added for convenience. It will be converted to
30
37
  # documents during importing.
31
38
  logo: str | HttpUrl | None = None
@@ -51,3 +58,17 @@ class ProviderV1(BaseModel):
51
58
  homepage: HttpUrl
52
59
  contact_email: EmailStr
53
60
  secondary_contact_email: EmailStr | None = None
61
+
62
+ # Status field to track provider state
63
+ status: ProviderStatusEnum = Field(
64
+ default=ProviderStatusEnum.active,
65
+ description="Provider status: active, disabled, or incomplete",
66
+ )
67
+
68
+ @field_validator("name")
69
+ @classmethod
70
+ def validate_name_format(cls, v: str) -> str:
71
+ """Validate that provider name uses URL-safe identifiers."""
72
+ # Note: display_name is not available in the validator context for suggesting
73
+ # Display name will be shown in error if user provides it
74
+ return validate_name(v, "provider", allow_slash=False)