OXYGENHUB

Assistants through the API and oh 62ceb2eec18c

List, add or change, and remove a repository's, an organisation's or
the site's assistants and read or set their prompts:
/repos/{o}/{n}/assistants (repo:admin), /orgs/{org}/assistants (its
owners), /admin/assistants (site administrators). Tokens are write-only;
an empty token on a change keeps the stored one; the model is checked
against the endpoint's list. oh assistant list|add|remove|prompt, with
--org or --site, reads a token only from an environment variable.
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-04 19:40 UTC
Signature
✔ signed by Andy McCall
Hash
62ceb2eec18c7f2e8a3742def34765ce1c158d57ad3284f508a47ac34f2220d7
Depends on
101740454617 155cab142b63 19bb31ff1f5c 1f1eb06a9009 2291f8cc9c96 5c6e92fd8d71 5ea4872f23e8 749d9a5fcd9f 7d2eb590051f 93be9ce4653e b5668108487f b64e2537d702 b66e70b7b6c7 d23464c0ac60 df71c7892d27 e71846aa07b9
Depended on by
40a43921ef42 9bbca80f777a 09ffa7b600a9 2a8b621676b4 16ee5d19d220 8aa5ef384ef1 c08cec372c81 c8684d44d549 9a777fec4ed9 71e65fcf95ab 617c2840446c 17a3a84b00d1 4d2e44d79b03
In bookmarks
api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check job-waits main oh-comment-body-file proposal-list-fast run-one-job wiki-says-so
In tags
v0.4.1 v0.4.2 v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
Edits
15 atoms, 0 tree operations, 8 files

Browse the tree at this change

modified CHANGELOG.md +2 −0
…
3 unchanged lines
4 4
5 5
## Unreleased
6 6
7 +
- **Assistants through the API and `oh` (§13.4).** List, add or change, and remove a repository's, an organisation's or the site's assistants, and read or set their prompts: `/repos/{o}/{n}/assistants`, `/orgs/{org}/assistants`, `/admin/assistants`; `oh assistant list|add|remove|prompt` with `--org` or `--site`. Tokens are write-only and read from an environment variable by `oh`.
8 +
7 9
- **Adding an assistant picks from what the provider offers (#109).** A provider list fills the name, endpoint and API (with *Other* for anything else), **List models** fetches the endpoint's own models into the Model field, and a model the endpoint does not list is refused on save with ones it does. Administration is a sectioned page with a left-hand menu, its audit log paged (#110).
8 10
9 11
- **Agents get their marks, contributors one row each.** A repository's contributors show one row per account with each agent's share beneath (`↳ via Claude Code · 1`), as `oxy log` does; agents' names on change pages, reviews and the ask buttons carry their brand mark (Claude, ChatGPT, Perplexity, DeepSeek, Mistral; Antigravity with the Gemini mark), served at `/avatar/agent/{name}`.
…
115 unchanged lines
modified crates/hub-api/src/lib.rs +61 −0
…
1071 unchanged lines
1072 1072
    /// The runner holding it, when running.
1073 1073
    pub runner: String,
1074 1074
}
1075 +
1076 +
// ------------------------------------------------------------- assistants
1077 +
1078 +
/// An AI assistant at a level (hub spec §13.4). The token never leaves the
1079 +
/// hub: `has_token` says whether one is set.
1080 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1081 +
pub struct Assistant {
1082 +
    pub name: String,
1083 +
    pub endpoint: String,
1084 +
    pub api: String,
1085 +
    pub model: String,
1086 +
    #[serde(default, skip_serializing_if = "String::is_empty")]
1087 +
    pub headers: String,
1088 +
    pub enabled: bool,
1089 +
    pub budget: i64,
1090 +
    pub has_token: bool,
1091 +
    /// Where it comes from: empty for the level's own, else `the site` or
1092 +
    /// `the NAME organisation`.
1093 +
    #[serde(default, skip_serializing_if = "String::is_empty")]
1094 +
    pub from: String,
1095 +
    /// Off because a level above turned a name like it off.
1096 +
    #[serde(default)]
1097 +
    pub off_above: bool,
1098 +
}
1099 +
1100 +
/// `POST …/assistants`: add one, or change the one of that name. A
1101 +
/// `preset` (`anthropic`, `openai`, `gemini`, `deepseek`, `mistral`,
1102 +
/// `perplexity`, `ollama`) fills what is left out; the model must be one
1103 +
/// the endpoint lists when it lists any; `token` is write-only.
1104 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1105 +
pub struct NewAssistant {
1106 +
    #[serde(default)]
1107 +
    pub name: String,
1108 +
    #[serde(default)]
1109 +
    pub preset: String,
1110 +
    #[serde(default)]
1111 +
    pub endpoint: String,
1112 +
    #[serde(default)]
1113 +
    pub api: String,
1114 +
    pub model: String,
1115 +
    #[serde(default)]
1116 +
    pub token: String,
1117 +
    #[serde(default)]
1118 +
    pub headers: String,
1119 +
    #[serde(default = "yes")]
1120 +
    pub enabled: bool,
1121 +
    #[serde(default)]
1122 +
    pub budget: Option<i64>,
1123 +
}
1124 +
1125 +
fn yes() -> bool {
1126 +
    true
1127 +
}
1128 +
1129 +
/// A level's prompt and those it inherits, outermost first.
1130 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1131 +
pub struct AssistantPrompt {
1132 +
    pub prompt: String,
1133 +
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1134 +
    pub inherited: Vec<(String, String)>,
1135 +
}
modified crates/hub/src/api/mod.rs +195 −0
…
92 unchanged lines
93 93
        .route("/repos/{owner}/{name}/milestones", get(milestones).post(create_milestone))
