mod openai;
mod pool;
use std::convert::Infallible;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::time::{SystemTime, UNIX_EPOCH};
use axum::extract::{Path, Request, State};
use axum::http::{HeaderMap, StatusCode};
use axum::middleware::{self, Next};
use axum::response::sse::{Event, KeepAlive, Sse};
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use axum::{Json, Router};
use offgrid::{ChatRequest, EmbedParams, Embedder, GpuLayers, Model, ModelParams, SessionParams, StreamEvent};
use serde_json::{Value, json};
use tokio::sync::mpsc;
use tokio_stream::wrappers::ReceiverStream;
pub use openai::ApiError;
use pool::ChatPool;
#[derive(Debug, Clone, Default)]
pub struct ServerConfig {
pub model: Option<PathBuf>,
pub mmproj: Option<PathBuf>,
pub embedding_model: Option<PathBuf>,
pub alias: Option<String>,
pub embedding_alias: Option<String>,
pub model_params: ModelParams,
pub session: SessionParams,
pub parallel: usize,
pub api_key: Option<String>,
pub max_body_bytes: usize,
}
pub const DEFAULT_MAX_BODY_BYTES: usize = 64 << 20;
pub struct AppState {
max_body_bytes: usize,
shutting_down: std::sync::atomic::AtomicBool,
chat: Option<(String, Arc<ChatPool>)>,
embed: Option<(String, Arc<Mutex<Embedder>>)>,
api_key: Option<String>,
created: u64,
}
fn now() -> u64 {
SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_secs())
}
fn model_id(path: &std::path::Path, alias: &Option<String>) -> String {
alias
.clone()
.unwrap_or_else(|| path.file_stem().map_or_else(|| "model".into(), |s| s.to_string_lossy().into_owned()))
}
impl AppState {
pub fn load(config: &ServerConfig) -> offgrid::Result<Arc<Self>> {
let chat = match &config.model {
Some(path) => {
let params = ModelParams { mmproj: config.mmproj.clone(), ..config.model_params.clone() };
let model = load_model(path, ¶ms)?;
let pool = ChatPool::new(model, &config.session, config.parallel)?;
Some((model_id(path, &config.alias), pool))
}
None => None,
};
let embed = match &config.embedding_model {
Some(path) => {
let params = ModelParams { mmproj: None, ..config.model_params.clone() };
let model = load_model(path, ¶ms)?;
let emb = Embedder::new(model, &EmbedParams::default())?;
Some((model_id(path, &config.embedding_alias), Arc::new(Mutex::new(emb))))
}
None => None,
};
if chat.is_none() && embed.is_none() {
return Err(offgrid::Error::InvalidArgument("no model configured (--model / --embedding-model)".into()));
}
Ok(Arc::new(Self {
shutting_down: std::sync::atomic::AtomicBool::new(false),
max_body_bytes: if config.max_body_bytes == 0 { DEFAULT_MAX_BODY_BYTES } else { config.max_body_bytes },
chat,
embed,
api_key: config.api_key.clone().filter(|k| !k.is_empty()),
created: now(),
}))
}
pub fn shutdown(&self) {
self.shutting_down.store(true, std::sync::atomic::Ordering::SeqCst);
if let Some((_, pool)) = &self.chat {
pool.cancel_all();
}
}
fn is_shutting_down(&self) -> bool {
self.shutting_down.load(std::sync::atomic::Ordering::SeqCst)
}
}
pub fn router(state: Arc<AppState>) -> Router {
let v1 = Router::new()
.route("/chat/completions", post(chat_completions))
.route("/embeddings", post(embeddings))
.route("/models", get(list_models))
.route("/models/{id}", get(get_model))
.layer(middleware::from_fn_with_state(state.clone(), auth))
.layer(axum::extract::DefaultBodyLimit::max(state.max_body_bytes));
Router::new()
.route("/health", get(|| async { Json(json!({"status": "ok"})) }))
.nest("/v1", v1)
.fallback(|| async { ApiError::not_found("unknown endpoint") })
.layer(middleware::map_response(json_charset))
.with_state(state)
}
async fn json_charset(mut res: Response) -> Response {
let ct = axum::http::header::CONTENT_TYPE;
if res.headers().get(&ct).is_some_and(|v| v == "application/json") {
res.headers_mut().insert(ct, axum::http::HeaderValue::from_static("application/json; charset=utf-8"));
}
res
}
async fn auth(State(state): State<Arc<AppState>>, headers: HeaderMap, req: Request, next: Next) -> Response {
if let Some(key) = &state.api_key {
let ok = headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.is_some_and(|given| given == key);
if !ok {
return ApiError::new(StatusCode::UNAUTHORIZED, "invalid_api_key", "invalid or missing API key")
.into_response();
}
}
next.run(req).await
}
fn model_entry(state: &AppState, id: &str) -> Value {
json!({"id": id, "object": "model", "created": state.created, "owned_by": "offgrid"})
}
async fn list_models(State(state): State<Arc<AppState>>) -> Json<Value> {
let data: Vec<Value> = state
.chat
.iter()
.map(|(id, _)| id)
.chain(state.embed.iter().map(|(id, _)| id))
.map(|id| model_entry(&state, id))
.collect();
Json(json!({"object": "list", "data": data}))
}
async fn get_model(State(state): State<Arc<AppState>>, Path(id): Path<String>) -> Response {
let known = state.chat.iter().map(|(i, _)| i).chain(state.embed.iter().map(|(i, _)| i)).any(|i| *i == id);
if known {
Json(model_entry(&state, &id)).into_response()
} else {
ApiError::not_found("model not found").into_response()
}
}
struct CancelOnDrop(offgrid::RequestCancel);
impl Drop for CancelOnDrop {
fn drop(&mut self) {
self.0.cancel();
}
}
struct CancellingStream<S> {
inner: S,
_cancel: CancelOnDrop,
}
impl<S: futures_util::Stream + Unpin> futures_util::Stream for CancellingStream<S> {
type Item = S::Item;
fn poll_next(
mut self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Option<Self::Item>> {
std::pin::Pin::new(&mut self.inner).poll_next(cx)
}
}
fn body_error(status: StatusCode, detail: String) -> Response {
let kind = if status == StatusCode::PAYLOAD_TOO_LARGE { "request_too_large" } else { "invalid_request_error" };
ApiError::new(status, kind, detail).into_response()
}
async fn chat_completions(
State(state): State<Arc<AppState>>,
body: Result<axum::body::Bytes, axum::extract::rejection::BytesRejection>,
) -> Response {
let body = match body {
Ok(b) => b,
Err(e) => return body_error(e.status(), e.body_text()),
};
let Some((model_id, pool)) = state.chat.clone() else {
return ApiError::bad_request("no chat model loaded").into_response();
};
let body: Value = match serde_json::from_slice(&body) {
Ok(v) => v,
Err(e) => return ApiError::bad_request(format!("invalid JSON body: {e}")).into_response(),
};
if body.get("n").and_then(Value::as_u64).is_some_and(|n| n != 1) {
return ApiError::bad_request("only n=1 is supported").into_response();
}
let stream = body.get("stream").and_then(Value::as_bool).unwrap_or(false);
let include_usage = body.pointer("/stream_options/include_usage").and_then(Value::as_bool).unwrap_or(false);
let req: ChatRequest = match serde_json::from_value(body) {
Ok(r) => r,
Err(e) => return ApiError::bad_request(format!("invalid request: {e}")).into_response(),
};
if req.messages.is_empty() {
return ApiError::bad_request("messages must not be empty").into_response();
}
let meta = openai::Meta::new(model_id);
let lease = pool.acquire().await;
if state.is_shutting_down() {
return ApiError::new(StatusCode::SERVICE_UNAVAILABLE, "server_error", "server is shutting down")
.into_response();
}
let cancel = CancelOnDrop(lease.next_request_cancel());
if stream {
let (tx, rx) = mpsc::channel::<Result<Event, Infallible>>(64);
tokio::task::spawn_blocking(move || {
let mut tool_index = 0usize;
let send = |v: Value| tx.blocking_send(Ok(Event::default().data(v.to_string()))).is_ok();
if !send(meta.chunk(json!({"role": "assistant", "content": ""}), None)) {
return;
}
let result = lease.session().complete_stream(&req, |event| {
let delta = match event {
StreamEvent::Content(t) => json!({"content": t}),
StreamEvent::Reasoning(t) => json!({"reasoning_content": t}),
StreamEvent::ToolCall(c) => {
tool_index += 1;
json!({"tool_calls": [openai::tool_call_delta(tool_index - 1, c)]})
}
};
send(meta.chunk(delta, None))
});
match result {
Ok(resp) => {
send(meta.chunk(json!({}), Some(openai::finish_reason(resp.finish_reason))));
if include_usage {
send(meta.usage_chunk(&resp.usage));
}
}
Err(e) => {
send(json!({"error": {"message": e.to_string(), "type": "server_error"}}));
}
}
let _ = tx.blocking_send(Ok(Event::default().data("[DONE]")));
});
let body = CancellingStream { inner: ReceiverStream::new(rx), _cancel: cancel };
Sse::new(body).keep_alive(KeepAlive::default()).into_response()
} else {
let _cancel = cancel; let joined = tokio::task::spawn_blocking(move || lease.session().complete_stream(&req, |_| true)).await;
match joined {
Ok(Ok(resp)) => Json(meta.completion(&resp)).into_response(),
Ok(Err(e)) => ApiError::from_core(e).into_response(),
Err(e) => ApiError::internal(format!("worker failed: {e}")).into_response(),
}
}
}
async fn embeddings(
State(state): State<Arc<AppState>>,
body: Result<Json<Value>, axum::extract::rejection::JsonRejection>,
) -> Response {
let body = match body {
Ok(Json(b)) => b,
Err(e) => return body_error(e.status(), e.body_text()),
};
let Some((model_id, embedder)) = state.embed.clone() else {
return ApiError::bad_request("no embedding model loaded (--embedding-model)").into_response();
};
let inputs: Vec<String> = match body.get("input") {
Some(Value::String(s)) => vec![s.clone()],
Some(Value::Array(items)) if items.iter().all(Value::is_string) => {
items.iter().filter_map(|v| v.as_str().map(String::from)).collect()
}
Some(Value::Array(_)) => {
return ApiError::bad_request(
"input must be a string or an array of strings (token arrays are not supported)",
)
.into_response();
}
_ => return ApiError::bad_request("missing input").into_response(),
};
if inputs.is_empty() || inputs.iter().any(String::is_empty) {
return ApiError::bad_request("input must not be empty").into_response();
}
let base64 = match body.get("encoding_format").and_then(Value::as_str) {
None | Some("float") => false,
Some("base64") => true,
Some(other) => return ApiError::bad_request(format!("unsupported encoding_format '{other}'")).into_response(),
};
let joined = tokio::task::spawn_blocking(move || {
let mut emb = embedder.lock().unwrap_or_else(|e| e.into_inner());
let tokens: usize = inputs.iter().map(|s| emb.model().tokenize(s, true, false).map_or(0, |t| t.len())).sum();
emb.embed(&inputs).map(|v| (v, tokens))
})
.await;
match joined {
Ok(Ok((vectors, tokens))) => Json(openai::embeddings(&model_id, &vectors, tokens, base64)).into_response(),
Ok(Err(e)) => ApiError::from_core(e).into_response(),
Err(e) => ApiError::internal(format!("worker failed: {e}")).into_response(),
}
}
fn load_model(path: &std::path::Path, params: &ModelParams) -> offgrid::Result<Arc<Model>> {
log::info!("loading {}", path.display());
let start = std::time::Instant::now();
let model = Model::load(path, params)?;
log::info!("loaded {} in {:.1}s", path.display(), start.elapsed().as_secs_f64());
Ok(model)
}
pub fn parse_gpu_layers(s: &str) -> Result<GpuLayers, String> {
match s {
"all" => Ok(GpuLayers::All),
"none" | "0" => Ok(GpuLayers::None),
n => n.parse().map(GpuLayers::Count).map_err(|_| format!("expected all, none or a number, got '{n}'")),
}
}