hypercli-cli 0.4.0__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.
- c3cli/__init__.py +1 -0
- c3cli/billing.py +60 -0
- c3cli/cli.py +183 -0
- c3cli/comfyui.py +823 -0
- c3cli/instances.py +193 -0
- c3cli/jobs.py +239 -0
- c3cli/llm.py +263 -0
- c3cli/output.py +78 -0
- c3cli/renders.py +192 -0
- c3cli/tui/__init__.py +4 -0
- c3cli/tui/job_monitor.py +335 -0
- c3cli/user.py +19 -0
- hypercli_cli-0.4.0.dist-info/METADATA +124 -0
- hypercli_cli-0.4.0.dist-info/RECORD +16 -0
- hypercli_cli-0.4.0.dist-info/WHEEL +4 -0
- hypercli_cli-0.4.0.dist-info/entry_points.txt +2 -0
c3cli/instances.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""c3 instances commands"""
|
|
2
|
+
import typer
|
|
3
|
+
from typing import Optional
|
|
4
|
+
from c3 import C3
|
|
5
|
+
from .output import output, console, success, spinner
|
|
6
|
+
|
|
7
|
+
app = typer.Typer(help="GPU instances - browse and launch")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def get_client() -> C3:
|
|
11
|
+
return C3()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@app.command("list")
|
|
15
|
+
def list_instances(
|
|
16
|
+
gpu: Optional[str] = typer.Option(None, "--gpu", "-g", help="Filter by GPU type"),
|
|
17
|
+
region: Optional[str] = typer.Option(None, "--region", "-r", help="Filter by region"),
|
|
18
|
+
fmt: str = typer.Option("table", "--output", "-o", help="Output format: table|json"),
|
|
19
|
+
):
|
|
20
|
+
"""List available GPU instances with pricing"""
|
|
21
|
+
c3 = get_client()
|
|
22
|
+
with spinner("Fetching instances..."):
|
|
23
|
+
available = c3.instances.list_available(gpu_type=gpu, region=region)
|
|
24
|
+
|
|
25
|
+
if fmt == "json":
|
|
26
|
+
output(available, "json")
|
|
27
|
+
else:
|
|
28
|
+
from rich.table import Table
|
|
29
|
+
table = Table(show_header=True, header_style="bold cyan")
|
|
30
|
+
table.add_column("GPU")
|
|
31
|
+
table.add_column("Count", justify="right")
|
|
32
|
+
table.add_column("Region")
|
|
33
|
+
table.add_column("Spot $/hr", justify="right")
|
|
34
|
+
table.add_column("On-Demand $/hr", justify="right")
|
|
35
|
+
table.add_column("vCPUs", justify="right")
|
|
36
|
+
table.add_column("RAM GB", justify="right")
|
|
37
|
+
|
|
38
|
+
for item in sorted(available, key=lambda x: (x["gpu_type"], x["gpu_count"], x.get("price_spot") or 999)):
|
|
39
|
+
spot_price = f"${item['price_spot']:.2f}" if item['price_spot'] else "-"
|
|
40
|
+
od_price = f"${item['price_on_demand']:.2f}" if item['price_on_demand'] else "-"
|
|
41
|
+
|
|
42
|
+
table.add_row(
|
|
43
|
+
f"[green]{item['gpu_type']}[/]",
|
|
44
|
+
str(item['gpu_count']),
|
|
45
|
+
f"{item['region']} ({item['region_name']})",
|
|
46
|
+
f"[cyan]{spot_price}[/]",
|
|
47
|
+
od_price,
|
|
48
|
+
str(int(item['cpu_cores'])),
|
|
49
|
+
str(int(item['memory_gb'])),
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
console.print(table)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@app.command("gpus")
|
|
56
|
+
def list_gpus(
|
|
57
|
+
region: Optional[str] = typer.Option(None, "--region", "-r", help="Filter by region"),
|
|
58
|
+
fmt: str = typer.Option("table", "--output", "-o", help="Output format: table|json"),
|
|
59
|
+
):
|
|
60
|
+
"""List available GPU types"""
|
|
61
|
+
c3 = get_client()
|
|
62
|
+
with spinner("Fetching GPU types..."):
|
|
63
|
+
types = c3.instances.types()
|
|
64
|
+
|
|
65
|
+
if fmt == "json":
|
|
66
|
+
output({k: {"name": v.name, "description": v.description, "configs": [
|
|
67
|
+
{"gpu_count": c.gpu_count, "cpu_cores": c.cpu_cores, "memory_gb": c.memory_gb, "regions": c.regions}
|
|
68
|
+
for c in v.configs
|
|
69
|
+
]} for k, v in types.items()}, "json")
|
|
70
|
+
else:
|
|
71
|
+
from rich.table import Table
|
|
72
|
+
table = Table(show_header=True, header_style="bold cyan")
|
|
73
|
+
table.add_column("GPU Type")
|
|
74
|
+
table.add_column("Name")
|
|
75
|
+
table.add_column("Description")
|
|
76
|
+
table.add_column("Counts")
|
|
77
|
+
table.add_column("Regions")
|
|
78
|
+
|
|
79
|
+
for gpu_id, gpu in types.items():
|
|
80
|
+
available_counts = []
|
|
81
|
+
available_regions = set()
|
|
82
|
+
for config in gpu.configs:
|
|
83
|
+
if config.regions:
|
|
84
|
+
if region and region not in config.regions:
|
|
85
|
+
continue
|
|
86
|
+
available_counts.append(str(config.gpu_count))
|
|
87
|
+
available_regions.update(config.regions)
|
|
88
|
+
|
|
89
|
+
if not available_counts:
|
|
90
|
+
continue
|
|
91
|
+
|
|
92
|
+
table.add_row(
|
|
93
|
+
f"[green]{gpu_id}[/]",
|
|
94
|
+
gpu.name,
|
|
95
|
+
gpu.description,
|
|
96
|
+
", ".join(available_counts),
|
|
97
|
+
", ".join(sorted(available_regions)),
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
console.print(table)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@app.command("regions")
|
|
104
|
+
def list_regions(
|
|
105
|
+
fmt: str = typer.Option("table", "--output", "-o", help="Output format: table|json"),
|
|
106
|
+
):
|
|
107
|
+
"""List available regions"""
|
|
108
|
+
c3 = get_client()
|
|
109
|
+
with spinner("Fetching regions..."):
|
|
110
|
+
regions = c3.instances.regions()
|
|
111
|
+
|
|
112
|
+
if fmt == "json":
|
|
113
|
+
output({k: {"description": v.description, "country": v.country} for k, v in regions.items()}, "json")
|
|
114
|
+
else:
|
|
115
|
+
from rich.table import Table
|
|
116
|
+
table = Table(show_header=True, header_style="bold cyan")
|
|
117
|
+
table.add_column("Code")
|
|
118
|
+
table.add_column("Location")
|
|
119
|
+
table.add_column("Country")
|
|
120
|
+
|
|
121
|
+
for region_id, region in regions.items():
|
|
122
|
+
table.add_row(
|
|
123
|
+
f"[green]{region_id}[/]",
|
|
124
|
+
region.description,
|
|
125
|
+
region.country,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
console.print(table)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@app.command("launch")
|
|
132
|
+
def launch(
|
|
133
|
+
image: str = typer.Argument(..., help="Docker image"),
|
|
134
|
+
command: Optional[str] = typer.Option(None, "--command", "-c", help="Command to run"),
|
|
135
|
+
gpu: str = typer.Option("l40s", "--gpu", "-g", help="GPU type"),
|
|
136
|
+
count: int = typer.Option(1, "--count", "-n", help="Number of GPUs"),
|
|
137
|
+
region: Optional[str] = typer.Option(None, "--region", "-r", help="Region code"),
|
|
138
|
+
runtime: Optional[int] = typer.Option(None, "--runtime", "-t", help="Runtime in seconds"),
|
|
139
|
+
interruptible: bool = typer.Option(True, "--interruptible/--on-demand", help="Use interruptible instances"),
|
|
140
|
+
env: Optional[list[str]] = typer.Option(None, "--env", "-e", help="Env vars (KEY=VALUE)"),
|
|
141
|
+
port: Optional[list[str]] = typer.Option(None, "--port", "-p", help="Ports (name:port)"),
|
|
142
|
+
follow: bool = typer.Option(False, "--follow", "-f", help="Follow logs after creation"),
|
|
143
|
+
fmt: str = typer.Option("table", "--output", "-o", help="Output format: table|json"),
|
|
144
|
+
):
|
|
145
|
+
"""Launch a new GPU instance"""
|
|
146
|
+
c3 = get_client()
|
|
147
|
+
|
|
148
|
+
# Parse env vars
|
|
149
|
+
env_dict = None
|
|
150
|
+
if env:
|
|
151
|
+
env_dict = {}
|
|
152
|
+
for e in env:
|
|
153
|
+
if "=" in e:
|
|
154
|
+
k, v = e.split("=", 1)
|
|
155
|
+
env_dict[k] = v
|
|
156
|
+
|
|
157
|
+
# Parse ports
|
|
158
|
+
ports_dict = None
|
|
159
|
+
if port:
|
|
160
|
+
ports_dict = {}
|
|
161
|
+
for p in port:
|
|
162
|
+
if ":" in p:
|
|
163
|
+
name, port_num = p.split(":", 1)
|
|
164
|
+
ports_dict[name] = int(port_num)
|
|
165
|
+
|
|
166
|
+
with spinner("Launching instance..."):
|
|
167
|
+
job = c3.jobs.create(
|
|
168
|
+
image=image,
|
|
169
|
+
command=command,
|
|
170
|
+
gpu_type=gpu,
|
|
171
|
+
gpu_count=count,
|
|
172
|
+
region=region,
|
|
173
|
+
runtime=runtime,
|
|
174
|
+
interruptible=interruptible,
|
|
175
|
+
env=env_dict,
|
|
176
|
+
ports=ports_dict,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
if fmt == "json":
|
|
180
|
+
output(job, "json")
|
|
181
|
+
else:
|
|
182
|
+
success(f"Instance launched: {job.job_id}")
|
|
183
|
+
console.print(f" State: {job.state}")
|
|
184
|
+
console.print(f" GPU: {job.gpu_type} x{job.gpu_count}")
|
|
185
|
+
console.print(f" Region: {job.region}")
|
|
186
|
+
console.print(f" Price: ${job.price_per_hour:.2f}/hr")
|
|
187
|
+
if job.hostname:
|
|
188
|
+
console.print(f" Hostname: {job.hostname}")
|
|
189
|
+
|
|
190
|
+
if follow:
|
|
191
|
+
console.print()
|
|
192
|
+
from .tui.job_monitor import run_job_monitor
|
|
193
|
+
run_job_monitor(job.job_id)
|
c3cli/jobs.py
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""c3 jobs commands"""
|
|
2
|
+
import typer
|
|
3
|
+
from typing import Optional
|
|
4
|
+
from c3 import C3
|
|
5
|
+
from .output import output, console, success, spinner
|
|
6
|
+
|
|
7
|
+
app = typer.Typer(help="Manage running jobs")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def get_client() -> C3:
|
|
11
|
+
return C3()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@app.command("list")
|
|
15
|
+
def list_jobs(
|
|
16
|
+
state: Optional[str] = typer.Option(None, "--state", "-s", help="Filter by state"),
|
|
17
|
+
fmt: str = typer.Option("table", "--output", "-o", help="Output format: table|json"),
|
|
18
|
+
):
|
|
19
|
+
"""List all jobs"""
|
|
20
|
+
c3 = get_client()
|
|
21
|
+
with spinner("Fetching jobs..."):
|
|
22
|
+
jobs = c3.jobs.list(state=state)
|
|
23
|
+
|
|
24
|
+
if fmt == "json":
|
|
25
|
+
output(jobs, "json")
|
|
26
|
+
else:
|
|
27
|
+
if not jobs:
|
|
28
|
+
console.print("[dim]No jobs found[/dim]")
|
|
29
|
+
return
|
|
30
|
+
output(jobs, "table", ["job_id", "state", "gpu_type", "gpu_count", "region", "hostname"])
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@app.command("get")
|
|
34
|
+
def get_job(
|
|
35
|
+
job_id: str = typer.Argument(..., help="Job ID"),
|
|
36
|
+
fmt: str = typer.Option("table", "--output", "-o", help="Output format: table|json"),
|
|
37
|
+
):
|
|
38
|
+
"""Get job details"""
|
|
39
|
+
c3 = get_client()
|
|
40
|
+
with spinner("Fetching job..."):
|
|
41
|
+
job = c3.jobs.get(job_id)
|
|
42
|
+
output(job, fmt)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@app.command("logs")
|
|
46
|
+
def logs(
|
|
47
|
+
job_id: str = typer.Argument(..., help="Job ID"),
|
|
48
|
+
follow: bool = typer.Option(False, "--follow", "-f", help="Stream logs"),
|
|
49
|
+
):
|
|
50
|
+
"""Get job logs"""
|
|
51
|
+
c3 = get_client()
|
|
52
|
+
|
|
53
|
+
if follow:
|
|
54
|
+
_follow_job(job_id)
|
|
55
|
+
else:
|
|
56
|
+
with spinner("Fetching logs..."):
|
|
57
|
+
logs_str = c3.jobs.logs(job_id)
|
|
58
|
+
console.print(logs_str)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@app.command("metrics")
|
|
62
|
+
def metrics(
|
|
63
|
+
job_id: str = typer.Argument(..., help="Job ID"),
|
|
64
|
+
watch: bool = typer.Option(False, "--watch", "-w", help="Watch metrics live"),
|
|
65
|
+
fmt: str = typer.Option("table", "--output", "-o", help="Output format: table|json"),
|
|
66
|
+
):
|
|
67
|
+
"""Get job GPU metrics"""
|
|
68
|
+
c3 = get_client()
|
|
69
|
+
|
|
70
|
+
if watch:
|
|
71
|
+
_watch_metrics(job_id)
|
|
72
|
+
else:
|
|
73
|
+
with spinner("Fetching metrics..."):
|
|
74
|
+
m = c3.jobs.metrics(job_id)
|
|
75
|
+
if fmt == "json":
|
|
76
|
+
output(m, "json")
|
|
77
|
+
else:
|
|
78
|
+
_print_metrics(m)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@app.command("cancel")
|
|
82
|
+
def cancel(
|
|
83
|
+
job_id: str = typer.Argument(..., help="Job ID"),
|
|
84
|
+
):
|
|
85
|
+
"""Cancel a running job"""
|
|
86
|
+
c3 = get_client()
|
|
87
|
+
with spinner("Cancelling job..."):
|
|
88
|
+
c3.jobs.cancel(job_id)
|
|
89
|
+
success(f"Job {job_id} cancelled")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@app.command("extend")
|
|
93
|
+
def extend(
|
|
94
|
+
job_id: str = typer.Argument(..., help="Job ID"),
|
|
95
|
+
runtime: int = typer.Argument(..., help="New runtime in seconds"),
|
|
96
|
+
fmt: str = typer.Option("table", "--output", "-o", help="Output format: table|json"),
|
|
97
|
+
):
|
|
98
|
+
"""Extend job runtime"""
|
|
99
|
+
c3 = get_client()
|
|
100
|
+
with spinner("Extending runtime..."):
|
|
101
|
+
job = c3.jobs.extend(job_id, runtime)
|
|
102
|
+
if fmt == "json":
|
|
103
|
+
output(job, "json")
|
|
104
|
+
else:
|
|
105
|
+
success(f"Job extended to {runtime}s runtime")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _print_metrics(m):
|
|
109
|
+
"""Print GPU metrics"""
|
|
110
|
+
from rich.panel import Panel
|
|
111
|
+
from rich.table import Table
|
|
112
|
+
|
|
113
|
+
# System metrics (CPU/RAM)
|
|
114
|
+
if m.system:
|
|
115
|
+
sys_table = Table(show_header=False, box=None, padding=(0, 2))
|
|
116
|
+
sys_table.add_column("Metric", style="cyan")
|
|
117
|
+
sys_table.add_column("Value")
|
|
118
|
+
sys_table.add_column("Bar", width=30)
|
|
119
|
+
|
|
120
|
+
cpu_bar = _make_bar(m.system.cpu_percent, 100)
|
|
121
|
+
mem_pct = (m.system.memory_used / m.system.memory_limit * 100) if m.system.memory_limit else 0
|
|
122
|
+
mem_bar = _make_bar(mem_pct, 100)
|
|
123
|
+
|
|
124
|
+
sys_table.add_row("CPU", f"{m.system.cpu_percent:5.1f}%", cpu_bar)
|
|
125
|
+
sys_table.add_row("RAM", f"{m.system.memory_used/1024:.1f}/{m.system.memory_limit/1024:.1f} GB", mem_bar)
|
|
126
|
+
|
|
127
|
+
console.print(Panel(sys_table, title="[bold]System[/bold]"))
|
|
128
|
+
|
|
129
|
+
if not m.gpus:
|
|
130
|
+
console.print("[dim]No GPU metrics available[/dim]")
|
|
131
|
+
return
|
|
132
|
+
|
|
133
|
+
for gpu in m.gpus:
|
|
134
|
+
table = Table(show_header=False, box=None, padding=(0, 2))
|
|
135
|
+
table.add_column("Metric", style="cyan")
|
|
136
|
+
table.add_column("Value")
|
|
137
|
+
table.add_column("Bar", width=30)
|
|
138
|
+
|
|
139
|
+
util_bar = _make_bar(gpu.utilization, 100)
|
|
140
|
+
mem_pct = (gpu.memory_used / gpu.memory_total * 100) if gpu.memory_total else 0
|
|
141
|
+
mem_bar = _make_bar(mem_pct, 100)
|
|
142
|
+
temp_bar = _make_bar(gpu.temperature, 100, warn=70, crit=85)
|
|
143
|
+
|
|
144
|
+
table.add_row("GPU", f"{gpu.utilization:5.1f}%", util_bar)
|
|
145
|
+
table.add_row("VRAM", f"{gpu.memory_used/1024:.1f}/{gpu.memory_total/1024:.1f} GB", mem_bar)
|
|
146
|
+
table.add_row("Temp", f"{gpu.temperature}°C", temp_bar)
|
|
147
|
+
table.add_row("Power", f"{gpu.power_draw:.0f}W", "")
|
|
148
|
+
|
|
149
|
+
title = f"[bold]GPU {gpu.index}: {gpu.name}[/bold]" if gpu.name else f"[bold]GPU {gpu.index}[/bold]"
|
|
150
|
+
console.print(Panel(table, title=title))
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _make_bar(value: float, max_val: float, warn: float = None, crit: float = None) -> str:
|
|
154
|
+
"""Create a colored progress bar"""
|
|
155
|
+
pct = min(value / max_val, 1.0) if max_val else 0
|
|
156
|
+
width = 25
|
|
157
|
+
filled = int(pct * width)
|
|
158
|
+
|
|
159
|
+
if crit and value >= crit:
|
|
160
|
+
color = "red"
|
|
161
|
+
elif warn and value >= warn:
|
|
162
|
+
color = "yellow"
|
|
163
|
+
else:
|
|
164
|
+
color = "green"
|
|
165
|
+
|
|
166
|
+
bar = "█" * filled + "░" * (width - filled)
|
|
167
|
+
return f"[{color}]{bar}[/{color}]"
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _follow_job(job_id: str):
|
|
171
|
+
"""Follow job with TUI"""
|
|
172
|
+
from .tui.job_monitor import run_job_monitor
|
|
173
|
+
run_job_monitor(job_id)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _watch_metrics(job_id: str):
|
|
177
|
+
"""Watch metrics live"""
|
|
178
|
+
import time
|
|
179
|
+
from rich.live import Live
|
|
180
|
+
|
|
181
|
+
c3 = get_client()
|
|
182
|
+
|
|
183
|
+
with Live(console=console, refresh_per_second=2) as live:
|
|
184
|
+
while True:
|
|
185
|
+
try:
|
|
186
|
+
m = c3.jobs.metrics(job_id)
|
|
187
|
+
live.update(_render_metrics(m))
|
|
188
|
+
time.sleep(1)
|
|
189
|
+
except KeyboardInterrupt:
|
|
190
|
+
break
|
|
191
|
+
except Exception as e:
|
|
192
|
+
console.print(f"[red]Error: {e}[/red]")
|
|
193
|
+
break
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _render_metrics(m):
|
|
197
|
+
"""Render metrics as Rich panel"""
|
|
198
|
+
from rich.panel import Panel
|
|
199
|
+
from rich.table import Table
|
|
200
|
+
from rich.console import Group
|
|
201
|
+
|
|
202
|
+
panels = []
|
|
203
|
+
|
|
204
|
+
# System metrics
|
|
205
|
+
if m.system:
|
|
206
|
+
sys_table = Table(show_header=False, box=None)
|
|
207
|
+
sys_table.add_column("Metric", style="cyan")
|
|
208
|
+
sys_table.add_column("Value")
|
|
209
|
+
cpu_bar = _make_bar(m.system.cpu_percent, 100)
|
|
210
|
+
mem_pct = (m.system.memory_used / m.system.memory_limit * 100) if m.system.memory_limit else 0
|
|
211
|
+
mem_bar = _make_bar(mem_pct, 100)
|
|
212
|
+
sys_table.add_row("CPU", f"{m.system.cpu_percent:5.1f}% {cpu_bar}")
|
|
213
|
+
sys_table.add_row("RAM", f"{m.system.memory_used/1024:.1f}/{m.system.memory_limit/1024:.1f}GB {mem_bar}")
|
|
214
|
+
panels.append(Panel(sys_table, title="[bold]System[/bold]", border_style="blue"))
|
|
215
|
+
|
|
216
|
+
if not m.gpus:
|
|
217
|
+
panels.append(Panel("[dim]No GPU metrics[/dim]"))
|
|
218
|
+
return Group(*panels)
|
|
219
|
+
|
|
220
|
+
table = Table(show_header=True, header_style="bold cyan", box=None)
|
|
221
|
+
table.add_column("GPU")
|
|
222
|
+
table.add_column("Util")
|
|
223
|
+
table.add_column("VRAM")
|
|
224
|
+
table.add_column("Temp")
|
|
225
|
+
table.add_column("Power")
|
|
226
|
+
|
|
227
|
+
for gpu in m.gpus:
|
|
228
|
+
util_bar = _make_bar(gpu.utilization, 100)
|
|
229
|
+
name = f"{gpu.index}: {gpu.name}" if gpu.name else str(gpu.index)
|
|
230
|
+
table.add_row(
|
|
231
|
+
f"[bold]{name}[/bold]",
|
|
232
|
+
f"{gpu.utilization:5.1f}% {util_bar}",
|
|
233
|
+
f"{gpu.memory_used/1024:.1f}/{gpu.memory_total/1024:.1f}GB",
|
|
234
|
+
f"{gpu.temperature}°C",
|
|
235
|
+
f"{gpu.power_draw:.0f}W"
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
panels.append(Panel(table, title="[bold]GPU Metrics[/bold]", border_style="green"))
|
|
239
|
+
return Group(*panels)
|
c3cli/llm.py
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""c3 llm commands - uses OpenAI SDK"""
|
|
2
|
+
import typer
|
|
3
|
+
from typing import Optional
|
|
4
|
+
from openai import OpenAI
|
|
5
|
+
from c3.config import get_api_key, get_api_url
|
|
6
|
+
from .output import output, console, spinner
|
|
7
|
+
|
|
8
|
+
app = typer.Typer(help="LLM API commands")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def get_openai_client() -> OpenAI:
|
|
12
|
+
"""Get OpenAI client configured for C3"""
|
|
13
|
+
api_key = get_api_key()
|
|
14
|
+
if not api_key:
|
|
15
|
+
raise typer.Exit("C3_API_KEY not set. Run: c3 configure")
|
|
16
|
+
|
|
17
|
+
base_url = get_api_url()
|
|
18
|
+
return OpenAI(api_key=api_key, base_url=f"{base_url}/v1")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@app.command("models")
|
|
22
|
+
def models(
|
|
23
|
+
fmt: str = typer.Option("table", "--output", "-o", help="Output format: table|json"),
|
|
24
|
+
):
|
|
25
|
+
"""List available models"""
|
|
26
|
+
client = get_openai_client()
|
|
27
|
+
with spinner("Fetching models..."):
|
|
28
|
+
models_list = list(client.models.list())
|
|
29
|
+
|
|
30
|
+
if fmt == "json":
|
|
31
|
+
output([{"id": m.id, "owned_by": m.owned_by} for m in models_list], "json")
|
|
32
|
+
else:
|
|
33
|
+
data = [{"id": m.id, "owned_by": m.owned_by} for m in models_list]
|
|
34
|
+
output(data, "table", ["id", "owned_by"])
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@app.command("chat")
|
|
38
|
+
def chat(
|
|
39
|
+
model: Optional[str] = typer.Argument(None, help="Model name (optional for interactive)"),
|
|
40
|
+
prompt: Optional[str] = typer.Argument(None, help="Prompt (omit for interactive)"),
|
|
41
|
+
system: Optional[str] = typer.Option(None, "--system", "-s", help="System message"),
|
|
42
|
+
max_tokens: int = typer.Option(4096, "--max-tokens", "-m", help="Max tokens"),
|
|
43
|
+
temperature: float = typer.Option(0.7, "--temperature", "-t", help="Temperature"),
|
|
44
|
+
no_stream: bool = typer.Option(False, "--no-stream", help="Disable streaming"),
|
|
45
|
+
interactive: bool = typer.Option(False, "--interactive", "-i", help="Interactive mode"),
|
|
46
|
+
fmt: str = typer.Option("text", "--output", "-o", help="Output format: text|json"),
|
|
47
|
+
):
|
|
48
|
+
"""Chat completion"""
|
|
49
|
+
client = get_openai_client()
|
|
50
|
+
|
|
51
|
+
# Interactive mode if -i flag or no model provided
|
|
52
|
+
if interactive or model is None:
|
|
53
|
+
_interactive_chat(client, model, system, max_tokens, temperature)
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
if prompt is None:
|
|
57
|
+
_interactive_chat(client, model, system, max_tokens, temperature)
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
messages = []
|
|
61
|
+
if system:
|
|
62
|
+
messages.append({"role": "system", "content": system})
|
|
63
|
+
messages.append({"role": "user", "content": prompt})
|
|
64
|
+
|
|
65
|
+
if no_stream or fmt == "json":
|
|
66
|
+
with spinner("Generating response..."):
|
|
67
|
+
response = client.chat.completions.create(
|
|
68
|
+
model=model,
|
|
69
|
+
messages=messages,
|
|
70
|
+
max_tokens=max_tokens,
|
|
71
|
+
temperature=temperature,
|
|
72
|
+
)
|
|
73
|
+
if fmt == "json":
|
|
74
|
+
output(response.model_dump(), "json")
|
|
75
|
+
else:
|
|
76
|
+
console.print(response.choices[0].message.content)
|
|
77
|
+
else:
|
|
78
|
+
stream = client.chat.completions.create(
|
|
79
|
+
model=model,
|
|
80
|
+
messages=messages,
|
|
81
|
+
max_tokens=max_tokens,
|
|
82
|
+
temperature=temperature,
|
|
83
|
+
stream=True,
|
|
84
|
+
)
|
|
85
|
+
for chunk in stream:
|
|
86
|
+
content = chunk.choices[0].delta.content
|
|
87
|
+
if content:
|
|
88
|
+
console.print(content, end="")
|
|
89
|
+
console.print()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _interactive_chat(client: OpenAI, model: Optional[str], system: Optional[str], max_tokens: int, temperature: float):
|
|
93
|
+
"""Interactive chat mode with slash commands"""
|
|
94
|
+
from rich.table import Table
|
|
95
|
+
|
|
96
|
+
# Default model if none provided
|
|
97
|
+
current_model = model or "meta-llama/llama-3.3-70b-instruct"
|
|
98
|
+
current_system = system
|
|
99
|
+
current_temp = temperature
|
|
100
|
+
current_max_tokens = max_tokens
|
|
101
|
+
messages: list[dict] = []
|
|
102
|
+
|
|
103
|
+
if current_system:
|
|
104
|
+
messages.append({"role": "system", "content": current_system})
|
|
105
|
+
|
|
106
|
+
def show_help():
|
|
107
|
+
console.print("\n[bold cyan]Commands:[/bold cyan]")
|
|
108
|
+
table = Table(show_header=False, box=None, padding=(0, 2))
|
|
109
|
+
table.add_column("Command", style="green")
|
|
110
|
+
table.add_column("Description")
|
|
111
|
+
table.add_row("/model <name>", "Switch to a different model")
|
|
112
|
+
table.add_row("/models", "List available models")
|
|
113
|
+
table.add_row("/system <prompt>", "Set system prompt")
|
|
114
|
+
table.add_row("/temp <value>", "Set temperature (0.0-2.0)")
|
|
115
|
+
table.add_row("/tokens <value>", "Set max tokens")
|
|
116
|
+
table.add_row("/clear", "Clear conversation history")
|
|
117
|
+
table.add_row("/history", "Show conversation history")
|
|
118
|
+
table.add_row("/help", "Show this help")
|
|
119
|
+
table.add_row("/quit", "Exit chat")
|
|
120
|
+
console.print(table)
|
|
121
|
+
console.print()
|
|
122
|
+
|
|
123
|
+
def show_status():
|
|
124
|
+
console.print(f"[dim]Model: [green]{current_model}[/] | Temp: {current_temp} | Max tokens: {current_max_tokens}[/]")
|
|
125
|
+
if current_system:
|
|
126
|
+
sys_preview = current_system[:50] + "..." if len(current_system) > 50 else current_system
|
|
127
|
+
console.print(f"[dim]System: {sys_preview}[/]")
|
|
128
|
+
|
|
129
|
+
console.print("\n[bold cyan]C3 Chat[/bold cyan]")
|
|
130
|
+
show_status()
|
|
131
|
+
console.print("[dim]Type /help for commands, /quit to exit[/dim]\n")
|
|
132
|
+
|
|
133
|
+
while True:
|
|
134
|
+
try:
|
|
135
|
+
user_input = console.input("[bold blue]> [/bold blue]").strip()
|
|
136
|
+
|
|
137
|
+
if not user_input:
|
|
138
|
+
continue
|
|
139
|
+
|
|
140
|
+
# Handle slash commands
|
|
141
|
+
if user_input.startswith("/"):
|
|
142
|
+
parts = user_input[1:].split(maxsplit=1)
|
|
143
|
+
cmd = parts[0].lower()
|
|
144
|
+
arg = parts[1] if len(parts) > 1 else None
|
|
145
|
+
|
|
146
|
+
if cmd in ("quit", "exit", "q"):
|
|
147
|
+
console.print("[dim]Goodbye![/dim]")
|
|
148
|
+
break
|
|
149
|
+
|
|
150
|
+
elif cmd == "help":
|
|
151
|
+
show_help()
|
|
152
|
+
|
|
153
|
+
elif cmd == "models":
|
|
154
|
+
with spinner("Fetching models..."):
|
|
155
|
+
models_list = list(client.models.list())
|
|
156
|
+
console.print("\n[bold]Available models:[/bold]")
|
|
157
|
+
for m in models_list:
|
|
158
|
+
marker = "[green]●[/]" if m.id == current_model else "[dim]○[/]"
|
|
159
|
+
console.print(f" {marker} {m.id}")
|
|
160
|
+
console.print()
|
|
161
|
+
|
|
162
|
+
elif cmd == "model":
|
|
163
|
+
if not arg:
|
|
164
|
+
console.print(f"[dim]Current model: [green]{current_model}[/][/]")
|
|
165
|
+
else:
|
|
166
|
+
current_model = arg
|
|
167
|
+
console.print(f"[green]✓[/] Switched to model: [bold]{current_model}[/]")
|
|
168
|
+
|
|
169
|
+
elif cmd == "system":
|
|
170
|
+
if not arg:
|
|
171
|
+
if current_system:
|
|
172
|
+
console.print(f"[dim]System prompt: {current_system}[/]")
|
|
173
|
+
else:
|
|
174
|
+
console.print("[dim]No system prompt set[/]")
|
|
175
|
+
else:
|
|
176
|
+
current_system = arg
|
|
177
|
+
# Update or add system message
|
|
178
|
+
if messages and messages[0]["role"] == "system":
|
|
179
|
+
messages[0]["content"] = current_system
|
|
180
|
+
else:
|
|
181
|
+
messages.insert(0, {"role": "system", "content": current_system})
|
|
182
|
+
console.print(f"[green]✓[/] System prompt updated")
|
|
183
|
+
|
|
184
|
+
elif cmd == "temp":
|
|
185
|
+
if not arg:
|
|
186
|
+
console.print(f"[dim]Temperature: {current_temp}[/]")
|
|
187
|
+
else:
|
|
188
|
+
try:
|
|
189
|
+
current_temp = float(arg)
|
|
190
|
+
console.print(f"[green]✓[/] Temperature set to {current_temp}")
|
|
191
|
+
except ValueError:
|
|
192
|
+
console.print("[red]Invalid temperature value[/]")
|
|
193
|
+
|
|
194
|
+
elif cmd == "tokens":
|
|
195
|
+
if not arg:
|
|
196
|
+
console.print(f"[dim]Max tokens: {current_max_tokens}[/]")
|
|
197
|
+
else:
|
|
198
|
+
try:
|
|
199
|
+
current_max_tokens = int(arg)
|
|
200
|
+
console.print(f"[green]✓[/] Max tokens set to {current_max_tokens}")
|
|
201
|
+
except ValueError:
|
|
202
|
+
console.print("[red]Invalid token value[/]")
|
|
203
|
+
|
|
204
|
+
elif cmd == "clear":
|
|
205
|
+
messages.clear()
|
|
206
|
+
if current_system:
|
|
207
|
+
messages.append({"role": "system", "content": current_system})
|
|
208
|
+
console.print("[green]✓[/] Conversation cleared")
|
|
209
|
+
|
|
210
|
+
elif cmd == "history":
|
|
211
|
+
if not messages or (len(messages) == 1 and messages[0]["role"] == "system"):
|
|
212
|
+
console.print("[dim]No conversation history[/]")
|
|
213
|
+
else:
|
|
214
|
+
console.print("\n[bold]Conversation history:[/bold]")
|
|
215
|
+
for msg in messages:
|
|
216
|
+
if msg["role"] == "system":
|
|
217
|
+
console.print(f"[dim]System: {msg['content'][:100]}...[/]")
|
|
218
|
+
elif msg["role"] == "user":
|
|
219
|
+
console.print(f"[blue]You:[/] {msg['content'][:100]}...")
|
|
220
|
+
else:
|
|
221
|
+
console.print(f"[green]Assistant:[/] {msg['content'][:100]}...")
|
|
222
|
+
console.print()
|
|
223
|
+
|
|
224
|
+
else:
|
|
225
|
+
console.print(f"[red]Unknown command: /{cmd}[/] - type /help for commands")
|
|
226
|
+
|
|
227
|
+
continue
|
|
228
|
+
|
|
229
|
+
# Regular message - send to model
|
|
230
|
+
messages.append({"role": "user", "content": user_input})
|
|
231
|
+
|
|
232
|
+
console.print("[bold green]Assistant:[/bold green] ", end="")
|
|
233
|
+
|
|
234
|
+
try:
|
|
235
|
+
full_response = []
|
|
236
|
+
stream = client.chat.completions.create(
|
|
237
|
+
model=current_model,
|
|
238
|
+
messages=messages,
|
|
239
|
+
max_tokens=current_max_tokens,
|
|
240
|
+
temperature=current_temp,
|
|
241
|
+
stream=True,
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
for chunk in stream:
|
|
245
|
+
if chunk.choices and chunk.choices[0].delta.content:
|
|
246
|
+
content = chunk.choices[0].delta.content
|
|
247
|
+
console.print(content, end="")
|
|
248
|
+
full_response.append(content)
|
|
249
|
+
|
|
250
|
+
console.print("\n")
|
|
251
|
+
messages.append({"role": "assistant", "content": "".join(full_response)})
|
|
252
|
+
|
|
253
|
+
except Exception as e:
|
|
254
|
+
console.print(f"\n[red]Error: {e}[/]\n")
|
|
255
|
+
# Remove the failed user message
|
|
256
|
+
messages.pop()
|
|
257
|
+
|
|
258
|
+
except KeyboardInterrupt:
|
|
259
|
+
console.print("\n[dim]Goodbye![/dim]")
|
|
260
|
+
break
|
|
261
|
+
except EOFError:
|
|
262
|
+
console.print("\n[dim]Goodbye![/dim]")
|
|
263
|
+
break
|