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.
Files changed (28) hide show
  1. {cortexgrid_infer-0.3.1 → cortexgrid_infer-0.3.2}/PKG-INFO +41 -1
  2. {cortexgrid_infer-0.3.1 → cortexgrid_infer-0.3.2}/README.md +39 -0
  3. {cortexgrid_infer-0.3.1 → cortexgrid_infer-0.3.2}/cortexgrid_infer/__init__.py +4 -0
  4. {cortexgrid_infer-0.3.1 → cortexgrid_infer-0.3.2}/cortexgrid_infer/models/__init__.py +3 -0
  5. cortexgrid_infer-0.3.2/cortexgrid_infer/models/gemini/__init__.py +11 -0
  6. cortexgrid_infer-0.3.2/cortexgrid_infer/models/gemini/base.py +50 -0
  7. cortexgrid_infer-0.3.2/cortexgrid_infer/models/gemini/text2image.py +116 -0
  8. cortexgrid_infer-0.3.2/cortexgrid_infer/models/gemini/text2text.py +146 -0
  9. {cortexgrid_infer-0.3.1 → cortexgrid_infer-0.3.2}/pyproject.toml +4 -1
  10. {cortexgrid_infer-0.3.1 → cortexgrid_infer-0.3.2}/.gitignore +0 -0
  11. {cortexgrid_infer-0.3.1 → cortexgrid_infer-0.3.2}/LICENSE +0 -0
  12. {cortexgrid_infer-0.3.1 → cortexgrid_infer-0.3.2}/cortexgrid_infer/compiling.py +0 -0
  13. {cortexgrid_infer-0.3.1 → cortexgrid_infer-0.3.2}/cortexgrid_infer/completion.py +0 -0
  14. {cortexgrid_infer-0.3.1 → cortexgrid_infer-0.3.2}/cortexgrid_infer/core.py +0 -0
  15. {cortexgrid_infer-0.3.1 → cortexgrid_infer-0.3.2}/cortexgrid_infer/device.py +0 -0
  16. {cortexgrid_infer-0.3.1 → cortexgrid_infer-0.3.2}/cortexgrid_infer/imaging.py +0 -0
  17. {cortexgrid_infer-0.3.1 → cortexgrid_infer-0.3.2}/cortexgrid_infer/importers/__init__.py +0 -0
  18. {cortexgrid_infer-0.3.1 → cortexgrid_infer-0.3.2}/cortexgrid_infer/importers/base.py +0 -0
  19. {cortexgrid_infer-0.3.1 → cortexgrid_infer-0.3.2}/cortexgrid_infer/importers/huggingface.py +0 -0
  20. {cortexgrid_infer-0.3.1 → cortexgrid_infer-0.3.2}/cortexgrid_infer/meshing.py +0 -0
  21. {cortexgrid_infer-0.3.1 → cortexgrid_infer-0.3.2}/cortexgrid_infer/models/anthropic.py +0 -0
  22. {cortexgrid_infer-0.3.1 → cortexgrid_infer-0.3.2}/cortexgrid_infer/models/base.py +0 -0
  23. {cortexgrid_infer-0.3.1 → cortexgrid_infer-0.3.2}/cortexgrid_infer/models/image2mesh.py +0 -0
  24. {cortexgrid_infer-0.3.1 → cortexgrid_infer-0.3.2}/cortexgrid_infer/models/text2image.py +0 -0
  25. {cortexgrid_infer-0.3.1 → cortexgrid_infer-0.3.2}/cortexgrid_infer/models/text2text.py +0 -0
  26. {cortexgrid_infer-0.3.1 → cortexgrid_infer-0.3.2}/cortexgrid_infer/py.typed +0 -0
  27. {cortexgrid_infer-0.3.1 → cortexgrid_infer-0.3.2}/cortexgrid_infer/registry.py +0 -0
  28. {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.1
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.1"
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