feat(openai): 支持用户级 Fast/Flex 策略

This commit is contained in:
Lyonle
2026-07-10 15:16:09 +08:00
parent 9a2f11b4e2
commit f2966530c5
15 changed files with 482 additions and 17 deletions
@@ -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) {
+1
View File
@@ -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"`
+4
View File
@@ -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"` // 未匹配白名单的模型的处理方式
+1
View File
@@ -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: '留空则使用默认错误消息',
+85
View File
@@ -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("");