cortexgrid-infer 0.1.10__py3-none-any.whl

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.
@@ -0,0 +1,68 @@
1
+ from cortexgrid_infer.core import (
2
+ CompletionChunk,
3
+ DeployedModel,
4
+ CompletingModel,
5
+ GeneratedImage,
6
+ GeneratingModel,
7
+ Message,
8
+ Tool,
9
+ ToolCall,
10
+ ToolSpec,
11
+ complete,
12
+ delete_model,
13
+ deploy_model,
14
+ deployment_status,
15
+ generate,
16
+ register_deleter,
17
+ register_provider,
18
+ register_status_provider,
19
+ register_uploader,
20
+ upload_model,
21
+ )
22
+ from cortexgrid_infer.device import detect_device
23
+ from cortexgrid_infer.providers.anthropic import deploy_anthropic, AnthropicModel
24
+ from cortexgrid_infer.providers.huggingface_complete import (
25
+ delete_huggingface,
26
+ deploy_huggingface,
27
+ upload_huggingface,
28
+ HuggingFaceCompletingModel,
29
+ )
30
+ from cortexgrid_infer.providers.huggingface_image import (
31
+ delete_huggingface_image,
32
+ deploy_huggingface_image,
33
+ upload_huggingface_image,
34
+ HuggingFaceImageModel,
35
+ )
36
+
37
+ __all__ = [
38
+ "CompletionChunk",
39
+ "DeployedModel",
40
+ "CompletingModel",
41
+ "GeneratedImage",
42
+ "GeneratingModel",
43
+ "Message",
44
+ "Tool",
45
+ "ToolCall",
46
+ "ToolSpec",
47
+ "complete",
48
+ "deploy_model",
49
+ "deployment_status",
50
+ "upload_model",
51
+ "delete_model",
52
+ "generate",
53
+ "detect_device",
54
+ "register_provider",
55
+ "register_status_provider",
56
+ "register_uploader",
57
+ "register_deleter",
58
+ "deploy_anthropic",
59
+ "AnthropicModel",
60
+ "deploy_huggingface",
61
+ "upload_huggingface",
62
+ "delete_huggingface",
63
+ "HuggingFaceCompletingModel",
64
+ "deploy_huggingface_image",
65
+ "upload_huggingface_image",
66
+ "delete_huggingface_image",
67
+ "HuggingFaceImageModel",
68
+ ]
@@ -0,0 +1,238 @@
1
+ """DeployedModel ABC, provider registry, and completion API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import abc
6
+ from dataclasses import dataclass, field
7
+ from functools import partial
8
+ from collections.abc import AsyncIterator
9
+ from typing import (
10
+ Any,
11
+ Callable,
12
+ Sequence,
13
+ TypeVar,
14
+ )
15
+
16
+
17
+ @dataclass
18
+ class ToolCall:
19
+ id: str
20
+ name: str
21
+ arguments: dict[str, Any]
22
+ _func: partial[Any] = field(repr=False)
23
+
24
+ def __call__(self) -> Any:
25
+ return self._func(**self.arguments)
26
+
27
+
28
+ @dataclass
29
+ class CompletionChunk:
30
+ content: str = ""
31
+ tool_calls: list[ToolCall] = field(default_factory=list)
32
+ finish_reason: str | None = None
33
+
34
+ @property
35
+ def has_text(self) -> bool:
36
+ return bool(self.content)
37
+
38
+ @property
39
+ def has_tool_calls(self) -> bool:
40
+ return bool(self.tool_calls)
41
+
42
+
43
+ Message = dict[str, Any]
44
+ ToolSpec = dict[str, Any]
45
+ Tool = Callable[..., Any] | ToolSpec
46
+
47
+
48
+ class DeployedModel(abc.ABC):
49
+ @property
50
+ @abc.abstractmethod
51
+ def name(self) -> str: ...
52
+
53
+ def undeploy(self) -> None:
54
+ """Release the underlying deployment, if this provider manages one.
55
+
56
+ The caller owns the model's lifecycle and calls this to tear it down
57
+ (e.g. on server shutdown). Hosted-API providers (e.g. Anthropic) have
58
+ nothing to release, so the default is a no-op; cluster-backed providers
59
+ override it to free their compute."""
60
+
61
+
62
+ class CompletingModel(DeployedModel):
63
+ @abc.abstractmethod
64
+ def complete(
65
+ self,
66
+ messages: list[Message],
67
+ tools: Sequence[Tool] | None = None,
68
+ max_new_tokens: int = 2048,
69
+ temperature: float = 0.7,
70
+ **kwargs: Any,
71
+ ) -> AsyncIterator[CompletionChunk]: ...
72
+
73
+
74
+ @dataclass
75
+ class GeneratedImage:
76
+ """One generated image: the encoded file bytes (PNG) plus the resolved
77
+ parameters the deployment used to produce it."""
78
+
79
+ image: bytes
80
+ width: int
81
+ height: int
82
+ params: dict[str, Any] = field(default_factory=dict)
83
+
84
+
85
+ class GeneratingModel(DeployedModel):
86
+ """A deployed image model. Unlike completion, generation is a single
87
+ request/response (no token streaming), so `generate` returns one result."""
88
+
89
+ @abc.abstractmethod
90
+ async def generate(
91
+ self,
92
+ prompt: str,
93
+ *,
94
+ image: bytes | None = None,
95
+ steps: int | None = None,
96
+ guidance: float | None = None,
97
+ size: int = 1024,
98
+ seed: int | None = None,
99
+ **kwargs: Any,
100
+ ) -> GeneratedImage: ...
101
+
102
+
103
+ _providers: list[tuple[str, Callable[[str], DeployedModel | None]]] = []
104
+
105
+ T = TypeVar("T", bound=DeployedModel)
106
+
107
+
108
+ def register_provider(
109
+ prefix: str, factory: Callable[[str], DeployedModel | None]
110
+ ) -> None:
111
+ """Register a model provider that handles model IDs starting with *prefix*."""
112
+ _providers.append((prefix, factory))
113
+
114
+
115
+ def deploy_model(model_id: str) -> DeployedModel:
116
+ for prefix, factory in _providers:
117
+ if not model_id.startswith(prefix):
118
+ continue
119
+ model = factory(model_id)
120
+ if model is None:
121
+ continue
122
+ return model
123
+ raise ValueError(
124
+ f"No provider registered for model '{model_id}'. "
125
+ f"Known prefixes: {[p for p, _ in _providers]}"
126
+ )
127
+
128
+
129
+ # Status providers mirror deploy providers: a prefix -> a function that reports the
130
+ # live deployment status for that model id (delegating to the platform, e.g.
131
+ # cortexgrid.model_serving_status). Kept separate from _providers because a status
132
+ # query must NOT construct/deploy anything - it just reads current state.
133
+ _status_providers: list[tuple[str, Callable[[str], Any]]] = []
134
+
135
+
136
+ def register_status_provider(prefix: str, fn: Callable[[str], Any]) -> None:
137
+ """Register a deployment-status reporter for model IDs starting with *prefix*."""
138
+ _status_providers.append((prefix, fn))
139
+
140
+
141
+ def deployment_status(model_id: str) -> Any:
142
+ """Report the current deployment status/phase for *model_id*, or None if no
143
+ status provider handles it (e.g. hosted-API models with nothing to schedule).
144
+ The returned object is whatever the platform reports - for cluster-backed
145
+ providers, a ``cortexgrid.ServingStatus`` (``phase`` + ``message``) once a
146
+ Serve app exists, or a ``cortexgrid.SavedModel`` while the weights are still
147
+ uploading to the registry."""
148
+ for prefix, fn in _status_providers:
149
+ if not model_id.startswith(prefix):
150
+ continue
151
+ result = fn(model_id)
152
+ if result is not None:
153
+ return result
154
+ return None
155
+
156
+
157
+ # Uploaders mirror deploy/status providers: a prefix -> a function that STARTS an
158
+ # ingest of the model's weights into the cortexgrid registry and returns the id of
159
+ # the background job doing it, or None when there is nothing to upload (already
160
+ # registered, or a hosted-API model with no weights). This is deliberately split
161
+ # from deploy: a model must be uploaded (registry phase `ready`) before
162
+ # `deploy_model` can schedule it, and the upload runs on the cluster - not the
163
+ # caller's machine - so large weights never round-trip through the client.
164
+ _uploaders: list[tuple[str, Callable[[str], str | None]]] = []
165
+
166
+
167
+ def register_uploader(prefix: str, fn: Callable[[str], str | None]) -> None:
168
+ """Register a registry-upload starter for model IDs starting with *prefix*."""
169
+ _uploaders.append((prefix, fn))
170
+
171
+
172
+ def upload_model(model_id: str) -> str | None:
173
+ """Start uploading *model_id*'s weights into the cortexgrid registry, returning
174
+ the id of the background job doing it, or None if nothing needs uploading
175
+ (already registered, or a hosted-API model with no weights to stage).
176
+
177
+ The upload runs on the cluster, not the caller's machine. Poll its progress
178
+ with ``deployment_status(model_id)`` (registry phase `uploading -> ready`);
179
+ once `ready`, call ``deploy_model(model_id)``."""
180
+ for prefix, fn in _uploaders:
181
+ if model_id.startswith(prefix):
182
+ return fn(model_id)
183
+ return None
184
+
185
+
186
+ # Deleters mirror the other registries: a prefix -> a function that removes a model
187
+ # from the platform (undeploy its Serve app if running, then delete its weights +
188
+ # serve bundle from the registry). The inverse of upload+deploy; kept separate so a
189
+ # caller can tear a model down by id without holding a deployed-model handle.
190
+ _deleters: list[tuple[str, Callable[[str], None]]] = []
191
+
192
+
193
+ def register_deleter(prefix: str, fn: Callable[[str], None]) -> None:
194
+ """Register a registry-cleanup handler for model IDs starting with *prefix*."""
195
+ _deleters.append((prefix, fn))
196
+
197
+
198
+ def delete_model(model_id: str) -> None:
199
+ """Remove *model_id* from the platform: undeploy its Serve app if running, then
200
+ delete its weights and serve bundle from the registry.
201
+
202
+ Idempotent - safe whether or not the model is currently deployed or registered -
203
+ and a no-op for model ids no deleter handles (e.g. hosted-API models, which have
204
+ nothing on the cluster to clean up)."""
205
+ for prefix, fn in _deleters:
206
+ if model_id.startswith(prefix):
207
+ fn(model_id)
208
+ return
209
+
210
+
211
+ async def complete(
212
+ deployed_model: CompletingModel,
213
+ messages: list[Message],
214
+ tools: Sequence[Tool] | None = None,
215
+ max_new_tokens: int = 2048,
216
+ temperature: float = 0.7,
217
+ **kwargs: Any,
218
+ ) -> AsyncIterator[CompletionChunk]:
219
+ """Async completion request to the deployed model."""
220
+ async for chunk in deployed_model.complete(
221
+ messages,
222
+ tools,
223
+ max_new_tokens=max_new_tokens,
224
+ temperature=temperature,
225
+ **kwargs,
226
+ ):
227
+ yield chunk
228
+
229
+
230
+ async def generate(
231
+ deployed_model: GeneratingModel,
232
+ prompt: str,
233
+ *,
234
+ image: bytes | None = None,
235
+ **kwargs: Any,
236
+ ) -> GeneratedImage:
237
+ """Single image-generation request to the deployed model."""
238
+ return await deployed_model.generate(prompt, image=image, **kwargs)
@@ -0,0 +1,14 @@
1
+ """Device detection for PyTorch workloads."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import torch
6
+
7
+
8
+ def detect_device() -> torch.device:
9
+ """Return the best available accelerator (CUDA > MPS > CPU)."""
10
+ if torch.cuda.is_available():
11
+ return torch.device("cuda")
12
+ if torch.backends.mps.is_available():
13
+ return torch.device("mps")
14
+ return torch.device("cpu")
File without changes
@@ -0,0 +1,186 @@
1
+ """Anthropic model deployment with streaming support."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from functools import partial
7
+ from collections.abc import AsyncIterator
8
+ from typing import Any, Sequence
9
+
10
+ from cortexgrid_infer.core import (
11
+ CompletingModel,
12
+ Message,
13
+ Tool,
14
+ ToolCall,
15
+ ToolSpec,
16
+ CompletionChunk,
17
+ register_provider,
18
+ )
19
+ from cortexgrid_infer.utils import build_tool_map, normalize_tools
20
+
21
+ from anthropic import AsyncAnthropic, Anthropic, NotFoundError
22
+ from dotenv import load_dotenv
23
+
24
+
25
+ _MODEL_PROVIDER_PREFIX = "Anthropic/"
26
+
27
+
28
+ def _to_anthropic_messages(
29
+ messages: list[Message],
30
+ ) -> tuple[str | None, list[dict[str, Any]]]:
31
+ """Convert OpenAI-style messages to Anthropic format."""
32
+ system_prompt: str | None = None
33
+ anthropic_messages: list[dict[str, Any]] = []
34
+
35
+ for msg in messages:
36
+ role = msg["role"]
37
+
38
+ if role == "system":
39
+ system_prompt = msg["content"]
40
+
41
+ elif role == "user":
42
+ anthropic_messages.append({"role": "user", "content": msg["content"]})
43
+
44
+ elif role == "assistant":
45
+ content_blocks: list[dict[str, Any]] = []
46
+ if msg.get("content"):
47
+ content_blocks.append({"type": "text", "text": msg["content"]})
48
+ for tc in msg.get("tool_calls", []):
49
+ func = tc["function"]
50
+ content_blocks.append(
51
+ {
52
+ "type": "tool_use",
53
+ "id": tc["id"],
54
+ "name": func["name"],
55
+ "input": func["arguments"],
56
+ }
57
+ )
58
+ anthropic_messages.append({"role": "assistant", "content": content_blocks})
59
+
60
+ elif role == "tool":
61
+ tool_result_block = {
62
+ "type": "tool_result",
63
+ "tool_use_id": msg["tool_call_id"],
64
+ "content": msg["content"],
65
+ }
66
+ if (
67
+ anthropic_messages
68
+ and anthropic_messages[-1]["role"] == "user"
69
+ and isinstance(anthropic_messages[-1]["content"], list)
70
+ and anthropic_messages[-1]["content"]
71
+ and anthropic_messages[-1]["content"][0].get("type") == "tool_result"
72
+ ):
73
+ anthropic_messages[-1]["content"].append(tool_result_block)
74
+ else:
75
+ anthropic_messages.append(
76
+ {
77
+ "role": "user",
78
+ "content": [tool_result_block],
79
+ }
80
+ )
81
+
82
+ return system_prompt, anthropic_messages
83
+
84
+
85
+ def _to_anthropic_tools(
86
+ tool_specs: list[ToolSpec] | None,
87
+ ) -> list[dict[str, Any]] | None:
88
+ """Convert OpenAI-style tool specs to Anthropic format."""
89
+ if not tool_specs:
90
+ return None
91
+ result = []
92
+ for spec in tool_specs:
93
+ func = spec["function"]
94
+ result.append(
95
+ {
96
+ "name": func["name"],
97
+ "description": func.get("description", ""),
98
+ "input_schema": func.get(
99
+ "parameters", {"type": "object", "properties": {}}
100
+ ),
101
+ }
102
+ )
103
+ return result
104
+
105
+
106
+ @dataclass
107
+ class AnthropicModel(CompletingModel):
108
+ client: Any
109
+ model_id: str
110
+
111
+ @property
112
+ def name(self) -> str:
113
+ return self.model_id
114
+
115
+ async def complete(
116
+ self,
117
+ messages: list[Message],
118
+ tools: Sequence[Tool] | None = None,
119
+ max_new_tokens: int = 16 * 1024,
120
+ temperature: float = 0.7,
121
+ **kwargs: Any,
122
+ ) -> AsyncIterator[CompletionChunk]:
123
+ tool_specs = normalize_tools(tools)
124
+ tool_map = build_tool_map(tools)
125
+ anthropic_tools = _to_anthropic_tools(tool_specs)
126
+
127
+ system_prompt, anthropic_messages = _to_anthropic_messages(messages)
128
+
129
+ create_kwargs: dict[str, Any] = {
130
+ "model": self.model_id,
131
+ "messages": anthropic_messages,
132
+ "max_tokens": max_new_tokens,
133
+ "temperature": temperature,
134
+ }
135
+ if system_prompt:
136
+ create_kwargs["system"] = system_prompt
137
+ if anthropic_tools:
138
+ create_kwargs["tools"] = anthropic_tools
139
+
140
+ async with self.client.messages.stream(**create_kwargs) as stream:
141
+ async for event in stream:
142
+ if event.type == "content_block_delta":
143
+ if event.delta.type == "text_delta":
144
+ yield CompletionChunk(content=event.delta.text)
145
+
146
+ elif event.type == "content_block_stop":
147
+ block = stream.current_message_snapshot.content[event.index]
148
+ if block.type == "tool_use":
149
+ func = tool_map.get(block.name)
150
+ tc = ToolCall(
151
+ id=block.id,
152
+ name=block.name,
153
+ arguments=dict(block.input),
154
+ _func=(
155
+ partial(func, **block.input)
156
+ if func
157
+ else partial(lambda: None)
158
+ ),
159
+ )
160
+ yield CompletionChunk(tool_calls=[tc])
161
+
162
+ elif event.type == "message_stop":
163
+ stop_reason = stream.current_message_snapshot.stop_reason
164
+ if stop_reason == "end_turn":
165
+ yield CompletionChunk(finish_reason="stop")
166
+
167
+
168
+ def _is_valid_anthropic_model(model_id: str) -> bool:
169
+ try:
170
+ Anthropic().models.retrieve(model_id)
171
+ return True
172
+ except NotFoundError:
173
+ return False
174
+
175
+
176
+ def deploy_anthropic(model_id: str) -> AnthropicModel | None:
177
+ load_dotenv()
178
+ actual_model_id = model_id.removeprefix(_MODEL_PROVIDER_PREFIX)
179
+ if not _is_valid_anthropic_model(actual_model_id):
180
+ return None
181
+
182
+ client = AsyncAnthropic()
183
+ return AnthropicModel(client=client, model_id=actual_model_id)
184
+
185
+
186
+ register_provider(_MODEL_PROVIDER_PREFIX, deploy_anthropic)