use std::net::IpAddr;
use serde::{Deserialize, Serialize};
use crate::workload_pkg::manifest::valid_token;
use crate::workload_runtime::types::{
InferencePayload, RuntimeError, VerifiedWorkload, WorkloadSource,
};
pub const KIND_INFERENCE: &str = "inference";
const MAX_EXTRA_ARGS: usize = 32;
const MAX_ARG_LEN: usize = 256;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum InferFlavor {
#[serde(rename = "infer.llamacpp")]
LlamaCpp,
#[serde(rename = "infer.mlx-lm")]
MlxLm,
#[serde(rename = "infer.ollama")]
Ollama,
}
impl InferFlavor {
pub fn id(self) -> &'static str {
match self {
Self::LlamaCpp => "infer.llamacpp",
Self::MlxLm => "infer.mlx-lm",
Self::Ollama => "infer.ollama",
}
}
pub fn capability(self) -> &'static str {
match self {
Self::LlamaCpp => "runtime.infer.llamacpp",
Self::MlxLm => "runtime.infer.mlx-lm",
Self::Ollama => "runtime.infer.ollama",
}
}
pub fn default_port(self) -> u16 {
match self {
Self::LlamaCpp => 8090,
Self::MlxLm => 8081,
Self::Ollama => 11434,
}
}
pub fn formats(self) -> &'static [&'static str] {
match self {
Self::LlamaCpp => &["gguf"],
Self::MlxLm => &["mlx", "safetensors"],
Self::Ollama => &["ollama", "gguf"],
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LatencyClass {
Interactive,
#[default]
Batch,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct MemoryBudget {
pub weights_bytes: u64,
pub kv_budget_bytes: u64,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ServeArgs {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub host: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub port: Option<u16>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ctx: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub kv_quant: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub draft_model: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub extra_args: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct InferenceSpec {
pub role: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
pub runtime: InferFlavor,
#[serde(default)]
pub serve: ServeArgs,
#[serde(default)]
pub memory: MemoryBudget,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub excludes: Vec<String>,
#[serde(default)]
pub latency_class: LatencyClass,
#[serde(default)]
pub sticky: bool,
#[serde(default = "default_api")]
pub api: String,
}
fn default_api() -> String {
"openai-v1".into()
}
fn bad(m: impl Into<String>) -> RuntimeError {
RuntimeError::InvalidConfig(m.into())
}
fn allowed_flags(flavor: InferFlavor) -> &'static [(&'static str, usize)] {
match flavor {
InferFlavor::LlamaCpp => &[
("--threads", 1),
("-t", 1),
("--threads-batch", 1),
("--batch-size", 1),
("-b", 1),
("--ubatch-size", 1),
("-ub", 1),
("--parallel", 1),
("-np", 1),
("--n-gpu-layers", 1),
("-ngl", 1),
("--flash-attn", 0),
("-fa", 0),
("--cont-batching", 0),
("--mlock", 0),
("--no-mmap", 0),
("--jinja", 0),
("--metrics", 0),
("--no-webui", 0),
("--temp", 1),
("--top-p", 1),
("--top-k", 1),
("--repeat-penalty", 1),
("--seed", 1),
],
InferFlavor::MlxLm => &[
("--max-tokens", 1),
("--temp", 1),
("--top-p", 1),
("--top-k", 1),
("--log-level", 1),
],
InferFlavor::Ollama => &[],
}
}
fn valid_flag_value(v: &str) -> bool {
!v.is_empty()
&& v.len() <= MAX_ARG_LEN
&& (!v.starts_with('-') || v.parse::<f64>().is_ok())
&& v.bytes()
.all(|b| b.is_ascii_alphanumeric() || b"._:+-/".contains(&b))
}
fn validate_extra_args(flavor: InferFlavor, args: &[String]) -> Result<(), RuntimeError> {
if args.len() > MAX_EXTRA_ARGS {
return Err(bad("too many extra_args"));
}
let allowed = allowed_flags(flavor);
let mut i = 0;
while i < args.len() {
let a = &args[i];
if a.bytes().any(|b| b.is_ascii_whitespace() || b == 0) {
return Err(bad(format!(
"extra_args entries are single tokens without whitespace: {a:?}"
)));
}
let Some(&(_, arity)) = allowed.iter().find(|(f, _)| f == a) else {
return Err(bad(format!(
"extra_args {a:?} is not an allowed {} flag (exact spelling, value as a separate entry)",
flavor.id()
)));
};
for v in args.iter().skip(i + 1).take(arity) {
if !valid_flag_value(v) {
return Err(bad(format!(
"extra_args value {v:?} for {a} is not a plain word or number"
)));
}
}
if i + arity >= args.len() && arity > 0 {
return Err(bad(format!("extra_args {a} needs {arity} value(s)")));
}
i += 1 + arity;
}
Ok(())
}
const MAX_MEMORY_BYTES: u64 = 1 << 42;
impl InferenceSpec {
pub fn new(role: &str, runtime: InferFlavor) -> Self {
Self {
role: role.into(),
model: None,
runtime,
serve: ServeArgs::default(),
memory: MemoryBudget::default(),
excludes: Vec::new(),
latency_class: LatencyClass::Batch,
sticky: false,
api: default_api(),
}
}
pub fn bind_ip(&self) -> Result<IpAddr, RuntimeError> {
let raw = self.serve.host.as_deref().unwrap_or("127.0.0.1");
let ip: IpAddr = if raw == "localhost" {
IpAddr::from([127, 0, 0, 1])
} else {
raw.parse()
.map_err(|_| bad(format!("serve.host {raw:?} is not an IP address")))?
};
if !ip.is_loopback() {
return Err(bad(format!(
"serve.host {raw} is not loopback: binding wider than loopback needs a governed \
Permit and is not supported by the inference adapters yet"
)));
}
Ok(ip)
}
pub fn port(&self) -> Result<u16, RuntimeError> {
match self.serve.port {
Some(0) => Err(bad("serve.port must not be 0")),
Some(p) => Ok(p),
None => Ok(self.runtime.default_port()),
}
}
pub fn base_url(&self) -> Result<String, RuntimeError> {
Ok(format!(
"http://{}",
std::net::SocketAddr::new(self.bind_ip()?, self.port()?)
))
}
pub fn validate(&self) -> Result<(), RuntimeError> {
if !valid_token(&self.role, 64) {
return Err(bad("role must be a plain token (1-64 chars)"));
}
for m in self.model.iter().chain(self.serve.draft_model.iter()) {
if !valid_token(m, 128) {
return Err(bad("model names must be plain tokens (1-128 chars)"));
}
}
self.bind_ip()?;
self.port()?;
if self.serve.ctx == Some(0) {
return Err(bad("serve.ctx must be positive"));
}
if let Some(q) = &self.serve.kv_quant
&& !(valid_token(q, 16) && !q.contains(':') && !q.contains('+'))
{
return Err(bad("serve.kv_quant must be a plain token"));
}
if self.serve.draft_model.is_some() && self.runtime != InferFlavor::LlamaCpp {
return Err(bad("draft models are supported by infer.llamacpp only"));
}
validate_extra_args(self.runtime, &self.serve.extra_args)?;
if self.memory.weights_bytes > MAX_MEMORY_BYTES
|| self.memory.kv_budget_bytes > MAX_MEMORY_BYTES
{
return Err(bad("memory figures must be at most 4 TiB"));
}
for x in &self.excludes {
if !valid_token(x, 64) {
return Err(bad("excludes entries must be plain tokens"));
}
}
if self.api != "openai-v1" {
return Err(bad("only api \"openai-v1\" is supported"));
}
Ok(())
}
}
impl VerifiedWorkload {
pub fn inference(spec: InferenceSpec) -> Result<Self, RuntimeError> {
spec.validate()?;
Ok(Self {
kind: KIND_INFERENCE.to_string(),
id: spec.role.clone(),
version: spec.model.clone().unwrap_or_else(|| "adopted".into()),
source: WorkloadSource::Inference(Box::new(InferencePayload { spec })),
})
}
pub fn inference_spec(&self, runtime: &str) -> Result<&InferenceSpec, RuntimeError> {
match &self.source {
WorkloadSource::Inference(p) => Ok(&p.spec),
_ => Err(RuntimeError::AdmissionRefused(format!(
"{runtime} runs only inference workloads"
))),
}
}
}