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            _ => 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                // Serde's full error may echo a malformed value, including a
171                // credential accidentally pasted into a boolean field.
172                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
201/// Whether a configured provider uses the OpenAI Chat Completions protocol.
202pub 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        // Merge: model-level headers, then provider-level headers (provider wins
252        // on conflict — it's the more specific-to-this-endpoint declaration).
253        // Values are resolved via `resolve_headers` (`$ENV`/`!command` expansion,
254        // mirroring pi's `resolveHeadersOrThrow`) so a copied pi models.json
255        // referencing env vars / commands resolves the same way. Models.json
256        // providers have no credential env overlay (only auth.json keys do), so
257        // the expansion is env-only here.
258        // Credential-derived headers are NOT synthesized in the catalog —
259        // [`crate::RuntimeConfig::resolve`] applies it centrally so it can skip it
260        // when a higher-priority x-api-key source (`--api-key` / auth.json /
261        // `ANTHROPIC_API_KEY`) wins. Folding it here unconditionally would put a
262        // Bearer on the model even on the x-api-key path. See
263        // `models_json_bearer_token` + the fold loop in `resolve`.
264        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
296/// Parse `["text","image"]`-style modality strings into [`InputModality`]s;
297/// unknown values drop to text-only. `None` ⇒ text (the [`Model::new`] default).
298fn 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
320/// The first-party Anthropic endpoint — used as the fallback `base_url` when a
321/// models.json provider omits it. Kept here (not imported from `rpi_ai`) so the
322/// config layer never depends on the provider's private `models` module.
323/// Public so [`crate::RuntimeConfig::resolve`] can tell a gateway model (whose
324/// `base_url` differs from this) from a built-in Anthropic model.
325pub const ANTHROPIC_DEFAULT_BASE_URL: &str = "https://api.anthropic.com";
326
327/// Same value as [`ANTHROPIC_DEFAULT_BASE_URL`], as an owned `String` for the
328/// `unwrap_or_else` ergonomic used by [`provider_to_models`].
329pub 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 {}