feat: add Grok media eligibility controls

This commit is contained in:
aofee
2026-09-02 11:50:33 +08:00
parent aa23648835
commit 01bd9b71a2
9 changed files with 624 additions and 3 deletions
+23 -1
View File
@@ -25,7 +25,9 @@ import type {
UpstreamBillingProbeSettings,
UpstreamBillingRatesResponse,
OllamaCloudUsageSettings,
OllamaCloudUsageState
OllamaCloudUsageState,
GrokMediaEligibilityMode,
GrokMediaEligibilityState
} from '@/types'
/**
@@ -237,6 +239,24 @@ export async function update(id: number, updates: UpdateAccountRequest): Promise
return data
}
export async function getGrokMediaEligibility(id: number): Promise<GrokMediaEligibilityState> {
const { data } = await apiClient.get<GrokMediaEligibilityState>(
`/admin/accounts/${id}/grok-media-eligibility`
)
return data
}
export async function updateGrokMediaEligibility(
id: number,
mode: GrokMediaEligibilityMode
): Promise<GrokMediaEligibilityState> {
const { data } = await apiClient.put<GrokMediaEligibilityState>(
`/admin/accounts/${id}/grok-media-eligibility`,
{ mode }
)
return data
}
/**
* Check mixed-channel risk for account-group binding.
*/
@@ -1053,6 +1073,8 @@ export const accountsAPI = {
create,
duplicate,
update,
getGrokMediaEligibility,
updateGrokMediaEligibility,
checkMixedChannelRisk,
delete: deleteAccount,
toggleStatus,
@@ -542,6 +542,57 @@
</div>
</div>
<!-- Grok OAuth media generation eligibility override -->
<div
v-if="isGrokOAuthAccount"
class="border-t border-gray-200 pt-4 dark:border-dark-600"
data-testid="grok-media-eligibility-card"
>
<div class="space-y-3">
<div>
<label class="input-label mb-0">{{ t('admin.accounts.grokMediaEligibility.title') }}</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.grokMediaEligibility.hint') }}
</p>
</div>
<select
v-model="grokMediaEligibilityMode"
class="input"
data-testid="grok-media-eligibility-mode"
:disabled="grokMediaEligibilityLoading"
>
<option value="auto">{{ t('admin.accounts.grokMediaEligibility.auto') }}</option>
<option value="enabled">{{ t('admin.accounts.grokMediaEligibility.enabled') }}</option>
<option value="disabled">{{ t('admin.accounts.grokMediaEligibility.disabled') }}</option>
</select>
<p v-if="grokMediaEligibilityLoading" class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.grokMediaEligibility.loading') }}
</p>
<p v-else-if="grokMediaEligibilityError" class="text-xs text-red-600 dark:text-red-400">
{{ grokMediaEligibilityError }}
</p>
<div v-else-if="grokMediaEligibilityState" class="rounded-lg bg-gray-50 p-3 text-xs dark:bg-dark-700">
<span class="font-medium">{{ t('admin.accounts.grokMediaEligibility.current') }}</span>
<span class="ml-1" data-testid="grok-media-eligibility-status">
{{ grokMediaEligibilityState.eligible ? t('admin.accounts.grokMediaEligibility.eligible') : t('admin.accounts.grokMediaEligibility.ineligible') }}
· {{ t(`admin.accounts.grokMediaEligibility.reasons.${grokMediaEligibilityState.reason}`) }}
</span>
</div>
<div
v-if="grokMediaEligibilityMode === 'enabled'"
class="rounded-lg bg-amber-50 p-3 dark:bg-amber-900/20"
>
<p class="text-xs text-amber-700 dark:text-amber-400">
<Icon name="exclamationTriangle" size="sm" class="mr-1 inline" :stroke-width="2" />
{{ t('admin.accounts.grokMediaEligibility.forceEnableWarning') }}
</p>
</div>
<p v-else-if="grokMediaEligibilityMode === 'auto'" class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.grokMediaEligibility.autoHint') }}
</p>
</div>
</div>
<!-- Grok OAuth Custom Upstream URL (仅改写转发端点,OAuth 授权/刷新不受影响) -->
<div
v-if="account.platform === 'grok' && account.type === 'oauth'"
@@ -2881,7 +2932,9 @@ import type {
OpenAICompactMode,
OpenAIResponsesMode,
OpenAIEndpointCapability,
OllamaCloudUsageState
OllamaCloudUsageState,
GrokMediaEligibilityMode,
GrokMediaEligibilityState
} from '@/types'
import BaseDialog from '@/components/common/BaseDialog.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
@@ -3194,6 +3247,46 @@ const grokOAuthBaseUrl = ref('')
// Grok Free OAuth accounts use client-tool prompt caching by default. Keep an
// explicit false in the account extra as the opt-out signal.
const grokClientToolCacheEnabled = ref(true)
const isGrokOAuthAccount = computed(
() => props.account?.platform === 'grok' && props.account?.type === 'oauth'
)
const grokMediaEligibilityMode = ref<GrokMediaEligibilityMode>('auto')
const grokMediaEligibilityInitialMode = ref<GrokMediaEligibilityMode>('auto')
const grokMediaEligibilityState = ref<GrokMediaEligibilityState | null>(null)
const grokMediaEligibilityLoading = ref(false)
const grokMediaEligibilityError = ref('')
let grokMediaEligibilityRequestVersion = 0
const modeFromGrokMediaExtra = (extra: Record<string, unknown> | undefined): GrokMediaEligibilityMode => {
if (extra?.grok_media_eligible === true) return 'enabled'
if (extra?.grok_media_eligible === false) return 'disabled'
return 'auto'
}
const loadGrokMediaEligibility = async (accountID: number): Promise<GrokMediaEligibilityState | null> => {
if (!isGrokOAuthAccount.value || typeof adminAPI.accounts.getGrokMediaEligibility !== 'function') {
return null
}
const requestVersion = ++grokMediaEligibilityRequestVersion
grokMediaEligibilityLoading.value = true
grokMediaEligibilityError.value = ''
try {
const state = await adminAPI.accounts.getGrokMediaEligibility(accountID)
if (requestVersion !== grokMediaEligibilityRequestVersion) return null
grokMediaEligibilityState.value = state
grokMediaEligibilityMode.value = state.mode
grokMediaEligibilityInitialMode.value = state.mode
return state
} catch (error: any) {
if (requestVersion !== grokMediaEligibilityRequestVersion) return null
grokMediaEligibilityError.value = error?.message || t('admin.accounts.grokMediaEligibility.loadFailed')
return null
} finally {
if (requestVersion === grokMediaEligibilityRequestVersion) {
grokMediaEligibilityLoading.value = false
}
}
}
const interceptWarmupRequests = ref(false)
const autoPauseOnExpired = ref(false)
@@ -3925,6 +4018,15 @@ const syncFormFromAccount = (newAccount: Account | null) => {
newAccount.platform === 'grok' &&
newAccount.type === 'oauth' &&
(grokClientToolCacheSetting === undefined || grokClientToolCacheSetting === true)
grokMediaEligibilityMode.value = modeFromGrokMediaExtra(extra)
grokMediaEligibilityInitialMode.value = grokMediaEligibilityMode.value
grokMediaEligibilityState.value = null
grokMediaEligibilityError.value = ''
if (newAccount.platform === 'grok' && newAccount.type === 'oauth') {
void loadGrokMediaEligibility(newAccount.id)
} else {
grokMediaEligibilityRequestVersion++
}
if (newAccount.platform === 'grok' && newAccount.type === 'oauth' && newAccount.credentials) {
const grokCreds = newAccount.credentials as Record<string, unknown>
if (isCustomGrokBaseUrl(grokCreds.base_url)) {
@@ -4606,10 +4708,48 @@ const handleClose = () => {
emit('close')
}
const persistGrokMediaEligibility = async (accountID: number, updatedAccount: Account): Promise<Account> => {
if (
!isGrokOAuthAccount.value ||
grokMediaEligibilityMode.value === grokMediaEligibilityInitialMode.value ||
typeof adminAPI.accounts.updateGrokMediaEligibility !== 'function'
) {
return updatedAccount
}
try {
const state = await adminAPI.accounts.updateGrokMediaEligibility(accountID, grokMediaEligibilityMode.value)
grokMediaEligibilityState.value = state
grokMediaEligibilityInitialMode.value = state.mode
const nextExtra = { ...((updatedAccount.extra as Record<string, unknown> | undefined) || {}) }
if (state.mode === 'auto') {
delete nextExtra.grok_media_eligible
} else {
nextExtra.grok_media_eligible = state.mode === 'enabled'
}
updatedAccount.extra = nextExtra
} catch (error: any) {
appStore.showError(t('admin.accounts.grokMediaEligibility.partialSave'))
try {
const state = await loadGrokMediaEligibility(accountID)
if (state) {
const nextExtra = { ...((updatedAccount.extra as Record<string, unknown> | undefined) || {}) }
if (state.mode === 'auto') delete nextExtra.grok_media_eligible
else nextExtra.grok_media_eligible = state.mode === 'enabled'
updatedAccount.extra = nextExtra
}
} catch {
// The original save result remains useful even when the refresh fails.
}
}
return updatedAccount
}
const submitUpdateAccount = async (accountID: number, updatePayload: Record<string, unknown>) => {
submitting.value = true
try {
const updatedAccount = await adminAPI.accounts.update(accountID, withAntigravityConfirmFlag(updatePayload))
let updatedAccount = await adminAPI.accounts.update(accountID, withAntigravityConfirmFlag(updatePayload))
updatedAccount = await persistGrokMediaEligibility(accountID, updatedAccount)
appStore.showSuccess(t('admin.accounts.accountUpdated'))
emit('updated', updatedAccount)
handleClose()
@@ -0,0 +1,110 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent } from 'vue'
import { mount } from '@vue/test-utils'
const {
updateAccountMock,
getEligibilityMock,
updateEligibilityMock,
showErrorMock,
authIsSimpleMode
} = vi.hoisted(() => ({
updateAccountMock: vi.fn(),
getEligibilityMock: vi.fn(),
updateEligibilityMock: vi.fn(),
showErrorMock: vi.fn(),
authIsSimpleMode: { value: true }
}))
vi.mock('@/stores/app', () => ({
useAppStore: () => ({ showError: showErrorMock, showSuccess: vi.fn(), showInfo: vi.fn() })
}))
vi.mock('@/stores/auth', () => ({ useAuthStore: () => ({ get isSimpleMode() { return authIsSimpleMode.value } }) }))
vi.mock('@/api/admin', () => ({
adminAPI: {
accounts: {
update: updateAccountMock,
getGrokMediaEligibility: getEligibilityMock,
updateGrokMediaEligibility: updateEligibilityMock,
checkMixedChannelRisk: vi.fn().mockResolvedValue({ has_risk: false })
},
settings: {
getWebSearchEmulationConfig: vi.fn().mockResolvedValue({ enabled: false, providers: [] }),
getSettings: vi.fn().mockResolvedValue({})
},
tlsFingerprintProfiles: { list: vi.fn().mockResolvedValue([]) }
}
}))
vi.mock('@/api/admin/accounts', () => ({ getAntigravityDefaultModelMapping: vi.fn() }))
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
return { ...actual, useI18n: () => ({ t: (key: string) => key }) }
})
import EditAccountModal from '../EditAccountModal.vue'
const BaseDialogStub = defineComponent({
props: { show: { type: Boolean, default: false } },
template: '<div v-if="show"><slot /><slot name="footer" /></div>'
})
const account = (platform = 'grok', type = 'oauth', extra: Record<string, unknown> = {}) => ({
id: 12, name: 'Grok', notes: '', platform, type,
credentials: { expires_at: '2027-01-01T00:00:00Z', token_type: 'Bearer' },
credentials_status: { has_access_token: true, has_refresh_token: true }, extra,
proxy_id: null, concurrency: 1, priority: 1, rate_multiplier: 1, status: 'active',
group_ids: [], expires_at: null, auto_pause_on_expired: false
})
function mountModal(value = account()) {
return mount(EditAccountModal, {
props: { show: true, account: value, proxies: [], groups: [] },
global: { stubs: {
BaseDialog: BaseDialogStub, Select: true, Icon: true, ProxySelector: true,
GroupSelector: true, ModelWhitelistSelector: true
} }
})
}
describe('EditAccountModal Grok media eligibility', () => {
beforeEach(() => {
authIsSimpleMode.value = true
updateAccountMock.mockReset()
getEligibilityMock.mockReset()
updateEligibilityMock.mockReset()
showErrorMock.mockReset()
updateAccountMock.mockResolvedValue(account())
getEligibilityMock.mockResolvedValue({ account_id: 12, mode: 'auto', eligible: false, reason: 'billing_inconclusive' })
updateEligibilityMock.mockImplementation(async (_id: number, mode: string) => ({
account_id: 12, mode, eligible: mode === 'enabled', reason: mode === 'enabled' ? 'override_enabled' : 'override_disabled'
}))
})
it('shows only for Grok OAuth and fills the current decision', async () => {
const wrapper = mountModal()
await vi.waitFor(() => expect(getEligibilityMock).toHaveBeenCalledWith(12))
expect(wrapper.find('[data-testid="grok-media-eligibility-card"]').exists()).toBe(true)
expect(wrapper.get('[data-testid="grok-media-eligibility-mode"]').element.value).toBe('auto')
expect(wrapper.get('[data-testid="grok-media-eligibility-status"]').text()).toContain('billing_inconclusive')
expect(mountModal(account('grok', 'apikey')).find('[data-testid="grok-media-eligibility-card"]').exists()).toBe(false)
expect(mountModal(account('openai', 'oauth')).find('[data-testid="grok-media-eligibility-card"]').exists()).toBe(false)
})
it('updates the dedicated endpoint only when the mode changes', async () => {
const wrapper = mountModal()
await vi.waitFor(() => expect(getEligibilityMock).toHaveBeenCalled())
await wrapper.get('[data-testid="grok-media-eligibility-mode"]').setValue('enabled')
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
await vi.waitFor(() => expect(updateEligibilityMock).toHaveBeenCalledWith(12, 'enabled'))
})
it('reports partial-save errors when the dedicated endpoint fails', async () => {
updateEligibilityMock.mockRejectedValueOnce(new Error('eligibility failed'))
const wrapper = mountModal()
await vi.waitFor(() => expect(getEligibilityMock).toHaveBeenCalled())
await wrapper.get('[data-testid="grok-media-eligibility-mode"]').setValue('disabled')
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
await vi.waitFor(() => expect(showErrorMock).toHaveBeenCalledWith('admin.accounts.grokMediaEligibility.partialSave'))
expect(getEligibilityMock).toHaveBeenCalledTimes(2)
})
})
@@ -830,6 +830,30 @@ export default {
title: 'Client Tool Cache (May Change Automatic Tool Selection)',
hint: 'For detected Grok Free OAuth accounts, this is enabled by default for client function tools such as Codex and Trae. Turn it off to opt out if the automatic tool-selection behavior is not acceptable.'
},
grokMediaEligibility: {
title: 'Media Generation Eligibility',
hint: 'Controls whether this Grok OAuth account may be selected for image and video generation.',
auto: 'Automatic detection',
enabled: 'Force enable',
disabled: 'Force disable',
current: 'Current decision:',
eligible: 'Eligible',
ineligible: 'Not eligible',
loading: 'Loading eligibility…',
loadFailed: 'Unable to load media eligibility',
autoHint: 'Automatic detection only clears the manual override; it does not trigger a media request.',
forceEnableWarning: 'Force enable bypasses automatic eligibility checks. Use only for accounts confirmed to support image/video generation.',
partialSave: 'Other account settings may have been saved, but media eligibility was not updated. Please retry.',
reasons: {
eligible: 'Paid entitlement confirmed',
billing_inconclusive: 'Billing information inconclusive',
billing_forbidden: 'Billing endpoint forbidden',
billing_free_tier: 'Free tier account',
billing_unobserved: 'Billing not observed yet',
override_enabled: 'Manually forced enabled',
override_disabled: 'Manually forced disabled'
}
},
autoPauseOnExpired: 'Auto Pause On Expired',
autoPauseOnExpiredDesc: 'When enabled, the account will auto pause scheduling after it expires',
autoPause5hThreshold: '5h Usage Threshold (%)',
@@ -901,6 +901,30 @@ export default {
title: '客户端工具缓存(可能改变自动工具选择)',
hint: '仅对已识别为 Free 的 Grok OAuth 账号生效,默认会为 Codex、Trae 等客户端函数工具请求启用上游提示缓存;如不接受自动工具选择行为,可关闭此开关退出。'
},
grokMediaEligibility: {
title: '媒体生成资格',
hint: '控制该 Grok OAuth 账号是否可被图片和视频生成请求选中。',
auto: '自动判断',
enabled: '强制启用',
disabled: '强制禁用',
current: '当前判定:',
eligible: '可用',
ineligible: '不可用',
loading: '正在读取媒体资格…',
loadFailed: '无法读取媒体资格',
autoHint: '自动判断只会清除手工覆盖,不会主动触发媒体请求。',
forceEnableWarning: '强制启用会绕过自动资格检查,仅应对已确认支持生图/生视频的账号使用。',
partialSave: '账号其他配置可能已保存,但媒体资格未更新,请重试。',
reasons: {
eligible: '已确认付费资格',
billing_inconclusive: 'Billing 信息不明确',
billing_forbidden: 'Billing 接口拒绝访问',
billing_free_tier: 'Free 账号',
billing_unobserved: '尚未探测到 Billing',
override_enabled: '手工强制启用',
override_disabled: '手工强制禁用'
}
},
autoPauseOnExpired: '过期自动暂停调度',
autoPauseOnExpiredDesc: '启用后,账号过期将自动暂停调度',
autoPause5hThreshold: '5h 用量阈值(%)',
+9
View File
@@ -1489,6 +1489,15 @@ export interface UpdateAccountRequest {
confirm_mixed_channel_risk?: boolean
}
export type GrokMediaEligibilityMode = 'auto' | 'enabled' | 'disabled'
export interface GrokMediaEligibilityState {
account_id: number
mode: GrokMediaEligibilityMode
eligible: boolean
reason: string
}
export interface CheckMixedChannelRequest {
platform: AccountPlatform
group_ids: number[]