cortexgrid-infer 0.3.1__tar.gz → 0.3.3__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_infer-0.3.1 → cortexgrid_infer-0.3.3}/PKG-INFO +46 -5
  2. {cortexgrid_infer-0.3.1 → cortexgrid_infer-0.3.3}/README.md +44 -4
  3. {cortexgrid_infer-0.3.1 → cortexgrid_infer-0.3.3}/cortexgrid_infer/__init__.py +21 -6
  4. {cortexgrid_infer-0.3.1 → cortexgrid_infer-0.3.3}/cortexgrid_infer/core.py +19 -0
  5. {cortexgrid_infer-0.3.1 → cortexgrid_infer-0.3.3}/cortexgrid_infer/importers/base.py +1 -1
  6. {cortexgrid_infer-0.3.1 → cortexgrid_infer-0.3.3}/cortexgrid_infer/importers/huggingface.py +1 -1
  7. cortexgrid_infer-0.3.3/cortexgrid_infer/protocols/__init__.py +14 -0
  8. {cortexgrid_infer-0.3.1/cortexgrid_infer → cortexgrid_infer-0.3.3/cortexgrid_infer/protocols}/imaging.py +1 -1
  9. {cortexgrid_infer-0.3.1/cortexgrid_infer → cortexgrid_infer-0.3.3/cortexgrid_infer/protocols}/meshing.py +3 -3
  10. cortexgrid_infer-0.3.3/cortexgrid_infer/protocols/rewriting.py +44 -0
  11. {cortexgrid_infer-0.3.1 → cortexgrid_infer-0.3.3}/cortexgrid_infer/registry.py +1 -1
  12. cortexgrid_infer-0.3.3/cortexgrid_infer/serve_apps/__init__.py +23 -0
  13. {cortexgrid_infer-0.3.1/cortexgrid_infer/models → cortexgrid_infer-0.3.3/cortexgrid_infer/serve_apps}/anthropic.py +2 -2
  14. cortexgrid_infer-0.3.3/cortexgrid_infer/serve_apps/gemini/__init__.py +11 -0
  15. cortexgrid_infer-0.3.3/cortexgrid_infer/serve_apps/gemini/base.py +50 -0
  16. cortexgrid_infer-0.3.3/cortexgrid_infer/serve_apps/gemini/text2image.py +116 -0
  17. cortexgrid_infer-0.3.3/cortexgrid_infer/serve_apps/gemini/text2text.py +146 -0
  18. {cortexgrid_infer-0.3.1/cortexgrid_infer/models → cortexgrid_infer-0.3.3/cortexgrid_infer/serve_apps}/image2mesh.py +5 -5
  19. {cortexgrid_infer-0.3.1/cortexgrid_infer/models → cortexgrid_infer-0.3.3/cortexgrid_infer/serve_apps}/text2image.py +3 -3
  20. {cortexgrid_infer-0.3.1/cortexgrid_infer/models → cortexgrid_infer-0.3.3/cortexgrid_infer/serve_apps}/text2text.py +2 -2
  21. cortexgrid_infer-0.3.3/cortexgrid_infer/serve_apps/text_rewriter.py +54 -0
  22. {cortexgrid_infer-0.3.1 → cortexgrid_infer-0.3.3}/pyproject.toml +5 -2
  23. cortexgrid_infer-0.3.1/cortexgrid_infer/models/__init__.py +0 -18
  24. {cortexgrid_infer-0.3.1 → cortexgrid_infer-0.3.3}/.gitignore +0 -0
  25. {cortexgrid_infer-0.3.1 → cortexgrid_infer-0.3.3}/LICENSE +0 -0
  26. {cortexgrid_infer-0.3.1 → cortexgrid_infer-0.3.3}/cortexgrid_infer/compiling.py +0 -0
  27. {cortexgrid_infer-0.3.1 → cortexgrid_infer-0.3.3}/cortexgrid_infer/device.py +0 -0
  28. {cortexgrid_infer-0.3.1 → cortexgrid_infer-0.3.3}/cortexgrid_infer/importers/__init__.py +0 -0
  29. {cortexgrid_infer-0.3.1/cortexgrid_infer → cortexgrid_infer-0.3.3/cortexgrid_infer/protocols}/completion.py +0 -0
  30. {cortexgrid_infer-0.3.1 → cortexgrid_infer-0.3.3}/cortexgrid_infer/py.typed +0 -0
  31. {cortexgrid_infer-0.3.1/cortexgrid_infer/models → cortexgrid_infer-0.3.3/cortexgrid_infer/serve_apps}/base.py +0 -0
  32. {cortexgrid_infer-0.3.1 → cortexgrid_infer-0.3.3}/cortexgrid_infer/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: cortexgrid-infer
3
- Version: 0.3.1
3
+ Version: 0.3.3
4
4
  Summary: Serve apps per task (text-to-text, text-to-image, image-to-mesh) and importers per source (HuggingFace) for running models on cortexgrid
5
5
  Project-URL: Homepage, https://github.com/robodatalab/cortexgrid-infer
6
6
  Project-URL: Repository, https://github.com/robodatalab/cortexgrid-infer
@@ -12,6 +12,7 @@ Requires-Dist: anthropic>=0.40.0
12
12
  Requires-Dist: cortexgrid>=0.3.4
13
13
  Requires-Dist: diffusers>=0.38
14
14
  Requires-Dist: fastapi>=0.133
15
+ Requires-Dist: google-genai>=1.0
15
16
  Requires-Dist: httpx>=0.27.0
16
17
  Requires-Dist: huggingface-hub>=0.20.0
17
18
  Requires-Dist: numpy>=1.24
