Skip to main content

rpi_runtime/
config.rs

1//! Host-supplied configuration and provider-scoped credentials. No file access.
2use 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/// A stored credential. Mirrors the TS `Credential` union
33/// (`packages/ai/src/auth/types.ts`). OAuth credentials are provider-scoped
34/// and must never be logged or exposed to the agent transcript.
35#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)]
36#[serde(rename_all = "snake_case", tag = "type")]
37pub enum Credential {
38    /// An API key, optionally sourced from an env var map. v1 stores only the
39    /// literal `key` (the `env` field is kept for upstream-shape compatibility).
40    ApiKey {
41        key: Option<String>,
42        #[serde(default, skip_serializing_if = "Option::is_none")]
43        env: Option<BTreeMap<String, String>>,
44    },
45    /// OAuth tokens (access + refresh + expiry).
46    Oauth {
47        access: String,
48        #[serde(default)]
49        refresh: String,
50        /// Unix epoch seconds. `0` means no known expiry.
51        #[serde(default)]
52        expires: i64,
53    },
54}
55
56/// The auth store: `providerId -> Credential`. Mirrors upstream
57/// `Record<providerId, Credential>`.
58pub type AuthStore = BTreeMap<String, Credential>;
59
60/// The `models.json` document. Mirrors TS `{ providers: Record<id, ProviderConfig> }`
61/// (`core/model-config.ts` `ModelsConfigSchema`).
62#[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    // Provider entries are walked in declaration order, so declaration order
67    // participates in the final available-model fallback.
68    pub providers: indexmap::IndexMap<String, ProviderConfig>,
69}
70
71/// A provider entry in `models.json`. The fields mirror the TS `ProviderConfig`
72/// for protocol, endpoint, headers, credentials and model metadata.
73#[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    /// `true` ⇒ wrap `api_key` as `Authorization: Bearer <key>` (mirrors
87    /// upstream `provider-composer.ts` `authHeader`).
88    #[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/// One model under a provider. `id` is required (mirrors TS `ModelDefinition`).
95#[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    /// USD per million tokens, including optional long-context pricing tiers.
112    #[serde(default, skip_serializing_if = "Option::is_none")]
113    pub cost: Option<ModelCost>,
114    /// Free-form modality strings ("text"/"image"); unknown values fall back
115    /// to text-only.
116    #[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                // Serde's full error may echo a malformed value, including a
178                // credential accidentally pasted into a boolean field.
179                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
208/// Whether a configured provider uses the OpenAI Chat Completions protocol.
209pub 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        // Merge: model-level headers, then provider-level headers (provider wins
259        // on conflict — it's the more specific-to-this-endpoint declaration).
260        // Values are resolved via `resolve_headers` (`$ENV`/`!command` expansion,
261        // mirroring pi's `resolveHeadersOrThrow`) so a copied pi models.json
262        // referencing env vars / commands resolves the same way. Models.json
263        // providers have no credential env overlay (only auth.json keys do), so
264        // the expansion is env-only here.
265        // Credential-derived headers are NOT synthesized in the catalog —
266        // [`crate::RuntimeConfig::resolve`] applies it centrally so it can skip it
267        // when a higher-priority x-api-key source (`--api-key` / auth.json /
268        // `ANTHROPIC_API_KEY`) wins. Folding it here unconditionally would put a
269        // Bearer on the model even on the x-api-key path. See
270        // `models_json_bearer_token` + the fold loop in `resolve`.
271        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
311/// Parse `["text","image"]`-style modality strings into [`InputModality`]s;
312/// unknown values drop to text-only. `None` ⇒ text (the [`Model::new`] default).
313fn 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
335/// The first-party Anthropic endpoint — used as the fallback `base_url` when a
336/// models.json provider omits it. Kept here (not imported from `rpi_ai`) so the
337/// config layer never depends on the provider's private `models` module.
338/// Public so [`crate::RuntimeConfig::resolve`] can tell a gateway model (whose
339/// `base_url` differs from this) from a built-in Anthropic model.
340pub const ANTHROPIC_DEFAULT_BASE_URL: &str = "https://api.anthropic.com";
341
342/// Same value as [`ANTHROPIC_DEFAULT_BASE_URL`], as an owned `String` for the
343/// `unwrap_or_else` ergonomic used by [`provider_to_models`].
344pub 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 {}