cortexgrid-infer 0.3.5__tar.gz → 0.3.7__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 (33) hide show
  1. {cortexgrid_infer-0.3.5 → cortexgrid_infer-0.3.7}/PKG-INFO +15 -11
  2. {cortexgrid_infer-0.3.5 → cortexgrid_infer-0.3.7}/README.md +13 -9
  3. {cortexgrid_infer-0.3.5 → cortexgrid_infer-0.3.7}/cortexgrid_infer/core.py +21 -0
  4. cortexgrid_infer-0.3.7/cortexgrid_infer/protocols/completion.py +92 -0
  5. {cortexgrid_infer-0.3.5 → cortexgrid_infer-0.3.7}/cortexgrid_infer/serve_apps/anthropic.py +25 -12
  6. {cortexgrid_infer-0.3.5 → cortexgrid_infer-0.3.7}/cortexgrid_infer/serve_apps/base.py +3 -3
  7. {cortexgrid_infer-0.3.5 → cortexgrid_infer-0.3.7}/cortexgrid_infer/serve_apps/gemini/base.py +4 -4
  8. {cortexgrid_infer-0.3.5 → cortexgrid_infer-0.3.7}/cortexgrid_infer/serve_apps/gemini/text2text.py +24 -13
  9. {cortexgrid_infer-0.3.5 → cortexgrid_infer-0.3.7}/cortexgrid_infer/serve_apps/image2mesh.py +4 -3
  10. cortexgrid_infer-0.3.7/cortexgrid_infer/serve_apps/streamed_completion.py +165 -0
  11. {cortexgrid_infer-0.3.5 → cortexgrid_infer-0.3.7}/cortexgrid_infer/serve_apps/text2image.py +3 -3
  12. {cortexgrid_infer-0.3.5 → cortexgrid_infer-0.3.7}/cortexgrid_infer/serve_apps/text2text.py +147 -8
  13. {cortexgrid_infer-0.3.5 → cortexgrid_infer-0.3.7}/cortexgrid_infer/serve_apps/text_rewriter.py +3 -3
  14. {cortexgrid_infer-0.3.5 → cortexgrid_infer-0.3.7}/pyproject.toml +4 -2
  15. cortexgrid_infer-0.3.5/cortexgrid_infer/protocols/completion.py +0 -222
  16. {cortexgrid_infer-0.3.5 → cortexgrid_infer-0.3.7}/.gitignore +0 -0
  17. {cortexgrid_infer-0.3.5 → cortexgrid_infer-0.3.7}/LICENSE +0 -0
  18. {cortexgrid_infer-0.3.5 → cortexgrid_infer-0.3.7}/cortexgrid_infer/__init__.py +0 -0
  19. {cortexgrid_infer-0.3.5 → cortexgrid_infer-0.3.7}/cortexgrid_infer/compiling.py +0 -0
  20. {cortexgrid_infer-0.3.5 → cortexgrid_infer-0.3.7}/cortexgrid_infer/device.py +0 -0
  21. {cortexgrid_infer-0.3.5 → cortexgrid_infer-0.3.7}/cortexgrid_infer/importers/__init__.py +0 -0
  22. {cortexgrid_infer-0.3.5 → cortexgrid_infer-0.3.7}/cortexgrid_infer/importers/base.py +0 -0
  23. {cortexgrid_infer-0.3.5 → cortexgrid_infer-0.3.7}/cortexgrid_infer/importers/huggingface.py +0 -0
  24. {cortexgrid_infer-0.3.5 → cortexgrid_infer-0.3.7}/cortexgrid_infer/protocols/__init__.py +0 -0
  25. {cortexgrid_infer-0.3.5 → cortexgrid_infer-0.3.7}/cortexgrid_infer/protocols/imaging.py +0 -0
  26. {cortexgrid_infer-0.3.5 → cortexgrid_infer-0.3.7}/cortexgrid_infer/protocols/meshing.py +0 -0
  27. {cortexgrid_infer-0.3.5 → cortexgrid_infer-0.3.7}/cortexgrid_infer/protocols/rewriting.py +0 -0
  28. {cortexgrid_infer-0.3.5 → cortexgrid_infer-0.3.7}/cortexgrid_infer/py.typed +0 -0
  29. {cortexgrid_infer-0.3.5 → cortexgrid_infer-0.3.7}/cortexgrid_infer/registry.py +0 -0
  30. {cortexgrid_infer-0.3.5 → cortexgrid_infer-0.3.7}/cortexgrid_infer/serve_apps/__init__.py +0 -0
  31. {cortexgrid_infer-0.3.5 → cortexgrid_infer-0.3.7}/cortexgrid_infer/serve_apps/gemini/__init__.py +0 -0
  32. {cortexgrid_infer-0.3.5 → cortexgrid_infer-0.3.7}/cortexgrid_infer/serve_apps/gemini/text2image.py +0 -0
  33. {cortexgrid_infer-0.3.5 → cortexgrid_infer-0.3.7}/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.5
3
+ Version: 0.3.7
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
@@ -9,7 +9,7 @@ License-File: LICENSE
9
9
  Requires-Python: <3.12,>=3.11
10
10
  Requires-Dist: accelerate>=0.33
11
11
  Requires-Dist: anthropic>=0.40.0
12
- Requires-Dist: cortexgrid>=0.3.4
12
+ Requires-Dist: cortexgrid>=0.3.12
13
13
  Requires-Dist: diffusers>=0.38
14
14
  Requires-Dist: fastapi>=0.133
15
15
  Requires-Dist: google-genai>=1.0
@@ -132,7 +132,7 @@ async def run():
132
132
  asyncio.run(run())
133
133
 
134
134
  # 4. Teardown.
135
- cortexgrid.undeploy_model(imp.family, imp.suffix, cortexgrid.IMPORTED) # free the GPU
135
+ cortexgrid.undeploy_model(deployment.key) # free the GPU
136
136
  cortexgrid.delete_model(imp.family, imp.suffix, cortexgrid.IMPORTED) # and the weights
137
137
  ```
138
138
 
@@ -179,20 +179,24 @@ beyond the weights. (A `Hosted` entry asks for none — it holds no weights.)
179
179
  ## Compilation
180
180
 
181
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:
182
+ does unless asked: a replica is eager unless its deployment's config carries
183
+ `compile: "true"`. Each `LocalModel` writes `compile: "false"` onto the model card
184
+ at import, so turning it on is a deployment that asks for it:
185
185
 
186
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)
187
+ cortexgrid.deploy_model(family, suffix, cortexgrid.IMPORTED, wait=True, config={"compile": "true"})
190
188
  ```
191
189
 
