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 pub grammar_dialects: Vec<sim_shape::GrammarDialect>,
36}
37
38impl ProviderConfig {
39 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}