fix(providers): Apple 语音渠道验证走原生引擎真实探针

本地 ASR 提供方此前一律 ValidationProbe::Unsupported,渠道卡「验证」必然
失败(provider validation is not available),而听写实际可用——用户看到
「验证失败 · …」误报。新增 AsrNativeSilence 探针:Apple 语音无需下载、
即时可用,验证经宿主注入的原生转写引擎跑同一段静音 WAV,真实检验授权
与识别器可用性;下载型本地引擎(whisper/qwen/sherpa)保持 Unsupported,
就绪状态仍由本地模型页报告。

- ProviderService 新增 with_native_transcription 注入点,Tauri 宿主传入
  转写路由(与听写同源);未注入宿主维持 Unsupported 语义
- 描述符 fixture 同步 apple-speech 探针值;新增描述符与验证往返测试
This commit is contained in:
sim committed 2026-09-11 12:48:33 +08:00
1 parent 2c760b490f
commit b25678ca39
4 files changed
+136 -10

No files matched your search

@@ -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(
@@ -47,6 +47,9 @@ pub struct ProviderService {
credentials: Arc<dyn CredentialStore>,
task_spawner: Arc<dyn TaskSpawner>,
transport: Arc<dyn ProviderTransport>,
/// Host-owned native engines (e.g. Apple Speech). Only consulted by the
/// [`ValidationProbe::AsrNativeSilence`] probe; cloud probes ignore it.
native_transcription: Option<Arc<dyn TranscriptionEngine>>,
}
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<dyn TranscriptionEngine>,
) -> Self {
self.native_transcription = Some(native_transcription);
self
}
async fn resolve(&self, request: ProviderRequest) -> Result<ResolvedProvider, BackendError> {
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<dyn TranscriptionEngine> = 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<crate::ports::TranscriptOutput, BackendError>> {
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<DictationContext>,
_partials: Arc<dyn TextStreamSink>,
) -> BoxFuture<'static, Result<Arc<dyn crate::ports::TranscriptionSession>, BackendError>>
{
assert_eq!(context.asr.provider_type, "apple-speech");
Box::pin(async {
Ok(Arc::new(FixtureNativeSession) as Arc<dyn crate::ports::TranscriptionSession>)
})
}
}
#[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(
@@ -184,6 +184,9 @@ pub(crate) fn backend_dependencies(
}
let polisher: Arc<dyn TextPolisher> = polisher;
let auxiliary_transcription: Arc<dyn TranscriptionEngine> = 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<dyn TranscriptionEngine> = transcription.clone();
let auxiliary_polisher: Arc<dyn TextPolisher> =
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(
@@ -541,7 +541,7 @@
"defaultEndpoint": null,
"defaultModel": null,
"authRequirement": "none",
"validationProbe": "unsupported",
"validationProbe": "asr_native_silence",
"staticModels": [],
"defaultRequestFormat": null,
"supportedRequestFormats": []