190
+ That is a separate, compiled deployment of the same model, alongside any uncompiled
191
+ one: a model gets one deployment per distinct config, each at its own
192
+ `deployment.url`. `Text2Text`'s `enable_thinking` and `max_total_tokens` are set per
193
+ deployment the same way. Whatever a deployment leaves out comes from the model card,
194
+ so the defaults `config()` writes there at import still apply.
195
+
192
196
  It is off by default because it is not free: every input shape pays a warm-up on
193
197
  the request that first brings it, and each CUDA graph holds memory of its own. It
194
198
  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.
199
+ launch overhead to matter. A deployment asking for it off CUDA is served eager.
196
200
 
197
201
  Compiled, a serve app runs `torch.compile` over the model it loads, because
198
202
  eager PyTorch bills per *operation* and a transformer forward is mostly glue —
@@ -233,7 +237,7 @@ Each serve app is tuned rather than defaulted:
233
237
  1.2 GB of keys and values re-read per generated token, several times the
234
238
  traffic of the model's own weights, to make room for a prompt nobody sent. It
235
239
  is pinned instead to the prompt and reply a caller plausibly sends: 4096 tokens,
236
- or `max_total_tokens` on the model card, never past the model's own
240
+ or `max_total_tokens` from the deployment's config, never past the model's own
237
241
  `max_position_embeddings`. A reply takes at most half of it, so an
238
242
  over-optimistic `max_new_tokens` cannot squeeze the prompt to nothing, and a
239
243
  prompt that will not fit in the rest is truncated — keeping the end, since a
@@ -104,7 +104,7 @@ async def run():
104
104
  asyncio.run(run())
105
105
 
106
106
  # 4. Teardown.
107
- cortexgrid.undeploy_model(imp.family, imp.suffix, cortexgrid.IMPORTED) # free the GPU
107
+ cortexgrid.undeploy_model(deployment.key) # free the GPU
108
108
  cortexgrid.delete_model(imp.family, imp.suffix, cortexgrid.IMPORTED) # and the weights
109
109
  ```
110
110
 
@@ -151,20 +151,24 @@ beyond the weights. (A `Hosted` entry asks for none — it holds no weights.)
151
151
  ## Compilation
152
152
 
153
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:
154
+ does unless asked: a replica is eager unless its deployment's config carries
155
+ `compile: "true"`. Each `LocalModel` writes `compile: "false"` onto the model card
156
+ at import, so turning it on is a deployment that asks for it:
157
157
 
158
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)
159
+ cortexgrid.deploy_model(family, suffix, cortexgrid.IMPORTED, wait=True, config={"compile": "true"})
162
160
  ```
163
161
 
162
+ That is a separate, compiled deployment of the same model, alongside any uncompiled
163
+ one: a model gets one deployment per distinct config, each at its own
164
+ `deployment.url`. `Text2Text`'s `enable_thinking` and `max_total_tokens` are set per
165
+ deployment the same way. Whatever a deployment leaves out comes from the model card,
166
+ so the defaults `config()` writes there at import still apply.
167
+
164
168
  It is off by default because it is not free: every input shape pays a warm-up on
165
169
  the request that first brings it, and each CUDA graph holds memory of its own. It
166
170
  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.
171
+ launch overhead to matter. A deployment asking for it off CUDA is served eager.
168
172
 
169
173
  Compiled, a serve app runs `torch.compile` over the model it loads, because
170
174
  eager PyTorch bills per *operation* and a transformer forward is mostly glue —
@@ -205,7 +209,7 @@ Each serve app is tuned rather than defaulted:
205
209
  1.2 GB of keys and values re-read per generated token, several times the
206
210
  traffic of the model's own weights, to make room for a prompt nobody sent. It
207
211
  is pinned instead to the prompt and reply a caller plausibly sends: 4096 tokens,
208
- or `max_total_tokens` on the model card, never past the model's own
212
+ or `max_total_tokens` from the deployment's config, never past the model's own
209
213
  `max_position_embeddings`. A reply takes at most half of it, so an
210
214
  over-optimistic `max_new_tokens` cannot squeeze the prompt to nothing, and a
211
215
  prompt that will not fit in the rest is truncated — keeping the end, since a
@@ -37,6 +37,27 @@ class CompletionChunk:
37
37
  def has_tool_calls(self) -> bool:
38
38
  return bool(self.tool_calls)
39
39
 
40
+ @classmethod
41
+ def from_wire(
42
+ cls, answered: dict[str, Any], tools_by_name: dict[str, Callable[..., Any]]
43
+ ) -> CompletionChunk:
44
+ tool_calls = []
45
+ for called in answered["tool_calls"]:
46
+ tool = tools_by_name.get(called["name"])
47
+ if tool is None:
48
+ continue
49
+ bound = partial(tool, **called["arguments"])
50
+ tool_call = ToolCall(
51
+ id=called["id"], name=called["name"], arguments=called["arguments"], _func=bound
52
+ )
53
+ tool_calls.append(tool_call)
54
+ return cls(
55
+ content=answered["content"],
56
+ thinking=answered["thinking"],
57
+ tool_calls=tool_calls,
58
+ finish_reason=answered["finish_reason"],
59
+ )
60
+
40
61
 
41
62
  Message = dict[str, Any]
42
63
  ToolSpec = dict[str, Any]
