gpyou 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.
- gpyou-0.1.0/PKG-INFO +26 -0
- gpyou-0.1.0/README.md +11 -0
- gpyou-0.1.0/pyproject.toml +31 -0
- gpyou-0.1.0/src/gpyou/__init__.py +1 -0
- gpyou-0.1.0/src/gpyou/agent/__init__.py +0 -0
- gpyou-0.1.0/src/gpyou/agent/cli.py +134 -0
- gpyou-0.1.0/src/gpyou/cli.py +437 -0
- gpyou-0.1.0/src/gpyou/cli_models.py +87 -0
- gpyou-0.1.0/src/gpyou/cli_tables.py +35 -0
- gpyou-0.1.0/src/gpyou/client.py +399 -0
- gpyou-0.1.0/src/gpyou/guide.py +297 -0
- gpyou-0.1.0/src/gpyou/menu.py +318 -0
- gpyou-0.1.0/src/gpyou/onboarding.py +137 -0
- gpyou-0.1.0/src/gpyou/units.py +23 -0
gpyou-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: gpyou
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Command-line client for GPYou: prepaid GPUs for fine-tuning and serving open models
|
|
5
|
+
Project-URL: Homepage, https://app.gpyou.ai
|
|
6
|
+
License-Expression: LicenseRef-Proprietary
|
|
7
|
+
Requires-Python: >=3.12
|
|
8
|
+
Requires-Dist: authlib<2,>=1.4
|
|
9
|
+
Requires-Dist: httpx<1,>=0.28
|
|
10
|
+
Requires-Dist: keyring<26,>=25
|
|
11
|
+
Requires-Dist: pyjwt[crypto]<3,>=2.15
|
|
12
|
+
Requires-Dist: qrcode<9,>=8
|
|
13
|
+
Requires-Dist: typer<1,>=0.15
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# gpyou
|
|
17
|
+
|
|
18
|
+
Command-line client for [GPYou](https://app.gpyou.ai): prepaid GPUs for fine-tuning and serving open models.
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
uv tool install gpyou # or: pipx install gpyou
|
|
22
|
+
gpyou signup # or: gpyou login
|
|
23
|
+
gpyou # interactive menu
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
The CLI talks to `https://app.gpyou.ai`. Set `GPYOU_API_URL` to use another GPYou service, such as a development server.
|
gpyou-0.1.0/README.md
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# gpyou
|
|
2
|
+
|
|
3
|
+
Command-line client for [GPYou](https://app.gpyou.ai): prepaid GPUs for fine-tuning and serving open models.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
uv tool install gpyou # or: pipx install gpyou
|
|
7
|
+
gpyou signup # or: gpyou login
|
|
8
|
+
gpyou # interactive menu
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
The CLI talks to `https://app.gpyou.ai`. Set `GPYOU_API_URL` to use another GPYou service, such as a development server.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# The stand-alone `gpyou` CLI published to PyPI. scripts/build_cli.py copies the CLI modules next to this file and
|
|
2
|
+
# builds it; the platform itself (pyproject.toml at the repository root) is never published.
|
|
3
|
+
[project]
|
|
4
|
+
name = "gpyou"
|
|
5
|
+
version = "0.1.0"
|
|
6
|
+
description = "Command-line client for GPYou: prepaid GPUs for fine-tuning and serving open models"
|
|
7
|
+
readme = "README.md"
|
|
8
|
+
requires-python = ">=3.12"
|
|
9
|
+
license = "LicenseRef-Proprietary"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"httpx>=0.28,<1",
|
|
12
|
+
"typer>=0.15,<1",
|
|
13
|
+
"keyring>=25,<26",
|
|
14
|
+
"authlib>=1.4,<2",
|
|
15
|
+
"pyjwt[crypto]>=2.15,<3",
|
|
16
|
+
"qrcode>=8,<9",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[project.urls]
|
|
20
|
+
Homepage = "https://app.gpyou.ai"
|
|
21
|
+
|
|
22
|
+
[project.scripts]
|
|
23
|
+
gpyou = "gpyou.cli:app"
|
|
24
|
+
docker-credential-gpyou = "gpyou.client:docker_helper"
|
|
25
|
+
|
|
26
|
+
[build-system]
|
|
27
|
+
requires = ["hatchling>=1.27,<2"]
|
|
28
|
+
build-backend = "hatchling.build"
|
|
29
|
+
|
|
30
|
+
[tool.hatch.build.targets.wheel]
|
|
31
|
+
packages = ["src/gpyou"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""GPYou control plane and client."""
|
|
File without changes
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""gpyou agent: a conversational console. Plain commands stay one '!' away; exit returns to the shell."""
|
|
2
|
+
import json
|
|
3
|
+
import secrets
|
|
4
|
+
import shlex
|
|
5
|
+
import sys
|
|
6
|
+
from contextlib import closing
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
from gpyou.client import Client
|
|
13
|
+
from gpyou.menu import Spinner, explain, interactive, say, wait_operation
|
|
14
|
+
|
|
15
|
+
ALLOWED = {("POST", "/v1/finetunes"), ("POST", "/v1/endpoints"), ("POST", "/v1/instances"), ("POST", "/v1/checkout")}
|
|
16
|
+
DELETABLE = ("/v1/instances/", "/v1/endpoints/")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def execute(client, proposal):
|
|
20
|
+
"""Run a confirmed proposal with the customer's own session; only known platform actions are accepted."""
|
|
21
|
+
if proposal["action"] == "upload":
|
|
22
|
+
return client.upload(proposal["bucket_id"], Path(proposal["path"]).expanduser())
|
|
23
|
+
method, path = proposal["method"], proposal["path"]
|
|
24
|
+
if (method, path) not in ALLOWED and not (method == "DELETE" and path.startswith(DELETABLE) and "/" not in path[14:]):
|
|
25
|
+
raise ValueError("The assistant proposed an action this CLI does not run")
|
|
26
|
+
result = client.request(method, path, json=proposal.get("body"), key=secrets.token_hex(16) if method == "POST" else None)
|
|
27
|
+
if interactive() and proposal["action"] in ("finetune_start", "instance_launch"):
|
|
28
|
+
until = "done" if proposal["action"] == "finetune_start" else "ready"
|
|
29
|
+
result = {**result, "waited": wait_operation(client, result["id"], until=until)}
|
|
30
|
+
return result
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def confirm(proposal):
|
|
34
|
+
say("\nProposed: " + proposal["summary"])
|
|
35
|
+
return typer.confirm("Run it?", default=False, err=True)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
WINDOW = 40 # messages sent per turn; the endpoint accepts 60, older ones are summarised in one line
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def window(transcript):
|
|
42
|
+
"""The recent transcript, with a one-line note standing in for what was dropped, always starting at a user turn."""
|
|
43
|
+
if len(transcript) <= WINDOW:
|
|
44
|
+
return list(transcript)
|
|
45
|
+
recent = transcript[-WINDOW:]
|
|
46
|
+
while recent and recent[0]["role"] != "user":
|
|
47
|
+
recent = recent[1:]
|
|
48
|
+
dropped = len(transcript) - len(recent)
|
|
49
|
+
return [{"role": "user", "content": f"(Earlier in this session we exchanged {dropped} messages that are no longer shown.)"},
|
|
50
|
+
{"role": "assistant", "content": "Understood; I will ask if I need something from earlier."}, *recent]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def turn(client, transcript, content, decide=confirm):
|
|
54
|
+
"""One exchange. Confirmed proposals run and their result is fed back as the next user message."""
|
|
55
|
+
transcript.append({"role": "user", "content": content})
|
|
56
|
+
reply, proposals, spinner = [], [], Spinner()
|
|
57
|
+
spinner.start("Thinking...")
|
|
58
|
+
try:
|
|
59
|
+
for event in client.stream_agent(window(transcript)):
|
|
60
|
+
if event["type"] == "text":
|
|
61
|
+
if not reply and not event["delta"].strip():
|
|
62
|
+
continue # reasoning models often open with blank lines
|
|
63
|
+
spinner.stop()
|
|
64
|
+
reply.append(event["delta"])
|
|
65
|
+
if interactive():
|
|
66
|
+
typer.echo(event["delta"], nl=False, err=True)
|
|
67
|
+
elif event["type"] == "tool":
|
|
68
|
+
spinner.stop()
|
|
69
|
+
if interactive() and reply and not reply[-1].endswith("\n"):
|
|
70
|
+
say("") # the tool line goes under the text streamed so far, not after it
|
|
71
|
+
say(f" · {event['name']}")
|
|
72
|
+
spinner.start("Working...")
|
|
73
|
+
elif event["type"] == "proposal":
|
|
74
|
+
proposals.append(event)
|
|
75
|
+
elif event["type"] == "error":
|
|
76
|
+
raise RuntimeError(event["message"])
|
|
77
|
+
finally:
|
|
78
|
+
spinner.stop()
|
|
79
|
+
if interactive():
|
|
80
|
+
say("")
|
|
81
|
+
transcript.append({"role": "assistant", "content": "".join(reply) or "(no reply)"})
|
|
82
|
+
for proposal in proposals:
|
|
83
|
+
if decide(proposal):
|
|
84
|
+
result = execute(client, proposal)
|
|
85
|
+
if proposal["action"] == "credit_add" and result.get("url"):
|
|
86
|
+
from gpyou.cli import open_browser
|
|
87
|
+
say("Complete the payment in your browser." if open_browser(result["url"]) else "Open this link to pay: " + result["url"])
|
|
88
|
+
return turn(client, transcript, f"I confirmed and ran: {proposal['summary']}. Result: {json.dumps(result, default=str)}", decide)
|
|
89
|
+
transcript.append({"role": "user", "content": "I declined: " + proposal["summary"]})
|
|
90
|
+
transcript.append({"role": "assistant", "content": "Understood, not doing that."})
|
|
91
|
+
return "".join(reply), proposals
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def command(line):
|
|
95
|
+
"""Run a plain gpyou command in-process, e.g. '!credits balance'."""
|
|
96
|
+
from gpyou.cli import app
|
|
97
|
+
try:
|
|
98
|
+
typer.main.get_command(app).main(shlex.split(line), prog_name="gpyou", standalone_mode=False)
|
|
99
|
+
except typer.Exit:
|
|
100
|
+
pass
|
|
101
|
+
except (typer.exceptions.TyperException, ValueError) as exc: # usage errors and bad parameters
|
|
102
|
+
say(str(exc))
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def repl(client):
|
|
106
|
+
import readline # noqa: F401 (line editing and history for the prompt)
|
|
107
|
+
say("GPYou assistant. Ask about your account, datasets, fine-tuning or serving. "
|
|
108
|
+
"Type exit to leave; !<command> runs a gpyou command, e.g. !credits balance.")
|
|
109
|
+
transcript = []
|
|
110
|
+
while True:
|
|
111
|
+
try:
|
|
112
|
+
line = typer.prompt("you", err=True, prompt_suffix="> ").strip()
|
|
113
|
+
except typer.Abort:
|
|
114
|
+
return
|
|
115
|
+
if line in ("exit", "quit"):
|
|
116
|
+
return
|
|
117
|
+
if line.startswith("!"):
|
|
118
|
+
command(line[1:])
|
|
119
|
+
elif line:
|
|
120
|
+
try:
|
|
121
|
+
turn(client, transcript, line)
|
|
122
|
+
except (httpx.HTTPError, RuntimeError) as exc:
|
|
123
|
+
say(explain(exc))
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def run(prompt=None):
|
|
127
|
+
with closing(Client()) as client:
|
|
128
|
+
if prompt is None and interactive():
|
|
129
|
+
return repl(client)
|
|
130
|
+
if prompt is None:
|
|
131
|
+
prompt = sys.stdin.read().strip()
|
|
132
|
+
reply, proposals = turn(client, [], prompt, decide=confirm if interactive() else lambda p: False)
|
|
133
|
+
if not interactive():
|
|
134
|
+
typer.echo(json.dumps({"reply": reply, "proposals": proposals}, indent=2))
|
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import secrets
|
|
4
|
+
import tempfile
|
|
5
|
+
from contextlib import closing
|
|
6
|
+
from decimal import Decimal
|
|
7
|
+
from enum import StrEnum
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Annotated
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
import typer
|
|
13
|
+
from keyring.errors import KeyringError
|
|
14
|
+
from typer.core import TyperGroup
|
|
15
|
+
|
|
16
|
+
from gpyou.client import Client, api_url, error_text
|
|
17
|
+
from gpyou.onboarding import Method
|
|
18
|
+
from gpyou.units import micros
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class CLIGroup(TyperGroup):
|
|
22
|
+
def invoke(self, ctx):
|
|
23
|
+
try:
|
|
24
|
+
return super().invoke(ctx)
|
|
25
|
+
except (typer.Exit, typer.Abort):
|
|
26
|
+
raise
|
|
27
|
+
except httpx.HTTPStatusError as exc:
|
|
28
|
+
typer.echo(error_text(exc.response), err=True)
|
|
29
|
+
raise typer.Exit(1) from exc
|
|
30
|
+
except httpx.HTTPError as exc:
|
|
31
|
+
typer.echo("Network request failed. Check the service URL and connection.", err=True)
|
|
32
|
+
raise typer.Exit(1) from exc
|
|
33
|
+
except (OSError, RuntimeError, ValueError, KeyringError) as exc:
|
|
34
|
+
typer.echo(str(exc), err=True)
|
|
35
|
+
raise typer.Exit(1) from exc
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
app = typer.Typer(cls=CLIGroup, help="gpyou_: prepaid GPU jobs, model development, and persistent storage.", no_args_is_help=True)
|
|
39
|
+
groups = {name: typer.Typer(no_args_is_help=True) for name in (
|
|
40
|
+
"credits", "billing", "identities", "instances", "finetune", "endpoints", "buckets", "storage", "registry", "images", "security")}
|
|
41
|
+
for name, group in groups.items():
|
|
42
|
+
app.add_typer(group, name=name)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@app.callback()
|
|
46
|
+
def main(ctx: typer.Context, json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON (all commands).")):
|
|
47
|
+
"""Before, `--json` was accepted and ignored, so a terminal still got text."""
|
|
48
|
+
from gpyou import menu
|
|
49
|
+
menu.FORCE_JSON = json_output
|
|
50
|
+
ctx.call_on_close(lambda: setattr(menu, 'FORCE_JSON', False)) # never leaks into the next command
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def output(value):
|
|
54
|
+
typer.echo(json.dumps(value, indent=2, default=str))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def open_browser(url):
|
|
58
|
+
import webbrowser
|
|
59
|
+
try:
|
|
60
|
+
return webbrowser.open(url)
|
|
61
|
+
except webbrowser.Error:
|
|
62
|
+
return False
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def cents(amount):
|
|
66
|
+
value = Decimal(str(amount)) * 100
|
|
67
|
+
if not value.is_finite() or value != value.to_integral_value():
|
|
68
|
+
raise typer.BadParameter("USD payment amounts must be finite and contain no fractional cents")
|
|
69
|
+
return int(value)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def call(method, path, *, body=None, key=None, params=None):
|
|
73
|
+
with closing(Client()) as client:
|
|
74
|
+
result = client.request(method, path, json=body, key=key, params=params)
|
|
75
|
+
output(result)
|
|
76
|
+
return result
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@app.command()
|
|
80
|
+
def signup(browser: bool = False, method: Method | None = None):
|
|
81
|
+
"""Verify email in the CLI; after approval, choose passkey or authenticator setup."""
|
|
82
|
+
if not browser:
|
|
83
|
+
from gpyou.onboarding import run
|
|
84
|
+
return run('signup', method)
|
|
85
|
+
try:
|
|
86
|
+
url = api_url() + "/signup"
|
|
87
|
+
except ValueError as exc:
|
|
88
|
+
raise typer.BadParameter(str(exc)) from exc
|
|
89
|
+
opened = open_browser(url)
|
|
90
|
+
output({"signup_url": url, "browser_opened": opened,
|
|
91
|
+
"next_step": "Create your account in the browser and confirm your email. "
|
|
92
|
+
"Then run gpyou login, or add a passkey under Security first."})
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@app.command()
|
|
96
|
+
def login(method: Method | None = None):
|
|
97
|
+
"""Sign in. Without --method a terminal asks: email code (default) or passkey."""
|
|
98
|
+
from gpyou.onboarding import run
|
|
99
|
+
run('login', method)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@groups['security'].command('setup')
|
|
103
|
+
def security_setup(method: Method, login_method: Method = Method.passkey):
|
|
104
|
+
"""Sign in freshly, then add a passkey or authenticator app."""
|
|
105
|
+
from gpyou.onboarding import run
|
|
106
|
+
run('setup', method, login_method)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@app.command()
|
|
110
|
+
def status():
|
|
111
|
+
"""Which API this terminal is signed in to, its mode, providers, schema and your account in one look."""
|
|
112
|
+
from gpyou.menu import dollars, interactive, say
|
|
113
|
+
with closing(Client()) as client:
|
|
114
|
+
info = client.request("GET", "/v1/status")
|
|
115
|
+
if not interactive():
|
|
116
|
+
return output({"api_url": client.url, **info})
|
|
117
|
+
say(f"API: {client.url} mode: {info['mode']} providers: {', '.join(info['providers']) or 'none'} "
|
|
118
|
+
f"schema: {info['schema_revision'] or 'unknown'} assistant: {'on' if info['assistant'] else 'off'}")
|
|
119
|
+
say(f"Account {info['tenant_id']} ({info['role']}): {dollars(info['available_micro_usd'])} available, "
|
|
120
|
+
f"up to {info['instance_limit']} GPUs at once.")
|
|
121
|
+
if info["mode"] == "simulation":
|
|
122
|
+
say("Simulation: offers are made up and nothing is rented; endpoints answer with a fixed sentence.")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@app.command()
|
|
126
|
+
def logout():
|
|
127
|
+
with closing(Client()) as client:
|
|
128
|
+
client.logout()
|
|
129
|
+
output({"signed_out": True})
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@groups["credits"].command("balance")
|
|
133
|
+
def balance():
|
|
134
|
+
"""Available, total and reserved credit: a sentence on a terminal, JSON when piped."""
|
|
135
|
+
from gpyou.menu import interactive, show_balance
|
|
136
|
+
with closing(Client()) as client:
|
|
137
|
+
if interactive():
|
|
138
|
+
return show_balance(client)
|
|
139
|
+
output(client.request("GET", "/v1/credits"))
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@groups["credits"].command("usage")
|
|
143
|
+
def credit_usage():
|
|
144
|
+
call("GET", "/v1/usage")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@groups["credits"].command("add")
|
|
148
|
+
def add_credit(amount: Annotated[float, typer.Argument(min=10, max=1000)],
|
|
149
|
+
save_for_topup: bool = False, request_id: str | None = None, browser: bool = True):
|
|
150
|
+
result = call("POST", "/v1/checkout", body={"amount_cents": cents(amount),
|
|
151
|
+
"save_for_topup": save_for_topup}, key=request_id or secrets.token_hex(16))
|
|
152
|
+
if browser and result.get("url"):
|
|
153
|
+
open_browser(result["url"])
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@groups["billing"].command("auto-topup")
|
|
157
|
+
def auto_topup(threshold: float, amount: float, monthly_limit: float, enabled: bool = True):
|
|
158
|
+
call("PUT", "/v1/billing/auto-topup", body={"enabled": enabled,
|
|
159
|
+
"threshold_cents": cents(threshold), "amount_cents": cents(amount),
|
|
160
|
+
"monthly_limit_cents": cents(monthly_limit)})
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@groups["billing"].command("payments")
|
|
164
|
+
def payments():
|
|
165
|
+
call("GET", "/v1/billing/payments")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@groups["billing"].command("recover")
|
|
169
|
+
def recover_payment(payment_id: str, request_id: str | None = None, browser: bool = True):
|
|
170
|
+
"""Open a manual replacement purchase; automatic top-ups stay paused."""
|
|
171
|
+
result = call("POST", f"/v1/billing/payments/{payment_id}/recover",
|
|
172
|
+
key=request_id or secrets.token_hex(16))
|
|
173
|
+
if browser and result.get("url"):
|
|
174
|
+
open_browser(result["url"])
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@groups["identities"].command("list")
|
|
178
|
+
def identities():
|
|
179
|
+
call("GET", "/v1/identities")
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@groups["identities"].command("trust")
|
|
183
|
+
def trust(subject: str, repository_id: str, repository_owner_id: str, read_only: bool = False):
|
|
184
|
+
call("POST", "/v1/identities", body={"subject": subject, "repository_id": repository_id, "repository_owner_id": repository_owner_id,
|
|
185
|
+
"scopes": ["resources:read"] if read_only else ["resources:read", "resources:write", "inference"]})
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@groups["identities"].command("revoke")
|
|
189
|
+
def revoke(identity_id: str):
|
|
190
|
+
call("DELETE", "/v1/identities/" + identity_id)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class Provider(StrEnum):
|
|
194
|
+
runpod = "runpod"
|
|
195
|
+
vast = "vast"
|
|
196
|
+
verda = "verda"
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def container_request(image, gpu, count, max_price, country, max_transfer, disk, max_duration, registry_id, provider=None):
|
|
200
|
+
return {"image": image, "gpu": gpu, "count": count, "max_hourly_micro_usd": micros(max_price),
|
|
201
|
+
"country": country, "max_transfer_micro_usd_gb": micros(max_transfer),
|
|
202
|
+
"disk_gb": disk, "max_duration_seconds": max_duration, "registry_id": registry_id, "provider": provider}
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@app.command()
|
|
206
|
+
def offers(image: str, max_price: float, gpu: str = "RTX 4090", count: int = 1,
|
|
207
|
+
country: str | None = None, max_transfer: float = 0, disk: int = 40, provider: Provider | None = None):
|
|
208
|
+
from gpyou.cli_tables import show_offers
|
|
209
|
+
with closing(Client()) as client:
|
|
210
|
+
show_offers(client.request("POST", "/v1/quotes",
|
|
211
|
+
json=container_request(image, gpu, count, max_price, country, max_transfer, disk, None, None, provider)))
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
@app.command()
|
|
215
|
+
def gpus(country: str | None = None, provider: Provider | None = None):
|
|
216
|
+
"""Live GPU offers, cheapest per model. On a terminal: pick one, then choose what to run on it."""
|
|
217
|
+
from gpyou.guide import all_offers, start_from_gpu
|
|
218
|
+
from gpyou.menu import interactive
|
|
219
|
+
with closing(Client()) as client:
|
|
220
|
+
rows = all_offers(client, country, provider)
|
|
221
|
+
if not interactive():
|
|
222
|
+
return output([offer for _, offer in rows])
|
|
223
|
+
start_from_gpu(client, rows, provider)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
@app.command()
|
|
227
|
+
def launch(image: Annotated[str | None, typer.Argument()] = None,
|
|
228
|
+
max_price: Annotated[float | None, typer.Argument()] = None, gpu: str = "RTX 4090", count: int = 1,
|
|
229
|
+
country: str | None = None, max_transfer: float = 0, disk: int = 40,
|
|
230
|
+
max_duration: int | None = None, registry_id: str | None = None,
|
|
231
|
+
yes: bool = typer.Option(False, "--yes", "-y"), request_id: str | None = None,
|
|
232
|
+
spec: Path | None = None, quote_id: str | None = None, provider: Provider | None = None):
|
|
233
|
+
if not (spec or quote_id) and (image is None or max_price is None):
|
|
234
|
+
raise typer.BadParameter("Supply IMAGE MAX_PRICE, --spec, or --quote-id")
|
|
235
|
+
body = (json.loads(spec.read_text()) if spec else container_request(image, gpu, count, max_price,
|
|
236
|
+
country, max_transfer, disk, max_duration, registry_id, provider)) if not quote_id else None
|
|
237
|
+
with closing(Client()) as client:
|
|
238
|
+
quote = client.request("GET", "/v1/quotes/" + quote_id) if quote_id else client.request("POST", "/v1/quotes", json=body)
|
|
239
|
+
typer.echo(json.dumps(quote, indent=2), err=True)
|
|
240
|
+
if not quote["offers"]:
|
|
241
|
+
raise typer.Exit(1)
|
|
242
|
+
if not yes:
|
|
243
|
+
typer.confirm("Launch at these rates? GPU disk is temporary; transfer is charged separately.", abort=True, err=True)
|
|
244
|
+
key = request_id or secrets.token_hex(16)
|
|
245
|
+
typer.echo("Request ID: " + key, err=True)
|
|
246
|
+
call("POST", "/v1/instances", body={"quote_id": quote["id"]}, key=key)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
@groups["instances"].command("list")
|
|
250
|
+
def instances():
|
|
251
|
+
call("GET", "/v1/instances")
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
@groups["instances"].command("get")
|
|
255
|
+
def instance_get(operation_id: str):
|
|
256
|
+
call("GET", "/v1/operations/" + operation_id)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
@app.command()
|
|
260
|
+
def logs(operation_id: str):
|
|
261
|
+
"""Recent output of a container, training job or endpoint: plain text on a terminal, JSON when piped."""
|
|
262
|
+
from gpyou.menu import interactive
|
|
263
|
+
with closing(Client()) as client:
|
|
264
|
+
result = client.request("GET", f"/v1/instances/{operation_id}/logs")
|
|
265
|
+
if not interactive():
|
|
266
|
+
return output(result)
|
|
267
|
+
typer.echo(result.get("logs") or "No log output yet.", nl=not (result.get("logs") or "").endswith("\n"))
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def local_time(timestamp):
|
|
271
|
+
"""The terminal's local time with its zone: notices showed a bare "18:27", unclear to anyone reading them elsewhere."""
|
|
272
|
+
from datetime import datetime
|
|
273
|
+
return datetime.fromtimestamp(timestamp).astimezone().strftime("%Y-%m-%d %H:%M %Z")
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
@app.command()
|
|
277
|
+
def notifications():
|
|
278
|
+
"""Job and account updates, newest first: plain text on a terminal, JSON when piped. The same notices are emailed."""
|
|
279
|
+
from gpyou.menu import interactive
|
|
280
|
+
with closing(Client()) as client:
|
|
281
|
+
items = client.request("GET", "/v1/notifications")
|
|
282
|
+
if not interactive():
|
|
283
|
+
return output(items)
|
|
284
|
+
for item in items or [{"message": "No notifications yet."}]:
|
|
285
|
+
typer.echo(f"{local_time(item['created_at'])} {item['message']}" if item.get("created_at") else item["message"])
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
@app.command()
|
|
289
|
+
def terminate(operation_id: str):
|
|
290
|
+
call("DELETE", "/v1/instances/" + operation_id)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
@groups["buckets"].command("create")
|
|
294
|
+
def bucket_create(name: str):
|
|
295
|
+
call("POST", "/v1/buckets", body={"name": name})
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
@groups["buckets"].command("list")
|
|
299
|
+
def buckets():
|
|
300
|
+
call("GET", "/v1/buckets")
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
@groups["buckets"].command("delete")
|
|
304
|
+
def bucket_delete(bucket_id: str):
|
|
305
|
+
call("DELETE", "/v1/buckets/" + bucket_id)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
@groups["storage"].command("upload")
|
|
309
|
+
def upload(bucket_id: str, source: Path, key: str | None = None, upload_id: str | None = None):
|
|
310
|
+
with closing(Client()) as client:
|
|
311
|
+
output(client.upload(bucket_id, source, key, upload_id))
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
@groups["storage"].command("download")
|
|
315
|
+
def download(object_id: str, destination: Path):
|
|
316
|
+
with closing(Client()) as client:
|
|
317
|
+
output(client.download(object_id, destination))
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
@groups["storage"].command("list")
|
|
321
|
+
def objects(bucket_id: str):
|
|
322
|
+
call("GET", f"/v1/buckets/{bucket_id}/objects")
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
@groups["storage"].command("delete")
|
|
326
|
+
def object_delete(object_id: str):
|
|
327
|
+
call("DELETE", "/v1/objects/" + object_id)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
@groups["storage"].command("abort")
|
|
331
|
+
def upload_abort(upload_id: str):
|
|
332
|
+
"""Cancel an incomplete upload and release its reserved storage."""
|
|
333
|
+
call("DELETE", "/v1/uploads/" + upload_id)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
@groups["registry"].command("add")
|
|
337
|
+
def registry_add(server: str, username: str):
|
|
338
|
+
password = typer.prompt("Registry password or pull token", hide_input=True, err=True)
|
|
339
|
+
call("POST", "/v1/registries", body={"server": server, "username": username, "password": password})
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
@groups["registry"].command("list")
|
|
343
|
+
def registry_list():
|
|
344
|
+
call("GET", "/v1/registries")
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
@groups["registry"].command("remove")
|
|
348
|
+
def registry_remove(registry_id: str):
|
|
349
|
+
call("DELETE", "/v1/registries/" + registry_id)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
@groups["registry"].command("login")
|
|
353
|
+
def registry_login():
|
|
354
|
+
with closing(Client()) as client:
|
|
355
|
+
result = client.request("GET", "/v1/registry/config")
|
|
356
|
+
if not result["server"]:
|
|
357
|
+
raise typer.BadParameter("Registry is not configured")
|
|
358
|
+
path = Path(os.environ.get("DOCKER_CONFIG", str(Path.home() / ".docker"))) / "config.json"
|
|
359
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
360
|
+
data = json.loads(path.read_text()) if path.exists() else {}
|
|
361
|
+
if not isinstance(data, dict) or not isinstance(data.get("credHelpers", {}), dict):
|
|
362
|
+
raise ValueError("Docker config and credHelpers must be JSON objects")
|
|
363
|
+
data.setdefault("credHelpers", {})[result["server"]] = "gpyou"
|
|
364
|
+
with tempfile.NamedTemporaryFile(mode="w", dir=path.parent, delete=False) as file:
|
|
365
|
+
temporary = Path(file.name)
|
|
366
|
+
try:
|
|
367
|
+
file.write(json.dumps(data, indent=2) + "\n")
|
|
368
|
+
file.flush()
|
|
369
|
+
temporary.replace(path)
|
|
370
|
+
finally:
|
|
371
|
+
temporary.unlink(missing_ok=True)
|
|
372
|
+
output(result)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
@groups["images"].command("list")
|
|
376
|
+
def images():
|
|
377
|
+
call("GET", "/v1/images")
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
@groups["images"].command("delete")
|
|
381
|
+
def image_delete(image_id: str):
|
|
382
|
+
call("DELETE", "/v1/images/" + image_id)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
@groups["finetune"].command("wait")
|
|
386
|
+
def finetune_wait(operation_id: str, timeout: int = 3600):
|
|
387
|
+
"""Follow a training job with live feedback until it completes or fails."""
|
|
388
|
+
from gpyou.menu import wait_operation
|
|
389
|
+
with closing(Client()) as client:
|
|
390
|
+
report_wait(wait_operation(client, operation_id, until="done", timeout=timeout))
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
@groups["instances"].command("wait")
|
|
394
|
+
def instance_wait(operation_id: str, timeout: int = 1800):
|
|
395
|
+
"""Follow a launch with live feedback until the GPU container is running."""
|
|
396
|
+
from gpyou.menu import wait_operation
|
|
397
|
+
with closing(Client()) as client:
|
|
398
|
+
report_wait(wait_operation(client, operation_id, until="ready", timeout=timeout))
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def report_wait(op):
|
|
402
|
+
"""The state lines were already printed while waiting; scripts get the final operation as JSON."""
|
|
403
|
+
from gpyou.menu import interactive
|
|
404
|
+
if not interactive():
|
|
405
|
+
output(op)
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
@groups["identities"].command("sessions")
|
|
409
|
+
def sessions():
|
|
410
|
+
call("GET", "/v1/sessions")
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
@groups["identities"].command("revoke-session")
|
|
414
|
+
def revoke_session(session_id: str):
|
|
415
|
+
call("DELETE", "/v1/sessions/" + session_id)
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
@app.command()
|
|
419
|
+
def agent(prompt: Annotated[str | None, typer.Argument()] = None):
|
|
420
|
+
"""Chat with the GPYou assistant about your account, datasets, fine-tuning, and serving."""
|
|
421
|
+
from gpyou.agent.cli import run
|
|
422
|
+
run(prompt)
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
@app.command()
|
|
426
|
+
def demo():
|
|
427
|
+
"""Run the complete simulated lifecycle in-process without accounts or GPU charges."""
|
|
428
|
+
import asyncio
|
|
429
|
+
|
|
430
|
+
try:
|
|
431
|
+
from gpyou.demo import run
|
|
432
|
+
except ModuleNotFoundError as exc: # the published CLI package leaves the server, and so the demo, out
|
|
433
|
+
raise typer.BadParameter("The demo runs the whole platform locally; use a checkout of the GPYou repository") from exc
|
|
434
|
+
output(asyncio.run(run()))
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
import gpyou.cli_models # noqa: E402,F401 - registers the fine-tune, serving and endpoint commands
|