Skip to main content

talon/
app.rs

1//! `talon run`: startup and graceful shutdown.
2//!
3//! Startup:
4//! 1. container runtime: detect, `ensure_running`, clean up orphans,
5//!    create the dedicated agent network (`talon-agents`, inter-container
6//!    traffic off; MYTH-05) unless the operator chose the runtime default —
7//!    fatal when it cannot be created;
8//! 2. database: open + migrate, import legacy JSON state, hash-chained audit
9//!    sink, log the audit chain head;
10//! 3. LLM gateway (when vLLM is configured *and* `TALON_VLLM_API_KEY` is
11//!    set — without a key containers could call vLLM directly and bypass
12//!    the gateway, so it is refused unless `TALON_VLLM_ALLOW_NO_API_KEY=true`;
13//!    MYTH-05): bind address from `TALON_LLM_GATEWAY_BIND` or the container
14//!    bridge, allow every group's model;
15//! 4. container runner (mount allowlist, redactor), group queue,
16//!    orchestrator (loads state), task scheduler, queue processor;
17//! 5. channels: CoPilot HTTP API (its `/palace/*` calls run in throw-away
18//!    hardened containers — never on the host; MYTH-01) + Telegram / Emacs /
19//!    webhook when configured; at least one must connect;
20//! 6. scheduler loop, IPC watcher, startup recovery, message loop.
21//!
22//! Shutdown (SIGTERM / SIGINT; a second signal exits immediately):
23//! stop accepting work and stop the loops, `queue.shutdown(grace)` (closes
24//! containers, stops stragglers), disconnect channels, stop the gateway,
25//! final audit record and chain head.
26
27use 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
54/// Host name containers use for the host (Docker Desktop built-in; mapped
55/// to the bridge gateway on Linux by `talon-container`).
56pub const DEFAULT_CONTAINER_HOST: &str = "host.docker.internal";
57/// Explicit opt-out of the vLLM API key requirement (MYTH-05).
58pub const ALLOW_NO_API_KEY_ENV: &str = "TALON_VLLM_ALLOW_NO_API_KEY";
59/// Logged when the gateway is refused for lack of an upstream API key.
60pub 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`";
65/// How long a channel gets to disconnect at shutdown.
66const CHANNEL_DISCONNECT_TIMEOUT: Duration = Duration::from_secs(10);
67/// How long background loops get to stop at shutdown.
68const LOOP_STOP_TIMEOUT: Duration = Duration::from_secs(5);
69
70/// Log the audit chain head (anchors the chain outside the database).
71pub 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/// Whether the upstream vLLM API key requirement is met (MYTH-05).
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85pub enum ApiKeyPolicy {
86    /// `TALON_VLLM_API_KEY` is set (non-blank) in `.env`.
87    Present,
88    /// No key, but the operator opted out (`TALON_VLLM_ALLOW_NO_API_KEY=true`).
89    MissingAllowed,
90    /// No key and no opt-out: the gateway must not start.
91    Missing,
92}
93
94/// The `.env` key holding the configured provider's API key
95/// (`TALON_VLLM_API_KEY`, or `TALON_ANTHROPIC_API_KEY` for the Anthropic
96/// provider).
97pub 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
103/// Evaluate the upstream API key requirement for `config`. The Anthropic
104/// provider has no opt-out: without a key nothing works.
105pub 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
120/// `true`/`false` (also `1/0`, `yes/no`, `on/off`, any case).
121fn 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
134/// Gateway configuration for the host's vLLM defaults, allowing every
135/// model a registered group may use. `None` when vLLM is not configured.
136pub 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
206/// Address the gateway binds: `TALON_LLM_GATEWAY_BIND` when set, else the
207/// container bridge address detected by `talon-container`.
208///
209/// # Errors
210/// An explicit bind value that is not an IP address.
211pub 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
267/// Create (or verify) the network agent containers run on (MYTH-05):
268/// `RunnerConfig::default_network`, normally
269/// [`talon_container::AGENT_NETWORK`]. Nothing to do when the operator
270/// chose the runtime's default bridge.
271///
272/// # Errors
273/// The network cannot be inspected or created — fatal, because the runner
274/// would otherwise fail every launch (or fall back to a weaker network).
275pub 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
355/// Run the orchestrator until SIGTERM / SIGINT.
356///
357/// # Errors
358/// Fatal startup problems (container runtime unavailable, database, gateway
359/// bind failure, invalid HTTP configuration, no channel connected).
360pub 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    // 1. Container runtime.
364    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    // 2. Database + audit.
377    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    // 3. LLM gateway.
396    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    // 4. Runner, queue, orchestrator, scheduler.
409    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    // The gateway goes down after the queue: containers still running after
421    // the grace period could not reach the model anyway.
422    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    // 5. Channels.
452    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    // 6. Background loops.
475    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    // Wait for a signal.
499    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    // Shutdown.
508    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; // process env overrides .env in CI shells
596        }
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; // overridden by the process environment
608        }
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; // process env overrides .env in CI shells
633        }
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        // A key always wins over the waiver.
652        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; // process env overrides .env in CI shells
673        }
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}