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:
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": []
|
||||
|
||||
Reference in new issue
Block a user