94 94
        .route("/repos/{owner}/{name}/milestones/{title}", axum::routing::patch(edit_milestone))
95 95
        .route("/repos/{owner}/{name}/hooks", get(hooks).post(create_hook))
96 +
        .route("/repos/{owner}/{name}/assistants", get(repo_assistants).post(repo_assistant_put))
97 +
        .route("/repos/{owner}/{name}/assistants/prompt", get(repo_prompt).put(repo_prompt_put))
98 +
        .route("/repos/{owner}/{name}/assistants/{assistant}", axum::routing::delete(repo_assistant_delete))
99 +
        .route("/orgs/{org}/assistants", get(org_assistants).post(org_assistant_put))
100 +
        .route("/orgs/{org}/assistants/prompt", get(org_prompt).put(org_prompt_put))
101 +
        .route("/orgs/{org}/assistants/{assistant}", axum::routing::delete(org_assistant_delete))
102 +
        .route("/admin/assistants", get(site_assistants).post(site_assistant_put))
103 +
        .route("/admin/assistants/prompt", get(site_prompt).put(site_prompt_put))
104 +
        .route("/admin/assistants/{assistant}", axum::routing::delete(site_assistant_delete))
96 105
        .route("/repos/{owner}/{name}/hooks/{id}/delete", post(delete_hook))
97 106
        .route("/repos/{owner}/{name}/checks", post(post_check))
98 107
        .route("/repos/{owner}/{name}/flow/runs", get(flow_runs).post(start_flow_run))
…
2062 unchanged lines
2161 2170
    crate::assistants::request_review(&app, &row, &p, &me, a).await.map_err(|e| ApiError(StatusCode::CONFLICT, e))?;
2162 2171
    Ok((StatusCode::ACCEPTED, Json(asked)))
