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.
- {cortexgrid_infer-0.3.5 → cortexgrid_infer-0.3.7}/PKG-INFO +15 -11
- {cortexgrid_infer-0.3.5 → cortexgrid_infer-0.3.7}/README.md +13 -9
- {cortexgrid_infer-0.3.5 → cortexgrid_infer-0.3.7}/cortexgrid_infer/core.py +21 -0
- cortexgrid_infer-0.3.7/cortexgrid_infer/protocols/completion.py +92 -0
- {cortexgrid_infer-0.3.5 → cortexgrid_infer-0.3.7}/cortexgrid_infer/serve_apps/anthropic.py +25 -12
- {cortexgrid_infer-0.3.5 → cortexgrid_infer-0.3.7}/cortexgrid_infer/serve_apps/base.py +3 -3
- {cortexgrid_infer-0.3.5 → cortexgrid_infer-0.3.7}/cortexgrid_infer/serve_apps/gemini/base.py +4 -4
- {cortexgrid_infer-0.3.5 → cortexgrid_infer-0.3.7}/cortexgrid_infer/serve_apps/gemini/text2text.py +24 -13
- {cortexgrid_infer-0.3.5 → cortexgrid_infer-0.3.7}/cortexgrid_infer/serve_apps/image2mesh.py +4 -3
- cortexgrid_infer-0.3.7/cortexgrid_infer/serve_apps/streamed_completion.py +165 -0
- {cortexgrid_infer-0.3.5 → cortexgrid_infer-0.3.7}/cortexgrid_infer/serve_apps/text2image.py +3 -3
- {cortexgrid_infer-0.3.5 → cortexgrid_infer-0.3.7}/cortexgrid_infer/serve_apps/text2text.py +147 -8
- {cortexgrid_infer-0.3.5 → cortexgrid_infer-0.3.7}/cortexgrid_infer/serve_apps/text_rewriter.py +3 -3
- {cortexgrid_infer-0.3.5 → cortexgrid_infer-0.3.7}/pyproject.toml +4 -2
- cortexgrid_infer-0.3.5/cortexgrid_infer/protocols/completion.py +0 -222
- {cortexgrid_infer-0.3.5 → cortexgrid_infer-0.3.7}/.gitignore +0 -0
- {cortexgrid_infer-0.3.5 → cortexgrid_infer-0.3.7}/LICENSE +0 -0
- {cortexgrid_infer-0.3.5 → cortexgrid_infer-0.3.7}/cortexgrid_infer/__init__.py +0 -0
- {cortexgrid_infer-0.3.5 → cortexgrid_infer-0.3.7}/cortexgrid_infer/compiling.py +0 -0
- {cortexgrid_infer-0.3.5 → cortexgrid_infer-0.3.7}/cortexgrid_infer/device.py +0 -0
- {cortexgrid_infer-0.3.5 → cortexgrid_infer-0.3.7}/cortexgrid_infer/importers/__init__.py +0 -0
- {cortexgrid_infer-0.3.5 → cortexgrid_infer-0.3.7}/cortexgrid_infer/importers/base.py +0 -0
- {cortexgrid_infer-0.3.5 → cortexgrid_infer-0.3.7}/cortexgrid_infer/importers/huggingface.py +0 -0
- {cortexgrid_infer-0.3.5 → cortexgrid_infer-0.3.7}/cortexgrid_infer/protocols/__init__.py +0 -0
- {cortexgrid_infer-0.3.5 → cortexgrid_infer-0.3.7}/cortexgrid_infer/protocols/imaging.py +0 -0
- {cortexgrid_infer-0.3.5 → cortexgrid_infer-0.3.7}/cortexgrid_infer/protocols/meshing.py +0 -0
- {cortexgrid_infer-0.3.5 → cortexgrid_infer-0.3.7}/cortexgrid_infer/protocols/rewriting.py +0 -0
- {cortexgrid_infer-0.3.5 → cortexgrid_infer-0.3.7}/cortexgrid_infer/py.typed +0 -0
- {cortexgrid_infer-0.3.5 → cortexgrid_infer-0.3.7}/cortexgrid_infer/registry.py +0 -0
- {cortexgrid_infer-0.3.5 → cortexgrid_infer-0.3.7}/cortexgrid_infer/serve_apps/__init__.py +0 -0
- {cortexgrid_infer-0.3.5 → cortexgrid_infer-0.3.7}/cortexgrid_infer/serve_apps/gemini/__init__.py +0 -0
- {cortexgrid_infer-0.3.5 → cortexgrid_infer-0.3.7}/cortexgrid_infer/serve_apps/gemini/text2image.py +0 -0
- {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.
|
|
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.
|
|
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(
|
|
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
|
|
183
|
-
`compile: "true"`. Each `LocalModel` writes `compile: "false"` onto the card
|
|
184
|
-
import, so turning it on is
|
|
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
|
-
|
|
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
|
|
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`
|
|
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(
|
|
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
|
|
155
|
-
`compile: "true"`. Each `LocalModel` writes `compile: "false"` onto the card
|
|
156
|
-
import, so turning it on is
|
|
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
|
-
|
|
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
|
|
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`
|
|
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
|
|
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,
|
|
135
|
-
config = cortexgrid.model_config(
|
|
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}
|
|
140
|
-
"its config; set them on the
|
|
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
|
-
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
|
35
|
-
# serving pays a warm-up per input shape, which is worth it
|
|
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
|
{cortexgrid_infer-0.3.5 → cortexgrid_infer-0.3.7}/cortexgrid_infer/serve_apps/gemini/base.py
RENAMED
|
@@ -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,
|
|
40
|
-
config = cortexgrid.model_config(
|
|
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
|
|
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]
|
{cortexgrid_infer-0.3.5 → cortexgrid_infer-0.3.7}/cortexgrid_infer/serve_apps/gemini/text2text.py
RENAMED
|
@@ -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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
166
|
+
line = writer.read_thinking(part.text)
|
|
167
|
+
yield line
|
|
167
168
|
|
|
168
169
|
elif part.text:
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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,
|
|
57
|
+
def __init__(self, deployment: cortexgrid.DeploymentKey) -> None:
|
|
58
58
|
self.device = detect_device()
|
|
59
|
-
|
|
60
|
-
|
|
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,
|
|
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(
|
|
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,
|
|
76
|
-
path = cortexgrid.load_model(
|
|
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.
|
|
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
|
-
|
|
198
|
-
yield
|
|
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="
|
|
341
|
+
return StreamingResponse(stream(), media_type="application/x-ndjson")
|
{cortexgrid_infer-0.3.5 → cortexgrid_infer-0.3.7}/cortexgrid_infer/serve_apps/text_rewriter.py
RENAMED
|
@@ -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,
|
|
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(
|
|
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.
|
|
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
|
-
|
|
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")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{cortexgrid_infer-0.3.5 → cortexgrid_infer-0.3.7}/cortexgrid_infer/serve_apps/gemini/__init__.py
RENAMED
|
File without changes
|
{cortexgrid_infer-0.3.5 → cortexgrid_infer-0.3.7}/cortexgrid_infer/serve_apps/gemini/text2image.py
RENAMED
|
File without changes
|
|
File without changes
|