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.
Files changed (35) hide show
  1. {urun_cli-0.7.3 → urun_cli-0.7.5}/PKG-INFO +28 -42
  2. {urun_cli-0.7.3 → urun_cli-0.7.5}/README.md +27 -41
  3. {urun_cli-0.7.3 → urun_cli-0.7.5}/pyproject.toml +1 -1
  4. {urun_cli-0.7.3 → urun_cli-0.7.5}/src/urun/api.py +103 -2
  5. {urun_cli-0.7.3 → urun_cli-0.7.5}/src/urun/catalog.py +119 -10
  6. {urun_cli-0.7.3 → urun_cli-0.7.5}/src/urun/cli.py +238 -262
  7. urun_cli-0.7.5/src/urun/scratch.py +108 -0
  8. {urun_cli-0.7.3 → urun_cli-0.7.5}/src/urun/serve.py +106 -11
  9. urun_cli-0.7.3/src/urun/scratch.py +0 -1163
  10. {urun_cli-0.7.3 → urun_cli-0.7.5}/.gitignore +0 -0
  11. {urun_cli-0.7.3 → urun_cli-0.7.5}/CHANGELOG.md +0 -0
  12. {urun_cli-0.7.3 → urun_cli-0.7.5}/LICENSE +0 -0
  13. {urun_cli-0.7.3 → urun_cli-0.7.5}/SECURITY.md +0 -0
  14. {urun_cli-0.7.3 → urun_cli-0.7.5}/hatch_build.py +0 -0
  15. {urun_cli-0.7.3 → urun_cli-0.7.5}/src/urun/__init__.py +0 -0
  16. {urun_cli-0.7.3 → urun_cli-0.7.5}/src/urun/_bin/README.md +0 -0
  17. {urun_cli-0.7.3 → urun_cli-0.7.5}/src/urun/buildlogs.py +0 -0
  18. {urun_cli-0.7.3 → urun_cli-0.7.5}/src/urun/client_tokens.py +0 -0
  19. {urun_cli-0.7.3 → urun_cli-0.7.5}/src/urun/config.py +0 -0
  20. {urun_cli-0.7.3 → urun_cli-0.7.5}/src/urun/deps.py +0 -0
  21. {urun_cli-0.7.3 → urun_cli-0.7.5}/src/urun/dev.py +0 -0
  22. {urun_cli-0.7.3 → urun_cli-0.7.5}/src/urun/devconsole.py +0 -0
  23. {urun_cli-0.7.3 → urun_cli-0.7.5}/src/urun/devstream.py +0 -0
  24. {urun_cli-0.7.3 → urun_cli-0.7.5}/src/urun/discovery.py +0 -0
  25. {urun_cli-0.7.3 → urun_cli-0.7.5}/src/urun/errors.py +0 -0
  26. {urun_cli-0.7.3 → urun_cli-0.7.5}/src/urun/fleet.py +0 -0
  27. {urun_cli-0.7.3 → urun_cli-0.7.5}/src/urun/frontend_launch.py +0 -0
  28. {urun_cli-0.7.3 → urun_cli-0.7.5}/src/urun/launch_auth.py +0 -0
  29. {urun_cli-0.7.3 → urun_cli-0.7.5}/src/urun/manifest.py +0 -0
  30. {urun_cli-0.7.3 → urun_cli-0.7.5}/src/urun/nodes.py +0 -0
  31. {urun_cli-0.7.3 → urun_cli-0.7.5}/src/urun/openai_proxy_bin.py +0 -0
  32. {urun_cli-0.7.3 → urun_cli-0.7.5}/src/urun/repo_deps.py +0 -0
  33. {urun_cli-0.7.3 → urun_cli-0.7.5}/src/urun/retry.py +0 -0
  34. {urun_cli-0.7.3 → urun_cli-0.7.5}/src/urun/runtime_health.py +0 -0
  35. {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
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 instances (rung-4 verification, dev-only)
501
+ ## Scratch occupants (GPU lane)
502
502
 
503
- The verification ladder for platform changes is: unit tests -> CPU harness ->
504
- kind hop -> **scratch pod** -> full deploy. Rungs 1-3 are one command each;
505
- `urun scratch` makes rung 4 one command too — a real GPU pod running the
506
- real render, without the 30-90 min deploy loop and without touching the
507
- live app:
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 with debug env, candidate wheel overlaid:
511
- urun scratch gemma-voice dg-brain --shape rtx6000:1 \
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
- urun scratch ls # list scratch instances + expiry
516
- urun scratch rm dg-brain-noble-repro # tear down
517
- urun scratch rm --expired # reap everything past its TTL
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
- - **Clones the app's rendered StatefulSet** into an isolated instance with
523
- every platform label detached: the materializer never adopts it, the
524
- capacity reconciler never counts it, and no session is ever routed to it.
525
- The clone boots even when the source app is crashlooping or scaled to 0.
526
- - **Overlays candidate artifacts**: `--wheel <req|url|path-on-storage>`
527
- installs `--no-deps` into a hardlink COPY of the deps venv (the shared
528
- venv is never modified); `--env KEY=VAL` adds debug vars; hot reload is
529
- always pinned off so the instance stays on the currently-rendered release
530
- (`--release <hash>` asserts which one that is).
531
- - **Takes GPUs explicitly**: `--shape` is required and must match the source
532
- render; the target karpenter nodepool is checked against its GPU limit and
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 instances (rung-4 verification, dev-only)
476
+ ## Scratch occupants (GPU lane)
477
477
 
478
- The verification ladder for platform changes is: unit tests -> CPU harness ->
479
- kind hop -> **scratch pod** -> full deploy. Rungs 1-3 are one command each;
480
- `urun scratch` makes rung 4 one command too — a real GPU pod running the
481
- real render, without the 30-90 min deploy loop and without touching the
482
- live app:
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 with debug env, candidate wheel overlaid:
486
- urun scratch gemma-voice dg-brain --shape rtx6000:1 \
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
- urun scratch ls # list scratch instances + expiry
491
- urun scratch rm dg-brain-noble-repro # tear down
492
- urun scratch rm --expired # reap everything past its TTL
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
- - **Clones the app's rendered StatefulSet** into an isolated instance with
498
- every platform label detached: the materializer never adopts it, the
499
- capacity reconciler never counts it, and no session is ever routed to it.
500
- The clone boots even when the source app is crashlooping or scaled to 0.
501
- - **Overlays candidate artifacts**: `--wheel <req|url|path-on-storage>`
502
- installs `--no-deps` into a hardlink COPY of the deps venv (the shared
503
- venv is never modified); `--env KEY=VAL` adds debug vars; hot reload is
504
- always pinned off so the instance stays on the currently-rendered release
505
- (`--release <hash>` asserts which one that is).
506
- - **Takes GPUs explicitly**: `--shape` is required and must match the source
507
- render; the target karpenter nodepool is checked against its GPU limit and
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.3"
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.base_url + path
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.base_url + path
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.17",
151
+ "sglang[all]==0.5.6.post3.dev11375+gf0e400193",
152
152
  "flash-attn-4==4.0.0b18",
153
- "torch==2.11.0",
154
- "torchvision==0.26.0",
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
- gpu_spec: str
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, non-disagg placements are dev-testable."""
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=req_str("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