@@ -0,0 +1,92 @@
1
+ """The wire protocol between a cortexgrid-served completion app and its client.
2
+
3
+ A completion serve app streams plain text from `POST /complete`, with tool calls
4
+ inlined in the text as `<tool_call>{"name": ..., "arguments": {...}}</tool_call>`.
5
+ That is the format an instruct-tuned causal LM emits on its own, so a serve app
6
+ wrapping one forwards its tokens untouched; a serve app wrapping a provider with
7
+ structured tool calls encodes them into the same form with `encode_tool_call`.
8
+
9
+ Either way the client is the same `ServedCompletingModel`, which streams text
10
+ back as `CompletionChunk`s and reassembles the tool calls.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from collections.abc import AsyncIterator
16
+ from dataclasses import dataclass
17
+ import json
18
+ from typing import Any, Sequence
19
+
20
+ import httpx
21
+
22
+ from cortexgrid_infer.core import CompletingModel, CompletionChunk, Message, Tool
23
+ from cortexgrid_infer.utils import build_tool_map, normalize_tools
24
+
25
+ @dataclass
26
+ class ServedCompletingModel(CompletingModel):
27
+ """Client for a completion app cortexgrid has deployed at `url`.
28
+
29
+ Provider-agnostic: every completion serve app speaks the protocol above, so
30
+ which model is behind it only shows up in `model_id`."""
31
+
32
+ url: str
33
+ model_id: str
34
+
35
+ @property
36
+ def name(self) -> str:
37
+ return self.model_id
38
+
39
+ async def loglikelihoods(
40
+ self, messages: list[Message], continuations: list[str]
41
+ ) -> list[float]:
42
+ body = {"messages": messages, "continuations": continuations}
43
+ loglikelihoods_url = f"{self.url}/loglikelihoods"
44
+ async with httpx.AsyncClient(timeout=None) as client:
45
+ response = await client.post(loglikelihoods_url, json=body)
46
+ response.raise_for_status()
47
+ answered = response.json()
48
+ return answered["loglikelihoods"]
49
+
50
+ async def complete(
51
+ self,
52
+ messages: list[Message],
53
+ tools: Sequence[Tool] | None = None,
54
+ max_new_tokens: int = 16 * 1024,
55
+ temperature: float = 0.7,
56
+ top_p: float = 0.9,
57
+ top_k: int = 50,
58
+ repetition_penalty: float = 1.0,
59
+ **kwargs: Any,
60
+ ) -> AsyncIterator[CompletionChunk]:
61
+ tool_specs = normalize_tools(tools)
62
+ tools_by_name = build_tool_map(tools)
63
+ body: dict[str, Any] = {
64
+ "messages": messages,
65
+ "tools": tool_specs,
66
+ "max_new_tokens": max_new_tokens,
67
+ "temperature": temperature,
68
+ "top_p": top_p,
69
+ "top_k": top_k,
70
+ "repetition_penalty": repetition_penalty,
71
+ **kwargs,
72
+ }
73
+ complete_url = f"{self.url}/complete"
74
+ async with httpx.AsyncClient(timeout=None) as client:
75
+ async with client.stream("POST", complete_url, json=body) as response:
76
+ response.raise_for_status()
77
+ async for line in response.aiter_lines():
78
+ answered = json.loads(line)
79
+ chunk = CompletionChunk.from_wire(answered, tools_by_name)
80
+ yield chunk
81
+
82
+ async def last_hidden_states(
83
+ self, parts: list[str], continuations_of_each_part: list[list[str]]
84
+ ) -> AsyncIterator[list[list[float]]]:
85
+ body = {"parts": parts, "continuations_of_each_part": continuations_of_each_part}
86
+ last_hidden_states_url = f"{self.url}/last_hidden_states"
87
+ async with httpx.AsyncClient(timeout=None) as client:
88
+ async with client.stream("POST", last_hidden_states_url, json=body) as response:
89
+ response.raise_for_status()
90
+ async for line in response.aiter_lines():
91
+ answered = json.loads(line)
92
+ yield answered["last_hidden_states"]
@@ -19,12 +19,13 @@ from typing import Any
19
19
  import cortexgrid
20
20
  from anthropic import AsyncAnthropic
21
21
  from cortexgrid import serve
22
- from fastapi import FastAPI
22
+ from fastapi import FastAPI, HTTPException, status
23
23
  from fastapi.responses import StreamingResponse
24
24
 
25
25
  from cortexgrid_infer.core import Message, ToolSpec
26
- from cortexgrid_infer.protocols.completion import ServedCompletingModel, encode_tool_call
26
+ from cortexgrid_infer.protocols.completion import ServedCompletingModel
27
27
  from cortexgrid_infer.serve_apps.base import HostedModel
28
+ from cortexgrid_infer.serve_apps.streamed_completion import StreamedCompletionWriter
28
29
 
29
30
  _app = FastAPI()
30
31
 
@@ -131,13 +132,14 @@ class AnthropicText2Text(HostedModel):
131
132
  def client(cls, url: str, name: str) -> ServedCompletingModel:
132
133
  return ServedCompletingModel(url=url, model_id=name)
133
134
 
134
- def __init__(self, family: str, suffix: str, run_name: str) -> None:
135
- config = cortexgrid.model_config(family, suffix, run_name)
135
+ def __init__(self, deployment: cortexgrid.DeploymentKey) -> None:
136
+ config = cortexgrid.model_config(deployment)
136
137
  missing = {MODEL_PARAM, API_KEY_SECRET_PARAM} - config.keys()
137
138
  if missing:
138
139
  raise RuntimeError(
139
- f"{family}/{suffix}/{run_name} is missing {sorted(missing)} from "
140
- "its config; set them on the model card"
140
+ f"{deployment.family}/{deployment.suffix}/{deployment.run_name} "
141
+ f"is missing {sorted(missing)} from its config; set them on the "
142
+ "model card"
141
143
  )
142
144
  self._model = config[MODEL_PARAM]
143
145
  self._client = AsyncAnthropic(
@@ -161,19 +163,30 @@ class AnthropicText2Text(HostedModel):
161
163
  create_kwargs["tools"] = tools
162
164
 
163
165
  async def stream() -> AsyncIterator[bytes]:
166
+ writer = StreamedCompletionWriter()
164
167
  async with self._client.messages.stream(**create_kwargs) as events:
165
168
  async for event in events:
166
169
  if (
167
170
  event.type == "content_block_delta"
168
171
  and event.delta.type == "text_delta"
169
172
  ):
170
- yield event.delta.text.encode("utf-8")
173
+ line = writer.read_content(event.delta.text)
174
+ yield line
171
175
 
172
176
  elif event.type == "content_block_stop":
173
177
  block = events.current_message_snapshot.content[event.index]
174
178
  if block.type == "tool_use":
175
- yield encode_tool_call(
176
- block.name, dict(block.input)
177
- ).encode("utf-8")
178
-
179
- return StreamingResponse(stream(), media_type="text/plain")
179
+ arguments = dict(block.input)
180
+ line = writer.read_tool_call(block.name, arguments, block.id)
181
+ yield line
182
+ for line in writer.finish():
183
+ yield line
184
+
185
+ return StreamingResponse(stream(), media_type="application/x-ndjson")
186
+
187
+ @_app.post("/last_hidden_states")
188
+ async def last_hidden_states(self, body: dict[str, Any]) -> None:
189
+ raise HTTPException(
190
+ status_code=status.HTTP_501_NOT_IMPLEMENTED,
191
+ detail="Anthropic does not expose its models' hidden states",
192
+ )
@@ -31,9 +31,9 @@ _GIB = float(1 << 30)
31
31
  ENABLE_THINKING_PARAM = "enable_thinking"
32
32
 
33
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.
34
+ # `cortexgrid_infer.compiling`). Off unless the deployment's config says
35
+ # "true": compiled serving pays a warm-up per input shape, which is worth it
36
+ # only where traffic repeats those shapes.
37
37
  COMPILE_PARAM = "compile"
38
38
 
39
39
  # Weights are only part of what a replica holds. The multiplier covers CUDA
@@ -36,13 +36,13 @@ class GeminiModel(HostedModel):
36
36
  holding the key to send it with."""
