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.
- lyceum/__init__.py +0 -0
- lyceum/external/__init__.py +0 -0
- lyceum/external/auth/__init__.py +0 -0
- lyceum/external/auth/login.py +462 -0
- lyceum/external/compute/__init__.py +0 -0
- lyceum/external/compute/execution/__init__.py +0 -0
- lyceum/external/compute/execution/python.py +99 -0
- lyceum/external/compute/inference/__init__.py +2 -0
- lyceum/external/compute/inference/batch.py +315 -0
- lyceum/external/compute/inference/chat.py +220 -0
- lyceum/external/compute/inference/models.py +242 -0
- lyceum/external/general/__init__.py +0 -0
- lyceum/main.py +52 -0
- lyceum/shared/__init__.py +0 -0
- lyceum/shared/config.py +144 -0
- lyceum/shared/display.py +195 -0
- lyceum/shared/streaming.py +134 -0
- {lyceum_cli-1.0.14.dist-info → lyceum_cli-1.0.18.dist-info}/METADATA +1 -1
- lyceum_cli-1.0.18.dist-info/RECORD +25 -0
- lyceum_cli-1.0.18.dist-info/top_level.txt +2 -0
- lyceum_cloud_execution_api_client/__init__.py +0 -0
- lyceum_cloud_execution_api_client/api/__init__.py +0 -0
- lyceum_cloud_execution_api_client/models/__init__.py +105 -0
- lyceum_cli-1.0.14.dist-info/RECORD +0 -5
- lyceum_cli-1.0.14.dist-info/top_level.txt +0 -1
- {lyceum_cli-1.0.14.dist-info → lyceum_cli-1.0.18.dist-info}/WHEEL +0 -0
- {lyceum_cli-1.0.14.dist-info → lyceum_cli-1.0.18.dist-info}/entry_points.txt +0 -0
|
@@ -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
|
lyceum/shared/config.py
ADDED
|
@@ -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()
|
lyceum/shared/display.py
ADDED
|
@@ -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 '⚪'
|