urun-cli 0.7.3__tar.gz → 0.7.5__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.
- {urun_cli-0.7.3 → urun_cli-0.7.5}/PKG-INFO +28 -42
- {urun_cli-0.7.3 → urun_cli-0.7.5}/README.md +27 -41
- {urun_cli-0.7.3 → urun_cli-0.7.5}/pyproject.toml +1 -1
- {urun_cli-0.7.3 → urun_cli-0.7.5}/src/urun/api.py +103 -2
- {urun_cli-0.7.3 → urun_cli-0.7.5}/src/urun/catalog.py +119 -10
- {urun_cli-0.7.3 → urun_cli-0.7.5}/src/urun/cli.py +238 -262
- urun_cli-0.7.5/src/urun/scratch.py +108 -0
- {urun_cli-0.7.3 → urun_cli-0.7.5}/src/urun/serve.py +106 -11
- urun_cli-0.7.3/src/urun/scratch.py +0 -1163
- {urun_cli-0.7.3 → urun_cli-0.7.5}/.gitignore +0 -0
- {urun_cli-0.7.3 → urun_cli-0.7.5}/CHANGELOG.md +0 -0
- {urun_cli-0.7.3 → urun_cli-0.7.5}/LICENSE +0 -0
- {urun_cli-0.7.3 → urun_cli-0.7.5}/SECURITY.md +0 -0
- {urun_cli-0.7.3 → urun_cli-0.7.5}/hatch_build.py +0 -0
- {urun_cli-0.7.3 → urun_cli-0.7.5}/src/urun/__init__.py +0 -0
- {urun_cli-0.7.3 → urun_cli-0.7.5}/src/urun/_bin/README.md +0 -0
- {urun_cli-0.7.3 → urun_cli-0.7.5}/src/urun/buildlogs.py +0 -0
- {urun_cli-0.7.3 → urun_cli-0.7.5}/src/urun/client_tokens.py +0 -0
- {urun_cli-0.7.3 → urun_cli-0.7.5}/src/urun/config.py +0 -0
- {urun_cli-0.7.3 → urun_cli-0.7.5}/src/urun/deps.py +0 -0
- {urun_cli-0.7.3 → urun_cli-0.7.5}/src/urun/dev.py +0 -0
- {urun_cli-0.7.3 → urun_cli-0.7.5}/src/urun/devconsole.py +0 -0
- {urun_cli-0.7.3 → urun_cli-0.7.5}/src/urun/devstream.py +0 -0
- {urun_cli-0.7.3 → urun_cli-0.7.5}/src/urun/discovery.py +0 -0
- {urun_cli-0.7.3 → urun_cli-0.7.5}/src/urun/errors.py +0 -0
- {urun_cli-0.7.3 → urun_cli-0.7.5}/src/urun/fleet.py +0 -0
- {urun_cli-0.7.3 → urun_cli-0.7.5}/src/urun/frontend_launch.py +0 -0
- {urun_cli-0.7.3 → urun_cli-0.7.5}/src/urun/launch_auth.py +0 -0
- {urun_cli-0.7.3 → urun_cli-0.7.5}/src/urun/manifest.py +0 -0
- {urun_cli-0.7.3 → urun_cli-0.7.5}/src/urun/nodes.py +0 -0
- {urun_cli-0.7.3 → urun_cli-0.7.5}/src/urun/openai_proxy_bin.py +0 -0
- {urun_cli-0.7.3 → urun_cli-0.7.5}/src/urun/repo_deps.py +0 -0
- {urun_cli-0.7.3 → urun_cli-0.7.5}/src/urun/retry.py +0 -0
- {urun_cli-0.7.3 → urun_cli-0.7.5}/src/urun/runtime_health.py +0 -0
- {urun_cli-0.7.3 → urun_cli-0.7.5}/src/urun/rust.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: urun-cli
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.5
|
|
4
4
|
Summary: End-user CLI for deploying apps to urun
|
|
5
5
|
Project-URL: Homepage, https://urun.sh
|
|
6
6
|
Project-URL: Repository, https://github.com/urun-sh/urun-cli
|
|
@@ -498,55 +498,41 @@ All `app` subcommands accept `--environment` (default `prod`) and `--json`.
|
|
|
498
498
|
These commands are **experimental** and require the server-side `app`
|
|
499
499
|
lifecycle endpoint, which is in development.
|
|
500
500
|
|
|
501
|
-
## Scratch
|
|
501
|
+
## Scratch occupants (GPU lane)
|
|
502
502
|
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
503
|
+
`urun scratch` runs an isolated, TTL-bounded scratch occupant for an app
|
|
504
|
+
release — an app function or a bare `python -c` command in that release's
|
|
505
|
+
venv with its weights. It rides the SAME pull-dispatch path as serving (a
|
|
506
|
+
BullMQ demand job → node scheduler admission → resident spawn), so there is
|
|
507
|
+
no separate provisioner and no kubectl. Authz is org-scoped via your API
|
|
508
|
+
key, exactly like `urun deploy`.
|
|
508
509
|
|
|
509
510
|
```bash
|
|
510
|
-
# Reproduce a broken app in isolation
|
|
511
|
-
urun scratch gemma-voice dg-brain --
|
|
512
|
-
--env VLLM_LOGGING_LEVEL=DEBUG --env CUDA_LAUNCH_BLOCKING=1 \
|
|
513
|
-
--env PYTHONFAULTHANDLER=1 --name dg-brain-noble-repro
|
|
511
|
+
# Reproduce a broken app in isolation on its release's shape:
|
|
512
|
+
urun scratch gemma-voice dg-brain --gpu rtx6000:1 --ttl 2h
|
|
514
513
|
|
|
515
|
-
|
|
516
|
-
urun scratch
|
|
517
|
-
|
|
514
|
+
# CPU-only occupant running a bare command (after --) instead of the server:
|
|
515
|
+
urun scratch run --app demo --cpu 4 --ttl 90m -- "import torch; print(torch.__version__)"
|
|
516
|
+
|
|
517
|
+
urun scratch list # live occupants: name, app, shape, node, state, expires
|
|
518
|
+
urun scratch logs sc_8f3a… # stream an occupant's stdout log
|
|
519
|
+
urun scratch exec sc_8f3a… -- "print(os.environ['URUN_SCRATCH_ID'])"
|
|
520
|
+
urun scratch end sc_8f3a… # tear down before the TTL
|
|
518
521
|
```
|
|
519
522
|
|
|
520
523
|
What it does:
|
|
521
524
|
|
|
522
|
-
- **
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
the command refuses at capacity unless `--allow-contention`. Scratch never
|
|
534
|
-
silently steals demo GPUs.
|
|
535
|
-
- **Never leaks**: every instance carries a TTL annotation (default `4h`,
|
|
536
|
-
`--ttl 6h`/`90m`) consumed by the scratch reaper, which runs on **every**
|
|
537
|
-
cluster (prod included); scratch-labeled objects without the annotation
|
|
538
|
-
(hand-applied) are reaped after a default 24h TTL, with a loud
|
|
539
|
-
`ScratchTTLExpired` Event. `urun scratch rm` tears down sooner.
|
|
540
|
-
Boot/self-test logs stream to your terminal (Ctrl-C detaches without
|
|
541
|
-
tearing down).
|
|
542
|
-
|
|
543
|
-
Dev-only (v1): talks to a dev cluster via your kubeconfig — the ACTIVE
|
|
544
|
-
kubectl context by default (`--context` overrides; with no active context
|
|
545
|
-
the command fails and demands one). Naming is the enforced control: the
|
|
546
|
-
context name must mark a dev cluster (contain `dev`, e.g. `dev-usw2`,
|
|
547
|
-
`dev-use2`) and must not contain `prod` — anything else is refused. There
|
|
548
|
-
is no hardcoded default cluster: with two dev clusters, a silent default
|
|
549
|
-
would fire kubectl at a cluster you are not looking at.
|
|
525
|
+
- **Isolated, never serving-touching**: scratch demand is never counted by
|
|
526
|
+
the autoscaler (it never mints nodes) and scratch residents are the
|
|
527
|
+
preferred eviction victims — scratch can never take capacity from
|
|
528
|
+
serving. Pin a release with `--release <id>` or let it resolve the app's
|
|
529
|
+
current promoted release.
|
|
530
|
+
- **Explicit compute**: `--gpu <shape>` (e.g. `l40s:1`) or `--cpu N` (shape
|
|
531
|
+
`cpu:N`). Omit both to use the release's default shape.
|
|
532
|
+
- **Auto-reclaimed**: every occupant carries a TTL (`--ttl 2h` / `90m`;
|
|
533
|
+
default `4h`) and is reaped when it lapses; `urun scratch end` ends it
|
|
534
|
+
sooner. `--no-logs` skips streaming the occupant's log after creation
|
|
535
|
+
(Ctrl-C also detaches without tearing the occupant down).
|
|
550
536
|
|
|
551
537
|
## What gets deployed
|
|
552
538
|
|
|
@@ -473,55 +473,41 @@ All `app` subcommands accept `--environment` (default `prod`) and `--json`.
|
|
|
473
473
|
These commands are **experimental** and require the server-side `app`
|
|
474
474
|
lifecycle endpoint, which is in development.
|
|
475
475
|
|
|
476
|
-
## Scratch
|
|
476
|
+
## Scratch occupants (GPU lane)
|
|
477
477
|
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
478
|
+
`urun scratch` runs an isolated, TTL-bounded scratch occupant for an app
|
|
479
|
+
release — an app function or a bare `python -c` command in that release's
|
|
480
|
+
venv with its weights. It rides the SAME pull-dispatch path as serving (a
|
|
481
|
+
BullMQ demand job → node scheduler admission → resident spawn), so there is
|
|
482
|
+
no separate provisioner and no kubectl. Authz is org-scoped via your API
|
|
483
|
+
key, exactly like `urun deploy`.
|
|
483
484
|
|
|
484
485
|
```bash
|
|
485
|
-
# Reproduce a broken app in isolation
|
|
486
|
-
urun scratch gemma-voice dg-brain --
|
|
487
|
-
--env VLLM_LOGGING_LEVEL=DEBUG --env CUDA_LAUNCH_BLOCKING=1 \
|
|
488
|
-
--env PYTHONFAULTHANDLER=1 --name dg-brain-noble-repro
|
|
486
|
+
# Reproduce a broken app in isolation on its release's shape:
|
|
487
|
+
urun scratch gemma-voice dg-brain --gpu rtx6000:1 --ttl 2h
|
|
489
488
|
|
|
490
|
-
|
|
491
|
-
urun scratch
|
|
492
|
-
|
|
489
|
+
# CPU-only occupant running a bare command (after --) instead of the server:
|
|
490
|
+
urun scratch run --app demo --cpu 4 --ttl 90m -- "import torch; print(torch.__version__)"
|
|
491
|
+
|
|
492
|
+
urun scratch list # live occupants: name, app, shape, node, state, expires
|
|
493
|
+
urun scratch logs sc_8f3a… # stream an occupant's stdout log
|
|
494
|
+
urun scratch exec sc_8f3a… -- "print(os.environ['URUN_SCRATCH_ID'])"
|
|
495
|
+
urun scratch end sc_8f3a… # tear down before the TTL
|
|
493
496
|
```
|
|
494
497
|
|
|
495
498
|
What it does:
|
|
496
499
|
|
|
497
|
-
- **
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
the command refuses at capacity unless `--allow-contention`. Scratch never
|
|
509
|
-
silently steals demo GPUs.
|
|
510
|
-
- **Never leaks**: every instance carries a TTL annotation (default `4h`,
|
|
511
|
-
`--ttl 6h`/`90m`) consumed by the scratch reaper, which runs on **every**
|
|
512
|
-
cluster (prod included); scratch-labeled objects without the annotation
|
|
513
|
-
(hand-applied) are reaped after a default 24h TTL, with a loud
|
|
514
|
-
`ScratchTTLExpired` Event. `urun scratch rm` tears down sooner.
|
|
515
|
-
Boot/self-test logs stream to your terminal (Ctrl-C detaches without
|
|
516
|
-
tearing down).
|
|
517
|
-
|
|
518
|
-
Dev-only (v1): talks to a dev cluster via your kubeconfig — the ACTIVE
|
|
519
|
-
kubectl context by default (`--context` overrides; with no active context
|
|
520
|
-
the command fails and demands one). Naming is the enforced control: the
|
|
521
|
-
context name must mark a dev cluster (contain `dev`, e.g. `dev-usw2`,
|
|
522
|
-
`dev-use2`) and must not contain `prod` — anything else is refused. There
|
|
523
|
-
is no hardcoded default cluster: with two dev clusters, a silent default
|
|
524
|
-
would fire kubectl at a cluster you are not looking at.
|
|
500
|
+
- **Isolated, never serving-touching**: scratch demand is never counted by
|
|
501
|
+
the autoscaler (it never mints nodes) and scratch residents are the
|
|
502
|
+
preferred eviction victims — scratch can never take capacity from
|
|
503
|
+
serving. Pin a release with `--release <id>` or let it resolve the app's
|
|
504
|
+
current promoted release.
|
|
505
|
+
- **Explicit compute**: `--gpu <shape>` (e.g. `l40s:1`) or `--cpu N` (shape
|
|
506
|
+
`cpu:N`). Omit both to use the release's default shape.
|
|
507
|
+
- **Auto-reclaimed**: every occupant carries a TTL (`--ttl 2h` / `90m`;
|
|
508
|
+
default `4h`) and is reaped when it lapses; `urun scratch end` ends it
|
|
509
|
+
sooner. `--no-logs` skips streaming the occupant's log after creation
|
|
510
|
+
(Ctrl-C also detaches without tearing the occupant down).
|
|
525
511
|
|
|
526
512
|
## What gets deployed
|
|
527
513
|
|
|
@@ -20,7 +20,7 @@ name = "urun-cli"
|
|
|
20
20
|
# so every merge remains on the already-published version until this value is
|
|
21
21
|
# advanced. Wiring semantic-release into CI remains separate work; this patch
|
|
22
22
|
# bump is the minimal release unblock (post-v0.6.3 fixes + the Rust feature minor).
|
|
23
|
-
version = "0.7.
|
|
23
|
+
version = "0.7.5"
|
|
24
24
|
description = "End-user CLI for deploying apps to urun"
|
|
25
25
|
readme = "README.md"
|
|
26
26
|
requires-python = ">=3.11"
|
|
@@ -45,6 +45,39 @@ class ApiClient:
|
|
|
45
45
|
self.backoff = backoff
|
|
46
46
|
self.on_retry = on_retry
|
|
47
47
|
|
|
48
|
+
@property
|
|
49
|
+
def session_api_origin(self) -> str:
|
|
50
|
+
"""The BARE gateway origin the session-API (``/api/...``) surface lives on.
|
|
51
|
+
|
|
52
|
+
The CLI's ``api_url`` is the REST base carrying the ``/v1`` prefix
|
|
53
|
+
(``https://api.urun.sh/v1``) — but ``/v1/<name>`` routes are the
|
|
54
|
+
Supabase EDGE surfaces (``/v1/apps``, ``/v1/cli/*``, ...), while the
|
|
55
|
+
session-api-ts HTTP surface is served on the BARE origin
|
|
56
|
+
(``https://api.urun.sh/api/...`` behind Envoy). Sending ``/api/*``
|
|
57
|
+
through the ``/v1`` base hits the edge runtime's unknown-function 404
|
|
58
|
+
(``Requested function was not found``) — proven live on
|
|
59
|
+
``GET /v1/api/scratch`` (2026-09-30, dev proof). One deterministic
|
|
60
|
+
transform, loud on surprise: strip exactly ONE trailing ``/v1`` path
|
|
61
|
+
segment (the mirror of :func:`urun.cli.sdk_base_url`); a bare origin
|
|
62
|
+
is returned as-is; anything else is a hard error.
|
|
63
|
+
"""
|
|
64
|
+
parsed = urllib.parse.urlsplit(self.base_url)
|
|
65
|
+
if parsed.path == "/v1":
|
|
66
|
+
return urllib.parse.urlunsplit((parsed.scheme, parsed.netloc, "", "", ""))
|
|
67
|
+
if parsed.path in ("", "/"):
|
|
68
|
+
return self.base_url
|
|
69
|
+
raise UrunError(
|
|
70
|
+
f"cannot derive the session-API origin from api_url {self.base_url!r}: "
|
|
71
|
+
"expected a bare origin or a single trailing /v1 path segment "
|
|
72
|
+
"(the /api/* session-API surface lives on the bare gateway origin)"
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
def _url(self, path: str) -> str:
|
|
76
|
+
"""Request URL for ``path``: ``/api/*`` → the session-API origin, rest → REST base."""
|
|
77
|
+
if path.startswith("/api/"):
|
|
78
|
+
return self.session_api_origin + path
|
|
79
|
+
return self.base_url + path
|
|
80
|
+
|
|
48
81
|
def register_manifest(self, manifest: dict[str, Any]) -> dict[str, Any]:
|
|
49
82
|
return self._json("POST", "/register-manifest", manifest)
|
|
50
83
|
|
|
@@ -650,6 +683,74 @@ class ApiClient:
|
|
|
650
683
|
def org_config(self) -> dict[str, Any]:
|
|
651
684
|
return self._json("GET", "/org-config", None)
|
|
652
685
|
|
|
686
|
+
# --- Scratch occupants ------------------------------------------------- #
|
|
687
|
+
# `urun scratch` rides the same pull-dispatch path as serving: the
|
|
688
|
+
# session API enqueues a BullMQ scratch demand job, the node scheduler
|
|
689
|
+
# admits it and spawns the resident. Org scoping comes from the same
|
|
690
|
+
# org-API-key authz the deploy/client-token routes use. GET
|
|
691
|
+
# /api/scratch/:id/logs and POST /api/scratch/:id/exec are Slice A
|
|
692
|
+
# (urun-infra) endpoints — this client is coded against the shared
|
|
693
|
+
# contract ({cmd} -> streamed output / polled log text).
|
|
694
|
+
|
|
695
|
+
def post_scratch_run(self, body: dict[str, Any]) -> dict[str, Any]:
|
|
696
|
+
"""Request a scratch occupant (``POST /api/scratch/run``).
|
|
697
|
+
|
|
698
|
+
``body`` is :func:`urun.scratch.scratch_run_body`'s payload: app,
|
|
699
|
+
optional function/gpu/release_id/cmd/name, and ``ttl_s``. The server
|
|
700
|
+
resolves the app's current promoted release when ``release_id`` is
|
|
701
|
+
omitted and answers ``{scratch_id, queue, expires_at}``.
|
|
702
|
+
"""
|
|
703
|
+
return self._post("/api/scratch/run", body)
|
|
704
|
+
|
|
705
|
+
def get_scratch(self) -> dict[str, Any]:
|
|
706
|
+
"""List the org's live scratch occupants (``GET /api/scratch``)."""
|
|
707
|
+
return self._get("/api/scratch")
|
|
708
|
+
|
|
709
|
+
def end_scratch(self, scratch_id: str) -> dict[str, Any]:
|
|
710
|
+
"""End a scratch occupant (``POST /api/scratch/:id/end``).
|
|
711
|
+
|
|
712
|
+
Removes the demand job and marks the Valkey row ``end_requested``;
|
|
713
|
+
the node scheduler reaps the resident on its next tick.
|
|
714
|
+
"""
|
|
715
|
+
return self._post(f"/api/scratch/{urllib.parse.quote(scratch_id, safe='')}/end", {})
|
|
716
|
+
|
|
717
|
+
def get_scratch_logs(self, scratch_id: str, tail: int | None = None) -> str:
|
|
718
|
+
"""Read a scratch occupant's stdout log (``GET /api/scratch/:id/logs``).
|
|
719
|
+
|
|
720
|
+
The endpoint proxies the scheduler's ``log_http`` surface and answers
|
|
721
|
+
**text/plain**; the scheduler's 404-after-reap is forwarded verbatim,
|
|
722
|
+
so ``ApiError`` with ``status == 404`` is the occupant-gone end of
|
|
723
|
+
the stream, not a routing error.
|
|
724
|
+
"""
|
|
725
|
+
payload = self._request_bytes(
|
|
726
|
+
"GET",
|
|
727
|
+
self._url(
|
|
728
|
+
self._path(
|
|
729
|
+
f"/api/scratch/{urllib.parse.quote(scratch_id, safe='')}/logs", {"tail": tail}
|
|
730
|
+
)
|
|
731
|
+
),
|
|
732
|
+
None,
|
|
733
|
+
timeout=self.timeout,
|
|
734
|
+
)
|
|
735
|
+
return payload.decode("utf-8", "replace")
|
|
736
|
+
|
|
737
|
+
def post_scratch_exec(self, scratch_id: str, cmd: str) -> dict[str, Any]:
|
|
738
|
+
"""Run an extra ``python -c`` command in the resident's venv.
|
|
739
|
+
|
|
740
|
+
``POST /api/scratch/:id/exec`` answers ``{exec_id, exec_state
|
|
741
|
+
("done"|"failed"), exec_exit, log_http, log_path}`` after the server's
|
|
742
|
+
own bounded poll (min remaining ttl, 600s cap; 504 on timeout).
|
|
743
|
+
"""
|
|
744
|
+
return self._post(
|
|
745
|
+
f"/api/scratch/{urllib.parse.quote(scratch_id, safe='')}/exec", {"cmd": cmd}
|
|
746
|
+
)
|
|
747
|
+
|
|
748
|
+
def get_scratch_exec_output(self, log_http: str) -> str:
|
|
749
|
+
"""Fetch an exec's streamed output from its ``log_http`` surface (text/plain)."""
|
|
750
|
+
return self._request_bytes("GET", log_http, None, timeout=self.timeout).decode(
|
|
751
|
+
"utf-8", "replace"
|
|
752
|
+
)
|
|
753
|
+
|
|
653
754
|
def trust_auth_connection(self, payload: dict[str, Any]) -> dict[str, Any]:
|
|
654
755
|
"""Register a trusted JWT/JWKS provider for the authenticated org.
|
|
655
756
|
|
|
@@ -800,7 +901,7 @@ class ApiClient:
|
|
|
800
901
|
if body is None
|
|
801
902
|
else json.dumps(body, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
802
903
|
)
|
|
803
|
-
url = self.
|
|
904
|
+
url = self._url(path)
|
|
804
905
|
try:
|
|
805
906
|
payload = self._request_bytes(method, url, data, timeout=self.timeout)
|
|
806
907
|
except ApiError:
|
|
@@ -825,7 +926,7 @@ class ApiClient:
|
|
|
825
926
|
if body is None
|
|
826
927
|
else json.dumps(body, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
827
928
|
)
|
|
828
|
-
url = self.
|
|
929
|
+
url = self._url(path)
|
|
829
930
|
|
|
830
931
|
try:
|
|
831
932
|
payload = retry_call(
|
|
@@ -148,10 +148,11 @@ ENGINE_DEPS: dict[str, list[str]] = {
|
|
|
148
148
|
"orjson",
|
|
149
149
|
],
|
|
150
150
|
"sglang": [
|
|
151
|
-
"sglang[all]==0.5.
|
|
151
|
+
"sglang[all]==0.5.6.post3.dev11375+gf0e400193",
|
|
152
152
|
"flash-attn-4==4.0.0b18",
|
|
153
|
-
"
|
|
154
|
-
"
|
|
153
|
+
"cuda-tile==1.6.0rc5",
|
|
154
|
+
"torch==2.13.0",
|
|
155
|
+
"torchvision==0.28.0",
|
|
155
156
|
"torchaudio==2.11.0",
|
|
156
157
|
"transformers==5.12.1",
|
|
157
158
|
"accelerate",
|
|
@@ -335,6 +336,8 @@ ENGINE_DEPS: dict[str, list[str]] = {
|
|
|
335
336
|
"accelerate",
|
|
336
337
|
"torchaudio==2.6.0",
|
|
337
338
|
"onnxruntime>=1.20",
|
|
339
|
+
"mistral-common[image]>=1.8.8",
|
|
340
|
+
"soundfile",
|
|
338
341
|
"soxr>=0.5",
|
|
339
342
|
],
|
|
340
343
|
"tts": [
|
|
@@ -349,6 +352,7 @@ ENGINE_DEPS: dict[str, list[str]] = {
|
|
|
349
352
|
"kokoro>=0.9.2",
|
|
350
353
|
"misaki[en,ja,zh]>=0.9.3",
|
|
351
354
|
"qwen-tts>=0.1.1",
|
|
355
|
+
"voxcpm>=2.0.3",
|
|
352
356
|
],
|
|
353
357
|
"ar": [
|
|
354
358
|
"torch==2.11.0",
|
|
@@ -419,6 +423,7 @@ ENGINE_DEPS: dict[str, list[str]] = {
|
|
|
419
423
|
"omegaconf",
|
|
420
424
|
"gpustat",
|
|
421
425
|
"ninja",
|
|
426
|
+
"ftfy>=6.3.1",
|
|
422
427
|
"fastvideo-kernel==0.3.2; sys_platform == 'linux'",
|
|
423
428
|
],
|
|
424
429
|
"ltx-realtime": [
|
|
@@ -525,6 +530,73 @@ def parse_gpu_spec(spec: str) -> tuple[str, int]:
|
|
|
525
530
|
return kind, (n if n > 0 else 1)
|
|
526
531
|
|
|
527
532
|
|
|
533
|
+
#: ``app_functions.dispatch_provider``'s DB CHECK (urun-infra migration
|
|
534
|
+
#: 20271051000000), spelled here so `urun serve` refuses a bad pin BEFORE any
|
|
535
|
+
#: deploy. Kept in lockstep with ``_validated_dispatch_provider`` (urun-python)
|
|
536
|
+
#: and ``dispatchShapeClass`` (session-api-ts) — the queue name is compared
|
|
537
|
+
#: byte-for-byte across all three, so one loose spelling is a silent mismatch.
|
|
538
|
+
_DISPATCH_PROVIDER_RE = re.compile(r"^[a-z0-9]{1,32}$")
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def split_gpu_provider_pin(spec: str) -> tuple[str, str | None]:
|
|
542
|
+
"""Split ``<class>[-<provider>]:<count>`` into its bare spec and provider pin.
|
|
543
|
+
|
|
544
|
+
``urun serve --gpu rtx6000-spheron:1`` names a LANE, not two orthogonal
|
|
545
|
+
things: the provider-pinned dispatch queue is literally keyed on
|
|
546
|
+
``{shape_class}-{provider}``, so the flag uses the SAME spelling the queue
|
|
547
|
+
does rather than inventing a second vocabulary for the same concept.
|
|
548
|
+
|
|
549
|
+
Returns ``(bare_spec, provider)`` — ``("rtx6000:1", "spheron")`` above, and
|
|
550
|
+
``(spec, None)`` for every unpinned spec, which is the overwhelming majority
|
|
551
|
+
and passes through byte-identical.
|
|
552
|
+
|
|
553
|
+
PARSE FIRST, THEN VALIDATE (review: Keegan, 2026-09-15). Every caller
|
|
554
|
+
downstream — the VRAM floor check, ``resolve_row``, the catalog SKU match —
|
|
555
|
+
must see the BARE class, because ``rtx6000-spheron`` matches no SKU and
|
|
556
|
+
would hard-fail there with a misleading "unknown GPU" error.
|
|
557
|
+
|
|
558
|
+
NEVER GUESSES THE SPLIT. A class is only treated as pinned when the full
|
|
559
|
+
token is NOT itself a known class AND the part before the last ``-`` IS one
|
|
560
|
+
— the same rule ``base_accelerator_class`` applies in urun-python's node
|
|
561
|
+
scheduler, so all three languages agree on where the boundary falls. No
|
|
562
|
+
class in :data:`GPU_VRAM_GB` contains a ``-`` today, but a future
|
|
563
|
+
``h100-sxm`` must resolve as a CLASS, not as ``h100`` pinned to ``sxm``.
|
|
564
|
+
An unrecognized base raises rather than silently deploying to a queue that
|
|
565
|
+
nothing consumes.
|
|
566
|
+
|
|
567
|
+
CASE IS NORMALIZED, NOT REJECTED — and this is a deliberate divergence from
|
|
568
|
+
``_validated_dispatch_provider`` (urun-python) and the DB CHECK, which both
|
|
569
|
+
refuse uppercase outright. ``parse_gpu_spec`` already lowercases the whole
|
|
570
|
+
``<class>:<count>`` token, so ``--gpu L4:1`` is long-standing CLI behavior;
|
|
571
|
+
rejecting ``--gpu RTX6000-SPHERON:1`` while accepting ``--gpu L4:1`` would
|
|
572
|
+
split one token's rules down the middle. Because the provider is read off
|
|
573
|
+
the ALREADY-lowercased token, this normalization can only ever emit a value
|
|
574
|
+
the stricter downstream validators accept — it widens what the CLI takes,
|
|
575
|
+
never what it sends.
|
|
576
|
+
"""
|
|
577
|
+
kind, sep, count = (spec or "").partition(":")
|
|
578
|
+
kind = kind.strip().lower()
|
|
579
|
+
suffix = f"{sep}{count}" if sep else ""
|
|
580
|
+
if "-" not in kind or kind in GPU_VRAM_GB:
|
|
581
|
+
return spec, None
|
|
582
|
+
base, _, provider = kind.rpartition("-")
|
|
583
|
+
if base not in GPU_VRAM_GB:
|
|
584
|
+
raise ValueError(
|
|
585
|
+
f"--gpu {spec!r}: {kind!r} is neither a known GPU class nor "
|
|
586
|
+
f"'<class>-<provider>' with a known class ({base!r} is not one of "
|
|
587
|
+
f"{sorted(GPU_VRAM_GB)}). Refusing to guess where the provider pin "
|
|
588
|
+
f"starts — a wrong split deploys to a dispatch queue nothing consumes."
|
|
589
|
+
)
|
|
590
|
+
if not _DISPATCH_PROVIDER_RE.fullmatch(provider):
|
|
591
|
+
raise ValueError(
|
|
592
|
+
f"--gpu {spec!r}: provider pin {provider!r} must be lowercase "
|
|
593
|
+
f"alphanumeric, 1..32 characters. It names the provider half of this "
|
|
594
|
+
f"deploy's dispatch queue ('{base}-{provider}'); anything else would "
|
|
595
|
+
f"key a queue no autoscaler scales and no scheduler consumes."
|
|
596
|
+
)
|
|
597
|
+
return f"{base}{suffix}", provider
|
|
598
|
+
|
|
599
|
+
|
|
528
600
|
def gpu_vram_gb(spec: str) -> int | None:
|
|
529
601
|
"""Total VRAM (GB) a ``<kind>:<count>`` SKU provides, or None if unknown kind."""
|
|
530
602
|
kind, count = parse_gpu_spec(spec)
|
|
@@ -835,7 +907,10 @@ class CatalogRow:
|
|
|
835
907
|
|
|
836
908
|
model_id: str
|
|
837
909
|
variant: str
|
|
838
|
-
|
|
910
|
+
# NULL for a CPU placement (`cpu: N` — owner design 2026-09-30: a CPU
|
|
911
|
+
# workload declares CORES, never a GPU SKU). A non-null value is the
|
|
912
|
+
# GPU spec ("<kind>:<count>").
|
|
913
|
+
gpu_spec: str | None
|
|
839
914
|
engine: str
|
|
840
915
|
family: str
|
|
841
916
|
task: str
|
|
@@ -851,6 +926,9 @@ class CatalogRow:
|
|
|
851
926
|
# ``default_gpu`` is model-level recommended placement (e.g. "l4:1");
|
|
852
927
|
# ``min_vram_gb`` is the GPU-resident VRAM floor for this placement;
|
|
853
928
|
# ``min_host_ram_gb`` is the host-RAM floor for tiered-offload (GGUF) models.
|
|
929
|
+
# PLACEMENT-level CPU cores for a CPU placement (gpu_spec NULL); None for
|
|
930
|
+
# every GPU row (additive — older catalogs lack the column).
|
|
931
|
+
cpu: int | None = None
|
|
854
932
|
default_gpu: str | None = None
|
|
855
933
|
min_vram_gb: int | None = None
|
|
856
934
|
min_host_ram_gb: int | None = None
|
|
@@ -871,7 +949,13 @@ class CatalogRow:
|
|
|
871
949
|
|
|
872
950
|
@property
|
|
873
951
|
def gpu_count(self) -> int:
|
|
874
|
-
"""GPU count parsed from ``<kind>:<n>`` (e.g. ``b200:8`` -> 8).
|
|
952
|
+
"""GPU count parsed from ``<kind>:<n>`` (e.g. ``b200:8`` -> 8).
|
|
953
|
+
|
|
954
|
+
A CPU placement (gpu_spec NULL) has ZERO GPUs — the cores declaration
|
|
955
|
+
(``cpu``) is the capacity axis there, never a GPU count.
|
|
956
|
+
"""
|
|
957
|
+
if self.gpu_spec is None:
|
|
958
|
+
return 0
|
|
875
959
|
_, _, count = self.gpu_spec.partition(":")
|
|
876
960
|
try:
|
|
877
961
|
return int(count)
|
|
@@ -880,8 +964,8 @@ class CatalogRow:
|
|
|
880
964
|
|
|
881
965
|
@property
|
|
882
966
|
def is_dev_testable(self) -> bool:
|
|
883
|
-
"""Contract rule: 1-GPU
|
|
884
|
-
return self.gpu_count == 1 and not self.disagg
|
|
967
|
+
"""Contract rule: 1-GPU (or single CPU-unit) non-disagg placements."""
|
|
968
|
+
return (self.gpu_count == 1 or self.gpu_spec is None) and not self.disagg
|
|
885
969
|
|
|
886
970
|
|
|
887
971
|
# PEP 508 requirement SHAPE (stdlib-only; this module deliberately carries no
|
|
@@ -983,11 +1067,25 @@ def parse_row(raw: dict[str, Any]) -> CatalogRow:
|
|
|
983
1067
|
"the catalog is public reference data — use a secret-managed index"
|
|
984
1068
|
)
|
|
985
1069
|
|
|
1070
|
+
gpu_spec = raw.get("gpu_spec")
|
|
1071
|
+
if gpu_spec is not None and (not isinstance(gpu_spec, str) or not gpu_spec):
|
|
1072
|
+
raise UrunError("catalog row 'gpu_spec' must be a non-empty string or null")
|
|
1073
|
+
# THE CPU SENTINEL: the DB keeps gpu_spec NOT NULL (it is part of the PK)
|
|
1074
|
+
# — a CPU row is the 'none' literal, normalized to None here (owner design
|
|
1075
|
+
# 2026-09-30), so every consumer reads one shape.
|
|
1076
|
+
if gpu_spec == "none":
|
|
1077
|
+
gpu_spec = None
|
|
1078
|
+
if gpu_spec is None and opt_int("cpu") is None:
|
|
1079
|
+
raise UrunError(
|
|
1080
|
+
"catalog row with NULL gpu_spec must declare 'cpu' cores "
|
|
1081
|
+
"(the CPU declaration: a CPU workload names its cores, never a GPU SKU)"
|
|
1082
|
+
)
|
|
986
1083
|
return CatalogRow(
|
|
987
1084
|
model_id=req_str("model_id"),
|
|
988
1085
|
variant=req_str("variant"),
|
|
989
|
-
gpu_spec=
|
|
1086
|
+
gpu_spec=gpu_spec,
|
|
990
1087
|
engine=req_str("engine"),
|
|
1088
|
+
cpu=opt_int("cpu"),
|
|
991
1089
|
family=req_str("family"),
|
|
992
1090
|
task=req_str("task"),
|
|
993
1091
|
hf_repo=req_str("hf_repo"),
|
|
@@ -1063,7 +1161,7 @@ def resolve_row(
|
|
|
1063
1161
|
return for_variant[0]
|
|
1064
1162
|
for_gpu = [r for r in for_variant if r.gpu_spec == gpu_spec]
|
|
1065
1163
|
if not for_gpu:
|
|
1066
|
-
known = ", ".join(sorted({r.gpu_spec for r in for_variant}))
|
|
1164
|
+
known = ", ".join(sorted(str(g) for g in {r.gpu_spec for r in for_variant}))
|
|
1067
1165
|
raise UrunError(
|
|
1068
1166
|
f"no placement on GPU {gpu_spec!r} for {model_id}:{variant}. "
|
|
1069
1167
|
f"Available placements: {known}"
|
|
@@ -1112,9 +1210,20 @@ def validate_gpu_against_row(
|
|
|
1112
1210
|
representative = resolve_row(rows, model_id, variant, None)
|
|
1113
1211
|
resolved_variant = representative.variant
|
|
1114
1212
|
for_variant = [r for r in rows if r.model_id == model_id and r.variant == resolved_variant]
|
|
1115
|
-
enumerated = sorted({r.gpu_spec for r in for_variant})
|
|
1213
|
+
enumerated = sorted(str(g) for g in {r.gpu_spec for r in for_variant})
|
|
1116
1214
|
ref = f"{model_id}:{resolved_variant}"
|
|
1117
1215
|
|
|
1216
|
+
if representative.gpu_spec is None and requested_gpu in (None, "", "none"):
|
|
1217
|
+
# THE CPU DECLARATION (owner design 2026-09-30): the variant's row
|
|
1218
|
+
# declares CORES (gpu_spec NULL) and the request names no GPU — there
|
|
1219
|
+
# is no VRAM axis to check and no unknown-SKU error; the cores
|
|
1220
|
+
# declaration IS the placement. Clean, like an enumerated GPU row.
|
|
1221
|
+
return (
|
|
1222
|
+
"ok",
|
|
1223
|
+
f"{ref} is a CPU placement (cpu: {representative.cpu} cores; no GPU SKU)."
|
|
1224
|
+
f"{_host_ram_note(representative)}",
|
|
1225
|
+
)
|
|
1226
|
+
|
|
1118
1227
|
if is_neuron_gpu_class(requested_gpu):
|
|
1119
1228
|
# Neuron (trn2): DELIBERATELY absent from GPU_VRAM_GB (mirroring
|
|
1120
1229
|
# urun-python, which omits trn2 from its VRAM map) — the VRAM floor is
|