完善 Codex 凭证与健康检查

This commit is contained in:
jiesen
2025-12-21 20:12:03 +07:00
parent 34d9f52789
commit 043ad052bf
6 changed files with 98 additions and 31 deletions
@@ -742,6 +742,7 @@ pub fn add_codex_oauth_credential(
db: State<'_, DbConnection>,
pool_service: State<'_, ProviderPoolServiceState>,
creds_file_path: String,
api_base_url: Option<String>,
name: Option<String>,
) -> Result<ProviderCredential, String> {
// 复制并重命名文件到应用存储目录
@@ -752,6 +753,7 @@ pub fn add_codex_oauth_credential(
"codex",
CredentialData::CodexOAuth {
creds_file_path: stored_file_path,
api_base_url,
},
name,
Some(true),
@@ -1236,6 +1238,7 @@ pub async fn get_codex_auth_url_and_wait(
"codex",
CredentialData::CodexOAuth {
creds_file_path: result.creds_file_path,
api_base_url: None,
},
name,
Some(true),
@@ -1276,6 +1279,7 @@ pub async fn start_codex_oauth_login(
"codex",
CredentialData::CodexOAuth {
creds_file_path: result.creds_file_path,
api_base_url: None,
},
name,
Some(true),
+12 -3
View File
@@ -72,7 +72,12 @@ pub enum CredentialData {
excluded_models: Vec<String>,
},
/// Codex OAuth 凭证(OpenAI Codex)
CodexOAuth { creds_file_path: String },
CodexOAuth {
creds_file_path: String,
/// API Base URL(可选,默认使用凭证文件中的配置)
#[serde(default)]
api_base_url: Option<String>,
},
/// Claude OAuth 凭证(Anthropic OAuth)
ClaudeOAuth { creds_file_path: String },
/// iFlow OAuth 凭证
@@ -113,7 +118,9 @@ impl CredentialData {
CredentialData::GeminiApiKey { api_key, .. } => {
format!("Gemini API Key: {}", mask_key(api_key))
}
CredentialData::CodexOAuth { creds_file_path } => {
CredentialData::CodexOAuth {
creds_file_path, ..
} => {
format!("Codex OAuth: {}", mask_path(creds_file_path))
}
CredentialData::ClaudeOAuth { creds_file_path } => {
@@ -550,7 +557,9 @@ pub fn get_oauth_creds_path(cred: &CredentialData) -> Option<String> {
CredentialData::AntigravityOAuth {
creds_file_path, ..
} => Some(creds_file_path.clone()),
CredentialData::CodexOAuth { creds_file_path } => Some(creds_file_path.clone()),
CredentialData::CodexOAuth {
creds_file_path, ..
} => Some(creds_file_path.clone()),
CredentialData::ClaudeOAuth { creds_file_path } => Some(creds_file_path.clone()),
CredentialData::IFlowOAuth { creds_file_path } => Some(creds_file_path.clone()),
CredentialData::IFlowCookie { creds_file_path } => Some(creds_file_path.clone()),
+35 -5
View File
@@ -455,13 +455,27 @@ impl CodexProvider {
.filter(|s| !s.is_empty())
}
fn build_responses_url(base_url: &str) -> String {
pub(crate) fn build_responses_url(base_url: &str) -> String {
let base = base_url.trim_end_matches('/');
// 规则说明:
// - 如果 base_url 以 /v1 结尾:直接拼 /responses
// - 如果 base_url 只有域名(path 为空或 /):拼 /v1/responses(OpenAI 标准)
// - 如果 base_url 已包含路径前缀(如 https://yunyi.cfd/codex):认为前缀已包含路由信息,拼 /responses
if base.ends_with("/v1") {
format!("{}/responses", base)
} else {
format!("{}/v1/responses", base)
return format!("{}/responses", base);
}
if let Ok(parsed) = url::Url::parse(base) {
let path = parsed.path().trim_end_matches('/');
if path.is_empty() || path == "/" {
return format!("{}/v1/responses", base);
}
return format!("{}/responses", base);
}
// 兜底:保持旧行为
format!("{}/v1/responses", base)
}
/// Load credentials from the default path
@@ -1092,7 +1106,18 @@ impl CodexProvider {
.header("Openai-Beta", "responses=experimental")
.json(&codex_request);
if matches!(mode, AuthMode::OAuth) {
// 部分三方 Codex 代理(如 Yunyi)会依赖 Codex CLI 的特征 headers;
// 仅在 OAuth 模式或显式配置了自定义 base_url 时附加,避免影响 OpenAI 官方 Key 模式。
let should_add_codex_cli_headers = matches!(mode, AuthMode::OAuth)
|| (matches!(mode, AuthMode::ApiKey)
&& self
.credentials
.api_base_url
.as_deref()
.map(|s| !s.trim().is_empty())
.unwrap_or(false));
if should_add_codex_cli_headers {
req = req
.header("Version", "0.21.0")
.header(
@@ -1101,6 +1126,7 @@ impl CodexProvider {
)
.header("Originator", "codex_cli_rs")
.header("Session_id", uuid::Uuid::new_v4().to_string())
.header("Conversation_id", uuid::Uuid::new_v4().to_string())
// Add account ID header if available
.header(
"Chatgpt-Account-Id",
@@ -1459,6 +1485,10 @@ mod tests {
CodexProvider::build_responses_url("https://example.com/v1/"),
"https://example.com/v1/responses"
);
assert_eq!(
CodexProvider::build_responses_url("https://yunyi.cfd/codex"),
"https://yunyi.cfd/codex/responses"
);
}
#[tokio::test]
@@ -383,6 +383,7 @@ pub async fn management_add_credential(
if let Some(token_file) = request.token_file {
CredentialData::CodexOAuth {
creds_file_path: token_file,
api_base_url: request.base_url.clone(),
}
} else {
return (
+40 -19
View File
@@ -487,8 +487,12 @@ impl ProviderPoolService {
self.check_gemini_api_key_health(api_key, base_url.as_deref(), model)
.await
}
CredentialData::CodexOAuth { creds_file_path } => {
self.check_codex_health(creds_file_path, model).await
CredentialData::CodexOAuth {
creds_file_path,
api_base_url,
} => {
self.check_codex_health(creds_file_path, api_base_url.as_deref(), model)
.await
}
CredentialData::ClaudeOAuth { creds_file_path } => {
self.check_claude_oauth_health(creds_file_path, model).await
@@ -925,7 +929,12 @@ impl ProviderPoolService {
// Codex 健康检查
// 支持 Yunyi 等代理使用 responses API 格式
async fn check_codex_health(&self, creds_path: &str, model: &str) -> Result<(), String> {
async fn check_codex_health(
&self,
creds_path: &str,
override_base_url: Option<&str>,
model: &str,
) -> Result<(), String> {
use crate::providers::codex::CodexProvider;
let mut provider = CodexProvider::new();
@@ -941,29 +950,34 @@ impl ProviderPoolService {
)
})?;
// 获取 base_url,如果设置了则使用 responses API,否则使用 OpenAI chat/completions
let base_url = provider
.credentials
.api_base_url
.as_deref()
// 优先使用 override_base_url(来自 CredentialData),其次使用凭证文件中的配置
let base_url = override_base_url
.map(|s| s.trim())
.filter(|s| !s.is_empty());
.filter(|s| !s.is_empty())
.or_else(|| {
provider
.credentials
.api_base_url
.as_deref()
.map(|s| s.trim())
.filter(|s| !s.is_empty())
});
match base_url {
Some(base) => {
// 使用自定义 base_url (如 Yunyi),使用 responses API 格式
let base = base.trim_end_matches('/');
let url = if base.ends_with("/v1") {
format!("{}/responses", base)
} else {
format!("{}/v1/responses", base)
};
// 使用自定义 base_url (如 Yunyi),与 CodexProvider 的 URL/headers 行为保持一致
let url = CodexProvider::build_responses_url(base);
// Codex/Yunyi 使用 responses API 格式
// Codex/Yunyi 使用 responses API 格式;云驿等代理要求 stream 必须为 true
let request_body = serde_json::json!({
"model": model,
"input": "Say OK",
"max_output_tokens": 10
"input": [{
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": "Say OK"}]
}],
"max_output_tokens": 10,
"stream": true
});
tracing::debug!(
@@ -979,6 +993,13 @@ impl ProviderPoolService {
.header("Content-Type", "application/json")
.header("Accept", "text/event-stream")
.header("Openai-Beta", "responses=experimental")
.header("Originator", "codex_cli_rs")
.header("Session_id", uuid::Uuid::new_v4().to_string())
.header("Conversation_id", uuid::Uuid::new_v4().to_string())
.header(
"User-Agent",
"codex_cli_rs/0.77.0 (ProxyCast health check; Mac OS; arm64)",
)
.json(&request_body)
.timeout(self.health_check_timeout)
.send()
@@ -282,9 +282,9 @@ impl TokenCacheService {
last_refresh_error: None,
})
}
CredentialData::CodexOAuth { creds_file_path } => {
self.refresh_codex(creds_file_path).await
}
CredentialData::CodexOAuth {
creds_file_path, ..
} => self.refresh_codex(creds_file_path).await,
CredentialData::ClaudeOAuth { creds_file_path } => {
self.refresh_claude_oauth(creds_file_path).await
}
@@ -764,7 +764,9 @@ impl TokenCacheService {
refresh_error_count: 0,
last_refresh_error: None,
}),
CredentialData::CodexOAuth { creds_file_path } => {
CredentialData::CodexOAuth {
creds_file_path, ..
} => {
let content = tokio::fs::read_to_string(creds_file_path)
.await
.map_err(|e| format!("读取 Codex 凭证文件失败: {}", e))?;