2163 2172
}
2173 +
2174 +
// -------------------------------------------------------- assistants (§13.4)
2175 +
2176 +
use crate::levels::At;
2177 +
use crate::web::assistants as aa;
2178 +
2179 +
/// The level a repository's assistants live at, for its admins (repo:admin).
2180 +
async fn repo_level(app: &Shared, c: &Caller, owner: &str, name: &str) -> Result<(Account, At), ApiError> {
2181 +
    let me = c.require("repo:admin")?.clone();
2182 +
    let (_, row, _) = reach(app, c, owner, name, Access::Admin).await?;
2183 +
    Ok((me, At::repo(&row)))
2184 +
}
2185 +
2186 +
/// An organisation's level, for its owners (repo:admin).
2187 +
async fn org_level(app: &Shared, c: &Caller, org: &str) -> Result<(Account, At), ApiError> {
2188 +
    let me = c.require("repo:admin")?.clone();
2189 +
    let o = db::accounts::org_by_name(&app.db, org).await?.ok_or_else(|| not_found("no such organisation"))?;
2190 +
    if !auth::roles::is_org_owner(&app.db, &me, o.id).await? {
2191 +
        return Err(not_found("no such organisation"));
2192 +
    }
2193 +
    Ok((me, At::org(&o)))
2194 +
}
2195 +
2196 +
/// The site's level, for its administrators.
2197 +
fn site_level(c: &Caller) -> Result<(Account, At), ApiError> {
2198 +
    Ok((require_site_admin(c)?.clone(), At::site()))
2199 +
}
2200 +
2201 +
fn put_outcome(r: Result<String, String>) -> Result<(StatusCode, Json<serde_json::Value>), ApiError> {
2202 +
    match r {
2203 +
        Ok(msg) => Ok((StatusCode::OK, Json(serde_json::json!({ "ok": msg })))),
2204 +
        Err(e) => Err(ApiError(StatusCode::UNPROCESSABLE_ENTITY, e)),
2205 +
    }
2206 +
}
2207 +
2208 +
macro_rules! assistant_routes {
2209 +
    ($list:ident, $put:ident, $del:ident, $prompt:ident, $prompt_put:ident, ($($p:ident: $t:ty),*), $level:expr) => {
2210 +
        async fn $list(State(app): State<Shared>, headers: HeaderMap, Path(($($p),*)): Path<($($t),*)>) -> Result<Json<Vec<Assistant>>, ApiError> {
2211 +
            let c = caller(&app, &headers).await?;
2212 +
            let (_, at) = $level(&app, &c, $(&$p),*).await?;
2213 +
            Ok(Json(aa::api_list(&app, &at).await?))
2214 +
        }
2215 +
        async fn $put(
2216 +
            State(app): State<Shared>,
2217 +
            headers: HeaderMap,
2218 +
            Path(($($p),*)): Path<($($t),*)>,
2219 +
            Json(body): Json<NewAssistant>,
2220 +
        ) -> Result<(StatusCode, Json<serde_json::Value>), ApiError> {
2221 +
            let c = caller(&app, &headers).await?;
2222 +
            let (me, at) = $level(&app, &c, $(&$p),*).await?;
2223 +
            put_outcome(aa::api_put(&app, &me, &at, body).await?)
2224 +
        }
2225 +
        async fn $prompt(State(app): State<Shared>, headers: HeaderMap, Path(($($p),*)): Path<($($t),*)>) -> Result<Json<AssistantPrompt>, ApiError> {
2226 +
            let c = caller(&app, &headers).await?;
2227 +
            let (_, at) = $level(&app, &c, $(&$p),*).await?;
2228 +
            Ok(Json(aa::api_prompt(&app, &at).await?))
2229 +
        }
2230 +
        async fn $prompt_put(
2231 +
            State(app): State<Shared>,
2232 +
            headers: HeaderMap,
2233 +
            Path(($($p),*)): Path<($($t),*)>,
2234 +
            Json(body): Json<AssistantPrompt>,
2235 +
        ) -> Result<Json<AssistantPrompt>, ApiError> {
2236 +
            let c = caller(&app, &headers).await?;
2237 +
            let (me, at) = $level(&app, &c, $(&$p),*).await?;
2238 +
            aa::api_set_prompt(&app, &me, &at, &body.prompt).await?.map_err(|e| ApiError(StatusCode::UNPROCESSABLE_ENTITY, e))?;
2239 +
            Ok(Json(aa::api_prompt(&app, &at).await?))
2240 +
        }
2241 +
    };
2242 +
}
2243 +
2244 +
assistant_routes!(repo_assistants, repo_assistant_put, _rd, repo_prompt, repo_prompt_put, (owner: String, name: String), repo_level);
2245 +
2246 +
async fn repo_assistant_delete(
2247 +
    State(app): State<Shared>,
2248 +
    headers: HeaderMap,
2249 +
    Path((owner, name, assistant)): Path<(String, String, String)>,
2250 +
) -> Result<StatusCode, ApiError> {
2251 +
    let c = caller(&app, &headers).await?;
2252 +
    let (me, at) = repo_level(&app, &c, &owner, &name).await?;
2253 +
    if aa::api_delete(&app, &me, &at, &assistant).await? {
2254 +
        Ok(StatusCode::NO_CONTENT)
2255 +
    } else {
2256 +
        Err(not_found("no such assistant at this level"))
2257 +
    }
2258 +
}
2259 +
2260 +
async fn org_level_one(app: &Shared, c: &Caller, org: &str) -> Result<(Account, At), ApiError> {
2261 +
    org_level(app, c, org).await
2262 +
}
2263 +
2264 +
async fn org_assistants(State(app): State<Shared>, headers: HeaderMap, Path(org): Path<String>) -> Result<Json<Vec<Assistant>>, ApiError> {
2265 +
    let c = caller(&app, &headers).await?;
2266 +
    let (_, at) = org_level_one(&app, &c, &org).await?;
2267 +
    Ok(Json(aa::api_list(&app, &at).await?))
2268 +
}
2269 +
2270 +
async fn org_assistant_put(
2271 +
    State(app): State<Shared>,
2272 +
    headers: HeaderMap,
2273 +
    Path(org): Path<String>,
2274 +
    Json(body): Json<NewAssistant>,
2275 +
) -> Result<(StatusCode, Json<serde_json::Value>), ApiError> {
2276 +
    let c = caller(&app, &headers).await?;
2277 +
    let (me, at) = org_level_one(&app, &c, &org).await?;
2278 +
    put_outcome(aa::api_put(&app, &me, &at, body).await?)
2279 +
}
2280 +
2281 +
async fn org_assistant_delete(
2282 +
    State(app): State<Shared>,
2283 +
    headers: HeaderMap,
2284 +
    Path((org, assistant)): Path<(String, String)>,
2285 +
) -> Result<StatusCode, ApiError> {
2286 +
    let c = caller(&app, &headers).await?;
2287 +
    let (me, at) = org_level_one(&app, &c, &org).await?;
2288 +
    if aa::api_delete(&app, &me, &at, &assistant).await? {
2289 +
        Ok(StatusCode::NO_CONTENT)
2290 +
    } else {
2291 +
        Err(not_found("no such assistant at this level"))
2292 +
    }
2293 +
}
2294 +
2295 +
async fn org_prompt(State(app): State<Shared>, headers: HeaderMap, Path(org): Path<String>) -> Result<Json<AssistantPrompt>, ApiError> {
2296 +
    let c = caller(&app, &headers).await?;
2297 +
    let (_, at) = org_level_one(&app, &c, &org).await?;
2298 +
    Ok(Json(aa::api_prompt(&app, &at).await?))
2299 +
}
2300 +
2301 +
async fn org_prompt_put(
2302 +
    State(app): State<Shared>,
2303 +
    headers: HeaderMap,
2304 +
    Path(org): Path<String>,
2305 +
    Json(body): Json<AssistantPrompt>,
2306 +
) -> Result<Json<AssistantPrompt>, ApiError> {
2307 +
    let c = caller(&app, &headers).await?;
2308 +
    let (me, at) = org_level_one(&app, &c, &org).await?;
2309 +
    aa::api_set_prompt(&app, &me, &at, &body.prompt).await?.map_err(|e| ApiError(StatusCode::UNPROCESSABLE_ENTITY, e))?;
2310 +
    Ok(Json(aa::api_prompt(&app, &at).await?))
2311 +
}
2312 +
2313 +
async fn site_assistants(State(app): State<Shared>, headers: HeaderMap) -> Result<Json<Vec<Assistant>>, ApiError> {
2314 +
    let c = caller(&app, &headers).await?;
2315 +
    let (_, at) = site_level(&c)?;
2316 +
    Ok(Json(aa::api_list(&app, &at).await?))
2317 +
}
2318 +
2319 +
async fn site_assistant_put(
2320 +
    State(app): State<Shared>,
2321 +
    headers: HeaderMap,
2322 +
    Json(body): Json<NewAssistant>,
2323 +
) -> Result<(StatusCode, Json<serde_json::Value>), ApiError> {
2324 +
    let c = caller(&app, &headers).await?;
2325 +
    let (me, at) = site_level(&c)?;
2326 +
    put_outcome(aa::api_put(&app, &me, &at, body).await?)
2327 +
}
2328 +
2329 +
async fn site_assistant_delete(
2330 +
    State(app): State<Shared>,
2331 +
    headers: HeaderMap,
2332 +
    Path(assistant): Path<String>,
2333 +
) -> Result<StatusCode, ApiError> {
2334 +
    let c = caller(&app, &headers).await?;
2335 +
    let (me, at) = site_level(&c)?;
2336 +
    if aa::api_delete(&app, &me, &at, &assistant).await? {
2337 +
        Ok(StatusCode::NO_CONTENT)
2338 +
    } else {
2339 +
        Err(not_found("no such assistant at this level"))
2340 +
    }
2341 +
}
2342 +
2343 +
async fn site_prompt(State(app): State<Shared>, headers: HeaderMap) -> Result<Json<AssistantPrompt>, ApiError> {
2344 +
    let c = caller(&app, &headers).await?;
2345 +
    let (_, at) = site_level(&c)?;
2346 +
    Ok(Json(aa::api_prompt(&app, &at).await?))
2347 +
}
2348 +
2349 +
async fn site_prompt_put(
2350 +
    State(app): State<Shared>,
2351 +
    headers: HeaderMap,
2352 +
    Json(body): Json<AssistantPrompt>,
2353 +
) -> Result<Json<AssistantPrompt>, ApiError> {
2354 +
    let c = caller(&app, &headers).await?;
2355 +
    let (me, at) = site_level(&c)?;
2356 +
    aa::api_set_prompt(&app, &me, &at, &body.prompt).await?.map_err(|e| ApiError(StatusCode::UNPROCESSABLE_ENTITY, e))?;
2357 +
    Ok(Json(aa::api_prompt(&app, &at).await?))
2358 +
}
modified crates/hub/src/web/assistants.rs +99 −0
…
300 unchanged lines
301 301
        Err(e) => serde_json::json!({ "error": e }),
