Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
14 changes: 11 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
# 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`.
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.
3 changes: 3 additions & 0 deletions THIRD-PARTY-LICENSES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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 |
Expand Down
5 changes: 5 additions & 0 deletions common/api_type.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions common/api_type_task_plugin_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
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)
}
8 changes: 8 additions & 0 deletions common/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 != "" {
Expand Down
54 changes: 54 additions & 0 deletions common/trusted_proxies.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
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
}
10 changes: 10 additions & 0 deletions constant/channel.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

)
Expand Down Expand Up @@ -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{
Expand Down Expand Up @@ -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 {
Expand Down
12 changes: 12 additions & 0 deletions constant/channel_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package constant

import (
"testing"

"github.com/stretchr/testify/assert"
)

func TestGetChannelBaseURLIsBoundsSafe(t *testing.T) {
assert.Empty(t, GetChannelBaseURL(ChannelTypeTaskPlugin))
assert.Empty(t, GetChannelBaseURL(9999))
}
3 changes: 2 additions & 1 deletion constant/context_key.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 4 additions & 0 deletions constant/env.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
40 changes: 29 additions & 11 deletions constant/task.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
27 changes: 27 additions & 0 deletions constant/task_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
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))
})
}
}
100 changes: 100 additions & 0 deletions controller/billing_option_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
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")
}
Loading
Loading