AI assistants: settings per site, organisation and repository e71846aa07b9
Each level holds assistants (name, endpoint, API shape, model, extra headers, token, context budget, on/off) and a prompt. A repository sees its organisation's and the site's beside its own: the innermost of a name wins and one turned off above is off below; prompts combine outermost first under headings. Tokens are sealed with ChaCha20-Poly1305 under keys/assistants.key and never shown again; TEST makes a one-line exchange. refs #97
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-04 15:32 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
e71846aa07b9b70b04e7b7fde94498cb1301d6ecdb9059df648fa4f1401ff428- Depends on
- 101740454617 19bb31ff1f5c 3556d08e17f9 3f4b0ea6eabf 5ea4872f23e8 64caed18236a 749d9a5fcd9f 7974adb0be66 8fb35df7d66e 9008bf6c2b8c 93be9ce4653e a160b98fb922 b5668108487f b64e2537d702 b6b7e4cf6b24 d23464c0ac60 d4792b4a4629 ea85398a51bc
- Depended on by
- c43e4f631d99 737de0fac722 c7067c7bda56 33d98147511b a7aa76ac52d7 119fd17d213f 5436f0dd9429 c08cec372c81 5d90e0e045e2 2cbf6f348595 b5a0a69b3622 3dd6582de060 9a777fec4ed9 5360cea36c30 ffb67a81188c 1f78655a0466 62ceb2eec18c 155cab142b63 e8e7158bbc73 ed89fae5180b 206c1fc1b06a 83940d53664f 89e89836cae7 d1727c573f30 4ac01a732552 2a83f5303458 7d2eb590051f
- 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
- 40 atoms, 4 tree operations, 17 files
Browse the tree at this change
added crates/hub/migrations/0035_assistants.sql +27 −0
| 1 | + | -- AI assistants (hub spec §13.4, #97): chat-completion endpoints the hub |
|
| 2 | + | -- may ask to review a proposal or draft its description. Each lives at |
|
| 3 | + | -- one level (site: scope_id 0; an organisation; a repository) and each |
|
| 4 | + | -- level may add a prompt. Tokens are sealed with keys/assistants.key. |
|
| 5 | + | CREATE TABLE assistants ( |
|
| 6 | + | id INTEGER PRIMARY KEY, |
|
| 7 | + | scope TEXT NOT NULL CHECK (scope IN ('site', 'org', 'repo')), |
|
| 8 | + | scope_id INTEGER NOT NULL DEFAULT 0, |
|
| 9 | + | name TEXT NOT NULL COLLATE NOCASE, |
|
| 10 | + | endpoint TEXT NOT NULL, |
|
| 11 | + | api TEXT NOT NULL, |
|
| 12 | + | model TEXT NOT NULL, |
|
| 13 | + | headers TEXT NOT NULL DEFAULT '', |
|
| 14 | + | token BLOB NOT NULL, |
|
| 15 | + | enabled INTEGER NOT NULL DEFAULT 1, |
|
| 16 | + | budget INTEGER NOT NULL DEFAULT 120000, |
|
| 17 | + | created_ms INTEGER NOT NULL, |
|
| 18 | + | updated_ms INTEGER NOT NULL, |
|
| 19 | + | UNIQUE (scope, scope_id, name) |
|
| 20 | + | ); |
|
| 21 | + | CREATE TABLE assistant_prompts ( |
|
| 22 | + | scope TEXT NOT NULL CHECK (scope IN ('site', 'org', 'repo')), |
|
| 23 | + | scope_id INTEGER NOT NULL DEFAULT 0, |
|
| 24 | + | prompt TEXT NOT NULL, |
|
| 25 | + | updated_ms INTEGER NOT NULL, |
|
| 26 | + | PRIMARY KEY (scope, scope_id) |
|
| 27 | + | ); |
added crates/hub/src/assistants.rs +525 −0
| 1 | + | //! AI assistants (hub spec §13.4, #97): chat-completion endpoints the hub |
|
| 2 | + | //! may ask to review a proposal or draft its description. Assistants are |
|
| 3 | + | //! kept at three levels, site, organisation and repository, and combined |
|
| 4 | + | //! downwards: a repository sees its organisation's and the site's beside |
|
| 5 | + | //! its own, the innermost of a name wins, and one disabled at an outer |
|
| 6 | + | //! level is off for everything inside it. Each level adds a prompt; the |
|
| 7 | + | //! prompt in force for a repository is the site's, the organisation's and |
|
| 8 | + | //! the repository's own, each under a heading. Tokens are sealed with |
|
| 9 | + | //! ChaCha20-Poly1305 under `keys/assistants.key`, shown masked, and never |
|
| 10 | + | //! leave the server. |
|
| 11 | + | ||
| 12 | + | use crate::db::{now_ms, Pool}; |
|
| 13 | + | use crate::Shared; |
|
| 14 | + | use rand::RngCore; |
|
| 15 | + | use ring::aead; |
|
| 16 | + | use sqlx::FromRow; |
|
| 17 | + | use std::path::Path; |
|
| 18 | + | use std::time::Duration; |
|
| 19 | + | ||
| 20 | + | /// The request shapes an assistant may speak. |
|
| 21 | + | pub const APIS: [&str; 2] = ["chat-completions", "anthropic-messages"]; |
|
| 22 | + | /// Characters of context (prompt, description, diffs) a call may carry |
|
| 23 | + | /// unless the assistant says otherwise. |
|
| 24 | + | pub const DEFAULT_BUDGET: i64 = 120_000; |
|
| 25 | + | /// How long one call may take. |
|
| 26 | + | pub const CALL_TIMEOUT: Duration = Duration::from_secs(60); |
|
| 27 | + | /// The most an answer is kept. |
|
| 28 | + | const MAX_ANSWER: usize = 64 * 1024; |
|
| 29 | + | ||
| 30 | + | /// Which level an assistant or a prompt belongs to. |
|
| 31 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
|
| 32 | + | pub enum Scope { |
|
| 33 | + | Site, |
|
| 34 | + | Org(i64), |
|
| 35 | + | Repo(i64), |
|
| 36 | + | } |
|
| 37 | + | ||
| 38 | + | impl Scope { |
|
| 39 | + | pub fn kind(self) -> &'static str { |
|
| 40 | + | match self { |
|
| 41 | + | Scope::Site => "site", |
|
| 42 | + | Scope::Org(_) => "org", |
|
| 43 | + | Scope::Repo(_) => "repo", |
|
| 44 | + | } |
|
| 45 | + | } |
|
| 46 | + | ||
| 47 | + | pub fn id(self) -> i64 { |
|
| 48 | + | match self { |
|
| 49 | + | Scope::Site => 0, |
|
| 50 | + | Scope::Org(id) | Scope::Repo(id) => id, |
|
| 51 | + | } |
|
| 52 | + | } |
|
| 53 | + | } |
|
| 54 | + | ||
| 55 | + | #[derive(Clone, Debug, FromRow)] |
|
| 56 | + | pub struct Assistant { |
|
| 57 | + | pub id: i64, |
|
| 58 | + | pub scope: String, |
|
| 59 | + | pub scope_id: i64, |
|
| 60 | + | pub name: String, |
|
| 61 | + | pub endpoint: String, |
|
| 62 | + | /// One of [`APIS`]. |
|
| 63 | + | pub api: String, |
|
| 64 | + | pub model: String, |
|
| 65 | + | /// Extra request headers, one `Name: value` per line. |
|
| 66 | + | pub headers: String, |
|
| 67 | + | /// The sealed token; empty when the endpoint needs none. |
|
| 68 | + | pub token: Vec<u8>, |
|
| 69 | + | pub enabled: bool, |
|
| 70 | + | pub budget: i64, |
|
| 71 | + | pub created_ms: i64, |
|
| 72 | + | pub updated_ms: i64, |
|
| 73 | + | } |
|
| 74 | + | ||
| 75 | + | impl Assistant { |
|
| 76 | + | pub fn scope(&self) -> Scope { |
|
| 77 | + | match self.scope.as_str() { |
|
| 78 | + | "org" => Scope::Org(self.scope_id), |
|
| 79 | + | "repo" => Scope::Repo(self.scope_id), |
|
| 80 | + | _ => Scope::Site, |
|
| 81 | + | } |
|
| 82 | + | } |
|
| 83 | + | ||
| 84 | + | pub fn has_token(&self) -> bool { |
|
| 85 | + | !self.token.is_empty() |
|
| 86 | + | } |
|
| 87 | + | ||
| 88 | + | /// The extra headers as pairs; malformed lines are skipped. |
|
| 89 | + | pub fn header_list(&self) -> Vec<(String, String)> { |
|
| 90 | + | parse_headers(&self.headers).unwrap_or_default() |
|
| 91 | + | } |
|
| 92 | + | } |
|
| 93 | + | ||
| 94 | + | /// What a settings form supplies for an assistant. |
|
| 95 | + | #[derive(Clone, Debug, Default)] |
|
| 96 | + | pub struct Fields { |
|
| 97 | + | pub name: String, |
|
| 98 | + | pub endpoint: String, |
|
| 99 | + | pub api: String, |
|
| 100 | + | pub model: String, |
|
| 101 | + | pub headers: String, |
|
| 102 | + | pub enabled: bool, |
|
| 103 | + | pub budget: i64, |
|
| 104 | + | } |
|
| 105 | + | ||
| 106 | + | /// `Name: value` lines to pairs: `Err(line)` names the first bad one. |
|
| 107 | + | pub fn parse_headers(text: &str) -> Result<Vec<(String, String)>, String> { |
|
| 108 | + | let mut out = vec![]; |
|
| 109 | + | for line in text.lines().map(str::trim).filter(|l| !l.is_empty()) { |
|
| 110 | + | let Some((name, value)) = line.split_once(':') else { return Err(line.to_string()) }; |
|
| 111 | + | let (name, value) = (name.trim(), value.trim()); |
|
| 112 | + | let name_ok = !name.is_empty() && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'); |
|
| 113 | + | if !name_ok || value.chars().any(|c| c.is_control()) { |
|
| 114 | + | return Err(line.to_string()); |
|
| 115 | + | } |
|
| 116 | + | out.push((name.to_string(), value.to_string())); |
|
| 117 | + | } |
|
| 118 | + | Ok(out) |
|
| 119 | + | } |
|
| 120 | + | ||
| 121 | + | // -------------------------------------------------------------- sealing |
|
| 122 | + | ||
| 123 | + | /// The key that seals tokens, kept in `<data>/keys/assistants.key`. |
|
| 124 | + | #[derive(Clone)] |
|
| 125 | + | pub struct SecretKey([u8; 32]); |
|
| 126 | + | ||
| 127 | + | impl SecretKey { |
|
| 128 | + | pub fn load_or_create(keys: &Path) -> anyhow::Result<SecretKey> { |
|
| 129 | + | std::fs::create_dir_all(keys)?; |
|
| 130 | + | let path = keys.join("assistants.key"); |
|
| 131 | + | if let Ok(bytes) = std::fs::read(&path) { |
|
| 132 | + | if bytes.len() == 32 { |
|
| 133 | + | return Ok(SecretKey(bytes.try_into().unwrap())); |
|
| 134 | + | } |
|
| 135 | + | } |
|
| 136 | + | let mut k = [0u8; 32]; |
|
| 137 | + | rand::rngs::OsRng.fill_bytes(&mut k); |
|
| 138 | + | std::fs::write(&path, k)?; |
|
| 139 | + | #[cfg(unix)] |
|
| 140 | + | { |
|
| 141 | + | use std::os::unix::fs::PermissionsExt; |
|
| 142 | + | std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?; |
|
| 143 | + | } |
|
| 144 | + | Ok(SecretKey(k)) |
|
| 145 | + | } |
|
| 146 | + | ||
| 147 | + | fn key(&self) -> aead::LessSafeKey { |
|
| 148 | + | aead::LessSafeKey::new(aead::UnboundKey::new(&aead::CHACHA20_POLY1305, &self.0).expect("key length")) |
|
| 149 | + | } |
|
| 150 | + | ||
| 151 | + | /// Seal a token: a random nonce, then the ciphertext and tag. An |
|
| 152 | + | /// empty token seals to nothing, meaning "no token". |
|
| 153 | + | pub fn seal(&self, plain: &str) -> Vec<u8> { |
|
| 154 | + | if plain.is_empty() { |
|
| 155 | + | return vec![]; |
|
| 156 | + | } |
|
| 157 | + | let mut nonce = [0u8; 12]; |
|
| 158 | + | rand::rngs::OsRng.fill_bytes(&mut nonce); |
|
| 159 | + | let mut out = plain.as_bytes().to_vec(); |
|
| 160 | + | self.key().seal_in_place_append_tag(aead::Nonce::assume_unique_for_key(nonce), aead::Aad::empty(), &mut out).expect("seal"); |
|
| 161 | + | let mut sealed = nonce.to_vec(); |
|
| 162 | + | sealed.extend(out); |
|
| 163 | + | sealed |
|
| 164 | + | } |
|
| 165 | + | ||
| 166 | + | /// The token back, or `None` when the bytes were sealed under another |
|
| 167 | + | /// key or damaged. Nothing opens to an empty token. |
|
| 168 | + | pub fn open(&self, sealed: &[u8]) -> Option<String> { |
|
| 169 | + | if sealed.is_empty() { |
|
| 170 | + | return Some(String::new()); |
|
| 171 | + | } |
|
| 172 | + | let (nonce, rest) = sealed.split_at_checked(12)?; |
|
| 173 | + | let mut buf = rest.to_vec(); |
|
| 174 | + | let nonce = aead::Nonce::try_assume_unique_for_key(nonce).ok()?; |
|
| 175 | + | let plain = self.key().open_in_place(nonce, aead::Aad::empty(), &mut buf).ok()?; |
|
| 176 | + | String::from_utf8(plain.to_vec()).ok() |
|
| 177 | + | } |
|
| 178 | + | } |
|
| 179 | + | ||
| 180 | + | // ------------------------------------------------------------- storage |
|
| 181 | + | ||
| 182 | + | pub async fn list(pool: &Pool, scope: Scope) -> sqlx::Result<Vec<Assistant>> { |
|
| 183 | + | sqlx::query_as("SELECT * FROM assistants WHERE scope = ? AND scope_id = ? ORDER BY name") |
|
| 184 | + | .bind(scope.kind()) |
|
| 185 | + | .bind(scope.id()) |
|
| 186 | + | .fetch_all(pool) |
|
| 187 | + | .await |
|
| 188 | + | } |
|
| 189 | + | ||
| 190 | + | pub async fn get(pool: &Pool, id: i64) -> sqlx::Result<Option<Assistant>> { |
|
| 191 | + | sqlx::query_as("SELECT * FROM assistants WHERE id = ?").bind(id).fetch_optional(pool).await |
|
| 192 | + | } |
|
| 193 | + | ||
| 194 | + | /// Add an assistant; `None` when the level already has one of that name. |
|
| 195 | + | pub async fn create(pool: &Pool, scope: Scope, f: &Fields, token: &[u8]) -> sqlx::Result<Option<Assistant>> { |
|
| 196 | + | let now = now_ms(); |
|
| 197 | + | let r = sqlx::query_as( |
|
| 198 | + | "INSERT INTO assistants (scope, scope_id, name, endpoint, api, model, headers, token, enabled, budget, created_ms, updated_ms) \ |
|
| 199 | + | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT DO NOTHING RETURNING *", |
|
| 200 | + | ) |
|
| 201 | + | .bind(scope.kind()) |
|
| 202 | + | .bind(scope.id()) |
|
| 203 | + | .bind(&f.name) |
|
| 204 | + | .bind(&f.endpoint) |
|
| 205 | + | .bind(&f.api) |
|
| 206 | + | .bind(&f.model) |
|
| 207 | + | .bind(&f.headers) |
|
| 208 | + | .bind(token) |
|
| 209 | + | .bind(f.enabled) |
|
| 210 | + | .bind(f.budget) |
|
| 211 | + | .bind(now) |
|
| 212 | + | .bind(now) |
|
| 213 | + | .fetch_optional(pool) |
|
| 214 | + | .await?; |
|
| 215 | + | Ok(r) |
|
| 216 | + | } |
|
| 217 | + | ||
| 218 | + | /// Change an assistant's details; `token` is `None` to keep the stored |
|
| 219 | + | /// one. False when the name is taken at that level. |
|
| 220 | + | pub async fn update(pool: &Pool, id: i64, f: &Fields, token: Option<&[u8]>) -> sqlx::Result<bool> { |
|
| 221 | + | let r = match token { |
|
| 222 | + | Some(t) => sqlx::query( |
|
| 223 | + | "UPDATE assistants SET name = ?, endpoint = ?, api = ?, model = ?, headers = ?, enabled = ?, budget = ?, token = ?, updated_ms = ? \ |
|
| 224 | + | WHERE id = ? AND NOT EXISTS (SELECT 1 FROM assistants o WHERE o.scope = assistants.scope AND o.scope_id = assistants.scope_id AND o.name = ? AND o.id <> ?)", |
|
| 225 | + | ) |
|
| 226 | + | .bind(&f.name) |
|
| 227 | + | .bind(&f.endpoint) |
|
| 228 | + | .bind(&f.api) |
|
| 229 | + | .bind(&f.model) |
|
| 230 | + | .bind(&f.headers) |
|
| 231 | + | .bind(f.enabled) |
|
| 232 | + | .bind(f.budget) |
|
| 233 | + | .bind(t) |
|
| 234 | + | .bind(now_ms()) |
|
| 235 | + | .bind(id) |
|
| 236 | + | .bind(&f.name) |
|
| 237 | + | .bind(id) |
|
| 238 | + | .execute(pool) |
|
| 239 | + | .await?, |
|
| 240 | + | None => sqlx::query( |
|
| 241 | + | "UPDATE assistants SET name = ?, endpoint = ?, api = ?, model = ?, headers = ?, enabled = ?, budget = ?, updated_ms = ? \ |
|
| 242 | + | WHERE id = ? AND NOT EXISTS (SELECT 1 FROM assistants o WHERE o.scope = assistants.scope AND o.scope_id = assistants.scope_id AND o.name = ? AND o.id <> ?)", |
|
| 243 | + | ) |
|
| 244 | + | .bind(&f.name) |
|
| 245 | + | .bind(&f.endpoint) |
|
| 246 | + | .bind(&f.api) |
|
| 247 | + | .bind(&f.model) |
|
| 248 | + | .bind(&f.headers) |
|
| 249 | + | .bind(f.enabled) |
|
| 250 | + | .bind(f.budget) |
|
| 251 | + | .bind(now_ms()) |
|
| 252 | + | .bind(id) |
|
| 253 | + | .bind(&f.name) |
|
| 254 | + | .bind(id) |
|
| 255 | + | .execute(pool) |
|
| 256 | + | .await?, |
|
| 257 | + | }; |
|
| 258 | + | Ok(r.rows_affected() == 1) |
|
| 259 | + | } |
|
| 260 | + | ||
| 261 | + | pub async fn set_enabled(pool: &Pool, id: i64, enabled: bool) -> sqlx::Result<()> { |
|
| 262 | + | sqlx::query("UPDATE assistants SET enabled = ?, updated_ms = ? WHERE id = ?") |
|
| 263 | + | .bind(enabled) |
|
| 264 | + | .bind(now_ms()) |
|
| 265 | + | .bind(id) |
|
| 266 | + | .execute(pool) |
|
| 267 | + | .await?; |
|
| 268 | + | Ok(()) |
|
| 269 | + | } |
|
| 270 | + | ||
| 271 | + | pub async fn delete(pool: &Pool, id: i64) -> sqlx::Result<()> { |
|
| 272 | + | sqlx::query("DELETE FROM assistants WHERE id = ?").bind(id).execute(pool).await?; |
|
| 273 | + | Ok(()) |
|
| 274 | + | } |
|
| 275 | + | ||
| 276 | + | /// Forget a level's assistants and prompt, when the organisation or |
|
| 277 | + | /// repository goes. |
|
| 278 | + | pub async fn delete_scope(pool: &Pool, scope: Scope) -> sqlx::Result<()> { |
|
| 279 | + | sqlx::query("DELETE FROM assistants WHERE scope = ? AND scope_id = ?").bind(scope.kind()).bind(scope.id()).execute(pool).await?; |
|
| 280 | + | sqlx::query("DELETE FROM assistant_prompts WHERE scope = ? AND scope_id = ?").bind(scope.kind()).bind(scope.id()).execute(pool).await?; |
|
| 281 | + | Ok(()) |
|
| 282 | + | } |
|
| 283 | + | ||
| 284 | + | /// The prompt a level adds, empty when none. |
|
| 285 | + | pub async fn prompt(pool: &Pool, scope: Scope) -> sqlx::Result<String> { |
|
| 286 | + | Ok(sqlx::query_scalar("SELECT prompt FROM assistant_prompts WHERE scope = ? AND scope_id = ?") |
|
| 287 | + | .bind(scope.kind()) |
|
| 288 | + | .bind(scope.id()) |
|
| 289 | + | .fetch_optional(pool) |
|
| 290 | + | .await? |
|
| 291 | + | .unwrap_or_default()) |
|
| 292 | + | } |
|
| 293 | + | ||
| 294 | + | pub async fn set_prompt(pool: &Pool, scope: Scope, text: &str) -> sqlx::Result<()> { |
|
| 295 | + | if text.trim().is_empty() { |
|
| 296 | + | sqlx::query("DELETE FROM assistant_prompts WHERE scope = ? AND scope_id = ?") |
|
| 297 | + | .bind(scope.kind()) |
|
| 298 | + | .bind(scope.id()) |
|
| 299 | + | .execute(pool) |
|
| 300 | + | .await?; |
|
| 301 | + | } else { |
|
| 302 | + | sqlx::query( |
|
| 303 | + | "INSERT INTO assistant_prompts (scope, scope_id, prompt, updated_ms) VALUES (?, ?, ?, ?) \ |
|
| 304 | + | ON CONFLICT (scope, scope_id) DO UPDATE SET prompt = excluded.prompt, updated_ms = excluded.updated_ms", |
|
| 305 | + | ) |
|
| 306 | + | .bind(scope.kind()) |
|
| 307 | + | .bind(scope.id()) |
|
| 308 | + | .bind(text.trim()) |
|
| 309 | + | .bind(now_ms()) |
|
| 310 | + | .execute(pool) |
|
| 311 | + | .await?; |
|
| 312 | + | } |
|
| 313 | + | Ok(()) |
|
| 314 | + | } |
|
| 315 | + | ||
| 316 | + | // ----------------------------------------------------- what applies where |
|
| 317 | + | ||
| 318 | + | /// The levels above a scope, outermost first: the site, then the |
|
| 319 | + | /// organisation when the owner is one. |
|
| 320 | + | pub async fn levels_above(pool: &Pool, scope: Scope, owner: &str) -> sqlx::Result<Vec<(Scope, String)>> { |
|
| 321 | + | let mut out = vec![]; |
|
| 322 | + | match scope { |
|
| 323 | + | Scope::Site => {} |
|
| 324 | + | Scope::Org(_) => out.push((Scope::Site, "the site".to_string())), |
|
| 325 | + | Scope::Repo(_) => { |
|
| 326 | + | out.push((Scope::Site, "the site".to_string())); |
|
| 327 | + | if let Some(org) = crate::db::accounts::org_by_name(pool, owner).await? { |
|
| 328 | + | out.push((Scope::Org(org.id), format!("the {} organisation", org.name))); |
|
| 329 | + | } |
|
| 330 | + | } |
|
| 331 | + | } |
|
| 332 | + | Ok(out) |
|
| 333 | + | } |
|
| 334 | + | ||
| 335 | + | /// An assistant seen from a level: where it comes from, and whether it is |
|
| 336 | + | /// on once outer levels have had their say. |
|
| 337 | + | #[derive(Clone, Debug)] |
|
| 338 | + | pub struct Available { |
|
| 339 | + | pub assistant: Assistant, |
|
| 340 | + | /// `the site`, `the acme organisation`, or empty for the level's own. |
|
| 341 | + | pub from: String, |
|
| 342 | + | /// Off because a level above disabled an assistant of this name. |
|
| 343 | + | pub off_above: bool, |
|
| 344 | + | } |
|
| 345 | + | ||
| 346 | + | impl Available { |
|
| 347 | + | pub fn on(&self) -> bool { |
|
| 348 | + | self.assistant.enabled && !self.off_above |
|
| 349 | + | } |
|
| 350 | + | } |
|
| 351 | + | ||
| 352 | + | /// Every assistant a level sees, outermost first, one per name: the |
|
| 353 | + | /// innermost definition of a name wins, and a name disabled at an outer |
|
| 354 | + | /// level is off inside it whatever the inner level says. |
|
| 355 | + | pub async fn available(pool: &Pool, scope: Scope, owner: &str) -> sqlx::Result<Vec<Available>> { |
|
| 356 | + | let mut levels = levels_above(pool, scope, owner).await?; |
|
| 357 | + | levels.push((scope, String::new())); |
|
| 358 | + | let mut out: Vec<Available> = vec![]; |
|
| 359 | + | let mut off: Vec<String> = vec![]; |
|
| 360 | + | for (level, from) in levels { |
|
| 361 | + | for a in list(pool, level).await? { |
|
| 362 | + | let off_above = off.iter().any(|n| n.eq_ignore_ascii_case(&a.name)); |
|
| 363 | + | if !a.enabled && !off.iter().any(|n| n.eq_ignore_ascii_case(&a.name)) { |
|
| 364 | + | off.push(a.name.clone()); |
|
| 365 | + | } |
|
| 366 | + | out.retain(|o| !o.assistant.name.eq_ignore_ascii_case(&a.name)); |
|
| 367 | + | out.push(Available { assistant: a, from: from.clone(), off_above }); |
|
| 368 | + | } |
|
| 369 | + | } |
|
| 370 | + | Ok(out) |
|
| 371 | + | } |
|
| 372 | + | ||
| 373 | + | /// The assistants a repository may ask, by name. |
|
| 374 | + | pub async fn enabled_for_repo(pool: &Pool, repo: &crate::db::repos::RepoRow) -> sqlx::Result<Vec<Assistant>> { |
|
| 375 | + | Ok(available(pool, Scope::Repo(repo.id), &repo.owner).await?.into_iter().filter(Available::on).map(|a| a.assistant).collect()) |
|
| 376 | + | } |
|
| 377 | + | ||
| 378 | + | /// The prompts a level inherits, outermost first: (where from, text), |
|
| 379 | + | /// only the levels that set one. |
|
| 380 | + | pub async fn inherited_prompts(pool: &Pool, scope: Scope, owner: &str) -> sqlx::Result<Vec<(String, String)>> { |
|
| 381 | + | let mut out = vec![]; |
|
| 382 | + | for (level, from) in levels_above(pool, scope, owner).await? { |
|
| 383 | + | let text = prompt(pool, level).await?; |
|
| 384 | + | if !text.is_empty() { |
|
| 385 | + | out.push((from, text)); |
|
| 386 | + | } |
|
| 387 | + | } |
|
| 388 | + | Ok(out) |
|
| 389 | + | } |
|
| 390 | + | ||
| 391 | + | /// The prompt in force for a repository: each level's text under a |
|
| 392 | + | /// heading, outermost first, so a project adds to what the site says. |
|
| 393 | + | pub async fn prompt_for_repo(pool: &Pool, repo: &crate::db::repos::RepoRow) -> sqlx::Result<String> { |
|
| 394 | + | let scope = Scope::Repo(repo.id); |
|
| 395 | + | let mut parts = inherited_prompts(pool, scope, &repo.owner).await?; |
|
| 396 | + | let own = prompt(pool, scope).await?; |
|
| 397 | + | if !own.is_empty() { |
|
| 398 | + | parts.push(("this repository".to_string(), own)); |
|
| 399 | + | } |
|
| 400 | + | Ok(parts.into_iter().map(|(from, text)| format!("## Instructions from {from}\n\n{text}\n")).collect::<Vec<_>>().join("\n")) |
|
| 401 | + | } |
|
| 402 | + | ||
| 403 | + | // -------------------------------------------------------------- calling |
|
| 404 | + | ||
| 405 | + | /// Ask an assistant: the system prompt and the request, the answer's |
|
| 406 | + | /// text. Errors are for the person who asked: a status and the first |
|
| 407 | + | /// line of what the endpoint said, or why no answer came. |
|
| 408 | + | pub async fn ask(app: &Shared, a: &Assistant, system: &str, user: &str) -> Result<String, String> { |
|
| 409 | + | let Some(token) = app.assistant_key.open(&a.token) else { |
|
| 410 | + | return Err("its token cannot be read: set it again".into()); |
|
| 411 | + | }; |
|
| 412 | + | let client = reqwest::Client::builder() |
|
| 413 | + | .timeout(CALL_TIMEOUT) |
|
| 414 | + | .user_agent(format!("oxygenhub/{}", env!("CARGO_PKG_VERSION"))) |
|
| 415 | + | .build() |
|
| 416 | + | .map_err(|e| e.to_string())?; |
|
| 417 | + | let mut req = client.post(&a.endpoint).header("content-type", "application/json"); |
|
| 418 | + | let body = match a.api.as_str() { |
|
| 419 | + | "anthropic-messages" => { |
|
| 420 | + | if !token.is_empty() { |
|
| 421 | + | req = req.header("x-api-key", &token); |
|
| 422 | + | } |
|
| 423 | + | req = req.header("anthropic-version", "2023-06-01"); |
|
| 424 | + | serde_json::json!({ |
|
| 425 | + | "model": a.model, |
|
| 426 | + | "max_tokens": 4096, |
|
| 427 | + | "system": system, |
|
| 428 | + | "messages": [{ "role": "user", "content": user }], |
|
| 429 | + | }) |
|
| 430 | + | } |
|
| 431 | + | _ => { |
|
| 432 | + | if !token.is_empty() { |
|
| 433 | + | req = req.header("authorization", format!("Bearer {token}")); |
|
| 434 | + | } |
|
| 435 | + | serde_json::json!({ |
|
| 436 | + | "model": a.model, |
|
| 437 | + | "messages": [ |
|
| 438 | + | { "role": "system", "content": system }, |
|
| 439 | + | { "role": "user", "content": user }, |
|
| 440 | + | ], |
|
| 441 | + | }) |
|
| 442 | + | } |
|
| 443 | + | }; |
|
| 444 | + | for (name, value) in a.header_list() { |
|
| 445 | + | req = req.header(name, value); |
|
| 446 | + | } |
|
| 447 | + | let r = req.json(&body).send().await.map_err(|e| { |
|
| 448 | + | if e.is_timeout() { |
|
| 449 | + | "no answer within 60 seconds".to_string() |
|
| 450 | + | } else { |
|
| 451 | + | scrub(&e.to_string(), &token) |
|
| 452 | + | } |
|
| 453 | + | })?; |
|
| 454 | + | let status = r.status(); |
|
| 455 | + | let text: String = r.text().await.unwrap_or_default().chars().take(MAX_ANSWER).collect(); |
|
| 456 | + | if !status.is_success() { |
|
| 457 | + | let why = serde_json::from_str::<serde_json::Value>(&text) |
|
| 458 | + | .ok() |
|
| 459 | + | .and_then(|v| v["error"]["message"].as_str().or(v["error"].as_str()).or(v["message"].as_str()).map(str::to_string)) |
|
| 460 | + | .unwrap_or_else(|| text.lines().next().unwrap_or("").chars().take(160).collect()); |
|
| 461 | + | return Err(scrub(&format!("{} {}", status.as_u16(), why.trim()), &token)); |
|
| 462 | + | } |
|
| 463 | + | let v: serde_json::Value = serde_json::from_str(&text).map_err(|_| "the answer was not JSON".to_string())?; |
|
| 464 | + | let answer = match a.api.as_str() { |
|
| 465 | + | "anthropic-messages" => v["content"] |
|
| 466 | + | .as_array() |
|
| 467 | + | .map(|parts| parts.iter().filter_map(|p| p["text"].as_str()).collect::<Vec<_>>().join("")) |
|
| 468 | + | .unwrap_or_default(), |
|
| 469 | + | _ => match &v["choices"][0]["message"]["content"] { |
|
| 470 | + | serde_json::Value::String(s) => s.clone(), |
|
| 471 | + | serde_json::Value::Array(parts) => parts.iter().filter_map(|p| p["text"].as_str()).collect::<Vec<_>>().join(""), |
|
| 472 | + | _ => String::new(), |
|
| 473 | + | }, |
|
| 474 | + | }; |
|
| 475 | + | if answer.trim().is_empty() { |
|
| 476 | + | return Err("the answer carried no text".into()); |
|
| 477 | + | } |
|
| 478 | + | Ok(answer) |
|
| 479 | + | } |
|
| 480 | + | ||
| 481 | + | /// A tiny exchange, for the settings page's TEST: the start of the answer. |
|
| 482 | + | pub async fn test(app: &Shared, a: &Assistant) -> Result<String, String> { |
|
| 483 | + | let answer = ask(app, a, "You are being connected to an OxygenHub site. Answer briefly.", "Reply with the single word OK.").await?; |
|
| 484 | + | Ok(answer.trim().chars().take(80).collect()) |
|
| 485 | + | } |
|
| 486 | + | ||
| 487 | + | /// Never let the token through in an error message. |
|
| 488 | + | fn scrub(text: &str, token: &str) -> String { |
|
| 489 | + | if token.is_empty() { |
|
| 490 | + | text.to_string() |
|
| 491 | + | } else { |
|
| 492 | + | text.replace(token, "***") |
|
| 493 | + | } |
|
| 494 | + | } |
|
| 495 | + | ||
| 496 | + | #[cfg(test)] |
|
| 497 | + | mod tests { |
|
| 498 | + | use super::*; |
|
| 499 | + | ||
| 500 | + | #[test] |
|
| 501 | + | fn tokens_seal_and_open_only_under_their_key() { |
|
| 502 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 503 | + | let k = SecretKey::load_or_create(dir.path()).unwrap(); |
|
| 504 | + | let sealed = k.seal("sk-very-secret"); |
|
| 505 | + | assert_ne!(sealed, b"sk-very-secret"); |
|
| 506 | + | assert!(!String::from_utf8_lossy(&sealed).contains("secret")); |
|
| 507 | + | assert_eq!(k.open(&sealed).as_deref(), Some("sk-very-secret")); |
|
| 508 | + | assert_eq!(SecretKey::load_or_create(dir.path()).unwrap().open(&sealed).as_deref(), Some("sk-very-secret"), "the key is kept"); |
|
| 509 | + | let other = SecretKey::load_or_create(tempfile::tempdir().unwrap().path()).unwrap(); |
|
| 510 | + | assert_eq!(other.open(&sealed), None); |
|
| 511 | + | assert_eq!(k.open(&[1, 2, 3]), None); |
|
| 512 | + | assert_eq!(k.seal(""), Vec::<u8>::new()); |
|
| 513 | + | assert_eq!(k.open(&[]).as_deref(), Some("")); |
|
| 514 | + | } |
|
| 515 | + | ||
| 516 | + | #[test] |
|
| 517 | + | fn headers_parse_or_name_the_bad_line() { |
|
| 518 | + | assert_eq!( |
|
| 519 | + | parse_headers("X-Org: acme\n\n OpenAI-Beta : v1 ").unwrap(), |
|
| 520 | + | vec![("X-Org".into(), "acme".into()), ("OpenAI-Beta".into(), "v1".into())] |
|
| 521 | + | ); |
|
| 522 | + | assert_eq!(parse_headers("no colon").unwrap_err(), "no colon"); |
|
| 523 | + | assert_eq!(parse_headers("bad name: x").unwrap_err(), "bad name: x"); |
|
| 524 | + | } |
|
| 525 | + | } |
added crates/hub/src/web/assistants.rs +186 −0
| 1 | + | //! The AI assistants panel (hub spec §13.4, #97) shared by the |
|
| 2 | + | //! administration, organisation and repository settings pages: the |
|
| 3 | + | //! level's own assistants, the ones it inherits, and its prompt. |
|
| 4 | + | ||
| 5 | + | use crate::assistants::{self, Assistant, Available, Fields, Scope, APIS, DEFAULT_BUDGET}; |
|
| 6 | + | use crate::auth::Viewer; |
|
| 7 | + | use crate::db::accounts; |
|
| 8 | + | use crate::Shared; |
|
| 9 | + | use serde::Deserialize; |
|
| 10 | + | ||
| 11 | + | /// Whose panel. |
|
| 12 | + | pub struct Where { |
|
| 13 | + | pub scope: Scope, |
|
| 14 | + | /// The owner name, so a repository finds its organisation. |
|
| 15 | + | pub owner: String, |
|
| 16 | + | /// What the audit log names. |
|
| 17 | + | pub label: String, |
|
| 18 | + | } |
|
| 19 | + | ||
| 20 | + | #[derive(Deserialize)] |
|
| 21 | + | pub struct AssistantForm { |
|
| 22 | + | #[serde(rename = "_csrf")] |
|
| 23 | + | pub csrf_token: String, |
|
| 24 | + | #[serde(default)] |
|
| 25 | + | action: String, |
|
| 26 | + | #[serde(default)] |
|
| 27 | + | id: Option<i64>, |
|
| 28 | + | #[serde(default)] |
|
| 29 | + | name: String, |
|
| 30 | + | #[serde(default)] |
|
| 31 | + | endpoint: String, |
|
| 32 | + | #[serde(default)] |
|
| 33 | + | api: String, |
|
| 34 | + | #[serde(default)] |
|
| 35 | + | model: String, |
|
| 36 | + | #[serde(default)] |
|
| 37 | + | headers: String, |
|
| 38 | + | #[serde(default)] |
|
| 39 | + | token: String, |
|
| 40 | + | #[serde(default)] |
|
| 41 | + | enabled: Option<String>, |
|
| 42 | + | #[serde(default)] |
|
| 43 | + | budget: String, |
|
| 44 | + | #[serde(default)] |
|
| 45 | + | prompt: String, |
|
| 46 | + | } |
|
| 47 | + | ||
| 48 | + | /// Everything the panel shows. |
|
| 49 | + | pub struct Panel { |
|
| 50 | + | /// The level's own assistants, each with whether a level above turned |
|
| 51 | + | /// its name off. |
|
| 52 | + | pub own: Vec<Available>, |
|
| 53 | + | /// The ones the levels above provide, outermost first. |
|
| 54 | + | pub inherited: Vec<Available>, |
|
| 55 | + | pub prompt: String, |
|
| 56 | + | /// (where from, text) for each level above that set a prompt. |
|
| 57 | + | pub inherited_prompts: Vec<(String, String)>, |
|
| 58 | + | pub apis: Vec<&'static str>, |
|
| 59 | + | pub default_budget: i64, |
|
| 60 | + | } |
|
| 61 | + | ||
| 62 | + | pub async fn panel(app: &Shared, at: &Where) -> sqlx::Result<Panel> { |
|
| 63 | + | let (own, inherited): (Vec<_>, Vec<_>) = |
|
| 64 | + | assistants::available(&app.db, at.scope, &at.owner).await?.into_iter().partition(|a| a.from.is_empty()); |
|
| 65 | + | Ok(Panel { |
|
| 66 | + | own, |
|
| 67 | + | inherited, |
|
| 68 | + | prompt: assistants::prompt(&app.db, at.scope).await?, |
|
| 69 | + | inherited_prompts: assistants::inherited_prompts(&app.db, at.scope, &at.owner).await?, |
|
| 70 | + | apis: APIS.to_vec(), |
|
| 71 | + | default_budget: DEFAULT_BUDGET, |
|
| 72 | + | }) |
|
| 73 | + | } |
|
| 74 | + | ||
| 75 | + | fn name_ok(name: &str) -> bool { |
|
| 76 | + | !name.is_empty() && name.chars().count() <= 40 && name.chars().all(|c| c.is_alphanumeric() || matches!(c, ' ' | '.' | '-' | '_')) |
|
| 77 | + | } |
|
| 78 | + | ||
| 79 | + | fn endpoint_ok(url: &str) -> bool { |
|
| 80 | + | (url.starts_with("http://") || url.starts_with("https://")) && url.len() <= 2000 && !url.contains(char::is_whitespace) |
|
| 81 | + | } |
|
| 82 | + | ||
| 83 | + | /// The form's details checked: `Err` is the message for the person. |
|
| 84 | + | fn fields(f: &AssistantForm) -> Result<Fields, String> { |
|
| 85 | + | let name = f.name.trim().to_string(); |
|
| 86 | + | if !name_ok(&name) { |
|
| 87 | + | return Err("An assistant's name is up to 40 letters, digits, spaces, dots, dashes or underscores.".into()); |
|
| 88 | + | } |
|
| 89 | + | let endpoint = f.endpoint.trim().to_string(); |
|
| 90 | + | if !endpoint_ok(&endpoint) { |
|
| 91 | + | return Err("The endpoint is the full URL the request is posted to, starting with https://.".into()); |
|
| 92 | + | } |
|
| 93 | + | if !APIS.contains(&f.api.as_str()) { |
|
| 94 | + | return Err("The API is chat-completions or anthropic-messages.".into()); |
|
| 95 | + | } |
|
| 96 | + | let model = f.model.trim().to_string(); |
|
| 97 | + | if model.is_empty() || model.len() > 100 || model.contains(char::is_whitespace) { |
|
| 98 | + | return Err("A model name is needed, such as claude-opus-5-5 or gpt-5.".into()); |
|
| 99 | + | } |
|
| 100 | + | let headers = f.headers.trim().to_string(); |
|
| 101 | + | if let Err(line) = assistants::parse_headers(&headers) { |
|
| 102 | + | return Err(format!("Extra headers are one Name: value per line; this line is not: {line}")); |
|
| 103 | + | } |
|
| 104 | + | if headers.lines().count() > 10 { |
|
| 105 | + | return Err("Up to ten extra headers.".into()); |
|
| 106 | + | } |
|
| 107 | + | let budget = if f.budget.trim().is_empty() { |
|
| 108 | + | DEFAULT_BUDGET |
|
| 109 | + | } else { |
|
| 110 | + | match f.budget.trim().parse::<i64>() { |
|
| 111 | + | Ok(n) if (1_000..=2_000_000).contains(&n) => n, |
|
| 112 | + | _ => return Err("The context budget is a number of characters from 1000 to 2000000.".into()), |
|
| 113 | + | } |
|
| 114 | + | }; |
|
| 115 | + | Ok(Fields { name, endpoint, api: f.api.clone(), model, headers, enabled: f.enabled.is_some(), budget }) |
|
| 116 | + | } |
|
| 117 | + | ||
| 118 | + | /// Apply the panel's form: `Ok(notice)` or `Err(message for the user)`. |
|
| 119 | + | pub async fn apply(app: &Shared, viewer: &Viewer, at: &Where, f: AssistantForm) -> sqlx::Result<Result<String, String>> { |
|
| 120 | + | let owned = |a: Option<Assistant>| a.filter(|a| a.scope() == at.scope); |
|
| 121 | + | let actor = viewer.account(); |
|
| 122 | + | Ok(Ok(match (f.action.as_str(), f.id) { |
|
| 123 | + | ("add", None) => { |
|
| 124 | + | let fields = match fields(&f) { |
|
| 125 | + | Ok(x) => x, |
|
| 126 | + | Err(e) => return Ok(Err(e)), |
|
| 127 | + | }; |
|
| 128 | + | let token = app.assistant_key.seal(f.token.trim()); |
|
| 129 | + | match assistants::create(&app.db, at.scope, &fields, &token).await? { |
|
| 130 | + | Some(a) => { |
|
| 131 | + | accounts::audit(&app.db, actor, "assistant.added", &at.label, &a.name).await.ok(); |
|
| 132 | + | format!("{} added.", a.name) |
|
| 133 | + | } |
|
| 134 | + | None => return Ok(Err(format!("There is already an assistant called {} here.", fields.name))), |
|
| 135 | + | } |
|
| 136 | + | } |
|
| 137 | + | ("update", Some(id)) => { |
|
| 138 | + | let Some(a) = owned(assistants::get(&app.db, id).await?) else { return Ok(Err("No such assistant.".into())) }; |
|
| 139 | + | let fields = match fields(&f) { |
|
| 140 | + | Ok(x) => x, |
|
| 141 | + | Err(e) => return Ok(Err(e)), |
|
| 142 | + | }; |
|
| 143 | + | let token = f.token.trim(); |
|
| 144 | + | let sealed = (!token.is_empty()).then(|| app.assistant_key.seal(token)); |
|
| 145 | + | if !assistants::update(&app.db, id, &fields, sealed.as_deref()).await? { |
|
| 146 | + | return Ok(Err(format!("There is already an assistant called {} here.", fields.name))); |
|
| 147 | + | } |
|
| 148 | + | accounts::audit(&app.db, actor, "assistant.updated", &at.label, &a.name).await.ok(); |
|
| 149 | + | format!("{} updated.", fields.name) |
|
| 150 | + | } |
|
| 151 | + | ("toggle", Some(id)) => { |
|
| 152 | + | let Some(a) = owned(assistants::get(&app.db, id).await?) else { return Ok(Err("No such assistant.".into())) }; |
|
| 153 | + | assistants::set_enabled(&app.db, id, !a.enabled).await?; |
|
| 154 | + | accounts::audit(&app.db, actor, if a.enabled { "assistant.disabled" } else { "assistant.enabled" }, &at.label, &a.name) |
|
| 155 | + | .await |
|
| 156 | + | .ok(); |
|
| 157 | + | format!("{} is {}.", a.name, if a.enabled { "off" } else { "on" }) |
|
| 158 | + | } |
|
| 159 | + | ("delete", Some(id)) => { |
|
| 160 | + | let Some(a) = owned(assistants::get(&app.db, id).await?) else { return Ok(Err("No such assistant.".into())) }; |
|
| 161 | + | assistants::delete(&app.db, id).await?; |
|
| 162 | + | accounts::audit(&app.db, actor, "assistant.deleted", &at.label, &a.name).await.ok(); |
|
| 163 | + | format!("{} deleted.", a.name) |
|
| 164 | + | } |
|
| 165 | + | ("test", Some(id)) => { |
|
| 166 | + | let Some(a) = owned(assistants::get(&app.db, id).await?) else { return Ok(Err("No such assistant.".into())) }; |
|
| 167 | + | match assistants::test(app, &a).await { |
|
| 168 | + | Ok(answer) => format!("{} answered: {answer}", a.name), |
|
| 169 | + | Err(why) => return Ok(Err(format!("{} did not answer: {why}", a.name))), |
|
| 170 | + | } |
|
| 171 | + | } |
|
| 172 | + | ("prompt", _) => { |
|
| 173 | + | if f.prompt.chars().count() > 20_000 { |
|
| 174 | + | return Ok(Err("A prompt is up to 20000 characters.".into())); |
|
| 175 | + | } |
|
| 176 | + | assistants::set_prompt(&app.db, at.scope, &f.prompt).await?; |
|
| 177 | + | accounts::audit(&app.db, actor, "assistant.prompt", &at.label, "").await.ok(); |
|
| 178 | + | if f.prompt.trim().is_empty() { |
|
| 179 | + | "Prompt cleared.".to_string() |
|
| 180 | + | } else { |
|
| 181 | + | "Prompt saved.".to_string() |
|
| 182 | + | } |
|
| 183 | + | } |
|
| 184 | + | _ => return Ok(Err("Unknown action.".into())), |
|
| 185 | + | })) |
|
| 186 | + | } |
added templates/assistants_panel.html +69 −0
| 1 | + | {% macro panel(base, a, csrf, what) %} |
|
| 2 | + | <section class="panel" id="assistants"> |
|
| 3 | + | <h2>AI assistants</h2> |
|
| 4 | + | <p class="small mute">{{ what }} An assistant reviews a proposal when asked, or drafts its description; its answers are shown as the person who asked, via the assistant. It never approves.</p> |
|
| 5 | + | {% if !a.inherited.is_empty() %} |
|
| 6 | + | <h3>Inherited</h3> |
|
| 7 | + | <ul class="hooks"> |
|
| 8 | + | {% for i in a.inherited %} |
|
| 9 | + | <li class="hook"> |
|
| 10 | + | <div class="hook-head"><strong>{{ i.assistant.name }}</strong> <span class="tag">{{ i.assistant.model }}</span> |
|
| 11 | + | {% if i.on() %}<span class="tag head">on</span>{% else if i.off_above %}<span class="tag dim">off above</span>{% else %}<span class="tag dim">off</span>{% endif %} |
|
| 12 | + | <span class="small mute">from {{ i.from }}</span></div> |
|
| 13 | + | </li> |
|
| 14 | + | {% endfor %} |
|
| 15 | + | </ul> |
|
| 16 | + | {% endif %} |
|
| 17 | + | {% if a.own.is_empty() %}<p class="mute small">{% if a.inherited.is_empty() %}None.{% else %}None of its own.{% endif %}</p>{% else %} |
|
| 18 | + | <ul class="hooks"> |
|
| 19 | + | {% for o in a.own %}{% let r = o.assistant %} |
|
| 20 | + | <li class="hook"> |
|
| 21 | + | <div class="hook-head"> |
|
| 22 | + | <strong>{{ r.name }}</strong> <span class="tag">{{ r.model }}</span> <span class="tag">{{ r.api }}</span> |
|
| 23 | + | {% if o.on() %}<span class="tag head">on</span>{% else if o.off_above %}<span class="tag dim" title="An assistant of this name is turned off at a level above">off above</span>{% else %}<span class="tag dim">off</span>{% endif %} |
|
| 24 | + | </div> |
|
| 25 | + | <p class="small mute"><code>{{ r.endpoint }}</code> · token {% if r.has_token() %}set{% else %}none{% endif %} · context {{ r.budget }} characters</p> |
|
| 26 | + | <form class="tiny" method="post" action="{{ base }}/assistants"><input type="hidden" name="_csrf" value="{{ csrf }}"><input type="hidden" name="id" value="{{ r.id }}"><button name="action" value="toggle">{% if r.enabled %}turn off{% else %}turn on{% endif %}</button> <button name="action" value="test">test</button> <button name="action" value="delete" class="danger-btn" onclick="return confirm('Delete {{ r.name }}?')">delete</button></form> |
|
| 27 | + | <details class="hook-edit"><summary class="small">edit</summary> |
|
| 28 | + | <form class="card" method="post" action="{{ base }}/assistants"> |
|
| 29 | + | <input type="hidden" name="_csrf" value="{{ csrf }}"><input type="hidden" name="id" value="{{ r.id }}"><input type="hidden" name="action" value="update"> |
|
| 30 | + | <label for="an{{ r.id }}">Name</label><input type="text" id="an{{ r.id }}" name="name" value="{{ r.name }}"> |
|
| 31 | + | <label for="ae{{ r.id }}">Endpoint</label><input type="text" id="ae{{ r.id }}" name="endpoint" value="{{ r.endpoint }}" autocomplete="off"> |
|
| 32 | + | <label for="aa{{ r.id }}">API</label> |
|
| 33 | + | <select id="aa{{ r.id }}" name="api">{% for api in a.apis %}<option value="{{ api }}"{% if r.api == *api %} selected{% endif %}>{{ api }}</option>{% endfor %}</select> |
|
| 34 | + | <label for="am{{ r.id }}">Model</label><input type="text" id="am{{ r.id }}" name="model" value="{{ r.model }}"> |
|
| 35 | + | <label for="at{{ r.id }}">Token</label><input type="password" id="at{{ r.id }}" name="token" placeholder="{% if r.has_token() %}unchanged{% else %}none{% endif %}" autocomplete="new-password"> |
|
| 36 | + | <label for="ah{{ r.id }}">Extra headers</label><textarea id="ah{{ r.id }}" name="headers" rows="2" placeholder="Name: value">{{ r.headers }}</textarea> |
|
| 37 | + | <label for="ab{{ r.id }}">Context budget (characters)</label><input type="text" id="ab{{ r.id }}" name="budget" value="{{ r.budget }}"> |
|
| 38 | + | <div class="choice"><label><input type="checkbox" name="enabled" value="1"{% if r.enabled %} checked{% endif %}> on</label></div> |
|
| 39 | + | <button type="submit">SAVE</button> |
|
| 40 | + | </form> |
|
| 41 | + | </details> |
|
| 42 | + | </li> |
|
| 43 | + | {% endfor %} |
|
| 44 | + | </ul>{% endif %} |
|
| 45 | + | <form class="card" method="post" action="{{ base }}/assistants"> |
|
| 46 | + | <input type="hidden" name="_csrf" value="{{ csrf }}"><input type="hidden" name="action" value="add"> |
|
| 47 | + | <h3>Add an assistant</h3> |
|
| 48 | + | <label for="aname">Name</label><input type="text" id="aname" name="name" placeholder="Claude" required> |
|
| 49 | + | <label for="aendpoint">Endpoint</label><input type="text" id="aendpoint" name="endpoint" placeholder="https://api.anthropic.com/v1/messages" required autocomplete="off"> |
|
| 50 | + | <label for="aapi">API</label> |
|
| 51 | + | <select id="aapi" name="api">{% for api in a.apis %}<option value="{{ api }}">{{ api }}{% if *api == "chat-completions" %} (OpenAI, DeepSeek, Ollama, most others){% else %} (Anthropic){% endif %}</option>{% endfor %}</select> |
|
| 52 | + | <label for="amodel">Model</label><input type="text" id="amodel" name="model" placeholder="claude-opus-5-5" required> |
|
| 53 | + | <label for="atoken">Token</label><input type="password" id="atoken" name="token" autocomplete="new-password"> |
|
| 54 | + | <p class="small mute">Sent as a bearer token, or as the API key for Anthropic; kept sealed on the server and never shown again. Leave it empty for an endpoint that needs none.</p> |
|
| 55 | + | <label for="aheaders">Extra headers</label><textarea id="aheaders" name="headers" rows="2" placeholder="Name: value"></textarea> |
|
| 56 | + | <label for="abudget">Context budget (characters)</label><input type="text" id="abudget" name="budget" value="{{ a.default_budget }}"> |
|
| 57 | + | <div class="choice"><label><input type="checkbox" name="enabled" value="1" checked> on</label></div> |
|
| 58 | + | <button type="submit">ADD</button> |
|
| 59 | + | </form> |
|
| 60 | + | <form class="card" method="post" action="{{ base }}/assistants"> |
|
| 61 | + | <input type="hidden" name="_csrf" value="{{ csrf }}"><input type="hidden" name="action" value="prompt"> |
|
| 62 | + | <h3>Prompt</h3> |
|
| 63 | + | {% for (from, text) in a.inherited_prompts %}<details><summary class="small mute">From {{ from }}</summary><pre class="snippet">{{ text }}</pre></details>{% endfor %} |
|
| 64 | + | <label for="aprompt">What every assistant is told here{% if !a.inherited_prompts.is_empty() %}, after the prompts above{% endif %}</label> |
|
| 65 | + | <textarea id="aprompt" name="prompt" rows="6" placeholder="Review for correctness first. This project is written in Rust; the style guide is in docs/style.md.">{{ a.prompt }}</textarea> |
|
| 66 | + | <button type="submit">SAVE PROMPT</button> |
|
| 67 | + | </form> |
|
| 68 | + | </section> |
|
| 69 | + | {% endmacro %} |
modified CHANGELOG.md +2 −0
| … | 3 unchanged lines |
||
| 4 | 4 | ||
| 5 | 5 | ## Unreleased |
|
| 6 | 6 | ||
| 7 | + | - **AI assistants, step one: settings (#97).** Administration, an organisation's settings and a repository's settings each hold a list of assistants (name, endpoint, API shape, model, extra headers, token, context budget, on or off) and a prompt. A repository sees its organisation's and the site's beside its own; the innermost of a name wins and one turned off above is off below. Tokens are sealed with ChaCha20-Poly1305 under `keys/assistants.key` and never shown again; TEST sends a one-line exchange. Review requests and drafted descriptions follow. |
|
| 8 | + | ||
| 7 | 9 | - **A runner's own labels and time limit (#98).** A runner's page under Admin → Runners has a Settings form: labels an administrator gives it beside those it reports (jobs that ask for a label match either), and a job time limit in minutes below the site's, applied when it claims. |
|
| 8 | 10 | ||
| 9 | 11 | - **Mail settings on the admin page (#101).** The SMTP URL and From address are set and saved from Administration, overriding `hub.toml`'s `[email]`, with a button that sends a test message to the administrator's own address; the password is kept on the server and shown masked. An empty URL turns mail off. |
|
| … | 89 unchanged lines |
modified Cargo.lock +1 −0
| … | 2240 unchanged lines |
||
| 2241 | 2241 | "rcgen", |
|
| 2242 | 2242 | "regex", |
|
| 2243 | 2243 | "reqwest", |
|
| 2244 | + | "ring", |
|
| 2244 | 2245 | "rustls", |
|
| 2245 | 2246 | "serde", |
|
| 2246 | 2247 | "serde_json", |
|
| … | 2404 unchanged lines |
modified crates/hub/Cargo.toml +2 −0
| … | 67 unchanged lines |
||
| 68 | 68 | lettre.workspace = true |
|
| 69 | 69 | regex.workspace = true |
|
| 70 | 70 | reqwest.workspace = true |
|
| 71 | + | # Seals assistant tokens at rest (hub spec §13.4); already in the tree through rustls. |
|
| 72 | + | ring = "0.17" |
|
| 71 | 73 | futures.workspace = true |
|
| 72 | 74 | tokio-stream.workspace = true |
|
| 73 | 75 | ||
| … | 4 unchanged lines |
modified crates/hub/src/lib.rs +5 −0
| … | 4 unchanged lines |
||
| 5 | 5 | ||
| 6 | 6 | pub mod api; |
|
| 7 | 7 | pub mod apt; |
|
| 8 | + | pub mod assistants; |
|
| 8 | 9 | pub mod auth; |
|
| 9 | 10 | pub mod config; |
|
| 10 | 11 | pub mod db; |
|
| … | 23 unchanged lines |
||
| 34 | 35 | pub server: Arc<oxy_server::Server>, |
|
| 35 | 36 | pub hub_key: keys::HubKey, |
|
| 36 | 37 | pub cookie_key: auth::CookieKey, |
|
| 38 | + | /// Seals AI assistants' tokens at rest (hub spec §13.4). |
|
| 39 | + | pub assistant_key: assistants::SecretKey, |
|
| 37 | 40 | pub events: events::Events, |
|
| 38 | 41 | /// Who is resolving which conflict together (hub spec §6.10). |
|
| 39 | 42 | pub rooms: Arc<live::Rooms>, |
|
| … | 13 unchanged lines |
||
| 53 | 56 | let repos = Arc::new(Repos::new(config.repos_dir())); |
|
| 54 | 57 | let hub_key = keys::ensure_identity(&config.keys_dir(), &config.site.name, config.base_url())?; |
|
| 55 | 58 | let cookie_key = auth::CookieKey::load_or_create(&config.keys_dir())?; |
|
| 59 | + | let assistant_key = assistants::SecretKey::load_or_create(&config.keys_dir())?; |
|
| 56 | 60 | let backend = protocol::HubBackend::new(repos.clone(), db.clone(), config.auth.push_tokens.clone(), tokio::runtime::Handle::current()); |
|
| 57 | 61 | let slot = backend.app_slot(); |
|
| 58 | 62 | let server = oxy_server::Server::new(backend); |
|
| … | 6 unchanged lines |
||
| 65 | 69 | server, |
|
| 66 | 70 | hub_key, |
|
| 67 | 71 | cookie_key, |
|
| 72 | + | assistant_key, |
|
| 68 | 73 | events: events::Events::default(), |
|
| 69 | 74 | rooms: Arc::new(live::Rooms::default()), |
|
| 70 | 75 | started: std::time::Instant::now(), |
|
| … | 50 unchanged lines |
modified crates/hub/src/web/admin.rs +21 −0
| … | 27 unchanged lines |
||
| 28 | 28 | mail_from: String, |
|
| 29 | 29 | mail_enabled: bool, |
|
| 30 | 30 | my_email: String, |
|
| 31 | + | /// The site's AI assistants and prompt (#97). |
|
| 32 | + | assistants: super::assistants::Panel, |
|
| 31 | 33 | } |
|
| 32 | 34 | ||
| 33 | 35 | fn require_admin(viewer: &Viewer, shell: &Shell) -> Result<accounts::Account, Error> { |
|
| … | 16 unchanged lines |
||
| 50 | 52 | Some(u) => accounts::account_by_name(&app.db, u).await.map_err(e)?.map(|a| a.email).unwrap_or_default(), |
|
| 51 | 53 | None => String::new(), |
|
| 52 | 54 | }; |
|
| 55 | + | let assistants = super::assistants::panel(app, &site_assistants()).await.map_err(e)?; |
|
| 53 | 56 | Ok(Page(AdminPage { |
|
| 57 | + | assistants, |
|
| 54 | 58 | shell, |
|
| 55 | 59 | accounts: accounts_list, |
|
| 56 | 60 | orgs, |
|
| … | 304 unchanged lines |
||
| 361 | 365 | let error = (!failed.is_empty()).then(|| failed.join("; ")); |
|
| 362 | 366 | storage_page(&app, shell, Some(summary), error).await |
|
| 363 | 367 | } |
|
| 368 | + | ||
| 369 | + | fn site_assistants() -> super::assistants::Where { |
|
| 370 | + | super::assistants::Where { scope: crate::assistants::Scope::Site, owner: String::new(), label: "site".into() } |
|
| 371 | + | } |
|
| 372 | + | ||
| 373 | + | /// `POST /admin/assistants`: the site's assistants and prompt (#97). |
|
| 374 | + | pub async fn assistants( |
|
| 375 | + | State(app): State<Shared>, |
|
| 376 | + | viewer: Viewer, |
|
| 377 | + | Form(f): Form<super::assistants::AssistantForm>, |
|
| 378 | + | ) -> Result<Page<AdminPage>, Error> { |
|
| 379 | + | let shell = Shell::new(&app, "/admin", &viewer); |
|
| 380 | + | require_admin(&viewer, &shell)?; |
|
| 381 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 382 | + | let outcome = super::assistants::apply(&app, &viewer, &site_assistants(), f).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 383 | + | admin_page(&app, shell, Some(outcome.unwrap_or_else(|e| e))).await |
|
| 384 | + | } |
modified crates/hub/src/web/mod.rs +4 −0
| … | 3 unchanged lines |
||
| 4 | 4 | //! render. |
|
| 5 | 5 | ||
| 6 | 6 | mod admin; |
|
| 7 | + | mod assistants; |
|
| 7 | 8 | mod avatar; |
|
| 8 | 9 | pub mod chain; |
|
| 9 | 10 | mod device; |
|
| … | 72 unchanged lines |
||
| 82 | 83 | .route("/orgs/{name}/settings/members", post(orgs::member)) |
|
| 83 | 84 | .route("/orgs/{name}/settings/teams", post(orgs::team)) |
|
| 84 | 85 | .route("/orgs/{name}/settings/hooks", post(orgs::hooks)) |
|
| 86 | + | .route("/orgs/{name}/settings/assistants", post(orgs::assistants)) |
|
| 85 | 87 | .route("/orgs/{name}/settings/runners/token", post(orgs::runner_token)) |
|
| 86 | 88 | .route("/orgs/{name}/settings/runners/{id}/remove", post(orgs::runner_remove)) |
|
| 87 | 89 | .route("/orgs/{name}/settings/hooks/{id}/deliveries", get(orgs::deliveries)) |
|
| … | 18 unchanged lines |
||
| 106 | 108 | .route("/admin/runners/{id}/limits", post(runners::set_limits)) |
|
| 107 | 109 | .route("/admin/mail", post(admin::set_mail)) |
|
| 108 | 110 | .route("/admin/mail/test", post(admin::test_mail)) |
|
| 111 | + | .route("/admin/assistants", post(admin::assistants)) |
|
| 109 | 112 | .route("/admin/runners/{id}/remove", post(runners::remove)) |
|
| 110 | 113 | .nest("/api/v1", crate::api::router(app.config.limits.asset_limit.0 as usize)) |
|
| 111 | 114 | .route("/{owner}", get(home::owner)) |
|
| … | 50 unchanged lines |
||
| 162 | 165 | .route("/{owner}/{name}/issue/{number}/project", post(projects::place_issue)) |
|
| 163 | 166 | .route("/{owner}/{name}/proposal/{number}/events", get(proposals::events)) |
|
| 164 | 167 | .route("/{owner}/{name}/settings/hooks", post(repo_settings::hooks)) |
|
| 168 | + | .route("/{owner}/{name}/settings/assistants", post(repo_settings::assistants)) |
|
| 165 | 169 | .route("/{owner}/{name}/settings/hooks/{id}/deliveries", get(repo_settings::deliveries)) |
|
| 166 | 170 | .route("/{owner}/{name}/settings/hooks/{id}/redeliver/{delivery}", post(repo_settings::redeliver)) |
|
| 167 | 171 | .route("/{owner}/{name}/settings/checks", post(repo_settings::required_checks)) |
|
| … | 460 unchanged lines |
modified crates/hub/src/web/orgs.rs +25 −1
| … | 69 unchanged lines |
||
| 70 | 70 | runners: Vec<(crate::db::runners::Runner, String, bool)>, |
|
| 71 | 71 | /// A registration token just made, shown once. |
|
| 72 | 72 | runner_token: Option<String>, |
|
| 73 | + | /// The organisation's AI assistants and prompt (#97). |
|
| 74 | + | assistants: super::assistants::Panel, |
|
| 73 | 75 | } |
|
| 74 | 76 | ||
| 75 | 77 | async fn org_page( |
|
| … | 21 unchanged lines |
||
| 97 | 99 | }; |
|
| 98 | 100 | runners.push((r, serves, own)); |
|
| 99 | 101 | } |
|
| 100 | - | Ok(Page(OrgSettingsPage { shell, org, members, teams, hooks, notice, error, runners, runner_token: None })) |
|
| 102 | + | let assistants = super::assistants::panel(app, &org_assistants(&org)).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 103 | + | Ok(Page(OrgSettingsPage { shell, org, members, teams, hooks, notice, error, runners, runner_token: None, assistants })) |
|
| 101 | 104 | } |
|
| 102 | 105 | ||
| 103 | 106 | async fn owned_org(app: &Shared, shell: &Shell, viewer: &Viewer, name: &str) -> Result<(accounts::Account, accounts::Organisation), Error> { |
|
| … | 255 unchanged lines |
||
| 359 | 362 | accounts::audit(&app.db, Some(&me), "flow.runner.removed", &r.name, &org.name).await.ok(); |
|
| 360 | 363 | org_page(&app, shell, org, Some(format!("{} removed: its credential no longer works.", r.name)), None).await |
|
| 361 | 364 | } |
|
| 365 | + | ||
| 366 | + | fn org_assistants(org: &accounts::Organisation) -> super::assistants::Where { |
|
| 367 | + | super::assistants::Where { scope: crate::assistants::Scope::Org(org.id), owner: org.name.clone(), label: org.name.clone() } |
|
| 368 | + | } |
|
| 369 | + | ||
| 370 | + | /// `POST /orgs/{name}/settings/assistants`: the organisation's assistants |
|
| 371 | + | /// and prompt (#97). |
|
| 372 | + | pub async fn assistants( |
|
| 373 | + | State(app): State<Shared>, |
|
| 374 | + | viewer: Viewer, |
|
| 375 | + | Path(name): Path<String>, |
|
| 376 | + | Form(f): Form<super::assistants::AssistantForm>, |
|
| 377 | + | ) -> Result<Page<OrgSettingsPage>, Error> { |
|
| 378 | + | let shell = Shell::new(&app, &format!("/orgs/{name}/settings"), &viewer); |
|
| 379 | + | let (_, org) = owned_org(&app, &shell, &viewer, &name).await?; |
|
| 380 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 381 | + | match super::assistants::apply(&app, &viewer, &org_assistants(&org), f).await.map_err(|e| Error::from_db(shell.clone(), e))? { |
|
| 382 | + | Ok(notice) => org_page(&app, shell, org, Some(notice), None).await, |
|
| 383 | + | Err(error) => org_page(&app, shell, org, None, Some(error)).await, |
|
| 384 | + | } |
|
| 385 | + | } |
modified crates/hub/src/web/repo_settings.rs +30 −0
| … | 99 unchanged lines |
||
| 100 | 100 | runners: Vec<(db::runners::Runner, String, bool)>, |
|
| 101 | 101 | /// A registration token just made, shown once. |
|
| 102 | 102 | runner_token: Option<String>, |
|
| 103 | + | /// The repository's AI assistants and prompt (#97). |
|
| 104 | + | assistants: super::assistants::Panel, |
|
| 103 | 105 | } |
|
| 104 | 106 | ||
| 105 | 107 | async fn settings_page( |
|
| … | 33 unchanged lines |
||
| 139 | 141 | (r, serves, own) |
|
| 140 | 142 | }) |
|
| 141 | 143 | .collect(); |
|
| 144 | + | let assistants = super::assistants::panel(app, &repo_assistants(&row)).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 142 | 145 | Ok(Page(RepoSettingsPage { |
|
| 146 | + | assistants, |
|
| 143 | 147 | runners, |
|
| 144 | 148 | runner_token: None, |
|
| 145 | 149 | shell, |
|
| … | 150 unchanged lines |
||
| 296 | 300 | let _guard = app.repos.write_guard(&repo).await; |
|
| 297 | 301 | app.repos.trash(&repo).map_err(|e| Error::from_repo(shell.clone(), e))?; |
|
| 298 | 302 | db::repos::delete(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 303 | + | crate::assistants::delete_scope(&app.db, crate::assistants::Scope::Repo(row.id)).await.ok(); |
|
| 299 | 304 | accounts::audit(&app.db, viewer.account(), "repo.deleted", &repo.to_string(), "moved to trash").await.ok(); |
|
| 300 | 305 | Ok(Redirect::to(&format!("/{owner}")).into_response()) |
|
| 301 | 306 | } |
|
| … | 215 unchanged lines |
||
| 517 | 522 | accounts::audit(&app.db, viewer.account(), "flow.runner.removed", &r.name, &row.slug()).await.ok(); |
|
| 518 | 523 | settings_page(&app, shell, row, role, Some(format!("{} removed: its credential no longer works.", r.name)), None).await |
|
| 519 | 524 | } |
|
| 525 | + | ||
| 526 | + | fn repo_assistants(row: &db::repos::RepoRow) -> super::assistants::Where { |
|
| 527 | + | super::assistants::Where { |
|
| 528 | + | scope: crate::assistants::Scope::Repo(row.id), |
|
| 529 | + | owner: row.owner.clone(), |
|
| 530 | + | label: format!("{}/{}", row.owner, row.name), |
|
| 531 | + | } |
|
| 532 | + | } |
|
| 533 | + | ||
| 534 | + | /// `POST /{owner}/{name}/settings/assistants`: the repository's assistants |
|
| 535 | + | /// and prompt (#97), for its admins. |
|
| 536 | + | pub async fn assistants( |
|
| 537 | + | State(app): State<Shared>, |
|
| 538 | + | viewer: Viewer, |
|
| 539 | + | Path((owner, name)): Path<(String, String)>, |
|
| 540 | + | Form(f): Form<super::assistants::AssistantForm>, |
|
| 541 | + | ) -> Result<Page<RepoSettingsPage>, Error> { |
|
| 542 | + | let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer); |
|
| 543 | + | let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?; |
|
| 544 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 545 | + | match super::assistants::apply(&app, &viewer, &repo_assistants(&row), f).await.map_err(|e| Error::from_db(shell.clone(), e))? { |
|
| 546 | + | Ok(notice) => settings_page(&app, shell, row, role, Some(notice), None).await, |
|
| 547 | + | Err(error) => settings_page(&app, shell, row, role, None, Some(error)).await, |
|
| 548 | + | } |
|
| 549 | + | } |
modified crates/hub/tests/accounts.rs +164 −0
| … | 625 unchanged lines |
||
| 626 | 626 | let r = post(app, "/admin/mail", None, &format!("_csrf={csrf}&smtp=&from=")).await; |
|
| 627 | 627 | assert_ne!(r.status, StatusCode::OK); |
|
| 628 | 628 | } |
|
| 629 | + | ||
| 630 | + | /// A stand-in chat-completions endpoint: answers "OK from mock" and keeps |
|
| 631 | + | /// the Authorization header and body of each call. |
|
| 632 | + | async fn mock_assistant() -> (String, std::sync::Arc<std::sync::Mutex<Vec<String>>>) { |
|
| 633 | + | use std::sync::{Arc, Mutex}; |
|
| 634 | + | let seen: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(vec![])); |
|
| 635 | + | let s2 = seen.clone(); |
|
| 636 | + | let router = axum::Router::new().route( |
|
| 637 | + | "/v1/chat/completions", |
|
| 638 | + | axum::routing::post(move |headers: axum::http::HeaderMap, body: String| { |
|
| 639 | + | let s2 = s2.clone(); |
|
| 640 | + | async move { |
|
| 641 | + | let auth = headers.get("authorization").and_then(|v| v.to_str().ok()).unwrap_or("").to_string(); |
|
| 642 | + | s2.lock().unwrap().push(format!("{auth} {body}")); |
|
| 643 | + | axum::Json(serde_json::json!({ "choices": [{ "message": { "role": "assistant", "content": "OK from mock" } }] })) |
|
| 644 | + | } |
|
| 645 | + | }), |
|
| 646 | + | ); |
|
| 647 | + | let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); |
|
| 648 | + | let port = listener.local_addr().unwrap().port(); |
|
| 649 | + | tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); |
|
| 650 | + | (format!("http://127.0.0.1:{port}/v1/chat/completions"), seen) |
|
| 651 | + | } |
|
| 652 | + | ||
| 653 | + | fn enc(s: &str) -> String { |
|
| 654 | + | s.replace('%', "%25").replace(':', "%3A").replace('/', "%2F").replace(' ', "+").replace('\n', "%0A").replace('&', "%26") |
|
| 655 | + | } |
|
| 656 | + | ||
| 657 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 658 | + | async fn ai_assistants_are_kept_per_level_with_sealed_tokens_and_combined_downwards() { |
|
| 659 | + | use oxygenhub::assistants::{self, Scope}; |
|
| 660 | + | let h = hub().await; |
|
| 661 | + | let app = &h.app; |
|
| 662 | + | let andy = register(app, "andy", "").await; |
|
| 663 | + | let (endpoint, seen) = mock_assistant().await; |
|
| 664 | + | ||
| 665 | + | // The site level: an administrator adds Claude with a token. |
|
| 666 | + | let page = get(app, "/admin", Some(&andy)).await.body; |
|
| 667 | + | assert!(page.contains("AI assistants") && page.contains("Add an assistant"), "{page}"); |
|
| 668 | + | let csrf = csrf_of(&page); |
|
| 669 | + | let add = |name: &str, extra: &str| { |
|
| 670 | + | format!( |
|
| 671 | + | "_csrf={csrf}&action=add&name={name}&endpoint={}&api=chat-completions&model=claude-opus-5-5&token=sk-test-secret&enabled=1&budget=50000{extra}", |
|
| 672 | + | enc(&endpoint) |
|
| 673 | + | ) |
|
| 674 | + | }; |
|
| 675 | + | let r = post(app, "/admin/assistants", Some(&andy), &add("Claude", "")).await; |
|
| 676 | + | assert!(r.body.contains("Claude added.") && r.body.contains("token set"), "{}", r.body); |
|
| 677 | + | assert!(!r.body.contains("sk-test-secret"), "the token never comes back"); |
|
| 678 | + | let site = assistants::list(&app.db, Scope::Site).await.unwrap(); |
|
| 679 | + | assert_eq!(site.len(), 1); |
|
| 680 | + | let claude = &site[0]; |
|
| 681 | + | assert!(!String::from_utf8_lossy(&claude.token).contains("sk-test"), "sealed at rest"); |
|
| 682 | + | assert_eq!(app.assistant_key.open(&claude.token).as_deref(), Some("sk-test-secret")); |
|
| 683 | + | // A second of the same name is refused; a bad endpoint is explained. |
|
| 684 | + | let r = post(app, "/admin/assistants", Some(&andy), &add("claude", "")).await; |
|
| 685 | + | assert!(r.body.contains("already an assistant called"), "{}", r.body); |
|
| 686 | + | let r = post( |
|
| 687 | + | app, |
|
| 688 | + | "/admin/assistants", |
|
| 689 | + | Some(&andy), |
|
| 690 | + | &format!("_csrf={csrf}&action=add&name=Bad&endpoint=ftp%3A%2F%2Fx&api=chat-completions&model=m"), |
|
| 691 | + | ) |
|
| 692 | + | .await; |
|
| 693 | + | assert!(r.body.contains("starting with https://"), "{}", r.body); |
|
| 694 | + | ||
| 695 | + | // TEST calls the endpoint with the bearer token and shows the answer. |
|
| 696 | + | let r = post(app, "/admin/assistants", Some(&andy), &format!("_csrf={csrf}&action=test&id={}", claude.id)).await; |
|
| 697 | + | assert!(r.body.contains("Claude answered: OK from mock"), "{}", r.body); |
|
| 698 | + | { |
|
| 699 | + | let calls = seen.lock().unwrap(); |
|
| 700 | + | assert_eq!(calls.len(), 1); |
|
| 701 | + | assert!(calls[0].starts_with("Bearer sk-test-secret "), "{}", calls[0]); |
|
| 702 | + | assert!(calls[0].contains("\"model\":\"claude-opus-5-5\"") && calls[0].contains("Reply with the single word OK"), "{}", calls[0]); |
|
| 703 | + | } |
|
| 704 | + | // An update with the token field empty keeps the stored token. |
|
| 705 | + | let r = post( |
|
| 706 | + | app, |
|
| 707 | + | "/admin/assistants", |
|
| 708 | + | Some(&andy), |
|
| 709 | + | &format!("_csrf={csrf}&action=update&id={}&name=Claude&endpoint={}&api=chat-completions&model=claude-opus-5-5&token=&enabled=1&budget=60000", claude.id, enc(&endpoint)), |
|
| 710 | + | ) |
|
| 711 | + | .await; |
|
| 712 | + | assert!(r.body.contains("Claude updated.") && r.body.contains("context 60000 characters"), "{}", r.body); |
|
| 713 | + | assert_eq!( |
|
| 714 | + | app.assistant_key.open(&assistants::get(&app.db, claude.id).await.unwrap().unwrap().token).as_deref(), |
|
| 715 | + | Some("sk-test-secret") |
|
| 716 | + | ); |
|
| 717 | + | // The site prompt. |
|
| 718 | + | let r = |
|
| 719 | + | post(app, "/admin/assistants", Some(&andy), &format!("_csrf={csrf}&action=prompt&prompt={}", enc("Review for correctness first."))) |
|
| 720 | + | .await; |
|
| 721 | + | assert!(r.body.contains("Prompt saved.") && r.body.contains("Review for correctness first."), "{}", r.body); |
|
| 722 | + | ||
| 723 | + | // An organisation and its repository see the site's assistant and prompt. |
|
| 724 | + | let r = post(app, "/orgs/new", Some(&andy), &format!("_csrf={csrf}&name=acme&display_name=Acme")).await; |
|
| 725 | + | assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); |
|
| 726 | + | let r = post(app, "/new", Some(&andy), &format!("_csrf={csrf}&owner=acme&name=plans&visibility=public")).await; |
|
| 727 | + | assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); |
|
| 728 | + | let org_page = get(app, "/orgs/acme/settings", Some(&andy)).await.body; |
|
| 729 | + | assert!(org_page.contains("Inherited") && org_page.contains("from the site") && org_page.contains("None of its own"), "{org_page}"); |
|
| 730 | + | assert!(org_page.contains("From the site") && org_page.contains("Review for correctness first."), "{org_page}"); |
|
| 731 | + | let repo = db::repos::get(&app.db, "acme", "plans").await.unwrap().unwrap(); |
|
| 732 | + | let on = assistants::enabled_for_repo(&app.db, &repo).await.unwrap(); |
|
| 733 | + | assert_eq!(on.iter().map(|a| a.name.as_str()).collect::<Vec<_>>(), ["Claude"]); |
|
| 734 | + | ||
| 735 | + | // The organisation adds a prompt and its own DeepSeek; the repository |
|
| 736 | + | // sees both levels' prompts under headings, outermost first. |
|
| 737 | + | let r = post( |
|
| 738 | + | app, |
|
| 739 | + | "/orgs/acme/settings/assistants", |
|
| 740 | + | Some(&andy), |
|
| 741 | + | &format!("_csrf={csrf}&action=prompt&prompt={}", enc("We write 6502 assembly.")), |
|
| 742 | + | ) |
|
| 743 | + | .await; |
|
| 744 | + | assert!(r.body.contains("Prompt saved."), "{}", r.body); |
|
| 745 | + | let r = post(app, "/orgs/acme/settings/assistants", Some(&andy), &add("DeepSeek", "")).await; |
|
| 746 | + | assert!(r.body.contains("DeepSeek added."), "{}", r.body); |
|
| 747 | + | let prompt = assistants::prompt_for_repo(&app.db, &repo).await.unwrap(); |
|
| 748 | + | assert!(prompt.starts_with("## Instructions from the site\n\nReview for correctness first.\n\n## Instructions from the acme organisation\n\nWe write 6502 assembly."), "{prompt}"); |
|
| 749 | + | let repo_page = get(app, "/acme/plans/settings", Some(&andy)).await.body; |
|
| 750 | + | assert!(repo_page.contains("from the acme organisation") && repo_page.contains("From the acme organisation"), "{repo_page}"); |
|
| 751 | + | let on = assistants::enabled_for_repo(&app.db, &repo).await.unwrap(); |
|
| 752 | + | assert_eq!(on.iter().map(|a| a.name.as_str()).collect::<Vec<_>>(), ["Claude", "DeepSeek"]); |
|
| 753 | + | ||
| 754 | + | // Turning Claude off at the site turns it off below, even where a |
|
| 755 | + | // repository defines its own Claude; turning it back on lets the |
|
| 756 | + | // repository's own definition win. |
|
| 757 | + | let r = post(app, "/admin/assistants", Some(&andy), &format!("_csrf={csrf}&action=toggle&id={}", claude.id)).await; |
|
| 758 | + | assert!(r.body.contains("Claude is off."), "{}", r.body); |
|
| 759 | + | let r = post(app, "/acme/plans/settings/assistants", Some(&andy), &add("Claude", "")).await; |
|
| 760 | + | assert!(r.body.contains("Claude added."), "{}", r.body); |
|
| 761 | + | let on = assistants::enabled_for_repo(&app.db, &repo).await.unwrap(); |
|
| 762 | + | assert_eq!(on.iter().map(|a| a.name.as_str()).collect::<Vec<_>>(), ["DeepSeek"]); |
|
| 763 | + | let repo_page = get(app, "/acme/plans/settings", Some(&andy)).await.body; |
|
| 764 | + | assert!(repo_page.contains("off above"), "{repo_page}"); |
|
| 765 | + | post(app, "/admin/assistants", Some(&andy), &format!("_csrf={csrf}&action=toggle&id={}", claude.id)).await; |
|
| 766 | + | let on = assistants::enabled_for_repo(&app.db, &repo).await.unwrap(); |
|
| 767 | + | assert_eq!(on.iter().map(|a| (a.name.as_str(), a.scope.as_str())).collect::<Vec<_>>(), [("DeepSeek", "org"), ("Claude", "repo")]); |
|
| 768 | + | ||
| 769 | + | // Only administrators and owners reach the panels. |
|
| 770 | + | let invite = { |
|
| 771 | + | let r = post(app, "/admin/invite", Some(&andy), &format!("_csrf={csrf}")).await; |
|
| 772 | + | let i = r.body.find("invite=").map(|i| i + 7).expect("an invitation"); |
|
| 773 | + | r.body[i..].chars().take_while(|c| c.is_ascii_alphanumeric()).collect::<String>() |
|
| 774 | + | }; |
|
| 775 | + | let bob = register(app, "bob", &invite).await; |
|
| 776 | + | assert_eq!( |
|
| 777 | + | post(app, "/admin/assistants", Some(&bob), &format!("_csrf={csrf}&action=prompt&prompt=x")).await.status, |
|
| 778 | + | StatusCode::NOT_FOUND |
|
| 779 | + | ); |
|
| 780 | + | assert_eq!( |
|
| 781 | + | post(app, "/orgs/acme/settings/assistants", Some(&bob), &format!("_csrf={csrf}&action=prompt&prompt=x")).await.status, |
|
| 782 | + | StatusCode::NOT_FOUND |
|
| 783 | + | ); |
|
| 784 | + | assert_eq!( |
|
| 785 | + | post(app, "/acme/plans/settings/assistants", Some(&bob), &format!("_csrf={csrf}&action=prompt&prompt=x")).await.status, |
|
| 786 | + | StatusCode::NOT_FOUND |
|
| 787 | + | ); |
|
| 788 | + | // Deleting the repository forgets its assistants and prompt. |
|
| 789 | + | let r = post(app, "/acme/plans/settings/delete", Some(&andy), &format!("_csrf={csrf}&confirm=acme%2Fplans")).await; |
|
| 790 | + | assert!(r.status == StatusCode::SEE_OTHER || r.status == StatusCode::OK, "{} {}", r.status, r.body); |
|
| 791 | + | assert!(assistants::list(&app.db, Scope::Repo(repo.id)).await.unwrap().is_empty()); |
|
| 792 | + | } |
modified docs/spec.md +2 −2
| … | 536 unchanged lines |
||
| 537 | 537 | - *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. |
|
| 538 | 538 | - *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. |
|
| 539 | 539 | ||
| 540 | - | ### 13.4 AI assistants (planned, design 2026-10-04) |
|
| 540 | + | ### 13.4 AI assistants (settings done 2026-10-04; review requests and drafts planned) |
|
| 541 | 541 | ||
| 542 | 542 | 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: |
|
| 543 | 543 | ||
| 544 | 544 | - **Where.** Administration (site), an organisation's settings, a repository's settings each hold a list of assistants: name (`Claude`, `Antigravity`, `ChatGPT`, `DeepSeek`, …), endpoint URL, API shape (`chat-completions` or `anthropic-messages`), model, extra headers, the token, and an *enabled* switch. Each level also holds a **prompt**. A repository sees the assistants of its organisation and of the site beside its own; an assistant disabled at an outer level is off for everything inside it, whatever an inner level says. The effective prompt for a call is the site's, then the organisation's, then the repository's, each under a heading, so a project adds to what the site says rather than replacing it, and each settings page shows the prompt it inherits above its own. |
|
| 545 | - | - **Secrets.** Tokens are kept encrypted at rest (ChaCha20-Poly1305 with a key in the data directory's `keys/`, beside the cookie key), shown masked, and never returned by the API. |
|
| 545 | + | - **Secrets.** Tokens are sealed at rest with ChaCha20-Poly1305 under `keys/assistants.key` (mode 0600, made at first start beside the cookie key; each token carries its own random nonce), shown only as set or none, and never returned by the API. An empty token suits endpoints that need none. Each settings panel has TEST, a one-line exchange whose answer or error is shown. Each level's changes are in the audit log (`assistant.added`, `.updated`, `.enabled`, `.disabled`, `.deleted`, `.prompt`). |
|
| 546 | 546 | - **Request review.** The proposal page's Reviewers menu lists the enabled assistants beside people. Asking one queues a worker task: the hub builds the context (the proposal's title and description, the diff of each changed file, trimmed to a size budget, the effective prompt), calls the endpoint, and records the answer as a review of verdict `comment` whose author is the person who asked and whose **via** is the assistant, shown as "andymccall via Claude" with an `agent` badge, exactly as agent-signed changes are shown (§6). An assistant never approves: approvals count only people. The event and the audit log record who asked which assistant; the proposal's author is told as for any review. |
|
| 547 | 547 | - **Draft the description.** On a new or open proposal, "Draft with …" asks an assistant for a description from the changes and puts it in the editor for the requester to edit before saving; nothing is saved by the assistant itself. |
|
| 548 | 548 | - **Limits and failure.** One request per assistant per proposal at a time, a per-repository rate limit, a 60-second call timeout, and a context budget (characters) per assistant; a failed call leaves an event ("Claude did not answer: …") and no review. |
|
| … | 149 unchanged lines |
modified templates/admin.html +2 −0
| 1 | 1 | {% extends "base.html" %} |
|
| 2 | + | {% import "assistants_panel.html" as ai %} |
|
| 2 | 3 | {% block title %}Administration{% endblock %} |
|
| 3 | 4 | {% block content %} |
|
| 4 | 5 | <div class="head"><h1>Administration <small>{{ accounts.len() }} accounts · {{ orgs.len() }} organisations · {{ repos }} repositories</small></h1></div> |
|
| … | 50 unchanged lines |
||
| 55 | 56 | <p class="small mute">{% if mail_enabled %}Mail is on: verification and reset links go by email.{% else %}Mail is off: verification and reset links are issued by hand.{% endif %} An empty URL turns it off. The password stays on the server and is shown masked.</p> |
|
| 56 | 57 | </aside> |
|
| 57 | 58 | </div> |
|
| 59 | + | {% call ai::panel("/admin", assistants, shell.csrf, "Every repository on the site may ask these, unless an organisation or repository turns one off.") %} |
|
| 58 | 60 | {% endblock %} |
modified templates/org_settings.html +3 −0
| 1 | 1 | {% extends "base.html" %} |
|
| 2 | 2 | {% import "webhooks_panel.html" as w %} |
|
| 3 | + | {% import "assistants_panel.html" as ai %} |
|
| 3 | 4 | {% block title %}{{ org.name }} settings{% endblock %} |
|
| 4 | 5 | {% block content %} |
|
| 5 | 6 | <div class="head"><h1>{{ org.name }} <small>organisation settings · <a href="/{{ org.name }}">repositories</a></small></h1></div> |
|
| … | 3 unchanged lines |
||
| 9 | 10 | <a href="#logo">Logo</a> |
|
| 10 | 11 | <a href="#members">Members</a> |
|
| 11 | 12 | <a href="#webhooks">Webhooks</a> |
|
| 13 | + | <a href="#assistants">AI assistants</a> |
|
| 12 | 14 | <a href="#teams">Teams</a> |
|
| 13 | 15 | </nav> |
|
| 14 | 16 | <div class="sections"> |
|
| … | 58 unchanged lines |
||
| 73 | 75 | <form method="post" action="/orgs/{{ org.name }}/settings/runners/token"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button type="submit">Add a runner for the organisation</button></form> |
|
| 74 | 76 | </section> |
|
| 75 | 77 | {% call w::panel("/orgs/" ~ org.name ~ "/settings", hooks, shell.csrf, "These hear every repository of the organisation, as well as each repository's own webhooks.") %} |
|
| 78 | + | {% call ai::panel("/orgs/" ~ org.name ~ "/settings", assistants, shell.csrf, "Every repository of the organisation may ask these, beside the site's and its own.") %} |
|
| 76 | 79 | </div> |
|
| 77 | 80 | </div> |
|
| 78 | 81 | {% endblock %} |
modified templates/repo_settings.html +3 −0
| 1 | 1 | {% extends "base.html" %} |
|
| 2 | 2 | {% import "webhooks_panel.html" as w %} |
|
| 3 | + | {% import "assistants_panel.html" as ai %} |
|
| 3 | 4 | {% import "repo_tabs.html" as m %} |
|
| 4 | 5 | {% block title %}Settings · {{ repo.owner }}/{{ repo.name }}{% endblock %} |
|
| 5 | 6 | {% block bodyattrs %} data-repo="{{ repo.url }}"{% endblock %} |
|
| … | 10 unchanged lines |
||
| 16 | 17 | <a href="#checks">Required checks</a> |
|
| 17 | 18 | <a href="#flow">OxygenFlow</a> |
|
| 18 | 19 | {% if role == crate::auth::Role::Admin %}<a href="#webhooks">Webhooks</a> |
|
| 20 | + | <a href="#assistants">AI assistants</a> |
|
| 19 | 21 | <a href="#collaborators">Collaborators</a> |
|
| 20 | 22 | <a href="#danger">Delete</a>{% endif %} |
|
| 21 | 23 | </nav> |
|
| … | 67 unchanged lines |
||
| 89 | 91 | <form method="post" action="{{ repo.url }}/settings/runners/token"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button type="submit">Add a runner for this repository</button></form> |
|
| 90 | 92 | </section> |
|
| 91 | 93 | {% call w::panel(repo.url ~ "/settings", hooks, shell.csrf, "Each delivery is signed with the secret (X-Oxyhub-Signature) and retried with backoff for a day.") %} |
|
| 94 | + | {% call ai::panel(repo.url ~ "/settings", assistants, shell.csrf, "This repository may ask these, beside the ones the site and its owner provide.") %} |
|
| 92 | 95 | <section class="panel" id="collaborators"> |
|
| 93 | 96 | <h2>Collaborators</h2> |
|
| 94 | 97 | {% if collaborators.is_empty() %}<p class="mute small">Nobody beyond the owner{% if !teams.is_empty() %} and the organisation's members{% endif %}.</p>{% else %} |
|
| … | 29 unchanged lines |