use std::io;
use std::sync::Arc;
use choreo_ai_protocols::openai::{OpenAiClient, ServiceConfig};
use choreo_ai_protocols::{
AnthropicClient, AnthropicConfig, ChatTurnRequest, ChatTurnResult, GoogleClient, GoogleConfig,
ImageGenerationClient, OpenAiImageClient, ProviderClient, ProviderProtocol, StreamEvent,
ZaiImageClient, lookup_provider,
};
use choreo_proto::InferenceError;
#[derive(Clone, Debug)]
pub struct InferenceProvider {
client: Arc<dyn ProviderClient>,
slug: String,
image_client: Option<Arc<dyn ImageGenerationClient>>,
}
#[derive(Clone, Debug)]
pub struct ImageProviderHandle {
pub slug: String,
pub client: Arc<dyn ImageGenerationClient>,
}
fn daemon_user_agent() -> String {
format!("choreographr/{}", env!("CARGO_PKG_VERSION"))
}
impl InferenceProvider {
pub fn from_openai(client: OpenAiClient) -> Self {
Self {
client: Arc::new(client),
slug: "openai".to_string(),
image_client: None,
}
}
pub fn from_anthropic(client: AnthropicClient) -> Self {
Self {
client: Arc::new(client),
slug: "anthropic".to_string(),
image_client: None,
}
}
pub fn from_google(client: GoogleClient) -> Self {
Self {
client: Arc::new(client),
slug: "google".to_string(),
image_client: None,
}
}
pub fn from_account_config(
config: &crate::accounts::AccountConfig,
api_key: Option<String>,
registry: &choreo_ai_protocols::SocketRegistry,
) -> Result<Self, String> {
let entry = lookup_provider(&config.provider)
.ok_or_else(|| format!("unknown provider: {}", config.provider))?;
match entry.protocol {
ProviderProtocol::OpenAi { max_tokens_field } => {
let mut svc_config = ServiceConfig {
base_url: entry.base_url.to_string(),
chat_completions_max_tokens_field: max_tokens_field,
provider_slug: entry.slug.clone(),
user_agent: Some(daemon_user_agent()),
..Default::default()
};
config.apply_overrides(&mut svc_config);
let key = api_key
.ok_or_else(|| format!("no API key for '{}' provider", config.provider))?;
let client = OpenAiClient::new(svc_config.clone(), key.clone(), registry)
.map_err(|e| format!("failed to create OpenAI client: {e}"))?;
let image_client: Arc<dyn ImageGenerationClient> =
if choreo_ai_protocols::images::is_zhipu_image_provider_slug(&entry.slug) {
Arc::new(ZaiImageClient::new(svc_config, key, registry))
} else {
Arc::new(OpenAiImageClient::new(svc_config, key, registry))
};
Ok(Self {
client: Arc::new(client),
slug: entry.slug,
image_client: Some(image_client),
})
}
ProviderProtocol::AnthropicMessages => {
let key = api_key
.ok_or_else(|| format!("no API key for '{}' provider", config.provider))?;
let mut anthro_cfg = AnthropicConfig::default();
if config.base_url.is_none() {
anthro_cfg.base_url = entry.base_url.to_string();
}
anthro_cfg.provider_slug = entry.slug.clone();
anthro_cfg.user_agent = Some(daemon_user_agent());
let overrides = config.provider_overrides();
anthro_cfg.apply_overrides(&overrides);
let client = AnthropicClient::new(anthro_cfg, key, registry)
.map_err(|e| format!("failed to create Anthropic client: {e}"))?;
Ok(Self {
client: Arc::new(client),
slug: entry.slug,
image_client: None,
})
}
ProviderProtocol::GoogleGenerativeAi => {
let key = api_key
.ok_or_else(|| format!("no API key for '{}' provider", config.provider))?;
let mut google_cfg = GoogleConfig::default();
if config.base_url.is_none() {
google_cfg.base_url = entry.base_url.to_string();
}
google_cfg.user_agent = Some(daemon_user_agent());
let overrides = config.provider_overrides();
google_cfg.apply_overrides(&overrides);
let client = GoogleClient::new(google_cfg, key, registry)
.map_err(|e| format!("failed to create Google client: {e}"))?;
Ok(Self {
client: Arc::new(client),
slug: entry.slug,
image_client: None,
})
}
_ => Err(format!(
"unsupported provider protocol for '{}'",
config.provider
)),
}
}
pub fn chat_completion_turn(
&self,
params: ChatTurnRequest<'_>,
) -> Result<ChatTurnResult, InferenceError> {
let start = std::time::Instant::now();
let model = params.model;
let result = self.client.chat_completion_turn(params);
self.record_api_metrics(model, start, &result);
result
}
pub fn chat_completion_turn_streaming(
&self,
params: ChatTurnRequest<'_>,
on_event: &mut dyn FnMut(StreamEvent) -> io::Result<()>,
) -> Result<ChatTurnResult, InferenceError> {
let start = std::time::Instant::now();
let model = params.model;
let result = self.client.chat_completion_turn_streaming(params, on_event);
self.record_api_metrics(model, start, &result);
result
}
fn record_api_metrics<T>(
&self,
model: &str,
start: std::time::Instant,
result: &Result<T, InferenceError>,
) {
let elapsed = start.elapsed().as_secs_f64();
crate::metrics::record_api_call(model, self.slug.as_str(), elapsed);
if let Err(e) = result {
crate::metrics::record_api_error(model, e.metric_label());
}
}
pub fn image_client(&self) -> Option<Arc<dyn ImageGenerationClient>> {
self.image_client.clone()
}
pub fn provider_slug(&self) -> &str {
self.slug.as_str()
}
pub fn resolve_context_window(&self, model: &str) -> Option<u32> {
self.client
.context_window_for_model(model)
.or_else(|| choreo_ai_protocols::lookup_context_window(&self.slug, model))
}
pub fn list_models(&self) -> Result<Vec<String>, InferenceError> {
self.client.list_models()
}
pub fn supports_programmatic_tool_calling(&self, model: &str) -> bool {
self.client.supports_programmatic_tool_calling(model)
}
}
#[cfg(test)]
#[serial_test::serial(catalog)]
mod tests {
use super::*;
use crate::accounts::AccountConfig;
use choreo_ai_protocols::openai::ServiceConfig;
#[test]
fn from_openai_constructs_provider() {
let config = ServiceConfig::default();
let client = OpenAiClient::new(
config,
"test-key".into(),
&choreo_ai_protocols::SocketRegistry::new(),
)
.unwrap();
let _provider = InferenceProvider::from_openai(client);
}
#[test]
fn from_anthropic_constructs_provider() {
let config = AnthropicConfig::default();
let client = AnthropicClient::new(
config,
"test-key".into(),
&choreo_ai_protocols::SocketRegistry::new(),
)
.unwrap();
let _provider = InferenceProvider::from_anthropic(client);
}
#[test]
fn from_google_constructs_provider() {
let config = GoogleConfig::default();
let client = GoogleClient::new(
config,
"test-key".into(),
&choreo_ai_protocols::SocketRegistry::new(),
)
.unwrap();
let _provider = InferenceProvider::from_google(client);
}
#[test]
fn from_account_config_unknown_provider_errors() {
let cfg = AccountConfig::simple("unknown", "nonexistent");
let err = InferenceProvider::from_account_config(
&cfg,
Some("key".into()),
&choreo_ai_protocols::SocketRegistry::new(),
)
.unwrap_err();
assert!(err.contains("unknown provider"), "{err}");
}
#[test]
fn from_account_config_anthropic_requires_key() {
let cfg = AccountConfig::simple("claude", "anthropic");
let err = InferenceProvider::from_account_config(
&cfg,
None,
&choreo_ai_protocols::SocketRegistry::new(),
)
.unwrap_err();
assert!(err.contains("no API key"), "{err}");
}
#[test]
fn from_account_config_openai_missing_key_errors() {
let cfg = AccountConfig::simple("openai", "openai");
let err = InferenceProvider::from_account_config(
&cfg,
None,
&choreo_ai_protocols::SocketRegistry::new(),
)
.unwrap_err();
assert!(err.contains("no API key"), "{err}");
}
#[test]
fn from_account_config_openai_succeeds() {
let cfg = AccountConfig::simple("openai", "openai");
let result = InferenceProvider::from_account_config(
&cfg,
Some("key".into()),
&choreo_ai_protocols::SocketRegistry::new(),
);
assert!(result.is_ok(), "{:?}", result.err());
}
#[test]
fn from_account_config_anthropic_succeeds() {
let cfg = AccountConfig::simple("claude", "anthropic");
let result = InferenceProvider::from_account_config(
&cfg,
Some("key".into()),
&choreo_ai_protocols::SocketRegistry::new(),
);
assert!(result.is_ok(), "{:?}", result.err());
}
#[test]
fn from_account_config_google_succeeds() {
let cfg = AccountConfig::simple("gemini", "google");
let result = InferenceProvider::from_account_config(
&cfg,
Some("key".into()),
&choreo_ai_protocols::SocketRegistry::new(),
);
assert!(result.is_ok(), "{:?}", result.err());
}
#[test]
fn from_account_config_google_missing_key_errors() {
let cfg = AccountConfig::simple("gemini", "google");
let err = InferenceProvider::from_account_config(
&cfg,
None,
&choreo_ai_protocols::SocketRegistry::new(),
)
.unwrap_err();
assert!(err.contains("no API key"), "{err}");
}
#[test]
fn from_account_config_zai_routes_to_dedicated_image_client() {
let cfg = AccountConfig::simple("zai", "zai");
let provider = InferenceProvider::from_account_config(
&cfg,
Some("key".into()),
&choreo_ai_protocols::SocketRegistry::new(),
)
.expect("zai account constructs");
let image_client = provider
.image_client()
.expect("OpenAI protocol gets an image client");
let debug = format!("{:?}", image_client);
assert!(debug.starts_with("ZaiImageClient"), "{debug}");
let cfg = AccountConfig::simple("zhipu", "zhipuai");
let provider = InferenceProvider::from_account_config(
&cfg,
Some("key".into()),
&choreo_ai_protocols::SocketRegistry::new(),
)
.expect("zhipuai account constructs");
let debug = format!(
"{:?}",
provider.image_client().expect("image client present")
);
assert!(debug.starts_with("ZaiImageClient"), "{debug}");
}
#[test]
fn from_account_config_openai_keeps_default_image_client() {
let cfg = AccountConfig::simple("openai", "openai");
let provider = InferenceProvider::from_account_config(
&cfg,
Some("key".into()),
&choreo_ai_protocols::SocketRegistry::new(),
)
.expect("openai account constructs");
let debug = format!(
"{:?}",
provider.image_client().expect("image client present")
);
assert!(debug.starts_with("OpenAiImageClient"), "{debug}");
}
#[test]
fn anthropic_provider_list_models_returns_known() {
let config = AnthropicConfig::default();
let client = AnthropicClient::new(
config,
"test-key".into(),
&choreo_ai_protocols::SocketRegistry::new(),
)
.unwrap();
let provider = InferenceProvider::from_anthropic(client);
let models = provider.list_models().unwrap();
assert!(!models.is_empty());
assert!(models.contains(&"claude-sonnet-4-20250514".to_string()));
}
#[test]
fn resolve_context_window_uses_client_then_catalog() {
let mut cfg = ServiceConfig::default();
cfg.context_window_config.per_model = [("gpt-4.1-nano".into(), 1_048_576)].into();
cfg.context_window_config.context_window = Some(128_000);
let client = OpenAiClient::new(
cfg,
"test-key".into(),
&choreo_ai_protocols::SocketRegistry::new(),
)
.unwrap();
let provider = InferenceProvider::from_openai(client);
assert_eq!(
provider.resolve_context_window("gpt-4.1-nano"),
Some(1_048_576)
);
assert_eq!(
provider.resolve_context_window("unknown-model"),
Some(128_000)
);
}
#[test]
fn resolve_context_window_falls_back_to_catalog() {
let config = AnthropicConfig::default();
let client = AnthropicClient::new(
config,
"test-key".into(),
&choreo_ai_protocols::SocketRegistry::new(),
)
.unwrap();
let provider = InferenceProvider::from_anthropic(client);
assert_eq!(
provider.resolve_context_window("claude-sonnet-4-6"),
Some(1_000_000)
);
assert_eq!(provider.resolve_context_window("completely-unknown"), None);
}
}
#[cfg(test)]
pub(crate) mod test_util {
use super::*;
#[derive(Debug)]
pub(crate) struct StubProviderClient;
#[allow(clippy::panic_in_result_fn)]
impl ProviderClient for StubProviderClient {
fn provider_slug(&self) -> &str {
"test-stub"
}
fn chat_completion_turn(
&self,
_params: ChatTurnRequest<'_>,
) -> Result<ChatTurnResult, InferenceError> {
panic!("StubProviderClient is not intended for real use");
}
fn chat_completion_turn_streaming(
&self,
_params: ChatTurnRequest<'_>,
_on_event: &mut dyn FnMut(StreamEvent) -> io::Result<()>,
) -> Result<ChatTurnResult, InferenceError> {
panic!("StubProviderClient is not intended for real use");
}
fn list_models(&self) -> Result<Vec<String>, InferenceError> {
panic!("StubProviderClient is not intended for real use");
}
}
pub(crate) fn make_test_provider() -> InferenceProvider {
InferenceProvider {
client: Arc::new(StubProviderClient),
slug: "test-stub".to_string(),
image_client: None,
}
}
#[derive(Debug)]
pub(crate) struct FailingProviderClient;
impl ProviderClient for FailingProviderClient {
fn provider_slug(&self) -> &str {
"test-failing"
}
fn chat_completion_turn(
&self,
_params: ChatTurnRequest<'_>,
) -> Result<ChatTurnResult, InferenceError> {
Err(InferenceError::ClientError {
status: 402,
detail: "Insufficient Balance".to_string(),
})
}
fn chat_completion_turn_streaming(
&self,
_params: ChatTurnRequest<'_>,
_on_event: &mut dyn FnMut(StreamEvent) -> io::Result<()>,
) -> Result<ChatTurnResult, InferenceError> {
Err(InferenceError::ClientError {
status: 402,
detail: "Insufficient Balance".to_string(),
})
}
fn list_models(&self) -> Result<Vec<String>, InferenceError> {
Err(InferenceError::ClientError {
status: 402,
detail: "Insufficient Balance".to_string(),
})
}
}
pub(crate) fn make_failing_provider() -> InferenceProvider {
InferenceProvider {
client: Arc::new(FailingProviderClient),
slug: "test-failing".to_string(),
image_client: None,
}
}
}