feat: add account oauth flow

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
zyycn
2026-06-28 02:29:26 +08:00
co-authored by Codex
parent e3971df6f0
commit e6e417de1a
19 changed files with 781 additions and 61 deletions
+2 -2
View File
@@ -13,8 +13,8 @@ ws_pool:
max_per_account: 8
fingerprint:
originator: Codex Desktop
app_version: '26.519.81530'
build_number: '3178'
app_version: '26.623.42026'
build_number: '4514'
platform: darwin
arch: arm64
chromium_version: '146'
+90 -1
View File
@@ -23,7 +23,9 @@ use crate::{
AdminEnvelope, AdminError, AdminResponse, CursorPageMeta, NumberedPageMeta, PageMeta,
},
admin::{
accounts::service::{AdminAccountError, AdminAccountMetadata, AdminAccountMetadataUpdate},
accounts::service::{
AdminAccountError, AdminAccountMetadata, AdminAccountMetadataUpdate, OAuthExchangeInput,
},
monitoring::{billing, service::AdminUsageRecord},
},
http::middleware::request_id::RequestId,
@@ -109,6 +111,15 @@ pub(crate) struct AccountTestRequest {
model_id: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct AccountOAuthExchangeRequest {
session_id: String,
callback_url: Option<String>,
code: Option<String>,
state: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct AccountTestModelsData {
@@ -482,6 +493,15 @@ struct AccountImportData {
source_format: String,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct AccountOAuthAuthorizeData {
session_id: String,
auth_url: String,
expires_at: String,
expires_at_display: String,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct HealthCheckData {
@@ -850,6 +870,66 @@ pub(crate) async fn import_accounts(
}
}
/// `POST /api/admin/accounts/oauth/authorize`
pub(crate) async fn oauth_authorize_account(
State(state): State<AppState>,
Extension(request_id): Extension<RequestId>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AdminError> {
let request_id = request_id.as_str().to_string();
require_admin_session(&state, &headers, &request_id).await?;
match state.services.admin_accounts.oauth_authorize().await {
Ok(result) => Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(
AccountOAuthAuthorizeData {
session_id: result.session_id,
auth_url: result.auth_url,
expires_at: china_rfc3339(&result.expires_at),
expires_at_display: china_datetime(&result.expires_at),
},
request_id,
),
)),
Err(error) => Err(account_error(error, request_id)),
}
}
/// `POST /api/admin/accounts/oauth/exchange`
pub(crate) async fn oauth_exchange_account(
State(state): State<AppState>,
Extension(request_id): Extension<RequestId>,
headers: HeaderMap,
Json(payload): Json<AccountOAuthExchangeRequest>,
) -> Result<impl IntoResponse, AdminError> {
let request_id = request_id.as_str().to_string();
require_admin_session(&state, &headers, &request_id).await?;
match state
.services
.admin_accounts
.oauth_exchange(OAuthExchangeInput {
session_id: payload.session_id,
callback_url: payload.callback_url,
code: payload.code,
state: payload.state,
})
.await
{
Ok(result) => Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(
AccountImportData {
imported: result.imported,
skipped: result.skipped,
source_format: result.source_format.to_string(),
},
request_id,
),
)),
Err(error) => Err(account_error(error, request_id)),
}
}
/// `POST /api/admin/accounts/test?id=...`
pub(crate) async fn test_account_connection(
State(state): State<AppState>,
@@ -1749,12 +1829,21 @@ fn account_error(error: AdminAccountError, request_id: String) -> AdminError {
| AdminAccountError::TokenRequired
| AdminAccountError::InvalidToken(_)
| AdminAccountError::RefreshTokenExchange(_)
| AdminAccountError::OAuthSessionInvalid
| AdminAccountError::OAuthCallbackInvalid
| AdminAccountError::OAuthStateMismatch
| AdminAccountError::NoValidCookies => AdminError::new(
StatusCode::BAD_REQUEST,
40001,
error.to_string(),
request_id,
),
AdminAccountError::OAuthCodeExchange(_) => AdminError::new(
StatusCode::BAD_GATEWAY,
50201,
error.to_string(),
request_id,
),
AdminAccountError::NotFound => account_not_found(request_id),
AdminAccountError::Inactive(_) => {
AdminError::new(StatusCode::CONFLICT, 40901, error.to_string(), request_id)
+6 -1
View File
@@ -3,6 +3,7 @@
mod cookies;
mod importing;
mod lifecycle;
pub(crate) mod oauth;
mod quota;
mod testing;
mod types;
@@ -22,7 +23,8 @@ use crate::{
pub use types::{
AdminAccountError, AdminAccountMetadata, AdminAccountMetadataUpdate, BatchDeleteAccounts,
BatchUpdateAccountStatus, ImportedAccounts, UpdatedAccountStatus,
BatchUpdateAccountStatus, ImportedAccounts, OAuthAuthorizeResult, OAuthExchangeInput,
UpdatedAccountStatus,
};
#[derive(Clone)]
@@ -33,6 +35,7 @@ pub struct AdminAccountService {
pub(crate) codex: StdArc<CodexBackendClient>,
pub(crate) account_pool: StdArc<RuntimeAccountPoolService>,
pub(crate) token_refresher: StdArc<dyn TokenRefresher>,
pub(crate) oauth: oauth::AccountOAuthService,
pub(crate) refresh_margin_seconds: u64,
pub(crate) installation_id: Option<String>,
}
@@ -49,6 +52,7 @@ impl AdminAccountService {
codex: StdArc<CodexBackendClient>,
account_pool: StdArc<RuntimeAccountPoolService>,
token_refresher: StdArc<dyn TokenRefresher>,
oauth: oauth::AccountOAuthService,
refresh_margin_seconds: u64,
installation_id: Option<String>,
) -> Self {
@@ -59,6 +63,7 @@ impl AdminAccountService {
codex,
account_pool,
token_refresher,
oauth,
refresh_margin_seconds,
installation_id,
}
+230
View File
@@ -0,0 +1,230 @@
use std::{collections::HashMap, sync::Arc};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use chrono::{DateTime, Duration, Utc};
use rand::Rng;
use reqwest::{Client, StatusCode, Url};
use serde::Deserialize;
use sha2::{Digest, Sha256};
use tokio::sync::Mutex;
use super::{
types::{AdminAccountError, OAuthAuthorizeResult, OAuthExchangeInput},
AdminAccountService,
};
const OPENAI_OAUTH_AUTHORIZE_ENDPOINT: &str = "https://auth.openai.com/oauth/authorize";
const OPENAI_OAUTH_REDIRECT_URI: &str = "http://localhost:1455/auth/callback";
const OPENAI_OAUTH_SCOPE: &str = "openid profile email offline_access";
const OAUTH_SESSION_TTL_MINUTES: i64 = 30;
#[derive(Clone)]
pub struct AccountOAuthService {
client: Client,
client_id: String,
token_endpoint: String,
sessions: Arc<Mutex<HashMap<String, AccountOAuthSession>>>,
}
impl AccountOAuthService {
pub fn new(client: Client, client_id: String, token_endpoint: String) -> Self {
Self {
client,
client_id,
token_endpoint,
sessions: Arc::new(Mutex::new(HashMap::new())),
}
}
async fn authorize(&self) -> Result<OAuthAuthorizeResult, AdminAccountError> {
let now = Utc::now();
let expires_at = now + Duration::minutes(OAUTH_SESSION_TTL_MINUTES);
let session_id = random_url_token(32);
let state = random_url_token(32);
let code_verifier = random_hex_token(64);
let code_challenge = code_challenge(&code_verifier);
let session = AccountOAuthSession {
state: state.clone(),
code_verifier,
expires_at,
};
let mut sessions = self.sessions.lock().await;
sessions.retain(|_, session| session.expires_at > now);
sessions.insert(session_id.clone(), session);
drop(sessions);
let mut auth_url =
Url::parse(OPENAI_OAUTH_AUTHORIZE_ENDPOINT).map_err(|_| AdminAccountError::Import)?;
auth_url
.query_pairs_mut()
.append_pair("response_type", "code")
.append_pair("client_id", &self.client_id)
.append_pair("redirect_uri", OPENAI_OAUTH_REDIRECT_URI)
.append_pair("scope", OPENAI_OAUTH_SCOPE)
.append_pair("state", &state)
.append_pair("code_challenge", &code_challenge)
.append_pair("code_challenge_method", "S256")
.append_pair("id_token_add_organizations", "true")
.append_pair("codex_cli_simplified_flow", "true");
Ok(OAuthAuthorizeResult {
session_id,
auth_url: auth_url.to_string(),
expires_at,
})
}
async fn exchange(
&self,
input: OAuthExchangeInput,
) -> Result<AccountOAuthTokens, AdminAccountError> {
let (code, state) = oauth_callback_parts(&input)?;
let session = self.session(&input.session_id).await?;
if session.state != state {
return Err(AdminAccountError::OAuthStateMismatch);
}
let response = self
.client
.post(&self.token_endpoint)
.form(&[
("grant_type", "authorization_code"),
("client_id", self.client_id.as_str()),
("code", code.as_str()),
("redirect_uri", OPENAI_OAUTH_REDIRECT_URI),
("code_verifier", session.code_verifier.as_str()),
])
.send()
.await
.map_err(|error| AdminAccountError::OAuthCodeExchange(error.to_string()))?;
let status = response.status();
let body = response
.text()
.await
.map_err(|error| AdminAccountError::OAuthCodeExchange(error.to_string()))?;
if !status.is_success() {
return Err(AdminAccountError::OAuthCodeExchange(exchange_error_text(
status, &body,
)));
}
let tokens = serde_json::from_str::<AccountOAuthTokens>(&body)
.map_err(|_| AdminAccountError::OAuthCodeExchange("invalid token response".into()))?;
if tokens.access_token.trim().is_empty() {
return Err(AdminAccountError::OAuthCodeExchange(
"empty access token".into(),
));
}
self.remove_session(&input.session_id).await;
Ok(tokens)
}
async fn session(&self, session_id: &str) -> Result<AccountOAuthSession, AdminAccountError> {
let now = Utc::now();
let mut sessions = self.sessions.lock().await;
sessions.retain(|_, session| session.expires_at > now);
sessions
.get(session_id)
.cloned()
.ok_or(AdminAccountError::OAuthSessionInvalid)
}
async fn remove_session(&self, session_id: &str) {
self.sessions.lock().await.remove(session_id);
}
}
impl AdminAccountService {
pub async fn oauth_authorize(&self) -> Result<OAuthAuthorizeResult, AdminAccountError> {
self.oauth.authorize().await
}
pub async fn oauth_exchange(
&self,
input: OAuthExchangeInput,
) -> Result<super::types::ImportedAccounts, AdminAccountError> {
let tokens = self.oauth.exchange(input).await?;
self.import(serde_json::json!({
"sourceFormat": "native",
"accounts": [{
"token": tokens.access_token,
"refreshToken": tokens.refresh_token,
}]
}))
.await
}
}
#[derive(Debug, Clone)]
struct AccountOAuthSession {
state: String,
code_verifier: String,
expires_at: DateTime<Utc>,
}
#[derive(Debug, Deserialize)]
struct AccountOAuthTokens {
access_token: String,
refresh_token: Option<String>,
}
fn oauth_callback_parts(input: &OAuthExchangeInput) -> Result<(String, String), AdminAccountError> {
if let Some(callback_url) = input.callback_url.as_deref() {
let (code, state) = callback_url_parts(callback_url)?;
return Ok((code, state));
}
let code = normalized(input.code.as_deref()).ok_or(AdminAccountError::OAuthCallbackInvalid)?;
let state =
normalized(input.state.as_deref()).ok_or(AdminAccountError::OAuthCallbackInvalid)?;
Ok((code, state))
}
fn callback_url_parts(callback_url: &str) -> Result<(String, String), AdminAccountError> {
let url =
Url::parse(callback_url.trim()).map_err(|_| AdminAccountError::OAuthCallbackInvalid)?;
let mut code = None;
let mut state = None;
for (key, value) in url.query_pairs() {
match key.as_ref() {
"code" => code = normalized(Some(value.as_ref())),
"state" => state = normalized(Some(value.as_ref())),
_ => {}
}
}
code.zip(state)
.ok_or(AdminAccountError::OAuthCallbackInvalid)
}
fn normalized(value: Option<&str>) -> Option<String> {
value
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string)
}
fn code_challenge(verifier: &str) -> String {
URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()))
}
fn random_url_token(size: usize) -> String {
let mut bytes = vec![0u8; size];
rand::rng().fill_bytes(&mut bytes);
URL_SAFE_NO_PAD.encode(bytes)
}
fn random_hex_token(size: usize) -> String {
let mut bytes = vec![0u8; size];
rand::rng().fill_bytes(&mut bytes);
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
}
fn exchange_error_text(status: StatusCode, body: &str) -> String {
let body = body.trim();
if body.is_empty() {
return format!("upstream returned {status}");
}
format!("upstream returned {status}: {body}")
}
+23
View File
@@ -110,6 +110,14 @@ pub enum AdminAccountError {
InvalidToken(&'static str),
#[error("token refresh exchange failed: {0}")]
RefreshTokenExchange(crate::upstream::accounts::token_refresh::RefreshFailure),
#[error("oauth session is invalid or expired")]
OAuthSessionInvalid,
#[error("oauth callback is missing code or state")]
OAuthCallbackInvalid,
#[error("oauth state does not match")]
OAuthStateMismatch,
#[error("oauth code exchange failed: {0}")]
OAuthCodeExchange(String),
#[error("no valid cookies provided")]
NoValidCookies,
#[error("account is {0}")]
@@ -162,6 +170,21 @@ pub struct ImportedAccounts {
pub source_format: &'static str,
}
#[derive(Debug, Clone)]
pub struct OAuthAuthorizeResult {
pub session_id: String,
pub auth_url: String,
pub expires_at: DateTime<Utc>,
}
#[derive(Debug, Clone)]
pub struct OAuthExchangeInput {
pub session_id: String,
pub callback_url: Option<String>,
pub code: Option<String>,
pub state: Option<String>,
}
#[derive(Debug, Clone)]
pub(super) struct ManualCreateTokens {
pub(super) access_token: String,
+10 -2
View File
@@ -11,8 +11,8 @@ use super::{
accounts::routes::{
account_quota, account_test_models, accounts, batch_delete_accounts, create_account,
export_accounts, get_account_cookies, health_check_accounts, import_accounts,
quota_warnings, refresh_account, reset_account_usage, set_account_cookies,
test_account_connection, update_account,
oauth_authorize_account, oauth_exchange_account, quota_warnings, refresh_account,
reset_account_usage, set_account_cookies, test_account_connection, update_account,
},
auth::session::{login, logout, session_status},
keys::routes::{
@@ -42,6 +42,14 @@ pub fn router() -> Router<AppState> {
.route("/api/admin/accounts", get(accounts).post(create_account))
.route("/api/admin/accounts/export", get(export_accounts))
.route("/api/admin/accounts/import", post(import_accounts))
.route(
"/api/admin/accounts/oauth/authorize",
post(oauth_authorize_account),
)
.route(
"/api/admin/accounts/oauth/exchange",
post(oauth_exchange_account),
)
.route("/api/admin/accounts/quota-warnings", get(quota_warnings))
.route(
"/api/admin/accounts/health-check",
+5
View File
@@ -166,6 +166,11 @@ impl Services {
codex.clone(),
account_pool.clone(),
token_client,
crate::admin::accounts::service::oauth::AccountOAuthService::new(
reqwest::Client::new(),
config.auth.oauth_client_id.clone(),
config.auth.oauth_token_endpoint.clone(),
),
config.auth.refresh_margin_seconds,
installation_id.clone(),
));
+1
View File
@@ -34,6 +34,7 @@ use tower::util::ServiceExt;
mod import_export;
mod lifecycle;
mod list;
mod oauth;
mod quota;
mod testing;
+196
View File
@@ -0,0 +1,196 @@
use super::*;
use wiremock::{
matchers::{body_string_contains, method, path},
Mock, MockServer, ResponseTemplate,
};
#[tokio::test]
async fn admin_account_oauth_authorize_should_return_pkce_auth_url() {
let (app, _state, _pool, _dir) =
admin_accounts_test_app("admin-account-oauth-authorize.sqlite", 94).await;
let response = post_oauth_authorize(&app, "req_oauth_authorize").await;
assert_eq!(response.status(), StatusCode::OK);
let body = response_json(response).await;
let auth_url = body["data"]["authUrl"].as_str().unwrap();
let url = reqwest::Url::parse(auth_url).unwrap();
let params: std::collections::HashMap<_, _> = url.query_pairs().into_owned().collect();
assert_eq!(
url.as_str().split('?').next().unwrap(),
"https://auth.openai.com/oauth/authorize"
);
assert_eq!(
params.get("response_type").map(String::as_str),
Some("code")
);
assert_eq!(
params.get("client_id").map(String::as_str),
Some("app_EMoamEEZ73f0CkXaXp7hrann")
);
assert_eq!(
params.get("redirect_uri").map(String::as_str),
Some("http://localhost:1455/auth/callback")
);
assert_eq!(
params.get("scope").map(String::as_str),
Some("openid profile email offline_access")
);
assert_eq!(
params.get("code_challenge_method").map(String::as_str),
Some("S256")
);
assert!(params.get("state").is_some_and(|value| !value.is_empty()));
assert!(params
.get("code_challenge")
.is_some_and(|value| !value.is_empty()));
}
#[tokio::test]
async fn admin_account_oauth_exchange_should_reject_callback_without_state() {
let server = MockServer::start().await;
let (app, _state, _pool, _dir) = admin_accounts_test_app_with_oauth_token_endpoint(
"admin-account-oauth-missing-state.sqlite",
95,
format!("{}/oauth/token", server.uri()),
)
.await;
let authorize =
response_json(post_oauth_authorize(&app, "req_oauth_missing_state").await).await;
let session_id = authorize["data"]["sessionId"].as_str().unwrap();
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/admin/accounts/oauth/exchange")
.header("content-type", "application/json")
.header("cookie", "cpr_admin_session=session_1")
.header("x-request-id", "req_oauth_missing_state_exchange")
.body(Body::from(
json!({
"sessionId": session_id,
"callbackUrl": "http://localhost:1455/auth/callback?code=oauth-code"
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
assert!(server.received_requests().await.unwrap().is_empty());
}
#[tokio::test]
async fn admin_account_oauth_exchange_should_import_account_tokens() {
let server = MockServer::start().await;
let access_token = test_jwt(
"chatgpt-oauth",
Some("user-oauth"),
Some("oauth@example.com"),
Some("plus"),
);
Mock::given(method("POST"))
.and(path("/oauth/token"))
.and(body_string_contains("grant_type=authorization_code"))
.and(body_string_contains(
"client_id=app_EMoamEEZ73f0CkXaXp7hrann",
))
.and(body_string_contains("code=oauth-code"))
.and(body_string_contains(
"redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback",
))
.and(body_string_contains("code_verifier="))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"access_token": access_token,
"refresh_token": "refresh-oauth"
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/api/codex/usage"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"account_id": "chatgpt-oauth",
"user_id": "user-oauth",
"email": "oauth@example.com",
"plan_type": "plus",
"rate_limit": {
"allowed": true,
"primary_window": {
"used_percent": 1,
"limit_window_seconds": 18000
}
}
})))
.mount(&server)
.await;
let (app, _state, pool, _dir) =
admin_accounts_test_app_with_api_base_url_and_oauth_token_endpoint(
"admin-account-oauth-exchange.sqlite",
96,
server.uri(),
format!("{}/oauth/token", server.uri()),
)
.await;
let authorize = response_json(post_oauth_authorize(&app, "req_oauth_exchange").await).await;
let session_id = authorize["data"]["sessionId"].as_str().unwrap();
let auth_url = reqwest::Url::parse(authorize["data"]["authUrl"].as_str().unwrap()).unwrap();
let state = auth_url
.query_pairs()
.find_map(|(key, value)| (key == "state").then(|| value.into_owned()))
.unwrap();
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/admin/accounts/oauth/exchange")
.header("content-type", "application/json")
.header("cookie", "cpr_admin_session=session_1")
.header("x-request-id", "req_oauth_exchange_done")
.body(Body::from(
json!({
"sessionId": session_id,
"callbackUrl": format!(
"http://localhost:1455/auth/callback?code=oauth-code&state={state}"
)
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response_json(response).await;
assert_eq!(body["data"]["imported"], 1);
let stored: (String, String, String, String) = sqlx::query_as(
"select email, chatgpt_account_id, access_token, refresh_token from accounts where chatgpt_account_id = ?",
)
.bind("chatgpt-oauth")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(stored.0, "oauth@example.com");
assert_eq!(stored.1, "chatgpt-oauth");
assert_eq!(stored.2, access_token);
assert_eq!(stored.3, "refresh-oauth");
}
async fn post_oauth_authorize(app: &axum::Router, request_id: &str) -> axum::response::Response {
app.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/admin/accounts/oauth/authorize")
.header("cookie", "cpr_admin_session=session_1")
.header("x-request-id", request_id)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap()
}
+16
View File
@@ -24,6 +24,22 @@ export function importAccounts(data: any) {
})
}
export function authorizeAccountOAuth(data?: any) {
return request({
url: '/api/admin/accounts/oauth/authorize',
method: 'POST',
data: data || {},
})
}
export function exchangeAccountOAuth(data: any) {
return request({
url: '/api/admin/accounts/oauth/exchange',
method: 'POST',
data,
})
}
export function deleteAccounts(data: any) {
return request({
url: '/api/admin/accounts/delete',
+2 -2
View File
@@ -32,7 +32,7 @@ const slots = useSlots()
const variantClasses = {
default:
'bg-(--cp-bg-subtle) text-(--cp-text-primary) hover:bg-(--cp-default-bg-hover) active:bg-(--cp-default-bg-active)',
'bg-(--cp-bg-muted) text-(--cp-text-primary) shadow-(--cp-shadow-control) hover:bg-(--cp-default-bg-active) active:bg-(--cp-default-bg-active)',
primary:
'bg-(--cp-info) text-(--cp-info-on) hover:bg-(--cp-info-hover) active:bg-(--cp-info-pressed)',
success:
@@ -94,7 +94,7 @@ const labelText = computed(() => {
})
const labelClasses = computed(() => [
'inline-flex min-w-0 items-center justify-center',
'inline-flex min-w-0 items-center justify-center gap-2 [&>svg]:shrink-0',
labelText.value.length === 2 ? 'tracking-[0.12em]' : undefined,
])
</script>
+1 -1
View File
@@ -323,7 +323,7 @@ function paginationPageClass(page: number) {
@mouseenter="horizontalHovering = true"
@mouseleave="horizontalHovering = false"
>
<div class="relative flex min-h-0 max-w-full flex-1 overflow-hidden pb-3">
<div v-loading="loading" class="relative flex min-h-0 max-w-full flex-1 overflow-hidden pb-3">
<div class="flex min-h-0 max-w-full flex-1 flex-col overflow-hidden">
<div ref="headerWrap" class="max-w-full overflow-hidden">
<table
@@ -1,17 +1,22 @@
<script setup lang="ts">
import { computed, ref, useTemplateRef } from 'vue'
import { Upload } from '@lucide/vue'
import { Copy, KeyRound, Upload } from '@lucide/vue'
import BaseButton from '@/components/base/BaseButton.vue'
import BaseModal from '@/components/base/BaseModal.vue'
import BaseScrollbar from '@/components/base/BaseScrollbar.vue'
import BaseSegmented from '@/components/base/BaseSegmented.vue'
import { toast } from '@/components/base/BaseToast'
import { copyText } from '@/utils/clipboard'
const props = withDefaults(
defineProps<{
saving?: boolean
oauthLoading?: boolean
}>(),
{
saving: false,
oauthLoading: false,
},
)
@@ -20,14 +25,16 @@ const form = defineModel<any>('form', { required: true })
const emit = defineEmits<{
create: []
generateOauth: []
}>()
const fileInput = useTemplateRef<HTMLInputElement>('fileInput')
const fileError = ref('')
const modeOptions = [
{ label: 'OAuth 授权', value: 'oauth' },
{ label: 'RT 导入', value: 'rt' },
{ label: '原生 JSON', value: 'native' },
{ label: 'CPR', value: 'native' },
{ label: 'Sub2API', value: 'sub2api' },
]
@@ -54,8 +61,22 @@ const importText = computed({
},
})
const oauthCallback = computed({
get: () => form.value.oauthCallback,
set: (value: string) => {
form.value = { ...form.value, oauthCallback: value }
},
})
const oauthAuthUrl = computed(() => form.value.oauthAuthUrl || '')
const canSubmit = computed(() => {
if (props.saving) return false
if (props.saving || props.oauthLoading) return false
if (mode.value === 'oauth') {
return Boolean(
form.value.oauthSessionId && oauthAuthUrl.value && oauthCallback.value.trim().length > 0,
)
}
if (mode.value === 'rt') return refreshToken.value.trim().length > 0
return importText.value.trim().length > 0
})
@@ -64,10 +85,13 @@ const description = computed(() => {
if (mode.value === 'rt') {
return '一行一个 Refresh Token,导入时会自动换取访问令牌并补全账号信息。'
}
if (mode.value === 'oauth') {
return '复制 OpenAI 授权链接,完成后粘贴回调地址,系统会自动写入或更新账号。'
}
if (mode.value === 'sub2api') {
return '导入 Sub2API 导出的账号 JSON,已存在账号会更新。'
}
return '导入 Codex Proxy RS 原生账号 JSON,已存在账号会更新。'
return '导入 Codex Proxy RS 账号 JSON,已存在账号会更新。'
})
async function handleFileChange(event: Event) {
@@ -84,12 +108,23 @@ async function handleFileChange(event: Event) {
input.value = ''
}
}
async function copyOAuthAuthUrl() {
if (!oauthAuthUrl.value) return
const copied = await copyText(oauthAuthUrl.value)
if (copied) {
toast.success('授权链接已复制')
return
}
toast.error('复制失败')
}
</script>
<template>
<BaseModal
v-model="open"
title="导入账号"
title="添加账号"
:description="description"
variant="info"
width="620px"
@@ -110,13 +145,84 @@ async function handleFileChange(event: Event) {
/>
</div>
<div v-else-if="mode === 'oauth'" class="flex flex-col gap-4">
<div class="rounded-(--cp-input-radius-base) bg-(--cp-bg-subtle) px-4 py-3">
<div class="flex items-start gap-3">
<div
class="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-(--cp-icon-button-radius) bg-(--cp-bg-surface) text-(--cp-info)"
>
<KeyRound class="size-4" />
</div>
<div class="min-w-0 flex-1">
<p class="m-0 text-[13px] font-[720] text-(--cp-text-primary)">
使用 OpenAI OAuth 完成账号接入
</p>
<p class="m-0 mt-1 text-[12px] leading-[1.55] font-medium text-(--cp-text-secondary)">
复制授权链接到浏览器打开,完成后把回调地址粘贴到下方即可导入。
</p>
</div>
</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<BaseButton
variant="default"
:loading="oauthLoading"
:disabled="saving"
@click="emit('generateOauth')"
>
生成授权链接
</BaseButton>
</div>
<div v-if="oauthAuthUrl" class="flex flex-col gap-2">
<div class="flex items-center justify-between gap-3">
<label class="block text-[13px] font-medium text-(--cp-text-secondary)">
授权链接
</label>
<BaseButton
icon-only
variant="default"
size="sm"
title="复制链接"
label="复制链接"
:disabled="saving || oauthLoading"
@click="copyOAuthAuthUrl"
>
<Copy class="size-3.5" />
</BaseButton>
</div>
<BaseScrollbar
max-height="92px"
view-class="rounded-(--cp-input-radius-base) bg-(--cp-input-current-bg,var(--cp-input-context-bg)) px-3.5 py-3 shadow-(--cp-shadow-input)"
>
<pre
class="m-0 whitespace-pre-wrap wrap-break-word font-mono text-[12px] leading-[1.6] font-[650] text-(--cp-text-secondary)"
>{{ oauthAuthUrl }}</pre
>
</BaseScrollbar>
</div>
<div class="flex flex-col gap-2">
<label class="block text-[13px] font-medium text-(--cp-text-secondary)">
回调地址 <span class="text-(--cp-danger)">*</span>
</label>
<textarea
v-model="oauthCallback"
class="h-28 w-full resize-none rounded-(--cp-input-radius-base) border-0 bg-(--cp-input-current-bg,var(--cp-input-context-bg)) px-3.5 py-3 font-mono text-[12px] leading-[1.55] font-[650] text-(--cp-text-primary) shadow-(--cp-shadow-input) outline-none transition-[background-color,box-shadow] duration-160 placeholder:text-(--cp-text-muted) hover:bg-(--cp-input-current-bg-hover,var(--cp-input-context-bg-hover)) hover:shadow-(--cp-shadow-input-hover) focus:bg-(--cp-input-soft-bg-focus) focus:shadow-(--cp-shadow-input-focus) disabled:cursor-not-allowed disabled:bg-(--cp-disabled-bg) disabled:text-(--cp-disabled-text) disabled:shadow-none"
placeholder="http://localhost:1455/auth/callback?code=...&state=..."
:disabled="saving"
/>
</div>
</div>
<div v-else class="flex flex-col gap-3">
<div class="flex items-center justify-between gap-3">
<label class="block text-[13px] font-medium text-(--cp-text-secondary)">
JSON 内容 <span class="text-(--cp-danger)">*</span>
</label>
<BaseButton variant="default" size="sm" :disabled="saving" @click="fileInput?.click()">
<Upload class="size-4" />
<Upload class="size-3.5" />
上传文件
</BaseButton>
<input
@@ -147,7 +253,7 @@ async function handleFileChange(event: Event) {
:disabled="!canSubmit"
@click="emit('create')"
>
导入
{{ mode === 'oauth' ? '完成授权导入' : '导入' }}
</BaseButton>
</template>
</BaseModal>
@@ -1,7 +1,9 @@
import { computed, onMounted, ref, type Ref } from 'vue'
import {
authorizeAccountOAuth,
deleteAccounts,
exchangeAccountOAuth,
getAccountQuota,
getAccounts,
importAccounts,
@@ -36,15 +38,18 @@ export function useAccountMutations(options: {
const refreshingQuotaAccountIds = ref<Set<string>>(new Set())
const updatingStatusAccountIds = ref<Set<string>>(new Set())
const deletingAccount = ref(false)
const loaded = ref(false)
const creatingAccount = ref(false)
const authorizingOAuth = ref(false)
const savingAccount = ref(false)
const batchDeleting = ref(false)
const createForm = ref({
mode: 'rt',
mode: 'oauth',
refreshToken: '',
importText: '',
oauthSessionId: '',
oauthAuthUrl: '',
oauthCallback: '',
})
const editForm = ref({
@@ -56,8 +61,6 @@ export function useAccountMutations(options: {
status: 'active',
})
const initialLoading = computed(() => loading.value && !loaded.value)
const showEditModal = computed({
get: () => editingAccount.value !== null,
set: (value: boolean) => {
@@ -100,11 +103,14 @@ export function useAccountMutations(options: {
}
} finally {
loading.value = false
loaded.value = true
}
}
async function handleCreate() {
if (createForm.value.mode === 'oauth') {
await handleExchangeOAuth()
return
}
if (creatingAccount.value) return
try {
@@ -114,7 +120,7 @@ export function useAccountMutations(options: {
creatingAccount.value = true
const result = await importAccounts(payload)
showCreateModal.value = false
createForm.value = { mode: 'rt', refreshToken: '', importText: '' }
resetCreateForm()
await loadAccounts()
toast.success(importSuccessText(result))
} catch (error) {
@@ -125,6 +131,14 @@ export function useAccountMutations(options: {
}
function accountImportPayload() {
if (createForm.value.mode === 'oauth') {
if (!createForm.value.oauthSessionId || !createForm.value.oauthCallback.trim()) return null
return {
sessionId: createForm.value.oauthSessionId,
callbackUrl: createForm.value.oauthCallback.trim(),
}
}
if (createForm.value.mode === 'rt') {
const accounts = createForm.value.refreshToken
.split(/\r?\n/)
@@ -155,6 +169,58 @@ export function useAccountMutations(options: {
return { sourceFormat, accounts: [parsed] }
}
async function handleAuthorizeOAuth() {
if (authorizingOAuth.value) return
try {
authorizingOAuth.value = true
const result = await authorizeAccountOAuth()
createForm.value = {
...createForm.value,
mode: 'oauth',
oauthSessionId: result.sessionId,
oauthAuthUrl: result.authUrl,
oauthCallback: '',
}
toast.success('授权链接已生成')
} catch (error: any) {
toast.error(error.message || '授权链接生成失败')
} finally {
authorizingOAuth.value = false
}
}
async function handleExchangeOAuth() {
if (creatingAccount.value) return
try {
const payload = accountImportPayload()
if (!payload) return
creatingAccount.value = true
const result = await exchangeAccountOAuth(payload)
showCreateModal.value = false
resetCreateForm()
await loadAccounts()
toast.success(importSuccessText(result))
} catch (error: any) {
toast.error(error.message || 'OAuth 授权导入失败')
} finally {
creatingAccount.value = false
}
}
function resetCreateForm() {
createForm.value = {
mode: 'oauth',
refreshToken: '',
importText: '',
oauthSessionId: '',
oauthAuthUrl: '',
oauthCallback: '',
}
}
function importSuccessText(result: any) {
const imported = result?.imported ?? 0
const skipped = result?.skipped ?? 0
@@ -325,14 +391,16 @@ export function useAccountMutations(options: {
updatingStatusAccountIds,
deletingAccount,
creatingAccount,
authorizingOAuth,
savingAccount,
batchDeleting,
createForm,
editForm,
editStatusModel,
initialLoading,
loadAccounts,
handleCreate,
handleAuthorizeOAuth,
handleExchangeOAuth,
openEditAccount,
handleSaveAccount,
requestDeleteAccount,
+4 -2
View File
@@ -71,14 +71,15 @@ const {
updatingStatusAccountIds,
deletingAccount,
creatingAccount,
authorizingOAuth,
savingAccount,
batchDeleting,
createForm,
editForm,
editStatusModel,
initialLoading,
loadAccounts,
handleCreate,
handleAuthorizeOAuth,
openEditAccount,
handleSaveAccount,
requestDeleteAccount,
@@ -144,7 +145,6 @@ function planTypeClass(planType?: string) {
<AccountOverviewCards :summary="accountSummary" />
<BaseCard
v-loading="initialLoading"
:padded="false"
class="mt-4 flex min-h-0 flex-1 flex-col"
header-class="px-4 pt-4 pb-2 md:px-5"
@@ -298,8 +298,10 @@ function planTypeClass(planType?: string) {
<AccountCreateModal
v-model="showCreateModal"
v-model:form="createForm"
:oauth-loading="authorizingOAuth"
:saving="creatingAccount"
@create="handleCreate"
@generate-oauth="handleAuthorizeOAuth"
/>
<AccountEditModal
+1 -1
View File
@@ -71,7 +71,6 @@ const { allSelected, indeterminate, selectedRowKeys, toggleSelection, toggleAll
</header>
<BaseCard
v-loading="loading"
:padded="false"
class="mt-5 flex min-h-0 flex-1 flex-col"
header-class="px-5 pt-4"
@@ -91,6 +90,7 @@ const { allSelected, indeterminate, selectedRowKeys, toggleSelection, toggleAll
<BaseTable
:columns="apiKeyColumns"
:rows="pagedKeys"
:loading="loading"
:selected-row-keys="selectedRowKeys"
:pagination="apiKeyPagination"
empty-text="暂无 API Key"
@@ -1,4 +1,4 @@
import { computed, onMounted, ref, type Ref } from 'vue'
import { onMounted, ref, type Ref } from 'vue'
import { clearLogs, getLogs } from '@/api'
import { toast } from '@/components/base/BaseToast'
@@ -16,10 +16,6 @@ export function useLogsTable(options: {
const showClearModal = ref(false)
const refreshingList = ref(false)
const clearingLogs = ref(false)
const loaded = ref(false)
const initialLoading = computed(() => loading.value && !loaded.value)
async function loadLogs() {
try {
loading.value = true
@@ -42,7 +38,6 @@ export function useLogsTable(options: {
toast.error(error.message || '加载失败')
} finally {
loading.value = false
loaded.value = true
}
}
@@ -83,7 +78,6 @@ export function useLogsTable(options: {
showClearModal,
refreshingList,
clearingLogs,
initialLoading,
loadLogs,
refreshLogs,
handleClearLogs,
-2
View File
@@ -34,7 +34,6 @@ const {
showClearModal,
refreshingList,
clearingLogs,
initialLoading,
loadLogs,
refreshLogs,
handleClearLogs,
@@ -65,7 +64,6 @@ bindLogLoader(loadLogs)
</header>
<BaseCard
v-loading="initialLoading"
:padded="false"
class="mt-5 flex min-h-0 flex-1 flex-col"
header-class="px-5 pt-4"
+5 -26
View File
@@ -1,13 +1,12 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { Gauge, GitBranch, Plus, RefreshCw, Save, Timer, Trash2, Zap } from '@lucide/vue'
import { Gauge, GitBranch, Plus, Save, Timer, Trash2, Zap } from '@lucide/vue'
import { getSettings, updateSettings } from '@/api'
import BaseButton from '@/components/base/BaseButton.vue'
import BaseCard from '@/components/base/BaseCard.vue'
import BaseInput from '@/components/base/BaseInput.vue'
import { toast } from '@/components/base/BaseToast'
import { withMinimumDuration } from '@/utils/async'
type RotationStrategy = 'least_used' | 'round_robin' | 'sticky'
@@ -26,7 +25,6 @@ interface SettingsForm {
}
const loading = ref(true)
const refreshing = ref(false)
const saving = ref(false)
const aliasError = ref('')
@@ -150,18 +148,8 @@ async function loadSettings() {
}
}
async function refreshSettings() {
if (refreshing.value || loading.value) return
refreshing.value = true
try {
await withMinimumDuration(loadSettings)
} finally {
refreshing.value = false
}
}
async function handleSave() {
if (saving.value || loading.value || refreshing.value) return
if (saving.value || loading.value) return
const aliasResult = rowsToAliases(aliasRows.value)
if (aliasResult.error) {
@@ -204,16 +192,10 @@ onMounted(loadSettings)
</div>
<div class="mt-0.5 flex shrink-0 items-center gap-2">
<BaseButton variant="ghost" :disabled="loading || refreshing || saving" @click="refreshSettings">
<template #icon>
<RefreshCw class="size-4" :class="refreshing ? 'animate-spin' : undefined" />
</template>
刷新
</BaseButton>
<BaseButton
variant="primary"
:loading="saving"
:disabled="loading || refreshing"
:disabled="loading"
@click="handleSave"
>
<template #icon>
@@ -226,14 +208,13 @@ onMounted(loadSettings)
<div class="mt-5 grid max-w-6xl gap-5">
<BaseCard
v-loading="loading"
:padded="false"
title="运行参数"
description="请求节奏、账号并发和 Token 刷新。"
header-class="px-5 pt-4"
body-class="px-5 py-5"
>
<div class="grid gap-4 lg:grid-cols-2">
<div class="grid items-start gap-4 lg:grid-cols-2">
<div class="grid gap-2">
<span class="text-xs leading-[1.15] font-bold text-(--cp-text-secondary)">
单账号最大并发
@@ -287,7 +268,6 @@ onMounted(loadSettings)
</BaseCard>
<BaseCard
v-loading="loading"
:padded="false"
title="模型映射"
description="把客户端可见名称指向真实上游模型。"
@@ -313,7 +293,7 @@ onMounted(loadSettings)
<div
v-for="(row, index) in aliasRows"
:key="index"
class="grid items-start gap-2 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_2.5rem]"
class="grid items-center gap-2 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_2.5rem]"
>
<BaseInput
:model-value="row.alias"
@@ -356,7 +336,6 @@ onMounted(loadSettings)
</BaseCard>
<BaseCard
v-loading="loading"
:padded="false"
title="账号选择"
description="决定每次请求如何使用账号池。"