Skip to main content

supercode_harness/
model_route.rs

1//! A model route: where an agent's model calls go, and with which key
2//! reference (`docs/architecture/content-spec-status.md`: spec, declared by the
3//! author, reaching the harness through its own verb).
4//!
5//! Hermes keeps the route in its `config.yaml` `model:` block (`provider`,
6//! `base_url`, `api_key`, `default`, `api_mode`). `apply` reads that block from
7//! the harness's own file, runs `hermes config set model.<field> <value>` for
8//! each declared field that differs, and answers from the file re-read. The key
9//! is only ever a reference: it is written as `${NAME}`, which Hermes keeps
10//! unresolved in the file and resolves from its environment at call time, so no
11//! credential value is written, read back or returned.
12//!
13//! Hermes is the only harness with a route verb supercode drives at the pin;
14//! OpenClaw and the orchestrator refuse.
15
16use 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/// One declared model route.
26#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
27#[serde(default, deny_unknown_fields)]
28pub struct ModelRouteApply {
29    /// Harness the route is applied to.
30    pub harness: String,
31    /// Hermes profile the route belongs to; absent means the root home.
32    pub profile: Option<String>,
33    /// Provider kind as the harness names it (Hermes: `custom` for an
34    /// OpenAI-compatible endpoint).
35    pub provider: Option<String>,
36    /// Where model calls go: a URL, or a `${NAME}` the harness resolves.
37    pub base_url: Option<String>,
38    /// The key, by reference only: an environment variable name.
39    pub key_env: Option<String>,
40    /// The default model.
41    pub model: Option<String>,
42    /// The wire the endpoint speaks (Hermes: `chat_completions`).
43    pub api_mode: Option<String>,
44    /// Report what would run without running it.
45    pub plan: bool,
46    /// Storage roots.
47    pub homes: HarnessHomes,
48}
49
50/// What a route apply did.
51#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
52pub struct ModelRouteOutcome {
53    /// Harness the route was applied to.
54    pub harness: String,
55    /// The harness commands that ran (or, with `plan`, would run).
56    pub ran: Vec<String>,
57    /// Fields already as declared.
58    pub unchanged: Vec<String>,
59    /// The route as the harness's own file holds it afterwards; `api_key` is
60    /// the reference as written, never a resolved value.
61    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
73/// The route's fields in Hermes's `model:` block.
74const ROUTE_FIELDS: [&str; 5] = ["provider", "base_url", "api_key", "default", "api_mode"];
75
76/// A field's value as text: Hermes's YAML stores a numeric-looking model id
77/// (`1.5`) as a number.
78fn 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
87/// The route fields of the harness's own `config.yaml`, as written.
88fn 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    // Only the route's own fields are read back; the rest of the block (custom
98    // headers, provider options) can carry credentials and is never answered.
99    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    // A key someone wrote into the file by value is never echoed back.
108    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
119/// Apply a declared model route through the harness's own config verb.
120pub 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}