chore: scaffold Phase 0 - Tauri2 + Rust + React + llama.cpp integration
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "xianren-api"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
reqwest.workspace = true
|
||||
axum.workspace = true
|
||||
tracing.workspace = true
|
||||
thiserror.workspace = true
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
mod server;
|
||||
|
||||
pub use server::{start, ApiServer, ApiState};
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
use axum::body::Body;
|
||||
use axum::extract::{Request, State};
|
||||
use axum::http::{header, StatusCode};
|
||||
use axum::middleware::{self, Next};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::{get, post};
|
||||
use axum::{Json, Router};
|
||||
use serde_json::{json, Value};
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use thiserror::Error;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ApiState {
|
||||
pub engine_base: Arc<RwLock<Option<String>>>,
|
||||
pub list_models: Arc<dyn Fn() -> Vec<Value> + Send + Sync>,
|
||||
pub api_key: Option<String>,
|
||||
}
|
||||
|
||||
impl ApiState {
|
||||
pub fn new(
|
||||
engine_base: Arc<RwLock<Option<String>>>,
|
||||
list_models: impl Fn() -> Vec<Value> + Send + Sync + 'static,
|
||||
api_key: Option<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
engine_base,
|
||||
list_models: Arc::new(list_models),
|
||||
api_key,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ApiServer {
|
||||
pub addr: SocketAddr,
|
||||
task: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl ApiServer {
|
||||
pub fn shutdown(self) {
|
||||
self.task.abort();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ApiError {
|
||||
#[error("io error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, ApiError>;
|
||||
|
||||
/// 启动 OpenAI 兼容本地 API 服务。
|
||||
pub async fn start(port: u16, state: ApiState) -> Result<ApiServer> {
|
||||
let app = Router::new()
|
||||
.route("/health", get(health))
|
||||
.route("/v1/models", get(list_models))
|
||||
.route("/v1/chat/completions", post(chat_completions))
|
||||
.route("/v1/embeddings", post(embeddings))
|
||||
.layer(middleware::from_fn_with_state(state.clone(), auth))
|
||||
.with_state(state);
|
||||
|
||||
let listener = TcpListener::bind(("127.0.0.1", port)).await?;
|
||||
let addr = listener.local_addr()?;
|
||||
tracing::info!(%addr, "local api server listening");
|
||||
let task = tokio::spawn(async move {
|
||||
if let Err(e) = axum::serve(listener, app).await {
|
||||
tracing::error!(error = %e, "api server stopped");
|
||||
}
|
||||
});
|
||||
Ok(ApiServer { addr, task })
|
||||
}
|
||||
|
||||
async fn health() -> impl IntoResponse {
|
||||
Json(json!({ "status": "ok" }))
|
||||
}
|
||||
|
||||
async fn list_models(State(state): State<ApiState>) -> impl IntoResponse {
|
||||
Json(json!({
|
||||
"object": "list",
|
||||
"data": (state.list_models)()
|
||||
}))
|
||||
}
|
||||
|
||||
async fn chat_completions(State(state): State<ApiState>, body: Json<Value>) -> Response {
|
||||
let base = state.engine_base.read().await.clone();
|
||||
let Some(base) = base else {
|
||||
return (
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(json!({ "error": { "message": "no local engine is running" } })),
|
||||
)
|
||||
.into_response();
|
||||
};
|
||||
|
||||
let wants_stream = body
|
||||
.get("stream")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let client = reqwest::Client::new();
|
||||
match client
|
||||
.post(format!("{base}/v1/chat/completions"))
|
||||
.json(&body.0)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) => {
|
||||
if wants_stream {
|
||||
let stream = resp.bytes_stream();
|
||||
Response::builder()
|
||||
.header(header::CONTENT_TYPE, "text/event-stream")
|
||||
.header(header::CACHE_CONTROL, "no-cache")
|
||||
.body(Body::from_stream(stream))
|
||||
.unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
|
||||
} else {
|
||||
match resp.bytes().await {
|
||||
Ok(bytes) => match serde_json::from_slice::<Value>(&bytes) {
|
||||
Ok(value) => Json(value).into_response(),
|
||||
Err(e) => (
|
||||
StatusCode::BAD_GATEWAY,
|
||||
Json(json!({ "error": { "message": format!("bad upstream response: {e}") } })),
|
||||
)
|
||||
.into_response(),
|
||||
},
|
||||
Err(e) => (
|
||||
StatusCode::BAD_GATEWAY,
|
||||
Json(json!({ "error": { "message": e.to_string() } })),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => (
|
||||
StatusCode::BAD_GATEWAY,
|
||||
Json(json!({ "error": { "message": e.to_string() } })),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn embeddings(State(state): State<ApiState>, body: Json<Value>) -> Response {
|
||||
let base = state.engine_base.read().await.clone();
|
||||
let Some(base) = base else {
|
||||
return (
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(json!({ "error": { "message": "no local engine is running" } })),
|
||||
)
|
||||
.into_response();
|
||||
};
|
||||
let client = reqwest::Client::new();
|
||||
match client
|
||||
.post(format!("{base}/v1/embeddings"))
|
||||
.json(&body.0)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) => match resp.bytes().await {
|
||||
Ok(bytes) => match serde_json::from_slice::<Value>(&bytes) {
|
||||
Ok(value) => Json(value).into_response(),
|
||||
Err(e) => (
|
||||
StatusCode::BAD_GATEWAY,
|
||||
Json(json!({ "error": { "message": format!("bad upstream response: {e}") } })),
|
||||
)
|
||||
.into_response(),
|
||||
},
|
||||
Err(e) => (
|
||||
StatusCode::BAD_GATEWAY,
|
||||
Json(json!({ "error": { "message": e.to_string() } })),
|
||||
)
|
||||
.into_response(),
|
||||
},
|
||||
Err(e) => (
|
||||
StatusCode::BAD_GATEWAY,
|
||||
Json(json!({ "error": { "message": e.to_string() } })),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn auth(State(state): State<ApiState>, req: Request, next: Next) -> Response {
|
||||
if let Some(expected) = &state.api_key {
|
||||
if expected.is_empty() {
|
||||
return next.run(req).await;
|
||||
}
|
||||
let provided = req
|
||||
.headers()
|
||||
.get(header::AUTHORIZATION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.strip_prefix("Bearer "))
|
||||
.map(|v| v.to_string());
|
||||
if provided.as_deref() != Some(expected.as_str()) {
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({ "error": { "message": "invalid api key" } })),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
next.run(req).await
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user