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
535 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}
169
170/// One of a workspace's AI providers, as any member sees it. Its key is
171/// never shown after it is saved; `secretHint` tells keys apart.
172#[derive(Clone, Debug, Serialize, Deserialize)]
173#[serde(rename_all = "camelCase")]
174pub struct AiConnection {
175 pub id: String,
176 pub workspace: String,
177 pub provider: AiProvider,
178 pub group: AiGroup,
179 pub name: String,
180 pub config: AiConfig,
181 /// The last four characters of the saved key, such as `…3f9a`.
182 pub secret_hint: Option<String>,
183 pub created_by: String,
184 /// RFC 3339.
185 pub created_at: String,
186 pub last_used_at: Option<String>,
187 /// The last thing that went wrong talking to it, until it next works.
188 pub last_error: Option<String>,
189 /// The models it offered when last checked.
190 #[serde(default)]
191 pub models: Vec<String>,
192}
193
194// --- Methods -----------------------------------------------------------------
195
196/// `ai_list`. Returns `Outcome<Vec<AiConnection>>`. Members only.
197#[derive(Debug, Serialize, Deserialize)]
198pub struct ListAiArgs {
199 pub workspace: String,
200 pub viewer: Viewer,
201}
202
203/// `ai_add`. Returns `Outcome<AiConnection>`, after checking the key.
204/// Owners only.
205#[derive(Debug, Serialize, Deserialize)]
206pub struct AddAiArgs {
207 pub actor: User,
208 pub workspace: String,
209 pub provider: AiProvider,
210 #[serde(default)]
211 pub name: Option<String>,
212 #[serde(default)]
213 pub config: AiConfig,
214 /// Its API key. Optional for an endpoint that needs none.
215 #[serde(default)]
216 pub secret: Option<String>,
217}
218
219/// `ai_update`: only the fields given change. Returns
220/// `Outcome<AiConnection>`. Owners only.
221#[derive(Debug, Serialize, Deserialize)]
222pub struct UpdateAiArgs {
223 pub actor: User,
224 pub workspace: String,
225 pub id: String,
226 #[serde(default)]
227 pub name: Option<String>,
228 #[serde(default)]
229 pub config: Option<AiConfig>,
230 /// A new key, replacing the old one.
231 #[serde(default)]
232 pub secret: Option<String>,
233}
234
235/// `ai_remove` and `ai_test`. `ai_remove` returns `Outcome<bool>`;
236/// `ai_test` returns `Outcome<integrations::Tested>`. Owners only.
237#[derive(Debug, Serialize, Deserialize)]
238pub struct AiConnectionArgs {
239 pub actor: User,
240 pub workspace: String,
241 pub id: String,
242}
243
244// --- Ask g1t -----------------------------------------------------------------
245
246/// The most messages one ask sends: the conversation's latest turns.
247pub const ASK_MAX_MESSAGES: usize = 40;
248/// The longest one message may be, in characters.
249pub const ASK_MAX_MESSAGE_CHARS: usize = 16_000;
250/// The longest a whole conversation may be, in characters.
251pub const ASK_MAX_TOTAL_CHARS: usize = 64_000;
252/// The most tokens one answer may run to.
253pub const ASK_MAX_OUTPUT_TOKENS: u32 = 4_096;
254
255/// The model name that means Auto in [`SetAskSettingsArgs`]: Ask picks one
256/// of the provider's models for each question.
257pub const ASK_AUTO_MODEL: &str = "auto";
258
259/// What Ask g1t answers with in a workspace: the AI provider and model an
260/// owner chose, or why it cannot answer yet.
261#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
262pub struct AskSetup {
263 /// The workspace's AI provider Ask answers with: an [`AiConnection`]'s id.
264 pub provider_id: Option<String>,
265 /// Its name, as its owner named it.
266 pub provider_name: Option<String>,
267 pub provider: Option<AiProvider>,
268 /// The model chosen. With `auto`, none, or the provider's own model
269 /// when it lists none (an endpoint's, an Azure deployment).
270 pub model: Option<String>,
271 /// Auto: each question goes to the provider's model that suits it (a
272 /// fast one for a lookup, a stronger one for a question that needs
273 /// several reads or reasoning). The default.
274 #[serde(default)]
275 pub auto: bool,
276 /// Why Ask cannot answer, in words for the person asking. `None` when
277 /// it can.
278 pub problem: Option<String>,
279}
280
281/// `ask_settings`: what Ask answers with. Returns `Outcome<AskSetup>`.
282/// Members only.
283#[derive(Debug, Serialize, Deserialize)]
284pub struct AskSettingsArgs {
285 pub workspace: String,
286 pub viewer: Viewer,
287}
288
289/// `set_ask_settings`: chooses the AI provider and model Ask answers with;
290/// no `provider_id` turns Ask off. Returns `Outcome<AskSetup>`. Owners
291/// only.
292#[derive(Debug, Serialize, Deserialize)]
293pub struct SetAskSettingsArgs {
294 pub actor: User,
295 pub workspace: String,
296 #[serde(default)]
297 pub provider_id: Option<String>,
298 /// One of the models the provider offers, or [`ASK_AUTO_MODEL`] for
299 /// Auto; its own model (an Azure deployment) when not given.
300 #[serde(default)]
301 pub model: Option<String>,
302}
303
304#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
305#[serde(rename_all = "snake_case")]
306pub enum AskRole {
307 User,
308 Assistant,
309}
310
311/// One turn of an Ask conversation.
312#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
313pub struct AskMessage {
314 pub role: AskRole,
315 pub content: String,
316}
317
318/// Where the person asking is in g1t, so the answer is about their work.
319#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
320pub struct AskPage {
321 /// The page's path, such as `/acme/web/issues/12`.
322 #[serde(default)]
323 pub path: Option<String>,
324 #[serde(default)]
325 pub repo: Option<RepoPath>,
326 #[serde(default)]
327 pub issue: Option<u32>,
328 #[serde(default)]
329 pub pull: Option<u32>,
330 /// The browser's IANA time zone, such as `America/Denver`: what
331 /// "today" means when their profile names none.
332 #[serde(default)]
333 pub time_zone: Option<String>,
334}
335
336/// `ask`: one answer, streamed as newline-delimited JSON [`AskEvent`]s:
337/// `meta` first, then `text` as it comes and a `tool` line as each step
338/// starts and ends, then `sources` and `done`, or `error` at any point.
339/// Members only. The conversation alternates, starting and ending with the
340/// person; its turns are text only, since the steps of an answer live in
341/// that answer alone.
342///
343/// The provider reads g1t with the MCP server's own tools, as the person
344/// asking, on a read-only credential identity makes for the answer
345/// (`create_ask_token`), so it sees what they may see and changes nothing.
346#[derive(Debug, Serialize, Deserialize)]
347pub struct AskArgs {
348 pub actor: User,
349 pub workspace: String,
350 pub messages: Vec<AskMessage>,
351 #[serde(default)]
352 pub page: AskPage,
353}
354
355/// The most rounds of tool calls one answer takes before it must answer
356/// with what it has.
357pub const ASK_MAX_TOOL_ROUNDS: usize = 10;
358/// The most tool calls one answer makes, across its rounds.
359pub const ASK_MAX_TOOL_CALLS: usize = 30;
360/// The most characters of one tool's result the model is given; more is
361/// trimmed, long texts and lists first, with a note saying so.
362pub const ASK_MAX_TOOL_RESULT_CHARS: usize = 16_000;
363/// The most characters of tool results one answer gives the model in all.
364pub const ASK_MAX_TOOL_TOTAL_CHARS: usize = 120_000;
365/// How long one answer may spend calling tools, in seconds. After it, the
366/// model answers with what it has.
367pub const ASK_MAX_TOOL_SECONDS: u64 = 120;
368/// The most sources one answer lists.
369pub const ASK_MAX_SOURCES: usize = 12;
370
371/// Where a step of an answer is.
372#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
373#[serde(rename_all = "snake_case")]
374pub enum AskToolStatus {
375 /// Started; its result has not come.
376 Running,
377 Done,
378 /// The tool answered with a refusal or an error: `summary` says what.
379 Failed,
380 /// Ask did not run it: it is not something Ask does (it only reads, and
381 /// hands work off), or the answer had used its tool calls.
382 Refused,
383}
384
385/// A page on g1t an answer drew on.
386#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
387pub struct AskSource {
388 pub title: String,
389 /// An absolute address on the site, such as
390 /// `https://g1t.sh/acme/web/pull/7`.
391 pub url: String,
392 /// Whether the answer links to it, rather than only having read it.
393 pub cited: bool,
394}
395
396/// One line of an `ask` answer.
397#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
398#[serde(tag = "type", rename_all = "snake_case")]
399pub enum AskEvent {
400 /// Who is answering.
401 /// `model` is the one answering; `auto` when Auto chose it.
402 Meta {
403 provider: AiProvider,
404 provider_label: String,
405 model: String,
406 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
407 auto: bool,
408 },
409 /// The next piece of the answer.
410 Text { text: String },
411 /// A step taken to answer: one of g1t's tools, run as the person
412 /// asking. Sent when it starts, `running`, and again with the same `id`
413 /// when it ends, with what it found in `summary` and the page it was
414 /// about in `link`.
415 Tool {
416 id: String,
417 /// The tool, such as `pull_request`, and its `action`, such as `list`.
418 name: String,
419 action: Option<String>,
420 /// What it does, for a person: "Read pull requests in acme/web".
421 label: String,
422 status: AskToolStatus,
423 summary: Option<String>,
424 link: Option<String>,
425 },
426 /// What the answer drew on, cited first. Sent once, before `done`.
427 Sources { sources: Vec<AskSource> },
428 /// Work Ask proposes to hand to a session in the person's own harness:
429 /// on an issue, or on a new one with `title` and `body`. Nothing has
430 /// started; the person starts it, or copies the command, from the card
431 /// the answer shows. `mode` is `plan`, `implement` or `review`.
432 Proposal {
433 id: String,
434 repo: String,
435 issue: Option<u64>,
436 title: Option<String>,
437 body: Option<String>,
438 mode: String,
439 instruction: Option<String>,
440 },
441 /// The answer is complete. `stop_reason` as the provider said it, such
442 /// as `end_turn`, `stop` or `max_tokens`.
443 Done { stop_reason: Option<String> },
444 /// The answer stopped: what went wrong, for the person asking.
445 Error { message: String },
446}
447
448#[cfg(test)]
449mod tests {
450 use super::*;
451
452 #[test]
453 fn every_ai_provider_is_named_once_and_found_again() {
454 let mut names: Vec<&str> = AI_PROVIDERS.iter().map(|spec| spec.name).collect();
455 for provider in AiProvider::all() {
456 assert_eq!(AiProvider::parse(provider.name()), Some(provider));
457 }
458 names.sort();
459 names.dedup();
460 assert_eq!(names.len(), AI_PROVIDERS.len());
461 // Integrations are not AI providers.
462 assert_eq!(AiProvider::parse("sentry"), None);
463 }
464
465 #[test]
466 fn ai_providers_say_how_to_reach_them() {
467 for spec in &AI_PROVIDERS {
468 assert!(spec.api == "anthropic" || spec.api == "openai", "{}", spec.name);
469 assert!(!spec.auth_header.is_empty(), "{}", spec.name);
470 assert!(spec.base_url.is_empty() || spec.base_url.starts_with("https://"), "{}", spec.name);
471 }
472 let stored: AiProvider = serde_json::from_str("\"openai_endpoint\"").unwrap();
473 assert!(stored.own_address());
474 assert_eq!(stored.group(), AiGroup::SelfHosted);
475 assert!(!AiProvider::Anthropic.own_address());
476 assert_eq!(serde_json::to_string(&AiGroup::SelfHosted).unwrap(), r#""self_hosted""#);
477 }
478
479 #[test]
480 fn settings_kept_from_when_they_were_integrations_still_parse() {
481 let config: AiConfig =
482 serde_json::from_str(r#"{"writeBack":true,"keys":[],"baseUrl":"https://llm.acme.dev","model":"qwen3"}"#).unwrap();
483 assert_eq!(config.base_url.as_deref(), Some("https://llm.acme.dev"));
484 assert_eq!(config.model.as_deref(), Some("qwen3"));
485 }
486
487 #[test]
488 fn ask_events_are_tagged_lines() {
489 let line = serde_json::to_string(&AskEvent::Text { text: "Hi".into() }).unwrap();
490 assert_eq!(line, r#"{"type":"text","text":"Hi"}"#);
491 let meta: AskEvent =
492 serde_json::from_str(r#"{"type":"meta","provider":"anthropic","provider_label":"Anthropic","model":"claude"}"#).unwrap();
493 assert_eq!(
494 meta,
495 AskEvent::Meta { provider: AiProvider::Anthropic, provider_label: "Anthropic".into(), model: "claude".into(), auto: false }
496 );
497 // Auto says so; a chosen model's line is as it was.
498 assert!(!serde_json::to_string(&meta).unwrap().contains("auto"));
499 let auto = AskEvent::Meta { provider: AiProvider::Anthropic, provider_label: "Anthropic".into(), model: "claude".into(), auto: true };
500 assert_eq!(
501 serde_json::to_string(&auto).unwrap(),
502 r#"{"type":"meta","provider":"anthropic","provider_label":"Anthropic","model":"claude","auto":true}"#
503 );
504 let page: AskPage = serde_json::from_str("{}").unwrap();
505 assert_eq!(page, AskPage::default());
506 }
507
508 #[test]
509 fn ask_steps_and_sources_are_snake_case_lines() {
510 let step = AskEvent::Tool {
511 id: "t1".into(),
512 name: "pull_request".into(),
513 action: Some("list".into()),
514 label: "Read pull requests in acme/web".into(),
515 status: AskToolStatus::Done,
516 summary: Some("12 results".into()),
517 link: Some("https://g1t.sh/acme/web/pulls".into()),
518 };
519 assert_eq!(
520 serde_json::to_value(&step).unwrap(),
521 serde_json::json!({
522 "type": "tool", "id": "t1", "name": "pull_request", "action": "list",
523 "label": "Read pull requests in acme/web", "status": "done",
524 "summary": "12 results", "link": "https://g1t.sh/acme/web/pulls",
525 })
526 );
527 let sources = AskEvent::Sources {
528 sources: vec![AskSource { title: "Fix login".into(), url: "https://g1t.sh/acme/web/pull/7".into(), cited: true }],
529 };
530 let line = serde_json::to_string(&sources).unwrap();
531 assert_eq!(line, r#"{"type":"sources","sources":[{"title":"Fix login","url":"https://g1t.sh/acme/web/pull/7","cited":true}]}"#);
532 assert_eq!(serde_json::from_str::<AskEvent>(&line).unwrap(), sources);
533 assert_eq!(serde_json::to_string(&AskToolStatus::Refused).unwrap(), r#""refused""#);
534 }
535}