cortexgrid-infer 0.3.1__tar.gz → 0.3.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {cortexgrid_infer-0.3.1 → cortexgrid_infer-0.3.2}/PKG-INFO +41 -1
- {cortexgrid_infer-0.3.1 → cortexgrid_infer-0.3.2}/README.md +39 -0
- {cortexgrid_infer-0.3.1 → cortexgrid_infer-0.3.2}/cortexgrid_infer/__init__.py +4 -0
- {cortexgrid_infer-0.3.1 → cortexgrid_infer-0.3.2}/cortexgrid_infer/models/__init__.py +3 -0
- cortexgrid_infer-0.3.2/cortexgrid_infer/models/gemini/__init__.py +11 -0
- cortexgrid_infer-0.3.2/cortexgrid_infer/models/gemini/base.py +50 -0
- cortexgrid_infer-0.3.2/cortexgrid_infer/models/gemini/text2image.py +116 -0
- cortexgrid_infer-0.3.2/cortexgrid_infer/models/gemini/text2text.py +146 -0
- {cortexgrid_infer-0.3.1 → cortexgrid_infer-0.3.2}/pyproject.toml +4 -1
- {cortexgrid_infer-0.3.1 → cortexgrid_infer-0.3.2}/.gitignore +0 -0
- {cortexgrid_infer-0.3.1 → cortexgrid_infer-0.3.2}/LICENSE +0 -0
- {cortexgrid_infer-0.3.1 → cortexgrid_infer-0.3.2}/cortexgrid_infer/compiling.py +0 -0
- {cortexgrid_infer-0.3.1 → cortexgrid_infer-0.3.2}/cortexgrid_infer/completion.py +0 -0
- {cortexgrid_infer-0.3.1 → cortexgrid_infer-0.3.2}/cortexgrid_infer/core.py +0 -0
- {cortexgrid_infer-0.3.1 → cortexgrid_infer-0.3.2}/cortexgrid_infer/device.py +0 -0
- {cortexgrid_infer-0.3.1 → cortexgrid_infer-0.3.2}/cortexgrid_infer/imaging.py +0 -0
- {cortexgrid_infer-0.3.1 → cortexgrid_infer-0.3.2}/cortexgrid_infer/importers/__init__.py +0 -0
- {cortexgrid_infer-0.3.1 → cortexgrid_infer-0.3.2}/cortexgrid_infer/importers/base.py +0 -0
- {cortexgrid_infer-0.3.1 → cortexgrid_infer-0.3.2}/cortexgrid_infer/importers/huggingface.py +0 -0
- {cortexgrid_infer-0.3.1 → cortexgrid_infer-0.3.2}/cortexgrid_infer/meshing.py +0 -0
- {cortexgrid_infer-0.3.1 → cortexgrid_infer-0.3.2}/cortexgrid_infer/models/anthropic.py +0 -0
- {cortexgrid_infer-0.3.1 → cortexgrid_infer-0.3.2}/cortexgrid_infer/models/base.py +0 -0
- {cortexgrid_infer-0.3.1 → cortexgrid_infer-0.3.2}/cortexgrid_infer/models/image2mesh.py +0 -0
- {cortexgrid_infer-0.3.1 → cortexgrid_infer-0.3.2}/cortexgrid_infer/models/text2image.py +0 -0
- {cortexgrid_infer-0.3.1 → cortexgrid_infer-0.3.2}/cortexgrid_infer/models/text2text.py +0 -0
- {cortexgrid_infer-0.3.1 → cortexgrid_infer-0.3.2}/cortexgrid_infer/py.typed +0 -0
- {cortexgrid_infer-0.3.1 → cortexgrid_infer-0.3.2}/cortexgrid_infer/registry.py +0 -0
- {cortexgrid_infer-0.3.1 → cortexgrid_infer-0.3.2}/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.
|
|
3
|
+
Version: 0.3.2
|
|
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
|
|
@@ -63,6 +64,8 @@ A model is two things, and the library keeps them apart:
|
|
|
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) |
|
|
65
66
|
| `AnthropicText2Text` | text to text | the Anthropic API, forwarded from the cluster | `ServedCompletingModel` (`complete`) |
|
|
67
|
+
| `GeminiText2Text` | text to text | the Gemini API, forwarded from the cluster | `ServedCompletingModel` (`complete`) |
|
|
68
|
+
| `GeminiText2Image` | text to image | the Gemini API, forwarded from the cluster | `ServedGeneratingModel` (`generate`) |
|
|
66
69
|
|
|
67
70
|
| Importer | Source |
|
|
68
71
|
|---|---|
|
|
@@ -290,6 +293,42 @@ included. From there it deploys and streams exactly like a cluster-served model:
|
|
|
290
293
|
reports tool calls as structured blocks rather than as generated text, so the
|
|
291
294
|
serve app re-encodes them into the text form the client parses.
|
|
292
295
|
|
|
296
|
+
### Gemini
|
|
297
|
+
|
|
298
|
+
A Gemini model registers exactly like an Anthropic one, with the serve app for
|
|
299
|
+
its task — `GeminiText2Text` or `GeminiText2Image`. The model id passed to
|
|
300
|
+
`Hosted` is the Gemini model name the deployment calls:
|
|
301
|
+
|
|
302
|
+
```python
|
|
303
|
+
cortexgrid.set_secret("GEMINI_API_KEY", "AIza...") # once
|
|
304
|
+
|
|
305
|
+
entry = mg.Hosted("gemini-2.5-pro", mg.GeminiText2Text) # or "gemini-2.5-flash", ...
|
|
306
|
+
cortexgrid.register_model(
|
|
307
|
+
entry.serve_app,
|
|
308
|
+
family=entry.family, suffix=entry.suffix,
|
|
309
|
+
requirements=entry.requirements(), config=entry.config(),
|
|
310
|
+
)
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
Everything said above for Anthropic holds: `config` carries the model name and
|
|
314
|
+
the name of the secret (`api_key_secret="..."` to use another), both editable on
|
|
315
|
+
the model card; a replica needs no hardware; and `entry.client(deployment.url)`
|
|
316
|
+
is a `ServedCompletingModel`, with Gemini's function calls re-encoded into the
|
|
317
|
+
text form the client parses.
|
|
318
|
+
|
|
319
|
+
`GeminiText2Image` answers the same `/generate` route as `Text2Image`, so its
|
|
320
|
+
client is a `ServedGeneratingModel`:
|
|
321
|
+
|
|
322
|
+
```python
|
|
323
|
+
entry = mg.Hosted("gemini-2.5-flash-image", mg.GeminiText2Image)
|
|
324
|
+
...
|
|
325
|
+
picture = await mg.generate(entry.client(deployment.url), "a fox in the snow", size=2048)
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
It takes a prompt only, and makes a square picture. `size` picks Gemini's size
|
|
329
|
+
tier (up to 1K, 2K or 4K) and `seed` is passed on; `steps` and `guidance` are
|
|
330
|
+
diffusion settings with no Gemini counterpart, and come back as `None`.
|
|
331
|
+
|
|
293
332
|
## API
|
|
294
333
|
|
|
295
334
|
| | |
|
|
@@ -300,6 +339,7 @@ serve app re-encodes them into the text form the client parses.
|
|
|
300
339
|
| `ModelEntry` | Base of both: `model_id`, `family`, `suffix`, `serve_app`, `requirements()`, `config()`, `client(url)`. |
|
|
301
340
|
| `Text2Text`, `Text2Image`, `Image2Mesh` | Serve apps that run weights (`LocalModel`s). |
|
|
302
341
|
| `AnthropicText2Text` | Serve app that forwards to the Anthropic API (a `HostedModel`). |
|
|
342
|
+
| `GeminiText2Text`, `GeminiText2Image` | Serve apps that forward to the Gemini API (`HostedModel`s sharing the `GeminiModel` base). |
|
|
303
343
|
| `LocalModel` | Base of the serve apps that run weights: `ignore_patterns()`, `bytes_per_param`, `min_vram_gb`, `requirements(weights)`, `client(url, name)`. |
|
|
304
344
|
| `HostedModel` | Base of the serve apps that forward: `config(model_id, **settings)`, `requirements()`, `client(url, name)`. |
|
|
305
345
|
| `Weights(params=None, file_bytes=0)` | What an importer reads about the weights, handed to the serve app to size a replica. |
|
|
@@ -36,6 +36,8 @@ A model is two things, and the library keeps them apart:
|
|
|
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
38
|
| `AnthropicText2Text` | text to text | the Anthropic API, forwarded from the cluster | `ServedCompletingModel` (`complete`) |
|
|
39
|
+
| `GeminiText2Text` | text to text | the Gemini API, forwarded from the cluster | `ServedCompletingModel` (`complete`) |
|
|
40
|
+
| `GeminiText2Image` | text to image | the Gemini API, forwarded from the cluster | `ServedGeneratingModel` (`generate`) |
|
|
39
41
|
|
|
40
42
|
| Importer | Source |
|
|
41
43
|
|---|---|
|
|
@@ -263,6 +265,42 @@ included. From there it deploys and streams exactly like a cluster-served model:
|
|
|
263
265
|
reports tool calls as structured blocks rather than as generated text, so the
|
|
264
266
|
serve app re-encodes them into the text form the client parses.
|
|
265
267
|
|
|
268
|
+
### Gemini
|
|
269
|
+
|
|
270
|
+
A Gemini model registers exactly like an Anthropic one, with the serve app for
|
|
271
|
+
its task — `GeminiText2Text` or `GeminiText2Image`. The model id passed to
|
|
272
|
+
`Hosted` is the Gemini model name the deployment calls:
|
|
273
|
+
|
|
274
|
+
```python
|
|
275
|
+
cortexgrid.set_secret("GEMINI_API_KEY", "AIza...") # once
|
|
276
|
+
|
|
277
|
+
entry = mg.Hosted("gemini-2.5-pro", mg.GeminiText2Text) # or "gemini-2.5-flash", ...
|
|
278
|
+
cortexgrid.register_model(
|
|
279
|
+
entry.serve_app,
|
|
280
|
+
family=entry.family, suffix=entry.suffix,
|
|
281
|
+
requirements=entry.requirements(), config=entry.config(),
|
|
282
|
+
)
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
Everything said above for Anthropic holds: `config` carries the model name and
|
|
286
|
+
the name of the secret (`api_key_secret="..."` to use another), both editable on
|
|
287
|
+
the model card; a replica needs no hardware; and `entry.client(deployment.url)`
|
|
288
|
+
is a `ServedCompletingModel`, with Gemini's function calls re-encoded into the
|
|
289
|
+
text form the client parses.
|
|
290
|
+
|
|
291
|
+
`GeminiText2Image` answers the same `/generate` route as `Text2Image`, so its
|
|
292
|
+
client is a `ServedGeneratingModel`:
|
|
293
|
+
|
|
294
|
+
```python
|
|
295
|
+
entry = mg.Hosted("gemini-2.5-flash-image", mg.GeminiText2Image)
|
|
296
|
+
...
|
|
297
|
+
picture = await mg.generate(entry.client(deployment.url), "a fox in the snow", size=2048)
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
It takes a prompt only, and makes a square picture. `size` picks Gemini's size
|
|
301
|
+
tier (up to 1K, 2K or 4K) and `seed` is passed on; `steps` and `guidance` are
|
|
302
|
+
diffusion settings with no Gemini counterpart, and come back as `None`.
|
|
303
|
+
|
|
266
304
|
## API
|
|
267
305
|
|
|
268
306
|
| | |
|
|
@@ -273,6 +311,7 @@ serve app re-encodes them into the text form the client parses.
|
|
|
273
311
|
| `ModelEntry` | Base of both: `model_id`, `family`, `suffix`, `serve_app`, `requirements()`, `config()`, `client(url)`. |
|
|
274
312
|
| `Text2Text`, `Text2Image`, `Image2Mesh` | Serve apps that run weights (`LocalModel`s). |
|
|
275
313
|
| `AnthropicText2Text` | Serve app that forwards to the Anthropic API (a `HostedModel`). |
|
|
314
|
+
| `GeminiText2Text`, `GeminiText2Image` | Serve apps that forward to the Gemini API (`HostedModel`s sharing the `GeminiModel` base). |
|
|
276
315
|
| `LocalModel` | Base of the serve apps that run weights: `ignore_patterns()`, `bytes_per_param`, `min_vram_gb`, `requirements(weights)`, `client(url, name)`. |
|
|
277
316
|
| `HostedModel` | Base of the serve apps that forward: `config(model_id, **settings)`, `requirements()`, `client(url, name)`. |
|
|
278
317
|
| `Weights(params=None, file_bytes=0)` | What an importer reads about the weights, handed to the serve app to size a replica. |
|
|
@@ -50,6 +50,8 @@ from cortexgrid_infer.meshing import ServedMeshingModel
|
|
|
50
50
|
from cortexgrid_infer.device import detect_device
|
|
51
51
|
from cortexgrid_infer.models import (
|
|
52
52
|
AnthropicText2Text,
|
|
53
|
+
GeminiText2Image,
|
|
54
|
+
GeminiText2Text,
|
|
53
55
|
HostedModel,
|
|
54
56
|
Image2Mesh,
|
|
55
57
|
LocalModel,
|
|
@@ -87,6 +89,8 @@ __all__ = [
|
|
|
87
89
|
"Text2Image",
|
|
88
90
|
"Image2Mesh",
|
|
89
91
|
"AnthropicText2Text",
|
|
92
|
+
"GeminiText2Text",
|
|
93
|
+
"GeminiText2Image",
|
|
90
94
|
"ModelEntry",
|
|
91
95
|
"Hosted",
|
|
92
96
|
"split_model_id",
|
|
@@ -3,6 +3,7 @@ that task whichever source it came from."""
|
|
|
3
3
|
|
|
4
4
|
from cortexgrid_infer.models.anthropic import AnthropicText2Text
|
|
5
5
|
from cortexgrid_infer.models.base import HostedModel, LocalModel, Weights
|
|
6
|
+
from cortexgrid_infer.models.gemini import GeminiText2Image, GeminiText2Text
|
|
6
7
|
from cortexgrid_infer.models.image2mesh import Image2Mesh
|
|
7
8
|
from cortexgrid_infer.models.text2image import Text2Image
|
|
8
9
|
from cortexgrid_infer.models.text2text import Text2Text
|
|
@@ -15,4 +16,6 @@ __all__ = [
|
|
|
15
16
|
"Text2Image",
|
|
16
17
|
"Image2Mesh",
|
|
17
18
|
"AnthropicText2Text",
|
|
19
|
+
"GeminiText2Text",
|
|
20
|
+
"GeminiText2Image",
|
|
18
21
|
]
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""The serve apps that forward to the Gemini API, one per task."""
|
|
2
|
+
|
|
3
|
+
from cortexgrid_infer.models.gemini.base import GeminiModel
|
|
4
|
+
from cortexgrid_infer.models.gemini.text2image import GeminiText2Image
|
|
5
|
+
from cortexgrid_infer.models.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.models.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.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.imaging import ServedGeneratingModel
|
|
22
|
+
from cortexgrid_infer.models.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.completion import ServedCompletingModel, encode_tool_call
|
|
19
|
+
from cortexgrid_infer.core import Message, ToolSpec
|
|
20
|
+
from cortexgrid_infer.models.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")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "cortexgrid-infer"
|
|
3
|
-
version = "0.3.
|
|
3
|
+
version = "0.3.2"
|
|
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,6 +22,9 @@ dependencies = [
|
|
|
22
22
|
"transformers>=4.47.0",
|
|
23
23
|
"huggingface_hub>=0.20.0",
|
|
24
24
|
"anthropic>=0.40.0",
|
|
25
|
+
# models.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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|