ember.cpp 0.1.0
C++ inference engine for Llama-family and paperkiln models (CPU, CUDA, WebAssembly)
Loading...
Searching...
No Matches
server.cpp File Reference

HTTP server implementation for TinyLlama chat interface. More...

#include "httplib.h"
#include <algorithm>
#include <cstdio>
#include <random>
#include <ctime>
#include <chrono>
#include <atomic>
#include <filesystem>
#include <fstream>
#include <functional>
#include <memory>
#include <mutex>
#include <nlohmann/json.hpp>
#include <string>
#include <thread>
#include <vector>
#include "api.h"
#include "chat_reply.h"
#include "chat_request.h"
#include "determinism.h"
#include "logger.h"
#include "score.h"
#include "tokenizer.h"
#include "model_macros.h"
#include "utf8_text.h"
Include dependency graph for server.cpp:

Go to the source code of this file.

Classes

struct  BpeChatRequest
 
struct  BpePrompt
 

Typedefs

using json = nlohmann::json
 

Functions

static std::string json_out (const json &j)
 
static std::string bpe_repeat_fields (const json &body, BpeChatRequest &r, int ctx, bool clamp)
 
static BpePrompt bpe_build_prompt (tinyllama::TinyLlamaSession &sess, const BpeChatRequest &r)
 
static json bpe_run (tinyllama::TinyLlamaSession &sess, const BpeChatRequest &r, const BpePrompt &p, std::chrono::steady_clock::time_point t_request, double waited_ms, const std::function< void(const std::string &)> &on_piece)
 
int main (int argc, char **argv)
 

Detailed Description

HTTP server implementation for TinyLlama chat interface.

This server provides a REST API for interacting with TinyLlama models. It handles both GGUF and SafeTensors models, applying appropriate prompt formatting for each:

  • For GGUF models: Applies Q:A format
  • For SafeTensors models: Uses the tokenizer's chat template

The server exposes a /chat endpoint that accepts POST requests with JSON body: { "user_input": "string", // Required: The prompt text "temperature": float, // Optional: Sampling temperature (default: 0.1) "max_new_tokens": int, // Optional: Max tokens to generate (default: 60) "top_k": int, // Optional: Top-K sampling parameter (default: 40) "top_p": float // Optional: Top-P sampling parameter (default: 0.9) }

Usage: tinyllama_server [model_path] [port] [host] [www_path] model_path: Path to model directory or .gguf file (default: data) port: Server port (default: 8080) host: Host to bind to (default: localhost) www_path: Path to static web files (default: ./www)

Definition in file server.cpp.

Typedef Documentation

◆ json

using json = nlohmann::json

Definition at line 68 of file server.cpp.

Function Documentation

◆ bpe_build_prompt()

static BpePrompt bpe_build_prompt ( tinyllama::TinyLlamaSession &  sess,
const BpeChatRequest &  r 
)
static

Definition at line 129 of file server.cpp.

129 {
131 *sess.chat_format(), r.conv, r.max_new_tokens, sess.get_config().max_position_embeddings,
132 [&](const std::string& text) { return sess.bpe_token_count(text); });
134 out.prompt = p.prompt;
135 out.prompt_tokens = p.prompt_tokens;
136 out.turns_used = p.turns_used;
137 out.error = p.error;
138 return out;
139}
const float * p[kIrDepthSourcesMax]
std::string text
node out
size_t j
ChatPrompt build_chat_prompt(const ChatFormat &f, const Conversation &c, int max_new, int ctx, Count count)
std::string prompt
Definition server.cpp:122

References ember::build_chat_prompt(), j, out, p, BpePrompt::prompt, and text.

Referenced by main().

◆ bpe_repeat_fields()

static std::string bpe_repeat_fields ( const json &  body,
BpeChatRequest &  r,
int  ctx,
bool  clamp 
)
static

Definition at line 99 of file server.cpp.

99 {
100 if (body.contains("repeat_penalty")) {
101 if (!body["repeat_penalty"].is_number()) return "repeat_penalty must be a number";
102 r.repeat_penalty = body["repeat_penalty"].get<float>();
103 }
104 if (body.contains("repeat_last_n")) {
105 if (!body["repeat_last_n"].is_number_integer()) return "repeat_last_n must be an integer";
106 r.repeat_last_n = body["repeat_last_n"].get<int>();
107 } else {
108 r.repeat_last_n = std::min(r.repeat_last_n, ctx); // default min(64, ctx), as mtstudio
109 }
110 if (clamp) {
111 r.repeat_penalty = std::min(std::max(r.repeat_penalty, 1.0f), 2.0f);
112 r.repeat_last_n = std::min(std::max(r.repeat_last_n, 0), ctx);
113 return "";
114 }
115 if (!(r.repeat_penalty >= 1.0f && r.repeat_penalty <= 2.0f)) return "repeat_penalty must be in [1.0, 2.0]";
116 if (r.repeat_last_n < 0 || r.repeat_last_n > ctx)
117 return "repeat_last_n must be in [0, " + std::to_string(ctx) + "]";
118 return "";
119}

References body, and j.

Referenced by main().

◆ bpe_run()

static json bpe_run ( tinyllama::TinyLlamaSession &  sess,
const BpeChatRequest &  r,
const BpePrompt &  p,
std::chrono::steady_clock::time_point  t_request,
double  waited_ms,
const std::function< void(const std::string &)> &  on_piece 
)
static

Definition at line 151 of file server.cpp.

