feat: 增强 OAuth 标识展示与 Codex 调试日志

- 优化 OAuth badge 支持 account_id/account_user_id,tooltip 展示完整身份信息
- 重构池管理额度进度条布局,倒计时独立展示并增加样式区分
- Codex 插件增加 token 解析快照日志,便于排查 OAuth 透传问题

Closes #245
Co-Authored-By: kayphoon <109347466+Kayphoon@users.noreply.github.com>
This commit is contained in:
fawney19
2026-03-19 00:50:18 +08:00
parent 6e55968487
commit 8a6a961900
5 changed files with 197 additions and 62 deletions

View File

@@ -125,7 +125,7 @@
v-if="getOAuthOrgBadge(key)"
variant="secondary"
class="text-[10px] px-1 py-0 h-4 shrink-0"
:title="getOAuthOrgBadge(key)?.id"
:title="getOAuthOrgBadge(key)?.title"
>{{ getOAuthOrgBadge(key)?.label }}</Badge>
<Badge
v-if="isBannedKey(key)"

View File

@@ -293,7 +293,7 @@
v-if="getOAuthOrgBadge(key)"
variant="secondary"
class="text-[10px] px-1.5 py-0 shrink-0"
:title="getOAuthOrgBadge(key)?.id"
:title="getOAuthOrgBadge(key)?.title"
>
{{ getOAuthOrgBadge(key)?.label }}
</Badge>

View File

