1use std::{collections::BTreeMap, path::Path};
6
7use serde::{Deserialize, Serialize};
8use url::Url;
9
10use super::storage::{Location, Storage, Tokens};
11use crate::{
12 config::{Backend, Provider},
13 llm::EndpointApi,
14};
15
16const FILE: &str = "providers.json";
17const LOCK: &str = "providers";
19const MAX_KEY_BYTES: usize = 4096;
21
22#[derive(Clone, PartialEq, Eq, Serialize)]
24#[serde(transparent)]
25pub struct ApiKey(String);
26
27impl<'de> Deserialize<'de> for ApiKey {
29 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
30 String::deserialize(deserializer)?
31 .parse()
32 .map_err(serde::de::Error::custom)
33 }
34}
35
36impl ApiKey {
37 pub(crate) fn expose(&self) -> &str {
38 &self.0
39 }
40}
41
42impl std::fmt::Debug for ApiKey {
43 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44 formatter.write_str("ApiKey(…)")
45 }
46}
47
48impl std::str::FromStr for ApiKey {
49 type Err = String;
50 fn from_str(value: &str) -> Result<Self, Self::Err> {
51 let key = value.trim();
52 if (1..=MAX_KEY_BYTES).contains(&key.len())
53 && key.bytes().all(|byte| byte.is_ascii_graphic())
54 {
55 Ok(Self(key.to_owned()))
56 } else {
57 Err("An API key is 1–4096 printable ASCII characters without spaces.".into())
58 }
59 }
60}
61
62#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
65#[serde(transparent)]
66pub struct EndpointUrl(String);
67
68impl EndpointUrl {
69 pub fn as_str(&self) -> &str {
70 &self.0
71 }
72}
73
74impl std::str::FromStr for EndpointUrl {
75 type Err = String;
76 fn from_str(value: &str) -> Result<Self, Self::Err> {
77 let url = Url::parse(value.trim()).map_err(|_| format!("{value:?} is not a URL."))?;
78 if !matches!(url.scheme(), "http" | "https") || url.host().is_none() {
79 return Err("An endpoint URL uses http:// or https:// and names a host.".into());
80 }
81 if !url.username().is_empty()
82 || url.password().is_some()
83 || url.query().is_some()
84 || url.fragment().is_some()
85 {
86 return Err(
87 "An endpoint URL has no credentials, query or fragment; the key goes in its own field."
88 .into(),
89 );
90 }
91 Ok(Self(url.as_str().trim_end_matches('/').to_owned()))
92 }
93}
94
95impl<'de> Deserialize<'de> for EndpointUrl {
96 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
97 String::deserialize(deserializer)?
98 .parse()
99 .map_err(serde::de::Error::custom)
100 }
101}
102
103#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
105#[serde(tag = "kind", rename_all = "lowercase", deny_unknown_fields)]
106pub enum Custom {
107 Alias { provider: Provider },
109 Endpoint {
111 api: EndpointApi,
112 url: EndpointUrl,
113 #[serde(default, skip_serializing_if = "Option::is_none")]
114 key: Option<ApiKey>,
115 },
116}
117
118#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
120#[serde(tag = "kind", rename_all = "lowercase")]
121pub enum CustomView {
122 Alias {
123 provider: Provider,
124 },
125 Endpoint {
126 api: EndpointApi,
127 url: EndpointUrl,
128 key: bool,
129 },
130}
131
132impl From<&Custom> for CustomView {
133 fn from(custom: &Custom) -> Self {
134 match custom {
135 Custom::Alias { provider } => Self::Alias {
136 provider: *provider,
137 },
138 Custom::Endpoint { api, url, key } => Self::Endpoint {
139 api: *api,
140 url: url.clone(),
141 key: key.is_some(),
142 },
143 }
144 }
145}
146
147#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
149#[serde(rename_all = "lowercase")]
150pub enum KeySource {
151 Environment,
152 Saved,
153 Absent,
154}
155
156#[derive(Debug, Clone, Serialize)]
158pub struct KeyStatus {
159 pub provider: Provider,
160 pub variable: &'static str,
162 pub source: KeySource,
163 pub saved: bool,
165}
166
167#[derive(Debug, Clone, Serialize)]
169pub struct Providers {
170 pub keys: Vec<KeyStatus>,
171 pub custom: BTreeMap<Backend, CustomView>,
172 #[serde(skip_serializing_if = "Option::is_none")]
174 pub refused: Option<String>,
175}
176
177#[derive(Debug, Default, Serialize, Deserialize)]
178#[serde(deny_unknown_fields)]
179struct Saved {
180 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
181 keys: BTreeMap<Provider, ApiKey>,
182 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
183 custom: BTreeMap<Backend, Custom>,
184}
185
186pub fn key_variable(provider: Provider) -> Option<&'static str> {
189 match provider {
190 Provider::Openai => Some("OPENAI_API_KEY"),
191 Provider::Anthropic => Some("ANTHROPIC_API_KEY"),
192 Provider::Gemini => Some("GEMINI_API_KEY"),
193 Provider::Codex => None,
194 }
195}
196
197fn environment_key(provider: Provider) -> Option<Result<ApiKey, String>> {
200 let variable = key_variable(provider)?;
201 let value = crate::platform::environment::var_text(variable)?;
202 Some(
203 value
204 .parse()
205 .map_err(|error| format!("{variable} is set but invalid: {error}")),
206 )
207}
208
209fn read(state: Option<&Path>, repo: Option<&Path>) -> Result<(Saved, Option<String>), String> {
212 let location = Location::find(state, repo)?;
213 if let Some(refusal) = location.refusal(Tokens::Providers) {
214 return match crate::platform::entry_exists(&location.directory.join(FILE)) {
215 Ok(false) => Ok((Saved::default(), Some(refusal))),
216 _ => Err(refusal),
217 };
218 }
219 let saved = Storage::inspect(state, repo, Tokens::Providers)?
220 .read(FILE)?
221 .unwrap_or_default();
222 Ok((saved, None))
223}
224
225pub fn list(state: Option<&Path>, repo: Option<&Path>) -> Result<Providers, String> {
227 let (saved, refused) = read(state, repo)?;
228 Ok(Providers {
229 keys: Provider::ALL
230 .into_iter()
231 .filter_map(|provider| {
232 let variable = key_variable(provider)?;
233 let stored = saved.keys.contains_key(&provider);
234 let source = if environment_key(provider).is_some() {
235 KeySource::Environment
236 } else if stored {
237 KeySource::Saved
238 } else {
239 KeySource::Absent
240 };
241 Some(KeyStatus {
242 provider,
243 variable,
244 source,
245 saved: stored,
246 })
247 })
248 .collect(),
249 custom: saved
250 .custom
251 .iter()
252 .map(|(name, custom)| (name.clone(), custom.into()))
253 .collect(),
254 refused,
255 })
256}
257
258pub(crate) fn key(
260 provider: Provider,
261 state: Option<&Path>,
262 repo: Option<&Path>,
263) -> Result<Option<ApiKey>, String> {
264 if let Some(key) = environment_key(provider) {
265 return key.map(Some);
266 }
267 Ok(read(state, repo)?.0.keys.remove(&provider))
268}
269
270pub(crate) fn custom(
272 name: &Backend,
273 state: Option<&Path>,
274 repo: Option<&Path>,
275) -> Result<Option<Custom>, String> {
276 Ok(read(state, repo)?.0.custom.remove(name))
277}
278
279async fn update(
280 state: Option<&Path>,
281 repo: Option<&Path>,
282 change: impl FnOnce(&mut Saved) -> Result<(), String>,
283) -> Result<(), String> {
284 let storage = Storage::new(state, repo, Tokens::Providers)?;
285 let _lock = storage.lock(LOCK).await?;
286 let mut saved: Saved = storage.read(FILE)?.unwrap_or_default();
287 change(&mut saved)?;
288 storage.save(FILE, &saved)
289}
290
291pub async fn save_key(
293 state: Option<&Path>,
294 repo: Option<&Path>,
295 provider: Provider,
296 key: ApiKey,
297) -> Result<(), String> {
298 if key_variable(provider).is_none() {
299 return Err(format!(
300 "{provider} signs in instead of using a key: artifactize login {provider}."
301 ));
302 }
303 update(state, repo, |saved| {
304 saved.keys.insert(provider, key);
305 Ok(())
306 })
307 .await
308}
309
310pub async fn remove_key(
312 state: Option<&Path>,
313 repo: Option<&Path>,
314 provider: Provider,
315) -> Result<(), String> {
316 update(state, repo, |saved| {
317 saved.keys.remove(&provider);
318 Ok(())
319 })
320 .await
321}
322
323pub enum KeyChange {
326 Set(ApiKey),
327 Keep,
328 None,
329}
330
331pub async fn save_custom(
333 state: Option<&Path>,
334 repo: Option<&Path>,
335 name: Backend,
336 mut custom: Custom,
337 key: KeyChange,
338) -> Result<(), String> {
339 if let Some(provider) = name.builtin() {
340 return Err(format!(
341 "{provider} is a built-in provider; give the custom provider another name."
342 ));
343 }
344 update(state, repo, |saved| {
345 if let Custom::Endpoint { key: slot, .. } = &mut custom {
346 *slot = match key {
347 KeyChange::Set(key) => Some(key),
348 KeyChange::Keep => match saved.custom.get(&name) {
349 Some(Custom::Endpoint { key, .. }) => key.clone(),
350 _ => None,
351 },
352 KeyChange::None => None,
353 };
354 }
355 saved.custom.insert(name, custom);
356 Ok(())
357 })
358 .await
359}
360
361pub async fn remove_custom(
363 state: Option<&Path>,
364 repo: Option<&Path>,
365 name: &Backend,
366) -> Result<(), String> {
367 update(state, repo, |saved| {
368 saved
369 .custom
370 .remove(name)
371 .map(drop)
372 .ok_or_else(|| format!("No custom provider is named {name}."))
373 })
374 .await
375}