Let users verify provider credentials before dictation

Provider setup needed an explicit way to validate OpenAI-compatible credentials and discover account models without forcing manual model IDs. Add minimal IPC commands for /models checks and a small Settings UI control that preserves manual entry when discovery fails.

Constraint: Do not leak API keys in logs or user-visible provider error bodies.

Rejected: Full provider-specific SDK integrations | OpenAI-compatible /models covers the configured providers with less scope.

Confidence: medium

Scope-risk: moderate

Tested: cargo check --manifest-path openless-all/app/src-tauri/Cargo.toml

Tested: cargo test --manifest-path openless-all/app/src-tauri/Cargo.toml commands::tests -- --nocapture

Tested: npm run build

Tested: git diff --check
This commit is contained in:
H-Chris233 committed 2026-05-01 11:14:16 +08:00
1 parent b7e934a8b9
commit 2bb2930f6c
6 files changed
+298 -2

No files matched your search

+148
View File
@@ -1,7 +1,10 @@
//! Tauri command surface — every IPC entry the React UI invokes lives here.
use std::sync::Arc;
use std::time::Duration;
use serde::Serialize;
use serde_json::Value;
use tauri::{AppHandle, State};
use crate::coordinator::Coordinator;
@@ -81,6 +84,126 @@ pub fn read_credential(account: String) -> Result<Option<String>, String> {
CredentialsVault::get(acc).map_err(|e| e.to_string())
}
#[derive(Serialize)]
pub struct ProviderCheckResult {
ok: bool,
message: String,
}
#[derive(Serialize)]
pub struct ProviderModelsResult {
models: Vec<String>,
}
#[tauri::command]
pub async fn validate_provider_credentials(kind: String) -> Result<ProviderCheckResult, String> {
let config = read_openai_provider_config(&kind)?;
fetch_provider_models(&config)
.await
.map(|models| ProviderCheckResult {
ok: true,
message: if models.is_empty() {
"鉴权成功,但账号未返回可用模型".to_string()
} else {
format!("鉴权成功,可用模型 {} 个", models.len())
},
})
}
#[tauri::command]
pub async fn list_provider_models(kind: String) -> Result<ProviderModelsResult, String> {
let config = read_openai_provider_config(&kind)?;
fetch_provider_models(&config)
.await
.map(|models| ProviderModelsResult { models })
}
struct ProviderConfig {
base_url: String,
api_key: String,
}
fn read_openai_provider_config(kind: &str) -> Result<ProviderConfig, String> {
let (api_key_account, endpoint_account) = match kind {
"llm" => (CredentialAccount::ArkApiKey, CredentialAccount::ArkEndpoint),
"asr" => (CredentialAccount::AsrApiKey, CredentialAccount::AsrEndpoint),
_ => return Err(format!("unknown provider kind: {kind}")),
};
let api_key = CredentialsVault::get(api_key_account)
.map_err(|e| e.to_string())?
.unwrap_or_default();
let base_url = CredentialsVault::get(endpoint_account)
.map_err(|e| e.to_string())?
.unwrap_or_default();
if api_key.trim().is_empty() {
return Err("API Key 为空".to_string());
}
if base_url.trim().is_empty() {
return Err("Endpoint 为空".to_string());
}
Ok(ProviderConfig { base_url, api_key })
}
async fn fetch_provider_models(config: &ProviderConfig) -> Result<Vec<String>, String> {
let url = models_url(&config.base_url);
log::info!("[provider-check] GET {url}");
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(15))
.build()
.map_err(|e| format!("HTTP client 初始化失败: {e}"))?;
let response = client
.get(&url)
.header("Authorization", format!("Bearer {}", config.api_key))
.send()
.await
.map_err(|e| {
if e.is_timeout() {
"请求超时".to_string()
} else {
format!("网络错误: {e}")
}
})?;
let status = response.status();
let body = response
.text()
.await
.map_err(|e| format!("读取响应失败: {e}"))?;
if !status.is_success() {
return Err(format!("providerHttpStatus:{}", status.as_u16()));
}
parse_model_ids(&body)
}
fn models_url(base_url: &str) -> String {
let trimmed = base_url.trim().trim_end_matches('/');
if trimmed.ends_with("/models") {
return trimmed.to_string();
}
if let Some(prefix) = trimmed.strip_suffix("/chat/completions") {
return format!("{prefix}/models");
}
format!("{trimmed}/models")
}
fn parse_model_ids(body: &str) -> Result<Vec<String>, String> {
let json: Value =
serde_json::from_str(body).map_err(|e| format!("模型列表不是有效 JSON: {e}"))?;
let data = json
.get("data")
.and_then(|v| v.as_array())
.ok_or_else(|| "模型列表缺少 data 数组".to_string())?;
let mut models = data
.iter()
.filter_map(|item| item.get("id").and_then(|id| id.as_str()))
.map(str::trim)
.filter(|id| !id.is_empty())
.map(ToOwned::to_owned)
.collect::<Vec<_>>();
models.sort();
models.dedup();
Ok(models)
}
fn parse_account(s: &str) -> Result<CredentialAccount, String> {
match s {
"volcengine.app_key" => Ok(CredentialAccount::VolcengineAppKey),
@@ -325,3 +448,28 @@ pub fn trigger_microphone_prompt(app: AppHandle) -> Result<(), String> {
#[allow(dead_code)]
fn _ensure_snapshot_used(_: CredentialsSnapshot) {}
#[cfg(test)]
mod tests {
use super::{models_url, parse_model_ids};
#[test]
fn models_url_accepts_base_or_chat_endpoint() {
assert_eq!(
models_url("https://api.openai.com/v1"),
"https://api.openai.com/v1/models"
);
assert_eq!(
models_url("https://api.openai.com/v1/chat/completions"),
"https://api.openai.com/v1/models"
);
}
#[test]
fn parse_model_ids_sorts_and_deduplicates() {
let models =
parse_model_ids(r#"{ "data": [{ "id": "b" }, { "id": "a" }, { "id": "b" }] }"#)
.unwrap();
assert_eq!(models, vec!["a".to_string(), "b".to_string()]);
}
}
+2
View File
@@ -175,6 +175,8 @@ pub fn run() {
commands::read_credential,
commands::set_active_asr_provider,
commands::set_active_llm_provider,
commands::validate_provider_credentials,
commands::list_provider_models,
restart_app,
])
.build(tauri::generate_context!())
+14
View File
@@ -246,6 +246,20 @@ export const en: typeof zhCN = {
appIdLabel: 'App ID',
accessKeyLabel: 'Access Key',
resourceIdLabel: 'Resource ID',
toolsLabel: 'Connection check',
toolsDesc: 'Save the fields above, then validate credentials or fetch models. Manual model input remains available if fetching fails.',
validate: 'Validate',
validating: 'Validating…',
fetchModels: 'Fetch models',
loadingModels: 'Fetching models…',
modelsEmpty: 'Credentials are valid, but no models were returned.',
modelsLoaded: 'Fetched {{count}} models.',
selectModel: 'Select a model to fill the field above',
validateSuccess: 'Credentials are valid.',
providerHttpStatus: 'Provider returned HTTP {{status}}. Check the API key permissions or endpoint.',
apiKeyMissing: 'API Key is empty.',
endpointMissing: 'Endpoint is empty.',
requestTimeout: 'Request timed out. Try again later.',
},
shortcuts: {
title: 'Shortcut reference',
+14
View File
@@ -244,6 +244,20 @@ export const zhCN = {
appIdLabel: 'App ID(应用 ID)',
accessKeyLabel: 'Access Key(访问密钥)',
resourceIdLabel: '资源 ID',
toolsLabel: '连接检查',
toolsDesc: '先保存上方配置,再验证鉴权或拉取模型;失败时仍可手动填写模型 ID。',
validate: '验证',
validating: '验证中…',
fetchModels: '拉取模型',
loadingModels: '拉取模型中…',
modelsEmpty: '鉴权成功,但没有返回可用模型。',
modelsLoaded: '已拉取 {{count}} 个模型。',
selectModel: '选择一个模型写入上方字段',
validateSuccess: '鉴权成功。',
providerHttpStatus: '供应商接口返回 {{status}},请检查 API Key 权限或 Endpoint。',
apiKeyMissing: 'API Key 为空。',
endpointMissing: 'Endpoint 为空。',
requestTimeout: '请求超时,请稍后重试。',
},
shortcuts: {
title: '快捷键速查',
+17
View File
@@ -63,6 +63,15 @@ const mockCredentialsStatus: CredentialsStatus = {
arkConfigured: true,
};
export interface ProviderCheckResult {
ok: boolean;
message: string;
}
export interface ProviderModelsResult {
models: string[];
}
const mockHotkeyStatus: HotkeyStatus = {
adapter: 'windowsLowLevel',
state: 'installed',
@@ -131,6 +140,14 @@ export function readCredential(account: string): Promise<string | null> {
return invokeOrMock<string | null>('read_credential', { account }, () => null);
}
export function validateProviderCredentials(kind: 'llm' | 'asr'): Promise<ProviderCheckResult> {
return invokeOrMock('validate_provider_credentials', { kind }, () => ({ ok: true, message: 'Mock credentials are valid.' }));
}
export function listProviderModels(kind: 'llm' | 'asr'): Promise<ProviderModelsResult> {
return invokeOrMock('list_provider_models', { kind }, () => ({ models: kind === 'llm' ? ['gpt-4o', 'deepseek-chat'] : ['whisper-1'] }));
}
// ── History ────────────────────────────────────────────────────────────
export function listHistory(): Promise<DictationSession[]> {
return invokeOrMock('list_history', undefined, () => mockHistory);
+103 -2
View File
@@ -15,12 +15,14 @@ import {
getHotkeyStatus,
openExternal,
openSystemSettings,
listProviderModels,
readCredential,
requestAccessibilityPermission,
requestMicrophonePermission,
setActiveAsrProvider,
setActiveLlmProvider,
setCredential,
validateProviderCredentials,
} from '../lib/ipc';
import type {
HotkeyCapability,
@@ -286,6 +288,8 @@ function ProvidersSection() {
const { prefs, updatePrefs } = useHotkeySettings();
const [llmProvider, setLlmProvider] = useState<LlmPresetId>('ark');
const [asrProvider, setAsrProvider] = useState<AsrPresetId>('volcengine');
const [llmModelRevision, setLlmModelRevision] = useState(0);
const [asrModelRevision, setAsrModelRevision] = useState(0);
useEffect(() => {
if (!prefs) return;
@@ -342,8 +346,9 @@ function ProvidersSection() {
<CredentialField key={`${llmProvider}:api_key`} label={t('settings.providers.apiKeyLabel')} account="ark.api_key" mono mask />
<CredentialField key={`${llmProvider}:endpoint`} label={t('settings.providers.baseUrlLabel')} account="ark.endpoint"
placeholder={preset.baseUrl || 'https://your-endpoint/v1'} />
<CredentialField key={`${llmProvider}:model`} label={t('settings.providers.modelLabel')} account="ark.model_id"
<CredentialField key={`${llmProvider}:model:${llmModelRevision}`} label={t('settings.providers.modelLabel')} account="ark.model_id"
placeholder={preset.modelPlaceholder || 'model-name'} mono />
<ProviderTools key={llmProvider} kind="llm" modelAccount="ark.model_id" onModelSelected={() => setLlmModelRevision(v => v + 1)} />
</Card>
<Card>
@@ -374,8 +379,9 @@ function ProvidersSection() {
<CredentialField key={`${asrProvider}:api_key`} label={t('settings.providers.apiKeyLabel')} account="asr.api_key" mono mask />
<CredentialField key={`${asrProvider}:endpoint`} label={t('settings.providers.baseUrlLabel')} account="asr.endpoint"
placeholder="https://api.openai.com/v1" defaultValue="https://api.openai.com/v1" />
<CredentialField key={`${asrProvider}:model`} label={t('settings.providers.modelLabel')} account="asr.model"
<CredentialField key={`${asrProvider}:model:${asrModelRevision}`} label={t('settings.providers.modelLabel')} account="asr.model"
placeholder="whisper-1" />
<ProviderTools kind="asr" modelAccount="asr.model" onModelSelected={() => setAsrModelRevision(v => v + 1)} />
</>
)}
</Card>
@@ -383,6 +389,92 @@ function ProvidersSection() {
);
}
type ProviderToolStatus = 'idle' | 'loading' | 'success' | 'empty' | 'error';
function ProviderTools({ kind, modelAccount, onModelSelected }: { kind: 'llm' | 'asr'; modelAccount: string; onModelSelected: () => void }) {
const { t } = useTranslation();
const [models, setModels] = useState<string[]>([]);
const [selectedModel, setSelectedModel] = useState('');
const [status, setStatus] = useState<ProviderToolStatus>('idle');
const [message, setMessage] = useState('');
const setResult = (next: ProviderToolStatus, nextMessage: string) => {
setStatus(next);
setMessage(nextMessage);
};
const validate = async () => {
setResult('loading', t('settings.providers.validating'));
try {
const result = await validateProviderCredentials(kind);
setResult(result.ok ? 'success' : 'error', result.message || t('settings.providers.validateSuccess'));
} catch (error) {
setResult('error', providerErrorMessage(error, t));
}
};
const loadModels = async () => {
setResult('loading', t('settings.providers.loadingModels'));
try {
const result = await listProviderModels(kind);
setModels(result.models);
if (result.models.length === 0) {
setResult('empty', t('settings.providers.modelsEmpty'));
} else {
setSelectedModel('');
setResult('success', t('settings.providers.modelsLoaded', { count: result.models.length }));
}
} catch (error) {
setModels([]);
setResult('error', providerErrorMessage(error, t));
}
};
const applyModel = async (model: string) => {
setSelectedModel(model);
await setCredential(modelAccount, model);
onModelSelected();
};
return (
<SettingRow label={t('settings.providers.toolsLabel')} desc={t('settings.providers.toolsDesc')}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, width: '100%', maxWidth: 420 }}>
<div style={{ display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' }}>
<button onClick={validate} style={miniBtnStyle} disabled={status === 'loading'}>{t('settings.providers.validate')}</button>
<button onClick={loadModels} style={miniBtnStyle} disabled={status === 'loading'}>{t('settings.providers.fetchModels')}</button>
{models.length > 0 && (
<select
value={selectedModel}
onChange={e => applyModel(e.target.value)}
style={{ ...inputStyle, maxWidth: 220 }}
>
<option value="" disabled>{t('settings.providers.selectModel')}</option>
{models.map(model => <option key={model} value={model}>{model}</option>)}
</select>
)}
</div>
{message && (
<span style={{ fontSize: 11, color: status === 'error' ? 'var(--ol-warn)' : status === 'empty' ? 'var(--ol-ink-4)' : 'var(--ol-ok)', lineHeight: 1.4 }}>
{message}
</span>
)}
</div>
</SettingRow>
);
}
function providerErrorMessage(error: unknown, t: ReturnType<typeof useTranslation>['t']): string {
const message = error instanceof Error ? error.message : String(error);
if (message.startsWith('providerHttpStatus:')) {
return t('settings.providers.providerHttpStatus', { status: message.split(':')[1] || '?' });
}
if (message.includes('API Key')) return t('settings.providers.apiKeyMissing');
if (message.includes('Endpoint')) return t('settings.providers.endpointMissing');
if (message.includes('timeout') || message.includes('超时')) return t('settings.providers.requestTimeout');
return t('common.operationFailed');
}
type CredentialFieldStatus = 'idle' | 'saving' | 'saved' | 'readError' | 'saveError' | 'copied' | 'copyError';
interface CredentialFieldProps {
@@ -567,6 +659,15 @@ const inputStyle: CSSProperties = {
width: '100%', maxWidth: 360,
transition: 'background 0.12s ease-out, border-color 0.12s ease-out',
};
const miniBtnStyle: CSSProperties = {
height: 32, padding: '0 10px',
border: '0.5px solid var(--ol-line-strong)',
borderRadius: 8, background: 'var(--ol-surface)',
color: 'var(--ol-ink-2)', cursor: 'default', flexShrink: 0,
fontSize: 12, fontWeight: 500,
transition: 'background 0.12s ease-out, border-color 0.12s ease-out, color 0.12s ease-out',
};
const iconBtnStyle: CSSProperties = {
width: 32, height: 32,
border: '0.5px solid var(--ol-line-strong)',