Files
rustytorch/crates/production/rtx-serving-api/src/inference.rs
T
osobhandClaude Fable 5 733b02cd8b
GPU Tests / Check GPU Availability (push) Successful in 1s
CI / Build (ubuntu-latest) (push) Failing after 6s
CI / Build (macos-latest) (push) Failing after 11s
Documentation / Build User Guide (push) Successful in 7s
CI / Clippy Check (push) Failing after 21s
CI / Format Check (push) Failing after 6s
GPU Tests / CUDA Tests (11.8) (push) Has been skipped
GPU Tests / CUDA Tests (12.1) (push) Has been skipped
CI / Build CPU-Only (Explicit) (push) Failing after 6s
GPU Tests / Metal Tests (push) Has been skipped
CI / Test (macos-latest) (push) Has been skipped
CI / Test (ubuntu-latest) (push) Has been skipped
CI / Python Bindings (maturin) (macos-latest) (push) Has been skipped
CI / Python Bindings (maturin) (ubuntu-latest) (push) Has been skipped
CI / WASM Build + Size Check (push) Has been skipped
CI / Distributed Training Tests (push) Has been skipped
CI / CI Success (push) Failing after 0s
Performance Benchmarks / Run Benchmarks (push) Successful in 28s
Documentation / Build API Documentation (push) Failing after 25s
feat(inference): concrete EAGLE draft model + real tokenizer at the serving boundary
EAGLE (rtx-inference/src/eagle.rs, ~610 lines, mirrors medusa.rs
conventions): EagleDraftHead autoregressive FFN with Concat/Add/
Attention feature fusion, EagleHeads draft model with draft/
draft_steps (per-step top-k for candidate trees) and teacher-forced
training_loss; implements the speculative::EagleDraftModel trait so it
plugs into the orchestration layer. 38 unit tests.

Tokenizer (rtx-inference/src/tokenizer.rs): ServingTokenizer enum —
Vocab (HuggingFace tokenizers, loadable from tokenizer.json) or
ByteLevel fallback preserving previous behavior. rtx-serving-api's
AppState and rtx-streaming's token generator now encode/decode through
it (with_engine_and_tokenizer / set_tokenizer added; existing
signatures unchanged). Also fixes two pre-existing compile errors in
rtx-streaming (missing import, stray .await) that blocked its lib
tests entirely.

Tests: rtx-inference 328 pass, rtx-serving-api 193 pass, rtx-streaming
53 pass (2 pre-existing mock-server connection failures unrelated to
these changes).

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-09 21:54:05 -07:00

192 lines
5.8 KiB
Rust

