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.

g1t.sh
779 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::{IssueDetail, PullDetail, ViewArgs};
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"];
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 _ => Response::error("Unknown method", 404),
39 }
40}
41
42const NO_SUCH: &str = "No such AI provider.";
43const 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)]
47pub(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
62impl 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
77fn 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.
96fn 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.
103fn 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
135impl 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.
638const ASK_TASK: &str = "ask";
639/// How much of an issue's or pull request's description Ask is given.
640const ASK_FACT_CHARS: usize = 6_000;
641const 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.
646fn 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.
662fn 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.
668fn 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.
673fn 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)]
681mod 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}