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#[derive(Clone, Debug, serde::Deserialize)]
23pub struct LlmGlobalConfig {
24 #[serde(default)]
26 pub default_provider: Option<String>,
27
28 #[serde(default)]
30 pub timeout_secs: Option<u64>,
31
32 #[serde(default = "default_max_prompt_bytes")]
34 pub max_prompt_bytes: usize,
35
36 #[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#[derive(Clone, serde::Deserialize)]
54#[serde(tag = "type", rename_all = "lowercase")]
55pub enum LlmProviderConfig {
56 Openai(OpenaiProviderConfig),
58 Ollama(OllamaProviderConfig),
60 Mock(MockProviderConfig),
62}
63
64impl LlmProviderConfig {
65 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 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 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 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 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 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#[derive(Clone, serde::Deserialize)]
164pub struct OpenaiProviderConfig {
165 pub api_key: String,
167
168 #[serde(default)]
170 pub base_url: Option<String>,
171
172 pub default_model: String,
174
175 #[serde(default)]
178 pub timeout_secs: Option<u64>,
179
180 #[serde(default)]
182 pub max_concurrency: Option<usize>,
183
184 #[serde(default)]
186 pub network_retry: Option<NetworkRetryPolicy>,
187
188 #[serde(default)]
190 pub pricing: Option<PricingTable>,
191
192 #[serde(default)]
195 pub cache_ttl_secs: Option<u64>,
196
197 #[serde(default)]
201 pub cache_max_entries: Option<usize>,
202}
203
204#[derive(Clone, Debug, serde::Deserialize)]
206pub struct OllamaProviderConfig {
207 pub base_url: String,
209
210 pub default_model: String,
212
213 #[serde(default)]
216 pub timeout_secs: Option<u64>,
217
218 #[serde(default)]
220 pub max_concurrency: Option<usize>,
221
222 #[serde(default)]
224 pub network_retry: Option<NetworkRetryPolicy>,
225
226 #[serde(default)]
228 pub pricing: Option<PricingTable>,
229
230 #[serde(default)]
233 pub cache_ttl_secs: Option<u64>,
234
235 #[serde(default)]
239 pub cache_max_entries: Option<usize>,
240}
241
242#[derive(Clone, Debug, serde::Deserialize)]
244pub struct MockProviderConfig {
245 #[serde(default = "default_mock_response")]
247 pub response: String,
248
249 #[serde(default = "default_mock_model")]
251 pub default_model: String,
252
253 #[serde(default)]
255 pub error_message: Option<String>,
256}
257
258#[derive(Clone, Copy, Debug, PartialEq, Eq)]
260pub enum LlmOperation {
261 Chat,
263 Embed,
265}
266
267impl LlmGlobalConfig {
268 pub fn validate(&self) -> Result<(), CamelError> {
273 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 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 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 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 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 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#[derive(Clone, Debug)]
326pub struct LlmEndpointConfig {
327 pub operation: LlmOperation,
329
330 pub provider: Option<String>,
332
333 pub model: Option<String>,
335
336 pub temperature: Option<f64>,
338
339 pub max_tokens: Option<u32>,
341
342 pub stream: bool,
344
345 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 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), 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, 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), 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), 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), 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), 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), }),
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); }
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 let ec = LlmEndpointConfig::from_uri("llm:chat?stream=false").unwrap();
792 assert!(!ec.stream);
793
794 let ec = LlmEndpointConfig::from_uri("llm:chat?stream=true").unwrap();
796 assert!(ec.stream);
797
798 let ec = LlmEndpointConfig::from_uri("llm:chat?stream=1").unwrap();
800 assert!(ec.stream);
801
802 let ec = LlmEndpointConfig::from_uri("llm:chat").unwrap();
804 assert!(ec.stream);
805 }
806}