diff --git a/docs/provider-channel-selection.md b/docs/provider-channel-selection.md new file mode 100644 index 000000000..b8da20b87 --- /dev/null +++ b/docs/provider-channel-selection.md @@ -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. diff --git a/openless-all/app/crates/openless-core/src/api.rs b/openless-all/app/crates/openless-core/src/api.rs index 8b9604294..0d13554a4 100644 --- a/openless-all/app/crates/openless-core/src/api.rs +++ b/openless-all/app/crates/openless-core/src/api.rs @@ -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()) diff --git a/openless-all/app/crates/openless-core/src/credentials.rs b/openless-all/app/crates/openless-core/src/credentials.rs index 934ecc986..0e0e097a7 100644 --- a/openless-all/app/crates/openless-core/src/credentials.rs +++ b/openless-all/app/crates/openless-core/src/credentials.rs @@ -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); @@ -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( @@ -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(); @@ -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, @@ -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(); diff --git a/openless-all/app/crates/openless-core/src/provider_resolution.rs b/openless-all/app/crates/openless-core/src/provider_resolution.rs index 57b44a761..f2a8e1a99 100644 --- a/openless-all/app/crates/openless-core/src/provider_resolution.rs +++ b/openless-all/app/crates/openless-core/src/provider_resolution.rs @@ -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}; @@ -12,39 +12,42 @@ pub(crate) async fn resolve_session_provider( slot: ProviderSlot, preference_fallback: &str, ) -> Result { - 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)?; @@ -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 { + 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 = 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 = + 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() { diff --git a/openless-all/app/crates/openless-core/tests/local_asr_contract.rs b/openless-all/app/crates/openless-core/tests/local_asr_contract.rs index 2b1f242fa..ceb56c00d 100644 --- a/openless-all/app/crates/openless-core/tests/local_asr_contract.rs +++ b/openless-all/app/crates/openless-core/tests/local_asr_contract.rs @@ -1268,6 +1268,77 @@ async fn activation_commit_failure_does_not_load_or_restore_a_runtime() { std::fs::remove_dir_all(data_dir).unwrap(); } +#[tokio::test] +async fn failed_activation_restores_the_channel_selected_during_native_preparation() { + use futures_util::FutureExt; + let credentials = Arc::new(InMemoryCredentialStore::default()); + let (data_dir, runtime, backend) = + local_asr_backend_with_credentials(credentials.clone(), Some("local-qwen3")); + let qwen = LocalAsrTarget::parse(LocalAsrRuntime::Generic, "qwen3-asr-0.6b").unwrap(); + let qwen_dir = data_dir.join("models").join(qwen.model_id()); + std::fs::create_dir_all(&qwen_dir).unwrap(); + std::fs::write(qwen_dir.join(openless_core::MODEL_READY_SENTINEL), b"ready").unwrap(); + backend + .activate_local_asr(LocalAsrActivationRequest { + target: qwen, + provider_id: "local-qwen3".into(), + }) + .await + .unwrap(); + let ChannelMutationResult::Created(cloud_id) = credentials + .mutate_channel(ChannelMutation::Create { + kind: ChannelKind::Asr, + provider_type: "openai-compatible".into(), + name: "Cloud channel selected during preparation".into(), + }) + .await + .unwrap() + else { + panic!("expected cloud channel") + }; + let expected_cloud_id = cloud_id.clone(); + let backend = Arc::new(backend); + let weak = Arc::downgrade(&backend); + *runtime.during_validation.lock().unwrap() = Some(Box::new(move || { + // The in-memory store completes synchronously. This models the user + // choosing C while validation for B is still awaiting native work. + credentials + .set_active_provider(ProviderSlot::Asr, cloud_id.clone()) + .now_or_never() + .unwrap() + .unwrap(); + let backend = weak.upgrade().unwrap(); + let mut next = backend.get_preferences(); + next.active_asr_provider = cloud_id.clone(); + backend + .update_settings( + next, + openless_core::SettingsUpdateOptions::SETTINGS_DOCUMENT, + &openless_core::NoopSettingsRuntime, + ) + .unwrap(); + })); + runtime + .fail_release + .store(true, std::sync::atomic::Ordering::SeqCst); + assert!(backend + .activate_local_asr(LocalAsrActivationRequest { + target: LocalAsrTarget::parse(LocalAsrRuntime::Foundry, "whisper-medium").unwrap(), + provider_id: "foundry-local-whisper".into(), + }) + .await + .is_err()); + assert_eq!( + backend.get_preferences().active_asr_provider, + expected_cloud_id + ); + assert_eq!( + backend.active_provider(ProviderSlot::Asr).await.unwrap(), + expected_cloud_id + ); + let _ = std::fs::remove_dir_all(data_dir); +} + #[tokio::test] async fn switching_models_releases_old_inference_but_does_not_load_the_selection() { let (data_dir, runtime, backend) = local_asr_backend(); diff --git a/openless-all/app/linux-egui/src/credentials.rs b/openless-all/app/linux-egui/src/credentials.rs index 00092f792..94da6b9d1 100644 --- a/openless-all/app/linux-egui/src/credentials.rs +++ b/openless-all/app/linux-egui/src/credentials.rs @@ -273,10 +273,24 @@ impl CredentialStore for LinuxCredentialStore { .expect("credential metadata lock poisoned") .clone(); Box::pin(async move { - let active_asr_provider = non_empty(state.metadata.active_provider(ProviderSlot::Asr)) - .unwrap_or(preferences.active_asr_provider); - let active_llm_provider = non_empty(state.metadata.active_provider(ProviderSlot::Llm)) - .unwrap_or(preferences.active_llm_provider); + let active_channel = |kind, slot, fallback: String| { + let active = state.metadata.selected_provider(slot); + if state.metadata.list_channels(kind).is_empty() { + non_empty(active).unwrap_or(fallback) + } else { + active + } + }; + let active_asr_provider = active_channel( + ChannelKind::Asr, + ProviderSlot::Asr, + preferences.active_asr_provider, + ); + let active_llm_provider = active_channel( + ChannelKind::Llm, + ProviderSlot::Llm, + preferences.active_llm_provider, + ); let active_omni_provider = non_empty(state.metadata.active_provider(ProviderSlot::Omni)) .unwrap_or(preferences.active_omni_provider); @@ -1234,6 +1248,45 @@ mod tests { let _ = std::fs::remove_dir_all(store.metadata_path.parent().unwrap()); } + #[tokio::test] + async fn disabled_channels_do_not_restore_stale_preferences_in_status() { + let store = temporary_store(); + let ChannelMutationResult::Created(id) = store + .mutate_channel(ChannelMutation::Create { + kind: ChannelKind::Asr, + provider_type: "tencent-cloud".into(), + name: "Cloud".into(), + }) + .await + .unwrap() + else { + panic!("expected created channel") + }; + store + .mutate_channel(ChannelMutation::SetEnabled { + kind: ChannelKind::Asr, + id, + enabled: false, + }) + .await + .unwrap(); + let reopened = LinuxCredentialStore::open(store.metadata_path.parent().unwrap()).unwrap(); + let status = reopened + .status(UserPreferences { + active_asr_provider: "local-qwen3-mlx".into(), + ..Default::default() + }) + .await + .unwrap(); + assert!(status.active_asr_provider.is_empty()); + assert!(reopened + .active_provider(ProviderSlot::Asr) + .await + .unwrap() + .is_empty()); + let _ = std::fs::remove_dir_all(store.metadata_path.parent().unwrap()); + } + #[tokio::test] async fn metadata_round_trips_without_secret_values() { let root = std::env::temp_dir().join(format!( @@ -1245,19 +1298,22 @@ mod tests { .set_active_provider(ProviderSlot::Asr, "local-qwen".into()) .await .unwrap(); - store + let ChannelMutationResult::Created(created) = store .mutate_channel(ChannelMutation::Create { kind: ChannelKind::Asr, provider_type: "openai-compatible".into(), name: "Primary".into(), }) .await - .unwrap(); + .unwrap() + else { + panic!("expected created channel") + }; let reopened = LinuxCredentialStore::open(&root).unwrap(); assert_eq!( reopened.active_provider(ProviderSlot::Asr).await.unwrap(), - "local-qwen" + created ); assert_eq!( reopened diff --git a/openless-all/app/scripts/build-mobile-rime.py b/openless-all/app/scripts/build-mobile-rime.py index 1005c91c9..1bde1ee29 100644 --- a/openless-all/app/scripts/build-mobile-rime.py +++ b/openless-all/app/scripts/build-mobile-rime.py @@ -24,11 +24,29 @@ 'prelude': ('rime/rime-prelude', '082425ea0684bca36474415d4a0e8db9b016487e'), } +REQUIRED_FILES = { + 'rime-1.17.0': ('CMakeLists.txt',), + 'yaml-0.8.0': ('CMakeLists.txt',), + 'leveldb-1.23': ('CMakeLists.txt',), + 'opencc-ver.1.1.9': ('CMakeLists.txt',), + 'pinyin-0c6861ef7420ee780270ca6d993d18d4101049d0': ('pinyin_simp.schema.yaml',), + 'ice-da1fbe602e38f26db846fa10120ee64c2b0324c0': ('rime_ice.dict.yaml', 'rime_ice.schema.yaml'), + 'essay-054920de4f54c9e5994276a96a4fc2a35cb51aa3': ('essay.txt', 'AUTHORS'), + 'prelude-082425ea0684bca36474415d4a0e8db9b016487e': ('default.yaml',), + 'boost-1.85.0': ('boost/core/detail/sp_thread_sleep.hpp',), +} + def fetch(name, url): dest = CACHE / 'sources' / name - if dest.exists(): - return dest archive = CACHE / 'downloads' / (name + '.tar.gz') + required = tuple(dest / path for path in REQUIRED_FILES.get(name, ())) + complete = lambda: dest.exists() and all(path.is_file() for path in required) + if complete(): + return dest + if dest.exists(): + print('Repair incomplete ' + name, flush=True) + shutil.rmtree(dest) + archive.unlink(missing_ok=True) archive.parent.mkdir(parents=True, exist_ok=True) if not archive.exists(): print('Download ' + name, flush=True) @@ -37,6 +55,8 @@ def fetch(name, url): shutil.copyfileobj(response, output) temporary.rename(archive) unpack = CACHE / 'sources' / (name + '-unpack') + if unpack.exists(): + shutil.rmtree(unpack) unpack.mkdir(parents=True, exist_ok=True) with tarfile.open(archive) as tar: if sys.version_info >= (3, 12): @@ -57,6 +77,11 @@ def fetch(name, url): raise RuntimeError('Unexpected archive structure: ' + name) roots[0].rename(dest) unpack.rmdir() + if not complete(): + print('Repair incomplete ' + name, flush=True) + shutil.rmtree(dest) + archive.unlink(missing_ok=True) + return fetch(name, url) return dest def run(*args): diff --git a/openless-all/app/src-tauri/src/persistence/credentials.rs b/openless-all/app/src-tauri/src/persistence/credentials.rs index be2de009f..873ca8cb2 100644 --- a/openless-all/app/src-tauri/src/persistence/credentials.rs +++ b/openless-all/app/src-tauri/src/persistence/credentials.rs @@ -1076,21 +1076,6 @@ fn channel_provider_type<'a, V: HasChannelMeta>(key: &'a str, entry: &'a V) -> & entry.meta().providerType.as_deref().unwrap_or(key) } -/// Only for the v1 -> v2 migration to repair a lost active; runtime active -/// policy lives in Core. -fn current_channel_id(map: &HashMap) -> Option { - map.iter() - .filter(|(_, entry)| entry.meta().enabled) - .min_by(|(left_key, left), (right_key, right)| { - let left_order = left.meta().order.unwrap_or(u32::MAX); - let right_order = right.meta().order.unwrap_or(u32::MAX); - left_order - .cmp(&right_order) - .then_with(|| left_key.as_str().cmp(right_key.as_str())) - }) - .map(|(key, _)| key.clone()) -} - /// v1 (one slot per preset) → v2 (channel cards). /// /// Two pillars of idempotency: @@ -1219,26 +1204,19 @@ fn migrate_channels(root: &mut CredsRoot) -> bool { false }; - let changed = asr_changed || llm_changed || seeded; - if changed { - if !root - .providers - .asr - .get(&root.active.asr) - .is_some_and(|entry| entry.meta().enabled) - { - root.active.asr = current_channel_id(&root.providers.asr).unwrap_or_default(); - } - if !root - .providers - .llm - .get(&root.active.llm) - .is_some_and(|entry| entry.meta().enabled) - { - root.active.llm = current_channel_id(&root.providers.llm).unwrap_or_default(); - } - } - // Omni normalization must not trigger the separate ASR/LLM active fallback. + // Reconcile on every successful load, including already-channelized vaults + // restored from disk/sync. Status and credential lookups must use the same + // first-enabled selection as Core and the settings list. This only changes + // the loaded copy; the next gated mutation owns durable persistence. + let metadata = credential_metadata(root); + root.active.asr = metadata.selected_provider(openless_core::ProviderSlot::Asr); + root.active.llm = metadata.selected_provider(openless_core::ProviderSlot::Llm); + let selection_changed = root.active.asr != active_asr || root.active.llm != active_llm; + if selection_changed { + root.metadata_revision = root.metadata_revision.saturating_add(1); + } + let changed = asr_changed || llm_changed || seeded || selection_changed; + // Omni uses its own explicit provider selection. migrate_legacy_omni_slot(&mut root.omni) || changed } @@ -3007,7 +2985,7 @@ fn channel_summaries( } }) .collect(); - // Same ordering as current_channel_id: ascending order, ties broken by id alphabetical order. + // Same ordering as Core channel selection: ascending order, ties broken by id alphabetical order. list.sort_by(|left, right| { left.order .cmp(&right.order) @@ -3071,8 +3049,8 @@ fn apply_credential_metadata( metadata.list_channels(ChannelKind::Llm), |entry, name| entry.displayName = name, ); - root.active.asr = metadata.active_provider(openless_core::ProviderSlot::Asr); - root.active.llm = metadata.active_provider(openless_core::ProviderSlot::Llm); + root.active.asr = metadata.selected_provider(openless_core::ProviderSlot::Asr); + root.active.llm = metadata.selected_provider(openless_core::ProviderSlot::Llm); root.omni.active = metadata.active_provider(openless_core::ProviderSlot::Omni); root.metadata_revision = metadata.revision(); Ok(()) @@ -4117,15 +4095,15 @@ mod tests { use super::load_android_credentials_from_source_with_crypto; use super::{ android_credentials_root_for_update, android_persistable_credentials, chunk_json_payload, - credentials_cache, get_android_marketplace_token_at, load_android_credentials_from_path, - load_android_credentials_from_path_with_crypto, load_credentials_into_cache_with, - lookup_account, lookup_marketplace_github_token, lookup_omni_account, - omni_extra_headers_json, omni_temperature_string, parse_extra_headers_json, - parse_llm_temperature, reset_credentials_cache_for_tests, + configuration_snapshot_with, credentials_cache, get_android_marketplace_token_at, + load_android_credentials_from_path, load_android_credentials_from_path_with_crypto, + load_credentials_into_cache_with, lookup_account, lookup_marketplace_github_token, + lookup_omni_account, migrate_channels, omni_extra_headers_json, omni_temperature_string, + parse_extra_headers_json, parse_llm_temperature, reset_credentials_cache_for_tests, set_llm_extra_headers_for_provider_in_root, set_llm_temperature_for_provider_in_root, - write_account, write_marketplace_github_token, write_omni_account, CredentialAccount, - CredentialsVault, CredsAsrEntry, CredsLlmEntry, CredsRoot, MarketplaceGithubToken, - KEYRING_CHUNK_MAX_UTF16_UNITS, + write_account, write_marketplace_github_token, write_omni_account, ChannelMeta, + CredentialAccount, CredentialsVault, CredsAsrEntry, CredsLlmEntry, CredsRoot, + MarketplaceGithubToken, CHANNELS_SCHEMA_VERSION, KEYRING_CHUNK_MAX_UTF16_UNITS, }; use anyhow::anyhow; use parking_lot::Mutex; @@ -5030,6 +5008,88 @@ mod tests { assert_eq!(serde_json::to_string(&root).expect("encode"), after_first); } + #[test] + fn loaded_channel_selection_repairs_stale_active_without_changing_credentials() { + for stale in ["local-qwen3-mlx", "local-account", "missing", ""] { + let mut root = CredsRoot::default(); + root.version = CHANNELS_SCHEMA_VERSION; + root.active.asr = stale.into(); + root.providers.asr.insert( + "local-account".into(), + CredsAsrEntry { + channel: ChannelMeta { + providerType: Some("local-qwen3-mlx".into()), + order: Some(1), + enabled: false, + lastTest: None, + }, + ..Default::default() + }, + ); + root.providers.asr.insert( + "cloud-account".into(), + CredsAsrEntry { + channel: ChannelMeta { + providerType: Some("tencent-cloud".into()), + order: Some(0), + enabled: true, + lastTest: None, + }, + model: Some("Hy-ASR-3.0-preview".into()), + tencentCloudSecretKey: Some("fixture-secret".into()), + ..Default::default() + }, + ); + let providers = serde_json::to_value(&root.providers).unwrap(); + assert!(migrate_channels(&mut root)); + assert_eq!(root.active.asr, "cloud-account"); + assert_eq!(root.metadata_revision, 1); + assert_eq!(serde_json::to_value(&root.providers).unwrap(), providers); + let serialized = serde_json::to_string(&root).unwrap(); + let mut restarted: CredsRoot = serde_json::from_str(&serialized).unwrap(); + assert!(!migrate_channels(&mut restarted)); + assert_eq!( + serde_json::to_value(&restarted).unwrap(), + serde_json::from_str::(&serialized).unwrap() + ); + let status = configuration_snapshot_with(false, || Ok(restarted)).unwrap(); + assert_eq!(status.active_asr_provider, "tencent-cloud"); + assert_eq!( + status.credentials.asr_model.as_deref(), + Some("Hy-ASR-3.0-preview") + ); + } + } + + #[test] + fn loaded_channel_selection_uses_order_even_when_old_active_is_enabled() { + let mut root = v1_root_with_two_asr_providers(); + migrate_channels(&mut root); + root.providers.asr.get_mut("groq").unwrap().channel.order = Some(0); + root.providers + .asr + .get_mut("volcengine") + .unwrap() + .channel + .order = Some(1); + assert!(migrate_channels(&mut root)); + assert_eq!(root.active.asr, "groq"); + assert!(!migrate_channels(&mut root)); + } + + #[test] + fn loaded_channel_selection_does_not_reactivate_disabled_models() { + let mut root = v1_root_with_two_asr_providers(); + migrate_channels(&mut root); + for entry in root.providers.asr.values_mut() { + entry.channel.enabled = false; + } + root.active.asr = "local-qwen3-mlx".into(); + assert!(migrate_channels(&mut root)); + assert!(root.active.asr.is_empty()); + assert!(!migrate_channels(&mut root)); + } + #[test] fn migrated_credentials_still_resolve_through_lookup_account() { let mut root = v1_root_with_two_asr_providers(); diff --git a/openless-all/app/src/pages/LocalAsr/index.tsx b/openless-all/app/src/pages/LocalAsr/index.tsx index 97a80e5ae..7a2833d77 100644 --- a/openless-all/app/src/pages/LocalAsr/index.tsx +++ b/openless-all/app/src/pages/LocalAsr/index.tsx @@ -13,6 +13,8 @@ import { LocalModelMetadataCache } from '../../lib/localModelMetadataCache'; import type { LocalAsrRemoteInfo, SherpaOnnxRemoteInfo } from '../../lib/localAsr'; import { restartApp } from '../../lib/ipc/permissions'; import { isTauri } from '../../lib/ipc'; +import { getCredentials } from '../../lib/ipc/asr-credentials'; +import type { CredentialsStatus } from '../../lib/types'; import { emitSaved } from '../../lib/savedEvent'; import { useLayoutStack } from '../../lib/useMobileLayout'; import { @@ -190,6 +192,46 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { })); const stackLayout = useLayoutStack(1000); const { prefs, updatePrefs } = useHotkeySettings(); + // The selected channel owns routing. Preferences only remember local model + // configuration and may still contain a legacy provider after switching away. + const [activeAsrProvider, setResolvedAsrProvider] = useState(null); + useEffect(() => { + let cancelled = false; + let revision = 0; + let unlisten: (() => void) | undefined; + const refreshActive = async () => { + const requestedRevision = revision; + try { + const status = await getCredentials(); + if (!cancelled && requestedRevision === revision) { + setResolvedAsrProvider(status.activeAsrProvider); + } + } catch { + if (!cancelled && requestedRevision === revision) setResolvedAsrProvider(null); + } + }; + void (async () => { + if (isTauri) { + const { listen } = await import('@tauri-apps/api/event'); + const off = await listen('credentials:changed', ({ payload }) => { + revision += 1; + if (!cancelled) setResolvedAsrProvider(payload.activeAsrProvider); + }); + if (cancelled) { + off(); + return; + } + unlisten = off; + } + await refreshActive(); + })().catch(() => { + if (!cancelled) setResolvedAsrProvider(null); + }); + return () => { + cancelled = true; + unlisten?.(); + }; + }, []); const [settings, setSettings] = useState(null); // Wait for the native capability query so Intel Macs never flash the MLX channel first. const [supportsQwen3Mlx, setSupportsQwen3Mlx] = useState(false); @@ -1467,7 +1509,7 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { // the built-in audio test, so Qwen3 and Whisper can be compared for load/transcribe latency on one page. const handleTest = async ( modelId: string, - provider: 'local-qwen3-mlx' | 'local-qwen3-c' | 'local-whisper' = prefs?.activeAsrProvider === + provider: 'local-qwen3-mlx' | 'local-qwen3-c' | 'local-whisper' = activeAsrProvider === 'local-qwen3-c' ? 'local-qwen3-c' : supportsQwen3Mlx @@ -1524,7 +1566,7 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { const foundryAvailable = foundryStatus?.available === true || (foundryPlatformAvailable && foundryStatus?.available !== false); - const foundryDefault = prefs?.activeAsrProvider === 'foundry-local-whisper'; + const foundryDefault = activeAsrProvider === 'foundry-local-whisper'; const selectedFoundryModel = FOUNDRY_LOCAL_ASR_MODELS.find((model) => model.alias === selectedFoundryAlias) ?? FOUNDRY_LOCAL_ASR_MODELS[0]; @@ -1557,7 +1599,7 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { const sherpaAvailable = sherpaStatus?.available === true || (foundryPlatformAvailable && sherpaStatus?.available !== false); - const sherpaDefault = prefs?.activeAsrProvider === 'sherpa-onnx-local'; + const sherpaDefault = activeAsrProvider === 'sherpa-onnx-local'; const selectedSherpaModel = SHERPA_ONNX_ASR_MODELS.find((model) => model.alias === selectedSherpaAlias) ?? SHERPA_ONNX_ASR_MODELS[0]; @@ -1710,9 +1752,9 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { isActive: settings?.activeModel === m.id && (isWhisper - ? prefs?.activeAsrProvider === 'local-whisper' + ? activeAsrProvider === 'local-whisper' : ['local-qwen3', 'local-qwen3-mlx', 'local-qwen3-c'].includes( - prefs?.activeAsrProvider ?? '', + activeAsrProvider ?? '', )), engine: isWhisper ? 'whisper' : 'qwen3', runtimeLabel: isWhisper @@ -1750,7 +1792,7 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { : null : null, isActive: - sherpaStatus?.activeModel === c.alias && prefs?.activeAsrProvider === 'sherpa-onnx-local', + sherpaStatus?.activeModel === c.alias && activeAsrProvider === 'sherpa-onnx-local', engine: 'sherpa', runtimeLabel: OS === 'android' ? 'Android · sherpa-onnx' : 'Windows · sherpa-onnx', downloadError: @@ -1777,8 +1819,7 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { isDownloading, percent: isDownloading && foundryProgress?.percent != null ? foundryProgress.percent : null, isActive: - foundryStatus?.activeModel === c.alias && - prefs?.activeAsrProvider === 'foundry-local-whisper', + foundryStatus?.activeModel === c.alias && activeAsrProvider === 'foundry-local-whisper', engine: 'foundry', runtimeLabel: 'Windows · Foundry Local', }); @@ -1790,7 +1831,7 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { remoteSizes, progress, settings?.activeModel, - prefs?.activeAsrProvider, + activeAsrProvider, sherpaCatalog, sherpaRemoteSizes, sherpaDownloadProgress,