mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-01 15:02:58 +08:00
feat(openai): 支持用户级 Fast/Flex 策略
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 ─────────────────────────────
|
||||
|
||||
|
||||
@@ -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})
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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{{
|
||||
|
||||
@@ -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 命中。
|
||||
//
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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"` // 未匹配白名单的模型的处理方式
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '留空则使用默认错误消息',
|
||||
|
||||
@@ -1189,6 +1189,72 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User Scope -->
|
||||
<div class="mt-3">
|
||||
<label
|
||||
class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400"
|
||||
>
|
||||
{{ t("admin.settings.openaiFastPolicy.userIds") }}
|
||||
</label>
|
||||
<p class="mb-2 text-xs text-gray-400 dark:text-gray-500">
|
||||
{{ t("admin.settings.openaiFastPolicy.userIdsHint") }}
|
||||
</p>
|
||||
<div
|
||||
v-for="(_, userIDIndex) in rule.user_ids || []"
|
||||
:key="userIDIndex"
|
||||
class="mb-1.5 flex items-center gap-2"
|
||||
>
|
||||
<input
|
||||
v-model.number="rule.user_ids![userIDIndex]"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
class="input input-sm flex-1"
|
||||
:placeholder="t('admin.settings.openaiFastPolicy.userIdPlaceholder')"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@click="removeOpenAIFastPolicyUserID(rule, userIDIndex)"
|
||||
class="shrink-0 rounded p-1 text-red-400 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
|
||||
:title="t('admin.settings.openaiFastPolicy.removeUserId')"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@click="addOpenAIFastPolicyUserID(rule)"
|
||||
class="mb-2 inline-flex items-center gap-1 text-xs text-primary-600 transition-colors hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
>
|
||||
<svg
|
||||
class="h-3.5 w-3.5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
{{ t("admin.settings.openaiFastPolicy.addUserId") }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Error Message (only when action=block) -->
|
||||
<div v-if="rule.action === 'block'" class="mt-3">
|
||||
<label
|
||||
@@ -9144,6 +9210,7 @@ async function loadSettings() {
|
||||
openaiFastPolicyForm.rules =
|
||||
settings.openai_fast_policy_settings.rules.map((rule) => ({
|
||||
...rule,
|
||||
user_ids: rule.user_ids ? [...rule.user_ids] : [],
|
||||
model_whitelist: rule.model_whitelist
|
||||
? [...rule.model_whitelist]
|
||||
: [],
|
||||
@@ -9651,6 +9718,10 @@ async function saveSettings() {
|
||||
service_tier: rule.service_tier,
|
||||
action: rule.action,
|
||||
scope: rule.scope,
|
||||
user_ids:
|
||||
rule.user_ids && rule.user_ids.length > 0
|
||||
? [...rule.user_ids]
|
||||
: undefined,
|
||||
error_message:
|
||||
rule.action === "block" ? rule.error_message : undefined,
|
||||
model_whitelist: hasWhitelist ? whitelist : undefined,
|
||||
@@ -9727,6 +9798,7 @@ async function saveSettings() {
|
||||
openaiFastPolicyForm.rules =
|
||||
updated.openai_fast_policy_settings.rules.map((rule) => ({
|
||||
...rule,
|
||||
user_ids: rule.user_ids ? [...rule.user_ids] : [],
|
||||
model_whitelist: rule.model_whitelist
|
||||
? [...rule.model_whitelist]
|
||||
: [],
|
||||
@@ -10142,6 +10214,7 @@ function addOpenAIFastPolicyRule() {
|
||||
service_tier: "priority",
|
||||
action: "filter",
|
||||
scope: "all",
|
||||
user_ids: [],
|
||||
error_message: "",
|
||||
model_whitelist: [],
|
||||
fallback_action: "pass",
|
||||
@@ -10153,6 +10226,18 @@ function removeOpenAIFastPolicyRule(index: number) {
|
||||
openaiFastPolicyForm.rules.splice(index, 1);
|
||||
}
|
||||
|
||||
function addOpenAIFastPolicyUserID(rule: OpenAIFastPolicyRule) {
|
||||
if (!rule.user_ids) rule.user_ids = [];
|
||||
rule.user_ids.push(0);
|
||||
}
|
||||
|
||||
function removeOpenAIFastPolicyUserID(
|
||||
rule: OpenAIFastPolicyRule,
|
||||
idx: number,
|
||||
) {
|
||||
rule.user_ids?.splice(idx, 1);
|
||||
}
|
||||
|
||||
function addOpenAIFastPolicyModelPattern(rule: OpenAIFastPolicyRule) {
|
||||
if (!rule.model_whitelist) rule.model_whitelist = [];
|
||||
rule.model_whitelist.push("");
|
||||
|
||||
Reference in New Issue
Block a user