cortexgrid-infer 0.3.3__tar.gz → 0.3.5__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.3 → cortexgrid_infer-0.3.5}/PKG-INFO +43 -13
  2. {cortexgrid_infer-0.3.3 → cortexgrid_infer-0.3.5}/README.md +42 -12
  3. {cortexgrid_infer-0.3.3 → cortexgrid_infer-0.3.5}/cortexgrid_infer/core.py +1 -0
  4. {cortexgrid_infer-0.3.3 → cortexgrid_infer-0.3.5}/cortexgrid_infer/importers/base.py +3 -0
  5. {cortexgrid_infer-0.3.3 → cortexgrid_infer-0.3.5}/cortexgrid_infer/protocols/completion.py +50 -20
  6. {cortexgrid_infer-0.3.3 → cortexgrid_infer-0.3.5}/cortexgrid_infer/serve_apps/base.py +12 -0
  7. {cortexgrid_infer-0.3.3 → cortexgrid_infer-0.3.5}/cortexgrid_infer/serve_apps/gemini/base.py +1 -0
  8. {cortexgrid_infer-0.3.3 → cortexgrid_infer-0.3.5}/cortexgrid_infer/serve_apps/gemini/text2text.py +28 -3
  9. {cortexgrid_infer-0.3.3 → cortexgrid_infer-0.3.5}/cortexgrid_infer/serve_apps/image2mesh.py +18 -1
  10. {cortexgrid_infer-0.3.3 → cortexgrid_infer-0.3.5}/cortexgrid_infer/serve_apps/text2image.py +7 -3
  11. {cortexgrid_infer-0.3.3 → cortexgrid_infer-0.3.5}/cortexgrid_infer/serve_apps/text2text.py +13 -2
  12. cortexgrid_infer-0.3.5/cortexgrid_infer/serve_apps/text_rewriter.py +107 -0
  13. {cortexgrid_infer-0.3.3 → cortexgrid_infer-0.3.5}/pyproject.toml +1 -1
  14. cortexgrid_infer-0.3.3/cortexgrid_infer/serve_apps/text_rewriter.py +0 -54
  15. {cortexgrid_infer-0.3.3 → cortexgrid_infer-0.3.5}/.gitignore +0 -0
  16. {cortexgrid_infer-0.3.3 → cortexgrid_infer-0.3.5}/LICENSE +0 -0
  17. {cortexgrid_infer-0.3.3 → cortexgrid_infer-0.3.5}/cortexgrid_infer/__init__.py +0 -0
  18. {cortexgrid_infer-0.3.3 → cortexgrid_infer-0.3.5}/cortexgrid_infer/compiling.py +0 -0
  19. {cortexgrid_infer-0.3.3 → cortexgrid_infer-0.3.5}/cortexgrid_infer/device.py +0 -0
  20. {cortexgrid_infer-0.3.3 → cortexgrid_infer-0.3.5}/cortexgrid_infer/importers/__init__.py +0 -0
  21. {cortexgrid_infer-0.3.3 → cortexgrid_infer-0.3.5}/cortexgrid_infer/importers/huggingface.py +0 -0
  22. {cortexgrid_infer-0.3.3 → cortexgrid_infer-0.3.5}/cortexgrid_infer/protocols/__init__.py +0 -0
  23. {cortexgrid_infer-0.3.3 → cortexgrid_infer-0.3.5}/cortexgrid_infer/protocols/imaging.py +0 -0
  24. {cortexgrid_infer-0.3.3 → cortexgrid_infer-0.3.5}/cortexgrid_infer/protocols/meshing.py +0 -0
  25. {cortexgrid_infer-0.3.3 → cortexgrid_infer-0.3.5}/cortexgrid_infer/protocols/rewriting.py +0 -0
  26. {cortexgrid_infer-0.3.3 → cortexgrid_infer-0.3.5}/cortexgrid_infer/py.typed +0 -0
  27. {cortexgrid_infer-0.3.3 → cortexgrid_infer-0.3.5}/cortexgrid_infer/registry.py +0 -0
  28. {cortexgrid_infer-0.3.3 → cortexgrid_infer-0.3.5}/cortexgrid_infer/serve_apps/__init__.py +0 -0
  29. {cortexgrid_infer-0.3.3 → cortexgrid_infer-0.3.5}/cortexgrid_infer/serve_apps/anthropic.py +0 -0
  30. {cortexgrid_infer-0.3.3 → cortexgrid_infer-0.3.5}/cortexgrid_infer/serve_apps/gemini/__init__.py +0 -0
  31. {cortexgrid_infer-0.3.3 → cortexgrid_infer-0.3.5}/cortexgrid_infer/serve_apps/gemini/text2image.py +0 -0
  32. {cortexgrid_infer-0.3.3 → cortexgrid_infer-0.3.5}/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
3
+ Version: 0.3.5
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
@@ -178,7 +178,23 @@ beyond the weights. (A `Hosted` entry asks for none — it holds no weights.)
178
178
 
179
179
  ## Compilation
180
180
 
