local-coding-agent 1.1.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.
- local_coding_agent/__init__.py +314 -0
- local_coding_agent/__main__.py +5 -0
- local_coding_agent/acp_server/__init__.py +38 -0
- local_coding_agent/acp_server/_codec.py +95 -0
- local_coding_agent/acp_server/_protocol.py +13 -0
- local_coding_agent/acp_server/_server.py +663 -0
- local_coding_agent/acp_server/_session.py +24 -0
- local_coding_agent/acp_server/_tools.py +126 -0
- local_coding_agent/ast_compactor.py +130 -0
- local_coding_agent/atomizer.py +77 -0
- local_coding_agent/benchmark/__init__.py +57 -0
- local_coding_agent/benchmark/_cases.py +307 -0
- local_coding_agent/benchmark/_judge.py +175 -0
- local_coding_agent/benchmark/_oracles.py +157 -0
- local_coding_agent/benchmark/_runner.py +259 -0
- local_coding_agent/benchmark/_stats.py +22 -0
- local_coding_agent/benchmark/_summary.py +143 -0
- local_coding_agent/benchmark_oracle_worker.py +169 -0
- local_coding_agent/calibration.py +154 -0
- local_coding_agent/capability.py +450 -0
- local_coding_agent/chat_compaction.py +276 -0
- local_coding_agent/cli/__init__.py +12 -0
- local_coding_agent/cli/_dispatch.py +377 -0
- local_coding_agent/cli/_handlers.py +320 -0
- local_coding_agent/cli/_handlers2.py +725 -0
- local_coding_agent/cli/_input.py +61 -0
- local_coding_agent/cli/_main.py +159 -0
- local_coding_agent/cli/_parser.py +327 -0
- local_coding_agent/context_manager.py +155 -0
- local_coding_agent/controller/__init__.py +18 -0
- local_coding_agent/controller/_constants.py +108 -0
- local_coding_agent/controller/_controller.py +1008 -0
- local_coding_agent/controller/_post_apply.py +216 -0
- local_coding_agent/delegator.py +180 -0
- local_coding_agent/desktop/__init__.py +8 -0
- local_coding_agent/desktop/app.py +100 -0
- local_coding_agent/desktop/assets/lucide.min.js +12 -0
- local_coding_agent/desktop/assets/tailwind.css +1 -0
- local_coding_agent/desktop/client_js.py +1271 -0
- local_coding_agent/desktop/components.py +555 -0
- local_coding_agent/desktop/server/__init__.py +37 -0
- local_coding_agent/desktop/server/_handlers.py +2178 -0
- local_coding_agent/desktop/server/_models.py +204 -0
- local_coding_agent/desktop/server/_server.py +347 -0
- local_coding_agent/desktop/server/_telemetry.py +87 -0
- local_coding_agent/desktop/styles.py +76 -0
- local_coding_agent/desktop/ui.py +53 -0
- local_coding_agent/doctor.py +413 -0
- local_coding_agent/hooks/__init__.py +19 -0
- local_coding_agent/hooks/_adapters.py +458 -0
- local_coding_agent/hooks/_bridge.py +346 -0
- local_coding_agent/hooks/_decision.py +26 -0
- local_coding_agent/lsp/__init__.py +60 -0
- local_coding_agent/lsp/_client.py +322 -0
- local_coding_agent/lsp/_fallback.py +297 -0
- local_coding_agent/lsp/_manager.py +173 -0
- local_coding_agent/lsp/_mock.py +200 -0
- local_coding_agent/lsp/_types.py +398 -0
- local_coding_agent/mcp_config.py +398 -0
- local_coding_agent/mcp_server.py +377 -0
- local_coding_agent/memory.py +140 -0
- local_coding_agent/mode_router.py +200 -0
- local_coding_agent/model_scanner.py +525 -0
- local_coding_agent/monitor.py +635 -0
- local_coding_agent/observation_policy.py +184 -0
- local_coding_agent/ollama_adapter.py +736 -0
- local_coding_agent/plan_mode.py +653 -0
- local_coding_agent/prescriptions.py +278 -0
- local_coding_agent/profiles.py +239 -0
- local_coding_agent/repository_tools.py +782 -0
- local_coding_agent/ripgrep.py +326 -0
- local_coding_agent/semantic_linter.py +122 -0
- local_coding_agent/service.py +405 -0
- local_coding_agent/session_events.py +684 -0
- local_coding_agent/session_query.py +578 -0
- local_coding_agent/skill_config.py +426 -0
- local_coding_agent/smoke.py +268 -0
- local_coding_agent/speculative_racing.py +85 -0
- local_coding_agent/spill.py +258 -0
- local_coding_agent/stats.py +255 -0
- local_coding_agent/stdio.py +132 -0
- local_coding_agent/subagent.py +516 -0
- local_coding_agent/task.py +90 -0
- local_coding_agent/task_store.py +142 -0
- local_coding_agent/tasks.py +240 -0
- local_coding_agent/terminal/__init__.py +35 -0
- local_coding_agent/terminal/_manager.py +156 -0
- local_coding_agent/terminal/_process.py +148 -0
- local_coding_agent/terminal/_session.py +452 -0
- local_coding_agent/terminal/_tools.py +233 -0
- local_coding_agent/terminal/_types.py +60 -0
- local_coding_agent/tool_call_parser.py +178 -0
- local_coding_agent/validators.py +563 -0
- local_coding_agent/vram_fit.py +240 -0
- local_coding_agent/worker_pool.py +516 -0
- local_coding_agent-1.1.0.dist-info/METADATA +202 -0
- local_coding_agent-1.1.0.dist-info/RECORD +101 -0
- local_coding_agent-1.1.0.dist-info/WHEEL +5 -0
- local_coding_agent-1.1.0.dist-info/entry_points.txt +3 -0
- local_coding_agent-1.1.0.dist-info/licenses/LICENSE +21 -0
- local_coding_agent-1.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,736 @@
|
|
|
1
|
+
"""Small, transport-independent adapter for the Ollama HTTP API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import codecs
|
|
6
|
+
import json
|
|
7
|
+
import time
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Any, Iterator, Protocol
|
|
10
|
+
from urllib.error import HTTPError, URLError
|
|
11
|
+
from urllib.request import Request, urlopen
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
_MAX_STREAM_FRAME_BYTES = 4 * 1024 * 1024
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _iter_stream_lines(chunks: Iterator[bytes]) -> Iterator[str]:
|
|
18
|
+
"""Frame arbitrary transport chunks into complete UTF-8 lines.
|
|
19
|
+
|
|
20
|
+
HTTP ``read`` boundaries are not protocol boundaries. Keep incomplete
|
|
21
|
+
JSON/SSE lines across reads, decode UTF-8 incrementally, and fail closed on
|
|
22
|
+
invalid/truncated frames instead of silently discarding parse errors.
|
|
23
|
+
"""
|
|
24
|
+
decoder = codecs.getincrementaldecoder("utf-8")("strict")
|
|
25
|
+
pending = ""
|
|
26
|
+
try:
|
|
27
|
+
for chunk in chunks:
|
|
28
|
+
if not isinstance(chunk, (bytes, bytearray)):
|
|
29
|
+
raise OllamaError("stream returned a non-byte frame", kind="stream_protocol")
|
|
30
|
+
try:
|
|
31
|
+
pending += decoder.decode(bytes(chunk), final=False)
|
|
32
|
+
except UnicodeDecodeError as error:
|
|
33
|
+
raise OllamaError("stream contained invalid UTF-8", kind="stream_protocol") from error
|
|
34
|
+
while "\n" in pending:
|
|
35
|
+
line, pending = pending.split("\n", 1)
|
|
36
|
+
if len(line.encode("utf-8")) > _MAX_STREAM_FRAME_BYTES:
|
|
37
|
+
raise OllamaError("stream frame exceeds the 4 MiB limit", kind="stream_protocol")
|
|
38
|
+
yield line.rstrip("\r")
|
|
39
|
+
if len(pending.encode("utf-8")) > _MAX_STREAM_FRAME_BYTES:
|
|
40
|
+
raise OllamaError("stream frame exceeds the 4 MiB limit", kind="stream_protocol")
|
|
41
|
+
try:
|
|
42
|
+
pending += decoder.decode(b"", final=True)
|
|
43
|
+
except UnicodeDecodeError as error:
|
|
44
|
+
raise OllamaError("stream ended in an incomplete UTF-8 sequence", kind="stream_protocol") from error
|
|
45
|
+
except OllamaError:
|
|
46
|
+
raise
|
|
47
|
+
if pending:
|
|
48
|
+
if len(pending.encode("utf-8")) > _MAX_STREAM_FRAME_BYTES:
|
|
49
|
+
raise OllamaError("stream frame exceeds the 4 MiB limit", kind="stream_protocol")
|
|
50
|
+
yield pending.rstrip("\r")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class OllamaError(RuntimeError):
|
|
54
|
+
"""A normalized error returned by the Ollama adapter."""
|
|
55
|
+
|
|
56
|
+
def __init__(self, message: str, *, kind: str, status_code: int | None = None) -> None:
|
|
57
|
+
super().__init__(message)
|
|
58
|
+
self.kind = kind
|
|
59
|
+
self.status_code = status_code
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def classify_backend_error(error: Exception) -> str | None:
|
|
63
|
+
"""Classify a backend error by normalized kind instead of OS-specific text."""
|
|
64
|
+
if isinstance(error, OllamaError):
|
|
65
|
+
if error.kind == "transport":
|
|
66
|
+
return "offline"
|
|
67
|
+
if error.kind == "http":
|
|
68
|
+
return "server_error"
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
BACKEND_OFFLINE_HINT = (
|
|
73
|
+
"Local model backend appears to be offline. "
|
|
74
|
+
"Start it with `ollama serve` (or launch llama-server for llama.cpp/OpenAI-compatible profiles), then retry."
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True)
|
|
79
|
+
class ModelProfile:
|
|
80
|
+
name: str
|
|
81
|
+
model: str
|
|
82
|
+
endpoint: str = "http://127.0.0.1:11434"
|
|
83
|
+
provider: str = "ollama"
|
|
84
|
+
think: bool = False
|
|
85
|
+
temperature: float = 0
|
|
86
|
+
num_ctx: int = 4096
|
|
87
|
+
num_predict: int = 256
|
|
88
|
+
keep_alive: str = "10m"
|
|
89
|
+
timeout_seconds: float = 30
|
|
90
|
+
# Historical field retained as a policy/recommendation cap. It is not
|
|
91
|
+
# evidence of the installed model's runtime limit; use
|
|
92
|
+
# ``model_context_limit`` only when the backend has verified that value.
|
|
93
|
+
max_context_length: int | None = None
|
|
94
|
+
model_context_limit: int | None = None
|
|
95
|
+
top_p: float | None = None
|
|
96
|
+
top_k: int | None = None
|
|
97
|
+
min_p: float | None = None
|
|
98
|
+
presence_penalty: float | None = None
|
|
99
|
+
frequency_penalty: float | None = None
|
|
100
|
+
repeat_penalty: float | None = None
|
|
101
|
+
seed: int | None = None
|
|
102
|
+
stop: tuple[str, ...] | None = None
|
|
103
|
+
system_contract: str | None = None
|
|
104
|
+
stream_idle_timeout_seconds: float = 300.0
|
|
105
|
+
|
|
106
|
+
def __post_init__(self) -> None:
|
|
107
|
+
if self.stream_idle_timeout_seconds <= 0:
|
|
108
|
+
raise ValueError("stream_idle_timeout_seconds must be positive")
|
|
109
|
+
if self.num_ctx <= 0:
|
|
110
|
+
raise ValueError("num_ctx must be positive")
|
|
111
|
+
if self.provider not in ("ollama", "openai"):
|
|
112
|
+
raise ValueError(f"unsupported provider {self.provider!r}; expected 'ollama' or 'openai'")
|
|
113
|
+
if self.max_context_length is not None:
|
|
114
|
+
if self.max_context_length <= 0:
|
|
115
|
+
raise ValueError("max_context_length policy cap must be positive")
|
|
116
|
+
if self.model_context_limit is not None:
|
|
117
|
+
if self.model_context_limit <= 0:
|
|
118
|
+
raise ValueError("model_context_limit must be positive")
|
|
119
|
+
if self.num_ctx > self.model_context_limit:
|
|
120
|
+
raise ValueError(
|
|
121
|
+
f"num_ctx={self.num_ctx} exceeds verified model context limit {self.model_context_limit}"
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class Transport(Protocol):
|
|
126
|
+
def request(
|
|
127
|
+
self,
|
|
128
|
+
method: str,
|
|
129
|
+
path: str,
|
|
130
|
+
body: bytes | None,
|
|
131
|
+
headers: dict[str, str],
|
|
132
|
+
timeout: float,
|
|
133
|
+
) -> tuple[int, bytes]: ...
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class UrllibTransport:
|
|
137
|
+
def __init__(self, endpoint: str) -> None:
|
|
138
|
+
self._endpoint = endpoint.rstrip("/")
|
|
139
|
+
|
|
140
|
+
def request(
|
|
141
|
+
self,
|
|
142
|
+
method: str,
|
|
143
|
+
path: str,
|
|
144
|
+
body: bytes | None,
|
|
145
|
+
headers: dict[str, str],
|
|
146
|
+
timeout: float,
|
|
147
|
+
) -> tuple[int, bytes]:
|
|
148
|
+
request = Request(
|
|
149
|
+
f"{self._endpoint}{path}",
|
|
150
|
+
data=body,
|
|
151
|
+
headers=headers,
|
|
152
|
+
method=method,
|
|
153
|
+
)
|
|
154
|
+
try:
|
|
155
|
+
with urlopen(request, timeout=timeout) as response:
|
|
156
|
+
return response.status, response.read()
|
|
157
|
+
except HTTPError as error:
|
|
158
|
+
return error.code, error.read()
|
|
159
|
+
except URLError as error:
|
|
160
|
+
raise OllamaError(f"Ollama transport error: {error.reason}", kind="transport") from error
|
|
161
|
+
except TimeoutError as error:
|
|
162
|
+
raise OllamaError("Ollama request timed out", kind="timeout") from error
|
|
163
|
+
|
|
164
|
+
def stream(
|
|
165
|
+
self,
|
|
166
|
+
method: str,
|
|
167
|
+
path: str,
|
|
168
|
+
body: bytes | None,
|
|
169
|
+
headers: dict[str, str],
|
|
170
|
+
timeout: float,
|
|
171
|
+
) -> Iterator[bytes]:
|
|
172
|
+
request = Request(
|
|
173
|
+
f"{self._endpoint}{path}",
|
|
174
|
+
data=body,
|
|
175
|
+
headers=headers,
|
|
176
|
+
method=method,
|
|
177
|
+
)
|
|
178
|
+
try:
|
|
179
|
+
with urlopen(request, timeout=timeout) as response:
|
|
180
|
+
if response.status < 200 or response.status >= 300:
|
|
181
|
+
raise OllamaError(
|
|
182
|
+
f"Ollama HTTP {response.status}: {response.read().decode('utf-8', errors='replace')[:500]}",
|
|
183
|
+
kind="http",
|
|
184
|
+
status_code=response.status,
|
|
185
|
+
)
|
|
186
|
+
while True:
|
|
187
|
+
chunk = response.read(8192)
|
|
188
|
+
if not chunk:
|
|
189
|
+
break
|
|
190
|
+
yield chunk
|
|
191
|
+
except HTTPError as error:
|
|
192
|
+
raise OllamaError(
|
|
193
|
+
f"Ollama HTTP {error.code}: {error.read().decode('utf-8', errors='replace')[:500]}",
|
|
194
|
+
kind="http",
|
|
195
|
+
status_code=error.code,
|
|
196
|
+
) from error
|
|
197
|
+
except URLError as error:
|
|
198
|
+
raise OllamaError(f"Ollama transport error: {error.reason}", kind="transport") from error
|
|
199
|
+
except TimeoutError as error:
|
|
200
|
+
raise OllamaError("Ollama request timed out", kind="timeout") from error
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class OllamaClient:
|
|
204
|
+
def __init__(self, profile: ModelProfile, *, transport: Transport | None = None) -> None:
|
|
205
|
+
self.profile = profile
|
|
206
|
+
self._transport = transport or UrllibTransport(profile.endpoint)
|
|
207
|
+
|
|
208
|
+
def chat(self, messages: list[dict[str, Any]], *, tools: list[dict[str, Any]] | None = None) -> dict[str, Any]:
|
|
209
|
+
options: dict[str, Any] = {
|
|
210
|
+
"temperature": self.profile.temperature,
|
|
211
|
+
"num_ctx": self.profile.num_ctx,
|
|
212
|
+
"num_predict": self.profile.num_predict,
|
|
213
|
+
}
|
|
214
|
+
if self.profile.top_p is not None:
|
|
215
|
+
options["top_p"] = self.profile.top_p
|
|
216
|
+
if self.profile.top_k is not None:
|
|
217
|
+
options["top_k"] = self.profile.top_k
|
|
218
|
+
if self.profile.min_p is not None:
|
|
219
|
+
options["min_p"] = self.profile.min_p
|
|
220
|
+
if self.profile.presence_penalty is not None:
|
|
221
|
+
options["presence_penalty"] = self.profile.presence_penalty
|
|
222
|
+
if self.profile.frequency_penalty is not None:
|
|
223
|
+
options["frequency_penalty"] = self.profile.frequency_penalty
|
|
224
|
+
if self.profile.repeat_penalty is not None:
|
|
225
|
+
options["repeat_penalty"] = self.profile.repeat_penalty
|
|
226
|
+
if self.profile.seed is not None:
|
|
227
|
+
options["seed"] = self.profile.seed
|
|
228
|
+
if self.profile.stop is not None:
|
|
229
|
+
options["stop"] = list(self.profile.stop)
|
|
230
|
+
|
|
231
|
+
payload: dict[str, Any] = {
|
|
232
|
+
"model": self.profile.model,
|
|
233
|
+
"messages": messages,
|
|
234
|
+
"stream": False,
|
|
235
|
+
"think": self.profile.think,
|
|
236
|
+
"keep_alive": self.profile.keep_alive,
|
|
237
|
+
"options": options,
|
|
238
|
+
}
|
|
239
|
+
if tools is not None:
|
|
240
|
+
payload["tools"] = tools
|
|
241
|
+
if not hasattr(self._transport, "stream"):
|
|
242
|
+
return self._request_json("POST", "/api/chat", payload)
|
|
243
|
+
|
|
244
|
+
payload["stream"] = True
|
|
245
|
+
body = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
|
246
|
+
headers = {"Content-Type": "application/json; charset=utf-8"}
|
|
247
|
+
chunks = self._transport.stream(
|
|
248
|
+
"POST",
|
|
249
|
+
"/api/chat",
|
|
250
|
+
body,
|
|
251
|
+
headers,
|
|
252
|
+
self.profile.stream_idle_timeout_seconds,
|
|
253
|
+
)
|
|
254
|
+
content_parts: list[str] = []
|
|
255
|
+
tool_calls: dict[int, dict[str, Any]] = {}
|
|
256
|
+
stats: dict[str, Any] = {
|
|
257
|
+
"prompt_eval_count": 0,
|
|
258
|
+
"eval_count": 0,
|
|
259
|
+
"prompt_eval_duration": 0,
|
|
260
|
+
"eval_duration": 0,
|
|
261
|
+
"total_duration": 0,
|
|
262
|
+
"load_duration": 0,
|
|
263
|
+
}
|
|
264
|
+
idle = self.profile.stream_idle_timeout_seconds
|
|
265
|
+
last = time.monotonic()
|
|
266
|
+
done = False
|
|
267
|
+
for line in _iter_stream_lines(chunks):
|
|
268
|
+
now = time.monotonic()
|
|
269
|
+
if now - last > idle:
|
|
270
|
+
raise OllamaError("Ollama stream idle timeout", kind="timeout")
|
|
271
|
+
last = now
|
|
272
|
+
line = line.strip()
|
|
273
|
+
if not line:
|
|
274
|
+
continue
|
|
275
|
+
try:
|
|
276
|
+
obj = json.loads(line)
|
|
277
|
+
except json.JSONDecodeError as error:
|
|
278
|
+
raise OllamaError("Ollama stream contained an invalid JSON frame", kind="stream_protocol") from error
|
|
279
|
+
if not isinstance(obj, dict):
|
|
280
|
+
raise OllamaError("Ollama stream frame must be a JSON object", kind="stream_protocol")
|
|
281
|
+
message = obj.get("message")
|
|
282
|
+
if isinstance(message, dict):
|
|
283
|
+
content = message.get("content")
|
|
284
|
+
if isinstance(content, str) and content:
|
|
285
|
+
content_parts.append(content)
|
|
286
|
+
for call in message.get("tool_calls") or []:
|
|
287
|
+
if isinstance(call, dict):
|
|
288
|
+
idx = call.get("index")
|
|
289
|
+
if idx is None:
|
|
290
|
+
continue
|
|
291
|
+
idx = int(idx)
|
|
292
|
+
if idx in tool_calls:
|
|
293
|
+
self._merge_ollama_tool_call(tool_calls[idx], call)
|
|
294
|
+
else:
|
|
295
|
+
tool_calls[idx] = self._make_ollama_tool_call(call)
|
|
296
|
+
for key in stats:
|
|
297
|
+
value = obj.get(key)
|
|
298
|
+
if value is not None:
|
|
299
|
+
stats[key] = value
|
|
300
|
+
if obj.get("done"):
|
|
301
|
+
done = True
|
|
302
|
+
break
|
|
303
|
+
if done:
|
|
304
|
+
break
|
|
305
|
+
if not done:
|
|
306
|
+
raise OllamaError("Ollama stream closed before completion", kind="stream_closed")
|
|
307
|
+
return {
|
|
308
|
+
"message": {
|
|
309
|
+
"role": "assistant",
|
|
310
|
+
"content": "".join(content_parts),
|
|
311
|
+
"tool_calls": [tool_calls[i] for i in sorted(tool_calls)],
|
|
312
|
+
},
|
|
313
|
+
**stats,
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
@staticmethod
|
|
317
|
+
def _make_ollama_tool_call(call: dict[str, Any]) -> dict[str, Any]:
|
|
318
|
+
function = call.get("function") if isinstance(call.get("function"), dict) else {}
|
|
319
|
+
return {
|
|
320
|
+
"index": call.get("index"),
|
|
321
|
+
"id": call.get("id"),
|
|
322
|
+
"type": call.get("type", "function"),
|
|
323
|
+
"function": {"name": function.get("name", ""), "arguments": function.get("arguments", "")},
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
@staticmethod
|
|
327
|
+
def _merge_ollama_tool_call(acc: dict[str, Any], call: dict[str, Any]) -> None:
|
|
328
|
+
function = call.get("function") if isinstance(call.get("function"), dict) else {}
|
|
329
|
+
name = function.get("name")
|
|
330
|
+
if isinstance(name, str) and name:
|
|
331
|
+
acc["function"]["name"] = name
|
|
332
|
+
args = function.get("arguments")
|
|
333
|
+
if isinstance(args, str) and args:
|
|
334
|
+
acc["function"]["arguments"] += args
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def loaded_models(self) -> dict[str, Any]:
|
|
338
|
+
return self._request_json("GET", "/api/ps")
|
|
339
|
+
|
|
340
|
+
def available_models(self) -> dict[str, Any]:
|
|
341
|
+
return self._request_json("GET", "/api/tags")
|
|
342
|
+
|
|
343
|
+
def unload_model(self, model: str | None = None) -> dict[str, Any]:
|
|
344
|
+
target = model or self.profile.model
|
|
345
|
+
if not isinstance(target, str) or not target.strip():
|
|
346
|
+
raise ValueError("model must be a non-empty string")
|
|
347
|
+
return self._request_json(
|
|
348
|
+
"POST",
|
|
349
|
+
"/api/generate",
|
|
350
|
+
{"model": target, "stream": False, "keep_alive": 0},
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
def _request_json(self, method: str, path: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
354
|
+
body = None if payload is None else json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
|
355
|
+
headers = {} if body is None else {"Content-Type": "application/json; charset=utf-8"}
|
|
356
|
+
status, raw_body = self._transport.request(
|
|
357
|
+
method,
|
|
358
|
+
path,
|
|
359
|
+
body,
|
|
360
|
+
headers,
|
|
361
|
+
self.profile.timeout_seconds,
|
|
362
|
+
)
|
|
363
|
+
if status < 200 or status >= 300:
|
|
364
|
+
detail = self._error_detail(raw_body)
|
|
365
|
+
suffix = f": {detail}" if detail else ""
|
|
366
|
+
raise OllamaError(f"Ollama HTTP {status}{suffix}", kind="http", status_code=status)
|
|
367
|
+
try:
|
|
368
|
+
decoded = json.loads(raw_body.decode("utf-8"))
|
|
369
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as error:
|
|
370
|
+
raise OllamaError("Ollama returned invalid JSON", kind="invalid_json") from error
|
|
371
|
+
if not isinstance(decoded, dict):
|
|
372
|
+
raise OllamaError("Ollama returned a non-object JSON value", kind="invalid_json")
|
|
373
|
+
return decoded
|
|
374
|
+
|
|
375
|
+
@staticmethod
|
|
376
|
+
def _error_detail(raw_body: bytes) -> str:
|
|
377
|
+
try:
|
|
378
|
+
payload = json.loads(raw_body.decode("utf-8"))
|
|
379
|
+
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
380
|
+
return raw_body.decode("utf-8", errors="replace").strip()[:500]
|
|
381
|
+
detail = payload.get("error") if isinstance(payload, dict) else None
|
|
382
|
+
return detail if isinstance(detail, str) else ""
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
class OpenAICompatibleClient:
|
|
386
|
+
"""Adapter for OpenAI-compatible backends (e.g. llama-server `/v1`).
|
|
387
|
+
|
|
388
|
+
The controller speaks a neutral message/tool vocabulary (the same one
|
|
389
|
+
``OllamaClient`` produces). This adapter maps it onto the OpenAI chat
|
|
390
|
+
completions wire format and normalizes the response back so callers see no
|
|
391
|
+
difference. Tool calls arrive as JSON strings in the OpenAI payload and are
|
|
392
|
+
parsed to objects for the controller.
|
|
393
|
+
"""
|
|
394
|
+
|
|
395
|
+
def __init__(self, profile: ModelProfile, *, transport: Transport | None = None) -> None:
|
|
396
|
+
self.profile = profile
|
|
397
|
+
self._transport = transport or UrllibTransport(profile.endpoint)
|
|
398
|
+
self._active_model_name: str | None = None
|
|
399
|
+
|
|
400
|
+
def complete(self, prompt: str, *, system: str = "", max_tokens: int | None = None) -> dict[str, Any]:
|
|
401
|
+
"""Convenience completion method for compatibility with controller / warmup callers."""
|
|
402
|
+
messages: list[dict[str, Any]] = []
|
|
403
|
+
if system:
|
|
404
|
+
messages.append({"role": "system", "content": system})
|
|
405
|
+
messages.append({"role": "user", "content": prompt})
|
|
406
|
+
return self.chat(messages)
|
|
407
|
+
|
|
408
|
+
def chat(self, messages: list[dict[str, Any]], *, tools: list[dict[str, Any]] | None = None) -> dict[str, Any]:
|
|
409
|
+
openai_messages: list[dict[str, Any]] = []
|
|
410
|
+
for message in messages:
|
|
411
|
+
role = message.get("role")
|
|
412
|
+
content = message.get("content", "")
|
|
413
|
+
converted: dict[str, Any] = {"role": role, "content": content or ""}
|
|
414
|
+
if "tool_calls" in message:
|
|
415
|
+
converted["tool_calls"] = message["tool_calls"]
|
|
416
|
+
if role == "tool":
|
|
417
|
+
converted["tool_call_id"] = message.get("tool_call_id") or "call_0"
|
|
418
|
+
if "name" in message:
|
|
419
|
+
converted["name"] = message["name"]
|
|
420
|
+
openai_messages.append(converted)
|
|
421
|
+
|
|
422
|
+
model_name = self._active_model_name or self.profile.model
|
|
423
|
+
payload: dict[str, Any] = {
|
|
424
|
+
"model": model_name,
|
|
425
|
+
"messages": openai_messages,
|
|
426
|
+
"temperature": self.profile.temperature,
|
|
427
|
+
"max_tokens": self.profile.num_predict,
|
|
428
|
+
"chat_template_kwargs": {"thinking_option": "off", "enable_thinking": False},
|
|
429
|
+
}
|
|
430
|
+
if self.profile.top_p is not None:
|
|
431
|
+
payload["top_p"] = self.profile.top_p
|
|
432
|
+
if self.profile.seed is not None:
|
|
433
|
+
payload["seed"] = self.profile.seed
|
|
434
|
+
if self.profile.stop:
|
|
435
|
+
payload["stop"] = list(self.profile.stop)
|
|
436
|
+
if tools is not None:
|
|
437
|
+
payload["tools"] = tools
|
|
438
|
+
|
|
439
|
+
try:
|
|
440
|
+
return self._chat_once(payload)
|
|
441
|
+
except OllamaError as err:
|
|
442
|
+
# Only retry when the backend rejected the model name (404, or 400 that mentions the model).
|
|
443
|
+
# ponytail: single retry with a name-prefix heuristic; extend if multi-model resolution needed.
|
|
444
|
+
if not _is_model_resolvable_error(err):
|
|
445
|
+
raise
|
|
446
|
+
avail = self.available_models()
|
|
447
|
+
models_list = [
|
|
448
|
+
m["name"]
|
|
449
|
+
for m in avail.get("models", [])
|
|
450
|
+
if isinstance(m, dict) and isinstance(m.get("name"), str) and m["name"]
|
|
451
|
+
]
|
|
452
|
+
resolved = _resolve_model_id(models_list, self.profile.model)
|
|
453
|
+
if not resolved:
|
|
454
|
+
raise err
|
|
455
|
+
self._active_model_name = resolved
|
|
456
|
+
payload["model"] = self._active_model_name
|
|
457
|
+
try:
|
|
458
|
+
return self._chat_once(payload)
|
|
459
|
+
except OllamaError:
|
|
460
|
+
raise err from None
|
|
461
|
+
|
|
462
|
+
def _chat_once(self, payload: dict[str, Any]) -> dict[str, Any]:
|
|
463
|
+
if not hasattr(self._transport, "stream"):
|
|
464
|
+
decoded = self._request_json("POST", "/v1/chat/completions", payload)
|
|
465
|
+
choice = _first_choice(decoded)
|
|
466
|
+
message = choice.get("message") if isinstance(choice, dict) else None
|
|
467
|
+
content = (message or {}).get("content") or ""
|
|
468
|
+
raw_calls = (message or {}).get("tool_calls") or []
|
|
469
|
+
tool_calls = [_normalize_tool_call(call) for call in raw_calls if isinstance(call, dict)]
|
|
470
|
+
|
|
471
|
+
usage = decoded.get("usage") if isinstance(decoded.get("usage"), dict) else {}
|
|
472
|
+
timings = decoded.get("timings") if isinstance(decoded.get("timings"), dict) else {}
|
|
473
|
+
prompt_ms = _as_float(timings.get("prompt_ms", 0))
|
|
474
|
+
predicted_ms = _as_float(timings.get("predicted_ms", 0))
|
|
475
|
+
return {
|
|
476
|
+
"message": {"role": "assistant", "content": content, "tool_calls": tool_calls},
|
|
477
|
+
"requested_model": self.profile.model,
|
|
478
|
+
"resolved_model": decoded.get("model") if isinstance(decoded.get("model"), str) else payload.get("model"),
|
|
479
|
+
"prompt_eval_count": _as_int(usage.get("prompt_tokens", 0)),
|
|
480
|
+
"eval_count": _as_int(usage.get("completion_tokens", 0)),
|
|
481
|
+
"prompt_eval_duration": _as_nanos_ms(timings.get("prompt_ms", 0)),
|
|
482
|
+
"eval_duration": _as_nanos_ms(timings.get("predicted_ms", 0)),
|
|
483
|
+
"total_duration": _as_nanos_ms(prompt_ms + predicted_ms),
|
|
484
|
+
"load_duration": 0,
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
payload = dict(payload)
|
|
488
|
+
payload["stream"] = True
|
|
489
|
+
payload["stream_options"] = {"include_usage": True}
|
|
490
|
+
body = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
|
491
|
+
headers = {"Content-Type": "application/json; charset=utf-8"}
|
|
492
|
+
chunks = self._transport.stream(
|
|
493
|
+
"POST",
|
|
494
|
+
"/v1/chat/completions",
|
|
495
|
+
body,
|
|
496
|
+
headers,
|
|
497
|
+
self.profile.stream_idle_timeout_seconds,
|
|
498
|
+
)
|
|
499
|
+
content_parts: list[str] = []
|
|
500
|
+
tool_calls: dict[int, dict[str, Any]] = {}
|
|
501
|
+
usage: dict[str, Any] = {}
|
|
502
|
+
timings: dict[str, Any] = {}
|
|
503
|
+
response_model: str | None = None
|
|
504
|
+
done = False
|
|
505
|
+
idle = self.profile.stream_idle_timeout_seconds
|
|
506
|
+
last = time.monotonic()
|
|
507
|
+
for line in _iter_stream_lines(chunks):
|
|
508
|
+
now = time.monotonic()
|
|
509
|
+
if now - last > idle:
|
|
510
|
+
raise OllamaError("backend stream idle timeout", kind="timeout")
|
|
511
|
+
last = now
|
|
512
|
+
if not line.startswith("data:"):
|
|
513
|
+
continue
|
|
514
|
+
data = line[5:].strip()
|
|
515
|
+
if data == "[DONE]":
|
|
516
|
+
done = True
|
|
517
|
+
break
|
|
518
|
+
if not data:
|
|
519
|
+
continue
|
|
520
|
+
try:
|
|
521
|
+
obj = json.loads(data)
|
|
522
|
+
except json.JSONDecodeError as error:
|
|
523
|
+
raise OllamaError("backend stream contained an invalid JSON frame", kind="stream_protocol") from error
|
|
524
|
+
if not isinstance(obj, dict):
|
|
525
|
+
raise OllamaError("backend stream frame must be a JSON object", kind="stream_protocol")
|
|
526
|
+
if isinstance(obj.get("model"), str) and obj["model"]:
|
|
527
|
+
response_model = obj["model"]
|
|
528
|
+
if isinstance(obj.get("usage"), dict):
|
|
529
|
+
usage = obj["usage"]
|
|
530
|
+
if isinstance(obj.get("timings"), dict):
|
|
531
|
+
timings = obj["timings"]
|
|
532
|
+
choices = obj.get("choices")
|
|
533
|
+
if not isinstance(choices, list) or not choices:
|
|
534
|
+
continue
|
|
535
|
+
choice = choices[0]
|
|
536
|
+
if not isinstance(choice, dict):
|
|
537
|
+
continue
|
|
538
|
+
delta = choice.get("delta")
|
|
539
|
+
if not isinstance(delta, dict):
|
|
540
|
+
continue
|
|
541
|
+
content = delta.get("content")
|
|
542
|
+
if isinstance(content, str) and content:
|
|
543
|
+
content_parts.append(content)
|
|
544
|
+
for call in delta.get("tool_calls") or []:
|
|
545
|
+
if isinstance(call, dict):
|
|
546
|
+
idx = int(call.get("index", 0))
|
|
547
|
+
if idx in tool_calls:
|
|
548
|
+
self._merge_openai_tool_call(tool_calls[idx], call)
|
|
549
|
+
else:
|
|
550
|
+
tool_calls[idx] = self._make_openai_tool_call(call)
|
|
551
|
+
if not done:
|
|
552
|
+
raise OllamaError("backend stream closed before [DONE]", kind="stream_closed")
|
|
553
|
+
prompt_ms = _as_float(timings.get("prompt_ms", 0))
|
|
554
|
+
predicted_ms = _as_float(timings.get("predicted_ms", 0))
|
|
555
|
+
return {
|
|
556
|
+
"message": {
|
|
557
|
+
"role": "assistant",
|
|
558
|
+
"content": "".join(content_parts),
|
|
559
|
+
"tool_calls": [_normalize_tool_call(tool_calls[i]) for i in sorted(tool_calls)],
|
|
560
|
+
},
|
|
561
|
+
"requested_model": self.profile.model,
|
|
562
|
+
"resolved_model": response_model or payload.get("model"),
|
|
563
|
+
"prompt_eval_count": _as_int(usage.get("prompt_tokens", 0)),
|
|
564
|
+
"eval_count": _as_int(usage.get("completion_tokens", 0)),
|
|
565
|
+
"prompt_eval_duration": _as_nanos_ms(timings.get("prompt_ms", 0)),
|
|
566
|
+
"eval_duration": _as_nanos_ms(timings.get("predicted_ms", 0)),
|
|
567
|
+
"total_duration": _as_nanos_ms(prompt_ms + predicted_ms),
|
|
568
|
+
"load_duration": 0,
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
@staticmethod
|
|
572
|
+
def _make_openai_tool_call(call: dict[str, Any]) -> dict[str, Any]:
|
|
573
|
+
function = call.get("function") if isinstance(call.get("function"), dict) else {}
|
|
574
|
+
return {
|
|
575
|
+
"id": call.get("id") or f"call_{int(time.time() * 1000)}",
|
|
576
|
+
"type": call.get("type", "function"),
|
|
577
|
+
"function": {"name": function.get("name", ""), "arguments": function.get("arguments", "")},
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
@staticmethod
|
|
581
|
+
def _merge_openai_tool_call(acc: dict[str, Any], call: dict[str, Any]) -> None:
|
|
582
|
+
function = call.get("function") if isinstance(call.get("function"), dict) else {}
|
|
583
|
+
name = function.get("name")
|
|
584
|
+
if isinstance(name, str) and name:
|
|
585
|
+
acc["function"]["name"] = name
|
|
586
|
+
args = function.get("arguments")
|
|
587
|
+
if isinstance(args, str) and args:
|
|
588
|
+
acc["function"]["arguments"] += args
|
|
589
|
+
|
|
590
|
+
def available_models(self) -> dict[str, Any]:
|
|
591
|
+
decoded = self._request_json("GET", "/v1/models", None)
|
|
592
|
+
data = decoded.get("data") if isinstance(decoded, dict) else None
|
|
593
|
+
models = []
|
|
594
|
+
if isinstance(data, list):
|
|
595
|
+
for item in data:
|
|
596
|
+
if isinstance(item, dict) and isinstance(item.get("id"), str):
|
|
597
|
+
models.append({"name": item["id"]})
|
|
598
|
+
return {"models": models}
|
|
599
|
+
|
|
600
|
+
def loaded_models(self) -> dict[str, Any]:
|
|
601
|
+
raise OllamaError(
|
|
602
|
+
"the openai provider does not expose loaded-model/VRAM introspection",
|
|
603
|
+
kind="unsupported",
|
|
604
|
+
)
|
|
605
|
+
|
|
606
|
+
def unload_model(self, model: str | None = None) -> dict[str, Any]:
|
|
607
|
+
raise OllamaError(
|
|
608
|
+
"the openai provider does not support unloading models",
|
|
609
|
+
kind="unsupported",
|
|
610
|
+
)
|
|
611
|
+
|
|
612
|
+
def _request_json(self, method: str, path: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
613
|
+
body = None if payload is None else json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
|
614
|
+
headers = {} if body is None else {"Content-Type": "application/json; charset=utf-8"}
|
|
615
|
+
status, raw_body = self._transport.request(
|
|
616
|
+
method,
|
|
617
|
+
path,
|
|
618
|
+
body,
|
|
619
|
+
headers,
|
|
620
|
+
self.profile.timeout_seconds,
|
|
621
|
+
)
|
|
622
|
+
if status < 200 or status >= 300:
|
|
623
|
+
detail = _openai_error_detail(raw_body)
|
|
624
|
+
suffix = f": {detail}" if detail else ""
|
|
625
|
+
raise OllamaError(f"backend HTTP {status}{suffix}", kind="http", status_code=status)
|
|
626
|
+
try:
|
|
627
|
+
decoded = json.loads(raw_body.decode("utf-8"))
|
|
628
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as error:
|
|
629
|
+
raise OllamaError("backend returned invalid JSON", kind="invalid_json") from error
|
|
630
|
+
if not isinstance(decoded, dict):
|
|
631
|
+
raise OllamaError("backend returned a non-object JSON value", kind="invalid_json")
|
|
632
|
+
return decoded
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
_MODEL_HINT_WORDS = ("model", "not found", "unknown", "does not exist", "no such")
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def _is_model_resolvable_error(err: OllamaError) -> bool:
|
|
639
|
+
"""True when an http error plausibly means the model name was rejected."""
|
|
640
|
+
if err.kind != "http":
|
|
641
|
+
return False
|
|
642
|
+
if err.status_code == 404:
|
|
643
|
+
return True
|
|
644
|
+
if err.status_code == 400:
|
|
645
|
+
lower = str(err).lower()
|
|
646
|
+
return any(word in lower for word in _MODEL_HINT_WORDS)
|
|
647
|
+
return False
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def _model_base(model_id: str) -> str:
|
|
651
|
+
# strip a :tag, a /path, or a .gguf suffix — whichever comes first
|
|
652
|
+
for sep in (":", "/", ".gguf"):
|
|
653
|
+
idx = model_id.find(sep)
|
|
654
|
+
if idx != -1:
|
|
655
|
+
return model_id[:idx]
|
|
656
|
+
return model_id
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def _resolve_model_id(models: list[str], requested: str) -> str | None:
|
|
660
|
+
"""Resolve only an exact id or an unambiguous explicit file-suffix alias.
|
|
661
|
+
|
|
662
|
+
A rejected model name must never silently turn into the first available
|
|
663
|
+
model or a same-prefix model from another namespace/quantization.
|
|
664
|
+
"""
|
|
665
|
+
if not models or requested in models:
|
|
666
|
+
return requested if requested in models else None
|
|
667
|
+
alias = requested[:-5] if requested.lower().endswith(".gguf") else None
|
|
668
|
+
if alias is None:
|
|
669
|
+
return None
|
|
670
|
+
matches = [model for model in models if model == alias]
|
|
671
|
+
return matches[0] if len(matches) == 1 else None
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def _openai_error_detail(raw_body: bytes) -> str:
|
|
675
|
+
try:
|
|
676
|
+
payload = json.loads(raw_body.decode("utf-8"))
|
|
677
|
+
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
678
|
+
return raw_body.decode("utf-8", errors="replace").strip()[:500]
|
|
679
|
+
if not isinstance(payload, dict):
|
|
680
|
+
return ""
|
|
681
|
+
error = payload.get("error")
|
|
682
|
+
if isinstance(error, str):
|
|
683
|
+
return error
|
|
684
|
+
if isinstance(error, dict) and isinstance(error.get("message"), str):
|
|
685
|
+
return error["message"]
|
|
686
|
+
return ""
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
def _first_choice(decoded: dict[str, Any]) -> dict[str, Any]:
|
|
690
|
+
choices = decoded.get("choices")
|
|
691
|
+
if not isinstance(choices, list) or not choices:
|
|
692
|
+
raise OllamaError("backend returned no choices", kind="invalid_json")
|
|
693
|
+
first = choices[0]
|
|
694
|
+
if not isinstance(first, dict):
|
|
695
|
+
raise OllamaError("backend returned an invalid choice", kind="invalid_json")
|
|
696
|
+
return first
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def _normalize_tool_call(call: dict[str, Any]) -> dict[str, Any]:
|
|
700
|
+
function = call.get("function")
|
|
701
|
+
if isinstance(function, dict) and isinstance(function.get("arguments"), str):
|
|
702
|
+
try:
|
|
703
|
+
function = dict(function)
|
|
704
|
+
function["arguments"] = json.loads(function["arguments"])
|
|
705
|
+
except json.JSONDecodeError:
|
|
706
|
+
pass
|
|
707
|
+
call_id = call.get("id") or f"call_{int(time.time() * 1000)}"
|
|
708
|
+
return {**call, "id": call_id, "function": function}
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
def _as_int(value: Any) -> int:
|
|
712
|
+
try:
|
|
713
|
+
return int(value)
|
|
714
|
+
except (TypeError, ValueError, OverflowError):
|
|
715
|
+
return 0
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def _as_float(value: Any) -> float:
|
|
719
|
+
try:
|
|
720
|
+
return float(value)
|
|
721
|
+
except (TypeError, ValueError, OverflowError):
|
|
722
|
+
return 0.0
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def _as_nanos_ms(value: Any) -> int:
|
|
726
|
+
try:
|
|
727
|
+
return int(_as_float(value) * 1_000_000)
|
|
728
|
+
except (TypeError, ValueError, OverflowError):
|
|
729
|
+
return 0
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
def build_client(profile: ModelProfile, *, transport: Transport | None = None) -> OllamaClient | OpenAICompatibleClient:
|
|
733
|
+
"""Return the transport matching the profile's declared provider."""
|
|
734
|
+
if profile.provider == "openai":
|
|
735
|
+
return OpenAICompatibleClient(profile, transport=transport)
|
|
736
|
+
return OllamaClient(profile, transport=transport)
|