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
540 linesCodeBlameRaw
1//! A workspace's AI: the AI providers it keeps keys for, and what Ask g1t
2//! answers with. Kept by the integrations service (`src/ai.rs`), but not an
3//! integration: integrations connect outside products, from a directory
4//! others will publish to; a workspace's AI is one of its own settings, at
5//! `/:workspace/-/ai`.
6//!
7//! - **AI providers.** A frontier lab or a model platform at a known
8//! address, or a model the workspace hosts itself behind an Anthropic- or
9//! OpenAI-compatible endpoint. Owners add, test, change and remove them;
10//! keys are sealed and never returned.
11//! - **Ask g1t.** A conversation with g1t from the top bar and with @g1t in
12//! Chat, answered by the provider and model an owner chose, on the
13//! workspace's own key. g1t hosts no model for it and bills nothing for
14//! it; the key never leaves the service.
15//!
16//! Mirrors `packages/contracts/src/ai.ts`.
17
18use serde::{Deserialize, Serialize};
19
20use crate::repos::RepoPath;
21use crate::{User, Viewer};
22
23/// Which AI provider a workspace's key is for.
24#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(rename_all = "snake_case")]
26pub enum AiProvider {
27 // Frontier labs.
28 Anthropic,
29 Openai,
30 /// Through Gemini's OpenAI-compatible endpoint.
31 Gemini,
32 Xai,
33 Mistral,
34 Deepseek,
35 // Model platforms: many labs' models, or a deployment of your own.
36 /// A deployment on the workspace's own Azure OpenAI resource.
37 AzureOpenai,
38 Openrouter,
39 Groq,
40 Together,
41 Fireworks,
42 Cerebras,
43 // Self-hosted, or the workspace's own gateway.
44 /// Any endpoint that speaks Anthropic's Messages API: the workspace's
45 /// own gateway, LiteLLM, a proxy in front of Bedrock or Vertex, or a
46 /// self-hosted model.
47 AnthropicEndpoint,
48 /// Any endpoint that speaks OpenAI's Chat Completions API: vLLM,
49 /// Ollama behind a tunnel, LiteLLM, a gateway.
50 OpenaiEndpoint,
51}
52
53/// Where an AI provider's models come from, as people choose them.
54#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
55#[serde(rename_all = "snake_case")]
56pub enum AiGroup {
57 /// A lab's own API.
58 Labs,
59 /// A platform serving many labs' models.
60 Platforms,
61 /// A model the workspace hosts, or its own gateway.
62 SelfHosted,
63}
64
65/// What g1t knows about an AI provider.
66pub struct AiSpec {
67 pub provider: AiProvider,
68 pub name: &'static str,
69 pub label: &'static str,
70 pub group: AiGroup,
71 /// The API it speaks: `anthropic` or `openai`.
72 pub api: &'static str,
73 /// Where its API is, with the version for OpenAI's API and without it
74 /// for Anthropic's. Empty when the workspace gives its own.
75 pub base_url: &'static str,
76 /// The header the key goes in; `authorization` means `Bearer <key>`.
77 pub auth_header: &'static str,
78}
79
80const fn spec(
81 provider: AiProvider,
82 name: &'static str,
83 label: &'static str,
84 group: AiGroup,
85 api: &'static str,
86 base_url: &'static str,
87 auth_header: &'static str,
88) -> AiSpec {
89 AiSpec { provider, name, label, group, api, base_url, auth_header }
90}
91
92use AiGroup::{Labs, Platforms, SelfHosted};
93
94/// Every AI provider, in the order people are shown them.
95pub const AI_PROVIDERS: [AiSpec; 14] = [
96 spec(AiProvider::Anthropic, "anthropic", "Anthropic", Labs, "anthropic", "https://api.anthropic.com", "x-api-key"),
97 spec(AiProvider::Openai, "openai", "OpenAI", Labs, "openai", "https://api.openai.com/v1", "authorization"),
98 spec(AiProvider::Gemini, "gemini", "Google Gemini", Labs, "openai", "https://generativelanguage.googleapis.com/v1beta/openai", "authorization"),
99 spec(AiProvider::Xai, "xai", "xAI", Labs, "openai", "https://api.x.ai/v1", "authorization"),
100 spec(AiProvider::Mistral, "mistral", "Mistral", Labs, "openai", "https://api.mistral.ai/v1", "authorization"),
101 spec(AiProvider::Deepseek, "deepseek", "DeepSeek", Labs, "openai", "https://api.deepseek.com/v1", "authorization"),
102 spec(AiProvider::AzureOpenai, "azure_openai", "Azure OpenAI", Platforms, "openai", "", "api-key"),
103 spec(AiProvider::Openrouter, "openrouter", "OpenRouter", Platforms, "openai", "https://openrouter.ai/api/v1", "authorization"),
104 spec(AiProvider::Groq, "groq", "Groq", Platforms, "openai", "https://api.groq.com/openai/v1", "authorization"),
105 spec(AiProvider::Together, "together", "Together AI", Platforms, "openai", "https://api.together.xyz/v1", "authorization"),
106 spec(AiProvider::Fireworks, "fireworks", "Fireworks AI", Platforms, "openai", "https://api.fireworks.ai/inference/v1", "authorization"),
107 spec(AiProvider::Cerebras, "cerebras", "Cerebras", Platforms, "openai", "https://api.cerebras.ai/v1", "authorization"),
108 spec(AiProvider::AnthropicEndpoint, "anthropic_endpoint", "Anthropic-compatible endpoint", SelfHosted, "anthropic", "", "x-api-key"),
109 spec(AiProvider::OpenaiEndpoint, "openai_endpoint", "OpenAI-compatible endpoint", SelfHosted, "openai", "", "authorization"),
110];
111
112impl AiProvider {
113 pub fn spec(self) -> &'static AiSpec {
114 AI_PROVIDERS
115 .iter()
116 .find(|spec| spec.provider == self)
117 .expect("every AI provider is in the catalogue")
118 }
119
120 pub fn all() -> impl Iterator<Item = AiProvider> {
121 AI_PROVIDERS.iter().map(|spec| spec.provider)
122 }
123
124 pub fn name(self) -> &'static str {
125 self.spec().name
126 }
127
128 pub fn parse(name: &str) -> Option<AiProvider> {
129 AI_PROVIDERS.iter().find(|spec| spec.name == name).map(|spec| spec.provider)
130 }
131
132 /// What people call it.
133 pub fn label(self) -> &'static str {
134 self.spec().label
135 }
136
137 pub fn group(self) -> AiGroup {
138 self.spec().group
139 }
140
141 /// The API it speaks: `anthropic` or `openai`.
142 pub fn api(self) -> &'static str {
143 self.spec().api
144 }
145
146 /// Whether the workspace gives the address, rather than g1t knowing it.
147 pub fn own_address(self) -> bool {
148 self.spec().base_url.is_empty()
149 }
150}
151
152/// An AI provider's settings: everything but its key.
153#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
154#[serde(rename_all = "camelCase")]
155pub struct AiConfig {
156 /// For an endpoint or an Azure OpenAI resource: its address over
157 /// `https://`. An Anthropic-compatible one without `/v1`, an
158 /// OpenAI-compatible one up to and including the version.
159 #[serde(default, skip_serializing_if = "Option::is_none")]
160 pub base_url: Option<String>,
161 /// For an endpoint: send the key as `x-api-key` or as
162 /// `authorization: Bearer`.
163 #[serde(default, skip_serializing_if = "Option::is_none")]
164 pub auth_header: Option<String>,
165 /// The model the team uses on it; for Azure OpenAI, the deployment.
166 #[serde(default, skip_serializing_if = "Option::is_none")]
167 pub model: Option<String>,
168 /// For Anthropic: the Console workspace the key belongs to, as its
169 /// Console address names it (`wrkspc_…`), so links to the sessions it
170 /// starts open there. Without it, the account's Default workspace.
171 #[serde(default, skip_serializing_if = "Option::is_none")]
172 pub console_workspace: Option<String>,
173}
174
175/// One of a workspace's AI providers, as any member sees it. Its key is
176/// never shown after it is saved; `secretHint` tells keys apart.
177#[derive(Clone, Debug, Serialize, Deserialize)]
178#[serde(rename_all = "camelCase")]
179pub struct AiConnection {
180 pub id: String,
181 pub workspace: String,
182 pub provider: AiProvider,
183 pub group: AiGroup,
184 pub name: String,
185 pub config: AiConfig,
186 /// The last four characters of the saved key, such as `…3f9a`.
187 pub secret_hint: Option<String>,
188 pub created_by: String,
189 /// RFC 3339.
190 pub created_at: String,
191 pub last_used_at: Option<String>,
192 /// The last thing that went wrong talking to it, until it next works.
193 pub last_error: Option<String>,
194 /// The models it offered when last checked.
195 #[serde(default)]
196 pub models: Vec<String>,
197}
198
199// --- Methods -----------------------------------------------------------------
200
201/// `ai_list`. Returns `Outcome<Vec<AiConnection>>`. Members only.
202#[derive(Debug, Serialize, Deserialize)]
203pub struct ListAiArgs {
204 pub workspace: String,
205 pub viewer: Viewer,
206}
207
208/// `ai_add`. Returns `Outcome<AiConnection>`, after checking the key.
209/// Owners only.
210#[derive(Debug, Serialize, Deserialize)]
211pub struct AddAiArgs {
212 pub actor: User,
213 pub workspace: String,
214 pub provider: AiProvider,
215 #[serde(default)]
216 pub name: Option<String>,
217 #[serde(default)]
218 pub config: AiConfig,
219 /// Its API key. Optional for an endpoint that needs none.
220 #[serde(default)]
221 pub secret: Option<String>,
222}
223
224/// `ai_update`: only the fields given change. Returns
225/// `Outcome<AiConnection>`. Owners only.
226#[derive(Debug, Serialize, Deserialize)]
227pub struct UpdateAiArgs {
228 pub actor: User,
229 pub workspace: String,
230 pub id: String,
231 #[serde(default)]
232 pub name: Option<String>,
233 #[serde(default)]
234 pub config: Option<AiConfig>,
235 /// A new key, replacing the old one.
236 #[serde(default)]
237 pub secret: Option<String>,
238}
239
240/// `ai_remove` and `ai_test`. `ai_remove` returns `Outcome<bool>`;
241/// `ai_test` returns `Outcome<integrations::Tested>`. Owners only.
242#[derive(Debug, Serialize, Deserialize)]
243pub struct AiConnectionArgs {
244 pub actor: User,
245 pub workspace: String,
246 pub id: String,
247}
248
249// --- Ask g1t -----------------------------------------------------------------
250
251/// The most messages one ask sends: the conversation's latest turns.
252pub const ASK_MAX_MESSAGES: usize = 40;
253/// The longest one message may be, in characters.
254pub const ASK_MAX_MESSAGE_CHARS: usize = 16_000;
255/// The longest a whole conversation may be, in characters.
256pub const ASK_MAX_TOTAL_CHARS: usize = 64_000;
257/// The most tokens one answer may run to.
258pub const ASK_MAX_OUTPUT_TOKENS: u32 = 4_096;
259
260/// The model name that means Auto in [`SetAskSettingsArgs`]: Ask picks one
261/// of the provider's models for each question.
262pub const ASK_AUTO_MODEL: &str = "auto";
263
264/// What Ask g1t answers with in a workspace: the AI provider and model an
265/// owner chose, or why it cannot answer yet.
266#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
267pub struct AskSetup {
268 /// The workspace's AI provider Ask answers with: an [`AiConnection`]'s id.
269 pub provider_id: Option<String>,
270 /// Its name, as its owner named it.
271 pub provider_name: Option<String>,
272 pub provider: Option<AiProvider>,
273 /// The model chosen. With `auto`, none, or the provider's own model
274 /// when it lists none (an endpoint's, an Azure deployment).
275 pub model: Option<String>,
276 /// Auto: each question goes to the provider's model that suits it (a
277 /// fast one for a lookup, a stronger one for a question that needs
278 /// several reads or reasoning). The default.
279 #[serde(default)]
280 pub auto: bool,
281 /// Why Ask cannot answer, in words for the person asking. `None` when
282 /// it can.
283 pub problem: Option<String>,
284}
285
286/// `ask_settings`: what Ask answers with. Returns `Outcome<AskSetup>`.
287/// Members only.
288#[derive(Debug, Serialize, Deserialize)]
289pub struct AskSettingsArgs {
290 pub workspace: String,
291 pub viewer: Viewer,
292}
293
294/// `set_ask_settings`: chooses the AI provider and model Ask answers with;
295/// no `provider_id` turns Ask off. Returns `Outcome<AskSetup>`. Owners
296/// only.
297#[derive(Debug, Serialize, Deserialize)]
298pub struct SetAskSettingsArgs {
299 pub actor: User,
300 pub workspace: String,
301 #[serde(default)]
302 pub provider_id: Option<String>,
303 /// One of the models the provider offers, or [`ASK_AUTO_MODEL`] for
304 /// Auto; its own model (an Azure deployment) when not given.
305 #[serde(default)]
306 pub model: Option<String>,
307}
308
309#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
310#[serde(rename_all = "snake_case")]
311pub enum AskRole {
312 User,
313 Assistant,
314}
315
316/// One turn of an Ask conversation.
317#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
318pub struct AskMessage {
319 pub role: AskRole,
320 pub content: String,
321}
322
323/// Where the person asking is in g1t, so the answer is about their work.
324#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
325pub struct AskPage {
326 /// The page's path, such as `/acme/web/issues/12`.
327 #[serde(default)]
328 pub path: Option<String>,
329 #[serde(default)]
330 pub repo: Option<RepoPath>,
331 #[serde(default)]
332 pub issue: Option<u32>,
333 #[serde(default)]
334 pub pull: Option<u32>,
335 /// The browser's IANA time zone, such as `America/Denver`: what
336 /// "today" means when their profile names none.
337 #[serde(default)]
338 pub time_zone: Option<String>,
339}
340
341/// `ask`: one answer, streamed as newline-delimited JSON [`AskEvent`]s:
342/// `meta` first, then `text` as it comes and a `tool` line as each step
343/// starts and ends, then `sources` and `done`, or `error` at any point.
344/// Members only. The conversation alternates, starting and ending with the
345/// person; its turns are text only, since the steps of an answer live in
346/// that answer alone.
347///
348/// The provider reads g1t with the MCP server's own tools, as the person
349/// asking, on a read-only credential identity makes for the answer
350/// (`create_ask_token`), so it sees what they may see and changes nothing.
351#[derive(Debug, Serialize, Deserialize)]
352pub struct AskArgs {
353 pub actor: User,
354 pub workspace: String,
355 pub messages: Vec<AskMessage>,
356 #[serde(default)]
357 pub page: AskPage,
358}
359
360/// The most rounds of tool calls one answer takes before it must answer
361/// with what it has.
362pub const ASK_MAX_TOOL_ROUNDS: usize = 10;
363/// The most tool calls one answer makes, across its rounds.
364pub const ASK_MAX_TOOL_CALLS: usize = 30;
365/// The most characters of one tool's result the model is given; more is
366/// trimmed, long texts and lists first, with a note saying so.
367pub const ASK_MAX_TOOL_RESULT_CHARS: usize = 16_000;
368/// The most characters of tool results one answer gives the model in all.
369pub const ASK_MAX_TOOL_TOTAL_CHARS: usize = 120_000;
370/// How long one answer may spend calling tools, in seconds. After it, the
371/// model answers with what it has.
372pub const ASK_MAX_TOOL_SECONDS: u64 = 120;
373/// The most sources one answer lists.
374pub const ASK_MAX_SOURCES: usize = 12;
375
376/// Where a step of an answer is.
377#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
378#[serde(rename_all = "snake_case")]
379pub enum AskToolStatus {
380 /// Started; its result has not come.
381 Running,
382 Done,
383 /// The tool answered with a refusal or an error: `summary` says what.
384 Failed,
385 /// Ask did not run it: it is not something Ask does (it only reads, and
386 /// hands work off), or the answer had used its tool calls.
387 Refused,
388}
389
390/// A page on g1t an answer drew on.
391#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
392pub struct AskSource {
393 pub title: String,
394 /// An absolute address on the site, such as
395 /// `https://g1t.sh/acme/web/pull/7`.
396 pub url: String,
397 /// Whether the answer links to it, rather than only having read it.
398 pub cited: bool,
399}
400
401/// One line of an `ask` answer.
402#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
403#[serde(tag = "type", rename_all = "snake_case")]
404pub enum AskEvent {
405 /// Who is answering.
406 /// `model` is the one answering; `auto` when Auto chose it.
407 Meta {
408 provider: AiProvider,
409 provider_label: String,
410 model: String,
411 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
412 auto: bool,
413 },
414 /// The next piece of the answer.
415 Text { text: String },
416 /// A step taken to answer: one of g1t's tools, run as the person
417 /// asking. Sent when it starts, `running`, and again with the same `id`
418 /// when it ends, with what it found in `summary` and the page it was
419 /// about in `link`.
420 Tool {
421 id: String,
422 /// The tool, such as `pull_request`, and its `action`, such as `list`.
423 name: String,
424 action: Option<String>,
425 /// What it does, for a person: "Read pull requests in acme/web".
426 label: String,
427 status: AskToolStatus,
428 summary: Option<String>,
429 link: Option<String>,
430 },
431 /// What the answer drew on, cited first. Sent once, before `done`.
432 Sources { sources: Vec<AskSource> },
433 /// Work Ask proposes to hand to a session in the person's own harness:
434 /// on an issue, or on a new one with `title` and `body`. Nothing has
435 /// started; the person starts it, or copies the command, from the card
436 /// the answer shows. `mode` is `plan`, `implement` or `review`.
437 Proposal {
438 id: String,
439 repo: String,
440 issue: Option<u64>,
441 title: Option<String>,
442 body: Option<String>,
443 mode: String,
444 instruction: Option<String>,
445 },
446 /// The answer is complete. `stop_reason` as the provider said it, such
447 /// as `end_turn`, `stop` or `max_tokens`.
448 Done { stop_reason: Option<String> },
449 /// The answer stopped: what went wrong, for the person asking.
450 Error { message: String },
451}
452
453#[cfg(test)]
454mod tests {
455 use super::*;
456
457 #[test]
458 fn every_ai_provider_is_named_once_and_found_again() {
459 let mut names: Vec<&str> = AI_PROVIDERS.iter().map(|spec| spec.name).collect();
460 for provider in AiProvider::all() {
461 assert_eq!(AiProvider::parse(provider.name()), Some(provider));
462 }
463 names.sort();
464 names.dedup();
465 assert_eq!(names.len(), AI_PROVIDERS.len());
466 // Integrations are not AI providers.
467 assert_eq!(AiProvider::parse("sentry"), None);
468 }
469
470 #[test]
471 fn ai_providers_say_how_to_reach_them() {
472 for spec in &AI_PROVIDERS {
473 assert!(spec.api == "anthropic" || spec.api == "openai", "{}", spec.name);
474 assert!(!spec.auth_header.is_empty(), "{}", spec.name);
475 assert!(spec.base_url.is_empty() || spec.base_url.starts_with("https://"), "{}", spec.name);
476 }
477 let stored: AiProvider = serde_json::from_str("\"openai_endpoint\"").unwrap();
478 assert!(stored.own_address());
479 assert_eq!(stored.group(), AiGroup::SelfHosted);
480 assert!(!AiProvider::Anthropic.own_address());
481 assert_eq!(serde_json::to_string(&AiGroup::SelfHosted).unwrap(), r#""self_hosted""#);
482 }
483
484 #[test]
485 fn settings_kept_from_when_they_were_integrations_still_parse() {
486 let config: AiConfig =
487 serde_json::from_str(r#"{"writeBack":true,"keys":[],"baseUrl":"https://llm.acme.dev","model":"qwen3"}"#).unwrap();
488 assert_eq!(config.base_url.as_deref(), Some("https://llm.acme.dev"));
489 assert_eq!(config.model.as_deref(), Some("qwen3"));
490 }
491
492 #[test]
493 fn ask_events_are_tagged_lines() {
494 let line = serde_json::to_string(&AskEvent::Text { text: "Hi".into() }).unwrap();
495 assert_eq!(line, r#"{"type":"text","text":"Hi"}"#);
496 let meta: AskEvent =
497 serde_json::from_str(r#"{"type":"meta","provider":"anthropic","provider_label":"Anthropic","model":"claude"}"#).unwrap();
498 assert_eq!(
499 meta,
500 AskEvent::Meta { provider: AiProvider::Anthropic, provider_label: "Anthropic".into(), model: "claude".into(), auto: false }
501 );
502 // Auto says so; a chosen model's line is as it was.
503 assert!(!serde_json::to_string(&meta).unwrap().contains("auto"));
504 let auto = AskEvent::Meta { provider: AiProvider::Anthropic, provider_label: "Anthropic".into(), model: "claude".into(), auto: true };
505 assert_eq!(
506 serde_json::to_string(&auto).unwrap(),
507 r#"{"type":"meta","provider":"anthropic","provider_label":"Anthropic","model":"claude","auto":true}"#
508 );
509 let page: AskPage = serde_json::from_str("{}").unwrap();
510 assert_eq!(page, AskPage::default());
511 }
512
513 #[test]
514 fn ask_steps_and_sources_are_snake_case_lines() {
515 let step = AskEvent::Tool {
516 id: "t1".into(),
517 name: "pull_request".into(),
518 action: Some("list".into()),
519 label: "Read pull requests in acme/web".into(),
520 status: AskToolStatus::Done,
521 summary: Some("12 results".into()),
522 link: Some("https://g1t.sh/acme/web/pulls".into()),
523 };
524 assert_eq!(
525 serde_json::to_value(&step).unwrap(),
526 serde_json::json!({
527 "type": "tool", "id": "t1", "name": "pull_request", "action": "list",
528 "label": "Read pull requests in acme/web", "status": "done",
529 "summary": "12 results", "link": "https://g1t.sh/acme/web/pulls",
530 })
531 );
532 let sources = AskEvent::Sources {
533 sources: vec![AskSource { title: "Fix login".into(), url: "https://g1t.sh/acme/web/pull/7".into(), cited: true }],
534 };
535 let line = serde_json::to_string(&sources).unwrap();
536 assert_eq!(line, r#"{"type":"sources","sources":[{"title":"Fix login","url":"https://g1t.sh/acme/web/pull/7","cited":true}]}"#);
537 assert_eq!(serde_json::from_str::<AskEvent>(&line).unwrap(), sources);
538 assert_eq!(serde_json::to_string(&AskToolStatus::Refused).unwrap(), r#""refused""#);
539 }
540}