mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-09-11 22:49:57 +00:00
feat(task): replace built-in task adaptors with a sandboxed JS plugin system (#7076)
This commit is contained in:
+13
-1
@@ -5,10 +5,12 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
"github.com/QuantumNous/new-api/i18n"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
@@ -515,7 +517,17 @@ func SetupContextForToken(c *gin.Context, token *model.Token, parts ...string) e
|
||||
}
|
||||
if len(parts) > 1 {
|
||||
if model.IsAdmin(token.UserId) {
|
||||
c.Set("specific_channel_id", parts[1])
|
||||
id, err := strconv.Atoi(parts[1])
|
||||
if err != nil {
|
||||
abortWithOpenAiMessage(c, http.StatusBadRequest, i18n.T(c, i18n.MsgDistributorInvalidChannelId))
|
||||
return fmt.Errorf("invalid specific channel id")
|
||||
}
|
||||
service.GetChannelConstraints(c).AddPin(dto.ChannelPin{
|
||||
ChannelId: id,
|
||||
Source: dto.PinSourceToken,
|
||||
Rank: dto.PinRankToken,
|
||||
RetryMode: dto.PinRetrySingleAttempt,
|
||||
})
|
||||
} else {
|
||||
c.Header("specific_channel_version", "701e3ae1dc3f7975556d354e0675168d004891c8")
|
||||
abortWithOpenAiMessage(c, http.StatusForbidden, "普通用户不支持指定渠道")
|
||||
|
||||
@@ -10,13 +10,13 @@ import (
|
||||
// 在请求处理完成后自动清理磁盘/内存缓存
|
||||
func BodyStorageCleanup() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// 处理请求
|
||||
defer func() {
|
||||
// 请求结束后清理存储
|
||||
common.CleanupBodyStorage(c)
|
||||
|
||||
// 清理文件缓存(URL 下载的文件等)
|
||||
service.CleanupFileSources(c)
|
||||
}()
|
||||
c.Next()
|
||||
|
||||
// 请求结束后清理存储
|
||||
common.CleanupBodyStorage(c)
|
||||
|
||||
// 清理文件缓存(URL 下载的文件等)
|
||||
service.CleanupFileSources(c)
|
||||
}
|
||||
}
|
||||
|
||||
+212
-35
@@ -6,7 +6,6 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -14,7 +13,9 @@ import (
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
taskdto "github.com/QuantumNous/new-api/dto"
|
||||
"github.com/QuantumNous/new-api/i18n"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/pkg/jsplugin"
|
||||
relayconstant "github.com/QuantumNous/new-api/relay/constant"
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
"github.com/QuantumNous/new-api/relaykit/types"
|
||||
@@ -33,25 +34,46 @@ type ModelRequest struct {
|
||||
func Distribute() func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var channel *model.Channel
|
||||
channelId, ok := common.GetContextKey(c, constant.ContextKeyTokenSpecificChannelId)
|
||||
constraints := service.GetChannelConstraints(c)
|
||||
constraints.AddFilter(taskdto.ChannelFilter{
|
||||
Kind: taskdto.FilterRequestPath,
|
||||
RequestPath: c.Request.URL.Path,
|
||||
})
|
||||
service.AppendTaskPluginIdentityFilter(c, c.GetString("expected_task_plugin_key"))
|
||||
modelRequest, shouldSelectChannel, err := getModelRequest(c)
|
||||
if err != nil {
|
||||
abortWithOpenAiMessage(c, http.StatusBadRequest, i18n.T(c, i18n.MsgDistributorInvalidRequest, map[string]any{"Error": err.Error()}))
|
||||
return
|
||||
}
|
||||
if ok {
|
||||
id, err := strconv.Atoi(channelId.(string))
|
||||
if err != nil {
|
||||
abortWithOpenAiMessage(c, http.StatusBadRequest, i18n.T(c, i18n.MsgDistributorInvalidChannelId))
|
||||
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.GetChannelById(id, true)
|
||||
channel, err = model.CacheGetChannel(pin.ChannelId)
|
||||
if err != nil {
|
||||
abortWithOpenAiMessage(c, http.StatusBadRequest, i18n.T(c, i18n.MsgDistributorInvalidChannelId))
|
||||
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 {
|
||||
abortWithOpenAiMessage(c, http.StatusForbidden, i18n.T(c, i18n.MsgDistributorChannelDisabled))
|
||||
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 {
|
||||
@@ -105,8 +127,11 @@ func Distribute() func(c *gin.Context) {
|
||||
if preferredChannelID, found := service.GetPreferredChannelByAffinity(c, modelRequest.Model, usingGroup); found {
|
||||
affinityUsable := false
|
||||
preferred, err := model.CacheGetChannel(preferredChannelID)
|
||||
if err == nil && preferred != nil && preferred.Status == common.ChannelStatusEnabled &&
|
||||
channelSupportsRequestPath(preferred, c.Request.URL.Path, modelRequest.Model) {
|
||||
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)
|
||||
@@ -161,6 +186,15 @@ func Distribute() func(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
}
|
||||
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")
|
||||
}
|
||||
abortWithOpenAiMessage(c, http.StatusServiceUnavailable, i18n.T(c, i18n.MsgDistributorNoAvailableChannel, map[string]any{"Group": common.GetContextKeyString(c, constant.ContextKeyUsingGroup), "Model": modelRequest.Model}), types.ErrorCodeModelNotFound)
|
||||
return
|
||||
}
|
||||
}
|
||||
common.SetContextKey(c, constant.ContextKeyRequestStartTime, time.Now())
|
||||
SetupContextForSelectedChannel(c, channel, modelRequest.Model)
|
||||
c.Next()
|
||||
@@ -170,18 +204,68 @@ func Distribute() func(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// channelSupportsRequestPath reports whether a channel can serve the request path.
|
||||
// Only Advanced Custom (type 58) channels are path-checked; all other channel types
|
||||
// always pass. A type-58 channel is usable only when one of its routes matches.
|
||||
func channelSupportsRequestPath(channel *model.Channel, requestPath string, requestModel string) bool {
|
||||
func channelMatchesExpectedTaskPlugin(c *gin.Context, channel *model.Channel, expected string) bool {
|
||||
if channel == nil {
|
||||
return false
|
||||
}
|
||||
if channel.Type != constant.ChannelTypeAdvancedCustom {
|
||||
if c != nil {
|
||||
if _, matched := pinnedEndpointCandidateForChannel(c, channel, expected); matched {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if channel.Type == constant.ChannelTypeTaskPlugin {
|
||||
return expected != "" && channel.GetSetting().TaskPluginKey == expected
|
||||
}
|
||||
if expected == "" {
|
||||
return true
|
||||
}
|
||||
config := channel.GetOtherSettings().AdvancedCustom
|
||||
return config != nil && config.SupportsPathForModel(requestPath, requestModel)
|
||||
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
value, exists := c.Get(jsplugin.ContextKeyPinnedPlugin)
|
||||
pinned, ok := value.(jsplugin.PinnedPlugin)
|
||||
if !exists || !ok || pinned.Generation == nil || pinned.Plugin == nil || pinned.Plugin.Meta.Key != expected {
|
||||
return false
|
||||
}
|
||||
plugin, ok := pinned.Generation.GetByChannelType(channel.Type)
|
||||
return ok && plugin == pinned.Plugin
|
||||
}
|
||||
|
||||
func pinnedEndpointCandidateForChannel(c *gin.Context, channel *model.Channel, expected string) (jsplugin.ProtocolBinding, bool) {
|
||||
if c == nil || channel == nil || expected == "" {
|
||||
return jsplugin.ProtocolBinding{}, false
|
||||
}
|
||||
value, exists := c.Get(jsplugin.ContextKeyPinnedEndpoint)
|
||||
pinned, ok := value.(jsplugin.PinnedEndpoint)
|
||||
if !exists || !ok || pinned.Generation == nil || pinned.Plugin == nil {
|
||||
return jsplugin.ProtocolBinding{}, false
|
||||
}
|
||||
candidates := pinned.Candidates
|
||||
if len(candidates) == 0 {
|
||||
candidates = []jsplugin.ProtocolBinding{{Plugin: pinned.Plugin, Protocol: pinned.Protocol, Operation: pinned.Operation, Model: pinned.Model}}
|
||||
}
|
||||
expectedOwned := false
|
||||
selected := jsplugin.ProtocolBinding{}
|
||||
for _, candidate := range candidates {
|
||||
if candidate.Plugin == nil {
|
||||
continue
|
||||
}
|
||||
if candidate.Plugin.Meta.Key == expected {
|
||||
expectedOwned = true
|
||||
}
|
||||
if channel.Type == constant.ChannelTypeTaskPlugin {
|
||||
if channel.GetSetting().TaskPluginKey == candidate.Plugin.Meta.Key {
|
||||
selected = candidate
|
||||
}
|
||||
continue
|
||||
}
|
||||
plugin, indexed := pinned.Generation.GetByChannelType(channel.Type)
|
||||
if indexed && plugin == candidate.Plugin {
|
||||
selected = candidate
|
||||
}
|
||||
}
|
||||
return selected, expectedOwned && selected.Plugin != nil
|
||||
}
|
||||
|
||||
// getModelFromRequest 从请求中读取模型信息
|
||||
@@ -190,6 +274,12 @@ func channelSupportsRequestPath(channel *model.Channel, requestPath string, requ
|
||||
// - application/x-www-form-urlencoded
|
||||
// - multipart/form-data
|
||||
func getModelFromRequest(c *gin.Context) (*ModelRequest, error) {
|
||||
if cached, exists := c.Get(contextKeyTaskPluginEndpointModel); exists {
|
||||
if modelRequest, ok := cached.(ModelRequest); ok {
|
||||
cachedRequest := modelRequest
|
||||
return &cachedRequest, nil
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(c.Request.Header.Get("Content-Type"), "application/json") {
|
||||
modelRequest, err := getModelFromJSONBody(c)
|
||||
if err != nil {
|
||||
@@ -218,6 +308,9 @@ func getModelFromJSONBody(c *gin.Context) (*ModelRequest, error) {
|
||||
if !gjson.ValidBytes(requestBody) {
|
||||
return nil, errors.New("invalid JSON request body")
|
||||
}
|
||||
if countTopLevelJSONKey(requestBody, "model") > 1 {
|
||||
return nil, errors.New("model must be provided once")
|
||||
}
|
||||
|
||||
values := gjson.GetManyBytes(requestBody, "model", "group")
|
||||
model, err := getJSONStringValue(values[0], "model")
|
||||
@@ -240,6 +333,64 @@ func getModelFromJSONBody(c *gin.Context) (*ModelRequest, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
func countTopLevelJSONKey(data []byte, target string) int {
|
||||
depth := 0
|
||||
inString := false
|
||||
escaped := false
|
||||
stringStart := 0
|
||||
expectingKey := false
|
||||
count := 0
|
||||
for index, current := range data {
|
||||
if inString {
|
||||
if escaped {
|
||||
escaped = false
|
||||
continue
|
||||
}
|
||||
if current == '\\' {
|
||||
escaped = true
|
||||
continue
|
||||
}
|
||||
if current != '"' {
|
||||
continue
|
||||
}
|
||||
inString = false
|
||||
if depth == 1 && expectingKey {
|
||||
key := string(data[stringStart:index])
|
||||
var decodedKey string
|
||||
if common.Unmarshal(data[stringStart-1:index+1], &decodedKey) == nil {
|
||||
key = decodedKey
|
||||
}
|
||||
cursor := index + 1
|
||||
for cursor < len(data) && (data[cursor] == ' ' || data[cursor] == '\t' || data[cursor] == '\r' || data[cursor] == '\n') {
|
||||
cursor++
|
||||
}
|
||||
if cursor < len(data) && data[cursor] == ':' && key == target {
|
||||
count++
|
||||
}
|
||||
expectingKey = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
switch current {
|
||||
case '"':
|
||||
inString = true
|
||||
stringStart = index + 1
|
||||
case '{':
|
||||
depth++
|
||||
if depth == 1 {
|
||||
expectingKey = true
|
||||
}
|
||||
case '}':
|
||||
depth--
|
||||
case ',':
|
||||
if depth == 1 {
|
||||
expectingKey = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func getJSONStringValue(result gjson.Result, field string) (string, error) {
|
||||
if !result.Exists() || result.Type == gjson.Null {
|
||||
return "", nil
|
||||
@@ -254,7 +405,9 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) {
|
||||
var modelRequest ModelRequest
|
||||
shouldSelectChannel := true
|
||||
var err error
|
||||
if strings.Contains(c.Request.URL.Path, "/mj/") {
|
||||
if modelName := c.GetString("resolved_task_model"); modelName != "" {
|
||||
modelRequest.Model = modelName
|
||||
} else if strings.Contains(c.Request.URL.Path, "/mj/") {
|
||||
relayMode := relayconstant.Path2RelayModeMidjourney(c.Request.URL.Path)
|
||||
if relayMode == relayconstant.RelayModeMidjourneyTaskFetch ||
|
||||
relayMode == relayconstant.RelayModeMidjourneyTaskFetchByCondition ||
|
||||
@@ -282,17 +435,6 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) {
|
||||
modelRequest.Model = midjourneyModel
|
||||
}
|
||||
c.Set("relay_mode", relayMode)
|
||||
} else if strings.Contains(c.Request.URL.Path, "/suno/") {
|
||||
relayMode := relayconstant.Path2RelaySuno(c.Request.Method, c.Request.URL.Path)
|
||||
if relayMode == relayconstant.RelayModeSunoFetch ||
|
||||
relayMode == relayconstant.RelayModeSunoFetchByID {
|
||||
shouldSelectChannel = false
|
||||
} else {
|
||||
modelName := service.CoverTaskActionToModelName(constant.TaskPlatformSuno, c.Param("action"))
|
||||
modelRequest.Model = modelName
|
||||
}
|
||||
c.Set("platform", string(constant.TaskPlatformSuno))
|
||||
c.Set("relay_mode", relayMode)
|
||||
} else if strings.Contains(c.Request.URL.Path, "/v1/videos/") && strings.HasSuffix(c.Request.URL.Path, "/remix") {
|
||||
relayMode := relayconstant.RelayModeVideoSubmit
|
||||
c.Set("relay_mode", relayMode)
|
||||
@@ -423,10 +565,6 @@ func getTaskOriginModelName(c *gin.Context) string {
|
||||
}
|
||||
|
||||
taskId := c.Param("task_id")
|
||||
if taskId == "" {
|
||||
// jimeng adapter
|
||||
taskId = c.GetString("task_id")
|
||||
}
|
||||
if taskId == "" {
|
||||
return ""
|
||||
}
|
||||
@@ -440,15 +578,54 @@ func getTaskOriginModelName(c *gin.Context) string {
|
||||
|
||||
func SetupContextForSelectedChannel(c *gin.Context, channel *model.Channel, modelName string) *types.NewAPIError {
|
||||
c.Set("original_model", modelName) // for retry
|
||||
expectedPlugin := c.GetString("expected_task_plugin_key")
|
||||
if channel == nil {
|
||||
logTaskPluginChannelDecision(c, nil, modelName, "channel_rejected", "nil_channel")
|
||||
return types.NewError(errors.New("channel is nil"), types.ErrorCodeGetChannelFailed, types.ErrOptionWithSkipRetry())
|
||||
}
|
||||
if expectedPlugin != "" && !channelMatchesExpectedTaskPlugin(c, channel, expectedPlugin) {
|
||||
logTaskPluginChannelDecision(c, channel, modelName, "channel_rejected", "identity_mismatch")
|
||||
return types.NewError(
|
||||
errors.New("selected channel does not match the pinned task plugin"),
|
||||
types.ErrorCodeGetChannelFailed,
|
||||
types.ErrOptionWithSkipRetry(),
|
||||
)
|
||||
}
|
||||
if candidate, matched := pinnedEndpointCandidateForChannel(c, channel, expectedPlugin); matched {
|
||||
if value, exists := c.Get(jsplugin.ContextKeyPinnedEndpoint); exists {
|
||||
if pinned, ok := value.(jsplugin.PinnedEndpoint); ok && candidate.Plugin != nil && candidate.Plugin != pinned.Plugin {
|
||||
previousPlugin := pinned.Plugin.Meta.Key
|
||||
pinned.Plugin = candidate.Plugin
|
||||
pinned.Protocol = candidate.Protocol
|
||||
pinned.Operation = candidate.Operation
|
||||
c.Set(jsplugin.ContextKeyPinnedEndpoint, pinned)
|
||||
c.Set(jsplugin.ContextKeyPinnedPlugin, jsplugin.PinnedPlugin{Generation: pinned.Generation, Plugin: candidate.Plugin})
|
||||
c.Set("expected_task_plugin_key", candidate.Plugin.Meta.Key)
|
||||
c.Set("task_plugin_key", candidate.Plugin.Meta.Key)
|
||||
c.Set("platform", candidate.Plugin.Meta.Key)
|
||||
logger.LogDebug(
|
||||
c,
|
||||
"task_plugin subsystem=endpoint event=provider_selected generation=%d previous_plugin=%q plugin=%q model=%q channel_id=%d channel_type=%d",
|
||||
pinned.Generation.Number,
|
||||
previousPlugin,
|
||||
candidate.Plugin.Meta.Key,
|
||||
modelName,
|
||||
channel.Id,
|
||||
channel.Type,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
common.SetContextKey(c, constant.ContextKeyChannelId, channel.Id)
|
||||
common.SetContextKey(c, constant.ContextKeyChannelName, channel.Name)
|
||||
common.SetContextKey(c, constant.ContextKeyChannelType, channel.Type)
|
||||
common.SetContextKey(c, constant.ContextKeyChannelCreateTime, channel.CreatedTime)
|
||||
common.SetContextKey(c, constant.ContextKeyChannelSetting, channel.GetSetting())
|
||||
common.SetContextKey(c, constant.ContextKeyChannelOtherSetting, channel.GetOtherSettings())
|
||||
if channel.Type == constant.ChannelTypeTaskPlugin {
|
||||
c.Set("task_plugin_key", channel.GetSetting().TaskPluginKey)
|
||||
}
|
||||
logTaskPluginChannelDecision(c, channel, modelName, "channel_selected", "")
|
||||
paramOverride := channel.GetParamOverride()
|
||||
headerOverride := channel.GetHeaderOverride()
|
||||
if mergedParam, applied := service.ApplyChannelAffinityOverrideTemplate(c, paramOverride); applied {
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/pkg/jsplugin"
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestChannelMatchesExpectedTaskPluginUsesGenericChannelSetting(t *testing.T) {
|
||||
channel := &model.Channel{Type: constant.ChannelTypeTaskPlugin}
|
||||
channel.SetSetting(dto.ChannelSettings{TaskPluginKey: "generic-alpha"})
|
||||
|
||||
assert.True(t, channelMatchesExpectedTaskPlugin(nil, channel, "generic-alpha"))
|
||||
assert.False(t, channelMatchesExpectedTaskPlugin(nil, channel, "generic-beta"))
|
||||
assert.False(t, channelMatchesExpectedTaskPlugin(nil, channel, ""))
|
||||
}
|
||||
|
||||
func TestChannelMatchesExpectedTaskPluginUsesPinnedLegacyIndex(t *testing.T) {
|
||||
registry := jsplugin.NewRegistry()
|
||||
alpha, err := registry.Register(distributorTaskPluginSource("legacy-alpha", constant.ChannelTypeKling), jsplugin.Options{})
|
||||
require.NoError(t, err)
|
||||
pinnedGeneration := registry.Generation()
|
||||
|
||||
require.NoError(t, registry.Unregister("legacy-alpha"))
|
||||
_, err = registry.Register(distributorTaskPluginSource("legacy-beta", constant.ChannelTypeKling), jsplugin.Options{})
|
||||
require.NoError(t, err)
|
||||
|
||||
c, _ := gin.CreateTestContext(nil)
|
||||
c.Set(jsplugin.ContextKeyPinnedPlugin, jsplugin.PinnedPlugin{
|
||||
Generation: pinnedGeneration,
|
||||
Plugin: alpha,
|
||||
})
|
||||
channel := &model.Channel{Type: constant.ChannelTypeKling}
|
||||
|
||||
assert.True(t, channelMatchesExpectedTaskPlugin(c, channel, "legacy-alpha"))
|
||||
assert.False(t, channelMatchesExpectedTaskPlugin(c, channel, "legacy-beta"))
|
||||
assert.False(t, channelMatchesExpectedTaskPlugin(c, &model.Channel{Type: constant.ChannelTypeJimeng}, "legacy-alpha"))
|
||||
}
|
||||
|
||||
func TestChannelMatchesExpectedTaskPluginRejectsUnindexedLegacyChannel(t *testing.T) {
|
||||
registry := jsplugin.NewRegistry()
|
||||
plugin, err := registry.Register(distributorTaskPluginSource("legacy-alpha", constant.ChannelTypeKling), jsplugin.Options{})
|
||||
require.NoError(t, err)
|
||||
|
||||
c, _ := gin.CreateTestContext(nil)
|
||||
c.Set(jsplugin.ContextKeyPinnedPlugin, jsplugin.PinnedPlugin{
|
||||
Generation: registry.Generation(),
|
||||
Plugin: plugin,
|
||||
})
|
||||
|
||||
assert.False(t, channelMatchesExpectedTaskPlugin(c, &model.Channel{Type: constant.ChannelTypeJimeng}, "legacy-alpha"))
|
||||
assert.False(t, channelMatchesExpectedTaskPlugin(c, &model.Channel{Type: 0}, "legacy-alpha"))
|
||||
assert.True(t, channelMatchesExpectedTaskPlugin(c, &model.Channel{Type: constant.ChannelTypeJimeng}, ""))
|
||||
assert.False(t, channelMatchesExpectedTaskPlugin(nil, &model.Channel{Type: constant.ChannelTypeKling}, "legacy-alpha"))
|
||||
|
||||
c.Set("expected_task_plugin_key", "legacy-alpha")
|
||||
setupErr := SetupContextForSelectedChannel(c, &model.Channel{Type: constant.ChannelTypeJimeng}, "task-model")
|
||||
require.NotNil(t, setupErr)
|
||||
assert.Contains(t, setupErr.Error(), "does not match")
|
||||
}
|
||||
|
||||
func TestSharedEndpointRebindsToSelectedLegacyProvider(t *testing.T) {
|
||||
registry := jsplugin.NewRegistry()
|
||||
_, err := registry.Register(distributorEndpointPluginSource("gemini-shared", constant.ChannelTypeGemini), jsplugin.Options{})
|
||||
require.NoError(t, err)
|
||||
_, err = registry.Register(distributorEndpointPluginSource("vertex-shared", constant.ChannelTypeVertexAi), jsplugin.Options{})
|
||||
require.NoError(t, err)
|
||||
candidates := registry.Generation().LookupEndpointCandidates("POST", "/v1/responses", "task-model")
|
||||
require.Len(t, candidates, 2)
|
||||
|
||||
c, _ := gin.CreateTestContext(nil)
|
||||
c.Set(jsplugin.ContextKeyPinnedPlugin, jsplugin.PinnedPlugin{Generation: registry.Generation(), Plugin: candidates[0].Plugin})
|
||||
c.Set(jsplugin.ContextKeyPinnedEndpoint, jsplugin.PinnedEndpoint{
|
||||
Generation: registry.Generation(),
|
||||
Plugin: candidates[0].Plugin,
|
||||
Protocol: candidates[0].Protocol,
|
||||
Operation: candidates[0].Operation,
|
||||
Model: "task-model",
|
||||
Candidates: candidates,
|
||||
})
|
||||
c.Set("expected_task_plugin_key", candidates[0].Plugin.Meta.Key)
|
||||
|
||||
geminiChannel := &model.Channel{Id: 1, Type: constant.ChannelTypeGemini}
|
||||
vertexChannel := &model.Channel{Id: 2, Type: constant.ChannelTypeVertexAi}
|
||||
assert.True(t, channelMatchesExpectedTaskPlugin(c, geminiChannel, candidates[0].Plugin.Meta.Key))
|
||||
assert.True(t, channelMatchesExpectedTaskPlugin(c, vertexChannel, candidates[0].Plugin.Meta.Key))
|
||||
assert.False(t, channelMatchesExpectedTaskPlugin(c, &model.Channel{Type: constant.ChannelTypeKling}, candidates[0].Plugin.Meta.Key))
|
||||
|
||||
require.Nil(t, SetupContextForSelectedChannel(c, vertexChannel, "task-model"))
|
||||
pinnedValue, exists := c.Get(jsplugin.ContextKeyPinnedEndpoint)
|
||||
require.True(t, exists)
|
||||
pinned, ok := pinnedValue.(jsplugin.PinnedEndpoint)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "vertex-shared", pinned.Plugin.Meta.Key)
|
||||
assert.Equal(t, "vertex-shared", c.GetString("expected_task_plugin_key"))
|
||||
assert.Equal(t, "vertex-shared", c.GetString("task_plugin_key"))
|
||||
assert.True(t, channelMatchesExpectedTaskPlugin(c, geminiChannel, "vertex-shared"), "a retry may select another declared provider")
|
||||
}
|
||||
|
||||
func distributorTaskPluginSource(key string, channelType int) string {
|
||||
return fmt.Sprintf(`
|
||||
export const meta = {
|
||||
apiVersion: 1,
|
||||
key: %q,
|
||||
name: %q,
|
||||
version: "1.0.0",
|
||||
author: {name: "Test"},
|
||||
channelTypes: [%d],
|
||||
models: ["task-model"],
|
||||
fetchMode: "per_task",
|
||||
};
|
||||
export function buildSubmitRequest() { return {}; }
|
||||
export function parseSubmitResponse() { return {taskId: "task"}; }
|
||||
export function buildQueryRequest() { return {}; }
|
||||
export function parseTaskResult() { return {status: "SUCCESS"}; }
|
||||
`, key, key, channelType)
|
||||
}
|
||||
|
||||
func distributorEndpointPluginSource(key string, channelType int) string {
|
||||
return fmt.Sprintf(`
|
||||
export const meta = {
|
||||
apiVersion: 1,
|
||||
key: %q,
|
||||
name: %q,
|
||||
version: "1.0.0",
|
||||
author: {name: "Test"},
|
||||
channelTypes: [%d],
|
||||
models: ["task-model"],
|
||||
fetchMode: "per_task",
|
||||
protocols: [{name: "openai_responses", supports: ["stream", "sync", "background"]}],
|
||||
};
|
||||
export function buildSubmitRequest() { return {}; }
|
||||
export function parseSubmitResponse() { return {taskId: "task"}; }
|
||||
export function buildQueryRequest() { return {}; }
|
||||
export function parseTaskResult() { return {status: "SUCCESS"}; }
|
||||
export const protocols = {openai_responses: {
|
||||
decodeRequest: function(ctx) { return {kind: "submit", model: "task-model", requestBody: ctx.body.value}; },
|
||||
renderEvents: function() { return {events: [], state: null, done: false}; },
|
||||
renderFinal: function() { return {output: []}; },
|
||||
}};
|
||||
`, key, key, channelType)
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
relayconstant "github.com/QuantumNous/new-api/relay/constant"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func JimengRequestConvert() func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
action := c.Query("Action")
|
||||
if action == "" {
|
||||
abortWithOpenAiMessage(c, http.StatusBadRequest, "Action query parameter is required")
|
||||
return
|
||||
}
|
||||
|
||||
// Handle Jimeng official API request
|
||||
var originalReq map[string]interface{}
|
||||
if err := common.UnmarshalBodyReusable(c, &originalReq); err != nil {
|
||||
abortWithOpenAiMessage(c, http.StatusBadRequest, "Invalid request body")
|
||||
return
|
||||
}
|
||||
model, _ := originalReq["req_key"].(string)
|
||||
prompt, _ := originalReq["prompt"].(string)
|
||||
|
||||
unifiedReq := map[string]interface{}{
|
||||
"model": model,
|
||||
"prompt": prompt,
|
||||
"metadata": originalReq,
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(unifiedReq)
|
||||
if err != nil {
|
||||
abortWithOpenAiMessage(c, http.StatusInternalServerError, "Failed to marshal request body")
|
||||
return
|
||||
}
|
||||
|
||||
// Update request body
|
||||
c.Request.Body = io.NopCloser(bytes.NewBuffer(jsonData))
|
||||
c.Set(common.KeyRequestBody, jsonData)
|
||||
|
||||
if image, ok := originalReq["image"]; !ok || image == "" {
|
||||
c.Set("action", constant.TaskActionTextGenerate)
|
||||
}
|
||||
|
||||
c.Request.URL.Path = "/v1/video/generations"
|
||||
|
||||
if action == "CVSync2AsyncGetResult" {
|
||||
taskId, ok := originalReq["task_id"].(string)
|
||||
if !ok || taskId == "" {
|
||||
abortWithOpenAiMessage(c, http.StatusBadRequest, "task_id is required for CVSync2AsyncGetResult")
|
||||
return
|
||||
}
|
||||
c.Request.URL.Path = "/v1/video/generations/" + taskId
|
||||
c.Request.Method = http.MethodGet
|
||||
c.Set("task_id", taskId)
|
||||
c.Set("relay_mode", relayconstant.RelayModeVideoFetchByID)
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func KlingRequestConvert() func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var originalReq map[string]interface{}
|
||||
if err := common.UnmarshalBodyReusable(c, &originalReq); err != nil {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// Support both model_name and model fields
|
||||
model, _ := originalReq["model_name"].(string)
|
||||
if model == "" {
|
||||
model, _ = originalReq["model"].(string)
|
||||
}
|
||||
prompt, _ := originalReq["prompt"].(string)
|
||||
|
||||
unifiedReq := map[string]interface{}{
|
||||
"model": model,
|
||||
"prompt": prompt,
|
||||
"metadata": originalReq,
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(unifiedReq)
|
||||
if err != nil {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// Rewrite request body and path
|
||||
c.Request.Body = io.NopCloser(bytes.NewBuffer(jsonData))
|
||||
c.Request.URL.Path = "/v1/video/generations"
|
||||
if image, ok := originalReq["image"]; !ok || image == "" {
|
||||
c.Set("action", constant.TaskActionTextGenerate)
|
||||
}
|
||||
|
||||
// We have to reset the request body for the next handlers
|
||||
c.Set(common.KeyRequestBody, jsonData)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ func RouteTag(tag string) gin.HandlerFunc {
|
||||
}
|
||||
|
||||
func SetUpLogger(server *gin.Engine) {
|
||||
server.Use(redactTaskArtifactAccessQuery())
|
||||
server.Use(gin.LoggerWithFormatter(func(param gin.LogFormatterParams) string {
|
||||
var requestID string
|
||||
if param.Keys != nil {
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/QuantumNous/new-api/setting/system_setting"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const TaskArtifactAccessContextKey = "task_artifact_access"
|
||||
|
||||
const (
|
||||
taskArtifactAccessRawContextKey = "task_artifact_access_raw"
|
||||
taskArtifactAccessPresentContextKey = "task_artifact_access_present"
|
||||
taskArtifactAccessInvalidContextKey = "task_artifact_access_invalid"
|
||||
taskArtifactAccessRateWindow = time.Minute
|
||||
taskArtifactAccessCleanupInterval = time.Minute
|
||||
maxEncodedTaskArtifactAccessQuerySize = 128
|
||||
)
|
||||
|
||||
type taskArtifactRateEntry struct {
|
||||
windowStart time.Time
|
||||
count int
|
||||
}
|
||||
|
||||
type taskArtifactAccessLimiter struct {
|
||||
mutex sync.Mutex
|
||||
global int
|
||||
byIP map[string]int
|
||||
byObject map[string]int
|
||||
rates map[string]taskArtifactRateEntry
|
||||
nextCleanup time.Time
|
||||
limits system_setting.TaskArtifactAccessLimits
|
||||
}
|
||||
|
||||
var taskArtifactAnonymousLimiter = newTaskArtifactAccessLimiter(
|
||||
system_setting.LoadTaskArtifactAccessLimits(),
|
||||
)
|
||||
|
||||
func newTaskArtifactAccessLimiter(limits system_setting.TaskArtifactAccessLimits) *taskArtifactAccessLimiter {
|
||||
return &taskArtifactAccessLimiter{
|
||||
byIP: make(map[string]int),
|
||||
byObject: make(map[string]int),
|
||||
rates: make(map[string]taskArtifactRateEntry),
|
||||
limits: limits,
|
||||
}
|
||||
}
|
||||
|
||||
func (l *taskArtifactAccessLimiter) invalidAttempt(now time.Time, ip string) bool {
|
||||
l.mutex.Lock()
|
||||
defer l.mutex.Unlock()
|
||||
|
||||
if l.nextCleanup.IsZero() || !now.Before(l.nextCleanup) {
|
||||
for key, entry := range l.rates {
|
||||
if now.Sub(entry.windowStart) >= taskArtifactAccessRateWindow {
|
||||
delete(l.rates, key)
|
||||
}
|
||||
}
|
||||
l.nextCleanup = now.Add(taskArtifactAccessCleanupInterval)
|
||||
}
|
||||
|
||||
rate := l.rates[ip]
|
||||
if rate.windowStart.IsZero() || now.Sub(rate.windowStart) >= taskArtifactAccessRateWindow {
|
||||
rate = taskArtifactRateEntry{windowStart: now}
|
||||
}
|
||||
if rate.count >= l.limits.InvalidRatePerMinute {
|
||||
return false
|
||||
}
|
||||
rate.count++
|
||||
l.rates[ip] = rate
|
||||
return true
|
||||
}
|
||||
|
||||
func (l *taskArtifactAccessLimiter) acquire(ip, taskID, artifactKey string) (func(), bool) {
|
||||
l.mutex.Lock()
|
||||
defer l.mutex.Unlock()
|
||||
objectKey := taskID + "\x00" + artifactKey
|
||||
if l.global >= l.limits.GlobalConcurrency ||
|
||||
l.byIP[ip] >= l.limits.IPConcurrency ||
|
||||
l.byObject[objectKey] >= l.limits.ObjectConcurrency {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
l.global++
|
||||
l.byIP[ip]++
|
||||
l.byObject[objectKey]++
|
||||
|
||||
var once sync.Once
|
||||
return func() {
|
||||
once.Do(func() {
|
||||
l.mutex.Lock()
|
||||
defer l.mutex.Unlock()
|
||||
l.global--
|
||||
l.byIP[ip]--
|
||||
l.byObject[objectKey]--
|
||||
if l.byIP[ip] == 0 {
|
||||
delete(l.byIP, ip)
|
||||
}
|
||||
if l.byObject[objectKey] == 0 {
|
||||
delete(l.byObject, objectKey)
|
||||
}
|
||||
})
|
||||
}, true
|
||||
}
|
||||
|
||||
func redactTaskArtifactAccessQuery() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
path := c.Request.URL.Path
|
||||
isArtifactContent := strings.HasPrefix(path, "/v1/tasks/") &&
|
||||
strings.Contains(path, "/artifacts/") &&
|
||||
strings.HasSuffix(path, "/content")
|
||||
isLegacyVideoContent := strings.HasPrefix(path, "/v1/videos/") &&
|
||||
strings.HasSuffix(path, "/content")
|
||||
if !isArtifactContent && !isLegacyVideoContent {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
rawAccess, present, invalid := popTaskArtifactAccessQuery(c.Request)
|
||||
if present {
|
||||
c.Set(taskArtifactAccessRawContextKey, rawAccess)
|
||||
c.Set(taskArtifactAccessPresentContextKey, true)
|
||||
c.Set(taskArtifactAccessInvalidContextKey, invalid)
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func popTaskArtifactAccessQuery(request *http.Request) (string, bool, bool) {
|
||||
if request == nil || request.URL == nil {
|
||||
return "", false, false
|
||||
}
|
||||
rawAccess := ""
|
||||
count := 0
|
||||
invalid := false
|
||||
kept := make([]string, 0)
|
||||
for _, part := range strings.Split(request.URL.RawQuery, "&") {
|
||||
rawKey, rawValue, _ := strings.Cut(part, "=")
|
||||
key, err := url.QueryUnescape(rawKey)
|
||||
if err != nil || key != service.TaskArtifactAccessQueryParameter {
|
||||
kept = append(kept, part)
|
||||
continue
|
||||
}
|
||||
count++
|
||||
if len(rawValue) > maxEncodedTaskArtifactAccessQuerySize {
|
||||
invalid = true
|
||||
continue
|
||||
}
|
||||
if count == 1 {
|
||||
value, decodeErr := url.QueryUnescape(rawValue)
|
||||
if decodeErr != nil {
|
||||
invalid = true
|
||||
} else {
|
||||
rawAccess = value
|
||||
}
|
||||
}
|
||||
}
|
||||
if count == 0 {
|
||||
return "", false, false
|
||||
}
|
||||
invalid = invalid || count != 1
|
||||
request.URL.RawQuery = strings.Join(kept, "&")
|
||||
request.RequestURI = request.URL.RequestURI()
|
||||
return rawAccess, true, invalid
|
||||
}
|
||||
|
||||
// TokenOrTaskArtifactAccessAuth accepts the normal relay API Bearer token or a
|
||||
// route-bound capability. Capabilities are verified before any database read.
|
||||
func TokenOrTaskArtifactAccessAuth(taskParam, artifactParam string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Header("Cache-Control", "private, no-store")
|
||||
|
||||
rawAccess := c.GetString(taskArtifactAccessRawContextKey)
|
||||
present := c.GetBool(taskArtifactAccessPresentContextKey)
|
||||
invalid := c.GetBool(taskArtifactAccessInvalidContextKey)
|
||||
if queryAccess, queryPresent, queryInvalid := popTaskArtifactAccessQuery(c.Request); queryPresent {
|
||||
present = true
|
||||
if rawAccess == "" {
|
||||
rawAccess = queryAccess
|
||||
}
|
||||
invalid = invalid || queryInvalid
|
||||
}
|
||||
if !present {
|
||||
TokenAuth()(c)
|
||||
return
|
||||
}
|
||||
|
||||
taskID := c.Param(taskParam)
|
||||
artifactKey := c.Param(artifactParam)
|
||||
ip := c.ClientIP()
|
||||
if ip == "" {
|
||||
ip = "unknown"
|
||||
}
|
||||
if invalid || !service.VerifyTaskArtifactAccess(rawAccess, taskID, artifactKey) {
|
||||
if !taskArtifactAnonymousLimiter.invalidAttempt(time.Now(), ip) {
|
||||
writeTaskArtifactAccessLimited(c)
|
||||
return
|
||||
}
|
||||
writeTaskArtifactAccessNotFound(c)
|
||||
return
|
||||
}
|
||||
|
||||
release, ok := taskArtifactAnonymousLimiter.acquire(ip, taskID, artifactKey)
|
||||
if !ok {
|
||||
writeTaskArtifactAccessLimited(c)
|
||||
return
|
||||
}
|
||||
defer release()
|
||||
|
||||
c.Set(TaskArtifactAccessContextKey, true)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func IsTaskArtifactAccess(c *gin.Context) bool {
|
||||
return c != nil && c.GetBool(TaskArtifactAccessContextKey)
|
||||
}
|
||||
|
||||
func writeTaskArtifactAccessNotFound(c *gin.Context) {
|
||||
c.Header("Cache-Control", "private, no-store")
|
||||
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{
|
||||
"error": gin.H{
|
||||
"message": "Task or artifact not found",
|
||||
"type": "artifact_not_found",
|
||||
"code": "artifact_not_found",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func writeTaskArtifactAccessLimited(c *gin.Context) {
|
||||
c.Header("Cache-Control", "private, no-store")
|
||||
c.Header("Retry-After", "60")
|
||||
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{
|
||||
"error": gin.H{
|
||||
"message": "Artifact access limit exceeded",
|
||||
"type": "rate_limit_error",
|
||||
"code": "artifact_access_limited",
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/QuantumNous/new-api/setting/system_setting"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestTaskArtifactAccessIsRedactedAndVerifiedBeforeHandler(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
previousSecret := common.CryptoSecret
|
||||
common.CryptoSecret = "task-artifact-middleware-secret"
|
||||
t.Cleanup(func() { common.CryptoSecret = previousSecret })
|
||||
|
||||
access, err := service.IssueTaskArtifactAccess("task-1", "video-main")
|
||||
require.NoError(t, err)
|
||||
|
||||
router := gin.New()
|
||||
router.Use(redactTaskArtifactAccessQuery())
|
||||
router.GET(
|
||||
"/v1/tasks/:key/artifacts/:artifact_key/content",
|
||||
TokenOrTaskArtifactAccessAuth("key", "artifact_key"),
|
||||
func(c *gin.Context) {
|
||||
assert.True(t, IsTaskArtifactAccess(c))
|
||||
assert.NotContains(t, c.Request.URL.RawQuery, service.TaskArtifactAccessQueryParameter)
|
||||
assert.Equal(t, "kept", c.Query("keep"))
|
||||
c.Status(http.StatusNoContent)
|
||||
},
|
||||
)
|
||||
request := httptest.NewRequest(
|
||||
http.MethodGet,
|
||||
"/v1/tasks/task-1/artifacts/video-main/content?access="+urlQueryEscape(access)+"&keep=kept",
|
||||
nil,
|
||||
)
|
||||
request.RemoteAddr = "192.0.2.1:1234"
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
assert.Equal(t, http.StatusNoContent, recorder.Code)
|
||||
}
|
||||
|
||||
func TestTaskArtifactAccessRejectsTamperedAndEmptyCapabilitiesAsNotFound(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
previousSecret := common.CryptoSecret
|
||||
common.CryptoSecret = "task-artifact-middleware-reject-secret"
|
||||
t.Cleanup(func() { common.CryptoSecret = previousSecret })
|
||||
|
||||
router := gin.New()
|
||||
router.Use(redactTaskArtifactAccessQuery())
|
||||
router.GET(
|
||||
"/v1/tasks/:key/artifacts/:artifact_key/content",
|
||||
TokenOrTaskArtifactAccessAuth("key", "artifact_key"),
|
||||
func(c *gin.Context) { c.Status(http.StatusNoContent) },
|
||||
)
|
||||
|
||||
for _, query := range []string{
|
||||
"?access=",
|
||||
"?access=invalid",
|
||||
"?access=first&access=second",
|
||||
"?access=" + strings.Repeat("x", 1024),
|
||||
"?access=%20" + strings.Repeat("A", 43) + "%20",
|
||||
} {
|
||||
request := httptest.NewRequest(
|
||||
http.MethodGet,
|
||||
"/v1/tasks/task-1/artifacts/video-main/content"+query,
|
||||
nil,
|
||||
)
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, request)
|
||||
assert.Equal(t, http.StatusNotFound, recorder.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskArtifactAccessLimiterDefaults(t *testing.T) {
|
||||
limits := system_setting.TaskArtifactAccessLimits{
|
||||
InvalidRatePerMinute: system_setting.DefaultTaskArtifactInvalidRateLimitPerMinute,
|
||||
GlobalConcurrency: system_setting.DefaultTaskArtifactGlobalConcurrency,
|
||||
IPConcurrency: system_setting.DefaultTaskArtifactIPConcurrency,
|
||||
ObjectConcurrency: system_setting.DefaultTaskArtifactObjectConcurrency,
|
||||
}
|
||||
limiter := newTaskArtifactAccessLimiter(limits)
|
||||
now := time.Unix(1000, 0)
|
||||
releases := make([]func(), 0, limits.ObjectConcurrency)
|
||||
for i := 0; i < limits.ObjectConcurrency; i++ {
|
||||
release, ok := limiter.acquire("192.0.2.1", "task-1", "video")
|
||||
require.True(t, ok)
|
||||
releases = append(releases, release)
|
||||
}
|
||||
_, ok := limiter.acquire("192.0.2.2", "task-1", "video")
|
||||
assert.False(t, ok, "task+key concurrency is shared across IPs")
|
||||
for _, release := range releases {
|
||||
release()
|
||||
}
|
||||
|
||||
rateLimiter := newTaskArtifactAccessLimiter(limits)
|
||||
for i := 0; i < limits.InvalidRatePerMinute; i++ {
|
||||
assert.True(t, rateLimiter.invalidAttempt(now, "192.0.2.10"))
|
||||
}
|
||||
assert.False(t, rateLimiter.invalidAttempt(now, "192.0.2.10"))
|
||||
assert.True(t, rateLimiter.invalidAttempt(now.Add(time.Minute), "192.0.2.10"))
|
||||
}
|
||||
|
||||
func TestRedactTaskArtifactAccessAlsoCoversLegacyVideoRoute(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.Use(redactTaskArtifactAccessQuery())
|
||||
router.GET("/v1/videos/:task_id/content", func(c *gin.Context) {
|
||||
assert.NotContains(t, c.Request.URL.RawQuery, "access")
|
||||
assert.NotContains(t, c.Request.RequestURI, "secret-capability")
|
||||
assert.Equal(t, "ok", c.Query("keep"))
|
||||
c.Status(http.StatusNoContent)
|
||||
})
|
||||
request := httptest.NewRequest(
|
||||
http.MethodGet,
|
||||
"/v1/videos/task-1/content?access=secret-capability&keep=ok",
|
||||
nil,
|
||||
)
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, request)
|
||||
assert.Equal(t, http.StatusNoContent, recorder.Code)
|
||||
}
|
||||
|
||||
func TestSetUpLoggerNeverWritesTaskArtifactAccess(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
previousWriter := gin.DefaultWriter
|
||||
var output bytes.Buffer
|
||||
gin.DefaultWriter = &output
|
||||
t.Cleanup(func() { gin.DefaultWriter = previousWriter })
|
||||
|
||||
router := gin.New()
|
||||
SetUpLogger(router)
|
||||
router.GET("/v1/tasks/:key/artifacts/:artifact_key/content", func(c *gin.Context) {
|
||||
c.Status(http.StatusNoContent)
|
||||
})
|
||||
request := httptest.NewRequest(
|
||||
http.MethodGet,
|
||||
"/v1/tasks/task-1/artifacts/video/content?access=never-log-this&keep=ok",
|
||||
nil,
|
||||
)
|
||||
router.ServeHTTP(httptest.NewRecorder(), request)
|
||||
|
||||
assert.False(t, strings.Contains(output.String(), "never-log-this"))
|
||||
}
|
||||
|
||||
func urlQueryEscape(value string) string {
|
||||
replacer := strings.NewReplacer("+", "%2B", "=", "%3D")
|
||||
return replacer.Replace(value)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,500 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
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"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func setupOriginTaskDB(t *testing.T) {
|
||||
t.Helper()
|
||||
previousDB := model.DB
|
||||
previousType := common.MainDatabaseType()
|
||||
database, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, database.AutoMigrate(&model.Task{}, &model.Channel{}))
|
||||
model.DB = database
|
||||
common.SetMainDatabaseType(common.DatabaseTypeSQLite)
|
||||
t.Cleanup(func() {
|
||||
model.DB = previousDB
|
||||
common.SetMainDatabaseType(previousType)
|
||||
})
|
||||
}
|
||||
|
||||
func insertOriginTaskChannel(t *testing.T, status int) *model.Channel {
|
||||
t.Helper()
|
||||
channel := &model.Channel{
|
||||
Name: "origin-channel",
|
||||
Key: "sk-origin",
|
||||
Status: status,
|
||||
Type: constant.ChannelTypeDoubaoVideo,
|
||||
}
|
||||
require.NoError(t, model.DB.Create(channel).Error)
|
||||
return channel
|
||||
}
|
||||
|
||||
func insertOriginOwnedTask(t *testing.T, taskID string, userID, channelID int, platform constant.TaskPlatform) *model.Task {
|
||||
t.Helper()
|
||||
task := &model.Task{
|
||||
TaskID: taskID,
|
||||
UserId: userID,
|
||||
ChannelId: channelID,
|
||||
Platform: platform,
|
||||
Action: "text_to_video",
|
||||
Status: model.TaskStatusSuccess,
|
||||
PrivateData: model.TaskPrivateData{
|
||||
UpstreamTaskID: "upstream-" + taskID,
|
||||
},
|
||||
}
|
||||
data, err := common.Marshal(map[string]any{"id": "upstream-" + taskID})
|
||||
require.NoError(t, err)
|
||||
task.Data = data
|
||||
require.NoError(t, model.DB.Create(task).Error)
|
||||
return task
|
||||
}
|
||||
|
||||
func originTaskTestContext(userID int) *gin.Context {
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/vendor/jobs", nil)
|
||||
common.SetContextKey(c, constant.ContextKeyUserId, userID)
|
||||
return c
|
||||
}
|
||||
|
||||
func resolvedOriginPin(c *gin.Context) (int, bool) {
|
||||
pin, found, _ := service.GetChannelConstraints(c).ResolvedPin()
|
||||
if !found {
|
||||
return 0, false
|
||||
}
|
||||
return pin.ChannelId, true
|
||||
}
|
||||
|
||||
func TestApplyOriginTaskIntent(t *testing.T) {
|
||||
setupOriginTaskDB(t)
|
||||
enabled := insertOriginTaskChannel(t, common.ChannelStatusEnabled)
|
||||
otherEnabled := insertOriginTaskChannel(t, common.ChannelStatusEnabled)
|
||||
disabled := insertOriginTaskChannel(t, common.ChannelStatusManuallyDisabled)
|
||||
insertOriginOwnedTask(t, "task-own", 7, enabled.Id, "origin-plugin")
|
||||
insertOriginOwnedTask(t, "task-own-b", 7, enabled.Id, "origin-plugin")
|
||||
insertOriginOwnedTask(t, "task-other-channel", 7, otherEnabled.Id, "origin-plugin")
|
||||
insertOriginOwnedTask(t, "task-foreign", 8, enabled.Id, "origin-plugin")
|
||||
insertOriginOwnedTask(t, "task-wrong-platform", 7, enabled.Id, "other-plugin")
|
||||
insertOriginOwnedTask(t, "task-legacy", 7, enabled.Id, constant.TaskPlatform(strconv.Itoa(constant.ChannelTypeDoubaoVideo)))
|
||||
insertOriginOwnedTask(t, "task-disabled", 7, disabled.Id, "origin-plugin")
|
||||
|
||||
tooMany := make([]any, 17)
|
||||
for i := range tooMany {
|
||||
tooMany[i] = fmt.Sprintf("task-%d", i)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
userID int
|
||||
intent map[string]any
|
||||
channelType int
|
||||
wantCode string
|
||||
wantPinned int
|
||||
wantIDs []string
|
||||
}{
|
||||
{
|
||||
name: "valid single origin id pins channel",
|
||||
userID: 7,
|
||||
intent: map[string]any{"originTaskIds": []any{"task-own"}},
|
||||
wantPinned: enabled.Id,
|
||||
wantIDs: []string{"task-own"},
|
||||
},
|
||||
{
|
||||
name: "dedupes preserving first order",
|
||||
userID: 7,
|
||||
intent: map[string]any{"originTaskIds": []any{"task-own", " task-own ", "task-own-b"}},
|
||||
wantPinned: enabled.Id,
|
||||
wantIDs: []string{"task-own", "task-own-b"},
|
||||
},
|
||||
{
|
||||
name: "legacy platform matches channelType",
|
||||
userID: 7,
|
||||
intent: map[string]any{"originTaskIds": []any{"task-legacy"}},
|
||||
channelType: constant.ChannelTypeDoubaoVideo,
|
||||
wantPinned: enabled.Id,
|
||||
wantIDs: []string{"task-legacy"},
|
||||
},
|
||||
{
|
||||
name: "unknown id",
|
||||
userID: 7,
|
||||
intent: map[string]any{"originTaskIds": []any{"task-missing"}},
|
||||
wantCode: "origin_task_not_found",
|
||||
},
|
||||
{
|
||||
name: "other user's task is not found",
|
||||
userID: 7,
|
||||
intent: map[string]any{"originTaskIds": []any{"task-foreign"}},
|
||||
wantCode: "origin_task_not_found",
|
||||
},
|
||||
{
|
||||
name: "platform mismatch",
|
||||
userID: 7,
|
||||
intent: map[string]any{"originTaskIds": []any{"task-wrong-platform"}},
|
||||
wantCode: "origin_task_platform_mismatch",
|
||||
},
|
||||
{
|
||||
name: "two ids on different channels",
|
||||
userID: 7,
|
||||
intent: map[string]any{"originTaskIds": []any{"task-own", "task-other-channel"}},
|
||||
wantCode: "origin_task_channel_conflict",
|
||||
},
|
||||
{
|
||||
name: "disabled channel",
|
||||
userID: 7,
|
||||
intent: map[string]any{"originTaskIds": []any{"task-disabled"}},
|
||||
wantCode: "origin_task_channel_disabled",
|
||||
},
|
||||
{
|
||||
name: "more than 16 ids",
|
||||
userID: 7,
|
||||
intent: map[string]any{"originTaskIds": tooMany},
|
||||
wantCode: "invalid_origin_task_ids",
|
||||
},
|
||||
{
|
||||
name: "non-array originTaskIds",
|
||||
userID: 7,
|
||||
intent: map[string]any{"originTaskIds": "task-own"},
|
||||
wantCode: "invalid_origin_task_ids",
|
||||
},
|
||||
{
|
||||
name: "empty string entry",
|
||||
userID: 7,
|
||||
intent: map[string]any{"originTaskIds": []any{"task-own", " "}},
|
||||
wantCode: "invalid_origin_task_ids",
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range tests {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
c := originTaskTestContext(testCase.userID)
|
||||
intentErr := applyOriginTaskIntent(c, testCase.intent, jsplugin.Meta{Key: "origin-plugin", ChannelTypes: []int{testCase.channelType}})
|
||||
if testCase.wantCode != "" {
|
||||
require.NotNil(t, intentErr)
|
||||
assert.Equal(t, testCase.wantCode, intentErr.Code)
|
||||
assert.Equal(t, http.StatusBadRequest, intentErr.StatusCode)
|
||||
_, pinned := resolvedOriginPin(c)
|
||||
assert.False(t, pinned)
|
||||
return
|
||||
}
|
||||
require.Nil(t, intentErr)
|
||||
pinnedID, ok := resolvedOriginPin(c)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, testCase.wantPinned, pinnedID)
|
||||
tasks, ok := common.GetContextKeyType[[]*model.Task](c, constant.ContextKeyOriginTasks)
|
||||
require.True(t, ok)
|
||||
require.Len(t, tasks, len(testCase.wantIDs))
|
||||
for i, wantID := range testCase.wantIDs {
|
||||
assert.Equal(t, wantID, tasks[i].TaskID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyOriginTaskIntentAbsentAndEmptyAreNoop(t *testing.T) {
|
||||
setupOriginTaskDB(t)
|
||||
c := originTaskTestContext(7)
|
||||
require.Nil(t, applyOriginTaskIntent(c, map[string]any{}, jsplugin.Meta{Key: "origin-plugin"}))
|
||||
require.Nil(t, applyOriginTaskIntent(c, map[string]any{"originTaskIds": []any{}}, jsplugin.Meta{Key: "origin-plugin"}))
|
||||
_, pinned := resolvedOriginPin(c)
|
||||
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)
|
||||
insertOriginOwnedTask(t, "task-route", 7, channel.Id, "origin-route")
|
||||
plugin := compileTaskRoutePlugin(t, `
|
||||
export const meta = {
|
||||
apiVersion: 1, key: "origin-route", name: "Origin", version: "1.0.0",
|
||||
author: {name: "Test"},
|
||||
models: ["resolved-model"], fetchMode: "per_task",
|
||||
routes: [{method: "POST", path: "/vendor/jobs", type: "submit", decode: "decodeJob", render: "jobCreated"}],
|
||||
};
|
||||
export const native = {
|
||||
decodeJob: function() { return {kind: "submit", model: "resolved-model", originTaskIds: ["task-route"], requestBody: {prompt: "ok"}}; },
|
||||
jobCreated: function(ctx, task) { return task; },
|
||||
};
|
||||
export function buildSubmitRequest() { return {url: "https://example.com"}; }
|
||||
export function parseSubmitResponse() { return {taskId: "one"}; }
|
||||
export function buildQueryRequest() { return {url: "https://example.com"}; }
|
||||
export function parseTaskResult() { return {status: "SUCCESS"}; }
|
||||
`)
|
||||
reached := false
|
||||
router := gin.New()
|
||||
router.POST("/vendor/jobs", pinTaskPluginRoute(plugin, 0), func(c *gin.Context) {
|
||||
common.SetContextKey(c, constant.ContextKeyUserId, 7)
|
||||
c.Next()
|
||||
}, PrepareTaskPluginRoute(), func(c *gin.Context) {
|
||||
reached = true
|
||||
pinnedID, ok := resolvedOriginPin(c)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, channel.Id, pinnedID)
|
||||
c.Status(http.StatusNoContent)
|
||||
})
|
||||
request := httptest.NewRequest(http.MethodPost, "/vendor/jobs", strings.NewReader(`{"model":"resolved-model"}`))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, request)
|
||||
assert.True(t, reached)
|
||||
assert.Equal(t, http.StatusNoContent, recorder.Code)
|
||||
}
|
||||
|
||||
func TestPrepareTaskPluginRouteRejectsUnknownOriginTask(t *testing.T) {
|
||||
setupOriginTaskDB(t)
|
||||
plugin := compileTaskRoutePlugin(t, `
|
||||
export const meta = {
|
||||
apiVersion: 1, key: "origin-route-missing", name: "Origin", version: "1.0.0",
|
||||
author: {name: "Test"},
|
||||
models: ["resolved-model"], fetchMode: "per_task",
|
||||
routes: [{method: "POST", path: "/vendor/jobs", type: "submit", decode: "decodeJob", render: "jobCreated"}],
|
||||
};
|
||||
export const native = {
|
||||
decodeJob: function() { return {kind: "submit", model: "resolved-model", originTaskIds: ["missing"], requestBody: {}}; },
|
||||
jobCreated: function(ctx, task) { return task; },
|
||||
};
|
||||
export function buildSubmitRequest() { return {url: "https://example.com"}; }
|
||||
export function parseSubmitResponse() { return {taskId: "one"}; }
|
||||
export function buildQueryRequest() { return {url: "https://example.com"}; }
|
||||
export function parseTaskResult() { return {status: "SUCCESS"}; }
|
||||
`)
|
||||
reached := false
|
||||
router := gin.New()
|
||||
router.POST("/vendor/jobs", pinTaskPluginRoute(plugin, 0), func(c *gin.Context) {
|
||||
common.SetContextKey(c, constant.ContextKeyUserId, 7)
|
||||
c.Next()
|
||||
}, PrepareTaskPluginRoute(), func(c *gin.Context) { reached = true })
|
||||
request := httptest.NewRequest(http.MethodPost, "/vendor/jobs", strings.NewReader(`{"model":"resolved-model"}`))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, request)
|
||||
assert.False(t, reached)
|
||||
assert.Equal(t, http.StatusBadRequest, recorder.Code)
|
||||
}
|
||||
|
||||
func TestPrepareTaskPluginEndpointPinsOriginTaskChannel(t *testing.T) {
|
||||
setupOriginTaskDB(t)
|
||||
channel := insertOriginTaskChannel(t, common.ChannelStatusEnabled)
|
||||
insertOriginOwnedTask(t, "task-endpoint", 7, channel.Id, "origin-endpoint")
|
||||
const key = "origin-endpoint"
|
||||
_, err := jsplugin.DefaultRegistry.Register(taskProtocolPluginSource(
|
||||
key,
|
||||
"1.0.0",
|
||||
`["claimed-model"]`,
|
||||
"/v1/responses",
|
||||
`return {model: ctx.model, originTaskIds: ["task-endpoint"], requestBody: {prompt: "ok"}};`,
|
||||
), jsplugin.Options{})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { require.NoError(t, jsplugin.DefaultRegistry.Unregister(key)) })
|
||||
|
||||
reached := false
|
||||
router := gin.New()
|
||||
router.POST("/v1/responses", func(c *gin.Context) {
|
||||
common.SetContextKey(c, constant.ContextKeyUserId, 7)
|
||||
c.Next()
|
||||
}, PinTaskPluginEndpoint(), PrepareTaskPluginEndpoint(), func(c *gin.Context) {
|
||||
reached = true
|
||||
pinnedID, ok := resolvedOriginPin(c)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, channel.Id, pinnedID)
|
||||
c.Status(http.StatusNoContent)
|
||||
})
|
||||
request := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"claimed-model","input":"hello"}`))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, request)
|
||||
assert.True(t, reached)
|
||||
assert.Equal(t, http.StatusNoContent, recorder.Code)
|
||||
}
|
||||
|
||||
func TestPrepareTaskPluginEndpointRejectsUnknownOriginTask(t *testing.T) {
|
||||
setupOriginTaskDB(t)
|
||||
const key = "origin-endpoint-missing"
|
||||
_, err := jsplugin.DefaultRegistry.Register(taskProtocolPluginSource(
|
||||
key,
|
||||
"1.0.0",
|
||||
`["claimed-model"]`,
|
||||
"/v1/responses",
|
||||
`return {model: ctx.model, originTaskIds: ["missing"], requestBody: {prompt: "ok"}};`,
|
||||
), jsplugin.Options{})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { require.NoError(t, jsplugin.DefaultRegistry.Unregister(key)) })
|
||||
|
||||
reached := false
|
||||
router := gin.New()
|
||||
router.POST("/v1/responses", func(c *gin.Context) {
|
||||
common.SetContextKey(c, constant.ContextKeyUserId, 7)
|
||||
c.Next()
|
||||
}, PinTaskPluginEndpoint(), PrepareTaskPluginEndpoint(), func(c *gin.Context) { reached = true })
|
||||
request := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"claimed-model","input":"hello"}`))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, request)
|
||||
assert.False(t, reached)
|
||||
assert.Equal(t, http.StatusBadRequest, recorder.Code)
|
||||
assert.Contains(t, recorder.Body.String(), "origin_task_not_found")
|
||||
}
|
||||
|
||||
func TestDistributeHonorsOriginTaskChannelPin(t *testing.T) {
|
||||
require.NoError(t, appI18n.Init())
|
||||
setupOriginTaskDB(t)
|
||||
channel := insertOriginTaskChannel(t, common.ChannelStatusEnabled)
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/vendor/jobs", strings.NewReader(`{}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
c.Set("resolved_task_model", "resolved-model")
|
||||
service.GetChannelConstraints(c).AddPin(dto.ChannelPin{
|
||||
ChannelId: channel.Id,
|
||||
Source: dto.PinSourceOriginTask,
|
||||
Rank: dto.PinRankOriginTask,
|
||||
RetryMode: dto.PinRetrySameChannel,
|
||||
})
|
||||
|
||||
nextCalled := false
|
||||
handler := Distribute()
|
||||
handler(c)
|
||||
if !c.IsAborted() {
|
||||
nextCalled = true
|
||||
}
|
||||
assert.True(t, nextCalled)
|
||||
assert.Equal(t, channel.Id, common.GetContextKeyInt(c, constant.ContextKeyChannelId))
|
||||
}
|
||||
|
||||
func TestDistributeTokenPinBeatsOriginPin(t *testing.T) {
|
||||
require.NoError(t, appI18n.Init())
|
||||
setupOriginTaskDB(t)
|
||||
tokenChannel := insertOriginTaskChannel(t, common.ChannelStatusEnabled)
|
||||
originChannel := insertOriginTaskChannel(t, common.ChannelStatusEnabled)
|
||||
|
||||
var warnBuf bytes.Buffer
|
||||
previousWriter := gin.DefaultErrorWriter
|
||||
gin.DefaultErrorWriter = &warnBuf
|
||||
t.Cleanup(func() { gin.DefaultErrorWriter = previousWriter })
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/vendor/jobs", strings.NewReader(`{}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
c.Set("resolved_task_model", "resolved-model")
|
||||
constraints := service.GetChannelConstraints(c)
|
||||
constraints.AddPin(dto.ChannelPin{
|
||||
ChannelId: tokenChannel.Id,
|
||||
Source: dto.PinSourceToken,
|
||||
Rank: dto.PinRankToken,
|
||||
RetryMode: dto.PinRetrySingleAttempt,
|
||||
})
|
||||
constraints.AddPin(dto.ChannelPin{
|
||||
ChannelId: originChannel.Id,
|
||||
Source: dto.PinSourceOriginTask,
|
||||
Rank: dto.PinRankOriginTask,
|
||||
RetryMode: dto.PinRetrySameChannel,
|
||||
})
|
||||
nextCalled := false
|
||||
Distribute()(c)
|
||||
if !c.IsAborted() {
|
||||
nextCalled = true
|
||||
}
|
||||
assert.True(t, nextCalled)
|
||||
assert.Equal(t, tokenChannel.Id, common.GetContextKeyInt(c, constant.ContextKeyChannelId))
|
||||
warn := warnBuf.String()
|
||||
assert.Contains(t, warn, "winning_source=token")
|
||||
assert.Contains(t, warn, fmt.Sprintf("winning_channel_id=%d", tokenChannel.Id))
|
||||
assert.Contains(t, warn, "overridden_source=origin_task")
|
||||
assert.Contains(t, warn, fmt.Sprintf("overridden_channel_id=%d", originChannel.Id))
|
||||
}
|
||||
|
||||
func TestDistributePinViolatingIdentityFilterErrors(t *testing.T) {
|
||||
require.NoError(t, appI18n.Init())
|
||||
setupOriginTaskDB(t)
|
||||
channel := insertOriginTaskChannel(t, common.ChannelStatusEnabled)
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/vendor/jobs", strings.NewReader(`{}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
c.Set("resolved_task_model", "resolved-model")
|
||||
c.Set("expected_task_plugin_key", "alpha")
|
||||
service.GetChannelConstraints(c).AddPin(dto.ChannelPin{
|
||||
ChannelId: channel.Id,
|
||||
Source: dto.PinSourceOriginTask,
|
||||
Rank: dto.PinRankOriginTask,
|
||||
RetryMode: dto.PinRetrySameChannel,
|
||||
})
|
||||
|
||||
Distribute()(c)
|
||||
assert.True(t, c.IsAborted())
|
||||
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)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,51 +1,20 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
var defaultTrustedProxyCIDRs = []string{
|
||||
"127.0.0.0/8",
|
||||
"::1",
|
||||
"10.0.0.0/8",
|
||||
"172.16.0.0/12",
|
||||
"192.168.0.0/16",
|
||||
"fc00::/7",
|
||||
}
|
||||
|
||||
func ConfigureTrustedProxies(engine *gin.Engine) error {
|
||||
rawTrustedProxies := strings.TrimSpace(os.Getenv("TRUSTED_PROXIES"))
|
||||
if rawTrustedProxies == "" {
|
||||
trustedProxies, usedDefaults, err := common.ResolveTrustedProxies(os.Getenv("TRUSTED_PROXIES"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if usedDefaults {
|
||||
log.Print("WARNING: TRUSTED_PROXIES is unset or blank; trusting loopback, RFC 1918, and IPv6 ULA proxy addresses for compatibility. Set TRUSTED_PROXIES=none to trust no proxies, or configure explicit proxy IPs/CIDRs to replace these defaults.")
|
||||
return engine.SetTrustedProxies(defaultTrustedProxyCIDRs)
|
||||
}
|
||||
if strings.EqualFold(rawTrustedProxies, "none") {
|
||||
return engine.SetTrustedProxies(nil)
|
||||
}
|
||||
|
||||
parts := strings.Split(rawTrustedProxies, ",")
|
||||
trustedProxies := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
trustedProxy := strings.TrimSpace(part)
|
||||
if trustedProxy == "" {
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(trustedProxy, "none") {
|
||||
return errors.New("TRUSTED_PROXIES=none must be used alone")
|
||||
}
|
||||
trustedProxies = append(trustedProxies, trustedProxy)
|
||||
}
|
||||
if len(trustedProxies) == 0 {
|
||||
return errors.New("TRUSTED_PROXIES does not contain an IP address or CIDR")
|
||||
}
|
||||
if err := engine.SetTrustedProxies(trustedProxies); err != nil {
|
||||
return fmt.Errorf("invalid TRUSTED_PROXIES: %w", err)
|
||||
}
|
||||
return nil
|
||||
return common.ConfigureTrustedProxies(engine, trustedProxies)
|
||||
}
|
||||
|
||||
+16
-7
@@ -4,7 +4,9 @@ import (
|
||||
"fmt"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
pluginruntime "github.com/QuantumNous/new-api/pkg/jsplugin"
|
||||
"github.com/QuantumNous/new-api/relaykit/types"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -15,13 +17,20 @@ func abortWithOpenAiMessage(c *gin.Context, statusCode int, message string, code
|
||||
codeStr = string(code[0])
|
||||
}
|
||||
userId := c.GetInt("id")
|
||||
c.JSON(statusCode, gin.H{
|
||||
"error": gin.H{
|
||||
"message": common.MessageWithRequestId(message, c.GetString(common.RequestIdKey)),
|
||||
"type": "new_api_error",
|
||||
"code": codeStr,
|
||||
},
|
||||
})
|
||||
_, preparedPluginRoute := c.Get(pluginruntime.ContextKeyRouteRequest)
|
||||
if !preparedPluginRoute || !RespondTaskPluginError(c, &dto.TaskError{
|
||||
Code: codeStr,
|
||||
Message: message,
|
||||
StatusCode: statusCode,
|
||||
}) {
|
||||
c.JSON(statusCode, gin.H{
|
||||
"error": gin.H{
|
||||
"message": common.MessageWithRequestId(message, c.GetString(common.RequestIdKey)),
|
||||
"type": "new_api_error",
|
||||
"code": codeStr,
|
||||
},
|
||||
})
|
||||
}
|
||||
c.Abort()
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("user %d | %s", userId, message))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user