viento 0.2.0__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.
viento/__init__.py ADDED
@@ -0,0 +1,34 @@
1
+ """
2
+ Zephyr SDK — Distributed AI Inference Runtime
3
+
4
+ Connect your local LLMs (Ollama, llama.cpp, vLLM) to the Zephyr Cloud
5
+ mesh and serve distributed inference jobs via the OpenAI-compatible API.
6
+
7
+ Quick Start:
8
+ >>> from viento.client.client import VientoClient
9
+ >>> client = VientoClient(api_key="zph_tmp_...")
10
+ >>> response = client.chat.completions.create(
11
+ ... model="llama3:latest",
12
+ ... messages=[{"role": "user", "content": "Hello!"}],
13
+ ... )
14
+
15
+ Run as a node:
16
+ $ viento run
17
+
18
+ Documentation: https://github.com/abhinav00anand/viento
19
+ """
20
+
21
+ __version__ = "0.2.0"
22
+ __author__ = "Zephyr Cloud Team"
23
+ __email__ = "indrohelpdesk@gmail.com"
24
+ __license__ = "MIT"
25
+ __url__ = "https://github.com/abhinav00anand/viento"
26
+
27
+ from viento.client.client import AsyncVientoClient, VientoClient
28
+
29
+ __all__ = [
30
+ "__version__",
31
+ "__author__",
32
+ "VientoClient",
33
+ "AsyncVientoClient",
34
+ ]
@@ -0,0 +1,45 @@
1
+ """Inference backend adapters for local and cloud LLM runtime engines."""
2
+
3
+ from typing import Optional
4
+ from viento.backends.base import (
5
+ InferenceBackend,
6
+ BackendError,
7
+ BackendOfflineError,
8
+ ModelNotFoundError,
9
+ ContextOverflowError,
10
+ BackendTimeoutError,
11
+ )
12
+ from viento.backends.ollama import OllamaAdapter
13
+ from viento.backends.llamacpp import LlamaCppAdapter
14
+ from viento.backends.vllm import VLLMAdapter
15
+
16
+
17
+ def get_backend_adapter(backend_name: str = "ollama", base_url: Optional[str] = None) -> InferenceBackend:
18
+ """
19
+ Factory function to instantiate the matching backend adapter class
20
+ based on backend_name string ("ollama", "vllm", "llamacpp").
21
+ """
22
+ name = (backend_name or "ollama").strip().lower()
23
+ if name in ("vllm", "vllm_adapter"):
24
+ url = base_url or "http://localhost:8000/v1"
25
+ return VLLMAdapter(base_url=url)
26
+ elif name in ("llamacpp", "llama_cpp", "llama.cpp"):
27
+ url = base_url or "http://localhost:8080"
28
+ return LlamaCppAdapter(base_url=url)
29
+ else:
30
+ url = base_url or "http://localhost:11434"
31
+ return OllamaAdapter(base_url=url)
32
+
33
+
34
+ __all__ = [
35
+ "InferenceBackend",
36
+ "BackendError",
37
+ "BackendOfflineError",
38
+ "ModelNotFoundError",
39
+ "ContextOverflowError",
40
+ "BackendTimeoutError",
41
+ "OllamaAdapter",
42
+ "LlamaCppAdapter",
43
+ "VLLMAdapter",
44
+ "get_backend_adapter",
45
+ ]
@@ -0,0 +1,122 @@
1
+ """
2
+ Abstract Base Classes for Inference Backends and Execution Handles.
3
+
4
+ Defines the execution handle contract for non-destructive HTTP stream cancellation,
5
+ backend exceptions, and thread-safe compute resource release.
6
+ """
7
+
8
+ from abc import ABC, abstractmethod
9
+ from typing import Any, Callable, Dict, List, Optional, Tuple
10
+ from pydantic import BaseModel
11
+
12
+
13
+ class BackendError(Exception):
14
+ """Base exception for inference backend errors."""
15
+ pass
16
+
17
+
18
+ class BackendOfflineError(BackendError):
19
+ """Raised when the backend server is unreachable or offline."""
20
+ pass
21
+
22
+
23
+ class BackendTimeoutError(BackendError):
24
+ """Raised when request times out on backend."""
25
+ pass
26
+
27
+
28
+ class ModelNotFoundError(BackendError):
29
+ """Raised when a requested model is not found on the backend."""
30
+ pass
31
+
32
+
33
+ class ContextLengthExceededError(BackendError):
34
+ """Raised when request prompt tokens exceed backend context length limits."""
35
+ pass
36
+
37
+
38
+ ContextOverflowError = ContextLengthExceededError
39
+
40
+
41
+ class ExecutionHandle(ABC):
42
+ """Abstract execution handle wrapping an active inference HTTP response stream."""
43
+
44
+ @abstractmethod
45
+ def cancel(self) -> None:
46
+ """Cancel execution and close underlying HTTP socket connection immediately."""
47
+ pass
48
+
49
+ @abstractmethod
50
+ def is_done(self) -> bool:
51
+ """Check if execution has completed or aborted."""
52
+ pass
53
+
54
+
55
+ class GenerationChunk(BaseModel):
56
+ delta: str
57
+ finish_reason: Optional[str] = None
58
+ index: int = 0
59
+
60
+
61
+ class GenerationResult(BaseModel):
62
+ full_text: str
63
+ prompt_tokens: int = 0
64
+ completion_tokens: int = 0
65
+ total_tokens: int = 0
66
+ finish_reason: str = "stop"
67
+ is_estimated: bool = False
68
+
69
+
70
+ class EmbeddingResult(BaseModel):
71
+ embeddings: List[List[float]]
72
+ prompt_tokens: int = 0
73
+ total_tokens: int = 0
74
+ is_estimated: bool = False
75
+
76
+
77
+ class InferenceBackend(ABC):
78
+ """Abstract base class for all local inference engine adapters (Ollama, vLLM, llama.cpp)."""
79
+
80
+ @abstractmethod
81
+ def name(self) -> str:
82
+ pass
83
+
84
+ @abstractmethod
85
+ def capabilities(self) -> List[str]:
86
+ pass
87
+
88
+ @abstractmethod
89
+ def health(self) -> bool:
90
+ pass
91
+
92
+ @abstractmethod
93
+ def list_models(self) -> List[Dict[str, Any]]:
94
+ pass
95
+
96
+ @abstractmethod
97
+ def generate(
98
+ self,
99
+ job_id: str,
100
+ model: str,
101
+ messages: List[Dict[str, str]],
102
+ temperature: float = 0.7,
103
+ max_tokens: int = 512,
104
+ callback: Optional[Callable[[GenerationChunk], None]] = None,
105
+ stop: Optional[List[str]] = None,
106
+ handle_callback: Optional[Callable[[ExecutionHandle], None]] = None,
107
+ ) -> Tuple[GenerationResult, ExecutionHandle]:
108
+ pass
109
+
110
+ @abstractmethod
111
+ def embeddings(
112
+ self,
113
+ model: str,
114
+ inputs: List[str],
115
+ job_id: Optional[str] = None,
116
+ handle_callback: Optional[Callable[[ExecutionHandle], None]] = None,
117
+ ) -> EmbeddingResult:
118
+ pass
119
+
120
+ @abstractmethod
121
+ def cancel(self, job_id: str) -> None:
122
+ pass
@@ -0,0 +1,242 @@
1
+ """Production llama.cpp REST backend adapter for local llama.cpp server instances."""
2
+
3
+ import json
4
+ import logging
5
+ import threading
6
+ import time
7
+ from typing import Any, Callable, Dict, List, Optional, Tuple, Union
8
+ import httpx
9
+
10
+ from viento.backends.base import (
11
+ BackendError,
12
+ BackendOfflineError,
13
+ BackendTimeoutError,
14
+ ContextOverflowError,
15
+ EmbeddingResult,
16
+ ExecutionHandle,
17
+ GenerationChunk,
18
+ GenerationResult,
19
+ InferenceBackend,
20
+ ModelNotFoundError,
21
+ )
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ class LlamaCppExecutionHandle(ExecutionHandle):
27
+ def __init__(self, job_id: str, response: httpx.Response):
28
+ self.job_id = job_id
29
+ self.response = response
30
+ self._is_done = False
31
+ self._cancelled = False
32
+ self._lock = threading.Lock()
33
+
34
+ def cancel(self) -> None:
35
+ with self._lock:
36
+ if not self._is_done and not self._cancelled:
37
+ self._cancelled = True
38
+ try:
39
+ self.response.close()
40
+ logger.info("Closed HTTP response stream for cancelled job %s", self.job_id)
41
+ except Exception:
42
+ pass
43
+
44
+ def is_done(self) -> bool:
45
+ with self._lock:
46
+ return self._is_done
47
+
48
+ def mark_done(self):
49
+ with self._lock:
50
+ self._is_done = True
51
+
52
+
53
+ class LlamaCppAdapter(InferenceBackend):
54
+ """Adapter connecting to llama.cpp server (default endpoint: http://localhost:8080)."""
55
+
56
+ def __init__(self, base_url: str = "http://localhost:8080", timeout: float = 120.0):
57
+ self.base_url = base_url.rstrip("/")
58
+ self.timeout = timeout
59
+ self._client = httpx.Client(base_url=self.base_url, timeout=self.timeout)
60
+
61
+ def name(self) -> str:
62
+ return "llamacpp"
63
+
64
+ def capabilities(self) -> List[str]:
65
+ return ["chat", "embeddings", "streaming"]
66
+
67
+ def health(self) -> bool:
68
+ try:
69
+ resp = self._client.get("/health")
70
+ if resp.status_code == 200:
71
+ return True
72
+ resp_root = self._client.get("/")
73
+ return resp_root.status_code == 200
74
+ except Exception:
75
+ return False
76
+
77
+ def list_models(self) -> List[Dict[str, Any]]:
78
+ try:
79
+ resp = self._client.get("/v1/models")
80
+ if resp.status_code == 200:
81
+ data = resp.json()
82
+ return data.get("data", [])
83
+
84
+ resp_props = self._client.get("/props")
85
+ if resp_props.status_code == 200:
86
+ props = resp_props.json()
87
+ default_name = props.get("default_generation_settings", {}).get("model", "llama.cpp-model")
88
+ return [{"id": default_name, "object": "model", "owned_by": "llama.cpp"}]
89
+
90
+ return [{"id": "llama.cpp-default", "object": "model"}]
91
+ except Exception as e:
92
+ logger.error("Failed to list llama.cpp models: %s", e)
93
+ return []
94
+
95
+ def generate(
96
+ self,
97
+ job_id: str,
98
+ model: str,
99
+ messages: List[Dict[str, str]],
100
+ temperature: float = 0.7,
101
+ max_tokens: int = 512,
102
+ callback: Optional[Callable[[GenerationChunk], None]] = None,
103
+ stop: Optional[List[str]] = None,
104
+ handle_callback: Optional[Callable[[ExecutionHandle], None]] = None,
105
+ ) -> Tuple[GenerationResult, ExecutionHandle]:
106
+ payload = {
107
+ "model": model,
108
+ "messages": messages,
109
+ "stream": True,
110
+ "temperature": temperature,
111
+ "max_tokens": max_tokens,
112
+ }
113
+ if stop:
114
+ payload["stop"] = stop
115
+
116
+ req = self._client.build_request("POST", "/v1/chat/completions", json=payload)
117
+ res = self._client.send(req, stream=True)
118
+
119
+ if res.status_code != 200:
120
+ res.close()
121
+ raise RuntimeError(f"llama.cpp returned HTTP {res.status_code}: {res.text}")
122
+
123
+ handle = LlamaCppExecutionHandle(job_id, res)
124
+ if handle_callback:
125
+ try:
126
+ handle_callback(handle)
127
+ except Exception:
128
+ pass
129
+
130
+ full_text = ""
131
+ prompt_tokens = 0
132
+ completion_tokens = 0
133
+ total_tokens = 0
134
+ finish_reason = "stop"
135
+ chunk_idx = 0
136
+ is_estimated = False
137
+
138
+ try:
139
+ for line in res.iter_lines():
140
+ if handle._cancelled:
141
+ finish_reason = "cancelled"
142
+ break
143
+ line_str = line.strip()
144
+ if not line_str or not line_str.startswith("data:"):
145
+ continue
146
+ data_str = line_str[5:].strip()
147
+ if data_str == "[DONE]":
148
+ break
149
+
150
+ try:
151
+ chunk = json.loads(data_str)
152
+ except Exception:
153
+ continue
154
+
155
+ choices = chunk.get("choices", [])
156
+ if choices:
157
+ delta = choices[0].get("delta", {}).get("content", "")
158
+ if delta:
159
+ full_text += delta
160
+ if callback:
161
+ callback(GenerationChunk(delta=delta, index=chunk_idx))
162
+ chunk_idx += 1
163
+
164
+ if choices[0].get("finish_reason"):
165
+ finish_reason = choices[0].get("finish_reason")
166
+
167
+ usage = chunk.get("usage")
168
+ if usage:
169
+ prompt_tokens = usage.get("prompt_tokens", 0)
170
+ completion_tokens = usage.get("completion_tokens", 0)
171
+
172
+ finally:
173
+ handle.mark_done()
174
+ res.close()
175
+
176
+ if prompt_tokens == 0 and completion_tokens == 0:
177
+ completion_tokens = chunk_idx
178
+ total_tokens = completion_tokens
179
+ is_estimated = True
180
+
181
+ result = GenerationResult(
182
+ full_text=full_text,
183
+ prompt_tokens=prompt_tokens,
184
+ completion_tokens=completion_tokens,
185
+ total_tokens=total_tokens,
186
+ finish_reason=finish_reason,
187
+ is_estimated=is_estimated,
188
+ )
189
+ return result, handle
190
+
191
+ def embeddings(
192
+ self,
193
+ model: str,
194
+ inputs: List[str],
195
+ job_id: Optional[str] = None,
196
+ handle_callback: Optional[Callable[[ExecutionHandle], None]] = None,
197
+ ) -> EmbeddingResult:
198
+ req = self._client.build_request(
199
+ "POST",
200
+ "/v1/embeddings",
201
+ json={"model": model, "input": inputs},
202
+ )
203
+ res = self._client.send(req, stream=True)
204
+ if res.status_code != 200:
205
+ res.close()
206
+ raise RuntimeError(f"llama.cpp embeddings failed HTTP {res.status_code}")
207
+
208
+ handle = LlamaCppExecutionHandle(job_id or "embedding", res)
209
+ if handle_callback:
210
+ try:
211
+ handle_callback(handle)
212
+ except Exception:
213
+ pass
214
+
215
+ try:
216
+ body_bytes = res.read()
217
+ if handle._cancelled:
218
+ return EmbeddingResult(embeddings=[], prompt_tokens=0, total_tokens=0, is_estimated=False)
219
+ data = json.loads(body_bytes)
220
+ items = data.get("data", [])
221
+ embeddings_list = [item.get("embedding", []) for item in items]
222
+ usage = data.get("usage", {})
223
+ prompt_tokens = usage.get("prompt_tokens", 0)
224
+ total_tokens = usage.get("total_tokens", prompt_tokens)
225
+
226
+ is_estimated = (prompt_tokens == 0)
227
+ if is_estimated:
228
+ prompt_tokens = sum(len(t) // 4 for t in inputs)
229
+ total_tokens = prompt_tokens
230
+
231
+ return EmbeddingResult(
232
+ embeddings=embeddings_list,
233
+ prompt_tokens=prompt_tokens,
234
+ total_tokens=total_tokens,
235
+ is_estimated=is_estimated,
236
+ )
237
+ finally:
238
+ handle.mark_done()
239
+ res.close()
240
+
241
+ def cancel(self, job_id: str) -> None:
242
+ pass
@@ -0,0 +1,249 @@
1
+ """
2
+ Production Adapter for Ollama REST Backend (http://localhost:11434).
3
+
4
+ Supports NDJSON streaming token extraction, ExecutionHandle TCP socket abort cancellation,
5
+ and accurate token count reporting.
6
+ """
7
+
8
+ import json
9
+ import logging
10
+ import threading
11
+ from typing import Any, Callable, Dict, List, Optional, Tuple
12
+ import httpx
13
+
14
+ from viento.backends.base import (
15
+ EmbeddingResult,
16
+ ExecutionHandle,
17
+ GenerationChunk,
18
+ GenerationResult,
19
+ InferenceBackend,
20
+ )
21
+
22
+ logger = logging.getLogger("viento.backends.ollama")
23
+
24
+
25
+ class OllamaExecutionHandle(ExecutionHandle):
26
+ """Execution handle wrapping an active Ollama HTTP streaming response."""
27
+
28
+ def __init__(self, job_id: str, response: httpx.Response):
29
+ self.job_id = job_id
30
+ self.response = response
31
+ self._is_done = False
32
+ self._cancelled = False
33
+ self._lock = threading.Lock()
34
+
35
+ def cancel(self) -> None:
36
+ """Close response stream instantly, aborting underlying TCP socket."""
37
+ with self._lock:
38
+ if not self._is_done and not self._cancelled:
39
+ self._cancelled = True
40
+ try:
41
+ self.response.close()
42
+ logger.info("Closed HTTP response stream for cancelled job %s", self.job_id)
43
+ except Exception as exc:
44
+ logger.warning("Error closing response stream for job %s: %s", self.job_id, exc)
45
+
46
+ def is_done(self) -> bool:
47
+ with self._lock:
48
+ return self._is_done
49
+
50
+ def mark_done(self):
51
+ with self._lock:
52
+ self._is_done = True
53
+
54
+
55
+ class OllamaAdapter(InferenceBackend):
56
+ """Ollama backend adapter interfacing with local Ollama service."""
57
+
58
+ def __init__(self, base_url: str = "http://localhost:11434", timeout: float = 120.0):
59
+ self.base_url = base_url.rstrip("/")
60
+ self.timeout = timeout
61
+ self._client = httpx.Client(base_url=self.base_url, timeout=self.timeout)
62
+
63
+ def name(self) -> str:
64
+ return "ollama"
65
+
66
+ def capabilities(self) -> List[str]:
67
+ return ["chat", "embeddings", "streaming", "model_pull"]
68
+
69
+ def health(self) -> bool:
70
+ try:
71
+ res = self._client.get("/")
72
+ return res.status_code == 200
73
+ except Exception:
74
+ return False
75
+
76
+ def list_models(self) -> List[Dict[str, Any]]:
77
+ try:
78
+ res = self._client.get("/api/tags")
79
+ if res.status_code != 200:
80
+ return []
81
+ data = res.json()
82
+ models = []
83
+ for item in data.get("models", []):
84
+ models.append({
85
+ "id": item.get("name"),
86
+ "name": item.get("name"),
87
+ "status": "ready",
88
+ "backend": "ollama",
89
+ "context_length": 8192,
90
+ "quantization": item.get("details", {}).get("quantization_level", "unknown"),
91
+ "capabilities": ["chat", "streaming", "embeddings"],
92
+ "max_concurrency": 2,
93
+ "active_jobs": 0,
94
+ })
95
+ return models
96
+ except Exception as exc:
97
+ logger.error("Failed to list Ollama models: %s", exc)
98
+ return []
99
+
100
+ def generate(
101
+ self,
102
+ job_id: str,
103
+ model: str,
104
+ messages: List[Dict[str, str]],
105
+ temperature: float = 0.7,
106
+ max_tokens: int = 512,
107
+ callback: Optional[Callable[[GenerationChunk], None]] = None,
108
+ stop: Optional[List[str]] = None,
109
+ handle_callback: Optional[Callable[[ExecutionHandle], None]] = None,
110
+ ) -> Tuple[GenerationResult, ExecutionHandle]:
111
+ payload = {
112
+ "model": model,
113
+ "messages": messages,
114
+ "stream": True,
115
+ "options": {
116
+ "temperature": temperature,
117
+ "num_predict": max_tokens,
118
+ },
119
+ }
120
+ if stop:
121
+ payload["options"]["stop"] = stop
122
+
123
+ req = self._client.build_request("POST", "/api/chat", json=payload)
124
+ res = self._client.send(req, stream=True)
125
+
126
+ if res.status_code != 200:
127
+ res.close()
128
+ raise RuntimeError(f"Ollama returned HTTP {res.status_code}: {res.text}")
129
+
130
+ handle = OllamaExecutionHandle(job_id, res)
131
+ if handle_callback:
132
+ try:
133
+ handle_callback(handle)
134
+ except Exception:
135
+ pass
136
+
137
+ full_text = ""
138
+ prompt_tokens = 0
139
+ completion_tokens = 0
140
+ total_tokens = 0
141
+ finish_reason = "stop"
142
+ chunk_idx = 0
143
+
144
+ try:
145
+ for line in res.iter_lines():
146
+ if handle._cancelled:
147
+ finish_reason = "cancelled"
148
+ break
149
+ if not line.strip():
150
+ continue
151
+
152
+ try:
153
+ data = json.loads(line)
154
+ except Exception:
155
+ continue
156
+
157
+ delta = data.get("message", {}).get("content", "")
158
+ if delta:
159
+ full_text += delta
160
+ if callback:
161
+ callback(GenerationChunk(delta=delta, index=chunk_idx))
162
+ chunk_idx += 1
163
+
164
+ if data.get("done"):
165
+ prompt_tokens = data.get("prompt_eval_count", 0)
166
+ completion_tokens = data.get("eval_count", 0)
167
+ total_tokens = prompt_tokens + completion_tokens
168
+ finish_reason = data.get("done_reason", "stop")
169
+ break
170
+
171
+ finally:
172
+ handle.mark_done()
173
+ res.close()
174
+
175
+ is_estimated = (prompt_tokens == 0 and completion_tokens == 0)
176
+ if is_estimated:
177
+ completion_tokens = chunk_idx
178
+ total_tokens = completion_tokens
179
+
180
+ result = GenerationResult(
181
+ full_text=full_text,
182
+ prompt_tokens=prompt_tokens,
183
+ completion_tokens=completion_tokens,
184
+ total_tokens=total_tokens,
185
+ finish_reason=finish_reason,
186
+ is_estimated=is_estimated,
187
+ )
188
+ return result, handle
189
+
190
+ def embeddings(
191
+ self,
192
+ model: str,
193
+ inputs: List[str],
194
+ job_id: Optional[str] = None,
195
+ handle_callback: Optional[Callable[[ExecutionHandle], None]] = None,
196
+ ) -> EmbeddingResult:
197
+ all_embeddings: List[List[float]] = []
198
+ total_prompt_tokens = 0
199
+ is_estimated = False
200
+
201
+ for input_text in inputs:
202
+ payload = {"model": model, "prompt": input_text}
203
+ req = self._client.build_request("POST", "/api/embeddings", json=payload)
204
+ res = self._client.send(req, stream=True)
205
+
206
+ if res.status_code != 200:
207
+ res.close()
208
+ req = self._client.build_request("POST", "/api/embed", json={"model": model, "input": input_text})
209
+ res = self._client.send(req, stream=True)
210
+ if res.status_code != 200:
211
+ res.close()
212
+ raise RuntimeError(f"Ollama embedding failed HTTP {res.status_code}")
213
+
214
+ handle = OllamaExecutionHandle(job_id or "embedding", res)
215
+ if handle_callback:
216
+ try:
217
+ handle_callback(handle)
218
+ except Exception:
219
+ pass
220
+
221
+ try:
222
+ body_bytes = res.read()
223
+ if handle._cancelled:
224
+ break
225
+ data = json.loads(body_bytes)
226
+ emb = data.get("embedding") or (data.get("embeddings", [[]])[0])
227
+ all_embeddings.append(emb)
228
+
229
+ tokens = data.get("prompt_eval_count", 0)
230
+ if tokens == 0:
231
+ tokens = max(1, len(input_text) // 4)
232
+ is_estimated = True
233
+ total_prompt_tokens += tokens
234
+ finally:
235
+ handle.mark_done()
236
+ res.close()
237
+
238
+ if handle._cancelled:
239
+ break
240
+
241
+ return EmbeddingResult(
242
+ embeddings=all_embeddings,
243
+ prompt_tokens=total_prompt_tokens,
244
+ total_tokens=total_prompt_tokens,
245
+ is_estimated=is_estimated,
246
+ )
247
+
248
+ def cancel(self, job_id: str) -> None:
249
+ pass