use std::{collections::BTreeMap, path::Path};
use serde::{Deserialize, Serialize};
use url::Url;
use super::storage::{Location, Storage, Tokens};
use crate::{
config::{Backend, Provider},
llm::EndpointApi,
};
const FILE: &str = "providers.json";
const LOCK: &str = "providers";
const MAX_KEY_BYTES: usize = 4096;
#[derive(Clone, PartialEq, Eq, Serialize)]
#[serde(transparent)]
pub struct ApiKey(String);
impl<'de> Deserialize<'de> for ApiKey {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
String::deserialize(deserializer)?
.parse()
.map_err(serde::de::Error::custom)
}
}
impl ApiKey {
pub(crate) fn expose(&self) -> &str {
&self.0
}
}
impl std::fmt::Debug for ApiKey {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str("ApiKey(…)")
}
}
impl std::str::FromStr for ApiKey {
type Err = String;
fn from_str(value: &str) -> Result<Self, Self::Err> {
let key = value.trim();
if (1..=MAX_KEY_BYTES).contains(&key.len())
&& key.bytes().all(|byte| byte.is_ascii_graphic())
{
Ok(Self(key.to_owned()))
} else {
Err("An API key is 1–4096 printable ASCII characters without spaces.".into())
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(transparent)]
pub struct EndpointUrl(String);
impl EndpointUrl {
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::str::FromStr for EndpointUrl {
type Err = String;
fn from_str(value: &str) -> Result<Self, Self::Err> {
let url = Url::parse(value.trim()).map_err(|_| format!("{value:?} is not a URL."))?;
if !matches!(url.scheme(), "http" | "https") || url.host().is_none() {
return Err("An endpoint URL uses http:// or https:// and names a host.".into());
}
if !url.username().is_empty()
|| url.password().is_some()
|| url.query().is_some()
|| url.fragment().is_some()
{
return Err(
"An endpoint URL has no credentials, query or fragment; the key goes in its own field."
.into(),
);
}
Ok(Self(url.as_str().trim_end_matches('/').to_owned()))
}
}
impl<'de> Deserialize<'de> for EndpointUrl {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
String::deserialize(deserializer)?
.parse()
.map_err(serde::de::Error::custom)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "lowercase", deny_unknown_fields)]
pub enum Custom {
Alias { provider: Provider },
Endpoint {
api: EndpointApi,
url: EndpointUrl,
#[serde(default, skip_serializing_if = "Option::is_none")]
key: Option<ApiKey>,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum CustomView {
Alias {
provider: Provider,
},
Endpoint {
api: EndpointApi,
url: EndpointUrl,
key: bool,
},
}
impl From<&Custom> for CustomView {
fn from(custom: &Custom) -> Self {
match custom {
Custom::Alias { provider } => Self::Alias {
provider: *provider,
},
Custom::Endpoint { api, url, key } => Self::Endpoint {
api: *api,
url: url.clone(),
key: key.is_some(),
},
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum KeySource {
Environment,
Saved,
Absent,
}
#[derive(Debug, Clone, Serialize)]
pub struct KeyStatus {
pub provider: Provider,
pub variable: &'static str,
pub source: KeySource,
pub saved: bool,
}
#[derive(Debug, Clone, Serialize)]
pub struct Providers {
pub keys: Vec<KeyStatus>,
pub custom: BTreeMap<Backend, CustomView>,
#[serde(skip_serializing_if = "Option::is_none")]
pub refused: Option<String>,
}
#[derive(Debug, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct Saved {
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
keys: BTreeMap<Provider, ApiKey>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
custom: BTreeMap<Backend, Custom>,
}
pub fn key_variable(provider: Provider) -> Option<&'static str> {
match provider {
Provider::Openai => Some("OPENAI_API_KEY"),
Provider::Anthropic => Some("ANTHROPIC_API_KEY"),
Provider::Gemini => Some("GEMINI_API_KEY"),
Provider::Codex | Provider::ClaudeAgent => None,
}
}
fn environment_key(provider: Provider) -> Option<Result<ApiKey, String>> {
let variable = key_variable(provider)?;
let value = crate::platform::environment::var_text(variable)?;
Some(
value
.parse()
.map_err(|error| format!("{variable} is set but invalid: {error}")),
)
}
fn read(state: Option<&Path>, repo: Option<&Path>) -> Result<(Saved, Option<String>), String> {
let location = Location::find(state, repo)?;
if let Some(refusal) = location.refusal(Tokens::Providers) {
return match crate::platform::entry_exists(&location.directory.join(FILE)) {
Ok(false) => Ok((Saved::default(), Some(refusal))),
_ => Err(refusal),
};
}
let saved = Storage::inspect(state, repo, Tokens::Providers)?
.read(FILE)?
.unwrap_or_default();
Ok((saved, None))
}
pub fn list(state: Option<&Path>, repo: Option<&Path>) -> Result<Providers, String> {
let (saved, refused) = read(state, repo)?;
Ok(Providers {
keys: Provider::ALL
.into_iter()
.filter_map(|provider| {
let variable = key_variable(provider)?;
let stored = saved.keys.contains_key(&provider);
let source = if environment_key(provider).is_some() {
KeySource::Environment
} else if stored {
KeySource::Saved
} else {
KeySource::Absent
};
Some(KeyStatus {
provider,
variable,
source,
saved: stored,
})
})
.collect(),
custom: saved
.custom
.iter()
.map(|(name, custom)| (name.clone(), custom.into()))
.collect(),
refused,
})
}
pub(crate) fn key(
provider: Provider,
state: Option<&Path>,
repo: Option<&Path>,
) -> Result<Option<ApiKey>, String> {
if let Some(key) = environment_key(provider) {
return key.map(Some);
}
Ok(read(state, repo)?.0.keys.remove(&provider))
}
pub(crate) fn custom(
name: &Backend,
state: Option<&Path>,
repo: Option<&Path>,
) -> Result<Option<Custom>, String> {
Ok(read(state, repo)?.0.custom.remove(name))
}
async fn update(
state: Option<&Path>,
repo: Option<&Path>,
change: impl FnOnce(&mut Saved) -> Result<(), String>,
) -> Result<(), String> {
let storage = Storage::new(state, repo, Tokens::Providers)?;
let _lock = storage.lock(LOCK).await?;
let mut saved: Saved = storage.read(FILE)?.unwrap_or_default();
change(&mut saved)?;
storage.save(FILE, &saved)
}
pub async fn save_key(
state: Option<&Path>,
repo: Option<&Path>,
provider: Provider,
key: ApiKey,
) -> Result<(), String> {
if key_variable(provider).is_none() {
return Err(format!(
"{provider} signs in instead of using a key: artifactize login {provider}."
));
}
update(state, repo, |saved| {
saved.keys.insert(provider, key);
Ok(())
})
.await
}
pub async fn remove_key(
state: Option<&Path>,
repo: Option<&Path>,
provider: Provider,
) -> Result<(), String> {
update(state, repo, |saved| {
saved.keys.remove(&provider);
Ok(())
})
.await
}
pub enum KeyChange {
Set(ApiKey),
Keep,
None,
}
pub async fn save_custom(
state: Option<&Path>,
repo: Option<&Path>,
name: Backend,
mut custom: Custom,
key: KeyChange,
) -> Result<(), String> {
if let Some(provider) = name.builtin() {
return Err(format!(
"{provider} is a built-in provider; give the custom provider another name."
));
}
update(state, repo, |saved| {
if let Custom::Endpoint { key: slot, .. } = &mut custom {
*slot = match key {
KeyChange::Set(key) => Some(key),
KeyChange::Keep => match saved.custom.get(&name) {
Some(Custom::Endpoint { key, .. }) => key.clone(),
_ => None,
},
KeyChange::None => None,
};
}
saved.custom.insert(name, custom);
Ok(())
})
.await
}
pub async fn remove_custom(
state: Option<&Path>,
repo: Option<&Path>,
name: &Backend,
) -> Result<(), String> {
update(state, repo, |saved| {
saved
.custom
.remove(name)
.map(drop)
.ok_or_else(|| format!("No custom provider is named {name}."))
})
.await
}