Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion openless-all/app/crates/openless-core/src/provider_rules.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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(
Expand Down
102 changes: 98 additions & 4 deletions openless-all/app/crates/openless-core/src/provider_service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 => (
Expand Down Expand Up @@ -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)) => {
Expand Down Expand Up @@ -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(
Expand Down
14 changes: 10 additions & 4 deletions openless-all/app/src-tauri/src/core_adapters.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -541,7 +541,7 @@
"defaultEndpoint": null,
"defaultModel": null,
"authRequirement": "none",
"validationProbe": "unsupported",
"validationProbe": "asr_native_silence",
"staticModels": [],
"defaultRequestFormat": null,
"supportedRequestFormats": []
Expand Down
21 changes: 19 additions & 2 deletions openless-all/app/src/pages/settings/navigation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ assert.deepEqual(
);
console.log('settings navigation tests passed');

const omniViews = availableServiceViews(true, true);
const omniViews = availableServiceViews(true, true, true);
assert.deepEqual(
omniViews,
['omni', 'models', 'connections'],
Expand All @@ -101,12 +101,28 @@ assert.equal(
'models',
'pipeline changes do not redirect a user managing local models',
);
const phoneViews = availableServiceViews(false, false);
const enabledTraditionalViews = availableServiceViews(true, false, true);
assert.deepEqual(
enabledTraditionalViews,
['omni', 'llm', 'asr', 'models', 'connections'],
'enabling the experiment must surface the Omni view while traditional pages remain',
);
assert.equal(
resolveServiceView('omni', enabledTraditionalViews),
'omni',
'the Omni view hosts the pipeline mode switcher',
);
const phoneViews = availableServiceViews(false, false, false);
assert.equal(
phoneViews.includes('models'),
false,
'unsupported local model management is not exposed',
);
assert.equal(
phoneViews.includes('omni'),
false,
'a disabled multimodal pipeline hides the Omni view',
);
assert.equal(
resolveServiceView('models', phoneViews),
'llm',
Expand All @@ -117,3 +133,4 @@ assert.equal(
'llm',
'leaving Omni returns to a traditional service',
);
console.log('settings service view tests passed');
16 changes: 13 additions & 3 deletions openless-all/app/src/pages/settings/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,20 @@ export function searchSettingsSections<T extends SearchableSettingsSection>(

export type ServiceViewId = 'llm' | 'asr' | 'omni' | 'models' | 'connections';

export function availableServiceViews(multimodal: boolean, localModels: boolean): ServiceViewId[] {
/**
* 多模态总开关(multimodalPipelineEnabled)打开即展示 omni 视图——
* 管线模式(传统 / 多模态)的切换器就在该视图里,否则开关打开后没有任何入口
* 进入多模态配置。只有真正切到多模态模式后才隐藏传统 llm/asr 页。
*/
export function availableServiceViews(
multimodalPipelineEnabled: boolean,
multimodalMode: boolean,
localModels: boolean,
): ServiceViewId[] {
return [
...(multimodal ? ['omni' as const] : ['llm' as const, 'asr' as const]),
...(localModels ? ['models' as const] : []),
...(multimodalPipelineEnabled ? (['omni'] as const) : []),
...(!multimodalMode ? (['llm', 'asr'] as const) : []),
...(localModels ? (['models'] as const) : []),
'connections',
];
}
Expand Down
6 changes: 3 additions & 3 deletions openless-all/app/src/pages/settings/tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,10 +87,10 @@ export function ServicesTab() {
const { prefs } = useHotkeySettings();
const platformCaps = usePlatformCaps();
const showLocalModel = platformCaps?.supportsLocalAsr === true;
const multimodal =
prefs?.multimodalPipelineEnabled === true && prefs.pipelineMode === 'multimodal';
const multimodalEnabled = prefs?.multimodalPipelineEnabled === true;
const multimodal = multimodalEnabled && prefs.pipelineMode === 'multimodal';
const [view, setView] = useState<ServiceViewId>('llm');
const views = availableServiceViews(multimodal, showLocalModel);
const views = availableServiceViews(multimodalEnabled, multimodal, showLocalModel);
const selectedView = resolveServiceView(view, views);
const contentRef = useRef<HTMLDivElement>(null);

Expand Down
Loading