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:
cneicy committed 2026-09-26 04:49:11 +08:00
1 parent aa6c3f50f4
commit ceddfd3d6b
828 files changed
+122253 -13702

No files matched your search

+45 -19
View File
@@ -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 {
+79
View File
@@ -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
View File
@@ -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
View File
@@ -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)
}
+17 -11
View File
@@ -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) {
+68
View File
@@ -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
View File
@@ -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)
}
+50
View File
@@ -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)
}
})
}
}