mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-01 15:02:58 +08:00
fix: 落实 #3867-#3870 合并审计的全部跟进项
透传规则(跟进 #3868/#3870,refs #3857): - CC/Messages 4 条协议转换路径改用共享 helper,走语义状态推断 + body 归一化,使按错误码配置的透传规则也能命中(原先传 0 恒不命中) - helper 增加 platform 参数:本服务同时承载 openai/grok 平台账号, 规则须按 account.Platform 匹配,消除硬编码平台错配 - /v1/responses 两条路径命中透传规则时补记 ops 上游错误事件, 对齐 CC/Messages 与 antigravity 先例,消除监控盲区 用户角色管理(跟进 #3869): - 补齐 EN 语言包缺失的 admin.users.form.roleLabel - 新增"最后一个管理员不可降级"守卫,覆盖跨管理员互降致零 admin 锁死 - 角色变更/创建管理员落审计日志(含操作者 actor_admin_id)
This commit is contained in:
@@ -276,6 +276,7 @@ func (h *UserHandler) Create(c *gin.Context) {
|
||||
Concurrency: req.Concurrency,
|
||||
RPMLimit: req.RPMLimit,
|
||||
AllowedGroups: req.AllowedGroups,
|
||||
ActorAdminID: getAdminIDFromContext(c),
|
||||
})
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
@@ -320,6 +321,7 @@ func (h *UserHandler) Update(c *gin.Context) {
|
||||
Status: req.Status,
|
||||
AllowedGroups: req.AllowedGroups,
|
||||
GroupRates: req.GroupRates,
|
||||
ActorAdminID: getAdminIDFromContext(c),
|
||||
})
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
|
||||
@@ -130,6 +130,8 @@ type CreateUserInput struct {
|
||||
Concurrency int
|
||||
RPMLimit int
|
||||
AllowedGroups []int64
|
||||
// ActorAdminID 执行本次操作的管理员ID(来自JWT),仅用于权限敏感操作的审计日志。
|
||||
ActorAdminID int64
|
||||
}
|
||||
|
||||
type UpdateUserInput struct {
|
||||
@@ -146,6 +148,8 @@ type UpdateUserInput struct {
|
||||
// GroupRates 用户专属分组倍率配置
|
||||
// map[groupID]*rate,nil 表示删除该分组的专属倍率
|
||||
GroupRates map[int64]*float64
|
||||
// ActorAdminID 执行本次操作的管理员ID(来自JWT),仅用于权限敏感操作的审计日志。
|
||||
ActorAdminID int64
|
||||
}
|
||||
|
||||
type AdminBindAuthIdentityInput struct {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@@ -83,3 +84,60 @@ func TestAdminService_UpdateUser_InvalidRoleRejected(t *testing.T) {
|
||||
require.Error(t, err)
|
||||
require.Nil(t, repo.lastUpdated, "非法角色不应触发持久化")
|
||||
}
|
||||
|
||||
// roleGuardUserRepoStub 在 rpmUserRepoStub 之上提供可控的管理员计数,
|
||||
// 用于测试"最后一个管理员不可降级"守卫。
|
||||
type roleGuardUserRepoStub struct {
|
||||
*rpmUserRepoStub
|
||||
adminTotal int64
|
||||
listCalls int
|
||||
}
|
||||
|
||||
func (s *roleGuardUserRepoStub) ListWithFilters(_ context.Context, _ pagination.PaginationParams, _ UserListFilters) ([]User, *pagination.PaginationResult, error) {
|
||||
s.listCalls++
|
||||
return nil, &pagination.PaginationResult{Total: s.adminTotal}, nil
|
||||
}
|
||||
|
||||
func TestAdminService_UpdateUser_DemoteLastAdminRejected(t *testing.T) {
|
||||
base := &userRepoStub{user: &User{ID: 42, Email: "a@example.com", Role: RoleAdmin}}
|
||||
repo := &roleGuardUserRepoStub{rpmUserRepoStub: &rpmUserRepoStub{userRepoStub: base}, adminTotal: 1}
|
||||
svc := &adminServiceImpl{userRepo: repo, redeemCodeRepo: &redeemRepoStub{}}
|
||||
|
||||
_, err := svc.UpdateUser(context.Background(), 42, &UpdateUserInput{Role: RoleUser})
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "last admin")
|
||||
require.Nil(t, repo.lastUpdated, "最后一个管理员不应被降级持久化")
|
||||
require.Equal(t, 1, repo.listCalls, "降级路径应触发管理员计数")
|
||||
}
|
||||
|
||||
func TestAdminService_UpdateUser_DemoteAdminAllowedWhenOthersExist(t *testing.T) {
|
||||
base := &userRepoStub{user: &User{ID: 42, Email: "a@example.com", Role: RoleAdmin}}
|
||||
repo := &roleGuardUserRepoStub{rpmUserRepoStub: &rpmUserRepoStub{userRepoStub: base}, adminTotal: 2}
|
||||
invalidator := &authCacheInvalidatorStub{}
|
||||
svc := &adminServiceImpl{
|
||||
userRepo: repo,
|
||||
redeemCodeRepo: &redeemRepoStub{},
|
||||
authCacheInvalidator: invalidator,
|
||||
}
|
||||
|
||||
updated, err := svc.UpdateUser(context.Background(), 42, &UpdateUserInput{Role: RoleUser})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, RoleUser, updated.Role)
|
||||
require.NotNil(t, repo.lastUpdated)
|
||||
require.Equal(t, RoleUser, repo.lastUpdated.Role, "存在其他管理员时允许降级")
|
||||
}
|
||||
|
||||
func TestAdminService_UpdateUser_PromoteDoesNotCountAdmins(t *testing.T) {
|
||||
base := &userRepoStub{user: &User{ID: 42, Email: "u@example.com", Role: RoleUser}}
|
||||
repo := &roleGuardUserRepoStub{rpmUserRepoStub: &rpmUserRepoStub{userRepoStub: base}, adminTotal: 1}
|
||||
svc := &adminServiceImpl{
|
||||
userRepo: repo,
|
||||
redeemCodeRepo: &redeemRepoStub{},
|
||||
authCacheInvalidator: &authCacheInvalidatorStub{},
|
||||
}
|
||||
|
||||
updated, err := svc.UpdateUser(context.Background(), 42, &UpdateUserInput{Role: RoleAdmin})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, RoleAdmin, updated.Role)
|
||||
require.Equal(t, 0, repo.listCalls, "升级路径不应触发管理员计数")
|
||||
}
|
||||
|
||||
@@ -148,10 +148,33 @@ func (s *adminServiceImpl) CreateUser(ctx context.Context, input *CreateUserInpu
|
||||
if err := s.userRepo.Create(ctx, user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 创建管理员属权限敏感操作,落审计日志(含操作者),便于事后追溯。
|
||||
if user.Role == RoleAdmin {
|
||||
logger.LegacyPrintf("service.admin", "audit: admin user created actor_admin_id=%d target_user_id=%d",
|
||||
input.ActorAdminID, user.ID)
|
||||
}
|
||||
s.assignDefaultSubscriptions(ctx, user.ID)
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// ensureNotLastAdmin 降级管理员前确认系统中仍存在其他管理员,防止零 admin 锁死。
|
||||
// 注:读取与写入之间存在竞态窗口,极端并发下仍可能双双降级;作为后台低频操作
|
||||
// 的兜底保护足够,彻底防护需依赖数据库层约束。
|
||||
func (s *adminServiceImpl) ensureNotLastAdmin(ctx context.Context) error {
|
||||
noSubs := false
|
||||
_, result, err := s.userRepo.ListWithFilters(ctx,
|
||||
pagination.PaginationParams{Page: 1, PageSize: 1},
|
||||
UserListFilters{Role: RoleAdmin, IncludeSubscriptions: &noSubs},
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("count admin users: %w", err)
|
||||
}
|
||||
if result == nil || result.Total <= 1 {
|
||||
return errors.New("cannot demote the last admin user")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *adminServiceImpl) assignDefaultSubscriptions(ctx context.Context, userID int64) {
|
||||
if s.settingService == nil || s.defaultSubAssigner == nil || userID <= 0 {
|
||||
return
|
||||
@@ -221,6 +244,13 @@ func (s *adminServiceImpl) UpdateUser(ctx context.Context, id int64, input *Upda
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 防锁死保护:不允许降级系统中最后一个管理员(自我降级已在 handler 层拦截,
|
||||
// 此处兜底覆盖跨管理员互降导致零 admin 的场景)。
|
||||
if user.Role == RoleAdmin && role == RoleUser {
|
||||
if err := s.ensureNotLastAdmin(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
user.Role = role
|
||||
}
|
||||
|
||||
@@ -240,6 +270,12 @@ func (s *adminServiceImpl) UpdateUser(ctx context.Context, id int64, input *Upda
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 角色变更属权限敏感操作,落审计日志(含操作者),便于事后追溯。
|
||||
if user.Role != oldRole {
|
||||
logger.LegacyPrintf("service.admin", "audit: user role changed actor_admin_id=%d target_user_id=%d old_role=%s new_role=%s",
|
||||
input.ActorAdminID, user.ID, oldRole, user.Role)
|
||||
}
|
||||
|
||||
// 同步用户专属分组倍率
|
||||
if input.GroupRates != nil && s.userGroupRateRepo != nil {
|
||||
if err := s.userGroupRateRepo.SyncUserGroupRates(ctx, user.ID, input.GroupRates); err != nil {
|
||||
|
||||
@@ -425,14 +425,11 @@ func (s *OpenAIGatewayService) handleChatBufferedStreamingResponse(
|
||||
return nil, s.newOpenAIStreamFailoverError(c, account, false, requestID, payload, message)
|
||||
}
|
||||
message = s.recordOpenAIStreamUpstreamError(c, account, false, requestID, "http_error", payload, message)
|
||||
// response.failed 到达在 HTTP 200 SSE 流上,无真实 HTTP 错误码,传 0。
|
||||
if status, errType, errMsg, matched := applyErrorPassthroughRule(
|
||||
c, account.Platform, 0, payload,
|
||||
http.StatusBadGateway, "upstream_error", message,
|
||||
// response.failed 到达在 HTTP 200 SSE 流上,无真实 HTTP 错误码;统一走语义
|
||||
// 状态推断 + body 归一化(与 /v1/responses 路径一致),使按错误码配置的规则可命中。
|
||||
if status, errType, errMsg, matched := applyOpenAIStreamFailedErrorPassthroughRule(
|
||||
c, account.Platform, payload, message,
|
||||
); matched {
|
||||
if status == 0 {
|
||||
status = http.StatusBadGateway
|
||||
}
|
||||
if errMsg == "" {
|
||||
errMsg = message
|
||||
}
|
||||
@@ -597,13 +594,11 @@ func (s *OpenAIGatewayService) handleChatStreamingResponse(
|
||||
}
|
||||
message = s.recordOpenAIStreamUpstreamError(c, account, false, requestID, "http_error", payloadBytes, message)
|
||||
defaultStatus, defaultErrType, defaultMsg := http.StatusBadGateway, "upstream_error", message
|
||||
if status, errType, errMsg, matched := applyErrorPassthroughRule(
|
||||
c, account.Platform, 0, payloadBytes,
|
||||
defaultStatus, defaultErrType, defaultMsg,
|
||||
// 统一走语义状态推断 + body 归一化(与 /v1/responses 路径一致),
|
||||
// 使按错误码配置的透传规则可命中。
|
||||
if status, errType, errMsg, matched := applyOpenAIStreamFailedErrorPassthroughRule(
|
||||
c, account.Platform, payloadBytes, message,
|
||||
); matched {
|
||||
if status == 0 {
|
||||
status = defaultStatus
|
||||
}
|
||||
if errMsg == "" {
|
||||
errMsg = defaultMsg
|
||||
}
|
||||
|
||||
@@ -465,13 +465,11 @@ func (s *OpenAIGatewayService) handleAnthropicBufferedStreamingResponse(
|
||||
return nil, s.newOpenAIStreamFailoverError(c, account, false, requestID, payload, message)
|
||||
}
|
||||
message = s.recordOpenAIStreamUpstreamError(c, account, false, requestID, "http_error", payload, message)
|
||||
if status, errType, errMsg, matched := applyErrorPassthroughRule(
|
||||
c, account.Platform, 0, payload,
|
||||
http.StatusBadGateway, "api_error", message,
|
||||
// 统一走语义状态推断 + body 归一化(与 /v1/responses 路径一致),
|
||||
// 使按错误码配置的透传规则可命中。
|
||||
if status, errType, errMsg, matched := applyOpenAIStreamFailedErrorPassthroughRule(
|
||||
c, account.Platform, payload, message,
|
||||
); matched {
|
||||
if status == 0 {
|
||||
status = http.StatusBadGateway
|
||||
}
|
||||
if errMsg == "" {
|
||||
errMsg = message
|
||||
}
|
||||
@@ -819,13 +817,11 @@ func (s *OpenAIGatewayService) handleAnthropicStreamingResponse(
|
||||
}
|
||||
message = s.recordOpenAIStreamUpstreamError(c, account, false, requestID, "http_error", payloadBytes, message)
|
||||
errStatus, errType, errMsg := http.StatusBadGateway, "api_error", message
|
||||
if status, et, em, matched := applyErrorPassthroughRule(
|
||||
c, account.Platform, 0, payloadBytes,
|
||||
errStatus, errType, errMsg,
|
||||
// 统一走语义状态推断 + body 归一化(与 /v1/responses 路径一致),
|
||||
// 使按错误码配置的透传规则可命中。
|
||||
if status, et, em, matched := applyOpenAIStreamFailedErrorPassthroughRule(
|
||||
c, account.Platform, payloadBytes, message,
|
||||
); matched {
|
||||
if status == 0 {
|
||||
status = errStatus
|
||||
}
|
||||
if em == "" {
|
||||
em = errMsg
|
||||
}
|
||||
|
||||
@@ -694,8 +694,12 @@ func openAIStreamFailedEventPassthroughBody(payload []byte, failedMessage string
|
||||
return body
|
||||
}
|
||||
|
||||
// applyOpenAIStreamFailedErrorPassthroughRule 对 response.failed 事件应用错误透传规则:
|
||||
// 归一化 body 供关键词匹配/消息提取,并推断语义状态码使按错误码配置的规则可以命中。
|
||||
// platform 必须传 account.Platform——本服务同时承载 openai 与 grok 平台账号,规则按平台匹配。
|
||||
func applyOpenAIStreamFailedErrorPassthroughRule(
|
||||
c *gin.Context,
|
||||
platform string,
|
||||
payload []byte,
|
||||
failedMessage string,
|
||||
) (status int, errType string, errMsg string, matched bool) {
|
||||
@@ -703,7 +707,7 @@ func applyOpenAIStreamFailedErrorPassthroughRule(
|
||||
upstreamStatus := openAIStreamFailedEventSemanticStatus(payload, failedMessage)
|
||||
return applyErrorPassthroughRule(
|
||||
c,
|
||||
PlatformOpenAI,
|
||||
platform,
|
||||
upstreamStatus,
|
||||
ruleBody,
|
||||
http.StatusBadGateway,
|
||||
@@ -922,7 +926,10 @@ func (s *OpenAIGatewayService) handleStreamingResponsePassthrough(
|
||||
})
|
||||
}
|
||||
if !openAIStreamClientOutputStarted(c, clientOutputStarted) {
|
||||
if status, errType, errMsg, matched := applyOpenAIStreamFailedErrorPassthroughRule(c, dataBytes, failedMessage); matched {
|
||||
if status, errType, errMsg, matched := applyOpenAIStreamFailedErrorPassthroughRule(c, account.Platform, dataBytes, failedMessage); matched {
|
||||
// 命中透传规则也要记录 ops 上游错误事件(对齐 CC/Messages 与
|
||||
// antigravity 先例),否则透传命中的 failed 在监控中不可见。
|
||||
s.recordOpenAIStreamUpstreamError(c, account, true, upstreamRequestID, "http_error", dataBytes, failedMessage)
|
||||
MarkResponseCommitted(c)
|
||||
c.Writer.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
c.JSON(status, gin.H{
|
||||
|
||||
@@ -141,3 +141,84 @@ func TestForwardAsChatCompletions_ResponseFailed_NoRule_Still502(t *testing.T) {
|
||||
require.Error(t, err)
|
||||
require.Equal(t, http.StatusBadGateway, rec.Code, "without passthrough rule should still be 502")
|
||||
}
|
||||
|
||||
// bindStatusCodePassthroughRule 绑定一条按错误码+关键词双条件(MatchModeAll)匹配的规则。
|
||||
// 此类规则依赖语义状态码推断才能在协议转换路径命中(response.failed 无真实 HTTP 状态码)。
|
||||
func bindStatusCodePassthroughRule(c *gin.Context, platform string, statusCode int, keyword string, responseCode int) {
|
||||
rule := &model.ErrorPassthroughRule{
|
||||
ID: 1,
|
||||
Name: "status-code-rule",
|
||||
Enabled: true,
|
||||
Priority: 1,
|
||||
Platforms: []string{platform},
|
||||
ErrorCodes: []int{statusCode},
|
||||
Keywords: []string{keyword},
|
||||
MatchMode: model.MatchModeAll,
|
||||
ResponseCode: &responseCode,
|
||||
PassthroughBody: true,
|
||||
}
|
||||
svc := &ErrorPassthroughService{}
|
||||
svc.setLocalCache([]*model.ErrorPassthroughRule{rule})
|
||||
BindErrorPassthroughService(c, svc)
|
||||
}
|
||||
|
||||
func TestForwardAsChatCompletions_ResponseFailed_ErrorCodeRuleMatchesViaSemanticStatus(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
body := []byte(`{"model":"gpt-5.4","messages":[{"role":"user","content":"hello"}],"stream":false}`)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
bindStatusCodePassthroughRule(c, "openai", http.StatusBadRequest, "context_length_exceeded", http.StatusBadRequest)
|
||||
|
||||
upstream := &httpUpstreamRecorder{resp: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
|
||||
Body: io.NopCloser(strings.NewReader(buildContextLengthFailedSSE())),
|
||||
}}
|
||||
svc := &OpenAIGatewayService{
|
||||
cfg: rawChatCompletionsTestConfig(),
|
||||
httpUpstream: upstream,
|
||||
}
|
||||
|
||||
account := rawChatCompletionsTestAccount()
|
||||
_, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "")
|
||||
|
||||
require.Error(t, err)
|
||||
require.Equal(t, http.StatusBadRequest, rec.Code, "error-code-conditioned rule should match via semantic status inference")
|
||||
respBody := rec.Body.String()
|
||||
require.Equal(t, "upstream_error", gjson.Get(respBody, "error.type").String())
|
||||
require.Contains(t, gjson.Get(respBody, "error.message").String(), "context window")
|
||||
}
|
||||
|
||||
func TestForwardAsAnthropic_ResponseFailed_ErrorCodeRuleMatchesViaSemanticStatus(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
body := []byte(`{"model":"gpt-5.4","max_tokens":32,"messages":[{"role":"user","content":"hello"}],"stream":false}`)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
bindStatusCodePassthroughRule(c, "openai", http.StatusBadRequest, "context_length_exceeded", http.StatusBadRequest)
|
||||
|
||||
upstream := &httpUpstreamRecorder{resp: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
|
||||
Body: io.NopCloser(strings.NewReader(buildContextLengthFailedSSE())),
|
||||
}}
|
||||
svc := &OpenAIGatewayService{
|
||||
cfg: rawChatCompletionsTestConfig(),
|
||||
httpUpstream: upstream,
|
||||
}
|
||||
|
||||
account := rawChatCompletionsTestAccount()
|
||||
_, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "")
|
||||
|
||||
require.Error(t, err)
|
||||
require.Equal(t, http.StatusBadRequest, rec.Code, "error-code-conditioned rule should match via semantic status inference")
|
||||
respBody := rec.Body.String()
|
||||
require.NotEmpty(t, gjson.Get(respBody, "error.message").String())
|
||||
}
|
||||
|
||||
@@ -256,8 +256,11 @@ func (s *OpenAIGatewayService) handleStreamingResponse(ctx context.Context, resp
|
||||
})
|
||||
}
|
||||
if !openAIStreamClientOutputStarted(c, clientOutputStarted) {
|
||||
if status, errType, errMsg, matched := applyOpenAIStreamFailedErrorPassthroughRule(c, dataBytes, failedMessage); matched {
|
||||
if status, errType, errMsg, matched := applyOpenAIStreamFailedErrorPassthroughRule(c, account.Platform, dataBytes, failedMessage); matched {
|
||||
sawFailedEvent = true
|
||||
// 命中透传规则也要记录 ops 上游错误事件(对齐 CC/Messages 与
|
||||
// antigravity 先例),否则透传命中的 failed 在监控中不可见。
|
||||
s.recordOpenAIStreamUpstreamError(c, account, false, upstreamRequestID, "http_error", dataBytes, failedMessage)
|
||||
MarkResponseCommitted(c)
|
||||
c.Writer.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
c.JSON(status, gin.H{
|
||||
|
||||
@@ -1504,6 +1504,11 @@ func TestOpenAIStreamingContextWindowResponseFailedBeforeOutputAppliesPassthroug
|
||||
require.Equal(t, upstreamMessage, gjson.Get(body, "error.message").String())
|
||||
require.NotContains(t, body, "response.failed")
|
||||
require.NotContains(t, body, "Upstream request failed")
|
||||
// 命中透传规则也应记录 ops 上游错误事件(对齐 CC/Messages 与 antigravity 先例)。
|
||||
opsVal, opsRecorded := c.Get(OpsUpstreamErrorsKey)
|
||||
require.True(t, opsRecorded, "passthrough hit should record an ops upstream error event")
|
||||
opsEvents, _ := opsVal.([]*OpsUpstreamErrorEvent)
|
||||
require.NotEmpty(t, opsEvents)
|
||||
}
|
||||
|
||||
func TestOpenAIStreamingPreambleOnlyMissingTerminalReturnsFailover(t *testing.T) {
|
||||
@@ -1890,6 +1895,11 @@ func TestOpenAIStreamingPassthroughContextWindowResponseFailedBeforeOutputApplie
|
||||
require.Equal(t, upstreamMessage, gjson.Get(body, "error.message").String())
|
||||
require.NotContains(t, body, "response.failed")
|
||||
require.NotContains(t, body, "Upstream request failed")
|
||||
// 命中透传规则也应记录 ops 上游错误事件(对齐 CC/Messages 与 antigravity 先例)。
|
||||
opsVal, opsRecorded := c.Get(OpsUpstreamErrorsKey)
|
||||
require.True(t, opsRecorded, "passthrough hit should record an ops upstream error event")
|
||||
opsEvents, _ := opsVal.([]*OpsUpstreamErrorEvent)
|
||||
require.NotEmpty(t, opsEvents)
|
||||
}
|
||||
|
||||
func TestOpenAIStreamingPassthroughContextWindowResponseFailedBeforeOutputWithoutRulePassesThrough(t *testing.T) {
|
||||
|
||||
@@ -433,6 +433,7 @@ export default {
|
||||
creating: 'Creating...',
|
||||
updating: 'Updating...',
|
||||
form: {
|
||||
roleLabel: 'Role',
|
||||
rpmLimit: 'Requests Per Minute (RPM)',
|
||||
rpmLimitPlaceholder: '0 = unlimited',
|
||||
rpmLimitHint: 'Max requests per minute for this user; 0 = unlimited. Acts as a fallback only when the group has no rpm_limit set.'
|
||||
|
||||
Reference in New Issue
Block a user