Skip to main content

dynamo_runtime/
config.rs

1// SPDX-FileCopyrightText: Copyright (c) 2024-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4use anyhow::Result;
5use derive_builder::Builder;
6use figment::{
7    Figment,
8    providers::{Env, Format, Serialized, Toml},
9};
10use serde::{Deserialize, Serialize};
11use std::fmt;
12use std::sync::OnceLock;
13use validator::Validate;
14
15#[doc(hidden)]
16pub mod env_config;
17pub mod environment_names;
18
19/// Parser generation shared by startup validation and frontend routing.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum ParserVersion {
22    Auto,
23    V1,
24    V2,
25}
26
27impl TryFrom<Option<&str>> for ParserVersion {
28    type Error = anyhow::Error;
29
30    fn try_from(value: Option<&str>) -> Result<Self> {
31        use environment_names::llm::DYN_PARSER_VERSION;
32        match value {
33            Some("1") => Ok(Self::V1),
34            Some("2") => Ok(Self::V2),
35            Some("auto") | None => Ok(Self::Auto),
36            Some(value) => {
37                anyhow::bail!("{DYN_PARSER_VERSION} must be unset, auto, 1, or 2; got {value:?}")
38            }
39        }
40    }
41}
42
43/// Read parser startup configuration once so request routing cannot diverge from it.
44pub fn selected_parser_version() -> Result<ParserVersion> {
45    use environment_names::llm::{DYN_ENABLE_EXPERIMENTAL_PARSERS_V2, DYN_PARSER_VERSION};
46    static VERSION: OnceLock<Result<ParserVersion, String>> = OnceLock::new();
47    VERSION
48        .get_or_init(|| {
49            let version = match std::env::var(DYN_PARSER_VERSION) {
50                Ok(value) => ParserVersion::try_from(Some(value.as_str())),
51                Err(std::env::VarError::NotPresent) => ParserVersion::try_from(None),
52                Err(std::env::VarError::NotUnicode(_)) => Err(anyhow::anyhow!(
53                    "{DYN_PARSER_VERSION} must be unset, auto, 1, or 2; value is not valid UTF-8"
54                )),
55            };
56            let experimental = std::env::var_os(DYN_ENABLE_EXPERIMENTAL_PARSERS_V2);
57            let enabled = experimental
58                .as_ref()
59                .map(|value| {
60                    let value = value.to_str().ok_or_else(|| {
61                        anyhow::anyhow!("value is not valid UTF-8")
62                    })?;
63                    parse_bool(value).map_err(|error| {
64                        anyhow::anyhow!("{DYN_ENABLE_EXPERIMENTAL_PARSERS_V2}={value:?} is not a valid boolean: {error}")
65                    })
66                })
67                .transpose();
68
69            let selection = match (enabled, version) {
70                (Ok(Some(true)), Ok(ParserVersion::V1))
71                | (Ok(Some(false)), Ok(ParserVersion::V2)) => Err(anyhow::anyhow!(
72                    "conflicting parser settings: {DYN_ENABLE_EXPERIMENTAL_PARSERS_V2}={:?} conflicts with {DYN_PARSER_VERSION}={:?}",
73                    experimental.as_ref().map(|value| value.to_string_lossy()),
74                    std::env::var_os(DYN_PARSER_VERSION)
75                        .map(|value| value.to_string_lossy().into_owned())
76                )),
77                (Err(error), _) => Err(error),
78                (Ok(_), Err(error)) => Err(error),
79                (Ok(Some(true)), Ok(ParserVersion::Auto)) => Ok(ParserVersion::V2),
80                (Ok(Some(false)) | Ok(None), Ok(ParserVersion::Auto)) => Ok(ParserVersion::Auto),
81                (Ok(Some(false)), Ok(ParserVersion::V1)) => Ok(ParserVersion::V1),
82                (Ok(Some(true)), Ok(ParserVersion::V2)) => Ok(ParserVersion::V2),
83                (Ok(None), Ok(ParserVersion::V1)) => Ok(ParserVersion::V1),
84                (Ok(None), Ok(ParserVersion::V2)) => Ok(ParserVersion::V2),
85            };
86
87            if let Some(raw_value) = experimental.as_ref() {
88                let value = raw_value.to_str().unwrap_or("<non-UTF-8>");
89                let resolved = selection
90                    .as_ref()
91                    .map(|selection| format!("{selection:?}"))
92                    .unwrap_or_else(|_| "invalid or conflicting settings".to_string());
93                tracing::warn!(
94                    target: "dynamo_unified",
95                    value,
96                    resolved_selection = %resolved,
97                    "experimental V2 parser routing setting is present"
98                );
99            }
100
101            selection.map_err(|error| error.to_string())
102        })
103        .as_ref()
104        .copied()
105        .map_err(|error| anyhow::anyhow!(error.clone()))
106}
107
108/// Default system host for health and metrics endpoints
109const DEFAULT_SYSTEM_HOST: &str = "0.0.0.0";
110
111/// Default system port for health and metrics endpoints (-1 = disabled)
112const DEFAULT_SYSTEM_PORT: i16 = -1;
113
114/// Default health endpoint paths
115const DEFAULT_SYSTEM_HEALTH_PATH: &str = "/health";
116const DEFAULT_SYSTEM_LIVE_PATH: &str = "/live";
117
118/// Default health check configuration
119/// This is the wait time before sending canary health checks when no activity is detected
120pub const DEFAULT_CANARY_WAIT_TIME_SECS: u64 = 10;
121/// Default timeout for individual health check requests
122pub const DEFAULT_HEALTH_CHECK_REQUEST_TIMEOUT_SECS: u64 = 3;
123
124#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct WorkerConfig {
126    /// Grace shutdown period for the system server.
127    pub graceful_shutdown_timeout: u64,
128}
129
130impl WorkerConfig {
131    /// Instantiates and reads server configurations from appropriate sources.
132    /// Panics on invalid configuration.
133    pub fn from_settings() -> Self {
134        // All calls should be global and thread safe.
135        Figment::new()
136            .merge(Serialized::defaults(Self::default()))
137            .merge(Env::prefixed("DYN_WORKER_"))
138            .extract()
139            .unwrap() // safety: Called on startup, so panic is reasonable
140    }
141}
142
143impl Default for WorkerConfig {
144    fn default() -> Self {
145        WorkerConfig {
146            graceful_shutdown_timeout: if cfg!(debug_assertions) {
147                1 // Debug build: 1 second
148            } else {
149                30 // Release build: 30 seconds
150            },
151        }
152    }
153}
154
155#[derive(Debug, Deserialize, Serialize, PartialEq, Clone)]
156#[serde(rename_all = "lowercase")]
157pub enum HealthStatus {
158    Ready,
159    NotReady,
160}
161
162/// Runtime configuration
163/// Defines the configuration for Tokio runtimes
164#[derive(Serialize, Deserialize, Validate, Debug, Builder, Clone)]
165#[builder(build_fn(private, name = "build_internal"), derive(Debug, Serialize))]
166pub struct RuntimeConfig {
167    /// Number of async worker threads
168    /// If set to 1, the runtime will run in single-threaded mode
169    /// Set this at runtime with environment variable DYN_RUNTIME_NUM_WORKER_THREADS. Defaults to
170    /// number of cores.
171    #[validate(range(min = 1))]
172    #[builder_field_attr(serde(skip_serializing_if = "Option::is_none"))]
173    pub num_worker_threads: Option<usize>,
174
175    /// Maximum number of blocking threads
176    /// Blocking threads are used for blocking operations, this value must be greater than 0.
177    /// Set this at runtime with environment variable DYN_RUNTIME_MAX_BLOCKING_THREADS.
178    ///
179    /// Defaults to the core count (`impl Default`). The `#[builder(default = "512")]` below
180    /// applies only when building through `RuntimeConfigBuilder` without setting this field.
181    ///
182    /// This is a ceiling, not a preallocation: Tokio spawns blocking threads on demand and reaps
183    /// them when idle, so measure at steady state under load.
184    #[validate(range(min = 1))]
185    #[builder(default = "512")]
186    #[builder_field_attr(serde(skip_serializing_if = "Option::is_none"))]
187    pub max_blocking_threads: usize,
188
189    /// System status server host for health and metrics endpoints
190    /// Set this at runtime with environment variable DYN_SYSTEM_HOST
191    #[builder(default = "DEFAULT_SYSTEM_HOST.to_string()")]
192    #[builder_field_attr(serde(skip_serializing_if = "Option::is_none"))]
193    pub system_host: String,
194
195    /// System status server port for health and metrics endpoints
196    /// Set to -1 to disable the system status server (default)
197    /// Set to 0 to bind to a random available port
198    /// Set to a positive port number (e.g. 8081) to bind to a specific port
199    /// Set this at runtime with environment variable DYN_SYSTEM_PORT
200    #[builder(default = "DEFAULT_SYSTEM_PORT")]
201    #[builder_field_attr(serde(skip_serializing_if = "Option::is_none"))]
202    pub system_port: i16,
203
204    /// Health and metrics System status server enabled (DEPRECATED)
205    /// This field is deprecated. Use system_port instead (set to positive value to enable)
206    /// Environment variable DYN_SYSTEM_ENABLED is deprecated
207    #[deprecated(
208        note = "Use system_port instead. Set DYN_SYSTEM_PORT to enable the system metrics server."
209    )]
210    #[builder(default = "false")]
211    #[builder_field_attr(serde(skip_serializing_if = "Option::is_none"))]
212    pub system_enabled: bool,
213
214    /// Starting Health Status
215    /// Set this at runtime with environment variable DYN_SYSTEM_STARTING_HEALTH_STATUS
216    #[builder(default = "HealthStatus::NotReady")]
217    #[builder_field_attr(serde(skip_serializing_if = "Option::is_none"))]
218    pub starting_health_status: HealthStatus,
219
220    /// Use Endpoint Health Status
221    /// When using endpoint health status, health status
222    /// is the AND of individual endpoint health
223    /// Set this at runtime with environment variable DYN_SYSTEM_USE_ENDPOINT_HEALTH_STATUS
224    /// with the list of endpoints to consider for system health
225    #[builder(default = "vec![]")]
226    #[builder_field_attr(serde(skip_serializing_if = "Option::is_none"))]
227    pub use_endpoint_health_status: Vec<String>,
228
229    /// Health endpoint paths
230    /// Set this at runtime with environment variable DYN_SYSTEM_HEALTH_PATH
231    #[builder(default = "DEFAULT_SYSTEM_HEALTH_PATH.to_string()")]
232    #[builder_field_attr(serde(skip_serializing_if = "Option::is_none"))]
233    pub system_health_path: String,
234    /// Set this at runtime with environment variable DYN_SYSTEM_LIVE_PATH
235    #[builder(default = "DEFAULT_SYSTEM_LIVE_PATH.to_string()")]
236    #[builder_field_attr(serde(skip_serializing_if = "Option::is_none"))]
237    pub system_live_path: String,
238
239    /// Number of threads for the Rayon compute pool
240    /// If not set, defaults to num_cpus / 2
241    /// Set this at runtime with environment variable DYN_COMPUTE_THREADS
242    #[builder(default = "None")]
243    #[builder_field_attr(serde(skip_serializing_if = "Option::is_none"))]
244    pub compute_threads: Option<usize>,
245
246    /// Stack size for compute threads in bytes
247    /// Defaults to 2MB (2097152 bytes)
248    /// Set this at runtime with environment variable DYN_COMPUTE_STACK_SIZE
249    #[builder(default = "Some(2 * 1024 * 1024)")]
250    #[builder_field_attr(serde(skip_serializing_if = "Option::is_none"))]
251    pub compute_stack_size: Option<usize>,
252
253    /// Thread name prefix for compute pool threads
254    /// Set this at runtime with environment variable DYN_COMPUTE_THREAD_PREFIX
255    #[builder(default = "\"compute\".to_string()")]
256    #[builder_field_attr(serde(skip_serializing_if = "Option::is_none"))]
257    pub compute_thread_prefix: String,
258
259    /// Enable active health checking with payloads
260    /// Set this at runtime with environment variable DYN_HEALTH_CHECK_ENABLED
261    #[builder(default = "false")]
262    #[builder_field_attr(serde(skip_serializing_if = "Option::is_none"))]
263    pub health_check_enabled: bool,
264
265    /// Canary wait time in seconds (time to wait before sending health check when no activity)
266    /// Set this at runtime with environment variable DYN_CANARY_WAIT_TIME
267    #[builder(default = "DEFAULT_CANARY_WAIT_TIME_SECS")]
268    #[builder_field_attr(serde(skip_serializing_if = "Option::is_none"))]
269    pub canary_wait_time_secs: u64,
270
271    /// Health check request timeout in seconds
272    /// Set this at runtime with environment variable DYN_HEALTH_CHECK_REQUEST_TIMEOUT
273    #[builder(default = "DEFAULT_HEALTH_CHECK_REQUEST_TIMEOUT_SECS")]
274    #[builder_field_attr(serde(skip_serializing_if = "Option::is_none"))]
275    pub health_check_request_timeout_secs: u64,
276}
277
278impl fmt::Display for RuntimeConfig {
279    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
280        // If None, it defaults to "number of cores", so we indicate that.
281        match self.num_worker_threads {
282            Some(val) => write!(f, "num_worker_threads={val}, ")?,
283            None => write!(f, "num_worker_threads=default (num_cores), ")?,
284        }
285
286        write!(f, "max_blocking_threads={}, ", self.max_blocking_threads)?;
287        write!(f, "system_host={}, ", self.system_host)?;
288        write!(f, "system_port={}, ", self.system_port)?;
289        write!(
290            f,
291            "use_endpoint_health_status={:?}",
292            self.use_endpoint_health_status
293        )?;
294        write!(
295            f,
296            "starting_health_status={:?}",
297            self.starting_health_status
298        )?;
299        write!(f, ", system_health_path={}", self.system_health_path)?;
300        write!(f, ", system_live_path={}", self.system_live_path)?;
301        write!(f, ", health_check_enabled={}", self.health_check_enabled)?;
302        write!(f, ", canary_wait_time_secs={}", self.canary_wait_time_secs)?;
303        write!(
304            f,
305            ", health_check_request_timeout_secs={}",
306            self.health_check_request_timeout_secs
307        )?;
308
309        Ok(())
310    }
311}
312
313impl RuntimeConfig {
314    pub fn builder() -> RuntimeConfigBuilder {
315        RuntimeConfigBuilder::default()
316    }
317
318    pub(crate) fn figment() -> Figment {
319        Figment::new()
320            .merge(Serialized::defaults(RuntimeConfig::default()))
321            .merge(Toml::file("/opt/dynamo/defaults/runtime.toml"))
322            .merge(Toml::file("/opt/dynamo/etc/runtime.toml"))
323            .merge(Env::prefixed("DYN_RUNTIME_").filter_map(|k| {
324                let full_key = format!("DYN_RUNTIME_{}", k.as_str());
325                // filters out empty environment variables
326                match std::env::var(&full_key) {
327                    Ok(v) if !v.is_empty() => Some(k.into()),
328                    _ => None,
329                }
330            }))
331            .merge(Env::prefixed("DYN_SYSTEM_").filter_map(|k| {
332                let full_key = format!("DYN_SYSTEM_{}", k.as_str());
333                // filters out empty environment variables
334                match std::env::var(&full_key) {
335                    Ok(v) if !v.is_empty() => {
336                        // Map DYN_SYSTEM_* to the correct field names
337                        let mapped_key = match k.as_str() {
338                            "HOST" => "system_host",
339                            "PORT" => "system_port",
340                            "ENABLED" => "system_enabled",
341                            "USE_ENDPOINT_HEALTH_STATUS" => "use_endpoint_health_status",
342                            "STARTING_HEALTH_STATUS" => "starting_health_status",
343                            "HEALTH_PATH" => "system_health_path",
344                            "LIVE_PATH" => "system_live_path",
345                            _ => k.as_str(),
346                        };
347                        Some(mapped_key.into())
348                    }
349                    _ => None,
350                }
351            }))
352            .merge(Env::prefixed("DYN_COMPUTE_").filter_map(|k| {
353                let full_key = format!("DYN_COMPUTE_{}", k.as_str());
354                // filters out empty environment variables
355                match std::env::var(&full_key) {
356                    Ok(v) if !v.is_empty() => {
357                        // Map DYN_COMPUTE_* to the correct field names
358                        let mapped_key = match k.as_str() {
359                            "THREADS" => "compute_threads",
360                            "STACK_SIZE" => "compute_stack_size",
361                            "THREAD_PREFIX" => "compute_thread_prefix",
362                            _ => k.as_str(),
363                        };
364                        Some(mapped_key.into())
365                    }
366                    _ => None,
367                }
368            }))
369            .merge(Env::prefixed("DYN_HEALTH_CHECK_").filter_map(|k| {
370                let full_key = format!("DYN_HEALTH_CHECK_{}", k.as_str());
371                // filters out empty environment variables
372                match std::env::var(&full_key) {
373                    Ok(v) if !v.is_empty() => {
374                        // Map DYN_HEALTH_CHECK_* to the correct field names
375                        let mapped_key = match k.as_str() {
376                            "ENABLED" => "health_check_enabled",
377                            "REQUEST_TIMEOUT" => "health_check_request_timeout_secs",
378                            _ => k.as_str(),
379                        };
380                        Some(mapped_key.into())
381                    }
382                    _ => None,
383                }
384            }))
385            .merge(Env::prefixed("DYN_CANARY_").filter_map(|k| {
386                let full_key = format!("DYN_CANARY_{}", k.as_str());
387                // filters out empty environment variables
388                match std::env::var(&full_key) {
389                    Ok(v) if !v.is_empty() => {
390                        // Map DYN_CANARY_* to the correct field names
391                        let mapped_key = match k.as_str() {
392                            "WAIT_TIME" => "canary_wait_time_secs",
393                            _ => k.as_str(),
394                        };
395                        Some(mapped_key.into())
396                    }
397                    _ => None,
398                }
399            }))
400    }
401
402    /// Load the runtime configuration from the environment and configuration files
403    /// Configuration is priorities in the following order, where the last has the lowest priority:
404    /// 1. Environment variables (top priority)
405    ///    TO DO: Add documentation for configuration files. Paths should be configurable.
406    /// 2. /opt/dynamo/etc/runtime.toml
407    /// 3. /opt/dynamo/defaults/runtime.toml (lowest priority)
408    ///
409    /// Environment variables are prefixed with `DYN_RUNTIME_` and `DYN_SYSTEM`
410    pub fn from_settings() -> Result<RuntimeConfig> {
411        use environment_names::runtime::system as env_system;
412        Self::validate_parser_environment()?;
413        // Check for deprecated environment variables
414        if std::env::var(env_system::DYN_SYSTEM_USE_ENDPOINT_HEALTH_STATUS).is_ok() {
415            tracing::warn!(
416                "DYN_SYSTEM_USE_ENDPOINT_HEALTH_STATUS is deprecated and no longer used. \
417                System health is now determined by endpoints that register with health check payloads. \
418                Please update your configuration to register health check payloads directly on endpoints."
419            );
420        }
421
422        if std::env::var(env_system::DYN_SYSTEM_ENABLED).is_ok() {
423            tracing::warn!(
424                "DYN_SYSTEM_ENABLED is deprecated. \
425                System metrics server is now controlled solely by DYN_SYSTEM_PORT. \
426                Set DYN_SYSTEM_PORT to a positive value to enable the server, or set to -1 to disable (default)."
427            );
428        }
429
430        let config: RuntimeConfig = Self::figment().extract()?;
431        config.validate()?;
432        Ok(config)
433    }
434
435    pub fn validate_parser_environment() -> Result<()> {
436        selected_parser_version().map(|_| ())
437    }
438
439    /// Check if System server should be enabled
440    /// System server is enabled when DYN_SYSTEM_PORT is set to 0 or a positive value
441    /// Port 0 binds to a random available port
442    /// Negative values disable the server
443    pub fn system_server_enabled(&self) -> bool {
444        self.system_port >= 0
445    }
446
447    pub fn single_threaded() -> Self {
448        RuntimeConfig {
449            num_worker_threads: Some(1),
450            max_blocking_threads: 1,
451            system_host: DEFAULT_SYSTEM_HOST.to_string(),
452            system_port: DEFAULT_SYSTEM_PORT,
453            #[allow(deprecated)]
454            system_enabled: false,
455            starting_health_status: HealthStatus::NotReady,
456            use_endpoint_health_status: vec![],
457            system_health_path: DEFAULT_SYSTEM_HEALTH_PATH.to_string(),
458            system_live_path: DEFAULT_SYSTEM_LIVE_PATH.to_string(),
459            compute_threads: Some(1),
460            compute_stack_size: Some(2 * 1024 * 1024),
461            compute_thread_prefix: "compute".to_string(),
462            health_check_enabled: false,
463            canary_wait_time_secs: DEFAULT_CANARY_WAIT_TIME_SECS,
464            health_check_request_timeout_secs: DEFAULT_HEALTH_CHECK_REQUEST_TIMEOUT_SECS,
465        }
466    }
467
468    /// The Tokio builder for this config, not yet built.
469    ///
470    /// Separate from [`Self::create_runtime`] because the pyo3 bridge builds its own runtime:
471    /// `pyo3_async_runtimes::tokio::init` takes a builder and calls `build()` later. Handing it
472    /// this builder is the only way to bound that runtime's size. Both paths go through here so
473    /// they cannot drift apart.
474    pub fn tokio_builder(&self) -> tokio::runtime::Builder {
475        let mut builder = tokio::runtime::Builder::new_multi_thread();
476        builder
477            .worker_threads(
478                self.num_worker_threads
479                    .unwrap_or_else(|| std::thread::available_parallelism().unwrap().get()),
480            )
481            .max_blocking_threads(self.max_blocking_threads)
482            .enable_all();
483        if env_is_truthy(environment_names::runtime::DYN_ENABLE_POLL_HISTOGRAM) {
484            tracing::info!(
485                "Tokio poll-time histogram enabled (DYN_ENABLE_POLL_HISTOGRAM); \
486                 expect ~2× Instant::now() overhead per task poll"
487            );
488            builder.enable_metrics_poll_time_histogram();
489        }
490        builder
491    }
492
493    /// Create a new default runtime configuration
494    pub(crate) fn create_runtime(&self) -> std::io::Result<tokio::runtime::Runtime> {
495        self.tokio_builder().build()
496    }
497}
498
499impl Default for RuntimeConfig {
500    fn default() -> Self {
501        let num_cores = std::thread::available_parallelism().unwrap().get();
502        Self {
503            num_worker_threads: Some(num_cores),
504            max_blocking_threads: num_cores,
505            system_host: DEFAULT_SYSTEM_HOST.to_string(),
506            system_port: DEFAULT_SYSTEM_PORT,
507            #[allow(deprecated)]
508            system_enabled: false,
509            starting_health_status: HealthStatus::NotReady,
510            use_endpoint_health_status: vec![],
511            system_health_path: DEFAULT_SYSTEM_HEALTH_PATH.to_string(),
512            system_live_path: DEFAULT_SYSTEM_LIVE_PATH.to_string(),
513            compute_threads: None,
514            compute_stack_size: Some(2 * 1024 * 1024),
515            compute_thread_prefix: "compute".to_string(),
516            health_check_enabled: false,
517            canary_wait_time_secs: DEFAULT_CANARY_WAIT_TIME_SECS,
518            health_check_request_timeout_secs: DEFAULT_HEALTH_CHECK_REQUEST_TIMEOUT_SECS,
519        }
520    }
521}
522
523impl RuntimeConfigBuilder {
524    /// Build and validate the runtime configuration
525    pub fn build(&self) -> Result<RuntimeConfig> {
526        let config = self.build_internal()?;
527        config.validate()?;
528        Ok(config)
529    }
530}
531
532// Canonical truthy/falsy/bool parsing for user-supplied configuration
533// (environment variables, headers, config values). The single implementation
534// lives in the zero-dependency `dynamo-truthy` crate so that crates which
535// cannot depend on `dynamo-runtime` share it too; this re-export is the
536// canonical import path for everything that can.
537pub use dynamo_truthy::{
538    env_is_falsey, env_is_truthy, is_falsey, is_truthy, parse_bool, parse_bool_opt,
539};
540
541#[derive(Debug, Clone, Copy, PartialEq, Eq)]
542pub enum ConsoleLogFormat {
543    Readable,
544    Jsonl,
545}
546
547impl ConsoleLogFormat {
548    fn from_env_value(value: &str) -> Option<Self> {
549        match value.to_ascii_lowercase().as_str() {
550            "readable" => Some(Self::Readable),
551            "jsonl" => Some(Self::Jsonl),
552            _ => None,
553        }
554    }
555
556    pub fn as_str(self) -> &'static str {
557        match self {
558            Self::Readable => "readable",
559            Self::Jsonl => "jsonl",
560        }
561    }
562}
563
564/// Return whether the legacy `DYN_LOGGING_JSONL` switch is enabled.
565///
566/// This remains a separate compatibility signal because older deployments
567/// also use it to enable local trace-context propagation.
568pub(crate) fn legacy_jsonl_logging_enabled() -> bool {
569    env_is_truthy(environment_names::logging::DYN_LOGGING_JSONL)
570}
571
572/// Return the console log format.
573///
574/// `DYN_LOGGING_CONSOLE_FORMAT` takes precedence. `DYN_LOGGING_JSONL` remains
575/// supported as a legacy fallback when the new setting is unset or blank.
576pub fn console_log_format() -> ConsoleLogFormat {
577    let legacy_format = || {
578        if legacy_jsonl_logging_enabled() {
579            ConsoleLogFormat::Jsonl
580        } else {
581            ConsoleLogFormat::Readable
582        }
583    };
584
585    match std::env::var(environment_names::logging::DYN_LOGGING_CONSOLE_FORMAT) {
586        Ok(value) if value.trim().is_empty() => legacy_format(),
587        Ok(value) => match ConsoleLogFormat::from_env_value(value.trim()) {
588            Some(format) => format,
589            None => {
590                eprintln!(
591                    "Invalid {} value '{}'; using readable console logs",
592                    environment_names::logging::DYN_LOGGING_CONSOLE_FORMAT,
593                    value
594                );
595                ConsoleLogFormat::Readable
596            }
597        },
598        Err(_) => legacy_format(),
599    }
600}
601
602/// Return whether the effective console log format is JSONL.
603pub fn jsonl_logging_enabled() -> bool {
604    console_log_format() == ConsoleLogFormat::Jsonl
605}
606
607/// Check whether logging with ANSI terminal escape codes and colors is disabled.
608/// Set the `DYN_SDK_DISABLE_ANSI_LOGGING` environment variable a [`is_truthy`] value
609pub fn disable_ansi_logging() -> bool {
610    env_is_truthy(environment_names::logging::DYN_SDK_DISABLE_ANSI_LOGGING)
611}
612
613/// Check whether to use local timezone for logging timestamps (default is UTC)
614/// Set the `DYN_LOG_USE_LOCAL_TZ` environment variable to a [`is_truthy`] value
615pub fn use_local_timezone() -> bool {
616    env_is_truthy(environment_names::logging::DYN_LOG_USE_LOCAL_TZ)
617}
618
619/// Returns true if `DYN_LOGGING_SPAN_EVENTS` is set to a truthy value.
620pub fn span_events_enabled() -> bool {
621    env_is_truthy(environment_names::logging::DYN_LOGGING_SPAN_EVENTS)
622}
623
624#[cfg(test)]
625mod tests {
626    use super::*;
627
628    #[test]
629    fn test_runtime_config_builder_overrides_related_fields() -> Result<()> {
630        let config = RuntimeConfig::builder()
631            .num_worker_threads(Some(24))
632            .max_blocking_threads(32)
633            .system_host("127.0.0.1".to_string())
634            .system_port(9090)
635            .build()?;
636
637        assert_eq!(config.num_worker_threads, Some(24));
638        assert_eq!(config.max_blocking_threads, 32);
639        assert_eq!(config.system_host, "127.0.0.1");
640        assert_eq!(config.system_port, 9090);
641        Ok(())
642    }
643
644    /// Both thread-pool variables must survive `from_settings()`.
645    ///
646    /// Covers parsing on its own, so if a frontend's thread count ignores
647    /// `DYN_RUNTIME_MAX_BLOCKING_THREADS` the cause is wiring rather than parsing.
648    ///
649    /// `temp_env::with_vars` restores the old values on the way out, including on panic.
650    #[test]
651    fn test_from_settings_reads_both_thread_env_vars() {
652        const WORKERS: &str = "DYN_RUNTIME_NUM_WORKER_THREADS";
653        const BLOCKING: &str = "DYN_RUNTIME_MAX_BLOCKING_THREADS";
654
655        temp_env::with_vars([(WORKERS, Some("7")), (BLOCKING, Some("11"))], || {
656            let config = RuntimeConfig::from_settings().expect("from_settings failed");
657            assert_eq!(config.num_worker_threads, Some(7), "{WORKERS} was not read");
658            assert_eq!(config.max_blocking_threads, 11, "{BLOCKING} was not read");
659        });
660    }
661
662    #[test]
663    fn parser_selection_configuration_table() {
664        const CHILD: &str = "DYNAMO_PARSER_SELECTION_TEST_CHILD";
665        const EXPECTED: &str = "DYNAMO_PARSER_SELECTION_EXPECTED";
666        use environment_names::llm::{DYN_ENABLE_EXPERIMENTAL_PARSERS_V2, DYN_PARSER_VERSION};
667
668        if std::env::var_os(CHILD).is_some() {
669            let expected = std::env::var(EXPECTED).expect("expected selection");
670            let result = selected_parser_version();
671            match expected.as_str() {
672                "Auto" => assert_eq!(result.unwrap(), ParserVersion::Auto),
673                "V1" => assert_eq!(result.unwrap(), ParserVersion::V1),
674                "V2" => assert_eq!(result.unwrap(), ParserVersion::V2),
675                "conflict" => {
676                    let error = result.unwrap_err().to_string();
677                    assert!(error.contains(DYN_ENABLE_EXPERIMENTAL_PARSERS_V2));
678                    assert!(error.contains(DYN_PARSER_VERSION));
679                }
680                "invalid" => {
681                    assert!(result.is_err());
682                    assert!(RuntimeConfig::from_settings().is_err());
683                }
684                other => panic!("unexpected expected selection: {other}"),
685            }
686            return;
687        }
688
689        let cases = [
690            (None, None, "Auto"),
691            (Some("false"), None, "Auto"),
692            (Some("0"), None, "Auto"),
693            (Some("off"), None, "Auto"),
694            (Some("no"), None, "Auto"),
695            (Some(""), None, "Auto"),
696            (Some("true"), None, "V2"),
697            (Some("1"), None, "V2"),
698            (Some("on"), None, "V2"),
699            (Some("yes"), None, "V2"),
700            (None, Some("auto"), "Auto"),
701            (None, Some("1"), "V1"),
702            (None, Some("v1"), "invalid"),
703            (None, Some("2"), "V2"),
704            (None, Some("v2"), "invalid"),
705            (Some("false"), Some("auto"), "Auto"),
706            (Some("true"), Some("auto"), "V2"),
707            (Some("false"), Some("1"), "V1"),
708            (Some("false"), Some("2"), "conflict"),
709            (Some("true"), Some("1"), "conflict"),
710            (Some("true"), Some("2"), "V2"),
711            (Some("false"), Some("v1"), "invalid"),
712            (Some("false"), Some("v2"), "invalid"),
713            (Some("true"), Some("v1"), "invalid"),
714            (Some("true"), Some("v2"), "invalid"),
715            (Some("perhaps"), None, "invalid"),
716            (None, Some(""), "invalid"),
717            (None, Some("3"), "invalid"),
718        ];
719        for (experimental, version, expected) in cases {
720            let mut command = crate::test_utils::isolated_command(
721                "config::tests::parser_selection_configuration_table",
722            );
723            command.env(CHILD, "1").env(EXPECTED, expected).env(
724                DYN_ENABLE_EXPERIMENTAL_PARSERS_V2,
725                experimental.unwrap_or(""),
726            );
727            if experimental.is_none() {
728                command.env_remove(DYN_ENABLE_EXPERIMENTAL_PARSERS_V2);
729            }
730            if let Some(version) = version {
731                command.env(DYN_PARSER_VERSION, version);
732            } else {
733                command.env_remove(DYN_PARSER_VERSION);
734            }
735            let output = command
736                .output()
737                .expect("run isolated parser selection test");
738            crate::test_utils::assert_isolated_success(&output);
739        }
740    }
741
742    #[test]
743    fn parser_selection_is_cached_for_process_lifetime() {
744        const CHILD: &str = "DYNAMO_PARSER_SELECTION_CACHE_CHILD";
745        if std::env::var_os(CHILD).is_none() {
746            let output = crate::test_utils::isolated_command(
747                "config::tests::parser_selection_is_cached_for_process_lifetime",
748            )
749            .env(CHILD, "1")
750            .env(
751                environment_names::llm::DYN_ENABLE_EXPERIMENTAL_PARSERS_V2,
752                "true",
753            )
754            .output()
755            .expect("run isolated parser cache test");
756            crate::test_utils::assert_isolated_success(&output);
757            return;
758        }
759
760        assert_eq!(selected_parser_version().unwrap(), ParserVersion::V2);
761        temp_env::with_vars(
762            [
763                (
764                    environment_names::llm::DYN_ENABLE_EXPERIMENTAL_PARSERS_V2,
765                    None,
766                ),
767                (environment_names::llm::DYN_PARSER_VERSION, Some("1")),
768            ],
769            || {
770                assert_eq!(selected_parser_version().unwrap(), ParserVersion::V2);
771            },
772        );
773    }
774
775    #[test]
776    fn parser_selection_warning_is_emitted_once_and_startup_rejects_conflicts() {
777        const CHILD: &str = "DYNAMO_PARSER_SELECTION_WARNING_CHILD";
778        const MODE: &str = "DYNAMO_PARSER_SELECTION_WARNING_MODE";
779        if std::env::var_os(CHILD).is_none() {
780            let warning = crate::test_utils::isolated_command(
781                "config::tests::parser_selection_warning_is_emitted_once_and_startup_rejects_conflicts",
782            )
783            .env(CHILD, "1")
784            .env(MODE, "warning")
785            .env(environment_names::llm::DYN_ENABLE_EXPERIMENTAL_PARSERS_V2, "true")
786            .output()
787            .expect("run isolated parser warning test");
788            crate::test_utils::assert_isolated_success(&warning);
789            let stderr = String::from_utf8_lossy(&warning.stderr);
790            assert_eq!(
791                stderr
792                    .matches("experimental V2 parser routing setting is present")
793                    .count(),
794                1,
795                "{stderr}"
796            );
797            assert!(stderr.contains("value=\"true\""), "{stderr}");
798            assert!(stderr.contains("resolved_selection=V2"), "{stderr}");
799
800            let conflict = crate::test_utils::isolated_command(
801                "config::tests::parser_selection_warning_is_emitted_once_and_startup_rejects_conflicts",
802            )
803            .env(CHILD, "1")
804            .env(MODE, "conflict")
805            .env(environment_names::llm::DYN_ENABLE_EXPERIMENTAL_PARSERS_V2, "true")
806            .env(environment_names::llm::DYN_PARSER_VERSION, "1")
807            .output()
808            .expect("run isolated parser startup conflict test");
809            crate::test_utils::assert_isolated_success(&conflict);
810            let stderr = String::from_utf8_lossy(&conflict.stderr);
811            assert_eq!(
812                stderr
813                    .matches("experimental V2 parser routing setting is present")
814                    .count(),
815                1,
816                "{stderr}"
817            );
818            return;
819        }
820
821        match std::env::var(MODE).as_deref() {
822            Ok("warning") => {
823                let subscriber = tracing_subscriber::fmt()
824                    .with_ansi(false)
825                    .with_writer(std::io::stderr)
826                    .finish();
827                tracing::subscriber::with_default(subscriber, || {
828                    assert_eq!(selected_parser_version().unwrap(), ParserVersion::V2);
829                    assert_eq!(selected_parser_version().unwrap(), ParserVersion::V2);
830                });
831            }
832            Ok("conflict") => {
833                let subscriber = tracing_subscriber::fmt()
834                    .with_ansi(false)
835                    .with_writer(std::io::stderr)
836                    .finish();
837                tracing::subscriber::with_default(subscriber, || {
838                    let error = RuntimeConfig::from_settings().unwrap_err().to_string();
839                    assert!(error.contains("DYN_ENABLE_EXPERIMENTAL_PARSERS_V2"));
840                    assert!(error.contains("DYN_PARSER_VERSION"));
841                });
842            }
843            value => panic!("unexpected test mode: {value:?}"),
844        }
845    }
846
847    /// The builder given to the pyo3 bridge must carry the configured worker count.
848    ///
849    /// The bridge calls `build()` itself, so nothing on our side sees the resulting runtime. If
850    /// this stopped applying the config, a bridge-built runtime would quietly go back to one
851    /// worker per CPU — the original bug, in a place no other test looks.
852    #[test]
853    fn test_tokio_builder_applies_configured_worker_threads() -> Result<()> {
854        let config = RuntimeConfig::builder()
855            .num_worker_threads(Some(3))
856            .max_blocking_threads(5)
857            .build()?;
858
859        let runtime = config.tokio_builder().build()?;
860        assert_eq!(runtime.metrics().num_workers(), 3);
861        Ok(())
862    }
863
864    /// With `num_worker_threads` unset, the builder falls back to the core count.
865    #[test]
866    fn test_tokio_builder_defaults_worker_threads_to_core_count() -> Result<()> {
867        let config = RuntimeConfig {
868            num_worker_threads: None,
869            ..RuntimeConfig::default()
870        };
871
872        let runtime = config.tokio_builder().build()?;
873        assert_eq!(
874            runtime.metrics().num_workers(),
875            std::thread::available_parallelism()?.get()
876        );
877        Ok(())
878    }
879
880    /// `max_blocking_threads` must actually cap concurrent blocking work.
881    ///
882    /// This is the setting whose effect on a frontend's thread count could not be observed, and
883    /// `num_workers()` cannot show it — Tokio counts blocking threads separately and only
884    /// exposes that count under `tokio_unstable`. Measuring concurrency works on stable instead:
885    /// blocking threads are spawned on demand up to the cap, so queueing more tasks than the cap
886    /// must serialize them.
887    ///
888    /// Only the upper bound is asserted. A missing cap shows up as a peak near the task count,
889    /// while asserting a lower bound would make the test depend on the scheduler overlapping
890    /// tasks, which a loaded CI machine need not do.
891    #[test]
892    fn test_tokio_builder_applies_max_blocking_threads() -> Result<()> {
893        use std::sync::Arc;
894        use std::sync::atomic::{AtomicUsize, Ordering};
895
896        const CAP: usize = 2;
897
898        let config = RuntimeConfig::builder()
899            .num_worker_threads(Some(2))
900            .max_blocking_threads(CAP)
901            .build()?;
902        let runtime = config.tokio_builder().build()?;
903
904        let in_flight = Arc::new(AtomicUsize::new(0));
905        let peak = Arc::new(AtomicUsize::new(0));
906
907        runtime.block_on(async {
908            let tasks: Vec<_> = (0..CAP * 4)
909                .map(|_| {
910                    let in_flight = Arc::clone(&in_flight);
911                    let peak = Arc::clone(&peak);
912                    tokio::task::spawn_blocking(move || {
913                        let now = in_flight.fetch_add(1, Ordering::SeqCst) + 1;
914                        peak.fetch_max(now, Ordering::SeqCst);
915                        // Long enough that tasks overlap if the cap allows it.
916                        std::thread::sleep(std::time::Duration::from_millis(20));
917                        in_flight.fetch_sub(1, Ordering::SeqCst);
918                    })
919                })
920                .collect();
921
922            for task in tasks {
923                task.await.expect("blocking task panicked");
924            }
925        });
926
927        let observed = peak.load(Ordering::SeqCst);
928        assert!(
929            observed <= CAP,
930            "{observed} blocking tasks ran at once, but the cap was {CAP}"
931        );
932        Ok(())
933    }
934
935    /// `Default` sets `max_blocking_threads` to the core count, not the `#[builder(default)]`
936    /// of 512 — that applies only when building through `RuntimeConfigBuilder`.
937    #[test]
938    fn test_default_max_blocking_threads_is_core_count() {
939        let cores = std::thread::available_parallelism().unwrap().get();
940        let config = RuntimeConfig::default();
941        assert_eq!(config.max_blocking_threads, cores);
942        assert_eq!(config.num_worker_threads, Some(cores));
943    }
944
945    #[test]
946    fn test_runtime_config_rejects_invalid_thread_count() -> Result<()> {
947        let result = RuntimeConfig::builder()
948            .num_worker_threads(Some(0))
949            .max_blocking_threads(0)
950            .build();
951
952        let error = result.unwrap_err().to_string();
953        assert!(error.contains("num_worker_threads: Validation error"));
954        assert!(error.contains("max_blocking_threads: Validation error"));
955        Ok(())
956    }
957
958    #[test]
959    fn test_system_server_enabled_by_nonnegative_port() {
960        let mut config = RuntimeConfig::default();
961        for (port, enabled) in [(-1, false), (0, true), (9527, true)] {
962            config.system_port = port;
963            assert_eq!(config.system_server_enabled(), enabled);
964        }
965    }
966
967    #[test]
968    fn test_is_truthy_and_falsey() {
969        // Test truthy values
970        assert!(is_truthy("1"));
971        assert!(is_truthy("true"));
972        assert!(is_truthy("TRUE"));
973        assert!(is_truthy("on"));
974        assert!(is_truthy("yes"));
975
976        // Test falsey values
977        assert!(is_falsey("0"));
978        assert!(is_falsey("false"));
979        assert!(is_falsey("FALSE"));
980        assert!(is_falsey("off"));
981        assert!(is_falsey("no"));
982
983        // Test opposite behavior
984        assert!(!is_truthy("0"));
985        assert!(!is_falsey("1"));
986    }
987
988    #[test]
989    fn test_console_log_format() {
990        use environment_names::logging;
991
992        for (console_format, legacy_jsonl, expected) in [
993            (None, None, ConsoleLogFormat::Readable),
994            (None, Some("true"), ConsoleLogFormat::Jsonl),
995            (Some(""), Some("true"), ConsoleLogFormat::Jsonl),
996            (Some("   "), Some("true"), ConsoleLogFormat::Jsonl),
997            (Some(" jsonl "), Some("false"), ConsoleLogFormat::Jsonl),
998            (Some("readable"), Some("true"), ConsoleLogFormat::Readable),
999            (Some("jsonl"), Some("false"), ConsoleLogFormat::Jsonl),
1000            (
1001                Some("unsupported"),
1002                Some("true"),
1003                ConsoleLogFormat::Readable,
1004            ),
1005        ] {
1006            temp_env::with_vars(
1007                [
1008                    (logging::DYN_LOGGING_CONSOLE_FORMAT, console_format),
1009                    (logging::DYN_LOGGING_JSONL, legacy_jsonl),
1010                ],
1011                || {
1012                    assert_eq!(console_log_format(), expected);
1013                    assert_eq!(jsonl_logging_enabled(), expected == ConsoleLogFormat::Jsonl);
1014                },
1015            );
1016        }
1017    }
1018}