Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions docs/provider-channel-selection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Provider channel selection

For the separate ASR and LLM channel lists, the first enabled channel is the
selected channel. Order is ascending, with channel ID breaking equal-order ties.
The settings list, credential status and new requests must follow this rule.

The stored active channel ID is a compatibility cache, not an independent
selection. Loading desktop credentials reconciles it with the channel list,
including already migrated vaults and restored data. Channel mutations update
the cache. Reconciliation changes the loaded copy; the next authorized credential
mutation persists it through the existing storage path.

New requests resolve both channel ID and provider type from the same channel
list snapshot, then look up that channel's model and credentials. A provider type
such as `local-qwen3-mlx` must not bypass configured channels or their enabled
state. A nonempty list with every channel disabled has no selected channel and
must not fall back to an old preference. Legacy active values and preferences
remain supported when there is no channel configuration. Omni keeps its own
explicit selection.

The local model page reads credential status and listens for credential changes
to display the current provider. Local model preferences still remember the
model configuration. Switching away from a local channel does not delete its
downloaded model or credentials; enable it and move it first to use it again.

Restart reconciliation checks local selection metadata. It does not send a
provider validation request or automatically choose another enabled provider
after a network failure. An ongoing recording retains the channel identity,
protocol and model selected at the start of that recording.
13 changes: 8 additions & 5 deletions openless-all/app/crates/openless-core/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5222,11 +5222,14 @@ impl OpenLessBackend {
.list_channels(ChannelKind::Asr)
.await
{
Ok(channels) => channels
.into_iter()
.find(|channel| channel.id == selected_id)
.map(|channel| channel.provider_type)
.unwrap_or(selected_id),
Ok(channels) => {
let provider = channels
.iter()
.find(|channel| channel.id == selected_id)
.or_else(|| channels.first())
.map(|channel| channel.provider_type.clone());
provider.unwrap_or(selected_id)
}
Err(_) => selected_id,
};
crate::cloud_providers::SHARED_CLOUD_ASR_PROVIDER_TYPES.contains(&provider_type.as_str())
Expand Down
168 changes: 165 additions & 3 deletions openless-all/app/crates/openless-core/src/credentials.rs
Original file line number Diff line number Diff line change
Expand Up @@ -641,6 +641,24 @@ impl CredentialMetadata {
.unwrap_or_default()
}

/// Returns the channel selected by the enabled channel order. Stores without
/// channel configuration retain their legacy active provider value.
pub fn selected_provider(&self, slot: ProviderSlot) -> String {
let kind = match slot {
ProviderSlot::Asr => Some(ChannelKind::Asr),
ProviderSlot::Llm => Some(ChannelKind::Llm),
ProviderSlot::Omni => None,
};
if let Some(channels) = kind.and_then(|kind| self.channels.get(&kind)) {
if !channels.is_empty() {
return first_enabled_channel(channels)
.map(|channel| channel.id.clone())
.unwrap_or_default();
}
}
self.active_provider(slot)
}

fn assign_active_provider(&mut self, slot: ProviderSlot, provider_id: String) {
if self.active_providers.get(&slot) != Some(&provider_id) {
self.active_providers.insert(slot, provider_id);
Expand Down Expand Up @@ -668,6 +686,19 @@ impl CredentialMetadata {
let mut reordered = false;
if let Some(kind) = kind {
let channels = self.channels.entry(kind).or_default();
let legacy_provider_type = match slot {
ProviderSlot::Asr => crate::cloud_providers::SHARED_CLOUD_ASR_PROVIDER_TYPES
.contains(&provider_id),
ProviderSlot::Llm => crate::cloud_providers::SHARED_CLOUD_LLM_PROVIDER_TYPES
.contains(&provider_id),
ProviderSlot::Omni => false,
};
if !channels.is_empty()
&& !channels.iter().any(|channel| channel.id == provider_id)
&& !legacy_provider_type
{
return Err(unknown_channel(kind, provider_id));
}
if let Some(selected) = channels.iter().find(|channel| channel.id == provider_id) {
if !selected.enabled {
return Err(BackendError::new(
Expand Down Expand Up @@ -727,13 +758,25 @@ impl CredentialMetadata {
| ChannelMutation::RecordTest { kind, .. } => *kind,
};
let slot = slot_for_kind(mutation_kind);
let active = self.active_provider(slot);
let active = self
.active_providers
.get(&slot)
.cloned()
.unwrap_or_default();
let active_was_managed = matches!(&mutation, ChannelMutation::ActivateLocalAsr { .. })
|| active.is_empty()
|| self
.channels
.get(&mutation_kind)
.is_some_and(|channels| channels.iter().any(|channel| channel.id == active));
.is_some_and(|channels| {
channels.iter().any(|channel| channel.id == active)
|| (matches!(&mutation, ChannelMutation::Create { .. })
&& channels.is_empty())
|| (!channels.is_empty()
&& first_enabled_channel(channels)
.map(|channel| channel.id.as_str())
!= Some(active.as_str()))
});
let (kind, result) = match mutation {
ChannelMutation::RegisterExperimentalAsr { model } => {
let channels = self.channels.entry(ChannelKind::Asr).or_default();
Expand Down Expand Up @@ -977,13 +1020,26 @@ impl CredentialMetadata {
let active = self
.channels
.get(&kind)
.and_then(|channels| channels.iter().find(|channel| channel.enabled))
.and_then(|channels| first_enabled_channel(channels))
.map(|channel| channel.id.clone())
.unwrap_or_default();
self.active_providers.insert(slot, active);
}
}

/// Shared selection rule for status, channel UI and new request contexts.
/// Use the channel id for credentials and its provider_type for protocol routing.
pub(crate) fn first_enabled_channel(channels: &[ChannelSummary]) -> Option<&ChannelSummary> {
channels
.iter()
.filter(|channel| channel.enabled)
.min_by(|left, right| {
left.order
.cmp(&right.order)
.then_with(|| left.id.cmp(&right.id))
})
}

fn slot_for_kind(kind: ChannelKind) -> ProviderSlot {
match kind {
ChannelKind::Asr => ProviderSlot::Asr,
Expand Down Expand Up @@ -1326,6 +1382,112 @@ mod tests {
}
}

#[test]
fn channel_order_is_authoritative_after_reloading_stale_active_metadata() {
for stale in ["local-qwen3-mlx", "local-channel", "missing", ""] {
let metadata = CredentialMetadata::from_parts(
vec![
summary("local-channel", 9, false),
summary("cloud-channel", 0, true),
],
vec![
summary("llm-backup", 1, true),
summary("llm-primary", 0, true),
],
stale,
"llm-backup",
"omni-selection",
7,
);
let reloaded: CredentialMetadata =
serde_json::from_str(&serde_json::to_string(&metadata).unwrap()).unwrap();
assert_eq!(reloaded.selected_provider(ProviderSlot::Asr), "cloud-channel");
assert_eq!(reloaded.selected_provider(ProviderSlot::Llm), "llm-primary");
assert_eq!(
reloaded.active_provider(ProviderSlot::Omni),
"omni-selection"
);
}
}

#[test]
fn all_disabled_channels_do_not_reactivate_a_legacy_provider() {
let metadata = CredentialMetadata::from_parts(
vec![summary("local-channel", 0, false)],
vec![],
"local-qwen3-mlx",
"legacy-llm",
"omni",
0,
);
assert_eq!(metadata.selected_provider(ProviderSlot::Asr), "");
assert_eq!(metadata.active_provider(ProviderSlot::Llm), "legacy-llm");
}

#[test]
fn channel_mutations_repair_an_unmanaged_active_selection() {
let mut metadata = CredentialMetadata::from_parts(
vec![
summary("local-channel", 0, true),
summary("cloud-channel", 1, true),
],
vec![],
"local-qwen3-mlx",
"",
"",
0,
);
metadata
.apply_channel_mutation(
ChannelMutation::Reorder {
kind: ChannelKind::Asr,
ids: vec!["cloud-channel".into(), "local-channel".into()],
},
|_| false,
)
.unwrap();
assert_eq!(
metadata.active_providers[&ProviderSlot::Asr],
"cloud-channel"
);
metadata
.apply_channel_mutation(
ChannelMutation::SetEnabled {
kind: ChannelKind::Asr,
id: "cloud-channel".into(),
enabled: false,
},
|_| false,
)
.unwrap();
assert_eq!(
metadata.active_providers[&ProviderSlot::Asr],
"local-channel"
);
}

#[test]
fn managed_selection_rejects_provider_types_and_disabled_channel_ids() {
let mut metadata = CredentialMetadata::from_parts(
vec![
summary("local-channel", 1, false),
summary("cloud-channel", 0, true),
],
vec![],
"cloud-channel",
"",
"",
0,
);
for id in ["local-qwen3-mlx", "local-channel"] {
assert!(metadata
.select_active_provider(ProviderSlot::Asr, id.into())
.is_err());
}
assert_eq!(metadata.selected_provider(ProviderSlot::Asr), "cloud-channel");
assert_eq!(metadata.revision(), 0);
}

#[test]
fn channel_ids_and_active_order_match_the_legacy_directory_contract() {
let mut metadata = CredentialMetadata::default();
Expand Down
Loading
Loading