OXYGENHUB

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