mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-31 00:24:38 +00:00
feat: add Grok media eligibility controls
This commit is contained in:
@@ -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()
|
||||
|
||||
+110
@@ -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 用量阈值(%)',
|
||||
|
||||
@@ -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[]
|
||||
|
||||
Reference in New Issue
Block a user