Merge branch 'upstream' into concedo_experimental

# Conflicts:
#	.github/workflows/build-webgpu.yml
#	CMakeLists.txt
#	common/CMakeLists.txt
#	docs/development/HOWTO-add-model.md
#	ggml/src/ggml-opencl/ggml-opencl.cpp
#	ggml/src/ggml-sycl/CMakeLists.txt
#	tests/test-arg-parser.cpp
#	tests/test-jinja.cpp
#	tests/test-llama-archs.cpp
#	tests/test-save-load-state.cpp
#	tools/cli/README.md
#	tools/completion/README.md
#	tools/llama-bench/llama-bench.cpp
#	tools/mtmd/CMakeLists.txt
#	tools/server/README.md
This commit is contained in:
Concedo
2026-07-27 22:28:38 +08:00
74 changed files with 2516 additions and 361 deletions
+5 -5
View File
@@ -136,13 +136,13 @@ Producer side: `server_res_generator` extends `server_res_spipe`, which keeps al
Lifetime safety: the session holds no back reference to the response, so `spipe` is a plain `unique_ptr` touched only by the http worker. `cancel` raises an atomic the producer polls; the producer finalizes the session from its destructor, which also runs `~server_response_reader::stop()` to cancel the generation at the queue level. A `DELETE` stops work by raising the flag and letting the worker unwind.
Consumer side: `GET /v1/stream/<conv_id>?from=N` opens a `text/event-stream` that replays buffered bytes from offset `N` and blocks for live bytes, so the browser reattaches like a fresh EventSource. An offset below the dropped prefix returns 400.
Consumer side: `GET /v1/stream?conv_id=<id>&from=N` opens a `text/event-stream` that replays buffered bytes from offset `N` and blocks for live bytes, so the browser reattaches like a fresh EventSource. An offset below the dropped prefix returns 400.
Routes:
- `GET /v1/stream/:conv_id?from=N`: replay or live reattach.
- `GET /v1/stream?conv_id=<id>&from=N`: replay or live reattach. The id travels in the query string because it can embed a model name containing slashes.
- `POST /v1/streams/lookup` with `{"conversation_ids": [...]}`: returns session status only for ids the caller already owns. There is no listing route, so live sessions cannot be enumerated (an earlier `GET /v1/streams` was removed for exactly this reason).
- `DELETE /v1/stream/:conv_id`: explicit Stop, idempotent (`evict_and_cancel`).
- `DELETE /v1/stream?conv_id=<id>`: explicit Stop, idempotent (`evict_and_cancel`).
Router mode binds the same paths to proxy handlers. A `conv_id -> child` map (`conv_models`), populated when a POST is routed, resolves the owning child in one lookup with no polling. The lookup groups ids per child; GET and DELETE proxy straight to the owner. This loopback REST hop is expected to move to a websocket IPC later, swapping only the transport.
@@ -166,8 +166,8 @@ graph TD
GC[GC thread] -- drop after TTL --> Sess
end
Sess -- read_from offset --> Cons[stream_pipe_consumer]
Cons -- "GET /v1/stream/:id?from=N" --> Client
DEL[DELETE /v1/stream/:id] -- evict_and_cancel --> Sess
Cons -- "GET /v1/stream?conv_id=id&from=N" --> Client
DEL[DELETE /v1/stream?conv_id=id] -- evict_and_cancel --> Sess
```
The diagram shows the buffer touch points. The live wire (chunks streamed to the original client during a normal generation) is the producer's default output, described under "Producer side" above.
+9 -25
View File
@@ -1,6 +1,6 @@
#include "server-mcp.h"
#include <sheredom/subprocess.h>
#include "subproc.h"
#include <atomic>
#include <chrono>
@@ -341,7 +341,7 @@ json server_mcp_transport::call_tool(const std::string & tool_name,
//
struct server_mcp_stdio::process_handle {
subprocess_s sp;
common_subproc sp;
FILE * in = nullptr; // child stdin
FILE * out = nullptr; // child stdout
FILE * err = nullptr; // child stderr
@@ -483,30 +483,15 @@ bool server_mcp_stdio::start() {
envp_s = mcp_build_env(config.env);
}
auto to_ptrs = [](std::vector<std::string> & v) {
std::vector<const char *> p;
p.reserve(v.size() + 1);
for (auto & s : v) {
p.push_back(s.c_str());
}
p.push_back(nullptr);
return p;
};
auto argv = to_ptrs(argv_s);
auto envp = to_ptrs(envp_s);
auto handle = std::make_unique<process_handle>();
int rc = subprocess_create_ex(argv.data(), options,
config.env.empty() ? nullptr : envp.data(),
config.cwd.empty() ? nullptr : config.cwd.c_str(),
&handle->sp);
if (rc != 0) {
bool ok = handle->sp.create(argv_s, options, envp_s, config.cwd.empty() ? nullptr : config.cwd.c_str());
if (!ok) {
SRV_WRN("MCP '%s': failed to spawn '%s'\n", config.name.c_str(), config.command.c_str());
return false;
}
handle->in = subprocess_stdin(&handle->sp);
handle->out = subprocess_stdout(&handle->sp);
handle->err = subprocess_stderr(&handle->sp);
handle->in = handle->sp.stdin_file();
handle->out = handle->sp.stdout_file();
handle->err = handle->sp.stderr_file();
proc = std::move(handle);
running.store(true);
@@ -654,14 +639,13 @@ void server_mcp_stdio::join_pumps() {
to_server.close_write(); // wake the writer if it waits for a message
from_server.close_write(); // wake any caller waiting for a reply
subprocess_terminate(&proc->sp); // child death unblocks the blocked fread/fwrite
proc->sp.terminate(); // child death unblocks the blocked fread/fwrite
if (writer.joinable()) writer.join();
if (reader.joinable()) reader.join();
if (errlog.joinable()) errlog.join();
subprocess_join(&proc->sp, nullptr); // reap the child: destroy() never waits, so the pid would stay a zombie for the process lifetime
subprocess_destroy(&proc->sp); // safe now: no thread touches the FILE* anymore
proc->sp.join(); // reap the child: never waiting would leave the pid a zombie for the process lifetime
proc.reset();
}
+64 -69
View File
@@ -8,10 +8,10 @@
#include "preset.h"
#include "download.h"
#include "http.h"
#include "subproc.h"
#include <cpp-httplib/httplib.h> // TODO: remove this once we use HTTP client from download.h
#include <optional>
#include <sheredom/subprocess.h>
#include <functional>
#include <optional>
@@ -49,43 +49,24 @@ extern char **environ;
#define CHILD_ADDR "127.0.0.1"
struct server_subproc {
std::optional<subprocess_s> sproc; // empty while in DOWNLOADING state
common_subproc sproc; // not yet spawned while in DOWNLOADING state
std::atomic<bool> stopped{false}; // set to cancel a download or signal child process exit
subprocess_s & get() {
GGML_ASSERT(sproc.has_value() && "subprocess not initialized");
return sproc.value();
}
bool is_alive() {
return sproc.has_value() && subprocess_alive(&sproc.value());
return sproc.alive();
}
void request_exit() {
if (sproc.has_value()) {
FILE * stdin_file = subprocess_stdin(&sproc.value());
if (stdin_file) {
fprintf(stdin_file, "%s\n", CMD_ROUTER_TO_CHILD_EXIT);
fflush(stdin_file);
}
FILE * stdin_file = sproc.stdin_file();
if (stdin_file) {
fprintf(stdin_file, "%s\n", CMD_ROUTER_TO_CHILD_EXIT);
fflush(stdin_file);
}
stopped.store(true, std::memory_order_relaxed);
}
void terminate() {
if (!sproc.has_value()) {
return;
}
#if defined(_WIN32)
if (sproc->hProcess == NULL) {
return;
}
#else
if (sproc->child <= 0) {
return;
}
#endif
subprocess_terminate(&sproc.value());
sproc.terminate();
}
};
@@ -711,18 +692,6 @@ std::optional<server_model_meta> server_models::get_meta(const std::string & nam
return std::nullopt;
}
// helper to convert vector<string> to char **
// pointers are only valid as long as the original vector is valid
static std::vector<char *> to_char_ptr_array(const std::vector<std::string> & vec) {
std::vector<char *> result;
result.reserve(vec.size() + 1);
for (const auto & s : vec) {
result.push_back(const_cast<char*>(s.c_str()));
}
result.push_back(nullptr);
return result;
}
std::vector<server_model_meta> server_models::get_all_meta() {
std::unique_lock<std::mutex> lk(mutex);
if (need_reload) {
@@ -845,15 +814,10 @@ void server_models::load(const std::string & name, const load_options & opts) {
}
inst.meta.args = child_args; // save for debugging
std::vector<char *> argv = to_char_ptr_array(child_args);
std::vector<char *> envp = to_char_ptr_array(child_env);
// TODO @ngxson : maybe separate stdout and stderr in the future
// so that we can use stdout for commands and stderr for logging
int options = subprocess_option_no_window | subprocess_option_combined_stdout_stderr;
inst.subproc->sproc.emplace();
int result = subprocess_create_ex(argv.data(), options, envp.data(), nullptr, &inst.subproc->get());
if (result != 0) {
if (!inst.subproc->sproc.create(child_args, options, child_env)) {
throw std::runtime_error("failed to spawn server instance");
}
}
@@ -867,8 +831,8 @@ void server_models::load(const std::string & name, const load_options & opts) {
stop_timeout = inst.meta.stop_timeout,
child_mode = opts.mode
]() {
FILE * stdin_file = subprocess_stdin(&child_proc->get());
FILE * stdout_file = subprocess_stdout(&child_proc->get()); // combined stdout/stderr
FILE * stdin_file = child_proc->sproc.stdin_file();
FILE * stdout_file = child_proc->sproc.stdout_file(); // combined stdout/stderr
std::thread log_thread([&]() {
// read stdout/stderr and forward to main server log
@@ -942,9 +906,7 @@ void server_models::load(const std::string & name, const load_options & opts) {
}
// get the exit code
int exit_code = 0;
subprocess_join(&child_proc->get(), &exit_code);
subprocess_destroy(&child_proc->get());
int exit_code = child_proc->sproc.join();
// update status and exit code
if (child_mode == SERVER_CHILD_MODE_DOWNLOAD) {
@@ -1210,7 +1172,7 @@ bool server_models::ensure_model_ready(const std::string & name) {
return true;
}
server_http_res_ptr server_models::proxy_request(const server_http_req & req, const std::string & method, const std::string & name, bool update_last_used) {
server_http_res_ptr server_models::proxy_request(const server_http_req & req, const std::string & method, const std::string & name, bool update_last_used, bool detached) {
auto meta = get_meta(name);
if (!meta.has_value()) {
throw std::runtime_error("model name=" + name + " is not found");
@@ -1236,7 +1198,10 @@ server_http_res_ptr server_models::proxy_request(const server_http_req & req, co
req.headers,
req.body,
req.files,
req.should_stop,
// a detached request belongs to a replay session that outlives the client socket:
// it reaches the child even when the downstream died during the load wait, the
// session buffer is the recipient and DELETE remains the stop
detached ? std::function<bool()>([]() { return false; }) : req.should_stop,
base_params.timeout_read,
base_params.timeout_write
);
@@ -1507,13 +1472,9 @@ static bool router_validate_model(std::string & name, server_models & models, bo
}
// resolve alias to canonical model name
name = meta->name;
if (models_autoload) {
models.ensure_model_ready(name);
} else {
if (!meta->is_running()) {
res_err(res, format_error_response("model is not loaded", ERROR_TYPE_INVALID_REQUEST));
return false;
}
if (!models_autoload && !meta->is_running()) {
res_err(res, format_error_response("model is not loaded", ERROR_TYPE_INVALID_REQUEST));
return false;
}
return true;
}
@@ -1567,6 +1528,10 @@ static std::optional<server_model_meta> resolve_child_for_conv(
}
void server_models_routes::init_routes() {
if (!common_subproc::is_supported()) {
throw std::runtime_error("subprocess is not enabled on this build");
}
this->get_router_props = [this](const server_http_req & req) {
std::string name = req.get_param("model");
if (name.empty()) {
@@ -1602,6 +1567,9 @@ void server_models_routes::init_routes() {
if (!router_validate_model(name, models, autoload, error_res)) {
return error_res;
}
if (autoload) {
models.ensure_model_ready(name);
}
return models.proxy_request(req, method, name, false);
};
@@ -1615,12 +1583,23 @@ void server_models_routes::init_routes() {
return error_res;
}
// remember which child serves this conversation so the stream routes can route straight
// to it without polling, keyed on the exact conv id from the header
// to it without polling, keyed on the exact conv id from the header. registered before
// the load wait so a stop issued while the model loads can erase the entry and cancel
// this request instead of leaving an orphan generation
std::string conv_id = server_stream_conv_id_from_headers(req.headers);
if (!conv_id.empty()) {
models.conv_models.remember(conv_id, name);
uint64_t ticket = models.conv_models.remember(conv_id, name);
bool waited = autoload && models.ensure_model_ready(name);
if (ticket != 0 && !models.conv_models.alive(conv_id, ticket)) {
SRV_INF("request for conv_id=%s cancelled while model name=%s was loading\n",
conv_id.c_str(), name.c_str());
res_err(error_res, format_error_response(
"request cancelled by a stop while the model was loading", ERROR_TYPE_INVALID_REQUEST));
return error_res;
}
return models.proxy_request(req, method, name, true); // update last usage for POST request only
// a session request that waited for a load detaches from the client socket: the
// client may have dropped during the wait (page reload) and the session buffer must
// still receive the generation for a later resume
return models.proxy_request(req, method, name, true, waited && ticket != 0); // update last usage for POST request only
};
this->post_router_models_load = [this](const server_http_req & req) {
@@ -1813,7 +1792,7 @@ void server_models_routes::init_routes() {
};
this->router_stream_get = [this](const server_http_req & req) {
// GET /v1/stream/<conv_id>?from=N. resolve the owning child from the conv_id -> model
// GET /v1/stream?conv_id=<id>&from=N. resolve the owning child from the conv_id -> model
// map, 404 when nothing maps
auto res = std::make_unique<server_http_res>();
std::string conv_id = req.get_param("conv_id");
@@ -1823,13 +1802,24 @@ void server_models_routes::init_routes() {
}
std::optional<server_model_meta> owner = resolve_child_for_conv(models, conv_id);
if (!owner.has_value()) {
res_err(res, format_error_response("Stream not found or expired", ERROR_TYPE_NOT_FOUND));
// a registered conv whose model is still loading earns a retry: the session appears
// once the load ends and the pending request reaches the child
auto tracked = models.conv_models.lookup(conv_id);
auto meta = tracked.has_value() ? models.get_meta(*tracked) : std::nullopt;
bool transient = meta.has_value() && (meta->status == SERVER_MODEL_STATUS_LOADING ||
meta->status == SERVER_MODEL_STATUS_DOWNLOADING ||
meta->status == SERVER_MODEL_STATUS_DOWNLOADED);
if (transient) {
res_err(res, format_error_response("Stream owner model is loading, retry later", ERROR_TYPE_UNAVAILABLE));
} else {
res_err(res, format_error_response("Stream not found or expired", ERROR_TYPE_NOT_FOUND));
}
return res;
}
std::string from = req.get_param("from");
std::string child_path = "/v1/stream/" + encode_qs(conv_id);
std::string child_path = "/v1/stream?conv_id=" + encode_qs(conv_id);
if (!from.empty()) {
child_path += "?from=" + from;
child_path += "&from=" + from;
}
SRV_TRC("proxying stream resume to model %s on port %d, path=%s\n",
owner->name.c_str(), owner->port, child_path.c_str());
@@ -1909,7 +1899,7 @@ void server_models_routes::init_routes() {
};
this->router_stream_delete = [this](const server_http_req & req) {
// DELETE /v1/stream/<conv_id>. resolve the owning child via the map and forward only to
// DELETE /v1/stream?conv_id=<id>. resolve the owning child via the map and forward only to
// it, evict_and_cancel is idempotent on the child
auto res = std::make_unique<server_http_res>();
std::string conv_id = req.get_param("conv_id");
@@ -1917,7 +1907,7 @@ void server_models_routes::init_routes() {
res_err(res, format_error_response("Missing conversation id in path", ERROR_TYPE_INVALID_REQUEST));
return res;
}
std::string child_path = "/v1/stream/" + encode_qs(conv_id);
std::string child_path = "/v1/stream?conv_id=" + encode_qs(conv_id);
auto owner = resolve_child_for_conv(models, conv_id);
if (owner.has_value()) {
httplib::Client cli(CHILD_ADDR, owner->port);
@@ -1926,6 +1916,11 @@ void server_models_routes::init_routes() {
cli.set_write_timeout(0, STREAM_LOOKUP_TIMEOUT_MS * 1000);
auto resp = cli.Delete(child_path.c_str());
(void) resp; // the child logs its own miss when the session is unknown there
} else if (auto tracked = models.conv_models.lookup(conv_id); tracked.has_value()) {
// the entry exists but its model is still loading: the forget below erases it,
// which cancels the request parked in proxy_post before the generation starts
SRV_INF("router stop for conv_id=%s while model name=%s is loading, cancelling the pending request\n",
conv_id.c_str(), tracked->c_str());
} else {
SRV_WRN("router stop for unknown conv_id=%s, no owning child in the conv map\n",
conv_id.c_str());
+24 -7
View File
@@ -134,12 +134,24 @@ private:
// proxy_request forwards a POST carrying an X-Conversation-Id. best effort: a stale entry just
// makes the child answer not found and the client recovers. owns its lock, one mutex per struct
struct conv_model_tracker {
void remember(const std::string & conv_id, const std::string & model) {
// returns the ticket of this registration, 0 when nothing was registered. erasing or
// replacing the entry invalidates the ticket, which is how a stop cancels a request
// parked in the model load wait
uint64_t remember(const std::string & conv_id, const std::string & model) {
if (conv_id.empty() || model.empty()) {
return;
return 0;
}
std::lock_guard<std::mutex> lock(mu);
map[conv_id] = model;
uint64_t ticket = next_ticket++;
map[conv_id] = { model, ticket };
return ticket;
}
// false means a stop erased the entry or a newer request replaced it
bool alive(const std::string & conv_id, uint64_t ticket) {
std::lock_guard<std::mutex> lock(mu);
auto it = map.find(conv_id);
return it != map.end() && it->second.ticket == ticket;
}
std::optional<std::string> lookup(const std::string & conv_id) {
@@ -151,7 +163,7 @@ private:
if (it == map.end()) {
return std::nullopt;
}
return it->second;
return it->second.model;
}
void forget(const std::string & conv_id) {
@@ -163,8 +175,13 @@ private:
}
private:
std::mutex mu;
std::unordered_map<std::string, std::string> map;
struct entry_t {
std::string model;
uint64_t ticket;
};
std::mutex mu;
uint64_t next_ticket = 1;
std::unordered_map<std::string, entry_t> map;
};
common_preset_context ctx_preset;
@@ -249,7 +266,7 @@ public:
bool ensure_model_ready(const std::string & name);
// proxy an HTTP request to the model instance
server_http_res_ptr proxy_request(const server_http_req & req, const std::string & method, const std::string & name, bool update_last_used);
server_http_res_ptr proxy_request(const server_http_req & req, const std::string & method, const std::string & name, bool update_last_used, bool detached = false);
// handle message sent from server_child::notify_to_router()
// raw input must starts with CMD_CHILD_TO_ROUTER_STATE, followed by a JSON string
+4 -4
View File
@@ -453,7 +453,7 @@ static server_http_res_ptr make_error_response(int status, const std::string & m
server_http_context::handler_t server_stream_make_get_handler() {
return [](const server_http_req & req) -> server_http_res_ptr {
// GET /v1/stream/<conv_id>?from=N replays buffered SSE bytes then blocks for live
// GET /v1/stream?conv_id=<id>&from=N replays buffered SSE bytes then blocks for live
// bytes until the session finalizes, streamed as text/event-stream for EventSource
std::string conv_id = req.get_param("conv_id");
if (conv_id.empty()) {
@@ -560,13 +560,13 @@ server_http_context::handler_t server_stream_make_lookup_handler() {
server_http_context::handler_t server_stream_make_delete_handler() {
return [](const server_http_req & req) -> server_http_res_ptr {
// DELETE /v1/stream/<conv_id> is the explicit user Stop, cancels the producer and evicts
// DELETE /v1/stream?conv_id=<id> is the explicit user Stop, cancels the producer and evicts
// the buffer. idempotent, returns 204 even if the session was already gone
std::string conv_id = req.get_param("conv_id");
if (conv_id.empty()) {
return make_error_response(400, "Missing conversation id in path", ERROR_TYPE_INVALID_REQUEST);
}
SRV_TRC("DELETE /v1/stream/%s -> evict_and_cancel\n", conv_id.c_str());
SRV_TRC("DELETE /v1/stream conv_id=%s -> evict_and_cancel\n", conv_id.c_str());
g_stream_sessions.evict_and_cancel(conv_id);
auto res = std::make_unique<server_http_res>();
res->status = 204;
@@ -621,7 +621,7 @@ bool server_res_spipe::conn_alive() {
bool server_res_spipe::should_stop() {
if (spipe) {
// note: if DELETE /v1/stream/<conv_id> is called, is_cancelled() will be true
// note: if DELETE /v1/stream is called for this conv, is_cancelled() will be true
return spipe->is_cancelled();
} else {
return !conn_alive();
+6
View File
@@ -45,7 +45,13 @@ void server_stream_session_manager_start();
void server_stream_session_manager_stop();
// route handler factories wired under /v1/stream/* by server.cpp
// child-side handlers for the resumable stream routes. the conv id travels in the conv_id
// query string because it can embed a model name containing slashes (org/repo), which the
// decoded path would split before the param is captured
server_http_context::handler_t server_stream_make_get_handler();
// POST /v1/streams/lookup with body {"conversation_ids": [...]}: only answers for ids the
// caller already owns (the WebUI passes the convs visible in its sidebar), the server never
// lists ids it has not been asked about, so a random caller cannot enumerate live sessions
server_http_context::handler_t server_stream_make_lookup_handler();
server_http_context::handler_t server_stream_make_delete_handler();
+11 -19
View File
@@ -1,6 +1,6 @@
#include "server-tools.h"
#include <sheredom/subprocess.h>
#include "subproc.h"
#include <filesystem>
#include <fstream>
@@ -138,15 +138,14 @@ public:
const std::function<bool(const std::string &)> & on_chunk = nullptr) const override {
exec_result res;
subprocess_s proc;
auto argv = to_cstr_vec(args);
common_subproc proc;
int options = subprocess_option_no_window
| subprocess_option_combined_stdout_stderr
| subprocess_option_inherit_environment
| subprocess_option_search_user_path;
if (subprocess_create(argv.data(), options, &proc) != 0) {
if (!proc.create(args, options)) {
res.output = "failed to spawn process";
return res;
}
@@ -159,14 +158,14 @@ public:
while (!done.load()) {
if (std::chrono::steady_clock::now() >= deadline) {
timed_out.store(true);
subprocess_terminate(&proc);
proc.terminate();
return;
}
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
});
FILE * f = subprocess_stdout(&proc);
FILE * f = proc.stdout_file();
std::string output;
bool truncated = false;
if (f) {
@@ -177,7 +176,7 @@ public:
if (output.size() + len <= max_output) {
output.append(buf, len);
if (on_chunk && !on_chunk(std::string(buf, len))) {
subprocess_terminate(&proc);
proc.terminate();
break;
}
} else {
@@ -195,8 +194,7 @@ public:
timeout_thread.join();
}
subprocess_join(&proc, &res.exit_code);
subprocess_destroy(&proc);
res.exit_code = proc.join();
res.output = output;
res.timed_out = timed_out.load();
@@ -207,16 +205,6 @@ public:
}
private:
static std::vector<char *> to_cstr_vec(const std::vector<std::string> & v) {
std::vector<char *> r;
r.reserve(v.size() + 1);
for (const auto & s : v) {
r.push_back(const_cast<char *>(s.c_str()));
}
r.push_back(nullptr);
return r;
}
static const std::unordered_set<std::string> & junk_dir_names() {
static const std::unordered_set<std::string> names = {
".git", ".svn", ".hg", "node_modules", "__pycache__",
@@ -1203,6 +1191,10 @@ static std::vector<std::unique_ptr<server_tool>> build_tools() {
void server_tools::setup(const std::vector<std::string> & enabled_tools,
server_mcp & mcp_mgr) {
if (!enabled_tools.empty()) {
if (!common_subproc::is_supported()) {
throw std::runtime_error("subprocess is not enabled on this build");
}
std::unordered_set<std::string> enabled_set(enabled_tools.begin(), enabled_tools.end());
auto all_tools = build_tools();
+4 -9
View File
@@ -272,10 +272,8 @@ int llama_server(common_params & params, int argc, char ** argv) {
ctx_http.get ("/slots", ex_wrapper(routes.get_slots));
ctx_http.post("/slots/:id_slot", ex_wrapper(routes.post_slots));
// resumable streaming, the conversation_id is the session identity end to end. router and
// child wire different handlers under the same paths: a child binds the local session
// factories, the router binds proxies that resolve the owning child through the
// conv_id -> model map
// resumable streaming: a child binds the local session factories, the router binds
// proxies that resolve the owning child, see server-stream.h
server_http_context::handler_t stream_get_h;
server_http_context::handler_t streams_lookup_h;
server_http_context::handler_t stream_delete_h;
@@ -288,12 +286,9 @@ int llama_server(common_params & params, int argc, char ** argv) {
streams_lookup_h = server_stream_make_lookup_handler();
stream_delete_h = server_stream_make_delete_handler();
}
ctx_http.get ("/v1/stream/:conv_id", ex_wrapper(stream_get_h));
// POST /v1/streams/lookup with body {"conversation_ids": [...]}. you can only ask for ids
// you already own (the WebUI passes the convs visible in its sidebar). the server never
// lists ids it has not been asked about, so a random caller cannot enumerate live sessions
ctx_http.get ("/v1/stream", ex_wrapper(stream_get_h));
ctx_http.post("/v1/streams/lookup", ex_wrapper(streams_lookup_h));
ctx_http.del ("/v1/stream/:conv_id", ex_wrapper(stream_delete_h));
ctx_http.del ("/v1/stream", ex_wrapper(stream_delete_h));
// Google Cloud Platform (Vertex AI) compat
ctx_http.register_gcp_compat();
+153
View File
@@ -0,0 +1,153 @@
import json
import socket
import threading
import time
from urllib.parse import quote
import pytest
from utils import *
server: ServerProcess
# a model name with slashes exercises the query string routing of the stream routes: the id
# cannot travel as a path param because the decoded slash would split it before capture
MODEL = "ggml-org/tinygemma3-GGUF:Q8_0"
STREAM_ID = f"conv-stream-test::{MODEL}"
QS = "conv_id=" + quote(STREAM_ID, safe="")
@pytest.fixture(autouse=True)
def create_server():
global server
server = ServerPreset.router()
def test_stream_resume_and_stop_with_slashed_model_name():
global server
server.start()
content = ""
for data in server.make_stream_request("POST", "/chat/completions", data={
"model": MODEL,
"stream": True,
"max_tokens": 16,
"messages": [{"role": "user", "content": "hello"}],
}, headers={"X-Conversation-Id": STREAM_ID}):
if data["choices"]:
content += data["choices"][0]["delta"].get("content") or ""
assert len(content) > 0
# the finished session replays from the beginning through the router
res = server.make_request("GET", f"/v1/stream?{QS}&from=0")
assert res.status_code == 200
assert "data: " in str(res.body)
# the explicit stop reaches the owning child and evicts the session
res = server.make_request("DELETE", f"/v1/stream?{QS}")
assert res.status_code == 204
res = server.make_request("GET", f"/v1/stream?{QS}&from=0")
assert res.status_code == 404
def test_stream_stop_during_model_load():
global server
server.start()
thread_error: list[ServerError] = []
thread_done = threading.Event()
def fire_post():
try:
for _ in server.make_stream_request("POST", "/chat/completions", data={
"model": MODEL,
"stream": True,
"max_tokens": 512,
"messages": [{"role": "user", "content": "Count from 1 to 1000."}],
}, headers={"X-Conversation-Id": STREAM_ID}):
pass
except ServerError as e:
thread_error.append(e)
finally:
thread_done.set()
t = threading.Thread(target=fire_post)
t.start()
# catch the autoload window, tiny models load fast so poll aggressively
saw_loading = False
deadline = time.time() + 5.0
while time.time() < deadline and not thread_done.is_set():
res = server.make_request("GET", "/models")
status = next(m["status"]["value"] for m in res.body["data"] if m["id"] == MODEL)
if status == "loading":
saw_loading = True
break
time.sleep(0.002)
if not saw_loading:
t.join()
pytest.skip("load window too short to be observed on this machine") # ty: ignore[too-many-positional-arguments]
# a stop during the load cancels the parked request instead of leaving an orphan
res = server.make_request("DELETE", f"/v1/stream?{QS}")
assert res.status_code == 204
assert thread_done.wait(timeout=60)
t.join()
assert len(thread_error) == 1
assert thread_error[0].code == 400
assert "cancelled" in json.dumps(thread_error[0].body)
res = server.make_request("GET", f"/v1/stream?{QS}&from=0")
assert res.status_code == 404
def test_stream_resumes_after_reload_during_model_load():
global server
server.start()
# raw socket client so the connection can be dropped mid load like a page reload
body = json.dumps({
"model": MODEL,
"stream": True,
"max_tokens": 16,
"messages": [{"role": "user", "content": "hello"}],
})
request = (
f"POST /v1/chat/completions HTTP/1.1\r\n"
f"Host: {server.server_host}:{server.server_port}\r\n"
f"Content-Type: application/json\r\n"
f"X-Conversation-Id: {STREAM_ID}\r\n"
f"Content-Length: {len(body)}\r\n"
f"Connection: close\r\n\r\n{body}"
)
sock = socket.create_connection((server.server_host, server.server_port))
sock.sendall(request.encode())
# drop the client while the model loads, poll aggressively to catch the window
saw_loading = False
saw_503 = False
deadline = time.time() + 5.0
while time.time() < deadline:
res = server.make_request("GET", "/models")
status = next(m["status"]["value"] for m in res.body["data"] if m["id"] == MODEL)
if status == "loading":
saw_loading = True
break
if status == "loaded":
break
time.sleep(0.002)
sock.close()
if not saw_loading:
pytest.skip("load window too short to be observed on this machine") # ty: ignore[too-many-positional-arguments]
# while the model loads the resume route answers retry later, then the session appears,
# receives the whole generation despite the dead client, and replays from the beginning
deadline = time.time() + 60.0
replay = None
while time.time() < deadline:
res = server.make_request("GET", f"/v1/stream?{QS}&from=0")
if res.status_code == 503:
saw_503 = True
elif res.status_code == 200 and "data: " in str(res.body):
replay = res
break
time.sleep(0.1)
assert saw_503, "resume during the load did not answer 503"
assert replay is not None, "session never became resumable after the client disconnect"