37
37
  return {MODEL_PARAM: model_id, API_KEY_SECRET_PARAM: api_key_secret}
38
38
 
39
- def __init__(self, family: str, suffix: str, run_name: str) -> None:
40
- config = cortexgrid.model_config(family, suffix, run_name)
39
+ def __init__(self, deployment: cortexgrid.DeploymentKey) -> None:
40
+ config = cortexgrid.model_config(deployment)
41
41
  missing = {MODEL_PARAM, API_KEY_SECRET_PARAM} - config.keys()
42
42
  if missing:
43
43
  raise RuntimeError(
44
- f"{family}/{suffix}/{run_name} is missing {sorted(missing)} from "
45
- "its config; set them on the model card"
44
+ f"{deployment.family}/{deployment.suffix}/{deployment.run_name} is "
45
+ f"missing {sorted(missing)} from its config; set them on the model card"
46
46
  )
47
47
  self._config = config
48
48
  self._model = config[MODEL_PARAM]
@@ -12,17 +12,14 @@ from collections.abc import AsyncIterator
12
12
  from typing import Any
13
13
 
14
14
  from cortexgrid import serve
15
- from fastapi import FastAPI
15
+ from fastapi import FastAPI, HTTPException, status
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 (
20
- ServedCompletingModel,
21
- encode_thinking,
22
- encode_tool_call,
23
- )
19
+ from cortexgrid_infer.protocols.completion import ServedCompletingModel
24
20
  from cortexgrid_infer.serve_apps.base import ENABLE_THINKING_PARAM
25
21
  from cortexgrid_infer.serve_apps.gemini.base import DEFAULT_API_KEY_SECRET, GeminiModel
22
+ from cortexgrid_infer.serve_apps.streamed_completion import StreamedCompletionWriter
26
23
 
27
24
  _app = FastAPI()
28
25
 
@@ -150,6 +147,7 @@ class GeminiText2Text(GeminiModel):
150
147
  generate_config["tools"] = tools
151
148
 
152
149
  async def stream() -> AsyncIterator[bytes]:
150
+ writer = StreamedCompletionWriter()
153
151
  chunks = await self._client.aio.models.generate_content_stream(
154
152
  model=self._model, contents=contents, config=generate_config
155
153
  )
@@ -158,14 +156,27 @@ class GeminiText2Text(GeminiModel):
158
156
  continue
159
157
  for part in chunk.candidates[0].content.parts or []:
160
158
  if part.function_call:
161
- yield encode_tool_call(
162
- part.function_call.name, dict(part.function_call.args or {})
163
- ).encode("utf-8")
159
+ arguments = dict(part.function_call.args or {})
160
+ line = writer.read_tool_call(
161
+ part.function_call.name, arguments, part.function_call.id
162
+ )
163
+ yield line
164
164
 
165
165
  elif part.text and part.thought:
166
- yield encode_thinking(part.text).encode("utf-8")
166
+ line = writer.read_thinking(part.text)
167
+ yield line
167
168
 
168
169
  elif part.text:
169
- yield part.text.encode("utf-8")
170
-
171
- return StreamingResponse(stream(), media_type="text/plain")
170
+ line = writer.read_content(part.text)
171
+ yield line
172
+ for line in writer.finish():
173
+ yield line
174
+
175
+ return StreamingResponse(stream(), media_type="application/x-ndjson")
176
+
177
+ @_app.post("/last_hidden_states")
178
+ async def last_hidden_states(self, body: dict[str, Any]) -> None:
179
+ raise HTTPException(
180
+ status_code=status.HTTP_501_NOT_IMPLEMENTED,
181
+ detail="Gemini does not expose its models' hidden states",
182
+ )
@@ -54,10 +54,11 @@ class Image2Mesh(LocalModel):
54
54
  def client(cls, url: str, name: str) -> ServedMeshingModel:
55
55
  return ServedMeshingModel(url=url, model_id=name)
56
56
 
57
- def __init__(self, family: str, suffix: str, run_name: str) -> None:
57
+ def __init__(self, deployment: cortexgrid.DeploymentKey) -> None:
58
58
  self.device = detect_device()
59
- self.load(Path(cortexgrid.load_model(family, suffix, run_name)), self.device)
60
- settings = cortexgrid.model_config(family, suffix, run_name)
59
+ path = cortexgrid.load_model(deployment.family, deployment.suffix, deployment.run_name)
60
+ self.load(Path(path), self.device)
61
+ settings = cortexgrid.model_config(deployment)
61
62
  requested = settings.get(COMPILE_PARAM, "false") == "true"
62
63
  self.compiled = requested and compiling.supported(self.device)
63
64
  if self.compiled:
