#![allow(missing_docs)]
use std::fmt;
use serde::{Deserialize, Serialize};
use crate::port::StoredLlmCredential;
pub fn supported_providers() -> Vec<&'static str> {
use crate::app::handlers::llm_proxy::Upstream;
Upstream::ALL.into_iter().map(Upstream::provider).collect()
}
#[derive(Deserialize)]
pub struct PutLlmCredentialRequest {
pub provider: String,
pub model: String,
pub api_key: String,
}
impl fmt::Debug for PutLlmCredentialRequest {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("PutLlmCredentialRequest")
.field("provider", &self.provider)
.field("model", &self.model)
.field("api_key", &"[redacted]")
.finish()
}
}
impl PutLlmCredentialRequest {
pub fn validate(&self) -> Result<&'static str, String> {
let provider = self.provider.trim().to_ascii_lowercase();
let Some(known) = crate::app::handlers::llm_proxy::Upstream::from_name(&provider) else {
return Err(format!(
"unsupported provider `{}`: expected one of {}",
self.provider,
supported_providers().join(", ")
));
};
let known = known.provider();
if self.model.trim().is_empty() {
return Err("`model` must not be empty".into());
}
if self.api_key.trim().is_empty() {
return Err("`api_key` must not be empty".into());
}
if self.api_key != self.api_key.trim() {
return Err("`api_key` has leading or trailing whitespace".into());
}
Ok(known)
}
}
#[derive(Debug, Serialize)]
pub struct LlmCredentialDto {
pub provider: String,
pub model: String,
pub fingerprint: String,
pub created_at_unix_secs: u64,
}
impl From<StoredLlmCredential> for LlmCredentialDto {
fn from(r: StoredLlmCredential) -> Self {
Self {
provider: r.provider,
model: r.model,
fingerprint: r.fingerprint,
created_at_unix_secs: r.created_at_unix_secs,
}
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ResolvedLlmDto {
pub provider: String,
pub model: String,
}
#[derive(Debug, Serialize)]
pub struct DeleteLlmCredentialDto {
pub deleted: bool,
}
#[cfg(test)]
mod tests {
use super::*;
fn req(provider: &str, model: &str, key: &str) -> PutLlmCredentialRequest {
PutLlmCredentialRequest {
provider: provider.into(),
model: model.into(),
api_key: key.into(),
}
}
#[test]
fn accepts_both_supported_providers_case_insensitively() {
assert_eq!(
req("openai", "gpt-4o-mini", "sk-x").validate().unwrap(),
"openai"
);
assert_eq!(
req("ANTHROPIC", "claude-haiku-4-5", "sk-x")
.validate()
.unwrap(),
"anthropic"
);
assert_eq!(
req(" OpenAI ", "gpt-4o", "sk-x").validate().unwrap(),
"openai"
);
}
#[test]
fn rejects_an_unknown_provider() {
let err = req("cohere", "m", "sk-x").validate().unwrap_err();
assert!(err.contains("cohere"), "{err}");
assert!(err.contains("openai"), "names the accepted set: {err}");
}
#[test]
fn rejects_empty_model_and_key() {
assert!(req("openai", "", "sk-x").validate().is_err());
assert!(req("openai", "m", "").validate().is_err());
assert!(req("openai", "m", " ").validate().is_err());
}
#[test]
fn rejects_a_key_with_stray_whitespace() {
let err = req("openai", "m", " sk-x\n").validate().unwrap_err();
assert!(err.contains("whitespace"), "{err}");
}
#[test]
fn no_validation_error_ever_quotes_the_key() {
let secret = "sk-live-must-never-appear";
for r in [
req("cohere", "m", secret),
req("openai", "", secret),
req("openai", "m", &format!(" {secret} ")),
] {
let err = r.validate().unwrap_err();
assert!(!err.contains(secret), "key leaked into error: {err}");
}
}
#[test]
fn debug_redacts_the_key() {
let d = format!("{:?}", req("openai", "gpt-4o-mini", "sk-live-secret"));
assert!(!d.contains("sk-live-secret"), "{d}");
assert!(d.contains("[redacted]"), "{d}");
assert!(
d.contains("gpt-4o-mini"),
"non-secret fields stay useful: {d}"
);
}
#[test]
fn the_response_shape_cannot_carry_a_key() {
let dto = LlmCredentialDto {
provider: "openai".into(),
model: "gpt-4o-mini".into(),
fingerprint: "…4f2a".into(),
created_at_unix_secs: 7,
};
let json = serde_json::to_string(&dto).unwrap();
assert!(json.contains("…4f2a"));
assert!(!json.contains("api_key"), "{json}");
}
}