feat: sync deployed v1.0.0-rc.38-custom.42

Sync production source including routing, billing, Astra detection and public uptime. Update custom build instructions and license packaging.
This commit is contained in:
cneicy committed 2026-09-26 04:49:11 +08:00
1 parent aa6c3f50f4
commit ceddfd3d6b
828 files changed
+122253 -13702

No files matched your search

+28
View File
@@ -0,0 +1,28 @@
package common
import (
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/relaykit/dto"
)
// GetAdvancedCustomPreset returns fresh defaults.
func GetAdvancedCustomPreset(channelType int) *dto.AdvancedCustomConfig {
if channelType != constant.ChannelTypeVLLM && channelType != constant.ChannelTypeSGLang {
return nil
}
config := &dto.AdvancedCustomConfig{}
for _, path := range []string{"/v1/chat/completions", "/v1/completions", "/v1/responses", "/v1/embeddings", "/v1/messages", dto.AdvancedCustomModelListPath} {
config.Routes = append(config.Routes, dto.AdvancedCustomRoute{
IncomingPath: path, UpstreamPath: path, Converter: "none",
Auth: &dto.AdvancedCustomRouteAuth{Type: dto.AdvancedCustomAuthTypeHeader, Name: "Authorization", Value: "Bearer {api_key}"},
})
}
if channelType == constant.ChannelTypeSGLang {
for _, path := range []string{"/v1/rerank", "/rerank"} {
config.Routes = append(config.Routes, dto.AdvancedCustomRoute{
IncomingPath: path, UpstreamPath: "/v1/rerank", Converter: dto.AdvancedCustomConverterSGLangRerank,
})
}
}
return config
}
+1 -1
View File
@@ -75,7 +75,7 @@ func ChannelType2APIType(channelType int) (int, bool) {
apiType = constant.APITypeReplicate
case constant.ChannelTypeCodex:
apiType = constant.APITypeCodex
case constant.ChannelTypeAdvancedCustom:
case constant.ChannelTypeAdvancedCustom, constant.ChannelTypeVLLM, constant.ChannelTypeSGLang:
apiType = constant.APITypeAdvancedCustom
case constant.ChannelTypeSub2API:
apiType = constant.APITypeSub2API
+2
View File
@@ -5,6 +5,7 @@ import (
"encoding/binary"
"fmt"
"io"
"strings"
"github.com/abema/go-mp4"
"github.com/go-audio/aiff"
@@ -20,6 +21,7 @@ import (
// 它不再依赖外部的 ffmpeg 或 ffprobe 程序。
func GetAudioDuration(ctx context.Context, f io.ReadSeeker, ext string) (duration float64, err error) {
SysLog(fmt.Sprintf("GetAudioDuration: ext=%s", ext))
ext = strings.ToLower(ext)
// 根据文件扩展名选择解析器
switch ext {
case ".mp3":
+54
View File
@@ -0,0 +1,54 @@
package common
import (
"bytes"
"context"
"encoding/binary"
"strings"
"testing"
)
// minimalWAV builds a valid 16-bit PCM mono WAV with the given number of sample frames.
func minimalWAV(sampleRate uint32, frames int) []byte {
const numChannels = 1
const bitsPerSample = 16
dataSize := frames * numChannels * (bitsPerSample / 8)
buf := &bytes.Buffer{}
buf.WriteString("RIFF")
binary.Write(buf, binary.LittleEndian, uint32(36+dataSize))
buf.WriteString("WAVE")
buf.WriteString("fmt ")
binary.Write(buf, binary.LittleEndian, uint32(16))
binary.Write(buf, binary.LittleEndian, uint16(1)) // PCM
binary.Write(buf, binary.LittleEndian, uint16(numChannels))
binary.Write(buf, binary.LittleEndian, sampleRate)
byteRate := sampleRate * numChannels * (bitsPerSample / 8)
binary.Write(buf, binary.LittleEndian, byteRate)
binary.Write(buf, binary.LittleEndian, uint16(numChannels*(bitsPerSample/8)))
binary.Write(buf, binary.LittleEndian, uint16(bitsPerSample))
buf.WriteString("data")
binary.Write(buf, binary.LittleEndian, uint32(dataSize))
buf.Write(make([]byte, dataSize))
return buf.Bytes()
}
func TestGetAudioDurationCaseInsensitiveExt(t *testing.T) {
wav := minimalWAV(8000, 8000) // 1 second
for _, ext := range []string{".wav", ".WAV", ".Wav"} {
d, err := GetAudioDuration(context.Background(), bytes.NewReader(wav), ext)
if err != nil {
t.Fatalf("ext %q: unexpected error: %v", ext, err)
}
if d < 0.99 || d > 1.01 {
t.Fatalf("ext %q: expected ~1s duration, got %v", ext, d)
}
}
}
func TestGetAudioDurationUppercaseNotUnsupported(t *testing.T) {
_, err := GetAudioDuration(context.Background(), bytes.NewReader([]byte("not audio")), ".MP3")
if err != nil && strings.Contains(err.Error(), "unsupported audio format") {
t.Fatalf("uppercase extension rejected as unsupported: %v", err)
}
}
+2
View File
@@ -28,6 +28,8 @@ func GetEndpointTypesByChannelType(channelType int, modelName string) []constant
endpointTypes = []constant.EndpointType{constant.EndpointTypeOpenAI}
case constant.ChannelTypeXai:
endpointTypes = []constant.EndpointType{constant.EndpointTypeOpenAI, constant.EndpointTypeOpenAIResponse}
case constant.ChannelTypeVLLM, constant.ChannelTypeSGLang:
endpointTypes = GetAdvancedCustomPreset(channelType).SupportedEndpointTypesForModel(modelName)
case constant.ChannelTypeSora:
endpointTypes = []constant.EndpointType{constant.EndpointTypeOpenAIVideo}
case constant.ChannelTypeSub2API, constant.ChannelTypeNewAPI:
+4 -2
View File
@@ -9,6 +9,8 @@ import (
"github.com/gin-gonic/gin/binding"
)
type RawMessage = json.RawMessage
// hostJSONCodec is the single place where the host chooses its JSON engine.
// Swap the implementation here (for example to sonic.ConfigStd) and every
// common.* and kitutil.* JSON helper, including relaykit DTO (un)marshalling,
@@ -73,11 +75,11 @@ func IndentJson(data []byte) ([]byte, error) {
return buffer.Bytes(), nil
}
func GetJsonType(data json.RawMessage) string {
func GetJsonType(data RawMessage) string {
return kitutil.GetJsonType(data)
}
// JsonRawMessageToString returns JSON strings as their decoded value and other JSON values as raw text.
func JsonRawMessageToString(data json.RawMessage) string {
func JsonRawMessageToString(data RawMessage) string {
return kitutil.JsonRawMessageToString(data)
}
+23 -4
View File
@@ -2,6 +2,9 @@ package common
import "strings"
// ZImagePromptExtendMultiplier is the legacy Ali image request surcharge.
const ZImagePromptExtendMultiplier = 2
var (
// OpenAIResponseOnlyModels is a list of models that are only available for OpenAI responses.
OpenAIResponseOnlyModels = []string{
@@ -12,7 +15,20 @@ var (
ImageGenerationModels = []string{
"dall-e-3",
"dall-e-2",
"gpt-image-1",
"prefix:dall-e", // Deprecated upstream models; retained for compatible routes.
"gpt-image-",
"qwen-image",
"z-image",
"wan2.7-image-pro",
"wan2.7-image",
"wan2.6-image",
"wan2.6-t2i",
"wan2.5-t2i-preview",
"wan2.2-t2i-flash",
"wan2.2-t2i-plus",
"wanx2.1-t2i-turbo",
"wanx2.1-t2i-plus",
"wanx2.0-t2i-turbo",
"prefix:imagen-",
"flux-",
"flux.1-",
@@ -38,10 +54,13 @@ func IsOpenAIResponseOnlyModel(modelName string) bool {
func IsImageGenerationModel(modelName string) bool {
modelName = strings.ToLower(modelName)
for _, m := range ImageGenerationModels {
if strings.Contains(modelName, m) {
return true
if prefix, ok := strings.CutPrefix(m, "prefix:"); ok {
if strings.HasPrefix(modelName, prefix) {
return true
}
continue
}
if strings.HasPrefix(m, "prefix:") && strings.HasPrefix(modelName, strings.TrimPrefix(m, "prefix:")) {
if strings.Contains(modelName, m) {
return true
}
}
+28
View File
@@ -0,0 +1,28 @@
package common_test
import (
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/stretchr/testify/assert"
)
func TestWanEndpointsDistinguishImagesFromVideos(t *testing.T) {
for _, name := range []string{
"wan2.7-image-pro", "wan2.7-image", "wan2.6-image", "wan2.6-t2i",
"wan2.5-t2i-preview", "wan2.2-t2i-flash", "wan2.2-t2i-plus",
"wanx2.1-t2i-turbo", "wanx2.1-t2i-plus", "wanx2.0-t2i-turbo",
} {
t.Run(name, func(t *testing.T) {
assert.Contains(t, common.GetEndpointTypesByChannelType(constant.ChannelTypeAli, name), constant.EndpointTypeImageGeneration)
})
}
for _, name := range []string{
"wanx2.1-t2v-plus", "wanx2.1-t2v-turbo", "wanx2.1-i2v-plus", "wanx2.1-i2v-turbo",
} {
t.Run(name, func(t *testing.T) {
assert.NotContains(t, common.GetEndpointTypesByChannelType(constant.ChannelTypeAli, name), constant.EndpointTypeImageGeneration)
})
}
}
+186 -45
View File
@@ -1,70 +1,211 @@
package common
import (
"container/list"
"sync"
"time"
)
// rateLimitRequest stores one accepted request in a key's sliding window.
type rateLimitRequest struct {
next *rateLimitRequest
timestamp int64
}
// rateLimitQueue keeps accepted requests ordered from oldest to newest.
type rateLimitQueue struct {
head *rateLimitRequest
tail *rateLimitRequest
length int
}
// append adds an accepted request without preallocating for the configured limit.
func (q *rateLimitQueue) append(timestamp int64) {
request := &rateLimitRequest{timestamp: timestamp}
if q.tail == nil {
q.head = request
q.tail = request
} else {
q.tail.next = request
q.tail = request
}
q.length++
}
// removeExpired releases every expired request at the front of the queue.
func (q *rateLimitQueue) removeExpired(now int64, duration int64) {
if q.head == nil || now-q.head.timestamp < duration {
return
}
// Requests are time ordered, so an expired tail means the whole queue expired.
if now-q.tail.timestamp >= duration {
q.clear()
return
}
for now-q.head.timestamp >= duration {
expired := q.head
q.head = expired.next
expired.next = nil
q.length--
}
}
// clear releases the whole request chain when its key expires.
func (q *rateLimitQueue) clear() {
q.head = nil
q.tail = nil
q.length = 0
}
// rateLimitEntry is both a rate-limit bucket and a node in the key-level LRU.
type rateLimitEntry struct {
lastActive time.Time
element *list.Element
requests rateLimitQueue
key string
}
// InMemoryRateLimiter implements a sliding-window limiter with idle-key eviction.
// Reservations are tracked apart from the LRU so an in-flight request survives
// its key being evicted while it is still running.
type InMemoryRateLimiter struct {
store map[string]*[]int64
store map[string]*rateLimitEntry
lru *list.List
reservations map[string]int
mutex sync.Mutex
expirationDuration time.Duration
}
// Init initializes the limiter once. Repeated calls leave the first configuration unchanged.
func (l *InMemoryRateLimiter) Init(expirationDuration time.Duration) {
if l.store == nil {
l.mutex.Lock()
if l.store == nil {
l.store = make(map[string]*[]int64)
l.expirationDuration = expirationDuration
if expirationDuration > 0 {
go l.clearExpiredItems()
}
}
l.mutex.Unlock()
l.mutex.Lock()
defer l.mutex.Unlock()
if l.store != nil {
return
}
l.store = make(map[string]*rateLimitEntry)
l.lru = list.New()
l.reservations = make(map[string]int)
l.expirationDuration = expirationDuration
if expirationDuration > 0 {
go l.clearExpiredItems(time.NewTicker(expirationDuration).C)
}
}
func (l *InMemoryRateLimiter) clearExpiredItems() {
for {
time.Sleep(l.expirationDuration)
l.mutex.Lock()
now := time.Now().Unix()
for key := range l.store {
queue := l.store[key]
size := len(*queue)
if size == 0 || now-(*queue)[size-1] > int64(l.expirationDuration.Seconds()) {
delete(l.store, key)
}
}
l.mutex.Unlock()
// clearExpiredItems periodically removes expired entries from the LRU tail.
func (l *InMemoryRateLimiter) clearExpiredItems(ticks <-chan time.Time) {
for now := range ticks {
l.deleteExpiredEntries(now)
}
}
// deleteExpiredEntries walks only the oldest LRU entries and stops at the first active key.
func (l *InMemoryRateLimiter) deleteExpiredEntries(now time.Time) {
if l.expirationDuration <= 0 {
return
}
l.mutex.Lock()
defer l.mutex.Unlock()
for {
oldest := l.lru.Back()
if oldest == nil {
return
}
entry := oldest.Value.(*rateLimitEntry)
if now.Sub(entry.lastActive) < l.expirationDuration {
return
}
delete(l.store, entry.key)
l.lru.Remove(oldest)
entry.element = nil
entry.requests.clear()
}
}
// touchEntry returns the key's bucket, creating it on demand, and marks it as
// the most recently active key. The caller must hold the mutex.
func (l *InMemoryRateLimiter) touchEntry(key string, now time.Time) *rateLimitEntry {
entry, ok := l.store[key]
if !ok {
entry = &rateLimitEntry{
key: key,
lastActive: now,
}
entry.element = l.lru.PushFront(entry)
l.store[key] = entry
return entry
}
entry.lastActive = now
l.lru.MoveToFront(entry.element)
return entry
}
// Request parameter duration's unit is seconds
func (l *InMemoryRateLimiter) Request(key string, maxRequestNum int, duration int64) bool {
l.mutex.Lock()
defer l.mutex.Unlock()
// [old <-- new]
queue, ok := l.store[key]
now := time.Now().Unix()
if ok {
if len(*queue) < maxRequestNum {
*queue = append(*queue, now)
return true
} else {
if now-(*queue)[0] >= duration {
*queue = (*queue)[1:]
*queue = append(*queue, now)
return true
} else {
return false
}
}
} else {
s := make([]int64, 0, maxRequestNum)
l.store[key] = &s
*(l.store[key]) = append(*(l.store[key]), now)
now := time.Now()
entry := l.touchEntry(key, now)
entry.requests.removeExpired(now.Unix(), duration)
allowed := entry.requests.length < maxRequestNum
if allowed {
entry.requests.append(now.Unix())
}
return true
return allowed
}
// RateLimitReservation counts an active request towards admission until its
// outcome is known. Failed requests release the slot without consuming quota.
type RateLimitReservation struct {
limiter *InMemoryRateLimiter
key string
once sync.Once
}
// Reserve admits a request when the key's accepted requests plus its in-flight
// reservations stay below maxRequests. It returns nil when the key is saturated.
func (l *InMemoryRateLimiter) Reserve(key string, maxRequests int, duration int64) *RateLimitReservation {
l.mutex.Lock()
defer l.mutex.Unlock()
now := time.Now()
entry := l.touchEntry(key, now)
entry.requests.removeExpired(now.Unix(), duration)
if entry.requests.length+l.reservations[key] >= maxRequests {
return nil
}
l.reservations[key]++
return &RateLimitReservation{limiter: l, key: key}
}
// Complete releases the reservation. A successful outcome records the request
// in the key's sliding window; a failed one only frees the slot.
func (r *RateLimitReservation) Complete(success bool) {
if r == nil {
return
}
r.once.Do(func() {
l := r.limiter
l.mutex.Lock()
defer l.mutex.Unlock()
l.reservations[r.key]--
if l.reservations[r.key] == 0 {
delete(l.reservations, r.key)
}
if success {
now := time.Now()
l.touchEntry(r.key, now).requests.append(now.Unix())
}
})
}
+150
View File
@@ -0,0 +1,150 @@
package common
import (
"sync"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestInMemoryRateLimiterAllocatesTimestampsOnDemand(t *testing.T) {
var limiter InMemoryRateLimiter
limiter.Init(0)
require.True(t, limiter.Request("client", 1_000_000, 60))
entry := limiter.store["client"]
require.NotNil(t, entry)
assert.Equal(t, 1, entry.requests.length)
assert.Same(t, entry.requests.head, entry.requests.tail)
assert.Nil(t, entry.requests.head.next)
limiter.deleteExpiredEntries(time.Now().Add(time.Hour))
assert.Contains(t, limiter.store, "client")
}
func TestInMemoryRateLimiterEnforcesWindowBoundary(t *testing.T) {
var queue rateLimitQueue
queue.append(100)
queue.append(100)
queue.removeExpired(159, 60)
assert.Equal(t, 2, queue.length)
queue.removeExpired(160, 60)
assert.Zero(t, queue.length)
assert.Nil(t, queue.head)
assert.Nil(t, queue.tail)
}
func TestInMemoryRateLimiterRemovesAllExpiredRequestsPerCall(t *testing.T) {
var queue rateLimitQueue
for range 5 {
queue.append(100)
}
queue.append(110)
queue.removeExpired(110, 10)
assert.Equal(t, 1, queue.length)
require.NotNil(t, queue.head)
assert.EqualValues(t, 110, queue.head.timestamp)
assert.Same(t, queue.head, queue.tail)
}
func TestInMemoryRateLimiterKeepsNonExpiredRequests(t *testing.T) {
var queue rateLimitQueue
for range 3 {
queue.append(100)
}
queue.removeExpired(109, 10)
assert.Equal(t, 3, queue.length)
assert.NotNil(t, queue.head)
assert.NotNil(t, queue.tail)
}
func TestInMemoryRateLimiterEvictsOnlyExpiredLRUTail(t *testing.T) {
var limiter InMemoryRateLimiter
limiter.Init(0)
limiter.expirationDuration = 10 * time.Second
require.True(t, limiter.Request("active", 1, 100))
require.True(t, limiter.Request("idle", 1, 100))
assert.False(t, limiter.Request("active", 1, 100))
assert.Equal(t, "active", limiter.lru.Front().Value.(*rateLimitEntry).key)
assert.Equal(t, "idle", limiter.lru.Back().Value.(*rateLimitEntry).key)
idleEntry := limiter.store["idle"]
now := time.Now()
idleEntry.lastActive = now.Add(-10 * time.Second)
limiter.store["active"].lastActive = now
limiter.deleteExpiredEntries(now)
assert.NotContains(t, limiter.store, "idle")
assert.Contains(t, limiter.store, "active")
assert.Equal(t, 1, limiter.lru.Len())
assert.Nil(t, idleEntry.element)
assert.Nil(t, idleEntry.requests.head)
assert.Nil(t, idleEntry.requests.tail)
assert.Zero(t, idleEntry.requests.length)
}
func TestInMemoryRateLimiterCleanupTicksEvictExpiredKeys(t *testing.T) {
var limiter InMemoryRateLimiter
limiter.Init(0)
limiter.expirationDuration = 10 * time.Second
require.True(t, limiter.Request("idle", 1, 100))
now := time.Now()
limiter.store["idle"].lastActive = now.Add(-10 * time.Second)
ticks := make(chan time.Time, 1)
done := make(chan struct{})
go func() {
limiter.clearExpiredItems(ticks)
close(done)
}()
ticks <- now
close(ticks)
<-done
assert.Empty(t, limiter.store)
assert.Zero(t, limiter.lru.Len())
}
func TestInMemoryRateLimiterInitKeepsFirstConfiguration(t *testing.T) {
var limiter InMemoryRateLimiter
limiter.Init(time.Hour)
limiter.Init(0)
assert.Equal(t, time.Hour, limiter.expirationDuration)
require.True(t, limiter.Request("client", 1, 60))
limiter.deleteExpiredEntries(time.Now().Add(time.Hour))
assert.NotContains(t, limiter.store, "client")
}
func TestInMemoryRateLimiterConcurrentInitializationAndRequests(t *testing.T) {
var limiter InMemoryRateLimiter
var allowed atomic.Int64
var waitGroup sync.WaitGroup
for range 100 {
waitGroup.Add(1)
go func() {
defer waitGroup.Done()
limiter.Init(0)
if limiter.Request("client", 10, 60) {
allowed.Add(1)
}
}()
}
waitGroup.Wait()
assert.EqualValues(t, 10, allowed.Load())
assert.Len(t, limiter.store, 1)
assert.Equal(t, 10, limiter.store["client"].requests.length)
}