From 01bd9b71a26fbb28ef11840b3a7f1a3e3589ef1c Mon Sep 17 00:00:00 2001 From: aofee <44353308+aofee@users.noreply.github.com> Date: Wed, 2 Sep 2026 11:43:15 +0800 Subject: [PATCH] feat: add Grok media eligibility controls --- .../admin/grok_media_eligibility_handler.go | 144 +++++++++++++++++ .../grok_media_eligibility_handler_test.go | 146 ++++++++++++++++++ backend/internal/server/routes/admin.go | 2 + frontend/src/api/admin/accounts.ts | 24 ++- .../components/account/EditAccountModal.vue | 144 ++++++++++++++++- ...tAccountModal.grokMediaEligibility.spec.ts | 110 +++++++++++++ .../src/i18n/locales/en/admin/accounts.ts | 24 +++ .../src/i18n/locales/zh/admin/accounts.ts | 24 +++ frontend/src/types/index.ts | 9 ++ 9 files changed, 624 insertions(+), 3 deletions(-) create mode 100644 backend/internal/handler/admin/grok_media_eligibility_handler.go create mode 100644 backend/internal/handler/admin/grok_media_eligibility_handler_test.go create mode 100644 frontend/src/components/account/__tests__/EditAccountModal.grokMediaEligibility.spec.ts diff --git a/backend/internal/handler/admin/grok_media_eligibility_handler.go b/backend/internal/handler/admin/grok_media_eligibility_handler.go new file mode 100644 index 0000000000..60ddbe3944 --- /dev/null +++ b/backend/internal/handler/admin/grok_media_eligibility_handler.go @@ -0,0 +1,144 @@ +package admin + +import ( + "strconv" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/Wei-Shaw/sub2api/internal/pkg/response" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" +) + +const ( + grokMediaEligibilityModeAuto = "auto" + grokMediaEligibilityModeEnabled = "enabled" + grokMediaEligibilityModeDisabled = "disabled" +) + +type grokMediaEligibilityResponse struct { + AccountID int64 `json:"account_id"` + Mode string `json:"mode"` + Eligible bool `json:"eligible"` + Reason string `json:"reason"` +} + +type updateGrokMediaEligibilityRequest struct { + Mode string `json:"mode" binding:"required"` +} + +// GetGrokMediaEligibility returns the current manual override and evaluated +// media-routing decision for a Grok OAuth account. +// GET /api/v1/admin/accounts/:id/grok-media-eligibility +func (h *AccountHandler) GetGrokMediaEligibility(c *gin.Context) { + account, err := h.getGrokOAuthAccount(c) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, buildGrokMediaEligibilityResponse(account)) +} + +// UpdateGrokMediaEligibility updates only the manual media eligibility +// override. It deliberately uses UpdateAccountExtra so unrelated account +// extra fields are preserved. +// PUT /api/v1/admin/accounts/:id/grok-media-eligibility +func (h *AccountHandler) UpdateGrokMediaEligibility(c *gin.Context) { + account, err := h.getGrokOAuthAccount(c) + if err != nil { + response.ErrorFrom(c, err) + return + } + + var req updateGrokMediaEligibilityRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + + var value any + switch req.Mode { + case grokMediaEligibilityModeAuto: + value = nil + case grokMediaEligibilityModeEnabled: + value = true + case grokMediaEligibilityModeDisabled: + value = false + default: + response.ErrorFrom(c, infraerrors.BadRequest( + "GROK_MEDIA_ELIGIBILITY_INVALID_MODE", + "mode must be one of auto, enabled, disabled", + )) + return + } + + updates := map[string]any{service.GrokMediaEligibleExtraKey: value} + if err := service.ValidateGrokMediaEligibilityExtra(account.Platform, updates); err != nil { + response.ErrorFrom(c, err) + return + } + if err := h.adminService.UpdateAccountExtra(c.Request.Context(), account.ID, updates); err != nil { + response.ErrorFrom(c, err) + return + } + + updated, err := h.adminService.GetAccount(c.Request.Context(), account.ID) + if err != nil { + response.ErrorFrom(c, err) + return + } + if err := ensureGrokOAuthAccount(updated); err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, buildGrokMediaEligibilityResponse(updated)) +} + +func (h *AccountHandler) getGrokOAuthAccount(c *gin.Context) (*service.Account, error) { + accountID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + return nil, infraerrors.BadRequest("INVALID_ACCOUNT_ID", "Invalid account ID") + } + account, err := h.adminService.GetAccount(c.Request.Context(), accountID) + if err != nil { + return nil, err + } + if err := ensureGrokOAuthAccount(account); err != nil { + return nil, err + } + return account, nil +} + +func ensureGrokOAuthAccount(account *service.Account) error { + if account == nil { + return infraerrors.NotFound("ACCOUNT_NOT_FOUND", "account not found") + } + if account.Platform != service.PlatformGrok || account.Type != service.AccountTypeOAuth { + return infraerrors.BadRequest( + "GROK_MEDIA_ELIGIBILITY_UNSUPPORTED_ACCOUNT", + "media eligibility is only supported for Grok OAuth accounts", + ) + } + return nil +} + +func buildGrokMediaEligibilityResponse(account *service.Account) grokMediaEligibilityResponse { + eligible, reason := account.GrokMediaGenerationEligibility() + return grokMediaEligibilityResponse{ + AccountID: account.ID, + Mode: grokMediaEligibilityModeFromExtra(account.Extra), + Eligible: eligible, + Reason: reason, + } +} + +func grokMediaEligibilityModeFromExtra(extra map[string]any) string { + if extra != nil { + if enabled, ok := extra[service.GrokMediaEligibleExtraKey].(bool); ok { + if enabled { + return grokMediaEligibilityModeEnabled + } + return grokMediaEligibilityModeDisabled + } + } + return grokMediaEligibilityModeAuto +} diff --git a/backend/internal/handler/admin/grok_media_eligibility_handler_test.go b/backend/internal/handler/admin/grok_media_eligibility_handler_test.go new file mode 100644 index 0000000000..7691d58667 --- /dev/null +++ b/backend/internal/handler/admin/grok_media_eligibility_handler_test.go @@ -0,0 +1,146 @@ +package admin + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +type grokMediaEligibilityAdminServiceStub struct { + *stubAdminService + account *service.Account +} + +func (s *grokMediaEligibilityAdminServiceStub) GetAccount(_ context.Context, _ int64) (*service.Account, error) { + return s.account, nil +} + +func (s *grokMediaEligibilityAdminServiceStub) UpdateAccountExtra(_ context.Context, _ int64, updates map[string]any) error { + if s.account.Extra == nil { + s.account.Extra = map[string]any{} + } + for key, value := range updates { + if value == nil { + delete(s.account.Extra, key) + } else { + s.account.Extra[key] = value + } + } + return nil +} + +func newGrokMediaEligibilityRouter(account *service.Account) *gin.Engine { + stub := &grokMediaEligibilityAdminServiceStub{ + stubAdminService: newStubAdminService(), + account: account, + } + h := NewAccountHandler(stub, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + router := gin.New() + router.GET("/accounts/:id/grok-media-eligibility", h.GetGrokMediaEligibility) + router.PUT("/accounts/:id/grok-media-eligibility", h.UpdateGrokMediaEligibility) + return router +} + +func TestGrokMediaEligibilityHandlerGetReturnsModeAndReason(t *testing.T) { + gin.SetMode(gin.TestMode) + router := newGrokMediaEligibilityRouter(&service.Account{ + ID: 12, + Platform: service.PlatformGrok, + Type: service.AccountTypeOAuth, + Extra: map[string]any{ + "grok_billing_snapshot": map[string]any{"status_code": 200, "partial": true}, + "unrelated_setting": "preserve", + }, + }) + + req := httptest.NewRequest(http.MethodGet, "/accounts/12/grok-media-eligibility", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + var envelope struct { + Data grokMediaEligibilityResponse `json:"data"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &envelope)) + require.Equal(t, int64(12), envelope.Data.AccountID) + require.Equal(t, grokMediaEligibilityModeAuto, envelope.Data.Mode) + require.Equal(t, "billing_inconclusive", envelope.Data.Reason) +} + +func TestGrokMediaEligibilityHandlerUpdateModesPreservesExtra(t *testing.T) { + gin.SetMode(gin.TestMode) + account := &service.Account{ + ID: 12, + Platform: service.PlatformGrok, + Type: service.AccountTypeOAuth, + Extra: map[string]any{"unrelated_setting": "preserve"}, + } + router := newGrokMediaEligibilityRouter(account) + + tests := []struct { + mode string + wantMode string + wantExtra any + wantEligible bool + wantReason string + }{ + {mode: "enabled", wantMode: grokMediaEligibilityModeEnabled, wantExtra: true, wantEligible: true, wantReason: "override_enabled"}, + {mode: "disabled", wantMode: grokMediaEligibilityModeDisabled, wantExtra: false, wantEligible: false, wantReason: "override_disabled"}, + {mode: "auto", wantMode: grokMediaEligibilityModeAuto, wantExtra: nil, wantEligible: false, wantReason: "billing_unobserved"}, + } + + for _, tt := range tests { + t.Run(tt.mode, func(t *testing.T) { + body, err := json.Marshal(map[string]string{"mode": tt.mode}) + require.NoError(t, err) + req := httptest.NewRequest(http.MethodPut, "/accounts/12/grok-media-eligibility", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + var envelope struct { + Data grokMediaEligibilityResponse `json:"data"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &envelope)) + require.Equal(t, tt.wantMode, envelope.Data.Mode) + require.Equal(t, tt.wantEligible, envelope.Data.Eligible) + require.Equal(t, tt.wantReason, envelope.Data.Reason) + require.Equal(t, "preserve", account.Extra["unrelated_setting"]) + if tt.wantExtra == nil { + _, exists := account.Extra[service.GrokMediaEligibleExtraKey] + require.False(t, exists) + } else { + require.Equal(t, tt.wantExtra, account.Extra[service.GrokMediaEligibleExtraKey]) + } + }) + } +} + +func TestGrokMediaEligibilityHandlerRejectsUnsupportedAndInvalidModes(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, tt := range []struct { + name string + account *service.Account + body string + }{ + {name: "unsupported account", account: &service.Account{ID: 1, Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth}, body: `{"mode":"enabled"}`}, + {name: "invalid mode", account: &service.Account{ID: 1, Platform: service.PlatformGrok, Type: service.AccountTypeOAuth}, body: `{"mode":"force"}`}, + } { + t.Run(tt.name, func(t *testing.T) { + router := newGrokMediaEligibilityRouter(tt.account) + req := httptest.NewRequest(http.MethodPut, "/accounts/1/grok-media-eligibility", bytes.NewBufferString(tt.body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + require.Equal(t, http.StatusBadRequest, rec.Code) + }) + } +} diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index d23a21c62c..28aaf92ed0 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -372,6 +372,8 @@ func registerAccountRoutes(admin *gin.RouterGroup, h *handler.Handlers, stepUpAu accounts.POST("/sync/crs", h.Admin.Account.SyncFromCRS) accounts.POST("/sync/crs/preview", h.Admin.Account.PreviewFromCRS) accounts.PUT("/:id", h.Admin.Account.Update) + accounts.GET("/:id/grok-media-eligibility", h.Admin.Account.GetGrokMediaEligibility) + accounts.PUT("/:id/grok-media-eligibility", h.Admin.Account.UpdateGrokMediaEligibility) accounts.PUT("/:id/upstream-billing-probe", h.Admin.Account.SetUpstreamBillingProbeEnabled) accounts.POST("/:id/upstream-billing-probe", h.Admin.Account.ProbeUpstreamBilling) accounts.GET("/:id/ollama-cloud-usage", h.Admin.Account.GetOllamaCloudUsage) diff --git a/frontend/src/api/admin/accounts.ts b/frontend/src/api/admin/accounts.ts index 8de2ed4996..0f1133c4ce 100644 --- a/frontend/src/api/admin/accounts.ts +++ b/frontend/src/api/admin/accounts.ts @@ -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 { + const { data } = await apiClient.get( + `/admin/accounts/${id}/grok-media-eligibility` + ) + return data +} + +export async function updateGrokMediaEligibility( + id: number, + mode: GrokMediaEligibilityMode +): Promise { + const { data } = await apiClient.put( + `/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, diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue index 8ef424f4d9..afee8a9cbd 100644 --- a/frontend/src/components/account/EditAccountModal.vue +++ b/frontend/src/components/account/EditAccountModal.vue @@ -542,6 +542,57 @@ + +
+
+
+ +

