flagon-io/g1t

public

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/crates/contracts/src/integrations.rs

628 lines22,374 bytesCodeBlame
1//! The integrations service: a workspace's connections to systems outside
2//! g1t, and everything that crosses between them.
3//!
4//! - **Models.** A workspace connects as many model providers as it uses
5//! (Anthropic, OpenAI, Gemini, and anything compatible with either API)
6//! and routes each kind of work to one of them, or to g1t's hosted models.
7//! Sandboxes never hold a key: they hold a token for one run, and the
8//! model proxy puts the credentials on each request, translating to
9//! OpenAI's API where the provider speaks it.
10//! - **Alerts.** Sentry, Datadog or any signed webhook opens an issue in a
11//! repository, once per problem however often it fires, and can put an
12//! agent on it.
13//! - **Trackers.** A Jira or Linear key, such as `TECH-1234`, resolves to the
14//! ticket: agents read it, people import it as an issue, and when the work
15//! lands the ticket is told.
16//!
17//! Mirrors `packages/contracts/src/integrations.ts`.
18
19use serde::{Deserialize, Serialize};
20
21use crate::repos::RepoPath;
22use crate::{User, Viewer};
23
24/// Which outside system a connection is to.
25#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(rename_all = "snake_case")]
27pub enum Provider {
28 // Model providers: the labs.
29 Anthropic,
30 Openai,
31 /// Through Gemini's OpenAI-compatible endpoint.
32 Gemini,
33 Xai,
34 Mistral,
35 Deepseek,
36 // Model providers: platforms that serve many labs' models.
37 /// A deployment on the workspace's own Azure OpenAI resource.
38 AzureOpenai,
39 Openrouter,
40 Groq,
41 Together,
42 Fireworks,
43 Cerebras,
44 // Model providers: anything else.
45 /// Any endpoint that speaks Anthropic's Messages API: the workspace's
46 /// own Cloudflare AI Gateway, LiteLLM, a proxy in front of Bedrock or
47 /// Vertex, or a self-hosted model.
48 AnthropicEndpoint,
49 /// Any endpoint that speaks OpenAI's Chat Completions API: vLLM,
50 /// Ollama behind a tunnel, LiteLLM, a gateway.
51 OpenaiEndpoint,
52 // Alerts.
53 Sentry,
54 Datadog,
55 /// Anything that can send a signed JSON request.
56 Webhook,
57 // Trackers.
58 Jira,
59 Linear,
60}
61
62/// What g1t knows about a provider.
63pub struct Spec {
64 pub provider: Provider,
65 pub name: &'static str,
66 pub label: &'static str,
67 pub kind: ProviderKind,
68 /// For a model provider: the API it speaks, `anthropic` or `openai`.
69 pub api: &'static str,
70 /// For a model provider with a fixed address: where its API is, with
71 /// the version for OpenAI's API and without it for Anthropic's. Empty
72 /// when the connection gives its own.
73 pub base_url: &'static str,
74 /// The header the key goes in; `authorization` means `Bearer <key>`.
75 pub auth_header: &'static str,
76}
77
78const fn model(provider: Provider, name: &'static str, label: &'static str, api: &'static str, base_url: &'static str, auth_header: &'static str) -> Spec {
79 Spec {
80 provider,
81 name,
82 label,
83 kind: ProviderKind::Models,
84 api,
85 base_url,
86 auth_header,
87 }
88}
89
90const fn other(provider: Provider, name: &'static str, label: &'static str, kind: ProviderKind) -> Spec {
91 Spec {
92 provider,
93 name,
94 label,
95 kind,
96 api: "",
97 base_url: "",
98 auth_header: "",
99 }
100}
101
102/// Every provider, in the order people are shown them.
103pub const PROVIDERS: [Spec; 19] = [
104 model(Provider::Anthropic, "anthropic", "Anthropic", "anthropic", "https://api.anthropic.com", "x-api-key"),
105 model(Provider::Openai, "openai", "OpenAI", "openai", "https://api.openai.com/v1", "authorization"),
106 model(Provider::Gemini, "gemini", "Google Gemini", "openai", "https://generativelanguage.googleapis.com/v1beta/openai", "authorization"),
107 model(Provider::Xai, "xai", "xAI", "openai", "https://api.x.ai/v1", "authorization"),
108 model(Provider::Mistral, "mistral", "Mistral", "openai", "https://api.mistral.ai/v1", "authorization"),
109 model(Provider::Deepseek, "deepseek", "DeepSeek", "openai", "https://api.deepseek.com/v1", "authorization"),
110 model(Provider::AzureOpenai, "azure_openai", "Azure OpenAI", "openai", "", "api-key"),
111 model(Provider::Openrouter, "openrouter", "OpenRouter", "openai", "https://openrouter.ai/api/v1", "authorization"),
112 model(Provider::Groq, "groq", "Groq", "openai", "https://api.groq.com/openai/v1", "authorization"),
113 model(Provider::Together, "together", "Together AI", "openai", "https://api.together.xyz/v1", "authorization"),
114 model(Provider::Fireworks, "fireworks", "Fireworks AI", "openai", "https://api.fireworks.ai/inference/v1", "authorization"),
115 model(Provider::Cerebras, "cerebras", "Cerebras", "openai", "https://api.cerebras.ai/v1", "authorization"),
116 model(Provider::AnthropicEndpoint, "anthropic_endpoint", "Anthropic-compatible endpoint", "anthropic", "", "x-api-key"),
117 model(Provider::OpenaiEndpoint, "openai_endpoint", "OpenAI-compatible endpoint", "openai", "", "authorization"),
118 other(Provider::Sentry, "sentry", "Sentry", ProviderKind::Alerts),
119 other(Provider::Datadog, "datadog", "Datadog", ProviderKind::Alerts),
120 other(Provider::Webhook, "webhook", "Webhook", ProviderKind::Alerts),
121 other(Provider::Jira, "jira", "Jira", ProviderKind::Tracker),
122 other(Provider::Linear, "linear", "Linear", ProviderKind::Tracker),
123];
124
125impl Provider {
126 pub fn spec(self) -> &'static Spec {
127 PROVIDERS
128 .iter()
129 .find(|spec| spec.provider == self)
130 .expect("every provider is in the catalogue")
131 }
132
133 pub fn all() -> impl Iterator<Item = Provider> {
134 PROVIDERS.iter().map(|spec| spec.provider)
135 }
136
137 pub fn name(self) -> &'static str {
138 self.spec().name
139 }
140
141 pub fn parse(name: &str) -> Option<Provider> {
142 PROVIDERS.iter().find(|spec| spec.name == name).map(|spec| spec.provider)
143 }
144
145 /// What people call it.
146 pub fn label(self) -> &'static str {
147 self.spec().label
148 }
149
150 pub fn kind(self) -> ProviderKind {
151 self.spec().kind
152 }
153
154 /// Whether it sends g1t requests, at the connection's own address.
155 pub fn receives(self) -> bool {
156 self.kind() == ProviderKind::Alerts
157 }
158
159 /// For a model provider, the API it speaks: `anthropic` or `openai`.
160 pub fn api(self) -> &'static str {
161 self.spec().api
162 }
163
164 /// Whether the connection gives the address, rather than g1t knowing it.
165 pub fn own_address(self) -> bool {
166 self.kind() == ProviderKind::Models && self.spec().base_url.is_empty()
167 }
168}
169
170/// The kinds of work a model is chosen for, and `default` for the rest.
171pub const MODEL_TASKS: [&str; 5] = ["default", "implement", "review", "plan", "update"];
172
173#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
174#[serde(rename_all = "snake_case")]
175pub enum ProviderKind {
176 /// Where agents' model requests go. A workspace can have several and
177 /// routes each kind of work to one.
178 Models,
179 /// Problems that become issues.
180 Alerts,
181 /// Tickets that agents read and people import.
182 Tracker,
183}
184
185/// A connection's settings: everything about it except its secrets. Each
186/// provider uses the fields that apply to it.
187#[derive(Clone, Debug, Serialize, Deserialize)]
188#[serde(rename_all = "camelCase")]
189pub struct ConnectionConfig {
190 /// For alerts: the repository issues are opened in, `owner/name`. For a
191 /// tracker: where an imported ticket goes when no repository is named.
192 #[serde(default, skip_serializing_if = "Option::is_none")]
193 pub repo: Option<String>,
194 /// For alerts: put a g1t agent on each issue opened.
195 #[serde(default)]
196 pub assign: bool,
197 /// For alerts: the label put on each issue opened. `bug` when unset.
198 #[serde(default, skip_serializing_if = "Option::is_none")]
199 pub label: Option<String>,
200 /// Tell the outside system when the work lands: resolve the Sentry
201 /// issue, comment on the ticket.
202 #[serde(default = "yes")]
203 pub write_back: bool,
204 /// Sentry: the organization's slug.
205 #[serde(default, skip_serializing_if = "Option::is_none")]
206 pub organization: Option<String>,
207 /// The system's address, for Jira (`https://acme.atlassian.net`) or a
208 /// Sentry that is not sentry.io.
209 #[serde(default, skip_serializing_if = "Option::is_none")]
210 pub site: Option<String>,
211 /// Jira: the account the API token belongs to.
212 #[serde(default, skip_serializing_if = "Option::is_none")]
213 pub email: Option<String>,
214 /// Jira project keys or Linear team keys this connection answers for,
215 /// such as `TECH`. Empty answers for every key.
216 #[serde(default, skip_serializing_if = "Vec::is_empty")]
217 pub keys: Vec<String>,
218 /// Your own endpoint: its base URL, without `/v1`.
219 #[serde(default, skip_serializing_if = "Option::is_none")]
220 pub base_url: Option<String>,
221 /// Your own endpoint: send the key as `x-api-key` (the default) or as
222 /// `authorization: Bearer`.
223 #[serde(default, skip_serializing_if = "Option::is_none")]
224 pub auth_header: Option<String>,
225 /// Models: the model used when a route to this connection names none.
226 /// Required for providers that speak OpenAI's API; for Anthropic, g1t's
227 /// choice for the kind of work when unset.
228 #[serde(default, skip_serializing_if = "Option::is_none")]
229 pub model: Option<String>,
230}
231
232fn yes() -> bool {
233 true
234}
235
236impl Default for ConnectionConfig {
237 fn default() -> Self {
238 ConnectionConfig {
239 repo: None,
240 assign: false,
241 label: None,
242 write_back: true,
243 organization: None,
244 site: None,
245 email: None,
246 keys: Vec::new(),
247 base_url: None,
248 auth_header: None,
249 model: None,
250 }
251 }
252}
253
254/// A connection, as anyone in the workspace sees it. Secrets are never
255/// shown after they are saved; `secretHint` is enough to tell keys apart.
256#[derive(Clone, Debug, Serialize, Deserialize)]
257#[serde(rename_all = "camelCase")]
258pub struct Connection {
259 pub id: String,
260 pub workspace: String,
261 pub provider: Provider,
262 pub kind: ProviderKind,
263 pub name: String,
264 pub config: ConnectionConfig,
265 /// The last four characters of the saved key, such as `…3f9a`.
266 pub secret_hint: Option<String>,
267 /// For a provider that sends g1t requests: where it sends them.
268 pub webhook_url: Option<String>,
269 pub created_by: String,
270 /// RFC 3339.
271 pub created_at: String,
272 pub last_used_at: Option<String>,
273 /// The last thing that went wrong talking to it, until it next works.
274 pub last_error: Option<String>,
275 /// For a model provider: the models it offered when last checked.
276 #[serde(default)]
277 pub models: Vec<String>,
278}
279
280/// Where one kind of work's model requests go in a workspace.
281#[derive(Clone, Debug, Serialize, Deserialize)]
282#[serde(rename_all = "camelCase")]
283pub struct ModelRoute {
284 /// One of [`MODEL_TASKS`].
285 pub task: String,
286 /// The workspace's own model connection, or `None` for g1t's hosted
287 /// models.
288 pub connection_id: Option<String>,
289 /// The model at that connection; its default model when `None`.
290 pub model: Option<String>,
291}
292
293/// One request an outside system sent, and what g1t did with it.
294#[derive(Clone, Debug, Serialize, Deserialize)]
295#[serde(rename_all = "camelCase")]
296pub struct Delivery {
297 pub id: String,
298 /// RFC 3339.
299 pub received_at: String,
300 /// What it was about, in the sender's terms: `issue.created`.
301 pub event: String,
302 /// `opened`, `updated`, `reopened`, `ignored` or `refused`.
303 pub outcome: String,
304 pub detail: String,
305 /// The issue it opened or updated, `owner/name#number`.
306 pub issue: Option<String>,
307}
308
309/// Something outside g1t, fetched as it is now. Its text was written outside
310/// g1t, so it is reference material and never instructions.
311#[derive(Clone, Debug, Serialize, Deserialize)]
312#[serde(rename_all = "camelCase")]
313pub struct ContextItem {
314 pub provider: Provider,
315 /// `TECH-1234`, or the Sentry issue's short id.
316 pub key: String,
317 pub title: String,
318 pub url: String,
319 /// Its status in that system: `In Progress`, `unresolved`.
320 pub status: Option<String>,
321 /// Its description, as plain text, shortened if long.
322 pub body: String,
323 /// RFC 3339: when g1t fetched it.
324 pub fetched_at: String,
325}
326
327/// An issue's tie to something outside g1t.
328#[derive(Clone, Debug, Serialize, Deserialize)]
329#[serde(rename_all = "camelCase")]
330pub struct Link {
331 pub provider: Provider,
332 pub connection_id: String,
333 pub key: String,
334 pub title: String,
335 pub url: String,
336 /// How many times an alert has fired for it.
337 pub count: u32,
338 /// RFC 3339.
339 pub first_seen: String,
340 pub last_seen: String,
341}
342
343/// Where a workspace's agents' model requests go.
344#[derive(Clone, Debug, Serialize, Deserialize)]
345#[serde(rename_all = "camelCase")]
346pub struct ModelSession {
347 /// What the sandbox sends instead of a key. Lives as long as one run.
348 pub token: String,
349 /// `g1t` when g1t pays the provider and charges the workspace,
350 /// `workspace` when the workspace's own account does.
351 pub billed_to: String,
352 /// The connection's name, when it is the workspace's own.
353 pub provider_name: Option<String>,
354 /// The model to use instead of g1t's choice, if the connection names one.
355 pub model: Option<String>,
356 /// Names the run in AI Gateway's logs (`metadata.session`), so billing
357 /// can charge each run what the gateway priced its requests at. Not a
358 /// secret: it cannot be turned back into the token.
359 #[serde(default)]
360 pub id: String,
361}
362
363/// What the model proxy needs to forward one run's requests.
364#[derive(Clone, Debug, Serialize, Deserialize)]
365#[serde(rename_all = "camelCase")]
366pub struct ModelUpstream {
367 /// `g1t`, `anthropic` or `endpoint`.
368 pub route: String,
369 /// The API the provider speaks: `anthropic` or `openai`, which the proxy
370 /// translates to.
371 pub api: String,
372 /// The model every request of the run is sent to, when the route names
373 /// one.
374 pub model: Option<String>,
375 /// For `openai`: OpenAI's own API, which shapes requests its own way.
376 pub official: bool,
377 /// Which provider it is, by name, so the proxy can meet its quirks.
378 #[serde(default)]
379 pub provider: String,
380 pub workspace: String,
381 pub repo: String,
382 pub number: u32,
383 pub task: String,
384 /// The session's id; see `ModelSession::id`.
385 #[serde(default)]
386 pub session: String,
387 /// For `endpoint`: where to send requests.
388 pub base_url: Option<String>,
389 /// For `anthropic` and `endpoint`: the workspace's key.
390 pub api_key: Option<String>,
391 /// `x-api-key` or `authorization`.
392 pub auth_header: Option<String>,
393 /// For an endpoint behind an authenticated Cloudflare AI Gateway: the
394 /// gateway's own token, sent as `cf-aig-authorization`.
395 #[serde(default)]
396 pub gateway_token: Option<String>,
397}
398
399// --- Methods -----------------------------------------------------------------
400
401/// `list`. Returns `Outcome<Vec<Connection>>`. Members only.
402#[derive(Debug, Serialize, Deserialize)]
403pub struct ListArgs {
404 pub workspace: String,
405 pub viewer: Viewer,
406}
407
408/// `connect`. Returns `Outcome<Connected>`. Owners only.
409#[derive(Debug, Serialize, Deserialize)]
410#[serde(rename_all = "camelCase")]
411pub struct ConnectArgs {
412 pub actor: User,
413 pub workspace: String,
414 pub provider: Provider,
415 #[serde(default)]
416 pub name: Option<String>,
417 #[serde(default)]
418 pub config: ConnectionConfig,
419 /// The API key or token g1t uses to call it.
420 #[serde(default)]
421 pub secret: Option<String>,
422 /// What it signs its requests to g1t with: Sentry's client secret.
423 /// Made by g1t for Datadog and webhooks, and shown once. For a model
424 /// endpoint behind an authenticated Cloudflare AI Gateway, the gateway's
425 /// token.
426 #[serde(default)]
427 pub signing_secret: Option<String>,
428}
429
430#[derive(Clone, Debug, Serialize, Deserialize)]
431#[serde(rename_all = "camelCase")]
432pub struct Connected {
433 pub connection: Connection,
434 /// A signing secret g1t made, shown this once.
435 pub signing_secret: Option<String>,
436}
437
438/// `update`: only the fields given change. Returns `Outcome<Connection>`.
439/// Owners only.
440#[derive(Debug, Serialize, Deserialize)]
441#[serde(rename_all = "camelCase")]
442pub struct UpdateArgs {
443 pub actor: User,
444 pub workspace: String,
445 pub id: String,
446 #[serde(default)]
447 pub name: Option<String>,
448 #[serde(default)]
449 pub config: Option<ConnectionConfig>,
450 #[serde(default)]
451 pub secret: Option<String>,
452 #[serde(default)]
453 pub signing_secret: Option<String>,
454}
455
456/// `disconnect` and `test`. `disconnect` returns `Outcome<bool>`; `test`
457/// returns `Outcome<Tested>`. Owners only.
458#[derive(Debug, Serialize, Deserialize)]
459pub struct ConnectionArgs {
460 pub actor: User,
461 pub workspace: String,
462 pub id: String,
463}
464
465#[derive(Clone, Debug, Serialize, Deserialize)]
466pub struct Tested {
467 pub ok: bool,
468 pub message: String,
469}
470
471/// `deliveries`: the latest requests a connection received, newest first.
472/// Returns `Outcome<Vec<Delivery>>`. Members only.
473#[derive(Debug, Serialize, Deserialize)]
474pub struct DeliveriesArgs {
475 pub workspace: String,
476 pub viewer: Viewer,
477 pub id: String,
478}
479
480/// `receive`: a request an outside system sent to a connection's address.
481/// Returns `Received`. Anyone can send one; only a signed one is acted on.
482#[derive(Debug, Serialize, Deserialize)]
483pub struct ReceiveArgs {
484 pub id: String,
485 /// Header names in lowercase.
486 pub headers: std::collections::HashMap<String, String>,
487 pub body: String,
488}
489
490#[derive(Clone, Debug, Serialize, Deserialize)]
491pub struct Received {
492 /// The HTTP status to answer with.
493 pub status: u16,
494 pub message: String,
495}
496
497/// `resolve`: fetches one outside reference. Returns `Outcome<ContextItem>`.
498/// Members of the workspace only.
499#[derive(Debug, Serialize, Deserialize)]
500pub struct ResolveArgs {
501 pub workspace: String,
502 pub viewer: Viewer,
503 /// `TECH-1234`, or a Jira, Linear or Sentry address.
504 pub reference: String,
505}
506
507/// `references`: every outside reference in `text` that one of the
508/// workspace's connections answers for, fetched. Returns `Vec<ContextItem>`.
509/// For g1t's own agents, about work in that workspace.
510#[derive(Debug, Serialize, Deserialize)]
511pub struct ReferencesArgs {
512 pub workspace: String,
513 pub text: String,
514 #[serde(default)]
515 pub limit: Option<u32>,
516}
517
518/// `import`: opens an issue from a ticket. Returns `Outcome<Imported>`.
519#[derive(Debug, Serialize, Deserialize)]
520pub struct ImportArgs {
521 pub actor: User,
522 pub repo: RepoPath,
523 pub reference: String,
524 /// Put a g1t agent on it.
525 #[serde(default)]
526 pub assign: bool,
527}
528
529#[derive(Clone, Debug, Serialize, Deserialize)]
530pub struct Imported {
531 pub number: u32,
532 pub item: ContextItem,
533 /// False when the ticket had been imported already, and `number` is
534 /// that issue.
535 pub created: bool,
536}
537
538/// `links`: what an issue is tied to outside g1t. Returns `Vec<Link>`.
539/// Callers must have checked the viewer may see the issue.
540#[derive(Debug, Serialize, Deserialize)]
541#[serde(rename_all = "camelCase")]
542pub struct LinksArgs {
543 pub repo: RepoPath,
544 pub number: u32,
545}
546
547/// `open_model_session`: where one run's model requests go, by the
548/// workspace's routes. Returns `Outcome<ModelSession>`: a failure, with the
549/// reason to show, when the route goes nowhere it can use.
550#[derive(Debug, Serialize, Deserialize)]
551#[serde(rename_all = "camelCase")]
552pub struct OpenModelSessionArgs {
553 pub workspace: String,
554 pub repo: RepoPath,
555 pub number: u32,
556 pub task: String,
557 /// Whether g1t's hosted models are open to the workspace. The runner
558 /// decides that; this service only follows the routes.
559 #[serde(default = "yes")]
560 pub hosted_open: bool,
561}
562
563/// `routes`: a workspace's model routes, one per kind of work that has its
564/// own. Returns `Outcome<Vec<ModelRoute>>`. Members only.
565#[derive(Debug, Serialize, Deserialize)]
566pub struct RoutesArgs {
567 pub workspace: String,
568 pub viewer: Viewer,
569}
570
571/// `set_routes`: replaces a workspace's model routes. A kind of work left
572/// out follows `default`; with no `default`, g1t's hosted models where they
573/// are open. Returns `Outcome<Vec<ModelRoute>>`. Owners only.
574#[derive(Debug, Serialize, Deserialize)]
575pub struct SetRoutesArgs {
576 pub actor: User,
577 pub workspace: String,
578 pub routes: Vec<ModelRoute>,
579}
580
581/// `model_upstream`: what a model session's token stands for, or null when
582/// it is unknown or expired. Returns `Option<ModelUpstream>`.
583#[derive(Debug, Serialize, Deserialize)]
584pub struct ModelUpstreamArgs {
585 pub token: String,
586}
587
588/// `close_model_sessions`: ends the model sessions whose tokens hash to
589/// these (SHA-256, lowercase hex), so a run's model token stops working
590/// when its run does rather than when it would lapse. Returns how many
591/// were open.
592#[derive(Debug, Serialize, Deserialize)]
593pub struct CloseModelSessionsArgs {
594 #[serde(alias = "tokenHashes")]
595 pub token_hashes: Vec<String>,
596}
597
598/// `model_provider`: the workspace's own model connection, if it has one.
599/// Returns `Option<Connection>`.
600#[derive(Debug, Serialize, Deserialize)]
601pub struct ModelProviderArgs {
602 pub workspace: String,
603}
604
605#[cfg(test)]
606mod tests {
607 use super::*;
608
609 #[test]
610 fn every_provider_is_named_once_and_found_again() {
611 let mut names: Vec<&str> = PROVIDERS.iter().map(|spec| spec.name).collect();
612 for provider in Provider::all() {
613 assert_eq!(Provider::parse(provider.name()), Some(provider));
614 }
615 names.sort();
616 names.dedup();
617 assert_eq!(names.len(), PROVIDERS.len());
618 }
619
620 #[test]
621 fn model_providers_say_how_to_reach_them() {
622 for spec in PROVIDERS.iter().filter(|spec| spec.kind == ProviderKind::Models) {
623 assert!(spec.api == "anthropic" || spec.api == "openai", "{}", spec.name);
624 assert!(!spec.auth_header.is_empty(), "{}", spec.name);
625 assert!(spec.base_url.is_empty() || spec.base_url.starts_with("https://"), "{}", spec.name);
626 }
627 }
628}