supercode_harness/
model_route.rs1use std::path::PathBuf;
17
18use serde::{Deserialize, Serialize};
19use serde_json::Value;
20
21use crate::harness_command::HarnessCommand;
22use crate::jobs_control::{harness_program, hermes_home, JobControlError, JobMutation};
23use crate::{HarnessHomes, HarnessId};
24
25#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
27#[serde(default, deny_unknown_fields)]
28pub struct ModelRouteApply {
29 pub harness: String,
31 pub profile: Option<String>,
33 pub provider: Option<String>,
36 pub base_url: Option<String>,
38 pub key_env: Option<String>,
40 pub model: Option<String>,
42 pub api_mode: Option<String>,
44 pub plan: bool,
46 pub homes: HarnessHomes,
48}
49
50#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
52pub struct ModelRouteOutcome {
53 pub harness: String,
55 pub ran: Vec<String>,
57 pub unchanged: Vec<String>,
59 pub route: Value,
62}
63
64fn home(request: &ModelRouteApply) -> PathBuf {
65 hermes_home(&JobMutation {
66 harness: request.harness.clone(),
67 profile: request.profile.clone(),
68 homes: request.homes.clone(),
69 ..JobMutation::default()
70 })
71}
72
73const ROUTE_FIELDS: [&str; 5] = ["provider", "base_url", "api_key", "default", "api_mode"];
75
76fn as_text(value: Option<&Value>) -> Option<String> {
79 match value? {
80 Value::String(text) => Some(text.clone()),
81 Value::Number(number) => Some(number.to_string()),
82 Value::Bool(flag) => Some(flag.to_string()),
83 _ => None,
84 }
85}
86
87fn read_route(home: &std::path::Path) -> Result<Value, JobControlError> {
89 let path = home.join("config.yaml");
90 if !path.exists() {
91 return Ok(Value::Object(Default::default()));
92 }
93 let text = std::fs::read_to_string(&path)
94 .map_err(|error| JobControlError::Failed(format!("{}: {error}", path.display())))?;
95 let config: Value = serde_yaml::from_str(&text)
96 .map_err(|error| JobControlError::Failed(format!("{}: {error}", path.display())))?;
97 let block = config.get("model").cloned().unwrap_or(Value::Null);
100 let mut route = serde_json::Map::new();
101 for field in ROUTE_FIELDS {
102 if let Some(value) = block.get(field) {
103 route.insert(field.to_string(), value.clone());
104 }
105 }
106 let mut route = Value::Object(route);
107 if let Some(key) = route.get_mut("api_key") {
109 let is_ref = key
110 .as_str()
111 .is_some_and(|k| k.starts_with("${") && k.ends_with('}'));
112 if !is_ref && !key.is_null() {
113 *key = Value::String("<redacted: a value, not a reference>".into());
114 }
115 }
116 Ok(route)
117}
118
119pub fn apply(request: &ModelRouteApply) -> Result<ModelRouteOutcome, JobControlError> {
121 if request.harness != HarnessId::HERMES {
122 return Err(JobControlError::Unsupported(format!(
123 "`{}` has no model-route verb supercode drives; a model route is supported for: {}",
124 request.harness,
125 HarnessId::HERMES
126 )));
127 }
128 let key_ref = match request.key_env.as_deref().map(str::trim) {
129 Some(name)
130 if !name.is_empty()
131 && name.starts_with(|c: char| c.is_ascii_uppercase() || c == '_')
132 && name.chars().all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_') =>
133 {
134 Some(format!("${{{name}}}"))
135 }
136 Some(other) => {
137 return Err(JobControlError::Invalid(format!(
138 "`key_env` names an environment variable (e.g. OPEN_AUTONOMY_KEY), never a key; got `{}`",
139 if other.len() > 8 { "<redacted>" } else { other }
140 )))
141 }
142 None => None,
143 };
144 if let Some(url) = request.base_url.as_deref() {
145 let authority = url
146 .split("://")
147 .nth(1)
148 .unwrap_or(url)
149 .split('/')
150 .next()
151 .unwrap_or("");
152 let query_credential = url.split_once('?').is_some_and(|(_, query)| {
153 query.split('&').any(|pair| {
154 let name = pair.split('=').next().unwrap_or("").to_ascii_lowercase();
155 ["key", "token", "secret", "auth", "password", "signature"]
156 .iter()
157 .any(|word| name.contains(word))
158 })
159 });
160 if authority.contains('@') || query_credential {
161 return Err(JobControlError::Invalid(
162 "`base_url` carries credentials; name the key with `key_env` instead".into(),
163 ));
164 }
165 }
166 let declared: Vec<(&str, Option<String>)> = vec![
167 ("provider", request.provider.clone()),
168 ("base_url", request.base_url.clone()),
169 ("api_key", key_ref),
170 ("default", request.model.clone()),
171 ("api_mode", request.api_mode.clone()),
172 ];
173 let home = home(request);
174 let before = read_route(&home)?;
175 let mut outcome = ModelRouteOutcome {
176 harness: request.harness.clone(),
177 ..ModelRouteOutcome::default()
178 };
179 let mut commands = Vec::new();
180 for (field, value) in &declared {
181 let Some(value) = value.as_deref().map(str::trim).filter(|v| !v.is_empty()) else {
182 continue;
183 };
184 if as_text(before.get(field)).as_deref() == Some(value) {
185 outcome.unchanged.push((*field).to_string());
186 continue;
187 }
188 let mut command = HarnessCommand::new(harness_program(HarnessId::HERMES)?);
189 command.env("HERMES_HOME", home.to_string_lossy());
190 command.args(["config", "set", &format!("model.{field}"), value]);
191 commands.push((*field, value.to_string(), command));
192 }
193 for (_, _, command) in &commands {
194 outcome.ran.push(command.narrate());
195 }
196 if request.plan {
197 outcome.route = before;
198 return Ok(outcome);
199 }
200 for (index, (_, _, command)) in commands.iter().enumerate() {
201 command.run().map_err(|error| {
202 JobControlError::Failed(format!(
203 "{error} (already ran: {})",
204 if index == 0 {
205 "nothing".to_string()
206 } else {
207 outcome.ran[..index].join("; ")
208 }
209 ))
210 })?;
211 }
212 let after = read_route(&home)?;
213 for (field, value, _) in &commands {
214 if as_text(after.get(field)).as_deref() != Some(value.as_str()) {
215 return Err(JobControlError::Failed(format!(
216 "`hermes config set model.{field}` exited but the file does not hold the declared value afterwards"
217 )));
218 }
219 }
220 outcome.route = after;
221 Ok(outcome)
222}