Merge pull request #490 from presenton/feat/codex-username-tier

feat: enhance Codex user profile management with username, email, and…
This commit is contained in:
Sudip Parajuli 2026-04-03 13:27:28 +05:45 committed by GitHub
commit 9b0803dc42
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 290 additions and 33 deletions

View file

@ -16,25 +16,32 @@ from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from utils.oauth.openai_codex import (
CodexAccountProfile,
OAuthCallbackServer,
TokenSuccess,
create_authorization_flow,
exchange_authorization_code,
get_account_id,
get_account_profile,
parse_authorization_input,
refresh_access_token,
)
from utils.get_env import (
get_codex_access_token_env,
get_codex_email_env,
get_codex_is_pro_env,
get_codex_refresh_token_env,
get_codex_token_expires_env,
get_codex_username_env,
)
from utils.set_env import (
set_codex_access_token_env,
set_codex_account_id_env,
set_codex_email_env,
set_codex_is_pro_env,
set_codex_refresh_token_env,
set_codex_token_expires_env,
set_codex_model_env,
set_codex_username_env,
)
from utils.user_config import save_codex_tokens_to_user_config
@ -60,6 +67,9 @@ class InitiateResponse(BaseModel):
class StatusResponse(BaseModel):
status: str # "pending" | "success" | "failed"
account_id: Optional[str] = None
username: Optional[str] = None
email: Optional[str] = None
is_pro: Optional[bool] = None
detail: Optional[str] = None
@ -69,11 +79,17 @@ class ExchangeRequest(BaseModel):
class ExchangeResponse(BaseModel):
account_id: str
account_id: Optional[str] = None
username: Optional[str] = None
email: Optional[str] = None
is_pro: Optional[bool] = None
class RefreshResponse(BaseModel):
account_id: Optional[str]
username: Optional[str] = None
email: Optional[str] = None
is_pro: Optional[bool] = None
detail: str
@ -81,16 +97,31 @@ class RefreshResponse(BaseModel):
# Helper
# ---------------------------------------------------------------------------
def _store_token(result: TokenSuccess) -> Optional[str]:
"""Persist token fields in env vars and userConfig.json. Returns account_id or None."""
def _parse_optional_bool(value: Optional[str]) -> Optional[bool]:
if value is None:
return None
normalized = value.strip().lower()
if normalized in {"true", "1", "yes", "y"}:
return True
if normalized in {"false", "0", "no", "n"}:
return False
return None
def _store_token(result: TokenSuccess) -> CodexAccountProfile:
"""Persist token fields in env vars and userConfig.json. Returns parsed profile."""
set_codex_access_token_env(result.access)
set_codex_refresh_token_env(result.refresh)
set_codex_token_expires_env(str(result.expires))
account_id = get_account_id(result.access)
if account_id:
set_codex_account_id_env(account_id)
profile = get_account_profile(result.access, result.id_token)
set_codex_account_id_env(profile.account_id or "")
set_codex_username_env(profile.username or "")
set_codex_email_env(profile.email or "")
set_codex_is_pro_env("" if profile.is_pro is None else str(profile.is_pro))
save_codex_tokens_to_user_config()
return account_id
return profile
# ---------------------------------------------------------------------------
@ -166,8 +197,14 @@ async def poll_codex_auth_status(session_id: str):
if not isinstance(result, TokenSuccess):
return StatusResponse(status="failed", detail=result.reason)
account_id = _store_token(result)
return StatusResponse(status="success", account_id=account_id)
profile = _store_token(result)
return StatusResponse(
status="success",
account_id=profile.account_id,
username=profile.username,
email=profile.email,
is_pro=profile.is_pro,
)
@CODEX_AUTH_ROUTER.post("/exchange", response_model=ExchangeResponse)
@ -207,11 +244,16 @@ async def exchange_codex_code(body: ExchangeRequest):
if not isinstance(result, TokenSuccess):
raise HTTPException(status_code=502, detail=f"Token exchange failed: {result.reason}")
account_id = _store_token(result)
if not account_id:
profile = _store_token(result)
if not profile.account_id:
raise HTTPException(status_code=502, detail="Token exchanged but could not extract account ID")
return ExchangeResponse(account_id=account_id)
return ExchangeResponse(
account_id=profile.account_id,
username=profile.username,
email=profile.email,
is_pro=profile.is_pro,
)
@CODEX_AUTH_ROUTER.post("/refresh", response_model=RefreshResponse)
@ -232,9 +274,12 @@ async def refresh_codex_token():
if not isinstance(result, TokenSuccess):
raise HTTPException(status_code=502, detail=f"Token refresh failed: {result.reason}")
account_id = _store_token(result)
profile = _store_token(result)
return RefreshResponse(
account_id=account_id,
account_id=profile.account_id,
username=profile.username,
email=profile.email,
is_pro=profile.is_pro,
detail="Token refreshed successfully",
)
@ -260,8 +305,18 @@ async def get_codex_auth_status():
except (ValueError, TypeError):
pass
account_id = get_account_id(access_token)
return StatusResponse(status="authenticated", account_id=account_id)
profile = get_account_profile(access_token)
return StatusResponse(
status="authenticated",
account_id=profile.account_id,
username=profile.username or get_codex_username_env(),
email=profile.email or get_codex_email_env(),
is_pro=(
profile.is_pro
if profile.is_pro is not None
else _parse_optional_bool(get_codex_is_pro_env())
),
)
@CODEX_AUTH_ROUTER.post("/logout")
@ -273,6 +328,9 @@ async def logout_codex():
set_codex_refresh_token_env("")
set_codex_token_expires_env("")
set_codex_account_id_env("")
set_codex_username_env("")
set_codex_email_env("")
set_codex_is_pro_env("")
set_codex_model_env("")
save_codex_tokens_to_user_config()
return {"detail": "Logged out successfully"}

