ferrox-server 0.15.0

OpenAI-compatible HTTP server for the Ferrox inference engine
Documentation
//! What the server currently has loaded, and the one seam where a
//! route that needs to *generate* finds out that it cannot.
//!
//! # Why an enum here and not a sixth `Model` variant
//!
//! [`crate::Model`] is the generation model: eighteen `match self` arms
//! asking for a chat template, stop tokens, a BOS policy, a vocabulary
//! size, an expert-cache reading. An encoder-only checkpoint (BERT/BGE,
//! see [`ferrox_models::EmbeddingModel`]) has an honest answer to none
//! of them — it has no output head, so there is no vocabulary to size,
//! nothing to stop, and no turn to template. Adding it as a variant
//! would mean writing eighteen invented answers, which is exactly the
//! "compute something else" failure this engine refuses everywhere
//! else.
//!
//! The alternative considered and rejected was making those eighteen
//! arms return `Option`/`Result`. That is eighteen new failure paths
//! for one new kind of model, and it pushes the refusal down to
//! `vocab_size()` — so a caller would learn "no vocabulary size" rather
//! than "this is an embedding model". The refusal has to name the
//! model, not the field.
//!
//! So the disjunction is one level up, in [`Loaded`], and there is
//! exactly ONE place that converts "what is loaded" into "the model I
//! can decode with": [`ActiveModel::generative`], which returns a
//! `Result`. A route cannot reach a decode without going through it,
//! and what it returns on an encoder is a refusal naming the
//! checkpoint, its architecture and the route that *would* serve it.
//!
//! Everything both kinds can genuinely answer — its name, its tokenizer
//! kind, whether it is the synthetic demo — is answered by
//! [`ActiveModel`] itself, so `/v1/models` and `/health` report an
//! encoder as the loaded model rather than reporting nothing.

use std::sync::Arc;

use axum::http::StatusCode;
use axum::Json;

use ferrox_models::EmbeddingModel;

use crate::{budget, serving, ApiError, Model};

/// The checkpoint this server is serving: exactly one kind, never both.
pub(crate) enum Loaded {
    /// Something with an output head. Chat, completions, embeddings by
    /// pooling its hidden states.
    Generative(Arc<Model>),
    /// An encoder-only embedding checkpoint. `/v1/embeddings` only.
    Encoder(Arc<EmbeddingModel>),
}

/// The model the server is serving *right now*, together with the
/// pieces that are built from it and must be replaced with it.
///
/// The continuous batcher owns a worker thread holding an
/// `Arc<Decoder>`, so it belongs to one specific model: keeping it in a
/// separate field would let a swap leave a batcher decoding against the
/// old weights while `Model` named the new ones. Bundling them means
/// one `Arc` swap replaces a consistent pair.
pub(crate) struct ActiveModel {
    /// Admin-surface id (see `admin::discover`), or `None` for a model
    /// that was not discovered through it -- the synthetic fallback, or
    /// a `FERROX_MODEL_PATH` outside the scanned directory.
    pub(crate) id: Option<String>,
    pub(crate) loaded: Loaded,
    /// Opt-in continuous-batching decode worker (`FERROX_CONTINUOUS_BATCHING=1`).
    /// Shares `forward_multi_seq` across concurrent GGUF requests. Disabled
    /// when a KV pool or prefix cache is configured (those keep the
    /// private-loop `generate` path). Always `None` for an encoder:
    /// there is no decode step to batch.
    pub(crate) batcher: Option<serving::batch::ContinuousBatcher>,
    /// The per-request context ceiling this model was priced for, or
    /// `None` when it could not be priced (see `crate::budget`).
    ///
    /// Lives on the *model* rather than on `AppState` because it is a
    /// property of the checkpoint plus the machine: `/admin/models/load`
    /// swapping in a different model must swap in its ceiling too,
    /// never keep the old model's arithmetic. The same `Arc` is inside
    /// this model's `batcher`, so the batched and private decode paths
    /// admit on one object.
    pub(crate) ceiling: Option<Arc<budget::ContextCeiling>>,
}

impl ActiveModel {
    /// The model to decode with, or a refusal naming the encoder that
    /// is loaded instead.
    ///
    /// **This is the only way to a `Model` from an `ActiveModel`**, and
    /// that is the point: an encoder cannot reach a decode by any other
    /// route, and a caller cannot forget to check, because there is
    /// nothing to forget — the `?` is the check.
    pub(crate) fn generative(&self) -> Result<&Arc<Model>, ApiError> {
        match &self.loaded {
            Loaded::Generative(m) => Ok(m),
            Loaded::Encoder(e) => Err(not_a_generative_model(e)),
        }
    }

