use anyhow::Context;
use rho_providers::{
credentials::auth_has_stored_credentials,
model::{
decision_models::{cached_decision_models, lists_decision_models},
provider_models::cached_provider_models,
},
openai_decisions::{OpenAiDecisionsModel, OPENAI_API_BASE},
provider::{provider_descriptor, ProviderAuthKind, ProviderDescriptor},
system_one::{SystemOneLimits, SystemOneModel},
CredentialStore,
};
use rho_sdk::{decision::DecisionModel, SecretString};
use url::Url;
use crate::{
config::{Config, ModelKind, RhoInternalAgentModel},
credential_store::AppCredentialStore,
};
struct Host {
provider: &'static str,
protocol: Protocol,
untyped_kind: ModelKind,
}
#[derive(Clone, Copy)]
enum Protocol {
SystemOne(SystemOneLimits),
OpenAiDecisions,
}
const HOSTS: &[Host] = &[
Host {
provider: "ollama",
protocol: Protocol::SystemOne(SystemOneLimits::OLLAMA),
untyped_kind: ModelKind::Decision,
},
Host {
provider: "typesafe",
protocol: Protocol::SystemOne(SystemOneLimits::TYPESAFE),
untyped_kind: ModelKind::Decision,
},
Host {
provider: "openai",
protocol: Protocol::OpenAiDecisions,
untyped_kind: ModelKind::Text,
},
];
const HOST_NAMES: &str = "ollama, typesafe, or openai";
#[derive(Debug, PartialEq, Eq, thiserror::Error)]
pub(crate) enum ConfigError {
#[error(
"[internal_agents.{entry}] kind `decision` needs a model on provider {HOST_NAMES}, got {configured}"
)]
NotOnDecisionHost {
entry: &'static str,
configured: String,
},
#[error("[internal_agents.{entry}] kind `text` needs a chat model, got {configured}")]
NotAChatModel {
entry: &'static str,
configured: String,
},
#[error(
"[internal_agents.{entry}] must name a model on one of Rho's providers, got {configured}"
)]
NotRhoRuntime {
entry: &'static str,
configured: String,
},
#[error(
"[internal_agents.{entry}] could not start text model {configured}; check its credentials"
)]
TextModelUnavailable {
entry: &'static str,
configured: String,
},
#[error("[internal_agents.{entry}] auth `{auth}` is not supported; use {supported}")]
UnsupportedAuth {
entry: &'static str,
auth: String,
supported: String,
},
#[error(
"[internal_agents.{entry}] allow_threshold_percent must be {min} to {max}, got {percent}"
)]
AllowThresholdOutOfRange {
entry: &'static str,
percent: u8,
min: u8,
max: u8,
},
#[error("[internal_agents.{entry}]: {message}")]
MissingApiKey {
entry: &'static str,
message: &'static str,
},
}
pub(crate) enum EntryModel<'a> {
Decision(Box<dyn DecisionModel>),
Text(&'a RhoInternalAgentModel),
}
pub(crate) fn entry_kind(selection: &RhoInternalAgentModel) -> ModelKind {
selection.kind.unwrap_or_else(|| {
host(&selection.provider).map_or(ModelKind::Text, |host| host.untyped_kind)
})
}
fn host(provider: &str) -> Option<&'static Host> {
HOSTS.iter().find(|host| host.provider == provider)
}
fn is_decision_host(provider: &str) -> bool {
host(provider).is_some()
}
pub(crate) fn discovered_decision_model(provider: &str, model: &str) -> Option<String> {
cached_decision_models(provider)
.into_iter()
.find(|listed| names_listed_model(model, listed))
}
fn names_listed_model(model: &str, listed: &str) -> bool {
listed == model || listed.strip_suffix(":latest") == Some(model)
}
pub(crate) fn kind_mismatch(selection: &RhoInternalAgentModel) -> Option<String> {
let RhoInternalAgentModel {
provider, model, ..
} = selection;
let discovered = discovered_decision_model(provider, model).is_some();
match entry_kind(selection) {
ModelKind::Decision if !is_decision_host(provider) => {
Some(format!("{provider} serves no decision models"))
}
ModelKind::Decision => (!discovered && !cached_decision_models(provider).is_empty())
.then(|| format!("{model} is not a decision model on {provider}")),
ModelKind::Text => {
let chat = cached_provider_models(provider)
.iter()
.any(|listed| names_listed_model(model, &listed.model));
(discovered && !chat && lists_decision_models(provider))
.then(|| format!("{model} on {provider} is a decision model, not a text model"))
}
}
}
pub(crate) fn resolve<'a>(
config: &'a Config,
entry: &'static str,
) -> anyhow::Result<Option<EntryModel<'a>>> {
let Some(configured) = config.internal_agent_model(entry) else {
return Ok(None);
};
let Some(selection) = configured.rho() else {
return Err(ConfigError::NotRhoRuntime {
entry,
configured: configured.display_reference(),
}
.into());
};
let host = match entry_kind(selection) {
ModelKind::Text => {
let _scope = config.providers.thread_scope()?;
let descriptor = provider_descriptor(&selection.provider)
.filter(|descriptor| descriptor.serves_chat())
.ok_or_else(|| ConfigError::NotAChatModel {
entry,
configured: configured.display_reference(),
})?;
if !has_credentials(
descriptor,
&selection.auth,
&|name| std::env::var(name).ok(),
&AppCredentialStore,
) {
return Err(ConfigError::TextModelUnavailable {
entry,
configured: configured.display_reference(),
}
.into());
}
return Ok(Some(EntryModel::Text(selection)));
}
ModelKind::Decision => {
host(&selection.provider).ok_or_else(|| ConfigError::NotOnDecisionHost {
entry,
configured: configured.display_reference(),
})?
}
};
let api_key = api_key(
entry,
host.provider,
&selection.auth,
&|name| std::env::var(name).ok(),
&AppCredentialStore,
)?;
let api_base = config.resolved_provider_endpoint(host.provider);
let model: Box<dyn DecisionModel> = match host.protocol {
Protocol::SystemOne(limits) => {
let api_base =
api_base.with_context(|| format!("{} has no API base URL", host.provider))?;
Box::new(
SystemOneModel::new(&api_base, selection.model.clone(), api_key)?
.with_limits(limits),
)
}
Protocol::OpenAiDecisions => {
let api_base = match api_base {
Some(api_base) => api_base,
None => Url::parse(OPENAI_API_BASE)?,
};
Box::new(OpenAiDecisionsModel::new(
&api_base,
selection.model.clone(),
api_key,
)?)
}
};
Ok(Some(EntryModel::Decision(model)))
}
fn has_credentials(
descriptor: &ProviderDescriptor,
auth: &str,
env: &dyn Fn(&str) -> Option<String>,
store: &dyn CredentialStore,
) -> bool {
descriptor.auth_mode(auth).is_some_and(|mode| {
mode.auth_kind
.env_var()
.and_then(env)
.is_some_and(|value| !value.trim().is_empty())
|| auth_has_stored_credentials(store, mode.id).unwrap_or(false)
})
}
fn api_key(
entry: &'static str,
provider: &str,
auth: &str,
env: &dyn Fn(&str) -> Option<String>,
store: &dyn CredentialStore,
) -> anyhow::Result<Option<SecretString>> {
let descriptor = provider_descriptor(provider)
.with_context(|| format!("decision host {provider} is not a registered provider"))?;
let unsupported = || ConfigError::UnsupportedAuth {
entry,
auth: auth.into(),
supported: descriptor
.auth_modes()
.filter(|mode| {
matches!(
mode.auth_kind,
ProviderAuthKind::None | ProviderAuthKind::ApiKey { .. }
)
})
.map(|mode| format!("`{}`", mode.id))
.collect::<Vec<_>>()
.join(" or "),
};
let mode = descriptor.auth_mode(auth).ok_or_else(unsupported)?;
match mode.auth_kind {
ProviderAuthKind::None => Ok(None),
ProviderAuthKind::ApiKey {
env_var,
account,
missing_message,
..
} => {
if let Some(key) = env(env_var).filter(|key| !key.trim().is_empty()) {
return Ok(Some(SecretString::new(key)));
}
let key = store
.get_secret(account)?
.filter(|key| !key.trim().is_empty())
.ok_or(ConfigError::MissingApiKey {
entry,
message: missing_message,
})?;
Ok(Some(SecretString::new(key)))
}
ProviderAuthKind::CodexOAuth { .. }
| ProviderAuthKind::GithubCopilotDevice { .. }
| ProviderAuthKind::XaiOAuth { .. }
| ProviderAuthKind::BearerCredential { .. }
| ProviderAuthKind::KimiOAuth { .. }
| ProviderAuthKind::MetaOAuth { .. }
| ProviderAuthKind::OllamaDeviceKey { .. } => Err(unsupported().into()),
}
}
#[cfg(test)]
#[path = "resolve_tests.rs"]
mod tests;