feat(agent): read available models from HIVE_AVAILABLE_MODELS env var

web_ui.rs: add available_models() helper that reads HIVE_AVAILABLE_MODELS
(comma-separated, injected by services.hyperhive.availableModels nix option).
Falls back to ["haiku", "sonnet", "opus"] when absent or empty. Field added
to StateSnapshot so the frontend receives the list on cold-load.

app.js: replace hardcoded MODEL_ALIASES array with availableModels module var.
Seeded from state.available_models before setHeader/populateOverflowMenu on
first /api/state load. Well-known aliases (haiku/sonnet/opus) still get their
descriptive labels; operator-declared custom model names show the name itself.

Implements the frontend + web_ui.rs side of issue #1359 (nix option shipped
separately in PR #1360 by atlas).
This commit is contained in:
iris 2026-06-05 13:27:51 +02:00 committed by mara
commit 1f54a07195
2 changed files with 57 additions and 12 deletions

View file

@ -273,33 +273,37 @@ window.marked = marked;
menu.append(logoutBtn);
// ─── model quick-picker ────────────────────────────────────────
// Three one-click shortcuts for the most common model aliases.
// One-click shortcuts for each model in `availableModels` (seeded
// from `state.available_models` / `HIVE_AVAILABLE_MODELS` nix option).
// The active model is highlighted via the `active` class; see
// `renderModelChip` which updates `modelPickerBtns` live.
const modelSep = el('div', { class: 'overflow-sep', 'aria-hidden': 'true' });
const modelLabel = el('div', { class: 'overflow-section-label' }, 'model');
menu.append(modelSep, modelLabel);
const MODEL_ALIASES = [
{ name: 'haiku', label: 'haiku (fast)' },
{ name: 'sonnet', label: 'sonnet (balanced)' },
{ name: 'opus', label: 'opus (powerful)' },
];
// Well-known aliases get a parenthetical description; unknown aliases
// (operator-declared custom models) show just the name.
const MODEL_DESCRIPTIONS = {
haiku: 'haiku (fast)',
sonnet: 'sonnet (balanced)',
opus: 'opus (powerful)',
};
modelPickerBtns = [];
for (const m of MODEL_ALIASES) {
for (const name of availableModels) {
const label = MODEL_DESCRIPTIONS[name] ?? name;
const btn = el('button', {
type: 'button',
class: 'overflow-item overflow-item-model',
role: 'menuitem',
title: `/model ${m.name}`,
'data-model': m.name,
title: `/model ${name}`,
'data-model': name,
},
el('span', { class: 'overflow-item-icon', 'aria-hidden': 'true' }, '⊞'),
m.label,
label,
);
btn.addEventListener('click', () => {
if (currentModel === m.name) { closeOverflowMenu(); return; }
if (currentModel === name) { closeOverflowMenu(); return; }
closeOverflowMenu();
postModel(m.name);
postModel(name);
});
modelPickerBtns.push(btn);
menu.append(btn);
@ -471,6 +475,10 @@ window.marked = marked;
// menu so renderModelChip can update their `active` state without
// rebuilding the whole menu.
let modelPickerBtns = [];
// Ordered list of model short-names available on this hive. Seeded from
// `state.available_models` (injected by the nix option); falls back to
// the built-in default until the first /api/state cold-load completes.
let availableModels = ['haiku', 'sonnet', 'opus'];
const SLASH_COMMANDS = [
{ name: '/help', desc: 'list slash commands' },
@ -1113,6 +1121,12 @@ window.marked = marked;
const resp = await fetch('api/state');
if (!resp.ok) throw new Error('http ' + resp.status);
const s = await resp.json();
// Seed available_models before populateOverflowMenu (called from
// setHeader on the first load) so the picker uses the operator-declared
// list rather than the JS fallback default.
if (Array.isArray(s.available_models) && s.available_models.length > 0) {
availableModels = s.available_models;
}
if (!headerSet) { setHeader(s.label, s.qualified_label, s.dashboard_port, s.hive_name, s.swarm_name); headerSet = true; }
currentLabel = s.label;
// Render server-supplied navigation links — see

View file

@ -436,6 +436,14 @@ struct StateSnapshot {
/// Human name of the swarm (e.g. `"constellat1on"`). Sourced from
/// `HYPERHIVE_SWARM_NAME`; `None` when unset.
swarm_name: Option<String>,
/// Ordered list of model short-names the operator has declared as
/// available on this hive. Sourced from `HIVE_AVAILABLE_MODELS`
/// (comma-separated, set by `services.hyperhive.availableModels`).
/// Falls back to `["haiku", "sonnet", "opus"]` when the env var is
/// absent or empty. The frontend model quick-picker renders one button
/// per entry in this list, so operators can add new models or drop
/// ones they don't want without touching the frontend code.
available_models: Vec<String>,
}
/// One navigation link in the agent page header row. The same JSON
@ -561,6 +569,7 @@ async fn api_state(State(state): State<AppState>) -> axum::Json<StateSnapshot> {
.filter(|s| !s.is_empty()),
hive_name: crate::identity::hive_name(),
swarm_name: crate::identity::swarm_name(),
available_models: available_models(),
})
}
@ -1076,3 +1085,25 @@ fn error_response(message: &str) -> Response {
// be noise.
(StatusCode::INTERNAL_SERVER_ERROR, message.to_owned()).into_response()
}
/// Read `HIVE_AVAILABLE_MODELS` (comma-separated short names injected by
/// `services.hyperhive.availableModels`) and return the parsed list.
/// Falls back to `["haiku", "sonnet", "opus"]` when the env var is absent
/// or resolves to an empty list after trimming.
fn available_models() -> Vec<String> {
const DEFAULT: &[&str] = &["haiku", "sonnet", "opus"];
let raw = match std::env::var("HIVE_AVAILABLE_MODELS") {
Ok(v) if !v.trim().is_empty() => v,
_ => return DEFAULT.iter().map(|s| s.to_string()).collect(),
};
let models: Vec<String> = raw
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
if models.is_empty() {
DEFAULT.iter().map(|s| s.to_string()).collect()
} else {
models
}
}