From f70556b70678fa43d1340a0926f82321193cbb1e Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Mon, 28 Sep 2026 01:06:01 +0800 Subject: [PATCH 1/3] fix(omni): align DashScope audio data-URL with official Base64 format (#1118) Use data:;base64 for Bailian hosts, set modalities=[text], and clarify Omni channel validation is text-only so a green check does not imply audio works. --- .../openless-core/src/cloud_providers.rs | 3 +- .../app/crates/openless-core/src/omni.rs | 32 ++++++++++++++----- openless-all/app/src/i18n/de.ts | 4 +++ openless-all/app/src/i18n/en.ts | 4 +++ openless-all/app/src/i18n/es.ts | 4 +++ openless-all/app/src/i18n/fr.ts | 4 +++ openless-all/app/src/i18n/ja.ts | 4 +++ openless-all/app/src/i18n/ko.ts | 4 +++ openless-all/app/src/i18n/zh-CN.ts | 3 ++ openless-all/app/src/i18n/zh-TW.ts | 3 ++ .../src/pages/settings/ProvidersSection.tsx | 14 ++++++-- 11 files changed, 68 insertions(+), 11 deletions(-) diff --git a/openless-all/app/crates/openless-core/src/cloud_providers.rs b/openless-all/app/crates/openless-core/src/cloud_providers.rs index 5a52a88ad..033ae22a1 100644 --- a/openless-all/app/crates/openless-core/src/cloud_providers.rs +++ b/openless-all/app/crates/openless-core/src/cloud_providers.rs @@ -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, context: Arc, diff --git a/openless-all/app/crates/openless-core/src/omni.rs b/openless-all/app/crates/openless-core/src/omni.rs index 171daff84..b7dd5fe3f 100644 --- a/openless-all/app/crates/openless-core/src/omni.rs +++ b/openless-all/app/crates/openless-core/src/omni.rs @@ -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() @@ -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, @@ -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 }; @@ -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"); @@ -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] @@ -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] diff --git a/openless-all/app/src/i18n/de.ts b/openless-all/app/src/i18n/de.ts index 4f591c423..7d5478ceb 100644 --- a/openless-all/app/src/i18n/de.ts +++ b/openless-all/app/src/i18n/de.ts @@ -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: @@ -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.', diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 08c604b67..0b9ee5705 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -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: @@ -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.', diff --git a/openless-all/app/src/i18n/es.ts b/openless-all/app/src/i18n/es.ts index 5999b71bf..4a54b27e8 100644 --- a/openless-all/app/src/i18n/es.ts +++ b/openless-all/app/src/i18n/es.ts @@ -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: @@ -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.', diff --git a/openless-all/app/src/i18n/fr.ts b/openless-all/app/src/i18n/fr.ts index 0a0a571b8..74b3e2116 100644 --- a/openless-all/app/src/i18n/fr.ts +++ b/openless-all/app/src/i18n/fr.ts @@ -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: @@ -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.', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index a5fb44762..4ba0039dd 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -1517,6 +1517,8 @@ export const ja: typeof zhCN = { validationTitle: '接続の確認', validationHint: '実際にリクエストを送信して設定を確認します。サービスの利用枠を消費する場合があります。設定の保存だけでは確認を実行しません。', + validationHintOmni: + 'テキストのみの疎通確認を手動送信します(録音なし)。成功しても音声ディクテーションが使えるとは限りません。短い録音で再確認してください。サービスの利用枠を消費する場合があります。', autoSaveHint: '変更は自動保存されます。設定が終わったら、手動で接続を確認できます。', nameHint: '同じプロバイダーのチャンネルを区別するための名前です。モデルや接続には影響しません。', @@ -1769,6 +1771,8 @@ export const ja: typeof zhCN = { selectModel: 'モデルを選んで上記欄に入力', modelSaved: 'モデル {{model}} を保存しました。', validateSuccess: '接続チェックに合格しました。', + validateSuccessOmni: + 'テキスト疎通は成功しました。これは音声ディクテーションが使えることを意味しません。短い録音で確認してください。', validateFailed: '接続チェックに失敗しました。', providerHttpStatus: 'サプライヤーが {{status}} を返しました。API Key 権限またはエンドポイントを確認してください。', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 12f885799..43bde39f5 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -1505,6 +1505,8 @@ export const ko: typeof zhCN = { validationTitle: '연결 확인', validationHint: '실제 요청을 보내 현재 설정을 확인합니다. 서비스 사용량이 차감될 수 있습니다. 설정을 저장해도 자동으로 확인하지 않습니다.', + validationHintOmni: + '텍스트 연결만 수동으로 확인합니다(녹음 없음). 통과해도 오디오 받아쓰기가 된다는 뜻은 아닙니다. 짧은 녹음으로 다시 확인하세요. 서비스 사용량이 차감될 수 있습니다.', autoSaveHint: '변경 사항은 자동으로 저장됩니다. 설정을 마친 후 연결을 직접 확인할 수 있습니다.', nameHint: @@ -1756,6 +1758,8 @@ export const ko: typeof zhCN = { selectModel: '모델을 선택해 위 필드에 입력', modelSaved: '모델 {{model}} 을(를) 저장했습니다.', validateSuccess: '연결 확인을 통과했습니다.', + validateSuccessOmni: + '텍스트 연결은 통과했습니다. 오디오 받아쓰기가 된다는 뜻은 아닙니다. 짧은 녹음으로 확인하세요.', validateFailed: '연결 확인에 실패했습니다.', providerHttpStatus: '공급자가 {{status}} 를 반환했습니다. API Key 권한 또는 Endpoint 를 확인해 주세요.', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index af66036d0..c422e26c6 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -1455,6 +1455,8 @@ export const zhCN = { validationTitle: '连接验证', validationHint: '手动发起一次真实请求,检查当前配置;可能消耗服务额度。保存设置不会自动验证。', + validationHintOmni: + '手动发起一次文本连通性请求(不含录音)。通过不代表音频听写可用,请再做一次短录音确认。可能消耗服务额度。', autoSaveHint: '字段修改后自动保存;完成配置后,可手动验证连接。', nameHint: '名称仅用于区分同一供应商的多个渠道,不影响模型或连接。', errModel: '模型', @@ -1690,6 +1692,7 @@ export const zhCN = { selectModel: '选择一个模型写入上方字段', modelSaved: '已保存模型 {{model}}。', validateSuccess: '连接检查通过。', + validateSuccessOmni: '文本连通性通过。这不代表音频听写可用,请再做一次短录音确认。', validateFailed: '连接检查未通过。', providerHttpStatus: '供应商接口返回 {{status}},请检查 API Key 权限或 Endpoint。', endpointMustUseHttps: diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 133222566..fd3be58f0 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -1456,6 +1456,8 @@ export const zhTW: typeof zhCN = { validationTitle: '連線驗證', validationHint: '手動發出一次實際請求,檢查目前設定;可能消耗服務額度。儲存設定不會自動驗證。', + validationHintOmni: + '手動發出一次文字連通性請求(不含錄音)。通過不代表音訊聽寫可用,請再做一次短錄音確認。可能消耗服務額度。', autoSaveHint: '修改欄位後會自動儲存;完成設定後,可手動驗證連線。', nameHint: '名稱僅用於區分同一供應商的多個渠道,不影響模型或連線。', errModel: '模型', @@ -1691,6 +1693,7 @@ export const zhTW: typeof zhCN = { selectModel: '選擇一個模型寫入上方欄位', modelSaved: '已儲存模型 {{model}}。', validateSuccess: '連線檢查透過。', + validateSuccessOmni: '文字連通性通過。這不代表音訊聽寫可用,請再做一次短錄音確認。', validateFailed: '連線檢查未透過。', providerHttpStatus: '供應商介面返回 {{status}},請檢查 API Key 權限或 Endpoint。', endpointMustUseHttps: diff --git a/openless-all/app/src/pages/settings/ProvidersSection.tsx b/openless-all/app/src/pages/settings/ProvidersSection.tsx index 14ac7ad86..a82a3acd1 100644 --- a/openless-all/app/src/pages/settings/ProvidersSection.tsx +++ b/openless-all/app/src/pages/settings/ProvidersSection.tsx @@ -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) { @@ -1469,7 +1475,11 @@ function ProviderTools({