cortexgrid-infer 0.3.6__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.6 → cortexgrid_infer-0.3.7}/PKG-INFO +1 -1
  2. {cortexgrid_infer-0.3.6 → cortexgrid_infer-0.3.7}/cortexgrid_infer/core.py +21 -0
  3. cortexgrid_infer-0.3.7/cortexgrid_infer/protocols/completion.py +92 -0
  4. {cortexgrid_infer-0.3.6 → cortexgrid_infer-0.3.7}/cortexgrid_infer/serve_apps/anthropic.py +20 -8
  5. {cortexgrid_infer-0.3.6 → cortexgrid_infer-0.3.7}/cortexgrid_infer/serve_apps/gemini/text2text.py +24 -13
  6. cortexgrid_infer-0.3.7/cortexgrid_infer/serve_apps/streamed_completion.py +165 -0
  7. {cortexgrid_infer-0.3.6 → cortexgrid_infer-0.3.7}/cortexgrid_infer/serve_apps/text2text.py +143 -6
  8. {cortexgrid_infer-0.3.6 → cortexgrid_infer-0.3.7}/pyproject.toml +1 -1
  9. cortexgrid_infer-0.3.6/cortexgrid_infer/protocols/completion.py +0 -222
  10. {cortexgrid_infer-0.3.6 → cortexgrid_infer-0.3.7}/.gitignore +0 -0
  11. {cortexgrid_infer-0.3.6 → cortexgrid_infer-0.3.7}/LICENSE +0 -0
  12. {cortexgrid_infer-0.3.6 → cortexgrid_infer-0.3.7}/README.md +0 -0
  13. {cortexgrid_infer-0.3.6 → cortexgrid_infer-0.3.7}/cortexgrid_infer/__init__.py +0 -0
  14. {cortexgrid_infer-0.3.6 → cortexgrid_infer-0.3.7}/cortexgrid_infer/compiling.py +0 -0
  15. {cortexgrid_infer-0.3.6 → cortexgrid_infer-0.3.7}/cortexgrid_infer/device.py +0 -0
  16. {cortexgrid_infer-0.3.6 → cortexgrid_infer-0.3.7}/cortexgrid_infer/importers/__init__.py +0 -0
  17. {cortexgrid_infer-0.3.6 → cortexgrid_infer-0.3.7}/cortexgrid_infer/importers/base.py +0 -0
  18. {cortexgrid_infer-0.3.6 → cortexgrid_infer-0.3.7}/cortexgrid_infer/importers/huggingface.py +0 -0
  19. {cortexgrid_infer-0.3.6 → cortexgrid_infer-0.3.7}/cortexgrid_infer/protocols/__init__.py +0 -0
  20. {cortexgrid_infer-0.3.6 → cortexgrid_infer-0.3.7}/cortexgrid_infer/protocols/imaging.py +0 -0
  21. {cortexgrid_infer-0.3.6 → cortexgrid_infer-0.3.7}/cortexgrid_infer/protocols/meshing.py +0 -0
  22. {cortexgrid_infer-0.3.6 → cortexgrid_infer-0.3.7}/cortexgrid_infer/protocols/rewriting.py +0 -0
  23. {cortexgrid_infer-0.3.6 → cortexgrid_infer-0.3.7}/cortexgrid_infer/py.typed +0 -0
  24. {cortexgrid_infer-0.3.6 → cortexgrid_infer-0.3.7}/cortexgrid_infer/registry.py +0 -0
  25. {cortexgrid_infer-0.3.6 → cortexgrid_infer-0.3.7}/cortexgrid_infer/serve_apps/__init__.py +0 -0
  26. {cortexgrid_infer-0.3.6 → cortexgrid_infer-0.3.7}/cortexgrid_infer/serve_apps/base.py +0 -0
  27. {cortexgrid_infer-0.3.6 → cortexgrid_infer-0.3.7}/cortexgrid_infer/serve_apps/gemini/__init__.py +0 -0
  28. {cortexgrid_infer-0.3.6 → cortexgrid_infer-0.3.7}/cortexgrid_infer/serve_apps/gemini/base.py +0 -0
  29. {cortexgrid_infer-0.3.6 → cortexgrid_infer-0.3.7}/cortexgrid_infer/serve_apps/gemini/text2image.py +0 -0
  30. {cortexgrid_infer-0.3.6 → cortexgrid_infer-0.3.7}/cortexgrid_infer/serve_apps/image2mesh.py +0 -0
  31. {cortexgrid_infer-0.3.6 → cortexgrid_infer-0.3.7}/cortexgrid_infer/serve_apps/text2image.py +0 -0
  32. {cortexgrid_infer-0.3.6 → cortexgrid_infer-0.3.7}/cortexgrid_infer/serve_apps/text_rewriter.py +0 -0
  33. {cortexgrid_infer-0.3.6 → 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.6
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
@@ -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
 
@@ -162,19 +163,30 @@ class AnthropicText2Text(HostedModel):
162
163
  create_kwargs["tools"] = tools
163
164
 
164
165
  async def stream() -> AsyncIterator[bytes]:
166
+ writer = StreamedCompletionWriter()
165
167
  async with self._client.messages.stream(**create_kwargs) as events:
166
168
  async for event in events:
167
169
  if (
168
170
  event.type == "content_block_delta"
169
171
  and event.delta.type == "text_delta"
170
172
  ):
171
- yield event.delta.text.encode("utf-8")
173
+ line = writer.read_content(event.delta.text)
174
+ yield line
172
175
 
173
176
  elif event.type == "content_block_stop":
174
177
  block = events.current_message_snapshot.content[event.index]
175
178
  if block.type == "tool_use":
176
- yield encode_tool_call(
177
- block.name, dict(block.input)
178
- ).encode("utf-8")
179
-
180
- 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
+ )
@@ -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
+ )
@@ -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
@@ -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
@@ -76,15 +130,15 @@ class Text2Text(LocalModel):
76
130
  path = cortexgrid.load_model(
77
131
  deployment.family, deployment.suffix, deployment.run_name
78
132
  )
