| 1 | //! A workspace's AI providers, and Ask g1t answering with one of them. Not |
| 2 | //! integrations: they have their own table (`ai_providers`), their own |
| 3 | //! route of what Ask answers with (`ai_routes`) and their own methods. See |
| 4 | //! `g1t_contracts::ai`. |
| 5 | //! |
| 6 | //! Keys are sealed with the service's key, bound to the provider's id, and |
| 7 | //! never returned. A provider's key is checked when it is saved and on |
| 8 | //! every test, which also learns the models it offers. |
| 9 | |
| 10 | use g1t_contracts::ai::*; |
| 11 | use g1t_contracts::identity::{AskToken, CreateAskTokenArgs, Profile, RevokeAskTokenArgs, SlugArgs, UsernameArgs, Workspace}; |
| 12 | use g1t_contracts::integrations::Tested; |
| 13 | use g1t_contracts::time::rfc3339; |
| 14 | use g1t_contracts::work::{IssueDetail, PullDetail, ViewArgs}; |
| 15 | use g1t_contracts::{FailureCode, Outcome, PrincipalKind, Role, User, new_id}; |
| 16 | use g1t_kit::{args, now_ms, reply}; |
| 17 | use serde::Deserialize; |
| 18 | use serde_json::Value; |
| 19 | use worker::{Headers, Method, Response, Result}; |
| 20 | |
| 21 | use crate::{Integrations, Secrets, ask, ask_loop, ask_tools, auto, fail, http, https, local_day, models, optional}; |
| 22 | use g1t_secrets as crypto; |
| 23 | |
| 24 | /// The methods this module answers. |
| 25 | pub const METHODS: &[&str] = &["ai_list", "ai_add", "ai_update", "ai_remove", "ai_test", "ask_settings", "set_ask_settings", "ask"]; |
| 26 | |
| 27 | /// Answers one of [`METHODS`]. |
| 28 | pub async fn route(service: &Integrations, method: &str, body: Value) -> Result<Response> { |
| 29 | match method { |
| 30 | "ai_list" => reply(&service.ai_list(args(body)?).await?), |
| 31 | "ai_add" => reply(&service.ai_add(args(body)?).await?), |
| 32 | "ai_update" => reply(&service.ai_update(args(body)?).await?), |
| 33 | "ai_remove" => reply(&service.ai_remove(args(body)?).await?), |
| 34 | "ai_test" => reply(&service.ai_test(args(body)?).await?), |
| 35 | "ask_settings" => reply(&service.ask_settings(args(body)?).await?), |
| 36 | "set_ask_settings" => reply(&service.set_ask_settings(args(body)?).await?), |
| 37 | "ask" => service.ask(args(body)?).await, |
| 38 | _ => Response::error("Unknown method", 404), |
| 39 | } |
| 40 | } |
| 41 | |
| 42 | const NO_SUCH: &str = "No such AI provider."; |
| 43 | const NO_SEALER: &str = "AI providers are not set up on this g1t: it has no key to keep secrets with."; |
| 44 | |
| 45 | /// A row of `ai_providers`. |
| 46 | #[derive(Deserialize)] |
| 47 | pub(crate) struct AiRow { |
| 48 | pub(crate) id: String, |
| 49 | pub(crate) workspace: String, |
| 50 | pub(crate) provider: String, |
| 51 | pub(crate) name: String, |
| 52 | pub(crate) config: String, |
| 53 | pub(crate) secrets: Option<String>, |
| 54 | pub(crate) secret_hint: Option<String>, |
| 55 | pub(crate) created_by: String, |
| 56 | pub(crate) created_at: String, |
| 57 | pub(crate) last_used_at: Option<String>, |
| 58 | pub(crate) last_error: Option<String>, |
| 59 | pub(crate) models: Option<String>, |
| 60 | } |
| 61 | |
| 62 | impl AiRow { |
| 63 | pub(crate) fn provider(&self) -> AiProvider { |
| 64 | AiProvider::parse(&self.provider).unwrap_or(AiProvider::OpenaiEndpoint) |
| 65 | } |
| 66 | |
| 67 | pub(crate) fn config(&self) -> AiConfig { |
| 68 | serde_json::from_str(&self.config).unwrap_or_default() |
| 69 | } |
| 70 | |
| 71 | /// The models it offered when last checked. |
| 72 | pub(crate) fn models(&self) -> Vec<String> { |
| 73 | self.models.as_deref().and_then(|models| serde_json::from_str(models).ok()).unwrap_or_default() |
| 74 | } |
| 75 | } |
| 76 | |
| 77 | fn to_ai(row: &AiRow) -> AiConnection { |
| 78 | let provider = row.provider(); |
| 79 | AiConnection { |
| 80 | id: row.id.clone(), |
| 81 | workspace: row.workspace.clone(), |
| 82 | provider, |
| 83 | group: provider.group(), |
| 84 | name: row.name.clone(), |
| 85 | config: row.config(), |
| 86 | secret_hint: row.secret_hint.clone(), |
| 87 | created_by: row.created_by.clone(), |
| 88 | created_at: row.created_at.clone(), |
| 89 | last_used_at: row.last_used_at.clone(), |
| 90 | last_error: row.last_error.clone(), |
| 91 | models: row.models(), |
| 92 | } |
| 93 | } |
| 94 | |
| 95 | /// Only an owner, as a person, changes a workspace's AI. |
| 96 | fn owner_only<T>(actor: &User, workspace: &str) -> Option<Outcome<T>> { |
| 97 | (actor.role_in(workspace) != Some(Role::Owner) || actor.kind != PrincipalKind::User) |
| 98 | .then(|| fail(FailureCode::Forbidden, "Only an owner of the workspace can manage its AI providers.")) |
| 99 | } |
| 100 | |
| 101 | /// Checks an AI provider's settings, tidying them. The message says what |
| 102 | /// to fix. |
| 103 | fn check_config(provider: AiProvider, config: &mut AiConfig, secret: Option<&str>) -> std::result::Result<(), String> { |
| 104 | if let Some(value) = &mut config.base_url { |
| 105 | *value = value.trim().trim_end_matches('/').to_owned(); |
| 106 | if !https(value) { |
| 107 | return Err("Addresses must start with https://.".to_owned()); |
| 108 | } |
| 109 | } |
| 110 | for value in [&mut config.model, &mut config.auth_header] { |
| 111 | *value = value.as_deref().map(str::trim).filter(|v| !v.is_empty()).map(str::to_owned); |
| 112 | } |
| 113 | let needs = |present: bool, what: &str| if present { Ok(()) } else { Err(what.to_owned()) }; |
| 114 | match provider { |
| 115 | AiProvider::AzureOpenai => { |
| 116 | needs(config.base_url.is_some(), "Give your Azure OpenAI resource's endpoint, such as https://acme.openai.azure.com.")?; |
| 117 | needs(config.model.is_some(), "Give the name of the deployment to use.")?; |
| 118 | needs(secret.is_some(), "Paste the resource's key.") |
| 119 | } |
| 120 | AiProvider::AnthropicEndpoint | AiProvider::OpenaiEndpoint => { |
| 121 | needs(config.base_url.is_some(), "Give the endpoint's address.")?; |
| 122 | if let Some(header) = &config.auth_header |
| 123 | && header != "x-api-key" |
| 124 | && header != "authorization" |
| 125 | { |
| 126 | return Err("Send the key as x-api-key or authorization.".to_owned()); |
| 127 | } |
| 128 | Ok(()) |
| 129 | } |
| 130 | // Every other AI provider is at a known address and needs only a key. |
| 131 | _ => needs(secret.is_some(), &format!("Paste your {} API key.", provider.label())), |
| 132 | } |
| 133 | } |
| 134 | |
| 135 | impl Integrations { |
| 136 | pub(crate) fn ai_secret(&self, row: &AiRow) -> Option<String> { |
| 137 | let (Some(sealer), Some(sealed)) = (&self.sealer, &row.secrets) else { |
| 138 | return None; |
| 139 | }; |
| 140 | sealer.open(sealed, &row.id).and_then(|plain| serde_json::from_str::<Secrets>(&plain).ok()).and_then(|secrets| secrets.secret) |
| 141 | } |
| 142 | |
| 143 | fn ai_seal(&self, id: &str, secret: Option<&str>) -> Option<String> { |
| 144 | let sealer = self.sealer.as_ref()?; |
| 145 | let secrets = Secrets { secret: Some(secret?.to_owned()), signing_secret: None }; |
| 146 | Some(sealer.seal(&serde_json::to_string(&secrets).unwrap_or_default(), id)) |
| 147 | } |
| 148 | |
| 149 | pub(crate) async fn ai_row(&self, id: &str) -> Result<Option<AiRow>> { |
| 150 | self.db.prepare("SELECT * FROM ai_providers WHERE id = ?").bind(&[id.into()])?.first::<AiRow>(None).await |
| 151 | } |
| 152 | |
| 153 | pub(crate) async fn ai_rows(&self, workspace: &str) -> Result<Vec<AiRow>> { |
| 154 | self.db |
| 155 | .prepare("SELECT * FROM ai_providers WHERE workspace = ? ORDER BY id") |
| 156 | .bind(&[workspace.into()])? |
| 157 | .all() |
| 158 | .await? |
| 159 | .results::<AiRow>() |
| 160 | } |
| 161 | |
| 162 | /// The AI provider, if it is in `workspace`. |
| 163 | pub(crate) async fn ai_row_in(&self, workspace: &str, id: &str) -> Result<Option<AiRow>> { |
| 164 | Ok(self.ai_row(id).await?.filter(|row| row.workspace == workspace)) |
| 165 | } |
| 166 | |
| 167 | /// Notes that talking to a provider worked, or what went wrong. |
| 168 | pub(crate) async fn ai_note(&self, id: &str, problem: Option<&str>) -> Result<()> { |
| 169 | let now = rfc3339(now_ms()); |
| 170 | match problem { |
| 171 | None => self |
| 172 | .db |
| 173 | .prepare("UPDATE ai_providers SET last_used_at = ?, last_error = NULL WHERE id = ?") |
| 174 | .bind(&[now.into(), id.into()])?, |
| 175 | Some(problem) => self |
| 176 | .db |
| 177 | .prepare("UPDATE ai_providers SET last_error = ? WHERE id = ?") |
| 178 | .bind(&[format!("{now}: {problem}").into(), id.into()])?, |
| 179 | } |
| 180 | .run() |
| 181 | .await?; |
| 182 | Ok(()) |
| 183 | } |
| 184 | |
| 185 | /// Keeps what a check found: the models it offers, or what went wrong. |
| 186 | async fn ai_checked(&self, id: &str, checked: &std::result::Result<(String, Vec<String>), String>) -> Result<()> { |
| 187 | if let Ok((_, models)) = checked |
| 188 | && !models.is_empty() |
| 189 | { |
| 190 | self.db |
| 191 | .prepare("UPDATE ai_providers SET models = ? WHERE id = ?") |
| 192 | .bind(&[serde_json::to_string(models)?.into(), id.into()])? |
| 193 | .run() |
| 194 | .await?; |
| 195 | } |
| 196 | self.ai_note(id, checked.as_ref().err().map(String::as_str)).await |
| 197 | } |
| 198 | |
| 199 | // --- The workspace's AI providers ------------------------------------------- |
| 200 | |
| 201 | async fn ai_list(&self, a: ListAiArgs) -> Result<Outcome<Vec<AiConnection>>> { |
| 202 | let workspace = a.workspace.to_lowercase(); |
| 203 | if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) { |
| 204 | return Ok(fail(FailureCode::Forbidden, "Only members can see a workspace's AI providers.")); |
| 205 | } |
| 206 | Ok(Outcome::Ok(self.ai_rows(&workspace).await?.iter().map(to_ai).collect())) |
| 207 | } |
| 208 | |
| 209 | async fn ai_add(&self, a: AddAiArgs) -> Result<Outcome<AiConnection>> { |
| 210 | let workspace = a.workspace.to_lowercase(); |
| 211 | if let Some(refused) = owner_only(&a.actor, &workspace) { |
| 212 | return Ok(refused); |
| 213 | } |
| 214 | if self.sealer.is_none() { |
| 215 | return Ok(fail(FailureCode::Conflict, NO_SEALER)); |
| 216 | } |
| 217 | let provider = a.provider; |
| 218 | let tidy = |value: Option<String>| value.map(|v| v.trim().to_owned()).filter(|v| !v.is_empty()); |
| 219 | let secret = tidy(a.secret); |
| 220 | let mut config = a.config; |
| 221 | if let Err(problem) = check_config(provider, &mut config, secret.as_deref()) { |
| 222 | return Ok(fail(FailureCode::Invalid, problem)); |
| 223 | } |
| 224 | let now = now_ms(); |
| 225 | let id = new_id("aip", now); |
| 226 | let name = tidy(a.name).unwrap_or_else(|| provider.label().to_owned()); |
| 227 | self.db |
| 228 | .prepare( |
| 229 | "INSERT INTO ai_providers |
| 230 | (id, workspace, provider, name, config, secrets, secret_hint, created_by, created_at) |
| 231 | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", |
| 232 | ) |
| 233 | .bind(&[ |
| 234 | id.as_str().into(), |
| 235 | workspace.as_str().into(), |
| 236 | provider.name().into(), |
| 237 | name.chars().take(80).collect::<String>().into(), |
| 238 | serde_json::to_string(&config)?.into(), |
| 239 | optional(self.ai_seal(&id, secret.as_deref()).as_deref()), |
| 240 | optional(secret.as_deref().map(crypto::hint).as_deref()), |
| 241 | a.actor.username.as_str().into(), |
| 242 | rfc3339(now).into(), |
| 243 | ])? |
| 244 | .run() |
| 245 | .await?; |
| 246 | // Checked at once, which also learns its models. |
| 247 | let checked = models::test(provider, &config, secret.as_deref()).await?; |
| 248 | self.ai_checked(&id, &checked).await?; |
| 249 | let Some(row) = self.ai_row(&id).await? else { |
| 250 | return Ok(fail(FailureCode::NotFound, "The AI provider was not saved.")); |
| 251 | }; |
| 252 | Ok(Outcome::Ok(to_ai(&row))) |
| 253 | } |
| 254 | |
| 255 | async fn ai_update(&self, a: UpdateAiArgs) -> Result<Outcome<AiConnection>> { |
| 256 | let workspace = a.workspace.to_lowercase(); |
| 257 | if let Some(refused) = owner_only(&a.actor, &workspace) { |
| 258 | return Ok(refused); |
| 259 | } |
| 260 | let Some(row) = self.ai_row_in(&workspace, &a.id).await? else { |
| 261 | return Ok(fail(FailureCode::NotFound, NO_SUCH)); |
| 262 | }; |
| 263 | let tidy = |value: Option<String>| value.map(|v| v.trim().to_owned()).filter(|v| !v.is_empty()); |
| 264 | let secret = tidy(a.secret).or_else(|| self.ai_secret(&row)); |
| 265 | let mut config = a.config.unwrap_or_else(|| row.config()); |
| 266 | if let Err(problem) = check_config(row.provider(), &mut config, secret.as_deref()) { |
| 267 | return Ok(fail(FailureCode::Invalid, problem)); |
| 268 | } |
| 269 | let name = tidy(a.name).unwrap_or(row.name.clone()); |
| 270 | self.db |
| 271 | .prepare("UPDATE ai_providers SET name = ?, config = ?, secrets = ?, secret_hint = ?, last_error = NULL WHERE id = ?") |
| 272 | .bind(&[ |
| 273 | name.chars().take(80).collect::<String>().into(), |
| 274 | serde_json::to_string(&config)?.into(), |
| 275 | optional(self.ai_seal(&row.id, secret.as_deref()).as_deref()), |
| 276 | optional(secret.as_deref().map(crypto::hint).as_deref()), |
| 277 | row.id.as_str().into(), |
| 278 | ])? |
| 279 | .run() |
| 280 | .await?; |
| 281 | let Some(row) = self.ai_row(&row.id).await? else { |
| 282 | return Ok(fail(FailureCode::NotFound, NO_SUCH)); |
| 283 | }; |
| 284 | Ok(Outcome::Ok(to_ai(&row))) |
| 285 | } |
| 286 | |
| 287 | async fn ai_remove(&self, a: AiConnectionArgs) -> Result<Outcome<bool>> { |
| 288 | let workspace = a.workspace.to_lowercase(); |
| 289 | if let Some(refused) = owner_only(&a.actor, &workspace) { |
| 290 | return Ok(refused); |
| 291 | } |
| 292 | let Some(row) = self.ai_row_in(&workspace, &a.id).await? else { |
| 293 | return Ok(fail(FailureCode::NotFound, NO_SUCH)); |
| 294 | }; |
| 295 | self.db |
| 296 | .batch(vec![ |
| 297 | self.db.prepare("DELETE FROM ai_providers WHERE id = ?").bind(&[row.id.as_str().into()])?, |
| 298 | // Ask stops answering with it. |
| 299 | self.db.prepare("DELETE FROM ai_routes WHERE provider_id = ?").bind(&[row.id.as_str().into()])?, |
| 300 | self.db.prepare("DELETE FROM managed_agents WHERE connection_id = ?").bind(&[row.id.as_str().into()])?, |
| 301 | ]) |
| 302 | .await?; |
| 303 | Ok(Outcome::Ok(true)) |
| 304 | } |
| 305 | |
| 306 | async fn ai_test(&self, a: AiConnectionArgs) -> Result<Outcome<Tested>> { |
| 307 | let workspace = a.workspace.to_lowercase(); |
| 308 | if let Some(refused) = owner_only(&a.actor, &workspace) { |
| 309 | return Ok(refused); |
| 310 | } |
| 311 | let Some(row) = self.ai_row_in(&workspace, &a.id).await? else { |
| 312 | return Ok(fail(FailureCode::NotFound, NO_SUCH)); |
| 313 | }; |
| 314 | let checked = models::test(row.provider(), &row.config(), self.ai_secret(&row).as_deref()).await?; |
| 315 | self.ai_checked(&row.id, &checked).await?; |
| 316 | Ok(Outcome::Ok(match checked { |
| 317 | Ok((message, _)) => Tested { ok: true, message }, |
| 318 | Err(message) => Tested { ok: false, message }, |
| 319 | })) |
| 320 | } |
| 321 | |
| 322 | // --- Ask g1t ------------------------------------------------------------------- |
| 323 | |
| 324 | /// What Ask answers with: the owner's choice, kept as the workspace's |
| 325 | /// `ask` route, and the AI provider it names. |
| 326 | pub(crate) async fn ask_setup(&self, workspace: &str) -> Result<(AskSetup, Option<AiRow>)> { |
| 327 | #[derive(Deserialize)] |
| 328 | struct Route { |
| 329 | provider_id: Option<String>, |
| 330 | model: Option<String>, |
| 331 | #[serde(default)] |
| 332 | auto: u32, |
| 333 | } |
| 334 | let route = self |
| 335 | .db |
| 336 | .prepare("SELECT provider_id, model, auto FROM ai_routes WHERE workspace = ? AND task = ?") |
| 337 | .bind(&[workspace.into(), ASK_TASK.into()])? |
| 338 | .first::<Route>(None) |
| 339 | .await?; |
| 340 | let Some(id) = route.as_ref().and_then(|route| route.provider_id.clone()) else { |
| 341 | return Ok(( |
| 342 | AskSetup { |
| 343 | problem: Some("No AI provider is chosen for Ask in this workspace yet.".to_owned()), |
| 344 | ..AskSetup::default() |
| 345 | }, |
| 346 | None, |
| 347 | )); |
| 348 | }; |
| 349 | let Some(row) = self.ai_row_in(workspace, &id).await? else { |
| 350 | return Ok(( |
| 351 | AskSetup { |
| 352 | problem: Some("The AI provider chosen for Ask was removed. Choose another.".to_owned()), |
| 353 | ..AskSetup::default() |
| 354 | }, |
| 355 | None, |
| 356 | )); |
| 357 | }; |
| 358 | let auto = route.as_ref().is_some_and(|route| route.auto == 1); |
| 359 | // With Auto, a model is named only when the provider offers just |
| 360 | // its own: an endpoint that lists none, or an Azure deployment. |
| 361 | let model = if auto { |
| 362 | auto_models(&row).is_empty().then(|| row.config().model).flatten() |
| 363 | } else { |
| 364 | route.and_then(|route| route.model).or_else(|| row.config().model) |
| 365 | } |
| 366 | .filter(|model| !model.trim().is_empty()); |
| 367 | let problem = if auto && model.is_none() && auto_models(&row).is_empty() { |
| 368 | Some(format!("{} has listed no models for Auto to choose from. Test it to list them, or choose a model for Ask.", row.name)) |
| 369 | } else if !auto && model.is_none() { |
| 370 | Some("No model is chosen for Ask.".to_owned()) |
| 371 | } else if self.sealer.is_none() { |
| 372 | Some(NO_SEALER.to_owned()) |
| 373 | } else if !row.provider().own_address() && self.ai_secret(&row).is_none() { |
| 374 | Some(format!("The key for {} cannot be read. Replace it.", row.name)) |
| 375 | } else { |
| 376 | None |
| 377 | }; |
| 378 | Ok(( |
| 379 | AskSetup { |
| 380 | provider_id: Some(row.id.clone()), |
| 381 | provider_name: Some(row.name.clone()), |
| 382 | provider: Some(row.provider()), |
| 383 | model, |
| 384 | auto, |
| 385 | problem, |
| 386 | }, |
| 387 | Some(row), |
| 388 | )) |
| 389 | } |
| 390 | |
| 391 | pub(crate) async fn ask_settings(&self, a: AskSettingsArgs) -> Result<Outcome<AskSetup>> { |
| 392 | let workspace = a.workspace.to_lowercase(); |
| 393 | if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) { |
| 394 | return Ok(fail(FailureCode::Forbidden, "Only members can use Ask in a workspace.")); |
| 395 | } |
| 396 | Ok(Outcome::Ok(self.ask_setup(&workspace).await?.0)) |
| 397 | } |
| 398 | |
| 399 | pub(crate) async fn set_ask_settings(&self, a: SetAskSettingsArgs) -> Result<Outcome<AskSetup>> { |
| 400 | let workspace = a.workspace.to_lowercase(); |
| 401 | if let Some(refused) = owner_only(&a.actor, &workspace) { |
| 402 | return Ok(refused); |
| 403 | } |
| 404 | let Some(id) = a.provider_id.map(|id| id.trim().to_owned()).filter(|id| !id.is_empty()) else { |
| 405 | self.db |
| 406 | .prepare("DELETE FROM ai_routes WHERE workspace = ? AND task = ?") |
| 407 | .bind(&[workspace.as_str().into(), ASK_TASK.into()])? |
| 408 | .run() |
| 409 | .await?; |
| 410 | return Ok(Outcome::Ok(self.ask_setup(&workspace).await?.0)); |
| 411 | }; |
| 412 | let Some(row) = self.ai_row_in(&workspace, &id).await? else { |
| 413 | return Ok(fail(FailureCode::NotFound, NO_SUCH)); |
| 414 | }; |
| 415 | let connection = to_ai(&row); |
| 416 | let auto = chose_auto(a.model.as_deref()); |
| 417 | let model = if auto { |
| 418 | None |
| 419 | } else { |
| 420 | match ask_model(&connection, a.model.as_deref()) { |
| 421 | Ok(model) => model, |
| 422 | Err(problem) => return Ok(fail(FailureCode::Invalid, problem)), |
| 423 | } |
| 424 | }; |
| 425 | self.db |
| 426 | .prepare( |
| 427 | "INSERT INTO ai_routes (workspace, task, provider_id, model, auto) VALUES (?, ?, ?, ?, ?) |
| 428 | ON CONFLICT (workspace, task) DO UPDATE SET provider_id = excluded.provider_id, model = excluded.model, auto = excluded.auto", |
| 429 | ) |
| 430 | .bind(&[workspace.as_str().into(), ASK_TASK.into(), row.id.as_str().into(), optional(model.as_deref()), u32::from(auto).into()])? |
| 431 | .run() |
| 432 | .await?; |
| 433 | Ok(Outcome::Ok(self.ask_setup(&workspace).await?.0)) |
| 434 | } |
| 435 | |
| 436 | /// The issue or pull request the person is looking at, as they may see |
| 437 | /// it. Nothing when there is none or they cannot. |
| 438 | pub(crate) async fn ask_facts(&self, actor: &User, page: &AskPage) -> Result<Vec<ask::Fact>> { |
| 439 | let Some(repo) = &page.repo else { |
| 440 | return Ok(Vec::new()); |
| 441 | }; |
| 442 | let view = |number: u32| ViewArgs { |
| 443 | repo: repo.clone(), |
| 444 | number, |
| 445 | viewer: Some(actor.clone()), |
| 446 | after_seq: 0, |
| 447 | }; |
| 448 | let state = |value: Value| value.as_str().unwrap_or_default().to_owned(); |
| 449 | let mut facts = Vec::new(); |
| 450 | if let Some(number) = page.issue { |
| 451 | let found: Outcome<IssueDetail> = g1t_kit::call(&self.work, "get_issue", &view(number)).await?; |
| 452 | if let Outcome::Ok(detail) = found { |
| 453 | facts.push(ask::Fact { |
| 454 | kind: "issue", |
| 455 | number, |
| 456 | state: state(serde_json::to_value(detail.issue.state)?), |
| 457 | title: detail.issue.title, |
| 458 | body: http::shorten(&detail.issue.body, ASK_FACT_CHARS), |
| 459 | }); |
| 460 | } |
| 461 | } |
| 462 | if let Some(number) = page.pull { |
| 463 | let found: Outcome<PullDetail> = g1t_kit::call(&self.work, "get_pull", &view(number)).await?; |
| 464 | if let Outcome::Ok(detail) = found { |
| 465 | facts.push(ask::Fact { |
| 466 | kind: "pull request", |
| 467 | number, |
| 468 | state: state(serde_json::to_value(detail.pull.status)?), |
| 469 | body: http::shorten(detail.pull.body.as_deref().unwrap_or_default(), ASK_FACT_CHARS), |
| 470 | title: detail.pull.title, |
| 471 | }); |
| 472 | } |
| 473 | } |
| 474 | Ok(facts) |
| 475 | } |
| 476 | |
| 477 | /// One answer, streamed: see [`AskArgs`] and ask_loop.rs. |
| 478 | /// |
| 479 | /// Before the provider is asked, identity makes the answer's read-only |
| 480 | /// credential for the person asking, and g1t's MCP server lists the |
| 481 | /// tools it may use. Without them the answer still comes, from the page |
| 482 | /// alone, and says so. |
| 483 | pub(crate) async fn ask(&self, a: AskArgs) -> Result<Response> { |
| 484 | let started = now_ms(); |
| 485 | let workspace = a.workspace.to_lowercase(); |
| 486 | if !a.actor.is_member(&workspace) { |
| 487 | return ask_refusal("Only members can use Ask in this workspace."); |
| 488 | } |
| 489 | let messages = match ask::conversation(a.messages) { |
| 490 | Ok(messages) => messages, |
| 491 | Err(problem) => return ask_refusal(&problem), |
| 492 | }; |
| 493 | let (setup, row) = self.ask_setup(&workspace).await?; |
| 494 | let (Some(row), None) = (row, setup.problem.clone()) else { |
| 495 | return ask_refusal(setup.problem.as_deref().unwrap_or("Ask is not set up in this workspace.")); |
| 496 | }; |
| 497 | // Auto picks for this question; otherwise the model chosen. |
| 498 | let model = if setup.auto { |
| 499 | auto::choose(&auto_models(&row), row.config().model.as_deref(), auto::need(&messages)) |
| 500 | } else { |
| 501 | setup.model.clone() |
| 502 | }; |
| 503 | let Some(model) = model else { |
| 504 | return ask_refusal("No model is chosen for Ask."); |
| 505 | }; |
| 506 | let provider = row.provider(); |
| 507 | let config = row.config(); |
| 508 | let key = self.ai_secret(&row); |
| 509 | let (named, facts, credential, profile) = futures_util::future::join4( |
| 510 | g1t_kit::call::<_, Option<Workspace>>(&self.identity, "get_workspace", &SlugArgs { slug: workspace.clone() }), |
| 511 | self.ask_facts(&a.actor, &a.page), |
| 512 | g1t_kit::call::<_, Outcome<AskToken>>( |
| 513 | &self.identity, |
| 514 | "create_ask_token", |
| 515 | &CreateAskTokenArgs { actor: a.actor.clone(), workspace: workspace.clone() }, |
| 516 | ), |
| 517 | // Their time zone, for what "today" means to them. |
| 518 | g1t_kit::call::<_, Option<Profile>>(&self.identity, "profile", &UsernameArgs { username: a.actor.username.clone() }), |
| 519 | ) |
| 520 | .await; |
| 521 | let name = named.ok().flatten().map(|found| found.name).unwrap_or_default(); |
| 522 | // Without what the page is about, the answer can still come. |
| 523 | let facts = facts.unwrap_or_default(); |
| 524 | let credential = match credential { |
| 525 | Ok(Outcome::Ok(credential)) => Some(credential), |
| 526 | Ok(Outcome::Fail(refused)) => { |
| 527 | worker::console_error!("ask: no credential for the tools: {}", refused.message); |
| 528 | None |
| 529 | } |
| 530 | Err(error) => { |
| 531 | worker::console_error!("ask: no credential for the tools: {error}"); |
| 532 | None |
| 533 | } |
| 534 | }; |
| 535 | let mcp = credential.as_ref().map(|credential| ask_tools::Mcp { url: self.mcp_url.clone(), token: credential.token.clone() }); |
| 536 | let listed = match &mcp { |
| 537 | Some(mcp) => mcp.list().await.unwrap_or_else(|problem| { |
| 538 | worker::console_error!("ask: g1t's tools could not be listed: {problem}"); |
| 539 | Vec::new() |
| 540 | }), |
| 541 | None => Vec::new(), |
| 542 | }; |
| 543 | let toolbox = ask_tools::Toolbox::new(listed, ask_tools::native()); |
| 544 | let now = rfc3339(started); |
| 545 | let today: String = now.chars().take(10).collect(); |
| 546 | let now: String = format!("{}Z", now.chars().take(19).collect::<String>()); |
| 547 | // Their profile's zone first, else the one their browser sent. |
| 548 | let zone = profile |
| 549 | .ok() |
| 550 | .flatten() |
| 551 | .and_then(|profile| profile.timezone) |
| 552 | .or_else(|| a.page.time_zone.clone()) |
| 553 | .filter(|zone| !zone.trim().is_empty() && zone.len() <= 64); |
| 554 | let day = zone.as_deref().and_then(|zone| local_day::today(zone, started)); |
| 555 | let day_started = day.as_ref().map(local_day::started_at); |
| 556 | let local = match (&zone, &day, &day_started) { |
| 557 | (Some(zone), Some(day), Some(started)) => Some((zone.as_str(), day.date.as_str(), started.as_str())), |
| 558 | _ => None, |
| 559 | }; |
| 560 | let username = a.actor.display_username.clone().unwrap_or_else(|| a.actor.username.clone()); |
| 561 | let system = ask::system_prompt(&ask::Context { |
| 562 | slug: &workspace, |
| 563 | name: &name, |
| 564 | username: &username, |
| 565 | site: &self.site_url, |
| 566 | today: &today, |
| 567 | now: &now, |
| 568 | page: &a.page, |
| 569 | facts: &facts, |
| 570 | tools: !toolbox.is_empty(), |
| 571 | local, |
| 572 | }); |
| 573 | let mut answering = ask_loop::Answering::new( |
| 574 | provider, |
| 575 | config, |
| 576 | key, |
| 577 | model.clone(), |
| 578 | system, |
| 579 | messages, |
| 580 | ASK_MAX_OUTPUT_TOKENS, |
| 581 | toolbox, |
| 582 | mcp, |
| 583 | self.site_url.clone(), |
| 584 | self.identity.clone(), |
| 585 | credential.map(|credential| credential.id), |
| 586 | started, |
| 587 | ); |
| 588 | // The first round is asked here, so a provider that refuses is a |
| 589 | // refusal with its reason, and the provider notes it. |
| 590 | let built = match answering.request() { |
| 591 | Ok(built) => built, |
| 592 | Err(problem) => return self.ask_refused(answering, &problem).await, |
| 593 | }; |
| 594 | let headers: Vec<(&str, &str)> = built.headers.iter().map(|(name, value)| (name.as_str(), value.as_str())).collect(); |
| 595 | let mut response = match http::open(Method::Post, &built.url, &headers, Some(built.body)).await { |
| 596 | Ok(response) => response, |
| 597 | Err(error) => { |
| 598 | let problem = format!("{} could not be reached: {error}", provider.label()); |
| 599 | self.ai_note(&row.id, Some(&problem)).await?; |
| 600 | return self.ask_refused(answering, &problem).await; |
| 601 | } |
| 602 | }; |
| 603 | let status = response.status_code(); |
| 604 | if !(200..300).contains(&status) { |
| 605 | let problem = ask_loop::refused_by(provider, status, response.text().await.unwrap_or_default()); |
| 606 | self.ai_note(&row.id, Some(&problem)).await?; |
| 607 | return self.ask_refused(answering, &problem).await; |
| 608 | } |
| 609 | self.ai_note(&row.id, None).await?; |
| 610 | let meta = ask::line(&AskEvent::Meta { |
| 611 | provider, |
| 612 | provider_label: provider.label().to_owned(), |
| 613 | model, |
| 614 | auto: setup.auto, |
| 615 | }); |
| 616 | answering.streaming(response.stream()?, meta); |
| 617 | let lines = futures_util::stream::unfold(answering, |mut answering| async move { |
| 618 | let line = answering.next().await?; |
| 619 | Some((Ok::<Vec<u8>, worker::Error>(line.into_bytes()), answering)) |
| 620 | }); |
| 621 | let headers = Headers::new(); |
| 622 | headers.set("content-type", ASK_CONTENT_TYPE)?; |
| 623 | headers.set("cache-control", "no-store")?; |
| 624 | Ok(Response::from_stream(lines)?.with_headers(headers)) |
| 625 | } |
| 626 | |
| 627 | /// An answer that never started: its credential ends now. |
| 628 | pub(crate) async fn ask_refused(&self, mut answering: ask_loop::Answering, problem: &str) -> Result<Response> { |
| 629 | if let Some(id) = answering.credential.take() { |
| 630 | let _: Result<bool> = g1t_kit::call(&self.identity, "revoke_ask_token", &RevokeAskTokenArgs { id }).await; |
| 631 | } |
| 632 | ask_refusal(problem) |
| 633 | } |
| 634 | |
| 635 | } |
| 636 | |
| 637 | /// The route that holds the workspace's choice for Ask g1t. |
| 638 | const ASK_TASK: &str = "ask"; |
| 639 | /// How much of an issue's or pull request's description Ask is given. |
| 640 | const ASK_FACT_CHARS: usize = 6_000; |
| 641 | const ASK_CONTENT_TYPE: &str = "application/x-ndjson; charset=utf-8"; |
| 642 | |
| 643 | /// The model Ask uses on `connection`: the one chosen, else the |
| 644 | /// provider's own (an Azure deployment). One the provider listed when it |
| 645 | /// was last checked, where it lists them and it is not an Azure deployment. |
| 646 | fn ask_model(connection: &AiConnection, chosen: Option<&str>) -> std::result::Result<Option<String>, String> { |
| 647 | let chosen = chosen.map(str::trim).filter(|model| !model.is_empty()); |
| 648 | let Some(model) = chosen.map(str::to_owned).or_else(|| connection.config.model.clone()) else { |
| 649 | return Err("Choose a model for Ask.".to_owned()); |
| 650 | }; |
| 651 | if model.chars().count() > 200 { |
| 652 | return Err("That model's name is too long.".to_owned()); |
| 653 | } |
| 654 | let checks_list = connection.provider != AiProvider::AzureOpenai && !connection.models.is_empty(); |
| 655 | if chosen.is_some() && checks_list && !connection.models.iter().any(|offered| offered == &model) { |
| 656 | return Err(format!("{} does not offer {model}. Test it to list its models again.", connection.name)); |
| 657 | } |
| 658 | Ok(Some(model)) |
| 659 | } |
| 660 | |
| 661 | /// Whether a choice of model for Ask is Auto: [`ASK_AUTO_MODEL`], in any case. |
| 662 | fn chose_auto(chosen: Option<&str>) -> bool { |
| 663 | chosen.is_some_and(|model| model.trim().eq_ignore_ascii_case(ASK_AUTO_MODEL)) |
| 664 | } |
| 665 | |
| 666 | /// The models Auto chooses among on a provider: those it listed, except |
| 667 | /// on Azure, whose requests name the owner's deployment, not a model. |
| 668 | fn auto_models(row: &AiRow) -> Vec<String> { |
| 669 | if row.provider() == AiProvider::AzureOpenai { Vec::new() } else { row.models() } |
| 670 | } |
| 671 | |
| 672 | /// An `ask` that cannot be answered: one `error` line. |
| 673 | fn ask_refusal(message: &str) -> Result<Response> { |
| 674 | let headers = Headers::new(); |
| 675 | headers.set("content-type", ASK_CONTENT_TYPE)?; |
| 676 | headers.set("cache-control", "no-store")?; |
| 677 | Ok(Response::ok(ask::line(&AskEvent::Error { message: message.to_owned() }))?.with_headers(headers)) |
| 678 | } |
| 679 | |
| 680 | #[cfg(test)] |
| 681 | mod tests { |
| 682 | use super::*; |
| 683 | |
| 684 | #[test] |
| 685 | fn an_ai_provider_needs_its_key_and_its_address_when_it_has_no_known_one() { |
| 686 | let key = Some("sk-test"); |
| 687 | let none = None; |
| 688 | let mut config = AiConfig::default(); |
| 689 | assert!(check_config(AiProvider::Anthropic, &mut config, key).is_ok()); |
| 690 | assert_eq!(check_config(AiProvider::Openai, &mut AiConfig::default(), none).unwrap_err(), "Paste your OpenAI API key."); |
| 691 | // Your own endpoint needs its address, over https, and a known header. |
| 692 | assert!(check_config(AiProvider::OpenaiEndpoint, &mut AiConfig::default(), none).is_err()); |
| 693 | let mut endpoint = AiConfig { base_url: Some(" https://llm.acme.dev/v1/ ".into()), ..AiConfig::default() }; |
| 694 | assert!(check_config(AiProvider::OpenaiEndpoint, &mut endpoint, none).is_ok()); |
| 695 | assert_eq!(endpoint.base_url.as_deref(), Some("https://llm.acme.dev/v1")); |
| 696 | let mut plain = AiConfig { base_url: Some("http://llm.acme.dev".into()), ..AiConfig::default() }; |
| 697 | assert!(check_config(AiProvider::AnthropicEndpoint, &mut plain, key).is_err()); |
| 698 | let mut header = AiConfig { |
| 699 | base_url: Some("https://llm.acme.dev".into()), |
| 700 | auth_header: Some("x-token".into()), |
| 701 | ..AiConfig::default() |
| 702 | }; |
| 703 | assert!(check_config(AiProvider::AnthropicEndpoint, &mut header, key).is_err()); |
| 704 | // Azure needs its resource and deployment as well as the key. |
| 705 | let mut azure = AiConfig { base_url: Some("https://acme.openai.azure.com".into()), ..AiConfig::default() }; |
| 706 | assert!(check_config(AiProvider::AzureOpenai, &mut azure, key).is_err()); |
| 707 | azure.model = Some("gpt-5".into()); |
| 708 | assert!(check_config(AiProvider::AzureOpenai, &mut azure, key).is_ok()); |
| 709 | } |
| 710 | |
| 711 | fn connection(provider: AiProvider, model: Option<&str>, models: &[&str]) -> AiConnection { |
| 712 | AiConnection { |
| 713 | id: "con_1".into(), |
| 714 | workspace: "acme".into(), |
| 715 | provider, |
| 716 | group: provider.group(), |
| 717 | name: "Team key".into(), |
| 718 | config: AiConfig { model: model.map(str::to_owned), ..AiConfig::default() }, |
| 719 | secret_hint: None, |
| 720 | created_by: "ana".into(), |
| 721 | created_at: "2026-10-12T00:00:00Z".into(), |
| 722 | last_used_at: None, |
| 723 | last_error: None, |
| 724 | models: models.iter().map(|model| (*model).to_owned()).collect(), |
| 725 | } |
| 726 | } |
| 727 | |
| 728 | #[test] |
| 729 | fn asks_model_is_one_the_provider_offers_or_its_own() { |
| 730 | let listed = connection(AiProvider::Openai, None, &["gpt-5", "gpt-5-mini"]); |
| 731 | assert_eq!(ask_model(&listed, Some(" gpt-5 ")).unwrap().as_deref(), Some("gpt-5")); |
| 732 | assert!(ask_model(&listed, Some("gpt-4")).unwrap_err().contains("does not offer gpt-4")); |
| 733 | assert_eq!(ask_model(&listed, None).unwrap_err(), "Choose a model for Ask."); |
| 734 | // The provider's own model when none is chosen. |
| 735 | let own = connection(AiProvider::Anthropic, Some("claude-x"), &[]); |
| 736 | assert_eq!(ask_model(&own, Some("")).unwrap().as_deref(), Some("claude-x")); |
| 737 | // An endpoint that lists nothing takes what the owner writes. |
| 738 | let open = connection(AiProvider::OpenaiEndpoint, None, &[]); |
| 739 | assert_eq!(ask_model(&open, Some("qwen3-coder")).unwrap().as_deref(), Some("qwen3-coder")); |
| 740 | // Azure lists base models, not deployments: its deployment stands. |
| 741 | let azure = connection(AiProvider::AzureOpenai, Some("prod-gpt"), &["gpt-5"]); |
| 742 | assert_eq!(ask_model(&azure, Some("prod-gpt")).unwrap().as_deref(), Some("prod-gpt")); |
| 743 | assert!(ask_model(&own, Some(&"m".repeat(201))).is_err()); |
| 744 | } |
| 745 | |
| 746 | fn row(provider: AiProvider, model: Option<&str>, models: &[&str]) -> AiRow { |
| 747 | AiRow { |
| 748 | id: "con_1".into(), |
| 749 | workspace: "acme".into(), |
| 750 | provider: provider.name().into(), |
| 751 | name: "Team key".into(), |
| 752 | config: serde_json::to_string(&AiConfig { model: model.map(str::to_owned), ..AiConfig::default() }).unwrap(), |
| 753 | secrets: None, |
| 754 | secret_hint: None, |
| 755 | created_by: "ana".into(), |
| 756 | created_at: "2026-10-12T00:00:00Z".into(), |
| 757 | last_used_at: None, |
| 758 | last_error: None, |
| 759 | models: Some(serde_json::to_string(models).unwrap()), |
| 760 | } |
| 761 | } |
| 762 | |
| 763 | #[test] |
| 764 | fn auto_is_chosen_by_name_and_picks_among_what_the_provider_lists() { |
| 765 | assert!(chose_auto(Some("auto")) && chose_auto(Some(" Auto "))); |
| 766 | assert!(!chose_auto(Some("claude-haiku-5-5")) && !chose_auto(None) && !chose_auto(Some(""))); |
| 767 | let anthropic = row(AiProvider::Anthropic, None, &["claude-haiku-5-5", "claude-opus-5-5", "claude-sonnet-5-5"]); |
| 768 | let models = auto_models(&anthropic); |
| 769 | let simple = [AskMessage { role: AskRole::User, content: "Who is assigned to #12?".into() }]; |
| 770 | let shipped = [AskMessage { role: AskRole::User, content: "What shipped today?".into() }]; |
| 771 | assert_eq!(auto::choose(&models, None, auto::need(&simple)).as_deref(), Some("claude-haiku-5-5")); |
| 772 | assert_eq!(auto::choose(&models, None, auto::need(&shipped)).as_deref(), Some("claude-sonnet-5-5")); |
| 773 | // Azure names a deployment, never a listed model: Auto uses it. |
| 774 | let azure = row(AiProvider::AzureOpenai, Some("prod-gpt"), &["gpt-5", "gpt-5-mini"]); |
| 775 | assert!(auto_models(&azure).is_empty()); |
| 776 | assert_eq!(auto::choose(&auto_models(&azure), azure.config().model.as_deref(), auto::need(&simple)).as_deref(), Some("prod-gpt")); |
| 777 | } |
| 778 | |
| 779 | } |