Skip to main content

artifactize_core/auth/
providers.rs

1//! API keys saved for the built-in providers and the custom providers of this machine, in
2//! `$STATE/auth/providers.json` beside the other credentials. A project only names a
3//! provider in `backend`; what the name reaches is set up here, by the person who runs it.
4
5use 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";
17/// Serialize read-modify-write updates across processes, as Codex refreshes are.
18const LOCK: &str = "providers";
19/// Bound a pasted key: provider keys are a few hundred bytes at most.
20const MAX_KEY_BYTES: usize = 4096;
21
22/// An API key; never printed, logged or returned to a caller outside this crate.
23#[derive(Clone, PartialEq, Eq, Serialize)]
24#[serde(transparent)]
25pub struct ApiKey(String);
26
27/// A saved key passes the same checks as a typed one.
28impl<'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/// A custom endpoint's API root: http(s), without credentials, a query or a fragment, which
63/// belong in its key.
64#[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/// What a custom provider reaches.
104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
105#[serde(tag = "kind", rename_all = "lowercase", deny_unknown_fields)]
106pub enum Custom {
107    /// A built-in provider under another name, with its sign-in or key.
108    Alias { provider: Provider },
109    /// An API at a URL, with an optional key.
110    Endpoint {
111        api: EndpointApi,
112        url: EndpointUrl,
113        #[serde(default, skip_serializing_if = "Option::is_none")]
114        key: Option<ApiKey>,
115    },
116}
117
118/// A custom provider as Settings and `provider list` show it: never its key.
119#[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/// Where a built-in provider's key comes from.
148#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
149#[serde(rename_all = "lowercase")]
150pub enum KeySource {
151    Environment,
152    Saved,
153    Absent,
154}
155
156/// A built-in API-key provider's key, as Settings and `doctor` show it.
157#[derive(Debug, Clone, Serialize)]
158pub struct KeyStatus {
159    pub provider: Provider,
160    /// The environment variable read first.
161    pub variable: &'static str,
162    pub source: KeySource,
163    /// Whether a key is saved here, also while the variable comes first.
164    pub saved: bool,
165}
166
167/// The providers this machine has set up.
168#[derive(Debug, Clone, Serialize)]
169pub struct Providers {
170    pub keys: Vec<KeyStatus>,
171    pub custom: BTreeMap<Backend, CustomView>,
172    /// Why nothing can be saved in this state, which holds nothing yet.
173    #[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
186/// The environment variable that holds an API-key provider's key; `None` for Codex, which
187/// signs in instead.
188pub 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
197/// The key in `provider`'s variable. A set variable is the key in use: an invalid value is
198/// an error, never a reason to fall back to a saved key from another account.
199fn 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
209/// The saved keys and custom providers, and why none can be saved here. Environment keys
210/// work in any state, so refused storage is an error only once it holds something.
211fn 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
225/// What `state` has set up, without any key.
226pub 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
258/// A built-in provider's key: its environment variable first, then the saved key.
259pub(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
270/// The custom provider `name`, if this machine has one.
271pub(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
291/// Save `key` for an API-key provider.
292pub 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
310/// Forget the saved key of `provider`; its environment variable is untouched.
311pub 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
323/// How a custom provider is saved: its key is replaced, kept from the provider it replaces,
324/// or left out.
325pub enum KeyChange {
326    Set(ApiKey),
327    Keep,
328    None,
329}
330
331/// Add the custom provider `name`, or replace it.
332pub 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
361/// Remove the custom provider `name`.
362pub 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}