Files
gufo/tests/cli/http_server_test.cpp
T

2978 lines
133 KiB
C++

#include "src/cli/serve/http_server.hpp"
#include <atomic>
#include <cassert>
#include <chrono>
#include <cstdint>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <memory>
#include <mutex>
#include <optional>
#include <semaphore>
#include <sstream>
#include <stdexcept>
#include <string>
#include <thread>
#include <utility>
#include <vector>
#include "src/cli/serve/embedding_service.hpp"
#include "src/cli/serve/logging.hpp"
#include "src/cli/serve/trace.hpp"
#include "src/core/platform/env.hpp"
#include "src/core/platform/net.hpp"
#include "src/core/platform/os_info.hpp"
#include "tests/cli/http_server_test_support.hpp"
namespace {
using gufo::server::Logger;
using gufo::server::LogLevel;
using gufo::server::LogLevelFromName;
using gufo::server::LogLevelName;
void TestAuthorization() {
RunningServer secured({.api_key = "test-secret"});
for (const std::string path :
{"/health", "/ready", "/v1/models", "/v1/chat/completions",
"/v1/responses", "/v1/messages", "/v1/audio/speech",
"/v1/audio/transcriptions", "/v1/video/generations", "/echo"}) {
ExpectStatus(secured.Send("POST " + path + " HTTP/1.1\r\n\r\n"), 401);
}
for (const std::string header :
{"", "Authorization: Bearer wrong\r\n",
"Authorization: Basic test-secret\r\n",
"Authorization: Bearer test-secret\r\nAuthorization: Bearer "
"test-secret\r\n"}) {
const auto response =
secured.Send("GET /v1/models HTTP/1.1\r\n" + header + "\r\n");
ExpectStatus(response, 401);
assert(response.find("WWW-Authenticate: Bearer") != std::string::npos);
assert(response.find("test-secret") == std::string::npos);
}
assert(secured.backend->calls == 0);
ExpectStatus(secured.Send("GET /v1/models HTTP/1.1\r\naUtHoRiZaTiOn: bEaReR "
"test-secret\r\n\r\n"),
200);
ExpectStatus(secured.Send("OPTIONS /v1/chat/completions HTTP/1.1\r\n\r\n"),
204);
const std::string body = R"({"prompt":"hi","max_tokens":1})";
ExpectStatus(secured.Send("POST /v1/completions HTTP/1.1\r\nAuthorization: "
"Bearer test-secret\r\n"
"Content-Length: " +
std::to_string(body.size()) + "\r\n\r\n" + body),
200);
assert(secured.backend->calls == 1);
}
struct CapturedLogs {
std::string text;
std::string request_id;
};
// Drives one pass of traffic at a fixed verbosity. The process-wide filter is
// shared by every test in this binary, so the previous level is restored.
CapturedLogs CaptureTraffic(LogLevel level) {
const LogLevel previous = Logger::Level();
Logger::SetLevel(level);
std::ostringstream output;
auto* previous_sink = std::clog.rdbuf(output.rdbuf());
CapturedLogs captured;
{
RunningServer server;
ExpectStatus(server.Send("GET /health HTTP/1.1\r\n\r\n"), 200);
const auto response = server.Post(
"/v1/chat/completions?private-query",
R"({"model":"test","messages":[{"role":"user","content":"private-prompt"}],"stream":true})");
ExpectStatus(response, 200);
const auto header = response.find("X-Request-ID: ");
assert(header != std::string::npos);
const auto begin = header + std::string("X-Request-ID: ").size();
captured.request_id =
response.substr(begin, response.find("\r\n", begin) - begin);
const auto failed = server.Post("/stream-error", "");
ExpectStatus(failed, 200);
assert(failed.find("first chunk") != std::string::npos);
assert(failed.find("HTTP/1.1", 1) == std::string::npos);
ExpectStatus(server.Send("GET /not-a-route HTTP/1.1\r\n\r\n"), 404);
}
Logger::Info("test", "escaped\n\x1b[31m");
std::clog.rdbuf(previous_sink);
Logger::SetLevel(previous);
captured.text = output.str();
return captured;
}
void TestRequestLogging() {
const CapturedLogs info = CaptureTraffic(LogLevel::kInfo);
const std::string& log = info.text;
assert(log.find("request=" + info.request_id + " event=received") !=
std::string::npos);
assert(log.find("request=" + info.request_id + " event=completed") !=
std::string::npos);
assert(log.find("cache=memory") != std::string::npos);
assert(log.find("acceptance_pct=50.0") != std::string::npos);
assert(log.find("rss_mib=") != std::string::npos);
assert(log.find("error_code=server_exception") != std::string::npos);
// A successful poll is quiet at the default level, and so is the received
// line for a GET that is not on the inference list.
assert(log.find("path=/health") == std::string::npos);
// Statuses never are: 4xx keeps escalating to WARN under any filter.
assert(log.find("path=/not-a-route status=404") != std::string::npos);
assert(log.find("[WARN]") != std::string::npos);
assert(log.find("private-query") == std::string::npos);
assert(log.find("private-prompt") == std::string::npos);
assert(log.find("escaped\\x0a\\x1b[31m") != std::string::npos);
// The startup confirmation is INFO-tier, so the default level reports it.
// TestQuietTiersSuppressLifecycle covers the tiers that do not.
assert(log.find("event=listening") != std::string::npos);
const CapturedLogs debug = CaptureTraffic(LogLevel::kDebug);
assert(debug.text.find("[DEBUG]") != std::string::npos);
assert(debug.text.find("event=received method=GET path=/health") !=
std::string::npos);
assert(debug.text.find("path=/health status=200") != std::string::npos);
// Debug adds lines; it must not downgrade an inference completion to DEBUG,
// nor pull a refused 4xx back under the threshold.
assert(debug.text.find("[INFO] [http] request=" + debug.request_id +
" event=completed") != std::string::npos);
assert(debug.text.find("path=/not-a-route status=404") != std::string::npos);
// The higher tier must still hide every byte of the request.
assert(debug.text.find("private-query") == std::string::npos);
assert(debug.text.find("private-prompt") == std::string::npos);
// Options are echoed by `gufo serve`, not by the HTTP layer itself.
assert(debug.text.find("event=options") == std::string::npos);
}
std::string ResponseRequestId(const std::string& response) {
const auto header = response.find("X-Request-ID: ");
assert(header != std::string::npos);
const auto begin = header + std::string("X-Request-ID: ").size();
return response.substr(begin, response.find("\r\n", begin) - begin);
}
// The body of a chunked HTTP/1.1 response, as the client reassembles it.
std::string ChunkedBody(const std::string& response) {
std::size_t cursor = response.find("\r\n\r\n");
assert(cursor != std::string::npos);
cursor += 4;
std::string body;
for (;;) {
const auto line_end = response.find("\r\n", cursor);
assert(line_end != std::string::npos);
const auto size =
std::stoul(response.substr(cursor, line_end - cursor), nullptr, 16);
if (size == 0) {
return body;
}
body += response.substr(line_end + 2, size);
cursor = line_end + 2 + size + 2;
}
}
// `--trace` keeps what a text request carried in and out under the id the
// logs name. Polls, other routes and unauthenticated requests stay out.
void TestContentTrace() {
namespace fs = std::filesystem;
using gufo::server::Trace;
const auto path = fs::temp_directory_path() /
("gufo-trace-test-" +
std::to_string(gufo::platform::ProcessId()) + ".jsonl");
fs::remove(path);
assert(!Trace::Open(path.string()).has_value());
std::string plain_id;
std::string stream_id;
std::string stream_wire;
std::string invalid_id;
{
RunningServer server(
{.api_key = "test-secret",
.sse_heartbeat_interval = std::chrono::milliseconds(5)});
server.backend->SetOutput("<tool_call>leak");
const auto post = [&](std::string_view route, std::string_view body,
bool authorized = true) {
return server.Send(
"POST " + std::string(route) + " HTTP/1.1\r\n" +
(authorized ? "Authorization: Bearer test-secret\r\n" : "") +
"Content-Length: " + std::to_string(body.size()) + "\r\n\r\n" +
std::string(body));
};
const auto plain = post("/v1/completions",
R"({"prompt":"private-prompt","max_tokens":8})");
ExpectStatus(plain, 200);
plain_id = ResponseRequestId(plain);
// The id is bound where the backend generates, so the scheduler's record
// can name it.
assert(server.backend->LastCall().trace_request == plain_id);
// Admission commits the stream before a slow first token, so keepalives
// reach the client and must reach the trace too.
server.backend->admit = true;
server.backend->token_delay = std::chrono::milliseconds(40);
const auto streamed =
post("/v1/completions",
R"({"prompt":"streamed-prompt","max_tokens":8,"stream":true})");
ExpectStatus(streamed, 200);
stream_id = ResponseRequestId(streamed);
stream_wire = ChunkedBody(streamed);
assert(stream_wire.find(": ping\n\n") != std::string::npos);
assert(server.backend->LastCall().trace_request == stream_id);
const auto invalid = post("/v1/completions", "{");
ExpectStatus(invalid, 400);
invalid_id = ResponseRequestId(invalid);
ExpectStatus(
post("/v1/completions", R"({"prompt":"unauthorized-prompt"})", false),
401);
ExpectStatus(post("/echo", "echo-body"), 200);
ExpectStatus(server.Send("GET /health HTTP/1.1\r\nAuthorization: Bearer "
"test-secret\r\n\r\n"),
200);
}
Trace::Close();
assert((fs::status(path).permissions() & fs::perms::all) ==
(fs::perms::owner_read | fs::perms::owner_write));
std::vector<gufo::json::Value> records;
{
std::ifstream input(path);
for (std::string line; std::getline(input, line);) {
assert(line.find("unauthorized-prompt") == std::string::npos);
assert(line.find("echo-body") == std::string::npos);
records.push_back(gufo::json::parse(line));
}
}
fs::remove(path);
assert(records.size() == 6);
const auto record = [&](std::string_view event,
const std::string& id) -> const gufo::json::Value& {
for (const auto& candidate : records) {
if (candidate.member_str("event") == event &&
candidate.member_str("request") == id) {
assert(!candidate.member_str("time").empty());
return candidate;
}
}
std::abort();
};
const auto& request = record("request", plain_id);
assert(request.member_str("method") == "POST");
assert(request.member_str("path") == "/v1/completions");
assert(request.member_str("body") ==
R"({"prompt":"private-prompt","max_tokens":8})");
const auto& reply = record("response", plain_id);
assert(reply.member_size("status") == 200);
assert(reply.member_str("outcome") == "completed");
assert(!reply.find("stream")->as_bool());
const auto body = gufo::json::parse(reply.member_str("body"));
assert(body.find("choices")->items()[0].member_str("text") ==
"<tool_call>leak");
assert(
record("request", stream_id).member_str("body").find("streamed-prompt") !=
std::string::npos);
const auto& streamed = record("response", stream_id);
assert(streamed.find("stream")->as_bool());
assert(streamed.member_str("body") == stream_wire);
assert(stream_wire.find("<tool_call>leak") != std::string::npos);
assert(record("request", invalid_id).member_str("body") == "{");
assert(record("response", invalid_id).member_size("status") == 400);
}
// The threshold covers the lifecycle lines too: `--log-level=warn|error` boots
// and stops without a word, which docs/SERVER.md states, while an escalation
// keeps its own tier and a filtered INFO receipt line never reaches the log.
void TestQuietTiersSuppressLifecycle() {
const CapturedLogs warn = CaptureTraffic(LogLevel::kWarn);
assert(warn.text.find("event=listening") == std::string::npos);
assert(warn.text.find("event=received") == std::string::npos);
assert(warn.text.find("path=/not-a-route status=404") != std::string::npos);
const CapturedLogs error = CaptureTraffic(LogLevel::kError);
assert(error.text.find("event=listening") == std::string::npos);
assert(error.text.find("event=received") == std::string::npos);
assert(error.text.find("path=/not-a-route status=404") == std::string::npos);
}
void TestLogLevelFilter() {
const LogLevel previous = Logger::Level();
assert(previous == LogLevel::kInfo);
std::ostringstream output;
auto* previous_sink = std::clog.rdbuf(output.rdbuf());
Logger::SetLevel(LogLevel::kError);
Logger::Debug("probe", "hidden-debug");
Logger::Info("probe", "hidden-info");
Logger::Warn("probe", "hidden-warn");
Logger::Error("probe", "visible-error");
// The printf-style entry point the imported DeepSeek runtime uses has to
// filter before it formats: an INFO loader line stays silent at an absolute
// threshold while an ERROR line survives.
Logger::LogFormatted(LogLevel::kInfo, "ds4", "hidden-ds4-info %d", 1);
Logger::LogFormatted(LogLevel::kError, "ds4", "visible-ds4-error %d", 2);
Logger::LogRequest("r-quiet", "GET", "/health", 200, 1.0, "", "completed",
LogLevel::kDebug);
Logger::LogRequest("r-404", "GET", "/missing", 404, 1.0);
Logger::LogRequest("r-500", "POST", "/boom", 500, 1.0);
Logger::SetLevel(LogLevel::kWarn);
Logger::Debug("probe", "still-hidden-debug");
Logger::Warn("probe", "visible-at-warn");
Logger::LogFormatted(LogLevel::kInfo, "ds4", "still-hidden-ds4-info");
Logger::LogFormatted(LogLevel::kWarn, "ds4", "visible-ds4-warn");
Logger::LogRequest("r-404b", "GET", "/missing", 404, 1.0);
std::clog.rdbuf(previous_sink);
Logger::SetLevel(previous);
const std::string log = output.str();
assert(log.find("hidden-debug") == std::string::npos);
assert(log.find("hidden-info") == std::string::npos);
assert(log.find("hidden-warn") == std::string::npos);
assert(log.find("still-hidden-debug") == std::string::npos);
assert(log.find("hidden-ds4-info") == std::string::npos);
assert(log.find("still-hidden-ds4-info") == std::string::npos);
assert(log.find("visible-error") != std::string::npos);
assert(log.find("visible-at-warn") != std::string::npos);
assert(log.find("visible-ds4-error 2") != std::string::npos);
assert(log.find("visible-ds4-warn") != std::string::npos);
assert(log.find("[DEBUG]") == std::string::npos);
// A poll that is quiet by default stays filtered, while an escalation keeps
// its own tier: `--log-level error` means errors only, not "hide failures".
assert(log.find("request=r-quiet event=completed") == std::string::npos);
assert(log.find("request=r-404 event=completed") == std::string::npos);
assert(log.find("request=r-500 event=completed") != std::string::npos);
assert(log.find("request=r-404b event=completed") != std::string::npos);
assert(Logger::Level() == LogLevel::kInfo);
}
void TestLogLevelNames() {
assert(LogLevelFromName("debug") == LogLevel::kDebug);
assert(LogLevelFromName("info") == LogLevel::kInfo);
assert(LogLevelFromName("warn") == LogLevel::kWarn);
assert(LogLevelFromName("error") == LogLevel::kError);
assert(!LogLevelFromName("trace").has_value());
assert(!LogLevelFromName("DEBUG").has_value());
assert(!LogLevelFromName("").has_value());
assert(LogLevelName(LogLevel::kDebug) == "debug");
assert(LogLevelName(LogLevel::kError) == "error");
}
void TestFramingAndMetrics() {
RunningServer server({.max_request_body_bytes = 8192});
ExpectStatus(server.Send("GET /health HTTP/1.1\r\n\r\n"), 200);
for (const std::string header :
{"Content-Length: nope", "Content-Length: -1", "Content-Length: 4junk",
"Content-Length: 18446744073709551616",
"Content-Length: 4\r\nContent-Length: 3", "Transfer-Encoding: chunked",
"broken-header", "Transfer-Encoding : chunked", "Content-Length\t: 5",
"X-Test: one\r\n folded", "Bad Name: x"}) {
ExpectStatus(server.Send("POST /echo HTTP/1.1\r\n" + header + "\r\n\r\n"),
400);
}
ExpectStatus(server.Send("POST /echo\r\n\r\n"), 400);
ExpectStatus(server.Send("POST /echo HTTP/1.1 extra\r\n\r\n"), 400);
ExpectStatus(
server.Send("POST /echo HTTP/1.1\r\nContent-Length: 4\r\n\r\nx", true),
400);
ExpectStatus(
server.Send("POST /echo HTTP/1.1\r\nContent-Length: 8193\r\n\r\n"), 413);
const std::string payload(5000, 'x');
const auto echo = server.Send(
"POST /echo HTTP/1.1\r\nContent-Length: 5000\r\nContent-Length: "
"5000\r\n\r\n" +
payload);
ExpectStatus(echo, 200);
assert(echo.substr(echo.find("\r\n\r\n") + 4) == payload);
const std::string body = R"({"prompt":"hi","n_predict":1})";
const auto response =
server.Send("POST /completion HTTP/1.1\r\nContent-Length: " +
std::to_string(body.size()) + "\r\n\r\n" + body);
ExpectStatus(response, 200);
const auto parsed =
gufo::json::parse(response.substr(response.find("\r\n\r\n") + 4));
const auto* timings = parsed.find("timings");
assert(timings != nullptr);
assert(timings->member_double("prompt_n") == 2);
assert(timings->member_double("cache_n") == 8);
assert(timings->member_double("prompt_per_second") == 500);
assert(timings->member_double("prompt_per_token_ms") == 2);
}
// curl sends "Expect: 100-continue" before a body over 1 MiB and waits for
// the interim response; without it, every large upload stalls for a second.
void TestExpectContinue() {
RunningServer server({.max_request_body_bytes = 8192});
for (const std::string expect :
{"Expect: 100-continue", "expect: 100-Continue"}) {
const net::Socket fd = server.Connect();
const std::string head =
"POST /echo HTTP/1.1\r\n" + expect + "\r\nContent-Length: 5\r\n\r\n";
assert(net::SendNoSignal(fd, head.data(), head.size()) ==
static_cast<std::intptr_t>(head.size()));
// The interim response arrives before any body byte is sent.
const std::string_view interim = "HTTP/1.1 100 Continue\r\n\r\n";
std::string response;
char buffer[4096];
while (response.size() < interim.size()) {
const auto count =
net::Receive(fd, buffer, interim.size() - response.size());
assert(count > 0);
response.append(buffer, static_cast<std::size_t>(count));
}
assert(response == interim);
assert(net::SendNoSignal(fd, "hello", 5) == 5);
response.clear();
for (;;) {
const auto count = net::Receive(fd, buffer, sizeof(buffer));
assert(count >= 0);
if (count == 0)
break;
response.append(buffer, static_cast<std::size_t>(count));
}
net::CloseSocket(fd);
ExpectStatus(response, 200);
assert(response.substr(response.find("\r\n\r\n") + 4) == "hello");
}
// An oversized body gets its final status instead of the interim one.
ExpectStatus(server.Send("POST /echo HTTP/1.1\r\nExpect: 100-continue\r\n"
"Content-Length: 8193\r\n\r\n"),
413);
}
void TestFallbackBackendMetrics() {
namespace metrics = gufo::server::detail;
RunningServer server;
struct Endpoint {
const char* path;
const char* body;
};
for (const auto& endpoint : {
Endpoint{"/v1/chat/completions",
R"({"messages":[{"role":"user","content":"hi"}]})"},
Endpoint{"/v1/completions", R"({"prompt":"hi"})"},
Endpoint{"/v1/responses", R"({"input":"hi"})"},
}) {
for (const bool stream : {false, true}) {
const auto prompt_before = metrics::TotalPromptTokens().load();
const auto generated_before = metrics::TotalGenTokens().load();
const auto cached_before = metrics::TotalCachedPromptTokens().load();
const auto rounds_before = metrics::TotalDraftRounds().load();
const auto drafts_before = metrics::TotalDraftTokens().load();
const auto accepted_before = metrics::TotalDraftAcceptedTokens().load();
const auto prompt_seconds_before = metrics::TotalPromptSeconds().load();
const auto gen_seconds_before = metrics::TotalGenSeconds().load();
auto body = gufo::json::parse(endpoint.body);
body["model"] = "test";
body["stream"] = stream;
ExpectStatus(server.Post(endpoint.path, body.dump()), 200);
const auto response = server.Send("GET /metrics HTTP/1.1\r\n\r\n");
ExpectStatus(response, 200);
// The backend reports 10 prompt tokens, of which 8 were cached.
assert(response.find("llamacpp:prompt_tokens_total " +
std::to_string(prompt_before + 2) + "\n") !=
std::string::npos);
assert(response.find("llamacpp:prompt_tokens_cached_total " +
std::to_string(cached_before + 8) + "\n") !=
std::string::npos);
assert(response.find("llamacpp:spec_decode_num_drafts_total " +
std::to_string(rounds_before + 2) + "\n") !=
std::string::npos);
assert(response.find("llamacpp:spec_decode_num_draft_tokens_total " +
std::to_string(drafts_before + 8) + "\n") !=
std::string::npos);
assert(response.find("llamacpp:spec_decode_num_accepted_tokens_total " +
std::to_string(accepted_before + 4) + "\n") !=
std::string::npos);
assert(metrics::TotalPromptSeconds().load() - prompt_seconds_before >
0.0039 &&
metrics::TotalGenSeconds().load() - gen_seconds_before > 0.0019);
assert(metrics::MaxSequenceTokens().load() >= 11);
assert(response.find("llamacpp:tokens_predicted_total " +
std::to_string(generated_before + 1) + "\n") !=
std::string::npos);
}
}
struct Case {
std::size_t prefill, cached;
bool cancelled;
std::size_t expected_prompt;
};
for (const auto& test : {
Case{6, 4, false, 6},
Case{0, 4, false, 6},
Case{0, 10, false, 0},
Case{0, 20, false, 0},
Case{0, 0, true, 0},
Case{3, 0, true, 3},
}) {
const auto prompt_before = metrics::TotalPromptTokens().load();
const auto generated_before = metrics::TotalGenTokens().load();
TextGenerationBackend::Result result;
result.prompt_tokens = 10;
result.cached_prompt_tokens = test.cached;
result.prefill_tokens = test.prefill;
result.completion_tokens = 2;
result.cancelled = test.cancelled;
gufo::server::RecordServerMetrics(result);
assert(metrics::TotalPromptTokens().load() ==
prompt_before + test.expected_prompt);
assert(metrics::TotalGenTokens().load() == generated_before + 2);
}
}
// /props reports what the server runs, as llama-server's does, so a client
// can record which model, build and settings answered it.
void TestLlamaProps() {
RunningServer server;
const auto props = [&](const std::string& path) {
const auto response = server.Send("GET " + path + " HTTP/1.1\r\n\r\n");
ExpectStatus(response, 200);
return gufo::json::parse(response.substr(response.find("\r\n\r\n") + 4));
};
// Nothing is invented for what the backend does not report.
const auto bare = props("/props");
assert(bare.member_str("model") == "test");
assert(bare.member_str("model_alias") == "test");
assert(!bare.contains("model_path"));
assert(!bare.contains("build_info"));
assert(!bare.contains("total_slots"));
const auto* generation = bare.find("default_generation_settings");
assert(generation != nullptr && generation->member_size("n_ctx") == 65536);
const auto* params = generation->find("params");
assert(params->contains("temperature"));
assert(params->find("n_predict")->as_double() == -1.0);
assert(params->find("max_tokens")->as_double() == -1.0);
assert(!bare.find("modalities")->find("vision")->as_bool(true));
server.backend->served_path = "/models/model-00001-of-00002.gguf";
server.backend->served_build = "0.2.0 (abc1234)";
server.backend->sessions.resize(2);
server.backend->image_support = true;
server.backend->defaults.max_tokens = 512;
const auto full = props("/props");
assert(full.member_str("model_path") == "/models/model-00001-of-00002.gguf");
assert(full.member_str("build_info") == "0.2.0 (abc1234)");
assert(full.member_size("total_slots") == 2);
assert(full.find("default_generation_settings")
->find("params")
->member_size("n_predict") == 512);
assert(full.find("modalities")->find("vision")->as_bool());
// ?model= is accepted and not required, as outside llama-server router mode.
const auto named = props("/props?model=alias");
assert(named.member_str("model") == "alias");
assert(named.member_str("model_alias") == "test");
assert(named.member_str("build_info") == "0.2.0 (abc1234)");
}
void TestLlamaSlotsAndMetrics() {
RunningServer server;
const auto get = [&](const std::string& path) {
const auto response = server.Send("GET " + path + " HTTP/1.1\r\n\r\n");
ExpectStatus(response, 200);
return response.substr(response.find("\r\n\r\n") + 4);
};
const auto expect_slot = [](const gufo::json::Value& slot, std::size_t id,
bool processing) {
assert(slot.member_size("id", 99) == id);
assert(slot.member_size("n_ctx") == 65536);
assert(slot.find("speculative")->is_bool());
assert(slot.find("is_processing")->as_bool(!processing) == processing);
assert(slot.find("state")->as_double() == (processing ? 1 : 0));
assert(slot.find("task_id")->as_double() ==
slot.find("id_task")->as_double());
for (const char* key : {"n_prompt_tokens", "n_prompt_tokens_cache",
"n_prompt_tokens_processed"}) {
assert(slot.find(key)->is_number());
}
assert(slot.find("prompt")->is_string() &&
slot.member_str("prompt").empty());
assert(slot.member_str("model") == "test");
const auto* next = slot.find("next_token");
assert(next != nullptr && next->is_array() && next->size() == 1);
const auto& token = next->items().front();
assert(token.find("has_next_token")->as_bool(!processing) == processing);
assert(token.find("has_new_line")->is_bool());
assert(token.find("n_remain")->is_number() &&
token.find("n_decoded")->is_number());
};
// Backends without a session pool report one idle slot.
auto slots = gufo::json::parse(get("/slots"));
assert(slots.is_array() && slots.size() == 1);
const auto& fallback = slots.items().front();
expect_slot(fallback, 0, false);
assert(fallback.find("id_task")->as_double() == -1);
assert(fallback.member_size("n_prompt_tokens", 99) == 0);
assert(fallback.find("next_token")
->items()
.front()
.find("n_remain")
->as_double() == -1);
assert(get("/metrics").find("llamacpp:kv_cache_usage_ratio 0\n") !=
std::string::npos);
server.backend->sessions.resize(2);
server.backend->sessions[1] = {
.processing = true,
.speculative = true,
.request_id = 42,
.prompt_tokens = 10,
.cached_prompt_tokens = 8,
.processed_prompt_tokens = 2,
.generated_tokens = 3,
.remaining_tokens = 5,
};
slots = gufo::json::parse(get("/v1/slots"));
assert(slots.size() == 2);
expect_slot(slots.items()[0], 0, false);
const auto& busy = slots.items()[1];
expect_slot(busy, 1, true);
assert(busy.find("speculative")->as_bool() &&
busy.member_size("id_task") == 42 &&
busy.member_size("n_prompt_tokens") == 10 &&
busy.member_size("n_prompt_tokens_cache") == 8 &&
busy.member_size("n_prompt_tokens_processed") == 2);
const auto& token = busy.find("next_token")->items().front();
assert(token.member_size("n_remain") == 5 &&
token.member_size("n_decoded") == 3);
const auto metrics = get("/metrics");
assert(metrics.find("# HELP gufo_device_lost_total ") != std::string::npos);
assert(metrics.find("# TYPE gufo_device_lost_total counter\n") !=
std::string::npos);
assert(metrics.find("gufo_device_lost_total 0\n") != std::string::npos);
for (const std::string counter : {"gufo_spec_lookup_tokens_total",
"gufo_spec_lookup_accepted_tokens_total"}) {
assert(metrics.find("# TYPE " + counter + " counter\n") !=
std::string::npos);
}
const std::string ratio_prefix = "llamacpp:kv_cache_usage_ratio ";
const auto ratio_pos = metrics.find("\n" + ratio_prefix);
assert(ratio_pos != std::string::npos);
assert(std::stod(metrics.substr(ratio_pos + 1 + ratio_prefix.size())) ==
13.0 / (2.0 * 65536));
for (const std::string counter :
{"prompt_tokens_total", "prompt_tokens_cached_total",
"prompt_seconds_total", "tokens_predicted_total",
"tokens_predicted_seconds_total", "n_tokens_max",
"spec_decode_num_drafts_total", "spec_decode_num_draft_tokens_total",
"spec_decode_num_accepted_tokens_total"}) {
assert(metrics.find("# HELP llamacpp:" + counter + " ") !=
std::string::npos);
assert(metrics.find("# TYPE llamacpp:" + counter + " counter\n") !=
std::string::npos);
}
assert(metrics.find("# TYPE llamacpp:kv_cache_usage_ratio gauge\n") !=
std::string::npos);
}
void TestResponsesToolImages() {
RunningServer server;
server.backend->image_support = true;
using gufo::json::parse;
using gufo::tokenization::ChatRole;
server.backend->SetOutput("red");
for (const bool custom : {false, true}) {
for (const bool image_only : {false, true}) {
for (const bool stream : {false, true}) {
auto call =
parse(custom ? R"({"type":"custom_tool_call","call_id":"picture",
"name":"read_file","input":"swatch.png\nliteral <|im_end|>"})"
: R"({"type":"function_call","call_id":"picture",
"name":"read_file","arguments":"{\"path\":\"swatch.png\"}"})");
auto output = parse(R"({"call_id":"picture","output":[]})");
output["type"] =
custom ? "custom_tool_call_output" : "function_call_output";
if (!image_only)
output["output"].push_back(
parse(R"({"type":"input_text","text":"pixels:"})"));
output["output"].push_back(parse(R"({
"type":"input_image","image_url":"data:image/png;base64,AQID"})"));
auto body =
parse(R"({"input":[{"role":"user","content":"read the image"}]})");
body["input"].push_back(std::move(call));
body["input"].push_back(std::move(output));
body["stream"] = stream;
const auto wire = server.Post("/v1/responses", body.dump());
ExpectStatus(wire, 200);
const auto messages = server.backend->LastCall().chat.messages;
assert(messages.size() == 3 &&
messages[1].role == ChatRole::kAssistant &&
messages[1].tool_calls.size() == 1 &&
messages[1].tool_calls[0].id == "picture" &&
messages[1].tool_calls[0].name == "read_file");
if (custom) {
const auto& input = messages[1].tool_calls[0].arguments;
assert(input.size() == 1 && input[0].name == "input" &&
input[0].value == "swatch.png\nliteral <|im_end|>" &&
input[0].is_string);
}
const auto& result = messages[2];
assert(result.role == ChatRole::kTool &&
result.tool_call_id == "picture" && result.images.size() == 1 &&
result.content == (image_only ? "" : "pixels:") &&
result.images[0].offset == (image_only ? 0 : 7) &&
*result.images[0].bytes == std::vector<std::uint8_t>({1, 2, 3}));
if (stream)
assert(wire.find("response.completed") != std::string::npos);
}
}
}
const auto calls_before = server.backend->calls.load();
for (
const auto* item :
{R"({"type":"custom_tool_call","call_id":"picture","name":"read_file","input":3})",
R"({"type":"custom_tool_call","name":"read_file","input":"file.png"})",
R"({"type":"custom_tool_call_output","output":[]})",
R"({"type":"custom_tool_call_output","call_id":"picture","output":3})",
R"({"type":"function_call_output","call_id":"picture","output":[{"type":"input_file","file_id":"x"}]})",
R"({"type":"custom_tool_call_output","call_id":"picture","output":[{"type":"input_image","file_id":"x"}]})"}) {
auto body = parse(R"({"input":[]})");
body["input"].push_back(parse(item));
ExpectStatus(server.Post("/v1/responses", body.dump()), 400);
}
assert(server.backend->calls == calls_before);
}
// A text-only model refuses image blocks before any image is read, so a
// client cannot make the server fetch a URL for a request that cannot succeed.
void TestTextOnlyBackendRefusesImagesBeforeReading() {
RunningServer server;
server.backend->image_support = false;
const int calls = server.backend->calls;
// An unresolvable host or invalid base64 would produce a different error
// if the image were fetched or decoded.
for (const auto* url : {"https://image-fetch-must-not-happen.invalid/x.png",
"data:image/png;base64,!!!not-base64!!!"}) {
const std::string messages =
std::string(R"({"messages":[{"role":"user","content":[{"type":"image",)"
R"("source":{"type":"url","url":")") +
url + R"("}}]}]})";
const std::string responses =
std::string(
R"({"input":[{"role":"user","content":[{"type":"input_image",)"
R"("image_url":")") +
url + R"("}]}]})";
for (const auto& [path, body] :
{std::pair<std::string, std::string>{"/v1/messages", messages},
{"/v1/messages/count_tokens", messages},
{"/v1/responses", responses}}) {
const auto rejected = server.Post(path, body);
ExpectStatus(rejected, 400);
assert(rejected.find("does not support image input") !=
std::string::npos);
}
}
assert(server.backend->calls == calls);
}
void TestCompatibilityRequests() {
RunningServer server;
server.backend->image_support = true;
using gufo::json::parse;
const auto response_body = [](const std::string& response) {
ExpectStatus(response, 200);
return parse(response.substr(response.find("\r\n\r\n") + 4));
};
struct Endpoint {
const char *path, *body, *limit;
};
for (const auto& endpoint : {
Endpoint{"/v1/completions", R"({"prompt":"hi"})", "max_tokens"},
Endpoint{"/v1/responses", R"({"input":"hi"})", "max_output_tokens"},
Endpoint{"/v1/messages",
R"({"messages":[{"role":"user","content":"hi"}]})",
"max_tokens"},
Endpoint{"/completion", R"({"prompt":"hi"})", "n_predict"},
}) {
auto body = parse(endpoint.body);
response_body(server.Post(endpoint.path, body.dump()));
assert(server.backend->LastCall().max_tokens == 0);
server.backend->defaults.model = gufo::sampling::TextModelPreset::kQwen38;
server.backend->defaults.supplied = {};
server.backend->reasoning.enabled = false;
response_body(server.Post(endpoint.path, body.dump()));
const auto preset = server.backend->LastCall().sampling;
assert(preset.temperature == 0.7F && preset.top_p == 0.8F &&
preset.top_k == 20 && preset.presence_penalty == 1.5F);
server.backend->defaults.sampling.top_k = 0;
server.backend->defaults.supplied.top_k = true;
body["temperature"] = 0;
body["presence_penalty"] = 0;
response_body(server.Post(endpoint.path, body.dump()));
const auto overridden = server.backend->LastCall().sampling;
assert(overridden.temperature == 0 && overridden.top_k == 0 &&
overridden.top_p == 0.8F && overridden.presence_penalty == 0);
server.backend->defaults = {};
server.backend->reasoning = {};
body["model"] = "test";
body[endpoint.limit] = 1;
body["temperature"] = 0.6;
body["top_k"] = 40;
body["top_p"] = 0.9;
body["seed"] = 123;
body["repeat_penalty"] = 1.1;
const auto output = response_body(server.Post(endpoint.path, body.dump()));
const auto last = server.backend->LastCall();
for (int failure : {1, 2}) {
server.backend->failure = failure;
const auto rejected = server.Post(endpoint.path, body.dump());
ExpectStatus(rejected, 400);
assert(rejected.find(failure == 1
? "context_length_exceeded"
: "invalid_prompt") != std::string::npos);
}
server.backend->failure = 0;
assert(last.client_id == "127.0.0.1");
assert(last.max_tokens == 1 && last.sampling.temperature == 0.6F &&
last.sampling.top_k == 40 && last.sampling.top_p == 0.9F &&
last.sampling.seed == 123 && last.sampling.repeat_penalty == 1.1F);
if (std::string_view(endpoint.path) == "/v1/responses") {
assert(output.member_str("status") == "incomplete");
assert(output.find("incomplete_details")->member_str("reason") ==
"max_output_tokens");
assert(output.find("usage")->contains("input_tokens_details"));
} else if (std::string_view(endpoint.path) == "/v1/messages") {
assert(output.member_str("stop_reason") == "max_tokens");
// input_tokens excludes cache reads; together they cover the prompt.
assert(output.find("usage")->member_size("input_tokens") == 2 &&
output.find("usage")->member_size("cache_read_input_tokens") == 8);
} else if (std::string_view(endpoint.path) == "/completion") {
assert(output.find("stopped_length")->as_bool());
assert(!output.find("stopped_eos")->as_bool());
} else {
assert(output.find("choices")->items()[0].member_str("finish_reason") ==
"length");
}
const int calls = server.backend->calls;
for (const auto& [field, value] :
{std::pair{"temperature", 2.01}, std::pair{"presence_penalty", 2.01},
std::pair{"frequency_penalty", -2.01}}) {
auto invalid = body;
invalid[field] = value;
ExpectStatus(server.Post(endpoint.path, invalid.dump()), 400);
}
for (const auto value : {"0", "-1", "1.5", "1e100", "\"1\"", "null"}) {
auto invalid = body;
invalid[endpoint.limit] = parse(value);
ExpectStatus(server.Post(endpoint.path, invalid.dump()), 400);
}
for (const auto field :
{"stream", "echo", "store", "background", "tools", "stop", "reasoning",
"output_config", "logit_bias", "ignore_eos"}) {
const std::string_view path(endpoint.path);
const std::string_view name(field);
if ((path == "/v1/responses" || path == "/v1/completions" ||
path == "/v1/messages") &&
name == "stream")
continue;
if (path == "/v1/completions" && name == "ignore_eos")
continue;
auto invalid = body;
invalid[field] = true;
ExpectStatus(server.Post(endpoint.path, invalid.dump()), 400);
}
const bool streams = std::string_view(endpoint.path) != "/completion";
if (streams) {
auto invalid = body;
invalid["stream"] = "true";
const auto rejected = server.Post(endpoint.path, invalid.dump());
ExpectStatus(rejected, 400);
assert(rejected.find("'stream' must be a boolean") != std::string::npos);
}
auto invalid = body;
invalid["n"] = 1.4;
ExpectStatus(server.Post(endpoint.path, invalid.dump()), 400);
invalid = body;
invalid["model"] = "wrong";
ExpectStatus(server.Post(endpoint.path, invalid.dump()), 404);
ExpectStatus(server.Post(endpoint.path, "[]"), 400);
ExpectStatus(server.Post(endpoint.path, "{"), 400);
assert(server.backend->calls == calls);
if (streams) {
// A null stream is unset, as on Chat Completions.
auto unset = body;
unset["stream"] = gufo::json::Value();
const auto buffered = server.Post(endpoint.path, unset.dump());
assert(buffered.find("text/event-stream") == std::string::npos);
response_body(buffered);
}
}
const int calls = server.backend->calls;
ExpectStatus(server.Post("/v1/completions", R"({"prompt":["one","two"]})"),
400);
ExpectStatus(server.Post("/v1/responses",
R"({"input":[{"role":"assistant","content":[
{"type":"input_text","text":"describe"},
{"type":"input_image","image_url":"data:image/png;base64,AA=="}]}]})"),
400);
ExpectStatus(server.Post("/v1/messages",
R"({"messages":[{"role":"tool","content":"hi"}]})"),
400);
ExpectStatus(server.Post("/v1/messages", R"({"messages":[]})"), 400);
ExpectStatus(
server.Post("/infill", R"({"input_prefix":"one","input_suffix":"two"})"),
501);
ExpectStatus(server.Post("/v1/messages/count_tokens",
R"({"messages":[{"role":"user","content":"hi"}]})"),
501);
assert(server.backend->calls == calls);
// With a backend that can count, count_tokens reports the prompt size of
// the same parsed request Messages would generate from, without generating.
server.backend->chat_token_count = 1234;
const auto counted = response_body(
server.Post("/v1/messages/count_tokens", R"({"system":"Be brief.",
"messages":[{"role":"user","content":[
{"type":"text","text":"what is it?"}]}],
"tools":[{"name":"look","input_schema":{"type":"object"}}]})"));
assert(counted.find("input_tokens") != nullptr &&
counted.find("input_tokens")->as_double() == 1234.0);
assert(server.backend->counted_messages == 2 &&
server.backend->counted_tools == 1);
assert(server.backend->calls == calls);
for (const auto* invalid :
{R"({"stream":true,"messages":[{"role":"user","content":"hi"}]})",
R"({"messages":[]})",
R"({"messages":[{"role":"user","content":"hi"}],"thinking":true})"})
ExpectStatus(server.Post("/v1/messages/count_tokens", invalid), 400);
ExpectStatus(server.Post("/v1/messages/count_tokens",
R"({"model":"other","messages":[
{"role":"user","content":"hi"}]})"),
404);
server.backend->chat_token_count.reset();
for (const auto& [url, expected] :
{std::pair{"data:image/gif;base64,AA==", "image/gif"},
std::pair{"data:image/png,AA==", "base64"},
std::pair{"data:image/png;base64,A===", "base64"}}) {
const auto input = parse(
R"({"input":[{"role":"user","content":[{"type":"input_image","image_url":)" +
gufo::json::Value(url).dump() +
R"(},{"type":"input_text","text":"describe"}]}]})");
const auto rejected = server.Post("/v1/responses", input.dump());
ExpectStatus(rejected, 400);
const auto parsed = parse(rejected.substr(rejected.find("\r\n\r\n") + 4));
const auto& error = *parsed.find("error");
assert(error.member_str("code") == "invalid_request");
assert(error.member_str("message").find(expected) != std::string::npos);
}
ExpectStatus(server.Post("/v1/responses",
R"({"input":[{"role":"user","content":[
{"type":"input_image","image_url":"data:image/png;base64,AA==","detail":"ultra"}]}]})"),
400);
assert(server.backend->calls == calls);
// Codex's view_image sends detail "high"; it is served as automatic detail.
response_body(server.Post("/v1/responses",
R"({"input":[{"role":"user","content":[
{"type":"input_text","text":"describe"},
{"type":"input_image","image_url":"data:image/png;base64,AA==","detail":"high"}]}]})"));
assert(server.backend->LastCall().chat.messages.back().images.size() == 1);
const auto structured =
response_body(server.Post("/v1/responses",
R"({"input":[{"role":"user","content":[
{"type":"input_text","text":"describe"},
{"type":"input_image","image_url":"data:image/png;base64,AA=="}]}],
"reasoning":{"effort":"none"},
"text":{"format":{"type":"json_schema","name":"answer","strict":true,
"schema":{"type":"object","properties":{"score":{"type":"integer","minimum":1,"maximum":5}},
"required":["score"],"additionalProperties":false}}}})"));
const auto request = server.backend->LastCall().chat;
assert(request.response_format && request.reasoning.enabled == false &&
request.messages.back().images.size() == 1 &&
request.messages.back().images[0].offset == 8);
const auto response = response_body(server.Post(
"/v1/responses",
R"({"instructions":"Be concise.","input":[{"role":"user","content":[
{"type":"input_text","text":"hi"}]}],"max_output_tokens":2,"store":false})"));
assert(response.member_str("status") == "completed");
const auto messages = server.backend->LastCall().chat.messages;
assert(messages.size() == 2);
assert(messages[0].role == gufo::tokenization::ChatRole::kSystem &&
messages[0].content == "Be concise." && messages[1].content == "hi");
for (const auto limit : {1, 2}) {
const auto streaming = server.Post(
"/v1/responses",
std::string(R"({"input":"hi","stream":true,"max_output_tokens":)") +
std::to_string(limit) + "}");
ExpectStatus(streaming, 200);
assert(streaming.find("text/event-stream") != std::string::npos);
assert(streaming.find("event: response.created") != std::string::npos);
assert(streaming.find("event: response.output_text.delta") !=
std::string::npos);
assert(streaming.find(limit == 1 ? "event: response.incomplete"
: "event: response.completed") !=
std::string::npos);
}
server.backend->failure = 6;
const auto failed_stream =
server.Post("/v1/responses", R"({"input":"hi","stream":true})");
ExpectStatus(failed_stream, 200); // Fake backend fails after headers.
assert(failed_stream.find("event: response.failed") != std::string::npos);
assert(failed_stream.find("event: response.completed") == std::string::npos);
const auto failed_data = failed_stream.find(
"data: ", failed_stream.find("event: response.failed"));
assert(failed_data != std::string::npos);
const auto failed_event = gufo::json::parse(
std::string_view(failed_stream)
.substr(failed_data + 6,
failed_stream.find("\n\n", failed_data) - failed_data - 6));
assert(failed_event.find("response")->find("error")->member_str("code") ==
"server_error");
assert(failed_stream.ends_with("0\r\n\r\n"));
server.backend->failure = 0;
const auto continued = response_body(
server.Post("/v1/responses",
R"({"input":[{"role":"user","content":"First question"},
{"type":"reasoning","id":"rs_1","status":"completed",
"summary":[{"type":"summary_text","text":"Thoughts"}]},
{"type":"message","role":"assistant","content":[
{"type":"output_text","text":"Answer","annotations":[]}]},
{"role":"user","content":"Next question"}]})"));
assert(continued.member_str("status") == "completed");
const auto replay_messages = server.backend->LastCall().chat.messages;
assert(replay_messages.size() == 3 &&
replay_messages[1].thought == "Thoughts" &&
replay_messages[1].content == "Answer");
// Codex replays reasoning items with the null content and encrypted_content
// fields it serialized from the response; only non-null payload is rejected.
const auto null_replay = response_body(
server.Post("/v1/responses",
R"({"input":[{"role":"user","content":"First question"},
{"type":"reasoning","id":"rs_1","status":"completed",
"summary":[{"type":"summary_text","text":"Thoughts"}],
"content":null,"encrypted_content":null},
{"type":"message","role":"assistant","content":[
{"type":"output_text","text":"Answer","annotations":[]}]}]})"));
assert(null_replay.member_str("status") == "completed");
const auto null_replay_messages = server.backend->LastCall().chat.messages;
assert(null_replay_messages.size() == 2 &&
null_replay_messages[1].thought == "Thoughts" &&
null_replay_messages[1].content == "Answer");
const auto opaque_replay =
server.Post("/v1/responses",
R"({"input":[{"role":"user","content":"question"},
{"type":"reasoning","id":"rs_1",
"summary":[],"encrypted_content":"gAAAA"}]})");
ExpectStatus(opaque_replay, 400);
// Codex re-sends developer items mid-conversation. They stay in place, so
// the prompt before them is unchanged and reusable.
const auto in_place =
response_body(server.Post("/v1/responses",
R"({"instructions":"Base rules.","input":[
{"role":"user","content":"first"},
{"type":"message","role":"assistant","content":[
{"type":"output_text","text":"noted","annotations":[]}]},
{"type":"message","role":"developer","content":[
{"type":"input_text","text":"Compacted state"}]},
{"role":"user","content":"second"}]})"));
assert(in_place.member_str("status") == "completed");
const auto in_place_messages = server.backend->LastCall().chat.messages;
assert(in_place_messages.size() == 5 &&
in_place_messages[0].role == gufo::tokenization::ChatRole::kSystem &&
in_place_messages[0].content == "Base rules." &&
in_place_messages[1].content == "first" &&
in_place_messages[2].content == "noted" &&
in_place_messages[3].role ==
gufo::tokenization::ChatRole::kDeveloper &&
in_place_messages[3].content == "Compacted state" &&
in_place_messages[4].content == "second");
// A developer item inside a replayed reasoning/function_call group waits
// for the group to end instead of splitting the assistant turn.
const auto replay_items = gufo::json::parse(R"([
{"role":"user","content":"Check state."},
{"type":"reasoning","summary":[
{"type":"summary_text","text":"I will inspect."}]},
{"type":"function_call","call_id":"call_1","name":"lookup",
"arguments":"{\"key\":\"state\"}"},
{"type":"function_call_output","call_id":"call_1","output":"OK"}])");
const auto developer =
gufo::json::parse(R"({"role":"developer","content":"Follow policy."})");
for (const std::size_t position : {2U, 3U}) {
auto input = gufo::json::Value::array();
for (std::size_t i = 0; i < replay_items.size(); ++i) {
if (i == position)
input.push_back(developer);
input.push_back(replay_items.items()[i]);
}
auto body = gufo::json::Value::object();
body["input"] = std::move(input);
const auto replay =
response_body(server.Post("/v1/responses", body.dump()));
assert(replay.member_str("status") == "completed");
const auto grouped = server.backend->LastCall().chat.messages;
assert(grouped.size() == 4);
assert(grouped[0].role == gufo::tokenization::ChatRole::kUser &&
grouped[1].role == gufo::tokenization::ChatRole::kAssistant &&
grouped[1].content.empty() &&
grouped[1].thought == "I will inspect." &&
grouped[1].tool_calls.size() == 1 &&
grouped[1].tool_calls[0].id == "call_1" &&
grouped[2].role == gufo::tokenization::ChatRole::kDeveloper &&
grouped[2].content == "Follow policy." &&
grouped[3].role == gufo::tokenization::ChatRole::kTool &&
grouped[3].tool_call_id == "call_1" && grouped[3].content == "OK");
}
// The Responses API carries request-only fields with no native effect here
// (hosted tool types, include, reasoning.summary, text.verbosity). Accept
// them and keep every executable function tool, flattening the client-side
// namespace grouping. Codex is one such client.
const auto hosted = response_body(server.Post(
"/v1/responses",
R"({"model":"test","instructions":"You are a coding agent.","input":[
{"type":"message","role":"developer","content":[
{"type":"input_text","text":"AGENTS instructions"}]},
{"type":"message","role":"user","content":[
{"type":"input_text","text":"say hi"}]}],
"reasoning":{"effort":"medium","summary":"auto"},
"text":{"verbosity":"low"},
"tool_choice":"auto","parallel_tool_calls":true,
"store":false,"stream":false,
"include":["reasoning.encrypted_content"],
"prompt_cache_key":"cache-1","client_metadata":{"thread_id":"t-1"},
"tools":[
{"type":"function","name":"exec_command","strict":false,
"parameters":{"type":"object",
"properties":{"cmd":{"type":"string"}},"required":["cmd"]}},
{"type":"namespace","name":"multi_agent_v1","tools":[
{"type":"function","name":"close_agent","strict":false,
"parameters":{"type":"object","properties":{}}}]},
{"type":"web_search","external_web_access":false}]})"));
assert(hosted.member_str("status") == "completed");
const auto hosted_call = server.backend->LastCall();
assert(hosted_call.chat.tools.size() == 2 &&
hosted_call.chat.tools[0].name == "exec_command" &&
hosted_call.chat.tools[1].name == "close_agent");
assert(hosted_call.chat.reasoning.enabled == true &&
hosted_call.chat.reasoning.effort == gufo::ReasoningEffort::kMedium);
assert(hosted_call.chat.messages.size() == 3 &&
hosted_call.chat.messages[0].role ==
gufo::tokenization::ChatRole::kSystem &&
hosted_call.chat.messages[0].content == "You are a coding agent." &&
hosted_call.chat.messages[2].content == "say hi");
// Responses replays function calls and their outputs between turns under the
// same call_id; the adapter folds them back into the prompt.
const auto tool_replay = response_body(server.Post("/v1/responses",
R"({"input":[
{"type":"message","role":"user","content":[
{"type":"input_text","text":"list files"}]},
{"type":"function_call","id":"fc_1","call_id":"call_1",
"name":"exec_command","arguments":"{\"cmd\":\"ls\"}",
"status":"completed"},
{"type":"function_call_output","call_id":"call_1","output":"file.txt"},
{"type":"message","role":"assistant","content":[
{"type":"output_text","text":"Here are the files.",
"annotations":[]}]}]})"));
assert(tool_replay.member_str("status") == "completed");
const auto tool_messages = server.backend->LastCall().chat.messages;
assert(tool_messages.size() == 4);
assert(tool_messages[1].role == gufo::tokenization::ChatRole::kAssistant &&
tool_messages[1].tool_calls.size() == 1 &&
tool_messages[1].tool_calls[0].id == "call_1" &&
tool_messages[1].tool_calls[0].name == "exec_command");
assert(tool_messages[2].content == "file.txt" &&
tool_messages[3].content == "Here are the files.");
// The include leniency is Responses-only: endpoints that run the shared
// compatibility validator (Messages) still reject it.
ExpectStatus(
server.Post(
"/v1/messages",
R"({"messages":[{"role":"user","content":"hi"}],"max_tokens":2,
"include":["x"]})"),
400);
const auto anthropic = response_body(
server.Post("/v1/messages",
R"({"system":[{"type":"text","text":"Be concise."}],
"messages":[{"role":"user","content":[{"type":"text","text":"hi"}]}],
"max_tokens":2})"));
assert(anthropic.member_str("stop_reason") == "end_turn");
assert(server.backend->LastCall().chat.messages[0].content == "Be concise.");
// Messages reports reasoning in its own block and restores replayed blocks
// as the assistant thought.
server.backend->SetOutput("<think>plan</think>answer");
const auto thinking = response_body(server.Post(
"/v1/messages", R"({"messages":[{"role":"user","content":"hi"}],
"thinking":{"type":"enabled","budget_tokens":1024}})"));
const auto blocks = thinking.find("content")->items();
assert(blocks.size() == 2 && blocks[0].member_str("type") == "thinking" &&
blocks[0].member_str("thinking") == "plan" &&
blocks[0].contains("signature") &&
blocks[1].member_str("type") == "text" &&
blocks[1].member_str("text") == "answer");
assert(server.backend->LastCall().chat.reasoning.enabled == true);
// budget_tokens caps the reasoning that the sampler allows (#266).
assert(server.backend->LastCall().chat.reasoning.budget_tokens ==
std::size_t{1024});
response_body(server.Post("/v1/messages", R"({"messages":[
{"role":"user","content":"hi"},
{"role":"assistant","content":[
{"type":"thinking","thinking":"plan","signature":""},
{"type":"text","text":"answer"}]},
{"role":"user","content":"next"}],
"thinking":{"type":"disabled"}})"));
const auto replayed = server.backend->LastCall().chat;
assert(replayed.reasoning.enabled == false &&
!replayed.reasoning.budget_tokens);
// --reasoning-budget is a server default for Chat, Responses and Messages;
// Messages budget_tokens overrides it per request.
server.backend->reasoning.budget_tokens = 50;
response_body(server.Post("/v1/responses", R"({"input":"hi"})"));
assert(server.backend->LastCall().chat.reasoning.budget_tokens ==
std::size_t{50});
response_body(
server.Post("/v1/chat/completions", R"({"model":"test","messages":[
{"role":"user","content":"hi"}]})"));
assert(server.backend->LastCall().chat.reasoning.budget_tokens ==
std::size_t{50});
response_body(server.Post("/v1/messages", R"({"messages":[
{"role":"user","content":"hi"}],
"thinking":{"type":"enabled","budget_tokens":8}})"));
assert(server.backend->LastCall().chat.reasoning.budget_tokens ==
std::size_t{8});
// As in llama.cpp, adaptive thinking takes no budget_tokens.
response_body(server.Post("/v1/messages", R"({"messages":[
{"role":"user","content":"hi"}],
"thinking":{"type":"adaptive","budget_tokens":8}})"));
assert(server.backend->LastCall().chat.reasoning.budget_tokens ==
std::size_t{50});
// llama.cpp's thinking_budget_tokens sets it on Chat and Responses; -1
// keeps the server default and 0 ends reasoning at once.
response_body(
server.Post("/v1/chat/completions", R"({"model":"test","messages":[
{"role":"user","content":"hi"}],"thinking_budget_tokens":0})"));
assert(server.backend->LastCall().chat.reasoning.budget_tokens ==
std::size_t{0});
response_body(
server.Post("/v1/chat/completions", R"({"model":"test","messages":[
{"role":"user","content":"hi"}],"thinking_budget_tokens":-1})"));
assert(server.backend->LastCall().chat.reasoning.budget_tokens ==
std::size_t{50});
response_body(server.Post("/v1/responses",
R"({"input":"hi","thinking_budget_tokens":12})"));
assert(server.backend->LastCall().chat.reasoning.budget_tokens ==
std::size_t{12});
for (const auto* invalid : {"-2", "1.5", "\"8\""}) {
const auto rejected = server.Post(
"/v1/chat/completions", std::string(R"({"model":"test","messages":[
{"role":"user","content":"hi"}],"thinking_budget_tokens":)") +
invalid + "}");
ExpectStatus(rejected, 400);
assert(rejected.find("thinking_budget_tokens") != std::string::npos);
}
for (const auto* path : {"/v1/chat/completions", "/v1/responses"}) {
const auto input = gufo::json::parse(
std::string_view(path) == "/v1/responses"
? R"({"input":"hi"})"
: R"({"model":"test","messages":[{"role":"user","content":"hi"}]})");
for (const auto* field :
{"reasoning_budget_tokens", "thinking_budget_tokens"}) {
for (const auto* value : {"-1", "0", "16", "2147483647"}) {
auto body = input;
body[field] = gufo::json::parse(value);
response_body(server.Post(path, body.dump()));
const auto expected =
std::string_view(value) == "-1"
? std::size_t{50}
: static_cast<std::size_t>(body[field].as_double());
assert(server.backend->LastCall().chat.reasoning.budget_tokens ==
expected);
}
for (const auto* invalid : {"-2", "1.5", "\"8\"", "2147483648", "1e30"}) {
auto body = input;
body[field] = gufo::json::parse(invalid);
const auto rejected = server.Post(path, body.dump());
ExpectStatus(rejected, 400);
assert(rejected.find(field) != std::string::npos);
}
}
auto body = input;
body["reasoning_budget_tokens"] = 4;
body["thinking_budget_tokens"] = 16;
response_body(server.Post(path, body.dump()));
assert(server.backend->LastCall().chat.reasoning.budget_tokens ==
std::size_t{4});
body["reasoning_budget_tokens"] = -1;
response_body(server.Post(path, body.dump()));
assert(server.backend->LastCall().chat.reasoning.budget_tokens ==
std::size_t{50});
body["reasoning_budget_tokens"] = gufo::json::Value{};
response_body(server.Post(path, body.dump()));
assert(server.backend->LastCall().chat.reasoning.budget_tokens ==
std::size_t{16});
}
for (const auto* invalid : {"2147483648", "18446744073709551616", "1e30"}) {
const auto rejected =
server.Post("/v1/messages",
std::string(R"({"messages":[{"role":"user","content":"hi"}],
"thinking":{"type":"enabled","budget_tokens":)") +
invalid + "}}");
ExpectStatus(rejected, 400);
assert(rejected.find("thinking.budget_tokens") != std::string::npos);
}
server.backend->reasoning.budget_tokens.reset();
assert(replayed.messages.size() == 3 &&
replayed.messages[1].thought == "plan" &&
replayed.messages[1].content == "answer");
// An explicit content phase preserves requested literal reasoning tags.
// The default automatic phase above still recognizes reasoning blocks.
server.backend->initial_output_state_override =
FakeBackend::InitialOutputState::kContent;
const std::string literal_thinking = "<think>literal example</think>";
server.backend->SetOutput(literal_thinking);
const auto literal = response_body(
server.Post("/v1/messages", R"({"messages":[{"role":"user","content":
"Copy this XML exactly: <think>literal example</think>"}],
"thinking":{"type":"disabled"}})"));
assert(server.backend->LastCall().chat.reasoning.enabled == false);
const auto literal_blocks = literal.find("content")->items();
assert(literal_blocks.size() == 1 &&
literal_blocks[0].member_str("type") == "text" &&
literal_blocks[0].member_str("text") == literal_thinking &&
"disabled thinking preserves literal tags as one text block");
server.backend->initial_output_state_override.reset();
server.backend->SetOutput("answer");
const auto plain = response_body(server.Post(
"/v1/messages", R"({"messages":[{"role":"user","content":"hi"}]})"));
assert(plain.find("content")->items().size() == 1 &&
plain.find("content")->items()[0].member_str("text") == "answer");
for (const auto* invalid :
{R"({"messages":[{"role":"user","content":[
{"type":"thinking","thinking":"plan"}]}]})",
R"({"messages":[{"role":"user","content":"hi"}],"thinking":true})",
R"({"messages":[{"role":"user","content":"hi"}],
"thinking":{"type":"enabled","budget_tokens":0}})",
R"({"messages":[{"role":"user","content":"hi"}],
"thinking":{"type":"adaptive","display":"full"}})",
R"({"messages":[{"role":"user","content":"hi"}],
"output_config":{"effort":"adaptive"}})",
R"({"messages":[{"role":"user","content":"hi"}],
"output_config":{"effort":"minimal"}})",
R"({"messages":[{"role":"user","content":"hi"}],
"output_config":"high"})",
R"({"messages":[{"role":"user","content":"hi"}],
"output_config":{"format":{"type":"json_schema"}}})",
R"({"messages":[{"role":"user","content":"hi"}],
"reasoning_effort":"high"})",
R"({"messages":[{"role":"user","content":"hi"}],
"chat_template_kwargs":{"enable_thinking":true}})"})
ExpectStatus(server.Post("/v1/messages", invalid), 400);
// The reasoning fields Claude Code sends on every request. Adaptive keeps
// the server's thinking default and the reasoning is still returned.
server.backend->SetOutput("<think>plan</think>answer");
for (const bool server_thinking : {false, true}) {
server.backend->reasoning.enabled = server_thinking;
server.backend->reasoning.effort = gufo::ReasoningEffort::kLow;
const auto adaptive = response_body(
server.Post("/v1/messages", R"({"max_tokens":256,"stream":false,
"thinking":{"type":"adaptive","display":"omitted"},
"output_config":{"effort":"xhigh"},
"messages":[{"role":"user","content":"hi"}]})"));
if (server_thinking)
assert(adaptive.find("content")->items()[0].member_str("thinking") ==
"plan");
const auto reasoning = server.backend->LastCall().chat.reasoning;
assert(reasoning.enabled == server_thinking);
assert(reasoning.effort == gufo::ReasoningEffort::kXHigh);
}
server.backend->reasoning = {};
const auto updates = response_body(server.Post(
"/v1/messages", R"({"messages":[{"role":"user","content":"hi"}],
"thinking":{"type":"enabled","display":"updates"}})"));
assert(updates.find("content")->items()[0].member_str("thinking") == "plan");
// Effort never enables thinking.
response_body(server.Post("/v1/messages",
R"({"messages":[{"role":"user","content":"hi"}],
"thinking":{"type":"disabled"},"output_config":{"effort":"low"}})"));
assert(server.backend->LastCall().chat.reasoning.enabled == false);
assert(server.backend->LastCall().chat.reasoning.effort ==
gufo::ReasoningEffort::kLow);
// Messages tools map onto the Chat tool path: declarations, tool_choice,
// tool_use output and replayed tool_use/tool_result history.
const std::string weather_tool = R"([{"name":"get_weather",
"description":"Get weather","input_schema":{"type":"object",
"properties":{"city":{"type":"string"}},"required":["city"]}}])";
server.backend->SetOutput(
"<tool_call>\n<function=get_weather>\n<parameter=city>\nRome\n"
"</parameter>\n</function>\n</tool_call>");
const auto called = response_body(server.Post(
"/v1/messages", R"({"max_tokens":64,"tool_choice":{"type":"any"},
"messages":[{"role":"user","content":"weather in Rome?"}],
"tools":)" + weather_tool +
"}"));
assert(called.member_str("stop_reason") == "tool_use");
const auto call_blocks = called.find("content")->items();
assert(call_blocks.size() == 1 &&
call_blocks[0].member_str("type") == "tool_use" &&
!call_blocks[0].member_str("id").empty() &&
call_blocks[0].member_str("name") == "get_weather" &&
call_blocks[0].find("input")->member_str("city") == "Rome");
const auto declared = server.backend->LastCall().chat;
assert(declared.tools.size() == 1 &&
declared.tools[0].name == "get_weather" &&
declared.tools[0].description == "Get weather" &&
declared.tool_choice ==
gufo::server::ChatRequest::ToolChoice::kRequired &&
declared.constrained_tools);
response_body(server.Post("/v1/messages",
R"({"messages":[{"role":"user","content":"hi"}],
"tool_choice":{"type":"tool","name":"get_weather"},
"tools":)" + weather_tool +
"}"));
assert(server.backend->LastCall().chat.forced_tool_name == "get_weather" &&
!server.backend->LastCall().chat.parallel_tool_calls);
response_body(server.Post("/v1/messages",
R"({"messages":[{"role":"user","content":"hi"}],
"tool_choice":{"type":"auto","disable_parallel_tool_use":true},
"tools":)" + weather_tool +
"}"));
assert(server.backend->LastCall().chat.tool_choice ==
gufo::server::ChatRequest::ToolChoice::kAuto &&
!server.backend->LastCall().chat.parallel_tool_calls);
response_body(server.Post("/v1/messages",
R"({"messages":[{"role":"user","content":"hi"}],
"tool_choice":{"type":"none"},"tools":)" +
weather_tool + "}"));
assert(server.backend->LastCall().chat.tool_choice ==
gufo::server::ChatRequest::ToolChoice::kNone &&
!server.backend->LastCall().chat.constrained_tools);
server.backend->SetOutput("It is sunny.");
const auto answered = response_body(server.Post("/v1/messages", R"({
"tools":)" + weather_tool + R"(,"messages":[
{"role":"user","content":"weather in Rome?"},
{"role":"assistant","content":[
{"type":"thinking","thinking":"look it up","signature":""},
{"type":"text","text":"Checking."},
{"type":"tool_use","id":"toolu_1","name":"get_weather",
"input":{"city":"Rome"}}]},
{"role":"user","content":[
{"type":"tool_result","tool_use_id":"toolu_1","content":"sunny"},
{"type":"tool_result","tool_use_id":"toolu_2","is_error":true,
"content":[{"type":"text","text":"timeout"}]},
{"type":"text","text":"thanks"}]}]})"));
assert(answered.member_str("stop_reason") == "end_turn" &&
answered.find("content")->items()[0].member_str("text") ==
"It is sunny.");
const auto history = server.backend->LastCall().chat.messages;
assert(history.size() == 5);
assert(history[1].role == gufo::tokenization::ChatRole::kAssistant &&
history[1].thought == "look it up" &&
history[1].content == "Checking." &&
history[1].tool_calls.size() == 1 &&
history[1].tool_calls[0].id == "toolu_1" &&
history[1].tool_calls[0].name == "get_weather" &&
history[1].tool_calls[0].arguments.size() == 1 &&
history[1].tool_calls[0].arguments[0].name == "city" &&
history[1].tool_calls[0].arguments[0].value == "Rome");
assert(history[2].role == gufo::tokenization::ChatRole::kTool &&
history[2].tool_call_id == "toolu_1" && history[2].content == "sunny");
assert(history[3].role == gufo::tokenization::ChatRole::kTool &&
history[3].tool_call_id == "toolu_2" &&
history[3].content == "timeout");
assert(history[4].role == gufo::tokenization::ChatRole::kUser &&
history[4].content == "thanks");
const auto calls_before = server.backend->calls.load();
for (const auto& invalid : std::vector<std::string>{
R"({"messages":[{"role":"user","content":"hi"}],"tools":{}})",
R"({"messages":[{"role":"user","content":"hi"}],
"tools":[{"type":"web_search_20250305","name":"web_search"}]})",
R"({"messages":[{"role":"user","content":"hi"}],
"tools":[{"name":"f"}]})",
R"({"messages":[{"role":"user","content":"hi"}],
"tool_choice":{"type":"any"}})",
R"({"messages":[{"role":"user","content":"hi"}],
"tool_choice":"auto","tools":)" +
weather_tool + "}",
R"({"messages":[{"role":"user","content":"hi"}],
"tool_choice":{"type":"auto","disable_parallel_tool_use":1},
"tools":)" +
weather_tool + "}",
R"({"messages":[{"role":"assistant","content":[{"type":"tool_use",
"id":"toolu_1","name":"f","input":"{}"}]}]})",
R"({"messages":[{"role":"assistant","content":[{"type":"tool_use",
"name":"f","input":{}}]}]})",
R"({"messages":[{"role":"user","content":[{"type":"tool_result",
"content":"x"}]}]})",
R"({"messages":[{"role":"user","content":[{"type":"tool_result",
"tool_use_id":"toolu_1","content":[{"type":"image"}]}]}]})",
})
ExpectStatus(server.Post("/v1/messages", invalid), 400);
assert(server.backend->calls == calls_before);
// Image blocks in a user turn and in a tool result reach the backend as
// images at their place in the text, from a base64 source.
server.backend->SetOutput("seen");
response_body(server.Post("/v1/messages", R"({"max_tokens":8,"messages":[
{"role":"user","content":[
{"type":"text","text":"look: "},
{"type":"image","source":{"type":"base64","media_type":"image/png",
"data":"AQID"}},
{"type":"text","text":"what is it?"}]}]})"));
{
const auto sent = server.backend->LastCall().chat.messages.back();
assert(sent.role == gufo::tokenization::ChatRole::kUser &&
sent.content == "look: what is it?" && sent.images.size() == 1 &&
sent.images[0].offset == 6 &&
*sent.images[0].bytes == std::vector<std::uint8_t>({1, 2, 3}));
}
response_body(server.Post("/v1/messages", R"({"max_tokens":8,"messages":[
{"role":"user","content":"read it"},
{"role":"assistant","content":[{"type":"tool_use","id":"toolu_1",
"name":"read","input":{}}]},
{"role":"user","content":[
{"type":"tool_result","tool_use_id":"toolu_1","content":[
{"type":"text","text":"swatch.png"},
{"type":"image","source":{"type":"base64","media_type":"image/jpeg",
"data":"BAUG"}}]},
{"type":"text","text":"which color?"}]}]})"));
{
const auto sent = server.backend->LastCall().chat.messages;
assert(sent.size() == 4 &&
sent[2].role == gufo::tokenization::ChatRole::kTool &&
sent[2].tool_call_id == "toolu_1" &&
sent[2].content == "swatch.png" && sent[2].images.size() == 1 &&
sent[2].images[0].offset == 10 &&
*sent[2].images[0].bytes == std::vector<std::uint8_t>({4, 5, 6}) &&
sent[3].role == gufo::tokenization::ChatRole::kUser &&
sent[3].images.empty() && sent[3].content == "which color?");
}
// The streamed path takes the same parsed turn.
const auto streamed_image = server.Post("/v1/messages", R"({"stream":true,
"max_tokens":8,"messages":[{"role":"user","content":[
{"type":"image","source":{"type":"base64","media_type":"image/png",
"data":"AQID"}},
{"type":"text","text":"what is it?"}]}]})");
ExpectStatus(streamed_image, 200);
assert(streamed_image.find("event: message_start\n") != std::string::npos);
{
const auto sent = server.backend->LastCall().chat.messages.back();
assert(sent.content == "what is it?" && sent.images.size() == 1 &&
sent.images[0].offset == 0);
}
// Unsupported spellings stay errors that name the problem.
const auto calls_before_images = server.backend->calls.load();
for (const auto& [body, expected] :
{std::pair<std::string, std::string>{
R"({"messages":[{"role":"user","content":[{"type":"image",
"source":{"type":"base64","media_type":"image/gif",
"data":"AA=="}}]}]})",
"image/gif"},
{R"({"messages":[{"role":"user","content":[{"type":"image",
"source":{"type":"url","url":"file:///tmp/x.png"}}]}]})",
"HTTPS"},
{R"({"messages":[{"role":"user","content":[{"type":"image",
"source":{"type":"file","file_id":"f"}}]}]})",
"base64 or url source"},
{R"({"messages":[{"role":"assistant","content":[{"type":"image",
"source":{"type":"base64","media_type":"image/png",
"data":"AQID"}}]}]})",
"'messages' must contain"}}) {
const auto rejected = server.Post("/v1/messages", body);
ExpectStatus(rejected, 400);
const auto error = parse(rejected.substr(rejected.find("\r\n\r\n") + 4));
assert(error.find("error")->member_str("message").find(expected) !=
std::string::npos);
}
assert(server.backend->calls == calls_before_images);
// A turn cut by max_tokens reports the cut, as Chat finish_reason does,
// even when a complete call precedes it.
server.backend->SetOutput(
"<tool_call>\n<function=get_weather>\n<parameter=city>\nRome\n"
"</parameter>\n</function>\n</tool_call>");
const auto truncated = response_body(server.Post(
"/v1/messages", R"({"max_tokens":1,"tool_choice":{"type":"auto"},
"messages":[{"role":"user","content":"weather in Rome?"}],
"tools":)" + weather_tool +
"}"));
assert(truncated.member_str("stop_reason") == "max_tokens");
// Without declared tools, call markup stays visible text as before.
server.backend->SetOutput("<tool_call>leak");
const auto undeclared = response_body(server.Post(
"/v1/messages", R"({"messages":[{"role":"user","content":"hi"}]})"));
assert(undeclared.member_str("stop_reason") == "end_turn" &&
undeclared.find("content")->items().size() == 1 &&
undeclared.find("content")->items()[0].member_str("text") ==
"<tool_call>leak");
// Streamed Messages use Anthropic SSE events over the same filters.
const auto sse_events = [](const std::string& wire) {
std::vector<gufo::json::Value> events;
for (auto data = wire.find("data: "); data != std::string::npos;
data = wire.find("data: ", data + 6)) {
const auto end = wire.find("\n\n", data);
events.push_back(gufo::json::parse(
std::string_view(wire).substr(data + 6, end - data - 6)));
}
return events;
};
server.backend->SetOutput("<think>plan</think>answer");
const auto streamed_text =
server.Post("/v1/messages", R"({"stream":true,"max_tokens":64,
"thinking":{"type":"enabled","budget_tokens":1024},
"messages":[{"role":"user","content":"hi"}]})");
ExpectStatus(streamed_text, 200);
assert(streamed_text.find("text/event-stream") != std::string::npos &&
streamed_text.find("event: message_start\n") != std::string::npos);
std::vector<std::string> types;
std::string thought, answer;
const auto text_events = sse_events(streamed_text);
for (const auto& event : text_events) {
types.push_back(event.member_str("type"));
if (const auto* delta = event.find("delta");
delta && delta->member_str("type") == "thinking_delta")
thought += delta->member_str("thinking");
else if (delta && delta->member_str("type") == "text_delta")
answer += delta->member_str("text");
}
assert(thought == "plan" && answer == "answer");
// Thinking blocks keep the trimmed reasoning Messages reported before
// streaming, buffered and streamed alike.
server.backend->SetOutput("<think>\nplan\n\nmore\n</think>\n\nanswer");
for (const bool stream : {false, true}) {
const auto framed =
server.Post("/v1/messages",
std::string(R"({"max_tokens":64,"stream":)") +
(stream ? "true" : "false") +
R"(,"thinking":{"type":"enabled","budget_tokens":1024},
"messages":[{"role":"user","content":"hi"}]})");
ExpectStatus(framed, 200);
std::string framed_thought;
if (stream) {
for (const auto& event : sse_events(framed))
if (const auto* delta = event.find("delta");
delta && delta->member_str("type") == "thinking_delta")
framed_thought += delta->member_str("thinking");
} else {
framed_thought =
response_body(framed).find("content")->items()[0].member_str(
"thinking");
}
assert(framed_thought == "plan\n\nmore");
}
assert(types.front() == "message_start" && types.back() == "message_stop");
assert(text_events[1].find("content_block")->member_str("type") ==
"thinking" &&
text_events[1].member_size("index") == 0);
const auto& text_done = text_events[text_events.size() - 2];
assert(text_done.member_str("type") == "message_delta" &&
text_done.find("delta")->member_str("stop_reason") == "end_turn" &&
text_done.find("usage")->member_size("input_tokens") == 2 &&
text_done.find("usage")->member_size("cache_read_input_tokens") == 8);
assert(std::ranges::count(types, "content_block_start") == 2 &&
std::ranges::count(types, "content_block_stop") == 2);
server.backend->SetOutput(
"<tool_call>\n<function=get_weather>\n<parameter=city>\nRome\n"
"</parameter>\n</function>\n</tool_call>");
const auto streamed_tool = server.Post(
"/v1/messages", R"({"stream":true,"tool_choice":{"type":"any"},
"messages":[{"role":"user","content":"weather in Rome?"}],
"tools":)" + weather_tool +
"}");
ExpectStatus(streamed_tool, 200);
assert(streamed_tool.find("<tool_call>") == std::string::npos);
bool tool_started = false;
std::string partial_json, stop_reason;
for (const auto& event : sse_events(streamed_tool)) {
if (const auto* block = event.find("content_block"))
tool_started |= block->member_str("type") == "tool_use" &&
block->member_str("name") == "get_weather";
if (const auto* delta = event.find("delta")) {
if (delta->member_str("type") == "input_json_delta")
partial_json += delta->member_str("partial_json");
if (event.member_str("type") == "message_delta")
stop_reason = delta->member_str("stop_reason");
}
}
assert(tool_started && stop_reason == "tool_use" &&
gufo::json::parse(partial_json).member_str("city") == "Rome");
server.backend->failure = 6;
const auto failed_messages = server.Post(
"/v1/messages",
R"({"stream":true,"messages":[{"role":"user","content":"hi"}]})");
ExpectStatus(failed_messages, 200); // Fake backend fails after headers.
const auto failed_events = sse_events(failed_messages);
assert(failed_events.back().member_str("type") == "error" &&
failed_events.back().find("error")->member_str("type") ==
"api_error" &&
failed_messages.find("event: message_stop") == std::string::npos);
server.backend->failure = 0;
server.backend->SetOutput("ok");
}
void TestModelInputModalities() {
RunningServer server;
// Keep the same model ID: capability follows the loaded backend, not its
// name.
for (const bool images : {false, true, false}) {
server.backend->image_support = images;
const auto response = server.Send("GET /v1/models HTTP/1.1\r\n\r\n");
ExpectStatus(response, 200);
const auto listing =
gufo::json::parse(response.substr(response.find("\r\n\r\n") + 4));
assert(listing.member_str("object") == "list");
const auto& models = listing.find("data")->items();
assert(models.size() == 1);
const auto& model = models[0];
assert(model.member_str("id") == "test");
assert(model.member_str("owned_by") == "gufo");
assert(model.member_size("context_length") == 65536);
const auto* architecture = model.find("architecture");
assert(architecture != nullptr);
const auto* modalities = architecture->find("input_modalities");
assert(modalities != nullptr);
assert(modalities->dump() ==
(images ? R"(["text","image"])" : R"(["text"])"));
}
}
void TestServedModelNames() {
RunningServer server;
using gufo::json::parse;
const auto body_of = [](const std::string& response) {
return parse(response.substr(response.find("\r\n\r\n") + 4));
};
const auto listed = [&] {
std::vector<std::string> ids;
const auto models = server.Send("GET /v1/models HTTP/1.1\r\n\r\n");
ExpectStatus(models, 200);
const auto listing = body_of(models);
for (const auto& model : listing.find("data")->items()) {
assert(model.member_size("context_length") == 65536);
ids.push_back(model.member_str("id"));
}
return ids;
};
struct Endpoint {
const char *path, *fields;
bool echoes = true;
};
const Endpoint endpoints[] = {
{"/v1/chat/completions",
R"("messages":[{"role":"user","content":"hi"}])"},
{"/v1/completions", R"("prompt":"hi")"},
{"/v1/responses", R"("input":"hi")"},
{"/v1/messages", R"("messages":[{"role":"user","content":"hi"}])"},
{"/v1/messages/count_tokens",
R"("messages":[{"role":"user","content":"hi"}])", false},
{"/completion", R"("prompt":"hi")", false},
};
server.backend->chat_token_count = 1;
const auto post = [&](const Endpoint& endpoint, const std::string& model) {
return server.Post(endpoint.path, R"({"model":")" + model + R"(",)" +
endpoint.fields + "}");
};
// One name serves as before: it alone is listed and accepted.
assert(listed() == std::vector<std::string>{"test"});
for (const auto& endpoint : endpoints) {
const auto response = post(endpoint, "test");
ExpectStatus(response, 200);
assert(!endpoint.echoes || body_of(response).member_str("model") == "test");
const auto unknown = post(endpoint, "additional");
ExpectStatus(unknown, 404);
assert(unknown.find("model_not_found") != std::string::npos);
}
server.backend->names = {"test", "additional", "books"};
assert(listed() == server.backend->names);
for (const auto& endpoint : endpoints) {
for (const auto& model : server.backend->names) {
const auto response = post(endpoint, model);
ExpectStatus(response, 200);
assert(!endpoint.echoes ||
body_of(response).member_str("model") == model);
}
const auto unknown = post(endpoint, "other");
ExpectStatus(unknown, 404);
assert(unknown.find("model_not_found") != std::string::npos);
}
// Streamed events carry the additional name the request selected.
const auto streamed = server.Post(
"/v1/responses", R"({"model":"books","input":"hi","stream":true})");
ExpectStatus(streamed, 200);
assert(streamed.find("text/event-stream") != std::string::npos &&
streamed.find(R"("model":"books")") != std::string::npos &&
streamed.find(R"("model":"test")") == std::string::npos);
// Without a model field the primary name answers.
assert(body_of(server.Post("/v1/responses", R"({"input":"hi"})"))
.member_str("model") == "test");
server.backend->names = {"test"};
server.backend->chat_token_count.reset();
}
void TestRawCompletionStreaming() {
RunningServer server;
using gufo::json::parse;
const auto models = server.Send("GET /v1/models HTTP/1.1\r\n\r\n");
ExpectStatus(models, 200);
const auto listing = parse(models.substr(models.find("\r\n\r\n") + 4));
assert(listing.find("data")->items()[0].member_size("context_length") ==
65536);
const auto response = server.Post(
"/v1/completions",
R"({"prompt":"hello","max_tokens":256,"stream":true,"stream_options":{"include_usage":true},"ignore_eos":true})");
ExpectStatus(response, 200);
assert(response.find("text/event-stream") != std::string::npos);
std::vector<gufo::json::Value> events;
std::size_t offset = 0;
while ((offset = response.find("data: ", offset)) != std::string::npos) {
const auto begin = offset + 6;
const auto end = response.find("\n\n", begin);
assert(end != std::string::npos);
const auto data = std::string_view(response).substr(begin, end - begin);
if (data != "[DONE]")
events.push_back(parse(data));
offset = end + 2;
}
assert(events.size() == 3);
const auto& content = events[0];
assert(content.member_str("object") == "text_completion");
assert(content.find("choices")->items().size() == 1);
assert(content.find("choices")->items()[0].member_str("text") == "ok");
assert(content.find("choices")->items()[0].find("finish_reason")->is_null());
assert(content.find("usage") == nullptr);
const auto& terminal = events[1];
assert(terminal.find("choices")->items().size() == 1);
assert(terminal.find("choices")->items()[0].member_str("text").empty());
assert(terminal.find("choices")->items()[0].member_str("finish_reason") ==
"stop");
assert(terminal.find("usage") == nullptr);
const auto* timings = terminal.find("timings");
assert(timings != nullptr);
assert(timings->member_size("prompt_n") == 2);
assert(timings->member_double("prompt_ms") == 4);
assert(timings->member_size("predicted_n") == 1);
assert(timings->member_size("cache_n") == 8);
const auto& usage = events[2];
assert(usage.find("choices")->items().empty());
assert(usage.find("usage")->member_size("completion_tokens") == 1);
assert(usage.find("usage")->member_size("cached_tokens") == 8);
assert(response.find("data: [DONE]\n\n") != std::string::npos);
assert(server.backend->last_ignore_eos);
assert(server.backend->LastCall().max_tokens == 256);
const auto without_usage =
server.Post("/v1/completions", R"({"prompt":"hello","stream":true})");
ExpectStatus(without_usage, 200);
assert(without_usage.find("\"timings\":") != std::string::npos);
assert(without_usage.find("\"usage\":") == std::string::npos);
for (const int failure : {1, 5, 6}) {
server.backend->failure = failure;
const std::string message =
failure == 5 ? "generation failed" : "context exceeded";
for (
const auto& [path, body] : {
std::pair{
"/v1/completions",
R"({"prompt":"hello","stream":true,"stream_options":{"include_usage":true}})"},
std::pair{
"/v1/chat/completions",
R"({"model":"test","messages":[{"role":"user","content":"hello"}],"stream":true})"},
std::pair{"/v1/responses", R"({"input":"hello","stream":true})"},
}) {
const auto failed = server.Post(path, body);
ExpectStatus(failed, failure == 6 ? 200 : 500);
assert(failed.find("\"message\":\"" + message + "\"") !=
std::string::npos);
if (failure != 6) {
assert(failed.find("Content-Type: application/json") !=
std::string::npos);
assert(failed.find("data: ") == std::string::npos);
assert(failed.find("\"code\":\"server_exception\"") !=
std::string::npos);
continue;
}
const bool responses = std::string_view(path) == "/v1/responses";
assert(failed.find(responses ? "\"code\":\"server_error\""
: "\"code\":\"generation_failed\"") !=
std::string::npos);
assert(failed.find(responses ? "event: response.failed"
: "data: [DONE]\n\n") != std::string::npos);
assert(failed.find("event: response.completed") == std::string::npos);
assert(failed.ends_with("0\r\n\r\n"));
}
}
server.backend->failure = 0;
for (
const auto* body : {
R"({"prompt":"hello","stream_options":{"include_usage":true}})",
R"({"prompt":"hello","stream":true,"stream_options":{"include_usage":"true"}})",
R"({"prompt":"hello","stream":true,"stream_options":{"other":true}})",
R"({"prompt":"hello","ignore_eos":1})",
R"({"prompt":"hello","stream":true,"text":{"format":{"type":"json_object"}}})",
R"({"prompt":"hello","stream":true,"reasoning":{"effort":"none"}})",
})
ExpectStatus(server.Post("/v1/completions", body), 400);
// Raw Completions owns the fixed-length contract; every other text endpoint
// rejects the field instead of silently generating a shorter run.
ExpectStatus(
server.Post(
"/v1/chat/completions",
R"({"model":"test","messages":[{"role":"user","content":"hi"}],"max_tokens":8,"ignore_eos":true})"),
400);
}
// A generic failure keeps its cause in the server log, not only in the
// client's response, whether it happens before or after streaming starts.
void TestGenerationFailureLogsCause() {
std::ostringstream output;
auto* previous_sink = std::clog.rdbuf(output.rdbuf());
{
RunningServer server;
for (const int failure : {1, 6}) {
server.backend->failure = failure;
for (const auto* path :
{"/v1/completions", "/v1/chat/completions", "/v1/responses"}) {
const std::string body =
std::string_view(path) == "/v1/chat/completions"
? R"({"model":"test","messages":[{"role":"user","content":"hello"}],"stream":true})"
: std::string_view(path) == "/v1/responses"
? R"({"input":"hello","stream":true})"
: R"({"prompt":"hello","stream":true})";
ExpectStatus(server.Post(path, body), failure == 6 ? 200 : 500);
}
}
}
std::clog.rdbuf(previous_sink);
const std::string log = output.str();
const auto count = [&log](std::string_view needle) {
std::size_t found = 0;
for (auto at = log.find(needle); at != std::string::npos;
at = log.find(needle, at + 1))
++found;
return found;
};
// Before streaming, the HTTP handler logs the cause with the request ID.
assert(count("event=server_exception reason=context exceeded") == 3);
// After streaming starts, each endpoint logs it once beside its stable code.
assert(count("[ERROR] [chat] context exceeded") == 3);
}
void TestDeviceLoss() {
std::atomic<int> hook_calls{0};
RunningServer server({.on_device_lost = [&] { ++hook_calls; }});
const std::string chat =
R"({"model":"test","messages":[{"role":"user","content":"hi"}]})";
// A failure while the device still accepts work keeps the existing contract.
server.backend->failure = 4;
const auto failed = server.Post("/v1/completions", R"({"prompt":"hello"})");
ExpectStatus(failed, 500);
assert(failed.find("\"code\":\"server_exception\"") != std::string::npos);
ExpectStatus(server.Send("GET /health HTTP/1.1\r\n\r\n"), 200);
ExpectStatus(server.Send("GET /ready HTTP/1.1\r\n\r\n"), 200);
assert(hook_calls == 0);
// A stream has committed its status; the terminal event names the loss.
server.backend->failure = 7;
const auto stream =
server.Post("/v1/completions", R"({"prompt":"hello","stream":true})");
ExpectStatus(stream, 200);
assert(stream.find("\"code\":\"device_lost\"") != std::string::npos);
assert(stream.find(gufo::server::kDeviceLostMessage) != std::string::npos);
assert(hook_calls == 1);
server.backend->failure = 0;
for (const std::string path : {"/health", "/v1/health", "/healthz", "/ready",
"/v1/ready", "/readyz"}) {
const auto response = server.Send("GET " + path + " HTTP/1.1\r\n\r\n");
ExpectStatus(response, 503);
assert(response.find("\"status\":\"device_lost\"") != std::string::npos);
assert(response.find("\"code\":\"device_lost\"") != std::string::npos);
}
const int calls = server.backend->calls;
for (const auto& [path, body] :
{std::pair<std::string, std::string>{"/v1/chat/completions", chat},
{"/v1/completions", R"({"prompt":"hello","stream":true})"},
{"/v1/embeddings", R"({"input":"hello"})"}}) {
const auto response = server.Post(path, body);
ExpectStatus(response, 503);
assert(response.find("\"code\":\"device_lost\"") != std::string::npos);
assert(response.find("Retry-After") == std::string::npos);
}
assert(server.backend->calls == calls);
// Routes that never reach the device keep answering.
ExpectStatus(server.Send("GET /v1/models HTTP/1.1\r\n\r\n"), 200);
ExpectStatus(server.Send("GET /metrics HTTP/1.1\r\n\r\n"), 200);
assert(hook_calls == 1);
// Before a response starts, the failing request itself gets the 503.
std::atomic<int> first_hook_calls{0};
RunningServer first({.on_device_lost = [&] { ++first_hook_calls; }});
first.backend->failure = 3;
for (const auto& [path, body] :
{std::pair<std::string, std::string>{"/v1/completions",
R"({"prompt":"hello"})"},
{"/v1/chat/completions", chat}}) {
const auto response = first.Post(path, body);
ExpectStatus(response, 503);
assert(response.find("\"code\":\"device_lost\"") != std::string::npos);
assert(response.find(gufo::server::kDeviceLostMessage) !=
std::string::npos);
assert(response.find("Retry-After") == std::string::npos);
}
assert(first.backend->calls == 1);
assert(first_hook_calls == 1);
for (
const auto& [path, body] :
{std::pair{"/v1/completions", R"({"prompt":"hello","stream":true})"},
std::pair{
"/v1/chat/completions",
R"({"model":"test","messages":[{"role":"user","content":"hello"}],"stream":true})"},
std::pair{"/v1/responses", R"({"input":"hello","stream":true})"}}) {
RunningServer early;
early.backend->failure = 3;
const auto response = early.Post(path, body);
ExpectStatus(response, 503);
assert(response.find("\"code\":\"device_lost\"") != std::string::npos);
assert(response.find("data: ") == std::string::npos);
}
}
void TestDeviceLossWhileWriterBlocked() {
std::atomic<int> hook_calls{0};
std::binary_semaphore hook_called{0};
std::binary_semaphore writing{0};
std::atomic<bool> write_finished{false};
RunningServer server({.sse_heartbeat_interval = std::chrono::milliseconds(0),
.on_device_lost = [&] {
++hook_calls;
hook_called.release();
}});
server.server.add("POST", "/blocked", [&](const auto&, auto&) {
return gufo::server::HttpResponse{
.headers = {{"Content-Type", "text/event-stream"}},
.streaming_body = [&](const auto& write) {
const std::string chunk(8 * 1024 * 1024, 'x');
writing.release();
(void)write(chunk);
write_finished = true;
}};
});
const int fd = server.Connect();
const int receive_buffer = 1024;
assert(::setsockopt(fd, SOL_SOCKET, SO_RCVBUF,
reinterpret_cast<const char*>(&receive_buffer),
sizeof(receive_buffer)) == 0);
const std::string request = "POST /blocked HTTP/1.1\r\n\r\n";
assert(net::SendNoSignal(fd, request.data(), request.size()) ==
static_cast<std::intptr_t>(request.size()));
assert(writing.try_acquire_for(std::chrono::seconds(2)));
// The peer never drains the response. No health request or completed write
// is available to trigger shutdown; the listener must observe the loss.
server.backend->lost = true;
assert(hook_called.try_acquire_for(std::chrono::seconds(2)));
assert(hook_calls == 1 && !write_finished);
net::ShutdownSocket(fd, net::ShutdownHow::kReadWrite);
net::CloseSocket(fd);
}
void TestInvalidBindSettings() {
for (const int port : {-1, 65536}) {
HttpServer server("127.0.0.1", port, nullptr);
std::string error;
assert(!server.start(&error));
assert(!error.empty());
}
HttpServer invalid("bad.address", 0, nullptr);
std::string error;
assert(!invalid.start(&error));
assert(!error.empty());
}
// Some CI containers have no IPv6 loopback; listener checks skip there.
bool HasIpv6Loopback() {
const net::Socket fd = net::CreateStreamSocket(AF_INET6);
if (fd == net::kInvalidSocket)
return false;
sockaddr_in6 address{};
address.sin6_family = AF_INET6;
address.sin6_addr = in6addr_loopback;
const bool bound = ::bind(fd, reinterpret_cast<const sockaddr*>(&address),
sizeof(address)) == 0;
net::CloseSocket(fd);
return bound;
}
void TestListenAddresses() {
using gufo::server::IsListenAddress;
for (const char* host : {"127.0.0.1", "0.0.0.0", "::", "::1", "fe80::1%lo"})
assert(IsListenAddress(host));
for (const char* host : {"", "localhost", "127.1", "[::1]", "bad.address"})
assert(!IsListenAddress(host));
if (!HasIpv6Loopback()) {
std::cout << "IPv6 loopback unavailable; skipping IPv6 listener checks.\n";
return;
}
const auto peer = [](RunningServer& server, const std::string& address) {
const auto response =
server.Send("GET /peer HTTP/1.1\r\n\r\n", false, address);
ExpectStatus(response, 200);
return response.substr(response.find("\r\n\r\n") + 4);
};
{
RunningServer loopback({}, false, "::1");
assert(peer(loopback, "::1") == "::1");
}
// `::` is dual-stack, and an IPv4 peer keeps its IPv4 client ID.
RunningServer dual({}, false, "::");
assert(peer(dual, "::1") == "::1");
assert(peer(dual, "127.0.0.1") == "127.0.0.1");
}
// Binds port 0 to find a free port, then releases it for the code under test.
int FreePort(const std::string& host = "127.0.0.1") {
sockaddr_storage address{};
net::SocklenT length = 0;
auto* ipv4 = reinterpret_cast<sockaddr_in*>(&address);
auto* ipv6 = reinterpret_cast<sockaddr_in6*>(&address);
if (::inet_pton(AF_INET, host.c_str(), &ipv4->sin_addr) == 1) {
ipv4->sin_family = AF_INET;
length = sizeof(*ipv4);
} else {
assert(::inet_pton(AF_INET6, host.c_str(), &ipv6->sin6_addr) == 1);
ipv6->sin6_family = AF_INET6;
length = sizeof(*ipv6);
}
const net::Socket fd = net::CreateStreamSocket(address.ss_family);
assert(fd != net::kInvalidSocket);
assert(::bind(fd, reinterpret_cast<const sockaddr*>(&address), length) == 0);
assert(::getsockname(fd, reinterpret_cast<sockaddr*>(&address), &length) ==
0);
net::CloseSocket(fd);
return ntohs(address.ss_family == AF_INET6 ? ipv6->sin6_port
: ipv4->sin_port);
}
net::Socket ConnectTo(const std::string& host, int port) {
sockaddr_storage address{};
net::SocklenT length = 0;
auto* ipv4 = reinterpret_cast<sockaddr_in*>(&address);
auto* ipv6 = reinterpret_cast<sockaddr_in6*>(&address);
if (::inet_pton(AF_INET, host.c_str(), &ipv4->sin_addr) == 1) {
ipv4->sin_family = AF_INET;
ipv4->sin_port = htons(static_cast<std::uint16_t>(port));
length = sizeof(*ipv4);
} else {
assert(::inet_pton(AF_INET6, host.c_str(), &ipv6->sin6_addr) == 1);
ipv6->sin6_family = AF_INET6;
ipv6->sin6_port = htons(static_cast<std::uint16_t>(port));
length = sizeof(*ipv6);
}
const net::Socket fd = net::CreateStreamSocket(address.ss_family);
assert(fd != net::kInvalidSocket);
assert(net::SetSocketTimeouts(fd, 3'000));
if (::connect(fd, reinterpret_cast<const sockaddr*>(&address), length) != 0) {
net::CloseSocket(fd);
return net::kInvalidSocket;
}
return fd;
}
// Sends each part separately, pausing between them, and reads to EOF.
std::string SendParts(const std::string& host, int port,
const std::vector<std::string>& parts) {
const net::Socket fd = ConnectTo(host, port);
assert(fd != net::kInvalidSocket);
for (std::size_t index = 0; index < parts.size(); ++index) {
if (index > 0)
std::this_thread::sleep_for(std::chrono::milliseconds(50));
assert(net::SendNoSignal(fd, parts[index].data(), parts[index].size()) ==
static_cast<std::intptr_t>(parts[index].size()));
}
std::string response;
char buffer[4096];
for (;;) {
const auto count = net::Receive(fd, buffer, sizeof(buffer));
assert(count >= 0);
if (count == 0)
break;
response.append(buffer, static_cast<std::size_t>(count));
}
net::CloseSocket(fd);
return response;
}
void ExpectLoading(const std::string& response) {
ExpectStatus(response, 503);
assert(response.find("\r\nRetry-After: 1\r\n") != std::string::npos);
const auto body = response.substr(response.find("\r\n\r\n") + 4);
assert(body ==
R"({"error":{"message":"Loading model","type":"unavailable_error",)"
R"("code":"loading"}})");
}
void TestLoadingResponder() {
using gufo::server::LoadingResponder;
// Nothing to hold for an ephemeral port or an address HttpServer rejects.
assert(!LoadingResponder("127.0.0.1", 0).listening());
assert(!LoadingResponder("127.0.0.1", 65536).listening());
assert(!LoadingResponder("localhost", FreePort()).listening());
const int port = FreePort();
LoadingResponder loading("127.0.0.1", port);
assert(loading.listening());
// Health and readiness included: a 200 would read as ready to llama-swap
// and other pollers, then their requests would fail.
for (const std::string path : {"/health", "/v1/health", "/healthz", "/ready",
"/v1/ready", "/readyz", "/v1/models"}) {
ExpectLoading(
SendParts("127.0.0.1", port, {"GET " + path + " HTTP/1.1\r\n\r\n"}));
}
ExpectLoading(SendParts(
"127.0.0.1", port,
{"POST /v1/chat/completions HTTP/1.1\r\nContent-Length: 2\r\n\r\n{}"}));
// A request head split across segments is answered after it completes.
ExpectLoading(
SendParts("127.0.0.1", port, {"GET /hea", "lth HTTP/1.1\r\n", "\r\n"}));
// The port is held: the real server cannot bind it until stop().
{
HttpServer early("127.0.0.1", port, nullptr);
std::string error;
assert(!early.start(&error));
}
// A client that connects and sends nothing delays neither the next client
// nor stop() beyond the bounded request wait.
const int idle = ConnectTo("127.0.0.1", port);
assert(idle >= 0);
auto started = std::chrono::steady_clock::now();
ExpectLoading(SendParts("127.0.0.1", port, {"GET /health HTTP/1.1\r\n\r\n"}));
assert(std::chrono::steady_clock::now() - started < std::chrono::seconds(2));
const int idle_during_stop = ConnectTo("127.0.0.1", port);
assert(idle_during_stop >= 0);
started = std::chrono::steady_clock::now();
loading.stop();
assert(std::chrono::steady_clock::now() - started < std::chrono::seconds(2));
assert(!loading.listening());
net::CloseSocket(idle);
net::CloseSocket(idle_during_stop);
// Handoff: the real server binds the same address and answers health.
HttpServer server("127.0.0.1", port, nullptr);
std::string error;
assert(server.start(&error));
std::jthread worker([&server] { server.run(); });
const auto health =
SendParts("127.0.0.1", port, {"GET /health HTTP/1.1\r\n\r\n"});
ExpectStatus(health, 200);
assert(health.ends_with(R"({"status":"ok"})"));
server.stop();
worker.join();
// A port already in use leaves the responder idle and the owner unaffected.
{
RunningServer owner;
LoadingResponder blocked("127.0.0.1", owner.server.port());
assert(!blocked.listening());
ExpectStatus(owner.Send("GET /health HTTP/1.1\r\n\r\n"), 200);
}
if (!HasIpv6Loopback()) {
std::cout << "IPv6 loopback unavailable; skipping IPv6 loading checks.\n";
return;
}
const int ipv6_port = FreePort("::1");
LoadingResponder ipv6("::1", ipv6_port);
assert(ipv6.listening());
ExpectLoading(SendParts("::1", ipv6_port, {"GET /health HTTP/1.1\r\n\r\n"}));
}
void TestCompatibilityUtf8() {
RunningServer server;
server.backend->SetOutput("é中😀\xE2\x94!\xF0\x9F");
const std::string expected = "é中😀\xEF\xBF\xBD!\xEF\xBF\xBD";
for (const auto& [path, input] : {
std::pair{"/v1/completions", R"({"prompt":"hi"})"},
std::pair{"/v1/responses", R"({"input":"hi"})"},
std::pair{"/v1/messages",
R"({"messages":[{"role":"user","content":"hi"}]})"},
std::pair{"/completion", R"({"prompt":"hi"})"},
}) {
const auto response = server.Post(path, input);
ExpectStatus(response, 200);
const auto output =
gufo::json::parse(response.substr(response.find("\r\n\r\n") + 4));
std::string text;
if (std::string_view(path) == "/v1/completions")
text = output.find("choices")->items()[0].member_str("text");
else if (std::string_view(path) == "/v1/responses")
text = output.find("output")
->items()[0]
.find("content")
->items()[0]
.member_str("text");
else if (std::string_view(path) == "/v1/messages")
text = output.find("content")->items()[0].member_str("text");
else
text = output.member_str("content");
assert(text == expected);
}
}
void TestQueryParameters() {
gufo::server::HttpRequest request;
request.query = "notafter=wrong&note=after=wrong&after=right+value%26x";
assert(request.query_param("after") == "right value&x");
request.query = "notafter=wrong&note=after=wrong";
assert(request.query_param("after").empty());
request.query = "%61fter=encoded&broken=%xz&empty";
assert(request.query_param("after") == "encoded");
assert(request.query_param("broken") == "%xz");
assert(request.query_param("empty").empty());
}
void TestPeerDisconnect() {
RunningServer server;
server.backend->wait_for_disconnect = true;
const int fd = server.Connect();
const std::string body = R"({"prompt":"hi","max_tokens":128})";
const std::string request =
"POST /v1/completions HTTP/1.1\r\nHost: localhost\r\n"
"Content-Type: application/json\r\nContent-Length: " +
std::to_string(body.size()) + "\r\n\r\n" + body;
assert(net::SendNoSignal(fd, request.data(), request.size()) ==
static_cast<std::intptr_t>(request.size()));
assert(server.backend->entered.try_acquire_for(std::chrono::seconds(2)));
net::CloseSocket(fd);
assert(server.backend->finished.try_acquire_for(std::chrono::seconds(2)));
assert(server.backend->disconnected);
}
void TestCompatibilityStopSequences() {
RunningServer server;
server.backend->forced_stop_sequence = "END";
for (
const auto& [path, body] : {
std::pair{"/v1/completions", R"({"prompt":"hello","stop":"END"})"},
std::pair{"/completion", R"({"prompt":"hello","stop":["END"]})"},
std::pair{
"/v1/messages",
R"({"messages":[{"role":"user","content":"hello"}],"max_tokens":32,"stop_sequences":["END"]})"},
}) {
const auto response = server.Post(path, body);
ExpectStatus(response, 200);
assert(server.backend->LastCall().stop_sequences ==
std::vector<std::string>{"END"});
const auto output =
gufo::json::parse(response.substr(response.find("\r\n\r\n") + 4));
if (std::string_view(path) == "/v1/messages") {
assert(output.member_str("stop_reason") == "stop_sequence");
assert(output.member_str("stop_sequence") == "END");
} else if (std::string_view(path) == "/completion") {
assert(output.find("stopped_word")->as_bool());
assert(!output.find("stopped_eos")->as_bool());
assert(output.member_str("stopping_word") == "END");
} else {
assert(output.find("choices")->items()[0].member_str("finish_reason") ==
"stop");
}
}
const auto before = server.backend->calls.load();
for (const auto* stop : {"null", "\"END\"", "[null]", "[\"\"]", "true"}) {
auto body = gufo::json::parse(
R"({"messages":[{"role":"user","content":"hello"}],"max_tokens":32})");
body["stop_sequences"] = gufo::json::parse(stop);
ExpectStatus(server.Post("/v1/messages", body.dump()), 400);
}
ExpectStatus(server.Post("/v1/responses", R"({"input":"hi","stop":"END"})"),
400);
assert(server.backend->calls == before);
auto body = gufo::json::parse(
R"({"messages":[{"role":"user","content":"hello"}],"max_tokens":32,"stop_sequences":[]})");
for (int i = 0; i < 65; ++i)
body["stop_sequences"].push_back(std::to_string(i));
ExpectStatus(server.Post("/v1/messages", body.dump()), 400);
body["stop_sequences"] = gufo::json::Value::array();
for (int i = 0; i < 5; ++i)
body["stop_sequences"].push_back(std::string(4096, 'x'));
ExpectStatus(server.Post("/v1/messages", body.dump()), 400);
server.backend->forced_stop_sequence.clear();
for (const int limit : {1, 32}) {
body["stop_sequences"] = gufo::json::Value::array();
body["max_tokens"] = limit;
const auto response = server.Post("/v1/messages", body.dump());
ExpectStatus(response, 200);
const auto output =
gufo::json::parse(response.substr(response.find("\r\n\r\n") + 4));
assert(output.member_str("stop_reason") ==
(limit == 1 ? "max_tokens" : "end_turn"));
assert(output.find("stop_sequence")->is_null());
}
}
void TestCompatibilityThinkingDefaults() {
RunningServer server;
for (const bool enabled : {false, true}) {
server.backend->reasoning = {
.enabled = enabled,
.effort = gufo::ReasoningEffort::kHigh,
.preserve_thinking = true,
};
for (
const auto& [path, body] : {
std::pair{"/v1/responses", R"({"input":"hello"})"},
std::pair{
"/v1/messages",
R"({"messages":[{"role":"user","content":"hello"}],"max_tokens":32})"},
}) {
ExpectStatus(server.Post(path, body), 200);
const auto reasoning = server.backend->LastCall().chat.reasoning;
assert(reasoning.enabled == enabled);
assert(reasoning.effort == gufo::ReasoningEffort::kHigh);
assert(reasoning.preserve_thinking == true);
}
}
}
void TestResponseSamplingDefaults() {
RunningServer server;
using gufo::sampling::TextModelPreset;
for (const auto model :
{TextModelPreset::kQwen38, TextModelPreset::kDeepSeekV4Flash}) {
server.backend->defaults.model = model;
for (const bool server_thinking : {false, true}) {
server.backend->reasoning.enabled = server_thinking;
for (const char* effort :
{"null", "\"none\"", "\"minimal\"", "\"low\"", "\"medium\"",
"\"high\"", "\"xhigh\"", "\"max\""}) {
auto body = gufo::json::parse(
R"({"input":"hello","max_output_tokens":1,"temperature":null,"top_p":null,"presence_penalty":null,"reasoning":{},"text":{"format":{"type":"json_object"}}})");
body["reasoning"]["effort"] = gufo::json::parse(effort);
const bool thinking = std::string_view(effort) == "null"
? server_thinking
: std::string_view(effort) != "\"none\"";
const bool qwen_off = model == TextModelPreset::kQwen38 && !thinking;
server.backend->defaults.supplied = {};
ExpectStatus(server.Post("/v1/responses", body.dump()), 200);
auto call = server.backend->LastCall();
assert(call.chat.reasoning.enabled == thinking);
assert(call.chat.response_format);
assert(call.sampling.temperature == (qwen_off ? 0.7F : 1.0F));
assert(call.sampling.top_p == (qwen_off ? 0.8F : 0.95F));
assert(call.sampling.presence_penalty == (qwen_off ? 1.5F : 0.0F));
assert(call.sampling.top_k ==
(model == TextModelPreset::kQwen38 ? 20 : 0));
// Explicit CLI values survive reasoning changes and SDK nulls.
server.backend->defaults.sampling.temperature = 0.25F;
server.backend->defaults.sampling.top_k = 0;
server.backend->defaults.supplied = {.temperature = true,
.top_k = true};
ExpectStatus(server.Post("/v1/responses", body.dump()), 200);
call = server.backend->LastCall();
assert(call.sampling.temperature == 0.25F && call.sampling.top_k == 0);
assert(call.sampling.top_p == (qwen_off ? 0.8F : 0.95F));
// Per-request zero overrides both CLI and model values, also streamed.
body["temperature"] = 0;
body["presence_penalty"] = 0;
body["top_k"] = 3;
body["stream"] = true;
ExpectStatus(server.Post("/v1/responses", body.dump()), 200);
call = server.backend->LastCall();
assert(call.sampling.temperature == 0 && call.sampling.top_k == 3);
assert(call.sampling.presence_penalty == 0);
}
}
}
// Responses must preserve the same request-owned cache control as Chat.
auto body = gufo::json::parse(R"({"input":"hello","max_output_tokens":1})");
ExpectStatus(server.Post("/v1/responses", body.dump()), 200);
assert(server.backend->LastCall().chat.cache_prompt);
for (const bool stream : {false, true}) {
body["stream"] = stream;
for (const bool cache : {false, true}) {
body["cache_prompt"] = cache;
ExpectStatus(server.Post("/v1/responses", body.dump()), 200);
assert(server.backend->LastCall().chat.cache_prompt == cache);
}
}
// As in llama-server, null keeps the default.
body["cache_prompt"] = false;
ExpectStatus(server.Post("/v1/responses", body.dump()), 200);
body["cache_prompt"] = gufo::json::Value();
ExpectStatus(server.Post("/v1/responses", body.dump()), 200);
assert(server.backend->LastCall().chat.cache_prompt);
for (const char* invalid : {"0", "\"false\""}) {
body["cache_prompt"] = gufo::json::parse(invalid);
const int calls = server.backend->calls;
ExpectStatus(server.Post("/v1/responses", body.dump()), 400);
assert(server.backend->calls == calls);
}
}
void TestStreamingFraming() {
RunningServer server;
const std::string chunks = std::string("3\r\na\0b\r\n", 8) + "3\r\nend\r\n";
for (const std::string body : {"", "fail", "reported"}) {
const auto response = server.Post("/stream", body);
ExpectStatus(response, 200);
assert(response.find("Transfer-Encoding: chunked\r\n") !=
std::string::npos);
assert(response.substr(response.find("\r\n\r\n") + 4) ==
chunks + (body == "fail" ? "" : "0\r\n\r\n"));
}
const auto thrown = server.Post("/stream-error", "");
assert(thrown.substr(thrown.find("\r\n\r\n") + 4) == "b\r\nfirst chunk\r\n");
// Existing HTTP/1.0 clients retain close-delimited framing.
const auto legacy = server.Send("POST /stream HTTP/1.0\r\n\r\n");
assert(legacy.find("Transfer-Encoding:") == std::string::npos);
assert(legacy.substr(legacy.find("\r\n\r\n") + 4) ==
std::string("a\0bend", 6));
}
void TestSseHeartbeat() {
RunningServer server(
{.sse_heartbeat_interval = std::chrono::milliseconds(20)});
const auto response = server.Post("/sse-idle", "");
ExpectStatus(response, 200);
const auto first = response.find("data: first\n\n");
const auto ping = response.find(": ping\n\n", first);
const auto second_ping = response.find(": ping\n\n", ping + 1);
const auto last = response.find("data: last\n\n", second_ping);
assert(first != std::string::npos);
assert(ping != std::string::npos);
assert(second_ping != std::string::npos);
assert(last != std::string::npos);
assert(response.find("data: [DONE]\n\n", last) != std::string::npos);
assert(response.ends_with("0\r\n\r\n"));
const auto legacy = server.Send("POST /sse-idle HTTP/1.0\r\n\r\n");
assert(legacy.find("Transfer-Encoding:") == std::string::npos);
assert(legacy.find(": ping\n\n") != std::string::npos);
assert(legacy.ends_with("data: [DONE]\n\n"));
RunningServer disabled({.sse_heartbeat_interval = {}});
assert(disabled.Post("/sse-idle", "").find(": ping") == std::string::npos);
}
void TestDeferredStreamHeaders() {
RunningServer server(
{.sse_heartbeat_interval = std::chrono::milliseconds(5)});
server.server.add("POST", "/early-failure", [](const auto&, auto&) {
return gufo::server::HttpResponse{
.headers = {{"Content-Type", "text/event-stream"}},
.streaming_body =
[](const auto&) {
std::this_thread::sleep_for(std::chrono::milliseconds(30));
throw std::runtime_error("early generation failure");
},
.defer_stream_headers = true};
});
const auto failed = server.Post("/early-failure", "");
ExpectStatus(failed, 500);
assert(failed.find(": ping") == std::string::npos);
for (
const auto& [path, body] :
{std::pair{"/v1/completions", R"({"prompt":"hello","stream":true})"},
std::pair{
"/v1/chat/completions",
R"({"model":"test","messages":[{"role":"user","content":"hello"}],"stream":true})"},
std::pair{"/v1/responses", R"({"input":"hello","stream":true})"}}) {
server.backend->failure =
8; // Successful termination without a token callback.
server.backend->SetOutput("");
const auto empty = server.Post(path, body);
ExpectStatus(empty, 200);
assert(empty.ends_with("0\r\n\r\n"));
}
// Opt-in progress deliberately starts the stream before tokens exist.
server.backend->progress = {{.total = 100, .processed = 10}};
server.backend->failure = 4;
const auto progress =
server.Post("/v1/completions",
R"({"prompt":"hello","stream":true,"return_progress":true})");
ExpectStatus(progress, 200);
assert(progress.find("prompt_progress") != std::string::npos);
assert(progress.find("\"code\":\"generation_failed\"") != std::string::npos);
server.backend->progress.clear();
for (
const auto& [path, body] :
{std::pair{"/v1/completions",
R"({"prompt":"hello","stream":true,"return_progress":true})"},
std::pair{
"/v1/chat/completions",
R"({"model":"test","messages":[{"role":"user","content":"hello"}],"stream":true,"return_progress":true})"},
std::pair{
"/v1/responses",
R"({"input":"hello","stream":true,"return_progress":true})"}}) {
const auto failure = server.Post(path, body);
ExpectStatus(failure, 200);
assert(failure.find(std::string_view(path) == "/v1/responses"
? "\"code\":\"server_error\""
: "\"code\":\"generation_failed\"") !=
std::string::npos);
assert(failure.ends_with("0\r\n\r\n"));
}
}
void TestAdmittedStreamHeaders() {
RunningServer server(
{.sse_heartbeat_interval = std::chrono::milliseconds(5)});
server.backend->admit = true;
const std::pair<const char*, const char*> requests[] = {
{"/v1/completions", R"({"prompt":"hello","stream":true})"},
{"/v1/chat/completions",
R"({"model":"test","messages":[{"role":"user","content":"hello"}],"stream":true})"},
{"/v1/responses", R"({"input":"hello","stream":true})"}};
// Admission commits headers, so keepalives cover prefill before a token.
server.backend->token_delay = std::chrono::milliseconds(40);
for (const auto& [path, body] : requests) {
const auto response = server.Post(path, body);
ExpectStatus(response, 200);
const auto ping = response.find(": ping\n\n");
assert(ping != std::string::npos);
const auto token = response.find(R"("ok")");
assert(token != std::string::npos);
assert(ping < token);
assert(response.ends_with("0\r\n\r\n"));
}
// After admission, failures before any token are terminal SSE errors.
for (const auto& [path, body] : requests) {
RunningServer lost;
lost.backend->admit = true;
lost.backend->failure = 3;
const auto failed = lost.Post(path, body);
ExpectStatus(failed, 200);
assert(failed.find(gufo::server::kDeviceLostMessage) != std::string::npos);
assert(failed.find("data: ") != std::string::npos);
assert(failed.ends_with("0\r\n\r\n"));
}
}
void TestSseHeartbeatShutdown() {
// Quick completion and exceptions can request stop while the heartbeat
// thread is entering its wait. Cleanup must not wait for this deadline:
// Send() has a three-second socket timeout.
RunningServer server({.sse_heartbeat_interval = std::chrono::seconds(30)});
std::vector<std::jthread> clients;
for (int client = 0; client < 8; ++client) {
clients.emplace_back([&] {
for (int request = 0; request < 16; ++request) {
const bool fail = request % 2 != 0;
const auto response = server.Post("/sse-finish", fail ? "fail" : "");
ExpectStatus(response, 200);
assert(response.find("data: first\n\n") != std::string::npos);
assert(response.find(": ping") == std::string::npos);
assert(response.ends_with("0\r\n\r\n") == !fail);
assert((response.find("data: [DONE]\n\n") != std::string::npos) ==
!fail);
}
});
}
}
} // namespace
void TestRawCompletionCachePrompt() {
RunningServer server;
for (const auto* path : {"/v1/completions", "/completion"}) {
ExpectStatus(
server.Post(path, R"({"prompt":"hello","cache_prompt":false})"), 200);
assert(!server.backend->last_cache_prompt);
ExpectStatus(server.Post(path, R"({"prompt":"hello"})"), 200);
assert(server.backend->last_cache_prompt);
ExpectStatus(
server.Post(path, R"({"prompt":"hello","cache_prompt":false})"), 200);
ExpectStatus(server.Post(path, R"({"prompt":"hello","cache_prompt":true})"),
200);
assert(server.backend->last_cache_prompt);
// As in llama-server, null keeps the default.
ExpectStatus(
server.Post(path, R"({"prompt":"hello","cache_prompt":false})"), 200);
ExpectStatus(server.Post(path, R"({"prompt":"hello","cache_prompt":null})"),
200);
assert(server.backend->last_cache_prompt);
const auto before = server.backend->calls.load();
for (const auto* value : {"\"false\"", "0"}) {
const auto rejected = server.Post(
path,
std::string(R"({"prompt":"hello","cache_prompt":)") + value + "}");
ExpectStatus(rejected, 400);
assert(rejected.find("'cache_prompt' must be a boolean") !=
std::string::npos);
}
assert(server.backend->calls == before);
}
}
void TestRawCompletionPromptProgress() {
RunningServer server;
server.backend->progress = {
{.total = 4, .cache = 1, .processed = 4, .time_ms = 2}};
const auto response =
server.Post("/v1/completions",
R"({"prompt":"hello","stream":true,"return_progress":true})");
ExpectStatus(response, 200);
const auto progress = response.find(
R"("choices":[{"text":"","index":0,"logprobs":null,"finish_reason":null}],)"
R"("prompt_progress":{"total":4,"cache":1,"processed":4,"time_ms":2}})");
assert(progress != std::string::npos);
assert(progress < response.find(R"("text":"ok")"));
const auto plain =
server.Post("/v1/completions", R"({"prompt":"hello","stream":true})");
ExpectStatus(plain, 200);
assert(plain.find("prompt_progress") == std::string::npos);
const auto null_progress =
server.Post("/v1/completions",
R"({"prompt":"hello","stream":true,"return_progress":null})");
ExpectStatus(null_progress, 200);
assert(null_progress.find("prompt_progress") == std::string::npos);
const auto buffered = server.Post(
"/v1/completions", R"({"prompt":"hello","return_progress":true})");
ExpectStatus(buffered, 200);
assert(buffered.find("prompt_progress") == std::string::npos);
ExpectStatus(server.Post("/v1/completions",
R"({"prompt":"hello","return_progress":1})"),
400);
}
// A server with only an embedding service and no text backend answers
// /v1/embeddings behind the API key, lists the model, and reports readiness.
std::shared_ptr<gufo::server::EmbeddingService> MakeEmbeddingService(
bool ready) {
using gufo::server::EmbeddingBatch;
using gufo::server::EmbeddingServiceOptions;
using gufo::server::EmbeddingStatus;
return std::make_shared<gufo::server::EmbeddingService>(
EmbeddingServiceOptions{
.model_root = "/nonexistent/embeddinggemma",
.validate_model = !ready, // Validation fails without a checkpoint.
.model_id = "embedding-test",
.runner =
[](const std::vector<gufo::server::EmbeddingInput>& texts,
EmbeddingBatch* batch, std::string*) {
for (const auto& text : texts) {
batch->embeddings.push_back({1.0F, 0.0F});
std::size_t count = 2;
for (const auto& part : text.content)
count += part.value.size();
batch->token_counts.push_back(count);
}
return EmbeddingStatus::kOk;
},
});
}
std::string EmbeddingPost(int port, std::string_view body,
std::string_view extra_headers = {}) {
return SendParts(
"127.0.0.1", port,
{"POST /v1/embeddings HTTP/1.1\r\n" + std::string(extra_headers) +
"Content-Length: " + std::to_string(body.size()) + "\r\n\r\n" +
std::string(body)});
}
void TestEmbeddingService() {
const std::string request = R"({"model":"embedding-test","input":"hi"})";
{
auto service = MakeEmbeddingService(true);
assert(service->ready());
HttpServer server("127.0.0.1", 0, nullptr, nullptr, nullptr, nullptr,
{.api_key = "test-secret"}, nullptr, service);
std::string error;
assert(server.start(&error));
std::binary_semaphore finished{0};
std::jthread worker([&] {
server.run(false);
finished.release();
});
const int port = server.port();
ExpectStatus(EmbeddingPost(port, request), 401);
ExpectStatus(EmbeddingPost(port, request, "Authorization: Bearer nope\r\n"),
401);
const auto response =
EmbeddingPost(port, request, "Authorization: Bearer test-secret\r\n");
ExpectStatus(response, 200);
assert(response.find("\"object\":\"list\"") != std::string::npos);
assert(response.find("\"embedding\":[1,0]") != std::string::npos);
assert(response.find("\"prompt_tokens\":4") != std::string::npos);
assert(response.find("\"model\":\"embedding-test\"") != std::string::npos);
const auto missing_model =
EmbeddingPost(port, R"({"model":"other","input":"hi"})",
"Authorization: Bearer test-secret\r\n");
ExpectStatus(missing_model, 404);
assert(missing_model.find("\"code\":\"model_not_found\"") !=
std::string::npos);
const auto models = SendParts("127.0.0.1", port,
{"GET /v1/models HTTP/1.1\r\nAuthorization: "
"Bearer test-secret\r\n\r\n"});
ExpectStatus(models, 200);
assert(models.find("\"id\":\"embedding-test\"") != std::string::npos);
assert(models.find("\"capability\":\"embedding\"") != std::string::npos);
const auto ready = SendParts(
"127.0.0.1", port,
{"GET /ready HTTP/1.1\r\nAuthorization: Bearer test-secret\r\n\r\n"});
ExpectStatus(ready, 200);
assert(ready.find("\"model\":\"embedding-test\"") != std::string::npos);
// Routes of other modalities stay absent.
ExpectStatus(
SendParts("127.0.0.1", port,
{"POST /v1/chat/completions HTTP/1.1\r\nAuthorization: "
"Bearer test-secret\r\nContent-Length: 2\r\n\r\n{}"}),
404);
server.stop();
worker.join();
}
{
// A service that failed to start is not ready and answers 503.
auto service = MakeEmbeddingService(false);
assert(!service->ready());
HttpServer server("127.0.0.1", 0, nullptr, nullptr, nullptr, nullptr, {},
nullptr, service);
std::string error;
assert(server.start(&error));
std::jthread worker([&] { server.run(false); });
const int port = server.port();
const auto response = EmbeddingPost(port, request);
ExpectStatus(response, 503);
assert(response.find("\"code\":\"embedding_service_unavailable\"") !=
std::string::npos);
ExpectStatus(SendParts("127.0.0.1", port, {"GET /ready HTTP/1.1\r\n\r\n"}),
503);
const auto models =
SendParts("127.0.0.1", port, {"GET /v1/models HTTP/1.1\r\n\r\n"});
assert(models.find("embedding-test") == std::string::npos);
server.stop();
worker.join();
}
{
// Without an embedding service, a server with no text backend does not
// serve the route, and one with a backend keeps its stub.
HttpServer server("127.0.0.1", 0, nullptr);
std::string error;
assert(server.start(&error));
std::jthread worker([&] { server.run(false); });
ExpectStatus(EmbeddingPost(server.port(), request), 404);
server.stop();
worker.join();
}
{
RunningServer text;
const auto response = text.Post("/v1/embeddings", R"({"input":"hi"})");
assert(!response.starts_with("HTTP/1.1 200 "));
assert(response.find("embedding_service_unavailable") == std::string::npos);
}
{
// A lost device answers 503, runs the shutdown hook once, and fails the
// health probe from then on.
auto service = std::make_shared<gufo::server::EmbeddingService>(
gufo::server::EmbeddingServiceOptions{
.model_root = "/nonexistent/embeddinggemma",
.validate_model = false,
.model_id = "embedding-test",
.runner =
[](const std::vector<gufo::server::EmbeddingInput>&,
gufo::server::EmbeddingBatch*, std::string*) {
return gufo::server::EmbeddingStatus::kDeviceLost;
},
});
std::atomic<int> hook_calls{0};
HttpServer server("127.0.0.1", 0, nullptr, nullptr, nullptr, nullptr,
{.on_device_lost = [&] { ++hook_calls; }}, nullptr,
service);
std::string error;
assert(server.start(&error));
std::jthread worker([&] { server.run(false); });
const int port = server.port();
ExpectStatus(SendParts("127.0.0.1", port, {"GET /health HTTP/1.1\r\n\r\n"}),
200);
const auto lost = EmbeddingPost(port, request);
ExpectStatus(lost, 503);
assert(lost.find("\"code\":\"device_lost\"") != std::string::npos);
assert(hook_calls.load() == 1);
ExpectStatus(SendParts("127.0.0.1", port, {"GET /health HTTP/1.1\r\n\r\n"}),
503);
ExpectStatus(EmbeddingPost(port, request), 503);
assert(hook_calls.load() == 1);
server.stop();
worker.join();
}
{
// With both configured, the service answers instead of the text stub.
auto service = MakeEmbeddingService(true);
auto backend = std::make_shared<FakeBackend>();
HttpServer server("127.0.0.1", 0, backend, nullptr, nullptr, nullptr, {},
nullptr, service);
std::string error;
assert(server.start(&error));
std::jthread worker([&] { server.run(false); });
ExpectStatus(EmbeddingPost(server.port(), request), 200);
server.stop();
worker.join();
}
}
int main() {
// The log assertions below match "[LEVEL] [component]" text written to a
// redirected stderr sink, so the real stderr's TTY state must not add ANSI
// tint around the level tag.
gufo::platform::SetEnv("NO_COLOR", "1");
TestRequestLogging();
TestContentTrace();
TestQuietTiersSuppressLifecycle();
TestLogLevelFilter();
TestLogLevelNames();
TestInvalidBindSettings();
TestListenAddresses();
TestLoadingResponder();
TestQueryParameters();
TestAuthorization();
TestFramingAndMetrics();
TestExpectContinue();
TestFallbackBackendMetrics();
TestLlamaProps();
TestLlamaSlotsAndMetrics();
TestCompatibilityRequests();
TestTextOnlyBackendRefusesImagesBeforeReading();
TestResponsesToolImages();
TestModelInputModalities();
TestServedModelNames();
TestRawCompletionStreaming();
TestGenerationFailureLogsCause();
TestDeviceLoss();
TestDeviceLossWhileWriterBlocked();
TestRawCompletionPromptProgress();
TestRawCompletionCachePrompt();
TestCompatibilityStopSequences();
TestCompatibilityThinkingDefaults();
TestResponseSamplingDefaults();
TestCompatibilityUtf8();
TestPeerDisconnect();
TestStreamingFraming();
TestSseHeartbeat();
TestSseHeartbeatShutdown();
TestDeferredStreamHeaders();
TestAdmittedStreamHeaders();
TestEmbeddingService();
std::cout << "HTTP transport checks passed.\n";
}