Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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