diff --git a/openless-all/app/crates/openless-core/src/provider_rules.rs b/openless-all/app/crates/openless-core/src/provider_rules.rs index 01bd1ec0..6ad7919b 100644 --- a/openless-all/app/crates/openless-core/src/provider_rules.rs +++ b/openless-all/app/crates/openless-core/src/provider_rules.rs @@ -125,6 +125,10 @@ pub enum ValidationProbe { Unsupported, AsrSilence, AsrSilenceAllowsNoFinal, + /// Local engine probe (Apple Speech): the host injects its native + /// transcription engine and validation runs the same silence WAV through it, + /// exercising authorization and recognizer availability for real. + AsrNativeSilence, AsrNonSilent, StepfunNoSpeech, LlmText, @@ -185,7 +189,14 @@ fn provider_descriptor_with_label( None, None, AuthRequirement::None, - ValidationProbe::Unsupported, + // Apple Speech starts instantly and needs no download, so its card + // can run a real validation probe; download-based local engines stay + // unverifiable here and report readiness from the local model page. + if id == "apple-speech" { + ValidationProbe::AsrNativeSilence + } else { + ValidationProbe::Unsupported + }, ), ProviderKind::Asr => ( default_asr_endpoint(&id), @@ -1187,6 +1198,21 @@ mod tests { assert!(!dashscope.static_models.is_empty()); } + #[test] + fn apple_speech_probes_natively_while_download_engines_stay_unsupported() { + let apple = provider_descriptor(ProviderKind::Asr, "apple-speech").unwrap(); + assert_eq!(apple.validation_probe, ValidationProbe::AsrNativeSilence); + assert_eq!(apple.auth_requirement, AuthRequirement::None); + for provider in ["local-whisper", "local-qwen3", "sherpa-onnx-local"] { + assert_eq!( + provider_descriptor(ProviderKind::Asr, provider) + .unwrap() + .validation_probe, + ValidationProbe::Unsupported + ); + } + } + #[test] fn custom_llm_auth_depends_on_the_effective_endpoint() { assert!(!api_key_required( diff --git a/openless-all/app/crates/openless-core/src/provider_service.rs b/openless-all/app/crates/openless-core/src/provider_service.rs index d4a89c3f..102f657d 100644 --- a/openless-all/app/crates/openless-core/src/provider_service.rs +++ b/openless-all/app/crates/openless-core/src/provider_service.rs @@ -47,6 +47,9 @@ pub struct ProviderService { credentials: Arc, task_spawner: Arc, transport: Arc, + /// Host-owned native engines (e.g. Apple Speech). Only consulted by the + /// [`ValidationProbe::AsrNativeSilence`] probe; cloud probes ignore it. + native_transcription: Option>, } impl ProviderService { @@ -72,9 +75,22 @@ impl ProviderService { credentials, task_spawner, transport, + native_transcription: None, } } + /// Inject the host's native transcription engine so local providers whose + /// descriptor probes [`ValidationProbe::AsrNativeSilence`] (Apple Speech) + /// validate through the real engine — exercising authorization and + /// recognizer availability — instead of reporting unavailable. + pub fn with_native_transcription( + mut self, + native_transcription: Arc, + ) -> Self { + self.native_transcription = Some(native_transcription); + self + } + async fn resolve(&self, request: ProviderRequest) -> Result { let (namespace, slot, channel_kind) = match request.kind { ProviderKind::Asr => ( @@ -227,10 +243,24 @@ impl ProviderService { let session_id = SessionId::new(); match resolved.kind { ProviderKind::Asr => { - let engine = SharedCloudTranscriptionEngine::with_task_spawner( - Arc::clone(&self.credentials), - Arc::clone(&self.task_spawner), - ); + // Native probes run through the host-registered engine so the + // check exercises the same engine dictation uses (Apple Speech + // authorization + recognizer availability); silence probes for + // cloud providers keep using the shared cloud engine. + let engine: Arc = match probe { + ValidationProbe::AsrNativeSilence => { + self.native_transcription.clone().ok_or_else(|| { + BackendError::new( + BackendErrorCode::Unsupported, + "host native transcription engine is not configured", + ) + })? + } + _ => Arc::new(SharedCloudTranscriptionEngine::with_task_spawner( + Arc::clone(&self.credentials), + Arc::clone(&self.task_spawner), + )), + }; let session = tokio::select! { _ = wait_for_cancellation(cancellation.clone()) => return Err(cancelled_request()), result = engine.start(session_id, context, Arc::new(DiscardTextStream)) => { @@ -1037,6 +1067,70 @@ mod tests { assert!(!request.contains("authorization:")); } + /// Minimal host-engine fixture: accepts any PCM and finishes successfully, + /// mirroring what the Apple Speech engine returns for a silence probe. + struct FixtureNativeEngine; + + struct FixtureNativeSession; + + impl crate::ports::AudioConsumer for FixtureNativeSession { + fn consume_pcm_chunk(&self, _pcm: &[u8]) {} + } + + impl crate::ports::TranscriptionSession for FixtureNativeSession { + fn finish( + &self, + ) -> BoxFuture<'static, Result> { + Box::pin(async { + Ok(crate::ports::TranscriptOutput { + text: String::new(), + duration_ms: 500, + }) + }) + } + + fn cancel(&self) -> BoxFuture<'static, Result<(), BackendError>> { + Box::pin(async { Ok(()) }) + } + } + + impl TranscriptionEngine for FixtureNativeEngine { + fn start( + &self, + _session_id: SessionId, + context: Arc, + _partials: Arc, + ) -> BoxFuture<'static, Result, BackendError>> + { + assert_eq!(context.asr.provider_type, "apple-speech"); + Box::pin(async { + Ok(Arc::new(FixtureNativeSession) as Arc) + }) + } + } + + #[tokio::test] + async fn apple_speech_validates_through_the_injected_native_engine() { + let credentials = Arc::new(InMemoryCredentialStore::default()); + let channel = + create_channel_with_values(&credentials, ChannelKind::Asr, "apple-speech", &[]).await; + let request = ProviderRequest { + thinking_enabled: false, + kind: ProviderKind::Asr, + channel_id: Some(channel), + }; + + // Without the host engine the native probe stays explicitly unsupported. + let without = ProviderService::new(credentials.clone(), Arc::new(crate::TokioTaskSpawner)); + let error = without.validate(request.clone()).await.unwrap_err(); + assert_eq!(error.code, BackendErrorCode::Unsupported); + + // With the engine injected the probe runs against the real engine port. + let with = ProviderService::new(credentials, Arc::new(crate::TokioTaskSpawner)) + .with_native_transcription(Arc::new(FixtureNativeEngine)); + with.validate(request).await.unwrap(); + } + #[tokio::test] async fn custom_llm_without_key_reaches_its_explicit_endpoint() { let (endpoint, request) = spawn_http_response( diff --git a/openless-all/app/src-tauri/src/core_adapters.rs b/openless-all/app/src-tauri/src/core_adapters.rs index 2ef82fa7..f5e738a9 100644 --- a/openless-all/app/src-tauri/src/core_adapters.rs +++ b/openless-all/app/src-tauri/src/core_adapters.rs @@ -184,6 +184,9 @@ pub(crate) fn backend_dependencies( } let polisher: Arc = polisher; let auxiliary_transcription: Arc = transcription.clone(); + // Provider validation for local engines (Apple Speech) probes through the + // same router dictation uses, so the check exercises the real engine. + let provider_native_transcription: Arc = transcription.clone(); let auxiliary_polisher: Arc = Arc::new(openless_core::SharedAuxiliaryTextPolisher::new( Arc::clone(&credential_store), @@ -227,10 +230,13 @@ pub(crate) fn backend_dependencies( dependencies .services .configure_auxiliary_runtime(auxiliary_polisher, auxiliary_transcription); - dependencies.services.provider = Arc::new(openless_core::ProviderService::new( - Arc::clone(&credential_store), - Arc::clone(&task_spawner), - )); + dependencies.services.provider = Arc::new( + openless_core::ProviderService::new( + Arc::clone(&credential_store), + Arc::clone(&task_spawner), + ) + .with_native_transcription(provider_native_transcription), + ); dependencies .services .configure_coding_agent_process(Arc::new( diff --git a/openless-all/app/src/lib/ipc/mock-provider-descriptors.json b/openless-all/app/src/lib/ipc/mock-provider-descriptors.json index 14c4d49b..4b5a47f9 100644 --- a/openless-all/app/src/lib/ipc/mock-provider-descriptors.json +++ b/openless-all/app/src/lib/ipc/mock-provider-descriptors.json @@ -541,7 +541,7 @@ "defaultEndpoint": null, "defaultModel": null, "authRequirement": "none", - "validationProbe": "unsupported", + "validationProbe": "asr_native_silence", "staticModels": [], "defaultRequestFormat": null, "supportedRequestFormats": []