cortexgrid 0.3.14__tar.gz → 0.3.16__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 (31) hide show
  1. {cortexgrid-0.3.14 → cortexgrid-0.3.16}/PKG-INFO +10 -3
  2. {cortexgrid-0.3.14 → cortexgrid-0.3.16}/cortexgrid/__init__.py +19 -2
  3. {cortexgrid-0.3.14 → cortexgrid-0.3.16}/cortexgrid/_model_scheduler.py +105 -25
  4. {cortexgrid-0.3.14 → cortexgrid-0.3.16}/cortexgrid/_serve_entry.py +3 -1
  5. {cortexgrid-0.3.14 → cortexgrid-0.3.16}/cortexgrid/model_serving/__init__.py +5 -2
  6. {cortexgrid-0.3.14 → cortexgrid-0.3.16}/cortexgrid/model_serving/application_spec.py +18 -10
  7. {cortexgrid-0.3.14 → cortexgrid-0.3.16}/cortexgrid/model_serving/deployment_key.py +21 -4
  8. {cortexgrid-0.3.14 → cortexgrid-0.3.16}/cortexgrid/model_serving/lifecycle.py +130 -16
  9. {cortexgrid-0.3.14 → cortexgrid-0.3.16}/cortexgrid/model_serving/placement.py +3 -1
  10. {cortexgrid-0.3.14 → cortexgrid-0.3.16}/cortexgrid/model_serving/registry_tags.py +23 -0
  11. {cortexgrid-0.3.14 → cortexgrid-0.3.16}/cortexgrid/model_serving/serve_bundle.py +8 -1
  12. {cortexgrid-0.3.14 → cortexgrid-0.3.16}/cortexgrid/model_serving/status.py +7 -48
  13. {cortexgrid-0.3.14 → cortexgrid-0.3.16}/cortexgrid/model_storage.py +35 -6
  14. {cortexgrid-0.3.14 → cortexgrid-0.3.16}/docs/cortexgrid/README.md +9 -2
  15. {cortexgrid-0.3.14 → cortexgrid-0.3.16}/pyproject.toml +1 -1
  16. {cortexgrid-0.3.14 → cortexgrid-0.3.16}/.gitignore +0 -0
  17. {cortexgrid-0.3.14 → cortexgrid-0.3.16}/LICENSE +0 -0
  18. {cortexgrid-0.3.14 → cortexgrid-0.3.16}/cortexgrid/_bundle.py +0 -0
  19. {cortexgrid-0.3.14 → cortexgrid-0.3.16}/cortexgrid/_ray_job_driver.py +0 -0
  20. {cortexgrid-0.3.14 → cortexgrid-0.3.16}/cortexgrid/checkpoint.py +0 -0
  21. {cortexgrid-0.3.14 → cortexgrid-0.3.16}/cortexgrid/experiment.py +0 -0
  22. {cortexgrid-0.3.14 → cortexgrid-0.3.16}/cortexgrid/infra.py +0 -0
  23. {cortexgrid-0.3.14 → cortexgrid-0.3.16}/cortexgrid/jobs.py +0 -0
  24. {cortexgrid-0.3.14 → cortexgrid-0.3.16}/cortexgrid/mlflow_util.py +0 -0
  25. {cortexgrid-0.3.14 → cortexgrid-0.3.16}/cortexgrid/model_serving/deployment_records.py +0 -0
  26. {cortexgrid-0.3.14 → cortexgrid-0.3.16}/cortexgrid/py.typed +0 -0
  27. {cortexgrid-0.3.14 → cortexgrid-0.3.16}/cortexgrid/ray_util.py +0 -0
  28. {cortexgrid-0.3.14 → cortexgrid-0.3.16}/cortexgrid/s3_util.py +0 -0
  29. {cortexgrid-0.3.14 → cortexgrid-0.3.16}/cortexgrid/secrets.py +0 -0
  30. {cortexgrid-0.3.14 → cortexgrid-0.3.16}/cortexgrid/serve.py +0 -0
  31. {cortexgrid-0.3.14 → cortexgrid-0.3.16}/cortexgrid/state.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: cortexgrid
3
- Version: 0.3.14
3
+ Version: 0.3.16
4
4
  Summary: Connect your ML code to the RoboLab compute cluster — Ray, MLflow, and S3
5
5
  Project-URL: Homepage, https://github.com/robodatalab/cortexgrid
6
6
  Project-URL: Repository, https://github.com/robodatalab/cortexgrid
@@ -213,8 +213,15 @@ from fastapi import FastAPI
213
213
 
214
214
  app = FastAPI()
215
215
 
216
+ class MyClient(cortexgrid.DeploymentClient):
217
+ def complete(self, prompt: str) -> str: ...
218
+
216
219
  @serve.ingress(app)
217
220
  class MyServeApp:
221
+ @classmethod
222
+ def client(cls, deployment: cortexgrid.Deployment[MyClient]) -> MyClient:
223
+ return MyClient(key=deployment.key, url=deployment.url)
224
+
218
225
  def __init__(self, deployment: cortexgrid.DeploymentKey) -> None:
219
226
  self._weights_dir = cortexgrid.load_model(
220
227
  deployment.family, deployment.suffix, deployment.run_name
@@ -228,8 +235,8 @@ saved = cortexgrid.save_model(
228
235
  # What one replica needs; the model is deployed only on a host that has it.
229
236
  requirements=cortexgrid.ModelRequirements(num_gpus=1, ram_gb=8, vram_gb=16),
230
237
  )
231
- deployed = cortexgrid.deploy_model("qwen", "instruct", saved.run_name, wait=True)
232
- print(deployed.url)
238
+ deployed = cortexgrid.deploy_model("qwen", "instruct", saved.run_name)
239
+ model = deployed.client() # MyServeApp's client, once the app serves
233
240
  ```
234
241
 
235
242
  The requirements are part of the model, not of the serve-app class: GPUs, RAM and VRAM (GiB, 0 meaning no requirement) are stored with it and matched against what the cluster's hosts have free. Among the hosts that fit, the model goes to the **smallest GPU** that does, so a 4 GiB model does not occupy a 128 GiB card a bigger one needs; it moves up only once the smaller cards are full. `num_gpus` may be a fraction (`0.25`) to share one card between models, in which case `vram_gb` is what keeps them from overcommitting it. Correct them later with `cortexgrid.set_model_requirements(family, suffix, run_name, requirements)` or on the model card in the dashboard; `cortexgrid.deploy_model(..., num_replicas=2)` chooses how many copies to run.
@@ -102,6 +102,7 @@ from cortexgrid.model_storage import register_model as _register_model_storage
102
102
  from cortexgrid.model_storage import save_model as _save_model_storage
103
103
  from cortexgrid.model_serving import (
104
104
  Deployment,
105
+ DeploymentClient,
105
106
  DeploymentConfig,
106
107
  DeploymentKey,
107
108
  ModelDeployFailed,
@@ -111,6 +112,7 @@ from cortexgrid.model_serving import (
111
112
  list_deployed_models,
112
113
  model_serving_status,
113
114
  redeploy_model,
115
+ required_models,
114
116
  undeploy_model,
115
117
  wait_for_model_serving,
116
118
  )
@@ -170,6 +172,7 @@ def save_model(
170
172
  run produced (e.g. a fine-tune). For a model produced elsewhere that should
171
173
  be uploaded once and reused across runs, use `import_model`."""
172
174
  experiment = Experiment.get_instance()
175
+ _register_required_models(requirements)
173
176
  return _save_model_storage(
174
177
  weights_dir,
175
178
  serve_app,
@@ -198,6 +201,7 @@ def import_model(
198
201
  `created_at` under (family, suffix), whether this call uploaded the model
199
202
  or reused it."""
200
203
  experiment = Experiment.get_instance()
204
+ _register_required_models(requirements)
201
205
  model = _import_model_storage(
202
206
  source, serve_app, family, suffix, requirements, config
203
207
  )
@@ -221,6 +225,7 @@ def register_model(
221
225
  (see `cortexgrid.model_storage.register_model`). The model belongs to no
222
226
  run, so the run keeps the link the same way."""
223
227
  experiment = Experiment.get_instance()
228
+ _register_required_models(requirements)
224
229
  model = _register_model_storage(
225
230
  serve_app, family, suffix, requirements, config
226
231
  )
@@ -235,8 +240,8 @@ def deploy_model(
235
240
  num_replicas: int = 1,
236
241
  wait: bool = False,
237
242
  timeout: float | None = 300.0,
238
- config: DeploymentConfig | None = None,
239
- ) -> Deployment:
243
+ config: dict[str, str] | None = None,
244
+ ) -> Deployment[Any]:
240
245
  experiment = active_experiment()
241
246
  return _deploy_model_serving(
242
247
  family,
@@ -250,6 +255,16 @@ def deploy_model(
250
255
  )
251
256
 
252
257
 
258
+ def _register_required_models(requirements: ModelRequirements | None) -> None:
259
+ for model in [] if requirements is None else requirements.models:
260
+ if model.serve_app is None:
261
+ continue
262
+ if model.source is None:
263
+ register_model(model.serve_app, model.family, model.suffix)
264
+ else:
265
+ import_model(model.source, model.serve_app, model.family, model.suffix)
266
+
267
+
253
268
  def _record_imported_model(
254
269
  run_id: str, family: str, suffix: str, model: SavedModel
255
270
  ) -> None:
@@ -335,6 +350,7 @@ __all__ = [
335
350
  "delete_model",
336
351
  # Model serving
337
352
  "Deployment",
353
+ "DeploymentClient",
338
354
  "DeploymentConfig",
339
355
  "DeploymentKey",
340
356
  "ModelDeployFailed",
@@ -342,6 +358,7 @@ __all__ = [
342
358
  "ServingStatus",
343
359
  "deploy_model",
344
360
  "redeploy_model",
361
+ "required_models",
345
362
  "wait_for_model_serving",
346
363
  "model_serving_status",
347
364
  "undeploy_model",
@@ -16,9 +16,10 @@ log = logging.getLogger(__name__)
16
16
 
17
17
 
18
18
  _RayResources = dict[str, float]
19
+ _ModelGraph = dict[str, set[str]]
19
20
 
20
21
 
21
- _SCHEDULER_PROTOCOL_VERSION = 2
22
+ _SCHEDULER_PROTOCOL_VERSION = 3
22
23
  _SCHEDULER_ACTOR_NAME = f"cortexgrid-model-scheduler-v{_SCHEDULER_PROTOCOL_VERSION}"
23
24
  _SCHEDULER_ACTOR_NAMESPACE = "cortexgrid"
24
25
 
@@ -33,7 +34,7 @@ _STATE_API_RESULT_LIMIT = 10_000
33
34
  _RESOURCE_FLOAT_TOLERANCE = 1e-6
34
35
 
35
36
 
36
- def model_autoscaling_config(max_replicas: int) -> dict[str, Any]:
37
+ def model_autoscaling_config(max_replicas: int, required_apps: list[str]) -> dict[str, Any]:
37
38
  return {
38
39
  "min_replicas": 0,
39
40
  "initial_replicas": max_replicas,
@@ -48,12 +49,14 @@ def model_autoscaling_config(max_replicas: int) -> dict[str, Any]:
48
49
  f"{ModelAutoscalingPolicy.__module__}:"
49
50
  f"{ModelAutoscalingPolicy.__qualname__}"
50
51
  ),
52
+ "policy_kwargs": {"required_apps": required_apps},
51
53
  },
52
54
  }
53
55
 
54
56
 
55
57
  class ModelAutoscalingPolicy:
56
- def __init__(self) -> None:
58
+ def __init__(self, required_apps: list[str]) -> None:
59
+ self._required_apps = required_apps
57
60
  self._scheduler: ActorProxy[_ModelScheduler] | None = None
58
61
  self._pending_pause_answer: Future[bool] | None = None
59
62
  self._pause_requested_by_scheduler = False
@@ -63,7 +66,9 @@ class ModelAutoscalingPolicy:
63
66
  if not self._awaiting_pause_answer():
64
67
  self._pause_requested_by_scheduler = self._collect_pause_answer()
65
68
  self._send_activity_report(
66
- context.deployment_id.to_replica_actor_class_name(), has_requests
69
+ context.deployment_id.to_replica_actor_class_name(),
70
+ context.deployment_id.app_name,
71
+ has_requests,
67
72
  )
68
73
  return self._replica_count(context, has_requests), context.policy_state
69
74
 
@@ -90,12 +95,14 @@ class ModelAutoscalingPolicy:
90
95
  self._scheduler = None
91
96
  return False
92
97
 
93
- def _send_activity_report(self, replica_class_name: str, has_requests: bool) -> None:
98
+ def _send_activity_report(
99
+ self, replica_class_name: str, app_name: str, has_requests: bool
100
+ ) -> None:
94
101
  if self._scheduler is None:
95
102
  self._scheduler = _get_or_create_scheduler_actor()
96
103
  self._pending_pause_answer = (
97
104
  self._scheduler.report_activity_and_check_pause.remote(
98
- replica_class_name, has_requests
105
+ replica_class_name, app_name, self._required_apps, has_requests
99
106
  ).future()
100
107
  )
101
108
 
@@ -117,6 +124,8 @@ def _get_or_create_scheduler_actor() -> ActorProxy[_ModelScheduler]:
117
124
 
118
125
  @dataclass
119
126
  class _ScheduledModel:
127
+ app_name: str = ""
128
+ required_apps: list[str] = field(default_factory=list)
120
129
  has_requests: bool = False
121
130
  last_request_at: float = 0.0
122
131
  last_report_at: float = 0.0
@@ -131,6 +140,7 @@ class _NodeOccupancy:
131
140
  @dataclass
132
141
  class _ReplicaWaitingForRoom:
133
142
  actor_id: str
143
+ replica_class_name: str
134
144
  required_resources: _RayResources
135
145
 
136
146
 
@@ -149,16 +159,27 @@ class _ModelScheduler:
149
159
 
150
160
  @ray.method
151
161
  def report_activity_and_check_pause(
152
- self, replica_class_name: str, has_requests: bool
162
+ self,
163
+ replica_class_name: str,
164
+ app_name: str,
165
+ required_apps: list[str],
166
+ has_requests: bool,
153
167
  ) -> bool:
154
168
  return self._record_activity_and_check_pause(
155
- replica_class_name, has_requests, time.monotonic()
169
+ replica_class_name, app_name, required_apps, has_requests, time.monotonic()
156
170
  )
157
171
 
158
172
  def _record_activity_and_check_pause(
159
- self, replica_class_name: str, has_requests: bool, now: float
173
+ self,
174
+ replica_class_name: str,
175
+ app_name: str,
176
+ required_apps: list[str],
177
+ has_requests: bool,
178
+ now: float,
160
179
  ) -> bool:
161
- self._record_activity(replica_class_name, has_requests, now)
180
+ self._record_activity(
181
+ replica_class_name, app_name, required_apps, has_requests, now
182
+ )
162
183
  if now - self._last_pause_decision_at >= _PAUSE_DECISION_INTERVAL_S:
163
184
  self._last_pause_decision_at = now
164
185
  self._forget_models_silent_since(now - _FORGET_MODELS_SILENT_FOR_S)
@@ -166,9 +187,16 @@ class _ModelScheduler:
166
187
  return replica_class_name in self._models_to_pause
167
188
 
168
189
  def _record_activity(
169
- self, replica_class_name: str, has_requests: bool, now: float
190
+ self,
191
+ replica_class_name: str,
192
+ app_name: str,
193
+ required_apps: list[str],
194
+ has_requests: bool,
195
+ now: float,
170
196
  ) -> None:
171
197
  model = self._models.setdefault(replica_class_name, _ScheduledModel())
198
+ model.app_name = app_name
199
+ model.required_apps = required_apps
172
200
  if has_requests:
173
201
  model.last_request_at = now
174
202
  model.has_requests = has_requests
@@ -194,25 +222,56 @@ class _ModelScheduler:
194
222
  occupancy.replicas_waiting_for_room,
195
223
  key=lambda replica: self._first_seen_waiting_at[replica.actor_id],
196
224
  )
225
+ running = {
226
+ name for node in occupancy.nodes for name in node.resources_held_by_model
227
+ }
228
+ required = self._models_required_directly()
197
229
  models_to_pause: set[str] = set()
198
230
  for replica in waiting_longest_first:
199
231
  models_to_pause |= self._fewest_idle_models_to_pause_for(
200
- replica.required_resources, occupancy.nodes, models_to_pause
232
+ replica.required_resources,
233
+ occupancy.nodes,
234
+ self._pause_groups(
235
+ running - models_to_pause,
236
+ {replica.replica_class_name}
237
+ | _reachable(replica.replica_class_name, required),
238
+ required,
239
+ ),
201
240
  )
202
241
  return models_to_pause
203
242
 
243
+ def _models_required_directly(self) -> _ModelGraph:
244
+ model_of_app = {model.app_name: name for name, model in self._models.items()}
245
+ return {
246
+ name: {model_of_app[app] for app in model.required_apps if app in model_of_app}
247
+ for name, model in self._models.items()
248
+ }
249
+
250
+ def _pause_groups(
251
+ self, running: set[str], unpausable: set[str], required: _ModelGraph
252
+ ) -> _ModelGraph:
253
+ dependents = _reversed(required)
254
+ groups: _ModelGraph = {}
255
+ for name in running:
256
+ group = {name} | (_reachable(name, dependents) & running)
257
+ if unpausable.isdisjoint(group) and not any(
258
+ self._models[member].has_requests for member in group
259
+ ):
260
+ groups[name] = group
261
+ return groups
262
+
204
263
  def _fewest_idle_models_to_pause_for(
205
264
  self,
206
265
  required_resources: _RayResources,
207
266
  nodes: list[_NodeOccupancy],
208
- already_pausing: set[str],
267
+ pause_groups: _ModelGraph,
209
268
  ) -> set[str]:
210
269
  fewest_models_to_pause: list[str] | None = None
211
270
  for node in nodes:
212
271
  if _has_room_for(required_resources, node.free_resources):
213
272
  return set()
214
273
  models_to_pause = self._least_recently_used_idle_models_freeing(
215
- required_resources, node, already_pausing
274
+ required_resources, node, pause_groups
216
275
  )
217
276
  if models_to_pause is not None and (
218
277
  fewest_models_to_pause is None
@@ -225,23 +284,21 @@ class _ModelScheduler:
225
284
  self,
226
285
  required_resources: _RayResources,
227
286
  node: _NodeOccupancy,
228
- already_pausing: set[str],
287
+ pause_groups: _ModelGraph,
229
288
  ) -> list[str] | None:
230
289
  idle_models_least_recently_used_first = sorted(
231
- (
232
- name
233
- for name in node.resources_held_by_model
234
- if name not in already_pausing and not self._models[name].has_requests
235
- ),
290
+ (name for name in node.resources_held_by_model if name in pause_groups),
236
291
  key=lambda name: self._models[name].last_request_at,
237
292
  )
238
293
  resources_free_after_pause = dict(node.free_resources)
239
294
  models_to_pause: list[str] = []
240
295
  for name in idle_models_least_recently_used_first:
241
- models_to_pause.append(name)
242
- _add_resources(
243
- resources_free_after_pause, node.resources_held_by_model[name]
244
- )
296
+ for member in sorted(pause_groups[name] - set(models_to_pause)):
297
+ models_to_pause.append(member)
298
+ _add_resources(
299
+ resources_free_after_pause,
300
+ node.resources_held_by_model.get(member, {}),
301
+ )
245
302
  if _has_room_for(required_resources, resources_free_after_pause):
246
303
  return models_to_pause
247
304
  return None
@@ -269,7 +326,11 @@ def _read_cluster_occupancy(scheduled_replica_class_names: set[str]) -> _Cluster
269
326
  reserved_resources = actor_fields["required_resources"] or {}
270
327
  if _is_serve_replica_waiting_for_room(actor_fields):
271
328
  replicas_waiting_for_room.append(
272
- _ReplicaWaitingForRoom(actor_fields["actor_id"], reserved_resources)
329
+ _ReplicaWaitingForRoom(
330
+ actor_fields["actor_id"],
331
+ actor_fields["class_name"],
332
+ reserved_resources,
333
+ )
273
334
  )
274
335
  continue
275
336
  occupancy = occupancy_by_node_id.get(actor_fields["node_id"])
@@ -305,6 +366,25 @@ def _is_serve_replica_waiting_for_room(actor_fields: dict[str, Any]) -> bool:
305
366
  )
306
367
 
307
368
 
369
+ def _reachable(start: str, edges: _ModelGraph) -> set[str]:
370
+ reached: set[str] = set()
371
+ to_visit = list(edges.get(start, ()))
372
+ while to_visit:
373
+ name = to_visit.pop()
374
+ if name not in reached:
375
+ reached.add(name)
376
+ to_visit.extend(edges.get(name, ()))
377
+ return reached
378
+
379
+
380
+ def _reversed(edges: _ModelGraph) -> _ModelGraph:
381
+ reversed_edges: _ModelGraph = {}
382
+ for source, targets in edges.items():
383
+ for target in targets:
384
+ reversed_edges.setdefault(target, set()).add(source)
385
+ return reversed_edges
386
+
387
+
308
388
  def _add_resources(target: _RayResources, amounts: _RayResources) -> None:
309
389
  for name, amount in amounts.items():
310
390
  target[name] = target.get(name, 0.0) + amount
@@ -77,7 +77,9 @@ def build(args: dict[str, Any]) -> Application:
77
77
  # This builder ships in the bundle, frozen at save time, while `args`
78
78
  # come from the cortexgrid that deploys it; one older than the bundle
79
79
  # sends neither key.
80
- autoscaling_config=model_autoscaling_config(args.get("num_replicas", 1)),
80
+ autoscaling_config=model_autoscaling_config(
81
+ args.get("num_replicas", 1), args.get("required_apps", [])
82
+ ),
81
83
  # Ray 2.32 lowered the default from 100 to 5; keep what serve-apps
82
84
  # had on Ray 2.9.
83
85
  max_ongoing_requests=_MAX_ONGOING_REQUESTS,
@@ -35,10 +35,14 @@ from cortexgrid.model_serving.deployment_key import (
35
35
  deployment_key,
36
36
  )
37
37
  from cortexgrid.model_serving.lifecycle import (
38
+ Deployment,
39
+ DeploymentClient,
38
40
  ModelDeployFailed,
39
41
  ModelNotDeployed,
40
42
  deploy_model,
43
+ list_deployed_models,
41
44
  redeploy_model,
45
+ required_models,
42
46
  undeploy_model,
43
47
  wait_for_model_serving,
44
48
  )
@@ -48,6 +52,7 @@ from cortexgrid.model_serving.registry_tags import (
48
52
  has_requirement_tags,
49
53
  metadata_from_tags,
50
54
  metadata_to_tags,
55
+ required_models_to_tags,
51
56
  requirements_from_tags,
52
57
  requirements_to_tags,
53
58
  )
@@ -60,12 +65,10 @@ from cortexgrid.model_serving.serve_bundle import (
60
65
  upload_bundle,
61
66
  )
62
67
  from cortexgrid.model_serving.status import (
63
- Deployment,
64
68
  ReplicaPlacement,
65
69
  ServingMessage,
66
70
  ServingStatus,
67
71
  deployment_config,
68
- list_deployed_models,
69
72
  model_replica_placements,
70
73
  model_serving_messages,
71
74
  model_serving_status,
@@ -2,7 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  from typing import Any
4
4
 
5
- from cortexgrid.model_serving.deployment_key import DeploymentKey
5
+ from cortexgrid.model_serving.deployment_key import DeploymentKey, deployment_key
6
6
  from cortexgrid.model_serving.placement import ModelRequirements, ray_actor_options
7
7
  from cortexgrid.model_serving.serve_bundle import (
8
8
  BundleMetadata,
@@ -41,6 +41,22 @@ def build_application_spec(
41
41
  runtime_env: dict[str, Any] = {"working_dir": meta.bundle_url}
42
42
  if meta.pip_requirements:
43
43
  runtime_env["pip"] = meta.pip_requirements
44
+ args: dict[str, Any] = {
45
+ "class_import_path": meta.class_import_path,
46
+ "family": key.family,
47
+ "suffix": key.suffix,
48
+ "run_name": key.run_name,
49
+ "config_fingerprint": key.config_fingerprint,
50
+ "num_replicas": num_replicas,
51
+ "ray_actor_options": ray_actor_options(requirements, tiers),
52
+ }
53
+ if requirements.models:
54
+ args["required_apps"] = [
55
+ app_name(
56
+ deployment_key(model.family, model.suffix, model.run_name, model.config)
57
+ )
58
+ for model in requirements.models
59
+ ]
44
60
  return {
45
61
  "name": app_name(key),
46
62
  "route_prefix": route_prefix(key),
@@ -49,15 +65,7 @@ def build_application_spec(
49
65
  # Deployment class is rejected, so cortexgrid.deploy_model goes through
50
66
  # a generic builder that re-imports the user's class and binds it.
51
67
  "import_path": "cortexgrid._serve_entry:build",
52
- "args": {
53
- "class_import_path": meta.class_import_path,
54
- "family": key.family,
55
- "suffix": key.suffix,
56
- "run_name": key.run_name,
57
- "config_fingerprint": key.config_fingerprint,
58
- "num_replicas": num_replicas,
59
- "ray_actor_options": ray_actor_options(requirements, tiers),
60
- },
68
+ "args": args,
61
69
  "runtime_env": runtime_env,
62
70
  }
63
71
 
@@ -2,10 +2,27 @@ from __future__ import annotations
2
2
 
3
3
  import hashlib
4
4
  import json
5
- from dataclasses import dataclass
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import Callable
6
8
 
7
9
 
8
- DeploymentConfig = dict[str, str]
10
+ @dataclass
11
+ class DeploymentConfig:
12
+ family: str
13
+ suffix: str
14
+ run_name: str
15
+ num_replicas: int = 1
16
+ config: dict[str, str] = field(default_factory=dict)
17
+ serve_app: type | None = None
18
+ source: Callable[[], str | Path] | None = None
19
+
20
+ def __post_init__(self) -> None:
21
+ if self.source is not None and self.serve_app is None:
22
+ raise ValueError(
23
+ f"{self.family}/{self.suffix} has a source but no serve_app to front it"
24
+ )
25
+
9
26
 
10
27
  _CONFIG_FINGERPRINT_LENGTH = 12
11
28
 
@@ -19,12 +36,12 @@ class DeploymentKey:
19
36
 
20
37
 
21
38
  def deployment_key(
22
- family: str, suffix: str, run_name: str, config: DeploymentConfig
39
+ family: str, suffix: str, run_name: str, config: dict[str, str]
23
40
  ) -> DeploymentKey:
24
41
  return DeploymentKey(family, suffix, run_name, _config_fingerprint(config))
25
42
 
26
43
 
27
- def _config_fingerprint(config: DeploymentConfig) -> str:
44
+ def _config_fingerprint(config: dict[str, str]) -> str:
28
45
  for name, value in config.items():
29
46
  if not isinstance(name, str) or not isinstance(value, str):
30
47
  raise ValueError(
@@ -1,7 +1,10 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import asyncio
4
+ import importlib
3
5
  import time
4
- from typing import Any
6
+ from dataclasses import dataclass
7
+ from typing import Any, Generic, TypeVar
5
8
 
6
9
  from ray.serve.schema import ApplicationStatus
7
10
 
@@ -22,6 +25,8 @@ from cortexgrid.model_serving.deployment_records import (
22
25
  DeploymentRecord,
23
26
  delete_deployment_record,
24
27
  get_deployment_record,
28
+ key_of_record,
29
+ list_deployment_records,
25
30
  patch_deployment_record,
26
31
  put_deployment_record,
27
32
  )
@@ -29,9 +34,8 @@ from cortexgrid.model_serving.placement import ModelRequirements, vram_tiers
29
34
  from cortexgrid.model_serving.registry_tags import load_deploy_metadata
30
35
  from cortexgrid.model_serving.serve_bundle import BundleMetadata
31
36
  from cortexgrid.model_serving.status import (
37
+ PHASE_NOT_DEPLOYED,
32
38
  PHASE_PAUSED,
33
- Deployment,
34
- deployment_of_record,
35
39
  observed,
36
40
  replaced_bundle_fingerprint_until_rolled_out,
37
41
  )
@@ -77,6 +81,10 @@ def _past(deadline: float | None) -> bool:
77
81
  return deadline is not None and time.monotonic() >= deadline
78
82
 
79
83
 
84
+ def _time_left(deadline: float | None) -> float | None:
85
+ return None if deadline is None else max(0.0, deadline - time.monotonic())
86
+
87
+
80
88
  def wait_for_model_serving(key: DeploymentKey, timeout: float | None = None) -> None:
81
89
  """Block until the model's Serve app is RUNNING.
82
90
 
@@ -97,16 +105,12 @@ def _wait_for_application_running(
97
105
  """`wait_for_model_serving` against a deadline already running. `timeout`
98
106
  only labels the TimeoutError."""
99
107
  while True:
100
- app = get_serve_details().get("applications", {}).get(name)
101
- if app is None:
102
- raise ModelDeployFailed(f"Serve app {name!r} does not exist")
108
+ app = _application_still_able_to_serve(name)
103
109
  status = str(app.get("status", "(missing)"))
104
- message = str(app.get("message", ""))
105
110
  if status == ApplicationStatus.RUNNING.value:
106
111
  return
107
- if status == ApplicationStatus.DEPLOY_FAILED.value:
108
- raise ModelDeployFailed(f"Serve app {name!r} DEPLOY_FAILED: {message}")
109
112
  if _past(deadline):
113
+ message = str(app.get("message", ""))
110
114
  raise TimeoutError(
111
115
  f"Serve app {name!r} did not reach RUNNING within {timeout}s "
112
116
  f"(last status={status!r}, message={message!r})"
@@ -114,6 +118,90 @@ def _wait_for_application_running(
114
118
  time.sleep(_SERVING_POLL_INTERVAL_S)
115
119
 
116
120
 
121
+ def _application_still_able_to_serve(name: str) -> dict[str, Any]:
122
+ serve_details = get_serve_details()
123
+ applications = serve_details.get("applications", {})
124
+ app: dict[str, Any] | None = applications.get(name)
125
+ if app is None:
126
+ raise ModelDeployFailed(f"Serve app {name!r} does not exist")
127
+ status = app.get("status")
128
+ if status == ApplicationStatus.DEPLOY_FAILED.value:
129
+ message = app.get("message", "")
130
+ raise ModelDeployFailed(f"Serve app {name!r} DEPLOY_FAILED: {message}")
131
+ return app
132
+
133
+
134
+ def _model_is_serving(key: DeploymentKey) -> bool:
135
+ name = app_name(key)
136
+ app = _application_still_able_to_serve(name)
137
+ status = app.get("status")
138
+ return status == ApplicationStatus.RUNNING.value
139
+
140
+
141
+ @dataclass
142
+ class DeploymentClient:
143
+ key: DeploymentKey
144
+ url: str
145
+
146
+ async def is_ready(self) -> bool:
147
+ serving = await asyncio.to_thread(_model_is_serving, self.key)
148
+ return serving
149
+
150
+
151
+ DeploymentClientT = TypeVar("DeploymentClientT", bound=DeploymentClient)
152
+
153
+
154
+ @dataclass
155
+ class Deployment(Generic[DeploymentClientT]):
156
+ """A scheduled Ray Serve app fronting a model. `phase` is the normalized
157
+ serving lifecycle phase (see `ServingStatus`); an app that appears in a
158
+ listing always exists, so its phase is never "not_deployed"."""
159
+
160
+ key: DeploymentKey
161
+ config: dict[str, str]
162
+ url: str
163
+ phase: str
164
+ bundle_fingerprint: str
165
+ replaced_bundle_fingerprint: str
166
+ experiment_name: str
167
+ class_import_path: str
168
+
169
+ def client(self) -> DeploymentClientT:
170
+ wait_for_model_serving(self.key)
171
+ deployment_client = self.client_async()
172
+ return deployment_client
173
+
174
+ def client_async(self) -> DeploymentClientT:
175
+ module_name, class_name = self.class_import_path.split(":")
176
+ serve_app_module = importlib.import_module(module_name)
177
+ serve_app = getattr(serve_app_module, class_name)
178
+ deployment_client: DeploymentClientT = serve_app.client(self)
179
+ return deployment_client
180
+
181
+
182
+ def list_deployed_models() -> list[Deployment[Any]]:
183
+ """Return a Deployment for every model `deploy_model` put on Ray Serve
184
+ whose app the control plane last saw existing."""
185
+ return [
186
+ deployment_of_record(record)
187
+ for record in list_deployment_records()
188
+ if record["phase"] != PHASE_NOT_DEPLOYED
189
+ ]
190
+
191
+
192
+ def deployment_of_record(record: DeploymentRecord) -> Deployment[Any]:
193
+ return Deployment(
194
+ key=key_of_record(record),
195
+ config=record["config"],
196
+ url=record["url"],
197
+ phase=record["phase"],
198
+ bundle_fingerprint=bundle_fingerprint_in_spec(record["spec"]),
199
+ replaced_bundle_fingerprint=record["replaced_bundle_fingerprint"],
200
+ experiment_name=record["experiment_name"],
201
+ class_import_path=record["spec"]["args"]["class_import_path"],
202
+ )
203
+
204
+
117
205
  def _clear_failed_application(
118
206
  key: DeploymentKey, timeout: float | None, deadline: float | None
119
207
  ) -> None:
@@ -219,13 +307,12 @@ def deploy_model(
219
307
  num_replicas: int = 1,
220
308
  wait: bool = False,
221
309
  timeout: float | None = 300.0,
222
- config: DeploymentConfig | None = None,
310
+ config: dict[str, str] | None = None,
223
311
  experiment_name: str = "",
224
- ) -> Deployment:
312
+ ) -> Deployment[Any]:
225
313
  """Schedule a Ray Serve app for a previously-saved model and return a
226
- handle carrying its base URL. The caller (e.g. model-gateway) builds
227
- whatever client the app's routes need - streaming, long timeouts, custom
228
- request schemas - against that URL; cortexgrid imposes no traffic contract.
314
+ handle on it. The handle's `client()` is the client the serve-app's own
315
+ `client` factory makes for its routes; cortexgrid imposes no traffic contract.
229
316
 
230
317
  The serve-app class is pulled from the registry entry's tags `save_model`
231
318
  wrote at save time; the caller does not need to hold the class object.
@@ -259,9 +346,20 @@ def deploy_model(
259
346
  stuck in DEPLOYING) will hang forever.
260
347
  """
261
348
  deadline = _deadline(timeout)
349
+ meta, requirements = load_deploy_metadata(family, suffix, run_name)
350
+ for model in requirements.models:
351
+ deploy_model(
352
+ model.family,
353
+ model.suffix,
354
+ model.run_name,
355
+ num_replicas=model.num_replicas,
356
+ wait=True,
357
+ timeout=_time_left(deadline),
358
+ config=model.config,
359
+ experiment_name=experiment_name,
360
+ )
262
361
  deployment_config = config or {}
263
362
  key = deployment_key(family, suffix, run_name, deployment_config)
264
- meta, requirements = load_deploy_metadata(family, suffix, run_name)
265
363
  record = get_deployment_record(key)
266
364
  if record is not None and _record_is_current(
267
365
  record, key, meta, requirements, num_replicas
@@ -317,10 +415,11 @@ def deploy_model(
317
415
  bundle_fingerprint=meta.fingerprint,
318
416
  replaced_bundle_fingerprint=observation["replaced_bundle_fingerprint"],
319
417
  experiment_name=experiment_name,
418
+ class_import_path=meta.class_import_path,
320
419
  )
321
420
 
322
421
 
323
- def redeploy_model(key: DeploymentKey) -> Deployment:
422
+ def redeploy_model(key: DeploymentKey) -> Deployment[Any]:
324
423
  record = get_deployment_record(key)
325
424
  if record is None:
326
425
  raise ModelNotDeployed(f"{key} is not deployed")
@@ -334,6 +433,21 @@ def redeploy_model(key: DeploymentKey) -> Deployment:
334
433
  )
335
434
 
336
435
 
436
+ def required_models(deployment: DeploymentKey) -> list[Deployment[Any]]:
437
+ _, requirements = load_deploy_metadata(
438
+ deployment.family, deployment.suffix, deployment.run_name
439
+ )
440
+ return [_deployment_of(model) for model in requirements.models]
441
+
442
+
443
+ def _deployment_of(model: DeploymentConfig) -> Deployment[Any]:
444
+ key = deployment_key(model.family, model.suffix, model.run_name, model.config)
445
+ record = get_deployment_record(key)
446
+ if record is None:
447
+ raise ModelNotDeployed(f"{key} is not deployed")
448
+ return deployment_of_record(record)
449
+
450
+
337
451
  def undeploy_model(key: DeploymentKey) -> None:
338
452
  """Tear down the Ray Serve app of this deployment and drop its record."""
339
453
  name = app_name(key)
@@ -1,8 +1,9 @@
1
1
  from __future__ import annotations
2
2
 
3
- from dataclasses import dataclass
3
+ from dataclasses import dataclass, field
4
4
  from typing import Any
5
5
 
6
+ from cortexgrid.model_serving.deployment_key import DeploymentConfig
6
7
  from cortexgrid.ray_util import get_ray_nodes
7
8
 
8
9
 
@@ -24,6 +25,7 @@ class ModelRequirements:
24
25
  ram_gb: float = 0.0
25
26
  # GPU memory across the replica's num_gpus GPUs, so it needs a GPU share.
26
27
  vram_gb: float = 0.0
28
+ models: list[DeploymentConfig] = field(default_factory=list)
27
29
 
28
30
  def __post_init__(self) -> None:
29
31
  if self.num_gpus < 0 or self.ram_gb < 0 or self.vram_gb < 0:
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
  import json
4
4
 
5
5
  from cortexgrid import state
6
+ from cortexgrid.model_serving.deployment_key import DeploymentConfig
6
7
  from cortexgrid.model_serving.placement import ModelRequirements
7
8
  from cortexgrid.model_serving.serve_bundle import BundleMetadata
8
9
 
@@ -48,6 +49,7 @@ def bundle_fingerprint_from_tags(tags: dict[str, str]) -> str:
48
49
  _NUM_GPUS_TAG = "num_gpus"
49
50
  _RAM_GB_TAG = "ram_gb"
50
51
  _VRAM_GB_TAG = "vram_gb"
52
+ _MODELS_TAG = "models"
51
53
 
52
54
 
53
55
  def has_requirement_tags(tags: dict[str, str]) -> bool:
@@ -62,6 +64,24 @@ def requirements_to_tags(requirements: ModelRequirements) -> dict[str, str]:
62
64
  _NUM_GPUS_TAG: str(requirements.num_gpus),
63
65
  _RAM_GB_TAG: str(requirements.ram_gb),
64
66
  _VRAM_GB_TAG: str(requirements.vram_gb),
67
+ **required_models_to_tags(requirements.models),
68
+ }
69
+
70
+
71
+ def required_models_to_tags(models: list[DeploymentConfig]) -> dict[str, str]:
72
+ return {
73
+ _MODELS_TAG: json.dumps(
74
+ [
75
+ {
76
+ "family": model.family,
77
+ "suffix": model.suffix,
78
+ "run_name": model.run_name,
79
+ "num_replicas": model.num_replicas,
80
+ "config": model.config,
81
+ }
82
+ for model in models
83
+ ]
84
+ )
65
85
  }
66
86
 
67
87
 
@@ -74,6 +94,9 @@ def requirements_from_tags(tags: dict[str, str]) -> ModelRequirements:
74
94
  num_gpus=float(tags.get(_NUM_GPUS_TAG, "0")),
75
95
  ram_gb=float(tags.get(_RAM_GB_TAG, "0")),
76
96
  vram_gb=float(tags.get(_VRAM_GB_TAG, "0")),
97
+ models=[
98
+ DeploymentConfig(**model) for model in json.loads(tags.get(_MODELS_TAG, "[]"))
99
+ ],
77
100
  )
78
101
 
79
102
 
@@ -55,12 +55,19 @@ def build_bundle(cls: type) -> ServeBundle:
55
55
  is a subclass Ray defines in its own module, and on older Ray (e.g. 2.9) it
56
56
  reports that module as its own, so the class's source and import path would
57
57
  resolve to Ray instead of the serve-app. `cortexgrid.serve.ingress` leaves
58
- the class unwrapped."""
58
+ the class unwrapped. Raises ValueError, too, for a class with no `client`
59
+ factory, which every `Deployment` of it makes its client with."""
59
60
  if any(klass.__module__.startswith("ray.serve") for klass in cls.__mro__):
60
61
  raise ValueError(
61
62
  f"{cls.__name__} is wrapped by ray.serve.ingress; decorate it with "
62
63
  "cortexgrid.serve.ingress instead (from cortexgrid import serve)"
63
64
  )
65
+ if not callable(getattr(cls, "client", None)):
66
+ raise ValueError(
67
+ f"{cls.__name__} has no client factory; give it a classmethod "
68
+ "client(cls, deployment: cortexgrid.Deployment) returning its "
69
+ "cortexgrid.DeploymentClient"
70
+ )
64
71
  entry_file = Path(inspect.getfile(cls)).resolve()
65
72
  serve_entry = Path(__file__).parent.with_name("_serve_entry.py")
66
73
  desc = bundle(entry_file).merge(bundle(serve_entry))
@@ -6,13 +6,9 @@ from typing import Any
6
6
 
7
7
  from ray.serve.schema import ApplicationStatus, ReplicaState
8
8
 
9
- from cortexgrid.model_serving.application_spec import (
10
- app_name,
11
- bundle_fingerprint_in_spec,
12
- )
13
- from cortexgrid.model_serving.deployment_key import DeploymentConfig, DeploymentKey
9
+ from cortexgrid.model_serving.application_spec import app_name
10
+ from cortexgrid.model_serving.deployment_key import DeploymentKey
14
11
  from cortexgrid.model_serving.deployment_records import (
15
- DeploymentRecord,
16
12
  get_deployment_record,
17
13
  key_of_record,
18
14
  list_deployment_records,
@@ -21,7 +17,7 @@ from cortexgrid.model_serving.deployment_records import (
21
17
  from cortexgrid.ray_util import get_serve_details
22
18
 
23
19
 
24
- _PHASE_NOT_DEPLOYED = "not_deployed"
20
+ PHASE_NOT_DEPLOYED = "not_deployed"
25
21
  PHASE_PAUSED = "paused"
26
22
  _ROLLED_OUT_PHASES = ("running", PHASE_PAUSED)
27
23
 
@@ -44,50 +40,13 @@ def _serve_phase(raw_status: str) -> str:
44
40
  return _PHASE_BY_SERVE_STATUS.get(raw_status, "deploying")
45
41
 
46
42
 
47
- @dataclass
48
- class Deployment:
49
- """A scheduled Ray Serve app fronting a model. `phase` is the normalized
50
- serving lifecycle phase (see `ServingStatus`); an app that appears in a
51
- listing always exists, so its phase is never "not_deployed"."""
52
-
53
- key: DeploymentKey
54
- config: DeploymentConfig
55
- url: str
56
- phase: str
57
- bundle_fingerprint: str
58
- replaced_bundle_fingerprint: str
59
- experiment_name: str
60
-
61
-
62
43
  def replaced_bundle_fingerprint_until_rolled_out(
63
44
  replaced_bundle_fingerprint: str, phase: str
64
45
  ) -> str:
65
46
  return "" if phase in _ROLLED_OUT_PHASES else replaced_bundle_fingerprint
66
47
 
67
48
 
68
- def list_deployed_models() -> list[Deployment]:
69
- """Return a Deployment for every model `deploy_model` put on Ray Serve
70
- whose app the control plane last saw existing."""
71
- return [
72
- deployment_of_record(record)
73
- for record in list_deployment_records()
74
- if record["phase"] != _PHASE_NOT_DEPLOYED
75
- ]
76
-
77
-
78
- def deployment_of_record(record: DeploymentRecord) -> Deployment:
79
- return Deployment(
80
- key=key_of_record(record),
81
- config=record["config"],
82
- url=record["url"],
83
- phase=record["phase"],
84
- bundle_fingerprint=bundle_fingerprint_in_spec(record["spec"]),
85
- replaced_bundle_fingerprint=record["replaced_bundle_fingerprint"],
86
- experiment_name=record["experiment_name"],
87
- )
88
-
89
-
90
- def deployment_config(key: DeploymentKey) -> DeploymentConfig:
49
+ def deployment_config(key: DeploymentKey) -> dict[str, str]:
91
50
  record = get_deployment_record(key)
92
51
  if record is None:
93
52
  raise ValueError(f"No deployment {key}")
@@ -134,8 +93,8 @@ def model_serving_status(key: DeploymentKey) -> ServingStatus:
134
93
  `cortexgrid.model_storage.model_registry_status`.
135
94
  """
136
95
  record = get_deployment_record(key)
137
- if record is None or record["phase"] == _PHASE_NOT_DEPLOYED:
138
- return ServingStatus(key, _PHASE_NOT_DEPLOYED, "", None)
96
+ if record is None or record["phase"] == PHASE_NOT_DEPLOYED:
97
+ return ServingStatus(key, PHASE_NOT_DEPLOYED, "", None)
139
98
  return ServingStatus(
140
99
  key=key,
141
100
  phase=record["phase"],
@@ -193,7 +152,7 @@ def observed(app: dict[str, Any] | None) -> dict[str, Any]:
193
152
  deployment record keeps; an app that does not exist reads as
194
153
  "not_deployed"."""
195
154
  if app is None:
196
- return {"phase": _PHASE_NOT_DEPLOYED, "message": "", "replicas": []}
155
+ return {"phase": PHASE_NOT_DEPLOYED, "message": "", "replicas": []}
197
156
  raw = str(app.get("status", ""))
198
157
  return {
199
158
  "phase": _phase(app),
@@ -8,7 +8,8 @@ One registry entry (a `models` row) per (family, suffix, run_name):
8
8
  "s3://<bucket>/models/<run_name>/<family>/<suffix>/weights/"
9
9
  (NO_WEIGHTS for a model registered without any)
10
10
  run linkage -> run_id (unset for imported models)
11
- requirements -> tags["num_gpus"], ["ram_gb"], ["vram_gb"]
11
+ requirements -> tags["num_gpus"], ["ram_gb"], ["vram_gb"], ["models"]
12
+ (models: JSON list)
12
13
  config -> tags["config"] (JSON object)
13
14
 
14
15
  Three ways in: `save_model` registers a fresh copy under the calling run's
@@ -28,9 +29,11 @@ cortexgrid/__init__.py.
28
29
 
29
30
  from __future__ import annotations
30
31
 
32
+ import fcntl
31
33
  import json
32
34
  import os
33
- from dataclasses import dataclass, field
35
+ import shutil
36
+ from dataclasses import dataclass, field, replace
34
37
  from datetime import datetime, timedelta, timezone
35
38
  from pathlib import Path
36
39
  import tempfile
@@ -48,6 +51,7 @@ from cortexgrid.model_serving import (
48
51
  has_requirement_tags,
49
52
  metadata_from_tags,
50
53
  metadata_to_tags,
54
+ required_models_to_tags,
51
55
  requirements_from_tags,
52
56
  requirements_to_tags,
53
57
  upload_bundle,
@@ -93,6 +97,9 @@ IMPORTED = "imported"
93
97
  # why there is no path instead of an empty one.
94
98
  NO_WEIGHTS = "cortexgrid://no-weights"
95
99
 
100
+ _MODEL_CACHE_DIR_ENV = "CORTEXGRID_MODEL_CACHE_DIR"
101
+ _DEFAULT_MODEL_CACHE_DIR = Path.home() / ".cache" / "cortexgrid" / "models"
102
+
96
103
 
97
104
  @dataclass
98
105
  class SavedModel:
@@ -223,6 +230,23 @@ def _download_s3_uri(uri: str, dest_dir: str | Path | None) -> Path:
223
230
  return dest
224
231
 
225
232
 
233
+ def _model_cache_dir() -> Path:
234
+ return Path(os.environ.get(_MODEL_CACHE_DIR_ENV, _DEFAULT_MODEL_CACHE_DIR))
235
+
236
+
237
+ def _load_imported_weights(uri: str, family: str, suffix: str) -> Path:
238
+ cached = _model_cache_dir() / family / suffix
239
+ cached.parent.mkdir(parents=True, exist_ok=True)
240
+ with open(cached.with_name(f"{suffix}.lock"), "w") as lock:
241
+ fcntl.flock(lock, fcntl.LOCK_EX)
242
+ if not cached.is_dir():
243
+ partial = cached.with_name(f"{suffix}.partial")
244
+ shutil.rmtree(partial, ignore_errors=True)
245
+ _download_s3_uri(uri, partial)
246
+ partial.rename(cached)
247
+ return cached
248
+
249
+
226
250
  def save_model(
227
251
  weights_dir: str | Path,
228
252
  serve_app: type,
@@ -292,8 +316,9 @@ def import_model(
292
316
  longer matches the stored bundle, it is re-bundled first and the
293
317
  weights are kept (see `_refresh_bundle`). `requirements` and `config`
294
318
  are stored only if the version has none yet, so values changed since
295
- with `set_model_requirements` / `set_model_config` are kept. To replace
296
- the weights, `delete_model` it first.
319
+ with `set_model_requirements` / `set_model_config` are kept; the
320
+ required models in `requirements` replace the stored ones regardless.
321
+ To replace the weights, `delete_model` it first.
297
322
  - "uploading": raises RuntimeError - another process is importing it.
298
323
  - "upload_failed" / "broken": deleted and imported again.
299
324
 
@@ -386,11 +411,13 @@ def _refresh_bundle(serve_app: type, family: str, suffix: str) -> None:
386
411
  def _set_missing_requirements(
387
412
  family: str, suffix: str, requirements: ModelRequirements
388
413
  ) -> ModelRequirements:
389
- """Store `requirements` on an imported model whose version has none yet.
414
+ """Store `requirements` on an imported model whose version has none yet;
415
+ its required models are stored either way, since only code declares them.
390
416
  Returns the requirements the version holds afterwards."""
391
417
  version = _get_version(family, suffix, IMPORTED)
392
418
  if has_requirement_tags(version.tags):
393
- return requirements_from_tags(version.tags)
419
+ _set_tags(family, suffix, IMPORTED, required_models_to_tags(requirements.models))
420
+ return replace(requirements_from_tags(version.tags), models=requirements.models)
394
421
  _set_tags(family, suffix, IMPORTED, requirements_to_tags(requirements))
395
422
  return requirements
396
423
 
@@ -499,6 +526,8 @@ def load_model(family: str, suffix: str, run_name: str) -> Path:
499
526
  f"Model {family}/{suffix}/{run_name} was registered without "
500
527
  "weights; there is nothing to load"
501
528
  )
529
+ if run_name == IMPORTED:
530
+ return _load_imported_weights(version.source, family, suffix)
502
531
  return _download_s3_uri(version.source, None)
503
532
 
504
533
 
@@ -185,8 +185,15 @@ from fastapi import FastAPI
185
185
 
186
186
  app = FastAPI()
187
187
 
188
+ class MyClient(cortexgrid.DeploymentClient):
189
+ def complete(self, prompt: str) -> str: ...
190
+
188
191
  @serve.ingress(app)
189
192
  class MyServeApp:
193
+ @classmethod
194
+ def client(cls, deployment: cortexgrid.Deployment[MyClient]) -> MyClient:
195
+ return MyClient(key=deployment.key, url=deployment.url)
196
+
190
197
  def __init__(self, deployment: cortexgrid.DeploymentKey) -> None:
191
198
  self._weights_dir = cortexgrid.load_model(
192
199
  deployment.family, deployment.suffix, deployment.run_name
@@ -200,8 +207,8 @@ saved = cortexgrid.save_model(
200
207
  # What one replica needs; the model is deployed only on a host that has it.
201
208
  requirements=cortexgrid.ModelRequirements(num_gpus=1, ram_gb=8, vram_gb=16),
202
209
  )
203
- deployed = cortexgrid.deploy_model("qwen", "instruct", saved.run_name, wait=True)
204
- print(deployed.url)
210
+ deployed = cortexgrid.deploy_model("qwen", "instruct", saved.run_name)
211
+ model = deployed.client() # MyServeApp's client, once the app serves
205
212
  ```
206
213
 
207
214
  The requirements are part of the model, not of the serve-app class: GPUs, RAM and VRAM (GiB, 0 meaning no requirement) are stored with it and matched against what the cluster's hosts have free. Among the hosts that fit, the model goes to the **smallest GPU** that does, so a 4 GiB model does not occupy a 128 GiB card a bigger one needs; it moves up only once the smaller cards are full. `num_gpus` may be a fraction (`0.25`) to share one card between models, in which case `vram_gb` is what keeps them from overcommitting it. Correct them later with `cortexgrid.set_model_requirements(family, suffix, run_name, requirements)` or on the model card in the dashboard; `cortexgrid.deploy_model(..., num_replicas=2)` chooses how many copies to run.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "cortexgrid"
3
- version = "0.3.14"
3
+ version = "0.3.16"
4
4
  description = "Connect your ML code to the RoboLab compute cluster — Ray, MLflow, and S3"
5
5
  readme = "docs/cortexgrid/README.md"
6
6
  license = "Apache-2.0"
File without changes
File without changes