@@ -0,0 +1,165 @@
1
+ from __future__ import annotations
2
+
3
+ import itertools
4
+ import json
5
+ import re
6
+ from typing import Any, Sequence
7
+
8
+ _tool_call_id_counter = itertools.count()
9
+
10
+ TOOL_CALL_OPENERS = ["<tool_call>", "<|tool_call|>", "```tool_call"]
11
+
12
+ THINKING_OPENER = "<think>"
13
+ THINKING_CLOSER = "</think>"
14
+
15
+ TOOL_CALL_PATTERNS = [
16
+ re.compile(r"<tool_call>\s*(\{.*?\})\s*</tool_call>", re.DOTALL),
17
+ re.compile(r"<\|tool_call\|>\s*(\{.*?\})\s*<\|/tool_call\|>", re.DOTALL),
18
+ re.compile(r"```tool_call\s*(\{.*?\})\s*```", re.DOTALL),
19
+ ]
20
+
21
+
22
+ def new_tool_call_id() -> str:
23
+ number = next(_tool_call_id_counter)
24
+ return f"call_{number}"
25
+
26
+
27
+ def completion_chunk(
28
+ content: str = "",
29
+ thinking: str = "",
30
+ tool_calls: Sequence[dict[str, Any]] = (),
31
+ finish_reason: str | None = None,
32
+ ) -> bytes:
33
+ chunk = {
34
+ "content": content,
35
+ "thinking": thinking,
36
+ "tool_calls": list(tool_calls),
37
+ "finish_reason": finish_reason,
38
+ }
39
+ line = json.dumps(chunk)
40
+ return f"{line}\n".encode("utf-8")
41
+
42
+
43
+ def _find_opener(text: str, openers: Sequence[str]) -> int | None:
44
+ """Find the earliest position of any of `openers` in text, or None."""
45
+ earliest = None
46
+ for opener in openers:
47
+ idx = text.find(opener)
48
+ if idx != -1 and (earliest is None or idx < earliest):
49
+ earliest = idx
50
+ return earliest
51
+
52
+
53
+ def _split_at_potential_prefix(text: str, openers: Sequence[str]) -> tuple[str, str]:
54
+ """Split text into (safe_to_stream, potential_opener_prefix).
55
+
56
+ The second part is a suffix that could be the beginning of one of
57
+ `openers`, so it must be held back until more text arrives.
58
+ """
59
+ for opener in openers:
60
+ for length in range(1, len(opener)):
61
+ if text.endswith(opener[:length]):
62
+ return text[:-length], text[-length:]
63
+ return text, ""
64
+
65
+
66
+ def tool_calls_in(text: str) -> tuple[str, list[dict[str, Any]]]:
67
+ tool_calls = []
68
+ clean_text = text
69
+ for pattern in TOOL_CALL_PATTERNS:
70
+ matches = pattern.findall(text)
71
+ for match in matches:
72
+ try:
73
+ data = json.loads(match)
74
+ name = data["name"]
75
+ except (json.JSONDecodeError, KeyError):
76
+ continue
77
+ arguments = data.get("arguments", {})
78
+ call_id = new_tool_call_id()
79
+ tool_calls.append({"id": call_id, "name": name, "arguments": arguments})
80
+ clean_text = pattern.sub("", clean_text)
81
+ stripped = clean_text.strip()
82
+ return stripped, tool_calls
83
+
84
+
85
+ class StreamedCompletionWriter:
86
+ def __init__(self) -> None:
87
+ self._pending = ""
88
+ self._in_thinking = False
89
+ self._in_tool_call = False
90
+ self._tool_call_text = ""
91
+ self._made_a_tool_call = False
92
+
93
+ def read_content(self, text: str) -> bytes:
94
+ return completion_chunk(content=text)
95
+
96
+ def read_thinking(self, text: str) -> bytes:
97
+ return completion_chunk(thinking=text)
98
+
99
+ def read_tool_call(
100
+ self, name: str, arguments: dict[str, Any], call_id: str | None = None
101
+ ) -> bytes:
102
+ self._made_a_tool_call = True
103
+ tool_call_id = call_id or new_tool_call_id()
104
+ tool_call = {"id": tool_call_id, "name": name, "arguments": arguments}
105
+ return completion_chunk(tool_calls=[tool_call])
106
+
107
+ def read(self, text: str) -> list[bytes]:
108
+ if not text:
109
+ return []
110
+ if self._in_tool_call:
111
+ self._tool_call_text += text
112
+ return []
113
+ self._pending += text
114
+ lines: list[bytes] = []
115
+ openers = [THINKING_OPENER, *TOOL_CALL_OPENERS]
116
+ while self._pending and not self._in_tool_call:
117
+ if self._in_thinking:
118
+ closer_pos = self._pending.find(THINKING_CLOSER)
119
+ if closer_pos == -1:
120
+ thought, self._pending = _split_at_potential_prefix(
121
+ self._pending, [THINKING_CLOSER]
122
+ )
123
+ if thought:
124
+ lines.append(completion_chunk(thinking=thought))
125
+ break
126
+ if closer_pos:
127
+ thought = self._pending[:closer_pos]
128
+ lines.append(completion_chunk(thinking=thought))
129
+ self._pending = self._pending[closer_pos + len(THINKING_CLOSER) :]
130
+ self._in_thinking = False
131
+ continue
132
+
133
+ opener_pos = _find_opener(self._pending, openers)
134
+ if opener_pos is None:
135
+ safe, self._pending = _split_at_potential_prefix(self._pending, openers)
136
+ if safe:
137
+ lines.append(completion_chunk(content=safe))
138
+ break
139
+
140
+ before = self._pending[:opener_pos]
141
+ if before:
142
+ lines.append(completion_chunk(content=before))
143
+ if self._pending.startswith(THINKING_OPENER, opener_pos):
144
+ self._pending = self._pending[opener_pos + len(THINKING_OPENER) :]
145
+ self._in_thinking = True
146
+ else:
147
+ self._tool_call_text = self._pending[opener_pos:]
148
+ self._in_tool_call = True
149
+ self._pending = ""
150
+ return lines
151
+
152
+ def finish(self) -> list[bytes]:
153
+ lines: list[bytes] = []
154
+ if self._pending and self._in_thinking:
155
+ lines.append(completion_chunk(thinking=self._pending))
156
+ elif self._pending:
157
+ lines.append(completion_chunk(content=self._pending))
158
+ clean_text, tool_calls = tool_calls_in(self._tool_call_text)
159
+ if clean_text:
160
+ lines.append(completion_chunk(content=clean_text))
161
+ if tool_calls:
162
+ lines.append(completion_chunk(tool_calls=tool_calls))
163
+ elif not self._made_a_tool_call:
164
+ lines.append(completion_chunk(finish_reason="stop"))
165
+ return lines
@@ -84,8 +84,8 @@ class Text2Image(LocalModel):
84
84
  def client(cls, url: str, name: str) -> ServedGeneratingModel:
85
85
  return ServedGeneratingModel(url=url, model_id=name)
86
86
 
87
- def __init__(self, family: str, suffix: str, run_name: str) -> None:
88
- path = cortexgrid.load_model(family, suffix, run_name)
87
+ def __init__(self, deployment: cortexgrid.DeploymentKey) -> None:
88
+ path = cortexgrid.load_model(deployment.family, deployment.suffix, deployment.run_name)
89
89
  self._device = detect_device()
90
90
  pipe = _pipeline_class(path).from_pretrained(path, torch_dtype=torch.bfloat16)
91
91
  pipe = pipe.to(self._device)
@@ -95,7 +95,7 @@ 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
- settings = cortexgrid.model_config(family, suffix, run_name)
98
+ settings = cortexgrid.model_config(deployment)
99
99
  requested = settings.get(COMPILE_PARAM, "false") == "true"
