Skip to main content

sim_lib_agent_runner_http/
config.rs

1//! Provider option-map parsing for HTTP model runners.
2
3use crate::provider::ProviderProfile;
4use sim_kernel::{Cx, Error, Expr, NumberLiteral, Result, Symbol, Value};
5use sim_lib_net_core::{UrlParts, parse_url};
6use std::{collections::HashMap, time::Duration};
7
8/// Concrete HTTP runner configuration derived from a provider profile and an
9/// option map.
10#[derive(Clone, Debug, PartialEq, Eq)]
11pub struct ProviderConfig {
12    /// Source provider profile.
13    pub profile: ProviderProfile,
14    /// Runner symbol after applying a `name` override.
15    pub runner: Symbol,
16    /// Codec symbol after applying a `codec` override.
17    pub codec: Symbol,
18    /// Base endpoint used by the HTTP runner.
19    pub endpoint: String,
20    /// Model name to send to the provider codec.
21    pub model: String,
22    /// Environment variable carrying the provider secret, when any.
23    pub api_key_env: Option<String>,
24    /// Runner locality after endpoint posture has been classified.
25    pub locality: Symbol,
26    /// HTTP timeout.
27    pub timeout: Duration,
28    /// Whether requests should use streaming.
29    pub stream: bool,
30    /// Whether request encoding should include tools.
31    pub tools: bool,
32    /// Maximum decoded provider response size.
33    pub max_output_bytes: usize,
34}
35
36impl ProviderConfig {
37    /// Builds provider config from the same option map used by the existing
38    /// agent runner constructors.
39    pub fn from_options(
40        profile: ProviderProfile,
41        cx: &mut Cx,
42        options: &HashMap<String, Value>,
43    ) -> Result<Self> {
44        let runner = symbol_option(cx, options, "name", profile.runner_symbol.clone())?;
45        let codec = symbol_option(cx, options, "codec", profile.codec.clone())?;
46        let endpoint = endpoint_option(cx, options, &profile)?;
47        let endpoint_parts = endpoint_parts(&endpoint)?;
48        let model = stringish_option(cx, options, "model", &profile.default_model)?;
49        let api_key_env = api_key_env_option(cx, options, &profile)?;
50        let timeout = duration_option(cx, options, "timeout", profile.default_timeout)?;
51        let stream = bool_option(cx, options, "stream", profile.default_stream)?;
52        let tools = bool_option(cx, options, "tools", profile.default_tools)?;
53        let max_output_bytes = usize_option(
54            cx,
55            options,
56            "max-output-bytes",
57            profile.default_max_output_bytes,
58        )?;
59        let locality = locality_for_endpoint(&profile, &endpoint_parts);
60        Ok(Self {
61            profile,
62            runner,
63            codec,
64            endpoint,
65            model,
66            api_key_env,
67            locality,
68            timeout,
69            stream,
70            tools,
71            max_output_bytes,
72        })
73    }
74}
75
76fn endpoint_option(
77    cx: &mut Cx,
78    options: &HashMap<String, Value>,
79    profile: &ProviderProfile,
80) -> Result<String> {
81    let endpoint = stringish_option(cx, options, "endpoint", &profile.default_endpoint)?;
82    if endpoint.is_empty() {
83        return Err(Error::Eval(format!(
84            "{} provider config requires endpoint",
85            profile.provider
86        )));
87    }
88    Ok(endpoint)
89}
90
91fn endpoint_parts(endpoint: &str) -> Result<UrlParts> {
92    parse_url(endpoint).map_err(|err| Error::Eval(format!("invalid provider endpoint: {err}")))
93}
94
95fn locality_for_endpoint(profile: &ProviderProfile, endpoint: &UrlParts) -> Symbol {
96    if profile.default_locality == Symbol::new("local") && !host_is_loopback(&endpoint.host) {
97        Symbol::new("network")
98    } else {
99        profile.default_locality.clone()
100    }
101}
102
103fn host_is_loopback(host: &str) -> bool {
104    let host = host
105        .strip_prefix('[')
106        .and_then(|value| value.strip_suffix(']'))
107        .unwrap_or(host);
108    matches!(host, "127.0.0.1" | "localhost" | "::1")
109}
110
111fn api_key_env_option(
112    cx: &mut Cx,
113    options: &HashMap<String, Value>,
114    profile: &ProviderProfile,
115) -> Result<Option<String>> {
116    let Some(value) = options.get("api-key-env") else {
117        return Ok(profile.auth.default_env().map(str::to_owned));
118    };
119    match value.object().as_expr(cx)? {
120        Expr::Nil => Ok(None),
121        Expr::String(text) if text.is_empty() => Ok(None),
122        Expr::String(text) => Ok(Some(text)),
123        Expr::Symbol(symbol) => Ok(Some(symbol.to_string())),
124        _ => Err(Error::Eval(
125            "provider config :api-key-env expects nil, string, or symbol".to_owned(),
126        )),
127    }
128}
129
130fn stringish_option(
131    cx: &mut Cx,
132    options: &HashMap<String, Value>,
133    key: &str,
134    default: &str,
135) -> Result<String> {
136    match options.get(key) {
137        Some(value) => match value.object().as_expr(cx)? {
138            Expr::String(text) => Ok(text),
139            Expr::Symbol(symbol) => Ok(symbol.to_string()),
140            _ => Err(Error::Eval(format!(
141                "provider config :{key} expects a string or symbol"
142            ))),
143        },
144        None => Ok(default.to_owned()),
145    }
146}
147
148fn symbol_option(
149    cx: &mut Cx,
150    options: &HashMap<String, Value>,
151    key: &str,
152    default: Symbol,
153) -> Result<Symbol> {
154    match options.get(key) {
155        Some(value) => match value.object().as_expr(cx)? {
156            Expr::Symbol(symbol) => Ok(symbol),
157            _ => Err(Error::Eval(format!(
158                "provider config :{key} expects a symbol"
159            ))),
160        },
161        None => Ok(default),
162    }
163}
164
165fn bool_option(
166    cx: &mut Cx,
167    options: &HashMap<String, Value>,
168    key: &str,
169    default: bool,
170) -> Result<bool> {
171    match options.get(key) {
172        Some(value) => match value.object().as_expr(cx)? {
173            Expr::Bool(flag) => Ok(flag),
174            _ => Err(Error::Eval(format!(
175                "provider config :{key} expects a boolean"
176            ))),
177        },
178        None => Ok(default),
179    }
180}
181
182fn duration_option(
183    cx: &mut Cx,
184    options: &HashMap<String, Value>,
185    key: &str,
186    default: Duration,
187) -> Result<Duration> {
188    match options.get(key) {
189        Some(value) => parse_duration(&value.object().as_expr(cx)?),
190        None => Ok(default),
191    }
192}
193
194fn parse_duration(expr: &Expr) -> Result<Duration> {
195    match expr {
196        Expr::String(text) => parse_duration_text(text),
197        Expr::Number(NumberLiteral { canonical, .. }) => canonical
198            .parse::<u64>()
199            .map(Duration::from_millis)
200            .map_err(|_| Error::Eval("provider timeout expects integer milliseconds".to_owned())),
201        _ => Err(Error::Eval(
202            "provider timeout expects a duration string or integer milliseconds".to_owned(),
203        )),
204    }
205}
206
207fn parse_duration_text(text: &str) -> Result<Duration> {
208    let (number, unit) = if let Some(number) = text.strip_suffix("ms") {
209        (number, "ms")
210    } else if let Some(number) = text.strip_suffix('s') {
211        (number, "s")
212    } else if let Some(number) = text.strip_suffix('m') {
213        (number, "m")
214    } else if let Some(number) = text.strip_suffix('h') {
215        (number, "h")
216    } else {
217        return Err(Error::Eval(format!(
218            "provider timeout {text} must end with ms, s, m, or h"
219        )));
220    };
221    let value = number.parse::<u64>().map_err(|_| {
222        Error::Eval(format!(
223            "provider timeout {text} has an invalid numeric prefix"
224        ))
225    })?;
226    Ok(match unit {
227        "ms" => Duration::from_millis(value),
228        "s" => Duration::from_secs(value),
229        "m" => Duration::from_secs(value.saturating_mul(60)),
230        "h" => Duration::from_secs(value.saturating_mul(60 * 60)),
231        _ => unreachable!(),
232    })
233}
234
235fn usize_option(
236    cx: &mut Cx,
237    options: &HashMap<String, Value>,
238    key: &str,
239    default: usize,
240) -> Result<usize> {
241    match options.get(key) {
242        Some(value) => match value.object().as_expr(cx)? {
243            Expr::String(text) => parse_usize(&text, key),
244            Expr::Number(NumberLiteral { canonical, .. }) => parse_usize(&canonical, key),
245            _ => Err(Error::Eval(format!(
246                "provider config :{key} expects an integer"
247            ))),
248        },
249        None => Ok(default),
250    }
251}
252
253fn parse_usize(text: &str, key: &str) -> Result<usize> {
254    text.parse::<usize>()
255        .map_err(|_| Error::Eval(format!("provider config :{key} expects an integer")))
256}
257
258#[cfg(test)]
259mod tests {
260    use super::ProviderConfig;
261    use crate::provider_profiles;
262    use sim_kernel::{Cx, DefaultFactory, EagerPolicy, Expr, NumberLiteral, Symbol, Value};
263    use std::{collections::HashMap, sync::Arc, time::Duration};
264
265    #[test]
266    fn parses_current_openai_compatible_option_map() {
267        let mut cx = test_cx();
268        let mut options = HashMap::new();
269        insert(
270            &mut cx,
271            &mut options,
272            "name",
273            Expr::Symbol(Symbol::new("hosted")),
274        );
275        insert(
276            &mut cx,
277            &mut options,
278            "model",
279            Expr::String("model-a".to_owned()),
280        );
281        insert(
282            &mut cx,
283            &mut options,
284            "endpoint",
285            Expr::String("http://127.0.0.1:8080/v1".to_owned()),
286        );
287        insert(
288            &mut cx,
289            &mut options,
290            "api-key-env",
291            Expr::String("TEST_KEY".to_owned()),
292        );
293        insert(
294            &mut cx,
295            &mut options,
296            "codec",
297            Expr::Symbol(Symbol::qualified("codec", "openai")),
298        );
299        insert(
300            &mut cx,
301            &mut options,
302            "timeout",
303            Expr::String("750ms".to_owned()),
304        );
305        insert(&mut cx, &mut options, "stream", Expr::Bool(true));
306        insert(&mut cx, &mut options, "tools", Expr::Bool(false));
307        insert(&mut cx, &mut options, "max-output-bytes", number("4096"));
308
309        let config =
310            ProviderConfig::from_options(provider_profiles::openai_compatible(), &mut cx, &options)
311                .unwrap();
312
313        assert_eq!(config.runner, Symbol::new("hosted"));
314        assert_eq!(config.codec, Symbol::qualified("codec", "openai"));
315        assert_eq!(config.endpoint, "http://127.0.0.1:8080/v1");
316        assert_eq!(config.model, "model-a");
317        assert_eq!(config.api_key_env, Some("TEST_KEY".to_owned()));
318        assert_eq!(config.locality, Symbol::new("network"));
319        assert_eq!(config.timeout, Duration::from_millis(750));
320        assert!(config.stream);
321        assert!(!config.tools);
322        assert_eq!(config.max_output_bytes, 4096);
323    }
324
325    #[test]
326    fn generic_profile_requires_endpoint() {
327        let mut cx = test_cx();
328        let error = ProviderConfig::from_options(
329            provider_profiles::openai_compatible(),
330            &mut cx,
331            &HashMap::new(),
332        )
333        .unwrap_err();
334        assert!(error.to_string().contains("requires endpoint"));
335    }
336
337    #[test]
338    fn ollama_defaults_preserve_existing_constructor_behavior() {
339        let mut cx = test_cx();
340        let config =
341            ProviderConfig::from_options(provider_profiles::ollama(), &mut cx, &HashMap::new())
342                .unwrap();
343
344        assert_eq!(config.runner, Symbol::qualified("runner", "ollama"));
345        assert_eq!(config.codec, Symbol::qualified("codec", "ollama"));
346        assert_eq!(config.endpoint, "http://127.0.0.1:11434");
347        assert_eq!(config.model, "qwen3.5:4b");
348        assert_eq!(config.api_key_env, None);
349        assert_eq!(config.locality, Symbol::new("local"));
350        assert_eq!(config.timeout, Duration::from_secs(120));
351        assert!(config.stream);
352        assert!(!config.tools);
353        assert_eq!(config.max_output_bytes, 1024 * 1024);
354    }
355
356    #[test]
357    fn local_profile_becomes_network_when_endpoint_is_not_loopback() {
358        let mut cx = test_cx();
359        let mut options = HashMap::new();
360        insert(
361            &mut cx,
362            &mut options,
363            "endpoint",
364            Expr::String("https://models.example/v1".to_owned()),
365        );
366
367        let config =
368            ProviderConfig::from_options(provider_profiles::lm_studio(), &mut cx, &options)
369                .unwrap();
370
371        assert_eq!(config.locality, Symbol::new("network"));
372        assert_eq!(config.api_key_env, None);
373    }
374
375    #[test]
376    fn lm_studio_auth_env_is_optional_but_accepted() {
377        let mut cx = test_cx();
378        let mut options = HashMap::new();
379        insert(
380            &mut cx,
381            &mut options,
382            "api-key-env",
383            Expr::String("LM_STUDIO_API_KEY".to_owned()),
384        );
385
386        let config =
387            ProviderConfig::from_options(provider_profiles::lm_studio(), &mut cx, &options)
388                .unwrap();
389
390        assert_eq!(config.api_key_env, Some("LM_STUDIO_API_KEY".to_owned()));
391        assert_eq!(config.locality, Symbol::new("local"));
392    }
393
394    #[test]
395    fn nil_api_key_env_disables_profile_default_auth_env() {
396        let mut cx = test_cx();
397        let mut options = HashMap::new();
398        insert(&mut cx, &mut options, "api-key-env", Expr::Nil);
399
400        let config =
401            ProviderConfig::from_options(provider_profiles::openai(), &mut cx, &options).unwrap();
402
403        assert_eq!(config.api_key_env, None);
404    }
405
406    fn test_cx() -> Cx {
407        Cx::new(Arc::new(EagerPolicy), Arc::new(DefaultFactory))
408    }
409
410    fn insert(cx: &mut Cx, options: &mut HashMap<String, Value>, key: &str, expr: Expr) {
411        options.insert(key.to_owned(), cx.factory().expr(expr).unwrap());
412    }
413
414    fn number(canonical: &str) -> Expr {
415        Expr::Number(NumberLiteral {
416            domain: Symbol::qualified("numbers", "f64"),
417            canonical: canonical.to_owned(),
418        })
419    }
420}