char-fork/llama.cpp

git clone https://git.t4t.associates/char-fork/llama.cpp

Pascalserver + ui: fix stream routes for model names containing a slash (#26137)d73c1d6b2

char/aliceai-t5-moe
5.3 KiB153 linesraw
1import json
2import socket
3import threading
4import time
5from urllib.parse import quote
6import pytest
7from utils import *
8
9server: ServerProcess
10
11# a model name with slashes exercises the query string routing of the stream routes: the id
12# cannot travel as a path param because the decoded slash would split it before capture
13MODEL = "ggml-org/tinygemma3-GGUF:Q8_0"
14STREAM_ID = f"conv-stream-test::{MODEL}"
15QS = "conv_id=" + quote(STREAM_ID, safe="")
16
17
18@pytest.fixture(autouse=True)
19def create_server():
20    global server
21    server = ServerPreset.router()
22
23
24def test_stream_resume_and_stop_with_slashed_model_name():
25    global server
26    server.start()
27
28    content = ""
29    for data in server.make_stream_request("POST", "/chat/completions", data={
30        "model": MODEL,
31        "stream": True,
32        "max_tokens": 16,
33        "messages": [{"role": "user", "content": "hello"}],
34    }, headers={"X-Conversation-Id": STREAM_ID}):
35        if data["choices"]:
36            content += data["choices"][0]["delta"].get("content") or ""
37    assert len(content) > 0
38
39    # the finished session replays from the beginning through the router
40    res = server.make_request("GET", f"/v1/stream?{QS}&from=0")
41    assert res.status_code == 200
42    assert "data: " in str(res.body)
43
44    # the explicit stop reaches the owning child and evicts the session
45    res = server.make_request("DELETE", f"/v1/stream?{QS}")
46    assert res.status_code == 204
47    res = server.make_request("GET", f"/v1/stream?{QS}&from=0")
48    assert res.status_code == 404
49
50
51def test_stream_stop_during_model_load():
52    global server
53    server.start()
54
55    thread_error: list[ServerError] = []
56    thread_done = threading.Event()
57
58    def fire_post():
59        try:
60            for _ in server.make_stream_request("POST", "/chat/completions", data={
61                "model": MODEL,
62                "stream": True,
63                "max_tokens": 512,
64                "messages": [{"role": "user", "content": "Count from 1 to 1000."}],
65            }, headers={"X-Conversation-Id": STREAM_ID}):
66                pass
67        except ServerError as e:
68            thread_error.append(e)
69        finally:
70            thread_done.set()
71
72    t = threading.Thread(target=fire_post)
73    t.start()
74
75    # catch the autoload window, tiny models load fast so poll aggressively
76    saw_loading = False
77    deadline = time.time() + 5.0
78    while time.time() < deadline and not thread_done.is_set():
79        res = server.make_request("GET", "/models")
80        status = next(m["status"]["value"] for m in res.body["data"] if m["id"] == MODEL)
81        if status == "loading":
82            saw_loading = True
83            break
84        time.sleep(0.002)
85    if not saw_loading:
86        t.join()
87        pytest.skip("load window too short to be observed on this machine")  # ty: ignore[too-many-positional-arguments]
88
89    # a stop during the load cancels the parked request instead of leaving an orphan
90    res = server.make_request("DELETE", f"/v1/stream?{QS}")
91    assert res.status_code == 204
92    assert thread_done.wait(timeout=60)
93    t.join()
94    assert len(thread_error) == 1
95    assert thread_error[0].code == 400
96    assert "cancelled" in json.dumps(thread_error[0].body)
97    res = server.make_request("GET", f"/v1/stream?{QS}&from=0")
98    assert res.status_code == 404
99
100
101def test_stream_resumes_after_reload_during_model_load():
102    global server
103    server.start()
104
105    # raw socket client so the connection can be dropped mid load like a page reload
106    body = json.dumps({
107        "model": MODEL,
108        "stream": True,
109        "max_tokens": 16,
110        "messages": [{"role": "user", "content": "hello"}],
111    })
112    request = (
113        f"POST /v1/chat/completions HTTP/1.1\r\n"
114        f"Host: {server.server_host}:{server.server_port}\r\n"
115        f"Content-Type: application/json\r\n"
116        f"X-Conversation-Id: {STREAM_ID}\r\n"
117        f"Content-Length: {len(body)}\r\n"
118        f"Connection: close\r\n\r\n{body}"
119    )
120    sock = socket.create_connection((server.server_host, server.server_port))
121    sock.sendall(request.encode())
122
123    # drop the client while the model loads, poll aggressively to catch the window
124    saw_loading = False
125    saw_503 = False
126    deadline = time.time() + 5.0
127    while time.time() < deadline:
128        res = server.make_request("GET", "/models")
129        status = next(m["status"]["value"] for m in res.body["data"] if m["id"] == MODEL)
130        if status == "loading":
131            saw_loading = True
132            break
133        if status == "loaded":
134            break
135        time.sleep(0.002)
136    sock.close()
137    if not saw_loading:
138        pytest.skip("load window too short to be observed on this machine")  # ty: ignore[too-many-positional-arguments]
139
140    # while the model loads the resume route answers retry later, then the session appears,
141    # receives the whole generation despite the dead client, and replays from the beginning
142    deadline = time.time() + 60.0
143    replay = None
144    while time.time() < deadline:
145        res = server.make_request("GET", f"/v1/stream?{QS}&from=0")
146        if res.status_code == 503:
147            saw_503 = True
148        elif res.status_code == 200 and "data: " in str(res.body):
149            replay = res
150            break
151        time.sleep(0.1)
152    assert saw_503, "resume during the load did not answer 503"
153    assert replay is not None, "session never became resumable after the client disconnect"