//! 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, allow: &[String]) -> Result<(), String> { match assistants::list_models(&fields.endpoint, &fields.api, token, allow).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(), &app.config.outbound.allow).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, &app.config.outbound.allow).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, &app.config.outbound.allow).await { Ok(m) => serde_json::json!({ "models": m }), Err(e) => serde_json::json!({ "error": e }), }) } /// For the API (§13.4): every assistant a level sees. pub async fn api_list(app: &Shared, at: &At) -> sqlx::Result> { Ok(assistants::available(&app.db, at.scope, &at.owner) .await? .into_iter() .map(|v| hub_api::Assistant { name: v.assistant.name.clone(), endpoint: v.assistant.endpoint.clone(), api: v.assistant.api.clone(), model: v.assistant.model.clone(), headers: v.assistant.headers.clone(), enabled: v.assistant.enabled, budget: v.assistant.budget, has_token: v.assistant.has_token(), off_above: v.off_above, from: v.from, }) .collect()) } /// For the API: add an assistant, or change the level's own one of that /// name (an empty token keeps the stored one). `Err` is for the caller. pub async fn api_put( app: &Shared, actor: &crate::db::accounts::Account, at: &At, b: hub_api::NewAssistant, ) -> sqlx::Result> { let f = AssistantForm { csrf_token: String::new(), action: String::new(), id: None, name: b.name, endpoint: b.endpoint, api: b.api, model: b.model, headers: b.headers, token: b.token, enabled: b.enabled.then(|| "1".to_string()), budget: b.budget.map(|n| n.to_string()).unwrap_or_default(), prompt: String::new(), preset: b.preset, }; let fields = match fields(&f) { Ok(x) => x, Err(e) => return Ok(Err(e)), }; let existing = assistants::list(&app.db, at.scope).await?.into_iter().find(|a| a.name.eq_ignore_ascii_case(&fields.name)); let token = f.token.trim().to_string(); let effective = match (&existing, token.is_empty()) { (Some(a), true) => app.assistant_key.open(&a.token).unwrap_or_default(), _ => token.clone(), }; if let Err(e) = model_offered(&fields, &effective, &app.config.outbound.allow).await { return Ok(Err(e)); } Ok(Ok(match existing { Some(a) => { let sealed = (!token.is_empty()).then(|| app.assistant_key.seal(&token)); assistants::update(&app.db, a.id, &fields, sealed.as_deref()).await?; accounts::audit(&app.db, Some(actor), "assistant.updated", &at.label, &fields.name).await.ok(); format!("{} updated", fields.name) } None => { assistants::create(&app.db, at.scope, &fields, &app.assistant_key.seal(&token)).await?; accounts::audit(&app.db, Some(actor), "assistant.added", &at.label, &fields.name).await.ok(); format!("{} added", fields.name) } })) } /// For the API: remove the level's own assistant of that name. pub async fn api_delete(app: &Shared, actor: &crate::db::accounts::Account, at: &At, name: &str) -> sqlx::Result { let Some(a) = assistants::list(&app.db, at.scope).await?.into_iter().find(|a| a.name.eq_ignore_ascii_case(name)) else { return Ok(false); }; assistants::delete(&app.db, a.id).await?; accounts::audit(&app.db, Some(actor), "assistant.deleted", &at.label, &a.name).await.ok(); Ok(true) } /// For the API: the level's prompt and what it inherits. pub async fn api_prompt(app: &Shared, at: &At) -> sqlx::Result { Ok(hub_api::AssistantPrompt { prompt: assistants::prompt(&app.db, at.scope).await?, inherited: assistants::inherited_prompts(&app.db, at.scope, &at.owner).await?, }) } /// For the API: set the level's prompt (empty clears it). pub async fn api_set_prompt(app: &Shared, actor: &crate::db::accounts::Account, at: &At, text: &str) -> sqlx::Result> { if text.chars().count() > 20_000 { return Ok(Err("a prompt is up to 20000 characters".into())); } assistants::set_prompt(&app.db, at.scope, text).await?; accounts::audit(&app.db, Some(actor), "assistant.prompt", &at.label, "").await.ok(); Ok(Ok(())) }