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")]
55 pub atlas: Option<String>,
56
57 #[arg(long, env = "ROBONIX_PILOT_LISTEN")]
59 pub listen: Option<String>,
60
61 #[arg(long, env = "ROBONIX_PILOT_PROVIDER_ID")]
63 pub id: Option<String>,
64
65 #[arg(long, env = "ROBONIX_VLM_UPSTREAM")]
67 pub vlm_upstream: Option<String>,
68
69 #[arg(long, env = "ROBONIX_VLM_API_KEY")]
71 pub vlm_api_key: Option<String>,
72
73 #[arg(long, env = "ROBONIX_VLM_MODEL")]
75 pub vlm_model: Option<String>,
76
77 #[arg(long, env = "ROBONIX_VLM_FORMAT")]
79 pub vlm_format: Option<String>,
80
81 #[arg(long, env = "ROBONIX_CONFIG_PATH")]
83 pub config: Option<PathBuf>,
84
85 #[arg(long)]
88 pub log: Option<String>,
89}
90
91#[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 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}