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)} />