server: (router) Forward form-data to model server (Fixes #22044) (#22118)

* This commit enables the router to forward form-data to model server.
Fixes #22044 (enabling to use the /v1/audio/transcriptions in router mode)

* * Applied the suggestion from Copilots first comment: using the non-throwing json::parse overload.
* Addressed Copilots third comment by extending the files representation to also include filename and content-type
* Addressed Copilots fourth comment by making the RNG thread_local

* Changed variable body from std::string to std::ostringstream in build_multipart_body
as suggested by ngxson in https://github.com/ggml-org/llama.cpp/pull/22118#discussion_r3127099053

* Added sanitize_field lambda in build_multipart_body for key, filename and content_type
as suggested by ngxson in https://github.com/ggml-org/llama.cpp/pull/22118#discussion_r3127104647

* explicitly checking if value/item is string before calling value/item.get<std::string>()
as requested by ngxson in https://github.com/ggml-org/llama.cpp/pull/22118#discussion_r3127111279

* Added double quote to the sanitize lambda and throw on json parse failure

---------

Co-authored-by: Ralph Paßgang <ralph@trust-it.de>
This commit is contained in:
tha80
2026-04-27 23:55:00 +02:00
committed by GitHub
parent 665abc6097
commit 983ca8992e
7 changed files with 135 additions and 10 deletions
+2 -2
View File
@@ -575,14 +575,14 @@ json server_chat_msg_diff_to_json_oaicompat(const common_chat_msg_diff & diff) {
json convert_transcriptions_to_chatcmpl( json convert_transcriptions_to_chatcmpl(
const json & inp_body, const json & inp_body,
const common_chat_templates * tmpls, const common_chat_templates * tmpls,
const std::map<std::string, raw_buffer> & in_files, const std::map<std::string, uploaded_file> & in_files,
std::vector<raw_buffer> & out_files) { std::vector<raw_buffer> & out_files) {
// TODO @ngxson : this function may need to be improved in the future // TODO @ngxson : this function may need to be improved in the future
// handle input files // handle input files
out_files.clear(); out_files.clear();
auto it = in_files.find("file"); auto it = in_files.find("file");
if (it != in_files.end()) { if (it != in_files.end()) {
out_files.push_back(it->second); out_files.push_back(it->second.data);
} else { } else {
throw std::invalid_argument("No input file found for transcription"); throw std::invalid_argument("No input file found for transcription");
} }
+2 -1
View File
@@ -4,6 +4,7 @@
#include "chat.h" #include "chat.h"
#include "server-common.h" #include "server-common.h"
#include "server-http.h"
#include <nlohmann/json_fwd.hpp> #include <nlohmann/json_fwd.hpp>
@@ -19,7 +20,7 @@ json server_chat_convert_anthropic_to_oai(const json & body);
json convert_transcriptions_to_chatcmpl( json convert_transcriptions_to_chatcmpl(
const json & body, const json & body,
const common_chat_templates * tmpls, const common_chat_templates * tmpls,
const std::map<std::string, raw_buffer> & in_files, const std::map<std::string, uploaded_file> & in_files,
std::vector<raw_buffer> & out_files); std::vector<raw_buffer> & out_files);
json server_chat_msg_diff_to_json_oaicompat(const common_chat_msg_diff & diff); json server_chat_msg_diff_to_json_oaicompat(const common_chat_msg_diff & diff);
+1
View File
@@ -49,6 +49,7 @@ static server_http_res_ptr proxy_request(const server_http_req & req, std::strin
parsed_url.path, parsed_url.path,
headers, headers,
req.body, req.body,
req.files,
req.should_stop, req.should_stop,
600, // timeout_read (default to 10 minutes) 600, // timeout_read (default to 10 minutes)
600 // timeout_write (default to 10 minutes) 600 // timeout_write (default to 10 minutes)
+6 -2
View File
@@ -438,7 +438,7 @@ void server_http_context::get(const std::string & path, const server_http_contex
void server_http_context::post(const std::string & path, const server_http_context::handler_t & handler) const { void server_http_context::post(const std::string & path, const server_http_context::handler_t & handler) const {
pimpl->srv->Post(path_prefix + path, [handler](const httplib::Request & req, httplib::Response & res) { pimpl->srv->Post(path_prefix + path, [handler](const httplib::Request & req, httplib::Response & res) {
std::string body = req.body; std::string body = req.body;
std::map<std::string, raw_buffer> files; std::map<std::string, uploaded_file> files;
if (req.is_multipart_form_data()) { if (req.is_multipart_form_data()) {
// translate text fields to a JSON object and use it as the body // translate text fields to a JSON object and use it as the body
@@ -459,7 +459,11 @@ void server_http_context::post(const std::string & path, const server_http_conte
// populate files from multipart form // populate files from multipart form
for (const auto & [key, file] : req.form.files) { for (const auto & [key, file] : req.form.files) {
files[key] = raw_buffer(file.content.begin(), file.content.end()); files[key] = uploaded_file{
raw_buffer(file.content.begin(), file.content.end()),
file.filename,
file.content_type,
};
} }
} }
+7 -1
View File
@@ -36,13 +36,19 @@ struct server_http_res {
using server_http_res_ptr = std::unique_ptr<server_http_res>; using server_http_res_ptr = std::unique_ptr<server_http_res>;
using raw_buffer = std::vector<uint8_t>; using raw_buffer = std::vector<uint8_t>;
struct uploaded_file {
raw_buffer data;
std::string filename;
std::string content_type;
};
struct server_http_req { struct server_http_req {
std::map<std::string, std::string> params; // path_params + query_params std::map<std::string, std::string> params; // path_params + query_params
std::map<std::string, std::string> headers; // used by MCP proxy std::map<std::string, std::string> headers; // used by MCP proxy
std::string path; std::string path;
std::string query_string; // query parameters string (e.g. "action=save") std::string query_string; // query parameters string (e.g. "action=save")
std::string body; std::string body;
std::map<std::string, raw_buffer> files; // used for file uploads (form data) std::map<std::string, uploaded_file> files; // used for file uploads (form data)
const std::function<bool()> & should_stop; const std::function<bool()> & should_stop;
std::string get_param(const std::string & key, const std::string & def = "") const { std::string get_param(const std::string & key, const std::string & def = "") const {
+116 -4
View File
@@ -18,6 +18,8 @@
#include <chrono> #include <chrono>
#include <queue> #include <queue>
#include <filesystem> #include <filesystem>
#include <random>
#include <sstream>
#include <cstring> #include <cstring>
#ifdef _WIN32 #ifdef _WIN32
@@ -823,6 +825,7 @@ server_http_res_ptr server_models::proxy_request(const server_http_req & req, co
proxy_path, proxy_path,
req.headers, req.headers,
req.body, req.body,
req.files,
req.should_stop, req.should_stop,
base_params.timeout_read, base_params.timeout_read,
base_params.timeout_write base_params.timeout_write
@@ -1126,6 +1129,77 @@ static bool should_strip_proxy_header(const std::string & header_name) {
return false; return false;
} }
static std::string generate_multipart_boundary() {
thread_local std::mt19937 gen(std::random_device{}());
static const char chars[] = "0123456789abcdefghijklmnopqrstuvwxyz";
std::uniform_int_distribution<> dis(0, sizeof(chars) - 2);
std::string boundary = "----llama-cpp-proxy-";
for (int i = 0; i < 16; i++) {
boundary += chars[dis(gen)];
}
return boundary;
}
static std::string build_multipart_body(
const json & form_fields,
const std::map<std::string, uploaded_file> & files,
const std::string & boundary) {
static auto sanitize_field = [](const std::string & text) {
std::string result;
result.reserve(text.size());
for (char c : text) {
if (c != '\n' && c != '\r' && c != '"') {
result += c;
}
}
return result;
};
std::ostringstream body;
for (const auto & [key, value] : form_fields.items()) {
if (value.is_array()) {
for (const auto & item : value) {
body << "--" << boundary << "\r\n";
body << "Content-Disposition: form-data; name=\"" << sanitize_field(key) << "\"\r\n";
body << "\r\n";
if (!item.is_string()) {
throw std::invalid_argument("expected string");
}
body << item.get<std::string>() << "\r\n";
}
} else {
body << "--" << boundary << "\r\n";
body << "Content-Disposition: form-data; name=\"" << sanitize_field(key) << "\"\r\n";
body << "\r\n";
if (!value.is_string()) {
throw std::invalid_argument("expected string");
}
body << value.get<std::string>() << "\r\n";
}
}
for (const auto & [key, file] : files) {
body << "--" << boundary << "\r\n";
body << "Content-Disposition: form-data; name=\"" << sanitize_field(key) << "\"";
if (!file.filename.empty()) {
body << "; filename=\"" << sanitize_field(file.filename) << "\"";
}
body << "\r\n";
if (!file.content_type.empty()) {
body << "Content-Type: " << sanitize_field(file.content_type) << "\r\n";
} else {
body << "Content-Type: application/octet-stream\r\n";
}
body << "\r\n";
body.write(reinterpret_cast<const char*>(file.data.data()), file.data.size());
body << "\r\n";
}
body << "--" << boundary << "--\r\n";
return body.str();
}
server_http_proxy::server_http_proxy( server_http_proxy::server_http_proxy(
const std::string & method, const std::string & method,
const std::string & scheme, const std::string & scheme,
@@ -1134,6 +1208,7 @@ server_http_proxy::server_http_proxy(
const std::string & path, const std::string & path,
const std::map<std::string, std::string> & headers, const std::map<std::string, std::string> & headers,
const std::string & body, const std::string & body,
const std::map<std::string, uploaded_file> & files,
const std::function<bool()> should_stop, const std::function<bool()> should_stop,
int32_t timeout_read, int32_t timeout_read,
int32_t timeout_write int32_t timeout_write
@@ -1195,28 +1270,65 @@ server_http_proxy::server_http_proxy(
return pipe->write({{}, 0, std::string(data, data_length), ""}); return pipe->write({{}, 0, std::string(data, data_length), ""});
}; };
// when files are present, the body was converted from multipart form data to JSON
// we need to reconstruct the multipart body for the downstream server
std::string effective_body = body;
std::string override_content_type;
bool has_files = !files.empty();
if (has_files) {
json form_fields = json::parse(body, nullptr, false);
if (!form_fields.is_discarded()) {
auto boundary = generate_multipart_boundary();
effective_body = build_multipart_body(form_fields, files, boundary);
override_content_type = "multipart/form-data; boundary=" + boundary;
} else {
throw std::runtime_error("failed to parse multipart form fields JSON");
}
}
// prepare the request to destination server // prepare the request to destination server
httplib::Request req; httplib::Request req;
{ {
req.method = method; req.method = method;
req.path = path; req.path = path;
for (const auto & [key, value] : headers) { for (const auto & [key, value] : headers) {
if (key == "Accept-Encoding") { const auto lowered = to_lower_copy(key);
if (lowered == "accept-encoding") {
// disable Accept-Encoding to avoid compressed responses // disable Accept-Encoding to avoid compressed responses
continue; continue;
} }
if (key == "Transfer-Encoding") { if (lowered == "transfer-encoding") {
// the body is already decoded // the body is already decoded
continue; continue;
} }
if (key == "Host" || key == "host") { if (lowered == "content-length") {
// let httplib calculate Content-Length from the actual body
continue;
}
if (lowered == "content-type") {
if (has_files) {
// we set our own Content-Type with the new boundary
continue;
}
// when no files but the original request was multipart,
// the body is now JSON, so correct the Content-Type
if (value.find("multipart/form-data") != std::string::npos) {
override_content_type = "application/json; charset=utf-8";
continue;
}
}
if (lowered == "host") {
bool is_default_port = (scheme == "https" && port == 443) || (scheme == "http" && port == 80); bool is_default_port = (scheme == "https" && port == 443) || (scheme == "http" && port == 80);
req.set_header(key, is_default_port ? host : host + ":" + std::to_string(port)); req.set_header(key, is_default_port ? host : host + ":" + std::to_string(port));
} else { } else {
req.set_header(key, value); req.set_header(key, value);
} }
} }
req.body = body; req.body = effective_body;
if (!override_content_type.empty()) {
req.set_header("Content-Type", override_content_type);
}
req.response_handler = response_handler; req.response_handler = response_handler;
req.content_receiver = content_receiver; req.content_receiver = content_receiver;
} }
+1
View File
@@ -202,6 +202,7 @@ public:
const std::string & path, const std::string & path,
const std::map<std::string, std::string> & headers, const std::map<std::string, std::string> & headers,
const std::string & body, const std::string & body,
const std::map<std::string, uploaded_file> & files,
const std::function<bool()> should_stop, const std::function<bool()> should_stop,
int32_t timeout_read, int32_t timeout_read,
int32_t timeout_write int32_t timeout_write