cortexgrid 0.3.15__tar.gz → 0.3.17__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 (32) hide show
  1. {cortexgrid-0.3.15 → cortexgrid-0.3.17}/PKG-INFO +9 -10
  2. {cortexgrid-0.3.15 → cortexgrid-0.3.17}/cortexgrid/__init__.py +3 -1
  3. {cortexgrid-0.3.15 → cortexgrid-0.3.17}/cortexgrid/model_serving/__init__.py +3 -2
  4. {cortexgrid-0.3.15 → cortexgrid-0.3.17}/cortexgrid/model_serving/lifecycle.py +100 -16
  5. {cortexgrid-0.3.15 → cortexgrid-0.3.17}/cortexgrid/model_serving/serve_bundle.py +8 -1
  6. {cortexgrid-0.3.15 → cortexgrid-0.3.17}/cortexgrid/model_serving/status.py +5 -46
  7. cortexgrid-0.3.17/cortexgrid/serve.py +167 -0
  8. {cortexgrid-0.3.15 → cortexgrid-0.3.17}/docs/cortexgrid/README.md +6 -9
  9. {cortexgrid-0.3.15 → cortexgrid-0.3.17}/pyproject.toml +3 -1
  10. cortexgrid-0.3.15/cortexgrid/serve.py +0 -43
  11. {cortexgrid-0.3.15 → cortexgrid-0.3.17}/.gitignore +0 -0
  12. {cortexgrid-0.3.15 → cortexgrid-0.3.17}/LICENSE +0 -0
  13. {cortexgrid-0.3.15 → cortexgrid-0.3.17}/cortexgrid/_bundle.py +0 -0
  14. {cortexgrid-0.3.15 → cortexgrid-0.3.17}/cortexgrid/_model_scheduler.py +0 -0
  15. {cortexgrid-0.3.15 → cortexgrid-0.3.17}/cortexgrid/_ray_job_driver.py +0 -0
  16. {cortexgrid-0.3.15 → cortexgrid-0.3.17}/cortexgrid/_serve_entry.py +0 -0
  17. {cortexgrid-0.3.15 → cortexgrid-0.3.17}/cortexgrid/checkpoint.py +0 -0
  18. {cortexgrid-0.3.15 → cortexgrid-0.3.17}/cortexgrid/experiment.py +0 -0
  19. {cortexgrid-0.3.15 → cortexgrid-0.3.17}/cortexgrid/infra.py +0 -0
  20. {cortexgrid-0.3.15 → cortexgrid-0.3.17}/cortexgrid/jobs.py +0 -0
  21. {cortexgrid-0.3.15 → cortexgrid-0.3.17}/cortexgrid/mlflow_util.py +0 -0
  22. {cortexgrid-0.3.15 → cortexgrid-0.3.17}/cortexgrid/model_serving/application_spec.py +0 -0
  23. {cortexgrid-0.3.15 → cortexgrid-0.3.17}/cortexgrid/model_serving/deployment_key.py +0 -0
  24. {cortexgrid-0.3.15 → cortexgrid-0.3.17}/cortexgrid/model_serving/deployment_records.py +0 -0
  25. {cortexgrid-0.3.15 → cortexgrid-0.3.17}/cortexgrid/model_serving/placement.py +0 -0
  26. {cortexgrid-0.3.15 → cortexgrid-0.3.17}/cortexgrid/model_serving/registry_tags.py +0 -0
  27. {cortexgrid-0.3.15 → cortexgrid-0.3.17}/cortexgrid/model_storage.py +0 -0
  28. {cortexgrid-0.3.15 → cortexgrid-0.3.17}/cortexgrid/py.typed +0 -0
  29. {cortexgrid-0.3.15 → cortexgrid-0.3.17}/cortexgrid/ray_util.py +0 -0
  30. {cortexgrid-0.3.15 → cortexgrid-0.3.17}/cortexgrid/s3_util.py +0 -0
  31. {cortexgrid-0.3.15 → cortexgrid-0.3.17}/cortexgrid/secrets.py +0 -0
  32. {cortexgrid-0.3.15 → cortexgrid-0.3.17}/cortexgrid/state.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: cortexgrid
