Skip to content
Draft
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.
163 changes: 138 additions & 25 deletions openless-all/app/crates/openless-core/src/credentials.rs
Original file line number Diff line number Diff line change
Expand Up @@ -576,6 +576,20 @@ impl CredentialMetadata {
}

pub fn active_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();
}
}
// Only stores without channel configuration may use a legacy provider
// identifier. A stale cache must never bypass channel order or enabled.
self.active_providers
.get(&slot)
.cloned()
Expand Down Expand Up @@ -609,6 +623,9 @@ impl CredentialMetadata {
let mut reordered = false;
if let Some(kind) = kind {
let channels = self.channels.entry(kind).or_default();
if !channels.is_empty() && !channels.iter().any(|channel| channel.id == provider_id) {
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 @@ -653,27 +670,6 @@ impl CredentialMetadata {
mutation: ChannelMutation,
has_credentials: impl Fn(&str) -> bool,
) -> Result<ChannelMutationResult, BackendError> {
let mutation_kind = match &mutation {
ChannelMutation::ActivateLocalAsr { .. } => ChannelKind::Asr,
ChannelMutation::Create { kind, .. }
| ChannelMutation::SetProviderType { kind, .. }
| ChannelMutation::InvalidateTest { kind, .. }
| ChannelMutation::InvalidateTests { kind }
| ChannelMutation::DeleteIfBlank { kind, .. }
| ChannelMutation::Rename { kind, .. }
| ChannelMutation::Delete { kind, .. }
| ChannelMutation::SetEnabled { kind, .. }
| ChannelMutation::Reorder { kind, .. }
| ChannelMutation::RecordTest { kind, .. } => *kind,
};
let slot = slot_for_kind(mutation_kind);
let active = self.active_provider(slot);
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));
let (kind, result) = match mutation {
ChannelMutation::ActivateLocalAsr { id, provider_type } => {
if provider_type.trim().is_empty() {
Expand Down Expand Up @@ -874,9 +870,7 @@ impl CredentialMetadata {
normalize_channel_order(self.channels.entry(kind).or_default());
if !matches!(result, ChannelMutationResult::DeletedIfBlank(false)) {
self.revision = self.revision.saturating_add(1);
if active_was_managed {
self.sync_active(kind);
}
self.sync_active(kind);
}
Ok(result)
}
Expand All @@ -886,13 +880,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 @@ -1222,6 +1229,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.active_provider(ProviderSlot::Asr), "cloud-channel");
assert_eq!(reloaded.active_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.active_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.active_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
128 changes: 102 additions & 26 deletions openless-all/app/crates/openless-core/src/provider_resolution.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
use std::sync::Arc;

use crate::credentials::{
ChannelKind, CredentialKey, CredentialNamespace, CredentialStore, ProviderChannelId,
ProviderSlot, ProviderType,
first_enabled_channel, ChannelKind, CredentialKey, CredentialNamespace, CredentialStore,
ProviderChannelId, ProviderSlot, ProviderType,
};
use crate::dictation_context::ProviderInvocation;
use crate::errors::{BackendError, BackendErrorCode};
Expand All @@ -12,39 +12,42 @@ pub(crate) async fn resolve_session_provider(
slot: ProviderSlot,
preference_fallback: &str,
) -> Result<ProviderInvocation, BackendError> {
let provider_id = match credential_store.active_provider(slot).await {
Ok(provider) if !provider.trim().is_empty() => provider,
Ok(_) => preference_fallback.to_string(),
Err(error) if error.code == BackendErrorCode::Unsupported => {
preference_fallback.to_string()
}
Err(error) => return Err(error),
};
let channel_kind = match slot {
ProviderSlot::Asr => Some(ChannelKind::Asr),
ProviderSlot::Llm => Some(ChannelKind::Llm),
ProviderSlot::Omni => None,
};
let provider_type = if let Some(kind) = channel_kind {
let selected_channel = if let Some(kind) = channel_kind {
match credential_store.list_channels(kind).await {
Ok(channels) => match channels
.into_iter()
.find(|channel| channel.id == provider_id)
{
Some(channel) if channel.enabled => channel.provider_type,
Some(_) => {
return Err(BackendError::new(
Ok(channels) if !channels.is_empty() => {
Some(first_enabled_channel(&channels).cloned().ok_or_else(|| {
BackendError::new(
BackendErrorCode::InvalidState,
"the selected provider channel is disabled",
));
}
None => provider_id.clone(),
},
Err(error) if error.code == BackendErrorCode::Unsupported => provider_id.clone(),
"no provider channel is enabled",
)
})?)
}
Ok(_) => None,
Err(error) if error.code == BackendErrorCode::Unsupported => None,
Err(error) => return Err(error),
}
} else {
provider_id.clone()
None
};
let (provider_id, provider_type) = if let Some(channel) = selected_channel {
// Resolve both fields from one channel snapshot, rather than trusting a
// separately persisted active value that may be stale after a restart.
(channel.id, channel.provider_type)
} else {
let legacy_id = match credential_store.active_provider(slot).await {
Ok(provider) if !provider.trim().is_empty() => provider,
Ok(_) => preference_fallback.to_string(),
Err(error) if error.code == BackendErrorCode::Unsupported => {
preference_fallback.to_string()
}
Err(error) => return Err(error),
};
(legacy_id.clone(), legacy_id)
};
let provider_id = ProviderChannelId::new(provider_id)?;
let provider_type = ProviderType::new(provider_type)?;
Expand Down Expand Up @@ -83,7 +86,80 @@ pub(crate) async fn resolve_session_provider(
#[cfg(test)]
mod tests {
use super::*;
use crate::credentials::{ChannelMutation, CredentialDirectory, InMemoryCredentialStore};
use crate::credentials::{
ChannelMutation, ChannelSummary, CredentialDirectory, CredentialMetadata,
CredentialMetadataStore, InMemoryCredentialStore, SecretValue,
};

async fn restored_store(enabled: bool, stale: &str) -> Arc<InMemoryCredentialStore> {
let store = Arc::new(InMemoryCredentialStore::default());
store
.save_metadata(CredentialMetadata::from_parts(
vec![
ChannelSummary {
id: "old-local".into(),
name: String::new(),
provider_type: "local-qwen3-mlx".into(),
enabled: false,
order: 2,
last_test: None,
},
ChannelSummary {
id: "cloud-account".into(),
name: String::new(),
provider_type: "tencent-cloud".into(),
enabled,
order: 0,
last_test: None,
},
],
vec![],
stale,
"",
"",
0,
))
.await
.unwrap();
store
.write(
CredentialKey::new(
CredentialNamespace::Asr,
Some("cloud-account".into()),
"asr.model",
)
.unwrap(),
SecretValue::new("Hy-ASR-3.0-preview"),
)
.await
.unwrap();
store
}

#[tokio::test]
async fn restored_channel_selection_resolves_actual_cloud_id_protocol_and_model() {
for stale in ["local-qwen3-mlx", "old-local", "missing", ""] {
let credential_store: Arc<dyn CredentialStore> = restored_store(true, stale).await;
let resolved =
resolve_session_provider(&credential_store, ProviderSlot::Asr, "local-qwen3-mlx")
.await
.unwrap();
assert_eq!(resolved.provider_id, "cloud-account");
assert_eq!(resolved.provider_type, "tencent-cloud");
assert_eq!(resolved.model.as_deref(), Some("Hy-ASR-3.0-preview"));
}
}

#[tokio::test]
async fn all_disabled_channels_block_legacy_preference_fallback() {
let credential_store: Arc<dyn CredentialStore> =
restored_store(false, "local-qwen3-mlx").await;
let error =
resolve_session_provider(&credential_store, ProviderSlot::Asr, "local-qwen3-mlx")
.await
.unwrap_err();
assert_eq!(error.code, BackendErrorCode::InvalidState);
}

#[tokio::test]
async fn reordered_asr_channel_wins_over_stale_preference() {
Expand Down
Loading
Loading