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.

821 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"];
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 work item or pull request the person is looking at, as they may
454 /// see 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 state = |value: Value| value.as_str().unwrap_or_default().to_owned();
457 let mut facts = Vec::new();
458 let workspace = page.repo.as_ref().map(|repo| repo.namespace.to_lowercase()).or_else(|| page.workspace.clone());
459 if let (Some(number), Some(workspace)) = (page.item, workspace) {
460 let view = WorkItemViewArgs { workspace, number, viewer: Some(actor.clone()) };
461 let found: Outcome<WorkItemDetail> = g1t_kit::call(&self.work, "get_work_item", &view).await?;
462 if let Outcome::Ok(detail) = found {
463 facts.push(ask::Fact {
464 kind: "work item",
465 number,
466 state: state(serde_json::to_value(detail.item.state)?),
467 title: detail.item.title,
468 body: http::shorten(&detail.item.body, ASK_FACT_CHARS),
469 });
470 }
471 }
472 let Some(repo) = &page.repo else {
473 return Ok(facts);
474 };
475 let view = |number: u32| ViewArgs {
476 repo: repo.clone(),
477 number,
478 viewer: Some(actor.clone()),
479 after_seq: 0,
480 };
481 if let Some(number) = page.pull {
482 let found: Outcome<PullDetail> = g1t_kit::call(&self.work, "get_pull", &view(number)).await?;
483 if let Outcome::Ok(detail) = found {
484 facts.push(ask::Fact {
485 kind: "pull request",
486 number,
487 state: state(serde_json::to_value(detail.pull.status)?),
488 body: http::shorten(detail.pull.body.as_deref().unwrap_or_default(), ASK_FACT_CHARS),
489 title: detail.pull.title,
490 });
491 }
492 }
493 Ok(facts)
494 }
495
496 /// One answer, streamed: see [`AskArgs`] and ask_loop.rs.
497 ///
498 /// Before the provider is asked, identity makes the answer's read-only
499 /// credential for the person asking, and g1t's MCP server lists the
500 /// tools it may use. Without them the answer still comes, from the page
501 /// alone, and says so.
502 pub(crate) async fn ask(&self, a: AskArgs) -> Result<Response> {
503 let started = now_ms();
504 let workspace = a.workspace.to_lowercase();
505 if !a.actor.is_member(&workspace) {
506 return ask_refusal("Only members can use Ask in this workspace.");
507 }
508 let messages = match ask::conversation(a.messages) {
509 Ok(messages) => messages,
510 Err(problem) => return ask_refusal(&problem),
511 };
512 let (setup, row) = self.ask_setup(&workspace).await?;
513 let (Some(row), None) = (row, setup.problem.clone()) else {
514 return ask_refusal(setup.problem.as_deref().unwrap_or("Ask is not set up in this workspace."));
515 };
516 // Auto picks for this question; otherwise the model chosen.
517 let model = if setup.auto {
518 auto::choose(&auto_models(&row), row.config().model.as_deref(), auto::need(&messages))
519 } else {
520 setup.model.clone()
521 };
522 let Some(model) = model else {
523 return ask_refusal("No model is chosen for Ask.");
524 };
525 let provider = row.provider();
526 let config = row.config();
527 let key = self.ai_secret(&row);
528 let (named, facts, credential, profile) = futures_util::future::join4(
529 g1t_kit::call::<_, Option<Workspace>>(&self.identity, "get_workspace", &SlugArgs { slug: workspace.clone() }),
530 self.ask_facts(&a.actor, &a.page),
531 g1t_kit::call::<_, Outcome<AskToken>>(
532 &self.identity,
533 "create_ask_token",
534 &CreateAskTokenArgs { actor: a.actor.clone(), workspace: workspace.clone() },
535 ),
536 // Their time zone, for what "today" means to them.
537 g1t_kit::call::<_, Option<Profile>>(&self.identity, "profile", &UsernameArgs { username: a.actor.username.clone() }),
538 )
539 .await;
540 let name = named.ok().flatten().map(|found| found.name).unwrap_or_default();
541 // Without what the page is about, the answer can still come.
542 let facts = facts.unwrap_or_default();
543 let credential = match credential {
544 Ok(Outcome::Ok(credential)) => Some(credential),
545 Ok(Outcome::Fail(refused)) => {
546 worker::console_error!("ask: no credential for the tools: {}", refused.message);
547 None
548 }
549 Err(error) => {
550 worker::console_error!("ask: no credential for the tools: {error}");
551 None
552 }
553 };
554 let mcp = credential.as_ref().map(|credential| ask_tools::Mcp { url: self.mcp_url.clone(), token: credential.token.clone() });
555 let listed = match &mcp {
556 Some(mcp) => mcp.list().await.unwrap_or_else(|problem| {
557 worker::console_error!("ask: g1t's tools could not be listed: {problem}");
558 Vec::new()
559 }),
560 None => Vec::new(),
561 };
562 let toolbox = ask_tools::Toolbox::new(listed, ask_tools::native());
563 let now = rfc3339(started);
564 let today: String = now.chars().take(10).collect();
565 let now: String = format!("{}Z", now.chars().take(19).collect::<String>());
566 // Their profile's zone first, else the one their browser sent.
567 let zone = profile
568 .ok()
569 .flatten()
570 .and_then(|profile| profile.timezone)
571 .or_else(|| a.page.time_zone.clone())
572 .filter(|zone| !zone.trim().is_empty() && zone.len() <= 64);
573 let day = zone.as_deref().and_then(|zone| local_day::today(zone, started));
574 let day_started = day.as_ref().map(local_day::started_at);
575 let local = match (&zone, &day, &day_started) {
576 (Some(zone), Some(day), Some(started)) => Some((zone.as_str(), day.date.as_str(), started.as_str())),
577 _ => None,
578 };
579 let username = a.actor.display_username.clone().unwrap_or_else(|| a.actor.username.clone());
580 let system = ask::system_prompt(&ask::Context {
581 slug: &workspace,
582 name: &name,
583 username: &username,
584 site: &self.site_url,
585 today: &today,
586 now: &now,
587 page: &a.page,
588 facts: &facts,
589 tools: !toolbox.is_empty(),
590 local,
591 });
592 let mut answering = ask_loop::Answering::new(
593 provider,
594 config,
595 key,
596 model.clone(),
597 system,
598 messages,
599 ASK_MAX_OUTPUT_TOKENS,
600 toolbox,
601 mcp,
602 self.site_url.clone(),
603 self.identity.clone(),
604 credential.map(|credential| credential.id),
605 started,
606 );
607 // The first round is asked here, so a provider that refuses is a
608 // refusal with its reason, and the provider notes it.
609 let built = match answering.request() {
610 Ok(built) => built,
611 Err(problem) => return self.ask_refused(answering, &problem).await,
612 };
613 let headers: Vec<(&str, &str)> = built.headers.iter().map(|(name, value)| (name.as_str(), value.as_str())).collect();
614 let mut response = match http::open(Method::Post, &built.url, &headers, Some(built.body)).await {
615 Ok(response) => response,
616 Err(error) => {
617 let problem = format!("{} could not be reached: {error}", provider.label());
618 self.ai_note(&row.id, Some(&problem)).await?;
619 return self.ask_refused(answering, &problem).await;
620 }
621 };
622 let status = response.status_code();
623 if !(200..300).contains(&status) {
624 let problem = ask_loop::refused_by(provider, status, response.text().await.unwrap_or_default());
625 self.ai_note(&row.id, Some(&problem)).await?;
626 return self.ask_refused(answering, &problem).await;
627 }
628 self.ai_note(&row.id, None).await?;
629 let meta = ask::line(&AskEvent::Meta {
630 provider,
631 provider_label: provider.label().to_owned(),
632 model,
633 auto: setup.auto,
634 });
635 answering.streaming(response.stream()?, meta);
636 let lines = futures_util::stream::unfold(answering, |mut answering| async move {
637 let line = answering.next().await?;
638 Some((Ok::<Vec<u8>, worker::Error>(line.into_bytes()), answering))
639 });
640 let headers = Headers::new();
641 headers.set("content-type", ASK_CONTENT_TYPE)?;
642 headers.set("cache-control", "no-store")?;
643 Ok(Response::from_stream(lines)?.with_headers(headers))
644 }
645
646 /// An answer that never started: its credential ends now.
647 pub(crate) async fn ask_refused(&self, mut answering: ask_loop::Answering, problem: &str) -> Result<Response> {
648 if let Some(id) = answering.credential.take() {
649 let _: Result<bool> = g1t_kit::call(&self.identity, "revoke_ask_token", &RevokeAskTokenArgs { id }).await;
650 }
651 ask_refusal(problem)
652 }
653
654}
655
656/// The route that holds the workspace's choice for Ask g1t.
657const ASK_TASK: &str = "ask";
658/// How much of a work item's or pull request's description Ask is given.
659const ASK_FACT_CHARS: usize = 6_000;
660const ASK_CONTENT_TYPE: &str = "application/x-ndjson; charset=utf-8";
661
662/// The model Ask uses on `connection`: the one chosen, else the
663/// provider's own (an Azure deployment). One the provider listed when it
664/// was last checked, where it lists them and it is not an Azure deployment.
665fn ask_model(connection: &AiConnection, chosen: Option<&str>) -> std::result::Result<Option<String>, String> {
666 let chosen = chosen.map(str::trim).filter(|model| !model.is_empty());
667 let Some(model) = chosen.map(str::to_owned).or_else(|| connection.config.model.clone()) else {
668 return Err("Choose a model for Ask.".to_owned());
669 };
670 if model.chars().count() > 200 {
671 return Err("That model's name is too long.".to_owned());
672 }
673 let checks_list = connection.provider != AiProvider::AzureOpenai && !connection.models.is_empty();
674 if chosen.is_some() && checks_list && !connection.models.iter().any(|offered| offered == &model) {
675 return Err(format!("{} does not offer {model}. Test it to list its models again.", connection.name));
676 }
677 Ok(Some(model))
678}
679
680/// Whether a choice of model for Ask is Auto: [`ASK_AUTO_MODEL`], in any case.
681fn chose_auto(chosen: Option<&str>) -> bool {
682 chosen.is_some_and(|model| model.trim().eq_ignore_ascii_case(ASK_AUTO_MODEL))
683}
684
685/// The models Auto chooses among on a provider: those it listed, except
686/// on Azure, whose requests name the owner's deployment, not a model.
687fn auto_models(row: &AiRow) -> Vec<String> {
688 if row.provider() == AiProvider::AzureOpenai { Vec::new() } else { row.models() }
689}
690
691/// An `ask` that cannot be answered: one `error` line.
692fn ask_refusal(message: &str) -> Result<Response> {
693 let headers = Headers::new();
694 headers.set("content-type", ASK_CONTENT_TYPE)?;
695 headers.set("cache-control", "no-store")?;
696 Ok(Response::ok(ask::line(&AskEvent::Error { message: message.to_owned() }))?.with_headers(headers))
697}
698
699#[cfg(test)]
700mod tests {
701 use super::*;
702
703 #[test]
704 fn an_ai_provider_needs_its_key_and_its_address_when_it_has_no_known_one() {
705 let key = Some("sk-test");
706 let none = None;
707 let mut config = AiConfig::default();
708 assert!(check_config(AiProvider::Anthropic, &mut config, key).is_ok());
709 assert_eq!(check_config(AiProvider::Openai, &mut AiConfig::default(), none).unwrap_err(), "Paste your OpenAI API key.");
710 // Your own endpoint needs its address, over https, and a known header.
711 assert!(check_config(AiProvider::OpenaiEndpoint, &mut AiConfig::default(), none).is_err());
712 let mut endpoint = AiConfig { base_url: Some(" https://llm.acme.dev/v1/ ".into()), ..AiConfig::default() };
713 assert!(check_config(AiProvider::OpenaiEndpoint, &mut endpoint, none).is_ok());
714 assert_eq!(endpoint.base_url.as_deref(), Some("https://llm.acme.dev/v1"));
715 let mut plain = AiConfig { base_url: Some("http://llm.acme.dev".into()), ..AiConfig::default() };
716 assert!(check_config(AiProvider::AnthropicEndpoint, &mut plain, key).is_err());
717 let mut header = AiConfig {
718 base_url: Some("https://llm.acme.dev".into()),
719 auth_header: Some("x-token".into()),
720 ..AiConfig::default()
721 };
722 assert!(check_config(AiProvider::AnthropicEndpoint, &mut header, key).is_err());
723 // Azure needs its resource and deployment as well as the key.
724 let mut azure = AiConfig { base_url: Some("https://acme.openai.azure.com".into()), ..AiConfig::default() };
725 assert!(check_config(AiProvider::AzureOpenai, &mut azure, key).is_err());
726 azure.model = Some("gpt-5".into());
727 assert!(check_config(AiProvider::AzureOpenai, &mut azure, key).is_ok());
728 }
729
730 #[test]
731 fn an_anthropic_providers_console_workspace_is_its_id_from_an_address_or_itself() {
732 let key = Some("sk-test");
733 let mut pasted = AiConfig {
734 console_workspace: Some(" https://platform.claude.com/workspaces/wrkspc_01AbC/sessions/sesn_1?event=e ".into()),
735 ..AiConfig::default()
736 };
737 assert!(check_config(AiProvider::Anthropic, &mut pasted, key).is_ok());
738 assert_eq!(pasted.console_workspace.as_deref(), Some("wrkspc_01AbC"));
739 let mut bare = AiConfig { console_workspace: Some("wrkspc_01AbC".into()), ..AiConfig::default() };
740 assert!(check_config(AiProvider::Anthropic, &mut bare, key).is_ok());
741 assert_eq!(bare.console_workspace.as_deref(), Some("wrkspc_01AbC"));
742 let mut empty = AiConfig { console_workspace: Some(" ".into()), ..AiConfig::default() };
743 assert!(check_config(AiProvider::Anthropic, &mut empty, key).is_ok());
744 assert_eq!(empty.console_workspace, None);
745 let mut wrong = AiConfig { console_workspace: Some("not a workspace".into()), ..AiConfig::default() };
746 assert!(check_config(AiProvider::Anthropic, &mut wrong, key).is_err());
747 // Only Anthropic's Console has workspaces to link to.
748 let mut other = AiConfig { console_workspace: Some("wrkspc_01AbC".into()), ..AiConfig::default() };
749 assert!(check_config(AiProvider::Openai, &mut other, key).is_ok());
750 assert_eq!(other.console_workspace, None);
751 }
752
753 fn connection(provider: AiProvider, model: Option<&str>, models: &[&str]) -> AiConnection {
754 AiConnection {
755 id: "con_1".into(),
756 workspace: "acme".into(),
757 provider,
758 group: provider.group(),
759 name: "Team key".into(),
760 config: AiConfig { model: model.map(str::to_owned), ..AiConfig::default() },
761 secret_hint: None,
762 created_by: "ana".into(),
763 created_at: "2026-10-12T00:00:00Z".into(),
764 last_used_at: None,
765 last_error: None,
766 models: models.iter().map(|model| (*model).to_owned()).collect(),
767 }
768 }
769
770 #[test]
771 fn asks_model_is_one_the_provider_offers_or_its_own() {
772 let listed = connection(AiProvider::Openai, None, &["gpt-5", "gpt-5-mini"]);
773 assert_eq!(ask_model(&listed, Some(" gpt-5 ")).unwrap().as_deref(), Some("gpt-5"));
774 assert!(ask_model(&listed, Some("gpt-4")).unwrap_err().contains("does not offer gpt-4"));
775 assert_eq!(ask_model(&listed, None).unwrap_err(), "Choose a model for Ask.");
776 // The provider's own model when none is chosen.
777 let own = connection(AiProvider::Anthropic, Some("claude-x"), &[]);
778 assert_eq!(ask_model(&own, Some("")).unwrap().as_deref(), Some("claude-x"));
779 // An endpoint that lists nothing takes what the owner writes.
780 let open = connection(AiProvider::OpenaiEndpoint, None, &[]);
781 assert_eq!(ask_model(&open, Some("qwen3-coder")).unwrap().as_deref(), Some("qwen3-coder"));
782 // Azure lists base models, not deployments: its deployment stands.
783 let azure = connection(AiProvider::AzureOpenai, Some("prod-gpt"), &["gpt-5"]);
784 assert_eq!(ask_model(&azure, Some("prod-gpt")).unwrap().as_deref(), Some("prod-gpt"));
785 assert!(ask_model(&own, Some(&"m".repeat(201))).is_err());
786 }
787
788 fn row(provider: AiProvider, model: Option<&str>, models: &[&str]) -> AiRow {
789 AiRow {
790 id: "con_1".into(),
791 workspace: "acme".into(),
792 provider: provider.name().into(),
793 name: "Team key".into(),
794 config: serde_json::to_string(&AiConfig { model: model.map(str::to_owned), ..AiConfig::default() }).unwrap(),
795 secrets: None,
796 secret_hint: None,
797 created_by: "ana".into(),
798 created_at: "2026-10-12T00:00:00Z".into(),
799 last_used_at: None,
800 last_error: None,
801 models: Some(serde_json::to_string(models).unwrap()),
802 }
803 }
804
805 #[test]
806 fn auto_is_chosen_by_name_and_picks_among_what_the_provider_lists() {
807 assert!(chose_auto(Some("auto")) && chose_auto(Some(" Auto ")));
808 assert!(!chose_auto(Some("claude-haiku-5-5")) && !chose_auto(None) && !chose_auto(Some("")));
809 let anthropic = row(AiProvider::Anthropic, None, &["claude-haiku-5-5", "claude-opus-5-5", "claude-sonnet-5-5"]);
810 let models = auto_models(&anthropic);
811 let simple = [AskMessage { role: AskRole::User, content: "Who is assigned to #12?".into() }];
812 let shipped = [AskMessage { role: AskRole::User, content: "What shipped today?".into() }];
813 assert_eq!(auto::choose(&models, None, auto::need(&simple)).as_deref(), Some("claude-haiku-5-5"));
814 assert_eq!(auto::choose(&models, None, auto::need(&shipped)).as_deref(), Some("claude-sonnet-5-5"));
815 // Azure names a deployment, never a listed model: Auto uses it.
816 let azure = row(AiProvider::AzureOpenai, Some("prod-gpt"), &["gpt-5", "gpt-5-mini"]);
817 assert!(auto_models(&azure).is_empty());
818 assert_eq!(auto::choose(&auto_models(&azure), azure.config().model.as_deref(), auto::need(&simple)).as_deref(), Some("prod-gpt"));
819 }
820
821}