From f2966530c52a2aa4b4f4b507592f4bd258837eb3 Mon Sep 17 00:00:00 2001 From: Lyonle <214648221+lyon-le@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:16:09 +0800 Subject: [PATCH] =?UTF-8?q?feat(openai):=20=E6=94=AF=E6=8C=81=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E7=BA=A7=20Fast/Flex=20=E7=AD=96=E7=95=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../handler/admin/admin_helpers_test.go | 2 + backend/internal/handler/dto/settings.go | 1 + backend/internal/pkg/ctxkey/ctxkey.go | 4 + .../server/middleware/api_key_auth.go | 2 + .../server/middleware/api_key_auth_test.go | 5 + .../openai_fast_policy_forwarding_test.go | 189 ++++++++++++++++++ .../service/openai_fast_policy_test.go | 86 ++++++++ .../service/openai_fast_policy_ws_test.go | 34 ++++ .../service/openai_gateway_request_body.go | 69 +++++-- backend/internal/service/setting_features.go | 10 + backend/internal/service/settings_view.go | 1 + frontend/src/api/admin/settings.ts | 1 + .../src/i18n/locales/en/admin/settings.ts | 5 + .../src/i18n/locales/zh/admin/settings.ts | 5 + frontend/src/views/admin/SettingsView.vue | 85 ++++++++ 15 files changed, 482 insertions(+), 17 deletions(-) create mode 100644 backend/internal/server/middleware/openai_fast_policy_forwarding_test.go diff --git a/backend/internal/handler/admin/admin_helpers_test.go b/backend/internal/handler/admin/admin_helpers_test.go index 6df4915486..c0775db6b6 100644 --- a/backend/internal/handler/admin/admin_helpers_test.go +++ b/backend/internal/handler/admin/admin_helpers_test.go @@ -265,10 +265,12 @@ func TestOpenAIFastPolicySettingsFromDTO_NormalizesServiceTier(t *testing.T) { ServiceTier: "PRIORITY", Action: "filter", Scope: "all", + UserIDs: []int64{42}, }}, } out := openaiFastPolicySettingsFromDTO(in) require.Equal(t, service.OpenAIFastTierPriority, out.Rules[0].ServiceTier) + require.Equal(t, []int64{42}, out.Rules[0].UserIDs) }) t.Run("non-empty values pass through (lowercased)", func(t *testing.T) { diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index 99fba54980..5b7e657f26 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -422,6 +422,7 @@ type OpenAIFastPolicyRule struct { ServiceTier string `json:"service_tier"` Action string `json:"action"` Scope string `json:"scope"` + UserIDs []int64 `json:"user_ids,omitempty"` ErrorMessage string `json:"error_message,omitempty"` ModelWhitelist []string `json:"model_whitelist,omitempty"` FallbackAction string `json:"fallback_action,omitempty"` diff --git a/backend/internal/pkg/ctxkey/ctxkey.go b/backend/internal/pkg/ctxkey/ctxkey.go index dacd1bd1cf..9ba397a12c 100644 --- a/backend/internal/pkg/ctxkey/ctxkey.go +++ b/backend/internal/pkg/ctxkey/ctxkey.go @@ -41,6 +41,10 @@ const ( // Group 认证后的分组信息,由 API Key 认证中间件设置 Group Key = "ctx_group" + // UserID 认证后的 Sub2API 用户 ID,由 API Key 认证中间件设置。 + // 供 service 层执行用户级策略,不能使用客户端请求体中的 user 标识替代。 + UserID Key = "ctx_user_id" + // IsMaxTokensOneHaikuRequest 标识当前请求是否为 max_tokens=1 + haiku 模型的探测请求 // 用于 ClaudeCodeOnly 验证绕过(绕过 system prompt 检查,但仍需验证 User-Agent) IsMaxTokensOneHaikuRequest Key = "ctx_is_max_tokens_one_haiku" diff --git a/backend/internal/server/middleware/api_key_auth.go b/backend/internal/server/middleware/api_key_auth.go index 04a09862b5..082766bfd5 100644 --- a/backend/internal/server/middleware/api_key_auth.go +++ b/backend/internal/server/middleware/api_key_auth.go @@ -126,6 +126,8 @@ func apiKeyAuthWithSubscription(apiKeyService *service.APIKeyService, subscripti if abortIfAPIKeyGroupNotAllowed(c, apiKey) { return } + ctx := context.WithValue(c.Request.Context(), ctxkey.UserID, apiKey.User.ID) + c.Request = c.Request.WithContext(ctx) // ── 4. SimpleMode → early return ───────────────────────────── diff --git a/backend/internal/server/middleware/api_key_auth_test.go b/backend/internal/server/middleware/api_key_auth_test.go index abb84ab852..d9b5c7efc1 100644 --- a/backend/internal/server/middleware/api_key_auth_test.go +++ b/backend/internal/server/middleware/api_key_auth_test.go @@ -286,6 +286,11 @@ func TestAPIKeyAuthSetsGroupContext(t *testing.T) { c.JSON(http.StatusInternalServerError, gin.H{"ok": false}) return } + userIDFromCtx, ok := c.Request.Context().Value(ctxkey.UserID).(int64) + if !ok || userIDFromCtx != user.ID { + c.JSON(http.StatusInternalServerError, gin.H{"ok": false}) + return + } c.JSON(http.StatusOK, gin.H{"ok": true}) }) diff --git a/backend/internal/server/middleware/openai_fast_policy_forwarding_test.go b/backend/internal/server/middleware/openai_fast_policy_forwarding_test.go new file mode 100644 index 0000000000..5fea5547a3 --- /dev/null +++ b/backend/internal/server/middleware/openai_fast_policy_forwarding_test.go @@ -0,0 +1,189 @@ +package middleware + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestAPIKeyAuthForwardsUserScopedOpenAIFastPolicyToUpstream(t *testing.T) { + gin.SetMode(gin.TestMode) + + upstreamBodies := make(chan []byte, 2) + upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "read request body", http.StatusInternalServerError) + return + } + upstreamBodies <- body + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"resp_test","object":"response","model":"gpt-5","status":"completed","usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}`)) + })) + defer upstreamServer.Close() + + settings := &service.OpenAIFastPolicySettings{ + Rules: []service.OpenAIFastPolicyRule{ + { + ServiceTier: service.OpenAIFastTierPriority, + Action: service.BetaPolicyActionFilter, + Scope: service.BetaPolicyScopeAll, + }, + { + ServiceTier: service.OpenAIFastTierPriority, + Action: service.BetaPolicyActionPass, + Scope: service.BetaPolicyScopeAll, + UserIDs: []int64{42}, + }, + }, + } + settingsJSON, err := json.Marshal(settings) + require.NoError(t, err) + + cfg := &config.Config{RunMode: config.RunModeSimple} + cfg.Security.URLAllowlist.Enabled = false + cfg.Security.URLAllowlist.AllowInsecureHTTP = true + + settingService := service.NewSettingService(&openAIFastPolicyForwardingSettingRepo{ + value: string(settingsJSON), + }, cfg) + gatewayService := service.NewOpenAIGatewayService( + nil, nil, nil, nil, nil, nil, nil, cfg, + nil, nil, nil, nil, nil, &openAIFastPolicyForwardingHTTPUpstream{client: upstreamServer.Client()}, + nil, nil, nil, nil, nil, nil, settingService, nil, + ) + + groupID := int64(101) + group := &service.Group{ + ID: groupID, + Name: "openai", + Status: service.StatusActive, + Platform: service.PlatformOpenAI, + Hydrated: true, + } + apiKeys := map[string]*service.APIKey{ + "key-user-42": newOpenAIFastPolicyForwardingAPIKey(1, "key-user-42", 42, groupID, group), + "key-user-43": newOpenAIFastPolicyForwardingAPIKey(2, "key-user-43", 43, groupID, group), + } + apiKeyService := service.NewAPIKeyService(&openAIFastPolicyForwardingAPIKeyRepo{apiKeys: apiKeys}, nil, nil, nil, nil, nil, cfg) + account := &service.Account{ + ID: 900, + Name: "openai-upstream", + Platform: service.PlatformOpenAI, + Type: service.AccountTypeAPIKey, + Status: service.StatusActive, + Schedulable: true, + Concurrency: 1, + Credentials: map[string]any{ + "api_key": "sk-test", + "base_url": upstreamServer.URL, + }, + Extra: map[string]any{"use_responses_api": true}, + } + + router := gin.New() + router.Use(gin.HandlerFunc(NewAPIKeyAuthMiddleware(apiKeyService, nil, cfg))) + router.POST("/v1/responses", func(c *gin.Context) { + body, readErr := io.ReadAll(c.Request.Body) + if readErr != nil { + c.Status(http.StatusBadRequest) + return + } + service.SetOpenAIClientTransport(c, service.OpenAIClientTransportHTTP) + if _, forwardErr := gatewayService.Forward(c.Request.Context(), c, account, body); forwardErr != nil { + c.Status(http.StatusBadGateway) + return + } + c.Status(http.StatusOK) + }) + + send := func(apiKey string) { + request := httptest.NewRequest( + http.MethodPost, + "/v1/responses", + bytes.NewBufferString(`{"model":"gpt-5","stream":false,"service_tier":"priority","input":"hi"}`), + ) + request.Header.Set("Content-Type", "application/json") + request.Header.Set("x-api-key", apiKey) + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + require.Equal(t, http.StatusOK, response.Code) + } + + send("key-user-42") + send("key-user-43") + + allowedUserBody := <-upstreamBodies + otherUserBody := <-upstreamBodies + require.Equal(t, service.OpenAIFastTierPriority, gjson.GetBytes(allowedUserBody, "service_tier").String()) + require.False(t, gjson.GetBytes(otherUserBody, "service_tier").Exists()) +} + +func newOpenAIFastPolicyForwardingAPIKey(id int64, key string, userID, groupID int64, group *service.Group) *service.APIKey { + return &service.APIKey{ + ID: id, + UserID: userID, + Key: key, + Status: service.StatusActive, + GroupID: &groupID, + User: &service.User{ + ID: userID, + Role: service.RoleUser, + Status: service.StatusActive, + Balance: 10, + Concurrency: 1, + }, + Group: group, + } +} + +type openAIFastPolicyForwardingAPIKeyRepo struct { + service.APIKeyRepository + apiKeys map[string]*service.APIKey +} + +func (r *openAIFastPolicyForwardingAPIKeyRepo) GetByKeyForAuth(_ context.Context, key string) (*service.APIKey, error) { + apiKey, ok := r.apiKeys[key] + if !ok { + return nil, service.ErrAPIKeyNotFound + } + clone := *apiKey + return &clone, nil +} + +func (r *openAIFastPolicyForwardingAPIKeyRepo) UpdateLastUsed(context.Context, int64, time.Time) error { + return nil +} + +type openAIFastPolicyForwardingSettingRepo struct { + service.SettingRepository + value string +} + +func (r *openAIFastPolicyForwardingSettingRepo) GetValue(context.Context, string) (string, error) { + return r.value, nil +} + +type openAIFastPolicyForwardingHTTPUpstream struct { + client *http.Client +} + +func (u *openAIFastPolicyForwardingHTTPUpstream) Do(req *http.Request, _ string, _ int64, _ int) (*http.Response, error) { + return u.client.Do(req) +} + +func (u *openAIFastPolicyForwardingHTTPUpstream) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, _ *tlsfingerprint.Profile) (*http.Response, error) { + return u.Do(req, proxyURL, accountID, accountConcurrency) +} diff --git a/backend/internal/service/openai_fast_policy_test.go b/backend/internal/service/openai_fast_policy_test.go index d0be963e5e..c5144b6a02 100644 --- a/backend/internal/service/openai_fast_policy_test.go +++ b/backend/internal/service/openai_fast_policy_test.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey" "github.com/stretchr/testify/require" "github.com/tidwall/gjson" ) @@ -138,6 +139,37 @@ func TestEvaluateOpenAIFastPolicy_ScopeFiltersOAuth(t *testing.T) { require.Equal(t, BetaPolicyActionPass, action) } +func TestEvaluateOpenAIFastPolicy_UserScopedRuleOverridesGlobalRule(t *testing.T) { + settings := &OpenAIFastPolicySettings{ + Rules: []OpenAIFastPolicyRule{ + { + ServiceTier: OpenAIFastTierPriority, + Action: BetaPolicyActionFilter, + Scope: BetaPolicyScopeAll, + }, + { + ServiceTier: OpenAIFastTierPriority, + Action: BetaPolicyActionPass, + Scope: BetaPolicyScopeAll, + UserIDs: []int64{42}, + }, + }, + } + svc := newOpenAIGatewayServiceWithSettings(t, settings) + account := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey} + + allowedUserCtx := context.WithValue(context.Background(), ctxkey.UserID, int64(42)) + action, _ := svc.evaluateOpenAIFastPolicy(allowedUserCtx, account, "gpt-5.5", OpenAIFastTierPriority) + require.Equal(t, BetaPolicyActionPass, action) + + otherUserCtx := context.WithValue(context.Background(), ctxkey.UserID, int64(43)) + action, _ = svc.evaluateOpenAIFastPolicy(otherUserCtx, account, "gpt-5.5", OpenAIFastTierPriority) + require.Equal(t, BetaPolicyActionFilter, action) + + action, _ = svc.evaluateOpenAIFastPolicy(context.Background(), account, "gpt-5.5", OpenAIFastTierPriority) + require.Equal(t, BetaPolicyActionFilter, action) +} + func TestApplyOpenAIFastPolicyToBody_DefaultPassesPriorityAndFast(t *testing.T) { svc := newOpenAIGatewayServiceWithSettings(t, DefaultOpenAIFastPolicySettings()) account := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey} @@ -179,6 +211,37 @@ func TestApplyOpenAIFastPolicyToBody_ExplicitFilterRemovesField(t *testing.T) { require.NotContains(t, string(updated), `"service_tier"`) } +func TestApplyOpenAIFastPolicyToBody_UserScopedRuleOverridesGlobalRule(t *testing.T) { + settings := &OpenAIFastPolicySettings{ + Rules: []OpenAIFastPolicyRule{ + { + ServiceTier: OpenAIFastTierPriority, + Action: BetaPolicyActionFilter, + Scope: BetaPolicyScopeAll, + }, + { + ServiceTier: OpenAIFastTierPriority, + Action: BetaPolicyActionPass, + Scope: BetaPolicyScopeAll, + UserIDs: []int64{42}, + }, + }, + } + svc := newOpenAIGatewayServiceWithSettings(t, settings) + account := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey} + body := []byte(`{"model":"gpt-5.5","service_tier":"priority"}`) + + allowedUserCtx := context.WithValue(context.Background(), ctxkey.UserID, int64(42)) + updated, err := svc.applyOpenAIFastPolicyToBody(allowedUserCtx, account, "gpt-5.5", body) + require.NoError(t, err) + require.Equal(t, "priority", gjson.GetBytes(updated, "service_tier").String()) + + otherUserCtx := context.WithValue(context.Background(), ctxkey.UserID, int64(43)) + updated, err = svc.applyOpenAIFastPolicyToBody(otherUserCtx, account, "gpt-5.5", body) + require.NoError(t, err) + require.NotContains(t, string(updated), `"service_tier"`) +} + func TestApplyOpenAIFastPolicyToBody_ForcePriorityRewritesKnownTier(t *testing.T) { settings := &OpenAIFastPolicySettings{ Rules: []OpenAIFastPolicyRule{{ @@ -309,12 +372,34 @@ func TestSetOpenAIFastPolicySettings_Validation(t *testing.T) { }) require.Error(t, err) + // Non-positive and duplicate user IDs are rejected. + err = svc.SetOpenAIFastPolicySettings(context.Background(), &OpenAIFastPolicySettings{ + Rules: []OpenAIFastPolicyRule{{ + ServiceTier: OpenAIFastTierPriority, + Action: BetaPolicyActionPass, + Scope: BetaPolicyScopeAll, + UserIDs: []int64{0}, + }}, + }) + require.Error(t, err) + + err = svc.SetOpenAIFastPolicySettings(context.Background(), &OpenAIFastPolicySettings{ + Rules: []OpenAIFastPolicyRule{{ + ServiceTier: OpenAIFastTierPriority, + Action: BetaPolicyActionPass, + Scope: BetaPolicyScopeAll, + UserIDs: []int64{42, 42}, + }}, + }) + require.Error(t, err) + // Valid settings persisted err = svc.SetOpenAIFastPolicySettings(context.Background(), &OpenAIFastPolicySettings{ Rules: []OpenAIFastPolicyRule{{ ServiceTier: OpenAIFastTierPriority, Action: OpenAIFastPolicyActionForcePriority, Scope: BetaPolicyScopeAll, + UserIDs: []int64{42, 43}, }}, }) require.NoError(t, err) @@ -324,4 +409,5 @@ func TestSetOpenAIFastPolicySettings_Validation(t *testing.T) { require.Len(t, got.Rules, 1) require.Equal(t, OpenAIFastTierPriority, got.Rules[0].ServiceTier) require.Equal(t, OpenAIFastPolicyActionForcePriority, got.Rules[0].Action) + require.Equal(t, []int64{42, 43}, got.Rules[0].UserIDs) } diff --git a/backend/internal/service/openai_fast_policy_ws_test.go b/backend/internal/service/openai_fast_policy_ws_test.go index a802540879..f624a0080b 100644 --- a/backend/internal/service/openai_fast_policy_ws_test.go +++ b/backend/internal/service/openai_fast_policy_ws_test.go @@ -14,6 +14,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" "github.com/Wei-Shaw/sub2api/internal/pkg/claude" + "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey" coderws "github.com/coder/websocket" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" @@ -67,6 +68,39 @@ func TestWSResponseCreate_ExplicitFilterStripsServiceTier(t *testing.T) { require.NotContains(t, string(updated), `"service_tier"`) } +func TestWSResponseCreate_UserScopedRuleOverridesGlobalRule(t *testing.T) { + settings := &OpenAIFastPolicySettings{ + Rules: []OpenAIFastPolicyRule{ + { + ServiceTier: OpenAIFastTierPriority, + Action: BetaPolicyActionFilter, + Scope: BetaPolicyScopeAll, + }, + { + ServiceTier: OpenAIFastTierPriority, + Action: BetaPolicyActionPass, + Scope: BetaPolicyScopeAll, + UserIDs: []int64{42}, + }, + }, + } + svc := newOpenAIGatewayServiceWithSettings(t, settings) + account := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey} + frame := []byte(`{"type":"response.create","model":"gpt-5.5","service_tier":"priority"}`) + + allowedUserCtx := context.WithValue(context.Background(), ctxkey.UserID, int64(42)) + updated, blocked, err := svc.applyOpenAIFastPolicyToWSResponseCreate(allowedUserCtx, account, "gpt-5.5", frame) + require.NoError(t, err) + require.Nil(t, blocked) + require.Equal(t, "priority", gjson.GetBytes(updated, "service_tier").String()) + + otherUserCtx := context.WithValue(context.Background(), ctxkey.UserID, int64(43)) + updated, blocked, err = svc.applyOpenAIFastPolicyToWSResponseCreate(otherUserCtx, account, "gpt-5.5", frame) + require.NoError(t, err) + require.Nil(t, blocked) + require.NotContains(t, string(updated), `"service_tier"`) +} + func TestWSResponseCreate_ForcePriorityRewritesKnownTier(t *testing.T) { settings := &OpenAIFastPolicySettings{ Rules: []OpenAIFastPolicyRule{{ diff --git a/backend/internal/service/openai_gateway_request_body.go b/backend/internal/service/openai_gateway_request_body.go index 0e888e145b..935a32f58b 100644 --- a/backend/internal/service/openai_gateway_request_body.go +++ b/backend/internal/service/openai_gateway_request_body.go @@ -9,6 +9,7 @@ import ( "net/http" "strings" + "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey" "github.com/Wei-Shaw/sub2api/internal/util/urlvalidator" "github.com/gin-gonic/gin" "github.com/google/uuid" @@ -659,9 +660,12 @@ func (e *OpenAIFastBlockedError) Error() string { return e.Message } // // Matching rules: // - Scope filters by account type (all / oauth / apikey / bedrock) +// - UserIDs, when present, filters by the trusted Sub2API user that owns the API key // - ServiceTier must be empty (= any), "all", or equal the normalized tier // - ModelWhitelist narrows the rule to specific models; FallbackAction // handles the non-matching case (default: pass) +// - User-specific rules take precedence over global rules; each group keeps +// the configured first-match order // // 与 Claude BetaPolicy 的差异(保留首条匹配 short-circuit): // - BetaPolicy 处理的是 anthropic-beta header 中的 token 集合,不同 @@ -687,39 +691,70 @@ func (s *OpenAIGatewayService) evaluateOpenAIFastPolicy(ctx context.Context, acc } settings = fetched } - return evaluateOpenAIFastPolicyWithSettings(settings, account, model, tier) + return evaluateOpenAIFastPolicyWithSettings(settings, openAIFastPolicyUserID(ctx), account, model, tier) } // evaluateOpenAIFastPolicyWithSettings is the pure-function core extracted so // long-lived sessions (e.g. WS) can prefetch settings once and avoid hitting // the settingService on every frame. See WSSession entry and // openAIFastPolicySettingsFromContext for the caching glue. -func evaluateOpenAIFastPolicyWithSettings(settings *OpenAIFastPolicySettings, account *Account, model, tier string) (action, errMsg string) { +func evaluateOpenAIFastPolicyWithSettings(settings *OpenAIFastPolicySettings, userID int64, account *Account, model, tier string) (action, errMsg string) { if settings == nil { return BetaPolicyActionPass, "" } isOAuth := account != nil && account.IsOAuth() isBedrock := account != nil && account.IsBedrock() - for _, rule := range settings.Rules { - if !betaPolicyScopeMatches(rule.Scope, isOAuth, isBedrock) { - continue + + // 用户专属规则先于全局规则。规则组内仍按配置顺序首条命中,允许 + // 管理员为某位用户配置例外,而不被先出现的全局规则覆盖。 + for _, userScoped := range []bool{true, false} { + for _, rule := range settings.Rules { + if (len(rule.UserIDs) > 0) != userScoped || !openAIFastPolicyUserMatches(rule.UserIDs, userID) { + continue + } + if !betaPolicyScopeMatches(rule.Scope, isOAuth, isBedrock) { + continue + } + ruleTier := strings.ToLower(strings.TrimSpace(rule.ServiceTier)) + if ruleTier != "" && ruleTier != OpenAIFastTierAny && ruleTier != tier { + continue + } + eff := BetaPolicyRule{ + Action: rule.Action, + ErrorMessage: rule.ErrorMessage, + ModelWhitelist: rule.ModelWhitelist, + FallbackAction: rule.FallbackAction, + FallbackErrorMessage: rule.FallbackErrorMessage, + } + return resolveRuleAction(eff, model) } - ruleTier := strings.ToLower(strings.TrimSpace(rule.ServiceTier)) - if ruleTier != "" && ruleTier != OpenAIFastTierAny && ruleTier != tier { - continue - } - eff := BetaPolicyRule{ - Action: rule.Action, - ErrorMessage: rule.ErrorMessage, - ModelWhitelist: rule.ModelWhitelist, - FallbackAction: rule.FallbackAction, - FallbackErrorMessage: rule.FallbackErrorMessage, - } - return resolveRuleAction(eff, model) } return BetaPolicyActionPass, "" } +func openAIFastPolicyUserID(ctx context.Context) int64 { + if ctx == nil { + return 0 + } + userID, _ := ctx.Value(ctxkey.UserID).(int64) + if userID <= 0 { + return 0 + } + return userID +} + +func openAIFastPolicyUserMatches(ruleUserIDs []int64, userID int64) bool { + if len(ruleUserIDs) == 0 { + return true + } + for _, ruleUserID := range ruleUserIDs { + if ruleUserID == userID { + return true + } + } + return false +} + // openAIFastPolicyCtxKey 是 context 中预取的 OpenAIFastPolicySettings 缓存 // 键,仅用于 WebSocket 长会话内多帧复用同一份策略快照,避免每帧 DB 命中。 // diff --git a/backend/internal/service/setting_features.go b/backend/internal/service/setting_features.go index 23dc32efa9..57c036861e 100644 --- a/backend/internal/service/setting_features.go +++ b/backend/internal/service/setting_features.go @@ -801,6 +801,16 @@ func (s *SettingService) SetOpenAIFastPolicySettings(ctx context.Context, settin if !validScopes[rule.Scope] { return fmt.Errorf("rule[%d]: invalid scope %q", i, rule.Scope) } + seenUserIDs := make(map[int64]struct{}, len(rule.UserIDs)) + for j, userID := range rule.UserIDs { + if userID <= 0 { + return fmt.Errorf("rule[%d]: user_ids[%d] must be positive", i, j) + } + if _, exists := seenUserIDs[userID]; exists { + return fmt.Errorf("rule[%d]: user_ids[%d] duplicates user_id %d", i, j, userID) + } + seenUserIDs[userID] = struct{}{} + } for j, pattern := range rule.ModelWhitelist { trimmed := strings.TrimSpace(pattern) if trimmed == "" { diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index 9357c0c1e1..285df80ba3 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -586,6 +586,7 @@ type OpenAIFastPolicyRule struct { ServiceTier string `json:"service_tier"` // "priority" | "flex" | "auto" | "default" | "scale" | "all" Action string `json:"action"` // "pass" | "filter" | "block" | "force_priority" Scope string `json:"scope"` // "all" | "oauth" | "apikey" | "bedrock" + UserIDs []int64 `json:"user_ids,omitempty"` // 空=所有 Sub2API 用户;非空=仅指定 API Key 所属用户 ErrorMessage string `json:"error_message,omitempty"` // 自定义错误消息 (action=block 时生效) ModelWhitelist []string `json:"model_whitelist,omitempty"` // 模型匹配模式列表(为空=对所有模型生效) FallbackAction string `json:"fallback_action,omitempty"` // 未匹配白名单的模型的处理方式 diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index 6f69163994..55c8088be1 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -1275,6 +1275,7 @@ export interface OpenAIFastPolicyRule { service_tier: "all" | "priority" | "flex"; action: "pass" | "filter" | "block" | "force_priority"; scope: "all" | "oauth" | "apikey" | "bedrock"; + user_ids?: number[]; error_message?: string; model_whitelist?: string[]; fallback_action?: "pass" | "filter" | "block" | "force_priority"; diff --git a/frontend/src/i18n/locales/en/admin/settings.ts b/frontend/src/i18n/locales/en/admin/settings.ts index d3d3f41ac9..37dc8aa1c8 100644 --- a/frontend/src/i18n/locales/en/admin/settings.ts +++ b/frontend/src/i18n/locales/en/admin/settings.ts @@ -979,6 +979,11 @@ export default { scopeOAuth: 'OAuth only', scopeAPIKey: 'API Key only', scopeBedrock: 'Bedrock only', + userIds: 'Specific user IDs', + userIdsHint: 'Leave empty to apply to all Sub2API users. Specified users match requests from their API keys and take precedence over global rules.', + userIdPlaceholder: 'e.g., 1001', + addUserId: 'Add user ID', + removeUserId: 'Remove user ID', errorMessage: 'Error message', errorMessagePlaceholder: 'Custom error message when blocked', errorMessageHint: 'Leave empty for default message', diff --git a/frontend/src/i18n/locales/zh/admin/settings.ts b/frontend/src/i18n/locales/zh/admin/settings.ts index bf848d1cc3..5c0d874b57 100644 --- a/frontend/src/i18n/locales/zh/admin/settings.ts +++ b/frontend/src/i18n/locales/zh/admin/settings.ts @@ -974,6 +974,11 @@ export default { scopeOAuth: '仅 OAuth 账号', scopeAPIKey: '仅 API Key 账号', scopeBedrock: '仅 Bedrock 账号', + userIds: '指定用户 ID', + userIdsHint: '留空表示对全部 Sub2API 用户生效。指定后仅匹配这些用户的 API Key 请求,且优先于全局规则。', + userIdPlaceholder: '例如: 1001', + addUserId: '添加用户 ID', + removeUserId: '移除用户 ID', errorMessage: '错误消息', errorMessagePlaceholder: '拦截时返回的自定义错误消息', errorMessageHint: '留空则使用默认错误消息', diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index 71db9cd4b0..a87cea9060 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -1189,6 +1189,72 @@ + +
+ +

+ {{ t("admin.settings.openaiFastPolicy.userIdsHint") }} +

+
+ + +
+ +
+