//! The AI assistants panel (hub spec ยง13.4, #97) shared by the //! administration, organisation and repository settings pages: the //! level's own assistants, the ones it inherits, and its prompt. use crate::assistants::{self, Assistant, Available, Fields, APIS, DEFAULT_BUDGET}; use crate::auth::Viewer; use crate::db::accounts; use crate::levels::At; use crate::Shared; use serde::Deserialize; #[derive(Deserialize)] pub struct AssistantForm { #[serde(rename = "_csrf")] pub csrf_token: String, #[serde(default)] action: String, #[serde(default)] id: Option, #[serde(default)] name: String, #[serde(default)] endpoint: String, #[serde(default)] api: String, #[serde(default)] model: String, #[serde(default)] headers: String, #[serde(default)] token: String, #[serde(default)] enabled: Option, #[serde(default)] budget: String, #[serde(default)] prompt: String, /// A provider from the add form's list (#109), filling what is left empty. #[serde(default)] preset: String, } /// Everything the panel shows. pub struct Panel { /// The level's own assistants, each with whether a level above turned /// its name off. pub own: Vec, /// The ones the levels above provide, outermost first. pub inherited: Vec, pub prompt: String, /// (where from, text) for each level above that set a prompt. pub inherited_prompts: Vec<(String, String)>, pub apis: Vec<&'static str>, pub default_budget: i64, pub presets: Vec<(&'static str, &'static str, &'static str, &'static str, &'static str)>, } pub async fn panel(app: &Shared, at: &At) -> sqlx::Result { let (own, inherited): (Vec<_>, Vec<_>) = assistants::available(&app.db, at.scope, &at.owner).await?.into_iter().partition(|a| a.from.is_empty()); Ok(Panel { own, inherited, prompt: assistants::prompt(&app.db, at.scope).await?, inherited_prompts: assistants::inherited_prompts(&app.db, at.scope, &at.owner).await?, apis: APIS.to_vec(), default_budget: DEFAULT_BUDGET, presets: assistants::PRESETS.to_vec(), }) } fn name_ok(name: &str) -> bool { !name.is_empty() && name.chars().count() <= 40 && name.chars().all(|c| c.is_alphanumeric() || matches!(c, ' ' | '.' | '-' | '_')) } fn endpoint_ok(url: &str) -> bool { (url.starts_with("http://") || url.starts_with("https://")) && url.len() <= 2000 && !url.contains(char::is_whitespace) } impl AssistantForm { fn clone_shallow(&self) -> AssistantForm { AssistantForm { csrf_token: String::new(), action: self.action.clone(), id: self.id, name: self.name.clone(), endpoint: self.endpoint.clone(), api: self.api.clone(), model: self.model.clone(), headers: self.headers.clone(), token: String::new(), enabled: self.enabled.clone(), budget: self.budget.clone(), prompt: String::new(), preset: self.preset.clone(), } } } /// When the endpoint lists its models, the model must be one of them /// (#109); `Err` names a few it does offer. async fn model_offered(fields: &Fields, token: &str) -> Result<(), String> { match assistants::list_models(&fields.endpoint, &fields.api, token).await { Ok(models) if !models.iter().any(|m| m == &fields.model) => { let close: Vec<&String> = { let stem = fields.model.split(['-', '.']).next().unwrap_or("").to_lowercase(); let near: Vec<&String> = models.iter().filter(|m| m.to_lowercase().contains(&stem)).take(8).collect(); if near.is_empty() { models.iter().take(8).collect() } else { near } }; Err(format!( "{} is not a model this endpoint offers. It offers, among others: {}. Use List models to pick one.", fields.model, close.iter().map(|m| m.as_str()).collect::>().join(", ") )) } _ => Ok(()), } } /// The form's details checked: `Err` is the message for the person. fn fields(f: &AssistantForm) -> Result { let mut f_owned; let f = match assistants::PRESETS.iter().find(|p| p.0 == f.preset) { Some(&(_, _, name, endpoint, api)) => { f_owned = f.clone_shallow(); if f_owned.name.trim().is_empty() { f_owned.name = name.into(); } if f_owned.endpoint.trim().is_empty() { f_owned.endpoint = endpoint.into(); } if !APIS.contains(&f_owned.api.as_str()) || f.endpoint.trim().is_empty() { f_owned.api = api.into(); } &f_owned } None => f, }; let name = f.name.trim().to_string(); if !name_ok(&name) { return Err("An assistant's name is up to 40 letters, digits, spaces, dots, dashes or underscores.".into()); } let endpoint = f.endpoint.trim().to_string(); if !endpoint_ok(&endpoint) { return Err("The endpoint is the full URL the request is posted to, starting with https://.".into()); } if !APIS.contains(&f.api.as_str()) { return Err("The API is chat-completions or anthropic-messages.".into()); } let model = f.model.trim().to_string(); if model.is_empty() || model.len() > 100 || model.contains(char::is_whitespace) { return Err("A model name is needed, such as claude-opus-5-5 or gpt-5.".into()); } let headers = f.headers.trim().to_string(); if let Err(line) = assistants::parse_headers(&headers) { return Err(format!("Extra headers are one Name: value per line; this line is not: {line}")); } if headers.lines().count() > 10 { return Err("Up to ten extra headers.".into()); } let budget = if f.budget.trim().is_empty() { DEFAULT_BUDGET } else { match f.budget.trim().parse::() { Ok(n) if (1_000..=2_000_000).contains(&n) => n, _ => return Err("The context budget is a number of characters from 1000 to 2000000.".into()), } }; Ok(Fields { name, endpoint, api: f.api.clone(), model, headers, enabled: f.enabled.is_some(), budget }) } /// Apply the panel's form: `Ok(notice)` or `Err(message for the user)`. pub async fn apply(app: &Shared, viewer: &Viewer, at: &At, f: AssistantForm) -> sqlx::Result> { let owned = |a: Option| a.filter(|a| a.scope() == at.scope); let actor = viewer.account(); Ok(Ok(match (f.action.as_str(), f.id) { ("add", None) => { let fields = match fields(&f) { Ok(x) => x, Err(e) => return Ok(Err(e)), }; if let Err(e) = model_offered(&fields, f.token.trim()).await { return Ok(Err(e)); } let token = app.assistant_key.seal(f.token.trim()); match assistants::create(&app.db, at.scope, &fields, &token).await? { Some(a) => { accounts::audit(&app.db, actor, "assistant.added", &at.label, &a.name).await.ok(); // Try it at once, so a wrong endpoint or token shows here. match assistants::test(app, &a).await { Ok(answer) => format!("{} added and answered: {answer}", a.name), Err(why) => { format!("{} added, but did not answer: {why}. Check the endpoint, API and token, then TEST CONNECTION.", a.name) } } } None => return Ok(Err(format!("There is already an assistant called {} here.", fields.name))), } } ("update", Some(id)) => { let Some(a) = owned(assistants::get(&app.db, id).await?) else { return Ok(Err("No such assistant.".into())) }; let fields = match fields(&f) { Ok(x) => x, Err(e) => return Ok(Err(e)), }; let token = f.token.trim(); let stored = if token.is_empty() { app.assistant_key.open(&a.token).unwrap_or_default() } else { token.to_string() }; if let Err(e) = model_offered(&fields, &stored).await { return Ok(Err(e)); } let sealed = (!token.is_empty()).then(|| app.assistant_key.seal(token)); if !assistants::update(&app.db, id, &fields, sealed.as_deref()).await? { return Ok(Err(format!("There is already an assistant called {} here.", fields.name))); } if a.name != fields.name { assistants::rename_in_history(&app.db, at.scope, &at.owner, &a.name, &fields.name).await?; } accounts::audit(&app.db, actor, "assistant.updated", &at.label, &a.name).await.ok(); format!("{} updated.", fields.name) } ("toggle", Some(id)) => { let Some(a) = owned(assistants::get(&app.db, id).await?) else { return Ok(Err("No such assistant.".into())) }; assistants::set_enabled(&app.db, id, !a.enabled).await?; accounts::audit(&app.db, actor, if a.enabled { "assistant.disabled" } else { "assistant.enabled" }, &at.label, &a.name) .await .ok(); format!("{} is {}.", a.name, if a.enabled { "off" } else { "on" }) } ("delete", Some(id)) => { let Some(a) = owned(assistants::get(&app.db, id).await?) else { return Ok(Err("No such assistant.".into())) }; assistants::delete(&app.db, id).await?; accounts::audit(&app.db, actor, "assistant.deleted", &at.label, &a.name).await.ok(); format!("{} deleted.", a.name) } ("test", Some(id)) => { let Some(a) = owned(assistants::get(&app.db, id).await?) else { return Ok(Err("No such assistant.".into())) }; match assistants::test(app, &a).await { Ok(answer) => format!("{} answered: {answer}", a.name), Err(why) => return Ok(Err(format!("{} did not answer: {why}. Check the endpoint, API and token.", a.name))), } } ("prompt", _) => { if f.prompt.chars().count() > 20_000 { return Ok(Err("A prompt is up to 20000 characters.".into())); } assistants::set_prompt(&app.db, at.scope, &f.prompt).await?; accounts::audit(&app.db, actor, "assistant.prompt", &at.label, "").await.ok(); if f.prompt.trim().is_empty() { "Prompt cleared.".to_string() } else { "Prompt saved.".to_string() } } _ => return Ok(Err("Unknown action.".into())), })) } #[derive(Deserialize)] pub struct ModelsForm { #[serde(rename = "_csrf")] pub csrf_token: String, #[serde(default)] id: Option, #[serde(default)] endpoint: String, #[serde(default)] api: String, #[serde(default)] token: String, #[serde(default)] preset: String, } /// The models a provider offers, as JSON for the form's List models /// button (#109): `{"models": [...]}` or `{"error": "..."}`. With `id`, /// the stored token of that assistant at this level is used when none is /// typed. pub async fn models(app: &Shared, at: &At, f: ModelsForm) -> sqlx::Result { let preset = assistants::PRESETS.iter().find(|p| p.0 == f.preset); let endpoint = if f.endpoint.trim().is_empty() { preset.map(|p| p.3.to_string()).unwrap_or_default() } else { f.endpoint.trim().to_string() }; let api = if APIS.contains(&f.api.as_str()) { f.api.clone() } else { preset.map(|p| p.4.to_string()).unwrap_or_else(|| "chat-completions".into()) }; let mut token = f.token.trim().to_string(); if token.is_empty() { if let Some(id) = f.id { if let Some(a) = assistants::get(&app.db, id).await?.filter(|a| a.scope() == at.scope) { token = app.assistant_key.open(&a.token).unwrap_or_default(); } } } Ok(match assistants::list_models(&endpoint, &api, &token).await { Ok(m) => serde_json::json!({ "models": m }), Err(e) => serde_json::json!({ "error": e }), }) }