From 68faeac8377b44f0929c17f1e787c6b8ac1d9e35 Mon Sep 17 00:00:00 2001 From: IanShaw027 Date: Sat, 8 Aug 2026 01:07:19 +0800 Subject: [PATCH] fix(grok): restore base URL resolution and operator settings wiring Honor account GetGrokBaseURLOr policy for official vs custom endpoints, and wire settings resolution used by responses/chat URL builders. --- backend/cmd/server/wire_gen.go | 4 +- .../internal/handler/admin/setting_handler.go | 1 + .../setting_handler_partial_payload_test.go | 12 +++ .../handler/admin/setting_handler_update.go | 18 +++-- backend/internal/handler/dto/settings.go | 1 + backend/internal/service/account.go | 67 ++++++++++++++--- .../service/grok_base_url_mode_test.go | 75 +++++++++++++++++++ backend/internal/service/grok_upstream_url.go | 26 +++++-- .../service/grok_upstream_url_test.go | 20 +++++ backend/internal/service/setting_parse.go | 2 + backend/internal/service/setting_service.go | 70 +++++++++++++++++ backend/internal/service/setting_update.go | 1 + backend/internal/service/settings_view.go | 2 + backend/internal/service/wire.go | 7 +- deploy/config.example.yaml | 6 +- frontend/src/api/admin/settings.ts | 2 + .../src/i18n/locales/en/admin/settings.ts | 7 ++ .../src/i18n/locales/zh/admin/settings.ts | 7 ++ frontend/src/types/index.ts | 3 + frontend/src/views/admin/SettingsView.vue | 27 ++++++- 20 files changed, 329 insertions(+), 29 deletions(-) create mode 100644 backend/internal/service/grok_base_url_mode_test.go diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 4f5e675bb4..3017a36bb3 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -193,11 +193,11 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { claudeUsageFetcher := repository.NewClaudeUsageFetcher(httpUpstream) antigravityQuotaFetcher := service.NewAntigravityQuotaFetcher(proxyRepository) grokQuotaFetcher := service.NewGrokQuotaFetcher() - grokQuotaService := service.ProvideGrokQuotaService(accountRepository, proxyRepository, grokTokenProvider, httpUpstream, configConfig, usageLogRepository) + grokQuotaService := service.ProvideGrokQuotaService(accountRepository, proxyRepository, grokTokenProvider, httpUpstream, configConfig, usageLogRepository, settingService) openAIQuotaService := service.ProvideOpenAIQuotaService(accountRepository, proxyRepository, openAITokenProvider, privacyClientFactory, openAIGatewayService) usageCache := service.NewUsageCache() accountUsageService := service.ProvideAccountUsageService(accountRepository, usageLogRepository, claudeUsageFetcher, geminiQuotaService, antigravityQuotaFetcher, grokQuotaFetcher, grokQuotaService, openAIQuotaService, usageCache, identityCache, tlsFingerprintProfileService, openAIGatewayService) - accountTestService := service.ProvideAccountTestService(accountRepository, geminiTokenProvider, claudeTokenProvider, grokTokenProvider, antigravityGatewayService, httpUpstream, configConfig, tlsFingerprintProfileService, openAIGatewayService) + accountTestService := service.ProvideAccountTestService(accountRepository, geminiTokenProvider, claudeTokenProvider, grokTokenProvider, antigravityGatewayService, httpUpstream, configConfig, tlsFingerprintProfileService, openAIGatewayService, settingService) crsSyncService := service.NewCRSSyncService(accountRepository, proxyRepository, oAuthService, openAIOAuthService, geminiOAuthService, configConfig) accountHandler := admin.ProvideAccountHandler(adminService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService, sessionLimitCache, rpmCache, compositeTokenCacheInvalidator, grokQuotaService) adminAnnouncementHandler := admin.NewAnnouncementHandler(announcementService) diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index b7bbf223a8..bb5ce72f8b 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -374,6 +374,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { GrokDefaultTextModel: settings.GrokDefaultTextModel, GrokCrossClientModelMapEnabled: settings.GrokCrossClientModelMapEnabled, + GrokDefaultBaseURLMode: settings.GrokDefaultBaseURLMode, AvailableChannelsEnabled: settings.AvailableChannelsEnabled, diff --git a/backend/internal/handler/admin/setting_handler_partial_payload_test.go b/backend/internal/handler/admin/setting_handler_partial_payload_test.go index 4d009cb0b2..b4c94809bb 100644 --- a/backend/internal/handler/admin/setting_handler_partial_payload_test.go +++ b/backend/internal/handler/admin/setting_handler_partial_payload_test.go @@ -66,6 +66,18 @@ func TestUpdateSettingsSMTPFromAliasIsWritable(t *testing.T) { require.Equal(t, "new@example.com", repo.values[service.SettingKeySMTPFrom]) } +func TestUpdateSettingsGrokDefaultBaseURLModeIsWritable(t *testing.T) { + h, repo := newStepUpSwitchTestHandler(t, map[string]string{ + service.SettingKeyGrokDefaultBaseURLMode: service.GrokDefaultBaseURLModeCLI, + }) + + rec := doUpdateSettings(t, h, map[string]any{ + "grok_default_base_url_mode": service.GrokDefaultBaseURLModeEUWest1, + }, nil) + require.Equal(t, http.StatusOK, rec.Code) + require.Equal(t, service.GrokDefaultBaseURLModeEUWest1, repo.values[service.SettingKeyGrokDefaultBaseURLMode]) +} + func TestUpdateSettingsRejectsTwoCaptchaProviders(t *testing.T) { h, _ := newStepUpSwitchTestHandler(t, map[string]string{ service.SettingKeyTurnstileEnabled: "true", diff --git a/backend/internal/handler/admin/setting_handler_update.go b/backend/internal/handler/admin/setting_handler_update.go index 581cda6290..d962196f35 100644 --- a/backend/internal/handler/admin/setting_handler_update.go +++ b/backend/internal/handler/admin/setting_handler_update.go @@ -333,6 +333,7 @@ type UpdateSettingsRequest struct { // Grok model mapping policy GrokDefaultTextModel *string `json:"grok_default_text_model"` GrokCrossClientModelMapEnabled *bool `json:"grok_cross_client_model_map_enabled"` + GrokDefaultBaseURLMode *string `json:"grok_default_base_url_mode"` // Available Channels feature switch (user-facing) AvailableChannelsEnabled *bool `json:"available_channels_enabled"` @@ -1880,6 +1881,12 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { } return previousSettings.GrokCrossClientModelMapEnabled }(), + GrokDefaultBaseURLMode: func() string { + if req.GrokDefaultBaseURLMode != nil { + return strings.TrimSpace(*req.GrokDefaultBaseURLMode) + } + return previousSettings.GrokDefaultBaseURLMode + }(), AvailableChannelsEnabled: func() bool { if req.AvailableChannelsEnabled != nil { return *req.AvailableChannelsEnabled @@ -2315,6 +2322,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { GrokDefaultTextModel: updatedSettings.GrokDefaultTextModel, GrokCrossClientModelMapEnabled: updatedSettings.GrokCrossClientModelMapEnabled, + GrokDefaultBaseURLMode: updatedSettings.GrokDefaultBaseURLMode, AvailableChannelsEnabled: updatedSettings.AvailableChannelsEnabled, @@ -2324,11 +2332,11 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { AffiliateEnabled: updatedSettings.AffiliateEnabled, - RiskControlEnabled: updatedSettings.RiskControlEnabled, - CyberSessionBlockEnabled: updatedSettings.CyberSessionBlockEnabled, - CyberSessionBlockTTLSeconds: updatedSettings.CyberSessionBlockTTLSeconds, - AccountSchedulingThresholds: updatedSettings.AccountSchedulingThresholds, - AllowUserViewErrorRequests: updatedSettings.AllowUserViewErrorRequests, + RiskControlEnabled: updatedSettings.RiskControlEnabled, + CyberSessionBlockEnabled: updatedSettings.CyberSessionBlockEnabled, + CyberSessionBlockTTLSeconds: updatedSettings.CyberSessionBlockTTLSeconds, + AccountSchedulingThresholds: updatedSettings.AccountSchedulingThresholds, + AllowUserViewErrorRequests: updatedSettings.AllowUserViewErrorRequests, } if fastPolicy, err := h.settingService.GetOpenAIFastPolicySettings(c.Request.Context()); err != nil { slog.Error("openai_fast_policy_settings_get_failed", "error", err) diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index ed1d81a193..8b1dab3f42 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -306,6 +306,7 @@ type SystemSettings struct { // Grok model mapping policy (admin settings; empty account mapping falls back to these). GrokDefaultTextModel string `json:"grok_default_text_model"` GrokCrossClientModelMapEnabled bool `json:"grok_cross_client_model_map_enabled"` + GrokDefaultBaseURLMode string `json:"grok_default_base_url_mode"` // Available Channels feature switch (user-facing aggregate view) AvailableChannelsEnabled bool `json:"available_channels_enabled"` diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go index eceaed0613..3d46b6d6f8 100644 --- a/backend/internal/service/account.go +++ b/backend/internal/service/account.go @@ -6,6 +6,7 @@ import ( "errors" "hash/fnv" "log/slog" + "net/url" "reflect" "sort" "strconv" @@ -1310,25 +1311,67 @@ func (a *Account) GetOpenAIRefreshToken() string { // traffic (OAuth authorization and token refresh) always uses the official // auth endpoints regardless of this value. func (a *Account) GetGrokBaseURL() string { - if !a.IsGrok() { + if a == nil || !a.IsGrok() { return "" } - baseURL := strings.TrimSpace(a.GetCredential("base_url")) if a.IsGrokOAuth() { - // Operators switch subscription traffic between the official CLI - // gateway, the official/regional API hosts and third-party relays - // (individual endpoints go down from time to time), so a stored - // value is always honored as-is. Only empty or unparseable values - // fall back to the default CLI gateway. - if baseURL == "" || !xai.IsParseableBaseURL(baseURL) { - return xai.DefaultCLIBaseURL + return a.GetGrokBaseURLOr(xai.DefaultCLIBaseURL) + } + return a.GetGrokBaseURLOr(xai.DefaultBaseURL) +} + +// GetGrokBaseURLOr resolves an explicit account endpoint, falling back to the +// supplied default. Official OAuth endpoints are normalized here; custom +// endpoints are retained for the request builder's operator URL policy. +func (a *Account) GetGrokBaseURLOr(defaultBaseURL string) string { + if a == nil || !a.IsGrok() { + return "" + } + defaultBaseURL = strings.TrimRight(strings.TrimSpace(defaultBaseURL), "/") + if defaultBaseURL == "" { + if a.IsGrokOAuth() { + defaultBaseURL = xai.DefaultCLIBaseURL + } else { + defaultBaseURL = xai.DefaultBaseURL } + } + baseURL := strings.TrimSpace(a.GetCredential("base_url")) + if baseURL == "" { + return defaultBaseURL + } + if !a.IsGrokOAuth() { return baseURL } - if baseURL != "" { - return baseURL + // Explicit regional/API or custom values remain pinned. Custom endpoints are checked by the + // operator URL policy at the request builder, which has access to config. + if validated, err := xai.ValidateTrustedBaseURL(baseURL); err == nil { + return validated } - return xai.DefaultBaseURL + if parsed, err := url.Parse(baseURL); err == nil && parsed.Scheme != "" && parsed.Host != "" && + parsed.User == nil && parsed.RawQuery == "" && parsed.Fragment == "" { + return strings.TrimRight(baseURL, "/") + } + return defaultBaseURL +} + +func isOfficialGrokAPIBaseURL(raw string) bool { + return isOfficialGrokBaseURL(raw, xai.DefaultBaseURL) +} + +func isOfficialGrokBaseURL(raw, expected string) bool { + parsed, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || parsed == nil || parsed.Opaque != "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" { + return false + } + want, err := url.Parse(expected) + if err != nil || !strings.EqualFold(parsed.Scheme, want.Scheme) || !strings.EqualFold(parsed.Hostname(), want.Hostname()) { + return false + } + if port := parsed.Port(); port != "" && port != "443" { + return false + } + path := strings.TrimRight(parsed.Path, "/") + return path == "" || path == strings.TrimRight(want.Path, "/") } // GetGrokMediaBaseURL selects the upstream used by Grok Imagine APIs. diff --git a/backend/internal/service/grok_base_url_mode_test.go b/backend/internal/service/grok_base_url_mode_test.go new file mode 100644 index 0000000000..516e5afb20 --- /dev/null +++ b/backend/internal/service/grok_base_url_mode_test.go @@ -0,0 +1,75 @@ +//go:build unit + +package service + +import ( + "context" + "fmt" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/pkg/xai" + "github.com/stretchr/testify/require" +) + +type grokBaseURLSettingRepoStub struct{ values map[string]string } + +func (r *grokBaseURLSettingRepoStub) GetValue(_ context.Context, key string) (string, error) { + if value, ok := r.values[key]; ok { + return value, nil + } + return "", fmt.Errorf("setting %s not found", key) +} +func (r *grokBaseURLSettingRepoStub) Get(context.Context, string) (*Setting, error) { + return nil, fmt.Errorf("unused") +} +func (r *grokBaseURLSettingRepoStub) Set(context.Context, string, string) error { return nil } +func (r *grokBaseURLSettingRepoStub) GetMultiple(context.Context, []string) (map[string]string, error) { + return r.values, nil +} +func (r *grokBaseURLSettingRepoStub) SetMultiple(context.Context, map[string]string) error { + return nil +} +func (r *grokBaseURLSettingRepoStub) GetAll(context.Context) (map[string]string, error) { + return r.values, nil +} +func (r *grokBaseURLSettingRepoStub) Delete(context.Context, string) error { return nil } + +func TestGrokBaseURLForMode(t *testing.T) { + for _, tc := range []struct { + mode string + want string + }{ + {"api", xai.DefaultBaseURL}, + {"us-east-1", xai.DefaultUSEast1BaseURL}, + {"us-west-2", xai.DefaultUSWest2BaseURL}, + {"eu-west-1", xai.DefaultEUWest1BaseURL}, + {"cli", xai.DefaultCLIBaseURL}, + {"invalid", xai.DefaultCLIBaseURL}, + } { + t.Run(tc.mode, func(t *testing.T) { + require.Equal(t, tc.want, GrokBaseURLForMode(tc.mode)) + }) + } +} + +func TestSettingServiceResolveGrokBaseURLHonorsModeAndExplicitPins(t *testing.T) { + repo := &grokBaseURLSettingRepoStub{values: map[string]string{SettingKeyGrokDefaultBaseURLMode: GrokDefaultBaseURLModeUSWest2}} + svc := NewSettingService(repo, nil) + account := &Account{Platform: PlatformGrok, Type: AccountTypeOAuth, Credentials: map[string]any{}} + require.Equal(t, xai.DefaultUSWest2BaseURL, svc.ResolveGrokBaseURL(context.Background(), account)) + + // An explicit official endpoint remains pinned. + account.Credentials["base_url"] = xai.DefaultBaseURL + require.Equal(t, xai.DefaultBaseURL, svc.ResolveGrokBaseURL(context.Background(), account)) + + // An explicit regional pin remains authoritative. + account.Credentials["base_url"] = xai.DefaultEUWest1BaseURL + require.Equal(t, xai.DefaultEUWest1BaseURL, svc.ResolveGrokBaseURL(context.Background(), account)) +} + +func TestAccountGetGrokBaseURLOrPreservesCustomOAuthURLForPolicyValidation(t *testing.T) { + account := &Account{Platform: PlatformGrok, Type: AccountTypeOAuth, Credentials: map[string]any{ + "base_url": "https://attacker.invalid/v1", + }} + require.Equal(t, "https://attacker.invalid/v1", account.GetGrokBaseURLOr(xai.DefaultCLIBaseURL)) +} diff --git a/backend/internal/service/grok_upstream_url.go b/backend/internal/service/grok_upstream_url.go index 6b757ba444..7d6b495198 100644 --- a/backend/internal/service/grok_upstream_url.go +++ b/backend/internal/service/grok_upstream_url.go @@ -1,6 +1,7 @@ package service import ( + "context" "errors" "fmt" "strings" @@ -68,20 +69,28 @@ func redactedGrokBaseURLValidator(validator xai.BaseURLValidator) xai.BaseURLVal } } -func buildGrokResponsesURL(account *Account, cfg *config.Config) (string, error) { +func buildGrokResponsesURL(account *Account, cfg *config.Config, settings ...*SettingService) (string, error) { validator, err := grokBaseURLValidator(account, cfg) if err != nil { return "", err } - return xai.BuildResponsesURLWithValidator(account.GetGrokBaseURL(), validator) + baseURL := account.GetGrokBaseURL() + if len(settings) > 0 && settings[0] != nil { + baseURL = settings[0].ResolveGrokBaseURL(context.Background(), account) + } + return xai.BuildResponsesURLWithValidator(baseURL, validator) } -func buildGrokChatCompletionsURL(account *Account, cfg *config.Config) (string, error) { +func buildGrokChatCompletionsURL(account *Account, cfg *config.Config, settings ...*SettingService) (string, error) { validator, err := grokBaseURLValidator(account, cfg) if err != nil { return "", err } - return xai.BuildChatCompletionsURLWithValidator(account.GetGrokBaseURL(), validator) + baseURL := account.GetGrokBaseURL() + if len(settings) > 0 && settings[0] != nil { + baseURL = settings[0].ResolveGrokBaseURL(context.Background(), account) + } + return xai.BuildChatCompletionsURLWithValidator(baseURL, validator) } // buildGrokBillingURL 解析 billing 探测端点:跟随账号的转发 base_url, @@ -91,7 +100,14 @@ func buildGrokBillingURL(account *Account, cfg *config.Config, weekly bool) (str if err != nil { return "", err } - return xai.BuildBillingURLWithValidator(account.GetGrokBaseURL(), weekly, validator) + baseURL := account.GetGrokBaseURL() + // Official public/regional API hosts do not expose Grok Build billing. + // Keep custom relays on their configured host because they may proxy the CLI + // billing path alongside inference. + if xai.IsOfficialBaseURL(baseURL) && !isGrokCLIProxyBaseURL(baseURL) { + baseURL = xai.DefaultCLIBaseURL + } + return xai.BuildBillingURLWithValidator(baseURL, weekly, validator) } func buildGrokMediaURL(account *Account, cfg *config.Config, endpoint GrokMediaEndpoint, requestID string) (string, error) { diff --git a/backend/internal/service/grok_upstream_url_test.go b/backend/internal/service/grok_upstream_url_test.go index e2f6e8bce5..8544823b4a 100644 --- a/backend/internal/service/grok_upstream_url_test.go +++ b/backend/internal/service/grok_upstream_url_test.go @@ -255,6 +255,26 @@ func TestGrokOAuthURLPolicy(t *testing.T) { }) } +func TestBuildGrokBillingURLUsesCLIForOfficialAPIHosts(t *testing.T) { + for _, baseURL := range []string{xai.DefaultBaseURL, "https://us-west-2.api.x.ai/v1"} { + account := &Account{Platform: PlatformGrok, Type: AccountTypeOAuth, Credentials: map[string]any{"base_url": baseURL}} + + weekly, err := buildGrokBillingURL(account, &config.Config{}, true) + require.NoError(t, err) + require.Equal(t, xai.DefaultCLIBaseURL+xai.BillingWeeklyPath, weekly) + } +} + +func TestBuildGrokBillingURLKeepsCustomRelay(t *testing.T) { + account := &Account{Platform: PlatformGrok, Type: AccountTypeOAuth, Credentials: map[string]any{ + "base_url": "https://relay.example.test/xai/v1", + }} + + monthly, err := buildGrokBillingURL(account, &config.Config{}, false) + require.NoError(t, err) + require.Equal(t, "https://relay.example.test/xai/v1"+xai.BillingMonthlyPath, monthly) +} + func TestGrokBillingURLFollowsAccountBaseURL(t *testing.T) { t.Run("oauth default stays on CLI gateway", func(t *testing.T) { account := &Account{ diff --git a/backend/internal/service/setting_parse.go b/backend/internal/service/setting_parse.go index d748e80061..28b55ee35a 100644 --- a/backend/internal/service/setting_parse.go +++ b/backend/internal/service/setting_parse.go @@ -191,6 +191,7 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error { // Grok: safe defaults — no cross-vendor model rewrite unless operators enable it. SettingKeyGrokDefaultTextModel: "grok-4.5", SettingKeyGrokCrossClientModelMapEnabled: "false", + SettingKeyGrokDefaultBaseURLMode: GrokDefaultBaseURLModeCLI, // Available channels feature (default disabled; opt-in) SettingKeyAvailableChannelsEnabled: "false", @@ -796,6 +797,7 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin result.GrokDefaultTextModel = "grok-4.5" } result.GrokCrossClientModelMapEnabled = settings[SettingKeyGrokCrossClientModelMapEnabled] == "true" + result.GrokDefaultBaseURLMode = normalizeGrokDefaultBaseURLMode(settings[SettingKeyGrokDefaultBaseURLMode]) // Available channels feature (default: disabled; strict true) result.AvailableChannelsEnabled = settings[SettingKeyAvailableChannelsEnabled] == "true" diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go index 42489ed53b..73bde4d30e 100644 --- a/backend/internal/service/setting_service.go +++ b/backend/internal/service/setting_service.go @@ -5,13 +5,83 @@ import ( "encoding/json" "errors" "fmt" + "strings" "sync/atomic" "github.com/Wei-Shaw/sub2api/internal/config" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/Wei-Shaw/sub2api/internal/pkg/xai" "golang.org/x/sync/singleflight" ) +const ( + GrokDefaultBaseURLModeAPI = "api" + GrokDefaultBaseURLModeUSEast1 = "us-east-1" + GrokDefaultBaseURLModeUSWest2 = "us-west-2" + GrokDefaultBaseURLModeEUWest1 = "eu-west-1" + GrokDefaultBaseURLModeCLI = "cli" +) + +func normalizeGrokDefaultBaseURLMode(mode string) string { + switch strings.ToLower(strings.TrimSpace(mode)) { + case GrokDefaultBaseURLModeAPI: + return GrokDefaultBaseURLModeAPI + case GrokDefaultBaseURLModeUSEast1: + return GrokDefaultBaseURLModeUSEast1 + case GrokDefaultBaseURLModeUSWest2: + return GrokDefaultBaseURLModeUSWest2 + case GrokDefaultBaseURLModeEUWest1: + return GrokDefaultBaseURLModeEUWest1 + case GrokDefaultBaseURLModeCLI: + return GrokDefaultBaseURLModeCLI + default: + return GrokDefaultBaseURLModeCLI + } +} + +func GrokBaseURLForMode(mode string) string { + switch normalizeGrokDefaultBaseURLMode(mode) { + case GrokDefaultBaseURLModeAPI: + return xai.DefaultBaseURL + case GrokDefaultBaseURLModeUSEast1: + return xai.DefaultUSEast1BaseURL + case GrokDefaultBaseURLModeUSWest2: + return xai.DefaultUSWest2BaseURL + case GrokDefaultBaseURLModeEUWest1: + return xai.DefaultEUWest1BaseURL + default: + return xai.DefaultCLIBaseURL + } +} + +func (s *SettingService) GetGrokDefaultBaseURLMode(ctx context.Context) string { + if s == nil || s.settingRepo == nil { + return GrokDefaultBaseURLModeCLI + } + dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), gatewayForwardingDBTimeout) + defer cancel() + raw, err := s.settingRepo.GetValue(dbCtx, SettingKeyGrokDefaultBaseURLMode) + if err != nil { + return GrokDefaultBaseURLModeCLI + } + return normalizeGrokDefaultBaseURLMode(raw) +} + +func (s *SettingService) GetGrokDefaultBaseURL(ctx context.Context) string { + return GrokBaseURLForMode(s.GetGrokDefaultBaseURLMode(ctx)) +} + +func (s *SettingService) ResolveGrokBaseURL(ctx context.Context, account *Account) string { + def := xai.DefaultCLIBaseURL + if s != nil { + def = s.GetGrokDefaultBaseURL(ctx) + } + if account == nil { + return def + } + return account.GetGrokBaseURLOr(def) +} + var ( ErrRegistrationDisabled = infraerrors.Forbidden("REGISTRATION_DISABLED", "registration is currently disabled") ErrSettingNotFound = infraerrors.NotFound("SETTING_NOT_FOUND", "setting not found") diff --git a/backend/internal/service/setting_update.go b/backend/internal/service/setting_update.go index 9270dead24..637e609b00 100644 --- a/backend/internal/service/setting_update.go +++ b/backend/internal/service/setting_update.go @@ -422,6 +422,7 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting updates[SettingKeyGrokDefaultTextModel] = "grok-4.5" } updates[SettingKeyGrokCrossClientModelMapEnabled] = strconv.FormatBool(settings.GrokCrossClientModelMapEnabled) + updates[SettingKeyGrokDefaultBaseURLMode] = normalizeGrokDefaultBaseURLMode(settings.GrokDefaultBaseURLMode) // Available channels feature switch updates[SettingKeyAvailableChannelsEnabled] = strconv.FormatBool(settings.AvailableChannelsEnabled) diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index d8563b937c..2b673a359c 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -202,6 +202,7 @@ type SystemSettings struct { // Grok model mapping policy (admin settings; empty mapping falls back to these). GrokDefaultTextModel string `json:"grok_default_text_model"` GrokCrossClientModelMapEnabled bool `json:"grok_cross_client_model_map_enabled"` + GrokDefaultBaseURLMode string `json:"grok_default_base_url_mode"` // Available Channels feature (user-facing aggregate view) AvailableChannelsEnabled bool `json:"available_channels_enabled"` @@ -375,6 +376,7 @@ type PublicSettings struct { // Grok model mapping policy (admin settings). GrokDefaultTextModel string `json:"grok_default_text_model"` GrokCrossClientModelMapEnabled bool `json:"grok_cross_client_model_map_enabled"` + GrokDefaultBaseURLMode string `json:"grok_default_base_url_mode"` // Available Channels feature (user-facing aggregate view) AvailableChannelsEnabled bool `json:"available_channels_enabled"` diff --git a/backend/internal/service/wire.go b/backend/internal/service/wire.go index 0a285d99b7..9e12ae0bed 100644 --- a/backend/internal/service/wire.go +++ b/backend/internal/service/wire.go @@ -229,6 +229,7 @@ func ProvideAccountTestService( cfg *config.Config, tlsFPProfileService *TLSFingerprintProfileService, openAIGatewayService *OpenAIGatewayService, + settingService *SettingService, ) *AccountTestService { service := NewAccountTestService( accountRepo, @@ -241,6 +242,7 @@ func ProvideAccountTestService( tlsFPProfileService, ) service.agentIdentityWS = openAIGatewayService + service.SetSettingService(settingService) return service } @@ -251,8 +253,11 @@ func ProvideGrokQuotaService( httpUpstream HTTPUpstream, cfg *config.Config, usageLogRepo UsageLogRepository, + settingService *SettingService, ) *GrokQuotaService { - return NewGrokQuotaService(accountRepo, proxyRepo, tokenProvider, httpUpstream, cfg, usageLogRepo) + service := NewGrokQuotaService(accountRepo, proxyRepo, tokenProvider, httpUpstream, cfg, usageLogRepo) + service.SetSettingService(settingService) + return service } // ProvideGeminiTokenProvider creates GeminiTokenProvider with OAuthRefreshAPI injection diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index d9d68f4659..fda6476a7f 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -444,9 +444,9 @@ gateway: # Enabled by default because free detection requires an explicit subscription_tier/plan_type of "free". # Stats/query failures fail open so DB issues do not block all Grok traffic. grok: - # Experimental admin-only email/password authorization. Disabled by default. - # Requires YESCAPTCHA_CLIENT_KEY (or legacy YESCAPTCHA_API_KEY) in the server environment. - # TurnstileTaskProxyless uses solver egress independent from the optional account proxy. + # Email/password authorization is hidden and hard-disabled. Use SSO cookie, + # browser OAuth, or refresh_token re-auth instead. The flag is retained for + # config compatibility only and is ignored by the server. password_auth_enabled: false free_quota_soft_gate_enabled: true free_quota_token_limit: 1000000 diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index 434e0b9214..4a839a9198 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -590,6 +590,7 @@ export interface SystemSettings { fallback_model_antigravity: string; grok_default_text_model: string; grok_cross_client_model_map_enabled: boolean; + grok_default_base_url_mode: string; // Per-platform account auto-pause thresholds (100 = disabled) account_scheduling_thresholds: AccountSchedulingThresholdsMap; @@ -911,6 +912,7 @@ export interface UpdateSettingsRequest { fallback_model_antigravity?: string; grok_default_text_model?: string; grok_cross_client_model_map_enabled?: boolean; + grok_default_base_url_mode?: string; account_scheduling_thresholds?: AccountSchedulingThresholdsMap; enable_identity_patch?: boolean; identity_patch_prompt?: string; diff --git a/frontend/src/i18n/locales/en/admin/settings.ts b/frontend/src/i18n/locales/en/admin/settings.ts index f7f484e08b..499fd4fe2e 100644 --- a/frontend/src/i18n/locales/en/admin/settings.ts +++ b/frontend/src/i18n/locales/en/admin/settings.ts @@ -445,6 +445,13 @@ export default { grokDefaultTextModelHint: 'Used for empty model values and, only when the switch is enabled, requests from other client model namespaces. Custom Grok model IDs are accepted.', grokCrossClientMap: 'Map other clients to Grok', grokCrossClientMapHint: 'Disabled by default. When enabled, GPT, Codex, o-series, and Claude model IDs are routed to the default Grok text model above.', + grokDefaultBaseURLMode: 'Default Grok upstream', + grokDefaultBaseURLModeHint: 'Used only when a Grok account has no explicit base URL. Media and voice endpoints continue to use their official API hosts.', + grokBaseURLModeCLI: 'CLI chat proxy', + grokBaseURLModeAPI: 'Public API', + grokBaseURLModeUSEast1: 'Regional API (us-east-1)', + grokBaseURLModeUSWest2: 'Regional API (us-west-2)', + grokBaseURLModeEUWest1: 'Regional API (eu-west-1)', fingerprintUnification: 'Fingerprint Unification', fingerprintUnificationHint: 'Unify X-Stainless-* headers across users sharing the same OAuth account. Disabling passes through each client\'s original headers.', metadataPassthrough: 'Metadata Passthrough', diff --git a/frontend/src/i18n/locales/zh/admin/settings.ts b/frontend/src/i18n/locales/zh/admin/settings.ts index c838a85c34..5a8d69d2df 100644 --- a/frontend/src/i18n/locales/zh/admin/settings.ts +++ b/frontend/src/i18n/locales/zh/admin/settings.ts @@ -438,6 +438,13 @@ export default { grokDefaultTextModelHint: '用于空模型值;仅在右侧开关开启时也用于其他客户端模型命名空间。允许填写自定义 Grok 模型 ID。', grokCrossClientMap: '映射其他客户端模型到 Grok', grokCrossClientMapHint: '默认关闭。开启后,GPT、Codex、o 系列和 Claude 模型 ID 会路由到左侧默认 Grok 文本模型。', + grokDefaultBaseURLMode: '默认 Grok 上游', + grokDefaultBaseURLModeHint: '仅用于 Grok 账号未配置显式 base URL 的文本请求;媒体和语音仍使用官方 API 主机。', + grokBaseURLModeCLI: 'CLI 聊天代理', + grokBaseURLModeAPI: '公共 API', + grokBaseURLModeUSEast1: '区域 API(us-east-1)', + grokBaseURLModeUSWest2: '区域 API(us-west-2)', + grokBaseURLModeEUWest1: '区域 API(eu-west-1)', fingerprintUnification: '指纹统一化', fingerprintUnificationHint: '统一共享同一 OAuth 账号的用户的 X-Stainless-* 请求头。关闭后透传客户端原始请求头。', metadataPassthrough: 'Metadata 透传', diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 9ef890e248..73162aaa13 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -991,6 +991,9 @@ export interface TempUnschedulableState { matched_keyword: string rule_index: number error_message: string + trigger_count?: number + trigger_threshold?: number + trigger_window_minutes?: number } export interface TempUnschedulableStatus { diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index 9aec7eb274..af3f469332 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -5178,7 +5178,30 @@ data-testid="grok-cross-client-model-map-toggle" /> - + +
+ + +

+ {{ t("admin.settings.gatewayForwarding.grokDefaultBaseURLModeHint") }} +

+
@@ -9520,6 +9543,7 @@ const form = reactive({ fallback_model_antigravity: "gemini-2.5-pro", grok_default_text_model: "grok-4.5", grok_cross_client_model_map_enabled: false, + grok_default_base_url_mode: "cli", // Identity patch (Claude -> Gemini) enable_identity_patch: true, identity_patch_prompt: "", @@ -11102,6 +11126,7 @@ async function saveSettings() { form.grok_default_text_model.trim() || "grok-4.5", grok_cross_client_model_map_enabled: form.grok_cross_client_model_map_enabled, + grok_default_base_url_mode: form.grok_default_base_url_mode, enable_identity_patch: form.enable_identity_patch, identity_patch_prompt: form.identity_patch_prompt, min_claude_code_version: form.min_claude_code_version,