Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| The contracts no longer describe g1t's own agents, and workspaces can keep their AI providers. Every consumer had already stopped using them, so the TypeScript and Rust contracts drop the workspace-agent, ability, look, skill-library, context, runner-agent and mention modules, the chat and docs RPCs agents posted and read through, the folio agent methods, the agent_waiting notification, agent.asked and memory.changed events, AI credit, token and gateway billing types, model routes and sessions, team agents, agent tokens, plan, review, message and confidence types, command rules, cost caps and confidence rules. The agents:run and models:* scopes are no longer offered by any preset, OAuth default or permission form, but a stored token or grant that names them still reads; and the removed operations are gone from the scope table, so the API's check that every operation has exactly one scope is strict again. What stored data needs in order to parse (old run and credential kinds, an agent team lead, retired rules and webhook events, old guardrail and integration settings) still parses, and a run's token is still PrincipalKind::Agent, which now means a run's token and the @g1t rows written before. The customize, marketplace, skills and claude-plugin contracts stay only until the mobile app drops its Customize screens. | 1 | //! A workspace's AI providers: where each one's API is, how it takes its |
| 2 | //! key, and checking that the key works by asking the provider for its | |
| 3 | //! models. | |
| 4 | ||
| 5 | use g1t_contracts::integrations::{ConnectionConfig, Provider}; | |
| 6 | use worker::{Method, Result}; | |
| 7 | ||
| 8 | use crate::http; | |
| 9 | ||
| 10 | /// Where requests go: without `/v1` for Anthropic's API, with the version | |
| 11 | /// for OpenAI's (`…/v1`, or Gemini's `…/v1beta/openai`). | |
| 12 | pub fn base_url(provider: Provider, config: &ConnectionConfig) -> String { | |
| 13 | let given = config.base_url.as_deref().unwrap_or_default().trim_end_matches('/'); | |
| 14 | match provider { | |
| 15 | Provider::AnthropicEndpoint => given.trim_end_matches("/v1").to_owned(), | |
| 16 | Provider::OpenaiEndpoint => given.to_owned(), | |
| 17 | // Azure's v1 API, on the workspace's own resource. | |
| 18 | Provider::AzureOpenai => format!("{}/openai/v1", given.trim_end_matches("/openai/v1").trim_end_matches("/openai")), | |
| 19 | _ => provider.spec().base_url.to_owned(), | |
| 20 | } | |
| 21 | } | |
| 22 | ||
| 23 | /// The header the key goes in; `authorization` means `Bearer <key>`. | |
| 24 | pub fn auth_header(provider: Provider, config: &ConnectionConfig) -> String { | |
| 25 | match provider { | |
| 26 | Provider::AnthropicEndpoint | Provider::OpenaiEndpoint => config | |
| 27 | .auth_header | |
| 28 | .clone() | |
| 29 | .unwrap_or_else(|| provider.spec().auth_header.to_owned()), | |
| 30 | _ => provider.spec().auth_header.to_owned(), | |
| 31 | } | |
| 32 | } | |
| 33 | ||
| 34 | /// Whether a model id is one for chat: not embeddings, images, | |
| 35 | /// speech or moderation. | |
| 36 | fn for_chat(id: &str) -> bool { | |
| 37 | let id = id.to_ascii_lowercase(); | |
| 38 | !["embed", "tts", "whisper", "dall-e", "image", "moderation", "audio", "transcribe", "realtime", "search", "aqa", "imagen", "veo"] | |
| 39 | .iter() | |
| 40 | .any(|word| id.contains(word)) | |
| 41 | } | |
| 42 | ||
| 43 | /// Asks the provider for its models with the key. `Ok` with what to say and | |
| 44 | /// the models it offers; `Err` with what went wrong. | |
| 45 | pub async fn test( | |
| 46 | provider: Provider, | |
| 47 | config: &ConnectionConfig, | |
| 48 | key: Option<&str>, | |
| 49 | ) -> Result<std::result::Result<(String, Vec<String>), String>> { | |
| 50 | let base = base_url(provider, config); | |
| 51 | let url = match provider.api() { | |
| 52 | "anthropic" => format!("{base}/v1/models?limit=100"), | |
| 53 | _ => format!("{base}/models"), | |
| 54 | }; | |
| 55 | let header = auth_header(provider, config); | |
| 56 | let bearer = key.map(|key| format!("Bearer {key}")); | |
| 57 | let mut headers = vec![("anthropic-version", "2023-06-01")]; | |
| 58 | if let Some(key) = key { | |
| 59 | if header == "authorization" { | |
| 60 | headers.push(("authorization", bearer.as_deref().unwrap_or_default())); | |
| 61 | } else { | |
| 62 | headers.push((header.as_str(), key)); | |
| 63 | } | |
| 64 | } | |
| 65 | let answer = http::send(Method::Get, &url, &headers, None).await?; | |
| 66 | let system = provider.label(); | |
| 67 | if answer.ok() { | |
| 68 | let mut models: Vec<String> = answer.json()["data"] | |
| 69 | .as_array() | |
| 70 | .map(|data| { | |
| 71 | data.iter() | |
| 72 | .filter_map(|model| model["id"].as_str()) | |
| 73 | // Gemini names models `models/gemini-…`. | |
| 74 | .map(|id| id.trim_start_matches("models/").to_owned()) | |
| 75 | .filter(|id| for_chat(id)) | |
| 76 | .collect() | |
| 77 | }) | |
| 78 | .unwrap_or_default(); | |
| 79 | models.sort(); | |
| 80 | models.truncate(1000); | |
| 81 | let message = match models.len() { | |
| 82 | 0 => format!("{system} accepted the key."), | |
| 83 | count => format!("{system} accepted the key and offers {count} models."), | |
| 84 | }; | |
| 85 | return Ok(Ok((message, models))); | |
| 86 | } | |
| 87 | // A proxy may answer messages but not list models: it was reached, but | |
| 88 | // the key could not be checked. | |
| 89 | if answer.status == 404 && matches!(provider, Provider::AnthropicEndpoint | Provider::OpenaiEndpoint) { | |
| 90 | return Ok(Ok(( | |
| 91 | "Reached the endpoint. It does not list models, so the key could not be checked.".to_owned(), | |
| 92 | Vec::new(), | |
| 93 | ))); | |
| 94 | } | |
| 95 | Ok(Err(answer.problem(system))) | |
| 96 | } | |
| 97 | ||
| 98 | #[cfg(test)] | |
| 99 | mod tests { | |
| 100 | use super::*; | |
| 101 | ||
| 102 | #[test] | |
| 103 | fn each_provider_has_its_address_and_header() { | |
| 104 | let config = ConnectionConfig { | |
| 105 | base_url: Some("https://llm.acme.dev/v1/".to_owned()), | |
| 106 | ..ConnectionConfig::default() | |
| 107 | }; | |
| 108 | assert_eq!(base_url(Provider::Openai, &config), "https://api.openai.com/v1"); | |
| 109 | assert_eq!(base_url(Provider::OpenaiEndpoint, &config), "https://llm.acme.dev/v1"); | |
| 110 | assert_eq!(base_url(Provider::AnthropicEndpoint, &config), "https://llm.acme.dev"); | |
| 111 | assert_eq!(auth_header(Provider::Gemini, &config), "authorization"); | |
| 112 | assert_eq!(auth_header(Provider::Anthropic, &config), "x-api-key"); | |
| 113 | assert_eq!(auth_header(Provider::AzureOpenai, &config), "api-key"); | |
| 114 | assert_eq!(base_url(Provider::Groq, &config), "https://api.groq.com/openai/v1"); | |
| 115 | let azure = ConnectionConfig { | |
| 116 | base_url: Some("https://acme.openai.azure.com/".to_owned()), | |
| 117 | ..ConnectionConfig::default() | |
| 118 | }; | |
| 119 | assert_eq!(base_url(Provider::AzureOpenai, &azure), "https://acme.openai.azure.com/openai/v1"); | |
| 120 | } | |
| 121 | ||
| 122 | #[test] | |
| 123 | fn only_models_that_can_chat_are_offered() { | |
| 124 | assert!(for_chat("gpt-5")); | |
| 125 | assert!(for_chat("gemini-2.5-pro")); | |
| 126 | assert!(!for_chat("text-embedding-3-large")); | |
| 127 | assert!(!for_chat("gpt-image-1")); | |
| 128 | } | |
| 129 | } |