urun-cli 0.6.0__tar.gz → 0.6.2__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 (33) hide show
  1. {urun_cli-0.6.0 → urun_cli-0.6.2}/.gitignore +3 -0
  2. {urun_cli-0.6.0 → urun_cli-0.6.2}/PKG-INFO +21 -10
  3. {urun_cli-0.6.0 → urun_cli-0.6.2}/README.md +20 -9
  4. {urun_cli-0.6.0 → urun_cli-0.6.2}/pyproject.toml +5 -12
  5. {urun_cli-0.6.0 → urun_cli-0.6.2}/src/urun/api.py +17 -0
  6. {urun_cli-0.6.0 → urun_cli-0.6.2}/src/urun/catalog.py +114 -15
  7. {urun_cli-0.6.0 → urun_cli-0.6.2}/src/urun/cli.py +266 -191
  8. {urun_cli-0.6.0 → urun_cli-0.6.2}/src/urun/client_tokens.py +34 -20
  9. {urun_cli-0.6.0 → urun_cli-0.6.2}/src/urun/dev.py +22 -10
  10. {urun_cli-0.6.0 → urun_cli-0.6.2}/src/urun/frontend_launch.py +76 -71
  11. {urun_cli-0.6.0 → urun_cli-0.6.2}/src/urun/launch_auth.py +10 -13
  12. {urun_cli-0.6.0 → urun_cli-0.6.2}/src/urun/scratch.py +48 -6
  13. {urun_cli-0.6.0 → urun_cli-0.6.2}/src/urun/serve.py +49 -8
  14. {urun_cli-0.6.0 → urun_cli-0.6.2}/CHANGELOG.md +0 -0
  15. {urun_cli-0.6.0 → urun_cli-0.6.2}/LICENSE +0 -0
  16. {urun_cli-0.6.0 → urun_cli-0.6.2}/SECURITY.md +0 -0
  17. {urun_cli-0.6.0 → urun_cli-0.6.2}/hatch_build.py +0 -0
  18. {urun_cli-0.6.0 → urun_cli-0.6.2}/src/urun/__init__.py +0 -0
  19. {urun_cli-0.6.0 → urun_cli-0.6.2}/src/urun/_bin/README.md +0 -0
  20. {urun_cli-0.6.0 → urun_cli-0.6.2}/src/urun/buildlogs.py +0 -0
  21. {urun_cli-0.6.0 → urun_cli-0.6.2}/src/urun/config.py +0 -0
  22. {urun_cli-0.6.0 → urun_cli-0.6.2}/src/urun/deps.py +0 -0
  23. {urun_cli-0.6.0 → urun_cli-0.6.2}/src/urun/devconsole.py +0 -0
  24. {urun_cli-0.6.0 → urun_cli-0.6.2}/src/urun/devstream.py +0 -0
  25. {urun_cli-0.6.0 → urun_cli-0.6.2}/src/urun/discovery.py +0 -0
  26. {urun_cli-0.6.0 → urun_cli-0.6.2}/src/urun/errors.py +0 -0
  27. {urun_cli-0.6.0 → urun_cli-0.6.2}/src/urun/fleet.py +0 -0
  28. {urun_cli-0.6.0 → urun_cli-0.6.2}/src/urun/manifest.py +0 -0
  29. {urun_cli-0.6.0 → urun_cli-0.6.2}/src/urun/nodes.py +0 -0
  30. {urun_cli-0.6.0 → urun_cli-0.6.2}/src/urun/openai_proxy_bin.py +0 -0
  31. {urun_cli-0.6.0 → urun_cli-0.6.2}/src/urun/repo_deps.py +0 -0
  32. {urun_cli-0.6.0 → urun_cli-0.6.2}/src/urun/retry.py +0 -0
  33. {urun_cli-0.6.0 → urun_cli-0.6.2}/src/urun/runtime_health.py +0 -0
@@ -21,3 +21,6 @@ build/
21
21
  # Vendored urun-openai proxy binary (scripts/vendor_openai_proxy.py puts it here
22
22
  # at release-build time; hatch `artifacts` ships it in the wheel despite this ignore)
23
23
  src/urun/_bin/urun-openai
