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