    /// The generation model when there is one, for the reporting
    /// surfaces (`/v1/models`, `/health`) that describe whatever is
    /// loaded rather than requiring a particular kind. They say less
    /// about an encoder; they must not say something false.
    pub(crate) fn generative_opt(&self) -> Option<&Arc<Model>> {
        match &self.loaded {
            Loaded::Generative(m) => Some(m),
            Loaded::Encoder(_) => None,
        }
    }

    /// The encoder when the loaded checkpoint is one.
    pub(crate) fn encoder(&self) -> Option<&Arc<EmbeddingModel>> {
        match &self.loaded {
            Loaded::Generative(_) => None,
            Loaded::Encoder(e) => Some(e),
        }
    }

    /// What `/v1/models` and `/health` call this checkpoint. Both kinds
    /// have a real name.
    pub(crate) fn name(&self) -> &str {
        match &self.loaded {
            Loaded::Generative(m) => m.name(),
            Loaded::Encoder(e) => e.name(),
        }
    }

    /// The tokenizer this checkpoint carries. An encoder's is real and
    /// is reported as such -- `EmbeddingModel` only accepts
    /// `tokenizer.ggml.model = "bert"`, so WordPiece is a fact about
    /// it, not a default.
    pub(crate) fn tokenizer_kind(&self) -> &'static str {
        match &self.loaded {
            Loaded::Generative(m) => m.tokenizer_kind(),
            Loaded::Encoder(_) => "gguf-wordpiece",
        }
    }

    /// Only the GGUF decoder path has a synthetic-weights fallback; an
    /// encoder is always a real checkpoint, because there is no
    /// random-weight encoder preset to fall back to.
    pub(crate) fn is_synthetic(&self) -> bool {
        self.generative_opt().is_some_and(|m| m.is_synthetic())
    }

    /// Live counters of the bounded expert cache, when the model
    /// streams routed experts. An encoder has no experts.
    pub(crate) fn expert_store_stats(&self) -> Option<ferrox_core::expert_store::ExpertStoreStats> {
        self.generative_opt().and_then(|m| m.expert_store_stats())
    }
}

/// The refusal a generation route gets when the loaded checkpoint is an
/// encoder.
///
/// 501 and not 503: 503 means "try again", and no amount of retrying
/// turns an encoder into a decoder. 501 with the model named is the
/// shape llama.cpp uses for the mirror-image case (`/v1/embeddings` on
/// a server started without `--embeddings`), and it says the thing that
/// is actually true -- this endpoint is not implemented *for this
/// model* -- rather than blaming a tensor.
fn not_a_generative_model(encoder: &EmbeddingModel) -> ApiError {
    (
        StatusCode::NOT_IMPLEMENTED,
        Json(serde_json::json!({"error": {
            "message": encoder_refusal(
                encoder.name(),
                encoder.architecture(),
                encoder.n_embd(),
                encoder.pooling_type().name(),
            ),
            "type": "unsupported",
            "param": "model",
        }})),
    )
}

/// The wording, split from [`not_a_generative_model`] only so a test
/// can check the facts survive a rewording without needing a 37 MB
/// checkpoint on disk to build an [`EmbeddingModel`] from.
fn encoder_refusal(name: &str, arch: &str, n_embd: usize, pooling: &str) -> String {
    format!(
        "the loaded model '{name}' is an embedding model ({arch} encoder, {n_embd} dims, \
         pooling {pooling}). An encoder has no output head, so it cannot generate text at \
         all. There is no next token for it to predict. POST /v1/embeddings to use it, or \
         load a generative checkpoint."
    )
}

#[cfg(test)]
mod tests {
    use super::*;

    /// The refusal must name the model that IS loaded and the route
    /// that WOULD serve it. "unsupported" alone sends an operator
    /// hunting a missing tensor that is not missing -- which is the
    /// exact failure this whole seam exists to prevent.
    #[test]
    fn the_encoder_refusal_names_the_model_and_the_route_that_works() {
        let msg = encoder_refusal("bge-small-en-v1.5", "bert", 384, "CLS");
        for fact in ["bge-small-en-v1.5", "bert", "384", "CLS", "/v1/embeddings"] {
            assert!(msg.contains(fact), "{msg} does not carry {fact}");
        }
        assert!(msg.contains("embedding model"), "{msg}");
    }
}