1use std::collections::HashMap;
28use std::net::{IpAddr, SocketAddr};
29use std::sync::Arc;
30use std::time::Duration;
31
32use anyhow::{Context as _, anyhow, bail};
33use talon_container::{ContainerRunner, ContainerRuntime, LogRedactor, MountPolicy, RunnerConfig};
34use talon_core::audit::{SharedAuditSink, event};
35use talon_core::channel::{Channel, ChannelHost};
36use talon_core::config::TalonConfig;
37use talon_core::provider::{effective_vllm, merge_container_config, provider_kind};
38use talon_core::types::{ProviderKind, RegisteredGroup};
39use talon_db::{Db, DbAuditSink, DbOptions, default_db_path};
40use talon_http::{HttpChannel, HttpConfig};
41use talon_ipc::{IpcHost, IpcWatcher, IpcWatcherConfig};
42use talon_llm::gateway::{GatewayConfig, GatewayHandle, LlmGateway};
43use talon_scheduler::{GroupQueue, QueueConfig, SchedulerConfig, TaskScheduler};
44use talon_security::mounts::MountValidator;
45use talon_security::redact::Redactor;
46use talon_security::senders::REQUIRED_ENV;
47use tokio_util::sync::CancellationToken;
48
49use crate::agent::{GatewayLlm, LlmAccess, RunnerLauncher};
50use crate::allowlist::AllowlistSource;
51use crate::orchestrator::{Orchestrator, OrchestratorDeps, Settings};
52use crate::palace::host_palace_runner;
53
54pub const DEFAULT_CONTAINER_HOST: &str = "host.docker.internal";
57pub const ALLOW_NO_API_KEY_ENV: &str = "TALON_VLLM_ALLOW_NO_API_KEY";
59pub const VLLM_API_KEY_REQUIRED: &str = "TALON_VLLM_API_KEY is not set: refusing to start the LLM \
61 gateway. Without an API key vLLM serves anyone who can reach it, so agent containers could \
62 call it directly and bypass the gateway (per-run tokens, model allowlist, request filtering). \
63 Set TALON_VLLM_API_KEY in .env to vLLM's --api-key; only if containers provably cannot reach \
64 vLLM, set TALON_VLLM_ALLOW_NO_API_KEY=true. Every agent run fails until then — run `talon doctor`";
65const CHANNEL_DISCONNECT_TIMEOUT: Duration = Duration::from_secs(10);
67const LOOP_STOP_TIMEOUT: Duration = Duration::from_secs(5);
69
70pub fn log_audit_head(db: &Db, phase: &str) {
72 match (db.audit_chain_head(), db.audit_count()) {
73 (Ok(Some((id, hash))), Ok(count)) => {
74 tracing::info!(phase, head_id = id, head_hash = %hash, events = count, "audit chain head");
75 }
76 (Ok(None), _) => tracing::info!(phase, "audit chain empty"),
77 (Err(e), _) | (_, Err(e)) => {
78 tracing::warn!(phase, error = %e, "cannot read audit chain head")
79 }
80 }
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85pub enum ApiKeyPolicy {
86 Present,
88 MissingAllowed,
90 Missing,
92}
93
94pub fn api_key_env(config: &TalonConfig) -> &'static str {
98 provider_kind(&config.provider_defaults)
99 .unwrap_or(ProviderKind::Vllm)
100 .api_key_env()
101}
102
103pub fn api_key_policy(config: &TalonConfig) -> ApiKeyPolicy {
106 let present = config
107 .env
108 .file_value(api_key_env(config))
109 .is_some_and(|k| !k.trim().is_empty());
110 let anthropic = provider_kind(&config.provider_defaults) == Some(ProviderKind::Anthropic);
111 if present {
112 ApiKeyPolicy::Present
113 } else if !anthropic && config.env.get_bool(ALLOW_NO_API_KEY_ENV) {
114 ApiKeyPolicy::MissingAllowed
115 } else {
116 ApiKeyPolicy::Missing
117 }
118}
119
120fn parse_bool(raw: &str) -> Option<bool> {
122 match raw.trim().to_ascii_lowercase().as_str() {
123 "1" | "true" | "yes" | "on" => Some(true),
124 "0" | "false" | "no" | "off" => Some(false),
125 _ => None,
126 }
127}
128
129fn normalize_base(url: &str) -> String {
130 let u = url.trim().trim_end_matches('/');
131 u.strip_suffix("/v1").unwrap_or(u).to_ascii_lowercase()
132}
133
134pub fn gateway_config(
137 config: &TalonConfig,
138 groups: &HashMap<String, RegisteredGroup>,
139 bind: SocketAddr,
140) -> Option<GatewayConfig> {
141 let vllm = effective_vllm(&config.provider_defaults)?;
142 let kind = provider_kind(&config.provider_defaults)?;
143 let api_key = config
144 .env
145 .file_value(api_key_env(config))
146 .map(str::trim)
147 .filter(|k| !k.is_empty())
148 .map(str::to_string);
149 let mut gw = GatewayConfig::from_provider(kind, vllm, api_key);
150 gw.bind = bind;
151 if let Some(rpm) = config
152 .env
153 .get("TALON_LLM_GATEWAY_RPM")
154 .and_then(|v| v.trim().parse::<u32>().ok())
155 {
156 gw.requests_per_minute = rpm;
157 }
158 gw.audit_requests = config.env.get_bool("TALON_LLM_GATEWAY_AUDIT_REQUESTS");
159 if let Some(raw) = config.env.get("TALON_LLM_GATEWAY_MAX_CONCURRENT") {
160 match raw.trim().parse::<u32>() {
161 Ok(n) => gw.max_concurrent_requests = n,
162 Err(_) => tracing::warn!(
163 value = %raw,
164 default = gw.max_concurrent_requests,
165 "TALON_LLM_GATEWAY_MAX_CONCURRENT is not a number; using the default"
166 ),
167 }
168 }
169 if let Some(raw) = config.env.get("TALON_LLM_GATEWAY_CACHE_SALT") {
170 match parse_bool(&raw) {
171 Some(on) => gw.cache_salt = on,
172 None => tracing::warn!(
173 value = %raw,
174 default = gw.cache_salt,
175 "TALON_LLM_GATEWAY_CACHE_SALT is not true/false; using the default"
176 ),
177 }
178 }
179
180 let upstream = normalize_base(&vllm.base_url);
181 let mut jids: Vec<&String> = groups.keys().collect();
182 jids.sort();
183 for jid in jids {
184 let group = &groups[jid];
185 let merged =
186 merge_container_config(&config.provider_defaults, group.container_config.as_ref());
187 let Some(v) = effective_vllm(&merged) else {
188 continue;
189 };
190 if normalize_base(&v.base_url) != upstream {
191 tracing::warn!(
192 jid = %jid,
193 group = %group.name,
194 "group overrides the vLLM base URL; the LLM gateway only proxies to the host \
195 upstream (TALON_VLLM_BASE_URL), so its requests go there"
196 );
197 }
198 gw = gw.allow_model(v.model.clone());
199 if let Some(fast) = &v.fast_model {
200 gw = gw.allow_model(fast.clone());
201 }
202 }
203 Some(gw)
204}
205
206pub async fn gateway_bind_ip(
212 config: &TalonConfig,
213 runtime: &ContainerRuntime,
214) -> anyhow::Result<IpAddr> {
215 match config.env.get("TALON_LLM_GATEWAY_BIND") {
216 Some(raw) => raw
217 .trim()
218 .parse::<IpAddr>()
219 .with_context(|| format!("TALON_LLM_GATEWAY_BIND {raw:?} is not an IP address")),
220 None => Ok(talon_container::detect_gateway_bind_addr(runtime).await),
221 }
222}
223
224async fn start_gateway(
225 config: &TalonConfig,
226 runtime: &ContainerRuntime,
227 groups: &HashMap<String, RegisteredGroup>,
228 audit: SharedAuditSink,
229) -> anyhow::Result<Option<GatewayHandle>> {
230 if effective_vllm(&config.provider_defaults).is_none() {
231 tracing::error!(
232 "no model provider is configured (TALON_VLLM_BASE_URL / TALON_VLLM_MODEL, or \
233 TALON_PROVIDER=anthropic + TALON_ANTHROPIC_MODEL): the LLM gateway is not started and \
234 every agent run will fail until it is — run `talon doctor`"
235 );
236 return Ok(None);
237 }
238 match api_key_policy(config) {
239 ApiKeyPolicy::Present => {}
240 ApiKeyPolicy::MissingAllowed => tracing::warn!(
241 "TALON_VLLM_API_KEY is not set and {ALLOW_NO_API_KEY_ENV}=true: the gateway calls vLLM \
242 without an API key; make sure agent containers cannot reach vLLM directly"
243 ),
244 ApiKeyPolicy::Missing => {
245 if provider_kind(&config.provider_defaults) == Some(ProviderKind::Anthropic) {
246 tracing::error!(
247 "TALON_ANTHROPIC_API_KEY is not set: refusing to start the LLM gateway — the \
248 Anthropic provider cannot work without it"
249 );
250 } else {
251 tracing::error!("{VLLM_API_KEY_REQUIRED}");
252 }
253 return Ok(None);
254 }
255 }
256 let ip = gateway_bind_ip(config, runtime).await?;
257 let bind = SocketAddr::new(ip, config.llm_gateway_port);
258 let Some(gw) = gateway_config(config, groups, bind) else {
259 return Ok(None);
260 };
261 let handle = LlmGateway::start(gw, audit)
262 .await
263 .map_err(|e| anyhow!("cannot start the LLM gateway on {bind}: {e}"))?;
264 Ok(Some(handle))
265}
266
267pub async fn ensure_agent_network(
276 runtime: &ContainerRuntime,
277 runner_config: &RunnerConfig,
278) -> anyhow::Result<()> {
279 let Some(network) = runner_config.default_network.as_deref() else {
280 tracing::warn!(
281 "agent containers use the runtime's default bridge network (no dedicated \
282 talon-agents network): containers can reach each other and egress is not \
283 restricted by `talon setup egress`"
284 );
285 return Ok(());
286 };
287 runtime.ensure_agent_network(network).await.map_err(|e| {
288 anyhow!(
289 "cannot prepare the agent container network {network:?}: {e}. Run `talon setup \
290 egress` (creates the network and its egress firewall rules), or check that the \
291 container runtime can create networks"
292 )
293 })?;
294 tracing::info!(network, "agent network ready");
295 Ok(())
296}
297
298async fn wait_for_signal() -> &'static str {
299 use tokio::signal::unix::{SignalKind, signal};
300 let mut term = match signal(SignalKind::terminate()) {
301 Ok(s) => Some(s),
302 Err(e) => {
303 tracing::warn!(error = %e, "cannot install SIGTERM handler");
304 None
305 }
306 };
307 tokio::select! {
308 r = tokio::signal::ctrl_c() => {
309 if let Err(e) = r {
310 tracing::warn!(error = %e, "cannot listen for SIGINT");
311 std::future::pending::<()>().await;
312 }
313 "SIGINT"
314 }
315 () = async {
316 match term.as_mut() {
317 Some(t) => { t.recv().await; }
318 None => std::future::pending::<()>().await,
319 }
320 } => "SIGTERM",
321 }
322}
323
324async fn connect_channels(candidates: Vec<Arc<dyn Channel>>) -> Vec<Arc<dyn Channel>> {
325 let mut connected = Vec::new();
326 for channel in candidates {
327 match channel.connect().await {
328 Ok(()) => {
329 tracing::info!(channel = channel.name(), "channel connected");
330 connected.push(channel);
331 }
332 Err(e) => {
333 tracing::error!(channel = channel.name(), error = %e, "channel failed to connect")
334 }
335 }
336 }
337 connected
338}
339
340async fn join_with_timeout(name: &str, handle: tokio::task::JoinHandle<()>) {
341 let abort = handle.abort_handle();
342 match tokio::time::timeout(LOOP_STOP_TIMEOUT, handle).await {
343 Ok(Ok(())) => {}
344 Ok(Err(e)) => tracing::warn!(task = name, error = %e, "background task ended abnormally"),
345 Err(_) => {
346 tracing::warn!(
347 task = name,
348 "background task did not stop in time; aborting"
349 );
350 abort.abort();
351 }
352 }
353}
354
355pub async fn run(config: Arc<TalonConfig>, redactor: Arc<Redactor>) -> anyhow::Result<()> {
361 tracing::info!(version = crate::VERSION, root = %config.project_root.display(), "Talon starting");
362
363 let runtime = Arc::new(
365 ContainerRuntime::detect(&config.container_runtime)
366 .map_err(|e| anyhow!("container runtime: {e}"))?,
367 );
368 runtime.ensure_running().await.map_err(|e| anyhow!("{e}"))?;
369 let orphans = runtime.cleanup_orphans().await;
370 if !orphans.is_empty() {
371 tracing::info!(count = orphans.len(), names = ?orphans, "Stopped orphaned containers");
372 }
373 let runner_config = RunnerConfig::from_talon_config(&config);
374 ensure_agent_network(&runtime, &runner_config).await?;
375
376 let db = Db::open_with_options(
378 &default_db_path(&config.store_dir),
379 &DbOptions {
380 assistant_name: config.assistant_name.clone(),
381 },
382 )
383 .map_err(|e| anyhow!("cannot open database: {e}"))?;
384 tracing::info!("Database initialized");
385 let audit: SharedAuditSink = Arc::new(DbAuditSink::new(db.clone()));
386 log_audit_head(&db, "startup");
387 audit.record(event(
388 "talon.start",
389 "talon",
390 None,
391 "info",
392 serde_json::json!({ "version": crate::VERSION, "pid": std::process::id() }),
393 ));
394
395 let persisted_groups = db
397 .get_all_registered_groups()
398 .map_err(|e| anyhow!("cannot load registered groups: {e}"))?;
399 let gateway = start_gateway(&config, &runtime, &persisted_groups, audit.clone()).await?;
400 let container_host = config
401 .env
402 .get("TALON_LLM_GATEWAY_HOST")
403 .unwrap_or_else(|| DEFAULT_CONTAINER_HOST.to_string());
404 let llm: Option<Arc<dyn LlmAccess>> = gateway
405 .clone()
406 .map(|g| Arc::new(GatewayLlm::new(g, container_host)) as Arc<dyn LlmAccess>);
407
408 let mounts: Arc<dyn MountPolicy> = Arc::new(MountValidator::load(&config.mount_allowlist_path));
410 let log_redactor: Arc<dyn LogRedactor> = redactor.clone();
411 let agent_network = runner_config.default_network.clone();
412 let runner = ContainerRunner::new(
413 runner_config,
414 runtime.clone(),
415 mounts,
416 log_redactor,
417 audit.clone(),
418 );
419 let mut queue_config = QueueConfig::from_talon_config(&config);
420 queue_config.stop_on_shutdown = true;
423 let queue = GroupQueue::new(queue_config);
424 let settings = Settings::default();
425 let shutdown_grace = settings.shutdown_grace;
426 let orch = Orchestrator::new(OrchestratorDeps {
427 config: config.clone(),
428 db: db.clone(),
429 queue: queue.clone(),
430 launcher: Arc::new(RunnerLauncher::new(runner)),
431 llm,
432 allowlist: AllowlistSource::from_file(
433 config.sender_allowlist_path.clone(),
434 config.env.get_bool(REQUIRED_ENV),
435 ),
436 redactor: redactor.clone(),
437 audit: audit.clone(),
438 settings,
439 })
440 .map_err(|e| anyhow!("cannot load state: {e}"))?;
441 let scheduler = Arc::new(TaskScheduler::new(
442 SchedulerConfig::from_talon_config(&config),
443 Arc::new(db.clone()),
444 queue.clone(),
445 orch.clone(),
446 orch.clone(),
447 ));
448 orch.set_scheduler(scheduler.clone());
449 queue.set_processor(orch.clone());
450
451 let host: Arc<dyn ChannelHost> = orch.clone();
453 let http_config =
454 HttpConfig::from_talon_config(&config).map_err(|e| anyhow!("HTTP channel: {e}"))?;
455 let palace = host_palace_runner(&config, &http_config, &runtime, agent_network);
456 let mut candidates: Vec<Arc<dyn Channel>> = vec![Arc::new(HttpChannel::with_palace(
457 http_config,
458 host.clone(),
459 db.clone(),
460 audit.clone(),
461 palace,
462 ))];
463 candidates.extend(talon_channels::build_channels(
464 host.clone(),
465 &config.env,
466 audit.clone(),
467 ));
468 let channels = connect_channels(candidates).await;
469 if channels.is_empty() {
470 bail!("No channels connected");
471 }
472 orch.set_channels(channels.clone());
473
474 let cancel = CancellationToken::new();
476 let scheduler_task = tokio::spawn({
477 let scheduler = scheduler.clone();
478 let cancel = cancel.clone();
479 async move { scheduler.run(cancel).await }
480 });
481 let ipc_host: Arc<dyn IpcHost> = orch.clone();
482 let watcher = IpcWatcher::new(
483 IpcWatcherConfig::from_talon_config(&config),
484 db.clone(),
485 ipc_host,
486 audit.clone(),
487 );
488 let ipc_task = tokio::spawn({
489 let cancel = cancel.clone();
490 async move { watcher.run(cancel).await }
491 });
492 let recovered = orch.recover_pending_messages();
493 if !recovered.is_empty() {
494 tracing::info!(lanes = recovered.len(), "Recovery: lanes enqueued");
495 }
496 let loop_task = tokio::spawn(orch.clone().message_loop(cancel.clone()));
497
498 let signal = wait_for_signal().await;
500 tracing::info!(signal, "Shutdown signal received");
501 tokio::spawn(async {
502 let second = wait_for_signal().await;
503 tracing::warn!(signal = second, "second signal: exiting immediately");
504 std::process::exit(130);
505 });
506
507 orch.stop_accepting();
509 cancel.cancel();
510 let report = queue.shutdown(shutdown_grace).await;
511 tracing::info!(
512 drained = report.drained,
513 still_active = ?report.still_active,
514 stopped = ?report.stopped_containers,
515 dropped_pending = report.dropped_pending,
516 "Group queue shut down"
517 );
518 for channel in &channels {
519 match tokio::time::timeout(CHANNEL_DISCONNECT_TIMEOUT, channel.disconnect()).await {
520 Ok(Ok(())) => {}
521 Ok(Err(e)) => {
522 tracing::warn!(channel = channel.name(), error = %e, "channel disconnect failed")
523 }
524 Err(_) => tracing::warn!(channel = channel.name(), "channel disconnect timed out"),
525 }
526 }
527 join_with_timeout("message-loop", loop_task).await;
528 join_with_timeout("scheduler", scheduler_task).await;
529 join_with_timeout("ipc-watcher", ipc_task).await;
530 if let Some(gateway) = &gateway {
531 gateway.shutdown().await;
532 }
533 audit.record(event(
534 "talon.stop",
535 "talon",
536 None,
537 "info",
538 serde_json::json!({ "signal": signal, "drained": report.drained }),
539 ));
540 log_audit_head(&db, "shutdown");
541 tracing::info!("Talon stopped");
542 Ok(())
543}
544
545#[cfg(test)]
546mod tests {
547 use std::path::Path;
548
549 use talon_core::env::EnvFile;
550 use talon_core::types::{ContainerConfig, ProviderKind, VllmConfig};
551
552 use super::*;
553
554 fn cfg(content: &str) -> TalonConfig {
555 TalonConfig::from_env(
556 Path::new("/tmp/talon-test"),
557 EnvFile::from_str_content(content),
558 )
559 }
560
561 fn group_with_model(model: &str, base: &str) -> RegisteredGroup {
562 RegisteredGroup {
563 name: model.into(),
564 folder: model.into(),
565 trigger: "@Talon".into(),
566 added_at: "2026-01-01T00:00:00.000Z".into(),
567 container_config: Some(ContainerConfig {
568 provider: Some(ProviderKind::Vllm),
569 vllm: Some(VllmConfig {
570 base_url: base.into(),
571 model: model.into(),
572 fast_model: Some(format!("{model}-fast")),
573 max_context_tokens: None,
574 max_output_tokens: None,
575 temperature: None,
576 top_p: None,
577 enable_thinking: None,
578 request_timeout_secs: None,
579 }),
580 ..ContainerConfig::default()
581 }),
582 requires_trigger: None,
583 is_main: false,
584 session_key: None,
585 ipc_key: None,
586 ephemeral_session: false,
587 trusted_session_commands: false,
588 }
589 }
590
591 #[test]
592 fn no_gateway_without_vllm() {
593 let c = cfg("");
594 if std::env::var("TALON_VLLM_BASE_URL").is_ok() {
595 return; }
597 assert!(gateway_config(&c, &HashMap::new(), "127.0.0.1:0".parse().unwrap()).is_none());
598 }
599
600 #[test]
601 fn gateway_allows_group_models_and_keeps_key_secret() {
602 let c = cfg(
603 "TALON_VLLM_BASE_URL=http://vllm:8000/v1\nTALON_VLLM_MODEL=qwen\n\
604 TALON_VLLM_FAST_MODEL=qwen-small\nTALON_VLLM_API_KEY=sekret-key-123\n",
605 );
606 if effective_vllm(&c.provider_defaults).map(|v| v.model.as_str()) != Some("qwen") {
607 return; }
609 let mut groups = HashMap::new();
610 groups.insert(
611 "g1".to_string(),
612 group_with_model("llama", "http://vllm:8000"),
613 );
614 groups.insert(
615 "g2".to_string(),
616 group_with_model("other", "http://elsewhere:8000"),
617 );
618 let bind: SocketAddr = "127.0.0.1:3999".parse().unwrap();
619 let gw = gateway_config(&c, &groups, bind).unwrap();
620 assert_eq!(gw.bind, bind);
621 assert_eq!(gw.default_model, "qwen");
622 for m in ["qwen", "qwen-small", "llama", "llama-fast", "other"] {
623 assert!(gw.allowed_models.iter().any(|a| a == m), "{m} not allowed");
624 }
625 assert_eq!(gw.upstream_api_key.as_deref(), Some("sekret-key-123"));
626 assert!(!format!("{gw:?}").contains("sekret-key-123"));
627 }
628
629 #[test]
630 fn api_key_is_required_unless_explicitly_waived() {
631 if std::env::var(ALLOW_NO_API_KEY_ENV).is_ok() {
632 return; }
634 assert_eq!(
635 api_key_policy(&cfg("TALON_VLLM_API_KEY=abc\n")),
636 ApiKeyPolicy::Present
637 );
638 assert_eq!(api_key_policy(&cfg("")), ApiKeyPolicy::Missing);
639 assert_eq!(
640 api_key_policy(&cfg("TALON_VLLM_API_KEY=\" \"\n")),
641 ApiKeyPolicy::Missing
642 );
643 assert_eq!(
644 api_key_policy(&cfg("TALON_VLLM_ALLOW_NO_API_KEY=false\n")),
645 ApiKeyPolicy::Missing
646 );
647 assert_eq!(
648 api_key_policy(&cfg("TALON_VLLM_ALLOW_NO_API_KEY=true\n")),
649 ApiKeyPolicy::MissingAllowed
650 );
651 assert_eq!(
653 api_key_policy(&cfg(
654 "TALON_VLLM_API_KEY=k\nTALON_VLLM_ALLOW_NO_API_KEY=true\n"
655 )),
656 ApiKeyPolicy::Present
657 );
658 }
659
660 #[test]
661 fn gateway_concurrency_and_cache_salt_settings() {
662 let base = "TALON_VLLM_BASE_URL=http://vllm:8000/v1\nTALON_VLLM_MODEL=qwen\n";
663 if [
664 "TALON_VLLM_BASE_URL",
665 "TALON_VLLM_MODEL",
666 "TALON_LLM_GATEWAY_MAX_CONCURRENT",
667 "TALON_LLM_GATEWAY_CACHE_SALT",
668 ]
669 .iter()
670 .any(|k| std::env::var(k).is_ok())
671 {
672 return; }
674 let bind: SocketAddr = "127.0.0.1:3999".parse().unwrap();
675 let defaults = gateway_config(&cfg(base), &HashMap::new(), bind).unwrap();
676 assert!(defaults.cache_salt);
677 let gw = gateway_config(
678 &cfg(&format!(
679 "{base}TALON_LLM_GATEWAY_MAX_CONCURRENT=7\nTALON_LLM_GATEWAY_CACHE_SALT=false\n"
680 )),
681 &HashMap::new(),
682 bind,
683 )
684 .unwrap();
685 assert_eq!(gw.max_concurrent_requests, 7);
686 assert!(!gw.cache_salt);
687 let gw = gateway_config(
688 &cfg(&format!(
689 "{base}TALON_LLM_GATEWAY_MAX_CONCURRENT=lots\nTALON_LLM_GATEWAY_CACHE_SALT=maybe\n"
690 )),
691 &HashMap::new(),
692 bind,
693 )
694 .unwrap();
695 assert_eq!(gw.max_concurrent_requests, defaults.max_concurrent_requests);
696 assert!(gw.cache_salt);
697 assert_eq!(parse_bool(" Off "), Some(false));
698 assert_eq!(parse_bool("YES"), Some(true));
699 }
700
701 #[test]
702 fn base_url_normalization() {
703 assert_eq!(normalize_base("http://V:8000/v1/"), "http://v:8000");
704 assert_eq!(normalize_base("http://v:8000"), "http://v:8000");
705 }
706}