153 {
154 if (r.has_seed) sess.set_seed(r.seed);
155 const int call_id = sess.chat_format()->tool_call_id;
156 const ember::ChatDecode decode = [&sess](const std::vector<int>& v) { return sess.bpe_decode(v); };
158 if (on_piece) {
159 sess.set_token_callback([&](int id) {
160 const std::string piece = builder.next(id);
161 if (!piece.empty()) on_piece(piece);
162 });
163 }
164 sess.set_repeat_penalty(r.repeat_penalty, r.repeat_last_n);
165 const std::string reply = sess.generate(p.prompt, r.max_new_tokens, r.temperature, r.top_k, r.top_p, "", false);
166 sess.set_repeat_penalty(1.0f, 64);
167 sess.set_token_callback(nullptr);
169 if (on_piece) { // the end: a character still cut, and the text before it
170 const std::string piece = builder.finish(streamed);
171 if (!piece.empty()) on_piece(piece);
172 }
173 const tinyllama::TtftBreakdown& tb = sess.last_ttft();
174 auto ms = [](auto a, auto b) { return std::chrono::duration<double, std::milli>(b - a).count(); };
176 if (call_id < 0) {
177 // the decoded bytes can end inside a character or hold byte tokens that make none (utf8_text.h)
178 res_json["reply"] = ember::utf8_valid(reply);
179 } else {
180 const ember::ChatReply cr = on_piece ? streamed : ember::reply_of(sess.last_generated_ids(), call_id, decode);
181 res_json["reply"] = cr.content;
182 if (!cr.tool_calls.empty()) res_json["tool_calls"] = ember::tool_calls_json<json>(cr.tool_calls);
183 }
184 res_json["stop_reason"] = sess.last_stop_reason();
185 res_json["tokens"] = sess.last_generated_tokens();
186 res_json["prompt_tokens"] = p.prompt_tokens;
187 res_json["history_turns_used"] = p.turns_used;
188 // Opt-in exact ids (preference data needs the sampled ids, not a
189 // re-encoding of the stripped reply text).
190 if (r.want_ids) {
191 res_json["prompt_ids"] = sess.last_prompt_ids();
192 res_json["ids"] = sess.last_generated_ids();
193 res_json["stop_id"] = sess.last_stop_id();
194 }
195 res_json["ttft"] = {{"first_token", tb.first_token},
196 {"queue_ms", waited_ms},
197 {"request_to_first_token_ms", tb.first_token ? ms(t_request, tb.t_first_token) : -1.0},
198 {"ttft_ms", tb.ttft_ms},
199 {"num_prompt_tokens", tb.num_prompt_tokens},
200 {"prefix_reused_tokens", tb.prefix_reused_tokens}, {"spec_drafted", tb.spec_drafted}, {"spec_accepted", tb.spec_accepted}, {"spec_passes", tb.spec_passes},
201 {"batch_prefill", tb.batch_prefill},
202 {"prefill_wall_ms", tb.prefill_wall_ms},
203 {"prefill_gpu_span_ms", tb.prefill_gpu_span_ms}};
204 if (tb.spec_draft_calls > 0) { // speculation on (EMBER_SPEC): the drafter's own time
205 res_json["ttft"]["spec_draft_ms"] = tb.spec_draft_ms;
206 res_json["ttft"]["spec_draft_calls"] = tb.spec_draft_calls;
207 }
208 return res_json;
209}
std::vector< float > v
std::vector< E > a
std::function< std::string(const std::vector< int > &)> ChatDecode
Definition chat_reply.h:30
ChatReply reply_of(const std::vector< int > &ids, int call_id, const ChatDecode &decode)
Definition chat_reply.h:45
std::string utf8_valid(const std::string &s)
Definition utf8_text.h:74
nlohmann::json json
Definition server.cpp:68
Where the time went between generate() being called and its first output token being emitted (docs/TT...
Definition api.h:37

References a, j, p, ember::reply_of(), ember::utf8_valid(), and v.

Referenced by main().

◆ json_out()

static std::string json_out ( const json &  j)
static

Definition at line 72 of file server.cpp.

72{ return ember::dump_valid(j); }
std::string dump_valid(const Json &j)
Definition utf8_text.h:153

References ember::dump_valid(), and j.

Referenced by main().

◆ main()

int main ( int  argc,
char **  argv 
)

Definition at line 211 of file server.cpp.

211 {
212 std::string model_dir = "data";
213 std::string host = "localhost";
214 int port = 8080;
215 std::string www_path = "./www";
216
217 if (argc > 1) {
218 model_dir = argv[1];
219 }
220 if (argc > 2) {
221 port = std::stoi(argv[2]);
222 }
223 if (argc > 3) {
224 host = argv[3];
225 }
226 if (argc > 4) {
227 www_path = argv[4];
228 }
229 int n_gpu_layers = -1; // -1 all layers on GPU, 0 none, N that many
230 if (argc > 5) {
231 n_gpu_layers = std::stoi(argv[5]);
232 }
233
234 Logger::info("Starting TinyLlama Chat Server...");
236 Logger::info("EMBER_DETERMINISTIC set: deterministic CUDA mode (run-to-run bit-identical logits; /health "
237 "reports \"deterministic\": true).");
238
239 std::shared_ptr<tinyllama::TinyLlamaSession> session;
240 try {
241 Logger::info("Loading model from: " + model_dir);
242 session = std::make_shared<tinyllama::TinyLlamaSession>(model_dir, "tokenizer.json", 4, n_gpu_layers, true);
243 Logger::info("Model loaded successfully.");
244 } catch (const std::exception& e) {
245 Logger::error(std::string("Failed to load model: ") + e.what());
246 return 1;
247 }
248
249 httplib::Server svr;
250 // EMBER_PUBLIC=1: settings for serving strangers on the open internet.
251 // Warnings-only logging (nothing a visitor types reaches the log), a small
252 // request size cap, socket timeouts, a bounded wait queue, and per-request
253 // caps on input, history and generation (applied in /chat below).
254 static const bool public_mode = [] {
255 const char* v = std::getenv("EMBER_PUBLIC");
256 return v && std::string(v) == "1";
257 }();
258 if (public_mode) {
260 svr.set_payload_max_length(64 * 1024);
261 svr.set_read_timeout(10, 0);
262 svr.set_write_timeout(30, 0);
263 }
264 // Test hook (TinyLlamaSession::set_scripted_replies): EMBER_TEST_SCRIPTED_REPLIES names a JSON file,
265 // a list of id lists; the chat requests take them in order in place of sampling. Refused on a public
266 // server.
267 if (const char* scripted = std::getenv("EMBER_TEST_SCRIPTED_REPLIES")) {
268 if (public_mode) {
269 Logger::error("EMBER_TEST_SCRIPTED_REPLIES is a test hook: refused under EMBER_PUBLIC=1");
270 return 1;
271 }
272 std::ifstream f(scripted);
273 const json s = json::parse(f, nullptr, false);
274 if (!session->is_bpe() || s.is_discarded() || !s.is_array()) {
275 Logger::error(std::string("EMBER_TEST_SCRIPTED_REPLIES: ") + scripted +
276 " is not a JSON list of id lists, or the model has no chat mode");
277 return 1;
278 }
279 session->set_scripted_replies(s.get<std::vector<std::vector<int>>>());
280 Logger::warning("EMBER_TEST_SCRIPTED_REPLIES: " + std::to_string(s.size()) +
281 " scripted replies stand in for sampling (a test server)");
282 }
283
284 if (std::filesystem::exists(www_path) &&
285 std::filesystem::is_directory(www_path)) {
286 Logger::info("Serving static files from: " + www_path);
287 bool mount_ok = svr.set_mount_point("/", www_path);
288 if (!mount_ok) {
289 Logger::error("Failed to mount static file directory: " + www_path);
290 return 1;
291 }
292 } else {
293 Logger::info("Static file directory not found: " + www_path +
294 ". Web client will not be served.");
295 }
296
297 // One session (one KV cache, one set of device buffers, one RNG) serves
298 // every request, and httplib runs handlers on a thread pool: requests
299 // must take turns. Without this, parallel /chat calls raced on the
300 // session and returned HTTP 500s or each other's state (19 Oct 2026,
301 // tools/chat_concurrency_check.py).
302 static std::mutex session_mu;
303 // Requests in a handler or a stream provider (waiting or generating).
304 static std::atomic<int> waiting{0};
305 struct Waiting {
306 Waiting() { ++waiting; }
307 ~Waiting() { --waiting; }
308 };
309 svr.Post("/chat", [&session](const httplib::Request& req,
310 httplib::Response& res) {
311 // TTFT clock starts here: the earliest point the server owns the request.
312 const auto t_request = std::chrono::steady_clock::now();
313 // Public mode: refuse rather than queue without bound.
315 if (public_mode && waiting.load() > 8) {
316 res.status = 503;
317 res.set_header("Access-Control-Allow-Origin", "*");
318 res.set_header("Retry-After", "2");
319 res.set_content(json_out(json{{"error", "busy: too many people chatting at once, try again in a moment"}}),
320 "application/json");
321 return;
322 }
323 std::lock_guard<std::mutex> session_lock(session_mu);
324 const double queue_ms =
325 std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - t_request).count();
326 Logger::info("Received request for /chat");
327 res.set_header("Access-Control-Allow-Origin", "*");
328 res.set_header("Access-Control-Allow-Methods", "POST, OPTIONS");
329 res.set_header("Access-Control-Allow-Headers", "Content-Type");
330
331 std::string user_input_from_client;
332
333 float temperature = 0.1f; // Lower temperature for more focused chat responses
334 int max_new_tokens = 60;
335 int top_k = 40; // Default top-k value
336 float top_p = 0.9f; // Default top-p value
337
338 // A record with tool turns (paperkiln/2): user_input may be left out after a
339 // tool message, and the history may hold tool calls and results (chat_request.h).
340 const microtorch::chat::Template* chat_tmpl = session->is_bpe() ? &session->chat_format()->tmpl : nullptr;
341 const bool tool_turns = chat_tmpl && chat_tmpl->has_tool;
342 try {
343 json req_json = json::parse(req.body);
344 if (req_json.contains("user_input")) {
345 user_input_from_client = req_json["user_input"].get<std::string>();
346 } else if (!tool_turns) {
347 throw std::runtime_error("Missing 'user_input' field in request JSON");
348 }
349
350 if (req_json.contains("max_new_tokens"))
351 max_new_tokens = req_json["max_new_tokens"].get<int>();
352 if (req_json.contains("temperature"))
353 temperature = req_json["temperature"].get<float>();
354 if (req_json.contains("top_k"))
355 top_k = req_json["top_k"].get<int>();
356 if (req_json.contains("top_p"))
357 top_p = req_json["top_p"].get<float>();
358
359 if (public_mode) {
360 constexpr size_t kMaxText = 600; // bytes per message
361 if (user_input_from_client.size() > kMaxText) {
362 res.status = 400;
363 res.set_header("Access-Control-Allow-Origin", "*");
364 res.set_content(json_out(json{{"error", "message too long (600 characters at most)"}}), "application/json");
365 return;
366 }
367 max_new_tokens = std::min(std::max(max_new_tokens, 1), 64);
368 temperature = std::min(std::max(temperature, 0.0f), 1.5f);
369 top_k = std::min(std::max(top_k, 0), 200);
370 top_p = std::min(std::max(top_p, 0.05f), 1.0f);
371 req_json.erase("return_ids");
372 if (req_json.contains("system") && req_json["system"].is_string() &&
373 req_json["system"].get<std::string>().size() > kMaxText)
374 req_json["system"] = req_json["system"].get<std::string>().substr(0, kMaxText);
375 if (req_json.contains("history") && req_json["history"].is_array()) {
376 json& hist = req_json["history"];
377 // The context holds ~5 turns anyway; a leading system entry is pinned.
378 const bool lead_system = !hist.empty() && hist[0].is_object() && hist[0].value("role", "") == "system";
379 const bool capped = hist.size() > 16;
380 while (hist.size() > 16) hist.erase(hist.begin() + (lead_system ? 1 : 0));
381 // Tool turns: the cap keeps whole exchanges (it drops the rest of a cut one).
382 const size_t at = lead_system ? 1 : 0;
383 while (tool_turns && capped && hist.size() > at && hist[at].is_object() && hist[at].contains("role") &&
384 hist[at].value("role", "") != "user")
385 hist.erase(hist.begin() + static_cast<std::ptrdiff_t>(at));
386 for (auto& h : hist)
387 for (const char* key : {"content", "user", "assistant"})
388 if (h.is_object() && h.contains(key) && h[key].is_string() &&
389 h[key].get<std::string>().size() > kMaxText)
390 h[key] = h[key].get<std::string>().substr(0, kMaxText);
391 }
392 }
393
394 // Chat-mode models: the same /chat contract as mtstudio chat
395 // (LOCAL_LLM_STACK.md section 3, points 3-5). The request is a prompt
396 // conversation (optional "system" or leading system history entry,
397 // the history, user_input, "thinking"), rendered by the model's chat
398 // template, with the oldest whole exchanges dropped until
399 // prompt + max_new_tokens fits the context (the system message pinned).
400 if (session->is_bpe()) {
402 try {
404 tool_turns ? ember::ordered_tools(req.body) : microtorch::chat::json());
405 } catch (const ember::RequestError& e) {
406 res.status = 400;
407 res.set_content(json_out(json{{"error", e.what()}}), "application/json");
408 return;
409 }
410 r.max_new_tokens = max_new_tokens;
411 r.temperature = temperature;
412 r.top_k = top_k;
413 r.top_p = top_p;
414 r.has_seed = req_json.contains("seed");
415 r.seed = r.has_seed ? req_json["seed"].get<unsigned>() : 0u;
416 r.want_ids = req_json.value("return_ids", false);
417 const std::string rp_error = bpe_repeat_fields(
418 req_json, r, session->get_config().max_position_embeddings, public_mode);
419 if (!rp_error.empty()) {
420 res.status = 400;
421 res.set_content(json_out(json{{"error", rp_error}}), "application/json");
422 return;
423 }
426 try {
428 } catch (const microtorch::chat::Error& e) {
429 res.status = 400; // the template refuses the conversation (section 3, point 3)
430 res.set_content(json_out(json{{"error", e.what()}}), "application/json");
431 return;
432 }
433 if (!bp.error.empty()) {
434 res.status = 400;
435 res.set_content(json_out(json{{"error", bp.error}}), "application/json");
436 return;
437 }
438 if (!req_json.value("stream", false)) {
439 res.set_content(json_out(bpe_run(*sess, r, bp, t_request, queue_ms, nullptr)), "application/json");
440 return;
441 }
442 // "stream": true -> server-sent events: {"token": text} as the
443 // reply is generated, then the full reply object with
444 // "done": true (with "tool_calls" when the reply has calls). The
445 // provider runs after this handler returns, so it takes the
446 // session lock itself.
447 res.set_header("Cache-Control", "no-cache");
448 res.set_chunked_content_provider(
449 "text/event-stream", [r, bp, t_request, sess](size_t, httplib::DataSink& sink) {
451 std::lock_guard<std::mutex> stream_lock(session_mu);
452 const double waited = std::chrono::duration<double, std::milli>(
453 std::chrono::steady_clock::now() - t_request).count();
454 auto send = [&sink](const json& j) {
455 const std::string ev = "data: " + json_out(j) + "\n\n";
456 sink.write(ev.data(), ev.size());
457 };
459 [&](const std::string& piece) { send(json{{"token", piece}}); });
460 final_json["done"] = true;
462 sink.done();
463 return true;
464 });
465 return;
466 }
467
468 Logger::info("Processing user input: " +
469 ember::utf8_valid(user_input_from_client.substr(0, 100)) + "...");
470
471 const ModelConfig& config = session->get_config();
472 std::string prompt_for_session_generate;
474
475 const Tokenizer* tokenizer = session->get_tokenizer();
476
477 if (config.is_gguf_file_loaded) {
479 // WORD_LEVEL models (transformer_cpp exports) understand their
480 // training format, not free-form chat. Probe the embedded vocab
481 // once to tell dialogue models (DailyDialog: has "assistant:")
482 // from story models (TinyStories-Instruct), then wrap natural
483 // input in the matching template. Power users typing explicit
484 // "summary:/words:/story:/user:" prompts pass through verbatim.
485 if (config.tokenizer_family == ModelConfig::TokenizerFamily::WORD_LEVEL) {
486 static int dialogue_model = -1; // -1 unknown, probed once
487 if (dialogue_model < 0 && tokenizer) {
488 auto ids = tokenizer->encode("assistant:", false, false);
489 int unk = 0; // word-level UNK id
490 dialogue_model = (!ids.empty() && ids[0] != unk) ? 1 : 0;
491 Logger::info(std::string("WORD_LEVEL model type probed: ") +
492 (dialogue_model ? "dialogue" : "story"));
493 }
494 std::string lower = user_input_from_client;
495 std::transform(lower.begin(), lower.end(), lower.begin(), ::tolower);
496 bool has_field = lower.find("story:") != std::string::npos ||
497 lower.find("summary:") != std::string::npos ||
498 lower.find("words:") != std::string::npos ||
499 lower.rfind("once upon", 0) == 0 ||
500 lower.rfind("user:", 0) == 0;
501 if (!has_field) {
502 if (dialogue_model == 1) {
503 // The word-level record (paperkiln/legacy-word, resolved at load):
504 // "user: X assistant:". History and system are not served on this path.
505 const microtorch::chat::Template* wt = session->chat_template();
509 : "user: " + user_input_from_client + " assistant:";
510 Logger::info("WORD_LEVEL: wrapped input as dialogue turn.");
511 } else {
513 "summary: " + user_input_from_client + " story:";
514 Logger::info("WORD_LEVEL: wrapped free-form input as story request.");
515 }
516 }
517 }
518 // Check for Llama 3 tokenizer family to disable Q&A for it
521 Logger::info("GGUF Llama 3 model detected (via tokenizer_family). Q&A prompt formatting will be DISABLED for session generate.");
522 } else {
525 "GGUF (Non-Llama 3) model detected. Using Q:A: format via session->generate.");
526 }
527 } else {
528 std::string system_prompt_text = "You are a helpful AI.";
529 if (tokenizer) {
530 prompt_for_session_generate = tokenizer->apply_chat_template(
533 "Safetensors model detected. Applied chat template via "
534 "tokenizer. Prompt: " +
535 ember::utf8_valid(prompt_for_session_generate.substr(0, 200)) + "...");
536 } else {
538 "CRITICAL: Tokenizer not available for Safetensors model in "
539 "server. Cannot apply chat template.");
540
542 }
544 }
545
546 std::string reply = session->generate(
547 prompt_for_session_generate, max_new_tokens, temperature, top_k, top_p, "",
549 // Dialogue models continue the whole transcript (both speakers):
550 // cut the reply at the next turn marker so the UI shows one turn.
551 {
552 size_t cut = reply.find("user:");
553 if (cut != std::string::npos && cut > 0) {
554 reply = reply.substr(0, cut);
555 }
556 }
557 Logger::info("Generated reply: " + ember::utf8_valid(reply.substr(0, 50)) + "...");
558
560 res_json["reply"] = ember::utf8_valid(reply);
561 res_json["stop_reason"] = session->last_stop_reason();
562 res_json["tokens"] = session->last_generated_tokens();
563
564 // TTFT receipt (docs/TTFT.md). request_to_first_token_ms adds the
565 // server's own parsing/templating before generate() to ttft_ms.
566 const tinyllama::TtftBreakdown& tb = session->last_ttft();
567 auto ms = [](auto a, auto b) {
568 return std::chrono::duration<double, std::milli>(b - a).count();
569 };
570 res_json["ttft"] = {
571 {"first_token", tb.first_token},
572 {"queue_ms", queue_ms},
573 {"request_to_first_token_ms",
574 tb.first_token ? ms(t_request, tb.t_first_token) : -1.0},
575 {"server_pre_generate_ms", ms(t_request, tb.t_entry)},
576 {"ttft_ms", tb.ttft_ms},
577 {"num_prompt_tokens", tb.num_prompt_tokens},
578 {"batch_prefill", tb.batch_prefill},
579 {"tokenize_ms", tb.tokenize_ms},
580 {"kv_reset_ms", tb.kv_reset_ms},
581 {"embed_ms", tb.embed_ms},
582 {"prefill_wall_ms", tb.prefill_wall_ms},
583 {"prefill_gpu_span_ms", tb.prefill_gpu_span_ms},
584 {"sample_ms", tb.sample_ms},
585 {"prefix_prev_cached_tokens", tb.prefix_prev_cached_tokens},
586 {"prefix_reusable_tokens", tb.prefix_reusable_tokens},
587 {"prefix_reused_tokens", tb.prefix_reused_tokens}, {"spec_drafted", tb.spec_drafted}, {"spec_accepted", tb.spec_accepted}, {"spec_passes", tb.spec_passes},
588 {"prefix_hit_rate", tb.prefix_hit_rate},
589 {"op_ms", tb.op_ms}};
590 if (tb.spec_draft_calls > 0) { // speculation on (EMBER_SPEC): the drafter's own time
591 res_json["ttft"]["spec_draft_ms"] = tb.spec_draft_ms;
592 res_json["ttft"]["spec_draft_calls"] = tb.spec_draft_calls;
593 }
594
595 res.set_content(json_out(res_json), "application/json");
596 Logger::info("Response sent successfully.");
597
598 } catch (const json::parse_error& e) {
599 Logger::error("JSON parsing error: " + std::string(e.what()));
600 res.status = 400;
602 err_json["error"] = "Invalid JSON format: " + std::string(e.what());
603 res.set_content(json_out(err_json), "application/json");
604 } catch (const std::exception& e) {
605 Logger::error("Generation error: " + std::string(e.what()));
606 res.status = 500;
608 err_json["error"] = "Internal server error: " + std::string(e.what());
609 res.set_content(json_out(err_json), "application/json");
610 }
611 });
612
613 // Liveness and what is being served (same path as mtstudio chat).
614 svr.Get("/health", [&session, &model_dir](const httplib::Request&, httplib::Response& res) {
615 const ModelConfig& c = session->get_config();
616 json h;
617 h["status"] = "ok";
618 h["model"] = model_dir;
619 h["tokenizer"] = session->is_bpe() ? "bpe" : "ember";
620 // The chat template resolved at load (LOCAL_LLM_STACK.md section 3, point 4), null for raw
621 // completion only; chat_template_why says how it was resolved, or why there is none.
622 if (const microtorch::chat::Template* t = session->chat_template())
623 h["chat_template"] = {{"name", t->name}, {"version", json::parse(t->version.dump())}};
624 else
625 h["chat_template"] = nullptr;
626 h["chat_template_why"] = session->chat_template_why();
627 h["context"] = c.max_position_embeddings;
628 h["vocab_size"] = c.vocab_size;
629 h["hidden_size"] = c.hidden_size;
630 h["layers"] = c.num_hidden_layers;
631 // EMBER_DETERMINISTIC=1 (determinism.h): reported only when on, so the default /health is unchanged.
632 if (ember::deterministic()) h["deterministic"] = true;
633 res.set_header("Access-Control-Allow-Origin", "*");
634 res.set_content(json_out(h), "application/json");
635 });
636
637 // ---- OpenAI-compatible API (chat-mode models) -------------------------
638 // POST /v1/chat/completions and GET /v1/models, so standard clients (the
639 // openai SDKs, chat front ends) can talk to ember. `messages` map one to
640 // one onto the model's chat template (ember::openai_conversation): system
641 // and developer messages before the first user message join with "\n\n"
642 // into the system message, a later one or a tool message is a 400, user and
643 // assistant messages alternate, and the last message must be from the user.
644 // Under a record with tool turns (paperkiln/2) tool messages and assistant
645 // tool_calls map one to one, "tools" passes through, the last message is the
646 // user's or a tool's, and a reply with calls carries message.tool_calls
647 // (finish_reason "tool_calls"; streamed, one whole delta per call).
648 // Defaults follow OpenAI's (temperature 1, top_p 1); top_k, thinking and
649 // chat_template_kwargs.enable_thinking are accepted as extensions.
650 const std::string model_id = std::filesystem::path(model_dir).filename().string();
651 auto openai_error = [](httplib::Response& res, int status, const std::string& msg) {
652 res.status = status;
653 res.set_header("Access-Control-Allow-Origin", "*");
654 res.set_content(json_out(json{{"error", {{"message", msg}, {"type", "invalid_request_error"}}}}),
655 "application/json");
656 };
657 svr.Get("/v1/models", [model_id](const httplib::Request&, httplib::Response& res) {
658 res.set_header("Access-Control-Allow-Origin", "*");
659 res.set_content(json_out(json{{"object", "list"},
660 {"data", json::array({json{{"id", model_id},
661 {"object", "model"},
662 {"created", 0},
663 {"owned_by", "ember.cpp"}}})}}),
664 "application/json");
665 });
666 svr.Options("/v1/chat/completions", [](const httplib::Request&, httplib::Response& res) {
667 res.set_header("Access-Control-Allow-Origin", "*");
668 res.set_header("Access-Control-Allow-Headers", "Content-Type, Authorization");
669 res.set_header("Access-Control-Allow-Methods", "POST, OPTIONS");
670 res.status = 204;
671 });
672 svr.Post("/v1/chat/completions", [&session, model_id, openai_error](const httplib::Request& req,
673 httplib::Response& res) {
674 const auto t_request = std::chrono::steady_clock::now();
676 if (public_mode && waiting.load() > 8) {
677 res.set_header("Retry-After", "2");
678 openai_error(res, 503, "busy: too many requests at once, try again in a moment");
679 return;
680 }
681 if (!session->is_bpe()) {
682 openai_error(res, 400, "this model has no chat template ember can apply; use POST /chat");
683 return;
684 }
685 json body = json::parse(req.body, nullptr, false);
686 if (body.is_discarded()) {
687 openai_error(res, 400, "body must be JSON with a non-empty 'messages' array");
688 return;
689 }
691 r.temperature = 1.0f;
692 r.top_p = 1.0f;
693 r.top_k = 0;
694 r.max_new_tokens = 64;
695 bool include_usage = false;
696 bool stream = false;
697 try {
698 // messages map one to one (chat_request.h): leading system/developer messages join into the
699 // system message, a later one or a tool message is refused, the last message is the user's.
700 // A record with tool turns (paperkiln/2): tool messages and assistant tool_calls map one to one,
701 // "tools" passes through (member order kept), and the last message is the user's or a tool's.
702 const microtorch::chat::Template* tmpl = &session->chat_format()->tmpl;
703 try {
704 r.conv = ember::openai_conversation(body, tmpl,
706 } catch (const ember::RequestError& e) {
707 openai_error(res, 400, e.what());
708 return;
709 }
710 if (body.contains("temperature") && body["temperature"].is_number()) r.temperature = body["temperature"].get<float>();
711 if (body.contains("top_p") && body["top_p"].is_number()) r.top_p = body["top_p"].get<float>();
712 if (body.contains("top_k") && body["top_k"].is_number()) r.top_k = body["top_k"].get<int>();
713 for (const char* key : {"max_completion_tokens", "max_tokens"})
714 if (body.contains(key) && body[key].is_number()) {
715 r.max_new_tokens = body[key].get<int>();
716 break;
717 }
718 if (body.contains("seed") && body["seed"].is_number()) {
719 r.has_seed = true;
720 r.seed = static_cast<unsigned>(body["seed"].get<long long>());
721 }
722 stream = body.value("stream", false);
723 if (body.contains("stream_options") && body["stream_options"].is_object())
724 include_usage = body["stream_options"].value("include_usage", false);
725 } catch (const json::exception& e) {
726 openai_error(res, 400, std::string("bad request field: ") + e.what());
727 return;
728 }
729 // OpenAI's frequency/presence penalties are not approximated: only 0 is
730 // accepted. The contract's repeat_penalty / repeat_last_n are extensions.
731 for (const char* key : {"frequency_penalty", "presence_penalty"})
732 if (body.contains(key) && !body[key].is_null() && !(body[key].is_number() && body[key].get<double>() == 0.0)) {
733 openai_error(res, 400, std::string(key) + " is not supported (only 0); use repeat_penalty and repeat_last_n");
734 return;
735 }
736 {
737 const std::string rp_error =
738 bpe_repeat_fields(body, r, session->get_config().max_position_embeddings, public_mode);
739 if (!rp_error.empty()) {
741 return;
742 }
743 }
744 r.max_new_tokens = std::max(r.max_new_tokens, 1);
745 if (public_mode && r.conv.general) {
746 // Tool turns: the same caps on the message list (600 bytes a message, the newest 8 past exchanges).
747 constexpr size_t kMaxText = 600;
748 std::vector<microtorch::chat::Message>& m = r.conv.messages;
749 if (m.back().role == "user" && m.back().content.size() > kMaxText) {
750 openai_error(res, 400, "message too long (600 characters at most)");
751 return;
752 }
753 r.max_new_tokens = std::min(r.max_new_tokens, 64);
754 r.temperature = std::min(std::max(r.temperature, 0.0f), 1.5f);
755 r.top_k = std::min(std::max(r.top_k, 0), 200);
756 r.top_p = std::min(std::max(r.top_p, 0.05f), 1.0f);
757 for (auto& x : m)
758 if (x.content.size() > kMaxText) x.content.resize(kMaxText);
759 const auto ex = microtorch::chat::exchanges(m);
760 if (ex.size() > 9)
761 m.erase(m.begin() + (m[0].role == "system" ? 1 : 0), m.begin() + static_cast<std::ptrdiff_t>(ex[ex.size() - 9].first));
762 } else if (public_mode) {
763 constexpr size_t kMaxText = 600;
764 if (r.conv.user.size() > kMaxText) {
765 openai_error(res, 400, "message too long (600 characters at most)");
766 return;
767 }
768 r.max_new_tokens = std::min(r.max_new_tokens, 64);
769 r.temperature = std::min(std::max(r.temperature, 0.0f), 1.5f);
770 r.top_k = std::min(std::max(r.top_k, 0), 200);
771 r.top_p = std::min(std::max(r.top_p, 0.05f), 1.0f);
772 if (r.conv.system.size() > kMaxText) r.conv.system.resize(kMaxText);
773 while (r.conv.turns.size() > 8) r.conv.turns.erase(r.conv.turns.begin());
774 for (auto& t : r.conv.turns) {
775 if (t.first.size() > kMaxText) t.first.resize(kMaxText);
776 if (t.second.size() > kMaxText) t.second.resize(kMaxText);
777 }
778 }
779 std::lock_guard<std::mutex> session_lock(session_mu);
780 const double queue_ms =
781 std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - t_request).count();
784 try {
786 } catch (const microtorch::chat::Error& e) {
787 openai_error(res, 400, e.what()); // the template refuses the conversation
788 return;
789 }
790 if (!bp.error.empty()) {
791 openai_error(res, 400, bp.error);
792 return;
793 }
794 static std::mt19937_64 id_rng{std::random_device{}()};
795 char idbuf[32];
796 std::snprintf(idbuf, sizeof idbuf, "chatcmpl-%016llx", static_cast<unsigned long long>(id_rng()));
797 const std::string id = idbuf;
798 const long long created = static_cast<long long>(std::time(nullptr));
799 res.set_header("Access-Control-Allow-Origin", "*");
800 auto usage_of = [&bp](const json& result) {
801 const int completion = result.value("tokens", 0);
802 return json{{"prompt_tokens", bp.prompt_tokens},
803 {"completion_tokens", completion},
804 {"total_tokens", bp.prompt_tokens + completion}};
805 };
806 // finish_reason: "length" at the length limit; otherwise "tool_calls" when the reply has calls
807 // (a record with tool turns), else "stop".
808 auto finish_of = [](const json& result) {
809 if (result.value("stop_reason", std::string("eos")) == "length") return "length";
810 return result.contains("tool_calls") ? "tool_calls" : "stop";
811 };
812 if (!stream) {
813 const json result = bpe_run(*sess, r, bp, t_request, queue_ms, nullptr);
814 json message = {{"role", "assistant"}, {"content", result["reply"]}};
815 if (result.contains("tool_calls")) message["tool_calls"] = result["tool_calls"];
816 json out = {{"id", id},
817 {"object", "chat.completion"},
818 {"created", created},
819 {"model", model_id},
820 {"choices", json::array({json{{"index", 0},
821 {"message", message},
822 {"logprobs", nullptr},
823 {"finish_reason", finish_of(result)}}})},
824 {"usage", usage_of(result)}};
825 res.set_content(json_out(out), "application/json");
826 return;
827 }
828 res.set_header("Cache-Control", "no-cache");
829 res.set_chunked_content_provider(
830 "text/event-stream",
832 size_t, httplib::DataSink& sink) {
834 std::lock_guard<std::mutex> stream_lock(session_mu);
835 const double waited =
836 std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - t_request).count();
837 auto chunk = [&](const json& delta, const json& finish) {
838 json c = {{"id", id},
839 {"object", "chat.completion.chunk"},
840 {"created", created},
841 {"model", model_id},
842 {"choices", json::array({json{{"index", 0}, {"delta", delta}, {"finish_reason", finish}}})}};
843 const std::string ev = "data: " + json_out(c) + "\n\n";
844 sink.write(ev.data(), ev.size());
845 };
846 chunk(json{{"role", "assistant"}, {"content", ""}}, nullptr);
847 const json result = bpe_run(*sess, r, bp, t_request, waited,
848 [&](const std::string& piece) { chunk(json{{"content", piece}}, nullptr); });
849 // Tool turns: each call as one whole delta (full name and arguments), after the content.
850 if (result.contains("tool_calls"))
851 for (size_t k = 0; k < result["tool_calls"].size(); ++k) {
852 json call = result["tool_calls"][k];
853 call["index"] = k;
854 chunk(json{{"tool_calls", json::array({call})}}, nullptr);
855 }
856 chunk(json::object(), finish_of(result));
857 if (include_usage) {
858 json u = {{"id", id},
859 {"object", "chat.completion.chunk"},
860 {"created", created},
861 {"model", model_id},
862 {"choices", json::array()},
863 {"usage", usage_of(result)}};
864 const std::string ev = "data: " + json_out(u) + "\n\n";
865 sink.write(ev.data(), ev.size());
866 }
867 const std::string done = "data: [DONE]\n\n";
868 sink.write(done.data(), done.size());
869 sink.done();
870 return true;
871 });
872 });
873
874 // ---- POST /score: log-probabilities of continuations (score.h) ----------
875 // mtstudio score's semantics on token ids, for log-prob benchmarks: per request, one fresh forward
876 // over prompt + continuation[:-1] from position 0, the continuation's log-probabilities (log-softmax
877 // at temperature 1 in double), the argmax at each of its positions (mtstudio's banned specials left
878 // out) and their sum; over_ctx past ctx + 1 ids. A batch holds the session from its first request
879 // to its last. Off under EMBER_PUBLIC=1 (the open-internet settings serve chat only).
880 svr.Post("/score", [&session](const httplib::Request& req, httplib::Response& res) {
881 const auto t_request = std::chrono::steady_clock::now();
883 res.set_header("Access-Control-Allow-Origin", "*");
884 auto refuse = [&res](int status, const std::string& msg) {
885 res.status = status;
886 res.set_content(json_out(json{{"error", msg}}), "application/json");
887 };
888 if (public_mode) {
889 refuse(403, "/score is off on a public server (EMBER_PUBLIC=1)");
890 return;
891 }
892 const json body = json::parse(req.body, nullptr, false);
893 if (body.is_discarded()) {
894 refuse(400, "body must be JSON: {\"requests\": [{\"key\", \"prompt_ids\", \"continuation_ids\"}, ...]}");
895 return;
896 }
898 m.vocab = static_cast<size_t>(session->score_vocab());
899 m.ctx = static_cast<size_t>(session->score_context());
900 if (session->state_k() > 0 && session->chat_format()) {
901 const ember::ChatFormat& f = *session->chat_format();
902 m.state_k = static_cast<size_t>(session->state_k());
903 m.eot = f.eos_ids.at(0);
904 m.user = f.turn_ids.at(0);
905 m.assistant = f.turn_ids.at(1);
906 }
907 std::vector<ember::ScoreItem> items;
908 const std::string err = ember::parse_score_body(body, m, items);
909 if (!err.empty()) {
910 refuse(400, err);
911 return;
912 }
913 static const std::vector<char> banned = ember::score_banned(session->gguf_tokens(), m.vocab);
914 std::lock_guard<std::mutex> session_lock(session_mu);
915 const auto t_start = std::chrono::steady_clock::now();
916 json results = json::array();
917 size_t scored = 0, over = 0, positions = 0, reused_total = 0;
918 try {
919 bool first = true;
920 for (ember::ScoreItem& it : items) {
921 if (it.over_ctx) {
922 it.rec["over_ctx"] = true;
923 results.push_back(std::move(it.rec));
924 ++over;
925 continue;
926 }
927 std::vector<int> ctx = it.prompt; // prompt + continuation[:-1]
928 ctx.insert(ctx.end(), it.cont.begin(), it.cont.end() - 1);
929 int reused = 0;
930 const std::vector<float> rows =
931 session->score_rows(ctx, static_cast<int>(it.prompt.size()) - 1, !first, &reused);
932 first = false;
933 ember::score_fill(it.rec, it.cont, rows.data(), m.vocab, banned);
934 results.push_back(std::move(it.rec));
935 ++scored;
936 positions += ctx.size();
937 reused_total += static_cast<size_t>(reused);
938 }
939 } catch (const std::invalid_argument& e) {
940 refuse(400, std::string("score: ") + e.what());
941 return;
942 } catch (const std::exception& e) {
943 refuse(500, std::string("score: ") + e.what());
944 return;
945 }
946 auto ms = [](auto a, auto b) { return std::chrono::duration<double, std::milli>(b - a).count(); };
947 const auto t_end = std::chrono::steady_clock::now();
948 json out = {{"results", std::move(results)},
949 {"stats", {{"requests", items.size()},
950 {"scored", scored},
951 {"over_ctx", over},
952 {"positions", positions},
953 {"reused_positions", reused_total},
954 {"queue_ms", ms(t_request, t_start)},
955 {"score_ms", ms(t_start, t_end)}}}};
956 res.set_content(json_out(out), "application/json");
957 });
958 svr.Options("/score", [](const httplib::Request&, httplib::Response& res) {
959 res.set_header("Access-Control-Allow-Origin", "*");
960 res.set_header("Access-Control-Allow-Headers", "Content-Type");
961 res.set_header("Access-Control-Allow-Methods", "POST, OPTIONS");
962 res.status = 204;
963 });
964
965 svr.Options("/chat", [](const httplib::Request& req, httplib::Response& res) {
966 res.set_header("Access-Control-Allow-Origin", "*");
967 res.set_header("Access-Control-Allow-Headers", "Content-Type");
968 res.set_header("Access-Control-Allow-Methods", "POST, OPTIONS");
969 res.status = 204;
970 });
971
972 unsigned int num_threads = SAFE_MAX(1u, std::thread::hardware_concurrency() / 2);
973 Logger::info("Starting server on " + host + ":" + std::to_string(port) +
974 " with " + std::to_string(num_threads) + " threads.");
975
976
977 svr.listen(host.c_str(), port);
978
979 Logger::info("Server stopped.");
980 return 0;
981}
static void warning(const std::string &message)
Definition logger.cpp:139
static void set_level(Level new_level)
Definition logger.cpp:34
static void info(const std::string &message)
Definition logger.cpp:135
static void error(const std::string &message)
Definition logger.cpp:143
A lightweight tokenizer implementation for text processing.
Definition tokenizer.h:61
std::vector< int > encode(const std::string &text, bool add_bos=true, bool add_eos=false, PreTokenizeMethod pre_tok_override=PreTokenizeMethod::DEFAULT) const
Encodes text into token IDs with optional special tokens.
Represents an active TinyLlama session holding the loaded model and tokenizer.
Definition api.h:85
float value
float c
std::vector< Value > items
Definition ir_digest.cpp:67
#define SAFE_MAX(a, b)
bool tool_turns(const chat::Template *t)
std::vector< Stage > lower(const Expr &e)
Conversation openai_conversation(const nlohmann::json &body, const chat::Template *t=nullptr, const chat::json &tools=chat::json())
std::string parse_score_body(const nlohmann::json &body, const ScoreModel &m, std::vector< ScoreItem > &out)
Definition score.h:92
bool deterministic()
Definition determinism.h:22
void score_fill(nlohmann::json &rec, const std::vector< int > &c, const float *rows, size_t V, const std::vector< char > &banned)
Definition score.h:147
chat::json ordered_tools(const std::string &body)
Conversation chat_conversation(const nlohmann::json &r, const chat::Template *t=nullptr, const chat::json &tools=chat::json())
std::vector< char > score_banned(const std::vector< std::string > &tokens, size_t vocab)
Definition score.h:133
std::vector< std::pair< size_t, size_t > > exchanges(const std::vector< Message > &m)
std::string render(const std::vector< Message > &m, const Template &t, bool generation_prompt, int thinking=-1, const std::vector< json > &tools={})
std::vector< Message > from_pairs(const std::vector< std::pair< std::string, std::string > > &turns, const std::string *user=nullptr, const std::string *system=nullptr)
static std::string json_out(const json &j)
Definition server.cpp:72
static json bpe_run(tinyllama::TinyLlamaSession &sess, const BpeChatRequest &r, const BpePrompt &p, std::chrono::steady_clock::time_point t_request, double waited_ms, const std::function< void(const std::string &)> &on_piece)
Definition server.cpp:151
static std::string bpe_repeat_fields(const json &body, BpeChatRequest &r, int ctx, bool clamp)
Definition server.cpp:99
static BpePrompt bpe_build_prompt(tinyllama::TinyLlamaSession &sess, const BpeChatRequest &r)
Definition server.cpp:129
float temperature
Definition server.cpp:86
ember::Conversation conv
Definition server.cpp:84
Model configuration structure holding architecture and hyperparameters.
Definition model.h:82
int max_position_embeddings
Definition model.h:89
std::vector< int > eos_ids
size_t vocab
Definition score.h:43

References a, body, bpe_build_prompt(), bpe_repeat_fields(), bpe_run(), c, ember::chat_conversation(), BpeChatRequest::conv, ember::deterministic(), Tokenizer::encode(), ember::ChatFormat::eos_ids, Logger::error(), microtorch::chat::exchanges(), tinyllama::TtftBreakdown::first_token, microtorch::chat::from_pairs(), microtorch::chat::Template::has_tool, Logger::info(), items, j, json_out(), k, ModelConfig::LLAMA3_TIKTOKEN, ModelConfig::max_position_embeddings, ember::openai_conversation(), ember::ordered_tools(), out, ember::parse_score_body(), microtorch::chat::render(), SAFE_MAX, ember::score_banned(), ember::score_fill(), Logger::set_level(), BpeChatRequest::temperature, ember::utf8_valid(), v, value, ember::ScoreModel::vocab, Logger::WARNING, Logger::warning(), and ModelConfig::WORD_LEVEL.