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 |