diff --git a/backend/internal/service/grok_media.go b/backend/internal/service/grok_media.go index 40ad54276c..154e3003ef 100644 --- a/backend/internal/service/grok_media.go +++ b/backend/internal/service/grok_media.go @@ -315,6 +315,11 @@ func (s *OpenAIGatewayService) ForwardGrokMedia( if err != nil { return nil, err } + requestInfo := ParseGrokMediaRequest(contentType, body) + body, contentType, err = sanitizeGrokMediaForwardBody(endpoint, body, contentType) + if err != nil { + return nil, err + } var bodyReader io.Reader if endpoint.RequiresRequestBody() { @@ -350,7 +355,6 @@ func (s *OpenAIGatewayService) ForwardGrokMedia( defer func() { _ = resp.Body.Close() }() requestIDHeader := firstNonEmpty(resp.Header.Get("x-request-id"), resp.Header.Get("xai-request-id")) - requestInfo := ParseGrokMediaRequest(contentType, body) requestModel := requestInfo.Model if resp.StatusCode >= 400 { s.updateGrokUsageSnapshot(ctx, account.ID, xai.ParseQuotaHeaders(resp.Header, resp.StatusCode)) @@ -462,6 +466,25 @@ func normalizeGrokMediaForwardBody(endpoint GrokMediaEndpoint, body []byte, cont return out, contentType, nil } +func sanitizeGrokMediaForwardBody(endpoint GrokMediaEndpoint, body []byte, contentType string) ([]byte, string, error) { + if !endpoint.RequiresRequestBody() || !gjson.ValidBytes(body) { + return body, contentType, nil + } + switch endpoint { + case GrokMediaEndpointImagesGenerations, GrokMediaEndpointImagesEdits: + if !gjson.GetBytes(body, "size").Exists() { + return body, contentType, nil + } + out, err := sjson.DeleteBytes(body, "size") + if err != nil { + return nil, "", fmt.Errorf("sanitize grok media size: %w", err) + } + return out, contentType, nil + default: + return body, contentType, nil + } +} + func (r GrokMediaRequestInfo) HasInputImage() bool { return len(r.InputImageURLs) > 0 || len(r.Uploads) > 0 } diff --git a/backend/internal/service/openai_gateway_grok_test.go b/backend/internal/service/openai_gateway_grok_test.go index c7cdcd7680..80135033da 100644 --- a/backend/internal/service/openai_gateway_grok_test.go +++ b/backend/internal/service/openai_gateway_grok_test.go @@ -323,6 +323,43 @@ func TestForwardGrokMediaImagesGenerationNormalizesImagineAlias(t *testing.T) { require.Equal(t, ImageBillingSize2K, result.ImageSize) } +func TestForwardGrokMediaImagesGenerationStripsUnsupportedSize(t *testing.T) { + t.Setenv(xai.EnvAllowUnsafeURLOverrides, "true") + gin.SetMode(gin.TestMode) + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + body := []byte(`{"model":"grok-imagine-image","prompt":"draw a cat","size":"1024x1024"}`) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + account := &Account{ + ID: 65, + Name: "grok", + Platform: PlatformGrok, + Type: AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{ + "api_key": "api-key", + "base_url": "https://xai.test/v1", + }, + } + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{ + "Content-Type": []string{"application/json"}, + }, + Body: io.NopCloser(strings.NewReader(`{"data":[]}`)), + }} + svc := &OpenAIGatewayService{httpUpstream: upstream} + + result, err := svc.ForwardGrokMedia(context.Background(), c, account, GrokMediaEndpointImagesGenerations, "", body, "application/json") + require.NoError(t, err) + require.JSONEq(t, `{"model":"grok-imagine-image","prompt":"draw a cat"}`, string(upstream.lastBody)) + require.Equal(t, ImageBillingSize1K, result.ImageSize) + require.Equal(t, "1024x1024", result.ImageInputSize) +} + func TestForwardGrokMediaImagesEditMultipartConvertsToJSON(t *testing.T) { t.Setenv(xai.EnvAllowUnsafeURLOverrides, "true") gin.SetMode(gin.TestMode) diff --git a/backend/internal/service/openai_images.go b/backend/internal/service/openai_images.go index 09472fbaf1..fc7d37a173 100644 --- a/backend/internal/service/openai_images.go +++ b/backend/internal/service/openai_images.go @@ -455,7 +455,15 @@ func applyOpenAIImagesDefaults(req *OpenAIImagesRequest) { } func isOpenAIImageGenerationModel(model string) bool { - return strings.HasPrefix(strings.ToLower(strings.TrimSpace(model)), "gpt-image-") + model = strings.ToLower(strings.TrimSpace(model)) + return strings.HasPrefix(model, "gpt-image-") || isGrokImageGenerationModel(model) +} + +func isGrokImageGenerationModel(model string) bool { + model = strings.ToLower(strings.TrimSpace(model)) + return model == "grok-imagine" || + model == "grok-imagine-edit" || + strings.HasPrefix(model, "grok-imagine-image") } func validateOpenAIImagesModel(model string) error { diff --git a/backend/internal/service/openai_images_test.go b/backend/internal/service/openai_images_test.go index 9897bffed0..0bcd68a386 100644 --- a/backend/internal/service/openai_images_test.go +++ b/backend/internal/service/openai_images_test.go @@ -334,6 +334,28 @@ func TestOpenAIGatewayServiceParseOpenAIImagesRequest_RejectsNonImageModel(t *te require.ErrorContains(t, err, `images endpoint requires an image model, got "gpt-5.4"`) } +func TestOpenAIGatewayServiceParseOpenAIImagesRequest_AllowsGrokImageModels(t *testing.T) { + gin.SetMode(gin.TestMode) + + for _, model := range []string{"grok-imagine", "grok-imagine-image", "grok-imagine-image-quality", "grok-imagine-edit"} { + t.Run(model, func(t *testing.T) { + body := []byte(fmt.Sprintf(`{"model":%q,"prompt":"draw a cat","response_format":"b64_json"}`, model)) + req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = req + + svc := &OpenAIGatewayService{} + parsed, err := svc.ParseOpenAIImagesRequest(c, body) + require.NoError(t, err) + require.NotNil(t, parsed) + require.Equal(t, model, parsed.Model) + require.Equal(t, OpenAIImagesCapabilityNative, parsed.RequiredCapability) + }) + } +} + func TestOpenAIGatewayServiceParseOpenAIImagesRequest_JSONEditURLs(t *testing.T) { gin.SetMode(gin.TestMode) body := []byte(`{ diff --git a/frontend/src/components/account/AccountUsageCell.vue b/frontend/src/components/account/AccountUsageCell.vue index a4cb70a9c3..81d97efb9c 100644 --- a/frontend/src/components/account/AccountUsageCell.vue +++ b/frontend/src/components/account/AccountUsageCell.vue @@ -372,6 +372,13 @@ A ${{ formatWindowCost(grokLocalUsage) }} + + U ${{ formatWindowUserCost(grokLocalUsage) }} + { const formatWindowRequests = (stats: WindowStats) => formatCompactNumber(stats.requests, { allowBillions: false }) const formatWindowTokens = (stats: WindowStats) => formatCompactNumber(stats.tokens) const formatWindowCost = (stats: WindowStats) => stats.cost.toFixed(2) +const formatWindowUserCost = (stats: WindowStats) => (stats.user_cost ?? 0).toFixed(2) // 账户类型显示标签 const antigravityTierLabel = computed(() => { diff --git a/frontend/src/components/account/__tests__/AccountUsageCell.spec.ts b/frontend/src/components/account/__tests__/AccountUsageCell.spec.ts index 55efc197a7..2df3cc07e2 100644 --- a/frontend/src/components/account/__tests__/AccountUsageCell.spec.ts +++ b/frontend/src/components/account/__tests__/AccountUsageCell.spec.ts @@ -566,6 +566,58 @@ describe('AccountUsageCell', () => { expect(badges.some(node => node.attributes('title') === 'usage.userBilled')).toBe(true) }) + it('Grok OAuth 会展示本地 user billed 用量并保留超限百分比', async () => { + getUsage.mockResolvedValue({ + grok_local_usage: { + requests: 4, + tokens: 1200, + cost: 0.12, + standard_cost: 0.12, + user_cost: 0.34 + }, + grok_request_quota: { + limit: 10, + remaining: -2, + reset_at: '2026-07-09T16:00:00Z' + }, + grok_quota_snapshot_state: 'observed' + }) + + const wrapper = mount(AccountUsageCell, { + props: { + account: makeAccount({ + id: 3861, + platform: 'grok', + type: 'oauth', + extra: {} + }) + }, + global: { + stubs: { + UsageProgressBar: { + props: ['label', 'utilization', 'resetsAt', 'color'], + template: '
{{ label }}|{{ utilization }}|{{ resetsAt }}
' + }, + AccountQuotaInfo: true, + GrokQuotaProbeCell: true + } + } + }) + + await flushPromises() + + expect(getUsage).toHaveBeenCalledWith(3861) + expect(wrapper.text()).toContain('4 req') + expect(wrapper.text()).toContain('1.2K') + expect(wrapper.text()).toContain('A $0.12') + expect(wrapper.text()).toContain('U $0.34') + expect(wrapper.text()).toContain('admin.accounts.usageWindow.grokRequests|120|2026-07-09T16:00:00Z') + + const badges = wrapper.findAll('span[title]') + expect(badges.some(node => node.attributes('title') === 'usage.accountBilled')).toBe(true) + expect(badges.some(node => node.attributes('title') === 'usage.userBilled')).toBe(true) + }) + it('Key 账号在 today stats loading 时显示骨架屏', async () => { const wrapper = mount(AccountUsageCell, { props: { diff --git a/frontend/src/composables/__tests__/useGrokOAuth.spec.ts b/frontend/src/composables/__tests__/useGrokOAuth.spec.ts new file mode 100644 index 0000000000..0d7c93d9c6 --- /dev/null +++ b/frontend/src/composables/__tests__/useGrokOAuth.spec.ts @@ -0,0 +1,55 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('@/stores/app', () => ({ + useAppStore: () => ({ + showError: vi.fn() + }) +})) + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ + t: (key: string) => { + const messages: Record = { + 'admin.accounts.oauth.grok.failedToExchangeCode': 'Grok 授权码兑换失败', + 'admin.accounts.oauth.grok.errors.GROK_OAUTH_INVALID_STATE': + 'Grok OAuth state 与当前会话不匹配。请粘贴同一次生成的授权链接返回的回调 URL。' + } + return messages[key] ?? key + } + }) +})) + +vi.mock('@/api/admin', () => ({ + adminAPI: { + grok: { + generateAuthUrl: vi.fn(), + exchangeCode: vi.fn(), + refreshGrokToken: vi.fn() + } + } +})) + +import { useGrokOAuth } from '@/composables/useGrokOAuth' +import { adminAPI } from '@/api/admin' + +describe('useGrokOAuth.exchangeAuthCode', () => { + it('shows a state mismatch recovery hint from structured backend errors', async () => { + vi.mocked(adminAPI.grok.exchangeCode).mockRejectedValueOnce({ + status: 400, + reason: 'GROK_OAUTH_INVALID_STATE', + message: 'invalid oauth state' + }) + const oauth = useGrokOAuth() + + const tokenInfo = await oauth.exchangeAuthCode({ + code: 'code', + sessionId: 'session-id', + state: 'wrong-state' + }) + + expect(tokenInfo).toBeNull() + expect(oauth.error.value).toBe( + 'Grok OAuth state 与当前会话不匹配。请粘贴同一次生成的授权链接返回的回调 URL。' + ) + }) +}) diff --git a/frontend/src/composables/useGrokOAuth.ts b/frontend/src/composables/useGrokOAuth.ts index 56c1783793..ad746bfecf 100644 --- a/frontend/src/composables/useGrokOAuth.ts +++ b/frontend/src/composables/useGrokOAuth.ts @@ -3,6 +3,7 @@ import { useI18n } from 'vue-i18n' import { useAppStore } from '@/stores/app' import { adminAPI } from '@/api/admin' import type { GrokTokenInfo } from '@/api/admin/grok' +import { extractApiErrorMessage, extractI18nErrorMessage } from '@/utils/apiError' export function useGrokOAuth() { const appStore = useAppStore() @@ -39,7 +40,7 @@ export function useGrokOAuth() { state.value = response.state return true } catch (err: any) { - error.value = err.response?.data?.detail || t('admin.accounts.oauth.grok.failedToGenerateUrl') + error.value = extractApiErrorMessage(err, t('admin.accounts.oauth.grok.failedToGenerateUrl')) appStore.showError(error.value) return false } finally { @@ -72,7 +73,12 @@ export function useGrokOAuth() { return await adminAPI.grok.exchangeCode(payload as any) } catch (err: any) { - error.value = err.response?.data?.detail || t('admin.accounts.oauth.grok.failedToExchangeCode') + error.value = extractI18nErrorMessage( + err, + t, + 'admin.accounts.oauth.grok.errors', + t('admin.accounts.oauth.grok.failedToExchangeCode') + ) appStore.showError(error.value) return null } finally { @@ -95,7 +101,12 @@ export function useGrokOAuth() { try { return await adminAPI.grok.refreshGrokToken(refreshToken.trim(), proxyId) } catch (err: any) { - error.value = err.response?.data?.detail || t('admin.accounts.oauth.grok.failedToValidateRT') + error.value = extractI18nErrorMessage( + err, + t, + 'admin.accounts.oauth.grok.errors', + t('admin.accounts.oauth.grok.failedToValidateRT') + ) return null } finally { loading.value = false diff --git a/frontend/src/i18n/locales/en/admin/accounts.ts b/frontend/src/i18n/locales/en/admin/accounts.ts index 8664da1d5a..57e2bbf327 100644 --- a/frontend/src/i18n/locales/en/admin/accounts.ts +++ b/frontend/src/i18n/locales/en/admin/accounts.ts @@ -873,6 +873,22 @@ export default { missingExchangeParams: 'Missing authorization code, state, or OAuth session', failedToExchangeCode: 'Failed to exchange Grok authorization code', failedToValidateRT: 'Failed to validate Grok refresh token', + errors: { + GROK_OAUTH_SESSION_NOT_FOUND: + 'Grok OAuth session was not found or has expired. Generate a new auth URL and paste the newest callback URL.', + GROK_OAUTH_INVALID_STATE: + 'Grok OAuth state does not match this session. Paste the callback URL from the same generated auth link.', + GROK_OAUTH_STATE_REQUIRED: + 'The callback URL is missing the OAuth state. Paste the full callback URL, not only the code.', + GROK_OAUTH_CODE_REQUIRED: + 'The Grok authorization code is missing. Paste the full callback URL, query string, or code value.', + GROK_OAUTH_NO_REFRESH_TOKEN: + 'The Grok response did not include a refresh token. Generate a new auth URL and approve offline access again.', + GROK_OAUTH_PROXY_NOT_AVAILABLE: + 'Grok OAuth proxy lookup is unavailable. Check the selected proxy and retry.', + GROK_OAUTH_PROXY_NOT_FOUND: + 'The selected proxy could not be found. Choose an available proxy and retry.' + }, oauthOnlyHint: 'Initial Grok support is OAuth subscription-backed Responses API text and reasoning traffic only.' }, // Gemini specific diff --git a/frontend/src/i18n/locales/zh/admin/accounts.ts b/frontend/src/i18n/locales/zh/admin/accounts.ts index c664386d4d..6f6c721e83 100644 --- a/frontend/src/i18n/locales/zh/admin/accounts.ts +++ b/frontend/src/i18n/locales/zh/admin/accounts.ts @@ -960,6 +960,22 @@ export default { missingExchangeParams: '缺少授权码、state 或 OAuth 会话', failedToExchangeCode: 'Grok 授权码兑换失败', failedToValidateRT: '验证 Grok refresh token 失败', + errors: { + GROK_OAUTH_SESSION_NOT_FOUND: + 'Grok OAuth 会话不存在或已过期。请重新生成授权链接,并粘贴最新的回调链接。', + GROK_OAUTH_INVALID_STATE: + 'Grok OAuth state 与当前会话不匹配。请粘贴同一次生成的授权链接返回的回调 URL。', + GROK_OAUTH_STATE_REQUIRED: + '回调链接缺少 OAuth state。请粘贴完整 callback URL,不要只粘贴 code。', + GROK_OAUTH_CODE_REQUIRED: + '缺少 Grok 授权码。请粘贴完整 callback URL、查询字符串或 code 值。', + GROK_OAUTH_NO_REFRESH_TOKEN: + 'Grok 响应未返回 refresh token。请重新生成授权链接,并再次确认 offline access 授权。', + GROK_OAUTH_PROXY_NOT_AVAILABLE: + '无法查询 Grok OAuth 代理配置。请检查选择的代理后重试。', + GROK_OAUTH_PROXY_NOT_FOUND: + '找不到所选代理。请选择可用代理后重试。' + }, oauthOnlyHint: '首版 Grok 支持仅包含 OAuth 订阅的 Responses API 文本/推理转发。' }, // Gemini specific