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 +34 -0
- viento/backends/__init__.py +45 -0
- viento/backends/base.py +122 -0
- viento/backends/llamacpp.py +242 -0
- viento/backends/ollama.py +249 -0
- viento/backends/vllm.py +234 -0
- viento/cli/__init__.py +7 -0
- viento/cli/main.py +545 -0
- viento/client/__init__.py +7 -0
- viento/client/client.py +463 -0
- viento/config/__init__.py +7 -0
- viento/config/defaults.py +221 -0
- viento/config/loader.py +136 -0
- viento/connection/__init__.py +7 -0
- viento/connection/manager.py +484 -0
- viento/protocol/__init__.py +57 -0
- viento/protocol/envelope.py +265 -0
- viento/protocol/validator.py +220 -0
- viento/py.typed +1 -0
- viento/scheduler/__init__.py +7 -0
- viento/scheduler/scheduler.py +346 -0
- viento/telemetry/__init__.py +23 -0
- viento/telemetry/benchmarks.py +114 -0
- viento/telemetry/collector.py +267 -0
- viento/telemetry/logging.py +183 -0
- viento-0.2.0.dist-info/METADATA +399 -0
- viento-0.2.0.dist-info/RECORD +31 -0
- viento-0.2.0.dist-info/WHEEL +5 -0
- viento-0.2.0.dist-info/entry_points.txt +2 -0
- viento-0.2.0.dist-info/licenses/LICENSE +21 -0
- viento-0.2.0.dist-info/top_level.txt +1 -0
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
|
+
]
|
viento/backends/base.py
ADDED
|
@@ -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
|