Skip to main content

robonix_pilot/
config.rs

1// SPDX-License-Identifier: MulanPSL-2.0
2// Author: wheatfox <wheatfox17@icloud.com>
3//
4// Pilot config: how a launched pilot process figures out where atlas is,
5// what address to bind, and how to reach the LLM upstream.
6//
7// Three sources, from lowest to highest priority:
8//   1. compiled defaults (atlas endpoint, listen address, id, …)
9//   2. optional YAML at `$ROBONIX_CONFIG_PATH` or `--config <path>`
10//      (used by `rbnx boot` to write a slice of `system.pilot` from
11//      `robonix_manifest.yaml`)
12//   3. CLI flags / per-field env vars
13//
14// Higher-priority source overrides lower. Manual launch only needs the
15// minimum: an atlas endpoint, a VLM upstream URL, and an API key.
16
17use anyhow::{Context, Result, bail};
18use clap::Parser;
19use serde::Deserialize;
20use std::path::{Path, PathBuf};
21
22pub const DEFAULT_PILOT_PROVIDER_ID: &str = "pilot";
23pub const PILOT_NAMESPACE: &str = "robonix/system/pilot";
24pub const DEFAULT_ATLAS_ENDPOINT: &str = "127.0.0.1:50051";
25pub const DEFAULT_LISTEN: &str = "127.0.0.1:50071";
26pub const DEFAULT_VLM_FORMAT: &str = "openai";
27
28/// Fully-resolved settings the pilot binary runs against.
29#[derive(Debug, Clone)]
30pub struct PilotConfig {
31    pub atlas_endpoint: String,
32    pub listen: String,
33    pub id: String,
34    pub vlm: VlmConfig,
35}
36
37#[derive(Debug, Clone)]
38pub struct VlmConfig {
39    pub upstream: String,
40    pub api_key: String,
41    pub model: String,
42    /// Wire dialect. Currently only "openai" is implemented; checked at
43    /// `resolve` time, kept on the struct for diagnostics / future routing.
44    #[allow(dead_code)]
45    pub api_format: String,
46}
47
48/// CLI surface; every field is optional so config-file mode stays usable
49/// without spelling out flags. clap also reads the listed env vars.
50#[derive(Parser, Debug)]
51#[command(name = "robonix-pilot", about = "Robonix Pilot — VLM planner")]
52pub struct Args {
53    /// Atlas control-plane endpoint.
54    #[arg(long, env = "ROBONIX_ATLAS_ENDPOINT")]
55    pub atlas: Option<String>,
56
57    /// Address pilot's SystemPilot gRPC binds to.
58    #[arg(long, env = "ROBONIX_PILOT_LISTEN")]
59    pub listen: Option<String>,
60
61    /// Override pilot's id (singleton, rarely needed).
62    #[arg(long, env = "ROBONIX_PILOT_PROVIDER_ID")]
63    pub id: Option<String>,
64
65    /// LLM API base URL (e.g. "https://api.openai.com/v1").
66    #[arg(long, env = "ROBONIX_VLM_UPSTREAM")]
67    pub vlm_upstream: Option<String>,
68
69    /// LLM API key.
70    #[arg(long, env = "ROBONIX_VLM_API_KEY")]
71    pub vlm_api_key: Option<String>,
72
73    /// LLM model identifier.
74    #[arg(long, env = "ROBONIX_VLM_MODEL")]
75    pub vlm_model: Option<String>,
76
77    /// LLM API dialect ("openai" only for now).
78    #[arg(long, env = "ROBONIX_VLM_FORMAT")]
79    pub vlm_format: Option<String>,
80
81    /// YAML config file (rbnx writes this; CLI/env still override individual fields).
82    #[arg(long, env = "ROBONIX_CONFIG_PATH")]
83    pub config: Option<PathBuf>,
84
85    /// Log filter (env_logger syntax; e.g. `info`, `robonix_pilot=debug`).
86    /// Default: `robonix_pilot=info`. Falls back to `RUST_LOG` if neither set.
87    #[arg(long)]
88    pub log: Option<String>,
89}
90
91/// Optional YAML schema. Field names match `PilotConfig` (flat) so a
92/// hand-written file looks like the manifest's `system.pilot` block.
93#[derive(Default, Deserialize)]
94struct FileConfig {
95    #[serde(default)]
96    atlas_endpoint: Option<String>,
97    #[serde(default)]
98    listen: Option<String>,
99    #[serde(default)]
100    id: Option<String>,
101    #[serde(default)]
102    vlm: Option<FileVlmConfig>,
103}
104
105#[derive(Default, Deserialize)]
106struct FileVlmConfig {
107    #[serde(default)]
108    upstream: Option<String>,
109    #[serde(default)]
110    api_key: Option<String>,
111    #[serde(default)]
112    model: Option<String>,
113    #[serde(default)]
114    api_format: Option<String>,
115}
116
117impl PilotConfig {
118    /// Build the resolved config from CLI args (which already pulled env
119    /// vars). Reads optional YAML; CLI/env still override file fields.
120    pub fn resolve(args: Args) -> Result<Self> {
121        let file_cfg: FileConfig = match &args.config {
122            Some(path) => load_yaml(path)?,
123            None => FileConfig::default(),
124        };
125        let file_vlm = file_cfg.vlm.unwrap_or_default();
126
127        let atlas_endpoint = args
128            .atlas
129            .or(file_cfg.atlas_endpoint)
130            .unwrap_or_else(|| DEFAULT_ATLAS_ENDPOINT.to_string());
131        let listen = args
132            .listen
133            .or(file_cfg.listen)
134            .unwrap_or_else(|| DEFAULT_LISTEN.to_string());
135        let id = args
136            .id
137            .or(file_cfg.id)
138            .unwrap_or_else(|| DEFAULT_PILOT_PROVIDER_ID.to_string());
139        let api_format = args
140            .vlm_format
141            .or(file_vlm.api_format)
142            .unwrap_or_else(|| DEFAULT_VLM_FORMAT.to_string());
143        if api_format != "openai" {
144            bail!("vlm api_format='{api_format}' not supported (only 'openai')");
145        }
146
147        let upstream = args
148            .vlm_upstream
149            .or(file_vlm.upstream)
150            .filter(|s| !s.trim().is_empty())
151            .ok_or_else(|| {
152                missing_field("vlm.upstream", "ROBONIX_VLM_UPSTREAM", "--vlm-upstream")
153            })?;
154        let api_key = args
155            .vlm_api_key
156            .or(file_vlm.api_key)
157            .filter(|s| !s.trim().is_empty())
158            .ok_or_else(|| missing_field("vlm.api_key", "ROBONIX_VLM_API_KEY", "--vlm-api-key"))?;
159        let model = args
160            .vlm_model
161            .or(file_vlm.model)
162            .filter(|s| !s.trim().is_empty())
163            .ok_or_else(|| missing_field("vlm.model", "ROBONIX_VLM_MODEL", "--vlm-model"))?;
164
165        Ok(Self {
166            atlas_endpoint,
167            listen,
168            id,
169            vlm: VlmConfig {
170                upstream,
171                api_key,
172                model,
173                api_format,
174            },
175        })
176    }
177}
178
179fn load_yaml(path: &Path) -> Result<FileConfig> {
180    let raw = std::fs::read_to_string(path)
181        .with_context(|| format!("read pilot config '{}'", path.display()))?;
182    serde_yaml::from_str(&raw).with_context(|| format!("parse pilot config '{}'", path.display()))
183}
184
185fn missing_field(yaml_path: &str, env_var: &str, flag: &str) -> anyhow::Error {
186    anyhow::anyhow!(
187        "missing required field '{yaml_path}': set it in --config YAML, env {env_var}, or pass {flag}"
188    )
189}