Skip to content
Closed
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
17 changes: 11 additions & 6 deletions openless-all/app/crates/openless-core/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10994,11 +10994,16 @@ mod tests {
Some("polished source".to_string()),
1250,
);
// FixedClock stamps history/activity; retain_with_policy still uses wall-clock
// Utc::now(). Keep the stamp within the last day so a 30-day retention window
// never prunes the just-written entry as the calendar advances.
use chrono::Timelike;
let stamp = (chrono::Utc::now() - chrono::Duration::days(1))
.with_nanosecond(0)
.expect("truncate sub-second");
let fixed_clock = Arc::new(crate::testing::FixedClock::new(
chrono::DateTime::parse_from_rfc3339("2026-08-28T12:34:56Z")
.unwrap()
.with_timezone(&chrono::Utc),
chrono::NaiveDate::from_ymd_opt(2026, 8, 28).unwrap(),
stamp,
stamp.date_naive(),
));
let backend = OpenLessBackend::new_with_clock(
BackendConfig {
Expand Down Expand Up @@ -11048,7 +11053,7 @@ mod tests {
assert_eq!(history.len(), 1);
let entry = &history[0];
assert_eq!(entry.id, session_id.to_string());
assert_eq!(entry.created_at, "2026-08-28T12:34:56+00:00");
assert_eq!(entry.created_at, stamp.to_rfc3339());
assert_eq!(entry.raw_transcript, "raw voice");
assert_eq!(entry.final_text, "translated output");
assert_eq!(entry.polish_source.as_deref(), Some("polished source"));
Expand All @@ -11060,7 +11065,7 @@ mod tests {
);
let activity = backend.list_activity().unwrap();
assert_eq!(activity.len(), 1);
assert_eq!(activity[0].date, "2026-08-28");
assert_eq!(activity[0].date, stamp.date_naive().to_string());
assert_eq!(
activity[0].chars,
"translated output".chars().count() as u64
Expand Down
3 changes: 2 additions & 1 deletion openless-all/app/crates/openless-core/src/cloud_providers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1687,7 +1687,8 @@ async fn build_omni_provider(
/// Validate an Omni provider using the same construction and request path as
/// the production dictation pipeline. The probe is intentionally text-only:
/// it exercises credential, endpoint, model and protocol resolution without
/// retaining user audio.
/// retaining user audio. UI must not treat text success as proof that audio
/// dictation works (issue #1118).
pub async fn validate_shared_omni_provider(
credentials: Arc<dyn CredentialStore>,
context: Arc<DictationContext>,
Expand Down
32 changes: 24 additions & 8 deletions openless-all/app/crates/openless-core/src/omni.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,10 @@ impl OmniConfig {
}

/// 百炼/DashScope 兼容端点把 `input_audio.data` 按 URL/data-URL 解析,裸 Base64
/// 会被 400 拒绝("The provided URL does not appear to be valid")。与
/// `asr::dashscope_multimodal` 转写通道同款,Base64 须带 data-URL 前缀;
/// 沿用 polish 的主机名关键词,但只检查 URL 解析后的真实 host。
/// 会被 400 拒绝("The provided URL does not appear to be valid")。官方
/// Qwen-Omni「输入 Base64 本地文件 · 音频」示例要求 `data:;base64,…`(无 MIME)
/// 并配合独立 `format`(issue #1118);沿用 polish 的主机名关键词,但只检查
/// URL 解析后的真实 host。
fn audio_requires_data_url(&self) -> bool {
reqwest::Url::parse(self.base_url.trim())
.ok()
Expand Down Expand Up @@ -96,6 +97,10 @@ impl OpenAICompatibleOmni {
body["temperature"] = json!(temperature);
}
}
// 百炼 Omni 官方示例普遍带 modalities=["text"](只要文本输出);其他兼容端点不注入。
if self.config.audio_requires_data_url() {
body["modalities"] = json!(["text"]);
}
apply_openai_compatible_thinking_control(
&mut body,
&self.config.provider_id,
Expand All @@ -115,9 +120,10 @@ impl OpenAICompatibleOmni {
let user_content = match wav_bytes {
Some(wav) => {
let encoded = base64::engine::general_purpose::STANDARD.encode(wav);
// 百炼系端点要求 data-URL 前缀;OpenAI 官方等其他兼容端点保持裸 Base64。
// 百炼 Omni 官方 Base64 音频:`data:;base64,`(无 MIME)+ format 字段;
// OpenAI 官方等其他兼容端点保持裸 Base64。
let data = if self.config.audio_requires_data_url() {
format!("data:audio/wav;base64,{encoded}")
format!("data:;base64,{encoded}")
} else {
encoded
};
Expand Down Expand Up @@ -486,8 +492,8 @@ mod tests {
.as_str()
.expect("audio data");
let payload = data
.strip_prefix("data:audio/wav;base64,")
.expect("data-url prefix for DashScope");
.strip_prefix("data:;base64,")
.expect("official DashScope Omni data-url prefix");
let decoded = base64::engine::general_purpose::STANDARD
.decode(payload)
.expect("valid base64");
Expand All @@ -505,7 +511,7 @@ mod tests {
assert!(parts[0]["input_audio"]["data"]
.as_str()
.expect("audio data")
.starts_with("data:audio/wav;base64,"));
.starts_with("data:;base64,"));
}

#[test]
Expand Down Expand Up @@ -551,6 +557,16 @@ mod tests {
assert_eq!(body["model"], "gpt-4o-audio-preview");
// temperature 以 f32 存(0.3f32 序列化后是 0.30000001192092896),用容差比较。
assert!((body["temperature"].as_f64().unwrap() - 0.3).abs() < 1e-6);
assert!(body.get("modalities").is_none());
}

#[test]
fn omni_body_sets_text_modalities_for_dashscope() {
let mut dashscope = config();
dashscope.base_url = "https://dashscope.aliyuncs.com/compatible-mode/v1".into();
let provider = OpenAICompatibleOmni::new(dashscope);
let body = provider.omni_body(true, vec![json!({"role": "user", "content": "x"})]);
assert_eq!(body["modalities"], json!(["text"]));
}

#[test]
Expand Down
4 changes: 4 additions & 0 deletions openless-all/app/src/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1560,6 +1560,8 @@ export const de: typeof zhCN = {
validationTitle: 'Verbindungsprüfung',
validationHint:
'Sendet manuell eine echte Anfrage, um diese Konfiguration zu prüfen. Dabei kann Dienstguthaben verbraucht werden. Das Speichern der Einstellungen führt keine Prüfung aus.',
validationHintOmni:
'Sendet manuell eine reine Text-Verbindungsanfrage (ohne Aufnahme). Erfolg bedeutet nicht, dass Audio-Diktat funktioniert — bitte mit einer kurzen Aufnahme bestätigen. Dabei kann Dienstguthaben verbraucht werden.',
autoSaveHint:
'Änderungen werden automatisch gespeichert. Anschließend kannst du die Verbindung manuell prüfen.',
nameHint:
Expand Down Expand Up @@ -1821,6 +1823,8 @@ export const de: typeof zhCN = {
selectModel: 'Wähle ein Modell, um das Feld oben auszufüllen',
modelSaved: 'Modell {{model}} gespeichert.',
validateSuccess: 'Verbindungsprüfung bestanden.',
validateSuccessOmni:
'Text-Verbindung bestanden. Das bedeutet nicht, dass Audio-Diktat funktioniert — bitte mit einer kurzen Aufnahme bestätigen.',
validateFailed: 'Verbindungsprüfung fehlgeschlagen.',
providerHttpStatus:
'Der Anbieter hat HTTP {{status}} zurückgegeben. Prüfe die Berechtigungen des API-Schlüssels oder den Endpunkt.',
Expand Down
4 changes: 4 additions & 0 deletions openless-all/app/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1527,6 +1527,8 @@ export const en: typeof zhCN = {
validationTitle: 'Connection check',
validationHint:
'Manually send a real request to check this configuration. It may use service credits. Saving settings does not run a check.',
validationHintOmni:
'Manually send a text-only connectivity request (no recording). Passing does not mean audio dictation works—please confirm with a short recording. It may use service credits.',
autoSaveHint:
'Changes save automatically. Once configured, you can check the connection manually.',
nameHint:
Expand Down Expand Up @@ -1782,6 +1784,8 @@ export const en: typeof zhCN = {
selectModel: 'Select a model to fill the field above',
modelSaved: 'Saved model {{model}}.',
validateSuccess: 'Connection check passed.',
validateSuccessOmni:
'Text connectivity passed. This does not mean audio dictation works—please confirm with a short recording.',
validateFailed: 'Connection check failed.',
providerHttpStatus:
'Provider returned HTTP {{status}}. Check the API key permissions or endpoint.',
Expand Down
4 changes: 4 additions & 0 deletions openless-all/app/src/i18n/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1552,6 +1552,8 @@ export const es: typeof zhCN = {
validationTitle: 'Comprobación de conexión',
validationHint:
'Envía manualmente una solicitud real para comprobar la configuración. Puede consumir saldo del servicio. Guardar los ajustes no ejecuta esta comprobación.',
validationHintOmni:
'Envía manualmente una solicitud solo de texto (sin grabación). Pasar no significa que el dictado de audio funcione: confirma con una grabación corta. Puede consumir saldo del servicio.',
autoSaveHint:
'Los cambios se guardan automáticamente. Después de configurar el servicio, puedes comprobar la conexión.',
nameHint:
Expand Down Expand Up @@ -1810,6 +1812,8 @@ export const es: typeof zhCN = {
selectModel: 'Elige un modelo para completar el campo de arriba',
modelSaved: 'Modelo {{model}} guardado.',
validateSuccess: 'Conexión comprobada correctamente.',
validateSuccessOmni:
'Conectividad de texto correcta. Esto no significa que el dictado de audio funcione: confirma con una grabación corta.',
validateFailed: 'La comprobación de conexión falló.',
providerHttpStatus:
'El proveedor devolvió HTTP {{status}}. Comprueba los permisos de la clave API o la dirección.',
Expand Down
4 changes: 4 additions & 0 deletions openless-all/app/src/i18n/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1575,6 +1575,8 @@ export const fr: typeof zhCN = {
validationTitle: 'Vérification de la connexion',
validationHint:
'Envoyez manuellement une requête réelle pour vérifier cette configuration. Elle peut consommer des crédits du service. L’enregistrement des réglages ne lance pas de vérification.',
validationHintOmni:
'Envoyez manuellement une requête de connectivité texte uniquement (sans enregistrement). Réussir ne signifie pas que la dictée audio fonctionne — confirmez avec un court enregistrement. Elle peut consommer des crédits du service.',
autoSaveHint:
'Les modifications sont enregistrées automatiquement. Une fois le service configuré, vous pouvez vérifier la connexion.',
nameHint:
Expand Down Expand Up @@ -1839,6 +1841,8 @@ export const fr: typeof zhCN = {
selectModel: 'Sélectionnez un modèle pour remplir le champ ci-dessus',
modelSaved: 'Modèle {{model}} enregistré.',
validateSuccess: 'Connexion vérifiée avec succès.',
validateSuccessOmni:
'Connectivité texte OK. Cela ne signifie pas que la dictée audio fonctionne — confirmez avec un court enregistrement.',
validateFailed: 'Échec de la vérification de connexion.',
providerHttpStatus:
'Le fournisseur a renvoyé HTTP {{status}}. Vérifiez les autorisations de la clé API ou l’adresse.',
Expand Down
4 changes: 4 additions & 0 deletions openless-all/app/src/i18n/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1517,6 +1517,8 @@ export const ja: typeof zhCN = {
validationTitle: '接続の確認',
validationHint:
'実際にリクエストを送信して設定を確認します。サービスの利用枠を消費する場合があります。設定の保存だけでは確認を実行しません。',
validationHintOmni:
'テキストのみの疎通確認を手動送信します(録音なし)。成功しても音声ディクテーションが使えるとは限りません。短い録音で再確認してください。サービスの利用枠を消費する場合があります。',
autoSaveHint: '変更は自動保存されます。設定が終わったら、手動で接続を確認できます。',
nameHint:
'同じプロバイダーのチャンネルを区別するための名前です。モデルや接続には影響しません。',
Expand Down Expand Up @@ -1769,6 +1771,8 @@ export const ja: typeof zhCN = {
selectModel: 'モデルを選んで上記欄に入力',
modelSaved: 'モデル {{model}} を保存しました。',
validateSuccess: '接続チェックに合格しました。',
validateSuccessOmni:
'テキスト疎通は成功しました。これは音声ディクテーションが使えることを意味しません。短い録音で確認してください。',
validateFailed: '接続チェックに失敗しました。',
providerHttpStatus:
'サプライヤーが {{status}} を返しました。API Key 権限またはエンドポイントを確認してください。',
Expand Down
4 changes: 4 additions & 0 deletions openless-all/app/src/i18n/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1505,6 +1505,8 @@ export const ko: typeof zhCN = {
validationTitle: '연결 확인',
validationHint:
'실제 요청을 보내 현재 설정을 확인합니다. 서비스 사용량이 차감될 수 있습니다. 설정을 저장해도 자동으로 확인하지 않습니다.',
validationHintOmni:
'텍스트 연결만 수동으로 확인합니다(녹음 없음). 통과해도 오디오 받아쓰기가 된다는 뜻은 아닙니다. 짧은 녹음으로 다시 확인하세요. 서비스 사용량이 차감될 수 있습니다.',
autoSaveHint:
'변경 사항은 자동으로 저장됩니다. 설정을 마친 후 연결을 직접 확인할 수 있습니다.',
nameHint:
Expand Down Expand Up @@ -1756,6 +1758,8 @@ export const ko: typeof zhCN = {
selectModel: '모델을 선택해 위 필드에 입력',
modelSaved: '모델 {{model}} 을(를) 저장했습니다.',
validateSuccess: '연결 확인을 통과했습니다.',
validateSuccessOmni:
'텍스트 연결은 통과했습니다. 오디오 받아쓰기가 된다는 뜻은 아닙니다. 짧은 녹음으로 확인하세요.',
validateFailed: '연결 확인에 실패했습니다.',
providerHttpStatus:
'공급자가 {{status}} 를 반환했습니다. API Key 권한 또는 Endpoint 를 확인해 주세요.',
Expand Down
3 changes: 3 additions & 0 deletions openless-all/app/src/i18n/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1455,6 +1455,8 @@ export const zhCN = {
validationTitle: '连接验证',
validationHint:
'手动发起一次真实请求,检查当前配置;可能消耗服务额度。保存设置不会自动验证。',
validationHintOmni:
'手动发起一次文本连通性请求(不含录音)。通过不代表音频听写可用,请再做一次短录音确认。可能消耗服务额度。',
autoSaveHint: '字段修改后自动保存;完成配置后,可手动验证连接。',
nameHint: '名称仅用于区分同一供应商的多个渠道,不影响模型或连接。',
errModel: '模型',
Expand Down Expand Up @@ -1690,6 +1692,7 @@ export const zhCN = {
selectModel: '选择一个模型写入上方字段',
modelSaved: '已保存模型 {{model}}。',
validateSuccess: '连接检查通过。',
validateSuccessOmni: '文本连通性通过。这不代表音频听写可用,请再做一次短录音确认。',
validateFailed: '连接检查未通过。',
providerHttpStatus: '供应商接口返回 {{status}},请检查 API Key 权限或 Endpoint。',
endpointMustUseHttps:
Expand Down
3 changes: 3 additions & 0 deletions openless-all/app/src/i18n/zh-TW.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1456,6 +1456,8 @@ export const zhTW: typeof zhCN = {
validationTitle: '連線驗證',
validationHint:
'手動發出一次實際請求,檢查目前設定;可能消耗服務額度。儲存設定不會自動驗證。',
validationHintOmni:
'手動發出一次文字連通性請求(不含錄音)。通過不代表音訊聽寫可用,請再做一次短錄音確認。可能消耗服務額度。',
autoSaveHint: '修改欄位後會自動儲存;完成設定後,可手動驗證連線。',
nameHint: '名稱僅用於區分同一供應商的多個渠道,不影響模型或連線。',
errModel: '模型',
Expand Down Expand Up @@ -1691,6 +1693,7 @@ export const zhTW: typeof zhCN = {
selectModel: '選擇一個模型寫入上方欄位',
modelSaved: '已儲存模型 {{model}}。',
validateSuccess: '連線檢查透過。',
validateSuccessOmni: '文字連通性通過。這不代表音訊聽寫可用,請再做一次短錄音確認。',
validateFailed: '連線檢查未透過。',
providerHttpStatus: '供應商介面返回 {{status}},請檢查 API Key 權限或 Endpoint。',
endpointMustUseHttps:
Expand Down
14 changes: 12 additions & 2 deletions openless-all/app/src/pages/settings/ProvidersSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1336,7 +1336,13 @@ function ProviderTools({
const latency = Math.round(performance.now() - started);
setResult(
result.ok ? 'success' : 'error',
t(result.ok ? 'settings.providers.validateSuccess' : 'settings.providers.validateFailed'),
t(
result.ok
? kind === 'omni'
? 'settings.providers.validateSuccessOmni'
: 'settings.providers.validateSuccess'
: 'settings.providers.validateFailed',
),
);
await persistTest(result.ok, result.ok ? latency : null, result.ok ? null : 'validateFailed');
} catch (error) {
Expand Down Expand Up @@ -1469,7 +1475,11 @@ function ProviderTools({
<ChannelSectionHeading
icon="bolt"
title={t('settings.channels.validationTitle')}
description={t('settings.channels.validationHint')}
description={t(
kind === 'omni'
? 'settings.channels.validationHintOmni'
: 'settings.channels.validationHint',
)}
/>
<button
className="ol-channel-verify"
Expand Down
Loading