181
- `Text2Text` and `Text2Image` run `torch.compile` over the model they load, because
181
+ Every serve app that runs weights can run them through `torch.compile`, and none
182
+ does unless asked: a replica is eager unless its model card carries
183
+ `compile: "true"`. Each `LocalModel` writes `compile: "false"` onto the card at
184
+ import, so turning it on is an edit of the card and a redeploy:
185
+
186
+ ```python
187
+ config = cortexgrid.model_config(family, suffix, cortexgrid.IMPORTED)
188
+ cortexgrid.set_model_config(family, suffix, cortexgrid.IMPORTED, {**config, "compile": "true"})
189
+ cortexgrid.deploy_model(family, suffix, cortexgrid.IMPORTED, wait=True)
190
+ ```
191
+
192
+ It is off by default because it is not free: every input shape pays a warm-up on
193
+ the request that first brings it, and each CUDA graph holds memory of its own. It
194
+ pays back where traffic keeps to a few shapes and the model is small enough for
195
+ launch overhead to matter. A card asking for it off CUDA is served eager.
196
+
197
+ Compiled, a serve app runs `torch.compile` over the model it loads, because
182
198
  eager PyTorch bills per *operation* and a transformer forward is mostly glue —
183
199
  views, broadcasts, permutes, elementwise adds — with only a small fraction of the
184
200
  dispatches being the matrix multiplies that do the arithmetic. While the tensors
@@ -197,7 +213,7 @@ fits the work it does:
197
213
  | `Mode.GRAPHED` (`reduce-overhead`) | fuses **and** captures a CUDA graph, so a forward replays one graph instead of issuing its launches | a loop of identically shaped forwards, where the capture is reused every iteration. A graph cannot go dynamic, so each distinct shape captures separately |
198
214
  | `Mode.FUSED` (`default`) | fuses only | work whose shapes move under it, where there is nothing stable to capture and dynamo is free to compile one dynamic kernel set for all of them |
199
215
 
200
- Both serve apps are tuned rather than defaulted:
216
+ Each serve app is tuned rather than defaulted:
201
217
 
202
218
  - **`Text2Image`** compiles the denoiser (`transformer` or `unet`)
203
219
  and every `text_encoder*` as `Mode.GRAPHED`. A denoising schedule is the ideal case —
@@ -224,12 +240,25 @@ Both serve apps are tuned rather than defaulted:
224
240
  chat template puts the turn to answer last — with a warning naming both
225
241
  lengths. Serving it whole would resize the cache and recompile the decode loop,
226
242
  which costs more than the generation itself.
227
-
228
- The image app asks to be configured for nothing, for the same reason neither asks
229
- which dtype to load in; the completion app takes `max_total_tokens` alone, because
230
- how much cache a caller needs is the one thing the model cannot tell it. Both
231
- happen on CUDA and nowhere else — inductor is weakest off it, and a host
232
- outrunning its accelerator is a GPU problem to begin with.
243
+ - **`TextRewriter`** decodes as `Text2Text` does — a static cache, `Mode.GRAPHED`,
244
+ transformers compiling the step — with the cache sized to the 512 new tokens a
245
+ reply may take, and a larger `max_new_tokens` capped to it. An encoder-decoder
246
+ has a second cache, though: `generate` sizes cross-attention to the encoded
247
+ text, so each distinct text length would capture the decode loop afresh. The
248
+ text is therefore padded up to the next of 32, 64, 128, 256 or 512 tokens —
249
+ one capture per bucket, the padding masked out — and a longer one is cut to
250
+ 512, keeping its start, with a warning naming both lengths.
251
+ - **`Image2Mesh`** cannot know what to compile: each subclass brings its own
252
+ model. It calls the subclass's `compile(device)` after `load`, which does
253
+ nothing unless overridden; a subclass compiles its own modules there with
254
+ `compiling.compile_module`, in the mode that fits them.
255
+
256
+ Beyond the switch, the image app asks to be configured for nothing, for the same
257
+ reason none asks which dtype to load in; the completion app takes
258
+ `max_total_tokens` alone, because how much cache a caller needs is the one thing
259
+ the model cannot tell it. All of it happens on CUDA and nowhere else — inductor
260
+ is weakest off it, and a host outrunning its accelerator is a GPU problem to
261
+ begin with.
233
262
 
234
263
  Two things worth knowing:
235
264
 
@@ -245,7 +274,7 @@ it is the floor; only quantizing the weights or running fewer forwards moves it.
245
274
 
246
275
  ## Inference
247
276
 
248
- `complete` streams `CompletionChunk`s (`.content`, `.tool_calls`, `.finish_reason`):
277
+ `complete` streams `CompletionChunk`s (`.content`, `.thinking`, `.tool_calls`, `.finish_reason`):
249
278
 
