| 1 | |
|
| 2 | |
|
| 3 | |
|
| 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 | | |
| 39 | | #[serde(default)] |
| 40 | | preset: String, |
| 41 | | } |
| 42 | |
|
| 43 | |
|
| 44 | | pub struct Panel { |
| 45 | | |
| 46 | | |
| 47 | | pub own: Vec<Available>, |
| 48 | | |
| 49 | | pub inherited: Vec<Available>, |
| 50 | | pub prompt: String, |
| 51 | | |
| 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 | |
|
| 58 | 249× | pub async fn panel(app: &Shared, at: &At) -> sqlx::Result<Panel> { |
| 59 | 249× | let (own, inherited): (Vec<_>, Vec<_>) = |
| 60 | 249× | assistants::available(&app.db, at.scope, &at.owner).await?.into_iter().partition(|a| a.from.is_empty()); |
| 61 | | Ok(Panel { |
| 62 | 249× | own, |
| 63 | 249× | inherited, |
| 64 | 249× | prompt: assistants::prompt(&app.db, at.scope).await?, |
| 65 | 249× | inherited_prompts: assistants::inherited_prompts(&app.db, at.scope, &at.owner).await?, |
| 66 | 249× | apis: APIS.to_vec(), |
| 67 | | default_budget: DEFAULT_BUDGET, |
| 68 | 249× | presets: assistants::PRESETS.to_vec(), |
| 69 | | }) |
| 70 | 249× | } |
| 71 | |
|
| 72 | 14× | fn name_ok(name: &str) -> bool { |
| 73 | 87× | !name.is_empty() && name.chars().count() <= 40 && name.chars().all(|c| c.is_alphanumeric() || matches!(c, ' ' | '.' | '-' | '_')) |
| 74 | 14× | } |
| 75 | |
|
| 76 | 14× | fn endpoint_ok(url: &str) -> bool { |
| 77 | 14× | (url.starts_with("http://") || url.starts_with("https://")) && url.len() <= 2000 && !url.contains(char::is_whitespace) |
| 78 | 14× | } |
| 79 | |
|
| 80 | | impl AssistantForm { |
| 81 | 3× | fn clone_shallow(&self) -> AssistantForm { |
| 82 | 3× | AssistantForm { |
| 83 | 3× | csrf_token: String::new(), |
| 84 | 3× | action: self.action.clone(), |
| 85 | 3× | id: self.id, |
| 86 | 3× | name: self.name.clone(), |
| 87 | 3× | endpoint: self.endpoint.clone(), |
| 88 | 3× | api: self.api.clone(), |
| 89 | 3× | model: self.model.clone(), |
| 90 | 3× | headers: self.headers.clone(), |
| 91 | 3× | token: String::new(), |
| 92 | 3× | enabled: self.enabled.clone(), |
| 93 | 3× | budget: self.budget.clone(), |
| 94 | 3× | prompt: String::new(), |
| 95 | 3× | preset: self.preset.clone(), |
| 96 | 3× | } |
| 97 | 3× | } |
| 98 | | } |
| 99 | |
|
| 100 | |
|
| 101 | |
|
| 102 | 13× | async fn model_offered(fields: &Fields, token: &str, allow: &[String]) -> Result<(), String> { |
| 103 | 13× | match assistants::list_models(&fields.endpoint, &fields.api, token, allow).await { |
| 104 | 4× | Ok(models) if !models.iter().any(|m| m == &fields.model) => { |
| 105 | 1× | let close: Vec<&String> = { |
| 106 | 1× | let stem = fields.model.split(['-', '.']).next().unwrap_or("").to_lowercase(); |
| 107 | 2× | let near: Vec<&String> = models.iter().filter(|m| m.to_lowercase().contains(&stem)).take(8).collect(); |
| 108 | 1× | if near.is_empty() { |
| 109 | 0× | models.iter().take(8).collect() |
| 110 | | } else { |
| 111 | 1× | near |
| 112 | | } |
| 113 | | }; |
| 114 | 1× | Err(format!( |
| 115 | | "{} is not a model this endpoint offers. It offers, among others: {}. Use List models to pick one.", |
| 116 | | fields.model, |
| 117 | 2× | close.iter().map(|m| m.as_str()).collect::<Vec<_>>().join(", ") |
| 118 | | )) |
| 119 | | } |
| 120 | 12× | _ => Ok(()), |
| 121 | | } |
| 122 | 13× | } |
| 123 | |
|
| 124 | |
|
| 125 | 14× | fn fields(f: &AssistantForm) -> Result<Fields, String> { |
| 126 | | let mut f_owned; |
| 127 | 86× | let f = match assistants::PRESETS.iter().find(|p| p.0 == f.preset) { |
| 128 | 3× | Some(&(_, _, name, endpoint, api)) => { |
| 129 | 3× | f_owned = f.clone_shallow(); |
| 130 | 3× | if f_owned.name.trim().is_empty() { |
| 131 | 3× | f_owned.name = name.into(); |
| 132 | 3× | } |
| 133 | 3× | if f_owned.endpoint.trim().is_empty() { |
| 134 | 0× | f_owned.endpoint = endpoint.into(); |
| 135 | 3× | } |
| 136 | 3× | if !APIS.contains(&f_owned.api.as_str()) || f.endpoint.trim().is_empty() { |
| 137 | 3× | f_owned.api = api.into(); |
| 138 | 3× | } |
| 139 | 3× | &f_owned |
| 140 | | } |
| 141 | 11× | None => f, |
| 142 | | }; |
| 143 | 14× | let name = f.name.trim().to_string(); |
| 144 | 14× | if !name_ok(&name) { |
| 145 | 0× | return Err("An assistant's name is up to 40 letters, digits, spaces, dots, dashes or underscores.".into()); |
| 146 | 14× | } |
| 147 | 14× | let endpoint = f.endpoint.trim().to_string(); |
| 148 | 14× | if !endpoint_ok(&endpoint) { |
| 149 | 1× | return Err("The endpoint is the full URL the request is posted to, starting with https://.".into()); |
| 150 | 13× | } |
| 151 | 13× | if !APIS.contains(&f.api.as_str()) { |
| 152 | 0× | return Err("The API is chat-completions or anthropic-messages.".into()); |
| 153 | 13× | } |
| 154 | 13× | let model = f.model.trim().to_string(); |
| 155 | 13× | if model.is_empty() || model.len() > 100 || model.contains(char::is_whitespace) { |
| 156 | 0× | return Err("A model name is needed, such as claude-opus-5-5 or gpt-5.".into()); |
| 157 | 13× | } |
| 158 | 13× | let headers = f.headers.trim().to_string(); |
| 159 | 13× | if let Err(line) = assistants::parse_headers(&headers) { |
| 160 | 0× | return Err(format!("Extra headers are one Name: value per line; this line is not: {line}")); |
| 161 | 13× | } |
| 162 | 13× | if headers.lines().count() > 10 { |
| 163 | 0× | return Err("Up to ten extra headers.".into()); |
| 164 | 13× | } |
| 165 | 13× | let budget = if f.budget.trim().is_empty() { |
| 166 | 7× | DEFAULT_BUDGET |
| 167 | | } else { |
| 168 | 6× | match f.budget.trim().parse::<i64>() { |
| 169 | 6× | Ok(n) if (1_000..=2_000_000).contains(&n) => n, |
| 170 | 0× | _ => return Err("The context budget is a number of characters from 1000 to 2000000.".into()), |
| 171 | | } |
| 172 | | }; |
| 173 | 13× | Ok(Fields { name, endpoint, api: f.api.clone(), model, headers, enabled: f.enabled.is_some(), budget }) |
| 174 | 14× | } |
| 175 | |
|
| 176 | |
|
| 177 | 17× | pub async fn apply(app: &Shared, viewer: &Viewer, at: &At, f: AssistantForm) -> sqlx::Result<Result<String, String>> { |
| 178 | 17× | let owned = |a: Option<Assistant>| a.filter(|a| a.scope() == at.scope); |
| 179 | 17× | let actor = viewer.account(); |
| 180 | 17× | Ok(Ok(match (f.action.as_str(), f.id) { |
| 181 | 17× | ("add", None) => { |
| 182 | 9× | let fields = match fields(&f) { |
| 183 | 8× | Ok(x) => x, |
| 184 | 1× | Err(e) => return Ok(Err(e)), |
| 185 | | }; |
| 186 | 8× | if let Err(e) = model_offered(&fields, f.token.trim(), &app.config.outbound.allow).await { |
| 187 | 1× | return Ok(Err(e)); |
| 188 | 7× | } |
| 189 | 7× | let token = app.assistant_key.seal(f.token.trim()); |
| 190 | 7× | match assistants::create(&app.db, at.scope, &fields, &token).await? { |
| 191 | 6× | Some(a) => { |
| 192 | 6× | accounts::audit(&app.db, actor, "assistant.added", &at.label, &a.name).await.ok(); |
| 193 | | |
| 194 | 6× | match assistants::test(app, &a).await { |
| 195 | 6× | Ok(answer) => format!("{} added and answered: {answer}", a.name), |
| 196 | 0× | Err(why) => { |
| 197 | 0× | format!("{} added, but did not answer: {why}. Check the endpoint, API and token, then TEST CONNECTION.", a.name) |
| 198 | | } |
| 199 | | } |
| 200 | | } |
| 201 | 1× | None => return Ok(Err(format!("There is already an assistant called {} here.", fields.name))), |
| 202 | | } |
| 203 | | } |
| 204 | 8× | ("update", Some(id)) => { |
| 205 | 2× | let Some(a) = owned(assistants::get(&app.db, id).await?) else { return Ok(Err("No such assistant.".into())) }; |
| 206 | 2× | let fields = match fields(&f) { |
| 207 | 2× | Ok(x) => x, |
| 208 | 0× | Err(e) => return Ok(Err(e)), |
| 209 | | }; |
| 210 | 2× | let token = f.token.trim(); |
| 211 | 2× | let stored = if token.is_empty() { app.assistant_key.open(&a.token).unwrap_or_default() } else { token.to_string() }; |
| 212 | 2× | if let Err(e) = model_offered(&fields, &stored, &app.config.outbound.allow).await { |
| 213 | 0× | return Ok(Err(e)); |
| 214 | 2× | } |
| 215 | 2× | let sealed = (!token.is_empty()).then(|| app.assistant_key.seal(token)); |
| 216 | 2× | if !assistants::update(&app.db, id, &fields, sealed.as_deref()).await? { |
| 217 | 0× | return Ok(Err(format!("There is already an assistant called {} here.", fields.name))); |
| 218 | 2× | } |
| 219 | 2× | if a.name != fields.name { |
| 220 | 1× | assistants::rename_in_history(&app.db, at.scope, &at.owner, &a.name, &fields.name).await?; |
| 221 | 1× | } |
| 222 | 2× | accounts::audit(&app.db, actor, "assistant.updated", &at.label, &a.name).await.ok(); |
| 223 | 2× | format!("{} updated.", fields.name) |
| 224 | | } |
| 225 | 6× | ("toggle", Some(id)) => { |
| 226 | 2× | let Some(a) = owned(assistants::get(&app.db, id).await?) else { return Ok(Err("No such assistant.".into())) }; |
| 227 | 2× | assistants::set_enabled(&app.db, id, !a.enabled).await?; |
| 228 | 2× | accounts::audit(&app.db, actor, if a.enabled { "assistant.disabled" } else { "assistant.enabled" }, &at.label, &a.name) |
| 229 | 2× | .await |
| 230 | 2× | .ok(); |
| 231 | 2× | format!("{} is {}.", a.name, if a.enabled { "off" } else { "on" }) |
| 232 | | } |
| 233 | 4× | ("delete", Some(id)) => { |
| 234 | 0× | let Some(a) = owned(assistants::get(&app.db, id).await?) else { return Ok(Err("No such assistant.".into())) }; |
| 235 | 0× | assistants::delete(&app.db, id).await?; |
| 236 | 0× | accounts::audit(&app.db, actor, "assistant.deleted", &at.label, &a.name).await.ok(); |
| 237 | 0× | format!("{} deleted.", a.name) |
| 238 | | } |
| 239 | 4× | ("test", Some(id)) => { |
| 240 | 1× | let Some(a) = owned(assistants::get(&app.db, id).await?) else { return Ok(Err("No such assistant.".into())) }; |
| 241 | 1× | match assistants::test(app, &a).await { |
| 242 | 1× | Ok(answer) => format!("{} answered: {answer}", a.name), |
| 243 | 0× | Err(why) => return Ok(Err(format!("{} did not answer: {why}. Check the endpoint, API and token.", a.name))), |
| 244 | | } |
| 245 | | } |
| 246 | 3× | ("prompt", _) => { |
| 247 | 3× | if f.prompt.chars().count() > 20_000 { |
| 248 | 0× | return Ok(Err("A prompt is up to 20000 characters.".into())); |
| 249 | 3× | } |
| 250 | 3× | assistants::set_prompt(&app.db, at.scope, &f.prompt).await?; |
| 251 | 3× | accounts::audit(&app.db, actor, "assistant.prompt", &at.label, "").await.ok(); |
| 252 | 3× | if f.prompt.trim().is_empty() { |
| 253 | 0× | "Prompt cleared.".to_string() |
| 254 | | } else { |
| 255 | 3× | "Prompt saved.".to_string() |
| 256 | | } |
| 257 | | } |
| 258 | 0× | _ => return Ok(Err("Unknown action.".into())), |
| 259 | | })) |
| 260 | 17× | } |
| 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 | |
|
| 279 | |
|
| 280 | |
|
| 281 | |
|
| 282 | 2× | pub async fn models(app: &Shared, at: &At, f: ModelsForm) -> sqlx::Result<serde_json::Value> { |
| 283 | 14× | let preset = assistants::PRESETS.iter().find(|p| p.0 == f.preset); |
| 284 | 2× | let endpoint = |
| 285 | 2× | if f.endpoint.trim().is_empty() { preset.map(|p| p.3.to_string()).unwrap_or_default() } else { f.endpoint.trim().to_string() }; |
| 286 | 2× | let api = if APIS.contains(&f.api.as_str()) { |
| 287 | 2× | f.api.clone() |
| 288 | | } else { |
| 289 | 0× | preset.map(|p| p.4.to_string()).unwrap_or_else(|| "chat-completions".into()) |
| 290 | | }; |
| 291 | 2× | let mut token = f.token.trim().to_string(); |
| 292 | 2× | if token.is_empty() { |
| 293 | 1× | if let Some(id) = f.id { |
| 294 | 1× | if let Some(a) = assistants::get(&app.db, id).await?.filter(|a| a.scope() == at.scope) { |
| 295 | 1× | token = app.assistant_key.open(&a.token).unwrap_or_default(); |
| 296 | 1× | } |
| 297 | 0× | } |
| 298 | 1× | } |
| 299 | 2× | Ok(match assistants::list_models(&endpoint, &api, &token, &app.config.outbound.allow).await { |
| 300 | 2× | Ok(m) => serde_json::json!({ "models": m }), |
| 301 | 0× | Err(e) => serde_json::json!({ "error": e }), |
| 302 | | }) |
| 303 | 2× | } |
| 304 | |
|
| 305 | |
|
| 306 | 2× | pub async fn api_list(app: &Shared, at: &At) -> sqlx::Result<Vec<hub_api::Assistant>> { |
| 307 | 2× | Ok(assistants::available(&app.db, at.scope, &at.owner) |
| 308 | 2× | .await? |
| 309 | 2× | .into_iter() |
| 310 | 2× | .map(|v| hub_api::Assistant { |
| 311 | 4× | name: v.assistant.name.clone(), |
| 312 | 4× | endpoint: v.assistant.endpoint.clone(), |
| 313 | 4× | api: v.assistant.api.clone(), |
| 314 | 4× | model: v.assistant.model.clone(), |
| 315 | 4× | headers: v.assistant.headers.clone(), |
| 316 | 4× | enabled: v.assistant.enabled, |
| 317 | 4× | budget: v.assistant.budget, |
| 318 | 4× | has_token: v.assistant.has_token(), |
| 319 | 4× | off_above: v.off_above, |
| 320 | 4× | from: v.from, |
| 321 | 4× | }) |
| 322 | 2× | .collect()) |
| 323 | 2× | } |
| 324 | |
|
| 325 | |
|
| 326 | |
|
| 327 | 3× | pub async fn api_put( |
| 328 | 3× | app: &Shared, |
| 329 | 3× | actor: &crate::db::accounts::Account, |
| 330 | 3× | at: &At, |
| 331 | 3× | b: hub_api::NewAssistant, |
| 332 | 3× | ) -> sqlx::Result<Result<String, String>> { |
| 333 | 3× | let f = AssistantForm { |
| 334 | 3× | csrf_token: String::new(), |
| 335 | 3× | action: String::new(), |
| 336 | 3× | id: None, |
| 337 | 3× | name: b.name, |
| 338 | 3× | endpoint: b.endpoint, |
| 339 | 3× | api: b.api, |
| 340 | 3× | model: b.model, |
| 341 | 3× | headers: b.headers, |
| 342 | 3× | token: b.token, |
| 343 | 3× | enabled: b.enabled.then(|| "1".to_string()), |
| 344 | 3× | budget: b.budget.map(|n| n.to_string()).unwrap_or_default(), |
| 345 | 3× | prompt: String::new(), |
| 346 | 3× | preset: b.preset, |
| 347 | | }; |
| 348 | 3× | let fields = match fields(&f) { |
| 349 | 3× | Ok(x) => x, |
| 350 | 0× | Err(e) => return Ok(Err(e)), |
| 351 | | }; |
| 352 | 3× | let existing = assistants::list(&app.db, at.scope).await?.into_iter().find(|a| a.name.eq_ignore_ascii_case(&fields.name)); |
| 353 | 3× | let token = f.token.trim().to_string(); |
| 354 | 3× | let effective = match (&existing, token.is_empty()) { |
| 355 | 1× | (Some(a), true) => app.assistant_key.open(&a.token).unwrap_or_default(), |
| 356 | 2× | _ => token.clone(), |
| 357 | | }; |
| 358 | 3× | if let Err(e) = model_offered(&fields, &effective, &app.config.outbound.allow).await { |
| 359 | 0× | return Ok(Err(e)); |
| 360 | 3× | } |
| 361 | 3× | Ok(Ok(match existing { |
| 362 | 1× | Some(a) => { |
| 363 | 1× | let sealed = (!token.is_empty()).then(|| app.assistant_key.seal(&token)); |
| 364 | 1× | assistants::update(&app.db, a.id, &fields, sealed.as_deref()).await?; |
| 365 | 1× | accounts::audit(&app.db, Some(actor), "assistant.updated", &at.label, &fields.name).await.ok(); |
| 366 | 1× | format!("{} updated", fields.name) |
| 367 | | } |
| 368 | | None => { |
| 369 | 2× | assistants::create(&app.db, at.scope, &fields, &app.assistant_key.seal(&token)).await?; |
| 370 | 2× | accounts::audit(&app.db, Some(actor), "assistant.added", &at.label, &fields.name).await.ok(); |
| 371 | 2× | format!("{} added", fields.name) |
| 372 | | } |
| 373 | | })) |
| 374 | 3× | } |
| 375 | |
|
| 376 | |
|
| 377 | 2× | pub async fn api_delete(app: &Shared, actor: &crate::db::accounts::Account, at: &At, name: &str) -> sqlx::Result<bool> { |
| 378 | 2× | let Some(a) = assistants::list(&app.db, at.scope).await?.into_iter().find(|a| a.name.eq_ignore_ascii_case(name)) else { |
| 379 | 1× | return Ok(false); |
| 380 | | }; |
| 381 | 1× | assistants::delete(&app.db, a.id).await?; |
| 382 | 1× | accounts::audit(&app.db, Some(actor), "assistant.deleted", &at.label, &a.name).await.ok(); |
| 383 | 1× | Ok(true) |
| 384 | 2× | } |
| 385 | |
|
| 386 | |
|
| 387 | 2× | pub async fn api_prompt(app: &Shared, at: &At) -> sqlx::Result<hub_api::AssistantPrompt> { |
| 388 | | Ok(hub_api::AssistantPrompt { |
| 389 | 2× | prompt: assistants::prompt(&app.db, at.scope).await?, |
| 390 | 2× | inherited: assistants::inherited_prompts(&app.db, at.scope, &at.owner).await?, |
| 391 | | }) |
| 392 | 2× | } |
| 393 | |
|
| 394 | |
|
| 395 | 2× | pub async fn api_set_prompt(app: &Shared, actor: &crate::db::accounts::Account, at: &At, text: &str) -> sqlx::Result<Result<(), String>> { |
| 396 | 2× | if text.chars().count() > 20_000 { |
| 397 | 0× | return Ok(Err("a prompt is up to 20000 characters".into())); |
| 398 | 2× | } |
| 399 | 2× | assistants::set_prompt(&app.db, at.scope, text).await?; |
| 400 | 2× | accounts::audit(&app.db, Some(actor), "assistant.prompt", &at.label, "").await.ok(); |
| 401 | 2× | Ok(Ok(())) |
| 402 | 2× | } |