@@ -1,7 +1,9 @@
import type { OAuthOrganizationInfo } from '@/api/endpoints/types/provider'
type OAuthIdentityDisplayValue = {
oauth_account_id?: string | null
oauth_account_name?: string | null
oauth_account_user_id?: string | null
oauth_organizations?: OAuthOrganizationInfo[] | null
} | null | undefined
@@ -16,6 +18,14 @@ function formatOAuthIdentityShort(
return `${normalized.slice(0, head)}...${normalized.slice(-tail)}`
}
function readStr(raw: unknown): string {
return typeof raw === 'string' ? raw.trim() : ''
}
function formatOAuthAccountBadge(accountId: string): string {
return accountId.slice(0, 8)
}
function getPrimaryOAuthOrganization(
value: OAuthIdentityDisplayValue,
): { id: string; title: string } | null {
@@ -44,16 +54,30 @@ function getPrimaryOAuthOrganization(
export function getOAuthOrgBadge(
value: OAuthIdentityDisplayValue,
): { id: string; label: string } | null {
): { id: string; label: string; title: string } | null {
const org = getPrimaryOAuthOrganization(value)
if (!org) return null
const accountName = typeof value?.oauth_account_name === 'string'
? value.oauth_account_name.trim()
: ''
const accountId = readStr(value?.oauth_account_id)
const accountName = readStr(value?.oauth_account_name)
const accountUserId = readStr(value?.oauth_account_user_id)
const badgeId = accountId || org?.id || ''
const label = accountName
|| (accountId ? formatOAuthAccountBadge(accountId) : '')
|| (org?.id ? `org:${formatOAuthIdentityShort(org.id, 6, 4)}` : '')
if (!badgeId || !label) return null
const titleParts = [
accountName ? `name: ${accountName}` : '',
accountId ? `account_id: ${accountId}` : '',
accountUserId ? `account_user_id: ${accountUserId}` : '',
org?.id ? `org_id: ${org.id}` : '',
org?.title ? `org_title: ${org.title}` : '',
].filter(Boolean)
return {
id: org.id,
label: accountName || org.title || formatOAuthIdentityShort(org.id),
id: badgeId,
label,
title: titleParts.join(' | '),
}
}

View File

@@ -496,7 +496,7 @@
v-if="getOAuthOrgBadge(key)"
variant="secondary"
class="text-[9px] px-1 py-0 h-4 shrink-0"
:title="getOAuthOrgBadge(key)?.id"
:title="getOAuthOrgBadge(key)?.title"
>
{{ getOAuthOrgBadge(key)?.label }}
</Badge>
@@ -509,42 +509,43 @@
>
<div
v-if="quotaProgressMap[key.key_id]?.length"
class="space-y-1 max-w-[220px]"
class="max-w-[208px] space-y-2"
>
<div
v-for="(item, idx) in quotaProgressMap[key.key_id].slice(0, 2)"
:key="`${key.key_id}-quota-${idx}`"
class="w-full"
class="space-y-0.5"
>
<div class="min-h-4 grid grid-cols-[20px_minmax(0,1fr)_80px] items-center gap-1 text-[10px] leading-tight">
<div class="flex items-center justify-between gap-2 min-w-0">
<span
class="text-muted-foreground whitespace-nowrap text-right tabular-nums"
class="shrink-0 text-[10px] leading-none text-muted-foreground tabular-nums whitespace-nowrap"
:title="getQuotaProgressTooltip(item)"
>
{{ getQuotaProgressLabel(item.label) }}
</span>
<div class="relative flex-1 h-1.5 bg-border rounded-full overflow-hidden">
<span
v-if="getQuotaProgressDisplayText(item)"
class="min-w-0 truncate text-[9px] leading-none tabular-nums text-right"
:class="getQuotaProgressDisplayClass(item)"
:title="getQuotaProgressTooltip(item)"
>
{{ getQuotaProgressDisplayText(item) }}
</span>
</div>
<div class="grid grid-cols-[minmax(0,1fr)_50px] items-center gap-2">
<div class="relative h-1.5 rounded-full bg-border/80 overflow-hidden">
<div
class="absolute left-0 top-0 h-full transition-all duration-300"
class="absolute left-0 top-0 h-full rounded-full transition-all duration-300"
:class="getQuotaRemainingBarColorByRemaining(item.remainingPercent)"
:style="{ width: `${item.remainingPercent}%` }"
/>
</div>
<div class="flex flex-col items-end justify-center gap-0.5 text-right leading-tight">
<span
class="tabular-nums whitespace-nowrap"
:class="getQuotaRemainingClassByRemaining(item.remainingPercent)"
>
{{ item.remainingPercent.toFixed(1) }}%
</span>
<span
v-if="getQuotaProgressCountdownText(item)"
class="text-[9px] text-muted-foreground whitespace-nowrap"
:title="getQuotaProgressTooltip(item)"
>
{{ getQuotaProgressCountdownText(item) }}
</span>
</div>
<span
class="text-[11px] font-medium leading-none tabular-nums whitespace-nowrap text-right"
:class="getQuotaRemainingClassByRemaining(item.remainingPercent)"
>
{{ item.remainingPercent.toFixed(1) }}%
</span>
</div>
</div>
</div>
@@ -815,7 +816,7 @@
v-if="getOAuthOrgBadge(key)"
variant="secondary"
class="text-[9px] px-1 py-0 h-4 shrink-0"
:title="getOAuthOrgBadge(key)?.id"
:title="getOAuthOrgBadge(key)?.title"
>
{{ getOAuthOrgBadge(key)?.label }}
</Badge>
@@ -973,42 +974,43 @@
</div>
<div
v-if="quotaProgressMap[key.key_id]?.length"
class="space-y-1"
class="space-y-2"
>
<div
v-for="(item, idx) in quotaProgressMap[key.key_id]"
:key="`${key.key_id}-quota-mobile-${idx}`"
class="w-full"
class="space-y-0.5"
>
<div class="grid grid-cols-[20px_minmax(0,1fr)_80px] items-center gap-1 text-[10px] leading-tight">
<div class="flex items-center justify-between gap-2 min-w-0">
<span
class="text-muted-foreground whitespace-nowrap text-right tabular-nums"
class="shrink-0 text-[10px] leading-none text-muted-foreground tabular-nums whitespace-nowrap"
:title="getQuotaProgressTooltip(item)"
>
{{ getQuotaProgressLabel(item.label) }}
</span>
<div class="relative flex-1 h-1.5 bg-border rounded-full overflow-hidden">
<span
v-if="getQuotaProgressDisplayText(item)"
class="min-w-0 truncate text-[9px] leading-none tabular-nums text-right"
:class="getQuotaProgressDisplayClass(item)"
:title="getQuotaProgressTooltip(item)"
>
{{ getQuotaProgressDisplayText(item) }}
</span>
</div>
<div class="grid grid-cols-[minmax(0,1fr)_50px] items-center gap-2">
<div class="relative h-1.5 rounded-full bg-border/80 overflow-hidden">
<div
class="absolute left-0 top-0 h-full transition-all duration-300"
class="absolute left-0 top-0 h-full rounded-full transition-all duration-300"
:class="getQuotaRemainingBarColorByRemaining(item.remainingPercent)"
:style="{ width: `${item.remainingPercent}%` }"
/>
</div>
<div class="flex flex-col items-end justify-center gap-0.5 text-right leading-tight">
<span
class="tabular-nums whitespace-nowrap"
:class="getQuotaRemainingClassByRemaining(item.remainingPercent)"
>
{{ item.remainingPercent.toFixed(1) }}%
</span>
<span
v-if="getQuotaProgressCountdownText(item)"
class="text-[9px] text-muted-foreground whitespace-nowrap"
:title="getQuotaProgressTooltip(item)"
>
{{ getQuotaProgressCountdownText(item) }}
</span>
</div>
<span
class="text-[11px] font-medium leading-none tabular-nums whitespace-nowrap text-right"
:class="getQuotaRemainingClassByRemaining(item.remainingPercent)"
>
{{ item.remainingPercent.toFixed(1) }}%
</span>
</div>
</div>
</div>
@@ -2456,6 +2458,32 @@ function getQuotaProgressCountdownText(item: QuotaProgressItem): string {
return status.isExpired ? status.text : `${status.text} 后重置`
}
function formatCompactQuotaCountdownText(text: string): string {
const normalized = text.trim()
const dayMatch = normalized.match(/^(\d+)天\s+(.+?)(?:\s+后重置)?$/)
if (dayMatch) {
return `${dayMatch[1]}天 ${dayMatch[2]}`
}
return normalized.replace(/\s+后重置$/, '')
}
function getQuotaProgressDisplayText(item: QuotaProgressItem): string {
const countdownText = getQuotaProgressCountdownText(item)
if (countdownText) return formatCompactQuotaCountdownText(countdownText)
// remainingPercent >= 100 时 getCodexResetCountdown 返回 null倒计时为空
// 但 UI 仍需展示"满额未消耗"的占位文本
if ((item.label === '5H' || item.label === '周') && item.remainingPercent >= 100) return '0天 00:00:00'
return item.detail?.trim() || ''
}
function getQuotaProgressDisplayClass(item: QuotaProgressItem): string {
const status = getQuotaProgressCountdown(item)
if (!status || status.isExpired) return 'text-muted-foreground/70'
if (status.isCritical) return 'text-destructive font-medium animate-pulse'
if (status.isUrgent) return 'text-amber-500 dark:text-amber-400'
return 'text-muted-foreground/70'
}
function getQuotaProgressTooltip(item: QuotaProgressItem): string {
if ((item.label === '5H' || item.label === '周') && item.remainingPercent >= 100) {
return ''

View File

@@ -53,6 +53,65 @@ def _build_short_header_id(seed: str) -> str:
return hashlib.sha256(seed.encode()).hexdigest()[:16]
def _normalize_codex_debug_organizations(value: Any) -> list[dict[str, Any]]:
if not isinstance(value, list):
return []
items: list[dict[str, Any]] = []
for raw in value:
if not isinstance(raw, Mapping):
continue
normalized: dict[str, Any] = {}
org_id = str(raw.get("id") or "").strip()
if org_id:
normalized["id"] = org_id
title = str(raw.get("title") or "").strip()
if title:
normalized["title"] = title
role = str(raw.get("role") or "").strip()
if role:
normalized["role"] = role
if "is_default" in raw:
normalized["is_default"] = bool(raw.get("is_default"))
if normalized:
items.append(normalized)
return items
def _build_safe_codex_debug_snapshot(values: Mapping[str, Any] | None) -> dict[str, Any]:
if not isinstance(values, Mapping):
return {}
snapshot: dict[str, Any] = {}
for field in (
"email",
"account_id",
"account_user_id",
"plan_type",
"user_id",
"account_name",
):
raw = values.get(field)
if isinstance(raw, str):
normalized = raw.strip()
if normalized:
snapshot[field] = normalized
elif raw is not None:
snapshot[field] = raw
organizations = _normalize_codex_debug_organizations(values.get("organizations"))
if organizations:
snapshot["organizations"] = organizations
return snapshot
def _build_codex_headers(
request_body: Any,
original_headers: Mapping[str, Any] | None,
@@ -213,24 +272,40 @@ async def enrich_codex(
if direct_email and not auth_config.get("email"):
auth_config["email"] = direct_email
direct_snapshot = _build_safe_codex_debug_snapshot(
{
"email": direct_email,
"account_id": direct_account_id,
"account_user_id": direct_account_user_id,
"plan_type": direct_plan_type,
"user_id": direct_user_id,
}
)
logger.debug(
"Codex enrich_auth_config: id_token_present={} access_token_present={} token_keys={}",
bool(token_response.get("id_token") or token_response.get("idToken")),
bool(token_response.get("access_token") or token_response.get("accessToken")),
list(token_response.keys()),
)
logger.debug("Codex direct token response values: {}", direct_snapshot)
token_candidates = [
token_response.get("id_token"),
token_response.get("idToken"),
token_response.get("access_token"),
token_response.get("accessToken"),
("id_token", token_response.get("id_token")),
("idToken", token_response.get("idToken")),
("access_token", token_response.get("access_token")),
("accessToken", token_response.get("accessToken")),
]
for token_payload in token_candidates:
for source_name, token_payload in token_candidates:
codex_info = parse_codex_id_token(token_payload)
if not codex_info:
continue
logger.debug("Codex parsed token fields: {}", list(codex_info.keys()))
logger.debug(
"Codex parsed token values: source={} fields={} values={}",
source_name,
list(codex_info.keys()),
_build_safe_codex_debug_snapshot(codex_info),
)
for key, value in codex_info.items():
if not auth_config.get(key):
auth_config[key] = value
@@ -243,9 +318,19 @@ async def enrich_codex(
proxy_config=proxy_config,
timeout_seconds=10.0,
)
logger.debug(
"Codex account_name lookup: account_id={} resolved_account_name={}",
account_id,
account_name,
)
if account_name:
auth_config["account_name"] = account_name
logger.debug(
"Codex enrich_auth_config final metadata: {}",
_build_safe_codex_debug_snapshot(auth_config),
)
return auth_config
@@ -274,9 +359,7 @@ def register_all() -> None:
# Provider Format Capability默认 body_rules
from src.core.api_format.metadata import CODEX_DEFAULT_BODY_RULES
register_provider_default_body_rules(
"codex", "openai:cli", CODEX_DEFAULT_BODY_RULES
)
register_provider_default_body_rules("codex", "openai:cli", CODEX_DEFAULT_BODY_RULES)
# Export: Codex uses the default export builder (strip null + temp fields)
# No need to register a custom one — the default in export.py suffices.