24
+
25
+ # Agent tooling (Serena MCP) — never ships with the CLI.
26
+ .serena/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: urun-cli
3
- Version: 0.6.0
3
+ Version: 0.6.2
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
@@ -98,14 +98,14 @@ In this release, `urun run` uses the same deploy pipeline as `urun deploy`.
98
98
  `deploy` remains available as the lower-level command while the full
99
99
  deploy/run/monitor workflow is being built.
100
100
 
101
- ### Launch an official browser demo
101
+ ### Attach an official browser frontend
102
102
 
103
103
  For official `urun-examples` apps, the shortest browser stream path is:
104
104
 
105
105
  ```bash
106
106
  cd ~/workspace/urun-examples
107
107
  urun deploy prompt_canary/backend/app.py --name prompt-canary --timeout 900
108
- urun demo prompt-canary --examples-root ~/workspace/urun-examples
108
+ urun dev --attach prompt-canary --examples-root ~/workspace/urun-examples
109
109
  ```
110
110
 
111
111
  The positional argument is the **deployed app slug** (as shown by `urun app
@@ -118,7 +118,7 @@ list`). Nothing is declared in a manifest — every value is discovered:
118
118
  | local port | the `--port N` in that package's `scripts.dev`, else 3000 |
119
119
  | function | read from the deployed app itself |
120
120
 
121
- `urun demo` uses your stored API key server-side to request a short-lived,
121
+ `urun dev --attach` uses your resolved API key server-side to request a short-lived,
122
122
  function-scoped, origin-pinned browser JWT, sets the frontend's
123
123
  `NEXT_PUBLIC_*` environment variables, starts the local Next.js frontend, and
124
124
  opens it in your browser. The API key itself never reaches the page.
@@ -129,15 +129,15 @@ the right directory yourself with `--frontend-dir` (relative to the examples
129
129
  root):
130
130
 
131
131
  ```bash
132
- urun demo test-matrix-game --frontend-dir matrix-game-3/frontend
132
+ urun dev --attach test-matrix-game --frontend-dir matrix-game-3/frontend
133
133
  ```
134
134
 
135
- #### There is no curated list of demoable examples
135
+ #### There is no curated list of attachable examples
136
136
 
137
- `urun demo` will launch **any** `<example>/frontend` it can match, and it does
138
- not know which examples actually work. Earlier releases read a manifest that
139
- carried a `supported` flag with a reason, and refused the examples marked
140
- unsupported; that allowlist is gone along with the manifest.
137
+ `urun dev --attach` will launch **any** `<example>/frontend` it can match, and
138
+ it does not know which examples actually work. Earlier releases read a
139
+ manifest that carried a `supported` flag with a reason, and refused the
140
+ examples marked unsupported; that allowlist is gone along with the manifest.
141
141
 
142
142
  The practical consequence: a frontend that is a stub, half-built, or known
143
143
  broken now starts a dev server and opens a browser instead of failing up
@@ -371,6 +371,17 @@ Enable a disabled app and bring it back online:
371
371
  urun app enable lingbot-handle
372
372
  ```
373
373
 
374
+ If `urun deploy` or `urun dev --attach` reports that a function was paused
375
+ after repeated startup failures, inspect its runtime logs before overriding
376
+ the safety pause. Then explicitly resume every paused function:
377
+
378
+ ```bash
379
+ urun app enable rapid-clips --force
380
+ ```
381
+
382
+ `--force` may restart a known-crashing function; it does not change the
383
+ platform's automatic pause policy.
384
+
374
385
  Stop a queued, starting, or live session:
375
386
 
376
387
  ```bash
@@ -76,14 +76,14 @@ In this release, `urun run` uses the same deploy pipeline as `urun deploy`.
76
76
  `deploy` remains available as the lower-level command while the full
77
77
  deploy/run/monitor workflow is being built.
78
78
 
79
- ### Launch an official browser demo
79
+ ### Attach an official browser frontend
80
80
 
81
81
  For official `urun-examples` apps, the shortest browser stream path is:
82
82
 
83
83
  ```bash
84
84
  cd ~/workspace/urun-examples
85
85
  urun deploy prompt_canary/backend/app.py --name prompt-canary --timeout 900
86
- urun demo prompt-canary --examples-root ~/workspace/urun-examples
86
+ urun dev --attach prompt-canary --examples-root ~/workspace/urun-examples
87
87
  ```
88
88
 