//! Inference endpoints with streaming support
use std::sync::Arc;
use axum::extract::State;
use rtx_inference::{InferenceEngine, ServingTokenizer};
use tokio::sync::RwLock;
use crate::{ApiError, ApiResult};
/// Shared application state carrying the inference engine.
///
/// `engine` is `None` until a model has been loaded (e.g. by `main.rs`
/// or an admin endpoint); handlers respond 503 rather than fabricating
/// output when no engine is available.
///
/// `tokenizer` defaults to byte-level (each UTF-8 byte is one token id);
/// load a real vocabulary tokenizer with `ServingTokenizer::from_file` and
/// attach it via `ServingServer::with_engine_and_tokenizer` to use it
/// instead.
#[derive(Clone)]
pub struct AppState {
pub engine: Option<Arc<RwLock<InferenceEngine>>>,
pub tokenizer: ServingTokenizer,
}
impl Default for AppState {
fn default() -> Self {
Self {
engine: None,
tokenizer: ServingTokenizer::default(),
}
}
}
/// Inference request
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct InferenceRequest {
/// Model ID to use for inference
pub model: String,
/// Input prompt
pub prompt: String,
/// Maximum tokens to generate
pub max_tokens: Option<u32>,
/// Temperature for sampling
pub temperature: Option<f32>,
/// Whether to stream the response
pub stream: Option<bool>,
}
/// Inference response
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct InferenceResponse {
/// Generated text
pub text: String,
/// Finish reason
pub finish_reason: String,
/// Usage statistics
pub usage: TokenUsage,
}
/// Token usage statistics
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct TokenUsage {
/// Prompt tokens
pub prompt_tokens: u32,
/// Completion tokens
pub completion_tokens: u32,
/// Total tokens
pub total_tokens: u32,
}
/// Handle inference request by dispatching to the rtx-inference engine.
///
/// Tokenization is pluggable via `state.tokenizer` (`ServingTokenizer`),
/// defaulting to byte-level (each UTF-8 byte is a token id) for backward
/// compatibility. Attach a real vocabulary tokenizer with
/// `ServingTokenizer::from_file` for vocabulary-aware tokenization. Returns
/// 503 when no engine/model is loaded.
pub async fn inference(
State(state): State<AppState>,
request: axum::Json<InferenceRequest>,
) -> ApiResult<axum::Json<InferenceResponse>> {
let req = request.0;
let engine = state.engine.as_ref().ok_or_else(|| {
ApiError::service_unavailable("no inference engine configured — load a model first")
})?;
let input_tokens: Vec<i32> = state.tokenizer.encode(&req.prompt);
let prompt_tokens = input_tokens.len() as u32;
let max_new_tokens = req.max_tokens.unwrap_or(128) as usize;
let mut infer_req =
rtx_inference::InferenceRequest::new(req.model.clone(), input_tokens, max_new_tokens);
if let Some(t) = req.temperature {
infer_req.temperature = t;
}
let result = {
let engine = engine.read().await;
engine
.infer(infer_req)
.await
.map_err(|e| ApiError::internal(format!("inference failed: {e}")))?
};
let completion_tokens = result.output_tokens.len() as u32;
let response_text = state.tokenizer.decode(&result.output_tokens);
let response = InferenceResponse {
text: response_text,
finish_reason: format!("{:?}", result.finish_reason).to_lowercase(),
usage: TokenUsage {
prompt_tokens,
completion_tokens,
total_tokens: prompt_tokens + completion_tokens,
},
};
Ok(axum::Json(response))
}
#[cfg(test)]
mod tests {
use super::*;
use axum::{Router, http::StatusCode, routing::post};
use axum_test::TestServer;
/// Without an engine configured, the endpoint must refuse (503),
/// never fabricate a response.
#[tokio::test]
async fn test_inference_endpoint_no_engine_returns_503() {
let app = Router::new()
.route("/v1/completions", post(inference))
.with_state(AppState::default());
let server = TestServer::new(app).unwrap();
let request = InferenceRequest {
model: "test-model".to_string(),
prompt: "Hello, world!".to_string(),
max_tokens: Some(100),
temperature: Some(0.7),
stream: Some(false),
};
let response = server.post("/v1/completions").json(&request).await;
response.assert_status(StatusCode::SERVICE_UNAVAILABLE);
}
#[test]
fn test_inference_request_serialization() {
let request = InferenceRequest {
model: "test-model".to_string(),
prompt: "Hello, world!".to_string(),
max_tokens: Some(100),
temperature: Some(0.7),
stream: Some(false),
};
let json = serde_json::to_string(&request).unwrap();
assert!(json.contains("test-model"));
assert!(json.contains("Hello, world!"));
}
#[test]
fn test_app_state_default_uses_byte_level_tokenizer() {
let state = AppState::default();
assert!(matches!(state.tokenizer, ServingTokenizer::ByteLevel));
let ids = state.tokenizer.encode("hi");
assert_eq!(state.tokenizer.decode(&ids), "hi");
}
#[test]
fn test_inference_response_serialization() {
let response = InferenceResponse {
text: "Hello back!".to_string(),
finish_reason: "stop".to_string(),
usage: TokenUsage {
prompt_tokens: 3,
completion_tokens: 2,
total_tokens: 5,
},
};
let json = serde_json::to_string(&response).unwrap();
assert!(json.contains("Hello back!"));
assert!(json.contains("stop"));
}
}