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/credentials.rs b/openless-all/app/crates/openless-core/src/credentials.rs index b74d8c65f..7e9add0e5 100644 --- a/openless-all/app/crates/openless-core/src/credentials.rs +++ b/openless-all/app/crates/openless-core/src/credentials.rs @@ -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() @@ -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( @@ -653,27 +670,6 @@ impl CredentialMetadata { mutation: ChannelMutation, has_credentials: impl Fn(&str) -> bool, ) -> Result { - 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() { @@ -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) } @@ -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, @@ -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(); 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 0040d52c2..3b33c3d97 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 @@ -1405,19 +1405,31 @@ async fn failed_activation_restores_the_channel_selected_during_native_preparati }) .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_prepare.lock().unwrap() = Some(Box::new(move || { // The in-memory store completes synchronously. This models the user // choosing C while preparation for B is still awaiting native work. credentials - .set_active_provider(ProviderSlot::Asr, "openai-compatible".into()) + .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 = "openai-compatible".into(); + next.active_asr_provider = cloud_id.clone(); backend .update_settings( next, @@ -1438,11 +1450,11 @@ async fn failed_activation_restores_the_channel_selected_during_native_preparati .is_err()); assert_eq!( backend.get_preferences().active_asr_provider, - "openai-compatible" + expected_cloud_id ); assert_eq!( backend.active_provider(ProviderSlot::Asr).await.unwrap(), - "openai-compatible" + expected_cloud_id ); let _ = std::fs::remove_dir_all(data_dir); } diff --git a/openless-all/app/linux-egui/src/credentials.rs b/openless-all/app/linux-egui/src/credentials.rs index 595c2fdf0..7d98a9f27 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.active_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); @@ -1215,6 +1229,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!( @@ -1226,19 +1279,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/src-tauri/src/persistence/credentials.rs b/openless-all/app/src-tauri/src/persistence/credentials.rs index 5ef7ef794..f67f5676a 100644 --- a/openless-all/app/src-tauri/src/persistence/credentials.rs +++ b/openless-all/app/src-tauri/src/persistence/credentials.rs @@ -1043,21 +1043,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: @@ -1186,26 +1171,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(); - } + // 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.active_provider(openless_core::ProviderSlot::Asr); + root.active.llm = metadata.active_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); } - // Omni normalization must not trigger the separate ASR/LLM active fallback. + let changed = asr_changed || llm_changed || seeded || selection_changed; + // Omni uses its own explicit provider selection. migrate_legacy_omni_slot(&mut root.omni) || changed } @@ -2955,7 +2933,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) @@ -4018,15 +3996,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; @@ -4931,6 +4909,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 f69d72f5a..71d565124 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 { @@ -185,6 +187,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); @@ -1458,7 +1500,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 @@ -1515,7 +1557,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]; @@ -1548,7 +1590,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]; @@ -1701,9 +1743,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 @@ -1741,7 +1783,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: 'Windows · sherpa-onnx', downloadError: @@ -1768,8 +1810,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', }); @@ -1781,7 +1822,7 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { remoteSizes, progress, settings?.activeModel, - prefs?.activeAsrProvider, + activeAsrProvider, sherpaCatalog, sherpaRemoteSizes, sherpaDownloadProgress,