1use 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#[derive(Clone, Debug, PartialEq, Eq)]
11pub struct ProviderConfig {
12 pub profile: ProviderProfile,
14 pub runner: Symbol,
16 pub codec: Symbol,
18 pub endpoint: String,
20 pub model: String,
22 pub api_key_env: Option<String>,
24 pub locality: Symbol,
26 pub timeout: Duration,
28 pub stream: bool,
30 pub tools: bool,
32 pub max_output_bytes: usize,
34}
35
36impl ProviderConfig {
37 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}