302 302
    })
303 303
}
304 +
305 +
/// For the API (§13.4): every assistant a level sees.
306 +
pub async fn api_list(app: &Shared, at: &At) -> sqlx::Result<Vec<hub_api::Assistant>> {
307 +
    Ok(assistants::available(&app.db, at.scope, &at.owner)
308 +
        .await?
309 +
        .into_iter()
310 +
        .map(|v| hub_api::Assistant {
311 +
            name: v.assistant.name.clone(),
312 +
            endpoint: v.assistant.endpoint.clone(),
313 +
            api: v.assistant.api.clone(),
314 +
            model: v.assistant.model.clone(),
315 +
            headers: v.assistant.headers.clone(),
316 +
            enabled: v.assistant.enabled,
317 +
            budget: v.assistant.budget,
318 +
            has_token: v.assistant.has_token(),
319 +
            off_above: v.off_above,
320 +
            from: v.from,
321 +
        })
322 +
        .collect())
323 +
}
324 +
325 +
/// For the API: add an assistant, or change the level's own one of that
326 +
/// name (an empty token keeps the stored one). `Err` is for the caller.
327 +
pub async fn api_put(
328 +
    app: &Shared,
329 +
    actor: &crate::db::accounts::Account,
330 +
    at: &At,
331 +
    b: hub_api::NewAssistant,
332 +
) -> sqlx::Result<Result<String, String>> {
333 +
    let f = AssistantForm {
334 +
        csrf_token: String::new(),
335 +
        action: String::new(),
336 +
        id: None,
337 +
        name: b.name,
338 +
        endpoint: b.endpoint,
339 +
        api: b.api,
340 +
        model: b.model,
341 +
        headers: b.headers,
342 +
        token: b.token,
343 +
        enabled: b.enabled.then(|| "1".to_string()),
344 +
        budget: b.budget.map(|n| n.to_string()).unwrap_or_default(),
345 +
        prompt: String::new(),
346 +
        preset: b.preset,
347 +
    };
348 +
    let fields = match fields(&f) {
349 +
        Ok(x) => x,
350 +
        Err(e) => return Ok(Err(e)),
351 +
    };
352 +
    let existing = assistants::list(&app.db, at.scope).await?.into_iter().find(|a| a.name.eq_ignore_ascii_case(&fields.name));
353 +
    let token = f.token.trim().to_string();
354 +
    let effective = match (&existing, token.is_empty()) {
355 +
        (Some(a), true) => app.assistant_key.open(&a.token).unwrap_or_default(),
356 +
        _ => token.clone(),
357 +
    };
358 +
    if let Err(e) = model_offered(&fields, &effective).await {
359 +
        return Ok(Err(e));
360 +
    }
361 +
    Ok(Ok(match existing {
362 +
        Some(a) => {
363 +
            let sealed = (!token.is_empty()).then(|| app.assistant_key.seal(&token));
364 +
            assistants::update(&app.db, a.id, &fields, sealed.as_deref()).await?;
365 +
            accounts::audit(&app.db, Some(actor), "assistant.updated", &at.label, &fields.name).await.ok();
366 +
            format!("{} updated", fields.name)
367 +
        }
368 +
        None => {
369 +
            assistants::create(&app.db, at.scope, &fields, &app.assistant_key.seal(&token)).await?;
370 +
            accounts::audit(&app.db, Some(actor), "assistant.added", &at.label, &fields.name).await.ok();
371 +
            format!("{} added", fields.name)
372 +
        }
373 +
    }))
374 +
}
375 +
376 +
/// For the API: remove the level's own assistant of that name.
377 +
pub async fn api_delete(app: &Shared, actor: &crate::db::accounts::Account, at: &At, name: &str) -> sqlx::Result<bool> {
378 +
    let Some(a) = assistants::list(&app.db, at.scope).await?.into_iter().find(|a| a.name.eq_ignore_ascii_case(name)) else {
379 +
        return Ok(false);
380 +
    };
381 +
    assistants::delete(&app.db, a.id).await?;
382 +
    accounts::audit(&app.db, Some(actor), "assistant.deleted", &at.label, &a.name).await.ok();
383 +
    Ok(true)
384 +
}
385 +
386 +
/// For the API: the level's prompt and what it inherits.
387 +
pub async fn api_prompt(app: &Shared, at: &At) -> sqlx::Result<hub_api::AssistantPrompt> {
388 +
    Ok(hub_api::AssistantPrompt {
389 +
        prompt: assistants::prompt(&app.db, at.scope).await?,
390 +
        inherited: assistants::inherited_prompts(&app.db, at.scope, &at.owner).await?,
391 +
    })
392 +
}
393 +
394 +
/// For the API: set the level's prompt (empty clears it).
395 +
pub async fn api_set_prompt(app: &Shared, actor: &crate::db::accounts::Account, at: &At, text: &str) -> sqlx::Result<Result<(), String>> {
396 +
    if text.chars().count() > 20_000 {
397 +
        return Ok(Err("a prompt is up to 20000 characters".into()));
398 +
    }
399 +
    assistants::set_prompt(&app.db, at.scope, text).await?;
400 +
    accounts::audit(&app.db, Some(actor), "assistant.prompt", &at.label, "").await.ok();
401 +
    Ok(Ok(()))
402 +
}
modified crates/hub/src/web/mod.rs +1 −1
…
3 unchanged lines
4 4
//! render.
5 5
6 6
mod admin;
7 -
mod assistants;
7 +
pub(crate) mod assistants;
8 8
mod avatar;
9 9
pub mod chain;
10 10
mod device;
…
692 unchanged lines
modified crates/hub/tests/api.rs +70 −0
…
1393 unchanged lines
1394 1394
    let r = call(app, "GET", "/device", None, None, None, None).await;
