| 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 | |
| 18 | use serde::{Deserialize, Serialize}; |
| 19 | |
| 20 | use crate::repos::RepoPath; |
| 21 | use 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")] |
| 26 | pub 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")] |
| 56 | pub 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. |
| 66 | pub 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 | |
| 80 | const 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 | |
| 92 | use AiGroup::{Labs, Platforms, SelfHosted}; |
| 93 | |
| 94 | /// Every AI provider, in the order people are shown them. |
| 95 | pub 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 | |
| 112 | impl 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")] |
| 155 | pub 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")] |
| 179 | pub 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)] |
| 203 | pub 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)] |
| 211 | pub 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)] |
| 227 | pub 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)] |
| 243 | pub 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. |
| 252 | pub const ASK_MAX_MESSAGES: usize = 40; |
| 253 | /// The longest one message may be, in characters. |
| 254 | pub const ASK_MAX_MESSAGE_CHARS: usize = 16_000; |
| 255 | /// The longest a whole conversation may be, in characters. |
| 256 | pub const ASK_MAX_TOTAL_CHARS: usize = 64_000; |
| 257 | /// The most tokens one answer may run to. |
| 258 | pub 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. |
| 262 | pub 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)] |
| 267 | pub 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)] |
| 289 | pub 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)] |
| 298 | pub 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")] |
| 311 | pub enum AskRole { |
| 312 | User, |
| 313 | Assistant, |
| 314 | } |
| 315 | |
| 316 | /// One turn of an Ask conversation. |
| 317 | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] |
| 318 | pub 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)] |
| 325 | pub 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)] |
| 352 | pub 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. |
| 362 | pub const ASK_MAX_TOOL_ROUNDS: usize = 10; |
| 363 | /// The most tool calls one answer makes, across its rounds. |
| 364 | pub 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. |
| 367 | pub const ASK_MAX_TOOL_RESULT_CHARS: usize = 16_000; |
| 368 | /// The most characters of tool results one answer gives the model in all. |
| 369 | pub 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. |
| 372 | pub const ASK_MAX_TOOL_SECONDS: u64 = 120; |
| 373 | /// The most sources one answer lists. |
| 374 | pub 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")] |
| 379 | pub 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)] |
| 392 | pub 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")] |
| 404 | pub 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)] |
| 454 | mod 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 | } |