anor 0.1.0__tar.gz
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.
- anor-0.1.0/.gitignore +52 -0
- anor-0.1.0/PKG-INFO +83 -0
- anor-0.1.0/README.md +54 -0
- anor-0.1.0/anor/__init__.py +2 -0
- anor-0.1.0/anor/api.py +124 -0
- anor-0.1.0/anor/cli.py +386 -0
- anor-0.1.0/anor/config.py +60 -0
- anor-0.1.0/pyproject.toml +46 -0
anor-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Dependencies
|
|
2
|
+
node_modules/
|
|
3
|
+
.pnpm-store/
|
|
4
|
+
|
|
5
|
+
# Build outputs
|
|
6
|
+
dist/
|
|
7
|
+
build/
|
|
8
|
+
.next/
|
|
9
|
+
out/
|
|
10
|
+
|
|
11
|
+
# Environment files
|
|
12
|
+
# .env
|
|
13
|
+
# .env.local
|
|
14
|
+
# .env.*.local
|
|
15
|
+
# .env.production
|
|
16
|
+
# .env.staging
|
|
17
|
+
|
|
18
|
+
# IDE
|
|
19
|
+
.idea/
|
|
20
|
+
.vscode/
|
|
21
|
+
*.swp
|
|
22
|
+
*.swo
|
|
23
|
+
|
|
24
|
+
# OS
|
|
25
|
+
.DS_Store
|
|
26
|
+
Thumbs.db
|
|
27
|
+
|
|
28
|
+
# Logs
|
|
29
|
+
*.log
|
|
30
|
+
npm-debug.log*
|
|
31
|
+
pnpm-debug.log*
|
|
32
|
+
|
|
33
|
+
# Testing
|
|
34
|
+
coverage/
|
|
35
|
+
.nyc_output/
|
|
36
|
+
|
|
37
|
+
# Prisma
|
|
38
|
+
apps/api/prisma/*.db
|
|
39
|
+
apps/api/prisma/*.db-journal
|
|
40
|
+
|
|
41
|
+
# Docker
|
|
42
|
+
.docker/
|
|
43
|
+
|
|
44
|
+
# Vercel
|
|
45
|
+
.vercel/
|
|
46
|
+
|
|
47
|
+
# GCP
|
|
48
|
+
.gcloud/
|
|
49
|
+
.vercel
|
|
50
|
+
|
|
51
|
+
# Orchestrator
|
|
52
|
+
apps/orchestrator/node/target/
|
anor-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: anor
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Anor Cloud GPU CLI - Managed GPU compute made simple
|
|
5
|
+
Project-URL: Homepage, https://anor.cloud
|
|
6
|
+
Project-URL: Documentation, https://docs.anor.cloud
|
|
7
|
+
Project-URL: Repository, https://github.com/Ornn-AI/ornn-gpu-marketplace
|
|
8
|
+
Author-email: Anor Cloud <support@anor.cloud>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
Keywords: ai,cli,cloud,compute,gpu,machine-learning
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: Science/Research
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
24
|
+
Requires-Python: >=3.8
|
|
25
|
+
Requires-Dist: click>=8.0.0
|
|
26
|
+
Requires-Dist: httpx>=0.24.0
|
|
27
|
+
Requires-Dist: rich>=13.0.0
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# Anor CLI
|
|
31
|
+
|
|
32
|
+
Managed GPU compute made simple.
|
|
33
|
+
|
|
34
|
+
## Installation
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install anor
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Quick Start
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
# Login to your account
|
|
44
|
+
anor login
|
|
45
|
+
|
|
46
|
+
# Run a training job
|
|
47
|
+
anor run --grade standard --gpus 8 --max-rate 2.20 "python train.py"
|
|
48
|
+
|
|
49
|
+
# List your jobs
|
|
50
|
+
anor jobs list
|
|
51
|
+
|
|
52
|
+
# Get job logs
|
|
53
|
+
anor jobs logs <job-id>
|
|
54
|
+
|
|
55
|
+
# View pricing
|
|
56
|
+
anor pricing
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Commands
|
|
60
|
+
|
|
61
|
+
| Command | Description |
|
|
62
|
+
|---------|-------------|
|
|
63
|
+
| `anor login` | Login and store credentials |
|
|
64
|
+
| `anor logout` | Clear stored credentials |
|
|
65
|
+
| `anor whoami` | Show current user |
|
|
66
|
+
| `anor run` | Submit a compute job |
|
|
67
|
+
| `anor jobs list` | List your jobs |
|
|
68
|
+
| `anor jobs get <id>` | Get job details |
|
|
69
|
+
| `anor jobs logs <id>` | Get job logs |
|
|
70
|
+
| `anor jobs cancel <id>` | Cancel a job |
|
|
71
|
+
| `anor pricing` | Show current pricing |
|
|
72
|
+
|
|
73
|
+
## Compute Grades
|
|
74
|
+
|
|
75
|
+
| Grade | Price | GPU Types |
|
|
76
|
+
|-------|-------|-----------|
|
|
77
|
+
| Standard | $1.80/GPU/hr | T4, V100, L4 |
|
|
78
|
+
| Performance | $2.50/GPU/hr | A100, A100-80GB |
|
|
79
|
+
| Premium | $4.00/GPU/hr | H100, H200, B200 |
|
|
80
|
+
|
|
81
|
+
## Documentation
|
|
82
|
+
|
|
83
|
+
https://docs.anor.cloud
|
anor-0.1.0/README.md
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# Anor CLI
|
|
2
|
+
|
|
3
|
+
Managed GPU compute made simple.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install anor
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
# Login to your account
|
|
15
|
+
anor login
|
|
16
|
+
|
|
17
|
+
# Run a training job
|
|
18
|
+
anor run --grade standard --gpus 8 --max-rate 2.20 "python train.py"
|
|
19
|
+
|
|
20
|
+
# List your jobs
|
|
21
|
+
anor jobs list
|
|
22
|
+
|
|
23
|
+
# Get job logs
|
|
24
|
+
anor jobs logs <job-id>
|
|
25
|
+
|
|
26
|
+
# View pricing
|
|
27
|
+
anor pricing
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Commands
|
|
31
|
+
|
|
32
|
+
| Command | Description |
|
|
33
|
+
|---------|-------------|
|
|
34
|
+
| `anor login` | Login and store credentials |
|
|
35
|
+
| `anor logout` | Clear stored credentials |
|
|
36
|
+
| `anor whoami` | Show current user |
|
|
37
|
+
| `anor run` | Submit a compute job |
|
|
38
|
+
| `anor jobs list` | List your jobs |
|
|
39
|
+
| `anor jobs get <id>` | Get job details |
|
|
40
|
+
| `anor jobs logs <id>` | Get job logs |
|
|
41
|
+
| `anor jobs cancel <id>` | Cancel a job |
|
|
42
|
+
| `anor pricing` | Show current pricing |
|
|
43
|
+
|
|
44
|
+
## Compute Grades
|
|
45
|
+
|
|
46
|
+
| Grade | Price | GPU Types |
|
|
47
|
+
|-------|-------|-----------|
|
|
48
|
+
| Standard | $1.80/GPU/hr | T4, V100, L4 |
|
|
49
|
+
| Performance | $2.50/GPU/hr | A100, A100-80GB |
|
|
50
|
+
| Premium | $4.00/GPU/hr | H100, H200, B200 |
|
|
51
|
+
|
|
52
|
+
## Documentation
|
|
53
|
+
|
|
54
|
+
https://docs.anor.cloud
|
anor-0.1.0/anor/api.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""API client for Anor Cloud"""
|
|
2
|
+
import httpx
|
|
3
|
+
from typing import Optional, Dict, Any, List
|
|
4
|
+
|
|
5
|
+
from .config import get_api_url, get_api_key
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class APIError(Exception):
|
|
9
|
+
def __init__(self, message: str, status_code: int = 0):
|
|
10
|
+
self.message = message
|
|
11
|
+
self.status_code = status_code
|
|
12
|
+
super().__init__(message)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AnorClient:
|
|
16
|
+
def __init__(self, api_url: Optional[str] = None, api_key: Optional[str] = None):
|
|
17
|
+
self.api_url = api_url or get_api_url()
|
|
18
|
+
self.api_key = api_key or get_api_key()
|
|
19
|
+
self.client = httpx.Client(
|
|
20
|
+
base_url=self.api_url,
|
|
21
|
+
timeout=30.0,
|
|
22
|
+
headers=self._headers(),
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
def _headers(self) -> Dict[str, str]:
|
|
26
|
+
headers = {"Content-Type": "application/json"}
|
|
27
|
+
if self.api_key:
|
|
28
|
+
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
29
|
+
return headers
|
|
30
|
+
|
|
31
|
+
def _handle_response(self, response: httpx.Response) -> Any:
|
|
32
|
+
if response.status_code >= 400:
|
|
33
|
+
try:
|
|
34
|
+
error = response.json()
|
|
35
|
+
message = error.get("message", response.text)
|
|
36
|
+
except Exception:
|
|
37
|
+
message = response.text
|
|
38
|
+
raise APIError(message, response.status_code)
|
|
39
|
+
|
|
40
|
+
if response.headers.get("content-type", "").startswith("application/json"):
|
|
41
|
+
return response.json()
|
|
42
|
+
return response.text
|
|
43
|
+
|
|
44
|
+
# Auth
|
|
45
|
+
def login(self, email: str, password: str) -> Dict[str, Any]:
|
|
46
|
+
response = self.client.post("/api/v1/auth/login", json={
|
|
47
|
+
"email": email,
|
|
48
|
+
"password": password,
|
|
49
|
+
})
|
|
50
|
+
return self._handle_response(response)
|
|
51
|
+
|
|
52
|
+
def register(self, email: str, password: str, name: str) -> Dict[str, Any]:
|
|
53
|
+
response = self.client.post("/api/v1/auth/register", json={
|
|
54
|
+
"email": email,
|
|
55
|
+
"password": password,
|
|
56
|
+
"name": name,
|
|
57
|
+
})
|
|
58
|
+
return self._handle_response(response)
|
|
59
|
+
|
|
60
|
+
def get_me(self) -> Dict[str, Any]:
|
|
61
|
+
response = self.client.get("/api/v1/auth/me")
|
|
62
|
+
return self._handle_response(response)
|
|
63
|
+
|
|
64
|
+
def create_api_key(self, name: str) -> Dict[str, Any]:
|
|
65
|
+
response = self.client.post("/api/v1/auth/api-keys", json={"name": name})
|
|
66
|
+
return self._handle_response(response)
|
|
67
|
+
|
|
68
|
+
# Jobs
|
|
69
|
+
def create_job(
|
|
70
|
+
self,
|
|
71
|
+
command: str,
|
|
72
|
+
grade: str,
|
|
73
|
+
gpu_count: int,
|
|
74
|
+
max_hourly_rate: float,
|
|
75
|
+
name: Optional[str] = None,
|
|
76
|
+
image: Optional[str] = None,
|
|
77
|
+
workdir: Optional[str] = None,
|
|
78
|
+
env_vars: Optional[Dict[str, str]] = None,
|
|
79
|
+
) -> Dict[str, Any]:
|
|
80
|
+
payload = {
|
|
81
|
+
"command": command,
|
|
82
|
+
"grade": grade.upper(),
|
|
83
|
+
"gpuCount": gpu_count,
|
|
84
|
+
"maxHourlyRate": max_hourly_rate,
|
|
85
|
+
}
|
|
86
|
+
if name:
|
|
87
|
+
payload["name"] = name
|
|
88
|
+
if image:
|
|
89
|
+
payload["image"] = image
|
|
90
|
+
if workdir:
|
|
91
|
+
payload["workdir"] = workdir
|
|
92
|
+
if env_vars:
|
|
93
|
+
payload["envVars"] = env_vars
|
|
94
|
+
|
|
95
|
+
response = self.client.post("/api/v1/jobs", json=payload)
|
|
96
|
+
return self._handle_response(response)
|
|
97
|
+
|
|
98
|
+
def list_jobs(self, state: Optional[str] = None, page: int = 1, limit: int = 20) -> Dict[str, Any]:
|
|
99
|
+
params = {"page": page, "limit": limit}
|
|
100
|
+
if state:
|
|
101
|
+
params["state"] = state
|
|
102
|
+
response = self.client.get("/api/v1/jobs", params=params)
|
|
103
|
+
return self._handle_response(response)
|
|
104
|
+
|
|
105
|
+
def get_job(self, job_id: str) -> Dict[str, Any]:
|
|
106
|
+
response = self.client.get(f"/api/v1/jobs/{job_id}")
|
|
107
|
+
return self._handle_response(response)
|
|
108
|
+
|
|
109
|
+
def get_job_logs(self, job_id: str) -> str:
|
|
110
|
+
response = self.client.get(f"/api/v1/jobs/{job_id}/logs")
|
|
111
|
+
return self._handle_response(response)
|
|
112
|
+
|
|
113
|
+
def cancel_job(self, job_id: str) -> Dict[str, Any]:
|
|
114
|
+
response = self.client.delete(f"/api/v1/jobs/{job_id}")
|
|
115
|
+
return self._handle_response(response)
|
|
116
|
+
|
|
117
|
+
def get_pricing(self) -> List[Dict[str, Any]]:
|
|
118
|
+
response = self.client.get("/api/v1/jobs/pricing")
|
|
119
|
+
return self._handle_response(response)
|
|
120
|
+
|
|
121
|
+
# Account
|
|
122
|
+
def get_balance(self) -> Dict[str, Any]:
|
|
123
|
+
response = self.client.get("/api/v1/account/balance")
|
|
124
|
+
return self._handle_response(response)
|
anor-0.1.0/anor/cli.py
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
"""Anor Cloud CLI"""
|
|
2
|
+
import click
|
|
3
|
+
from rich.console import Console
|
|
4
|
+
from rich.table import Table
|
|
5
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
6
|
+
from typing import Optional, Tuple
|
|
7
|
+
import getpass
|
|
8
|
+
|
|
9
|
+
from . import __version__
|
|
10
|
+
from .config import is_logged_in, set_api_key, clear_credentials, get_api_key
|
|
11
|
+
from .api import AnorClient, APIError
|
|
12
|
+
|
|
13
|
+
console = Console()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
import functools
|
|
17
|
+
|
|
18
|
+
def require_auth(func):
|
|
19
|
+
"""Decorator to require authentication"""
|
|
20
|
+
@functools.wraps(func)
|
|
21
|
+
def wrapper(*args, **kwargs):
|
|
22
|
+
if not is_logged_in():
|
|
23
|
+
console.print("[red]Not logged in. Run `anor login` first.[/red]")
|
|
24
|
+
raise SystemExit(1)
|
|
25
|
+
return func(*args, **kwargs)
|
|
26
|
+
return wrapper
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@click.group()
|
|
30
|
+
@click.version_option(version=__version__, prog_name="anor")
|
|
31
|
+
def main():
|
|
32
|
+
"""Anor Cloud GPU CLI - Managed GPU compute made simple."""
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ============ Auth Commands ============
|
|
37
|
+
|
|
38
|
+
@main.command()
|
|
39
|
+
@click.option("--email", "-e", help="Email address")
|
|
40
|
+
@click.option("--password", "-p", help="Password")
|
|
41
|
+
@click.option("--api-key", "-k", help="Use existing API key")
|
|
42
|
+
def login(email: Optional[str], password: Optional[str], api_key: Optional[str]):
|
|
43
|
+
"""Login to Anor Cloud"""
|
|
44
|
+
if api_key:
|
|
45
|
+
set_api_key(api_key)
|
|
46
|
+
console.print("[green]API key saved successfully![/green]")
|
|
47
|
+
return
|
|
48
|
+
|
|
49
|
+
if not email:
|
|
50
|
+
email = click.prompt("Email")
|
|
51
|
+
if not password:
|
|
52
|
+
password = getpass.getpass("Password: ")
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
with Progress(
|
|
56
|
+
SpinnerColumn(),
|
|
57
|
+
TextColumn("[progress.description]{task.description}"),
|
|
58
|
+
console=console,
|
|
59
|
+
transient=True,
|
|
60
|
+
) as progress:
|
|
61
|
+
progress.add_task("Logging in...", total=None)
|
|
62
|
+
client = AnorClient()
|
|
63
|
+
result = client.login(email, password)
|
|
64
|
+
|
|
65
|
+
if "accessToken" in result:
|
|
66
|
+
# Create an API key for CLI use
|
|
67
|
+
client = AnorClient(api_key=result["accessToken"])
|
|
68
|
+
key_result = client.create_api_key("anor-cli")
|
|
69
|
+
set_api_key(key_result["key"])
|
|
70
|
+
console.print(f"[green]Logged in as {email}[/green]")
|
|
71
|
+
else:
|
|
72
|
+
console.print("[red]Login failed: No access token received[/red]")
|
|
73
|
+
except APIError as e:
|
|
74
|
+
console.print(f"[red]Login failed: {e.message}[/red]")
|
|
75
|
+
raise SystemExit(1)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@main.command()
|
|
79
|
+
def logout():
|
|
80
|
+
"""Logout and clear credentials"""
|
|
81
|
+
clear_credentials()
|
|
82
|
+
console.print("[green]Logged out successfully[/green]")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@main.command()
|
|
86
|
+
def whoami():
|
|
87
|
+
"""Show current user"""
|
|
88
|
+
if not is_logged_in():
|
|
89
|
+
console.print("[yellow]Not logged in[/yellow]")
|
|
90
|
+
return
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
client = AnorClient()
|
|
94
|
+
user = client.get_me()
|
|
95
|
+
console.print(f"[bold]Email:[/bold] {user.get('email', 'N/A')}")
|
|
96
|
+
console.print(f"[bold]Name:[/bold] {user.get('name', 'N/A')}")
|
|
97
|
+
console.print(f"[bold]ID:[/bold] {user.get('id', 'N/A')}")
|
|
98
|
+
except APIError as e:
|
|
99
|
+
console.print(f"[red]Error: {e.message}[/red]")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# ============ Run Command ============
|
|
103
|
+
|
|
104
|
+
@main.command()
|
|
105
|
+
@click.argument("command", nargs=-1, required=True)
|
|
106
|
+
@click.option("--grade", "-g", required=True, type=click.Choice(["standard", "performance", "premium"], case_sensitive=False), help="Compute grade")
|
|
107
|
+
@click.option("--gpus", required=True, type=int, help="Number of GPUs")
|
|
108
|
+
@click.option("--max-rate", required=True, type=float, help="Max hourly rate ($/GPU/hr)")
|
|
109
|
+
@click.option("--name", "-n", help="Job name")
|
|
110
|
+
@click.option("--image", "-i", help="Docker image")
|
|
111
|
+
@click.option("--workdir", "-w", help="Working directory")
|
|
112
|
+
@click.option("--env", "-e", multiple=True, help="Environment variables (KEY=VALUE)")
|
|
113
|
+
@require_auth
|
|
114
|
+
def run(command: Tuple[str, ...], grade: str, gpus: int, max_rate: float,
|
|
115
|
+
name: Optional[str], image: Optional[str], workdir: Optional[str], env: Tuple[str, ...]):
|
|
116
|
+
"""Submit a compute job"""
|
|
117
|
+
cmd_str = " ".join(command)
|
|
118
|
+
|
|
119
|
+
env_vars = {}
|
|
120
|
+
for e in env:
|
|
121
|
+
if "=" in e:
|
|
122
|
+
key, value = e.split("=", 1)
|
|
123
|
+
env_vars[key] = value
|
|
124
|
+
|
|
125
|
+
try:
|
|
126
|
+
with Progress(
|
|
127
|
+
SpinnerColumn(),
|
|
128
|
+
TextColumn("[progress.description]{task.description}"),
|
|
129
|
+
console=console,
|
|
130
|
+
transient=True,
|
|
131
|
+
) as progress:
|
|
132
|
+
progress.add_task("Submitting job...", total=None)
|
|
133
|
+
client = AnorClient()
|
|
134
|
+
job = client.create_job(
|
|
135
|
+
command=cmd_str,
|
|
136
|
+
grade=grade,
|
|
137
|
+
gpu_count=gpus,
|
|
138
|
+
max_hourly_rate=max_rate,
|
|
139
|
+
name=name,
|
|
140
|
+
image=image,
|
|
141
|
+
workdir=workdir,
|
|
142
|
+
env_vars=env_vars if env_vars else None,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
console.print("[green]Job submitted successfully![/green]\n")
|
|
146
|
+
console.print(f"[bold]Job ID:[/bold] {job['id']}")
|
|
147
|
+
console.print(f"[bold]Grade:[/bold] {job['grade']}")
|
|
148
|
+
console.print(f"[bold]GPUs:[/bold] {job['gpuCount']}")
|
|
149
|
+
console.print(f"[bold]Max Rate:[/bold] ${job['maxHourlyRate']}/GPU/hr")
|
|
150
|
+
console.print(f"[bold]State:[/bold] {job['state']}")
|
|
151
|
+
console.print()
|
|
152
|
+
console.print(f"[dim]Track with: anor jobs get {job['id']}[/dim]")
|
|
153
|
+
console.print(f"[dim]View logs: anor jobs logs {job['id']}[/dim]")
|
|
154
|
+
|
|
155
|
+
except APIError as e:
|
|
156
|
+
console.print(f"[red]Failed to submit job: {e.message}[/red]")
|
|
157
|
+
raise SystemExit(1)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# ============ Jobs Commands ============
|
|
161
|
+
|
|
162
|
+
@main.group()
|
|
163
|
+
def jobs():
|
|
164
|
+
"""Manage compute jobs"""
|
|
165
|
+
pass
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@jobs.command("list")
|
|
169
|
+
@click.option("--state", "-s", help="Filter by state")
|
|
170
|
+
@click.option("--limit", "-l", default=20, help="Number of jobs to show")
|
|
171
|
+
@require_auth
|
|
172
|
+
def jobs_list(state: Optional[str], limit: int):
|
|
173
|
+
"""List your jobs"""
|
|
174
|
+
try:
|
|
175
|
+
with Progress(
|
|
176
|
+
SpinnerColumn(),
|
|
177
|
+
TextColumn("[progress.description]{task.description}"),
|
|
178
|
+
console=console,
|
|
179
|
+
transient=True,
|
|
180
|
+
) as progress:
|
|
181
|
+
progress.add_task("Fetching jobs...", total=None)
|
|
182
|
+
client = AnorClient()
|
|
183
|
+
result = client.list_jobs(state=state, limit=limit)
|
|
184
|
+
|
|
185
|
+
jobs_data = result.get("jobs", [])
|
|
186
|
+
|
|
187
|
+
if not jobs_data:
|
|
188
|
+
console.print("[yellow]No jobs found[/yellow]")
|
|
189
|
+
return
|
|
190
|
+
|
|
191
|
+
table = Table(title="Jobs")
|
|
192
|
+
table.add_column("ID", style="cyan")
|
|
193
|
+
table.add_column("Name")
|
|
194
|
+
table.add_column("Grade")
|
|
195
|
+
table.add_column("GPUs", justify="right")
|
|
196
|
+
table.add_column("State")
|
|
197
|
+
table.add_column("Created")
|
|
198
|
+
|
|
199
|
+
for job in jobs_data:
|
|
200
|
+
state_color = {
|
|
201
|
+
"QUEUED": "yellow",
|
|
202
|
+
"ROUTING": "yellow",
|
|
203
|
+
"STARTING": "blue",
|
|
204
|
+
"RUNNING": "green",
|
|
205
|
+
"SUCCEEDED": "green",
|
|
206
|
+
"FAILED": "red",
|
|
207
|
+
"CANCELLED": "dim",
|
|
208
|
+
}.get(job["state"], "white")
|
|
209
|
+
|
|
210
|
+
table.add_row(
|
|
211
|
+
job["id"][:8],
|
|
212
|
+
job.get("name") or "-",
|
|
213
|
+
job["grade"],
|
|
214
|
+
str(job["gpuCount"]),
|
|
215
|
+
f"[{state_color}]{job['state']}[/{state_color}]",
|
|
216
|
+
job.get("queuedAt", "")[:19] if job.get("queuedAt") else "-",
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
console.print(table)
|
|
220
|
+
console.print(f"\nShowing {len(jobs_data)} of {result.get('total', len(jobs_data))} jobs")
|
|
221
|
+
|
|
222
|
+
except APIError as e:
|
|
223
|
+
console.print(f"[red]Error: {e.message}[/red]")
|
|
224
|
+
raise SystemExit(1)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@jobs.command("get")
|
|
228
|
+
@click.argument("job_id")
|
|
229
|
+
@require_auth
|
|
230
|
+
def jobs_get(job_id: str):
|
|
231
|
+
"""Get job details"""
|
|
232
|
+
try:
|
|
233
|
+
with Progress(
|
|
234
|
+
SpinnerColumn(),
|
|
235
|
+
TextColumn("[progress.description]{task.description}"),
|
|
236
|
+
console=console,
|
|
237
|
+
transient=True,
|
|
238
|
+
) as progress:
|
|
239
|
+
progress.add_task("Fetching job...", total=None)
|
|
240
|
+
client = AnorClient()
|
|
241
|
+
job = client.get_job(job_id)
|
|
242
|
+
|
|
243
|
+
console.print("\n[bold]Job Details[/bold]")
|
|
244
|
+
console.print("─" * 40)
|
|
245
|
+
console.print(f"[bold]ID:[/bold] {job['id']}")
|
|
246
|
+
console.print(f"[bold]Grade:[/bold] {job['grade']}")
|
|
247
|
+
console.print(f"[bold]GPUs:[/bold] {job['gpuCount']}")
|
|
248
|
+
console.print(f"[bold]Command:[/bold] {job['command']}")
|
|
249
|
+
if job.get("image"):
|
|
250
|
+
console.print(f"[bold]Image:[/bold] {job['image']}")
|
|
251
|
+
console.print(f"[bold]Max Rate:[/bold] ${job['maxHourlyRate']}/GPU/hr")
|
|
252
|
+
if job.get("softLockPrice"):
|
|
253
|
+
console.print(f"[bold]Locked Price:[/bold] ${job['softLockPrice']}/GPU/hr")
|
|
254
|
+
console.print()
|
|
255
|
+
console.print(f"[bold]State:[/bold] {job['state']}")
|
|
256
|
+
if job.get("stateMessage"):
|
|
257
|
+
console.print(f"[bold]Message:[/bold] {job['stateMessage']}")
|
|
258
|
+
console.print()
|
|
259
|
+
if job.get("queuedAt"):
|
|
260
|
+
console.print(f"[bold]Queued:[/bold] {job['queuedAt']}")
|
|
261
|
+
if job.get("startedAt"):
|
|
262
|
+
console.print(f"[bold]Started:[/bold] {job['startedAt']}")
|
|
263
|
+
if job.get("endedAt"):
|
|
264
|
+
console.print(f"[bold]Ended:[/bold] {job['endedAt']}")
|
|
265
|
+
|
|
266
|
+
except APIError as e:
|
|
267
|
+
console.print(f"[red]Error: {e.message}[/red]")
|
|
268
|
+
raise SystemExit(1)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
@jobs.command("logs")
|
|
272
|
+
@click.argument("job_id")
|
|
273
|
+
@click.option("--follow", "-f", is_flag=True, help="Follow log output (not yet implemented)")
|
|
274
|
+
@require_auth
|
|
275
|
+
def jobs_logs(job_id: str, follow: bool):
|
|
276
|
+
"""Get job logs"""
|
|
277
|
+
try:
|
|
278
|
+
with Progress(
|
|
279
|
+
SpinnerColumn(),
|
|
280
|
+
TextColumn("[progress.description]{task.description}"),
|
|
281
|
+
console=console,
|
|
282
|
+
transient=True,
|
|
283
|
+
) as progress:
|
|
284
|
+
progress.add_task("Fetching logs...", total=None)
|
|
285
|
+
client = AnorClient()
|
|
286
|
+
logs = client.get_job_logs(job_id)
|
|
287
|
+
|
|
288
|
+
if logs:
|
|
289
|
+
console.print(logs)
|
|
290
|
+
else:
|
|
291
|
+
console.print("[yellow]No logs available yet[/yellow]")
|
|
292
|
+
|
|
293
|
+
except APIError as e:
|
|
294
|
+
console.print(f"[red]Error: {e.message}[/red]")
|
|
295
|
+
raise SystemExit(1)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
@jobs.command("cancel")
|
|
299
|
+
@click.argument("job_id")
|
|
300
|
+
@click.confirmation_option(prompt="Are you sure you want to cancel this job?")
|
|
301
|
+
@require_auth
|
|
302
|
+
def jobs_cancel(job_id: str):
|
|
303
|
+
"""Cancel a job"""
|
|
304
|
+
try:
|
|
305
|
+
with Progress(
|
|
306
|
+
SpinnerColumn(),
|
|
307
|
+
TextColumn("[progress.description]{task.description}"),
|
|
308
|
+
console=console,
|
|
309
|
+
transient=True,
|
|
310
|
+
) as progress:
|
|
311
|
+
progress.add_task("Cancelling job...", total=None)
|
|
312
|
+
client = AnorClient()
|
|
313
|
+
job = client.cancel_job(job_id)
|
|
314
|
+
|
|
315
|
+
console.print(f"[green]Job {job_id} cancelled[/green]")
|
|
316
|
+
|
|
317
|
+
except APIError as e:
|
|
318
|
+
console.print(f"[red]Error: {e.message}[/red]")
|
|
319
|
+
raise SystemExit(1)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
# ============ Pricing Command ============
|
|
323
|
+
|
|
324
|
+
@main.command()
|
|
325
|
+
def pricing():
|
|
326
|
+
"""Show current compute pricing"""
|
|
327
|
+
try:
|
|
328
|
+
with Progress(
|
|
329
|
+
SpinnerColumn(),
|
|
330
|
+
TextColumn("[progress.description]{task.description}"),
|
|
331
|
+
console=console,
|
|
332
|
+
transient=True,
|
|
333
|
+
) as progress:
|
|
334
|
+
progress.add_task("Fetching pricing...", total=None)
|
|
335
|
+
client = AnorClient()
|
|
336
|
+
prices = client.get_pricing()
|
|
337
|
+
|
|
338
|
+
table = Table(title="Anor Compute Pricing")
|
|
339
|
+
table.add_column("Grade", style="bold")
|
|
340
|
+
table.add_column("Price ($/GPU/hr)", justify="right")
|
|
341
|
+
table.add_column("Available GPUs", justify="right")
|
|
342
|
+
|
|
343
|
+
for tier in prices:
|
|
344
|
+
table.add_row(
|
|
345
|
+
tier["grade"].title(),
|
|
346
|
+
f"${tier['pricePerGpuHour']:.2f}",
|
|
347
|
+
str(tier.get("availableGpus", 0)),
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
console.print(table)
|
|
351
|
+
console.print("\n[dim]Prices subject to real-time market conditions.[/dim]")
|
|
352
|
+
console.print("[dim]Use --max-rate to cap your spend.[/dim]")
|
|
353
|
+
|
|
354
|
+
except APIError as e:
|
|
355
|
+
console.print(f"[red]Error: {e.message}[/red]")
|
|
356
|
+
raise SystemExit(1)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
# ============ Balance Command ============
|
|
360
|
+
|
|
361
|
+
@main.command()
|
|
362
|
+
@require_auth
|
|
363
|
+
def balance():
|
|
364
|
+
"""Show account balance"""
|
|
365
|
+
try:
|
|
366
|
+
with Progress(
|
|
367
|
+
SpinnerColumn(),
|
|
368
|
+
TextColumn("[progress.description]{task.description}"),
|
|
369
|
+
console=console,
|
|
370
|
+
transient=True,
|
|
371
|
+
) as progress:
|
|
372
|
+
progress.add_task("Fetching balance...", total=None)
|
|
373
|
+
client = AnorClient()
|
|
374
|
+
data = client.get_balance()
|
|
375
|
+
|
|
376
|
+
console.print(f"\n[bold]Available Balance:[/bold] ${data.get('available', 0):.2f}")
|
|
377
|
+
console.print(f"[bold]Pending:[/bold] ${data.get('pending', 0):.2f}")
|
|
378
|
+
console.print(f"[bold]Total:[/bold] ${data.get('total', 0):.2f}")
|
|
379
|
+
|
|
380
|
+
except APIError as e:
|
|
381
|
+
console.print(f"[red]Error: {e.message}[/red]")
|
|
382
|
+
raise SystemExit(1)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
if __name__ == "__main__":
|
|
386
|
+
main()
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Configuration management for Anor CLI"""
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
CONFIG_DIR = Path.home() / ".anor"
|
|
8
|
+
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
9
|
+
|
|
10
|
+
DEFAULT_API_URL = "https://anorcloud.com"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def ensure_config_dir():
|
|
14
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def load_config() -> dict:
|
|
18
|
+
if not CONFIG_FILE.exists():
|
|
19
|
+
return {}
|
|
20
|
+
try:
|
|
21
|
+
return json.loads(CONFIG_FILE.read_text())
|
|
22
|
+
except Exception:
|
|
23
|
+
return {}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def save_config(config: dict):
|
|
27
|
+
ensure_config_dir()
|
|
28
|
+
CONFIG_FILE.write_text(json.dumps(config, indent=2))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def get_api_url() -> str:
|
|
32
|
+
config = load_config()
|
|
33
|
+
return config.get("api_url") or os.environ.get("ANOR_API_URL") or DEFAULT_API_URL
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get_api_key() -> Optional[str]:
|
|
37
|
+
config = load_config()
|
|
38
|
+
return config.get("api_key") or os.environ.get("ANOR_API_KEY")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def set_api_key(api_key: str):
|
|
42
|
+
config = load_config()
|
|
43
|
+
config["api_key"] = api_key
|
|
44
|
+
save_config(config)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def set_api_url(api_url: str):
|
|
48
|
+
config = load_config()
|
|
49
|
+
config["api_url"] = api_url
|
|
50
|
+
save_config(config)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def clear_credentials():
|
|
54
|
+
config = load_config()
|
|
55
|
+
config.pop("api_key", None)
|
|
56
|
+
save_config(config)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def is_logged_in() -> bool:
|
|
60
|
+
return get_api_key() is not None
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "anor"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Anor Cloud GPU CLI - Managed GPU compute made simple"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
authors = [
|
|
12
|
+
{ name = "Anor Cloud", email = "support@anor.cloud" }
|
|
13
|
+
]
|
|
14
|
+
keywords = ["gpu", "cloud", "compute", "cli", "machine-learning", "ai"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Environment :: Console",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Intended Audience :: Science/Research",
|
|
20
|
+
"License :: OSI Approved :: MIT License",
|
|
21
|
+
"Operating System :: OS Independent",
|
|
22
|
+
"Programming Language :: Python :: 3",
|
|
23
|
+
"Programming Language :: Python :: 3.8",
|
|
24
|
+
"Programming Language :: Python :: 3.9",
|
|
25
|
+
"Programming Language :: Python :: 3.10",
|
|
26
|
+
"Programming Language :: Python :: 3.11",
|
|
27
|
+
"Programming Language :: Python :: 3.12",
|
|
28
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
29
|
+
]
|
|
30
|
+
requires-python = ">=3.8"
|
|
31
|
+
dependencies = [
|
|
32
|
+
"click>=8.0.0",
|
|
33
|
+
"httpx>=0.24.0",
|
|
34
|
+
"rich>=13.0.0",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
[project.scripts]
|
|
38
|
+
anor = "anor.cli:main"
|
|
39
|
+
|
|
40
|
+
[project.urls]
|
|
41
|
+
Homepage = "https://anor.cloud"
|
|
42
|
+
Documentation = "https://docs.anor.cloud"
|
|
43
|
+
Repository = "https://github.com/Ornn-AI/ornn-gpu-marketplace"
|
|
44
|
+
|
|
45
|
+
[tool.hatch.build.targets.wheel]
|
|
46
|
+
packages = ["anor"]
|