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 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