3
- Version: 0.3.15
3
+ Version: 0.3.17
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
@@ -11,7 +11,9 @@ Requires-Python: <3.12,>=3.11
11
11
  Requires-Dist: boto3>=1.34
12
12
  Requires-Dist: cloudpickle>=3.0
13
13
  Requires-Dist: fabric>=3.2.3
14
+ Requires-Dist: fastapi<1,>=0.115
14
15
  Requires-Dist: haikunator>=2.1.0
16
+ Requires-Dist: httpx>=0.27
15
17
  Requires-Dist: mlflow<4,>=3.11
16
18
  Requires-Dist: packaging>=24
17
19
  Requires-Dist: pip>=23.0
@@ -205,31 +207,28 @@ s3_client = cortexgrid.get_s3_client() # boto3 S3 client
205
207
 
206
208
  #### Model registry and serving
207
209
 
208
- Save a trained model's weights together with the serve-app that fronts it, then deploy it as a Ray Serve application. A serve-app is a class fronted by a FastAPI app, marked with cortexgrid's `serve.ingress` (not Ray's):
210
+ Save a trained model's weights together with the serve-app that fronts it, then deploy it as a Ray Serve application. A serve-app is a class marked with cortexgrid's `serve.ingress` (not Ray's), whose `serve.endpoint` methods it serves and whose client it generates:
209
211
 
210
212
  ```python
211
213
  from cortexgrid import serve
212
- from fastapi import FastAPI
213
214
 
214
- app = FastAPI()
215
-
216
- @serve.ingress(app)
215
+ @serve.ingress
217
216
  class MyServeApp:
218
217
  def __init__(self, deployment: cortexgrid.DeploymentKey) -> None:
219
218
  self._weights_dir = cortexgrid.load_model(
220
219
  deployment.family, deployment.suffix, deployment.run_name
221
220
  )
222
221
 
223
- @app.post("/complete")
224
- async def complete(self, body: dict): ...
222
+ @serve.endpoint
223
+ async def complete(self, prompt: str) -> str: ...
225
224
 
226
225
  saved = cortexgrid.save_model(
227
226
  weights_dir, MyServeApp, family="qwen", suffix="instruct",
228
227
  # What one replica needs; the model is deployed only on a host that has it.
229
228
  requirements=cortexgrid.ModelRequirements(num_gpus=1, ram_gb=8, vram_gb=16),
230
229
  )
231
- deployed = cortexgrid.deploy_model("qwen", "instruct", saved.run_name, wait=True)
232
- print(deployed.url)
230
+ deployed = cortexgrid.deploy_model("qwen", "instruct", saved.run_name)
231
+ model: MyServeApp = deployed.client() # MyServeApp's client, once the app serves
233
232
  ```
234
233
 
235
234
  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,
@@ -240,7 +241,7 @@ def deploy_model(
240
241
  wait: bool = False,
241
242
  timeout: float | None = 300.0,
242
243
  config: dict[str, str] | None = None,
243
- ) -> Deployment:
244
+ ) -> Deployment[Any]:
244
245
  experiment = active_experiment()
245
246
  return _deploy_model_serving(
246
247
  family,
@@ -349,6 +350,7 @@ __all__ = [
349
350
  "delete_model",
350
351
  # Model serving
351
352
  "Deployment",
353
+ "DeploymentClient",
352
354
  "DeploymentConfig",
353
355
  "DeploymentKey",
354
356
  "ModelDeployFailed",
@@ -35,9 +35,12 @@ 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,
42
45
  required_models,
43
46
  undeploy_model,
@@ -62,12 +65,10 @@ from cortexgrid.model_serving.serve_bundle import (
62
65
  upload_bundle,
63
66
  )
64
67
  from cortexgrid.model_serving.status import (
65
- Deployment,
66
68
  ReplicaPlacement,
67
69
  ServingMessage,
68
70
  ServingStatus,
69
71
  deployment_config,
70
- list_deployed_models,
71
72
  model_replica_placements,
72
73
  model_serving_messages,
73
74
  model_serving_status,
@@ -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
  )
@@ -101,16 +105,12 @@ def _wait_for_application_running(
101
105
  """`wait_for_model_serving` against a deadline already running. `timeout`
102
106
  only labels the TimeoutError."""
103
107
  while True:
104
- app = get_serve_details().get("applications", {}).get(name)
105
- if app is None:
106
- raise ModelDeployFailed(f"Serve app {name!r} does not exist")
108
+ app = _application_still_able_to_serve(name)
107
109
  status = str(app.get("status", "(missing)"))
108
- message = str(app.get("message", ""))
109
110
  if status == ApplicationStatus.RUNNING.value:
110
111
  return
111
- if status == ApplicationStatus.DEPLOY_FAILED.value:
112
- raise ModelDeployFailed(f"Serve app {name!r} DEPLOY_FAILED: {message}")
113
112
  if _past(deadline):
113
+ message = str(app.get("message", ""))
114
114
  raise TimeoutError(
115
115
  f"Serve app {name!r} did not reach RUNNING within {timeout}s "
116
116
  f"(last status={status!r}, message={message!r})"
@@ -118,6 +118,90 @@ def _wait_for_application_running(
118
118
  time.sleep(_SERVING_POLL_INTERVAL_S)
119
119
 
120
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
+ ServeAppT = TypeVar("ServeAppT")
152
+
153
+
154
+ @dataclass
155
+ class Deployment(Generic[ServeAppT]):
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) -> ServeAppT:
170
+ wait_for_model_serving(self.key)
171
+ deployment_client = self.client_async()
172
+ return deployment_client
173
+
174
+ def client_async(self) -> ServeAppT:
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: ServeAppT = 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
+
121
205
  def _clear_failed_application(
122
206
  key: DeploymentKey, timeout: float | None, deadline: float | None
123
207
  ) -> None:
@@ -225,11 +309,10 @@ def deploy_model(
225
309
  timeout: float | None = 300.0,
226
310
  config: dict[str, str] | None = None,
227
311
  experiment_name: str = "",
228
- ) -> Deployment:
312
+ ) -> Deployment[Any]:
229
313
  """Schedule a Ray Serve app for a previously-saved model and return a
230
- handle carrying its base URL. The caller (e.g. model-gateway) builds
231
- whatever client the app's routes need - streaming, long timeouts, custom
232
- 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.
233
316
 
234
317
  The serve-app class is pulled from the registry entry's tags `save_model`
235
318
  wrote at save time; the caller does not need to hold the class object.
@@ -332,10 +415,11 @@ def deploy_model(
332
415
  bundle_fingerprint=meta.fingerprint,
333
416
  replaced_bundle_fingerprint=observation["replaced_bundle_fingerprint"],
334
417
  experiment_name=experiment_name,
418
+ class_import_path=meta.class_import_path,
335
419
  )
336
420
 
337
421
 
338
- def redeploy_model(key: DeploymentKey) -> Deployment:
422
+ def redeploy_model(key: DeploymentKey) -> Deployment[Any]:
339
423
  record = get_deployment_record(key)
340
424
  if record is None:
341
425
  raise ModelNotDeployed(f"{key} is not deployed")
@@ -349,14 +433,14 @@ def redeploy_model(key: DeploymentKey) -> Deployment:
349
433
  )
350
434
 
351
435
 
352
- def required_models(deployment: DeploymentKey) -> list[Deployment]:
436
+ def required_models(deployment: DeploymentKey) -> list[Deployment[Any]]:
353
437
  _, requirements = load_deploy_metadata(
354
438
  deployment.family, deployment.suffix, deployment.run_name
355
439
  )
356
440
  return [_deployment_of(model) for model in requirements.models]
357
441
 
358
442
 
359
- def _deployment_of(model: DeploymentConfig) -> Deployment:
443
+ def _deployment_of(model: DeploymentConfig) -> Deployment[Any]:
360
444
  key = deployment_key(model.family, model.suffix, model.run_name, model.config)
361
445
  record = get_deployment_record(key)
362
446
  if record is None:
@@ -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
- )
9
+ from cortexgrid.model_serving.application_spec import app_name
13
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,49 +40,12 @@ 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: dict[str, str]
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
49
  def deployment_config(key: DeploymentKey) -> dict[str, str]:
91
50
  record = get_deployment_record(key)
92
51
  if record is None:
@@ -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),
@@ -0,0 +1,167 @@
1
+ """Declare a serve-app's HTTP ingress without importing Ray.
2
+
3
+ from cortexgrid import serve
4
+
5
+ @serve.ingress
6
+ class MyServeApp:
7
+ @serve.endpoint
8
+ async def predict(self, xs: list[float]) -> list[float]: ...
9
+
10
+ Unlike `ray.serve.ingress`, it builds the FastAPI app from the class's
11
+ `serve.endpoint` methods, and the class is left unwrapped: the FastAPI app
12
+ and the client generated for it are only recorded on it, and
13
+ `cortexgrid._serve_entry.build` applies Ray's ingress when it builds the Serve
14
+ application on the cluster.
15
+
16
+ Ray's decorator replaces the class with a wrapper subclass defined in
17
+ ray/serve/api.py; older Ray (e.g. 2.9) leaves the wrapper's __module__ naming
18
+ that module. Everything that locates a serve-app by its module - bundling its
19
+ source, recording its import path - would then find Ray instead of the user's
20
+ code. Deferring the wrap to the one place Serve needs it keeps the class
21
+ locatable everywhere else (the laptop, Ray jobs, tests).
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import inspect
27
+ from dataclasses import dataclass
28
+ from typing import Any, Callable, TypeVar, get_type_hints
29
+
30
+ import httpx
31
+ from fastapi import FastAPI
32
+ from pydantic import TypeAdapter
33
+
34
+ from cortexgrid.model_serving.lifecycle import Deployment, DeploymentClient
35
+
36
+
37
+ _T = TypeVar("_T", bound=type)
38
+ _Method = TypeVar("_Method", bound=Callable[..., Any])
39
+
40
+ _INGRESS_APP_ATTR = "__cortexgrid_ingress_app__"
41
+ _ENDPOINT_ATTR = "__cortexgrid_endpoint__"
42
+
43
+
44
+ def endpoint(method: _Method) -> _Method:
45
+ setattr(method, _ENDPOINT_ATTR, True)
46
+ return method
47
+
48
+
49
+ def ingress(cls: _T) -> _T:
50
+ """Mark a serve-app class as fronted by a FastAPI app serving its
51
+ endpoints, and give it the client that calls them. Returns the class
52
+ itself, unwrapped."""
53
+ app = FastAPI()
54
+ calls: dict[str, Any] = {"__module__": cls.__module__}
55
+ methods = inspect.getmembers(cls, inspect.isfunction)
56
+ for name, method in methods:
57
+ if getattr(method, _ENDPOINT_ATTR, False):
58
+ marshalling = _EndpointMarshalling.of(method)
59
+ route = _route_of(method, marshalling)
60
+ app.add_api_route(f"/{name}", route, methods=["POST"])
61
+ calls[name] = _call_of(name, method, marshalling)
62
+ client = type(f"{cls.__name__}Client", (_EndpointsClient,), calls)
63
+ setattr(cls, _INGRESS_APP_ATTR, app)
64
+ setattr(cls, "client", client)
65
+ return cls
66
+
67
+
68
+ def ingress_app(cls: type) -> Any | None:
69
+ """The app `cls` was marked with by `ingress`, or None if it was not."""
70
+ return getattr(cls, _INGRESS_APP_ATTR, None)
71
+
72
+
73
+ class _EndpointsClient(DeploymentClient):
74
+ def __init__(self, deployment: Deployment[Any]) -> None:
75
+ super().__init__(key=deployment.key, url=deployment.url)
76
+
77
+
78
+ @dataclass(frozen=True)
79
+ class _EndpointMarshalling:
80
+ signature_without_self: inspect.Signature
81
+ parameters: dict[str, TypeAdapter[Any]]
82
+ answer: TypeAdapter[Any]
83
+
84
+ @classmethod
85
+ def of(cls, method: Callable[..., Any]) -> _EndpointMarshalling:
86
+ signature = inspect.signature(method)
87
+ parameter_values = signature.parameters.values()
88
+ parameters_in_order = list(parameter_values)
89
+ signature_without_self = signature.replace(parameters=parameters_in_order[1:])
90
+ hints = get_type_hints(method)
91
+ answer_hint = hints.pop("return")
92
+ answer = TypeAdapter(answer_hint)
93
+ parameters = {name: TypeAdapter(hint) for name, hint in hints.items()}
94
+ return cls(signature_without_self, parameters, answer)
95
+
96
+ def arguments_to_json(self, *args: Any, **kwargs: Any) -> dict[str, Any]:
97
+ bound = self.signature_without_self.bind(*args, **kwargs)
98
+ return {
99
+ name: self.parameters[name].dump_python(value, mode="json")
100
+ for name, value in bound.arguments.items()
101
+ }
102
+
103
+ def arguments_from_json(self, body: dict[str, Any]) -> dict[str, Any]:
104
+ return {
105
+ name: parameter.validate_python(body[name])
106
+ for name, parameter in self.parameters.items()
107
+ if name in body
108
+ }
109
+
110
+ def answer_to_json(self, answer: Any) -> Any:
111
+ return self.answer.dump_python(answer, mode="json")
112
+
113
+ def answer_from_json(self, answered: Any) -> Any:
114
+ return self.answer.validate_python(answered)
115
+
116
+
117
+ def _route_of(
118
+ method: Callable[..., Any], marshalling: _EndpointMarshalling
119
+ ) -> Callable[..., Any]:
120
+ if inspect.iscoroutinefunction(method):
121
+
122
+ async def route(self: Any, body: dict[str, Any]) -> Any:
123
+ arguments = marshalling.arguments_from_json(body)
124
+ answer = await method(self, **arguments)
125
+ answered = marshalling.answer_to_json(answer)
126
+ return answered
127
+
128
+ else:
129
+
130
+ def route(self: Any, body: dict[str, Any]) -> Any:
131
+ arguments = marshalling.arguments_from_json(body)
132
+ answer = method(self, **arguments)
133
+ answered = marshalling.answer_to_json(answer)
134
+ return answered
135
+
136
+ route.__name__ = method.__name__
137
+ route.__qualname__ = method.__qualname__
138
+ return route
139
+
140
+
141
+ def _call_of(
142
+ name: str, method: Callable[..., Any], marshalling: _EndpointMarshalling
143
+ ) -> Callable[..., Any]:
144
+ if inspect.iscoroutinefunction(method):
145
+
146
+ async def call(self: _EndpointsClient, *args: Any, **kwargs: Any) -> Any:
147
+ body = marshalling.arguments_to_json(*args, **kwargs)
148
+ async with httpx.AsyncClient(timeout=None) as client:
149
+ response = await client.post(f"{self.url}/{name}", json=body)
150
+ response.raise_for_status()
151
+ answered = response.json()
152
+ answer = marshalling.answer_from_json(answered)
153
+ return answer
154
+
155
+ else:
156
+
157
+ def call(self: _EndpointsClient, *args: Any, **kwargs: Any) -> Any:
158
+ body = marshalling.arguments_to_json(*args, **kwargs)
159
+ with httpx.Client(timeout=None) as client:
160
+ response = client.post(f"{self.url}/{name}", json=body)
161
+ response.raise_for_status()
162
+ answered = response.json()
163
+ answer = marshalling.answer_from_json(answered)
164
+ return answer
165
+
166
+ call.__name__ = name
167
+ return call
@@ -177,31 +177,28 @@ s3_client = cortexgrid.get_s3_client() # boto3 S3 client
177
177
 
178
178
  #### Model registry and serving
179
179
 
180
- Save a trained model's weights together with the serve-app that fronts it, then deploy it as a Ray Serve application. A serve-app is a class fronted by a FastAPI app, marked with cortexgrid's `serve.ingress` (not Ray's):
180
+ Save a trained model's weights together with the serve-app that fronts it, then deploy it as a Ray Serve application. A serve-app is a class marked with cortexgrid's `serve.ingress` (not Ray's), whose `serve.endpoint` methods it serves and whose client it generates:
181
181
 
182
182
  ```python
183
183
  from cortexgrid import serve
184
- from fastapi import FastAPI
185
184
 
186
- app = FastAPI()
187
-
188
- @serve.ingress(app)
185
+ @serve.ingress
189
186
  class MyServeApp:
190
187
  def __init__(self, deployment: cortexgrid.DeploymentKey) -> None:
191
188
  self._weights_dir = cortexgrid.load_model(
192
189
  deployment.family, deployment.suffix, deployment.run_name
193
190
  )
194
191
 
195
- @app.post("/complete")
196
- async def complete(self, body: dict): ...
192
+ @serve.endpoint
193
+ async def complete(self, prompt: str) -> str: ...
197
194
 
198
195
  saved = cortexgrid.save_model(
199
196
  weights_dir, MyServeApp, family="qwen", suffix="instruct",
200
197
  # What one replica needs; the model is deployed only on a host that has it.
201
198
  requirements=cortexgrid.ModelRequirements(num_gpus=1, ram_gb=8, vram_gb=16),
202
199
  )
203
- deployed = cortexgrid.deploy_model("qwen", "instruct", saved.run_name, wait=True)
204
- print(deployed.url)
200
+ deployed = cortexgrid.deploy_model("qwen", "instruct", saved.run_name)
201
+ model: MyServeApp = deployed.client() # MyServeApp's client, once the app serves
205
202
  ```
206
203
 
207
204
  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.15"
3
+ version = "0.3.17"
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"
@@ -26,6 +26,8 @@ dependencies = [
26
26
  "pydotenv>=0.0.7",
27
27
  "pydantic>=2.13.3",
28
28
  "requests>=2.31",
29
+ "fastapi>=0.115,<1",
30
+ "httpx>=0.27",
29
31
  ]
30
32
 
31
33
  [project.urls]
@@ -1,43 +0,0 @@
1
- """Declare a serve-app's HTTP ingress without importing Ray.
2
-
3
- from cortexgrid import serve
4
-
5
- @serve.ingress(app)
6
- class MyServeApp: ...
7
-
8
- Same shape as `ray.serve.ingress`, but the class is left exactly as written: the
9
- FastAPI app is only recorded on it, and `cortexgrid._serve_entry.build` applies
10
- Ray's ingress when it builds the Serve application on the cluster.
11
-
12
- Ray's decorator replaces the class with a wrapper subclass defined in
13
- ray/serve/api.py; older Ray (e.g. 2.9) leaves the wrapper's __module__ naming
14
- that module. Everything that locates a serve-app by its module - bundling its
15
- source, recording its import path - would then find Ray instead of the user's
16
- code. Deferring the wrap to the one place Serve needs it keeps the class
17
- locatable everywhere else (the laptop, Ray jobs, tests).
18
- """
19
-
20
- from __future__ import annotations
21
-
22
- from typing import Any, Callable, TypeVar
23
-
24
-
25
- _T = TypeVar("_T", bound=type)
26
-
27
- _INGRESS_APP_ATTR = "__cortexgrid_ingress_app__"
28
-
29
-
30
- def ingress(app: Any) -> Callable[[_T], _T]:
31
- """Mark a serve-app class as fronted by the ASGI `app` (e.g. a FastAPI
32
- instance). Returns the class itself, unwrapped."""
33
-
34
- def decorator(cls: _T) -> _T:
35
- setattr(cls, _INGRESS_APP_ATTR, app)
36
- return cls
37
-
38
- return decorator
39
-
40
-
41
- def ingress_app(cls: type) -> Any | None:
42
- """The app `cls` was marked with by `ingress`, or None if it was not."""
43
- return getattr(cls, _INGRESS_APP_ATTR, None)
File without changes
File without changes