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