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.

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