133
+ settings = cortexgrid.model_config(deployment)
79
134
  self._device = detect_device()
80
135
  self._tokenizer = AutoTokenizer.from_pretrained(path)
81
136
  if self._tokenizer.pad_token is None:
82
137
  self._tokenizer.pad_token = self._tokenizer.eos_token
83
138
  self._model = AutoModelForCausalLM.from_pretrained(
84
- str(path), torch_dtype=torch.float16
139
+ str(path), torch_dtype=getattr(torch, settings.get(DTYPE_PARAM, DEFAULT_DTYPE))
85
140
  )
86
141
  self._model.to(self._device) # type: ignore
87
- settings = cortexgrid.model_config(deployment)
88
142
  context = getattr(
89
143
  self._model.config, "max_position_embeddings", DEFAULT_MAX_TOTAL_TOKENS
90
144
  )
@@ -127,6 +181,82 @@ class Text2Text(LocalModel):
127
181
  inputs[key] = value[..., -limit:]
128
182
  return inputs
129
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
+
130
260
  @_app.post("/complete")
131
261
  async def complete(self, body: dict[str, Any]) -> StreamingResponse:
132
262
  messages = body["messages"]
@@ -138,6 +268,7 @@ class Text2Text(LocalModel):
138
268
  top_p = body.get("top_p", 0.9)
139
269
  top_k = body.get("top_k", 50)
140
270
  repetition_penalty = body.get("repetition_penalty", 1.0)
271
+ choices = body.get("choices")
141
272
  extra = {k: v for k, v in body.items() if k not in _RESERVED_BODY_KEYS}
142
273
 
143
274
  prompt = self._tokenizer.apply_chat_template(
@@ -151,6 +282,9 @@ class Text2Text(LocalModel):
151
282
  self._tokenizer(prompt, return_tensors="pt"),
152
283
  self._max_total_tokens - max_new_tokens,
153
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)
154
288
 
155
289
  queue: asyncio.Queue[str | None] = asyncio.Queue()
156
290
  error: list[BaseException] = []
@@ -192,13 +326,16 @@ class Text2Text(LocalModel):
192
326
  loop.run_in_executor(None, generate)
193
327
 
194
328
  async def stream() -> AsyncIterator[bytes]:
329
+ writer = StreamedCompletionWriter()
195
330
  while True:
196
331
  text = await queue.get()
197
332
  if text is None:
198
333
  break
199
- if text:
200
- yield text.encode("utf-8")
334
+ for line in writer.read(text):
335
+ yield line
201
336
  if error:
202
337
  raise error[0]
338
+ for line in writer.finish():
339
+ yield line
203
340
 
204
- return StreamingResponse(stream(), media_type="text/plain")
341
+ return StreamingResponse(stream(), media_type="application/x-ndjson")
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "cortexgrid-infer"
3
- version = "0.3.6"
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"
@@ -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")