Watch
0
0
Fork
You've already forked hyperhive
0

hive-agent: ACP model picker honours availableModels (#4391)

Filter the ACP model picker's list by services.hyperhive.agent.availableModels: an
unconfigured agent (env absent) shows every model the session offers, a
configured list narrows the picker to whatever it names that the session
also offers (in the session's own order), and a configured list matching
none of the session's models (the claude names on an ACP agent that never
touched the option) falls back to showing everything, with one warning
naming the mismatch.

The nix option's default, the model-vs-availableModels build assertion and
hive-subagent-mcp's check_model rail are unchanged — this only touches the
web UI's picker.
This commit is contained in:
atlas 2026-09-30 09:39:30 +02:00
commit 8a8da5ec8a
2 changed files with 119 additions and 21 deletions

View file

@ -187,7 +187,8 @@ pub(super) struct StateSnapshot {
/// 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.
/// On ACP, the models the session offers instead (see [`pickers`]).
/// On ACP, the models the session offers, narrowed to this same list
/// when at least one entry matches one it offers (see [`pickers`]).
available_models: Vec<String>,
/// Currently-active claude effort level. Reflected on the page so the
/// operator's effort picker shows the live selection. Mutable at
@ -423,20 +424,26 @@ async fn recent_inbox(socket: &std::path::Path) -> Vec<hive_sh4re::inbox::InboxR
}
}
/// 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"];
// Absent / empty / all-whitespace env all funnel to the single
// emptiness check below — no separate up-front guard needed.
let models: Vec<String> = std::env::var("HIVE_AVAILABLE_MODELS")
/// Parse `HIVE_AVAILABLE_MODELS` (comma-separated, injected by
/// `services.hyperhive.agent.availableModels`) into its entries. Empty
/// (absent env, or nothing left after trimming) means different things to
/// the two callers below, so neither the empty check nor a fallback lives
/// here.
fn parse_available_models_env() -> Vec<String> {
std::env::var("HIVE_AVAILABLE_MODELS")
.unwrap_or_default()
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
.collect()
}
/// The claude picker's model 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 models = parse_available_models_env();
if models.is_empty() {
DEFAULT.iter().map(ToString::to_string).collect()
} else {
@ -444,6 +451,43 @@ fn available_models() -> Vec<String> {
}
}
/// The operator's configured model list, for filtering the ACP picker.
/// `None` when the env is absent or empty, so [`filter_offered_models`] can
/// tell "nothing configured" apart from a real (if unmatched) list — unlike
/// [`available_models`], which folds both into its claude-only fallback.
fn configured_models() -> Option<Vec<String>> {
let models = parse_available_models_env();
(!models.is_empty()).then_some(models)
}
/// Keep only the entries of `offered` (an ACP session's own model list, in
/// its own order) that `configured` names. `configured: None` (the env is
/// unset) or a `configured` list matching none of `offered` both leave
/// `offered` unfiltered — the latter is what an ACP agent that has never
/// set `services.hyperhive.agent.availableModels` hits, since the option's
/// default is the claude names, so it must show every model the session
/// offers rather than an empty picker; a warning names the mismatch once.
fn filter_offered_models(offered: Vec<String>, configured: Option<&[String]>) -> Vec<String> {
let Some(configured) = configured else {
return offered;
};
let matching: Vec<String> = offered
.iter()
.filter(|model| configured.iter().any(|c| c == *model))
.cloned()
.collect();
if matching.is_empty() {
tracing::warn!(
configured = configured.join(", "),
"HIVE_AVAILABLE_MODELS matches none of the models this ACP session offers; \
showing every model it offers instead"
);
offered
} else {
matching
}
}
/// The model and effort pickers: the value each shows and the values it
/// offers.
#[derive(Debug, PartialEq, Eq)]
@ -460,7 +504,9 @@ pub(super) struct Pickers {
pub(super) fn pickers(bus: &crate::events::Bus) -> Pickers {
let (model, effort) = (bus.model(), bus.effort());
match bus.session_choices() {
Some(choices) => offered_pickers(model, effort, choices.get()),
Some(choices) => {
offered_pickers(model, effort, choices.get(), configured_models().as_deref())
}
None => Pickers {
model,
available_models: available_models(),
@ -477,11 +523,14 @@ pub(super) fn pickers(bus: &crate::events::Bus) -> Pickers {
/// on the next turn, and only if the session offers it, so each picker shows
/// the requested value when offered and the session's own otherwise. The
/// effort levels on offer come with the model, so while a new model waits
/// for the next turn the effort picker is empty.
/// for the next turn the effort picker is empty. `configured` narrows the
/// model list per [`filter_offered_models`]; it never narrows which value is
/// shown as current, only which values the picker offers alongside it.
fn offered_pickers(
model: String,
effort: String,
offered: hive_runtime::SessionChoices,
configured: Option<&[String]>,
) -> Pickers {
let shown = |wanted: String, choice: Option<&hive_runtime::Choice>| match choice {
Some(c) if !c.values.contains(&wanted) => c.current.clone(),
@ -495,7 +544,10 @@ fn offered_pickers(
Pickers {
model: shown(model, offered.model.as_ref()),
effort: shown(effort, effort_choice.as_ref()),
available_models: offered.model.map(|c| c.values).unwrap_or_default(),
available_models: offered
.model
.map(|c| filter_offered_models(c.values, configured))
.unwrap_or_default(),
available_efforts: effort_choice.map(|c| c.values).unwrap_or_default(),
}
}
@ -504,7 +556,7 @@ fn offered_pickers(
mod tests {
use hive_runtime::{Choice, SessionChoices};
use super::{Pickers, offered_pickers};
use super::{Pickers, filter_offered_models, offered_pickers};
fn choice(current: &str, values: &[&str]) -> Choice {
Choice {
@ -525,7 +577,7 @@ mod tests {
};
// `haiku` and `max` are not on offer: the session's own values show.
assert_eq!(
offered_pickers("haiku".into(), "max".into(), offered.clone()),
offered_pickers("haiku".into(), "max".into(), offered.clone(), None),
Pickers {
model: "m/think".into(),
available_models: strings(&["m/think", "m/plain"]),
@ -533,7 +585,7 @@ mod tests {
available_efforts: strings(&["low", "high"]),
}
);
let picked = offered_pickers("m/think".into(), "high".into(), offered);
let picked = offered_pickers("m/think".into(), "high".into(), offered, None);
assert_eq!(picked.effort, "high");
assert_eq!(picked.available_efforts, strings(&["low", "high"]));
}
@ -544,7 +596,7 @@ mod tests {
model: Some(choice("m/plain", &["m/think", "m/plain"])),
effort: None,
};
let shown = offered_pickers("m/plain".into(), "high".into(), plain);
let shown = offered_pickers("m/plain".into(), "high".into(), plain, None);
assert_eq!(shown.model, "m/plain");
assert!(shown.available_efforts.is_empty());
@ -552,14 +604,57 @@ mod tests {
model: Some(choice("m/think", &["m/think", "m/plain"])),
effort: Some(choice("low", &["low", "high"])),
};
let pending = offered_pickers("m/plain".into(), "high".into(), think);
let pending = offered_pickers("m/plain".into(), "high".into(), think, None);
assert_eq!(pending.model, "m/plain");
assert!(pending.available_efforts.is_empty());
}
#[test]
fn acp_pickers_are_empty_before_a_session_offers_anything() {
let none = offered_pickers("haiku".into(), "high".into(), SessionChoices::default());
let none = offered_pickers(
"haiku".into(),
"high".into(),
SessionChoices::default(),
None,
);
assert!(none.available_models.is_empty() && none.available_efforts.is_empty());
}
#[test]
fn unconfigured_model_list_leaves_the_session_offer_untouched() {
assert_eq!(
filter_offered_models(strings(&["m/think", "m/plain"]), None),
strings(&["m/think", "m/plain"])
);
}
#[test]
fn configured_model_list_keeps_only_the_matches_in_the_session_s_order() {
let configured = strings(&["m/plain", "m/other"]);
assert_eq!(
filter_offered_models(strings(&["m/think", "m/plain"]), Some(&configured)),
strings(&["m/plain"])
);
}
#[test]
fn configured_model_list_matching_nothing_falls_back_to_every_offered_model() {
// The claude names on an ACP agent that never set `availableModels`.
let configured = strings(&["haiku", "sonnet", "opus"]);
assert_eq!(
filter_offered_models(strings(&["m/think", "m/plain"]), Some(&configured)),
strings(&["m/think", "m/plain"])
);
}
#[test]
fn offered_pickers_applies_the_configured_model_filter() {
let offered = SessionChoices {
model: Some(choice("m/think", &["m/think", "m/plain"])),
effort: None,
};
let configured = strings(&["m/plain"]);
let filtered = offered_pickers("m/think".into(), "high".into(), offered, Some(&configured));
assert_eq!(filtered.available_models, strings(&["m/plain"]));
}
}

View file

@ -103,7 +103,10 @@ in
UI; this option only controls which choices the picker presents.
Values are the short model names that `claude --model` accepts:
`"haiku"`, `"sonnet"`, `"opus"` (or any future identifier).
`"haiku"`, `"sonnet"`, `"opus"` (or any future identifier). On an ACP
agent this same list also filters the model picker, matched against
the model ids opencode reports (`<provider-id>/<model-id>`); a list
that matches none of them shows every model the session offers.
'';
};