OXYGENHUB

Coverage / crates/hub/src/web/assistants.rs 88.3%

249 of 282 lines run, 36 of 84 functions · run #522 on main · run missed

1
//! The AI assistants panel (hub spec §13.4, #97) shared by the
2
//! administration, organisation and repository settings pages: the
3
//! level's own assistants, the ones it inherits, and its prompt.
4
5
use crate::assistants::{self, Assistant, Available, Fields, APIS, DEFAULT_BUDGET};
6
use crate::auth::Viewer;
7
use crate::db::accounts;
8
use crate::levels::At;
9
use crate::Shared;
10
use serde::Deserialize;
11
12
#[derive(Deserialize)]
13
pub struct AssistantForm {
14
    #[serde(rename = "_csrf")]
15
    pub csrf_token: String,
16
    #[serde(default)]
17
    action: String,
18
    #[serde(default)]
19
    id: Option<i64>,
20
    #[serde(default)]
21
    name: String,
22
    #[serde(default)]
23
    endpoint: String,
24
    #[serde(default)]
25
    api: String,
26
    #[serde(default)]
27
    model: String,
28
    #[serde(default)]
29
    headers: String,
30
    #[serde(default)]
31
    token: String,
32
    #[serde(default)]
33
    enabled: Option<String>,
34
    #[serde(default)]
35
    budget: String,
36
    #[serde(default)]
37
    prompt: String,
38
    /// A provider from the add form's list (#109), filling what is left empty.
39
    #[serde(default)]
40
    preset: String,
41
}
42
43
/// Everything the panel shows.
44
pub struct Panel {
45
    /// The level's own assistants, each with whether a level above turned
46
    /// its name off.
47
    pub own: Vec<Available>,
48
    /// The ones the levels above provide, outermost first.
49
    pub inherited: Vec<Available>,
50
    pub prompt: String,
51
    /// (where from, text) for each level above that set a prompt.
52
    pub inherited_prompts: Vec<(String, String)>,
53
    pub apis: Vec<&'static str>,
54
    pub default_budget: i64,
55
    pub presets: Vec<(&'static str, &'static str, &'static str, &'static str, &'static str)>,
56
}
57
58260×
pub async fn panel(app: &Shared, at: &At) -> sqlx::Result<Panel> {
59260×
    let (own, inherited): (Vec<_>, Vec<_>) =
60260×
        assistants::available(&app.db, at.scope, &at.owner).await?.into_iter().partition(|a| a.from.is_empty());
61
    Ok(Panel {
62260×
        own,
63260×
        inherited,
64260×
        prompt: assistants::prompt(&app.db, at.scope).await?,
65260×
        inherited_prompts: assistants::inherited_prompts(&app.db, at.scope, &at.owner).await?,
66260×
        apis: APIS.to_vec(),
67
        default_budget: DEFAULT_BUDGET,
68260×
        presets: assistants::PRESETS.to_vec(),
69
    })
70260×
}
71
7215×
fn name_ok(name: &str) -> bool {
7393×
    !name.is_empty() && name.chars().count() <= 40 && name.chars().all(|c| c.is_alphanumeric() || matches!(c, ' ' | '.' | '-' | '_'))
7415×
}
75
7615×
fn endpoint_ok(url: &str) -> bool {
7715×
    (url.starts_with("http://") || url.starts_with("https://")) && url.len() <= 2000 && !url.contains(char::is_whitespace)
7815×
}
79
80
impl AssistantForm {
813×
    fn clone_shallow(&self) -> AssistantForm {
823×
        AssistantForm {
833×
            csrf_token: String::new(),
843×
            action: self.action.clone(),
853×
            id: self.id,
863×
            name: self.name.clone(),
873×
            endpoint: self.endpoint.clone(),
883×
            api: self.api.clone(),
893×
            model: self.model.clone(),
903×
            headers: self.headers.clone(),
913×
            token: String::new(),
923×
            enabled: self.enabled.clone(),
933×
            budget: self.budget.clone(),
943×
            prompt: String::new(),
953×
            preset: self.preset.clone(),
963×
        }
973×
    }
98
}
99
100
/// When the endpoint lists its models, the model must be one of them
101
/// (#109); `Err` names a few it does offer.
10214×
async fn model_offered(fields: &Fields, token: &str, allow: &[String]) -> Result<(), String> {
10314×
    match assistants::list_models(&fields.endpoint, &fields.api, token, allow).await {
1044×
        Ok(models) if !models.iter().any(|m| m == &fields.model) => {
1051×
            let close: Vec<&String> = {
1061×
                let stem = fields.model.split(['-', '.']).next().unwrap_or("").to_lowercase();
1072×
                let near: Vec<&String> = models.iter().filter(|m| m.to_lowercase().contains(&stem)).take(8).collect();
1081×
                if near.is_empty() {
1090×
                    models.iter().take(8).collect()
110
                } else {
1111×
                    near
112
                }
113
            };
1141×
            Err(format!(
115
                "{} is not a model this endpoint offers. It offers, among others: {}. Use List models to pick one.",
116
                fields.model,
1172×
                close.iter().map(|m| m.as_str()).collect::<Vec<_>>().join(", ")
118
            ))
119
        }
12013×
        _ => Ok(()),
121
    }
12214×
}
123
124
/// The form's details checked: `Err` is the message for the person.
12515×
fn fields(f: &AssistantForm) -> Result<Fields, String> {
126
    let mut f_owned;
12793×
    let f = match assistants::PRESETS.iter().find(|p| p.0 == f.preset) {
1283×
        Some(&(_, _, name, endpoint, api)) => {
1293×
            f_owned = f.clone_shallow();
1303×
            if f_owned.name.trim().is_empty() {
1313×
                f_owned.name = name.into();
1323×
            }
1333×
            if f_owned.endpoint.trim().is_empty() {
1340×
                f_owned.endpoint = endpoint.into();
1353×
            }
1363×
            if !APIS.contains(&f_owned.api.as_str()) || f.endpoint.trim().is_empty() {
1373×
                f_owned.api = api.into();
1383×
            }
1393×
            &f_owned
140
        }
14112×
        None => f,
142
    };
14315×
    let name = f.name.trim().to_string();
14415×
    if !name_ok(&name) {
1450×
        return Err("An assistant's name is up to 40 letters, digits, spaces, dots, dashes or underscores.".into());
14615×
    }
14715×
    let endpoint = f.endpoint.trim().to_string();
14815×
    if !endpoint_ok(&endpoint) {
1491×
        return Err("The endpoint is the full URL the request is posted to, starting with https://.".into());
15014×
    }
15114×
    if !APIS.contains(&f.api.as_str()) {
1520×
        return Err("The API is chat-completions or anthropic-messages.".into());
15314×
    }
15414×
    let model = f.model.trim().to_string();
15514×
    if model.is_empty() || model.len() > 100 || model.contains(char::is_whitespace) {
1560×
        return Err("A model name is needed, such as claude-opus-5-5 or gpt-5.".into());
15714×
    }
15814×
    let headers = f.headers.trim().to_string();
15914×
    if let Err(line) = assistants::parse_headers(&headers) {
1600×
        return Err(format!("Extra headers are one Name: value per line; this line is not: {line}"));
16114×
    }
16214×
    if headers.lines().count() > 10 {
1630×
        return Err("Up to ten extra headers.".into());
16414×
    }
16514×
    let budget = if f.budget.trim().is_empty() {
1668×
        DEFAULT_BUDGET
167
    } else {
1686×
        match f.budget.trim().parse::<i64>() {
1696×
            Ok(n) if (1_000..=2_000_000).contains(&n) => n,
1700×
            _ => return Err("The context budget is a number of characters from 1000 to 2000000.".into()),
171
        }
172
    };
17314×
    Ok(Fields { name, endpoint, api: f.api.clone(), model, headers, enabled: f.enabled.is_some(), budget })
17415×
}
175
176
/// Apply the panel's form: `Ok(notice)` or `Err(message for the user)`.
17717×
pub async fn apply(app: &Shared, viewer: &Viewer, at: &At, f: AssistantForm) -> sqlx::Result<Result<String, String>> {
17817×
    let owned = |a: Option<Assistant>| a.filter(|a| a.scope() == at.scope);
17917×
    let actor = viewer.account();
18017×
    Ok(Ok(match (f.action.as_str(), f.id) {
18117×
        ("add", None) => {
1829×
            let fields = match fields(&f) {
1838×
                Ok(x) => x,
1841×
                Err(e) => return Ok(Err(e)),
185
            };
1868×
            if let Err(e) = model_offered(&fields, f.token.trim(), &app.config.outbound.allow).await {
1871×
                return Ok(Err(e));
1887×
            }
1897×
            let token = app.assistant_key.seal(f.token.trim());
1907×
            match assistants::create(&app.db, at.scope, &fields, &token).await? {
1916×
                Some(a) => {
1926×
                    accounts::audit(&app.db, actor, "assistant.added", &at.label, &a.name).await.ok();
193
                    // Try it at once, so a wrong endpoint or token shows here.
1946×
                    match assistants::test(app, &a).await {
1956×
                        Ok(answer) => format!("{} added and answered: {answer}", a.name),
1960×
                        Err(why) => {
1970×
                            format!("{} added, but did not answer: {why}. Check the endpoint, API and token, then TEST CONNECTION.", a.name)
198
                        }
199
                    }
200
                }
2011×
                None => return Ok(Err(format!("There is already an assistant called {} here.", fields.name))),
202
            }
203
        }
2048×
        ("update", Some(id)) => {
2052×
            let Some(a) = owned(assistants::get(&app.db, id).await?) else { return Ok(Err("No such assistant.".into())) };
2062×
            let fields = match fields(&f) {
2072×
                Ok(x) => x,
2080×
                Err(e) => return Ok(Err(e)),
209
            };
2102×
            let token = f.token.trim();
2112×
            let stored = if token.is_empty() { app.assistant_key.open(&a.token).unwrap_or_default() } else { token.to_string() };
2122×
            if let Err(e) = model_offered(&fields, &stored, &app.config.outbound.allow).await {
2130×
                return Ok(Err(e));
2142×
            }
2152×
            let sealed = (!token.is_empty()).then(|| app.assistant_key.seal(token));
2162×
            if !assistants::update(&app.db, id, &fields, sealed.as_deref()).await? {
2170×
                return Ok(Err(format!("There is already an assistant called {} here.", fields.name)));
2182×
            }
2192×
            if a.name != fields.name {
2201×
                assistants::rename_in_history(&app.db, at.scope, &at.owner, &a.name, &fields.name).await?;
2211×
            }
2222×
            accounts::audit(&app.db, actor, "assistant.updated", &at.label, &a.name).await.ok();
2232×
            format!("{} updated.", fields.name)
224
        }
2256×
        ("toggle", Some(id)) => {
2262×
            let Some(a) = owned(assistants::get(&app.db, id).await?) else { return Ok(Err("No such assistant.".into())) };
2272×
            assistants::set_enabled(&app.db, id, !a.enabled).await?;
2282×
            accounts::audit(&app.db, actor, if a.enabled { "assistant.disabled" } else { "assistant.enabled" }, &at.label, &a.name)
2292×
                .await
2302×
                .ok();
2312×
            format!("{} is {}.", a.name, if a.enabled { "off" } else { "on" })
232
        }
2334×
        ("delete", Some(id)) => {
2340×
            let Some(a) = owned(assistants::get(&app.db, id).await?) else { return Ok(Err("No such assistant.".into())) };
2350×
            assistants::delete(&app.db, id).await?;
2360×
            accounts::audit(&app.db, actor, "assistant.deleted", &at.label, &a.name).await.ok();
2370×
            format!("{} deleted.", a.name)
238
        }
2394×
        ("test", Some(id)) => {
2401×
            let Some(a) = owned(assistants::get(&app.db, id).await?) else { return Ok(Err("No such assistant.".into())) };
2411×
            match assistants::test(app, &a).await {
2421×
                Ok(answer) => format!("{} answered: {answer}", a.name),
2430×
                Err(why) => return Ok(Err(format!("{} did not answer: {why}. Check the endpoint, API and token.", a.name))),
244
            }
245
        }
2463×
        ("prompt", _) => {
2473×
            if f.prompt.chars().count() > 20_000 {
2480×
                return Ok(Err("A prompt is up to 20000 characters.".into()));
2493×
            }
2503×
            assistants::set_prompt(&app.db, at.scope, &f.prompt).await?;
2513×
            accounts::audit(&app.db, actor, "assistant.prompt", &at.label, "").await.ok();
2523×
            if f.prompt.trim().is_empty() {
2530×
                "Prompt cleared.".to_string()
254
            } else {
2553×
                "Prompt saved.".to_string()
256
            }
257
        }
2580×
        _ => return Ok(Err("Unknown action.".into())),
259
    }))
26017×
}
261
262
#[derive(Deserialize)]
263
pub struct ModelsForm {
264
    #[serde(rename = "_csrf")]
265
    pub csrf_token: String,
266
    #[serde(default)]
267
    id: Option<i64>,
268
    #[serde(default)]
269
    endpoint: String,
270
    #[serde(default)]
271
    api: String,
272
    #[serde(default)]
273
    token: String,
274
    #[serde(default)]
275
    preset: String,
276
}
277
278
/// The models a provider offers, as JSON for the form's List models
279
/// button (#109): `{"models": [...]}` or `{"error": "..."}`. With `id`,
280
/// the stored token of that assistant at this level is used when none is
281
/// typed.
2822×
pub async fn models(app: &Shared, at: &At, f: ModelsForm) -> sqlx::Result<serde_json::Value> {
28314×
    let preset = assistants::PRESETS.iter().find(|p| p.0 == f.preset);
2842×
    let endpoint =
2852×
        if f.endpoint.trim().is_empty() { preset.map(|p| p.3.to_string()).unwrap_or_default() } else { f.endpoint.trim().to_string() };
2862×
    let api = if APIS.contains(&f.api.as_str()) {
2872×
        f.api.clone()
288
    } else {
2890×
        preset.map(|p| p.4.to_string()).unwrap_or_else(|| "chat-completions".into())
290
    };
2912×
    let mut token = f.token.trim().to_string();
2922×
    if token.is_empty() {
2931×
        if let Some(id) = f.id {
2941×
            if let Some(a) = assistants::get(&app.db, id).await?.filter(|a| a.scope() == at.scope) {
2951×
                token = app.assistant_key.open(&a.token).unwrap_or_default();
2961×
            }
2970×
        }
2981×
    }
2992×
    Ok(match assistants::list_models(&endpoint, &api, &token, &app.config.outbound.allow).await {
3002×
        Ok(m) => serde_json::json!({ "models": m }),
3010×
        Err(e) => serde_json::json!({ "error": e }),
302
    })
3032×
}
304
305
/// For the API (§13.4): every assistant a level sees.
3062×
pub async fn api_list(app: &Shared, at: &At) -> sqlx::Result<Vec<hub_api::Assistant>> {
3072×
    Ok(assistants::available(&app.db, at.scope, &at.owner)
3082×
        .await?
3092×
        .into_iter()
3102×
        .map(|v| hub_api::Assistant {
3114×
            name: v.assistant.name.clone(),
3124×
            endpoint: v.assistant.endpoint.clone(),
3134×
            api: v.assistant.api.clone(),
3144×
            model: v.assistant.model.clone(),
3154×
            headers: v.assistant.headers.clone(),
3164×
            enabled: v.assistant.enabled,
3174×
            budget: v.assistant.budget,
3184×
            has_token: v.assistant.has_token(),
3194×
            off_above: v.off_above,
3204×
            from: v.from,
3214×
        })
3222×
        .collect())
3232×
}
324
325
/// For the API: add an assistant, or change the level's own one of that
326
/// name (an empty token keeps the stored one). `Err` is for the caller.
3274×
pub async fn api_put(
3284×
    app: &Shared,
3294×
    actor: &crate::db::accounts::Account,
3304×
    at: &At,
3314×
    b: hub_api::NewAssistant,
3324×
) -> sqlx::Result<Result<String, String>> {
3334×
    let f = AssistantForm {
3344×
        csrf_token: String::new(),
3354×
        action: String::new(),
3364×
        id: None,
3374×
        name: b.name,
3384×
        endpoint: b.endpoint,
3394×
        api: b.api,
3404×
        model: b.model,
3414×
        headers: b.headers,
3424×
        token: b.token,
3434×
        enabled: b.enabled.then(|| "1".to_string()),
3444×
        budget: b.budget.map(|n| n.to_string()).unwrap_or_default(),
3454×
        prompt: String::new(),
3464×
        preset: b.preset,
347
    };
3484×
    let fields = match fields(&f) {
3494×
        Ok(x) => x,
3500×
        Err(e) => return Ok(Err(e)),
351
    };
3524×
    let existing = assistants::list(&app.db, at.scope).await?.into_iter().find(|a| a.name.eq_ignore_ascii_case(&fields.name));
3534×
    let token = f.token.trim().to_string();
3544×
    let effective = match (&existing, token.is_empty()) {
3551×
        (Some(a), true) => app.assistant_key.open(&a.token).unwrap_or_default(),
3563×
        _ => token.clone(),
357
    };
3584×
    if let Err(e) = model_offered(&fields, &effective, &app.config.outbound.allow).await {
3590×
        return Ok(Err(e));
3604×
    }
3614×
    Ok(Ok(match existing {
3621×
        Some(a) => {
3631×
            let sealed = (!token.is_empty()).then(|| app.assistant_key.seal(&token));
3641×
            assistants::update(&app.db, a.id, &fields, sealed.as_deref()).await?;
3651×
            accounts::audit(&app.db, Some(actor), "assistant.updated", &at.label, &fields.name).await.ok();
3661×
            format!("{} updated", fields.name)
367
        }
368
        None => {
3693×
            assistants::create(&app.db, at.scope, &fields, &app.assistant_key.seal(&token)).await?;
3703×
            accounts::audit(&app.db, Some(actor), "assistant.added", &at.label, &fields.name).await.ok();
3713×
            format!("{} added", fields.name)
372
        }
373
    }))
3744×
}
375
376
/// For the API: remove the level's own assistant of that name.
3772×
pub async fn api_delete(app: &Shared, actor: &crate::db::accounts::Account, at: &At, name: &str) -> sqlx::Result<bool> {
3782×
    let Some(a) = assistants::list(&app.db, at.scope).await?.into_iter().find(|a| a.name.eq_ignore_ascii_case(name)) else {
3791×
        return Ok(false);
380
    };
3811×
    assistants::delete(&app.db, a.id).await?;
3821×
    accounts::audit(&app.db, Some(actor), "assistant.deleted", &at.label, &a.name).await.ok();
3831×
    Ok(true)
3842×
}
385
386
/// For the API: the level's prompt and what it inherits.
3872×
pub async fn api_prompt(app: &Shared, at: &At) -> sqlx::Result<hub_api::AssistantPrompt> {
388
    Ok(hub_api::AssistantPrompt {
3892×
        prompt: assistants::prompt(&app.db, at.scope).await?,
3902×
        inherited: assistants::inherited_prompts(&app.db, at.scope, &at.owner).await?,
391
    })
3922×
}
393
394
/// For the API: set the level's prompt (empty clears it).
3952×
pub async fn api_set_prompt(app: &Shared, actor: &crate::db::accounts::Account, at: &At, text: &str) -> sqlx::Result<Result<(), String>> {
3962×
    if text.chars().count() > 20_000 {
3970×
        return Ok(Err("a prompt is up to 20000 characters".into()));
3982×
    }
3992×
    assistants::set_prompt(&app.db, at.scope, text).await?;
4002×
    accounts::audit(&app.db, Some(actor), "assistant.prompt", &at.label, "").await.ok();
4012×
    Ok(Ok(()))
4022×
}