use std::fmt;
use anyhow::Result;
use crate::{CompletionRequest, CompletionResponse, ModelAdapter};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum CloudVendor {
Claude,
Gpt,
Gemini,
}
impl CloudVendor {
pub fn api_key_env(self) -> &'static str {
match self {
CloudVendor::Claude => "ANTHROPIC_API_KEY",
CloudVendor::Gpt => "OPENAI_API_KEY",
CloudVendor::Gemini => "GEMINI_API_KEY",
}
}
pub fn display_name(self) -> &'static str {
match self {
CloudVendor::Claude => "Claude",
CloudVendor::Gpt => "GPT",
CloudVendor::Gemini => "Gemini",
}
}
pub fn default_model(self) -> &'static str {
match self {
CloudVendor::Claude => "claude-sonnet-latest",
CloudVendor::Gpt => "gpt-latest",
CloudVendor::Gemini => "gemini-latest",
}
}
}
pub trait CloudAdapter: ModelAdapter {
fn vendor(&self) -> CloudVendor;
fn api_key(&self) -> Option<String> {
std::env::var(self.vendor().api_key_env()).ok().filter(|key| !key.is_empty())
}
fn is_available(&self) -> bool {
self.api_key().is_some()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CloudUnavailable {
NoApiKey {
vendor: CloudVendor,
env_var: &'static str,
},
NotYetImplemented {
vendor: CloudVendor,
},
}
impl fmt::Display for CloudUnavailable {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
CloudUnavailable::NoApiKey { vendor, env_var } => write!(
f,
"cloud {} unavailable: no API key — set {} to enable it",
vendor.display_name(),
env_var,
),
CloudUnavailable::NotYetImplemented { vendor } => write!(
f,
"cloud {} has a key but its HTTP layer isn't built yet \
(scaffold only — real network is a follow-up)",
vendor.display_name(),
),
}
}
}
impl std::error::Error for CloudUnavailable {}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CloudStub {
vendor: CloudVendor,
}
impl CloudStub {
pub fn new(vendor: CloudVendor) -> Self {
Self { vendor }
}
pub fn claude() -> Self {
Self::new(CloudVendor::Claude)
}
pub fn gpt() -> Self {
Self::new(CloudVendor::Gpt)
}
pub fn gemini() -> Self {
Self::new(CloudVendor::Gemini)
}
fn send(&self, _request: &CompletionRequest) -> Result<CompletionResponse, CloudUnavailable> {
match self.api_key() {
None => Err(CloudUnavailable::NoApiKey {
vendor: self.vendor,
env_var: self.vendor.api_key_env(),
}),
Some(_key) => Err(CloudUnavailable::NotYetImplemented { vendor: self.vendor }),
}
}
}
impl ModelAdapter for CloudStub {
fn name(&self) -> &str {
match self.vendor {
CloudVendor::Claude => "cloud-claude",
CloudVendor::Gpt => "cloud-gpt",
CloudVendor::Gemini => "cloud-gemini",
}
}
fn complete(&self, request: &CompletionRequest) -> Result<CompletionResponse> {
Ok(self.send(request)?)
}
}
impl CloudAdapter for CloudStub {
fn vendor(&self) -> CloudVendor {
self.vendor
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Message, StreamChunk};
use std::sync::Mutex;
static ENV_LOCK: Mutex<()> = Mutex::new(());
#[test]
fn vendor_env_vars_are_the_conventional_names() {
assert_eq!(CloudVendor::Claude.api_key_env(), "ANTHROPIC_API_KEY");
assert_eq!(CloudVendor::Gpt.api_key_env(), "OPENAI_API_KEY");
assert_eq!(CloudVendor::Gemini.api_key_env(), "GEMINI_API_KEY");
}
#[test]
fn no_key_means_unavailable_and_complete_errs_with_nokey() {
let _guard = ENV_LOCK.lock().unwrap();
let stub = CloudStub::claude();
unsafe {
std::env::remove_var(CloudVendor::Claude.api_key_env());
}
assert!(!stub.is_available());
let err = stub.complete(&CompletionRequest::new([Message::user("hi")])).unwrap_err();
let cloud = err.downcast_ref::<CloudUnavailable>().expect("a CloudUnavailable");
assert!(matches!(cloud, CloudUnavailable::NoApiKey { vendor: CloudVendor::Claude, .. }));
}
#[test]
fn no_key_stream_is_a_single_error_chunk() {
let _guard = ENV_LOCK.lock().unwrap();
let stub = CloudStub::gpt();
unsafe {
std::env::remove_var(CloudVendor::Gpt.api_key_env());
}
let chunks: Vec<StreamChunk> =
stub.stream(&CompletionRequest::new([Message::user("hi")])).iter().collect();
assert_eq!(chunks.len(), 1);
assert!(matches!(&chunks[0], StreamChunk::Error(msg) if msg.contains("no API key")));
}
#[test]
fn key_present_is_available_but_not_yet_implemented() {
let _guard = ENV_LOCK.lock().unwrap();
let stub = CloudStub::gemini();
unsafe {
std::env::set_var(CloudVendor::Gemini.api_key_env(), "sk-test-not-real");
}
assert!(stub.is_available());
let err = stub.complete(&CompletionRequest::new([Message::user("hi")])).unwrap_err();
let cloud = err.downcast_ref::<CloudUnavailable>().expect("a CloudUnavailable");
assert!(matches!(cloud, CloudUnavailable::NotYetImplemented { vendor: CloudVendor::Gemini }));
unsafe {
std::env::remove_var(CloudVendor::Gemini.api_key_env());
}
}
}