Files

1406 lines
46 KiB
C++
Raw Permalink Normal View History

2026-10-14 09:18:19 +03:00
/*
Dynagate C++20 test suite (port of gateway_test.go)
Created by Luxferre in 2026, released into the public domain
*/
#ifndef _DARWIN_C_SOURCE
#define _DARWIN_C_SOURCE
#endif
#include "dynagate.hpp"
#include <atomic>
#include <cerrno>
#include <chrono>
#include <csignal>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <filesystem>
#include <fstream>
#include <functional>
#include <iostream>
#include <mutex>
#include <sstream>
#include <string>
#include <thread>
#include <vector>
#include <arpa/inet.h>
#include <curl/curl.h>
#include <netinet/in.h>
#include <poll.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <unistd.h>
namespace {
int g_failures = 0;
void fail_test(const char *test_name, int line, const std::string &msg) {
std::fprintf(stderr, " [FAIL] %s (line %d): %s\n",
test_name, line, msg.c_str());
++g_failures;
}
#define EXPECT_TRUE(cond, msg) \
do { \
if (!(cond)) { \
fail_test(__func__, __LINE__, (msg)); \
return; \
} \
} while (0)
#define EXPECT_EQ(actual, expected, msg) \
do { \
if (!((actual) == (expected))) { \
std::ostringstream _oss; \
_oss << (msg); \
fail_test(__func__, __LINE__, _oss.str()); \
return; \
} \
} while (0)
bool write_all_fd(int fd, std::string_view data) {
size_t written = 0;
while (written < data.size()) {
ssize_t n = write(fd, data.data() + written, data.size() - written);
if (n > 0) {
written += static_cast<size_t>(n);
} else if (n < 0 && errno == EINTR) {
continue;
} else {
return false;
}
}
return true;
}
class MockHttpServer {
public:
using HandlerFunc = std::function<void(const HttpRequest &req, int client_fd)>;
explicit MockHttpServer(HandlerFunc handler) : handler_(std::move(handler)) {
fd_ = socket(AF_INET, SOCK_STREAM, 0);
int one = 1;
setsockopt(fd_, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));
#ifdef SO_NOSIGPIPE
setsockopt(fd_, SOL_SOCKET, SO_NOSIGPIPE, &one, sizeof(one));
#endif
struct sockaddr_in addr;
std::memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
addr.sin_port = htons(0);
if (bind(fd_, reinterpret_cast<struct sockaddr *>(&addr), sizeof(addr)) <
0) {
std::perror("MockHttpServer bind");
std::exit(1);
}
if (listen(fd_, 64) < 0) {
std::perror("MockHttpServer listen");
std::exit(1);
}
struct sockaddr_in bound;
socklen_t len = sizeof(bound);
getsockname(fd_, reinterpret_cast<struct sockaddr *>(&bound), &len);
port_ = ntohs(bound.sin_port);
url_ = "http://127.0.0.1:" + std::to_string(port_);
thread_ = std::thread([this]() { serve_loop(); });
}
~MockHttpServer() {
stop_.store(true);
if (fd_ >= 0) {
shutdown(fd_, SHUT_RDWR);
close(fd_);
fd_ = -1;
}
if (thread_.joinable()) {
thread_.join();
}
}
const std::string &url() const { return url_; }
static void send_response(int fd,
int status_code,
std::string_view content_type,
std::string_view body) {
std::string resp = "HTTP/1.1 " + std::to_string(status_code) + " Status\r\n";
if (!content_type.empty()) {
resp += "Content-Type: " + std::string(content_type) + "\r\n";
}
resp += "Content-Length: " + std::to_string(body.size()) + "\r\n";
resp += "Connection: close\r\n\r\n";
resp.append(body.data(), body.size());
write_all_fd(fd, resp);
}
private:
void serve_loop() {
while (!stop_.load()) {
struct pollfd pfd;
pfd.fd = fd_;
pfd.events = POLLIN;
pfd.revents = 0;
int prc = poll(&pfd, 1, 50);
if (prc <= 0 || !(pfd.revents & POLLIN)) {
continue;
}
struct sockaddr_in caddr;
socklen_t clen = sizeof(caddr);
int cfd =
accept(fd_, reinterpret_cast<struct sockaddr *>(&caddr), &clen);
if (cfd < 0) {
continue;
}
HttpRequest req;
if (read_req(cfd, req)) {
handler_(req, cfd);
}
close(cfd);
}
}
static bool read_req(int fd, HttpRequest &req) {
std::string buf;
char tmp[4096];
size_t hend = std::string::npos;
for (;;) {
hend = buf.find("\r\n\r\n");
if (hend != std::string::npos) {
break;
}
ssize_t n = read(fd, tmp, sizeof(tmp));
if (n <= 0) {
return false;
}
buf.append(tmp, static_cast<size_t>(n));
}
std::string_view hblock(buf.data(), hend);
size_t fl = hblock.find("\r\n");
std::string_view rline =
(fl == std::string_view::npos) ? hblock : hblock.substr(0, fl);
size_t sp1 = rline.find(' ');
if (sp1 == std::string_view::npos) {
return false;
}
req.method = std::string(rline.substr(0, sp1));
std::string_view rest = rline.substr(sp1 + 1);
size_t sp2 = rest.find(' ');
req.path = std::string(
(sp2 == std::string_view::npos) ? rest : rest.substr(0, sp2));
size_t pos = (fl == std::string_view::npos) ? hblock.size() : fl + 2;
long clen = 0;
while (pos < hblock.size()) {
size_t le = hblock.find("\r\n", pos);
std::string_view line =
(le == std::string_view::npos)
? hblock.substr(pos)
: hblock.substr(pos, le - pos);
pos = (le == std::string_view::npos) ? hblock.size() : le + 2;
size_t colon = line.find(':');
if (colon != std::string_view::npos) {
std::string k = str_trim(line.substr(0, colon));
std::string v = str_trim(line.substr(colon + 1));
if (str_iequals(k, "Content-Length")) {
clen = std::atol(v.c_str());
}
req.headers.add(k, v);
}
}
size_t boff = hend + 4;
size_t avail = buf.size() - boff;
size_t needed = clen > 0 ? static_cast<size_t>(clen) : 0;
if (avail > 0) {
req.body.append(buf.data() + boff, std::min(avail, needed));
}
while (req.body.size() < needed) {
ssize_t n = read(fd, tmp, std::min(sizeof(tmp), needed - req.body.size()));
if (n <= 0) {
break;
}
req.body.append(tmp, static_cast<size_t>(n));
}
return true;
}
int fd_ = -1;
int port_ = 0;
std::string url_;
std::atomic<bool> stop_{false};
HandlerFunc handler_;
std::thread thread_;
};
std::filesystem::path make_temp_dir(std::string_view prefix) {
auto base = std::filesystem::temp_directory_path();
auto now = std::chrono::steady_clock::now().time_since_epoch().count();
auto path =
base / (std::string(prefix) + "-" + std::to_string(getpid()) + "-" +
std::to_string(now));
std::filesystem::create_directories(path);
return path;
}
struct TempDirGuard {
std::filesystem::path path;
explicit TempDirGuard(std::string_view prefix)
: path(make_temp_dir(prefix)) {}
~TempDirGuard() {
std::error_code ec;
std::filesystem::remove_all(path, ec);
}
};
void TestURLBuilding() {
struct TestCase {
std::string endpoint;
std::string req_path;
std::string expected;
};
std::vector<TestCase> tests = {
{"https://api.openai.com/v1", "/v1/chat/completions",
"https://api.openai.com/v1/chat/completions"},
{"https://api.openai.com/v1/", "/v1/chat/completions",
"https://api.openai.com/v1/chat/completions"},
{"https://api.openai.com", "/v1/chat/completions",
"https://api.openai.com/v1/chat/completions"},
{"http://localhost:8000/api/v1", "/v1/chat/completions",
"http://localhost:8000/api/v1/chat/completions"},
{"http://localhost:11434", "/v1/chat/completions",
"http://localhost:11434/v1/chat/completions"},
{"https://custom.endpoint/v1/custom", "/v1/chat/completions",
"https://custom.endpoint/v1/custom/v1/chat/completions"},
};
for (const auto &tc : tests) {
std::string got = build_url(tc.endpoint, tc.req_path);
EXPECT_EQ(got, tc.expected,
"build_url(" + tc.endpoint + ", " + tc.req_path + ") = " + got +
"; want " + tc.expected);
}
}
void TestTrialOrdering() {
std::vector<ModelConfig> configs = {
{"gpt-4", "key-a1", "ep-a", ""},
{"gpt-4", "key-a2", "ep-a", ""},
{"gpt-3.5", "key-b1", "ep-b", ""},
{"claude", "key-c1", "ep-c", ""},
};
std::vector<std::string> unique_models = {"gpt-4", "gpt-3.5", "claude"};
// Case 1: "gpt-4"
{
auto trials = get_trial_configs(configs, unique_models, "gpt-4");
std::vector<ModelConfig> expected = {
{"gpt-4", "key-a1", "ep-a", ""},
{"gpt-4", "key-a2", "ep-a", ""},
{"gpt-3.5", "key-b1", "ep-b", ""},
{"claude", "key-c1", "ep-c", ""},
};
EXPECT_EQ(trials, expected, "Trial ordering for gpt-4 mismatch");
}
// Case 2: "gpt-3.5"
{
auto trials = get_trial_configs(configs, unique_models, "gpt-3.5");
std::vector<ModelConfig> expected = {
{"gpt-3.5", "key-b1", "ep-b", ""},
{"claude", "key-c1", "ep-c", ""},
{"gpt-4", "key-a1", "ep-a", ""},
{"gpt-4", "key-a2", "ep-a", ""},
};
EXPECT_EQ(trials, expected, "Trial ordering for gpt-3.5 mismatch");
}
// Case 3: "claude"
{
auto trials = get_trial_configs(configs, unique_models, "claude");
std::vector<ModelConfig> expected = {
{"claude", "key-c1", "ep-c", ""},
{"gpt-4", "key-a1", "ep-a", ""},
{"gpt-4", "key-a2", "ep-a", ""},
{"gpt-3.5", "key-b1", "ep-b", ""},
};
EXPECT_EQ(trials, expected, "Trial ordering for claude mismatch");
}
// Case 4: "non-existent"
{
auto trials = get_trial_configs(configs, unique_models, "non-existent");
std::vector<ModelConfig> expected = {
{"gpt-4", "key-a1", "ep-a", ""},
{"gpt-4", "key-a2", "ep-a", ""},
{"gpt-3.5", "key-b1", "ep-b", ""},
{"claude", "key-c1", "ep-c", ""},
};
EXPECT_EQ(trials, expected, "Trial ordering for non-existent mismatch");
}
}
void TestConfigManagerCSVWatcher() {
TempDirGuard tmp("dynagate-test");
auto csv_file = (tmp.path / "models.csv").string();
std::string initial_content =
"model,key,endpoint,extra\n"
"gpt-4,key-1,http://localhost:8001,\"{\"\"User-Agent\"\":\"\"test/1.0\"\"}\"\n"
"gpt-3.5,key-2,http://localhost:8002,\n";
{
std::ofstream ofs(csv_file, std::ios::binary);
ofs << initial_content;
}
ConfigManager cm(csv_file);
auto configs = cm.get_configs();
EXPECT_EQ(configs.size(), 2u, "Expected 2 initial configs");
EXPECT_EQ(configs[0].model, "gpt-4", "Unexpected model 0");
EXPECT_EQ(configs[0].extra, "{\"User-Agent\":\"test/1.0\"}",
"Unexpected quoted extra JSON");
EXPECT_EQ(configs[1].model, "gpt-3.5", "Unexpected model 1");
std::atomic<bool> stop_flag{false};
std::thread watcher([&]() { cm.watch(stop_flag); });
std::this_thread::sleep_for(std::chrono::milliseconds(1100));
std::string updated_content =
"model,key,endpoint\n"
"gpt-4,key-1,http://localhost:8001\n"
"gpt-3.5,key-2,http://localhost:8002\n"
"claude,key-3,http://localhost:8003\n";
{
std::ofstream ofs(csv_file, std::ios::binary | std::ios::trunc);
ofs << updated_content;
}
auto deadline =
std::chrono::steady_clock::now() + std::chrono::seconds(3);
std::vector<ModelConfig> updated_configs;
while (std::chrono::steady_clock::now() < deadline) {
updated_configs = cm.get_configs();
if (updated_configs.size() == 3) {
break;
}
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
stop_flag.store(true);
if (watcher.joinable()) {
watcher.join();
}
EXPECT_EQ(updated_configs.size(), 3u, "Hot-reload failed to load 3 configs");
EXPECT_EQ(updated_configs[2].model, "claude",
"Hot-reload third model mismatch");
}
void TestGatewayCompletionsFailover() {
MockHttpServer server_a([](const HttpRequest &, int fd) {
MockHttpServer::send_response(
fd, 500, "application/json", R"({"error": "Internal Server Error A"})");
});
MockHttpServer server_b([](const HttpRequest &, int fd) {
MockHttpServer::send_response(
fd, 401, "application/json", R"({"error": "Unauthorized B"})");
});
MockHttpServer server_c([](const HttpRequest &req, int fd) {
Json body = Json::parse(req.body);
std::string model_sent = body["model"].str();
std::string resp_body =
R"({"choices":[{"message":{"content":"Hello from )" + model_sent +
R"("}}]})";
MockHttpServer::send_response(fd, 200, "application/json", resp_body);
});
std::vector<ModelConfig> configs = {
{"model-1", "key-fail-1", server_a.url(), ""},
{"model-1", "key-fail-2", server_b.url(), ""},
{"model-2", "key-success", server_c.url(), ""},
};
ConfigManager cm(configs, {"model-1", "model-2"});
Json req_body_obj = Json::object({
{"model", "model-1"},
{"messages", Json::array({
Json::object({{"role", "user"}, {"content", "Hi"}}),
})},
});
HttpRequest req;
req.method = "POST";
req.path = "/v1/chat/completions";
req.headers.set("Content-Type", "application/json");
req.body = req_body_obj.dump();
RecorderResponseWriter w;
handle_chat_completions(cm, {}, std::chrono::milliseconds(10), req, w);
EXPECT_EQ(w.status_code(), 200, "Expected status 200 after failover");
Json resp_obj = Json::parse(w.body());
EXPECT_TRUE(resp_obj.contains("choices") && resp_obj["choices"].is_array() &&
!resp_obj["choices"].empty(),
"Invalid response choices structure");
std::string content =
resp_obj["choices"].at(0)["message"]["content"].str();
EXPECT_EQ(content, "Hello from model-2",
"Expected content 'Hello from model-2', got: " + content);
}
void TestGatewayCompletionsStreamingFailover() {
MockHttpServer server_a([](const HttpRequest &, int fd) {
MockHttpServer::send_response(fd, 500, "text/plain", "Error");
});
MockHttpServer server_b([](const HttpRequest &, int fd) {
std::string headers =
"HTTP/1.1 200 OK\r\n"
"Content-Type: text/event-stream\r\n"
"Cache-Control: no-cache\r\n"
"Connection: close\r\n\r\n";
write_all_fd(fd, headers);
write_all_fd(fd, "data: chunk1\n\n");
write_all_fd(fd, "data: chunk2\n\n");
});
std::vector<ModelConfig> configs = {
{"model-1", "key-fail", server_a.url(), ""},
{"model-2", "key-success", server_b.url(), ""},
};
ConfigManager cm(configs, {"model-1", "model-2"});
Json req_body_obj = Json::object({
{"model", "model-1"},
{"stream", true},
{"messages", Json::array({
Json::object({{"role", "user"}, {"content", "Hi"}}),
})},
});
HttpRequest req;
req.method = "POST";
req.path = "/v1/chat/completions";
req.headers.set("Content-Type", "application/json");
req.body = req_body_obj.dump();
RecorderResponseWriter w;
handle_chat_completions(cm, {}, std::chrono::milliseconds(10), req, w);
EXPECT_EQ(w.status_code(), 200, "Expected status 200 on stream failover");
EXPECT_TRUE(w.body().find("data: chunk1") != std::string::npos &&
w.body().find("data: chunk2") != std::string::npos,
"Expected streaming chunks to be forwarded, got: " + w.body());
}
void TestModelsEndpoint() {
std::vector<ModelConfig> configs = {
{"model-1", "key-1", "ep-1", ""},
{"model-1", "key-2", "ep-1", ""},
{"model-2", "key-3", "ep-2", ""},
};
ConfigManager cm(configs, {"model-1", "model-2"});
HttpRequest req;
req.method = "GET";
req.path = "/v1/models";
RecorderResponseWriter w;
handle_models(cm, {}, req, w);
EXPECT_EQ(w.status_code(), 200, "Expected status 200 on /v1/models");
Json resp_obj = Json::parse(w.body());
EXPECT_TRUE(resp_obj.contains("data") && resp_obj["data"].is_array() &&
resp_obj["data"].size() == 2,
"Expected 2 models in response data");
EXPECT_EQ(resp_obj["data"].at(0)["id"].str(), "model-1",
"Expected first model to be model-1");
EXPECT_EQ(resp_obj["data"].at(1)["id"].str(), "model-2",
"Expected second model to be model-2");
}
void TestGatewayAuthentication() {
std::vector<ModelConfig> configs = {
{"model-1", "key-1", "http://127.0.0.1:1", ""},
};
ConfigManager cm(configs, {"model-1"});
std::vector<std::string> expected_tokens = {
"secret-token-1",
"secret-token-2",
};
// Case 1: No Authorization header
{
HttpRequest req;
req.method = "GET";
req.path = "/v1/models";
RecorderResponseWriter w;
handle_models(cm, expected_tokens, req, w);
EXPECT_EQ(w.status_code(), 401,
"Expected 401 Unauthorized for missing token");
}
// Case 2: Incorrect Authorization header
{
HttpRequest req;
req.method = "GET";
req.path = "/v1/models";
req.headers.set("Authorization", "Bearer wrong-token");
RecorderResponseWriter w;
handle_models(cm, expected_tokens, req, w);
EXPECT_EQ(w.status_code(), 401,
"Expected 401 Unauthorized for wrong token");
}
// Case 3: Correct Authorization header (first token)
{
HttpRequest req;
req.method = "GET";
req.path = "/v1/models";
req.headers.set("Authorization", "Bearer secret-token-1");
RecorderResponseWriter w;
handle_models(cm, expected_tokens, req, w);
EXPECT_EQ(w.status_code(), 200, "Expected 200 OK for correct token 1");
}
// Case 3b: Correct Authorization header (second token)
{
HttpRequest req;
req.method = "GET";
req.path = "/v1/models";
req.headers.set("Authorization", "Bearer secret-token-2");
RecorderResponseWriter w;
handle_models(cm, expected_tokens, req, w);
EXPECT_EQ(w.status_code(), 200, "Expected 200 OK for correct token 2");
}
// Case 4: Completions endpoint with correct token
{
HttpRequest req;
req.method = "POST";
req.path = "/v1/chat/completions";
req.headers.set("Authorization", "Bearer secret-token-2");
req.headers.set("Content-Type", "application/json");
req.body = R"({"model":"model-1"})";
RecorderResponseWriter w;
handle_chat_completions(cm, expected_tokens, std::chrono::milliseconds(0),
req, w);
EXPECT_TRUE(w.status_code() != 401,
"Expected completions request to pass authentication");
}
}
void TestCSVUpdater() {
TempDirGuard tmp("dynagate-updater-test");
auto csv_file = (tmp.path / "models.csv").string();
{
std::ofstream ofs(csv_file, std::ios::binary);
ofs << "model,key,endpoint\ngpt-4,key-1,http://localhost:8001\n";
}
std::string command =
"echo 'gpt-3.5,key-2,http://localhost:8002' >> " + csv_file;
std::atomic<bool> stop_flag{false};
std::thread updater([&]() {
start_csv_updater(stop_flag, command, std::chrono::milliseconds(100));
});
auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(2);
bool contains_new_model = false;
while (std::chrono::steady_clock::now() < deadline) {
std::ifstream ifs(csv_file);
std::ostringstream oss;
oss << ifs.rdbuf();
if (oss.str().find("gpt-3.5") != std::string::npos) {
contains_new_model = true;
break;
}
std::this_thread::sleep_for(std::chrono::milliseconds(50));
}
stop_flag.store(true);
if (updater.joinable()) {
updater.join();
}
EXPECT_TRUE(contains_new_model,
"Expected CSV file to contain updater-appended model");
}
void TestGatewayImageGenerations() {
MockHttpServer server([](const HttpRequest &req, int fd) {
if (req.path != "/v1/images/generations") {
MockHttpServer::send_response(fd, 404, "text/plain", "Not Found");
return;
}
Json body = Json::parse(req.body);
std::string model_sent = body["model"].str();
std::string resp =
R"({"created":1589478378,"data":[{"url":"http://image-url/generated-by-)" +
model_sent + R"("}]})";
MockHttpServer::send_response(fd, 200, "application/json", resp);
});
std::vector<ModelConfig> configs = {
{"dall-e-3", "dalle-key", server.url(), ""},
};
ConfigManager cm(configs, {"dall-e-3"});
Json req_body = Json::object({
{"prompt", "a beautiful kitten"},
{"model", "dall-e-3"},
});
HttpRequest req;
req.method = "POST";
req.path = "/v1/images/generations";
req.headers.set("Content-Type", "application/json");
req.body = req_body.dump();
RecorderResponseWriter w;
handle_image_generations(cm, {}, std::chrono::milliseconds(0), req, w);
EXPECT_EQ(w.status_code(), 200, "Expected 200 OK for image generations");
Json resp_obj = Json::parse(w.body());
std::string url_val = resp_obj["data"].at(0)["url"].str();
EXPECT_EQ(url_val, "http://image-url/generated-by-dall-e-3",
"Unexpected image URL: " + url_val);
}
void TestGatewayNoAuthHeaderOnEmptyKey() {
std::atomic<bool> auth_was_empty{true};
MockHttpServer server([&](const HttpRequest &req, int fd) {
if (!req.headers.get("Authorization").empty()) {
auth_was_empty.store(false);
MockHttpServer::send_response(fd, 400, "text/plain", "Unexpected Auth");
return;
}
MockHttpServer::send_response(
fd, 200, "application/json",
R"({"choices":[{"message":{"content":"Hello"}}]})");
});
std::vector<ModelConfig> configs = {
{"model-no-key", "", server.url(), ""},
{"model-no-key-2", "-", server.url(), ""},
};
ConfigManager cm(configs, {"model-no-key", "model-no-key-2"});
// Case 1: Empty key
{
Json req_body = Json::object({
{"model", "model-no-key"},
{"messages", Json::array({
Json::object({{"role", "user"}, {"content", "Hi"}}),
})},
});
HttpRequest req;
req.method = "POST";
req.path = "/v1/chat/completions";
req.headers.set("Content-Type", "application/json");
req.headers.set("Authorization", "Bearer some-client-token");
req.body = req_body.dump();
RecorderResponseWriter w;
handle_chat_completions(cm, {}, std::chrono::milliseconds(0), req, w);
EXPECT_EQ(w.status_code(), 200, "Expected status 200 for empty key");
EXPECT_TRUE(auth_was_empty.load(),
"Expected no Authorization header on upstream request");
}
// Case 2: Dash key
{
Json req_body = Json::object({
{"model", "model-no-key-2"},
{"messages", Json::array({
Json::object({{"role", "user"}, {"content", "Hi"}}),
})},
});
HttpRequest req;
req.method = "POST";
req.path = "/v1/chat/completions";
req.headers.set("Content-Type", "application/json");
req.headers.set("Authorization", "Bearer some-client-token");
req.body = req_body.dump();
RecorderResponseWriter w;
handle_chat_completions(cm, {}, std::chrono::milliseconds(0), req, w);
EXPECT_EQ(w.status_code(), 200, "Expected status 200 for dash key");
EXPECT_TRUE(auth_was_empty.load(),
"Expected no Authorization header on upstream request");
}
}
void TestGatewayBlankKey() {
std::string received_auth;
std::mutex mu;
MockHttpServer server([&](const HttpRequest &req, int fd) {
{
std::lock_guard<std::mutex> lock(mu);
received_auth = req.headers.get("Authorization");
}
if (received_auth != "Bearer") {
MockHttpServer::send_response(fd, 400, "text/plain", "Bad Auth");
return;
}
MockHttpServer::send_response(
fd, 200, "application/json",
R"({"choices":[{"message":{"content":"Hello"}}]})");
});
std::vector<ModelConfig> configs = {
{"model-blank-key", "-blank-", server.url(), ""},
};
ConfigManager cm(configs, {"model-blank-key"});
Json req_body = Json::object({
{"model", "model-blank-key"},
{"messages", Json::array({
Json::object({{"role", "user"}, {"content", "Hi"}}),
})},
});
HttpRequest req;
req.method = "POST";
req.path = "/v1/chat/completions";
req.headers.set("Content-Type", "application/json");
req.headers.set("Authorization", "Bearer some-client-token");
req.body = req_body.dump();
RecorderResponseWriter w;
handle_chat_completions(cm, {}, std::chrono::milliseconds(0), req, w);
EXPECT_EQ(w.status_code(), 200, "Expected status 200 for -blank- key");
EXPECT_EQ(received_auth, "Bearer",
"Expected Authorization header 'Bearer', got: " + received_auth);
}
void TestGatewayExtraHeaders() {
MockHttpServer server([](const HttpRequest &req, int fd) {
if (req.headers.get("X-My-Custom-Header") != "HeaderValue" ||
req.headers.get("X-Another-Header") != "123") {
MockHttpServer::send_response(fd, 400, "text/plain", "Missing headers");
return;
}
MockHttpServer::send_response(
fd, 200, "application/json",
R"({"choices":[{"message":{"content":"Hello"}}]})");
});
std::vector<ModelConfig> configs = {
{"model-extra-headers", "my-key", server.url(),
R"({"X-My-Custom-Header":"HeaderValue","X-Another-Header":123})"},
};
ConfigManager cm(configs, {"model-extra-headers"});
Json req_body = Json::object({
{"model", "model-extra-headers"},
{"messages", Json::array({
Json::object({{"role", "user"}, {"content", "Hi"}}),
})},
});
HttpRequest req;
req.method = "POST";
req.path = "/v1/chat/completions";
req.headers.set("Content-Type", "application/json");
req.body = req_body.dump();
RecorderResponseWriter w;
handle_chat_completions(cm, {}, std::chrono::milliseconds(0), req, w);
EXPECT_EQ(w.status_code(), 200, "Expected status 200 for extra headers");
}
void TestMultipleTokensKeyFile() {
std::string key_file_content = "\n token-a \n\ntoken-b\n \ntoken-c\n";
auto auth_tokens = parse_auth_tokens(key_file_content);
std::vector<std::string> expected = {"token-a", "token-b", "token-c"};
EXPECT_EQ(auth_tokens, expected, "Parsed auth tokens mismatch");
HttpRequest req_ok;
req_ok.method = "GET";
req_ok.path = "/v1/models";
req_ok.headers.set("Authorization", "Bearer token-b");
EXPECT_TRUE(check_auth(auth_tokens, req_ok),
"Expected token-b to authenticate");
HttpRequest req_wrong;
req_wrong.method = "GET";
req_wrong.path = "/v1/models";
req_wrong.headers.set("Authorization", "Bearer token-wrong");
EXPECT_TRUE(!check_auth(auth_tokens, req_wrong),
"Expected token-wrong to fail authentication");
auto empty_tokens = parse_auth_tokens("\n \n\n \n");
EXPECT_TRUE(empty_tokens.empty(), "Expected empty tokens vector");
HttpRequest req_no_auth;
req_no_auth.method = "GET";
req_no_auth.path = "/v1/models";
EXPECT_TRUE(check_auth(empty_tokens, req_no_auth),
"Expected check_auth to return true for empty expected_tokens");
}
void TestRequestMessagesAutodetectionAndContentArrays() {
Json received_body;
std::mutex mu;
MockHttpServer server([&](const HttpRequest &req, int fd) {
{
std::lock_guard<std::mutex> lock(mu);
received_body = Json::parse(req.body);
}
MockHttpServer::send_response(
fd, 200, "application/json",
R"({"choices":[{"message":{"content":"OK"}}]})");
});
std::vector<ModelConfig> configs = {
{"test-model", "test-key", server.url(), ""},
};
ConfigManager cm(configs, {"test-model"});
// 1. Pi agent format - array content
{
Json req_obj = Json::object({
{"model", "test-model"},
{"messages", Json::array({
Json::object({
{"role", "user"},
{"content", Json::array({
Json::object({
{"type", "text"},
{"text", "Hello from Pi agent"},
}),
})},
}),
})},
});
HttpRequest req;
req.method = "POST";
req.path = "/v1/chat/completions";
req.headers.set("Content-Type", "application/json");
req.body = req_obj.dump();
RecorderResponseWriter w;
handle_chat_completions(cm, {}, std::chrono::milliseconds(0), req, w);
EXPECT_EQ(w.status_code(), 200, "Expected 200 OK for Pi agent format");
EXPECT_EQ(received_body["messages"].size(), 1u, "Expected 1 message");
EXPECT_EQ(received_body["messages"].at(0)["content"].str(),
"Hello from Pi agent", "Unexpected Pi agent normalized content");
}
// 2. Top-level system prompt
{
Json req_obj = Json::object({
{"model", "test-model"},
{"system", "You are a helpful coding assistant"},
{"messages", Json::array({
Json::object({
{"role", "user"},
{"content", "Write a test"},
}),
})},
});
HttpRequest req;
req.method = "POST";
req.path = "/v1/chat/completions";
req.headers.set("Content-Type", "application/json");
req.body = req_obj.dump();
RecorderResponseWriter w;
handle_chat_completions(cm, {}, std::chrono::milliseconds(0), req, w);
EXPECT_EQ(received_body["messages"].size(), 2u, "Expected 2 messages");
EXPECT_EQ(received_body["messages"].at(0)["role"].str(),
"system", "Expected system role first");
EXPECT_EQ(received_body["messages"].at(0)["content"].str(),
"You are a helpful coding assistant",
"Unexpected system message content");
EXPECT_EQ(received_body["messages"].at(1)["content"].str(),
"Write a test", "Unexpected user message content");
EXPECT_TRUE(!received_body.contains("system"),
"Expected top-level system field to be removed");
}
// 3. Prompt field autodetection
{
Json req_obj = Json::object({
{"model", "test-model"},
{"prompt", "Explain recursion"},
});
HttpRequest req;
req.method = "POST";
req.path = "/v1/chat/completions";
req.headers.set("Content-Type", "application/json");
req.body = req_obj.dump();
RecorderResponseWriter w;
handle_chat_completions(cm, {}, std::chrono::milliseconds(0), req, w);
EXPECT_EQ(received_body["messages"].size(), 1u, "Expected 1 message");
EXPECT_EQ(received_body["messages"].at(0)["role"].str(), "user",
"Expected user role");
EXPECT_EQ(received_body["messages"].at(0)["content"].str(),
"Explain recursion", "Unexpected prompt autodetection content");
}
// 4. Multimodal array content preserved
{
Json req_obj = Json::object({
{"model", "test-model"},
{"messages", Json::array({
Json::object({
{"role", "user"},
{"content",
Json::array({
Json::object({
{"type", "text"},
{"text", "Describe this image"},
}),
Json::object({
{"type", "image_url"},
{"image_url",
Json::object({
{"url", "http://example.com/img.png"},
})},
}),
})},
}),
})},
});
HttpRequest req;
req.method = "POST";
req.path = "/v1/chat/completions";
req.headers.set("Content-Type", "application/json");
req.body = req_obj.dump();
RecorderResponseWriter w;
handle_chat_completions(cm, {}, std::chrono::milliseconds(0), req, w);
EXPECT_TRUE(received_body["messages"].at(0)["content"].is_array() &&
received_body["messages"].at(0)["content"].size() == 2,
"Expected multimodal array content to be preserved");
}
}
void TestFibonacciBackoff() {
std::vector<std::pair<int, int64_t>> expected_seq = {
{0, 0}, {1, 1}, {2, 1}, {3, 2}, {4, 3}, {5, 5},
{6, 8}, {7, 13}, {8, 21}, {9, 34}, {10, 55},
};
for (const auto &p : expected_seq) {
EXPECT_EQ(fibonacci(p.first), p.second, "fibonacci mismatch");
}
}
void TestAnthropicMessagesEndpoint() {
Json received_openai_body;
std::mutex mu;
MockHttpServer server([&](const HttpRequest &req, int fd) {
if (req.path != "/v1/chat/completions") {
MockHttpServer::send_response(fd, 404, "text/plain", "Wrong path");
return;
}
Json body = Json::parse(req.body);
{
std::lock_guard<std::mutex> lock(mu);
received_openai_body = body;
}
bool is_stream = body["stream"].as_bool(false);
if (is_stream) {
std::string headers =
"HTTP/1.1 200 OK\r\n"
"Content-Type: text/event-stream\r\n"
"Connection: close\r\n\r\n";
write_all_fd(fd, headers);
write_all_fd(
fd, "data: {\"choices\":[{\"delta\":{\"content\":\"Hello \"}}]}\n\n");
write_all_fd(
fd,
"data: {\"choices\":[{\"delta\":{\"content\":\"Anthropic!\"},\"finish_reason\":\"stop\"}]}\n\n");
write_all_fd(fd, "data: [DONE]\n\n");
return;
}
Json resp = Json::object({
{"id", "chatcmpl-test"},
{"model", "claude-model"},
{"choices", Json::array({
Json::object({
{"index", 0},
{"message", Json::object({
{"role", "assistant"},
{"content", "Hello from Anthropic proxy"},
})},
{"finish_reason", "stop"},
}),
})},
{"usage", Json::object({
{"prompt_tokens", 10},
{"completion_tokens", 15},
{"total_tokens", 25},
})},
});
MockHttpServer::send_response(fd, 200, "application/json", resp.dump());
});
std::vector<ModelConfig> configs = {
{"claude-model", "test-key", server.url(), ""},
};
ConfigManager cm(configs, {"claude-model"});
// 1. Non-streaming test
{
Json req_obj = Json::object({
{"model", "claude-model"},
{"system", "You are a helpful bot"},
{"messages", Json::array({
Json::object({
{"role", "user"},
{"content", "Hello"},
}),
})},
{"max_tokens", 100},
});
HttpRequest req;
req.method = "POST";
req.path = "/v1/messages";
req.headers.set("Content-Type", "application/json");
req.body = req_obj.dump();
RecorderResponseWriter w;
handle_messages(cm, {}, std::chrono::milliseconds(0), req, w);
EXPECT_EQ(w.status_code(), 200, "Expected 200 OK for Anthropic messages");
Json anthropic_resp = Json::parse(w.body());
EXPECT_EQ(anthropic_resp["type"].str(), "message",
"Expected type 'message'");
EXPECT_EQ(anthropic_resp["role"].str(), "assistant",
"Expected role 'assistant'");
EXPECT_EQ(anthropic_resp["content"].at(0)["text"].str(),
"Hello from Anthropic proxy", "Unexpected Anthropic text");
EXPECT_EQ(received_openai_body["messages"].size(), 2u,
"Expected 2 messages in OpenAI body");
EXPECT_EQ(received_openai_body["messages"].at(0)["role"].str(),
"system", "Expected first message to be system");
}
// 2. Streaming test
{
Json req_obj = Json::object({
{"model", "claude-model"},
{"stream", true},
{"messages", Json::array({
Json::object({
{"role", "user"},
{"content", "Stream me"},
}),
})},
});
HttpRequest req;
req.method = "POST";
req.path = "/v1/messages";
req.headers.set("Content-Type", "application/json");
req.body = req_obj.dump();
RecorderResponseWriter w;
handle_messages(cm, {}, std::chrono::milliseconds(0), req, w);
EXPECT_EQ(w.status_code(), 200,
"Expected 200 OK for streaming Anthropic messages");
EXPECT_TRUE(w.body().find("event: message_start") != std::string::npos &&
w.body().find("event: content_block_delta") !=
std::string::npos &&
w.body().find("event: message_stop") != std::string::npos,
"Expected Anthropic SSE stream events in response");
}
}
void TestOpenAIResponsesEndpoint() {
Json received_openai_body;
std::mutex mu;
MockHttpServer server([&](const HttpRequest &req, int fd) {
if (req.path != "/v1/chat/completions") {
MockHttpServer::send_response(fd, 404, "text/plain", "Wrong path");
return;
}
Json body = Json::parse(req.body);
{
std::lock_guard<std::mutex> lock(mu);
received_openai_body = body;
}
bool is_stream = body["stream"].as_bool(false);
if (is_stream) {
std::string headers =
"HTTP/1.1 200 OK\r\n"
"Content-Type: text/event-stream\r\n"
"Connection: close\r\n\r\n";
write_all_fd(fd, headers);
write_all_fd(
fd,
"data: {\"choices\":[{\"delta\":{\"content\":\"Response \"}}]}\n\n");
write_all_fd(
fd,
"data: {\"choices\":[{\"delta\":{\"content\":\"Chunk\"},\"finish_reason\":\"stop\"}]}\n\n");
write_all_fd(fd, "data: [DONE]\n\n");
return;
}
Json resp = Json::object({
{"id", "chatcmpl-resp-test"},
{"model", "gpt-5-model"},
{"choices", Json::array({
Json::object({
{"index", 0},
{"message", Json::object({
{"role", "assistant"},
{"content", "Hello from Responses proxy"},
})},
{"finish_reason", "stop"},
}),
})},
{"usage", Json::object({
{"prompt_tokens", 12},
{"completion_tokens", 18},
{"total_tokens", 30},
})},
});
MockHttpServer::send_response(fd, 200, "application/json", resp.dump());
});
std::vector<ModelConfig> configs = {
{"gpt-5-model", "test-key", server.url(), ""},
};
ConfigManager cm(configs, {"gpt-5-model"});
// 1. Non-streaming test
{
Json req_obj = Json::object({
{"model", "gpt-5-model"},
{"instructions", "Be accurate"},
{"input", "What is 2+2?"},
});
HttpRequest req;
req.method = "POST";
req.path = "/v1/responses";
req.headers.set("Content-Type", "application/json");
req.body = req_obj.dump();
RecorderResponseWriter w;
handle_responses(cm, {}, std::chrono::milliseconds(0), req, w);
EXPECT_EQ(w.status_code(), 200, "Expected 200 OK for OpenAI Responses");
Json resp_obj = Json::parse(w.body());
EXPECT_EQ(resp_obj["object"].str(), "response",
"Expected object 'response'");
EXPECT_EQ(resp_obj["status"].str(), "completed",
"Expected status 'completed'");
EXPECT_EQ(
resp_obj["output"].at(0)["content"].at(0)["text"].str(),
"Hello from Responses proxy", "Unexpected Responses output text");
EXPECT_EQ(received_openai_body["messages"].size(), 2u,
"Expected 2 messages in OpenAI body");
EXPECT_EQ(received_openai_body["messages"].at(0)["role"].str(),
"system", "Expected system role for instructions");
}
// 2. Streaming test
{
Json req_obj = Json::object({
{"model", "gpt-5-model"},
{"stream", true},
{"input", "Stream test"},
});
HttpRequest req;
req.method = "POST";
req.path = "/v1/responses";
req.headers.set("Content-Type", "application/json");
req.body = req_obj.dump();
RecorderResponseWriter w;
handle_responses(cm, {}, std::chrono::milliseconds(0), req, w);
EXPECT_EQ(w.status_code(), 200,
"Expected 200 OK for streaming OpenAI Responses");
EXPECT_TRUE(
w.body().find("event: response.created") != std::string::npos &&
w.body().find("event: response.output_text.delta") !=
std::string::npos &&
w.body().find("event: response.completed") != std::string::npos,
"Expected OpenAI Responses SSE events in response stream");
}
}
void TestFullGatewayServerEndToEnd() {
MockHttpServer upstream([](const HttpRequest &req, int fd) {
Json body = Json::parse(req.body);
std::string model_sent = body["model"].str();
Json resp = Json::object({
{"id", "chatcmpl-e2e"},
{"model", model_sent},
{"choices", Json::array({
Json::object({
{"index", 0},
{"message", Json::object({
{"role", "assistant"},
{"content", "E2E OK from " + model_sent},
})},
{"finish_reason", "stop"},
}),
})},
});
MockHttpServer::send_response(fd, 200, "application/json", resp.dump());
});
std::vector<ModelConfig> configs = {
{"e2e-model", "e2e-key", upstream.url(), ""},
};
ConfigManager cm(configs, {"e2e-model"});
GatewayServer gw(0, cm, {"gw-token"}, std::chrono::milliseconds(0));
std::string err;
EXPECT_TRUE(gw.start(&err), "GatewayServer start failed: " + err);
std::thread gw_thread([&]() { gw.serve(); });
std::string base_url = "http://127.0.0.1:" + std::to_string(gw.bound_port());
// Perform a real HTTP GET /v1/models via libcurl
{
CURL *c = curl_easy_init();
std::string resp_body;
struct curl_slist *hdrs = nullptr;
hdrs = curl_slist_append(hdrs, "Authorization: Bearer gw-token");
std::string url = base_url + "/v1/models";
curl_easy_setopt(c, CURLOPT_URL, url.c_str());
curl_easy_setopt(c, CURLOPT_HTTPHEADER, hdrs);
curl_easy_setopt(
c, CURLOPT_WRITEFUNCTION,
+[](char *p, size_t s, size_t n, void *ud) -> size_t {
static_cast<std::string *>(ud)->append(p, s * n);
return s * n;
});
curl_easy_setopt(c, CURLOPT_WRITEDATA, &resp_body);
CURLcode rc = curl_easy_perform(c);
long code = 0;
curl_easy_getinfo(c, CURLINFO_RESPONSE_CODE, &code);
curl_slist_free_all(hdrs);
curl_easy_cleanup(c);
EXPECT_EQ(rc, CURLE_OK, "curl /v1/models failed");
EXPECT_EQ(code, 200L, "Expected 200 from GatewayServer /v1/models");
Json j = Json::parse(resp_body);
EXPECT_EQ(j["data"].at(0)["id"].str(), "e2e-model",
"Unexpected model from GatewayServer");
}
// Perform a real HTTP POST /v1/chat/completions via libcurl
{
CURL *c = curl_easy_init();
std::string resp_body;
struct curl_slist *hdrs = nullptr;
hdrs = curl_slist_append(hdrs, "Authorization: Bearer gw-token");
hdrs = curl_slist_append(hdrs, "Content-Type: application/json");
std::string payload =
R"({"model":"e2e-model","messages":[{"role":"user","content":"ping"}]})";
std::string url = base_url + "/v1/chat/completions";
curl_easy_setopt(c, CURLOPT_URL, url.c_str());
curl_easy_setopt(c, CURLOPT_POST, 1L);
curl_easy_setopt(c, CURLOPT_POSTFIELDS, payload.c_str());
curl_easy_setopt(c, CURLOPT_HTTPHEADER, hdrs);
curl_easy_setopt(
c, CURLOPT_WRITEFUNCTION,
+[](char *p, size_t s, size_t n, void *ud) -> size_t {
static_cast<std::string *>(ud)->append(p, s * n);
return s * n;
});
curl_easy_setopt(c, CURLOPT_WRITEDATA, &resp_body);
CURLcode rc = curl_easy_perform(c);
long code = 0;
curl_easy_getinfo(c, CURLINFO_RESPONSE_CODE, &code);
curl_slist_free_all(hdrs);
curl_easy_cleanup(c);
EXPECT_EQ(rc, CURLE_OK, "curl /v1/chat/completions failed");
EXPECT_EQ(code, 200L,
"Expected 200 from GatewayServer /v1/chat/completions");
Json j = Json::parse(resp_body);
EXPECT_EQ(j["choices"].at(0)["message"]["content"].str(),
"E2E OK from e2e-model", "Unexpected E2E chat completion");
}
gw.stop();
if (gw_thread.joinable()) {
gw_thread.join();
}
}
} // namespace
int main() {
std::signal(SIGPIPE, SIG_IGN);
ensure_curl_initialized();
struct TestEntry {
const char *name;
void (*fn)();
};
std::vector<TestEntry> tests = {
{"TestURLBuilding", TestURLBuilding},
{"TestTrialOrdering", TestTrialOrdering},
{"TestConfigManagerCSVWatcher", TestConfigManagerCSVWatcher},
{"TestGatewayCompletionsFailover", TestGatewayCompletionsFailover},
{"TestGatewayCompletionsStreamingFailover",
TestGatewayCompletionsStreamingFailover},
{"TestModelsEndpoint", TestModelsEndpoint},
{"TestGatewayAuthentication", TestGatewayAuthentication},
{"TestCSVUpdater", TestCSVUpdater},
{"TestGatewayImageGenerations", TestGatewayImageGenerations},
{"TestGatewayNoAuthHeaderOnEmptyKey", TestGatewayNoAuthHeaderOnEmptyKey},
{"TestGatewayBlankKey", TestGatewayBlankKey},
{"TestGatewayExtraHeaders", TestGatewayExtraHeaders},
{"TestMultipleTokensKeyFile", TestMultipleTokensKeyFile},
{"TestRequestMessagesAutodetectionAndContentArrays",
TestRequestMessagesAutodetectionAndContentArrays},
{"TestFibonacciBackoff", TestFibonacciBackoff},
{"TestAnthropicMessagesEndpoint", TestAnthropicMessagesEndpoint},
{"TestOpenAIResponsesEndpoint", TestOpenAIResponsesEndpoint},
{"TestFullGatewayServerEndToEnd", TestFullGatewayServerEndToEnd},
};
for (const auto &t : tests) {
int before = g_failures;
std::printf("=== RUN %s\n", t.name);
t.fn();
if (g_failures == before) {
std::printf("--- PASS: %s\n", t.name);
} else {
std::printf("--- FAIL: %s\n", t.name);
}
}
if (g_failures == 0) {
std::printf("PASS\n");
return 0;
}
std::printf("FAIL (%d failures)\n", g_failures);
return 1;
}