250
279
  ```python
251
280
  imp = mg.HuggingFaceImporter("Qwen/Qwen2.5-7B-Instruct", mg.Text2Text)
@@ -338,10 +367,10 @@ diffusion settings with no Gemini counterpart, and come back as `None`.
338
367
  | `Importer` | Base of the importers: identity, scratch directory, `source`, and `requirements()` / `client(url)` taken from the serve app. A new source implements `download(local_dir)` and `weights()`. |
339
368
  | `Hosted(model_id, serve_app, **settings)` | Entry for a model hosted elsewhere; no weights. `settings` are the serve app's `config` arguments. |
340
369
  | `ModelEntry` | Base of both: `model_id`, `family`, `suffix`, `serve_app`, `requirements()`, `config()`, `client(url)`. |
341
- | `Text2Text`, `Text2Image`, `Image2Mesh` | Serve apps that run weights (`LocalModel`s). |
370
+ | `Text2Text`, `Text2Image`, `TextRewriter`, `Image2Mesh` | Serve apps that run weights (`LocalModel`s). |
342
371
  | `AnthropicText2Text` | Serve app that forwards to the Anthropic API (a `HostedModel`). |
343
372
  | `GeminiText2Text`, `GeminiText2Image` | Serve apps that forward to the Gemini API (`HostedModel`s sharing the `GeminiModel` base). |
344
- | `LocalModel` | Base of the serve apps that run weights: `ignore_patterns()`, `bytes_per_param`, `min_vram_gb`, `requirements(weights)`, `client(url, name)`. |
373
+ | `LocalModel` | Base of the serve apps that run weights: `ignore_patterns()`, `bytes_per_param`, `min_vram_gb`, `requirements(weights)`, `config()` (the model card's defaults, `compile: "false"` among them), `client(url, name)`. |
345
374
  | `HostedModel` | Base of the serve apps that forward: `config(model_id, **settings)`, `requirements()`, `client(url, name)`. |
346
375
  | `Weights(params=None, file_bytes=0)` | What an importer reads about the weights, handed to the serve app to size a replica. |
347
376
  | `complete(model, messages, tools=None, max_new_tokens=2048, temperature=0.7, **kw)` | Async stream of `CompletionChunk` for a `CompletingModel`. |
@@ -370,7 +399,8 @@ Extending it:
370
399
  (there is an `encode_tool_call` for upstreams that report them structurally) —
371
400
  then `ServedCompletingModel` is its client, unchanged.
372
401
  - **A model no task's serve app can load** gets its own serve app — an `Image2Mesh`
373
- subclass, say — and, if its source is unusual too, its own importer.
402
+ subclass, say, with `load` and `make_mesh`, and `compile` if compiling it pays —
403
+ and, if its source is unusual too, its own importer.
374
404
 
375
405
  ## Notes
376
406
 
@@ -150,7 +150,23 @@ beyond the weights. (A `Hosted` entry asks for none — it holds no weights.)
150
150
 
151
151
  ## Compilation
152
152
 
153
- `Text2Text` and `Text2Image` run `torch.compile` over the model they load, because
153
+ Every serve app that runs weights can run them through `torch.compile`, and none
154
+ does unless asked: a replica is eager unless its model card carries
155
+ `compile: "true"`. Each `LocalModel` writes `compile: "false"` onto the card at
156
+ import, so turning it on is an edit of the card and a redeploy:
157
+
158
+ ```python
159
+ config = cortexgrid.model_config(family, suffix, cortexgrid.IMPORTED)
160
+ cortexgrid.set_model_config(family, suffix, cortexgrid.IMPORTED, {**config, "compile": "true"})
161
+ cortexgrid.deploy_model(family, suffix, cortexgrid.IMPORTED, wait=True)
162
+ ```
163
+
164
+ It is off by default because it is not free: every input shape pays a warm-up on
165
+ the request that first brings it, and each CUDA graph holds memory of its own. It
166
+ pays back where traffic keeps to a few shapes and the model is small enough for
167
+ launch overhead to matter. A card asking for it off CUDA is served eager.
168
+
169
+ Compiled, a serve app runs `torch.compile` over the model it loads, because
154
170
  eager PyTorch bills per *operation* and a transformer forward is mostly glue —
155
171
  views, broadcasts, permutes, elementwise adds — with only a small fraction of the
156
172
  dispatches being the matrix multiplies that do the arithmetic. While the tensors
@@ -169,7 +185,7 @@ fits the work it does:
169
185
  | `Mode.GRAPHED` (`reduce-overhead`) | fuses **and** captures a CUDA graph, so a forward replays one graph instead of issuing its launches | a loop of identically shaped forwards, where the capture is reused every iteration. A graph cannot go dynamic, so each distinct shape captures separately |
170
186
  | `Mode.FUSED` (`default`) | fuses only | work whose shapes move under it, where there is nothing stable to capture and dynamo is free to compile one dynamic kernel set for all of them |
171
187
 
172
- Both serve apps are tuned rather than defaulted:
188
+ Each serve app is tuned rather than defaulted:
173
189
 
174
190
  - **`Text2Image`** compiles the denoiser (`transformer` or `unet`)
175
191
  and every `text_encoder*` as `Mode.GRAPHED`. A denoising schedule is the ideal case —
@@ -196,12 +212,25 @@ Both serve apps are tuned rather than defaulted:
196
212
  chat template puts the turn to answer last — with a warning naming both
197
213
  lengths. Serving it whole would resize the cache and recompile the decode loop,
198
214
  which costs more than the generation itself.
199
-
200
- The image app asks to be configured for nothing, for the same reason neither asks
201
- which dtype to load in; the completion app takes `max_total_tokens` alone, because
202
- how much cache a caller needs is the one thing the model cannot tell it. Both
203
- happen on CUDA and nowhere else — inductor is weakest off it, and a host
204
- outrunning its accelerator is a GPU problem to begin with.
215
+ - **`TextRewriter`** decodes as `Text2Text` does — a static cache, `Mode.GRAPHED`,
216
+ transformers compiling the step — with the cache sized to the 512 new tokens a
217
+ reply may take, and a larger `max_new_tokens` capped to it. An encoder-decoder
218
+ has a second cache, though: `generate` sizes cross-attention to the encoded
219
+ text, so each distinct text length would capture the decode loop afresh. The
220
+ text is therefore padded up to the next of 32, 64, 128, 256 or 512 tokens —
221
+ one capture per bucket, the padding masked out — and a longer one is cut to
222
+ 512, keeping its start, with a warning naming both lengths.
223
+ - **`Image2Mesh`** cannot know what to compile: each subclass brings its own
224
+ model. It calls the subclass's `compile(device)` after `load`, which does
225
+ nothing unless overridden; a subclass compiles its own modules there with
226
+ `compiling.compile_module`, in the mode that fits them.
227
+
228
+ Beyond the switch, the image app asks to be configured for nothing, for the same
229
+ reason none asks which dtype to load in; the completion app takes
230
+ `max_total_tokens` alone, because how much cache a caller needs is the one thing
231
+ the model cannot tell it. All of it happens on CUDA and nowhere else — inductor
232
+ is weakest off it, and a host outrunning its accelerator is a GPU problem to
233
+ begin with.
205
234
 
206
235
  Two things worth knowing:
207
236
 
@@ -217,7 +246,7 @@ it is the floor; only quantizing the weights or running fewer forwards moves it.
217
246
 
218
247
  ## Inference
219
248
 
220
- `complete` streams `CompletionChunk`s (`.content`, `.tool_calls`, `.finish_reason`):
249
+ `complete` streams `CompletionChunk`s (`.content`, `.thinking`, `.tool_calls`, `.finish_reason`):
221
250
 
222
251
  ```python