89
89
  The positional argument is the **deployed app slug** (as shown by `urun app
@@ -96,7 +96,7 @@ list`). Nothing is declared in a manifest — every value is discovered:
96
96
  | local port | the `--port N` in that package's `scripts.dev`, else 3000 |
97
97
  | function | read from the deployed app itself |
98
98
 
99
- `urun demo` uses your stored API key server-side to request a short-lived,
99
+ `urun dev --attach` uses your resolved API key server-side to request a short-lived,
100
100
  function-scoped, origin-pinned browser JWT, sets the frontend's
101
101
  `NEXT_PUBLIC_*` environment variables, starts the local Next.js frontend, and
102
102
  opens it in your browser. The API key itself never reaches the page.
@@ -107,15 +107,15 @@ the right directory yourself with `--frontend-dir` (relative to the examples
107
107
  root):
108
108
 
109
109
  ```bash
110
- urun demo test-matrix-game --frontend-dir matrix-game-3/frontend
110
+ urun dev --attach test-matrix-game --frontend-dir matrix-game-3/frontend
111
111
  ```
112
112
 
113
- #### There is no curated list of demoable examples
113
+ #### There is no curated list of attachable examples
114
114
 
115
- `urun demo` will launch **any** `<example>/frontend` it can match, and it does
116
- not know which examples actually work. Earlier releases read a manifest that
117
- carried a `supported` flag with a reason, and refused the examples marked
118
- unsupported; that allowlist is gone along with the manifest.
115
+ `urun dev --attach` will launch **any** `<example>/frontend` it can match, and
116
+ it does not know which examples actually work. Earlier releases read a
117
+ manifest that carried a `supported` flag with a reason, and refused the
118
+ examples marked unsupported; that allowlist is gone along with the manifest.
119
119
 
120
120
  The practical consequence: a frontend that is a stub, half-built, or known
121
121
  broken now starts a dev server and opens a browser instead of failing up
@@ -349,6 +349,17 @@ Enable a disabled app and bring it back online:
349
349
  urun app enable lingbot-handle
350
350
  ```
351
351
 
352
+ If `urun deploy` or `urun dev --attach` reports that a function was paused
353
+ after repeated startup failures, inspect its runtime logs before overriding
354
+ the safety pause. Then explicitly resume every paused function:
355
+
356
+ ```bash
357
+ urun app enable rapid-clips --force
358
+ ```
359
+
360
+ `--force` may restart a known-crashing function; it does not change the
361
+ platform's automatic pause policy.
362
+
352
363
  Stop a queued, starting, or live session:
353
364
 
354
365
  ```bash
@@ -10,24 +10,17 @@ build-backend = "hatchling.build"
10
10
 
11
11
  [project]
12
12
  name = "urun-cli"
13
- # Cut a release for the 11 commits stranded since v0.5.32 (2026-08-20).
13
+ # Cut the paused-function recovery fixes merged after v0.6.0.
14
14
  #
15
15
  # WHY THIS IS EDITED BY HAND. [tool.semantic_release] below is fully
16
16
  # configured -- conventional commit parser, version_toml pointing here, tag
17
17
  # format, changelog templates -- but NOTHING invokes it: `grep -rn
18
18
  # semantic.release .github/workflows/` matches nothing. release.yaml only
19
19
  # READS this value and skips publishing when the matching tag already exists,
20
- # so the version has been frozen since v0.5.32 was tagged and every merge
21
- # after it published nothing. CHANGELOG.md stopping at 0.5.4 is the same gap
22
- # seen from the other end. Wiring semantic-release into CI is the real fix and
23
- # is deliberately NOT bundled here; this bump is the reversible unblock.
24
- #
25
- # 0.6.0 is semantic-release's OWN answer, not a guess:
26
- # $ uvx --from python-semantic-release semantic-release --noop version --print
27
- # 0.6.0
28
- # (several feat: commits, and major_on_zero = false keeps 0.x on a minor bump)
29
- # so choosing it leaves the numbering where automation would resume.
30
- version = "0.6.0"
20
+ # so every merge remains on the already-published version until this value is
21
+ # advanced. Wiring semantic-release into CI remains separate work; this patch
22
+ # bump is the minimal release unblock for the three post-v0.6.0 fixes.
23
+ version = "0.6.2"
31
24
  description = "End-user CLI for deploying apps to urun"
