Skip to main content

embacle_server/uhp/
harnesses.rs

1// ABOUTME: GET /v1/harnesses, /v1/harnesses/{id} and the UHP model catalogue endpoints
2// ABOUTME: Each installed embacle runner is advertised as one harness; availability is probed
3//
4// SPDX-License-Identifier: Apache-2.0
5// Copyright (c) 2026 dravr.ai
6
7//! Harnesses and their models.
8//!
9//! embacle already runs twelve CLI harnesses behind one trait, which is exactly
10//! what UHP calls a **runner**: a server that puts existing harnesses behind the
11//! contract and advertises them as its catalog. So a harness here is one
12//! embacle provider whose binary is actually installed.
13//!
14//! `available` is computed, never asserted. The specification is blunt about
15//! why: listing a model as available and then failing the task is the worst
16//! outcome for a client, because a user has already chosen it.
17
18use std::collections::BTreeMap;
19use std::env;
20
21use axum::extract::{Path, State};
22use axum::response::IntoResponse;
23use axum::Json;
24use embacle::config::CliRunnerType;
25use embacle::discovery::resolve_binary;
26use serde::{Deserialize, Serialize};
27
28use super::error::UhpFailure;
29use super::state::UhpState;
30use crate::runner::ALL_PROVIDERS;
31use crate::state::SharedState;
32
33/// One harness this server can run.
34#[derive(Debug, Clone, Serialize)]
35pub struct Harness {
36    /// Stable id, prefixed `chrn_` as the schema requires.
37    pub id: String,
38    /// Always `harness`.
39    pub object: &'static str,
40    /// Human-readable name.
41    pub name: String,
42    /// Opaque harness family, e.g. `claude-code`. Deliberately not enumerated
43    /// by the specification, so a client must treat it as a string.
44    pub base: String,
45    /// The model used when a task names none.
46    #[serde(rename = "defaultModel", skip_serializing_if = "Option::is_none")]
47    pub default_model: Option<String>,
48    /// Skill bundles this harness carries.
49    #[serde(default, skip_serializing_if = "Vec::is_empty")]
50    pub skills: Vec<Skill>,
51    /// MCP servers this harness may reach.
52    #[serde(rename = "mcpServers", default, skip_serializing_if = "Vec::is_empty")]
53    pub mcp_servers: Vec<McpServer>,
54    /// Tools withheld from the model on this harness.
55    #[serde(
56        rename = "disabledTools",
57        default,
58        skip_serializing_if = "Vec::is_empty"
59    )]
60    pub disabled_tools: Vec<String>,
61}
62
63/// One file inside a skill bundle.
64///
65/// A member may carry its bytes as `content` or, when it is not text, as
66/// `content_b64`. Both round-trip, and anything else the client attached comes
67/// back too: a bundle is the client's, and dropping a field it set would be
68/// indistinguishable from the server having stored it.
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct SkillFile {
71    /// Path relative to the bundle root.
72    pub path: String,
73    /// Text contents.
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub content: Option<String>,
76    /// Base64 contents, for a member that is not text.
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub content_b64: Option<String>,
79    /// Everything else the client attached.
80    #[serde(flatten)]
81    pub rest: serde_json::Map<String, serde_json::Value>,
82}
83
84/// A skill bundle: a manifest plus whatever it references.
85///
86/// The whole folder round-trips. Storing only `SKILL.md` breaks every skill
87/// that carries references, scripts or data — and breaks it at run time, long
88/// after the client thought it had configured the harness.
89#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct Skill {
91    /// Bundle name, which is how it is addressed.
92    pub name: String,
93    /// Whether the harness may use it.
94    #[serde(default)]
95    pub enabled: bool,
96    /// Every file in the bundle.
97    #[serde(default)]
98    pub files: Vec<SkillFile>,
99}
100
101impl Skill {
102    /// The manifest every bundle must carry.
103    pub const MANIFEST: &'static str = "SKILL.md";
104
105    /// Whether this bundle carries its manifest.
106    ///
107    /// A bundle without one would be stored and then silently ignored at run
108    /// time, which is the hardest kind of failure for a user to diagnose — so
109    /// it is refused at config time instead.
110    #[must_use]
111    pub fn has_manifest(&self) -> bool {
112        self.files.iter().any(|f| f.path == Self::MANIFEST)
113    }
114}
115
116/// An MCP server a harness may reach.
117#[derive(Debug, Clone, Serialize, Deserialize)]
118pub struct McpServer {
119    /// Server name.
120    pub name: String,
121    /// Where it lives.
122    #[serde(default, skip_serializing_if = "Option::is_none")]
123    pub url: Option<String>,
124    /// How to reach it.
125    #[serde(default, skip_serializing_if = "Option::is_none")]
126    pub transport: Option<String>,
127    /// Whether it is contacted at all.
128    ///
129    /// Preserved exactly as sent: the difference between true and false decides
130    /// whether a third party is contacted, so a client must be able to tell a
131    /// disabled entry from an enabled one.
132    #[serde(default)]
133    pub enabled: bool,
134}
135
136/// The `GET /v1/harnesses` body.
137#[derive(Debug, Clone, Serialize)]
138pub struct HarnessList {
139    /// Every harness whose binary is installed.
140    pub harnesses: Vec<Harness>,
141}
142
143/// One model, with computed availability.
144#[derive(Debug, Clone, Serialize)]
145pub struct Model {
146    /// Model id as the harness names it.
147    pub id: String,
148    /// Which harness serves it.
149    pub backend: String,
150    /// Whether this server can serve it right now.
151    pub available: bool,
152    /// Whether it is the harness's default.
153    pub default: bool,
154}
155
156/// Models grouped by backend, which is the shape the schema requires — a flat
157/// list is what the `OpenAI` surface serves, and the two must not be confused.
158#[derive(Debug, Clone, Serialize)]
159pub struct BackendModels {
160    /// The model used when a task names none.
161    pub default: String,
162    /// Every model this backend accepts.
163    pub models: Vec<Model>,
164}
165
166/// The `GET /v1/harnesses/{id}/models` body.
167///
168/// Deliberately NOT [`ModelCatalog`]: one harness answers a flat `models`
169/// array plus its own defaults, while the server-wide catalogue groups by
170/// backend. Serving the grouped shape here fails schema validation on a
171/// missing `models` property.
172#[derive(Debug, Clone, Serialize)]
173pub struct HarnessModels {
174    /// The harness these models belong to.
175    pub harness_id: String,
176    /// Its backend family.
177    pub backend: String,
178    /// The model used when a task names none.
179    pub default: String,
180    /// Every model this harness accepts.
181    pub models: Vec<Model>,
182}
183
184/// The `GET /v1/models` body.
185#[derive(Debug, Clone, Serialize)]
186pub struct ModelCatalog {
187    /// Keyed by backend id.
188    pub backends: BTreeMap<String, BackendModels>,
189}
190
191/// A harness id for a provider name, satisfying the schema's `^chrn_` pattern.
192fn harness_id(provider: &str) -> String {
193    let slug: String = provider
194        .chars()
195        .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
196        .collect();
197    format!("chrn_{slug}")
198}
199
200/// Whether this provider's CLI binary resolves on this machine.
201fn installed(provider: CliRunnerType) -> bool {
202    let env_override = env::var(provider.env_override_key()).ok();
203    resolve_binary(provider.binary_name(), env_override.as_deref()).is_ok()
204}
205
206/// One installed provider, as everything the routes need to describe or run it.
207pub struct Discovered {
208    /// The embacle runner type, for constructing the runner.
209    pub provider: CliRunnerType,
210    /// Its provider name, which is also its UHP backend id.
211    pub name: String,
212    /// The harness object served over the wire.
213    pub harness: Harness,
214    /// The models it accepts.
215    pub models: Vec<String>,
216}
217
218/// Every installed provider, as harnesses.
219pub async fn discover(state: &SharedState) -> Vec<Discovered> {
220    let mut found = Vec::new();
221    for &provider in ALL_PROVIDERS {
222        if !installed(provider) {
223            continue;
224        }
225        let Ok(runner) = state.get_runner(provider).await else {
226            continue;
227        };
228        let name = runner.name().to_owned();
229        let models = runner.available_models().to_vec();
230        let default_model = runner.default_model().to_owned();
231        found.push(Discovered {
232            provider,
233            harness: Harness {
234                id: harness_id(&name),
235                object: "harness",
236                name: name.clone(),
237                base: name.clone(),
238                default_model: Some(default_model),
239                skills: Vec::new(),
240                mcp_servers: Vec::new(),
241                disabled_tools: Vec::new(),
242            },
243            name,
244            models,
245        });
246    }
247    found
248}
249
250/// Handle `GET /v1/harnesses`.
251pub async fn list(State(state): State<SharedState>) -> impl IntoResponse {
252    let harnesses = discover(&state)
253        .await
254        .into_iter()
255        .map(|d| d.harness)
256        .collect();
257    Json(HarnessList { harnesses })
258}
259
260/// Handle `GET /v1/harnesses/{harness_id}`.
261///
262/// # Errors
263///
264/// Answers `404 harness_not_found` when the id names nothing installed.
265pub async fn get(
266    State(state): State<UhpState>,
267    Path(id): Path<String>,
268) -> Result<Json<Harness>, UhpFailure> {
269    if let Some(configured) = state.configured.get(&id) {
270        return Ok(Json(configured));
271    }
272    discover(&state.shared)
273        .await
274        .into_iter()
275        .find(|d| d.harness.id == id)
276        .map(|d| Json(d.harness))
277        .ok_or_else(|| {
278            UhpFailure::not_found("harness_not_found", "no harness with that id is configured")
279        })
280}
281
282/// Build the catalogue for a set of discovered harnesses.
283fn catalogue(found: Vec<Discovered>) -> ModelCatalog {
284    let mut backends = BTreeMap::new();
285    for d in found {
286        let name = d.name;
287        let default = d.harness.default_model.unwrap_or_default();
288        let models = d
289            .models
290            .into_iter()
291            .map(|id| Model {
292                default: id == default,
293                id,
294                backend: name.clone(),
295                // Installed and constructible, which is what this server can
296                // actually act on. A model the harness lists but cannot serve
297                // would be the failure the spec warns about.
298                available: true,
299            })
300            .collect();
301        backends.insert(name, BackendModels { default, models });
302    }
303    ModelCatalog { backends }
304}
305
306/// Handle `GET /v1/models` on the UHP surface.
307pub async fn models(State(state): State<UhpState>) -> impl IntoResponse {
308    Json(catalogue(discover(&state.shared).await))
309}
310
311/// Handle `GET /v1/harnesses/{harness_id}/models`.
312///
313/// # Errors
314///
315/// Answers `404 harness_not_found` when the id names nothing installed.
316pub async fn harness_models(
317    State(state): State<UhpState>,
318    Path(id): Path<String>,
319) -> Result<Json<HarnessModels>, UhpFailure> {
320    let d = discover(&state.shared)
321        .await
322        .into_iter()
323        .find(|d| d.harness.id == id)
324        .ok_or_else(|| {
325            UhpFailure::not_found("harness_not_found", "no harness with that id is configured")
326        })?;
327
328    let name = d.name;
329    let default = d.harness.default_model.unwrap_or_default();
330    Ok(Json(HarnessModels {
331        harness_id: d.harness.id,
332        backend: name.clone(),
333        models: d
334            .models
335            .into_iter()
336            .map(|id| Model {
337                default: id == default,
338                id,
339                backend: name.clone(),
340                available: true,
341            })
342            .collect(),
343        default,
344    }))
345}
346
347/// Fallback for a path under the UHP base that names no route.
348pub async fn unknown_route() -> UhpFailure {
349    UhpFailure::not_found("not_found", "no such endpoint on this server")
350}
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355
356    #[test]
357    fn a_harness_id_satisfies_the_schema_pattern() {
358        // The Harness schema pins `^chrn_`, so an id that does not start with
359        // it fails validation for every harness the server advertises.
360        assert!(harness_id("claude_code").starts_with("chrn_"));
361        assert_eq!(harness_id("claude-code"), "chrn_claude_code");
362        assert_eq!(harness_id("copilot"), "chrn_copilot");
363    }
364
365    #[test]
366    fn the_catalogue_groups_by_backend_and_marks_one_default() {
367        let found = vec![Discovered {
368            provider: CliRunnerType::Copilot,
369            name: "copilot".to_owned(),
370            harness: Harness {
371                id: harness_id("copilot"),
372                object: "harness",
373                name: "copilot".to_owned(),
374                base: "copilot".to_owned(),
375                default_model: Some("gpt-5".to_owned()),
376                skills: Vec::new(),
377                mcp_servers: Vec::new(),
378                disabled_tools: Vec::new(),
379            },
380            models: vec!["gpt-5".to_owned(), "claude-sonnet".to_owned()],
381        }];
382        let cat = catalogue(found);
383
384        let backend = cat.backends.get("copilot").expect("backend present"); // Safe: test assertion
385        assert_eq!(backend.default, "gpt-5");
386        assert_eq!(backend.models.len(), 2);
387        assert_eq!(
388            backend.models.iter().filter(|m| m.default).count(),
389            1,
390            "exactly one model is the default"
391        );
392        assert!(
393            backend.models.iter().all(|m| m.available),
394            "availability is a boolean on every model, never absent"
395        );
396    }
397}