fix: format and apply lint fixes in python script
This commit is contained in:
+21
-117
@@ -1,64 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Mock llama-server for exercising zshell's LLM error handling.
|
|
||||||
|
|
||||||
Implements the OpenAI-compatible subset of llama-server that the C++
|
|
||||||
backend talks to, and lets you trigger specific failure modes from the
|
|
||||||
QML chat UI by typing a keyword as your message.
|
|
||||||
|
|
||||||
Wire format is taken from ggml-org/llama.cpp (tools/server):
|
|
||||||
- SSE events are "data: <json>\n\n" (note the space after "data:").
|
|
||||||
- A clean stream ends with "data: [DONE]\n\n".
|
|
||||||
- Errors are {"error": {"code", "message", "type"}}.
|
|
||||||
- A stream that dies mid-way sends the error event, then closes
|
|
||||||
WITHOUT [DONE].
|
|
||||||
- A request that errors before the first token is a plain HTTP 4xx/5xx
|
|
||||||
with the error JSON body (not a stream).
|
|
||||||
- /props is a JSON object; n_ctx lives under
|
|
||||||
default_generation_settings.n_ctx.
|
|
||||||
|
|
||||||
Run:
|
|
||||||
python3 scripts/mock-llama-server.py [--port 8080]
|
|
||||||
|
|
||||||
Then point zshell's LLM endpoint at http://localhost:8080.
|
|
||||||
|
|
||||||
Trigger words (type as the chat message, case-insensitive):
|
|
||||||
normal -> healthy stream, ends with [DONE] (also the default)
|
|
||||||
instant error -> 500 before any token ("server_error")
|
|
||||||
context overflow -> 400 "exceeds the available context size (2048 ...)";
|
|
||||||
backend retries once at 2048, then fails
|
|
||||||
error during -> streams a few tokens, then an SSE error event,
|
|
||||||
closes without [DONE]
|
|
||||||
no done -> streams a few tokens, closes without [DONE] and
|
|
||||||
without an error (server killed mid-response)
|
|
||||||
200 error -> HTTP 200 with a plain JSON error body (not SSE)
|
|
||||||
tool call -> streams reasoning, then a tool_calls round calling
|
|
||||||
readfile on /etc/os-release (finish_reason
|
|
||||||
"tool_calls"); after you approve (or deny) the
|
|
||||||
permission dialog, the follow-up round that
|
|
||||||
carries the tool result gets a normal summary
|
|
||||||
answer. Exercises the tool permission dialog,
|
|
||||||
tool execution and the multi-round transcript.
|
|
||||||
multiple tool -> two tool calls in one round (readfile + webfetch
|
|
||||||
calls of this mock's own / endpoint); approve both at
|
|
||||||
once
|
|
||||||
unknown tool -> a tool call for a tool the client does not have;
|
|
||||||
approving it yields an "unknown tool" error
|
|
||||||
result
|
|
||||||
|
|
||||||
Control endpoint (for the non-chat routes / global state), via curl:
|
|
||||||
curl -XPOST localhost:8080/_mock -d '{"loading": true}'
|
|
||||||
/props, /v1/models, /health all answer 503 "Loading model"
|
|
||||||
curl -XPOST localhost:8080/_mock -d '{"no_models": true}'
|
|
||||||
/v1/models returns an empty list
|
|
||||||
curl -XPOST localhost:8080/_mock -d '{"chat_mode": "error_during"}'
|
|
||||||
force a chat behaviour regardless of the message text
|
|
||||||
(values: normal|instant_error|context_overflow|error_during|
|
|
||||||
no_done|200_error|tool_call|tool_call_multi|
|
|
||||||
tool_call_unknown, or null to go back to keywords)
|
|
||||||
curl -XPOST localhost:8080/_mock -d '{"reset": true}'
|
|
||||||
"""
|
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import contextlib
|
||||||
import json
|
import json
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
@@ -67,17 +8,14 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|||||||
MODEL_ID = "mock-model"
|
MODEL_ID = "mock-model"
|
||||||
CTX_SIZE = 8192
|
CTX_SIZE = 8192
|
||||||
|
|
||||||
# Set in main(); used by the tool-call behaviours so webfetch can point
|
|
||||||
# at this server itself.
|
|
||||||
_host = "127.0.0.1"
|
_host = "127.0.0.1"
|
||||||
_port = 8080
|
_port = 8080
|
||||||
|
|
||||||
# Mutable, shared across handler threads.
|
|
||||||
_state_lock = threading.Lock()
|
_state_lock = threading.Lock()
|
||||||
_state = {
|
_state = {
|
||||||
"loading": False, # 503 "Loading model" on /props, /v1/models, /health
|
"loading": False,
|
||||||
"no_models": False, # /v1/models returns an empty list
|
"no_models": False,
|
||||||
"chat_mode": None, # force a chat behaviour, overriding keywords
|
"chat_mode": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -122,7 +60,6 @@ def _error(code, message, etype):
|
|||||||
|
|
||||||
|
|
||||||
def _sse(obj):
|
def _sse(obj):
|
||||||
# Exact llama.cpp format_oai_sse shape: "data: " + compact json + "\n\n".
|
|
||||||
return ("data: " + json.dumps(obj, separators=(",", ":")) + "\n\n").encode()
|
return ("data: " + json.dumps(obj, separators=(",", ":")) + "\n\n").encode()
|
||||||
|
|
||||||
|
|
||||||
@@ -130,9 +67,6 @@ def _sse_done():
|
|||||||
return b"data: [DONE]\n\n"
|
return b"data: [DONE]\n\n"
|
||||||
|
|
||||||
|
|
||||||
# Chat behaviours. Each returns ("json", status, body_bytes) for a
|
|
||||||
# non-streaming answer, or ("sse", 200, None) and then writes to the
|
|
||||||
# provided stream writer.
|
|
||||||
def _behaviour_for(text):
|
def _behaviour_for(text):
|
||||||
t = text.lower()
|
t = text.lower()
|
||||||
if "instant error" in t:
|
if "instant error" in t:
|
||||||
@@ -145,7 +79,6 @@ def _behaviour_for(text):
|
|||||||
return "no_done"
|
return "no_done"
|
||||||
if "200 error" in t:
|
if "200 error" in t:
|
||||||
return "200_error"
|
return "200_error"
|
||||||
# Order matters: "multiple tool calls" contains "tool call".
|
|
||||||
if "two tool calls" in t or "two tool call" in t:
|
if "two tool calls" in t or "two tool call" in t:
|
||||||
return "tool_call_duo"
|
return "tool_call_duo"
|
||||||
if "some tool calls" in t:
|
if "some tool calls" in t:
|
||||||
@@ -183,11 +116,6 @@ def _tool_call_delta(index, call_id=None, name=None, arguments=None):
|
|||||||
|
|
||||||
|
|
||||||
def _stream_tool_calls(w, cid, reasoning, calls):
|
def _stream_tool_calls(w, cid, reasoning, calls):
|
||||||
# One tool round: optional reasoning, then the calls (llama.cpp
|
|
||||||
# streams the id/name with the first fragment of each call and the
|
|
||||||
# arguments as successive fragments), then finish_reason
|
|
||||||
# "tool_calls". The client concatenates the argument fragments and
|
|
||||||
# shows the permission dialog on stream end.
|
|
||||||
w(_sse(_chunk(cid, {"role": "assistant"})))
|
w(_sse(_chunk(cid, {"role": "assistant"})))
|
||||||
if reasoning:
|
if reasoning:
|
||||||
for piece in reasoning:
|
for piece in reasoning:
|
||||||
@@ -222,8 +150,6 @@ def _tool_result_snippets(messages, limit=160):
|
|||||||
|
|
||||||
|
|
||||||
def _stream_tool_round_done(w, cid, messages):
|
def _stream_tool_round_done(w, cid, messages):
|
||||||
# Follow-up round: the client sent the tool results back; answer
|
|
||||||
# with a plain content summary so the turn completes.
|
|
||||||
w(_sse(_chunk(cid, {"role": "assistant"})))
|
w(_sse(_chunk(cid, {"role": "assistant"})))
|
||||||
parts = ["mock:", "tool", "round", "complete."]
|
parts = ["mock:", "tool", "round", "complete."]
|
||||||
for i, snippet in enumerate(_tool_result_snippets(messages), 1):
|
for i, snippet in enumerate(_tool_result_snippets(messages), 1):
|
||||||
@@ -252,8 +178,6 @@ def _stream_error_during(w, cid, words):
|
|||||||
for word in words:
|
for word in words:
|
||||||
w(_sse(_chunk(cid, {"content": word + " "})))
|
w(_sse(_chunk(cid, {"content": word + " "})))
|
||||||
time.sleep(0.05)
|
time.sleep(0.05)
|
||||||
# Mid-stream failure: error event, then the connection closes without
|
|
||||||
# [DONE] (llama.cpp terminates the stream on a task error).
|
|
||||||
w(_sse(_error(500, "Mock: generation failed mid-stream", "server_error")))
|
w(_sse(_error(500, "Mock: generation failed mid-stream", "server_error")))
|
||||||
|
|
||||||
|
|
||||||
@@ -262,7 +186,6 @@ def _stream_no_done(w, cid, words):
|
|||||||
for word in words:
|
for word in words:
|
||||||
w(_sse(_chunk(cid, {"content": word + " "})))
|
w(_sse(_chunk(cid, {"content": word + " "})))
|
||||||
time.sleep(0.05)
|
time.sleep(0.05)
|
||||||
# Clean FIN, no [DONE], no error: server died mid-response.
|
|
||||||
|
|
||||||
|
|
||||||
def _last_user_text(messages):
|
def _last_user_text(messages):
|
||||||
@@ -270,7 +193,6 @@ def _last_user_text(messages):
|
|||||||
if msg.get("role") == "user":
|
if msg.get("role") == "user":
|
||||||
content = msg.get("content")
|
content = msg.get("content")
|
||||||
if isinstance(content, list):
|
if isinstance(content, list):
|
||||||
# multimodal shape: pull out the text parts
|
|
||||||
parts = []
|
parts = []
|
||||||
for part in content:
|
for part in content:
|
||||||
if isinstance(part, dict) and part.get("type") == "text":
|
if isinstance(part, dict) and part.get("type") == "text":
|
||||||
@@ -284,7 +206,6 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
protocol_version = "HTTP/1.1"
|
protocol_version = "HTTP/1.1"
|
||||||
server_version = "MockLlama/1.0"
|
server_version = "MockLlama/1.0"
|
||||||
|
|
||||||
# --- low-level response helpers -----------------------------------
|
|
||||||
def _send_json(self, status, obj):
|
def _send_json(self, status, obj):
|
||||||
body = json.dumps(obj, separators=(",", ":")).encode()
|
body = json.dumps(obj, separators=(",", ":")).encode()
|
||||||
self.send_response(status)
|
self.send_response(status)
|
||||||
@@ -295,7 +216,6 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self.wfile.write(body)
|
self.wfile.write(body)
|
||||||
|
|
||||||
def _send_loading_error(self):
|
def _send_loading_error(self):
|
||||||
# llama.cpp middleware: server not ready -> 503 on every route.
|
|
||||||
self._send_json(503, _error(503, "Loading model", "unavailable_error"))
|
self._send_json(503, _error(503, "Loading model", "unavailable_error"))
|
||||||
|
|
||||||
def _not_ready(self):
|
def _not_ready(self):
|
||||||
@@ -313,12 +233,9 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
except (BrokenPipeError, ConnectionResetError):
|
except (BrokenPipeError, ConnectionResetError):
|
||||||
pass
|
pass
|
||||||
finally:
|
finally:
|
||||||
try:
|
with contextlib.suppress(BrokenPipeError, ConnectionResetError):
|
||||||
self.wfile.flush()
|
self.wfile.flush()
|
||||||
except (BrokenPipeError, ConnectionResetError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
# --- routing -------------------------------------------------------
|
|
||||||
def do_GET(self):
|
def do_GET(self):
|
||||||
path = self.path.split("?", 1)[0].rstrip("/") or "/"
|
path = self.path.split("?", 1)[0].rstrip("/") or "/"
|
||||||
if path in ("/health", "/v1/health"):
|
if path in ("/health", "/v1/health"):
|
||||||
@@ -382,7 +299,6 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
|
|
||||||
return self._send_json(404, _error(404, "not found", "not_found_error"))
|
return self._send_json(404, _error(404, "not found", "not_found_error"))
|
||||||
|
|
||||||
# --- /_mock control ------------------------------------------------
|
|
||||||
def _mock_control(self, body):
|
def _mock_control(self, body):
|
||||||
with _state_lock:
|
with _state_lock:
|
||||||
if body.get("reset"):
|
if body.get("reset"):
|
||||||
@@ -399,7 +315,6 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self.log_message("control -> %s", json.dumps(snapshot))
|
self.log_message("control -> %s", json.dumps(snapshot))
|
||||||
return self._send_json(200, {"ok": True, "state": snapshot})
|
return self._send_json(200, {"ok": True, "state": snapshot})
|
||||||
|
|
||||||
# --- /chat/completions --------------------------------------------
|
|
||||||
def _chat(self, body):
|
def _chat(self, body):
|
||||||
text = _last_user_text(body.get("messages"))
|
text = _last_user_text(body.get("messages"))
|
||||||
with _state_lock:
|
with _state_lock:
|
||||||
@@ -411,7 +326,6 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self.log_message("chat mode=%s stream=%s text=%r", mode, stream, text)
|
self.log_message("chat mode=%s stream=%s text=%r", mode, stream, text)
|
||||||
|
|
||||||
if mode == "instant_error":
|
if mode == "instant_error":
|
||||||
# Error before the first token: plain HTTP error, not a stream.
|
|
||||||
return self._send_json(
|
return self._send_json(
|
||||||
500, _error(500, "Mock: instant server error", "server_error")
|
500, _error(500, "Mock: instant server error", "server_error")
|
||||||
)
|
)
|
||||||
@@ -428,15 +342,11 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if mode == "200_error":
|
if mode == "200_error":
|
||||||
# 200 with a plain JSON error body (not SSE) -- the case the
|
|
||||||
# data: filter would otherwise swallow.
|
|
||||||
return self._send_json(
|
return self._send_json(
|
||||||
200, _error(400, "Mock: 200-with-json-error", "server_error")
|
200, _error(400, "Mock: 200-with-json-error", "server_error")
|
||||||
)
|
)
|
||||||
|
|
||||||
if mode in _TOOL_MODES:
|
if mode in _TOOL_MODES:
|
||||||
# Continuation round: the previous round's tool results came
|
|
||||||
# back through the client (role: "tool" messages).
|
|
||||||
if any(m.get("role") == "tool" for m in body.get("messages") or []):
|
if any(m.get("role") == "tool" for m in body.get("messages") or []):
|
||||||
return self._stream(
|
return self._stream(
|
||||||
200,
|
200,
|
||||||
@@ -467,7 +377,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
"call_mock_2",
|
"call_mock_2",
|
||||||
"webfetch",
|
"webfetch",
|
||||||
[
|
[
|
||||||
'{"url": "http://%s:%d/", ' % (_host, _port),
|
f'{{"url": "http://{_host}:{_port}/", ',
|
||||||
'"format": "text"}',
|
'"format": "text"}',
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -487,7 +397,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
"call_mock_2",
|
"call_mock_2",
|
||||||
"webfetch",
|
"webfetch",
|
||||||
[
|
[
|
||||||
'{"url": "http://%s:%d/", ' % (_host, _port),
|
f'{{"url": "http://{_host}:{_port}/", ',
|
||||||
'"format": "text"}',
|
'"format": "text"}',
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -500,7 +410,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
"call_mock_4",
|
"call_mock_4",
|
||||||
"webfetch",
|
"webfetch",
|
||||||
[
|
[
|
||||||
'{"url": "http://%s:%d/", ' % (_host, _port),
|
f'{{"url": "http://{_host}:{_port}/", ',
|
||||||
'"format": "text"}',
|
'"format": "text"}',
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -513,7 +423,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
"call_mock_6",
|
"call_mock_6",
|
||||||
"webfetch",
|
"webfetch",
|
||||||
[
|
[
|
||||||
'{"url": "http://%s:%d/", ' % (_host, _port),
|
f'{{"url": "http://{_host}:{_port}/", ',
|
||||||
'"format": "text"}',
|
'"format": "text"}',
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -533,7 +443,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
"call_mock_2",
|
"call_mock_2",
|
||||||
"webfetch",
|
"webfetch",
|
||||||
[
|
[
|
||||||
'{"url": "http://%s:%d/", ' % (_host, _port),
|
f'{{"url": "http://{_host}:{_port}/", ',
|
||||||
'"format": "text"}',
|
'"format": "text"}',
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -546,7 +456,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
"call_mock_4",
|
"call_mock_4",
|
||||||
"webfetch",
|
"webfetch",
|
||||||
[
|
[
|
||||||
'{"url": "http://%s:%d/", ' % (_host, _port),
|
f'{{"url": "http://{_host}:{_port}/", ',
|
||||||
'"format": "text"}',
|
'"format": "text"}',
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -559,7 +469,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
"call_mock_6",
|
"call_mock_6",
|
||||||
"webfetch",
|
"webfetch",
|
||||||
[
|
[
|
||||||
'{"url": "http://%s:%d/", ' % (_host, _port),
|
f'{{"url": "http://{_host}:{_port}/", ',
|
||||||
'"format": "text"}',
|
'"format": "text"}',
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -572,7 +482,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
"call_mock_2",
|
"call_mock_2",
|
||||||
"webfetch",
|
"webfetch",
|
||||||
[
|
[
|
||||||
'{"url": "http://%s:%d/", ' % (_host, _port),
|
f'{{"url": "http://{_host}:{_port}/", ',
|
||||||
'"format": "text"}',
|
'"format": "text"}',
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -585,7 +495,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
"call_mock_4",
|
"call_mock_4",
|
||||||
"webfetch",
|
"webfetch",
|
||||||
[
|
[
|
||||||
'{"url": "http://%s:%d/", ' % (_host, _port),
|
f'{{"url": "http://{_host}:{_port}/", ',
|
||||||
'"format": "text"}',
|
'"format": "text"}',
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -598,7 +508,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
"call_mock_6",
|
"call_mock_6",
|
||||||
"webfetch",
|
"webfetch",
|
||||||
[
|
[
|
||||||
'{"url": "http://%s:%d/", ' % (_host, _port),
|
f'{{"url": "http://{_host}:{_port}/", ',
|
||||||
'"format": "text"}',
|
'"format": "text"}',
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -611,7 +521,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
"call_mock_2",
|
"call_mock_2",
|
||||||
"webfetch",
|
"webfetch",
|
||||||
[
|
[
|
||||||
'{"url": "http://%s:%d/", ' % (_host, _port),
|
f'{{"url": "http://{_host}:{_port}/", ',
|
||||||
'"format": "text"}',
|
'"format": "text"}',
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -624,7 +534,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
"call_mock_4",
|
"call_mock_4",
|
||||||
"webfetch",
|
"webfetch",
|
||||||
[
|
[
|
||||||
'{"url": "http://%s:%d/", ' % (_host, _port),
|
f'{{"url": "http://{_host}:{_port}/", ',
|
||||||
'"format": "text"}',
|
'"format": "text"}',
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -637,7 +547,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
"call_mock_6",
|
"call_mock_6",
|
||||||
"webfetch",
|
"webfetch",
|
||||||
[
|
[
|
||||||
'{"url": "http://%s:%d/", ' % (_host, _port),
|
f'{{"url": "http://{_host}:{_port}/", ',
|
||||||
'"format": "text"}',
|
'"format": "text"}',
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -646,7 +556,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
"Two independent tool calls, ",
|
"Two independent tool calls, ",
|
||||||
"batched together.",
|
"batched together.",
|
||||||
]
|
]
|
||||||
else: # tool_call_unknown
|
else:
|
||||||
calls = [
|
calls = [
|
||||||
(
|
(
|
||||||
"call_mock_3",
|
"call_mock_3",
|
||||||
@@ -663,7 +573,6 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if not stream:
|
if not stream:
|
||||||
# Non-streaming success: single chat.completion object.
|
|
||||||
content = " ".join(words)
|
content = " ".join(words)
|
||||||
return self._send_json(
|
return self._send_json(
|
||||||
200,
|
200,
|
||||||
@@ -699,10 +608,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
return self._stream(200, lambda w: _stream_normal(w, cid, words))
|
return self._stream(200, lambda w: _stream_normal(w, cid, words))
|
||||||
|
|
||||||
def log_message(self, format, *args):
|
def log_message(self, format, *args):
|
||||||
# Compact single-line logging to stdout.
|
print(f"{self.log_date_time_string()} - {format % args}", flush=True)
|
||||||
print(
|
|
||||||
"%s - %s" % (self.log_date_time_string(), format % args), flush=True
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
@@ -725,10 +631,8 @@ def main():
|
|||||||
"type trigger words in the chat UI, or curl /_mock to change state",
|
"type trigger words in the chat UI, or curl /_mock to change state",
|
||||||
flush=True,
|
flush=True,
|
||||||
)
|
)
|
||||||
try:
|
with contextlib.suppress(KeyboardInterrupt):
|
||||||
server.serve_forever()
|
server.serve_forever()
|
||||||
except KeyboardInterrupt:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
Reference in New Issue
Block a user