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,2178 @@
|
|
|
1
|
+
"""HTTP request handler serving the Desktop UI and REST endpoints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import http.client
|
|
6
|
+
import hmac
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
import time
|
|
13
|
+
import urllib.request
|
|
14
|
+
import uuid
|
|
15
|
+
from dataclasses import replace
|
|
16
|
+
from http import HTTPStatus
|
|
17
|
+
from http.server import BaseHTTPRequestHandler
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
from urllib.parse import urlparse
|
|
21
|
+
|
|
22
|
+
from ...controller import mediated_apply
|
|
23
|
+
from ...doctor import remediate_environment
|
|
24
|
+
from ...memory import ModelMemoryManager
|
|
25
|
+
from ...model_scanner import discover_llama_server_binary, get_live_system_path
|
|
26
|
+
from ...ollama_adapter import OllamaError, build_client
|
|
27
|
+
from ...profiles import ModelProfile, get_profile, list_profiles
|
|
28
|
+
from ...task import TaskEnvelope
|
|
29
|
+
from ...validators import apply_patch, parse_unified_diff
|
|
30
|
+
from ...vram_fit import ContextFit, fit_context, kv_bytes_per_token, read_gguf_ctx_params
|
|
31
|
+
from ..ui import render_desktop_html
|
|
32
|
+
from ._models import (
|
|
33
|
+
_classify_backend_error,
|
|
34
|
+
discover_local_ollama_models,
|
|
35
|
+
find_discovered_gguf,
|
|
36
|
+
resolve_model_profile,
|
|
37
|
+
select_available_profile,
|
|
38
|
+
)
|
|
39
|
+
from ._telemetry import get_nvidia_gpu_telemetry
|
|
40
|
+
|
|
41
|
+
# Bounded recent-prompt history feeding the hybrid mode router's isolated
|
|
42
|
+
# context. Kept module-level (not on server state) so DesktopServer.__init__
|
|
43
|
+
# needs no change. Mutations are guarded by server_inst.apply_lock; a deque
|
|
44
|
+
# could replace the list if it ever needs to grow past a small bound.
|
|
45
|
+
_MAX_RECENT_PROMPTS = 6
|
|
46
|
+
_recent_prompts: list[str] = []
|
|
47
|
+
_ASSET_DIR = Path(__file__).resolve().parent.parent / "assets"
|
|
48
|
+
|
|
49
|
+
# Hard resource ceiling for a client-supplied context override. The per-model
|
|
50
|
+
# VRAM preflight in _launch_llama_model clamps below this, but the cap keeps a
|
|
51
|
+
# hostile/mistyped request from ever reaching llama-server.
|
|
52
|
+
_MAX_CTX_OVERRIDE = 262144
|
|
53
|
+
_DEFAULT_LLAMA_CTX = 8192
|
|
54
|
+
_MAX_REQUEST_BODY_BYTES = 8 * 1024 * 1024
|
|
55
|
+
_MAX_BODY_READ_CHUNK = 64 * 1024
|
|
56
|
+
_MAX_REJECTED_BODY_DRAIN = 64 * 1024
|
|
57
|
+
_REQUEST_BODY_IDLE_TIMEOUT = 2.0
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class _RequestBodyError(ValueError):
|
|
61
|
+
"""A request body cannot be safely framed or read."""
|
|
62
|
+
|
|
63
|
+
def __init__(self, status: HTTPStatus, message: str) -> None:
|
|
64
|
+
super().__init__(message)
|
|
65
|
+
self.status = status
|
|
66
|
+
self.message = message
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def build_queue_controller(profile_name: str, workspace_str: str, cancel_event: Any = None) -> Any:
|
|
70
|
+
"""Build the real Controller for a queued desktop task.
|
|
71
|
+
|
|
72
|
+
Mirrors _handle_delegate's construction exactly (resolve_model_profile ->
|
|
73
|
+
build_client -> Controller) and resolves symbols through this module's
|
|
74
|
+
namespace, so tests can monkeypatch ``build_client`` / the Controller
|
|
75
|
+
class with the existing idioms. The background worker passes a
|
|
76
|
+
cooperative cancel Event through to the controller.
|
|
77
|
+
"""
|
|
78
|
+
profile = resolve_model_profile(profile_name)
|
|
79
|
+
client = build_client(profile)
|
|
80
|
+
from ...controller import Controller
|
|
81
|
+
|
|
82
|
+
return Controller(client, workspace_str, cancel_event=cancel_event)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class DesktopRequestHandler(BaseHTTPRequestHandler):
|
|
86
|
+
"""HTTP request handler serving the Desktop UI and REST endpoints."""
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def server_inst(self) -> Any:
|
|
90
|
+
return getattr(self.server, "desktop_server", self.server)
|
|
91
|
+
|
|
92
|
+
def do_GET(self) -> None:
|
|
93
|
+
parsed = urlparse(self.path)
|
|
94
|
+
path = parsed.path.rstrip("/")
|
|
95
|
+
|
|
96
|
+
if path in {"", "/app", "/index.html"}:
|
|
97
|
+
self._send_response(
|
|
98
|
+
HTTPStatus.OK,
|
|
99
|
+
"text/html; charset=utf-8",
|
|
100
|
+
render_desktop_html(self.server_inst.mutation_token).encode("utf-8"),
|
|
101
|
+
)
|
|
102
|
+
elif path == "/assets/tailwind.css":
|
|
103
|
+
self._send_asset("tailwind.css", "text/css; charset=utf-8")
|
|
104
|
+
elif path == "/assets/lucide.min.js":
|
|
105
|
+
self._send_asset("lucide.min.js", "text/javascript; charset=utf-8")
|
|
106
|
+
elif path in {"/api/status", "/status"}:
|
|
107
|
+
self._handle_status()
|
|
108
|
+
elif path in {"/api/gpu", "/gpu", "/api/gpu/telemetry"}:
|
|
109
|
+
self._handle_gpu_telemetry()
|
|
110
|
+
elif path in {"/api/models", "/models", "/api/profiles"}:
|
|
111
|
+
self._handle_models()
|
|
112
|
+
elif path in {"/api/sessions", "/sessions"}:
|
|
113
|
+
self._handle_sessions()
|
|
114
|
+
elif path in {"/api/tasks", "/tasks"}:
|
|
115
|
+
self._handle_tasks_list()
|
|
116
|
+
elif path in {"/api/workspace/files", "/workspace/files"}:
|
|
117
|
+
self._handle_workspace_files()
|
|
118
|
+
elif path in {"/api/health", "/health"}:
|
|
119
|
+
self._send_json({"status": "ok", "uptime": round(time.monotonic() - self.server_inst.started_at, 2)})
|
|
120
|
+
elif path == "/v1/models":
|
|
121
|
+
self._handle_v1_models()
|
|
122
|
+
else:
|
|
123
|
+
self._send_response(HTTPStatus.NOT_FOUND, "text/plain; charset=utf-8", b"404 Not Found\n")
|
|
124
|
+
|
|
125
|
+
def do_POST(self) -> None:
|
|
126
|
+
parsed = urlparse(self.path)
|
|
127
|
+
path = parsed.path.rstrip("/")
|
|
128
|
+
|
|
129
|
+
self._request_body = None
|
|
130
|
+
try:
|
|
131
|
+
content_length = self._parse_content_length()
|
|
132
|
+
except _RequestBodyError as error:
|
|
133
|
+
self._send_request_body_error(error)
|
|
134
|
+
return
|
|
135
|
+
|
|
136
|
+
rejection = self._unsafe_mutation_reason(content_length)
|
|
137
|
+
if rejection:
|
|
138
|
+
# Windows may reset a connection when the handler closes it while
|
|
139
|
+
# the client request body is still buffered. Drain the declared
|
|
140
|
+
# body before returning the intentional 403 so callers receive
|
|
141
|
+
# the rejection instead of a transport-level connection abort.
|
|
142
|
+
# The drain is capped; a connection with unread bytes is closed
|
|
143
|
+
# rather than being reused with an unknown request boundary.
|
|
144
|
+
try:
|
|
145
|
+
self._discard_request_body(content_length)
|
|
146
|
+
except _RequestBodyError as error:
|
|
147
|
+
self._send_request_body_error(error)
|
|
148
|
+
return
|
|
149
|
+
self._send_json(
|
|
150
|
+
{"status": "rejected", "error": rejection},
|
|
151
|
+
HTTPStatus.FORBIDDEN,
|
|
152
|
+
)
|
|
153
|
+
return
|
|
154
|
+
|
|
155
|
+
try:
|
|
156
|
+
self._request_body = self._read_request_body(content_length)
|
|
157
|
+
except _RequestBodyError as error:
|
|
158
|
+
self._send_request_body_error(error)
|
|
159
|
+
return
|
|
160
|
+
|
|
161
|
+
if path in {"/api/chat", "/chat"}:
|
|
162
|
+
self._handle_chat()
|
|
163
|
+
elif path in {"/api/delegate", "/delegate"}:
|
|
164
|
+
self._handle_delegate()
|
|
165
|
+
elif path in {"/api/tasks", "/tasks"}:
|
|
166
|
+
self._handle_tasks_submit()
|
|
167
|
+
elif path in {"/api/tasks/cancel", "/tasks/cancel"}:
|
|
168
|
+
self._handle_tasks_cancel()
|
|
169
|
+
elif path in {"/api/apply", "/apply"}:
|
|
170
|
+
self._handle_apply()
|
|
171
|
+
elif path in {"/api/rollback", "/rollback"}:
|
|
172
|
+
self._handle_rollback()
|
|
173
|
+
elif path in {"/api/server/start", "/server/start"}:
|
|
174
|
+
self._handle_server_start()
|
|
175
|
+
elif path in {"/api/server/stop", "/server/stop"}:
|
|
176
|
+
self._handle_server_stop()
|
|
177
|
+
elif path in {"/api/model/load", "/model/load"}:
|
|
178
|
+
self._handle_model_load()
|
|
179
|
+
elif path in {"/api/model/unload", "/model/unload"}:
|
|
180
|
+
self._handle_model_unload()
|
|
181
|
+
elif path in {"/api/model/unload_all", "/model/unload_all"}:
|
|
182
|
+
self._handle_model_unload_all()
|
|
183
|
+
elif path in {"/api/doctor/fix", "/doctor/fix"}:
|
|
184
|
+
self._handle_doctor_fix()
|
|
185
|
+
elif path in {"/api/sessions", "/sessions"}:
|
|
186
|
+
self._handle_create_session()
|
|
187
|
+
elif path in {"/api/models/scan", "/models/scan"}:
|
|
188
|
+
self._handle_model_scan()
|
|
189
|
+
elif path == "/v1/chat/completions":
|
|
190
|
+
self._handle_v1_chat_completions()
|
|
191
|
+
elif path in {"/api/models/add_dir", "/models/add_dir"}:
|
|
192
|
+
self._handle_model_add_dir()
|
|
193
|
+
elif path in {"/api/models/remove_dir", "/models/remove_dir"}:
|
|
194
|
+
self._handle_model_remove_dir()
|
|
195
|
+
else:
|
|
196
|
+
self._send_response(HTTPStatus.NOT_FOUND, "text/plain; charset=utf-8", b"404 Not Found\n")
|
|
197
|
+
|
|
198
|
+
def _unsafe_mutation_reason(self, content_length: int | None = None) -> str:
|
|
199
|
+
"""Reject browser-driven loopback CSRF while preserving local CLI use."""
|
|
200
|
+
expected_port = self.server_inst.actual_port
|
|
201
|
+
allowed_hosts = {
|
|
202
|
+
f"127.0.0.1:{expected_port}",
|
|
203
|
+
f"localhost:{expected_port}",
|
|
204
|
+
f"[::1]:{expected_port}",
|
|
205
|
+
}
|
|
206
|
+
host = self.headers.get("Host", "").lower()
|
|
207
|
+
if host not in allowed_hosts:
|
|
208
|
+
return "Host must target this loopback desktop server"
|
|
209
|
+
|
|
210
|
+
supplied_token = self.headers.get("X-Desktop-Token", "")
|
|
211
|
+
if not hmac.compare_digest(supplied_token, self.server_inst.mutation_token):
|
|
212
|
+
return "Missing or invalid desktop mutation token"
|
|
213
|
+
|
|
214
|
+
origin = self.headers.get("Origin")
|
|
215
|
+
if origin:
|
|
216
|
+
parsed = urlparse(origin)
|
|
217
|
+
origin_host = (parsed.netloc or "").lower()
|
|
218
|
+
if parsed.scheme not in {"http", "https"} or origin_host not in allowed_hosts:
|
|
219
|
+
return "Cross-origin desktop API mutations are not allowed"
|
|
220
|
+
|
|
221
|
+
if content_length is None:
|
|
222
|
+
content_length = self._parse_content_length()
|
|
223
|
+
content_type = self.headers.get("Content-Type", "").split(";", 1)[0].strip().lower()
|
|
224
|
+
if content_length > 0 and content_type != "application/json":
|
|
225
|
+
return "State-changing request bodies must use application/json"
|
|
226
|
+
return ""
|
|
227
|
+
|
|
228
|
+
def _parse_content_length(self) -> int:
|
|
229
|
+
"""Parse one bounded Content-Length or reject ambiguous framing."""
|
|
230
|
+
values = None
|
|
231
|
+
get_all = getattr(self.headers, "get_all", None)
|
|
232
|
+
if get_all is not None:
|
|
233
|
+
values = get_all("Content-Length")
|
|
234
|
+
if values is None:
|
|
235
|
+
raw = self.headers.get("Content-Length")
|
|
236
|
+
values = [] if raw is None else [raw]
|
|
237
|
+
if len(values) > 1:
|
|
238
|
+
raise _RequestBodyError(HTTPStatus.BAD_REQUEST, "Exactly one Content-Length header is required")
|
|
239
|
+
|
|
240
|
+
transfer_encoding = self.headers.get("Transfer-Encoding", "")
|
|
241
|
+
if transfer_encoding.strip():
|
|
242
|
+
raise _RequestBodyError(
|
|
243
|
+
HTTPStatus.BAD_REQUEST,
|
|
244
|
+
"Transfer-Encoding is unsupported; use a single Content-Length header",
|
|
245
|
+
)
|
|
246
|
+
if not values:
|
|
247
|
+
return 0
|
|
248
|
+
|
|
249
|
+
raw = str(values[0]).strip()
|
|
250
|
+
if not raw or not re.fullmatch(r"[0-9]+", raw):
|
|
251
|
+
raise _RequestBodyError(
|
|
252
|
+
HTTPStatus.BAD_REQUEST,
|
|
253
|
+
"Content-Length must be a non-negative decimal integer",
|
|
254
|
+
)
|
|
255
|
+
digits = raw.lstrip("0") or "0"
|
|
256
|
+
maximum = str(_MAX_REQUEST_BODY_BYTES)
|
|
257
|
+
if len(digits) > len(maximum) or (len(digits) == len(maximum) and digits > maximum):
|
|
258
|
+
raise _RequestBodyError(
|
|
259
|
+
HTTPStatus.REQUEST_ENTITY_TOO_LARGE,
|
|
260
|
+
f"Request body exceeds the {_MAX_REQUEST_BODY_BYTES}-byte limit",
|
|
261
|
+
)
|
|
262
|
+
return int(digits)
|
|
263
|
+
|
|
264
|
+
def _read_request_body(self, length: int, *, retain: bool = True) -> bytes:
|
|
265
|
+
"""Read exactly ``length`` bytes with a bounded per-read idle timeout."""
|
|
266
|
+
if length <= 0:
|
|
267
|
+
return b""
|
|
268
|
+
|
|
269
|
+
connection = getattr(self, "connection", None)
|
|
270
|
+
original_timeout = None
|
|
271
|
+
if connection is not None:
|
|
272
|
+
original_timeout = connection.gettimeout()
|
|
273
|
+
connection.settimeout(_REQUEST_BODY_IDLE_TIMEOUT)
|
|
274
|
+
chunks: list[bytes] = []
|
|
275
|
+
remaining = length
|
|
276
|
+
try:
|
|
277
|
+
while remaining:
|
|
278
|
+
try:
|
|
279
|
+
chunk = self.rfile.read(min(remaining, _MAX_BODY_READ_CHUNK))
|
|
280
|
+
except (TimeoutError, OSError) as error:
|
|
281
|
+
raise _RequestBodyError(
|
|
282
|
+
HTTPStatus.BAD_REQUEST,
|
|
283
|
+
"Request body was incomplete or idle before its Content-Length was satisfied",
|
|
284
|
+
) from error
|
|
285
|
+
if not chunk:
|
|
286
|
+
raise _RequestBodyError(
|
|
287
|
+
HTTPStatus.BAD_REQUEST,
|
|
288
|
+
"Request body was shorter than its Content-Length",
|
|
289
|
+
)
|
|
290
|
+
remaining -= len(chunk)
|
|
291
|
+
if retain:
|
|
292
|
+
chunks.append(chunk)
|
|
293
|
+
finally:
|
|
294
|
+
if connection is not None:
|
|
295
|
+
connection.settimeout(original_timeout)
|
|
296
|
+
return b"".join(chunks) if retain else b""
|
|
297
|
+
|
|
298
|
+
def _discard_request_body(self, length: int | None = None) -> None:
|
|
299
|
+
"""Drain a bounded rejected body and close if bytes remain unsynchronized."""
|
|
300
|
+
if length is None:
|
|
301
|
+
try:
|
|
302
|
+
length = self._parse_content_length()
|
|
303
|
+
except _RequestBodyError:
|
|
304
|
+
return
|
|
305
|
+
if length <= 0:
|
|
306
|
+
return
|
|
307
|
+
drain_length = min(length, _MAX_REJECTED_BODY_DRAIN)
|
|
308
|
+
if drain_length < length:
|
|
309
|
+
self.close_connection = True
|
|
310
|
+
self._read_request_body(drain_length, retain=False)
|
|
311
|
+
|
|
312
|
+
def _read_json_body(self) -> dict[str, Any]:
|
|
313
|
+
body = getattr(self, "_request_body", None)
|
|
314
|
+
if body is None:
|
|
315
|
+
body = self._read_request_body(self._parse_content_length())
|
|
316
|
+
if not body:
|
|
317
|
+
return {}
|
|
318
|
+
try:
|
|
319
|
+
val = json.loads(body.decode("utf-8"))
|
|
320
|
+
return val if isinstance(val, dict) else {}
|
|
321
|
+
except Exception:
|
|
322
|
+
return {}
|
|
323
|
+
|
|
324
|
+
def _send_asset(self, filename: str, content_type: str) -> None:
|
|
325
|
+
try:
|
|
326
|
+
body = (_ASSET_DIR / filename).read_bytes()
|
|
327
|
+
except OSError:
|
|
328
|
+
self._send_response(HTTPStatus.NOT_FOUND, "text/plain; charset=utf-8", b"Asset not found\n")
|
|
329
|
+
return
|
|
330
|
+
self._send_response(HTTPStatus.OK, content_type, body)
|
|
331
|
+
|
|
332
|
+
def _handle_status(self) -> None:
|
|
333
|
+
workspace = self.server_inst.workspace
|
|
334
|
+
branch = "main"
|
|
335
|
+
try:
|
|
336
|
+
res = subprocess.run(
|
|
337
|
+
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
338
|
+
cwd=workspace,
|
|
339
|
+
capture_output=True,
|
|
340
|
+
text=True,
|
|
341
|
+
check=False,
|
|
342
|
+
)
|
|
343
|
+
if res.returncode == 0 and res.stdout.strip():
|
|
344
|
+
branch = res.stdout.strip()
|
|
345
|
+
except Exception:
|
|
346
|
+
pass
|
|
347
|
+
|
|
348
|
+
# Check server endpoints with loading / ready distinction
|
|
349
|
+
ollama_online, ollama_status = self._probe_server_status("http://127.0.0.1:11434/api/tags")
|
|
350
|
+
llama_online, llama_status = self._probe_server_status("http://127.0.0.1:8080/v1/models")
|
|
351
|
+
|
|
352
|
+
# 1. First priority: real hardware readings from nvidia-smi. Missing
|
|
353
|
+
# telemetry is an unknown, never a synthetic 16 GiB "System GPU".
|
|
354
|
+
gpu_telemetry = get_nvidia_gpu_telemetry()
|
|
355
|
+
vram_info = gpu_telemetry or {
|
|
356
|
+
"status": "unavailable",
|
|
357
|
+
"source": "nvidia-smi",
|
|
358
|
+
"error": "GPU telemetry is unavailable on this host",
|
|
359
|
+
"gpu_name": None,
|
|
360
|
+
"used_mb": None,
|
|
361
|
+
"total_mb": None,
|
|
362
|
+
"used_gb": None,
|
|
363
|
+
"total_gb": None,
|
|
364
|
+
"percent": None,
|
|
365
|
+
"utilization_pct": None,
|
|
366
|
+
"temp_c": None,
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
# Ollama can confirm loaded-model bytes, but /api/ps is not a GPU
|
|
370
|
+
# capacity source. Keep this partial observation separate from VRAM
|
|
371
|
+
# capacity and percentage fields.
|
|
372
|
+
if not gpu_telemetry and ollama_online:
|
|
373
|
+
try:
|
|
374
|
+
client = build_client(get_profile(self.server_inst.default_profile))
|
|
375
|
+
snap = ModelMemoryManager(client).snapshot()
|
|
376
|
+
if snap.is_supported:
|
|
377
|
+
vram_info["loaded_models"] = [
|
|
378
|
+
{
|
|
379
|
+
"name": model.name,
|
|
380
|
+
"size_vram": model.size_vram,
|
|
381
|
+
"size": model.size,
|
|
382
|
+
"expires_at": model.expires_at,
|
|
383
|
+
}
|
|
384
|
+
for model in snap.models
|
|
385
|
+
]
|
|
386
|
+
vram_info["loaded_vram_bytes"] = snap.total_vram_bytes
|
|
387
|
+
vram_info["loaded_vram_source"] = "ollama:/api/ps"
|
|
388
|
+
except Exception:
|
|
389
|
+
pass
|
|
390
|
+
|
|
391
|
+
task_counts = {"queued": 0, "running": 0}
|
|
392
|
+
for record in self.server_inst.load_tasks():
|
|
393
|
+
status = record.get("status")
|
|
394
|
+
if status in task_counts:
|
|
395
|
+
task_counts[status] += 1
|
|
396
|
+
|
|
397
|
+
payload = {
|
|
398
|
+
"status": "healthy",
|
|
399
|
+
"uptime_seconds": round(time.monotonic() - self.server_inst.started_at, 2),
|
|
400
|
+
"workspace": str(Path(workspace).resolve()),
|
|
401
|
+
"workspace_name": Path(workspace).resolve().name,
|
|
402
|
+
"git_branch": branch,
|
|
403
|
+
"profile": self.server_inst.default_profile,
|
|
404
|
+
"servers": {
|
|
405
|
+
"ollama": {"online": ollama_online, "status": ollama_status, "endpoint": "http://127.0.0.1:11434"},
|
|
406
|
+
"llama_server": {
|
|
407
|
+
"online": llama_online,
|
|
408
|
+
"status": llama_status,
|
|
409
|
+
"endpoint": "http://127.0.0.1:8080",
|
|
410
|
+
"requested_ctx": self.server_inst.llama_num_ctx,
|
|
411
|
+
"effective_ctx": self.server_inst.llama_effective_ctx,
|
|
412
|
+
},
|
|
413
|
+
},
|
|
414
|
+
"vram": vram_info,
|
|
415
|
+
"stats": self.server_inst.stats.snapshot() if self.server_inst.stats else {},
|
|
416
|
+
"tasks": task_counts,
|
|
417
|
+
}
|
|
418
|
+
self._send_json(payload)
|
|
419
|
+
|
|
420
|
+
def _handle_gpu_telemetry(self) -> None:
|
|
421
|
+
gpu = get_nvidia_gpu_telemetry()
|
|
422
|
+
if gpu and gpu.get("status") == "ok":
|
|
423
|
+
self._send_json({"status": "ok", "gpu": gpu})
|
|
424
|
+
else:
|
|
425
|
+
self._send_json({
|
|
426
|
+
"status": gpu.get("status", "unavailable") if gpu else "unavailable",
|
|
427
|
+
"message": gpu.get("error", "nvidia-smi telemetry not available on this host") if gpu else "nvidia-smi telemetry not available on this host",
|
|
428
|
+
})
|
|
429
|
+
|
|
430
|
+
def _handle_models(self) -> None:
|
|
431
|
+
profiles_data = []
|
|
432
|
+
for name in list_profiles():
|
|
433
|
+
prof = get_profile(name)
|
|
434
|
+
profiles_data.append({
|
|
435
|
+
"name": name,
|
|
436
|
+
"model": prof.model,
|
|
437
|
+
"provider": prof.provider,
|
|
438
|
+
"endpoint": prof.endpoint,
|
|
439
|
+
"num_ctx": prof.num_ctx,
|
|
440
|
+
"policy_context_cap": prof.max_context_length,
|
|
441
|
+
"model_context_limit": prof.model_context_limit,
|
|
442
|
+
})
|
|
443
|
+
|
|
444
|
+
ollama_online, _ = self._probe_server_status("http://127.0.0.1:11434/api/tags")
|
|
445
|
+
ollama_models = discover_local_ollama_models()
|
|
446
|
+
|
|
447
|
+
llama_online, _ = self._probe_server_status("http://127.0.0.1:8080/v1/models")
|
|
448
|
+
llama_models = self._list_llama_model_ids() if llama_online else []
|
|
449
|
+
|
|
450
|
+
from ...model_scanner import get_model_registry
|
|
451
|
+
discovered_ggufs = [m.to_dict() for m in get_model_registry().get_models(auto_scan=True)]
|
|
452
|
+
|
|
453
|
+
self._send_json({
|
|
454
|
+
"profiles": profiles_data,
|
|
455
|
+
"active_profile": self.server_inst.default_profile,
|
|
456
|
+
"backends": {
|
|
457
|
+
"ollama": {"online": ollama_online, "endpoint": "http://127.0.0.1:11434", "models": ollama_models},
|
|
458
|
+
"llama_server": {"online": llama_online, "endpoint": "http://127.0.0.1:8080", "models": llama_models},
|
|
459
|
+
"local_gguf": {"models": discovered_ggufs},
|
|
460
|
+
},
|
|
461
|
+
})
|
|
462
|
+
|
|
463
|
+
def _handle_model_scan(self) -> None:
|
|
464
|
+
from ...model_scanner import get_model_registry
|
|
465
|
+
data = self._read_json_body()
|
|
466
|
+
deep = bool(data.get("deep", False))
|
|
467
|
+
registry = get_model_registry()
|
|
468
|
+
models = registry.scan(deep=deep)
|
|
469
|
+
self._send_json({"status": "ok", "total_models": len(models), "models": [m.to_dict() for m in models]})
|
|
470
|
+
|
|
471
|
+
def _handle_model_add_dir(self) -> None:
|
|
472
|
+
from ...model_scanner import get_model_registry
|
|
473
|
+
data = self._read_json_body()
|
|
474
|
+
path_val = data.get("path", "").strip()
|
|
475
|
+
if not path_val or not Path(path_val).is_dir():
|
|
476
|
+
self._send_json({"status": "failed", "error": f"Directory does not exist: {path_val}"})
|
|
477
|
+
return
|
|
478
|
+
added = get_model_registry().add_custom_directory(path_val)
|
|
479
|
+
self._send_json({"status": "added" if added else "already_present", "path": path_val})
|
|
480
|
+
|
|
481
|
+
def _handle_model_remove_dir(self) -> None:
|
|
482
|
+
from ...model_scanner import get_model_registry
|
|
483
|
+
data = self._read_json_body()
|
|
484
|
+
path_val = data.get("path", "").strip()
|
|
485
|
+
removed = get_model_registry().remove_custom_directory(path_val)
|
|
486
|
+
self._send_json({"status": "removed" if removed else "not_found", "path": path_val})
|
|
487
|
+
|
|
488
|
+
def _handle_workspace_files(self) -> None:
|
|
489
|
+
workspace = Path(self.server_inst.workspace)
|
|
490
|
+
files = []
|
|
491
|
+
for p in workspace.rglob("*"):
|
|
492
|
+
if p.is_file() and not any(part.startswith(".") or part in ("__pycache__", "venv", ".git", "build", "dist", "node_modules") for part in p.parts):
|
|
493
|
+
rel = str(p.relative_to(workspace).as_posix())
|
|
494
|
+
files.append({
|
|
495
|
+
"path": rel,
|
|
496
|
+
"name": p.name,
|
|
497
|
+
"size_bytes": p.stat().st_size,
|
|
498
|
+
"is_code": p.suffix in (".py", ".ts", ".js", ".go", ".rs", ".json", ".md"),
|
|
499
|
+
})
|
|
500
|
+
files.sort(key=lambda x: (not x["is_code"], x["path"]))
|
|
501
|
+
self._send_json({"workspace": str(workspace), "files": files[:80]})
|
|
502
|
+
|
|
503
|
+
def _handle_sessions(self) -> None:
|
|
504
|
+
try:
|
|
505
|
+
self._send_json({"sessions": self.server_inst.load_sessions()})
|
|
506
|
+
except Exception as error:
|
|
507
|
+
self._send_json({"status": "failed", "sessions": [], "error": str(error)})
|
|
508
|
+
|
|
509
|
+
def _handle_create_session(self) -> None:
|
|
510
|
+
data = self._read_json_body()
|
|
511
|
+
session_id = data.get("id") or f"sess-{uuid.uuid4().hex[:12]}"
|
|
512
|
+
session_type = data.get("type", "user")
|
|
513
|
+
title = data.get("title", "New Task Session")
|
|
514
|
+
file_path = data.get("file", "src/main.py")
|
|
515
|
+
patch = data.get("patch", "")
|
|
516
|
+
checks = data.get("checks", [])
|
|
517
|
+
|
|
518
|
+
session = {
|
|
519
|
+
"id": session_id,
|
|
520
|
+
"type": session_type,
|
|
521
|
+
"title": title,
|
|
522
|
+
"file": file_path,
|
|
523
|
+
"patch": patch,
|
|
524
|
+
"checks": checks,
|
|
525
|
+
"status": data.get("status", "Active"),
|
|
526
|
+
"evidence_verified": False,
|
|
527
|
+
"time": "Just now",
|
|
528
|
+
}
|
|
529
|
+
for key in (
|
|
530
|
+
"prompt",
|
|
531
|
+
"profile",
|
|
532
|
+
"message",
|
|
533
|
+
"thinking",
|
|
534
|
+
"testResult",
|
|
535
|
+
"plan",
|
|
536
|
+
"test_evidence",
|
|
537
|
+
"files",
|
|
538
|
+
):
|
|
539
|
+
if key in data:
|
|
540
|
+
session[key] = data[key]
|
|
541
|
+
try:
|
|
542
|
+
self.server_inst.save_session(session)
|
|
543
|
+
except Exception as error:
|
|
544
|
+
self._send_json({"status": "failed", "persisted": False, "error": str(error)})
|
|
545
|
+
return
|
|
546
|
+
self._send_json({"status": "created", "persisted": True, "session": session})
|
|
547
|
+
|
|
548
|
+
def _save_chat_session(
|
|
549
|
+
self,
|
|
550
|
+
response: dict[str, Any],
|
|
551
|
+
task_checks: list[str],
|
|
552
|
+
*,
|
|
553
|
+
status: str,
|
|
554
|
+
evidence_verified: bool = False,
|
|
555
|
+
) -> None:
|
|
556
|
+
prompt = str(response.get("prompt") or "")
|
|
557
|
+
session = {
|
|
558
|
+
"id": response.get("task_id") or f"sess-{uuid.uuid4().hex[:12]}",
|
|
559
|
+
"type": "user",
|
|
560
|
+
"mode": response.get("mode", "chat"),
|
|
561
|
+
"title": prompt[:50] + ("..." if len(prompt) > 50 else ""),
|
|
562
|
+
"prompt": prompt,
|
|
563
|
+
"profile": response.get("profile", self.server_inst.default_profile),
|
|
564
|
+
"file": response.get("file", "workspace"),
|
|
565
|
+
"files": response.get("files") or [],
|
|
566
|
+
"patch": response.get("patch", ""),
|
|
567
|
+
"checks": task_checks,
|
|
568
|
+
"test_evidence": response.get("checks") or [],
|
|
569
|
+
"status": status,
|
|
570
|
+
"evidence_verified": evidence_verified,
|
|
571
|
+
"time": "Just now",
|
|
572
|
+
"message": response.get("message", ""),
|
|
573
|
+
"thinking": response.get("thinking", ""),
|
|
574
|
+
"testResult": response.get("testResult", "NOT RUN"),
|
|
575
|
+
}
|
|
576
|
+
if response.get("plan") is not None:
|
|
577
|
+
session["plan"] = response["plan"]
|
|
578
|
+
try:
|
|
579
|
+
self.server_inst.save_session(session)
|
|
580
|
+
response["persisted"] = True
|
|
581
|
+
except Exception as error:
|
|
582
|
+
response["persisted"] = False
|
|
583
|
+
response["persistence_error"] = str(error)
|
|
584
|
+
|
|
585
|
+
def _server_log_file(self, backend: str) -> Path:
|
|
586
|
+
log_dir = Path(self.server_inst.workspace) / ".local_agent" / "logs"
|
|
587
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
588
|
+
return log_dir / f"{backend}.log"
|
|
589
|
+
|
|
590
|
+
def _read_log_tail(self, backend: str, n: int = 40) -> str:
|
|
591
|
+
log_file = self._server_log_file(backend)
|
|
592
|
+
if not log_file.exists():
|
|
593
|
+
return ""
|
|
594
|
+
try:
|
|
595
|
+
with open(log_file, "r", encoding="utf-8", errors="replace") as fh:
|
|
596
|
+
lines = fh.readlines()
|
|
597
|
+
return "".join(lines[-n:])
|
|
598
|
+
except Exception:
|
|
599
|
+
return ""
|
|
600
|
+
|
|
601
|
+
def _wait_for_ready(self, url: str, proc: subprocess.Popen, backend: str, timeout: float = 30.0) -> dict:
|
|
602
|
+
deadline = time.monotonic() + timeout
|
|
603
|
+
while time.monotonic() < deadline:
|
|
604
|
+
online, status = self._probe_server_status(url)
|
|
605
|
+
if online and status == "ready":
|
|
606
|
+
return {"ok": True, "status": "started"}
|
|
607
|
+
if proc.poll() is not None:
|
|
608
|
+
return {
|
|
609
|
+
"ok": False,
|
|
610
|
+
"status": "failed",
|
|
611
|
+
"error": f"Server exited during startup (code {proc.returncode}). Log tail:\n{self._read_log_tail(backend)}",
|
|
612
|
+
}
|
|
613
|
+
time.sleep(0.5)
|
|
614
|
+
|
|
615
|
+
if proc.poll() is not None:
|
|
616
|
+
return {
|
|
617
|
+
"ok": False,
|
|
618
|
+
"status": "failed",
|
|
619
|
+
"error": f"Server exited during startup (code {proc.returncode}). Log tail:\n{self._read_log_tail(backend)}",
|
|
620
|
+
}
|
|
621
|
+
return {
|
|
622
|
+
"ok": False,
|
|
623
|
+
"status": "loading",
|
|
624
|
+
"message": "server started but not ready yet",
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
def _handle_server_start(self) -> None:
|
|
628
|
+
data = self._read_json_body()
|
|
629
|
+
backend = data.get("backend", "ollama")
|
|
630
|
+
custom_path = data.get("custom_path")
|
|
631
|
+
model_path = data.get("model_path")
|
|
632
|
+
|
|
633
|
+
if backend == "ollama":
|
|
634
|
+
ollama_bin = self._find_ollama_bin()
|
|
635
|
+
if not ollama_bin:
|
|
636
|
+
self._send_json({"status": "failed", "error": "Ollama executable not found. Please install Ollama from ollama.com"})
|
|
637
|
+
return
|
|
638
|
+
try:
|
|
639
|
+
log_handle = open(self._server_log_file("ollama"), "ab")
|
|
640
|
+
proc = subprocess.Popen(
|
|
641
|
+
[ollama_bin, "serve"],
|
|
642
|
+
stdout=log_handle,
|
|
643
|
+
stderr=log_handle,
|
|
644
|
+
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
|
645
|
+
)
|
|
646
|
+
log_handle.close()
|
|
647
|
+
self.server_inst.spawned_processes["ollama"] = proc
|
|
648
|
+
result = self._wait_for_ready("http://127.0.0.1:11434/api/tags", proc, "ollama")
|
|
649
|
+
if result.get("status") == "started":
|
|
650
|
+
self._send_json({"status": "started", "backend": "ollama", "pid": proc.pid})
|
|
651
|
+
else:
|
|
652
|
+
self._send_json(result)
|
|
653
|
+
except Exception as error:
|
|
654
|
+
self._send_json({"status": "failed", "error": f"{error}\nLog tail:\n{self._read_log_tail('ollama')}"})
|
|
655
|
+
|
|
656
|
+
elif backend in ("llama_server", "llama.cpp"):
|
|
657
|
+
if self._probe_models_loaded("http://127.0.0.1:8080/v1/models"):
|
|
658
|
+
self._send_json({
|
|
659
|
+
"status": "already_running",
|
|
660
|
+
"backend": "llama_server",
|
|
661
|
+
"endpoint": "http://127.0.0.1:8080",
|
|
662
|
+
})
|
|
663
|
+
return
|
|
664
|
+
|
|
665
|
+
llama_bin = self._find_llama_server_bin(custom_path)
|
|
666
|
+
if not llama_bin:
|
|
667
|
+
self._send_json({
|
|
668
|
+
"status": "failed",
|
|
669
|
+
"error": (
|
|
670
|
+
"llama-server executable not found in system PATH. "
|
|
671
|
+
"Add your llama-server directory to PATH or set LLAMA_SERVER_PATH."
|
|
672
|
+
),
|
|
673
|
+
})
|
|
674
|
+
return
|
|
675
|
+
|
|
676
|
+
self._kill_llama_on_port(8080)
|
|
677
|
+
gguf_path = self._find_gguf_model(model_path)
|
|
678
|
+
cmd = [llama_bin, "--port", "8080"]
|
|
679
|
+
if gguf_path:
|
|
680
|
+
alias = Path(gguf_path).stem
|
|
681
|
+
cmd.extend(["-m", gguf_path, "-c", str(self.server_inst.llama_num_ctx), "--alias", alias])
|
|
682
|
+
self.server_inst.llama_gguf_path = gguf_path
|
|
683
|
+
self.server_inst.llama_gguf_label = alias
|
|
684
|
+
|
|
685
|
+
try:
|
|
686
|
+
log_handle = open(self._server_log_file("llama_server"), "ab")
|
|
687
|
+
proc = subprocess.Popen(
|
|
688
|
+
cmd,
|
|
689
|
+
stdout=log_handle,
|
|
690
|
+
stderr=log_handle,
|
|
691
|
+
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
|
692
|
+
)
|
|
693
|
+
log_handle.close()
|
|
694
|
+
self.server_inst.spawned_processes["llama_server"] = proc
|
|
695
|
+
if gguf_path:
|
|
696
|
+
result = self._wait_for_model_loaded(proc, "llama_server")
|
|
697
|
+
else:
|
|
698
|
+
result = self._wait_for_ready("http://127.0.0.1:8080/v1/models", proc, "llama_server")
|
|
699
|
+
if result.get("status") == "started":
|
|
700
|
+
self._send_json({
|
|
701
|
+
"status": "started",
|
|
702
|
+
"backend": "llama_server",
|
|
703
|
+
"pid": proc.pid,
|
|
704
|
+
"bin": llama_bin,
|
|
705
|
+
"model": gguf_path or "unspecified",
|
|
706
|
+
})
|
|
707
|
+
else:
|
|
708
|
+
self._send_json(result)
|
|
709
|
+
except Exception as error:
|
|
710
|
+
self._send_json({"status": "failed", "error": f"{error}\nLog tail:\n{self._read_log_tail('llama_server')}"})
|
|
711
|
+
else:
|
|
712
|
+
self._send_json({"status": "failed", "error": f"Unknown backend: {backend}"})
|
|
713
|
+
|
|
714
|
+
def _find_llama_server_bin(self, custom: str | None = None) -> str | None:
|
|
715
|
+
return discover_llama_server_binary(custom)
|
|
716
|
+
|
|
717
|
+
def _find_ollama_bin(self) -> str | None:
|
|
718
|
+
live_path = get_live_system_path()
|
|
719
|
+
ollama_bin = shutil.which("ollama", path=live_path) or shutil.which("ollama.exe", path=live_path)
|
|
720
|
+
if not ollama_bin:
|
|
721
|
+
appdata_ollama = Path(os.path.expandvars(r"%LOCALAPPDATA%\Programs\Ollama\ollama.exe"))
|
|
722
|
+
if appdata_ollama.exists():
|
|
723
|
+
ollama_bin = str(appdata_ollama)
|
|
724
|
+
return ollama_bin
|
|
725
|
+
|
|
726
|
+
def _ensure_ollama_running(self) -> bool:
|
|
727
|
+
"""Start ollama serve if offline; return True when the API is reachable."""
|
|
728
|
+
online, _ = self._probe_server_status("http://127.0.0.1:11434/api/tags")
|
|
729
|
+
if online:
|
|
730
|
+
return True
|
|
731
|
+
ollama_bin = self._find_ollama_bin()
|
|
732
|
+
if not ollama_bin:
|
|
733
|
+
return False
|
|
734
|
+
try:
|
|
735
|
+
log_handle = open(self._server_log_file("ollama"), "ab")
|
|
736
|
+
proc = subprocess.Popen(
|
|
737
|
+
[ollama_bin, "serve"],
|
|
738
|
+
stdout=log_handle,
|
|
739
|
+
stderr=log_handle,
|
|
740
|
+
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
|
741
|
+
)
|
|
742
|
+
log_handle.close()
|
|
743
|
+
self.server_inst.spawned_processes["ollama"] = proc
|
|
744
|
+
except Exception:
|
|
745
|
+
return False
|
|
746
|
+
result = self._wait_for_ready("http://127.0.0.1:11434/api/tags", proc, "ollama")
|
|
747
|
+
return result.get("status") == "started"
|
|
748
|
+
|
|
749
|
+
def _find_gguf_model(self, custom: str | None = None) -> str | None:
|
|
750
|
+
if custom and custom.strip() and Path(custom.strip()).is_file():
|
|
751
|
+
return str(Path(custom.strip()).resolve())
|
|
752
|
+
|
|
753
|
+
env_model = os.environ.get("LLAMA_MODEL_PATH") or os.environ.get("GGUF_MODEL_PATH")
|
|
754
|
+
if env_model and Path(env_model.strip()).is_file():
|
|
755
|
+
return str(Path(env_model.strip()).resolve())
|
|
756
|
+
|
|
757
|
+
return None
|
|
758
|
+
|
|
759
|
+
def _handle_server_stop(self) -> None:
|
|
760
|
+
data = self._read_json_body()
|
|
761
|
+
backend = data.get("backend", "all")
|
|
762
|
+
stopped = []
|
|
763
|
+
|
|
764
|
+
for name in list(self.server_inst.spawned_processes):
|
|
765
|
+
if backend in (name, "all"):
|
|
766
|
+
self._stop_backend(name)
|
|
767
|
+
stopped.append(name)
|
|
768
|
+
|
|
769
|
+
self._send_json({"status": "stopped", "backends": stopped})
|
|
770
|
+
|
|
771
|
+
def _auto_fit_llama_ctx(self, gguf: dict) -> int | None:
|
|
772
|
+
"""Fit llama-server ctx from GGUF metadata + free VRAM for num_ctx=auto.
|
|
773
|
+
|
|
774
|
+
Best-effort: never raises; leaves ``llama_num_ctx`` untouched when the
|
|
775
|
+
metadata, exact model size, or VRAM telemetry is unavailable. A
|
|
776
|
+
numeric context is applied only for an explicit ``fits`` result.
|
|
777
|
+
"""
|
|
778
|
+
params = read_gguf_ctx_params(gguf["path"])
|
|
779
|
+
gpu = get_nvidia_gpu_telemetry()
|
|
780
|
+
if not gpu or not isinstance(gpu.get("total_mb"), (int, float)) or not isinstance(gpu.get("used_mb"), (int, float)):
|
|
781
|
+
return None
|
|
782
|
+
total_mb = float(gpu["total_mb"])
|
|
783
|
+
used_mb = float(gpu["used_mb"])
|
|
784
|
+
if total_mb <= 0 or used_mb < 0 or used_mb > total_mb:
|
|
785
|
+
return None
|
|
786
|
+
try:
|
|
787
|
+
weights_bytes = Path(gguf["path"]).stat().st_size
|
|
788
|
+
except (KeyError, OSError, TypeError):
|
|
789
|
+
return None
|
|
790
|
+
kv = (
|
|
791
|
+
kv_bytes_per_token(
|
|
792
|
+
params["n_layers"], params["n_head_kv"], params["head_dim"]
|
|
793
|
+
)
|
|
794
|
+
if params.get("n_layers") and params.get("n_head_kv") and params.get("head_dim")
|
|
795
|
+
else 0
|
|
796
|
+
)
|
|
797
|
+
native = params.get("native_context_length")
|
|
798
|
+
if not native:
|
|
799
|
+
return None
|
|
800
|
+
result = fit_context(
|
|
801
|
+
int((total_mb - used_mb) * 1024 * 1024),
|
|
802
|
+
weights_bytes,
|
|
803
|
+
kv,
|
|
804
|
+
max_ctx=native,
|
|
805
|
+
)
|
|
806
|
+
if result.status == "fits" and result.context is not None:
|
|
807
|
+
return result.context
|
|
808
|
+
return None
|
|
809
|
+
|
|
810
|
+
def _handle_model_load(self) -> None:
|
|
811
|
+
data = self._read_json_body()
|
|
812
|
+
model_name = data.get("model") or self.server_inst.default_profile
|
|
813
|
+
requested_ctx = data.get("num_ctx")
|
|
814
|
+
requested_llama_ctx = None
|
|
815
|
+
if requested_ctx is not None and requested_ctx != "auto":
|
|
816
|
+
ctx, ctx_error = self._parse_ctx_override(requested_ctx)
|
|
817
|
+
if ctx_error:
|
|
818
|
+
self._send_json({"status": "failed", "error": ctx_error}, HTTPStatus.BAD_REQUEST)
|
|
819
|
+
return
|
|
820
|
+
requested_llama_ctx = ctx
|
|
821
|
+
try:
|
|
822
|
+
gguf = find_discovered_gguf(model_name)
|
|
823
|
+
if gguf and gguf.get("path"):
|
|
824
|
+
launch_ctx = requested_llama_ctx
|
|
825
|
+
if requested_ctx == "auto":
|
|
826
|
+
auto_ctx = self._auto_fit_llama_ctx(gguf)
|
|
827
|
+
# Evaluate auto for the new model. If fit evidence is
|
|
828
|
+
# unavailable, use the documented launch default rather
|
|
829
|
+
# than inheriting a previous model's context.
|
|
830
|
+
launch_ctx = auto_ctx or _DEFAULT_LLAMA_CTX
|
|
831
|
+
result = self._launch_llama_model(
|
|
832
|
+
gguf["path"],
|
|
833
|
+
gguf.get("display_name") or gguf.get("name") or model_name,
|
|
834
|
+
num_ctx=launch_ctx,
|
|
835
|
+
)
|
|
836
|
+
if result.get("status") == "started":
|
|
837
|
+
try:
|
|
838
|
+
warmup = ModelProfile(
|
|
839
|
+
name=gguf.get("display_name") or model_name,
|
|
840
|
+
model=gguf.get("display_name") or model_name,
|
|
841
|
+
provider="openai",
|
|
842
|
+
endpoint="http://127.0.0.1:8080",
|
|
843
|
+
num_ctx=self.server_inst.llama_num_ctx,
|
|
844
|
+
)
|
|
845
|
+
build_client(warmup).complete("warmup", system="warmup", max_tokens=1)
|
|
846
|
+
except Exception:
|
|
847
|
+
pass
|
|
848
|
+
self._send_json({
|
|
849
|
+
"status": "loaded",
|
|
850
|
+
"model": model_name,
|
|
851
|
+
"backend": "llama_server",
|
|
852
|
+
"path": gguf["path"],
|
|
853
|
+
"effective_ctx": self.server_inst.llama_effective_ctx,
|
|
854
|
+
**({"ctx_warning": result["ctx_warning"]} if result.get("ctx_warning") else {}),
|
|
855
|
+
})
|
|
856
|
+
else:
|
|
857
|
+
self._send_json(result)
|
|
858
|
+
return
|
|
859
|
+
|
|
860
|
+
prof = resolve_model_profile(model_name)
|
|
861
|
+
client = build_client(prof)
|
|
862
|
+
if prof.provider == "ollama":
|
|
863
|
+
if not self._ensure_ollama_running():
|
|
864
|
+
self._send_json({"status": "failed", "error": "Ollama (port 11434) could not be started. Start it manually or check PATH."})
|
|
865
|
+
return
|
|
866
|
+
if hasattr(client, "_request_json"):
|
|
867
|
+
try:
|
|
868
|
+
client._request_json("POST", "/api/generate", {"model": prof.model, "prompt": "", "keep_alive": "10m"})
|
|
869
|
+
except OllamaError as oe:
|
|
870
|
+
if "not found" in str(oe).lower():
|
|
871
|
+
base = prof.model.split(":", 1)[0]
|
|
872
|
+
candidates = [prof.model, f"{base}:latest", base]
|
|
873
|
+
avail = set()
|
|
874
|
+
try:
|
|
875
|
+
for m in client.available_models().get("models", []):
|
|
876
|
+
if isinstance(m, dict) and "name" in m:
|
|
877
|
+
avail.add(m["name"])
|
|
878
|
+
except Exception:
|
|
879
|
+
avail = set(discover_local_ollama_models())
|
|
880
|
+
alt = next((c for c in candidates if c in avail), None)
|
|
881
|
+
if alt is None:
|
|
882
|
+
raise
|
|
883
|
+
client._request_json("POST", "/api/generate", {"model": alt, "prompt": "", "keep_alive": "10m"})
|
|
884
|
+
else:
|
|
885
|
+
raise
|
|
886
|
+
else:
|
|
887
|
+
client.complete("warmup", system="warmup", max_tokens=1)
|
|
888
|
+
elif prof.provider == "openai":
|
|
889
|
+
if hasattr(client, "complete"):
|
|
890
|
+
client.complete("warmup", system="warmup", max_tokens=1)
|
|
891
|
+
elif hasattr(client, "chat"):
|
|
892
|
+
client.chat([{"role": "user", "content": "warmup"}])
|
|
893
|
+
else:
|
|
894
|
+
if hasattr(client, "complete"):
|
|
895
|
+
client.complete("warmup", system="warmup", max_tokens=1)
|
|
896
|
+
self._send_json({"status": "loaded", "model": model_name, "backend": prof.provider})
|
|
897
|
+
except Exception as error:
|
|
898
|
+
kind = _classify_backend_error(error)
|
|
899
|
+
if kind == "offline":
|
|
900
|
+
is_llama = prof.provider == "openai"
|
|
901
|
+
backend_name = "llama-server (port 8080)" if is_llama else "Ollama (port 11434)"
|
|
902
|
+
self._send_json({"status": "failed", "error": f"{backend_name} is OFFLINE. Start it in Local Inference Servers."})
|
|
903
|
+
else:
|
|
904
|
+
self._send_json({"status": "failed", "error": str(error)})
|
|
905
|
+
|
|
906
|
+
def _launch_llama_model(self, gguf_path: str, model_label: str, num_ctx: int | None = None) -> dict:
|
|
907
|
+
"""Launch llama-server with a specific GGUF file (single-model server).
|
|
908
|
+
|
|
909
|
+
Uses ``--alias`` so the model id exposed by ``/v1/models`` is the clean
|
|
910
|
+
stem (e.g. ``Ling-3.0-tiny-Q6_K``) rather than the on-disk filename.
|
|
911
|
+
``-ngl`` is intentionally omitted: forcing ``-ngl 99`` aborts the load
|
|
912
|
+
when free VRAM cannot fit every layer, whereas the default auto-fit
|
|
913
|
+
spills only the overflow to CPU.
|
|
914
|
+
|
|
915
|
+
Resource preflight (P1): before stopping the running server, the
|
|
916
|
+
requested context is clamped to what the GGUF geometry + free VRAM
|
|
917
|
+
actually fit, so a typo'd 200k window cannot wedge the machine.
|
|
918
|
+
On a failed relaunch the previous configuration is relaunched once so
|
|
919
|
+
the user keeps a working server.
|
|
920
|
+
"""
|
|
921
|
+
previous_ctx = self.server_inst.llama_num_ctx
|
|
922
|
+
previous_path = self.server_inst.llama_gguf_path
|
|
923
|
+
previous_label = self.server_inst.llama_gguf_label
|
|
924
|
+
effective_ctx = num_ctx
|
|
925
|
+
ctx_warning = ""
|
|
926
|
+
if num_ctx is not None:
|
|
927
|
+
requested = max(512, int(num_ctx))
|
|
928
|
+
fit = self._preflight_context_fit(gguf_path)
|
|
929
|
+
if fit.status == "does_not_fit":
|
|
930
|
+
return {
|
|
931
|
+
"status": "failed",
|
|
932
|
+
"error": fit.reason or "Requested context cannot fit the verified model and VRAM resources.",
|
|
933
|
+
}
|
|
934
|
+
if fit.status == "fits" and fit.context is not None and fit.context != requested:
|
|
935
|
+
ctx_warning = (
|
|
936
|
+
f"Requested context {requested} exceeds what free VRAM fits next to "
|
|
937
|
+
f"the model weights; clamped to {fit.context} tokens."
|
|
938
|
+
)
|
|
939
|
+
effective_ctx = fit.context
|
|
940
|
+
llama_bin = self._find_llama_server_bin(None)
|
|
941
|
+
if not llama_bin:
|
|
942
|
+
return {
|
|
943
|
+
"status": "failed",
|
|
944
|
+
"error": (
|
|
945
|
+
"llama-server executable not found in PATH. "
|
|
946
|
+
"Add your llama-server directory to PATH or set LLAMA_SERVER_PATH."
|
|
947
|
+
),
|
|
948
|
+
}
|
|
949
|
+
if effective_ctx is not None:
|
|
950
|
+
self.server_inst.llama_num_ctx = max(512, int(effective_ctx))
|
|
951
|
+
self._stop_backend("llama_server")
|
|
952
|
+
self._kill_llama_on_port(8080)
|
|
953
|
+
cmd = [
|
|
954
|
+
llama_bin, "--port", "8080", "-m", gguf_path,
|
|
955
|
+
"-c", str(self.server_inst.llama_num_ctx), "--alias", model_label,
|
|
956
|
+
]
|
|
957
|
+
try:
|
|
958
|
+
log_handle = open(self._server_log_file("llama_server"), "ab")
|
|
959
|
+
proc = subprocess.Popen(
|
|
960
|
+
cmd,
|
|
961
|
+
stdout=log_handle,
|
|
962
|
+
stderr=log_handle,
|
|
963
|
+
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
|
964
|
+
)
|
|
965
|
+
log_handle.close()
|
|
966
|
+
self.server_inst.spawned_processes["llama_server"] = proc
|
|
967
|
+
self.server_inst.llama_gguf_path = gguf_path
|
|
968
|
+
self.server_inst.llama_gguf_label = model_label
|
|
969
|
+
result = self._wait_for_model_loaded(proc, "llama_server")
|
|
970
|
+
if result.get("status") == "started":
|
|
971
|
+
effective = self._read_effective_ctx()
|
|
972
|
+
self.server_inst.llama_effective_ctx = effective
|
|
973
|
+
if effective is not None and effective != self.server_inst.llama_num_ctx:
|
|
974
|
+
readback = (
|
|
975
|
+
f"Requested context {self.server_inst.llama_num_ctx} via -c "
|
|
976
|
+
f"but llama-server reports n_ctx={effective}; the window was "
|
|
977
|
+
"likely clamped to the model's native context length."
|
|
978
|
+
)
|
|
979
|
+
ctx_warning = f"{ctx_warning} {readback}".strip() if ctx_warning else readback
|
|
980
|
+
result.update({"backend": "llama_server", "pid": proc.pid, "model": model_label})
|
|
981
|
+
if ctx_warning:
|
|
982
|
+
result["ctx_warning"] = ctx_warning
|
|
983
|
+
return result
|
|
984
|
+
failure = result
|
|
985
|
+
except Exception as error:
|
|
986
|
+
failure = {"status": "failed", "error": f"{error}\nLog tail:\n{self._read_log_tail('llama_server')}"}
|
|
987
|
+
|
|
988
|
+
# Relaunch failed: restore the previous working configuration once so
|
|
989
|
+
# the risky reload never leaves the machine without a server.
|
|
990
|
+
self.server_inst.llama_num_ctx = previous_ctx
|
|
991
|
+
self.server_inst.llama_gguf_path = previous_path
|
|
992
|
+
self.server_inst.llama_gguf_label = previous_label
|
|
993
|
+
if previous_path:
|
|
994
|
+
restore = self._launch_llama_model(previous_path, previous_label or Path(previous_path).stem, num_ctx=previous_ctx)
|
|
995
|
+
if restore.get("status") == "started":
|
|
996
|
+
failure["restored"] = f"Previous configuration restored ({Path(previous_path).name}, -c {previous_ctx})."
|
|
997
|
+
else:
|
|
998
|
+
failure["restore_error"] = restore.get("error", "unknown error")
|
|
999
|
+
return failure
|
|
1000
|
+
|
|
1001
|
+
def _preflight_context_fit(self, gguf_path: str) -> ContextFit:
|
|
1002
|
+
"""Return an explicit fit state before stopping a running backend."""
|
|
1003
|
+
try:
|
|
1004
|
+
params = read_gguf_ctx_params(gguf_path)
|
|
1005
|
+
n_layers = params.get("n_layers")
|
|
1006
|
+
n_head_kv = params.get("n_head_kv")
|
|
1007
|
+
head_dim = params.get("head_dim")
|
|
1008
|
+
if not (n_layers and n_head_kv and head_dim):
|
|
1009
|
+
return ContextFit("unknown", reason="GGUF KV-cache geometry is unavailable")
|
|
1010
|
+
native = params.get("native_context_length")
|
|
1011
|
+
if not native:
|
|
1012
|
+
return ContextFit("unknown", reason="GGUF native context length is unavailable")
|
|
1013
|
+
gpu = get_nvidia_gpu_telemetry()
|
|
1014
|
+
if not gpu or not isinstance(gpu.get("total_mb"), (int, float)) or not isinstance(gpu.get("used_mb"), (int, float)):
|
|
1015
|
+
return ContextFit("unknown", reason="GPU VRAM telemetry is unavailable")
|
|
1016
|
+
total_mb = float(gpu["total_mb"])
|
|
1017
|
+
used_mb = float(gpu["used_mb"])
|
|
1018
|
+
if total_mb <= 0 or used_mb < 0 or used_mb > total_mb:
|
|
1019
|
+
return ContextFit("unknown", reason="GPU VRAM telemetry is invalid")
|
|
1020
|
+
try:
|
|
1021
|
+
weights_bytes = Path(gguf_path).stat().st_size
|
|
1022
|
+
except OSError:
|
|
1023
|
+
return ContextFit("unknown", reason="GGUF weight size is unavailable")
|
|
1024
|
+
kv = kv_bytes_per_token(n_layers, n_head_kv, head_dim)
|
|
1025
|
+
return fit_context(
|
|
1026
|
+
int((total_mb - used_mb) * 1024 * 1024),
|
|
1027
|
+
weights_bytes,
|
|
1028
|
+
kv,
|
|
1029
|
+
max_ctx=native,
|
|
1030
|
+
)
|
|
1031
|
+
except Exception:
|
|
1032
|
+
return ContextFit("unknown", reason="Context fit could not be verified")
|
|
1033
|
+
|
|
1034
|
+
def _preflight_ctx_fit(self, gguf_path: str, requested: int) -> int | None:
|
|
1035
|
+
"""Backward-compatible numeric view of the explicit fit result."""
|
|
1036
|
+
result = self._preflight_context_fit(gguf_path)
|
|
1037
|
+
if result.status == "fits" and result.context is not None and requested > result.context:
|
|
1038
|
+
return result.context
|
|
1039
|
+
return None
|
|
1040
|
+
|
|
1041
|
+
def _wait_for_model_loaded(self, proc: subprocess.Popen, backend: str, timeout: float = 90.0) -> dict:
|
|
1042
|
+
"""Wait until llama-server actually exposes a loaded model in /v1/models."""
|
|
1043
|
+
deadline = time.monotonic() + timeout
|
|
1044
|
+
while time.monotonic() < deadline:
|
|
1045
|
+
if self._probe_models_loaded("http://127.0.0.1:8080/v1/models"):
|
|
1046
|
+
return {"ok": True, "status": "started"}
|
|
1047
|
+
if proc.poll() is not None:
|
|
1048
|
+
return {
|
|
1049
|
+
"ok": False,
|
|
1050
|
+
"status": "failed",
|
|
1051
|
+
"error": f"Server exited during startup (code {proc.returncode}). Log tail:\n{self._read_log_tail(backend)}",
|
|
1052
|
+
}
|
|
1053
|
+
time.sleep(1.0)
|
|
1054
|
+
if proc.poll() is not None:
|
|
1055
|
+
return {
|
|
1056
|
+
"ok": False,
|
|
1057
|
+
"status": "failed",
|
|
1058
|
+
"error": f"Server exited during startup (code {proc.returncode}). Log tail:\n{self._read_log_tail(backend)}",
|
|
1059
|
+
}
|
|
1060
|
+
return {"ok": False, "status": "loading", "message": "server started but model not loaded yet"}
|
|
1061
|
+
|
|
1062
|
+
def _probe_models_loaded(self, url: str) -> bool:
|
|
1063
|
+
"""Return True only when the backend lists at least one loaded model."""
|
|
1064
|
+
try:
|
|
1065
|
+
req = urllib.request.Request(url)
|
|
1066
|
+
with urllib.request.urlopen(req, timeout=0.5) as resp:
|
|
1067
|
+
if resp.status != 200:
|
|
1068
|
+
return False
|
|
1069
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
1070
|
+
except Exception:
|
|
1071
|
+
return False
|
|
1072
|
+
models = data.get("data") or data.get("models") or []
|
|
1073
|
+
return isinstance(models, list) and len(models) > 0
|
|
1074
|
+
|
|
1075
|
+
def _read_effective_ctx(self, port: int = 8080) -> int | None:
|
|
1076
|
+
"""Read back the context window llama-server actually serves (/props).
|
|
1077
|
+
|
|
1078
|
+
llama-server may silently clamp ``-c`` to the model's native context
|
|
1079
|
+
length, so verify reality instead of trusting the launch flag. Returns
|
|
1080
|
+
None when /props is unavailable (older builds, external servers).
|
|
1081
|
+
"""
|
|
1082
|
+
try:
|
|
1083
|
+
req = urllib.request.Request(f"http://127.0.0.1:{port}/props")
|
|
1084
|
+
with urllib.request.urlopen(req, timeout=2.0) as resp:
|
|
1085
|
+
if resp.status != 200:
|
|
1086
|
+
return None
|
|
1087
|
+
props = json.loads(resp.read().decode("utf-8"))
|
|
1088
|
+
n_ctx = (props.get("default_generation_settings") or {}).get("n_ctx")
|
|
1089
|
+
return int(n_ctx) if n_ctx else None
|
|
1090
|
+
except Exception:
|
|
1091
|
+
return None
|
|
1092
|
+
|
|
1093
|
+
def _list_llama_model_ids(self) -> list[str]:
|
|
1094
|
+
"""Model ids currently served by llama-server /v1/models ([] if offline)."""
|
|
1095
|
+
try:
|
|
1096
|
+
req = urllib.request.Request("http://127.0.0.1:8080/v1/models")
|
|
1097
|
+
with urllib.request.urlopen(req, timeout=0.5) as resp:
|
|
1098
|
+
if resp.status != 200:
|
|
1099
|
+
return []
|
|
1100
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
1101
|
+
raw_list = data.get("data") or data.get("models") or []
|
|
1102
|
+
ids = []
|
|
1103
|
+
for m in raw_list:
|
|
1104
|
+
if isinstance(m, dict) and "id" in m:
|
|
1105
|
+
ids.append(m["id"])
|
|
1106
|
+
elif isinstance(m, dict) and "name" in m:
|
|
1107
|
+
ids.append(m["name"])
|
|
1108
|
+
return ids
|
|
1109
|
+
except Exception:
|
|
1110
|
+
return []
|
|
1111
|
+
|
|
1112
|
+
def _list_ollama_model_tags(self) -> list[str]:
|
|
1113
|
+
return discover_local_ollama_models()
|
|
1114
|
+
|
|
1115
|
+
def _resolve_v1_target(self, model_id: str) -> tuple[str, int] | None:
|
|
1116
|
+
"""Map a requested model id to the local backend that serves it.
|
|
1117
|
+
|
|
1118
|
+
Resident llama-server wins when the id matches (or is unspecified);
|
|
1119
|
+
exact Ollama tag match otherwise; None means no local backend serves it.
|
|
1120
|
+
"""
|
|
1121
|
+
if self._probe_server_status("http://127.0.0.1:8080/v1/models")[0]:
|
|
1122
|
+
llama_ids = self._list_llama_model_ids()
|
|
1123
|
+
if not model_id or model_id in llama_ids:
|
|
1124
|
+
return ("127.0.0.1", 8080)
|
|
1125
|
+
if model_id and model_id in self._list_ollama_model_tags():
|
|
1126
|
+
return ("127.0.0.1", 11434)
|
|
1127
|
+
return None
|
|
1128
|
+
|
|
1129
|
+
def _handle_v1_models(self) -> None:
|
|
1130
|
+
"""OpenAI-compatible catalog: union of both local backends."""
|
|
1131
|
+
data = [{"id": mid, "object": "model", "owned_by": "llama-server"} for mid in self._list_llama_model_ids()]
|
|
1132
|
+
data.extend({"id": tag, "object": "model", "owned_by": "ollama"} for tag in self._list_ollama_model_tags())
|
|
1133
|
+
self._send_json({"object": "list", "data": data})
|
|
1134
|
+
|
|
1135
|
+
def _handle_v1_chat_completions(self) -> None:
|
|
1136
|
+
"""OpenAI-compatible chat proxy to the active local backend.
|
|
1137
|
+
|
|
1138
|
+
Both Ollama and llama-server already speak OpenAI wire format natively,
|
|
1139
|
+
so this is a byte-level router: resolve the model id to a backend and
|
|
1140
|
+
stream the upstream response straight back without translation.
|
|
1141
|
+
"""
|
|
1142
|
+
body = getattr(self, "_request_body", None)
|
|
1143
|
+
if body is None:
|
|
1144
|
+
body = self._read_request_body(self._parse_content_length()) or b"{}"
|
|
1145
|
+
try:
|
|
1146
|
+
payload = json.loads(body.decode("utf-8"))
|
|
1147
|
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
1148
|
+
payload = {}
|
|
1149
|
+
model_id = str(payload.get("model") or "")
|
|
1150
|
+
target = self._resolve_v1_target(model_id)
|
|
1151
|
+
if target is None:
|
|
1152
|
+
available = sorted(set(self._list_llama_model_ids()) | set(self._list_ollama_model_tags()))
|
|
1153
|
+
self._send_json({
|
|
1154
|
+
"error": {
|
|
1155
|
+
"message": (
|
|
1156
|
+
f"Model '{model_id or '(unspecified)'}' is not served by any local backend. "
|
|
1157
|
+
f"Available models: {available}. Load one via the Desktop Harness "
|
|
1158
|
+
"(Local Inference Servers panel) and retry."
|
|
1159
|
+
),
|
|
1160
|
+
"type": "model_not_found",
|
|
1161
|
+
"code": 404,
|
|
1162
|
+
}
|
|
1163
|
+
}, status=HTTPStatus.NOT_FOUND)
|
|
1164
|
+
return
|
|
1165
|
+
host, port = target
|
|
1166
|
+
try:
|
|
1167
|
+
conn = http.client.HTTPConnection(host, port, timeout=600)
|
|
1168
|
+
conn.request(
|
|
1169
|
+
"POST",
|
|
1170
|
+
"/v1/chat/completions",
|
|
1171
|
+
body=body,
|
|
1172
|
+
headers={"Content-Type": "application/json", "Accept": self.headers.get("Accept", "*/*")},
|
|
1173
|
+
)
|
|
1174
|
+
upstream = conn.getresponse()
|
|
1175
|
+
self.send_response(upstream.status)
|
|
1176
|
+
self.send_header("Content-Type", upstream.getheader("Content-Type", "application/json"))
|
|
1177
|
+
self.end_headers()
|
|
1178
|
+
while True:
|
|
1179
|
+
chunk = upstream.read(4096)
|
|
1180
|
+
if not chunk:
|
|
1181
|
+
break
|
|
1182
|
+
self.wfile.write(chunk)
|
|
1183
|
+
self.wfile.flush()
|
|
1184
|
+
conn.close()
|
|
1185
|
+
except Exception as error:
|
|
1186
|
+
# Headers may already be on the wire mid-stream; then the client
|
|
1187
|
+
# just sees a truncated body — nothing sane left to send.
|
|
1188
|
+
try:
|
|
1189
|
+
self._send_json({
|
|
1190
|
+
"error": {
|
|
1191
|
+
"message": f"Upstream {host}:{port} failed: {error}",
|
|
1192
|
+
"type": "upstream_error",
|
|
1193
|
+
"code": 502,
|
|
1194
|
+
}
|
|
1195
|
+
}, status=HTTPStatus.BAD_GATEWAY)
|
|
1196
|
+
except Exception:
|
|
1197
|
+
pass
|
|
1198
|
+
|
|
1199
|
+
def _find_pid_on_port(self, port: int) -> int | None:
|
|
1200
|
+
if os.name == "nt":
|
|
1201
|
+
try:
|
|
1202
|
+
out = subprocess.run(
|
|
1203
|
+
["netstat", "-ano", "-p", "tcp"],
|
|
1204
|
+
capture_output=True,
|
|
1205
|
+
text=True,
|
|
1206
|
+
check=False,
|
|
1207
|
+
timeout=5,
|
|
1208
|
+
).stdout
|
|
1209
|
+
for line in out.splitlines():
|
|
1210
|
+
parts = line.split()
|
|
1211
|
+
if len(parts) >= 5 and f":{port}" in parts[1] and parts[3].upper() == "LISTENING":
|
|
1212
|
+
try:
|
|
1213
|
+
return int(parts[4])
|
|
1214
|
+
except ValueError:
|
|
1215
|
+
continue
|
|
1216
|
+
except Exception:
|
|
1217
|
+
pass
|
|
1218
|
+
else:
|
|
1219
|
+
try:
|
|
1220
|
+
out = subprocess.run(
|
|
1221
|
+
["lsof", "-ti", f":{port}"],
|
|
1222
|
+
capture_output=True,
|
|
1223
|
+
text=True,
|
|
1224
|
+
check=False,
|
|
1225
|
+
timeout=5,
|
|
1226
|
+
).stdout
|
|
1227
|
+
pid = out.strip().splitlines()[0] if out.strip() else ""
|
|
1228
|
+
if pid.isdigit():
|
|
1229
|
+
return int(pid)
|
|
1230
|
+
except Exception:
|
|
1231
|
+
pass
|
|
1232
|
+
return None
|
|
1233
|
+
|
|
1234
|
+
def _looks_like_llama(self, pid: int) -> bool:
|
|
1235
|
+
if os.name == "nt":
|
|
1236
|
+
try:
|
|
1237
|
+
out = subprocess.run(
|
|
1238
|
+
["tasklist", "/FI", f"PID eq {pid}", "/FO", "CSV", "/NH"],
|
|
1239
|
+
capture_output=True,
|
|
1240
|
+
text=True,
|
|
1241
|
+
check=False,
|
|
1242
|
+
timeout=5,
|
|
1243
|
+
).stdout
|
|
1244
|
+
return "llama" in out.lower()
|
|
1245
|
+
except Exception:
|
|
1246
|
+
return False
|
|
1247
|
+
return True
|
|
1248
|
+
|
|
1249
|
+
def _kill_llama_on_port(self, port: int) -> None:
|
|
1250
|
+
"""Stop only a backend owned by this server instance.
|
|
1251
|
+
|
|
1252
|
+
A port lookup cannot prove process ownership: another listener may
|
|
1253
|
+
share a name, expose a similar port (for example ``:80801``), or be a
|
|
1254
|
+
user's independently managed llama-server. The registered Popen
|
|
1255
|
+
handle is the only trustworthy ownership evidence, and normal launch
|
|
1256
|
+
cleanup already removes that handle before this compatibility hook is
|
|
1257
|
+
reached. External listeners are therefore left untouched; the next
|
|
1258
|
+
bind reports the real conflict to the caller.
|
|
1259
|
+
"""
|
|
1260
|
+
del port # retained for callers of the old compatibility seam
|
|
1261
|
+
for name, proc in list(self.server_inst.spawned_processes.items()):
|
|
1262
|
+
if name == "llama_server" and proc is not None:
|
|
1263
|
+
self._stop_backend(name)
|
|
1264
|
+
|
|
1265
|
+
def _stop_backend(self, name: str) -> None:
|
|
1266
|
+
proc = self.server_inst.spawned_processes.pop(name, None)
|
|
1267
|
+
if proc is None:
|
|
1268
|
+
return
|
|
1269
|
+
try:
|
|
1270
|
+
if os.name == "nt":
|
|
1271
|
+
subprocess.run(
|
|
1272
|
+
["taskkill", "/F", "/T", "/PID", str(proc.pid)],
|
|
1273
|
+
capture_output=True,
|
|
1274
|
+
check=False,
|
|
1275
|
+
)
|
|
1276
|
+
else:
|
|
1277
|
+
proc.terminate()
|
|
1278
|
+
proc.wait(timeout=2.0)
|
|
1279
|
+
except Exception:
|
|
1280
|
+
try:
|
|
1281
|
+
proc.kill()
|
|
1282
|
+
except Exception:
|
|
1283
|
+
pass
|
|
1284
|
+
|
|
1285
|
+
def _handle_model_unload(self) -> None:
|
|
1286
|
+
data = self._read_json_body()
|
|
1287
|
+
model_name = data.get("model")
|
|
1288
|
+
if not model_name:
|
|
1289
|
+
self._send_json({"status": "failed", "error": "model name required"})
|
|
1290
|
+
return
|
|
1291
|
+
try:
|
|
1292
|
+
client = build_client(resolve_model_profile(self.server_inst.default_profile))
|
|
1293
|
+
manager = ModelMemoryManager(client)
|
|
1294
|
+
snap = manager.unload_model(model_name)
|
|
1295
|
+
self._send_json({"status": "unloaded", "model": model_name, "remaining": [m.name for m in snap.models]})
|
|
1296
|
+
except Exception as error:
|
|
1297
|
+
self._send_json({"status": "failed", "error": str(error)})
|
|
1298
|
+
|
|
1299
|
+
def _handle_model_unload_all(self) -> None:
|
|
1300
|
+
try:
|
|
1301
|
+
client = build_client(resolve_model_profile(self.server_inst.default_profile))
|
|
1302
|
+
manager = ModelMemoryManager(client)
|
|
1303
|
+
snap = manager.unload_all()
|
|
1304
|
+
stopped = []
|
|
1305
|
+
for name in list(self.server_inst.spawned_processes):
|
|
1306
|
+
if name == "llama_server":
|
|
1307
|
+
self._stop_backend(name)
|
|
1308
|
+
stopped.append(name)
|
|
1309
|
+
self.server_inst.llama_effective_ctx = None
|
|
1310
|
+
self.server_inst.llama_gguf_path = None
|
|
1311
|
+
self.server_inst.llama_gguf_label = None
|
|
1312
|
+
self._send_json({
|
|
1313
|
+
"status": "unloaded_all",
|
|
1314
|
+
"remaining_bytes": snap.total_vram_bytes,
|
|
1315
|
+
"stopped": stopped,
|
|
1316
|
+
})
|
|
1317
|
+
except Exception as error:
|
|
1318
|
+
self._send_json({"status": "failed", "error": str(error)})
|
|
1319
|
+
|
|
1320
|
+
@staticmethod
|
|
1321
|
+
def _parse_ctx_override(requested_ctx: Any) -> tuple[int | None, str | None]:
|
|
1322
|
+
if requested_ctx is None:
|
|
1323
|
+
return None, None
|
|
1324
|
+
if isinstance(requested_ctx, bool):
|
|
1325
|
+
return None, "Context override must be an integer of at least 512 tokens"
|
|
1326
|
+
try:
|
|
1327
|
+
ctx = int(requested_ctx)
|
|
1328
|
+
except (TypeError, ValueError):
|
|
1329
|
+
return None, "Context override must be an integer of at least 512 tokens"
|
|
1330
|
+
if isinstance(requested_ctx, float) and not requested_ctx.is_integer():
|
|
1331
|
+
return None, "Context override must be an integer of at least 512 tokens"
|
|
1332
|
+
if ctx < 512:
|
|
1333
|
+
return None, "Context override must be at least 512 tokens"
|
|
1334
|
+
if ctx > _MAX_CTX_OVERRIDE:
|
|
1335
|
+
return None, (
|
|
1336
|
+
f"Context override {ctx} is too large; the hard resource cap is "
|
|
1337
|
+
f"{_MAX_CTX_OVERRIDE} tokens"
|
|
1338
|
+
)
|
|
1339
|
+
return ctx, None
|
|
1340
|
+
|
|
1341
|
+
def _apply_ctx_override(self, profile: ModelProfile, requested_ctx: Any) -> tuple[ModelProfile, str | None]:
|
|
1342
|
+
"""Clamp and apply a per-request context-window override.
|
|
1343
|
+
|
|
1344
|
+
Returns (profile, relaunch_error). For openai-provider (llama-server)
|
|
1345
|
+
profiles the context window is fixed at server launch (``-c``), so a
|
|
1346
|
+
changed value triggers a relaunch of the remembered GGUF; when the
|
|
1347
|
+
server was started externally the caller gets a prescriptive error.
|
|
1348
|
+
"""
|
|
1349
|
+
ctx, error = self._parse_ctx_override(requested_ctx)
|
|
1350
|
+
if error:
|
|
1351
|
+
return profile, error
|
|
1352
|
+
if ctx is None:
|
|
1353
|
+
return profile, None
|
|
1354
|
+
# Static profile values are policy recommendations, not proof of the
|
|
1355
|
+
# installed model's runtime limit. Clamp only to a verified limit.
|
|
1356
|
+
max_len = getattr(profile, "model_context_limit", None)
|
|
1357
|
+
if max_len and ctx > max_len:
|
|
1358
|
+
ctx = int(max_len)
|
|
1359
|
+
profile = replace(profile, num_ctx=ctx)
|
|
1360
|
+
if profile.provider != "openai":
|
|
1361
|
+
return profile, None # Ollama receives options.num_ctx per request
|
|
1362
|
+
if self.server_inst.llama_num_ctx == ctx:
|
|
1363
|
+
return profile, None
|
|
1364
|
+
gguf_path = self.server_inst.llama_gguf_path
|
|
1365
|
+
if not gguf_path:
|
|
1366
|
+
return profile, (
|
|
1367
|
+
f"llama-server is running with -c {self.server_inst.llama_num_ctx}. "
|
|
1368
|
+
f"To use a {ctx}-token context window, reload the model from the "
|
|
1369
|
+
"model panel (Load into VRAM) with the Context Override set."
|
|
1370
|
+
)
|
|
1371
|
+
label = self.server_inst.llama_gguf_label or Path(gguf_path).stem
|
|
1372
|
+
result = self._launch_llama_model(gguf_path, label, num_ctx=ctx)
|
|
1373
|
+
if result.get("status") != "started":
|
|
1374
|
+
error = (
|
|
1375
|
+
f"llama-server relaunch with -c {ctx} failed: {result.get('error', 'unknown error')}"
|
|
1376
|
+
)
|
|
1377
|
+
if result.get("restored"):
|
|
1378
|
+
error += f" {result['restored']}"
|
|
1379
|
+
elif result.get("restore_error"):
|
|
1380
|
+
error += f" Automatic restore of the previous configuration also failed: {result['restore_error']}"
|
|
1381
|
+
return profile, error
|
|
1382
|
+
return profile, None
|
|
1383
|
+
|
|
1384
|
+
def _handle_chat(self) -> None:
|
|
1385
|
+
data = self._read_json_body()
|
|
1386
|
+
prompt = str(data.get("prompt", "")).strip()
|
|
1387
|
+
profile_name = data.get("profile", self.server_inst.default_profile)
|
|
1388
|
+
if profile_name == self.server_inst.default_profile:
|
|
1389
|
+
profile_name = select_available_profile(profile_name)
|
|
1390
|
+
files = data.get("files") or []
|
|
1391
|
+
checks = data.get("checks") or []
|
|
1392
|
+
requested_ctx = data.get("num_ctx")
|
|
1393
|
+
|
|
1394
|
+
if not prompt:
|
|
1395
|
+
self._send_json({"status": "failed", "error": "Prompt cannot be empty"})
|
|
1396
|
+
return
|
|
1397
|
+
_, ctx_error = self._parse_ctx_override(requested_ctx)
|
|
1398
|
+
if ctx_error:
|
|
1399
|
+
self._send_json({"status": "failed", "error": ctx_error}, HTTPStatus.BAD_REQUEST)
|
|
1400
|
+
return
|
|
1401
|
+
|
|
1402
|
+
from ...mode_router import MODES, classify_fast, classify_mode
|
|
1403
|
+
|
|
1404
|
+
# Resolve the user-selected interaction mode. Invalid / missing falls
|
|
1405
|
+
# back to "hybrid" (auto-classify to chat/build/plan).
|
|
1406
|
+
raw_mode = data.get("mode")
|
|
1407
|
+
mode = raw_mode if raw_mode in MODES else "hybrid"
|
|
1408
|
+
if mode == "hybrid":
|
|
1409
|
+
# Keep a bounded recent-prompt history for the router's isolated context.
|
|
1410
|
+
with self.server_inst.apply_lock:
|
|
1411
|
+
_recent_prompts.append(prompt)
|
|
1412
|
+
del _recent_prompts[:-_MAX_RECENT_PROMPTS]
|
|
1413
|
+
recent_snapshot = list(_recent_prompts)
|
|
1414
|
+
router = None
|
|
1415
|
+
try:
|
|
1416
|
+
from ...mode_router import build_mode_router
|
|
1417
|
+
|
|
1418
|
+
# Small default profile in the isolated context, never the main profile.
|
|
1419
|
+
router = build_mode_router()
|
|
1420
|
+
except Exception:
|
|
1421
|
+
router = None # ponytail: deterministic fallback, never break chat
|
|
1422
|
+
mode = classify_mode(
|
|
1423
|
+
prompt,
|
|
1424
|
+
current_mode=None,
|
|
1425
|
+
router=router,
|
|
1426
|
+
n_every=3,
|
|
1427
|
+
counter=self.server_inst.hybrid_counter,
|
|
1428
|
+
recent_prompts=recent_snapshot,
|
|
1429
|
+
)
|
|
1430
|
+
with self.server_inst.apply_lock:
|
|
1431
|
+
self.server_inst.hybrid_counter += 1
|
|
1432
|
+
|
|
1433
|
+
workspace = self.server_inst.workspace
|
|
1434
|
+
# Scope discovery is for mutation/build work. Chat and small-talk do
|
|
1435
|
+
# not need a repository scan, and an informational request without an
|
|
1436
|
+
# explicit file must ask for context rather than guess one.
|
|
1437
|
+
if not files and mode == "plan":
|
|
1438
|
+
files = detect_plan_candidates(workspace)
|
|
1439
|
+
elif not files and (mode != "chat" or re.search(r"[\w./\\-]+\.\w{1,5}", prompt)):
|
|
1440
|
+
files = detect_relevant_files(workspace, prompt)
|
|
1441
|
+
if not checks and mode == "build":
|
|
1442
|
+
checks = detect_test_checks(workspace)
|
|
1443
|
+
if mode == "build" and not files:
|
|
1444
|
+
self._send_json({
|
|
1445
|
+
"status": "needs_context",
|
|
1446
|
+
"mode": "build",
|
|
1447
|
+
"message": "Name an existing file or provide an explicit scope before requesting a build change.",
|
|
1448
|
+
"files": [],
|
|
1449
|
+
})
|
|
1450
|
+
return
|
|
1451
|
+
|
|
1452
|
+
# ponytail: uuid suffix — int(time.time()) collided when two chats
|
|
1453
|
+
# landed in the same second and silently overwrote History.
|
|
1454
|
+
task_id = f"task-{uuid.uuid4().hex[:12]}"
|
|
1455
|
+
|
|
1456
|
+
# Read-only planning for plan mode — never produces a patch, always
|
|
1457
|
+
# returns a PlanArtifact. Failures degrade to an empty plan, never raise.
|
|
1458
|
+
if mode == "plan":
|
|
1459
|
+
try:
|
|
1460
|
+
from ...plan_mode import PlanArtifact
|
|
1461
|
+
from ...controller import Controller
|
|
1462
|
+
profile = resolve_model_profile(profile_name)
|
|
1463
|
+
profile, ctx_error = self._apply_ctx_override(profile, requested_ctx)
|
|
1464
|
+
if ctx_error:
|
|
1465
|
+
self._send_json({"status": "failed", "error": ctx_error})
|
|
1466
|
+
return
|
|
1467
|
+
client = build_client(profile)
|
|
1468
|
+
task = TaskEnvelope(
|
|
1469
|
+
id=task_id,
|
|
1470
|
+
goal=prompt,
|
|
1471
|
+
files=tuple(files),
|
|
1472
|
+
checks=tuple(checks),
|
|
1473
|
+
)
|
|
1474
|
+
# Read-only enforcement at the tool-policy layer: block the two
|
|
1475
|
+
# tools that mutate the workspace so plan mode cannot patch.
|
|
1476
|
+
controller = Controller(
|
|
1477
|
+
client, workspace,
|
|
1478
|
+
blocked_tools={"propose_patch", "run_tests"},
|
|
1479
|
+
)
|
|
1480
|
+
result = controller.run(task, apply=False)
|
|
1481
|
+
|
|
1482
|
+
summary = result.get("summary") or ""
|
|
1483
|
+
steps = [summary] if summary else []
|
|
1484
|
+
risks = result.get("risks") or []
|
|
1485
|
+
files_to_modify = list(files)
|
|
1486
|
+
plan = PlanArtifact(
|
|
1487
|
+
goal=prompt,
|
|
1488
|
+
steps=[str(s) for s in steps],
|
|
1489
|
+
risks=[str(r) if isinstance(r, str) else str(r.get("message", "")) for r in risks],
|
|
1490
|
+
files_to_modify=[str(f) for f in files_to_modify],
|
|
1491
|
+
)
|
|
1492
|
+
plan_dict = plan.to_dict()
|
|
1493
|
+
message = summary or f"Plan generated for '{prompt}'."
|
|
1494
|
+
except Exception as error:
|
|
1495
|
+
plan_dict = {
|
|
1496
|
+
"goal": prompt,
|
|
1497
|
+
"steps": [],
|
|
1498
|
+
"risks": [],
|
|
1499
|
+
"files_to_modify": list(files),
|
|
1500
|
+
}
|
|
1501
|
+
message = f"Could not generate a detailed plan: {error}"
|
|
1502
|
+
response = {
|
|
1503
|
+
"status": "completed",
|
|
1504
|
+
"mode": "plan",
|
|
1505
|
+
"task_id": task_id,
|
|
1506
|
+
"prompt": prompt,
|
|
1507
|
+
"profile": profile_name,
|
|
1508
|
+
"plan": plan_dict,
|
|
1509
|
+
"file": "workspace",
|
|
1510
|
+
"patch": "",
|
|
1511
|
+
"thinking": "Plan mode: read-only exploration produced a plan.",
|
|
1512
|
+
"testResult": "READY",
|
|
1513
|
+
"checks": [],
|
|
1514
|
+
"message": message,
|
|
1515
|
+
}
|
|
1516
|
+
self._save_chat_session(response, [], status="Planned")
|
|
1517
|
+
self._send_json(response)
|
|
1518
|
+
return
|
|
1519
|
+
|
|
1520
|
+
# Friendly conversational / small-talk handling. The Controller tool-loop
|
|
1521
|
+
# demands structured JSON that small models can't reliably emit for
|
|
1522
|
+
# non-coding prompts, so route small talk to a plain completion.
|
|
1523
|
+
greetings = {"hi", "hello", "hey", "привет", "здравствуйте", "yo", "sup", "help", "test", "ok", "thanks", "спасибо"}
|
|
1524
|
+
greeting_phrases = ("how are you", "what's up", "whats up", "wassup", "how are u")
|
|
1525
|
+
lowered = prompt.lower().strip("!.,? ")
|
|
1526
|
+
tokens = lowered.split()
|
|
1527
|
+
is_small_talk = (
|
|
1528
|
+
lowered in greetings
|
|
1529
|
+
or any(phrase in lowered for phrase in greeting_phrases)
|
|
1530
|
+
or (len(tokens) <= 4 and any(tok.strip("!.,?") in greetings for tok in tokens))
|
|
1531
|
+
)
|
|
1532
|
+
if is_small_talk:
|
|
1533
|
+
try:
|
|
1534
|
+
profile = resolve_model_profile(profile_name)
|
|
1535
|
+
profile, ctx_error = self._apply_ctx_override(profile, requested_ctx)
|
|
1536
|
+
if ctx_error:
|
|
1537
|
+
self._send_json({"status": "failed", "error": ctx_error})
|
|
1538
|
+
return
|
|
1539
|
+
client = build_client(profile)
|
|
1540
|
+
resp = client.chat([
|
|
1541
|
+
{"role": "system", "content": "You are a concise local coding assistant."},
|
|
1542
|
+
{"role": "user", "content": prompt},
|
|
1543
|
+
])
|
|
1544
|
+
reply = (resp.get("message") or {}).get("content")
|
|
1545
|
+
except Exception as error:
|
|
1546
|
+
self._send_offline_or_error(error, profile_name)
|
|
1547
|
+
return
|
|
1548
|
+
response = {
|
|
1549
|
+
"status": "completed",
|
|
1550
|
+
"mode": mode,
|
|
1551
|
+
"task_id": task_id,
|
|
1552
|
+
"prompt": prompt,
|
|
1553
|
+
"profile": profile_name,
|
|
1554
|
+
"file": "workspace",
|
|
1555
|
+
"patch": "",
|
|
1556
|
+
"thinking": "Conversational intent detected.",
|
|
1557
|
+
"testResult": "READY",
|
|
1558
|
+
"checks": [],
|
|
1559
|
+
"message": reply or (
|
|
1560
|
+
f"Hello! Connected to `{profile_name}`. "
|
|
1561
|
+
"Please give me a specific coding task, bug fix, or refactoring goal "
|
|
1562
|
+
"(e.g. 'Fix off-by-one in sliding window' or 'Write unit tests for tax logic')."
|
|
1563
|
+
),
|
|
1564
|
+
}
|
|
1565
|
+
self._save_chat_session(response, [], status="Answered")
|
|
1566
|
+
self._send_json(response)
|
|
1567
|
+
return
|
|
1568
|
+
|
|
1569
|
+
# Informational / Code Inquiry Handling (e.g. "read main.py and tell me what it does",
|
|
1570
|
+
# "explain foo", "can u tell me what main.py does?"). The shared classifier detects
|
|
1571
|
+
# questions anywhere in the prompt, not just as prefixes; a question never needs a
|
|
1572
|
+
# patch, so it bypasses the Controller AND the blind chat completion — the model
|
|
1573
|
+
# must see the file it is asked about, whatever mode the user selected.
|
|
1574
|
+
if classify_fast(prompt) == "chat" and files:
|
|
1575
|
+
try:
|
|
1576
|
+
profile = resolve_model_profile(profile_name)
|
|
1577
|
+
profile, ctx_error = self._apply_ctx_override(profile, requested_ctx)
|
|
1578
|
+
if ctx_error:
|
|
1579
|
+
self._send_json({"status": "failed", "error": ctx_error})
|
|
1580
|
+
return
|
|
1581
|
+
if profile.num_predict < 2048:
|
|
1582
|
+
profile = replace(profile, num_predict=2048)
|
|
1583
|
+
client = build_client(profile)
|
|
1584
|
+
if not files:
|
|
1585
|
+
self._send_json({
|
|
1586
|
+
"status": "needs_context",
|
|
1587
|
+
"mode": "chat",
|
|
1588
|
+
"message": "Please name the file or provide its path so I can answer from repository evidence.",
|
|
1589
|
+
"file": None,
|
|
1590
|
+
})
|
|
1591
|
+
return
|
|
1592
|
+
target_file = files[0]
|
|
1593
|
+
# Strict Scope Boundary (server-enforced, mirrors apply): normalize
|
|
1594
|
+
# and verify the resolved path stays inside the workspace before
|
|
1595
|
+
# reading. An absolute path or `../` escapes the workspace -> skip.
|
|
1596
|
+
# Up to 3 allowlisted files are injected: "what component does X"
|
|
1597
|
+
# usually spans more than one file and a single-file context makes
|
|
1598
|
+
# the model answer "I don't have access".
|
|
1599
|
+
workspace_root = Path(workspace).resolve()
|
|
1600
|
+
snippets: list[str] = []
|
|
1601
|
+
for candidate_file in (files[:3] or ["src/main.py"]):
|
|
1602
|
+
try:
|
|
1603
|
+
resolved = (workspace_root / candidate_file).resolve()
|
|
1604
|
+
if resolved.is_relative_to(workspace_root) and resolved.is_file():
|
|
1605
|
+
body = resolved.read_text(encoding="utf-8", errors="replace")[:6000]
|
|
1606
|
+
snippets.append(f"--- {candidate_file} ---\n{body}")
|
|
1607
|
+
except Exception:
|
|
1608
|
+
continue
|
|
1609
|
+
content_snippet = "\n\n".join(snippets)
|
|
1610
|
+
|
|
1611
|
+
messages = [
|
|
1612
|
+
{"role": "system", "content": f"You are a helpful coding assistant answering a question about this workspace. Relevant files: {', '.join(files[:3]) or target_file}\n\n{content_snippet}"},
|
|
1613
|
+
{"role": "user", "content": prompt},
|
|
1614
|
+
]
|
|
1615
|
+
resp = client.chat(messages)
|
|
1616
|
+
msg_content = (resp.get("message") or {}).get("content") or "No response received."
|
|
1617
|
+
|
|
1618
|
+
response = {
|
|
1619
|
+
"status": "completed",
|
|
1620
|
+
"mode": mode,
|
|
1621
|
+
"task_id": task_id,
|
|
1622
|
+
"prompt": prompt,
|
|
1623
|
+
"profile": profile_name,
|
|
1624
|
+
"file": target_file,
|
|
1625
|
+
"patch": "",
|
|
1626
|
+
"thinking": f"Read context from {target_file} and formulated code explanation.",
|
|
1627
|
+
"testResult": "READY",
|
|
1628
|
+
"checks": [],
|
|
1629
|
+
"message": msg_content,
|
|
1630
|
+
}
|
|
1631
|
+
self._save_chat_session(response, checks, status="Answered")
|
|
1632
|
+
self._send_json(response)
|
|
1633
|
+
return
|
|
1634
|
+
except Exception as error:
|
|
1635
|
+
self._send_offline_or_error(error, profile_name)
|
|
1636
|
+
return
|
|
1637
|
+
|
|
1638
|
+
# Plain conversational completion for chat mode — never runs the
|
|
1639
|
+
# Controller. Reached only for chat-mode prompts that are neither
|
|
1640
|
+
# small talk nor questions (opinions, jokes, ...); questions above
|
|
1641
|
+
# already got the file-aware answer.
|
|
1642
|
+
if mode == "chat":
|
|
1643
|
+
try:
|
|
1644
|
+
profile = resolve_model_profile(profile_name)
|
|
1645
|
+
profile, ctx_error = self._apply_ctx_override(profile, requested_ctx)
|
|
1646
|
+
if ctx_error:
|
|
1647
|
+
self._send_json({"status": "failed", "error": ctx_error}, HTTPStatus.BAD_REQUEST)
|
|
1648
|
+
return
|
|
1649
|
+
client = build_client(profile)
|
|
1650
|
+
resp = client.chat([
|
|
1651
|
+
{"role": "system", "content": "You are a concise local coding assistant."},
|
|
1652
|
+
{"role": "user", "content": prompt},
|
|
1653
|
+
])
|
|
1654
|
+
reply = (resp.get("message") or {}).get("content")
|
|
1655
|
+
except Exception as error:
|
|
1656
|
+
self._send_offline_or_error(error, profile_name)
|
|
1657
|
+
return
|
|
1658
|
+
response = {
|
|
1659
|
+
"status": "completed",
|
|
1660
|
+
"mode": "chat",
|
|
1661
|
+
"task_id": task_id,
|
|
1662
|
+
"prompt": prompt,
|
|
1663
|
+
"profile": profile_name,
|
|
1664
|
+
"file": "workspace",
|
|
1665
|
+
"patch": "",
|
|
1666
|
+
"thinking": "Conversational intent detected.",
|
|
1667
|
+
"testResult": "READY",
|
|
1668
|
+
"checks": [],
|
|
1669
|
+
"message": reply or (
|
|
1670
|
+
f"Hello! Connected to `{profile_name}`. "
|
|
1671
|
+
"Please give me a specific coding task, bug fix, or refactoring goal "
|
|
1672
|
+
"(e.g. 'Fix off-by-one in sliding window' or 'Write unit tests for tax logic')."
|
|
1673
|
+
),
|
|
1674
|
+
}
|
|
1675
|
+
self._save_chat_session(response, [], status="Answered")
|
|
1676
|
+
self._send_json(response)
|
|
1677
|
+
return
|
|
1678
|
+
|
|
1679
|
+
task = TaskEnvelope(
|
|
1680
|
+
id=task_id,
|
|
1681
|
+
goal=prompt,
|
|
1682
|
+
files=tuple(files),
|
|
1683
|
+
checks=tuple(checks),
|
|
1684
|
+
)
|
|
1685
|
+
|
|
1686
|
+
try:
|
|
1687
|
+
from ...controller import Controller
|
|
1688
|
+
profile = resolve_model_profile(profile_name)
|
|
1689
|
+
profile, ctx_error = self._apply_ctx_override(profile, requested_ctx)
|
|
1690
|
+
if ctx_error:
|
|
1691
|
+
self._send_json({"status": "failed", "error": ctx_error})
|
|
1692
|
+
return
|
|
1693
|
+
client = build_client(profile)
|
|
1694
|
+
controller = Controller(client, workspace)
|
|
1695
|
+
result = controller.run(task, apply=False)
|
|
1696
|
+
|
|
1697
|
+
patch_content = result.get("patch", "")
|
|
1698
|
+
target_file = files[0] if files else "src/main.py"
|
|
1699
|
+
check_evidence = result.get("checks") if isinstance(result.get("checks"), list) else []
|
|
1700
|
+
evidence_verified = bool(check_evidence) and all(
|
|
1701
|
+
isinstance(item, dict) and item.get("passed") is True
|
|
1702
|
+
for item in check_evidence
|
|
1703
|
+
)
|
|
1704
|
+
|
|
1705
|
+
summary = result.get("summary") or ""
|
|
1706
|
+
error = result.get("error")
|
|
1707
|
+
err_kind = error.get("kind") if isinstance(error, dict) else None
|
|
1708
|
+
if err_kind == "duplicate_tool_call":
|
|
1709
|
+
friendly = "The model got stuck repeating the same step. Try a simpler single-step task, or a larger model."
|
|
1710
|
+
elif err_kind == "context_overflow":
|
|
1711
|
+
friendly = summary
|
|
1712
|
+
elif err_kind == "retry_budget_exhausted":
|
|
1713
|
+
friendly = "The model couldn't produce a valid result after several attempts. Simplify the request or switch to a larger model."
|
|
1714
|
+
elif result.get("status") == "failed":
|
|
1715
|
+
friendly = f"The task could not be completed: {summary}"
|
|
1716
|
+
else:
|
|
1717
|
+
friendly = summary
|
|
1718
|
+
|
|
1719
|
+
response = {
|
|
1720
|
+
"status": result.get("status", "completed"),
|
|
1721
|
+
"mode": mode,
|
|
1722
|
+
"task_id": task_id,
|
|
1723
|
+
"prompt": prompt,
|
|
1724
|
+
"profile": profile_name,
|
|
1725
|
+
"file": target_file,
|
|
1726
|
+
"files": list(files),
|
|
1727
|
+
"patch": patch_content,
|
|
1728
|
+
"thinking": friendly or "AST context compacted, generated candidate patch, ran external tests.",
|
|
1729
|
+
"testResult": "PASSED" if evidence_verified else ("NOT RUN" if result.get("status") == "accepted" else "FAILED"),
|
|
1730
|
+
"checks": check_evidence,
|
|
1731
|
+
"message": friendly or f"Task processed for '{prompt}'.",
|
|
1732
|
+
}
|
|
1733
|
+
self._save_chat_session(
|
|
1734
|
+
response,
|
|
1735
|
+
checks,
|
|
1736
|
+
status="Verified" if evidence_verified else "Needs Review",
|
|
1737
|
+
evidence_verified=evidence_verified,
|
|
1738
|
+
)
|
|
1739
|
+
self._send_json(response)
|
|
1740
|
+
except Exception as error:
|
|
1741
|
+
kind = _classify_backend_error(error)
|
|
1742
|
+
if kind == "offline":
|
|
1743
|
+
is_llama = profile.provider == "openai"
|
|
1744
|
+
server_name = "llama-server on port 8080" if is_llama else "Ollama on port 11434"
|
|
1745
|
+
prescript = f"Local backend server ({server_name}) is currently OFFLINE. Click 'Start {('llama-server' if is_llama else 'Ollama')}' or launch your local engine."
|
|
1746
|
+
self._send_json({"status": "failed", "error": prescript, "offline_server": "llama_server" if is_llama else "ollama"})
|
|
1747
|
+
else:
|
|
1748
|
+
self._send_json({"status": "failed", "error": str(error)})
|
|
1749
|
+
|
|
1750
|
+
def _handle_delegate(self) -> None:
|
|
1751
|
+
data = self._read_json_body()
|
|
1752
|
+
raw_task = data.get("task", {})
|
|
1753
|
+
profile_name = data.get("profile", self.server_inst.default_profile)
|
|
1754
|
+
apply_flag = bool(data.get("apply", False))
|
|
1755
|
+
|
|
1756
|
+
try:
|
|
1757
|
+
from ...controller import Controller
|
|
1758
|
+
task = TaskEnvelope.from_mapping(raw_task)
|
|
1759
|
+
profile = resolve_model_profile(profile_name)
|
|
1760
|
+
client = build_client(profile)
|
|
1761
|
+
controller = Controller(client, self.server_inst.workspace)
|
|
1762
|
+
result = controller.run(task, apply=apply_flag)
|
|
1763
|
+
self._send_json(result)
|
|
1764
|
+
except Exception as error:
|
|
1765
|
+
self._send_json({"status": "failed", "error": str(error)})
|
|
1766
|
+
|
|
1767
|
+
def _handle_tasks_submit(self) -> None:
|
|
1768
|
+
data = self._read_json_body()
|
|
1769
|
+
server = self.server_inst
|
|
1770
|
+
goal = data.get("goal")
|
|
1771
|
+
if not isinstance(goal, str) or not goal.strip():
|
|
1772
|
+
self._send_json({"status": "failed", "error": "goal is required"})
|
|
1773
|
+
return
|
|
1774
|
+
files = data.get("files") or []
|
|
1775
|
+
checks = data.get("checks") or []
|
|
1776
|
+
if not isinstance(files, list) or not all(isinstance(f, str) and f for f in files):
|
|
1777
|
+
self._send_json({"status": "failed", "error": "files must be a list of non-empty strings"})
|
|
1778
|
+
return
|
|
1779
|
+
if not isinstance(checks, list) or not all(isinstance(c, str) and c for c in checks):
|
|
1780
|
+
self._send_json({"status": "failed", "error": "checks must be a list of non-empty strings"})
|
|
1781
|
+
return
|
|
1782
|
+
record = {
|
|
1783
|
+
"id": f"task-{uuid.uuid4().hex[:12]}",
|
|
1784
|
+
"goal": goal.strip(),
|
|
1785
|
+
"files": [str(f) for f in files],
|
|
1786
|
+
"checks": [str(c) for c in checks],
|
|
1787
|
+
"profile": str(data.get("profile") or server.default_profile),
|
|
1788
|
+
"status": "queued",
|
|
1789
|
+
"created_at": time.time(),
|
|
1790
|
+
"started_at": None,
|
|
1791
|
+
"finished_at": None,
|
|
1792
|
+
"summary": "",
|
|
1793
|
+
"patch": "",
|
|
1794
|
+
"checks_results": [],
|
|
1795
|
+
"error": "",
|
|
1796
|
+
}
|
|
1797
|
+
with server.task_queue_lock:
|
|
1798
|
+
server.save_task(record)
|
|
1799
|
+
self._send_json({"status": "queued", "task": record})
|
|
1800
|
+
|
|
1801
|
+
def _handle_tasks_list(self) -> None:
|
|
1802
|
+
self._send_json({"tasks": self.server_inst.load_tasks()})
|
|
1803
|
+
|
|
1804
|
+
def _handle_tasks_cancel(self) -> None:
|
|
1805
|
+
data = self._read_json_body()
|
|
1806
|
+
task_id = str(data.get("id") or "")
|
|
1807
|
+
server = self.server_inst
|
|
1808
|
+
with server.task_queue_lock:
|
|
1809
|
+
record = server.get_task(task_id)
|
|
1810
|
+
if record is None:
|
|
1811
|
+
self._send_json({"status": "failed", "error": f"Unknown task id: {task_id}"})
|
|
1812
|
+
return
|
|
1813
|
+
status = record.get("status")
|
|
1814
|
+
if status == "queued":
|
|
1815
|
+
updated = server.update_task(task_id, status="cancelled", finished_at=time.time())
|
|
1816
|
+
self._send_json({"status": "cancelled", "task": updated})
|
|
1817
|
+
return
|
|
1818
|
+
if status == "running":
|
|
1819
|
+
event = server._task_events.get(task_id)
|
|
1820
|
+
if event is not None:
|
|
1821
|
+
event.set()
|
|
1822
|
+
self._send_json({"status": "cancelling", "task": record})
|
|
1823
|
+
return
|
|
1824
|
+
self._send_json({"status": "failed", "error": f"Task {task_id} is already {status}"})
|
|
1825
|
+
|
|
1826
|
+
def _handle_apply(self) -> None:
|
|
1827
|
+
data = self._read_json_body()
|
|
1828
|
+
patch_str = data.get("patch", "")
|
|
1829
|
+
checks = data.get("checks", [])
|
|
1830
|
+
files = data.get("files", [])
|
|
1831
|
+
workspace = Path(self.server_inst.workspace)
|
|
1832
|
+
|
|
1833
|
+
if not isinstance(patch_str, str) or not patch_str.strip():
|
|
1834
|
+
self._send_json({"status": "failed", "error": "No patch content provided to apply"})
|
|
1835
|
+
return
|
|
1836
|
+
if not isinstance(files, list) or not all(isinstance(f, str) for f in files):
|
|
1837
|
+
self._send_json({"status": "rejected", "error": "files must be a list of strings"})
|
|
1838
|
+
return
|
|
1839
|
+
if not isinstance(checks, list) or not all(isinstance(c, str) for c in checks):
|
|
1840
|
+
self._send_json({"status": "rejected", "error": "checks must be a list of strings"})
|
|
1841
|
+
return
|
|
1842
|
+
|
|
1843
|
+
# Strict Scope Boundary (server-enforced, mirrors validate_candidate):
|
|
1844
|
+
# the caller must declare the allowlist. The boundary lives on the
|
|
1845
|
+
# server, not the client.
|
|
1846
|
+
if not files:
|
|
1847
|
+
self._send_json({
|
|
1848
|
+
"status": "rejected",
|
|
1849
|
+
"error": "No declared file scope; refusing to apply a patch with an empty allowlist",
|
|
1850
|
+
})
|
|
1851
|
+
return
|
|
1852
|
+
|
|
1853
|
+
try:
|
|
1854
|
+
task = TaskEnvelope(
|
|
1855
|
+
id="desktop-apply",
|
|
1856
|
+
goal="apply reviewed patch",
|
|
1857
|
+
files=tuple(files),
|
|
1858
|
+
checks=tuple(checks),
|
|
1859
|
+
)
|
|
1860
|
+
except ValueError as error:
|
|
1861
|
+
self._send_json({"status": "rejected", "error": f"Invalid apply request: {error}"})
|
|
1862
|
+
return
|
|
1863
|
+
|
|
1864
|
+
# Mediated apply under the lock: the same verification gate as the CLI
|
|
1865
|
+
# and service paths (declared scope, semantic lint, targeted checks,
|
|
1866
|
+
# verified rollback). Holding the lock across apply->checks->rollback
|
|
1867
|
+
# closes the race on last_applied_files.
|
|
1868
|
+
with self.server_inst.apply_lock:
|
|
1869
|
+
result = mediated_apply(workspace, patch_str, task)
|
|
1870
|
+
payload: dict[str, Any] = {
|
|
1871
|
+
"status": "applied" if result.get("applied") is True else "rejected",
|
|
1872
|
+
"checks": result.get("checks", []),
|
|
1873
|
+
"rolled_back": bool(result.get("rolled_back")),
|
|
1874
|
+
}
|
|
1875
|
+
if result.get("applied") is True:
|
|
1876
|
+
changed, _ = parse_unified_diff(patch_str)
|
|
1877
|
+
# Record only the files the patch actually changes (not the
|
|
1878
|
+
# declared allowlist), so a later rollback never wipes unrelated
|
|
1879
|
+
# work.
|
|
1880
|
+
self.server_inst.last_applied_files = list(changed)
|
|
1881
|
+
self.server_inst.last_applied_patch = patch_str
|
|
1882
|
+
else:
|
|
1883
|
+
error = result.get("error")
|
|
1884
|
+
payload["error"] = error.get("message", "apply rejected") if isinstance(error, dict) else str(error)
|
|
1885
|
+
self._send_json(payload)
|
|
1886
|
+
|
|
1887
|
+
def _handle_rollback(self) -> None:
|
|
1888
|
+
workspace = Path(self.server_inst.workspace)
|
|
1889
|
+
with self.server_inst.apply_lock:
|
|
1890
|
+
targets = list(self.server_inst.last_applied_files)
|
|
1891
|
+
patch = self.server_inst.last_applied_patch
|
|
1892
|
+
if not targets or not patch:
|
|
1893
|
+
self._send_json({"status": "rolled_back", "restored": []})
|
|
1894
|
+
return
|
|
1895
|
+
# Scoped rollback via reverse-apply of the stored patch: handles both
|
|
1896
|
+
# modified (tracked) and newly-created (untracked) files uniformly,
|
|
1897
|
+
# unlike `git restore` which cannot remove untracked files. Restores
|
|
1898
|
+
# only the files this apply touched — never unrelated work.
|
|
1899
|
+
reverted, detail = apply_patch(workspace, patch, reverse=True)
|
|
1900
|
+
if not reverted:
|
|
1901
|
+
self._send_json({
|
|
1902
|
+
"status": "failed",
|
|
1903
|
+
"error": f"Rollback failed: {detail}",
|
|
1904
|
+
"restored": [],
|
|
1905
|
+
})
|
|
1906
|
+
return
|
|
1907
|
+
self.server_inst.last_applied_files = []
|
|
1908
|
+
self.server_inst.last_applied_patch = ""
|
|
1909
|
+
self._send_json({"status": "rolled_back", "restored": targets})
|
|
1910
|
+
|
|
1911
|
+
def _handle_doctor_fix(self) -> None:
|
|
1912
|
+
try:
|
|
1913
|
+
report = remediate_environment(write=True)
|
|
1914
|
+
report_data = report.to_dict()
|
|
1915
|
+
if report.success:
|
|
1916
|
+
self._send_json({"status": "ok", "report": report_data})
|
|
1917
|
+
return
|
|
1918
|
+
errors = getattr(report, "errors", [])
|
|
1919
|
+
actions = getattr(report, "actions", [])
|
|
1920
|
+
if actions:
|
|
1921
|
+
# Partial success: some targets applied, others were denied
|
|
1922
|
+
# (e.g. permission-denied global .gemini/.codex/.claude). A
|
|
1923
|
+
# bare 500 would hide the real progress from the user.
|
|
1924
|
+
self._send_json({
|
|
1925
|
+
"status": "partial",
|
|
1926
|
+
"error": "; ".join(errors) or "Doctor remediation was incomplete",
|
|
1927
|
+
"report": report_data,
|
|
1928
|
+
})
|
|
1929
|
+
return
|
|
1930
|
+
self._send_json(
|
|
1931
|
+
{
|
|
1932
|
+
"status": "failed",
|
|
1933
|
+
"error": "; ".join(errors) or "Doctor remediation was incomplete",
|
|
1934
|
+
"report": report_data,
|
|
1935
|
+
},
|
|
1936
|
+
HTTPStatus.INTERNAL_SERVER_ERROR,
|
|
1937
|
+
)
|
|
1938
|
+
except Exception as error:
|
|
1939
|
+
self._send_json(
|
|
1940
|
+
{"status": "failed", "error": str(error)},
|
|
1941
|
+
HTTPStatus.INTERNAL_SERVER_ERROR,
|
|
1942
|
+
)
|
|
1943
|
+
|
|
1944
|
+
def _probe_port(self, url: str) -> bool:
|
|
1945
|
+
online, _ = self._probe_server_status(url)
|
|
1946
|
+
return online
|
|
1947
|
+
|
|
1948
|
+
def _probe_server_status(self, url: str) -> tuple[bool, str]:
|
|
1949
|
+
try:
|
|
1950
|
+
req = urllib.request.Request(url)
|
|
1951
|
+
with urllib.request.urlopen(req, timeout=0.3) as resp:
|
|
1952
|
+
if resp.status == 200:
|
|
1953
|
+
return True, "ready"
|
|
1954
|
+
return True, "loading"
|
|
1955
|
+
except urllib.error.HTTPError as e:
|
|
1956
|
+
if e.code == 503:
|
|
1957
|
+
return True, "loading"
|
|
1958
|
+
return False, "offline"
|
|
1959
|
+
except Exception:
|
|
1960
|
+
return False, "offline"
|
|
1961
|
+
|
|
1962
|
+
def _send_json(self, data: Any, status: HTTPStatus = HTTPStatus.OK) -> None:
|
|
1963
|
+
body = json.dumps(data, ensure_ascii=False).encode("utf-8")
|
|
1964
|
+
self._send_response(status, "application/json; charset=utf-8", body)
|
|
1965
|
+
|
|
1966
|
+
def _send_request_body_error(self, error: _RequestBodyError) -> None:
|
|
1967
|
+
"""Return a framing error and prevent reuse of an unsynchronized socket."""
|
|
1968
|
+
self.close_connection = True
|
|
1969
|
+
self._send_json({"status": "rejected", "error": error.message}, error.status)
|
|
1970
|
+
|
|
1971
|
+
def _send_offline_or_error(self, error: Exception, profile_name: Any) -> None:
|
|
1972
|
+
"""Prescriptive failure for chat completions — never mask a dead
|
|
1973
|
+
backend behind the canned 'Connected' greeting."""
|
|
1974
|
+
try:
|
|
1975
|
+
profile = resolve_model_profile(profile_name)
|
|
1976
|
+
except Exception:
|
|
1977
|
+
profile = None
|
|
1978
|
+
if profile is not None and _classify_backend_error(error) == "offline":
|
|
1979
|
+
is_llama = profile.provider == "openai"
|
|
1980
|
+
server_name = "llama-server on port 8080" if is_llama else "Ollama on port 11434"
|
|
1981
|
+
self._send_json({
|
|
1982
|
+
"status": "failed",
|
|
1983
|
+
"error": (
|
|
1984
|
+
f"Local backend server ({server_name}) is currently OFFLINE. "
|
|
1985
|
+
f"Click 'Start {('llama-server' if is_llama else 'Ollama')}' or launch your local engine."
|
|
1986
|
+
),
|
|
1987
|
+
"offline_server": "llama_server" if is_llama else "ollama",
|
|
1988
|
+
})
|
|
1989
|
+
else:
|
|
1990
|
+
self._send_json({"status": "failed", "error": str(error)})
|
|
1991
|
+
|
|
1992
|
+
def _send_response(self, status: HTTPStatus, content_type: str, body: bytes) -> None:
|
|
1993
|
+
self.send_response(status)
|
|
1994
|
+
self.send_header("Content-Type", content_type)
|
|
1995
|
+
self.send_header("Content-Length", str(len(body)))
|
|
1996
|
+
if self.close_connection:
|
|
1997
|
+
self.send_header("Connection", "close")
|
|
1998
|
+
self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
|
|
1999
|
+
self.send_header("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'")
|
|
2000
|
+
self.send_header("X-Content-Type-Options", "nosniff")
|
|
2001
|
+
self.send_header("X-Frame-Options", "DENY")
|
|
2002
|
+
self.send_header("Referrer-Policy", "no-referrer")
|
|
2003
|
+
self.end_headers()
|
|
2004
|
+
self.wfile.write(body)
|
|
2005
|
+
|
|
2006
|
+
def log_message(self, format: str, *args: Any) -> None:
|
|
2007
|
+
del format, args
|
|
2008
|
+
|
|
2009
|
+
|
|
2010
|
+
def detect_relevant_files(workspace: str, prompt: str) -> list[str]:
|
|
2011
|
+
"""Heuristic allowlist for a task with no explicit files.
|
|
2012
|
+
|
|
2013
|
+
Module-level (not a handler method) so the background task worker can
|
|
2014
|
+
resolve a file scope without an HTTP request context.
|
|
2015
|
+
"""
|
|
2016
|
+
ws_path = Path(workspace)
|
|
2017
|
+
|
|
2018
|
+
# 1. Files explicitly named in the prompt win over every heuristic:
|
|
2019
|
+
# "what does main.py do?" must not answer about an unrelated dirty file.
|
|
2020
|
+
lowered = prompt.lower()
|
|
2021
|
+
# ponytail: dot-token candidates let "__main__.py" match a "main.py"
|
|
2022
|
+
# mention via suffix; drop the stem tier if false positives appear.
|
|
2023
|
+
candidates = {
|
|
2024
|
+
tok.strip("./\\'\"(),:?!")
|
|
2025
|
+
for tok in re.findall(r"[\w./\\-]+\.\w{1,5}", lowered)
|
|
2026
|
+
} - {""}
|
|
2027
|
+
# A bare basename is an explicit scope only when it identifies one real
|
|
2028
|
+
# file. Duplicate basenames require the caller to provide a path.
|
|
2029
|
+
basename_counts: dict[str, int] = {}
|
|
2030
|
+
# Keyword scoring turns "desktop ui components" into desktop/ui.py +
|
|
2031
|
+
# desktop/components.py when no filename is mentioned outright.
|
|
2032
|
+
# ponytail: substring scoring, no stopwords — noise only widens the
|
|
2033
|
+
# allowlist; tighten if wrong-file answers ever come back.
|
|
2034
|
+
keywords = re.findall(r"[a-z_]{2,}", lowered)
|
|
2035
|
+
mentioned: list[str] = []
|
|
2036
|
+
scored: list[tuple[int, str]] = []
|
|
2037
|
+
file_records: list[tuple[str, str, str]] = []
|
|
2038
|
+
try:
|
|
2039
|
+
# One os.walk both discovers files and counts basenames, so an
|
|
2040
|
+
# explicit ``main.py`` can be rejected when duplicate paths exist
|
|
2041
|
+
# without traversing the workspace twice.
|
|
2042
|
+
for root, dirs, files_os in os.walk(ws_path):
|
|
2043
|
+
dirs[:] = [
|
|
2044
|
+
d for d in dirs
|
|
2045
|
+
if not d.startswith(".") and d not in ("__pycache__", "venv", ".venv", "build", "dist", "node_modules")
|
|
2046
|
+
]
|
|
2047
|
+
for fname in files_os:
|
|
2048
|
+
p = Path(root) / fname
|
|
2049
|
+
if p.suffix.lower() not in {
|
|
2050
|
+
".py", ".js", ".ts", ".tsx", ".jsx", ".md", ".json", ".toml",
|
|
2051
|
+
".yaml", ".yml", ".rs", ".go", ".c", ".h", ".cpp", ".java",
|
|
2052
|
+
".rb", ".php", ".sh", ".ps1", ".sql", ".css", ".html",
|
|
2053
|
+
}:
|
|
2054
|
+
continue
|
|
2055
|
+
rel = p.relative_to(ws_path).as_posix()
|
|
2056
|
+
name = fname.lower()
|
|
2057
|
+
file_records.append((rel, name, root))
|
|
2058
|
+
basename_counts[name] = basename_counts.get(name, 0) + 1
|
|
2059
|
+
|
|
2060
|
+
for rel, name, root in file_records:
|
|
2061
|
+
if (
|
|
2062
|
+
len(mentioned) < 3
|
|
2063
|
+
and (
|
|
2064
|
+
any(
|
|
2065
|
+
(
|
|
2066
|
+
("/" in cand or "\\" in cand)
|
|
2067
|
+
and rel.lower() == cand.replace("\\", "/")
|
|
2068
|
+
)
|
|
2069
|
+
or (
|
|
2070
|
+
"/" not in cand
|
|
2071
|
+
and "\\" not in cand
|
|
2072
|
+
and name == cand
|
|
2073
|
+
and basename_counts.get(cand, 0) == 1
|
|
2074
|
+
)
|
|
2075
|
+
for cand in candidates
|
|
2076
|
+
)
|
|
2077
|
+
)
|
|
2078
|
+
):
|
|
2079
|
+
mentioned.append(rel)
|
|
2080
|
+
continue
|
|
2081
|
+
if keywords:
|
|
2082
|
+
stem = name.rsplit(".", 1)[0]
|
|
2083
|
+
parent = Path(root).name.lower()
|
|
2084
|
+
score = 0
|
|
2085
|
+
for kw in keywords:
|
|
2086
|
+
if len(kw) >= 4:
|
|
2087
|
+
if kw in stem:
|
|
2088
|
+
score += 2
|
|
2089
|
+
elif kw in rel.lower():
|
|
2090
|
+
score += 1
|
|
2091
|
+
elif kw == stem or kw == parent:
|
|
2092
|
+
score += 2
|
|
2093
|
+
if score:
|
|
2094
|
+
scored.append((score, rel))
|
|
2095
|
+
except Exception:
|
|
2096
|
+
pass
|
|
2097
|
+
if mentioned:
|
|
2098
|
+
return mentioned
|
|
2099
|
+
if any(
|
|
2100
|
+
"/" not in candidate and "\\" not in candidate and basename_counts.get(candidate, 0) > 1
|
|
2101
|
+
for candidate in candidates
|
|
2102
|
+
):
|
|
2103
|
+
return []
|
|
2104
|
+
if scored:
|
|
2105
|
+
scored.sort(key=lambda t: (-t[0], t[1]))
|
|
2106
|
+
return [rel for _, rel in scored[:3]]
|
|
2107
|
+
|
|
2108
|
+
# No explicit or high-confidence scope: callers must request context
|
|
2109
|
+
# instead of turning dirty files or a guessed ``src/main.py`` into a
|
|
2110
|
+
# permission to patch an arbitrary file.
|
|
2111
|
+
return []
|
|
2112
|
+
|
|
2113
|
+
|
|
2114
|
+
def detect_plan_candidates(workspace: str) -> list[str]:
|
|
2115
|
+
"""Return existing dirty files as read-only plan suggestions.
|
|
2116
|
+
|
|
2117
|
+
Plan mode may show the user what changed, but this candidate list is not
|
|
2118
|
+
reused as a build/apply allowlist. An unavailable Git view yields no
|
|
2119
|
+
fabricated path.
|
|
2120
|
+
"""
|
|
2121
|
+
try:
|
|
2122
|
+
result = subprocess.run(
|
|
2123
|
+
["git", "status", "--porcelain=v1", "-z", "--untracked-files=all"],
|
|
2124
|
+
cwd=workspace,
|
|
2125
|
+
capture_output=True,
|
|
2126
|
+
text=True,
|
|
2127
|
+
check=False,
|
|
2128
|
+
)
|
|
2129
|
+
if result.returncode != 0:
|
|
2130
|
+
return []
|
|
2131
|
+
candidates: list[str] = []
|
|
2132
|
+
entries = result.stdout.split("\0")
|
|
2133
|
+
index = 0
|
|
2134
|
+
while index < len(entries):
|
|
2135
|
+
entry = entries[index]
|
|
2136
|
+
index += 1
|
|
2137
|
+
if len(entry) < 4:
|
|
2138
|
+
continue
|
|
2139
|
+
status, path = entry[:2], entry[3:].replace("\\", "/")
|
|
2140
|
+
if "R" in status or "C" in status:
|
|
2141
|
+
index += 1
|
|
2142
|
+
if not path or any(part.startswith(".") for part in Path(path).parts):
|
|
2143
|
+
continue
|
|
2144
|
+
if (Path(workspace) / path).is_file():
|
|
2145
|
+
candidates.append(path)
|
|
2146
|
+
return candidates[:3]
|
|
2147
|
+
except (OSError, subprocess.SubprocessError):
|
|
2148
|
+
return []
|
|
2149
|
+
|
|
2150
|
+
|
|
2151
|
+
def detect_test_checks(workspace: str) -> list[str]:
|
|
2152
|
+
ws_path = Path(workspace)
|
|
2153
|
+
package_path = ws_path / "package.json"
|
|
2154
|
+
if package_path.is_file():
|
|
2155
|
+
try:
|
|
2156
|
+
package = json.loads(package_path.read_text(encoding="utf-8-sig"))
|
|
2157
|
+
scripts = package.get("scripts") if isinstance(package, dict) else None
|
|
2158
|
+
if isinstance(scripts, dict) and isinstance(scripts.get("test"), str) and scripts["test"].strip():
|
|
2159
|
+
return ["npm test"]
|
|
2160
|
+
except (OSError, UnicodeError, json.JSONDecodeError):
|
|
2161
|
+
return []
|
|
2162
|
+
|
|
2163
|
+
if (ws_path / "Cargo.toml").is_file():
|
|
2164
|
+
return ["cargo test"]
|
|
2165
|
+
if (ws_path / "go.mod").is_file():
|
|
2166
|
+
return ["go test ./..."]
|
|
2167
|
+
|
|
2168
|
+
python_manifest = any(
|
|
2169
|
+
(ws_path / name).is_file()
|
|
2170
|
+
for name in ("pyproject.toml", "setup.cfg", "setup.py", "pytest.ini", "tox.ini")
|
|
2171
|
+
)
|
|
2172
|
+
if python_manifest:
|
|
2173
|
+
if (ws_path / "tests").is_dir():
|
|
2174
|
+
return ["pytest tests/"]
|
|
2175
|
+
if (ws_path / "test").is_dir():
|
|
2176
|
+
return ["pytest test/"]
|
|
2177
|
+
return ["pytest"]
|
|
2178
|
+
return []
|