32
25
  readme = "README.md"
33
26
  requires-python = ">=3.11"
@@ -607,6 +607,23 @@ class ApiClient:
607
607
  body: dict[str, Any] | None = {"environment": environment} if environment else None
608
608
  return self._json("POST", f"/app/{urllib.parse.quote(app, safe='')}/activate", body)
609
609
 
610
+ def set_function_paused(
611
+ self,
612
+ app: str,
613
+ function_name: str,
614
+ paused: bool,
615
+ environment: str | None = None,
616
+ ) -> dict[str, Any]:
617
+ """Pause or resume one function of an app.
618
+
619
+ Hits ``POST /app/<slug>/pause`` with the function name and explicit
620
+ pause state. The org is derived from the API key.
621
+ """
622
+ body: dict[str, Any] = {"paused": paused, "function_name": function_name}
623
+ if environment:
624
+ body["environment"] = environment
625
+ return self._json("POST", f"/app/{urllib.parse.quote(app, safe='')}/pause", body)
626
+
610
627
  def retire_app(self, app: str, environment: str | None = None) -> dict[str, Any]:
611
628
  """Retire an app through the CLI edge route (backs ``urun app delete``).
612
629
 
@@ -115,10 +115,11 @@ ENGINE_DEPS: dict[str, list[str]] = {
115
115
  "safetensors",
116
116
  "huggingface_hub>=1.5.0,<2.0",
117
117
  "opencv-python-headless==4.11.0.86",
118
- "nvidia-cuda-nvcc==13.3.73",
119
- "nvidia-cuda-crt==13.3.73",
120
- "nvidia-cuda-cccl==13.3.3.4.1",
121
- "nvidia-cuda-runtime==13.3.29",
118
+ "nvidia-cuda-nvcc==13.0.88",
119
+ "nvidia-cuda-crt==13.0.88",
120
+ "nvidia-cuda-cccl==13.0.85",
121
+ "nvidia-nvvm==13.0.88",
122
+ "nvidia-cuda-runtime==13.0.96",
122
123
  "numpy<2",
123
124
  "pillow>=10.0.0",
124
125
  "orjson",
@@ -155,10 +156,11 @@ ENGINE_DEPS: dict[str, list[str]] = {
155
156
  "accelerate",
156
157
  "safetensors",
157
158
  "huggingface_hub>=1.5.0,<2.0",
158
- "nvidia-cuda-nvcc==13.3.73",
159
- "nvidia-cuda-crt==13.3.73",
160
- "nvidia-cuda-cccl==13.3.3.4.1",
161
- "nvidia-cuda-runtime==13.3.29",
159
+ "nvidia-cuda-nvcc==13.0.88",
160
+ "nvidia-cuda-crt==13.0.88",
161
+ "nvidia-cuda-cccl==13.0.85",
162
+ "nvidia-nvvm==13.0.88",
163
+ "nvidia-cuda-runtime==13.0.96",
162
164
  "numpy<2",
163
165
  "pillow>=10.0.0",
164
166
  "orjson",
@@ -357,10 +359,11 @@ ENGINE_DEPS: dict[str, list[str]] = {
357
359
  "safetensors",
358
360
  "huggingface_hub>=1.5.0,<2.0",
359
361
  "flashinfer_python[cu13]==0.6.14",
360
- "nvidia-cuda-nvcc==13.2.78",
361
- "nvidia-cuda-crt==13.2.78",
362
- "nvidia-cuda-cccl==13.2.75",
363
- "nvidia-cuda-runtime==13.2.86",
362
+ "nvidia-cuda-nvcc==13.0.88",
363
+ "nvidia-cuda-crt==13.0.88",
364
+ "nvidia-cuda-cccl==13.0.85",
365
+ "nvidia-nvvm==13.0.88",
366
+ "nvidia-cuda-runtime==13.0.96",
364
367
  "numpy<2",
365
368
  "pillow>=10.0.0",
366
369
  "orjson",
@@ -670,13 +673,21 @@ def _published_cuda_for_torch(minor: tuple[int, int]) -> tuple[int, ...] | None:
670
673
  return None
671
674
 
672
675
 
673
- def torch_backend_for_serve(engine: str | None, gpu_spec: str) -> str:
676
+ def torch_backend_for_serve(
677
+ engine: str | None,
678
+ gpu_spec: str,
679
+ *,
680
+ engine_deps: list[str] | None = None,
681
+ ) -> str:
674
682
  """The ``deps.torch_backend`` value to bake for a catalog serve.