View file

@ -55,3 +55,6 @@ class UserConfig(BaseModel):
CODEX_REFRESH_TOKEN: Optional[str] = None
CODEX_TOKEN_EXPIRES: Optional[str] = None
CODEX_ACCOUNT_ID: Optional[str] = None
CODEX_USERNAME: Optional[str] = None
CODEX_EMAIL: Optional[str] = None
CODEX_IS_PRO: Optional[bool] = None

View file

@ -136,6 +136,18 @@ def get_codex_account_id_env():
return os.getenv("CODEX_ACCOUNT_ID")
def get_codex_username_env():
return os.getenv("CODEX_USERNAME")
def get_codex_email_env():
return os.getenv("CODEX_EMAIL")
def get_codex_is_pro_env():
return os.getenv("CODEX_IS_PRO")
def get_codex_model_env():
return os.getenv("CODEX_MODEL")

View file

@ -244,6 +244,7 @@ class TokenSuccess:
access: str
refresh: str
expires: int # Unix ms timestamp when the token expires
id_token: Optional[str] = None
@dataclass
@ -261,6 +262,14 @@ class AuthorizationFlow:
url: str
@dataclass
class CodexAccountProfile:
account_id: Optional[str] = None
username: Optional[str] = None
email: Optional[str] = None
is_pro: Optional[bool] = None
# ---------------------------------------------------------------------------
# JWT helpers
# ---------------------------------------------------------------------------
@ -296,6 +305,51 @@ def get_account_id(access_token: str) -> Optional[str]:
return None
def _as_non_empty_str(value) -> Optional[str]:
if isinstance(value, str):
stripped = value.strip()
return stripped or None
return None
def get_account_profile(access_token: str, id_token: Optional[str] = None) -> CodexAccountProfile:
"""Extract profile from exact observed JWT paths in access/id tokens."""
access_payload = _decode_jwt_payload(access_token) or {}
access_auth = access_payload.get(JWT_CLAIM_PATH)
access_auth = access_auth if isinstance(access_auth, dict) else {}
access_profile = access_payload.get("https://api.openai.com/profile")
access_profile = access_profile if isinstance(access_profile, dict) else {}
id_payload = _decode_jwt_payload(id_token) if id_token else None
id_payload = id_payload if isinstance(id_payload, dict) else {}
id_auth = id_payload.get(JWT_CLAIM_PATH)
id_auth = id_auth if isinstance(id_auth, dict) else {}
account_id = _as_non_empty_str(access_auth.get("chatgpt_account_id")) or _as_non_empty_str(
id_auth.get("chatgpt_account_id")
)
username = _as_non_empty_str(id_payload.get("name"))
email = _as_non_empty_str(access_profile.get("email")) or _as_non_empty_str(
id_payload.get("email")
)
plan_type = _as_non_empty_str(access_auth.get("chatgpt_plan_type")) or _as_non_empty_str(
id_auth.get("chatgpt_plan_type")
)
if plan_type:
is_pro = plan_type.strip().lower() != "free"
else:
is_pro = None
return CodexAccountProfile(
account_id=account_id,
username=username,
email=email,
is_pro=is_pro,
)
# ---------------------------------------------------------------------------
# Authorization URL + PKCE
# ---------------------------------------------------------------------------
@ -474,6 +528,7 @@ def exchange_authorization_code(
return TokenFailure(reason=f"HTTP {response.status_code}: {response.text[:200]}")
body = response.json()
access = body.get("access_token")
refresh = body.get("refresh_token")
expires_in = body.get("expires_in")
@ -482,7 +537,9 @@ def exchange_authorization_code(
return TokenFailure(reason=f"Token response missing fields: {list(body.keys())}")
expires_ms = int(time.time() * 1000) + int(expires_in) * 1000
return TokenSuccess(access=access, refresh=refresh, expires=expires_ms)
id_token = body.get("id_token")
id_token = id_token if isinstance(id_token, str) else None
return TokenSuccess(access=access, refresh=refresh, expires=expires_ms, id_token=id_token)
except Exception as exc:
return TokenFailure(reason=str(exc))

View file

@ -122,5 +122,17 @@ def set_codex_account_id_env(value: str):
os.environ["CODEX_ACCOUNT_ID"] = value
def set_codex_username_env(value: str):
os.environ["CODEX_USERNAME"] = value
def set_codex_email_env(value: str):
os.environ["CODEX_EMAIL"] = value
def set_codex_is_pro_env(value: str):
os.environ["CODEX_IS_PRO"] = value
def set_codex_model_env(value: str):
os.environ["CODEX_MODEL"] = value

View file

@ -32,6 +32,9 @@ from utils.get_env import (
get_codex_refresh_token_env,
get_codex_token_expires_env,
get_codex_account_id_env,
get_codex_username_env,
get_codex_email_env,
get_codex_is_pro_env,
get_codex_model_env,
)
from utils.parsers import parse_bool_or_none
@ -64,6 +67,9 @@ from utils.set_env import (
set_codex_refresh_token_env,
set_codex_token_expires_env,
set_codex_account_id_env,
set_codex_username_env,
set_codex_email_env,
set_codex_is_pro_env,
set_codex_model_env,
)
@ -133,6 +139,13 @@ def get_user_config():
CODEX_REFRESH_TOKEN=existing_config.CODEX_REFRESH_TOKEN or get_codex_refresh_token_env(),
CODEX_TOKEN_EXPIRES=existing_config.CODEX_TOKEN_EXPIRES or get_codex_token_expires_env(),
CODEX_ACCOUNT_ID=existing_config.CODEX_ACCOUNT_ID or get_codex_account_id_env(),
CODEX_USERNAME=existing_config.CODEX_USERNAME or get_codex_username_env(),
CODEX_EMAIL=existing_config.CODEX_EMAIL or get_codex_email_env(),
CODEX_IS_PRO=(
existing_config.CODEX_IS_PRO
if existing_config.CODEX_IS_PRO is not None
else parse_bool_or_none(get_codex_is_pro_env())
),
)
@ -196,6 +209,12 @@ def update_env_with_user_config():
set_codex_token_expires_env(user_config.CODEX_TOKEN_EXPIRES)
if user_config.CODEX_ACCOUNT_ID:
set_codex_account_id_env(user_config.CODEX_ACCOUNT_ID)
if user_config.CODEX_USERNAME:
set_codex_username_env(user_config.CODEX_USERNAME)
if user_config.CODEX_EMAIL:
set_codex_email_env(user_config.CODEX_EMAIL)
if user_config.CODEX_IS_PRO is not None:
set_codex_is_pro_env(str(user_config.CODEX_IS_PRO))
def save_codex_tokens_to_user_config() -> None:
@ -220,6 +239,9 @@ def save_codex_tokens_to_user_config() -> None:
existing["CODEX_REFRESH_TOKEN"] = get_codex_refresh_token_env()
existing["CODEX_TOKEN_EXPIRES"] = get_codex_token_expires_env()
existing["CODEX_ACCOUNT_ID"] = get_codex_account_id_env()
existing["CODEX_USERNAME"] = get_codex_username_env()
existing["CODEX_EMAIL"] = get_codex_email_env()
existing["CODEX_IS_PRO"] = parse_bool_or_none(get_codex_is_pro_env())
try:
with open(user_config_path, "w") as f:

View file

@ -50,6 +50,9 @@ export async function POST(request: Request) {
CODEX_REFRESH_TOKEN: existingConfig.CODEX_REFRESH_TOKEN,
CODEX_TOKEN_EXPIRES: existingConfig.CODEX_TOKEN_EXPIRES,
CODEX_ACCOUNT_ID: existingConfig.CODEX_ACCOUNT_ID,
CODEX_USERNAME: existingConfig.CODEX_USERNAME,
CODEX_EMAIL: existingConfig.CODEX_EMAIL,
CODEX_IS_PRO: existingConfig.CODEX_IS_PRO,
USE_CUSTOM_URL:
userConfig.USE_CUSTOM_URL === undefined
? existingConfig.USE_CUSTOM_URL

View file

@ -6,6 +6,7 @@ import {
Loader2,
RefreshCw,
Trash2,
Crown,
User,
UserCheck,
ArrowRight,
@ -34,6 +35,9 @@ type AuthStatus = "checking" | "unauthenticated" | "polling" | "authenticated";
interface StatusResponse {
status: string;
account_id?: string;
username?: string;
email?: string;
is_pro?: boolean;
detail?: string;
}
@ -60,6 +64,9 @@ export default function CodexConfig({
}: CodexConfigProps) {
const [authStatus, setAuthStatus] = useState<AuthStatus>("checking");
const [accountId, setAccountId] = useState<string | null>(null);
const [username, setUsername] = useState<string | null>(null);
const [email, setEmail] = useState<string | null>(null);
const [isPro, setIsPro] = useState<boolean | null>(null);
const [sessionId, setSessionId] = useState<string | null>(null);
const [manualCode, setManualCode] = useState("");
const [isExchanging, setIsExchanging] = useState(false);
@ -80,22 +87,32 @@ export default function CodexConfig({
return () => stopPolling();
}, []);
const applyProfile = (data: Partial<StatusResponse>) => {
setAccountId(data.account_id ?? null);
setUsername(data.username ?? null);
setEmail(data.email ?? null);
setIsPro(typeof data.is_pro === "boolean" ? data.is_pro : null);
};
const checkCurrentAuthStatus = async () => {
try {
const res = await fetch(getApiUrl("/api/v1/ppt/codex/auth/status"));
if (!res.ok) {
setAuthStatus("unauthenticated");
applyProfile({});
return;
}
const data: StatusResponse = await res.json();
if (data.status === "authenticated") {
setAuthStatus("authenticated");
setAccountId(data.account_id ?? null);
applyProfile(data);
} else {
setAuthStatus("unauthenticated");
applyProfile({});
}
} catch {
setAuthStatus("unauthenticated");
applyProfile({});
}
};
@ -125,7 +142,7 @@ export default function CodexConfig({
if (pollData.status === "success") {
stopPolling();
setAuthStatus("authenticated");
setAccountId(pollData.account_id ?? null);
applyProfile(pollData);
setSessionId(null);
if (!codexModel) {
onInputChange(DEFAULT_CODEX_MODEL, "codex_model");
@ -134,6 +151,7 @@ export default function CodexConfig({
} else if (pollData.status === "failed") {
stopPolling();
setAuthStatus("unauthenticated");
applyProfile({});
toast.error("Authentication failed. Please try again.");
}
} catch {
@ -143,6 +161,7 @@ export default function CodexConfig({
} catch (err) {
toast.error("Failed to start sign-in flow");
setAuthStatus("unauthenticated");
applyProfile({});
}
};
@ -162,7 +181,7 @@ export default function CodexConfig({
const data = await res.json();
stopPolling();
setAuthStatus("authenticated");
setAccountId(data.account_id);
applyProfile(data);
setSessionId(null);
setManualCode("");
if (!codexModel) {
@ -189,6 +208,9 @@ export default function CodexConfig({
await fetch(getApiUrl("/api/v1/ppt/codex/auth/logout"), { method: "POST" });
setAuthStatus("unauthenticated");
setAccountId(null);
setUsername(null);
setEmail(null);
setIsPro(null);
onInputChange("", "codex_model");
toast.success("Signed out from ChatGPT");
} catch {
@ -206,11 +228,12 @@ export default function CodexConfig({
});
if (!res.ok) throw new Error("Refresh failed");
const data = await res.json();
if (data.account_id) setAccountId(data.account_id);
applyProfile(data);
toast.success("Token refreshed successfully");
} catch {
toast.error("Token refresh failed. Please sign in again.");
setAuthStatus("unauthenticated");
applyProfile({});
} finally {
setIsRefreshing(false);
}
@ -287,17 +310,39 @@ export default function CodexConfig({
}
if (authStatus === "authenticated") {
const planLabel = isPro === true ? "Pro" : isPro === false ? "Free" : "Unknown";
return (
<div className=" mb-5">
<div className="flex items-center justify-between gap-3 p-5 border border-[#EDEEEF] rounded-[8px]">
<div className="flex items-center gap-3">
<UserCheck className="w-6 h-6 text-black shrink-0" />
<div className="flex-gpt 5.4 mini1 min-w-0">
{accountId && (
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 min-w-0">
<p className="text-sm font-medium text-gray-800 truncate">
Acc: {accountId}
{username || email || (accountId ? `Account ${accountId}` : "ChatGPT Account")}
</p>
<span
className={cn(
"inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium shrink-0",
isPro === true ? "bg-amber-100 text-amber-800" : "bg-gray-100 text-gray-700"
)}
title={`Subscription: ${planLabel}`}
>
{isPro === true ? (
<Crown className="w-3 h-3" />
) : (
<User className="w-3 h-3" />
)}
{planLabel}
</span>
</div>
{email && username && (
<p className="text-xs text-gray-500 truncate">{email}</p>
)}
{!email && accountId && (
<p className="text-xs text-gray-500 truncate">ID: {accountId}</p>
)}
<p className="text-xs text-gray-400">Signed in to ChatGPT</p>
</div>

View file

@ -6,6 +6,7 @@ import {
Loader2,
RefreshCw,
Trash2,
Crown,
User,
UserCheck,
} from "lucide-react";
@ -33,6 +34,9 @@ type AuthStatus = "checking" | "unauthenticated" | "polling" | "authenticated";
interface StatusResponse {
status: string;
account_id?: string;
username?: string;
email?: string;
is_pro?: boolean;
detail?: string;
}
@ -59,6 +63,9 @@ export default function CodexConfig({
}: CodexConfigProps) {
const [authStatus, setAuthStatus] = useState<AuthStatus>("checking");
const [accountId, setAccountId] = useState<string | null>(null);
const [username, setUsername] = useState<string | null>(null);
const [email, setEmail] = useState<string | null>(null);
const [isPro, setIsPro] = useState<boolean | null>(null);
const [sessionId, setSessionId] = useState<string | null>(null);
const [manualCode, setManualCode] = useState("");
const [isExchanging, setIsExchanging] = useState(false);
@ -79,22 +86,32 @@ export default function CodexConfig({
return () => stopPolling();
}, []);
const applyProfile = (data: Partial<StatusResponse>) => {
setAccountId(data.account_id ?? null);
setUsername(data.username ?? null);
setEmail(data.email ?? null);
setIsPro(typeof data.is_pro === "boolean" ? data.is_pro : null);
};
const checkCurrentAuthStatus = async () => {
try {
const res = await fetch(getApiUrl("/api/v1/ppt/codex/auth/status"));
if (!res.ok) {
setAuthStatus("unauthenticated");
applyProfile({});
return;
}
const data: StatusResponse = await res.json();
if (data.status === "authenticated") {
setAuthStatus("authenticated");
setAccountId(data.account_id ?? null);
applyProfile(data);
} else {
setAuthStatus("unauthenticated");
applyProfile({});
}
} catch {
setAuthStatus("unauthenticated");
applyProfile({});
}
};
@ -122,7 +139,7 @@ export default function CodexConfig({
if (pollData.status === "success") {
stopPolling();
setAuthStatus("authenticated");
setAccountId(pollData.account_id ?? null);
applyProfile(pollData);
setSessionId(null);
if (!codexModel) {
onInputChange(DEFAULT_CODEX_MODEL, "codex_model");
@ -131,6 +148,7 @@ export default function CodexConfig({
} else if (pollData.status === "failed") {
stopPolling();
setAuthStatus("unauthenticated");
applyProfile({});
toast.error("Authentication failed. Please try again.");
}
} catch {
@ -140,6 +158,7 @@ export default function CodexConfig({
} catch (err) {
toast.error("Failed to start sign-in flow");
setAuthStatus("unauthenticated");
applyProfile({});
}
};
@ -159,7 +178,7 @@ export default function CodexConfig({
const data = await res.json();
stopPolling();
setAuthStatus("authenticated");
setAccountId(data.account_id);
applyProfile(data);
setSessionId(null);
setManualCode("");
if (!codexModel) {
@ -185,7 +204,7 @@ export default function CodexConfig({
try {
await fetch(getApiUrl("/api/v1/ppt/codex/auth/logout"), { method: "POST" });
setAuthStatus("unauthenticated");
setAccountId(null);
applyProfile({});
onInputChange("", "codex_model");
toast.success("Signed out from ChatGPT");
} catch {
@ -203,11 +222,12 @@ export default function CodexConfig({
});
if (!res.ok) throw new Error("Refresh failed");
const data = await res.json();
if (data.account_id) setAccountId(data.account_id);
applyProfile(data);
toast.success("Token refreshed successfully");
} catch {
toast.error("Token refresh failed. Please sign in again.");
setAuthStatus("unauthenticated");
applyProfile({});
} finally {
setIsRefreshing(false);
}
@ -266,15 +286,37 @@ export default function CodexConfig({
}
if (authStatus === "authenticated") {
const planLabel = isPro === true ? "Pro" : isPro === false ? "Free" : "Unknown";
return (
<div className="space-y-4">
<div className="flex items-center gap-3 p-3 border border-[#EDEEEF] rounded-[8px]">
<div className="flex items-center gap-3 p-3 border border-[#EDEEEF] rounded-lg">
<UserCheck className="w-5 h-5 text-black shrink-0" />
<div className="flex-1 min-w-0">
{accountId && (
<div className="flex items-center gap-2 min-w-0">
<p className="text-sm font-medium text-gray-800 truncate">
Acc: {accountId}
{username || email || (accountId ? `Account ${accountId}` : "ChatGPT Account")}
</p>
<span
className={cn(
"inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium shrink-0",
isPro === true ? "bg-amber-100 text-amber-800" : "bg-gray-100 text-gray-700"
)}
title={`Subscription: ${planLabel}`}
>
{isPro === true ? (
<Crown className="w-3 h-3" />
) : (
<User className="w-3 h-3" />
)}
{planLabel}
</span>
</div>
{email && username && (
<p className="text-xs text-gray-500 truncate">{email}</p>
)}
{!email && accountId && (
<p className="text-xs text-gray-500 truncate">ID: {accountId}</p>
)}
<p className="text-xs text-gray-400">Signed in to ChatGPT</p>
</div>

View file

@ -49,6 +49,9 @@ export interface LLMConfig {
CODEX_REFRESH_TOKEN?: string;
CODEX_TOKEN_EXPIRES?: string;
CODEX_ACCOUNT_ID?: string;
CODEX_USERNAME?: string;
CODEX_EMAIL?: string;
CODEX_IS_PRO?: boolean;
// Only used in UI settings
USE_CUSTOM_URL?: boolean;