mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
完善 Codex 凭证与健康检查
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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))?;
|
||||
|
||||
Reference in New Issue
Block a user