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
393
394
395
396
397
// SPDX-License-Identifier: MIT OR Apache-2.0
//! 2026-09-26: `AppState`, the per-model state the HTTP handlers read, and the
//! LoRA adapter routing and promotion it carries.
//!
//! Owner: server (HTTP layer).
//! Invariants: none beyond the types.
use std::sync::Arc;
use tokio::sync::mpsc;
use crate::api::InferenceRequest;
use crate::tokenizer::ChatTokenizer;
use crate::{
conversation_store, rate_limiter, reasoning_parser, request_dumper, response_store, tool_parser,
};
/// 2026-09-26: Map a request's adapter selector to a LoRA pool slot: `None`
/// gives `Some(-1)` (the model's active adapter), a name in `adapter_names`
/// gives `Some(its index)`, and any other name gives `None`.
pub fn resolve_adapter_slot(adapter_names: &[String], adapter: Option<&str>) -> Option<i32> {
match adapter {
None => Some(-1),
Some(name) => adapter_names
.iter()
.position(|n| n == name)
.map(|i| i as i32),
}
}
pub struct AppState {
/// 2026-09-28: The model's forward (`--forward`) and its decode plan digest, as
/// `GET /forward` reports them.
pub forward: metrale_bench::gate::record_serve::LiveForward,
/// 2026-10-01: The built model's KV pool and slots and the serve's memory budget, as
/// `GET /memory` reports them with the readings it takes per request.
pub memory: crate::main_modules::memory_probe::MemoryFacts,
pub tokenizer: ChatTokenizer,
pub model_name: String,
/// 2026-09-26: The first `--lora-adapter`'s name, if any. No handler reads
/// it.
pub adapter_name: Option<String>,
/// 2026-09-26: Resident adapter names; index i is the slot
/// `resolve_adapter_slot` gives that name. Listed by /v1/models, and the
/// only names `POST /v1/lora/active` accepts.
pub adapter_names: Vec<String>,
/// 2026-09-26: The adapter last made active through the LoRA control
/// routes, starting at the LoRA pool's first adapter, if any. Written,
/// never read; the model holds the active slot.
pub active_adapter: std::sync::Arc<std::sync::Mutex<Option<String>>>,
pub max_seq_len: usize,
pub request_tx: mpsc::Sender<InferenceRequest>,
/// 2026-09-26: LoRA control channel to the scheduler: a `LoraCommand` and
/// its ack. `None` when no `--lora-adapter` pool is loaded.
pub rotation_tx: Option<mpsc::Sender<crate::scheduler::LoraRotation>>,
/// 2026-09-26: The model config's `vision` section (`serve_load.rs`).
pub vision_config: Option<metrale_config::VisionConfig>,
/// 2026-09-26: Image area bound in pixels (`resolve_vision_max_pixels`);
/// `None` when none is declared.
pub vision_max_pixels: Option<usize>,
/// 2026-09-26: Whether and how `image_url` parts with an http(s) URL are
/// fetched; off unless `--vision-allow-remote-images`.
pub remote_image_policy: crate::api::chat::remote_image::RemoteImagePolicy,
/// 2026-10-08: `--limit-images-per-prompt` / `--limit-videos-per-prompt`
/// (`api/chat/media_limits.rs`).
pub media_limits: crate::api::chat::media_limits::MediaLimits,
/// 2026-09-26: ffmpeg subprocess policy for video parts; off unless
/// `--video-allow-ffmpeg`.
pub video_ffmpeg: metrale_model_layers::video_decode_ffmpeg::FfmpegPolicy,
/// 2026-09-26: `--video-fps`: frames per second a video is sampled at.
pub video_fps: f32,
/// 2026-09-26: Default temperature, top-k and top-p: `generation_config.json`,
/// else MODEL.toml `[sampling.non_thinking]` (`resolve_sampling_defaults`).
pub default_temperature: f32,
pub default_top_k: u32,
pub default_top_p: f32,
/// 2026-09-26: `generation_config.json`, else `--default-top-n-sigma`.
pub default_top_n_sigma: f32,
/// 2026-09-26: `generation_config.json`, else MODEL.toml
/// `[sampling.non_thinking]`, else `--default-min-p`.
pub default_min_p: f32,
/// 2026-09-26: Tool call parser; `None` turns tool handling off
/// (`api/chat/prepare.rs`). An `Arc`, so each request's
/// `GrammarSpec::ToolCall` can share it.
pub tool_call_parser: Option<std::sync::Arc<dyn tool_parser::ToolCallParser>>,
/// 2026-09-26: Chat-path levers, resolved at load from MODEL.toml
/// `[behavior]` and `METRALE_*` variables (`ChatLevers::resolve`).
pub chat: crate::api::chat::levers::ChatLevers,
pub reasoning_parser: Option<Box<dyn reasoning_parser::ReasoningParser>>,
/// 2026-09-26: End-of-thinking token id, from the reasoning parser's
/// `end_token_id`.
pub think_end_token_id: Option<u32>,
/// 2026-09-26: `<think>` token id, when it encodes to one token. An
/// unclosed `<think>` in the prompt's last 8 tokens turns thinking on with
/// the model's `max_thinking_budget` (`api/chat/template.rs`).
pub think_start_token_id: Option<u32>,
/// 2026-09-26: `--tool-max-tokens`: the `max_tokens` cap for a request with
/// tools active.
pub tool_max_tokens: usize,
/// 2026-09-26: MODEL.toml sampling presets, per request category.
pub sampling_presets: metrale_kernels::SamplingPresets,
/// 2026-09-26: The tool-call opener's token id (`<tool_call>`, or
/// `<minimax:tool_call>` for `minimax_xml`) when it encodes to one token.
/// Biased by repeat count when tools are active (`api/chat/sampling_setup.rs`).
pub tool_call_start_token_id: Option<u32>,
/// 2026-09-26: `--auto-compact`. Any value above 0 turns compaction on; the
/// trigger is fixed at 70% of `max_seq_len` (`api/chat/template.rs`).
pub auto_compact_threshold: Option<f32>,
/// 2026-09-26: `--request-timeout` in seconds; 0 means no deadline.
pub request_timeout: u32,
/// 2026-09-26: Set to 0 at load (`serve_load.rs`) and read nowhere.
pub effective_context: usize,
/// 2026-09-26: MODEL.toml `[behavior]`, embedded at build time, with
/// `--max-thinking-budget`, `--tool-grammar` and the `preserve_thinking`
/// server kwarg applied over it (`serve_load.rs`).
pub behavior: metrale_kernels::ModelBehavior,
/// 2026-09-26: `--disable-thinking`: `resolve_thinking` turns thinking off
/// whatever the request or MODEL.toml asks (`api/chat/thinking.rs`).
pub disable_thinking: bool,
/// 2026-10-08: `--uncapped-thinking`: `resolve_thinking` and the template-opened
/// block arm no budget of the server's own (`api/chat/thinking.rs`).
pub uncapped_thinking: bool,
/// 2026-09-26: The thinking directive from `--default-chat-template-kwargs`,
/// used when the request gives no explicit one (`api/chat/prepare.rs`).
pub default_thinking: crate::ir::ThinkingDirective,
/// 2026-09-26: `reasoning_effort` from `--default-chat-template-kwargs`,
/// used when the request gives none and thinking is on
/// (`api/chat/prepare.rs`). `None` leaves the renderer's `"medium"`
/// (`tokenizer/chat_render.rs`).
pub default_reasoning_effort: Option<crate::ir::ReasoningEffort>,
/// 2026-09-26: In-memory store behind Responses API `previous_response_id`
/// and Chat Completions `store: true`.
pub response_store: Arc<response_store::ResponseStore>,
/// 2026-09-26: Per-client rate limiter; off when `METRALE_RATE_LIMIT_RPM`
/// and `METRALE_RATE_LIMIT_TPM` are both 0 or unset.
pub rate_limiter: Arc<rate_limiter::RateLimiter>,
pub conversation_store: Arc<conversation_store::ConversationStore>,
/// 2026-09-26: `--dump` JSONL writer; `None` when `--dump` is absent or its
/// target could not be opened.
pub dump_writer: Option<request_dumper::DumpHandle>,
/// 2026-09-26: `--lora-stageable`: adapters a weight peer can promote into
/// a cache slot, by name.
pub lora_stageable:
std::collections::HashMap<String, crate::main_modules::promotion::StageableAdapter>,
/// 2026-09-26: `METRALE_LORA_PEER`, the weight peer a `--lora-stageable`
/// promote reads from; without it those names are not promoted.
pub lora_peer_addr: Option<String>,
/// 2026-09-26: Single-flight coordinator for promotions. `Some` only with a
/// resident pool and a stageable source: `--lora-stageable` with a peer,
/// or `--lora-stageable-disk`.
pub promotion: Option<Arc<crate::main_modules::promotion::PromotionManager>>,
/// 2026-09-26: Promoted name → cache slot. A successful promote inserts its
/// name and drops the evicted one, so later requests for it skip the
/// promote.
pub promoted_slots: Arc<std::sync::RwLock<std::collections::HashMap<String, i32>>>,
/// 2026-09-26: `--lora-stageable-disk`: name → (adapter dir, PEFT config).
/// The config is parsed and rank-checked at load; the disk swap reads it
/// again at promote time.
pub lora_disk_stageable:
std::collections::HashMap<String, (std::path::PathBuf, metrale_config::PeftAdapterConfig)>,
}
use crate::main_modules::promotion::PromoteReject;
/// 2026-09-26: Turn a timeout budget in seconds into an absolute deadline; 0
/// or less means no deadline (`None`).
pub fn deadline_from(secs: f32, now: std::time::Instant) -> Option<std::time::Instant> {
if secs > 0.0 {
Some(now + std::time::Duration::from_secs_f32(secs))
} else {
None
}
}
impl AppState {
/// 2026-10-14: The reasoning field of this model's chat responses: its
/// reasoning format's (`ReasoningParser::reasoning_wire`), and
/// `reasoning_content` for a model without a reasoning parser.
pub fn reasoning_wire(&self) -> reasoning_parser::ReasoningWire {
self.reasoning_parser
.as_deref()
.map_or(reasoning_parser::ReasoningWire::ReasoningContent, |p| {
p.reasoning_wire()
})
}
/// 2026-09-26: The absolute deadline for one request: `override_secs` (the
/// request's `timeout`) when given, else `--request-timeout`.
pub fn request_deadline(&self, override_secs: Option<f32>) -> Option<std::time::Instant> {
deadline_from(
override_secs.unwrap_or(self.request_timeout as f32),
std::time::Instant::now(),
)
}
/// 2026-09-26: For a name `resolve_adapter_slot` did not find, promote a
/// stageable adapter into a cache slot.
///
/// - `Ok(None)`: not stageable, or promotion is not armed for it.
/// - `Ok(Some(slot))`: promoted earlier, or promoted now.
/// - `Err`: the promote failed (`lora_control` answers 503 for
/// `PoolFull`, 502 for `Peer`).
///
/// Concurrent calls for one name share one promote
/// (`PromotionManager::coalesce`), whose lock is not held across the
/// scheduler round-trip.
pub async fn ensure_adapter_hot_opt(&self, name: &str) -> Result<Option<i32>, PromoteReject> {
// 2026-09-26: Not gated on `lora_peer_addr`: a disk-stageable name needs
// no peer, and a peer-stageable one without a peer returns `Ok(None)`.
let (Some(promotion), Some(tx)) = (&self.promotion, &self.rotation_tx) else {
return Ok(None);
};
enum Backing {
Peer(crate::main_modules::promotion::StageableAdapter, String),
Disk(std::path::PathBuf),
}
let backing = if let Some(st) = self.lora_stageable.get(name).cloned() {
match &self.lora_peer_addr {
Some(addr) => Backing::Peer(st, addr.clone()),
None => return Ok(None),
}
} else if let Some((dir, _peft)) = self.lora_disk_stageable.get(name) {
Backing::Disk(dir.clone())
} else {
return Ok(None);
};
if let Some(&slot) = self
.promoted_slots
.read()
.expect("promoted_slots poisoned")
.get(name)
{
return Ok(Some(slot));
}
let promoted_slots = Arc::clone(&self.promoted_slots);
let tx = tx.clone();
let name_owned = name.to_string();
let slot = promotion
.coalesce(name, move || async move {
// 2026-09-26: A leader for this name that finished after the
// check above has already inserted it.
if let Some(&slot) = promoted_slots
.read()
.expect("promoted_slots poisoned")
.get(&name_owned)
{
return Ok(slot);
}
let (slot, evicted) = match backing {
Backing::Peer(st, addr) => {
Self::dispatch_promote(&tx, &addr, &name_owned, &st).await?
}
Backing::Disk(dir) => {
Self::dispatch_promote_disk(&tx, &name_owned, &dir).await?
}
};
let mut ov = promoted_slots.write().expect("promoted_slots poisoned");
if let Some(ev) = evicted {
ov.remove(&ev);
}
ov.insert(name_owned.clone(), slot);
Ok(slot)
})
.await?;
Ok(Some(slot))
}
/// 2026-09-26: Whether `name` is a peer- or disk-stageable adapter. Request
/// routing and `GET /v1/models/{id}` accept such a name, which is not in
/// `adapter_names`.
pub fn is_stageable_name(&self, name: &str) -> bool {
self.lora_stageable.contains_key(name) || self.lora_disk_stageable.contains_key(name)
}
/// 2026-09-26: Send one `Promote` to the scheduler and wait up to 30 s for
/// its ack; a timeout is reported as `PoolFull`.
async fn dispatch_promote(
tx: &mpsc::Sender<crate::scheduler::LoraRotation>,
peer_addr: &str,
name: &str,
stageable: &crate::main_modules::promotion::StageableAdapter,
) -> Result<(i32, Option<String>), PromoteReject> {
use crate::scheduler::{LoraAck, LoraCommand};
let (ack_tx, ack_rx) = tokio::sync::oneshot::channel();
let cmd = LoraCommand::Promote {
peer_addr: peer_addr.to_string(),
adapter_id: stageable.peer_stage_id.clone(),
name: name.to_string(),
peft: stageable.peft.clone(),
};
if tx.send((cmd, ack_tx)).await.is_err() {
return Err(PromoteReject::Peer(
"scheduler promote channel closed".to_string(),
));
}
let acked = tokio::time::timeout(std::time::Duration::from_secs(30), ack_rx).await;
match acked {
Err(_timeout) => Err(PromoteReject::PoolFull(
"promotion timed out waiting for scheduler quiescence; retry".to_string(),
)),
Ok(Err(_recv)) => Err(PromoteReject::Peer(
"scheduler dropped the promote ack (shutting down?)".to_string(),
)),
Ok(Ok(Err(reason))) => {
if reason.contains("POOL_FULL") || reason.contains("ref_count>0") {
Err(PromoteReject::PoolFull(reason))
} else {
Err(PromoteReject::Peer(reason))
}
}
Ok(Ok(Ok(LoraAck::Promoted { slot, evicted }))) => Ok((slot as i32, evicted)),
Ok(Ok(Ok(LoraAck::Done))) => Err(PromoteReject::Peer(
"scheduler returned a non-promote ack for a promote".to_string(),
)),
}
}
/// 2026-09-26: [`Self::dispatch_promote`] for a disk-stageable adapter: sends
/// `PromoteDisk`, with the same 30 s bound and error mapping. The model reads
/// the directory's `adapter_config.json` itself, so no PEFT config is sent.
async fn dispatch_promote_disk(
tx: &mpsc::Sender<crate::scheduler::LoraRotation>,
name: &str,
dir: &std::path::Path,
) -> Result<(i32, Option<String>), PromoteReject> {
use crate::scheduler::{LoraAck, LoraCommand};
let (ack_tx, ack_rx) = tokio::sync::oneshot::channel();
let cmd = LoraCommand::PromoteDisk {
name: name.to_string(),
dir: dir.to_path_buf(),
};
if tx.send((cmd, ack_tx)).await.is_err() {
return Err(PromoteReject::Peer(
"scheduler promote channel closed".to_string(),
));
}
let acked = tokio::time::timeout(std::time::Duration::from_secs(30), ack_rx).await;
match acked {
Err(_timeout) => Err(PromoteReject::PoolFull(
"disk promotion timed out waiting for scheduler quiescence; retry".to_string(),
)),
Ok(Err(_recv)) => Err(PromoteReject::Peer(
"scheduler dropped the promote ack (shutting down?)".to_string(),
)),
Ok(Ok(Err(reason))) => {
if reason.contains("POOL_FULL") || reason.contains("ref_count>0") {
Err(PromoteReject::PoolFull(reason))
} else {
Err(PromoteReject::Peer(reason))
}
}
Ok(Ok(Ok(LoraAck::Promoted { slot, evicted }))) => Ok((slot as i32, evicted)),
Ok(Ok(Ok(LoraAck::Done))) => Err(PromoteReject::Peer(
"scheduler returned a non-promote ack for a disk promote".to_string(),
)),
}
}
}
pub type ModelBehavior = metrale_kernels::ModelBehavior;
#[cfg(test)]
mod tests {
use super::{deadline_from, resolve_adapter_slot};
#[test]
fn zero_timeout_disables_the_deadline() {
let now = std::time::Instant::now();
assert!(deadline_from(0.0, now).is_none());
assert!(deadline_from(-1.0, now).is_none());
}
#[test]
fn positive_timeout_yields_that_budget() {
let now = std::time::Instant::now();
let d = deadline_from(5.0, now).expect("5s budget is a deadline");
assert_eq!(d.saturating_duration_since(now).as_millis(), 5000);
}
#[test]
fn adapter_slot_resolution_rules() {
let names = vec!["alpha".to_string(), "beta".to_string(), "gamma".to_string()];
assert_eq!(resolve_adapter_slot(&names, None), Some(-1));
assert_eq!(resolve_adapter_slot(&names, Some("alpha")), Some(0));
assert_eq!(resolve_adapter_slot(&names, Some("beta")), Some(1));
assert_eq!(resolve_adapter_slot(&names, Some("gamma")), Some(2));
assert_eq!(resolve_adapter_slot(&names, Some("delta")), None);
assert_eq!(resolve_adapter_slot(&[], Some("alpha")), None);
assert_eq!(resolve_adapter_slot(&[], None), Some(-1));
}
}