mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
Codex 支持 API Key 凭证
This commit is contained in:
@@ -17,6 +17,7 @@ const OPENAI_TOKEN_URL: &str = "https://auth.openai.com/oauth/token";
|
||||
const OPENAI_CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
|
||||
const DEFAULT_CALLBACK_PORT: u16 = 1455;
|
||||
const CODEX_API_BASE_URL: &str = "https://chatgpt.com/backend-api/codex";
|
||||
const DEFAULT_API_BASE_URL: &str = "https://api.openai.com";
|
||||
|
||||
/// Codex OAuth credentials storage
|
||||
///
|
||||
@@ -26,6 +27,10 @@ const CODEX_API_BASE_URL: &str = "https://chatgpt.com/backend-api/codex";
|
||||
/// Supports multiple field name formats:
|
||||
/// - snake_case: `refresh_token`, `access_token`, `id_token`, `account_id`, `last_refresh`
|
||||
/// - camelCase: `refreshToken`, `accessToken`, `idToken`, `accountId`, `lastRefresh`
|
||||
///
|
||||
/// 同时兼容 Codex CLI 的 API Key 登录格式:
|
||||
/// - `api_key` / `apiKey`
|
||||
/// - `api_base_url` / `apiBaseUrl`
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CodexCredentials {
|
||||
/// JWT ID token containing user claims
|
||||
@@ -45,6 +50,12 @@ pub struct CodexCredentials {
|
||||
alias = "refreshToken"
|
||||
)]
|
||||
pub refresh_token: Option<String>,
|
||||
/// API Key(Codex CLI 支持通过 API Key 登录)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none", alias = "apiKey")]
|
||||
pub api_key: Option<String>,
|
||||
/// API Base URL(可选)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none", alias = "apiBaseUrl")]
|
||||
pub api_base_url: Option<String>,
|
||||
/// OpenAI account identifier
|
||||
#[serde(default, skip_serializing_if = "Option::is_none", alias = "accountId")]
|
||||
pub account_id: Option<String>,
|
||||
@@ -83,6 +94,8 @@ impl Default for CodexCredentials {
|
||||
id_token: None,
|
||||
access_token: None,
|
||||
refresh_token: None,
|
||||
api_key: None,
|
||||
api_base_url: None,
|
||||
account_id: None,
|
||||
last_refresh: None,
|
||||
email: None,
|
||||
@@ -428,6 +441,24 @@ impl CodexProvider {
|
||||
CODEX_API_BASE_URL
|
||||
}
|
||||
|
||||
/// 获取已配置的 API Key(trim 后的非空值)
|
||||
fn get_api_key(&self) -> Option<&str> {
|
||||
self.credentials
|
||||
.api_key
|
||||
.as_deref()
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
fn build_responses_url(base_url: &str) -> String {
|
||||
let base = base_url.trim_end_matches('/');
|
||||
if base.ends_with("/v1") {
|
||||
format!("{}/responses", base)
|
||||
} else {
|
||||
format!("{}/v1/responses", base)
|
||||
}
|
||||
}
|
||||
|
||||
/// Load credentials from the default path
|
||||
pub async fn load_credentials(&mut self) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
let path = Self::default_creds_path();
|
||||
@@ -457,9 +488,14 @@ impl CodexProvider {
|
||||
})?;
|
||||
|
||||
// 检查关键字段
|
||||
if creds.refresh_token.is_none() {
|
||||
let has_api_key = creds
|
||||
.api_key
|
||||
.as_deref()
|
||||
.map(|s| !s.trim().is_empty())
|
||||
.unwrap_or(false);
|
||||
if creds.refresh_token.is_none() && !has_api_key {
|
||||
tracing::warn!(
|
||||
"[CODEX] 凭证文件缺少 refresh_token 字段。支持的字段名: refresh_token, refreshToken"
|
||||
"[CODEX] 凭证文件缺少 refresh_token/api_key 字段。支持的字段名: refresh_token, refreshToken, api_key, apiKey"
|
||||
);
|
||||
// 打印文件中的顶级字段名,帮助调试
|
||||
if let Ok(json_value) = serde_json::from_str::<serde_json::Value>(&content) {
|
||||
@@ -471,9 +507,10 @@ impl CodexProvider {
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
"[CODEX] 凭证加载成功: has_access={}, has_refresh={}, email={:?}, path={:?}",
|
||||
"[CODEX] 凭证加载成功: has_access={}, has_refresh={}, has_api_key={}, email={:?}, path={:?}",
|
||||
creds.access_token.is_some(),
|
||||
creds.refresh_token.is_some(),
|
||||
has_api_key,
|
||||
creds.email,
|
||||
path
|
||||
);
|
||||
@@ -505,6 +542,11 @@ impl CodexProvider {
|
||||
|
||||
/// Check if the access token is expired
|
||||
pub fn is_token_expired(&self) -> bool {
|
||||
// API Key 模式:不涉及过期概念
|
||||
if self.get_api_key().is_some() {
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Some(expires_str) = &self.credentials.expires_at {
|
||||
if let Ok(expires) = chrono::DateTime::parse_from_rfc3339(expires_str) {
|
||||
let now = chrono::Utc::now();
|
||||
@@ -518,6 +560,9 @@ impl CodexProvider {
|
||||
|
||||
/// Check if credentials are valid (has access token and not expired)
|
||||
pub fn is_valid(&self) -> bool {
|
||||
if self.get_api_key().is_some() {
|
||||
return true;
|
||||
}
|
||||
self.credentials.access_token.is_some() && !self.is_token_expired()
|
||||
}
|
||||
|
||||
@@ -608,6 +653,8 @@ impl CodexProvider {
|
||||
id_token,
|
||||
access_token: Some(access_token),
|
||||
refresh_token,
|
||||
api_key: None,
|
||||
api_base_url: None,
|
||||
account_id,
|
||||
last_refresh: Some(chrono::Utc::now().to_rfc3339()),
|
||||
email,
|
||||
@@ -627,10 +674,15 @@ impl CodexProvider {
|
||||
|
||||
/// Refresh the access token using the refresh token
|
||||
pub async fn refresh_token(&mut self) -> Result<String, Box<dyn Error + Send + Sync>> {
|
||||
// API Key 模式无需刷新
|
||||
if let Some(api_key) = self.get_api_key() {
|
||||
return Ok(api_key.to_string());
|
||||
}
|
||||
|
||||
let refresh_token = self.credentials.refresh_token.as_ref().ok_or_else(|| {
|
||||
create_config_error(
|
||||
"没有可用的 refresh_token。请确保凭证文件包含 refresh_token 或 refreshToken 字段,\
|
||||
或使用 OAuth 登录功能重新获取凭证",
|
||||
"没有可用的 refresh_token 或 api_key。请确保凭证文件包含 refresh_token/refreshToken 或 api_key/apiKey 字段,\
|
||||
或使用 OAuth 登录功能重新获取凭证(需要 refresh_token)",
|
||||
)
|
||||
})?;
|
||||
|
||||
@@ -768,6 +820,11 @@ impl CodexProvider {
|
||||
|
||||
/// Check if token needs refresh (expiring within the specified duration)
|
||||
pub fn needs_refresh(&self, lead_time: chrono::Duration) -> bool {
|
||||
// API Key 模式无需刷新
|
||||
if self.get_api_key().is_some() {
|
||||
return false;
|
||||
}
|
||||
|
||||
if self.credentials.access_token.is_none() {
|
||||
return true;
|
||||
}
|
||||
@@ -788,6 +845,11 @@ impl CodexProvider {
|
||||
/// This is the recommended method to call before making API requests.
|
||||
/// It will automatically refresh the token if it's expired or about to expire.
|
||||
pub async fn ensure_valid_token(&mut self) -> Result<String, Box<dyn Error + Send + Sync>> {
|
||||
// 兼容 Codex CLI 的 API Key 登录:auth.json 只有 api_key,没有 refresh_token
|
||||
if let Some(api_key) = self.get_api_key() {
|
||||
return Ok(api_key.to_string());
|
||||
}
|
||||
|
||||
// Refresh if token expires within 5 minutes
|
||||
let lead_time = chrono::Duration::minutes(5);
|
||||
|
||||
@@ -811,6 +873,11 @@ impl CodexProvider {
|
||||
|
||||
/// Get the access token, refreshing if necessary
|
||||
pub async fn get_access_token(&mut self) -> Result<String, Box<dyn Error + Send + Sync>> {
|
||||
// API Key 模式直接返回
|
||||
if let Some(api_key) = self.get_api_key() {
|
||||
return Ok(api_key.to_string());
|
||||
}
|
||||
|
||||
if self.is_token_expired() {
|
||||
self.refresh_token().await?;
|
||||
}
|
||||
@@ -919,42 +986,68 @@ impl CodexProvider {
|
||||
&self,
|
||||
request: &serde_json::Value,
|
||||
) -> Result<reqwest::Response, Box<dyn Error + Send + Sync>> {
|
||||
let token = self
|
||||
.credentials
|
||||
.access_token
|
||||
.as_ref()
|
||||
.ok_or("No access token available")?;
|
||||
enum AuthMode {
|
||||
ApiKey,
|
||||
OAuth,
|
||||
}
|
||||
|
||||
let (token, mode) = match self.get_api_key() {
|
||||
Some(api_key) => (api_key, AuthMode::ApiKey),
|
||||
None => (
|
||||
self.credentials
|
||||
.access_token
|
||||
.as_deref()
|
||||
.ok_or("No access token or api_key available")?,
|
||||
AuthMode::OAuth,
|
||||
),
|
||||
};
|
||||
|
||||
// Build the Codex API URL
|
||||
let url = format!("{}/responses", CODEX_API_BASE_URL);
|
||||
let url = match mode {
|
||||
AuthMode::ApiKey => {
|
||||
let base_url = self
|
||||
.credentials
|
||||
.api_base_url
|
||||
.as_deref()
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or(DEFAULT_API_BASE_URL);
|
||||
Self::build_responses_url(base_url)
|
||||
}
|
||||
AuthMode::OAuth => format!("{}/responses", CODEX_API_BASE_URL),
|
||||
};
|
||||
|
||||
// Transform OpenAI chat completion request to Codex format
|
||||
let codex_request = transform_to_codex_format(request)?;
|
||||
|
||||
tracing::debug!("[CODEX] Calling API: {}", url);
|
||||
|
||||
let resp = self
|
||||
let mut req = self
|
||||
.client
|
||||
.post(&url)
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Accept", "text/event-stream")
|
||||
.header("Version", "0.21.0")
|
||||
.header("Openai-Beta", "responses=experimental")
|
||||
.header(
|
||||
"User-Agent",
|
||||
"codex_cli_rs/0.50.0 (Mac OS 26.0.1; arm64) Apple_Terminal/464",
|
||||
)
|
||||
.header("Originator", "codex_cli_rs")
|
||||
.header("Session_id", uuid::Uuid::new_v4().to_string())
|
||||
// Add account ID header if available
|
||||
.header(
|
||||
"Chatgpt-Account-Id",
|
||||
self.credentials.account_id.as_deref().unwrap_or(""),
|
||||
)
|
||||
.json(&codex_request)
|
||||
.send()
|
||||
.await?;
|
||||
.json(&codex_request);
|
||||
|
||||
if matches!(mode, AuthMode::OAuth) {
|
||||
req = req
|
||||
.header("Version", "0.21.0")
|
||||
.header(
|
||||
"User-Agent",
|
||||
"codex_cli_rs/0.50.0 (Mac OS 26.0.1; arm64) Apple_Terminal/464",
|
||||
)
|
||||
.header("Originator", "codex_cli_rs")
|
||||
.header("Session_id", uuid::Uuid::new_v4().to_string())
|
||||
// Add account ID header if available
|
||||
.header(
|
||||
"Chatgpt-Account-Id",
|
||||
self.credentials.account_id.as_deref().unwrap_or(""),
|
||||
);
|
||||
}
|
||||
|
||||
let resp = req.send().await?;
|
||||
|
||||
Ok(resp)
|
||||
}
|
||||
@@ -1163,6 +1256,7 @@ mod tests {
|
||||
let creds = CodexCredentials::default();
|
||||
assert!(creds.access_token.is_none());
|
||||
assert!(creds.refresh_token.is_none());
|
||||
assert!(creds.api_key.is_none());
|
||||
assert_eq!(creds.r#type, "codex");
|
||||
}
|
||||
|
||||
@@ -1232,6 +1326,32 @@ mod tests {
|
||||
assert_eq!(creds.expires_at, Some("2024-12-31T23:59:59Z".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_codex_credentials_api_key_fields() {
|
||||
let json = r#"{
|
||||
"api_key": "sk-test",
|
||||
"api_base_url": "https://api.openai.com/v1"
|
||||
}"#;
|
||||
|
||||
let creds: CodexCredentials = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(creds.api_key, Some("sk-test".to_string()));
|
||||
assert_eq!(
|
||||
creds.api_base_url,
|
||||
Some("https://api.openai.com/v1".to_string())
|
||||
);
|
||||
|
||||
let json2 = r#"{
|
||||
"apiKey": "sk-test-2",
|
||||
"apiBaseUrl": "https://example.com/v1"
|
||||
}"#;
|
||||
let creds2: CodexCredentials = serde_json::from_str(json2).unwrap();
|
||||
assert_eq!(creds2.api_key, Some("sk-test-2".to_string()));
|
||||
assert_eq!(
|
||||
creds2.api_base_url,
|
||||
Some("https://example.com/v1".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_codex_credentials_expires_at_alias() {
|
||||
// 测试 expires_at 字段的多种别名
|
||||
@@ -1264,6 +1384,31 @@ mod tests {
|
||||
assert!(provider.credentials.access_token.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_responses_url() {
|
||||
assert_eq!(
|
||||
CodexProvider::build_responses_url("https://api.openai.com"),
|
||||
"https://api.openai.com/v1/responses"
|
||||
);
|
||||
assert_eq!(
|
||||
CodexProvider::build_responses_url("https://api.openai.com/v1"),
|
||||
"https://api.openai.com/v1/responses"
|
||||
);
|
||||
assert_eq!(
|
||||
CodexProvider::build_responses_url("https://example.com/v1/"),
|
||||
"https://example.com/v1/responses"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_ensure_valid_token_prefers_api_key() {
|
||||
let mut provider = CodexProvider::new();
|
||||
provider.credentials.api_key = Some("sk-test".to_string());
|
||||
|
||||
let token = provider.ensure_valid_token().await.unwrap();
|
||||
assert_eq!(token, "sk-test");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_auth_url() {
|
||||
let provider = CodexProvider::new();
|
||||
@@ -1342,7 +1487,12 @@ mod tests {
|
||||
// No expiry - should be considered expired
|
||||
assert!(provider.is_token_expired());
|
||||
|
||||
// API Key 模式 - 不应视为过期
|
||||
provider.credentials.api_key = Some("sk-test".to_string());
|
||||
assert!(!provider.is_token_expired());
|
||||
|
||||
// Expired token
|
||||
provider.credentials.api_key = None;
|
||||
provider.credentials.expires_at = Some("2020-01-01T00:00:00Z".to_string());
|
||||
assert!(provider.is_token_expired());
|
||||
|
||||
|
||||
@@ -852,28 +852,15 @@ impl ProviderPoolService {
|
||||
.map_err(|e| format!("获取 Codex Token 失败: {}", e))?;
|
||||
|
||||
// 使用 OpenAI 兼容 API 进行健康检查
|
||||
let url = "https://api.openai.com/v1/chat/completions";
|
||||
let request_body = serde_json::json!({
|
||||
"model": model,
|
||||
"messages": [{"role": "user", "content": "Say OK"}],
|
||||
"max_tokens": 10
|
||||
});
|
||||
// 兼容 Codex CLI API Key 模式:如果 auth.json 提供 api_base_url,则优先使用
|
||||
let base_url = provider
|
||||
.credentials
|
||||
.api_base_url
|
||||
.as_deref()
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty());
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.post(url)
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.json(&request_body)
|
||||
.timeout(self.health_check_timeout)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("请求失败: {}", e))?;
|
||||
|
||||
if response.status().is_success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("HTTP {}", response.status()))
|
||||
}
|
||||
self.check_openai_health(&token, base_url, model).await
|
||||
}
|
||||
|
||||
// Claude OAuth 健康检查
|
||||
@@ -1078,12 +1065,20 @@ impl ProviderPoolService {
|
||||
let creds: serde_json::Value =
|
||||
serde_json::from_str(&content).map_err(|e| format!("解析凭证文件失败: {}", e))?;
|
||||
|
||||
let has_access_token = creds
|
||||
let has_api_key = creds
|
||||
.get("apiKey")
|
||||
.or_else(|| creds.get("api_key"))
|
||||
.map(|v| v.as_str().is_some())
|
||||
.unwrap_or(false);
|
||||
|
||||
let has_oauth_access_token = creds
|
||||
.get("accessToken")
|
||||
.or_else(|| creds.get("access_token"))
|
||||
.map(|v| v.as_str().is_some())
|
||||
.unwrap_or(false);
|
||||
|
||||
let has_access_token = has_oauth_access_token || has_api_key;
|
||||
|
||||
let has_refresh_token = creds
|
||||
.get("refreshToken")
|
||||
.or_else(|| creds.get("refresh_token"))
|
||||
@@ -1114,6 +1109,20 @@ impl ProviderPoolService {
|
||||
(has_access_token, None)
|
||||
}
|
||||
}
|
||||
"codex" => {
|
||||
// Codex: 兼容 OAuth token 或 Codex CLI 的 API Key 登录
|
||||
if has_api_key {
|
||||
(true, None)
|
||||
} else {
|
||||
let expires_at = creds
|
||||
.get("expiresAt")
|
||||
.or_else(|| creds.get("expires_at"))
|
||||
.or_else(|| creds.get("expired"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
(has_oauth_access_token, expires_at)
|
||||
}
|
||||
}
|
||||
_ => (has_access_token, None),
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user