@@ -48,10 +49,10 @@ Upgrading from 0.2.x? See [MIGRATION.md](https://github.com/robodatalab/cortexgr
48
49
 
49
50
  A model is two things, and the library keeps them apart:
50
51
 
51
- - **What runs it** — a serve app in `cortexgrid_infer.models`, named for the task it
52
- runs, not for where the model came from. It loads the weights, answers the task's
53
- routes, and names the client that speaks them. One serve app runs every model of
54
- its task, whichever source staged it.
52
+ - **What runs it** — a serve app in `cortexgrid_infer.serve_apps`, named for the task
53
+ it runs, not for where the model came from. It loads the weights, answers the task's
54
+ routes (defined in `cortexgrid_infer.protocols`), and names the client that speaks
55
+ them. One serve app runs every model of its task, whichever source staged it.
55
56
  - **Where its weights come from** — an importer in `cortexgrid_infer.importers`, one
56
57
  per source. It names the model in the registry, downloads the weights, and sizes
57
58
  them from the source's metadata. It is handed the serve app, so one importer takes
@@ -62,7 +63,10 @@ A model is two things, and the library keeps them apart:
62
63
  | `Text2Text` | text to text | any causal LM in the transformers layout, via `AutoModelForCausalLM` | `ServedCompletingModel` (`complete`) |
63
64
  | `Text2Image` | text (and image) to image | any diffusers pipeline | `ServedGeneratingModel` (`generate`) |
64
65
  | `Image2Mesh` — a base: subclass it with the model's `load` and `make_mesh` | image to mesh | the model the subclass loads, behind the task's `POST /mesh` | `ServedMeshingModel` (`mesh`: one picture in, a `GeneratedMesh` out) |
66
+ | `TextRewriter` | text to text, rewritten | any encoder-decoder LM (T5, BART, Marian, ...) in the transformers layout, via `AutoModelForSeq2SeqLM` | `ServedRewritingModel` (`rewrite`: one text in, one text out) |
65
67
  | `AnthropicText2Text` | text to text | the Anthropic API, forwarded from the cluster | `ServedCompletingModel` (`complete`) |
68
+ | `GeminiText2Text` | text to text | the Gemini API, forwarded from the cluster | `ServedCompletingModel` (`complete`) |
69
+ | `GeminiText2Image` | text to image | the Gemini API, forwarded from the cluster | `ServedGeneratingModel` (`generate`) |
66
70
 
67
71
  | Importer | Source |
68
72
  |---|---|
@@ -290,6 +294,42 @@ included. From there it deploys and streams exactly like a cluster-served model:
290
294
  reports tool calls as structured blocks rather than as generated text, so the
291
295
  serve app re-encodes them into the text form the client parses.
292
296
 
297
+ ### Gemini
298
+
299
+ A Gemini model registers exactly like an Anthropic one, with the serve app for
300
+ its task — `GeminiText2Text` or `GeminiText2Image`. The model id passed to
301
+ `Hosted` is the Gemini model name the deployment calls:
302
+
303
+ ```python
304
+ cortexgrid.set_secret("GEMINI_API_KEY", "AIza...") # once
305
+
306
+ entry = mg.Hosted("gemini-2.5-pro", mg.GeminiText2Text) # or "gemini-2.5-flash", ...
307
+ cortexgrid.register_model(
308
+ entry.serve_app,
309
+ family=entry.family, suffix=entry.suffix,
310
+ requirements=entry.requirements(), config=entry.config(),
311
+ )
312
+ ```
313
+
314
+ Everything said above for Anthropic holds: `config` carries the model name and
315
+ the name of the secret (`api_key_secret="..."` to use another), both editable on
316
+ the model card; a replica needs no hardware; and `entry.client(deployment.url)`
317
+ is a `ServedCompletingModel`, with Gemini's function calls re-encoded into the
318
+ text form the client parses.
319
+
320
+ `GeminiText2Image` answers the same `/generate` route as `Text2Image`, so its
321
+ client is a `ServedGeneratingModel`:
322
+
323
+ ```python
324
+ entry = mg.Hosted("gemini-2.5-flash-image", mg.GeminiText2Image)
325
+ ...
326
+ picture = await mg.generate(entry.client(deployment.url), "a fox in the snow", size=2048)
327
+ ```
328
+
329
+ It takes a prompt only, and makes a square picture. `size` picks Gemini's size
330
+ tier (up to 1K, 2K or 4K) and `seed` is passed on; `steps` and `guidance` are
331
+ diffusion settings with no Gemini counterpart, and come back as `None`.
332
+
293
333
  ## API
294
334
 
295
335
  | | |
@@ -300,6 +340,7 @@ serve app re-encodes them into the text form the client parses.
300
340
  | `ModelEntry` | Base of both: `model_id`, `family`, `suffix`, `serve_app`, `requirements()`, `config()`, `client(url)`. |
301
341
  | `Text2Text`, `Text2Image`, `Image2Mesh` | Serve apps that run weights (`LocalModel`s). |
302
342
  | `AnthropicText2Text` | Serve app that forwards to the Anthropic API (a `HostedModel`). |
343
+ | `GeminiText2Text`, `GeminiText2Image` | Serve apps that forward to the Gemini API (`HostedModel`s sharing the `GeminiModel` base). |
303
344
  | `LocalModel` | Base of the serve apps that run weights: `ignore_patterns()`, `bytes_per_param`, `min_vram_gb`, `requirements(weights)`, `client(url, name)`. |
304
345
  | `HostedModel` | Base of the serve apps that forward: `config(model_id, **settings)`, `requirements()`, `client(url, name)`. |
305
346
  | `Weights(params=None, file_bytes=0)` | What an importer reads about the weights, handed to the serve app to size a replica. |
@@ -21,10 +21,10 @@ Upgrading from 0.2.x? See [MIGRATION.md](https://github.com/robodatalab/cortexgr
21
21
 
22
22
  A model is two things, and the library keeps them apart:
23
23
 
24
- - **What runs it** — a serve app in `cortexgrid_infer.models`, named for the task it
25
- runs, not for where the model came from. It loads the weights, answers the task's
26
- routes, and names the client that speaks them. One serve app runs every model of
27
- its task, whichever source staged it.
24
+ - **What runs it** — a serve app in `cortexgrid_infer.serve_apps`, named for the task
25
+ it runs, not for where the model came from. It loads the weights, answers the task's
26
+ routes (defined in `cortexgrid_infer.protocols`), and names the client that speaks
27
+ them. One serve app runs every model of its task, whichever source staged it.
28
28
  - **Where its weights come from** — an importer in `cortexgrid_infer.importers`, one
29
29
  per source. It names the model in the registry, downloads the weights, and sizes
30
30
  them from the source's metadata. It is handed the serve app, so one importer takes
@@ -35,7 +35,10 @@ A model is two things, and the library keeps them apart:
35
35
  | `Text2Text` | text to text | any causal LM in the transformers layout, via `AutoModelForCausalLM` | `ServedCompletingModel` (`complete`) |
36
36
  | `Text2Image` | text (and image) to image | any diffusers pipeline | `ServedGeneratingModel` (`generate`) |
37
37
  | `Image2Mesh` — a base: subclass it with the model's `load` and `make_mesh` | image to mesh | the model the subclass loads, behind the task's `POST /mesh` | `ServedMeshingModel` (`mesh`: one picture in, a `GeneratedMesh` out) |
38
+ | `TextRewriter` | text to text, rewritten | any encoder-decoder LM (T5, BART, Marian, ...) in the transformers layout, via `AutoModelForSeq2SeqLM` | `ServedRewritingModel` (`rewrite`: one text in, one text out) |
38
39
  | `AnthropicText2Text` | text to text | the Anthropic API, forwarded from the cluster | `ServedCompletingModel` (`complete`) |
40
+ | `GeminiText2Text` | text to text | the Gemini API, forwarded from the cluster | `ServedCompletingModel` (`complete`) |
41
+ | `GeminiText2Image` | text to image | the Gemini API, forwarded from the cluster | `ServedGeneratingModel` (`generate`) |
39
42
 
40
43
  | Importer | Source |
41
44
  |---|---|
@@ -263,6 +266,42 @@ included. From there it deploys and streams exactly like a cluster-served model:
263
266
  reports tool calls as structured blocks rather than as generated text, so the
264
267
  serve app re-encodes them into the text form the client parses.
265
268
 
269
+ ### Gemini
270
+
271
+ A Gemini model registers exactly like an Anthropic one, with the serve app for
272
+ its task — `GeminiText2Text` or `GeminiText2Image`. The model id passed to
273
+ `Hosted` is the Gemini model name the deployment calls:
274
+
275
+ ```python
276
+ cortexgrid.set_secret("GEMINI_API_KEY", "AIza...") # once
277
+
278
+ entry = mg.Hosted("gemini-2.5-pro", mg.GeminiText2Text) # or "gemini-2.5-flash", ...
279
+ cortexgrid.register_model(
280
+ entry.serve_app,
281
+ family=entry.family, suffix=entry.suffix,
282
+ requirements=entry.requirements(), config=entry.config(),
283
+ )
284
+ ```
285
+
286
+ Everything said above for Anthropic holds: `config` carries the model name and
287
+ the name of the secret (`api_key_secret="..."` to use another), both editable on
288
+ the model card; a replica needs no hardware; and `entry.client(deployment.url)`
289
+ is a `ServedCompletingModel`, with Gemini's function calls re-encoded into the
290
+ text form the client parses.
291
+
292
+ `GeminiText2Image` answers the same `/generate` route as `Text2Image`, so its
293
+ client is a `ServedGeneratingModel`:
294
+
295
+ ```python
296
+ entry = mg.Hosted("gemini-2.5-flash-image", mg.GeminiText2Image)
297
+ ...
298
+ picture = await mg.generate(entry.client(deployment.url), "a fox in the snow", size=2048)
299
+ ```
300
+
301
+ It takes a prompt only, and makes a square picture. `size` picks Gemini's size
302
+ tier (up to 1K, 2K or 4K) and `seed` is passed on; `steps` and `guidance` are
303
+ diffusion settings with no Gemini counterpart, and come back as `None`.
304
+
266
305
  ## API
267
306
 
268
307
  | | |
@@ -273,6 +312,7 @@ serve app re-encodes them into the text form the client parses.
273
312
  | `ModelEntry` | Base of both: `model_id`, `family`, `suffix`, `serve_app`, `requirements()`, `config()`, `client(url)`. |
274
313
  | `Text2Text`, `Text2Image`, `Image2Mesh` | Serve apps that run weights (`LocalModel`s). |
275
314
  | `AnthropicText2Text` | Serve app that forwards to the Anthropic API (a `HostedModel`). |
315
+ | `GeminiText2Text`, `GeminiText2Image` | Serve apps that forward to the Gemini API (`HostedModel`s sharing the `GeminiModel` base). |
276
316
  | `LocalModel` | Base of the serve apps that run weights: `ignore_patterns()`, `bytes_per_param`, `min_vram_gb`, `requirements(weights)`, `client(url, name)`. |
277
317
  | `HostedModel` | Base of the serve apps that forward: `config(model_id, **settings)`, `requirements()`, `client(url, name)`. |
278
318
  | `Weights(params=None, file_bytes=0)` | What an importer reads about the weights, handed to the serve app to size a replica. |
@@ -7,8 +7,9 @@ nothing else, split along the two things a model is:
7
7
 
8
8
  - where its weights come from - an importer per source (`importers`), which
9
9
  names, fetches and sizes them;
10
- - what runs it - a serve app per task (`models`), which loads the weights,
11
- answers the task's routes, and names the client that speaks them.
10
+ - what runs it - a serve app per task (`serve_apps`), which loads the weights,
11
+ answers the task's routes (`protocols`), and names the client that speaks
12
+ them.
12
13
 
13
14
  imp = cortexgrid_infer.HuggingFaceImporter("Qwen/Qwen2.5-0.5B-Instruct", cortexgrid_infer.Text2Text)
14
15
  with imp:
@@ -37,24 +38,32 @@ from cortexgrid_infer.core import (
37
38
  GeneratingModel,
38
39
  MeshingModel,
39
40
  Message,
41
+ RewritingModel,
40
42
  Tool,
41
43
  ToolCall,
42
44
  ToolSpec,
43
45
  complete,
44
46
  generate,
45
47
  mesh,
48
+ rewrite,
49
+ )
50
+ from cortexgrid_infer.protocols import (
51
+ ServedCompletingModel,
52
+ ServedGeneratingModel,
53
+ ServedMeshingModel,
54
+ ServedRewritingModel,
46
55
  )
47
- from cortexgrid_infer.completion import ServedCompletingModel
48
- from cortexgrid_infer.imaging import ServedGeneratingModel
49
- from cortexgrid_infer.meshing import ServedMeshingModel
50
56
  from cortexgrid_infer.device import detect_device
51
- from cortexgrid_infer.models import (
57
+ from cortexgrid_infer.serve_apps import (
52
58
  AnthropicText2Text,
59
+ GeminiText2Image,
60
+ GeminiText2Text,
53
61
  HostedModel,
54
62
  Image2Mesh,
55
63
  LocalModel,
56
64
  Text2Image,
57
65
  Text2Text,
66
+ TextRewriter,
58
67
  Weights,
59
68
  )
60
69
  from cortexgrid_infer.registry import Hosted, ModelEntry, split_model_id
@@ -68,6 +77,7 @@ __all__ = [
68
77
  "GeneratedMesh",
69
78
  "GeneratingModel",
70
79
  "MeshingModel",
80
+ "RewritingModel",
71
81
  "Message",
72
82
  "Tool",
73
83
  "ToolCall",
@@ -75,18 +85,23 @@ __all__ = [
75
85
  "complete",
76
86
  "generate",
77
87
  "mesh",
88
+ "rewrite",
78
89
  "ModelDeployFailed",
79
90
  "detect_device",
80
91
  "ServedCompletingModel",
81
92
  "ServedGeneratingModel",
82
93
  "ServedMeshingModel",
94
+ "ServedRewritingModel",
83
95
  "LocalModel",
84
96
  "HostedModel",
85
97
  "Weights",
86
98
  "Text2Text",
87
99
  "Text2Image",
88
100
  "Image2Mesh",
101
+ "TextRewriter",
89
102
  "AnthropicText2Text",
103
+ "GeminiText2Text",
104
+ "GeminiText2Image",
90
105
  "ModelEntry",
91
106
  "Hosted",
92
107
  "split_model_id",
@@ -109,6 +109,15 @@ class MeshingModel(DeployedModel):
109
109
  async def mesh(self, image: bytes, **kwargs: Any) -> GeneratedMesh: ...
110
110
 
111
111
 
112
+ class RewritingModel(DeployedModel):
113
+ """A deployed model that rewrites one text into another - corrects,
114
+ paraphrases, summarises or translates it. Like generation, a single
115
+ request/response."""
116
+
117
+ @abc.abstractmethod
118
+ async def rewrite(self, text: str, max_new_tokens: int = 512, **kwargs: Any) -> str: ...
119
+
120
+
112
121
  async def complete(
113
122
  deployed_model: CompletingModel,
114
123
  messages: list[Message],
@@ -142,3 +151,13 @@ async def generate(
142
151
  async def mesh(deployed_model: MeshingModel, image: bytes, **kwargs: Any) -> GeneratedMesh:
143
152
  """Single image-to-mesh request to the deployed model."""
144
153
  return await deployed_model.mesh(image, **kwargs)
154
+
155
+
156
+ async def rewrite(
157
+ deployed_model: RewritingModel,
158
+ text: str,
159
+ max_new_tokens: int = 512,
160
+ **kwargs: Any,
161
+ ) -> str:
162
+ """Single rewriting request to the deployed model."""
163
+ return await deployed_model.rewrite(text, max_new_tokens=max_new_tokens, **kwargs)
@@ -30,8 +30,8 @@ from typing import Self
30
30
  import cortexgrid
31
31
 
32
32
  from cortexgrid_infer.core import DeployedModel
33
- from cortexgrid_infer.models.base import LocalModel, Weights
34
33
  from cortexgrid_infer.registry import ModelEntry, split_model_id
34
+ from cortexgrid_infer.serve_apps.base import LocalModel, Weights
35
35
 
36
36
 
37
37
  class Importer(ModelEntry):
@@ -15,7 +15,7 @@ from huggingface_hub import HfApi, get_safetensors_metadata, snapshot_download
15
15
  from huggingface_hub.errors import NotASafetensorsRepoError
16
16
 
17
17
  from cortexgrid_infer.importers.base import Importer
18
- from cortexgrid_infer.models.base import LocalModel, Weights
18
+ from cortexgrid_infer.serve_apps.base import LocalModel, Weights
19
19
 
20
20
  # Files that are weights rather than configs, tokenizers or docs. Used for the
21
21
  # fallback estimate, where a repo has no top-level safetensors index.
@@ -0,0 +1,14 @@
1
+ """The wire protocols between a serve app and its client, one per task, each with
2
+ the client that speaks it."""
3
+
4
+ from cortexgrid_infer.protocols.completion import ServedCompletingModel
5
+ from cortexgrid_infer.protocols.imaging import ServedGeneratingModel
6
+ from cortexgrid_infer.protocols.meshing import ServedMeshingModel
7
+ from cortexgrid_infer.protocols.rewriting import ServedRewritingModel
8
+
9
+ __all__ = [
10
+ "ServedCompletingModel",
11
+ "ServedGeneratingModel",
12
+ "ServedMeshingModel",
13
+ "ServedRewritingModel",
14
+ ]
@@ -7,7 +7,7 @@ as null, take the app's defaults. The reply carries the picture as a base64 PNG
7
7
  under `image`, its `width` and `height`, the options the app resolved, and
8
8
  `duration_s`.
9
9
 
10
- `cortexgrid_infer.models.text2image.Text2Image` speaks it for any diffusers
10
+ `cortexgrid_infer.serve_apps.text2image.Text2Image` speaks it for any diffusers
11
11
  pipeline, so one client, `ServedGeneratingModel`, serves every model of the
12
12
  task whoever made it.
13
13
  """
@@ -7,9 +7,9 @@ the mesh as base64 little-endian arrays - `vertices` float32 x, y, z, `faces`
7
7
  int32 indices, `colours` uint8 r, g, b per vertex - in the frame
8
8
  `GeneratedMesh` documents, and `params`, what the model resolved.
9
9
 
10
- Every such app speaks it through `cortexgrid_infer.models.image2mesh.Image2Mesh`,
11
- so one client, `ServedMeshingModel`, serves every model of the task whoever
12
- made it.
10
+ Every such app speaks it through
11
+ `cortexgrid_infer.serve_apps.image2mesh.Image2Mesh`, so one client,
12
+ `ServedMeshingModel`, serves every model of the task whoever made it.
13
13
  """
14
14
 
15
15
  from __future__ import annotations
@@ -0,0 +1,44 @@
1
+ """The wire protocol between a cortexgrid-served rewriting app and its client.
2
+
3
+ A rewriting serve app answers `POST /rewrite`: the request carries the text to
4
+ rewrite under `text`, plus whatever generation options the model takes
5
+ (`max_new_tokens`, `num_beams`, say) as further fields. The reply carries the
6
+ rewritten text under `text`. A request without `max_new_tokens` takes the app's
7
+ default: left to transformers, it would be 20 tokens, cutting all but the
8
+ shortest text.
9
+
10
+ Rewriting is one text in, one text out - correcting grammar, paraphrasing,
11
+ summarising, translating - with no conversation, tools or streaming, which is
12
+ what an encoder-decoder model does. One client, `ServedRewritingModel`, serves
13
+ every model of the task whoever made it.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass
19
+ from typing import Any
20
+
21
+ import httpx
22
+
23
+ from cortexgrid_infer.core import RewritingModel
24
+
25
+
26
+ @dataclass
27
+ class ServedRewritingModel(RewritingModel):
28
+ """The client of any rewriting serve app deployed at `url`."""
29
+
30
+ url: str
31
+ model_id: str
32
+
33
+ @property
34
+ def name(self) -> str:
35
+ return self.model_id
36
+
37
+ async def rewrite(
38
+ self, text: str, max_new_tokens: int = 512, **generation_options: Any
39
+ ) -> str:
40
+ body = {"text": text, "max_new_tokens": max_new_tokens, **generation_options}
41
+ async with httpx.AsyncClient(timeout=None) as client:
42
+ response = await client.post(f"{self.url}/rewrite", json=body)
43
+ response.raise_for_status()
44
+ return response.json()["text"]
@@ -28,7 +28,7 @@ import abc
28
28
  import cortexgrid
29
29
 
30
30
  from cortexgrid_infer.core import DeployedModel
31
- from cortexgrid_infer.models.base import HostedModel
31
+ from cortexgrid_infer.serve_apps.base import HostedModel
32
32
 
33
33
 
34
34
  def split_model_id(model_id: str) -> tuple[str, str]:
@@ -0,0 +1,23 @@
1
+ """The serve apps cortexgrid runs, one per task, each working with a model of
2
+ that task whichever source it came from."""
3
+
4
+ from cortexgrid_infer.serve_apps.anthropic import AnthropicText2Text
5
+ from cortexgrid_infer.serve_apps.base import HostedModel, LocalModel, Weights
6
+ from cortexgrid_infer.serve_apps.gemini import GeminiText2Image, GeminiText2Text
7
+ from cortexgrid_infer.serve_apps.image2mesh import Image2Mesh
8
+ from cortexgrid_infer.serve_apps.text2image import Text2Image
9
+ from cortexgrid_infer.serve_apps.text2text import Text2Text
10
+ from cortexgrid_infer.serve_apps.text_rewriter import TextRewriter
11
+
12
+ __all__ = [
13
+ "LocalModel",
14
+ "HostedModel",
15
+ "Weights",
16
+ "Text2Text",
17
+ "Text2Image",
18
+ "Image2Mesh",
19
+ "TextRewriter",
20
+ "AnthropicText2Text",
21
+ "GeminiText2Text",
22
+ "GeminiText2Image",
23
+ ]
@@ -22,9 +22,9 @@ from cortexgrid import serve
22
22
  from fastapi import FastAPI
23
23
  from fastapi.responses import StreamingResponse
24
24
 
25
- from cortexgrid_infer.completion import ServedCompletingModel, encode_tool_call
26
25
  from cortexgrid_infer.core import Message, ToolSpec
27
- from cortexgrid_infer.models.base import HostedModel
26
+ from cortexgrid_infer.protocols.completion import ServedCompletingModel, encode_tool_call
27
+ from cortexgrid_infer.serve_apps.base import HostedModel
28
28
 
29
29
  _app = FastAPI()
30
30
 
@@ -0,0 +1,11 @@
1
+ """The serve apps that forward to the Gemini API, one per task."""
2
+
3
+ from cortexgrid_infer.serve_apps.gemini.base import GeminiModel
4
+ from cortexgrid_infer.serve_apps.gemini.text2image import GeminiText2Image
5
+ from cortexgrid_infer.serve_apps.gemini.text2text import GeminiText2Text
6
+
7
+ __all__ = [
8
+ "GeminiModel",
9
+ "GeminiText2Text",
10
+ "GeminiText2Image",
11
+ ]
@@ -0,0 +1,50 @@
1
+ """What every Gemini serve app shares: which model to call, and the key to call
2
+ it with.
3
+
4
+ A Gemini app loads no weights and needs no GPU, so there is nothing to import:
5
+ it is registered with `registry.Hosted`. What it needs instead comes from the
6
+ registry entry's `config`, set when the model was registered and editable on its
7
+ model card afterwards.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import cortexgrid
13
+ from google import genai
14
+
15
+ from cortexgrid_infer.serve_apps.base import HostedModel
16
+
17
+ # Keys the registry entry must carry for the deployment to reach the API.
18
+ MODEL_PARAM = "model"
19
+ API_KEY_SECRET_PARAM = "api_key_secret"
20
+
21
+ # The cortexgrid secret the deployment reads its API key from. It is the secret's
22
+ # name that is stored on the registry entry, never the key itself: entries are
23
+ # readable by anyone who can see the model.
24
+ DEFAULT_API_KEY_SECRET = "GEMINI_API_KEY"
25
+
26
+
27
+ class GeminiModel(HostedModel):
28
+ """Base of the serve apps that forward to the Gemini API, one per task."""
29
+
30
+ @classmethod
31
+ def config(
32
+ cls, model_id: str, api_key_secret: str = DEFAULT_API_KEY_SECRET
33
+ ) -> dict[str, str]:
34
+ """`model_id` is the name Gemini knows the model by, and is what the
35
+ deployment sends upstream; `api_key_secret` names the cortexgrid secret
36
+ holding the key to send it with."""
37
+ return {MODEL_PARAM: model_id, API_KEY_SECRET_PARAM: api_key_secret}
38
+
39
+ def __init__(self, family: str, suffix: str, run_name: str) -> None:
40
+ config = cortexgrid.model_config(family, suffix, run_name)
41
+ missing = {MODEL_PARAM, API_KEY_SECRET_PARAM} - config.keys()
42
+ if missing:
43
+ raise RuntimeError(
44
+ f"{family}/{suffix}/{run_name} is missing {sorted(missing)} from "
45
+ "its config; set them on the model card"
46
+ )
47
+ self._model = config[MODEL_PARAM]
48
+ self._client = genai.Client(
49
+ api_key=cortexgrid.get_secret(config[API_KEY_SECRET_PARAM])
50
+ )
@@ -0,0 +1,116 @@
1
+ """The text-to-image serve app that forwards to the Gemini API.
2
+
3
+ It answers the same `POST /generate` route as `Text2Image` (see
4
+ `cortexgrid_infer.protocols.imaging`), so the same client talks to it. It takes a prompt
5
+ only. Of the sampling options, `size` and `seed` have a Gemini counterpart;
6
+ `steps` and `guidance` are diffusion settings, and are reported back as unused.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import base64
12
+ import io
13
+ import time
14
+ from typing import Any
15
+
16
+ from cortexgrid import serve
17
+ from fastapi import FastAPI, HTTPException
18
+ from google.genai import types
19
+ from PIL import Image
20
+
21
+ from cortexgrid_infer.protocols.imaging import ServedGeneratingModel
22
+ from cortexgrid_infer.serve_apps.gemini.base import GeminiModel
23
+
24
+ _app = FastAPI()
25
+
26
+
27
+ def image_size(size: int) -> str | None:
28
+ """The Gemini size tier that covers `size` pixels a side.
29
+
30
+ None up to 1K, the model's default, so that it is left unsent: not every
31
+ Gemini image model takes a size tier."""
32
+ if size <= 1024:
33
+ return None
34
+ if size <= 2048:
35
+ return "2K"
36
+ return "4K"
37
+
38
+
39
+ def to_png(data: bytes) -> tuple[bytes, int, int]:
40
+ """`data`, a picture in whatever format Gemini returned it, as PNG bytes
41
+ along with its width and height."""
42
+ image = Image.open(io.BytesIO(data))
43
+ if image.format == "PNG":
44
+ return data, image.width, image.height
45
+ buffer = io.BytesIO()
46
+ image.save(buffer, format="PNG")
47
+ return buffer.getvalue(), image.width, image.height
48
+
49
+
50
+ def no_image_reason(response: types.GenerateContentResponse) -> str:
51
+ """Why `response` carries no picture: what the model said instead, or why
52
+ it stopped."""
53
+ feedback = response.prompt_feedback
54
+ if feedback and feedback.block_reason:
55
+ return f"the prompt was blocked ({feedback.block_reason})"
56
+ text = " ".join(part.text for part in response.parts or [] if part.text)
57
+ if text:
58
+ return text
59
+ candidate = response.candidates[0] if response.candidates else None
60
+ return f"finish reason {candidate.finish_reason if candidate else None}"
61
+
62
+
63
+ @serve.ingress(_app)
64
+ class GeminiText2Image(GeminiModel):
65
+ @classmethod
66
+ def client(cls, url: str, name: str) -> ServedGeneratingModel:
67
+ return ServedGeneratingModel(url=url, model_id=name)
68
+
69
+ @_app.post("/generate")
70
+ async def generate(self, body: dict[str, Any]) -> dict[str, Any]:
71
+ if body.get("image"):
72
+ raise HTTPException(
73
+ status_code=400,
74
+ detail=f"{type(self).__name__} takes a prompt only, not an image",
75
+ )
76
+ size = int(body.get("size") or 1024)
77
+ seed = body.get("seed")
78
+
79
+ # Square, like `Text2Image` without a reference picture.
80
+ image_config: dict[str, Any] = {"aspect_ratio": "1:1"}
81
+ tier = image_size(size)
82
+ if tier:
83
+ image_config["image_size"] = tier
84
+ config: dict[str, Any] = {
85
+ "response_modalities": ["IMAGE"],
86
+ "image_config": image_config,
87
+ }
88
+ if seed is not None:
89
+ config["seed"] = int(seed)
90
+
91
+ t0 = time.time()
92
+ response = await self._client.aio.models.generate_content(
93
+ model=self._model, contents=body["prompt"], config=config
94
+ )
95
+ duration = time.time() - t0
96
+
97
+ data = next(
98
+ (part.inline_data.data for part in response.parts or [] if part.inline_data),
99
+ None,
100
+ )
101
+ if data is None:
102
+ raise HTTPException(
103
+ status_code=502,
104
+ detail=f"{self._model} returned no image: {no_image_reason(response)}",
105
+ )
106
+
107
+ png, width, height = to_png(data)
108
+ return {
109
+ "image": base64.b64encode(png).decode("ascii"),
110
+ "width": width,
111
+ "height": height,
112
+ "steps": None,
113
+ "guidance": None,
114
+ "seed": seed,
115
+ "duration_s": round(duration, 2),
116
+ }
@@ -0,0 +1,146 @@
1
+ """The text-to-text serve app that forwards to the Gemini API.
2
+
3
+ It speaks the same `/complete` protocol as `Text2Text`, so the same client talks
4
+ to it. Gemini reports tool calls as structured parts rather than as generated
5
+ text, so they are re-encoded on the way out.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import base64
11
+ from collections.abc import AsyncIterator
12
+ from typing import Any
13
+
14
+ from cortexgrid import serve
15
+ from fastapi import FastAPI
16
+ from fastapi.responses import StreamingResponse
17
+
18
+ from cortexgrid_infer.core import Message, ToolSpec
19
+ from cortexgrid_infer.protocols.completion import ServedCompletingModel, encode_tool_call
20
+ from cortexgrid_infer.serve_apps.gemini.base import GeminiModel
21
+
22
+ _app = FastAPI()
23
+
24
+ # Gemini 3 rejects a function call replayed without the thought signature it was
25
+ # returned with, and signatures do not survive the trip through the client as
26
+ # text. Google documents this value as the one that skips the check. The SDK
27
+ # base64-encodes the bytes it is given, so they are decoded here for the value
28
+ # to go out verbatim.
29
+ SKIP_THOUGHT_SIGNATURE = base64.urlsafe_b64decode("skip_thought_signature_validator")
30
+
31
+
32
+ def to_gemini_contents(
33
+ messages: list[Message],
34
+ ) -> tuple[str | None, list[dict[str, Any]]]:
35
+ """Convert OpenAI-style messages to Gemini contents."""
36
+ system_prompt: str | None = None
37
+ contents: list[dict[str, Any]] = []
38
+ # Gemini answers a function call by name, which a tool message does not
39
+ # carry, so it is looked up from the call the message answers.
40
+ tool_names: dict[str, str] = {}
41
+
42
+ for msg in messages:
43
+ role = msg["role"]
44
+
45
+ if role == "system":
46
+ system_prompt = msg["content"]
47
+
48
+ elif role == "user":
49
+ contents.append({"role": "user", "parts": [{"text": msg["content"]}]})
50
+
51
+ elif role == "assistant":
52
+ parts: list[dict[str, Any]] = []
53
+ if msg.get("content"):
54
+ parts.append({"text": msg["content"]})
55
+ for i, tc in enumerate(msg.get("tool_calls", [])):
56
+ func = tc["function"]
57
+ tool_names[tc["id"]] = func["name"]
58
+ call_part: dict[str, Any] = {
59
+ "function_call": {
60
+ "id": tc["id"],
61
+ "name": func["name"],
62
+ "args": func["arguments"],
63
+ }
64
+ }
65
+ # Gemini signs only the first call of a parallel batch.
66
+ if i == 0:
67
+ call_part["thought_signature"] = SKIP_THOUGHT_SIGNATURE
68
+ parts.append(call_part)
69
+ contents.append({"role": "model", "parts": parts})
70
+
71
+ elif role == "tool":
72
+ response_part = {
73
+ "function_response": {
74
+ "id": msg["tool_call_id"],
75
+ "name": tool_names[msg["tool_call_id"]],
76
+ "response": {"output": msg["content"]},
77
+ }
78
+ }
79
+ if (
80
+ contents
81
+ and contents[-1]["role"] == "user"
82
+ and "function_response" in contents[-1]["parts"][0]
83
+ ):
84
+ contents[-1]["parts"].append(response_part)
85
+ else:
86
+ contents.append({"role": "user", "parts": [response_part]})
87
+
88
+ return system_prompt, contents
89
+
90
+
91
+ def to_gemini_tools(
92
+ tool_specs: list[ToolSpec] | None,
93
+ ) -> list[dict[str, Any]] | None:
94
+ """Convert OpenAI-style tool specs to one Gemini tool of function
95
+ declarations."""
96
+ if not tool_specs:
97
+ return None
98
+ declarations = []
99
+ for spec in tool_specs:
100
+ func = spec["function"]
101
+ declaration = {"name": func["name"], "description": func.get("description", "")}
102
+ # Plain JSON Schema, which `parameters` (an OpenAPI subset) is not. A
103
+ # function that takes nothing declares no schema at all.
104
+ if "parameters" in func:
105
+ declaration["parameters_json_schema"] = func["parameters"]
106
+ declarations.append(declaration)
107
+ return [{"function_declarations": declarations}]
108
+
109
+
110
+ @serve.ingress(_app)
111
+ class GeminiText2Text(GeminiModel):
112
+ @classmethod
113
+ def client(cls, url: str, name: str) -> ServedCompletingModel:
114
+ return ServedCompletingModel(url=url, model_id=name)
115
+
116
+ @_app.post("/complete")
117
+ async def complete(self, body: dict[str, Any]) -> StreamingResponse:
118
+ system_prompt, contents = to_gemini_contents(body["messages"])
119
+ tools = to_gemini_tools(body.get("tools"))
120
+
121
+ generate_config: dict[str, Any] = {
122
+ "max_output_tokens": body.get("max_new_tokens", 16 * 1024),
123
+ "temperature": body.get("temperature", 0.7),
124
+ }
125
+ if system_prompt:
126
+ generate_config["system_instruction"] = system_prompt
127
+ if tools:
128
+ generate_config["tools"] = tools
129
+
130
+ async def stream() -> AsyncIterator[bytes]:
131
+ chunks = await self._client.aio.models.generate_content_stream(
132
+ model=self._model, contents=contents, config=generate_config
133
+ )
134
+ async for chunk in chunks:
135
+ if not chunk.candidates or not chunk.candidates[0].content:
136
+ continue
137
+ for part in chunk.candidates[0].content.parts or []:
138
+ if part.function_call:
139
+ yield encode_tool_call(
140
+ part.function_call.name, dict(part.function_call.args or {})
141
+ ).encode("utf-8")
142
+
143
+ elif part.text and not part.thought:
144
+ yield part.text.encode("utf-8")
145
+
146
+ return StreamingResponse(stream(), media_type="text/plain")
@@ -6,8 +6,8 @@ There is no one loader for image-to-mesh models the way `AutoModelForCausalLM`
6
6
  or a diffusers `model_index.json` serves a whole family of repos: each model
7
7
  ships its own code. So a model's serve app subclasses `Image2Mesh` and supplies
8
8
  `load` and `make_mesh`; the subclass inherits the route, whose protocol is
9
- `cortexgrid_infer.meshing`'s, and the client that speaks it. cortexgrid bundles
10
- the subclass's own file, so the model's code travels with it.
9
+ `cortexgrid_infer.protocols.meshing`'s, and the client that speaks it.
10
+ cortexgrid bundles the subclass's own file, so the model's code travels with it.
11
11
 
12
12
  class MyMesh(Image2Mesh):
13
13
  min_vram_gb = 6.0
@@ -31,11 +31,11 @@ from fastapi import FastAPI
31
31
  from PIL import Image, ImageOps
32
32
  import torch
33
33
 
34
- from cortexgrid_infer import meshing
35
34
  from cortexgrid_infer.core import GeneratedMesh
36
35
  from cortexgrid_infer.device import detect_device
37
- from cortexgrid_infer.meshing import ServedMeshingModel
38
- from cortexgrid_infer.models.base import LocalModel
36
+ from cortexgrid_infer.protocols import meshing
37
+ from cortexgrid_infer.protocols.meshing import ServedMeshingModel
38
+ from cortexgrid_infer.serve_apps.base import LocalModel
39
39
 
40
40
 
41
41
  _app = FastAPI()
@@ -1,5 +1,5 @@
1
1
  """The text-to-image serve app: a diffusers pipeline loaded from the cortexgrid
2
- registry, answering `POST /generate` (see `cortexgrid_infer.imaging`).
2
+ registry, answering `POST /generate` (see `cortexgrid_infer.protocols.imaging`).
3
3
 
4
4
  The pipeline class is not hardcoded: it is read from the model's
5
5
  ``model_index.json`` (``_class_name``) and resolved against ``diffusers``, so
@@ -28,8 +28,8 @@ import torch
28
28
 
29
29
  from cortexgrid_infer import compiling
30
30
  from cortexgrid_infer.device import detect_device
31
- from cortexgrid_infer.imaging import ServedGeneratingModel
32
- from cortexgrid_infer.models.base import LocalModel
31
+ from cortexgrid_infer.protocols.imaging import ServedGeneratingModel
32
+ from cortexgrid_infer.serve_apps.base import LocalModel
33
33
 
34
34
 
35
35
  _app = FastAPI()
@@ -28,9 +28,9 @@ from transformers import (
28
28
  import torch
29
29
 
30
30
  from cortexgrid_infer import compiling
31
- from cortexgrid_infer.completion import ServedCompletingModel
32
31
  from cortexgrid_infer.device import detect_device
33
- from cortexgrid_infer.models.base import LocalModel
32
+ from cortexgrid_infer.protocols.completion import ServedCompletingModel
33
+ from cortexgrid_infer.serve_apps.base import LocalModel
34
34
 
35
35
 
36
36
  log = logging.getLogger(__name__)
@@ -0,0 +1,54 @@
1
+ """The text-rewriting serve app: an encoder-decoder LM loaded from the cortexgrid
2
+ registry, answering `POST /rewrite` (see `cortexgrid_infer.protocols.rewriting`).
3
+
4
+ Loads with `AutoModelForSeq2SeqLM`, so it runs any encoder-decoder model (T5,
5
+ BART, Marian, ...) whose weights are in the transformers layout, whichever
6
+ importer staged them. Any task prefix the model expects (`gec: ` for grammar
7
+ correction, say) is the caller's to put in the text.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any
13
+
14
+ import cortexgrid
15
+ from cortexgrid import serve
16
+ from fastapi import FastAPI
17
+ from transformers import AutoModelForSeq2SeqLM, AutoTokenizer
18
+ import torch
19
+
20
+ from cortexgrid_infer.device import detect_device
21
+ from cortexgrid_infer.protocols.rewriting import ServedRewritingModel
22
+ from cortexgrid_infer.serve_apps.base import LocalModel
23
+
24
+
25
+ _app = FastAPI()
26
+
27
+ # Output budget for a request that names none: T5's training context, and
28
+ # generation stops at the end-of-sequence token well before it for most text.
29
+ DEFAULT_MAX_NEW_TOKENS = 512
30
+
31
+
32
+ @serve.ingress(_app)
33
+ class TextRewriter(LocalModel):
34
+ @classmethod
35
+ def client(cls, url: str, name: str) -> ServedRewritingModel:
36
+ return ServedRewritingModel(url=url, model_id=name)
37
+
38
+ def __init__(self, family: str, suffix: str, run_name: str) -> None:
39
+ path = cortexgrid.load_model(family, suffix, run_name)
40
+ self._device = detect_device()
41
+ self._tokenizer = AutoTokenizer.from_pretrained(path)
42
+ self._model = AutoModelForSeq2SeqLM.from_pretrained(
43
+ str(path), torch_dtype=torch.bfloat16
44
+ )
45
+ self._model.to(self._device)
46
+
47
+ @_app.post("/rewrite")
48
+ async def rewrite(self, body: dict[str, Any]) -> dict[str, str]:
49
+ generation_options = {"max_new_tokens": DEFAULT_MAX_NEW_TOKENS} | {
50
+ key: value for key, value in body.items() if key != "text"
51
+ }
52
+ inputs = self._tokenizer(body["text"], return_tensors="pt").to(self._device)
53
+ output = self._model.generate(**inputs, **generation_options)
54
+ return {"text": self._tokenizer.decode(output[0], skip_special_tokens=True)}
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "cortexgrid-infer"
3
- version = "0.3.1"
3
+ version = "0.3.3"
4
4
  description = "Serve apps per task (text-to-text, text-to-image, image-to-mesh) and importers per source (HuggingFace) for running models on cortexgrid"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -22,12 +22,15 @@ dependencies = [
22
22
  "transformers>=4.47.0",
23
23
  "huggingface_hub>=0.20.0",
24
24
  "anthropic>=0.40.0",
25
+ # serve_apps.gemini. The unified Google Gen AI SDK, not the deprecated
26
+ # google-generativeai.
27
+ "google-genai>=1.0",
25
28
  "python-dotenv>=1.0.0",
26
29
  "httpx>=0.27.0",
27
30
  # The serve-apps run on the cluster's FastAPI, which Ray 2.58's Serve
28
31
  # requires to be >=0.133.
29
32
  "fastapi>=0.133",
30
- # Text-to-image serving (models.text2image). FLUX.2 pipelines land in
33
+ # Text-to-image serving (serve_apps.text2image). FLUX.2 pipelines land in
31
34
  # diffusers 0.38; the Qwen3 text encoder needs sentencepiece + protobuf.
32
35
  "diffusers>=0.38",
33
36
  "pillow>=10.0",
@@ -1,18 +0,0 @@
1
- """The serve apps cortexgrid runs, one per task, each working with a model of
2
- that task whichever source it came from."""
3
-
4
- from cortexgrid_infer.models.anthropic import AnthropicText2Text
5
- from cortexgrid_infer.models.base import HostedModel, LocalModel, Weights
6
- from cortexgrid_infer.models.image2mesh import Image2Mesh
7
- from cortexgrid_infer.models.text2image import Text2Image
8
- from cortexgrid_infer.models.text2text import Text2Text
9
-
10
- __all__ = [
11
- "LocalModel",
12
- "HostedModel",
13
- "Weights",
14
- "Text2Text",
15
- "Text2Image",
16
- "Image2Mesh",
17
- "AnthropicText2Text",
18
- ]