675
683
 
676
684
  ARCH-ANCHORED mirror of urun-python ``resolve_torch_backend`` (KEEP IN
677
685
  SYNC): the GPU-arch map value (:func:`torch_backend_for_gpu`) encodes the
678
686
  runtime base image's CUDA ABI and is kept byte-identical whenever it
679
- publishes the ENGINE's pinned torch (from :data:`ENGINE_DEPS`). When the
687
+ publishes the pinned torch. ``engine_deps`` (a MODEL-level override) is
688
+ the pin SOURCE when provided — the SAME list the shim's deps literal and
689
+ ``URUN_SERVE_DEPS`` carry, so one derivation anchors the backend; None
690
+ falls back to the ENGINE's group (from :data:`ENGINE_DEPS`). When the
680
691
  arch lane provably cannot publish the pin, repair to a lane that does AND
681
692
  meets the GPU's kernel floor — preferring the ABI-compatible direction:
682
693
  DOWN (newest below the anchor) for the pre-cxx11 torch<=2.6 era, the
@@ -712,7 +723,8 @@ def torch_backend_for_serve(engine: str | None, gpu_spec: str) -> str:
712
723
  "verdict. Deploy a plain @app.function on gh200 instead."
713
724
  )
714
725
  arch_backend = torch_backend_for_gpu(gpu_spec)
715
- minor = _torch_minor(pinned_torch_version(serve_deps_for_engine(engine)))
726
+ dep_list = engine_deps if engine_deps is not None else serve_deps_for_engine(engine)
727
+ minor = _torch_minor(pinned_torch_version(dep_list))
716
728
  if minor is None:
717
729
  return arch_backend
718
730
  published = _published_cuda_for_torch(minor)
@@ -735,6 +747,24 @@ def torch_backend_for_serve(engine: str | None, gpu_spec: str) -> str:
735
747
  return f"cu{chosen}"
736
748
 
737
749
 
750
+ def serve_deps_for_row(row: CatalogRow) -> list[str]:
751
+ """The python dependency list for THIS row's serve venv.
752
+
753
+ MODEL-level selection: an explicit ``row.engine_deps`` is the COMPLETE
754
+ list (requirement strings — including pinned wheel URLs — pass through
755
+ unchanged); ``None`` falls back to the ENGINE-level source of truth
756
+ (:func:`serve_deps_for_engine`, ``strict=True`` — an engine the map does
757
+ not know fails the deploy, never a silently empty venv).
758
+ """
759
+ # The engine itself is validated strictly on BOTH paths: an unknown engine
760
+ # with an override would otherwise reach shim generation and the
761
+ # function-engines sidecar, moving a clear pre-deploy failure into the pod.
762
+ default = serve_deps_for_engine(row.engine, strict=True)
763
+ if row.engine_deps is not None:
764
+ return list(row.engine_deps)
765
+ return default
766
+
767
+
738
768
  @dataclass(frozen=True)
739
769
  class CatalogRow:
