use super::*;
use thiserror::Error;
#[derive(Debug, Clone, Error)]
pub enum ImageProviderError {
#[error("keystore is locked — unlock first")]
Locked,
#[error(
"account '{name}' is not configured or has no resolved provider — \
add it and set it on the session"
)]
AccountNotConfigured { name: String },
#[error("provider '{slug}' does not support image generation")]
NoImageBackend { slug: String },
#[error("provider '{slug}' does not support image generation")]
NoImageCapableAccount { slug: String },
}
impl DaemonState {
pub(super) fn handle_get_image_generation_provider(
&self,
session_id: u64,
account_name: Option<String>,
reply: crossbeam_channel::Sender<
Result<crate::providers::ImageProviderHandle, ImageProviderError>,
>,
) {
let result = self.resolve_image_generation_provider(session_id, account_name.as_deref());
match &result {
Ok(handle) => {
info!(
account = ?account_name,
slug = %handle.slug,
"resolved image-generation provider"
);
}
Err(err) => {
warn!(
account = ?account_name,
error = %err,
"image-generation provider resolution failed"
);
}
}
let _ = reply.send(result);
}
pub(super) fn resolve_image_generation_provider(
&self,
session_id: u64,
account_name: Option<&str>,
) -> Result<crate::providers::ImageProviderHandle, ImageProviderError> {
if self.locked {
return Err(ImageProviderError::Locked);
}
let fallback;
let registry = match self.session_registries.get(&session_id) {
Some(r) => r,
None => {
fallback = &self.daemon_registry;
fallback
}
};
let build = |name: &str| {
self.accounts
.get(name)
.and_then(|config| {
InferenceProvider::from_account_config(config, self.api_key_for(name), registry)
.ok()
})
.ok_or_else(|| ImageProviderError::AccountNotConfigured {
name: name.to_string(),
})
};
match account_name {
Some(name) => {
let provider = build(name)?;
let client =
provider
.image_client()
.ok_or_else(|| ImageProviderError::NoImageBackend {
slug: provider.provider_slug().to_string(),
})?;
Ok(crate::providers::ImageProviderHandle {
slug: provider.provider_slug().to_string(),
client,
})
}
None => {
let mut names: Vec<String> = self
.accounts
.all_configs()
.iter()
.map(|c| c.name.clone())
.collect();
names.sort();
let mut inspected_slug = String::new();
for name in names {
let Ok(provider) = build(name.as_str()) else {
continue;
};
if inspected_slug.is_empty() {
inspected_slug = provider.provider_slug().to_string();
}
if let Some(client) = provider.image_client() {
return Ok(crate::providers::ImageProviderHandle {
slug: provider.provider_slug().to_string(),
client,
});
}
}
Err(ImageProviderError::NoImageCapableAccount {
slug: inspected_slug,
})
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::daemon::tests::make_daemon_state;
fn send_get_image_provider(
state: &mut DaemonState,
session_id: u64,
account_name: Option<String>,
) -> Result<crate::providers::ImageProviderHandle, ImageProviderError> {
let (reply, rx) = crossbeam_channel::unbounded();
state.handle_command(DaemonCommand::GetImageGenerationProvider {
session_id,
account_name,
reply,
});
rx.recv().unwrap()
}
fn seed_account(state: &mut DaemonState, name: &str, provider_slug: &str) {
state.locked = false;
state
.accounts
.add(AccountConfig::simple(name, provider_slug))
.unwrap();
state.credentials.insert(
name.to_string(),
ServiceCredential::ApiKey {
key: "test-key".to_string(),
},
);
}
#[test]
fn get_image_provider_locked_keystore_errors_with_unlock_guidance() {
let (mut state, _rx) = make_daemon_state();
let err = send_get_image_provider(&mut state, 7, None).unwrap_err();
assert!(
matches!(err, ImageProviderError::Locked)
&& err.to_string().contains("keystore is locked"),
"unlock guidance expected, got: {err}"
);
}
#[test]
fn get_image_provider_named_account_without_image_backend_names_slug() {
let (mut state, _rx) = make_daemon_state();
seed_account(&mut state, "claude", "anthropic");
let err = send_get_image_provider(&mut state, 7, Some("claude".into())).unwrap_err();
assert!(
matches!(&err, ImageProviderError::NoImageBackend { slug } if slug == "anthropic"),
"error must name the provider slug, got: {err}"
);
}
#[test]
fn get_image_provider_unknown_named_account_names_the_account() {
let (mut state, _rx) = make_daemon_state();
seed_account(&mut state, "openai", "openai");
let err = send_get_image_provider(&mut state, 7, Some("oepnai".into())).unwrap_err();
assert!(
matches!(&err, ImageProviderError::AccountNotConfigured { name } if name == "oepnai")
&& err
.to_string()
.contains("account 'oepnai' is not configured"),
"error must name the missing account, got: {err}"
);
}
#[test]
fn get_image_provider_happy_path_returns_handle_with_working_client() {
let (mut state, _rx) = make_daemon_state();
seed_account(&mut state, "openai", "openai");
let handle = send_get_image_provider(&mut state, 7, Some("openai".into())).unwrap();
assert_eq!(handle.slug, "openai");
assert_eq!(handle.client.provider_slug(), "openai");
}
#[test]
fn get_image_provider_default_selection_picks_image_capable_account() {
let (mut state, _rx) = make_daemon_state();
seed_account(&mut state, "claude", "anthropic");
seed_account(&mut state, "openai", "openai");
let handle = send_get_image_provider(&mut state, 7, None).unwrap();
assert_eq!(handle.slug, "openai");
assert_eq!(handle.client.provider_slug(), "openai");
}
#[test]
fn get_image_provider_revoked_after_lock() {
let (mut state, _rx) = make_daemon_state();
seed_account(&mut state, "openai", "openai");
assert!(send_get_image_provider(&mut state, 7, Some("openai".into())).is_ok());
state.credentials.clear();
state.locked = true;
let err = send_get_image_provider(&mut state, 7, Some("openai".into())).unwrap_err();
assert!(
matches!(err, ImageProviderError::Locked),
"post-lock resolution must fail with unlock guidance, got: {err}"
);
}
}