1395 1395
    assert!(r.status.is_redirection(), "{}", r.status);
1396 1396
}
1397 +
1398 +
#[tokio::test(flavor = "multi_thread")]
1399 +
async fn api_assistants_at_each_level() {
1400 +
    let h = hub().await;
1401 +
    let app = &h.app;
1402 +
    let andy = call(app, "POST", "/register", None, None, None, Some("username=andy&email=a%40example.com&password=correct-horse-battery"))
1403 +
        .await
1404 +
        .cookie
1405 +
        .unwrap();
1406 +
    let page = call(app, "GET", "/settings/tokens", None, Some(&andy), None, None).await;
1407 +
    let csrf = csrf_of(&page.body);
1408 +
    let mint = |scopes: &'static str| {
1409 +
        let (app, andy, csrf) = (app.clone(), andy.clone(), csrf.clone());
1410 +
        async move {
1411 +
            let r = call(&app, "POST", "/settings/tokens", None, Some(&andy), None, Some(&format!("_csrf={csrf}&name=t&{scopes}"))).await;
1412 +
            r.body.split("oxyhub_").nth(1).map(|s| format!("oxyhub_{}", s.split('<').next().unwrap().trim())).unwrap()
1413 +
        }
1414 +
    };
1415 +
    let admin = mint("scopes=repo%3Aadmin").await;
1416 +
    let write = mint("scopes=repo%3Aread&scopes=repo%3Awrite").await;
