1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
// SPDX-License-Identifier: MIT OR Apache-2.0
//! 2026-09-26: The last `met serve` flags, from `--request-timeout` to
//! `--lora-stageable-disk`: the request deadline, profiling, FP8 KV calibration,
//! weight loading, the dashboard, vision and video input, the listener, auth and
//! LoRA adapters, then the chat-surface flags (`serve_args_chat_surface.rs`).
//! `ServeSchedulingArgs` flattens this struct last.
//!
//! Owner: server CLI.
//! Invariants: the `///` text on the struct's fields is the `--help` output and
//! carries no date. clap lists the flags in declaration order, so a flattened
//! struct's fields sit where its `#[command(flatten)]` field is: last.
use clap::Args;
use metrale_model_layers::layers::{DenseQuantization, DsaQuantization};
use super::super::flag_values::{
DenseQuantizationArg, DsaQuantizationArg, IndexerCacheDtypeArg, MoeExpertLayoutArg,
PrefillTailModeArg,
};
use super::{DEFAULT_REQUEST_TIMEOUT_SECS, parse_lora_adapter_spec, parse_lora_stageable_spec};
use metrale_gpu_runtime::prefill_tail_mode::PrefillTailMode;
/// 2026-09-30: `--activation-quantization`, parsed by the config crate's one grammar.
fn parse_activation_quantization(
s: &str,
) -> Result<metrale_config::ActivationQuantization, String> {
metrale_config::ActivationQuantization::parse(s).map_err(|e| format!("{e:#}"))
}
// 2026-09-26: `ServeArgs` reaches these fields through `Deref`, via
// `ServeSchedulingArgs`.
#[derive(Args, Debug, Clone, PartialEq)]
pub struct ServeServiceArgs {
/// Server-side deadline for a single request, in seconds. A request
/// that exceeds it is cut and the response is reported with
/// `finish_reason="timeout"` (never "length") plus a WARN log naming
/// the slot, elapsed time and tokens emitted. `0` disables the deadline
/// entirely. Overridable per request via the OpenAI `timeout` field.
#[arg(long, default_value_t = DEFAULT_REQUEST_TIMEOUT_SECS)]
pub request_timeout: u32,
/// Enable per-kernel profiling: sync + time each operation within layers.
/// Disables CUDA graphs on the decode path for accurate per-op timing.
#[arg(long, default_value_t = false)]
pub profile: bool,
/// Number of warmup tokens for online FP8 KV cache scale calibration.
/// Tracks max |K| and max |V| over the first N observed tokens, across
/// requests, then computes per-tensor scales as amax*headroom/448 (mapping
/// the observed range to FP8 E4M3 [-448, 448]).
/// The window's own KV is held in BF16 and requantized at the freeze, so
/// the write scale always equals the read scale; N is clamped to 4096 to
/// bound that staging. 1 = freeze on the first observe. 0 = disabled.
/// Only applies when --kv-cache-dtype is fp8.
/// Precedence (highest wins): this flag → MODEL.toml
/// `[behavior].fp8_kv_calibration_tokens` → 0. An explicit value always
/// wins: passing 0 force-disables calibration even on a model whose
/// MODEL.toml enables it.
#[arg(long)]
pub fp8_kv_calibration_tokens: Option<usize>,
/// Headroom multiplier applied to the accumulated absmax when the online
/// FP8 KV scale freezes. The calibration window only sees the first
/// `--fp8-kv-calibration-tokens` tokens, so the frozen scale covers
/// headroom× their max, and later tokens whose magnitude grows don't clip.
/// Must be ≥ 1.0 (below 1.0 guarantees clipping; rejected at startup).
#[arg(long, default_value_t = 2.0)]
pub fp8_kv_headroom: f32,
/// Not implemented: rejected at startup. Nothing reads this: no prompt is
/// tokenized and no prefill runs. Send one throwaway request after startup
/// instead.
#[arg(long)]
pub warmup_prompt: Option<std::path::PathBuf>,
/// Enable adaptive sampling: per token, the logits' entropy and the output's
/// zone (tool call, thinking, grammar) set an effective temperature, and a
/// greedy gate can switch the token to greedy decoding.
/// Off by default.
#[arg(long, default_value_t = false)]
pub adaptive_sampling: bool,
/// Disable the fast weight loader and use the mmap loader instead. The fast
/// loader (O_DIRECT + pipelined reader/copier) is on by default; this flag is
/// an escape hatch for filesystems that misbehave with O_DIRECT or for A/B
/// debugging.
/// Setting `METRALE_FAST_LOAD=0` has the same effect.
#[arg(long, default_value_t = false)]
pub no_fast_load: bool,
/// Disable the interactive TUI dashboard even on a TTY, keeping the plain
/// log stream. The TUI also auto-disables when stdout/stdin is not an
/// interactive terminal (pipes, `docker -d`, CI) or `METRALE_NO_TUI=1`.
#[arg(long, default_value_t = false)]
pub no_tui: bool,
/// Ask the fast loader to prefetch each buffered shard before per-tensor
/// reads. Also enabled by `METRALE_FAST_LOAD_PREFETCH_SHARDS=1` (or `true`).
#[arg(long, default_value_t = false)]
pub fast_load_prefetch_shards: bool,
/// Activation precision of each decode projection, by projection family and by the
/// number of rows in the launch (batch rows, MTP verify rows included).
///
/// One format (`bf16`, `fp8`, `nvfp4`, `declared`) runs that activation precision
/// through one fixed-order kernel family at every row count, so a sequence's logits do
/// not depend on how many other sequences share its step. `declared` is each layer's
/// checkpoint-declared input-activation format. `adaptive` is the routing before this
/// flag: each projection picks its kernel, and with it the activation precision, by row
/// count. A ladder maps row ranges to formats, `1=bf16;2-8=nvfp4;9-=fp8`, and a family
/// can be given its own ladder after a comma: `declared,lm_head:bf16`. Families: `gdn`,
/// `attn`, `ffn`, `moe`, `lm_head`. Recipes pin `adaptive`. On a model without a
/// row-invariant path for a family, that family runs `adaptive` and the load log says so.
#[arg(long, value_name = "SPEC", default_value = "declared", value_parser = parse_activation_quantization)]
pub activation_quantization: metrale_config::ActivationQuantization,
/// Precision of the checkpoint's 16-bit (unquantized) dense projections.
///
/// `declared` (the default) serves them at the checkpoint's width. `fp8` quantizes them at
/// load to FP8 E4M3 with one scale per output channel and decodes them W8A8 with dynamic
/// per-token FP8 activations: BELOW the checkpoint's declared precision, so the model's
/// answers change; the boot log and benchmark records say so. GLM-5.3 (`glm5_next`) only:
/// its attention (KDA, DSA latent, indexer) and shared-expert projections; pair it with
/// `--lm-head-dtype fp8` for the head. The FP8 copies sit beside the 16-bit weights. Any
/// other model refuses it. Unmeasured.
///
/// `w4a16` is FURTHER below declared than `fp8`, so the answers change more: the KDA q/k/v,
/// f_a, b, g_a and o projections and the shared expert's gate/up/down are quantized at load
/// to NVFP4 (E2M1 weights, one E4M3 scale per 16, one F32 scale per tensor) and decoded
/// W4A16 with 16-bit activations; the 16-bit copies of those are freed. The rest of `fp8`'s
/// set (DSA latent and indexer projections, KDA f_b and g_b) runs as under `fp8`. At TP>1
/// it splits the KDA heads in pairs and the shared expert in 256-wide units. GLM-5.3 only.
/// Unmeasured.
#[arg(long, value_enum, default_value_t = DenseQuantizationArg(DenseQuantization::Declared))]
pub dense_quantization: DenseQuantizationArg,
/// Precision of GLM-5.3's DSA query-down (q_a), absorbed query and absorbed output
/// projections, independent of `--dense-quantization`.
///
/// `declared` (the default) serves them at the checkpoint's BF16 (the absorbed products are
/// formed at load in FP32 and kept BF16), whatever `--dense-quantization` is. `w8a8`
/// quantizes them at load to FP8 E4M3 with one scale per output channel and decodes them
/// with per-token FP8 activations, what the reference engine executes there, and what
/// `--dense-quantization fp8`/`w4a16` served them as before this flag existed. `w4a16`
/// quantizes them to NVFP4 and decodes them W4A16 (the former `METRALE_GLM_DSA_W4A16=1`),
/// FURTHER below declared. Below `declared` the answers change; the boot log says so.
/// GLM-5.3 (`glm5_next`) only; any other model refuses a value below `declared`.
#[arg(long, value_enum, default_value_t = DsaQuantizationArg(DsaQuantization::Declared))]
pub dsa_quantization: DsaQuantizationArg,
/// How a prefill pass that captures its SSM state for the prefix cache at the tail split
/// point (the last block boundary below the prompt's end) runs the rows after that point.
///
/// `merge` (the default) runs them in the pass's ordinary sub-chunks and splits only the
/// KDA conv and recurrence at the point to take the captured state: the captured state is
/// the same, a cold request is deterministic, and a warm request's replayed tail rows may
/// differ from the cold pass's in low bits (disclosure PREFILL-TAILMERGE-1). `split` runs
/// them as a separate segment, so they carry the bits of the pass a warm request that
/// restores the captured state runs, at the cost of every layer's weights read a second
/// time for up to one block of rows; it is the mode beside METRALE_GLM_SP_PIPELINE,
/// METRALE_GLM_PREFILL_PIECE_ROWS and the KDA chunk-scan levers, which `merge` refuses.
/// GLM-5.3 (`glm5_next`) only; other models run `split`.
#[arg(long, value_enum, default_value_t = PrefillTailModeArg(PrefillTailMode::default()))]
pub prefill_tail_mode: PrefillTailModeArg,
/// How a routed MoE's experts are laid out over the ranks.
///
/// `ep` (the default) gives each expert whole to one of the `--ep-size` ranks, so a token's
/// routed experts land unevenly on the ranks and the MoE all-reduce waits for the busiest.
/// `tp` splits every expert's intermediate width over the `--tp-size` ranks the way the
/// shared expert is split, so every routed slot is local on every rank and each rank reads
/// the same bytes per token; each rank holds about the same expert bytes as under `ep`. It
/// needs `--ep-size 1` and `--tp-size` of 2 or more. GLM-5.3 (`glm5_next`) only; any other
/// model refuses it. Unmeasured.
#[arg(long, value_enum, default_value_t = MoeExpertLayoutArg(metrale_config::MoeExpertLayout::Ep))]
pub moe_expert_layout: MoeExpertLayoutArg,
/// How a sparse-attention indexer stores its rows in the paged KV blocks.
///
/// `bf16` (the default) keeps a bf16 key row and a bf16 gate row for every token. `fp8`
/// keeps, for each complete pool of `index_kpool` tokens, the pool's compressed key rotated
/// by a 128-point Walsh-Hadamard transform and quantized to FP8 E4M3 with one power-of-two
/// scale, plus a bf16 ring holding the tokens of the pool still being filled (sized from the
/// verify draft count). That shrinks each indexer layer's share of a KV block, so the same
/// pool holds more tokens; pool scores are computed from the dequantized keys. GLM-5.3
/// (`glm5_next`) with the paged indexer cache only; any other model refuses it.
#[arg(long, value_enum, default_value_t = IndexerCacheDtypeArg(metrale_config::IndexerCacheDtype::Bf16))]
pub indexer_cache_dtype: IndexerCacheDtypeArg,
/// Vision input area bound in pixels, applied before patching. A non-zero
/// value overrides the checkpoint in both directions: it may raise the bound
/// as well as lower it.
///
/// 0 (the default): `METRALE_VISION_MAX_PIXELS` when set, else the
/// checkpoint's own bound, read from `preprocessor_config.json` or
/// `processor_config.json` (`size.longest_edge`, or `max_pixels`; despite the
/// name both are pixel counts). When the checkpoint declares none, the
/// preprocessor clamps the long side to 1280px.
///
/// Raising it raises the vision token count per image, which is charged
/// against the context budget.
#[arg(long, default_value_t = 0)]
pub vision_max_pixels: usize,
/// Fetch `image_url` parts that carry an http(s) URL, instead of
/// rejecting them.
///
/// Off by default: enabling it lets anyone who can send the server a chat
/// request make it issue outbound HTTP to addresses of their choosing, a
/// server-side request forgery primitive. With the flag off, a URL is refused
/// with a 400 naming this flag; clients that cannot be changed send a base64
/// `data:` URI instead.
///
/// Switched on, the fetch is still bounded: loopback/private/link-local
/// destinations are refused (including across redirects), the body is
/// capped while being read rather than by trusting `Content-Length`, the
/// response must declare an image content type, and each HTTP request is
/// time-limited.
#[arg(long, default_value_t = false)]
pub vision_allow_remote_images: bool,
/// Cap, in MiB, on a single fetched remote image. Enforced against bytes
/// actually read, so a remote understating its `Content-Length` cannot
/// exceed it. No effect unless `--vision-allow-remote-images` is set.
#[arg(long, default_value_t = 20)]
pub vision_remote_image_max_mb: usize,
/// Time limit, in seconds, for each HTTP request made to fetch a remote image;
/// a redirect hop is a separate request. No effect unless
/// `--vision-allow-remote-images` is set.
#[arg(long, default_value_t = 10)]
pub vision_remote_image_timeout_s: u64,
/// Also permit remote images on loopback, private and link-local
/// addresses: the destination address check is skipped.
///
/// A second grant on top of `--vision-allow-remote-images`. Only set this
/// where the image host is internal: it re-opens the path to link-local
/// cloud instance metadata (169.254.169.254).
#[arg(long, default_value_t = false)]
pub vision_remote_image_allow_private: bool,
/// Decode video content parts with ffmpeg.
///
/// Video support needs ffmpeg on the host for every container except
/// animated GIF. GIF is decoded in-process in pure Rust; MP4/MOV,
/// WebM/Matroska and AVI (H.264, H.265, VP9 and AV1) are decoded by running
/// `ffmpeg`. Without this flag a video part is refused with an error naming
/// the flag; with it but no usable ffmpeg, the server warns at startup and
/// each video request fails.
///
/// Install it with `apt install ffmpeg` (Debian/Ubuntu) or
/// `dnf install ffmpeg` (Fedora/RHEL).
///
/// Off by default because it makes the server execute another program
/// per video request. The decode is bounded: no shell, no temp file,
/// capped frames, capped output, capped wall clock.
#[arg(long, default_value_t = false)]
pub video_allow_ffmpeg: bool,
/// Path to the ffmpeg binary. A bare name is resolved on PATH; an
/// absolute path is used as given, so a deployment can pin a known build
/// instead of inheriting whatever PATH offers. No effect unless
/// `--video-allow-ffmpeg` is set.
#[arg(long, default_value = "ffmpeg")]
pub video_ffmpeg_path: String,
/// Frames per second to sample a video at. ffmpeg does the sampling
/// (`-vf fps=`) rather than decoding every frame. Raising this multiplies the
/// vision tokens a clip costs.
#[arg(long, default_value_t = 2.0)]
pub video_fps: f32,
/// Hard cap on frames taken from one video. At the default 2 fps this is
/// 384 seconds of clip.
#[arg(long, default_value_t = 768)]
pub video_max_frames: usize,
/// Wall-clock budget, in seconds, for decoding one video; the ffmpeg child is
/// killed when it expires.
#[arg(long, default_value_t = 120)]
pub video_decode_timeout_s: u64,
/// Address to bind the HTTP listener to. Defaults to `127.0.0.1` so a
/// fresh install is reachable only from the local machine; pass
/// `0.0.0.0` to expose on all interfaces (the server logs a warning
/// when it does; the CORS policy allows any origin, so the API is then
/// reachable to anything on the LAN).
#[arg(long, alias = "host", default_value = "127.0.0.1", value_name = "ADDR")]
pub bind: String,
/// Require an `Authorization: Bearer <token>` header on `/v1/*`,
/// `/tokenize`, and `/detokenize`. The token must match one loaded
/// via `--auth-tokens-file` or `--auth-token`; without either, startup is
/// refused. Other paths, such as `/health`, `/health/live` and `/metrics`,
/// stay open.
///
/// Off by default. Turn it on whenever the server is reachable from anywhere
/// other than `localhost` (for example with `--bind 0.0.0.0`).
#[arg(long, default_value_t = false)]
pub require_auth: bool,
/// Path to a file containing valid bearer tokens, one per line. Blank
/// lines and lines starting with `#` are ignored. Permissions should
/// be `0600`. The file is read once at startup: restart the server to
/// rotate keys.
#[arg(long, value_name = "PATH", conflicts_with = "auth_token")]
pub auth_tokens_file: Option<std::path::PathBuf>,
/// A single inline bearer token. Convenient for quick starts; not
/// recommended for production because the token is visible in
/// `ps`/`/proc/<pid>/cmdline`. Use `--auth-tokens-file` instead.
#[arg(long, value_name = "TOKEN", conflicts_with = "auth_tokens_file")]
pub auth_token: Option<String>,
/// LoRA adapter to serve, as NAME=PATH_OR_HF_ID (e.g.
/// `holo-sft=/data/adapters/holo-sft` or `holo-sft=org/holo-31-08b-lora`).
/// Repeatable: each adapter loads into its own pool slot at startup and is
/// advertised by GET /v1/models; a request selects one by name in its
/// `adapter` field.
#[arg(long, value_name = "NAME=PATH_OR_HF_ID", value_parser = parse_lora_adapter_spec)]
pub lora_adapter: Vec<(String, String)>,
/// NLLB/M2M-100 only: source-language token for translation (e.g.
/// `eng_Latn`). Required when serving an `m2m_100`/`nllb` checkpoint;
/// ignored otherwise.
#[arg(long)]
pub src_lang: Option<String>,
/// NLLB/M2M-100 only: target-language token (e.g. `fra_Latn`, `gvn_Latn`).
/// Required when serving an `m2m_100`/`nllb` checkpoint; ignored otherwise.
#[arg(long)]
pub tgt_lang: Option<String>,
/// Maximum LoRA adapter rank. The adapter slot pool is sized for this rank at
/// startup; an adapter whose `r` exceeds it is rejected at load.
///
/// Unset (default): the largest `r` among the `--lora-adapter` adapters, so
/// a small adapter is not padded to a larger rank; 64 when a stageable adapter
/// (`--lora-stageable`, `--lora-stageable-disk`) is configured, since its rank
/// is not known at startup.
///
/// Set it explicitly only to reserve headroom for a larger adapter staged in
/// later: the pool layout is fixed at startup, so a stage-in above the pool's
/// rank is rejected.
#[arg(long)]
pub max_lora_rank: Option<usize>,
/// Maximum number of LoRA adapter slots in the pool. Slots beyond the
/// startup-resident adapters are cache headroom for demand promotion
/// (`--lora-stageable`, `--lora-stageable-disk`).
#[arg(long, default_value_t = 8)]
pub max_loras: usize,
/// A stageable (promotable-but-not-resident) LoRA adapter, as
/// `NAME=PEER_STAGE_ID=CONFIG_DIR` (repeatable). NAME is what a request's
/// `adapter` field asks for; PEER_STAGE_ID is the adapter's id on the
/// `$METRALE_LORA_PEER` weight peer; CONFIG_DIR is a local dir with
/// `adapter_config.json` (the peft scaling is read from there at startup). A
/// request naming a stageable adapter triggers an on-miss RDMA promotion into
/// a cache pool slot. Requires `$METRALE_LORA_PEER` and at least one
/// `--lora-adapter`.
#[arg(long, value_name = "NAME=PEER_ID=DIR", value_parser = parse_lora_stageable_spec)]
pub lora_stageable: Vec<(String, String, String)>,
/// A disk-stageable (promotable-but-not-resident, no peer) LoRA adapter, as
/// `NAME=PATH_OR_HF_ID` (repeatable). A request naming NAME triggers an
/// on-miss disk load into a cache pool slot. Needs `METRALE_LORA_ROTATE=1`
/// (which arms runtime adapter rotation, so the disk swap can re-point a
/// cache slot) unless
/// `$METRALE_LORA_PEER` is set, at least one `--lora-adapter`, and
/// `--max-loras` above the resident count for cache headroom. Its rank must
/// not exceed `--max-lora-rank` (64 when unset).
#[arg(long, value_name = "NAME=PATH_OR_HF_ID", value_parser = parse_lora_adapter_spec)]
pub lora_stageable_disk: Vec<(String, String)>,
/// One-shot all-reduce bound, in KiB, at `--world-size` 2 or more: a BF16
/// all-reduce of at most this size is exchanged with every peer in one
/// grouped send/recv and summed in rank order (FP32, rounded once, the
/// same bytes on every rank); larger payloads use NCCL's all-reduce. `0`
/// (the default) sends every all-reduce to NCCL. At world size 2 a plain
/// all-reduce keeps its own 2-rank exchange (the same bytes but for the
/// sign of a zero sum of two negative zeros); the bound there enables the
/// fused consumers of the exchange (2026-10-16: was refused at 2).
#[arg(long, default_value_t = 0)]
pub all_reduce_oneshot_max_kb: usize,
/// 2026-10-08: `--chat-template` and the other chat-surface flags, listed last.
#[command(flatten)]
pub chat_surface: crate::cli::serve_args_chat_surface::ServeChatSurfaceArgs,
}