Merge pull request #401 from BBossss/beta

Merging Codex OAuth LLM provider integration from external contributor.
This commit is contained in:
TRIP authored and GitHub committed 2026-05-11 07:10:15 +08:00
commit e7a0ad5971
15 files changed
+1098 -67

No files matched your search

+1 -1
View File
@@ -29,7 +29,7 @@ serde_json = "1"
tokio = { version = "1", features = ["full"] }
tokio-tungstenite = { version = "0.24", features = ["rustls-tls-native-roots"] }
futures-util = "0.3"
reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "rustls-tls", "native-tls", "stream"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "rustls-tls", "native-tls", "stream", "system-proxy"] }
zip = "2"
thiserror = "1"
anyhow = "1"
@@ -508,7 +508,9 @@ pub fn partial_actual_size(partial: &Path) -> u64 {
let mut seen: HashSet<usize> = HashSet::new();
let mut total: u64 = 0;
for line in content.lines() {
let Ok(idx) = line.trim().parse::<usize>() else { continue };
let Ok(idx) = line.trim().parse::<usize>() else {
continue;
};
if !seen.insert(idx) {
continue;
}
@@ -40,7 +40,7 @@ pub struct VolcengineCredentials {
impl VolcengineCredentials {
pub fn default_resource_id() -> &'static str {
"volc.seedasr.sauc.duration"
"volc.bigasr.sauc.duration"
}
}
@@ -719,7 +719,7 @@ mod tests {
fn default_resource_id_is_sauc_duration() {
assert_eq!(
VolcengineCredentials::default_resource_id(),
"volc.seedasr.sauc.duration"
"volc.bigasr.sauc.duration"
);
}
@@ -29,12 +29,7 @@ pub struct WhisperBatchASR {
}
impl WhisperBatchASR {
pub fn new(
api_key: String,
base_url: String,
model: String,
prompt: Option<String>,
) -> Self {
pub fn new(api_key: String, base_url: String, model: String, prompt: Option<String>) -> Self {
Self {
api_key,
base_url,
@@ -205,7 +200,10 @@ mod tests {
#[test]
fn build_prompt_single_phrase() {
let phrases = vec!["梁山泊".to_string()];
assert_eq!(build_prompt_from_phrases(&phrases), Some("梁山泊.".to_string()));
assert_eq!(
build_prompt_from_phrases(&phrases),
Some("梁山泊.".to_string())
);
}
#[test]
+66 -11
View File
@@ -1,7 +1,6 @@
//! Tauri command surface — every IPC entry the React UI invokes lives here.
use std::sync::Arc;
use std::time::Duration;
use parking_lot::Mutex;
use serde::Serialize;
@@ -16,7 +15,11 @@ use crate::asr::local::FoundryLocalRuntime;
use crate::coordinator::Coordinator;
use crate::permissions::{self, PermissionStatus};
use crate::persistence::{CredentialAccount, CredentialsSnapshot, CredentialsVault};
use crate::polish::{LLMError, OpenAICompatibleConfig, OpenAICompatibleLLMProvider};
use crate::polish::{
http_client_builder, CodexOAuthConfig, CodexOAuthCredentials, CodexOAuthLLMProvider, LLMError,
OpenAICompatibleConfig, OpenAICompatibleLLMProvider, CODEX_DEFAULT_MODEL,
CODEX_OAUTH_PROVIDER_ID,
};
use crate::recorder::{AudioConsumer, Recorder};
use crate::types::{
ChineseScriptPreference, ComboBinding, CorrectionRule, CredentialsStatus, DictationSession,
@@ -392,6 +395,9 @@ fn asr_configured_for_provider(provider: &str, snap: &CredentialsSnapshot) -> bo
}
fn llm_configured_for_provider(provider: &str, snap: &CredentialsSnapshot) -> bool {
if provider == CODEX_OAUTH_PROVIDER_ID {
return CodexOAuthCredentials::load_default().is_ok();
}
let endpoint = snap.ark_endpoint.as_deref().unwrap_or_default();
let endpoint_and_model = configured(&snap.ark_endpoint) && configured(&snap.ark_model_id);
if endpoint_and_model
@@ -554,6 +560,16 @@ pub async fn list_provider_models(kind: String) -> Result<ProviderModelsResult,
models: vec![crate::asr::bailian::DEFAULT_MODEL.to_string()],
});
}
if kind == "llm" && CredentialsVault::get_active_llm() == CODEX_OAUTH_PROVIDER_ID {
return Ok(ProviderModelsResult {
models: vec![
CODEX_DEFAULT_MODEL.to_string(),
"gpt-5.3-codex".to_string(),
"gpt-5.4".to_string(),
"gpt-5.5".to_string(),
],
});
}
let config = read_openai_provider_config(&kind)?;
fetch_provider_models(&config)
.await
@@ -595,6 +611,33 @@ fn read_openai_provider_config(kind: &str) -> Result<ProviderConfig, String> {
}
async fn validate_llm_provider() -> Result<(), String> {
if CredentialsVault::get_active_llm() == CODEX_OAUTH_PROVIDER_ID {
let model = CredentialsVault::get(CredentialAccount::ArkModelId)
.map_err(|e| e.to_string())?
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| CODEX_DEFAULT_MODEL.to_string());
let provider = CodexOAuthLLMProvider::new(CodexOAuthConfig::new(model));
return provider
.polish(
"验证连接",
PolishMode::Raw,
&[],
&[],
ChineseScriptPreference::Auto,
OutputLanguagePreference::Auto,
None,
&[],
)
.await
.map(|_| ())
.map_err(|e| match e {
LLMError::InvalidResponse { status, .. } => {
format!("providerHttpStatus:{status}")
}
other => other.to_string(),
});
}
let config = read_openai_provider_config("llm")?;
let model = CredentialsVault::get(CredentialAccount::ArkModelId)
.map_err(|e| e.to_string())?
@@ -711,8 +754,7 @@ async fn validate_asr_transcription(config: &ProviderConfig, model: &str) -> Res
let form = reqwest::multipart::Form::new()
.part("file", wav_part)
.text("model", model.to_string());
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(20))
let client = http_client_builder(&url, 20)
.build()
.map_err(|_| "providerClientInitFailed".to_string())?;
let response = client
@@ -812,8 +854,7 @@ async fn fetch_provider_models(config: &ProviderConfig) -> Result<Vec<String>, S
let url = models_url(&config.base_url);
let is_gemini = is_gemini_base_url(&config.base_url);
log::info!("[provider-check] GET {url} (gemini={is_gemini})");
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(15))
let client = http_client_builder(&config.base_url, 15)
.build()
.map_err(|e| format!("HTTP client 初始化失败: {e}"))?;
let mut request = client.get(&url);
@@ -903,7 +944,10 @@ fn parse_gemini_model_ids(body: &str) -> Result<Vec<String>, String> {
let mut ids = models
.iter()
.filter(|item| {
match item.get("supportedGenerationMethods").and_then(|v| v.as_array()) {
match item
.get("supportedGenerationMethods")
.and_then(|v| v.as_array())
{
Some(methods) => methods
.iter()
.any(|m| m.as_str() == Some("generateContent")),
@@ -911,7 +955,12 @@ fn parse_gemini_model_ids(body: &str) -> Result<Vec<String>, String> {
}
})
.filter_map(|item| item.get("name").and_then(|n| n.as_str()))
.map(|name| name.strip_prefix("models/").unwrap_or(name).trim().to_string())
.map(|name| {
name.strip_prefix("models/")
.unwrap_or(name)
.trim()
.to_string()
})
.filter(|id| !id.is_empty())
.collect::<Vec<_>>();
ids.sort();
@@ -2234,10 +2283,16 @@ mod tests {
#[test]
fn is_gemini_base_url_matches_official_domain() {
assert!(is_gemini_base_url("https://generativelanguage.googleapis.com/v1beta"));
assert!(is_gemini_base_url("https://generativelanguage.googleapis.com/v1beta/"));
assert!(is_gemini_base_url(
"https://generativelanguage.googleapis.com/v1beta"
));
assert!(is_gemini_base_url(
"https://generativelanguage.googleapis.com/v1beta/"
));
assert!(!is_gemini_base_url("https://api.openai.com/v1"));
assert!(!is_gemini_base_url("https://ark.cn-beijing.volces.com/api/v3"));
assert!(!is_gemini_base_url(
"https://ark.cn-beijing.volces.com/api/v3"
));
}
#[test]
+30 -36
View File
@@ -37,7 +37,10 @@ use crate::persistence::{
};
use crate::llm_gemini::{GeminiConfig, GeminiProvider};
use crate::polish::{OpenAICompatibleConfig, OpenAICompatibleLLMProvider};
use crate::polish::{
ActiveLLMProvider, CodexOAuthConfig, CodexOAuthLLMProvider, OpenAICompatibleConfig,
OpenAICompatibleLLMProvider, CODEX_DEFAULT_MODEL, CODEX_OAUTH_PROVIDER_ID,
};
use crate::qa_hotkey::{QaHotkeyError, QaHotkeyEvent, QaHotkeyMonitor};
use crate::recorder::{Recorder, RecorderError};
use crate::selection::capture_selection;
@@ -2068,18 +2071,7 @@ async fn polish_text(
.await?);
}
let api_key = CredentialsVault::get(CredentialAccount::ArkApiKey)?.unwrap_or_default();
let model = CredentialsVault::get(CredentialAccount::ArkModelId)?
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "deepseek-v3-2".to_string());
let endpoint = resolve_ark_endpoint(&api_key)?;
let base_url = endpoint
.trim_end_matches("/chat/completions")
.trim_end_matches('/')
.to_string();
let config = OpenAICompatibleConfig::new("ark", "Doubao Ark", base_url, api_key, model);
let provider = OpenAICompatibleLLMProvider::new(config);
let provider = build_active_llm_provider()?;
Ok(provider
.polish(
raw,
@@ -2146,18 +2138,7 @@ async fn translate_text(
.await?);
}
let api_key = CredentialsVault::get(CredentialAccount::ArkApiKey)?.unwrap_or_default();
let model = CredentialsVault::get(CredentialAccount::ArkModelId)?
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "deepseek-v3-2".to_string());
let endpoint = resolve_ark_endpoint(&api_key)?;
let base_url = endpoint
.trim_end_matches("/chat/completions")
.trim_end_matches('/')
.to_string();
let config = OpenAICompatibleConfig::new("ark", "Doubao Ark", base_url, api_key, model);
let provider = OpenAICompatibleLLMProvider::new(config);
let provider = build_active_llm_provider()?;
Ok(provider
.translate_to(
raw,
@@ -2736,17 +2717,7 @@ where
.await?);
}
let api_key = CredentialsVault::get(CredentialAccount::ArkApiKey)?.unwrap_or_default();
let model = CredentialsVault::get(CredentialAccount::ArkModelId)?
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "deepseek-v3-2".to_string());
let endpoint = resolve_ark_endpoint(&api_key)?;
let base_url = endpoint
.trim_end_matches("/chat/completions")
.trim_end_matches('/')
.to_string();
let config = OpenAICompatibleConfig::new("ark", "Doubao Ark", base_url, api_key, model);
let provider = OpenAICompatibleLLMProvider::new(config);
let provider = build_active_llm_provider()?;
Ok(provider
.answer_chat_streaming(
messages,
@@ -2784,6 +2755,29 @@ fn read_gemini_credentials() -> anyhow::Result<(String, String, String)> {
Ok((api_key, model, base_url))
}
fn build_active_llm_provider() -> anyhow::Result<ActiveLLMProvider> {
let active = CredentialsVault::get_active_llm();
let model =
CredentialsVault::get(CredentialAccount::ArkModelId)?.filter(|s| !s.trim().is_empty());
if active == CODEX_OAUTH_PROVIDER_ID {
let config =
CodexOAuthConfig::new(model.unwrap_or_else(|| CODEX_DEFAULT_MODEL.to_string()));
return Ok(ActiveLLMProvider::Codex(CodexOAuthLLMProvider::new(config)));
}
let api_key = CredentialsVault::get(CredentialAccount::ArkApiKey)?.unwrap_or_default();
let model = model.unwrap_or_else(|| "deepseek-v3-2".to_string());
let endpoint = resolve_ark_endpoint(&api_key)?;
let base_url = endpoint
.trim_end_matches("/chat/completions")
.trim_end_matches('/')
.to_string();
let config = OpenAICompatibleConfig::new(active, "OpenLess LLM", base_url, api_key, model);
Ok(ActiveLLMProvider::OpenAI(OpenAICompatibleLLMProvider::new(
config,
)))
}
fn resolve_ark_endpoint(api_key: &str) -> anyhow::Result<String> {
let endpoint = CredentialsVault::get(CredentialAccount::ArkEndpoint)?.filter(|s| !s.is_empty());
resolve_ark_endpoint_with_policy(api_key, endpoint)
@@ -796,8 +796,7 @@ impl HistoryStore {
// Prepend so the newest session is at index 0, matching the Swift impl.
sessions.insert(0, session);
if retention_days > 0 {
let cutoff =
chrono::Utc::now() - chrono::Duration::days(i64::from(retention_days));
let cutoff = chrono::Utc::now() - chrono::Duration::days(i64::from(retention_days));
sessions.retain(|s| {
chrono::DateTime::parse_from_rfc3339(&s.created_at)
.map(|t| t.with_timezone(&chrono::Utc) >= cutoff)
File diff suppressed because it is too large. Load diff
+2
View File
@@ -355,6 +355,7 @@ export const en: typeof zhCN = {
providerLabel: 'Provider',
llmProviderDesc: 'Selecting a preset auto-fills the default Base URL.',
credentialStorageNotice: 'Credentials are stored in the OS credential vault. Legacy local JSON credentials are migrated into the vault and removed after a successful write.',
codexOAuthNotice: 'Codex OAuth uses the local Codex login state (~/.codex/auth.json). OpenLess does not store an API key or Base URL for this provider.',
asrProviderDesc: 'Switching providers automatically loads the matching credentials.',
asrTitle: 'ASR (transcription)',
asrDesc: 'Used to turn speech into text in real time.',
@@ -364,6 +365,7 @@ export const en: typeof zhCN = {
siliconflow: 'SiliconFlow',
openai: 'OpenAI',
gemini: 'Google Gemini',
codexOAuth: 'Codex OAuth',
mimo: 'Xiaomi MiMo',
cometapi: 'CometAPI',
openrouterFree: 'OpenRouter (free models)',
+2
View File
@@ -357,6 +357,7 @@ export const ja: typeof zhCN = {
providerLabel: 'サプライヤー',
llmProviderDesc: '選択するとデフォルトの Base URL が自動入力されます。',
credentialStorageNotice: '認証情報は OS の認証情報ストアに保存されます。旧バージョンのローカル JSON 認証情報はストアへ移行され、書き込み成功後に削除されます。',
codexOAuthNotice: 'Codex OAuth はローカルの Codex ログイン状態(~/.codex/auth.json)を使用します。OpenLess は API Key や Base URL を保存しません。',
asrProviderDesc: '切り替えると対応する認証情報が自動選択されます。',
asrTitle: 'ASR 音声(転写)',
asrDesc: '口述をリアルタイムでテキストに転写。',
@@ -366,6 +367,7 @@ export const ja: typeof zhCN = {
siliconflow: 'SiliconFlow',
openai: 'OpenAI',
gemini: 'Google Gemini',
codexOAuth: 'Codex OAuth',
mimo: 'Xiaomi MiMo',
cometapi: 'CometAPI',
openrouterFree: 'OpenRouter(無料モデル)',
+2
View File
@@ -357,6 +357,7 @@ export const ko: typeof zhCN = {
providerLabel: '공급자',
llmProviderDesc: '선택 시 Base URL 기본값이 자동 입력됩니다.',
credentialStorageNotice: '자격 증명은 OS 자격 증명 저장소에 저장됩니다. 이전 로컬 JSON 자격 증명은 저장소로 마이그레이션되고, 쓰기 성공 후 삭제됩니다.',
codexOAuthNotice: 'Codex OAuth는 로컬 Codex 로그인 상태(~/.codex/auth.json)를 사용합니다. OpenLess는 API Key나 Base URL을 저장하지 않습니다.',
asrProviderDesc: '전환 시 해당하는 자격 증명이 자동 선택됩니다.',
asrTitle: 'ASR 음성(전사)',
asrDesc: '구술을 실시간으로 텍스트로 전사합니다.',
@@ -366,6 +367,7 @@ export const ko: typeof zhCN = {
siliconflow: 'SiliconFlow',
openai: 'OpenAI',
gemini: 'Google Gemini',
codexOAuth: 'Codex OAuth',
mimo: 'Xiaomi MiMo',
cometapi: 'CometAPI',
openrouterFree: 'OpenRouter(무료 모델)',
+2
View File
@@ -353,6 +353,7 @@ export const zhCN = {
providerLabel: '供应商',
llmProviderDesc: '选择后将自动填入 Base URL 默认值。',
credentialStorageNotice: '凭据保存在系统凭据库中。旧版本地 JSON 凭据会迁移到系统凭据库,并在成功写入后删除。',
codexOAuthNotice: 'Codex OAuth 使用本机 Codex 登录状态(~/.codex/auth.json),无需在 OpenLess 中保存 API Key 或 Base URL。',
asrProviderDesc: '切换后将自动选用对应凭据。',
asrTitle: 'ASR 语音(转写)',
asrDesc: '用于将口述实时转写为文本。',
@@ -362,6 +363,7 @@ export const zhCN = {
siliconflow: '硅基流动',
openai: 'OpenAI',
gemini: 'Google Gemini',
codexOAuth: 'Codex OAuth',
mimo: '小米 MiMo',
cometapi: 'CometAPI',
openrouterFree: 'OpenRouter(免费模型)',
+2
View File
@@ -355,6 +355,7 @@ export const zhTW: typeof zhCN = {
providerLabel: '供應商',
llmProviderDesc: '選擇後將自動填入 Base URL 默認值。',
credentialStorageNotice: '憑據儲存在系統憑據庫中。舊版本機 JSON 憑據會遷移到系統憑據庫,並在成功寫入後刪除。',
codexOAuthNotice: 'Codex OAuth 使用本機 Codex 登入狀態(~/.codex/auth.json),無需在 OpenLess 中保存 API Key 或 Base URL。',
asrProviderDesc: '切換後將自動選用對應憑據。',
asrTitle: 'ASR 語音(轉寫)',
asrDesc: '用於將口述實時轉寫爲文本。',
@@ -364,6 +365,7 @@ export const zhTW: typeof zhCN = {
siliconflow: '硅基流動',
openai: 'OpenAI',
gemini: 'Google Gemini',
codexOAuth: 'Codex OAuth',
mimo: '小米 MiMo',
cometapi: 'CometAPI',
openrouterFree: 'OpenRouter(免費模型)',
+1
View File
@@ -39,6 +39,7 @@ const LLM_NAME_KEY_BY_ID: Record<string, string> = {
deepseek: 'deepseek',
siliconflow: 'siliconflow',
openai: 'openai',
codex_oauth: 'codexOAuth',
mimo: 'mimo',
cometapi: 'cometapi',
openrouterFree: 'openrouterFree',
+19 -4
View File
@@ -1177,6 +1177,12 @@ const LLM_PRESETS = [
baseUrl: 'https://generativelanguage.googleapis.com/v1beta',
modelPlaceholder: 'gemini-2.5-flash',
},
{
id: 'codex_oauth',
nameKey: 'codexOAuth',
baseUrl: '',
modelPlaceholder: 'gpt-5.3-codex-spark',
},
{
id: 'mimo',
nameKey: 'mimo',
@@ -1217,7 +1223,7 @@ const LLM_PRESETS = [
type LlmPresetId = typeof LLM_PRESETS[number]['id'];
const ASR_DEFAULT_RESOURCE_ID = 'volc.seedasr.sauc.duration';
const ASR_DEFAULT_RESOURCE_ID = 'volc.bigasr.sauc.duration';
// `volcengine` / `bailian` 走自建流式客户端;其余走 OpenAI 兼容
// `/audio/transcriptions`(`coordinator.rs::is_whisper_compatible_provider`)。
@@ -1374,6 +1380,7 @@ function ProvidersSection() {
// 否则受控 <select> 立刻切到新厂商,但凭据字段还在显示旧 entry,placeholder
// 会先于实际数据切换、视觉上对不上。
const preset = LLM_PRESETS.find(p => p.id === committedLlmProvider) ?? LLM_PRESETS[LLM_PRESETS.length - 1];
const codexOAuthSelected = committedLlmProvider === 'codex_oauth';
const asrPreset = visibleAsrPresets.find(p => p.id === committedAsrProvider);
return (
@@ -1401,9 +1408,17 @@ function ProvidersSection() {
))}
</select>
</SettingRow>
<CredentialField key={`${committedLlmProvider}:api_key`} label={t('settings.providers.apiKeyLabel')} account="ark.api_key" mono mask />
<CredentialField key={`${committedLlmProvider}:endpoint`} label={t('settings.providers.baseUrlLabel')} account="ark.endpoint"
placeholder={preset.baseUrl || 'https://your-endpoint/v1'} />
{codexOAuthSelected ? (
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', lineHeight: 1.6, margin: '2px 0 10px' }}>
{t('settings.providers.codexOAuthNotice')}
</div>
) : (
<>
<CredentialField key={`${committedLlmProvider}:api_key`} label={t('settings.providers.apiKeyLabel')} account="ark.api_key" mono mask />
<CredentialField key={`${committedLlmProvider}:endpoint`} label={t('settings.providers.baseUrlLabel')} account="ark.endpoint"
placeholder={preset.baseUrl || 'https://your-endpoint/v1'} />
</>
)}
<CredentialField key={`${committedLlmProvider}:model:${llmModelRevision}`} label={t('settings.providers.modelLabel')} account="ark.model_id"
placeholder={preset.modelPlaceholder || 'model-name'} mono />
<ProviderTools key={committedLlmProvider} kind="llm" modelAccount="ark.model_id" onModelSelected={() => setLlmModelRevision(v => v + 1)} />