use std::{collections::BTreeMap, path::PathBuf, sync::Arc, time::Duration};
use crate::{
domain::{
errors::{AgentError, AgentResult, ErrorCode},
pi_rpc::{PiRpcCommand, PiRpcResponse},
protocol::AvailableModel,
},
infrastructure::pi_rpc_probe::PiRpcProbe,
operational::pi_rpc_manager::PiRpcManager,
};
use serde::Deserialize;
use serde_json::Value;
#[derive(Clone)]
pub(crate) struct PiModels {
command: Vec<String>,
cwd: PathBuf,
request_timeout: Duration,
manager: Arc<PiRpcManager>,
}
impl PiModels {
pub(crate) fn new(
command: Vec<String>,
cwd: PathBuf,
request_timeout: Duration,
manager: Arc<PiRpcManager>,
) -> Self {
Self {
command,
cwd,
request_timeout,
manager,
}
}
pub(crate) async fn list(&self) -> AgentResult<Vec<AvailableModel>> {
if self.command.is_empty() {
return Err(AgentError::new(
ErrorCode::CatalogNotConfigured,
"Pi RPC command is not configured for model discovery",
));
}
match self.manager.available_models().await {
Ok(Some(models)) => return project_available_models(&models),
Ok(None) => {}
Err(error) => return Err(catalog_transport_error("active Pi session", error)),
}
let response = PiRpcProbe::new(self.command.clone(), self.request_timeout)
.request(
&self.cwd,
PiRpcCommand::GetAvailableModels {
id: "__regy:models:probe".into(),
},
)
.await
.map_err(|error| catalog_transport_error("ephemeral Pi probe", error))?;
match response {
PiRpcResponse::GetAvailableModels { data, .. } => {
project_available_models(&data.models)
}
PiRpcResponse::Failure { error, .. } => Err(AgentError::new(
ErrorCode::CatalogUnavailable,
format!("Pi get_available_models failed: {error}"),
)),
response => Err(AgentError::new(
ErrorCode::CatalogResponseInvalid,
format!(
"Pi returned {} while waiting for get_available_models",
response.command()
),
)),
}
}
pub(crate) fn manager(&self) -> Arc<PiRpcManager> {
self.manager.clone()
}
}
fn catalog_transport_error(context: &str, error: AgentError) -> AgentError {
let code = if error.message().contains("invalid Pi RPC JSON record") {
ErrorCode::CatalogResponseInvalid
} else {
ErrorCode::CatalogUnavailable
};
AgentError::new(
code,
format!("{context} model discovery failed: {}", error.message()),
)
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct PiModelProjection {
provider: String,
id: String,
name: String,
reasoning: bool,
#[serde(default)]
thinking_level_map: Option<BTreeMap<String, Option<String>>>,
}
pub(crate) fn project_available_models(raw: &[Value]) -> AgentResult<Vec<AvailableModel>> {
if raw.is_empty() {
return Err(AgentError::new(
ErrorCode::CatalogAuthenticationFailed,
"Pi returned no authenticated models",
));
}
raw.iter()
.enumerate()
.map(|(index, value)| {
let model =
serde_json::from_value::<PiModelProjection>(value.clone()).map_err(|err| {
AgentError::new(
ErrorCode::CatalogResponseInvalid,
format!("invalid Pi model at index {index}: {err}"),
)
})?;
validate_non_empty(&model.provider, "provider", index)?;
validate_non_empty(&model.id, "id", index)?;
validate_non_empty(&model.name, "name", index)?;
Ok(AvailableModel {
provider: model.provider,
id: model.id,
name: model.name,
thinking_levels: thinking_levels(
model.reasoning,
model.thinking_level_map.as_ref(),
),
})
})
.collect()
}
fn validate_non_empty(value: &str, field: &str, index: usize) -> AgentResult<()> {
if value.trim().is_empty() {
return Err(AgentError::new(
ErrorCode::CatalogResponseInvalid,
format!("Pi model at index {index} has an empty {field}"),
));
}
Ok(())
}
fn thinking_levels(reasoning: bool, map: Option<&BTreeMap<String, Option<String>>>) -> Vec<String> {
if !reasoning {
return vec!["off".into()];
}
let mut levels = ["off", "minimal", "low", "medium", "high"]
.into_iter()
.filter(|level| {
map.and_then(|values| values.get(*level))
.is_none_or(Option::is_some)
})
.map(str::to_string)
.collect::<Vec<_>>();
for level in ["xhigh", "max"] {
if map
.and_then(|values| values.get(level))
.is_some_and(Option::is_some)
{
levels.push(level.into());
}
}
levels
}