+ {{ t('admin.accounts.grokMediaEligibility.hint') }} +

+
+ +

+ {{ t('admin.accounts.grokMediaEligibility.loading') }} +

+

+ {{ grokMediaEligibilityError }} +

+
+ {{ t('admin.accounts.grokMediaEligibility.current') }} + + {{ grokMediaEligibilityState.eligible ? t('admin.accounts.grokMediaEligibility.eligible') : t('admin.accounts.grokMediaEligibility.ineligible') }} + · {{ t(`admin.accounts.grokMediaEligibility.reasons.${grokMediaEligibilityState.reason}`) }} + +
+
+

+ + {{ t('admin.accounts.grokMediaEligibility.forceEnableWarning') }} +

+
+

+ {{ t('admin.accounts.grokMediaEligibility.autoHint') }} +

+
+
+
props.account?.platform === 'grok' && props.account?.type === 'oauth' +) +const grokMediaEligibilityMode = ref('auto') +const grokMediaEligibilityInitialMode = ref('auto') +const grokMediaEligibilityState = ref(null) +const grokMediaEligibilityLoading = ref(false) +const grokMediaEligibilityError = ref('') +let grokMediaEligibilityRequestVersion = 0 + +const modeFromGrokMediaExtra = (extra: Record | 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 => { + 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 if (isCustomGrokBaseUrl(grokCreds.base_url)) { @@ -4606,10 +4708,48 @@ const handleClose = () => { emit('close') } +const persistGrokMediaEligibility = async (accountID: number, updatedAccount: Account): Promise => { + 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 | 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 | 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) => { 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() diff --git a/frontend/src/components/account/__tests__/EditAccountModal.grokMediaEligibility.spec.ts b/frontend/src/components/account/__tests__/EditAccountModal.grokMediaEligibility.spec.ts new file mode 100644 index 0000000000..81f95c7392 --- /dev/null +++ b/frontend/src/components/account/__tests__/EditAccountModal.grokMediaEligibility.spec.ts @@ -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('vue-i18n') + return { ...actual, useI18n: () => ({ t: (key: string) => key }) } +}) + +import EditAccountModal from '../EditAccountModal.vue' + +const BaseDialogStub = defineComponent({ + props: { show: { type: Boolean, default: false } }, + template: '
' +}) + +const account = (platform = 'grok', type = 'oauth', extra: Record = {}) => ({ + 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) + }) +}) diff --git a/frontend/src/i18n/locales/en/admin/accounts.ts b/frontend/src/i18n/locales/en/admin/accounts.ts index a172ca3ddd..963376c163 100644 --- a/frontend/src/i18n/locales/en/admin/accounts.ts +++ b/frontend/src/i18n/locales/en/admin/accounts.ts @@ -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 (%)', diff --git a/frontend/src/i18n/locales/zh/admin/accounts.ts b/frontend/src/i18n/locales/zh/admin/accounts.ts index b4d2684f70..62af419e2c 100644 --- a/frontend/src/i18n/locales/zh/admin/accounts.ts +++ b/frontend/src/i18n/locales/zh/admin/accounts.ts @@ -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 用量阈值(%)', diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 1c2fcc6f18..dd04ca363f 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -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[]