feat: sync deployed v1.0.0-rc.38-custom.42
Sync production source including routing, billing, Astra detection and public uptime. Update custom build instructions and license packaging.
This commit is contained in:
1 parent
aa6c3f50f4
commit
ceddfd3d6b
828 files changed
+122253
-13702
No files matched your search
+45
-19
@@ -361,20 +361,7 @@ func TokenAuthReadOnly() func(c *gin.Context) {
|
||||
func TokenAuth() func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
// 先检测是否为ws
|
||||
if c.Request.Header.Get("Sec-WebSocket-Protocol") != "" {
|
||||
// Sec-WebSocket-Protocol: realtime, openai-insecure-api-key.sk-xxx, openai-beta.realtime-v1
|
||||
// read sk from Sec-WebSocket-Protocol
|
||||
key := c.Request.Header.Get("Sec-WebSocket-Protocol")
|
||||
parts := strings.SplitSeq(key, ",")
|
||||
for part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if strings.HasPrefix(part, "openai-insecure-api-key") {
|
||||
key = strings.TrimPrefix(part, "openai-insecure-api-key.")
|
||||
break
|
||||
}
|
||||
}
|
||||
c.Request.Header.Set("Authorization", "Bearer "+key)
|
||||
}
|
||||
applyWebSocketSubprotocolAuthorization(c.Request.Header)
|
||||
// 检查path包含/v1/messages 或 /v1/models
|
||||
if strings.Contains(c.Request.URL.Path, "/v1/messages") || strings.Contains(c.Request.URL.Path, "/v1/models") {
|
||||
anthropicKey := c.Request.Header.Get("x-api-key")
|
||||
@@ -467,7 +454,7 @@ func TokenAuth() func(c *gin.Context) {
|
||||
|
||||
userGroup := userCache.Group
|
||||
tokenGroup := token.Group
|
||||
if tokenGroup != "" {
|
||||
if tokenGroup != "" && token.RoutingPolicy == "" {
|
||||
// check common.UserUsableGroups[userGroup]
|
||||
if _, ok := service.GetUserUsableGroups(userGroup)[tokenGroup]; !ok {
|
||||
abortWithOpenAiMessage(c, http.StatusForbidden, fmt.Sprintf("无权访问 %s 分组", tokenGroup))
|
||||
@@ -486,12 +473,39 @@ func TokenAuth() func(c *gin.Context) {
|
||||
|
||||
err = SetupContextForToken(c, token, parts...)
|
||||
if err != nil {
|
||||
if !c.IsAborted() {
|
||||
abortWithOpenAiMessage(c, http.StatusForbidden, "Invalid token routing policy")
|
||||
}
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func applyWebSocketSubprotocolAuthorization(header http.Header) bool {
|
||||
key, ok := apiKeyFromWebSocketSubprotocol(strings.Join(header.Values("Sec-WebSocket-Protocol"), ","))
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
header.Set("Authorization", "Bearer "+key)
|
||||
return true
|
||||
}
|
||||
|
||||
func apiKeyFromWebSocketSubprotocol(protocols string) (string, bool) {
|
||||
if protocols == "" {
|
||||
return "", false
|
||||
}
|
||||
const insecureAPIKeyPrefix = "openai-insecure-api-key."
|
||||
for part := range strings.SplitSeq(protocols, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if strings.HasPrefix(part, insecureAPIKeyPrefix) {
|
||||
key := strings.TrimPrefix(part, insecureAPIKeyPrefix)
|
||||
return key, key != ""
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func SetupContextForToken(c *gin.Context, token *model.Token, parts ...string) error {
|
||||
if token == nil {
|
||||
return fmt.Errorf("token is nil")
|
||||
@@ -511,15 +525,27 @@ func SetupContextForToken(c *gin.Context, token *model.Token, parts ...string) e
|
||||
c.Set("token_model_limit_enabled", false)
|
||||
}
|
||||
common.SetContextKey(c, constant.ContextKeyTokenGroup, token.Group)
|
||||
common.SetContextKey(c, constant.ContextKeyTokenAstraAutoSwitch, token.AstraAutoSwitch && token.AstraDetectionEnabled)
|
||||
common.SetContextKey(c, constant.ContextKeyTokenAstraDetectionDisabled, !token.AstraDetectionEnabled)
|
||||
common.SetContextKey(c, constant.ContextKeyTokenCrossGroupRetry, token.CrossGroupRetry)
|
||||
if policy, err := token.GetRoutingPolicy(); err != nil {
|
||||
return err
|
||||
} else if policy != nil {
|
||||
c.Set(model.TokenRoutingContextKey, policy)
|
||||
common.SetContextKey(c, constant.ContextKeyTokenCrossGroupRetry, true)
|
||||
}
|
||||
if token.AutoGroups != "" {
|
||||
autoGroups, err := token.GetAutoGroups()
|
||||
groupPool, err := token.GetGroupPool()
|
||||
if err != nil {
|
||||
common.SysError(fmt.Sprintf("failed to parse auto groups for token %d: %v", token.Id, err))
|
||||
autoGroups = []string{}
|
||||
common.SetContextKey(c, constant.ContextKeyTokenAutoGroups, autoGroups)
|
||||
} else if len(autoGroups) > 0 {
|
||||
common.SetContextKey(c, constant.ContextKeyTokenAutoGroups, []string{})
|
||||
} else if len(groupPool) > 0 {
|
||||
autoGroups := make([]string, 0, len(groupPool))
|
||||
for _, entry := range groupPool {
|
||||
autoGroups = append(autoGroups, entry.Group)
|
||||
}
|
||||
common.SetContextKey(c, constant.ContextKeyTokenAutoGroups, autoGroups)
|
||||
common.SetContextKey(c, constant.ContextKeyTokenGroupPool, groupPool)
|
||||
}
|
||||
}
|
||||
if len(parts) > 1 {
|
||||
|
||||
@@ -255,3 +255,82 @@ func TestTryUserAuthCredentialClassification(t *testing.T) {
|
||||
assert.Equal(t, http.StatusInternalServerError, databaseFailureResponse.Code)
|
||||
assert.Contains(t, databaseFailureResponse.Body.String(), "AUTH_INTERNAL_ERROR")
|
||||
}
|
||||
|
||||
func TestAPIKeyFromWebSocketSubprotocol(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
protocols string
|
||||
wantKey string
|
||||
wantOK bool
|
||||
}{
|
||||
{
|
||||
name: "responses protocol only",
|
||||
protocols: "responses",
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "realtime protocol only",
|
||||
protocols: "realtime",
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "responses with insecure key",
|
||||
protocols: "responses, openai-insecure-api-key.sk-test",
|
||||
wantKey: "sk-test",
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "realtime with beta and insecure key",
|
||||
protocols: "realtime, openai-insecure-api-key.sk-realtime, openai-beta.realtime-v1",
|
||||
wantKey: "sk-realtime",
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "empty insecure key",
|
||||
protocols: "responses, openai-insecure-api-key.",
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "bare insecure marker is not a key",
|
||||
protocols: "openai-insecure-api-key",
|
||||
wantOK: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gotKey, gotOK := apiKeyFromWebSocketSubprotocol(tt.protocols)
|
||||
assert.Equal(t, tt.wantOK, gotOK)
|
||||
assert.Equal(t, tt.wantKey, gotKey)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyWebSocketSubprotocolAuthorizationDoesNotOverrideProtocolOnly(t *testing.T) {
|
||||
header := http.Header{}
|
||||
header.Set("Authorization", "Bearer sk-original")
|
||||
header.Set("Sec-WebSocket-Protocol", "responses")
|
||||
header.Add("Sec-WebSocket-Protocol", "openai-beta.realtime-v1")
|
||||
|
||||
assert.False(t, applyWebSocketSubprotocolAuthorization(header))
|
||||
assert.Equal(t, "Bearer sk-original", header.Get("Authorization"))
|
||||
}
|
||||
|
||||
func TestApplyWebSocketSubprotocolAuthorizationOverridesWithInsecureKey(t *testing.T) {
|
||||
header := http.Header{}
|
||||
header.Set("Authorization", "Bearer sk-original")
|
||||
header.Set("Sec-WebSocket-Protocol", "responses, openai-insecure-api-key.sk-from-protocol")
|
||||
|
||||
assert.True(t, applyWebSocketSubprotocolAuthorization(header))
|
||||
assert.Equal(t, "Bearer sk-from-protocol", header.Get("Authorization"))
|
||||
}
|
||||
|
||||
func TestApplyWebSocketSubprotocolAuthorizationReadsRepeatedHeaders(t *testing.T) {
|
||||
header := http.Header{}
|
||||
header.Set("Authorization", "Bearer sk-original")
|
||||
header.Add("Sec-WebSocket-Protocol", "responses")
|
||||
header.Add("Sec-WebSocket-Protocol", "openai-insecure-api-key.sk-later-field")
|
||||
|
||||
assert.True(t, applyWebSocketSubprotocolAuthorization(header))
|
||||
assert.Equal(t, "Bearer sk-later-field", header.Get("Authorization"))
|
||||
}
|
||||
+67
-113
@@ -34,6 +34,18 @@ type ModelRequest struct {
|
||||
func Distribute() func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var channel *model.Channel
|
||||
defer func() {
|
||||
if c.Writer.Status() >= 400 {
|
||||
service.RecordRequestPolicyTermination(c, types.NewErrorWithStatusCode(errors.New("request rejected"), types.ErrorCodeInvalidRequest, c.Writer.Status(), types.ErrOptionWithSkipRetry()))
|
||||
// Rejections before the relay starts have no per-channel error log.
|
||||
if constant.ErrorLogEnabled && service.RequestPolicy(c).Attempts == 0 && len(service.RequestPolicy(c).Events()) > 1 {
|
||||
other := model.NewLogOther()
|
||||
other.SetPublic("status_code", c.Writer.Status())
|
||||
service.AppendRelayLogAdminInfo(c, nil, other)
|
||||
model.RecordErrorLog(c, c.GetInt("id"), 0, c.GetString("original_model"), c.GetString("token_name"), "No available channel for this request.", c.GetInt("token_id"), 0, false, "", other)
|
||||
}
|
||||
}
|
||||
}()
|
||||
constraints := service.GetChannelConstraints(c)
|
||||
constraints.AddFilter(taskdto.ChannelFilter{
|
||||
Kind: taskdto.FilterRequestPath,
|
||||
@@ -45,38 +57,8 @@ func Distribute() func(c *gin.Context) {
|
||||
abortWithOpenAiMessage(c, http.StatusBadRequest, i18n.T(c, i18n.MsgDistributorInvalidRequest, map[string]any{"Error": err.Error()}))
|
||||
return
|
||||
}
|
||||
if pin, found, overridden := constraints.ResolvedPin(); found {
|
||||
for _, lost := range overridden {
|
||||
logger.LogWarn(c, fmt.Sprintf(
|
||||
"channel pin overridden: winning_source=%s winning_channel_id=%d overridden_source=%s overridden_channel_id=%d",
|
||||
pin.Source, pin.ChannelId, lost.Source, lost.ChannelId,
|
||||
))
|
||||
}
|
||||
channel, err = model.CacheGetChannel(pin.ChannelId)
|
||||
if err != nil {
|
||||
if pin.Source == taskdto.PinSourceOriginTask {
|
||||
abortWithOpenAiMessage(c, http.StatusBadRequest, "origin_task_channel_disabled", types.ErrorCode("origin_task_channel_disabled"))
|
||||
} else {
|
||||
abortWithOpenAiMessage(c, http.StatusBadRequest, i18n.T(c, i18n.MsgDistributorInvalidChannelId))
|
||||
}
|
||||
return
|
||||
}
|
||||
if channel.Status != common.ChannelStatusEnabled {
|
||||
if pin.Source == taskdto.PinSourceOriginTask {
|
||||
abortWithOpenAiMessage(c, http.StatusBadRequest, "origin_task_channel_disabled", types.ErrorCode("origin_task_channel_disabled"))
|
||||
} else {
|
||||
abortWithOpenAiMessage(c, http.StatusForbidden, i18n.T(c, i18n.MsgDistributorChannelDisabled))
|
||||
}
|
||||
return
|
||||
}
|
||||
if ok, kind := model.ChannelSatisfiesFilters(channel, modelRequest.Model, constraints.Filters); !ok {
|
||||
if kind == taskdto.FilterTaskPluginIdentity {
|
||||
logTaskPluginChannelDecision(c, channel, modelRequest.Model, "channel_rejected", "identity_mismatch")
|
||||
}
|
||||
abortWithOpenAiMessage(c, http.StatusBadRequest, i18n.T(c, i18n.MsgDistributorNoAvailableChannel, map[string]any{"Group": common.GetContextKeyString(c, constant.ContextKeyUsingGroup), "Model": modelRequest.Model}), types.ErrorCode(kind))
|
||||
return
|
||||
}
|
||||
} else {
|
||||
_, pinned, _ := constraints.ResolvedPin()
|
||||
if !pinned {
|
||||
// Select a channel for the user
|
||||
// check token model mapping
|
||||
modelLimitEnable := common.GetContextKeyBool(c, constant.ContextKeyTokenModelLimitEnabled)
|
||||
@@ -92,7 +74,7 @@ func Distribute() func(c *gin.Context) {
|
||||
if !ok {
|
||||
tokenModelLimit = map[string]bool{}
|
||||
}
|
||||
if !tokenModelLimitAllows(tokenModelLimit, modelRequest.Model) {
|
||||
if !TokenModelLimitAllows(tokenModelLimit, modelRequest.Model) {
|
||||
abortWithOpenAiMessage(c, http.StatusForbidden, i18n.T(c, i18n.MsgDistributorTokenModelForbidden, map[string]any{"Model": modelRequest.Model}))
|
||||
return
|
||||
}
|
||||
@@ -103,10 +85,9 @@ func Distribute() func(c *gin.Context) {
|
||||
abortWithOpenAiMessage(c, http.StatusBadRequest, i18n.T(c, i18n.MsgDistributorModelNameRequired))
|
||||
return
|
||||
}
|
||||
var selectGroup string
|
||||
usingGroup := common.GetContextKeyString(c, constant.ContextKeyUsingGroup)
|
||||
// check path is /pg/chat/completions
|
||||
if strings.HasPrefix(c.Request.URL.Path, "/pg/chat/completions") {
|
||||
usingGroup := common.GetContextKeyString(c, constant.ContextKeyUsingGroup)
|
||||
playgroundRequest := &dto.PlayGroundRequest{}
|
||||
err = common.UnmarshalBodyReusable(c, playgroundRequest)
|
||||
if err != nil {
|
||||
@@ -118,79 +99,32 @@ func Distribute() func(c *gin.Context) {
|
||||
abortWithOpenAiMessage(c, http.StatusForbidden, i18n.T(c, i18n.MsgDistributorGroupAccessDenied))
|
||||
return
|
||||
}
|
||||
usingGroup = playgroundRequest.Group
|
||||
common.SetContextKey(c, constant.ContextKeyUsingGroup, usingGroup)
|
||||
}
|
||||
}
|
||||
|
||||
if preferredChannelID, found := service.GetPreferredChannelByAffinity(c, modelRequest.Model, usingGroup); found {
|
||||
affinityUsable := false
|
||||
preferred, err := model.CacheGetChannel(preferredChannelID)
|
||||
affinitySatisfied := false
|
||||
if err == nil && preferred != nil && preferred.Status == common.ChannelStatusEnabled {
|
||||
affinitySatisfied, _ = model.ChannelSatisfiesFilters(preferred, modelRequest.Model, constraints.Filters)
|
||||
}
|
||||
if affinitySatisfied {
|
||||
if usingGroup == "auto" {
|
||||
userGroup := common.GetContextKeyString(c, constant.ContextKeyUserGroup)
|
||||
autoGroups := service.GetRequestAutoGroups(c, userGroup)
|
||||
for _, g := range autoGroups {
|
||||
if model.IsChannelEnabledForGroupModel(g, modelRequest.Model, preferred.Id) {
|
||||
selectGroup = g
|
||||
common.SetContextKey(c, constant.ContextKeyAutoGroup, g)
|
||||
channel = preferred
|
||||
affinityUsable = true
|
||||
service.MarkChannelAffinityUsed(c, g, preferred.Id)
|
||||
break
|
||||
}
|
||||
}
|
||||
} else if model.IsChannelEnabledForGroupModel(usingGroup, modelRequest.Model, preferred.Id) {
|
||||
channel = preferred
|
||||
selectGroup = usingGroup
|
||||
affinityUsable = true
|
||||
service.MarkChannelAffinityUsed(c, usingGroup, preferred.Id)
|
||||
}
|
||||
}
|
||||
if !affinityUsable && !service.ShouldKeepChannelAffinityOnChannelDisabled() {
|
||||
service.ClearCurrentChannelAffinityCache(c)
|
||||
}
|
||||
}
|
||||
|
||||
if channel == nil {
|
||||
channel, selectGroup, err = service.CacheGetRandomSatisfiedChannel(&service.RetryParam{
|
||||
Ctx: c,
|
||||
ModelName: modelRequest.Model,
|
||||
TokenGroup: usingGroup,
|
||||
RequestPath: c.Request.URL.Path,
|
||||
Retry: common.GetPointer(0),
|
||||
})
|
||||
if err != nil {
|
||||
showGroup := usingGroup
|
||||
if usingGroup == "auto" {
|
||||
showGroup = fmt.Sprintf("auto(%s)", selectGroup)
|
||||
}
|
||||
message := i18n.T(c, i18n.MsgDistributorGetChannelFailed, map[string]any{"Group": showGroup, "Model": modelRequest.Model, "Error": err.Error()})
|
||||
// 如果错误,但是渠道不为空,说明是数据库一致性问题
|
||||
//if channel != nil {
|
||||
// common.SysError(fmt.Sprintf("渠道不存在:%d", channel.Id))
|
||||
// message = "数据库一致性已被破坏,请联系管理员"
|
||||
//}
|
||||
abortWithOpenAiMessage(c, http.StatusServiceUnavailable, message, types.ErrorCodeModelNotFound)
|
||||
return
|
||||
}
|
||||
if channel == nil {
|
||||
abortWithOpenAiMessage(c, http.StatusServiceUnavailable, noAvailableChannelMessage(c, usingGroup, modelRequest.Model), types.ErrorCodeModelNotFound)
|
||||
return
|
||||
common.SetContextKey(c, constant.ContextKeyUsingGroup, playgroundRequest.Group)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if channel != nil {
|
||||
if ok, kind := model.ChannelSatisfiesFilters(channel, modelRequest.Model, constraints.Filters); !ok {
|
||||
if kind == taskdto.FilterTaskPluginIdentity {
|
||||
logTaskPluginChannelDecision(c, channel, modelRequest.Model, "channel_rejected", "identity_mismatch")
|
||||
if pinned || shouldSelectChannel {
|
||||
usingGroup := common.GetContextKeyString(c, constant.ContextKeyUsingGroup)
|
||||
var selectErr *service.ChannelSelectError
|
||||
channel, _, selectErr = service.SelectChannelForRequest(c, modelRequest.Model, &service.RetryParam{
|
||||
Ctx: c,
|
||||
ModelName: modelRequest.Model,
|
||||
TokenGroup: usingGroup,
|
||||
RequestPath: c.Request.URL.Path,
|
||||
Retry: common.GetPointer(0),
|
||||
})
|
||||
if selectErr != nil {
|
||||
if selectErr.FilterKind == taskdto.FilterTaskPluginIdentity {
|
||||
logTaskPluginChannelDecision(c, selectErr.Channel, modelRequest.Model, "channel_rejected", "identity_mismatch")
|
||||
}
|
||||
abortWithOpenAiMessage(c, http.StatusServiceUnavailable, noAvailableChannelMessage(c, common.GetContextKeyString(c, constant.ContextKeyUsingGroup), modelRequest.Model), types.ErrorCodeModelNotFound)
|
||||
message := selectErr.Message
|
||||
if selectErr.NoAvailableChannel {
|
||||
message = noAvailableChannelMessage(c, usingGroup, modelRequest.Model)
|
||||
} else if selectErr.MessageID != "" {
|
||||
message = i18n.T(c, selectErr.MessageID, selectErr.Params)
|
||||
}
|
||||
abortWithOpenAiMessage(c, selectErr.StatusCode, message, selectErr.Code)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -198,22 +132,35 @@ func Distribute() func(c *gin.Context) {
|
||||
SetupContextForSelectedChannel(c, channel, modelRequest.Model)
|
||||
c.Next()
|
||||
if channel != nil && c.Writer != nil && c.Writer.Status() < http.StatusBadRequest {
|
||||
service.RecordChannelAffinity(c, channel.Id)
|
||||
if service.RequestRoutingPolicy(c) != nil {
|
||||
service.RecordPoolAffinity(c)
|
||||
} else {
|
||||
service.RecordChannelAffinity(c, channel.Id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// noAvailableChannelMessage explains a 503 for a task-plugin-claimed model.
|
||||
// A model claimed by a plugin is served only by that plugin's channels, so the
|
||||
// generic "no channel" text hides the real cause: the claiming plugin has no
|
||||
// enabled channel, and the operator must disable or override that plugin for
|
||||
// any other plugin or channel to take the model. Non-plugin requests keep the
|
||||
// generic message.
|
||||
// The response tells the caller the model is plugin-claimed without naming the
|
||||
// plugin; the candidate plugin keys go to the server log under the request id.
|
||||
func noAvailableChannelMessage(c *gin.Context, group, modelName string) string {
|
||||
value, exists := c.Get(jsplugin.ContextKeyPinnedPlugin)
|
||||
pinned, ok := value.(jsplugin.PinnedPlugin)
|
||||
if exists && ok && pinned.Plugin != nil {
|
||||
return i18n.T(c, i18n.MsgDistributorNoAvailableChannelTaskPlugin, map[string]any{"Group": group, "Model": modelName, "Plugin": pinned.Plugin.Meta.Key})
|
||||
keys := []string{pinned.Plugin.Meta.Key}
|
||||
if value, exists := c.Get(jsplugin.ContextKeyPinnedEndpoint); exists {
|
||||
if endpoint, ok := value.(jsplugin.PinnedEndpoint); ok && len(endpoint.Candidates) > 0 {
|
||||
keys = nil
|
||||
for _, candidate := range endpoint.Candidates {
|
||||
if candidate.Plugin != nil {
|
||||
keys = append(keys, candidate.Plugin.Meta.Key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
logger.LogWarn(c, "task_plugin subsystem=distribution event=no_available_channel group=%q model=%q plugins=%q reason=no_eligible_channel", group, modelName, strings.Join(keys, ","))
|
||||
return i18n.T(c, i18n.MsgDistributorNoAvailableChannelTaskPlugin, map[string]any{"Group": group, "Model": modelName})
|
||||
}
|
||||
return i18n.T(c, i18n.MsgDistributorNoAvailableChannel, map[string]any{"Group": group, "Model": modelName})
|
||||
}
|
||||
@@ -327,6 +274,12 @@ func getModelFromJSONBody(c *gin.Context) (*ModelRequest, error) {
|
||||
}
|
||||
|
||||
values := gjson.GetManyBytes(requestBody, "model", "group")
|
||||
for _, key := range []string{"session_id", "thread_id"} {
|
||||
value := gjson.GetBytes(requestBody, key)
|
||||
if value.Type == gjson.String {
|
||||
c.Set("request_"+key, value.String())
|
||||
}
|
||||
}
|
||||
model, err := getJSONStringValue(values[0], "model")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -569,10 +522,11 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) {
|
||||
return &modelRequest, shouldSelectChannel, nil
|
||||
}
|
||||
|
||||
// tokenModelLimitAllows reports whether a token model-limit map authorizes
|
||||
// TokenModelLimitAllows reports whether a token model-limit map authorizes
|
||||
// model. Exact name, wildcard-normalized name, and routing-normalized name
|
||||
// (modifiers and legacy aliases stripped) are all accepted.
|
||||
func tokenModelLimitAllows(limit map[string]bool, model string) bool {
|
||||
// (modifiers and legacy aliases stripped) are all accepted. The Responses
|
||||
// WebSocket relay shares this rule so both transports admit the same names.
|
||||
func TokenModelLimitAllows(limit map[string]bool, model string) bool {
|
||||
if limit[model] {
|
||||
return true
|
||||
}
|
||||
|
||||
+109
-19
@@ -1,11 +1,14 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/i18n"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
@@ -154,15 +157,15 @@ export const protocols = {openai_responses: {
|
||||
|
||||
func TestTokenModelLimitAllowsLegacyAliasAndModifierVariant(t *testing.T) {
|
||||
aliasOnly := map[string]bool{"claude-3-7-sonnet-thinking": true}
|
||||
assert.True(t, tokenModelLimitAllows(aliasOnly, "claude-3-7-sonnet-thinking"))
|
||||
assert.False(t, tokenModelLimitAllows(aliasOnly, "claude-3-7-sonnet"))
|
||||
assert.True(t, TokenModelLimitAllows(aliasOnly, "claude-3-7-sonnet-thinking"))
|
||||
assert.False(t, TokenModelLimitAllows(aliasOnly, "claude-3-7-sonnet"))
|
||||
|
||||
baseOnly := map[string]bool{"claude-3-7-sonnet": true}
|
||||
assert.True(t, tokenModelLimitAllows(baseOnly, "claude-3-7-sonnet@thinking:on"))
|
||||
assert.True(t, tokenModelLimitAllows(baseOnly, "claude-3-7-sonnet-thinking"))
|
||||
assert.True(t, TokenModelLimitAllows(baseOnly, "claude-3-7-sonnet@thinking:on"))
|
||||
assert.True(t, TokenModelLimitAllows(baseOnly, "claude-3-7-sonnet-thinking"))
|
||||
|
||||
wildcard := map[string]bool{"gemini-2.5-flash-thinking-*": true}
|
||||
assert.True(t, tokenModelLimitAllows(wildcard, "gemini-2.5-flash-thinking-8192"))
|
||||
assert.True(t, TokenModelLimitAllows(wildcard, "gemini-2.5-flash-thinking-8192"))
|
||||
}
|
||||
|
||||
func TestTokenModelLimitAllowsExemptAtNameByFullName(t *testing.T) {
|
||||
@@ -172,27 +175,90 @@ func TestTokenModelLimitAllowsExemptAtNameByFullName(t *testing.T) {
|
||||
settings.ThinkingModelBlacklist = append(original, "re:.*@sha256:.*")
|
||||
|
||||
fullOnly := map[string]bool{"opaque@sha256:deadbeef": true}
|
||||
assert.True(t, tokenModelLimitAllows(fullOnly, "opaque@sha256:deadbeef"))
|
||||
assert.True(t, TokenModelLimitAllows(fullOnly, "opaque@sha256:deadbeef"))
|
||||
|
||||
baseOnly := map[string]bool{"opaque": true}
|
||||
assert.False(t, tokenModelLimitAllows(baseOnly, "opaque@sha256:deadbeef"))
|
||||
assert.False(t, TokenModelLimitAllows(baseOnly, "opaque@sha256:deadbeef"))
|
||||
}
|
||||
|
||||
func TestNoAvailableChannelMessageNamesClaimingTaskPlugin(t *testing.T) {
|
||||
func TestDistributeHidesTaskPluginDetailsButLogsDiagnostics(t *testing.T) {
|
||||
require.NoError(t, i18n.Init())
|
||||
registry := jsplugin.NewRegistry()
|
||||
plugin, err := registry.Register(distributorTaskPluginSource("claimer", constant.ChannelTypeKling), jsplugin.Options{})
|
||||
require.NoError(t, err)
|
||||
previousCacheEnabled := common.MemoryCacheEnabled
|
||||
common.MemoryCacheEnabled = true
|
||||
t.Cleanup(func() { common.MemoryCacheEnabled = previousCacheEnabled })
|
||||
|
||||
pinned, _ := gin.CreateTestContext(nil)
|
||||
pinned.Request = httptest.NewRequest(http.MethodPost, "/v1/videos", nil)
|
||||
pinned.Request.Header.Set("Accept-Language", "en")
|
||||
pinned.Set(jsplugin.ContextKeyPinnedPlugin, jsplugin.PinnedPlugin{Generation: registry.Generation(), Plugin: plugin})
|
||||
message := noAvailableChannelMessage(pinned, "default", "kling-v1")
|
||||
assert.Contains(t, message, `"claimer"`)
|
||||
assert.Contains(t, message, "disable or override")
|
||||
assert.Contains(t, message, "kling-v1")
|
||||
const group = "private-plugin-error-test-group"
|
||||
for _, locale := range []struct{ language, message string }{
|
||||
{"en", "No available channel for model task-model under group " + group + ": the model is claimed by a task plugin, which has no enabled channel serving it (distributor)"},
|
||||
{"zh-CN", "分组 " + group + " 下模型 task-model 无可用渠道:该模型由任务插件认领,但当前没有启用的渠道可服务此模型(distributor)"},
|
||||
{"zh-TW", "分組 " + group + " 下模型 task-model 無可用管道:該模型由任務插件認領,但目前沒有啟用的管道可服務此模型(distributor)"},
|
||||
} {
|
||||
for _, providerCount := range []int{1, 2} {
|
||||
t.Run(fmt.Sprintf("%s/%d_providers", locale.language, providerCount), func(t *testing.T) {
|
||||
registry := jsplugin.NewRegistry()
|
||||
keys := []string{"private-provider-alpha", "private-provider-beta"}[:providerCount]
|
||||
for index, key := range keys {
|
||||
_, err := registry.Register(distributorEndpointPluginSource(key, constant.ChannelTypeKling+index), jsplugin.Options{})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
generation := registry.Generation()
|
||||
candidates := generation.LookupEndpointCandidates("POST", "/v1/responses", "task-model")
|
||||
require.Len(t, candidates, providerCount)
|
||||
|
||||
var logs bytes.Buffer
|
||||
common.LogWriterMu.Lock()
|
||||
previousWriter := gin.DefaultErrorWriter
|
||||
gin.DefaultErrorWriter = &logs
|
||||
common.LogWriterMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
common.LogWriterMu.Lock()
|
||||
gin.DefaultErrorWriter = previousWriter
|
||||
common.LogWriterMu.Unlock()
|
||||
})
|
||||
|
||||
router := gin.New()
|
||||
router.POST("/v1/responses", RequestId(), func(c *gin.Context) {
|
||||
common.SetContextKey(c, constant.ContextKeyUsingGroup, group)
|
||||
c.Set("resolved_task_model", "task-model")
|
||||
c.Set("expected_task_plugin_key", keys[0])
|
||||
c.Set(jsplugin.ContextKeyPinnedPlugin, jsplugin.PinnedPlugin{Generation: generation, Plugin: candidates[0].Plugin})
|
||||
if providerCount > 1 {
|
||||
c.Set(jsplugin.ContextKeyPinnedEndpoint, jsplugin.PinnedEndpoint{
|
||||
Generation: generation, Plugin: candidates[0].Plugin,
|
||||
Protocol: candidates[0].Protocol, Operation: candidates[0].Operation,
|
||||
Model: "task-model", Candidates: candidates,
|
||||
})
|
||||
}
|
||||
}, Distribute(), func(c *gin.Context) {
|
||||
t.Error("unavailable requests must stop before the relay handler")
|
||||
c.Status(http.StatusNoContent)
|
||||
})
|
||||
request := httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
|
||||
request.Header.Set("Accept-Language", locale.language)
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
require.Equal(t, http.StatusServiceUnavailable, recorder.Code)
|
||||
requestID := recorder.Header().Get(common.RequestIdKey)
|
||||
require.NotEmpty(t, requestID)
|
||||
assert.JSONEq(t, fmt.Sprintf(`{"error":{"message":%q,"type":"new_api_error","code":"model_not_found"}}`,
|
||||
locale.message+" (request id: "+requestID+")"), recorder.Body.String())
|
||||
assert.NotContains(t, recorder.Body.String(), "disable or override")
|
||||
for _, key := range keys {
|
||||
assert.NotContains(t, recorder.Body.String(), key)
|
||||
assert.Contains(t, logs.String(), key)
|
||||
}
|
||||
assert.Contains(t, logs.String(), requestID)
|
||||
assert.Contains(t, logs.String(), `group="`+group+`"`)
|
||||
assert.Contains(t, logs.String(), `model="task-model"`)
|
||||
assert.Contains(t, logs.String(), "reason=no_eligible_channel")
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoAvailableChannelMessageWithoutPlugin(t *testing.T) {
|
||||
require.NoError(t, i18n.Init())
|
||||
plain, _ := gin.CreateTestContext(nil)
|
||||
plain.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
|
||||
plain.Request.Header.Set("Accept-Language", "en")
|
||||
@@ -200,3 +266,27 @@ func TestNoAvailableChannelMessageNamesClaimingTaskPlugin(t *testing.T) {
|
||||
assert.NotContains(t, generic, "task plugin")
|
||||
assert.Contains(t, generic, "gpt-4o")
|
||||
}
|
||||
|
||||
func TestSharedEndpointRebindsToSelectedType61Plugin(t *testing.T) {
|
||||
registry := jsplugin.NewRegistry()
|
||||
for _, key := range []string{"alpha", "beta"} {
|
||||
source := strings.Replace(distributorEndpointPluginSource(key, 0), "channelTypes: [0],", "", 1)
|
||||
_, err := registry.Register(source, jsplugin.Options{})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
generation := registry.Generation()
|
||||
candidates := generation.LookupEndpointCandidates("POST", "/v1/responses", "task-model")
|
||||
require.Len(t, candidates, 2)
|
||||
c, _ := gin.CreateTestContext(nil)
|
||||
c.Set(jsplugin.ContextKeyPinnedPlugin, jsplugin.PinnedPlugin{Generation: generation, Plugin: candidates[0].Plugin})
|
||||
c.Set(jsplugin.ContextKeyPinnedEndpoint, jsplugin.PinnedEndpoint{Generation: generation, Plugin: candidates[0].Plugin, Protocol: candidates[0].Protocol, Operation: candidates[0].Operation, Model: "task-model", Candidates: candidates})
|
||||
c.Set("expected_task_plugin_key", "alpha")
|
||||
channel := &model.Channel{Id: 2, Type: constant.ChannelTypeTaskPlugin}
|
||||
channel.SetSetting(dto.ChannelSettings{TaskPluginKey: "unrelated"})
|
||||
assert.False(t, channelMatchesExpectedTaskPlugin(c, channel, "alpha"))
|
||||
channel.SetSetting(dto.ChannelSettings{TaskPluginKey: "beta"})
|
||||
require.Nil(t, SetupContextForSelectedChannel(c, channel, "task-model"))
|
||||
assert.Equal(t, "beta", c.GetString("task_plugin_key"))
|
||||
assert.Equal(t, "beta", c.GetString("expected_task_plugin_key"))
|
||||
assert.Equal(t, "beta", c.MustGet(jsplugin.ContextKeyPinnedEndpoint).(jsplugin.PinnedEndpoint).Plugin.Meta.Key)
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/common/limiter"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/setting"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -117,6 +118,7 @@ func redisRateLimitHandler(duration int64, totalMaxCount, successMaxCount int) g
|
||||
|
||||
if !allowed {
|
||||
abortWithOpenAiMessage(c, http.StatusTooManyRequests, fmt.Sprintf("您已达到总请求数限制:%d分钟内最多请求%d次,包括失败次数,请检查您的请求是否正确", setting.ModelRequestRateLimitDurationMinutes, totalMaxCount))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,7 +126,7 @@ func redisRateLimitHandler(duration int64, totalMaxCount, successMaxCount int) g
|
||||
c.Next()
|
||||
|
||||
// 5. 如果请求成功,记录成功请求
|
||||
if c.Writer.Status() < 400 {
|
||||
if modelRequestSucceeded(c) {
|
||||
recordRedisRequest(ctx, rdb, successKey, successMaxCount)
|
||||
}
|
||||
}
|
||||
@@ -146,25 +148,29 @@ func memoryRateLimitHandler(duration int64, totalMaxCount, successMaxCount int)
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 检查成功请求数限制
|
||||
// 使用一个临时key来检查限制,这样可以避免实际记录
|
||||
checkKey := successKey + "_check"
|
||||
if !inMemoryRateLimiter.Request(checkKey, successMaxCount, duration) {
|
||||
c.Status(http.StatusTooManyRequests)
|
||||
c.Abort()
|
||||
return
|
||||
var reservation *common.RateLimitReservation
|
||||
if successMaxCount > 0 {
|
||||
reservation = inMemoryRateLimiter.Reserve(successKey, successMaxCount, duration)
|
||||
if reservation == nil {
|
||||
c.AbortWithStatus(http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
defer reservation.Complete(false)
|
||||
}
|
||||
|
||||
// 3. 处理请求
|
||||
c.Next()
|
||||
|
||||
// 4. 如果请求成功,记录到实际的成功请求计数中
|
||||
if c.Writer.Status() < 400 {
|
||||
inMemoryRateLimiter.Request(successKey, successMaxCount, duration)
|
||||
}
|
||||
reservation.Complete(modelRequestSucceeded(c))
|
||||
}
|
||||
}
|
||||
|
||||
func modelRequestSucceeded(c *gin.Context) bool {
|
||||
status, _ := common.GetContextKeyType[*relaycommon.StreamStatus](c, constant.ContextKeyResponseStreamStatus)
|
||||
return c.Writer.Status() < 400 && !status.ResponseFailed()
|
||||
}
|
||||
|
||||
// ModelRequestRateLimit 模型请求限流中间件
|
||||
func ModelRequestRateLimit() func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
|
||||
@@ -2,9 +2,16 @@ package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -32,3 +39,64 @@ func TestModelRedisRateLimitUsesUTCRegardlessOfLocalTimezone(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
assert.False(t, allowed, "an existing UTC timestamp inside the window must remain limited on a non-UTC host")
|
||||
}
|
||||
|
||||
var modelRateLimitTestUsers atomic.Int64
|
||||
|
||||
func TestModelRateLimitStreamFailuresDoNotConsumeSuccessLimit(t *testing.T) {
|
||||
for _, backend := range []string{"memory", "redis"} {
|
||||
for _, totalLimit := range []int{0, 2} {
|
||||
t.Run(fmt.Sprintf("%s/total=%d", backend, totalLimit), func(t *testing.T) {
|
||||
userID := 7200000 + int(modelRateLimitTestUsers.Add(1))
|
||||
handler := memoryRateLimitHandler(60, totalLimit, 1)
|
||||
if backend == "redis" {
|
||||
useRateLimitMiniRedis(t)
|
||||
handler = redisRateLimitHandler(60, totalLimit, 1)
|
||||
}
|
||||
router := gin.New()
|
||||
router.GET("/:outcome", func(c *gin.Context) { c.Set("id", userID) }, handler, func(c *gin.Context) {
|
||||
status := relaycommon.NewStreamStatus()
|
||||
if c.Param("outcome") == "failed" {
|
||||
status.MarkFailed("server_error", "", 0)
|
||||
} else {
|
||||
status.MarkCompleted()
|
||||
}
|
||||
common.SetContextKey(c, constant.ContextKeyResponseStreamStatus, status)
|
||||
c.Status(http.StatusOK)
|
||||
})
|
||||
assert.Equal(t, http.StatusOK, performRateLimitRequest(router, "/failed", "127.0.0.1:1000").Code)
|
||||
if totalLimit > 0 {
|
||||
assert.Equal(t, http.StatusOK, performRateLimitRequest(router, "/failed", "127.0.0.1:1000").Code)
|
||||
} else {
|
||||
assert.Equal(t, http.StatusOK, performRateLimitRequest(router, "/completed", "127.0.0.1:1000").Code)
|
||||
}
|
||||
assert.Equal(t, http.StatusTooManyRequests, performRateLimitRequest(router, "/completed", "127.0.0.1:1000").Code)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelMemoryRateLimitReservesConcurrentSuccessAdmission(t *testing.T) {
|
||||
userID := 7200000 + int(modelRateLimitTestUsers.Add(1))
|
||||
entered, release, finished := make(chan struct{}), make(chan struct{}), make(chan struct{})
|
||||
router := gin.New()
|
||||
router.GET("/:outcome", func(c *gin.Context) { c.Set("id", userID) }, memoryRateLimitHandler(60, 0, 1), func(c *gin.Context) {
|
||||
if c.Param("outcome") == "failed" {
|
||||
close(entered)
|
||||
<-release
|
||||
status := relaycommon.NewStreamStatus()
|
||||
status.MarkFailed("server_error", "", 0)
|
||||
common.SetContextKey(c, constant.ContextKeyResponseStreamStatus, status)
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
})
|
||||
go func() {
|
||||
defer close(finished)
|
||||
assert.Equal(t, http.StatusOK, performRateLimitRequest(router, "/failed", "127.0.0.1:1000").Code)
|
||||
}()
|
||||
<-entered
|
||||
assert.Equal(t, http.StatusTooManyRequests, performRateLimitRequest(router, "/completed", "127.0.0.1:1000").Code)
|
||||
close(release)
|
||||
<-finished
|
||||
assert.Equal(t, http.StatusOK, performRateLimitRequest(router, "/completed", "127.0.0.1:1000").Code)
|
||||
assert.Equal(t, http.StatusTooManyRequests, performRateLimitRequest(router, "/completed", "127.0.0.1:1000").Code)
|
||||
}
|
||||
+68
-70
@@ -501,8 +501,7 @@ func TaskPluginEndpointOnly(handler gin.HandlerFunc) gin.HandlerFunc {
|
||||
}
|
||||
|
||||
// PrepareTaskPluginEndpoint normalizes a claimed shared request through the
|
||||
// deterministic parser pinned before distribution. A shared-model request can
|
||||
// later rebind to another declared legacy provider from the same generation.
|
||||
// candidates pinned before distribution, retaining only plugins that accept it.
|
||||
func PrepareTaskPluginEndpoint() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
pinnedValue, exists := c.Get(pluginruntime.ContextKeyPinnedEndpoint)
|
||||
@@ -604,80 +603,79 @@ func PrepareTaskPluginEndpoint() gin.HandlerFunc {
|
||||
UpstreamModel: pinned.MappedModel,
|
||||
Stream: stream,
|
||||
}
|
||||
c.Set(pluginruntime.ContextKeyProtocolRequest, protocolContext)
|
||||
hookStarted := time.Now()
|
||||
// Parsing belongs to the durable task submission path. A client
|
||||
// disconnect only stops the later Responses observation.
|
||||
resolvedValue, callErr := pinned.Plugin.Engine.CallPathWithAdmissionTimeout(
|
||||
context.WithoutCancel(c.Request.Context()),
|
||||
pluginruntime.DefaultCallTimeout,
|
||||
"protocols",
|
||||
[]string{pinned.Protocol, "decodeRequest"},
|
||||
protocolContext.JSValue(),
|
||||
)
|
||||
if callErr != nil {
|
||||
logger.LogWarn(
|
||||
c,
|
||||
"task_plugin subsystem=endpoint event=prepare_rejected generation=%d plugin=%q stage=parse_request reason=hook_failed err=%q elapsed_ms=%d",
|
||||
pinned.Generation.Number,
|
||||
pinned.Plugin.Meta.Key,
|
||||
callErr.Error(),
|
||||
time.Since(hookStarted).Milliseconds(),
|
||||
candidates := pinned.Candidates
|
||||
if len(candidates) == 0 {
|
||||
candidates = []pluginruntime.ProtocolBinding{{Plugin: pinned.Plugin, Protocol: pinned.Protocol, Operation: pinned.Operation, Model: pinned.Model}}
|
||||
}
|
||||
accepted := make([]pluginruntime.ProtocolBinding, 0, len(candidates))
|
||||
var resolved map[string]any
|
||||
var failures []string
|
||||
rejectedPlugins := make(map[string][]string)
|
||||
for _, candidate := range candidates {
|
||||
candidateContext := protocolContext
|
||||
candidateContext.Protocol = candidate.Protocol
|
||||
candidateContext.Operation = candidate.Operation.Name
|
||||
// Parsing belongs to durable submission; disconnecting only stops
|
||||
// the later Responses observation.
|
||||
resolvedValue, callErr := candidate.Plugin.Engine.CallPathWithAdmissionTimeout(
|
||||
context.WithoutCancel(c.Request.Context()), pluginruntime.DefaultCallTimeout,
|
||||
"protocols", []string{candidate.Protocol, "decodeRequest"}, candidateContext.JSValue(),
|
||||
)
|
||||
detail := taskPluginHookDetail(callErr)
|
||||
if detail == "" {
|
||||
detail = "Invalid task protocol request"
|
||||
result, resultOK := resolvedValue.(map[string]any)
|
||||
detail := ""
|
||||
reason := ""
|
||||
if callErr != nil {
|
||||
reason = "hook_failed"
|
||||
detail = taskPluginHookDetail(callErr)
|
||||
if detail == "" {
|
||||
detail = "Invalid task protocol request"
|
||||
}
|
||||
} else if !resultOK {
|
||||
reason = "result_not_object"
|
||||
detail = taskPluginInvalidRouteResult
|
||||
} else if kind, _ := result["kind"].(string); kind != string(pluginruntime.RouteTypeSubmit) {
|
||||
reason = "unsupported_kind"
|
||||
detail = taskPluginInvalidRouteResult
|
||||
} else if model, _ := result["model"].(string); strings.TrimSpace(model) == "" {
|
||||
reason = "invalid_model"
|
||||
detail = "decoded request is missing a model"
|
||||
} else if model != pinned.Model || (pinned.MappedModel == "" && !slices.Contains(candidate.Plugin.Meta.Models, model)) {
|
||||
reason = "resolved_model_not_owned"
|
||||
detail = fmt.Sprintf("model %q is not served by this plugin", model)
|
||||
}
|
||||
if detail != "" {
|
||||
logger.LogWarn(c, "task_plugin subsystem=endpoint event=prepare_rejected generation=%d plugin=%q stage=parse_request reason=%s err=%q elapsed_ms=%d",
|
||||
pinned.Generation.Number, candidate.Plugin.Meta.Key, reason, detail, time.Since(hookStarted).Milliseconds())
|
||||
if _, seen := rejectedPlugins[detail]; !seen {
|
||||
failures = append(failures, detail)
|
||||
}
|
||||
rejectedPlugins[detail] = append(rejectedPlugins[detail], candidate.Plugin.Meta.Key)
|
||||
continue
|
||||
}
|
||||
accepted = append(accepted, candidate)
|
||||
if resolved == nil {
|
||||
resolved = result
|
||||
protocolContext = candidateContext
|
||||
}
|
||||
abortWithOpenAiMessage(c, http.StatusBadRequest, detail)
|
||||
return
|
||||
}
|
||||
resolved, ok := resolvedValue.(map[string]any)
|
||||
if !ok {
|
||||
logger.LogWarn(
|
||||
c,
|
||||
"task_plugin subsystem=endpoint event=prepare_rejected generation=%d plugin=%q stage=parse_request reason=result_not_object elapsed_ms=%d",
|
||||
pinned.Generation.Number,
|
||||
pinned.Plugin.Meta.Key,
|
||||
time.Since(hookStarted).Milliseconds(),
|
||||
)
|
||||
abortWithOpenAiMessage(c, http.StatusBadRequest, taskPluginInvalidRouteResult)
|
||||
return
|
||||
}
|
||||
if kind, _ := resolved["kind"].(string); kind != string(pluginruntime.RouteTypeSubmit) {
|
||||
logger.LogWarn(
|
||||
c,
|
||||
"task_plugin subsystem=endpoint event=prepare_rejected generation=%d plugin=%q stage=parse_request reason=unsupported_kind",
|
||||
pinned.Generation.Number,
|
||||
pinned.Plugin.Meta.Key,
|
||||
)
|
||||
abortWithOpenAiMessage(c, http.StatusBadRequest, taskPluginInvalidRouteResult)
|
||||
return
|
||||
}
|
||||
resolvedModel, ok := resolved["model"].(string)
|
||||
if !ok || strings.TrimSpace(resolvedModel) == "" {
|
||||
logger.LogWarn(
|
||||
c,
|
||||
"task_plugin subsystem=endpoint event=prepare_rejected generation=%d plugin=%q stage=parse_request reason=invalid_model",
|
||||
pinned.Generation.Number,
|
||||
pinned.Plugin.Meta.Key,
|
||||
)
|
||||
abortWithOpenAiMessage(c, http.StatusBadRequest, "decoded request is missing a model")
|
||||
return
|
||||
}
|
||||
modelOwned := slices.Contains(pinned.Plugin.Meta.Models, resolvedModel)
|
||||
mappedPin := pinned.MappedModel != ""
|
||||
if resolvedModel != pinned.Model || (!modelOwned && !mappedPin) {
|
||||
logger.LogWarn(
|
||||
c,
|
||||
"task_plugin subsystem=endpoint event=prepare_rejected generation=%d plugin=%q stage=parse_request reason=resolved_model_not_owned claimed_model=%q resolved_model=%q",
|
||||
pinned.Generation.Number,
|
||||
pinned.Plugin.Meta.Key,
|
||||
pinned.Model,
|
||||
resolvedModel,
|
||||
)
|
||||
abortWithOpenAiMessage(c, http.StatusBadRequest, fmt.Sprintf("model %q is not served by this plugin", resolvedModel))
|
||||
if len(accepted) == 0 {
|
||||
if len(candidates) > 1 {
|
||||
for index, detail := range failures {
|
||||
failures[index] = strings.Join(rejectedPlugins[detail], ", ") + ": " + detail
|
||||
}
|
||||
}
|
||||
abortWithOpenAiMessage(c, http.StatusBadRequest, strings.Join(failures, "; "))
|
||||
return
|
||||
}
|
||||
pinned.Candidates = accepted
|
||||
pinned.Plugin = accepted[0].Plugin
|
||||
pinned.Protocol = accepted[0].Protocol
|
||||
pinned.Operation = accepted[0].Operation
|
||||
c.Set(pluginruntime.ContextKeyPinnedEndpoint, pinned)
|
||||
c.Set(pluginruntime.ContextKeyPinnedPlugin, pluginruntime.PinnedPlugin{Generation: pinned.Generation, Plugin: pinned.Plugin})
|
||||
c.Set(pluginruntime.ContextKeyProtocolRequest, protocolContext)
|
||||
resolvedModel := pinned.Model
|
||||
|
||||
action := ""
|
||||
if resolvedAction, present := resolved["action"]; present {
|
||||
|
||||
@@ -15,8 +15,6 @@ import (
|
||||
appI18n "github.com/QuantumNous/new-api/i18n"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/pkg/jsplugin"
|
||||
"github.com/QuantumNous/new-api/relay"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
@@ -222,28 +220,6 @@ func TestApplyOriginTaskIntentAbsentAndEmptyAreNoop(t *testing.T) {
|
||||
assert.False(t, pinned)
|
||||
}
|
||||
|
||||
func TestApplyOriginTaskAffinitySetsLockedChannel(t *testing.T) {
|
||||
setupOriginTaskDB(t)
|
||||
channel := insertOriginTaskChannel(t, common.ChannelStatusEnabled)
|
||||
insertOriginOwnedTask(t, "task-lock", 7, channel.Id, "origin-plugin")
|
||||
|
||||
c := originTaskTestContext(7)
|
||||
require.Nil(t, applyOriginTaskIntent(c, map[string]any{"originTaskIds": []any{"task-lock"}}, jsplugin.Meta{Key: "origin-plugin"}))
|
||||
|
||||
info := &relaycommon.RelayInfo{TaskRelayInfo: &relaycommon.TaskRelayInfo{}}
|
||||
taskErr := relay.ApplyOriginTaskAffinity(c, info)
|
||||
require.Nil(t, taskErr)
|
||||
locked, ok := info.LockedChannel.(*model.Channel)
|
||||
require.True(t, ok)
|
||||
require.NotNil(t, locked)
|
||||
assert.Equal(t, channel.Id, locked.Id)
|
||||
require.Len(t, info.OriginTasks, 1)
|
||||
assert.Equal(t, "task-lock", info.OriginTasks[0].TaskID)
|
||||
assert.Equal(t, "upstream-task-lock", info.OriginTasks[0].UpstreamTaskID)
|
||||
assert.Equal(t, "text_to_video", info.OriginTasks[0].Action)
|
||||
assert.Equal(t, string(model.TaskStatusSuccess), info.OriginTasks[0].Status)
|
||||
}
|
||||
|
||||
func TestPrepareTaskPluginRoutePinsOriginTaskChannel(t *testing.T) {
|
||||
setupOriginTaskDB(t)
|
||||
channel := insertOriginTaskChannel(t, common.ChannelStatusEnabled)
|
||||
@@ -472,29 +448,3 @@ func TestDistributePinViolatingIdentityFilterErrors(t *testing.T) {
|
||||
assert.Equal(t, http.StatusBadRequest, recorder.Code)
|
||||
assert.Contains(t, recorder.Body.String(), string(dto.FilterTaskPluginIdentity))
|
||||
}
|
||||
|
||||
func TestApplyChannelPinLocksOnlySameChannelRetry(t *testing.T) {
|
||||
setupOriginTaskDB(t)
|
||||
channel := insertOriginTaskChannel(t, common.ChannelStatusEnabled)
|
||||
insertOriginOwnedTask(t, "task-lock-mode", 7, channel.Id, "origin-plugin")
|
||||
|
||||
c := originTaskTestContext(7)
|
||||
require.Nil(t, applyOriginTaskIntent(c, map[string]any{"originTaskIds": []any{"task-lock-mode"}}, jsplugin.Meta{Key: "origin-plugin"}))
|
||||
|
||||
info := &relaycommon.RelayInfo{TaskRelayInfo: &relaycommon.TaskRelayInfo{}}
|
||||
require.Nil(t, relay.ApplyChannelPin(c, info))
|
||||
locked, ok := info.LockedChannel.(*model.Channel)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, channel.Id, locked.Id)
|
||||
|
||||
tokenOnly := originTaskTestContext(7)
|
||||
service.GetChannelConstraints(tokenOnly).AddPin(dto.ChannelPin{
|
||||
ChannelId: channel.Id,
|
||||
Source: dto.PinSourceToken,
|
||||
Rank: dto.PinRankToken,
|
||||
RetryMode: dto.PinRetrySingleAttempt,
|
||||
})
|
||||
tokenInfo := &relaycommon.RelayInfo{TaskRelayInfo: &relaycommon.TaskRelayInfo{}}
|
||||
require.Nil(t, relay.ApplyChannelPin(tokenOnly, tokenInfo))
|
||||
assert.Nil(t, tokenInfo.LockedChannel)
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/pkg/jsplugin"
|
||||
builtinplugins "github.com/QuantumNous/new-api/plugins"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -1698,3 +1699,52 @@ func insertTaskPluginRouteTask(t *testing.T, task *model.Task) {
|
||||
t.Helper()
|
||||
require.NoError(t, model.DB.Create(task).Error)
|
||||
}
|
||||
|
||||
func TestPrepareTaskPluginEndpointFiltersEachSharedCandidate(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, alpha, beta string
|
||||
wantKeys []string
|
||||
wantError string
|
||||
}{
|
||||
{name: "first decoder rejects", alpha: `throw new Error("alpha only accepts 720p")`, beta: `return {model:ctx.model,action:"beta",requestBody:{resolution:"1080p"}}`, wantKeys: []string{"decode-beta"}},
|
||||
{name: "second decoder rejects", alpha: `return {model:ctx.model,action:"alpha"}`, beta: `throw new Error("beta rejects")`, wantKeys: []string{"decode-alpha"}},
|
||||
{name: "both decoders accept", alpha: `return {model:ctx.model,action:"alpha"}`, beta: `return {model:ctx.model,action:"beta"}`, wantKeys: []string{"decode-alpha", "decode-beta"}},
|
||||
{name: "all decoders reject", alpha: `throw new Error("alpha rejects first")`, beta: `throw new Error("beta rejects second")`, wantError: "decode-alpha: alpha rejects first; decode-beta: beta rejects second"},
|
||||
{name: "duplicate failures are grouped", alpha: `throw new Error("unsupported resolution")`, beta: `throw new Error("unsupported resolution")`, wantError: "decode-alpha, decode-beta: unsupported resolution"},
|
||||
{name: "invalid result is excluded", alpha: `return {kind:"query",model:ctx.model}`, beta: `return {model:ctx.model,action:"beta"}`, wantKeys: []string{"decode-beta"}},
|
||||
{name: "rewritten model is excluded", alpha: `return {model:"another-model"}`, beta: `return {model:ctx.model,action:"beta"}`, wantKeys: []string{"decode-beta"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
for _, spec := range []struct{ key, decode string }{{"decode-alpha", tc.alpha}, {"decode-beta", tc.beta}} {
|
||||
_, err := jsplugin.DefaultRegistry.Register(taskResponsesPluginSource(spec.key, 0, `["decode-shared-model"]`, `["sync"]`, `renderFinal:function(){return {};}`, spec.decode), jsplugin.Options{})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { require.NoError(t, jsplugin.DefaultRegistry.Unregister(spec.key)) })
|
||||
}
|
||||
var gotKeys []string
|
||||
router := gin.New()
|
||||
router.POST("/v1/responses", PinTaskPluginEndpoint(), PrepareTaskPluginEndpoint(), func(c *gin.Context) {
|
||||
pinned := c.MustGet(jsplugin.ContextKeyPinnedEndpoint).(jsplugin.PinnedEndpoint)
|
||||
for _, candidate := range pinned.Candidates {
|
||||
gotKeys = append(gotKeys, candidate.Plugin.Meta.Key)
|
||||
}
|
||||
assert.Equal(t, tc.wantKeys[0], pinned.Plugin.Meta.Key)
|
||||
assert.Equal(t, tc.wantKeys[0], c.GetString("task_plugin_key"))
|
||||
assert.Same(t, pinned.Plugin, c.MustGet(jsplugin.ContextKeyPinnedPlugin).(jsplugin.PinnedPlugin).Plugin)
|
||||
assert.Equal(t, tc.wantKeys, service.GetChannelConstraints(c).Filters[0].TaskPluginKeys)
|
||||
c.Status(http.StatusNoContent)
|
||||
})
|
||||
request := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"decode-shared-model","resolution":"1080p"}`))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, request)
|
||||
if tc.wantError != "" {
|
||||
assert.Equal(t, http.StatusBadRequest, recorder.Code)
|
||||
assert.Contains(t, recorder.Body.String(), tc.wantError)
|
||||
assert.Empty(t, gotKeys)
|
||||
} else {
|
||||
assert.Equal(t, http.StatusNoContent, recorder.Code, recorder.Body.String())
|
||||
assert.Equal(t, tc.wantKeys, gotKeys)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user