mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-15 08:06:21 +00:00
refactor: remove legacy compatibility
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -4,10 +4,9 @@ use axum::{
|
||||
response::IntoResponse,
|
||||
Extension,
|
||||
};
|
||||
use chrono::{SecondsFormat, Utc};
|
||||
use secrecy::ExposeSecret;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Map, Value};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::{
|
||||
codex::accounts::repository::StoredAccount, platform::http::request_id::RequestId,
|
||||
@@ -24,12 +23,6 @@ pub struct AccountExportQuery {
|
||||
pub format: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum AccountExportFormat {
|
||||
Native,
|
||||
Sub2Api,
|
||||
}
|
||||
|
||||
pub async fn export_accounts(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_id): Extension<RequestId>,
|
||||
@@ -37,17 +30,8 @@ pub async fn export_accounts(
|
||||
Query(query): Query<AccountExportQuery>,
|
||||
) -> Result<impl IntoResponse, AdminError> {
|
||||
let request_id = request_id.as_str().to_string();
|
||||
let format = match parse_account_export_format(query.format.as_deref()) {
|
||||
Ok(format) => format,
|
||||
Err(message) => {
|
||||
return Err(AdminError::new(
|
||||
StatusCode::BAD_REQUEST,
|
||||
40001,
|
||||
message,
|
||||
request_id,
|
||||
));
|
||||
}
|
||||
};
|
||||
validate_account_export_format(query.format.as_deref())
|
||||
.map_err(|message| AdminError::new(StatusCode::BAD_REQUEST, 40001, message, &request_id))?;
|
||||
require_admin_session(&state, &headers, &request_id).await?;
|
||||
|
||||
let ids = account_export_ids(query.ids.as_deref());
|
||||
@@ -59,20 +43,16 @@ pub async fn export_accounts(
|
||||
.map_err(|error| account_service_error(error, &request_id))?;
|
||||
|
||||
// 账号导出会返回可重新导入的 OAuth token;只允许 admin session 访问,不写入日志。
|
||||
let data = match format {
|
||||
AccountExportFormat::Native => native_account_export(accounts),
|
||||
AccountExportFormat::Sub2Api => sub2api_account_export(accounts),
|
||||
};
|
||||
let data = native_account_export(accounts);
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(data, request_id),
|
||||
))
|
||||
}
|
||||
|
||||
fn parse_account_export_format(value: Option<&str>) -> Result<AccountExportFormat, &'static str> {
|
||||
fn validate_account_export_format(value: Option<&str>) -> Result<(), &'static str> {
|
||||
match value.unwrap_or("native") {
|
||||
"" | "native" | "full" => Ok(AccountExportFormat::Native),
|
||||
"sub2api" => Ok(AccountExportFormat::Sub2Api),
|
||||
"" | "native" => Ok(()),
|
||||
_ => Err("Unsupported account export format"),
|
||||
}
|
||||
}
|
||||
@@ -103,61 +83,3 @@ fn native_export_account(account: StoredAccount) -> Value {
|
||||
"updatedAt": account.updated_at.to_rfc3339(),
|
||||
})
|
||||
}
|
||||
|
||||
fn sub2api_account_export(accounts: Vec<StoredAccount>) -> Value {
|
||||
json!({
|
||||
"exported_at": Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true),
|
||||
"proxies": [],
|
||||
"accounts": accounts.into_iter().map(sub2api_export_account).collect::<Vec<_>>(),
|
||||
"type": "sub2api-data",
|
||||
"version": 1,
|
||||
})
|
||||
}
|
||||
|
||||
fn sub2api_export_account(account: StoredAccount) -> Value {
|
||||
let mut credentials = Map::new();
|
||||
credentials.insert(
|
||||
"access_token".to_string(),
|
||||
Value::String(account.access_token.expose_secret().to_string()),
|
||||
);
|
||||
insert_optional_string(
|
||||
&mut credentials,
|
||||
"refresh_token",
|
||||
account
|
||||
.refresh_token
|
||||
.as_ref()
|
||||
.map(|token| token.expose_secret().to_string()),
|
||||
);
|
||||
insert_optional_string(&mut credentials, "email", account.email.clone());
|
||||
insert_optional_string(&mut credentials, "chatgpt_account_id", account.account_id);
|
||||
insert_optional_string(&mut credentials, "chatgpt_user_id", account.user_id);
|
||||
insert_optional_string(&mut credentials, "plan_type", account.plan_type);
|
||||
insert_optional_string(
|
||||
&mut credentials,
|
||||
"expires_at",
|
||||
account
|
||||
.access_token_expires_at
|
||||
.map(|value| value.to_rfc3339()),
|
||||
);
|
||||
|
||||
json!({
|
||||
"name": account
|
||||
.label
|
||||
.as_deref()
|
||||
.filter(|label| !label.trim().is_empty())
|
||||
.map(str::to_string)
|
||||
.or_else(|| account.email.clone())
|
||||
.unwrap_or_else(|| account.id.clone()),
|
||||
"platform": "openai",
|
||||
"type": "oauth",
|
||||
"credentials": credentials,
|
||||
"concurrency": 0,
|
||||
"priority": 0,
|
||||
})
|
||||
}
|
||||
|
||||
fn insert_optional_string(map: &mut Map<String, Value>, key: &str, value: Option<String>) {
|
||||
if let Some(value) = value.filter(|value| !value.is_empty()) {
|
||||
map.insert(key.to_string(), Value::String(value));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
use axum::{
|
||||
body::Bytes,
|
||||
extract::State,
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::IntoResponse,
|
||||
Extension, Json,
|
||||
Extension,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -16,10 +17,9 @@ use super::super::{require_admin_session, AdminEnvelope, AdminError, AdminRespon
|
||||
use super::account_status_value;
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct HealthCheckRequest {
|
||||
pub ids: Option<Vec<String>>,
|
||||
#[serde(alias = "stagger_ms")]
|
||||
pub stagger_ms: Option<u64>,
|
||||
pub concurrency: Option<u8>,
|
||||
}
|
||||
@@ -56,10 +56,10 @@ pub async fn health_check_accounts(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_id): Extension<RequestId>,
|
||||
headers: HeaderMap,
|
||||
payload: Option<Json<HealthCheckRequest>>,
|
||||
body: Bytes,
|
||||
) -> Result<impl IntoResponse, AdminError> {
|
||||
let request_id = request_id.as_str().to_string();
|
||||
let payload = payload.map(|Json(payload)| payload).unwrap_or_default();
|
||||
let payload = parse_health_check_request(&body, &request_id)?;
|
||||
if payload.ids.as_ref().is_some_and(Vec::is_empty) {
|
||||
return Err(AdminError::new(
|
||||
StatusCode::BAD_REQUEST,
|
||||
@@ -125,6 +125,23 @@ pub async fn health_check_accounts(
|
||||
))
|
||||
}
|
||||
|
||||
fn parse_health_check_request(
|
||||
body: &Bytes,
|
||||
request_id: &str,
|
||||
) -> Result<HealthCheckRequest, AdminError> {
|
||||
if body.is_empty() {
|
||||
return Ok(HealthCheckRequest::default());
|
||||
}
|
||||
serde_json::from_slice(body).map_err(|_| {
|
||||
AdminError::new(
|
||||
StatusCode::BAD_REQUEST,
|
||||
40001,
|
||||
"Invalid health check request",
|
||||
request_id,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn health_check_error(error: HealthCheckError, request_id: &str) -> AdminError {
|
||||
match error {
|
||||
HealthCheckError::RepositoryUnavailable => AdminError::new(
|
||||
|
||||
@@ -15,7 +15,7 @@ use crate::{
|
||||
},
|
||||
platform::http::request_id::RequestId,
|
||||
runtime::state::AppState,
|
||||
utils::json::{first_string, string_at},
|
||||
utils::json::first_string,
|
||||
};
|
||||
|
||||
use super::super::{require_admin_session, AdminEnvelope, AdminError, AdminResponse};
|
||||
@@ -24,7 +24,6 @@ use super::validated_account_import_error;
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum AccountImportFormat {
|
||||
Native,
|
||||
Sub2Api,
|
||||
CodexCli,
|
||||
}
|
||||
|
||||
@@ -32,7 +31,6 @@ impl AccountImportFormat {
|
||||
fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Native => "native",
|
||||
Self::Sub2Api => "sub2api",
|
||||
Self::CodexCli => "codex_cli",
|
||||
}
|
||||
}
|
||||
@@ -208,227 +206,88 @@ fn store_import_account_error(error: StoreImportAccountError, request_id: &str)
|
||||
}
|
||||
|
||||
fn parse_account_import_payload(payload: &Value) -> ParsedAccountImportPayload {
|
||||
if let Some(accounts) = parse_sub2api_oauth_payload(payload) {
|
||||
return ParsedAccountImportPayload {
|
||||
accounts,
|
||||
source_format: AccountImportFormat::Sub2Api,
|
||||
};
|
||||
}
|
||||
|
||||
parse_native_account_payload(payload)
|
||||
}
|
||||
|
||||
fn parse_sub2api_oauth_payload(payload: &Value) -> Option<Vec<AccountImportEntry>> {
|
||||
let accounts = payload.get("accounts")?.as_array()?;
|
||||
let looks_like_sub2api = string_at(payload, &["type"]).as_deref() == Some("sub2api-data")
|
||||
|| payload.get("proxies").is_some()
|
||||
|| accounts
|
||||
.iter()
|
||||
.any(|account| account.get("credentials").is_some());
|
||||
if !looks_like_sub2api {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(
|
||||
accounts
|
||||
.iter()
|
||||
.filter_map(sub2api_oauth_account_entry)
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
fn sub2api_oauth_account_entry(account: &Value) -> Option<AccountImportEntry> {
|
||||
let platform = string_at(account, &["platform"])?.to_ascii_lowercase();
|
||||
let account_type = string_at(account, &["type"])?.to_ascii_lowercase();
|
||||
if platform != "openai" || account_type != "oauth" {
|
||||
return None;
|
||||
}
|
||||
let credentials = account.get("credentials")?;
|
||||
let fallback_label = normalized_label(string_at(account, &["name"]));
|
||||
let mut entry = account_entry_from_value(credentials, fallback_label);
|
||||
if entry.token.is_none() && entry.refresh_token.is_none() {
|
||||
return None;
|
||||
}
|
||||
if entry.email.is_none() {
|
||||
entry.email = string_at(credentials, &["email"]);
|
||||
}
|
||||
if entry.account_id.is_none() {
|
||||
entry.account_id = first_string(
|
||||
credentials,
|
||||
&[&["chatgpt_account_id"], &["account_id"], &["accountId"]],
|
||||
);
|
||||
}
|
||||
if entry.user_id.is_none() {
|
||||
entry.user_id = first_string(
|
||||
credentials,
|
||||
&[&["chatgpt_user_id"], &["user_id"], &["userId"]],
|
||||
);
|
||||
}
|
||||
if entry.plan_type.is_none() {
|
||||
entry.plan_type = first_string(credentials, &[&["plan_type"], &["planType"]]);
|
||||
}
|
||||
Some(entry)
|
||||
}
|
||||
|
||||
fn parse_native_account_payload(payload: &Value) -> ParsedAccountImportPayload {
|
||||
if let Some(accounts) = payload.as_array() {
|
||||
return ParsedAccountImportPayload {
|
||||
accounts: accounts
|
||||
.iter()
|
||||
.filter_map(|account| {
|
||||
let entry = account_entry_from_value(account, None);
|
||||
(entry.token.is_some() || entry.refresh_token.is_some()).then_some(entry)
|
||||
})
|
||||
.collect(),
|
||||
source_format: AccountImportFormat::Native,
|
||||
};
|
||||
}
|
||||
|
||||
if let Some(accounts) = payload.get("accounts").and_then(Value::as_array) {
|
||||
let source_format = if looks_like_sub2api_native_export(accounts) {
|
||||
AccountImportFormat::Sub2Api
|
||||
} else {
|
||||
AccountImportFormat::Native
|
||||
};
|
||||
return ParsedAccountImportPayload {
|
||||
accounts: accounts
|
||||
.iter()
|
||||
.filter_map(|account| {
|
||||
let entry = account_entry_from_value(account, None);
|
||||
(entry.token.is_some() || entry.refresh_token.is_some()).then_some(entry)
|
||||
})
|
||||
.collect(),
|
||||
source_format,
|
||||
};
|
||||
}
|
||||
|
||||
let entry = account_entry_from_value(payload, None);
|
||||
let accounts = if entry.token.is_some() || entry.refresh_token.is_some() {
|
||||
vec![entry]
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
ParsedAccountImportPayload {
|
||||
accounts,
|
||||
accounts: parse_native_account_payload(payload),
|
||||
source_format: AccountImportFormat::Native,
|
||||
}
|
||||
}
|
||||
|
||||
fn looks_like_sub2api_native_export(accounts: &[Value]) -> bool {
|
||||
accounts.iter().any(|account| {
|
||||
// sub2api 兼容导出会携带代理/配额运行态字段;这里只用于格式识别,代理数据不进入 Rust 服务。
|
||||
account.get("proxyApiKey").is_some()
|
||||
|| account.get("cachedQuota").is_some()
|
||||
|| account.get("quotaVerifyRequired").is_some()
|
||||
})
|
||||
fn parse_native_account_payload(payload: &Value) -> Vec<AccountImportEntry> {
|
||||
if has_removed_import_container_field(payload) {
|
||||
return Vec::new();
|
||||
}
|
||||
let Some(accounts) = payload.get("accounts").and_then(Value::as_array) else {
|
||||
return Vec::new();
|
||||
};
|
||||
accounts
|
||||
.iter()
|
||||
.filter_map(account_entry_from_value)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn account_entry_from_value(value: &Value, fallback_label: Option<String>) -> AccountImportEntry {
|
||||
let token = first_string(
|
||||
value,
|
||||
&[
|
||||
&["token"],
|
||||
&["accessToken"],
|
||||
&["access_token"],
|
||||
&["tokens", "accessToken"],
|
||||
&["tokens", "access_token"],
|
||||
&["credentials", "token"],
|
||||
&["credentials", "accessToken"],
|
||||
&["credentials", "access_token"],
|
||||
],
|
||||
)
|
||||
.map(normalize_bearer_token);
|
||||
let refresh_token = first_string(
|
||||
value,
|
||||
&[
|
||||
&["refreshToken"],
|
||||
&["refresh_token"],
|
||||
&["tokens", "refreshToken"],
|
||||
&["tokens", "refresh_token"],
|
||||
&["credentials", "refreshToken"],
|
||||
&["credentials", "refresh_token"],
|
||||
],
|
||||
);
|
||||
|
||||
AccountImportEntry {
|
||||
id: first_string(value, &[&["id"]]),
|
||||
email: first_string(value, &[&["email"], &["credentials", "email"]]),
|
||||
account_id: first_string(
|
||||
value,
|
||||
&[
|
||||
&["accountId"],
|
||||
&["account_id"],
|
||||
&["chatgpt_account_id"],
|
||||
&["credentials", "accountId"],
|
||||
&["credentials", "account_id"],
|
||||
&["credentials", "chatgpt_account_id"],
|
||||
],
|
||||
),
|
||||
user_id: first_string(
|
||||
value,
|
||||
&[
|
||||
&["userId"],
|
||||
&["user_id"],
|
||||
&["chatgpt_user_id"],
|
||||
&["credentials", "userId"],
|
||||
&["credentials", "user_id"],
|
||||
&["credentials", "chatgpt_user_id"],
|
||||
],
|
||||
),
|
||||
label: label_from_value(value).or(fallback_label),
|
||||
plan_type: first_string(
|
||||
value,
|
||||
&[
|
||||
&["planType"],
|
||||
&["plan_type"],
|
||||
&["credentials", "planType"],
|
||||
&["credentials", "plan_type"],
|
||||
],
|
||||
),
|
||||
token,
|
||||
refresh_token,
|
||||
access_token_expires_at: first_string(
|
||||
value,
|
||||
&[
|
||||
&["accessTokenExpiresAt"],
|
||||
&["access_token_expires_at"],
|
||||
&["credentials", "accessTokenExpiresAt"],
|
||||
&["credentials", "access_token_expires_at"],
|
||||
],
|
||||
),
|
||||
status: first_string(value, &[&["status"]]),
|
||||
fn account_entry_from_value(value: &Value) -> Option<AccountImportEntry> {
|
||||
if has_removed_import_account_field(value) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let entry = AccountImportEntry {
|
||||
id: first_string(value, &[&["id"]]),
|
||||
email: first_string(value, &[&["email"]]),
|
||||
account_id: first_string(value, &[&["accountId"]]),
|
||||
user_id: first_string(value, &[&["userId"]]),
|
||||
label: label_from_value(value),
|
||||
plan_type: first_string(value, &[&["planType"]]),
|
||||
token: first_string(value, &[&["token"]]),
|
||||
refresh_token: first_string(value, &[&["refreshToken"]]),
|
||||
access_token_expires_at: first_string(value, &[&["accessTokenExpiresAt"]]),
|
||||
status: first_string(value, &[&["status"]]),
|
||||
};
|
||||
(entry.token.is_some() || entry.refresh_token.is_some()).then_some(entry)
|
||||
}
|
||||
|
||||
fn has_removed_import_container_field(value: &Value) -> bool {
|
||||
["proxies", "type", "exported_at"]
|
||||
.iter()
|
||||
.any(|field| value.get(*field).is_some())
|
||||
}
|
||||
|
||||
fn has_removed_import_account_field(value: &Value) -> bool {
|
||||
[
|
||||
"accessToken",
|
||||
"access_token",
|
||||
"refresh_token",
|
||||
"tokens",
|
||||
"credentials",
|
||||
"account_id",
|
||||
"user_id",
|
||||
"chatgpt_account_id",
|
||||
"chatgpt_user_id",
|
||||
"plan_type",
|
||||
"access_token_expires_at",
|
||||
"expires_at",
|
||||
"name",
|
||||
"account_name",
|
||||
"accountName",
|
||||
"account_note",
|
||||
"accountNote",
|
||||
"note",
|
||||
"proxyApiKey",
|
||||
"cachedQuota",
|
||||
"quotaVerifyRequired",
|
||||
"usage",
|
||||
]
|
||||
.iter()
|
||||
.any(|field| value.get(*field).is_some())
|
||||
}
|
||||
|
||||
fn label_from_value(value: &Value) -> Option<String> {
|
||||
normalized_label(first_string(
|
||||
value,
|
||||
&[
|
||||
&["label"],
|
||||
&["name"],
|
||||
&["account_name"],
|
||||
&["accountName"],
|
||||
&["account_note"],
|
||||
&["accountNote"],
|
||||
&["note"],
|
||||
],
|
||||
))
|
||||
normalized_label(first_string(value, &[&["label"]]))
|
||||
}
|
||||
|
||||
fn normalized_label(value: Option<String>) -> Option<String> {
|
||||
value.map(|label| label.chars().take(64).collect())
|
||||
}
|
||||
|
||||
fn normalize_bearer_token(value: String) -> String {
|
||||
let trimmed = value.trim();
|
||||
trimmed
|
||||
.strip_prefix("Bearer ")
|
||||
.or_else(|| trimmed.strip_prefix("bearer "))
|
||||
.unwrap_or(trimmed)
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn empty_to_none(value: Option<String>) -> Option<String> {
|
||||
value
|
||||
.map(|value| value.trim().to_string())
|
||||
|
||||
@@ -33,7 +33,6 @@ pub fn router() -> Router<AppState> {
|
||||
.route("/api/admin/auth/logout", post(auth_logout))
|
||||
.route("/api/admin/auth/login-start", post(auth_login_start))
|
||||
.route("/api/admin/auth/code-relay", post(auth_code_relay))
|
||||
.route("/api/admin/auth/callback", get(auth_callback))
|
||||
.route("/auth/openai/callback", get(auth_callback))
|
||||
.route("/api/admin/auth/device-login", post(auth_device_login))
|
||||
.route(
|
||||
|
||||
Reference in New Issue
Block a user