1417 +
    api(app, "POST", "/api/v1/repos", Some(&admin), Some(r#"{"owner":"andy","name":"demo","visibility":"public"}"#)).await;
1418 +
    // A stand-in endpoint with no model list: any model name is taken.
1419 +
    let router = axum::Router::new().route(
1420 +
        "/v1/chat/completions",
1421 +
        axum::routing::post(|| async { axum::Json(serde_json::json!({ "choices": [{ "message": { "content": "OK" } }] })) }),
1422 +
    );
1423 +
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1424 +
    let port = listener.local_addr().unwrap().port();
1425 +
    tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
1426 +
    let ep = format!("http://127.0.0.1:{port}/v1/chat/completions");
1427 +
1428 +
    // The site's, by its administrator; the repository's, by its admin.
1429 +
    let body = serde_json::json!({ "preset": "gemini", "endpoint": ep, "model": "gemini-3-pro", "token": "sk-secret" }).to_string();
1430 +
    let (st, v) = api(app, "POST", "/api/v1/admin/assistants", Some(&admin), Some(&body)).await;
1431 +
    assert_eq!(st, StatusCode::OK, "{v}");
1432 +
    assert_eq!(v["ok"], "Gemini added");
1433 +
    let body =
1434 +
        serde_json::json!({ "name": "Claude", "endpoint": ep, "api": "chat-completions", "model": "m", "token": "sk-2" }).to_string();
1435 +
    let (st, _) = api(app, "POST", "/api/v1/repos/andy/demo/assistants", Some(&write), Some(&body)).await;
1436 +
    assert_eq!(st, StatusCode::FORBIDDEN, "repo:admin is needed");
1437 +
    let (st, v) = api(app, "POST", "/api/v1/repos/andy/demo/assistants", Some(&admin), Some(&body)).await;
1438 +
    assert_eq!(st, StatusCode::OK, "{v}");
1439 +
    let (st, v) = api(app, "GET", "/api/v1/repos/andy/demo/assistants", Some(&admin), None).await;
1440 +
    assert_eq!(st, StatusCode::OK);
1441 +
    let names: Vec<(&str, &str, bool)> = v
1442 +
        .as_array()
1443 +
        .unwrap()
1444 +
        .iter()
1445 +
        .map(|a| (a["name"].as_str().unwrap(), a["from"].as_str().unwrap_or(""), a["has_token"].as_bool().unwrap()))
1446 +
        .collect();
1447 +
    assert_eq!(names, [("Gemini", "the site", true), ("Claude", "", true)]);
1448 +
    assert!(!v.to_string().contains("sk-"), "tokens never come back");
1449 +
    // Changing it keeps the token when none is given.
1450 +
    let body = serde_json::json!({ "name": "Claude", "endpoint": ep, "api": "chat-completions", "model": "m2" }).to_string();
1451 +
    let (_, v) = api(app, "POST", "/api/v1/repos/andy/demo/assistants", Some(&admin), Some(&body)).await;
1452 +
    assert_eq!(v["ok"], "Claude updated");
1453 +
    let (_, v) = api(app, "GET", "/api/v1/repos/andy/demo/assistants", Some(&admin), None).await;
1454 +
    assert_eq!((v[1]["model"].as_str(), v[1]["has_token"].as_bool()), (Some("m2"), Some(true)));
1455 +
    // Prompts: the site's is inherited by the repository.
1456 +
    let (st, _) = api(app, "PUT", "/api/v1/admin/assistants/prompt", Some(&admin), Some(r#"{"prompt":"Be brief."}"#)).await;
1457 +
    assert_eq!(st, StatusCode::OK);
1458 +
    let (_, v) = api(app, "PUT", "/api/v1/repos/andy/demo/assistants/prompt", Some(&admin), Some(r#"{"prompt":"Rust."}"#)).await;
1459 +
    assert_eq!(v["prompt"], "Rust.");
1460 +
    assert_eq!(v["inherited"], serde_json::json!([["the site", "Be brief."]]));
1461 +
    // Removed.
1462 +
    let (st, _) = api(app, "DELETE", "/api/v1/repos/andy/demo/assistants/claude", Some(&admin), None).await;
1463 +
    assert_eq!(st, StatusCode::NO_CONTENT);
1464 +
    let (st, _) = api(app, "DELETE", "/api/v1/repos/andy/demo/assistants/claude", Some(&admin), None).await;
1465 +
    assert_eq!(st, StatusCode::NOT_FOUND);
1466 +
}
modified crates/oh/src/main.rs +124 −0
…
76 unchanged lines
77 77
        #[command(subcommand)]
78 78
        cmd: MilestoneCmd,
79 79
    },
80 +
    /// AI assistants of the repository (its admins), or with --org or
81 +
    /// --site of an organisation or the site
82 +
    Assistant {
83 +
        #[command(subcommand)]
84 +
        cmd: AssistantCmd,
85 +
        /// An organisation's assistants instead of the repository's
86 +
        #[arg(long, global = true)]
87 +
        org: Option<String>,
88 +
        /// The site's assistants (administrators)
89 +
        #[arg(long, global = true, conflicts_with = "org")]
90 +
        site: bool,
91 +
    },
80 92
    /// Call the API directly: `oh api /repos/OWNER/NAME/issues`
81 93
    Api {
82 94
        /// A path under /api/v1
…
154 unchanged lines
237 249
    Checks { number: i64 },
238 250
    /// Land a proposal on its target, as the page's Land button does
239 251
    Land { number: i64 },
252 +
}
253 +
254 +
#[derive(Subcommand)]
255 +
enum AssistantCmd {
256 +
    /// The assistants this level sees, its own and those inherited
257 +
    List,
258 +
    /// Add an assistant, or change the one of that name
259 +
    Add {
260 +
        /// How it shows on reviews (default: the provider's name)
261 +
        #[arg(long, default_value = "")]
262 +
        name: String,
263 +
        /// anthropic, openai, gemini, deepseek, mistral, perplexity or ollama
264 +
        #[arg(long, default_value = "")]
265 +
        preset: String,
266 +
        /// The endpoint, when no preset or another one
267 +
        #[arg(long, default_value = "")]
268 +
        endpoint: String,
269 +
        /// chat-completions or anthropic-messages
270 +
        #[arg(long, default_value = "")]
271 +
        api: String,
272 +
        /// One the endpoint lists (`oh assistant models` shows them)
273 +
        #[arg(long)]
274 +
        model: String,
275 +
        /// The API key, read from this environment variable (never typed on the command line)
276 +
        #[arg(long, value_name = "VAR")]
277 +
        token_env: Option<String>,
278 +
        /// Add it turned off
279 +
        #[arg(long)]
280 +
        off: bool,
281 +
    },
282 +
    /// Remove this level's assistant of that name
283 +
    Remove { name: String },
284 +
    /// Show this level's prompt, and those it inherits
285 +
    Prompt {
286 +
        /// Set it from this file (`-` for standard input; an empty file clears it)
287 +
        #[arg(long, value_name = "FILE")]
288 +
        set: Option<std::path::PathBuf>,
289 +
    },
240 290
}
241 291
242 292
#[derive(Subcommand)]
…
227 unchanged lines
470 520
        Cmd::Release { cmd } => release(&c, &t, cmd, json),
471 521
        Cmd::Label { cmd } => label(&c, &t, cmd, json),
472 522
        Cmd::Milestone { cmd } => milestone(&c, &t, cmd, json),
523 +
        Cmd::Assistant { cmd, org, site } => assistant(&c, &t, cmd, org, site, json),
473 524
    }
474 525
}
475 526
…
515 unchanged lines
991 1042
        LabelCmd::Delete { name } => {
992 1043
            c.send_text("DELETE", &format!("{base}/labels/{}", enc(&name)), &serde_json::json!({}))?;
993 1044
            println!("{} deleted label {name}", ui::ok("✔"));
1045 +
        }
1046 +
    }
1047 +
    Ok(0)
1048 +
}
1049 +
1050 +
fn assistant(c: &Client, t: &Target, cmd: AssistantCmd, org: Option<String>, site: bool, json: bool) -> Result<i32> {
1051 +
    let base = match (&org, site) {
1052 +
        (Some(o), _) => format!("/orgs/{}/assistants", enc(o)),
1053 +
        (None, true) => "/admin/assistants".to_string(),
1054 +
        (None, false) => format!("{}/assistants", t.repo_path()?),
1055 +
    };
1056 +
    match cmd {
1057 +
        AssistantCmd::List => {
1058 +
            if json {
1059 +
                println!("{}", c.get_text(&base)?);
1060 +
                return Ok(0);
1061 +
            }
1062 +
            let list: Vec<hub_api::Assistant> = c.get(&base)?;
1063 +
            if list.is_empty() {
1064 +
                println!("{}", ui::dim("No assistants."));
1065 +
            }
1066 +
            for a in list {
1067 +
                let state = if a.enabled && !a.off_above {
1068 +
                    ui::ok("on")
1069 +
                } else if a.off_above {
1070 +
                    ui::dim("off above")
1071 +
                } else {
1072 +
                    ui::dim("off")
1073 +
                };
1074 +
                let from = if a.from.is_empty() { String::new() } else { ui::dim(&format!(" · from {}", a.from)) };
1075 +
                println!("{} {}  {} {}{}", state, a.name, ui::dim(&a.model), ui::dim(&a.endpoint), from);
1076 +
            }
1077 +
        }
1078 +
        AssistantCmd::Add { name, preset, endpoint, api, model, token_env, off } => {
1079 +
            let token = match token_env {
1080 +
                Some(var) => std::env::var(&var).map_err(|_| anyhow::anyhow!("${var} is not set"))?,
1081 +
                None => String::new(),
1082 +
            };
1083 +
            let body =
1084 +
                hub_api::NewAssistant { name, preset, endpoint, api, model, token, headers: String::new(), enabled: !off, budget: None };
1085 +
            let r: serde_json::Value = c.send("POST", &base, &body)?;
1086 +
            println!("{} {}", ui::ok("✔"), r["ok"].as_str().unwrap_or("saved"));
1087 +
        }
1088 +
        AssistantCmd::Remove { name } => {
1089 +
            c.send_text("DELETE", &format!("{base}/{}", enc(&name)), &serde_json::json!({}))?;
1090 +
            println!("{} removed {name}", ui::ok("✔"));
1091 +
        }
1092 +
        AssistantCmd::Prompt { set } => {
1093 +
            let p: hub_api::AssistantPrompt = match set {
1094 +
                Some(path) => {
1095 +
                    let text = if path.as_os_str() == "-" {
1096 +
                        let mut s = String::new();
1097 +
                        std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?;
1098 +
                        s
1099 +
                    } else {
1100 +
                        std::fs::read_to_string(&path)?
1101 +
                    };
1102 +
                    c.send("PUT", &format!("{base}/prompt"), &hub_api::AssistantPrompt { prompt: text, inherited: vec![] })?
1103 +
                }
1104 +
                None => c.get(&format!("{base}/prompt"))?,
1105 +
            };
1106 +
            if json {
1107 +
                println!("{}", serde_json::to_string(&p)?);
1108 +
                return Ok(0);
1109 +
            }
1110 +
            for (from, text) in &p.inherited {
1111 +
                println!("{}\n{}\n", ui::dim(&format!("── from {from}")), text);
1112 +
            }
1113 +
            if p.prompt.is_empty() {
1114 +
                println!("{}", ui::dim("No prompt of its own."));
1115 +
            } else {
1116 +
                println!("{}\n{}", ui::dim("── this level"), p.prompt);
1117 +
            }
994 1118
        }
995 1119
    }
996 1120
    Ok(0)
…
151 unchanged lines
modified docs/spec.md +2 −2
…
547 unchanged lines
548 548
- *Costs.* One microVM per open workspace: 2 vCPU and 4 GB by default, from the runner's capacity like any job, so open workspaces compete with pipeline jobs; a runner label `workspace` keeps them on runners meant for it. Idle stop at 30 minutes keeps the fleet small.
549 549
- *Open questions.* Whether the Oxygen VCS extension should talk to the microVM's `oxy` (simplest; works offline in the VM) or to the hub's API (no `oxy` in the VM, but a second code path for every operation): the first. Whether a stopped workspace's overlay should be kept for a day so an unpushed edit is not lost: probably yes, as a tarball artifact of the job, restored on reopen. Whether to allow terminals: yes, it is the point.
550 550
551 -
### 13.4 AI assistants (done 2026-10-04; settings API planned)
551 +
### 13.4 AI assistants (done 2026-10-04)
552 552
553 553
An **assistant** is a chat-completion endpoint the hub may ask to review a proposal or draft its description (oxygenhub#97). Assistants are configured at three levels and combined downwards:
554 554
…
3 unchanged lines
558 558
- **Request review.** Under Reviewers on the proposal page, the author or a writer sees an **ask NAME** button per assistant the repository may use. Asking records the event (`asked NAME to review`) and the audit entry (`assistant.asked`), then a background task builds the context (title, description, the list of changes, the diff of each changed file as `-`/`+` lines per hunk, binary files noted, the whole cut to the assistant's context budget less the prompt), prefixes a fixed system prompt (who it is, that it answers for the person who asked, that it cannot approve, the shape of a review) with the levels' prompts, calls the endpoint, and records the answer as a review of verdict `comment` whose author is the person who asked and whose **via** (migration 0036) is the assistant, shown as "bob ↳ agent Claude reviewed" in the timeline, exactly as agent-signed changes are shown (§6). An assistant never approves: approvals count only people, and an assistant's review never replaces the asker's own latest verdict. The proposal's author is told as for any review; the `review` webhook carries `via`. No answer leaves an event (`NAME did not answer: why`) and no review.
559 559
- **Draft the description.** The new-proposal form (anyone who may open one) and an open proposal's editor (the author or a maintainer) have a **draft with NAME** button per assistant. It asks the assistant, now, for a description from the title so far, the changes (titles and the first lines of their messages) and the diff, under a fixed system prompt (description field only, what and why, what to look at, how to verify) plus the levels' prompts, and puts the answer in the editor with a note; nothing is saved until the person updates or opens the proposal. The audit log records `assistant.drafted`; the same per-repository hourly limit applies.
560 560
- **Limits.** One request per assistant per proposal at a time, 30 assistant calls per repository per hour (in memory), a 60-second call timeout, answers kept to 64 KiB, and a context budget (characters) per assistant.
561 -
- **API.** `POST /repos/{o}/{n}/proposals/{n}/review-requests {"assistant": NAME}` (the author or a writer; 202 when started, 409 when that assistant is already answering or the repository is over its hourly limit, 422 for a name the repository cannot use); reviews carry `via`. Settings endpoints are planned. Hub actions by an assistant are attributed "ASSISTANT via WHO" everywhere, so a project can require that a human has approved regardless of what an assistant said.
561 +
- **API.** `POST /repos/{o}/{n}/proposals/{n}/review-requests {"assistant": NAME}` (the author or a writer; 202 when started, 409 when that assistant is already answering or the repository is over its hourly limit, 422 for a name the repository cannot use); reviews carry `via`. Settings: `GET`/`POST /repos/{o}/{n}/assistants` (repo:admin; `POST` adds or changes by name, with `preset`, an optional write-only `token` that an empty value keeps, and the model checked against the endpoint's list), `DELETE …/assistants/{name}`, and `GET`/`PUT …/assistants/prompt` (the level's text and what it inherits); the same under `/orgs/{org}/assistants` for its owners and `/admin/assistants` for site administrators. `oh assistant list|add|remove|prompt` uses them, with `--org` or `--site`, and reads a token only from an environment variable (`--token-env`). Hub actions by an assistant are attributed "ASSISTANT via WHO" everywhere, so a project can require that a human has approved regardless of what an assistant said.
562 562
563 563
### 13.5 A job's identity: the hub as an OIDC issuer (done 2026-10-04)
564 564
…
154 unchanged lines