// attributeId -> value
include_subscriptions?: boolean
},
@@ -35,6 +36,7 @@ export async function list(
status: filters?.status,
role: filters?.role,
search: filters?.search,
+ group_name: filters?.group_name,
include_subscriptions: filters?.include_subscriptions
}
@@ -223,6 +225,25 @@ export async function getUserBalanceHistory(
return data
}
+/**
+ * Replace user's exclusive group
+ * @param userId - User ID
+ * @param oldGroupId - Current group ID to replace
+ * @param newGroupId - New group ID to replace with
+ * @returns Number of migrated keys
+ */
+export async function replaceGroup(
+ userId: number,
+ oldGroupId: number,
+ newGroupId: number
+): Promise<{ migrated_keys: number }> {
+ const { data } = await apiClient.post<{ migrated_keys: number }>(
+ `/admin/users/${userId}/replace-group`,
+ { old_group_id: oldGroupId, new_group_id: newGroupId }
+ )
+ return data
+}
+
export const usersAPI = {
list,
getById,
@@ -234,7 +255,8 @@ export const usersAPI = {
toggleStatus,
getUserApiKeys,
getUserUsageStats,
- getUserBalanceHistory
+ getUserBalanceHistory,
+ replaceGroup
}
export default usersAPI
diff --git a/frontend/src/components/account/AccountUsageCell.vue b/frontend/src/components/account/AccountUsageCell.vue
index 09236edd..37e18c35 100644
--- a/frontend/src/components/account/AccountUsageCell.vue
+++ b/frontend/src/components/account/AccountUsageCell.vue
@@ -67,21 +67,54 @@
:resets-at="usageInfo.seven_day_sonnet.resets_at"
color="purple"
/>
+
+
+
+
+ {{ t('admin.accounts.usageWindow.passiveSampled') }}
+
+
+
+
+
+ {{ t('admin.accounts.usageWindow.activeQuery') }}
+
+
-
-
+
-
+
-
-
-
-
-
-
-
-
@@ -136,24 +139,6 @@
-
-
-
-
-
@@ -389,8 +374,43 @@
-
-
+
+
+
+
+
+
+ {{ formatKeyRequests }} req
+
+
+ {{ formatKeyTokens }}
+
+
+ A ${{ formatKeyCost }}
+
+
+ U ${{ formatKeyUserCost }}
+
+
+
+
+
+
+
+
+
+
-
-
-
@@ -422,17 +444,28 @@ import { useI18n } from 'vue-i18n'
import { adminAPI } from '@/api/admin'
import type { Account, AccountUsageInfo, GeminiCredentials, WindowStats } from '@/types'
import { buildOpenAIUsageRefreshKey } from '@/utils/accountUsageRefresh'
-import { resolveCodexUsageWindow } from '@/utils/codexUsage'
+import { formatCompactNumber } from '@/utils/format'
import UsageProgressBar from './UsageProgressBar.vue'
import AccountQuotaInfo from './AccountQuotaInfo.vue'
-const props = defineProps<{
- account: Account
-}>()
+const props = withDefaults(
+ defineProps<{
+ account: Account
+ todayStats?: WindowStats | null
+ todayStatsLoading?: boolean
+ manualRefreshToken?: number
+ }>(),
+ {
+ todayStats: null,
+ todayStatsLoading: false,
+ manualRefreshToken: 0
+ }
+)
const { t } = useI18n()
const loading = ref(false)
+const activeQueryLoading = ref(false)
const error = ref
(null)
const usageInfo = ref(null)
@@ -470,54 +503,17 @@ const geminiUsageAvailable = computed(() => {
)
})
-const codex5hWindow = computed(() => resolveCodexUsageWindow(props.account.extra, '5h'))
-const codex7dWindow = computed(() => resolveCodexUsageWindow(props.account.extra, '7d'))
-
-// OpenAI Codex usage computed properties
-const hasCodexUsage = computed(() => {
- return codex5hWindow.value.usedPercent !== null || codex7dWindow.value.usedPercent !== null
-})
-
const hasOpenAIUsageFallback = computed(() => {
if (props.account.platform !== 'openai' || props.account.type !== 'oauth') return false
return !!usageInfo.value?.five_hour || !!usageInfo.value?.seven_day
})
-const isActiveOpenAIRateLimited = computed(() => {
- if (props.account.platform !== 'openai' || props.account.type !== 'oauth') return false
- if (!props.account.rate_limit_reset_at) return false
- const resetAt = Date.parse(props.account.rate_limit_reset_at)
- return !Number.isNaN(resetAt) && resetAt > Date.now()
-})
-
-const preferFetchedOpenAIUsage = computed(() => {
- return (isActiveOpenAIRateLimited.value || isOpenAICodexSnapshotStale.value) && hasOpenAIUsageFallback.value
-})
-
const openAIUsageRefreshKey = computed(() => buildOpenAIUsageRefreshKey(props.account))
-const isOpenAICodexSnapshotStale = computed(() => {
- if (props.account.platform !== 'openai' || props.account.type !== 'oauth') return false
- const extra = props.account.extra as Record | undefined
- const updatedAtRaw = extra?.codex_usage_updated_at
- if (!updatedAtRaw) return true
- const updatedAt = Date.parse(String(updatedAtRaw))
- if (Number.isNaN(updatedAt)) return true
- return Date.now() - updatedAt >= 10 * 60 * 1000
-})
-
const shouldAutoLoadUsageOnMount = computed(() => {
- if (props.account.platform === 'openai' && props.account.type === 'oauth') {
- return isActiveOpenAIRateLimited.value || !hasCodexUsage.value || isOpenAICodexSnapshotStale.value
- }
return shouldFetchUsage.value
})
-const codex5hUsedPercent = computed(() => codex5hWindow.value.usedPercent)
-const codex5hResetAt = computed(() => codex5hWindow.value.resetAt)
-const codex7dUsedPercent = computed(() => codex7dWindow.value.usedPercent)
-const codex7dResetAt = computed(() => codex7dWindow.value.resetAt)
-
// Antigravity quota types (用于 API 返回的数据)
interface AntigravityUsageResult {
utilization: number
@@ -925,14 +921,18 @@ const copyValidationURL = async () => {
}
}
-const loadUsage = async () => {
+const isAnthropicOAuthOrSetupToken = computed(() => {
+ return props.account.platform === 'anthropic' && (props.account.type === 'oauth' || props.account.type === 'setup-token')
+})
+
+const loadUsage = async (source?: 'passive' | 'active') => {
if (!shouldFetchUsage.value) return
loading.value = true
error.value = null
try {
- usageInfo.value = await adminAPI.accounts.getUsage(props.account.id)
+ usageInfo.value = await adminAPI.accounts.getUsage(props.account.id, source)
} catch (e: any) {
error.value = t('common.error')
console.error('Failed to load usage:', e)
@@ -941,6 +941,17 @@ const loadUsage = async () => {
}
}
+const loadActiveUsage = async () => {
+ activeQueryLoading.value = true
+ try {
+ usageInfo.value = await adminAPI.accounts.getUsage(props.account.id, 'active')
+ } catch (e: any) {
+ console.error('Failed to load active usage:', e)
+ } finally {
+ activeQueryLoading.value = false
+ }
+}
+
// ===== API Key quota progress bars =====
interface QuotaBarInfo {
@@ -1006,18 +1017,53 @@ const quotaTotalBar = computed((): QuotaBarInfo | null => {
return makeQuotaBar(props.account.quota_used ?? 0, limit)
})
+// ===== Key account today stats formatters =====
+
+const formatKeyRequests = computed(() => {
+ if (!props.todayStats) return ''
+ return formatCompactNumber(props.todayStats.requests, { allowBillions: false })
+})
+
+const formatKeyTokens = computed(() => {
+ if (!props.todayStats) return ''
+ return formatCompactNumber(props.todayStats.tokens)
+})
+
+const formatKeyCost = computed(() => {
+ if (!props.todayStats) return '0.00'
+ return props.todayStats.cost.toFixed(2)
+})
+
+const formatKeyUserCost = computed(() => {
+ if (!props.todayStats || props.todayStats.user_cost == null) return '0.00'
+ return props.todayStats.user_cost.toFixed(2)
+})
+
onMounted(() => {
if (!shouldAutoLoadUsageOnMount.value) return
- loadUsage()
+ const source = isAnthropicOAuthOrSetupToken.value ? 'passive' : undefined
+ loadUsage(source)
})
watch(openAIUsageRefreshKey, (nextKey, prevKey) => {
if (!prevKey || nextKey === prevKey) return
if (props.account.platform !== 'openai' || props.account.type !== 'oauth') return
- if (!isActiveOpenAIRateLimited.value && hasCodexUsage.value && !isOpenAICodexSnapshotStale.value) return
loadUsage().catch((e) => {
console.error('Failed to refresh OpenAI usage:', e)
})
})
+
+watch(
+ () => props.manualRefreshToken,
+ (nextToken, prevToken) => {
+ if (nextToken === prevToken) return
+ if (!shouldFetchUsage.value) return
+
+ const source = isAnthropicOAuthOrSetupToken.value ? 'passive' : undefined
+ loadUsage(source).catch((e) => {
+ console.error('Failed to refresh usage after manual refresh:', e)
+ })
+ }
+)
diff --git a/frontend/src/components/account/BulkEditAccountModal.vue b/frontend/src/components/account/BulkEditAccountModal.vue
index 64524d51..2934fbd9 100644
--- a/frontend/src/components/account/BulkEditAccountModal.vue
+++ b/frontend/src/components/account/BulkEditAccountModal.vue
@@ -31,6 +31,57 @@
+
+
+
+
+
+ {{ t('admin.accounts.openai.oauthPassthrough') }}
+
+
+ {{ t('admin.accounts.openai.oauthPassthroughDesc') }}
+
+
+
+
+
+
+
+
+
+
+
@@ -89,100 +140,30 @@
role="group"
aria-labelledby="bulk-edit-model-restriction-label"
>
-
-
-
-
-
-
- {{ t('admin.accounts.modelWhitelist') }}
-
-
-
-
-
- {{ t('admin.accounts.modelMapping') }}
-
-
-
-
-
-
-
-
-
-
- {{ t('admin.accounts.selectAllowedModels') }}
-
-
-
-
-
-
- {{ t('admin.accounts.selectedModels', { count: allowedModels.length }) }}
- {{
- t('admin.accounts.supportsAllModels')
- }}
+
+
+ {{ t('admin.accounts.openai.modelRestrictionDisabledByPassthrough') }}
-
-
-
-
+
+
+
+
- {{ t('admin.accounts.mapRequestModels') }}
-
+ {{ t('admin.accounts.modelWhitelist') }}
+
+
+
+
+
+ {{ t('admin.accounts.modelMapping') }}
+
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+ {{ t('admin.accounts.selectAllowedModels') }}
+
+
+
+
+
+
+ {{ t('admin.accounts.selectedModels', { count: allowedModels.length }) }}
+ {{
+ t('admin.accounts.supportsAllModels')
+ }}
+
+
+
+
+
+
+
+
+
+
+ {{ t('admin.accounts.mapRequestModels') }}
+
+
+
+
+
+
+
+
+
+
+ {{ t('admin.accounts.addMapping') }}
+
+
+
+
+
+ + {{ preset.label }}
-
-
-
-
-
- {{ t('admin.accounts.addMapping') }}
-
-
-
-
-
- + {{ preset.label }}
-
-
-
+
@@ -599,6 +661,43 @@
+
+
+
+
+ {{ t('admin.accounts.openai.wsMode') }}
+
+
+
+
+
+ {{ t('admin.accounts.openai.wsModeDesc') }}
+
+
+ {{ t(openAIWSModeConcurrencyHintKey) }}
+
+
+
+
+
@@ -821,7 +920,13 @@ import {
buildModelMappingObject as buildModelMappingPayload,
getPresetMappingsByPlatform
} from '@/composables/useModelWhitelist'
-
+import {
+ OPENAI_WS_MODE_OFF,
+ OPENAI_WS_MODE_PASSTHROUGH,
+ isOpenAIWSModeEnabled,
+ resolveOpenAIWSModeConcurrencyHintKey
+} from '@/utils/openaiWsMode'
+import type { OpenAIWSMode } from '@/utils/openaiWsMode'
interface Props {
show: boolean
accountIds: number[]
@@ -843,6 +948,24 @@ const appStore = useAppStore()
// Platform awareness
const isMixedPlatform = computed(() => props.selectedPlatforms.length > 1)
+const allOpenAIPassthroughCapable = computed(() => {
+ return (
+ props.selectedPlatforms.length === 1 &&
+ props.selectedPlatforms[0] === 'openai' &&
+ props.selectedTypes.length > 0 &&
+ props.selectedTypes.every(t => t === 'oauth' || t === 'apikey')
+ )
+})
+
+const allOpenAIOAuth = computed(() => {
+ return (
+ props.selectedPlatforms.length === 1 &&
+ props.selectedPlatforms[0] === 'openai' &&
+ props.selectedTypes.length > 0 &&
+ props.selectedTypes.every(t => t === 'oauth')
+ )
+})
+
// 是否全部为 Anthropic OAuth/SetupToken(RPM 配置仅在此条件下显示)
const allAnthropicOAuthOrSetupToken = computed(() => {
return (
@@ -886,6 +1009,8 @@ const enablePriority = ref(false)
const enableRateMultiplier = ref(false)
const enableStatus = ref(false)
const enableGroups = ref(false)
+const enableOpenAIPassthrough = ref(false)
+const enableOpenAIWSMode = ref(false)
const enableRpmLimit = ref(false)
// State - field values
@@ -907,6 +1032,8 @@ const priority = ref(1)
const rateMultiplier = ref(1)
const status = ref<'active' | 'inactive'>('active')
const groupIds = ref([])
+const openaiPassthroughEnabled = ref(false)
+const openaiOAuthResponsesWebSocketV2Mode = ref(OPENAI_WS_MODE_OFF)
const rpmLimitEnabled = ref(false)
const bulkBaseRpm = ref(null)
const bulkRpmStrategy = ref<'tiered' | 'sticky_exempt'>('tiered')
@@ -933,6 +1060,20 @@ const statusOptions = computed(() => [
{ value: 'active', label: t('common.active') },
{ value: 'inactive', label: t('common.inactive') }
])
+const isOpenAIModelRestrictionDisabled = computed(
+ () =>
+ allOpenAIPassthroughCapable.value &&
+ enableOpenAIPassthrough.value &&
+ openaiPassthroughEnabled.value
+)
+
+const openAIWSModeOptions = computed(() => [
+ { value: OPENAI_WS_MODE_OFF, label: t('admin.accounts.openai.wsModeOff') },
+ { value: OPENAI_WS_MODE_PASSTHROUGH, label: t('admin.accounts.openai.wsModePassthrough') }
+])
+const openAIWSModeConcurrencyHintKey = computed(() =>
+ resolveOpenAIWSModeConcurrencyHintKey(openaiOAuthResponsesWebSocketV2Mode.value)
+)
// Model mapping helpers
const addModelMapping = () => {
@@ -1015,6 +1156,12 @@ const buildUpdatePayload = (): Record | null => {
const updates: Record = {}
const credentials: Record = {}
let credentialsChanged = false
+ const ensureExtra = (): Record => {
+ if (!updates.extra) {
+ updates.extra = {}
+ }
+ return updates.extra as Record
+ }
if (enableProxy.value) {
// 后端期望 proxy_id: 0 表示清除代理,而不是 null
@@ -1055,25 +1202,30 @@ const buildUpdatePayload = (): Record | null => {
}
}
- if (enableModelRestriction.value) {
- const modelMapping = buildModelMappingObject()
+ if (enableOpenAIPassthrough.value) {
+ const extra = ensureExtra()
+ extra.openai_passthrough = openaiPassthroughEnabled.value
+ if (!openaiPassthroughEnabled.value) {
+ extra.openai_oauth_passthrough = false
+ }
+ }
+ if (enableModelRestriction.value && !isOpenAIModelRestrictionDisabled.value) {
// 统一使用 model_mapping 字段
if (modelRestrictionMode.value === 'whitelist') {
- if (allowedModels.value.length > 0) {
- // 白名单模式:将模型转换为 model_mapping 格式(key=value)
- const mapping: Record = {}
- for (const m of allowedModels.value) {
- mapping[m] = m
- }
- credentials.model_mapping = mapping
- credentialsChanged = true
+ // 白名单模式:将模型转换为 model_mapping 格式(key=value)
+ // 空白名单表示“支持所有模型”,需显式发送空对象以覆盖已有限制。
+ const mapping: Record = {}
+ for (const m of allowedModels.value) {
+ mapping[m] = m
}
+ credentials.model_mapping = mapping
+ credentialsChanged = true
} else {
- if (modelMapping) {
- credentials.model_mapping = modelMapping
- credentialsChanged = true
- }
+ // 映射模式下空配置同样表示“支持所有模型”。
+ const modelMapping = buildModelMappingObject()
+ credentials.model_mapping = modelMapping ?? {}
+ credentialsChanged = true
}
}
@@ -1092,9 +1244,17 @@ const buildUpdatePayload = (): Record | null => {
updates.credentials = credentials
}
+ if (enableOpenAIWSMode.value) {
+ const extra = ensureExtra()
+ extra.openai_oauth_responses_websockets_v2_mode = openaiOAuthResponsesWebSocketV2Mode.value
+ extra.openai_oauth_responses_websockets_v2_enabled = isOpenAIWSModeEnabled(
+ openaiOAuthResponsesWebSocketV2Mode.value
+ )
+ }
+
// RPM limit settings (写入 extra 字段)
if (enableRpmLimit.value) {
- const extra: Record = {}
+ const extra = ensureExtra()
if (rpmLimitEnabled.value && bulkBaseRpm.value != null && bulkBaseRpm.value > 0) {
extra.base_rpm = bulkBaseRpm.value
extra.rpm_strategy = bulkRpmStrategy.value
@@ -1114,8 +1274,7 @@ const buildUpdatePayload = (): Record | null => {
// UMQ mode(独立于 RPM 保存)
if (userMsgQueueMode.value !== null) {
- if (!updates.extra) updates.extra = {}
- const umqExtra = updates.extra as Record
+ const umqExtra = ensureExtra()
umqExtra.user_msg_queue_mode = userMsgQueueMode.value // '' = 清除账号级覆盖
umqExtra.user_msg_queue_enabled = false // 清理旧字段(JSONB merge)
}
@@ -1171,6 +1330,7 @@ const handleSubmit = async () => {
const hasAnyFieldEnabled =
enableBaseUrl.value ||
+ enableOpenAIPassthrough.value ||
enableModelRestriction.value ||
enableCustomErrorCodes.value ||
enableInterceptWarmup.value ||
@@ -1181,6 +1341,7 @@ const handleSubmit = async () => {
enableRateMultiplier.value ||
enableStatus.value ||
enableGroups.value ||
+ enableOpenAIWSMode.value ||
enableRpmLimit.value ||
userMsgQueueMode.value !== null
@@ -1272,10 +1433,13 @@ watch(
enableRateMultiplier.value = false
enableStatus.value = false
enableGroups.value = false
+ enableOpenAIPassthrough.value = false
+ enableOpenAIWSMode.value = false
enableRpmLimit.value = false
// Reset all values
baseUrl.value = ''
+ openaiPassthroughEnabled.value = false
modelRestrictionMode.value = 'whitelist'
allowedModels.value = []
modelMappings.value = []
@@ -1289,6 +1453,7 @@ watch(
rateMultiplier.value = 1
status.value = 'active'
groupIds.value = []
+ openaiOAuthResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF
rpmLimitEnabled.value = false
bulkBaseRpm.value = null
bulkRpmStrategy.value = 'tiered'
diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue
index 6f02a9d9..7ffa453f 100644
--- a/frontend/src/components/account/CreateAccountModal.vue
+++ b/frontend/src/components/account/CreateAccountModal.vue
@@ -2169,6 +2169,14 @@
/>
+
+
+
+ {{ t('admin.accounts.quotaControl.tlsFingerprint.defaultProfile') }}
+ {{ t('admin.accounts.quotaControl.tlsFingerprint.randomProfile') }}
+ {{ p.name }}
+
+
@@ -2237,6 +2245,41 @@
+
+
+
+
+
+
{{ t('admin.accounts.quotaControl.customBaseUrl.label') }}
+
+ {{ t('admin.accounts.quotaControl.customBaseUrl.hint') }}
+
+
+
+
+
+
+
+
+
+
@@ -2504,6 +2547,7 @@
:allow-multiple="form.platform === 'anthropic'"
:show-cookie-option="form.platform === 'anthropic'"
:show-refresh-token-option="form.platform === 'openai' || form.platform === 'sora' || form.platform === 'antigravity'"
+ :show-mobile-refresh-token-option="form.platform === 'openai'"
:show-session-token-option="form.platform === 'sora'"
:show-access-token-option="form.platform === 'sora'"
:platform="form.platform"
@@ -2511,6 +2555,7 @@
@generate-url="handleGenerateUrl"
@cookie-auth="handleCookieAuth"
@validate-refresh-token="handleValidateRefreshToken"
+ @validate-mobile-refresh-token="handleOpenAIValidateMobileRT"
@validate-session-token="handleValidateSessionToken"
@import-access-token="handleImportAccessToken"
/>
@@ -3080,9 +3125,13 @@ const umqModeOptions = computed(() => [
{ value: 'serialize', label: t('admin.accounts.quotaControl.rpmLimit.umqModeSerialize') },
])
const tlsFingerprintEnabled = ref(false)
+const tlsFingerprintProfileId = ref(null)
+const tlsFingerprintProfiles = ref<{ id: number; name: string }[]>([])
const sessionIdMaskingEnabled = ref(false)
const cacheTTLOverrideEnabled = ref(false)
const cacheTTLOverrideTarget = ref('5m')
+const customBaseUrlEnabled = ref(false)
+const customBaseUrl = ref('')
// Gemini tier selection (used as fallback when auto-detection is unavailable/fails)
const geminiTierGoogleOne = ref<'google_one_free' | 'google_ai_pro' | 'google_ai_ultra'>('google_one_free')
@@ -3245,6 +3294,10 @@ watch(
() => props.show,
(newVal) => {
if (newVal) {
+ // Load TLS fingerprint profiles
+ adminAPI.tlsFingerprintProfiles.list()
+ .then(profiles => { tlsFingerprintProfiles.value = profiles.map(p => ({ id: p.id, name: p.name })) })
+ .catch(() => { tlsFingerprintProfiles.value = [] })
// Modal opened - fill related models
allowedModels.value = [...getModelsByPlatform(form.platform)]
// Antigravity: 默认使用映射模式并填充默认映射
@@ -3745,9 +3798,12 @@ const resetForm = () => {
rpmStickyBuffer.value = null
userMsgQueueMode.value = ''
tlsFingerprintEnabled.value = false
+ tlsFingerprintProfileId.value = null
sessionIdMaskingEnabled.value = false
cacheTTLOverrideEnabled.value = false
cacheTTLOverrideTarget.value = '5m'
+ customBaseUrlEnabled.value = false
+ customBaseUrl.value = ''
allowOverages.value = false
antigravityAccountType.value = 'oauth'
upstreamBaseUrl.value = ''
@@ -4360,11 +4416,14 @@ const handleOpenAIExchange = async (authCode: string) => {
}
// OpenAI 手动 RT 批量验证和创建
-const handleOpenAIValidateRT = async (refreshTokenInput: string) => {
+// OpenAI Mobile RT 使用的 client_id(与后端 openai.SoraClientID 一致)
+const OPENAI_MOBILE_RT_CLIENT_ID = 'app_LlGpXReQgckcGGUo2JrYvtJK'
+
+// OpenAI/Sora RT 批量验证和创建(共享逻辑)
+const handleOpenAIBatchRT = async (refreshTokenInput: string, clientId?: string) => {
const oauthClient = activeOpenAIOAuth.value
if (!refreshTokenInput.trim()) return
- // Parse multiple refresh tokens (one per line)
const refreshTokens = refreshTokenInput
.split('\n')
.map((rt) => rt.trim())
@@ -4389,7 +4448,8 @@ const handleOpenAIValidateRT = async (refreshTokenInput: string) => {
try {
const tokenInfo = await oauthClient.validateRefreshToken(
refreshTokens[i],
- form.proxy_id
+ form.proxy_id,
+ clientId
)
if (!tokenInfo) {
failedCount++
@@ -4399,6 +4459,9 @@ const handleOpenAIValidateRT = async (refreshTokenInput: string) => {
}
const credentials = oauthClient.buildCredentials(tokenInfo)
+ if (clientId) {
+ credentials.client_id = clientId
+ }
const oauthExtra = oauthClient.buildExtraInfo(tokenInfo) as Record | undefined
const extra = buildOpenAIExtra(oauthExtra)
@@ -4410,8 +4473,9 @@ const handleOpenAIValidateRT = async (refreshTokenInput: string) => {
}
}
- // Generate account name with index for batch
- const accountName = refreshTokens.length > 1 ? `${form.name} #${i + 1}` : form.name
+ // Generate account name; fallback to email if name is empty (ent schema requires NotEmpty)
+ const baseName = form.name || tokenInfo.email || 'OpenAI OAuth Account'
+ const accountName = refreshTokens.length > 1 ? `${baseName} #${i + 1}` : baseName
let openaiAccountId: string | number | undefined
@@ -4494,6 +4558,12 @@ const handleOpenAIValidateRT = async (refreshTokenInput: string) => {
}
}
+// 手动输入 RT(Codex CLI client_id,默认)
+const handleOpenAIValidateRT = (rt: string) => handleOpenAIBatchRT(rt)
+
+// 手动输入 Mobile RT(SoraClientID)
+const handleOpenAIValidateMobileRT = (rt: string) => handleOpenAIBatchRT(rt, OPENAI_MOBILE_RT_CLIENT_ID)
+
// Sora 手动 ST 批量验证和创建
const handleSoraValidateST = async (sessionTokenInput: string) => {
const oauthClient = activeOpenAIOAuth.value
@@ -4809,6 +4879,9 @@ const handleAnthropicExchange = async (authCode: string) => {
// Add TLS fingerprint settings
if (tlsFingerprintEnabled.value) {
extra.enable_tls_fingerprint = true
+ if (tlsFingerprintProfileId.value) {
+ extra.tls_fingerprint_profile_id = tlsFingerprintProfileId.value
+ }
}
// Add session ID masking settings
@@ -4822,6 +4895,12 @@ const handleAnthropicExchange = async (authCode: string) => {
extra.cache_ttl_override_target = cacheTTLOverrideTarget.value
}
+ // Add custom base URL settings
+ if (customBaseUrlEnabled.value && customBaseUrl.value.trim()) {
+ extra.custom_base_url_enabled = true
+ extra.custom_base_url = customBaseUrl.value.trim()
+ }
+
const credentials: Record = { ...tokenInfo }
applyInterceptWarmup(credentials, interceptWarmupRequests.value, 'create')
await createAccountAndFinish(form.platform, addMethod.value as AccountType, credentials, extra)
@@ -4924,6 +5003,9 @@ const handleCookieAuth = async (sessionKey: string) => {
// Add TLS fingerprint settings
if (tlsFingerprintEnabled.value) {
extra.enable_tls_fingerprint = true
+ if (tlsFingerprintProfileId.value) {
+ extra.tls_fingerprint_profile_id = tlsFingerprintProfileId.value
+ }
}
// Add session ID masking settings
@@ -4937,6 +5019,12 @@ const handleCookieAuth = async (sessionKey: string) => {
extra.cache_ttl_override_target = cacheTTLOverrideTarget.value
}
+ // Add custom base URL settings
+ if (customBaseUrlEnabled.value && customBaseUrl.value.trim()) {
+ extra.custom_base_url_enabled = true
+ extra.custom_base_url = customBaseUrl.value.trim()
+ }
+
const accountName = keys.length > 1 ? `${form.name} #${i + 1}` : form.name
const credentials: Record = { ...tokenInfo }
diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue
index c2f2f7d2..607e7a69 100644
--- a/frontend/src/components/account/EditAccountModal.vue
+++ b/frontend/src/components/account/EditAccountModal.vue
@@ -1504,6 +1504,14 @@
/>
+
+
+
+ {{ t('admin.accounts.quotaControl.tlsFingerprint.defaultProfile') }}
+ {{ t('admin.accounts.quotaControl.tlsFingerprint.randomProfile') }}
+ {{ p.name }}
+
+
@@ -1572,6 +1580,41 @@
+
+
+
+
+
+
{{ t('admin.accounts.quotaControl.customBaseUrl.label') }}
+
+ {{ t('admin.accounts.quotaControl.customBaseUrl.hint') }}
+
+
+
+
+
+
+
+
+
+
@@ -1841,9 +1884,13 @@ const umqModeOptions = computed(() => [
{ value: 'serialize', label: t('admin.accounts.quotaControl.rpmLimit.umqModeSerialize') },
])
const tlsFingerprintEnabled = ref(false)
+const tlsFingerprintProfileId = ref(null)
+const tlsFingerprintProfiles = ref<{ id: number; name: string }[]>([])
const sessionIdMaskingEnabled = ref(false)
const cacheTTLOverrideEnabled = ref(false)
const cacheTTLOverrideTarget = ref('5m')
+const customBaseUrlEnabled = ref(false)
+const customBaseUrl = ref('')
// OpenAI 自动透传开关(OAuth/API Key)
const openaiPassthroughEnabled = ref(false)
@@ -1980,276 +2027,296 @@ const normalizePoolModeRetryCount = (value: number) => {
return normalized
}
-watch(
- () => props.account,
- (newAccount) => {
- if (newAccount) {
- antigravityMixedChannelConfirmed.value = false
- showMixedChannelWarning.value = false
- mixedChannelWarningDetails.value = null
- mixedChannelWarningRawMessage.value = ''
- mixedChannelWarningAction.value = null
- form.name = newAccount.name
- form.notes = newAccount.notes || ''
- form.proxy_id = newAccount.proxy_id
- form.concurrency = newAccount.concurrency
- form.load_factor = newAccount.load_factor ?? null
- form.priority = newAccount.priority
- form.rate_multiplier = newAccount.rate_multiplier ?? 1
- form.status = (newAccount.status === 'active' || newAccount.status === 'inactive' || newAccount.status === 'error')
- ? newAccount.status
- : 'active'
- form.group_ids = newAccount.group_ids || []
- form.expires_at = newAccount.expires_at ?? null
+const syncFormFromAccount = (newAccount: Account | null) => {
+ if (!newAccount) {
+ return
+ }
+ antigravityMixedChannelConfirmed.value = false
+ showMixedChannelWarning.value = false
+ mixedChannelWarningDetails.value = null
+ mixedChannelWarningRawMessage.value = ''
+ mixedChannelWarningAction.value = null
+ form.name = newAccount.name
+ form.notes = newAccount.notes || ''
+ form.proxy_id = newAccount.proxy_id
+ form.concurrency = newAccount.concurrency
+ form.load_factor = newAccount.load_factor ?? null
+ form.priority = newAccount.priority
+ form.rate_multiplier = newAccount.rate_multiplier ?? 1
+ form.status = (newAccount.status === 'active' || newAccount.status === 'inactive' || newAccount.status === 'error')
+ ? newAccount.status
+ : 'active'
+ form.group_ids = newAccount.group_ids || []
+ form.expires_at = newAccount.expires_at ?? null
- // Load intercept warmup requests setting (applies to all account types)
- const credentials = newAccount.credentials as Record | undefined
- interceptWarmupRequests.value = credentials?.intercept_warmup_requests === true
- autoPauseOnExpired.value = newAccount.auto_pause_on_expired === true
+ // Load intercept warmup requests setting (applies to all account types)
+ const credentials = newAccount.credentials as Record | undefined
+ interceptWarmupRequests.value = credentials?.intercept_warmup_requests === true
+ autoPauseOnExpired.value = newAccount.auto_pause_on_expired === true
- // Load mixed scheduling setting (only for antigravity accounts)
- mixedScheduling.value = false
- allowOverages.value = false
- const extra = newAccount.extra as Record | undefined
- mixedScheduling.value = extra?.mixed_scheduling === true
- allowOverages.value = extra?.allow_overages === true
+ // Load mixed scheduling setting (only for antigravity accounts)
+ mixedScheduling.value = false
+ allowOverages.value = false
+ const extra = newAccount.extra as Record | undefined
+ mixedScheduling.value = extra?.mixed_scheduling === true
+ allowOverages.value = extra?.allow_overages === true
- // Load OpenAI passthrough toggle (OpenAI OAuth/API Key)
- openaiPassthroughEnabled.value = false
- openaiOAuthResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF
- openaiAPIKeyResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF
- codexCLIOnlyEnabled.value = false
- anthropicPassthroughEnabled.value = false
- if (newAccount.platform === 'openai' && (newAccount.type === 'oauth' || newAccount.type === 'apikey')) {
- openaiPassthroughEnabled.value = extra?.openai_passthrough === true || extra?.openai_oauth_passthrough === true
- openaiOAuthResponsesWebSocketV2Mode.value = resolveOpenAIWSModeFromExtra(extra, {
- modeKey: 'openai_oauth_responses_websockets_v2_mode',
- enabledKey: 'openai_oauth_responses_websockets_v2_enabled',
- fallbackEnabledKeys: ['responses_websockets_v2_enabled', 'openai_ws_enabled'],
- defaultMode: OPENAI_WS_MODE_OFF
- })
- openaiAPIKeyResponsesWebSocketV2Mode.value = resolveOpenAIWSModeFromExtra(extra, {
- modeKey: 'openai_apikey_responses_websockets_v2_mode',
- enabledKey: 'openai_apikey_responses_websockets_v2_enabled',
- fallbackEnabledKeys: ['responses_websockets_v2_enabled', 'openai_ws_enabled'],
- defaultMode: OPENAI_WS_MODE_OFF
- })
- if (newAccount.type === 'oauth') {
- codexCLIOnlyEnabled.value = extra?.codex_cli_only === true
- }
- }
- if (newAccount.platform === 'anthropic' && newAccount.type === 'apikey') {
- anthropicPassthroughEnabled.value = extra?.anthropic_passthrough === true
- }
+ // Load OpenAI passthrough toggle (OpenAI OAuth/API Key)
+ openaiPassthroughEnabled.value = false
+ openaiOAuthResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF
+ openaiAPIKeyResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF
+ codexCLIOnlyEnabled.value = false
+ anthropicPassthroughEnabled.value = false
+ if (newAccount.platform === 'openai' && (newAccount.type === 'oauth' || newAccount.type === 'apikey')) {
+ openaiPassthroughEnabled.value = extra?.openai_passthrough === true || extra?.openai_oauth_passthrough === true
+ openaiOAuthResponsesWebSocketV2Mode.value = resolveOpenAIWSModeFromExtra(extra, {
+ modeKey: 'openai_oauth_responses_websockets_v2_mode',
+ enabledKey: 'openai_oauth_responses_websockets_v2_enabled',
+ fallbackEnabledKeys: ['responses_websockets_v2_enabled', 'openai_ws_enabled'],
+ defaultMode: OPENAI_WS_MODE_OFF
+ })
+ openaiAPIKeyResponsesWebSocketV2Mode.value = resolveOpenAIWSModeFromExtra(extra, {
+ modeKey: 'openai_apikey_responses_websockets_v2_mode',
+ enabledKey: 'openai_apikey_responses_websockets_v2_enabled',
+ fallbackEnabledKeys: ['responses_websockets_v2_enabled', 'openai_ws_enabled'],
+ defaultMode: OPENAI_WS_MODE_OFF
+ })
+ if (newAccount.type === 'oauth') {
+ codexCLIOnlyEnabled.value = extra?.codex_cli_only === true
+ }
+ }
+ if (newAccount.platform === 'anthropic' && newAccount.type === 'apikey') {
+ anthropicPassthroughEnabled.value = extra?.anthropic_passthrough === true
+ }
- // Load quota limit for apikey/bedrock accounts (bedrock quota is also loaded in its own branch above)
- if (newAccount.type === 'apikey' || newAccount.type === 'bedrock') {
- const quotaVal = extra?.quota_limit as number | undefined
- editQuotaLimit.value = (quotaVal && quotaVal > 0) ? quotaVal : null
- const dailyVal = extra?.quota_daily_limit as number | undefined
- editQuotaDailyLimit.value = (dailyVal && dailyVal > 0) ? dailyVal : null
- const weeklyVal = extra?.quota_weekly_limit as number | undefined
- editQuotaWeeklyLimit.value = (weeklyVal && weeklyVal > 0) ? weeklyVal : null
- // Load quota reset mode config
- editDailyResetMode.value = (extra?.quota_daily_reset_mode as 'rolling' | 'fixed') || null
- editDailyResetHour.value = (extra?.quota_daily_reset_hour as number) ?? null
- editWeeklyResetMode.value = (extra?.quota_weekly_reset_mode as 'rolling' | 'fixed') || null
- editWeeklyResetDay.value = (extra?.quota_weekly_reset_day as number) ?? null
- editWeeklyResetHour.value = (extra?.quota_weekly_reset_hour as number) ?? null
- editResetTimezone.value = (extra?.quota_reset_timezone as string) || null
+ // Load quota limit for apikey/bedrock accounts (bedrock quota is also loaded in its own branch above)
+ if (newAccount.type === 'apikey' || newAccount.type === 'bedrock') {
+ const quotaVal = extra?.quota_limit as number | undefined
+ editQuotaLimit.value = (quotaVal && quotaVal > 0) ? quotaVal : null
+ const dailyVal = extra?.quota_daily_limit as number | undefined
+ editQuotaDailyLimit.value = (dailyVal && dailyVal > 0) ? dailyVal : null
+ const weeklyVal = extra?.quota_weekly_limit as number | undefined
+ editQuotaWeeklyLimit.value = (weeklyVal && weeklyVal > 0) ? weeklyVal : null
+ // Load quota reset mode config
+ editDailyResetMode.value = (extra?.quota_daily_reset_mode as 'rolling' | 'fixed') || null
+ editDailyResetHour.value = (extra?.quota_daily_reset_hour as number) ?? null
+ editWeeklyResetMode.value = (extra?.quota_weekly_reset_mode as 'rolling' | 'fixed') || null
+ editWeeklyResetDay.value = (extra?.quota_weekly_reset_day as number) ?? null
+ editWeeklyResetHour.value = (extra?.quota_weekly_reset_hour as number) ?? null
+ editResetTimezone.value = (extra?.quota_reset_timezone as string) || null
+ } else {
+ editQuotaLimit.value = null
+ editQuotaDailyLimit.value = null
+ editQuotaWeeklyLimit.value = null
+ editDailyResetMode.value = null
+ editDailyResetHour.value = null
+ editWeeklyResetMode.value = null
+ editWeeklyResetDay.value = null
+ editWeeklyResetHour.value = null
+ editResetTimezone.value = null
+ }
+
+ // Load antigravity model mapping (Antigravity 只支持映射模式)
+ if (newAccount.platform === 'antigravity') {
+ const credentials = newAccount.credentials as Record | undefined
+
+ // Antigravity 始终使用映射模式
+ antigravityModelRestrictionMode.value = 'mapping'
+ antigravityWhitelistModels.value = []
+
+ // 从 model_mapping 读取映射配置
+ const rawAgMapping = credentials?.model_mapping as Record | undefined
+ if (rawAgMapping && typeof rawAgMapping === 'object') {
+ const entries = Object.entries(rawAgMapping)
+ // 无论是白名单样式(key===value)还是真正的映射,都统一转换为映射列表
+ antigravityModelMappings.value = entries.map(([from, to]) => ({ from, to }))
+ } else {
+ // 兼容旧数据:从 model_whitelist 读取,转换为映射格式
+ const rawWhitelist = credentials?.model_whitelist
+ if (Array.isArray(rawWhitelist) && rawWhitelist.length > 0) {
+ antigravityModelMappings.value = rawWhitelist
+ .map((v) => String(v).trim())
+ .filter((v) => v.length > 0)
+ .map((m) => ({ from: m, to: m }))
} else {
- editQuotaLimit.value = null
- editQuotaDailyLimit.value = null
- editQuotaWeeklyLimit.value = null
- editDailyResetMode.value = null
- editDailyResetHour.value = null
- editWeeklyResetMode.value = null
- editWeeklyResetDay.value = null
- editWeeklyResetHour.value = null
- editResetTimezone.value = null
- }
-
- // Load antigravity model mapping (Antigravity 只支持映射模式)
- if (newAccount.platform === 'antigravity') {
- const credentials = newAccount.credentials as Record | undefined
-
- // Antigravity 始终使用映射模式
- antigravityModelRestrictionMode.value = 'mapping'
- antigravityWhitelistModels.value = []
-
- // 从 model_mapping 读取映射配置
- const rawAgMapping = credentials?.model_mapping as Record | undefined
- if (rawAgMapping && typeof rawAgMapping === 'object') {
- const entries = Object.entries(rawAgMapping)
- // 无论是白名单样式(key===value)还是真正的映射,都统一转换为映射列表
- antigravityModelMappings.value = entries.map(([from, to]) => ({ from, to }))
- } else {
- // 兼容旧数据:从 model_whitelist 读取,转换为映射格式
- const rawWhitelist = credentials?.model_whitelist
- if (Array.isArray(rawWhitelist) && rawWhitelist.length > 0) {
- antigravityModelMappings.value = rawWhitelist
- .map((v) => String(v).trim())
- .filter((v) => v.length > 0)
- .map((m) => ({ from: m, to: m }))
- } else {
- antigravityModelMappings.value = []
- }
- }
- } else {
- antigravityModelRestrictionMode.value = 'mapping'
- antigravityWhitelistModels.value = []
antigravityModelMappings.value = []
}
+ }
+ } else {
+ antigravityModelRestrictionMode.value = 'mapping'
+ antigravityWhitelistModels.value = []
+ antigravityModelMappings.value = []
+ }
- // Load quota control settings (Anthropic OAuth/SetupToken only)
- loadQuotaControlSettings(newAccount)
+ // Load quota control settings (Anthropic OAuth/SetupToken only)
+ loadQuotaControlSettings(newAccount)
- loadTempUnschedRules(credentials)
+ loadTempUnschedRules(credentials)
- // Initialize API Key fields for apikey type
- if (newAccount.type === 'apikey' && newAccount.credentials) {
- const credentials = newAccount.credentials as Record
- const platformDefaultUrl =
- newAccount.platform === 'openai' || newAccount.platform === 'sora'
- ? 'https://api.openai.com'
- : newAccount.platform === 'gemini'
- ? 'https://generativelanguage.googleapis.com'
- : 'https://api.anthropic.com'
- editBaseUrl.value = (credentials.base_url as string) || platformDefaultUrl
+ // Initialize API Key fields for apikey type
+ if (newAccount.type === 'apikey' && newAccount.credentials) {
+ const credentials = newAccount.credentials as Record
+ const platformDefaultUrl =
+ newAccount.platform === 'openai' || newAccount.platform === 'sora'
+ ? 'https://api.openai.com'
+ : newAccount.platform === 'gemini'
+ ? 'https://generativelanguage.googleapis.com'
+ : 'https://api.anthropic.com'
+ editBaseUrl.value = (credentials.base_url as string) || platformDefaultUrl
- // Load model mappings and detect mode
- const existingMappings = credentials.model_mapping as Record | undefined
- if (existingMappings && typeof existingMappings === 'object') {
- const entries = Object.entries(existingMappings)
+ // Load model mappings and detect mode
+ const existingMappings = credentials.model_mapping as Record | undefined
+ if (existingMappings && typeof existingMappings === 'object') {
+ const entries = Object.entries(existingMappings)
- // Detect if this is whitelist mode (all from === to) or mapping mode
- const isWhitelistMode = entries.length > 0 && entries.every(([from, to]) => from === to)
+ // Detect if this is whitelist mode (all from === to) or mapping mode
+ const isWhitelistMode = entries.length > 0 && entries.every(([from, to]) => from === to)
- if (isWhitelistMode) {
- // Whitelist mode: populate allowedModels
- modelRestrictionMode.value = 'whitelist'
- allowedModels.value = entries.map(([from]) => from)
- modelMappings.value = []
- } else {
- // Mapping mode: populate modelMappings
- modelRestrictionMode.value = 'mapping'
- modelMappings.value = entries.map(([from, to]) => ({ from, to }))
- allowedModels.value = []
- }
- } else {
- // No mappings: default to whitelist mode with empty selection (allow all)
- modelRestrictionMode.value = 'whitelist'
- modelMappings.value = []
- allowedModels.value = []
- }
-
- // Load pool mode
- poolModeEnabled.value = credentials.pool_mode === true
- poolModeRetryCount.value = normalizePoolModeRetryCount(
- Number(credentials.pool_mode_retry_count ?? DEFAULT_POOL_MODE_RETRY_COUNT)
- )
-
- // Load custom error codes
- customErrorCodesEnabled.value = credentials.custom_error_codes_enabled === true
- const existingErrorCodes = credentials.custom_error_codes as number[] | undefined
- if (existingErrorCodes && Array.isArray(existingErrorCodes)) {
- selectedErrorCodes.value = [...existingErrorCodes]
- } else {
- selectedErrorCodes.value = []
- }
- } else if (newAccount.type === 'bedrock' && newAccount.credentials) {
- const bedrockCreds = newAccount.credentials as Record
- const authMode = (bedrockCreds.auth_mode as string) || 'sigv4'
- editBedrockRegion.value = (bedrockCreds.aws_region as string) || ''
- editBedrockForceGlobal.value = (bedrockCreds.aws_force_global as string) === 'true'
-
- if (authMode === 'apikey') {
- editBedrockApiKeyValue.value = ''
- } else {
- editBedrockAccessKeyId.value = (bedrockCreds.aws_access_key_id as string) || ''
- editBedrockSecretAccessKey.value = ''
- editBedrockSessionToken.value = ''
- }
-
- // Load pool mode for bedrock
- poolModeEnabled.value = bedrockCreds.pool_mode === true
- const retryCount = bedrockCreds.pool_mode_retry_count
- poolModeRetryCount.value = (typeof retryCount === 'number' && retryCount >= 0) ? retryCount : DEFAULT_POOL_MODE_RETRY_COUNT
-
- // Load quota limits for bedrock
- const bedrockExtra = (newAccount.extra as Record) || {}
- editQuotaLimit.value = typeof bedrockExtra.quota_limit === 'number' ? bedrockExtra.quota_limit : null
- editQuotaDailyLimit.value = typeof bedrockExtra.quota_daily_limit === 'number' ? bedrockExtra.quota_daily_limit : null
- editQuotaWeeklyLimit.value = typeof bedrockExtra.quota_weekly_limit === 'number' ? bedrockExtra.quota_weekly_limit : null
-
- // Load model mappings for bedrock
- const existingMappings = bedrockCreds.model_mapping as Record | undefined
- if (existingMappings && typeof existingMappings === 'object') {
- const entries = Object.entries(existingMappings)
- const isWhitelistMode = entries.length > 0 && entries.every(([from, to]) => from === to)
- if (isWhitelistMode) {
- modelRestrictionMode.value = 'whitelist'
- allowedModels.value = entries.map(([from]) => from)
- modelMappings.value = []
- } else {
- modelRestrictionMode.value = 'mapping'
- modelMappings.value = entries.map(([from, to]) => ({ from, to }))
- allowedModels.value = []
- }
- } else {
- modelRestrictionMode.value = 'whitelist'
- modelMappings.value = []
- allowedModels.value = []
- }
- } else if (newAccount.type === 'upstream' && newAccount.credentials) {
- const credentials = newAccount.credentials as Record
- editBaseUrl.value = (credentials.base_url as string) || ''
+ if (isWhitelistMode) {
+ // Whitelist mode: populate allowedModels
+ modelRestrictionMode.value = 'whitelist'
+ allowedModels.value = entries.map(([from]) => from)
+ modelMappings.value = []
} else {
- const platformDefaultUrl =
- newAccount.platform === 'openai' || newAccount.platform === 'sora'
- ? 'https://api.openai.com'
- : newAccount.platform === 'gemini'
- ? 'https://generativelanguage.googleapis.com'
- : 'https://api.anthropic.com'
- editBaseUrl.value = platformDefaultUrl
+ // Mapping mode: populate modelMappings
+ modelRestrictionMode.value = 'mapping'
+ modelMappings.value = entries.map(([from, to]) => ({ from, to }))
+ allowedModels.value = []
+ }
+ } else {
+ // No mappings: default to whitelist mode with empty selection (allow all)
+ modelRestrictionMode.value = 'whitelist'
+ modelMappings.value = []
+ allowedModels.value = []
+ }
- // Load model mappings for OpenAI OAuth accounts
- if (newAccount.platform === 'openai' && newAccount.credentials) {
- const oauthCredentials = newAccount.credentials as Record
- const existingMappings = oauthCredentials.model_mapping as Record | undefined
- if (existingMappings && typeof existingMappings === 'object') {
- const entries = Object.entries(existingMappings)
- const isWhitelistMode = entries.length > 0 && entries.every(([from, to]) => from === to)
- if (isWhitelistMode) {
- modelRestrictionMode.value = 'whitelist'
- allowedModels.value = entries.map(([from]) => from)
- modelMappings.value = []
- } else {
- modelRestrictionMode.value = 'mapping'
- modelMappings.value = entries.map(([from, to]) => ({ from, to }))
- allowedModels.value = []
- }
- } else {
- modelRestrictionMode.value = 'whitelist'
- modelMappings.value = []
- allowedModels.value = []
- }
- } else {
+ // Load pool mode
+ poolModeEnabled.value = credentials.pool_mode === true
+ poolModeRetryCount.value = normalizePoolModeRetryCount(
+ Number(credentials.pool_mode_retry_count ?? DEFAULT_POOL_MODE_RETRY_COUNT)
+ )
+
+ // Load custom error codes
+ customErrorCodesEnabled.value = credentials.custom_error_codes_enabled === true
+ const existingErrorCodes = credentials.custom_error_codes as number[] | undefined
+ if (existingErrorCodes && Array.isArray(existingErrorCodes)) {
+ selectedErrorCodes.value = [...existingErrorCodes]
+ } else {
+ selectedErrorCodes.value = []
+ }
+ } else if (newAccount.type === 'bedrock' && newAccount.credentials) {
+ const bedrockCreds = newAccount.credentials as Record
+ const authMode = (bedrockCreds.auth_mode as string) || 'sigv4'
+ editBedrockRegion.value = (bedrockCreds.aws_region as string) || ''
+ editBedrockForceGlobal.value = (bedrockCreds.aws_force_global as string) === 'true'
+
+ if (authMode === 'apikey') {
+ editBedrockApiKeyValue.value = ''
+ } else {
+ editBedrockAccessKeyId.value = (bedrockCreds.aws_access_key_id as string) || ''
+ editBedrockSecretAccessKey.value = ''
+ editBedrockSessionToken.value = ''
+ }
+
+ // Load pool mode for bedrock
+ poolModeEnabled.value = bedrockCreds.pool_mode === true
+ const retryCount = bedrockCreds.pool_mode_retry_count
+ poolModeRetryCount.value = (typeof retryCount === 'number' && retryCount >= 0) ? retryCount : DEFAULT_POOL_MODE_RETRY_COUNT
+
+ // Load quota limits for bedrock
+ const bedrockExtra = (newAccount.extra as Record) || {}
+ editQuotaLimit.value = typeof bedrockExtra.quota_limit === 'number' ? bedrockExtra.quota_limit : null
+ editQuotaDailyLimit.value = typeof bedrockExtra.quota_daily_limit === 'number' ? bedrockExtra.quota_daily_limit : null
+ editQuotaWeeklyLimit.value = typeof bedrockExtra.quota_weekly_limit === 'number' ? bedrockExtra.quota_weekly_limit : null
+
+ // Load model mappings for bedrock
+ const existingMappings = bedrockCreds.model_mapping as Record | undefined
+ if (existingMappings && typeof existingMappings === 'object') {
+ const entries = Object.entries(existingMappings)
+ const isWhitelistMode = entries.length > 0 && entries.every(([from, to]) => from === to)
+ if (isWhitelistMode) {
+ modelRestrictionMode.value = 'whitelist'
+ allowedModels.value = entries.map(([from]) => from)
+ modelMappings.value = []
+ } else {
+ modelRestrictionMode.value = 'mapping'
+ modelMappings.value = entries.map(([from, to]) => ({ from, to }))
+ allowedModels.value = []
+ }
+ } else {
+ modelRestrictionMode.value = 'whitelist'
+ modelMappings.value = []
+ allowedModels.value = []
+ }
+ } else if (newAccount.type === 'upstream' && newAccount.credentials) {
+ const credentials = newAccount.credentials as Record
+ editBaseUrl.value = (credentials.base_url as string) || ''
+ } else {
+ const platformDefaultUrl =
+ newAccount.platform === 'openai' || newAccount.platform === 'sora'
+ ? 'https://api.openai.com'
+ : newAccount.platform === 'gemini'
+ ? 'https://generativelanguage.googleapis.com'
+ : 'https://api.anthropic.com'
+ editBaseUrl.value = platformDefaultUrl
+
+ // Load model mappings for OpenAI OAuth accounts
+ if (newAccount.platform === 'openai' && newAccount.credentials) {
+ const oauthCredentials = newAccount.credentials as Record
+ const existingMappings = oauthCredentials.model_mapping as Record | undefined
+ if (existingMappings && typeof existingMappings === 'object') {
+ const entries = Object.entries(existingMappings)
+ const isWhitelistMode = entries.length > 0 && entries.every(([from, to]) => from === to)
+ if (isWhitelistMode) {
modelRestrictionMode.value = 'whitelist'
+ allowedModels.value = entries.map(([from]) => from)
modelMappings.value = []
+ } else {
+ modelRestrictionMode.value = 'mapping'
+ modelMappings.value = entries.map(([from, to]) => ({ from, to }))
allowedModels.value = []
}
- poolModeEnabled.value = false
- poolModeRetryCount.value = DEFAULT_POOL_MODE_RETRY_COUNT
- customErrorCodesEnabled.value = false
- selectedErrorCodes.value = []
+ } else {
+ modelRestrictionMode.value = 'whitelist'
+ modelMappings.value = []
+ allowedModels.value = []
}
- editApiKey.value = ''
+ } else {
+ modelRestrictionMode.value = 'whitelist'
+ modelMappings.value = []
+ allowedModels.value = []
+ }
+ poolModeEnabled.value = false
+ poolModeRetryCount.value = DEFAULT_POOL_MODE_RETRY_COUNT
+ customErrorCodesEnabled.value = false
+ selectedErrorCodes.value = []
+ }
+ editApiKey.value = ''
+}
+
+watch(
+ [() => props.show, () => props.account],
+ ([show, newAccount], [wasShow, previousAccount]) => {
+ if (!show || !newAccount) {
+ return
+ }
+ if (!wasShow || newAccount !== previousAccount) {
+ syncFormFromAccount(newAccount)
+ loadTLSProfiles()
}
},
{ immediate: true }
)
+const loadTLSProfiles = async () => {
+ try {
+ const profiles = await adminAPI.tlsFingerprintProfiles.list()
+ tlsFingerprintProfiles.value = profiles.map(p => ({ id: p.id, name: p.name }))
+ } catch {
+ tlsFingerprintProfiles.value = []
+ }
+}
+
// Model mapping helpers
const addModelMapping = () => {
modelMappings.value.push({ from: '', to: '' })
@@ -2448,9 +2515,12 @@ function loadQuotaControlSettings(account: Account) {
rpmStickyBuffer.value = null
userMsgQueueMode.value = ''
tlsFingerprintEnabled.value = false
+ tlsFingerprintProfileId.value = null
sessionIdMaskingEnabled.value = false
cacheTTLOverrideEnabled.value = false
cacheTTLOverrideTarget.value = '5m'
+ customBaseUrlEnabled.value = false
+ customBaseUrl.value = ''
// Only applies to Anthropic OAuth/SetupToken accounts
if (account.platform !== 'anthropic' || (account.type !== 'oauth' && account.type !== 'setup-token')) {
@@ -2485,6 +2555,7 @@ function loadQuotaControlSettings(account: Account) {
if (account.enable_tls_fingerprint === true) {
tlsFingerprintEnabled.value = true
}
+ tlsFingerprintProfileId.value = account.tls_fingerprint_profile_id ?? null
// Load session ID masking setting
if (account.session_id_masking_enabled === true) {
@@ -2496,6 +2567,12 @@ function loadQuotaControlSettings(account: Account) {
cacheTTLOverrideEnabled.value = true
cacheTTLOverrideTarget.value = account.cache_ttl_override_target || '5m'
}
+
+ // Load custom base URL setting
+ if (account.custom_base_url_enabled === true) {
+ customBaseUrlEnabled.value = true
+ customBaseUrl.value = account.custom_base_url || ''
+ }
}
function formatTempUnschedKeywords(value: unknown) {
@@ -2922,8 +2999,14 @@ const handleSubmit = async () => {
// TLS fingerprint setting
if (tlsFingerprintEnabled.value) {
newExtra.enable_tls_fingerprint = true
+ if (tlsFingerprintProfileId.value) {
+ newExtra.tls_fingerprint_profile_id = tlsFingerprintProfileId.value
+ } else {
+ delete newExtra.tls_fingerprint_profile_id
+ }
} else {
delete newExtra.enable_tls_fingerprint
+ delete newExtra.tls_fingerprint_profile_id
}
// Session ID masking setting
@@ -2942,6 +3025,15 @@ const handleSubmit = async () => {
delete newExtra.cache_ttl_override_target
}
+ // Custom base URL relay setting
+ if (customBaseUrlEnabled.value && customBaseUrl.value.trim()) {
+ newExtra.custom_base_url_enabled = true
+ newExtra.custom_base_url = customBaseUrl.value.trim()
+ } else {
+ delete newExtra.custom_base_url_enabled
+ delete newExtra.custom_base_url
+ }
+
updatePayload.extra = newExtra
}
diff --git a/frontend/src/components/account/OAuthAuthorizationFlow.vue b/frontend/src/components/account/OAuthAuthorizationFlow.vue
index cc74f8ce..b4c299db 100644
--- a/frontend/src/components/account/OAuthAuthorizationFlow.vue
+++ b/frontend/src/components/account/OAuthAuthorizationFlow.vue
@@ -48,6 +48,17 @@
t(getOAuthKey('refreshTokenAuth'))
}}
+
+
+ {{
+ t('admin.accounts.oauth.openai.mobileRefreshTokenAuth', '手动输入 Mobile RT')
+ }}
+
-
-
+
+
@@ -759,6 +770,7 @@ interface Props {
methodLabel?: string
showCookieOption?: boolean // Whether to show cookie auto-auth option
showRefreshTokenOption?: boolean // Whether to show refresh token input option (OpenAI only)
+ showMobileRefreshTokenOption?: boolean // Whether to show mobile refresh token option (OpenAI only)
showSessionTokenOption?: boolean // Whether to show session token input option (Sora only)
showAccessTokenOption?: boolean // Whether to show access token input option (Sora only)
platform?: AccountPlatform // Platform type for different UI/text
@@ -776,6 +788,7 @@ const props = withDefaults(defineProps
(), {
methodLabel: 'Authorization Method',
showCookieOption: true,
showRefreshTokenOption: false,
+ showMobileRefreshTokenOption: false,
showSessionTokenOption: false,
showAccessTokenOption: false,
platform: 'anthropic',
@@ -787,6 +800,7 @@ const emit = defineEmits<{
'exchange-code': [code: string]
'cookie-auth': [sessionKey: string]
'validate-refresh-token': [refreshToken: string]
+ 'validate-mobile-refresh-token': [refreshToken: string]
'validate-session-token': [sessionToken: string]
'import-access-token': [accessToken: string]
'update:inputMethod': [method: AuthInputMethod]
@@ -834,7 +848,7 @@ const oauthState = ref('')
const projectId = ref('')
// Computed: show method selection when either cookie or refresh token option is enabled
-const showMethodSelection = computed(() => props.showCookieOption || props.showRefreshTokenOption || props.showSessionTokenOption || props.showAccessTokenOption)
+const showMethodSelection = computed(() => props.showCookieOption || props.showRefreshTokenOption || props.showMobileRefreshTokenOption || props.showSessionTokenOption || props.showAccessTokenOption)
// Clipboard
const { copied, copyToClipboard } = useClipboard()
@@ -945,7 +959,11 @@ const handleCookieAuth = () => {
const handleValidateRefreshToken = () => {
if (refreshTokenInput.value.trim()) {
- emit('validate-refresh-token', refreshTokenInput.value.trim())
+ if (inputMethod.value === 'mobile_refresh_token') {
+ emit('validate-mobile-refresh-token', refreshTokenInput.value.trim())
+ } else {
+ emit('validate-refresh-token', refreshTokenInput.value.trim())
+ }
}
}
diff --git a/frontend/src/components/account/UsageProgressBar.vue b/frontend/src/components/account/UsageProgressBar.vue
index cd5c991f..52f0ecbb 100644
--- a/frontend/src/components/account/UsageProgressBar.vue
+++ b/frontend/src/components/account/UsageProgressBar.vue
@@ -2,7 +2,7 @@
@@ -12,12 +12,13 @@
{{ formatTokens }}
-
+
A ${{ formatAccountCost }}
U ${{ formatUserCost }}
@@ -47,7 +48,7 @@
-
+
{{ formatResetTime }}
@@ -55,8 +56,11 @@
diff --git a/frontend/src/components/admin/account/AccountActionMenu.vue b/frontend/src/components/admin/account/AccountActionMenu.vue
index f5bc5aa0..06bd23ab 100644
--- a/frontend/src/components/admin/account/AccountActionMenu.vue
+++ b/frontend/src/components/admin/account/AccountActionMenu.vue
@@ -32,6 +32,10 @@
{{ t('admin.accounts.refreshToken') }}
+
+
+ {{ t('admin.accounts.setPrivacy') }}
+
@@ -55,7 +59,7 @@ import { Icon } from '@/components/icons'
import type { Account } from '@/types'
const props = defineProps<{ show: boolean; account: Account | null; position: { top: number; left: number } | null }>()
-const emit = defineEmits(['close', 'test', 'stats', 'schedule', 'reauth', 'refresh-token', 'recover-state', 'reset-quota'])
+const emit = defineEmits(['close', 'test', 'stats', 'schedule', 'reauth', 'refresh-token', 'recover-state', 'reset-quota', 'set-privacy'])
const { t } = useI18n()
const isRateLimited = computed(() => {
if (props.account?.rate_limit_reset_at && new Date(props.account.rate_limit_reset_at) > new Date()) {
@@ -75,6 +79,9 @@ const isTempUnschedulable = computed(() => props.account?.temp_unschedulable_unt
const hasRecoverableState = computed(() => {
return props.account?.status === 'error' || Boolean(isRateLimited.value) || Boolean(isOverloaded.value) || Boolean(isTempUnschedulable.value)
})
+const isAntigravityOAuth = computed(() => props.account?.platform === 'antigravity' && props.account?.type === 'oauth')
+const isOpenAIOAuth = computed(() => props.account?.platform === 'openai' && props.account?.type === 'oauth')
+const supportsPrivacy = computed(() => isAntigravityOAuth.value || isOpenAIOAuth.value)
const hasQuotaLimit = computed(() => {
return (props.account?.type === 'apikey' || props.account?.type === 'bedrock') && (
(props.account?.quota_limit ?? 0) > 0 ||
diff --git a/frontend/src/components/admin/account/AccountTableFilters.vue b/frontend/src/components/admin/account/AccountTableFilters.vue
index d8068336..43e703ec 100644
--- a/frontend/src/components/admin/account/AccountTableFilters.vue
+++ b/frontend/src/components/admin/account/AccountTableFilters.vue
@@ -10,6 +10,7 @@
+
@@ -22,9 +23,21 @@ const emit = defineEmits(['update:searchQuery', 'update:filters', 'change']); co
const updatePlatform = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, platform: value }) }
const updateType = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, type: value }) }
const updateStatus = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, status: value }) }
+const updatePrivacyMode = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, privacy_mode: value }) }
const updateGroup = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, group: value }) }
const pOpts = computed(() => [{ value: '', label: t('admin.accounts.allPlatforms') }, { value: 'anthropic', label: 'Anthropic' }, { value: 'openai', label: 'OpenAI' }, { value: 'gemini', label: 'Gemini' }, { value: 'antigravity', label: 'Antigravity' }, { value: 'sora', label: 'Sora' }])
const tOpts = computed(() => [{ value: '', label: t('admin.accounts.allTypes') }, { value: 'oauth', label: t('admin.accounts.oauthType') }, { value: 'setup-token', label: t('admin.accounts.setupToken') }, { value: 'apikey', label: t('admin.accounts.apiKey') }, { value: 'bedrock', label: 'AWS Bedrock' }])
const sOpts = computed(() => [{ value: '', label: t('admin.accounts.allStatus') }, { value: 'active', label: t('admin.accounts.status.active') }, { value: 'inactive', label: t('admin.accounts.status.inactive') }, { value: 'error', label: t('admin.accounts.status.error') }, { value: 'rate_limited', label: t('admin.accounts.status.rateLimited') }, { value: 'temp_unschedulable', label: t('admin.accounts.status.tempUnschedulable') }])
-const gOpts = computed(() => [{ value: '', label: t('admin.accounts.allGroups') }, ...(props.groups || []).map(g => ({ value: String(g.id), label: g.name }))])
+const privacyOpts = computed(() => [
+ { value: '', label: t('admin.accounts.allPrivacyModes') },
+ { value: '__unset__', label: t('admin.accounts.privacyUnset') },
+ { value: 'training_off', label: 'Privacy' },
+ { value: 'training_set_cf_blocked', label: 'CF' },
+ { value: 'training_set_failed', label: 'Fail' }
+])
+const gOpts = computed(() => [
+ { value: '', label: t('admin.accounts.allGroups') },
+ { value: 'ungrouped', label: t('admin.accounts.ungroupedGroup') },
+ ...(props.groups || []).map(g => ({ value: String(g.id), label: g.name }))
+])
diff --git a/frontend/src/components/admin/account/__tests__/AccountTableFilters.spec.ts b/frontend/src/components/admin/account/__tests__/AccountTableFilters.spec.ts
new file mode 100644
index 00000000..5a0044e5
--- /dev/null
+++ b/frontend/src/components/admin/account/__tests__/AccountTableFilters.spec.ts
@@ -0,0 +1,56 @@
+import { describe, expect, it, vi } from 'vitest'
+import { mount } from '@vue/test-utils'
+
+import AccountTableFilters from '../AccountTableFilters.vue'
+
+vi.mock('vue-i18n', async () => {
+ const actual = await vi.importActual
('vue-i18n')
+ return {
+ ...actual,
+ useI18n: () => ({
+ t: (key: string) => key
+ })
+ }
+})
+
+describe('AccountTableFilters', () => {
+ it('renders privacy mode options and emits privacy_mode updates', async () => {
+ const wrapper = mount(AccountTableFilters, {
+ props: {
+ searchQuery: '',
+ filters: {
+ platform: '',
+ type: '',
+ status: '',
+ group: '',
+ privacy_mode: ''
+ },
+ groups: []
+ },
+ global: {
+ stubs: {
+ SearchInput: {
+ template: '
'
+ },
+ Select: {
+ props: ['modelValue', 'options'],
+ emits: ['update:modelValue', 'change'],
+ template: '
'
+ }
+ }
+ }
+ })
+
+ const selects = wrapper.findAll('.select-stub')
+ expect(selects).toHaveLength(5)
+
+ const privacyOptions = JSON.parse(selects[3].attributes('data-options'))
+ expect(privacyOptions).toEqual([
+ { value: '', label: 'admin.accounts.allPrivacyModes' },
+ { value: '__unset__', label: 'admin.accounts.privacyUnset' },
+ { value: 'training_off', label: 'Privacy' },
+ { value: 'training_set_cf_blocked', label: 'CF' },
+ { value: 'training_set_failed', label: 'Fail' }
+ ])
+ })
+})
diff --git a/frontend/src/components/admin/announcements/AnnouncementReadStatusDialog.vue b/frontend/src/components/admin/announcements/AnnouncementReadStatusDialog.vue
index e7d991a8..a0d9de3c 100644
--- a/frontend/src/components/admin/announcements/AnnouncementReadStatusDialog.vue
+++ b/frontend/src/components/admin/announcements/AnnouncementReadStatusDialog.vue
@@ -69,6 +69,7 @@ import { adminAPI } from '@/api/admin'
import { formatDateTime } from '@/utils/format'
import type { AnnouncementUserReadStatus } from '@/types'
import type { Column } from '@/components/common/types'
+import { getPersistedPageSize } from '@/composables/usePersistedPageSize'
import BaseDialog from '@/components/common/BaseDialog.vue'
import DataTable from '@/components/common/DataTable.vue'
@@ -92,7 +93,7 @@ const search = ref('')
const pagination = reactive({
page: 1,
- page_size: 20,
+ page_size: getPersistedPageSize(),
total: 0,
pages: 0
})
diff --git a/frontend/src/components/admin/usage/UsageFilters.vue b/frontend/src/components/admin/usage/UsageFilters.vue
index a632a76e..ee5020e7 100644
--- a/frontend/src/components/admin/usage/UsageFilters.vue
+++ b/frontend/src/components/admin/usage/UsageFilters.vue
@@ -139,17 +139,6 @@
-
-
- {{ t('usage.timeRange') }}
-
-
@@ -177,7 +166,6 @@ import { ref, onMounted, onUnmounted, toRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { adminAPI } from '@/api/admin'
import Select, { type SelectOption } from '@/components/common/Select.vue'
-import DateRangePicker from '@/components/common/DateRangePicker.vue'
import type { SimpleApiKey, SimpleUser } from '@/api/admin/usage'
type ModelValue = Record
@@ -195,8 +183,6 @@ const props = withDefaults(defineProps(), {
})
const emit = defineEmits([
'update:modelValue',
- 'update:startDate',
- 'update:endDate',
'change',
'refresh',
'reset',
@@ -248,16 +234,6 @@ const billingTypeOptions = ref([
const emitChange = () => emit('change')
-const updateStartDate = (value: string) => {
- emit('update:startDate', value)
- filters.value.start_date = value
-}
-
-const updateEndDate = (value: string) => {
- emit('update:endDate', value)
- filters.value.end_date = value
-}
-
const debounceUserSearch = () => {
if (userSearchTimeout) clearTimeout(userSearchTimeout)
userSearchTimeout = setTimeout(async () => {
@@ -441,7 +417,11 @@ onMounted(async () => {
groupOptions.value.push(...gs.items.map((g: any) => ({ value: g.id, label: g.name })))
const uniqueModels = new Set()
- ms.models?.forEach((s: any) => s.model && uniqueModels.add(s.model))
+ ms.models?.forEach((s: any) => {
+ if (s.model) {
+ uniqueModels.add(s.model)
+ }
+ })
modelOptions.value.push(
...Array.from(uniqueModels)
.sort()
diff --git a/frontend/src/components/admin/usage/UsageTable.vue b/frontend/src/components/admin/usage/UsageTable.vue
index aa6c2bbd..4a42ab05 100644
--- a/frontend/src/components/admin/usage/UsageTable.vue
+++ b/frontend/src/components/admin/usage/UsageTable.vue
@@ -25,8 +25,16 @@
{{ row.account?.name || '-' }}
-
- {{ value }}
+
+
+
+ {{ row.model }}
+
+
+ ↳ {{ row.upstream_model }}
+
+
+ {{ row.model }}
diff --git a/frontend/src/components/admin/usage/__tests__/UsageTable.spec.ts b/frontend/src/components/admin/usage/__tests__/UsageTable.spec.ts
index e38bb4f7..9309c88b 100644
--- a/frontend/src/components/admin/usage/__tests__/UsageTable.spec.ts
+++ b/frontend/src/components/admin/usage/__tests__/UsageTable.spec.ts
@@ -39,6 +39,7 @@ const DataTableStub = {
template: `
@@ -108,4 +109,42 @@ describe('admin UsageTable tooltip', () => {
expect(text).toContain('$30.0000 / 1M tokens')
expect(text).toContain('$0.069568')
})
+
+ it('shows requested and upstream models separately for admin rows', () => {
+ const row = {
+ request_id: 'req-admin-model-1',
+ model: 'claude-sonnet-4',
+ upstream_model: 'claude-sonnet-4-20250514',
+ actual_cost: 0,
+ total_cost: 0,
+ account_rate_multiplier: 1,
+ rate_multiplier: 1,
+ input_cost: 0,
+ output_cost: 0,
+ cache_creation_cost: 0,
+ cache_read_cost: 0,
+ input_tokens: 0,
+ output_tokens: 0,
+ }
+
+ const wrapper = mount(UsageTable, {
+ props: {
+ data: [row],
+ loading: false,
+ columns: [],
+ },
+ global: {
+ stubs: {
+ DataTable: DataTableStub,
+ EmptyState: true,
+ Icon: true,
+ Teleport: true,
+ },
+ },
+ })
+
+ const text = wrapper.text()
+ expect(text).toContain('claude-sonnet-4')
+ expect(text).toContain('claude-sonnet-4-20250514')
+ })
})
diff --git a/frontend/src/components/admin/user/GroupReplaceModal.vue b/frontend/src/components/admin/user/GroupReplaceModal.vue
new file mode 100644
index 00000000..429826ba
--- /dev/null
+++ b/frontend/src/components/admin/user/GroupReplaceModal.vue
@@ -0,0 +1,131 @@
+
+
+
+
+
+ {{ t('admin.users.replaceGroupHint', { old: oldGroup.name }) }}
+
+
+
+
+
+
+ {{ oldGroup.name }}
+
+
+ {{ availableGroups.find(g => g.id === selectedGroupId)?.name }}
+
+ ?
+
+
+
+
+
+
+
+
+
+ {{ group.name }}
+ {{ group.platform }}
+
+
+
+
+
+
+ {{ t('admin.users.noOtherGroups') }}
+
+
+
+
+
+
{{ t('common.cancel') }}
+
+
+
+
+
+ {{ submitting ? t('common.saving') : t('admin.users.replaceGroupConfirm') }}
+
+
+
+
+
+
+
diff --git a/frontend/src/components/charts/EndpointDistributionChart.vue b/frontend/src/components/charts/EndpointDistributionChart.vue
index c0a21b4a..5e3fc23b 100644
--- a/frontend/src/components/charts/EndpointDistributionChart.vue
+++ b/frontend/src/components/charts/EndpointDistributionChart.vue
@@ -1,10 +1,10 @@
-
+
{{ title || t('usage.endpointDistribution') }}
-
+
-
-
- {{ item.endpoint }}
-
-
- {{ formatNumber(item.requests) }}
-
-
- {{ formatTokens(item.total_tokens) }}
-
-
- ${{ formatCost(item.actual_cost) }}
-
-
- ${{ formatCost(item.cost) }}
-
-
+
+
+
+
+
+
+ {{ item.endpoint }}
+
+
+
+ {{ formatNumber(item.requests) }}
+
+
+ {{ formatTokens(item.total_tokens) }}
+
+
+ ${{ formatCost(item.actual_cost) }}
+
+
+ ${{ formatCost(item.cost) }}
+
+
+
+
+
+
+
+
@@ -119,12 +132,14 @@
diff --git a/frontend/src/components/common/DataTable.vue b/frontend/src/components/common/DataTable.vue
index 16aea107..159fbd84 100644
--- a/frontend/src/components/common/DataTable.vue
+++ b/frontend/src/components/common/DataTable.vue
@@ -79,7 +79,8 @@
'sticky-header-cell py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-dark-400',
getAdaptivePaddingClass(),
{ 'cursor-pointer hover:bg-gray-100 dark:hover:bg-dark-700': column.sortable },
- getStickyColumnClass(column, index)
+ getStickyColumnClass(column, index),
+ column.class
]"
@click="column.sortable && handleSort(column.key)"
>
@@ -147,28 +148,47 @@
-
-
-
+
+
+
+
+
+
-
- {{ column.formatter ? column.formatter(row[column.key], row) : row[column.key] }}
-
-
-
+
+
+ {{ column.formatter
+ ? column.formatter(sortedData[virtualRow.index][column.key], sortedData[virtualRow.index])
+ : sortedData[virtualRow.index][column.key] }}
+
+
+
+
+
+
+
+
@@ -176,6 +196,7 @@