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 _ if provider_is_anthropic_compatible(cfg) => {
137 serde_json::to_value(rpi_ai::model::AnthropicMessagesCompat::default())
138 .expect("compat defaults serialize")
139 }
140 _ => continue,
141 };
142 for model in &cfg.models {
143 let Some(value) = &model.compat else { continue };
144 let context = format!("provider {provider:?}, model {:?}, compat", model.id);
145 if let Some(fields) = value.as_object() {
146 if provider_is_openai_completions(cfg) {
147 if let (Some(native), Some(alias)) = (
148 fields.get("requiresReasoningContent"),
149 fields.get("requiresReasoningContentOnAssistantMessages"),
150 ) {
151 if native != alias {
152 warnings.push(format!("{context}: conflicting reasoning-content flags; requiresReasoningContent takes precedence"));
153 }
154 }
155 }
156 for key in fields.keys() {
157 let alias = provider_is_openai_completions(cfg)
158 && key == "requiresReasoningContentOnAssistantMessages";
159 if defaults.get(key).is_none() && !alias {
160 warnings.push(format!("{context}: unknown field {key:?}; ignored"));
161 }
162 }
163 }
164 let parsed = if provider_is_openai_completions(cfg) {
165 serde_json::from_value::<rpi_ai::model::OpenaiCompletionsCompat>(
166 normalize_completions_compat(value.clone()),
167 )
168 .map(|_| ())
169 } else if provider_is_anthropic_compatible(cfg) {
170 serde_json::from_value::<rpi_ai::model::AnthropicMessagesCompat>(value.clone())
171 .map(|_| ())
172 } else {
173 serde_json::from_value::<rpi_ai::model::OpenaiResponsesCompat>(value.clone())
174 .map(|_| ())
175 };
176 if let Err(error) = parsed {
177 let error = error.to_string();
180 let expected = error
181 .rsplit_once(", expected ")
182 .map_or("a valid compat object", |(_, expected)| expected);
183 warnings.push(format!(
184 "{context}: invalid configuration; expected {expected}; compat ignored"
185 ));
186 }
187 }
188 }
189 warnings
190}
191
192fn normalize_completions_compat(mut value: serde_json::Value) -> serde_json::Value {
193 if let Some(fields) = value.as_object_mut() {
194 if let Some(alias) = fields.remove("requiresReasoningContentOnAssistantMessages") {
195 fields.entry("requiresReasoningContent").or_insert(alias);
196 }
197 }
198 value
199}
200
201pub fn provider_is_anthropic_compatible(cfg: &ProviderConfig) -> bool {
202 matches!(
203 cfg.api.as_deref(),
204 None | Some("") | Some("anthropic-messages")
205 )
206}
207
208pub fn provider_is_openai_completions(cfg: &ProviderConfig) -> bool {
210 matches!(cfg.api.as_deref(), Some("openai-completions"))
211}
212
213pub fn provider_is_openai_responses(cfg: &ProviderConfig) -> bool {
214 matches!(cfg.api.as_deref(), Some("openai-responses"))
215}
216
217pub fn provider_to_models(
218 provider_id: &str,
219 cfg: &ProviderConfig,
220 values: &dyn crate::ValueSource,
221) -> Option<Vec<Model>> {
222 let api = if provider_is_anthropic_compatible(cfg) {
223 Api::AnthropicMessages
224 } else if provider_is_openai_completions(cfg) {
225 Api::OpenaiCompletions
226 } else if provider_is_openai_responses(cfg) {
227 Api::OpenaiResponses
228 } else {
229 return None;
230 };
231 let provider_base = cfg.base_url.clone().unwrap_or_else(|| match api {
232 Api::OpenaiCompletions | Api::OpenaiResponses => "https://api.openai.com".to_string(),
233 _ => default_anthropic_base_url(),
234 });
235 let mut merged: Vec<Model> = Vec::with_capacity(cfg.models.len());
236 for def in &cfg.models {
237 let base_url = def
238 .base_url
239 .clone()
240 .unwrap_or_else(|| provider_base.clone());
241 let name = def.name.clone().unwrap_or_else(|| def.id.clone());
242 let mut m = Model::new(
243 def.id.clone(),
244 name,
245 api.clone(),
246 provider_id.to_string(),
247 base_url,
248 );
249 m.reasoning = def.reasoning.unwrap_or(false);
250 m.thinking_level_map = def.thinking_level_map.clone();
251 m.context_window = def.context_window.unwrap_or(0);
252 m.max_tokens = def.max_tokens.unwrap_or(0);
253 if let Some(cost) = &def.cost {
254 m.cost = cost.clone();
255 m.cost_configured = true;
256 }
257 m.input = parse_input_modalities(def.input.as_deref());
258 let mut headers: BTreeMap<String, String> = BTreeMap::new();
272 if let Some(h) = def.headers.clone() {
273 for (k, v) in crate::values::resolve_headers(&h, None, values) {
274 headers.insert(k, v);
275 }
276 }
277 if let Some(h) = cfg.headers.clone() {
278 for (k, v) in crate::values::resolve_headers(&h, None, values) {
279 headers.insert(k, v);
280 }
281 }
282 if let Some(value) = def.compat.clone() {
283 match api {
284 Api::OpenaiResponses => {
285 if let Ok(compat) = serde_json::from_value(value) {
286 m.compat = Some(StreamingProtocolCompat::OpenaiResponses(compat));
287 }
288 }
289 Api::OpenaiCompletions => {
290 if let Ok(compat) = serde_json::from_value(normalize_completions_compat(value))
291 {
292 m.compat = Some(StreamingProtocolCompat::OpenaiCompletions(compat));
293 }
294 }
295 Api::AnthropicMessages => {
296 if let Ok(compat) = serde_json::from_value(value) {
297 m.compat = Some(StreamingProtocolCompat::AnthropicMessages(compat));
298 }
299 }
300 _ => {}
301 }
302 }
303 if !headers.is_empty() {
304 m.headers = Some(headers);
305 }
306 merged.push(m);
307 }
308 Some(merged)
309}
310
311fn parse_input_modalities(input: Option<&[String]>) -> Vec<InputModality> {
314 match input {
315 None => vec![InputModality::Text],
316 Some([]) => vec![InputModality::Text],
317 Some(list) => list
318 .iter()
319 .filter_map(|s| match s.to_ascii_lowercase().as_str() {
320 "text" => Some(InputModality::Text),
321 "image" => Some(InputModality::Image),
322 _ => None,
323 })
324 .collect::<Vec<_>>()
325 .pipe(|v| {
326 if v.is_empty() {
327 vec![InputModality::Text]
328 } else {
329 v
330 }
331 }),
332 }
333}
334
335pub const ANTHROPIC_DEFAULT_BASE_URL: &str = "https://api.anthropic.com";
341
342pub fn default_anthropic_base_url() -> String {
345 ANTHROPIC_DEFAULT_BASE_URL.to_string()
346}
347
348trait Pipe: Sized {
349 fn pipe<R>(self, f: impl FnOnce(Self) -> R) -> R {
350 f(self)
351 }
352}
353impl<T> Pipe for T {}