Commit eb48396d by Calcium-Ion Committed by GitHub

feat(task): replace built-in task adaptors with a sandboxed JS plugin system (#7076)

parent 7037ac15
# CLAUDE.md — Project Conventions for new-api
@AGENTS.md
## MANDATORY: Read AGENTS.md with the Read tool
## Claude Code
Do not treat `@AGENTS.md` as loaded. Claude Code does not reliably inline that import.
- Follow the shared project instructions imported from `AGENTS.md`.
\ No newline at end of file
Before any planning, coding, reviewing, or answering a project question, you MUST call the Read tool on the repo-root file `AGENTS.md` and wait for the full contents. This is the first action of every session and every new task.
Rules:
- Do not start from memory, summaries, or this file alone.
- Do not skip the Read because a previous turn mentioned AGENTS.md.
- Do not replace the Read with a grep, glob, or partial skim.
- After reading, follow every rule in `AGENTS.md` for the rest of the work.
- If the task touches `web/`, also Read `web/AGENTS.md` before editing frontend files.
......@@ -34,11 +34,13 @@ Transitive dependencies should be audited before a final external release.
| backend | production | Go | `github.com/google/uuid` | `v1.6.0` | BSD-3-Clause |
| backend | production | Go | `github.com/gorilla/websocket` | `v1.5.0` | BSD-2-Clause |
| backend | production | Go | `github.com/grafana/pyroscope-go` | `v1.2.7` | Apache-2.0 |
| backend | production | Go | `github.com/grafana/sobek` | `v0.0.0-20260708062710-267a0e055bb4` | MIT |
| backend | production | Go | `github.com/jfreymuth/oggvorbis` | `v1.0.5` | MIT |
| backend | production | Go | `github.com/jinzhu/copier` | `v0.4.0` | MIT |
| backend | production | Go | `github.com/joho/godotenv` | `v1.5.1` | MIT |
| backend | production | Go | `github.com/mewkiz/flac` | `v1.0.13` | Unlicense |
| backend | production | Go | `github.com/nicksnyder/go-i18n/v2` | `v2.6.1` | MIT |
| backend | test | Go | `github.com/openai/openai-go` | `v1.12.0` | Apache-2.0 |
| backend | production | Go | `github.com/pkg/errors` | `v0.9.1` | BSD-2-Clause |
| backend | production | Go | `github.com/pquerna/otp` | `v1.5.0` | Apache-2.0 |
| backend | production | Go | `github.com/samber/hot` | `v0.11.0` | MIT |
......@@ -66,6 +68,7 @@ Transitive dependencies should be audited before a final external release.
| backend | production | Go | `gorm.io/gorm` | `v1.25.2` | MIT |
| backend | production | Go | `github.com/expr-lang/expr` | `v1.17.8` | MIT |
| web | production | npm | `@base-ui/react` | `1.6.0` | MIT |
| web | production | npm | `@codemirror/lang-javascript` | `6.2.5` | MIT |
| web | production | npm | `@codemirror/lang-markdown` | `6.5.1` | MIT |
| web | production | npm | `@codemirror/language` | `6.12.4` | MIT |
| web | production | npm | `@codemirror/state` | `6.7.1` | MIT |
......
......@@ -83,6 +83,11 @@ func ChannelType2APIType(channelType int) (int, bool) {
apiType = constant.APITypeNewAPI
}
if apiType == -1 {
// Task plugin channels are served by the task relay and must never
// fall back to the OpenAI adaptor.
if channelType == constant.ChannelTypeTaskPlugin {
return -1, false
}
return constant.APITypeOpenAI, false
}
return apiType, true
......
package common
import (
"testing"
"github.com/QuantumNous/new-api/constant"
"github.com/stretchr/testify/assert"
)
func TestTaskPluginChannelHasNoOrdinaryAPIType(t *testing.T) {
apiType, ok := ChannelType2APIType(constant.ChannelTypeTaskPlugin)
assert.Equal(t, -1, apiType)
assert.False(t, ok)
}
......@@ -187,6 +187,8 @@ func initConstantEnv() {
constant.GetMediaToken = GetEnvOrDefaultBool("GET_MEDIA_TOKEN", true)
constant.GetMediaTokenNotStream = GetEnvOrDefaultBool("GET_MEDIA_TOKEN_NOT_STREAM", false)
constant.UpdateTask = GetEnvOrDefaultBool("UPDATE_TASK", true)
constant.TaskPluginEnabled = GetEnvOrDefaultBool("TASK_PLUGIN_ENABLED", true)
constant.TaskPluginOverrideEnabled = GetEnvOrDefaultBool("TASK_PLUGIN_OVERRIDE_ENABLED", true)
constant.AzureDefaultAPIVersion = GetEnvOrDefaultString("AZURE_DEFAULT_API_VERSION", "2025-04-01-preview")
constant.NotifyLimitCount = GetEnvOrDefault("NOTIFY_LIMIT_COUNT", 2)
constant.NotificationLimitDurationMinute = GetEnvOrDefault("NOTIFICATION_LIMIT_DURATION_MINUTE", 10)
......@@ -198,6 +200,12 @@ func initConstantEnv() {
constant.TaskQueryLimit = GetEnvOrDefault("TASK_QUERY_LIMIT", 1000)
// 异步任务超时时间(分钟),超过此时间未完成的任务将被标记为失败并退款。0 表示禁用。
constant.TaskTimeoutMinutes = GetEnvOrDefault("TASK_TIMEOUT_MINUTES", 1440)
// 声明式任务协议桥只观察数据库;这些值控制一次客户端观察连接,
// 不改变后台轮询或结算生命周期。
constant.TaskPluginProtocolTimeoutSeconds = GetEnvOrDefault("TASK_PLUGIN_PROTOCOL_TIMEOUT_SECONDS", 600)
constant.TaskPluginProtocolTickMilliseconds = GetEnvOrDefault("TASK_PLUGIN_PROTOCOL_TICK_MILLISECONDS", 2000)
constant.TaskPluginProtocolTickJitterMilliseconds = GetEnvOrDefault("TASK_PLUGIN_PROTOCOL_TICK_JITTER_MILLISECONDS", 500)
constant.TaskPluginProtocolHeartbeatSeconds = GetEnvOrDefault("TASK_PLUGIN_PROTOCOL_HEARTBEAT_SECONDS", 15)
soraPatchStr := GetEnvOrDefaultString("TASK_PRICE_PATCH", "")
if soraPatchStr != "" {
......
package common
import (
"errors"
"fmt"
"strings"
"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",
}
// ResolveTrustedProxies parses TRUSTED_PROXIES without applying it to an
// engine. The returned slice can be reused by the outer and plugin engines.
func ResolveTrustedProxies(raw string) (trustedProxies []string, usedDefaults bool, err error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return append([]string(nil), defaultTrustedProxyCIDRs...), true, nil
}
if strings.EqualFold(raw, "none") {
return nil, false, nil
}
parts := strings.Split(raw, ",")
trustedProxies = make([]string, 0, len(parts))
for _, part := range parts {
trustedProxy := strings.TrimSpace(part)
if trustedProxy == "" {
continue
}
if strings.EqualFold(trustedProxy, "none") {
return nil, false, errors.New("TRUSTED_PROXIES=none must be used alone")
}
trustedProxies = append(trustedProxies, trustedProxy)
}
if len(trustedProxies) == 0 {
return nil, false, errors.New("TRUSTED_PROXIES does not contain an IP address or CIDR")
}
return trustedProxies, false, nil
}
func ConfigureTrustedProxies(engine *gin.Engine, trustedProxies []string) error {
if err := engine.SetTrustedProxies(trustedProxies); err != nil {
return fmt.Errorf("invalid TRUSTED_PROXIES: %w", err)
}
return nil
}
......@@ -58,6 +58,7 @@ const (
ChannelTypeAdvancedCustom = 58
ChannelTypeSub2API = 59
ChannelTypeNewAPI = 60
ChannelTypeTaskPlugin = 61
ChannelTypeDummy // this one is only for count, do not add any channel after this
)
......@@ -124,6 +125,14 @@ var ChannelBaseURLs = []string{
"", //58
"", //59
"", //60
"", //61
}
func GetChannelBaseURL(channelType int) string {
if channelType < 0 || channelType >= len(ChannelBaseURLs) {
return ""
}
return ChannelBaseURLs[channelType]
}
var ChannelTypeNames = map[int]string{
......@@ -184,6 +193,7 @@ var ChannelTypeNames = map[int]string{
ChannelTypeAdvancedCustom: "Advanced Custom",
ChannelTypeSub2API: "Sub2API",
ChannelTypeNewAPI: "New API",
ChannelTypeTaskPlugin: "Task Plugin",
}
func GetChannelTypeName(channelType int) string {
......
package constant
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestGetChannelBaseURLIsBoundsSafe(t *testing.T) {
assert.Empty(t, GetChannelBaseURL(ChannelTypeTaskPlugin))
assert.Empty(t, GetChannelBaseURL(9999))
}
......@@ -15,7 +15,8 @@ const (
ContextKeyTokenKey ContextKey = "token_key"
ContextKeyTokenId ContextKey = "token_id"
ContextKeyTokenGroup ContextKey = "token_group"
ContextKeyTokenSpecificChannelId ContextKey = "specific_channel_id"
ContextKeyOriginTasks ContextKey = "origin_tasks"
ContextKeyChannelConstraints ContextKey = "channel_constraints"
ContextKeyTokenModelLimitEnabled ContextKey = "token_model_limit_enabled"
ContextKeyTokenModelLimit ContextKey = "token_model_limit"
ContextKeyTokenCrossGroupRetry ContextKey = "token_cross_group_retry"
......
......@@ -18,6 +18,10 @@ var GenerateDefaultToken bool
var ErrorLogEnabled bool
var TaskQueryLimit int
var TaskTimeoutMinutes int
var TaskPluginProtocolTimeoutSeconds int
var TaskPluginProtocolTickMilliseconds int
var TaskPluginProtocolTickJitterMilliseconds int
var TaskPluginProtocolHeartbeatSeconds int
// temporary variable for sora patch, will be removed in future
var TaskPricePatches []string
......
......@@ -8,17 +8,35 @@ const (
)
const (
SunoActionMusic = "MUSIC"
SunoActionLyrics = "LYRICS"
TaskActionGenerate = "generate"
TaskActionTextGenerate = "textGenerate"
TaskActionFirstTailGenerate = "firstTailGenerate"
TaskActionReferenceGenerate = "referenceGenerate"
TaskActionRemix = "remixGenerate"
TaskActionImageToVideo = "image_to_video"
TaskActionTextToVideo = "text_to_video"
TaskActionFirstTailToVideo = "first_tail_to_video"
TaskActionReferenceToVideo = "reference_to_video"
TaskActionRemix = "remix"
)
var SunoModel2Action = map[string]string{
"suno_music": SunoActionMusic,
"suno_lyrics": SunoActionLyrics,
var legacyTaskActionAliases = map[string]string{
"generate": TaskActionImageToVideo,
"textGenerate": TaskActionTextToVideo,
"firstTailGenerate": TaskActionFirstTailToVideo,
"referenceGenerate": TaskActionReferenceToVideo,
"remixGenerate": TaskActionRemix,
}
// TaskPluginEnabled is the master switch for the whole task-plugin system.
// When disabled, factory and override plugins both stop serving.
var TaskPluginEnabled = true
// TaskPluginOverrideEnabled controls whether the database override layer is
// active. When disabled, uploaded plugins are ignored and factory plugins are
// used instead; the factory layer is unaffected.
var TaskPluginOverrideEnabled = true
// NormalizeTaskAction maps persisted legacy action names to the canonical task
// action vocabulary. Unknown platform-specific actions pass through unchanged.
func NormalizeTaskAction(action string) string {
if canonical, ok := legacyTaskActionAliases[action]; ok {
return canonical
}
return action
}
package constant
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestNormalizeTaskAction(t *testing.T) {
tests := map[string]string{
"generate": TaskActionImageToVideo,
"textGenerate": TaskActionTextToVideo,
"firstTailGenerate": TaskActionFirstTailToVideo,
"referenceGenerate": TaskActionReferenceToVideo,
"remixGenerate": TaskActionRemix,
TaskActionTextToVideo: TaskActionTextToVideo,
"MUSIC": "MUSIC",
"custom_action": "custom_action",
"": "",
}
for input, expected := range tests {
t.Run(input, func(t *testing.T) {
assert.Equal(t, expected, NormalizeTaskAction(input))
})
}
}
package controller
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/pkg/jsplugin"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestUpdateOptionRejectsInvalidTaskBillingExpressions(t *testing.T) {
const pluginKey = "billing-save-probe"
const modelName = "billing-save-model"
source := `
export const meta = {
apiVersion: 1, key: "billing-save-probe", name: "Billing Save Probe", version: "1.0.0", author: {name: "Test"},
models: ["billing-save-model"], fetchMode: "per_task",
usageSchema: {seconds: {type: "number", unit: "second"}}
};
export function buildSubmitRequest() { return {}; }
export function parseSubmitResponse() { return {}; }
export function buildQueryRequest() { return {}; }
export function parseTaskResult() { return {}; }
`
_, err := jsplugin.DefaultRegistry.Register(source, jsplugin.Options{})
require.NoError(t, err)
t.Cleanup(func() { jsplugin.DefaultRegistry.Unregister(pluginKey) })
tests := []struct {
name string
expression string
errorText string
}{
{
name: "invalid syntax",
expression: `tier("base",`,
errorText: "expr compile error",
},
{
name: "undeclared usage key",
expression: `tier("base", u("clips") * 0.1)`,
errorText: `usage key \"clips\" is not declared`,
},
}
for _, testCase := range tests {
t.Run(testCase.name, func(t *testing.T) {
expressions, marshalErr := common.Marshal(map[string]string{modelName: testCase.expression})
require.NoError(t, marshalErr)
body, marshalErr := common.Marshal(OptionUpdateRequest{
Key: "billing_setting.billing_expr",
Value: string(expressions),
})
require.NoError(t, marshalErr)
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
context.Request = httptest.NewRequest(http.MethodPut, "/api/option/", strings.NewReader(string(body)))
UpdateOption(context)
assert.Equal(t, http.StatusOK, recorder.Code)
assert.Contains(t, recorder.Body.String(), `"success":false`)
assert.Contains(t, recorder.Body.String(), modelName)
assert.Contains(t, recorder.Body.String(), testCase.errorText)
})
}
}
func TestUpdateOptionRejectsUsageExpressionWithoutTaskPlugin(t *testing.T) {
const modelName = "billing-save-model-without-plugin"
expressions, err := common.Marshal(map[string]string{
modelName: `u("mode") == "std" ? 1 : 2`,
})
require.NoError(t, err)
body, err := common.Marshal(OptionUpdateRequest{
Key: "billing_setting.billing_expr",
Value: string(expressions),
})
require.NoError(t, err)
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
context.Request = httptest.NewRequest(
http.MethodPut,
"/api/option/",
strings.NewReader(string(body)),
)
UpdateOption(context)
assert.Equal(t, http.StatusOK, recorder.Code)
assert.Contains(t, recorder.Body.String(), `"success":false`)
assert.Contains(t, recorder.Body.String(), modelName)
assert.Contains(t, recorder.Body.String(), "mode")
assert.Contains(t, recorder.Body.String(), "no task plugin usage schema")
}
......@@ -463,7 +463,7 @@ func updateChannelBalance(channel *model.Channel) (channelBalanceResult, error)
}
func updateStandardChannelBalance(channel *model.Channel) (float64, error) {
baseURL := constant.ChannelBaseURLs[channel.Type]
baseURL := constant.GetChannelBaseURL(channel.Type)
if channel.GetBaseURL() == "" {
channel.BaseURL = &baseURL
}
......@@ -538,6 +538,10 @@ func UpdateChannelBalance(c *gin.Context) {
common.ApiError(c, err)
return
}
if channel.Type == constant.ChannelTypeTaskPlugin {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "Task Plugin channels do not support balance queries"})
return
}
if channel.ChannelInfo.IsMultiKey {
c.JSON(http.StatusOK, gin.H{
"success": false,
......
......@@ -82,6 +82,7 @@ func testChannel(ctx context.Context, channel *model.Channel, testUserID int, te
constant.ChannelTypeJimeng,
constant.ChannelTypeDoubaoVideo,
constant.ChannelTypeVidu,
constant.ChannelTypeTaskPlugin,
}
if lo.Contains(unsupportedTestChannelTypes, channel.Type) {
channelTypeName := constant.GetChannelTypeName(channel.Type)
......
......@@ -13,6 +13,7 @@ import (
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/pkg/jsplugin"
relaychannel "github.com/QuantumNous/new-api/relay/channel"
"github.com/QuantumNous/new-api/relay/channel/ollama"
relaycommon "github.com/QuantumNous/new-api/relay/common"
......@@ -480,6 +481,21 @@ func validateChannel(channel *model.Channel, isAdd bool) error {
if err := channel.ValidateSettings(); err != nil {
return fmt.Errorf("渠道额外设置[channel setting] 格式错误:%s", err.Error())
}
if channel.Type == constant.ChannelTypeTaskPlugin {
pluginKey := strings.TrimSpace(channel.GetSetting().TaskPluginKey)
if pluginKey == "" {
return fmt.Errorf("task plugin key is required")
}
if len(pluginKey) > 30 {
return fmt.Errorf("task plugin key must not exceed 30 characters")
}
if _, ok := jsplugin.DefaultRegistry.Get(pluginKey); !ok {
return fmt.Errorf("task plugin %q is not registered", pluginKey)
}
if channel.BaseURL == nil || strings.TrimSpace(*channel.BaseURL) == "" {
return fmt.Errorf("base URL is required for task plugin channels")
}
}
if channel.Type == constant.ChannelTypeNewAPI && strings.TrimSpace(channel.GetBaseURL()) == "" {
return fmt.Errorf("New API channel base URL cannot be empty")
......@@ -617,6 +633,15 @@ func AddChannel(c *gin.Context) {
return
}
if addChannelRequest.Channel != nil && addChannelRequest.Channel.Type == constant.ChannelTypeTaskPlugin &&
!authz.Can(c.GetInt("id"), c.GetInt("role"), authz.TaskPluginBind) {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "task plugin channels require the task_plugin.bind permission",
})
return
}
// 使用统一的校验函数
if err := validateChannel(addChannelRequest.Channel, true); err != nil {
c.JSON(http.StatusOK, gin.H{
......@@ -964,6 +989,15 @@ func UpdateChannel(c *gin.Context) {
}
clearChannelReadOnlyFields(&channel, requestData)
if channel.Type == constant.ChannelTypeTaskPlugin &&
!authz.Can(c.GetInt("id"), c.GetInt("role"), authz.TaskPluginBind) {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "task plugin channels require the task_plugin.bind permission",
})
return
}
// 使用统一的校验函数
if err := validateChannel(&channel.Channel, false); err != nil {
c.JSON(http.StatusOK, gin.H{
......@@ -1299,7 +1333,7 @@ func FetchModels(c *gin.Context) {
baseURL = strings.TrimSpace(*req.BaseURL)
}
if baseURL == "" {
baseURL = constant.ChannelBaseURLs[req.Type]
baseURL = constant.GetChannelBaseURL(req.Type)
}
key := strings.TrimSpace(req.Key)
......@@ -1424,6 +1458,11 @@ func CopyChannel(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取渠道信息失败,请稍后重试"})
return
}
if origin.Type == constant.ChannelTypeTaskPlugin &&
!authz.Can(c.GetInt("id"), c.GetInt("role"), authz.TaskPluginBind) {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "task plugin channels require the task_plugin.bind permission"})
return
}
// clone channel
clone := *origin // shallow copy is sufficient as we will overwrite primitives
......@@ -2010,7 +2049,7 @@ func OllamaPullModel(c *gin.Context) {
return
}
baseURL := constant.ChannelBaseURLs[channel.Type]
baseURL := constant.GetChannelBaseURL(channel.Type)
if channel.GetBaseURL() != "" {
baseURL = channel.GetBaseURL()
}
......@@ -2073,7 +2112,7 @@ func OllamaPullModelStream(c *gin.Context) {
return
}
baseURL := constant.ChannelBaseURLs[channel.Type]
baseURL := constant.GetChannelBaseURL(channel.Type)
if channel.GetBaseURL() != "" {
baseURL = channel.GetBaseURL()
}
......@@ -2155,7 +2194,7 @@ func OllamaDeleteModel(c *gin.Context) {
return
}
baseURL := constant.ChannelBaseURLs[channel.Type]
baseURL := constant.GetChannelBaseURL(channel.Type)
if channel.GetBaseURL() != "" {
baseURL = channel.GetBaseURL()
}
......@@ -2204,7 +2243,7 @@ func OllamaVersion(c *gin.Context) {
return
}
baseURL := constant.ChannelBaseURLs[channel.Type]
baseURL := constant.GetChannelBaseURL(channel.Type)
if channel.GetBaseURL() != "" {
baseURL = channel.GetBaseURL()
}
......
package controller
import (
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestShouldRetryHonorsPinRetryMode(t *testing.T) {
openaiErr := types.NewOpenAIError(errors.New("upstream"), types.ErrorCodeBadResponseStatusCode, http.StatusInternalServerError)
c := newPinRetryContext()
assert.True(t, shouldRetry(c, openaiErr, 1))
origin := newPinRetryContext()
service.GetChannelConstraints(origin).AddPin(dto.ChannelPin{
ChannelId: 2,
Source: dto.PinSourceOriginTask,
Rank: dto.PinRankOriginTask,
RetryMode: dto.PinRetrySameChannel,
})
assert.True(t, shouldRetry(origin, openaiErr, 1), "origin pin retries on the same channel")
token := newPinRetryContext()
service.GetChannelConstraints(token).AddPin(dto.ChannelPin{
ChannelId: 1,
Source: dto.PinSourceToken,
Rank: dto.PinRankToken,
RetryMode: dto.PinRetrySingleAttempt,
})
assert.False(t, shouldRetry(token, openaiErr, 1), "token pin suppresses retry")
}
func TestShouldRetryTaskRelayHonorsPinRetryMode(t *testing.T) {
taskErr := &dto.TaskError{StatusCode: http.StatusInternalServerError}
c := newPinRetryContext()
assert.True(t, shouldRetryTaskRelay(c, 1, taskErr, 1))
origin := newPinRetryContext()
service.GetChannelConstraints(origin).AddPin(dto.ChannelPin{
ChannelId: 2,
Source: dto.PinSourceOriginTask,
Rank: dto.PinRankOriginTask,
RetryMode: dto.PinRetrySameChannel,
})
assert.True(t, shouldRetryTaskRelay(origin, 2, taskErr, 1))
token := newPinRetryContext()
service.GetChannelConstraints(token).AddPin(dto.ChannelPin{
ChannelId: 1,
Source: dto.PinSourceToken,
Rank: dto.PinRankToken,
RetryMode: dto.PinRetrySingleAttempt,
})
assert.False(t, shouldRetryTaskRelay(token, 1, taskErr, 1))
}
func TestSameChannelPinsMergeToStricterRetryMode(t *testing.T) {
c := newPinRetryContext()
constraints := service.GetChannelConstraints(c)
constraints.AddPin(dto.ChannelPin{
ChannelId: 7,
Source: dto.PinSourceOriginTask,
Rank: dto.PinRankOriginTask,
RetryMode: dto.PinRetrySameChannel,
})
constraints.AddPin(dto.ChannelPin{
ChannelId: 7,
Source: dto.PinSourceToken,
Rank: dto.PinRankToken,
RetryMode: dto.PinRetrySingleAttempt,
})
pin, found, overridden := constraints.ResolvedPin()
require.True(t, found)
assert.Equal(t, 7, pin.ChannelId)
assert.Equal(t, dto.PinRetrySingleAttempt, pin.RetryMode)
assert.Empty(t, overridden)
assert.False(t, shouldRetry(c, types.NewOpenAIError(errors.New("upstream"), types.ErrorCodeBadResponseStatusCode, http.StatusInternalServerError), 1))
}
func newPinRetryContext() *gin.Context {
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
return c
}
package controller
import (
"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/model"
"github.com/QuantumNous/new-api/pkg/jsplugin"
"github.com/QuantumNous/new-api/service/authz"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func setupTaskPluginBindChannelTest(t *testing.T) {
t.Helper()
wasMaster := common.IsMasterNode
common.IsMasterNode = true
previousRedisEnabled := common.RedisEnabled
common.RedisEnabled = false
originalDB, originalLogDB := model.DB, model.LOG_DB
database, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
sqlDB, err := database.DB()
require.NoError(t, err)
sqlDB.SetMaxOpenConns(1)
require.NoError(t, database.AutoMigrate(&model.Channel{}, &model.Ability{}, &model.CasbinRule{}, &model.AuthzRole{}, &model.Log{}, &model.User{}))
model.DB = database
model.LOG_DB = database
require.NoError(t, authz.Init(database))
t.Cleanup(func() {
common.IsMasterNode = wasMaster
common.RedisEnabled = previousRedisEnabled
model.DB = originalDB
model.LOG_DB = originalLogDB
})
}
func postAddChannel(t *testing.T, userID, role int, body string) *httptest.ResponseRecorder {
t.Helper()
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
context.Set("id", userID)
context.Set("role", role)
context.Request = httptest.NewRequest(http.MethodPost, "/api/channel", strings.NewReader(body))
context.Request.Header.Set("Content-Type", "application/json")
AddChannel(context)
return recorder
}
func TestAddChannelTaskPluginRequiresBindPermission(t *testing.T) {
setupTaskPluginBindChannelTest(t)
const key = "channel-bind"
source := `
export const meta = {apiVersion: 1, key: "channel-bind", name: "Bind", version: "1.0.0", author: {name: "Test"}, models: ["doc"], fetchMode: "per_task"};
export function buildSubmitRequest() { return {}; }
export function parseSubmitResponse() { return {}; }
export function buildQueryRequest() { return {}; }
export function parseTaskResult() { return {}; }
`
_, err := jsplugin.DefaultRegistry.Register(source, jsplugin.Options{})
require.NoError(t, err)
t.Cleanup(func() { jsplugin.DefaultRegistry.Unregister(key) })
taskPluginBody := `{"mode":"single","channel":{"type":61,"name":"plugin-channel","key":"sk","models":"doc","group":"default","base_url":"https://example.com","setting":"{\"task_plugin_key\":\"channel-bind\"}"}}`
openaiBody := `{"mode":"single","channel":{"type":1,"name":"openai-channel","key":"sk","models":"gpt","group":"default"}}`
adminDenied := postAddChannel(t, 2, common.RoleAdminUser, taskPluginBody)
assert.Contains(t, adminDenied.Body.String(), "task plugin channels require the task_plugin.bind permission")
assert.Contains(t, adminDenied.Body.String(), `"success":false`)
rootAllowed := postAddChannel(t, 1, common.RoleRootUser, taskPluginBody)
assert.Contains(t, rootAllowed.Body.String(), `"success":true`)
assert.NotContains(t, rootAllowed.Body.String(), "task_plugin.bind")
adminOtherType := postAddChannel(t, 2, common.RoleAdminUser, openaiBody)
assert.Contains(t, adminOtherType.Body.String(), `"success":true`)
assert.NotContains(t, adminOtherType.Body.String(), "task_plugin.bind")
}
func TestUpdateChannelTaskPluginRequiresBindPermission(t *testing.T) {
setupTaskPluginBindChannelTest(t)
const key = "channel-bind-update"
source := `
export const meta = {apiVersion: 1, key: "channel-bind-update", name: "Bind", version: "1.0.0", author: {name: "Test"}, models: ["doc"], fetchMode: "per_task"};
export function buildSubmitRequest() { return {}; }
export function parseSubmitResponse() { return {}; }
export function buildQueryRequest() { return {}; }
export function parseTaskResult() { return {}; }
`
_, err := jsplugin.DefaultRegistry.Register(source, jsplugin.Options{})
require.NoError(t, err)
t.Cleanup(func() { jsplugin.DefaultRegistry.Unregister(key) })
baseURL := "https://example.com"
setting := `{"task_plugin_key":"channel-bind-update"}`
channel := model.Channel{
Type: constant.ChannelTypeTaskPlugin,
Status: common.ChannelStatusEnabled,
Name: "existing-plugin",
Models: "doc",
Group: "default",
Key: "sk",
BaseURL: &baseURL,
Setting: &setting,
}
require.NoError(t, channel.Insert())
payload := fmt.Sprintf(
`{"id":%d,"type":61,"name":"existing-plugin","key":"sk","models":"doc","group":"default","base_url":"https://example.com","setting":"{\"task_plugin_key\":\"channel-bind-update\"}"}`,
channel.Id,
)
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
context.Set("id", 2)
context.Set("role", common.RoleAdminUser)
context.Request = httptest.NewRequest(http.MethodPut, "/api/channel", strings.NewReader(payload))
context.Request.Header.Set("Content-Type", "application/json")
UpdateChannel(context)
assert.Contains(t, recorder.Body.String(), "task plugin channels require the task_plugin.bind permission")
assert.Contains(t, recorder.Body.String(), `"success":false`)
}
package controller
import (
"strings"
"testing"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/pkg/jsplugin"
"github.com/stretchr/testify/require"
)
func TestValidateTaskPluginChannel(t *testing.T) {
source := `
export const meta = {apiVersion: 1, key: "channel-validation", name: "Validation", version: "1.0.0", author: {name: "Test"}, models: ["doc"], fetchMode: "per_task"};
export function buildSubmitRequest() { return {}; }
export function parseSubmitResponse() { return {}; }
export function buildQueryRequest() { return {}; }
export function parseTaskResult() { return {}; }
`
_, err := jsplugin.DefaultRegistry.Register(source, jsplugin.Options{})
require.NoError(t, err)
t.Cleanup(func() { jsplugin.DefaultRegistry.Unregister("channel-validation") })
baseURL := "https://example.com"
channel := &model.Channel{Type: constant.ChannelTypeTaskPlugin, BaseURL: &baseURL}
require.ErrorContains(t, validateChannel(channel, false), "task plugin key is required")
missing := `{"task_plugin_key":"missing"}`
channel.Setting = &missing
require.ErrorContains(t, validateChannel(channel, false), "is not registered")
longKey := `{"task_plugin_key":"` + strings.Repeat("x", 31) + `"}`
channel.Setting = &longKey
require.ErrorContains(t, validateChannel(channel, false), "must not exceed 30")
valid := `{"task_plugin_key":"channel-validation"}`
channel.Setting = &valid
channel.BaseURL = nil
require.ErrorContains(t, validateChannel(channel, false), "base URL is required")
}
......@@ -16,6 +16,7 @@ import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/pkg/jsplugin"
"github.com/QuantumNous/new-api/relay/channel/advancedcustom"
"github.com/QuantumNous/new-api/relay/channel/gemini"
"github.com/QuantumNous/new-api/relay/channel/ollama"
......@@ -361,7 +362,14 @@ func getFetchModelsResponseBody(method string, requestURL string, channel *model
}
func fetchChannelUpstreamModelIDs(channel *model.Channel) ([]string, error) {
baseURL := constant.ChannelBaseURLs[channel.Type]
if channel.Type == constant.ChannelTypeTaskPlugin {
plugin, ok := jsplugin.DefaultRegistry.Get(channel.GetSetting().TaskPluginKey)
if !ok {
return nil, fmt.Errorf("task plugin %q is not registered", channel.GetSetting().TaskPluginKey)
}
return normalizeModelNames(plugin.Meta.Models), nil
}
baseURL := constant.GetChannelBaseURL(channel.Type)
if channel.GetBaseURL() != "" {
baseURL = channel.GetBaseURL()
}
......
......@@ -27,6 +27,9 @@ func GetAllLogs(c *gin.Context) {
common.ApiError(c, err)
return
}
if c.GetInt("role") < common.RoleRootUser {
model.FormatAdminLogs(logs)
}
pageInfo.SetTotal(int(total))
pageInfo.SetItems(logs)
common.ApiSuccess(c, pageInfo)
......
......@@ -9,6 +9,7 @@ import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/pkg/jsplugin"
"github.com/QuantumNous/new-api/relay"
"github.com/QuantumNous/new-api/relay/channel/ai360"
"github.com/QuantumNous/new-api/relay/channel/lingyiwanwu"
......@@ -97,6 +98,9 @@ func init() {
for i := 1; i <= constant.ChannelTypeDummy; i++ {
apiType, success := common.ChannelType2APIType(i)
if !success || apiType == constant.APITypeAIProxyLibrary {
if plugin, ok := jsplugin.DefaultRegistry.GetByChannelType(i); ok {
channelId2Models[i] = append([]string(nil), plugin.Meta.Models...)
}
continue
}
meta := &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{
......@@ -105,6 +109,11 @@ func init() {
adaptor := relay.GetAdaptor(apiType)
adaptor.Init(meta)
channelId2Models[i] = adaptor.GetModelList()
if len(channelId2Models[i]) == 0 {
if plugin, ok := jsplugin.DefaultRegistry.GetByChannelType(i); ok {
channelId2Models[i] = append([]string(nil), plugin.Meta.Models...)
}
}
}
openAIModels = lo.UniqBy(openAIModels, func(m dto.OpenAIModels) string {
return m.Id
......@@ -314,9 +323,18 @@ func ChannelListModels(c *gin.Context) {
}
func DashboardListModels(c *gin.Context) {
modelsByChannel := make(map[int][]string, len(channelId2Models))
for channelType, models := range channelId2Models {
modelsByChannel[channelType] = append([]string(nil), models...)
}
for channelType := 1; channelType <= constant.ChannelTypeDummy; channelType++ {
if plugin, ok := jsplugin.DefaultRegistry.GetByChannelType(channelType); ok {
modelsByChannel[channelType] = append([]string(nil), plugin.Meta.Models...)
}
}
c.JSON(200, gin.H{
"success": true,
"data": channelId2Models,
"data": modelsByChannel,
})
}
......
......@@ -3,13 +3,17 @@ package controller
import (
"fmt"
"net/http"
"sort"
"strconv"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/pkg/jsplugin"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting"
"github.com/QuantumNous/new-api/setting/billing_setting"
"github.com/QuantumNous/new-api/setting/console_setting"
"github.com/QuantumNous/new-api/setting/model_setting"
"github.com/QuantumNous/new-api/setting/operation_setting"
......@@ -153,6 +157,12 @@ func UpdateOption(c *gin.Context) {
return
}
}
if option.Key == "TaskPublicAddress" && option.Value.(string) != "" {
if err := service.ValidateTaskArtifactBaseURL(option.Value.(string)); err != nil {
common.ApiErrorMsg(c, err.Error())
return
}
}
switch option.Key {
case "GitHubOAuthEnabled":
if option.Value == "true" && common.GitHubClientId == "" {
......@@ -326,6 +336,30 @@ func UpdateOption(c *gin.Context) {
})
return
}
case "billing_setting.billing_expr":
expressions := make(map[string]string)
if err = common.UnmarshalJsonStr(option.Value.(string), &expressions); err != nil {
common.ApiErrorMsg(c, "计费表达式配置必须是模型到表达式的 JSON 对象: "+err.Error())
return
}
models := make([]string, 0, len(expressions))
for modelName := range expressions {
models = append(models, modelName)
}
sort.Strings(models)
generation := jsplugin.DefaultRegistry.Generation()
for _, modelName := range models {
expression := expressions[modelName]
if plugin, ok := generation.GetByModel(modelName); ok {
err = billing_setting.SmokeTestTaskExpr(expression, plugin.Meta.UsageSchema)
} else {
err = billing_setting.SmokeTestExpr(expression)
}
if err != nil {
common.ApiErrorMsg(c, fmt.Sprintf("模型 %s 的计费表达式无效: %v", modelName, err))
return
}
}
case "console_setting.api_info":
err = console_setting.ValidateConsoleSettings(option.Value.(string), "ApiInfo")
if err != nil {
......
package controller
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/QuantumNous/new-api/pkg/jsplugin"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
)
func TestRelayTaskPluginEndpointPreservesUnclaimedFallback(t *testing.T) {
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
fallbackCalls := 0
RelayTaskPluginEndpoint(c, func(c *gin.Context) {
fallbackCalls++
c.Status(http.StatusNoContent)
c.Writer.WriteHeaderNow()
})
assert.Equal(t, 1, fallbackCalls)
assert.Equal(t, http.StatusNoContent, recorder.Code)
}
func TestRelayTaskPluginEndpointNeverEntersOrdinaryRelayWhenClaimed(t *testing.T) {
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Set(jsplugin.ContextKeyPinnedEndpoint, jsplugin.PinnedEndpoint{
Generation: &jsplugin.RoutingGeneration{},
Plugin: &jsplugin.LoadedPlugin{},
Protocol: "openai_responses",
Operation: jsplugin.HostProtocolOperation{Name: "create"},
})
fallbackCalls := 0
RelayTaskPluginEndpoint(c, func(c *gin.Context) {
fallbackCalls++
c.Status(http.StatusNoContent)
c.Writer.WriteHeaderNow()
})
assert.Zero(t, fallbackCalls)
assert.NotEqual(t, http.StatusNoContent, recorder.Code)
}
package controller
import (
"bytes"
"context"
"io"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
pluginruntime "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/QuantumNous/new-api/setting/ratio_setting"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
type nativeRouteBilling struct {
events []string
preConsumed int
userID int
settled bool
}
func (b *nativeRouteBilling) Settle(int) error {
b.events = append(b.events, "settle")
b.settled = true
return nil
}
func (b *nativeRouteBilling) Refund(*gin.Context) {
b.events = append(b.events, "refund")
if !b.settled && b.preConsumed > 0 {
_ = model.IncreaseUserQuota(b.userID, b.preConsumed, true)
b.preConsumed = 0
}
}
func (b *nativeRouteBilling) NeedsRefund() bool {
return !b.settled && b.preConsumed > 0
}
func (b *nativeRouteBilling) GetPreConsumedQuota() int {
return b.preConsumed
}
func (b *nativeRouteBilling) Reserve(quota int) error {
b.events = append(b.events, "reserve")
if err := model.DecreaseUserQuota(b.userID, quota, true); err != nil {
return err
}
b.preConsumed = quota
return nil
}
func TestKlingNativeRouteSubmitPollSettleAndQuery(t *testing.T) {
gin.SetMode(gin.TestMode)
service.InitHttpClient()
previousDB := model.DB
previousLogDB := model.LOG_DB
previousMemoryCache := common.MemoryCacheEnabled
previousBatchUpdate := common.BatchUpdateEnabled
previousLogConsume := common.LogConsumeEnabled
previousRedisEnabled := common.RedisEnabled
database, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, database.AutoMigrate(&model.User{}, &model.Channel{}, &model.Task{}, &model.Log{}))
model.DB = database
model.LOG_DB = database
common.MemoryCacheEnabled = false
common.BatchUpdateEnabled = false
common.LogConsumeEnabled = false
common.RedisEnabled = false
previousModelRatios := ratio_setting.ModelRatio2JSONString()
require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(`{"kling-v1":1}`))
t.Cleanup(func() {
model.DB = previousDB
model.LOG_DB = previousLogDB
common.MemoryCacheEnabled = previousMemoryCache
common.BatchUpdateEnabled = previousBatchUpdate
common.LogConsumeEnabled = previousLogConsume
common.RedisEnabled = previousRedisEnabled
require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(previousModelRatios))
})
require.NoError(t, database.Create(&model.User{
Id: 7,
Username: "native-route-user",
Group: "default",
Quota: 1_000_000,
}).Error)
var submitCalls atomic.Int32
var queryCalls atomic.Int32
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.Method == http.MethodPost && r.URL.Path == "/kling/v1/videos/text2video":
submitCalls.Add(1)
body, readErr := io.ReadAll(r.Body)
if !assert.NoError(t, readErr) {
http.Error(w, "read request", http.StatusInternalServerError)
return
}
assert.Contains(t, string(body), `"model_name":"kling-v1"`)
_, _ = io.WriteString(w, `{"code":0,"message":"","data":{"task_id":"kling-private-1","task_status":"submitted"}}`)
case r.Method == http.MethodGet && r.URL.Path == "/kling/v1/videos/text2video/kling-private-1":
queryCalls.Add(1)
_, _ = io.WriteString(w, `{"code":0,"message":"","data":{"task_id":"kling-private-1","task_status":"succeed","task_status_msg":"","task_result":{"videos":[{"id":"video-private","url":"https://cdn.example/video.mp4","duration":"5"}]},"final_unit_deduction":"1"}}`)
default:
http.NotFound(w, r)
}
}))
defer upstream.Close()
channel := model.Channel{
Type: constant.ChannelTypeKling,
Name: "kling-native-e2e",
Key: "sk-test",
BaseURL: &upstream.URL,
Status: common.ChannelStatusEnabled,
Models: "kling-v1",
Group: "default",
}
require.NoError(t, database.Create(&channel).Error)
generation := pluginruntime.DefaultRegistry.Generation()
require.NotNil(t, generation)
submitBinding, found := generation.LookupDeclaredRoute(http.MethodPost, "/kling/v1/videos/text2video")
require.True(t, found)
require.Equal(t, "kling", submitBinding.Plugin.Meta.Key)
submitRecorder := httptest.NewRecorder()
submitContext, _ := gin.CreateTestContext(submitRecorder)
submitContext.Request = httptest.NewRequest(
http.MethodPost,
"/kling/v1/videos/text2video",
bytes.NewBufferString(`{"model_name":"kling-v1","prompt":"a lighthouse"}`),
)
submitContext.Request.Header.Set("Content-Type", "application/json")
submitContext.Set(pluginruntime.ContextKeyPinnedRoute, pluginruntime.PinnedRoute{
Generation: generation,
Plugin: submitBinding.Plugin,
Route: submitBinding.Route,
})
common.SetContextKey(submitContext, constant.ContextKeyUserId, 7)
common.SetContextKey(submitContext, constant.ContextKeyUserGroup, "default")
common.SetContextKey(submitContext, constant.ContextKeyUsingGroup, "default")
common.SetContextKey(submitContext, constant.ContextKeyTokenGroup, "default")
common.SetContextKey(submitContext, constant.ContextKeyUserQuota, 1_000_000)
middleware.PrepareTaskPluginRoute()(submitContext)
require.False(t, submitContext.IsAborted(), submitRecorder.Body.String())
require.Equal(t, "kling-v1", submitContext.GetString("resolved_task_model"))
require.Equal(t, "text_to_video", submitContext.GetString("task_action"))
require.Nil(t, middleware.SetupContextForSelectedChannel(submitContext, &channel, "kling-v1"))
billing := &nativeRouteBilling{userID: 7}
relayInfo := &relaycommon.RelayInfo{
UserId: 7,
UserGroup: "default",
UsingGroup: "default",
UserQuota: 1_000_000,
TokenGroup: "default",
OriginModelName: "kling-v1",
Billing: billing,
TaskRelayInfo: &relaycommon.TaskRelayInfo{
Action: submitContext.GetString("task_action"),
PublicTaskID: "task_kling_public",
LockedChannel: &channel,
},
}
outcome, taskErr := executeTaskSubmissionWith(submitContext, relayInfo, relay.RelayTaskSubmit)
require.Nil(t, taskErr)
require.NotNil(t, outcome)
require.Equal(t, []string{"reserve", "settle"}, billing.events)
require.False(t, submitContext.Writer.Written())
presentTaskSubmission(submitContext, outcome)
require.Equal(t, http.StatusOK, submitRecorder.Code)
assert.Contains(t, submitRecorder.Body.String(), `"task_id":"task_kling_public"`)
assert.NotContains(t, submitRecorder.Body.String(), "kling-private-1")
assert.Equal(t, int32(1), submitCalls.Load())
var persisted model.Task
require.NoError(t, database.Where("task_id = ?", "task_kling_public").First(&persisted).Error)
assert.Equal(t, constant.TaskPlatform("kling"), persisted.Platform)
assert.Equal(t, "kling-private-1", persisted.PrivateData.UpstreamTaskID)
assert.Equal(t, model.TaskStatus(model.TaskStatusNotStart), persisted.Status)
previousAdaptorFactory := service.GetTaskAdaptorFunc
service.GetTaskAdaptorFunc = func(platform constant.TaskPlatform) service.TaskPollingAdaptor {
return relay.GetTaskAdaptor(platform)
}
t.Cleanup(func() { service.GetTaskAdaptorFunc = previousAdaptorFactory })
service.DispatchPlatformUpdate(
context.Background(),
persisted.Platform,
map[int][]string{channel.Id: {"kling-private-1"}},
map[string]*model.Task{"kling-private-1": &persisted},
)
require.NoError(t, database.Where("task_id = ?", "task_kling_public").First(&persisted).Error)
assert.Equal(t, model.TaskStatus(model.TaskStatusSuccess), persisted.Status)
assert.Equal(t, "100%", persisted.Progress)
assert.Equal(t, 1, persisted.Quota)
assert.Equal(t, int32(1), queryCalls.Load())
var settledUser model.User
require.NoError(t, database.First(&settledUser, 7).Error)
assert.Equal(t, 999_999, settledUser.Quota)
queryBinding, found := generation.LookupDeclaredRoute(http.MethodGet, "/kling/v1/videos/text2video/:task_id")
require.True(t, found)
queryRecorder := httptest.NewRecorder()
queryContext, _ := gin.CreateTestContext(queryRecorder)
queryContext.Request = httptest.NewRequest(http.MethodGet, "/kling/v1/videos/text2video/task_kling_public", nil)
queryContext.Params = gin.Params{{Key: "task_id", Value: "task_kling_public"}}
queryContext.Set(pluginruntime.ContextKeyPinnedRoute, pluginruntime.PinnedRoute{
Generation: generation,
Plugin: queryBinding.Plugin,
Route: queryBinding.Route,
})
common.SetContextKey(queryContext, constant.ContextKeyUserId, 7)
middleware.PrepareTaskPluginRoute()(queryContext)
require.True(t, queryContext.IsAborted())
require.Equal(t, http.StatusOK, queryRecorder.Code)
assert.Contains(t, queryRecorder.Body.String(), `"task_id":"task_kling_public"`)
assert.Contains(t, queryRecorder.Body.String(), `"task_status":"succeed"`)
assert.NotContains(t, queryRecorder.Body.String(), "kling-private-1")
assert.NotContains(t, queryRecorder.Body.String(), upstream.URL)
}
package controller
import (
"errors"
"fmt"
"strings"
"sync"
)
var (
errPluginProtocolObservationLimitExceeded = errors.New("plugin protocol observation limit exceeded")
errInvalidPluginProtocolObservationIdentity = errors.New("invalid plugin protocol observation identity")
)
type pluginProtocolObservationLimits struct {
global int
perPlugin int
perUser int
perToken int
}
var defaultPluginProtocolObservationLimits = pluginProtocolObservationLimits{
global: 128,
perPlugin: 32,
perUser: 4,
perToken: 2,
}
var pluginProtocolObservationAdmissions = newPluginProtocolObservationLimiter(
defaultPluginProtocolObservationLimits,
)
type pluginProtocolObservationLimitError struct {
scope string
limit int
}
func (e *pluginProtocolObservationLimitError) Error() string {
return fmt.Sprintf("%s: %s capacity is %d", errPluginProtocolObservationLimitExceeded, e.scope, e.limit)
}
func (e *pluginProtocolObservationLimitError) Unwrap() error {
return errPluginProtocolObservationLimitExceeded
}
type pluginProtocolObservationLimiter struct {
mu sync.Mutex
limits pluginProtocolObservationLimits
global int
plugin map[string]int
user map[int]int
token map[int]int
}
func newPluginProtocolObservationLimiter(limits pluginProtocolObservationLimits) *pluginProtocolObservationLimiter {
return &pluginProtocolObservationLimiter{
limits: limits,
plugin: make(map[string]int),
user: make(map[int]int),
token: make(map[int]int),
}
}
func (l *pluginProtocolObservationLimiter) acquire(
pluginKey string,
userID int,
tokenID int,
) (func(), error) {
pluginKey = strings.TrimSpace(pluginKey)
switch {
case pluginKey == "":
return nil, fmt.Errorf("%w: plugin key is required", errInvalidPluginProtocolObservationIdentity)
case userID <= 0:
return nil, fmt.Errorf("%w: user id must be positive", errInvalidPluginProtocolObservationIdentity)
case tokenID <= 0:
return nil, fmt.Errorf("%w: token id must be positive", errInvalidPluginProtocolObservationIdentity)
}
l.mu.Lock()
if l.global >= l.limits.global {
l.mu.Unlock()
return nil, &pluginProtocolObservationLimitError{
scope: "global",
limit: l.limits.global,
}
}
l.global++
if l.plugin[pluginKey] >= l.limits.perPlugin {
l.global--
l.mu.Unlock()
return nil, &pluginProtocolObservationLimitError{
scope: "plugin",
limit: l.limits.perPlugin,
}
}
l.plugin[pluginKey]++
if l.user[userID] >= l.limits.perUser {
l.global--
l.plugin[pluginKey]--
if l.plugin[pluginKey] == 0 {
delete(l.plugin, pluginKey)
}
l.mu.Unlock()
return nil, &pluginProtocolObservationLimitError{
scope: "user",
limit: l.limits.perUser,
}
}
l.user[userID]++
if l.token[tokenID] >= l.limits.perToken {
l.global--
l.plugin[pluginKey]--
if l.plugin[pluginKey] == 0 {
delete(l.plugin, pluginKey)
}
l.user[userID]--
if l.user[userID] == 0 {
delete(l.user, userID)
}
l.mu.Unlock()
return nil, &pluginProtocolObservationLimitError{
scope: "token",
limit: l.limits.perToken,
}
}
l.token[tokenID]++
l.mu.Unlock()
var once sync.Once
return func() {
once.Do(func() {
l.mu.Lock()
defer l.mu.Unlock()
l.global--
l.plugin[pluginKey]--
if l.plugin[pluginKey] == 0 {
delete(l.plugin, pluginKey)
}
l.user[userID]--
if l.user[userID] == 0 {
delete(l.user, userID)
}
l.token[tokenID]--
if l.token[tokenID] == 0 {
delete(l.token, tokenID)
}
})
}, nil
}
package controller
import (
"errors"
"sync"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestPluginProtocolObservationLimiterCaps(t *testing.T) {
t.Run("global", func(t *testing.T) {
limiter := newPluginProtocolObservationLimiter(pluginProtocolObservationLimits{
global: 2,
perPlugin: 2,
perUser: 2,
perToken: 2,
})
releaseFirst, err := limiter.acquire("first", 1, 1)
require.NoError(t, err)
defer releaseFirst()
releaseSecond, err := limiter.acquire("second", 2, 2)
require.NoError(t, err)
defer releaseSecond()
release, err := limiter.acquire("third", 3, 3)
assert.Nil(t, release)
assertLimitError(t, err, "global", 2)
})
t.Run("plugin", func(t *testing.T) {
limiter := newPluginProtocolObservationLimiter(pluginProtocolObservationLimits{
global: 3,
perPlugin: 1,
perUser: 3,
perToken: 3,
})
releaseFirst, err := limiter.acquire("shared", 1, 1)
require.NoError(t, err)
defer releaseFirst()
release, err := limiter.acquire("shared", 2, 2)
assert.Nil(t, release)
assertLimitError(t, err, "plugin", 1)
releaseOther, err := limiter.acquire("other", 2, 2)
require.NoError(t, err)
defer releaseOther()
})
t.Run("user", func(t *testing.T) {
limiter := newPluginProtocolObservationLimiter(pluginProtocolObservationLimits{
global: 3,
perPlugin: 3,
perUser: 1,
perToken: 3,
})
releaseFirst, err := limiter.acquire("first", 1, 1)
require.NoError(t, err)
defer releaseFirst()
release, err := limiter.acquire("second", 1, 2)
assert.Nil(t, release)
assertLimitError(t, err, "user", 1)
releaseOther, err := limiter.acquire("second", 2, 2)
require.NoError(t, err)
defer releaseOther()
})
t.Run("token", func(t *testing.T) {
limiter := newPluginProtocolObservationLimiter(pluginProtocolObservationLimits{
global: 3,
perPlugin: 3,
perUser: 3,
perToken: 1,
})
releaseFirst, err := limiter.acquire("first", 1, 1)
require.NoError(t, err)
defer releaseFirst()
release, err := limiter.acquire("second", 2, 1)
assert.Nil(t, release)
assertLimitError(t, err, "token", 1)
releaseOther, err := limiter.acquire("second", 2, 2)
require.NoError(t, err)
defer releaseOther()
})
}
func TestPluginProtocolObservationLimiterRollsBackFailedAdmission(t *testing.T) {
t.Run("user failure", func(t *testing.T) {
limiter := newPluginProtocolObservationLimiter(pluginProtocolObservationLimits{
global: 2,
perPlugin: 2,
perUser: 1,
perToken: 2,
})
releaseHeld, err := limiter.acquire("first", 1, 1)
require.NoError(t, err)
defer releaseHeld()
release, err := limiter.acquire("second", 1, 2)
assert.Nil(t, release)
assertLimitError(t, err, "user", 1)
releaseReplacement, err := limiter.acquire("second", 2, 2)
require.NoError(t, err)
defer releaseReplacement()
})
t.Run("token failure", func(t *testing.T) {
limiter := newPluginProtocolObservationLimiter(pluginProtocolObservationLimits{
global: 2,
perPlugin: 2,
perUser: 2,
perToken: 1,
})
releaseHeld, err := limiter.acquire("first", 1, 1)
require.NoError(t, err)
defer releaseHeld()
release, err := limiter.acquire("second", 2, 1)
assert.Nil(t, release)
assertLimitError(t, err, "token", 1)
releaseReplacement, err := limiter.acquire("second", 2, 2)
require.NoError(t, err)
defer releaseReplacement()
})
}
func TestPluginProtocolObservationLimiterReleaseIsIdempotent(t *testing.T) {
limiter := newPluginProtocolObservationLimiter(pluginProtocolObservationLimits{
global: 1,
perPlugin: 1,
perUser: 1,
perToken: 1,
})
release, err := limiter.acquire("plugin", 1, 1)
require.NoError(t, err)
release()
release()
releaseAgain, err := limiter.acquire("plugin", 1, 1)
require.NoError(t, err)
releaseAgain()
}
func TestPluginProtocolObservationLimiterRejectsMissingIdentityWithoutConsumingCapacity(t *testing.T) {
limiter := newPluginProtocolObservationLimiter(pluginProtocolObservationLimits{
global: 1,
perPlugin: 1,
perUser: 1,
perToken: 1,
})
for _, testCase := range []struct {
name string
pluginKey string
userID int
tokenID int
}{
{name: "empty plugin", userID: 1, tokenID: 1},
{name: "blank plugin", pluginKey: " \t", userID: 1, tokenID: 1},
{name: "zero user", pluginKey: "plugin", tokenID: 1},
{name: "negative user", pluginKey: "plugin", userID: -1, tokenID: 1},
{name: "zero token", pluginKey: "plugin", userID: 1},
{name: "negative token", pluginKey: "plugin", userID: 1, tokenID: -1},
} {
t.Run(testCase.name, func(t *testing.T) {
release, err := limiter.acquire(testCase.pluginKey, testCase.userID, testCase.tokenID)
assert.Nil(t, release)
assert.ErrorIs(t, err, errInvalidPluginProtocolObservationIdentity)
})
}
release, err := limiter.acquire("plugin", 1, 1)
require.NoError(t, err)
release()
}
func TestPluginProtocolObservationLimiterConcurrentAdmissionsRespectCap(t *testing.T) {
const (
workerCount = 8
globalLimit = 3
)
limiter := newPluginProtocolObservationLimiter(pluginProtocolObservationLimits{
global: globalLimit,
perPlugin: workerCount,
perUser: workerCount,
perToken: workerCount,
})
start := make(chan struct{})
releases := make(chan func(), workerCount)
errorsFound := make(chan error, workerCount)
var workers sync.WaitGroup
workers.Add(workerCount)
for worker := 1; worker <= workerCount; worker++ {
go func(id int) {
defer workers.Done()
<-start
release, err := limiter.acquire("plugin", id, id)
if err != nil {
errorsFound <- err
return
}
releases <- release
}(worker)
}
close(start)
workers.Wait()
close(releases)
close(errorsFound)
assert.Len(t, releases, globalLimit)
assert.Len(t, errorsFound, workerCount-globalLimit)
for err := range errorsFound {
assert.ErrorIs(t, err, errPluginProtocolObservationLimitExceeded)
}
for release := range releases {
release()
}
release, err := limiter.acquire("plugin", 1, 1)
require.NoError(t, err)
release()
}
func assertLimitError(
t *testing.T,
err error,
expectedScope string,
expectedLimit int,
) {
t.Helper()
require.Error(t, err)
assert.ErrorIs(t, err, errPluginProtocolObservationLimitExceeded)
var limitError *pluginProtocolObservationLimitError
require.True(t, errors.As(err, &limitError))
assert.Equal(t, expectedScope, limitError.scope)
assert.Equal(t, expectedLimit, limitError.limit)
}
package controller
import (
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestTaskLogDTOSeparatesUserAdminAndRootDetails(t *testing.T) {
task := &model.Task{
TaskID: "task_public",
Platform: "document-parser",
PrivateData: model.TaskPrivateData{
Key: "channel-secret-canary",
UpstreamTaskID: "upstream-private",
NodeName: "node-a",
Execution: &model.TaskExecutionSnapshot{
RequestID: "request-public",
RequestPath: "/v1/documents",
TaskPlugin: &model.TaskPluginSnapshot{
Key: "document-parser",
Name: "Document Parser",
Version: "1.2.3",
Author: &model.TaskPluginAuthorSnapshot{
Name: "Community Author",
URL: "https://plugins.example/author",
},
APIVersion: 1,
Generation: 42,
},
},
},
}
userView := tasksToDto([]*model.Task{task}, false, common.RoleCommonUser)[0]
assert.Nil(t, userView.AdminInfo)
assert.Nil(t, userView.RootInfo)
adminView := tasksToDto([]*model.Task{task}, false, common.RoleAdminUser)[0]
require.NotNil(t, adminView.AdminInfo)
require.NotNil(t, adminView.AdminInfo.TaskPlugin)
assert.Equal(t, "document-parser", adminView.AdminInfo.TaskPlugin.Key)
assert.Equal(t, "Document Parser", adminView.AdminInfo.TaskPlugin.Name)
assert.Equal(t, "1.2.3", adminView.AdminInfo.TaskPlugin.Version)
require.NotNil(t, adminView.AdminInfo.TaskPlugin.Author)
assert.Equal(t, "Community Author", adminView.AdminInfo.TaskPlugin.Author.Name)
assert.Equal(t, "https://plugins.example/author", adminView.AdminInfo.TaskPlugin.Author.URL)
assert.Equal(t, "request-public", adminView.AdminInfo.RequestID)
assert.Equal(t, "/v1/documents", adminView.AdminInfo.RequestPath)
assert.Nil(t, adminView.RootInfo)
rootView := tasksToDto([]*model.Task{task}, false, common.RoleRootUser)[0]
require.NotNil(t, rootView.AdminInfo)
require.NotNil(t, rootView.RootInfo)
require.NotNil(t, rootView.RootInfo.TaskPlugin)
assert.Equal(t, 1, rootView.RootInfo.TaskPlugin.APIVersion)
assert.Equal(t, uint64(42), rootView.RootInfo.TaskPlugin.Generation)
assert.Equal(t, "upstream-private", rootView.RootInfo.UpstreamTaskID)
assert.Equal(t, "node-a", rootView.RootInfo.NodeName)
adminJSON, err := common.Marshal(adminView)
require.NoError(t, err)
assert.NotContains(t, string(adminJSON), "channel-secret-canary")
assert.NotContains(t, string(adminJSON), "upstream-private")
rootJSON, err := common.Marshal(rootView)
require.NoError(t, err)
assert.NotContains(t, string(rootJSON), "channel-secret-canary")
assert.Contains(t, string(rootJSON), "upstream-private")
}
func TestTaskLogDTODoesNotInventHistoricalPluginProvenance(t *testing.T) {
task := &model.Task{
TaskID: "task_without_snapshot",
Platform: "document-parser",
}
adminView := tasksToDto([]*model.Task{task}, false, common.RoleAdminUser)[0]
assert.Nil(t, adminView.AdminInfo)
assert.Nil(t, adminView.RootInfo)
}
func TestTaskLogDTOReplacesLegacyVideoURLWithAvailabilityFlag(t *testing.T) {
task := &model.Task{
TaskID: "task_legacy_video",
Platform: "jimeng",
Action: constant.TaskActionTextToVideo,
Status: model.TaskStatusSuccess,
FailReason: "https://private-upstream.invalid/video.mp4?signature=secret",
}
view := tasksToDto([]*model.Task{task}, false, common.RoleCommonUser)[0]
assert.True(t, view.LegacyVideoAvailable)
assert.Empty(t, view.ResultURL)
assert.Empty(t, view.FailReason)
encoded, err := common.Marshal(view)
require.NoError(t, err)
assert.NotContains(t, string(encoded), "private-upstream.invalid")
assert.NotContains(t, string(encoded), "result_url")
assert.Contains(t, string(encoded), "legacy_video_available")
}
func TestTaskLogDTOKeepsFailureReasonAndDoesNotMarkPluginTaskLegacy(t *testing.T) {
failed := &model.Task{
TaskID: "task_failed",
Platform: "jimeng",
Action: constant.TaskActionTextToVideo,
Status: model.TaskStatusFailure,
FailReason: "provider rejected the request",
}
failedView := tasksToDto([]*model.Task{failed}, false, common.RoleCommonUser)[0]
assert.Equal(t, "provider rejected the request", failedView.FailReason)
assert.False(t, failedView.LegacyVideoAvailable)
pluginTask := &model.Task{
TaskID: "task_plugin_video",
Platform: "community-video",
Action: constant.TaskActionTextToVideo,
Status: model.TaskStatusSuccess,
FailReason: "https://stale-upstream.invalid/plugin-video.mp4",
PrivateData: model.TaskPrivateData{
ResultURL: "https://private-upstream.invalid/plugin-video.mp4",
Execution: &model.TaskExecutionSnapshot{
TaskPlugin: &model.TaskPluginSnapshot{Key: "community-video"},
},
},
}
pluginView := tasksToDto([]*model.Task{pluginTask}, false, common.RoleCommonUser)[0]
assert.False(t, pluginView.LegacyVideoAvailable)
assert.Empty(t, pluginView.ResultURL)
assert.Empty(t, pluginView.FailReason)
}
package controller
import (
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model"
pluginruntime "github.com/QuantumNous/new-api/pkg/jsplugin"
"github.com/QuantumNous/new-api/relay"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/gin-gonic/gin"
)
// taskPluginSubmitDiagnostics keeps plugin-only lifecycle logging out of the
// ordinary task path. An empty plugin key makes every method a no-op.
type taskPluginSubmitDiagnostics struct {
context *gin.Context
pluginKey string
generation uint64
}
func newTaskPluginSubmitDiagnostics(c *gin.Context) taskPluginSubmitDiagnostics {
diagnostics := taskPluginSubmitDiagnostics{
context: c,
pluginKey: c.GetString("expected_task_plugin_key"),
}
if diagnostics.pluginKey == "" {
return diagnostics
}
if pinnedValue, exists := c.Get(pluginruntime.ContextKeyPinnedPlugin); exists {
if pinned, ok := pinnedValue.(pluginruntime.PinnedPlugin); ok && pinned.Generation != nil {
diagnostics.generation = pinned.Generation.Number
}
}
return diagnostics
}
func (d taskPluginSubmitDiagnostics) start(info *relaycommon.RelayInfo) {
if d.pluginKey == "" {
return
}
logger.LogDebug(
d.context,
"task_plugin subsystem=submit event=start generation=%d plugin=%q model=%q action_present=%t",
d.generation,
d.pluginKey,
info.OriginModelName,
info.Action != "",
)
}
func (d taskPluginSubmitDiagnostics) refund(stage string) {
if d.pluginKey == "" {
return
}
logger.LogDebug(
d.context,
"task_plugin subsystem=submit event=refund_invoked generation=%d plugin=%q stage=%q durable=false",
d.generation,
d.pluginKey,
stage,
)
}
func (d taskPluginSubmitDiagnostics) cancelled(stage string, attempt int) {
if d.pluginKey == "" {
return
}
logger.LogDebug(
d.context,
"task_plugin subsystem=submit event=cancelled generation=%d plugin=%q stage=%q attempt=%d",
d.generation,
d.pluginKey,
stage,
attempt,
)
}
func (d taskPluginSubmitDiagnostics) attempt(attempt int, channel *model.Channel, locked bool) {
if d.pluginKey == "" || channel == nil {
return
}
logger.LogDebug(
d.context,
"task_plugin subsystem=submit event=attempt generation=%d plugin=%q attempt=%d channel_id=%d channel_type=%d locked=%t",
d.generation,
d.pluginKey,
attempt,
channel.Id,
channel.Type,
locked,
)
}
func (d taskPluginSubmitDiagnostics) attemptSucceeded(attempt int, result *relay.TaskSubmitResult) {
if d.pluginKey == "" || result == nil {
return
}
logger.LogDebug(
d.context,
"task_plugin subsystem=submit event=attempt_succeeded generation=%d plugin=%q attempt=%d platform=%q quota=%d task_data_bytes=%d client_response=%t immediate=%t",
d.generation,
d.pluginKey,
attempt,
result.Platform,
result.Quota,
len(result.TaskData),
result.ClientResponse != nil,
result.Immediate != nil,
)
}
func (d taskPluginSubmitDiagnostics) attemptFailed(attempt int, channel *model.Channel, taskErr *dto.TaskError, willRetry bool) {
if d.pluginKey == "" || channel == nil || taskErr == nil {
return
}
logger.LogDebug(
d.context,
"task_plugin subsystem=submit event=attempt_failed generation=%d plugin=%q attempt=%d channel_id=%d channel_type=%d code=%q status=%d local=%t will_retry=%t",
d.generation,
d.pluginKey,
attempt,
channel.Id,
channel.Type,
taskErr.Code,
taskErr.StatusCode,
taskErr.LocalError,
willRetry,
)
}
func (d taskPluginSubmitDiagnostics) failed(stage, reason string, taskErr *dto.TaskError, durable bool) {
if d.pluginKey == "" {
return
}
code := ""
status := 0
local := true
if taskErr != nil {
code = taskErr.Code
status = taskErr.StatusCode
local = taskErr.LocalError
}
logger.LogDebug(
d.context,
"task_plugin subsystem=submit event=failed generation=%d plugin=%q stage=%q reason=%q code=%q status=%d local=%t durable=%t",
d.generation,
d.pluginKey,
stage,
reason,
code,
status,
local,
durable,
)
}
func (d taskPluginSubmitDiagnostics) reserve(event string, quota int) {
if d.pluginKey == "" {
return
}
logger.LogDebug(
d.context,
"task_plugin subsystem=submit event=%s generation=%d plugin=%q quota=%d",
event,
d.generation,
d.pluginKey,
quota,
)
}
func (d taskPluginSubmitDiagnostics) insertStart(task *model.Task) {
if d.pluginKey == "" || task == nil {
return
}
logger.LogDebug(
d.context,
"task_plugin subsystem=submit event=insert_start generation=%d plugin=%q public_task_id=%q platform=%q channel_id=%d quota=%d",
d.generation,
d.pluginKey,
task.TaskID,
task.Platform,
task.ChannelId,
task.Quota,
)
}
func (d taskPluginSubmitDiagnostics) durable(task *model.Task) {
if d.pluginKey == "" || task == nil {
return
}
logger.LogDebug(
d.context,
"task_plugin subsystem=submit event=durable generation=%d plugin=%q public_task_id=%q status=%q durable=true",
d.generation,
d.pluginKey,
task.TaskID,
taskPluginDebugStatus(string(task.Status)),
)
}
func (d taskPluginSubmitDiagnostics) settleStart(task *model.Task, quota int) {
if d.pluginKey == "" || task == nil {
return
}
logger.LogDebug(
d.context,
"task_plugin subsystem=submit event=settle_start generation=%d plugin=%q public_task_id=%q quota=%d durable=true",
d.generation,
d.pluginKey,
task.TaskID,
quota,
)
}
func (d taskPluginSubmitDiagnostics) complete(task *model.Task, quota int) {
if d.pluginKey == "" || task == nil {
return
}
logger.LogDebug(
d.context,
"task_plugin subsystem=submit event=complete generation=%d plugin=%q public_task_id=%q quota=%d durable=true",
d.generation,
d.pluginKey,
task.TaskID,
quota,
)
}
func (d taskPluginSubmitDiagnostics) present(task *model.Task, presenter string) {
if d.pluginKey == "" || task == nil {
return
}
logger.LogDebug(
d.context,
"task_plugin subsystem=submit event=present generation=%d plugin=%q public_task_id=%q presenter=%q durable=true",
d.generation,
d.pluginKey,
task.TaskID,
presenter,
)
}
func (d taskPluginSubmitDiagnostics) presentError(taskErr *dto.TaskError) {
if d.pluginKey == "" || taskErr == nil {
return
}
logger.LogDebug(
d.context,
"task_plugin subsystem=submit event=present_error generation=%d plugin=%q code=%q status=%d local=%t",
d.generation,
d.pluginKey,
taskErr.Code,
taskErr.StatusCode,
taskErr.LocalError,
)
}
func taskPluginDebugStatus(status string) string {
switch model.TaskStatus(status) {
case model.TaskStatusSubmitted,
model.TaskStatusQueued,
model.TaskStatusInProgress,
model.TaskStatusSuccess,
model.TaskStatusFailure:
return status
default:
return "unknown"
}
}
package controller
import (
"bytes"
"net/http"
"net/http/httptest"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/relay"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestTaskPluginSubmitDiagnosticsArePluginOnlyAndDoNotLogPayloads(t *testing.T) {
previousDebug := common.DebugEnabled
common.DebugEnabled = true
t.Cleanup(func() { common.DebugEnabled = previousDebug })
var output bytes.Buffer
common.LogWriterMu.Lock()
previousWriter := gin.DefaultErrorWriter
gin.DefaultErrorWriter = &output
common.LogWriterMu.Unlock()
t.Cleanup(func() {
common.LogWriterMu.Lock()
gin.DefaultErrorWriter = previousWriter
common.LogWriterMu.Unlock()
})
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodPost, "/v1/tasks/debug-plugin", nil)
c.Set(common.RequestIdKey, "plugin-submit-request")
info := &relaycommon.RelayInfo{
OriginModelName: "safe-model",
TaskRelayInfo: &relaycommon.TaskRelayInfo{Action: "https://private-action.invalid/?key=hidden"},
}
newTaskPluginSubmitDiagnostics(c).start(info)
assert.Empty(t, output.String())
c.Set("expected_task_plugin_key", "debug-plugin")
diagnostics := newTaskPluginSubmitDiagnostics(c)
diagnostics.start(info)
diagnostics.attemptSucceeded(1, &relay.TaskSubmitResult{
UpstreamTaskID: "private-upstream-canary",
TaskData: []byte("private-task-data-canary"),
ClientResponse: map[string]any{"secret": "private-client-response-canary"},
Platform: constant.TaskPlatform("debug-plugin"),
Quota: 12,
})
task := &model.Task{
TaskID: "public-task-id",
Platform: constant.TaskPlatform("debug-plugin"),
Status: model.TaskStatus("https://private-status.invalid/?key=hidden"),
PrivateData: model.TaskPrivateData{
UpstreamTaskID: "private-task-record-canary",
ResultURL: "https://private-url.invalid/result",
},
}
diagnostics.insertStart(task)
diagnostics.durable(task)
diagnostics.complete(task, 12)
logOutput := output.String()
require.Contains(t, logOutput, "plugin-submit-request")
assert.Contains(t, logOutput, `plugin="debug-plugin"`)
assert.Contains(t, logOutput, `public_task_id="public-task-id"`)
assert.Contains(t, logOutput, "task_data_bytes=24")
assert.Contains(t, logOutput, "action_present=true")
assert.Contains(t, logOutput, `status="unknown"`)
for _, secret := range []string{
"private-upstream-canary",
"private-task-data-canary",
"private-client-response-canary",
"private-task-record-canary",
"private-url.invalid",
"private-action.invalid",
"private-status.invalid",
"key=hidden",
} {
assert.NotContains(t, logOutput, secret)
}
}
package controller
import (
"fmt"
"io"
"strconv"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/relay"
)
func getGeminiVideoURL(channel *model.Channel, task *model.Task, apiKey string) (string, error) {
if channel == nil || task == nil {
return "", fmt.Errorf("invalid channel or task")
}
if url := extractGeminiVideoURLFromTaskData(task); url != "" {
return ensureAPIKey(url, apiKey), nil
}
baseURL := constant.ChannelBaseURLs[channel.Type]
if channel.GetBaseURL() != "" {
baseURL = channel.GetBaseURL()
}
adaptor := relay.GetTaskAdaptor(constant.TaskPlatform(strconv.Itoa(channel.Type)))
if adaptor == nil {
return "", fmt.Errorf("gemini task adaptor not found")
}
if apiKey == "" {
return "", fmt.Errorf("api key not available for task")
}
proxy := channel.GetSetting().Proxy
resp, err := adaptor.FetchTask(baseURL, apiKey, map[string]any{
"task_id": task.GetUpstreamTaskID(),
"action": task.Action,
}, proxy)
if err != nil {
return "", fmt.Errorf("fetch task failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("read task response failed: %w", err)
}
taskInfo, parseErr := adaptor.ParseTaskResult(body)
if parseErr == nil && taskInfo != nil && taskInfo.RemoteUrl != "" {
return ensureAPIKey(taskInfo.RemoteUrl, apiKey), nil
}
if url := extractGeminiVideoURLFromPayload(body); url != "" {
return ensureAPIKey(url, apiKey), nil
}
if parseErr != nil {
return "", fmt.Errorf("parse task result failed: %w", parseErr)
}
return "", fmt.Errorf("gemini video url not found")
}
func extractGeminiVideoURLFromTaskData(task *model.Task) string {
if task == nil || len(task.Data) == 0 {
return ""
}
var payload map[string]any
if err := common.Unmarshal(task.Data, &payload); err != nil {
return ""
}
return extractGeminiVideoURLFromMap(payload)
}
func extractGeminiVideoURLFromPayload(body []byte) string {
var payload map[string]any
if err := common.Unmarshal(body, &payload); err != nil {
return ""
}
return extractGeminiVideoURLFromMap(payload)
}
func extractGeminiVideoURLFromMap(payload map[string]any) string {
if payload == nil {
return ""
}
if uri, ok := payload["uri"].(string); ok && uri != "" {
return uri
}
if resp, ok := payload["response"].(map[string]any); ok {
if uri := extractGeminiVideoURLFromResponse(resp); uri != "" {
return uri
}
}
return ""
}
func extractGeminiVideoURLFromResponse(resp map[string]any) string {
if resp == nil {
return ""
}
if gvr, ok := resp["generateVideoResponse"].(map[string]any); ok {
if uri := extractGeminiVideoURLFromGeneratedSamples(gvr); uri != "" {
return uri
}
}
if videos, ok := resp["videos"].([]any); ok {
for _, video := range videos {
if vm, ok := video.(map[string]any); ok {
if uri, ok := vm["uri"].(string); ok && uri != "" {
return uri
}
}
}
}
if uri, ok := resp["video"].(string); ok && uri != "" {
return uri
}
if uri, ok := resp["uri"].(string); ok && uri != "" {
return uri
}
return ""
}
func extractGeminiVideoURLFromGeneratedSamples(gvr map[string]any) string {
if gvr == nil {
return ""
}
if samples, ok := gvr["generatedSamples"].([]any); ok {
for _, sample := range samples {
if sm, ok := sample.(map[string]any); ok {
if video, ok := sm["video"].(map[string]any); ok {
if uri, ok := video["uri"].(string); ok && uri != "" {
return uri
}
}
}
}
}
return ""
}
func getVertexVideoURL(channel *model.Channel, task *model.Task) (string, error) {
if channel == nil || task == nil {
return "", fmt.Errorf("invalid channel or task")
}
if url := strings.TrimSpace(task.GetResultURL()); url != "" && !isTaskProxyContentURL(url, task.TaskID) {
return url, nil
}
if url := extractVertexVideoURLFromTaskData(task); url != "" {
return url, nil
}
baseURL := constant.ChannelBaseURLs[channel.Type]
if channel.GetBaseURL() != "" {
baseURL = channel.GetBaseURL()
}
adaptor := relay.GetTaskAdaptor(constant.TaskPlatform(strconv.Itoa(channel.Type)))
if adaptor == nil {
return "", fmt.Errorf("vertex task adaptor not found")
}
key := getVertexTaskKey(channel, task)
if key == "" {
return "", fmt.Errorf("vertex key not available for task")
}
resp, err := adaptor.FetchTask(baseURL, key, map[string]any{
"task_id": task.GetUpstreamTaskID(),
"action": task.Action,
}, channel.GetSetting().Proxy)
if err != nil {
return "", fmt.Errorf("fetch task failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("read task response failed: %w", err)
}
taskInfo, parseErr := adaptor.ParseTaskResult(body)
if parseErr == nil && taskInfo != nil && strings.TrimSpace(taskInfo.Url) != "" {
return taskInfo.Url, nil
}
if url := extractVertexVideoURLFromPayload(body); url != "" {
return url, nil
}
if parseErr != nil {
return "", fmt.Errorf("parse task result failed: %w", parseErr)
}
return "", fmt.Errorf("vertex video url not found")
}
func isTaskProxyContentURL(url string, taskID string) bool {
if strings.TrimSpace(url) == "" || strings.TrimSpace(taskID) == "" {
return false
}
return strings.Contains(url, "/v1/videos/"+taskID+"/content")
}
func getVertexTaskKey(channel *model.Channel, task *model.Task) string {
if task != nil {
if key := strings.TrimSpace(task.PrivateData.Key); key != "" {
return key
}
}
if channel == nil {
return ""
}
keys := channel.GetKeys()
for _, key := range keys {
key = strings.TrimSpace(key)
if key != "" {
return key
}
}
return strings.TrimSpace(channel.Key)
}
func extractVertexVideoURLFromTaskData(task *model.Task) string {
if task == nil || len(task.Data) == 0 {
return ""
}
return extractVertexVideoURLFromPayload(task.Data)
}
func extractVertexVideoURLFromPayload(body []byte) string {
var payload map[string]any
if err := common.Unmarshal(body, &payload); err != nil {
return ""
}
resp, ok := payload["response"].(map[string]any)
if !ok || resp == nil {
return ""
}
if videos, ok := resp["videos"].([]any); ok && len(videos) > 0 {
if video, ok := videos[0].(map[string]any); ok && video != nil {
if b64, _ := video["bytesBase64Encoded"].(string); strings.TrimSpace(b64) != "" {
mime, _ := video["mimeType"].(string)
enc, _ := video["encoding"].(string)
return buildVideoDataURL(mime, enc, b64)
}
}
}
if b64, _ := resp["bytesBase64Encoded"].(string); strings.TrimSpace(b64) != "" {
enc, _ := resp["encoding"].(string)
return buildVideoDataURL("", enc, b64)
}
if video, _ := resp["video"].(string); strings.TrimSpace(video) != "" {
if strings.HasPrefix(video, "data:") || strings.HasPrefix(video, "http://") || strings.HasPrefix(video, "https://") {
return video
}
enc, _ := resp["encoding"].(string)
return buildVideoDataURL("", enc, video)
}
return ""
}
func buildVideoDataURL(mimeType string, encoding string, base64Data string) string {
mime := strings.TrimSpace(mimeType)
if mime == "" {
enc := strings.TrimSpace(encoding)
if enc == "" {
enc = "mp4"
}
if strings.Contains(enc, "/") {
mime = enc
} else {
mime = "video/" + enc
}
}
return "data:" + mime + ";base64," + base64Data
}
func ensureAPIKey(uri, key string) string {
if key == "" || uri == "" {
return uri
}
if strings.Contains(uri, "key=") {
return uri
}
if strings.Contains(uri, "?") {
return fmt.Sprintf("%s&key=%s", uri, key)
}
return fmt.Sprintf("%s?key=%s", uri, key)
}
# Task plugin API v1
Task plugins are single-file synchronous ECMAScript modules. The plugin contract
is currently unreleased; [`v1.schema.json`](./v1.schema.json) and
[`v1.d.ts`](./v1.d.ts) are the authoritative v1 contract.
## Contract and lifecycle
Every plugin exports `meta`, `buildSubmitRequest`, `parseSubmitResponse`, and
`parseTaskResult`. A `per_task` plugin also exports `buildQueryRequest`; a
`batch` plugin exports `buildBatchQueryRequest` and `parseBatchResult`.
`meta.author.name` is required and `meta.author.url`, when present, must be an
absolute HTTP(S) URL. This is self-declared attribution; a future marketplace's
verified publisher identity is a separate host-owned record.
Plugins may declare authenticated vendor-native `meta.routes` and claim
host-owned names through `meta.protocols`. Submit and dynamic routes name a
`native` decoder and presenter; query routes name only a presenter. Protocol
bindings are registered once by the host registry, and protocol decoders receive
the host-parsed `body` union plus the pinned model. Shared protocol hooks are
synchronous transformations; Go owns connections and wire framing.
The host selects a channel, invokes the request-building hook, validates the
returned URL against the channel host, performs HTTP, and gives the decoded
response to the matching parse hook. It owns persistence, retries, polling,
billing, and settlement. Plugins only transform data and report usage facts.
See [v1.d.ts](./v1.d.ts) for signatures and
[v1.schema.json](./v1.schema.json) for machine-readable shapes.
Plugins that expose task outputs export `listArtifacts(task)` and
`buildContentRequest(ctx)` together. Artifacts are projected on explicit reads
from persisted `Task.Data`; they are never stored as a second source of truth.
The list contains only stable `key`, `type`, and optional `mimeType` fields.
The content hook receives the selected key, raw decoded task data, the explicit
private upstream task id, the producer plugin version, channel authentication,
and a safe client Range/conditional-header subset. Its URL and headers exist
only for that proxy request.
When a Responses observation reaches persisted `SUCCESS`, the host also runs
the pinned plugin's `listArtifacts` and injects a read-only
`ctx.artifacts[key] = {key, type, mimeType?, url}` map into `renderEvents` or
`renderFinal`. Each `url` is a long-lived host-signed capability URL, never the
provider URL from `Task.Data`. Nonterminal and failed tasks receive no artifact
map. Capability construction or rendering failure fails only that Responses
observation; it cannot change the task, billing settlement, or refunds.
The absolute URL uses `TaskPublicAddress`, falling back only to
`ServerAddress`; multi-node deployments must share the effective
`CRYPTO_SECRET`.
Dashboard artifact reads return each `content_url` (or the legacy
`legacy_content_url`) directly, without a temporary URL exchange. Capability
generation and verification are stateless and have no expiry; after
verification the host still loads the task, owner, and plugin needed to serve
the artifact. Rotating `CRYPTO_SECRET` invalidates issued URLs. The `access`
query is redacted before request logging.
Deployment boundaries and concurrency environment variables are documented in
[v1.md](./v1.md#generic-task-management-api).
The host treats `protocols.openai_video.render` as a standard DTO, not an arbitrary
JSON passthrough. Unknown top-level fields and legacy `task_id` are removed,
`id` is forced to the public task id, and case-insensitive `url` entries are
removed from metadata. Provider output URLs belong only behind artifact
capabilities.
Provider-authenticated content URLs must use the channel base host or a
plugin-declared `meta.allowedHosts` entry. A public dynamic CDN URL may instead
set `credentialless: true`; the host then permits only GET/HEAD with no
plugin-supplied headers or body and applies SSRF checks to the initial URL and
every redirect.
Registry publication is generation-atomic. A request pins one plugin generation
for its full lifetime, while background polling may use a later active plugin
version. New versions must continue parsing responses for in-flight tasks.
Root administrators can inspect the local node with
`GET /api/plugin/task/runtime/status`. The response includes the node-local generation,
a deterministic revision of the active database overrides, the latest rebuild
outcome, and plugin-level compile or routing errors. Generation numbers are
local to a node; compare database revisions when diagnosing rollout lag between
nodes. If the database snapshot is temporarily unavailable, the endpoint keeps
serving node-local state and the last known revision with `database_error` set.
For live diagnosis, start the process with `DEBUG=true` and filter logs on
`task_plugin`. Plugin registry, routing, endpoint ownership, channel selection,
submit durability, polling adapters, and protocol observation emit safe
key/value lifecycle events. Request-context events carry the request id;
scheduled, background, and context-less work is labeled `SYSTEM`. Plugin
`console.log` output is also forwarded in DEBUG mode. Hook-time output is
prefixed with plugin key/version; module-initialization output may have an empty
identity during initial upload validation. Do not print credentials, headers,
request bodies, upstream payloads, or private URLs from plugin code; free-form
console output cannot be redacted by the host.
## Fixtures and dry runs
A fixture case is `{name?, hook, member?, args, expected?, expectedError?}`.
Keep deterministic cases for every exported hook, its main error branch, batch
behavior, renderers, usage, and content requests. Run a fixture locally with:
```sh
new-api plugin lint plugin.js
new-api plugin test plugin.js --fixture golden.json
```
Root administrators can open the plugin detail Sandbox tab, choose a hook, and
submit an `args` JSON array. `POST /api/plugin/task/:key/dryrun` compiles the
active database source or factory source in a temporary registry and invokes
only that synchronous function. Dry runs never execute a request descriptor and
therefore never contact an upstream service.
## Upload and release
Upload from the root-only task plugin page or `POST /api/plugin/task` with
`{"source":"...","remark":"..."}`. The server compiles the module, validates
v1 metadata and required exports, and rejects invalid source before saving it.
Use semantic plugin versions. Reusing a key/version with different source is
rejected; activate or roll back a stored version through the management page.
For a third-party platform, create a channel of type `Task Plugin`, select the
plugin key, provide an explicit base URL, and configure models. Clients may use
the plugin's declared native routes. The generic management surface remains
`POST /v1/tasks/:pluginKey`, `GET /v1/tasks/:taskId`, and
`GET /v1/tasks/:taskId/artifacts` plus
`GET|HEAD /v1/tasks/:taskId/artifacts/:key/content`.
## Security boundary
Plugins have no `fetch`, filesystem, `require`, imports, async functions, or
environment access. The host limits execution time, concurrency, input size,
allowed request hosts, and resolves OAuth credentials outside JavaScript.
Multipart files enter JavaScript only as opaque references.
This is not a hard memory-isolation boundary. A plugin sees data needed for the
current request and can influence an authenticated upstream request. Uploading a
plugin is an administrator-level trust decision equivalent to configuring a
channel credential. Review source and version diffs before activation. Never run
untrusted plugins merely because they compile.
Usage hooks may return facts such as seconds, resolution, or upstream units, but
must never calculate prices or attempt quota settlement. The host owns all
pricing and clamps billing conversions.
export type JSONValue = null | boolean | number | string | readonly JSONValue[] | {readonly [key: string]: JSONValue};
export type FileReference = Readonly<{ref: string; field: string; filename: string; mimeType: string; size: number}>;
export type FilePlaceholder = Readonly<{__fileRef: string; encoding: "base64" | "dataUrl"; mimeType?: string; maxBytes?: number}>;
export type DecodedBody =
| Readonly<{kind: "json"; value: JSONValue}>
| Readonly<{kind: "form"; fields: Readonly<Record<string, readonly string[]>>}>
| Readonly<{kind: "multipart"; fields: Readonly<Record<string, readonly string[]>>; files: readonly FileReference[]}>
| Readonly<{kind: "none"}>;
export interface NativeDecodeContext {method: string; path: string; params: Readonly<Record<string, string>>; query: Readonly<Record<string, readonly string[]>>; body: DecodedBody}
export interface ProtocolDecodeContext extends NativeDecodeContext {protocol: "openai_responses" | "openai_video"; operation: string; model: string; stream: boolean}
export type SubmitIntent = {kind: "submit"; model: string; action?: string; requestBody?: unknown; originTaskIds?: readonly string[]};
export type QueryIntent = {kind: "query"; taskIds: readonly string[]};
export type TaskIntent = SubmitIntent | QueryIntent;
export interface NativeRoute {method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; path: string; type: "submit" | "query" | "dynamic"; action?: string; taskIdParam?: string; decode?: string; render: string; models?: readonly string[]}
export type ProtocolName = "openai_responses" | "openai_video";
export type ResponsesMode = "stream" | "sync" | "background";
export type ProtocolClaim =
| "openai_video"
| {name: "openai_responses"; supports: readonly ResponsesMode[]; models?: readonly string[]}
| {name: "openai_video"; models?: readonly string[]};
export type LocalizedText = string | ({ en: string } & Record<string, string>);
export type UsageFieldSchema = {type: "number"; unit: "second" | "count" | "token" | "credit"; description?: LocalizedText} | {type: "boolean"; description?: LocalizedText} | {enum: readonly string[]; description?: LocalizedText};
export type UsageExample = {label: string; facts: Readonly<Record<string, string | number>>};
export interface Meta {apiVersion: 1; key: string; name: string; icon?: string; description?: LocalizedText; version: string; author: {name: string; url?: string}; channelTypes?: readonly number[]; models: readonly string[]; fetchMode: "per_task" | "batch"; allowedHosts?: readonly string[]; routes?: readonly NativeRoute[]; protocols?: readonly ProtocolClaim[]; usageSchema?: Readonly<Record<string, UsageFieldSchema>>; usageExamples?: readonly UsageExample[]; auth?: "none" | "api_key" | "vertex_oauth" | {type: "none" | "api_key" | "oauth2_jwt"}}
export interface TaskView {task_id: string; status: string; progress?: string; fail_reason?: string; created_at?: number; updated_at?: number; data?: unknown; properties?: Record<string, unknown>}
export interface DriverContext {requestBody: unknown; requestHeaders: Readonly<Record<string, string>>; action: string; model: string; upstreamModel: string; baseUrl: string; apiKey?: string; authHeader: string; files: readonly FileReference[]; publicTaskId: string; originTasks?: readonly {taskId: string; upstreamTaskId: string; action: string; status: string; data: unknown}[]}
export interface RequestDescriptor {url: string; method?: string; headers?: Record<string, string>; /** JSON body may contain FilePlaceholder objects at any depth; the host replaces each with a Base64 or data-URL string. */ body?: unknown; credentialless?: boolean; action?: string; model?: string; rewriteModel?: string; bodyType?: "json" | "multipart"; parts?: readonly {name: string; value?: unknown; fileRef?: string; filename?: string}[]}
export interface UpstreamResponse {statusCode: number; headers: Readonly<Record<string, readonly string[]>>; body: unknown}
export interface NormalizedTaskResult {taskId?: string; status: "NOT_START" | "SUBMITTED" | "QUEUED" | "IN_PROGRESS" | "SUCCESS" | "FAILURE" | "UNKNOWN"; progress?: string; reason?: string; url?: string; remoteUrl?: string; completionTokens?: number; totalTokens?: number}
export interface TaskArtifact {key: string; type: "video" | "audio" | "image" | "file"; mimeType?: string}
export declare const meta: Meta;
export declare const native: Record<string, ((ctx: NativeDecodeContext) => TaskIntent) | ((ctx: NativeDecodeContext, task: TaskView | readonly TaskView[]) => unknown)> & {error?: (ctx: NativeDecodeContext, error: {code: string; message: string; httpStatus: number; retryable: boolean}) => unknown};
export declare const protocols: {
openai_responses?: {decodeRequest(ctx: ProtocolDecodeContext): SubmitIntent; renderEvents?(ctx: unknown, task: TaskView, previousState: unknown): unknown; renderFinal?(ctx: unknown, task: TaskView): unknown};
openai_video?: {decodeRequest(ctx: ProtocolDecodeContext): SubmitIntent; render(ctx: unknown, task: TaskView): unknown};
};
export declare function buildSubmitRequest(ctx: DriverContext): RequestDescriptor;
export declare function parseSubmitResponse(ctx: DriverContext, response: UpstreamResponse): {taskId: string; taskData?: unknown; immediate?: NormalizedTaskResult};
export declare function buildQueryRequest(ctx: DriverContext & {taskId: string}): RequestDescriptor;
export declare function buildBatchQueryRequest(ctx: DriverContext, taskIds: readonly string[]): RequestDescriptor;
export declare function parseTaskResult(ctx: DriverContext, body: unknown): NormalizedTaskResult;
export declare function parseBatchResult(ctx: DriverContext, body: unknown): readonly (NormalizedTaskResult & {taskId: string; data?: unknown})[];
export declare function extractUsage(ctx: DriverContext & {usagePurpose?: "facts" | "billing_ratios"}): Readonly<Record<string, string | number | boolean>> | null;
export declare function extractUsageOnSubmit(ctx: DriverContext, taskData: unknown): Readonly<Record<string, string | number | boolean>> | null;
export declare function extractUsageOnComplete(task: TaskView, result: NormalizedTaskResult, data: unknown): Readonly<Record<string, string | number | boolean>> | null;
export declare function listArtifacts(task: {taskId: string; status: string; action: string; data: unknown; producerVersion: string}): readonly TaskArtifact[];
export declare function buildContentRequest(ctx: DriverContext & {artifactKey: string; data: unknown; upstreamTaskId: string; clientRequest: {method: "GET" | "HEAD"; headers: Readonly<Record<string, string>>}}): RequestDescriptor;
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/QuantumNous/new-api/docs/plugin-api/v1.schema.json",
"title": "new-api Task Plugin v1 manifest",
"type": "object",
"additionalProperties": false,
"required": ["apiVersion", "key", "name", "version", "author", "models", "fetchMode"],
"properties": {
"apiVersion": {"const": 1}, "key": {"type": "string"}, "name": {"type": "string"}, "icon": {"type": "string", "maxLength": 128}, "description": {"$ref": "#/$defs/localizedText"}, "version": {"type": "string"},
"author": {"type": "object", "required": ["name"], "additionalProperties": false, "properties": {"name": {"type": "string"}, "url": {"type": "string", "format": "uri"}}},
"channelTypes": {"type": "array", "uniqueItems": true, "items": {"type": "integer", "minimum": 1}}, "models": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "minLength": 1}},
"fetchMode": {"enum": ["per_task", "batch"]}, "allowedHosts": {"type": "array", "uniqueItems": true, "items": {"type": "string"}},
"protocols": {"type": "array", "uniqueItems": true, "items": {"oneOf": [
{"enum": ["openai_video"]},
{"type": "object", "additionalProperties": false, "required": ["name", "supports"], "properties": {"name": {"const": "openai_responses"}, "supports": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"enum": ["stream", "sync", "background"]}}, "models": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "minLength": 1}}}},
{"type": "object", "additionalProperties": false, "required": ["name"], "properties": {"name": {"const": "openai_video"}, "models": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "minLength": 1}}}}
]}},
"routes": {"type": "array", "items": {"$ref": "#/$defs/route"}}, "usageSchema": {"type": "object", "additionalProperties": {"type": "object", "properties": {"description": {"$ref": "#/$defs/localizedText"}}}}, "usageExamples": {"type": "array", "items": {"type": "object", "additionalProperties": false, "required": ["label", "facts"], "properties": {"label": {"type": "string"}, "facts": {"type": "object"}}}}, "auth": {}
},
"$defs": {
"localizedText": {
"oneOf": [
{"type": "string", "minLength": 1},
{
"type": "object",
"required": ["en"],
"maxProperties": 16,
"propertyNames": {"pattern": "^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$"},
"additionalProperties": {"type": "string", "minLength": 1}
}
]
},
"filePlaceholder": {
"type": "object",
"additionalProperties": false,
"required": ["__fileRef", "encoding"],
"properties": {
"__fileRef": {"type": "string", "minLength": 1},
"encoding": {"enum": ["base64", "dataUrl"]},
"mimeType": {"type": "string", "minLength": 1},
"maxBytes": {"type": "integer", "exclusiveMinimum": 0}
}
},
"route": {"type": "object", "additionalProperties": false, "required": ["method", "path", "type", "render"], "properties": {"method": {"enum": ["GET", "POST", "PUT", "PATCH", "DELETE"]}, "path": {"type": "string", "pattern": "^/"}, "type": {"enum": ["submit", "query", "dynamic"]}, "action": {"type": "string"}, "taskIdParam": {"type": "string"}, "decode": {"type": "string"}, "render": {"type": "string"}, "models": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "minLength": 1}}}, "allOf": [{"if": {"properties": {"type": {"const": "query"}}}, "then": {"allOf": [{"not": {"required": ["decode"]}}, {"not": {"required": ["models"]}}]}}, {"if": {"properties": {"type": {"enum": ["submit", "dynamic"]}}}, "then": {"required": ["decode"]}}]}
}
}
package dto
type ChannelPinSource string
const (
PinSourceToken ChannelPinSource = "token" // Rank 0, highest
PinSourceOriginTask ChannelPinSource = "origin_task" // Rank 10
)
const (
PinRankToken = 0
PinRankOriginTask = 10
)
type PinRetryMode int
const (
PinRetrySameChannel PinRetryMode = iota
PinRetrySingleAttempt
)
func (m PinRetryMode) Stricter(other PinRetryMode) PinRetryMode {
if m == PinRetrySingleAttempt || other == PinRetrySingleAttempt {
return PinRetrySingleAttempt
}
return PinRetrySameChannel
}
type ChannelPin struct {
ChannelId int
Source ChannelPinSource
Rank int
RetryMode PinRetryMode
}
type ChannelFilterKind string
const (
FilterRequestPath ChannelFilterKind = "request_path"
FilterTaskPluginIdentity ChannelFilterKind = "task_plugin_identity"
)
type ChannelFilter struct {
Kind ChannelFilterKind
RequestPath string
TaskPluginKey string
TaskPluginChannelTypes []int
}
type ChannelConstraints struct {
Pins []ChannelPin
Filters []ChannelFilter
}
func (cc *ChannelConstraints) AddPin(p ChannelPin) {
if cc == nil {
return
}
cc.Pins = append(cc.Pins, p)
}
func (cc *ChannelConstraints) AddFilter(f ChannelFilter) {
if cc == nil {
return
}
cc.Filters = append(cc.Filters, f)
}
// ResolvedPin returns the winning pin after priority resolution.
// Lowest Rank wins. Pins that name the same channel are merged (stricter RetryMode).
// overridden lists pins that lost to a different channel (for warn logging).
func (cc *ChannelConstraints) ResolvedPin() (ChannelPin, bool, []ChannelPin) {
if cc == nil || len(cc.Pins) == 0 {
return ChannelPin{}, false, nil
}
merged := make(map[int]ChannelPin, len(cc.Pins))
order := make([]int, 0, len(cc.Pins))
for _, pin := range cc.Pins {
existing, seen := merged[pin.ChannelId]
if !seen {
merged[pin.ChannelId] = pin
order = append(order, pin.ChannelId)
continue
}
existing.RetryMode = existing.RetryMode.Stricter(pin.RetryMode)
if pin.Rank < existing.Rank {
existing.Rank = pin.Rank
existing.Source = pin.Source
}
merged[pin.ChannelId] = existing
}
winner := merged[order[0]]
for _, channelID := range order[1:] {
candidate := merged[channelID]
if candidate.Rank < winner.Rank {
winner = candidate
}
}
var overridden []ChannelPin
for _, channelID := range order {
candidate := merged[channelID]
if candidate.ChannelId != winner.ChannelId {
overridden = append(overridden, candidate)
}
}
return winner, true, overridden
}
func (cc *ChannelConstraints) SuppressesRetry() bool {
pin, found, _ := cc.ResolvedPin()
return found && pin.RetryMode == PinRetrySingleAttempt
}
package dto
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestResolvedPinPriorityAndMerge(t *testing.T) {
t.Run("token pin beats origin pin on a different channel", func(t *testing.T) {
constraints := &ChannelConstraints{}
constraints.AddPin(ChannelPin{ChannelId: 10, Source: PinSourceOriginTask, Rank: PinRankOriginTask, RetryMode: PinRetrySameChannel})
constraints.AddPin(ChannelPin{ChannelId: 1, Source: PinSourceToken, Rank: PinRankToken, RetryMode: PinRetrySingleAttempt})
pin, found, overridden := constraints.ResolvedPin()
require.True(t, found)
assert.Equal(t, 1, pin.ChannelId)
assert.Equal(t, PinSourceToken, pin.Source)
assert.Equal(t, PinRetrySingleAttempt, pin.RetryMode)
require.Len(t, overridden, 1)
assert.Equal(t, PinSourceOriginTask, overridden[0].Source)
assert.Equal(t, 10, overridden[0].ChannelId)
})
t.Run("same channel pins merge to the stricter retry mode", func(t *testing.T) {
constraints := &ChannelConstraints{}
constraints.AddPin(ChannelPin{ChannelId: 7, Source: PinSourceOriginTask, Rank: PinRankOriginTask, RetryMode: PinRetrySameChannel})
constraints.AddPin(ChannelPin{ChannelId: 7, Source: PinSourceToken, Rank: PinRankToken, RetryMode: PinRetrySingleAttempt})
pin, found, overridden := constraints.ResolvedPin()
require.True(t, found)
assert.Equal(t, 7, pin.ChannelId)
assert.Equal(t, PinSourceToken, pin.Source)
assert.Equal(t, PinRetrySingleAttempt, pin.RetryMode)
assert.Empty(t, overridden)
assert.True(t, constraints.SuppressesRetry())
})
t.Run("empty set has no pin", func(t *testing.T) {
pin, found, overridden := (*ChannelConstraints)(nil).ResolvedPin()
assert.False(t, found)
assert.Zero(t, pin.ChannelId)
assert.Nil(t, overridden)
})
}
package dto
// PluginResponsesResponse is the host-owned Responses facade used for stream
// snapshots and sanitized terminal failures. Non-stream success objects may
// retain additional validated plugin fields, but identifiers, lifecycle state,
// and retrieval metadata are always populated by the host.
type PluginResponsesResponse struct {
ID string `json:"id"`
Object string `json:"object"`
CreatedAt int64 `json:"created_at"`
Status string `json:"status"`
Error *PluginResponsesError `json:"error"`
IncompleteDetails *PluginResponsesIncompleteDetail `json:"incomplete_details"`
Instructions any `json:"instructions"`
Model string `json:"model"`
Output []PluginResponsesOutput `json:"output"`
ParallelToolCalls bool `json:"parallel_tool_calls"`
Temperature float64 `json:"temperature"`
ToolChoice any `json:"tool_choice"`
Tools []any `json:"tools"`
TopP float64 `json:"top_p"`
Metadata map[string]string `json:"metadata"`
Usage *PluginResponsesUsage `json:"usage"`
}
type PluginResponsesError struct {
Code string `json:"code"`
Message string `json:"message"`
}
type PluginResponsesIncompleteDetail struct {
Reason string `json:"reason"`
}
type PluginResponsesUsage struct {
InputTokens int `json:"input_tokens"`
InputTokensDetails PluginResponsesInputTokenDetails `json:"input_tokens_details"`
OutputTokens int `json:"output_tokens"`
OutputTokensDetails PluginResponsesOutputTokenDetails `json:"output_tokens_details"`
TotalTokens int `json:"total_tokens"`
}
type PluginResponsesInputTokenDetails struct {
CachedTokens int `json:"cached_tokens"`
}
type PluginResponsesOutputTokenDetails struct {
ReasoningTokens int `json:"reasoning_tokens"`
}
type PluginResponsesOutput struct {
ID string `json:"id"`
Type string `json:"type"`
Status string `json:"status"`
Role string `json:"role"`
Content []PluginResponsesContent `json:"content"`
}
type PluginResponsesContent struct {
ID string `json:"id"`
Type string `json:"type"`
Text string `json:"text"`
Annotations []any `json:"annotations"`
Logprobs []any `json:"logprobs"`
}
// PluginResponsesStreamEvent contains the exact event-specific fields used by
// the host Responses state machine. Pointer fields preserve required empty
// strings and arrays on delta/done events while omitting unrelated fields.
type PluginResponsesStreamEvent struct {
Type string `json:"type"`
SequenceNumber int `json:"sequence_number"`
Response *PluginResponsesResponse `json:"response,omitempty"`
OutputIndex *int `json:"output_index,omitempty"`
ContentIndex *int `json:"content_index,omitempty"`
ItemID string `json:"item_id,omitempty"`
Item *PluginResponsesOutput `json:"item,omitempty"`
Part *PluginResponsesContent `json:"part,omitempty"`
Delta *string `json:"delta,omitempty"`
Text *string `json:"text,omitempty"`
Logprobs *[]any `json:"logprobs,omitempty"`
}
......@@ -43,6 +43,7 @@ type TaskDto struct {
Status string `json:"status"`
FailReason string `json:"fail_reason"`
ResultURL string `json:"result_url,omitempty"` // 任务结果 URL(视频地址等)
LegacyVideoAvailable bool `json:"legacy_video_available,omitempty"`
SubmitTime int64 `json:"submit_time"`
StartTime int64 `json:"start_time"`
FinishTime int64 `json:"finish_time"`
......@@ -50,6 +51,39 @@ type TaskDto struct {
Properties any `json:"properties"`
Username string `json:"username,omitempty"`
Data json.RawMessage `json:"data"`
AdminInfo *TaskAdminInfo `json:"admin_info,omitempty"`
RootInfo *TaskRootInfo `json:"root_info,omitempty"`
}
type TaskPluginInfo struct {
Key string `json:"key"`
Name string `json:"name"`
Version string `json:"version,omitempty"`
Author *TaskPluginAuthorInfo `json:"author,omitempty"`
}
type TaskPluginAuthorInfo struct {
Name string `json:"name"`
URL string `json:"url,omitempty"`
}
type TaskPluginRuntimeInfo struct {
Key string `json:"key"`
Version string `json:"version"`
APIVersion int `json:"api_version"`
Generation uint64 `json:"generation"`
}
type TaskAdminInfo struct {
RequestID string `json:"request_id,omitempty"`
RequestPath string `json:"request_path,omitempty"`
TaskPlugin *TaskPluginInfo `json:"task_plugin,omitempty"`
}
type TaskRootInfo struct {
TaskPlugin *TaskPluginRuntimeInfo `json:"task_plugin,omitempty"`
UpstreamTaskID string `json:"upstream_task_id,omitempty"`
NodeName string `json:"node_name,omitempty"`
}
type FetchReq struct {
......
package dto
type TaskPluginError struct {
Code string `json:"code"`
Message string `json:"message"`
HTTPStatus int `json:"httpStatus"`
Retryable bool `json:"retryable"`
}
// TaskView is the only persisted-task shape exposed to JavaScript plugins.
// It deliberately excludes ownership, channel, quota, properties, and private
// upstream identifiers.
type TaskView struct {
TaskID string `json:"task_id"`
Platform string `json:"platform"`
Status string `json:"status"`
Progress string `json:"progress"`
FailReason string `json:"fail_reason"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at,omitempty"`
FinishedAt int64 `json:"finished_at,omitempty"`
Data any `json:"data,omitempty"`
}
package e2e
import (
"bytes"
"context"
"io"
"net/http"
"net/http/httptest"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/controller"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/middleware"
"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/QuantumNous/new-api/setting/system_setting"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
const docParsePluginSource = `export const meta = {apiVersion:1,key:"doc-parse",name:"Document Parser",version:"1.0.0",author:{name:"Test"},models:["doc-parse-v1"],fetchMode:"batch"};
export function buildSubmitRequest(ctx){return {url:ctx.baseUrl+"/submit",method:"POST",headers:{"Content-Type":"application/json"},body:ctx.requestBody,action:"parse_document"};}
export function parseSubmitResponse(ctx,resp){if(!resp.body.id)throw new Error("missing id");return {taskId:resp.body.id,taskData:resp.body};}
export function buildBatchQueryRequest(ctx,taskIds){return {url:ctx.baseUrl+"/batch",method:"POST",headers:{"Content-Type":"application/json"},body:{ids:taskIds}};}
export function parseBatchResult(ctx,body){return body.tasks.map((task)=>({taskId:task.id,status:task.status,progress:"100%",data:task}));}
export function parseTaskResult(ctx,body){return {taskId:body.id,status:body.status};}
export function listArtifacts(task){return task.status==="SUCCESS"?(task.data.artifacts||[]).map((item)=>({key:item.key,type:"file",mimeType:item.mimeType})):[];}
export function buildContentRequest(ctx){const item=(ctx.data.artifacts||[]).find((artifact)=>artifact.key===ctx.artifactKey);if(!item)throw new Error("artifact_not_found");return {url:item.url,method:ctx.clientRequest.method,credentialless:true};}
`
func TestDocumentPluginRunsGenericBatchArtifactChain(t *testing.T) {
service.InitHttpClient()
originalDB := model.DB
database, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, database.AutoMigrate(&model.TaskPlugin{}, &model.Channel{}, &model.Task{}))
model.DB = database
t.Cleanup(func() { model.DB = originalDB; jsplugin.DefaultRegistry.Unregister("doc-parse") })
source := docParsePluginSource
uploadBody, err := common.Marshal(map[string]any{"source": source, "remark": "phase 4 acceptance"})
require.NoError(t, err)
uploadRecorder := httptest.NewRecorder()
uploadContext, _ := gin.CreateTestContext(uploadRecorder)
uploadContext.Request = httptest.NewRequest(http.MethodPost, "/api/plugin/task", bytes.NewReader(uploadBody))
uploadContext.Request.Header.Set("Content-Type", "application/json")
controller.UploadTaskPlugin(uploadContext)
require.Equal(t, http.StatusOK, uploadRecorder.Code, uploadRecorder.Body.String())
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/submit":
_, _ = io.WriteString(w, `{"id":"doc-upstream-1"}`)
case "/batch":
_, _ = io.WriteString(w, `{"tasks":[{"id":"doc-upstream-1","status":"SUCCESS","artifacts":[{"key":"text","url":"`+"http://"+r.Host+`/artifact/text","mimeType":"text/plain"},{"key":"json","url":"`+"http://"+r.Host+`/artifact/json","mimeType":"application/json"}]}]}`)
case "/artifact/text":
w.Header().Set("Content-Type", "text/plain")
_, _ = io.WriteString(w, "parsed text")
case "/artifact/json":
_, _ = io.WriteString(w, `{"pages":2}`)
default:
http.NotFound(w, r)
}
}))
defer upstream.Close()
setting := dto.ChannelSettings{TaskPluginKey: "doc-parse"}
channel := model.Channel{Type: constant.ChannelTypeTaskPlugin, Name: "documents", Key: "unused", BaseURL: &upstream.URL, Status: common.ChannelStatusEnabled, Models: "doc-parse-v1", Group: "default"}
channel.SetSetting(setting)
require.NoError(t, database.Create(&channel).Error)
adaptor := relay.GetTaskAdaptor("doc-parse")
require.NotNil(t, adaptor)
info := &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{ChannelType: channel.Type, ChannelBaseUrl: upstream.URL, ApiKey: channel.Key, ChannelSetting: setting, UpstreamModelName: "doc-parse-v1"}, OriginModelName: "doc-parse-v1", TaskRelayInfo: &relaycommon.TaskRelayInfo{PublicTaskID: "task_doc_parse"}}
adaptor.Init(info)
submitRecorder := httptest.NewRecorder()
submitContext, _ := gin.CreateTestContext(submitRecorder)
submitContext.Request = httptest.NewRequest(http.MethodPost, "/v1/tasks/doc-parse", bytes.NewBufferString(`{"model":"doc-parse-v1","document":"opaque-ref"}`))
submitContext.Request.Header.Set("Content-Type", "application/json")
submitContext.Params = gin.Params{{Key: "key", Value: "doc-parse"}}
middleware.PrepareTaskPluginSubmit()(submitContext)
require.Empty(t, submitRecorder.Body.String())
require.Equal(t, "doc-parse-v1", submitContext.GetString("resolved_task_model"))
require.Nil(t, adaptor.ValidateRequestAndSetAction(submitContext, info))
require.Equal(t, "parse_document", info.Action)
requestBody, err := adaptor.BuildRequestBody(submitContext, info)
require.NoError(t, err)
response, err := adaptor.DoRequest(submitContext, info, requestBody)
require.NoError(t, err)
parsed, taskErr := adaptor.ParseResponse(submitContext, response, info)
require.Nil(t, taskErr)
require.NotNil(t, parsed)
require.Equal(t, "doc-upstream-1", parsed.UpstreamTaskID)
task := model.Task{
TaskID: info.PublicTaskID, Platform: "doc-parse", UserId: 7, ChannelId: channel.Id,
Status: model.TaskStatusInProgress, Data: parsed.TaskData,
PrivateData: model.TaskPrivateData{
UpstreamTaskID: parsed.UpstreamTaskID,
Execution: &model.TaskExecutionSnapshot{TaskPlugin: &model.TaskPluginSnapshot{
Key: "doc-parse", Name: "Document Parser", Version: "1.0.0",
Author: &model.TaskPluginAuthorSnapshot{Name: "Test"}, APIVersion: 1,
}},
},
}
require.NoError(t, database.Create(&task).Error)
originalFactory := service.GetTaskAdaptorFunc
service.GetTaskAdaptorFunc = func(platform constant.TaskPlatform) service.TaskPollingAdaptor { return relay.GetTaskAdaptor(platform) }
t.Cleanup(func() { service.GetTaskAdaptorFunc = originalFactory })
service.DispatchPlatformUpdate(context.Background(), "doc-parse", map[int][]string{channel.Id: {parsed.UpstreamTaskID}}, map[string]*model.Task{parsed.UpstreamTaskID: &task})
require.NoError(t, database.First(&task, task.ID).Error)
assert.Equal(t, model.TaskStatus(model.TaskStatusSuccess), task.Status)
queryRecorder := httptest.NewRecorder()
queryContext, _ := gin.CreateTestContext(queryRecorder)
queryContext.Set("id", 7)
queryContext.Params = gin.Params{{Key: "key", Value: task.TaskID}}
queryContext.Request = httptest.NewRequest(http.MethodGet, "/v1/tasks/"+task.TaskID+"/artifacts", nil)
controller.GetTaskArtifacts(queryContext)
require.Equal(t, http.StatusOK, queryRecorder.Code)
var query struct {
Artifacts []map[string]any `json:"artifacts"`
}
require.NoError(t, common.Unmarshal(queryRecorder.Body.Bytes(), &query))
require.Len(t, query.Artifacts, 2)
originalFetch := *system_setting.GetFetchSetting()
system_setting.GetFetchSetting().EnableSSRFProtection = true
system_setting.GetFetchSetting().AllowPrivateIp = true
system_setting.GetFetchSetting().AllowedPorts = []string{"1-65535"}
t.Cleanup(func() { *system_setting.GetFetchSetting() = originalFetch })
contentRecorder := httptest.NewRecorder()
contentContext, _ := gin.CreateTestContext(contentRecorder)
contentContext.Set("id", 7)
contentContext.Params = gin.Params{{Key: "key", Value: task.TaskID}, {Key: "artifact_key", Value: "text"}}
contentContext.Request = httptest.NewRequest(http.MethodGet, "/v1/tasks/"+task.TaskID+"/artifacts/text/content", nil)
controller.TaskArtifactContent(contentContext)
assert.Equal(t, http.StatusOK, contentRecorder.Code)
assert.Equal(t, "parsed text", contentRecorder.Body.String())
}
......@@ -70,8 +70,11 @@ require (
github.com/ClickHouse/ch-go v0.65.0 // indirect
github.com/bmatcuk/doublestar/v4 v4.6.1 // indirect
github.com/casbin/govaluate v1.10.0 // indirect
github.com/dlclark/regexp2/v2 v2.2.2 // indirect
github.com/go-faster/city v1.0.1 // indirect
github.com/go-faster/errors v0.7.1 // indirect
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e // indirect
github.com/hashicorp/go-version v1.7.0 // indirect
github.com/paulmach/orb v0.11.1 // indirect
github.com/pierrec/lz4/v4 v4.1.22 // indirect
......@@ -85,6 +88,8 @@ require (
require (
github.com/Azure/go-ntlmssp v0.1.1
github.com/alicebob/miniredis/v2 v2.38.0
github.com/grafana/sobek v0.0.0-20260708062710-267a0e055bb4
github.com/openai/openai-go v1.12.0
)
require (
......@@ -146,7 +151,7 @@ require (
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/samber/go-singleflightx v0.3.2 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tklauser/go-sysconf v0.3.12 // indirect
github.com/tklauser/numcpus v0.6.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
......
......@@ -643,6 +643,8 @@ github.com/ClickHouse/clickhouse-go/v2 v2.32.0/go.mod h1:rGFIgeNbJVggBp2C+0FXOdf
github.com/DmitriyVTitov/size v1.5.0 h1:/PzqxYrOyOUX1BXj6J9OuVRVGe+66VL4D9FlUaW515g=
github.com/DmitriyVTitov/size v1.5.0/go.mod h1:le6rNI4CoLQV1b9gzp1+3d7hMAD/uu2QcJ+aYbNgiU0=
github.com/JohnCGriffin/overflow v0.0.0-20211019200055-46fa312c352c/go.mod h1:X0CRv0ky0k6m906ixxpzmDRLvX58TFUKS2eePweuyxk=
github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE=
github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/Microsoft/go-winio v0.4.11/go.mod h1:VhR8bwka0BXejwEJY73c50VrPtXAaKcyvVC4A4RozmA=
github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA=
github.com/Microsoft/go-winio v0.4.15-0.20190919025122-fc70bd9a86b5/go.mod h1:tTuCMEN+UleMWgg9dVx4Hu52b1bJo+59jBh3ajtinzw=
......@@ -1010,6 +1012,8 @@ github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8
github.com/distribution/distribution/v3 v3.0.0-20220526142353-ffbd94cbe269/go.mod h1:28YO/VJk9/64+sTGNuYaBjWxrXTPrj0C0XmgTIOjxX4=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dlclark/regexp2/v2 v2.2.2 h1:MYWvNYw8okuqNhwTYO587EZMiDruVa2vhV6fsGpfya0=
github.com/dlclark/regexp2/v2 v2.2.2/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
github.com/dmarkham/enumer v1.5.8/go.mod h1:d10o8R3t/gROm2p3BXqTkMt2+HMuxEmWCXzorAruYak=
github.com/dnaeon/go-vcr v1.0.1/go.mod h1:aBB1+wY4s93YsC3HHjMBMrwTj2R9FHDzUr9KyGc8n1E=
github.com/docker/cli v0.0.0-20191017083524-a8ff7f821017/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
......@@ -1196,6 +1200,8 @@ github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBEx
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI=
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
......@@ -1211,6 +1217,8 @@ github.com/goccy/go-json v0.9.7/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGF
github.com/goccy/go-json v0.9.11/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/godbus/dbus v0.0.0-20151105175453-c7fdd8b5cd55/go.mod h1:/YcGZj5zSblfDWMMoOzV4fas9FZnQYTkDnsGvmh2Grw=
github.com/godbus/dbus v0.0.0-20180201030542-885f9cc04c9c/go.mod h1:/YcGZj5zSblfDWMMoOzV4fas9FZnQYTkDnsGvmh2Grw=
github.com/godbus/dbus v0.0.0-20190422162347-ade71ed3457e/go.mod h1:bBOAhwG1umN6/6ZUMtDFBMQR8jRg9O75tm9K00oMsK4=
......@@ -1375,6 +1383,8 @@ github.com/grafana/pyroscope-go v1.2.7 h1:VWBBlqxjyR0Cwk2W6UrE8CdcdD80GOFNutj0Kb
github.com/grafana/pyroscope-go v1.2.7/go.mod h1:o/bpSLiJYYP6HQtvcoVKiE9s5RiNgjYTj1DhiddP2Pc=
github.com/grafana/pyroscope-go/godeltaprof v0.1.9 h1:c1Us8i6eSmkW+Ez05d3co8kasnuOY813tbMN8i/a3Og=
github.com/grafana/pyroscope-go/godeltaprof v0.1.9/go.mod h1:2+l7K7twW49Ct4wFluZD3tZ6e0SjanjcUUBPVD/UuGU=
github.com/grafana/sobek v0.0.0-20260708062710-267a0e055bb4 h1:LJ2pEOxFbfUIhrkpROwZ6hLhuCn6e5GSX08v5GFwN/4=
github.com/grafana/sobek v0.0.0-20260708062710-267a0e055bb4/go.mod h1:BL/2XROA/Wtlb+zGEhkSdSZiMFIgw+D2ZdDfrbccyVE=
github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA=
github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=
github.com/grpc-ecosystem/go-grpc-middleware v1.0.1-0.20190118093823-f849b5445de4/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=
......@@ -1677,6 +1687,8 @@ github.com/onsi/gomega v1.24.1/go.mod h1:3AOiACssS3/MajrniINInwbfOOtfZvplPzuRSmv
github.com/onsi/gomega v1.24.2 h1:J/tulyYK6JwBldPViHJReihxxZ+22FHs0piGjQAvoUE=
github.com/onsi/gomega v1.24.2/go.mod h1:gs3J10IS7Z7r7eXRoNJIrNqU4ToQukCJhFtKrWgHWnk=
github.com/open-policy-agent/opa v0.42.2/go.mod h1:MrmoTi/BsKWT58kXlVayBb+rYVeaMwuBm3nYAN3923s=
github.com/openai/openai-go v1.12.0 h1:NBQCnXzqOTv5wsgNC36PrFEiskGfO5wccfCWDo9S1U0=
github.com/openai/openai-go v1.12.0/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y=
github.com/opencontainers/go-digest v0.0.0-20170106003457-a6d0ee40d420/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s=
github.com/opencontainers/go-digest v0.0.0-20180430190053-c9281466c8b2/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s=
github.com/opencontainers/go-digest v1.0.0-rc1/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s=
......@@ -1951,8 +1963,9 @@ github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4s
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/tiktoken-go/tokenizer v0.6.2 h1:t0GN2DvcUZSFWT/62YOgoqb10y7gSXBGs0A+4VCQK+g=
......
......@@ -77,7 +77,10 @@ func LogInfo(ctx context.Context, msg string) {
logHelper(ctx, loggerINFO, msg)
}
func LogWarn(ctx context.Context, msg string) {
func LogWarn(ctx context.Context, msg string, args ...any) {
if len(args) > 0 {
msg = fmt.Sprintf(msg, args...)
}
logHelper(ctx, loggerWarn, msg)
}
......
......@@ -23,6 +23,7 @@ import (
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/oauth"
"github.com/QuantumNous/new-api/pkg/jsplugin"
perfmetrics "github.com/QuantumNous/new-api/pkg/perf_metrics"
"github.com/QuantumNous/new-api/relay"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
......@@ -46,6 +47,9 @@ var buildFS embed.FS
var indexPage []byte
func main() {
if len(os.Args) > 1 && os.Args[1] == "plugin" {
os.Exit(jsplugin.RunCLI(os.Args[2:], os.Stdout, os.Stderr))
}
startTime := time.Now()
kitutil.SetLogging(common.SysLog, func(message string) {
logger.LogError(nil, message)
......@@ -107,6 +111,7 @@ func main() {
// 热更新配置
go model.SyncOptions(common.SyncFrequency)
go controller.SyncTaskPlugins()
// 周期性重载授权策略,保证多节点/多 master 部署下权限变更能传播到每个实例
go authz.StartPolicySync(common.SyncFrequency)
......
......@@ -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) {
// 处理请求
c.Next()
defer func() {
// 请求结束后清理存储
common.CleanupBodyStorage(c)
// 清理文件缓存(URL 下载的文件等)
service.CleanupFileSources(c)
}()
c.Next()
}
}
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)
}
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()
}
}
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 {
......
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",
},
})
}
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)
}
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 == "" {
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)
trustedProxies, usedDefaults, err := common.ResolveTrustedProxies(os.Getenv("TRUSTED_PROXIES"))
if err != nil {
return err
}
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)
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 nil
return common.ConfigureTrustedProxies(engine, trustedProxies)
}
......@@ -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,6 +17,12 @@ func abortWithOpenAiMessage(c *gin.Context, statusCode int, message string, code
codeStr = string(code[0])
}
userId := c.GetInt("id")
_, 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)),
......@@ -22,6 +30,7 @@ func abortWithOpenAiMessage(c *gin.Context, statusCode int, message string, code
"code": codeStr,
},
})
}
c.Abort()
logger.LogError(c.Request.Context(), fmt.Sprintf("user %d | %s", userId, message))
}
......
......@@ -3,12 +3,12 @@ package model
import (
"errors"
"fmt"
"sort"
"strings"
"sync"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/dto"
"github.com/samber/lo"
"gorm.io/gorm"
......@@ -105,23 +105,40 @@ func getChannelQuery(group string, model string, retry int) (*gorm.DB, error) {
return channelQuery, nil
}
func GetChannel(group string, model string, retry int, requestPath string) (*Channel, error) {
func GetChannel(
group string,
model string,
retry int,
filters []dto.ChannelFilter,
) (*Channel, error) {
var abilities []Ability
var err error = nil
channelQuery, err := getChannelQuery(group, model, retry)
err := DB.Where(commonGroupCol+" = ? and model = ? and enabled = ?", group, model, true).Order("priority DESC, weight DESC").Find(&abilities).Error
if err != nil {
return nil, err
}
if common.UsingMainDatabase(common.DatabaseTypeSQLite) || common.UsingMainDatabase(common.DatabaseTypePostgreSQL) {
err = channelQuery.Order("weight DESC").Find(&abilities).Error
} else {
err = channelQuery.Order("weight DESC").Find(&abilities).Error
abilities = filterAbilitiesByConstraints(abilities, model, filters)
if len(abilities) > 0 {
priorities := make([]int64, 0)
seen := make(map[int64]bool)
for _, ability := range abilities {
priority := int64(0)
if ability.Priority != nil {
priority = *ability.Priority
}
if err != nil {
return nil, err
if !seen[priority] {
seen[priority] = true
priorities = append(priorities, priority)
}
}
sort.Slice(priorities, func(i, j int) bool { return priorities[i] > priorities[j] })
if retry >= len(priorities) {
retry = len(priorities) - 1
}
targetPriority := priorities[retry]
abilities = lo.Filter(abilities, func(ability Ability, _ int) bool {
return ability.Priority == nil && targetPriority == 0 || ability.Priority != nil && *ability.Priority == targetPriority
})
}
abilities = filterAbilitiesByRequestPathAndModel(abilities, requestPath, model)
channel := Channel{}
if len(abilities) > 0 {
// Randomly choose one
......@@ -146,14 +163,12 @@ func GetChannel(group string, model string, retry int, requestPath string) (*Cha
return &channel, err
}
// filterAbilitiesByRequestPathAndModel restricts candidates by request path and
// model for the DB (non-memory-cache) selection path. Only Advanced Custom
// (type 58) channels are path-checked: kept only when one of their routes matches
// requestPath and model; all other channel types always pass. When requestPath is
// empty, filtering is skipped.
func filterAbilitiesByRequestPathAndModel(abilities []Ability, requestPath string, model string) []Ability {
if requestPath == "" || len(abilities) == 0 {
return abilities
// filterAbilitiesByConstraints applies the same ChannelSatisfiesFilters
// predicate used by the memory-cache path. A failed channel lookup fails
// closed when a task-plugin identity is required and fails open otherwise.
func filterAbilitiesByConstraints(abilities []Ability, modelName string, filters []dto.ChannelFilter) []Ability {
if len(abilities) == 0 {
return nil
}
channelIds := make([]int, 0, len(abilities))
......@@ -168,31 +183,36 @@ func filterAbilitiesByRequestPathAndModel(abilities []Ability, requestPath strin
var channels []*Channel
if err := DB.Where("id IN ?", channelIds).Find(&channels).Error; err != nil {
// On error, fall back to unfiltered candidates to avoid blocking selection
if identityFilterRequiresKey(filters) {
return nil
}
return abilities
}
advancedConfigs := make(map[int]*dto.AdvancedCustomConfig)
channelsByID := make(map[int]*Channel, len(channels))
for _, channel := range channels {
if channel.Type == constant.ChannelTypeAdvancedCustom {
advancedConfigs[channel.Id] = channel.GetOtherSettings().AdvancedCustom
}
channelsByID[channel.Id] = channel
}
filtered := make([]Ability, 0, len(abilities))
for _, ability := range abilities {
config, isAdvancedCustom := advancedConfigs[ability.ChannelId]
if !isAdvancedCustom {
filtered = append(filtered, ability)
continue
}
if config != nil && config.SupportsPathForModel(requestPath, model) {
channel := channelsByID[ability.ChannelId]
if ok, _ := ChannelSatisfiesFilters(channel, modelName, filters); ok {
filtered = append(filtered, ability)
}
}
return filtered
}
func identityFilterRequiresKey(filters []dto.ChannelFilter) bool {
for _, filter := range filters {
if filter.Kind == dto.FilterTaskPluginIdentity && filter.TaskPluginKey != "" {
return true
}
}
return false
}
func (channel *Channel) AddAbilities(tx *gorm.DB) error {
models_ := strings.Split(channel.Models, ",")
groups_ := strings.Split(channel.Group, ",")
......
......@@ -419,6 +419,15 @@ func SearchChannels(keyword string, group string, model string, idSort bool, sor
return channels, nil
}
// GetChannelById loads a channel directly from the database, bypassing the
// in-memory channel cache.
//
// WARNING: do NOT call this on request hot paths (middleware, distribution,
// relay submit/retry, polling). Every call is a synchronous DB query and will
// not see cache-only state. Use CacheGetChannel instead: it serves from the
// in-memory cache and falls back to this function automatically when
// MemoryCacheEnabled is false. Direct use is appropriate only where fresh DB
// state is required, e.g. admin CRUD, channel testing, or cache (re)building.
func GetChannelById(id int, selectAll bool) (*Channel, error) {
channel := &Channel{Id: id}
var err error = nil
......@@ -510,7 +519,7 @@ func (channel *Channel) GetBaseURL() string {
}
url := *channel.BaseURL
if url == "" {
url = constant.ChannelBaseURLs[channel.Type]
url = constant.GetChannelBaseURL(channel.Type)
}
return url
}
......
......@@ -12,7 +12,8 @@ import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/dto"
kitdto "github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/setting/ratio_setting"
)
......@@ -20,7 +21,7 @@ var group2model2channels map[string]map[string][]int // enabled channel
var channelsIDM map[int]*Channel // all channels include disabled
// channel2advancedCustomConfig caches parsed Advanced Custom (type 58) configs so
// path-aware selection avoids re-parsing JSON per request. Refreshed on full sync.
var channel2advancedCustomConfig map[int]*dto.AdvancedCustomConfig
var channel2advancedCustomConfig map[int]*kitdto.AdvancedCustomConfig
var channelSyncLock sync.RWMutex
func InitChannelCache() {
......@@ -29,7 +30,7 @@ func InitChannelCache() {
return
}
newChannelId2channel := make(map[int]*Channel)
newChannel2advancedCustomConfig := make(map[int]*dto.AdvancedCustomConfig)
newChannel2advancedCustomConfig := make(map[int]*kitdto.AdvancedCustomConfig)
var channels []*Channel
DB.Find(&channels)
for _, channel := range channels {
......@@ -111,22 +112,27 @@ func SyncChannelCache(frequency int) {
}
}
func GetRandomSatisfiedChannel(group string, model string, retry int, requestPath string) (*Channel, error) {
func GetRandomSatisfiedChannel(
group string,
model string,
retry int,
filters []dto.ChannelFilter,
) (*Channel, error) {
// if memory cache is disabled, get channel directly from database
if !common.MemoryCacheEnabled {
return GetChannel(group, model, retry, requestPath)
return GetChannel(group, model, retry, filters)
}
channelSyncLock.RLock()
defer channelSyncLock.RUnlock()
// First, try to find channels with the exact model name.
channels := filterChannelsByRequestPathAndModel(group2model2channels[group][model], requestPath, model)
channels, _ := filterCandidateIDs(group2model2channels[group][model], model, filters)
// If no channels found, try to find channels with the normalized model name.
if len(channels) == 0 {
normalizedModel := ratio_setting.FormatMatchingModelName(model)
channels = filterChannelsByRequestPathAndModel(group2model2channels[group][normalizedModel], requestPath, model)
channels, _ = filterCandidateIDs(group2model2channels[group][normalizedModel], model, filters)
}
if len(channels) == 0 {
......@@ -208,34 +214,6 @@ func GetRandomSatisfiedChannel(group string, model string, retry int, requestPat
return nil, errors.New("channel not found")
}
// filterChannelsByRequestPathAndModel restricts candidates by request path and
// model. Only Advanced Custom (type 58) channels are path-checked: they are kept
// only when one of their configured routes matches requestPath and model. All
// other channel types always pass. When requestPath is empty, filtering is skipped.
// Caller must hold channelSyncLock (read lock). The cached slice is never mutated.
func filterChannelsByRequestPathAndModel(channels []int, requestPath string, model string) []int {
if requestPath == "" || len(channels) == 0 {
return channels
}
filtered := make([]int, 0, len(channels))
for _, channelId := range channels {
channel, ok := channelsIDM[channelId]
if !ok {
// keep it so the downstream consistency error is raised as before
filtered = append(filtered, channelId)
continue
}
if channel.Type != constant.ChannelTypeAdvancedCustom {
filtered = append(filtered, channelId)
continue
}
if config := channel2advancedCustomConfig[channelId]; config != nil && config.SupportsPathForModel(requestPath, model) {
filtered = append(filtered, channelId)
}
}
return filtered
}
func CacheGetChannel(id int) (*Channel, error) {
if !common.MemoryCacheEnabled {
return GetChannelById(id, true)
......@@ -311,7 +289,7 @@ func CacheUpdateChannel(channel *Channel) {
}
channelsIDM[channel.Id] = channel
if channel2advancedCustomConfig == nil {
channel2advancedCustomConfig = make(map[int]*dto.AdvancedCustomConfig)
channel2advancedCustomConfig = make(map[int]*kitdto.AdvancedCustomConfig)
}
delete(channel2advancedCustomConfig, channel.Id)
if channel.Type == constant.ChannelTypeAdvancedCustom {
......
package model
import (
"slices"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto"
)
var filterEvalOrder = []dto.ChannelFilterKind{
dto.FilterRequestPath,
dto.FilterTaskPluginIdentity,
}
// ChannelSatisfiesFilters reports whether ch passes every filter.
// On false, it returns the kind of the first violated filter (request_path
// then task_plugin_identity) for error attribution.
func ChannelSatisfiesFilters(ch *Channel, modelName string, filters []dto.ChannelFilter) (bool, dto.ChannelFilterKind) {
if ch == nil {
return false, ""
}
for _, kind := range filterEvalOrder {
for _, filter := range filters {
if filter.Kind != kind {
continue
}
if !channelMatchesFilter(ch, modelName, filter) {
return false, kind
}
}
}
return true, ""
}
// filterCandidateIDs applies filters to a cached candidate id list.
// Caller must hold channelSyncLock (read lock). The input slice is never mutated.
// A missing id in channelsIDM is kept for request_path (downstream consistency
// error) and dropped for task_plugin_identity, matching the previous filters.
func filterCandidateIDs(ids []int, modelName string, filters []dto.ChannelFilter) (kept []int, emptiedBy dto.ChannelFilterKind) {
if len(ids) == 0 {
return ids, ""
}
kept = ids
for _, kind := range filterEvalOrder {
kindFilters := filtersByKind(filters, kind)
if len(kindFilters) == 0 {
continue
}
next := make([]int, 0, len(kept))
for _, id := range kept {
channel, exists := channelsIDM[id]
if candidatePassesKindFilters(channel, exists, modelName, kind, kindFilters) {
next = append(next, id)
}
}
if len(kept) > 0 && len(next) == 0 {
return next, kind
}
kept = next
}
return kept, ""
}
func filtersByKind(filters []dto.ChannelFilter, kind dto.ChannelFilterKind) []dto.ChannelFilter {
var matched []dto.ChannelFilter
for _, filter := range filters {
if filter.Kind == kind {
matched = append(matched, filter)
}
}
return matched
}
func candidatePassesKindFilters(ch *Channel, exists bool, modelName string, kind dto.ChannelFilterKind, filters []dto.ChannelFilter) bool {
if kind == dto.FilterRequestPath && !exists {
return true
}
if !exists || ch == nil {
return false
}
for _, filter := range filters {
if !channelMatchesFilter(ch, modelName, filter) {
return false
}
}
return true
}
func channelMatchesFilter(ch *Channel, modelName string, filter dto.ChannelFilter) bool {
switch filter.Kind {
case dto.FilterRequestPath:
if filter.RequestPath == "" {
return true
}
if ch.Type != constant.ChannelTypeAdvancedCustom {
return true
}
config := ch.GetOtherSettings().AdvancedCustom
return config != nil && config.SupportsPathForModel(filter.RequestPath, modelName)
case dto.FilterTaskPluginIdentity:
if ch.Type == constant.ChannelTypeTaskPlugin {
return filter.TaskPluginKey != "" && ch.GetSetting().TaskPluginKey == filter.TaskPluginKey
}
return filter.TaskPluginKey == "" || slices.Contains(filter.TaskPluginChannelTypes, ch.Type)
default:
return true
}
}
package model
import (
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto"
kitdto "github.com/QuantumNous/new-api/relaykit/dto"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestFilterCandidateIDs(t *testing.T) {
alphaSetting := `{"task_plugin_key":"alpha"}`
betaSetting := `{"task_plugin_key":"beta"}`
alpha := &Channel{Id: 900001, Type: constant.ChannelTypeTaskPlugin, Status: common.ChannelStatusEnabled, Setting: &alphaSetting}
beta := &Channel{Id: 900002, Type: constant.ChannelTypeTaskPlugin, Status: common.ChannelStatusEnabled, Setting: &betaSetting}
ordinary := &Channel{Id: 900003, Type: constant.ChannelTypeOpenAI, Status: common.ChannelStatusEnabled}
kling := &Channel{Id: 900004, Type: constant.ChannelTypeKling, Status: common.ChannelStatusEnabled}
jimeng := &Channel{Id: 900005, Type: constant.ChannelTypeJimeng, Status: common.ChannelStatusEnabled}
matchingCustom := &Channel{Id: 900010, Type: constant.ChannelTypeAdvancedCustom, Status: common.ChannelStatusEnabled}
matchingCustom.SetOtherSettings(kitdto.ChannelOtherSettings{
AdvancedCustom: &kitdto.AdvancedCustomConfig{
Routes: []kitdto.AdvancedCustomRoute{{
IncomingPath: "/v1/chat/completions",
Models: []string{"gpt-4"},
}},
},
})
otherCustom := &Channel{Id: 900011, Type: constant.ChannelTypeAdvancedCustom, Status: common.ChannelStatusEnabled}
otherCustom.SetOtherSettings(kitdto.ChannelOtherSettings{
AdvancedCustom: &kitdto.AdvancedCustomConfig{
Routes: []kitdto.AdvancedCustomRoute{{
IncomingPath: "/v1/responses",
Models: []string{"gpt-4"},
}},
},
})
pathFilter := dto.ChannelFilter{Kind: dto.FilterRequestPath, RequestPath: "/v1/chat/completions"}
emptyPathFilter := dto.ChannelFilter{Kind: dto.FilterRequestPath, RequestPath: ""}
tests := []struct {
name string
ids []int
modelName string
filters []dto.ChannelFilter
wantKept []int
wantEmpty dto.ChannelFilterKind
}{
{
name: "identity keeps matching type-59 key",
ids: []int{900001, 900002},
modelName: "shared",
filters: identityFilters("alpha", nil),
wantKept: []int{900001},
},
{
name: "identity empty key drops all type-59",
ids: []int{900001, 900002},
modelName: "shared",
filters: identityFilters("", nil),
wantKept: []int{},
wantEmpty: dto.FilterTaskPluginIdentity,
},
{
name: "identity empty key keeps ordinary channel",
ids: []int{900003},
modelName: "ordinary",
filters: identityFilters("", nil),
wantKept: []int{900003},
},
{
name: "identity keeps matching legacy type",
ids: []int{900004, 900005},
modelName: "legacy",
filters: identityFilters("legacy-alpha", []int{constant.ChannelTypeKling}),
wantKept: []int{900004},
},
{
name: "identity keeps all listed legacy types",
ids: []int{900004, 900005},
modelName: "legacy",
filters: identityFilters("legacy-alpha", []int{constant.ChannelTypeKling, constant.ChannelTypeJimeng}),
wantKept: []int{900004, 900005},
},
{
name: "identity keyed with no types drops legacy",
ids: []int{900004, 900005},
modelName: "legacy",
filters: identityFilters("legacy-alpha", nil),
wantKept: []int{},
wantEmpty: dto.FilterTaskPluginIdentity,
},
{
name: "identity drops missing cache entry",
ids: []int{900004, 999999},
modelName: "legacy",
filters: identityFilters("legacy-alpha", []int{constant.ChannelTypeKling}),
wantKept: []int{900004},
},
{
name: "empty request path is a passthrough including missing ids",
ids: []int{900003, 900010, 999999},
modelName: "gpt-4",
filters: []dto.ChannelFilter{emptyPathFilter},
wantKept: []int{900003, 900010, 999999},
},
{
name: "request path keeps missing cache entry for consistency",
ids: []int{900003, 999999},
modelName: "gpt-4",
filters: []dto.ChannelFilter{pathFilter},
wantKept: []int{900003, 999999},
},
{
name: "request path keeps matching type-58 and ordinary",
ids: []int{900003, 900010, 900011},
modelName: "gpt-4",
filters: []dto.ChannelFilter{pathFilter},
wantKept: []int{900003, 900010},
},
{
name: "request path empties when only unmatched type-58 remains",
ids: []int{900011},
modelName: "gpt-4",
filters: []dto.ChannelFilter{pathFilter},
wantKept: []int{},
wantEmpty: dto.FilterRequestPath,
},
{
name: "intersection attributes empty set to identity after path keeps candidates",
ids: []int{900001, 900010},
modelName: "gpt-4",
filters: []dto.ChannelFilter{pathFilter, identityFilters("missing", nil)[0]},
wantKept: []int{},
wantEmpty: dto.FilterTaskPluginIdentity,
},
{
name: "intersection attributes empty set to path when path runs first",
ids: []int{900011},
modelName: "gpt-4",
filters: []dto.ChannelFilter{identityFilters("", nil)[0], pathFilter},
wantKept: []int{},
wantEmpty: dto.FilterRequestPath,
},
}
channelSyncLock.Lock()
previous := channelsIDM
channelsIDM = map[int]*Channel{
900001: alpha,
900002: beta,
900003: ordinary,
900004: kling,
900005: jimeng,
900010: matchingCustom,
900011: otherCustom,
}
t.Cleanup(func() {
channelsIDM = previous
channelSyncLock.Unlock()
})
for _, testCase := range tests {
t.Run(testCase.name, func(t *testing.T) {
kept, emptiedBy := filterCandidateIDs(testCase.ids, testCase.modelName, testCase.filters)
if testCase.wantKept == nil {
assert.Nil(t, kept)
} else {
assert.Equal(t, testCase.wantKept, kept)
}
assert.Equal(t, testCase.wantEmpty, emptiedBy)
})
}
}
func TestChannelSatisfiesFilters(t *testing.T) {
alphaSetting := `{"task_plugin_key":"alpha"}`
alpha := &Channel{Id: 1, Type: constant.ChannelTypeTaskPlugin, Setting: &alphaSetting}
ordinary := &Channel{Id: 2, Type: constant.ChannelTypeOpenAI}
custom := &Channel{Id: 3, Type: constant.ChannelTypeAdvancedCustom}
custom.SetOtherSettings(kitdto.ChannelOtherSettings{
AdvancedCustom: &kitdto.AdvancedCustomConfig{
Routes: []kitdto.AdvancedCustomRoute{{
IncomingPath: "/v1/chat/completions",
Models: []string{"gpt-4"},
}},
},
})
ok, kind := ChannelSatisfiesFilters(nil, "gpt-4", nil)
assert.False(t, ok)
assert.Equal(t, dto.ChannelFilterKind(""), kind)
ok, kind = ChannelSatisfiesFilters(alpha, "shared", identityFilters("alpha", nil))
require.True(t, ok)
assert.Equal(t, dto.ChannelFilterKind(""), kind)
ok, kind = ChannelSatisfiesFilters(alpha, "shared", identityFilters("beta", nil))
assert.False(t, ok)
assert.Equal(t, dto.FilterTaskPluginIdentity, kind)
ok, kind = ChannelSatisfiesFilters(ordinary, "gpt-4", []dto.ChannelFilter{{
Kind: dto.FilterRequestPath,
RequestPath: "/v1/chat/completions",
}})
require.True(t, ok)
assert.Equal(t, dto.ChannelFilterKind(""), kind)
ok, kind = ChannelSatisfiesFilters(custom, "gpt-4", []dto.ChannelFilter{{
Kind: dto.FilterRequestPath,
RequestPath: "/v1/responses",
}})
assert.False(t, ok)
assert.Equal(t, dto.FilterRequestPath, kind)
}
......@@ -121,6 +121,8 @@ func formatUserLogs(logs []*Log, startIdx int) {
if otherMap != nil {
// Remove admin-only debug fields.
delete(otherMap, "admin_info")
// Remove diagnostics reserved for root.
delete(otherMap, "root_info")
// Remove operation-audit details (operator/route info), admin-only.
delete(otherMap, "audit_info")
// delete(otherMap, "reject_reason")
......@@ -131,6 +133,19 @@ func formatUserLogs(logs []*Log, startIdx int) {
assignDisplayLogIds(logs, startIdx)
}
// FormatAdminLogs removes root-only diagnostics while retaining operational
// admin_info. Root callers must not pass their results through this formatter.
func FormatAdminLogs(logs []*Log) {
for i := range logs {
otherMap, _ := common.StrToMap(logs[i].Other)
if otherMap == nil {
continue
}
delete(otherMap, "root_info")
logs[i].Other = common.MapToJsonStr(otherMap)
}
}
func GetLogByTokenId(tokenId int) (logs []*Log, err error) {
order := "id desc"
if common.UsingLogDatabase(common.DatabaseTypeClickHouse) {
......
......@@ -5,6 +5,7 @@ import (
"github.com/QuantumNous/new-api/common"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
......@@ -33,3 +34,50 @@ func TestFormatUserLogsStripsQuotaSaturation(t *testing.T) {
// Non-admin billing fields remain visible.
require.Contains(t, parsed, "model_price")
}
func TestTaskPluginLogVisibilityIsRoleSeparated(t *testing.T) {
other := common.MapToJsonStr(map[string]interface{}{
"model_price": 1.25,
"admin_info": map[string]interface{}{
"task_plugin": map[string]interface{}{
"key": "document-parser",
"name": "Document Parser",
"version": "1.2.3",
},
},
"root_info": map[string]interface{}{
"upstream_task_id": "upstream-private",
"task_plugin": map[string]interface{}{
"generation": 42,
},
},
})
t.Run("user", func(t *testing.T) {
logs := []*Log{{Other: other}}
formatUserLogs(logs, 0)
parsed, err := common.StrToMap(logs[0].Other)
require.NoError(t, err)
assert.NotContains(t, parsed, "admin_info")
assert.NotContains(t, parsed, "root_info")
assert.Equal(t, 1.25, parsed["model_price"])
})
t.Run("admin", func(t *testing.T) {
logs := []*Log{{Other: other}}
FormatAdminLogs(logs)
parsed, err := common.StrToMap(logs[0].Other)
require.NoError(t, err)
assert.Contains(t, parsed, "admin_info")
assert.NotContains(t, parsed, "root_info")
})
t.Run("root", func(t *testing.T) {
parsed, err := common.StrToMap(other)
require.NoError(t, err)
assert.Contains(t, parsed, "admin_info")
assert.Contains(t, parsed, "root_info")
})
}
......@@ -323,6 +323,7 @@ func migrateDB() error {
&TopUp{},
&QuotaData{},
&Task{},
&TaskPlugin{},
&Model{},
&Vendor{},
&PrefillGroup{},
......
......@@ -6,6 +6,8 @@ import (
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/pkg/jsplugin"
"github.com/QuantumNous/new-api/setting"
"github.com/QuantumNous/new-api/setting/config"
"github.com/QuantumNous/new-api/setting/operation_setting"
......@@ -52,6 +54,13 @@ func InitOptionMap() {
common.OptionMap["DisplayTokenStatEnabled"] = strconv.FormatBool(common.DisplayTokenStatEnabled)
common.OptionMap["DrawingEnabled"] = strconv.FormatBool(common.DrawingEnabled)
common.OptionMap["TaskEnabled"] = strconv.FormatBool(common.TaskEnabled)
common.OptionMap["TaskPluginEnabled"] = strconv.FormatBool(constant.TaskPluginEnabled)
jsplugin.DefaultRegistry.SetEnabled(constant.TaskPluginEnabled)
common.OptionMap["TaskPluginOverrideEnabled"] = strconv.FormatBool(constant.TaskPluginOverrideEnabled)
jsplugin.DefaultRegistry.SetOverrideEnabled(constant.TaskPluginOverrideEnabled)
common.OptionMap[setting.TaskPluginMarketplaceSourcesKey] = setting.TaskPluginMarketplaceSources2JsonString()
common.OptionMap[setting.TaskPluginDisabledFactoryKeysKey] = "[]"
jsplugin.DefaultRegistry.SetDisabledFactoryKeys(nil)
common.OptionMap["DataExportEnabled"] = strconv.FormatBool(common.DataExportEnabled)
common.OptionMap["ChannelDisableThreshold"] = strconv.FormatFloat(common.ChannelDisableThreshold, 'f', -1, 64)
common.OptionMap["EmailDomainRestrictionEnabled"] = strconv.FormatBool(common.EmailDomainRestrictionEnabled)
......@@ -73,6 +82,7 @@ func InitOptionMap() {
common.OptionMap["SystemName"] = common.SystemName
common.OptionMap["Logo"] = common.Logo
common.OptionMap["ServerAddress"] = ""
common.OptionMap["TaskPublicAddress"] = system_setting.TaskPublicAddress
common.OptionMap["WorkerUrl"] = system_setting.WorkerUrl
common.OptionMap["WorkerValidKey"] = system_setting.WorkerValidKey
common.OptionMap["WorkerAllowHttpImageRequestEnabled"] = strconv.FormatBool(system_setting.WorkerAllowHttpImageRequestEnabled)
......@@ -352,6 +362,12 @@ func updateOptionMap(key string, value string) (err error) {
common.DrawingEnabled = boolValue
case "TaskEnabled":
common.TaskEnabled = boolValue
case "TaskPluginEnabled":
constant.TaskPluginEnabled = boolValue
jsplugin.DefaultRegistry.SetEnabled(boolValue)
case "TaskPluginOverrideEnabled":
constant.TaskPluginOverrideEnabled = boolValue
jsplugin.DefaultRegistry.SetOverrideEnabled(boolValue)
case "DataExportEnabled":
common.DataExportEnabled = boolValue
case "DefaultCollapseSidebar":
......@@ -394,6 +410,9 @@ func updateOptionMap(key string, value string) (err error) {
ratio_setting.SetExposeRatioEnabled(boolValue)
}
}
if key == setting.TaskPluginDisabledFactoryKeysKey {
jsplugin.DefaultRegistry.SetDisabledFactoryKeys(setting.ParseTaskPluginDisabledFactoryKeys(value))
}
switch key {
case "EmailDomainWhitelist":
common.EmailDomainWhitelist = strings.Split(value, ",")
......@@ -410,6 +429,8 @@ func updateOptionMap(key string, value string) (err error) {
common.SMTPToken = value
case "ServerAddress":
system_setting.ServerAddress = value
case "TaskPublicAddress":
system_setting.TaskPublicAddress = value
case "WorkerUrl":
system_setting.WorkerUrl = value
case "WorkerValidKey":
......
package model
import (
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/pkg/jsplugin"
"github.com/QuantumNous/new-api/setting"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestTaskPluginEnabledOptionUpdatesRegistry(t *testing.T) {
originalEnabled := constant.TaskPluginEnabled
originalMap := common.OptionMap
common.OptionMap = map[string]string{}
const key = "option-master-off"
source := `
export const meta = {apiVersion: 1, key: "option-master-off", name: "Option Master", version: "1.0.0", author: {name: "Test"}, models: ["option-master-model"], fetchMode: "per_task"};
export function buildSubmitRequest() { return {}; }
export function parseSubmitResponse() { return {}; }
export function buildQueryRequest() { return {}; }
export function parseTaskResult() { return {}; }
`
_, err := jsplugin.DefaultRegistry.RegisterFactory(source, jsplugin.Options{})
require.NoError(t, err)
t.Cleanup(func() {
constant.TaskPluginEnabled = originalEnabled
jsplugin.DefaultRegistry.SetEnabled(originalEnabled)
common.OptionMap = originalMap
})
_, ok := jsplugin.DefaultRegistry.Get(key)
require.True(t, ok)
require.NoError(t, updateOptionMap("TaskPluginEnabled", "false"))
assert.False(t, constant.TaskPluginEnabled)
assert.Equal(t, "false", common.OptionMap["TaskPluginEnabled"])
_, ok = jsplugin.DefaultRegistry.Get(key)
assert.False(t, ok)
require.NoError(t, updateOptionMap("TaskPluginEnabled", "true"))
_, ok = jsplugin.DefaultRegistry.Get(key)
assert.True(t, ok)
}
func TestTaskPluginOverrideEnabledOptionUpdatesRuntimeSwitch(t *testing.T) {
originalEnabled := constant.TaskPluginOverrideEnabled
originalMap := common.OptionMap
common.OptionMap = map[string]string{}
t.Cleanup(func() {
constant.TaskPluginOverrideEnabled = originalEnabled
jsplugin.DefaultRegistry.SetOverrideEnabled(originalEnabled)
common.OptionMap = originalMap
})
require.NoError(t, updateOptionMap("TaskPluginOverrideEnabled", "false"))
assert.False(t, constant.TaskPluginOverrideEnabled)
assert.Equal(t, "false", common.OptionMap["TaskPluginOverrideEnabled"])
}
func TestTaskPluginDisabledFactoryKeysOptionUpdatesRegistry(t *testing.T) {
originalMap := common.OptionMap
common.OptionMap = map[string]string{}
const key = "option-factory-off"
source := `
export const meta = {apiVersion: 1, key: "option-factory-off", name: "Option Factory", version: "1.0.0", author: {name: "Test"}, models: ["option-factory-model"], fetchMode: "per_task"};
export function buildSubmitRequest() { return {}; }
export function parseSubmitResponse() { return {}; }
export function buildQueryRequest() { return {}; }
export function parseTaskResult() { return {}; }
`
_, err := jsplugin.DefaultRegistry.RegisterFactory(source, jsplugin.Options{})
require.NoError(t, err)
t.Cleanup(func() {
jsplugin.DefaultRegistry.SetDisabledFactoryKeys(nil)
common.OptionMap = originalMap
})
_, ok := jsplugin.DefaultRegistry.Get(key)
require.True(t, ok)
require.NoError(t, updateOptionMap(setting.TaskPluginDisabledFactoryKeysKey, `["option-factory-off"]`))
assert.Equal(t, `["option-factory-off"]`, common.OptionMap[setting.TaskPluginDisabledFactoryKeysKey])
_, ok = jsplugin.DefaultRegistry.Get(key)
assert.False(t, ok)
assert.Equal(t, []string{key}, jsplugin.DefaultRegistry.Snapshot().DisabledFactory)
}
This source diff could not be displayed because it is too large. You can view the blob instead.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment