mirror of
https://github.com/ggml-org/llama.cpp.git
synced 2026-06-26 14:20:21 +00:00
1a87dcdc45
* server: SSE replay buffer, survives client disconnect
Opt in on POST /v1/chat/completions when the client sends
X-Stream-Resume: 1 and a non empty X-Conversation-Id. The conv id is
the session identity end to end, no extra opaque token. The drain
runs detached server side and buffers SSE bytes, the generation
survives HTTP disconnect, F5, or lets users switch from iOS Safari
to another app without losing the actively generated response.
Routes:
GET /v1/stream/<conv_id>?from=N replay
GET /v1/streams[?conversation_id=X] list, drives sidebar spinners
DELETE /v1/stream/<conv_id> Stop, idempotent
Router parent fans out to children for list and delete, probes on GET
to route to the owner, fans out DELETE on POST so "one session per
conv" holds across model swaps.
WebUI: the layout snapshots /v1/streams at mount and on
visibilitychange, the sidebar reflects live inferences across all
convs. The chat page reattaches on mount, append vs fresh is detected
from existing content so continue mid stream keeps its prefix.
update_slots: on llama_memory_seq_rm refusal at a deep position, full
clear of the seq and reprefill from zero instead of GGML_ABORT.
OAI strict path unchanged when the opt in headers are absent.
* server: create stream session only after post_tasks succeeds
* server, ui: drop X-Stream-Resume, X-Conversation-Id alone enables the replay buffer
* server: drop magic 17, derive the X-Conversation-Id header length from sizeof at build time
* refactor: address review feedback from ngxson
* server-context: cleaning
* server-stream: fix use-after-free on rd
Guard stop_producer with a shared alive flag, flipped by on_stream_end
before rd dies. Prevents a late cancel (session eviction by a later
POST on the same conv_id, or a DELETE arriving after the producer
ended) from touching a destroyed rd.
* ui: fix cross-conversation contamination
Scope streaming flags per conv so one finishing does not unflag the
others, guard discoverActiveStream against concurrent runs to avoid
duplicate attaches, and stop racing syncRemoteRunningStreams for the
sidebar set.
* server-http: keep request alive in detached SSE drain
The response next() lambda may reach into *request via &req long
after on_complete reset the request shared_ptr. Capture request in
the detached thread so it outlives the drain.
* ui: address review feedback from coder543
Forward Authorization to /v1/stream and /v1/streams fetches, the resumable routes
must obey --api-key like the rest of the API.
Wrap reader.read() in a try/catch, the underlying connection drop rejects with
TypeError instead of resolving done=true, treat it as a premature end of stream
so the existing resume loop kicks in.
Freeze the model at session start in chatStreamingStates.model and thread it
through cancel and resume, the dropdown selection may have changed since the
POST and the server side identity is fixed at that time.
* format
* ui: remove unused selectedModelName
* server-stream: poll session->is_cancelled() in stream_aware_should_stop
Address review feedback from coder543. The cancel propagation through
rd.stop() relies on the slot eventually processing the cancel task and
posting a result that notifies the recv condvar, remove_waiting_task_ids
does not notify directly. Add a defensive poll on session->is_cancelled()
so the producer-side next() loop exits on its next iteration after
cancel() without waiting for the cancel task to round trip through a slot.
* server-stream, ui: replace GET /v1/streams with POST /v1/streams/lookup
Address review feedback from coder543. Listing live sessions leaks the
conversation_id of every concurrent user, which defeats the random UUID
unguessability. The new route takes {conversation_ids: [...]} in the
body and returns matches only for the ids the caller already owns, so
foreign UUIDs stay private. The router fans out the same POST to every
child and aggregates, the WebUI passes the convs visible in its sidebar.
* ui: read conv ids from IndexedDB in syncRemoteRunningStreams
The conversations store is not hydrated yet at +layout onMount, so the
sidebar spinners stayed off for background convs until the user clicked
on them. Read straight from the DB to dodge the init race.
* server-models: deduplicate stream lookup timeouts behind one constant
* ui: extract visibility kick grace into a stream constant, bump to 1000 ms
* make it safer & more simple
* server-stream: survive client disconnect via stream_pipe::finish_producer
After the RAII rewrite the generation stopped the moment the client
disconnected. httplib bails its content provider on the is_peer_alive
check at the top of write_content_chunked, so returning true from the
provider never keeps it producing: the response resets, rd is destroyed
and its task gets cancelled.
Reinstate the disconnect survival inside the pipe. stream_pipe gains
finish_producer, which pumps the response next() into the ring buffer
until the generation ends, and mark_producer_done for the clean wire
end. server-http only triggers them: mark before sink.done on a clean
close, finish in on_complete when the peer left early. No detach, no
stream logic in server-http beyond the trigger, and the strict OAI path
is untouched when no pipe is attached.
Known limitation: finish_producer pumps synchronously on the http
worker, so a disconnected stream keeps its worker busy until the
generation ends. A follow-up will move the drain off the http worker so
no worker is held.
* server-stream: drain disconnected streams on a manager owned thread
The previous commit pumped the post disconnect drain synchronously in
on_complete, on the http worker, so a disconnected stream kept its
worker busy until the generation ended. Under a wave of reloads or tab
closes that pins workers from the pool.
Move the drain off the http worker. on_complete now hands the response
to stream_session_manager::adopt_orphan, which pumps it to completion on
a manager owned thread and releases the worker at once. One thread per
disconnected stream still generating, stored in a list, joined and
reaped on the next adopt, by the GC, and at shutdown. No detach, the
thread lifecycle is fully owned by the manager. needs_drain gates the
handoff so a cleanly finished stream never spawns a thread, and the
strict OAI path stays untouched when no pipe is attached.
stop_gc now cancels sessions before finalizing them, so an in flight
drain sees is_cancelled and exits instead of blocking the shutdown join
until the generation ends naturally.
* ui: add missing JSDoc
* server-stream: drain on the http worker, drop the manager thread
Address @ngxson review: httplib runs a large dynamic pool and a worker
blocked in next() sits on a condvar instead of burning cpu, so draining
the rest of the generation on that worker is fine and much simpler than
a dedicated thread.
on_complete calls finish_producer directly again. Removes adopt_orphan,
the orphan thread list and its reaping, the stop_gc session cancel that
only existed to unblock those threads, and the now dead drain_shutdown
flag.
* server-stream: split stream_pipe into producer and consumer classes
Address @ngxson review: one class covering both ends was messy. stream_pipe
is now a base holding the session and is_cancelled, with stream_pipe_producer
(write, mark_producer_done, finish_producer, cleanup, finalizes on destruct)
and stream_pipe_consumer (read only, no finalize) deriving from it.
Drops the is_producer_ discriminator and its runtime guards, the type now
encodes the role. res.spipe is retyped to shared_ptr<stream_pipe_producer>
since it is only ever a producer. No behavior change.
* server-stream: rename producer methods to unix pipe semantics
Address @ngxson review: mark_producer_done becomes done(), finish_producer
becomes close(), matching a unix pipe write end. The producer_done_ member
follows as done_. write() is unchanged. No behavior change.
* server, ui: route resumable streams via a conv map, persist resume identity
Address ngxson review: drop the polling probe, proxy_post records a conv_id ->
model map and the stream routes resolve the owning child with one lookup. The
map is the single source of truth, the ::model suffix stays for child session
uniqueness but the router never parses it.
UI: the server keys a session by the POST time identity (conv::model), but reload
probed with the bare conv id and missed model tagged sessions, so F5 stopped the
stream and sidebar spinners stayed off. Persist the model and rebuild the exact
identity on resume, single conv and bulk sidebar both send it.
Add unit coverage for the identity round trip.
* ui: resolve continue target by id to stop cross-conversation flash on switch
* ui: skip stream resume when the abort is intentional
* server: move the conv id to model map into a self contained tracker
Address review from ngxson: server_models held two mutexes side by side, the
global one and a bare conv_model_mu guarding a loose map, which made the locking
hard to follow. Wrap the map and its lock in a small conv_model_tracker struct
that owns its mutex, one mutex per struct. The remember, lookup and forget
methods move inline into the tracker, server_models exposes a single conv_models
member and the routes call models.conv_models.lookup and friends. No behavior
change, the map stays the single source of truth for routing resumable streams
to a child.
* ui: replace stream magic values with enums and shared constants
Address review from allozaur: lift the inline literals around the resumable
stream code into named symbols so the intent is explicit and reusable.
* ui: fold the stream resume and discovery helpers into ChatService
Address review from allozaur: drop the two standalone stream-*.service files.
They were used only by the chat service and store, carried no shared state, and
did not follow the static class pattern the other services use, so a separate
abstraction was not warranted. Move the helpers onto ChatService as static
methods. No behavior change, tests now exercise them through ChatService.
* docs: document the SSE replay buffer in server README-dev
Add the resumable streaming section, list stream_session_manager in the
backend component inventory, and link PR 23226 in the related PRs.
* ui: align attachServerStream call with onCompletionId param in handleStreamResponse
* server-http: rename del_ to del to match get and post
* ui: address review feedback from allozaur
* ui: drop duplicate SSE constants, keep sse.ts canonical
* ui: use svelte:document for the visibilitychange listener
address review from allozaur: replace the manual document.addEventListener
in onMount with a declarative <svelte:document onvisibilitychange>. svelte
handles attach, detach and SSR, so the typeof document guard and the onMount
cleanup go away. onMount keeps only the first load snapshot.
* server: trim redundant stream drain comments
Address review from ngxson
* server: balance and clean up stream comments
remove redundant comments and tighten the verbose ones across the resumable
stream code, keeping the concurrency and lifetime rationale that is not obvious
from the code. also fix two stale comments in server.cpp and server-models.h
that still described the old ::model suffix probe and fan out routing, now
replaced by the conv_id -> model map
Address review from ngxson
* ui: balance and clean up stream comments
dedup repeated rationale (frozen conv::model identity, the lookup privacy note,
the abort patterns) down to one canonical spot, tighten the verbose blocks, and
keep the concurrency and resume-offset reasoning. fix stale comments in
stream-identity.ts and chat.service.ts that still described the old loopback
probe and fan out routing, now the conv_id -> model map.
---------
Co-authored-by: Xuan Son Nguyen <son@huggingface.co>
356 lines
14 KiB
C++
356 lines
14 KiB
C++
#pragma once
|
|
|
|
#include "common.h"
|
|
#include "download.h"
|
|
#include "preset.h"
|
|
#include "server-common.h"
|
|
#include "server-http.h"
|
|
#include "server-queue.h"
|
|
|
|
#include <mutex>
|
|
#include <condition_variable>
|
|
#include <functional>
|
|
#include <memory>
|
|
#include <optional>
|
|
#include <set>
|
|
#include <string>
|
|
#include <unordered_map>
|
|
|
|
/**
|
|
* state diagram:
|
|
*
|
|
* DOWNLOADING ──► DOWNLOADED ──► (replaced by new instance)
|
|
*
|
|
* UNLOADED ──► LOADING ──► LOADED ◄──── SLEEPING
|
|
* ▲ │ │ ▲
|
|
* └───failed───┘ │ │
|
|
* ▲ └──sleeping─────┘
|
|
* └────────unloaded─────────┘
|
|
*/
|
|
enum server_model_status {
|
|
// TODO: also add downloading state when the logic is added
|
|
SERVER_MODEL_STATUS_DOWNLOADING,
|
|
SERVER_MODEL_STATUS_DOWNLOADED,
|
|
SERVER_MODEL_STATUS_UNLOADED,
|
|
SERVER_MODEL_STATUS_LOADING,
|
|
SERVER_MODEL_STATUS_LOADED,
|
|
SERVER_MODEL_STATUS_SLEEPING
|
|
};
|
|
|
|
enum server_model_source {
|
|
SERVER_MODEL_SOURCE_PRESET,
|
|
SERVER_MODEL_SOURCE_MODELS_DIR,
|
|
SERVER_MODEL_SOURCE_CACHE,
|
|
};
|
|
|
|
enum server_child_mode {
|
|
SERVER_CHILD_MODE_NORMAL, // load the model and run normally
|
|
SERVER_CHILD_MODE_DOWNLOAD, // download the model and exit
|
|
};
|
|
|
|
static std::string server_model_status_to_string(server_model_status status) {
|
|
switch (status) {
|
|
case SERVER_MODEL_STATUS_DOWNLOADING: return "downloading";
|
|
case SERVER_MODEL_STATUS_DOWNLOADED: return "downloaded";
|
|
case SERVER_MODEL_STATUS_UNLOADED: return "unloaded";
|
|
case SERVER_MODEL_STATUS_LOADING: return "loading";
|
|
case SERVER_MODEL_STATUS_LOADED: return "loaded";
|
|
case SERVER_MODEL_STATUS_SLEEPING: return "sleeping";
|
|
default: return "unknown";
|
|
}
|
|
}
|
|
|
|
static std::string server_model_source_to_string(server_model_source source) {
|
|
switch (source) {
|
|
case SERVER_MODEL_SOURCE_PRESET: return "preset";
|
|
case SERVER_MODEL_SOURCE_MODELS_DIR: return "models_dir";
|
|
case SERVER_MODEL_SOURCE_CACHE: return "cache";
|
|
default: return "unknown";
|
|
}
|
|
}
|
|
|
|
struct server_model_meta {
|
|
server_model_source source = SERVER_MODEL_SOURCE_CACHE;
|
|
common_preset preset;
|
|
std::string name;
|
|
std::set<std::string> aliases; // additional names that resolve to this model
|
|
std::set<std::string> tags; // informational tags, not used for routing
|
|
int port = 0;
|
|
server_model_status status = SERVER_MODEL_STATUS_UNLOADED;
|
|
int64_t last_used = 0; // for LRU unloading
|
|
std::vector<std::string> args; // args passed to the model instance, will be populated by render_args()
|
|
json loaded_info; // info to be reflected via /v1/models endpoint ; if in DOWNLOADING state, it should contain download progress info
|
|
json progress; // reflect load or download progress info, if any
|
|
int exit_code = 0; // exit code of the model instance process (only valid if status == FAILED)
|
|
int stop_timeout = 0; // seconds to wait before force-killing the model instance during shutdown
|
|
mtmd_caps multimodal; // multimodal capabilities
|
|
// bool need_download = false; // whether the model needs to be downloaded before loading // TODO @ngxson: implement this
|
|
|
|
bool is_ready() const {
|
|
return status == SERVER_MODEL_STATUS_LOADED;
|
|
}
|
|
|
|
bool is_running() const {
|
|
return status == SERVER_MODEL_STATUS_LOADED || status == SERVER_MODEL_STATUS_LOADING || status == SERVER_MODEL_STATUS_SLEEPING;
|
|
}
|
|
|
|
bool is_failed() const {
|
|
return status == SERVER_MODEL_STATUS_UNLOADED && exit_code != 0;
|
|
}
|
|
|
|
void update_args(common_preset_context & ctx_presets, std::string bin_path);
|
|
void update_caps();
|
|
};
|
|
|
|
struct server_models_routes;
|
|
struct server_subproc; // defined in server-models.cpp
|
|
|
|
struct server_models {
|
|
friend struct server_models_routes;
|
|
|
|
private:
|
|
struct instance_t {
|
|
std::shared_ptr<server_subproc> subproc; // shared between main thread and monitoring thread
|
|
std::thread th;
|
|
server_model_meta meta;
|
|
};
|
|
|
|
std::mutex mutex;
|
|
std::condition_variable cv;
|
|
std::map<std::string, instance_t> mapping;
|
|
|
|
// for stopping models
|
|
std::condition_variable cv_stop;
|
|
std::set<std::string> stopping_models;
|
|
|
|
// set to true while load_models() is executing a reload; load() will wait until clear
|
|
bool is_reloading = false;
|
|
|
|
// if true, the next get_meta() will trigger a reload of model list
|
|
bool need_reload = false;
|
|
|
|
// conv_id -> model name that currently serves its stream session, lets the resumable stream
|
|
// routes go straight to the owning child instead of polling every one. populated when
|
|
// proxy_request forwards a POST carrying an X-Conversation-Id. best effort: a stale entry just
|
|
// makes the child answer not found and the client recovers. owns its lock, one mutex per struct
|
|
struct conv_model_tracker {
|
|
void remember(const std::string & conv_id, const std::string & model) {
|
|
if (conv_id.empty() || model.empty()) {
|
|
return;
|
|
}
|
|
std::lock_guard<std::mutex> lock(mu);
|
|
map[conv_id] = model;
|
|
}
|
|
|
|
std::optional<std::string> lookup(const std::string & conv_id) {
|
|
if (conv_id.empty()) {
|
|
return std::nullopt;
|
|
}
|
|
std::lock_guard<std::mutex> lock(mu);
|
|
auto it = map.find(conv_id);
|
|
if (it == map.end()) {
|
|
return std::nullopt;
|
|
}
|
|
return it->second;
|
|
}
|
|
|
|
void forget(const std::string & conv_id) {
|
|
if (conv_id.empty()) {
|
|
return;
|
|
}
|
|
std::lock_guard<std::mutex> lock(mu);
|
|
map.erase(conv_id);
|
|
}
|
|
|
|
private:
|
|
std::mutex mu;
|
|
std::unordered_map<std::string, std::string> map;
|
|
};
|
|
|
|
common_preset_context ctx_preset;
|
|
|
|
common_params base_params;
|
|
std::string bin_path;
|
|
std::vector<std::string> base_env;
|
|
common_preset base_preset; // base preset from llama-server CLI args
|
|
|
|
void update_meta(const std::string & name, const server_model_meta & meta);
|
|
|
|
// unload least recently used models if the limit is reached
|
|
void unload_lru();
|
|
|
|
// not thread-safe, caller must hold mutex
|
|
void add_model(server_model_meta && meta);
|
|
|
|
// notify SSE clients
|
|
void notify_sse(const std::string & event, const std::string & model_id, const json & data = nullptr);
|
|
|
|
public:
|
|
// conv_id -> model tracker for the resumable stream routes, owns its lock
|
|
conv_model_tracker conv_models;
|
|
|
|
server_models(const common_params & params, int argc, char ** argv);
|
|
|
|
server_response sse; // for real-time updates via SSE endpoint
|
|
|
|
// (re-)load the list of models from various sources and prepare the metadata mapping
|
|
// - if this is called the first time, simply populate the metadata
|
|
// - if this is called subsequently (e.g. when refreshing from disk):
|
|
// - if a model is running but updated or removed from the source, it will be unloaded
|
|
// - if a model is not running, it will be added or updated according to the source
|
|
void load_models();
|
|
|
|
// check if a model instance exists (thread-safe)
|
|
bool has_model(const std::string & name);
|
|
|
|
// return a copy of model metadata (thread-safe)
|
|
std::optional<server_model_meta> get_meta(const std::string & name);
|
|
|
|
// return a copy of all model metadata (thread-safe)
|
|
std::vector<server_model_meta> get_all_meta();
|
|
|
|
struct load_options {
|
|
server_child_mode mode = SERVER_CHILD_MODE_NORMAL;
|
|
// used for spawning a downloading child process
|
|
std::optional<server_model_meta> custom_meta = std::nullopt;
|
|
};
|
|
|
|
// load and unload model instances
|
|
// these functions are thread-safe
|
|
void load(const std::string & name);
|
|
void load(const std::string & name, const load_options & opts);
|
|
void unload(const std::string & name);
|
|
void unload_all();
|
|
|
|
struct update_status_args {
|
|
server_model_status status;
|
|
int exit_code = 0; // only valid if status == UNLOADED
|
|
json loaded_info = nullptr;
|
|
json progress = nullptr;
|
|
};
|
|
// update the status of a model instance (thread-safe)
|
|
// also send SSE notification to /models/sse endpoint
|
|
void update_status(const std::string & name, const update_status_args & args);
|
|
void update_download_progress(const std::string & name, const common_download_progress & progress, bool done, bool ok = true);
|
|
|
|
// remove a cache model from disk and update the list (thread-safe)
|
|
// note: only cache models can be removed; returns false if the model doesn't exist or is not a cache model
|
|
bool remove(const std::string & name);
|
|
|
|
// wait until the model instance is fully loaded (thread-safe)
|
|
// note: predicate is called while holding the lock
|
|
// return when the model no longer in "loading" state
|
|
void wait(const std::string & name, std::function<bool(const server_model_meta &)> predicate);
|
|
void wait(std::unique_lock<std::mutex> & lk, const std::string & name, std::function<bool(const server_model_meta &)> predicate);
|
|
|
|
// ensure the model is in ready state (thread-safe)
|
|
// return false if model is ready
|
|
// otherwise, load the model and blocking wait until it's ready, then return true (meta may need to be refreshed)
|
|
bool ensure_model_ready(const std::string & name);
|
|
|
|
// proxy an HTTP request to the model instance
|
|
server_http_res_ptr proxy_request(const server_http_req & req, const std::string & method, const std::string & name, bool update_last_used);
|
|
|
|
// handle message sent from server_child::notify_to_router()
|
|
// raw input must starts with CMD_CHILD_TO_ROUTER_STATE, followed by a JSON string
|
|
// this function is not thread-safe, must be called from instance's monitoring thread
|
|
// payload per state:
|
|
// state = loading -> payload = {} (TODO: add progress info)
|
|
// state = ready -> payload = model_info (json), or {} if wakeup from sleeping
|
|
// state = sleeping -> payload = {}
|
|
void handle_child_state(const std::string & name, const std::string & raw_input);
|
|
};
|
|
|
|
struct server_child {
|
|
// serializes the notify_to_router writes
|
|
std::mutex mtx_stdout;
|
|
std::atomic<bool> is_finished_downloading = false; // set by run_download
|
|
|
|
// return true if the current process is a child server instance
|
|
bool is_child();
|
|
server_child_mode get_mode();
|
|
int run_download(common_params & params);
|
|
|
|
// register the shutdown_handler to be called by the router
|
|
// return the monitoring thread (to be joined by the caller)
|
|
std::thread setup(const std::function<void(int)> & shutdown_handler);
|
|
|
|
// notify router server for status changes (e.g. loading, downloading, sleeping, etc.)
|
|
// message will be handled by server_models::handle_child_state() on the router side
|
|
void notify_to_router(const std::string & state_name, const json & payload);
|
|
};
|
|
|
|
struct server_models_routes {
|
|
common_params params;
|
|
json ui_settings = json::object(); // Primary: new name
|
|
std::atomic<bool> stopping = false; // for graceful disconnecting SSE clients during shutdown
|
|
server_models models;
|
|
server_models_routes(const common_params & params, int argc, char ** argv)
|
|
: params(params), models(params, argc, argv) {
|
|
const std::string & cfg = this->params.ui_config_json;
|
|
if (!cfg.empty()) {
|
|
try {
|
|
json json_settings = json::parse(cfg);
|
|
ui_settings = json_settings;
|
|
} catch (const std::exception & e) {
|
|
LOG_ERR("%s: failed to parse UI config: %s\n", __func__, e.what());
|
|
throw;
|
|
}
|
|
}
|
|
init_routes();
|
|
}
|
|
|
|
void init_routes();
|
|
// handlers using lambda function, so that they can capture `this` without `std::bind`
|
|
server_http_context::handler_t get_router_props;
|
|
server_http_context::handler_t proxy_get;
|
|
server_http_context::handler_t proxy_post;
|
|
server_http_context::handler_t get_router_models;
|
|
server_http_context::handler_t post_router_models_load;
|
|
server_http_context::handler_t post_router_models_unload;
|
|
// management API
|
|
server_http_context::handler_t get_router_models_sse;
|
|
server_http_context::handler_t post_router_models;
|
|
server_http_context::handler_t del_router_models;
|
|
|
|
// router side handlers for the resumable streaming routes. each resolves the child that owns
|
|
// a conversation through the conv_id -> model map, no probing or fan out
|
|
server_http_context::handler_t router_stream_get;
|
|
server_http_context::handler_t router_streams_lookup;
|
|
server_http_context::handler_t router_stream_delete;
|
|
};
|
|
|
|
/**
|
|
* A simple HTTP proxy that forwards requests to another server
|
|
* and relays the responses back.
|
|
*/
|
|
struct server_http_proxy : server_http_res {
|
|
std::function<void()> cleanup = nullptr;
|
|
public:
|
|
server_http_proxy(const std::string & method,
|
|
const std::string & scheme,
|
|
const std::string & host,
|
|
int port,
|
|
const std::string & path,
|
|
const std::map<std::string, std::string> & headers,
|
|
const std::string & body,
|
|
const std::map<std::string, uploaded_file> & files,
|
|
const std::function<bool()> should_stop,
|
|
int32_t timeout_read,
|
|
int32_t timeout_write
|
|
);
|
|
~server_http_proxy() {
|
|
if (cleanup) {
|
|
cleanup();
|
|
}
|
|
}
|
|
private:
|
|
std::thread thread;
|
|
struct msg_t {
|
|
std::map<std::string, std::string> headers;
|
|
int status = 0;
|
|
std::string data;
|
|
std::string content_type;
|
|
};
|
|
};
|