feat(engine): add sr-voice LLM inference service for NPC voice pipeline
Standalone Rust crate wrapping llama-cpp-2 for GGUF model inference. Persistent HTTP server architecture — model loaded once, requests processed sequentially, zero CPU contention by construction. Subcommands: serve (load model, listen), generate (single prompt), batch (JSONL), benchmark (5-run average). Makefile targets for build/serve/run/stop workflow. Spike 1 validated: Gemma 2B Q4_K_M at ~16 t/s CPU, 4 cultures tested (Krenn, Ireland, Shek'na, Aranthi), composition-engine oath injection mechanism proven. GO for Spike 2. Refs: D-138, #639 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
use std::num::NonZeroU32;
|
||||
use std::path::Path;
|
||||
use std::time::Instant;
|
||||
|
||||
use llama_cpp_2::context::params::LlamaContextParams;
|
||||
use llama_cpp_2::llama_backend::LlamaBackend;
|
||||
use llama_cpp_2::llama_batch::LlamaBatch;
|
||||
use llama_cpp_2::model::params::LlamaModelParams;
|
||||
use llama_cpp_2::model::{AddBos, LlamaModel, Special};
|
||||
use llama_cpp_2::sampling::LlamaSampler;
|
||||
|
||||
use crate::VoiceError;
|
||||
|
||||
/// Configuration for model loading and inference.
|
||||
pub struct InferenceConfig {
|
||||
pub model_path: String,
|
||||
pub threads: u32,
|
||||
pub ctx_size: u32,
|
||||
pub seed: Option<u32>,
|
||||
}
|
||||
|
||||
/// Result of a single generation call.
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct GenerationResult {
|
||||
pub text: String,
|
||||
pub tokens_generated: u32,
|
||||
pub generation_time_ms: u64,
|
||||
pub tokens_per_sec: f64,
|
||||
pub prefill_time_ms: u64,
|
||||
}
|
||||
|
||||
/// Wraps llama.cpp model and context for text generation.
|
||||
pub struct InferenceEngine {
|
||||
backend: LlamaBackend,
|
||||
model: LlamaModel,
|
||||
ctx_size: u32,
|
||||
threads: u32,
|
||||
}
|
||||
|
||||
impl InferenceEngine {
|
||||
/// Load a GGUF model from disk.
|
||||
pub fn load(config: &InferenceConfig) -> Result<Self, VoiceError> {
|
||||
let backend =
|
||||
LlamaBackend::init().map_err(|e| VoiceError::ModelLoadFailed(e.to_string()))?;
|
||||
|
||||
let model_params = LlamaModelParams::default();
|
||||
let model = LlamaModel::load_from_file(
|
||||
&backend,
|
||||
Path::new(&config.model_path),
|
||||
&model_params,
|
||||
)
|
||||
.map_err(|e| VoiceError::ModelLoadFailed(e.to_string()))?;
|
||||
|
||||
Ok(Self {
|
||||
backend,
|
||||
model,
|
||||
ctx_size: config.ctx_size,
|
||||
threads: config.threads,
|
||||
})
|
||||
}
|
||||
|
||||
/// Generate text from a prompt.
|
||||
pub fn generate(
|
||||
&self,
|
||||
prompt: &str,
|
||||
max_tokens: u32,
|
||||
temperature: f32,
|
||||
top_p: f32,
|
||||
seed: Option<u32>,
|
||||
) -> Result<GenerationResult, VoiceError> {
|
||||
let ctx_params = LlamaContextParams::default()
|
||||
.with_n_ctx(NonZeroU32::new(self.ctx_size))
|
||||
.with_n_threads(self.threads as i32)
|
||||
.with_n_threads_batch(self.threads as i32);
|
||||
|
||||
let mut ctx = self
|
||||
.model
|
||||
.new_context(&self.backend, ctx_params)
|
||||
.map_err(|e| VoiceError::InferenceFailed(e.to_string()))?;
|
||||
|
||||
// Tokenize the prompt
|
||||
let tokens = self
|
||||
.model
|
||||
.str_to_token(prompt, AddBos::Always)
|
||||
.map_err(|e| VoiceError::InferenceFailed(e.to_string()))?;
|
||||
|
||||
if tokens.len() as u32 >= self.ctx_size {
|
||||
return Err(VoiceError::InferenceFailed(format!(
|
||||
"Prompt ({} tokens) exceeds context size ({})",
|
||||
tokens.len(),
|
||||
self.ctx_size
|
||||
)));
|
||||
}
|
||||
|
||||
// Prefill: evaluate the prompt tokens
|
||||
let prefill_start = Instant::now();
|
||||
let mut batch = LlamaBatch::new(self.ctx_size as usize, 1);
|
||||
for (i, &token) in tokens.iter().enumerate() {
|
||||
let is_last = i == tokens.len() - 1;
|
||||
batch
|
||||
.add(token, i as i32, &[0], is_last)
|
||||
.map_err(|e| VoiceError::InferenceFailed(e.to_string()))?;
|
||||
}
|
||||
ctx.decode(&mut batch)
|
||||
.map_err(|e| VoiceError::InferenceFailed(e.to_string()))?;
|
||||
let prefill_time_ms = prefill_start.elapsed().as_millis() as u64;
|
||||
|
||||
// Generation loop
|
||||
let gen_start = Instant::now();
|
||||
let mut generated_tokens: u32 = 0;
|
||||
let mut output = String::new();
|
||||
let mut cur_pos = tokens.len() as i32;
|
||||
|
||||
let mut sampler = LlamaSampler::chain_simple([
|
||||
LlamaSampler::temp(temperature),
|
||||
LlamaSampler::top_p(top_p, 1),
|
||||
LlamaSampler::dist(seed.unwrap_or(1234)),
|
||||
]);
|
||||
|
||||
loop {
|
||||
if generated_tokens >= max_tokens {
|
||||
break;
|
||||
}
|
||||
|
||||
let logits_index = batch.n_tokens() - 1;
|
||||
let token = sampler.sample(&ctx, logits_index);
|
||||
|
||||
if self.model.is_eog_token(token) {
|
||||
break;
|
||||
}
|
||||
|
||||
#[allow(deprecated)]
|
||||
let piece = self
|
||||
.model
|
||||
.token_to_str(token, Special::Tokenize)
|
||||
.map_err(|e| VoiceError::InferenceFailed(e.to_string()))?;
|
||||
output.push_str(&piece);
|
||||
generated_tokens += 1;
|
||||
|
||||
batch.clear();
|
||||
batch
|
||||
.add(token, cur_pos, &[0], true)
|
||||
.map_err(|e| VoiceError::InferenceFailed(e.to_string()))?;
|
||||
cur_pos += 1;
|
||||
|
||||
ctx.decode(&mut batch)
|
||||
.map_err(|e| VoiceError::InferenceFailed(e.to_string()))?;
|
||||
}
|
||||
|
||||
let generation_time_ms = gen_start.elapsed().as_millis() as u64;
|
||||
let tokens_per_sec = if generation_time_ms > 0 {
|
||||
(generated_tokens as f64 / generation_time_ms as f64) * 1000.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
Ok(GenerationResult {
|
||||
text: output,
|
||||
tokens_generated: generated_tokens,
|
||||
generation_time_ms,
|
||||
tokens_per_sec,
|
||||
prefill_time_ms,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
mod inference;
|
||||
mod prompt;
|
||||
mod server;
|
||||
|
||||
use std::io::Read;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
use inference::{InferenceConfig, InferenceEngine};
|
||||
|
||||
/// Errors for the sr-voice CLI.
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum VoiceError {
|
||||
#[error("model load failed: {0}")]
|
||||
ModelLoadFailed(String),
|
||||
#[error("inference failed: {0}")]
|
||||
InferenceFailed(String),
|
||||
#[error("invalid input: {0}")]
|
||||
InvalidInput(String),
|
||||
}
|
||||
|
||||
/// sr-voice — LLM inference service for The Settled Reach
|
||||
#[derive(Parser)]
|
||||
#[command(name = "sr-voice", version, about)]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Command,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Command {
|
||||
/// Start the inference server (loads model, listens for requests)
|
||||
Serve {
|
||||
/// Path to GGUF model file
|
||||
#[arg(long)]
|
||||
model: String,
|
||||
/// Listen port
|
||||
#[arg(long, default_value = "8321")]
|
||||
port: u16,
|
||||
/// CPU threads for inference
|
||||
#[arg(long)]
|
||||
threads: Option<u32>,
|
||||
/// Context window size in tokens
|
||||
#[arg(long, default_value = "512")]
|
||||
ctx_size: u32,
|
||||
},
|
||||
/// Generate text from a single prompt (requires running server)
|
||||
Generate {
|
||||
/// Server port
|
||||
#[arg(long, default_value = "8321")]
|
||||
port: u16,
|
||||
/// RNG seed
|
||||
#[arg(long)]
|
||||
seed: Option<u32>,
|
||||
/// Prompt file (reads from stdin if omitted)
|
||||
prompt_file: Option<String>,
|
||||
},
|
||||
/// Process a JSONL batch of prompts (requires running server)
|
||||
Batch {
|
||||
/// Server port
|
||||
#[arg(long, default_value = "8321")]
|
||||
port: u16,
|
||||
/// Input JSONL file
|
||||
#[arg(long)]
|
||||
input: String,
|
||||
},
|
||||
/// Run 5 inferences and report average tokens/sec (requires running server)
|
||||
Benchmark {
|
||||
/// Server port
|
||||
#[arg(long, default_value = "8321")]
|
||||
port: u16,
|
||||
},
|
||||
}
|
||||
|
||||
fn default_threads() -> u32 {
|
||||
let cores = std::thread::available_parallelism()
|
||||
.map(|n| n.get() as u32)
|
||||
.unwrap_or(4);
|
||||
cores.saturating_sub(1).max(1)
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let cli = Cli::parse();
|
||||
|
||||
match cli.command {
|
||||
Command::Serve { model, port, threads, ctx_size } => {
|
||||
let threads = threads.unwrap_or_else(default_threads);
|
||||
let config = InferenceConfig {
|
||||
model_path: model.clone(),
|
||||
threads,
|
||||
ctx_size,
|
||||
seed: None,
|
||||
};
|
||||
|
||||
eprintln!("Loading model: {}", config.model_path);
|
||||
let engine = InferenceEngine::load(&config)?;
|
||||
eprintln!("Model loaded ({} threads, {} ctx)", threads, ctx_size);
|
||||
|
||||
let model_name = std::path::Path::new(&model)
|
||||
.file_name()
|
||||
.map(|f| f.to_string_lossy().to_string())
|
||||
.unwrap_or(model);
|
||||
|
||||
server::run_server(engine, port, &model_name)?;
|
||||
}
|
||||
Command::Generate { port, seed, prompt_file } => {
|
||||
let prompt = read_prompt(prompt_file)?;
|
||||
let req = serde_json::json!({ "prompt": prompt, "seed": seed });
|
||||
let body = post_with_status(port, "/generate", &req.to_string())?;
|
||||
let result: serde_json::Value = serde_json::from_str(&body)?;
|
||||
|
||||
if let Some(err) = result.get("error") {
|
||||
return Err(format!("Server error: {}", err).into());
|
||||
}
|
||||
println!("{}", result["text"].as_str().unwrap_or(""));
|
||||
eprintln!(
|
||||
"[{} tokens in {}ms — {:.1} t/s, prefill {}ms]",
|
||||
result["tokens_generated"],
|
||||
result["generation_time_ms"],
|
||||
result["tokens_per_sec"].as_f64().unwrap_or(0.0),
|
||||
result["prefill_time_ms"],
|
||||
);
|
||||
}
|
||||
Command::Batch { port, input } => {
|
||||
let file = std::fs::File::open(&input)?;
|
||||
let reader = std::io::BufReader::new(file);
|
||||
let payloads = prompt::parse_jsonl(reader)?;
|
||||
|
||||
let body = post_with_status(port, "/batch", &serde_json::to_string(&payloads)?)?;
|
||||
|
||||
for line in body.lines() {
|
||||
if line.is_empty() { continue; }
|
||||
let result: serde_json::Value = serde_json::from_str(line)?;
|
||||
let id = result["id"].as_str().unwrap_or("?");
|
||||
if let Some(err) = result.get("error") {
|
||||
eprintln!("--- {} --- ERROR: {}", id, err);
|
||||
} else {
|
||||
println!("--- {} ---", id);
|
||||
println!("{}", result["text"].as_str().unwrap_or(""));
|
||||
eprintln!(
|
||||
"[{} tokens in {}ms — {:.1} t/s]",
|
||||
result["tokens_generated"],
|
||||
result["generation_time_ms"],
|
||||
result["tokens_per_sec"].as_f64().unwrap_or(0.0),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Command::Benchmark { port } => {
|
||||
let prompt = "Rephrase in terse dialect: The worker tends the crops in the field.";
|
||||
let runs = 5;
|
||||
eprintln!("Benchmark: {} runs", runs);
|
||||
|
||||
let mut total_tps = 0.0;
|
||||
let mut total_prefill = 0u64;
|
||||
let mut total_gen = 0u64;
|
||||
|
||||
for i in 0..runs {
|
||||
let req = serde_json::json!({ "prompt": prompt });
|
||||
let body = post_with_status(port, "/generate", &req.to_string())?;
|
||||
let result: serde_json::Value = serde_json::from_str(&body)?;
|
||||
|
||||
let tps = result["tokens_per_sec"].as_f64().unwrap_or(0.0);
|
||||
let prefill = result["prefill_time_ms"].as_u64().unwrap_or(0);
|
||||
let gen = result["generation_time_ms"].as_u64().unwrap_or(0);
|
||||
let tokens = result["tokens_generated"].as_u64().unwrap_or(0);
|
||||
|
||||
eprintln!(" run {}: {} tokens, {:.1} t/s, prefill {}ms", i + 1, tokens, tps, prefill);
|
||||
total_tps += tps;
|
||||
total_prefill += prefill;
|
||||
total_gen += gen;
|
||||
}
|
||||
|
||||
eprintln!("\n=== Benchmark Results ===");
|
||||
eprintln!(" Avg tokens/sec: {:.1}", total_tps / runs as f64);
|
||||
eprintln!(" Avg prefill: {}ms", total_prefill / runs);
|
||||
eprintln!(" Avg generation: {}ms", total_gen / runs);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_prompt(prompt_file: Option<String>) -> Result<String, Box<dyn std::error::Error>> {
|
||||
let raw = match prompt_file {
|
||||
Some(path) => std::fs::read_to_string(&path)?,
|
||||
None => {
|
||||
let mut buf = String::new();
|
||||
std::io::stdin().read_to_string(&mut buf)?;
|
||||
buf
|
||||
}
|
||||
};
|
||||
let trimmed = raw.trim().to_string();
|
||||
if trimmed.is_empty() {
|
||||
return Err("No prompt provided".into());
|
||||
}
|
||||
Ok(trimmed)
|
||||
}
|
||||
|
||||
/// POST to the server. Prints "Server is processing..." if response takes > 500ms.
|
||||
fn post_with_status(port: u16, path: &str, body: &str) -> Result<String, Box<dyn std::error::Error>> {
|
||||
let base = format!("http://127.0.0.1:{}", port);
|
||||
let agent = ureq::Agent::config_builder()
|
||||
.timeout_global(Some(Duration::from_secs(600)))
|
||||
.timeout_connect(Some(Duration::from_secs(2)))
|
||||
.build()
|
||||
.new_agent();
|
||||
|
||||
// Health check — clear error if server isn't running
|
||||
if agent.get(&format!("{}/health", base)).call().is_err() {
|
||||
return Err(format!(
|
||||
"No sr-voice server on port {}. Start one with: sr-voice serve --model <path>",
|
||||
port
|
||||
).into());
|
||||
}
|
||||
|
||||
let url = format!("{}{}", base, path);
|
||||
let start = Instant::now();
|
||||
let printed = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let flag = printed.clone();
|
||||
|
||||
let handle = std::thread::spawn(move || {
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
if !flag.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
eprint!("Server is processing...");
|
||||
flag.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
});
|
||||
|
||||
let result = agent.post(&url)
|
||||
.header("Content-Type", "application/json")
|
||||
.send(body);
|
||||
|
||||
let was_printed = printed.load(std::sync::atomic::Ordering::Relaxed);
|
||||
printed.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
let _ = handle.join();
|
||||
if was_printed {
|
||||
eprintln!(" done ({:.1}s)", start.elapsed().as_secs_f64());
|
||||
}
|
||||
|
||||
match result {
|
||||
Ok(response) => {
|
||||
let text = response.into_body().read_to_string()?;
|
||||
Ok(text)
|
||||
}
|
||||
Err(e) => Err(format!("Request failed: {}", e).into()),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::io::BufRead;
|
||||
|
||||
use crate::VoiceError;
|
||||
|
||||
/// Content types for voice generation.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ContentType {
|
||||
Behavior,
|
||||
Dialogue,
|
||||
Tell,
|
||||
}
|
||||
|
||||
/// A single prompt payload, used in batch JSONL mode.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PromptPayload {
|
||||
pub id: String,
|
||||
pub content_type: ContentType,
|
||||
pub prompt: String,
|
||||
#[serde(default)]
|
||||
pub base_text: Option<String>,
|
||||
#[serde(default)]
|
||||
pub semantic_core: Option<String>,
|
||||
}
|
||||
|
||||
/// Parse a JSONL file into a list of prompt payloads.
|
||||
pub fn parse_jsonl(reader: impl BufRead) -> Result<Vec<PromptPayload>, VoiceError> {
|
||||
let mut payloads = Vec::new();
|
||||
for (i, line) in reader.lines().enumerate() {
|
||||
let line = line.map_err(|e| VoiceError::InvalidInput(format!("line {}: {}", i + 1, e)))?;
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let payload: PromptPayload = serde_json::from_str(trimmed)
|
||||
.map_err(|e| VoiceError::InvalidInput(format!("line {}: {}", i + 1, e)))?;
|
||||
payloads.push(payload);
|
||||
}
|
||||
Ok(payloads)
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
use std::time::Instant;
|
||||
|
||||
use tiny_http::{Header, Method, Response, Server};
|
||||
|
||||
use crate::inference::InferenceEngine;
|
||||
use crate::prompt::PromptPayload;
|
||||
|
||||
const MAX_TOKENS: u32 = 64;
|
||||
const TEMPERATURE: f32 = 0.7;
|
||||
const TOP_P: f32 = 0.9;
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct GenerateRequest {
|
||||
prompt: String,
|
||||
seed: Option<u32>,
|
||||
}
|
||||
|
||||
pub fn run_server(
|
||||
engine: InferenceEngine,
|
||||
port: u16,
|
||||
model_name: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let addr = format!("127.0.0.1:{}", port);
|
||||
let server = Server::http(&addr)
|
||||
.map_err(|e| format!("Failed to bind {}: {}", addr, e))?;
|
||||
|
||||
let start = Instant::now();
|
||||
eprintln!("sr-voice server ready on http://{}", addr);
|
||||
eprintln!(" model: {}", model_name);
|
||||
eprintln!(" POST /generate POST /batch GET /health");
|
||||
|
||||
for request in server.incoming_requests() {
|
||||
let path = request.url().to_string();
|
||||
let method = request.method().clone();
|
||||
|
||||
match (method, path.as_str()) {
|
||||
(Method::Get, "/health") => {
|
||||
let body = serde_json::json!({
|
||||
"status": "ready",
|
||||
"model": model_name,
|
||||
"uptime_secs": start.elapsed().as_secs(),
|
||||
});
|
||||
respond(request, 200, &body.to_string());
|
||||
}
|
||||
(Method::Post, "/generate") => handle_generate(&engine, request),
|
||||
(Method::Post, "/batch") => handle_batch(&engine, request),
|
||||
_ => {
|
||||
respond(request, 404, &serde_json::json!({"error": "not found"}).to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_generate(engine: &InferenceEngine, mut request: tiny_http::Request) {
|
||||
let mut body = String::new();
|
||||
if std::io::Read::read_to_string(request.as_reader(), &mut body).is_err() {
|
||||
respond(request, 400, r#"{"error":"failed to read body"}"#);
|
||||
return;
|
||||
}
|
||||
|
||||
let req: GenerateRequest = match serde_json::from_str(&body) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
let msg = serde_json::json!({"error": format!("invalid JSON: {}", e)});
|
||||
respond(request, 400, &msg.to_string());
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
eprintln!(" generate: {} chars", req.prompt.len());
|
||||
match engine.generate(&req.prompt, MAX_TOKENS, TEMPERATURE, TOP_P, req.seed) {
|
||||
Ok(result) => {
|
||||
eprintln!(" -> {} tokens, {:.1} t/s", result.tokens_generated, result.tokens_per_sec);
|
||||
respond(request, 200, &serde_json::to_string(&result).unwrap());
|
||||
}
|
||||
Err(e) => {
|
||||
let msg = serde_json::json!({"error": e.to_string()});
|
||||
respond(request, 500, &msg.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_batch(engine: &InferenceEngine, mut request: tiny_http::Request) {
|
||||
let mut body = String::new();
|
||||
if std::io::Read::read_to_string(request.as_reader(), &mut body).is_err() {
|
||||
respond(request, 400, r#"{"error":"failed to read body"}"#);
|
||||
return;
|
||||
}
|
||||
|
||||
let payloads: Vec<PromptPayload> = match serde_json::from_str(&body) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
let msg = serde_json::json!({"error": format!("invalid JSON: {}", e)});
|
||||
respond(request, 400, &msg.to_string());
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
eprintln!(" batch: {} prompts", payloads.len());
|
||||
let mut output = String::new();
|
||||
for payload in &payloads {
|
||||
match engine.generate(&payload.prompt, MAX_TOKENS, TEMPERATURE, TOP_P, None) {
|
||||
Ok(result) => {
|
||||
eprintln!(" -> {}: {} tokens, {:.1} t/s", payload.id, result.tokens_generated, result.tokens_per_sec);
|
||||
#[derive(serde::Serialize)]
|
||||
struct BatchLine<'a> {
|
||||
id: &'a str,
|
||||
#[serde(flatten)]
|
||||
result: &'a crate::inference::GenerationResult,
|
||||
}
|
||||
let line = serde_json::to_string(&BatchLine { id: &payload.id, result: &result }).unwrap();
|
||||
output.push_str(&line);
|
||||
output.push('\n');
|
||||
}
|
||||
Err(e) => {
|
||||
let line = serde_json::json!({"id": payload.id, "error": e.to_string()});
|
||||
output.push_str(&line.to_string());
|
||||
output.push('\n');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
respond(request, 200, &output);
|
||||
}
|
||||
|
||||
fn respond(request: tiny_http::Request, status: u16, body: &str) {
|
||||
let header = Header::from_bytes("Content-Type", "application/json").unwrap();
|
||||
let response = Response::from_string(body)
|
||||
.with_status_code(status)
|
||||
.with_header(header);
|
||||
let _ = request.respond(response);
|
||||
}
|
||||
Reference in New Issue
Block a user