740
770
  """One flattened ``(model_id, variant, gpu_spec)`` catalog placement.
@@ -764,6 +794,10 @@ class CatalogRow:
764
794
  default_gpu: str | None = None
765
795
  min_vram_gb: int | None = None
766
796
  min_host_ram_gb: int | None = None
797
+ # MODEL-level python dependency override (additive — older catalogs predate
798
+ # it): null = the ENGINE-level dep group, verbatim; an explicit list is the
799
+ # COMPLETE replacement (never merged with the engine's group).
800
+ engine_deps: list[str] | None = None
767
801
 
768
802
  @property
769
803
  def ref(self) -> str:
@@ -790,6 +824,40 @@ class CatalogRow:
790
824
  return self.gpu_count == 1 and not self.disagg
791
825
 
792
826
 
827
+ # PEP 508 requirement SHAPE (stdlib-only; this module deliberately carries no
828
+ # `packaging` dependency): ``name[extras] (@ url | version clauses)? (; marker)?``
829
+ # with a real name and a version after every operator — enough to refuse the
830
+ # garbage a hand-typed catalog row can carry (``vllm===``, ``==``, ``torch 2``)
831
+ # at the gate instead of minutes later inside the venv build.
832
+ _REQ_NAME = r"[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?"
833
+ _REQ_VERSION_CLAUSE = r"(?:===|==|!=|<=|>=|~=|<|>)\s*[A-Za-z0-9][A-Za-z0-9.*+!_-]*"
834
+ _REQUIREMENT_SHAPE_RE = re.compile(
835
+ rf"^{_REQ_NAME}\s*(?:\[[A-Za-z0-9._,\s-]*\])?\s*"
836
+ rf"(?:@\s*\S+|{_REQ_VERSION_CLAUSE}(?:\s*,\s*{_REQ_VERSION_CLAUSE})*)?"
837
+ rf"\s*(?:;.+)?$"
838
+ )
839
+
840
+
841
+ def _is_requirement_shaped(dep: str) -> bool:
842
+ """Whether ``dep`` has the shape of a PEP 508 requirement string."""
843
+ return _REQUIREMENT_SHAPE_RE.match(dep.strip()) is not None
844
+
845
+
846
+ def _requirement_url_carries_credentials(dep: str) -> bool:
847
+ """Whether a direct-URL requirement (``name @ url``) embeds userinfo.
848
+
849
+ The catalog is public reference data (anon-readable) and the resolved list
850
+ is echoed into deploy artifacts, so a ``https://token@host/...`` URL would
851
+ publish a credential. Private indexes go through a secret-managed auth
852
+ path, never through the row.
853
+ """
854
+ _name, sep, url = dep.partition("@")
855
+ if not sep:
856
+ return False
857
+ parts = urllib.parse.urlsplit(url.strip())
858
+ return bool(parts.username or parts.password)
859
+
860
+
793
861
  def parse_row(raw: dict[str, Any]) -> CatalogRow:
794
862
  """Build a :class:`CatalogRow` from one PostgREST JSON object.
795
863
 
@@ -825,6 +893,36 @@ def parse_row(raw: dict[str, Any]) -> CatalogRow:
825
893
  value = raw.get(key)
826
894
  return value if isinstance(value, int) and not isinstance(value, bool) else None
827
895
 
896
+ # Additive MODEL-level dep override: ABSENT (older catalogs) and SQL NULL
897
+ # both mean "the engine's own dep group". An explicit value REPLACES that
898
+ # group verbatim, so anything malformed fails LOUDLY here — never a silent
899
+ # fall-back to the default (old) engine stack.
900
+ engine_deps = raw.get("engine_deps")
901
+ if engine_deps is not None:
902
+ if (
903
+ not isinstance(engine_deps, list)
904
+ or not engine_deps
905
+ or not all(isinstance(dep, str) and dep.strip() for dep in engine_deps)
906
+ ):
907
+ raise UrunError(
908
+ "catalog row 'engine_deps' must be null or a non-empty list of "
909
+ f"non-blank requirement strings, got {engine_deps!r}"
910
+ )
911
+ bad = [dep for dep in engine_deps if not _is_requirement_shaped(dep)]
912
+ if bad:
913
+ raise UrunError(
914
+ "catalog row 'engine_deps' entries must be PEP 508 requirement "
915
+ f"strings (name[extras] @ url | version clauses ; marker), got {bad!r}"
916
+ )
917
+ for dep in engine_deps:
918
+ if _requirement_url_carries_credentials(dep):
919
+ # Name only: never echo the credential-bearing URL.
920
+ raise UrunError(
921
+ "catalog row 'engine_deps' entry for "
922
+ f"{dep.partition('@')[0].strip()!r} embeds credentials in its URL; "
923
+ "the catalog is public reference data — use a secret-managed index"
924
+ )
925
+
828
926
  return CatalogRow(
829
927
  model_id=req_str("model_id"),
830
928
  variant=req_str("variant"),
@@ -842,6 +940,7 @@ def parse_row(raw: dict[str, Any]) -> CatalogRow:
842
940
  default_gpu=default_gpu,
843
941
  min_vram_gb=opt_int("min_vram_gb"),
844
942
  min_host_ram_gb=opt_int("min_host_ram_gb"),
943
+ engine_deps=list(engine_deps) if engine_deps is not None else None,
845
944
  )
846
945
 
847
946