Adding an assistant: provider presets and the endpoint's own model list 155cab142b63
A provider list fills the name, endpoint and API shape, with Other for an endpoint typed by hand. List models asks the endpoint's models list (beside chat/completions or messages, with the typed token or the stored one) and offers the chat models; a model the endpoint does not list is refused on save, naming some it does. closes #109
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-04 19:13 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
155cab142b63cc2fbfd0807d7168722ca32f8d93eb55a62cd5bd0eb8142188cb- Depends on
- 19bb31ff1f5c 206c1fc1b06a 2a83f5303458 3ea0b8af2d46 4ac01a732552 749d9a5fcd9f 83940d53664f 93be9ce4653e b5668108487f d23464c0ac60 d4792b4a4629 e1dc108bd814 e71846aa07b9 e8e7158bbc73 ed89fae5180b
- Depended on by
- dc5c64296b0a 737de0fac722 c7067c7bda56 9a777fec4ed9 1870e4fa9808 2a0c09c09716 5360cea36c30 16626c0d69c0 62ceb2eec18c
- 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
- 25 atoms, 0 tree operations, 12 files
Browse the tree at this change
modified CHANGELOG.md +2 −0
| … | 3 unchanged lines |
||
| 4 | 4 | ||
| 5 | 5 | ## Unreleased |
|
| 6 | 6 | ||
| 7 | + | - **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 | + | ||
| 7 | 9 | - **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}`. |
|
| 8 | 10 | ||
| 9 | 11 | - **Jobs go to runners of the right architecture (oxygenflow#36).** Runners report each image's architecture; the hub matches a job's `arch` or its image's against the runner's, and the queue says what a waiting job asks for. |
|
| … | 113 unchanged lines |
modified assets/hub.js +39 −0
| … | 443 unchanged lines |
||
| 444 | 444 | } |
|
| 445 | 445 | } |
|
| 446 | 446 | } catch (err) { /* no storage: nothing to restore */ } |
|
| 447 | + | ||
| 448 | + | /* AI assistants (#109): a provider fills the name, endpoint and API; |
|
| 449 | + | List models asks the endpoint what it offers and fills the model |
|
| 450 | + | field's suggestions. Without this script, both are typed by hand. */ |
|
| 451 | + | document.querySelectorAll("[data-preset-select]").forEach(function (sel) { |
|
| 452 | + | var form = sel.form; |
|
| 453 | + | function apply() { |
|
| 454 | + | var o = sel.options[sel.selectedIndex]; |
|
| 455 | + | if (!o || !o.value) return; |
|
| 456 | + | var name = form.querySelector("[name=name]"), ep = form.querySelector("[name=endpoint]"), api = form.querySelector("[name=api]"); |
|
| 457 | + | name.placeholder = o.dataset.name; ep.value = o.dataset.endpoint; api.value = o.dataset.api; |
|
| 458 | + | } |
|
| 459 | + | sel.addEventListener("change", apply); |
|
| 460 | + | apply(); |
|
| 461 | + | }); |
|
| 462 | + | document.querySelectorAll("[data-list-models]").forEach(function (b) { |
|
| 463 | + | b.addEventListener("click", function () { |
|
| 464 | + | var form = b.form, input = document.getElementById(b.dataset.for); |
|
| 465 | + | var list = document.getElementById(b.dataset.for + "-list"), note = document.querySelector("[data-models-note='" + b.dataset.for + "']"); |
|
| 466 | + | var body = new URLSearchParams(); |
|
| 467 | + | body.set("_csrf", b.dataset.csrf); |
|
| 468 | + | ["endpoint", "api", "token", "preset"].forEach(function (k) { var el = form.querySelector("[name=" + k + "]"); if (el) body.set(k, el.value); }); |
|
| 469 | + | if (b.dataset.id) body.set("id", b.dataset.id); |
|
| 470 | + | if (note) note.textContent = "Asking the provider…"; |
|
| 471 | + | fetch(b.dataset.listModels, { method: "POST", body: body, credentials: "same-origin" }) |
|
| 472 | + | .then(function (r) { return r.json(); }) |
|
| 473 | + | .then(function (j) { |
|
| 474 | + | if (j.models) { |
|
| 475 | + | list.innerHTML = ""; |
|
| 476 | + | j.models.forEach(function (m) { var o = document.createElement("option"); o.value = m; list.appendChild(o); }); |
|
| 477 | + | if (note) note.textContent = j.models.length + " models: pick one in the Model field."; |
|
| 478 | + | input.focus(); |
|
| 479 | + | } else if (note) { |
|
| 480 | + | note.textContent = "No list: " + (j.error || "unknown") + ". Type the model's name."; |
|
| 481 | + | } |
|
| 482 | + | }) |
|
| 483 | + | .catch(function () { if (note) note.textContent = "The hub could not ask; type the model's name."; }); |
|
| 484 | + | }); |
|
| 485 | + | }); |
|
| 447 | 486 | })(); |
modified crates/hub/src/assistants.rs +62 −0
| … | 497 unchanged lines |
||
| 498 | 498 | Ok(answer) |
|
| 499 | 499 | } |
|
| 500 | 500 | ||
| 501 | + | /// Providers the add form offers, with what they need (#109): |
|
| 502 | + | /// (id, label, name, endpoint, api). |
|
| 503 | + | pub const PRESETS: &[(&str, &str, &str, &str, &str)] = &[ |
|
| 504 | + | ("anthropic", "Anthropic (Claude)", "Claude", "https://api.anthropic.com/v1/messages", "anthropic-messages"), |
|
| 505 | + | ("openai", "OpenAI (ChatGPT)", "ChatGPT", "https://api.openai.com/v1/chat/completions", "chat-completions"), |
|
| 506 | + | ("gemini", "Google Gemini", "Gemini", "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions", "chat-completions"), |
|
| 507 | + | ("deepseek", "DeepSeek", "DeepSeek", "https://api.deepseek.com/chat/completions", "chat-completions"), |
|
| 508 | + | ("mistral", "Mistral", "Mistral", "https://api.mistral.ai/v1/chat/completions", "chat-completions"), |
|
| 509 | + | ("perplexity", "Perplexity", "Perplexity", "https://api.perplexity.ai/chat/completions", "chat-completions"), |
|
| 510 | + | ("ollama", "Ollama (on your network)", "Ollama", "http://localhost:11434/v1/chat/completions", "chat-completions"), |
|
| 511 | + | ]; |
|
| 512 | + | ||
| 513 | + | /// Where an endpoint lists its models: `…/chat/completions` → `…/models`, |
|
| 514 | + | /// `…/messages` → `…/models`. |
|
| 515 | + | pub fn models_url(endpoint: &str) -> Option<String> { |
|
| 516 | + | let e = endpoint.trim_end_matches('/'); |
|
| 517 | + | e.strip_suffix("/chat/completions").or_else(|| e.strip_suffix("/messages")).map(|base| format!("{base}/models")) |
|
| 518 | + | } |
|
| 519 | + | ||
| 520 | + | /// The chat models an endpoint offers for this token, sorted; `Err` when |
|
| 521 | + | /// it cannot say (no list, refused, unreachable). Embedding, speech and |
|
| 522 | + | /// image models are left out. |
|
| 523 | + | pub async fn list_models(endpoint: &str, api: &str, token: &str) -> Result<Vec<String>, String> { |
|
| 524 | + | let url = models_url(endpoint).ok_or("this endpoint has no model list next to it")?; |
|
| 525 | + | let client = reqwest::Client::builder() |
|
| 526 | + | .timeout(std::time::Duration::from_secs(15)) |
|
| 527 | + | .user_agent(format!("oxygenhub/{}", env!("CARGO_PKG_VERSION"))) |
|
| 528 | + | .build() |
|
| 529 | + | .map_err(|e| e.to_string())?; |
|
| 530 | + | let mut req = client.get(&url); |
|
| 531 | + | if api == "anthropic-messages" { |
|
| 532 | + | req = req.header("anthropic-version", "2023-06-01"); |
|
| 533 | + | if !token.is_empty() { |
|
| 534 | + | req = req.header("x-api-key", token); |
|
| 535 | + | } |
|
| 536 | + | } else if !token.is_empty() { |
|
| 537 | + | req = req.header("authorization", format!("Bearer {token}")); |
|
| 538 | + | } |
|
| 539 | + | let r = req.send().await.map_err(|e| scrub(&e.to_string(), token))?; |
|
| 540 | + | let status = r.status(); |
|
| 541 | + | if !status.is_success() { |
|
| 542 | + | return Err(format!("the model list answered {}", status.as_u16())); |
|
| 543 | + | } |
|
| 544 | + | let v: serde_json::Value = r.json().await.map_err(|_| "the model list was not JSON".to_string())?; |
|
| 545 | + | let skip = ["embed", "tts", "whisper", "dall-e", "moderation", "audio", "image", "realtime", "transcribe", "search-preview"]; |
|
| 546 | + | let mut out: Vec<String> = v["data"] |
|
| 547 | + | .as_array() |
|
| 548 | + | .or(v["models"].as_array()) |
|
| 549 | + | .ok_or("the model list had no data")? |
|
| 550 | + | .iter() |
|
| 551 | + | .filter_map(|m| m["id"].as_str().or(m["name"].as_str())) |
|
| 552 | + | .map(|id| id.strip_prefix("models/").unwrap_or(id).to_string()) |
|
| 553 | + | .filter(|id| !skip.iter().any(|s| id.to_lowercase().contains(s))) |
|
| 554 | + | .collect(); |
|
| 555 | + | out.sort(); |
|
| 556 | + | out.dedup(); |
|
| 557 | + | if out.is_empty() { |
|
| 558 | + | return Err("the model list was empty".into()); |
|
| 559 | + | } |
|
| 560 | + | Ok(out) |
|
| 561 | + | } |
|
| 562 | + | ||
| 501 | 563 | /// A tiny exchange, for the settings page's TEST: the start of the answer. |
|
| 502 | 564 | pub async fn test(app: &Shared, a: &Assistant) -> Result<String, String> { |
|
| 503 | 565 | let answer = ask(app, a, "You are being connected to an OxygenHub site. Answer briefly.", "Reply with the single word OK.").await?; |
|
| … | 308 unchanged lines |
modified crates/hub/src/web/admin.rs +12 −0
| … | 457 unchanged lines |
||
| 458 | 458 | let outcome = super::signin::apply(&app, &viewer, f).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 459 | 459 | admin_page(&app, shell, Some(outcome.unwrap_or_else(|e| e))).await |
|
| 460 | 460 | } |
|
| 461 | + | ||
| 462 | + | /// `POST /admin/assistants/models`: what a provider offers, as JSON (#109). |
|
| 463 | + | pub async fn assistant_models( |
|
| 464 | + | State(app): State<Shared>, |
|
| 465 | + | viewer: Viewer, |
|
| 466 | + | Form(f): Form<super::assistants::ModelsForm>, |
|
| 467 | + | ) -> Result<axum::Json<serde_json::Value>, Error> { |
|
| 468 | + | let shell = Shell::new(&app, "/admin", &viewer); |
|
| 469 | + | require_admin(&viewer, &shell)?; |
|
| 470 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 471 | + | Ok(axum::Json(super::assistants::models(&app, &site_assistants(), f).await.map_err(|e| Error::from_db(shell.clone(), e))?)) |
|
| 472 | + | } |
modified crates/hub/src/web/assistants.rs +116 −0
| … | 34 unchanged lines |
||
| 35 | 35 | budget: String, |
|
| 36 | 36 | #[serde(default)] |
|
| 37 | 37 | prompt: String, |
|
| 38 | + | /// A provider from the add form's list (#109), filling what is left empty. |
|
| 39 | + | #[serde(default)] |
|
| 40 | + | preset: String, |
|
| 38 | 41 | } |
|
| 39 | 42 | ||
| 40 | 43 | /// Everything the panel shows. |
|
| … | 8 unchanged lines |
||
| 49 | 52 | pub inherited_prompts: Vec<(String, String)>, |
|
| 50 | 53 | pub apis: Vec<&'static str>, |
|
| 51 | 54 | pub default_budget: i64, |
|
| 55 | + | pub presets: Vec<(&'static str, &'static str, &'static str, &'static str, &'static str)>, |
|
| 52 | 56 | } |
|
| 53 | 57 | ||
| 54 | 58 | pub async fn panel(app: &Shared, at: &At) -> sqlx::Result<Panel> { |
|
| … | 6 unchanged lines |
||
| 61 | 65 | inherited_prompts: assistants::inherited_prompts(&app.db, at.scope, &at.owner).await?, |
|
| 62 | 66 | apis: APIS.to_vec(), |
|
| 63 | 67 | default_budget: DEFAULT_BUDGET, |
|
| 68 | + | presets: assistants::PRESETS.to_vec(), |
|
| 64 | 69 | }) |
|
| 65 | 70 | } |
|
| 66 | 71 | ||
| … | 3 unchanged lines |
||
| 70 | 75 | ||
| 71 | 76 | fn endpoint_ok(url: &str) -> bool { |
|
| 72 | 77 | (url.starts_with("http://") || url.starts_with("https://")) && url.len() <= 2000 && !url.contains(char::is_whitespace) |
|
| 78 | + | } |
|
| 79 | + | ||
| 80 | + | impl AssistantForm { |
|
| 81 | + | fn clone_shallow(&self) -> AssistantForm { |
|
| 82 | + | AssistantForm { |
|
| 83 | + | csrf_token: String::new(), |
|
| 84 | + | action: self.action.clone(), |
|
| 85 | + | id: self.id, |
|
| 86 | + | name: self.name.clone(), |
|
| 87 | + | endpoint: self.endpoint.clone(), |
|
| 88 | + | api: self.api.clone(), |
|
| 89 | + | model: self.model.clone(), |
|
| 90 | + | headers: self.headers.clone(), |
|
| 91 | + | token: String::new(), |
|
| 92 | + | enabled: self.enabled.clone(), |
|
| 93 | + | budget: self.budget.clone(), |
|
| 94 | + | prompt: String::new(), |
|
| 95 | + | preset: self.preset.clone(), |
|
| 96 | + | } |
|
| 97 | + | } |
|
| 98 | + | } |
|
| 99 | + | ||
| 100 | + | /// When the endpoint lists its models, the model must be one of them |
|
| 101 | + | /// (#109); `Err` names a few it does offer. |
|
| 102 | + | async fn model_offered(fields: &Fields, token: &str) -> Result<(), String> { |
|
| 103 | + | match assistants::list_models(&fields.endpoint, &fields.api, token).await { |
|
| 104 | + | Ok(models) if !models.iter().any(|m| m == &fields.model) => { |
|
| 105 | + | let close: Vec<&String> = { |
|
| 106 | + | let stem = fields.model.split(['-', '.']).next().unwrap_or("").to_lowercase(); |
|
| 107 | + | let near: Vec<&String> = models.iter().filter(|m| m.to_lowercase().contains(&stem)).take(8).collect(); |
|
| 108 | + | if near.is_empty() { |
|
| 109 | + | models.iter().take(8).collect() |
|
| 110 | + | } else { |
|
| 111 | + | near |
|
| 112 | + | } |
|
| 113 | + | }; |
|
| 114 | + | Err(format!( |
|
| 115 | + | "{} is not a model this endpoint offers. It offers, among others: {}. Use List models to pick one.", |
|
| 116 | + | fields.model, |
|
| 117 | + | close.iter().map(|m| m.as_str()).collect::<Vec<_>>().join(", ") |
|
| 118 | + | )) |
|
| 119 | + | } |
|
| 120 | + | _ => Ok(()), |
|
| 121 | + | } |
|
| 73 | 122 | } |
|
| 74 | 123 | ||
| 75 | 124 | /// The form's details checked: `Err` is the message for the person. |
|
| 76 | 125 | fn fields(f: &AssistantForm) -> Result<Fields, String> { |
|
| 126 | + | let mut f_owned; |
|
| 127 | + | let f = match assistants::PRESETS.iter().find(|p| p.0 == f.preset) { |
|
| 128 | + | Some(&(_, _, name, endpoint, api)) => { |
|
| 129 | + | f_owned = f.clone_shallow(); |
|
| 130 | + | if f_owned.name.trim().is_empty() { |
|
| 131 | + | f_owned.name = name.into(); |
|
| 132 | + | } |
|
| 133 | + | if f_owned.endpoint.trim().is_empty() { |
|
| 134 | + | f_owned.endpoint = endpoint.into(); |
|
| 135 | + | } |
|
| 136 | + | if !APIS.contains(&f_owned.api.as_str()) || f.endpoint.trim().is_empty() { |
|
| 137 | + | f_owned.api = api.into(); |
|
| 138 | + | } |
|
| 139 | + | &f_owned |
|
| 140 | + | } |
|
| 141 | + | None => f, |
|
| 142 | + | }; |
|
| 77 | 143 | let name = f.name.trim().to_string(); |
|
| 78 | 144 | if !name_ok(&name) { |
|
| 79 | 145 | return Err("An assistant's name is up to 40 letters, digits, spaces, dots, dashes or underscores.".into()); |
|
| … | 37 unchanged lines |
||
| 117 | 183 | Ok(x) => x, |
|
| 118 | 184 | Err(e) => return Ok(Err(e)), |
|
| 119 | 185 | }; |
|
| 186 | + | if let Err(e) = model_offered(&fields, f.token.trim()).await { |
|
| 187 | + | return Ok(Err(e)); |
|
| 188 | + | } |
|
| 120 | 189 | let token = app.assistant_key.seal(f.token.trim()); |
|
| 121 | 190 | match assistants::create(&app.db, at.scope, &fields, &token).await? { |
|
| 122 | 191 | Some(a) => { |
|
| … | 16 unchanged lines |
||
| 139 | 208 | Err(e) => return Ok(Err(e)), |
|
| 140 | 209 | }; |
|
| 141 | 210 | let token = f.token.trim(); |
|
| 211 | + | let stored = if token.is_empty() { app.assistant_key.open(&a.token).unwrap_or_default() } else { token.to_string() }; |
|
| 212 | + | if let Err(e) = model_offered(&fields, &stored).await { |
|
| 213 | + | return Ok(Err(e)); |
|
| 214 | + | } |
|
| 142 | 215 | let sealed = (!token.is_empty()).then(|| app.assistant_key.seal(token)); |
|
| 143 | 216 | if !assistants::update(&app.db, id, &fields, sealed.as_deref()).await? { |
|
| 144 | 217 | return Ok(Err(format!("There is already an assistant called {} here.", fields.name))); |
|
| … | 39 unchanged lines |
||
| 184 | 257 | } |
|
| 185 | 258 | _ => return Ok(Err("Unknown action.".into())), |
|
| 186 | 259 | })) |
|
| 260 | + | } |
|
| 261 | + | ||
| 262 | + | #[derive(Deserialize)] |
|
| 263 | + | pub struct ModelsForm { |
|
| 264 | + | #[serde(rename = "_csrf")] |
|
| 265 | + | pub csrf_token: String, |
|
| 266 | + | #[serde(default)] |
|
| 267 | + | id: Option<i64>, |
|
| 268 | + | #[serde(default)] |
|
| 269 | + | endpoint: String, |
|
| 270 | + | #[serde(default)] |
|
| 271 | + | api: String, |
|
| 272 | + | #[serde(default)] |
|
| 273 | + | token: String, |
|
| 274 | + | #[serde(default)] |
|
| 275 | + | preset: String, |
|
| 276 | + | } |
|
| 277 | + | ||
| 278 | + | /// The models a provider offers, as JSON for the form's List models |
|
| 279 | + | /// button (#109): `{"models": [...]}` or `{"error": "..."}`. With `id`, |
|
| 280 | + | /// the stored token of that assistant at this level is used when none is |
|
| 281 | + | /// typed. |
|
| 282 | + | pub async fn models(app: &Shared, at: &At, f: ModelsForm) -> sqlx::Result<serde_json::Value> { |
|
| 283 | + | let preset = assistants::PRESETS.iter().find(|p| p.0 == f.preset); |
|
| 284 | + | let endpoint = |
|
| 285 | + | if f.endpoint.trim().is_empty() { preset.map(|p| p.3.to_string()).unwrap_or_default() } else { f.endpoint.trim().to_string() }; |
|
| 286 | + | let api = if APIS.contains(&f.api.as_str()) { |
|
| 287 | + | f.api.clone() |
|
| 288 | + | } else { |
|
| 289 | + | preset.map(|p| p.4.to_string()).unwrap_or_else(|| "chat-completions".into()) |
|
| 290 | + | }; |
|
| 291 | + | let mut token = f.token.trim().to_string(); |
|
| 292 | + | if token.is_empty() { |
|
| 293 | + | if let Some(id) = f.id { |
|
| 294 | + | if let Some(a) = assistants::get(&app.db, id).await?.filter(|a| a.scope() == at.scope) { |
|
| 295 | + | token = app.assistant_key.open(&a.token).unwrap_or_default(); |
|
| 296 | + | } |
|
| 297 | + | } |
|
| 298 | + | } |
|
| 299 | + | Ok(match assistants::list_models(&endpoint, &api, &token).await { |
|
| 300 | + | Ok(m) => serde_json::json!({ "models": m }), |
|
| 301 | + | Err(e) => serde_json::json!({ "error": e }), |
|
| 302 | + | }) |
|
| 187 | 303 | } |
modified crates/hub/src/web/mod.rs +3 −0
| … | 90 unchanged lines |
||
| 91 | 91 | .route("/orgs/{name}/settings/teams", post(orgs::team)) |
|
| 92 | 92 | .route("/orgs/{name}/settings/hooks", post(orgs::hooks)) |
|
| 93 | 93 | .route("/orgs/{name}/settings/assistants", post(orgs::assistants)) |
|
| 94 | + | .route("/orgs/{name}/settings/assistants/models", post(orgs::assistant_models)) |
|
| 94 | 95 | .route("/orgs/{name}/settings/variables", post(orgs::variables)) |
|
| 95 | 96 | .route("/orgs/{name}/settings/secrets", post(orgs::secrets)) |
|
| 96 | 97 | .route("/orgs/{name}/settings/runners/token", post(orgs::runner_token)) |
|
| … | 21 unchanged lines |
||
| 118 | 119 | .route("/admin/mail", post(admin::set_mail)) |
|
| 119 | 120 | .route("/admin/mail/test", post(admin::test_mail)) |
|
| 120 | 121 | .route("/admin/assistants", post(admin::assistants)) |
|
| 122 | + | .route("/admin/assistants/models", post(admin::assistant_models)) |
|
| 121 | 123 | .route("/admin/signin", post(admin::signin)) |
|
| 122 | 124 | .route("/login/{provider}", get(signin::start)) |
|
| 123 | 125 | .route("/login/{provider}/callback", get(signin::callback)) |
|
| … | 72 unchanged lines |
||
| 196 | 198 | .route("/{owner}/{name}/proposal/{number}/events", get(proposals::events)) |
|
| 197 | 199 | .route("/{owner}/{name}/settings/hooks", post(repo_settings::hooks)) |
|
| 198 | 200 | .route("/{owner}/{name}/settings/assistants", post(repo_settings::assistants)) |
|
| 201 | + | .route("/{owner}/{name}/settings/assistants/models", post(repo_settings::assistant_models)) |
|
| 199 | 202 | .route("/{owner}/{name}/settings/variables", post(repo_settings::variables)) |
|
| 200 | 203 | .route("/{owner}/{name}/settings/secrets", post(repo_settings::secrets)) |
|
| 201 | 204 | .route("/{owner}/{name}/settings/hooks/{id}/deliveries", get(repo_settings::deliveries)) |
|
| … | 498 unchanged lines |
modified crates/hub/src/web/orgs.rs +13 −0
| … | 434 unchanged lines |
||
| 435 | 435 | Err(error) => org_page(&app, shell, org, None, Some(error)).await, |
|
| 436 | 436 | } |
|
| 437 | 437 | } |
|
| 438 | + | ||
| 439 | + | /// `POST /orgs/{name}/settings/assistants/models`: what a provider offers (#109). |
|
| 440 | + | pub async fn assistant_models( |
|
| 441 | + | State(app): State<Shared>, |
|
| 442 | + | viewer: Viewer, |
|
| 443 | + | Path(name): Path<String>, |
|
| 444 | + | Form(f): Form<super::assistants::ModelsForm>, |
|
| 445 | + | ) -> Result<axum::Json<serde_json::Value>, Error> { |
|
| 446 | + | let shell = Shell::new(&app, &format!("/orgs/{name}/settings"), &viewer); |
|
| 447 | + | let (_, org) = owned_org(&app, &shell, &viewer, &name).await?; |
|
| 448 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 449 | + | Ok(axum::Json(super::assistants::models(&app, &org_assistants(&org), f).await.map_err(|e| Error::from_db(shell.clone(), e))?)) |
|
| 450 | + | } |
modified crates/hub/src/web/repo_settings.rs +13 −0
| … | 719 unchanged lines |
||
| 720 | 720 | Err(error) => settings_page(&app, shell, row, role, None, Some(error)).await, |
|
| 721 | 721 | } |
|
| 722 | 722 | } |
|
| 723 | + | ||
| 724 | + | /// `POST /{owner}/{name}/settings/assistants/models`: what a provider offers (#109). |
|
| 725 | + | pub async fn assistant_models( |
|
| 726 | + | State(app): State<Shared>, |
|
| 727 | + | viewer: Viewer, |
|
| 728 | + | Path((owner, name)): Path<(String, String)>, |
|
| 729 | + | Form(f): Form<super::assistants::ModelsForm>, |
|
| 730 | + | ) -> Result<axum::Json<serde_json::Value>, Error> { |
|
| 731 | + | let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer); |
|
| 732 | + | let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?; |
|
| 733 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 734 | + | Ok(axum::Json(super::assistants::models(&app, &repo_assistants(&row), f).await.map_err(|e| Error::from_db(shell.clone(), e))?)) |
|
| 735 | + | } |
modified crates/hub/tests/accounts.rs +59 −0
| … | 1002 unchanged lines |
||
| 1003 | 1003 | let older = get(app, &format!("/admin?audit_before={before}"), Some(&andy)).await.body; |
|
| 1004 | 1004 | assert!(older.contains(">t5<") && !older.contains(">t59<"), "the next page: {older}"); |
|
| 1005 | 1005 | } |
|
| 1006 | + | ||
| 1007 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 1008 | + | async fn adding_an_assistant_offers_providers_and_only_models_the_endpoint_lists() { |
|
| 1009 | + | let h = hub().await; |
|
| 1010 | + | let app = &h.app; |
|
| 1011 | + | let andy = register(app, "andy", "").await; |
|
| 1012 | + | let router = axum::Router::new() |
|
| 1013 | + | .route( |
|
| 1014 | + | "/v1/models", |
|
| 1015 | + | axum::routing::get(|headers: axum::http::HeaderMap| async move { |
|
| 1016 | + | assert_eq!(headers.get("authorization").and_then(|v| v.to_str().ok()), Some("Bearer sk-m")); |
|
| 1017 | + | axum::Json(serde_json::json!({ "data": [ |
|
| 1018 | + | { "id": "models/gemini-3-pro" }, { "id": "gemini-3-flash" }, { "id": "text-embedding-004" } |
|
| 1019 | + | ] })) |
|
| 1020 | + | }), |
|
| 1021 | + | ) |
|
| 1022 | + | .route( |
|
| 1023 | + | "/v1/chat/completions", |
|
| 1024 | + | axum::routing::post(|| async { axum::Json(serde_json::json!({ "choices": [{ "message": { "content": "OK" } }] })) }), |
|
| 1025 | + | ); |
|
| 1026 | + | let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); |
|
| 1027 | + | let port = listener.local_addr().unwrap().port(); |
|
| 1028 | + | tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); |
|
| 1029 | + | let ep = format!("http%3A%2F%2F127.0.0.1%3A{port}%2Fv1%2Fchat%2Fcompletions"); |
|
| 1030 | + | ||
| 1031 | + | let page = get(app, "/admin", Some(&andy)).await.body; |
|
| 1032 | + | assert!(page.contains("data-preset-select") && page.contains("Google Gemini") && page.contains("Other (enter the endpoint)"), "{page}"); |
|
| 1033 | + | assert!(page.contains("LIST MODELS")); |
|
| 1034 | + | let csrf = csrf_of(&page); |
|
| 1035 | + | // The model list, as the button asks for it: chat models only, prefixes gone. |
|
| 1036 | + | let r = |
|
| 1037 | + | post(app, "/admin/assistants/models", Some(&andy), &format!("_csrf={csrf}&endpoint={ep}&api=chat-completions&token=sk-m")).await; |
|
| 1038 | + | let v: serde_json::Value = serde_json::from_str(&r.body).unwrap(); |
|
| 1039 | + | assert_eq!(v["models"], serde_json::json!(["gemini-3-flash", "gemini-3-pro"]), "{}", r.body); |
|
| 1040 | + | // A model the endpoint does not list is refused, with what it offers. |
|
| 1041 | + | let r = post( |
|
| 1042 | + | app, |
|
| 1043 | + | "/admin/assistants", |
|
| 1044 | + | Some(&andy), |
|
| 1045 | + | &format!("_csrf={csrf}&action=add&preset=gemini&endpoint={ep}&model=gemini-2.5-pro&token=sk-m&enabled=1"), |
|
| 1046 | + | ) |
|
| 1047 | + | .await; |
|
| 1048 | + | assert!(r.body.contains("gemini-2.5-pro is not a model this endpoint offers") && r.body.contains("gemini-3-pro"), "{}", r.body); |
|
| 1049 | + | // One it lists is added, named after the provider when no name is given. |
|
| 1050 | + | let r = post( |
|
| 1051 | + | app, |
|
| 1052 | + | "/admin/assistants", |
|
| 1053 | + | Some(&andy), |
|
| 1054 | + | &format!("_csrf={csrf}&action=add&preset=gemini&endpoint={ep}&model=gemini-3-pro&token=sk-m&enabled=1"), |
|
| 1055 | + | ) |
|
| 1056 | + | .await; |
|
| 1057 | + | assert!(r.body.contains("Gemini added and answered: OK"), "{}", r.body); |
|
| 1058 | + | let a = &oxygenhub::assistants::list(&app.db, oxygenhub::levels::Scope::Site).await.unwrap()[0]; |
|
| 1059 | + | assert_eq!((a.name.as_str(), a.api.as_str(), a.model.as_str()), ("Gemini", "chat-completions", "gemini-3-pro")); |
|
| 1060 | + | // The stored token serves the edit form's button. |
|
| 1061 | + | let r = |
|
| 1062 | + | post(app, "/admin/assistants/models", Some(&andy), &format!("_csrf={csrf}&id={}&endpoint={ep}&api=chat-completions", a.id)).await; |
|
| 1063 | + | assert!(r.body.contains("gemini-3-flash"), "{}", r.body); |
|
| 1064 | + | } |
modified docs/spec.md +1 −0
| … | 552 unchanged lines |
||
| 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 | ||
| 555 | 555 | - **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. |
|
| 556 | + | - **Adding one** (oxygenhub#109). The form offers providers (Anthropic, OpenAI, Google Gemini, DeepSeek, Mistral, Perplexity, Ollama) that fill the name, endpoint and API shape, and *Other* for an endpoint typed by hand. **List models** asks the endpoint's own list (`…/models` beside `…/chat/completions` or `…/messages`, with the token typed or, when editing, the one stored) and offers the chat models in the Model field; on save, a model the endpoint lists none of is refused with some it does offer. An endpoint without a list takes any name. |
|
| 556 | 557 | - **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`). |
|
| 557 | 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. |
|
| 558 | 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. |
|
| … | 159 unchanged lines |
modified templates/assistants_panel.html +17 −5
| … | 30 unchanged lines |
||
| 31 | 31 | <label for="ae{{ r.id }}">Endpoint</label><input type="text" id="ae{{ r.id }}" name="endpoint" value="{{ r.endpoint }}" autocomplete="off"> |
|
| 32 | 32 | <label for="aa{{ r.id }}">API</label> |
|
| 33 | 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 }}"> |
|
| 34 | + | <label for="am{{ r.id }}">Model</label> |
|
| 35 | + | <div class="model-row"><input type="text" id="am{{ r.id }}" name="model" value="{{ r.model }}" list="am{{ r.id }}-list" autocomplete="off"><button type="button" class="small" data-list-models="{{ base }}/assistants/models" data-for="am{{ r.id }}" data-id="{{ r.id }}" data-csrf="{{ csrf }}">LIST MODELS</button></div> |
|
| 36 | + | <datalist id="am{{ r.id }}-list"></datalist> |
|
| 37 | + | <p class="small mute" data-models-note="am{{ r.id }}"></p> |
|
| 35 | 38 | <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 | 39 | <label for="ah{{ r.id }}">Extra headers</label><textarea id="ah{{ r.id }}" name="headers" rows="2" placeholder="Name: value">{{ r.headers }}</textarea> |
|
| 37 | 40 | <label for="ab{{ r.id }}">Context budget (characters)</label><input type="text" id="ab{{ r.id }}" name="budget" value="{{ r.budget }}"> |
|
| … | 7 unchanged lines |
||
| 45 | 48 | <form class="card" method="post" action="{{ base }}/assistants"> |
|
| 46 | 49 | <input type="hidden" name="_csrf" value="{{ csrf }}"><input type="hidden" name="action" value="add"> |
|
| 47 | 50 | <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"> |
|
| 51 | + | <label for="apreset">Provider</label> |
|
| 52 | + | <select id="apreset" name="preset" data-preset-select> |
|
| 53 | + | {% for p in a.presets %}<option value="{{ p.0 }}" data-name="{{ p.2 }}" data-endpoint="{{ p.3 }}" data-api="{{ p.4 }}">{{ p.1 }}</option>{% endfor %} |
|
| 54 | + | <option value="">Other (enter the endpoint)</option> |
|
| 55 | + | </select> |
|
| 56 | + | <label for="aname">Name</label><input type="text" id="aname" name="name" placeholder="Claude"> |
|
| 57 | + | <p class="small mute">How it shows on reviews ("you via Claude"); the provider's name when left empty.</p> |
|
| 58 | + | <label for="aendpoint">Endpoint</label><input type="text" id="aendpoint" name="endpoint" placeholder="https://api.anthropic.com/v1/messages" autocomplete="off"> |
|
| 50 | 59 | <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> |
|
| 60 | + | <select id="aapi" name="api">{% for api in a.apis %}<option value="{{ api }}">{{ api }}{% if *api == "chat-completions" %} (OpenAI, Gemini, DeepSeek, Mistral, Ollama, most others){% else %} (Anthropic){% endif %}</option>{% endfor %}</select> |
|
| 53 | 61 | <label for="atoken">Token</label><input type="password" id="atoken" name="token" autocomplete="new-password"> |
|
| 54 | 62 | <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> |
|
| 63 | + | <label for="amodel">Model</label> |
|
| 64 | + | <div class="model-row"><input type="text" id="amodel" name="model" list="amodel-list" placeholder="pick from List models" required autocomplete="off"><button type="button" class="small" data-list-models="{{ base }}/assistants/models" data-for="amodel" data-csrf="{{ csrf }}">LIST MODELS</button></div> |
|
| 65 | + | <datalist id="amodel-list"></datalist> |
|
| 66 | + | <p class="small mute" data-models-note="amodel">The endpoint's own list, once the provider and token are set. A model it does not list is refused on save.</p> |
|
| 55 | 67 | <label for="aheaders">Extra headers</label><textarea id="aheaders" name="headers" rows="2" placeholder="Name: value"></textarea> |
|
| 56 | 68 | <label for="abudget">Context budget (characters)</label><input type="text" id="abudget" name="budget" value="{{ a.default_budget }}"> |
|
| 57 | 69 | <div class="choice"><label><input type="checkbox" name="enabled" value="1" checked> on</label></div> |
|
| … | 12 unchanged lines |
modified templates/style.css +2 −0
| … | 729 unchanged lines |
||
| 730 | 730 | .side-list form.inline { margin-left: 6px; } |
|
| 731 | 731 | .side-assist { display: flex; flex-direction: column; align-items: flex-start; gap: 6px; margin-top: 8px; } |
|
| 732 | 732 | .side-assist form.inline { display: block; margin: 0; } |
|
| 733 | + | .model-row { display: flex; gap: 6px; align-items: center; } |
|
| 734 | + | .model-row input { flex: 1; } |
|
| 733 | 735 | /* A button that asks an AI agent: its mark, then the verb and its name. */ |
|
| 734 | 736 | button.agent-btn { display: inline-flex; align-items: center; gap: 7px; padding: 4px 10px 4px 6px; font-size: 11px; line-height: 16px; } |
|
| 735 | 737 | button.agent-btn img { width: 16px; height: 16px; border-radius: 50%; flex: 0 0 16px; } |
|
| … | 70 unchanged lines |