feat: add doc endpoint

This commit is contained in:
2026-04-11 20:23:11 +02:00
parent fc391b5d0e
commit e1f46c07b4
7 changed files with 244 additions and 37 deletions
+46
View File
@@ -0,0 +1,46 @@
use utoipa::OpenApi;
use crate::dto::api;
use crate::routes;
#[derive(OpenApi)]
#[openapi(
paths(
routes::v1::chat::completions,
routes::v1::chat::chat_completions,
// routes::v1::models::list_models,
// routes::v1::models::load_model,
// routes::v1::models::unload_model,
),
components(
schemas(
api::ErrorResponse,
api::ModelsResponse,
api::ModelInfo,
api::LoadModelResponse,
api::LoadModelBody,
api::UnloadModelResponse,
api::BaseLLMRequest,
api::CompletionRequest,
api::CompletionObject,
api::FinishReason,
api::CompletionResponse,
api::Choice,
api::Usage,
api::CompletionChunk,
api::ChatRequest,
api::Message,
api::Role,
api::ChatCompletionResponse,
api::ChatChoice,
api::ChatCompletionChunk,
api::ChatChunkChoice,
api::ChatDelta,
)
),
tags(
(name = "chat", description = "Chat & completions"),
(name = "models", description = "Model management")
)
)]
pub struct ApiDoc;
+14 -3
View File
@@ -1,6 +1,17 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use utoipa::ToSchema; use utoipa::ToSchema;
#[derive(Debug, Serialize, ToSchema)]
pub struct ErrorResponse {
pub error: String,
}
impl ErrorResponse {
pub fn new(msg: impl Into<String>) -> Self {
Self { error: msg.into() }
}
}
#[derive(Debug, Serialize, Deserialize, ToSchema)] #[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct ModelsResponse { pub struct ModelsResponse {
pub models: Vec<ModelInfo>, pub models: Vec<ModelInfo>,
@@ -148,21 +159,21 @@ pub struct ChatChoice {
pub finish_reason: FinishReason, pub finish_reason: FinishReason,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, ToSchema)]
pub struct ChatCompletionChunk { pub struct ChatCompletionChunk {
pub id: String, pub id: String,
pub object: String, pub object: String,
pub choices: Vec<ChatChunkChoice>, pub choices: Vec<ChatChunkChoice>,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, ToSchema)]
pub struct ChatChunkChoice { pub struct ChatChunkChoice {
pub index: u32, pub index: u32,
pub delta: ChatDelta, pub delta: ChatDelta,
pub finish_reason: Option<FinishReason>, pub finish_reason: Option<FinishReason>,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, ToSchema)]
pub struct ChatDelta { pub struct ChatDelta {
pub role: Option<Role>, pub role: Option<Role>,
pub content: Option<String>, pub content: Option<String>,
+1 -1
View File
@@ -1,7 +1,7 @@
mod auth; mod auth;
mod docs;
mod dto; mod dto;
mod errors; mod errors;
mod openapi;
mod providers; mod providers;
mod routes; mod routes;
mod state; mod state;
-18
View File
@@ -1,18 +0,0 @@
// use utoipa::OpenApi;
// #[derive(OpenApi)]
// #[openapi(
// paths(
// crate::routes::v1::chat::completions
// ),
// components(
// schemas(
// // add your request/response structs here later
// )
// ),
// tags(
// (name = "chat", description = "Chat endpoints"),
// (name = "models", description = "Model management")
// )
// )]
// pub struct V1ApiDoc;
+82 -9
View File
@@ -13,32 +13,105 @@ use crate::state::app_state::AppState;
#[utoipa::path( #[utoipa::path(
post, post,
path = "/chat/completions", path = "/completions",
tag = "chat",
request_body(
content = api::CompletionRequest,
description = "Text completion request",
content_type = "application/json"
),
responses( responses(
(status = 200, description = "Chat completion", body = Value), (
status = 200,
description = "Text completion response. If stream=true, response is SSE stream of chunks ending in [DONE].",
body = api::CompletionResponse,
content_type = "application/json"
),
(
status = 400,
description = "Invalid request: missing prompt, model, or invalid format",
body = api::ErrorResponse,
example = json!({ "error": "prompt is required and cannot be empty" })
),
(
status = 422,
description = "Model not found or not available locally",
body = api::ErrorResponse,
example = json!({ "error": "model 'llama3' is not available — run `ollama pull llama3` first" })
),
(
status = 500,
description = "Internal server error (Ollama or network failure)",
body = api::ErrorResponse,
example = json!({ "error": "connection refused" })
)
) )
)] )]
pub async fn completions( pub async fn completions(
State(state): State<AppState>, State(state): State<AppState>,
Json(body): Json<api::CompletionRequest>, Json(body): Json<api::CompletionRequest>,
) -> Result<Response, (axum::http::StatusCode, String)> { ) -> Result<Response, (axum::http::StatusCode, Json<api::ErrorResponse>)> {
if body.base.stream { if body.base.stream {
let stream = state let stream = state.ollama.completions_stream(&body).await.map_err(|e| {
.ollama let (code, msg) = ollama_err(e);
.completions_stream(&body) (code, Json(api::ErrorResponse::new(msg)))
.await })?;
.map_err(ollama_err)?;
Ok(Sse::new(stream) Ok(Sse::new(stream)
.keep_alive(KeepAlive::default()) .keep_alive(KeepAlive::default())
.into_response()) .into_response())
} else { } else {
let response = state.ollama.completions(&body).await.map_err(ollama_err)?; let response = state.ollama.completions(&body).await.map_err(|e| {
let (code, msg) = ollama_err(e);
(code, Json(api::ErrorResponse::new(msg)))
})?;
Ok(Json(response).into_response()) Ok(Json(response).into_response())
} }
} }
#[utoipa::path(
post,
path = "/chat/completions",
tag = "chat",
request_body(
content = api::ChatRequest,
description = "Chat completion request with message history",
content_type = "application/json"
),
responses(
(
status = 200,
description = "Chat completion response. If stream=false returns JSON. If stream=true returns SSE stream of chunks ending with [DONE].",
body = api::ChatCompletionResponse,
content_type = "application/json"
),
(
status = 400,
description = "Invalid request",
body = api::ErrorResponse,
example = json!({ "error": "messages array with at least one user message is required" })
),
(
status = 401,
description = "Unauthorized",
body = api::ErrorResponse,
example = json!({ "error": "missing or invalid token" })
),
(
status = 422,
description = "Model not found or unavailable",
body = api::ErrorResponse,
example = json!({ "error": "model 'llama3' is not available — run `ollama pull llama3` first" })
),
(
status = 500,
description = "Internal server error",
body = api::ErrorResponse,
example = json!({ "error": "connection refused" })
)
)
)]
pub async fn chat_completions( pub async fn chat_completions(
State(state): State<AppState>, State(state): State<AppState>,
Json(body): Json<api::ChatRequest>, Json(body): Json<api::ChatRequest>,
+11 -6
View File
@@ -1,14 +1,19 @@
pub mod chat; pub mod chat;
pub mod models; pub mod models;
mod openapi;
use crate::auth::middleware::auth_middleware; use crate::auth::middleware::auth_middleware;
use crate::docs::ApiDoc;
use crate::state::app_state::AppState; use crate::state::app_state::AppState;
use axum::{Router, middleware, routing::get, routing::post}; use axum::{Json, Router, middleware, routing::get, routing::post};
use utoipa::OpenApi;
// fn public_router() -> Router<AppState> { async fn openapi_json() -> Json<utoipa::openapi::OpenApi> {
// Router::new().route("/openapi.json", get(openapi::openapi_json)) Json(ApiDoc::openapi())
// } }
fn public_router() -> Router<AppState> {
Router::new().route("/openapi.json", get(openapi_json))
}
pub fn protected_router() -> Router<AppState> { pub fn protected_router() -> Router<AppState> {
Router::new() Router::new()
@@ -21,6 +26,6 @@ pub fn protected_router() -> Router<AppState> {
pub fn router() -> Router<AppState> { pub fn router() -> Router<AppState> {
Router::new() Router::new()
// .merge(public_router()) .merge(public_router())
.merge(protected_router().layer(middleware::from_fn(auth_middleware))) .merge(protected_router().layer(middleware::from_fn(auth_middleware)))
} }
+90
View File
@@ -6,6 +6,25 @@ use axum::{
use crate::dto::api; use crate::dto::api;
use crate::{errors::OllamaError, state::app_state::AppState}; use crate::{errors::OllamaError, state::app_state::AppState};
#[utoipa::path(
get,
path = "/models",
tag = "models",
responses(
(
status = 200,
description = "List of locally available Ollama models",
body = api::ModelsResponse,
content_type = "application/json",
),
(
status = 500,
description = "Internal server error (Ollama or network failure)",
body = api::ErrorResponse,
example = json!({ "error": "connection refused" })
)
)
)]
pub async fn list_models( pub async fn list_models(
State(state): State<AppState>, State(state): State<AppState>,
) -> Result<Json<api::ModelsResponse>, (axum::http::StatusCode, String)> { ) -> Result<Json<api::ModelsResponse>, (axum::http::StatusCode, String)> {
@@ -15,6 +34,49 @@ pub async fn list_models(
} }
} }
#[utoipa::path(
post,
path = "/models/{model}/load",
tag = "models",
params(
("model" = String, Path, description = "Name of the model to load into memory (e.g. 'llama3')")
),
request_body(
content = api::LoadModelBody,
description = "Load model request",
content_type = "application/json",
example = json!({ "keep_alive": "10m" })
),
responses(
(
status = 200,
description = "Model successfully loaded into memory",
body = api::LoadModelResponse,
content_type = "application/json",
),
(
status = 400,
description = "Invalid or missing keep_alive format",
body = api::ErrorResponse,
examples(
("Missing" = (value = json!({ "error": "keep alive is required and cannot be empty" }))),
("Invalid" = (value = json!({ "error": "invalid keep_alive '10x' — use 30s / 10m / 2h, a plain integer, or -1" })))
)
),
(
status = 404,
description = "Model not found locally",
body = api::ErrorResponse,
example = json!({ "error": "model 'llama3' not found — run `ollama pull llama3`" })
),
(
status = 500,
description = "Internal server error (Ollama or network failure)",
body = api::ErrorResponse,
example = json!({ "error": "connection refused" })
)
)
)]
pub async fn load_model( pub async fn load_model(
State(state): State<AppState>, State(state): State<AppState>,
Path(model): Path<String>, Path(model): Path<String>,
@@ -29,6 +91,34 @@ pub async fn load_model(
Ok(Json(response)) Ok(Json(response))
} }
#[utoipa::path(
delete,
path = "/models/{model}/load",
tag = "models",
params(
("model" = String, Path, description = "Name of the model to unload from memory (e.g. 'llama3')")
),
responses(
(
status = 200,
description = "Model successfully unloaded from memory",
body = api::UnloadModelResponse,
content_type = "application/json",
),
(
status = 404,
description = "Model not found locally",
body = api::ErrorResponse,
example = json!({ "error": "model 'llama3' not found — run `ollama pull llama3`" })
),
(
status = 500,
description = "Internal server error (Ollama or network failure)",
body = api::ErrorResponse,
example = json!({ "error": "connection refused" })
)
)
)]
pub async fn unload_model( pub async fn unload_model(
State(state): State<AppState>, State(state): State<AppState>,
Path(model): Path<String>, Path(model): Path<String>,