1use rpi_ai::{Api, InputModality, Model, ModelCost, StreamingProtocolCompat, ThinkingLevelMap};
3use std::collections::BTreeMap;
4use std::path::PathBuf;
5pub const DEFAULT_PROVIDER_ID: &str = "anthropic";
6#[derive(Debug, thiserror::Error)]
7pub enum ConfigError {
8 #[error("could not resolve home directory (set {env} to override)")]
9 NoHomeDir { env: &'static str },
10 #[error("config dir override {env}={val:?} is not an absolute path")]
11 RelativeOverride { env: &'static str, val: String },
12 #[error("could not read {path}: {source}")]
13 Read {
14 path: PathBuf,
15 #[source]
16 source: std::io::Error,
17 },
18 #[error("could not write {path}: {source}")]
19 Write {
20 path: PathBuf,
21 #[source]
22 source: std::io::Error,
23 },
24 #[error("invalid JSON in {path}: {source}")]
25 Json {
26 path: PathBuf,
27 #[source]
28 source: serde_json::Error,
29 },
30}
31
32#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)]
36#[serde(rename_all = "snake_case", tag = "type")]
37pub enum Credential {
38 ApiKey {
41 key: Option<String>,
42 #[serde(default, skip_serializing_if = "Option::is_none")]
43 env: Option<BTreeMap<String, String>>,
44 },
45 Oauth {
47 access: String,
48 #[serde(default)]
49 refresh: String,
50 #[serde(default)]
52 expires: i64,
53 },
54}
55
56pub type AuthStore = BTreeMap<String, Credential>;
59
60#[derive(serde::Serialize, serde::Deserialize, Default, Debug, Clone)]
63#[serde(rename_all = "camelCase")]
64pub struct ModelsConfig {
65 #[serde(default, skip_serializing_if = "indexmap::IndexMap::is_empty")]
66 pub providers: indexmap::IndexMap<String, ProviderConfig>,
69}
70
71#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
74#[serde(rename_all = "camelCase")]
75pub struct ProviderConfig {
76 #[serde(default, skip_serializing_if = "Option::is_none")]
77 pub name: Option<String>,
78 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub base_url: Option<String>,
80 #[serde(default, skip_serializing_if = "Option::is_none")]
81 pub api_key: Option<String>,
82 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub api: Option<String>,
84 #[serde(default, skip_serializing_if = "Option::is_none")]
85 pub headers: Option<BTreeMap<String, String>>,
86 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub auth_header: Option<bool>,
90 #[serde(default, skip_serializing_if = "Vec::is_empty")]
91 pub models: Vec<ModelDefinition>,
92}
93
94#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
96#[serde(rename_all = "camelCase")]
97pub struct ModelDefinition {
98 pub id: String,
99 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub name: Option<String>,
101 #[serde(default, skip_serializing_if = "Option::is_none")]
102 pub base_url: Option<String>,
103 #[serde(default, skip_serializing_if = "Option::is_none")]
104 pub reasoning: Option<bool>,
105 #[serde(default, skip_serializing_if = "Option::is_none")]
106 pub thinking_level_map: Option<ThinkingLevelMap>,
107 #[serde(default, skip_serializing_if = "Option::is_none")]
108 pub context_window: Option<u64>,
109 #[serde(default, skip_serializing_if = "Option::is_none")]
110 pub max_tokens: Option<u64>,
111 #[serde(default, skip_serializing_if = "Option::is_none")]
113 pub cost: Option<ModelCost>,
114 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub input: Option<Vec<String>>,
118 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub headers: Option<BTreeMap<String, String>>,
120 #[serde(default, skip_serializing_if = "Option::is_none")]
121 pub compat: Option<serde_json::Value>,
122}
123
124pub fn model_compat_warnings(config: &ModelsConfig) -> Vec<String> {
125 let mut warnings = Vec::new();
126 for (provider, cfg) in &config.providers {
127 let defaults = match cfg.api.as_deref() {
128 Some("openai-completions") => {
129 serde_json::to_value(rpi_ai::model::OpenaiCompletionsCompat::default())
130 .expect("compat defaults serialize")
131 }
132 Some("openai-responses") => {
133 serde_json::to_value(rpi_ai::model::OpenaiResponsesCompat::default())
134 .expect("compat defaults serialize")
135 }
136 _ => continue,
137 };
138 for model in &cfg.models {
139 let Some(value) = &model.compat else { continue };
140 let context = format!("provider {provider:?}, model {:?}, compat", model.id);
141 if let Some(fields) = value.as_object() {
142 if provider_is_openai_completions(cfg) {
143 if let (Some(native), Some(alias)) = (
144 fields.get("requiresReasoningContent"),
145 fields.get("requiresReasoningContentOnAssistantMessages"),
146 ) {
147 if native != alias {
148 warnings.push(format!("{context}: conflicting reasoning-content flags; requiresReasoningContent takes precedence"));
149 }
150 }
151 }
152 for key in fields.keys() {
153 let alias = provider_is_openai_completions(cfg)
154 && key == "requiresReasoningContentOnAssistantMessages";
155 if defaults.get(key).is_none() && !alias {
156 warnings.push(format!("{context}: unknown field {key:?}; ignored"));
157 }
158 }
159 }
160 let parsed = if provider_is_openai_completions(cfg) {
161 serde_json::from_value::<rpi_ai::model::OpenaiCompletionsCompat>(
162 normalize_completions_compat(value.clone()),
163 )
164 .map(|_| ())
165 } else {
166 serde_json::from_value::<rpi_ai::model::OpenaiResponsesCompat>(value.clone())
167 .map(|_| ())
168 };
169 if let Err(error) = parsed {
170 let error = error.to_string();
173 let expected = error
174 .rsplit_once(", expected ")
175 .map_or("a valid compat object", |(_, expected)| expected);
176 warnings.push(format!(
177 "{context}: invalid configuration; expected {expected}; compat ignored"
178 ));
179 }
180 }
181 }
182 warnings
183}
184
185fn normalize_completions_compat(mut value: serde_json::Value) -> serde_json::Value {
186 if let Some(fields) = value.as_object_mut() {
187 if let Some(alias) = fields.remove("requiresReasoningContentOnAssistantMessages") {
188 fields.entry("requiresReasoningContent").or_insert(alias);
189 }
190 }
191 value
192}
193
194pub fn provider_is_anthropic_compatible(cfg: &ProviderConfig) -> bool {
195 matches!(
196 cfg.api.as_deref(),
197 None | Some("") | Some("anthropic-messages")
198 )
199}
200
201pub fn provider_is_openai_completions(cfg: &ProviderConfig) -> bool {
203 matches!(cfg.api.as_deref(), Some("openai-completions"))
204}
205
206pub fn provider_is_openai_responses(cfg: &ProviderConfig) -> bool {
207 matches!(cfg.api.as_deref(), Some("openai-responses"))
208}
209
210pub fn provider_to_models(
211 provider_id: &str,
212 cfg: &ProviderConfig,
213 values: &dyn crate::ValueSource,
214) -> Option<Vec<Model>> {
215 let api = if provider_is_anthropic_compatible(cfg) {
216 Api::AnthropicMessages
217 } else if provider_is_openai_completions(cfg) {
218 Api::OpenaiCompletions
219 } else if provider_is_openai_responses(cfg) {
220 Api::OpenaiResponses
221 } else {
222 return None;
223 };
224 let provider_base = cfg.base_url.clone().unwrap_or_else(|| match api {
225 Api::OpenaiCompletions | Api::OpenaiResponses => "https://api.openai.com".to_string(),
226 _ => default_anthropic_base_url(),
227 });
228 let mut merged: Vec<Model> = Vec::with_capacity(cfg.models.len());
229 for def in &cfg.models {
230 let base_url = def
231 .base_url
232 .clone()
233 .unwrap_or_else(|| provider_base.clone());
234 let name = def.name.clone().unwrap_or_else(|| def.id.clone());
235 let mut m = Model::new(
236 def.id.clone(),
237 name,
238 api.clone(),
239 provider_id.to_string(),
240 base_url,
241 );
242 m.reasoning = def.reasoning.unwrap_or(false);
243 m.thinking_level_map = def.thinking_level_map.clone();
244 m.context_window = def.context_window.unwrap_or(0);
245 m.max_tokens = def.max_tokens.unwrap_or(0);
246 if let Some(cost) = &def.cost {
247 m.cost = cost.clone();
248 m.cost_configured = true;
249 }
250 m.input = parse_input_modalities(def.input.as_deref());
251 let mut headers: BTreeMap<String, String> = BTreeMap::new();
265 if let Some(h) = def.headers.clone() {
266 for (k, v) in crate::values::resolve_headers(&h, None, values) {
267 headers.insert(k, v);
268 }
269 }
270 if let Some(h) = cfg.headers.clone() {
271 for (k, v) in crate::values::resolve_headers(&h, None, values) {
272 headers.insert(k, v);
273 }
274 }
275 if matches!(api, Api::OpenaiCompletions | Api::OpenaiResponses) {
276 if let Some(value) = def.compat.clone() {
277 if matches!(api, Api::OpenaiResponses) {
278 if let Ok(compat) = serde_json::from_value(value) {
279 m.compat = Some(StreamingProtocolCompat::OpenaiResponses(compat));
280 }
281 } else if let Ok(compat) =
282 serde_json::from_value(normalize_completions_compat(value))
283 {
284 m.compat = Some(StreamingProtocolCompat::OpenaiCompletions(compat));
285 }
286 }
287 }
288 if !headers.is_empty() {
289 m.headers = Some(headers);
290 }
291 merged.push(m);
292 }
293 Some(merged)
294}
295
296fn parse_input_modalities(input: Option<&[String]>) -> Vec<InputModality> {
299 match input {
300 None => vec![InputModality::Text],
301 Some([]) => vec![InputModality::Text],
302 Some(list) => list
303 .iter()
304 .filter_map(|s| match s.to_ascii_lowercase().as_str() {
305 "text" => Some(InputModality::Text),
306 "image" => Some(InputModality::Image),
307 _ => None,
308 })
309 .collect::<Vec<_>>()
310 .pipe(|v| {
311 if v.is_empty() {
312 vec![InputModality::Text]
313 } else {
314 v
315 }
316 }),
317 }
318}
319
320pub const ANTHROPIC_DEFAULT_BASE_URL: &str = "https://api.anthropic.com";
326
327pub fn default_anthropic_base_url() -> String {
330 ANTHROPIC_DEFAULT_BASE_URL.to_string()
331}
332
333trait Pipe: Sized {
334 fn pipe<R>(self, f: impl FnOnce(Self) -> R) -> R {
335 f(self)
336 }
337}
338impl<T> Pipe for T {}