223
252
  imp = mg.HuggingFaceImporter("Qwen/Qwen2.5-7B-Instruct", mg.Text2Text)
@@ -310,10 +339,10 @@ diffusion settings with no Gemini counterpart, and come back as `None`.
310
339
  | `Importer` | Base of the importers: identity, scratch directory, `source`, and `requirements()` / `client(url)` taken from the serve app. A new source implements `download(local_dir)` and `weights()`. |
311
340
  | `Hosted(model_id, serve_app, **settings)` | Entry for a model hosted elsewhere; no weights. `settings` are the serve app's `config` arguments. |
312
341
  | `ModelEntry` | Base of both: `model_id`, `family`, `suffix`, `serve_app`, `requirements()`, `config()`, `client(url)`. |
313
- | `Text2Text`, `Text2Image`, `Image2Mesh` | Serve apps that run weights (`LocalModel`s). |
342
+ | `Text2Text`, `Text2Image`, `TextRewriter`, `Image2Mesh` | Serve apps that run weights (`LocalModel`s). |
314
343
  | `AnthropicText2Text` | Serve app that forwards to the Anthropic API (a `HostedModel`). |
315
344
  | `GeminiText2Text`, `GeminiText2Image` | Serve apps that forward to the Gemini API (`HostedModel`s sharing the `GeminiModel` base). |
