Skip to main content

ryu_predict/
api.rs

1//! HTTP API for predictive typing (`/api/predict/*`): the system-wide inline
2//! autocomplete brain.
3//!
4//! - `GET  /api/predict/config`   → the normalized [`PredictConfig`] (defaults
5//!   applied) so the desktop settings tab and the `apps-store/predict` overlay both
6//!   read one shape in a single call.
7//! - `PUT  /api/predict/config`   → persist the config blob.
8//! - `POST /api/predict/complete` → given the caret context, return a single
9//!   inline suggestion. Enforces the app allowlist + the secure-field denylist
10//!   here (Core decides *what runs*), then hands the model call to the Gateway
11//!   via [`PredictHost::call_side_model`] (the same path `/btw` uses).
12//!
13//! Pure logic (prompt assembly, denylist, cleanup) lives in the crate root.
14//!
15//! The router is built with its own state ([`PredictCtx`]) inside this crate so it
16//! returns a state-less, mergeable `Router<()>`. Routes are declared relative to
17//! `/api/predict` (Core nests this service at that prefix behind the Predict-App
18//! gate), while the OpenAPI annotations keep the full external paths.
19
20use std::sync::Arc;
21
22use axum::{
23    extract::State,
24    http::StatusCode,
25    response::IntoResponse,
26    routing::{get, post},
27    Json, Router,
28};
29use serde::{Deserialize, Serialize};
30use serde_json::json;
31
32use crate::{PredictConfig, PredictHost, PREDICT_CONFIG_PREF};
33
34/// Router state for the predict HTTP surface: the [`PredictHost`] that inverts the
35/// kernel couplings (enabled flag, preferences, agent-bound model, default model,
36/// Gateway side-model call). Cloneable so the router bakes a concrete state and
37/// returns `Router<()>`.
38#[derive(Clone)]
39pub struct PredictCtx {
40    host: Arc<dyn PredictHost>,
41}
42
43impl PredictCtx {
44    pub fn new(host: Arc<dyn PredictHost>) -> Self {
45        Self { host }
46    }
47}
48
49/// Build the `/api/predict/*` router with its own state baked in, returning a
50/// state-less `Router<()>` the host nests at `/api/predict` behind the App gate.
51pub fn routes(ctx: PredictCtx) -> Router<()> {
52    Router::new()
53        .route("/config", get(get_config).put(put_config))
54        .route("/complete", post(complete))
55        .with_state(ctx)
56}
57
58/// The OpenAPI sub-document for the predict surface, merged into Core's spec.
59pub fn openapi() -> utoipa::openapi::OpenApi {
60    <PredictApiDoc as utoipa::OpenApi>::openapi()
61}
62
63#[derive(utoipa::OpenApi)]
64#[openapi(paths(complete, get_config, put_config))]
65struct PredictApiDoc;
66
67/// Build a JSON error response with a `{ "error": msg }` body and the given status.
68fn json_error(status: StatusCode, msg: String) -> axum::response::Response {
69    (status, Json(json!({ "error": msg }))).into_response()
70}
71
72/// Load the persisted config (defaults applied) for the current node.
73async fn load_config(ctx: &PredictCtx) -> PredictConfig {
74    let raw = ctx.host.pref_get(PREDICT_CONFIG_PREF).await;
75    PredictConfig::from_pref(raw.as_deref())
76}
77
78/// Resolve the model that answers predictions: an explicit `agent_id`'s bound
79/// model → `config.model` → env `RYU_PREDICT_MODEL`/`RYU_DEFAULT_LLM_MODEL` →
80/// the built-in default. Nothing hardcoded.
81async fn resolve_model(
82    ctx: &PredictCtx,
83    config: &PredictConfig,
84    agent_id: Option<&str>,
85) -> String {
86    // An explicit agent's bound chat model wins — it makes the prediction agent
87    // a real, swappable card.
88    if let Some(id) = agent_id.filter(|s| !s.is_empty()) {
89        if let Some(m) = ctx
90            .host
91            .agent_bound_model(id)
92            .await
93            .filter(|m| !m.trim().is_empty())
94        {
95            return m;
96        }
97    }
98    let configured = config.model.trim();
99    if !configured.is_empty() {
100        return configured.to_string();
101    }
102    for var in ["RYU_PREDICT_MODEL", "RYU_DEFAULT_LLM_MODEL"] {
103        if let Ok(val) = std::env::var(var) {
104            if !val.is_empty() {
105                return val;
106            }
107        }
108    }
109    ctx.host.default_model()
110}
111
112/// `GET /api/predict/config` — the normalized predictive-typing config.
113#[utoipa::path(
114    get,
115    path = "/api/predict/config",
116    tag = "Predict",
117    summary = "Get predictive-typing config",
118    responses((status = 200, description = "OK", body = serde_json::Value))
119)]
120pub async fn get_config(State(ctx): State<PredictCtx>) -> Json<PredictConfig> {
121    Json(load_config(&ctx).await)
122}
123
124/// `PUT /api/predict/config` — persist the predictive-typing config.
125#[utoipa::path(
126    put,
127    path = "/api/predict/config",
128    tag = "Predict",
129    summary = "Update predictive-typing config",
130    request_body = serde_json::Value,
131    responses((status = 200, description = "OK", body = serde_json::Value))
132)]
133pub async fn put_config(
134    State(ctx): State<PredictCtx>,
135    Json(config): Json<PredictConfig>,
136) -> axum::response::Response {
137    let raw = match serde_json::to_string(&config) {
138        Ok(s) => s,
139        Err(e) => {
140            return json_error(StatusCode::BAD_REQUEST, format!("invalid config: {e}"));
141        }
142    };
143    match ctx.host.pref_set(PREDICT_CONFIG_PREF, &raw).await {
144        Ok(()) => Json(config).into_response(),
145        Err(e) => json_error(
146            StatusCode::INTERNAL_SERVER_ERROR,
147            format!("failed to persist config: {e}"),
148        ),
149    }
150}
151
152/// `POST /api/predict/complete` request body. `context` is the text immediately
153/// before the caret (the model context). `app` is the focused process name (for
154/// the allowlist). `control` is the focused control's localized type (for the
155/// secure-field denylist). `agent_id` optionally overrides the model.
156#[derive(Debug, Deserialize)]
157pub struct CompleteBody {
158    #[serde(default)]
159    pub context: String,
160    #[serde(default)]
161    pub app: Option<String>,
162    #[serde(default)]
163    pub control: Option<String>,
164    #[serde(default, rename = "agentId")]
165    pub agent_id: Option<String>,
166}
167
168/// `POST /api/predict/complete` response. `suggestion` is empty when there is
169/// nothing to suggest OR the request was refused (disabled / app not allowed /
170/// secure field / no context) — `reason` says which, for diagnostics.
171#[derive(Debug, Serialize)]
172pub struct CompleteResponse {
173    pub suggestion: String,
174    pub model: String,
175    #[serde(skip_serializing_if = "Option::is_none")]
176    pub reason: Option<String>,
177}
178
179fn refused(reason: &str) -> Json<CompleteResponse> {
180    Json(CompleteResponse {
181        suggestion: String::new(),
182        model: String::new(),
183        reason: Some(reason.to_string()),
184    })
185}
186
187/// `POST /api/predict/complete` — return one inline suggestion for the caret
188/// context. Always 200 with a (possibly empty) suggestion; refusals carry a
189/// `reason` rather than an error status, so the dumb overlay never has to branch
190/// on HTTP codes.
191#[utoipa::path(
192    post,
193    path = "/api/predict/complete",
194    tag = "Predict",
195    summary = "Predict the inline continuation for caret context",
196    request_body = serde_json::Value,
197    responses((status = 200, description = "OK", body = serde_json::Value))
198)]
199pub async fn complete(
200    State(ctx): State<PredictCtx>,
201    Json(body): Json<CompleteBody>,
202) -> axum::response::Response {
203    // The built-in Predict plugin's enabled state is the single on/off switch
204    // (Core seeds it at boot and flips it live from the plugin enable/disable
205    // path). Cheap flag check before touching prefs; there is no separate config
206    // toggle any more.
207    if !ctx.host.is_enabled() {
208        return refused("predictive typing plugin is disabled").into_response();
209    }
210
211    let config = load_config(&ctx).await;
212
213    // Privacy floor: never read context or suggest in a password/secure field.
214    if let Some(control) = body.control.as_deref() {
215        if crate::is_secure_control(control) {
216            return refused("secure field").into_response();
217        }
218    }
219
220    // App allowlist (empty = all apps).
221    if let Some(app) = body.app.as_deref() {
222        if !crate::app_allowed(&config.app_allowlist, app) {
223            return refused("app not in allowlist").into_response();
224        }
225    }
226
227    let context = body.context.trim();
228    if context.is_empty() {
229        return refused("no context").into_response();
230    }
231
232    let agent_id = body.agent_id.as_deref().or(config.agent_id.as_deref());
233    let model = resolve_model(&ctx, &config, agent_id).await;
234    let (system, user) = crate::build_messages(context);
235
236    match ctx
237        .host
238        .call_side_model(&model, config.effort.trim(), &system, &user)
239        .await
240    {
241        Ok(text) => {
242            let suggestion = crate::clean_suggestion(&text, config.max_chars);
243            Json(CompleteResponse {
244                suggestion,
245                model,
246                reason: None,
247            })
248            .into_response()
249        }
250        Err(e) => json_error(
251            StatusCode::BAD_GATEWAY,
252            format!("prediction model unavailable: {e}"),
253        ),
254    }
255}