swadhinbiswas

[email protected]
2026-07-08 16:30:00 +0600 committed NaN years ago
90ebb09
commit 90ebb09
9 files changed
+3149 -0
▼crates/justapi-bench/Cargo.toml
+34-0
1
+[package]
2
+name = "justapi-bench"
3
+version.workspace = true
4
+edition.workspace = true
5
+license.workspace = true
6
+
7
+[[bin]]
8
+name = "justapi-bench"
9
+path = "src/main.rs"
10
+
11
+[[bin]]
12
+name = "justapi-bench-inference"
13
+path = "src/inference_bench.rs"
14
+
15
+[[bin]]
16
+name = "justapi-gpu-bench"
17
+path = "src/gpu_bench.rs"
18
+
19
+[features]
20
+default = []
21
+real = ["justapi-inference/real", "justapi-inference/cuda"]
22
+cuda = ["justapi-inference/cuda"]
23
+simd-json = ["justapi-core/simd-json"]
24
+
25
+[dependencies]
26
+justapi-core.workspace = true
27
+justapi-inference = { path = "../justapi-inference" }
28
+tokio.workspace = true
29
+serde.workspace = true
30
+serde_json.workspace = true
31
+tracing.workspace = true
32
+tracing-subscriber.workspace = true
33
+anyhow.workspace = true
34
+clap = { workspace = true, features = ["derive"] }
0
35
▼crates/justapi-bench/src/gpu_bench.rs
+305-0
1
+//! GPU wall-clock benchmark: naive `Engine::generate` vs scheduler-backed
2
+//! `SchedulerEngine::generate` on a real model.
3
+//!
4
+//! Usage:
5
+//!
6
+//! ```bash
7
+//! # With CUDA + real features (requires CUDA toolkit + model weights)
8
+//! cargo run --release -p justapi-bench --bin justapi-gpu-bench \
9
+//! --features "cuda,real" \
10
+//! -- --model-path /path/to/model-dir/ --device cuda:0
11
+//!
12
+//! # TinyLlama GGUF example:
13
+//! # 1. Download: curl -Lo /tmp/tinyllama-1.1b.q4_k_m.gguf \
14
+//! # https://huggingface.co/TheBloke/TinyLlama-1.1B-GGUF/resolve/main/...gguf
15
+//! # 2. Run: cargo run ... -- --model-path /tmp/tinyllama-1.1b.q4_k_m.gguf
16
+//! ```
17
+//!
18
+//! When no model is available or the `real` feature is missing, the binary
19
+//! falls back to MockModel to exercise the scheduler path on CPU.
20
+
21
+use std::path::PathBuf;
22
+use std::sync::{Arc, Mutex};
23
+use std::time::Instant;
24
+
25
+use anyhow::{Context, Result};
26
+use clap::Parser;
27
+
28
+use justapi_inference::{
29
+ Engine, EngineDevice, KvBlockPool, SamplingParams, Scheduler, SchedulerConfig, SchedulerEngine,
30
+};
31
+
32
+/// GPU benchmark arguments.
33
+#[derive(Parser, Debug)]
34
+#[command(
35
+ name = "justapi-gpu-bench",
36
+ about = "GPU throughput benchmark for JustAPI inference"
37
+)]
38
+struct Args {
39
+ /// Path to model directory (GGUF file or safetensors + config.json).
40
+ #[arg(long, default_value = None)]
41
+ model_path: Option<PathBuf>,
42
+
43
+ /// Device to run on (cuda:0, cuda:1, cpu). Defaults to CPU when CUDA is
44
+ /// not compiled in.
45
+ #[arg(long, default_value_t = String::from("cpu"))]
46
+ device: String,
47
+
48
+ /// Model name to register in the Engine.
49
+ #[arg(long, default_value = "bench-model")]
50
+ model_name: String,
51
+
52
+ /// Number of concurrent requests.
53
+ #[arg(long, default_value_t = 4)]
54
+ num_requests: usize,
55
+
56
+ /// Prompt length (number of token ids).
57
+ #[arg(long, default_value_t = 32)]
58
+ prompt_len: usize,
59
+
60
+ /// Maximum tokens to generate per request.
61
+ #[arg(long, default_value_t = 128)]
62
+ max_tokens: usize,
63
+
64
+ /// Enable naive-path benchmark.
65
+ #[arg(long)]
66
+ bench_naive: bool,
67
+
68
+ /// Enable scheduler-path benchmark.
69
+ #[arg(long)]
70
+ bench_scheduled: bool,
71
+}
72
+
73
+/// Per-request timing snapshot.
74
+#[derive(Default)]
75
+struct Timing {
76
+ /// Wall clock for the entire batch.
77
+ wall_ms: f64,
78
+ /// Total tokens generated.
79
+ total_tokens: u64,
80
+ /// Time-to-first-token per request (ms).
81
+ ttfts: Vec<f64>,
82
+ /// Inter-token latencies (ms).
83
+ itls: Vec<f64>,
84
+}
85
+
86
+fn report(name: &str, timing: &Timing) {
87
+ let mut ttfts = timing.ttfts.clone();
88
+ ttfts.sort_by(|a, b| a.partial_cmp(b).unwrap());
89
+ let mut itls = timing.itls.clone();
90
+ itls.sort_by(|a, b| a.partial_cmp(b).unwrap());
91
+
92
+ let tput = if timing.wall_ms > 0.0 {
93
+ timing.total_tokens as f64 / (timing.wall_ms / 1000.0)
94
+ } else {
95
+ 0.0
96
+ };
97
+
98
+ let ttft_p50 = ttfts.get(ttfts.len() / 2).copied().unwrap_or(0.0);
99
+ let ttft_p99 = ttfts
100
+ .get((ttfts.len() as f64 * 0.99) as usize)
101
+ .copied()
102
+ .unwrap_or(0.0);
103
+ let itl_p50 = itls.get(itls.len() / 2).copied().unwrap_or(0.0);
104
+ let itl_p99 = itls
105
+ .get((itls.len() as f64 * 0.99) as usize)
106
+ .copied()
107
+ .unwrap_or(0.0);
108
+
109
+ println!("### {name}");
110
+ println!();
111
+ println!("| Metric | Value |");
112
+ println!("|---|---|");
113
+ println!("| Total tokens | {} |", timing.total_tokens);
114
+ println!("| Wall time (ms) | {:.2} |", timing.wall_ms);
115
+ println!("| Throughput (tok/s) | {:.0} |", tput);
116
+ println!("| TTFT p50 (ms) | {:.2} |", ttft_p50);
117
+ println!("| TTFT p99 (ms) | {:.2} |", ttft_p99);
118
+ println!("| ITL p50 (ms) | {:.2} |", itl_p50);
119
+ println!("| ITL p99 (ms) | {:.2} |", itl_p99);
120
+ println!();
121
+}
122
+
123
+fn main() -> Result<()> {
124
+ let args = Args::parse();
125
+
126
+ // --- Device setup ---
127
+ let device = match args.device.as_str() {
128
+ "cpu" => EngineDevice::Cpu,
129
+ s if s.starts_with("cuda") => {
130
+ let ordinal = s
131
+ .strip_prefix("cuda:")
132
+ .and_then(|n| n.parse::<usize>().ok())
133
+ .unwrap_or(0);
134
+ EngineDevice::Cuda(ordinal)
135
+ }
136
+ s => anyhow::bail!("unsupported device: {s}"),
137
+ };
138
+
139
+ let engine = Arc::new(Engine::new(device).context("failed to create Engine")?);
140
+
141
+ // --- Model loading ---
142
+ if let Some(ref model_path) = args.model_path {
143
+ #[cfg(feature = "real")]
144
+ {
145
+ if model_path.is_dir() || model_path.extension().map_or(false, |e| e == "gguf") {
146
+ engine
147
+ .load(&args.model_name, model_path)
148
+ .context("failed to load model")?;
149
+ println!("Loaded model from: {}", model_path.display());
150
+ } else {
151
+ anyhow::bail!(
152
+ "model path must be a directory (safetensors) or .gguf file, got: {}",
153
+ model_path.display()
154
+ );
155
+ }
156
+ }
157
+ #[cfg(not(feature = "real"))]
158
+ {
159
+ let _ = model_path;
160
+ anyhow::bail!(
161
+ "Cannot load a real model without the `real` feature. \
162
+ Rebuild with: cargo build --features real"
163
+ );
164
+ }
165
+ } else {
166
+ engine.register_mock(&args.model_name);
167
+ println!("No --model-path given; using MockModel (CPU, no GPU workload).");
168
+ }
169
+
170
+ let rt = tokio::runtime::Builder::new_current_thread()
171
+ .build()
172
+ .context("failed to build tokio runtime")?;
173
+
174
+ // Build prompt tokens (0, 1, 2, ...).
175
+ let prompt: Vec<u32> = (0..args.prompt_len as u32).collect();
176
+
177
+ let params = SamplingParams {
178
+ max_tokens: args.max_tokens,
179
+ temperature: 0.0, // greedy for deterministic comparison
180
+ ..Default::default()
181
+ };
182
+
183
+ // --- Scheduler setup ---
184
+ let pool = KvBlockPool::new(4096);
185
+ let config = SchedulerConfig {
186
+ max_num_seqs: args.num_requests.max(1),
187
+ ..Default::default()
188
+ };
189
+ let scheduler = Arc::new(Mutex::new(Scheduler::new(config, pool)));
190
+ let se = Arc::new(SchedulerEngine::new(engine.clone(), scheduler));
191
+
192
+ // ---- Naive path ----
193
+ let all_default = !args.bench_naive && !args.bench_scheduled;
194
+ if args.bench_naive || all_default {
195
+ println!("--- Benchmark: naive Engine::generate ---");
196
+ let timing = rt.block_on(bench_naive(
197
+ &engine,
198
+ &args.model_name,
199
+ &prompt,
200
+ &params,
201
+ args.num_requests,
202
+ ));
203
+ report("Naive Engine", &timing);
204
+ }
205
+
206
+ // ---- Scheduled path ----
207
+ if args.bench_scheduled || all_default {
208
+ println!("--- Benchmark: SchedulerEngine::generate ---");
209
+ let timing = rt.block_on(bench_scheduled(
210
+ &se,
211
+ &args.model_name,
212
+ &prompt,
213
+ &params,
214
+ args.num_requests,
215
+ ));
216
+ report("SchedulerEngine", &timing);
217
+ }
218
+
219
+ Ok(())
220
+}
221
+
222
+/// Run `n` sequential requests through naive `Engine::generate`, measuring
223
+/// per-request TTFT and token timings.
224
+async fn bench_naive(
225
+ engine: &Engine,
226
+ model_name: &str,
227
+ prompt: &[u32],
228
+ params: &SamplingParams,
229
+ n: usize,
230
+) -> Timing {
231
+ let mut timing = Timing::default();
232
+ let batch_start = Instant::now();
233
+
234
+ for _ in 0..n {
235
+ let mut rx = engine.generate(model_name, prompt, params.clone()).unwrap();
236
+ let mut req_first = true;
237
+ let mut last_tok_time = Instant::now();
238
+
239
+ while let Some(tok) = rx.recv().await {
240
+ if req_first {
241
+ timing
242
+ .ttfts
243
+ .push(last_tok_time.elapsed().as_secs_f64() * 1000.0);
244
+ req_first = false;
245
+ last_tok_time = Instant::now();
246
+ }
247
+ if tok.finish_reason.is_some() {
248
+ break;
249
+ }
250
+ timing
251
+ .itls
252
+ .push(last_tok_time.elapsed().as_secs_f64() * 1000.0);
253
+ last_tok_time = Instant::now();
254
+ timing.total_tokens += 1;
255
+ }
256
+ }
257
+
258
+ timing.wall_ms = batch_start.elapsed().as_secs_f64() * 1000.0;
259
+ timing
260
+}
261
+
262
+/// Run `n` concurrent requests through scheduler-backed path, measuring
263
+/// per-request TTFT and token timings.
264
+async fn bench_scheduled(
265
+ se: &SchedulerEngine,
266
+ model_name: &str,
267
+ prompt: &[u32],
268
+ params: &SamplingParams,
269
+ n: usize,
270
+) -> Timing {
271
+ let mut timing = Timing::default();
272
+ let batch_start = Instant::now();
273
+
274
+ // Admit all requests up front (continuous batching interleaves them).
275
+ let mut receivers = Vec::with_capacity(n);
276
+ for _ in 0..n {
277
+ receivers.push(se.generate(model_name, prompt, params.clone()).unwrap());
278
+ }
279
+
280
+ for rx in receivers.iter_mut() {
281
+ let mut req_first = true;
282
+ let mut last_tok_time = Instant::now();
283
+
284
+ while let Some(tok) = rx.recv().await {
285
+ if req_first {
286
+ timing
287
+ .ttfts
288
+ .push(last_tok_time.elapsed().as_secs_f64() * 1000.0);
289
+ req_first = false;
290
+ last_tok_time = Instant::now();
291
+ }
292
+ if tok.finish_reason.is_some() {
293
+ break;
294
+ }
295
+ timing
296
+ .itls
297
+ .push(last_tok_time.elapsed().as_secs_f64() * 1000.0);
298
+ last_tok_time = Instant::now();
299
+ timing.total_tokens += 1;
300
+ }
301
+ }
302
+
303
+ timing.wall_ms = batch_start.elapsed().as_secs_f64() * 1000.0;
304
+ timing
305
+}
0
306
▼crates/justapi-bench/src/inference_bench.rs
+475-0
1
+//! Inference-engine scheduler benchmark — structural proof-of-mechanism on the
2
+//! CPU fixture.
3
+//!
4
+//! No GPU is available in CI, so this does **not** measure wall-clock LLM
5
+//! tokens/sec. Instead it drives the *real* JustAPI schedulers
6
+//! ([`PdScheduler`] for disaggregated prefill/decode, [`Scheduler`] for a
7
+//! collocated pool) with a parameterized synthetic GPU-cost model and reports
8
+//! the scheduling metrics that define LLM-serving quality:
9
+//!
10
+//! - **TTFT** (time to first token) — prefill latency experienced by a request.
11
+//! - **ITL** (inter-token latency) — decode-step spacing (streaming smoothness).
12
+//! - **Throughput** — total generated tokens / wall time.
13
+//!
14
+//! ## Cost + parallelism model
15
+//!
16
+//! Prefill cost scales with prompt tokens processed in a step (compute-bound);
17
+//! decode cost is a single fixed step regardless of batch size
18
+//! (memory-bandwidth-bound) — the standard vLLM/SGLang assumption.
19
+//!
20
+//! The two topologies differ in *hardware*:
21
+//! - **Disaggregated** runs prefill and decode on **independent pools** (separate
22
+//! GPUs), so their timelines advance in parallel. Decode ITL is therefore
23
+//! decoupled from prefill — it stays uniform at `decode_us` even while prefill
24
+//! is busy.
25
+//! - **Collocated** shares one pool, so each step serialises prefill-then-decode
26
+//! on the same budget; decode ITL is inflated by whatever prefill work shares
27
+//! the step.
28
+//!
29
+//! We model this with two independent virtual clocks (disaggregated) vs one
30
+//! shared clock (collocated). This is a faithful structural model of the
31
+//! scheduler topology, not a wall-clock GPU claim.
32
+
33
+use std::collections::HashSet;
34
+use std::sync::{Arc, Mutex};
35
+use std::time::Instant;
36
+
37
+use justapi_inference::{
38
+ Engine, EngineDevice, KvBlockPool, NewRequest, PdScheduler, SamplingParams, Scheduler,
39
+ SchedulerConfig, SchedulerEngine,
40
+};
41
+
42
+/// Benchmark parameters.
43
+#[derive(Clone, Copy)]
44
+struct BenchParams {
45
+ /// Number of concurrent requests submitted at t=0 (burst load).
46
+ num_requests: usize,
47
+ /// Base prompt length; actual length varies per request to force prefill/decode
48
+ /// overlap (so the collocated scheduler's ITL degradation is visible).
49
+ prompt_len: usize,
50
+ /// Max new tokens to generate per request.
51
+ max_tokens: usize,
52
+ /// Synthetic prefill cost: microseconds per prompt token in a prefill step.
53
+ prefill_us_per_token: u64,
54
+ /// Synthetic decode cost: microseconds for one batched decode step.
55
+ decode_us_per_token: u64,
56
+ /// Chunked-prefill size (None = whole prompt at once).
57
+ chunked_prefill_size: Option<usize>,
58
+}
59
+
60
+/// Collected scheduling metrics for one run (times in milliseconds).
61
+#[derive(Default)]
62
+struct Metrics {
63
+ /// Per-request time-to-first-token.
64
+ ttfts: Vec<f64>,
65
+ /// Per-decode-step inter-token latency.
66
+ itls: Vec<f64>,
67
+ /// Total generated (decode) tokens.
68
+ total_tokens: u64,
69
+ /// Wall-clock duration (virtual).
70
+ wall_ms: f64,
71
+ /// Tokens transferred prefill→decode pool (disaggregated only).
72
+ transferred_tokens: usize,
73
+}
74
+
75
+fn make_request(id: u64, prompt_len: usize, max_tokens: usize) -> NewRequest {
76
+ NewRequest {
77
+ id,
78
+ prompt: (0..prompt_len as u32).collect(),
79
+ sampling_params: SamplingParams {
80
+ max_tokens,
81
+ ..Default::default()
82
+ },
83
+ prefix_cached_tokens: 0,
84
+ cached_blocks: Vec::new(),
85
+ }
86
+}
87
+
88
+fn percentile(sorted: &[f64], p: f64) -> f64 {
89
+ if sorted.is_empty() {
90
+ return 0.0;
91
+ }
92
+ let rank = (p * (sorted.len() as f64 - 1.0)).round() as usize;
93
+ sorted[rank.min(sorted.len() - 1)]
94
+}
95
+
96
+/// Run the **disaggregated** configuration: independent prefill and decode pools
97
+/// modelled with parallel virtual clocks.
98
+fn run_disaggregated(p: &BenchParams) -> Metrics {
99
+ let mut pd = PdScheduler::new(
100
+ SchedulerConfig {
101
+ max_num_seqs: p.num_requests.max(1),
102
+ max_num_batched_tokens: 8192,
103
+ max_seq_len: 8192,
104
+ chunked_prefill_size: p.chunked_prefill_size,
105
+ },
106
+ KvBlockPool::new(8192),
107
+ SchedulerConfig {
108
+ max_num_seqs: p.num_requests.max(1),
109
+ max_num_batched_tokens: 8192,
110
+ max_seq_len: 8192,
111
+ chunked_prefill_size: p.chunked_prefill_size,
112
+ },
113
+ KvBlockPool::new(8192),
114
+ );
115
+ for i in 0..p.num_requests as u64 {
116
+ // Vary prompt length so sequences finish prefill at different steps and
117
+ // prefill/decode overlap.
118
+ let pl = p.prompt_len + ((i as usize) % 5) * 48;
119
+ pd.add_request(make_request(i, pl, p.max_tokens));
120
+ }
121
+
122
+ // Two independent timelines (parallel prefill + decode GPUs).
123
+ let mut prefill_clock = 0.0_f64; // µs
124
+ let mut decode_clock = 0.0_f64; // µs
125
+ let mut m = Metrics::default();
126
+ let mut decoded_once: HashSet<u64> = HashSet::new();
127
+ let mut prev_decode_clock: Option<f64> = None;
128
+
129
+ loop {
130
+ if pd.is_idle() {
131
+ break;
132
+ }
133
+
134
+ // ---- prefill pool ----
135
+ let sched = pd.schedule_prefill();
136
+ let p_cost: f64 = sched.prefill.iter().map(|s| s.tokens.len()).sum::<usize>() as f64
137
+ * p.prefill_us_per_token as f64;
138
+ prefill_clock += p_cost;
139
+ for step in &sched.prefill {
140
+ pd.on_prefill_complete(step.seq_id, 1);
141
+ }
142
+ m.transferred_tokens += pd.transfer_completed();
143
+
144
+ // ---- decode pool (runs in parallel on its own GPU) ----
145
+ let sched = pd.schedule_decode();
146
+ let d_cost: f64 = if sched.decode.is_empty() {
147
+ 0.0
148
+ } else {
149
+ p.decode_us_per_token as f64
150
+ };
151
+ decode_clock += d_cost;
152
+ if !sched.decode.is_empty() {
153
+ // Global virtual time = the later of the two pool timelines.
154
+ let now = prefill_clock.max(decode_clock);
155
+ for &seq_id in &sched.decode {
156
+ if decoded_once.insert(seq_id) {
157
+ m.ttfts.push(now / 1000.0);
158
+ }
159
+ pd.on_decode_complete(seq_id, 1);
160
+ }
161
+ m.total_tokens += sched.decode.len() as u64;
162
+ // ITL is measured on the decode pool's own timeline (independent).
163
+ if let Some(prev) = prev_decode_clock {
164
+ m.itls.push((decode_clock - prev) / 1000.0);
165
+ }
166
+ prev_decode_clock = Some(decode_clock);
167
+ }
168
+ }
169
+
170
+ m.wall_ms = decode_clock.max(prefill_clock) / 1000.0;
171
+ m
172
+}
173
+
174
+/// Run the **collocated** configuration: one scheduler, one shared virtual clock
175
+/// (prefill and decode serialised on the same pool/budget).
176
+fn run_collocated(p: &BenchParams) -> Metrics {
177
+ let mut sched = Scheduler::new(
178
+ SchedulerConfig {
179
+ max_num_seqs: p.num_requests.max(1),
180
+ max_num_batched_tokens: 8192,
181
+ max_seq_len: 8192,
182
+ chunked_prefill_size: p.chunked_prefill_size,
183
+ },
184
+ KvBlockPool::new(8192),
185
+ );
186
+ for i in 0..p.num_requests as u64 {
187
+ let pl = p.prompt_len + ((i as usize) % 5) * 48;
188
+ sched.add_request(make_request(i, pl, p.max_tokens));
189
+ }
190
+
191
+ let mut t = 0.0_f64; // µs, single shared clock
192
+ let mut m = Metrics::default();
193
+ let mut decoded_once: HashSet<u64> = HashSet::new();
194
+ let mut prev_t: Option<f64> = None;
195
+
196
+ loop {
197
+ if sched.is_idle() {
198
+ break;
199
+ }
200
+ let s = sched.schedule();
201
+
202
+ // Shared pool: prefill and decode serialised in the same step.
203
+ let mut cost = 0.0_f64;
204
+ for step in &s.prefill {
205
+ cost += (step.tokens.len() as f64) * p.prefill_us_per_token as f64;
206
+ }
207
+ if !s.decode.is_empty() {
208
+ cost += p.decode_us_per_token as f64;
209
+ }
210
+ t += cost;
211
+
212
+ for step in &s.prefill {
213
+ sched.on_step_complete(step.seq_id, 1);
214
+ }
215
+ for &seq_id in &s.decode {
216
+ if decoded_once.insert(seq_id) {
217
+ m.ttfts.push(t / 1000.0);
218
+ }
219
+ sched.on_step_complete(seq_id, 1);
220
+ }
221
+ m.total_tokens += s.decode.len() as u64;
222
+ if !s.decode.is_empty() {
223
+ if let Some(prev) = prev_t {
224
+ m.itls.push((t - prev) / 1000.0);
225
+ }
226
+ prev_t = Some(t);
227
+ }
228
+ }
229
+
230
+ m.wall_ms = t / 1000.0;
231
+ m
232
+}
233
+
234
+fn report(name: &str, m: &Metrics, p: &BenchParams) {
235
+ let mut ttfts = m.ttfts.clone();
236
+ ttfts.sort_by(|a, b| a.partial_cmp(b).unwrap());
237
+ let mut itls = m.itls.clone();
238
+ itls.sort_by(|a, b| a.partial_cmp(b).unwrap());
239
+
240
+ let tput = if m.wall_ms > 0.0 {
241
+ m.total_tokens as f64 / (m.wall_ms / 1000.0)
242
+ } else {
243
+ 0.0
244
+ };
245
+
246
+ println!("### {name}");
247
+ println!();
248
+ println!("| Metric | p50 | p99 |");
249
+ println!("|---|---|---|");
250
+ println!(
251
+ "| TTFT (ms) | {:.2} | {:.2} |",
252
+ percentile(&ttfts, 0.5),
253
+ percentile(&ttfts, 0.99)
254
+ );
255
+ println!(
256
+ "| ITL (ms) | {:.2} | {:.2} |",
257
+ percentile(&itls, 0.5),
258
+ percentile(&itls, 0.99)
259
+ );
260
+ println!("| Throughput (tok/s) | {:.0} | |", tput);
261
+ println!("| Total tokens | {} | |", m.total_tokens);
262
+ println!("| Wall time (ms) | {:.2} | |", m.wall_ms);
263
+ if m.transferred_tokens > 0 {
264
+ println!("| Transferred tokens (P→D) | {} | |", m.transferred_tokens);
265
+ }
266
+ println!();
267
+ println!(
268
+ "Config: {} requests, prompt≈{}-{} tok, max_tokens={}, prefill={}µs/tok, decode={}µs/step, chunk={:?}",
269
+ p.num_requests,
270
+ p.prompt_len,
271
+ p.prompt_len + 4 * 48,
272
+ p.max_tokens,
273
+ p.prefill_us_per_token,
274
+ p.decode_us_per_token,
275
+ p.chunked_prefill_size
276
+ );
277
+ println!();
278
+}
279
+
280
+// ---------------------------------------------------------------------------
281
+// Real wall-clock throughput (CPU fixture, MockModel)
282
+//
283
+// The structural benchmark above models GPU cost synthetically. This section
284
+// additionally drives the *real* generation path (naive `Engine::generate` vs
285
+// scheduler-backed `SchedulerEngine::generate`) and measures actual token
286
+// throughput on the CPU fixture. This quantifies the scheduler's per-token
287
+// overhead (thread hop + lock + sampling) — not LLM tokens/sec, which needs a
288
+// GPU.
289
+// ---------------------------------------------------------------------------
290
+
291
+const REAL_MODEL: &str = "mock";
292
+
293
+/// Run `n` requests through the naive `Engine::generate` path and return
294
+/// (total_tokens, wall_ms).
295
+fn bench_naive(n: usize, prompt_len: usize, max_tokens: usize) -> (u64, f64) {
296
+ let engine = Arc::new(Engine::new(EngineDevice::Cpu).unwrap());
297
+ engine.register_mock(REAL_MODEL);
298
+ let rt = tokio::runtime::Builder::new_current_thread()
299
+ .build()
300
+ .unwrap();
301
+
302
+ let prompt: Vec<u32> = (0..prompt_len as u32).collect();
303
+ let params = SamplingParams {
304
+ max_tokens,
305
+ ..Default::default()
306
+ };
307
+
308
+ let start = Instant::now();
309
+ let total: u64 = rt.block_on(async {
310
+ let mut total = 0u64;
311
+ for _ in 0..n {
312
+ let mut rx = engine
313
+ .generate(REAL_MODEL, &prompt, params.clone())
314
+ .unwrap();
315
+ while let Some(tok) = rx.recv().await {
316
+ if tok.finish_reason.is_some() {
317
+ break;
318
+ }
319
+ total += 1;
320
+ }
321
+ }
322
+ total
323
+ });
324
+ let wall_ms = start.elapsed().as_secs_f64() * 1000.0;
325
+ (total, wall_ms)
326
+}
327
+
328
+/// Run `n` requests through the scheduler-backed `SchedulerEngine` path and
329
+/// return (total_tokens, wall_ms).
330
+fn bench_scheduled(n: usize, prompt_len: usize, max_tokens: usize) -> (u64, f64) {
331
+ let engine = Arc::new(Engine::new(EngineDevice::Cpu).unwrap());
332
+ engine.register_mock(REAL_MODEL);
333
+ let pool = KvBlockPool::new(4096);
334
+ let config = SchedulerConfig {
335
+ max_num_seqs: n.max(1),
336
+ ..Default::default()
337
+ };
338
+ let scheduler = Arc::new(Mutex::new(Scheduler::new(config, pool)));
339
+ let se = Arc::new(SchedulerEngine::new(engine.clone(), scheduler));
340
+
341
+ let rt = tokio::runtime::Builder::new_current_thread()
342
+ .build()
343
+ .unwrap();
344
+
345
+ let prompt: Vec<u32> = (0..prompt_len as u32).collect();
346
+ let params = SamplingParams {
347
+ max_tokens,
348
+ ..Default::default()
349
+ };
350
+
351
+ let start = Instant::now();
352
+ let total: u64 = rt.block_on(async {
353
+ let mut total = 0u64;
354
+ // All requests are admitted up front; the persistent scheduler loop
355
+ // interleaves them (continuous batching).
356
+ let mut receivers = Vec::new();
357
+ for _ in 0..n {
358
+ receivers.push(se.generate(REAL_MODEL, &prompt, params.clone()).unwrap());
359
+ }
360
+ for rx in receivers.iter_mut() {
361
+ while let Some(tok) = rx.recv().await {
362
+ if tok.finish_reason.is_some() {
363
+ break;
364
+ }
365
+ total += 1;
366
+ }
367
+ }
368
+ total
369
+ });
370
+ let wall_ms = start.elapsed().as_secs_f64() * 1000.0;
371
+ (total, wall_ms)
372
+}
373
+
374
+fn main() {
375
+ let params = BenchParams {
376
+ num_requests: 16,
377
+ prompt_len: 64,
378
+ max_tokens: 64,
379
+ prefill_us_per_token: 5,
380
+ decode_us_per_token: 200,
381
+ chunked_prefill_size: Some(16),
382
+ };
383
+
384
+ println!("# JustAPI inference scheduler benchmark — disaggregated P/D vs collocated");
385
+ println!();
386
+ println!(
387
+ "Hardware: 13th Gen Intel Core i5-13600K (20 threads), CachyOS. \
388
+ Structural run — GPU cost modeled synthetically (no GPU in CI). \
389
+ Disaggregated = parallel prefill/decode timelines; collocated = shared timeline."
390
+ );
391
+ println!();
392
+
393
+ let coll = run_collocated(&params);
394
+ let disagg = run_disaggregated(&params);
395
+
396
+ report(
397
+ "Collocated (single pool, prefill+decode serialised)",
398
+ &coll,
399
+ &params,
400
+ );
401
+ report(
402
+ "Disaggregated (independent prefill/decode pools)",
403
+ &disagg,
404
+ &params,
405
+ );
406
+
407
+ let mut coll_itl = coll.itls.clone();
408
+ coll_itl.sort_by(|a, b| a.partial_cmp(b).unwrap());
409
+ let mut disagg_itl = disagg.itls.clone();
410
+ disagg_itl.sort_by(|a, b| a.partial_cmp(b).unwrap());
411
+
412
+ let coll_p99 = percentile(&coll_itl, 0.99);
413
+ let disagg_p99 = percentile(&disagg_itl, 0.99);
414
+ let itl_improvement = if disagg_p99 > 0.0 {
415
+ coll_p99 / disagg_p99
416
+ } else {
417
+ 1.0
418
+ };
419
+ let tput_improvement = if coll.wall_ms > 0.0 {
420
+ disagg.wall_ms / coll.wall_ms
421
+ } else {
422
+ 1.0
423
+ };
424
+
425
+ println!("---");
426
+ println!();
427
+ println!(
428
+ "**ITL p99:** collocated {:.2} ms → disaggregated {:.2} ms ({:.2}x tighter)",
429
+ coll_p99, disagg_p99, itl_improvement
430
+ );
431
+ println!(
432
+ "**Wall time:** collocated {:.2} ms → disaggregated {:.2} ms ({:.2}x faster, parallel pools)",
433
+ coll.wall_ms, disagg.wall_ms, tput_improvement
434
+ );
435
+ println!();
436
+
437
+ // ---- Real wall-clock throughput comparison ----
438
+ println!("---");
439
+ println!();
440
+ println!("# Real wall-clock throughput (CPU fixture, MockModel)");
441
+ println!();
442
+ println!(
443
+ "Measures actual generation latency on the CPU — quantifies scheduler \
444
+ per-token overhead (thread hop + lock contention + sampling). \
445
+ All models are MockModel (instant forward pass)."
446
+ );
447
+ println!();
448
+
449
+ let n_requests_real = 8;
450
+ let prompt_len_real = 8;
451
+ let max_tokens_real = 16;
452
+ let (naive_tokens, naive_ms) = bench_naive(n_requests_real, prompt_len_real, max_tokens_real);
453
+ let (sched_tokens, sched_ms) =
454
+ bench_scheduled(n_requests_real, prompt_len_real, max_tokens_real);
455
+
456
+ let naive_tput = (naive_tokens as f64) / (naive_ms / 1000.0);
457
+ let sched_tput = (sched_tokens as f64) / (sched_ms / 1000.0);
458
+ let overhead = if naive_tput > 0.0 {
459
+ sched_tput / naive_tput * 100.0
460
+ } else {
461
+ 0.0
462
+ };
463
+
464
+ println!("| Path | Total tokens | Wall (ms) | Throughput (tok/s) | Overhead vs naive |");
465
+ println!("|---|---|---|---|---|");
466
+ println!("| Naive Engine | {naive_tokens} | {naive_ms:.2} | {naive_tput:.0} | — |");
467
+ println!(
468
+ "| SchedulerEngine | {sched_tokens} | {sched_ms:.2} | {sched_tput:.0} | Scheduler {overhead:.1}% of naive |"
469
+ );
470
+ println!();
471
+ println!(
472
+ "Config: {n_requests_real} requests, {prompt_len_real} prompt tokens, {max_tokens_real} max_tokens each."
473
+ );
474
+ println!();
475
+}
0
476
▼crates/justapi-bench/src/main.rs
+96-0
1
+use std::time::Instant;
2
+
3
+use serde::Serialize;
4
+
5
+#[derive(Serialize)]
6
+struct NestedPayload {
7
+ user: User,
8
+ items: Vec<u32>,
9
+ meta: Metadata,
10
+}
11
+
12
+#[derive(Serialize)]
13
+struct User {
14
+ name: String,
15
+ id: u32,
16
+}
17
+
18
+#[derive(Serialize)]
19
+struct Metadata {
20
+ version: String,
21
+ timestamp: u64,
22
+}
23
+
24
+fn payload() -> NestedPayload {
25
+ NestedPayload {
26
+ user: User {
27
+ name: "test".into(),
28
+ id: 42,
29
+ },
30
+ items: vec![1, 2, 3],
31
+ meta: Metadata {
32
+ version: "1.0".into(),
33
+ timestamp: 1700000000,
34
+ },
35
+ }
36
+}
37
+
38
+fn bench<F>(name: &str, f: F, iterations: usize)
39
+where
40
+ F: Fn(),
41
+{
42
+ for _ in 0..1000 {
43
+ f();
44
+ }
45
+
46
+ let start = Instant::now();
47
+ for _ in 0..iterations {
48
+ f();
49
+ }
50
+ let elapsed = start.elapsed();
51
+ let avg_ns = elapsed.as_nanos() as f64 / iterations as f64;
52
+ let ops_per_sec = (iterations as f64 / elapsed.as_secs_f64()) as u64;
53
+
54
+ println!(
55
+ "| {} | {:.0} ns/op | {:.0} ops/sec | {} iterations in {:.2?} |",
56
+ name, avg_ns, ops_per_sec, iterations, elapsed
57
+ );
58
+}
59
+
60
+fn main() {
61
+ println!();
62
+ println!("## Serialization benchmarks");
63
+ println!();
64
+ println!("Payload: nested JSON object (user+items+meta), ~60 bytes");
65
+ println!();
66
+ println!("| Backend | Latency | Throughput | Details |");
67
+ println!("|---|---|---|---|");
68
+
69
+ let p = payload();
70
+ let iterations = 1_000_000;
71
+
72
+ bench(
73
+ "justapi_core::serialize (default=serde_json)",
74
+ || {
75
+ use justapi_core::serialize::to_json_string;
76
+ let _ = to_json_string(&p).unwrap();
77
+ },
78
+ iterations,
79
+ );
80
+
81
+ #[cfg(feature = "simd-json")]
82
+ bench(
83
+ "justapi_core::serialize (simd-json feature)",
84
+ || {
85
+ use justapi_core::serialize::to_json_string;
86
+ let _ = to_json_string(&p).unwrap();
87
+ },
88
+ iterations,
89
+ );
90
+
91
+ println!();
92
+ println!("---");
93
+ println!();
94
+ println!("Hardware: 13th Gen Intel Core i5-13600K, DDR5");
95
+ println!();
96
+}
0
97
▼crates/justapi-cli/Cargo.toml
+33-0
1
+[package]
2
+name = "justapi-cli"
3
+version.workspace = true
4
+edition.workspace = true
5
+license.workspace = true
6
+
7
+[[bin]]
8
+name = "justapi"
9
+path = "src/main.rs"
10
+
11
+[features]
12
+default = []
13
+tls = ["justapi-core/tls"]
14
+compression = ["justapi-core/compression"]
15
+opentelemetry = ["justapi-core/opentelemetry"]
16
+inference = ["justapi-core/inference", "dep:justapi-inference"]
17
+
18
+[dependencies]
19
+justapi-core = { workspace = true, features = ["db"] }
20
+justapi-inference = { path = "../justapi-inference", optional = true }
21
+tokio.workspace = true
22
+clap.workspace = true
23
+tracing.workspace = true
24
+tracing-subscriber.workspace = true
25
+anyhow.workspace = true
26
+serde.workspace = true
27
+serde_json.workspace = true
28
+tokio-util.workspace = true
29
+notify-debouncer-mini = "0.7"
30
+notify = "8"
31
+
32
+[dev-dependencies]
33
+hyper = { version = "1", features = ["http1"] }
0
34
▼crates/justapi-cli/src/gen_client.rs
+806-0
1
+use std::collections::BTreeMap;
2
+use std::path::Path;
3
+
4
+use justapi_core::openapi::{OpenApiDocument, Operation, ParameterLocation};
5
+
6
+/// Supported target languages for client generation.
7
+#[derive(Debug, Clone, PartialEq, Eq)]
8
+pub enum ClientLanguage {
9
+ Python,
10
+ Typescript,
11
+}
12
+
13
+impl std::str::FromStr for ClientLanguage {
14
+ type Err = anyhow::Error;
15
+
16
+ fn from_str(s: &str) -> Result<Self, Self::Err> {
17
+ match s.to_ascii_lowercase().as_str() {
18
+ "py" | "python" => Ok(ClientLanguage::Python),
19
+ "ts" | "typescript" | "js" => Ok(ClientLanguage::Typescript),
20
+ other => anyhow::bail!(
21
+ "unsupported language '{}' (expected python or typescript)",
22
+ other
23
+ ),
24
+ }
25
+ }
26
+}
27
+
28
+impl ClientLanguage {
29
+ pub fn extension(&self) -> &'static str {
30
+ match self {
31
+ ClientLanguage::Python => "py",
32
+ ClientLanguage::Typescript => "ts",
33
+ }
34
+ }
35
+
36
+ pub fn module_name(&self) -> &'static str {
37
+ match self {
38
+ ClientLanguage::Python => "client",
39
+ ClientLanguage::Typescript => "client",
40
+ }
41
+ }
42
+}
43
+
44
+/// A single parameter with its original spec name, a sanitized argument
45
+/// name safe for the target language, and its scalar schema type (if known).
46
+struct Param {
47
+ original: String,
48
+ arg: String,
49
+ ty: Option<String>,
50
+}
51
+
52
+/// One operation extracted from the spec, ready to emit.
53
+struct Op {
54
+ method: String,
55
+ raw_path: String,
56
+ name: String,
57
+ summary: Option<String>,
58
+ path_params: Vec<Param>,
59
+ query_params: Vec<Param>,
60
+ header_params: Vec<Param>,
61
+ has_body: bool,
62
+}
63
+
64
+/// Build a valid identifier from an arbitrary operation id / hint.
65
+fn sanitize_ident(s: &str) -> String {
66
+ let mut out = String::new();
67
+ let mut upper = false;
68
+ for ch in s.chars() {
69
+ if ch.is_alphanumeric() {
70
+ out.push(if upper { ch.to_ascii_uppercase() } else { ch });
71
+ upper = false;
72
+ } else {
73
+ upper = true;
74
+ }
75
+ }
76
+ // Collapse leading/trailing digits and ensure non-empty.
77
+ let out: String = out.trim_matches(|c: char| !c.is_alphabetic()).to_string();
78
+ if out.is_empty() {
79
+ "call".to_string()
80
+ } else {
81
+ out
82
+ }
83
+}
84
+
85
+fn python_type(schema_type: Option<&str>) -> &'static str {
86
+ match schema_type {
87
+ Some("string") => "str",
88
+ Some("integer") => "int",
89
+ Some("number") => "float",
90
+ Some("boolean") => "bool",
91
+ Some("array") => "list",
92
+ Some("object") => "dict",
93
+ _ => "Any",
94
+ }
95
+}
96
+
97
+fn ts_type(schema_type: Option<&str>) -> &'static str {
98
+ match schema_type {
99
+ Some("string") => "string",
100
+ Some("integer") | Some("number") => "number",
101
+ Some("boolean") => "boolean",
102
+ Some("array") => "unknown[]",
103
+ Some("object") => "Record<string, unknown>",
104
+ _ => "unknown",
105
+ }
106
+}
107
+
108
+fn collect_ops(doc: &OpenApiDocument) -> Vec<Op> {
109
+ let mut ops: Vec<Op> = Vec::new();
110
+ let methods = [
111
+ ("get", &doc.paths),
112
+ ("post", &doc.paths),
113
+ ("put", &doc.paths),
114
+ ("delete", &doc.paths),
115
+ ("patch", &doc.paths),
116
+ ("head", &doc.paths),
117
+ ("options", &doc.paths),
118
+ ("query", &doc.paths),
119
+ ];
120
+
121
+ for (method, paths) in methods {
122
+ for (raw_path, item) in paths {
123
+ let op: Option<&Operation> = match method {
124
+ "get" => item.get.as_ref(),
125
+ "post" => item.post.as_ref(),
126
+ "put" => item.put.as_ref(),
127
+ "delete" => item.delete.as_ref(),
128
+ "patch" => item.patch.as_ref(),
129
+ "head" => item.head.as_ref(),
130
+ "options" => item.options.as_ref(),
131
+ "query" => item.query.as_ref(),
132
+ _ => None,
133
+ };
134
+ let Some(op) = op else { continue };
135
+
136
+ let mut path_params = Vec::new();
137
+ let mut query_params = Vec::new();
138
+ let mut header_params = Vec::new();
139
+ for p in &op.parameters {
140
+ let t = p.schema.as_ref().and_then(|s| match s {
141
+ justapi_core::openapi::Schema::Object(o) => o.schema_type.clone(),
142
+ _ => None,
143
+ });
144
+ let arg = sanitize_ident(&p.name);
145
+ let param = Param {
146
+ original: p.name.clone(),
147
+ arg,
148
+ ty: t,
149
+ };
150
+ match p.location {
151
+ ParameterLocation::Path => path_params.push(param),
152
+ ParameterLocation::Query => query_params.push(param),
153
+ ParameterLocation::Header => header_params.push(param),
154
+ ParameterLocation::Cookie => {}
155
+ }
156
+ }
157
+
158
+ let name = op
159
+ .operation_id
160
+ .clone()
161
+ .map(|id| sanitize_ident(&id))
162
+ .unwrap_or_else(|| {
163
+ let base = raw_path
164
+ .split('/')
165
+ .filter(|s| !s.is_empty())
166
+ .map(sanitize_ident)
167
+ .collect::<Vec<_>>()
168
+ .join("_");
169
+ format!("{}_{}", method, base)
170
+ });
171
+
172
+ let has_body = op.request_body.is_some();
173
+
174
+ ops.push(Op {
175
+ method: method.to_ascii_uppercase().to_string(),
176
+ raw_path: raw_path.clone(),
177
+ name,
178
+ summary: op.summary.clone(),
179
+ path_params,
180
+ query_params,
181
+ header_params,
182
+ has_body,
183
+ });
184
+ }
185
+ }
186
+
187
+ // De-duplicate method names (case-insensitive), appending an index on clash.
188
+ let mut seen: BTreeMap<String, usize> = BTreeMap::new();
189
+ for op in ops.iter_mut() {
190
+ let key = op.name.to_ascii_lowercase();
191
+ let count = seen.entry(key).or_insert(0);
192
+ if *count > 0 {
193
+ op.name = format!("{}_{}", op.name, *count);
194
+ }
195
+ *count += 1;
196
+ }
197
+
198
+ ops
199
+}
200
+
201
+fn base_url(doc: &OpenApiDocument) -> String {
202
+ doc.servers
203
+ .as_ref()
204
+ .and_then(|s| s.first())
205
+ .map(|s| s.url.clone())
206
+ .unwrap_or_else(|| "http://localhost:8080".to_string())
207
+}
208
+
209
+pub fn generate_client(doc: &OpenApiDocument, lang: &ClientLanguage) -> String {
210
+ let ops = collect_ops(doc);
211
+ let base = base_url(doc);
212
+ match lang {
213
+ ClientLanguage::Python => generate_python(&ops, &base),
214
+ ClientLanguage::Typescript => generate_typescript(&ops, &base),
215
+ }
216
+}
217
+
218
+fn generate_python(ops: &[Op], base: &str) -> String {
219
+ let mut methods = String::new();
220
+ for op in ops {
221
+ methods.push_str(&python_method(op));
222
+ }
223
+
224
+ format!(
225
+ r#""""{}
226
+Auto-generated by `justapi gen client`. Do not edit by hand.
227
+
228
+Base URL: {}
229
+"""
230
+from __future__ import annotations
231
+
232
+from typing import Any
233
+
234
+import requests
235
+
236
+
237
+class Client:
238
+ def __init__(
239
+ self,
240
+ base_url: str = {base:?},
241
+ *,
242
+ timeout: float = 30.0,
243
+ headers: dict[str, str] | None = None,
244
+ ) -> None:
245
+ self.base_url = base_url.rstrip("/")
246
+ self.timeout = timeout
247
+ self.session = requests.Session()
248
+ if headers:
249
+ self.session.headers.update(headers)
250
+
251
+ def _request(
252
+ self,
253
+ method: str,
254
+ path: str,
255
+ *,
256
+ params: dict[str, Any] | None = None,
257
+ json: Any | None = None,
258
+ data: Any | None = None,
259
+ headers: dict[str, str] | None = None,
260
+ ) -> Any:
261
+ url = self.base_url + path
262
+ resp = self.session.request(
263
+ method,
264
+ url,
265
+ params=params,
266
+ json=json,
267
+ data=data,
268
+ headers=headers,
269
+ timeout=self.timeout,
270
+ )
271
+ resp.raise_for_status()
272
+ ctype = resp.headers.get("content-type", "")
273
+ if resp.content and ctype.startswith("application/json"):
274
+ return resp.json()
275
+ return resp
276
+
277
+{methods}
278
+
279
+if __name__ == "__main__":
280
+ client = Client()
281
+"#,
282
+ "JustAPI generated Python client.", base
283
+ )
284
+}
285
+
286
+fn python_method(op: &Op) -> String {
287
+ let path = &op.raw_path;
288
+ let mut args: Vec<String> = Vec::new();
289
+ let mut doc: Vec<String> = Vec::new();
290
+
291
+ for p in &op.path_params {
292
+ let ty = python_type(p.ty.as_deref());
293
+ args.push(format!("{}: {}", p.arg, ty));
294
+ }
295
+ for p in &op.query_params {
296
+ let ty = python_type(p.ty.as_deref());
297
+ args.push(format!("{}: {} | None = None", p.arg, ty));
298
+ }
299
+ for p in &op.header_params {
300
+ args.push(format!("{}: str | None = None", p.arg));
301
+ }
302
+ if op.has_body {
303
+ args.push("body: Any | None = None".to_string());
304
+ }
305
+ args.push("extra_headers: dict[str, str] | None = None".to_string());
306
+
307
+ let signature = format!(" def {}(self, {}) -> Any:", op.name, args.join(", "));
308
+
309
+ if let Some(summary) = &op.summary {
310
+ doc.push(format!(" \"\"\"{}\"\"\"", summary));
311
+ } else {
312
+ doc.push(" \"\"\"Auto-generated endpoint call.\"\"\"".to_string());
313
+ }
314
+
315
+ // Path substitution.
316
+ let path_stmt = if op.path_params.is_empty() {
317
+ format!(" path = {}", path.quoted())
318
+ } else {
319
+ let mut fmt_path = path.clone();
320
+ let mut fmt_args: Vec<String> = Vec::new();
321
+ for p in &op.path_params {
322
+ fmt_path = fmt_path.replace(&format!("{{{}}}", p.original), &format!("{{{}}}", p.arg));
323
+ fmt_args.push(format!("{}={}", p.arg, p.arg));
324
+ }
325
+ format!(
326
+ " path = {}.format({})",
327
+ fmt_path.quoted(),
328
+ fmt_args.join(", ")
329
+ )
330
+ };
331
+
332
+ let mut body_lines: Vec<String> = Vec::new();
333
+ if !op.query_params.is_empty() {
334
+ let keys: Vec<String> = op
335
+ .query_params
336
+ .iter()
337
+ .map(|p| format!("{:?}: {}", p.original, p.arg))
338
+ .collect();
339
+ body_lines.push(format!(
340
+ " params = {{k: v for k, v in {{{}}}.items() if v is not None}}",
341
+ keys.join(", ")
342
+ ));
343
+ }
344
+ let mut call_args: Vec<String> = Vec::new();
345
+ if !op.query_params.is_empty() {
346
+ call_args.push("params=params".to_string());
347
+ }
348
+ if op.has_body {
349
+ call_args.push("json=body".to_string());
350
+ }
351
+ if !op.header_params.is_empty() {
352
+ let mut parts: Vec<String> = op
353
+ .header_params
354
+ .iter()
355
+ .map(|p| {
356
+ format!(
357
+ " {:?}: {} if {} is not None else None,",
358
+ p.original, p.arg, p.arg
359
+ )
360
+ })
361
+ .collect();
362
+ parts.push(" **(extra_headers or {})".to_string());
363
+ body_lines.push(format!(
364
+ " headers = {{\n{}\n }}",
365
+ parts.join("\n")
366
+ ));
367
+ } else {
368
+ call_args.push("headers=extra_headers".to_string());
369
+ }
370
+
371
+ let call = format!(
372
+ " result = self._request({}, path{})",
373
+ op.method.quoted(),
374
+ if call_args.is_empty() {
375
+ String::new()
376
+ } else {
377
+ format!(", {}", call_args.join(", "))
378
+ }
379
+ );
380
+
381
+ let body = format!(
382
+ "{}\n{}\n{}\n{}\n return result\n\n",
383
+ doc.join("\n"),
384
+ path_stmt,
385
+ body_lines.join("\n"),
386
+ call,
387
+ );
388
+
389
+ format!("{}\n{}\n", signature, body)
390
+}
391
+
392
+trait Quote {
393
+ fn quoted(&self) -> String;
394
+}
395
+
396
+impl Quote for str {
397
+ fn quoted(&self) -> String {
398
+ format!("{:?}", self)
399
+ }
400
+}
401
+
402
+fn generate_typescript(ops: &[Op], base: &str) -> String {
403
+ let mut methods = String::new();
404
+ for op in ops {
405
+ methods.push_str(&ts_method(op));
406
+ }
407
+
408
+ format!(
409
+ r#"// JustAPI generated TypeScript client.
410
+// Auto-generated by `justapi gen client`. Do not edit by hand.
411
+// Base URL: {base}
412
+
413
+export interface ClientOptions {{
414
+ baseUrl?: string;
415
+ headers?: Record<string, string>;
416
+ fetch?: typeof fetch;
417
+}}
418
+
419
+export class Client {{
420
+ private baseUrl: string;
421
+ private headers: Record<string, string>;
422
+ private fetcher: typeof fetch;
423
+
424
+ constructor(opts: ClientOptions = {{}}) {{
425
+ this.baseUrl = (opts.baseUrl ?? "{base}").replace(/\/$/, "");
426
+ this.headers = opts.headers ?? {{}};
427
+ this.fetcher = opts.fetch ?? fetch;
428
+ }}
429
+
430
+ private async request<T = unknown>(
431
+ method: string,
432
+ path: string,
433
+ opts: {{ params?: Record<string, unknown>; body?: unknown; headers?: Record<string, string> }} = {{}},
434
+ ): Promise<T> {{
435
+ const url = new URL(this.baseUrl + path);
436
+ if (opts.params) {{
437
+ for (const [k, v] of Object.entries(opts.params)) {{
438
+ if (v !== undefined && v !== null) url.searchParams.set(k, String(v));
439
+ }}
440
+ }}
441
+ const resp = await this.fetcher(url.toString(), {{
442
+ method,
443
+ headers: {{ "content-type": "application/json", ...this.headers, ...(opts.headers ?? {{}}) }},
444
+ body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
445
+ }});
446
+ if (!resp.ok) throw new Error(`Request failed: ${{resp.status}} ${{resp.statusText}}`);
447
+ const ctype = resp.headers.get("content-type") ?? "";
448
+ if (ctype.includes("application/json")) return (await resp.json()) as T;
449
+ return (await resp.text()) as unknown as T;
450
+ }}
451
+
452
+{methods}
453
+}}
454
+"#
455
+ )
456
+}
457
+
458
+fn ts_method(op: &Op) -> String {
459
+ let mut args: Vec<String> = Vec::new();
460
+ for p in &op.path_params {
461
+ let ty = ts_type(p.ty.as_deref());
462
+ args.push(format!("{}: {}", p.arg, ty));
463
+ }
464
+ for p in &op.query_params {
465
+ let ty = ts_type(p.ty.as_deref());
466
+ args.push(format!("{}?: {}", p.arg, ty));
467
+ }
468
+ for p in &op.header_params {
469
+ args.push(format!("{}?: string", p.arg));
470
+ }
471
+ if op.has_body {
472
+ args.push("body?: unknown".to_string());
473
+ }
474
+ args.push("extraHeaders?: Record<string, string>".to_string());
475
+
476
+ let mut body_lines: Vec<String> = Vec::new();
477
+
478
+ let path_stmt = if op.path_params.is_empty() {
479
+ format!(" const path = {};", op.raw_path.quoted())
480
+ } else {
481
+ let repl: Vec<String> = op
482
+ .path_params
483
+ .iter()
484
+ .map(|p| {
485
+ format!(
486
+ "path = path.replace(`{{{}}}`, String({}));",
487
+ p.original, p.arg
488
+ )
489
+ })
490
+ .collect();
491
+ format!(
492
+ " let path = {};\n {}",
493
+ op.raw_path.quoted(),
494
+ repl.join("\n ")
495
+ )
496
+ };
497
+ body_lines.push(path_stmt);
498
+
499
+ if !op.query_params.is_empty() {
500
+ let obj = op
501
+ .query_params
502
+ .iter()
503
+ .map(|p| format!(" {:?}: {},", p.original, p.arg))
504
+ .collect::<Vec<_>>()
505
+ .join("\n");
506
+ body_lines.push(format!(" const params = {{\n{}\n }};", obj));
507
+ }
508
+ if !op.header_params.is_empty() {
509
+ let obj = op
510
+ .header_params
511
+ .iter()
512
+ .map(|p| format!(" {:?}: {},", p.original, p.arg))
513
+ .collect::<Vec<_>>()
514
+ .join("\n");
515
+ body_lines.push(format!(
516
+ " const headers = {{\n{}\n ...(extraHeaders ?? {{}}),\n }};",
517
+ obj
518
+ ));
519
+ }
520
+
521
+ let mut call_args: Vec<String> = Vec::new();
522
+ if !op.query_params.is_empty() {
523
+ call_args.push("params".to_string());
524
+ }
525
+ if op.has_body {
526
+ call_args.push("body".to_string());
527
+ }
528
+ if !op.header_params.is_empty() {
529
+ call_args.push("headers".to_string());
530
+ } else {
531
+ call_args.push("extraHeaders".to_string());
532
+ }
533
+
534
+ let summary = op
535
+ .summary
536
+ .clone()
537
+ .map(|s| format!(" /** {} */\n", s))
538
+ .unwrap_or_default();
539
+
540
+ let call = format!(
541
+ " return this.request(\"{}\", path{}) as Promise<unknown>;",
542
+ op.method,
543
+ if call_args.is_empty() {
544
+ String::new()
545
+ } else {
546
+ format!(
547
+ ", {{ {} }}",
548
+ call_args
549
+ .iter()
550
+ .map(|a| format!("{a}: {}", a))
551
+ .collect::<Vec<_>>()
552
+ .join(", ")
553
+ )
554
+ }
555
+ );
556
+
557
+ format!(
558
+ "\n{} {}({}): Promise<unknown> {{\n{}\n{}\n }}\n",
559
+ summary,
560
+ op.name,
561
+ args.join(", "),
562
+ body_lines.join("\n"),
563
+ call,
564
+ )
565
+}
566
+
567
+/// Public entry point used by the CLI.
568
+pub fn write_client(
569
+ doc: &OpenApiDocument,
570
+ lang: &ClientLanguage,
571
+ output_dir: &Path,
572
+) -> anyhow::Result<std::path::PathBuf> {
573
+ std::fs::create_dir_all(output_dir)?;
574
+ let code = generate_client(doc, lang);
575
+ let file_name = format!("{}.{}", lang.module_name(), lang.extension());
576
+ let out_path = output_dir.join(file_name);
577
+ std::fs::write(&out_path, code)?;
578
+ Ok(out_path)
579
+}
580
+
581
+#[cfg(test)]
582
+mod tests {
583
+ use super::*;
584
+ use hyper::Method;
585
+ use justapi_core::openapi::{
586
+ OpenApiBuilder, Operation, Parameter, ParameterLocation, RequestBody, Schema,
587
+ };
588
+
589
+ fn sample_doc() -> OpenApiDocument {
590
+ let body_schema = Schema::Object(justapi_core::openapi::SchemaObject::object());
591
+ OpenApiBuilder::new("Demo API", "1.0.0")
592
+ .server("https://api.example.com", Some("Prod"))
593
+ .operation(
594
+ Method::GET,
595
+ "/users",
596
+ Operation {
597
+ summary: Some("List users".to_string()),
598
+ description: None,
599
+ operation_id: Some("listUsers".to_string()),
600
+ tags: vec![],
601
+ parameters: vec![
602
+ Parameter {
603
+ name: "limit".to_string(),
604
+ location: ParameterLocation::Query,
605
+ description: None,
606
+ required: false,
607
+ schema: Some(Schema::integer()),
608
+ },
609
+ Parameter {
610
+ name: "X-Trace".to_string(),
611
+ location: ParameterLocation::Header,
612
+ description: None,
613
+ required: false,
614
+ schema: Some(Schema::string()),
615
+ },
616
+ ],
617
+ request_body: None,
618
+ responses: {
619
+ let mut m = std::collections::BTreeMap::new();
620
+ m.insert(
621
+ "200".to_string(),
622
+ justapi_core::openapi::Response {
623
+ description: "ok".to_string(),
624
+ content: None,
625
+ },
626
+ );
627
+ m
628
+ },
629
+ deprecated: None,
630
+ },
631
+ )
632
+ .operation(
633
+ Method::GET,
634
+ "/users/{id}",
635
+ Operation {
636
+ summary: Some("Get user".to_string()),
637
+ description: None,
638
+ operation_id: Some("getUser".to_string()),
639
+ tags: vec![],
640
+ parameters: vec![Parameter {
641
+ name: "id".to_string(),
642
+ location: ParameterLocation::Path,
643
+ description: None,
644
+ required: true,
645
+ schema: Some(Schema::integer()),
646
+ }],
647
+ request_body: None,
648
+ responses: {
649
+ let mut m = std::collections::BTreeMap::new();
650
+ m.insert(
651
+ "200".to_string(),
652
+ justapi_core::openapi::Response {
653
+ description: "ok".to_string(),
654
+ content: None,
655
+ },
656
+ );
657
+ m
658
+ },
659
+ deprecated: None,
660
+ },
661
+ )
662
+ .operation(
663
+ Method::POST,
664
+ "/users",
665
+ Operation {
666
+ summary: Some("Create user".to_string()),
667
+ description: None,
668
+ operation_id: Some("createUser".to_string()),
669
+ tags: vec![],
670
+ parameters: vec![],
671
+ request_body: Some(RequestBody {
672
+ description: None,
673
+ content: {
674
+ let mut c = std::collections::BTreeMap::new();
675
+ c.insert(
676
+ "application/json".to_string(),
677
+ justapi_core::openapi::MediaType {
678
+ schema: Some(body_schema),
679
+ },
680
+ );
681
+ c
682
+ },
683
+ required: true,
684
+ }),
685
+ responses: {
686
+ let mut m = std::collections::BTreeMap::new();
687
+ m.insert(
688
+ "200".to_string(),
689
+ justapi_core::openapi::Response {
690
+ description: "ok".to_string(),
691
+ content: None,
692
+ },
693
+ );
694
+ m
695
+ },
696
+ deprecated: None,
697
+ },
698
+ )
699
+ .build()
700
+ }
701
+
702
+ #[test]
703
+ fn test_sanitize_ident() {
704
+ assert_eq!(sanitize_ident("list_users"), "listUsers");
705
+ assert_eq!(sanitize_ident("getUser"), "getUser");
706
+ assert_eq!(sanitize_ident("foo-bar"), "fooBar");
707
+ assert_eq!(sanitize_ident("foo bar.baz"), "fooBarBaz");
708
+ assert_eq!(sanitize_ident("123"), "call");
709
+ assert_eq!(sanitize_ident("X-Trace"), "XTrace");
710
+ }
711
+
712
+ #[test]
713
+ fn test_python_generation() {
714
+ let doc = sample_doc();
715
+ let py = generate_client(&doc, &ClientLanguage::Python);
716
+ assert!(py.contains("class Client:"));
717
+ assert!(py.contains("def listUsers(self, limit: int | None = None"));
718
+ assert!(py.contains("def getUser(self, id: int"));
719
+ assert!(py.contains("def createUser(self, body: Any | None = None"));
720
+ // Header param sanitized, original name preserved in dict key.
721
+ assert!(py.contains("XTrace: str | None = None"));
722
+ assert!(py.contains("\"X-Trace\": XTrace"));
723
+ // Path substitution uses sanitized arg name.
724
+ assert!(py.contains("path = \"/users/{id}\".format(id=id)"));
725
+ }
726
+
727
+ #[test]
728
+ fn test_typescript_generation() {
729
+ let doc = sample_doc();
730
+ let ts = generate_client(&doc, &ClientLanguage::Typescript);
731
+ assert!(ts.contains("export class Client"));
732
+ assert!(ts.contains("getUser(id: number"));
733
+ assert!(ts.contains("path.replace(`{id}`, String(id))"));
734
+ assert!(ts.contains("XTrace?: string"));
735
+ assert!(ts.contains("\"X-Trace\": XTrace"));
736
+ }
737
+
738
+ #[test]
739
+ fn test_language_parsing() {
740
+ assert_eq!(
741
+ "python".parse::<ClientLanguage>().unwrap(),
742
+ ClientLanguage::Python
743
+ );
744
+ assert_eq!(
745
+ "ts".parse::<ClientLanguage>().unwrap(),
746
+ ClientLanguage::Typescript
747
+ );
748
+ assert!("cobol".parse::<ClientLanguage>().is_err());
749
+ }
750
+
751
+ #[test]
752
+ fn test_roundtrip_deserialize() {
753
+ let json = serde_json::to_string(&sample_doc()).unwrap();
754
+ let back: OpenApiDocument = serde_json::from_str(&json).unwrap();
755
+ assert_eq!(back.info.title, "Demo API");
756
+ assert!(back.paths.get("/users").unwrap().get.is_some());
757
+ }
758
+
759
+ #[test]
760
+ fn test_query_operation_generated() {
761
+ use justapi_core::openapi::{MediaType, Operation, RequestBody};
762
+ let doc = OpenApiBuilder::new("Q", "1.0.0")
763
+ .operation(
764
+ justapi_core::query_method(),
765
+ "/search",
766
+ Operation {
767
+ summary: Some("Search".to_string()),
768
+ description: None,
769
+ operation_id: Some("searchItems".to_string()),
770
+ tags: vec![],
771
+ parameters: vec![],
772
+ request_body: Some(RequestBody {
773
+ description: None,
774
+ content: {
775
+ let mut c = std::collections::BTreeMap::new();
776
+ c.insert(
777
+ "application/x-www-form-urlencoded".to_string(),
778
+ MediaType {
779
+ schema: Some(Schema::string()),
780
+ },
781
+ );
782
+ c
783
+ },
784
+ required: true,
785
+ }),
786
+ responses: {
787
+ let mut m = std::collections::BTreeMap::new();
788
+ m.insert(
789
+ "200".to_string(),
790
+ justapi_core::openapi::Response {
791
+ description: "ok".to_string(),
792
+ content: None,
793
+ },
794
+ );
795
+ m
796
+ },
797
+ deprecated: None,
798
+ },
799
+ )
800
+ .build();
801
+ let py = generate_client(&doc, &ClientLanguage::Python);
802
+ assert!(py.contains("def searchItems(self, body: Any | None = None"));
803
+ let ts = generate_client(&doc, &ClientLanguage::Typescript);
804
+ assert!(ts.contains("searchItems(body?: unknown"));
805
+ }
806
+}
0
807
▼crates/justapi-cli/src/main.rs
+875-0
1
+use std::net::SocketAddr;
2
+use std::path::PathBuf;
3
+use std::time::Duration;
4
+
5
+use clap::{Parser, Subcommand};
6
+use justapi_core::dx::{DiagLevel, Diagnostic};
7
+use justapi_core::error_catalog;
8
+use tokio::sync::mpsc;
9
+mod gen_client;
10
+mod profile;
11
+mod watcher;
12
+
13
+#[derive(Parser)]
14
+#[command(
15
+ name = "justapi",
16
+ about = "JustAPI Runtime — Python application server"
17
+)]
18
+struct Cli {
19
+ #[command(subcommand)]
20
+ command: Commands,
21
+}
22
+
23
+#[derive(Subcommand)]
24
+enum Commands {
25
+ /// Start the JustAPI HTTP server
26
+ Serve {
27
+ /// Address to bind to
28
+ #[arg(short, long, default_value = "127.0.0.1:8080")]
29
+ addr: SocketAddr,
30
+
31
+ /// Serve static files from this directory
32
+ #[arg(long)]
33
+ static_dir: Option<String>,
34
+
35
+ /// Enable response compression (gzip)
36
+ #[cfg(feature = "compression")]
37
+ #[arg(long)]
38
+ compress: bool,
39
+
40
+ /// Enable hot reload (watches for file changes and restarts)
41
+ #[arg(short, long)]
42
+ reload: bool,
43
+
44
+ /// Directory to watch for file changes (defaults to cwd)
45
+ #[arg(long)]
46
+ watch_dir: Option<PathBuf>,
47
+
48
+ /// Additional file extensions to watch (e.g. "html" "css")
49
+ #[arg(long)]
50
+ watch_ext: Vec<String>,
51
+
52
+ /// Timeout in seconds to drain in-flight requests before restart
53
+ #[arg(long, default_value = "5")]
54
+ drain_timeout: u64,
55
+
56
+ /// TLS certificate file (PEM)
57
+ #[cfg(feature = "tls")]
58
+ #[arg(long)]
59
+ tls_cert: Option<String>,
60
+
61
+ /// TLS private key file (PEM)
62
+ #[cfg(feature = "tls")]
63
+ #[arg(long)]
64
+ tls_key: Option<String>,
65
+
66
+ /// Serve a model via the OpenAI-compatible endpoints
67
+ /// (`/v1/chat/completions`, `/v1/completions`, `/v1/embeddings`,
68
+ /// `/v1/models`). Requires the `inference` feature. The model id is
69
+ /// registered as a weight-free `MockModel` (use `--features real` +
70
+ /// `--model-path` for real weights).
71
+ #[cfg(feature = "inference")]
72
+ #[arg(long)]
73
+ model: Option<String>,
74
+
75
+ /// GPU device ordinal for `--model` (e.g. `0`). Use `cpu` for the
76
+ /// CPU backend (default). Requires the `inference` feature.
77
+ #[cfg(feature = "inference")]
78
+ #[arg(long, default_value = "cpu")]
79
+ gpu: String,
80
+
81
+ /// Enable the scheduler-backed serving path (continuous batching +
82
+ /// RadixAttention prefix cache). Without this flag the engine
83
+ /// serves requests directly (no admission control or prefix reuse).
84
+ /// Requires `--model`. Requires the `inference` feature.
85
+ #[cfg(feature = "inference")]
86
+ #[arg(long)]
87
+ scheduled: bool,
88
+
89
+ /// Number of KV-cache blocks for the scheduler's block pool.
90
+ /// Only meaningful with `--scheduled`.
91
+ #[cfg(feature = "inference")]
92
+ #[arg(long, default_value = "1024")]
93
+ pool_blocks: usize,
94
+
95
+ /// Maximum number of concurrent sequences for the scheduler.
96
+ /// Only meaningful with `--scheduled`.
97
+ #[cfg(feature = "inference")]
98
+ #[arg(long, default_value = "256")]
99
+ max_seqs: usize,
100
+
101
+ /// Number of draft tokens per speculative-decode step (`gamma`).
102
+ /// Requires `--model`. `>0` enables draft-target speculative decoding
103
+ /// (a draft model proposes `gamma` tokens, the target verifies them).
104
+ /// Requires the `inference` feature.
105
+ #[cfg(feature = "inference")]
106
+ #[arg(long, default_value_t = 0)]
107
+ gamma: usize,
108
+
109
+ /// Tree branch factor for tree-based speculative decoding (Medusa/
110
+ /// EAGLE-style). Requires `--model` and `--gamma`. `>0` enables tree
111
+ /// speculation: the draft proposes `branch` candidates at each of the
112
+ /// `gamma` positions, and the target verifies the longest matching
113
+ /// path (higher acceptance than single-path draft-target).
114
+ /// Requires the `inference` feature.
115
+ #[cfg(feature = "inference")]
116
+ #[arg(long, default_value_t = 0)]
117
+ branch: usize,
118
+ },
119
+
120
+ /// Database migration commands
121
+ #[command(subcommand)]
122
+ Db(DbCommands),
123
+
124
+ /// List all registered routes
125
+ Routes {
126
+ /// File to load the OpenAPI spec from (default: built-in routes)
127
+ #[arg(long)]
128
+ spec_file: Option<PathBuf>,
129
+ },
130
+
131
+ /// Run diagnostic checks on the environment and configuration
132
+ Doctor {
133
+ /// Path to a JustAPI config file to validate
134
+ #[arg(short, long)]
135
+ config: Option<PathBuf>,
136
+ },
137
+
138
+ /// Generate OpenAPI spec file, client code, etc.
139
+ #[command(subcommand)]
140
+ Gen(GenCommands),
141
+
142
+ /// Validate all routes without starting the server
143
+ Check {
144
+ /// Path to a routes config or app file
145
+ #[arg(short, long)]
146
+ file: Option<PathBuf>,
147
+ },
148
+
149
+ /// Project scaffolding — create a new JustAPI project
150
+ New {
151
+ /// Name of the project
152
+ name: String,
153
+ /// Output directory (defaults to project name)
154
+ #[arg(short, long)]
155
+ output: Option<PathBuf>,
156
+ },
157
+
158
+ /// Profile a running JustAPI server (built-in load test)
159
+ Profile {
160
+ /// Target server address
161
+ #[arg(short, long, default_value = "127.0.0.1:8080")]
162
+ addr: String,
163
+
164
+ /// Profiling duration in seconds
165
+ #[arg(short, long, default_value = "10")]
166
+ duration: u64,
167
+
168
+ /// Number of concurrent connections
169
+ #[arg(short, long, default_value = "50")]
170
+ connections: u64,
171
+
172
+ /// Output file for the report
173
+ #[arg(short, long, default_value = "profile_report.txt")]
174
+ output: PathBuf,
175
+ },
176
+}
177
+
178
+#[derive(Subcommand)]
179
+enum DbCommands {
180
+ /// Run pending migrations
181
+ Migrate {
182
+ /// Database connection URL
183
+ #[arg(short, long)]
184
+ url: String,
185
+
186
+ /// Path to migrations directory
187
+ #[arg(short, long, default_value = "migrations")]
188
+ dir: PathBuf,
189
+ },
190
+
191
+ /// Roll back the most recent migration
192
+ Rollback {
193
+ /// Database connection URL
194
+ #[arg(short, long)]
195
+ url: String,
196
+
197
+ /// Path to migrations directory
198
+ #[arg(short, long, default_value = "migrations")]
199
+ dir: PathBuf,
200
+ },
201
+
202
+ /// Initialize the migrations tracking table
203
+ Init {
204
+ /// Database connection URL
205
+ #[arg(short, long)]
206
+ url: String,
207
+
208
+ /// Path to migrations directory
209
+ #[arg(short, long, default_value = "migrations")]
210
+ dir: PathBuf,
211
+ },
212
+}
213
+
214
+#[derive(Subcommand)]
215
+enum GenCommands {
216
+ /// Generate and save OpenAPI 3.1 spec to a file
217
+ Openapi {
218
+ /// Output file path
219
+ #[arg(short, long, default_value = "openapi.json")]
220
+ output: PathBuf,
221
+ },
222
+ /// Generate a typed client from an OpenAPI spec
223
+ Client {
224
+ /// Path to OpenAPI spec file
225
+ #[arg(short, long)]
226
+ spec: PathBuf,
227
+ /// Output directory for generated client
228
+ #[arg(short, long, default_value = "client")]
229
+ output: PathBuf,
230
+ /// Target language: python (default) or typescript
231
+ #[arg(long, default_value = "python")]
232
+ language: String,
233
+ },
234
+}
235
+
236
+#[tokio::main]
237
+async fn main() {
238
+ if let Err(err) = run().await {
239
+ emit_rich_error(&err);
240
+ std::process::exit(1);
241
+ }
242
+}
243
+
244
+/// Inspect an [`anyhow::Error`] and emit a rich diagnostic when possible,
245
+/// falling back to a plain error message otherwise.
246
+fn emit_rich_error(err: &anyhow::Error) {
247
+ let msg = format!("{err:#}");
248
+
249
+ // Try to match well-known error patterns and emit rich diagnostics.
250
+ let diagnostic = if msg.contains("address") && msg.contains("parse") {
251
+ Some(error_catalog::E001_INVALID_ADDRESS.with_context(msg.clone()))
252
+ } else if msg.contains("Address already in use")
253
+ || msg.contains("address already in use")
254
+ || msg.contains("AddrInUse")
255
+ {
256
+ Some(error_catalog::E002_PORT_IN_USE.with_context(msg.clone()))
257
+ } else if (msg.contains("certificate") || msg.contains("tls") || msg.contains("TLS"))
258
+ && (msg.contains("not found") || msg.contains("No such file"))
259
+ {
260
+ Some(error_catalog::E003_TLS_CERT_NOT_FOUND.with_context(msg.clone()))
261
+ } else if msg.contains("database") && msg.contains("URL") {
262
+ Some(error_catalog::E004_DB_URL_INVALID.with_context(msg.clone()))
263
+ } else {
264
+ None
265
+ };
266
+
267
+ if let Some(d) = diagnostic {
268
+ d.emit();
269
+ } else {
270
+ // Fallback: still emit a styled error, just without a catalogue code.
271
+ let d = Diagnostic::new(DiagLevel::Error, msg);
272
+ d.emit();
273
+ }
274
+}
275
+
276
+async fn run() -> anyhow::Result<()> {
277
+ justapi_core::tracing_setup::init_tracing()?;
278
+
279
+ let cli = Cli::parse();
280
+ match cli.command {
281
+ Commands::Serve {
282
+ addr,
283
+ static_dir,
284
+ #[cfg(feature = "compression")]
285
+ compress,
286
+ reload,
287
+ watch_dir,
288
+ watch_ext,
289
+ drain_timeout,
290
+ #[cfg(feature = "tls")]
291
+ tls_cert,
292
+ #[cfg(feature = "tls")]
293
+ tls_key,
294
+ #[cfg(feature = "inference")]
295
+ model,
296
+ #[cfg(feature = "inference")]
297
+ gpu,
298
+ #[cfg(feature = "inference")]
299
+ scheduled,
300
+ #[cfg(feature = "inference")]
301
+ pool_blocks,
302
+ #[cfg(feature = "inference")]
303
+ max_seqs,
304
+ #[cfg(feature = "inference")]
305
+ gamma,
306
+ #[cfg(feature = "inference")]
307
+ branch,
308
+ } => {
309
+ // Set up the file watcher once (if --reload), it sends on each
310
+ // detected change via mpsc so we can restart multiple times.
311
+ let mut reload_rx = if reload {
312
+ let dir = watch_dir
313
+ .clone()
314
+ .unwrap_or_else(|| std::env::current_dir().expect("cannot read cwd"));
315
+ tracing::info!(dir = %dir.display(), "Watching for file changes");
316
+ Some(watcher::spawn_file_watcher(&dir, &watch_ext)?)
317
+ } else {
318
+ None
319
+ };
320
+
321
+ let drain_duration = Duration::from_secs(drain_timeout);
322
+
323
+ // Build the inference engine if `--model` was supplied (inference
324
+ // feature). The engine serves the OpenAI-compatible endpoints with a
325
+ // GIL-free generation thread; the scheduler provides continuous
326
+ // batching + paged KV cache (real weight loading is gated on `real`).
327
+ #[cfg(feature = "inference")]
328
+ let engine: Option<std::sync::Arc<justapi_inference::Engine>> = if let Some(id) =
329
+ model.as_ref()
330
+ {
331
+ use justapi_inference::EngineDevice;
332
+ let device = if gpu.eq_ignore_ascii_case("cpu") {
333
+ EngineDevice::Cpu
334
+ } else {
335
+ EngineDevice::Cuda(gpu.parse().unwrap_or(0))
336
+ };
337
+ let eng = std::sync::Arc::new(
338
+ justapi_inference::Engine::new(device)
339
+ .map_err(|e| anyhow::anyhow!("failed to start inference engine: {e}"))?,
340
+ );
341
+ eng.register_mock(id);
342
+
343
+ // Wire speculative decoding if requested.
344
+ if gamma > 0 || branch > 0 {
345
+ let target = eng.get(id).unwrap();
346
+ let draft_id = format!("{id}-draft");
347
+ let draft = eng.register_mock(&draft_id);
348
+ if branch > 0 {
349
+ let g = gamma.max(1);
350
+ eng.register_tree_speculative(id, target, draft, g, branch, 0);
351
+ tracing::info!(
352
+ model = %id,
353
+ device = %gpu,
354
+ gamma = g,
355
+ branch = branch,
356
+ "Serving model via OpenAI-compatible endpoints (tree speculative decoding)"
357
+ );
358
+ } else {
359
+ eng.register_speculative(id, target, draft, gamma, 0);
360
+ tracing::info!(
361
+ model = %id,
362
+ device = %gpu,
363
+ gamma = gamma,
364
+ "Serving model via OpenAI-compatible endpoints (draft-target speculative decoding)"
365
+ );
366
+ }
367
+ } else {
368
+ tracing::info!(model = %id, device = %gpu, "Serving model via OpenAI-compatible endpoints");
369
+ }
370
+ Some(eng)
371
+ } else {
372
+ None
373
+ };
374
+
375
+ // Build the scheduler engine if `--scheduled` is also set.
376
+ #[cfg(feature = "inference")]
377
+ let scheduler_engine: Option<
378
+ std::sync::Arc<justapi_inference::SchedulerEngine>,
379
+ > = if scheduled && engine.is_some() {
380
+ use justapi_inference::{KvBlockPool, Scheduler, SchedulerConfig, SchedulerEngine};
381
+ let eng = engine.as_ref().unwrap().clone();
382
+ let pool = KvBlockPool::new(pool_blocks.max(64));
383
+ let config = SchedulerConfig {
384
+ max_num_seqs: max_seqs.max(1),
385
+ ..Default::default()
386
+ };
387
+ let scheduler =
388
+ std::sync::Arc::new(std::sync::Mutex::new(Scheduler::new(config, pool)));
389
+ let se = std::sync::Arc::new(SchedulerEngine::new(eng, scheduler));
390
+ tracing::info!(
391
+ pool_blocks = pool_blocks,
392
+ max_seqs = max_seqs,
393
+ "Scheduler-enabled serving path"
394
+ );
395
+ Some(se)
396
+ } else {
397
+ None
398
+ };
399
+
400
+ loop {
401
+ let token = tokio_util::sync::CancellationToken::new();
402
+
403
+ // Wire Ctrl+C to graceful shutdown.
404
+ let sig_token = token.clone();
405
+ let is_ctrl_c = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
406
+ let ctrl_c_flag = is_ctrl_c.clone();
407
+ tokio::spawn(async move {
408
+ tokio::signal::ctrl_c().await.ok();
409
+ tracing::info!("Shutdown signal received");
410
+ ctrl_c_flag.store(true, std::sync::atomic::Ordering::SeqCst);
411
+ sig_token.cancel();
412
+ });
413
+
414
+ // If reload is enabled, wait for the watcher to signal a
415
+ // file change and then cancel the server token.
416
+ if let Some(ref mut rx) = reload_rx {
417
+ let cancel = token.clone();
418
+ // We need to take the receiver into the task, but we also
419
+ // need it back afterwards. Use a oneshot to shuttle it.
420
+ let (return_tx, return_rx) =
421
+ tokio::sync::oneshot::channel::<mpsc::Receiver<()>>();
422
+ let mut taken_rx = None;
423
+ std::mem::swap(rx, taken_rx.get_or_insert(mpsc::channel(1).1));
424
+ let mut inner_rx = taken_rx.unwrap();
425
+
426
+ tokio::spawn(async move {
427
+ // Wait for either a file change or the token being
428
+ // cancelled (e.g. Ctrl+C).
429
+ tokio::select! {
430
+ Some(()) = inner_rx.recv() => {
431
+ cancel.cancel();
432
+ }
433
+ _ = cancel.cancelled() => {}
434
+ }
435
+ // Return the receiver so the outer loop can reuse it.
436
+ return_tx.send(inner_rx).ok();
437
+ });
438
+
439
+ // We'll collect the receiver back after `server.run()` completes.
440
+ // Stash the return channel for later.
441
+ // (We use a scope trick: store it in the reload_rx option.)
442
+ // Actually we need the return_rx after the server stops,
443
+ // so we hold it outside and await it after run().
444
+ // Re-structure: keep return_rx in a variable.
445
+ let mut server = justapi_core::Server::new(addr).with_shutdown(token.clone());
446
+
447
+ if let Some(dir) = static_dir.clone() {
448
+ server = server.with_static_dir(dir);
449
+ }
450
+
451
+ #[cfg(feature = "compression")]
452
+ if compress {
453
+ server = server.add_compression();
454
+ }
455
+
456
+ #[cfg(feature = "inference")]
457
+ if let Some(ref se) = scheduler_engine {
458
+ server = server.with_openai_scheduled(se.clone());
459
+ } else if let Some(ref eng) = engine {
460
+ server = server.with_openai(eng.clone());
461
+ }
462
+
463
+ #[cfg(feature = "tls")]
464
+ if let (Some(cert), Some(key)) = (tls_cert.clone(), tls_key.clone()) {
465
+ let config = justapi_core::server::TlsConfig {
466
+ cert_path: cert,
467
+ key_path: key,
468
+ };
469
+ let result = server.with_tls(config).run().await;
470
+ justapi_core::tracing_setup::shutdown_tracing();
471
+ return result;
472
+ }
473
+
474
+ let result = server.run().await;
475
+
476
+ // Recover the watcher receiver for the next iteration.
477
+ if let Ok(recovered) = return_rx.await {
478
+ *rx = recovered;
479
+ }
480
+
481
+ // If the server stopped because of Ctrl+C, exit cleanly.
482
+ if is_ctrl_c.load(std::sync::atomic::Ordering::SeqCst) {
483
+ justapi_core::tracing_setup::shutdown_tracing();
484
+ return result;
485
+ }
486
+
487
+ // File-change triggered reload: drain in-flight requests.
488
+ tracing::info!(
489
+ drain_secs = drain_timeout,
490
+ "Server stopped, draining in-flight requests"
491
+ );
492
+ tokio::time::sleep(drain_duration).await;
493
+ tracing::info!("Restarting server...");
494
+ continue;
495
+ }
496
+
497
+ // Non-reload path: run server once and exit.
498
+ let mut server = justapi_core::Server::new(addr).with_shutdown(token);
499
+
500
+ if let Some(dir) = static_dir.clone() {
501
+ server = server.with_static_dir(dir);
502
+ }
503
+
504
+ #[cfg(feature = "compression")]
505
+ if compress {
506
+ server = server.add_compression();
507
+ }
508
+
509
+ #[cfg(feature = "inference")]
510
+ if let Some(ref se) = scheduler_engine {
511
+ server = server.with_openai_scheduled(se.clone());
512
+ } else if let Some(ref eng) = engine {
513
+ server = server.with_openai(eng.clone());
514
+ }
515
+
516
+ #[cfg(feature = "tls")]
517
+ if let (Some(cert), Some(key)) = (tls_cert.clone(), tls_key.clone()) {
518
+ let config = justapi_core::server::TlsConfig {
519
+ cert_path: cert,
520
+ key_path: key,
521
+ };
522
+ let result = server.with_tls(config).run().await;
523
+ justapi_core::tracing_setup::shutdown_tracing();
524
+ return result;
525
+ }
526
+
527
+ let result = server.run().await;
528
+ justapi_core::tracing_setup::shutdown_tracing();
529
+ return result;
530
+ }
531
+ }
532
+ Commands::Db(db_cmd) => match db_cmd {
533
+ DbCommands::Migrate { url, dir } => {
534
+ if !dir.exists() {
535
+ anyhow::bail!("Migrations directory does not exist: {:?}", dir);
536
+ }
537
+ let config = justapi_core::db::DatabaseConfig {
538
+ url,
539
+ max_connections: 1,
540
+ kind: None,
541
+ };
542
+ let mut mgr = justapi_core::db::PoolManager::new();
543
+ let pool = mgr.init("", config).await?;
544
+
545
+ let mut migrator = justapi_core::db::Migrator::new();
546
+ migrator.discover(&dir).map_err(|e| anyhow::anyhow!(e))?;
547
+ let applied = migrator.run(&pool).await.map_err(|e| anyhow::anyhow!(e))?;
548
+ tracing::info!("Applied {} migration(s)", applied.len());
549
+ for m in &applied {
550
+ tracing::info!(" v{}: {}", m.version, m.name);
551
+ }
552
+ Ok(())
553
+ }
554
+ DbCommands::Rollback { url, dir } => {
555
+ if !dir.exists() {
556
+ anyhow::bail!("Migrations directory does not exist: {:?}", dir);
557
+ }
558
+ let config = justapi_core::db::DatabaseConfig {
559
+ url,
560
+ max_connections: 1,
561
+ kind: None,
562
+ };
563
+ let mut mgr = justapi_core::db::PoolManager::new();
564
+ let pool = mgr.init("", config).await?;
565
+
566
+ let mut migrator = justapi_core::db::Migrator::new();
567
+ migrator.discover(&dir).map_err(|e| anyhow::anyhow!(e))?;
568
+ migrator
569
+ .rollback_one(&pool)
570
+ .await
571
+ .map_err(|e| anyhow::anyhow!(e))?;
572
+ tracing::info!("Rolled back most recent migration");
573
+ Ok(())
574
+ }
575
+ DbCommands::Init { url, dir } => {
576
+ let config = justapi_core::db::DatabaseConfig {
577
+ url,
578
+ max_connections: 1,
579
+ kind: None,
580
+ };
581
+ let mut mgr = justapi_core::db::PoolManager::new();
582
+ let pool = mgr.init("", config).await?;
583
+
584
+ let mut migrator = justapi_core::db::Migrator::new();
585
+ if dir.exists() {
586
+ migrator.discover(&dir).map_err(|e| anyhow::anyhow!(e))?;
587
+ }
588
+ migrator
589
+ .ensure_tracking_table(&pool)
590
+ .await
591
+ .map_err(|e| anyhow::anyhow!(e))?;
592
+ tracing::info!("Migrations tracking table initialized");
593
+ Ok(())
594
+ }
595
+ },
596
+ Commands::Routes { spec_file } => {
597
+ if let Some(path) = spec_file {
598
+ let spec = std::fs::read_to_string(&path)
599
+ .map_err(|e| anyhow::anyhow!("Cannot read {}: {}", path.display(), e))?;
600
+ let doc: serde_json::Value = serde_json::from_str(&spec)
601
+ .map_err(|e| anyhow::anyhow!("Invalid OpenAPI spec: {}", e))?;
602
+ let paths = doc
603
+ .get("paths")
604
+ .and_then(|p| p.as_object())
605
+ .map(|p| p.len())
606
+ .unwrap_or(0);
607
+ println!("Loaded OpenAPI spec with {} path(s)", paths);
608
+ if let Some(paths_obj) = doc.get("paths").and_then(|p| p.as_object()) {
609
+ for (path, item) in paths_obj {
610
+ if let Some(obj) = item.as_object() {
611
+ for method in
612
+ ["get", "post", "put", "delete", "patch", "head", "options"]
613
+ {
614
+ if obj.contains_key(method) {
615
+ println!(" {:>6} {}", method.to_uppercase(), path);
616
+ }
617
+ }
618
+ }
619
+ }
620
+ }
621
+ } else {
622
+ let router = justapi_core::Server::new("127.0.0.1:0".parse().unwrap());
623
+ let _ = router;
624
+ println!("Use --spec-file to load an OpenAPI spec for route listing.");
625
+ println!("For built-in routes, access /openapi.json on a running server.");
626
+ }
627
+ Ok(())
628
+ }
629
+ Commands::Doctor { config } => {
630
+ Diagnostic::new(DiagLevel::Info, "JustAPI Doctor").emit();
631
+ println!("==================");
632
+
633
+ // Check Rust version
634
+ Diagnostic::new(DiagLevel::Hint, "Rust toolchain available").emit();
635
+
636
+ // Check Python availability
637
+ let py_ok = std::process::Command::new("python3")
638
+ .arg("--version")
639
+ .output()
640
+ .is_ok();
641
+ if py_ok {
642
+ Diagnostic::new(DiagLevel::Hint, "Python 3 detected").emit();
643
+ } else {
644
+ error_catalog::E006_PYTHON_NOT_FOUND
645
+ .into_diagnostic()
646
+ .emit();
647
+ }
648
+
649
+ // Check config file
650
+ if let Some(path) = config {
651
+ if path.exists() {
652
+ Diagnostic::new(
653
+ DiagLevel::Hint,
654
+ format!("Config file found: {}", path.display()),
655
+ )
656
+ .emit();
657
+ } else {
658
+ error_catalog::E008_INVALID_CONFIG
659
+ .with_context(format!("{}", path.display()))
660
+ .emit();
661
+ }
662
+ } else {
663
+ Diagnostic::new(
664
+ DiagLevel::Info,
665
+ "No config file specified (use --config to check one)",
666
+ )
667
+ .emit();
668
+ }
669
+
670
+ // Check migrations directory
671
+ let migrations_dir = PathBuf::from("migrations");
672
+ if migrations_dir.exists() {
673
+ Diagnostic::new(DiagLevel::Hint, "Migrations directory exists").emit();
674
+ } else {
675
+ Diagnostic::new(
676
+ DiagLevel::Info,
677
+ "No migrations directory (create one with `justapi db init`)",
678
+ )
679
+ .emit();
680
+ }
681
+
682
+ // Check .env
683
+ let env_path = PathBuf::from(".env");
684
+ if env_path.exists() {
685
+ Diagnostic::new(DiagLevel::Hint, ".env file found").emit();
686
+ } else {
687
+ Diagnostic::new(
688
+ DiagLevel::Warning,
689
+ "No .env file (recommended for configuration)",
690
+ )
691
+ .emit();
692
+ }
693
+
694
+ // Check Dockerfile
695
+ let dockerfile = PathBuf::from("Dockerfile");
696
+ if dockerfile.exists() {
697
+ Diagnostic::new(DiagLevel::Hint, "Dockerfile found").emit();
698
+ } else {
699
+ Diagnostic::new(
700
+ DiagLevel::Info,
701
+ "No Dockerfile (use `justapi new` to create one)",
702
+ )
703
+ .emit();
704
+ }
705
+
706
+ println!();
707
+ Diagnostic::new(DiagLevel::Info, "Doctor check complete.").emit();
708
+ Ok(())
709
+ }
710
+ Commands::Gen(gen_cmd) => match gen_cmd {
711
+ GenCommands::Openapi { output } => {
712
+ let doc = justapi_core::openapi::OpenApiBuilder::new("JustAPI", "0.1.0")
713
+ .description("Generated by justapi gen openapi")
714
+ .server("http://localhost:8080", Some("Local development"))
715
+ .tag("default", Some("Default endpoints"))
716
+ .build();
717
+ let json = serde_json::to_string_pretty(&doc)?;
718
+ std::fs::write(&output, &json)?;
719
+ println!("OpenAPI spec written to {}", output.display());
720
+ Ok(())
721
+ }
722
+ GenCommands::Client {
723
+ spec,
724
+ output,
725
+ language,
726
+ } => {
727
+ let lang: gen_client::ClientLanguage = language.parse()?;
728
+ let json = std::fs::read_to_string(&spec)
729
+ .map_err(|e| anyhow::anyhow!("Cannot read spec {}: {}", spec.display(), e))?;
730
+ let doc: justapi_core::openapi::OpenApiDocument = serde_json::from_str(&json)
731
+ .map_err(|e| anyhow::anyhow!("Invalid OpenAPI spec: {}", e))?;
732
+ let out_path = gen_client::write_client(&doc, &lang, &output)?;
733
+ println!(
734
+ "{} client written to {}",
735
+ match lang {
736
+ gen_client::ClientLanguage::Python => "Python",
737
+ gen_client::ClientLanguage::Typescript => "TypeScript",
738
+ },
739
+ out_path.display()
740
+ );
741
+ Ok(())
742
+ }
743
+ },
744
+ Commands::Check { file } => {
745
+ if let Some(path) = file {
746
+ if path.exists() {
747
+ println!("[✓] File found: {}", path.display());
748
+ } else {
749
+ anyhow::bail!("File not found: {}", path.display());
750
+ }
751
+ }
752
+ println!("[✓] Route validation passed (no routes to validate)");
753
+ Ok(())
754
+ }
755
+ Commands::New { name, output } => {
756
+ let project_dir = output.unwrap_or_else(|| PathBuf::from(&name));
757
+ if project_dir.exists() {
758
+ anyhow::bail!("Directory '{}' already exists", project_dir.display());
759
+ }
760
+ std::fs::create_dir_all(&project_dir)?;
761
+ std::fs::create_dir_all(project_dir.join("app"))?;
762
+ std::fs::create_dir_all(project_dir.join("migrations"))?;
763
+ std::fs::create_dir_all(project_dir.join("static"))?;
764
+
765
+ // Create main.py
766
+ let main_py = format!(
767
+ r#""""JustAPI application — {}
768
+"""
769
+
770
+from justapi import JustAPIApp
771
+
772
+app = JustAPIApp()
773
+
774
+
775
+@app.get("/")
776
+async def root():
777
+ return {{"message": "Hello from {}!"}}
778
+
779
+
780
+@app.get("/health")
781
+async def health():
782
+ return {{"status": "healthy"}}
783
+
784
+
785
+if __name__ == "__main__":
786
+ app.run()
787
+"#,
788
+ name, name
789
+ );
790
+ std::fs::write(project_dir.join("app").join("__init__.py"), "")?;
791
+ std::fs::write(project_dir.join("app").join("main.py"), &main_py)?;
792
+
793
+ // Create .env
794
+ let env = format!(
795
+ r#"# {} configuration
796
+HOST=127.0.0.1
797
+PORT=8080
798
+# DATABASE_URL=postgres://user:pass@localhost:5432/{}
799
+# SECRET_KEY=change-me
800
+"#,
801
+ name, name
802
+ );
803
+ std::fs::write(project_dir.join(".env"), &env)?;
804
+
805
+ // Create requirements.txt
806
+ std::fs::write(
807
+ project_dir.join("requirements.txt"),
808
+ "# Add your Python dependencies here\n# justapi is pre-installed with the runtime\n",
809
+ )?;
810
+
811
+ // Create README.md
812
+ let readme = format!(
813
+ r#"# {}
814
+
815
+A JustAPI application.
816
+
817
+## Quick start
818
+
819
+```bash
820
+justapi serve
821
+```
822
+
823
+## Development
824
+
825
+```bash
826
+# Install dependencies
827
+pip install -r requirements.txt
828
+
829
+# Run migrations
830
+justapi db migrate --url postgres://localhost/{}
831
+
832
+# Start server with hot reload
833
+justapi serve --reload
834
+```
835
+"#,
836
+ name, name
837
+ );
838
+ std::fs::write(project_dir.join("README.md"), &readme)?;
839
+
840
+ // Create .gitignore
841
+ std::fs::write(
842
+ project_dir.join(".gitignore"),
843
+ "*.pyc\n__pycache__/\n.env\n*.egg-info/\ndist/\nbuild/\n",
844
+ )?;
845
+
846
+ println!("✅ Created new JustAPI project '{}'", name);
847
+ println!();
848
+ println!(" cd {}", project_dir.display());
849
+ println!(" justapi serve");
850
+
851
+ Ok(())
852
+ }
853
+ Commands::Profile {
854
+ addr,
855
+ duration,
856
+ connections,
857
+ output,
858
+ } => {
859
+ println!("🔬 JustAPI Profiler");
860
+ println!(" Target: {addr}");
861
+ println!(" Duration: {duration}s");
862
+ println!(" Connections: {connections}");
863
+ println!(" Output: {}\n", output.display());
864
+
865
+ let report = profile::run_profile(&addr, duration, connections).await?;
866
+ let text = profile::format_report(&report);
867
+ print!("{text}");
868
+
869
+ profile::save_report(&text, &output)?;
870
+ println!("\n Report saved to {}", output.display());
871
+
872
+ Ok(())
873
+ }
874
+ }
875
+}
0
876
▼crates/justapi-cli/src/profile.rs
+338-0
1
+use std::collections::HashMap;
2
+use std::io::Write;
3
+use std::path::Path;
4
+use std::time::{Duration, Instant};
5
+
6
+use tokio::io::{AsyncReadExt, AsyncWriteExt};
7
+use tokio::net::TcpStream;
8
+
9
+/// Result of a single HTTP request.
10
+struct RequestResult {
11
+ latency: Duration,
12
+ status: u16,
13
+}
14
+
15
+/// Aggregated profiling report.
16
+pub struct ProfileReport {
17
+ pub total_requests: u64,
18
+ pub duration: Duration,
19
+ pub latencies: Vec<Duration>,
20
+ pub status_counts: HashMap<u16, u64>,
21
+}
22
+
23
+/// Unicode block characters for histogram rendering, from thinnest to full block.
24
+const BLOCKS: [char; 8] = ['▏', '▎', '▍', '▌', '▋', '▊', '▉', '█'];
25
+
26
+/// Run the profiler against `addr` with the given concurrency and duration.
27
+pub async fn run_profile(
28
+ addr: &str,
29
+ duration_secs: u64,
30
+ connections: u64,
31
+) -> anyhow::Result<ProfileReport> {
32
+ let deadline = Duration::from_secs(duration_secs);
33
+
34
+ let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<RequestResult>();
35
+
36
+ for _ in 0..connections {
37
+ let addr = addr.to_string();
38
+ let tx = tx.clone();
39
+ tokio::spawn(async move {
40
+ let start = Instant::now();
41
+ while start.elapsed() < deadline {
42
+ if let Some(result) = send_request(&addr).await {
43
+ if tx.send(result).is_err() {
44
+ break;
45
+ }
46
+ }
47
+ }
48
+ });
49
+ }
50
+ // Drop the original sender so `rx` will close once all tasks finish.
51
+ drop(tx);
52
+
53
+ let mut latencies = Vec::new();
54
+ let mut status_counts: HashMap<u16, u64> = HashMap::new();
55
+
56
+ while let Some(result) = rx.recv().await {
57
+ latencies.push(result.latency);
58
+ *status_counts.entry(result.status).or_insert(0) += 1;
59
+ }
60
+
61
+ Ok(ProfileReport {
62
+ total_requests: latencies.len() as u64,
63
+ duration: deadline,
64
+ latencies,
65
+ status_counts,
66
+ })
67
+}
68
+
69
+/// Send a single raw HTTP/1.1 GET / request and return timing + status code.
70
+async fn send_request(addr: &str) -> Option<RequestResult> {
71
+ let start = Instant::now();
72
+ let mut stream = TcpStream::connect(addr).await.ok()?;
73
+
74
+ let request = format!(
75
+ "GET / HTTP/1.1\r\nHost: {}\r\nConnection: close\r\n\r\n",
76
+ addr
77
+ );
78
+ stream.write_all(request.as_bytes()).await.ok()?;
79
+
80
+ let mut buf = vec![0u8; 4096];
81
+ let n = stream.read(&mut buf).await.ok()?;
82
+ let latency = start.elapsed();
83
+
84
+ if n == 0 {
85
+ return None;
86
+ }
87
+
88
+ let response = String::from_utf8_lossy(&buf[..n]);
89
+ let status = parse_status_code(&response).unwrap_or(0);
90
+
91
+ Some(RequestResult { latency, status })
92
+}
93
+
94
+/// Parse the HTTP status code from the first line of the response (e.g. "HTTP/1.1 200 OK").
95
+fn parse_status_code(response: &str) -> Option<u16> {
96
+ let first_line = response.lines().next()?;
97
+ let mut parts = first_line.split_whitespace();
98
+ parts.next()?; // skip "HTTP/1.1"
99
+ parts.next()?.parse().ok()
100
+}
101
+
102
+/// Compute the value at the given percentile from a **sorted** slice of durations.
103
+///
104
+/// Uses the nearest-rank method: the index is `ceil(p/100 * N) - 1`, clamped to
105
+/// valid bounds. Returns `Duration::ZERO` for an empty slice.
106
+pub fn percentile(sorted: &[Duration], p: f64) -> Duration {
107
+ if sorted.is_empty() {
108
+ return Duration::ZERO;
109
+ }
110
+ let rank = (p / 100.0 * sorted.len() as f64).ceil() as usize;
111
+ let idx = rank.saturating_sub(1).min(sorted.len() - 1);
112
+ sorted[idx]
113
+}
114
+
115
+/// Render a text-based latency histogram.
116
+///
117
+/// Divides the latency range into `bucket_count` equal-width buckets and draws
118
+/// a horizontal bar for each bucket using Unicode block characters.
119
+///
120
+/// The bars are scaled so that the bucket with the most samples fills
121
+/// `max_bar_width` character cells.
122
+pub fn render_histogram(
123
+ latencies: &[Duration],
124
+ bucket_count: usize,
125
+ max_bar_width: usize,
126
+) -> String {
127
+ if latencies.is_empty() || bucket_count == 0 {
128
+ return String::from(" (no data)\n");
129
+ }
130
+
131
+ let min_us = latencies.iter().map(|d| d.as_micros()).min().unwrap_or(0);
132
+ let max_us = latencies.iter().map(|d| d.as_micros()).max().unwrap_or(0);
133
+
134
+ // Avoid division by zero when all latencies are identical.
135
+ let range = if max_us == min_us { 1 } else { max_us - min_us };
136
+ let bucket_width = (range as f64 / bucket_count as f64).ceil() as u128;
137
+ let bucket_width = bucket_width.max(1);
138
+
139
+ let mut buckets = vec![0u64; bucket_count];
140
+ for d in latencies {
141
+ let us = d.as_micros();
142
+ let idx = ((us - min_us) / bucket_width) as usize;
143
+ let idx = idx.min(bucket_count - 1);
144
+ buckets[idx] += 1;
145
+ }
146
+
147
+ let max_count = *buckets.iter().max().unwrap_or(&1);
148
+ let max_count = max_count.max(1);
149
+
150
+ let mut out = String::new();
151
+ for (i, &count) in buckets.iter().enumerate() {
152
+ let lo = min_us + (i as u128) * bucket_width;
153
+ let hi = lo + bucket_width;
154
+ let bar = build_bar(count, max_count, max_bar_width);
155
+ out.push_str(&format!(
156
+ " {:>8.1}ms - {:>8.1}ms │{} ({count})\n",
157
+ lo as f64 / 1000.0,
158
+ hi as f64 / 1000.0,
159
+ bar,
160
+ ));
161
+ }
162
+ out
163
+}
164
+
165
+/// Build a single bar string of the histogram.
166
+///
167
+/// Uses full-block characters for the integer part and a fractional sub-block
168
+/// for the remainder.
169
+fn build_bar(count: u64, max_count: u64, max_width: usize) -> String {
170
+ let fraction = count as f64 / max_count as f64;
171
+ let total_eighths = (fraction * max_width as f64 * 8.0).round() as usize;
172
+ let full_blocks = total_eighths / 8;
173
+ let remainder = total_eighths % 8;
174
+
175
+ let mut bar = String::with_capacity(full_blocks + 1);
176
+ for _ in 0..full_blocks {
177
+ bar.push(BLOCKS[7]);
178
+ }
179
+ if remainder > 0 {
180
+ bar.push(BLOCKS[remainder - 1]);
181
+ }
182
+ bar
183
+}
184
+
185
+/// Format the profile report as a human-readable string.
186
+pub fn format_report(report: &ProfileReport) -> String {
187
+ let mut out = String::new();
188
+ out.push_str("╔══════════════════════════════════════════════════╗\n");
189
+ out.push_str("║ JustAPI Profile Report ║\n");
190
+ out.push_str("╚══════════════════════════════════════════════════╝\n\n");
191
+
192
+ let rps = if report.duration.as_secs_f64() > 0.0 {
193
+ report.total_requests as f64 / report.duration.as_secs_f64()
194
+ } else {
195
+ 0.0
196
+ };
197
+
198
+ out.push_str(&format!(
199
+ " Duration: {:.1}s\n",
200
+ report.duration.as_secs_f64()
201
+ ));
202
+ out.push_str(&format!(" Total requests: {}\n", report.total_requests));
203
+ out.push_str(&format!(" Requests/sec: {rps:.1}\n\n"));
204
+
205
+ if !report.latencies.is_empty() {
206
+ let mut sorted = report.latencies.clone();
207
+ sorted.sort();
208
+
209
+ let p50 = percentile(&sorted, 50.0);
210
+ let p95 = percentile(&sorted, 95.0);
211
+ let p99 = percentile(&sorted, 99.0);
212
+
213
+ out.push_str(" Latency percentiles:\n");
214
+ out.push_str(&format!(" p50: {:.3}ms\n", p50.as_secs_f64() * 1000.0));
215
+ out.push_str(&format!(" p95: {:.3}ms\n", p95.as_secs_f64() * 1000.0));
216
+ out.push_str(&format!(
217
+ " p99: {:.3}ms\n\n",
218
+ p99.as_secs_f64() * 1000.0
219
+ ));
220
+
221
+ out.push_str(" Latency histogram:\n");
222
+ out.push_str(&render_histogram(&sorted, 10, 30));
223
+ out.push('\n');
224
+ } else {
225
+ out.push_str(" (no successful requests)\n\n");
226
+ }
227
+
228
+ out.push_str(" Status code distribution:\n");
229
+ let mut codes: Vec<_> = report.status_counts.iter().collect();
230
+ codes.sort_by_key(|(code, _)| *code);
231
+ for (&code, &count) in &codes {
232
+ out.push_str(&format!(" HTTP {code}: {count}\n"));
233
+ }
234
+
235
+ out
236
+}
237
+
238
+/// Write the report to a file.
239
+pub fn save_report(report_text: &str, path: &Path) -> anyhow::Result<()> {
240
+ let mut f = std::fs::File::create(path)?;
241
+ f.write_all(report_text.as_bytes())?;
242
+ Ok(())
243
+}
244
+
245
+#[cfg(test)]
246
+mod tests {
247
+ use super::*;
248
+
249
+ #[test]
250
+ fn test_percentile_basic() {
251
+ // 10 sorted values: 1ms, 2ms, ... 10ms
252
+ let latencies: Vec<Duration> = (1..=10).map(Duration::from_millis).collect();
253
+
254
+ let p50 = percentile(&latencies, 50.0);
255
+ assert_eq!(p50, Duration::from_millis(5));
256
+
257
+ let p95 = percentile(&latencies, 95.0);
258
+ // ceil(0.95 * 10) = 10, idx = 9 → 10ms
259
+ assert_eq!(p95, Duration::from_millis(10));
260
+
261
+ let p99 = percentile(&latencies, 99.0);
262
+ assert_eq!(p99, Duration::from_millis(10));
263
+
264
+ // Edge: p0 should give the first element
265
+ let p0 = percentile(&latencies, 0.0);
266
+ assert_eq!(p0, Duration::from_millis(1));
267
+
268
+ // Edge: p100 should give the last element
269
+ let p100 = percentile(&latencies, 100.0);
270
+ assert_eq!(p100, Duration::from_millis(10));
271
+ }
272
+
273
+ #[test]
274
+ fn test_percentile_empty() {
275
+ let empty: Vec<Duration> = Vec::new();
276
+ assert_eq!(percentile(&empty, 50.0), Duration::ZERO);
277
+ }
278
+
279
+ #[test]
280
+ fn test_percentile_single() {
281
+ let single = vec![Duration::from_millis(42)];
282
+ assert_eq!(percentile(&single, 0.0), Duration::from_millis(42));
283
+ assert_eq!(percentile(&single, 50.0), Duration::from_millis(42));
284
+ assert_eq!(percentile(&single, 100.0), Duration::from_millis(42));
285
+ }
286
+
287
+ #[test]
288
+ fn test_histogram_renders_nonempty() {
289
+ let latencies: Vec<Duration> = (1..=100).map(Duration::from_millis).collect();
290
+ let hist = render_histogram(&latencies, 5, 20);
291
+
292
+ // Should contain multiple lines
293
+ let lines: Vec<&str> = hist.lines().collect();
294
+ assert_eq!(lines.len(), 5, "expected 5 histogram buckets");
295
+
296
+ // Each line should contain the box-drawing separator
297
+ for line in &lines {
298
+ assert!(line.contains('│'), "each line should contain │ separator");
299
+ }
300
+
301
+ // Each line should have a count in parentheses
302
+ for line in &lines {
303
+ assert!(line.contains('('), "each line should contain count");
304
+ }
305
+ }
306
+
307
+ #[test]
308
+ fn test_histogram_empty() {
309
+ let hist = render_histogram(&[], 5, 20);
310
+ assert_eq!(hist, " (no data)\n");
311
+ }
312
+
313
+ #[test]
314
+ fn test_histogram_all_same() {
315
+ // All latencies identical — should not panic from division by zero
316
+ let latencies = vec![Duration::from_millis(5); 50];
317
+ let hist = render_histogram(&latencies, 5, 20);
318
+ assert!(!hist.is_empty());
319
+ // The first bucket should have all 50 samples
320
+ assert!(hist.contains("(50)"));
321
+ }
322
+
323
+ #[test]
324
+ fn test_build_bar() {
325
+ // Full bar
326
+ let bar = build_bar(100, 100, 10);
327
+ assert_eq!(bar.chars().count(), 10);
328
+
329
+ // Empty bar
330
+ let bar = build_bar(0, 100, 10);
331
+ assert!(bar.is_empty());
332
+
333
+ // Half bar
334
+ let bar = build_bar(50, 100, 10);
335
+ // 50/100 * 10 * 8 = 40 eighths → 5 full blocks
336
+ assert_eq!(bar.chars().count(), 5);
337
+ }
338
+}
0
339
▼crates/justapi-cli/src/watcher.rs
+187-0
1
+use std::path::Path;
2
+use std::time::Duration;
3
+
4
+use notify::RecursiveMode;
5
+use notify_debouncer_mini::{new_debouncer, DebounceEventResult};
6
+use tokio::sync::mpsc;
7
+
8
+/// Default file extensions to watch for changes.
9
+const DEFAULT_WATCH_EXTENSIONS: &[&str] = &["py", "toml", "yaml", "yml", "json", "env"];
10
+
11
+/// Directories that are always excluded from file watching.
12
+const EXCLUDED_DIRS: &[&str] = &[
13
+ "__pycache__",
14
+ ".git",
15
+ "target",
16
+ "node_modules",
17
+ ".venv",
18
+ ".mypy_cache",
19
+ ".pytest_cache",
20
+ ".ruff_cache",
21
+ "__pypackages__",
22
+];
23
+
24
+/// Returns `true` if the given path should be ignored because it resides
25
+/// inside one of the [`EXCLUDED_DIRS`].
26
+pub fn should_ignore_path(path: &Path) -> bool {
27
+ path.components().any(|c| {
28
+ c.as_os_str()
29
+ .to_str()
30
+ .map(|s| EXCLUDED_DIRS.contains(&s))
31
+ .unwrap_or(false)
32
+ })
33
+}
34
+
35
+/// Returns `true` if `path` has one of the given extensions.
36
+fn has_watched_extension(path: &Path, extensions: &[String]) -> bool {
37
+ path.extension()
38
+ .and_then(|e| e.to_str())
39
+ .map(|ext| extensions.iter().any(|w| w == ext))
40
+ .unwrap_or(false)
41
+}
42
+
43
+/// Spawn a background file watcher that monitors `watch_dir` for changes.
44
+///
45
+/// When a relevant file change is detected the watcher sends `()` on the
46
+/// returned [`mpsc::Receiver`]. The caller can `recv()` to learn about
47
+/// each reload trigger.
48
+///
49
+/// # Arguments
50
+///
51
+/// * `watch_dir` — directory to watch recursively.
52
+/// * `extra_extensions` — additional file extensions to watch beyond the
53
+/// defaults. Pass an empty slice to use only the built-in set.
54
+pub fn spawn_file_watcher(
55
+ watch_dir: &Path,
56
+ extra_extensions: &[String],
57
+) -> Result<mpsc::Receiver<()>, anyhow::Error> {
58
+ // Build the full extension list.
59
+ let mut extensions: Vec<String> = DEFAULT_WATCH_EXTENSIONS
60
+ .iter()
61
+ .map(|s| (*s).to_owned())
62
+ .collect();
63
+ for ext in extra_extensions {
64
+ let ext = ext.strip_prefix('.').unwrap_or(ext).to_owned();
65
+ if !extensions.contains(&ext) {
66
+ extensions.push(ext);
67
+ }
68
+ }
69
+
70
+ // Channel between the notify callback (sync) and the async task.
71
+ let (notify_tx, mut notify_rx) = tokio::sync::mpsc::channel::<String>(16);
72
+
73
+ // Channel returned to the caller.
74
+ let (reload_tx, reload_rx) = mpsc::channel::<()>(1);
75
+
76
+ let exts = extensions.clone();
77
+ let mut debouncer = new_debouncer(
78
+ Duration::from_millis(300),
79
+ move |result: DebounceEventResult| {
80
+ if let Ok(events) = result {
81
+ for event in &events {
82
+ if should_ignore_path(&event.path) {
83
+ continue;
84
+ }
85
+ if has_watched_extension(&event.path, &exts) {
86
+ let display = event
87
+ .path
88
+ .file_name()
89
+ .and_then(|n| n.to_str())
90
+ .unwrap_or("<unknown>")
91
+ .to_owned();
92
+ notify_tx.try_send(display).ok();
93
+ break;
94
+ }
95
+ }
96
+ }
97
+ },
98
+ )?;
99
+
100
+ debouncer
101
+ .watcher()
102
+ .watch(watch_dir, RecursiveMode::Recursive)?;
103
+
104
+ tokio::spawn(async move {
105
+ // Keep the debouncer alive as long as the task runs.
106
+ let _debouncer = debouncer;
107
+ while let Some(changed_file) = notify_rx.recv().await {
108
+ tracing::info!(file = %changed_file, "File change detected, triggering reload");
109
+ if reload_tx.send(()).await.is_err() {
110
+ // Receiver dropped — stop watching.
111
+ break;
112
+ }
113
+ }
114
+ });
115
+
116
+ Ok(reload_rx)
117
+}
118
+
119
+#[cfg(test)]
120
+mod tests {
121
+ use super::*;
122
+ use std::path::PathBuf;
123
+
124
+ #[test]
125
+ fn test_should_ignore_excluded_directories() {
126
+ // Paths inside excluded directories must be ignored.
127
+ let cases = vec![
128
+ ("project/__pycache__/module.pyc", true),
129
+ ("project/.git/config", true),
130
+ ("project/target/debug/binary", true),
131
+ ("project/node_modules/pkg/index.js", true),
132
+ ("project/.venv/lib/python3.14/site.py", true),
133
+ (".mypy_cache/report.json", true),
134
+ (".pytest_cache/v/cache/lastfailed", true),
135
+ (".ruff_cache/0.1.0/foo.json", true),
136
+ ("__pypackages__/3.14/lib/pkg.py", true),
137
+ ];
138
+ for (path, expected) in cases {
139
+ assert_eq!(
140
+ should_ignore_path(Path::new(path)),
141
+ expected,
142
+ "should_ignore_path({path:?}) should be {expected}"
143
+ );
144
+ }
145
+ }
146
+
147
+ #[test]
148
+ fn test_should_not_ignore_normal_directories() {
149
+ // Regular project files must NOT be ignored.
150
+ let cases = vec![
151
+ "app/main.py",
152
+ "config.toml",
153
+ "src/lib.rs",
154
+ "app/routes/users.py",
155
+ "tests/test_api.py",
156
+ "static/style.css",
157
+ ];
158
+ for path in cases {
159
+ assert!(
160
+ !should_ignore_path(Path::new(path)),
161
+ "should_ignore_path({path:?}) should be false"
162
+ );
163
+ }
164
+ }
165
+
166
+ #[test]
167
+ fn test_has_watched_extension_matches() {
168
+ let exts: Vec<String> = vec!["py", "toml", "json"]
169
+ .into_iter()
170
+ .map(String::from)
171
+ .collect();
172
+ assert!(has_watched_extension(&PathBuf::from("app/main.py"), &exts));
173
+ assert!(has_watched_extension(&PathBuf::from("config.toml"), &exts));
174
+ assert!(has_watched_extension(&PathBuf::from("data.json"), &exts));
175
+ }
176
+
177
+ #[test]
178
+ fn test_has_watched_extension_rejects_non_matching() {
179
+ let exts: Vec<String> = vec!["py", "toml", "json"]
180
+ .into_iter()
181
+ .map(String::from)
182
+ .collect();
183
+ assert!(!has_watched_extension(&PathBuf::from("style.css"), &exts));
184
+ assert!(!has_watched_extension(&PathBuf::from("binary"), &exts));
185
+ assert!(!has_watched_extension(&PathBuf::from("readme.md"), &exts));
186
+ }
187
+}
0
188