lyceum-cli 1.0.14__py3-none-any.whl → 1.0.18__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,242 @@
1
+ """
2
+ Model discovery and information commands for AI inference
3
+ """
4
+
5
+ from typing import Optional
6
+ import typer
7
+ import httpx
8
+ from rich.console import Console
9
+ from rich.table import Table
10
+ from rich.panel import Panel
11
+
12
+ from ....shared.config import config
13
+
14
+ console = Console()
15
+
16
+ models_app = typer.Typer(name="models", help="Model discovery and information")
17
+
18
+
19
+ @models_app.command("list")
20
+ def list_models(
21
+ model_type: Optional[str] = typer.Option(None, "--type", "-t", help="Filter by model type (text, image, multimodal, etc.)"),
22
+ available_only: bool = typer.Option(False, "--available", "-a", help="Show only available models"),
23
+ sync_only: bool = typer.Option(False, "--sync", help="Show only models that support synchronous inference"),
24
+ async_only: bool = typer.Option(False, "--async", help="Show only models that support async/batch inference"),
25
+ ):
26
+ """List all available AI models"""
27
+ try:
28
+ # Determine the endpoint based on filters
29
+ if sync_only:
30
+ endpoint = "/api/v2/external/models/sync"
31
+ elif async_only:
32
+ endpoint = "/api/v2/external/models/async"
33
+ elif model_type:
34
+ endpoint = f"/api/v2/external/models/type/{model_type}"
35
+ else:
36
+ endpoint = "/api/v2/external/models/"
37
+
38
+ url = f"{config.base_url}{endpoint}"
39
+ headers = {"Authorization": f"Bearer {config.api_key}"}
40
+
41
+ console.print("[dim]🔍 Fetching available models...[/dim]")
42
+
43
+ with httpx.Client() as http_client:
44
+ response = http_client.get(url, headers=headers, timeout=10.0)
45
+
46
+ if response.status_code != 200:
47
+ console.print(f"[red]❌ Error: HTTP {response.status_code}[/red]")
48
+ console.print(f"[red]{response.text}[/red]")
49
+ raise typer.Exit(1)
50
+
51
+ models = response.json()
52
+
53
+ # Filter by availability if requested
54
+ if available_only:
55
+ models = [m for m in models if m.get('available', False)]
56
+
57
+ if not models:
58
+ console.print("[yellow]⚠️ No models found matching your criteria[/yellow]")
59
+ return
60
+
61
+ # Create a detailed table
62
+ table = Table(title="Available AI Models", show_header=True, header_style="bold cyan")
63
+ table.add_column("Model ID", style="cyan", no_wrap=True)
64
+ table.add_column("Name", style="white")
65
+ table.add_column("Type", style="magenta")
66
+ table.add_column("Provider", style="blue")
67
+ table.add_column("Status", justify="center")
68
+ table.add_column("Price/1K", justify="right", style="green")
69
+ table.add_column("Sync", justify="center", style="yellow")
70
+ table.add_column("Async", justify="center", style="yellow")
71
+
72
+ # Sort models: available first, then by type, then by name
73
+ sorted_models = sorted(models, key=lambda m: (
74
+ not m.get('available', False),
75
+ m.get('type', 'text'),
76
+ m.get('model_id', '')
77
+ ))
78
+
79
+ for model in sorted_models:
80
+ # Status with emoji
81
+ status = "🟢 Yes" if model.get('available') else "🔴 No"
82
+
83
+ # Sync/Async support
84
+ sync_support = "✓" if model.get('supports_sync', True) else "✗"
85
+ async_support = "✓" if model.get('supports_async', True) else "✗"
86
+
87
+ # Price
88
+ price = model.get('price_per_1k_tokens', 0)
89
+ price_str = f"${price:.4f}" if price > 0 else "Free"
90
+
91
+ table.add_row(
92
+ model.get('model_id', 'Unknown'),
93
+ model.get('name', 'Unknown'),
94
+ model.get('type', 'text').title(),
95
+ model.get('provider', 'unknown').title(),
96
+ status,
97
+ price_str,
98
+ sync_support,
99
+ async_support
100
+ )
101
+
102
+ console.print(table)
103
+
104
+ # Show summary
105
+ available_count = sum(1 for m in models if m.get('available'))
106
+ total_count = len(models)
107
+ console.print(f"\n[dim]📊 {available_count}/{total_count} models available[/dim]")
108
+
109
+ except Exception as e:
110
+ console.print(f"[red]❌ Error: {e}[/red]")
111
+ raise typer.Exit(1)
112
+
113
+
114
+ @models_app.command("info")
115
+ def get_model_info(
116
+ model_id: str = typer.Argument(..., help="Model ID to get information about"),
117
+ ):
118
+ """Get detailed information about a specific model"""
119
+ try:
120
+ url = f"{config.base_url}/api/v2/external/models/{model_id}"
121
+ headers = {"Authorization": f"Bearer {config.api_key}"}
122
+
123
+ console.print(f"[dim]🔍 Fetching info for model: {model_id}...[/dim]")
124
+
125
+ with httpx.Client() as http_client:
126
+ response = http_client.get(url, headers=headers, timeout=10.0)
127
+
128
+ if response.status_code == 404:
129
+ console.print(f"[red]❌ Model '{model_id}' not found[/red]")
130
+ raise typer.Exit(1)
131
+ elif response.status_code != 200:
132
+ console.print(f"[red]❌ Error: HTTP {response.status_code}[/red]")
133
+ console.print(f"[red]{response.text}[/red]")
134
+ raise typer.Exit(1)
135
+
136
+ model = response.json()
137
+
138
+ # Build detailed info display
139
+ status_color = "green" if model.get('available') else "red"
140
+ status_text = "Available ✓" if model.get('available') else "Unavailable ✗"
141
+
142
+ info_lines = [
143
+ f"[bold cyan]Model ID:[/bold cyan] {model.get('model_id', 'Unknown')}",
144
+ f"[bold]Name:[/bold] {model.get('name', 'Unknown')}",
145
+ f"[bold]Description:[/bold] {model.get('description', 'No description available')}",
146
+ "",
147
+ f"[bold]Type:[/bold] {model.get('type', 'text').title()}",
148
+ f"[bold]Provider:[/bold] {model.get('provider', 'unknown').title()}",
149
+ f"[bold]Version:[/bold] {model.get('version', 'N/A')}",
150
+ f"[bold]Status:[/bold] [{status_color}]{status_text}[/{status_color}]",
151
+ "",
152
+ f"[bold yellow]Capabilities:[/bold yellow]",
153
+ f" • Synchronous inference: {'Yes ✓' if model.get('supports_sync', True) else 'No ✗'}",
154
+ f" • Asynchronous/Batch: {'Yes ✓' if model.get('supports_async', True) else 'No ✗'}",
155
+ f" • GPU Required: {'Yes' if model.get('gpu_required', False) else 'No'}",
156
+ "",
157
+ f"[bold green]Input/Output:[/bold green]",
158
+ f" • Input types: {', '.join(model.get('input_types', []))}",
159
+ f" • Output types: {', '.join(model.get('output_types', []))}",
160
+ f" • Max input tokens: {model.get('max_input_tokens', 'N/A'):,}",
161
+ f" • Max output tokens: {model.get('max_output_tokens', 'N/A'):,}",
162
+ "",
163
+ f"[bold green]Pricing:[/bold green]",
164
+ f" • Base price: ${model.get('price_per_1k_tokens', 0):.4f} per 1K tokens",
165
+ f" • Batch discount: {model.get('batch_pricing_discount', 0.5) * 100:.0f}% off",
166
+ "",
167
+ f"[bold blue]Performance:[/bold blue]",
168
+ f" • Estimated latency: {model.get('estimated_latency_ms', 0):,} ms",
169
+ ]
170
+
171
+ panel = Panel(
172
+ "\n".join(info_lines),
173
+ title=f"[bold white]Model Information: {model.get('name', model_id)}[/bold white]",
174
+ border_style="cyan",
175
+ padding=(1, 2)
176
+ )
177
+
178
+ console.print(panel)
179
+
180
+ except Exception as e:
181
+ console.print(f"[red]❌ Error: {e}[/red]")
182
+ raise typer.Exit(1)
183
+
184
+
185
+ @models_app.command("instances")
186
+ def list_model_instances(
187
+ model_id: str = typer.Argument(..., help="Model ID to list instances for"),
188
+ ):
189
+ """List running instances for a specific model"""
190
+ try:
191
+ url = f"{config.base_url}/api/v2/external/models/{model_id}/instances"
192
+ headers = {"Authorization": f"Bearer {config.api_key}"}
193
+
194
+ console.print(f"[dim]🔍 Fetching instances for model: {model_id}...[/dim]")
195
+
196
+ with httpx.Client() as http_client:
197
+ response = http_client.get(url, headers=headers, timeout=10.0)
198
+
199
+ if response.status_code == 404:
200
+ console.print(f"[red]❌ Model '{model_id}' not found[/red]")
201
+ raise typer.Exit(1)
202
+ elif response.status_code != 200:
203
+ console.print(f"[red]❌ Error: HTTP {response.status_code}[/red]")
204
+ console.print(f"[red]{response.text}[/red]")
205
+ raise typer.Exit(1)
206
+
207
+ instances = response.json()
208
+
209
+ if not instances:
210
+ console.print(f"[yellow]⚠️ No instances found for model '{model_id}'[/yellow]")
211
+ return
212
+
213
+ # Create table for instances
214
+ table = Table(title=f"Instances for {model_id}", show_header=True, header_style="bold cyan")
215
+ table.add_column("Instance ID", style="cyan", no_wrap=True)
216
+ table.add_column("Instance URL", style="blue")
217
+ table.add_column("Status", justify="center", style="green")
218
+ table.add_column("Node ID", style="magenta")
219
+ table.add_column("Last Health Check", style="dim")
220
+
221
+ for instance in instances:
222
+ # Truncate instance ID for display
223
+ instance_id = instance.get('id', 'Unknown')
224
+ short_id = instance_id[:12] + "..." if len(instance_id) > 12 else instance_id
225
+
226
+ status = instance.get('status', 'unknown')
227
+ status_emoji = "🟢" if status == "running" else "🔴"
228
+
229
+ table.add_row(
230
+ short_id,
231
+ instance.get('instance_url', 'N/A'),
232
+ f"{status_emoji} {status}",
233
+ instance.get('node_id', 'N/A') or 'N/A',
234
+ instance.get('last_health_check', 'N/A') or 'N/A'
235
+ )
236
+
237
+ console.print(table)
238
+ console.print(f"\n[dim]📊 Total instances: {len(instances)}[/dim]")
239
+
240
+ except Exception as e:
241
+ console.print(f"[red]❌ Error: {e}[/red]")
242
+ raise typer.Exit(1)
File without changes
lyceum/main.py ADDED
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Lyceum CLI - Command-line interface for Lyceum Cloud Execution API
4
+ Refactored to match API directory structure
5
+ """
6
+
7
+ import typer
8
+ from rich.console import Console
9
+
10
+ # Import all command modules
11
+ from .external.auth.login import auth_app
12
+ from .external.compute.execution.python import python_app
13
+ from .external.compute.inference.batch import batch_app
14
+ from .external.compute.inference.chat import chat_app
15
+ from .external.compute.inference.models import models_app
16
+
17
+ app = typer.Typer(
18
+ name="lyceum",
19
+ help="Lyceum Cloud Execution CLI",
20
+ add_completion=False,
21
+ )
22
+
23
+ console = Console()
24
+
25
+ # Add all command groups
26
+ app.add_typer(auth_app, name="auth")
27
+ app.add_typer(python_app, name="python")
28
+ app.add_typer(batch_app, name="batch")
29
+ app.add_typer(chat_app, name="chat")
30
+ app.add_typer(models_app, name="models")
31
+
32
+ # Legacy aliases for backward compatibility
33
+
34
+
35
+
36
+
37
+
38
+
39
+
40
+
41
+
42
+
43
+
44
+
45
+
46
+
47
+
48
+
49
+
50
+
51
+ if __name__ == "__main__":
52
+ app()
File without changes
@@ -0,0 +1,144 @@
1
+ """
2
+ Configuration management for Lyceum CLI
3
+ """
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Optional
8
+ import jwt
9
+ import time
10
+ import requests
11
+
12
+ from rich.console import Console
13
+ import typer
14
+ from supabase import create_client
15
+
16
+ # Add the generated client to the path
17
+ import sys
18
+ sys.path.insert(0, str(Path(__file__).parent.parent.parent / "lyceum-cloud-execution-api-client"))
19
+
20
+ # Commented out - using httpx directly instead
21
+ # from lyceum_cloud_execution_api_client.client import AuthenticatedClient
22
+
23
+ console = Console()
24
+
25
+ # Configuration
26
+ CONFIG_DIR = Path.home() / ".lyceum"
27
+ CONFIG_FILE = CONFIG_DIR / "config.json"
28
+
29
+
30
+ class Config:
31
+ """Configuration management for Lyceum CLI"""
32
+
33
+ def __init__(self):
34
+ self.api_key: Optional[str] = None
35
+ self.base_url: str = "https://api.lyceum.technology" # Production API URL
36
+ self.refresh_token: Optional[str] = None
37
+ self.dashboard_url: str = "https://dashboard.lyceum.technology"
38
+ self.load()
39
+
40
+ def load(self):
41
+ """Load configuration from file"""
42
+ if CONFIG_FILE.exists():
43
+ try:
44
+ with open(CONFIG_FILE) as f:
45
+ data = json.load(f)
46
+ self.api_key = data.get("api_key")
47
+ self.base_url = data.get("base_url", self.base_url)
48
+ self.refresh_token = data.get("refresh_token")
49
+ self.dashboard_url = data.get("dashboard_url", self.dashboard_url)
50
+ except Exception as e:
51
+ console.print(f"[yellow]Warning: Could not load config: {e}[/yellow]")
52
+
53
+ def save(self):
54
+ """Save configuration to file"""
55
+ CONFIG_DIR.mkdir(exist_ok=True)
56
+ with open(CONFIG_FILE, "w") as f:
57
+ json.dump({
58
+ "api_key": self.api_key,
59
+ "base_url": self.base_url,
60
+ "refresh_token": self.refresh_token,
61
+ "dashboard_url": self.dashboard_url,
62
+ }, f, indent=2)
63
+
64
+ def is_token_expired(self) -> bool:
65
+ """Check if the current token is expired or will expire soon (within 5 minutes)"""
66
+ if not self.api_key:
67
+ return True
68
+
69
+ # Legacy API keys (starting with lk_) don't expire
70
+ if self.api_key.startswith('lk_'):
71
+ return False
72
+
73
+ try:
74
+ # Decode without verification to get expiration
75
+ decoded = jwt.decode(self.api_key, options={"verify_signature": False})
76
+ exp = decoded.get('exp', 0)
77
+
78
+ # Check if token expires within 5 minutes (300 seconds)
79
+ current_time = time.time()
80
+ return (exp - current_time) <= 300
81
+
82
+ except Exception:
83
+ # If we can't decode the token, assume it's expired
84
+ return True
85
+
86
+ def refresh_access_token(self) -> bool:
87
+ """Attempt to refresh the access token using Supabase refresh token"""
88
+ if not self.refresh_token:
89
+ return False
90
+
91
+ # Only refresh JWT tokens, not legacy API keys
92
+ if self.api_key and self.api_key.startswith('lk_'):
93
+ return False
94
+
95
+ try:
96
+ # Use Supabase's refresh_session method with the service role key
97
+ supabase_url = "https://tqcebgbexyszvqhnwnhh.supabase.co"
98
+ supabase_service_key = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InRxY2ViZ2JleHlzenZxaG53bmhoIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc0NzE1NDQ3MSwiZXhwIjoyMDYyNzMwNDcxfQ.RpxhmMxFKJSJERobr28bmaZOG9Fxe-qthTYlg8iyFdc"
99
+
100
+ supabase = create_client(supabase_url, supabase_service_key)
101
+
102
+ # Use refresh_session method
103
+ response = supabase.auth.refresh_session(refresh_token=self.refresh_token)
104
+
105
+ if response.session and response.session.access_token:
106
+ # Successfully refreshed
107
+ self.api_key = response.session.access_token
108
+ if response.session.refresh_token:
109
+ self.refresh_token = response.session.refresh_token
110
+ self.save()
111
+ console.print("[dim]🔄 Token refreshed automatically[/dim]")
112
+ return True
113
+ else:
114
+ # Refresh failed
115
+ error_msg = getattr(response, 'error', None)
116
+ if error_msg:
117
+ console.print(f"[yellow]⚠️ Token refresh failed: {error_msg.message}[/yellow]")
118
+ else:
119
+ console.print("[yellow]⚠️ Token refresh failed: No session returned[/yellow]")
120
+ return False
121
+
122
+ except Exception as e:
123
+ console.print(f"[yellow]⚠️ Token refresh error: {e}[/yellow]")
124
+ return False
125
+
126
+ def get_client(self):
127
+ """Get authenticated API client with automatic token refresh"""
128
+ if not self.api_key:
129
+ console.print("[red]Error: Not authenticated. Run 'lyceum login' first.[/red]")
130
+ raise typer.Exit(1)
131
+
132
+ # Check if token is expired and try to refresh
133
+ if self.is_token_expired():
134
+ console.print("[dim]🔄 Token expired, attempting refresh...[/dim]")
135
+ if not self.refresh_access_token():
136
+ console.print("[red]❌ Token refresh failed. Please run 'lyceum login' again.[/red]")
137
+ raise typer.Exit(1)
138
+
139
+ # Return config instance - commands use httpx directly
140
+ return self
141
+
142
+
143
+ # Global config instance
144
+ config = Config()
@@ -0,0 +1,195 @@
1
+ """
2
+ Display utilities for CLI output formatting
3
+ """
4
+
5
+ from rich.table import Table
6
+ from datetime import datetime
7
+ from typing import Optional
8
+
9
+
10
+ def create_table(title: str, columns: list[tuple[str, str]]) -> Table:
11
+ """
12
+ Create a Rich table with the given title and columns.
13
+
14
+ Args:
15
+ title: Table title
16
+ columns: List of (column_name, style) tuples
17
+
18
+ Returns:
19
+ Rich Table instance
20
+ """
21
+ table = Table(title=title)
22
+ for col_name, style in columns:
23
+ table.add_column(col_name, style=style)
24
+ return table
25
+
26
+
27
+ def format_timestamp(timestamp: str | int | datetime, relative: bool = False) -> str:
28
+ """
29
+ Format a timestamp for display.
30
+
31
+ Args:
32
+ timestamp: Unix timestamp (int/float), ISO string, or datetime object
33
+ relative: If True, show relative time (e.g., "2 hours ago")
34
+
35
+ Returns:
36
+ Formatted timestamp string
37
+ """
38
+ try:
39
+ # Convert to datetime if needed
40
+ if isinstance(timestamp, (int, float)):
41
+ dt = datetime.fromtimestamp(timestamp)
42
+ elif isinstance(timestamp, str):
43
+ # Try parsing ISO format
44
+ dt = datetime.fromisoformat(timestamp.replace('Z', '+00:00'))
45
+ elif isinstance(timestamp, datetime):
46
+ dt = timestamp
47
+ else:
48
+ return str(timestamp)
49
+
50
+ if relative:
51
+ # Calculate relative time
52
+ now = datetime.now(dt.tzinfo) if dt.tzinfo else datetime.now()
53
+ diff = now - dt
54
+
55
+ seconds = diff.total_seconds()
56
+ if seconds < 60:
57
+ return f"{int(seconds)}s ago"
58
+ elif seconds < 3600:
59
+ return f"{int(seconds / 60)}m ago"
60
+ elif seconds < 86400:
61
+ return f"{int(seconds / 3600)}h ago"
62
+ elif seconds < 604800:
63
+ return f"{int(seconds / 86400)}d ago"
64
+ else:
65
+ return dt.strftime("%Y-%m-%d")
66
+ else:
67
+ # Return absolute time
68
+ return dt.strftime("%Y-%m-%d %H:%M:%S")
69
+
70
+ except Exception:
71
+ return str(timestamp)
72
+
73
+
74
+ def truncate_id(id_str: str, length: int = 8) -> str:
75
+ """
76
+ Truncate an ID string for display.
77
+
78
+ Args:
79
+ id_str: The ID string to truncate
80
+ length: Number of characters to keep from the start
81
+
82
+ Returns:
83
+ Truncated ID string
84
+ """
85
+ if not id_str:
86
+ return ""
87
+
88
+ if len(id_str) <= length:
89
+ return id_str
90
+
91
+ return f"{id_str[:length]}..."
92
+
93
+
94
+ def format_file_size(bytes: int) -> str:
95
+ """
96
+ Format file size in human-readable format.
97
+
98
+ Args:
99
+ bytes: Size in bytes
100
+
101
+ Returns:
102
+ Formatted size string (e.g., "1.5 MB")
103
+ """
104
+ for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
105
+ if bytes < 1024.0:
106
+ return f"{bytes:.1f} {unit}"
107
+ bytes /= 1024.0
108
+ return f"{bytes:.1f} PB"
109
+
110
+
111
+ def format_duration(seconds: float) -> str:
112
+ """
113
+ Format duration in human-readable format.
114
+
115
+ Args:
116
+ seconds: Duration in seconds
117
+
118
+ Returns:
119
+ Formatted duration string (e.g., "2h 15m 30s")
120
+ """
121
+ if seconds < 60:
122
+ return f"{seconds:.1f}s"
123
+
124
+ minutes = int(seconds / 60)
125
+ secs = int(seconds % 60)
126
+
127
+ if minutes < 60:
128
+ return f"{minutes}m {secs}s"
129
+
130
+ hours = int(minutes / 60)
131
+ mins = int(minutes % 60)
132
+
133
+ if hours < 24:
134
+ return f"{hours}h {mins}m"
135
+
136
+ days = int(hours / 24)
137
+ hrs = int(hours % 24)
138
+
139
+ return f"{days}d {hrs}h"
140
+
141
+
142
+ def status_color(status: str) -> str:
143
+ """
144
+ Get color for status display.
145
+
146
+ Args:
147
+ status: Status string
148
+
149
+ Returns:
150
+ Color name for Rich formatting
151
+ """
152
+ status_lower = status.lower()
153
+
154
+ if status_lower in ['completed', 'success', 'running']:
155
+ return 'green'
156
+ elif status_lower in ['failed', 'error', 'failed_user', 'failed_system']:
157
+ return 'red'
158
+ elif status_lower in ['pending', 'queued', 'in_progress', 'validating', 'finalizing']:
159
+ return 'yellow'
160
+ elif status_lower in ['cancelled', 'expired', 'timeout']:
161
+ return 'dim'
162
+ else:
163
+ return 'white'
164
+
165
+
166
+ def status_emoji(status: str) -> str:
167
+ """
168
+ Get emoji for status display.
169
+
170
+ Args:
171
+ status: Status string
172
+
173
+ Returns:
174
+ Emoji character
175
+ """
176
+ status_lower = status.lower()
177
+
178
+ if status_lower in ['completed', 'success']:
179
+ return '✅'
180
+ elif status_lower in ['failed', 'error', 'failed_user', 'failed_system']:
181
+ return '❌'
182
+ elif status_lower in ['running', 'in_progress']:
183
+ return '🔄'
184
+ elif status_lower in ['pending', 'queued']:
185
+ return '⏳'
186
+ elif status_lower in ['validating']:
187
+ return '🔍'
188
+ elif status_lower in ['finalizing']:
189
+ return '📦'
190
+ elif status_lower in ['cancelled']:
191
+ return '🛑'
192
+ elif status_lower in ['expired', 'timeout']:
193
+ return '⏰'
194
+ else:
195
+ return '⚪'