FIX SOME BUGS

This commit is contained in:
2026-09-12 10:14:55 +02:00
parent 02d48a304a
commit 8f1c3207c9
15 changed files with 1356 additions and 82 deletions
+3
View File
@@ -12,6 +12,9 @@ host = "127.0.0.1"
base_url = "http://127.0.0.1:11434"
# Default model used when a client does not request a specific one.
model = "llama3.2"
# Context window the daemon requests from ollama. The agent sends long
# transcripts (system prompt + tool results), so do not drop this below 8192.
num_ctx = 16384
[database]
# SQLite file for user accounts. Relative to the project root.
+116 -15
View File
@@ -24,6 +24,7 @@ import cmd
import hashlib
import hmac
import json
import os
import sqlite3
import sys
import threading
@@ -35,7 +36,9 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
ROOT = Path(__file__).resolve().parent
CONFIG_FILE = ROOT / "config.toml"
# SJCODE_CONFIG lets you run a second daemon (tests, staging) without
# touching the regular config; the database is created next to that file.
CONFIG_FILE = Path(os.environ.get("SJCODE_CONFIG") or (ROOT / "config.toml"))
DB_FILE = ROOT / "sjcode.db" # overwritten by config value once loaded
PEPPER = b"sjcode-v1"
@@ -43,7 +46,8 @@ SESSION_TTL = 24 * 3600 # login token valid for one day
CONF: dict = {
"server": {"host": "127.0.0.1", "port": 8420},
"ollama": {"base_url": "http://127.0.0.1:11434", "model": "llama3.2"},
"ollama": {"base_url": "http://127.0.0.1:11434", "model": "llama3.2",
"num_ctx": 16384},
"database": {"path": "sjcode.db"},
"limits": {"user": 120, "admin": -1},
}
@@ -66,7 +70,7 @@ def load_config() -> None:
for section, values in loaded.items():
if isinstance(values, dict) and isinstance(CONF.get(section), dict):
CONF[section].update(values)
DB_FILE = ROOT / CONF["database"]["path"]
DB_FILE = CONFIG_FILE.parent / CONF["database"]["path"]
# --------------------------------------------------------------------------
@@ -232,15 +236,62 @@ def ollama_json(path: str, payload: dict | None = None, timeout: int = 300) -> t
return 502, {"error": f"ollama not reachable: {exc}"}
def ollama_generate(prompt: str, model: str | None) -> tuple[int, str]:
status, body = ollama_json(
"/api/generate",
{
"model": model or CONF["ollama"]["model"],
"prompt": prompt,
"stream": False,
},
def ollama_stream(path: str, payload: dict) -> tuple[int, object]:
"""POST to ollama and return the raw response to iterate line by line."""
url = CONF["ollama"]["base_url"].rstrip("/") + path
data = json.dumps(payload).encode()
req = urllib.request.Request(
url, data=data, headers={"Content-Type": "application/json"}
)
try:
resp = urllib.request.urlopen(req, timeout=600)
except urllib.error.HTTPError as exc:
try:
body = json.loads(exc.read().decode())
except Exception:
body = {"error": str(exc)}
return exc.code, str(body.get("error", "ollama error"))
except (urllib.error.URLError, TimeoutError, ConnectionError) as exc:
return 502, {"error": f"ollama not reachable: {exc}"}
return resp.status, resp # caller iterates the lines and closes it
def ollama_generate(prompt: str, model: str | None, stream: bool = False):
"""Generate a reply. Returns (status, text) or (200, token-iterator)."""
options = {"num_ctx": int(CONF["ollama"].get("num_ctx", 16384))}
payload = {
"model": model or CONF["ollama"]["model"],
"prompt": prompt,
"stream": stream,
"options": options,
}
if stream:
status, body = ollama_stream("/api/generate", payload)
if status != 200:
return status, str(body)
def tokens():
try:
for raw in body:
line = raw.decode() if isinstance(raw, bytes) else raw
if not line.strip():
continue
try:
obj = json.loads(line)
except json.JSONDecodeError:
continue
if obj.get("error"):
yield f"\n[ollama error: {obj['error']}]"
return
piece = obj.get("response", "")
if piece:
yield piece
finally:
body.close()
return 200, tokens()
status, body = ollama_json("/api/generate", payload)
if status != 200:
return status, str(body.get("error", "ollama error"))
return 200, str(body.get("response", ""))
@@ -259,7 +310,8 @@ def list_models() -> tuple[int, list[str]]:
# --------------------------------------------------------------------------
class ApiHandler(BaseHTTPRequestHandler):
server_version = "sjcode-daemon/0.1"
server_version = "sjcode-daemon/0.2"
protocol_version = "HTTP/1.1" # required for chunked streamed replies
# -- helpers ----------------------------------------------------------
def _json(self, status: int, payload: dict) -> None:
@@ -277,8 +329,48 @@ class ApiHandler(BaseHTTPRequestHandler):
except (ValueError, json.JSONDecodeError):
return {}
def log_message(self, fmt: str, *args) -> None: # quiet by default
return
def _respond_stream(self, tokens, model: str, started: float,
username: str) -> None:
"""Stream a reply as JSON lines: {"chunk": ...} ... {"response": ...}."""
self.send_response(200)
self.send_header("Content-Type", "application/x-ndjson")
self.send_header("Transfer-Encoding", "chunked")
self.end_headers()
def send_line(obj: dict) -> None:
data = (json.dumps(obj) + "\n").encode()
self.wfile.write(f"{len(data):x}\r\n".encode() + data + b"\r\n")
self.wfile.flush()
parts: list[str] = []
abandoned = False
try:
for piece in tokens:
parts.append(piece)
send_line({"chunk": piece})
except (BrokenPipeError, ConnectionResetError):
abandoned = True # client went away; still bill the usage below
except OSError as exc:
parts.append(f"\n[stream error: {exc}]")
send_line({"chunk": f"\n[stream error: {exc}]"})
finally:
text = "".join(parts)
elapsed = time.monotonic() - started
used_min = elapsed / 60.0
add_used_minutes(username, used_min)
if not abandoned:
left_now = remaining_minutes(username)
send_line({
"response": text,
"model": model,
"used_minutes": round(used_min, 4),
"remaining_minutes": None if left_now == float("inf")
else round(left_now, 2),
})
self.wfile.write(b"0\r\n\r\n")
self.wfile.flush()
print(f"[chat] {username}: {len(text)} chars in "
f"{elapsed:.1f}s (streamed)")
# -- routes -----------------------------------------------------------
def do_GET(self) -> None: # noqa: N802 (http.server API)
@@ -353,9 +445,15 @@ class ApiHandler(BaseHTTPRequestHandler):
return
model = str(data.get("model") or "") or CONF["ollama"]["model"]
want_stream = bool(data.get("stream"))
started = time.monotonic()
status, answer = ollama_generate(message, model)
status, answer = ollama_generate(message, model, stream=want_stream)
if want_stream and status == 200:
self._respond_stream(answer, model, started, username)
return
elapsed = time.monotonic() - started
if status != 200:
@@ -375,6 +473,9 @@ class ApiHandler(BaseHTTPRequestHandler):
},
)
def log_message(self, fmt: str, *args) -> None: # quiet by default
return
# --------------------------------------------------------------------------
# admin CLI
Binary file not shown.