Skip to main content

talon/
app.rs

1//! `talahon 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 (keyed with `TALAHON_AUDIT_KEY` when set), log the audit chain head;
10//! 3. LLM gateway (when vLLM is configured *and* `TALAHON_VLLM_API_KEY` is
11//!    set — without a key containers could call vLLM directly and bypass
12//!    the gateway, so it is refused unless `TALAHON_VLLM_ALLOW_NO_API_KEY=true`;
13//!    MYTH-05): bind address from `TALAHON_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 = "TALAHON_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 = "TALAHON_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 TALAHON_VLLM_API_KEY in .env to vLLM's --api-key; only if containers provably cannot reach \
64     vLLM, set TALAHON_VLLM_ALLOW_NO_API_KEY=true. Every agent run fails until then — run `talahon 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, as
71/// far as the log is shipped off the host).
72pub 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/// Whether the upstream vLLM API key requirement is met (MYTH-05).
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub enum ApiKeyPolicy {
87    /// `TALAHON_VLLM_API_KEY` is set (non-blank) in `.env`.
88    Present,
89    /// No key, but the operator opted out (`TALAHON_VLLM_ALLOW_NO_API_KEY=true`).
90    MissingAllowed,
91    /// No key and no opt-out: the gateway must not start.
92    Missing,
93}
94
95/// The `.env` key holding the configured provider's API key
96/// (`TALAHON_VLLM_API_KEY`, or `TALAHON_ANTHROPIC_API_KEY` for the Anthropic
97/// provider).
98pub 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
104/// Evaluate the upstream API key requirement for `config`. The Anthropic
105/// provider has no opt-out: without a key nothing works.
106pub 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
121/// `true`/`false` (also `1/0`, `yes/no`, `on/off`, any case).
122fn 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
135/// Gateway configuration for the host's vLLM defaults, allowing every
136/// model a registered group may use. `None` when vLLM is not configured.
137pub 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("TALAHON_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("TALAHON_LLM_GATEWAY_AUDIT_REQUESTS");
160    if let Some(raw) = config.env.get("TALAHON_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                "TALAHON_LLM_GATEWAY_MAX_CONCURRENT is not a number; using the default"
167            ),
168        }
169    }
170    if let Some(raw) = config.env.get("TALAHON_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                "TALAHON_LLM_GATEWAY_LANE_SHARE is not a percentage; using the default"
177            ),
178        }
179    }
180    if let Some(raw) = config.env.get("TALAHON_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                "TALAHON_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 (TALAHON_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
217/// Address the gateway binds: `TALAHON_LLM_GATEWAY_BIND` when set, else the
218/// container bridge address detected by `talon-container`.
219///
220/// # Errors
221/// An explicit bind value that is not an IP address.
222pub async fn gateway_bind_ip(
223    config: &TalonConfig,
224    runtime: &ContainerRuntime,
225) -> anyhow::Result<IpAddr> {
226    match config.env.get("TALAHON_LLM_GATEWAY_BIND") {
227        Some(raw) => raw
228            .trim()
229            .parse::<IpAddr>()
230            .with_context(|| format!("TALAHON_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 (TALAHON_VLLM_BASE_URL / TALAHON_VLLM_MODEL, or \
244             TALAHON_PROVIDER=anthropic + TALAHON_ANTHROPIC_MODEL): the LLM gateway is not started and \
245             every agent run will fail until it is — run `talahon doctor`"
246        );
247        return Ok(None);
248    }
249    match api_key_policy(config) {
250        ApiKeyPolicy::Present => {}
251        ApiKeyPolicy::MissingAllowed => tracing::warn!(
252            "TALAHON_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                    "TALAHON_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
278/// Create (or verify) the network agent containers run on (MYTH-05):
279/// `RunnerConfig::default_network`, normally
280/// [`talon_container::AGENT_NETWORK`]. Nothing to do when the operator
281/// chose the runtime's default bridge.
282///
283/// # Errors
284/// The network cannot be inspected or created — fatal, because the runner
285/// would otherwise fail every launch (or fall back to a weaker network).
286pub 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 `talahon 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 `talahon 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
366/// Run the orchestrator until SIGTERM / SIGINT.
367///
368/// # Errors
369/// Fatal startup problems (container runtime unavailable, database, gateway
370/// bind failure, invalid HTTP configuration, no channel connected).
371pub async fn run(config: Arc<TalonConfig>, redactor: Arc<Redactor>) -> anyhow::Result<()> {
372    tracing::info!(version = crate::VERSION, root = %config.project_root.display(), "Talahon starting");
373
374    // 1. Container runtime.
375    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    // 2. Database + audit.
388    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    // Audit key (TALAHON_AUDIT_KEY): keyed chain + truncation checkpoint.
397    // Refuses to start when events could not be recorded with it.
398    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    // 3. LLM gateway.
410    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("TALAHON_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    // 4. Runner, queue, orchestrator, scheduler.
423    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    // The gateway goes down after the queue: containers still running after
435    // the grace period could not reach the model anyway.
436    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    // 5. Channels.
466    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    // 6. Background loops.
489    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    // Wait for a signal.
513    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    // Shutdown.
522    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!("Talahon 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: "@Talahon".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 talon_core::env::process_var_is_set("TALAHON_VLLM_BASE_URL") {
609            return; // process env overrides .env in CI shells
610        }
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            "TALAHON_VLLM_BASE_URL=http://vllm:8000/v1\nTALAHON_VLLM_MODEL=qwen\n\
618             TALAHON_VLLM_FAST_MODEL=qwen-small\nTALAHON_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; // overridden by the process environment
622        }
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 talon_core::env::process_var_is_set(ALLOW_NO_API_KEY_ENV) {
646            return; // process env overrides .env in CI shells
647        }
648        assert_eq!(
649            api_key_policy(&cfg("TALAHON_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("TALAHON_VLLM_API_KEY=\"   \"\n")),
655            ApiKeyPolicy::Missing
656        );
657        assert_eq!(
658            api_key_policy(&cfg("TALAHON_VLLM_ALLOW_NO_API_KEY=false\n")),
659            ApiKeyPolicy::Missing
660        );
661        assert_eq!(
662            api_key_policy(&cfg("TALAHON_VLLM_ALLOW_NO_API_KEY=true\n")),
663            ApiKeyPolicy::MissingAllowed
664        );
665        // A key always wins over the waiver.
666        assert_eq!(
667            api_key_policy(&cfg(
668                "TALAHON_VLLM_API_KEY=k\nTALAHON_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 = "TALAHON_VLLM_BASE_URL=http://vllm:8000/v1\nTALAHON_VLLM_MODEL=qwen\n";
677        if [
678            "TALAHON_VLLM_BASE_URL",
679            "TALAHON_VLLM_MODEL",
680            "TALAHON_LLM_GATEWAY_MAX_CONCURRENT",
681            "TALAHON_LLM_GATEWAY_LANE_SHARE",
682            "TALAHON_LLM_GATEWAY_CACHE_SALT",
683        ]
684        .iter()
685        .any(|k| talon_core::env::process_var_is_set(k))
686        {
687            return; // process env overrides .env in CI shells
688        }
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}TALAHON_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}TALAHON_LLM_GATEWAY_MAX_CONCURRENT=7\nTALAHON_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}TALAHON_LLM_GATEWAY_MAX_CONCURRENT=lots\nTALAHON_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}