SHA256
FIX SOME BUGS
This commit is contained in:
+116
-15
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user