const CODEX_USAGE_API = 'https://chatgpt.com/backend-api/wham/usage'
const REFRESH_INTERVAL = 5 * 60 * 1000
/** @type {EsmPlugin} */
export default (Plugin) => {
const onRun = async () => {
openUsageUI(Plugin)
return 0
}
const onReady = async () => {
addCoreStateUI(Plugin)
startAutoRefresh(Plugin)
return 0
}
const onEnabled = onReady
const onInstall = onReady
const onDispose = () => {
stopAutoRefresh(Plugin)
removeCoreStateUI(Plugin)
return 0
}
const onTrayUpdate = async (tray, menus) => {
return {
tray: {
...tray,
tooltip: getTrayTooltip(getPluginState(Plugin).accounts.value)
},
menus
}
}
return { onRun, onReady, onEnabled, onInstall, onDispose, onTrayUpdate }
}
function openUsageUI(Plugin) {
const { h, defineComponent, ref, computed, onMounted, resolveComponent } = Vue
const state = getPluginState(Plugin)
const showEmail = ref(false)
const component = defineComponent({
template: /* html */ `
Codex 额度
5小时额度 / 周额度剩余与重置时间
{{ accounts.length }} 个账号
{{ account.success ? '可用' : '失败' }}
{{ formatAccount(account) }}
{{ account.allowed ? 'Available' : 'Rate limited' }}
5小时额度
剩余 {{ account.primary.remaining }}%,已用 {{ account.primary.used }}%
重置倒计时 {{ account.primary.left }},本地时间 {{ account.primary.resetAt }}
周额度
剩余 {{ account.secondary.remaining }}%,已用 {{ account.secondary.used }}%
重置倒计时 {{ account.secondary.left }},本地时间 {{ account.secondary.resetAt }}
{{ account.source }}
{{ account.error }}
账号列表为空时,会尝试读取用户目录下的 .codex/auth.json。配置多账号时,每行填写:名称|auth.json完整路径,或 名称|Bearer token/access_token,或 名称|Cookie字符串。
`,
setup() {
async function refreshUsage() {
await refreshAndUpdateTray(Plugin)
}
onMounted(() => {
if (state.accounts.value.length === 0 && !state.loading.value) {
refreshUsage()
}
})
return {
accounts: state.accounts,
loading: state.loading,
hasAnyError: computed(() => state.accounts.value.some((item) => !item.success)),
refreshUsage,
formatAccount(account) {
const email = showEmail.value ? account.email || 'Unknown email' : '邮箱已隐藏'
const plan = account.plan || 'Unknown plan'
return `${email} - ${plan} - ${account.updatedAt}`
}
}
}
})
const modal = Plugins.modal(
{
title: Plugin.name,
width: '90',
height: '90',
submit: false,
maskClosable: true,
cancelText: '关闭',
afterClose: () => modal.destroy()
},
{
toolbar: () =>
h(
resolveComponent('Button'),
{
type: 'text',
onClick: () => {
showEmail.value = !showEmail.value
}
},
() => (showEmail.value ? '隐藏邮箱' : '显示邮箱')
),
default: () => h(component)
}
)
modal.open()
}
async function loadAccountResults(Plugin) {
const accounts = await getConfiguredAccounts(Plugin)
const results = await Promise.all(accounts.map((account) => loadAccountUsage(account)))
return results
}
async function refreshAndUpdateTray(Plugin) {
const state = getPluginState(Plugin)
state.loading.value = true
try {
const accounts = await loadAccountResults(Plugin)
state.accounts.value = accounts
await updateTrayTooltip(accounts)
return accounts
} finally {
state.loading.value = false
}
}
function startAutoRefresh(Plugin) {
const state = getPluginState(Plugin)
stopAutoRefresh(Plugin)
state.timer = setInterval(() => {
refreshAndUpdateTray(Plugin).catch((e) => {
console.log(`[${Plugin.name}]`, '自动刷新 Codex 用量失败', e)
})
}, REFRESH_INTERVAL)
refreshAndUpdateTray(Plugin).catch((e) => {
console.log(`[${Plugin.name}]`, '刷新 Codex 用量失败', e)
})
}
function stopAutoRefresh(Plugin) {
const state = getPluginState(Plugin)
if (state.timer) {
clearInterval(state.timer)
state.timer = null
}
}
function getPluginState(Plugin) {
window[Plugin.id] = window[Plugin.id] || {
timer: null,
accounts: Vue.ref([]),
loading: Vue.ref(false)
}
return window[Plugin.id]
}
function addCoreStateUI(Plugin) {
const uiId = Plugin.id + '_core_state'
const appStore = Plugins.useAppStore()
appStore.removeCustomActions('core_state', [uiId])
appStore.addCustomActions('core_state', {
id: uiId,
component: 'Button',
componentProps: {
type: 'link',
size: 'small',
onClick: () => openUsageUI(Plugin)
},
componentSlots: {
default: ({ h }) => h('span', getCoreStateText(Plugin))
}
})
}
function removeCoreStateUI(Plugin) {
Plugins.useAppStore().removeCustomActions('core_state', [Plugin.id + '_core_state'])
}
function getCoreStateText(Plugin) {
const state = getPluginState(Plugin)
if (state.loading.value) return 'Codex 刷新中'
const okAccounts = state.accounts.value.filter((item) => item.success)
if (okAccounts.length === 0) return 'Codex --'
return `Codex ${okAccounts[0].primary.remaining}%`
}
async function updateTrayTooltip(accounts) {
await Plugins.UpdateTray({
tooltip: getTrayTooltip(accounts)
})
}
function getTrayTooltip(accounts) {
const okAccounts = accounts.filter((item) => item.success)
const value = okAccounts.length > 0 ? `${okAccounts[0].primary.remaining}%` : 'error'
return Plugins.APP_TITLE + ' ' + Plugins.APP_VERSION + '\n' + 'codex: ' + value
}
async function getConfiguredAccounts(Plugin) {
const entries = Array.isArray(Plugin.Accounts) ? Plugin.Accounts.filter((item) => String(item || '').trim()) : []
if (entries.length > 0) {
return entries.map(parseAccountEntry)
}
const path = await getDefaultAuthPath()
return [
{
id: 'default',
name: '默认账号',
authPath: path,
source: path || '用户目录/.codex/auth.json'
}
]
}
function parseAccountEntry(entry, index) {
const text = String(entry || '').trim()
if (!text) {
return { id: `account-${index}`, name: `账号 ${index + 1}`, source: '' }
}
if (text.startsWith('{')) {
const account = JSON.parse(text)
return {
id: `account-${index}`,
name: account.name || `账号 ${index + 1}`,
authPath: account.authPath || account.path || '',
authorization: account.authorization || account.Authorization || account.token || '',
cookie: account.cookie || account.Cookie || '',
source: account.authPath || account.path || 'JSON 配置'
}
}
const parts = splitAccountEntry(text)
const name = parts.length > 1 ? parts[0].trim() : `账号 ${index + 1}`
const credential = (parts.length > 1 ? parts.slice(1).join('|') : parts[0]).trim()
const account = {
id: `account-${index}`,
name,
source: credential
}
if (/^cookie\s*=/i.test(credential)) {
account.cookie = credential.replace(/^cookie\s*=\s*/i, '')
} else if (/^authorization\s*=/i.test(credential)) {
account.authorization = credential.replace(/^authorization\s*=\s*/i, '')
} else if (credential.includes('__Secure-next-auth.session-token=')) {
account.cookie = credential
} else if (looksLikeAuthPath(credential)) {
account.authPath = credential
} else {
account.authorization = credential
}
return account
}
function splitAccountEntry(text) {
if (text.includes('|')) return text.split('|')
if (text.includes(',')) return text.split(',')
return [text]
}
async function loadAccountUsage(account) {
try {
const usage = await fetchCodexUsage(account)
const rateLimit = usage.rate_limit || {}
return {
...account,
success: true,
email: usage.email,
plan: usage.plan_type,
allowed: !!rateLimit.allowed && !rateLimit.limit_reached,
primary: getWindowSummary(rateLimit.primary_window),
secondary: getWindowSummary(rateLimit.secondary_window),
updatedAt: new Date().toLocaleString()
}
} catch (e) {
return {
...account,
success: false,
error: formatError(e),
primary: getWindowSummary(),
secondary: getWindowSummary(),
updatedAt: new Date().toLocaleString()
}
}
}
async function fetchCodexUsage(account) {
const headers = await getAuthHeaders(account)
const { status, body } = await Plugins.HttpGet(CODEX_USAGE_API, headers)
const data = typeof body === 'string' ? JSON.parse(body) : body
if (status && (status < 200 || status >= 300)) {
throw new Error(`HTTP ${status}`)
}
if (!data?.rate_limit) {
throw new Error('返回数据缺少 rate_limit 字段')
}
return data
}
async function getAuthHeaders(account) {
const headers = {
Accept: 'application/json',
'User-Agent': 'PluginHubCodexUsage/1.0'
}
if (account.authorization) {
headers.Authorization = normalizeAuthorization(account.authorization)
}
if (account.cookie) {
headers.Cookie = account.cookie
}
if (!headers.Authorization && account.authPath) {
const accessToken = await readCodexAccessToken(account.authPath)
if (accessToken) headers.Authorization = `Bearer ${accessToken}`
}
if (!headers.Authorization && !headers.Cookie) {
throw new Error('未找到授权信息')
}
return headers
}
async function readCodexAccessToken(path) {
const raw = await Plugins.ReadFile(path)
if (!raw || !raw.trim()) return ''
const auth = JSON.parse(raw)
if (auth?.access_token) return String(auth.access_token)
if (auth?.tokens?.access_token) return String(auth.tokens.access_token)
return ''
}
async function getDefaultAuthPath() {
const home = normalizePath((await Plugins.GetEnv('USERPROFILE').catch(() => '')) || (await Plugins.GetEnv('HOME').catch(() => '')))
return home ? `${home}/.codex/auth.json` : ''
}
function normalizePath(path) {
return String(path || '').replaceAll('\\', '/')
}
function looksLikeAuthPath(value) {
return /\.json$/i.test(value) || value.includes('/.codex/') || value.includes('\\.codex\\')
}
function normalizeAuthorization(value) {
const text = String(value).trim()
if (!text) return ''
return /^Bearer\s+/i.test(text) ? text : `Bearer ${text}`
}
function getWindowSummary(window) {
const used = clampPercent(Number(window?.used_percent || 0))
return {
used,
remaining: clampPercent(100 - used),
left: formatTimeLeft(window?.reset_after_seconds),
resetAt: formatResetAt(window?.reset_at)
}
}
function clampPercent(value) {
if (!Number.isFinite(value)) return 0
return Math.max(0, Math.min(100, Math.round(value)))
}
function formatTimeLeft(seconds) {
if (!Number.isFinite(Number(seconds)) || Number(seconds) < 0) return '--'
const total = Number(seconds)
const days = Math.floor(total / 86400)
const hours = Math.floor((total % 86400) / 3600)
const minutes = Math.ceil((total % 3600) / 60)
if (days >= 1) return `${days}d ${hours}h`
if (hours >= 1) return `${hours}h ${Math.max(0, minutes)}m`
return `${Math.max(0, minutes)}m`
}
function formatResetAt(unixSeconds) {
if (!unixSeconds) return '--'
const date = new Date(Number(unixSeconds) * 1000)
if (Number.isNaN(date.getTime())) return '--'
return date.toLocaleString()
}
function formatError(error) {
const message = error?.message || String(error || '未知错误')
return message.length > 180 ? message.slice(0, 180) + '...' : message
}