1use 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#[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 #[allow(dead_code)]
45 pub api_format: String,
46}
47
48#[derive(Parser, Debug)]
51#[command(name = "robonix-pilot", about = "Robonix Pilot — VLM planner")]
52pub struct Args {
53 #[arg(long, env = "ROBONIX_ATLAS_ENDPOINT")]
56 pub atlas: Option<String>,
57
58 #[arg(long, env = "ROBONIX_PILOT_LISTEN")]
60 pub listen: Option<String>,
61
62 #[arg(long, env = "ROBONIX_PILOT_PROVIDER_ID")]
64 pub id: Option<String>,
65
66 #[arg(long, env = "ROBONIX_VLM_UPSTREAM")]
68 pub vlm_upstream: Option<String>,
69
70 #[arg(long, env = "ROBONIX_VLM_API_KEY")]
72 pub vlm_api_key: Option<String>,
73
74 #[arg(long, env = "ROBONIX_VLM_MODEL")]
76 pub vlm_model: Option<String>,
77
78 #[arg(long, env = "ROBONIX_VLM_FORMAT")]
80 pub vlm_format: Option<String>,
81
82 #[arg(long, env = "ROBONIX_CONFIG_PATH")]
84 pub config: Option<PathBuf>,
85
86 #[arg(long)]
90 pub log: Option<String>,
91
92 #[arg(long)]
97 pub config_json: Option<String>,
98}
99
100#[derive(Default, Deserialize)]
103struct FileConfig {
104 #[serde(default)]
105 atlas_endpoint: Option<String>,
106 #[serde(default)]
107 listen: Option<String>,
108 #[serde(default)]
109 id: Option<String>,
110 #[serde(default)]
111 vlm: Option<FileVlmConfig>,
112}
113
114#[derive(Default, Deserialize)]
115struct FileVlmConfig {
116 #[serde(default)]
117 upstream: Option<String>,
118 #[serde(default)]
119 api_key: Option<String>,
120 #[serde(default)]
121 model: Option<String>,
122 #[serde(default)]
123 api_format: Option<String>,
124}
125
126impl PilotConfig {
127 pub fn resolve(args: Args) -> Result<Self> {
130 let file_cfg: FileConfig = match &args.config {
131 Some(path) => load_yaml(path)?,
132 None => FileConfig::default(),
133 };
134 let file_vlm = file_cfg.vlm.unwrap_or_default();
135
136 let atlas_endpoint = args
137 .atlas
138 .or_else(env_atlas)
139 .or(file_cfg.atlas_endpoint)
140 .unwrap_or_else(|| DEFAULT_ATLAS_ENDPOINT.to_string());
141 let listen = args
142 .listen
143 .or(file_cfg.listen)
144 .unwrap_or_else(|| DEFAULT_LISTEN.to_string());
145 let id = args
146 .id
147 .or(file_cfg.id)
148 .unwrap_or_else(|| DEFAULT_PILOT_PROVIDER_ID.to_string());
149 let api_format = args
150 .vlm_format
151 .or(file_vlm.api_format)
152 .unwrap_or_else(|| DEFAULT_VLM_FORMAT.to_string());
153 if api_format != "openai" {
154 bail!("vlm api_format='{api_format}' not supported (only 'openai')");
155 }
156
157 let upstream = args
158 .vlm_upstream
159 .or(file_vlm.upstream)
160 .filter(|s| !s.trim().is_empty())
161 .ok_or_else(|| {
162 missing_field("vlm.upstream", "ROBONIX_VLM_UPSTREAM", "--vlm-upstream")
163 })?;
164 let api_key = args
165 .vlm_api_key
166 .or(file_vlm.api_key)
167 .filter(|s| !s.trim().is_empty())
168 .ok_or_else(|| missing_field("vlm.api_key", "ROBONIX_VLM_API_KEY", "--vlm-api-key"))?;
169 let model = args
170 .vlm_model
171 .or(file_vlm.model)
172 .filter(|s| !s.trim().is_empty())
173 .ok_or_else(|| missing_field("vlm.model", "ROBONIX_VLM_MODEL", "--vlm-model"))?;
174
175 Ok(Self {
176 atlas_endpoint,
177 listen,
178 id,
179 vlm: VlmConfig {
180 upstream,
181 api_key,
182 model,
183 api_format,
184 },
185 })
186 }
187}
188
189fn env_atlas() -> Option<String> {
201 std::env::var("ROBONIX_ATLAS")
202 .ok()
203 .filter(|v| !v.is_empty())
204}
205
206fn load_yaml(path: &Path) -> Result<FileConfig> {
207 let raw = std::fs::read_to_string(path)
208 .with_context(|| format!("read pilot config '{}'", path.display()))?;
209 serde_yaml::from_str(&raw).with_context(|| format!("parse pilot config '{}'", path.display()))
210}
211
212fn missing_field(yaml_path: &str, env_var: &str, flag: &str) -> anyhow::Error {
213 anyhow::anyhow!(
214 "missing required field '{yaml_path}': set it in --config YAML, env {env_var}, or pass {flag}"
215 )
216}