Skip to content
129 linesCodeBlameRaw
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
5use g1t_contracts::integrations::{ConnectionConfig, Provider};
6use worker::{Method, Result};
7
8use 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`).
12pub 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>`.
24pub 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.
36fn 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.
45pub 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)]
99mod 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}