Skip to main content

camel_component_llm/
config.rs

1use std::collections::HashMap;
2use std::fmt;
3
4use camel_component_api::CamelError;
5use camel_component_api::NetworkRetryPolicy;
6
7use crate::cost::PricingTable;
8
9fn default_max_prompt_bytes() -> usize {
10    32768
11}
12
13fn default_mock_response() -> String {
14    "echo".into()
15}
16
17fn default_mock_model() -> String {
18    "mock-model".into()
19}
20
21/// Global LLM configuration, typically deserialized from TOML.
22#[derive(Clone, Debug, serde::Deserialize)]
23pub struct LlmGlobalConfig {
24    /// Default provider name to use when none is specified.
25    #[serde(default)]
26    pub default_provider: Option<String>,
27
28    /// Default timeout in seconds for LLM operations. `None` means no timeout.
29    #[serde(default)]
30    pub timeout_secs: Option<u64>,
31
32    /// Maximum prompt size in bytes before truncation or rejection.
33    #[serde(default = "default_max_prompt_bytes")]
34    pub max_prompt_bytes: usize,
35
36    /// Map of provider name to provider configuration.
37    #[serde(default)]
38    pub providers: HashMap<String, LlmProviderConfig>,
39}
40
41impl Default for LlmGlobalConfig {
42    fn default() -> Self {
43        Self {
44            default_provider: None,
45            timeout_secs: None,
46            max_prompt_bytes: default_max_prompt_bytes(),
47            providers: HashMap::new(),
48        }
49    }
50}
51
52/// Configuration for a single LLM provider, discriminated by `type` field.
53#[derive(Clone, serde::Deserialize)]
54#[serde(tag = "type", rename_all = "lowercase")]
55pub enum LlmProviderConfig {
56    /// OpenAI-compatible provider (also Azure OpenAI).
57    Openai(OpenaiProviderConfig),
58    /// Ollama (local) provider.
59    Ollama(OllamaProviderConfig),
60    /// Mock provider for testing.
61    Mock(MockProviderConfig),
62}
63
64impl LlmProviderConfig {
65    /// Extract the `max_concurrency` from whichever provider variant,
66    /// returning `None` if the variant doesn't support it (e.g. Mock).
67    pub fn max_concurrency(&self) -> Option<usize> {
68        match self {
69            LlmProviderConfig::Openai(c) => c.max_concurrency,
70            LlmProviderConfig::Ollama(c) => c.max_concurrency,
71            LlmProviderConfig::Mock(_) => None,
72        }
73    }
74
75    /// Extract the `timeout_secs` from whichever provider variant,
76    /// returning `None` if the variant doesn't support it (e.g. Mock).
77    pub fn timeout_secs(&self) -> Option<u64> {
78        match self {
79            LlmProviderConfig::Openai(c) => c.timeout_secs,
80            LlmProviderConfig::Ollama(c) => c.timeout_secs,
81            LlmProviderConfig::Mock(_) => None,
82        }
83    }
84
85    /// Extract the `network_retry` from whichever provider variant,
86    /// returning `None` if the variant doesn't support it (e.g. Mock).
87    pub fn network_retry(&self) -> Option<NetworkRetryPolicy> {
88        match self {
89            LlmProviderConfig::Openai(c) => c.network_retry.clone(),
90            LlmProviderConfig::Ollama(c) => c.network_retry.clone(),
91            LlmProviderConfig::Mock(_) => None,
92        }
93    }
94
95    /// Extract the `pricing` from whichever provider variant,
96    /// returning `None` if the variant doesn't support it (e.g. Mock).
97    pub fn pricing(&self) -> Option<PricingTable> {
98        match self {
99            LlmProviderConfig::Openai(c) => c.pricing.clone(),
100            LlmProviderConfig::Ollama(c) => c.pricing.clone(),
101            LlmProviderConfig::Mock(_) => None,
102        }
103    }
104
105    /// Extract the `cache_ttl_secs` from whichever provider variant,
106    /// returning `None` if the variant doesn't support it (e.g. Mock).
107    pub fn cache_ttl_secs(&self) -> Option<u64> {
108        match self {
109            LlmProviderConfig::Openai(c) => c.cache_ttl_secs,
110            LlmProviderConfig::Ollama(c) => c.cache_ttl_secs,
111            LlmProviderConfig::Mock(_) => None,
112        }
113    }
114
115    /// Extract the `cache_max_entries` from whichever provider variant,
116    /// returning `None` if the variant doesn't support it (e.g. Mock).
117    pub fn cache_max_entries(&self) -> Option<usize> {
118        match self {
119            LlmProviderConfig::Openai(c) => c.cache_max_entries,
120            LlmProviderConfig::Ollama(c) => c.cache_max_entries,
121            LlmProviderConfig::Mock(_) => None,
122        }
123    }
124}
125
126impl fmt::Debug for LlmProviderConfig {
127    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
128        match self {
129            LlmProviderConfig::Openai(c) => f
130                .debug_struct("Openai")
131                .field("api_key", &"[REDACTED]")
132                .field("base_url", &c.base_url)
133                .field("default_model", &c.default_model)
134                .field("timeout_secs", &c.timeout_secs)
135                .field("max_concurrency", &c.max_concurrency)
136                .field("network_retry", &c.network_retry)
137                .field("pricing", &c.pricing)
138                .field("cache_ttl_secs", &c.cache_ttl_secs)
139                .field("cache_max_entries", &c.cache_max_entries)
140                .finish(),
141            LlmProviderConfig::Ollama(c) => f
142                .debug_struct("Ollama")
143                .field("base_url", &c.base_url)
144                .field("default_model", &c.default_model)
145                .field("timeout_secs", &c.timeout_secs)
146                .field("max_concurrency", &c.max_concurrency)
147                .field("network_retry", &c.network_retry)
148                .field("pricing", &c.pricing)
149                .field("cache_ttl_secs", &c.cache_ttl_secs)
150                .field("cache_max_entries", &c.cache_max_entries)
151                .finish(),
152            LlmProviderConfig::Mock(c) => f
153                .debug_struct("Mock")
154                .field("response", &c.response)
155                .field("default_model", &c.default_model)
156                .field("error_message", &c.error_message)
157                .finish(),
158        }
159    }
160}
161
162/// Configuration for an OpenAI-compatible provider.
163#[derive(Clone, serde::Deserialize)]
164pub struct OpenaiProviderConfig {
165    /// API key for authentication.
166    pub api_key: String,
167
168    /// Base URL override (defaults to provider's standard endpoint).
169    #[serde(default)]
170    pub base_url: Option<String>,
171
172    /// Default model to use (e.g., "gpt-4o").
173    pub default_model: String,
174
175    /// Optional per-provider timeout override (seconds).
176    /// Overrides global `timeout_secs` when set.
177    #[serde(default)]
178    pub timeout_secs: Option<u64>,
179
180    /// Optional max concurrency for this provider.
181    #[serde(default)]
182    pub max_concurrency: Option<usize>,
183
184    /// Optional network retry policy for transient failures.
185    #[serde(default)]
186    pub network_retry: Option<NetworkRetryPolicy>,
187
188    /// Optional pricing table for cost estimation.
189    #[serde(default)]
190    pub pricing: Option<PricingTable>,
191
192    /// Optional response cache TTL in seconds (materialized-only).
193    /// Absent = cache disabled for this provider.
194    #[serde(default)]
195    pub cache_ttl_secs: Option<u64>,
196
197    /// Optional maximum cache entries (LRU eviction boundary).
198    /// When set, oldest entries are evicted when the cache exceeds this
199    /// size. When absent, the cache is unbounded.
200    #[serde(default)]
201    pub cache_max_entries: Option<usize>,
202}
203
204/// Configuration for an Ollama (local) provider.
205#[derive(Clone, Debug, serde::Deserialize)]
206pub struct OllamaProviderConfig {
207    /// Base URL for the Ollama server (e.g., "http://localhost:11434").
208    pub base_url: String,
209
210    /// Default model to use (e.g., "llama3").
211    pub default_model: String,
212
213    /// Optional per-provider timeout override (seconds).
214    /// Overrides global `timeout_secs` when set.
215    #[serde(default)]
216    pub timeout_secs: Option<u64>,
217
218    /// Optional max concurrency for this provider.
219    #[serde(default)]
220    pub max_concurrency: Option<usize>,
221
222    /// Optional network retry policy for transient failures.
223    #[serde(default)]
224    pub network_retry: Option<NetworkRetryPolicy>,
225
226    /// Optional pricing table for cost estimation.
227    #[serde(default)]
228    pub pricing: Option<PricingTable>,
229
230    /// Optional response cache TTL in seconds (materialized-only).
231    /// Absent = cache disabled for this provider.
232    #[serde(default)]
233    pub cache_ttl_secs: Option<u64>,
234
235    /// Optional maximum cache entries (LRU eviction boundary).
236    /// When set, oldest entries are evicted when the cache exceeds this
237    /// size. When absent, the cache is unbounded.
238    #[serde(default)]
239    pub cache_max_entries: Option<usize>,
240}
241
242/// Configuration for the mock testing provider.
243#[derive(Clone, Debug, serde::Deserialize)]
244pub struct MockProviderConfig {
245    /// Response mode ("echo" or custom text).
246    #[serde(default = "default_mock_response")]
247    pub response: String,
248
249    /// Default model identifier.
250    #[serde(default = "default_mock_model")]
251    pub default_model: String,
252
253    /// Optional error message to simulate provider failure.
254    #[serde(default)]
255    pub error_message: Option<String>,
256}
257
258/// The type of LLM operation to perform.
259#[derive(Clone, Copy, Debug, PartialEq, Eq)]
260pub enum LlmOperation {
261    /// Chat completion (streaming or non-streaming).
262    Chat,
263    /// Text embedding generation.
264    Embed,
265}
266
267impl LlmGlobalConfig {
268    /// Validate the configuration, rejecting `Some(0)` for `timeout_secs`,
269    /// `Some(0)` for `max_concurrency`, `Some(0)` for `cache_ttl_secs`, and
270    /// `Some(0)` for `cache_max_entries` on both global and per-provider
271    /// levels.
272    pub fn validate(&self) -> Result<(), CamelError> {
273        // Global timeout_secs: Some(0) is a misconfiguration
274        if self.timeout_secs == Some(0) {
275            return Err(CamelError::Config(
276                "global timeout_secs must be > 0 when set (got 0)".into(),
277            ));
278        }
279
280        for (name, provider) in &self.providers {
281            // Provider-level timeout_secs: Some(0) is invalid
282            if provider.timeout_secs() == Some(0) {
283                return Err(CamelError::Config(format!(
284                    "provider '{name}' timeout_secs must be > 0 when set (got 0)"
285                )));
286            }
287
288            // Provider-level max_concurrency: Some(0) is invalid
289            if provider.max_concurrency() == Some(0) {
290                return Err(CamelError::Config(format!(
291                    "provider '{name}' max_concurrency must be > 0 when set (got 0)"
292                )));
293            }
294
295            // Provider-level pricing: negative values are invalid
296            if let Some(p) = provider.pricing()
297                && (p.input_per_1k_tokens < 0.0 || p.output_per_1k_tokens < 0.0)
298            {
299                return Err(CamelError::Config(format!(
300                    "provider '{name}' pricing has negative values: input={}, output={}",
301                    p.input_per_1k_tokens, p.output_per_1k_tokens
302                )));
303            }
304
305            // Provider-level cache_ttl_secs: Some(0) is invalid
306            if provider.cache_ttl_secs() == Some(0) {
307                return Err(CamelError::Config(format!(
308                    "provider '{name}' cache_ttl_secs must be > 0 when set (got 0)"
309                )));
310            }
311
312            // Provider-level cache_max_entries: Some(0) is invalid
313            if provider.cache_max_entries() == Some(0) {
314                return Err(CamelError::Config(format!(
315                    "provider '{name}' cache_max_entries must be > 0 when set (got 0)"
316                )));
317            }
318        }
319
320        Ok(())
321    }
322}
323
324/// Parsed endpoint configuration derived from a URI like `llm:chat?provider=...`.
325#[derive(Clone, Debug)]
326pub struct LlmEndpointConfig {
327    /// The operation type (chat or embed).
328    pub operation: LlmOperation,
329
330    /// Provider name override.
331    pub provider: Option<String>,
332
333    /// Model name override.
334    pub model: Option<String>,
335
336    /// Sampling temperature.
337    pub temperature: Option<f64>,
338
339    /// Maximum tokens to generate.
340    pub max_tokens: Option<u32>,
341
342    /// Whether to stream the response (default: true).
343    pub stream: bool,
344
345    /// System prompt override.
346    pub system_prompt: Option<String>,
347}
348
349impl Default for LlmEndpointConfig {
350    fn default() -> Self {
351        Self {
352            operation: LlmOperation::Chat,
353            provider: None,
354            model: None,
355            temperature: None,
356            max_tokens: None,
357            stream: true,
358            system_prompt: None,
359        }
360    }
361}
362
363impl LlmEndpointConfig {
364    /// Parse an endpoint configuration from a URI string.
365    ///
366    /// # Format
367    /// `llm:{operation}?provider={name}&model={model}&temperature={n}&max_tokens={n}&stream={bool}&system_prompt={text}`
368    ///
369    /// # Parameters
370    /// - `provider` — Provider name override.
371    /// - `model` — Model name override.
372    /// - `temperature` — Sampling temperature (parseable float).
373    /// - `max_tokens` — Maximum tokens to generate (parseable integer).
374    /// - `stream` — Whether to stream the response (default: `true`, also accepts `1`/`0`).
375    /// - `system_prompt` — System prompt override.
376    ///
377    /// # Errors
378    /// Returns [`CamelError::InvalidUri`] if the operation is not recognized.
379    pub fn from_uri(uri: &str) -> Result<Self, CamelError> {
380        let (operation_str, query) = match uri.split_once('?') {
381            Some((path, q)) => (path, q),
382            None => (uri, ""),
383        };
384
385        let operation = match operation_str.trim_start_matches("llm:") {
386            "chat" => LlmOperation::Chat,
387            "embed" => LlmOperation::Embed,
388            other => {
389                return Err(CamelError::InvalidUri(format!(
390                    "unknown llm operation: '{other}' (expected 'chat' or 'embed')"
391                )));
392            }
393        };
394
395        let params: HashMap<String, String> = url::form_urlencoded::parse(query.as_bytes())
396            .into_owned()
397            .collect();
398
399        let stream = params
400            .get("stream")
401            .map(|v| v == "true" || v == "1")
402            .unwrap_or(true);
403
404        Ok(Self {
405            operation,
406            provider: params.get("provider").cloned(),
407            model: params.get("model").cloned(),
408            temperature: params.get("temperature").and_then(|v| v.parse().ok()),
409            max_tokens: params.get("max_tokens").and_then(|v| v.parse().ok()),
410            stream,
411            system_prompt: params.get("system_prompt").cloned(),
412        })
413    }
414}
415
416#[cfg(test)]
417mod tests {
418    use super::*;
419
420    #[test]
421    fn rejects_zero_global_timeout() {
422        let cfg = LlmGlobalConfig {
423            default_provider: None,
424            timeout_secs: Some(0), // Some(0) is invalid; None is valid
425            max_prompt_bytes: 32768,
426            providers: HashMap::new(),
427        };
428        assert!(
429            cfg.validate().is_err(),
430            "Some(0) global timeout_secs must be rejected"
431        );
432    }
433
434    #[test]
435    fn accepts_none_global_timeout() {
436        let cfg = LlmGlobalConfig {
437            default_provider: None,
438            timeout_secs: None, // None = no timeout, valid
439            max_prompt_bytes: 32768,
440            providers: HashMap::new(),
441        };
442        assert!(
443            cfg.validate().is_ok(),
444            "None global timeout_secs must be valid"
445        );
446    }
447
448    #[test]
449    fn rejects_zero_provider_timeout() {
450        let mut providers = HashMap::new();
451        providers.insert(
452            "bad".into(),
453            LlmProviderConfig::Openai(OpenaiProviderConfig {
454                api_key: "sk-test".into(),
455                base_url: None,
456                default_model: "gpt-4o".into(),
457                timeout_secs: Some(0), // invalid
458                max_concurrency: None,
459                network_retry: None,
460                pricing: None,
461                cache_ttl_secs: None,
462                cache_max_entries: None,
463            }),
464        );
465        let cfg = LlmGlobalConfig {
466            default_provider: None,
467            timeout_secs: None,
468            max_prompt_bytes: 32768,
469            providers,
470        };
471        assert!(
472            cfg.validate().is_err(),
473            "Some(0) provider timeout_secs must be rejected"
474        );
475    }
476
477    #[test]
478    fn rejects_zero_max_concurrency() {
479        let mut providers = HashMap::new();
480        providers.insert(
481            "bad".into(),
482            LlmProviderConfig::Openai(OpenaiProviderConfig {
483                api_key: "sk-test".into(),
484                base_url: None,
485                default_model: "gpt-4o".into(),
486                timeout_secs: None,
487                max_concurrency: Some(0), // invalid
488                network_retry: None,
489                pricing: None,
490                cache_ttl_secs: None,
491                cache_max_entries: None,
492            }),
493        );
494        let cfg = LlmGlobalConfig {
495            default_provider: None,
496            timeout_secs: None,
497            max_prompt_bytes: 32768,
498            providers,
499        };
500        assert!(
501            cfg.validate().is_err(),
502            "Some(0) max_concurrency must be rejected"
503        );
504    }
505
506    #[test]
507    fn rejects_zero_ollama_timeout() {
508        let mut providers = HashMap::new();
509        providers.insert(
510            "bad".into(),
511            LlmProviderConfig::Ollama(OllamaProviderConfig {
512                base_url: "http://localhost:11434".into(),
513                default_model: "llama3".into(),
514                timeout_secs: Some(0), // invalid
515                max_concurrency: None,
516                network_retry: None,
517                pricing: None,
518                cache_ttl_secs: None,
519                cache_max_entries: None,
520            }),
521        );
522        let cfg = LlmGlobalConfig {
523            default_provider: None,
524            timeout_secs: None,
525            max_prompt_bytes: 32768,
526            providers,
527        };
528        assert!(
529            cfg.validate().is_err(),
530            "Some(0) Ollama timeout_secs must be rejected"
531        );
532    }
533
534    #[test]
535    fn rejects_negative_pricing() {
536        let mut providers = HashMap::new();
537        providers.insert(
538            "bad".into(),
539            LlmProviderConfig::Openai(OpenaiProviderConfig {
540                api_key: "sk-test".into(),
541                base_url: None,
542                default_model: "gpt-4o".into(),
543                timeout_secs: None,
544                max_concurrency: None,
545                network_retry: None,
546                pricing: Some(PricingTable {
547                    input_per_1k_tokens: -0.01,
548                    output_per_1k_tokens: 0.03,
549                }),
550                cache_ttl_secs: None,
551                cache_max_entries: None,
552            }),
553        );
554        let cfg = LlmGlobalConfig {
555            default_provider: None,
556            timeout_secs: None,
557            max_prompt_bytes: 32768,
558            providers,
559        };
560        assert!(
561            cfg.validate().is_err(),
562            "Negative input_per_1k_tokens must be rejected"
563        );
564    }
565
566    #[test]
567    fn rejects_zero_cache_ttl() {
568        let mut providers = HashMap::new();
569        providers.insert(
570            "bad-cache".into(),
571            LlmProviderConfig::Openai(OpenaiProviderConfig {
572                api_key: "sk-test".into(),
573                base_url: None,
574                default_model: "gpt-4o".into(),
575                timeout_secs: None,
576                max_concurrency: None,
577                network_retry: None,
578                pricing: None,
579                cache_ttl_secs: Some(0), // invalid
580                cache_max_entries: None,
581            }),
582        );
583        let cfg = LlmGlobalConfig {
584            default_provider: None,
585            timeout_secs: None,
586            max_prompt_bytes: 32768,
587            providers,
588        };
589        assert!(
590            cfg.validate().is_err(),
591            "Some(0) cache_ttl_secs must be rejected"
592        );
593    }
594
595    #[test]
596    fn rejects_zero_cache_max_entries() {
597        let mut providers = HashMap::new();
598        providers.insert(
599            "bad-entries".into(),
600            LlmProviderConfig::Openai(OpenaiProviderConfig {
601                api_key: "sk-test".into(),
602                base_url: None,
603                default_model: "gpt-4o".into(),
604                timeout_secs: None,
605                max_concurrency: None,
606                network_retry: None,
607                pricing: None,
608                cache_ttl_secs: None,
609                cache_max_entries: Some(0), // invalid
610            }),
611        );
612        let cfg = LlmGlobalConfig {
613            default_provider: None,
614            timeout_secs: None,
615            max_prompt_bytes: 32768,
616            providers,
617        };
618        assert!(
619            cfg.validate().is_err(),
620            "Some(0) cache_max_entries must be rejected"
621        );
622    }
623
624    #[test]
625    fn accepts_valid_provider_config() {
626        let mut providers = HashMap::new();
627        providers.insert(
628            "valid".into(),
629            LlmProviderConfig::Openai(OpenaiProviderConfig {
630                api_key: "sk-test".into(),
631                base_url: None,
632                default_model: "gpt-4o".into(),
633                timeout_secs: Some(30),
634                max_concurrency: Some(5),
635                network_retry: None,
636                pricing: None,
637                cache_ttl_secs: None,
638                cache_max_entries: None,
639            }),
640        );
641        let cfg = LlmGlobalConfig {
642            default_provider: None,
643            timeout_secs: Some(60),
644            max_prompt_bytes: 32768,
645            providers,
646        };
647        assert!(
648            cfg.validate().is_ok(),
649            "Valid config with non-zero timeouts and concurrency must pass"
650        );
651    }
652
653    #[test]
654    fn validate_error_contains_provider_name() {
655        let mut providers = HashMap::new();
656        providers.insert(
657            "my-openai".into(),
658            LlmProviderConfig::Openai(OpenaiProviderConfig {
659                api_key: "sk-test".into(),
660                base_url: None,
661                default_model: "gpt-4o".into(),
662                timeout_secs: Some(0),
663                max_concurrency: None,
664                network_retry: None,
665                pricing: None,
666                cache_ttl_secs: None,
667                cache_max_entries: None,
668            }),
669        );
670        let cfg = LlmGlobalConfig {
671            default_provider: None,
672            timeout_secs: None,
673            max_prompt_bytes: 32768,
674            providers,
675        };
676        let err = cfg.validate().unwrap_err();
677        let msg = err.to_string();
678        assert!(
679            msg.contains("my-openai"),
680            "validation error should include provider name: {msg}"
681        );
682    }
683
684    #[test]
685    fn deserialize_global_config_with_providers() {
686        let toml_str = r#"
687default_provider = "my-openai"
688
689[providers.my-openai]
690type = "openai"
691api_key = "secret-key"
692default_model = "gpt-4o"
693
694[providers.local]
695type = "ollama"
696base_url = "http://localhost:11434"
697default_model = "llama3"
698
699[providers.test]
700type = "mock"
701response = "echo"
702default_model = "mock-model"
703"#;
704        let cfg: LlmGlobalConfig = toml::from_str(toml_str).expect("parse");
705        assert_eq!(cfg.default_provider.as_deref(), Some("my-openai"));
706        assert_eq!(cfg.providers.len(), 3);
707        assert!(matches!(
708            cfg.providers["my-openai"],
709            LlmProviderConfig::Openai(_)
710        ));
711        assert!(matches!(
712            cfg.providers["local"],
713            LlmProviderConfig::Ollama(_)
714        ));
715        assert!(matches!(cfg.providers["test"], LlmProviderConfig::Mock(_)));
716    }
717
718    #[test]
719    fn default_config_has_no_providers() {
720        let cfg = LlmGlobalConfig::default();
721        assert!(cfg.providers.is_empty());
722        assert_eq!(cfg.timeout_secs, None);
723        assert_eq!(cfg.max_prompt_bytes, 32768);
724    }
725
726    #[test]
727    fn debug_redacts_api_key() {
728        let cfg = LlmProviderConfig::Openai(OpenaiProviderConfig {
729            api_key: "sk-secret123".into(),
730            base_url: None,
731            default_model: "gpt-4o".into(),
732            timeout_secs: None,
733            max_concurrency: None,
734            network_retry: None,
735            pricing: None,
736            cache_ttl_secs: None,
737            cache_max_entries: None,
738        });
739        let debug_str = format!("{:?}", cfg);
740        assert!(debug_str.contains("[REDACTED]"));
741        assert!(!debug_str.contains("sk-secret123"));
742    }
743
744    #[test]
745    fn endpoint_config_from_uri_chat() {
746        let ec = LlmEndpointConfig::from_uri(
747            "llm:chat?provider=my-openai&model=gpt-4o&temperature=0.7&stream=false",
748        );
749        let ec = ec.expect("parse");
750        assert_eq!(ec.operation, LlmOperation::Chat);
751        assert_eq!(ec.provider.as_deref(), Some("my-openai"));
752        assert_eq!(ec.model.as_deref(), Some("gpt-4o"));
753        assert!(!ec.stream);
754    }
755
756    #[test]
757    fn endpoint_config_from_uri_embed() {
758        let ec = LlmEndpointConfig::from_uri("llm:embed?provider=local");
759        let ec = ec.expect("parse");
760        assert_eq!(ec.operation, LlmOperation::Embed);
761        assert!(ec.stream); // default
762    }
763
764    #[test]
765    fn from_uri_unknown_operation_returns_invalid_uri() {
766        let result = LlmEndpointConfig::from_uri("llm:summarize?provider=x");
767        assert!(result.is_err());
768        let err = result.unwrap_err();
769        assert!(matches!(err, CamelError::InvalidUri(_)));
770        assert!(err.to_string().contains("summarize"));
771    }
772
773    #[test]
774    fn mock_config_with_error_message_deserializes() {
775        let toml_str = r#"
776[providers.err]
777type = "mock"
778error_message = "boom"
779"#;
780        let cfg: LlmGlobalConfig = toml::from_str(toml_str).expect("parse");
781        let mock_cfg = match &cfg.providers["err"] {
782            LlmProviderConfig::Mock(c) => c,
783            _ => panic!("expected Mock"),
784        };
785        assert_eq!(mock_cfg.error_message.as_deref(), Some("boom"));
786    }
787
788    #[test]
789    fn from_uri_stream_parsing() {
790        // Explicit false
791        let ec = LlmEndpointConfig::from_uri("llm:chat?stream=false").unwrap();
792        assert!(!ec.stream);
793
794        // Explicit true
795        let ec = LlmEndpointConfig::from_uri("llm:chat?stream=true").unwrap();
796        assert!(ec.stream);
797
798        // Numeric true
799        let ec = LlmEndpointConfig::from_uri("llm:chat?stream=1").unwrap();
800        assert!(ec.stream);
801
802        // Default (no param)
803        let ec = LlmEndpointConfig::from_uri("llm:chat").unwrap();
804        assert!(ec.stream);
805    }
806}