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