use rpi_ai::{Api, InputModality, Model, ModelCost, StreamingProtocolCompat, ThinkingLevelMap};
use std::collections::BTreeMap;
use std::path::PathBuf;
pub const DEFAULT_PROVIDER_ID: &str = "anthropic";
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("could not resolve home directory (set {env} to override)")]
NoHomeDir { env: &'static str },
#[error("config dir override {env}={val:?} is not an absolute path")]
RelativeOverride { env: &'static str, val: String },
#[error("could not read {path}: {source}")]
Read {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("could not write {path}: {source}")]
Write {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("invalid JSON in {path}: {source}")]
Json {
path: PathBuf,
#[source]
source: serde_json::Error,
},
}
#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)]
#[serde(rename_all = "snake_case", tag = "type")]
pub enum Credential {
ApiKey {
key: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
env: Option<BTreeMap<String, String>>,
},
Oauth {
access: String,
#[serde(default)]
refresh: String,
#[serde(default)]
expires: i64,
},
}
pub type AuthStore = BTreeMap<String, Credential>;
#[derive(serde::Serialize, serde::Deserialize, Default, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct ModelsConfig {
#[serde(default, skip_serializing_if = "indexmap::IndexMap::is_empty")]
pub providers: indexmap::IndexMap<String, ProviderConfig>,
}
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct ProviderConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub api_key: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub api: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub headers: Option<BTreeMap<String, String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auth_header: Option<bool>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub models: Vec<ModelDefinition>,
}
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct ModelDefinition {
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub thinking_level_map: Option<ThinkingLevelMap>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context_window: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_tokens: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cost: Option<ModelCost>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub headers: Option<BTreeMap<String, String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub compat: Option<serde_json::Value>,
}
pub fn model_compat_warnings(config: &ModelsConfig) -> Vec<String> {
let mut warnings = Vec::new();
for (provider, cfg) in &config.providers {
let defaults = match cfg.api.as_deref() {
Some("openai-completions") => {
serde_json::to_value(rpi_ai::model::OpenaiCompletionsCompat::default())
.expect("compat defaults serialize")
}
Some("openai-responses") => {
serde_json::to_value(rpi_ai::model::OpenaiResponsesCompat::default())
.expect("compat defaults serialize")
}
_ => continue,
};
for model in &cfg.models {
let Some(value) = &model.compat else { continue };
let context = format!("provider {provider:?}, model {:?}, compat", model.id);
if let Some(fields) = value.as_object() {
if provider_is_openai_completions(cfg) {
if let (Some(native), Some(alias)) = (
fields.get("requiresReasoningContent"),
fields.get("requiresReasoningContentOnAssistantMessages"),
) {
if native != alias {
warnings.push(format!("{context}: conflicting reasoning-content flags; requiresReasoningContent takes precedence"));
}
}
}
for key in fields.keys() {
let alias = provider_is_openai_completions(cfg)
&& key == "requiresReasoningContentOnAssistantMessages";
if defaults.get(key).is_none() && !alias {
warnings.push(format!("{context}: unknown field {key:?}; ignored"));
}
}
}
let parsed = if provider_is_openai_completions(cfg) {
serde_json::from_value::<rpi_ai::model::OpenaiCompletionsCompat>(
normalize_completions_compat(value.clone()),
)
.map(|_| ())
} else {
serde_json::from_value::<rpi_ai::model::OpenaiResponsesCompat>(value.clone())
.map(|_| ())
};
if let Err(error) = parsed {
let error = error.to_string();
let expected = error
.rsplit_once(", expected ")
.map_or("a valid compat object", |(_, expected)| expected);
warnings.push(format!(
"{context}: invalid configuration; expected {expected}; compat ignored"
));
}
}
}
warnings
}
fn normalize_completions_compat(mut value: serde_json::Value) -> serde_json::Value {
if let Some(fields) = value.as_object_mut() {
if let Some(alias) = fields.remove("requiresReasoningContentOnAssistantMessages") {
fields.entry("requiresReasoningContent").or_insert(alias);
}
}
value
}
pub fn provider_is_anthropic_compatible(cfg: &ProviderConfig) -> bool {
matches!(
cfg.api.as_deref(),
None | Some("") | Some("anthropic-messages")
)
}
pub fn provider_is_openai_completions(cfg: &ProviderConfig) -> bool {
matches!(cfg.api.as_deref(), Some("openai-completions"))
}
pub fn provider_is_openai_responses(cfg: &ProviderConfig) -> bool {
matches!(cfg.api.as_deref(), Some("openai-responses"))
}
pub fn provider_to_models(
provider_id: &str,
cfg: &ProviderConfig,
values: &dyn crate::ValueSource,
) -> Option<Vec<Model>> {
let api = if provider_is_anthropic_compatible(cfg) {
Api::AnthropicMessages
} else if provider_is_openai_completions(cfg) {
Api::OpenaiCompletions
} else if provider_is_openai_responses(cfg) {
Api::OpenaiResponses
} else {
return None;
};
let provider_base = cfg.base_url.clone().unwrap_or_else(|| match api {
Api::OpenaiCompletions | Api::OpenaiResponses => "https://api.openai.com".to_string(),
_ => default_anthropic_base_url(),
});
let mut merged: Vec<Model> = Vec::with_capacity(cfg.models.len());
for def in &cfg.models {
let base_url = def
.base_url
.clone()
.unwrap_or_else(|| provider_base.clone());
let name = def.name.clone().unwrap_or_else(|| def.id.clone());
let mut m = Model::new(
def.id.clone(),
name,
api.clone(),
provider_id.to_string(),
base_url,
);
m.reasoning = def.reasoning.unwrap_or(false);
m.thinking_level_map = def.thinking_level_map.clone();
m.context_window = def.context_window.unwrap_or(0);
m.max_tokens = def.max_tokens.unwrap_or(0);
if let Some(cost) = &def.cost {
m.cost = cost.clone();
m.cost_configured = true;
}
m.input = parse_input_modalities(def.input.as_deref());
let mut headers: BTreeMap<String, String> = BTreeMap::new();
if let Some(h) = def.headers.clone() {
for (k, v) in crate::values::resolve_headers(&h, None, values) {
headers.insert(k, v);
}
}
if let Some(h) = cfg.headers.clone() {
for (k, v) in crate::values::resolve_headers(&h, None, values) {
headers.insert(k, v);
}
}
if matches!(api, Api::OpenaiCompletions | Api::OpenaiResponses) {
if let Some(value) = def.compat.clone() {
if matches!(api, Api::OpenaiResponses) {
if let Ok(compat) = serde_json::from_value(value) {
m.compat = Some(StreamingProtocolCompat::OpenaiResponses(compat));
}
} else if let Ok(compat) =
serde_json::from_value(normalize_completions_compat(value))
{
m.compat = Some(StreamingProtocolCompat::OpenaiCompletions(compat));
}
}
}
if !headers.is_empty() {
m.headers = Some(headers);
}
merged.push(m);
}
Some(merged)
}
fn parse_input_modalities(input: Option<&[String]>) -> Vec<InputModality> {
match input {
None => vec![InputModality::Text],
Some([]) => vec![InputModality::Text],
Some(list) => list
.iter()
.filter_map(|s| match s.to_ascii_lowercase().as_str() {
"text" => Some(InputModality::Text),
"image" => Some(InputModality::Image),
_ => None,
})
.collect::<Vec<_>>()
.pipe(|v| {
if v.is_empty() {
vec![InputModality::Text]
} else {
v
}
}),
}
}
pub const ANTHROPIC_DEFAULT_BASE_URL: &str = "https://api.anthropic.com";
pub fn default_anthropic_base_url() -> String {
ANTHROPIC_DEFAULT_BASE_URL.to_string()
}
trait Pipe: Sized {
fn pipe<R>(self, f: impl FnOnce(Self) -> R) -> R {
f(self)
}
}
impl<T> Pipe for T {}