316
- | `LocalModel` | Base of the serve apps that run weights: `ignore_patterns()`, `bytes_per_param`, `min_vram_gb`, `requirements(weights)`, `client(url, name)`. |
345
+ | `LocalModel` | Base of the serve apps that run weights: `ignore_patterns()`, `bytes_per_param`, `min_vram_gb`, `requirements(weights)`, `config()` (the model card's defaults, `compile: "false"` among them), `client(url, name)`. |
317
346
  | `HostedModel` | Base of the serve apps that forward: `config(model_id, **settings)`, `requirements()`, `client(url, name)`. |
318
347
  | `Weights(params=None, file_bytes=0)` | What an importer reads about the weights, handed to the serve app to size a replica. |
319
348
  | `complete(model, messages, tools=None, max_new_tokens=2048, temperature=0.7, **kw)` | Async stream of `CompletionChunk` for a `CompletingModel`. |
@@ -342,7 +371,8 @@ Extending it:
342
371
  (there is an `encode_tool_call` for upstreams that report them structurally) —
343
372
  then `ServedCompletingModel` is its client, unchanged.
344
373
  - **A model no task's serve app can load** gets its own serve app — an `Image2Mesh`
345
- subclass, say — and, if its source is unusual too, its own importer.
374
+ subclass, say, with `load` and `make_mesh`, and `compile` if compiling it pays —
375
+ and, if its source is unusual too, its own importer.
346
376
 
347
377
  ## Notes
348
378
 
@@ -25,6 +25,7 @@ class ToolCall:
25
25
  @dataclass
26
26
  class CompletionChunk:
27
27
  content: str = ""
28
+ thinking: str = ""
28
29
  tool_calls: list[ToolCall] = field(default_factory=list)
29
30
  finish_reason: str | None = None
30
31
 
@@ -95,5 +95,8 @@ class Importer(ModelEntry):
95
95
  have, so a hand-set value is never overwritten."""
96
96
  return self.serve_app.requirements(self.weights())
97
97
 
98
+ def config(self) -> dict[str, str]:
99
+ return self.serve_app.config()
100
+
98
101
  def client(self, url: str) -> DeployedModel:
99
102
  return self.serve_app.client(url, self.model_id)
@@ -35,6 +35,9 @@ _tool_call_id_counter = itertools.count()
35
35
 
36
36
  TOOL_CALL_OPENERS = ["<tool_call>", "<|tool_call|>", "```tool_call"]
37
37
 
38
+ THINKING_OPENER = "<think>"
39
+ THINKING_CLOSER = "</think>"
40
+
38
41
  TOOL_CALL_PATTERNS = [
39
42
  re.compile(r"<tool_call>\s*(\{.*?\})\s*</tool_call>", re.DOTALL),
40
43
  re.compile(r"<\|tool_call\|>\s*(\{.*?\})\s*<\|/tool_call\|>", re.DOTALL),
@@ -51,23 +54,27 @@ def encode_tool_call(name: str, arguments: dict[str, Any]) -> str:
51
54
  return f'<tool_call>{json.dumps({"name": name, "arguments": arguments})}</tool_call>'
52
55
 
53
56
 
54
- def _find_opener(text: str) -> int | None:
55
- """Find the earliest tool call opener position in text, or None."""
57
+ def encode_thinking(text: str) -> str:
58
+ return f"{THINKING_OPENER}{text}{THINKING_CLOSER}"
59
+
60
+
61
+ def _find_opener(text: str, openers: Sequence[str]) -> int | None:
62
+ """Find the earliest position of any of `openers` in text, or None."""
56
63
  earliest = None
57
- for opener in TOOL_CALL_OPENERS:
64
+ for opener in openers:
58
65
  idx = text.find(opener)
59
66
  if idx != -1 and (earliest is None or idx < earliest):
60
67
  earliest = idx
61
68
  return earliest
62
69
 
63
70
 
64
- def _split_at_potential_prefix(text: str) -> tuple[str, str]:
71
+ def _split_at_potential_prefix(text: str, openers: Sequence[str]) -> tuple[str, str]:
65
72
  """Split text into (safe_to_stream, potential_opener_prefix).
66
73
 
67
- The second part is a suffix that could be the beginning of a tool call
68
- opening tag, so it must be held back until more text arrives.
74
+ The second part is a suffix that could be the beginning of one of
75
+ `openers`, so it must be held back until more text arrives.
69
76
  """
70
- for opener in TOOL_CALL_OPENERS:
77
+ for opener in openers:
71
78
  for length in range(1, len(opener)):
72
79
  if text.endswith(opener[:length]):
73
80
  return text[:-length], text[-length:]
@@ -144,8 +151,10 @@ class ServedCompletingModel(CompletingModel):
144
151
  }
145
152
 
146
153
  pending = ""
154
+ in_thinking = False
147
155
  in_tool_call = False
148
156
  tool_call_text = ""
157
+ openers = [THINKING_OPENER, *TOOL_CALL_OPENERS]
149
158
 
150
159
  async with httpx.AsyncClient(timeout=None) as client:
151
160
  async with client.stream(
@@ -162,22 +171,43 @@ class ServedCompletingModel(CompletingModel):
162
171
 
163
172
  pending += text
164
173
 
165
- opener_pos = _find_opener(pending)
166
- if opener_pos is not None:
174
+ while pending and not in_tool_call:
175
+ if in_thinking:
176
+ closer_pos = pending.find(THINKING_CLOSER)
177
+ if closer_pos == -1:
178
+ thought, pending = _split_at_potential_prefix(
179
+ pending, [THINKING_CLOSER]
180
+ )
181
+ if thought:
182
+ yield CompletionChunk(thinking=thought)
183
+ break
184
+ if closer_pos:
185
+ yield CompletionChunk(thinking=pending[:closer_pos])
186
+ pending = pending[closer_pos + len(THINKING_CLOSER) :]
187
+ in_thinking = False
188
+ continue
189
+
190
+ opener_pos = _find_opener(pending, openers)
191
+ if opener_pos is None:
192
+ safe, pending = _split_at_potential_prefix(pending, openers)
193
+ if safe:
194
+ yield CompletionChunk(content=safe)
195
+ break
196
+
167
197
  before = pending[:opener_pos]
168
198
  if before:
169
199
  yield CompletionChunk(content=before)
170
- tool_call_text = pending[opener_pos:]
171
- in_tool_call = True
172
- pending = ""
173
- continue
174
-
175
- safe, held = _split_at_potential_prefix(pending)
176
- if safe:
177
- yield CompletionChunk(content=safe)
178
- pending = held
179
-
180
- if pending:
200
+ if pending.startswith(THINKING_OPENER, opener_pos):
201
+ pending = pending[opener_pos + len(THINKING_OPENER) :]
202
+ in_thinking = True
203
+ else:
204
+ tool_call_text = pending[opener_pos:]
205
+ in_tool_call = True
206
+ pending = ""
207
+
208
+ if pending and in_thinking:
209
+ yield CompletionChunk(thinking=pending)
210
+ elif pending:
181
211
  yield CompletionChunk(content=pending)
182
212
 
183
213
  if tool_call_text:
@@ -28,6 +28,14 @@ from cortexgrid_infer.core import DeployedModel
28
28
 
29
29
  _GIB = float(1 << 30)
30
30
 
31
+ ENABLE_THINKING_PARAM = "enable_thinking"
32
+
33
+ # Whether a replica runs its model through `torch.compile` (see
34
+ # `cortexgrid_infer.compiling`). Off unless the model card says "true": compiled
35
+ # serving pays a warm-up per input shape, which is worth it only where traffic
36
+ # repeats those shapes.
37
+ COMPILE_PARAM = "compile"
38
+
31
39
  # Weights are only part of what a replica holds. The multiplier covers CUDA
32
40
  # context, activations and fragmentation; the floor covers the KV cache of a
33
41
  # long context, which does not scale with model size the way activations do.
@@ -94,6 +102,10 @@ class LocalModel:
94
102
  vram_gb=max(round(size * _VRAM_HEADROOM + _VRAM_FLOOR_GB, 1), cls.min_vram_gb),
95
103
  )
96
104
 
105
+ @classmethod
106
+ def config(cls) -> dict[str, str]:
107
+ return {COMPILE_PARAM: "false"}
108
+
97
109
  @classmethod
98
110
  def client(cls, url: str, name: str) -> DeployedModel:
99
111
  """A client for this app deployed at `url`, serving the model `name`."""
@@ -44,6 +44,7 @@ class GeminiModel(HostedModel):
44
44
  f"{family}/{suffix}/{run_name} is missing {sorted(missing)} from "
45
45
  "its config; set them on the model card"
46
46
  )
47
+ self._config = config
47
48
  self._model = config[MODEL_PARAM]
48
49
  self._client = genai.Client(
49
50
  api_key=cortexgrid.get_secret(config[API_KEY_SECRET_PARAM])
@@ -16,8 +16,13 @@ from fastapi import FastAPI
16
16
  from fastapi.responses import StreamingResponse
17
17
 
18
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
19
+ from cortexgrid_infer.protocols.completion import (
20
+ ServedCompletingModel,
21
+ encode_thinking,
22
+ encode_tool_call,
23
+ )
24
+ from cortexgrid_infer.serve_apps.base import ENABLE_THINKING_PARAM
25
+ from cortexgrid_infer.serve_apps.gemini.base import DEFAULT_API_KEY_SECRET, GeminiModel
21
26
 
22
27
  _app = FastAPI()
23
28
 
@@ -109,6 +114,18 @@ def to_gemini_tools(
109
114
 
110
115
  @serve.ingress(_app)
111
116
  class GeminiText2Text(GeminiModel):
117
+ @classmethod
118
+ def config(
119
+ cls,
120
+ model_id: str,
121
+ api_key_secret: str = DEFAULT_API_KEY_SECRET,
122
+ enable_thinking: str = "true",
123
+ ) -> dict[str, str]:
124
+ return {
125
+ **super().config(model_id, api_key_secret),
126
+ ENABLE_THINKING_PARAM: enable_thinking,
127
+ }
128
+
112
129
  @classmethod
113
130
  def client(cls, url: str, name: str) -> ServedCompletingModel:
114
131
  return ServedCompletingModel(url=url, model_id=name)
@@ -121,6 +138,11 @@ class GeminiText2Text(GeminiModel):
121
138
  generate_config: dict[str, Any] = {
122
139
  "max_output_tokens": body.get("max_new_tokens", 16 * 1024),
123
140
  "temperature": body.get("temperature", 0.7),
141
+ "thinking_config": (
142
+ {"include_thoughts": True}
143
+ if self._config.get(ENABLE_THINKING_PARAM, "true") == "true"
144
+ else {"thinking_budget": 0}
145
+ ),
124
146
  }
125
147
  if system_prompt:
126
148
  generate_config["system_instruction"] = system_prompt
@@ -140,7 +162,10 @@ class GeminiText2Text(GeminiModel):
140
162
  part.function_call.name, dict(part.function_call.args or {})
141
163
  ).encode("utf-8")
142
164
 
143
- elif part.text and not part.thought:
165
+ elif part.text and part.thought:
166
+ yield encode_thinking(part.text).encode("utf-8")
167
+
168
+ elif part.text:
144
169
  yield part.text.encode("utf-8")
145
170
 
146
171
  return StreamingResponse(stream(), media_type="text/plain")
@@ -13,6 +13,7 @@ cortexgrid bundles the subclass's own file, so the model's code travels with it.
13
13
  min_vram_gb = 6.0
14
14
  def load(self, path, device): ...
15
15
  def make_mesh(self, image, **options) -> GeneratedMesh: ...
16
+ def compile(self, device): ... # optional; see `Image2Mesh.compile`
16
17
 
17
18
  imp = HuggingFaceImporter("org/my-mesh-model", MyMesh)
18
19
  """
@@ -31,11 +32,12 @@ from fastapi import FastAPI
31
32
  from PIL import Image, ImageOps
32
33
  import torch
33
34
 
35
+ from cortexgrid_infer import compiling
34
36
  from cortexgrid_infer.core import GeneratedMesh
35
37
  from cortexgrid_infer.device import detect_device
36
38
  from cortexgrid_infer.protocols import meshing
37
39
  from cortexgrid_infer.protocols.meshing import ServedMeshingModel
38
- from cortexgrid_infer.serve_apps.base import LocalModel
40
+ from cortexgrid_infer.serve_apps.base import COMPILE_PARAM, LocalModel
39
41
 
40
42
 
41
43
  _app = FastAPI()
@@ -55,11 +57,26 @@ class Image2Mesh(LocalModel):
55
57
  def __init__(self, family: str, suffix: str, run_name: str) -> None:
56
58
  self.device = detect_device()
57
59
  self.load(Path(cortexgrid.load_model(family, suffix, run_name)), self.device)
60
+ settings = cortexgrid.model_config(family, suffix, run_name)
61
+ requested = settings.get(COMPILE_PARAM, "false") == "true"
62
+ self.compiled = requested and compiling.supported(self.device)
63
+ if self.compiled:
64
+ self.compile(self.device)
58
65
 
59
66
  def load(self, path: Path, device: torch.device) -> None:
60
67
  """Build the model from its files at `path`, on `device`."""
61
68
  raise NotImplementedError
62
69
 
70
+ def compile(self, device: torch.device) -> None:
71
+ """Compile what `load` built, on `device`.
72
+
73
+ Called after `load`, and only when the model card asks for it on a
74
+ device worth compiling on. Does nothing unless overridden: which of a
75
+ model's modules pay back compiling, and in which `compiling.Mode`, is
76
+ the model's to know - a denoising loop of fixed shapes suits
77
+ `Mode.GRAPHED`, a decoder queried over a grid whose size moves suits
78
+ `Mode.FUSED`. `compiling.compile_module` does the rest."""
79
+
63
80
  def make_mesh(self, image: Image.Image, **options: Any) -> GeneratedMesh:
64
81
  """The mesh of the object in `image`, in the frame `GeneratedMesh`
65
82
  documents, with the options the request carried; `params` says what the
@@ -29,7 +29,7 @@ import torch
29
29
  from cortexgrid_infer import compiling
30
30
  from cortexgrid_infer.device import detect_device
31
31
  from cortexgrid_infer.protocols.imaging import ServedGeneratingModel
32
- from cortexgrid_infer.serve_apps.base import LocalModel
32
+ from cortexgrid_infer.serve_apps.base import COMPILE_PARAM, LocalModel
33
33
 
34
34
 
35
35
  _app = FastAPI()
@@ -95,8 +95,12 @@ class Text2Image(LocalModel):
95
95
  vae.enable_tiling()
96
96
  self._pipe = pipe
97
97
  # A denoising schedule is a loop of identically shaped forwards: capture once, replay.
98
- self._compiled = compiling.compile_pipeline(
99
- pipe, self._device, compiling.Mode.GRAPHED
98
+ settings = cortexgrid.model_config(family, suffix, run_name)
99
+ requested = settings.get(COMPILE_PARAM, "false") == "true"
100
+ self._compiled = (
101
+ compiling.compile_pipeline(pipe, self._device, compiling.Mode.GRAPHED)
102
+ if requested
103
+ else []
100
104
  )
101
105
 
102
106
  @_app.post("/generate")
@@ -30,7 +30,11 @@ import torch
30
30
  from cortexgrid_infer import compiling
31
31
  from cortexgrid_infer.device import detect_device
32
32
  from cortexgrid_infer.protocols.completion import ServedCompletingModel
33
- from cortexgrid_infer.serve_apps.base import LocalModel
33
+ from cortexgrid_infer.serve_apps.base import (
34
+ COMPILE_PARAM,
35
+ ENABLE_THINKING_PARAM,
36
+ LocalModel,
37
+ )
34
38
 
35
39
 
36
40
  log = logging.getLogger(__name__)
@@ -60,6 +64,10 @@ _RESERVED_BODY_KEYS = {
60
64
 
61
65
  @serve.ingress(_app)
62
66
  class Text2Text(LocalModel):
67
+ @classmethod
68
+ def config(cls) -> dict[str, str]:
69
+ return {**super().config(), ENABLE_THINKING_PARAM: "true"}
70
+
63
71
  @classmethod
64
72
  def client(cls, url: str, name: str) -> ServedCompletingModel:
65
73
  return ServedCompletingModel(url=url, model_id=name)
@@ -81,11 +89,13 @@ class Text2Text(LocalModel):
81
89
  self._max_total_tokens = min(
82
90
  int(settings.get(MAX_TOTAL_TOKENS_PARAM, DEFAULT_MAX_TOTAL_TOKENS)), context
83
91
  )
92
+ self._enable_thinking = settings.get(ENABLE_THINKING_PARAM, "true") == "true"
84
93
  # A static cache of fixed length is what makes decode compilable: it
85
94
  # pins the shape every forward sees, so the loop is captured once
86
95
  # instead of recompiled per token. transformers then compiles `generate`
87
96
  # itself, which is why the module is not compiled here as well.
88
- self._compiled = compiling.supported(self._device)
97
+ requested = settings.get(COMPILE_PARAM, "false") == "true"
98
+ self._compiled = requested and compiling.supported(self._device)
89
99
  if self._compiled:
90
100
  config = self._model.generation_config
91
101
  config.cache_implementation = "static"
@@ -133,6 +143,7 @@ class Text2Text(LocalModel):
133
143
  tools=tools,
134
144
  tokenize=False,
135
145
  add_generation_prompt=True,
146
+ enable_thinking=self._enable_thinking,
136
147
  )
137
148
  inputs = self._truncate(
138
149
  self._tokenizer(prompt, return_tensors="pt"),
@@ -0,0 +1,107 @@
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
+ import logging
13
+ from typing import Any
14
+
15
+ import cortexgrid
16
+ from cortexgrid import serve
17
+ from fastapi import FastAPI
18
+ from transformers import AutoModelForSeq2SeqLM, AutoTokenizer, CompileConfig
19
+ import torch
20
+
21
+ from cortexgrid_infer import compiling
22
+ from cortexgrid_infer.device import detect_device
23
+ from cortexgrid_infer.protocols.rewriting import ServedRewritingModel
24
+ from cortexgrid_infer.serve_apps.base import COMPILE_PARAM, LocalModel
25
+
26
+
27
+ log = logging.getLogger(__name__)
28
+
29
+ _app = FastAPI()
30
+
31
+ # Output budget for a request that names none: T5's training context, and
32
+ # generation stops at the end-of-sequence token well before it for most text.
33
+ DEFAULT_MAX_NEW_TOKENS = 512
34
+
35
+ # Lengths a compiled replica pads a text up to. `generate` sizes the
36
+ # cross-attention cache to the encoded text, so every distinct length would
37
+ # capture the decode loop afresh; rounding up to a power of two bounds that at
38
+ # one capture per bucket, at the cost of a little masked-out padding.
39
+ _INPUT_BUCKETS = (32, 64, 128, 256, 512)
40
+
41
+
42
+ def _bucket(length: int) -> int:
43
+ """The smallest of `_INPUT_BUCKETS` that holds `length` tokens, else the largest."""
44
+ return next((b for b in _INPUT_BUCKETS if b >= length), _INPUT_BUCKETS[-1])
45
+
46
+
47
+ @serve.ingress(_app)
48
+ class TextRewriter(LocalModel):
49
+ @classmethod
50
+ def client(cls, url: str, name: str) -> ServedRewritingModel:
51
+ return ServedRewritingModel(url=url, model_id=name)
52
+
53
+ def __init__(self, family: str, suffix: str, run_name: str) -> None:
54
+ path = cortexgrid.load_model(family, suffix, run_name)
55
+ self._device = detect_device()
56
+ self._tokenizer = AutoTokenizer.from_pretrained(path)
57
+ self._model = AutoModelForSeq2SeqLM.from_pretrained(
58
+ str(path), torch_dtype=torch.bfloat16
59
+ )
60
+ self._model.to(self._device)
61
+ settings = cortexgrid.model_config(family, suffix, run_name)
62
+ # As in Text2Text: a static cache pins the decoder's self-attention to
63
+ # one shape, and transformers compiles the decode step itself. The
64
+ # cross-attention cache is pinned by padding the text; see `_tokenize`.
65
+ requested = settings.get(COMPILE_PARAM, "false") == "true"
66
+ self._compiled = requested and compiling.supported(self._device)
67
+ if self._compiled:
68
+ config = self._model.generation_config
69
+ config.cache_implementation = "static"
70
+ config.max_cache_len = DEFAULT_MAX_NEW_TOKENS
71
+ config.compile_config = CompileConfig(mode=compiling.Mode.GRAPHED)
72
+
73
+ def _tokenize(self, text: str) -> Any:
74
+ """The model's inputs for `text`, on the device.
75
+
76
+ Compiled, the text is padded up to its bucket, and one longer than the
77
+ last bucket is cut to it, keeping its start."""
78
+ if not self._compiled:
79
+ return self._tokenizer(text, return_tensors="pt").to(self._device)
80
+ length = len(self._tokenizer(text)["input_ids"])
81
+ bucket = _bucket(length)
82
+ if length > bucket:
83
+ log.warning(
84
+ "truncating text from %d to %d tokens, the most this replica takes",
85
+ length, bucket,
86
+ )
87
+ return self._tokenizer(
88
+ text,
89
+ padding="max_length",
90
+ truncation=True,
91
+ max_length=bucket,
92
+ return_tensors="pt",
93
+ ).to(self._device)
94
+
95
+ @_app.post("/rewrite")
96
+ async def rewrite(self, body: dict[str, Any]) -> dict[str, str]:
97
+ generation_options = {"max_new_tokens": DEFAULT_MAX_NEW_TOKENS} | {
98
+ key: value for key, value in body.items() if key != "text"
99
+ }
100
+ if self._compiled:
101
+ # A reply longer than the cache would reallocate it and recompile.
102
+ generation_options["max_new_tokens"] = min(
103
+ generation_options["max_new_tokens"], DEFAULT_MAX_NEW_TOKENS
104
+ )
105
+ inputs = self._tokenize(body["text"])
106
+ output = self._model.generate(**inputs, **generation_options)
107
+ 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.3"
3
+ version = "0.3.5"
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"
@@ -1,54 +0,0 @@
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)}