mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
feat(oauth): 新增 Codex account_user_id 和 organizations 字段采集、展示与判重
- 从 Codex id_token claims 和 token_response 中提取 account_user_id 和 organizations - OAuth 判重逻辑改为优先按 account_user_id 匹配,支持同用户不同 Team 不误判 - 号池和 Provider 详情页展示组织标签、account ID 和 account_user_id - 前端重复的 OAuth identity 工具函数提取到 utils/oauthIdentity.ts - 后端重复的 normalize_oauth_organizations 提取到 core/provider_oauth_utils.py
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import client from '../client'
|
import client from '../client'
|
||||||
import { dedupedRequest } from '@/utils/cache'
|
import { dedupedRequest } from '@/utils/cache'
|
||||||
import type { AllowedModels, ProxyConfig } from './types/provider'
|
import type { AllowedModels, OAuthOrganizationInfo, ProxyConfig } from './types/provider'
|
||||||
|
|
||||||
const POOL_BATCH_ACTION_TIMEOUT_MS = 5 * 60 * 1000
|
const POOL_BATCH_ACTION_TIMEOUT_MS = 5 * 60 * 1000
|
||||||
|
|
||||||
@@ -102,6 +102,9 @@ export interface PoolKeyDetail {
|
|||||||
oauth_invalid_at?: number | null
|
oauth_invalid_at?: number | null
|
||||||
oauth_invalid_reason?: string | null
|
oauth_invalid_reason?: string | null
|
||||||
oauth_plan_type?: string | null
|
oauth_plan_type?: string | null
|
||||||
|
oauth_account_id?: string | null
|
||||||
|
oauth_account_user_id?: string | null
|
||||||
|
oauth_organizations?: OAuthOrganizationInfo[] | null
|
||||||
quota_updated_at?: number | null
|
quota_updated_at?: number | null
|
||||||
health_score?: number
|
health_score?: number
|
||||||
circuit_breaker_open?: boolean
|
circuit_breaker_open?: boolean
|
||||||
|
|||||||
@@ -12,6 +12,13 @@ export interface ProxyConfig {
|
|||||||
enabled?: boolean // 是否启用代理(false 时保留配置但不使用)
|
enabled?: boolean // 是否启用代理(false 时保留配置但不使用)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface OAuthOrganizationInfo {
|
||||||
|
id?: string | null
|
||||||
|
title?: string | null
|
||||||
|
is_default?: boolean | null
|
||||||
|
role?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 请求头规则类型
|
* 请求头规则类型
|
||||||
* - set: 设置/覆盖请求头
|
* - set: 设置/覆盖请求头
|
||||||
@@ -264,6 +271,8 @@ export interface EndpointAPIKey {
|
|||||||
oauth_email?: string | null // OAuth 授权的邮箱
|
oauth_email?: string | null // OAuth 授权的邮箱
|
||||||
oauth_plan_type?: string | null // Codex 订阅类型: plus/free/team/enterprise
|
oauth_plan_type?: string | null // Codex 订阅类型: plus/free/team/enterprise
|
||||||
oauth_account_id?: string | null // Codex ChatGPT 账号 ID
|
oauth_account_id?: string | null // Codex ChatGPT 账号 ID
|
||||||
|
oauth_account_user_id?: string | null // Codex ChatGPT account-user 联合 ID
|
||||||
|
oauth_organizations?: OAuthOrganizationInfo[] | null // OAuth 关联组织/工作区摘要
|
||||||
oauth_invalid_at?: number | null // OAuth Token 失效时间(Unix 时间戳)
|
oauth_invalid_at?: number | null // OAuth Token 失效时间(Unix 时间戳)
|
||||||
oauth_invalid_reason?: string | null // OAuth Token 失效原因
|
oauth_invalid_reason?: string | null // OAuth Token 失效原因
|
||||||
// 上游元数据(由上游响应采集,如 Codex 额度信息 / Antigravity 配额信息)
|
// 上游元数据(由上游响应采集,如 Codex 额度信息 / Antigravity 配额信息)
|
||||||
|
|||||||
@@ -121,6 +121,18 @@
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
class="text-[10px] px-1 py-0 h-4 shrink-0"
|
class="text-[10px] px-1 py-0 h-4 shrink-0"
|
||||||
>{{ key.oauth_plan_type }}</Badge>
|
>{{ key.oauth_plan_type }}</Badge>
|
||||||
|
<Badge
|
||||||
|
v-if="getPrimaryOAuthOrganizationTitle(key)"
|
||||||
|
variant="secondary"
|
||||||
|
class="text-[10px] px-1 py-0 h-4 shrink-0 max-w-[92px] truncate"
|
||||||
|
:title="getOAuthOrganizationsTooltip(key)"
|
||||||
|
>{{ getPrimaryOAuthOrganizationTitle(key) }}</Badge>
|
||||||
|
<Badge
|
||||||
|
v-if="key.oauth_account_id"
|
||||||
|
variant="secondary"
|
||||||
|
class="text-[10px] px-1 py-0 h-4 shrink-0"
|
||||||
|
:title="key.oauth_account_id"
|
||||||
|
>acct {{ formatOAuthIdentityShort(key.oauth_account_id) }}</Badge>
|
||||||
<Badge
|
<Badge
|
||||||
v-if="isBannedKey(key)"
|
v-if="isBannedKey(key)"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
@@ -129,6 +141,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1.5 mt-0.5 text-[11px] text-muted-foreground flex-wrap">
|
<div class="flex items-center gap-1.5 mt-0.5 text-[11px] text-muted-foreground flex-wrap">
|
||||||
<span :class="key.is_active ? '' : 'text-destructive'">{{ key.is_active ? '启用' : '禁用' }}</span>
|
<span :class="key.is_active ? '' : 'text-destructive'">{{ key.is_active ? '启用' : '禁用' }}</span>
|
||||||
|
<span
|
||||||
|
v-if="key.oauth_account_user_id"
|
||||||
|
:title="key.oauth_account_user_id"
|
||||||
|
>AUID {{ formatOAuthIdentityShort(key.oauth_account_user_id, 10, 8) }}</span>
|
||||||
<span v-if="key.account_quota">{{ shortenQuota(key.account_quota) }}</span>
|
<span v-if="key.account_quota">{{ shortenQuota(key.account_quota) }}</span>
|
||||||
<span v-if="key.proxy?.node_id">独立代理</span>
|
<span v-if="key.proxy?.node_id">独立代理</span>
|
||||||
<span
|
<span
|
||||||
@@ -277,6 +293,7 @@ import {
|
|||||||
import { exportKey, refreshProviderQuota } from '@/api/endpoints/keys'
|
import { exportKey, refreshProviderQuota } from '@/api/endpoints/keys'
|
||||||
import { refreshProviderOAuth } from '@/api/endpoints/provider_oauth'
|
import { refreshProviderOAuth } from '@/api/endpoints/provider_oauth'
|
||||||
import { useProxyNodesStore } from '@/stores/proxy-nodes'
|
import { useProxyNodesStore } from '@/stores/proxy-nodes'
|
||||||
|
import { formatOAuthIdentityShort, getPrimaryOAuthOrganizationTitle, getOAuthOrganizationsTooltip } from '@/utils/oauthIdentity'
|
||||||
|
|
||||||
type QuickSelectorValue =
|
type QuickSelectorValue =
|
||||||
| 'banned'
|
| 'banned'
|
||||||
|
|||||||
@@ -289,6 +289,22 @@
|
|||||||
>
|
>
|
||||||
{{ formatOAuthPlanType(key.oauth_plan_type) }}
|
{{ formatOAuthPlanType(key.oauth_plan_type) }}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
v-if="getPrimaryOAuthOrganizationTitle(key)"
|
||||||
|
variant="secondary"
|
||||||
|
class="text-[10px] px-1.5 py-0 shrink-0 max-w-[120px] truncate"
|
||||||
|
:title="getOAuthOrganizationsTooltip(key)"
|
||||||
|
>
|
||||||
|
{{ getPrimaryOAuthOrganizationTitle(key) }}
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
v-if="key.oauth_account_id"
|
||||||
|
variant="secondary"
|
||||||
|
class="text-[10px] px-1.5 py-0 shrink-0"
|
||||||
|
:title="key.oauth_account_id"
|
||||||
|
>
|
||||||
|
acct {{ formatOAuthIdentityShort(key.oauth_account_id) }}
|
||||||
|
</Badge>
|
||||||
<!-- Kiro 订阅类型标签 -->
|
<!-- Kiro 订阅类型标签 -->
|
||||||
<Badge
|
<Badge
|
||||||
v-if="provider.provider_type === 'kiro' && key.upstream_metadata?.kiro?.subscription_title"
|
v-if="provider.provider_type === 'kiro' && key.upstream_metadata?.kiro?.subscription_title"
|
||||||
@@ -303,6 +319,13 @@
|
|||||||
<span class="text-[11px] font-mono text-muted-foreground">
|
<span class="text-[11px] font-mono text-muted-foreground">
|
||||||
{{ key.auth_type === 'oauth' ? '[Refresh Token]' : (key.auth_type === 'service_account' ? '[Service Account]' : key.api_key_masked) }}
|
{{ key.auth_type === 'oauth' ? '[Refresh Token]' : (key.auth_type === 'service_account' ? '[Service Account]' : key.api_key_masked) }}
|
||||||
</span>
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="key.oauth_account_user_id"
|
||||||
|
class="text-[10px] text-muted-foreground"
|
||||||
|
:title="key.oauth_account_user_id"
|
||||||
|
>
|
||||||
|
AUID {{ formatOAuthIdentityShort(key.oauth_account_user_id, 10, 8) }}
|
||||||
|
</span>
|
||||||
<Button
|
<Button
|
||||||
v-if="key.auth_type === 'oauth'"
|
v-if="key.auth_type === 'oauth'"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -1103,6 +1126,7 @@ import type { UpstreamMetadata, AntigravityModelQuota } from '@/api/endpoints/ty
|
|||||||
import { formatApiFormat } from '@/api/endpoints/types/api-format'
|
import { formatApiFormat } from '@/api/endpoints/types/api-format'
|
||||||
import { isOAuthAccountProviderType, isKeyManagedProviderType } from '../utils/providerTypeUtils'
|
import { isOAuthAccountProviderType, isKeyManagedProviderType } from '../utils/providerTypeUtils'
|
||||||
import { isAccountLevelBlockReason, cleanAccountBlockReason } from '@/utils/accountBlock'
|
import { isAccountLevelBlockReason, cleanAccountBlockReason } from '@/utils/accountBlock'
|
||||||
|
import { formatOAuthIdentityShort, getPrimaryOAuthOrganizationTitle, getOAuthOrganizationsTooltip } from '@/utils/oauthIdentity'
|
||||||
|
|
||||||
// 扩展端点类型,包含密钥列表
|
// 扩展端点类型,包含密钥列表
|
||||||
interface ProviderEndpointWithKeys extends ProviderEndpoint {
|
interface ProviderEndpointWithKeys extends ProviderEndpoint {
|
||||||
|
|||||||
43
frontend/src/utils/oauthIdentity.ts
Normal file
43
frontend/src/utils/oauthIdentity.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import type { OAuthOrganizationInfo } from '@/api/endpoints/types/provider'
|
||||||
|
|
||||||
|
export function formatOAuthIdentityShort(
|
||||||
|
value: string | null | undefined,
|
||||||
|
head = 8,
|
||||||
|
tail = 6,
|
||||||
|
): string {
|
||||||
|
const normalized = String(value || '').trim()
|
||||||
|
if (!normalized) return ''
|
||||||
|
if (normalized.length <= head + tail + 3) return normalized
|
||||||
|
return `${normalized.slice(0, head)}...${normalized.slice(-tail)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPrimaryOAuthOrganizationTitle(
|
||||||
|
value: { oauth_organizations?: OAuthOrganizationInfo[] | null } | null | undefined,
|
||||||
|
): string | null {
|
||||||
|
const organizations = Array.isArray(value?.oauth_organizations) ? value.oauth_organizations : []
|
||||||
|
const defaultOrg = organizations.find(
|
||||||
|
(org) => org?.is_default && typeof org?.title === 'string' && org.title.trim(),
|
||||||
|
)
|
||||||
|
if (defaultOrg?.title) return defaultOrg.title.trim()
|
||||||
|
const firstWithTitle = organizations.find(
|
||||||
|
(org) => typeof org?.title === 'string' && org.title.trim(),
|
||||||
|
)
|
||||||
|
return firstWithTitle?.title?.trim() || null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOAuthOrganizationsTooltip(
|
||||||
|
value: { oauth_organizations?: OAuthOrganizationInfo[] | null } | null | undefined,
|
||||||
|
): string {
|
||||||
|
const organizations = Array.isArray(value?.oauth_organizations) ? value.oauth_organizations : []
|
||||||
|
if (organizations.length === 0) return ''
|
||||||
|
return organizations
|
||||||
|
.map((org) => {
|
||||||
|
const title =
|
||||||
|
typeof org?.title === 'string' && org.title.trim() ? org.title.trim() : '未命名组织'
|
||||||
|
const role =
|
||||||
|
typeof org?.role === 'string' && org.role.trim() ? ` (${org.role.trim()})` : ''
|
||||||
|
const suffix = org?.is_default ? ' [default]' : ''
|
||||||
|
return `${title}${role}${suffix}`
|
||||||
|
})
|
||||||
|
.join('\n')
|
||||||
|
}
|
||||||
@@ -492,6 +492,29 @@
|
|||||||
>
|
>
|
||||||
{{ formatOAuthPlanType(key.oauth_plan_type) }}
|
{{ formatOAuthPlanType(key.oauth_plan_type) }}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
v-if="getPrimaryOAuthOrganizationTitle(key)"
|
||||||
|
variant="secondary"
|
||||||
|
class="text-[9px] px-1 py-0 h-4 shrink-0 max-w-[92px] truncate"
|
||||||
|
:title="getOAuthOrganizationsTooltip(key)"
|
||||||
|
>
|
||||||
|
{{ getPrimaryOAuthOrganizationTitle(key) }}
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
v-if="key.oauth_account_id"
|
||||||
|
variant="secondary"
|
||||||
|
class="text-[9px] px-1 py-0 h-4 shrink-0"
|
||||||
|
:title="key.oauth_account_id"
|
||||||
|
>
|
||||||
|
acct {{ formatOAuthIdentityShort(key.oauth_account_id) }}
|
||||||
|
</Badge>
|
||||||
|
<span
|
||||||
|
v-if="key.oauth_account_user_id"
|
||||||
|
class="text-[10px] text-muted-foreground shrink-0"
|
||||||
|
:title="key.oauth_account_user_id"
|
||||||
|
>
|
||||||
|
AUID {{ formatOAuthIdentityShort(key.oauth_account_user_id, 10, 8) }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -794,6 +817,29 @@
|
|||||||
>
|
>
|
||||||
{{ formatOAuthPlanType(key.oauth_plan_type) }}
|
{{ formatOAuthPlanType(key.oauth_plan_type) }}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
v-if="getPrimaryOAuthOrganizationTitle(key)"
|
||||||
|
variant="secondary"
|
||||||
|
class="text-[9px] px-1 py-0 h-4 shrink-0 max-w-[92px] truncate"
|
||||||
|
:title="getOAuthOrganizationsTooltip(key)"
|
||||||
|
>
|
||||||
|
{{ getPrimaryOAuthOrganizationTitle(key) }}
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
v-if="key.oauth_account_id"
|
||||||
|
variant="secondary"
|
||||||
|
class="text-[9px] px-1 py-0 h-4 shrink-0"
|
||||||
|
:title="key.oauth_account_id"
|
||||||
|
>
|
||||||
|
acct {{ formatOAuthIdentityShort(key.oauth_account_id) }}
|
||||||
|
</Badge>
|
||||||
|
<span
|
||||||
|
v-if="key.oauth_account_user_id"
|
||||||
|
class="text-[10px] text-muted-foreground shrink-0"
|
||||||
|
:title="key.oauth_account_user_id"
|
||||||
|
>
|
||||||
|
AUID {{ formatOAuthIdentityShort(key.oauth_account_user_id, 10, 8) }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-0.5 shrink-0 flex-wrap justify-end max-w-[210px]">
|
<div class="flex items-center gap-0.5 shrink-0 flex-wrap justify-end max-w-[210px]">
|
||||||
@@ -1161,7 +1207,12 @@ import type {
|
|||||||
PoolKeysPageResponse,
|
PoolKeysPageResponse,
|
||||||
PoolPresetMeta,
|
PoolPresetMeta,
|
||||||
} from '@/api/endpoints/pool'
|
} from '@/api/endpoints/pool'
|
||||||
import type { ClaudeCodeAdvancedConfig, EndpointAPIKey, PoolAdvancedConfig, ProviderWithEndpointsSummary } from '@/api/endpoints/types/provider'
|
import type {
|
||||||
|
ClaudeCodeAdvancedConfig,
|
||||||
|
EndpointAPIKey,
|
||||||
|
PoolAdvancedConfig,
|
||||||
|
ProviderWithEndpointsSummary,
|
||||||
|
} from '@/api/endpoints/types/provider'
|
||||||
import { getProvider, updateProvider } from '@/api/endpoints'
|
import { getProvider, updateProvider } from '@/api/endpoints'
|
||||||
import { useProxyNodesStore } from '@/stores/proxy-nodes'
|
import { useProxyNodesStore } from '@/stores/proxy-nodes'
|
||||||
import PoolSchedulingDialog from '@/features/pool/components/PoolSchedulingDialog.vue'
|
import PoolSchedulingDialog from '@/features/pool/components/PoolSchedulingDialog.vue'
|
||||||
@@ -1174,6 +1225,7 @@ import OAuthKeyEditDialog from '@/features/providers/components/OAuthKeyEditDial
|
|||||||
import OAuthAccountDialog from '@/features/providers/components/OAuthAccountDialog.vue'
|
import OAuthAccountDialog from '@/features/providers/components/OAuthAccountDialog.vue'
|
||||||
import ProxyNodeSelect from '@/features/providers/components/ProxyNodeSelect.vue'
|
import ProxyNodeSelect from '@/features/providers/components/ProxyNodeSelect.vue'
|
||||||
import { isAccountLevelBlockReason, classifyAccountBlockLabel, cleanAccountBlockReason } from '@/utils/accountBlock'
|
import { isAccountLevelBlockReason, classifyAccountBlockLabel, cleanAccountBlockReason } from '@/utils/accountBlock'
|
||||||
|
import { formatOAuthIdentityShort, getPrimaryOAuthOrganizationTitle, getOAuthOrganizationsTooltip } from '@/utils/oauthIdentity'
|
||||||
|
|
||||||
const { success, error: showError, warning: showWarning } = useToast()
|
const { success, error: showError, warning: showWarning } = useToast()
|
||||||
const { confirm } = useConfirm()
|
const { confirm } = useConfirm()
|
||||||
@@ -1717,7 +1769,11 @@ function toEndpointApiKey(key: PoolKeyDetail): EndpointAPIKey {
|
|||||||
model_include_patterns: key.model_include_patterns || [],
|
model_include_patterns: key.model_include_patterns || [],
|
||||||
model_exclude_patterns: key.model_exclude_patterns || [],
|
model_exclude_patterns: key.model_exclude_patterns || [],
|
||||||
oauth_expires_at: key.oauth_expires_at ?? null,
|
oauth_expires_at: key.oauth_expires_at ?? null,
|
||||||
|
oauth_email: null,
|
||||||
oauth_plan_type: key.oauth_plan_type ?? null,
|
oauth_plan_type: key.oauth_plan_type ?? null,
|
||||||
|
oauth_account_id: key.oauth_account_id ?? null,
|
||||||
|
oauth_account_user_id: key.oauth_account_user_id ?? null,
|
||||||
|
oauth_organizations: key.oauth_organizations ?? [],
|
||||||
oauth_invalid_at: key.oauth_invalid_at ?? null,
|
oauth_invalid_at: key.oauth_invalid_at ?? null,
|
||||||
oauth_invalid_reason: key.oauth_invalid_reason ?? null,
|
oauth_invalid_reason: key.oauth_invalid_reason ?? null,
|
||||||
proxy: key.proxy ?? null,
|
proxy: key.proxy ?? null,
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ from src.api.base.pipeline import ApiRequestPipeline
|
|||||||
from src.core.crypto import crypto_service
|
from src.core.crypto import crypto_service
|
||||||
from src.core.exceptions import NotFoundException
|
from src.core.exceptions import NotFoundException
|
||||||
from src.core.logger import logger
|
from src.core.logger import logger
|
||||||
|
from src.core.provider_oauth_utils import normalize_oauth_organizations
|
||||||
from src.database import get_db
|
from src.database import get_db
|
||||||
from src.models.database import Provider, ProviderAPIKey
|
from src.models.database import Provider, ProviderAPIKey
|
||||||
from src.services.billing.precision import to_money_decimal
|
from src.services.billing.precision import to_money_decimal
|
||||||
@@ -355,6 +356,32 @@ def _derive_oauth_plan_type(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _derive_oauth_account_id(auth_config: dict[str, Any] | None = None) -> str | None:
|
||||||
|
if not isinstance(auth_config, dict):
|
||||||
|
return None
|
||||||
|
raw = auth_config.get("account_id")
|
||||||
|
if not isinstance(raw, str):
|
||||||
|
return None
|
||||||
|
normalized = raw.strip()
|
||||||
|
return normalized or None
|
||||||
|
|
||||||
|
|
||||||
|
def _derive_oauth_account_user_id(auth_config: dict[str, Any] | None = None) -> str | None:
|
||||||
|
if not isinstance(auth_config, dict):
|
||||||
|
return None
|
||||||
|
raw = auth_config.get("account_user_id")
|
||||||
|
if not isinstance(raw, str):
|
||||||
|
return None
|
||||||
|
normalized = raw.strip()
|
||||||
|
return normalized or None
|
||||||
|
|
||||||
|
|
||||||
|
def _derive_oauth_organizations(auth_config: dict[str, Any] | None = None) -> list[dict[str, Any]]:
|
||||||
|
if not isinstance(auth_config, dict):
|
||||||
|
return []
|
||||||
|
return normalize_oauth_organizations(auth_config.get("organizations"))
|
||||||
|
|
||||||
|
|
||||||
def _compute_health_aggregate(
|
def _compute_health_aggregate(
|
||||||
health_by_format: Any, circuit_breaker_by_format: Any
|
health_by_format: Any, circuit_breaker_by_format: Any
|
||||||
) -> tuple[float, bool]:
|
) -> tuple[float, bool]:
|
||||||
@@ -1063,6 +1090,9 @@ async def _serialize_pool_key_details(
|
|||||||
oauth_plan_type=_derive_oauth_plan_type(
|
oauth_plan_type=_derive_oauth_plan_type(
|
||||||
k, provider_type, auth_config=oauth_auth_config
|
k, provider_type, auth_config=oauth_auth_config
|
||||||
),
|
),
|
||||||
|
oauth_account_id=_derive_oauth_account_id(oauth_auth_config),
|
||||||
|
oauth_account_user_id=_derive_oauth_account_user_id(oauth_auth_config),
|
||||||
|
oauth_organizations=_derive_oauth_organizations(oauth_auth_config),
|
||||||
quota_updated_at=_extract_quota_updated_at(
|
quota_updated_at=_extract_quota_updated_at(
|
||||||
provider_type,
|
provider_type,
|
||||||
getattr(k, "upstream_metadata", None),
|
getattr(k, "upstream_metadata", None),
|
||||||
|
|||||||
@@ -66,6 +66,13 @@ class PoolSchedulingReason(BaseModel):
|
|||||||
detail: str | None = None
|
detail: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class OAuthOrganizationSummary(BaseModel):
|
||||||
|
id: str | None = None
|
||||||
|
title: str | None = None
|
||||||
|
is_default: bool = False
|
||||||
|
role: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class PoolKeyDetail(BaseModel):
|
class PoolKeyDetail(BaseModel):
|
||||||
"""Detailed status of a single pool key."""
|
"""Detailed status of a single pool key."""
|
||||||
|
|
||||||
@@ -77,6 +84,9 @@ class PoolKeyDetail(BaseModel):
|
|||||||
oauth_invalid_at: int | None = None
|
oauth_invalid_at: int | None = None
|
||||||
oauth_invalid_reason: str | None = None
|
oauth_invalid_reason: str | None = None
|
||||||
oauth_plan_type: str | None = None
|
oauth_plan_type: str | None = None
|
||||||
|
oauth_account_id: str | None = None
|
||||||
|
oauth_account_user_id: str | None = None
|
||||||
|
oauth_organizations: list[OAuthOrganizationSummary] = Field(default_factory=list)
|
||||||
quota_updated_at: int | None = None
|
quota_updated_at: int | None = None
|
||||||
# 健康度聚合字段(与 Provider Key 列表口径一致)
|
# 健康度聚合字段(与 Provider Key 列表口径一致)
|
||||||
health_score: float = 1.0
|
health_score: float = 1.0
|
||||||
|
|||||||
@@ -500,6 +500,60 @@ def _normalize_codex_plan_group(plan_type: Any) -> str | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_codex_identity_value(value: Any) -> str | None:
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return None
|
||||||
|
normalized = value.strip()
|
||||||
|
return normalized or None
|
||||||
|
|
||||||
|
|
||||||
|
def _is_codex_provider(provider_type: Any) -> bool:
|
||||||
|
return str(provider_type or "").strip().lower() == ProviderType.CODEX.value
|
||||||
|
|
||||||
|
|
||||||
|
def _match_codex_identity(
|
||||||
|
*,
|
||||||
|
new_auth_config: dict[str, Any],
|
||||||
|
existing_auth_config: dict[str, Any],
|
||||||
|
) -> bool | None:
|
||||||
|
"""Codex 判重优先按 account/team 维度进行。
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True: 明确重复
|
||||||
|
False: 明确不是重复(例如同用户不同 account/team)
|
||||||
|
None: 信息不足,调用方应继续使用兜底规则
|
||||||
|
"""
|
||||||
|
new_provider_type = new_auth_config.get("provider_type")
|
||||||
|
existing_provider_type = existing_auth_config.get("provider_type")
|
||||||
|
if not (_is_codex_provider(new_provider_type) or _is_codex_provider(existing_provider_type)):
|
||||||
|
return None
|
||||||
|
|
||||||
|
new_account_user_id = _normalize_codex_identity_value(new_auth_config.get("account_user_id"))
|
||||||
|
existing_account_user_id = _normalize_codex_identity_value(
|
||||||
|
existing_auth_config.get("account_user_id")
|
||||||
|
)
|
||||||
|
if new_account_user_id and existing_account_user_id:
|
||||||
|
return new_account_user_id == existing_account_user_id
|
||||||
|
|
||||||
|
new_account_id = _normalize_codex_identity_value(new_auth_config.get("account_id"))
|
||||||
|
existing_account_id = _normalize_codex_identity_value(existing_auth_config.get("account_id"))
|
||||||
|
new_user_id = _normalize_codex_identity_value(new_auth_config.get("user_id"))
|
||||||
|
existing_user_id = _normalize_codex_identity_value(existing_auth_config.get("user_id"))
|
||||||
|
new_email = _normalize_codex_identity_value(new_auth_config.get("email"))
|
||||||
|
existing_email = _normalize_codex_identity_value(existing_auth_config.get("email"))
|
||||||
|
|
||||||
|
if new_account_id and existing_account_id and new_account_id != existing_account_id:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if new_account_id and existing_account_id and new_user_id and existing_user_id:
|
||||||
|
return new_account_id == existing_account_id and new_user_id == existing_user_id
|
||||||
|
|
||||||
|
if new_account_id and existing_account_id and new_email and existing_email:
|
||||||
|
return new_account_id == existing_account_id and new_email == existing_email
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _is_codex_cross_plan_group_non_duplicate(
|
def _is_codex_cross_plan_group_non_duplicate(
|
||||||
*,
|
*,
|
||||||
new_provider_type: Any,
|
new_provider_type: Any,
|
||||||
@@ -528,8 +582,9 @@ def _check_duplicate_oauth_account(
|
|||||||
检查是否存在重复的 OAuth 账号。
|
检查是否存在重复的 OAuth 账号。
|
||||||
|
|
||||||
通过以下字段判断重复:
|
通过以下字段判断重复:
|
||||||
- user_id: Codex 等使用用户级别 ID(同 team 下不同成员共享 account_id 但 user_id 不同)
|
- Codex: 优先 account_user_id,其次 (user_id, account_id) / (email, account_id)
|
||||||
对 Codex 额外按账号类型分组:free 与 Team/Plus/Enterprise 互不判重
|
同一用户切换不同 Team/account_id 时不判重;free 与 Team/Plus/Enterprise 互不判重
|
||||||
|
- user_id: Codex 之外优先使用用户级别 ID
|
||||||
- email + auth_method: Kiro 使用 email + auth_method 组合判断
|
- email + auth_method: Kiro 使用 email + auth_method 组合判断
|
||||||
(同一邮箱可能通过 Social 和 IdC 两种方式登录,视为不同账号)
|
(同一邮箱可能通过 Social 和 IdC 两种方式登录,视为不同账号)
|
||||||
- email: 其他 OAuth Provider 使用邮箱判断
|
- email: 其他 OAuth Provider 使用邮箱判断
|
||||||
@@ -576,8 +631,22 @@ def _check_duplicate_oauth_account(
|
|||||||
|
|
||||||
is_duplicate = False
|
is_duplicate = False
|
||||||
|
|
||||||
|
codex_identity_match = _match_codex_identity(
|
||||||
|
new_auth_config=auth_config,
|
||||||
|
existing_auth_config=decrypted_config,
|
||||||
|
)
|
||||||
|
if codex_identity_match is True:
|
||||||
|
is_duplicate = True
|
||||||
|
elif codex_identity_match is False:
|
||||||
|
is_duplicate = False
|
||||||
|
|
||||||
# user_id 相同即重复(Codex 等,同一 team 下不同成员共享 account_id 但 user_id 不同)
|
# user_id 相同即重复(Codex 等,同一 team 下不同成员共享 account_id 但 user_id 不同)
|
||||||
if new_user_id and existing_user_id and new_user_id == existing_user_id:
|
if (
|
||||||
|
codex_identity_match is None
|
||||||
|
and new_user_id
|
||||||
|
and existing_user_id
|
||||||
|
and new_user_id == existing_user_id
|
||||||
|
):
|
||||||
if not _is_codex_cross_plan_group_non_duplicate(
|
if not _is_codex_cross_plan_group_non_duplicate(
|
||||||
new_provider_type=new_provider_type,
|
new_provider_type=new_provider_type,
|
||||||
existing_provider_type=existing_provider_type,
|
existing_provider_type=existing_provider_type,
|
||||||
@@ -587,7 +656,13 @@ def _check_duplicate_oauth_account(
|
|||||||
is_duplicate = True
|
is_duplicate = True
|
||||||
|
|
||||||
# email 判断
|
# email 判断
|
||||||
if not is_duplicate and new_email and existing_email and new_email == existing_email:
|
if (
|
||||||
|
codex_identity_match is None
|
||||||
|
and not is_duplicate
|
||||||
|
and new_email
|
||||||
|
and existing_email
|
||||||
|
and new_email == existing_email
|
||||||
|
):
|
||||||
is_kiro = new_provider_type == "kiro" or existing_provider_type == "kiro"
|
is_kiro = new_provider_type == "kiro" or existing_provider_type == "kiro"
|
||||||
if is_kiro:
|
if is_kiro:
|
||||||
# Kiro: 只有 email + auth_method 都相同才视为重复
|
# Kiro: 只有 email + auth_method 都相同才视为重复
|
||||||
@@ -617,7 +692,13 @@ def _check_duplicate_oauth_account(
|
|||||||
return existing_key
|
return existing_key
|
||||||
|
|
||||||
# 活跃的重复账号,拒绝添加
|
# 活跃的重复账号,拒绝添加
|
||||||
identifier = new_email or new_user_id or ""
|
identifier = (
|
||||||
|
auth_config.get("account_user_id")
|
||||||
|
or auth_config.get("account_id")
|
||||||
|
or new_email
|
||||||
|
or new_user_id
|
||||||
|
or ""
|
||||||
|
)
|
||||||
raise InvalidRequestException(
|
raise InvalidRequestException(
|
||||||
f"该 OAuth 账号 ({identifier}) 已存在于当前 Provider 中"
|
f"该 OAuth 账号 ({identifier}) 已存在于当前 Provider 中"
|
||||||
f"(名称: {existing_key.name})"
|
f"(名称: {existing_key.name})"
|
||||||
@@ -1318,7 +1399,7 @@ def _coerce_import_str(value: Any) -> str | None:
|
|||||||
return normalized or None
|
return normalized or None
|
||||||
|
|
||||||
|
|
||||||
def _extract_standard_oauth_import_entry(item: Any) -> dict[str, str] | None:
|
def _extract_standard_oauth_import_entry(item: Any) -> dict[str, Any] | None:
|
||||||
if isinstance(item, str):
|
if isinstance(item, str):
|
||||||
token = _coerce_import_str(item)
|
token = _coerce_import_str(item)
|
||||||
if token:
|
if token:
|
||||||
@@ -1333,7 +1414,7 @@ def _extract_standard_oauth_import_entry(item: Any) -> dict[str, str] | None:
|
|||||||
if not refresh_token:
|
if not refresh_token:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
entry: dict[str, str] = {"refresh_token": refresh_token}
|
entry: dict[str, Any] = {"refresh_token": refresh_token}
|
||||||
|
|
||||||
account_id = (
|
account_id = (
|
||||||
_coerce_import_str(item.get("account_id"))
|
_coerce_import_str(item.get("account_id"))
|
||||||
@@ -1344,6 +1425,15 @@ def _extract_standard_oauth_import_entry(item: Any) -> dict[str, str] | None:
|
|||||||
if account_id:
|
if account_id:
|
||||||
entry["account_id"] = account_id
|
entry["account_id"] = account_id
|
||||||
|
|
||||||
|
account_user_id = (
|
||||||
|
_coerce_import_str(item.get("account_user_id"))
|
||||||
|
or _coerce_import_str(item.get("accountUserId"))
|
||||||
|
or _coerce_import_str(item.get("chatgpt_account_user_id"))
|
||||||
|
or _coerce_import_str(item.get("chatgptAccountUserId"))
|
||||||
|
)
|
||||||
|
if account_user_id:
|
||||||
|
entry["account_user_id"] = account_user_id
|
||||||
|
|
||||||
plan_type = (
|
plan_type = (
|
||||||
_coerce_import_str(item.get("plan_type"))
|
_coerce_import_str(item.get("plan_type"))
|
||||||
or _coerce_import_str(item.get("planType"))
|
or _coerce_import_str(item.get("planType"))
|
||||||
@@ -1369,7 +1459,7 @@ def _extract_standard_oauth_import_entry(item: Any) -> dict[str, str] | None:
|
|||||||
return entry
|
return entry
|
||||||
|
|
||||||
|
|
||||||
def _parse_standard_oauth_import_entries(raw_input: str) -> list[dict[str, str]]:
|
def _parse_standard_oauth_import_entries(raw_input: str) -> list[dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
解析标准 OAuth 导入输入,保留 refresh_token 及可用账号提示字段。
|
解析标准 OAuth 导入输入,保留 refresh_token 及可用账号提示字段。
|
||||||
|
|
||||||
@@ -1384,7 +1474,7 @@ def _parse_standard_oauth_import_entries(raw_input: str) -> list[dict[str, str]]
|
|||||||
if not raw:
|
if not raw:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
result: list[dict[str, str]] = []
|
result: list[dict[str, Any]] = []
|
||||||
|
|
||||||
if raw.startswith("["):
|
if raw.startswith("["):
|
||||||
try:
|
try:
|
||||||
@@ -1668,9 +1758,9 @@ def _commit_batch_import_writes_if_needed(db: Session, pending_writes: int) -> i
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def _apply_codex_import_hints(auth_config: dict[str, Any], import_entry: dict[str, str]) -> None:
|
def _apply_codex_import_hints(auth_config: dict[str, Any], import_entry: dict[str, Any]) -> None:
|
||||||
"""将导入文件中可用的 Codex 账号信息作为兜底补全(不覆盖已有值)。"""
|
"""将导入文件中可用的 Codex 账号信息作为兜底补全(不覆盖已有值)。"""
|
||||||
for field in ("account_id", "plan_type", "user_id", "email"):
|
for field in ("account_user_id", "account_id", "plan_type", "user_id", "email"):
|
||||||
value = import_entry.get(field)
|
value = import_entry.get(field)
|
||||||
if value and not auth_config.get(field):
|
if value and not auth_config.get(field):
|
||||||
auth_config[field] = value
|
auth_config[field] = value
|
||||||
|
|||||||
@@ -326,6 +326,21 @@ def _extract_codex_fields_from_claims(claims: dict[str, Any]) -> dict[str, Any]:
|
|||||||
if account_id:
|
if account_id:
|
||||||
result["account_id"] = account_id
|
result["account_id"] = account_id
|
||||||
|
|
||||||
|
account_user_id = _first_non_empty_str(
|
||||||
|
[
|
||||||
|
auth.get("chatgpt_account_user_id"),
|
||||||
|
auth.get("chatgptAccountUserId"),
|
||||||
|
auth.get("account_user_id"),
|
||||||
|
auth.get("accountUserId"),
|
||||||
|
claims.get("chatgpt_account_user_id"),
|
||||||
|
claims.get("chatgptAccountUserId"),
|
||||||
|
claims.get("account_user_id"),
|
||||||
|
claims.get("accountUserId"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
if account_user_id:
|
||||||
|
result["account_user_id"] = account_user_id
|
||||||
|
|
||||||
plan_type = _first_non_empty_str(
|
plan_type = _first_non_empty_str(
|
||||||
[
|
[
|
||||||
auth.get("chatgpt_plan_type"),
|
auth.get("chatgpt_plan_type"),
|
||||||
@@ -357,6 +372,10 @@ def _extract_codex_fields_from_claims(claims: dict[str, Any]) -> dict[str, Any]:
|
|||||||
if user_id:
|
if user_id:
|
||||||
result["user_id"] = user_id
|
result["user_id"] = user_id
|
||||||
|
|
||||||
|
organizations = auth.get("organizations")
|
||||||
|
if isinstance(organizations, list) and organizations:
|
||||||
|
result["organizations"] = organizations
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@@ -509,3 +528,35 @@ async def enrich_auth_config(
|
|||||||
if enricher:
|
if enricher:
|
||||||
return await enricher(auth_config, token_response, access_token, proxy_config)
|
return await enricher(auth_config, token_response, access_token, proxy_config)
|
||||||
return auth_config
|
return auth_config
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_oauth_organizations(raw: Any) -> list[dict[str, Any]]:
|
||||||
|
"""Normalize raw organizations list from OAuth auth_config into a clean list of dicts."""
|
||||||
|
if not isinstance(raw, list):
|
||||||
|
return []
|
||||||
|
|
||||||
|
result: list[dict[str, Any]] = []
|
||||||
|
for item in raw:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
|
||||||
|
normalized: dict[str, Any] = {}
|
||||||
|
org_id = item.get("id")
|
||||||
|
if isinstance(org_id, str) and org_id.strip():
|
||||||
|
normalized["id"] = org_id.strip()
|
||||||
|
|
||||||
|
title = item.get("title")
|
||||||
|
if isinstance(title, str) and title.strip():
|
||||||
|
normalized["title"] = title.strip()
|
||||||
|
|
||||||
|
role = item.get("role")
|
||||||
|
if isinstance(role, str) and role.strip():
|
||||||
|
normalized["role"] = role.strip()
|
||||||
|
|
||||||
|
if "is_default" in item:
|
||||||
|
normalized["is_default"] = bool(item.get("is_default"))
|
||||||
|
|
||||||
|
if normalized:
|
||||||
|
result.append(normalized)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|||||||
@@ -710,6 +710,13 @@ class EndpointAPIKeyUpdate(BaseModel):
|
|||||||
return v.strip()
|
return v.strip()
|
||||||
|
|
||||||
|
|
||||||
|
class OAuthOrganizationResponse(BaseModel):
|
||||||
|
id: str | None = Field(default=None, description="OAuth 组织/工作区 ID")
|
||||||
|
title: str | None = Field(default=None, description="OAuth 组织/工作区标题")
|
||||||
|
is_default: bool = Field(default=False, description="是否为默认组织/工作区")
|
||||||
|
role: str | None = Field(default=None, description="当前账号在组织中的角色")
|
||||||
|
|
||||||
|
|
||||||
class EndpointAPIKeyResponse(BaseModel):
|
class EndpointAPIKeyResponse(BaseModel):
|
||||||
"""Endpoint API Key 响应"""
|
"""Endpoint API Key 响应"""
|
||||||
|
|
||||||
@@ -753,6 +760,14 @@ class EndpointAPIKeyResponse(BaseModel):
|
|||||||
default=None, description="OAuth 账号套餐类型(如 free/plus/team/enterprise)"
|
default=None, description="OAuth 账号套餐类型(如 free/plus/team/enterprise)"
|
||||||
)
|
)
|
||||||
oauth_account_id: str | None = Field(default=None, description="OAuth 账号 ID")
|
oauth_account_id: str | None = Field(default=None, description="OAuth 账号 ID")
|
||||||
|
oauth_account_user_id: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="OAuth 账号-工作区联合 ID(如 Codex chatgpt_account_user_id)",
|
||||||
|
)
|
||||||
|
oauth_organizations: list[OAuthOrganizationResponse] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="OAuth 关联的组织/工作区摘要列表",
|
||||||
|
)
|
||||||
oauth_invalid_at: int | None = Field(
|
oauth_invalid_at: int | None = Field(
|
||||||
default=None, description="OAuth Token 失效时间(Unix 时间戳),如账号被封、授权撤销等"
|
default=None, description="OAuth Token 失效时间(Unix 时间戳),如账号被封、授权撤销等"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ async def enrich_codex(
|
|||||||
access_token: str, # noqa: ARG001
|
access_token: str, # noqa: ARG001
|
||||||
proxy_config: dict[str, Any] | None, # noqa: ARG001
|
proxy_config: dict[str, Any] | None, # noqa: ARG001
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Codex auth_config enrichment: parse id_token -> email/account_id/plan_type/user_id."""
|
"""Codex auth_config enrichment: parse token claims -> account/team identity metadata."""
|
||||||
from src.core.provider_oauth_utils import parse_codex_id_token
|
from src.core.provider_oauth_utils import parse_codex_id_token
|
||||||
|
|
||||||
def _read_non_empty_str(*values: Any) -> str | None:
|
def _read_non_empty_str(*values: Any) -> str | None:
|
||||||
@@ -100,6 +100,15 @@ async def enrich_codex(
|
|||||||
if direct_account_id and not auth_config.get("account_id"):
|
if direct_account_id and not auth_config.get("account_id"):
|
||||||
auth_config["account_id"] = direct_account_id
|
auth_config["account_id"] = direct_account_id
|
||||||
|
|
||||||
|
direct_account_user_id = _read_non_empty_str(
|
||||||
|
token_response.get("account_user_id"),
|
||||||
|
token_response.get("accountUserId"),
|
||||||
|
token_response.get("chatgpt_account_user_id"),
|
||||||
|
token_response.get("chatgptAccountUserId"),
|
||||||
|
)
|
||||||
|
if direct_account_user_id and not auth_config.get("account_user_id"):
|
||||||
|
auth_config["account_user_id"] = direct_account_user_id
|
||||||
|
|
||||||
direct_plan_type = _read_non_empty_str(
|
direct_plan_type = _read_non_empty_str(
|
||||||
token_response.get("plan_type"),
|
token_response.get("plan_type"),
|
||||||
token_response.get("planType"),
|
token_response.get("planType"),
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import json
|
|||||||
|
|
||||||
from src.core.crypto import crypto_service
|
from src.core.crypto import crypto_service
|
||||||
from src.core.logger import logger
|
from src.core.logger import logger
|
||||||
|
from src.core.provider_oauth_utils import normalize_oauth_organizations
|
||||||
from src.models.database import ProviderAPIKey
|
from src.models.database import ProviderAPIKey
|
||||||
from src.models.endpoint_models import EndpointAPIKeyResponse
|
from src.models.endpoint_models import EndpointAPIKeyResponse
|
||||||
from src.services.provider_keys.auth_type import normalize_auth_type
|
from src.services.provider_keys.auth_type import normalize_auth_type
|
||||||
@@ -47,6 +48,8 @@ def build_key_response(
|
|||||||
oauth_email = None
|
oauth_email = None
|
||||||
oauth_plan_type = None
|
oauth_plan_type = None
|
||||||
oauth_account_id = None
|
oauth_account_id = None
|
||||||
|
oauth_account_user_id = None
|
||||||
|
oauth_organizations: list[dict[str, object]] = []
|
||||||
encrypted_auth_config = key_dict.pop("auth_config", None) # 移除敏感字段,避免泄露
|
encrypted_auth_config = key_dict.pop("auth_config", None) # 移除敏感字段,避免泄露
|
||||||
if auth_type == "oauth" and encrypted_auth_config:
|
if auth_type == "oauth" and encrypted_auth_config:
|
||||||
try:
|
try:
|
||||||
@@ -61,6 +64,8 @@ def build_key_response(
|
|||||||
if ag_tier and isinstance(ag_tier, str):
|
if ag_tier and isinstance(ag_tier, str):
|
||||||
oauth_plan_type = ag_tier.lower()
|
oauth_plan_type = ag_tier.lower()
|
||||||
oauth_account_id = auth_config.get("account_id") # Codex: chatgpt_account_id
|
oauth_account_id = auth_config.get("account_id") # Codex: chatgpt_account_id
|
||||||
|
oauth_account_user_id = auth_config.get("account_user_id")
|
||||||
|
oauth_organizations = normalize_oauth_organizations(auth_config.get("organizations"))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Failed to decrypt auth_config for key {}: {}", key.id, e)
|
logger.error("Failed to decrypt auth_config for key {}: {}", key.id, e)
|
||||||
|
|
||||||
@@ -112,6 +117,8 @@ def build_key_response(
|
|||||||
"oauth_email": oauth_email,
|
"oauth_email": oauth_email,
|
||||||
"oauth_plan_type": oauth_plan_type,
|
"oauth_plan_type": oauth_plan_type,
|
||||||
"oauth_account_id": oauth_account_id,
|
"oauth_account_id": oauth_account_id,
|
||||||
|
"oauth_account_user_id": oauth_account_user_id,
|
||||||
|
"oauth_organizations": oauth_organizations,
|
||||||
"oauth_invalid_at": (
|
"oauth_invalid_at": (
|
||||||
int(key.oauth_invalid_at.timestamp()) if key.oauth_invalid_at else None
|
int(key.oauth_invalid_at.timestamp()) if key.oauth_invalid_at else None
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -18,8 +18,10 @@ def test_parse_codex_id_token_extracts_auth_claim_fields() -> None:
|
|||||||
"email": "u@example.com",
|
"email": "u@example.com",
|
||||||
"https://api.openai.com/auth": {
|
"https://api.openai.com/auth": {
|
||||||
"chatgpt_account_id": "acc-1",
|
"chatgpt_account_id": "acc-1",
|
||||||
|
"chatgpt_account_user_id": "user-1__acc-1",
|
||||||
"chatgpt_plan_type": "team",
|
"chatgpt_plan_type": "team",
|
||||||
"chatgpt_user_id": "user-1",
|
"chatgpt_user_id": "user-1",
|
||||||
|
"organizations": [{"id": "org-1", "title": "Personal", "is_default": True}],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -29,8 +31,10 @@ def test_parse_codex_id_token_extracts_auth_claim_fields() -> None:
|
|||||||
assert parsed == {
|
assert parsed == {
|
||||||
"email": "u@example.com",
|
"email": "u@example.com",
|
||||||
"account_id": "acc-1",
|
"account_id": "acc-1",
|
||||||
|
"account_user_id": "user-1__acc-1",
|
||||||
"plan_type": "team",
|
"plan_type": "team",
|
||||||
"user_id": "user-1",
|
"user_id": "user-1",
|
||||||
|
"organizations": [{"id": "org-1", "title": "Personal", "is_default": True}],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
55
tests/unit/test_provider_key_response_builder.py
Normal file
55
tests/unit/test_provider_key_response_builder.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.models.database import ProviderAPIKey
|
||||||
|
from src.services.provider_keys import response_builder as module
|
||||||
|
from src.services.provider_keys.response_builder import build_key_response
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_key_response_includes_codex_identity_metadata(
|
||||||
|
monkeypatch: "pytest.MonkeyPatch",
|
||||||
|
) -> None:
|
||||||
|
key = ProviderAPIKey(
|
||||||
|
id="key-1",
|
||||||
|
provider_id="provider-1",
|
||||||
|
api_formats=["openai:chat"],
|
||||||
|
auth_type="oauth",
|
||||||
|
api_key="enc-access-token",
|
||||||
|
auth_config='{"email":"u@example.com","plan_type":"team","account_id":"acc-1","account_user_id":"user-1__acc-1","organizations":[{"id":"org-1","title":"Personal","is_default":true,"role":"owner"}],"expires_at":123456}',
|
||||||
|
name="codex-user",
|
||||||
|
)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
key.success_count = 0
|
||||||
|
key.request_count = 0
|
||||||
|
key.error_count = 0
|
||||||
|
key.total_response_time_ms = 0
|
||||||
|
key.rpm_limit = None
|
||||||
|
key.global_priority_by_format = None
|
||||||
|
key.allowed_models = None
|
||||||
|
key.capabilities = None
|
||||||
|
key.is_active = True
|
||||||
|
key.created_at = now
|
||||||
|
key.updated_at = now
|
||||||
|
key.cache_ttl_minutes = 5
|
||||||
|
key.max_probe_interval_minutes = 32
|
||||||
|
key.health_by_format = None
|
||||||
|
key.circuit_breaker_by_format = None
|
||||||
|
key.oauth_invalid_at = None
|
||||||
|
key.oauth_invalid_reason = None
|
||||||
|
key.note = None
|
||||||
|
key.last_used_at = None
|
||||||
|
|
||||||
|
monkeypatch.setattr(module.crypto_service, "decrypt", lambda value: value)
|
||||||
|
|
||||||
|
result = build_key_response(key)
|
||||||
|
|
||||||
|
assert result.oauth_email == "u@example.com"
|
||||||
|
assert result.oauth_plan_type == "team"
|
||||||
|
assert result.oauth_account_id == "acc-1"
|
||||||
|
assert result.oauth_account_user_id == "user-1__acc-1"
|
||||||
|
assert len(result.oauth_organizations) == 1
|
||||||
|
assert result.oauth_organizations[0].title == "Personal"
|
||||||
|
assert result.oauth_organizations[0].is_default is True
|
||||||
@@ -1,17 +1,24 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from src.api.admin import provider_oauth as module
|
from src.api.admin import provider_oauth as module
|
||||||
|
from src.core.exceptions import InvalidRequestException
|
||||||
|
|
||||||
|
|
||||||
def test_parse_standard_oauth_import_entries_keeps_codex_hints() -> None:
|
def test_parse_standard_oauth_import_entries_keeps_codex_hints() -> None:
|
||||||
entries = module._parse_standard_oauth_import_entries(
|
entries = module._parse_standard_oauth_import_entries(
|
||||||
'[{"refresh_token":"rt_1","accountId":"acc-1","planType":"TEAM","userId":"u-1","email":"u@example.com"}]'
|
'[{"refresh_token":"rt_1","accountId":"acc-1","chatgptAccountUserId":"u-1__acc-1","planType":"TEAM","userId":"u-1","email":"u@example.com"}]'
|
||||||
)
|
)
|
||||||
|
|
||||||
assert entries == [
|
assert entries == [
|
||||||
{
|
{
|
||||||
"refresh_token": "rt_1",
|
"refresh_token": "rt_1",
|
||||||
"account_id": "acc-1",
|
"account_id": "acc-1",
|
||||||
|
"account_user_id": "u-1__acc-1",
|
||||||
"plan_type": "team",
|
"plan_type": "team",
|
||||||
"user_id": "u-1",
|
"user_id": "u-1",
|
||||||
"email": "u@example.com",
|
"email": "u@example.com",
|
||||||
@@ -32,6 +39,7 @@ def test_apply_codex_import_hints_only_fills_missing_fields() -> None:
|
|||||||
module._apply_codex_import_hints(
|
module._apply_codex_import_hints(
|
||||||
auth_config,
|
auth_config,
|
||||||
{
|
{
|
||||||
|
"account_user_id": "u-1__acc-1",
|
||||||
"account_id": "acc-1",
|
"account_id": "acc-1",
|
||||||
"plan_type": "plus",
|
"plan_type": "plus",
|
||||||
"user_id": "user-1",
|
"user_id": "user-1",
|
||||||
@@ -40,6 +48,106 @@ def test_apply_codex_import_hints_only_fills_missing_fields() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert auth_config["account_id"] == "existing-account"
|
assert auth_config["account_id"] == "existing-account"
|
||||||
|
assert auth_config["account_user_id"] == "u-1__acc-1"
|
||||||
assert auth_config["plan_type"] == "plus"
|
assert auth_config["plan_type"] == "plus"
|
||||||
assert auth_config["user_id"] == "user-1"
|
assert auth_config["user_id"] == "user-1"
|
||||||
assert auth_config["email"] == "u@example.com"
|
assert auth_config["email"] == "u@example.com"
|
||||||
|
|
||||||
|
|
||||||
|
class _DummyQuery:
|
||||||
|
def __init__(self, keys: list[SimpleNamespace]) -> None:
|
||||||
|
self._keys = keys
|
||||||
|
|
||||||
|
def filter(self, *_args: object, **_kwargs: object) -> "_DummyQuery":
|
||||||
|
return self
|
||||||
|
|
||||||
|
def all(self) -> list[SimpleNamespace]:
|
||||||
|
return self._keys
|
||||||
|
|
||||||
|
|
||||||
|
class _DummyDB:
|
||||||
|
def __init__(self, keys: list[SimpleNamespace]) -> None:
|
||||||
|
self._keys = keys
|
||||||
|
|
||||||
|
def query(self, _model: object) -> _DummyQuery:
|
||||||
|
return _DummyQuery(self._keys)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_oauth_key(*, key_id: str, name: str, auth_config: dict[str, object]) -> SimpleNamespace:
|
||||||
|
return SimpleNamespace(
|
||||||
|
id=key_id,
|
||||||
|
name=name,
|
||||||
|
provider_id="provider-1",
|
||||||
|
auth_type="oauth",
|
||||||
|
auth_config=json.dumps(auth_config),
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_duplicate_oauth_account_codex_allows_same_user_different_account_id(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(module.crypto_service, "decrypt", lambda value, silent=True: value)
|
||||||
|
|
||||||
|
existing_key = _make_oauth_key(
|
||||||
|
key_id="key-1",
|
||||||
|
name="existing",
|
||||||
|
auth_config={
|
||||||
|
"provider_type": "codex",
|
||||||
|
"email": "u@example.com",
|
||||||
|
"user_id": "user-1",
|
||||||
|
"account_id": "acc-1",
|
||||||
|
"account_user_id": "user-1__acc-1",
|
||||||
|
"plan_type": "team",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
db = _DummyDB([existing_key])
|
||||||
|
|
||||||
|
result = module._check_duplicate_oauth_account(
|
||||||
|
db, # type: ignore[arg-type]
|
||||||
|
"provider-1",
|
||||||
|
{
|
||||||
|
"provider_type": "codex",
|
||||||
|
"email": "u@example.com",
|
||||||
|
"user_id": "user-1",
|
||||||
|
"account_id": "acc-2",
|
||||||
|
"account_user_id": "user-1__acc-2",
|
||||||
|
"plan_type": "team",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_duplicate_oauth_account_codex_rejects_same_account_user_identity(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(module.crypto_service, "decrypt", lambda value, silent=True: value)
|
||||||
|
|
||||||
|
existing_key = _make_oauth_key(
|
||||||
|
key_id="key-1",
|
||||||
|
name="existing",
|
||||||
|
auth_config={
|
||||||
|
"provider_type": "codex",
|
||||||
|
"email": "u@example.com",
|
||||||
|
"user_id": "user-1",
|
||||||
|
"account_id": "acc-1",
|
||||||
|
"account_user_id": "user-1__acc-1",
|
||||||
|
"plan_type": "team",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
db = _DummyDB([existing_key])
|
||||||
|
|
||||||
|
with pytest.raises(InvalidRequestException, match="已存在"):
|
||||||
|
module._check_duplicate_oauth_account(
|
||||||
|
db, # type: ignore[arg-type]
|
||||||
|
"provider-1",
|
||||||
|
{
|
||||||
|
"provider_type": "codex",
|
||||||
|
"email": "u@example.com",
|
||||||
|
"user_id": "user-1",
|
||||||
|
"account_id": "acc-1",
|
||||||
|
"account_user_id": "user-1__acc-1",
|
||||||
|
"plan_type": "team",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user