unitysvc-services 0.1.9__py3-none-any.whl → 0.1.11__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.

Potentially problematic release.


This version of unitysvc-services might be problematic. Click here for more details.

@@ -0,0 +1,1129 @@
1
+ """Interactive prompt system for creating data files.
2
+
3
+ This module provides a systematic way to prompt users for field values
4
+ using a field registry approach, making it easy to add/remove/modify fields.
5
+ """
6
+
7
+ import json
8
+ import tomllib
9
+ from collections.abc import Callable
10
+ from dataclasses import dataclass, field
11
+ from datetime import UTC, datetime
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ import typer
16
+ from rich.console import Console
17
+ from rich.prompt import Confirm, IntPrompt, Prompt
18
+
19
+ console = Console()
20
+
21
+
22
+ # =============================================================================
23
+ # HELPER FUNCTIONS FOR AUTO-DISCOVERY
24
+ # =============================================================================
25
+
26
+
27
+ def find_seller_name(data_dir: Path | None = None) -> str | None:
28
+ """Find seller name from seller.json or seller.toml in data directory.
29
+
30
+ Args:
31
+ data_dir: Directory to search for seller file (defaults to search common locations)
32
+
33
+ Returns:
34
+ Seller name if found, None otherwise
35
+ """
36
+ # Search in multiple common locations
37
+ search_dirs = []
38
+
39
+ if data_dir is not None:
40
+ search_dirs.append(data_dir)
41
+ else:
42
+ # Common locations to search
43
+ cwd = Path.cwd()
44
+ search_dirs.extend(
45
+ [
46
+ cwd / "data", # ./data (most common)
47
+ cwd, # current directory
48
+ cwd.parent / "data", # ../data (if we're in a subdirectory)
49
+ cwd.parent, # parent directory
50
+ ]
51
+ )
52
+
53
+ # Look for seller file in each search directory
54
+ for search_dir in search_dirs:
55
+ for filename in ["seller.json", "seller.toml"]:
56
+ seller_file = search_dir / filename
57
+ if seller_file.exists():
58
+ try:
59
+ if filename.endswith(".json"):
60
+ with open(seller_file) as f:
61
+ data = json.load(f)
62
+ else: # .toml
63
+ with open(seller_file, "rb") as f:
64
+ data = tomllib.load(f)
65
+
66
+ seller_name = data.get("name")
67
+ if seller_name:
68
+ return seller_name
69
+ except Exception:
70
+ continue
71
+
72
+ return None
73
+
74
+
75
+ def prompt_for_pricing() -> dict[str, Any]:
76
+ """Interactively prompt for pricing information.
77
+
78
+ Returns:
79
+ Dictionary with pricing data
80
+ """
81
+ console.print("\n[bold cyan]Adding pricing information[/bold cyan]")
82
+
83
+ # Required field: unit
84
+ unit = Prompt.ask(
85
+ "[bold blue]Pricing unit[/bold blue] [red]*[/red]",
86
+ choices=["one_million_tokens", "one_second", "image", "step"],
87
+ default="one_million_tokens",
88
+ )
89
+
90
+ # Optional fields
91
+ name = Prompt.ask(
92
+ "[bold blue]Pricing tier name[/bold blue] [dim](optional, e.g., 'Standard', 'Pro')[/dim]",
93
+ default="",
94
+ )
95
+ description = Prompt.ask("[bold blue]Description[/bold blue] [dim](optional)[/dim]", default="")
96
+ currency = Prompt.ask("[bold blue]Currency code[/bold blue] [dim](optional, e.g., 'USD')[/dim]", default="USD")
97
+ reference = Prompt.ask(
98
+ "[bold blue]Reference URL[/bold blue] [dim](optional, link to upstream pricing page)[/dim]", default=""
99
+ )
100
+
101
+ # Price data - ask user what type of pricing structure they want
102
+ console.print("\n[dim]Price data is a flexible structure. Common examples:[/dim]")
103
+ console.print('[dim] 1. Simple: {"amount": 10.00}[/dim]')
104
+ console.print('[dim] 2. Input/Output (LLMs): {"input": 5.00, "output": 15.00}[/dim]')
105
+ console.print('[dim] 3. Custom JSON: {"base": 5.00, "per_unit": 0.001}[/dim]')
106
+
107
+ pricing_type = Prompt.ask(
108
+ "\n[bold blue]Pricing structure type[/bold blue]",
109
+ choices=["simple", "input_output", "custom"],
110
+ default="simple",
111
+ )
112
+
113
+ if pricing_type == "simple":
114
+ amount = Prompt.ask("[bold blue]Price amount[/bold blue] [dim](numeric value)[/dim]", default="0")
115
+ try:
116
+ price_amount = float(amount)
117
+ except ValueError:
118
+ console.print("[yellow]Invalid amount, using 0[/yellow]")
119
+ price_amount = 0.0
120
+ price_data = {"amount": price_amount}
121
+
122
+ elif pricing_type == "input_output":
123
+ input_amount = Prompt.ask("[bold blue]Input price amount[/bold blue] [dim](numeric value)[/dim]", default="0")
124
+ try:
125
+ input_price = float(input_amount)
126
+ except ValueError:
127
+ console.print("[yellow]Invalid amount, using 0[/yellow]")
128
+ input_price = 0.0
129
+
130
+ output_amount = Prompt.ask("[bold blue]Output price amount[/bold blue] [dim](numeric value)[/dim]", default="0")
131
+ try:
132
+ output_price = float(output_amount)
133
+ except ValueError:
134
+ console.print("[yellow]Invalid amount, using 0[/yellow]")
135
+ output_price = 0.0
136
+
137
+ price_data = {"input": input_price, "output": output_price}
138
+
139
+ else: # custom
140
+ console.print('\n[dim]Enter price_data as JSON (e.g., {"base": 5.00, "per_unit": 0.001})[/dim]')
141
+ while True:
142
+ json_input = Prompt.ask("[bold blue]Price data JSON[/bold blue]", default="{}")
143
+ try:
144
+ price_data = json.loads(json_input)
145
+ if not isinstance(price_data, dict):
146
+ console.print("[red]Error: Price data must be a JSON object (dict)[/red]")
147
+ continue
148
+ break
149
+ except json.JSONDecodeError as e:
150
+ console.print(f"[red]Invalid JSON: {e}[/red]")
151
+ console.print("[dim]Try again or press Ctrl+C to cancel[/dim]")
152
+
153
+ # Build pricing dict
154
+ pricing: dict[str, Any] = {
155
+ "unit": unit,
156
+ "price_data": price_data,
157
+ }
158
+
159
+ if name:
160
+ pricing["name"] = name
161
+ if description:
162
+ pricing["description"] = description
163
+ if currency:
164
+ pricing["currency"] = currency
165
+ if reference:
166
+ pricing["reference"] = reference
167
+
168
+ return pricing
169
+
170
+
171
+ def prompt_for_document(listing_dir: Path) -> dict[str, Any]:
172
+ """Interactively prompt for a single document.
173
+
174
+ Args:
175
+ listing_dir: Directory where the listing file will be created (for validating file paths)
176
+
177
+ Returns:
178
+ Dictionary with document data
179
+ """
180
+ console.print("\n[bold cyan]Adding a document[/bold cyan]")
181
+
182
+ # Required fields
183
+ title = Prompt.ask("[bold blue]Document title[/bold blue] [red]*[/red]")
184
+
185
+ mime_type = Prompt.ask(
186
+ "[bold blue]MIME type[/bold blue] [red]*[/red]",
187
+ choices=["markdown", "python", "javascript", "bash", "html", "text", "pdf", "jpeg", "png", "svg", "url"],
188
+ default="markdown",
189
+ )
190
+
191
+ category = Prompt.ask(
192
+ "[bold blue]Document category[/bold blue] [red]*[/red]",
193
+ choices=[
194
+ "getting_started",
195
+ "api_reference",
196
+ "tutorial",
197
+ "code_example",
198
+ "use_case",
199
+ "troubleshooting",
200
+ "changelog",
201
+ "best_practice",
202
+ "specification",
203
+ "service_level_agreement",
204
+ "terms_of_service",
205
+ "logo",
206
+ "other",
207
+ ],
208
+ default="getting_started",
209
+ )
210
+
211
+ # Optional fields
212
+ description = Prompt.ask("[bold blue]Description[/bold blue] [dim](optional)[/dim]", default="")
213
+
214
+ # At least one of file_path or external_url must be specified
215
+ file_path = ""
216
+ external_url = ""
217
+
218
+ while not file_path and not external_url:
219
+ console.print("[dim]At least one of file path or external URL must be specified[/dim]")
220
+
221
+ file_path = Prompt.ask(
222
+ "[bold blue]File path[/bold blue] [dim](relative to listing dir, e.g., 'docs/guide.md')[/dim]",
223
+ default="",
224
+ )
225
+
226
+ # If file_path is provided, validate it exists
227
+ if file_path:
228
+ file_full_path = listing_dir / file_path
229
+ if not file_full_path.exists():
230
+ console.print(f"[yellow]Warning: File not found at {file_full_path}[/yellow]")
231
+ if not Confirm.ask("[bold blue]Use this path anyway?[/bold blue]", default=False):
232
+ file_path = ""
233
+ continue
234
+
235
+ # If no file_path, must provide external_url
236
+ if not file_path:
237
+ external_url = Prompt.ask(
238
+ "[bold blue]External URL[/bold blue] [dim](required if no file path)[/dim]",
239
+ default="",
240
+ )
241
+ if not external_url:
242
+ console.print("[red]Either file path or external URL must be provided[/red]")
243
+ continue
244
+
245
+ is_public = Confirm.ask("[bold blue]Is public?[/bold blue]", default=False)
246
+
247
+ # Build document dict
248
+ doc: dict[str, Any] = {
249
+ "title": title,
250
+ "mime_type": mime_type,
251
+ "category": category,
252
+ }
253
+
254
+ if description:
255
+ doc["description"] = description
256
+ if file_path:
257
+ doc["file_path"] = file_path
258
+ if external_url:
259
+ doc["external_url"] = external_url
260
+ if is_public:
261
+ doc["is_public"] = is_public
262
+
263
+ return doc
264
+
265
+
266
+ def find_service_name(service_dir: Path | None = None) -> str | None:
267
+ """Find service name from service.json or service.toml in service directory.
268
+
269
+ Args:
270
+ service_dir: Directory to search for service file (defaults to search common locations)
271
+
272
+ Returns:
273
+ Service name if found, None otherwise
274
+ """
275
+ # Search in multiple common locations
276
+ search_dirs = []
277
+
278
+ if service_dir is not None:
279
+ search_dirs.append(service_dir)
280
+ else:
281
+ # Common locations to search
282
+ cwd = Path.cwd()
283
+ search_dirs.extend(
284
+ [
285
+ cwd, # current directory (most common for services)
286
+ cwd.parent, # parent directory (if we're in a subdirectory)
287
+ ]
288
+ )
289
+
290
+ # Look for service file in each search directory
291
+ for search_dir in search_dirs:
292
+ for filename in ["service.json", "service.toml"]:
293
+ service_file = search_dir / filename
294
+ if service_file.exists():
295
+ try:
296
+ if filename.endswith(".json"):
297
+ with open(service_file) as f:
298
+ data = json.load(f)
299
+ else: # .toml
300
+ with open(service_file, "rb") as f:
301
+ data = tomllib.load(f)
302
+
303
+ service_name = data.get("name")
304
+ if service_name:
305
+ return service_name
306
+ except Exception:
307
+ continue
308
+
309
+ return None
310
+
311
+
312
+ @dataclass
313
+ class FieldDef:
314
+ """Definition for a single field to prompt.
315
+
316
+ Attributes:
317
+ name: Field name in the schema
318
+ prompt_text: Text to display to the user
319
+ field_type: Type of field (string, email, choice, boolean, integer)
320
+ required: Whether the field is required
321
+ default: Default value (can be callable that takes context)
322
+ choices: List of choices for choice-type fields
323
+ description: Help text for the field
324
+ skip_if: Callable that returns True if field should be skipped
325
+ validate: Optional validation function
326
+ group: Logical grouping for related fields
327
+ """
328
+
329
+ name: str
330
+ prompt_text: str
331
+ field_type: str = "string" # string, email, uri, choice, boolean, integer
332
+ required: bool = False
333
+ default: Any = None
334
+ choices: list[str] | None = None
335
+ description: str | None = None
336
+ skip_if: Callable[[dict], bool] | None = None
337
+ validate: Callable[[Any], Any] | None = None
338
+ group: str = "general"
339
+
340
+
341
+ @dataclass
342
+ class FieldGroup:
343
+ """Group of related fields."""
344
+
345
+ name: str
346
+ title: str
347
+ fields: list[FieldDef] = field(default_factory=list)
348
+
349
+
350
+ class PromptEngine:
351
+ """Engine for prompting users based on field definitions."""
352
+
353
+ def __init__(self, groups: list[FieldGroup]):
354
+ """Initialize prompt engine with field groups.
355
+
356
+ Args:
357
+ groups: List of field groups to prompt for
358
+ """
359
+ self.groups = groups
360
+
361
+ def prompt_all(self, context: dict[str, Any] | None = None) -> dict[str, Any]:
362
+ """Prompt for all fields in all groups.
363
+
364
+ Args:
365
+ context: Initial context/data (e.g., name provided via CLI arg)
366
+
367
+ Returns:
368
+ Dictionary with all prompted values
369
+ """
370
+ if context is None:
371
+ context = {}
372
+
373
+ data = {}
374
+
375
+ for group in self.groups:
376
+ if group.fields:
377
+ console.print(f"\n[bold cyan]{group.title}[/bold cyan]")
378
+
379
+ for field_def in group.fields:
380
+ # Skip if condition met
381
+ if field_def.skip_if and field_def.skip_if(context):
382
+ continue
383
+
384
+ # Skip if already in context (provided via CLI)
385
+ if field_def.name in context:
386
+ value = context[field_def.name]
387
+ console.print(f"[dim]{field_def.prompt_text}: {value} (from CLI)[/dim]")
388
+ data[field_def.name] = value
389
+ continue
390
+
391
+ # Get default value (can be callable)
392
+ default_value = field_def.default
393
+ if callable(default_value):
394
+ default_value = default_value(context, data)
395
+
396
+ # Prompt based on field type
397
+ value = self._prompt_field(field_def, default_value, data)
398
+
399
+ # Only add non-None values (unless required)
400
+ if value is not None or field_def.required:
401
+ data[field_def.name] = value
402
+
403
+ # Add to context for subsequent fields
404
+ context[field_def.name] = value
405
+
406
+ return data
407
+
408
+ def _prompt_field(self, field_def: FieldDef, default_value: Any, current_data: dict[str, Any]) -> Any:
409
+ """Prompt for a single field.
410
+
411
+ Args:
412
+ field_def: Field definition
413
+ default_value: Default value to suggest
414
+ current_data: Currently collected data (for validation)
415
+
416
+ Returns:
417
+ User input value
418
+ """
419
+ # Indicate if field is optional
420
+ required_marker = " [red]*[/red]" if field_def.required else " [dim](optional)[/dim]"
421
+ prompt_label = f"[bold blue]{field_def.prompt_text}{required_marker}[/bold blue]"
422
+
423
+ if field_def.description:
424
+ console.print(f"[dim]{field_def.description}[/dim]")
425
+
426
+ try:
427
+ if field_def.field_type == "boolean":
428
+ return Confirm.ask(prompt_label, default=default_value if default_value is not None else False)
429
+
430
+ elif field_def.field_type == "integer":
431
+ while True:
432
+ try:
433
+ if not field_def.required:
434
+ # Optional integer - allow empty input
435
+ default_str = str(default_value) if default_value is not None else ""
436
+ result = Prompt.ask(prompt_label, default=default_str)
437
+ if result == "":
438
+ return None
439
+ value = int(result)
440
+ elif default_value is not None:
441
+ value = IntPrompt.ask(prompt_label, default=default_value)
442
+ else:
443
+ value = IntPrompt.ask(prompt_label)
444
+
445
+ # Apply custom validation if provided
446
+ if field_def.validate:
447
+ value = field_def.validate(value)
448
+ return value
449
+ except ValueError:
450
+ console.print("[red]Please enter a valid integer[/red]")
451
+
452
+ elif field_def.field_type == "choice":
453
+ if not field_def.choices:
454
+ raise ValueError(f"Field {field_def.name} is type 'choice' but has no choices defined")
455
+
456
+ return Prompt.ask(
457
+ prompt_label,
458
+ choices=field_def.choices,
459
+ default=default_value if default_value else field_def.choices[0],
460
+ )
461
+
462
+ else: # string, email, uri
463
+ while True:
464
+ if not field_def.required and default_value is None:
465
+ # Optional field with no default - allow empty
466
+ result = Prompt.ask(prompt_label, default="")
467
+ if result == "":
468
+ return None
469
+ elif default_value is not None:
470
+ result = Prompt.ask(prompt_label, default=str(default_value))
471
+ else:
472
+ result = Prompt.ask(prompt_label)
473
+
474
+ # Validate input
475
+ if self._validate_field_value(field_def, result):
476
+ # Apply custom validation if provided
477
+ if field_def.validate:
478
+ try:
479
+ result = field_def.validate(result)
480
+ except Exception as e:
481
+ console.print(f"[red]Validation error: {e}[/red]")
482
+ continue
483
+ return result
484
+ else:
485
+ # Validation failed, loop to retry
486
+ pass
487
+
488
+ except KeyboardInterrupt:
489
+ console.print("\n[yellow]Cancelled by user[/yellow]")
490
+ raise typer.Abort()
491
+ except Exception as e:
492
+ console.print(f"[red]Error prompting for {field_def.name}: {e}[/red]")
493
+ raise
494
+
495
+ def _validate_field_value(self, field_def: FieldDef, value: Any) -> bool:
496
+ """Validate field value based on field type.
497
+
498
+ Args:
499
+ field_def: Field definition
500
+ value: Value to validate
501
+
502
+ Returns:
503
+ True if valid, False otherwise (with error message printed)
504
+ """
505
+ if value is None or value == "":
506
+ if field_def.required:
507
+ console.print("[red]This field is required[/red]")
508
+ return False
509
+ return True
510
+
511
+ # Type-specific validation
512
+ if field_def.field_type == "email":
513
+ if "@" not in value or "." not in value.split("@")[-1]:
514
+ console.print("[red]Please enter a valid email address[/red]")
515
+ return False
516
+
517
+ elif field_def.field_type == "uri":
518
+ if not value.startswith(("http://", "https://")):
519
+ console.print("[red]URL must start with http:// or https://[/red]")
520
+ return False
521
+
522
+ return True
523
+
524
+
525
+ # =============================================================================
526
+ # SELLER FIELD REGISTRY
527
+ # =============================================================================
528
+
529
+ SELLER_GROUPS = [
530
+ FieldGroup(
531
+ name="basic",
532
+ title="Basic Information",
533
+ fields=[
534
+ FieldDef(
535
+ name="name",
536
+ prompt_text="Seller ID (URL-friendly)",
537
+ field_type="string",
538
+ required=True,
539
+ description="Unique identifier (e.g., 'acme-corp', 'john-doe')",
540
+ ),
541
+ FieldDef(
542
+ name="display_name",
543
+ prompt_text="Display name",
544
+ field_type="string",
545
+ required=False,
546
+ default=lambda ctx, data: ctx.get("name", "").replace("-", " ").replace("_", " ").title(),
547
+ description="Human-readable name (e.g., 'ACME Corporation')",
548
+ ),
549
+ FieldDef(
550
+ name="seller_type",
551
+ prompt_text="Seller type",
552
+ field_type="choice",
553
+ choices=["individual", "organization", "partnership", "corporation"],
554
+ default="individual",
555
+ description="Type of seller entity",
556
+ ),
557
+ ],
558
+ ),
559
+ FieldGroup(
560
+ name="contact",
561
+ title="Contact Information",
562
+ fields=[
563
+ FieldDef(
564
+ name="contact_email",
565
+ prompt_text="Primary contact email",
566
+ field_type="email",
567
+ required=True,
568
+ ),
569
+ FieldDef(
570
+ name="secondary_contact_email",
571
+ prompt_text="Secondary contact email",
572
+ field_type="email",
573
+ required=False,
574
+ ),
575
+ FieldDef(
576
+ name="homepage",
577
+ prompt_text="Homepage URL",
578
+ field_type="uri",
579
+ required=False,
580
+ ),
581
+ ],
582
+ ),
583
+ FieldGroup(
584
+ name="details",
585
+ title="Additional Details",
586
+ fields=[
587
+ FieldDef(
588
+ name="description",
589
+ prompt_text="Description",
590
+ field_type="string",
591
+ required=False,
592
+ default=lambda ctx, data: f"{ctx.get('name', 'seller')} - {data.get('seller_type', 'seller')}",
593
+ ),
594
+ FieldDef(
595
+ name="account_manager",
596
+ prompt_text="Account manager email",
597
+ field_type="email",
598
+ required=False,
599
+ description="Email of the user managing this seller account (must be a registered user)",
600
+ ),
601
+ FieldDef(
602
+ name="business_registration",
603
+ prompt_text="Business registration number",
604
+ field_type="string",
605
+ required=False,
606
+ skip_if=lambda ctx: ctx.get("seller_type") == "individual",
607
+ description="Required for organizations",
608
+ ),
609
+ FieldDef(
610
+ name="tax_id",
611
+ prompt_text="Tax ID (EIN, VAT, etc.)",
612
+ field_type="string",
613
+ required=False,
614
+ skip_if=lambda ctx: ctx.get("seller_type") == "individual",
615
+ ),
616
+ ],
617
+ ),
618
+ FieldGroup(
619
+ name="status",
620
+ title="Status & Verification",
621
+ fields=[
622
+ FieldDef(
623
+ name="status",
624
+ prompt_text="Status",
625
+ field_type="choice",
626
+ choices=["active", "pending", "disabled", "incomplete"],
627
+ default="active",
628
+ ),
629
+ FieldDef(
630
+ name="is_verified",
631
+ prompt_text="Is verified (KYC complete)?",
632
+ field_type="boolean",
633
+ default=False,
634
+ ),
635
+ ],
636
+ ),
637
+ ]
638
+
639
+
640
+ # =============================================================================
641
+ # PROVIDER FIELD REGISTRY
642
+ # =============================================================================
643
+
644
+ PROVIDER_GROUPS = [
645
+ FieldGroup(
646
+ name="basic",
647
+ title="Basic Information",
648
+ fields=[
649
+ FieldDef(
650
+ name="name",
651
+ prompt_text="Provider ID (URL-friendly)",
652
+ field_type="string",
653
+ required=True,
654
+ description="Unique identifier (e.g., 'openai', 'fireworks')",
655
+ ),
656
+ FieldDef(
657
+ name="display_name",
658
+ prompt_text="Display name",
659
+ field_type="string",
660
+ required=False,
661
+ default=lambda ctx, data: ctx.get("name", "").replace("-", " ").replace("_", " ").title(),
662
+ description="Human-readable name (e.g., 'OpenAI', 'Fireworks.ai')",
663
+ ),
664
+ FieldDef(
665
+ name="description",
666
+ prompt_text="Description",
667
+ field_type="string",
668
+ required=False,
669
+ ),
670
+ ],
671
+ ),
672
+ FieldGroup(
673
+ name="contact",
674
+ title="Contact & Web",
675
+ fields=[
676
+ FieldDef(
677
+ name="contact_email",
678
+ prompt_text="Contact email",
679
+ field_type="email",
680
+ required=True,
681
+ ),
682
+ FieldDef(
683
+ name="secondary_contact_email",
684
+ prompt_text="Secondary contact email",
685
+ field_type="email",
686
+ required=False,
687
+ ),
688
+ FieldDef(
689
+ name="homepage",
690
+ prompt_text="Homepage URL",
691
+ field_type="uri",
692
+ required=True,
693
+ ),
694
+ ],
695
+ ),
696
+ FieldGroup(
697
+ name="access",
698
+ title="Provider Access (API Credentials)",
699
+ fields=[
700
+ FieldDef(
701
+ name="api_endpoint",
702
+ prompt_text="API endpoint URL",
703
+ field_type="uri",
704
+ required=True,
705
+ description="Base URL for API access (e.g., 'https://api.openai.com/v1')",
706
+ ),
707
+ FieldDef(
708
+ name="api_key",
709
+ prompt_text="API key (optional, can be set later)",
710
+ field_type="string",
711
+ required=False,
712
+ description="Leave empty if you'll set it later or use env var",
713
+ ),
714
+ FieldDef(
715
+ name="access_method",
716
+ prompt_text="Access method",
717
+ field_type="choice",
718
+ choices=["http", "websocket", "grpc"],
719
+ default="http",
720
+ ),
721
+ ],
722
+ ),
723
+ FieldGroup(
724
+ name="status",
725
+ title="Status",
726
+ fields=[
727
+ FieldDef(
728
+ name="status",
729
+ prompt_text="Provider status",
730
+ field_type="choice",
731
+ choices=["active", "pending", "disabled", "incomplete"],
732
+ default="active",
733
+ ),
734
+ ],
735
+ ),
736
+ FieldGroup(
737
+ name="automation",
738
+ title="Service Population (Optional)",
739
+ fields=[
740
+ FieldDef(
741
+ name="enable_services_populator",
742
+ prompt_text="Enable automated service population?",
743
+ field_type="boolean",
744
+ required=False,
745
+ default=False,
746
+ description="Use a script to automatically populate service offerings and listings",
747
+ ),
748
+ FieldDef(
749
+ name="populator_command",
750
+ prompt_text="Populator script command",
751
+ field_type="string",
752
+ required=False,
753
+ skip_if=lambda ctx: not ctx.get("enable_services_populator", False),
754
+ description="Command to execute (e.g., 'python scripts/populate.py'). Run by 'usvc populate'",
755
+ ),
756
+ ],
757
+ ),
758
+ ]
759
+
760
+
761
+ # =============================================================================
762
+ # SERVICE OFFERING FIELD REGISTRY
763
+ # =============================================================================
764
+
765
+ OFFERING_GROUPS = [
766
+ FieldGroup(
767
+ name="basic",
768
+ title="Basic Information",
769
+ fields=[
770
+ FieldDef(
771
+ name="name",
772
+ prompt_text="Service name (e.g., 'gpt-4', 'llama-3-1-405b')",
773
+ field_type="string",
774
+ required=True,
775
+ description="Usually the model name or service identifier",
776
+ ),
777
+ FieldDef(
778
+ name="display_name",
779
+ prompt_text="Display name",
780
+ field_type="string",
781
+ required=True,
782
+ default=lambda ctx, data: ctx.get("name", "").replace("-", " ").title(),
783
+ ),
784
+ FieldDef(
785
+ name="version",
786
+ prompt_text="Version",
787
+ field_type="string",
788
+ required=False,
789
+ default=None,
790
+ ),
791
+ FieldDef(
792
+ name="description",
793
+ prompt_text="Description",
794
+ field_type="string",
795
+ required=True,
796
+ description="Brief description of the service",
797
+ ),
798
+ ],
799
+ ),
800
+ FieldGroup(
801
+ name="classification",
802
+ title="Service Classification",
803
+ fields=[
804
+ FieldDef(
805
+ name="service_type",
806
+ prompt_text="Service type",
807
+ field_type="choice",
808
+ choices=["llm", "embedding", "vision", "audio", "image", "video", "other"],
809
+ default="llm",
810
+ required=True,
811
+ ),
812
+ FieldDef(
813
+ name="upstream_status",
814
+ prompt_text="Upstream status",
815
+ field_type="choice",
816
+ choices=["uploading", "ready", "deprecated"],
817
+ default="ready",
818
+ ),
819
+ ],
820
+ ),
821
+ FieldGroup(
822
+ name="access",
823
+ title="Upstream Access Interface",
824
+ fields=[
825
+ FieldDef(
826
+ name="upstream_api_endpoint",
827
+ prompt_text="Upstream API endpoint URL",
828
+ field_type="uri",
829
+ required=True,
830
+ description="Base URL for accessing this service upstream",
831
+ ),
832
+ FieldDef(
833
+ name="upstream_api_key",
834
+ prompt_text="Upstream API key (optional)",
835
+ field_type="string",
836
+ required=False,
837
+ description="Leave empty if using provider's API key",
838
+ ),
839
+ FieldDef(
840
+ name="add_upstream_documents",
841
+ prompt_text="Add documents to upstream access interface?",
842
+ field_type="boolean",
843
+ default=False,
844
+ description="API docs, code examples, etc. for accessing the upstream service",
845
+ ),
846
+ ],
847
+ ),
848
+ FieldGroup(
849
+ name="pricing",
850
+ title="Upstream Pricing (Optional)",
851
+ fields=[
852
+ FieldDef(
853
+ name="add_upstream_pricing",
854
+ prompt_text="Add upstream pricing information?",
855
+ field_type="boolean",
856
+ default=False,
857
+ description="How the upstream provider charges for this service",
858
+ ),
859
+ ],
860
+ ),
861
+ FieldGroup(
862
+ name="additional",
863
+ title="Additional Information (Optional)",
864
+ fields=[
865
+ FieldDef(
866
+ name="tagline",
867
+ prompt_text="Tagline",
868
+ field_type="string",
869
+ required=False,
870
+ description="Short elevator pitch for the service",
871
+ ),
872
+ ],
873
+ ),
874
+ ]
875
+
876
+
877
+ # =============================================================================
878
+ # SERVICE LISTING FIELD REGISTRY
879
+ # =============================================================================
880
+
881
+ LISTING_GROUPS = [
882
+ FieldGroup(
883
+ name="basic",
884
+ title="Basic Information",
885
+ fields=[
886
+ FieldDef(
887
+ name="service_name",
888
+ prompt_text="Service name (must match service.json)",
889
+ field_type="string",
890
+ required=False,
891
+ default=lambda ctx, data: find_service_name(),
892
+ description="Auto-detected from service.json in current directory",
893
+ ),
894
+ FieldDef(
895
+ name="name",
896
+ prompt_text="Listing identifier",
897
+ field_type="string",
898
+ required=False,
899
+ description="Name identifier for the service listing (defaults to filename)",
900
+ ),
901
+ FieldDef(
902
+ name="display_name",
903
+ prompt_text="Display name",
904
+ field_type="string",
905
+ required=False,
906
+ description="Human-readable listing name (e.g., 'Premium GPT-4 Access')",
907
+ ),
908
+ ],
909
+ ),
910
+ FieldGroup(
911
+ name="seller",
912
+ title="Seller Information",
913
+ fields=[
914
+ FieldDef(
915
+ name="seller_name",
916
+ prompt_text="Seller name (must match seller.json)",
917
+ field_type="string",
918
+ required=False,
919
+ default=lambda ctx, data: find_seller_name(),
920
+ description="Auto-detected from seller.json in data directory",
921
+ ),
922
+ ],
923
+ ),
924
+ FieldGroup(
925
+ name="status",
926
+ title="Status",
927
+ fields=[
928
+ FieldDef(
929
+ name="listing_status",
930
+ prompt_text="Listing status",
931
+ field_type="choice",
932
+ choices=["unknown", "upstream_ready", "downstream_ready", "ready", "in_service", "deprecated"],
933
+ default="unknown",
934
+ ),
935
+ ],
936
+ ),
937
+ FieldGroup(
938
+ name="documents",
939
+ title="Documents (Optional)",
940
+ fields=[
941
+ FieldDef(
942
+ name="add_documents",
943
+ prompt_text="Add documents (SLA, guides, etc.)?",
944
+ field_type="boolean",
945
+ default=False,
946
+ description="Documents provide additional information about the listing",
947
+ ),
948
+ ],
949
+ ),
950
+ ]
951
+
952
+
953
+ # =============================================================================
954
+ # HELPER FUNCTIONS
955
+ # =============================================================================
956
+
957
+
958
+ def create_seller_data(user_input: dict[str, Any]) -> dict[str, Any]:
959
+ """Create seller data structure from user input.
960
+
961
+ Args:
962
+ user_input: User-provided field values
963
+
964
+ Returns:
965
+ Complete seller data dictionary
966
+ """
967
+ data = {
968
+ "schema": "seller_v1",
969
+ "time_created": datetime.now(UTC).isoformat().replace("+00:00", "Z"),
970
+ }
971
+
972
+ # Add all non-None user input
973
+ for key, value in user_input.items():
974
+ if value is not None:
975
+ data[key] = value
976
+
977
+ return data
978
+
979
+
980
+ def create_provider_data(user_input: dict[str, Any]) -> dict[str, Any]:
981
+ """Create provider data structure from user input.
982
+
983
+ Args:
984
+ user_input: User-provided field values
985
+
986
+ Returns:
987
+ Complete provider data dictionary
988
+ """
989
+ # Extract access interface fields
990
+ access_fields = ["api_endpoint", "api_key", "access_method"]
991
+ provider_access_info: dict[str, Any] = {}
992
+
993
+ for key in access_fields:
994
+ if key in user_input and user_input[key] is not None:
995
+ provider_access_info[key] = user_input[key]
996
+
997
+ # Extract services_populator fields
998
+ populator_fields = ["enable_services_populator", "populator_command"]
999
+ services_populator: dict[str, Any] | None = None
1000
+
1001
+ if user_input.get("enable_services_populator") and user_input.get("populator_command"):
1002
+ services_populator = {"command": user_input["populator_command"]}
1003
+
1004
+ # Create base data
1005
+ data = {
1006
+ "schema": "provider_v1",
1007
+ "time_created": datetime.now(UTC).isoformat().replace("+00:00", "Z"),
1008
+ "provider_access_info": provider_access_info,
1009
+ }
1010
+
1011
+ # Add services_populator if configured
1012
+ if services_populator:
1013
+ data["services_populator"] = services_populator
1014
+
1015
+ # Add non-access, non-populator fields
1016
+ excluded_fields = access_fields + populator_fields
1017
+ for key, value in user_input.items():
1018
+ if key not in excluded_fields and value is not None:
1019
+ data[key] = value
1020
+
1021
+ return data
1022
+
1023
+
1024
+ def create_offering_data(user_input: dict[str, Any], offering_dir: Path | None = None) -> dict[str, Any]:
1025
+ """Create service offering data structure from user input.
1026
+
1027
+ Args:
1028
+ user_input: User-provided field values
1029
+ offering_dir: Directory where offering file will be created (for validating document file paths)
1030
+
1031
+ Returns:
1032
+ Complete service offering data dictionary
1033
+ """
1034
+ # Extract upstream access interface fields
1035
+ upstream_fields = ["upstream_api_endpoint", "upstream_api_key", "add_upstream_documents"]
1036
+ upstream_access_interface: dict[str, Any] = {}
1037
+
1038
+ for key in upstream_fields:
1039
+ # Map to the actual field names in AccessInterface
1040
+ if key == "upstream_api_endpoint" and user_input.get(key):
1041
+ upstream_access_interface["api_endpoint"] = user_input[key]
1042
+ elif key == "upstream_api_key" and user_input.get(key):
1043
+ upstream_access_interface["api_key"] = user_input[key]
1044
+
1045
+ # Handle documents for upstream access interface if user wants to add them
1046
+ if user_input.get("add_upstream_documents"):
1047
+ if offering_dir is None:
1048
+ console.print("[yellow]Warning: Cannot validate file paths without offering directory[/yellow]")
1049
+ offering_dir = Path.cwd()
1050
+
1051
+ console.print("\n[bold cyan]Add documents to upstream access interface[/bold cyan]")
1052
+ documents = []
1053
+ while True:
1054
+ doc = prompt_for_document(offering_dir)
1055
+ documents.append(doc)
1056
+
1057
+ if not Confirm.ask("\n[bold blue]Add another document?[/bold blue]", default=False):
1058
+ break
1059
+
1060
+ if documents:
1061
+ upstream_access_interface["documents"] = documents
1062
+
1063
+ # Handle upstream pricing if user wants to add it
1064
+ upstream_price = None
1065
+ if user_input.get("add_upstream_pricing"):
1066
+ upstream_price = prompt_for_pricing()
1067
+
1068
+ # Create base data
1069
+ data = {
1070
+ "schema": "service_v1",
1071
+ "time_created": datetime.now(UTC).isoformat().replace("+00:00", "Z"),
1072
+ "upstream_access_interface": upstream_access_interface,
1073
+ "details": {}, # Required field, user can add details manually later
1074
+ }
1075
+
1076
+ # Add upstream price if provided
1077
+ if upstream_price:
1078
+ data["upstream_price"] = upstream_price
1079
+
1080
+ # Add non-upstream fields (exclude add_upstream_documents and add_upstream_pricing which are just flags)
1081
+ excluded_fields = upstream_fields + ["add_upstream_pricing"]
1082
+ for key, value in user_input.items():
1083
+ if key not in excluded_fields and value is not None:
1084
+ data[key] = value
1085
+
1086
+ return data
1087
+
1088
+
1089
+ def create_listing_data(user_input: dict[str, Any], listing_dir: Path | None = None) -> dict[str, Any]:
1090
+ """Create service listing data structure from user input.
1091
+
1092
+ Args:
1093
+ user_input: User-provided field values
1094
+ listing_dir: Directory where listing file will be created (for validating document file paths)
1095
+
1096
+ Returns:
1097
+ Complete service listing data dictionary
1098
+ """
1099
+ data: dict[str, Any] = {
1100
+ "schema": "listing_v1",
1101
+ "time_created": datetime.now(UTC).isoformat().replace("+00:00", "Z"),
1102
+ "user_access_interfaces": [], # Required field, user must add interfaces manually
1103
+ "user_price": None, # Optional, can be added later
1104
+ }
1105
+
1106
+ # Handle documents if user wants to add them
1107
+ documents = []
1108
+ if user_input.get("add_documents"):
1109
+ if listing_dir is None:
1110
+ console.print("[yellow]Warning: Cannot validate file paths without listing directory[/yellow]")
1111
+ listing_dir = Path.cwd()
1112
+
1113
+ console.print("\n[bold cyan]Add documents to listing[/bold cyan]")
1114
+ while True:
1115
+ doc = prompt_for_document(listing_dir)
1116
+ documents.append(doc)
1117
+
1118
+ if not Confirm.ask("\n[bold blue]Add another document?[/bold blue]", default=False):
1119
+ break
1120
+
1121
+ if documents:
1122
+ data["documents"] = documents
1123
+
1124
+ # Add all non-None user input (except add_documents which is just a flag)
1125
+ for key, value in user_input.items():
1126
+ if key != "add_documents" and value is not None:
1127
+ data[key] = value
1128
+
1129
+ return data