use std::sync::Arc;
use axum::{
extract::State,
http::StatusCode,
response::IntoResponse,
routing::{get, post},
Json, Router,
};
use serde::{Deserialize, Serialize};
use serde_json::json;
use crate::{PredictConfig, PredictHost, PREDICT_CONFIG_PREF};
#[derive(Clone)]
pub struct PredictCtx {
host: Arc<dyn PredictHost>,
}
impl PredictCtx {
pub fn new(host: Arc<dyn PredictHost>) -> Self {
Self { host }
}
}
pub fn routes(ctx: PredictCtx) -> Router<()> {
Router::new()
.route("/config", get(get_config).put(put_config))
.route("/complete", post(complete))
.with_state(ctx)
}
pub fn openapi() -> utoipa::openapi::OpenApi {
<PredictApiDoc as utoipa::OpenApi>::openapi()
}
#[derive(utoipa::OpenApi)]
#[openapi(paths(complete, get_config, put_config))]
struct PredictApiDoc;
fn json_error(status: StatusCode, msg: String) -> axum::response::Response {
(status, Json(json!({ "error": msg }))).into_response()
}
async fn load_config(ctx: &PredictCtx) -> PredictConfig {
let raw = ctx.host.pref_get(PREDICT_CONFIG_PREF).await;
PredictConfig::from_pref(raw.as_deref())
}
async fn resolve_model(ctx: &PredictCtx, config: &PredictConfig, agent_id: Option<&str>) -> String {
if let Some(id) = agent_id.filter(|s| !s.is_empty()) {
if let Some(m) = ctx
.host
.agent_bound_model(id)
.await
.filter(|m| !m.trim().is_empty())
{
return m;
}
}
let configured = config.model.trim();
if !configured.is_empty() {
return configured.to_string();
}
for var in ["RYU_PREDICT_MODEL", "RYU_DEFAULT_LLM_MODEL"] {
if let Ok(val) = std::env::var(var) {
if !val.is_empty() {
return val;
}
}
}
ctx.host.default_model()
}
#[utoipa::path(
get,
path = "/api/predict/config",
tag = "Predict",
summary = "Get predictive-typing config",
responses((status = 200, description = "OK", body = serde_json::Value))
)]
pub async fn get_config(State(ctx): State<PredictCtx>) -> Json<PredictConfig> {
Json(load_config(&ctx).await)
}
#[utoipa::path(
put,
path = "/api/predict/config",
tag = "Predict",
summary = "Update predictive-typing config",
request_body = serde_json::Value,
responses((status = 200, description = "OK", body = serde_json::Value))
)]
pub async fn put_config(
State(ctx): State<PredictCtx>,
Json(config): Json<PredictConfig>,
) -> axum::response::Response {
let raw = match serde_json::to_string(&config) {
Ok(s) => s,
Err(e) => {
return json_error(StatusCode::BAD_REQUEST, format!("invalid config: {e}"));
}
};
match ctx.host.pref_set(PREDICT_CONFIG_PREF, &raw).await {
Ok(()) => Json(config).into_response(),
Err(e) => json_error(
StatusCode::INTERNAL_SERVER_ERROR,
format!("failed to persist config: {e}"),
),
}
}
#[derive(Debug, Deserialize)]
pub struct CompleteBody {
#[serde(default)]
pub context: String,
#[serde(default)]
pub app: Option<String>,
#[serde(default)]
pub control: Option<String>,
#[serde(default, rename = "agentId")]
pub agent_id: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct CompleteResponse {
pub suggestion: String,
pub model: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
fn refused(reason: &str) -> Json<CompleteResponse> {
Json(CompleteResponse {
suggestion: String::new(),
model: String::new(),
reason: Some(reason.to_string()),
})
}
#[utoipa::path(
post,
path = "/api/predict/complete",
tag = "Predict",
summary = "Predict the inline continuation for caret context",
request_body = serde_json::Value,
responses((status = 200, description = "OK", body = serde_json::Value))
)]
pub async fn complete(
State(ctx): State<PredictCtx>,
Json(body): Json<CompleteBody>,
) -> axum::response::Response {
if !ctx.host.is_enabled() {
return refused("predictive typing plugin is disabled").into_response();
}
let config = load_config(&ctx).await;
if let Some(control) = body.control.as_deref() {
if crate::is_secure_control(control) {
return refused("secure field").into_response();
}
}
if let Some(app) = body.app.as_deref() {
if !crate::app_allowed(&config.app_allowlist, app) {
return refused("app not in allowlist").into_response();
}
}
let context = body.context.trim();
if context.is_empty() {
return refused("no context").into_response();
}
let agent_id = body.agent_id.as_deref().or(config.agent_id.as_deref());
let model = resolve_model(&ctx, &config, agent_id).await;
let (system, user) = crate::build_messages(context);
match ctx
.host
.call_side_model(&model, config.effort.trim(), &system, &user)
.await
{
Ok(text) => {
let suggestion = crate::clean_suggestion(&text, config.max_chars);
Json(CompleteResponse {
suggestion,
model,
reason: None,
})
.into_response()
}
Err(e) => json_error(
StatusCode::BAD_GATEWAY,
format!("prediction model unavailable: {e}"),
),
}
}