100
100
  self._compiled = (
101
101
  compiling.compile_pipeline(pipe, self._device, compiling.Mode.GRAPHED)
@@ -10,8 +10,10 @@ its tokens are forwarded untouched.
10
10
  from __future__ import annotations
11
11
 
12
12
  import asyncio
13
+ import copy
14
+ import json
13
15
  import logging
14
- from collections.abc import AsyncIterator
16
+ from collections.abc import AsyncIterator, Callable
15
17
  from threading import Thread
16
18
  from typing import Any
17
19
 
@@ -22,7 +24,9 @@ from fastapi.responses import StreamingResponse
22
24
  from transformers import (
23
25
  AutoModelForCausalLM,
24
26
  AutoTokenizer,
27
+ Cache,
25
28
  CompileConfig,
29
+ PreTrainedModel,
26
30
  TextIteratorStreamer,
27
31
  )
28
32
  import torch
@@ -35,6 +39,7 @@ from cortexgrid_infer.serve_apps.base import (
35
39
  ENABLE_THINKING_PARAM,
36
40
  LocalModel,
37
41
  )
42
+ from cortexgrid_infer.serve_apps.streamed_completion import StreamedCompletionWriter
38
43
 
39
44
 
40
45
  log = logging.getLogger(__name__)
@@ -51,6 +56,9 @@ _app = FastAPI()
51
56
  MAX_TOTAL_TOKENS_PARAM = "max_total_tokens"
52
57
  DEFAULT_MAX_TOTAL_TOKENS = 4096
53
58
 
59
+ DTYPE_PARAM = "dtype"
60
+ DEFAULT_DTYPE = "float16"
61
+
54
62
  _RESERVED_BODY_KEYS = {
55
63
  "messages",
56
64
  "tools",
@@ -59,9 +67,55 @@ _RESERVED_BODY_KEYS = {
59
67
  "top_p",
60
68
  "top_k",
61
69
  "repetition_penalty",
70
+ "choices",
62
71
  }
63
72
 
64
73
 
74
+ def allowed_next_tokens(
75
+ choices: list[list[int]], generated: list[int], end_of_text: int
76
+ ) -> list[int]:
77
+ matching = [choice for choice in choices if choice[: len(generated)] == generated]
78
+ continuing = {choice[len(generated)] for choice in matching if len(choice) > len(generated)}
79
+ finished = any(choice == generated for choice in matching)
80
+ allowed = sorted(continuing | {end_of_text}) if finished else sorted(continuing)
81
+ return allowed or [end_of_text]
82
+
83
+
84
+ def continuation_loglikelihood(
85
+ model: PreTrainedModel, prompt_ids: list[int], continuation_ids: list[int]
86
+ ) -> float:
87
+ read = torch.tensor([prompt_ids + continuation_ids], device=model.device)
88
+ with torch.inference_mode():
89
+ outputs = model(input_ids=read, logits_to_keep=len(continuation_ids) + 1)
90
+ predicting_the_continuation = outputs.logits[0, :-1].float()
91
+ log_probabilities = torch.log_softmax(predicting_the_continuation, dim=-1)
92
+ positions = torch.arange(len(continuation_ids), device=log_probabilities.device)
93
+ continuation = torch.tensor(continuation_ids, device=log_probabilities.device)
94
+ return float(log_probabilities[positions, continuation].sum())
95
+
96
+
97
+ def read_further(model: PreTrainedModel, read: Cache | None, ids: list[int]) -> Cache:
98
+ unread = torch.tensor([ids], device=model.device)
99
+ with torch.inference_mode():
100
+ outputs = model(input_ids=unread, past_key_values=read, use_cache=True, logits_to_keep=1)
101
+ return outputs.past_key_values
102
+
103
+
104
+ def last_hidden_state_after(model: PreTrainedModel, read: Cache, ids: list[int]) -> torch.Tensor:
105
+ read_for_the_continuation = copy.deepcopy(read)
106
+ continuation = torch.tensor([ids], device=model.device)
107
+ with torch.inference_mode():
108
+ outputs = model(
109
+ input_ids=continuation,
110
+ past_key_values=read_for_the_continuation,
111
+ use_cache=True,
112
+ output_hidden_states=True,
113
+ logits_to_keep=1,
114
+ )
115
+ last_layer = outputs.hidden_states[-1]
116
+ return last_layer[0, -1].float()
117
+
118
+
65
119
  @serve.ingress(_app)
66
120
  class Text2Text(LocalModel):
67
121
  @classmethod
@@ -72,17 +126,19 @@ class Text2Text(LocalModel):
72
126
  def client(cls, url: str, name: str) -> ServedCompletingModel:
73
127
  return ServedCompletingModel(url=url, model_id=name)
74
128
 
75
- def __init__(self, family: str, suffix: str, run_name: str) -> None:
76
- path = cortexgrid.load_model(family, suffix, run_name)
129
+ def __init__(self, deployment: cortexgrid.DeploymentKey) -> None:
130
+ path = cortexgrid.load_model(
131
+ deployment.family, deployment.suffix, deployment.run_name
132
+ )
133
+ settings = cortexgrid.model_config(deployment)
77
134
  self._device = detect_device()
78
135
  self._tokenizer = AutoTokenizer.from_pretrained(path)
79
136
  if self._tokenizer.pad_token is None:
80
137
  self._tokenizer.pad_token = self._tokenizer.eos_token
81
138
  self._model = AutoModelForCausalLM.from_pretrained(
82
- str(path), torch_dtype=torch.float16
139
+ str(path), torch_dtype=getattr(torch, settings.get(DTYPE_PARAM, DEFAULT_DTYPE))
83
140
  )
84
141
  self._model.to(self._device) # type: ignore
85
- settings = cortexgrid.model_config(family, suffix, run_name)
86
142
  context = getattr(
87
143
  self._model.config, "max_position_embeddings", DEFAULT_MAX_TOTAL_TOKENS
88
144
  )
@@ -125,6 +181,82 @@ class Text2Text(LocalModel):
125
181
  inputs[key] = value[..., -limit:]
126
182
  return inputs
127
183
 
184
+ def _prompt_ids(self, messages: list[dict[str, Any]]) -> list[int]:
185
+ prompt = self._tokenizer.apply_chat_template(
186
+ messages,
187
+ tokenize=False,
188
+ add_generation_prompt=True,
189
+ enable_thinking=self._enable_thinking,
190
+ )
191
+ return self._tokenizer(prompt)["input_ids"]
192
+
193
+ def _continuation_ids(self, text: str) -> list[int]:
194
+ return self._tokenizer(text, add_special_tokens=False)["input_ids"]
195
+
196
+ def _only_the_choices(
197
+ self, choices: list[str], prompt_length: int
198
+ ) -> Callable[[int, torch.Tensor], list[int]]:
199
+ choice_ids = [self._continuation_ids(choice) for choice in choices]
200
+ end_of_text = self._tokenizer.eos_token_id
201
+
202
+ def allowed(_batch: int, sequence: torch.Tensor) -> list[int]:
203
+ generated = sequence[prompt_length:].tolist()
204
+ return allowed_next_tokens(choice_ids, generated, end_of_text)
205
+
206
+ return allowed
207
+
208
+ def _loglikelihoods(
209
+ self, messages: list[dict[str, Any]], continuations: list[str]
210
+ ) -> list[float]:
211
+ prompt_ids = self._prompt_ids(messages)
212
+ continuation_ids = [self._continuation_ids(continuation) for continuation in continuations]
213
+ return [
214
+ continuation_loglikelihood(self._model, prompt_ids, ids) for ids in continuation_ids
215
+ ]
216
+
217
+ @_app.post("/loglikelihoods")
218
+ async def loglikelihoods(self, body: dict[str, Any]) -> dict[str, list[float]]:
219
+ loop = asyncio.get_running_loop()
220
+ loglikelihoods = await loop.run_in_executor(
221
+ None, self._loglikelihoods, body["messages"], body["continuations"]
222
+ )
223
+ return {"loglikelihoods": loglikelihoods}
224
+
225
+ def _read_part(
226
+ self, read: Cache | None, part: str, continuations: list[str]
227
+ ) -> tuple[Cache, list[list[float]]]:
228
+ tokenized = self._tokenizer(part, add_special_tokens=read is None)
229
+ part_ids = tokenized["input_ids"]
230
+ read = read_further(self._model, read, part_ids)
231
+ continuation_ids = [self._continuation_ids(continuation) for continuation in continuations]
232
+ last_hidden_states = [
233
+ last_hidden_state_after(self._model, read, ids) for ids in continuation_ids
234
+ ]
235
+ answered = [hidden_state.tolist() for hidden_state in last_hidden_states]
236
+ return read, answered
237
+
238
+ async def _last_hidden_states_of_each_part(
239
+ self, parts: list[str], continuations_of_each_part: list[list[str]]
240
+ ) -> AsyncIterator[bytes]:
241
+ loop = asyncio.get_running_loop()
242
+ read: Cache | None = None
243
+ for part, continuations in zip(parts, continuations_of_each_part):
244
+ read, last_hidden_states = await loop.run_in_executor(
245
+ None, self._read_part, read, part, continuations
246
+ )
247
+ answered = json.dumps({"last_hidden_states": last_hidden_states})
248
+ line = f"{answered}\n".encode("utf-8")
249
+ yield line
250
+
251
+ @_app.post("/last_hidden_states")
252
+ async def last_hidden_states(self, body: dict[str, Any]) -> StreamingResponse:
253
+ last_hidden_states_of_each_part = self._last_hidden_states_of_each_part(
254
+ body["parts"], body["continuations_of_each_part"]
255
+ )
256
+ return StreamingResponse(
257
+ last_hidden_states_of_each_part, media_type="application/x-ndjson"
258
+ )
259
+
128
260
  @_app.post("/complete")
129
261
  async def complete(self, body: dict[str, Any]) -> StreamingResponse:
130
262
  messages = body["messages"]
@@ -136,6 +268,7 @@ class Text2Text(LocalModel):
136
268
  top_p = body.get("top_p", 0.9)
137
269
  top_k = body.get("top_k", 50)
138
270
  repetition_penalty = body.get("repetition_penalty", 1.0)
271
+ choices = body.get("choices")
139
272
  extra = {k: v for k, v in body.items() if k not in _RESERVED_BODY_KEYS}
140
273
 
141
274
  prompt = self._tokenizer.apply_chat_template(
@@ -149,6 +282,9 @@ class Text2Text(LocalModel):
149
282
  self._tokenizer(prompt, return_tensors="pt"),
150
283
  self._max_total_tokens - max_new_tokens,
151
284
  ).to(self._model.device)
285
+ if choices:
286
+ prompt_length = inputs["input_ids"].shape[-1]
287
+ extra["prefix_allowed_tokens_fn"] = self._only_the_choices(choices, prompt_length)
152
288
 
153
289
  queue: asyncio.Queue[str | None] = asyncio.Queue()
154
290
  error: list[BaseException] = []
@@ -190,13 +326,16 @@ class Text2Text(LocalModel):
190
326
  loop.run_in_executor(None, generate)
191
327
 
192
328
  async def stream() -> AsyncIterator[bytes]:
329
+ writer = StreamedCompletionWriter()
193
330
  while True:
194
331
  text = await queue.get()
195
332
  if text is None:
196
333
  break
197
- if text:
198
- yield text.encode("utf-8")
334
+ for line in writer.read(text):
335
+ yield line
199
336
  if error:
200
337
  raise error[0]
338
+ for line in writer.finish():
339
+ yield line
201
340
 
202
- return StreamingResponse(stream(), media_type="text/plain")
341
+ return StreamingResponse(stream(), media_type="application/x-ndjson")
@@ -50,15 +50,15 @@ class TextRewriter(LocalModel):
50
50
  def client(cls, url: str, name: str) -> ServedRewritingModel:
51
51
  return ServedRewritingModel(url=url, model_id=name)
52
52
 
53
- def __init__(self, family: str, suffix: str, run_name: str) -> None:
54
- path = cortexgrid.load_model(family, suffix, run_name)
53
+ def __init__(self, deployment: cortexgrid.DeploymentKey) -> None:
54
+ path = cortexgrid.load_model(deployment.family, deployment.suffix, deployment.run_name)
55
55
  self._device = detect_device()
56
56
  self._tokenizer = AutoTokenizer.from_pretrained(path)
57
57
  self._model = AutoModelForSeq2SeqLM.from_pretrained(
58
58
  str(path), torch_dtype=torch.bfloat16
59
59
  )
60
60
  self._model.to(self._device)
61
- settings = cortexgrid.model_config(family, suffix, run_name)
61
+ settings = cortexgrid.model_config(deployment)
62
62
  # As in Text2Text: a static cache pins the decoder's self-attention to
63
63
  # one shape, and transformers compiles the decode step itself. The
64
64
  # cross-attention cache is pinned by padding the text; see `_tokenize`.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "cortexgrid-infer"
3
- version = "0.3.5"
3
+ version = "0.3.7"
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"
@@ -15,7 +15,9 @@ dependencies = [
15
15
  # 0.3.4: jobs return results (remote -> JobFuture), register_model for a
16
16
  # model that stages no weights, model_config, and deploy_model skipping a
17
17
  # redundant PUT - which is why this library has no reuse guard of its own.
18
- "cortexgrid>=0.3.4",
18
+ # 0.3.12: DeploymentKey - a serve-app is constructed with its deployment's
19
+ # key and reads that deployment's config with model_config(key).
20
+ "cortexgrid>=0.3.12",
19
21
  "torch>=2.0.0",
20
22
  # 4.47: CompileConfig, which the completion serve app hands to `generate`
21
23
  # alongside a static cache so transformers compiles the decode step itself.
@@ -1,222 +0,0 @@
1
- """The wire protocol between a cortexgrid-served completion app and its client.
2
-
3
- A completion serve app streams plain text from `POST /complete`, with tool calls
4
- inlined in the text as `<tool_call>{"name": ..., "arguments": {...}}</tool_call>`.
5
- That is the format an instruct-tuned causal LM emits on its own, so a serve app
6
- wrapping one forwards its tokens untouched; a serve app wrapping a provider with
7
- structured tool calls encodes them into the same form with `encode_tool_call`.
8
-
9
- Either way the client is the same `ServedCompletingModel`, which streams text
10
- back as `CompletionChunk`s and reassembles the tool calls.
11
- """
12
-
13
- from __future__ import annotations
14
-
15
- from collections.abc import AsyncIterator
16
- from dataclasses import dataclass
17
- from functools import partial
18
- import itertools
19
- import json
20
- import re
21
- from typing import Any, Callable, Sequence
22
-
23
- import httpx
24
-
25
- from cortexgrid_infer.core import (
26
- CompletingModel,
27
- CompletionChunk,
28
- Message,
29
- Tool,
30
- ToolCall,
31
- )
32
- from cortexgrid_infer.utils import build_tool_map, normalize_tools
33
-
34
- _tool_call_id_counter = itertools.count()
35
-
36
- TOOL_CALL_OPENERS = ["<tool_call>", "<|tool_call|>", "```tool_call"]
37
-
38
- THINKING_OPENER = "<think>"
39
- THINKING_CLOSER = "</think>"
40
-
41
- TOOL_CALL_PATTERNS = [
42
- re.compile(r"<tool_call>\s*(\{.*?\})\s*</tool_call>", re.DOTALL),
43
- re.compile(r"<\|tool_call\|>\s*(\{.*?\})\s*<\|/tool_call\|>", re.DOTALL),
44
- re.compile(r"```tool_call\s*(\{.*?\})\s*```", re.DOTALL),
45
- ]
46
-
47
-
48
- def encode_tool_call(name: str, arguments: dict[str, Any]) -> str:
49
- """Render a tool call in the form the client parses back out of the stream.
50
-
51
- For serve apps whose upstream reports tool calls as structured data rather
52
- than as text the model generated - they are re-encoded here so one client
53
- handles every completion app."""
54
- return f'<tool_call>{json.dumps({"name": name, "arguments": arguments})}</tool_call>'
55
-
56
-
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."""
63
- earliest = None
64
- for opener in openers:
65
- idx = text.find(opener)
66
- if idx != -1 and (earliest is None or idx < earliest):
67
- earliest = idx
68
- return earliest
69
-
70
-
71
- def _split_at_potential_prefix(text: str, openers: Sequence[str]) -> tuple[str, str]:
72
- """Split text into (safe_to_stream, potential_opener_prefix).
73
-
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.
76
- """
77
- for opener in openers:
78
- for length in range(1, len(opener)):
79
- if text.endswith(opener[:length]):
80
- return text[:-length], text[-length:]
81
- return text, ""
82
-
83
-
84
- def parse_tool_calls(
85
- text: str, tool_map: dict[str, Callable[..., Any]]
86
- ) -> tuple[str, list[ToolCall]]:
87
- """Parse tool calls from generated text and bind them to their functions."""
88
- tool_calls: list[ToolCall] = []
89
- clean_text = text
90
-
91
- for pattern in TOOL_CALL_PATTERNS:
92
- matches = pattern.findall(text)
93
- for match in matches:
94
- try:
95
- data = json.loads(match)
96
- name = data["name"]
97
- arguments = data.get("arguments", {})
98
- if name in tool_map:
99
- tool_calls.append(
100
- ToolCall(
101
- id=f"call_{next(_tool_call_id_counter)}",
102
- name=name,
103
- arguments=arguments,
104
- _func=partial(tool_map[name], **arguments),
105
- )
106
- )
107
- except (json.JSONDecodeError, KeyError):
108
- continue
109
- clean_text = pattern.sub("", clean_text)
110
-
111
- return clean_text.strip(), tool_calls
112
-
113
-
114
- @dataclass
115
- class ServedCompletingModel(CompletingModel):
116
- """Client for a completion app cortexgrid has deployed at `url`.
117
-
118
- Provider-agnostic: every completion serve app speaks the protocol above, so
119
- which model is behind it only shows up in `model_id`."""
120
-
121
- url: str
122
- model_id: str
123
-
124
- @property
125
- def name(self) -> str:
126
- return self.model_id
127
-
128
- async def complete(
129
- self,
130
- messages: list[Message],
131
- tools: Sequence[Tool] | None = None,
132
- max_new_tokens: int = 16 * 1024,
133
- temperature: float = 0.7,
134
- top_p: float = 0.9,
135
- top_k: int = 50,
136
- repetition_penalty: float = 1.0,
137
- **kwargs: Any,
138
- ) -> AsyncIterator[CompletionChunk]:
139
- tool_specs = normalize_tools(tools)
140
- tool_map = build_tool_map(tools)
141
-
142
- body: dict[str, Any] = {
143
- "messages": messages,
144
- "tools": tool_specs,
145
- "max_new_tokens": max_new_tokens,
146
- "temperature": temperature,
147
- "top_p": top_p,
148
- "top_k": top_k,
149
- "repetition_penalty": repetition_penalty,
150
- **kwargs,
151
- }
152
-
153
- pending = ""
154
- in_thinking = False
155
- in_tool_call = False
156
- tool_call_text = ""
157
- openers = [THINKING_OPENER, *TOOL_CALL_OPENERS]
158
-
159
- async with httpx.AsyncClient(timeout=None) as client:
160
- async with client.stream(
161
- "POST", f"{self.url}/complete", json=body
162
- ) as response:
163
- response.raise_for_status()
164
- async for text in response.aiter_text():
165
- if not text:
166
- continue
167
-
168
- if in_tool_call:
169
- tool_call_text += text
170
- continue
171
-
172
- pending += text
173
-
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
-
197
- before = pending[:opener_pos]
198
- if before:
199
- yield CompletionChunk(content=before)
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:
211
- yield CompletionChunk(content=pending)
212
-
213
- if tool_call_text:
214
- clean_text, tool_calls = parse_tool_calls(tool_call_text, tool_map)
215
- if clean_text:
216
- yield CompletionChunk(content=clean_text)
217
- if tool_calls:
218
- yield CompletionChunk(tool_calls=tool_calls)
219
- else:
220
- yield CompletionChunk(finish_reason="stop")
221
- else:
222
- yield CompletionChunk(finish_reason="stop")