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.
Files changed (101) hide show
  1. local_coding_agent/__init__.py +314 -0
  2. local_coding_agent/__main__.py +5 -0
  3. local_coding_agent/acp_server/__init__.py +38 -0
  4. local_coding_agent/acp_server/_codec.py +95 -0
  5. local_coding_agent/acp_server/_protocol.py +13 -0
  6. local_coding_agent/acp_server/_server.py +663 -0
  7. local_coding_agent/acp_server/_session.py +24 -0
  8. local_coding_agent/acp_server/_tools.py +126 -0
  9. local_coding_agent/ast_compactor.py +130 -0
  10. local_coding_agent/atomizer.py +77 -0
  11. local_coding_agent/benchmark/__init__.py +57 -0
  12. local_coding_agent/benchmark/_cases.py +307 -0
  13. local_coding_agent/benchmark/_judge.py +175 -0
  14. local_coding_agent/benchmark/_oracles.py +157 -0
  15. local_coding_agent/benchmark/_runner.py +259 -0
  16. local_coding_agent/benchmark/_stats.py +22 -0
  17. local_coding_agent/benchmark/_summary.py +143 -0
  18. local_coding_agent/benchmark_oracle_worker.py +169 -0
  19. local_coding_agent/calibration.py +154 -0
  20. local_coding_agent/capability.py +450 -0
  21. local_coding_agent/chat_compaction.py +276 -0
  22. local_coding_agent/cli/__init__.py +12 -0
  23. local_coding_agent/cli/_dispatch.py +377 -0
  24. local_coding_agent/cli/_handlers.py +320 -0
  25. local_coding_agent/cli/_handlers2.py +725 -0
  26. local_coding_agent/cli/_input.py +61 -0
  27. local_coding_agent/cli/_main.py +159 -0
  28. local_coding_agent/cli/_parser.py +327 -0
  29. local_coding_agent/context_manager.py +155 -0
  30. local_coding_agent/controller/__init__.py +18 -0
  31. local_coding_agent/controller/_constants.py +108 -0
  32. local_coding_agent/controller/_controller.py +1008 -0
  33. local_coding_agent/controller/_post_apply.py +216 -0
  34. local_coding_agent/delegator.py +180 -0
  35. local_coding_agent/desktop/__init__.py +8 -0
  36. local_coding_agent/desktop/app.py +100 -0
  37. local_coding_agent/desktop/assets/lucide.min.js +12 -0
  38. local_coding_agent/desktop/assets/tailwind.css +1 -0
  39. local_coding_agent/desktop/client_js.py +1271 -0
  40. local_coding_agent/desktop/components.py +555 -0
  41. local_coding_agent/desktop/server/__init__.py +37 -0
  42. local_coding_agent/desktop/server/_handlers.py +2178 -0
  43. local_coding_agent/desktop/server/_models.py +204 -0
  44. local_coding_agent/desktop/server/_server.py +347 -0
  45. local_coding_agent/desktop/server/_telemetry.py +87 -0
  46. local_coding_agent/desktop/styles.py +76 -0
  47. local_coding_agent/desktop/ui.py +53 -0
  48. local_coding_agent/doctor.py +413 -0
  49. local_coding_agent/hooks/__init__.py +19 -0
  50. local_coding_agent/hooks/_adapters.py +458 -0
  51. local_coding_agent/hooks/_bridge.py +346 -0
  52. local_coding_agent/hooks/_decision.py +26 -0
  53. local_coding_agent/lsp/__init__.py +60 -0
  54. local_coding_agent/lsp/_client.py +322 -0
  55. local_coding_agent/lsp/_fallback.py +297 -0
  56. local_coding_agent/lsp/_manager.py +173 -0
  57. local_coding_agent/lsp/_mock.py +200 -0
  58. local_coding_agent/lsp/_types.py +398 -0
  59. local_coding_agent/mcp_config.py +398 -0
  60. local_coding_agent/mcp_server.py +377 -0
  61. local_coding_agent/memory.py +140 -0
  62. local_coding_agent/mode_router.py +200 -0
  63. local_coding_agent/model_scanner.py +525 -0
  64. local_coding_agent/monitor.py +635 -0
  65. local_coding_agent/observation_policy.py +184 -0
  66. local_coding_agent/ollama_adapter.py +736 -0
  67. local_coding_agent/plan_mode.py +653 -0
  68. local_coding_agent/prescriptions.py +278 -0
  69. local_coding_agent/profiles.py +239 -0
  70. local_coding_agent/repository_tools.py +782 -0
  71. local_coding_agent/ripgrep.py +326 -0
  72. local_coding_agent/semantic_linter.py +122 -0
  73. local_coding_agent/service.py +405 -0
  74. local_coding_agent/session_events.py +684 -0
  75. local_coding_agent/session_query.py +578 -0
  76. local_coding_agent/skill_config.py +426 -0
  77. local_coding_agent/smoke.py +268 -0
  78. local_coding_agent/speculative_racing.py +85 -0
  79. local_coding_agent/spill.py +258 -0
  80. local_coding_agent/stats.py +255 -0
  81. local_coding_agent/stdio.py +132 -0
  82. local_coding_agent/subagent.py +516 -0
  83. local_coding_agent/task.py +90 -0
  84. local_coding_agent/task_store.py +142 -0
  85. local_coding_agent/tasks.py +240 -0
  86. local_coding_agent/terminal/__init__.py +35 -0
  87. local_coding_agent/terminal/_manager.py +156 -0
  88. local_coding_agent/terminal/_process.py +148 -0
  89. local_coding_agent/terminal/_session.py +452 -0
  90. local_coding_agent/terminal/_tools.py +233 -0
  91. local_coding_agent/terminal/_types.py +60 -0
  92. local_coding_agent/tool_call_parser.py +178 -0
  93. local_coding_agent/validators.py +563 -0
  94. local_coding_agent/vram_fit.py +240 -0
  95. local_coding_agent/worker_pool.py +516 -0
  96. local_coding_agent-1.1.0.dist-info/METADATA +202 -0
  97. local_coding_agent-1.1.0.dist-info/RECORD +101 -0
  98. local_coding_agent-1.1.0.dist-info/WHEEL +5 -0
  99. local_coding_agent-1.1.0.dist-info/entry_points.txt +3 -0
  100. local_coding_agent-1.1.0.dist-info/licenses/LICENSE +21 -0
  101. 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)