From b480545c19d6926c1360e5cb6c3ffd629c896883 Mon Sep 17 00:00:00 2001 From: Heatherm Huang Date: Thu, 9 Jul 2026 17:47:54 +0800 Subject: [PATCH 1/2] fix: improve Grok OAuth, image, and usage flows --- backend/internal/service/grok_media.go | 25 ++++++++- .../service/openai_gateway_grok_test.go | 37 +++++++++++++ backend/internal/service/openai_images.go | 10 +++- .../internal/service/openai_images_test.go | 22 ++++++++ .../components/account/AccountUsageCell.vue | 10 +++- .../__tests__/AccountUsageCell.spec.ts | 52 ++++++++++++++++++ .../__tests__/useGrokOAuth.spec.ts | 55 +++++++++++++++++++ frontend/src/composables/useGrokOAuth.ts | 17 +++++- .../src/i18n/locales/en/admin/accounts.ts | 16 ++++++ .../src/i18n/locales/zh/admin/accounts.ts | 16 ++++++ 10 files changed, 254 insertions(+), 6 deletions(-) create mode 100644 frontend/src/composables/__tests__/useGrokOAuth.spec.ts 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 From 1a0a6ea9a87fd30a99b8be10c66e67e2f3d3e493 Mon Sep 17 00:00:00 2001 From: Heatherm Huang Date: Thu, 9 Jul 2026 17:58:31 +0800 Subject: [PATCH 2/2] fix: stabilize Grok quota probe model --- backend/internal/pkg/xai/models.go | 1 + backend/internal/pkg/xai/oauth_test.go | 1 + .../internal/service/grok_quota_service.go | 23 +++--- .../service/grok_quota_service_test.go | 70 +++++++++++++++++++ frontend/src/api/admin/grok.ts | 1 + .../__tests__/useModelWhitelist.spec.ts | 8 +++ frontend/src/composables/useModelWhitelist.ts | 2 + 7 files changed, 97 insertions(+), 9 deletions(-) diff --git a/backend/internal/pkg/xai/models.go b/backend/internal/pkg/xai/models.go index a42f9b1871..3a65f32c51 100644 --- a/backend/internal/pkg/xai/models.go +++ b/backend/internal/pkg/xai/models.go @@ -51,6 +51,7 @@ func DefaultModelMapping() map[string]string { mapping["grok-build"] = "grok-build-0.1" mapping["grok-build-latest"] = "grok-4.5" mapping["grok-composer"] = "grok-composer-2.5-fast" + mapping["composer-2.5"] = "grok-composer-2.5-fast" mapping["grok-4.20-reasoning"] = "grok-4.20-0309-reasoning" mapping["grok-4.20-non-reasoning"] = "grok-4.20-0309-non-reasoning" return mapping diff --git a/backend/internal/pkg/xai/oauth_test.go b/backend/internal/pkg/xai/oauth_test.go index 1eea83640b..d3d3d5cb29 100644 --- a/backend/internal/pkg/xai/oauth_test.go +++ b/backend/internal/pkg/xai/oauth_test.go @@ -214,6 +214,7 @@ func TestDefaultModelMappingIncludesGrokAliases(t *testing.T) { require.Equal(t, "grok-build-0.1", mapping["grok-build"]) require.Equal(t, "grok-4.5", mapping["grok-build-latest"]) require.Equal(t, "grok-composer-2.5-fast", mapping["grok-composer"]) + require.Equal(t, "grok-composer-2.5-fast", mapping["composer-2.5"]) require.Equal(t, "grok-4.20-0309-reasoning", mapping["grok-4.20-reasoning"]) require.Equal(t, "grok-4.20-0309-non-reasoning", mapping["grok-4.20-non-reasoning"]) require.Equal(t, "grok-4.20-multi-agent-0309", mapping["grok-4.20-multi-agent-0309"]) diff --git a/backend/internal/service/grok_quota_service.go b/backend/internal/service/grok_quota_service.go index 19b1a01a0a..06d90a609e 100644 --- a/backend/internal/service/grok_quota_service.go +++ b/backend/internal/service/grok_quota_service.go @@ -22,6 +22,7 @@ const ( type GrokQuotaProbeResult struct { Source string `json:"source"` + Model string `json:"model"` Snapshot *xai.QuotaSnapshot `json:"snapshot,omitempty"` StatusCode int `json:"status_code,omitempty"` HeadersObserved bool `json:"headers_observed"` @@ -62,7 +63,8 @@ func (s *GrokQuotaService) ProbeUsage(ctx context.Context, accountID int64) (*Gr return nil, err } - body, err := buildGrokQuotaProbeBody(account) + probeModel := grokQuotaProbeModel() + body, err := buildGrokQuotaProbeBody(probeModel) if err != nil { return nil, infraerrors.Newf(http.StatusBadRequest, "GROK_QUOTA_PROBE_BODY_ERROR", "failed to build probe body: %v", err) } @@ -95,6 +97,7 @@ func (s *GrokQuotaService) ProbeUsage(ctx context.Context, accountID int64) (*Gr result := &GrokQuotaProbeResult{ Source: "active_probe", + Model: probeModel, Snapshot: snapshot, StatusCode: resp.StatusCode, HeadersObserved: snapshot.HeadersObserved, @@ -107,8 +110,8 @@ func (s *GrokQuotaService) ProbeUsage(ctx context.Context, accountID int64) (*Gr if resp.StatusCode >= 400 { bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 240)) bodyText := truncate(strings.TrimSpace(string(bodyBytes)), 240) - slog.Warn("grok_quota_probe_failed", "account_id", account.ID, "status", resp.StatusCode, "body", bodyText) - return nil, infraerrors.Newf(mapUpstreamStatus(resp.StatusCode), "GROK_QUOTA_PROBE_UPSTREAM_ERROR", "upstream returned %d: %s", resp.StatusCode, bodyText) + slog.Warn("grok_quota_probe_failed", "account_id", account.ID, "model", probeModel, "status", resp.StatusCode, "body", bodyText) + return nil, infraerrors.Newf(mapUpstreamStatus(resp.StatusCode), "GROK_QUOTA_PROBE_UPSTREAM_ERROR", "upstream returned %d for probe model %q: %s", resp.StatusCode, probeModel, bodyText) } return result, nil } @@ -175,12 +178,14 @@ func (s *GrokQuotaService) loadGrokOAuthAccount(ctx context.Context, accountID i return account, nil } -func buildGrokQuotaProbeBody(account *Account) ([]byte, error) { - model := grokQuotaDefaultModel - if account != nil { - if mapped := strings.TrimSpace(account.GetMappedModel("grok")); mapped != "" { - model = mapped - } +func grokQuotaProbeModel() string { + return grokQuotaDefaultModel +} + +func buildGrokQuotaProbeBody(model string) ([]byte, error) { + model = strings.TrimSpace(model) + if model == "" { + model = grokQuotaDefaultModel } return json.Marshal(map[string]any{ "model": model, diff --git a/backend/internal/service/grok_quota_service_test.go b/backend/internal/service/grok_quota_service_test.go index d1da2e50a5..fe1a00aa89 100644 --- a/backend/internal/service/grok_quota_service_test.go +++ b/backend/internal/service/grok_quota_service_test.go @@ -13,6 +13,7 @@ import ( infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/Wei-Shaw/sub2api/internal/pkg/xai" "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" ) type grokQuotaAccountRepo struct { @@ -85,6 +86,7 @@ func TestGrokQuotaServiceProbeUsageStoresHeaders(t *testing.T) { result, err := svc.ProbeUsage(context.Background(), 42) require.NoError(t, err) require.Equal(t, http.StatusOK, result.StatusCode) + require.Equal(t, "grok-4.3", result.Model) require.True(t, result.HeadersObserved) require.NotNil(t, result.Snapshot) require.True(t, result.Snapshot.HeadersObserved) @@ -96,11 +98,79 @@ func TestGrokQuotaServiceProbeUsageStoresHeaders(t *testing.T) { require.EqualValues(t, 7, *result.Snapshot.Requests.Remaining) require.Equal(t, "https://api.x.ai/v1/responses", upstream.lastReq.URL.String()) require.Equal(t, "Bearer access-token", upstream.lastReq.Header.Get("Authorization")) + require.Equal(t, "grok-4.3", gjson.GetBytes(upstream.lastBody, "model").String()) require.Contains(t, string(upstream.lastBody), `"max_output_tokens":1`) require.Contains(t, string(upstream.lastBody), `"store":false`) require.NotNil(t, repo.updates[42][grokQuotaSnapshotExtraKey]) } +func TestGrokQuotaServiceProbeUsageIgnoresAccountGrokMapping(t *testing.T) { + t.Parallel() + + account := &Account{ + ID: 47, + Platform: PlatformGrok, + Type: AccountTypeOAuth, + Concurrency: 1, + Credentials: map[string]any{ + "access_token": "access-token", + "expires_at": time.Now().Add(time.Hour).UTC().Format(time.RFC3339), + "model_mapping": map[string]any{ + "grok": "grok-composer", + "grok-composer": "grok-composer-2.5-fast", + }, + }, + } + repo := &grokQuotaAccountRepo{ + mockAccountRepoForPlatform: &mockAccountRepoForPlatform{ + accountsByID: map[int64]*Account{47: account}, + }, + } + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{}, + Body: io.NopCloser(strings.NewReader(`{"id":"resp_probe"}`)), + }} + svc := NewGrokQuotaService(repo, nil, NewGrokTokenProvider(repo, nil), upstream) + + result, err := svc.ProbeUsage(context.Background(), 47) + require.NoError(t, err) + require.Equal(t, "grok-4.3", result.Model) + require.Equal(t, "grok-4.3", gjson.GetBytes(upstream.lastBody, "model").String()) + require.NotContains(t, string(upstream.lastBody), "grok-composer") +} + +func TestGrokQuotaServiceProbeUsageReportsProbeModelOnUpstreamError(t *testing.T) { + t.Parallel() + + account := &Account{ + ID: 48, + Platform: PlatformGrok, + Type: AccountTypeOAuth, + Concurrency: 1, + Credentials: map[string]any{ + "access_token": "access-token", + "expires_at": time.Now().Add(time.Hour).UTC().Format(time.RFC3339), + }, + } + repo := &grokQuotaAccountRepo{ + mockAccountRepoForPlatform: &mockAccountRepoForPlatform{ + accountsByID: map[int64]*Account{48: account}, + }, + } + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusBadRequest, + Header: http.Header{}, + Body: io.NopCloser(strings.NewReader(`{"code":"invalid-argument","error":"Model not found"}`)), + }} + svc := NewGrokQuotaService(repo, nil, NewGrokTokenProvider(repo, nil), upstream) + + _, err := svc.ProbeUsage(context.Background(), 48) + require.Error(t, err) + require.Equal(t, "GROK_QUOTA_PROBE_UPSTREAM_ERROR", infraerrors.Reason(err)) + require.Contains(t, infraerrors.Message(err), `probe model "grok-4.3"`) +} + func TestGrokQuotaServiceProbeUsageLoadsProxyWhenAccountEdgeMissing(t *testing.T) { t.Parallel() diff --git a/frontend/src/api/admin/grok.ts b/frontend/src/api/admin/grok.ts index 2e0f5349b0..c0055d4dcc 100644 --- a/frontend/src/api/admin/grok.ts +++ b/frontend/src/api/admin/grok.ts @@ -63,6 +63,7 @@ export interface GrokQuotaSnapshot { export interface GrokQuotaProbeResult { source: 'active_probe' + model: string snapshot?: GrokQuotaSnapshot | null status_code?: number headers_observed: boolean diff --git a/frontend/src/composables/__tests__/useModelWhitelist.spec.ts b/frontend/src/composables/__tests__/useModelWhitelist.spec.ts index d34d7113bb..a9de605c28 100644 --- a/frontend/src/composables/__tests__/useModelWhitelist.spec.ts +++ b/frontend/src/composables/__tests__/useModelWhitelist.spec.ts @@ -69,6 +69,14 @@ describe('useModelWhitelist', () => { }) }) + it('grok 模型列表包含 Composer 默认项和兼容别名', () => { + const models = getModelsByPlatform('grok') + + expect(models).toContain('grok-composer-2.5-fast') + expect(models).toContain('grok-composer') + expect(models).toContain('composer-2.5') + }) + it('gemini 模型列表包含原生生图模型', () => { const models = getModelsByPlatform('gemini') diff --git a/frontend/src/composables/useModelWhitelist.ts b/frontend/src/composables/useModelWhitelist.ts index 2f8b7486ac..cce3df6a52 100644 --- a/frontend/src/composables/useModelWhitelist.ts +++ b/frontend/src/composables/useModelWhitelist.ts @@ -148,6 +148,7 @@ const xaiModels = [ 'grok-build', 'grok-build-latest', 'grok-composer', + 'composer-2.5', 'grok-4.20-reasoning', 'grok-4.20-non-reasoning', 'grok-imagine', @@ -306,6 +307,7 @@ const grokPresetMappings = [ { label: 'Build 0.1', from: 'grok-build', to: 'grok-build-0.1', color: 'bg-cyan-100 text-cyan-700 hover:bg-cyan-200 dark:bg-cyan-900/30 dark:text-cyan-400' }, { label: 'Build Latest', from: 'grok-build-latest', to: 'grok-4.5', color: 'bg-teal-100 text-teal-700 hover:bg-teal-200 dark:bg-teal-900/30 dark:text-teal-400' }, { label: 'Composer 2.5', from: 'grok-composer', to: 'grok-composer-2.5-fast', color: 'bg-teal-100 text-teal-700 hover:bg-teal-200 dark:bg-teal-900/30 dark:text-teal-400' }, + { label: 'Composer legacy', from: 'composer-2.5', to: 'grok-composer-2.5-fast', color: 'bg-teal-100 text-teal-700 hover:bg-teal-200 dark:bg-teal-900/30 dark:text-teal-400' }, { label: '4.20 Reasoning', from: 'grok-4.20-reasoning', to: 'grok-4.20-0309-reasoning', color: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400' }, { label: '4.20 Non Reasoning', from: 'grok-4.20-non-reasoning', to: 'grok-4.20-0309-non-reasoning', color: 'bg-violet-100 text-violet-700 hover:bg-violet-200 dark:bg-violet-900/30 dark:text-violet-400' }, { label: 'Imagine Image', from: 'grok-imagine', to: 'grok-imagine-image-quality', color: 'bg-sky-100 text-sky-700 hover:bg-sky-200 dark:bg-sky-900/30 dark:text-sky-400' },