1use std::net::IpAddr;
5
6use serde::{Deserialize, Serialize};
7
8use crate::workload_pkg::manifest::valid_token;
9use crate::workload_runtime::types::{
10 InferencePayload, RuntimeError, VerifiedWorkload, WorkloadSource,
11};
12
13pub const KIND_INFERENCE: &str = "inference";
15
16const MAX_EXTRA_ARGS: usize = 32;
18const MAX_ARG_LEN: usize = 256;
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
22pub enum InferFlavor {
23 #[serde(rename = "infer.llamacpp")]
25 LlamaCpp,
26 #[serde(rename = "infer.mlx-lm")]
28 MlxLm,
29 #[serde(rename = "infer.ollama")]
31 Ollama,
32}
33
34impl InferFlavor {
35 pub fn id(self) -> &'static str {
37 match self {
38 Self::LlamaCpp => "infer.llamacpp",
39 Self::MlxLm => "infer.mlx-lm",
40 Self::Ollama => "infer.ollama",
41 }
42 }
43
44 pub fn capability(self) -> &'static str {
46 match self {
47 Self::LlamaCpp => "runtime.infer.llamacpp",
48 Self::MlxLm => "runtime.infer.mlx-lm",
49 Self::Ollama => "runtime.infer.ollama",
50 }
51 }
52
53 pub fn default_port(self) -> u16 {
56 match self {
57 Self::LlamaCpp => 8090,
58 Self::MlxLm => 8081,
59 Self::Ollama => 11434,
60 }
61 }
62
63 pub fn formats(self) -> &'static [&'static str] {
65 match self {
66 Self::LlamaCpp => &["gguf"],
67 Self::MlxLm => &["mlx", "safetensors"],
68 Self::Ollama => &["ollama", "gguf"],
69 }
70 }
71}
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
75#[serde(rename_all = "snake_case")]
76pub enum LatencyClass {
77 Interactive,
79 #[default]
81 Batch,
82}
83
84#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
86pub struct MemoryBudget {
87 pub weights_bytes: u64,
89 pub kv_budget_bytes: u64,
91}
92
93#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
96#[serde(deny_unknown_fields)]
97pub struct ServeArgs {
98 #[serde(default, skip_serializing_if = "Option::is_none")]
101 pub host: Option<String>,
102 #[serde(default, skip_serializing_if = "Option::is_none")]
104 pub port: Option<u16>,
105 #[serde(default, skip_serializing_if = "Option::is_none")]
107 pub ctx: Option<u32>,
108 #[serde(default, skip_serializing_if = "Option::is_none")]
110 pub kv_quant: Option<String>,
111 #[serde(default, skip_serializing_if = "Option::is_none")]
113 pub draft_model: Option<String>,
114 #[serde(default, skip_serializing_if = "Vec::is_empty")]
116 pub extra_args: Vec<String>,
117}
118
119#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
121#[serde(deny_unknown_fields)]
122pub struct InferenceSpec {
123 pub role: String,
125 #[serde(default, skip_serializing_if = "Option::is_none")]
128 pub model: Option<String>,
129 pub runtime: InferFlavor,
131 #[serde(default)]
133 pub serve: ServeArgs,
134 #[serde(default)]
136 pub memory: MemoryBudget,
137 #[serde(default, skip_serializing_if = "Vec::is_empty")]
139 pub excludes: Vec<String>,
140 #[serde(default)]
142 pub latency_class: LatencyClass,
143 #[serde(default)]
145 pub sticky: bool,
146 #[serde(default = "default_api")]
148 pub api: String,
149}
150
151fn default_api() -> String {
152 "openai-v1".into()
153}
154
155fn bad(m: impl Into<String>) -> RuntimeError {
156 RuntimeError::InvalidConfig(m.into())
157}
158
159fn allowed_flags(flavor: InferFlavor) -> &'static [(&'static str, usize)] {
167 match flavor {
168 InferFlavor::LlamaCpp => &[
169 ("--threads", 1),
170 ("-t", 1),
171 ("--threads-batch", 1),
172 ("--batch-size", 1),
173 ("-b", 1),
174 ("--ubatch-size", 1),
175 ("-ub", 1),
176 ("--parallel", 1),
177 ("-np", 1),
178 ("--n-gpu-layers", 1),
179 ("-ngl", 1),
180 ("--flash-attn", 0),
181 ("-fa", 0),
182 ("--cont-batching", 0),
183 ("--mlock", 0),
184 ("--no-mmap", 0),
185 ("--jinja", 0),
186 ("--metrics", 0),
187 ("--no-webui", 0),
188 ("--temp", 1),
189 ("--top-p", 1),
190 ("--top-k", 1),
191 ("--repeat-penalty", 1),
192 ("--seed", 1),
193 ],
194 InferFlavor::MlxLm => &[
195 ("--max-tokens", 1),
196 ("--temp", 1),
197 ("--top-p", 1),
198 ("--top-k", 1),
199 ("--log-level", 1),
200 ],
201 InferFlavor::Ollama => &[],
203 }
204}
205
206fn valid_flag_value(v: &str) -> bool {
209 !v.is_empty()
210 && v.len() <= MAX_ARG_LEN
211 && (!v.starts_with('-') || v.parse::<f64>().is_ok())
212 && v.bytes()
213 .all(|b| b.is_ascii_alphanumeric() || b"._:+-/".contains(&b))
214}
215
216fn validate_extra_args(flavor: InferFlavor, args: &[String]) -> Result<(), RuntimeError> {
217 if args.len() > MAX_EXTRA_ARGS {
218 return Err(bad("too many extra_args"));
219 }
220 let allowed = allowed_flags(flavor);
221 let mut i = 0;
222 while i < args.len() {
223 let a = &args[i];
224 if a.bytes().any(|b| b.is_ascii_whitespace() || b == 0) {
225 return Err(bad(format!(
226 "extra_args entries are single tokens without whitespace: {a:?}"
227 )));
228 }
229 let Some(&(_, arity)) = allowed.iter().find(|(f, _)| f == a) else {
230 return Err(bad(format!(
231 "extra_args {a:?} is not an allowed {} flag (exact spelling, value as a separate entry)",
232 flavor.id()
233 )));
234 };
235 for v in args.iter().skip(i + 1).take(arity) {
236 if !valid_flag_value(v) {
237 return Err(bad(format!(
238 "extra_args value {v:?} for {a} is not a plain word or number"
239 )));
240 }
241 }
242 if i + arity >= args.len() && arity > 0 {
243 return Err(bad(format!("extra_args {a} needs {arity} value(s)")));
244 }
245 i += 1 + arity;
246 }
247 Ok(())
248}
249
250const MAX_MEMORY_BYTES: u64 = 1 << 42;
252
253impl InferenceSpec {
254 pub fn new(role: &str, runtime: InferFlavor) -> Self {
256 Self {
257 role: role.into(),
258 model: None,
259 runtime,
260 serve: ServeArgs::default(),
261 memory: MemoryBudget::default(),
262 excludes: Vec::new(),
263 latency_class: LatencyClass::Batch,
264 sticky: false,
265 api: default_api(),
266 }
267 }
268
269 pub fn bind_ip(&self) -> Result<IpAddr, RuntimeError> {
271 let raw = self.serve.host.as_deref().unwrap_or("127.0.0.1");
272 let ip: IpAddr = if raw == "localhost" {
273 IpAddr::from([127, 0, 0, 1])
274 } else {
275 raw.parse()
276 .map_err(|_| bad(format!("serve.host {raw:?} is not an IP address")))?
277 };
278 if !ip.is_loopback() {
279 return Err(bad(format!(
280 "serve.host {raw} is not loopback: binding wider than loopback needs a governed \
281 Permit and is not supported by the inference adapters yet"
282 )));
283 }
284 Ok(ip)
285 }
286
287 pub fn port(&self) -> Result<u16, RuntimeError> {
289 match self.serve.port {
290 Some(0) => Err(bad("serve.port must not be 0")),
291 Some(p) => Ok(p),
292 None => Ok(self.runtime.default_port()),
293 }
294 }
295
296 pub fn base_url(&self) -> Result<String, RuntimeError> {
298 Ok(format!(
299 "http://{}",
300 std::net::SocketAddr::new(self.bind_ip()?, self.port()?)
301 ))
302 }
303
304 pub fn validate(&self) -> Result<(), RuntimeError> {
306 if !valid_token(&self.role, 64) {
307 return Err(bad("role must be a plain token (1-64 chars)"));
308 }
309 for m in self.model.iter().chain(self.serve.draft_model.iter()) {
310 if !valid_token(m, 128) {
311 return Err(bad("model names must be plain tokens (1-128 chars)"));
312 }
313 }
314 self.bind_ip()?;
315 self.port()?;
316 if self.serve.ctx == Some(0) {
317 return Err(bad("serve.ctx must be positive"));
318 }
319 if let Some(q) = &self.serve.kv_quant
320 && !(valid_token(q, 16) && !q.contains(':') && !q.contains('+'))
321 {
322 return Err(bad("serve.kv_quant must be a plain token"));
323 }
324 if self.serve.draft_model.is_some() && self.runtime != InferFlavor::LlamaCpp {
325 return Err(bad("draft models are supported by infer.llamacpp only"));
326 }
327 validate_extra_args(self.runtime, &self.serve.extra_args)?;
328 if self.memory.weights_bytes > MAX_MEMORY_BYTES
329 || self.memory.kv_budget_bytes > MAX_MEMORY_BYTES
330 {
331 return Err(bad("memory figures must be at most 4 TiB"));
332 }
333 for x in &self.excludes {
334 if !valid_token(x, 64) {
335 return Err(bad("excludes entries must be plain tokens"));
336 }
337 }
338 if self.api != "openai-v1" {
339 return Err(bad("only api \"openai-v1\" is supported"));
340 }
341 Ok(())
342 }
343}
344
345impl VerifiedWorkload {
346 pub fn inference(spec: InferenceSpec) -> Result<Self, RuntimeError> {
350 spec.validate()?;
351 Ok(Self {
352 kind: KIND_INFERENCE.to_string(),
353 id: spec.role.clone(),
354 version: spec.model.clone().unwrap_or_else(|| "adopted".into()),
355 source: WorkloadSource::Inference(Box::new(InferencePayload { spec })),
356 })
357 }
358
359 pub fn inference_spec(&self, runtime: &str) -> Result<&InferenceSpec, RuntimeError> {
361 match &self.source {
362 WorkloadSource::Inference(p) => Ok(&p.spec),
363 _ => Err(RuntimeError::AdmissionRefused(format!(
364 "{runtime} runs only inference workloads"
365 ))),
366 }
367 }
368}