From 8391e566c6c5b4af295225c0b458fd6ffa49e5dc Mon Sep 17 00:00:00 2001 From: bbossss <1551929492@qq.com> Date: Sun, 10 May 2026 22:04:25 +0800 Subject: [PATCH] Let OpenLess reuse Codex auth for low-latency polishing Codex OAuth should let users spend their existing Codex subscription on short dictation polish, translation, and QA without storing another API key in OpenLess. The provider reads the local Codex auth file, keeps OpenAI-compatible providers on their existing path, uses a lighter Spark default for latency, and broadens Codex auth discovery for Windows GUI environments. The same change keeps Volcengine on the duration-priced BigASR resource validated locally and preserves proxy behavior so Codex network paths can use system proxy settings while loopback/local provider checks avoid the proxy. Constraint: Codex OAuth credentials must remain sourced from the Codex auth file and must not be copied into OpenLess credential storage. Constraint: PR #401 is a fork PR from BBossss/openless:beta to appergb/openless:beta. Rejected: Require a separate OpenAI API key | the requested path was to reuse the existing Codex subscription. Rejected: Keep gpt-5.5 as the default | it is unnecessarily heavy for short dictation polish and was observed timing out. Rejected: Use only HOME for Codex auth lookup | Windows GUI sessions commonly expose USERPROFILE instead. Confidence: high Scope-risk: moderate Reversibility: clean Directive: Do not persist Codex OAuth tokens into OpenLess credentials; keep using the Codex auth file as the source of truth. Directive: Keep Codex OAuth defaults optimized for short low-latency dictation polishing unless product requirements change. Tested: git diff --cached --check Tested: cargo test --manifest-path src-tauri/Cargo.toml --lib Tested: npm run build Tested: PR #401 CI passed on Windows, macOS, and Linux before this history-only squash. Co-authored-by: OmX --- openless-all/app/src-tauri/Cargo.toml | 2 +- .../app/src-tauri/src/asr/local/download.rs | 4 +- .../app/src-tauri/src/asr/volcengine.rs | 4 +- openless-all/app/src-tauri/src/asr/whisper.rs | 12 +- openless-all/app/src-tauri/src/commands.rs | 77 +- openless-all/app/src-tauri/src/coordinator.rs | 66 +- openless-all/app/src-tauri/src/persistence.rs | 3 +- openless-all/app/src-tauri/src/polish.rs | 963 +++++++++++++++++- openless-all/app/src/i18n/en.ts | 2 + openless-all/app/src/i18n/ja.ts | 2 + openless-all/app/src/i18n/ko.ts | 2 + openless-all/app/src/i18n/zh-CN.ts | 2 + openless-all/app/src/i18n/zh-TW.ts | 2 + openless-all/app/src/pages/Overview.tsx | 1 + openless-all/app/src/pages/Settings.tsx | 23 +- 15 files changed, 1098 insertions(+), 67 deletions(-) diff --git a/openless-all/app/src-tauri/Cargo.toml b/openless-all/app/src-tauri/Cargo.toml index b819507b..a19cbad0 100644 --- a/openless-all/app/src-tauri/Cargo.toml +++ b/openless-all/app/src-tauri/Cargo.toml @@ -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" diff --git a/openless-all/app/src-tauri/src/asr/local/download.rs b/openless-all/app/src-tauri/src/asr/local/download.rs index 2ddf53fb..b3ed1361 100644 --- a/openless-all/app/src-tauri/src/asr/local/download.rs +++ b/openless-all/app/src-tauri/src/asr/local/download.rs @@ -508,7 +508,9 @@ pub fn partial_actual_size(partial: &Path) -> u64 { let mut seen: HashSet = HashSet::new(); let mut total: u64 = 0; for line in content.lines() { - let Ok(idx) = line.trim().parse::() else { continue }; + let Ok(idx) = line.trim().parse::() else { + continue; + }; if !seen.insert(idx) { continue; } diff --git a/openless-all/app/src-tauri/src/asr/volcengine.rs b/openless-all/app/src-tauri/src/asr/volcengine.rs index 2fc0feac..7d28fc05 100644 --- a/openless-all/app/src-tauri/src/asr/volcengine.rs +++ b/openless-all/app/src-tauri/src/asr/volcengine.rs @@ -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" ); } diff --git a/openless-all/app/src-tauri/src/asr/whisper.rs b/openless-all/app/src-tauri/src/asr/whisper.rs index 13c8264e..6cfc2e52 100644 --- a/openless-all/app/src-tauri/src/asr/whisper.rs +++ b/openless-all/app/src-tauri/src/asr/whisper.rs @@ -29,12 +29,7 @@ pub struct WhisperBatchASR { } impl WhisperBatchASR { - pub fn new( - api_key: String, - base_url: String, - model: String, - prompt: Option, - ) -> Self { + pub fn new(api_key: String, base_url: String, model: String, prompt: Option) -> 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] diff --git a/openless-all/app/src-tauri/src/commands.rs b/openless-all/app/src-tauri/src/commands.rs index 5a21232c..bb8a1cea 100644 --- a/openless-all/app/src-tauri/src/commands.rs +++ b/openless-all/app/src-tauri/src/commands.rs @@ -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 Result { } 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, 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, 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, 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::>(); 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] diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index e2e16754..c3fe72f8 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -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 { + 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 { let endpoint = CredentialsVault::get(CredentialAccount::ArkEndpoint)?.filter(|s| !s.is_empty()); resolve_ark_endpoint_with_policy(api_key, endpoint) diff --git a/openless-all/app/src-tauri/src/persistence.rs b/openless-all/app/src-tauri/src/persistence.rs index f5984202..43f45e7d 100644 --- a/openless-all/app/src-tauri/src/persistence.rs +++ b/openless-all/app/src-tauri/src/persistence.rs @@ -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) diff --git a/openless-all/app/src-tauri/src/polish.rs b/openless-all/app/src-tauri/src/polish.rs index 2ffc1509..b7da2e24 100644 --- a/openless-all/app/src-tauri/src/polish.rs +++ b/openless-all/app/src-tauri/src/polish.rs @@ -5,7 +5,10 @@ use std::borrow::Cow; use std::collections::HashMap; +use std::net::IpAddr; +use std::path::{Path, PathBuf}; use std::time::Duration; +use std::time::{SystemTime, UNIX_EPOCH}; use serde_json::{json, Value}; use thiserror::Error; @@ -15,6 +18,10 @@ use crate::types::{ChineseScriptPreference, OutputLanguagePreference, PolishMode const DEFAULT_TEMPERATURE: f32 = 0.3; const DEFAULT_REQUEST_TIMEOUT_SECS: u64 = 30; const BODY_PREVIEW_LIMIT: usize = 200; +pub const CODEX_OAUTH_PROVIDER_ID: &str = "codex_oauth"; +pub const CODEX_DEFAULT_BASE_URL: &str = "https://chatgpt.com/backend-api"; +pub const CODEX_DEFAULT_MODEL: &str = "gpt-5.3-codex-spark"; +const CODEX_MIN_TOKEN_TTL_SECS: u64 = 60; #[derive(Clone, Debug)] pub struct OpenAICompatibleConfig { @@ -61,6 +68,139 @@ pub enum LLMError { InvalidResponse { status: u16, body: String }, #[error("parse error: {0}")] ParseError(String), + #[error("codex oauth credentials unavailable: {0}")] + CodexAuth(String), +} + +pub enum ActiveLLMProvider { + OpenAI(OpenAICompatibleLLMProvider), + Codex(CodexOAuthLLMProvider), +} + +impl ActiveLLMProvider { + pub async fn polish( + &self, + raw_text: &str, + mode: PolishMode, + hotwords: &[String], + working_languages: &[String], + chinese_script_preference: ChineseScriptPreference, + output_language_preference: OutputLanguagePreference, + front_app: Option<&str>, + prior_turns: &[(String, String)], + ) -> Result { + match self { + Self::OpenAI(provider) => { + provider + .polish( + raw_text, + mode, + hotwords, + working_languages, + chinese_script_preference, + output_language_preference, + front_app, + prior_turns, + ) + .await + } + Self::Codex(provider) => { + provider + .polish( + raw_text, + mode, + hotwords, + working_languages, + chinese_script_preference, + output_language_preference, + front_app, + prior_turns, + ) + .await + } + } + } + + pub async fn translate_to( + &self, + raw_text: &str, + target_language: &str, + working_languages: &[String], + chinese_script_preference: ChineseScriptPreference, + output_language_preference: OutputLanguagePreference, + front_app: Option<&str>, + ) -> Result { + match self { + Self::OpenAI(provider) => { + provider + .translate_to( + raw_text, + target_language, + working_languages, + chinese_script_preference, + output_language_preference, + front_app, + ) + .await + } + Self::Codex(provider) => { + provider + .translate_to( + raw_text, + target_language, + working_languages, + chinese_script_preference, + output_language_preference, + front_app, + ) + .await + } + } + } + + pub async fn answer_chat_streaming( + &self, + messages: &[QaChatMessage], + working_languages: &[String], + chinese_script_preference: ChineseScriptPreference, + output_language_preference: OutputLanguagePreference, + front_app: Option<&str>, + on_delta: F, + should_cancel: C, + ) -> Result + where + F: Fn(&str) + Send + Sync, + C: Fn() -> bool + Send + Sync, + { + match self { + Self::OpenAI(provider) => { + provider + .answer_chat_streaming( + messages, + working_languages, + chinese_script_preference, + output_language_preference, + front_app, + on_delta, + should_cancel, + ) + .await + } + Self::Codex(provider) => { + provider + .answer_chat_streaming( + messages, + working_languages, + chinese_script_preference, + output_language_preference, + front_app, + on_delta, + should_cancel, + ) + .await + } + } + } } pub struct OpenAICompatibleLLMProvider { @@ -73,8 +213,7 @@ impl OpenAICompatibleLLMProvider { // Build reqwest client with the configured timeout. If client construction // fails for some reason (it should not on a normal target), fall back to // the default client so we still surface a useful error at request time. - let client = reqwest::Client::builder() - .timeout(Duration::from_secs(config.request_timeout_secs)) + let client = http_client_builder(&config.base_url, config.request_timeout_secs) .build() .unwrap_or_else(|_| reqwest::Client::new()); Self { config, client } @@ -415,6 +554,329 @@ impl OpenAICompatibleLLMProvider { } } +#[derive(Clone, Debug)] +pub struct CodexOAuthConfig { + pub base_url: String, + pub model: String, + pub auth_path: Option, + pub reasoning_effort: Option, + pub text_verbosity: Option, + pub request_timeout_secs: u64, +} + +impl CodexOAuthConfig { + pub fn new(model: impl Into) -> Self { + Self { + base_url: CODEX_DEFAULT_BASE_URL.to_string(), + model: normalize_codex_model(model.into().as_str()), + auth_path: None, + reasoning_effort: Some("medium".to_string()), + text_verbosity: Some("medium".to_string()), + request_timeout_secs: DEFAULT_REQUEST_TIMEOUT_SECS, + } + } + + pub fn with_base_url(mut self, base_url: impl Into) -> Self { + self.base_url = base_url.into(); + self + } + + pub fn with_auth_path(mut self, auth_path: PathBuf) -> Self { + self.auth_path = Some(auth_path); + self + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CodexOAuthCredentials { + pub access_token: String, + pub account_id: String, + pub expires_at_unix_secs: u64, +} + +impl CodexOAuthCredentials { + pub fn load_default() -> Result { + Self::load_from_path(&default_codex_auth_path()) + } + + pub fn load_from_path(path: &Path) -> Result { + let body = std::fs::read_to_string(path).map_err(|e| { + LLMError::CodexAuth(format!("无法读取 Codex 登录文件 {}: {}", path.display(), e)) + })?; + let json: Value = serde_json::from_str(&body) + .map_err(|e| LLMError::CodexAuth(format!("Codex 登录文件不是合法 JSON: {}", e)))?; + let tokens = json + .get("tokens") + .and_then(|v| v.as_object()) + .ok_or_else(|| LLMError::CodexAuth("Codex 登录文件缺少 tokens 对象".into()))?; + let access_token = tokens + .get("access_token") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .ok_or_else(|| LLMError::CodexAuth("Codex 登录文件缺少 access_token".into()))?; + let account_id = tokens + .get("account_id") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .ok_or_else(|| LLMError::CodexAuth("Codex 登录文件缺少 account_id".into()))?; + + let payload = decode_jwt_payload(access_token)?; + let expires_at_unix_secs = payload + .get("exp") + .and_then(|v| v.as_u64()) + .ok_or_else(|| LLMError::CodexAuth("Codex access token 缺少 exp".into()))?; + let claim_account_id = payload + .get("https://api.openai.com/auth.chatgpt_account_id") + .and_then(|v| v.as_str()) + .map(str::trim); + if claim_account_id.is_some_and(|claim| claim != account_id) { + return Err(LLMError::CodexAuth( + "Codex access token 的 account id 与 auth.json 不一致".into(), + )); + } + let now = unix_now_secs(); + if expires_at_unix_secs <= now + CODEX_MIN_TOKEN_TTL_SECS { + return Err(LLMError::CodexAuth( + "Codex access token 已过期或即将过期,请先在 Codex CLI/App 重新登录".into(), + )); + } + + Ok(Self { + access_token: access_token.to_string(), + account_id: account_id.to_string(), + expires_at_unix_secs, + }) + } +} + +pub struct CodexOAuthLLMProvider { + config: CodexOAuthConfig, + client: reqwest::Client, +} + +impl CodexOAuthLLMProvider { + pub fn new(config: CodexOAuthConfig) -> Self { + let client = http_client_builder(&config.base_url, config.request_timeout_secs) + .build() + .unwrap_or_else(|_| reqwest::Client::new()); + Self { config, client } + } + + pub fn config(&self) -> &CodexOAuthConfig { + &self.config + } + + pub async fn polish( + &self, + raw_text: &str, + mode: PolishMode, + hotwords: &[String], + working_languages: &[String], + chinese_script_preference: ChineseScriptPreference, + output_language_preference: OutputLanguagePreference, + front_app: Option<&str>, + prior_turns: &[(String, String)], + ) -> Result { + let mut system_prompt = compose_system_prompt(mode, hotwords); + if let Some(premise) = context_premise( + working_languages, + chinese_script_preference, + output_language_preference, + front_app, + ) { + system_prompt = format!("{}\n\n{}", premise, system_prompt); + } + if !prior_turns.is_empty() { + system_prompt = format!( + "{}\n\n{}", + system_prompt, + prompts::polish_context_instruction() + ); + } + let user_prompt = prompts::user_prompt(raw_text); + let messages = build_polish_history_messages(&system_prompt, prior_turns, &user_prompt); + self.codex_responses(messages, |_| {}, || false).await + } + + pub async fn translate_to( + &self, + raw_text: &str, + target_language: &str, + working_languages: &[String], + chinese_script_preference: ChineseScriptPreference, + _output_language_preference: OutputLanguagePreference, + front_app: Option<&str>, + ) -> Result { + let mut system_prompt = prompts::translate_system_prompt(target_language); + if let Some(premise) = context_premise( + working_languages, + chinese_script_preference, + OutputLanguagePreference::Auto, + front_app, + ) { + system_prompt = format!("{}\n\n{}", premise, system_prompt); + } + let messages = vec![ + json!({ "role": "system", "content": system_prompt }), + json!({ "role": "user", "content": prompts::user_prompt(raw_text) }), + ]; + self.codex_responses(messages, |_| {}, || false).await + } + + pub async fn answer_chat_streaming( + &self, + messages: &[QaChatMessage], + working_languages: &[String], + chinese_script_preference: ChineseScriptPreference, + output_language_preference: OutputLanguagePreference, + front_app: Option<&str>, + on_delta: F, + should_cancel: C, + ) -> Result + where + F: Fn(&str) + Send + Sync, + C: Fn() -> bool + Send + Sync, + { + let mut system_prompt = prompts::qa_system_prompt(); + if let Some(premise) = context_premise( + working_languages, + chinese_script_preference, + output_language_preference, + front_app, + ) { + system_prompt = format!("{}\n\n{}", premise, system_prompt); + } + + let mut request_messages = Vec::with_capacity(messages.len() + 1); + request_messages.push(json!({ "role": "system", "content": system_prompt })); + for message in messages { + request_messages.push(json!({ "role": message.role, "content": message.content })); + } + self.codex_responses(request_messages, on_delta, should_cancel) + .await + } + + async fn codex_responses( + &self, + messages: Vec, + on_delta: F, + should_cancel: C, + ) -> Result + where + F: Fn(&str) + Send + Sync, + C: Fn() -> bool + Send + Sync, + { + let auth_path = self + .config + .auth_path + .clone() + .unwrap_or_else(default_codex_auth_path); + let creds = CodexOAuthCredentials::load_from_path(&auth_path)?; + let url = codex_responses_url(&self.config.base_url); + let mut body = json!({ + "model": normalize_codex_model(&self.config.model), + "store": false, + "stream": true, + "input": codex_input_from_chat_messages(&messages), + "include": ["reasoning.encrypted_content"], + "instructions": "You are OpenLess' text polishing assistant. Follow the developer messages exactly and return only the final user-visible text.", + }); + if let Some(effort) = self.config.reasoning_effort.as_deref() { + body["reasoning"] = json!({ "effort": effort }); + } + if let Some(verbosity) = self.config.text_verbosity.as_deref() { + body["text"] = json!({ "verbosity": verbosity }); + } + + log::info!( + "[llm] POST {} provider={} model={} stream=true", + url, + CODEX_OAUTH_PROVIDER_ID, + self.config.model + ); + + let request = self + .client + .post(&url) + .header("Content-Type", "application/json") + .header("Accept", "text/event-stream") + .header("Authorization", format!("Bearer {}", creds.access_token)) + .header("chatgpt-account-id", creds.account_id) + .header("OpenAI-Beta", "responses=experimental") + .header("originator", "codex_cli_rs") + .json(&body); + let response = match request.send().await { + Ok(r) => r, + Err(e) => { + if e.is_timeout() { + return Err(LLMError::Timeout); + } + return Err(LLMError::Network(e.to_string())); + } + }; + + let status = response.status(); + if !status.is_success() { + let body_text = response + .text() + .await + .map_err(|e| LLMError::Network(e.to_string()))?; + let preview_end = BODY_PREVIEW_LIMIT.min(body_text.len()); + let preview = safe_str_slice(&body_text, preview_end); + log::error!("[llm] codex HTTP {} body={}", status.as_u16(), preview); + return Err(LLMError::InvalidResponse { + status: status.as_u16(), + body: preview.to_string(), + }); + } + + let mut response = response; + let mut buffer = String::new(); + let mut full_text = String::new(); + let mut final_text = String::new(); + loop { + if should_cancel() { + log::info!("[llm] codex stream cancelled by caller; breaking SSE loop"); + break; + } + let chunk_opt = response + .chunk() + .await + .map_err(|e| LLMError::Network(e.to_string()))?; + let Some(chunk) = chunk_opt else { break }; + let s = std::str::from_utf8(&chunk) + .map_err(|e| LLMError::Network(format!("non-utf8 SSE chunk: {e}")))?; + buffer.push_str(s); + + while let Some(idx) = buffer.find("\n\n") { + let event = buffer[..idx].to_string(); + buffer.drain(..idx + 2); + handle_codex_sse_event(&event, &mut full_text, &mut final_text, &on_delta); + } + } + if !buffer.trim().is_empty() { + handle_codex_sse_event(&buffer, &mut full_text, &mut final_text, &on_delta); + } + + if full_text.is_empty() && !final_text.is_empty() { + full_text = final_text; + } + log::info!( + "[llm] codex HTTP 200 stream done; total chars={}", + full_text.chars().count() + ); + if full_text.is_empty() { + return Err(LLMError::InvalidResponse { + status: 200, + body: "empty stream".to_string(), + }); + } + Ok(clean_polish_output(&full_text)) + } +} + /// Slice up to `end` bytes off `s`, but don't split a UTF-8 codepoint. pub(crate) fn safe_str_slice(s: &str, end: usize) -> &str { if end >= s.len() { @@ -465,6 +927,237 @@ fn chat_completions_url(base_url: &str) -> String { format!("{}/chat/completions", without_trailing) } +pub(crate) fn http_client_builder(base_url: &str, timeout_secs: u64) -> reqwest::ClientBuilder { + let builder = reqwest::Client::builder().timeout(Duration::from_secs(timeout_secs)); + if should_bypass_proxy_for_base_url(base_url) { + builder.no_proxy() + } else { + builder + } +} + +fn should_bypass_proxy_for_base_url(base_url: &str) -> bool { + let Ok(url) = reqwest::Url::parse(base_url.trim()) else { + return false; + }; + let Some(host) = url.host_str() else { + return false; + }; + if host.eq_ignore_ascii_case("localhost") { + return true; + } + host.parse::().is_ok_and(|ip| ip.is_loopback()) +} + +fn codex_responses_url(base_url: &str) -> String { + let trimmed = base_url.trim(); + if trimmed.ends_with("/codex/responses") { + return trimmed.to_string(); + } + let without_trailing = trimmed.strip_suffix('/').unwrap_or(trimmed); + format!("{}/codex/responses", without_trailing) +} + +fn default_codex_auth_path() -> PathBuf { + if let Ok(path) = std::env::var("OPENLESS_CODEX_AUTH_PATH") { + let trimmed = path.trim(); + if !trimmed.is_empty() { + return PathBuf::from(trimmed); + } + } + default_codex_home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".codex") + .join("auth.json") +} + +fn default_codex_home_dir() -> Option { + if let Some(home) = non_empty_env_path("HOME") { + return Some(home); + } + if let Some(userprofile) = non_empty_env_path("USERPROFILE") { + return Some(userprofile); + } + let drive = std::env::var_os("HOMEDRIVE")?; + let path = std::env::var_os("HOMEPATH")?; + let drive = drive.to_string_lossy(); + let path = path.to_string_lossy(); + if drive.trim().is_empty() || path.trim().is_empty() { + return None; + } + Some(PathBuf::from(format!("{drive}{path}"))) +} + +fn non_empty_env_path(key: &str) -> Option { + std::env::var_os(key) + .map(PathBuf::from) + .filter(|path| !path.as_os_str().is_empty()) +} + +fn normalize_codex_model(model: &str) -> String { + let trimmed = model.trim(); + let normalized = trimmed + .rsplit_once('/') + .map(|(_, tail)| tail.trim()) + .unwrap_or(trimmed); + if normalized.is_empty() { + CODEX_DEFAULT_MODEL.to_string() + } else { + normalized.to_string() + } +} + +fn codex_input_from_chat_messages(messages: &[Value]) -> Vec { + messages + .iter() + .filter_map(|message| { + let role = message.get("role").and_then(|v| v.as_str())?; + let text = message.get("content").and_then(|v| v.as_str())?; + let (codex_role, content_type) = match role { + "system" => ("developer", "input_text"), + "assistant" => ("assistant", "output_text"), + _ => ("user", "input_text"), + }; + Some(json!({ + "type": "message", + "role": codex_role, + "content": [{ "type": content_type, "text": text }], + })) + }) + .collect() +} + +fn handle_codex_sse_event( + event: &str, + full_text: &mut String, + final_text: &mut String, + on_delta: &F, +) where + F: Fn(&str) + Send + Sync, +{ + for line in event.lines() { + let Some(payload) = line + .strip_prefix("data: ") + .or_else(|| line.strip_prefix("data:")) + else { + continue; + }; + let payload = payload.trim(); + if payload.is_empty() || payload == "[DONE]" { + continue; + } + let v: Value = match serde_json::from_str(payload) { + Ok(v) => v, + Err(e) => { + log::warn!( + "[llm] codex SSE parse skip: {e}; payload preview: {}", + safe_str_slice(payload, 80) + ); + continue; + } + }; + if let Some(delta) = extract_codex_text_delta(&v) { + if !delta.is_empty() { + full_text.push_str(delta); + on_delta(delta); + } + } + let event_type = v.get("type").and_then(|t| t.as_str()).unwrap_or_default(); + if matches!(event_type, "response.done" | "response.completed") { + if let Some(text) = extract_codex_response_text(v.get("response").unwrap_or(&v)) { + *final_text = text; + } + } + } +} + +fn extract_codex_text_delta(event: &Value) -> Option<&str> { + let event_type = event + .get("type") + .and_then(|v| v.as_str()) + .unwrap_or_default(); + if !(event_type.ends_with("output_text.delta") || event_type.ends_with("text.delta")) { + return None; + } + event + .get("delta") + .and_then(|v| v.as_str()) + .or_else(|| event.get("text").and_then(|v| v.as_str())) +} + +fn extract_codex_response_text(response: &Value) -> Option { + if let Some(text) = response.get("output_text").and_then(|v| v.as_str()) { + return Some(clean_polish_output(text)); + } + + let mut pieces = Vec::new(); + let output = response.get("output").and_then(|v| v.as_array())?; + for item in output { + if item.get("type").and_then(|v| v.as_str()) != Some("message") { + continue; + } + let Some(content) = item.get("content").and_then(|v| v.as_array()) else { + continue; + }; + for part in content { + let text = part + .get("text") + .and_then(|v| v.as_str()) + .or_else(|| part.get("content").and_then(|v| v.as_str())); + if let Some(text) = text { + pieces.push(text); + } + } + } + if pieces.is_empty() { + None + } else { + Some(clean_polish_output(&pieces.join(""))) + } +} + +fn decode_jwt_payload(token: &str) -> Result { + let payload = token + .split('.') + .nth(1) + .ok_or_else(|| LLMError::CodexAuth("Codex access token 不是 JWT 格式".into()))?; + let bytes = decode_base64_url(payload) + .map_err(|e| LLMError::CodexAuth(format!("Codex access token payload 解码失败: {e}")))?; + serde_json::from_slice(&bytes) + .map_err(|e| LLMError::CodexAuth(format!("Codex access token payload 不是合法 JSON: {e}"))) +} + +fn decode_base64_url(input: &str) -> Result, String> { + let mut buffer = 0u32; + let mut bits = 0u8; + let mut out = Vec::with_capacity(input.len() * 3 / 4); + for byte in input.bytes() { + let value = match byte { + b'A'..=b'Z' => byte - b'A', + b'a'..=b'z' => byte - b'a' + 26, + b'0'..=b'9' => byte - b'0' + 52, + b'-' => 62, + b'_' => 63, + b'=' => continue, + _ => return Err(format!("invalid base64url byte 0x{byte:02x}")), + }; + buffer = (buffer << 6) | u32::from(value); + bits += 6; + if bits >= 8 { + bits -= 8; + out.push(((buffer >> bits) & 0xff) as u8); + } + } + Ok(out) +} + +fn unix_now_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + /// 把 working_languages + front_app 拼成 system prompt 头部前提: /// # 上下文 /// 用户的工作语言:… @@ -1094,10 +1787,131 @@ pub mod prompts { #[cfg(test)] mod tests { use super::*; + use std::ffi::OsString; use std::io::{Read, Write}; use std::net::TcpListener; + use std::sync::atomic::{AtomicU64, Ordering}; + use std::sync::Mutex as StdMutex; use std::thread; + static CODEX_AUTH_FIXTURE_COUNTER: AtomicU64 = AtomicU64::new(0); + static ENV_LOCK: StdMutex<()> = StdMutex::new(()); + + struct EnvSnapshot { + values: Vec<(&'static str, Option)>, + } + + impl EnvSnapshot { + fn capture(keys: &[&'static str]) -> Self { + Self { + values: keys + .iter() + .map(|key| (*key, std::env::var_os(key))) + .collect(), + } + } + } + + impl Drop for EnvSnapshot { + fn drop(&mut self) { + for (key, value) in &self.values { + match value { + Some(value) => std::env::set_var(key, value), + None => std::env::remove_var(key), + } + } + } + } + + fn unique_codex_auth_path(label: &str) -> PathBuf { + let id = CODEX_AUTH_FIXTURE_COUNTER.fetch_add(1, Ordering::SeqCst); + std::env::temp_dir().join(format!( + "openless-codex-{label}-{}-{}-{id}.json", + std::process::id(), + unix_now_secs() + )) + } + + fn write_codex_auth_fixture(account_id: &str, exp: u64) -> PathBuf { + let path = unique_codex_auth_path(&format!("auth-{account_id}")); + let token = fixture_access_token(account_id, exp); + std::fs::write( + &path, + format!( + r#"{{"tokens":{{"access_token":"{}","account_id":"{}"}}}}"#, + token, account_id + ), + ) + .unwrap(); + path + } + + fn fixture_access_token(account_id: &str, exp: u64) -> String { + let header = base64_url_no_pad(r#"{"alg":"none"}"#); + let payload = base64_url_no_pad(&format!( + r#"{{"exp":{},"https://api.openai.com/auth.chatgpt_account_id":"{}"}}"#, + exp, account_id + )); + format!("{}.{}.sig", header, payload) + } + + fn fixture_access_token_without_account_claim(exp: u64) -> String { + let header = base64_url_no_pad(r#"{"alg":"none"}"#); + let payload = base64_url_no_pad(&format!(r#"{{"exp":{}}}"#, exp)); + format!("{}.{}.sig", header, payload) + } + + fn base64_url_no_pad(input: &str) -> String { + const TABLE: &[u8; 64] = + b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + let bytes = input.as_bytes(); + let mut out = String::new(); + let mut i = 0; + while i < bytes.len() { + let b0 = bytes[i]; + let b1 = bytes.get(i + 1).copied().unwrap_or(0); + let b2 = bytes.get(i + 2).copied().unwrap_or(0); + out.push(TABLE[(b0 >> 2) as usize] as char); + out.push(TABLE[(((b0 & 0b0000_0011) << 4) | (b1 >> 4)) as usize] as char); + if i + 1 < bytes.len() { + out.push(TABLE[(((b1 & 0b0000_1111) << 2) | (b2 >> 6)) as usize] as char); + } + if i + 2 < bytes.len() { + out.push(TABLE[(b2 & 0b0011_1111) as usize] as char); + } + i += 3; + } + out + } + + fn read_http_request(stream: &mut std::net::TcpStream) -> Vec { + let mut buf = [0u8; 8192]; + let mut request = Vec::new(); + loop { + let n = stream.read(&mut buf).unwrap(); + if n == 0 { + break; + } + request.extend_from_slice(&buf[..n]); + let Some(header_end) = request.windows(4).position(|w| w == b"\r\n\r\n") else { + continue; + }; + let header_text = String::from_utf8_lossy(&request[..header_end + 4]); + let content_length = header_text + .lines() + .find_map(|line| { + line.strip_prefix("content-length:") + .or_else(|| line.strip_prefix("Content-Length:")) + }) + .and_then(|value| value.trim().parse::().ok()) + .unwrap_or(0); + if request.len() >= header_end + 4 + content_length { + break; + } + } + request + } + // ──────────────── 对话感知 polish 的 chat 消息构造 ──────────────── // 用户的核心顾虑:让 LLM 拿到上下文但**不要把上下文吐出来**。 // 这里的不变量保证「不复读」靠两层防御: @@ -1129,7 +1943,11 @@ mod tests { let msgs = build_polish_history_messages("SYS", &prior, "USER_NOW"); // 1 system + 3 turns × 2 + 1 current = 8 条 - assert_eq!(msgs.len(), 8, "应该是 system + 3×(user/assistant) + 当前 user"); + assert_eq!( + msgs.len(), + 8, + "应该是 system + 3×(user/assistant) + 当前 user" + ); // [0] system assert_eq!(msgs[0]["role"], "system"); @@ -1359,6 +2177,145 @@ mod tests { } } + #[test] + fn codex_oauth_reads_codex_app_auth_file_without_refresh() { + let exp = unix_now_secs() + 3600; + let auth_path = write_codex_auth_fixture("acct-openless", exp); + + let creds = CodexOAuthCredentials::load_from_path(&auth_path).unwrap(); + + assert_eq!( + creds.access_token, + fixture_access_token("acct-openless", exp) + ); + assert_eq!(creds.account_id, "acct-openless"); + assert!(creds.expires_at_unix_secs > unix_now_secs()); + + let _ = std::fs::remove_file(auth_path); + } + + #[test] + fn codex_oauth_accepts_real_auth_file_without_account_claim() { + let path = unique_codex_auth_path("auth-no-claim"); + let exp = unix_now_secs() + 3600; + let token = fixture_access_token_without_account_claim(exp); + std::fs::write( + &path, + format!( + r#"{{"tokens":{{"access_token":"{}","account_id":"acct-openless"}}}}"#, + token + ), + ) + .unwrap(); + + let creds = CodexOAuthCredentials::load_from_path(&path).unwrap(); + + assert_eq!(creds.account_id, "acct-openless"); + assert_eq!(creds.expires_at_unix_secs, exp); + let _ = std::fs::remove_file(path); + } + + #[test] + fn codex_oauth_rejects_mismatched_account_claim() { + let path = unique_codex_auth_path("auth-mismatch"); + let token = fixture_access_token("acct-a", unix_now_secs() + 3600); + std::fs::write( + &path, + format!( + r#"{{"tokens":{{"access_token":"{}","account_id":"acct-b"}}}}"#, + token + ), + ) + .unwrap(); + + let err = CodexOAuthCredentials::load_from_path(&path).unwrap_err(); + + assert!(matches!(err, LLMError::CodexAuth(_))); + let _ = std::fs::remove_file(path); + } + + #[test] + fn default_codex_auth_path_falls_back_to_userprofile_when_home_missing() { + let _guard = ENV_LOCK.lock().unwrap(); + let _env = EnvSnapshot::capture(&[ + "OPENLESS_CODEX_AUTH_PATH", + "HOME", + "USERPROFILE", + "HOMEDRIVE", + "HOMEPATH", + ]); + let userprofile = std::env::temp_dir().join("openless-codex-userprofile"); + std::env::remove_var("OPENLESS_CODEX_AUTH_PATH"); + std::env::remove_var("HOME"); + std::env::set_var("USERPROFILE", &userprofile); + std::env::remove_var("HOMEDRIVE"); + std::env::remove_var("HOMEPATH"); + + assert_eq!( + default_codex_auth_path(), + userprofile.join(".codex").join("auth.json") + ); + } + + #[tokio::test] + async fn codex_oauth_provider_streams_text_from_codex_responses() { + let auth_path = write_codex_auth_fixture("acct-openless", unix_now_secs() + 3600); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let request = read_http_request(&mut stream); + let request_text = String::from_utf8_lossy(&request); + let request_text_lower = request_text.to_ascii_lowercase(); + assert!(request_text.starts_with("POST /codex/responses HTTP/1.1")); + assert!(request_text_lower.contains("authorization: bearer ")); + assert!(request_text_lower.contains("chatgpt-account-id: acct-openless")); + assert!(request_text_lower.contains("openai-beta: responses=experimental")); + assert!(request_text_lower.contains("originator: codex_cli_rs")); + assert!(request_text.contains(r#""store":false"#)); + assert!(request_text.contains(r#""stream":true"#)); + assert!(request_text.contains(r#""role":"developer"#)); + assert!(request_text.contains(r#""type":"input_text"#)); + assert!(!request_text.contains(r#""temperature":"#)); + + let body = concat!( + "data: {\"type\":\"response.output_text.delta\",\"delta\":\"最终\"}\n\n", + "data: {\"type\":\"response.output_text.delta\",\"delta\":\"文本。\"}\n\n", + "data: {\"type\":\"response.completed\",\"response\":{\"output\":[]}}\n\n" + ); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + stream.write_all(response.as_bytes()).unwrap(); + }); + + let provider = CodexOAuthLLMProvider::new( + CodexOAuthConfig::new("gpt-5.5") + .with_base_url(format!("http://{}", addr)) + .with_auth_path(auth_path.clone()), + ); + let output = provider + .polish( + "原文", + PolishMode::Raw, + &[], + &[], + ChineseScriptPreference::Auto, + OutputLanguagePreference::Auto, + None, + &[], + ) + .await + .unwrap(); + + assert_eq!(output, "最终文本。"); + server.join().unwrap(); + let _ = std::fs::remove_file(auth_path); + } + #[tokio::test] async fn chat_completion_omits_authorization_when_api_key_is_empty() { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index bf1a4631..07776e34 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -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)', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index 45c2836a..8100df4b 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -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(無料モデル)', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 1fbf83a5..31545f98 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -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(무료 모델)', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index d9da9665..7a1765ac 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -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(免费模型)', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index d9417145..a838e2c2 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -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(免費模型)', diff --git a/openless-all/app/src/pages/Overview.tsx b/openless-all/app/src/pages/Overview.tsx index 8316bb68..ea26b74b 100644 --- a/openless-all/app/src/pages/Overview.tsx +++ b/openless-all/app/src/pages/Overview.tsx @@ -39,6 +39,7 @@ const LLM_NAME_KEY_BY_ID: Record = { deepseek: 'deepseek', siliconflow: 'siliconflow', openai: 'openai', + codex_oauth: 'codexOAuth', mimo: 'mimo', cometapi: 'cometapi', openrouterFree: 'openrouterFree', diff --git a/openless-all/app/src/pages/Settings.tsx b/openless-all/app/src/pages/Settings.tsx index 89124acb..5ae5f5d8 100644 --- a/openless-all/app/src/pages/Settings.tsx +++ b/openless-all/app/src/pages/Settings.tsx @@ -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`)。 @@ -1351,6 +1357,7 @@ function ProvidersSection() { // 否则受控 - - + {codexOAuthSelected ? ( +
+ {t('settings.providers.codexOAuthNotice')} +
+ ) : ( + <> + + + + )} setLlmModelRevision(v => v + 1)} />