feat: publish v1.0.0-rc.36-custom.1
Publish Docker image (Multi-arch) / Build & push (amd64) (push) Failing after 9m20s
Release (Linux, macOS, Windows) / Linux Release (push) Failing after 55m7s
Publish Docker image (Multi-arch) / Build & push (arm64) (push) Canceled after 0s
Release (Linux, macOS, Windows) / macOS Release (push) Canceled after 0s
Release (Linux, macOS, Windows) / Windows Release (push) Canceled after 0s
Publish Docker image (Multi-arch) / Create multi-arch manifests (push) Canceled after 0s

This commit is contained in:
Codex committed 2026-09-11 05:07:48 +08:00
commit bb7968b612
2381 files changed
+551494

No files matched your search

+41
View File
@@ -0,0 +1,41 @@
package service
import (
"fmt"
"html"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/oauth"
"github.com/QuantumNous/new-api/setting/system_setting"
)
func UnbindAccountOAuth(identity AuthIdentity, providerID int) error {
enabled := model.AccountLoginMethods{
Password: common.PasswordLoginEnabled,
Passkey: system_setting.GetPasskeySettings().Enabled,
WeChat: common.WeChatAuthEnabled,
}
for _, provider := range oauth.GetAllProviders() {
if !provider.IsEnabled() {
continue
}
if custom, ok := provider.(*oauth.GenericOAuthProvider); ok {
enabled.CustomProviderIDs = append(enabled.CustomProviderIDs, custom.GetProviderId())
} else {
enabled.OAuthColumns = append(enabled.OAuthColumns, provider.ProviderUserIDColumn())
}
}
return model.UnbindUserOAuthForSession(identity, providerID, enabled)
}
// NotifyAccountSecurityChange never includes credentials or tokens. The caller
// records delivery failure independently from the already-committed change.
func NotifyAccountSecurityChange(email, event string) error {
if email == "" {
return nil
}
subject := common.SystemName + " — Account security notification"
content := fmt.Sprintf("<p>Your account security settings have changed: %s.</p><p>If you did not make this change, open your account security settings, revoke other login sessions, and contact your administrator.</p>", html.EscapeString(event))
return common.SendEmail(subject, email, content)
}
+48
View File
@@ -0,0 +1,48 @@
package service
import (
"encoding/base64"
"fmt"
"strings"
)
func parseAudio(audioBase64 string, format string) (duration float64, err error) {
audioData, err := base64.StdEncoding.DecodeString(audioBase64)
if err != nil {
return 0, fmt.Errorf("base64 decode error: %v", err)
}
var samplesCount int
var sampleRate int
switch format {
case "pcm16":
samplesCount = len(audioData) / 2 // 16位 = 2字节每样本
sampleRate = 24000 // 24kHz
case "g711_ulaw", "g711_alaw":
samplesCount = len(audioData) // 8位 = 1字节每样本
sampleRate = 8000 // 8kHz
default:
samplesCount = len(audioData) // 8位 = 1字节每样本
sampleRate = 8000 // 8kHz
}
duration = float64(samplesCount) / float64(sampleRate)
return duration, nil
}
func DecodeBase64AudioData(audioBase64 string) (string, error) {
// 检查并移除 data:audio/xxx;base64, 前缀
idx := strings.Index(audioBase64, ",")
if idx != -1 {
audioBase64 = audioBase64[idx+1:]
}
// 解码 Base64 数据
_, err := base64.StdEncoding.DecodeString(audioBase64)
if err != nil {
return "", fmt.Errorf("base64 decode error: %v", err)
}
return audioBase64, nil
}
+51
View File
@@ -0,0 +1,51 @@
package service
import (
"fmt"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
)
const authArtifactCleanupInterval = time.Hour
// StartAuthArtifactCleanup removes expired dashboard Sessions and old
// one-time authentication flows. Only the master instance performs cleanup.
func StartAuthArtifactCleanup() {
if !common.IsMasterNode {
return
}
go func() {
cleanupAuthArtifacts()
ticker := time.NewTicker(authArtifactCleanupInterval)
defer ticker.Stop()
for range ticker.C {
cleanupAuthArtifacts()
}
}()
}
func cleanupAuthArtifacts() {
now := time.Now()
count, err := model.CountUserSessionsCreatedSince(0, now.Add(-time.Hour).Unix())
if err != nil {
common.SysError("failed to count hourly user session issuance: " + err.Error())
} else if count > int64(common.UserSessionHourlyAlertThreshold) {
common.SysError(fmt.Sprintf(
"hourly user session issuance exceeded alert threshold: count=%d threshold=%d window_seconds=%d",
count,
common.UserSessionHourlyAlertThreshold,
int64(time.Hour/time.Second),
))
}
if err := model.DeleteExpiredUserSessions(now.Unix()); err != nil {
common.SysError("failed to delete expired user sessions: " + err.Error())
}
if err := model.DeleteOldRevokedUserSessions(now.Unix()); err != nil {
common.SysError("failed to delete old revoked user sessions: " + err.Error())
}
if err := model.DeleteExpiredAuthFlows(now); err != nil {
common.SysError("failed to delete expired authentication flows: " + err.Error())
}
}
+476
View File
@@ -0,0 +1,476 @@
package service
import (
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
const RefreshCookieName = "new_api_refresh"
// SessionHintCookieName is the script-readable companion to RefreshCookieName.
// See writeSessionHintCookie for why it exists and what it is not.
const SessionHintCookieName = "new_api_has_session"
// SessionHintCookieValue is the only value the hint ever carries.
const SessionHintCookieValue = "1"
var (
ErrLoginSessionInvalid = errors.New("login session is invalid")
ErrLoginSessionRevoked = errors.New("login session is revoked")
ErrLoginSessionMismatch = errors.New("login session does not match the expected session")
ErrRefreshTokenInvalid = errors.New("refresh token is invalid")
ErrRefreshRace = errors.New("refresh token was already rotated")
)
type LoginSessionView struct {
SID string `json:"sid"`
Current bool `json:"current"`
LoginMethod string `json:"login_method"`
IP string `json:"ip"`
UserAgent string `json:"user_agent"`
CreatedAt int64 `json:"created_at"`
LastActiveAt int64 `json:"last_active_at"`
ExpiresAt int64 `json:"expires_at"`
}
type AuthBundle struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
AccessExpiresAt int64 `json:"access_expires_at"`
Session LoginSessionView `json:"session"`
RefreshToken string `json:"-"`
}
func CreateLoginSession(userID int, loginMethod, ip, userAgent string) (*AuthBundle, error) {
return createLoginSession(userID, 0, loginMethod, ip, userAgent)
}
func CreateLoginSessionAtAuthVersion(userID int, expectedAuthVersion int64, loginMethod, ip, userAgent string) (*AuthBundle, error) {
if expectedAuthVersion <= 0 {
return nil, ErrLoginSessionInvalid
}
return createLoginSession(userID, expectedAuthVersion, loginMethod, ip, userAgent)
}
func createLoginSession(userID int, expectedAuthVersion int64, loginMethod, ip, userAgent string) (*AuthBundle, error) {
user, err := model.GetUserCache(userID)
if err != nil {
return nil, err
}
if user.Status != common.UserStatusEnabled || user.AuthVersion <= 0 {
return nil, ErrLoginSessionInvalid
}
if expectedAuthVersion > 0 && user.AuthVersion != expectedAuthVersion {
return nil, ErrLoginSessionRevoked
}
now := time.Now().Unix()
activeCount, err := model.CountActiveUserSessions(userID, now)
if err != nil {
return nil, err
}
if activeCount >= int64(common.UserSessionActiveLimit) {
return nil, model.ErrUserSessionLimit
}
issuanceCount, err := model.CountUserSessionsCreatedSince(userID, now-common.UserSessionIssuanceWindowSeconds)
if err != nil {
return nil, err
}
if issuanceCount >= int64(common.UserSessionIssuanceLimit) {
return nil, model.ErrUserSessionIssuanceLimit
}
session, refreshSecret, err := newLoginSession(userID, user.AuthVersion, loginMethod, ip, userAgent)
if err != nil {
return nil, err
}
if err := model.CreateUserSession(session); err != nil {
return nil, err
}
bundle, err := issueAuthBundle(session, session.SID+"."+refreshSecret, true)
if err != nil {
_, _ = model.RevokeUserSession(userID, session.SID, "token_issue_failed")
return nil, err
}
return bundle, nil
}
func newLoginSession(userID int, authVersion int64, loginMethod, ip, userAgent string) (*model.UserSession, string, error) {
refreshSecret, err := common.GenerateRandomCharsKey(64)
if err != nil {
return nil, "", err
}
now := time.Now().Unix()
session := &model.UserSession{
SID: uuid.NewString(),
UserID: userID,
Version: 1,
UserAuthVersion: authVersion,
Status: model.UserSessionStatusActive,
RefreshHash: hashRefreshSecret(refreshSecret),
LoginMethod: strings.TrimSpace(loginMethod),
IP: truncateAuthMetadata(ip, 64),
UserAgent: truncateAuthMetadata(userAgent, 512),
CreatedAt: now,
LastActiveAt: now,
ExpiresAt: time.Unix(now, 0).Add(LoginSessionTTL).Unix(),
}
if session.LoginMethod == "" {
session.LoginMethod = "unknown"
}
return session, refreshSecret, nil
}
func ValidateLoginSession(identity AuthIdentity) (*model.UserSession, *model.UserBase, error) {
session, err := model.GetUserSessionCached(identity.SessionID)
if err != nil {
if errors.Is(err, model.ErrUserSessionInactive) {
return nil, nil, ErrLoginSessionRevoked
}
return nil, nil, err
}
now := time.Now().Unix()
if session.UserID != identity.UserID || session.Status != model.UserSessionStatusActive || session.RevokedAt != 0 || session.ExpiresAt <= now || session.Version != identity.SessionVersion || session.UserAuthVersion != identity.UserAuthVersion {
return nil, nil, ErrLoginSessionRevoked
}
user, err := model.GetUserCache(identity.UserID)
if err != nil {
return nil, nil, err
}
if user.Status != common.UserStatusEnabled || user.AuthVersion != identity.UserAuthVersion {
return nil, nil, ErrLoginSessionRevoked
}
return session, user, nil
}
// ValidateSessionReference validates a server-side flow bound to an existing
// dashboard session without requiring an access token on the callback request.
func ValidateSessionReference(userID int, sid string) (AuthIdentity, error) {
if userID <= 0 || strings.TrimSpace(sid) == "" {
return AuthIdentity{}, ErrLoginSessionInvalid
}
session, err := model.GetUserSessionCached(sid)
if err != nil {
return AuthIdentity{}, err
}
identity := AuthIdentity{
UserID: userID,
SessionID: sid,
UserAuthVersion: session.UserAuthVersion,
SessionVersion: session.Version,
}
if _, _, err := ValidateLoginSession(identity); err != nil {
return AuthIdentity{}, err
}
return identity, nil
}
// AdvanceCurrentSessionSecurity increments the user's global auth version,
// preserves only the current browser session at a new session version and
// returns a replacement access token. Call after a successful 2FA/passkey
// security-setting mutation that did not already advance AuthVersion.
func AdvanceCurrentSessionSecurity(identity AuthIdentity, reason string) (*AuthBundle, error) {
nextUserAuthVersion, err := model.BumpUserAuthVersion(identity.UserID)
if err != nil {
return nil, err
}
return advanceCurrentSessionToVersion(identity, nextUserAuthVersion, reason)
}
// AdvanceCurrentSessionToUserVersion is used when the security mutation and
// AuthVersion increment were committed in the same transaction (for example,
// a password change).
func AdvanceCurrentSessionToUserVersion(identity AuthIdentity, reason string) (*AuthBundle, error) {
user, err := model.GetUserCache(identity.UserID)
if err != nil {
return nil, err
}
if user.Status != common.UserStatusEnabled || user.AuthVersion <= identity.UserAuthVersion {
return nil, ErrLoginSessionRevoked
}
return advanceCurrentSessionToVersion(identity, user.AuthVersion, reason)
}
func advanceCurrentSessionToVersion(identity AuthIdentity, nextUserAuthVersion int64, reason string) (*AuthBundle, error) {
session, err := model.AdvanceUserSessionAuthVersion(
identity.UserID,
identity.SessionID,
identity.SessionVersion,
identity.UserAuthVersion,
nextUserAuthVersion,
)
if err != nil {
return nil, err
}
if _, err := model.RevokeOtherUserSessions(identity.UserID, identity.SessionID, reason); err != nil {
return nil, err
}
return issueAuthBundle(session, "", true)
}
func RefreshLoginSession(rawRefreshToken, expectedSID, ip, userAgent string) (*AuthBundle, *model.User, error) {
sid, secret, ok := splitRefreshToken(rawRefreshToken)
if !ok {
return nil, nil, ErrRefreshTokenInvalid
}
if expectedSID = strings.TrimSpace(expectedSID); expectedSID != "" && expectedSID != sid {
return nil, nil, ErrLoginSessionMismatch
}
session, err := model.GetUserSessionCached(sid)
if err != nil {
if errors.Is(err, model.ErrUserSessionInactive) {
return nil, nil, ErrLoginSessionRevoked
}
return nil, nil, ErrRefreshTokenInvalid
}
if session.Status != model.UserSessionStatusActive || session.RevokedAt != 0 || session.ExpiresAt <= time.Now().Unix() {
return nil, nil, ErrLoginSessionRevoked
}
userCache, err := model.GetUserCache(session.UserID)
if err != nil {
return nil, nil, err
}
currentUser, err := model.GetSelfUserById(session.UserID)
if err != nil {
return nil, nil, err
}
if userCache.Status != common.UserStatusEnabled || userCache.AuthVersion != session.UserAuthVersion ||
currentUser.Status != common.UserStatusEnabled || currentUser.AuthVersion != session.UserAuthVersion {
_, _ = model.RevokeUserSession(session.UserID, session.SID, "user_security_changed")
return nil, nil, ErrLoginSessionRevoked
}
nextSecret := deriveNextRefreshSecret(sid, secret)
rotated, err := model.RotateUserSessionRefresh(session.UserID, sid, hashRefreshSecret(secret), hashRefreshSecret(nextSecret), time.Now().Unix(), RefreshReplayWindow)
if err != nil {
if errors.Is(err, model.ErrUserSessionRefreshRace) && rotated != nil &&
hashRefreshSecret(nextSecret) == rotated.RefreshHash {
bundle, issueErr := issueAuthBundle(rotated, sid+"."+nextSecret, true)
if issueErr != nil {
return nil, nil, issueErr
}
return bundle, currentUser, nil
}
if errors.Is(err, model.ErrUserSessionRefreshReuse) {
return nil, nil, ErrLoginSessionRevoked
}
if errors.Is(err, model.ErrUserSessionRefreshInvalid) {
return nil, nil, ErrRefreshTokenInvalid
}
if errors.Is(err, model.ErrUserSessionRefreshRace) {
return nil, nil, ErrRefreshRace
}
return nil, nil, err
}
rotated.IP = truncateAuthMetadata(ip, 64)
rotated.UserAgent = truncateAuthMetadata(userAgent, 512)
bundle, err := issueAuthBundle(rotated, sid+"."+nextSecret, true)
if err != nil {
return nil, nil, err
}
return bundle, currentUser, nil
}
func RevokeByRefreshToken(rawRefreshToken, expectedSID, reason string) error {
sid, secret, ok := splitRefreshToken(rawRefreshToken)
if !ok {
return nil
}
if expectedSID = strings.TrimSpace(expectedSID); expectedSID != "" && expectedSID != sid {
return ErrLoginSessionMismatch
}
_, err := model.RevokeUserSessionByRefreshHash(sid, hashRefreshSecret(secret), reason)
return err
}
func RefreshTokenSID(rawRefreshToken string) (string, bool) {
sid, _, ok := splitRefreshToken(rawRefreshToken)
return sid, ok
}
func ListLoginSessions(userID int, currentSID string) ([]LoginSessionView, error) {
sessions, err := model.ListActiveUserSessions(userID, currentSID, time.Now().Unix())
if err != nil {
return nil, err
}
views := make([]LoginSessionView, 0, len(sessions))
for i := range sessions {
views = append(views, sessionView(&sessions[i], sessions[i].SID == currentSID))
}
return views, nil
}
func WriteRefreshCookie(c *gin.Context, rawToken string) {
expiresAt := time.Now().Add(LoginSessionTTL)
if sid, _, ok := splitRefreshToken(rawToken); ok {
if session, err := model.GetUserSessionCached(sid); err == nil && session.ExpiresAt > time.Now().Unix() {
expiresAt = time.Unix(session.ExpiresAt, 0)
}
}
maxAge := max(int(time.Until(expiresAt)/time.Second), 1)
http.SetCookie(c.Writer, &http.Cookie{
Name: RefreshCookieName,
Value: rawToken,
Path: "/api/user/auth",
MaxAge: maxAge,
Expires: expiresAt,
HttpOnly: true,
Secure: common.SessionCookieSecure,
SameSite: http.SameSiteStrictMode,
})
writeSessionHintCookie(c, maxAge, expiresAt)
}
func ClearRefreshCookie(c *gin.Context) {
http.SetCookie(c.Writer, &http.Cookie{
Name: RefreshCookieName,
Value: "",
Path: "/api/user/auth",
MaxAge: -1,
Expires: time.Unix(1, 0),
HttpOnly: true,
Secure: common.SessionCookieSecure,
SameSite: http.SameSiteStrictMode,
})
clearSessionHintCookie(c)
}
// writeSessionHintCookie mirrors the Refresh Cookie's lifetime with a
// script-readable marker. The Refresh Cookie itself is HttpOnly and scoped to
// /api/user/auth, so a page at / cannot tell whether a login session exists;
// without this hint the frontend has to POST /api/user/auth/refresh on every
// cold boot just to learn that an anonymous visitor is anonymous. That request
// is guaranteed to 401 and still consumes a slot of the IP-keyed
// CriticalRateLimit budget shared by everyone behind the same address.
//
// The value is the constant "1" and carries no credential: it states that a
// Refresh Cookie was issued, never who for. Authorization still derives solely
// from the Refresh Cookie and the Access Token, so forging this hint only costs
// the forger the round trip it was meant to avoid.
//
// It must be written and cleared in lockstep with the Refresh Cookie, which is
// why it lives inside these two helpers rather than at their call sites: both
// cookies then ride the same response with the same expiry, and no login path
// can set one without the other.
func writeSessionHintCookie(c *gin.Context, maxAge int, expiresAt time.Time) {
http.SetCookie(c.Writer, &http.Cookie{
Name: SessionHintCookieName,
Value: SessionHintCookieValue,
Path: "/",
MaxAge: maxAge,
Expires: expiresAt,
HttpOnly: false,
Secure: common.SessionCookieSecure,
SameSite: http.SameSiteStrictMode,
})
}
func clearSessionHintCookie(c *gin.Context) {
http.SetCookie(c.Writer, &http.Cookie{
Name: SessionHintCookieName,
Value: "",
Path: "/",
MaxAge: -1,
Expires: time.Unix(1, 0),
HttpOnly: false,
Secure: common.SessionCookieSecure,
SameSite: http.SameSiteStrictMode,
})
}
func issueAuthBundle(session *model.UserSession, rawRefreshToken string, current bool) (*AuthBundle, error) {
identity := AuthIdentity{
UserID: session.UserID,
SessionID: session.SID,
UserAuthVersion: session.UserAuthVersion,
SessionVersion: session.Version,
}
accessToken, accessExpiresAt, err := IssueAccessToken(identity)
if err != nil {
return nil, err
}
return &AuthBundle{
AccessToken: accessToken,
TokenType: "Bearer",
AccessExpiresAt: accessExpiresAt,
Session: sessionView(session, current),
RefreshToken: rawRefreshToken,
}, nil
}
func sessionView(session *model.UserSession, current bool) LoginSessionView {
return LoginSessionView{
SID: session.SID,
Current: current,
LoginMethod: session.LoginMethod,
IP: session.IP,
UserAgent: session.UserAgent,
CreatedAt: session.CreatedAt,
LastActiveAt: session.LastActiveAt,
ExpiresAt: session.ExpiresAt,
}
}
func splitRefreshToken(raw string) (string, string, bool) {
sid, secret, ok := strings.Cut(strings.TrimSpace(raw), ".")
if !ok || sid == "" || secret == "" || strings.Contains(secret, ".") {
return "", "", false
}
if _, err := uuid.Parse(sid); err != nil {
return "", "", false
}
return sid, secret, true
}
func hashRefreshSecret(secret string) string {
return common.GenerateHMACWithKey(authSigningKey("refresh"), secret)
}
func deriveNextRefreshSecret(sid, currentSecret string) string {
return common.GenerateHMACWithKey(authSigningKey("refresh-rotate"), sid+"."+currentSecret)
}
func truncateAuthMetadata(value string, max int) string {
value = strings.TrimSpace(value)
if len(value) <= max {
return value
}
return value[:max]
}
func authSessionErrorCode(err error) (int, string) {
switch {
case errors.Is(err, model.ErrUserSessionLimit):
return http.StatusConflict, "AUTH_SESSION_LIMIT"
case errors.Is(err, model.ErrUserSessionIssuanceLimit):
return http.StatusTooManyRequests, "AUTH_SESSION_ISSUANCE_LIMIT"
case errors.Is(err, ErrLoginSessionMismatch):
return http.StatusConflict, "AUTH_SESSION_MISMATCH"
case errors.Is(err, ErrRefreshRace):
return http.StatusConflict, "AUTH_REFRESH_RACE"
case errors.Is(err, ErrAuthTokenExpired):
return http.StatusUnauthorized, "AUTH_TOKEN_EXPIRED"
case errors.Is(err, ErrLoginSessionRevoked):
return http.StatusUnauthorized, "AUTH_SESSION_REVOKED"
case errors.Is(err, ErrRefreshTokenInvalid), errors.Is(err, ErrAuthTokenInvalid):
return http.StatusUnauthorized, "AUTH_UNAUTHORIZED"
default:
return http.StatusInternalServerError, "AUTH_INTERNAL_ERROR"
}
}
func AuthSessionErrorCode(err error) (int, string) {
return authSessionErrorCode(err)
}
func FormatAuthError(err error) string {
if err == nil {
return ""
}
return fmt.Sprintf("authentication failed: %v", err)
}
+431
View File
@@ -0,0 +1,431 @@
package service
import (
"bytes"
"errors"
"fmt"
"strings"
"testing"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/alicebob/miniredis/v2"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/go-redis/redis/v8"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func setupAuthSessionTestDB(t *testing.T) *model.User {
t.Helper()
previousDB, previousRedis := model.DB, common.RedisEnabled
previousActiveLimit := common.UserSessionActiveLimit
previousIssuanceLimit := common.UserSessionIssuanceLimit
previousIssuanceWindow := common.UserSessionIssuanceWindowSeconds
previousRevokedRetention := common.UserSessionRevokedRetentionDays
previousAlertThreshold := common.UserSessionHourlyAlertThreshold
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
sqlDB, err := db.DB()
require.NoError(t, err)
sqlDB.SetMaxOpenConns(1)
require.NoError(t, db.AutoMigrate(&model.User{}, &model.UserSession{}, &model.AuthFlow{}))
model.DB = db
common.RedisEnabled = false
common.UserSessionActiveLimit = common.DefaultUserSessionActiveLimit
common.UserSessionIssuanceLimit = common.DefaultUserSessionIssuanceLimit
common.UserSessionIssuanceWindowSeconds = int64(common.DefaultUserSessionIssuanceWindowSeconds)
common.UserSessionRevokedRetentionDays = common.DefaultUserSessionRevokedRetentionDays
common.UserSessionHourlyAlertThreshold = common.DefaultUserSessionHourlyAlertThreshold
t.Cleanup(func() {
model.DB = previousDB
common.RedisEnabled = previousRedis
common.UserSessionActiveLimit = previousActiveLimit
common.UserSessionIssuanceLimit = previousIssuanceLimit
common.UserSessionIssuanceWindowSeconds = previousIssuanceWindow
common.UserSessionRevokedRetentionDays = previousRevokedRetention
common.UserSessionHourlyAlertThreshold = previousAlertThreshold
_ = sqlDB.Close()
})
user := &model.User{
Username: "session-user",
Password: "unused-password-hash",
Role: common.RoleCommonUser,
Status: common.UserStatusEnabled,
Group: "default",
AuthVersion: 1,
}
require.NoError(t, db.Create(user).Error)
return user
}
func useIndependentAuthSessionRedis(t *testing.T) (*miniredis.Miniredis, *redis.Client, *miniredis.Miniredis, *redis.Client) {
t.Helper()
previousRedisEnabled := common.RedisEnabled
previousRDB := common.RDB
previousSyncFrequency := common.SyncFrequency
serverA := miniredis.RunT(t)
serverB := miniredis.RunT(t)
clientA := redis.NewClient(&redis.Options{Addr: serverA.Addr()})
clientB := redis.NewClient(&redis.Options{Addr: serverB.Addr()})
common.RedisEnabled = true
common.SyncFrequency = 2
common.RDB = clientA
t.Cleanup(func() {
_ = clientA.Close()
_ = clientB.Close()
common.RedisEnabled = previousRedisEnabled
common.RDB = previousRDB
common.SyncFrequency = previousSyncFrequency
})
return serverA, clientA, serverB, clientB
}
func cachedLoginSessionKey(t *testing.T, server *miniredis.Miniredis) string {
t.Helper()
for _, key := range server.Keys() {
if strings.HasPrefix(key, "auth:session:") {
return key
}
}
require.FailNow(t, "login session was not cached")
return ""
}
func TestCreateLoginSessionEnforcesActiveLimitAcrossAuthVersions(t *testing.T) {
useTestSessionSecret(t)
user := setupAuthSessionTestDB(t)
common.UserSessionActiveLimit = 50
common.UserSessionIssuanceLimit = 100
now := time.Now().Unix()
rows := make([]model.UserSession, 0, 49)
for i := range 49 {
authVersion := user.AuthVersion
if i == 0 {
authVersion++
}
rows = append(rows, model.UserSession{
SID: fmt.Sprintf("active-limit-%02d", i),
UserID: user.Id,
Version: 1,
UserAuthVersion: authVersion,
Status: model.UserSessionStatusActive,
RefreshHash: fmt.Sprintf("hash-%02d", i),
LoginMethod: "password",
CreatedAt: now - int64(i),
LastActiveAt: now - int64(i),
ExpiresAt: now + 3600,
})
}
require.NoError(t, model.DB.Create(&rows).Error)
_, err := CreateLoginSession(user.Id, "password", "127.0.0.1", "test-agent")
require.NoError(t, err, "49 active sessions must allow creation of the 50th")
_, err = CreateLoginSession(user.Id, "password", "127.0.0.1", "test-agent")
assert.ErrorIs(t, err, model.ErrUserSessionLimit)
var count int64
require.NoError(t, model.DB.Model(&model.UserSession{}).Count(&count).Error)
assert.Equal(t, int64(50), count)
}
func TestCreateLoginSessionEnforcesIssuanceLimitAcrossAllStatuses(t *testing.T) {
useTestSessionSecret(t)
user := setupAuthSessionTestDB(t)
common.UserSessionActiveLimit = 10
common.UserSessionIssuanceLimit = 3
common.UserSessionIssuanceWindowSeconds = 60
now := time.Now().Unix()
rows := []model.UserSession{
{
SID: "issuance-limit-revoked", UserID: user.Id, Version: 1, UserAuthVersion: user.AuthVersion + 1,
Status: model.UserSessionStatusRevoked, RefreshHash: "hash-revoked", LoginMethod: "password",
CreatedAt: now - 2, LastActiveAt: now - 2, ExpiresAt: now + 3600, RevokedAt: now - 1,
},
{
SID: "issuance-limit-expired", UserID: user.Id, Version: 1, UserAuthVersion: user.AuthVersion,
Status: model.UserSessionStatusActive, RefreshHash: "hash-expired", LoginMethod: "password",
CreatedAt: now - 1, LastActiveAt: now - 1, ExpiresAt: now - 1,
},
{
SID: "issuance-outside-effective-window", UserID: user.Id, Version: 1, UserAuthVersion: user.AuthVersion,
Status: model.UserSessionStatusRevoked, RefreshHash: "hash-outside", LoginMethod: "password",
CreatedAt: now - 61, LastActiveAt: now - 61, ExpiresAt: now + 3600, RevokedAt: now - 60,
},
}
require.NoError(t, model.DB.Create(&rows).Error)
_, err := CreateLoginSession(user.Id, "password", "127.0.0.1", "test-agent")
require.NoError(t, err, "rows outside the effective issuance window must not consume the limit")
_, err = CreateLoginSession(user.Id, "password", "127.0.0.1", "test-agent")
assert.ErrorIs(t, err, model.ErrUserSessionIssuanceLimit)
var count int64
require.NoError(t, model.DB.Model(&model.UserSession{}).Count(&count).Error)
assert.Equal(t, int64(4), count)
}
func TestPasswordResetDoesNotClearSessionIssuanceHistory(t *testing.T) {
useTestSessionSecret(t)
user := setupAuthSessionTestDB(t)
common.UserSessionActiveLimit = 50
common.UserSessionIssuanceLimit = 1
email := "session-reset@example.com"
require.NoError(t, model.DB.Model(user).Update("email", email).Error)
_, err := CreateLoginSession(user.Id, "password", "127.0.0.1", "test-agent")
require.NoError(t, err)
require.NoError(t, model.ResetUserPasswordByEmail(email, "new-password"))
_, err = CreateLoginSession(user.Id, "password", "127.0.0.1", "test-agent")
assert.ErrorIs(t, err, model.ErrUserSessionIssuanceLimit)
}
func TestCreateLoginSessionFailsClosedWhenLimitCountFails(t *testing.T) {
useTestSessionSecret(t)
user := setupAuthSessionTestDB(t)
forcedErr := errors.New("forced session count failure")
callbackName := "test:fail_user_session_limit_count"
callbackRegistered := true
require.NoError(t, model.DB.Callback().Query().Before("gorm:query").Register(callbackName, func(tx *gorm.DB) {
if tx.Statement != nil && tx.Statement.Table == "user_sessions" {
tx.AddError(forcedErr)
}
}))
t.Cleanup(func() {
if callbackRegistered {
_ = model.DB.Callback().Query().Remove(callbackName)
}
})
_, err := CreateLoginSession(user.Id, "password", "127.0.0.1", "test-agent")
assert.ErrorIs(t, err, forcedErr)
require.NoError(t, model.DB.Callback().Query().Remove(callbackName))
callbackRegistered = false
var count int64
require.NoError(t, model.DB.Model(&model.UserSession{}).Count(&count).Error)
assert.Zero(t, count)
}
func TestCleanupAuthArtifactsAlertsBeforeDeletingHourlyIssuance(t *testing.T) {
setupAuthSessionTestDB(t)
common.UserSessionHourlyAlertThreshold = 2
common.UserSessionIssuanceWindowSeconds = 1
now := time.Now()
boundaryRows := make([]model.UserSession, 0, 2)
for i := range 2 {
boundaryRows = append(boundaryRows, model.UserSession{
SID: "hourly-boundary-" + string(rune('a'+i)), UserID: 1, Version: 1, UserAuthVersion: 1,
Status: model.UserSessionStatusActive, RefreshHash: "hash", LoginMethod: "password",
CreatedAt: now.Add(-2 * time.Second).Unix(), LastActiveAt: now.Add(-time.Hour).Unix(), ExpiresAt: now.Add(-time.Minute).Unix(),
})
}
require.NoError(t, model.DB.Create(&boundaryRows).Error)
var logBuffer bytes.Buffer
common.LogWriterMu.Lock()
previousErrorWriter := gin.DefaultErrorWriter
gin.DefaultErrorWriter = &logBuffer
common.LogWriterMu.Unlock()
t.Cleanup(func() {
common.LogWriterMu.Lock()
gin.DefaultErrorWriter = previousErrorWriter
common.LogWriterMu.Unlock()
})
cleanupAuthArtifacts()
assert.Empty(t, logBuffer.String(), "the hourly alert uses a strict greater-than threshold")
var count int64
require.NoError(t, model.DB.Model(&model.UserSession{}).Count(&count).Error)
assert.Zero(t, count)
exceededRows := make([]model.UserSession, 0, 3)
for i := range 3 {
exceededRows = append(exceededRows, model.UserSession{
SID: "hourly-exceeded-" + string(rune('a'+i)), UserID: 1, Version: 1, UserAuthVersion: 1,
Status: model.UserSessionStatusActive, RefreshHash: "hash", LoginMethod: "password",
CreatedAt: now.Add(-2 * time.Second).Unix(), LastActiveAt: now.Add(-time.Hour).Unix(), ExpiresAt: now.Add(-time.Minute).Unix(),
})
}
require.NoError(t, model.DB.Create(&exceededRows).Error)
logBuffer.Reset()
cleanupAuthArtifacts()
assert.Contains(t, logBuffer.String(), "hourly user session issuance exceeded alert threshold")
require.NoError(t, model.DB.Model(&model.UserSession{}).Count(&count).Error)
assert.Zero(t, count, "alerting must happen before expired rows are deleted")
}
func TestCleanupAuthArtifactsRemovesOnlyExpiredRecords(t *testing.T) {
setupAuthSessionTestDB(t)
now := time.Now()
oldExpiry := now.Add(-25 * time.Hour)
require.NoError(t, model.DB.Create(&model.UserSession{
SID: "expired-session", UserID: 1, Version: 1, UserAuthVersion: 1,
Status: model.UserSessionStatusActive, RefreshHash: "hash", LoginMethod: "password",
CreatedAt: oldExpiry.Unix(), LastActiveAt: oldExpiry.Unix(), ExpiresAt: oldExpiry.Unix(),
}).Error)
require.NoError(t, model.DB.Create(&model.AuthFlow{
TokenHash: "expired-flow", Purpose: model.AuthFlowPurposeTwoFALogin,
ExpiresAt: oldExpiry,
}).Error)
require.NoError(t, model.DB.Create(&model.AuthFlow{
TokenHash: "recent-flow", Purpose: model.AuthFlowPurposeTwoFALogin,
ExpiresAt: now.Add(time.Minute),
}).Error)
cleanupAuthArtifacts()
var sessionCount int64
require.NoError(t, model.DB.Model(&model.UserSession{}).Count(&sessionCount).Error)
assert.Zero(t, sessionCount)
var flows []model.AuthFlow
require.NoError(t, model.DB.Find(&flows).Error)
require.Len(t, flows, 1)
assert.Equal(t, "recent-flow", flows[0].TokenHash)
}
func TestCleanupAuthArtifactsContinuesWithRevokedCleanupAfterExpiredBatchFailure(t *testing.T) {
setupAuthSessionTestDB(t)
now := time.Now()
oldCreatedAt := now.Add(-8 * 24 * time.Hour).Unix()
require.NoError(t, model.DB.Create(&[]model.UserSession{
{
SID: "failed-expired-cleanup", UserID: 1, Version: 1, UserAuthVersion: 1,
Status: model.UserSessionStatusActive, RefreshHash: "hash-expired", LoginMethod: "password",
CreatedAt: oldCreatedAt, LastActiveAt: oldCreatedAt, ExpiresAt: now.Add(-time.Minute).Unix(),
},
{
SID: "independent-revoked-cleanup", UserID: 1, Version: 1, UserAuthVersion: 1,
Status: model.UserSessionStatusRevoked, RefreshHash: "hash-revoked", LoginMethod: "password",
CreatedAt: oldCreatedAt, LastActiveAt: oldCreatedAt, ExpiresAt: now.Add(time.Hour).Unix(),
RevokedAt: now.Add(-8 * 24 * time.Hour).Unix(),
},
}).Error)
forcedErr := errors.New("forced expired cleanup failure")
callbackName := "test:fail_first_user_session_cleanup_batch"
failedFirstDelete := false
require.NoError(t, model.DB.Callback().Delete().Before("gorm:delete").Register(callbackName, func(tx *gorm.DB) {
if tx.Statement != nil && tx.Statement.Table == "user_sessions" && !failedFirstDelete {
failedFirstDelete = true
tx.AddError(forcedErr)
}
}))
t.Cleanup(func() { _ = model.DB.Callback().Delete().Remove(callbackName) })
cleanupAuthArtifacts()
var expired model.UserSession
require.NoError(t, model.DB.First(&expired, "sid = ?", "failed-expired-cleanup").Error)
var revoked model.UserSession
assert.ErrorIs(t, model.DB.First(&revoked, "sid = ?", "independent-revoked-cleanup").Error, gorm.ErrRecordNotFound)
}
func TestLoginSessionCreateRefreshAndRevoke(t *testing.T) {
useTestSessionSecret(t)
user := setupAuthSessionTestDB(t)
bundle, err := CreateLoginSession(user.Id, "password", "127.0.0.1", "test-agent")
require.NoError(t, err)
assert.NotEmpty(t, bundle.RefreshToken)
identity, err := ParseAccessToken(bundle.AccessToken)
require.NoError(t, err)
_, cachedUser, err := ValidateLoginSession(identity)
require.NoError(t, err)
assert.Equal(t, user.Id, cachedUser.Id)
require.NoError(t, RevokeByRefreshToken(bundle.Session.SID+".wrong-refresh-secret", "", "logout"))
_, _, err = ValidateLoginSession(identity)
require.NoError(t, err, "a caller that only knows sid must not be able to revoke the session")
refreshed, _, err := RefreshLoginSession(bundle.RefreshToken, bundle.Session.SID, "127.0.0.2", "test-agent-2")
require.NoError(t, err)
assert.NotEqual(t, bundle.RefreshToken, refreshed.RefreshToken)
recovered, _, err := RefreshLoginSession(bundle.RefreshToken, bundle.Session.SID, "127.0.0.2", "test-agent-2")
require.NoError(t, err)
assert.Equal(t, refreshed.RefreshToken, recovered.RefreshToken, "a concurrent refresh must recover the winner's rotated token")
_, _, err = RefreshLoginSession(refreshed.RefreshToken, "different-session", "127.0.0.2", "test-agent-2")
assert.ErrorIs(t, err, ErrLoginSessionMismatch)
require.NoError(t, RevokeByRefreshToken(refreshed.RefreshToken, refreshed.Session.SID, "logout"))
_, _, err = ValidateLoginSession(identity)
assert.True(t, errors.Is(err, ErrLoginSessionRevoked))
}
func TestIndependentRedisSessionRevokeConvergesAfterCacheTTL(t *testing.T) {
useTestSessionSecret(t)
user := setupAuthSessionTestDB(t)
_, clientA, serverB, clientB := useIndependentAuthSessionRedis(t)
common.RDB = clientA
bundle, err := CreateLoginSession(user.Id, "password", "127.0.0.1", "node-a")
require.NoError(t, err)
identity, err := ParseAccessToken(bundle.AccessToken)
require.NoError(t, err)
common.RDB = clientB
_, _, err = ValidateLoginSession(identity)
require.NoError(t, err)
assert.NotEmpty(t, cachedLoginSessionKey(t, serverB), "node B must hold its own session cache entry")
common.RDB = clientA
require.NoError(t, RevokeByRefreshToken(bundle.RefreshToken, bundle.Session.SID, "logout"))
serverB.FastForward(3 * time.Second)
common.RDB = clientB
_, _, err = ValidateLoginSession(identity)
assert.ErrorIs(t, err, ErrLoginSessionRevoked)
}
func TestIndependentRedisAuthVersionAdvanceConvergesAfterCacheTTL(t *testing.T) {
useTestSessionSecret(t)
user := setupAuthSessionTestDB(t)
_, clientA, serverB, clientB := useIndependentAuthSessionRedis(t)
common.RDB = clientA
bundle, err := CreateLoginSession(user.Id, "password", "127.0.0.1", "node-a")
require.NoError(t, err)
oldIdentity, err := ParseAccessToken(bundle.AccessToken)
require.NoError(t, err)
common.RDB = clientB
_, _, err = ValidateLoginSession(oldIdentity)
require.NoError(t, err)
cacheKey := cachedLoginSessionKey(t, serverB)
version := serverB.HGet(cacheKey, "Version")
assert.Equal(t, "1", version, "node B must hold the pre-rotation session version")
common.RDB = clientA
rotated, err := AdvanceCurrentSessionSecurity(oldIdentity, "security_update")
require.NoError(t, err)
newIdentity, err := ParseAccessToken(rotated.AccessToken)
require.NoError(t, err)
assert.Greater(t, newIdentity.SessionVersion, oldIdentity.SessionVersion)
assert.Greater(t, newIdentity.UserAuthVersion, oldIdentity.UserAuthVersion)
serverB.FastForward(3 * time.Second)
common.RDB = clientB
_, _, err = ValidateLoginSession(newIdentity)
require.NoError(t, err)
_, _, err = ValidateLoginSession(oldIdentity)
assert.ErrorIs(t, err, ErrLoginSessionRevoked)
}
func TestUserAuthVersionInvalidatesExistingSession(t *testing.T) {
useTestSessionSecret(t)
user := setupAuthSessionTestDB(t)
bundle, err := CreateLoginSession(user.Id, "password", "127.0.0.1", "test-agent")
require.NoError(t, err)
identity, err := ParseAccessToken(bundle.AccessToken)
require.NoError(t, err)
_, err = model.BumpUserAuthVersion(user.Id)
require.NoError(t, err)
_, _, err = ValidateLoginSession(identity)
assert.ErrorIs(t, err, ErrLoginSessionRevoked)
_, err = CreateLoginSessionAtAuthVersion(user.Id, identity.UserAuthVersion, "2fa", "127.0.0.1", "test-agent")
assert.ErrorIs(t, err, ErrLoginSessionRevoked, "a pending 2FA flow must not survive an auth-version change")
}
+205
View File
@@ -0,0 +1,205 @@
package service
import (
"crypto/hmac"
"crypto/sha256"
"errors"
"fmt"
"slices"
"strconv"
"strings"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
)
const (
AccessTokenTTL = 15 * time.Minute
SecurityProofTTL = time.Minute
LoginSessionTTL = 30 * 24 * time.Hour
RefreshReplayWindow = 30 * time.Second
accessTokenUse = "access"
securityProofTokenUse = "security_proof"
authTokenIssuer = "new-api"
authTokenAudience = "new-api-dashboard"
)
var (
ErrAuthTokenInvalid = errors.New("authentication token is invalid")
ErrAuthTokenExpired = errors.New("authentication token has expired")
ErrProofScope = errors.New("security proof scope mismatch")
ErrProofMethod = errors.New("security proof method mismatch")
ErrProofContext = errors.New("security proof context mismatch")
ErrProofConsumed = errors.New("security proof has already been consumed")
)
// AuthIdentity is the server-validated identity attached to dashboard requests.
// Role, status and group are deliberately loaded from the user cache instead of JWT claims.
type AuthIdentity = model.AuthSessionIdentity
type authClaims struct {
TokenUse string `json:"token_use"`
SessionID string `json:"sid"`
UserAuthVersion int64 `json:"uv"`
SessionVersion int64 `json:"sv"`
Method string `json:"method,omitempty"`
Scopes []string `json:"scopes,omitempty"`
ContextHash string `json:"context_hash,omitempty"`
jwt.RegisteredClaims
}
func authSigningKey(purpose string) []byte {
mac := hmac.New(sha256.New, []byte(common.SessionSecret))
_, _ = mac.Write([]byte("new-api/auth/" + purpose + "/v1"))
return mac.Sum(nil)
}
func IssueAccessToken(identity AuthIdentity) (string, int64, error) {
if identity.UserID <= 0 || identity.SessionID == "" || identity.UserAuthVersion <= 0 || identity.SessionVersion <= 0 {
return "", 0, ErrAuthTokenInvalid
}
now := time.Now()
expiresAt := now.Add(AccessTokenTTL)
claims := authClaims{
TokenUse: accessTokenUse,
SessionID: identity.SessionID,
UserAuthVersion: identity.UserAuthVersion,
SessionVersion: identity.SessionVersion,
RegisteredClaims: jwt.RegisteredClaims{
Issuer: authTokenIssuer,
Subject: strconv.Itoa(identity.UserID),
Audience: jwt.ClaimStrings{authTokenAudience},
ExpiresAt: jwt.NewNumericDate(expiresAt),
NotBefore: jwt.NewNumericDate(now.Add(-5 * time.Second)),
IssuedAt: jwt.NewNumericDate(now),
ID: uuid.NewString(),
},
}
signed, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(authSigningKey(accessTokenUse))
return signed, expiresAt.Unix(), err
}
func ParseAccessToken(raw string) (AuthIdentity, error) {
claims, err := parseAuthClaims(raw, accessTokenUse, authSigningKey(accessTokenUse))
if err != nil {
return AuthIdentity{}, err
}
userID, err := strconv.Atoi(claims.Subject)
if err != nil || userID <= 0 || claims.SessionID == "" || claims.UserAuthVersion <= 0 || claims.SessionVersion <= 0 {
return AuthIdentity{}, ErrAuthTokenInvalid
}
return AuthIdentity{
UserID: userID,
SessionID: claims.SessionID,
UserAuthVersion: claims.UserAuthVersion,
SessionVersion: claims.SessionVersion,
}, nil
}
// ParseDashboardAccessToken distinguishes new-api dashboard JWTs from opaque
// credentials. A token carrying the dashboard issuer, audience and a known
// token use is always treated as internal, even when its signature, lifetime
// or requested purpose is invalid, so it can never fall through to PAT or
// relay-token authentication.
func ParseDashboardAccessToken(raw string) (identity AuthIdentity, internal bool, err error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return AuthIdentity{}, false, nil
}
claims := &authClaims{}
parsed, _, parseErr := jwt.NewParser().ParseUnverified(raw, claims)
if parseErr != nil || parsed == nil {
return AuthIdentity{}, false, nil
}
audienceMatches := slices.Contains(claims.Audience, authTokenAudience)
knownTokenUse := claims.TokenUse == accessTokenUse || claims.TokenUse == securityProofTokenUse
if claims.Issuer != authTokenIssuer || !audienceMatches || !knownTokenUse {
return AuthIdentity{}, false, nil
}
identity, err = ParseAccessToken(raw)
return identity, true, err
}
func IssueSecurityProof(identity AuthIdentity, method string, binding VerificationBinding) (string, int64, error) {
method = strings.TrimSpace(method)
if identity.UserID <= 0 || identity.SessionID == "" || identity.UserAuthVersion <= 0 || identity.SessionVersion <= 0 || method == "" || binding.Scope == "" || binding.ContextHash == "" {
return "", 0, ErrAuthTokenInvalid
}
now := time.Now()
expiresAt := now.Add(SecurityProofTTL).Truncate(time.Second)
proofID, _, err := model.CreateAuthFlow(model.AuthFlowCreate{
Purpose: model.AuthFlowPurposeSecurityProof, UserId: identity.UserID,
SessionId: identity.SessionID, ExpiresAt: expiresAt,
})
if err != nil {
return "", 0, err
}
claims := authClaims{
TokenUse: securityProofTokenUse,
SessionID: identity.SessionID,
UserAuthVersion: identity.UserAuthVersion,
SessionVersion: identity.SessionVersion,
Method: method,
Scopes: []string{binding.Scope},
ContextHash: binding.ContextHash,
RegisteredClaims: jwt.RegisteredClaims{
Issuer: authTokenIssuer,
Subject: strconv.Itoa(identity.UserID),
Audience: jwt.ClaimStrings{authTokenAudience},
ExpiresAt: jwt.NewNumericDate(expiresAt),
NotBefore: jwt.NewNumericDate(now.Add(-5 * time.Second)),
IssuedAt: jwt.NewNumericDate(now),
ID: proofID,
},
}
signed, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(authSigningKey(securityProofTokenUse))
return signed, expiresAt.Unix(), err
}
func verifySecurityProof(raw string, identity AuthIdentity, binding VerificationBinding) (*authClaims, error) {
claims, err := parseAuthClaims(raw, securityProofTokenUse, authSigningKey(securityProofTokenUse))
if err != nil {
return nil, err
}
userID, err := strconv.Atoi(claims.Subject)
if err != nil || userID != identity.UserID || claims.SessionID != identity.SessionID || claims.UserAuthVersion != identity.UserAuthVersion || claims.SessionVersion != identity.SessionVersion {
return nil, ErrAuthTokenInvalid
}
if len(claims.Scopes) != 1 || claims.Scopes[0] != binding.Scope {
return nil, ErrProofScope
}
if claims.ContextHash == "" {
return nil, ErrAuthTokenInvalid
}
if !hmac.Equal([]byte(claims.ContextHash), []byte(binding.ContextHash)) {
return nil, ErrProofContext
}
return claims, nil
}
func parseAuthClaims(raw, expectedUse string, key []byte) (*authClaims, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, ErrAuthTokenInvalid
}
claims := &authClaims{}
parsed, err := jwt.ParseWithClaims(raw, claims, func(token *jwt.Token) (any, error) {
if token.Method.Alg() != jwt.SigningMethodHS256.Alg() {
return nil, fmt.Errorf("%w: unexpected signing method", ErrAuthTokenInvalid)
}
return key, nil
}, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}), jwt.WithIssuer(authTokenIssuer), jwt.WithAudience(authTokenAudience), jwt.WithExpirationRequired(), jwt.WithIssuedAt(), jwt.WithLeeway(5*time.Second))
if err != nil {
if errors.Is(err, jwt.ErrTokenExpired) {
return nil, ErrAuthTokenExpired
}
return nil, fmt.Errorf("%w: %v", ErrAuthTokenInvalid, err)
}
if !parsed.Valid || claims.TokenUse != expectedUse || claims.ID == "" || claims.IssuedAt == nil || claims.NotBefore == nil {
return nil, ErrAuthTokenInvalid
}
return claims, nil
}
+162
View File
@@ -0,0 +1,162 @@
package service
import (
"errors"
"testing"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/golang-jwt/jwt/v5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func useTestSessionSecret(t *testing.T) {
t.Helper()
previous := common.SessionSecret
common.SessionSecret = "test-session-secret-with-sufficient-entropy"
t.Cleanup(func() { common.SessionSecret = previous })
}
func TestAccessTokenRoundTripAndPurposeIsolation(t *testing.T) {
setupAuthSessionTestDB(t)
useTestSessionSecret(t)
identity := AuthIdentity{UserID: 42, SessionID: "session-1", UserAuthVersion: 3, SessionVersion: 2}
token, expiresAt, err := IssueAccessToken(identity)
require.NoError(t, err)
assert.Positive(t, expiresAt)
parsed, err := ParseAccessToken(token)
require.NoError(t, err)
assert.Equal(t, identity, parsed)
binding, err := BindVerificationOperation(VerificationOperation{Scope: "channel.key.read", Context: []byte(`{"channel_id":123}`)})
require.NoError(t, err)
proof, _, err := IssueSecurityProof(identity, "2fa", binding)
require.NoError(t, err)
_, err = ParseAccessToken(proof)
assert.ErrorIs(t, err, ErrAuthTokenInvalid)
}
func TestAccessTokenRejectsTampering(t *testing.T) {
useTestSessionSecret(t)
identity := AuthIdentity{UserID: 42, SessionID: "session-1", UserAuthVersion: 1, SessionVersion: 1}
token, _, err := IssueAccessToken(identity)
require.NoError(t, err)
tamperAt := len(token) - 2
replacement := "x"
if token[tamperAt] == 'x' {
replacement = "y"
}
tampered := token[:tamperAt] + replacement + token[tamperAt+1:]
_, err = ParseAccessToken(tampered)
assert.ErrorIs(t, err, ErrAuthTokenInvalid)
_, internal, err := ParseDashboardAccessToken(tampered)
assert.True(t, internal)
assert.ErrorIs(t, err, ErrAuthTokenInvalid)
}
func TestDashboardAccessTokenClassification(t *testing.T) {
setupAuthSessionTestDB(t)
useTestSessionSecret(t)
identity, internal, err := ParseDashboardAccessToken("opaque.key.with-dots")
require.NoError(t, err)
assert.False(t, internal)
assert.Empty(t, identity)
external := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"iss": "external-issuer",
"aud": authTokenAudience,
"exp": time.Now().Add(time.Minute).Unix(),
})
externalRaw, err := external.SignedString([]byte("external-secret"))
require.NoError(t, err)
_, internal, err = ParseDashboardAccessToken(externalRaw)
require.NoError(t, err)
assert.False(t, internal)
unknownUse := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"iss": authTokenIssuer,
"aud": authTokenAudience,
"token_use": "third_party",
"exp": time.Now().Add(time.Minute).Unix(),
})
unknownUseRaw, err := unknownUse.SignedString([]byte("external-secret"))
require.NoError(t, err)
_, internal, err = ParseDashboardAccessToken(unknownUseRaw)
require.NoError(t, err)
assert.False(t, internal)
binding, err := BindVerificationOperation(VerificationOperation{Scope: "channel.key.read", Context: []byte(`{"channel_id":123}`)})
require.NoError(t, err)
proof, _, err := IssueSecurityProof(AuthIdentity{
UserID: 42, SessionID: "session-1", UserAuthVersion: 1, SessionVersion: 1,
}, "2fa", binding)
require.NoError(t, err)
_, internal, err = ParseDashboardAccessToken(proof)
assert.True(t, internal)
assert.ErrorIs(t, err, ErrAuthTokenInvalid)
expiredClaims := authClaims{
TokenUse: accessTokenUse,
SessionID: "expired-session",
UserAuthVersion: 1,
SessionVersion: 1,
RegisteredClaims: jwt.RegisteredClaims{
Issuer: authTokenIssuer,
Subject: "42",
Audience: jwt.ClaimStrings{authTokenAudience},
ExpiresAt: jwt.NewNumericDate(time.Now().Add(-time.Minute)),
NotBefore: jwt.NewNumericDate(time.Now().Add(-2 * time.Minute)),
IssuedAt: jwt.NewNumericDate(time.Now().Add(-2 * time.Minute)),
ID: "expired-token",
},
}
expired, err := jwt.NewWithClaims(jwt.SigningMethodHS256, expiredClaims).SignedString(authSigningKey(accessTokenUse))
require.NoError(t, err)
_, internal, err = ParseDashboardAccessToken(expired)
assert.True(t, internal)
assert.ErrorIs(t, err, ErrAuthTokenExpired)
}
func TestSecurityProofBindsIdentityAndOperation(t *testing.T) {
setupAuthSessionTestDB(t)
useTestSessionSecret(t)
identity := AuthIdentity{UserID: 42, SessionID: "session-1", UserAuthVersion: 3, SessionVersion: 2}
operation := VerificationOperation{Scope: "channel.key.read", Context: []byte(`{"channel_id":123}`)}
binding, err := BindVerificationOperation(operation)
require.NoError(t, err)
proof, _, err := IssueSecurityProof(identity, "2fa", binding)
require.NoError(t, err)
claims, err := verifySecurityProof(proof, identity, binding)
require.NoError(t, err)
assert.Equal(t, "2fa", claims.Method)
wrongScope, err := BindVerificationOperation(VerificationOperation{Scope: "passkey.delete"})
require.NoError(t, err)
_, err = verifySecurityProof(proof, identity, wrongScope)
assert.ErrorIs(t, err, ErrProofScope)
otherChannel, err := BindVerificationOperation(VerificationOperation{Scope: "channel.key.read", Context: []byte(`{"channel_id":456}`)})
require.NoError(t, err)
_, err = verifySecurityProof(proof, identity, otherChannel)
assert.ErrorIs(t, err, ErrProofContext)
otherSession := identity
otherSession.SessionID = "session-2"
_, err = verifySecurityProof(proof, otherSession, binding)
assert.True(t, errors.Is(err, ErrAuthTokenInvalid))
claims, err = parseAuthClaims(proof, securityProofTokenUse, authSigningKey(securityProofTokenUse))
require.NoError(t, err)
claims.ExpiresAt = jwt.NewNumericDate(time.Now().Add(-time.Minute))
expired, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(authSigningKey(securityProofTokenUse))
require.NoError(t, err)
_, err = ConsumeOperationProof(expired, identity, operation)
assert.ErrorIs(t, err, ErrAuthTokenExpired)
}
+121
View File
@@ -0,0 +1,121 @@
package authz
import (
"strings"
"github.com/QuantumNous/new-api/model"
casbinmodel "github.com/casbin/casbin/v2/model"
"github.com/casbin/casbin/v2/persist"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type gormAdapter struct {
db *gorm.DB
}
func newGormAdapter(db *gorm.DB) *gormAdapter {
return &gormAdapter{db: db}
}
func (a *gormAdapter) LoadPolicy(m casbinmodel.Model) error {
var rules []model.CasbinRule
if err := a.db.Order("id asc").Find(&rules).Error; err != nil {
return err
}
for _, rule := range rules {
if err := persist.LoadPolicyLine(ruleToLine(rule), m); err != nil {
return err
}
}
return nil
}
func (a *gormAdapter) SavePolicy(m casbinmodel.Model) error {
return a.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("1 = 1").Delete(&model.CasbinRule{}).Error; err != nil {
return err
}
rules := make([]model.CasbinRule, 0)
for ptype, ast := range m["p"] {
for _, policy := range ast.Policy {
rules = append(rules, newRule(ptype, policy))
}
}
for ptype, ast := range m["g"] {
for _, policy := range ast.Policy {
rules = append(rules, newRule(ptype, policy))
}
}
if len(rules) == 0 {
return nil
}
return tx.Create(&rules).Error
})
}
func (a *gormAdapter) AddPolicy(_ string, ptype string, rule []string) error {
casbinRule := newRule(ptype, rule)
var count int64
if err := a.ruleQuery(a.db.Model(&model.CasbinRule{}), ptype, rule).Count(&count).Error; err != nil {
return err
}
if count > 0 {
return nil
}
return a.db.Clauses(clause.OnConflict{DoNothing: true}).Create(&casbinRule).Error
}
func (a *gormAdapter) RemovePolicy(_ string, ptype string, rule []string) error {
return a.ruleQuery(a.db, ptype, rule).Delete(&model.CasbinRule{}).Error
}
func (a *gormAdapter) RemoveFilteredPolicy(_ string, ptype string, fieldIndex int, fieldValues ...string) error {
query := a.db.Where("ptype = ?", ptype)
for i, value := range fieldValues {
if value == "" {
continue
}
query = query.Where("v"+string(rune('0'+fieldIndex+i))+" = ?", value)
}
return query.Delete(&model.CasbinRule{}).Error
}
func (a *gormAdapter) ruleQuery(query *gorm.DB, ptype string, rule []string) *gorm.DB {
query = query.Where("ptype = ?", ptype)
for idx := range 6 {
value := ""
if idx < len(rule) {
value = rule[idx]
}
query = query.Where("v"+string(rune('0'+idx))+" = ?", value)
}
return query
}
func newRule(ptype string, policy []string) model.CasbinRule {
rule := model.CasbinRule{Ptype: ptype}
values := []*string{&rule.V0, &rule.V1, &rule.V2, &rule.V3, &rule.V4, &rule.V5}
for idx, value := range policy {
if idx >= len(values) {
break
}
*values[idx] = value
}
return rule
}
func ruleToLine(rule model.CasbinRule) string {
parts := []string{rule.Ptype}
values := []string{rule.V0, rule.V1, rule.V2, rule.V3, rule.V4, rule.V5}
if rule.Ptype == "p" && rule.V0 != "" && rule.V1 != "" && rule.V2 != "" && rule.V3 == "" {
values[3] = EffectAllow
}
for _, value := range values {
if value == "" {
continue
}
parts = append(parts, value)
}
return strings.Join(parts, ", ")
}
+20
View File
@@ -0,0 +1,20 @@
package authz
import "github.com/QuantumNous/new-api/common"
// resolveSubjectRoles returns the role keys assigned to a subject. The mapping
// is derived from the caller's system role.
var resolveSubjectRoles = func(userID int, systemRole int) []string {
switch {
case systemRole >= common.RoleRootUser:
return []string{BuiltInRoleRoot}
case systemRole >= common.RoleAdminUser:
return []string{BuiltInRoleAdmin}
default:
return nil
}
}
// managedRoleKey is the role whose baseline per-user overrides are expressed
// relative to.
const managedRoleKey = BuiltInRoleAdmin
+273
View File
@@ -0,0 +1,273 @@
package authz
import (
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func newAuthzTestDB(t *testing.T) *gorm.DB {
t.Helper()
wasMaster := common.IsMasterNode
common.IsMasterNode = true
t.Cleanup(func() {
common.IsMasterNode = wasMaster
})
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
sqlDB, err := db.DB()
require.NoError(t, err)
sqlDB.SetMaxOpenConns(1)
require.NoError(t, db.AutoMigrate(&model.CasbinRule{}, &model.AuthzRole{}))
return db
}
func TestInitSeedsBuiltInRolesAndPoliciesOnce(t *testing.T) {
db := newAuthzTestDB(t)
require.NoError(t, Init(db))
require.NoError(t, Init(db))
// root is a superuser role and is granted everything implicitly, so only the
// admin baseline is written as explicit policy rows.
var count int64
require.NoError(t, db.Model(&model.CasbinRule{}).Count(&count).Error)
assert.Equal(t, int64(len(PermissionsForRole(BuiltInRoleAdmin))), count)
var roles []model.AuthzRole
require.NoError(t, db.Order("sort asc").Find(&roles).Error)
require.Len(t, roles, 2)
assert.Equal(t, BuiltInRoleRoot, roles[0].Key)
assert.Equal(t, BuiltInRoleAdmin, roles[1].Key)
assert.True(t, Can(1, common.RoleRootUser, ChannelSensitiveWrite))
assert.True(t, Can(2, common.RoleAdminUser, ChannelRead))
assert.True(t, Can(2, common.RoleAdminUser, ChannelOperate))
assert.True(t, Can(2, common.RoleAdminUser, ChannelWrite))
assert.False(t, Can(2, common.RoleAdminUser, ChannelSensitiveWrite))
assert.False(t, Can(3, common.RoleCommonUser, ChannelRead))
}
func TestInitOnSlaveOnlyLoadsPolicies(t *testing.T) {
wasMaster := common.IsMasterNode
common.IsMasterNode = false
t.Cleanup(func() {
common.IsMasterNode = wasMaster
})
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
sqlDB, err := db.DB()
require.NoError(t, err)
sqlDB.SetMaxOpenConns(1)
require.NoError(t, db.AutoMigrate(&model.CasbinRule{}, &model.AuthzRole{}))
require.NoError(t, Init(db))
var roleCount int64
require.NoError(t, db.Model(&model.AuthzRole{}).Count(&roleCount).Error)
assert.Equal(t, int64(0), roleCount)
var policyCount int64
require.NoError(t, db.Model(&model.CasbinRule{}).Count(&policyCount).Error)
assert.Equal(t, int64(0), policyCount)
assert.False(t, Can(2, common.RoleAdminUser, ChannelRead))
}
func TestSetUserPermissionsStoresOnlyOverrides(t *testing.T) {
db := newAuthzTestDB(t)
require.NoError(t, Init(db))
require.NoError(t, SetUserPermissions(42, PermissionsMap{
ResourceChannel: {
ActionRead: true,
ActionOperate: true,
ActionWrite: false,
ActionSensitiveWrite: true,
ActionSecretView: false,
"unknown": true,
},
"unknown": {
ActionRead: true,
},
}))
assert.True(t, Can(42, common.RoleAdminUser, ChannelSensitiveWrite))
assert.False(t, Can(42, common.RoleAdminUser, ChannelWrite))
assert.Equal(t, PermissionsMap{
ResourceChannel: {
ActionRead: true,
ActionOperate: true,
ActionWrite: false,
ActionSensitiveWrite: true,
ActionSecretView: false,
},
ResourceTaskPlugin: {
ActionBind: false,
},
ResourceAudit: {ActionRead: false},
}, ExplicitUserPermissions(42))
assert.Equal(t, PermissionsMap{
ResourceChannel: {
ActionSensitiveWrite: true,
ActionWrite: false,
},
}, ExplicitUserOverrides(42))
var userPolicyCount int64
require.NoError(t, db.Model(&model.CasbinRule{}).Where("v0 = ?", UserSubject(42)).Count(&userPolicyCount).Error)
assert.Equal(t, int64(2), userPolicyCount)
require.NoError(t, SetUserPermissions(42, PermissionsMap{ResourceChannel: {
ActionRead: true,
ActionOperate: true,
ActionWrite: true,
ActionSensitiveWrite: false,
ActionSecretView: false,
}}))
assert.False(t, Can(42, common.RoleAdminUser, ChannelSensitiveWrite))
assert.Equal(t, PermissionsMap{
ResourceChannel: {
ActionRead: true,
ActionOperate: true,
ActionWrite: true,
ActionSensitiveWrite: false,
ActionSecretView: false,
},
ResourceTaskPlugin: {
ActionBind: false,
},
ResourceAudit: {ActionRead: false},
}, ExplicitUserPermissions(42))
assert.Empty(t, ExplicitUserOverrides(42))
}
func TestClearUserAuthorizationRemovesOverrides(t *testing.T) {
db := newAuthzTestDB(t)
require.NoError(t, Init(db))
require.NoError(t, SetUserPermissions(90, PermissionsMap{ResourceChannel: {
ActionWrite: false,
ActionSensitiveWrite: true,
}}))
assert.True(t, Can(90, common.RoleAdminUser, ChannelSensitiveWrite))
assert.False(t, Can(90, common.RoleAdminUser, ChannelWrite))
require.NoError(t, ClearUserAuthorization(90))
assert.Empty(t, ExplicitUserOverrides(90))
assert.True(t, Can(90, common.RoleAdminUser, ChannelRead))
assert.True(t, Can(90, common.RoleAdminUser, ChannelWrite))
assert.False(t, Can(90, common.RoleAdminUser, ChannelSensitiveWrite))
assert.False(t, Can(90, common.RoleCommonUser, ChannelRead))
}
func TestSetUserPermissionsInTxDoesNotMutateEnforcerBeforeReload(t *testing.T) {
db := newAuthzTestDB(t)
require.NoError(t, Init(db))
require.NoError(t, db.Transaction(func(tx *gorm.DB) error {
return SetUserPermissionsInTx(tx, 42, PermissionsMap{ResourceChannel: {
ActionRead: true,
ActionOperate: true,
ActionWrite: true,
ActionSensitiveWrite: true,
ActionSecretView: false,
}})
}))
assert.False(t, Can(42, common.RoleAdminUser, ChannelSensitiveWrite))
require.NoError(t, ReloadPolicy())
assert.True(t, Can(42, common.RoleAdminUser, ChannelSensitiveWrite))
}
func TestSetUserPermissionsInTxRollbackLeavesNoPolicy(t *testing.T) {
db := newAuthzTestDB(t)
require.NoError(t, Init(db))
tx := db.Begin()
require.NoError(t, tx.Error)
require.NoError(t, SetUserPermissionsInTx(tx, 43, PermissionsMap{ResourceChannel: {
ActionSensitiveWrite: true,
}}))
require.NoError(t, tx.Rollback().Error)
require.NoError(t, ReloadPolicy())
assert.False(t, Can(43, common.RoleAdminUser, ChannelSensitiveWrite))
var count int64
require.NoError(t, db.Model(&model.CasbinRule{}).Where("v0 = ?", UserSubject(43)).Count(&count).Error)
assert.Equal(t, int64(0), count)
}
func TestAdapterAddPolicyIsIdempotent(t *testing.T) {
db := newAuthzTestDB(t)
adapter := newGormAdapter(db)
rule := []string{UserSubject(55), ResourceChannel, ActionSensitiveWrite, EffectAllow}
require.NoError(t, adapter.AddPolicy("p", "p", rule))
require.NoError(t, adapter.AddPolicy("p", "p", rule))
var count int64
require.NoError(t, db.Model(&model.CasbinRule{}).Where(
"ptype = ? AND v0 = ? AND v1 = ? AND v2 = ? AND v3 = ?",
"p",
UserSubject(55),
ResourceChannel,
ActionSensitiveWrite,
EffectAllow,
).Count(&count).Error)
assert.Equal(t, int64(1), count)
}
func TestCapabilitiesUseCatalogShape(t *testing.T) {
db := newAuthzTestDB(t)
require.NoError(t, Init(db))
capabilities := Capabilities(7, common.RoleAdminUser)
assert.True(t, capabilities[ResourceChannel][ActionRead])
assert.True(t, capabilities[ResourceChannel][ActionOperate])
assert.True(t, capabilities[ResourceChannel][ActionWrite])
assert.False(t, capabilities[ResourceChannel][ActionSensitiveWrite])
assert.False(t, capabilities[ResourceChannel][ActionSecretView])
assert.False(t, capabilities[ResourceTaskPlugin][ActionBind])
}
func TestTaskPluginBindIsRootOnlyUntilGranted(t *testing.T) {
db := newAuthzTestDB(t)
require.NoError(t, Init(db))
var bindAction *ActionDefinition
for _, resource := range Catalog() {
if resource.Resource != ResourceTaskPlugin {
continue
}
assert.Equal(t, "Task Plugin", resource.LabelKey)
for i := range resource.Actions {
if resource.Actions[i].Action == ActionBind {
bindAction = &resource.Actions[i]
}
}
}
require.NotNil(t, bindAction)
assert.Equal(t, "Bind task plugins", bindAction.LabelKey)
assert.Equal(t, "List registered task plugins and bind them when creating or editing task plugin channels.", bindAction.DescriptionKey)
assert.Empty(t, bindAction.DefaultRoles)
assert.False(t, Can(2, common.RoleAdminUser, TaskPluginBind))
assert.True(t, Can(1, common.RoleRootUser, TaskPluginBind))
enforcer := currentEnforcer()
require.NotNil(t, enforcer)
_, err := enforcer.AddPolicy(RoleSubject(BuiltInRoleAdmin), ResourceTaskPlugin, ActionBind, EffectAllow)
require.NoError(t, err)
assert.True(t, Can(2, common.RoleAdminUser, TaskPluginBind))
_, err = enforcer.RemovePolicy(RoleSubject(BuiltInRoleAdmin), ResourceTaskPlugin, ActionBind, EffectAllow)
require.NoError(t, err)
assert.False(t, Can(2, common.RoleAdminUser, TaskPluginBind))
}
+94
View File
@@ -0,0 +1,94 @@
package authz
import (
"fmt"
"sync"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/casbin/casbin/v2"
casbinmodel "github.com/casbin/casbin/v2/model"
"gorm.io/gorm"
)
var (
enforcerMu sync.RWMutex
enforcer *casbin.SyncedEnforcer
)
const modelText = `
[request_definition]
r = sub, obj, act
[policy_definition]
p = sub, obj, act, eft
[policy_effect]
e = some(where (p.eft == allow))
[matchers]
m = r.sub == p.sub && r.obj == p.obj && r.act == p.act && p.eft == "allow"
`
func Init(db *gorm.DB) error {
if common.IsMasterNode {
if err := seedBuiltInRoles(db); err != nil {
return err
}
if err := resetBuiltInRolePolicies(db); err != nil {
return err
}
}
m, err := casbinmodel.NewModelFromString(modelText)
if err != nil {
return err
}
e, err := casbin.NewSyncedEnforcer(m, newGormAdapter(db))
if err != nil {
return err
}
e.EnableAutoSave(true)
enforcerMu.Lock()
enforcer = e
enforcerMu.Unlock()
if !common.IsMasterNode {
return nil
}
return seedDefaultPolicies()
}
func currentEnforcer() *casbin.SyncedEnforcer {
enforcerMu.RLock()
defer enforcerMu.RUnlock()
return enforcer
}
func ReloadPolicy() error {
enforcerMu.Lock()
defer enforcerMu.Unlock()
if enforcer == nil {
return fmt.Errorf("authz enforcer is not initialized")
}
return enforcer.LoadPolicy()
}
// StartPolicySync periodically reloads the authorization policy from the database.
// The enforcer keeps an in-memory snapshot, and permission changes are written
// straight to the DB (see SetUserPermissionsInTx) with only the local node's
// snapshot refreshed afterwards. Without this loop other instances in a
// multi-node deployment would keep serving stale permissions (including not
// honoring a revoked grant) until restart. Mirrors model.SyncOptions polling.
func StartPolicySync(frequency int) {
if frequency <= 0 {
return
}
for {
time.Sleep(time.Duration(frequency) * time.Second)
if err := ReloadPolicy(); err != nil {
common.SysError("failed to reload authz policy: " + err.Error())
}
}
}
+163
View File
@@ -0,0 +1,163 @@
package authz
import (
"fmt"
"sort"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/casbin/casbin/v2"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type overridePolicy struct {
Resource string
Action string
Effect string
}
func SetUserPermissions(userID int, permissions PermissionsMap) error {
e := currentEnforcer()
if e == nil {
return fmt.Errorf("authz enforcer is not initialized")
}
for resource, actions := range permissions {
if !isKnownResource(resource) {
continue
}
if _, err := e.RemoveFilteredPolicy(0, UserSubject(userID), resource); err != nil {
return err
}
for _, policy := range userOverridePolicies(e, resource, actions) {
if _, err := e.AddPolicy(UserSubject(userID), policy.Resource, policy.Action, policy.Effect); err != nil {
return err
}
}
}
return nil
}
func SetUserPermissionsInTx(tx *gorm.DB, userID int, permissions PermissionsMap) error {
e := currentEnforcer()
if e == nil {
return fmt.Errorf("authz enforcer is not initialized")
}
for resource, actions := range permissions {
if !isKnownResource(resource) {
continue
}
if err := tx.Where("ptype = ? AND v0 = ? AND v1 = ?", "p", UserSubject(userID), resource).Delete(&model.CasbinRule{}).Error; err != nil {
return err
}
policies := userOverridePolicies(e, resource, actions)
if len(policies) == 0 {
continue
}
rules := make([]model.CasbinRule, 0, len(policies))
for _, policy := range policies {
rules = append(rules, newRule("p", []string{UserSubject(userID), policy.Resource, policy.Action, policy.Effect}))
}
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&rules).Error; err != nil {
return err
}
}
return nil
}
func ClearUserPermissions(userID int) error {
e := currentEnforcer()
if e == nil {
return fmt.Errorf("authz enforcer is not initialized")
}
for _, resource := range registry {
if _, err := e.RemoveFilteredPolicy(0, UserSubject(userID), resource.Resource); err != nil {
return err
}
}
return nil
}
func ClearUserPermissionsInTx(tx *gorm.DB, userID int) error {
for _, resource := range registry {
if err := tx.Where("ptype = ? AND v0 = ? AND v1 = ?", "p", UserSubject(userID), resource.Resource).Delete(&model.CasbinRule{}).Error; err != nil {
return err
}
}
return nil
}
func ClearUserAuthorization(userID int) error {
return ClearUserPermissions(userID)
}
func ClearUserAuthorizationInTx(tx *gorm.DB, userID int) error {
return ClearUserPermissionsInTx(tx, userID)
}
// ExplicitUserPermissions returns the effective permission matrix for the
// managed role plus any per-user overrides.
func ExplicitUserPermissions(userID int) PermissionsMap {
return Capabilities(userID, common.RoleAdminUser)
}
// ExplicitUserOverrides returns only the per-user override entries.
func ExplicitUserOverrides(userID int) PermissionsMap {
e := currentEnforcer()
if e == nil {
return PermissionsMap{}
}
result := PermissionsMap{}
for _, resource := range registry {
policies, err := e.GetFilteredPolicy(0, UserSubject(userID), resource.Resource)
if err != nil {
return PermissionsMap{}
}
actions := make(map[string]bool, len(policies))
for _, policy := range policies {
if len(policy) >= 3 && isKnownPermission(Permission{Resource: policy[1], Action: policy[2]}) {
effect := policyEffect(policy)
if effect == EffectAllow || effect == EffectDeny {
actions[policy[2]] = effect == EffectAllow
}
}
}
if len(actions) > 0 {
result[resource.Resource] = actions
}
}
return result
}
// userOverridePolicies returns the override entries that differ from the managed
// role baseline; entries matching the baseline are omitted.
func userOverridePolicies(e *casbin.SyncedEnforcer, resource string, actions map[string]bool) []overridePolicy {
overrides := make([]overridePolicy, 0, len(actions))
for _, action := range catalogActions(resource) {
desired, ok := actions[action.Action]
if !ok {
continue
}
permission := Permission{Resource: resource, Action: action.Action}
if desired == roleBaselineAllows(e, managedRoleKey, permission) {
continue
}
effect := EffectDeny
if desired {
effect = EffectAllow
}
overrides = append(overrides, overridePolicy{
Resource: resource,
Action: action.Action,
Effect: effect,
})
}
sort.Slice(overrides, func(i, j int) bool {
return overrides[i].Action < overrides[j].Action
})
return overrides
}
+27
View File
@@ -0,0 +1,27 @@
package authz
import "strconv"
// Permission identifies a single action on a resource.
type Permission struct {
Resource string
Action string
}
// PermissionsMap is a resource -> action -> allowed lookup.
type PermissionsMap map[string]map[string]bool
const (
EffectAllow = "allow"
EffectDeny = "deny"
)
// UserSubject is the casbin subject string for a single user.
func UserSubject(userID int) string {
return "user:" + strconv.Itoa(userID)
}
// RoleSubject is the casbin subject string for a role.
func RoleSubject(roleKey string) string {
return "role:" + roleKey
}
+105
View File
@@ -0,0 +1,105 @@
package authz
import "slices"
// ActionDefinition describes a single action exposed by a resource. DefaultRoles
// lists the role keys that receive this action as part of their baseline grants.
type ActionDefinition struct {
Action string `json:"action"`
LabelKey string `json:"label_key"`
DescriptionKey string `json:"description_key"`
DefaultRoles []string `json:"-"`
}
// ResourceDefinition describes a resource and the actions it exposes.
type ResourceDefinition struct {
Resource string `json:"resource"`
LabelKey string `json:"label_key"`
Actions []ActionDefinition `json:"actions"`
}
var registry []ResourceDefinition
// RegisterResource adds a resource definition to the permission registry.
func RegisterResource(resource ResourceDefinition) {
registry = append(registry, resource)
}
// Catalog returns a copy of the registered resource definitions.
func Catalog() []ResourceDefinition {
result := make([]ResourceDefinition, 0, len(registry))
for _, resource := range registry {
result = append(result, ResourceDefinition{
Resource: resource.Resource,
LabelKey: resource.LabelKey,
Actions: append([]ActionDefinition(nil), resource.Actions...),
})
}
return result
}
// AllPermissions returns every registered permission.
func AllPermissions() []Permission {
permissions := make([]Permission, 0)
for _, resource := range registry {
for _, action := range resource.Actions {
permissions = append(permissions, Permission{
Resource: resource.Resource,
Action: action.Action,
})
}
}
return permissions
}
// PermissionsForRole returns the permissions whose DefaultRoles include roleKey.
func PermissionsForRole(roleKey string) []Permission {
permissions := make([]Permission, 0)
for _, resource := range registry {
for _, action := range resource.Actions {
if actionHasRole(action, roleKey) {
permissions = append(permissions, Permission{
Resource: resource.Resource,
Action: action.Action,
})
}
}
}
return permissions
}
func actionHasRole(action ActionDefinition, roleKey string) bool {
return slices.Contains(action.DefaultRoles, roleKey)
}
func isKnownResource(resource string) bool {
for _, known := range registry {
if known.Resource == resource {
return true
}
}
return false
}
func catalogActions(resource string) []ActionDefinition {
for _, known := range registry {
if known.Resource == resource {
return known.Actions
}
}
return nil
}
func isKnownPermission(permission Permission) bool {
for _, resource := range registry {
if resource.Resource != permission.Resource {
continue
}
for _, action := range resource.Actions {
if action.Action == permission.Action {
return true
}
}
}
return false
}
+83
View File
@@ -0,0 +1,83 @@
package authz
import "slices"
import "github.com/casbin/casbin/v2"
// Can reports whether the subject may perform the permission. A superuser role
// short-circuits to allow. Otherwise a per-user override wins, then the union of
// the subject's role baselines applies.
func Can(userID int, systemRole int, permission Permission) bool {
roles := resolveSubjectRoles(userID, systemRole)
if len(roles) == 0 {
return false
}
if slices.ContainsFunc(roles, isSuperuserRole) {
return true
}
if !isKnownPermission(permission) {
return false
}
e := currentEnforcer()
if e == nil {
return false
}
if effect, ok := explicitSubjectEffect(e, UserSubject(userID), permission); ok {
return effect == EffectAllow
}
for _, role := range roles {
if roleBaselineAllows(e, role, permission) {
return true
}
}
return false
}
// Capabilities returns the full resource/action matrix the subject is allowed.
func Capabilities(userID int, systemRole int) PermissionsMap {
result := make(PermissionsMap, len(registry))
for _, resource := range registry {
actions := make(map[string]bool, len(resource.Actions))
for _, action := range resource.Actions {
actions[action.Action] = Can(userID, systemRole, Permission{
Resource: resource.Resource,
Action: action.Action,
})
}
result[resource.Resource] = actions
}
return result
}
func roleBaselineAllows(e *casbin.SyncedEnforcer, roleKey string, permission Permission) bool {
effect, ok := explicitSubjectEffect(e, RoleSubject(roleKey), permission)
return ok && effect == EffectAllow
}
func explicitSubjectEffect(e *casbin.SyncedEnforcer, subject string, permission Permission) (string, bool) {
policies, err := e.GetFilteredPolicy(0, subject, permission.Resource, permission.Action)
if err != nil {
return "", false
}
hasAllow := false
for _, policy := range policies {
switch policyEffect(policy) {
case EffectDeny:
return EffectDeny, true
case EffectAllow:
hasAllow = true
}
}
if hasAllow {
return EffectAllow, true
}
return "", false
}
func policyEffect(policy []string) string {
if len(policy) < 4 || policy[3] == "" {
return EffectAllow
}
return policy[3]
}
+17
View File
@@ -0,0 +1,17 @@
package authz
const ResourceAudit = "audit"
var AuditRead = Permission{Resource: ResourceAudit, Action: ActionRead}
func init() {
RegisterResource(ResourceDefinition{
Resource: ResourceAudit,
LabelKey: "Audit Logs",
Actions: []ActionDefinition{{
Action: ActionRead,
LabelKey: "View other accounts' audit logs",
DescriptionKey: "View audit records from user and admin roles. Root records are always excluded.",
}},
})
}
+56
View File
@@ -0,0 +1,56 @@
package authz
const (
ResourceChannel = "channel"
ActionRead = "read"
ActionOperate = "operate"
ActionWrite = "write"
ActionSensitiveWrite = "sensitive_write"
ActionSecretView = "secret_view"
)
var (
ChannelRead = Permission{Resource: ResourceChannel, Action: ActionRead}
ChannelOperate = Permission{Resource: ResourceChannel, Action: ActionOperate}
ChannelWrite = Permission{Resource: ResourceChannel, Action: ActionWrite}
ChannelSensitiveWrite = Permission{Resource: ResourceChannel, Action: ActionSensitiveWrite}
ChannelSecretView = Permission{Resource: ResourceChannel, Action: ActionSecretView}
)
func init() {
RegisterResource(ResourceDefinition{
Resource: ResourceChannel,
LabelKey: "Channel Management",
Actions: []ActionDefinition{
{
Action: ActionRead,
LabelKey: "Read channels",
DescriptionKey: "View channel lists and details without secrets.",
DefaultRoles: []string{BuiltInRoleAdmin},
},
{
Action: ActionOperate,
LabelKey: "Operate channels",
DescriptionKey: "Test channels, refresh balances, and enable/disable individual, batch, or tagged channels.",
DefaultRoles: []string{BuiltInRoleAdmin},
},
{
Action: ActionWrite,
LabelKey: "Edit channel routing",
DescriptionKey: "Edit non-sensitive settings such as models, groups, and routing rules.",
DefaultRoles: []string{BuiltInRoleAdmin},
},
{
Action: ActionSensitiveWrite,
LabelKey: "Edit sensitive channel settings",
DescriptionKey: "Create channels or edit keys, base URLs, and overrides.",
},
{
Action: ActionSecretView,
LabelKey: "View channel secrets",
DescriptionKey: "Reserved for viewing complete channel keys after secure verification.",
},
},
})
}
+23
View File
@@ -0,0 +1,23 @@
package authz
const (
ResourceTaskPlugin = "task_plugin"
ActionBind = "bind"
)
var TaskPluginBind = Permission{Resource: ResourceTaskPlugin, Action: ActionBind}
func init() {
RegisterResource(ResourceDefinition{
Resource: ResourceTaskPlugin,
LabelKey: "Task Plugin",
Actions: []ActionDefinition{
{
Action: ActionBind,
LabelKey: "Bind task plugins",
DescriptionKey: "List registered task plugins and bind them when creating or editing task plugin channels.",
},
},
})
}
+86
View File
@@ -0,0 +1,86 @@
package authz
const (
BuiltInRoleRoot = "root"
BuiltInRoleAdmin = "admin"
)
// RoleSpec describes a role. A superuser role is allowed every permission
// without an explicit policy entry.
type RoleSpec struct {
Key string
Name string
Description string
BuiltIn bool
Superuser bool
Sort int
}
var builtInRoles = []RoleSpec{
{
Key: BuiltInRoleRoot,
Name: "Root",
Description: "Built-in root authorization role",
BuiltIn: true,
Superuser: true,
Sort: 0,
},
{
Key: BuiltInRoleAdmin,
Name: "Admin",
Description: "Built-in admin authorization role",
BuiltIn: true,
Superuser: false,
Sort: 10,
},
}
// RoleDescriptor exposes a role together with its baseline grant matrix.
type RoleDescriptor struct {
Key string `json:"key"`
Name string `json:"name"`
BuiltIn bool `json:"built_in"`
Superuser bool `json:"superuser"`
Grants PermissionsMap `json:"grants"`
}
// Roles returns the role descriptors with their baseline grants.
func Roles() []RoleDescriptor {
result := make([]RoleDescriptor, 0, len(builtInRoles))
for _, spec := range builtInRoles {
result = append(result, RoleDescriptor{
Key: spec.Key,
Name: spec.Name,
BuiltIn: spec.BuiltIn,
Superuser: spec.Superuser,
Grants: roleGrants(spec),
})
}
return result
}
func roleGrants(spec RoleSpec) PermissionsMap {
grants := make(PermissionsMap, len(registry))
for _, resource := range registry {
actions := make(map[string]bool, len(resource.Actions))
for _, action := range resource.Actions {
actions[action.Action] = spec.Superuser || actionHasRole(action, spec.Key)
}
grants[resource.Resource] = actions
}
return grants
}
func roleSpec(roleKey string) (RoleSpec, bool) {
for _, spec := range builtInRoles {
if spec.Key == roleKey {
return spec, true
}
}
return RoleSpec{}, false
}
func isSuperuserRole(roleKey string) bool {
spec, ok := roleSpec(roleKey)
return ok && spec.Superuser
}
+62
View File
@@ -0,0 +1,62 @@
package authz
import (
"fmt"
"github.com/QuantumNous/new-api/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
func seedBuiltInRoles(db *gorm.DB) error {
for _, spec := range builtInRoles {
role := model.AuthzRole{
Key: spec.Key,
Name: spec.Name,
Description: spec.Description,
BuiltIn: spec.BuiltIn,
Enabled: true,
Sort: spec.Sort,
}
if err := db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "key"}},
DoUpdates: clause.AssignmentColumns([]string{
"name",
"description",
"built_in",
"enabled",
"sort",
}),
}).Create(&role).Error; err != nil {
return err
}
}
return nil
}
func resetBuiltInRolePolicies(db *gorm.DB) error {
subjects := make([]string, 0, len(builtInRoles))
for _, spec := range builtInRoles {
subjects = append(subjects, RoleSubject(spec.Key))
}
return db.Where("ptype = ? AND v0 IN ?", "p", subjects).Delete(&model.CasbinRule{}).Error
}
func seedDefaultPolicies() error {
e := currentEnforcer()
if e == nil {
return fmt.Errorf("authz enforcer is not initialized")
}
for _, spec := range builtInRoles {
if spec.Superuser {
continue
}
for _, permission := range PermissionsForRole(spec.Key) {
if _, err := e.AddPolicy(RoleSubject(spec.Key), permission.Resource, permission.Action, EffectAllow); err != nil {
return err
}
}
}
return nil
}
+95
View File
@@ -0,0 +1,95 @@
package service
import (
"fmt"
"net/http"
"github.com/QuantumNous/new-api/logger"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/gin-gonic/gin"
)
const (
BillingSourceWallet = "wallet"
BillingSourceSubscription = "subscription"
)
// PreConsumeBilling 根据用户计费偏好创建 BillingSession 并执行预扣费。
// 会话存储在 relayInfo.Billing 上,供后续 Settle / Refund 使用。
func PreConsumeBilling(c *gin.Context, preConsumedQuota int, relayInfo *relaycommon.RelayInfo) *types.NewAPIError {
if relayInfo != nil && relayInfo.QuotaClamp != nil {
return types.NewErrorWithStatusCode(
relayInfo.QuotaClamp,
types.ErrorCodeModelPriceError,
http.StatusBadRequest,
types.ErrOptionWithSkipRetry(),
)
}
if preConsumedQuota < 0 {
return types.NewErrorWithStatusCode(
fmt.Errorf("pre-consume quota cannot be negative: %d", preConsumedQuota),
types.ErrorCodeModelPriceError,
http.StatusBadRequest,
types.ErrOptionWithSkipRetry(),
)
}
session, apiErr := NewBillingSession(c, relayInfo, preConsumedQuota)
if apiErr != nil {
return apiErr
}
relayInfo.Billing = session
return nil
}
// ---------------------------------------------------------------------------
// SettleBilling — 后结算辅助函数
// ---------------------------------------------------------------------------
// SettleBilling 执行计费结算。如果 RelayInfo 上有 BillingSession 则通过 session 结算,
// 否则回退到旧的 PostConsumeQuota 路径(兼容按次计费等场景)。
func SettleBilling(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, actualQuota int) error {
if relayInfo.Billing != nil {
preConsumed := relayInfo.Billing.GetPreConsumedQuota()
delta := actualQuota - preConsumed
if delta > 0 {
logger.LogInfo(ctx, fmt.Sprintf("预扣费后补扣费:%s(实际消耗:%s,预扣费:%s)",
logger.FormatQuota(delta),
logger.FormatQuota(actualQuota),
logger.FormatQuota(preConsumed),
))
} else if delta < 0 {
logger.LogInfo(ctx, fmt.Sprintf("预扣费后返还扣费:%s(实际消耗:%s,预扣费:%s)",
logger.FormatQuota(-delta),
logger.FormatQuota(actualQuota),
logger.FormatQuota(preConsumed),
))
} else {
logger.LogInfo(ctx, fmt.Sprintf("预扣费与实际消耗一致,无需调整:%s(按次计费)",
logger.FormatQuota(actualQuota),
))
}
if err := relayInfo.Billing.Settle(actualQuota); err != nil {
return err
}
// 发送额度通知(订阅计费使用订阅剩余额度)
if actualQuota != 0 {
if relayInfo.BillingSource == BillingSourceSubscription {
checkAndSendSubscriptionQuotaNotify(relayInfo)
} else {
checkAndSendQuotaNotify(relayInfo, actualQuota-preConsumed, preConsumed)
}
}
return nil
}
// 回退:无 BillingSession 时使用旧路径
quotaDelta := actualQuota - relayInfo.FinalPreConsumedQuota
if quotaDelta != 0 {
return PostConsumeQuota(relayInfo, quotaDelta, relayInfo.FinalPreConsumedQuota, true)
}
return nil
}
+457
View File
@@ -0,0 +1,457 @@
package service
import (
"errors"
"fmt"
"net/http"
"strings"
"sync"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/bytedance/gopkg/util/gopool"
"github.com/gin-gonic/gin"
)
// ---------------------------------------------------------------------------
// BillingSession — 统一计费会话
// ---------------------------------------------------------------------------
// BillingSession 封装单次请求的预扣费/结算/退款生命周期。
// 实现 relaycommon.BillingSettler 接口。
type BillingSession struct {
relayInfo *relaycommon.RelayInfo
funding FundingSource
preConsumedQuota int // 实际预扣额度(信任用户可能为 0)
tokenConsumed int // 令牌额度实际扣减量
extraReserved int // 发送前补充预扣的额度(订阅退款时需要单独回滚)
trusted bool // 是否命中信任额度旁路
fundingSettled bool // funding.Settle 已成功,资金来源已提交
settled bool // Settle 全部完成(资金 + 令牌)
refunded bool // Refund 已调用
mu sync.Mutex
}
// Settle 根据实际消耗额度进行结算。
// 资金来源和令牌额度分两步提交:若资金来源已提交但令牌调整失败,
// 会标记 fundingSettled 防止 Refund 对已提交的资金来源执行退款。
func (s *BillingSession) Settle(actualQuota int) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.settled {
return nil
}
delta := actualQuota - s.preConsumedQuota
if delta == 0 {
s.settled = true
return nil
}
// 1) 调整资金来源(仅在尚未提交时执行,防止重复调用)
if !s.fundingSettled {
if err := s.funding.Settle(delta); err != nil {
return err
}
s.fundingSettled = true
}
// 2) 调整令牌额度
var tokenErr error
if !s.relayInfo.IsPlayground {
if delta > 0 {
tokenErr = model.DecreaseTokenQuota(s.relayInfo.TokenId, s.relayInfo.TokenKey, delta)
} else {
tokenErr = model.IncreaseTokenQuota(s.relayInfo.TokenId, s.relayInfo.TokenKey, -delta)
}
if tokenErr != nil {
// 资金来源已提交,令牌调整失败只能记录日志;标记 settled 防止 Refund 误退资金
common.SysLog(fmt.Sprintf("error adjusting token quota after funding settled (userId=%d, tokenId=%d, delta=%d): %s",
s.relayInfo.UserId, s.relayInfo.TokenId, delta, tokenErr.Error()))
}
}
// 3) 更新 relayInfo 上的订阅 PostDelta(用于日志)
if s.funding.Source() == BillingSourceSubscription {
s.relayInfo.SubscriptionPostDelta += int64(delta)
}
s.settled = true
return tokenErr
}
// Refund 退还所有预扣费,幂等安全,异步执行。
func (s *BillingSession) Refund(c *gin.Context) {
s.mu.Lock()
if s.settled || s.refunded || !s.needsRefundLocked() {
s.mu.Unlock()
return
}
s.refunded = true
s.mu.Unlock()
logger.LogInfo(c, fmt.Sprintf("用户 %d 请求失败, 返还预扣费(token_quota=%s, funding=%s)",
s.relayInfo.UserId,
logger.FormatQuota(s.tokenConsumed),
s.funding.Source(),
))
// 复制需要的值到闭包中
tokenId := s.relayInfo.TokenId
tokenKey := s.relayInfo.TokenKey
isPlayground := s.relayInfo.IsPlayground
tokenConsumed := s.tokenConsumed
extraReserved := s.extraReserved
subscriptionId := s.relayInfo.SubscriptionId
funding := s.funding
gopool.Go(func() {
// 1) 退还资金来源
if err := funding.Refund(); err != nil {
common.SysLog("error refunding billing source: " + err.Error())
}
if extraReserved > 0 && funding.Source() == BillingSourceSubscription && subscriptionId > 0 {
if err := model.PostConsumeUserSubscriptionDelta(subscriptionId, -int64(extraReserved)); err != nil {
common.SysLog("error refunding subscription extra reserved quota: " + err.Error())
}
}
// 2) 退还令牌额度
if tokenConsumed > 0 && !isPlayground {
if err := model.IncreaseTokenQuota(tokenId, tokenKey, tokenConsumed); err != nil {
common.SysLog("error refunding token quota: " + err.Error())
}
}
})
}
// NeedsRefund 返回是否存在需要退还的预扣状态。
func (s *BillingSession) NeedsRefund() bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.needsRefundLocked()
}
func (s *BillingSession) needsRefundLocked() bool {
if s.settled || s.refunded || s.fundingSettled {
// fundingSettled 时资金来源已提交结算,不能再退预扣费
return false
}
if s.tokenConsumed > 0 {
return true
}
// 订阅可能在 tokenConsumed=0 时仍预扣了额度
if sub, ok := s.funding.(*SubscriptionFunding); ok && sub.preConsumed > 0 {
return true
}
return false
}
// GetPreConsumedQuota 返回实际预扣的额度。
func (s *BillingSession) GetPreConsumedQuota() int {
return s.preConsumedQuota
}
func (s *BillingSession) Reserve(targetQuota int) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.settled || s.refunded || s.trusted || targetQuota <= s.preConsumedQuota {
return nil
}
delta := targetQuota - s.preConsumedQuota
if delta <= 0 {
return nil
}
if err := s.reserveFunding(delta); err != nil {
return err
}
if err := s.reserveToken(delta); err != nil {
s.rollbackFundingReserve(delta)
return err
}
s.preConsumedQuota += delta
s.tokenConsumed += delta
s.extraReserved += delta
s.syncRelayInfo()
return nil
}
// ---------------------------------------------------------------------------
// PreConsume — 统一预扣费入口(含信任额度旁路)
// ---------------------------------------------------------------------------
// preConsume 执行预扣费:信任检查 -> 令牌预扣 -> 资金来源预扣。
// 任一步骤失败时原子回滚已完成的步骤。
func (s *BillingSession) preConsume(c *gin.Context, quota int) *types.NewAPIError {
effectiveQuota := quota
// ---- 信任额度旁路 ----
if s.shouldTrust(c) {
s.trusted = true
effectiveQuota = 0
logger.LogInfo(c, fmt.Sprintf("用户 %d 额度充足, 信任且不需要预扣费 (funding=%s)", s.relayInfo.UserId, s.funding.Source()))
} else if effectiveQuota > 0 {
logger.LogInfo(c, fmt.Sprintf("用户 %d 需要预扣费 %s (funding=%s)", s.relayInfo.UserId, logger.FormatQuota(effectiveQuota), s.funding.Source()))
}
// ---- 1) 预扣令牌额度 ----
if effectiveQuota > 0 {
if err := PreConsumeTokenQuota(s.relayInfo, effectiveQuota); err != nil {
return types.NewErrorWithStatusCode(err, types.ErrorCodePreConsumeTokenQuotaFailed, http.StatusForbidden, types.ErrOptionWithSkipRetry(), types.ErrOptionWithNoRecordErrorLog())
}
s.tokenConsumed = effectiveQuota
}
// ---- 2) 预扣资金来源 ----
if err := s.funding.PreConsume(effectiveQuota); err != nil {
// 预扣费失败,回滚令牌额度
if s.tokenConsumed > 0 && !s.relayInfo.IsPlayground {
if rollbackErr := model.IncreaseTokenQuota(s.relayInfo.TokenId, s.relayInfo.TokenKey, s.tokenConsumed); rollbackErr != nil {
common.SysLog(fmt.Sprintf("error rolling back token quota (userId=%d, tokenId=%d, amount=%d, fundingErr=%s): %s",
s.relayInfo.UserId, s.relayInfo.TokenId, s.tokenConsumed, err.Error(), rollbackErr.Error()))
}
s.tokenConsumed = 0
}
// TODO: model 层应定义哨兵错误(如 ErrNoActiveSubscription),用 errors.Is 替代字符串匹配
if errors.Is(err, ErrInsufficientWalletQuota) {
userQuota, quotaErr := model.GetUserQuota(s.relayInfo.UserId, false)
if quotaErr != nil {
userQuota = 0
}
return types.NewErrorWithStatusCode(
fmt.Errorf("用户额度不足, 剩余额度: %s", logger.FormatQuota(userQuota)),
types.ErrorCodeInsufficientUserQuota, http.StatusForbidden,
types.ErrOptionWithSkipRetry(), types.ErrOptionWithNoRecordErrorLog())
}
errMsg := err.Error()
if strings.Contains(errMsg, "no active subscription") || strings.Contains(errMsg, "subscription quota insufficient") {
return types.NewErrorWithStatusCode(fmt.Errorf("订阅额度不足或未配置订阅: %s", errMsg), types.ErrorCodeInsufficientUserQuota, http.StatusForbidden, types.ErrOptionWithSkipRetry(), types.ErrOptionWithNoRecordErrorLog())
}
return types.NewError(err, types.ErrorCodeUpdateDataError, types.ErrOptionWithSkipRetry())
}
s.preConsumedQuota = effectiveQuota
// ---- 同步 RelayInfo 兼容字段 ----
s.syncRelayInfo()
return nil
}
func (s *BillingSession) reserveFunding(delta int) error {
switch funding := s.funding.(type) {
case *WalletFunding:
// 与结算补扣(SettleBilling 正差额 → WalletFunding.Settle)语义一致:
// 全额无条件扣减,余额不足的部分记为欠费(余额可为负),不中断请求,
// 保证日志记录的预扣额度与用户余额的实际变动始终对账一致。
// DecreaseUserQuota 仅在数据库错误时失败。
if err := model.DecreaseUserQuota(funding.userId, delta, false); err != nil {
return types.NewError(err, types.ErrorCodeUpdateDataError, types.ErrOptionWithSkipRetry())
}
funding.consumed += delta
return nil
case *SubscriptionFunding:
if err := model.PostConsumeUserSubscriptionDelta(funding.subscriptionId, int64(delta)); err != nil {
return types.NewErrorWithStatusCode(
fmt.Errorf("订阅额度不足或未配置订阅: %s", err.Error()),
types.ErrorCodeInsufficientUserQuota,
http.StatusForbidden,
types.ErrOptionWithSkipRetry(),
types.ErrOptionWithNoRecordErrorLog(),
)
}
return nil
default:
return types.NewError(fmt.Errorf("unsupported funding source: %s", s.funding.Source()), types.ErrorCodeUpdateDataError, types.ErrOptionWithSkipRetry())
}
}
func (s *BillingSession) rollbackFundingReserve(delta int) {
switch funding := s.funding.(type) {
case *WalletFunding:
if err := model.IncreaseUserQuota(funding.userId, delta, false); err != nil {
common.SysLog("error rolling back wallet funding reserve: " + err.Error())
} else {
funding.consumed -= delta
}
case *SubscriptionFunding:
if err := model.PostConsumeUserSubscriptionDelta(funding.subscriptionId, -int64(delta)); err != nil {
common.SysLog("error rolling back subscription funding reserve: " + err.Error())
}
}
}
func (s *BillingSession) reserveToken(delta int) error {
if delta <= 0 || s.relayInfo.IsPlayground {
return nil
}
if err := PreConsumeTokenQuota(s.relayInfo, delta); err != nil {
return types.NewErrorWithStatusCode(err, types.ErrorCodePreConsumeTokenQuotaFailed, http.StatusForbidden, types.ErrOptionWithSkipRetry(), types.ErrOptionWithNoRecordErrorLog())
}
return nil
}
// shouldTrust 统一信任额度检查,适用于钱包和订阅。
func (s *BillingSession) shouldTrust(c *gin.Context) bool {
// 异步任务(ForcePreConsume=true)必须预扣全额,不允许信任旁路
if s.relayInfo.ForcePreConsume {
return false
}
trustQuota := common.GetTrustQuota()
if trustQuota <= 0 {
return false
}
// 检查令牌是否充足
tokenTrusted := s.relayInfo.TokenUnlimited
if !tokenTrusted {
tokenQuota := c.GetInt("token_quota")
tokenTrusted = tokenQuota > trustQuota
}
if !tokenTrusted {
return false
}
switch s.funding.Source() {
case BillingSourceWallet:
return s.relayInfo.UserQuota > trustQuota
case BillingSourceSubscription:
// 订阅不能启用信任旁路。原因:
// 1. PreConsumeUserSubscription 要求 amount>0 来创建预扣记录并锁定订阅
// 2. SubscriptionFunding.PreConsume 忽略参数,始终用 s.amount 预扣
// 3. 若信任旁路将 effectiveQuota 设为 0,会导致 preConsumedQuota 与实际订阅预扣不一致
return false
default:
return false
}
}
// syncRelayInfo 将 BillingSession 的状态同步到 RelayInfo 的兼容字段上。
func (s *BillingSession) syncRelayInfo() {
info := s.relayInfo
info.FinalPreConsumedQuota = s.preConsumedQuota
info.BillingSource = s.funding.Source()
if sub, ok := s.funding.(*SubscriptionFunding); ok {
info.SubscriptionId = sub.subscriptionId
info.SubscriptionPreConsumed = sub.preConsumed + int64(s.extraReserved)
info.SubscriptionPostDelta = 0
info.SubscriptionAmountTotal = sub.AmountTotal
info.SubscriptionAmountUsedAfterPreConsume = sub.AmountUsedAfter + int64(s.extraReserved)
info.SubscriptionPlanId = sub.PlanId
info.SubscriptionPlanTitle = sub.PlanTitle
} else {
info.SubscriptionId = 0
info.SubscriptionPreConsumed = 0
}
}
// ---------------------------------------------------------------------------
// NewBillingSession 工厂 — 根据计费偏好创建会话并处理回退
// ---------------------------------------------------------------------------
// NewBillingSession 根据用户计费偏好创建 BillingSession,处理 subscription_first / wallet_first 的回退。
func NewBillingSession(c *gin.Context, relayInfo *relaycommon.RelayInfo, preConsumedQuota int) (*BillingSession, *types.NewAPIError) {
if relayInfo == nil {
return nil, types.NewError(fmt.Errorf("relayInfo is nil"), types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry())
}
pref := common.NormalizeBillingPreference(relayInfo.UserSetting.BillingPreference)
// 钱包路径需要先检查用户额度
tryWallet := func() (*BillingSession, *types.NewAPIError) {
userQuota, err := model.GetUserQuota(relayInfo.UserId, false)
if err != nil {
return nil, types.NewError(err, types.ErrorCodeQueryDataError, types.ErrOptionWithSkipRetry())
}
if userQuota <= 0 {
return nil, types.NewErrorWithStatusCode(
fmt.Errorf("用户额度不足, 剩余额度: %s", logger.FormatQuota(userQuota)),
types.ErrorCodeInsufficientUserQuota, http.StatusForbidden,
types.ErrOptionWithSkipRetry(), types.ErrOptionWithNoRecordErrorLog())
}
if userQuota-preConsumedQuota < 0 {
return nil, types.NewErrorWithStatusCode(
fmt.Errorf("预扣费额度失败, 用户剩余额度: %s, 需要预扣费额度: %s", logger.FormatQuota(userQuota), logger.FormatQuota(preConsumedQuota)),
types.ErrorCodeInsufficientUserQuota, http.StatusForbidden,
types.ErrOptionWithSkipRetry(), types.ErrOptionWithNoRecordErrorLog())
}
relayInfo.UserQuota = userQuota
session := &BillingSession{
relayInfo: relayInfo,
funding: &WalletFunding{userId: relayInfo.UserId},
}
if apiErr := session.preConsume(c, preConsumedQuota); apiErr != nil {
return nil, apiErr
}
return session, nil
}
trySubscription := func() (*BillingSession, *types.NewAPIError) {
subConsume := int64(preConsumedQuota)
if subConsume <= 0 {
subConsume = 1
}
session := &BillingSession{
relayInfo: relayInfo,
funding: &SubscriptionFunding{
requestId: relayInfo.RequestId,
userId: relayInfo.UserId,
modelName: relayInfo.GetBillingModelName(),
amount: subConsume,
},
}
// 必须传 subConsume 而非 preConsumedQuota,保证 SubscriptionFunding.amount、
// preConsume 参数和 FinalPreConsumedQuota 三者一致,避免订阅多扣费。
if apiErr := session.preConsume(c, int(subConsume)); apiErr != nil {
return nil, apiErr
}
return session, nil
}
switch pref {
case "subscription_only":
return trySubscription()
case "wallet_only":
return tryWallet()
case "wallet_first":
session, err := tryWallet()
if err != nil {
if err.GetErrorCode() == types.ErrorCodeInsufficientUserQuota {
return trySubscription()
}
return nil, err
}
return session, nil
case "subscription_first":
fallthrough
default:
hasSub, subCheckErr := model.HasActiveUserSubscription(relayInfo.UserId)
if subCheckErr != nil {
return nil, types.NewError(subCheckErr, types.ErrorCodeQueryDataError, types.ErrOptionWithSkipRetry())
}
if !hasSub {
return tryWallet()
}
session, apiErr := trySubscription()
if apiErr != nil {
if apiErr.GetErrorCode() == types.ErrorCodeInsufficientUserQuota {
// 仅当用户的活跃订阅允许钱包回退时才回退到钱包,否则返回订阅额度不足错误
allowOverflow, overflowErr := model.UserActiveSubscriptionsAllowWalletOverflow(relayInfo.UserId)
if overflowErr != nil {
return nil, types.NewError(overflowErr, types.ErrorCodeQueryDataError, types.ErrOptionWithSkipRetry())
}
if allowOverflow {
return tryWallet()
}
return nil, apiErr
}
return nil, apiErr
}
return session, nil
}
}
+68
View File
@@ -0,0 +1,68 @@
package service
import (
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/relaykit/dto"
)
const (
usageBillingPathLocal = "local"
usageBillingPathUpstream = "upstream"
usageBillingPathOpenAI = "billing-usage-openai"
usageBillingPathOpenAIEstimated = "billing-usage-openai-estimated"
usageBillingPathAnthropic = "billing-usage-anthropic"
usageBillingPathAnthropicEstimated = "billing-usage-anthropic-estimated"
usageBillingPathGemini = "billing-usage-gemini"
usageBillingPathGeminiEstimated = "billing-usage-gemini-estimated"
)
func effectiveBillingUsage(usage *dto.Usage) *dto.Usage {
if billingUsage, ok := usageFromBillingUsage(usage); ok {
return billingUsage
}
return usage
}
func usageBillingPathForLog(isLocalCountTokens bool, usage *dto.Usage) string {
effectiveUsage, ok := usageFromBillingUsage(usage)
if !ok {
if isLocalCountTokens {
return usageBillingPathLocal
}
return usageBillingPathUpstream
}
switch effectiveUsage.UsageSemantic {
case dto.BillingUsageSemanticOpenAI:
if usage.BillingUsage.Estimated {
return usageBillingPathOpenAIEstimated
}
return usageBillingPathOpenAI
case dto.BillingUsageSemanticAnthropic:
if usage.BillingUsage.Estimated {
return usageBillingPathAnthropicEstimated
}
return usageBillingPathAnthropic
case dto.BillingUsageSemanticGemini:
if usage.BillingUsage.Estimated {
return usageBillingPathGeminiEstimated
}
return usageBillingPathGemini
}
return usageBillingPathUpstream
}
func appendUsageBillingPathForLog(other *model.LogOther, isLocalCountTokens bool, usage *dto.Usage) {
if other == nil {
return
}
other.SetAdmin("usage_billing_path", usageBillingPathForLog(isLocalCountTokens, usage))
}
func usageFromBillingUsage(usage *dto.Usage) (*dto.Usage, bool) {
if usage == nil || usage.BillingUsage == nil {
return nil, false
}
return usage.BillingUsage.CanonicalUsage()
}
+78
View File
@@ -0,0 +1,78 @@
package service
import (
"fmt"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/setting/operation_setting"
)
func formatNotifyType(channelId int, status int) string {
return fmt.Sprintf("%s_%d_%d", dto.NotifyTypeChannelUpdate, channelId, status)
}
// disable & notify
func DisableChannel(channelError types.ChannelError, reason string) {
common.SysLog(fmt.Sprintf("通道「%s」(#%d)发生错误,准备禁用,原因:%s", channelError.ChannelName, channelError.ChannelId, common.LocalLogPreview(reason)))
// 检查是否启用自动禁用功能
if !channelError.AutoBan {
common.SysLog(fmt.Sprintf("通道「%s」(#%d)未启用自动禁用功能,跳过禁用操作", channelError.ChannelName, channelError.ChannelId))
return
}
success := model.UpdateChannelStatus(channelError.ChannelId, channelError.UsingKey, common.ChannelStatusAutoDisabled, reason)
if success {
subject := fmt.Sprintf("通道「%s」(#%d)已被禁用", channelError.ChannelName, channelError.ChannelId)
content := fmt.Sprintf("通道「%s」(#%d)已被禁用,原因:%s", channelError.ChannelName, channelError.ChannelId, reason)
NotifyRootUser(formatNotifyType(channelError.ChannelId, common.ChannelStatusAutoDisabled), subject, content)
}
}
func EnableChannel(channelId int, usingKey string, channelName string) {
success := model.UpdateChannelStatus(channelId, usingKey, common.ChannelStatusEnabled, "")
if success {
subject := fmt.Sprintf("通道「%s」(#%d)已被启用", channelName, channelId)
content := fmt.Sprintf("通道「%s」(#%d)已被启用", channelName, channelId)
NotifyRootUser(formatNotifyType(channelId, common.ChannelStatusEnabled), subject, content)
}
}
func ShouldDisableChannel(err *types.NewAPIError) bool {
if !common.AutomaticDisableChannelEnabled {
return false
}
if err == nil {
return false
}
if types.IsChannelError(err) {
return true
}
if types.IsSkipRetryError(err) {
return false
}
if operation_setting.ShouldDisableByStatusCode(err.StatusCode) {
return true
}
lowerMessage := strings.ToLower(err.Error())
search, _ := AcSearch(lowerMessage, operation_setting.AutomaticDisableKeywords, true)
return search
}
func ShouldEnableChannel(newAPIError *types.NewAPIError, status int) bool {
if !common.AutomaticEnableChannelEnabled {
return false
}
if newAPIError != nil {
return false
}
if status != common.ChannelStatusAutoDisabled {
return false
}
return true
}
File diff suppressed because it is too large. Load diff
+334
View File
@@ -0,0 +1,334 @@
package service
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func buildChannelAffinityTemplateContextForTest(meta channelAffinityMeta) *gin.Context {
rec := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(rec)
setChannelAffinityContext(ctx, meta)
return ctx
}
func TestApplyChannelAffinityOverrideTemplate_NoTemplate(t *testing.T) {
ctx := buildChannelAffinityTemplateContextForTest(channelAffinityMeta{
RuleName: "rule-no-template",
})
base := map[string]any{
"temperature": 0.7,
}
merged, applied := ApplyChannelAffinityOverrideTemplate(ctx, base)
require.False(t, applied)
require.Equal(t, base, merged)
}
func TestApplyChannelAffinityOverrideTemplate_MergeTemplate(t *testing.T) {
ctx := buildChannelAffinityTemplateContextForTest(channelAffinityMeta{
RuleName: "rule-with-template",
ParamTemplate: map[string]any{
"temperature": 0.2,
"top_p": 0.95,
},
UsingGroup: "default",
ModelName: "gpt-4.1",
RequestPath: "/v1/responses",
KeySourceType: "gjson",
KeySourcePath: "prompt_cache_key",
KeyHint: "abcd...wxyz",
KeyFingerprint: "abcd1234",
})
base := map[string]any{
"temperature": 0.7,
"max_tokens": 2000,
}
merged, applied := ApplyChannelAffinityOverrideTemplate(ctx, base)
require.True(t, applied)
require.Equal(t, 0.7, merged["temperature"])
require.Equal(t, 0.95, merged["top_p"])
require.Equal(t, 2000, merged["max_tokens"])
require.Equal(t, 0.7, base["temperature"])
anyInfo, ok := ctx.Get(ginKeyChannelAffinityLogInfo)
require.True(t, ok)
info, ok := anyInfo.(map[string]any)
require.True(t, ok)
overrideInfoAny, ok := info["override_template"]
require.True(t, ok)
overrideInfo, ok := overrideInfoAny.(map[string]any)
require.True(t, ok)
require.Equal(t, true, overrideInfo["applied"])
require.Equal(t, "rule-with-template", overrideInfo["rule_name"])
require.EqualValues(t, 2, overrideInfo["param_override_keys"])
}
func TestApplyChannelAffinityOverrideTemplate_MergeOperations(t *testing.T) {
ctx := buildChannelAffinityTemplateContextForTest(channelAffinityMeta{
RuleName: "rule-with-ops-template",
ParamTemplate: map[string]any{
"operations": []map[string]any{
{
"mode": "pass_headers",
"value": []string{"Originator"},
},
},
},
})
base := map[string]any{
"temperature": 0.7,
"operations": []map[string]any{
{
"path": "model",
"mode": "trim_prefix",
"value": "openai/",
},
},
}
merged, applied := ApplyChannelAffinityOverrideTemplate(ctx, base)
require.True(t, applied)
require.Equal(t, 0.7, merged["temperature"])
opsAny, ok := merged["operations"]
require.True(t, ok)
ops, ok := opsAny.([]any)
require.True(t, ok)
require.Len(t, ops, 2)
firstOp, ok := ops[0].(map[string]any)
require.True(t, ok)
require.Equal(t, "pass_headers", firstOp["mode"])
secondOp, ok := ops[1].(map[string]any)
require.True(t, ok)
require.Equal(t, "trim_prefix", secondOp["mode"])
}
func TestShouldSkipRetryAfterChannelAffinityFailure(t *testing.T) {
tests := []struct {
name string
ctx func() *gin.Context
want bool
}{
{
name: "nil context",
ctx: func() *gin.Context {
return nil
},
want: false,
},
{
name: "explicit skip retry flag in context",
ctx: func() *gin.Context {
ctx := buildChannelAffinityTemplateContextForTest(channelAffinityMeta{
RuleName: "rule-explicit-flag",
SkipRetry: false,
UsingGroup: "default",
ModelName: "gpt-5",
})
ctx.Set(ginKeyChannelAffinitySkipRetry, true)
return ctx
},
want: true,
},
{
name: "fallback to matched rule meta",
ctx: func() *gin.Context {
return buildChannelAffinityTemplateContextForTest(channelAffinityMeta{
RuleName: "rule-skip-retry",
SkipRetry: true,
UsingGroup: "default",
ModelName: "gpt-5",
})
},
want: true,
},
{
name: "no flag and no skip retry meta",
ctx: func() *gin.Context {
return buildChannelAffinityTemplateContextForTest(channelAffinityMeta{
RuleName: "rule-no-skip-retry",
SkipRetry: false,
UsingGroup: "default",
ModelName: "gpt-5",
})
},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
require.Equal(t, tt.want, ShouldSkipRetryAfterChannelAffinityFailure(tt.ctx()))
})
}
}
func TestExtractChannelAffinityValue_RequestHeader(t *testing.T) {
rec := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(rec)
ctx.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
ctx.Request.Header.Set("X-Affinity-Key", " tenant-123 ")
value := extractChannelAffinityValue(ctx, operation_setting.ChannelAffinityKeySource{
Type: "request_header",
Key: "X-Affinity-Key",
})
require.Equal(t, "tenant-123", value)
}
func TestGetPreferredChannelByAffinity_RequestHeaderKeySource(t *testing.T) {
gin.SetMode(gin.TestMode)
rule := operation_setting.ChannelAffinityRule{
Name: "header-affinity",
ModelRegex: []string{"^gpt-.*$"},
PathRegex: []string{"/v1/responses"},
KeySources: []operation_setting.ChannelAffinityKeySource{
{Type: "request_header", Key: "X-Affinity-Key"},
},
IncludeRuleName: true,
IncludeModelName: true,
}
affinityValue := fmt.Sprintf("header-hit-%d", time.Now().UnixNano())
cacheKeySuffix := buildChannelAffinityCacheKeySuffix(rule, "gpt-5", "default", affinityValue)
cache := getChannelAffinityCache()
require.NoError(t, cache.SetWithTTL(cacheKeySuffix, 9528, time.Minute))
t.Cleanup(func() {
_, _ = cache.DeleteMany([]string{cacheKeySuffix})
})
setting := operation_setting.GetChannelAffinitySetting()
originalRules := setting.Rules
setting.Rules = append([]operation_setting.ChannelAffinityRule{rule}, originalRules...)
t.Cleanup(func() {
setting.Rules = originalRules
})
rec := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(rec)
ctx.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
ctx.Request.Header.Set("X-Affinity-Key", affinityValue)
channelID, found := GetPreferredChannelByAffinity(ctx, "gpt-5", "default")
require.True(t, found)
require.Equal(t, 9528, channelID)
meta, ok := getChannelAffinityMeta(ctx)
require.True(t, ok)
require.Equal(t, "request_header", meta.KeySourceType)
require.Equal(t, "X-Affinity-Key", meta.KeySourceKey)
require.Equal(t, buildChannelAffinityKeyHint(affinityValue), meta.KeyHint)
}
func TestClearCurrentChannelAffinityCache(t *testing.T) {
gin.SetMode(gin.TestMode)
cacheKeySuffix := fmt.Sprintf("codex cli trace:default:clear-current-%d", time.Now().UnixNano())
cacheKeyFull := channelAffinityCacheNamespace + ":" + cacheKeySuffix
cache := getChannelAffinityCache()
require.NoError(t, cache.SetWithTTL(cacheKeySuffix, 9527, time.Minute))
t.Cleanup(func() {
_, _ = cache.DeleteMany([]string{cacheKeySuffix})
})
ctx := buildChannelAffinityTemplateContextForTest(channelAffinityMeta{
CacheKey: cacheKeyFull,
TTLSeconds: 60,
RuleName: "codex cli trace",
SkipRetry: true,
})
require.True(t, ShouldSkipRetryAfterChannelAffinityFailure(ctx))
deleted := ClearCurrentChannelAffinityCache(ctx)
require.True(t, deleted)
_, found, err := cache.Get(cacheKeySuffix)
require.NoError(t, err)
require.False(t, found)
require.False(t, ShouldSkipRetryAfterChannelAffinityFailure(ctx))
}
func TestChannelAffinityHitCodexTemplatePassHeadersEffective(t *testing.T) {
gin.SetMode(gin.TestMode)
setting := operation_setting.GetChannelAffinitySetting()
require.NotNil(t, setting)
var codexRule *operation_setting.ChannelAffinityRule
for i := range setting.Rules {
rule := &setting.Rules[i]
if strings.EqualFold(strings.TrimSpace(rule.Name), "codex cli trace") {
codexRule = rule
break
}
}
require.NotNil(t, codexRule)
affinityValue := fmt.Sprintf("pc-hit-%d", time.Now().UnixNano())
cacheKeySuffix := buildChannelAffinityCacheKeySuffix(*codexRule, "gpt-5", "default", affinityValue)
cache := getChannelAffinityCache()
require.NoError(t, cache.SetWithTTL(cacheKeySuffix, 9527, time.Minute))
t.Cleanup(func() {
_, _ = cache.DeleteMany([]string{cacheKeySuffix})
})
rec := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(rec)
ctx.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(fmt.Sprintf(`{"prompt_cache_key":"%s"}`, affinityValue)))
ctx.Request.Header.Set("Content-Type", "application/json")
channelID, found := GetPreferredChannelByAffinity(ctx, "gpt-5", "default")
require.True(t, found)
require.Equal(t, 9527, channelID)
baseOverride := map[string]any{
"temperature": 0.2,
}
mergedOverride, applied := ApplyChannelAffinityOverrideTemplate(ctx, baseOverride)
require.True(t, applied)
require.Equal(t, 0.2, mergedOverride["temperature"])
info := &relaycommon.RelayInfo{
RequestHeaders: map[string]string{
"Originator": "Codex CLI",
"Session_id": "sess-123",
"User-Agent": "codex-cli-test",
},
ChannelMeta: &relaycommon.ChannelMeta{
ParamOverride: mergedOverride,
HeadersOverride: map[string]any{
"X-Static": "legacy-static",
},
},
}
_, err := relaycommon.ApplyParamOverrideWithRelayInfo([]byte(`{"model":"gpt-5"}`), info)
require.NoError(t, err)
require.True(t, info.UseRuntimeHeadersOverride)
require.Equal(t, "legacy-static", info.RuntimeHeadersOverride["x-static"])
require.Equal(t, "Codex CLI", info.RuntimeHeadersOverride["originator"])
require.Equal(t, "sess-123", info.RuntimeHeadersOverride["session_id"])
require.Equal(t, "codex-cli-test", info.RuntimeHeadersOverride["user-agent"])
_, exists := info.RuntimeHeadersOverride["x-codex-beta-features"]
require.False(t, exists)
_, exists = info.RuntimeHeadersOverride["x-codex-turn-metadata"]
require.False(t, exists)
}
@@ -0,0 +1,105 @@
package service
import (
"fmt"
"net/http/httptest"
"testing"
"time"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func buildChannelAffinityStatsContextForTest(ruleName, usingGroup, keyFP string) *gin.Context {
rec := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(rec)
setChannelAffinityContext(ctx, channelAffinityMeta{
CacheKey: fmt.Sprintf("test:%s:%s:%s", ruleName, usingGroup, keyFP),
TTLSeconds: 600,
RuleName: ruleName,
UsingGroup: usingGroup,
KeyFingerprint: keyFP,
})
return ctx
}
func TestObserveChannelAffinityUsageCacheByRelayFormat_ClaudeMode(t *testing.T) {
ruleName := fmt.Sprintf("rule_%d", time.Now().UnixNano())
usingGroup := "default"
keyFP := fmt.Sprintf("fp_%d", time.Now().UnixNano())
ctx := buildChannelAffinityStatsContextForTest(ruleName, usingGroup, keyFP)
usage := &dto.Usage{
PromptTokens: 100,
CompletionTokens: 40,
TotalTokens: 140,
PromptTokensDetails: dto.InputTokenDetails{
CachedTokens: 30,
},
}
ObserveChannelAffinityUsageCacheByRelayFormat(ctx, usage, types.RelayFormatClaude)
stats := GetChannelAffinityUsageCacheStats(ruleName, usingGroup, keyFP)
require.EqualValues(t, 1, stats.Total)
require.EqualValues(t, 1, stats.Hit)
require.EqualValues(t, 100, stats.PromptTokens)
require.EqualValues(t, 40, stats.CompletionTokens)
require.EqualValues(t, 140, stats.TotalTokens)
require.EqualValues(t, 30, stats.CachedTokens)
require.Equal(t, cacheTokenRateModeCachedOverPromptPlusCached, stats.CachedTokenRateMode)
}
func TestObserveChannelAffinityUsageCacheByRelayFormat_MixedMode(t *testing.T) {
ruleName := fmt.Sprintf("rule_%d", time.Now().UnixNano())
usingGroup := "default"
keyFP := fmt.Sprintf("fp_%d", time.Now().UnixNano())
ctx := buildChannelAffinityStatsContextForTest(ruleName, usingGroup, keyFP)
openAIUsage := &dto.Usage{
PromptTokens: 100,
PromptTokensDetails: dto.InputTokenDetails{
CachedTokens: 10,
},
}
claudeUsage := &dto.Usage{
PromptTokens: 80,
PromptTokensDetails: dto.InputTokenDetails{
CachedTokens: 20,
},
}
ObserveChannelAffinityUsageCacheByRelayFormat(ctx, openAIUsage, types.RelayFormatOpenAI)
ObserveChannelAffinityUsageCacheByRelayFormat(ctx, claudeUsage, types.RelayFormatClaude)
stats := GetChannelAffinityUsageCacheStats(ruleName, usingGroup, keyFP)
require.EqualValues(t, 2, stats.Total)
require.EqualValues(t, 2, stats.Hit)
require.EqualValues(t, 180, stats.PromptTokens)
require.EqualValues(t, 30, stats.CachedTokens)
require.Equal(t, cacheTokenRateModeMixed, stats.CachedTokenRateMode)
}
func TestObserveChannelAffinityUsageCacheByRelayFormat_UnsupportedModeKeepsEmpty(t *testing.T) {
ruleName := fmt.Sprintf("rule_%d", time.Now().UnixNano())
usingGroup := "default"
keyFP := fmt.Sprintf("fp_%d", time.Now().UnixNano())
ctx := buildChannelAffinityStatsContextForTest(ruleName, usingGroup, keyFP)
usage := &dto.Usage{
PromptTokens: 100,
PromptTokensDetails: dto.InputTokenDetails{
CachedTokens: 25,
},
}
ObserveChannelAffinityUsageCacheByRelayFormat(ctx, usage, types.RelayFormatGemini)
stats := GetChannelAffinityUsageCacheStats(ruleName, usingGroup, keyFP)
require.EqualValues(t, 1, stats.Total)
require.EqualValues(t, 1, stats.Hit)
require.EqualValues(t, 25, stats.CachedTokens)
require.Equal(t, "", stats.CachedTokenRateMode)
}
+265
View File
@@ -0,0 +1,265 @@
package service
import (
"errors"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/pkg/jsplugin"
"github.com/gin-gonic/gin"
)
func GetChannelConstraints(c *gin.Context) *dto.ChannelConstraints {
if c == nil {
return &dto.ChannelConstraints{}
}
if existing, ok := common.GetContextKeyType[*dto.ChannelConstraints](c, constant.ContextKeyChannelConstraints); ok && existing != nil {
return existing
}
constraints := &dto.ChannelConstraints{}
common.SetContextKey(c, constant.ContextKeyChannelConstraints, constraints)
return constraints
}
func AppendTaskPluginIdentityFilter(c *gin.Context, pluginKey string) {
if c == nil {
return
}
GetChannelConstraints(c).AddFilter(dto.ChannelFilter{
Kind: dto.FilterTaskPluginIdentity,
TaskPluginKey: pluginKey,
TaskPluginChannelTypes: pinnedTaskPluginChannelTypes(c, pluginKey),
})
}
type RetryParam struct {
Ctx *gin.Context
TokenGroup string
ModelName string
RequestPath string
Retry *int
resetNextTry bool
}
func (p *RetryParam) GetRetry() int {
if p.Retry == nil {
return 0
}
return *p.Retry
}
func (p *RetryParam) SetRetry(retry int) {
p.Retry = &retry
}
func (p *RetryParam) IncreaseRetry() {
if p.resetNextTry {
p.resetNextTry = false
return
}
if p.Retry == nil {
p.Retry = new(int)
}
*p.Retry++
}
func (p *RetryParam) ResetRetryNextTry() {
p.resetNextTry = true
}
// CacheGetRandomSatisfiedChannel tries to get a random channel that satisfies the requirements.
// 尝试获取一个满足要求的随机渠道。
//
// For ordered Auto groups or a fixed token group with a backup group:
// 对于有序 Auto 分组,或配置了备用分组的固定令牌分组:
//
// - Each group will exhaust all its priorities before moving to the next group.
// 每个分组会用完所有优先级后才会切换到下一个分组。
//
// - Uses ContextKeyAutoGroupIndex to track current group index.
// 使用 ContextKeyAutoGroupIndex 跟踪当前分组索引。
//
// - Uses ContextKeyAutoGroupRetryIndex to track the global Retry count when current group started.
// 使用 ContextKeyAutoGroupRetryIndex 跟踪当前分组开始时的全局重试次数。
//
// - priorityRetry = Retry - startRetryIndex, represents the priority level within current group.
// priorityRetry = Retry - startRetryIndex,表示当前分组内的优先级级别。
//
// - When GetRandomSatisfiedChannel returns nil (priorities exhausted), moves to next group.
// 当 GetRandomSatisfiedChannel 返回 nil(优先级用完)时,切换到下一个分组。
//
// Example flow (2 groups, each with 2 priorities, RetryTimes=3):
// 示例流程(2个分组,每个有2个优先级,RetryTimes=3):
//
// Retry=0: GroupA, priority0 (startRetryIndex=0, priorityRetry=0)
// 分组A, 优先级0
//
// Retry=1: GroupA, priority1 (startRetryIndex=0, priorityRetry=1)
// 分组A, 优先级1
//
// Retry=2: GroupA exhausted → GroupB, priority0 (startRetryIndex=2, priorityRetry=0)
// 分组A用完 → 分组B, 优先级0
//
// Retry=3: GroupB, priority1 (startRetryIndex=2, priorityRetry=1)
// 分组B, 优先级1
func CacheGetRandomSatisfiedChannel(param *RetryParam) (*model.Channel, string, error) {
var channel *model.Channel
var err error
selectGroup := param.TokenGroup
userGroup := common.GetContextKeyString(param.Ctx, constant.ContextKeyUserGroup)
filters := GetChannelConstraints(param.Ctx).Filters
crossGroupRetry := common.GetContextKeyBool(param.Ctx, constant.ContextKeyTokenCrossGroupRetry)
orderedGroups := make([]string, 0)
if param.TokenGroup == "auto" {
orderedGroups = GetRequestAutoGroups(param.Ctx, userGroup)
if len(orderedGroups) == 0 {
return nil, selectGroup, errors.New("auto groups is not enabled")
}
} else if crossGroupRetry {
if value, exists := common.GetContextKey(param.Ctx, constant.ContextKeyTokenAutoGroups); exists {
if backupGroups, ok := value.([]string); ok {
orderedGroups = append(orderedGroups, param.TokenGroup)
for _, group := range FilterUserTokenAutoGroups(userGroup, backupGroups) {
if group != param.TokenGroup {
orderedGroups = append(orderedGroups, group)
}
}
}
}
}
if len(orderedGroups) > 0 {
// startGroupIndex: the group index to start searching from
// startGroupIndex: 开始搜索的分组索引
startGroupIndex := 0
if lastGroupIndex, exists := common.GetContextKey(param.Ctx, constant.ContextKeyAutoGroupIndex); exists {
if idx, ok := lastGroupIndex.(int); ok {
startGroupIndex = idx
}
}
for i := startGroupIndex; i < len(orderedGroups); i++ {
autoGroup := orderedGroups[i]
// Calculate priorityRetry for current group
// 计算当前分组的 priorityRetry
priorityRetry := param.GetRetry()
// If moved to a new group, reset priorityRetry and update startRetryIndex
// 如果切换到新分组,重置 priorityRetry 并更新 startRetryIndex
if i > startGroupIndex {
priorityRetry = 0
}
logger.LogDebug(param.Ctx, "Auto selecting group: %s, priorityRetry: %d", autoGroup, priorityRetry)
channel, _ = model.GetRandomSatisfiedChannel(
autoGroup,
param.ModelName,
priorityRetry,
filters,
)
if channel == nil {
// Current group has no available channel for this model, try next group
// 当前分组没有该模型的可用渠道,尝试下一个分组
logger.LogDebug(param.Ctx, "No available channel in group %s for model %s at priorityRetry %d, trying next group", autoGroup, param.ModelName, priorityRetry)
// 重置状态以尝试下一个分组
common.SetContextKey(param.Ctx, constant.ContextKeyAutoGroupIndex, i+1)
common.SetContextKey(param.Ctx, constant.ContextKeyAutoGroupRetryIndex, 0)
// Reset retry counter so outer loop can continue for next group
// 重置重试计数器,以便外层循环可以为下一个分组继续
param.SetRetry(0)
continue
}
common.SetContextKey(param.Ctx, constant.ContextKeyAutoGroup, autoGroup)
selectGroup = autoGroup
logger.LogDebug(param.Ctx, "Auto selected group: %s", autoGroup)
// Prepare state for next retry
// 为下一次重试准备状态
if crossGroupRetry && priorityRetry >= common.RetryTimes {
// Current group has exhausted all retries, prepare to switch to next group
// This request still uses current group, but next retry will use next group
// 当前分组已用完所有重试次数,准备切换到下一个分组
// 本次请求仍使用当前分组,但下次重试将使用下一个分组
logger.LogDebug(param.Ctx, "Current group %s retries exhausted (priorityRetry=%d >= RetryTimes=%d), preparing switch to next group for next retry", autoGroup, priorityRetry, common.RetryTimes)
common.SetContextKey(param.Ctx, constant.ContextKeyAutoGroupIndex, i+1)
// Reset retry counter so outer loop can continue for next group
// 重置重试计数器,以便外层循环可以为下一个分组继续
param.SetRetry(0)
param.ResetRetryNextTry()
} else {
// Stay in current group, save current state
// 保持在当前分组,保存当前状态
common.SetContextKey(param.Ctx, constant.ContextKeyAutoGroupIndex, i)
}
break
}
} else {
channel, err = model.GetRandomSatisfiedChannel(
param.TokenGroup,
param.ModelName,
param.GetRetry(),
filters,
)
if err != nil {
return nil, param.TokenGroup, err
}
}
return channel, selectGroup, nil
}
func pinnedTaskPluginChannelTypes(c *gin.Context, expected string) []int {
if c == nil || expected == "" {
return nil
}
if value, exists := c.Get(jsplugin.ContextKeyPinnedEndpoint); exists {
pinned, ok := value.(jsplugin.PinnedEndpoint)
if ok && pinned.Generation != nil && len(pinned.Candidates) > 1 {
expectedFound := false
channelTypes := make([]int, 0, len(pinned.Candidates))
seen := make(map[int]struct{}, len(pinned.Candidates))
for _, candidate := range pinned.Candidates {
if candidate.Plugin == nil {
continue
}
if candidate.Plugin.Meta.Key == expected {
expectedFound = true
}
for _, channelType := range candidate.Plugin.Meta.ChannelTypes {
if channelType == 0 || channelType == constant.ChannelTypeTaskPlugin {
continue
}
if _, duplicate := seen[channelType]; duplicate {
continue
}
if plugin, indexed := pinned.Generation.GetByChannelType(channelType); indexed && plugin == candidate.Plugin {
seen[channelType] = struct{}{}
channelTypes = append(channelTypes, channelType)
}
}
}
if expectedFound {
return channelTypes
}
}
}
value, exists := c.Get(jsplugin.ContextKeyPinnedPlugin)
pinned, ok := value.(jsplugin.PinnedPlugin)
if !exists || !ok || pinned.Generation == nil || pinned.Plugin == nil || pinned.Plugin.Meta.Key != expected {
return nil
}
channelTypes := make([]int, 0, len(pinned.Plugin.Meta.ChannelTypes))
for _, channelType := range pinned.Plugin.Meta.ChannelTypes {
if channelType == 0 || channelType == constant.ChannelTypeTaskPlugin {
continue
}
channelTypes = append(channelTypes, channelType)
}
if len(channelTypes) == 0 {
return nil
}
return channelTypes
}
+195
View File
@@ -0,0 +1,195 @@
package service
import (
"fmt"
"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/setting"
"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"
)
func setupChannelSelectAutoGroupsTest(t *testing.T) *gorm.DB {
t.Helper()
originalDB := model.DB
originalMemoryCacheEnabled := common.MemoryCacheEnabled
originalRetryTimes := common.RetryTimes
originalAutoGroups := setting.AutoGroups2JsonString()
originalUsableGroups := setting.UserUsableGroups2JSONString()
originalGroupRatios := ratio_setting.GroupRatio2JSONString()
originalMaxTokenAutoGroups := setting.GetMaxTokenAutoGroups()
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"))
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&model.Channel{}, &model.Ability{}))
model.DB = db
common.MemoryCacheEnabled = true
common.RetryTimes = 0
require.NoError(t, setting.UpdateAutoGroupsByJsonString(`[]`))
require.NoError(t, setting.UpdateUserUsableGroupsByJSONString(`{"default":"Default","vip":"VIP"}`))
require.NoError(t, ratio_setting.UpdateGroupRatioByJSONString(`{"default":1,"vip":2}`))
require.NoError(t, setting.UpdateMaxTokenAutoGroups("2"))
t.Cleanup(func() {
model.DB = originalDB
common.MemoryCacheEnabled = originalMemoryCacheEnabled
common.RetryTimes = originalRetryTimes
require.NoError(t, setting.UpdateAutoGroupsByJsonString(originalAutoGroups))
require.NoError(t, setting.UpdateUserUsableGroupsByJSONString(originalUsableGroups))
require.NoError(t, ratio_setting.UpdateGroupRatioByJSONString(originalGroupRatios))
require.NoError(t, setting.UpdateMaxTokenAutoGroups(fmt.Sprintf("%d", originalMaxTokenAutoGroups)))
if originalMemoryCacheEnabled && originalDB != nil &&
originalDB.Migrator().HasTable(&model.Channel{}) && originalDB.Migrator().HasTable(&model.Ability{}) {
model.InitChannelCache()
}
sqlDB, err := db.DB()
if err == nil {
require.NoError(t, sqlDB.Close())
}
})
return db
}
func createChannelSelectAutoGroupsChannel(t *testing.T, db *gorm.DB, id int, group, modelName string) {
t.Helper()
priority := int64(0)
weight := uint(100)
require.NoError(t, db.Create(&model.Channel{
Id: id,
Type: constant.ChannelTypeOpenAI,
Key: fmt.Sprintf("key-%d", id),
Status: common.ChannelStatusEnabled,
Name: fmt.Sprintf("channel-%d", id),
Weight: &weight,
Models: modelName,
Group: group,
Priority: &priority,
}).Error)
require.NoError(t, db.Create(&model.Ability{
Group: group,
Model: modelName,
ChannelId: id,
Enabled: true,
Priority: &priority,
Weight: weight,
}).Error)
}
func TestCacheGetRandomSatisfiedChannelUsesTokenAutoGroupsWhenGlobalAutoIsEmpty(t *testing.T) {
db := setupChannelSelectAutoGroupsTest(t)
const modelName = "auto-groups-runtime-model"
createChannelSelectAutoGroupsChannel(t, db, 2101, "vip", modelName)
createChannelSelectAutoGroupsChannel(t, db, 2102, "default", modelName)
model.InitChannelCache()
gin.SetMode(gin.TestMode)
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
common.SetContextKey(ctx, constant.ContextKeyUserGroup, "default")
common.SetContextKey(ctx, constant.ContextKeyTokenAutoGroups, []string{"vip", "default"})
common.SetContextKey(ctx, constant.ContextKeyTokenCrossGroupRetry, true)
retry := 0
param := &RetryParam{
Ctx: ctx,
TokenGroup: "auto",
ModelName: modelName,
RequestPath: "/v1/chat/completions",
Retry: &retry,
}
first, selectedGroup, err := CacheGetRandomSatisfiedChannel(param)
require.NoError(t, err)
require.NotNil(t, first)
assert.Equal(t, 2101, first.Id)
assert.Equal(t, "vip", selectedGroup)
assert.Equal(t, "vip", common.GetContextKeyString(ctx, constant.ContextKeyAutoGroup))
assert.Empty(t, setting.GetAutoGroups(), "the selection must not depend on the global Auto list")
param.IncreaseRetry()
second, selectedGroup, err := CacheGetRandomSatisfiedChannel(param)
require.NoError(t, err)
require.NotNil(t, second)
assert.Equal(t, 2102, second.Id)
assert.Equal(t, "default", selectedGroup)
assert.Equal(t, "default", common.GetContextKeyString(ctx, constant.ContextKeyAutoGroup))
}
func TestCacheGetRandomSatisfiedChannelUsesFixedTokenBackupGroup(t *testing.T) {
db := setupChannelSelectAutoGroupsTest(t)
const modelName = "fixed-backup-runtime-model"
createChannelSelectAutoGroupsChannel(t, db, 2201, "default", modelName)
createChannelSelectAutoGroupsChannel(t, db, 2202, "vip", modelName)
model.InitChannelCache()
gin.SetMode(gin.TestMode)
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
common.SetContextKey(ctx, constant.ContextKeyUserGroup, "default")
common.SetContextKey(ctx, constant.ContextKeyTokenAutoGroups, []string{"vip"})
common.SetContextKey(ctx, constant.ContextKeyTokenCrossGroupRetry, true)
retry := 0
param := &RetryParam{
Ctx: ctx,
TokenGroup: "default",
ModelName: modelName,
RequestPath: "/v1/chat/completions",
Retry: &retry,
}
primary, selectedGroup, err := CacheGetRandomSatisfiedChannel(param)
require.NoError(t, err)
require.NotNil(t, primary)
assert.Equal(t, 2201, primary.Id)
assert.Equal(t, "default", selectedGroup)
assert.Equal(t, "default", common.GetContextKeyString(ctx, constant.ContextKeyAutoGroup))
param.IncreaseRetry()
backup, selectedGroup, err := CacheGetRandomSatisfiedChannel(param)
require.NoError(t, err)
require.NotNil(t, backup)
assert.Equal(t, 2202, backup.Id)
assert.Equal(t, "vip", selectedGroup)
assert.Equal(t, "vip", common.GetContextKeyString(ctx, constant.ContextKeyAutoGroup))
}
func TestCacheGetRandomSatisfiedChannelSkipsUnavailableFixedPrimary(t *testing.T) {
db := setupChannelSelectAutoGroupsTest(t)
const modelName = "fixed-backup-no-primary-model"
createChannelSelectAutoGroupsChannel(t, db, 2302, "vip", modelName)
model.InitChannelCache()
gin.SetMode(gin.TestMode)
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
common.SetContextKey(ctx, constant.ContextKeyUserGroup, "default")
common.SetContextKey(ctx, constant.ContextKeyTokenAutoGroups, []string{"vip"})
common.SetContextKey(ctx, constant.ContextKeyTokenCrossGroupRetry, true)
retry := 0
channel, selectedGroup, err := CacheGetRandomSatisfiedChannel(&RetryParam{
Ctx: ctx,
TokenGroup: "default",
ModelName: modelName,
RequestPath: "/v1/responses",
Retry: &retry,
})
require.NoError(t, err)
require.NotNil(t, channel)
assert.Equal(t, 2302, channel.Id)
assert.Equal(t, "vip", selectedGroup)
assert.Equal(t, "vip", common.GetContextKeyString(ctx, constant.ContextKeyAutoGroup))
}
+152
View File
@@ -0,0 +1,152 @@
package service
import (
"fmt"
"testing"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/pkg/jsplugin"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestPinnedTaskPluginChannelTypesUsesPinnedGenerationIndex(t *testing.T) {
registry := jsplugin.NewRegistry()
plugin, err := registry.Register(channelSelectTaskPluginSource("legacy-select", constant.ChannelTypeKling), jsplugin.Options{})
require.NoError(t, err)
c, _ := gin.CreateTestContext(nil)
c.Set(jsplugin.ContextKeyPinnedPlugin, jsplugin.PinnedPlugin{
Generation: registry.Generation(),
Plugin: plugin,
})
assert.Equal(t, []int{constant.ChannelTypeKling}, pinnedTaskPluginChannelTypes(c, "legacy-select"))
assert.Empty(t, pinnedTaskPluginChannelTypes(c, "another-plugin"))
assert.Empty(t, pinnedTaskPluginChannelTypes(nil, "legacy-select"))
}
func TestPinnedTaskPluginChannelTypesLeavesGenericChannelsKeyed(t *testing.T) {
registry := jsplugin.NewRegistry()
plugin, err := registry.Register(channelSelectTaskPluginSource("generic-select", constant.ChannelTypeTaskPlugin), jsplugin.Options{})
require.NoError(t, err)
c, _ := gin.CreateTestContext(nil)
c.Set(jsplugin.ContextKeyPinnedPlugin, jsplugin.PinnedPlugin{
Generation: registry.Generation(),
Plugin: plugin,
})
assert.Empty(t, pinnedTaskPluginChannelTypes(c, "generic-select"))
}
func TestPinnedTaskPluginChannelTypesIncludesSharedEndpointProviders(t *testing.T) {
registry := jsplugin.NewRegistry()
_, err := registry.Register(channelSelectEndpointPluginSource("gemini-select", constant.ChannelTypeGemini), jsplugin.Options{})
require.NoError(t, err)
_, err = registry.Register(channelSelectEndpointPluginSource("vertex-select", 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,
})
assert.Equal(t, []int{constant.ChannelTypeGemini, constant.ChannelTypeVertexAi}, pinnedTaskPluginChannelTypes(c, candidates[0].Plugin.Meta.Key))
}
func channelSelectTaskPluginSource(key string, channelType int) string {
return fmt.Sprintf(`
export const meta = {
apiVersion: 1,
key: %q,
name: %q,
version: "1.0.0",
author: {name: "Test"},
%s
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, channelSelectChannelTypesField(channelType))
}
func channelSelectEndpointPluginSource(key string, channelType int) string {
return fmt.Sprintf(`
export const meta = {
apiVersion: 1,
key: %q,
name: %q,
version: "1.0.0",
author: {name: "Test"},
%s
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, channelSelectChannelTypesField(channelType))
}
func channelSelectChannelTypesField(channelType int) string {
if channelType <= 0 || channelType == constant.ChannelTypeTaskPlugin {
return ""
}
return fmt.Sprintf("channelTypes: [%d],", channelType)
}
func TestPinnedTaskPluginChannelTypesIncludesCompatibleTypes(t *testing.T) {
registry := jsplugin.NewRegistry()
plugin, err := registry.Register(channelSelectCompatiblePluginSource("sora-select", constant.ChannelTypeSora, constant.ChannelTypeOpenAI), jsplugin.Options{})
require.NoError(t, err)
c, _ := gin.CreateTestContext(nil)
c.Set(jsplugin.ContextKeyPinnedPlugin, jsplugin.PinnedPlugin{
Generation: registry.Generation(),
Plugin: plugin,
})
assert.Equal(t, []int{constant.ChannelTypeSora, constant.ChannelTypeOpenAI}, pinnedTaskPluginChannelTypes(c, "sora-select"))
}
func channelSelectCompatiblePluginSource(key string, channelType, compatibleType int) string {
return fmt.Sprintf(`
export const meta = {
apiVersion: 1,
key: %q,
name: %q,
version: "1.0.0",
author: {name: "Test"},
channelTypes: [%d, %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, compatibleType)
}
+81
View File
@@ -0,0 +1,81 @@
package service
import (
"context"
"fmt"
"net/http"
"strings"
"time"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/model"
)
func FetchCodexChannelModels(channel *model.Channel) ([]string, error) {
if channel == nil || channel.Type != constant.ChannelTypeCodex {
return nil, fmt.Errorf("channel type is not Codex")
}
if channel.ChannelInfo.IsMultiKey {
return nil, fmt.Errorf("codex channel does not support multi-key model discovery")
}
client, err := NewProxyHttpClient(channel.GetSetting().Proxy)
if err != nil {
return nil, err
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
clientVersion, err := GetLatestCodexClientVersion(ctx, client)
if err != nil {
return nil, fmt.Errorf("failed to get Codex client version: %w", err)
}
baseURL := channel.GetBaseURL()
if baseURL == "" {
baseURL = constant.GetChannelBaseURL(constant.ChannelTypeCodex)
}
return fetchCodexChannelModels(ctx, channel, baseURL, client, clientVersion)
}
func fetchCodexChannelModels(
ctx context.Context,
channel *model.Channel,
baseURL string,
client *http.Client,
clientVersion string,
) ([]string, error) {
oauthKey, err := parseCodexOAuthKey(strings.TrimSpace(channel.Key))
if err != nil {
return nil, err
}
statusCode, models, err := FetchCodexModels(ctx, client, baseURL, oauthKey, clientVersion)
if err != nil {
return nil, err
}
if statusCode == http.StatusUnauthorized {
if channel.Id <= 0 {
return nil, fmt.Errorf("codex channel credential expired; save the channel before retrying model fetch")
}
refreshedKey, _, refreshErr := RefreshCodexChannelCredential(
ctx,
channel.Id,
CodexCredentialRefreshOptions{ResetCaches: true},
)
if refreshErr != nil {
return nil, fmt.Errorf("failed to refresh Codex channel credential: %w", refreshErr)
}
statusCode, models, err = FetchCodexModels(ctx, client, baseURL, &CodexOAuthKey{
AccessToken: refreshedKey.AccessToken,
AccountID: refreshedKey.AccountID,
}, clientVersion)
if err != nil {
return nil, err
}
}
if statusCode < http.StatusOK || statusCode >= http.StatusMultipleChoices {
return nil, fmt.Errorf("upstream status: %d", statusCode)
}
return models, nil
}
+103
View File
@@ -0,0 +1,103 @@
package service
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/model"
)
type CodexCredentialRefreshOptions struct {
ResetCaches bool
}
type CodexOAuthKey struct {
IDToken string `json:"id_token,omitempty"`
AccessToken string `json:"access_token,omitempty"`
RefreshToken string `json:"refresh_token,omitempty"`
AccountID string `json:"account_id,omitempty"`
LastRefresh string `json:"last_refresh,omitempty"`
Email string `json:"email,omitempty"`
Type string `json:"type,omitempty"`
Expired string `json:"expired,omitempty"`
}
func parseCodexOAuthKey(raw string) (*CodexOAuthKey, error) {
if strings.TrimSpace(raw) == "" {
return nil, errors.New("codex channel: empty oauth key")
}
var key CodexOAuthKey
if err := common.Unmarshal([]byte(raw), &key); err != nil {
return nil, errors.New("codex channel: invalid oauth key json")
}
return &key, nil
}
func RefreshCodexChannelCredential(ctx context.Context, channelID int, opts CodexCredentialRefreshOptions) (*CodexOAuthKey, *model.Channel, error) {
ch, err := model.GetChannelById(channelID, true)
if err != nil {
return nil, nil, err
}
if ch == nil {
return nil, nil, fmt.Errorf("channel not found")
}
if ch.Type != constant.ChannelTypeCodex {
return nil, nil, fmt.Errorf("channel type is not Codex")
}
oauthKey, err := parseCodexOAuthKey(strings.TrimSpace(ch.Key))
if err != nil {
return nil, nil, err
}
if strings.TrimSpace(oauthKey.RefreshToken) == "" {
return nil, nil, fmt.Errorf("codex channel: refresh_token is required to refresh credential")
}
refreshCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
res, err := RefreshCodexOAuthTokenWithProxy(refreshCtx, oauthKey.RefreshToken, ch.GetSetting().Proxy)
if err != nil {
return nil, nil, err
}
oauthKey.AccessToken = res.AccessToken
oauthKey.RefreshToken = res.RefreshToken
oauthKey.LastRefresh = time.Now().Format(time.RFC3339)
oauthKey.Expired = res.ExpiresAt.Format(time.RFC3339)
if strings.TrimSpace(oauthKey.Type) == "" {
oauthKey.Type = "codex"
}
if strings.TrimSpace(oauthKey.AccountID) == "" {
if accountID, ok := ExtractCodexAccountIDFromJWT(oauthKey.AccessToken); ok {
oauthKey.AccountID = accountID
}
}
if strings.TrimSpace(oauthKey.Email) == "" {
if email, ok := ExtractEmailFromJWT(oauthKey.AccessToken); ok {
oauthKey.Email = email
}
}
encoded, err := common.Marshal(oauthKey)
if err != nil {
return nil, nil, err
}
if err := model.DB.Model(&model.Channel{}).Where("id = ?", ch.Id).Update("key", string(encoded)).Error; err != nil {
return nil, nil, err
}
if opts.ResetCaches {
model.InitChannelCache()
}
return oauthKey, ch, nil
}
+147
View File
@@ -0,0 +1,147 @@
package service
import (
"context"
"fmt"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model"
"github.com/bytedance/gopkg/util/gopool"
)
const (
codexCredentialRefreshTickInterval = 10 * time.Minute
codexCredentialRefreshThreshold = 24 * time.Hour
codexCredentialRefreshBatchSize = 200
codexCredentialRefreshTimeout = 15 * time.Second
)
var (
codexCredentialRefreshOnce sync.Once
codexCredentialRefreshRunning atomic.Bool
)
func shouldAutoRefreshCodexChannelStatus(status int) bool {
return status == common.ChannelStatusEnabled || status == common.ChannelStatusAutoDisabled
}
func StartCodexCredentialAutoRefreshTask() {
codexCredentialRefreshOnce.Do(func() {
if !common.IsMasterNode {
return
}
gopool.Go(func() {
logger.LogInfo(context.Background(), fmt.Sprintf("codex credential auto-refresh task started: tick=%s threshold=%s", codexCredentialRefreshTickInterval, codexCredentialRefreshThreshold))
ticker := time.NewTicker(codexCredentialRefreshTickInterval)
defer ticker.Stop()
runCodexCredentialAutoRefreshOnce()
for range ticker.C {
runCodexCredentialAutoRefreshOnce()
}
})
})
}
func runCodexCredentialAutoRefreshOnce() {
if !codexCredentialRefreshRunning.CompareAndSwap(false, true) {
return
}
defer codexCredentialRefreshRunning.Store(false)
ctx := context.Background()
now := time.Now()
var refreshed int
var scanned int
offset := 0
for {
var channels []*model.Channel
err := model.DB.
Select("id", "name", "key", "status", "channel_info").
Where("type = ? AND (status = ? OR status = ?)",
constant.ChannelTypeCodex,
common.ChannelStatusEnabled,
common.ChannelStatusAutoDisabled,
).
Order("id asc").
Limit(codexCredentialRefreshBatchSize).
Offset(offset).
Find(&channels).Error
if err != nil {
logger.LogError(ctx, fmt.Sprintf("codex credential auto-refresh: query channels failed: %v", err))
return
}
if len(channels) == 0 {
break
}
offset += codexCredentialRefreshBatchSize
for _, ch := range channels {
if ch == nil {
continue
}
scanned++
if ch.ChannelInfo.IsMultiKey {
continue
}
rawKey := strings.TrimSpace(ch.Key)
if rawKey == "" {
continue
}
oauthKey, err := parseCodexOAuthKey(rawKey)
if err != nil {
continue
}
refreshToken := strings.TrimSpace(oauthKey.RefreshToken)
if refreshToken == "" {
continue
}
expiredAtRaw := strings.TrimSpace(oauthKey.Expired)
expiredAt, err := time.Parse(time.RFC3339, expiredAtRaw)
if err == nil && !expiredAt.IsZero() && expiredAt.Sub(now) > codexCredentialRefreshThreshold {
continue
}
refreshCtx, cancel := context.WithTimeout(ctx, codexCredentialRefreshTimeout)
newKey, _, err := RefreshCodexChannelCredential(refreshCtx, ch.Id, CodexCredentialRefreshOptions{ResetCaches: false})
cancel()
if err != nil {
logger.LogWarn(ctx, fmt.Sprintf("codex credential auto-refresh: channel_id=%d name=%s refresh failed: %v", ch.Id, ch.Name, err))
continue
}
refreshed++
logger.LogInfo(ctx, fmt.Sprintf("codex credential auto-refresh: channel_id=%d name=%s refreshed, expires_at=%s", ch.Id, ch.Name, newKey.Expired))
}
}
if refreshed > 0 {
func() {
defer func() {
if r := recover(); r != nil {
logger.LogWarn(ctx, fmt.Sprintf("codex credential auto-refresh: InitChannelCache panic: %v", r))
}
}()
model.InitChannelCache()
}()
}
if common.DebugEnabled {
logger.LogDebug(ctx, "codex credential auto-refresh: scanned=%d refreshed=%d", scanned, refreshed)
}
}
+180
View File
@@ -0,0 +1,180 @@
package service
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/QuantumNous/new-api/common"
)
const (
codexLatestReleaseURL = "https://api.github.com/repos/openai/codex/releases/latest"
codexClientVersionCacheTTL = time.Hour
)
type codexClientVersionCache struct {
sync.Mutex
version string
expiresAt time.Time
}
var latestCodexClientVersion codexClientVersionCache
func GetLatestCodexClientVersion(ctx context.Context, client *http.Client) (string, error) {
return latestCodexClientVersion.get(ctx, client, codexLatestReleaseURL, time.Now())
}
func (cache *codexClientVersionCache) get(ctx context.Context, client *http.Client, releaseURL string, now time.Time) (string, error) {
cache.Lock()
defer cache.Unlock()
if cache.version != "" && now.Before(cache.expiresAt) {
return cache.version, nil
}
version, err := fetchLatestCodexClientVersion(ctx, client, releaseURL)
if err != nil {
if cache.version != "" {
cache.expiresAt = now.Add(codexClientVersionCacheTTL)
return cache.version, nil
}
return "", err
}
cache.version = version
cache.expiresAt = now.Add(codexClientVersionCacheTTL)
return version, nil
}
func fetchLatestCodexClientVersion(ctx context.Context, client *http.Client, releaseURL string) (string, error) {
if client == nil {
return "", fmt.Errorf("nil http client")
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, releaseURL, nil)
if err != nil {
return "", err
}
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("User-Agent", "new-api")
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return "", fmt.Errorf("codex release lookup failed: status=%d", resp.StatusCode)
}
var release struct {
Name string `json:"name"`
Draft bool `json:"draft"`
Prerelease bool `json:"prerelease"`
}
if err := common.DecodeJson(resp.Body, &release); err != nil {
return "", err
}
if release.Draft || release.Prerelease {
return "", fmt.Errorf("latest codex release is not stable")
}
version := strings.TrimSpace(release.Name)
if version == "" {
return "", fmt.Errorf("latest codex release has no version name")
}
return version, nil
}
func FetchCodexModels(
ctx context.Context,
client *http.Client,
baseURL string,
oauthKey *CodexOAuthKey,
clientVersion string,
) (statusCode int, models []string, err error) {
if client == nil {
return 0, nil, fmt.Errorf("nil http client")
}
if oauthKey == nil {
return 0, nil, fmt.Errorf("nil oauth key")
}
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
accessToken := strings.TrimSpace(oauthKey.AccessToken)
accountID := strings.TrimSpace(oauthKey.AccountID)
clientVersion = strings.TrimSpace(clientVersion)
if baseURL == "" {
return 0, nil, fmt.Errorf("empty baseURL")
}
if accessToken == "" {
return 0, nil, fmt.Errorf("codex channel: access_token is required")
}
if accountID == "" {
return 0, nil, fmt.Errorf("codex channel: account_id is required")
}
if clientVersion == "" {
return 0, nil, fmt.Errorf("codex channel: client_version is required")
}
modelsURL, err := url.Parse(baseURL + "/backend-api/codex/models")
if err != nil {
return 0, nil, err
}
query := modelsURL.Query()
query.Set("client_version", clientVersion)
modelsURL.RawQuery = query.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, modelsURL.String(), nil)
if err != nil {
return 0, nil, err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("ChatGPT-Account-Id", accountID)
req.Header.Set("User-Agent", "codex-cli/"+clientVersion)
req.Header.Set("Accept", "application/json")
resp, err := client.Do(req)
if err != nil {
return 0, nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return resp.StatusCode, nil, err
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return resp.StatusCode, nil, nil
}
var result struct {
Models []struct {
Slug string `json:"slug"`
} `json:"models"`
}
if err := common.Unmarshal(body, &result); err != nil {
return resp.StatusCode, nil, err
}
seen := make(map[string]struct{}, len(result.Models))
models = make([]string, 0, len(result.Models))
for _, item := range result.Models {
slug := strings.TrimSpace(item.Slug)
if slug == "" {
continue
}
if _, ok := seen[slug]; ok {
continue
}
seen[slug] = struct{}{}
models = append(models, slug)
}
return resp.StatusCode, models, nil
}
+170
View File
@@ -0,0 +1,170 @@
package service
import (
"context"
"encoding/base64"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"github.com/QuantumNous/new-api/common"
)
const (
codexOAuthClientID = "app_EMoamEEZ73f0CkXaXp7hrann"
codexOAuthTokenURL = "https://auth.openai.com/oauth/token"
codexJWTClaimPath = "https://api.openai.com/auth"
defaultHTTPTimeout = 20 * time.Second
)
type CodexOAuthTokenResult struct {
AccessToken string
RefreshToken string
ExpiresAt time.Time
}
func RefreshCodexOAuthToken(ctx context.Context, refreshToken string) (*CodexOAuthTokenResult, error) {
return RefreshCodexOAuthTokenWithProxy(ctx, refreshToken, "")
}
func RefreshCodexOAuthTokenWithProxy(ctx context.Context, refreshToken string, proxyURL string) (*CodexOAuthTokenResult, error) {
client, err := getCodexOAuthHTTPClient(proxyURL)
if err != nil {
return nil, err
}
return refreshCodexOAuthToken(ctx, client, codexOAuthTokenURL, codexOAuthClientID, refreshToken)
}
func refreshCodexOAuthToken(
ctx context.Context,
client *http.Client,
tokenURL string,
clientID string,
refreshToken string,
) (*CodexOAuthTokenResult, error) {
rt := strings.TrimSpace(refreshToken)
if rt == "" {
return nil, errors.New("empty refresh_token")
}
form := url.Values{}
form.Set("grant_type", "refresh_token")
form.Set("refresh_token", rt)
form.Set("client_id", clientID)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, strings.NewReader(form.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var payload struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int `json:"expires_in"`
}
if err := common.DecodeJson(resp.Body, &payload); err != nil {
return nil, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("codex oauth refresh failed: status=%d", resp.StatusCode)
}
if strings.TrimSpace(payload.AccessToken) == "" || strings.TrimSpace(payload.RefreshToken) == "" || payload.ExpiresIn <= 0 {
return nil, errors.New("codex oauth refresh response missing fields")
}
return &CodexOAuthTokenResult{
AccessToken: strings.TrimSpace(payload.AccessToken),
RefreshToken: strings.TrimSpace(payload.RefreshToken),
ExpiresAt: time.Now().Add(time.Duration(payload.ExpiresIn) * time.Second),
}, nil
}
func getCodexOAuthHTTPClient(proxyURL string) (*http.Client, error) {
baseClient, err := GetHttpClientWithProxy(strings.TrimSpace(proxyURL))
if err != nil {
return nil, err
}
if baseClient == nil {
return &http.Client{Timeout: defaultHTTPTimeout}, nil
}
clientCopy := *baseClient
clientCopy.Timeout = defaultHTTPTimeout
return &clientCopy, nil
}
func ExtractCodexAccountIDFromJWT(token string) (string, bool) {
claims, ok := decodeJWTClaims(token)
if !ok {
return "", false
}
raw, ok := claims[codexJWTClaimPath]
if !ok {
return "", false
}
obj, ok := raw.(map[string]any)
if !ok {
return "", false
}
v, ok := obj["chatgpt_account_id"]
if !ok {
return "", false
}
s, ok := v.(string)
if !ok {
return "", false
}
s = strings.TrimSpace(s)
if s == "" {
return "", false
}
return s, true
}
func ExtractEmailFromJWT(token string) (string, bool) {
claims, ok := decodeJWTClaims(token)
if !ok {
return "", false
}
v, ok := claims["email"]
if !ok {
return "", false
}
s, ok := v.(string)
if !ok {
return "", false
}
s = strings.TrimSpace(s)
if s == "" {
return "", false
}
return s, true
}
func decodeJWTClaims(token string) (map[string]any, bool) {
parts := strings.Split(token, ".")
if len(parts) != 3 {
return nil, false
}
payloadRaw, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return nil, false
}
var claims map[string]any
if err := common.Unmarshal(payloadRaw, &claims); err != nil {
return nil, false
}
return claims, true
}
+161
View File
@@ -0,0 +1,161 @@
package service
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/google/uuid"
)
func FetchCodexWhamUsage(
ctx context.Context,
client *http.Client,
baseURL string,
accessToken string,
accountID string,
) (statusCode int, body []byte, err error) {
if client == nil {
return 0, nil, fmt.Errorf("nil http client")
}
bu := strings.TrimRight(strings.TrimSpace(baseURL), "/")
if bu == "" {
return 0, nil, fmt.Errorf("empty baseURL")
}
at := strings.TrimSpace(accessToken)
aid := strings.TrimSpace(accountID)
if at == "" {
return 0, nil, fmt.Errorf("empty accessToken")
}
if aid == "" {
return 0, nil, fmt.Errorf("empty accountID")
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, bu+"/backend-api/wham/usage", nil)
if err != nil {
return 0, nil, err
}
setCodexWhamRequestHeaders(req, at, aid)
resp, err := client.Do(req)
if err != nil {
return 0, nil, err
}
defer resp.Body.Close()
body, err = io.ReadAll(resp.Body)
if err != nil {
return resp.StatusCode, nil, err
}
return resp.StatusCode, body, nil
}
func FetchCodexWhamRateLimitResetCredits(
ctx context.Context,
client *http.Client,
baseURL string,
accessToken string,
accountID string,
) (statusCode int, body []byte, err error) {
if client == nil {
return 0, nil, fmt.Errorf("nil http client")
}
bu := strings.TrimRight(strings.TrimSpace(baseURL), "/")
if bu == "" {
return 0, nil, fmt.Errorf("empty baseURL")
}
at := strings.TrimSpace(accessToken)
aid := strings.TrimSpace(accountID)
if at == "" {
return 0, nil, fmt.Errorf("empty accessToken")
}
if aid == "" {
return 0, nil, fmt.Errorf("empty accountID")
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, bu+"/backend-api/wham/rate-limit-reset-credits", nil)
if err != nil {
return 0, nil, err
}
setCodexWhamRequestHeaders(req, at, aid)
resp, err := client.Do(req)
if err != nil {
return 0, nil, err
}
defer resp.Body.Close()
body, err = io.ReadAll(resp.Body)
if err != nil {
return resp.StatusCode, nil, err
}
return resp.StatusCode, body, nil
}
func ConsumeCodexWhamRateLimitResetCredit(
ctx context.Context,
client *http.Client,
baseURL string,
accessToken string,
accountID string,
) (statusCode int, body []byte, err error) {
if client == nil {
return 0, nil, fmt.Errorf("nil http client")
}
bu := strings.TrimRight(strings.TrimSpace(baseURL), "/")
if bu == "" {
return 0, nil, fmt.Errorf("empty baseURL")
}
at := strings.TrimSpace(accessToken)
aid := strings.TrimSpace(accountID)
if at == "" {
return 0, nil, fmt.Errorf("empty accessToken")
}
if aid == "" {
return 0, nil, fmt.Errorf("empty accountID")
}
requestBody, err := common.Marshal(map[string]string{
"redeem_request_id": uuid.NewString(),
})
if err != nil {
return 0, nil, err
}
req, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
bu+"/backend-api/wham/rate-limit-reset-credits/consume",
bytes.NewReader(requestBody),
)
if err != nil {
return 0, nil, err
}
setCodexWhamRequestHeaders(req, at, aid)
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return 0, nil, err
}
defer resp.Body.Close()
body, err = io.ReadAll(resp.Body)
if err != nil {
return resp.StatusCode, nil, err
}
return resp.StatusCode, body, nil
}
func setCodexWhamRequestHeaders(req *http.Request, accessToken string, accountID string) {
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("chatgpt-account-id", accountID)
req.Header.Set("Accept", "application/json")
if req.Header.Get("originator") == "" {
req.Header.Set("originator", "codex_cli_rs")
}
}
+27
View File
@@ -0,0 +1,27 @@
package service
import (
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert"
)
func NormalizeCacheCreationSplit(totalTokens int, tokens5m int, tokens1h int) (int, int) {
return relayconvert.NormalizeCacheCreationSplit(totalTokens, tokens5m, tokens1h)
}
func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamResponse, info *relaycommon.RelayInfo) []*dto.ClaudeResponse {
return relayconvert.StreamResponseOpenAI2Claude(openAIResponse, info)
}
func ResponseOpenAI2Claude(openAIResponse *dto.OpenAITextResponse, info *relaycommon.RelayInfo) *dto.ClaudeResponse {
return relayconvert.ResponseOpenAI2Claude(openAIResponse, info)
}
func ResponseOpenAI2Gemini(openAIResponse *dto.OpenAITextResponse, info *relaycommon.RelayInfo) *dto.GeminiChatResponse {
return relayconvert.ResponseOpenAI2Gemini(openAIResponse, info)
}
func StreamResponseOpenAI2Gemini(openAIResponse *dto.ChatCompletionsStreamResponse, info *relaycommon.RelayInfo) *dto.GeminiChatResponse {
return relayconvert.StreamResponseOpenAI2Gemini(openAIResponse, info)
}
+114
View File
@@ -0,0 +1,114 @@
package service
import (
"testing"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestResponseConverterFacades(t *testing.T) {
cache5m, cache1h := NormalizeCacheCreationSplit(10, 3, 2)
assert.Equal(t, 8, cache5m)
assert.Equal(t, 2, cache1h)
chatResp := &dto.OpenAITextResponse{
Id: "chatcmpl_1",
Model: "gpt-test",
Choices: []dto.OpenAITextResponseChoice{
{
Message: dto.Message{
Role: "assistant",
Content: "hello",
},
FinishReason: "stop",
},
},
}
claudeResp := ResponseOpenAI2Claude(chatResp, &relaycommon.RelayInfo{})
require.NotNil(t, claudeResp)
assert.Equal(t, "message", claudeResp.Type)
geminiResp := ResponseOpenAI2Gemini(chatResp, &relaycommon.RelayInfo{})
require.NotNil(t, geminiResp)
require.Len(t, geminiResp.Candidates, 1)
}
func TestStreamResponseConverterFacades(t *testing.T) {
info := &relaycommon.RelayInfo{
SendResponseCount: 1,
ClaudeConvertInfo: &relaycommon.ClaudeConvertInfo{
LastMessagesType: relaycommon.LastMessageTypeNone,
},
}
streamResp := &dto.ChatCompletionsStreamResponse{
Id: "chatcmpl_1",
Model: "gpt-test",
Choices: []dto.ChatCompletionsStreamResponseChoice{
{
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{
Content: ptrValue("hello"),
},
},
},
}
claudeResponses := StreamResponseOpenAI2Claude(streamResp, info)
require.NotEmpty(t, claudeResponses)
geminiResp := StreamResponseOpenAI2Gemini(streamResp, &relaycommon.RelayInfo{})
require.NotNil(t, geminiResp)
require.Len(t, geminiResp.Candidates, 1)
}
func TestRequestConverterFacadeAcceptsTypedNilRelayInfo(t *testing.T) {
for _, target := range []types.RelayFormat{types.RelayFormatClaude, types.RelayFormatGemini} {
t.Run(string(target), func(t *testing.T) {
var info *relaycommon.RelayInfo
request := &dto.GeneralOpenAIRequest{
Model: "test-model",
Messages: []dto.Message{
{Role: "user", Content: "hello"},
},
}
result, err := ConvertRequest(nil, info, target, request)
require.NoError(t, err)
require.NotNil(t, result)
assert.Equal(t, target, result.To)
})
}
}
func TestStreamResponseConverterFacadesAcceptTypedNilRelayInfo(t *testing.T) {
var info *relaycommon.RelayInfo
streamResp := &dto.ChatCompletionsStreamResponse{
Id: "chatcmpl_typed_nil",
Model: "gpt-test",
Choices: []dto.ChatCompletionsStreamResponseChoice{
{
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{
Content: ptrValue("hello"),
},
},
},
}
claudeResponses := StreamResponseOpenAI2Claude(streamResp, info)
require.NotEmpty(t, claudeResponses)
assert.Equal(t, "content_block_start", claudeResponses[0].Type)
geminiResp := StreamResponseOpenAI2Gemini(streamResp, info)
require.NotNil(t, geminiResp)
require.Len(t, geminiResp.Candidates, 1)
assert.Zero(t, geminiResp.UsageMetadata.PromptTokenCount)
}
func ptrValue[T any](value T) *T {
return &value
}
+69
View File
@@ -0,0 +1,69 @@
package service
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/setting/system_setting"
)
// WorkerRequest Worker请求的数据结构
type WorkerRequest struct {
URL string `json:"url"`
Key string `json:"key"`
Method string `json:"method,omitempty"`
Headers map[string]string `json:"headers,omitempty"`
Body json.RawMessage `json:"body,omitempty"`
}
// DoWorkerRequest 通过Worker发送请求
func DoWorkerRequest(req *WorkerRequest) (*http.Response, error) {
if !system_setting.EnableWorker() {
return nil, fmt.Errorf("worker not enabled")
}
if !system_setting.WorkerAllowHttpImageRequestEnabled && !strings.HasPrefix(req.URL, "https") {
return nil, fmt.Errorf("only support https url")
}
// SSRF防护:验证请求URL
fetchSetting := system_setting.GetFetchSetting()
if err := common.ValidateURLWithFetchSetting(req.URL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts, fetchSetting.ApplyIPFilterForDomain); err != nil {
return nil, fmt.Errorf("request reject: %v", err)
}
workerUrl := system_setting.WorkerUrl
if !strings.HasSuffix(workerUrl, "/") {
workerUrl += "/"
}
// 序列化worker请求数据
workerPayload, err := common.Marshal(req)
if err != nil {
return nil, fmt.Errorf("failed to marshal worker payload: %v", err)
}
return GetHttpClient().Post(workerUrl, "application/json", bytes.NewBuffer(workerPayload))
}
func DoDownloadRequest(originUrl string, reason ...string) (resp *http.Response, err error) {
if system_setting.EnableWorker() {
common.SysLog(fmt.Sprintf("downloading file from worker: %s, reason: %s", originUrl, strings.Join(reason, ", ")))
req := &WorkerRequest{
URL: originUrl,
Key: system_setting.WorkerValidKey,
}
return DoWorkerRequest(req)
} else {
// SSRF防护:验证请求URL(非Worker模式)
if err := ValidateSSRFProtectedFetchURL(originUrl); err != nil {
return nil, fmt.Errorf("request reject: %v", err)
}
common.SysLog(fmt.Sprintf("downloading from origin: %s, reason: %s", common.MaskSensitiveInfo(originUrl), strings.Join(reason, ", ")))
return GetSSRFProtectedHTTPClient().Get(originUrl)
}
}
+198
View File
@@ -0,0 +1,198 @@
package service
import (
"crypto/rand"
"errors"
"fmt"
"html"
"math/big"
"slices"
"strings"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
)
var (
ErrAccountEmailInvalid = errors.New("Please enter a valid email address")
ErrAccountEmailRestricted = errors.New("This email address is not allowed by the administrator's email policy.")
ErrEmailBindingDelivery = errors.New("Verification email could not be sent. Start email verification again.")
)
type EmailBindingData struct {
FlowToken string `json:"flow_token"`
Email string `json:"email"`
CurrentEmail string `json:"current_email,omitempty"`
OldEmailRequired bool `json:"old_email_required"`
ExpiresAt int64 `json:"expires_at"`
ResendAt int64 `json:"resend_at"`
NotificationWarning bool `json:"notification_warning"`
}
// ValidateAccountEmail is shared with registration mail delivery so binding
// cannot bypass the site's existing domain, alias and address-length rules.
func ValidateAccountEmail(email string) (string, error) {
email = model.NormalizeEmail(email)
if common.Validate.Var(email, "required,email,max=50") != nil {
return "", ErrAccountEmailInvalid
}
parts := strings.Split(email, "@")
if len(parts) != 2 {
return "", ErrAccountEmailInvalid
}
if common.EmailDomainRestrictionEnabled {
allowed := slices.Contains(common.EmailDomainWhitelist, parts[1])
if !allowed {
return "", ErrAccountEmailRestricted
}
}
if common.EmailAliasRestrictionEnabled && strings.ContainsAny(parts[0], "+.") {
return "", ErrAccountEmailRestricted
}
return email, nil
}
func StartEmailBinding(identity AuthIdentity, authorization *model.AuthFlowAuthorization, email string) (*EmailBindingData, error) {
email, err := ValidateAccountEmail(email)
if err != nil {
return nil, err
}
context, err := common.Marshal(AccountBindingContext{Provider: "email", Email: email})
if err != nil {
return nil, err
}
if err := ValidateFlowAuthorization(identity, VerificationOperation{Scope: VerificationScopeAccountBind, Context: context}, authorization); err != nil {
return nil, err
}
user, err := model.GetUserById(identity.UserID, false)
if err != nil {
return nil, err
}
state := model.EmailBindingState{Authorization: authorization, CurrentEmail: model.NormalizeEmail(user.Email), Email: email, ResendAt: time.Now().Add(model.EmailBindingResendDelay).Unix()}
requireOld := state.CurrentEmail != "" && authorization.Method != VerificationMethodTwoFA && authorization.Method != VerificationMethodPasskey
codes, err := generateEmailBindingCodes(requireOld)
if err != nil {
return nil, err
}
state.NewCodeHash, state.OldCodeHash = codes.NewHash, codes.OldHash
token, flow, err := model.CreateEmailBinding(identity, state)
if err != nil {
return nil, err
}
if err := sendEmailBindingCodes(state, codes); err != nil {
// A partially delivered pair must not leave a usable change request.
_, _ = model.ConsumeAuthFlow(token, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeEmailBinding, UserId: identity.UserID, SessionId: identity.SessionID})
return nil, ErrEmailBindingDelivery
}
data := emailBindingData(token, flow, &state)
if state.CurrentEmail != "" && !requireOld {
data.NotificationWarning = NotifyAccountSecurityChange(state.CurrentEmail, "A change of your email address was requested") != nil
}
return data, nil
}
func ResendAccountEmailBinding(identity AuthIdentity, token string) (*EmailBindingData, error) {
_, state, err := model.GetEmailBinding(identity, token)
if err != nil {
return nil, err
}
context, err := common.Marshal(AccountBindingContext{Provider: "email", Email: state.Email})
if err != nil {
return nil, err
}
if err := ValidateFlowAuthorization(identity, VerificationOperation{Scope: VerificationScopeAccountBind, Context: context}, state.Authorization); err != nil {
return nil, err
}
if state.ResendAt > time.Now().Unix() {
return nil, model.ErrEmailBindingResendWait
}
if state.FailedAttempts >= model.EmailBindingMaxAttempts {
return nil, model.ErrEmailBindingLocked
}
codes, err := generateEmailBindingCodes(state.OldCodeHash != "")
if err != nil {
return nil, err
}
flow, state, err := model.ResendEmailBinding(identity, token, codes.NewHash, codes.OldHash)
if err != nil {
return nil, err
}
if err := sendEmailBindingCodes(*state, codes); err != nil {
_, _ = model.ConsumeAuthFlow(token, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeEmailBinding, UserId: identity.UserID, SessionId: identity.SessionID})
return nil, ErrEmailBindingDelivery
}
return emailBindingData(token, flow, state), nil
}
func FinishEmailBinding(identity AuthIdentity, token, newCode, oldCode string) (*model.EmailBindingState, error) {
_, state, err := model.GetEmailBinding(identity, token)
if err != nil {
return nil, err
}
context, err := common.Marshal(AccountBindingContext{Provider: "email", Email: state.Email})
if err != nil {
return nil, err
}
if err := ValidateFlowAuthorization(identity, VerificationOperation{Scope: VerificationScopeAccountBind, Context: context}, state.Authorization); err != nil {
return nil, err
}
if _, err := ValidateAccountEmail(state.Email); err != nil {
return nil, err
}
return model.CompleteEmailBinding(identity, token, state.Email, newCode, oldCode)
}
type emailBindingCodes struct {
New, Old string
NewHash, OldHash string
}
func generateEmailBindingCodes(requireOld bool) (emailBindingCodes, error) {
var codes emailBindingCodes
count := 1
if requireOld {
count = 2
}
for index := 0; index < count; {
number, err := rand.Int(rand.Reader, big.NewInt(1_000_000))
if err != nil {
return codes, err
}
code := fmt.Sprintf("%06d", number.Int64())
if code == codes.New {
continue
}
hash, err := common.Password2Hash(code)
if err != nil {
return codes, err
}
if index == 0 {
codes.New, codes.NewHash = code, hash
} else {
codes.Old, codes.OldHash = code, hash
}
index++
}
return codes, nil
}
func sendEmailBindingCodes(state model.EmailBindingState, codes emailBindingCodes) error {
subject := common.SystemName + " — Confirm your email address"
content := fmt.Sprintf("<p>Confirm linking this email address to your account.</p><p>Verification code: <strong>%s</strong></p><p>This code expires in 10 minutes. If you did not request this change, do not share this code.</p>", html.EscapeString(codes.New))
if err := common.SendEmail(subject, state.Email, content); err != nil {
return err
}
if codes.Old != "" {
content = fmt.Sprintf("<p>A change to your account email address was requested. Confirm replacing your current address.</p><p>Verification code: <strong>%s</strong></p><p>This code expires in 10 minutes. If you did not request this change, do not share this code and contact your administrator.</p>", html.EscapeString(codes.Old))
return common.SendEmail(subject, state.CurrentEmail, content)
}
return nil
}
func emailBindingData(token string, flow *model.AuthFlow, state *model.EmailBindingState) *EmailBindingData {
return &EmailBindingData{
FlowToken: token, Email: state.Email, CurrentEmail: common.MaskEmail(state.CurrentEmail),
OldEmailRequired: state.OldCodeHash != "", ExpiresAt: flow.ExpiresAt.Unix(), ResendAt: state.ResendAt,
}
}
+13
View File
@@ -0,0 +1,13 @@
package service
import (
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/QuantumNous/new-api/setting/system_setting"
)
func GetCallbackAddress() string {
if operation_setting.CustomCallbackAddress == "" {
return system_setting.ServerAddress
}
return operation_setting.CustomCallbackAddress
}
+230
View File
@@ -0,0 +1,230 @@
package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"net/http"
"strconv"
"strings"
"github.com/QuantumNous/new-api/common"
taskdto "github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/types"
)
func MidjourneyErrorWrapper(code int, desc string) *taskdto.MidjourneyResponse {
return &taskdto.MidjourneyResponse{
Code: code,
Description: desc,
}
}
func MidjourneyErrorWithStatusCodeWrapper(code int, desc string, statusCode int) *taskdto.MidjourneyResponseWithStatusCode {
return &taskdto.MidjourneyResponseWithStatusCode{
StatusCode: statusCode,
Response: *MidjourneyErrorWrapper(code, desc),
}
}
//// OpenAIErrorWrapper wraps an error into an OpenAIErrorWithStatusCode
//func OpenAIErrorWrapper(err error, code string, statusCode int) *dto.OpenAIErrorWithStatusCode {
// text := err.Error()
// lowerText := strings.ToLower(text)
// if !strings.HasPrefix(lowerText, "get file base64 from url") && !strings.HasPrefix(lowerText, "mime type is not supported") {
// if strings.Contains(lowerText, "post") || strings.Contains(lowerText, "dial") || strings.Contains(lowerText, "http") {
// common.SysLog(fmt.Sprintf("error: %s", text))
// text = "请求上游地址失败"
// }
// }
// openAIError := dto.OpenAIError{
// Message: text,
// Type: "new_api_error",
// Code: code,
// }
// return &dto.OpenAIErrorWithStatusCode{
// Error: openAIError,
// StatusCode: statusCode,
// }
//}
//
//func OpenAIErrorWrapperLocal(err error, code string, statusCode int) *dto.OpenAIErrorWithStatusCode {
// openaiErr := OpenAIErrorWrapper(err, code, statusCode)
// openaiErr.LocalError = true
// return openaiErr
//}
func ClaudeErrorWrapper(err error, code string, statusCode int) *dto.ClaudeErrorWithStatusCode {
text := err.Error()
lowerText := strings.ToLower(text)
if !strings.HasPrefix(lowerText, "get file base64 from url") {
if strings.Contains(lowerText, "post") || strings.Contains(lowerText, "dial") || strings.Contains(lowerText, "http") {
common.SysLog(fmt.Sprintf("error: %s", text))
text = "请求上游地址失败"
}
}
claudeError := types.ClaudeError{
Message: text,
Type: "new_api_error",
}
return &dto.ClaudeErrorWithStatusCode{
Error: claudeError,
StatusCode: statusCode,
}
}
func ClaudeErrorWrapperLocal(err error, code string, statusCode int) *dto.ClaudeErrorWithStatusCode {
claudeErr := ClaudeErrorWrapper(err, code, statusCode)
claudeErr.LocalError = true
return claudeErr
}
func RelayErrorHandler(ctx context.Context, resp *http.Response, showBodyWhenFail bool) (newApiErr *types.NewAPIError) {
newApiErr = types.InitOpenAIError(types.ErrorCodeBadResponseStatusCode, resp.StatusCode)
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return
}
CloseResponseBodyGracefully(resp)
var errResponse dto.GeneralErrorResponse
responseBodyText := string(responseBody)
responseBodyPreview := common.LocalLogPreview(responseBodyText)
buildErrWithBody := func(message string) error {
if message == "" {
return fmt.Errorf("bad response status code %d, body: %s", resp.StatusCode, responseBodyText)
}
return fmt.Errorf("bad response status code %d, message: %s, body: %s", resp.StatusCode, message, responseBodyText)
}
err = common.Unmarshal(responseBody, &errResponse)
if err != nil {
if showBodyWhenFail {
newApiErr.Err = buildErrWithBody("")
} else {
logger.LogError(ctx, fmt.Sprintf("bad response status code %d, body: %s", resp.StatusCode, responseBodyPreview))
newApiErr.Err = fmt.Errorf("bad response status code %d", resp.StatusCode)
}
return
}
if common.GetJsonType(errResponse.Error) == "object" {
// General format error (OpenAI, Anthropic, Gemini, etc.)
oaiError := errResponse.TryToOpenAIError()
if oaiError != nil {
newApiErr = types.WithOpenAIError(*oaiError, resp.StatusCode)
if showBodyWhenFail {
newApiErr.Err = buildErrWithBody(newApiErr.Error())
}
return
}
}
message := errResponse.ToMessage()
if message == "" {
// The body parsed as JSON but carried no usable error message; log the
// raw body so the upstream failure remains diagnosable.
logger.LogError(ctx, fmt.Sprintf("bad response status code %d with empty error message, body: %s", resp.StatusCode, responseBodyPreview))
}
newApiErr = types.NewOpenAIError(errors.New(message), types.ErrorCodeBadResponseStatusCode, resp.StatusCode)
if showBodyWhenFail {
newApiErr.Err = buildErrWithBody(newApiErr.Error())
}
return
}
func ResetStatusCode(newApiErr *types.NewAPIError, statusCodeMappingStr string) {
if newApiErr == nil {
return
}
if statusCodeMappingStr == "" || statusCodeMappingStr == "{}" {
return
}
statusCodeMapping := make(map[string]any)
err := common.Unmarshal([]byte(statusCodeMappingStr), &statusCodeMapping)
if err != nil {
return
}
if newApiErr.StatusCode == http.StatusOK {
return
}
codeStr := strconv.Itoa(newApiErr.StatusCode)
if value, ok := statusCodeMapping[codeStr]; ok {
intCode, ok := parseStatusCodeMappingValue(value)
if !ok {
return
}
newApiErr.StatusCode = intCode
}
}
func parseStatusCodeMappingValue(value any) (int, bool) {
switch v := value.(type) {
case string:
if v == "" {
return 0, false
}
statusCode, err := strconv.Atoi(v)
if err != nil {
return 0, false
}
return statusCode, true
case float64:
if v != math.Trunc(v) {
return 0, false
}
return int(v), true
case int:
return v, true
case json.Number:
statusCode, err := strconv.Atoi(v.String())
if err != nil {
return 0, false
}
return statusCode, true
default:
return 0, false
}
}
func TaskErrorWrapperLocal(err error, code string, statusCode int) *taskdto.TaskError {
openaiErr := TaskErrorWrapper(err, code, statusCode)
openaiErr.LocalError = true
return openaiErr
}
func TaskErrorWrapper(err error, code string, statusCode int) *taskdto.TaskError {
text := err.Error()
lowerText := strings.ToLower(text)
if strings.Contains(lowerText, "post") || strings.Contains(lowerText, "dial") || strings.Contains(lowerText, "http") {
common.SysLog(fmt.Sprintf("error: %s", text))
//text = "请求上游地址失败"
text = common.MaskSensitiveInfo(text)
}
//避免暴露内部错误
taskError := &taskdto.TaskError{
Code: code,
Message: text,
StatusCode: statusCode,
Error: err,
}
return taskError
}
// TaskErrorFromAPIError 将 PreConsumeBilling 返回的 NewAPIError 转换为 TaskError。
func TaskErrorFromAPIError(apiErr *types.NewAPIError) *taskdto.TaskError {
if apiErr == nil {
return nil
}
return &taskdto.TaskError{
Code: string(apiErr.GetErrorCode()),
Message: apiErr.Err.Error(),
StatusCode: apiErr.StatusCode,
Error: apiErr.Err,
}
}
+160
View File
@@ -0,0 +1,160 @@
package service
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"strings"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func TestResetStatusCode(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
statusCode int
statusCodeConfig string
expectedCode int
}{
{
name: "map string value",
statusCode: 429,
statusCodeConfig: `{"429":"503"}`,
expectedCode: 503,
},
{
name: "map int value",
statusCode: 429,
statusCodeConfig: `{"429":503}`,
expectedCode: 503,
},
{
name: "skip invalid string value",
statusCode: 429,
statusCodeConfig: `{"429":"bad-code"}`,
expectedCode: 429,
},
{
name: "skip status code 200",
statusCode: 200,
statusCodeConfig: `{"200":503}`,
expectedCode: 200,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
newAPIError := &types.NewAPIError{
StatusCode: tc.statusCode,
}
ResetStatusCode(newAPIError, tc.statusCodeConfig)
require.Equal(t, tc.expectedCode, newAPIError.StatusCode)
})
}
}
func TestRelayErrorHandlerTruncatesInvalidJSONBodyInLog(t *testing.T) {
withDebugEnabled(t, false)
body := strings.Repeat("b", common.LocalLogContentLimit+256)
var logBuffer bytes.Buffer
common.LogWriterMu.Lock()
oldWriter := gin.DefaultErrorWriter
gin.DefaultErrorWriter = &logBuffer
common.LogWriterMu.Unlock()
t.Cleanup(func() {
common.LogWriterMu.Lock()
gin.DefaultErrorWriter = oldWriter
common.LogWriterMu.Unlock()
})
resp := &http.Response{
StatusCode: http.StatusInternalServerError,
Body: io.NopCloser(strings.NewReader(body)),
}
newAPIError := RelayErrorHandler(context.Background(), resp, false)
require.NotNil(t, newAPIError)
require.Equal(t, "bad response status code 500", newAPIError.Error())
require.Contains(t, logBuffer.String(), "[truncated")
require.Contains(t, logBuffer.String(), fmt.Sprintf("original_length=%d", len(body)))
require.NotContains(t, logBuffer.String(), strings.Repeat("b", common.LocalLogContentLimit+1))
}
func TestRelayErrorHandlerKeepsStructuredErrorMessage(t *testing.T) {
message := strings.Repeat("c", common.LocalLogContentLimit+256)
body := `{"message":"` + message + `"}`
resp := &http.Response{
StatusCode: http.StatusInternalServerError,
Body: io.NopCloser(strings.NewReader(body)),
}
newAPIError := RelayErrorHandler(context.Background(), resp, false)
require.NotNil(t, newAPIError)
require.Equal(t, message, newAPIError.Error())
}
func TestRelayErrorHandlerKeepsOpenAIErrorMessage(t *testing.T) {
message := strings.Repeat("d", common.LocalLogContentLimit+256)
body := `{"error":{"message":"` + message + `","type":"server_error","code":"server_error"}}`
resp := &http.Response{
StatusCode: http.StatusInternalServerError,
Body: io.NopCloser(strings.NewReader(body)),
}
newAPIError := RelayErrorHandler(context.Background(), resp, false)
require.NotNil(t, newAPIError)
require.Equal(t, message, newAPIError.Error())
}
func TestRelayErrorHandlerKeepsInvalidJSONBodyInDebugLog(t *testing.T) {
withDebugEnabled(t, true)
body := strings.Repeat("e", common.LocalLogContentLimit+256)
var logBuffer bytes.Buffer
common.LogWriterMu.Lock()
oldWriter := gin.DefaultErrorWriter
gin.DefaultErrorWriter = &logBuffer
common.LogWriterMu.Unlock()
t.Cleanup(func() {
common.LogWriterMu.Lock()
gin.DefaultErrorWriter = oldWriter
common.LogWriterMu.Unlock()
})
resp := &http.Response{
StatusCode: http.StatusInternalServerError,
Body: io.NopCloser(strings.NewReader(body)),
}
newAPIError := RelayErrorHandler(context.Background(), resp, false)
require.NotNil(t, newAPIError)
require.NotContains(t, logBuffer.String(), "[truncated")
require.Contains(t, logBuffer.String(), body)
}
func withDebugEnabled(t *testing.T, enabled bool) {
t.Helper()
oldDebug := common.DebugEnabled
common.DebugEnabled = enabled
t.Cleanup(func() {
common.DebugEnabled = oldDebug
})
}
+212
View File
@@ -0,0 +1,212 @@
package service
import (
"bytes"
"fmt"
"image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"io"
"net/http"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/gin-gonic/gin"
)
// GetFileTypeFromUrl 获取文件类型,返回 mime type, 例如 image/jpeg, image/png, image/gif, image/bmp, image/tiff, application/pdf
// 如果获取失败,返回 application/octet-stream
func GetFileTypeFromUrl(c *gin.Context, url string, reason ...string) (string, error) {
response, err := DoDownloadRequest(url, []string{"get_mime_type", strings.Join(reason, ", ")}...)
if err != nil {
common.SysLog(fmt.Sprintf("fail to get file type from url: %s, error: %s", url, err.Error()))
return "", err
}
defer response.Body.Close()
if response.StatusCode != 200 {
logger.LogError(c, fmt.Sprintf("failed to download file from %s, status code: %d", url, response.StatusCode))
return "", fmt.Errorf("failed to download file, status code: %d", response.StatusCode)
}
if headerType := strings.TrimSpace(response.Header.Get("Content-Type")); headerType != "" {
if i := strings.Index(headerType, ";"); i != -1 {
headerType = headerType[:i]
}
if headerType != "application/octet-stream" {
return headerType, nil
}
}
if cd := response.Header.Get("Content-Disposition"); cd != "" {
parts := strings.SplitSeq(cd, ";")
for part := range parts {
part = strings.TrimSpace(part)
if strings.HasPrefix(strings.ToLower(part), "filename=") {
name := strings.TrimSpace(strings.TrimPrefix(part, "filename="))
if len(name) > 2 && name[0] == '"' && name[len(name)-1] == '"' {
name = name[1 : len(name)-1]
}
if dot := strings.LastIndex(name, "."); dot != -1 && dot+1 < len(name) {
ext := strings.ToLower(name[dot+1:])
if ext != "" {
mt := GetMimeTypeByExtension(ext)
if mt != "application/octet-stream" {
return mt, nil
}
}
}
break
}
}
}
cleanedURL := url
if q := strings.Index(cleanedURL, "?"); q != -1 {
cleanedURL = cleanedURL[:q]
}
if slash := strings.LastIndex(cleanedURL, "/"); slash != -1 && slash+1 < len(cleanedURL) {
last := cleanedURL[slash+1:]
if dot := strings.LastIndex(last, "."); dot != -1 && dot+1 < len(last) {
ext := strings.ToLower(last[dot+1:])
if ext != "" {
mt := GetMimeTypeByExtension(ext)
if mt != "application/octet-stream" {
return mt, nil
}
}
}
}
var readData []byte
limits := []int{512, 8 * 1024, 24 * 1024, 64 * 1024}
for _, limit := range limits {
logger.LogDebug(c, "Trying to read %d bytes to determine file type", limit)
if len(readData) < limit {
need := limit - len(readData)
tmp := make([]byte, need)
n, _ := io.ReadFull(response.Body, tmp)
if n > 0 {
readData = append(readData, tmp[:n]...)
}
}
if len(readData) == 0 {
continue
}
sniffed := http.DetectContentType(readData)
if sniffed != "" && sniffed != "application/octet-stream" {
return sniffed, nil
}
// Try HEIF/HEIC detection (Go standard library doesn't recognize it)
if heifMime := detectHEIF(readData); heifMime != "" {
return heifMime, nil
}
if _, format, err := image.DecodeConfig(bytes.NewReader(readData)); err == nil {
switch strings.ToLower(format) {
case "jpeg", "jpg":
return "image/jpeg", nil
case "png":
return "image/png", nil
case "gif":
return "image/gif", nil
case "bmp":
return "image/bmp", nil
case "tiff":
return "image/tiff", nil
default:
if format != "" {
return "image/" + strings.ToLower(format), nil
}
}
}
}
// Fallback
return "application/octet-stream", nil
}
// GetFileBase64FromUrl 从 URL 获取文件的 base64 编码数据
// Deprecated: 请使用 GetBase64Data 配合 types.NewURLFileSource 替代
// 此函数保留用于向后兼容,内部已重构为调用统一的文件服务
func GetFileBase64FromUrl(c *gin.Context, url string, reason ...string) (*types.LocalFileData, error) {
source := types.NewURLFileSource(url)
cachedData, err := LoadFileSource(c, source, reason...)
if err != nil {
return nil, err
}
// 转换为旧的 LocalFileData 格式以保持兼容
base64Data, err := cachedData.GetBase64Data()
if err != nil {
return nil, err
}
return &types.LocalFileData{
Base64Data: base64Data,
MimeType: cachedData.MimeType,
Size: cachedData.Size,
Url: url,
}, nil
}
func GetMimeTypeByExtension(ext string) string {
// Convert to lowercase for case-insensitive comparison
ext = strings.ToLower(ext)
switch ext {
// Text files
case "txt", "md", "markdown", "csv", "json", "xml", "html", "htm":
return "text/plain"
// Image files
case "jpg", "jpeg":
return "image/jpeg"
case "png":
return "image/png"
case "gif":
return "image/gif"
case "jfif":
return "image/jpeg"
case "heic":
return "image/heic"
case "heif":
return "image/heif"
// Audio files
case "mp3":
return "audio/mp3"
case "wav":
return "audio/wav"
case "mpeg":
return "audio/mpeg"
// Video files
case "mp4":
return "video/mp4"
case "wmv":
return "video/wmv"
case "flv":
return "video/flv"
case "mov":
return "video/mov"
case "mpg":
return "video/mpg"
case "avi":
return "video/avi"
case "mpegps":
return "video/mpegps"
// Document files
case "pdf":
return "application/pdf"
default:
return "application/octet-stream" // Default for unknown types
}
}
+607
View File
@@ -0,0 +1,607 @@
package service
import (
"bytes"
"encoding/base64"
"encoding/binary"
"fmt"
"image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"io"
"net/http"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/gin-gonic/gin"
"golang.org/x/image/webp"
)
// FileService 统一的文件处理服务
// 提供文件下载、解码、缓存等功能的统一入口
// getContextCacheKey 生成 URL context 缓存的 key
func getContextCacheKey(url string) string {
return fmt.Sprintf("file_cache_%s", common.GenerateHMAC(url))
}
// getBase64ContextCacheKey 生成 base64 context 缓存的 key
// 使用 length + MIME + 前 128 字符作为输入,避免对整个 base64 数据做 hash
func getBase64ContextCacheKey(data string, mimeType string) string {
keyMaterial := fmt.Sprintf("%d:%s:", len(data), mimeType)
if len(data) > 128 {
keyMaterial += data[:128]
} else {
keyMaterial += data
}
return fmt.Sprintf("b64_cache_%s", common.GenerateHMAC(keyMaterial))
}
// LoadFileSource 加载文件源数据
// 这是统一的入口,会自动处理缓存和不同的来源类型
func LoadFileSource(c *gin.Context, source types.FileSource, reason ...string) (*types.CachedFileData, error) {
if source == nil {
return nil, fmt.Errorf("file source is nil")
}
if common.DebugEnabled {
logger.LogDebug(c, "LoadFileSource starting for: %s", source.GetIdentifier())
}
// 1. 快速检查内部缓存
if source.HasCache() {
if c != nil {
registerSourceForCleanup(c, source)
}
return source.GetCache(), nil
}
// 2. 加锁保护加载过程
source.Mu().Lock()
defer source.Mu().Unlock()
// 3. 双重检查
if source.HasCache() {
if c != nil {
registerSourceForCleanup(c, source)
}
return source.GetCache(), nil
}
// 4. 根据来源类型加载(含 URL context 缓存查找)
var cachedData *types.CachedFileData
var contextKey string
var err error
switch s := source.(type) {
case *types.URLSource:
if c != nil {
contextKey = getContextCacheKey(s.URL)
if cached, exists := c.Get(contextKey); exists {
data := cached.(*types.CachedFileData)
source.SetCache(data)
registerSourceForCleanup(c, source)
return data, nil
}
}
cachedData, err = loadFromURL(c, s.URL, reason...)
case *types.Base64Source:
if c != nil {
contextKey = getBase64ContextCacheKey(s.Base64Data, s.MimeType)
if cached, exists := c.Get(contextKey); exists {
data := cached.(*types.CachedFileData)
source.SetCache(data)
registerSourceForCleanup(c, source)
return data, nil
}
}
cachedData, err = loadFromBase64(s.Base64Data, s.MimeType)
default:
return nil, fmt.Errorf("unsupported file source type: %T", source)
}
if err != nil {
return nil, err
}
// 5. 设置缓存
source.SetCache(cachedData)
if contextKey != "" && c != nil {
c.Set(contextKey, cachedData)
}
// 6. 注册到 context 以便请求结束时自动清理
if c != nil {
registerSourceForCleanup(c, source)
}
return cachedData, nil
}
// registerSourceForCleanup 注册 FileSource 到 context 以便请求结束时清理
func registerSourceForCleanup(c *gin.Context, source types.FileSource) {
if source.IsRegistered() {
return
}
key := string(constant.ContextKeyFileSourcesToCleanup)
var sources []types.FileSource
if existing, exists := c.Get(key); exists {
sources = existing.([]types.FileSource)
}
sources = append(sources, source)
c.Set(key, sources)
source.SetRegistered(true)
}
// CleanupFileSources 清理请求中所有注册的 FileSource
// 应在请求结束时调用(通常由中间件自动调用)
func CleanupFileSources(c *gin.Context) {
key := string(constant.ContextKeyFileSourcesToCleanup)
if sources, exists := c.Get(key); exists {
for _, source := range sources.([]types.FileSource) {
if cache := source.GetCache(); cache != nil {
cache.Close()
}
}
c.Set(key, nil)
}
}
// loadFromURL 从 URL 加载文件
func loadFromURL(c *gin.Context, url string, reason ...string) (*types.CachedFileData, error) {
// 下载文件
var maxFileSize = constant.MaxFileDownloadMB * 1024 * 1024
if common.DebugEnabled {
logger.LogDebug(c, "loadFromURL: initiating download")
}
resp, err := DoDownloadRequest(url, reason...)
if err != nil {
return nil, fmt.Errorf("failed to download file from %s: %w", url, err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("failed to download file, status code: %d", resp.StatusCode)
}
// 读取文件内容(限制大小)
if common.DebugEnabled {
logger.LogDebug(c, "loadFromURL: reading response body")
}
fileBytes, err := io.ReadAll(io.LimitReader(resp.Body, int64(maxFileSize+1)))
if err != nil {
return nil, fmt.Errorf("failed to read file content: %w", err)
}
if len(fileBytes) > maxFileSize {
return nil, fmt.Errorf("file size exceeds maximum allowed size: %dMB", constant.MaxFileDownloadMB)
}
// 转换为 base64
base64Data := base64.StdEncoding.EncodeToString(fileBytes)
// 智能获取 MIME 类型
mimeType := smartDetectMimeType(resp, url, fileBytes)
// 判断是否使用磁盘缓存
base64Size := int64(len(base64Data))
var cachedData *types.CachedFileData
if shouldUseDiskCache(base64Size) {
// 使用磁盘缓存
diskPath, err := writeToDiskCache(base64Data)
if err != nil {
// 磁盘缓存失败,回退到内存
logger.LogWarn(c, fmt.Sprintf("Failed to write to disk cache, falling back to memory: %v", err))
cachedData = types.NewMemoryCachedData(base64Data, mimeType, int64(len(fileBytes)))
} else {
cachedData = types.NewDiskCachedData(diskPath, mimeType, int64(len(fileBytes)))
cachedData.DiskSize = base64Size
cachedData.OnClose = func(size int64) {
common.DecrementDiskFiles(size)
}
common.IncrementDiskFiles(base64Size)
if common.DebugEnabled {
logger.LogDebug(c, "File cached to disk: %s, size: %d bytes", diskPath, base64Size)
}
}
} else {
// 使用内存缓存
cachedData = types.NewMemoryCachedData(base64Data, mimeType, int64(len(fileBytes)))
}
// 如果是图片,尝试获取图片配置
if strings.HasPrefix(mimeType, "image/") {
if common.DebugEnabled {
logger.LogDebug(c, "loadFromURL: decoding image config")
}
config, format, err := decodeImageConfig(fileBytes)
if err == nil {
cachedData.ImageConfig = &config
cachedData.ImageFormat = format
// 如果通过图片解码获取了更准确的格式,更新 MIME 类型
if mimeType == "application/octet-stream" || mimeType == "" {
cachedData.MimeType = "image/" + format
}
}
}
return cachedData, nil
}
// shouldUseDiskCache 判断是否应该使用磁盘缓存
func shouldUseDiskCache(dataSize int64) bool {
return common.ShouldUseDiskCache(dataSize)
}
// writeToDiskCache 将数据写入磁盘缓存
func writeToDiskCache(base64Data string) (string, error) {
return common.WriteDiskCacheFileString(common.DiskCacheTypeFile, base64Data)
}
// smartDetectMimeType 智能检测 MIME 类型
func smartDetectMimeType(resp *http.Response, url string, fileBytes []byte) string {
// 1. 尝试从 Content-Type header 获取
mimeType := resp.Header.Get("Content-Type")
if idx := strings.Index(mimeType, ";"); idx != -1 {
mimeType = strings.TrimSpace(mimeType[:idx])
}
if mimeType != "" && mimeType != "application/octet-stream" {
return mimeType
}
// 2. 尝试从 Content-Disposition header 的 filename 获取
if cd := resp.Header.Get("Content-Disposition"); cd != "" {
parts := strings.SplitSeq(cd, ";")
for part := range parts {
part = strings.TrimSpace(part)
if strings.HasPrefix(strings.ToLower(part), "filename=") {
name := strings.TrimSpace(strings.TrimPrefix(part, "filename="))
// 移除引号
if len(name) > 2 && name[0] == '"' && name[len(name)-1] == '"' {
name = name[1 : len(name)-1]
}
if dot := strings.LastIndex(name, "."); dot != -1 && dot+1 < len(name) {
ext := strings.ToLower(name[dot+1:])
if ext != "" {
mt := GetMimeTypeByExtension(ext)
if mt != "application/octet-stream" {
return mt
}
}
}
break
}
}
}
// 3. 尝试从 URL 路径获取扩展名
mt := guessMimeTypeFromURL(url)
if mt != "application/octet-stream" {
return mt
}
// 4. 使用 http.DetectContentType 内容嗅探
if len(fileBytes) > 0 {
sniffed := http.DetectContentType(fileBytes)
if sniffed != "" && sniffed != "application/octet-stream" {
// 去除可能的 charset 参数
if idx := strings.Index(sniffed, ";"); idx != -1 {
sniffed = strings.TrimSpace(sniffed[:idx])
}
return sniffed
}
// 4.5 尝试 HEIF/HEIC 检测(Go 标准库不识别)
if heifMime := detectHEIF(fileBytes); heifMime != "" {
return heifMime
}
}
// 5. 尝试作为图片解码获取格式
if len(fileBytes) > 0 {
if _, format, err := decodeImageConfig(fileBytes); err == nil && format != "" {
return "image/" + strings.ToLower(format)
}
}
// 最终回退
return "application/octet-stream"
}
// loadFromBase64 从 base64 字符串加载文件
func loadFromBase64(base64String string, providedMimeType string) (*types.CachedFileData, error) {
var mimeType string
var cleanBase64 string
// 处理 data: 前缀
if strings.HasPrefix(base64String, "data:") {
before, after, ok := strings.Cut(base64String, ",")
if ok {
header := before
cleanBase64 = after
if strings.Contains(header, ":") && strings.Contains(header, ";") {
mimeStart := strings.Index(header, ":") + 1
mimeEnd := strings.Index(header, ";")
if mimeStart < mimeEnd {
mimeType = header[mimeStart:mimeEnd]
}
}
} else {
cleanBase64 = base64String
}
} else {
cleanBase64 = base64String
}
if providedMimeType != "" {
mimeType = providedMimeType
}
decodedData, err := base64.StdEncoding.DecodeString(cleanBase64)
if err != nil {
return nil, fmt.Errorf("failed to decode base64 data: %w", err)
}
base64Size := int64(len(cleanBase64))
var cachedData *types.CachedFileData
if shouldUseDiskCache(base64Size) {
diskPath, err := writeToDiskCache(cleanBase64)
if err != nil {
cachedData = types.NewMemoryCachedData(cleanBase64, mimeType, int64(len(decodedData)))
} else {
cachedData = types.NewDiskCachedData(diskPath, mimeType, int64(len(decodedData)))
cachedData.DiskSize = base64Size
cachedData.OnClose = func(size int64) {
common.DecrementDiskFiles(size)
}
common.IncrementDiskFiles(base64Size)
}
} else {
cachedData = types.NewMemoryCachedData(cleanBase64, mimeType, int64(len(decodedData)))
}
if mimeType == "" || strings.HasPrefix(mimeType, "image/") {
config, format, err := decodeImageConfig(decodedData)
if err == nil {
cachedData.ImageConfig = &config
cachedData.ImageFormat = format
if mimeType == "" {
cachedData.MimeType = "image/" + format
}
}
}
return cachedData, nil
}
// GetImageConfig 获取图片配置
func GetImageConfig(c *gin.Context, source types.FileSource) (image.Config, string, error) {
cachedData, err := LoadFileSource(c, source, "get_image_config")
if err != nil {
return image.Config{}, "", err
}
if cachedData.ImageConfig != nil {
return *cachedData.ImageConfig, cachedData.ImageFormat, nil
}
base64Str, err := cachedData.GetBase64Data()
if err != nil {
return image.Config{}, "", fmt.Errorf("failed to get base64 data: %w", err)
}
decodedData, err := base64.StdEncoding.DecodeString(base64Str)
if err != nil {
return image.Config{}, "", fmt.Errorf("failed to decode base64 for image config: %w", err)
}
config, format, err := decodeImageConfig(decodedData)
if err != nil {
return image.Config{}, "", err
}
cachedData.ImageConfig = &config
cachedData.ImageFormat = format
return config, format, nil
}
// GetBase64Data 获取 base64 编码的数据
func GetBase64Data(c *gin.Context, source types.FileSource, reason ...string) (string, string, error) {
cachedData, err := LoadFileSource(c, source, reason...)
if err != nil {
return "", "", err
}
base64Str, err := cachedData.GetBase64Data()
if err != nil {
return "", "", fmt.Errorf("failed to get base64 data: %w", err)
}
return base64Str, cachedData.MimeType, nil
}
// GetMimeType 获取文件的 MIME 类型
func GetMimeType(c *gin.Context, source types.FileSource) (string, error) {
if source.HasCache() {
return source.GetCache().MimeType, nil
}
if urlSource, ok := source.(*types.URLSource); ok {
mimeType, err := GetFileTypeFromUrl(c, urlSource.URL, "get_mime_type")
if err == nil && mimeType != "" && mimeType != "application/octet-stream" {
return mimeType, nil
}
}
cachedData, err := LoadFileSource(c, source, "get_mime_type")
if err != nil {
return "", err
}
return cachedData.MimeType, nil
}
// DetectFileType 检测文件类型
func DetectFileType(mimeType string) types.FileType {
if strings.HasPrefix(mimeType, "image/") {
return types.FileTypeImage
}
if strings.HasPrefix(mimeType, "audio/") {
return types.FileTypeAudio
}
if strings.HasPrefix(mimeType, "video/") {
return types.FileTypeVideo
}
return types.FileTypeFile
}
// decodeImageConfig 从字节数据解码图片配置
func decodeImageConfig(data []byte) (image.Config, string, error) {
reader := bytes.NewReader(data)
config, format, err := image.DecodeConfig(reader)
if err == nil {
return config, format, nil
}
reader.Seek(0, io.SeekStart)
config, err = webp.DecodeConfig(reader)
if err == nil {
return config, "webp", nil
}
// Try HEIF/HEIC: parse ISOBMFF ispe box for dimensions
if heifMime := detectHEIF(data); heifMime != "" {
formatName := "heif"
if heifMime == "image/heic" {
formatName = "heic"
}
if w, h, ok := parseHEIFDimensions(data); ok {
return image.Config{Width: w, Height: h}, formatName, nil
}
return image.Config{}, "", fmt.Errorf("failed to decode HEIF/HEIC image dimensions")
}
return image.Config{}, "", fmt.Errorf("failed to decode image config: unsupported format")
}
// detectHEIF checks ISOBMFF magic bytes to detect HEIC/HEIF files.
// Returns "image/heic", "image/heif", or "" if not recognized.
func detectHEIF(data []byte) string {
if len(data) < 12 {
return ""
}
// ISOBMFF: bytes[4:8] must be "ftyp"
if string(data[4:8]) != "ftyp" {
return ""
}
brand := string(data[8:12])
switch brand {
case "heic", "heix", "hevc", "hevx", "heim", "heis":
return "image/heic"
case "mif1", "msf1":
return "image/heif"
default:
return ""
}
}
// parseHEIFDimensions parses ISOBMFF box tree to find the ispe box
// and extract image width/height. Returns (width, height, ok).
func parseHEIFDimensions(data []byte) (int, int, bool) {
size := len(data)
if size < 12 {
return 0, 0, false
}
// Walk top-level boxes to find "meta"
offset := 0
for offset+8 <= size {
boxSize := int(binary.BigEndian.Uint32(data[offset : offset+4]))
boxType := string(data[offset+4 : offset+8])
headerLen := 8
if boxSize == 1 {
// 64-bit extended size
if offset+16 > size {
break
}
boxSize = int(binary.BigEndian.Uint64(data[offset+8 : offset+16]))
headerLen = 16
} else if boxSize == 0 {
// box extends to end of data
boxSize = size - offset
}
if boxSize < headerLen || offset+boxSize > size {
break
}
if boxType == "meta" {
// meta is a full box: 4 bytes version/flags after header
metaData := data[offset+headerLen : offset+boxSize]
if len(metaData) < 4 {
return 0, 0, false
}
return findISPE(metaData[4:])
}
offset += boxSize
}
return 0, 0, false
}
// findISPE recursively searches for the ispe box within container boxes.
// Path: meta -> iprp -> ipco -> ispe
func findISPE(data []byte) (int, int, bool) {
offset := 0
size := len(data)
for offset+8 <= size {
boxSize := int(binary.BigEndian.Uint32(data[offset : offset+4]))
boxType := string(data[offset+4 : offset+8])
if boxSize < 8 || offset+boxSize > size {
break
}
content := data[offset+8 : offset+boxSize]
switch boxType {
case "iprp", "ipco":
if w, h, ok := findISPE(content); ok {
return w, h, true
}
case "ispe":
// ispe is a full box: 4 bytes version/flags, then 4 bytes width, 4 bytes height
if len(content) >= 12 {
w := int(binary.BigEndian.Uint32(content[4:8]))
h := int(binary.BigEndian.Uint32(content[8:12]))
if w > 0 && h > 0 {
return w, h, true
}
}
}
offset += boxSize
}
return 0, 0, false
}
// guessMimeTypeFromURL 从 URL 猜测 MIME 类型
func guessMimeTypeFromURL(url string) string {
cleanedURL := url
if q := strings.Index(cleanedURL, "?"); q != -1 {
cleanedURL = cleanedURL[:q]
}
if slash := strings.LastIndex(cleanedURL, "/"); slash != -1 && slash+1 < len(cleanedURL) {
last := cleanedURL[slash+1:]
if dot := strings.LastIndex(last, "."); dot != -1 && dot+1 < len(last) {
ext := strings.ToLower(last[dot+1:])
return GetMimeTypeByExtension(ext)
}
}
return "application/octet-stream"
}
+149
View File
@@ -0,0 +1,149 @@
package service
import (
"errors"
"time"
"github.com/QuantumNous/new-api/model"
)
// ---------------------------------------------------------------------------
// FundingSource — 资金来源接口(钱包 or 订阅)
// ---------------------------------------------------------------------------
// FundingSource 抽象了预扣费的资金来源。
type FundingSource interface {
// Source 返回资金来源标识:"wallet" 或 "subscription"
Source() string
// PreConsume 从该资金来源预扣 amount 额度
PreConsume(amount int) error
// Settle 根据差额调整资金来源(正数补扣,负数退还)
Settle(delta int) error
// Refund 退还所有预扣费
Refund() error
}
// ---------------------------------------------------------------------------
// WalletFunding — 钱包资金来源实现
// ---------------------------------------------------------------------------
// ErrInsufficientWalletQuota 钱包原子预扣失败(余额不足),未发生任何扣减。
// BillingSession 据此映射为 ErrorCodeInsufficientUserQuota,
// 使 wallet_first 等计费偏好可以回退到订阅。
var ErrInsufficientWalletQuota = errors.New("wallet quota insufficient")
type WalletFunding struct {
userId int
consumed int // 实际预扣的用户额度
}
func (w *WalletFunding) Source() string { return BillingSourceWallet }
func (w *WalletFunding) PreConsume(amount int) error {
if amount <= 0 {
return nil
}
reserved, err := model.TryReserveUserQuota(w.userId, amount)
if err != nil {
return err
}
if !reserved {
return ErrInsufficientWalletQuota
}
w.consumed = amount
return nil
}
func (w *WalletFunding) Settle(delta int) error {
if delta == 0 {
return nil
}
if delta > 0 {
return model.DecreaseUserQuota(w.userId, delta, false)
}
return model.IncreaseUserQuota(w.userId, -delta, false)
}
func (w *WalletFunding) Refund() error {
if w.consumed <= 0 {
return nil
}
// IncreaseUserQuota 是 quota += N 的非幂等操作,不能重试,否则会多退额度。
// 订阅的 RefundSubscriptionPreConsume 有 requestId 幂等保护所以可以重试。
return model.IncreaseUserQuota(w.userId, w.consumed, false)
}
// ---------------------------------------------------------------------------
// SubscriptionFunding — 订阅资金来源实现
// ---------------------------------------------------------------------------
type SubscriptionFunding struct {
requestId string
userId int
modelName string
amount int64 // 预扣的订阅额度(subConsume)
subscriptionId int
preConsumed int64
// 以下字段在 PreConsume 成功后填充,供 RelayInfo 同步使用
AmountTotal int64
AmountUsedAfter int64
PlanId int
PlanTitle string
}
func (s *SubscriptionFunding) Source() string { return BillingSourceSubscription }
func (s *SubscriptionFunding) PreConsume(_ int) error {
// amount 参数被忽略,使用内部 s.amount(已在构造时根据 preConsumedQuota 计算)
res, err := model.PreConsumeUserSubscription(s.requestId, s.userId, s.modelName, 0, s.amount)
if err != nil {
return err
}
s.subscriptionId = res.UserSubscriptionId
s.preConsumed = res.PreConsumed
s.AmountTotal = res.AmountTotal
s.AmountUsedAfter = res.AmountUsedAfter
// 获取订阅计划信息
if planInfo, err := model.GetSubscriptionPlanInfoByUserSubscriptionId(res.UserSubscriptionId); err == nil && planInfo != nil {
s.PlanId = planInfo.PlanId
s.PlanTitle = planInfo.PlanTitle
}
return nil
}
func (s *SubscriptionFunding) Settle(delta int) error {
if delta == 0 {
return nil
}
return model.PostConsumeUserSubscriptionDelta(s.subscriptionId, int64(delta))
}
func (s *SubscriptionFunding) Refund() error {
if s.preConsumed <= 0 {
return nil
}
return refundWithRetry(func() error {
return model.RefundSubscriptionPreConsume(s.requestId)
})
}
// refundWithRetry 尝试多次执行退款操作以提高成功率,只能用于基于事务的退款函数!!!!!!
// try to refund with retries, only for refund functions based on transactions!!!
func refundWithRetry(fn func() error) error {
if fn == nil {
return nil
}
const maxAttempts = 3
var lastErr error
for i := range maxAttempts {
if err := fn(); err == nil {
return nil
} else {
lastErr = err
}
if i < maxAttempts-1 {
time.Sleep(time.Duration(200*(i+1)) * time.Millisecond)
}
}
return lastErr
}
+133
View File
@@ -0,0 +1,133 @@
package service
import (
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/setting"
"github.com/QuantumNous/new-api/setting/ratio_setting"
"github.com/gin-gonic/gin"
)
func GetUserUsableGroups(userGroup string) map[string]string {
groupsCopy := setting.GetUserUsableGroupsCopy()
if userGroup != "" {
specialSettings, b := ratio_setting.GetGroupRatioSetting().GroupSpecialUsableGroup.Get(userGroup)
if b {
// 处理特殊可用分组
for specialGroup, desc := range specialSettings {
if after, ok := strings.CutPrefix(specialGroup, "-:"); ok {
// 移除分组
groupToRemove := after
delete(groupsCopy, groupToRemove)
} else if after, ok := strings.CutPrefix(specialGroup, "+:"); ok {
// 添加分组
groupToAdd := after
groupsCopy[groupToAdd] = desc
} else {
// 直接添加分组
groupsCopy[specialGroup] = desc
}
}
}
// 如果userGroup不在UserUsableGroups中,返回UserUsableGroups + userGroup
if _, ok := groupsCopy[userGroup]; !ok {
groupsCopy[userGroup] = "用户分组"
}
}
return groupsCopy
}
func GroupInUserUsableGroups(userGroup, groupName string) bool {
_, ok := GetUserUsableGroups(userGroup)[groupName]
return ok
}
func IsUserSelectableGroup(userGroup, groupName string) bool {
if groupName == "" || groupName == "auto" {
return false
}
return GroupInUserUsableGroups(userGroup, groupName) && ratio_setting.ContainsGroupRatio(groupName)
}
// GetUserAutoGroup 根据用户分组获取自动分组设置
func GetUserAutoGroup(userGroup string) []string {
autoGroups := make([]string, 0)
seen := make(map[string]struct{})
for _, group := range setting.GetAutoGroups() {
if !IsUserSelectableGroup(userGroup, group) {
continue
}
if _, ok := seen[group]; ok {
continue
}
seen[group] = struct{}{}
autoGroups = append(autoGroups, group)
}
return autoGroups
}
// FilterUserTokenAutoGroups applies current permissions before the current
// per-token limit. It intentionally does not fall back to the global Auto list.
func FilterUserTokenAutoGroups(userGroup string, groups []string) []string {
maxCount := setting.GetMaxTokenAutoGroups()
filtered := make([]string, 0, min(len(groups), maxCount))
seen := make(map[string]struct{})
for _, group := range groups {
if !IsUserSelectableGroup(userGroup, group) {
continue
}
if _, ok := seen[group]; ok {
continue
}
seen[group] = struct{}{}
filtered = append(filtered, group)
if len(filtered) == maxCount {
break
}
}
return filtered
}
// GetRequestAutoGroups resolves the ordered Auto groups for the current token.
// The absence of the context value means that the token inherits the complete
// global Auto list; a present (even empty) value is an explicit token snapshot.
func GetRequestAutoGroups(c *gin.Context, userGroup string) []string {
value, ok := common.GetContextKey(c, constant.ContextKeyTokenAutoGroups)
if !ok {
return GetUserAutoGroup(userGroup)
}
groups, ok := value.([]string)
if !ok {
return []string{}
}
return FilterUserTokenAutoGroups(userGroup, groups)
}
// GetGroupsEnabledModels 按 groups 顺序获取各分组启用的模型并去重
func GetGroupsEnabledModels(groups []string) []string {
seen := make(map[string]struct{})
models := make([]string, 0)
for _, group := range groups {
for _, modelName := range model.GetGroupEnabledModels(group) {
if _, ok := seen[modelName]; !ok {
seen[modelName] = struct{}{}
models = append(models, modelName)
}
}
}
return models
}
// GetUserGroupRatio 获取用户使用某个分组的倍率
// userGroup 用户分组
// group 需要获取倍率的分组
func GetUserGroupRatio(userGroup, group string) float64 {
ratio, ok := ratio_setting.GetGroupGroupRatio(userGroup, group)
if ok {
return ratio
}
return ratio_setting.GetGroupRatio(group)
}
+72
View File
@@ -0,0 +1,72 @@
package service
import (
"fmt"
"net/http/httptest"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/setting"
"github.com/QuantumNous/new-api/setting/ratio_setting"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func configureRequestAutoGroupsTest(t *testing.T) {
t.Helper()
originalMax := setting.GetMaxTokenAutoGroups()
originalAutoGroups := setting.AutoGroups2JsonString()
originalUsableGroups := setting.UserUsableGroups2JSONString()
originalRatios := ratio_setting.GroupRatio2JSONString()
require.NoError(t, setting.UpdateMaxTokenAutoGroups("2"))
require.NoError(t, setting.UpdateAutoGroupsByJsonString(`["vip","default","svip"]`))
require.NoError(t, setting.UpdateUserUsableGroupsByJSONString(`{"default":"Default","vip":"VIP","svip":"SVIP"}`))
require.NoError(t, ratio_setting.UpdateGroupRatioByJSONString(`{"default":1,"vip":1,"svip":1}`))
t.Cleanup(func() {
require.NoError(t, setting.UpdateMaxTokenAutoGroups(fmt.Sprintf("%d", originalMax)))
require.NoError(t, setting.UpdateAutoGroupsByJsonString(originalAutoGroups))
require.NoError(t, setting.UpdateUserUsableGroupsByJSONString(originalUsableGroups))
require.NoError(t, ratio_setting.UpdateGroupRatioByJSONString(originalRatios))
})
}
func newRequestAutoGroupsContext() *gin.Context {
gin.SetMode(gin.TestMode)
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
return ctx
}
func TestGetRequestAutoGroupsInheritedListIsNotLimited(t *testing.T) {
configureRequestAutoGroupsTest(t)
ctx := newRequestAutoGroupsContext()
groups := GetRequestAutoGroups(ctx, "default")
assert.Equal(t, []string{"vip", "default", "svip"}, groups)
}
func TestGetRequestAutoGroupsFiltersBeforeApplyingCurrentLimit(t *testing.T) {
configureRequestAutoGroupsTest(t)
ctx := newRequestAutoGroupsContext()
common.SetContextKey(ctx, constant.ContextKeyTokenAutoGroups, []string{"revoked", "vip", "default", "svip"})
require.NoError(t, setting.UpdateAutoGroupsByJsonString(`[]`))
groups := GetRequestAutoGroups(ctx, "default")
assert.Equal(t, []string{"vip", "default"}, groups)
require.NoError(t, setting.UpdateMaxTokenAutoGroups("1"))
assert.Equal(t, []string{"vip"}, GetRequestAutoGroups(ctx, "default"))
}
func TestGetRequestAutoGroupsDoesNotFallBackAfterPermissionChange(t *testing.T) {
configureRequestAutoGroupsTest(t)
ctx := newRequestAutoGroupsContext()
common.SetContextKey(ctx, constant.ContextKeyTokenAutoGroups, []string{"vip"})
require.NoError(t, setting.UpdateUserUsableGroupsByJSONString(`{"default":"Default"}`))
groups := GetRequestAutoGroups(ctx, "default")
assert.Empty(t, groups)
}
+79
View File
@@ -0,0 +1,79 @@
package service
import (
"bytes"
"fmt"
"io"
"net/http"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/logger"
"github.com/gin-gonic/gin"
)
func CloseResponseBodyGracefully(httpResponse *http.Response) {
if httpResponse == nil || httpResponse.Body == nil {
return
}
err := httpResponse.Body.Close()
if err != nil {
common.SysError("failed to close response body: " + err.Error())
}
}
// ShouldCopyUpstreamHeader checks whether a given upstream response header
// should be copied to the client response. It returns false for Content-Length
// (managed separately) and X-Oneapi-Request-Id (to preserve the local instance
// ID). When the upstream header is X-Oneapi-Request-Id, the value is captured
// into the Gin context for later logging.
func ShouldCopyUpstreamHeader(c *gin.Context, k string, v []string) bool {
if strings.EqualFold(k, "Content-Length") {
return false
}
if strings.EqualFold(k, common.RequestIdKey) {
if c != nil && len(v) > 0 {
c.Set(common.UpstreamRequestIdKey, v[0])
}
return false
}
return true
}
func IOCopyBytesGracefully(c *gin.Context, src *http.Response, data []byte) {
if c.Writer == nil {
return
}
body := io.NopCloser(bytes.NewBuffer(data))
// We shouldn't set the header before we parse the response body, because the parse part may fail.
// And then we will have to send an error response, but in this case, the header has already been set.
// So the httpClient will be confused by the response.
// For example, Postman will report error, and we cannot check the response at all.
if src != nil {
for k, v := range src.Header {
if !ShouldCopyUpstreamHeader(c, k, v) {
continue
}
c.Writer.Header().Set(k, v[0])
}
}
// set Content-Length header manually BEFORE calling WriteHeader
c.Writer.Header().Set("Content-Length", fmt.Sprintf("%d", len(data)))
// Write header with status code (this sends the headers)
if src != nil {
c.Writer.WriteHeader(src.StatusCode)
} else {
c.Writer.WriteHeader(http.StatusOK)
}
_, err := io.Copy(c.Writer, body)
if err != nil {
logger.LogError(c, fmt.Sprintf("failed to copy response body: %s", err.Error()))
}
c.Writer.Flush()
}
+469
View File
@@ -0,0 +1,469 @@
package service
import (
"context"
"crypto/tls"
"fmt"
"math"
"net"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/setting/system_setting"
"golang.org/x/net/proxy"
)
var (
httpClient *http.Client
ssrfProtectedHTTPClient *http.Client
proxyClients = proxyHTTPClientCache{
clients: make(map[string]*http.Client),
aliases: make(map[string]string),
}
legacyProxyURLWarnings sync.Map
)
type proxyHTTPClientCache struct {
mutex sync.RWMutex
clients map[string]*http.Client
aliases map[string]string // rawProxyURL -> canonicalProxyURL
}
type proxyURLConfig struct {
parsedURL *url.URL
cacheKey string
}
func checkRedirect(req *http.Request, via []*http.Request) error {
urlStr := req.URL.String()
if err := validateURLWithCurrentFetchSetting(urlStr, true); err != nil {
return fmt.Errorf("redirect to %s blocked: %v", urlStr, err)
}
if len(via) >= 10 {
return fmt.Errorf("stopped after 10 redirects")
}
return nil
}
func checkProtectedFetchRedirect(req *http.Request, via []*http.Request) error {
urlStr := req.URL.String()
if err := ValidateSSRFProtectedFetchURL(urlStr); err != nil {
return fmt.Errorf("redirect to %s blocked: %v", urlStr, err)
}
if len(via) >= 10 {
return fmt.Errorf("stopped after 10 redirects")
}
return nil
}
func validateURLWithCurrentFetchSetting(urlStr string, applyDomainIPFilter bool) error {
fetchSetting := system_setting.GetFetchSetting()
return common.ValidateURLWithFetchSetting(urlStr, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts, applyDomainIPFilter && fetchSetting.ApplyIPFilterForDomain)
}
func ValidateSSRFProtectedFetchURL(urlStr string) error {
return validateURLWithCurrentFetchSetting(urlStr, true)
}
// maxTimeoutSeconds is the largest number of seconds that still converts to a
// time.Duration without overflowing (~292 years).
const maxTimeoutSeconds = int(math.MaxInt64 / int64(time.Second))
func newRelayHTTPTransport() *http.Transport {
var transport *http.Transport
if defaultTransport, ok := http.DefaultTransport.(*http.Transport); ok && defaultTransport != nil {
transport = defaultTransport.Clone()
} else {
dialer := &net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
}
transport = &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: dialer.DialContext,
ForceAttemptHTTP2: true,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: time.Second,
}
}
transport.MaxIdleConns = common.RelayMaxIdleConns
transport.MaxIdleConnsPerHost = common.RelayMaxIdleConnsPerHost
transport.IdleConnTimeout = time.Duration(common.RelayIdleConnTimeout) * time.Second
// Bound the wait for upstream response headers. Without it, an upstream that
// accepts the connection but never responds (and never sends FIN/RST) parks the
// goroutine forever, and every buffer that request owns -- the raw body read by
// io.ReadAll, the decoded messages, and the re-marshalled upstream body -- stays
// reachable for the lifetime of the process.
//
// This only covers the wait for the headers; streaming after the headers arrive
// is not affected. Set RELAY_RESPONSE_HEADER_TIMEOUT=0 to restore the old
// unbounded behaviour.
if seconds := common.RelayResponseHeaderTimeout; seconds > 0 {
// Clamp before converting: seconds beyond maxTimeoutSeconds overflow
// time.Duration and can wrap into a tiny positive timeout, which would cut
// every relay request instead of only the stuck ones.
if seconds > maxTimeoutSeconds {
seconds = maxTimeoutSeconds
}
transport.ResponseHeaderTimeout = time.Duration(seconds) * time.Second
}
transport.ForceAttemptHTTP2 = true
if common.TLSInsecureSkipVerify {
transport.TLSClientConfig = common.InsecureTLSConfig
}
return transport
}
func newRelayHTTPClient(transport http.RoundTripper) *http.Client {
client := &http.Client{
Transport: transport,
CheckRedirect: checkRedirect,
}
if common.RelayTimeout != 0 {
client.Timeout = time.Duration(common.RelayTimeout) * time.Second
}
return client
}
func clientCacheKey(proxyCacheKey string, policy HTTPTransportPolicy) string {
return proxyCacheKey + "\x00" + policy.cacheKeyPart()
}
func InitHttpClient() {
policy := defaultHTTPTransportPolicy()
httpClient = newDirectHTTPClient(policy, nil)
proxyClients.store(clientCacheKey("", policy), httpClient)
ssrfProtectedHTTPClient = newProtectedFetchHTTPClient()
}
// GetHttpClient returns the general outbound client used by relay/provider
// integrations. Do not attach the SSRF-protected dialer here: provider base URLs
// are root/operator-managed deployment targets, not arbitrary user-controlled
// input, and may legitimately point at private networks, private-link endpoints,
// self-hosted services, or local proxies. Code paths that fetch arbitrary
// user-controlled URLs must use GetSSRFProtectedHTTPClient or
// ValidateSSRFProtectedFetchURL instead.
func GetHttpClient() *http.Client {
return httpClient
}
// GetSSRFProtectedHTTPClient 返回带拨号时 SSRF 校验的客户端。
// ssrfProtectedHTTPClient 由 InitHttpClient 在启动时初始化,运行期只读。
func GetSSRFProtectedHTTPClient() *http.Client {
if fetchSetting := system_setting.GetFetchSetting(); fetchSetting != nil && !fetchSetting.EnableSSRFProtection {
return GetHttpClient()
}
return ssrfProtectedHTTPClient
}
func newProxyURLConfig(parsedURL *url.URL) *proxyURLConfig {
return &proxyURLConfig{
parsedURL: parsedURL,
cacheKey: parsedURL.String(),
}
}
func warnLegacyProxyURLOnce(config *proxyURLConfig) {
if _, loaded := legacyProxyURLWarnings.LoadOrStore(config.cacheKey, struct{}{}); loaded {
return
}
logger.LogWarn(
context.Background(),
fmt.Sprintf(
"legacy proxy URL suffix ignored at runtime: scheme=%s host=%s; update the channel proxy setting",
config.parsedURL.Scheme,
config.parsedURL.Host,
),
)
}
// NormalizeProxyURL validates a proxy URL using runtime-compatible rules and returns its canonical cache key.
func NormalizeProxyURL(rawProxyURL string) (string, error) {
parsedURL, legacySuffixStripped, err := common.ParseProxyURLRuntime(rawProxyURL)
if err != nil {
return "", err
}
if parsedURL == nil {
return "", nil
}
config := newProxyURLConfig(parsedURL)
if legacySuffixStripped {
warnLegacyProxyURLOnce(config)
}
return config.cacheKey, nil
}
// ValidateProxyURL validates a channel proxy URL without connecting to it.
func ValidateProxyURL(rawProxyURL string) error {
_, err := common.ParseProxyURLStrict(rawProxyURL)
return err
}
func (cache *proxyHTTPClientCache) store(fullKey string, client *http.Client) {
cache.mutex.Lock()
defer cache.mutex.Unlock()
cache.clients[fullKey] = client
}
func (cache *proxyHTTPClientCache) resolveProxyKey(rawProxyURL string) string {
if canonicalKey, ok := cache.aliases[rawProxyURL]; ok {
return canonicalKey
}
return rawProxyURL
}
func (cache *proxyHTTPClientCache) get(rawProxyURL string, policy HTTPTransportPolicy) (*http.Client, bool) {
cache.mutex.RLock()
defer cache.mutex.RUnlock()
proxyKey := cache.resolveProxyKey(rawProxyURL)
client, ok := cache.clients[clientCacheKey(proxyKey, policy)]
return client, ok
}
func (cache *proxyHTTPClientCache) getOrCreate(
rawProxyURL string,
config *proxyURLConfig,
policy HTTPTransportPolicy,
factory func() (*http.Client, error),
) (*http.Client, error) {
cache.mutex.Lock()
defer cache.mutex.Unlock()
proxyKey := ""
if config != nil {
proxyKey = config.cacheKey
cache.aliases[rawProxyURL] = proxyKey
} else if rawProxyURL != "" {
proxyKey = cache.resolveProxyKey(rawProxyURL)
}
fullKey := clientCacheKey(proxyKey, policy)
if client, ok := cache.clients[fullKey]; ok {
return client, nil
}
client, err := factory()
if err != nil {
return nil, err
}
cache.clients[fullKey] = client
return client, nil
}
func (cache *proxyHTTPClientCache) removeProxy(proxyCacheKey string) []*http.Client {
cache.mutex.Lock()
defer cache.mutex.Unlock()
removed := make([]*http.Client, 0)
prefix := proxyCacheKey + "\x00"
for key, client := range cache.clients {
if strings.HasPrefix(key, prefix) {
removed = append(removed, client)
delete(cache.clients, key)
}
}
for alias, canonicalKey := range cache.aliases {
if canonicalKey == proxyCacheKey {
delete(cache.aliases, alias)
}
}
return removed
}
func (cache *proxyHTTPClientCache) reset() map[string]*http.Client {
cache.mutex.Lock()
defer cache.mutex.Unlock()
oldClients := cache.clients
cache.clients = make(map[string]*http.Client)
cache.aliases = make(map[string]string)
return oldClients
}
func configureProxyTransport(transport *http.Transport, proxyURL *url.URL) error {
switch proxyURL.Scheme {
case "http", "https":
transport.Proxy = http.ProxyURL(proxyURL)
return nil
case "socks5", "socks5h":
transport.Proxy = nil
forwardDialer := &net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
}
dialer, err := proxy.FromURL(proxyURL, forwardDialer)
if err != nil {
return err
}
contextDialer, ok := dialer.(proxy.ContextDialer)
if !ok {
return fmt.Errorf("SOCKS proxy dialer does not support context cancellation")
}
transport.DialContext = contextDialer.DialContext
return nil
default:
return fmt.Errorf("unsupported proxy scheme")
}
}
func newTransportFactory(proxyURL *url.URL, tlsConfig *tls.Config) (func() *http.Transport, error) {
// Validate proxy configuration once before creating shard transports.
if proxyURL != nil {
probe := newRelayHTTPTransport()
if err := configureProxyTransport(probe, proxyURL); err != nil {
return nil, err
}
}
return func() *http.Transport {
transport := newRelayHTTPTransport()
if proxyURL != nil {
_ = configureProxyTransport(transport, proxyURL)
} else {
transport.Proxy = http.ProxyFromEnvironment
}
if tlsConfig != nil {
transport.TLSClientConfig = tlsConfig.Clone()
}
return transport
}, nil
}
func newHTTPClientFromPolicy(policy HTTPTransportPolicy, proxyURL *url.URL, tlsConfig *tls.Config) (*http.Client, error) {
factory, err := newTransportFactory(proxyURL, tlsConfig)
if err != nil {
return nil, err
}
return newHTTPClientFromTransportFactory(policy, factory), nil
}
func newHTTPClientFromTransportFactory(policy HTTPTransportPolicy, factory func() *http.Transport) *http.Client {
if policy.Shards < 1 {
policy.Shards = 1
}
if policy.Protocol == dto.HTTPProtocolHTTP1 || policy.Shards == 1 {
transport := factory()
applyHTTPTransportPolicy(transport, policy)
return newRelayHTTPClient(transport)
}
shardedFactory := func() *http.Transport {
transport := factory()
applyHTTPTransportPolicy(transport, policy)
return transport
}
return newRelayHTTPClient(newShardedRoundTripper(policy, shardedFactory))
}
func newDirectHTTPClient(policy HTTPTransportPolicy, tlsConfig *tls.Config) *http.Client {
client, err := newHTTPClientFromPolicy(policy, nil, tlsConfig)
if err != nil {
// Direct clients cannot fail proxy configuration.
transport := newRelayHTTPTransport()
applyHTTPTransportPolicy(transport, policy)
return newRelayHTTPClient(transport)
}
return client
}
// newHTTPClientWithPolicyAndTLS is a test seam that builds a never-used transport
// stack with the given policy and TLS config (for httptest certificate trust).
func newHTTPClientWithPolicyAndTLS(policy HTTPTransportPolicy, tlsConfig *tls.Config) *http.Client {
return newDirectHTTPClient(policy, tlsConfig)
}
func newProxyHTTPClient(proxyURL *url.URL) (*http.Client, error) {
return newHTTPClientFromPolicy(defaultHTTPTransportPolicy(), proxyURL, nil)
}
// GetHttpClientWithProxy returns the default client or a cached proxy-enabled client.
func GetHttpClientWithProxy(rawProxyURL string) (*http.Client, error) {
return GetHttpClientWithProxySettings(rawProxyURL, dto.ChannelSettings{})
}
// GetHttpClientWithProxySettings returns a cached HTTP client for the proxy URL and
// channel transport settings. Default auto + 1 shard shares the same client pool as
// GetHttpClientWithProxy / GetHttpClient for the empty-proxy case.
func GetHttpClientWithProxySettings(rawProxyURL string, settings dto.ChannelSettings) (*http.Client, error) {
policy := NormalizeHTTPTransportPolicy(settings)
trimmedProxyURL := strings.TrimSpace(rawProxyURL)
if trimmedProxyURL == "" {
return getOrCreateDirectClient(policy)
}
if client, ok := proxyClients.get(trimmedProxyURL, policy); ok {
return client, nil
}
parsedURL, legacySuffixStripped, err := common.ParseProxyURLRuntime(trimmedProxyURL)
if err != nil {
return nil, err
}
config := newProxyURLConfig(parsedURL)
if legacySuffixStripped {
warnLegacyProxyURLOnce(config)
}
return proxyClients.getOrCreate(trimmedProxyURL, config, policy, func() (*http.Client, error) {
return newHTTPClientFromPolicy(policy, config.parsedURL, nil)
})
}
func getOrCreateDirectClient(policy HTTPTransportPolicy) (*http.Client, error) {
defaultPolicy := defaultHTTPTransportPolicy()
if policy == defaultPolicy {
if client := GetHttpClient(); client != nil {
return client, nil
}
// Compatibility with pre-init callers: never assign httpClient outside InitHttpClient.
return http.DefaultClient, nil
}
if client, ok := proxyClients.get("", policy); ok {
return client, nil
}
return proxyClients.getOrCreate("", nil, policy, func() (*http.Client, error) {
return newDirectHTTPClient(policy, nil), nil
})
}
// InvalidateProxyClient removes every cached policy variant for one proxy and
// closes their idle connections (including all HTTP/2 shards).
func InvalidateProxyClient(rawProxyURL string) {
parsedURL, legacySuffixStripped, err := common.ParseProxyURLRuntime(rawProxyURL)
if err != nil || parsedURL == nil {
return
}
config := newProxyURLConfig(parsedURL)
if legacySuffixStripped {
warnLegacyProxyURLOnce(config)
}
for _, client := range proxyClients.removeProxy(config.cacheKey) {
client.CloseIdleConnections()
}
}
// ResetProxyClientCache clears cached proxy and non-default direct policy clients
// and closes idle connections on every transport/shard. The package-level default
// httpClient pointer stays stable after InitHttpClient; it is only closed and
// re-registered in the policy cache so concurrent GetHttpClient readers never race
// a pointer replacement.
func ResetProxyClientCache() {
defaultClient := httpClient
for _, client := range proxyClients.reset() {
client.CloseIdleConnections()
}
if defaultClient == nil {
return
}
defaultClient.CloseIdleConnections()
proxyClients.store(clientCacheKey("", defaultHTTPTransportPolicy()), defaultClient)
}
// NewProxyHttpClient is kept for compatibility.
// Deprecated: use GetHttpClientWithProxy.
func NewProxyHttpClient(proxyURL string) (*http.Client, error) {
return GetHttpClientWithProxy(proxyURL)
}
+491
View File
@@ -0,0 +1,491 @@
package service
import (
"crypto/tls"
"crypto/x509"
"encoding/pem"
"fmt"
"io"
"net/http"
"net/http/httptest"
"sync"
"sync/atomic"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func withRelayHTTPTransportSettings(t *testing.T) {
t.Helper()
prevMaxIdle := common.RelayMaxIdleConns
prevPerHost := common.RelayMaxIdleConnsPerHost
prevTimeout := common.RelayIdleConnTimeout
common.RelayMaxIdleConns = 500
common.RelayMaxIdleConnsPerHost = 100
common.RelayIdleConnTimeout = 90
t.Cleanup(func() {
common.RelayMaxIdleConns = prevMaxIdle
common.RelayMaxIdleConnsPerHost = prevPerHost
common.RelayIdleConnTimeout = prevTimeout
})
}
func initDefaultHTTPClientFixture(t *testing.T) *http.Client {
t.Helper()
withRelayHTTPTransportSettings(t)
if httpClient == nil {
InitHttpClient()
} else {
ResetProxyClientCache()
}
require.NotNil(t, httpClient)
t.Cleanup(ResetProxyClientCache)
return httpClient
}
func TestShardedRoundTripperPerOriginRotation(t *testing.T) {
s := &shardedRoundTripper{n: 4}
originA := "https://a.example:443"
originB := "https://b.example:443"
gotA := make([]uint32, 0, 8)
for range 8 {
gotA = append(gotA, s.pickShard(originA))
}
assert.Equal(t, []uint32{0, 1, 2, 3, 0, 1, 2, 3}, gotA)
gotB := make([]uint32, 0, 4)
for range 4 {
gotB = append(gotB, s.pickShard(originB))
}
assert.Equal(t, []uint32{0, 1, 2, 3}, gotB, "independent origins must have independent counters")
var wg sync.WaitGroup
const workers = 32
const perWorker = 50
var badShardCount atomic.Uint32
wg.Add(workers)
for range workers {
go func() {
defer wg.Done()
for range perWorker {
idx := s.pickShard(originA)
if idx >= 4 {
badShardCount.Add(1)
}
}
}()
}
wg.Wait()
assert.Equal(t, uint32(0), badShardCount.Load())
}
func TestOriginKeyUsesSchemeAndHost(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "HTTPS://Example.COM:8443/path", nil)
assert.Equal(t, "https://Example.COM:8443", originKey(req))
}
func testTLSClientConfig(t *testing.T, server *httptest.Server) *tls.Config {
t.Helper()
pool := x509.NewCertPool()
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: server.Certificate().Raw})
require.True(t, pool.AppendCertsFromPEM(certPEM))
return &tls.Config{RootCAs: pool}
}
func startHTTP2TLSServer(t *testing.T, handler http.Handler) *httptest.Server {
t.Helper()
server := httptest.NewUnstartedServer(handler)
server.EnableHTTP2 = true
server.StartTLS()
t.Cleanup(server.Close)
return server
}
func drainClose(t *testing.T, resp *http.Response) {
t.Helper()
require.NotNil(t, resp)
_, err := io.Copy(io.Discard, resp.Body)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
}
func TestAutoOneShardNegotiatesHTTP2SingleConnection(t *testing.T) {
withRelayHTTPTransportSettings(t)
var mu sync.Mutex
addrs := make(map[string]struct{})
var sawHTTP2 atomic.Bool
server := startHTTP2TLSServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
addrs[r.RemoteAddr] = struct{}{}
mu.Unlock()
if r.ProtoMajor == 2 {
sawHTTP2.Store(true)
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
}))
client := newHTTPClientWithPolicyAndTLS(defaultHTTPTransportPolicy(), testTLSClientConfig(t, server))
for range 4 {
resp, err := client.Get(server.URL)
require.NoError(t, err)
assert.Equal(t, 2, resp.ProtoMajor)
drainClose(t, resp)
}
mu.Lock()
defer mu.Unlock()
assert.True(t, sawHTTP2.Load())
assert.Len(t, addrs, 1, "auto+1 must reuse a single HTTP/2 connection")
}
func TestFourShardHTTP2ReusesExactlyFourConnections(t *testing.T) {
withRelayHTTPTransportSettings(t)
var mu sync.Mutex
addrs := make(map[string]struct{})
var nonHTTP2Count atomic.Uint32
server := startHTTP2TLSServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
addrs[r.RemoteAddr] = struct{}{}
mu.Unlock()
if r.ProtoMajor != 2 {
nonHTTP2Count.Add(1)
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
}))
policy := HTTPTransportPolicy{Protocol: dto.HTTPProtocolAuto, Shards: 4}
client := newHTTPClientWithPolicyAndTLS(policy, testTLSClientConfig(t, server))
for range 8 {
resp, err := client.Get(server.URL)
require.NoError(t, err)
assert.Equal(t, 2, resp.ProtoMajor)
drainClose(t, resp)
}
mu.Lock()
defer mu.Unlock()
assert.Equal(t, uint32(0), nonHTTP2Count.Load())
assert.Len(t, addrs, 4, "four shards must establish and reuse exactly four connections")
}
func TestForcedHTTP1AgainstHTTP2Server(t *testing.T) {
withRelayHTTPTransportSettings(t)
var nonHTTP1Count atomic.Uint32
server := startHTTP2TLSServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.ProtoMajor != 1 {
nonHTTP1Count.Add(1)
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
}))
policy := HTTPTransportPolicy{Protocol: dto.HTTPProtocolHTTP1, Shards: 1}
client := newHTTPClientWithPolicyAndTLS(policy, testTLSClientConfig(t, server))
resp, err := client.Get(server.URL)
require.NoError(t, err)
assert.Equal(t, 1, resp.ProtoMajor)
drainClose(t, resp)
assert.Equal(t, uint32(0), nonHTTP1Count.Load())
transport, ok := client.Transport.(*http.Transport)
require.True(t, ok)
assert.False(t, transport.DisableKeepAlives)
assert.False(t, transport.ForceAttemptHTTP2)
assert.NotNil(t, transport.TLSNextProto)
assert.Len(t, transport.TLSNextProto, 0)
}
func TestForcedHTTP1ConcurrentDistinctConnections(t *testing.T) {
withRelayHTTPTransportSettings(t)
const k = 8
var mu sync.Mutex
addrs := make(map[string]struct{})
arrived := make(chan struct{}, k)
release := make(chan struct{})
var nonHTTP1Count atomic.Uint32
server := startHTTP2TLSServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
addrs[r.RemoteAddr] = struct{}{}
mu.Unlock()
if r.ProtoMajor != 1 {
nonHTTP1Count.Add(1)
}
arrived <- struct{}{}
<-release
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
}))
policy := HTTPTransportPolicy{Protocol: dto.HTTPProtocolHTTP1, Shards: 1}
client := newHTTPClientWithPolicyAndTLS(policy, testTLSClientConfig(t, server))
errCh := make(chan error, k)
for range k {
go func() {
resp, err := client.Get(server.URL)
if err != nil {
errCh <- err
return
}
if resp.ProtoMajor != 1 {
errCh <- fmt.Errorf("expected HTTP/1.x, got %s", resp.Proto)
_ = resp.Body.Close()
return
}
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
errCh <- nil
}()
}
for range k {
<-arrived
}
mu.Lock()
activeAddrs := len(addrs)
mu.Unlock()
close(release)
for range k {
require.NoError(t, <-errCh)
}
assert.Equal(t, uint32(0), nonHTTP1Count.Load())
assert.Equal(t, k, activeAddrs, "all HTTP/1.1 handlers active together must use K distinct connections")
}
func TestHTTPClientCachePolicyAndCompatibility(t *testing.T) {
defaultClient := initDefaultHTTPClientFixture(t)
compat, err := GetHttpClientWithProxy("")
require.NoError(t, err)
aware, err := GetHttpClientWithProxySettings("", dto.ChannelSettings{})
require.NoError(t, err)
assert.Same(t, defaultClient, compat)
assert.Same(t, compat, aware)
assert.Same(t, GetHttpClient(), aware)
http1, err := GetHttpClientWithProxySettings("", dto.ChannelSettings{HTTPProtocol: dto.HTTPProtocolHTTP1})
require.NoError(t, err)
assert.NotSame(t, aware, http1)
sharded, err := GetHttpClientWithProxySettings("", dto.ChannelSettings{HTTP2ConnectionShards: 4})
require.NoError(t, err)
assert.NotSame(t, aware, sharded)
assert.NotSame(t, http1, sharded)
proxyA := "http://proxy.example:8080"
proxyAlias := "http://proxy.example:8080/"
clientA, err := GetHttpClientWithProxy(proxyA)
require.NoError(t, err)
clientAlias, err := GetHttpClientWithProxy(proxyAlias)
require.NoError(t, err)
assert.Same(t, clientA, clientAlias, "canonical proxy aliases must share the default policy client")
proxyHTTP1, err := GetHttpClientWithProxySettings(proxyA, dto.ChannelSettings{HTTPProtocol: dto.HTTPProtocolHTTP1})
require.NoError(t, err)
assert.NotSame(t, clientA, proxyHTTP1)
}
func TestHTTPClientCacheConcurrentGetOrCreate(t *testing.T) {
initDefaultHTTPClientFixture(t)
proxyURL := "http://concurrent-proxy.example:9090"
const workers = 32
results := make([]*http.Client, workers)
errs := make([]error, workers)
var wg sync.WaitGroup
wg.Add(workers)
for i := range workers {
i := i
go func() {
defer wg.Done()
client, err := GetHttpClientWithProxySettings(proxyURL, dto.ChannelSettings{HTTP2ConnectionShards: 3})
errs[i] = err
results[i] = client
}()
}
wg.Wait()
for i := range workers {
require.NoError(t, errs[i])
}
for i := 1; i < workers; i++ {
assert.Same(t, results[0], results[i])
}
}
type closeCountingRoundTripper struct {
closes atomic.Int32
}
func (c *closeCountingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Body: http.NoBody,
Header: make(http.Header),
Request: req,
}, nil
}
func (c *closeCountingRoundTripper) CloseIdleConnections() {
c.closes.Add(1)
}
func TestShardedRoundTripperCloseIdleConnectionsFansOut(t *testing.T) {
trackers := []*closeCountingRoundTripper{{}, {}, {}}
shards := make([]http.RoundTripper, len(trackers))
for i, tracker := range trackers {
shards[i] = tracker
}
s := &shardedRoundTripper{shards: shards, n: uint32(len(shards))}
s.CloseIdleConnections()
for _, tracker := range trackers {
assert.Equal(t, int32(1), tracker.closes.Load())
}
}
func TestInvalidateProxyClientClosesAllPolicyVariants(t *testing.T) {
initDefaultHTTPClientFixture(t)
proxyURL := "http://invalidate-proxy.example:8080"
defaultClient, err := GetHttpClientWithProxy(proxyURL)
require.NoError(t, err)
http1Client, err := GetHttpClientWithProxySettings(proxyURL, dto.ChannelSettings{HTTPProtocol: dto.HTTPProtocolHTTP1})
require.NoError(t, err)
shardedClient, err := GetHttpClientWithProxySettings(proxyURL, dto.ChannelSettings{HTTP2ConnectionShards: 2})
require.NoError(t, err)
InvalidateProxyClient(proxyURL)
afterDefault, err := GetHttpClientWithProxy(proxyURL)
require.NoError(t, err)
afterHTTP1, err := GetHttpClientWithProxySettings(proxyURL, dto.ChannelSettings{HTTPProtocol: dto.HTTPProtocolHTTP1})
require.NoError(t, err)
afterSharded, err := GetHttpClientWithProxySettings(proxyURL, dto.ChannelSettings{HTTP2ConnectionShards: 2})
require.NoError(t, err)
assert.NotSame(t, defaultClient, afterDefault)
assert.NotSame(t, http1Client, afterHTTP1)
assert.NotSame(t, shardedClient, afterSharded)
}
func TestResetProxyClientCacheKeepsDefaultPointerAndRecreatesVariants(t *testing.T) {
defaultClient := initDefaultHTTPClientFixture(t)
http1Client, err := GetHttpClientWithProxySettings("", dto.ChannelSettings{HTTPProtocol: dto.HTTPProtocolHTTP1})
require.NoError(t, err)
shardedClient, err := GetHttpClientWithProxySettings("", dto.ChannelSettings{HTTP2ConnectionShards: 3})
require.NoError(t, err)
proxyClient, err := GetHttpClientWithProxy("http://reset-proxy.example:8080")
require.NoError(t, err)
ResetProxyClientCache()
assert.Same(t, defaultClient, GetHttpClient(), "default httpClient pointer must stay stable across reset")
aware, err := GetHttpClientWithProxySettings("", dto.ChannelSettings{})
require.NoError(t, err)
assert.Same(t, defaultClient, aware)
afterHTTP1, err := GetHttpClientWithProxySettings("", dto.ChannelSettings{HTTPProtocol: dto.HTTPProtocolHTTP1})
require.NoError(t, err)
afterSharded, err := GetHttpClientWithProxySettings("", dto.ChannelSettings{HTTP2ConnectionShards: 3})
require.NoError(t, err)
afterProxy, err := GetHttpClientWithProxy("http://reset-proxy.example:8080")
require.NoError(t, err)
assert.NotSame(t, http1Client, afterHTTP1)
assert.NotSame(t, shardedClient, afterSharded)
assert.NotSame(t, proxyClient, afterProxy)
}
func TestResetProxyClientCacheClosesDefaultIdlePool(t *testing.T) {
defaultClient := initDefaultHTTPClientFixture(t)
tracker := &closeCountingRoundTripper{}
previousTransport := defaultClient.Transport
defaultClient.Transport = tracker
t.Cleanup(func() {
defaultClient.Transport = previousTransport
})
ResetProxyClientCache()
assert.Same(t, defaultClient, GetHttpClient())
assert.GreaterOrEqual(t, tracker.closes.Load(), int32(1), "reset must close idle connections on the stable default client")
aware, err := GetHttpClientWithProxySettings("", dto.ChannelSettings{})
require.NoError(t, err)
assert.Same(t, defaultClient, aware)
}
func TestResetProxyClientCacheConcurrentWithGetHttpClient(t *testing.T) {
initDefaultHTTPClientFixture(t)
const workers = 64
var wg sync.WaitGroup
wg.Add(workers * 2)
for range workers {
go func() {
defer wg.Done()
_ = GetHttpClient()
}()
go func() {
defer wg.Done()
ResetProxyClientCache()
}()
}
wg.Wait()
assert.NotNil(t, GetHttpClient())
aware, err := GetHttpClientWithProxySettings("", dto.ChannelSettings{})
require.NoError(t, err)
assert.Same(t, GetHttpClient(), aware)
}
func TestCloseIdleConnectionsRedialsHTTP2(t *testing.T) {
withRelayHTTPTransportSettings(t)
var mu sync.Mutex
addrs := make([]string, 0, 2)
server := startHTTP2TLSServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
addrs = append(addrs, r.RemoteAddr)
mu.Unlock()
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
}))
client := newHTTPClientWithPolicyAndTLS(defaultHTTPTransportPolicy(), testTLSClientConfig(t, server))
resp, err := client.Get(server.URL)
require.NoError(t, err)
drainClose(t, resp)
client.CloseIdleConnections()
resp, err = client.Get(server.URL)
require.NoError(t, err)
drainClose(t, resp)
mu.Lock()
defer mu.Unlock()
require.Len(t, addrs, 2)
assert.NotEqual(t, addrs[0], addrs[1], "after CloseIdleConnections the next request must redial")
}
func TestNormalizeHTTPTransportPolicyClampsWithoutPanic(t *testing.T) {
assert.Equal(t, defaultHTTPTransportPolicy(), NormalizeHTTPTransportPolicy(dto.ChannelSettings{}))
assert.Equal(t, HTTPTransportPolicy{Protocol: dto.HTTPProtocolAuto, Shards: 1}, NormalizeHTTPTransportPolicy(dto.ChannelSettings{HTTPProtocol: "AUTO"}))
assert.Equal(t, HTTPTransportPolicy{Protocol: dto.HTTPProtocolHTTP1, Shards: 1}, NormalizeHTTPTransportPolicy(dto.ChannelSettings{HTTPProtocol: "HTTP1", HTTP2ConnectionShards: 8}))
assert.Equal(t, HTTPTransportPolicy{Protocol: dto.HTTPProtocolAuto, Shards: 1}, NormalizeHTTPTransportPolicy(dto.ChannelSettings{HTTPProtocol: "http3"}))
assert.Equal(t, HTTPTransportPolicy{Protocol: dto.HTTPProtocolAuto, Shards: 1}, NormalizeHTTPTransportPolicy(dto.ChannelSettings{HTTP2ConnectionShards: -3}))
assert.Equal(t, HTTPTransportPolicy{Protocol: dto.HTTPProtocolAuto, Shards: 8}, NormalizeHTTPTransportPolicy(dto.ChannelSettings{HTTP2ConnectionShards: 99}))
}
+91
View File
@@ -0,0 +1,91 @@
package service
import (
"context"
"fmt"
"strings"
"sync"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/relaykit/dto"
)
// HTTPTransportPolicy is the runtime-normalized outbound HTTP transport policy
// for a channel. Unknown or out-of-range stored values are clamped safely.
type HTTPTransportPolicy struct {
Protocol string // dto.HTTPProtocolAuto or dto.HTTPProtocolHTTP1
Shards int // 1..dto.MaxHTTP2ConnectionShards
}
var httpTransportPolicyWarnings sync.Map
func defaultHTTPTransportPolicy() HTTPTransportPolicy {
return HTTPTransportPolicy{
Protocol: dto.HTTPProtocolAuto,
Shards: 1,
}
}
// NormalizeHTTPTransportPolicy converts channel settings into a safe runtime policy.
// Invalid stored values never panic; they clamp to defaults and warn once per bad value.
func NormalizeHTTPTransportPolicy(settings dto.ChannelSettings) HTTPTransportPolicy {
policy := defaultHTTPTransportPolicy()
protocol := strings.ToLower(strings.TrimSpace(settings.HTTPProtocol))
switch protocol {
case "", dto.HTTPProtocolAuto:
policy.Protocol = dto.HTTPProtocolAuto
case dto.HTTPProtocolHTTP1:
policy.Protocol = dto.HTTPProtocolHTTP1
default:
warnHTTPTransportPolicyOnce("http_protocol", settings.HTTPProtocol)
policy.Protocol = dto.HTTPProtocolAuto
}
shards := settings.HTTP2ConnectionShards
switch {
case shards == 0:
policy.Shards = 1
case shards < 1:
warnHTTPTransportPolicyOnce("http2_connection_shards", fmt.Sprintf("%d", shards))
policy.Shards = 1
case shards > dto.MaxHTTP2ConnectionShards:
warnHTTPTransportPolicyOnce("http2_connection_shards", fmt.Sprintf("%d", shards))
policy.Shards = dto.MaxHTTP2ConnectionShards
default:
policy.Shards = shards
}
if policy.Protocol == dto.HTTPProtocolHTTP1 {
if settings.HTTP2ConnectionShards > 1 {
warnHTTPTransportPolicyOnce(
"http_protocol+http2_connection_shards",
fmt.Sprintf("%s+%d", dto.HTTPProtocolHTTP1, settings.HTTP2ConnectionShards),
)
}
policy.Shards = 1
}
if policy.Shards < 1 {
policy.Shards = 1
}
return policy
}
func warnHTTPTransportPolicyOnce(field, value string) {
key := field + "=" + value
if _, loaded := httpTransportPolicyWarnings.LoadOrStore(key, struct{}{}); loaded {
return
}
logger.LogWarn(
context.Background(),
fmt.Sprintf("invalid channel http transport setting clamped: %s=%q", field, value),
)
}
func (p HTTPTransportPolicy) cacheKeyPart() string {
return fmt.Sprintf("%s|%d", p.Protocol, p.Shards)
}
func (p HTTPTransportPolicy) String() string {
return p.cacheKeyPart()
}
+128
View File
@@ -0,0 +1,128 @@
package service
import (
"crypto/tls"
"fmt"
"net/http"
"strings"
"sync"
"sync/atomic"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/relaykit/dto"
)
// shardedRoundTripper fans requests for each origin across N independent
// transports so each origin can keep N reusable HTTP/2 connections.
type shardedRoundTripper struct {
shards []http.RoundTripper
n uint32
policy HTTPTransportPolicy
counters sync.Map // origin -> *atomic.Uint32
}
func newShardedRoundTripper(policy HTTPTransportPolicy, factory func() *http.Transport) *shardedRoundTripper {
n := max(policy.Shards, 1)
shards := make([]http.RoundTripper, n)
for i := 0; i < n; i++ {
transport := factory()
transport.MaxIdleConns = max(1, transport.MaxIdleConns/n)
transport.MaxIdleConnsPerHost = max(1, transport.MaxIdleConnsPerHost/n)
shards[i] = transport
}
return &shardedRoundTripper{
shards: shards,
n: uint32(n),
policy: policy,
}
}
func originKey(req *http.Request) string {
if req == nil || req.URL == nil {
return ""
}
return strings.ToLower(req.URL.Scheme) + "://" + req.URL.Host
}
func (s *shardedRoundTripper) pickShard(origin string) uint32 {
if s.n <= 1 {
return 0
}
counterAny, _ := s.counters.LoadOrStore(origin, &atomic.Uint32{})
counter := counterAny.(*atomic.Uint32)
return (counter.Add(1) - 1) % s.n
}
func (s *shardedRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
origin := originKey(req)
idx := s.pickShard(origin)
resp, err := s.shards[idx].RoundTrip(req)
if common.DebugEnabled {
proto := ""
if resp != nil {
proto = resp.Proto
}
host := ""
if req != nil && req.URL != nil {
host = req.URL.Host
}
logger.LogDebug(
req.Context(),
fmt.Sprintf(
"http transport: host=%s protocol=%s shard=%d/%d policy=%s negotiated=%s",
host,
s.policy.Protocol,
idx,
s.n,
s.policy.cacheKeyPart(),
proto,
),
)
}
return resp, err
}
func (s *shardedRoundTripper) CloseIdleConnections() {
for _, shard := range s.shards {
closeIdleConnections(shard)
}
}
func closeIdleConnections(rt http.RoundTripper) {
type idleCloser interface {
CloseIdleConnections()
}
if closer, ok := rt.(idleCloser); ok {
closer.CloseIdleConnections()
}
}
// applyHTTP1Force disables automatic HTTP/2 on a never-used transport.
// ForceAttemptHTTP2=false alone is insufficient; a non-nil empty TLSNextProto
// map prevents net/http from wiring HTTP/2.
func applyHTTP1Force(transport *http.Transport) {
if transport == nil {
return
}
transport.ForceAttemptHTTP2 = false
transport.DisableKeepAlives = false
transport.TLSNextProto = make(map[string]func(authority string, c *tls.Conn) http.RoundTripper)
if transport.TLSClientConfig != nil {
cfg := transport.TLSClientConfig.Clone()
cfg.NextProtos = nil
transport.TLSClientConfig = cfg
}
}
func applyHTTPTransportPolicy(transport *http.Transport, policy HTTPTransportPolicy) {
if transport == nil {
return
}
if policy.Protocol == dto.HTTPProtocolHTTP1 {
applyHTTP1Force(transport)
return
}
transport.ForceAttemptHTTP2 = true
transport.DisableKeepAlives = false
}
+194
View File
@@ -0,0 +1,194 @@
package service
import (
"bytes"
"encoding/base64"
"errors"
"fmt"
"image"
"io"
"net/http"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"golang.org/x/image/webp"
)
// return image.Config, format, clean base64 string, error
func DecodeBase64ImageData(base64String string) (image.Config, string, string, error) {
// 去除base64数据的URL前缀(如果有)
if idx := strings.Index(base64String, ","); idx != -1 {
base64String = base64String[idx+1:]
}
if len(base64String) == 0 {
return image.Config{}, "", "", errors.New("base64 string is empty")
}
// 将base64字符串解码为字节切片
decodedData, err := base64.StdEncoding.DecodeString(base64String)
if err != nil {
fmt.Println("Error: Failed to decode base64 string")
return image.Config{}, "", "", fmt.Errorf("failed to decode base64 string: %s", err.Error())
}
// 创建一个bytes.Buffer用于存储解码后的数据
reader := bytes.NewReader(decodedData)
config, format, err := getImageConfig(reader)
return config, format, base64String, err
}
func DecodeBase64FileData(base64String string) (string, string, error) {
var mimeType string
var idx int
idx = strings.Index(base64String, ",")
if idx == -1 {
_, file_type, base64, err := DecodeBase64ImageData(base64String)
return "image/" + file_type, base64, err
}
mimeType = base64String[:idx]
base64String = base64String[idx+1:]
idx = strings.Index(mimeType, ";")
if idx == -1 {
_, file_type, base64, err := DecodeBase64ImageData(base64String)
return "image/" + file_type, base64, err
}
mimeType = mimeType[:idx]
idx = strings.Index(mimeType, ":")
if idx == -1 {
_, file_type, base64, err := DecodeBase64ImageData(base64String)
return "image/" + file_type, base64, err
}
mimeType = mimeType[idx+1:]
return mimeType, base64String, nil
}
// GetImageFromUrl 获取图片的类型和base64编码的数据
func GetImageFromUrl(url string) (mimeType string, data string, err error) {
resp, err := DoDownloadRequest(url)
if err != nil {
return "", "", fmt.Errorf("failed to download image: %w", err)
}
defer resp.Body.Close()
// Check HTTP status code
if resp.StatusCode != http.StatusOK {
return "", "", fmt.Errorf("failed to download image: HTTP %d", resp.StatusCode)
}
contentType := resp.Header.Get("Content-Type")
if contentType != "application/octet-stream" && !strings.HasPrefix(contentType, "image/") {
return "", "", fmt.Errorf("invalid content type: %s, required image/*", contentType)
}
maxImageSize := int64(constant.MaxFileDownloadMB * 1024 * 1024)
// Check Content-Length if available
if resp.ContentLength > maxImageSize {
return "", "", fmt.Errorf("image size %d exceeds maximum allowed size of %d bytes", resp.ContentLength, maxImageSize)
}
// Use LimitReader to prevent reading oversized images
limitReader := io.LimitReader(resp.Body, maxImageSize)
buffer := &bytes.Buffer{}
written, err := io.Copy(buffer, limitReader)
if err != nil {
return "", "", fmt.Errorf("failed to read image data: %w", err)
}
if written >= maxImageSize {
return "", "", fmt.Errorf("image size exceeds maximum allowed size of %d bytes", maxImageSize)
}
data = base64.StdEncoding.EncodeToString(buffer.Bytes())
mimeType = contentType
// Handle application/octet-stream type
if mimeType == "application/octet-stream" {
_, format, _, err := DecodeBase64ImageData(data)
if err != nil {
return "", "", err
}
mimeType = "image/" + format
}
return mimeType, data, nil
}
func DecodeUrlImageData(imageUrl string) (image.Config, string, error) {
response, err := DoDownloadRequest(imageUrl)
if err != nil {
common.SysLog(fmt.Sprintf("fail to get image from url: %s", err.Error()))
return image.Config{}, "", err
}
defer response.Body.Close()
if response.StatusCode != 200 {
err = errors.New(fmt.Sprintf("fail to get image from url: %s", response.Status))
return image.Config{}, "", err
}
mimeType := response.Header.Get("Content-Type")
if mimeType != "application/octet-stream" && !strings.HasPrefix(mimeType, "image/") {
return image.Config{}, "", fmt.Errorf("invalid content type: %s, required image/*", mimeType)
}
var readData []byte
for _, limit := range []int64{1024 * 8, 1024 * 24, 1024 * 64} {
common.SysLog(fmt.Sprintf("try to decode image config with limit: %d", limit))
// 从response.Body读取更多的数据直到达到当前的限制
additionalData := make([]byte, limit-int64(len(readData)))
n, _ := io.ReadFull(response.Body, additionalData)
readData = append(readData, additionalData[:n]...)
// 使用io.MultiReader组合已经读取的数据和response.Body
limitReader := io.MultiReader(bytes.NewReader(readData), response.Body)
var config image.Config
var format string
config, format, err = getImageConfig(limitReader)
if err == nil {
return config, format, nil
}
}
return image.Config{}, "", err // 返回最后一个错误
}
func getImageConfig(reader io.Reader) (image.Config, string, error) {
// Read all data so we can retry with different decoders
data, readErr := io.ReadAll(reader)
if readErr != nil {
return image.Config{}, "", fmt.Errorf("failed to read image data: %w", readErr)
}
// 读取图片的头部信息来获取图片尺寸
config, format, err := image.DecodeConfig(bytes.NewReader(data))
if err == nil {
return config, format, nil
}
common.SysLog(fmt.Sprintf("fail to decode image config(gif, jpg, png): %s", err.Error()))
config, err = webp.DecodeConfig(bytes.NewReader(data))
if err == nil {
return config, "webp", nil
}
common.SysLog(fmt.Sprintf("fail to decode image config(webp): %s", err.Error()))
// Try HEIF/HEIC: parse ISOBMFF ispe box for dimensions
if heifMime := detectHEIF(data); heifMime != "" {
formatName := "heif"
if heifMime == "image/heic" {
formatName = "heic"
}
if w, h, ok := parseHEIFDimensions(data); ok {
return image.Config{Width: w, Height: h}, formatName, nil
}
return image.Config{}, "", fmt.Errorf("failed to decode HEIF/HEIC image dimensions")
}
return image.Config{}, "", err
}
+330
View File
@@ -0,0 +1,330 @@
package service
import (
"encoding/base64"
"fmt"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/pkg/billingexpr"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/types"
hosttypes "github.com/QuantumNous/new-api/types"
"github.com/gin-gonic/gin"
)
// attachQuotaSaturationToOther nests a quota saturation marker under
// other.admin_info.quota_saturation. Nesting under admin_info makes it
// admin-only for free, since model.formatUserLogs strips the whole admin_info
// object for non-admin viewers. Creates admin_info if absent. No-op when the
// clamp is nil (the common case: no saturation happened).
func attachQuotaSaturationToOther(other *model.LogOther, clamp *common.QuotaClamp) {
if clamp == nil || other == nil {
return
}
other.SetAdmin("quota_saturation", clamp.AuditMap())
}
// attachQuotaSaturation records the request's quota clamp (if any) onto the
// consume log's other.admin_info and emits a request-correlated backend audit
// line. Called right before RecordConsumeLog on the text/audio/wss paths.
func attachQuotaSaturation(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, other *model.LogOther) {
if relayInfo == nil {
return
}
clamp := relayInfo.QuotaClamp
if clamp == nil {
return
}
attachQuotaSaturationToOther(other, clamp)
logger.LogWarn(ctx, fmt.Sprintf("quota saturation on consume log: op=%s kind=%s original=%g clamped=%d user=%d model=%s",
clamp.Op, clamp.Kind, clamp.Original, clamp.Clamped, relayInfo.UserId, relayInfo.GetBillingModelName()))
}
func appendRequestPath(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, other *model.LogOther) {
if other == nil {
return
}
if ctx != nil && ctx.Request != nil && ctx.Request.URL != nil {
if path := ctx.Request.URL.Path; path != "" {
other.SetPublic("request_path", path)
return
}
}
if relayInfo != nil && relayInfo.RequestURLPath != "" {
path := relayInfo.RequestURLPath
if idx := strings.Index(path, "?"); idx != -1 {
path = path[:idx]
}
other.SetPublic("request_path", path)
}
}
// AppendRelayLogAdminInfo records relay routing and conversion diagnostics in
// the admin-only scope shared by successful and failed request logs.
func AppendRelayLogAdminInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, other *model.LogOther) {
if ctx == nil || other == nil {
return
}
other.SetAdmin("use_channel", ctx.GetStringSlice("use_channel"))
if relayInfo != nil {
if billingModel := relayInfo.GetBillingModelName(); billingModel != "" && billingModel != relayInfo.OriginModelName {
other.SetAdmin("billing_model", billingModel)
}
if diagnostics := relayInfo.ConversionDiagnostics(); len(diagnostics) > 0 {
other.SetAdmin("conversion_diagnostics", diagnostics)
}
if relayInfo.ConversionDiagnosticsTruncated() {
other.SetAdmin("conversion_diagnostics_truncated", true)
}
}
if common.GetContextKeyBool(ctx, constant.ContextKeyChannelIsMultiKey) {
other.SetAdmin("is_multi_key", true)
other.SetAdmin("multi_key_index", common.GetContextKeyInt(ctx, constant.ContextKeyChannelMultiKeyIndex))
}
if common.GetContextKeyBool(ctx, constant.ContextKeyLocalCountTokens) {
other.SetAdmin("local_count_tokens", true)
}
AppendChannelAffinityAdminInfo(ctx, other)
}
func GenerateTextOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, modelRatio, groupRatio, completionRatio float64,
cacheTokens int, cacheRatio float64, modelPrice float64, userGroupRatio float64) *model.LogOther {
other := model.NewLogOther()
other.SetPublic("model_ratio", modelRatio)
other.SetPublic("group_ratio", groupRatio)
other.SetPublic("completion_ratio", completionRatio)
other.SetPublic("cache_tokens", cacheTokens)
other.SetPublic("cache_ratio", cacheRatio)
other.SetPublic("model_price", modelPrice)
other.SetPublic("user_group_ratio", userGroupRatio)
other.SetPublic("frt", float64(relayInfo.FirstResponseTime.UnixMilli()-relayInfo.StartTime.UnixMilli()))
if relayInfo.ReasoningEffort != "" {
other.SetPublic("reasoning_effort", relayInfo.ReasoningEffort)
}
if relayInfo.IsModelMapped {
other.SetPublic("is_model_mapped", true)
other.SetPublic("upstream_model_name", relayInfo.UpstreamModelName)
}
isSystemPromptOverwritten := common.GetContextKeyBool(ctx, constant.ContextKeySystemPromptOverride)
if isSystemPromptOverwritten {
other.SetPublic("is_system_prompt_overwritten", true)
}
AppendRelayLogAdminInfo(ctx, relayInfo, other)
appendRequestPath(ctx, relayInfo, other)
appendRequestConversionChain(relayInfo, other)
appendFinalRequestFormat(relayInfo, other)
appendBillingInfo(relayInfo, other)
appendParamOverrideInfo(relayInfo, other)
appendStreamStatus(relayInfo, other)
return other
}
func appendParamOverrideInfo(relayInfo *relaycommon.RelayInfo, other *model.LogOther) {
if relayInfo == nil || other == nil || len(relayInfo.ParamOverrideAudit) == 0 {
return
}
other.SetPublic("po", relayInfo.ParamOverrideAudit)
}
func appendStreamStatus(relayInfo *relaycommon.RelayInfo, other *model.LogOther) {
if relayInfo == nil || other == nil || !relayInfo.IsStream || relayInfo.StreamStatus == nil {
return
}
ss := relayInfo.StreamStatus
status := "ok"
if ss.HasErrors() || (ss.EndReason != relaycommon.StreamEndReasonClientGone && !ss.IsNormalEnd()) {
status = "error"
}
streamInfo := map[string]any{
"status": status,
"end_reason": string(ss.EndReason),
}
if ss.EndError != nil {
streamInfo["end_error"] = ss.EndError.Error()
}
if ss.ErrorCount > 0 {
streamInfo["error_count"] = ss.ErrorCount
messages := make([]string, 0, len(ss.Errors))
for _, e := range ss.Errors {
messages = append(messages, e.Message)
}
streamInfo["errors"] = messages
}
other.SetPublic("stream_status", streamInfo)
}
func appendBillingInfo(relayInfo *relaycommon.RelayInfo, other *model.LogOther) {
if relayInfo == nil || other == nil {
return
}
// billing_source: "wallet" or "subscription"
if relayInfo.BillingSource != "" {
other.SetPublic("billing_source", relayInfo.BillingSource)
}
if relayInfo.UserSetting.BillingPreference != "" {
other.SetPublic("billing_preference", relayInfo.UserSetting.BillingPreference)
}
if relayInfo.BillingSource == "subscription" {
if relayInfo.SubscriptionId != 0 {
other.SetPublic("subscription_id", relayInfo.SubscriptionId)
}
if relayInfo.SubscriptionPreConsumed > 0 {
other.SetPublic("subscription_pre_consumed", relayInfo.SubscriptionPreConsumed)
}
// post_delta: settlement delta applied after actual usage is known (can be negative for refund)
if relayInfo.SubscriptionPostDelta != 0 {
other.SetPublic("subscription_post_delta", relayInfo.SubscriptionPostDelta)
}
if relayInfo.SubscriptionPlanId != 0 {
other.SetPublic("subscription_plan_id", relayInfo.SubscriptionPlanId)
}
if relayInfo.SubscriptionPlanTitle != "" {
other.SetPublic("subscription_plan_title", relayInfo.SubscriptionPlanTitle)
}
// Compute "this request" subscription consumed + remaining
consumed := relayInfo.SubscriptionPreConsumed + relayInfo.SubscriptionPostDelta
usedFinal := relayInfo.SubscriptionAmountUsedAfterPreConsume + relayInfo.SubscriptionPostDelta
if consumed < 0 {
consumed = 0
}
if usedFinal < 0 {
usedFinal = 0
}
if relayInfo.SubscriptionAmountTotal > 0 {
remain := max(relayInfo.SubscriptionAmountTotal-usedFinal, 0)
other.SetPublic("subscription_total", relayInfo.SubscriptionAmountTotal)
other.SetPublic("subscription_used", usedFinal)
other.SetPublic("subscription_remain", remain)
}
if consumed > 0 {
other.SetPublic("subscription_consumed", consumed)
}
// Wallet quota is not deducted when billed from subscription.
other.SetPublic("wallet_quota_deducted", 0)
}
}
func appendRequestConversionChain(relayInfo *relaycommon.RelayInfo, other *model.LogOther) {
if relayInfo == nil || other == nil {
return
}
if len(relayInfo.RequestConversionChain) == 0 {
return
}
chain := make([]string, 0, len(relayInfo.RequestConversionChain))
for _, f := range relayInfo.RequestConversionChain {
switch f {
case types.RelayFormatOpenAI:
chain = append(chain, "OpenAI Compatible")
case types.RelayFormatClaude:
chain = append(chain, "Claude Messages")
case types.RelayFormatGemini:
chain = append(chain, "Google Gemini")
case types.RelayFormatOpenAIResponses:
chain = append(chain, "OpenAI Responses")
default:
chain = append(chain, string(f))
}
}
if len(chain) == 0 {
return
}
other.SetPublic("request_conversion", chain)
}
func appendFinalRequestFormat(relayInfo *relaycommon.RelayInfo, other *model.LogOther) {
if relayInfo == nil || other == nil {
return
}
if relayInfo.GetFinalRequestRelayFormat() == types.RelayFormatClaude {
// claude indicates the final upstream request format is Claude Messages.
// Frontend log rendering uses this to keep the original Claude input display.
other.SetPublic("claude", true)
}
}
func GenerateWssOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.RealtimeUsage, modelRatio, groupRatio, completionRatio, audioRatio, audioCompletionRatio, modelPrice, userGroupRatio float64) *model.LogOther {
info := GenerateTextOtherInfo(ctx, relayInfo, modelRatio, groupRatio, completionRatio, 0, 0.0, modelPrice, userGroupRatio)
info.SetPublic("ws", true)
info.SetPublic("audio_input", usage.InputTokenDetails.AudioTokens)
info.SetPublic("audio_output", usage.OutputTokenDetails.AudioTokens)
info.SetPublic("text_input", usage.InputTokenDetails.TextTokens)
info.SetPublic("text_output", usage.OutputTokenDetails.TextTokens)
info.SetPublic("audio_ratio", audioRatio)
info.SetPublic("audio_completion_ratio", audioCompletionRatio)
return info
}
func GenerateAudioOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.Usage, modelRatio, groupRatio, completionRatio, audioRatio, audioCompletionRatio, modelPrice, userGroupRatio float64) *model.LogOther {
info := GenerateTextOtherInfo(ctx, relayInfo, modelRatio, groupRatio, completionRatio, 0, 0.0, modelPrice, userGroupRatio)
info.SetPublic("audio", true)
info.SetPublic("audio_input", usage.PromptTokensDetails.AudioTokens)
info.SetPublic("audio_output", usage.CompletionTokenDetails.AudioTokens)
info.SetPublic("text_input", usage.PromptTokensDetails.TextTokens)
info.SetPublic("text_output", usage.CompletionTokenDetails.TextTokens)
info.SetPublic("audio_ratio", audioRatio)
info.SetPublic("audio_completion_ratio", audioCompletionRatio)
return info
}
func GenerateClaudeOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, modelRatio, groupRatio, completionRatio float64,
cacheTokens int, cacheRatio float64,
cacheCreationTokens int, cacheCreationRatio float64,
cacheCreationTokens5m int, cacheCreationRatio5m float64,
cacheCreationTokens1h int, cacheCreationRatio1h float64,
modelPrice float64, userGroupRatio float64) *model.LogOther {
info := GenerateTextOtherInfo(ctx, relayInfo, modelRatio, groupRatio, completionRatio, cacheTokens, cacheRatio, modelPrice, userGroupRatio)
info.SetPublic("claude", true)
info.SetPublic("cache_creation_tokens", cacheCreationTokens)
info.SetPublic("cache_creation_ratio", cacheCreationRatio)
if cacheCreationTokens5m != 0 {
info.SetPublic("cache_creation_tokens_5m", cacheCreationTokens5m)
info.SetPublic("cache_creation_ratio_5m", cacheCreationRatio5m)
}
if cacheCreationTokens1h != 0 {
info.SetPublic("cache_creation_tokens_1h", cacheCreationTokens1h)
info.SetPublic("cache_creation_ratio_1h", cacheCreationRatio1h)
}
return info
}
func GenerateMjOtherInfo(relayInfo *relaycommon.RelayInfo, priceData hosttypes.PriceData) *model.LogOther {
other := model.NewLogOther()
other.SetPublic("model_price", priceData.ModelPrice)
other.SetPublic("group_ratio", priceData.GroupRatioInfo.GroupRatio)
if priceData.GroupRatioInfo.HasSpecialRatio {
other.SetPublic("user_group_ratio", priceData.GroupRatioInfo.GroupSpecialRatio)
}
appendRequestPath(nil, relayInfo, other)
return other
}
// InjectTieredBillingInfo overlays tiered billing fields onto an existing
// module-specific other map. Call this after GenerateTextOtherInfo /
// GenerateClaudeOtherInfo / etc. when the request used tiered_expr billing.
func InjectTieredBillingInfo(other *model.LogOther, relayInfo *relaycommon.RelayInfo, result *billingexpr.TieredResult) {
if relayInfo == nil || other == nil {
return
}
snap := relayInfo.TieredBillingSnapshot
if snap == nil {
return
}
other.SetPublic("billing_mode", "tiered_expr")
other.SetPublic("expr_b64", base64.StdEncoding.EncodeToString([]byte(snap.ExprString)))
if result != nil {
other.SetPublic("matched_tier", result.MatchedTier)
if len(result.RequestRules) > 0 {
other.SetPublic("request_rules", result.RequestRules)
}
}
}
+28
View File
@@ -0,0 +1,28 @@
package service
import (
"testing"
"github.com/QuantumNous/new-api/model"
relaycommon "github.com/QuantumNous/new-api/relay/common"
)
func TestAppendStreamStatusTreatsClientGoneAsDisplayNormal(t *testing.T) {
status := relaycommon.NewStreamStatus()
status.SetEndReason(relaycommon.StreamEndReasonClientGone, nil)
info := &relaycommon.RelayInfo{IsStream: true, StreamStatus: status}
other := model.NewLogOther()
appendStreamStatus(info, other)
streamInfo, ok := other.Snapshot()["stream_status"].(map[string]any)
if !ok {
t.Fatal("stream_status was not written")
}
if got := streamInfo["status"]; got != "ok" {
t.Fatalf("expected client_gone display status ok, got %v", got)
}
if got := streamInfo["end_reason"]; got != string(relaycommon.StreamEndReasonClientGone) {
t.Fatalf("expected client_gone end reason to be retained, got %v", got)
}
}
+149
View File
@@ -0,0 +1,149 @@
package service
import (
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
)
const LoginVerificationTTL = 5 * time.Minute
type LoginChallenge struct {
RequireVerification bool `json:"require_verification"`
FlowToken string `json:"flow_token"`
ExpiresAt int64 `json:"expires_at"`
Methods []VerificationMethodOption `json:"methods"`
}
type loginFlowPayload struct {
AuthVersion int64 `json:"auth_version"`
LoginMethod string `json:"login_method"`
}
// LoginVerification is server-owned state read from a primary-authenticated flow.
// It is never constructed from a user ID or an authentication claim in a request.
type LoginVerification struct {
Flow *model.AuthFlow
State *model.UserVerificationState
payload loginFlowPayload
}
func StartLoginVerification(user *model.User, loginMethod string) (*LoginChallenge, error) {
if user == nil || user.Id <= 0 || user.AuthVersion <= 0 || loginMethod == "" {
return nil, model.ErrAuthFlowInvalid
}
state, err := model.GetUserVerificationState(user.Id)
if err != nil {
return nil, err
}
if state.Status != common.UserStatusEnabled || state.AuthVersion != user.AuthVersion {
return nil, model.ErrUserSessionInactive
}
methods, err := securityVerificationPolicy(VerificationScopeLogin, *state)
if err != nil || len(methods) == 0 {
return nil, err
}
available := false
for _, method := range methods {
available = available || method.Available
}
if !available {
return nil, ErrVerificationUnavailable
}
payload, err := common.Marshal(loginFlowPayload{AuthVersion: state.AuthVersion, LoginMethod: loginMethod})
if err != nil {
return nil, err
}
expiresAt := time.Now().Add(LoginVerificationTTL)
token, _, err := model.CreateAuthFlow(model.AuthFlowCreate{
Purpose: model.AuthFlowPurposeLoginVerification, UserId: user.Id,
Payload: string(payload), ExpiresAt: expiresAt,
})
if err != nil {
return nil, err
}
return &LoginChallenge{RequireVerification: true, FlowToken: token, ExpiresAt: expiresAt.Unix(), Methods: methods}, nil
}
func RequireLoginVerification(token, method string) (*LoginVerification, error) {
flow, err := model.GetAuthFlow(token, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeLoginVerification})
if err != nil {
return nil, err
}
var payload loginFlowPayload
if err := common.UnmarshalJsonStr(flow.Payload, &payload); err != nil || payload.AuthVersion <= 0 || payload.LoginMethod == "" {
return nil, model.ErrAuthFlowInvalid
}
state, err := model.GetUserVerificationState(flow.UserId)
if err != nil {
return nil, err
}
if state.Status != common.UserStatusEnabled || state.AuthVersion != payload.AuthVersion {
return nil, model.ErrUserSessionInactive
}
if err := requireLoginVerificationMethod(state, method); err != nil {
return nil, err
}
return &LoginVerification{Flow: flow, State: state, payload: payload}, nil
}
func requireLoginVerificationMethod(state *model.UserVerificationState, method string) error {
methods, err := securityVerificationPolicy(VerificationScopeLogin, *state)
if err != nil {
return err
}
for _, option := range methods {
if option.Method != method {
continue
}
if !option.Available {
return ErrVerificationUnavailable
}
return nil
}
return ErrProofMethod
}
func VerifyLoginCode(token, code, ip, userAgent string) (*AuthBundle, error) {
verification, err := RequireLoginVerification(token, VerificationMethodTwoFA)
if err != nil {
return nil, err
}
twoFA, err := model.GetTwoFAByUserId(verification.State.UserID)
if err != nil {
return nil, err
}
if err := VerifyTwoFactorCode(twoFA, code); err != nil {
return nil, err
}
return CompleteLoginVerification(token, verification, VerificationMethodTwoFA, ip, userAgent)
}
// CompleteLoginVerification must only run after a concrete factor ceremony.
// Recheck the bound version and method while consuming the flow and creating the
// session atomically; a different request cannot reuse this authorization.
func CompleteLoginVerification(token string, verification *LoginVerification, method, ip, userAgent string) (*AuthBundle, error) {
if verification == nil || verification.Flow == nil || verification.State == nil {
return nil, model.ErrAuthFlowInvalid
}
session, refreshSecret, err := newLoginSession(verification.State.UserID, verification.payload.AuthVersion, verification.payload.LoginMethod, ip, userAgent)
if err != nil {
return nil, err
}
if err := model.CreateUserSessionFromLoginFlow(token, session, func(flow *model.AuthFlow, state *model.UserVerificationState) error {
var payload loginFlowPayload
if flow.Id != verification.Flow.Id || common.UnmarshalJsonStr(flow.Payload, &payload) != nil || payload != verification.payload {
return model.ErrAuthFlowInvalid
}
return requireLoginVerificationMethod(state, method)
}); err != nil {
return nil, err
}
bundle, err := issueAuthBundle(session, session.SID+"."+refreshSecret, true)
if err != nil {
_, _ = model.RevokeUserSession(session.UserID, session.SID, "token_issue_failed")
return nil, err
}
return bundle, nil
}
+368
View File
@@ -0,0 +1,368 @@
package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model"
relaycommon "github.com/QuantumNous/new-api/relay/common"
relayconstant "github.com/QuantumNous/new-api/relay/constant"
"github.com/QuantumNous/new-api/setting"
"github.com/gin-gonic/gin"
)
func CovertMjpActionToModelName(mjAction string) string {
modelName := "mj_" + strings.ToLower(mjAction)
if mjAction == constant.MjActionSwapFace {
modelName = "swap_face"
}
return modelName
}
// PrepareMidjourneyTaskBilling sets the durable refund marker before the task is inserted.
func PrepareMidjourneyTaskBilling(relayInfo *relaycommon.RelayInfo, task *model.Midjourney, quota int, shouldBill bool) (bool, error) {
if task == nil {
return false, errors.New("Midjourney task is nil")
}
task.Quota = 0
task.TokenId = 0
task.BillingChannelId = 0
if !shouldBill {
return false, nil
}
if relayInfo == nil {
return false, errors.New("relay info is nil")
}
if quota < 0 {
return false, errors.New("quota cannot be negative")
}
if relayInfo.BillingSource == BillingSourceSubscription {
return false, errors.New("legacy Midjourney billing does not support subscriptions")
}
task.Quota = quota
task.BillingChannelId = task.ChannelId
if relayInfo.ChannelMeta != nil && relayInfo.ChannelId > 0 {
task.BillingChannelId = relayInfo.ChannelId
}
return true, nil
}
// SettleMidjourneyTaskBilling charges a persisted legacy task and records the applied stages.
func SettleMidjourneyTaskBilling(relayInfo *relaycommon.RelayInfo, task *model.Midjourney, prepared bool) (bool, error) {
if !prepared {
return false, nil
}
if relayInfo == nil {
return false, errors.New("relay info is nil")
}
if task == nil || task.Id == 0 {
return false, errors.New("Midjourney task must be persisted before billing")
}
result, billingErr := postConsumeQuotaWithResult(relayInfo, task.Quota, 0, true)
if !result.FundingApplied {
task.Quota = 0
task.TokenId = 0
task.BillingChannelId = 0
if updateErr := task.UpdateBillingState(); updateErr != nil {
return false, errors.Join(billingErr, fmt.Errorf("clear Midjourney billing state: %w", updateErr))
}
return false, billingErr
}
task.TokenId = 0
if result.TokenApplied {
task.TokenId = relayInfo.TokenId
}
if updateErr := task.UpdateBillingState(); updateErr != nil {
return true, errors.Join(billingErr, fmt.Errorf("update Midjourney billing state: %w", updateErr))
}
return true, billingErr
}
// RefundMidjourneyQuota reverses every accounting element recorded for a billed legacy task.
func RefundMidjourneyQuota(ctx context.Context, task *model.Midjourney, reason string) bool {
quota := task.Quota
if quota == 0 {
return true
}
if err := model.IncreaseUserQuota(task.UserId, quota, false); err != nil {
logger.LogWarn(ctx, fmt.Sprintf("退还 Midjourney 用户额度失败 task %s: %s", task.MjId, err.Error()))
return false
}
if task.TokenId > 0 {
tokenKey := resolveTokenKey(ctx, task.TokenId, task.MjId)
if tokenKey != "" {
if err := model.IncreaseTokenQuota(task.TokenId, tokenKey, quota); err != nil {
logger.LogWarn(ctx, fmt.Sprintf("退还 Midjourney 令牌额度失败 task %s: %s", task.MjId, err.Error()))
}
}
}
billingChannelId := task.GetBillingChannelId()
model.UpdateUserUsedQuota(task.UserId, -quota)
model.UpdateChannelUsedQuota(billingChannelId, -quota)
other := model.NewLogOther()
other.SetPublic("task_id", task.MjId)
other.SetPublic("reason", reason)
model.RecordTaskBillingLog(model.RecordTaskBillingLogParams{
UserId: task.UserId,
LogType: model.LogTypeRefund,
Content: "",
ChannelId: billingChannelId,
ModelName: CovertMjpActionToModelName(task.Action),
Quota: quota,
TokenId: task.TokenId,
Other: other,
})
task.Quota = 0
if err := task.UpdateBillingState(); err != nil {
logger.LogError(ctx, fmt.Sprintf("Midjourney 退款成功但清除 quota 失败 task %s: %s", task.MjId, err.Error()))
}
return true
}
func GetMjRequestModel(relayMode int, midjRequest *dto.MidjourneyRequest) (string, *dto.MidjourneyResponse, bool) {
action := ""
if relayMode == relayconstant.RelayModeMidjourneyAction {
// plus request
err := CoverPlusActionToNormalAction(midjRequest)
if err != nil {
return "", err, false
}
action = midjRequest.Action
} else {
switch relayMode {
case relayconstant.RelayModeMidjourneyImagine:
action = constant.MjActionImagine
case relayconstant.RelayModeMidjourneyVideo:
action = constant.MjActionVideo
case relayconstant.RelayModeMidjourneyEdits:
action = constant.MjActionEdits
case relayconstant.RelayModeMidjourneyDescribe:
action = constant.MjActionDescribe
case relayconstant.RelayModeMidjourneyBlend:
action = constant.MjActionBlend
case relayconstant.RelayModeMidjourneyShorten:
action = constant.MjActionShorten
case relayconstant.RelayModeMidjourneyChange:
action = midjRequest.Action
case relayconstant.RelayModeMidjourneyModal:
action = constant.MjActionModal
case relayconstant.RelayModeSwapFace:
action = constant.MjActionSwapFace
case relayconstant.RelayModeMidjourneyUpload:
action = constant.MjActionUpload
case relayconstant.RelayModeMidjourneySimpleChange:
params := ConvertSimpleChangeParams(midjRequest.Content)
if params == nil {
return "", MidjourneyErrorWrapper(constant.MjRequestError, "invalid_request"), false
}
action = params.Action
case relayconstant.RelayModeMidjourneyTaskFetch, relayconstant.RelayModeMidjourneyTaskFetchByCondition, relayconstant.RelayModeMidjourneyNotify:
return "", nil, true
default:
return "", MidjourneyErrorWrapper(constant.MjRequestError, "unknown_relay_action"), false
}
}
modelName := CovertMjpActionToModelName(action)
return modelName, nil, true
}
func CoverPlusActionToNormalAction(midjRequest *dto.MidjourneyRequest) *dto.MidjourneyResponse {
// "customId": "MJ::JOB::upsample::2::3dbbd469-36af-4a0f-8f02-df6c579e7011"
customId := midjRequest.CustomId
if customId == "" {
return MidjourneyErrorWrapper(constant.MjRequestError, "custom_id_is_required")
}
splits := strings.Split(customId, "::")
var action string
if splits[1] == "JOB" {
action = splits[2]
} else {
action = splits[1]
}
if action == "" {
return MidjourneyErrorWrapper(constant.MjRequestError, "unknown_action")
}
if strings.Contains(action, "upsample") {
index, err := strconv.Atoi(splits[3])
if err != nil {
return MidjourneyErrorWrapper(constant.MjRequestError, "index_parse_failed")
}
midjRequest.Index = index
midjRequest.Action = constant.MjActionUpscale
} else if strings.Contains(action, "variation") {
midjRequest.Index = 1
if action == "variation" {
index, err := strconv.Atoi(splits[3])
if err != nil {
return MidjourneyErrorWrapper(constant.MjRequestError, "index_parse_failed")
}
midjRequest.Index = index
midjRequest.Action = constant.MjActionVariation
} else if action == "low_variation" {
midjRequest.Action = constant.MjActionLowVariation
} else if action == "high_variation" {
midjRequest.Action = constant.MjActionHighVariation
}
} else if strings.Contains(action, "pan") {
midjRequest.Action = constant.MjActionPan
midjRequest.Index = 1
} else if strings.Contains(action, "reroll") {
midjRequest.Action = constant.MjActionReRoll
midjRequest.Index = 1
} else if action == "Outpaint" {
midjRequest.Action = constant.MjActionZoom
midjRequest.Index = 1
} else if action == "CustomZoom" {
midjRequest.Action = constant.MjActionCustomZoom
midjRequest.Index = 1
} else if action == "Inpaint" {
midjRequest.Action = constant.MjActionInPaint
midjRequest.Index = 1
} else {
return MidjourneyErrorWrapper(constant.MjRequestError, "unknown_action:"+customId)
}
return nil
}
func ConvertSimpleChangeParams(content string) *dto.MidjourneyRequest {
split := strings.Split(content, " ")
if len(split) != 2 {
return nil
}
action := strings.ToLower(split[1])
changeParams := &dto.MidjourneyRequest{}
changeParams.TaskId = split[0]
if action[0] == 'u' {
changeParams.Action = "UPSCALE"
} else if action[0] == 'v' {
changeParams.Action = "VARIATION"
} else if action == "r" {
changeParams.Action = "REROLL"
return changeParams
} else {
return nil
}
index, err := strconv.Atoi(action[1:2])
if err != nil || index < 1 || index > 4 {
return nil
}
changeParams.Index = index
return changeParams
}
func DoMidjourneyHttpRequest(c *gin.Context, timeout time.Duration, fullRequestURL string) (*dto.MidjourneyResponseWithStatusCode, []byte, error) {
var nullBytes []byte
//var requestBody io.Reader
//requestBody = c.Request.Body
// read request body to json, delete accountFilter and notifyHook
var mapResult map[string]any
// if get request, no need to read request body
if c.Request.Method != "GET" {
err := json.NewDecoder(c.Request.Body).Decode(&mapResult)
if err != nil {
return MidjourneyErrorWithStatusCodeWrapper(constant.MjErrorUnknown, "read_request_body_failed", http.StatusInternalServerError), nullBytes, err
}
if !setting.MjAccountFilterEnabled {
delete(mapResult, "accountFilter")
}
if !setting.MjNotifyEnabled {
delete(mapResult, "notifyHook")
}
//req, err := http.NewRequest(c.Request.Method, fullRequestURL, requestBody)
// make new request with mapResult
}
if setting.MjModeClearEnabled {
if prompt, ok := mapResult["prompt"].(string); ok {
prompt = strings.Replace(prompt, "--fast", "", -1)
prompt = strings.Replace(prompt, "--relax", "", -1)
prompt = strings.Replace(prompt, "--turbo", "", -1)
mapResult["prompt"] = prompt
}
}
reqBody, err := json.Marshal(mapResult)
if err != nil {
return MidjourneyErrorWithStatusCodeWrapper(constant.MjErrorUnknown, "marshal_request_body_failed", http.StatusInternalServerError), nullBytes, err
}
req, err := http.NewRequest(c.Request.Method, fullRequestURL, strings.NewReader(string(reqBody)))
if err != nil {
return MidjourneyErrorWithStatusCodeWrapper(constant.MjErrorUnknown, "create_request_failed", http.StatusInternalServerError), nullBytes, err
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
// 使用带有超时的 context 创建新的请求
req = req.WithContext(ctx)
req.Header.Set("Content-Type", c.Request.Header.Get("Content-Type"))
req.Header.Set("Accept", c.Request.Header.Get("Accept"))
auth := common.GetContextKeyString(c, constant.ContextKeyChannelKey)
if auth != "" {
auth = strings.TrimPrefix(auth, "Bearer ")
req.Header.Set("mj-api-secret", auth)
}
defer cancel()
resp, err := GetHttpClient().Do(req)
if err != nil {
common.SysLog("do request failed: " + err.Error())
return MidjourneyErrorWithStatusCodeWrapper(constant.MjErrorUnknown, "do_request_failed", http.StatusInternalServerError), nullBytes, err
}
statusCode := resp.StatusCode
//if statusCode != 200 {
// return MidjourneyErrorWithStatusCodeWrapper(constant.MjErrorUnknown, "bad_response_status_code", statusCode), nullBytes, nil
//}
err = req.Body.Close()
if err != nil {
return MidjourneyErrorWithStatusCodeWrapper(constant.MjErrorUnknown, "close_request_body_failed", statusCode), nullBytes, err
}
err = c.Request.Body.Close()
if err != nil {
return MidjourneyErrorWithStatusCodeWrapper(constant.MjErrorUnknown, "close_request_body_failed", statusCode), nullBytes, err
}
var midjResponse dto.MidjourneyResponse
var midjourneyUploadsResponse dto.MidjourneyUploadResponse
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return MidjourneyErrorWithStatusCodeWrapper(constant.MjErrorUnknown, "read_response_body_failed", statusCode), nullBytes, err
}
CloseResponseBodyGracefully(resp)
logger.LogDebug(c, "midjourney response body: %s", responseBody)
if len(responseBody) == 0 {
return MidjourneyErrorWithStatusCodeWrapper(constant.MjErrorUnknown, "empty_response_body", statusCode), responseBody, nil
} else {
err = json.Unmarshal(responseBody, &midjResponse)
if err != nil {
err2 := json.Unmarshal(responseBody, &midjourneyUploadsResponse)
if err2 != nil {
return MidjourneyErrorWithStatusCodeWrapper(constant.MjErrorUnknown, "unmarshal_response_body_failed", statusCode), responseBody, err
}
}
}
//for k, v := range resp.Header {
// c.Writer.Header().Set(k, v[0])
//}
return &dto.MidjourneyResponseWithStatusCode{
StatusCode: statusCode,
Response: midjResponse,
}, responseBody, nil
}
+118
View File
@@ -0,0 +1,118 @@
package service
import (
"fmt"
"strconv"
"sync"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/bytedance/gopkg/util/gopool"
)
// notifyLimitStore is used for in-memory rate limiting when Redis is disabled
var (
notifyLimitStore sync.Map
cleanupOnce sync.Once
)
type limitCount struct {
Count int
Timestamp time.Time
}
func getDuration() time.Duration {
minute := constant.NotificationLimitDurationMinute
return time.Duration(minute) * time.Minute
}
// startCleanupTask starts a background task to clean up expired entries
func startCleanupTask() {
gopool.Go(func() {
for {
time.Sleep(time.Hour)
now := time.Now()
notifyLimitStore.Range(func(key, value any) bool {
if limit, ok := value.(limitCount); ok {
if now.Sub(limit.Timestamp) >= getDuration() {
notifyLimitStore.Delete(key)
}
}
return true
})
}
})
}
// CheckNotificationLimit checks if the user has exceeded their notification limit
// Returns true if the user can send notification, false if limit exceeded
func CheckNotificationLimit(userId int, notifyType string) (bool, error) {
if common.RedisEnabled {
return checkRedisLimit(userId, notifyType)
}
return checkMemoryLimit(userId, notifyType)
}
func checkRedisLimit(userId int, notifyType string) (bool, error) {
key := fmt.Sprintf("notify_limit:%d:%s:%s", userId, notifyType, time.Now().Format("2006010215"))
// Get current count
count, err := common.RedisGet(key)
if err != nil && err.Error() != "redis: nil" {
return false, fmt.Errorf("failed to get notification count: %w", err)
}
// If key doesn't exist, initialize it
if count == "" {
err = common.RedisSet(key, "1", getDuration())
return true, err
}
currentCount, _ := strconv.Atoi(count)
limit := constant.NotifyLimitCount
// Check if limit is already reached
if currentCount >= limit {
return false, nil
}
// Only increment if under limit
err = common.RedisIncr(key, 1)
if err != nil {
return false, fmt.Errorf("failed to increment notification count: %w", err)
}
return true, nil
}
func checkMemoryLimit(userId int, notifyType string) (bool, error) {
// Ensure cleanup task is started
cleanupOnce.Do(startCleanupTask)
key := fmt.Sprintf("%d:%s:%s", userId, notifyType, time.Now().Format("2006010215"))
now := time.Now()
// Get current limit count or initialize new one
var currentLimit limitCount
if value, ok := notifyLimitStore.Load(key); ok {
currentLimit = value.(limitCount)
// Check if the entry has expired
if now.Sub(currentLimit.Timestamp) >= getDuration() {
currentLimit = limitCount{Count: 0, Timestamp: now}
}
} else {
currentLimit = limitCount{Count: 0, Timestamp: now}
}
// Increment count
currentLimit.Count++
// Check against limits
limit := constant.NotifyLimitCount
// Store updated count
notifyLimitStore.Store(key, currentLimit)
return currentLimit.Count <= limit, nil
}
+44
View File
@@ -0,0 +1,44 @@
package service
import "errors"
var ErrOAuthAccountMismatch = errors.New("The OAuth account does not match the account linked to your profile.")
type OAuthVerificationFlow struct {
Scope string `json:"scope"`
ContextHash string `json:"context_hash"`
ProviderUserID string `json:"provider_user_id"`
UserAuthVersion int64 `json:"auth_version"`
SessionVersion int64 `json:"session_version"`
}
func StartOAuthVerification(identity AuthIdentity, operation VerificationOperation, provider string) (*OAuthVerificationFlow, error) {
binding, err := BindVerificationOperation(operation)
if err != nil {
return nil, err
}
providerUserID, err := GetOAuthVerificationBinding(identity, binding.Scope, provider)
if err != nil {
return nil, err
}
return &OAuthVerificationFlow{
Scope: binding.Scope, ContextHash: binding.ContextHash, ProviderUserID: providerUserID,
UserAuthVersion: identity.UserAuthVersion, SessionVersion: identity.SessionVersion,
}, nil
}
// FinishOAuthVerification only verifies an existing binding. It deliberately
// never calls OAuth's login or find-or-create-user paths.
func FinishOAuthVerification(identity AuthIdentity, provider, actualUserID string, flow *OAuthVerificationFlow) (*SecurityProof, error) {
if flow == nil || flow.ContextHash == "" || flow.UserAuthVersion != identity.UserAuthVersion || flow.SessionVersion != identity.SessionVersion {
return nil, ErrAuthTokenInvalid
}
expectedUserID, err := GetOAuthVerificationBinding(identity, flow.Scope, provider)
if err != nil {
return nil, err
}
if actualUserID == "" || actualUserID != expectedUserID || actualUserID != flow.ProviderUserID {
return nil, ErrOAuthAccountMismatch
}
return CompleteSecurityVerification(identity, VerificationBinding{Scope: flow.Scope, ContextHash: flow.ContextHash}, VerificationMethodOAuth)
}
+30
View File
@@ -0,0 +1,30 @@
package service
import (
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert"
)
func ChatCompletionsRequestToResponsesRequest(req *dto.GeneralOpenAIRequest) (*dto.OpenAIResponsesRequest, error) {
return relayconvert.ChatCompletionsRequestToResponsesRequest(req)
}
func ResponsesRequestToChatCompletionsRequest(req *dto.OpenAIResponsesRequest) (*dto.GeneralOpenAIRequest, error) {
return relayconvert.ResponsesRequestToChatCompletionsRequest(req)
}
func ChatCompletionsResponseToResponsesResponse(resp *dto.OpenAITextResponse, id string) (*dto.OpenAIResponsesResponse, *dto.Usage, error) {
return relayconvert.ChatCompletionsResponseToResponsesResponse(resp, id)
}
func ResponsesResponseToChatCompletionsResponse(resp *dto.OpenAIResponsesResponse, id string) (*dto.OpenAITextResponse, *dto.Usage, error) {
return relayconvert.ResponsesResponseToChatCompletionsResponse(resp, id)
}
func ResponsesFinishReasonFromStatus(resp *dto.OpenAIResponsesResponse) (string, bool) {
return relayconvert.ResponsesFinishReasonFromStatus(resp)
}
func ExtractOutputTextFromResponses(resp *dto.OpenAIResponsesResponse) string {
return relayconvert.ExtractOutputTextFromResponses(resp)
}
+54
View File
@@ -0,0 +1,54 @@
package service
import (
"regexp"
"sync"
"github.com/QuantumNous/new-api/setting/model_setting"
)
// Chat→Responses upgrade policy is host routing logic (it decides *whether*
// to convert, reading host settings), so it lives here, not in relayconvert.
var chatResponsesRegexCache sync.Map // map[string]*regexp.Regexp
func matchAnyModelPattern(patterns []string, model string) bool {
if len(patterns) == 0 || model == "" {
return false
}
for _, pattern := range patterns {
if pattern == "" {
continue
}
re, ok := chatResponsesRegexCache.Load(pattern)
if !ok {
compiled, err := regexp.Compile(pattern)
if err != nil {
// Treat invalid patterns as non-matching to avoid breaking runtime traffic.
continue
}
re = compiled
chatResponsesRegexCache.Store(pattern, re)
}
if re.(*regexp.Regexp).MatchString(model) {
return true
}
}
return false
}
func ShouldChatCompletionsUseResponsesPolicy(policy model_setting.ChatCompletionsToResponsesPolicy, channelID int, channelType int, model string) bool {
if !policy.IsChannelEnabled(channelID, channelType) {
return false
}
return matchAnyModelPattern(policy.ModelPatterns, model)
}
func ShouldChatCompletionsUseResponsesGlobal(channelID int, channelType int, model string) bool {
return ShouldChatCompletionsUseResponsesPolicy(
model_setting.GetGlobalSettings().ChatCompletionsToResponsesPolicy,
channelID,
channelType,
model,
)
}
+171
View File
@@ -0,0 +1,171 @@
package passkey
import (
"errors"
"fmt"
"net"
"net/http"
"net/url"
"strings"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/setting/system_setting"
"github.com/go-webauthn/webauthn/protocol"
webauthn "github.com/go-webauthn/webauthn/webauthn"
)
// BuildWebAuthn constructs a WebAuthn instance using the current passkey settings and request context.
func BuildWebAuthn(r *http.Request) (*webauthn.WebAuthn, error) {
settings := system_setting.GetPasskeySettings()
if settings == nil {
return nil, errors.New("未找到 Passkey 设置")
}
displayName := strings.TrimSpace(settings.RPDisplayName)
if displayName == "" {
displayName = common.SystemName
}
origins, err := resolveOrigins(r, settings)
if err != nil {
return nil, err
}
rpID, err := resolveRPID(r, settings, origins)
if err != nil {
return nil, err
}
selection := protocol.AuthenticatorSelection{
ResidentKey: protocol.ResidentKeyRequirementRequired,
RequireResidentKey: protocol.ResidentKeyRequired(),
UserVerification: protocol.UserVerificationRequirement(settings.UserVerification),
}
if selection.UserVerification == "" {
selection.UserVerification = protocol.VerificationPreferred
}
if attachment := strings.TrimSpace(settings.AttachmentPreference); attachment != "" {
selection.AuthenticatorAttachment = protocol.AuthenticatorAttachment(attachment)
}
config := &webauthn.Config{
RPID: rpID,
RPDisplayName: displayName,
RPOrigins: origins,
AuthenticatorSelection: selection,
Debug: common.DebugEnabled,
Timeouts: webauthn.TimeoutsConfig{
Login: webauthn.TimeoutConfig{
Enforce: true,
Timeout: 2 * time.Minute,
TimeoutUVD: 2 * time.Minute,
},
Registration: webauthn.TimeoutConfig{
Enforce: true,
Timeout: 2 * time.Minute,
TimeoutUVD: 2 * time.Minute,
},
},
}
return webauthn.New(config)
}
func resolveOrigins(r *http.Request, settings *system_setting.PasskeySettings) ([]string, error) {
originsStr := strings.TrimSpace(settings.Origins)
if originsStr != "" {
originList := strings.Split(originsStr, ",")
origins := make([]string, 0, len(originList))
for _, origin := range originList {
trimmed := strings.TrimSpace(origin)
if trimmed == "" {
continue
}
if !settings.AllowInsecureOrigin && strings.HasPrefix(strings.ToLower(trimmed), "http://") {
return nil, fmt.Errorf("Passkey 不允许使用不安全的 Origin: %s", trimmed)
}
origins = append(origins, trimmed)
}
if len(origins) == 0 {
// 如果配置了Origins但过滤后为空,使用自动推导
goto autoDetect
}
return origins, nil
}
autoDetect:
scheme := detectScheme(r)
if scheme == "http" && !settings.AllowInsecureOrigin && r.Host != "localhost" && r.Host != "127.0.0.1" && !strings.HasPrefix(r.Host, "127.0.0.1:") && !strings.HasPrefix(r.Host, "localhost:") {
return nil, fmt.Errorf("Passkey 仅支持 HTTPS,当前访问: %s://%s,请在 Passkey 设置中允许不安全 Origin 或配置 HTTPS", scheme, r.Host)
}
// 优先使用请求的完整Host(包含端口)
host := r.Host
// 如果无法从请求获取Host,尝试从ServerAddress获取
if host == "" && system_setting.ServerAddress != "" {
if parsed, err := url.Parse(system_setting.ServerAddress); err == nil && parsed.Host != "" {
host = parsed.Host
if scheme == "" && parsed.Scheme != "" {
scheme = parsed.Scheme
}
}
}
if host == "" {
return nil, fmt.Errorf("无法确定 Passkey 的 Origin,请在系统设置或 Passkey 设置中指定。当前 Host: '%s', ServerAddress: '%s'", r.Host, system_setting.ServerAddress)
}
if scheme == "" {
scheme = "https"
}
origin := fmt.Sprintf("%s://%s", scheme, host)
return []string{origin}, nil
}
func resolveRPID(r *http.Request, settings *system_setting.PasskeySettings, origins []string) (string, error) {
rpID := strings.TrimSpace(settings.RPID)
if rpID != "" {
return hostWithoutPort(rpID), nil
}
if len(origins) == 0 {
return "", errors.New("Passkey 未配置 Origin,无法推导 RPID")
}
parsed, err := url.Parse(origins[0])
if err != nil {
return "", fmt.Errorf("无法解析 Passkey Origin: %w", err)
}
return hostWithoutPort(parsed.Host), nil
}
func hostWithoutPort(host string) string {
host = strings.TrimSpace(host)
if host == "" {
return ""
}
if strings.Contains(host, ":") {
if host, _, err := net.SplitHostPort(host); err == nil {
return host
}
}
return host
}
func detectScheme(r *http.Request) string {
if r == nil {
return ""
}
if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" {
parts := strings.Split(proto, ",")
return strings.ToLower(strings.TrimSpace(parts[0]))
}
if r.TLS != nil {
return "https"
}
if r.URL != nil && r.URL.Scheme != "" {
return strings.ToLower(r.URL.Scheme)
}
if r.Header.Get("X-Forwarded-Protocol") != "" {
return strings.ToLower(strings.TrimSpace(r.Header.Get("X-Forwarded-Protocol")))
}
return "http"
}
+97
View File
@@ -0,0 +1,97 @@
package passkey
import (
"errors"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
webauthn "github.com/go-webauthn/webauthn/webauthn"
"gorm.io/gorm"
)
const passkeyFlowTTL = 5 * time.Minute
type flowPayload struct {
SessionData webauthn.SessionData `json:"session_data"`
Security FlowSecurity `json:"security"`
}
type FlowSecurity struct {
model.AuthSessionIdentity
Scope string `json:"scope"`
ContextHash string `json:"context_hash"`
Authorization *model.AuthFlowAuthorization `json:"authorization,omitempty"`
LoginFlowID int64 `json:"login_flow_id,omitempty"`
LoginExpiresAt int64 `json:"login_expires_at,omitempty"`
}
func CreateSessionDataFlow(purpose string, security FlowSecurity, data *webauthn.SessionData) (string, int64, error) {
if data == nil {
return "", 0, errors.New("Passkey 会话数据不能为空")
}
if purpose == model.AuthFlowPurposeLoginPasskey {
if security.UserID <= 0 || security.UserAuthVersion <= 0 || security.LoginFlowID <= 0 || security.LoginExpiresAt <= time.Now().Unix() || security.SessionID != "" || security.SessionVersion != 0 {
return "", 0, model.ErrAuthFlowInvalid
}
} else if purpose != model.AuthFlowPurposePasskeyLogin && (security.UserID <= 0 || security.SessionID == "" || security.Scope == "" || security.ContextHash == "" || security.UserAuthVersion <= 0 || security.SessionVersion <= 0) {
return "", 0, model.ErrAuthFlowInvalid
}
if purpose == model.AuthFlowPurposePasskeyRegister && (security.Authorization == nil || security.Authorization.ProofID <= 0 || security.Authorization.AuthSessionIdentity != security.AuthSessionIdentity) {
return "", 0, model.ErrAuthFlowInvalid
}
payload, err := common.Marshal(flowPayload{SessionData: *data, Security: security})
if err != nil {
return "", 0, err
}
expiresAt := time.Now().Add(passkeyFlowTTL)
if purpose == model.AuthFlowPurposeLoginPasskey && security.LoginExpiresAt < expiresAt.Unix() {
expiresAt = time.Unix(security.LoginExpiresAt, 0)
}
token, _, err := model.CreateAuthFlow(model.AuthFlowCreate{
Purpose: purpose,
UserId: security.UserID,
SessionId: security.SessionID,
Payload: string(payload),
ExpiresAt: expiresAt,
})
if err != nil {
return "", 0, err
}
return token, expiresAt.Unix(), nil
}
func PopSessionDataFlow(token, purpose string, identity model.AuthSessionIdentity) (*webauthn.SessionData, *FlowSecurity, error) {
var payload flowPayload
_, err := model.ConsumeAuthFlowWithAction(token, model.AuthFlowMatch{
Purpose: purpose,
UserId: identity.UserID,
SessionId: identity.SessionID,
}, func(tx *gorm.DB, flow *model.AuthFlow) error {
if err := common.UnmarshalJsonStr(flow.Payload, &payload); err != nil {
return err
}
if purpose == model.AuthFlowPurposePasskeyLogin {
return nil
}
security := payload.Security
if purpose == model.AuthFlowPurposeLoginPasskey {
if security.AuthSessionIdentity != identity || identity.UserID <= 0 || identity.UserAuthVersion <= 0 || identity.SessionID != "" || identity.SessionVersion != 0 || security.LoginFlowID <= 0 || security.LoginExpiresAt <= time.Now().Unix() {
return model.ErrAuthFlowInvalid
}
return nil
}
if security.AuthSessionIdentity != identity || security.Scope == "" || security.ContextHash == "" {
return model.ErrAuthFlowInvalid
}
if purpose == model.AuthFlowPurposePasskeyRegister && (security.Authorization == nil || security.Authorization.ProofID <= 0 || security.Authorization.AuthSessionIdentity != identity) {
return model.ErrAuthFlowInvalid
}
return model.ValidateAuthSessionWithTx(tx, identity)
})
if err != nil {
return nil, nil, err
}
return &payload.SessionData, &payload.Security, nil
}
+71
View File
@@ -0,0 +1,71 @@
package passkey
import (
"fmt"
"strconv"
"strings"
"github.com/QuantumNous/new-api/model"
webauthn "github.com/go-webauthn/webauthn/webauthn"
)
type WebAuthnUser struct {
user *model.User
credential *model.PasskeyCredential
}
func NewWebAuthnUser(user *model.User, credential *model.PasskeyCredential) *WebAuthnUser {
return &WebAuthnUser{user: user, credential: credential}
}
func (u *WebAuthnUser) WebAuthnID() []byte {
if u == nil || u.user == nil {
return nil
}
return []byte(strconv.Itoa(u.user.Id))
}
func (u *WebAuthnUser) WebAuthnName() string {
if u == nil || u.user == nil {
return ""
}
name := strings.TrimSpace(u.user.Username)
if name == "" {
return fmt.Sprintf("user-%d", u.user.Id)
}
return name
}
func (u *WebAuthnUser) WebAuthnDisplayName() string {
if u == nil || u.user == nil {
return ""
}
display := strings.TrimSpace(u.user.DisplayName)
if display != "" {
return display
}
return u.WebAuthnName()
}
func (u *WebAuthnUser) WebAuthnCredentials() []webauthn.Credential {
if u == nil || u.credential == nil {
return nil
}
cred := u.credential.ToWebAuthnCredential()
return []webauthn.Credential{cred}
}
func (u *WebAuthnUser) ModelUser() *model.User {
if u == nil {
return nil
}
return u.user
}
func (u *WebAuthnUser) PasskeyCredential() *model.PasskeyCredential {
if u == nil {
return nil
}
return u.credential
}
+239
View File
@@ -0,0 +1,239 @@
package service
import (
"context"
"fmt"
"net"
"net/http"
"net/url"
"strconv"
"sync"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/setting/system_setting"
)
type ssrfResolver interface {
LookupIPAddr(ctx context.Context, host string) ([]net.IPAddr, error)
}
type protectedFetchDialer struct {
resolver ssrfResolver
dialContext func(ctx context.Context, network, address string) (net.Conn, error)
getProtection func() (*common.SSRFProtection, bool, error)
}
type ssrfProtectedRoundTripper struct {
resolver ssrfResolver
dialContext func(ctx context.Context, network, address string) (net.Conn, error)
getProtection func() (*common.SSRFProtection, bool, error)
proxy func(*http.Request) (*url.URL, error)
mutex sync.Mutex
transports map[string]*http.Transport
}
func currentFetchProtection() (*common.SSRFProtection, bool, error) {
fetchSetting := system_setting.GetFetchSetting()
if !fetchSetting.EnableSSRFProtection {
return nil, false, nil
}
protection, err := common.NewSSRFProtectionFromFetchSetting(
fetchSetting.AllowPrivateIp,
fetchSetting.DomainFilterMode,
fetchSetting.IpFilterMode,
fetchSetting.DomainList,
fetchSetting.IpList,
fetchSetting.AllowedPorts,
fetchSetting.ApplyIPFilterForDomain,
)
if err != nil {
return nil, true, err
}
return protection, true, nil
}
func newProtectedFetchHTTPClient() *http.Client {
return newProtectedFetchHTTPClientWithDialer(nil, nil, nil)
}
func newProtectedFetchHTTPClientWithDialer(resolver ssrfResolver, dialContext func(ctx context.Context, network, address string) (net.Conn, error), getProtection func() (*common.SSRFProtection, bool, error)) *http.Client {
return newProtectedFetchHTTPClientWithProxy(resolver, dialContext, getProtection, http.ProxyFromEnvironment)
}
func newProtectedFetchHTTPClientWithProxy(resolver ssrfResolver, dialContext func(ctx context.Context, network, address string) (net.Conn, error), getProtection func() (*common.SSRFProtection, bool, error), proxy func(*http.Request) (*url.URL, error)) *http.Client {
if resolver == nil {
resolver = net.DefaultResolver
}
if dialContext == nil {
netDialer := &net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
}
dialContext = netDialer.DialContext
}
if getProtection == nil {
getProtection = currentFetchProtection
}
if proxy == nil {
proxy = http.ProxyFromEnvironment
}
client := &http.Client{
Transport: &ssrfProtectedRoundTripper{
resolver: resolver,
dialContext: dialContext,
getProtection: getProtection,
proxy: proxy,
transports: make(map[string]*http.Transport),
},
CheckRedirect: checkProtectedFetchRedirect,
}
if common.RelayTimeout != 0 {
client.Timeout = time.Duration(common.RelayTimeout) * time.Second
}
return client
}
func (t *ssrfProtectedRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
if req == nil || req.URL == nil {
return nil, fmt.Errorf("invalid request")
}
if err := ValidateSSRFProtectedFetchURL(req.URL.String()); err != nil {
return nil, err
}
proxyURL, err := t.proxy(req)
if err != nil {
return nil, err
}
return t.transportFor(proxyURL).RoundTrip(req)
}
func (t *ssrfProtectedRoundTripper) CloseIdleConnections() {
t.mutex.Lock()
defer t.mutex.Unlock()
for _, transport := range t.transports {
transport.CloseIdleConnections()
}
}
func (t *ssrfProtectedRoundTripper) transportFor(proxyURL *url.URL) *http.Transport {
// 只按代理地址分组:代理来自环境变量,取值有限,map 有界;
// 目标 origin 是用户可控输入,不能作为缓存 key。
key := "direct"
if proxyURL != nil {
key = proxyURL.String()
}
t.mutex.Lock()
defer t.mutex.Unlock()
if transport, ok := t.transports[key]; ok {
return transport
}
transport := t.newTransport(proxyURL)
t.transports[key] = transport
return transport
}
func (t *ssrfProtectedRoundTripper) newTransport(proxyURL *url.URL) *http.Transport {
dialContext := t.dialContext
proxyFunc := http.ProxyURL(proxyURL)
if proxyURL == nil {
protectedDialer := &protectedFetchDialer{
resolver: t.resolver,
dialContext: t.dialContext,
getProtection: t.getProtection,
}
dialContext = protectedDialer.DialContext
proxyFunc = nil
}
transport := &http.Transport{
MaxIdleConns: common.RelayMaxIdleConns,
MaxIdleConnsPerHost: common.RelayMaxIdleConnsPerHost,
IdleConnTimeout: time.Duration(common.RelayIdleConnTimeout) * time.Second,
ForceAttemptHTTP2: true,
Proxy: proxyFunc,
DialContext: dialContext,
}
if common.TLSInsecureSkipVerify {
transport.TLSClientConfig = common.InsecureTLSConfig
}
return transport
}
func (d *protectedFetchDialer) DialContext(ctx context.Context, network, addr string) (net.Conn, error) {
protection, enabled, err := d.getProtection()
if err != nil {
return nil, err
}
if !enabled {
return d.dialContext(ctx, network, addr)
}
host, portText, err := net.SplitHostPort(addr)
if err != nil {
return nil, fmt.Errorf("invalid dial address %s: %w", addr, err)
}
port, err := strconv.Atoi(portText)
if err != nil {
return nil, fmt.Errorf("invalid port: %s", portText)
}
if err := protection.ValidateNetworkTarget(host, port); err != nil {
return nil, err
}
if ip := net.ParseIP(host); ip != nil {
return d.dialContext(ctx, network, net.JoinHostPort(ip.String(), portText))
}
if !protection.ApplyIPFilterForDomain {
return d.dialContext(ctx, network, addr)
}
resolved, err := d.resolver.LookupIPAddr(ctx, host)
if err != nil {
return nil, fmt.Errorf("DNS resolution failed for %s: %v", host, err)
}
var candidateIPs []net.IP
for _, ipAddr := range resolved {
ip := ipAddr.IP
if ip == nil || !networkAllowsIP(network, ip) {
continue
}
if err := protection.ValidateResolvedIP(host, ip); err != nil {
return nil, err
}
candidateIPs = append(candidateIPs, ip)
}
var lastDialErr error
for _, ip := range candidateIPs {
conn, err := d.dialContext(ctx, network, net.JoinHostPort(ip.String(), portText))
if err == nil {
return conn, nil
}
lastDialErr = err
}
if lastDialErr != nil {
return nil, lastDialErr
}
return nil, fmt.Errorf("DNS resolution for %s returned no usable IP addresses", host)
}
func networkAllowsIP(network string, ip net.IP) bool {
switch network {
case "tcp4":
return ip.To4() != nil
case "tcp6":
return ip.To4() == nil
default:
return true
}
}
+317
View File
@@ -0,0 +1,317 @@
package service
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"net/url"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/setting/system_setting"
"github.com/stretchr/testify/require"
)
type staticSSRFResolver map[string][]net.IPAddr
func (r staticSSRFResolver) LookupIPAddr(ctx context.Context, host string) ([]net.IPAddr, error) {
if ips, ok := r[host]; ok {
return ips, nil
}
return nil, fmt.Errorf("unexpected lookup for %s", host)
}
func staticProtection(protection *common.SSRFProtection) func() (*common.SSRFProtection, bool, error) {
return func() (*common.SSRFProtection, bool, error) {
return protection, true, nil
}
}
func testConn(t *testing.T) net.Conn {
t.Helper()
clientConn, serverConn := net.Pipe()
t.Cleanup(func() {
clientConn.Close()
serverConn.Close()
})
return clientConn
}
func configureSSRFTestFetchSetting(t *testing.T) {
t.Helper()
fetchSetting := system_setting.GetFetchSetting()
original := *fetchSetting
t.Cleanup(func() {
*fetchSetting = original
})
fetchSetting.EnableSSRFProtection = true
fetchSetting.AllowPrivateIp = false
fetchSetting.DomainFilterMode = false
fetchSetting.IpFilterMode = false
fetchSetting.DomainList = nil
fetchSetting.IpList = nil
fetchSetting.AllowedPorts = []string{"80", "443"}
fetchSetting.ApplyIPFilterForDomain = true
}
func mustParseURL(t *testing.T, rawURL string) *url.URL {
t.Helper()
parsedURL, err := url.Parse(rawURL)
require.NoError(t, err)
return parsedURL
}
func TestProtectedFetchDialerRejectsPrivateReboundAddress(t *testing.T) {
dialer := &protectedFetchDialer{
resolver: staticSSRFResolver{
"safe.example": {{IP: net.ParseIP("127.0.0.1")}},
},
dialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
t.Fatalf("dialContext should not be called for blocked address %s", address)
return nil, nil
},
getProtection: staticProtection(&common.SSRFProtection{
AllowPrivateIp: false,
DomainFilterMode: false,
IpFilterMode: false,
ApplyIPFilterForDomain: true,
}),
}
conn, err := dialer.DialContext(context.Background(), "tcp", "safe.example:80")
require.Error(t, err)
require.Nil(t, conn)
require.Contains(t, err.Error(), "private IP address not allowed")
}
func TestProtectedFetchDialerRejectsMixedResolvedIPs(t *testing.T) {
var dialed []string
dialer := &protectedFetchDialer{
resolver: staticSSRFResolver{
"safe.example": {
{IP: net.ParseIP("10.0.0.1")},
{IP: net.ParseIP("8.8.8.8")},
},
},
dialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
dialed = append(dialed, address)
return testConn(t), nil
},
getProtection: staticProtection(&common.SSRFProtection{
AllowPrivateIp: false,
DomainFilterMode: false,
IpFilterMode: false,
ApplyIPFilterForDomain: true,
}),
}
conn, err := dialer.DialContext(context.Background(), "tcp", "safe.example:443")
require.Error(t, err)
require.Nil(t, conn)
require.Empty(t, dialed)
require.Contains(t, err.Error(), "private IP address not allowed")
}
func TestProtectedFetchDialerDialsWhenAllResolvedIPsAllowed(t *testing.T) {
var dialed []string
dialer := &protectedFetchDialer{
resolver: staticSSRFResolver{
"safe.example": {
{IP: net.ParseIP("8.8.8.8")},
{IP: net.ParseIP("1.1.1.1")},
},
},
dialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
dialed = append(dialed, address)
return testConn(t), nil
},
getProtection: staticProtection(&common.SSRFProtection{
AllowPrivateIp: false,
DomainFilterMode: false,
IpFilterMode: false,
ApplyIPFilterForDomain: true,
}),
}
conn, err := dialer.DialContext(context.Background(), "tcp", "safe.example:443")
require.NoError(t, err)
require.NotNil(t, conn)
require.Equal(t, []string{"8.8.8.8:443"}, dialed)
}
func TestProtectedFetchDialerAllowsPrivateIPWhenWhitelisted(t *testing.T) {
var dialed []string
dialer := &protectedFetchDialer{
resolver: staticSSRFResolver{
"internal.example": {{IP: net.ParseIP("10.1.2.3")}},
},
dialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
dialed = append(dialed, address)
return testConn(t), nil
},
getProtection: staticProtection(&common.SSRFProtection{
AllowPrivateIp: true,
DomainFilterMode: false,
IpFilterMode: true,
IpList: []string{"10.0.0.0/8"},
ApplyIPFilterForDomain: true,
}),
}
conn, err := dialer.DialContext(context.Background(), "tcp", "internal.example:80")
require.NoError(t, err)
require.NotNil(t, conn)
require.Equal(t, []string{"10.1.2.3:80"}, dialed)
}
func TestProtectedFetchDialerSkipsResolvedIPCheckWhenDisabled(t *testing.T) {
var dialed []string
dialer := &protectedFetchDialer{
resolver: staticSSRFResolver{},
dialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
dialed = append(dialed, address)
return testConn(t), nil
},
getProtection: staticProtection(&common.SSRFProtection{
AllowPrivateIp: false,
DomainFilterMode: false,
IpFilterMode: false,
ApplyIPFilterForDomain: false,
}),
}
conn, err := dialer.DialContext(context.Background(), "tcp", "safe.example:80")
require.NoError(t, err)
require.NotNil(t, conn)
require.Equal(t, []string{"safe.example:80"}, dialed)
}
func TestGetSSRFProtectedHTTPClientFallsBackToDefaultClientWhenProtectionDisabled(t *testing.T) {
fetchSetting := system_setting.GetFetchSetting()
originalFetchSetting := *fetchSetting
originalHTTPClient := httpClient
originalProtectedClient := ssrfProtectedHTTPClient
t.Cleanup(func() {
*fetchSetting = originalFetchSetting
httpClient = originalHTTPClient
ssrfProtectedHTTPClient = originalProtectedClient
})
fetchSetting.EnableSSRFProtection = false
expected := &http.Client{}
httpClient = expected
ssrfProtectedHTTPClient = &http.Client{}
require.Same(t, expected, GetSSRFProtectedHTTPClient())
}
func TestProtectedFetchRoundTripperUsesConfiguredProxy(t *testing.T) {
configureSSRFTestFetchSetting(t)
proxyURL := mustParseURL(t, "http://127.0.0.1:3128")
var dialed []string
client := newProtectedFetchHTTPClientWithProxy(
staticSSRFResolver{},
func(ctx context.Context, network, address string) (net.Conn, error) {
dialed = append(dialed, address)
return nil, errors.New("stop after proxy dial")
},
staticProtection(&common.SSRFProtection{
AllowPrivateIp: false,
DomainFilterMode: false,
IpFilterMode: false,
ApplyIPFilterForDomain: true,
}),
func(req *http.Request) (*url.URL, error) {
return proxyURL, nil
},
)
req, err := http.NewRequest(http.MethodGet, "http://93.184.216.34/resource", nil)
require.NoError(t, err)
resp, err := client.Do(req)
require.Error(t, err)
require.Nil(t, resp)
require.Equal(t, []string{"127.0.0.1:3128"}, dialed)
}
func TestProtectedFetchRoundTripperRejectsPrivateTargetBeforeProxy(t *testing.T) {
configureSSRFTestFetchSetting(t)
proxyURL := mustParseURL(t, "http://127.0.0.1:3128")
var dialed []string
client := newProtectedFetchHTTPClientWithProxy(
staticSSRFResolver{},
func(ctx context.Context, network, address string) (net.Conn, error) {
dialed = append(dialed, address)
return nil, errors.New("proxy should not be dialed")
},
staticProtection(&common.SSRFProtection{
AllowPrivateIp: false,
DomainFilterMode: false,
IpFilterMode: false,
ApplyIPFilterForDomain: true,
}),
func(req *http.Request) (*url.URL, error) {
return proxyURL, nil
},
)
req, err := http.NewRequest(http.MethodGet, "http://localhost/resource", nil)
require.NoError(t, err)
resp, err := client.Do(req)
require.Error(t, err)
require.Nil(t, resp)
require.Contains(t, err.Error(), "private IP address not allowed")
require.Empty(t, dialed)
}
func TestProtectedFetchRoundTripperNoProxyUsesProtectedDialer(t *testing.T) {
configureSSRFTestFetchSetting(t)
var dialed []string
client := newProtectedFetchHTTPClientWithProxy(
staticSSRFResolver{},
func(ctx context.Context, network, address string) (net.Conn, error) {
dialed = append(dialed, address)
return nil, errors.New("unexpected direct dial")
},
staticProtection(&common.SSRFProtection{
AllowPrivateIp: false,
DomainFilterMode: false,
IpFilterMode: false,
ApplyIPFilterForDomain: true,
}),
func(req *http.Request) (*url.URL, error) {
return nil, nil
},
)
req, err := http.NewRequest(http.MethodGet, "http://127.0.0.1/resource", nil)
require.NoError(t, err)
resp, err := client.Do(req)
require.Error(t, err)
require.Nil(t, resp)
require.Contains(t, err.Error(), "private IP address not allowed")
require.Empty(t, dialed)
}
func TestProtectedFetchRoundTripperReusesTransportPerProxy(t *testing.T) {
client := newProtectedFetchHTTPClientWithDialer(nil, nil, nil)
roundTripper, ok := client.Transport.(*ssrfProtectedRoundTripper)
require.True(t, ok)
direct := roundTripper.transportFor(nil)
directAgain := roundTripper.transportFor(nil)
proxied := roundTripper.transportFor(mustParseURL(t, "http://127.0.0.1:3128"))
require.Same(t, direct, directAgain)
require.NotSame(t, direct, proxied)
require.True(t, direct.ForceAttemptHTTP2)
require.False(t, direct.DisableKeepAlives)
}
+566
View File
@@ -0,0 +1,566 @@
package service
import (
"errors"
"fmt"
"strings"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/pkg/billingexpr"
perfmetrics "github.com/QuantumNous/new-api/pkg/perf_metrics"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/setting/ratio_setting"
"github.com/QuantumNous/new-api/types"
"github.com/bytedance/gopkg/util/gopool"
"github.com/gin-gonic/gin"
"github.com/shopspring/decimal"
)
type TokenDetails struct {
TextTokens int
AudioTokens int
}
type QuotaInfo struct {
InputDetails TokenDetails
OutputDetails TokenDetails
ModelName string
UsePrice bool
ModelPrice float64
ModelRatio float64
GroupRatio float64
}
func hasCustomModelRatio(modelName string, currentRatio float64) bool {
defaultRatio, exists := ratio_setting.GetDefaultModelRatioMap()[modelName]
if !exists {
return true
}
return currentRatio != defaultRatio
}
func calculateAudioQuota(info QuotaInfo) (int, *common.QuotaClamp) {
if info.UsePrice {
modelPrice := decimal.NewFromFloat(info.ModelPrice)
quotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
groupRatio := decimal.NewFromFloat(info.GroupRatio)
quota := modelPrice.Mul(quotaPerUnit).Mul(groupRatio)
return common.QuotaFromDecimalChecked(quota)
}
completionRatio := decimal.NewFromFloat(ratio_setting.GetCompletionRatio(info.ModelName))
audioRatio := decimal.NewFromFloat(ratio_setting.GetAudioRatio(info.ModelName))
audioCompletionRatio := decimal.NewFromFloat(ratio_setting.GetAudioCompletionRatio(info.ModelName))
groupRatio := decimal.NewFromFloat(info.GroupRatio)
modelRatio := decimal.NewFromFloat(info.ModelRatio)
ratio := groupRatio.Mul(modelRatio)
inputTextTokens := decimal.NewFromInt(int64(info.InputDetails.TextTokens))
outputTextTokens := decimal.NewFromInt(int64(info.OutputDetails.TextTokens))
inputAudioTokens := decimal.NewFromInt(int64(info.InputDetails.AudioTokens))
outputAudioTokens := decimal.NewFromInt(int64(info.OutputDetails.AudioTokens))
quota := decimal.Zero
quota = quota.Add(inputTextTokens)
quota = quota.Add(outputTextTokens.Mul(completionRatio))
quota = quota.Add(inputAudioTokens.Mul(audioRatio))
quota = quota.Add(outputAudioTokens.Mul(audioRatio).Mul(audioCompletionRatio))
quota = quota.Mul(ratio)
// If ratio is not zero and quota is less than or equal to zero, set quota to 1
if !ratio.IsZero() && quota.LessThanOrEqual(decimal.Zero) {
quota = decimal.NewFromInt(1)
}
return common.QuotaFromDecimalChecked(quota)
}
func PreWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.RealtimeUsage) error {
if relayInfo.UsePrice {
return nil
}
userQuota, err := model.GetUserQuota(relayInfo.UserId, false)
if err != nil {
return err
}
token, err := model.GetTokenByKey(strings.TrimPrefix(relayInfo.TokenKey, "sk-"), false)
if err != nil {
return err
}
modelName := relayInfo.OriginModelName
textInputTokens := usage.InputTokenDetails.TextTokens
textOutTokens := usage.OutputTokenDetails.TextTokens
audioInputTokens := usage.InputTokenDetails.AudioTokens
audioOutTokens := usage.OutputTokenDetails.AudioTokens
groupRatio := ratio_setting.GetGroupRatio(relayInfo.UsingGroup)
modelRatio, _, _ := ratio_setting.GetModelRatio(modelName)
autoGroup, exists := common.GetContextKey(ctx, constant.ContextKeyAutoGroup)
if exists {
groupRatio = ratio_setting.GetGroupRatio(autoGroup.(string))
logger.LogDebug(ctx, "final group ratio: %f", groupRatio)
relayInfo.UsingGroup = autoGroup.(string)
}
actualGroupRatio := groupRatio
userGroupRatio, ok := ratio_setting.GetGroupGroupRatio(relayInfo.UserGroup, relayInfo.UsingGroup)
if ok {
actualGroupRatio = userGroupRatio
}
quotaInfo := QuotaInfo{
InputDetails: TokenDetails{
TextTokens: textInputTokens,
AudioTokens: audioInputTokens,
},
OutputDetails: TokenDetails{
TextTokens: textOutTokens,
AudioTokens: audioOutTokens,
},
ModelName: modelName,
UsePrice: relayInfo.UsePrice,
ModelRatio: modelRatio,
GroupRatio: actualGroupRatio,
}
quota, clamp := calculateAudioQuota(quotaInfo)
noteQuotaClamp(relayInfo, clamp)
if userQuota < quota {
return fmt.Errorf("user quota is not enough, user quota: %s, need quota: %s", logger.FormatQuota(userQuota), logger.FormatQuota(quota))
}
if !token.UnlimitedQuota && token.RemainQuota < quota {
return fmt.Errorf("token quota is not enough, token remain quota: %s, need quota: %s", logger.FormatQuota(token.RemainQuota), logger.FormatQuota(quota))
}
err = PostConsumeQuota(relayInfo, quota, 0, false)
if err != nil {
return err
}
logger.LogInfo(ctx, "realtime streaming consume quota success, quota: "+fmt.Sprintf("%d", quota))
return nil
}
func PostWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, modelName string,
usage *dto.RealtimeUsage, extraContent string) {
var tieredResult *billingexpr.TieredResult
tieredOk, tieredQuota, tieredRes := TryTieredSettle(relayInfo, billingexpr.TokenParams{
P: float64(usage.InputTokens),
C: float64(usage.OutputTokens),
Len: float64(usage.InputTokens),
})
if tieredOk {
tieredResult = tieredRes
}
useTimeSeconds := time.Now().Unix() - relayInfo.StartTime.Unix()
textInputTokens := usage.InputTokenDetails.TextTokens
textOutTokens := usage.OutputTokenDetails.TextTokens
audioInputTokens := usage.InputTokenDetails.AudioTokens
audioOutTokens := usage.OutputTokenDetails.AudioTokens
tokenName := ctx.GetString("token_name")
completionRatio := decimal.NewFromFloat(ratio_setting.GetCompletionRatio(modelName))
audioRatio := decimal.NewFromFloat(ratio_setting.GetAudioRatio(relayInfo.OriginModelName))
audioCompletionRatio := decimal.NewFromFloat(ratio_setting.GetAudioCompletionRatio(modelName))
modelRatio := relayInfo.PriceData.ModelRatio
groupRatio := relayInfo.PriceData.GroupRatioInfo.GroupRatio
modelPrice := relayInfo.PriceData.ModelPrice
usePrice := relayInfo.PriceData.UsePrice
quotaInfo := QuotaInfo{
InputDetails: TokenDetails{
TextTokens: textInputTokens,
AudioTokens: audioInputTokens,
},
OutputDetails: TokenDetails{
TextTokens: textOutTokens,
AudioTokens: audioOutTokens,
},
ModelName: modelName,
UsePrice: usePrice,
ModelRatio: modelRatio,
GroupRatio: groupRatio,
}
quota, clamp := calculateAudioQuota(quotaInfo)
noteQuotaClamp(relayInfo, clamp)
if tieredOk {
quota = tieredQuota
}
totalTokens := usage.TotalTokens
var logContent string
if !usePrice {
logContent = fmt.Sprintf("模型倍率 %.2f,补全倍率 %.2f,音频倍率 %.2f,音频补全倍率 %.2f,分组倍率 %.2f",
modelRatio, completionRatio.InexactFloat64(), audioRatio.InexactFloat64(), audioCompletionRatio.InexactFloat64(), groupRatio)
} else {
logContent = fmt.Sprintf("模型价格 %.2f,分组倍率 %.2f", modelPrice, groupRatio)
}
// record all the consume log even if quota is 0
if totalTokens == 0 {
// in this case, must be some error happened
// we cannot just return, because we may have to return the pre-consumed quota
quota = 0
logContent += "(可能是上游超时)"
logger.LogError(ctx, fmt.Sprintf("total tokens is 0, cannot consume quota, userId %d, channelId %d, "+
"tokenId %d, model %s, pre-consumed quota %d", relayInfo.UserId, relayInfo.ChannelId, relayInfo.TokenId, modelName, relayInfo.FinalPreConsumedQuota))
} else {
model.UpdateUserUsedQuotaAndRequestCount(relayInfo.UserId, quota)
model.UpdateChannelUsedQuota(relayInfo.ChannelId, quota)
}
if err := SettleBilling(ctx, relayInfo, quota); err != nil {
logger.LogError(ctx, "error settling billing: "+err.Error())
}
logModel := modelName
if extraContent != "" {
logContent += ", " + extraContent
}
other := GenerateWssOtherInfo(ctx, relayInfo, usage, modelRatio, groupRatio,
completionRatio.InexactFloat64(), audioRatio.InexactFloat64(), audioCompletionRatio.InexactFloat64(), modelPrice, relayInfo.PriceData.GroupRatioInfo.GroupSpecialRatio)
if tieredResult != nil {
InjectTieredBillingInfo(other, relayInfo, tieredResult)
}
attachQuotaSaturation(ctx, relayInfo, other)
model.RecordConsumeLog(ctx, relayInfo.UserId, model.RecordConsumeLogParams{
ChannelId: relayInfo.ChannelId,
PromptTokens: usage.InputTokens,
CompletionTokens: usage.OutputTokens,
ModelName: logModel,
TokenName: tokenName,
Quota: quota,
Content: logContent,
TokenId: relayInfo.TokenId,
UseTimeSeconds: int(useTimeSeconds),
IsStream: relayInfo.IsStream,
Group: relayInfo.UsingGroup,
Other: other,
})
}
func CalcOpenRouterCacheCreateTokens(usage dto.Usage, priceData types.PriceData) int {
if priceData.CacheCreationRatio == 1 {
return 0
}
quotaPrice := priceData.ModelRatio / common.QuotaPerUnit
promptCacheCreatePrice := quotaPrice * priceData.CacheCreationRatio
promptCacheReadPrice := quotaPrice * priceData.CacheRatio
completionPrice := quotaPrice * priceData.CompletionRatio
cost, _ := usage.Cost.(float64)
totalPromptTokens := float64(usage.PromptTokens)
completionTokens := float64(usage.CompletionTokens)
promptCacheReadTokens := float64(usage.PromptTokensDetails.CachedTokens)
value := (cost -
totalPromptTokens*quotaPrice +
promptCacheReadTokens*(quotaPrice-promptCacheReadPrice) -
completionTokens*completionPrice) /
(promptCacheCreatePrice - quotaPrice)
quota, clamp := common.QuotaRoundChecked(value)
if clamp != nil {
return -1
}
return quota
}
func PostAudioConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.Usage, extraContent string) {
var tieredUsedVars map[string]bool
if snap := relayInfo.TieredBillingSnapshot; snap != nil {
tieredUsedVars = billingexpr.UsedVars(snap.ExprString)
}
var tieredResult *billingexpr.TieredResult
tieredOk, tieredQuota, tieredRes := TryTieredSettle(relayInfo, BuildTieredTokenParams(usage, false, tieredUsedVars))
if tieredOk {
tieredResult = tieredRes
}
useTimeSeconds := time.Now().Unix() - relayInfo.StartTime.Unix()
textInputTokens := usage.PromptTokensDetails.TextTokens
textOutTokens := usage.CompletionTokenDetails.TextTokens
audioInputTokens := usage.PromptTokensDetails.AudioTokens
audioOutTokens := usage.CompletionTokenDetails.AudioTokens
tokenName := ctx.GetString("token_name")
billingModelName := relayInfo.GetBillingModelName()
completionRatio := decimal.NewFromFloat(ratio_setting.GetCompletionRatio(billingModelName))
audioRatio := decimal.NewFromFloat(ratio_setting.GetAudioRatio(billingModelName))
audioCompletionRatio := decimal.NewFromFloat(ratio_setting.GetAudioCompletionRatio(billingModelName))
modelRatio := relayInfo.PriceData.ModelRatio
groupRatio := relayInfo.PriceData.GroupRatioInfo.GroupRatio
modelPrice := relayInfo.PriceData.ModelPrice
usePrice := relayInfo.PriceData.UsePrice
quotaInfo := QuotaInfo{
InputDetails: TokenDetails{
TextTokens: textInputTokens,
AudioTokens: audioInputTokens,
},
OutputDetails: TokenDetails{
TextTokens: textOutTokens,
AudioTokens: audioOutTokens,
},
ModelName: billingModelName,
UsePrice: usePrice,
ModelRatio: modelRatio,
GroupRatio: groupRatio,
}
quota, clamp := calculateAudioQuota(quotaInfo)
noteQuotaClamp(relayInfo, clamp)
if tieredOk {
quota = tieredQuota
}
totalTokens := usage.TotalTokens
var logContent string
if !usePrice {
logContent = fmt.Sprintf("模型倍率 %.2f,补全倍率 %.2f,音频倍率 %.2f,音频补全倍率 %.2f,分组倍率 %.2f",
modelRatio, completionRatio.InexactFloat64(), audioRatio.InexactFloat64(), audioCompletionRatio.InexactFloat64(), groupRatio)
} else {
logContent = fmt.Sprintf("模型价格 %.2f,分组倍率 %.2f", modelPrice, groupRatio)
}
// record all the consume log even if quota is 0
if totalTokens == 0 {
// in this case, must be some error happened
// we cannot just return, because we may have to return the pre-consumed quota
quota = 0
logContent += "(可能是上游超时)"
logger.LogError(ctx, fmt.Sprintf("total tokens is 0, cannot consume quota, userId %d, channelId %d, "+
"tokenId %d, model %s, pre-consumed quota %d", relayInfo.UserId, relayInfo.ChannelId, relayInfo.TokenId, billingModelName, relayInfo.FinalPreConsumedQuota))
} else {
model.UpdateUserUsedQuotaAndRequestCount(relayInfo.UserId, quota)
model.UpdateChannelUsedQuota(relayInfo.ChannelId, quota)
}
if err := SettleBilling(ctx, relayInfo, quota); err != nil {
logger.LogError(ctx, "error settling billing: "+err.Error())
}
logModel := billingModelName
if extraContent != "" {
logContent += ", " + extraContent
}
other := GenerateAudioOtherInfo(ctx, relayInfo, usage, modelRatio, groupRatio,
completionRatio.InexactFloat64(), audioRatio.InexactFloat64(), audioCompletionRatio.InexactFloat64(), modelPrice, relayInfo.PriceData.GroupRatioInfo.GroupSpecialRatio)
if tieredResult != nil {
InjectTieredBillingInfo(other, relayInfo, tieredResult)
}
attachQuotaSaturation(ctx, relayInfo, other)
model.RecordConsumeLog(ctx, relayInfo.UserId, model.RecordConsumeLogParams{
ChannelId: relayInfo.ChannelId,
PromptTokens: usage.PromptTokens,
CompletionTokens: usage.CompletionTokens,
ModelName: logModel,
TokenName: tokenName,
Quota: quota,
Content: logContent,
TokenId: relayInfo.TokenId,
UseTimeSeconds: int(useTimeSeconds),
IsStream: relayInfo.IsStream,
Group: relayInfo.UsingGroup,
Other: other,
})
gopool.Go(func() {
perfmetrics.RecordRelaySample(relayInfo, true, int64(usage.CompletionTokens))
})
}
func PreConsumeTokenQuota(relayInfo *relaycommon.RelayInfo, quota int) error {
if quota < 0 {
return errors.New("quota 不能为负数!")
}
if relayInfo.IsPlayground {
return nil
}
// 原子预扣:检查与扣减在同一操作中完成,并发请求不可能同时通过检查后超扣。
reserved, err := model.TryReserveTokenQuota(relayInfo.TokenId, relayInfo.TokenKey, quota, relayInfo.TokenUnlimited)
if err != nil {
return err
}
if !reserved {
remainQuota := 0
if token, tokenErr := model.GetTokenByKey(relayInfo.TokenKey, false); tokenErr == nil && token != nil {
remainQuota = token.RemainQuota
}
return fmt.Errorf("token quota is not enough, token remain quota: %s, need quota: %s", logger.FormatQuota(remainQuota), logger.FormatQuota(quota))
}
return nil
}
type postConsumeQuotaResult struct {
FundingApplied bool
TokenApplied bool
}
func PostConsumeQuota(relayInfo *relaycommon.RelayInfo, quota int, preConsumedQuota int, sendEmail bool) error {
_, err := postConsumeQuotaWithResult(relayInfo, quota, preConsumedQuota, sendEmail)
return err
}
func postConsumeQuotaWithResult(relayInfo *relaycommon.RelayInfo, quota int, preConsumedQuota int, sendEmail bool) (result postConsumeQuotaResult, err error) {
// 1) Consume from wallet quota OR subscription item
if relayInfo != nil && relayInfo.BillingSource == BillingSourceSubscription {
if relayInfo.SubscriptionId == 0 {
return result, errors.New("subscription id is missing")
}
delta := int64(quota)
if delta != 0 {
if err := model.PostConsumeUserSubscriptionDelta(relayInfo.SubscriptionId, delta); err != nil {
return result, err
}
relayInfo.SubscriptionPostDelta += delta
}
} else {
// Wallet
if quota > 0 {
err = model.DecreaseUserQuota(relayInfo.UserId, quota, false)
} else {
err = model.IncreaseUserQuota(relayInfo.UserId, -quota, false)
}
if err != nil {
return result, err
}
}
result.FundingApplied = true
if !relayInfo.IsPlayground {
if quota > 0 {
err = model.DecreaseTokenQuota(relayInfo.TokenId, relayInfo.TokenKey, quota)
} else {
err = model.IncreaseTokenQuota(relayInfo.TokenId, relayInfo.TokenKey, -quota)
}
if err != nil {
return result, err
}
result.TokenApplied = true
}
if sendEmail {
if (quota + preConsumedQuota) != 0 {
checkAndSendQuotaNotify(relayInfo, quota, preConsumedQuota)
}
}
return result, nil
}
func checkAndSendQuotaNotify(relayInfo *relaycommon.RelayInfo, quota int, preConsumedQuota int) {
gopool.Go(func() {
userSetting := relayInfo.UserSetting
threshold := common.QuotaRemindThreshold
if userSetting.QuotaWarningThreshold != 0 {
threshold = int(userSetting.QuotaWarningThreshold)
}
//noMoreQuota := userCache.Quota-(quota+preConsumedQuota) <= 0
quotaTooLow := false
consumeQuota := quota + preConsumedQuota
if relayInfo.UserQuota-consumeQuota < threshold {
quotaTooLow = true
}
if quotaTooLow {
prompt := "您的额度即将用尽"
topUpLink := PaymentReturnURL("/wallet")
// 根据通知方式生成不同的内容格式
var content string
var values []any
notifyType := userSetting.NotifyType
if notifyType == "" {
notifyType = dto.NotifyTypeEmail
}
if notifyType == dto.NotifyTypeBark {
// Bark推送使用简短文本,不支持HTML
content = "{{value}},剩余额度:{{value}},请及时充值"
values = []any{prompt, logger.FormatQuota(relayInfo.UserQuota)}
} else if notifyType == dto.NotifyTypeGotify {
content = "{{value}},当前剩余额度为 {{value}},请及时充值。"
values = []any{prompt, logger.FormatQuota(relayInfo.UserQuota)}
} else {
// 默认内容格式,适用于Email和Webhook(支持HTML)
content = "{{value}},当前剩余额度为 {{value}},为了不影响您的使用,请及时充值。<br/>充值链接:<a href='{{value}}'>{{value}}</a>"
values = []any{prompt, logger.FormatQuota(relayInfo.UserQuota), topUpLink, topUpLink}
}
err := NotifyUser(relayInfo.UserId, relayInfo.UserEmail, relayInfo.UserSetting, dto.NewNotify(dto.NotifyTypeQuotaExceed, prompt, content, values))
if err != nil {
common.SysError(fmt.Sprintf("failed to send quota notify to user %d: %s", relayInfo.UserId, err.Error()))
}
}
})
}
func checkAndSendSubscriptionQuotaNotify(relayInfo *relaycommon.RelayInfo) {
gopool.Go(func() {
if relayInfo == nil {
return
}
if relayInfo.SubscriptionId == 0 || relayInfo.SubscriptionAmountTotal <= 0 {
return
}
userSetting := relayInfo.UserSetting
threshold := common.QuotaRemindThreshold
if userSetting.QuotaWarningThreshold != 0 {
threshold = int(userSetting.QuotaWarningThreshold)
}
usedAfter := relayInfo.SubscriptionAmountUsedAfterPreConsume + relayInfo.SubscriptionPostDelta
remaining := relayInfo.SubscriptionAmountTotal - usedAfter
if remaining >= int64(threshold) {
return
}
prompt := "您的订阅额度即将用尽"
topUpLink := PaymentReturnURL("/wallet")
var content string
var values []any
notifyType := userSetting.NotifyType
if notifyType == "" {
notifyType = dto.NotifyTypeEmail
}
if notifyType == dto.NotifyTypeBark {
content = "{{value}},剩余额度:{{value}},请及时充值"
values = []any{prompt, logger.FormatQuota(int(remaining))}
} else if notifyType == dto.NotifyTypeGotify {
content = "{{value}},当前剩余额度为 {{value}},请及时充值。"
values = []any{prompt, logger.FormatQuota(int(remaining))}
} else {
content = "{{value}},当前剩余额度为 {{value}},为了不影响您的使用,请及时充值。<br/>充值链接:<a href='{{value}}'>{{value}}</a>"
values = []any{prompt, logger.FormatQuota(int(remaining)), topUpLink, topUpLink}
}
if err := NotifyUser(relayInfo.UserId, relayInfo.UserEmail, relayInfo.UserSetting, dto.NewNotify(dto.NotifyTypeQuotaExceed, prompt, content, values)); err != nil {
common.SysError(fmt.Sprintf("failed to send subscription quota notify to user %d: %s", relayInfo.UserId, err.Error()))
}
})
}
+140
View File
@@ -0,0 +1,140 @@
package service
import (
"math"
"net/http"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/types"
hosttypes "github.com/QuantumNous/new-api/types"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
// TestAttachQuotaSaturationNestsUnderAdminInfo verifies the saturation marker
// is nested under other.admin_info.quota_saturation so it is admin-only (the
// log formatter strips admin_info for non-admin viewers).
func TestAttachQuotaSaturationNestsUnderAdminInfo(t *testing.T) {
gin.SetMode(gin.TestMode)
ctx, _ := gin.CreateTestContext(nil)
relayInfo := &relaycommon.RelayInfo{
UserId: 7,
OriginModelName: "gpt-image-1",
QuotaClamp: &common.QuotaClamp{
Op: "QuotaFromDecimal",
Kind: common.QuotaClampOverflow,
Original: 1.8e19,
Clamped: common.MaxQuota,
},
}
other := model.NewLogOther()
other.SetPublic("model_price", 0.004)
attachQuotaSaturation(ctx, relayInfo, other)
adminInfo, ok := other.Snapshot()["admin_info"].(map[string]any)
require.True(t, ok, "admin_info should be created")
sat, ok := adminInfo["quota_saturation"].(map[string]any)
require.True(t, ok, "quota_saturation should be nested under admin_info")
require.Equal(t, "QuotaFromDecimal", sat["op"])
require.Equal(t, common.QuotaClampOverflow, sat["kind"])
require.Equal(t, common.MaxQuota, sat["clamped"])
}
func TestCalcViolationFeeQuotaSaturates(t *testing.T) {
oldQuotaPerUnit := common.QuotaPerUnit
common.QuotaPerUnit = 500_000
t.Cleanup(func() { common.QuotaPerUnit = oldQuotaPerUnit })
require.Equal(t, common.MaxQuota, calcViolationFeeQuota(1e20, 1))
}
func TestCalcOpenRouterCacheCreateTokensDoesNotWrap(t *testing.T) {
oldQuotaPerUnit := common.QuotaPerUnit
common.QuotaPerUnit = 500_000
t.Cleanup(func() { common.QuotaPerUnit = oldQuotaPerUnit })
got := CalcOpenRouterCacheCreateTokens(dto.Usage{Cost: math.Inf(1)}, hosttypes.PriceData{
ModelRatio: 1,
CacheCreationRatio: 2,
CacheRatio: 1,
CompletionRatio: 1,
})
require.Equal(t, -1, got)
}
// TestAttachQuotaSaturationPreservesExistingAdminInfo verifies the marker is
// merged into a pre-existing admin_info map without clobbering it.
func TestAttachQuotaSaturationPreservesExistingAdminInfo(t *testing.T) {
gin.SetMode(gin.TestMode)
ctx, _ := gin.CreateTestContext(nil)
relayInfo := &relaycommon.RelayInfo{
QuotaClamp: &common.QuotaClamp{Op: "QuotaFromFloat", Kind: common.QuotaClampUnderflow, Clamped: common.MinQuota},
}
other := model.NewLogOther()
other.SetAdmin("admin_username", "root")
attachQuotaSaturation(ctx, relayInfo, other)
adminInfo := other.Snapshot()["admin_info"].(map[string]any)
require.Equal(t, "root", adminInfo["admin_username"], "existing admin_info fields preserved")
require.NotNil(t, adminInfo["quota_saturation"])
}
// TestAttachQuotaSaturationNoClampNoMarker verifies the common case (no
// saturation) leaves the log untouched.
func TestAttachQuotaSaturationNoClampNoMarker(t *testing.T) {
gin.SetMode(gin.TestMode)
ctx, _ := gin.CreateTestContext(nil)
relayInfo := &relaycommon.RelayInfo{QuotaClamp: nil}
other := model.NewLogOther()
other.SetPublic("model_price", 0.004)
attachQuotaSaturation(ctx, relayInfo, other)
_, hasAdmin := other.Snapshot()["admin_info"]
require.False(t, hasAdmin, "no admin_info should be added when there is no clamp")
}
func TestPreConsumeBillingRejectsSaturatedQuotaBeforeDeduction(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(nil)
info := &relaycommon.RelayInfo{
QuotaClamp: &common.QuotaClamp{
Op: "QuotaFromFloat",
Kind: common.QuotaClampOverflow,
Original: 1e30,
Clamped: common.MaxQuota,
},
}
apiErr := PreConsumeBilling(c, common.MaxQuota, info)
require.NotNil(t, apiErr)
require.Equal(t, types.ErrorCodeModelPriceError, apiErr.GetErrorCode())
require.Equal(t, http.StatusBadRequest, apiErr.StatusCode)
require.Same(t, info.QuotaClamp, apiErr.Err)
var clamp *common.QuotaClamp
require.ErrorAs(t, apiErr, &clamp)
require.Same(t, info.QuotaClamp, clamp)
require.Nil(t, info.Billing)
}
func TestPreConsumeBillingRejectsNegativeQuotaBeforeDeduction(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(nil)
info := &relaycommon.RelayInfo{}
apiErr := PreConsumeBilling(c, -1, info)
require.NotNil(t, apiErr)
require.Equal(t, types.ErrorCodeModelPriceError, apiErr.GetErrorCode())
require.Equal(t, http.StatusBadRequest, apiErr.StatusCode)
require.Nil(t, info.Billing)
}
+596
View File
@@ -0,0 +1,596 @@
package service
import (
"fmt"
"math"
"slices"
"sort"
"sync"
"time"
"github.com/QuantumNous/new-api/model"
)
const (
rankingCacheTTL = 5 * time.Minute
rankingLeaderboardLimit = 20
rankingHistoryLimit = 10
rankingVendorLimit = 5
rankingMoverLimit = 6
rankingOthersLabel = "Others"
rankingUnknownVendor = "Unknown"
)
type RankingsResponse struct {
Models []RankedModel `json:"models"`
Vendors []RankedVendor `json:"vendors"`
TopMovers []RankingMover `json:"top_movers"`
TopDroppers []RankingMover `json:"top_droppers"`
ModelsHistory ModelHistorySeries `json:"models_history"`
VendorShareHistory VendorShareSeries `json:"vendor_share_history"`
}
type RankedModel struct {
Rank int `json:"rank"`
PreviousRank *int `json:"previous_rank,omitempty"`
ModelName string `json:"model_name"`
Vendor string `json:"vendor"`
VendorIcon string `json:"vendor_icon,omitempty"`
Category string `json:"category"`
TotalTokens int64 `json:"total_tokens"`
Share float64 `json:"share"`
GrowthPct float64 `json:"growth_pct"`
}
type RankedVendor struct {
Rank int `json:"rank"`
Vendor string `json:"vendor"`
VendorIcon string `json:"vendor_icon,omitempty"`
TotalTokens int64 `json:"total_tokens"`
Share float64 `json:"share"`
GrowthPct float64 `json:"growth_pct"`
ModelsCount int `json:"models_count"`
TopModel string `json:"top_model"`
}
type RankingMover struct {
ModelName string `json:"model_name"`
Vendor string `json:"vendor"`
VendorIcon string `json:"vendor_icon,omitempty"`
RankDelta int `json:"rank_delta"`
CurrentRank int `json:"current_rank"`
GrowthPct float64 `json:"growth_pct"`
}
type ModelHistoryPoint struct {
Ts string `json:"ts"`
Label string `json:"label"`
Model string `json:"model"`
Vendor string `json:"vendor"`
Tokens int64 `json:"tokens"`
}
type ModelHistoryModel struct {
Name string `json:"name"`
Vendor string `json:"vendor"`
Total int64 `json:"total"`
}
type ModelHistorySeries struct {
Points []ModelHistoryPoint `json:"points"`
Models []ModelHistoryModel `json:"models"`
Buckets int `json:"buckets"`
}
type VendorSharePoint struct {
Ts string `json:"ts"`
Label string `json:"label"`
Vendor string `json:"vendor"`
Share float64 `json:"share"`
Tokens int64 `json:"tokens"`
}
type VendorShareVendor struct {
Name string `json:"name"`
Total int64 `json:"total"`
Share float64 `json:"share"`
}
type VendorShareSeries struct {
Points []VendorSharePoint `json:"points"`
Vendors []VendorShareVendor `json:"vendors"`
Buckets int `json:"buckets"`
}
type rankingPeriodConfig struct {
id string
duration time.Duration
bucketSize int64
labelLayout string
hasPrevious bool
}
type rankingCacheItem struct {
expiresAt time.Time
data *RankingsResponse
}
type rankingModelMeta struct {
vendor string
vendorIcon string
}
type vendorAggregate struct {
name string
icon string
totalTokens int64
previousTokens int64
models map[string]struct{}
topModel string
topModelTokens int64
}
var (
rankingCacheMu sync.Mutex
rankingCache = map[string]rankingCacheItem{}
)
func GetRankingsSnapshot(period string) (*RankingsResponse, error) {
config, err := rankingConfig(period)
if err != nil {
return nil, err
}
now := time.Now()
rankingCacheMu.Lock()
if item, ok := rankingCache[config.id]; ok && now.Before(item.expiresAt) {
rankingCacheMu.Unlock()
return item.data, nil
}
rankingCacheMu.Unlock()
data, err := buildRankingsSnapshot(config, now)
if err != nil {
return nil, err
}
rankingCacheMu.Lock()
rankingCache[config.id] = rankingCacheItem{
expiresAt: now.Add(rankingCacheTTL),
data: data,
}
rankingCacheMu.Unlock()
return data, nil
}
func rankingConfig(period string) (rankingPeriodConfig, error) {
switch period {
case "", "week":
return rankingPeriodConfig{id: "week", duration: 7 * 24 * time.Hour, bucketSize: 24 * 3600, labelLayout: "Jan 2", hasPrevious: true}, nil
case "today":
return rankingPeriodConfig{id: "today", duration: 24 * time.Hour, bucketSize: 3600, labelLayout: "15:04", hasPrevious: true}, nil
case "month":
return rankingPeriodConfig{id: "month", duration: 30 * 24 * time.Hour, bucketSize: 24 * 3600, labelLayout: "Jan 2", hasPrevious: true}, nil
case "year":
return rankingPeriodConfig{id: "year", duration: 365 * 24 * time.Hour, bucketSize: 7 * 24 * 3600, labelLayout: "Jan 2", hasPrevious: true}, nil
default:
return rankingPeriodConfig{}, fmt.Errorf("invalid ranking period: %s", period)
}
}
func buildRankingsSnapshot(config rankingPeriodConfig, now time.Time) (*RankingsResponse, error) {
startTime, endTime := rankingTimeRange(config, now)
currentTotals, err := model.GetRankingQuotaTotals(startTime, endTime)
if err != nil {
return nil, err
}
currentBuckets, err := model.GetRankingQuotaBuckets(startTime, endTime, config.bucketSize)
if err != nil {
return nil, err
}
var previousTotals []model.RankingQuotaTotal
if config.hasPrevious {
previousStart, previousEnd := previousRankingTimeRange(config, startTime)
previousTotals, err = model.GetRankingQuotaTotals(previousStart, previousEnd)
if err != nil {
return nil, err
}
}
meta := buildRankingModelMeta()
totalTokens := sumRankingTokens(currentTotals)
previousRankByModel := rankingRankMap(previousTotals)
previousTokensByModel := rankingTokenMap(previousTotals)
rankedModels := buildRankedModels(currentTotals, totalTokens, previousRankByModel, previousTokensByModel, meta, config.hasPrevious)
vendors := buildRankedVendors(currentTotals, previousTotals, totalTokens, meta, config.hasPrevious)
modelHistory := buildModelHistory(currentBuckets, currentTotals, meta, config)
vendorHistory := buildVendorShareHistory(currentBuckets, vendors, totalTokens, meta, config)
movers, droppers := buildRankingMovers(rankedModels)
return &RankingsResponse{
Models: limitRankedModels(rankedModels, rankingLeaderboardLimit),
Vendors: vendors,
TopMovers: movers,
TopDroppers: droppers,
ModelsHistory: modelHistory,
VendorShareHistory: vendorHistory,
}, nil
}
func rankingTimeRange(config rankingPeriodConfig, now time.Time) (int64, int64) {
endTime := now.Unix()
if config.duration <= 0 {
return 0, endTime
}
return now.Add(-config.duration).Unix(), endTime
}
func previousRankingTimeRange(config rankingPeriodConfig, currentStart int64) (int64, int64) {
previousEnd := currentStart - 1
previousStart := time.Unix(currentStart, 0).Add(-config.duration).Unix()
return previousStart, previousEnd
}
func buildRankingModelMeta() map[string]rankingModelMeta {
vendorByID := make(map[int]model.PricingVendor)
for _, vendor := range model.GetVendors() {
vendorByID[vendor.ID] = vendor
}
meta := make(map[string]rankingModelMeta)
for _, pricing := range model.GetPricing() {
item := rankingModelMeta{vendor: rankingUnknownVendor}
if vendor, ok := vendorByID[pricing.VendorID]; ok {
item.vendor = vendor.Name
item.vendorIcon = vendor.Icon
} else if pricing.OwnerBy != "" {
item.vendor = pricing.OwnerBy
}
meta[pricing.ModelName] = item
}
return meta
}
func modelMeta(modelName string, meta map[string]rankingModelMeta) rankingModelMeta {
if item, ok := meta[modelName]; ok && item.vendor != "" {
return item
}
return rankingModelMeta{vendor: rankingUnknownVendor}
}
func buildRankedModels(totals []model.RankingQuotaTotal, totalTokens int64, previousRanks map[string]int, previousTokens map[string]int64, meta map[string]rankingModelMeta, showGrowth bool) []RankedModel {
rows := make([]RankedModel, 0, len(totals))
for idx, item := range totals {
modelMeta := modelMeta(item.ModelName, meta)
var previousRank *int
if rank, ok := previousRanks[item.ModelName]; ok {
rankCopy := rank
previousRank = &rankCopy
}
growth := 0.0
if showGrowth {
growth = rankingGrowthPct(item.TotalTokens, previousTokens[item.ModelName])
}
rows = append(rows, RankedModel{
Rank: idx + 1,
PreviousRank: previousRank,
ModelName: item.ModelName,
Vendor: modelMeta.vendor,
VendorIcon: modelMeta.vendorIcon,
Category: "all",
TotalTokens: item.TotalTokens,
Share: rankingShare(item.TotalTokens, totalTokens),
GrowthPct: growth,
})
}
return rows
}
func buildRankedVendors(currentTotals []model.RankingQuotaTotal, previousTotals []model.RankingQuotaTotal, totalTokens int64, meta map[string]rankingModelMeta, showGrowth bool) []RankedVendor {
aggregates := make(map[string]*vendorAggregate)
for _, item := range currentTotals {
modelMeta := modelMeta(item.ModelName, meta)
agg := ensureVendorAggregate(aggregates, modelMeta)
agg.totalTokens += item.TotalTokens
agg.models[item.ModelName] = struct{}{}
if item.TotalTokens > agg.topModelTokens {
agg.topModel = item.ModelName
agg.topModelTokens = item.TotalTokens
}
}
for _, item := range previousTotals {
modelMeta := modelMeta(item.ModelName, meta)
agg := ensureVendorAggregate(aggregates, modelMeta)
agg.previousTokens += item.TotalTokens
}
rows := make([]RankedVendor, 0, len(aggregates))
for _, agg := range aggregates {
if agg.totalTokens <= 0 {
continue
}
growth := 0.0
if showGrowth {
growth = rankingGrowthPct(agg.totalTokens, agg.previousTokens)
}
rows = append(rows, RankedVendor{
Vendor: agg.name,
VendorIcon: agg.icon,
TotalTokens: agg.totalTokens,
Share: rankingShare(agg.totalTokens, totalTokens),
GrowthPct: growth,
ModelsCount: len(agg.models),
TopModel: agg.topModel,
})
}
sort.Slice(rows, func(i, j int) bool {
if rows[i].TotalTokens == rows[j].TotalTokens {
return rows[i].Vendor < rows[j].Vendor
}
return rows[i].TotalTokens > rows[j].TotalTokens
})
for idx := range rows {
rows[idx].Rank = idx + 1
}
return rows
}
func ensureVendorAggregate(aggregates map[string]*vendorAggregate, meta rankingModelMeta) *vendorAggregate {
name := meta.vendor
if name == "" {
name = rankingUnknownVendor
}
agg, ok := aggregates[name]
if !ok {
agg = &vendorAggregate{
name: name,
icon: meta.vendorIcon,
models: make(map[string]struct{}),
}
aggregates[name] = agg
}
if agg.icon == "" && meta.vendorIcon != "" {
agg.icon = meta.vendorIcon
}
return agg
}
func buildModelHistory(buckets []model.RankingQuotaBucket, totals []model.RankingQuotaTotal, meta map[string]rankingModelMeta, config rankingPeriodConfig) ModelHistorySeries {
topModels := make(map[string]struct{})
models := make([]ModelHistoryModel, 0, minInt(len(totals), rankingHistoryLimit)+1)
otherTotal := int64(0)
for idx, item := range totals {
if idx < rankingHistoryLimit {
topModels[item.ModelName] = struct{}{}
modelMeta := modelMeta(item.ModelName, meta)
models = append(models, ModelHistoryModel{Name: item.ModelName, Vendor: modelMeta.vendor, Total: item.TotalTokens})
continue
}
otherTotal += item.TotalTokens
}
if otherTotal > 0 {
models = append(models, ModelHistoryModel{Name: rankingOthersLabel, Vendor: "Various", Total: otherTotal})
}
bucketSet := make(map[int64]struct{})
tokensByBucketAndModel := make(map[int64]map[string]int64)
for _, item := range buckets {
modelName := item.ModelName
if _, ok := topModels[modelName]; !ok {
modelName = rankingOthersLabel
}
bucketSet[item.Bucket] = struct{}{}
if _, ok := tokensByBucketAndModel[item.Bucket]; !ok {
tokensByBucketAndModel[item.Bucket] = make(map[string]int64)
}
tokensByBucketAndModel[item.Bucket][modelName] += item.Tokens
}
sortedBuckets := sortedRankingBuckets(bucketSet)
points := make([]ModelHistoryPoint, 0, len(sortedBuckets)*len(models))
for _, bucket := range sortedBuckets {
for _, historyModel := range models {
tokens := tokensByBucketAndModel[bucket][historyModel.Name]
if tokens <= 0 {
continue
}
points = append(points, ModelHistoryPoint{
Ts: rankingBucketTs(bucket),
Label: rankingBucketLabel(bucket, config),
Model: historyModel.Name,
Vendor: historyModel.Vendor,
Tokens: tokens,
})
}
}
return ModelHistorySeries{
Points: points,
Models: models,
Buckets: len(sortedBuckets),
}
}
func buildVendorShareHistory(buckets []model.RankingQuotaBucket, vendors []RankedVendor, totalTokens int64, meta map[string]rankingModelMeta, config rankingPeriodConfig) VendorShareSeries {
topVendors := make(map[string]struct{})
vendorRows := make([]VendorShareVendor, 0, minInt(len(vendors), rankingVendorLimit)+1)
otherTotal := int64(0)
for idx, vendor := range vendors {
if idx < rankingVendorLimit {
topVendors[vendor.Vendor] = struct{}{}
vendorRows = append(vendorRows, VendorShareVendor{Name: vendor.Vendor, Total: vendor.TotalTokens, Share: vendor.Share})
continue
}
otherTotal += vendor.TotalTokens
}
if otherTotal > 0 {
vendorRows = append(vendorRows, VendorShareVendor{Name: rankingOthersLabel, Total: otherTotal, Share: rankingShare(otherTotal, totalTokens)})
}
bucketSet := make(map[int64]struct{})
tokensByBucketAndVendor := make(map[int64]map[string]int64)
totalsByBucket := make(map[int64]int64)
for _, item := range buckets {
modelMeta := modelMeta(item.ModelName, meta)
vendorName := modelMeta.vendor
if _, ok := topVendors[vendorName]; !ok {
vendorName = rankingOthersLabel
}
bucketSet[item.Bucket] = struct{}{}
if _, ok := tokensByBucketAndVendor[item.Bucket]; !ok {
tokensByBucketAndVendor[item.Bucket] = make(map[string]int64)
}
tokensByBucketAndVendor[item.Bucket][vendorName] += item.Tokens
totalsByBucket[item.Bucket] += item.Tokens
}
sortedBuckets := sortedRankingBuckets(bucketSet)
points := make([]VendorSharePoint, 0, len(sortedBuckets)*len(vendorRows))
for _, bucket := range sortedBuckets {
for _, vendor := range vendorRows {
tokens := tokensByBucketAndVendor[bucket][vendor.Name]
if tokens <= 0 {
continue
}
points = append(points, VendorSharePoint{
Ts: rankingBucketTs(bucket),
Label: rankingBucketLabel(bucket, config),
Vendor: vendor.Name,
Share: rankingShare(tokens, totalsByBucket[bucket]),
Tokens: tokens,
})
}
}
return VendorShareSeries{
Points: points,
Vendors: vendorRows,
Buckets: len(sortedBuckets),
}
}
func buildRankingMovers(models []RankedModel) ([]RankingMover, []RankingMover) {
movers := make([]RankingMover, 0)
droppers := make([]RankingMover, 0)
for _, item := range models {
if item.PreviousRank == nil {
continue
}
delta := *item.PreviousRank - item.Rank
if delta == 0 {
continue
}
row := RankingMover{
ModelName: item.ModelName,
Vendor: item.Vendor,
VendorIcon: item.VendorIcon,
RankDelta: delta,
CurrentRank: item.Rank,
GrowthPct: item.GrowthPct,
}
if delta > 0 {
movers = append(movers, row)
} else {
droppers = append(droppers, row)
}
}
sort.Slice(movers, func(i, j int) bool {
if movers[i].RankDelta == movers[j].RankDelta {
return movers[i].GrowthPct > movers[j].GrowthPct
}
return movers[i].RankDelta > movers[j].RankDelta
})
sort.Slice(droppers, func(i, j int) bool {
if droppers[i].RankDelta == droppers[j].RankDelta {
return droppers[i].GrowthPct < droppers[j].GrowthPct
}
return droppers[i].RankDelta < droppers[j].RankDelta
})
return limitRankingMovers(movers, rankingMoverLimit), limitRankingMovers(droppers, rankingMoverLimit)
}
func sortedRankingBuckets(bucketSet map[int64]struct{}) []int64 {
buckets := make([]int64, 0, len(bucketSet))
for bucket := range bucketSet {
buckets = append(buckets, bucket)
}
slices.Sort(buckets)
return buckets
}
func rankingBucketTs(bucket int64) string {
return time.Unix(bucket, 0).UTC().Format(time.RFC3339)
}
func rankingBucketLabel(bucket int64, config rankingPeriodConfig) string {
return time.Unix(bucket, 0).Format(config.labelLayout)
}
func rankingRankMap(totals []model.RankingQuotaTotal) map[string]int {
ranks := make(map[string]int, len(totals))
for idx, item := range totals {
ranks[item.ModelName] = idx + 1
}
return ranks
}
func rankingTokenMap(totals []model.RankingQuotaTotal) map[string]int64 {
tokens := make(map[string]int64, len(totals))
for _, item := range totals {
tokens[item.ModelName] = item.TotalTokens
}
return tokens
}
func sumRankingTokens(totals []model.RankingQuotaTotal) int64 {
total := int64(0)
for _, item := range totals {
total += item.TotalTokens
}
return total
}
func rankingShare(value int64, total int64) float64 {
if total <= 0 || value <= 0 {
return 0
}
return roundRankingFloat(float64(value) / float64(total))
}
func rankingGrowthPct(current int64, previous int64) float64 {
if previous <= 0 {
if current > 0 {
return 100
}
return 0
}
return roundRankingFloat((float64(current-previous) / float64(previous)) * 100)
}
func roundRankingFloat(value float64) float64 {
return math.Round(value*10000) / 10000
}
func limitRankedModels(rows []RankedModel, limit int) []RankedModel {
if limit <= 0 || len(rows) <= limit {
return rows
}
return rows[:limit]
}
func limitRankingMovers(rows []RankingMover, limit int) []RankingMover {
if limit <= 0 || len(rows) <= limit {
return rows
}
return rows[:limit]
}
func minInt(a int, b int) int {
if a < b {
return a
}
return b
}
+72
View File
@@ -0,0 +1,72 @@
package service
import (
"context"
"fmt"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/gin-gonic/gin"
)
func init() {
relayconvert.SetMediaResolver(relayconvert.MediaResolver{
// relayconvert is gin-free; recover the gin context when the caller
// passed one so file caching/cleanup keeps working.
GetBase64Data: func(ctx context.Context, source types.FileSource, reason ...string) (string, string, error) {
ginCtx, _ := ctx.(*gin.Context)
return GetBase64Data(ginCtx, source, reason...)
},
DecodeBase64FileData: DecodeBase64FileData,
})
}
func ConvertRequest(c *gin.Context, info *relaycommon.RelayInfo, target types.RelayFormat, request any) (*relayconvert.RequestResult, error) {
result, err := relayconvert.ConvertRequest(c, info, target, request)
if result != nil {
info.RecordConversionDiagnostics(c, result.Diagnostics)
}
return result, err
}
func ConvertRequestByID(c *gin.Context, info *relaycommon.RelayInfo, converter string, request any) (*relayconvert.RequestResult, error) {
result, err := relayconvert.ConvertRequestByID(c, info, converter, request)
if result != nil {
info.RecordConversionDiagnostics(c, result.Diagnostics)
}
return result, err
}
func ConvertRequestVia(c *gin.Context, info *relaycommon.RelayInfo, request any, path ...types.RelayFormat) (*relayconvert.RequestResult, error) {
result, err := relayconvert.ConvertRequestVia(c, info, request, path...)
if result != nil {
info.RecordConversionDiagnostics(c, result.Diagnostics)
}
return result, err
}
func ClaudeToOpenAIRequest(claudeRequest dto.ClaudeRequest, info *relaycommon.RelayInfo) (*dto.GeneralOpenAIRequest, error) {
result, err := ConvertRequest(nil, info, types.RelayFormatOpenAI, &claudeRequest)
if err != nil {
return nil, err
}
openAIRequest, ok := result.Value.(*dto.GeneralOpenAIRequest)
if !ok {
return nil, fmt.Errorf("expected OpenAI chat completions request, got %T", result.Value)
}
return openAIRequest, nil
}
func GeminiToOpenAIRequest(geminiRequest *dto.GeminiChatRequest, info *relaycommon.RelayInfo) (*dto.GeneralOpenAIRequest, error) {
result, err := ConvertRequest(nil, info, types.RelayFormatOpenAI, geminiRequest)
if err != nil {
return nil, err
}
openAIRequest, ok := result.Value.(*dto.GeneralOpenAIRequest)
if !ok {
return nil, fmt.Errorf("expected OpenAI chat completions request, got %T", result.Value)
}
return openAIRequest, nil
}
+40
View File
@@ -0,0 +1,40 @@
package service
import (
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relaykit/relayconvert"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/gin-gonic/gin"
)
func ConvertResponse(c *gin.Context, info *relaycommon.RelayInfo, target types.RelayFormat, response any) (*relayconvert.ResponseResult, error) {
result, err := relayconvert.ConvertResponse(c, info, target, response)
if result != nil {
info.RecordConversionDiagnostics(c, result.Diagnostics)
}
return result, err
}
func ConvertStreamResponse(c *gin.Context, info *relaycommon.RelayInfo, target types.RelayFormat, response any) (*relayconvert.ResponseResult, error) {
result, err := relayconvert.ConvertStreamResponse(c, info, target, response)
if result != nil {
info.RecordConversionDiagnostics(c, result.Diagnostics)
}
return result, err
}
func ConvertStreamResponseChunk(c *gin.Context, info *relaycommon.RelayInfo, state *relayconvert.ResponseStreamState, response any) ([]relayconvert.ResponseResult, error) {
results, err := relayconvert.ConvertStreamResponseChunk(c, info, state, response)
if state != nil {
info.RecordConversionDiagnostics(c, state.Diagnostics())
}
return results, err
}
func FinalizeStreamResponse(c *gin.Context, info *relaycommon.RelayInfo, state *relayconvert.ResponseStreamState) ([]relayconvert.ResponseResult, error) {
results, err := relayconvert.FinalizeStreamResponse(c, info, state)
if state != nil {
info.RecordConversionDiagnostics(c, state.Diagnostics())
}
return results, err
}
+12
View File
@@ -0,0 +1,12 @@
package service
import (
"strings"
"github.com/QuantumNous/new-api/setting/system_setting"
)
func PaymentReturnURL(suffix string) string {
base := strings.TrimRight(system_setting.ServerAddress, "/")
return base + suffix
}
+16
View File
@@ -0,0 +1,16 @@
package service
import (
"testing"
"github.com/QuantumNous/new-api/setting/system_setting"
"github.com/stretchr/testify/assert"
)
func TestPaymentReturnURLUsesSuppliedDefaultDashboardPath(t *testing.T) {
previousAddress := system_setting.ServerAddress
system_setting.ServerAddress = "https://dashboard.example.com/"
t.Cleanup(func() { system_setting.ServerAddress = previousAddress })
assert.Equal(t, "https://dashboard.example.com/wallet", PaymentReturnURL("/wallet"))
}
+499
View File
@@ -0,0 +1,499 @@
package service
import (
"crypto/hmac"
"encoding/json"
"errors"
"sort"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/oauth"
"github.com/QuantumNous/new-api/setting/system_setting"
"gorm.io/gorm"
)
const (
VerificationMethodTwoFA = "2fa"
VerificationMethodPasskey = "passkey"
VerificationMethodPassword = "password"
VerificationMethodOAuth = "oauth"
VerificationMethodSession = "session"
VerificationScopeChannelKeyRead = "channel.key.read"
VerificationScopePasskeyRegister = "passkey.register"
VerificationScopePasskeyDelete = "passkey.delete"
VerificationScopeTwoFASetup = "2fa.setup"
VerificationScopeTwoFADisable = "2fa.disable"
VerificationScopeTwoFABackupCodes = "2fa.backup_codes.regenerate"
VerificationScopeLogin = "auth.login"
VerificationScopeAccessTokenGenerate = "access_token.generate"
VerificationScopeAccessTokenRevoke = "access_token.revoke"
VerificationScopeAccountBind = "account.binding.bind"
VerificationScopeAccountUnbind = "account.binding.unbind"
VerificationScopePasswordSet = "account.password.set"
VerificationScopePasswordChange = "account.password.change"
VerificationScopeAccountDelete = "account.delete"
)
var (
ErrVerificationFailed = errors.New("Verification failed. Please try again.")
ErrVerificationUnavailable = errors.New("This verification method is currently unavailable.")
ErrVerificationLocked = errors.New("Two-factor authentication is temporarily locked.")
ErrVerificationFlowRequired = errors.New("This verification method requires its dedicated verification flow.")
ErrVerificationContextInvalid = errors.New("The action details are invalid.")
ErrVerificationForbidden = errors.New("You do not have permission to perform this action.")
)
// VerificationOperation describes the action being authorized, independently of
// its authentication method. Each scope owns a strict, normalized context schema.
type VerificationOperation struct {
Scope string `json:"scope"`
Context json.RawMessage `json:"context,omitempty"`
}
type ChannelKeyReadContext struct {
ChannelID int `json:"channel_id"`
}
type AccountBindingContext struct {
Provider string `json:"provider"`
Email string `json:"email,omitempty"`
Code string `json:"code,omitempty"`
}
type AccountUnbindingContext struct {
ProviderID int `json:"provider_id"`
}
// VerificationBinding contains no original operation parameters. It can safely
// travel through a signed proof or a server-owned interactive verification flow.
type VerificationBinding struct {
Scope string `json:"scope"`
ContextHash string `json:"context_hash"`
}
func BindVerificationOperation(operation VerificationOperation) (VerificationBinding, error) {
var fields map[string]json.RawMessage
if len(operation.Context) > 0 {
if common.GetJsonType(operation.Context) != "object" || common.Unmarshal(operation.Context, &fields) != nil {
return VerificationBinding{}, ErrVerificationContextInvalid
}
}
var normalized any
switch operation.Scope {
case VerificationScopeChannelKeyRead:
var context ChannelKeyReadContext
if len(fields) != 1 || common.Unmarshal(fields["channel_id"], &context.ChannelID) != nil || context.ChannelID <= 0 {
return VerificationBinding{}, ErrVerificationContextInvalid
}
normalized = context
case VerificationScopeAccountBind:
var context AccountBindingContext
if common.Unmarshal(operation.Context, &context) != nil {
return VerificationBinding{}, ErrVerificationContextInvalid
}
context.Provider = strings.TrimSpace(context.Provider)
switch context.Provider {
case "email":
context.Email = model.NormalizeEmail(context.Email)
if len(fields) != 2 || common.Validate.Var(context.Email, "required,email") != nil || context.Code != "" {
return VerificationBinding{}, ErrVerificationContextInvalid
}
case "wechat":
context.Code = strings.TrimSpace(context.Code)
if len(fields) != 2 || context.Code == "" || len(context.Code) > 128 || context.Email != "" {
return VerificationBinding{}, ErrVerificationContextInvalid
}
default:
if len(fields) != 1 || context.Provider == "" || len(context.Provider) > 64 || oauth.GetProvider(context.Provider) == nil {
return VerificationBinding{}, ErrVerificationContextInvalid
}
}
normalized = context
case VerificationScopeAccountUnbind:
var context AccountUnbindingContext
if len(fields) != 1 || common.Unmarshal(fields["provider_id"], &context.ProviderID) != nil || context.ProviderID <= 0 {
return VerificationBinding{}, ErrVerificationContextInvalid
}
normalized = context
case VerificationScopePasskeyRegister, VerificationScopePasskeyDelete, VerificationScopeTwoFASetup,
VerificationScopeTwoFADisable, VerificationScopeTwoFABackupCodes,
VerificationScopeAccessTokenGenerate, VerificationScopeAccessTokenRevoke,
VerificationScopePasswordSet, VerificationScopePasswordChange, VerificationScopeAccountDelete:
if len(fields) != 0 {
return VerificationBinding{}, ErrVerificationContextInvalid
}
normalized = struct{}{}
default:
return VerificationBinding{}, ErrProofScope
}
payload, err := common.Marshal(struct {
Scope string `json:"scope"`
Context any `json:"context"`
}{Scope: operation.Scope, Context: normalized})
if err != nil {
return VerificationBinding{}, err
}
return VerificationBinding{
Scope: operation.Scope,
ContextHash: common.GenerateHMACWithKey(authSigningKey("verification-context"), string(payload)),
}, nil
}
type SecurityProof struct {
ProofToken string `json:"proof_token"`
ExpiresAt int64 `json:"expires_at"`
Method string `json:"method"`
Scope string `json:"scope"`
}
type VerificationMethodOption struct {
Method string `json:"method"`
Available bool `json:"available"`
Reason string `json:"reason,omitempty"`
}
type VerificationOAuthProvider struct {
Slug string `json:"slug"`
Name string `json:"name"`
UserID string `json:"-"`
}
type VerificationRequirements struct {
Scope string `json:"scope"`
Methods []VerificationMethodOption `json:"methods"`
OAuthProviders []VerificationOAuthProvider `json:"oauth_providers"`
PasswordEncryptionEnabled bool `json:"password_encryption_enabled"`
}
// securityVerificationPolicy is the only operation-to-method policy. Device
// support and disabled providers never turn an enrolled factor into an absent one.
func securityVerificationPolicy(scope string, state model.UserVerificationState) ([]VerificationMethodOption, error) {
var methods []string
if state.HasTwoFA {
methods = append(methods, VerificationMethodTwoFA)
}
if state.HasPasskey {
methods = append(methods, VerificationMethodPasskey)
}
switch scope {
case VerificationScopeChannelKeyRead, VerificationScopePasskeyDelete, VerificationScopeLogin:
case VerificationScopeTwoFADisable, VerificationScopeTwoFABackupCodes:
if !state.HasTwoFA {
return nil, model.ErrTwoFANotEnabled
}
case VerificationScopePasskeyRegister, VerificationScopeTwoFASetup,
VerificationScopeAccessTokenGenerate, VerificationScopeAccessTokenRevoke,
VerificationScopeAccountBind, VerificationScopeAccountUnbind,
VerificationScopePasswordSet, VerificationScopePasswordChange, VerificationScopeAccountDelete:
if scope == VerificationScopeAccountDelete && state.Role == common.RoleRootUser {
return nil, ErrVerificationForbidden
}
if scope == VerificationScopeTwoFASetup && state.HasTwoFA {
return nil, model.ErrTwoFAAlreadyEnabled
}
if (scope == VerificationScopePasswordSet && state.HasPassword) || (scope == VerificationScopePasswordChange && !state.HasPassword) {
return nil, ErrVerificationForbidden
}
if len(methods) == 0 {
if state.HasPassword {
methods = []string{VerificationMethodPassword}
} else {
methods = []string{VerificationMethodOAuth}
}
}
default:
return nil, ErrProofScope
}
options := make([]VerificationMethodOption, 0, len(methods))
for _, method := range methods {
option := VerificationMethodOption{Method: method, Available: true}
if method == VerificationMethodTwoFA && state.TwoFALocked {
option.Available, option.Reason = false, ErrVerificationLocked.Error()
}
if !system_setting.GetPasskeySettings().Enabled && (method == VerificationMethodPasskey || scope == VerificationScopePasskeyRegister) {
option.Available, option.Reason = false, "Passkey authentication is disabled."
}
options = append(options, option)
}
return options, nil
}
func GetVerificationRequirements(identity AuthIdentity, scope string) (*VerificationRequirements, error) {
if scope == VerificationScopeLogin {
return nil, ErrProofScope
}
state, err := model.GetUserVerificationState(identity.UserID)
if err != nil {
return nil, err
}
if state.Status != common.UserStatusEnabled || state.AuthVersion != identity.UserAuthVersion {
return nil, ErrAuthTokenInvalid
}
if scope == VerificationScopeChannelKeyRead && state.Role != common.RoleRootUser {
return nil, ErrVerificationForbidden
}
methods, err := securityVerificationPolicy(scope, *state)
if err != nil {
return nil, err
}
requirements := &VerificationRequirements{Scope: scope, Methods: methods, OAuthProviders: []VerificationOAuthProvider{}, PasswordEncryptionEnabled: common.PasswordLoginEncryptionEnabled}
for i := range methods {
if methods[i].Method == VerificationMethodPassword && !common.PasswordLoginEnabled {
switch scope {
case VerificationScopeAccountBind, VerificationScopeAccountUnbind, VerificationScopePasswordSet, VerificationScopePasswordChange, VerificationScopeAccountDelete:
methods[i].Available, methods[i].Reason = false, "Password authentication is disabled."
}
}
if methods[i].Method != VerificationMethodOAuth {
continue
}
user, err := model.GetUserById(identity.UserID, false)
if err != nil {
return nil, err
}
requirements.OAuthProviders, err = verificationOAuthProviders(user)
if err != nil {
return nil, err
}
if len(requirements.OAuthProviders) == 0 {
methods[i].Available, methods[i].Reason = false, "No linked OAuth provider is available."
if user.TelegramId != "" {
if err := oauth.TelegramConfigurationError(); err != nil {
methods[i].Reason = err.Error()
}
}
}
}
return requirements, nil
}
func verificationOAuthProviders(user *model.User) ([]VerificationOAuthProvider, error) {
bindings := map[int]string{}
if len(oauth.GetEnabledCustomProviders()) > 0 {
stored, err := model.GetUserOAuthBindingsByUserId(user.Id)
if err != nil {
return nil, err
}
for _, binding := range stored {
bindings[binding.ProviderId] = binding.ProviderUserId
}
}
providers := []VerificationOAuthProvider{}
for slug, provider := range oauth.GetAllProviders() {
if !provider.IsEnabled() {
continue
}
var userID string
if custom, ok := provider.(*oauth.GenericOAuthProvider); ok {
userID = bindings[custom.GetProviderId()]
} else {
switch provider.ProviderUserIDColumn() {
case "github_id":
userID = user.GitHubId
case "discord_id":
userID = user.DiscordId
case "oidc_id":
userID = user.OidcId
case "linux_do_id":
userID = user.LinuxDOId
case "telegram_id":
userID = user.TelegramId
}
}
if userID != "" {
providers = append(providers, VerificationOAuthProvider{Slug: slug, Name: provider.GetName(), UserID: userID})
}
}
sort.Slice(providers, func(i, j int) bool { return providers[i].Slug < providers[j].Slug })
return providers, nil
}
func RequireVerificationMethod(identity AuthIdentity, scope, method string) (*VerificationRequirements, error) {
requirements, err := GetVerificationRequirements(identity, scope)
if err != nil {
return nil, err
}
for _, option := range requirements.Methods {
if option.Method != method {
continue
}
if !option.Available {
return nil, ErrVerificationUnavailable
}
return requirements, nil
}
return nil, ErrProofMethod
}
// CompleteSecurityVerification runs after the concrete authentication ceremony.
// Recheck the session and policy after potentially slow external authentication.
func CompleteSecurityVerification(identity AuthIdentity, binding VerificationBinding, method string) (*SecurityProof, error) {
if _, _, err := ValidateLoginSession(identity); err != nil {
return nil, err
}
if _, err := RequireVerificationMethod(identity, binding.Scope, method); err != nil {
return nil, err
}
token, expiresAt, err := IssueSecurityProof(identity, method, binding)
if err != nil {
return nil, err
}
return &SecurityProof{ProofToken: token, ExpiresAt: expiresAt, Method: method, Scope: binding.Scope}, nil
}
// ConsumeOperationProof accepts a proof at most once. The consumption commits
// before the caller performs its action; an action failure must not restore it.
func ConsumeOperationProof(raw string, identity AuthIdentity, operation VerificationOperation) (*model.AuthFlowAuthorization, error) {
binding, err := BindVerificationOperation(operation)
if err != nil {
return nil, err
}
claims, err := verifySecurityProof(raw, identity, binding)
if err != nil {
return nil, err
}
if _, _, err := ValidateLoginSession(identity); err != nil {
return nil, err
}
if _, err := RequireVerificationMethod(identity, binding.Scope, claims.Method); err != nil {
return nil, err
}
flow, err := model.ConsumeAuthFlowWithAction(claims.ID, model.AuthFlowMatch{
Purpose: model.AuthFlowPurposeSecurityProof, UserId: identity.UserID, SessionId: identity.SessionID,
}, func(tx *gorm.DB, _ *model.AuthFlow) error {
return model.ValidateAuthSessionWithTx(tx, identity)
})
switch {
case errors.Is(err, model.ErrAuthFlowConsumed):
return nil, ErrProofConsumed
case errors.Is(err, model.ErrAuthFlowExpired):
return nil, ErrAuthTokenExpired
case errors.Is(err, model.ErrAuthFlowInvalid):
return nil, ErrAuthTokenInvalid
case err != nil:
return nil, err
}
return &model.AuthFlowAuthorization{
AuthSessionIdentity: identity, ProofID: flow.Id, Scope: binding.Scope,
ContextHash: binding.ContextHash, Method: claims.Method,
}, nil
}
// ValidateFlowAuthorization permits a dedicated configuration flow to outlive
// its consumed proof, while retaining its operation, session and method policy.
func ValidateFlowAuthorization(identity AuthIdentity, operation VerificationOperation, authorization *model.AuthFlowAuthorization) error {
if authorization == nil || authorization.ProofID <= 0 || authorization.AuthSessionIdentity != identity {
return model.ErrAuthFlowInvalid
}
binding, err := BindVerificationOperation(operation)
if err != nil {
return err
}
if authorization.Scope != binding.Scope || !hmac.Equal([]byte(authorization.ContextHash), []byte(binding.ContextHash)) {
return model.ErrAuthFlowInvalid
}
if _, _, err := ValidateLoginSession(identity); err != nil {
return err
}
_, err = RequireVerificationMethod(identity, binding.Scope, authorization.Method)
return err
}
type VerificationInput struct {
Method string `json:"method"`
Scope string `json:"scope"`
Context json.RawMessage `json:"context,omitempty"`
Code string `json:"code,omitempty"`
Password string `json:"password,omitempty"`
PasswordEncrypted string `json:"password_encrypted,omitempty"`
EncryptionKeyID string `json:"encryption_key_id,omitempty"`
}
func VerifySecurityInput(identity AuthIdentity, input VerificationInput) (*SecurityProof, error) {
binding, err := BindVerificationOperation(VerificationOperation{Scope: input.Scope, Context: input.Context})
if err != nil {
return nil, err
}
if _, err := RequireVerificationMethod(identity, input.Scope, input.Method); err != nil {
return nil, err
}
switch input.Method {
case VerificationMethodPassword:
password := input.Password
if common.PasswordLoginEncryptionEnabled {
var err error
password, err = common.DecryptPassword(input.PasswordEncrypted, input.EncryptionKeyID)
if err != nil {
return nil, ErrVerificationFailed
}
}
user, err := model.GetUserById(identity.UserID, true)
if err != nil {
return nil, err
}
if password == "" || user.Password == "" || !common.ValidatePasswordAndHash(password, user.Password) {
return nil, ErrVerificationFailed
}
case VerificationMethodTwoFA:
if input.Scope == VerificationScopeTwoFABackupCodes {
if _, err := common.ValidateNumericCode(input.Code); err != nil {
return nil, ErrVerificationFailed
}
}
twoFA, err := model.GetTwoFAByUserId(identity.UserID)
if err != nil {
return nil, err
}
if err := VerifyTwoFactorCode(twoFA, input.Code); err != nil {
return nil, err
}
case VerificationMethodPasskey, VerificationMethodOAuth:
return nil, ErrVerificationFlowRequired
default:
return nil, ErrProofMethod
}
return CompleteSecurityVerification(identity, binding, input.Method)
}
// VerifyTwoFactorCode classifies the input before verification so one failed
// submission cannot increment the failure counter for both TOTP and backup codes.
func VerifyTwoFactorCode(twoFA *model.TwoFA, code string) error {
if twoFA == nil || !twoFA.IsEnabled {
return model.ErrTwoFANotEnabled
}
if twoFA.IsLocked() {
return ErrVerificationLocked
}
code = strings.TrimSpace(code)
var valid bool
var err error
if numeric, numericErr := common.ValidateNumericCode(code); numericErr == nil {
valid, err = twoFA.ValidateTOTPAndUpdateUsage(numeric)
} else if common.ValidateBackupCode(code) {
valid, err = twoFA.ValidateBackupCodeAndUpdateUsage(code)
} else {
err = twoFA.IncrementFailedAttempts()
}
if err != nil {
return err
}
if !valid {
return ErrVerificationFailed
}
return nil
}
func GetOAuthVerificationBinding(identity AuthIdentity, scope, provider string) (string, error) {
requirements, err := RequireVerificationMethod(identity, scope, VerificationMethodOAuth)
if err != nil {
return "", err
}
for _, linked := range requirements.OAuthProviders {
if linked.Slug == provider {
return linked.UserID, nil
}
}
return "", ErrVerificationUnavailable
}
+77
View File
@@ -0,0 +1,77 @@
package service
import (
"errors"
"strings"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/setting"
)
func CheckSensitiveMessages(messages []dto.Message) ([]string, error) {
if len(messages) == 0 {
return nil, nil
}
for _, message := range messages {
arrayContent := message.ParseContent()
for _, m := range arrayContent {
if m.Type == "image_url" {
// TODO: check image url
continue
}
// 检查 text 是否为空
if m.Text == "" {
continue
}
if ok, words := SensitiveWordContains(m.Text); ok {
return words, errors.New("sensitive words detected")
}
}
}
return nil, nil
}
func CheckSensitiveText(text string) (bool, []string) {
return SensitiveWordContains(text)
}
// SensitiveWordContains 是否包含敏感词,返回是否包含敏感词和敏感词列表
func SensitiveWordContains(text string) (bool, []string) {
if len(setting.SensitiveWords) == 0 {
return false, nil
}
if len(text) == 0 {
return false, nil
}
checkText := strings.ToLower(text)
return AcSearch(checkText, setting.SensitiveWords, true)
}
// SensitiveWordReplace 敏感词替换,返回是否包含敏感词和替换后的文本
func SensitiveWordReplace(text string, returnImmediately bool) (bool, []string, string) {
if len(setting.SensitiveWords) == 0 {
return false, nil, text
}
checkText := strings.ToLower(text)
m := getOrBuildAC(setting.SensitiveWords)
hits := m.MultiPatternSearch([]rune(checkText), returnImmediately)
if len(hits) > 0 {
words := make([]string, 0, len(hits))
var builder strings.Builder
builder.Grow(len(text))
lastPos := 0
for _, hit := range hits {
pos := hit.Pos
word := string(hit.Word)
builder.WriteString(text[lastPos:pos])
builder.WriteString("**###**")
lastPos = pos + len(word)
words = append(words, word)
}
builder.WriteString(text[lastPos:])
return true, words, builder.String()
}
return false, nil, text
}
+152
View File
@@ -0,0 +1,152 @@
package service
import (
"bytes"
"fmt"
"hash/fnv"
"sort"
"strings"
"sync"
goahocorasick "github.com/anknown/ahocorasick"
)
func SundaySearch(text string, pattern string) bool {
// 计算偏移表
offset := make(map[rune]int)
for i, c := range pattern {
offset[c] = len(pattern) - i
}
// 文本串长度和模式串长度
n, m := len(text), len(pattern)
// 主循环,i表示当前对齐的文本串位置
for i := 0; i <= n-m; {
// 检查子串
j := 0
for j < m && text[i+j] == pattern[j] {
j++
}
// 如果完全匹配,返回匹配位置
if j == m {
return true
}
// 如果还有剩余字符,则检查下一位字符在偏移表中的值
if i+m < n {
next := rune(text[i+m])
if val, ok := offset[next]; ok {
i += val // 存在于偏移表中,进行跳跃
} else {
i += len(pattern) + 1 // 不存在于偏移表中,跳过整个模式串长度
}
} else {
break
}
}
return false // 如果没有找到匹配,返回-1
}
func RemoveDuplicate(s []string) []string {
result := make([]string, 0, len(s))
temp := map[string]struct{}{}
for _, item := range s {
if _, ok := temp[item]; !ok {
temp[item] = struct{}{}
result = append(result, item)
}
}
return result
}
func InitAc(dict []string) *goahocorasick.Machine {
m := new(goahocorasick.Machine)
runes := readRunes(dict)
if err := m.Build(runes); err != nil {
fmt.Println(err)
return nil
}
return m
}
var acCache sync.Map
func acKey(dict []string) string {
if len(dict) == 0 {
return ""
}
normalized := make([]string, 0, len(dict))
for _, w := range dict {
w = strings.ToLower(strings.TrimSpace(w))
if w != "" {
normalized = append(normalized, w)
}
}
if len(normalized) == 0 {
return ""
}
sort.Strings(normalized)
hasher := fnv.New64a()
for _, w := range normalized {
hasher.Write([]byte{0})
hasher.Write([]byte(w))
}
return fmt.Sprintf("%x", hasher.Sum64())
}
func getOrBuildAC(dict []string) *goahocorasick.Machine {
key := acKey(dict)
if key == "" {
return nil
}
if v, ok := acCache.Load(key); ok {
if m, ok2 := v.(*goahocorasick.Machine); ok2 {
return m
}
}
m := InitAc(dict)
if m == nil {
return nil
}
if actual, loaded := acCache.LoadOrStore(key, m); loaded {
if cached, ok := actual.(*goahocorasick.Machine); ok {
return cached
}
}
return m
}
func readRunes(dict []string) [][]rune {
var runes [][]rune
for _, word := range dict {
word = strings.ToLower(word)
l := bytes.TrimSpace([]byte(word))
runes = append(runes, bytes.Runes(l))
}
return runes
}
func AcSearch(findText string, dict []string, stopImmediately bool) (bool, []string) {
if len(dict) == 0 {
return false, nil
}
if len(findText) == 0 {
return false, nil
}
m := getOrBuildAC(dict)
if m == nil {
return false, nil
}
hits := m.MultiPatternSearch([]rune(findText), stopImmediately)
if len(hits) > 0 {
words := make([]string, 0)
for _, hit := range hits {
words = append(words, string(hit.Word))
}
return true, words
}
return false, nil
}
+93
View File
@@ -0,0 +1,93 @@
package service
import (
"context"
"fmt"
"sync"
"sync/atomic"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model"
"github.com/bytedance/gopkg/util/gopool"
)
const (
subscriptionResetTickInterval = 1 * time.Minute
subscriptionResetBatchSize = 300
subscriptionCleanupInterval = 30 * time.Minute
)
var (
subscriptionResetOnce sync.Once
subscriptionResetRunning atomic.Bool
subscriptionCleanupLast atomic.Int64
)
func StartSubscriptionQuotaResetTask() {
subscriptionResetOnce.Do(func() {
if !common.IsMasterNode {
return
}
gopool.Go(func() {
logger.LogInfo(context.Background(), fmt.Sprintf("subscription quota reset task started: tick=%s", subscriptionResetTickInterval))
ticker := time.NewTicker(subscriptionResetTickInterval)
defer ticker.Stop()
runSubscriptionQuotaResetOnce()
for range ticker.C {
runSubscriptionQuotaResetOnce()
}
})
})
}
func runSubscriptionQuotaResetOnce() {
if !subscriptionResetRunning.CompareAndSwap(false, true) {
return
}
defer subscriptionResetRunning.Store(false)
ctx := context.Background()
totalReset := 0
totalExpired := 0
for {
n, err := model.ExpireDueSubscriptions(subscriptionResetBatchSize)
if err != nil {
logger.LogWarn(ctx, fmt.Sprintf("subscription expire task failed: %v", err))
return
}
if n == 0 {
break
}
totalExpired += n
if n < subscriptionResetBatchSize {
break
}
}
for {
n, err := model.ResetDueSubscriptions(subscriptionResetBatchSize)
if err != nil {
logger.LogWarn(ctx, fmt.Sprintf("subscription quota reset task failed: %v", err))
return
}
if n == 0 {
break
}
totalReset += n
if n < subscriptionResetBatchSize {
break
}
}
lastCleanup := time.Unix(subscriptionCleanupLast.Load(), 0)
if time.Since(lastCleanup) >= subscriptionCleanupInterval {
if _, err := model.CleanupSubscriptionPreConsumeRecords(7 * 24 * 3600); err == nil {
subscriptionCleanupLast.Store(time.Now().Unix())
}
}
if common.DebugEnabled && (totalReset > 0 || totalExpired > 0) {
logger.LogDebug(ctx, "subscription maintenance: reset_count=%d, expired_count=%d", totalReset, totalExpired)
}
}
+130
View File
@@ -0,0 +1,130 @@
package service
import (
"context"
"fmt"
"os"
"runtime"
"strings"
"sync"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model"
"github.com/bytedance/gopkg/util/gopool"
)
const systemInstanceReportInterval = 30 * time.Second
var systemInstanceReporterOnce sync.Once
type SystemInstanceInfo struct {
SchemaVersion int `json:"schema_version"`
Node common.NodeIdentity `json:"node"`
Role SystemInstanceRoleInfo `json:"role"`
Runtime SystemInstanceRuntimeInfo `json:"runtime"`
Host SystemInstanceHostInfo `json:"host"`
Resources SystemInstanceResources `json:"resources"`
Extra map[string]any `json:"extra,omitempty"`
}
type SystemInstanceRoleInfo struct {
IsMaster bool `json:"is_master"`
}
type SystemInstanceRuntimeInfo struct {
Version string `json:"version"`
GOOS string `json:"goos"`
GOARCH string `json:"goarch"`
StartedAt int64 `json:"started_at"`
}
type SystemInstanceHostInfo struct {
Hostname string `json:"hostname"`
}
type SystemInstanceResources struct {
CPU SystemInstanceResourceUsage `json:"cpu"`
Memory SystemInstanceResourceUsage `json:"memory"`
Storage SystemInstanceStorageMetrics `json:"storage"`
}
type SystemInstanceResourceUsage struct {
UsagePercent float64 `json:"usage_percent"`
}
type SystemInstanceStorageMetrics struct {
TotalBytes uint64 `json:"total_bytes"`
UsedBytes uint64 `json:"used_bytes"`
FreeBytes uint64 `json:"free_bytes"`
UsedPercent float64 `json:"used_percent"`
}
func StartSystemInstanceReporter() {
systemInstanceReporterOnce.Do(func() {
gopool.Go(func() {
reportSystemInstanceWithLog()
ticker := time.NewTicker(systemInstanceReportInterval)
defer ticker.Stop()
for range ticker.C {
reportSystemInstanceWithLog()
}
})
})
}
func ReportCurrentSystemInstance() error {
identity := common.GetNodeIdentity()
hostname, hostnameErr := os.Hostname()
if strings.TrimSpace(identity.Name) == "" {
if hostnameErr != nil || strings.TrimSpace(hostname) == "" {
return fmt.Errorf("system instance node name is empty")
}
identity.Name = hostname
identity.Source = common.NodeNameSourceHostname
identity.ManuallyConfigured = false
identity.ShouldConfigureManually = true
}
systemStatus := common.GetSystemStatus()
diskInfo := common.GetDiskSpaceInfo()
info := SystemInstanceInfo{
SchemaVersion: 1,
Node: identity,
Role: SystemInstanceRoleInfo{
IsMaster: common.IsMasterNode,
},
Runtime: SystemInstanceRuntimeInfo{
Version: common.Version,
GOOS: runtime.GOOS,
GOARCH: runtime.GOARCH,
StartedAt: common.StartTime,
},
Host: SystemInstanceHostInfo{
Hostname: hostname,
},
Resources: SystemInstanceResources{
CPU: SystemInstanceResourceUsage{
UsagePercent: systemStatus.CPUUsage,
},
Memory: SystemInstanceResourceUsage{
UsagePercent: systemStatus.MemoryUsage,
},
Storage: SystemInstanceStorageMetrics{
TotalBytes: diskInfo.Total,
UsedBytes: diskInfo.Used,
FreeBytes: diskInfo.Free,
UsedPercent: diskInfo.UsedPercent,
},
},
}
return model.UpsertSystemInstance(identity.Name, info, common.StartTime, common.GetTimestamp())
}
func reportSystemInstanceWithLog() {
if err := ReportCurrentSystemInstance(); err != nil {
logger.LogWarn(context.Background(), fmt.Sprintf("system instance report failed: %v", err))
}
}
+524
View File
@@ -0,0 +1,524 @@
package service
import (
"context"
"errors"
"fmt"
"sync"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model"
"github.com/bytedance/gopkg/util/gopool"
)
const (
// systemTaskRunnerIdleInterval is the fallback poll interval used to pick up
// tasks created on other nodes and mark expired leases failed.
systemTaskRunnerIdleInterval = 15 * time.Second
systemTaskLockTTL = 60 * time.Second
logCleanupBatchSize = 100
// systemTaskSchedulerInterval throttles how often the scheduler/stale-lock
// pass runs, independent of how often the runner wakes to claim tasks.
systemTaskSchedulerInterval = 15 * time.Second
systemTaskStaleLockInterval = 30 * time.Second
)
// SystemTaskHandler executes a claimed task of a specific type. Run owns the
// task lifecycle from claim to terminal state: it MUST call
// model.FinishSystemTask (succeeded/failed) before returning and MUST honor
// ctx cancellation, which the runner triggers if the per-type lock is lost.
type SystemTaskHandler interface {
Type() string
Run(ctx context.Context, task *model.SystemTask, runnerID string)
}
// ScheduledSystemTaskHandler is a SystemTaskHandler that the scheduler also
// creates periodically when enabled and the configured interval has elapsed
// since the last run.
type ScheduledSystemTaskHandler interface {
SystemTaskHandler
Enabled() bool
Interval() time.Duration
NewPayload() any
}
var (
systemTaskHandlersMu sync.RWMutex
systemTaskHandlers = map[string]SystemTaskHandler{}
)
// RegisterSystemTaskHandler registers a handler keyed by its Type(). It must be
// called before StartSystemTaskRunner (or any time, since the runner snapshots
// the registry every pass). Re-registering a type replaces the previous handler.
func RegisterSystemTaskHandler(h SystemTaskHandler) {
if h == nil {
return
}
systemTaskHandlersMu.Lock()
defer systemTaskHandlersMu.Unlock()
systemTaskHandlers[h.Type()] = h
}
func registeredSystemTaskHandlers() []SystemTaskHandler {
systemTaskHandlersMu.RLock()
defer systemTaskHandlersMu.RUnlock()
handlers := make([]SystemTaskHandler, 0, len(systemTaskHandlers))
for _, h := range systemTaskHandlers {
handlers = append(handlers, h)
}
return handlers
}
// logCleanupHandler wraps the existing on-demand log cleanup task as a
// registered (non-scheduled) handler. It is created via StartLogCleanupTask.
type logCleanupHandler struct{}
func (logCleanupHandler) Type() string { return model.SystemTaskTypeLogCleanup }
func (logCleanupHandler) Run(ctx context.Context, task *model.SystemTask, runnerID string) {
runLogCleanupTask(ctx, task, runnerID)
}
func init() {
RegisterSystemTaskHandler(logCleanupHandler{})
}
type LogCleanupPayload struct {
TargetTimestamp int64 `json:"target_timestamp"`
BatchSize int `json:"batch_size"`
}
type LogCleanupState struct {
Total int64 `json:"total"`
Processed int64 `json:"processed"`
Progress int `json:"progress"`
Remaining int64 `json:"remaining"`
}
type LogCleanupResult struct {
DeletedCount int64 `json:"deleted_count"`
}
var (
systemTaskRunnerOnce sync.Once
// systemTaskWakeup signals the runner to check for runnable tasks
// immediately instead of waiting for the idle poll. Buffered so a signal
// raised while the runner is busy is not lost and is handled on the next loop.
systemTaskWakeup = make(chan struct{}, 1)
)
// notifySystemTaskRunner wakes the runner without blocking. If a wakeup is
// already pending it is a no-op, which is fine since one pass drains all work.
func notifySystemTaskRunner() {
select {
case systemTaskWakeup <- struct{}{}:
default:
}
}
func StartSystemTaskRunner() {
systemTaskRunnerOnce.Do(func() {
if !common.IsMasterNode {
return
}
runnerID := fmt.Sprintf("%s-%s", common.NodeName, common.GetRandomString(8))
gopool.Go(func() {
logger.LogInfo(context.Background(), fmt.Sprintf("system task runner started: runner=%s idle_interval=%s", runnerID, systemTaskRunnerIdleInterval))
ticker := time.NewTicker(systemTaskRunnerIdleInterval)
defer ticker.Stop()
var lastScheduler time.Time
var lastStaleLockCleanup time.Time
runPass := func() {
// The scheduler/stale-lock pass is throttled independently of the
// claim pass: wakeups (e.g. a manual log cleanup) should claim
// immediately without re-running the scheduler every time.
now := time.Now()
if now.Sub(lastStaleLockCleanup) >= systemTaskStaleLockInterval {
lastStaleLockCleanup = now
if err := model.ExpireStaleSystemTaskLocks(common.GetTimestamp()); err != nil {
logger.LogWarn(context.Background(), fmt.Sprintf("system task stale lock cleanup failed: %v", err))
}
}
if now.Sub(lastScheduler) >= systemTaskSchedulerInterval {
lastScheduler = now
runSystemTaskScheduler()
}
runSystemTaskClaimPass(runnerID)
}
runPass()
for {
select {
case <-ticker.C:
case <-systemTaskWakeup:
}
runPass()
}
})
})
}
func StartLogCleanupTask(targetTimestamp int64) (*model.SystemTask, error) {
if targetTimestamp <= 0 {
return nil, errors.New("target timestamp is required")
}
activeTask, err := model.GetActiveSystemTask(model.SystemTaskTypeLogCleanup)
if err != nil {
return nil, err
}
if activeTask != nil {
return activeTask, nil
}
payload := LogCleanupPayload{
TargetTimestamp: targetTimestamp,
BatchSize: logCleanupBatchSize,
}
state := LogCleanupState{}
task, err := model.CreateSystemTask(model.SystemTaskTypeLogCleanup, payload, state)
if err != nil {
activeTask, activeErr := model.GetActiveSystemTask(model.SystemTaskTypeLogCleanup)
if activeErr == nil && activeTask != nil {
return activeTask, nil
}
return nil, err
}
notifySystemTaskRunner()
return task, nil
}
// EnqueueSystemTask creates an on-demand task of the given type. The returned
// bool is true only when a new pending row was created; false means an active
// task of the same type already exists and was returned.
func EnqueueSystemTask(taskType string, payload any) (*model.SystemTask, bool, error) {
activeTask, err := model.GetActiveSystemTask(taskType)
if err != nil {
return nil, false, err
}
if activeTask != nil {
return activeTask, false, nil
}
task, err := model.CreateSystemTask(taskType, payload, nil)
if err != nil {
activeTask, activeErr := model.GetActiveSystemTask(taskType)
if activeErr == nil && activeTask != nil {
return activeTask, false, nil
}
return nil, false, err
}
notifySystemTaskRunner()
return task, true, nil
}
// runSystemTaskClaimPass tries to claim one pending task per registered type
// and dispatches each claimed task in its own goroutine so a long-running
// handler (e.g. channel test) never blocks another type (e.g. log cleanup).
func runSystemTaskClaimPass(runnerID string) {
handlers := registeredSystemTaskHandlers()
taskTypes := make([]string, 0, len(handlers))
for _, handler := range handlers {
taskTypes = append(taskTypes, handler.Type())
}
pendingTasks, err := model.FindEarliestPendingSystemTasks(taskTypes)
if err != nil {
logger.LogWarn(context.Background(), fmt.Sprintf("system task runner query failed: %v", err))
return
}
for _, handler := range handlers {
task := pendingTasks[handler.Type()]
if task == nil {
continue
}
claimedTask, claimed, err := model.ClaimSystemTask(task.ID, handler.Type(), runnerID, systemTaskLockUntil())
if err != nil {
logger.LogWarn(context.Background(), fmt.Sprintf("system task claim failed: %v", err))
continue
}
if !claimed {
continue
}
dispatchHandler := handler
dispatchTask := claimedTask
gopool.Go(func() {
runWithLeaseHeartbeat(dispatchTask, runnerID, func(ctx context.Context) {
dispatchHandler.Run(ctx, dispatchTask, runnerID)
})
})
}
}
// runSystemTaskScheduler creates a new task row for each enabled scheduled
// handler whose interval has elapsed since its last run and that has no active
// row. The task active_key unique index deduplicates concurrent creation while
// the per-type lock guarantees only one runner executes the task.
func runSystemTaskScheduler() {
now := common.GetTimestamp()
handlers := registeredSystemTaskHandlers()
scheduledHandlers := make([]ScheduledSystemTaskHandler, 0, len(handlers))
taskTypes := make([]string, 0, len(handlers))
for _, handler := range handlers {
scheduled, ok := handler.(ScheduledSystemTaskHandler)
if !ok || !scheduled.Enabled() {
continue
}
scheduledHandlers = append(scheduledHandlers, scheduled)
taskTypes = append(taskTypes, scheduled.Type())
}
latestTasks, err := model.GetLatestSystemTasks(taskTypes)
if err != nil {
logger.LogWarn(context.Background(), fmt.Sprintf("system task scheduler query failed: %v", err))
return
}
for _, scheduled := range scheduledHandlers {
latest := latestTasks[scheduled.Type()]
if latest != nil {
if latest.Status == model.SystemTaskStatusPending || latest.Status == model.SystemTaskStatusRunning {
continue // an active row already exists
}
if now-latest.UpdatedAt < int64(scheduled.Interval().Seconds()) {
continue // not due yet
}
}
if _, err := model.CreateSystemTask(scheduled.Type(), scheduled.NewPayload(), nil); err != nil {
activeTask, activeErr := model.GetActiveSystemTask(scheduled.Type())
if activeErr == nil && activeTask != nil {
continue
}
if activeErr != nil {
logger.LogWarn(context.Background(), fmt.Sprintf("system task scheduler active lookup failed: type=%s err=%v", scheduled.Type(), activeErr))
}
logger.LogWarn(context.Background(), fmt.Sprintf("system task scheduler create failed: type=%s err=%v", scheduled.Type(), err))
continue
}
}
}
// runWithLeaseHeartbeat renews the per-type lock on a background ticker while
// fn runs. The TTL is a crash-detection window, not a task time limit: an
// arbitrarily long handler stays alive as long as the heartbeat succeeds.
func runWithLeaseHeartbeat(task *model.SystemTask, runnerID string, fn func(ctx context.Context)) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
interval := systemTaskLockTTL / 3
if interval <= 0 {
interval = systemTaskLockTTL
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
done := make(chan struct{})
go func() {
for {
select {
case <-done:
return
case <-ticker.C:
if err := model.RenewSystemTaskLock(task.TaskID, runnerID, systemTaskLockUntil()); err != nil {
cancel()
return
}
}
}
}()
fn(ctx)
close(done)
}
func runLogCleanupTask(ctx context.Context, task *model.SystemTask, runnerID string) {
payload := LogCleanupPayload{}
if err := task.DecodePayload(&payload); err != nil {
failSystemTask(task, runnerID, err)
return
}
if payload.TargetTimestamp <= 0 {
failSystemTask(task, runnerID, errors.New("target timestamp is required"))
return
}
if payload.BatchSize <= 0 {
payload.BatchSize = logCleanupBatchSize
}
state := LogCleanupState{}
if err := task.DecodeState(&state); err != nil {
failSystemTask(task, runnerID, err)
return
}
for {
remaining, err := model.CountOldLog(ctx, payload.TargetTimestamp)
if err != nil {
failSystemTask(task, runnerID, err)
return
}
syncLogCleanupStateFromRemaining(&state, remaining)
if err := model.UpdateSystemTaskState(task.TaskID, runnerID, state); err != nil {
logSystemTaskLockError(ctx, task, err)
return
}
if state.Remaining == 0 {
break
}
// Track whether this pass deleted anything so a fresh recount that still
// reports remaining rows resumes immediately instead of waiting for the
// lock to expire. If a whole pass deletes nothing while rows remain, the
// rows cannot be removed and we fail instead of busy-looping.
progressed := false
for state.Remaining > 0 {
rowsAffected, err := model.DeleteOldLogBatch(ctx, payload.TargetTimestamp, payload.BatchSize)
if err != nil {
failSystemTask(task, runnerID, err)
return
}
if rowsAffected == 0 {
break
}
progressed = true
state.Processed += rowsAffected
if state.Total < state.Processed {
state.Total = state.Processed
}
if state.Remaining > rowsAffected {
state.Remaining -= rowsAffected
} else {
state.Remaining = 0
}
state.Progress = logCleanupProgress(state.Processed, state.Total)
if err := model.UpdateSystemTaskState(task.TaskID, runnerID, state); err != nil {
logSystemTaskLockError(ctx, task, err)
return
}
}
if !progressed {
failSystemTask(task, runnerID, errors.New("no log rows were deleted"))
return
}
}
state.Remaining = 0
state.Progress = 100
if state.Total < state.Processed {
state.Total = state.Processed
}
if err := model.UpdateSystemTaskState(task.TaskID, runnerID, state); err != nil {
logSystemTaskLockError(ctx, task, err)
return
}
result := LogCleanupResult{DeletedCount: state.Processed}
if err := model.FinishSystemTask(task.TaskID, runnerID, model.SystemTaskStatusSucceeded, result, ""); err != nil {
logSystemTaskLockError(ctx, task, err)
}
}
func syncLogCleanupStateFromRemaining(state *LogCleanupState, remaining int64) {
if state.Total <= 0 {
state.Total = remaining
state.Processed = 0
} else {
processedFromRemaining := state.Total - remaining
if processedFromRemaining > state.Processed {
state.Processed = processedFromRemaining
}
}
if state.Processed < 0 {
state.Processed = 0
}
state.Remaining = remaining
state.Progress = logCleanupProgress(state.Processed, state.Total)
}
func logCleanupProgress(processed int64, total int64) int {
if total <= 0 {
return 100
}
if processed <= 0 {
return 0
}
if processed >= total {
return 100
}
return int(processed * 100 / total)
}
func systemTaskLockUntil() int64 {
return common.GetTimestamp() + int64(systemTaskLockTTL.Seconds())
}
// SystemTaskProgress is the state shape used by handlers that report percentage
// progress (channel test, model update). The frontend reads the progress field
// (0-100) to render a per-task progress indicator.
type SystemTaskProgress struct {
Total int `json:"total"`
Processed int `json:"processed"`
Progress int `json:"progress"`
}
// NewSystemTaskProgressReporter returns a throttled progress callback bound to a
// running task. Handlers call it with (processed, total) as they iterate work;
// it persists a {processed,total,progress} state at most once every ~2s, always
// emitting the first update and the final 100%.
// Lock-loss errors are ignored: the lease heartbeat cancels the handler ctx on
// loss, so progress writes are best-effort and never abort the run themselves.
// The returned func is single-goroutine only (call it from the handler loop).
func NewSystemTaskProgressReporter(task *model.SystemTask, runnerID string) func(processed, total int) {
const minWriteInterval = 2 * time.Second
var (
lastWriteAt time.Time
lastProgress = -1
)
return func(processed, total int) {
progress := 100
if total > 0 {
progress = processed * 100 / total
}
if progress < 0 {
progress = 0
} else if progress > 100 {
progress = 100
}
if progress < 100 {
if progress == lastProgress {
return
}
if !lastWriteAt.IsZero() && time.Since(lastWriteAt) < minWriteInterval {
return
}
}
lastProgress = progress
lastWriteAt = time.Now()
state := SystemTaskProgress{Total: total, Processed: processed, Progress: progress}
_ = model.UpdateSystemTaskState(task.TaskID, runnerID, state)
}
}
func failSystemTask(task *model.SystemTask, runnerID string, err error) {
logger.LogWarn(context.Background(), fmt.Sprintf("system task %s failed: %v", task.TaskID, err))
if finishErr := model.FinishSystemTask(task.TaskID, runnerID, model.SystemTaskStatusFailed, nil, err.Error()); finishErr != nil {
logger.LogWarn(context.Background(), fmt.Sprintf("system task %s failed to save failure state: %v", task.TaskID, finishErr))
}
}
func logSystemTaskLockError(ctx context.Context, task *model.SystemTask, err error) {
if errors.Is(err, model.ErrSystemTaskLockLost) {
logger.LogWarn(ctx, fmt.Sprintf("system task %s lock lost", task.TaskID))
return
}
logger.LogWarn(ctx, fmt.Sprintf("system task %s update failed: %v", task.TaskID, err))
}
+234
View File
@@ -0,0 +1,234 @@
package service
import (
"context"
"testing"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// withSystemTaskRegistry swaps the package registry for the given handlers for
// the duration of a test and restores the original registry afterward.
func withSystemTaskRegistry(t *testing.T, handlers ...SystemTaskHandler) {
t.Helper()
systemTaskHandlersMu.Lock()
saved := systemTaskHandlers
systemTaskHandlers = map[string]SystemTaskHandler{}
for _, h := range handlers {
systemTaskHandlers[h.Type()] = h
}
systemTaskHandlersMu.Unlock()
t.Cleanup(func() {
systemTaskHandlersMu.Lock()
systemTaskHandlers = saved
systemTaskHandlersMu.Unlock()
})
}
type stubScheduledHandler struct {
taskType string
enabled bool
interval time.Duration
onRun func(ctx context.Context, task *model.SystemTask, runnerID string)
}
type stubSystemTaskRunResult struct {
taskID string
taskType string
err error
}
func (h *stubScheduledHandler) Type() string { return h.taskType }
func (h *stubScheduledHandler) Run(ctx context.Context, task *model.SystemTask, runnerID string) {
if h.onRun != nil {
h.onRun(ctx, task, runnerID)
}
}
func (h *stubScheduledHandler) Enabled() bool { return h.enabled }
func (h *stubScheduledHandler) Interval() time.Duration { return h.interval }
func (h *stubScheduledHandler) NewPayload() any { return nil }
func countSystemTasks(t *testing.T, taskType string) int64 {
t.Helper()
var count int64
require.NoError(t, model.DB.Model(&model.SystemTask{}).Where("type = ?", taskType).Count(&count).Error)
return count
}
func TestSystemTaskSchedulerCreatesWhenDueAndDedups(t *testing.T) {
truncate(t)
handler := &stubScheduledHandler{taskType: "test_scheduled", enabled: true, interval: time.Minute}
withSystemTaskRegistry(t, handler)
runSystemTaskScheduler()
require.Equal(t, int64(1), countSystemTasks(t, handler.taskType))
// An active (pending) row already exists, so a second pass must not create
// another row.
runSystemTaskScheduler()
require.Equal(t, int64(1), countSystemTasks(t, handler.taskType))
// Finish the run; with a fresh updated_at the next run is not due yet.
latest, err := model.GetLatestSystemTask(handler.taskType)
require.NoError(t, err)
require.NotNil(t, latest)
_, claimed, err := model.ClaimSystemTask(latest.ID, handler.taskType, "runner-a", common.GetTimestamp()+60)
require.NoError(t, err)
require.True(t, claimed)
require.NoError(t, model.FinishSystemTask(latest.TaskID, "runner-a", model.SystemTaskStatusSucceeded, nil, ""))
runSystemTaskScheduler()
require.Equal(t, int64(1), countSystemTasks(t, handler.taskType))
// Backdate the finished row beyond the interval -> the job becomes due again.
require.NoError(t, model.DB.Model(&model.SystemTask{}).
Where("task_id = ?", latest.TaskID).
Update("updated_at", common.GetTimestamp()-120).Error)
runSystemTaskScheduler()
require.Equal(t, int64(2), countSystemTasks(t, handler.taskType))
}
func TestSystemTaskSchedulerSkipsDisabled(t *testing.T) {
truncate(t)
handler := &stubScheduledHandler{taskType: "test_disabled", enabled: false, interval: time.Minute}
withSystemTaskRegistry(t, handler)
runSystemTaskScheduler()
assert.Equal(t, int64(0), countSystemTasks(t, handler.taskType))
}
func TestSystemTaskClaimPassDispatchesByType(t *testing.T) {
truncate(t)
ran := make(chan stubSystemTaskRunResult, 1)
handler := &stubScheduledHandler{
taskType: "test_dispatch",
enabled: true,
interval: time.Minute,
onRun: func(_ context.Context, task *model.SystemTask, runnerID string) {
ran <- stubSystemTaskRunResult{
taskType: task.Type,
err: model.FinishSystemTask(task.TaskID, runnerID, model.SystemTaskStatusSucceeded, nil, ""),
}
},
}
withSystemTaskRegistry(t, handler)
_, err := model.CreateSystemTask(handler.taskType, nil, nil)
require.NoError(t, err)
runSystemTaskClaimPass("runner-dispatch")
select {
case got := <-ran:
require.NoError(t, got.err)
assert.Equal(t, handler.taskType, got.taskType)
case <-time.After(2 * time.Second):
t.Fatal("claimed task was not dispatched to its handler")
}
require.Eventually(t, func() bool {
latest, err := model.GetLatestSystemTask(handler.taskType)
return err == nil && latest != nil && latest.Status == model.SystemTaskStatusSucceeded
}, 2*time.Second, 20*time.Millisecond)
}
func TestSystemTaskClaimPassDispatchesEarliestPendingByType(t *testing.T) {
truncate(t)
ran := make(chan stubSystemTaskRunResult, 2)
handlerA := &stubScheduledHandler{
taskType: "test_dispatch_a",
enabled: true,
interval: time.Minute,
onRun: func(_ context.Context, task *model.SystemTask, runnerID string) {
ran <- stubSystemTaskRunResult{
taskID: task.TaskID,
err: model.FinishSystemTask(task.TaskID, runnerID, model.SystemTaskStatusSucceeded, nil, ""),
}
},
}
handlerB := &stubScheduledHandler{
taskType: "test_dispatch_b",
enabled: true,
interval: time.Minute,
onRun: func(_ context.Context, task *model.SystemTask, runnerID string) {
ran <- stubSystemTaskRunResult{
taskID: task.TaskID,
err: model.FinishSystemTask(task.TaskID, runnerID, model.SystemTaskStatusSucceeded, nil, ""),
}
},
}
withSystemTaskRegistry(t, handlerA, handlerB)
firstA, err := model.CreateSystemTask(handlerA.taskType, nil, nil)
require.NoError(t, err)
secondTaskID, err := model.GenerateSystemTaskID()
require.NoError(t, err)
secondA := &model.SystemTask{
TaskID: secondTaskID,
Type: handlerA.taskType,
Status: model.SystemTaskStatusPending,
}
require.NoError(t, model.DB.Create(secondA).Error)
firstB, err := model.CreateSystemTask(handlerB.taskType, nil, nil)
require.NoError(t, err)
runSystemTaskClaimPass("runner-dispatch")
got := map[string]bool{}
for range 2 {
select {
case result := <-ran:
require.NoError(t, result.err)
got[result.taskID] = true
case <-time.After(2 * time.Second):
t.Fatal("claimed tasks were not dispatched to their handlers")
}
}
assert.True(t, got[firstA.TaskID])
assert.True(t, got[firstB.TaskID])
assert.False(t, got[secondA.TaskID])
require.Eventually(t, func() bool {
reloaded, err := model.GetSystemTaskByTaskID(secondA.TaskID)
return err == nil && reloaded != nil && reloaded.Status == model.SystemTaskStatusPending
}, 2*time.Second, 20*time.Millisecond)
}
func TestEnqueueSystemTaskReportsCreatedAndExistingActive(t *testing.T) {
truncate(t)
first, created, err := EnqueueSystemTask("test_enqueue", map[string]bool{"manual": true})
require.NoError(t, err)
require.True(t, created)
require.NotNil(t, first)
existing, created, err := EnqueueSystemTask("test_enqueue", nil)
require.NoError(t, err)
require.False(t, created)
require.NotNil(t, existing)
assert.Equal(t, first.TaskID, existing.TaskID)
_, claimed, err := model.ClaimSystemTask(first.ID, first.Type, "runner-a", common.GetTimestamp()+60)
require.NoError(t, err)
require.True(t, claimed)
require.NoError(t, model.FinishSystemTask(first.TaskID, "runner-a", model.SystemTaskStatusSucceeded, nil, ""))
second, created, err := EnqueueSystemTask("test_enqueue", nil)
require.NoError(t, err)
require.True(t, created)
require.NotNil(t, second)
assert.NotEqual(t, first.TaskID, second.TaskID)
}
+11
View File
@@ -0,0 +1,11 @@
package service
import (
"strings"
"github.com/QuantumNous/new-api/constant"
)
func CoverTaskActionToModelName(platform constant.TaskPlatform, action string) string {
return strings.ToLower(string(platform)) + "_" + strings.ToLower(action)
}
+137
View File
@@ -0,0 +1,137 @@
package service
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"net/url"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/setting/system_setting"
)
const (
TaskArtifactAccessQueryParameter = "access"
taskArtifactAccessVersion = "v1"
taskArtifactAccessLength = 43
maxTaskArtifactTaskIDLength = 191
maxTaskArtifactKeyLength = 128
)
var ErrTaskArtifactAccessInvalid = errors.New("task artifact access is invalid")
func taskArtifactAccessMessage(taskID, artifactKey string) []byte {
return []byte(taskArtifactAccessVersion + "\x00" + taskID + "\x00" + artifactKey)
}
// IssueTaskArtifactAccess creates a stable capability bound to exactly one
// public task ID and artifact key. It contains no user or upstream data.
func IssueTaskArtifactAccess(taskID, artifactKey string) (string, error) {
taskID = strings.TrimSpace(taskID)
artifactKey = strings.TrimSpace(artifactKey)
if taskID == "" || len(taskID) > maxTaskArtifactTaskIDLength ||
artifactKey == "" || len(artifactKey) > maxTaskArtifactKeyLength ||
common.CryptoSecret == "" {
return "", ErrTaskArtifactAccessInvalid
}
mac := hmac.New(sha256.New, []byte(common.CryptoSecret))
_, _ = mac.Write(taskArtifactAccessMessage(taskID, artifactKey))
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)), nil
}
// VerifyTaskArtifactAccess verifies the route binding without reading task,
// user, or token state. Signature comparison is constant-time.
func VerifyTaskArtifactAccess(access, taskID, artifactKey string) bool {
taskID = strings.TrimSpace(taskID)
artifactKey = strings.TrimSpace(artifactKey)
if len(access) != taskArtifactAccessLength ||
taskID == "" || len(taskID) > maxTaskArtifactTaskIDLength ||
artifactKey == "" || len(artifactKey) > maxTaskArtifactKeyLength ||
common.CryptoSecret == "" {
return false
}
actualSignature, err := base64.RawURLEncoding.Strict().DecodeString(access)
if err != nil || len(actualSignature) != sha256.Size {
return false
}
mac := hmac.New(sha256.New, []byte(common.CryptoSecret))
_, _ = mac.Write(taskArtifactAccessMessage(taskID, artifactKey))
return hmac.Equal(actualSignature, mac.Sum(nil))
}
// ValidateTaskArtifactBaseURL validates configuration syntax only. It
// deliberately performs no DNS lookup or reachability probe.
func ValidateTaskArtifactBaseURL(raw string) error {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return errors.New("task artifact base URL is empty")
}
if raw != trimmed {
return errors.New("task artifact base URL must not contain surrounding whitespace")
}
raw = trimmed
parsed, err := url.Parse(raw)
if err != nil || parsed == nil {
return errors.New("task artifact base URL is invalid")
}
if !strings.EqualFold(parsed.Scheme, "http") && !strings.EqualFold(parsed.Scheme, "https") {
return errors.New("task artifact base URL must use http or https")
}
if parsed.Host == "" || parsed.User != nil || parsed.Opaque != "" {
return errors.New("task artifact base URL must contain a host and no userinfo")
}
if parsed.RawQuery != "" || parsed.ForceQuery || parsed.Fragment != "" || strings.Contains(raw, "#") {
return errors.New("task artifact base URL must not contain a query or fragment")
}
return nil
}
// BuildTaskArtifactContentURL returns an absolute, long-lived capability URL.
// TaskPublicAddress wins when configured; ServerAddress is the only fallback.
// Request Host headers are intentionally not involved.
func BuildTaskArtifactContentURL(taskID, artifactKey string) (string, error) {
taskID = strings.TrimSpace(taskID)
artifactKey = strings.TrimSpace(artifactKey)
if taskID == "" || len(taskID) > maxTaskArtifactTaskIDLength ||
artifactKey == "" || len(artifactKey) > maxTaskArtifactKeyLength {
return "", ErrTaskArtifactAccessInvalid
}
baseAddress := strings.TrimSpace(system_setting.TaskPublicAddress)
if baseAddress == "" {
baseAddress = strings.TrimSpace(system_setting.ServerAddress)
}
if err := ValidateTaskArtifactBaseURL(baseAddress); err != nil {
return "", err
}
baseURL, err := url.Parse(baseAddress)
if err != nil {
return "", err
}
access, err := IssueTaskArtifactAccess(taskID, artifactKey)
if err != nil {
return "", err
}
basePath := strings.TrimRight(baseURL.Path, "/")
escapedBasePath := strings.TrimRight(baseURL.EscapedPath(), "/")
suffixPath := fmt.Sprintf("/v1/tasks/%s/artifacts/%s/content", taskID, artifactKey)
escapedSuffixPath := fmt.Sprintf(
"/v1/tasks/%s/artifacts/%s/content",
url.PathEscape(taskID),
url.PathEscape(artifactKey),
)
baseURL.Path = basePath + suffixPath
baseURL.RawPath = escapedBasePath + escapedSuffixPath
query := baseURL.Query()
query.Set(TaskArtifactAccessQueryParameter, access)
baseURL.RawQuery = query.Encode()
return baseURL.String(), nil
}
+99
View File
@@ -0,0 +1,99 @@
package service
import (
"net/url"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/setting/system_setting"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestTaskArtifactAccessBindsTaskAndKey(t *testing.T) {
previousSecret := common.CryptoSecret
common.CryptoSecret = "task-artifact-access-test-secret"
t.Cleanup(func() { common.CryptoSecret = previousSecret })
access, err := IssueTaskArtifactAccess("task-1", "video-main")
require.NoError(t, err)
assert.Len(t, access, 43)
assert.NotContains(t, access, ".")
assert.True(t, VerifyTaskArtifactAccess(access, "task-1", "video-main"))
assert.False(t, VerifyTaskArtifactAccess(access, "task-2", "video-main"))
assert.False(t, VerifyTaskArtifactAccess(access, "task-1", "video-other"))
assert.False(t, VerifyTaskArtifactAccess(access+"x", "task-1", "video-main"))
common.CryptoSecret = "another-node-secret"
assert.False(t, VerifyTaskArtifactAccess(access, "task-1", "video-main"))
}
func TestBuildTaskArtifactContentURLUsesConfiguredAddressAndPreservesPrefix(t *testing.T) {
previousSecret := common.CryptoSecret
previousPublicAddress := system_setting.TaskPublicAddress
previousServerAddress := system_setting.ServerAddress
common.CryptoSecret = "task-artifact-url-test-secret"
system_setting.TaskPublicAddress = "https://media.example/gateway/prefix/"
system_setting.ServerAddress = "https://fallback.invalid"
t.Cleanup(func() {
common.CryptoSecret = previousSecret
system_setting.TaskPublicAddress = previousPublicAddress
system_setting.ServerAddress = previousServerAddress
})
contentURL, err := BuildTaskArtifactContentURL("task-public", "video-main")
require.NoError(t, err)
parsed, err := url.Parse(contentURL)
require.NoError(t, err)
assert.Equal(t, "media.example", parsed.Host)
assert.Equal(t, "/gateway/prefix/v1/tasks/task-public/artifacts/video-main/content", parsed.Path)
assert.True(t, VerifyTaskArtifactAccess(
parsed.Query().Get(TaskArtifactAccessQueryParameter),
"task-public",
"video-main",
))
}
func TestBuildTaskArtifactContentURLFallsBackOnlyToServerAddress(t *testing.T) {
previousSecret := common.CryptoSecret
previousPublicAddress := system_setting.TaskPublicAddress
previousServerAddress := system_setting.ServerAddress
common.CryptoSecret = "task-artifact-fallback-test-secret"
system_setting.TaskPublicAddress = ""
system_setting.ServerAddress = "https://gateway.example/root"
t.Cleanup(func() {
common.CryptoSecret = previousSecret
system_setting.TaskPublicAddress = previousPublicAddress
system_setting.ServerAddress = previousServerAddress
})
contentURL, err := BuildTaskArtifactContentURL("task-fallback", "audio")
require.NoError(t, err)
assert.Contains(t, contentURL, "https://gateway.example/root/v1/tasks/task-fallback/artifacts/audio/content")
system_setting.TaskPublicAddress = "not-a-url"
_, err = BuildTaskArtifactContentURL("task-fallback", "audio")
assert.Error(t, err)
}
func TestValidateTaskArtifactBaseURLOnlyAcceptsSafeAbsoluteHTTPURLs(t *testing.T) {
for _, valid := range []string{
"http://localhost:3000",
"https://gateway.example",
"https://gateway.example/prefix/path/",
} {
assert.NoError(t, ValidateTaskArtifactBaseURL(valid), valid)
}
for _, invalid := range []string{
"",
"/relative",
"ftp://gateway.example",
"https://user:secret@gateway.example",
"https://gateway.example/path?tenant=1",
"https://gateway.example/path#fragment",
" https://gateway.example",
"https://gateway.example ",
} {
assert.Error(t, ValidateTaskArtifactBaseURL(invalid), invalid)
}
}
+63
View File
@@ -0,0 +1,63 @@
package service
import (
"context"
"errors"
"io"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/setting/system_setting"
"github.com/QuantumNous/new-api/types"
"github.com/gin-gonic/gin"
)
// StoredArtifactRef describes a persisted artifact object. No reference is
// produced until a concrete storage backend is implemented.
type StoredArtifactRef struct {
Backend string
Bucket string
ObjectKey string
MimeType string
Size int64
}
// TaskArtifactStore is the persistence boundary for generated artifact bytes.
// types.TaskArtifact is re-exported by relay/channel as channel.TaskArtifact.
type TaskArtifactStore interface {
Enabled() bool
Resolve(task *model.Task, artifactKey string) (*StoredArtifactRef, error)
Persist(ctx context.Context, task *model.Task, artifact types.TaskArtifact, content io.Reader) (*StoredArtifactRef, error)
Serve(c *gin.Context, task *model.Task, ref *StoredArtifactRef) error
}
var ErrTaskArtifactStoreDisabled = errors.New("task artifact store is disabled")
type disabledArtifactStore struct{}
func (disabledArtifactStore) Enabled() bool {
return false
}
func (disabledArtifactStore) Resolve(*model.Task, string) (*StoredArtifactRef, error) {
return nil, nil
}
func (disabledArtifactStore) Persist(context.Context, *model.Task, types.TaskArtifact, io.Reader) (*StoredArtifactRef, error) {
return nil, ErrTaskArtifactStoreDisabled
}
func (disabledArtifactStore) Serve(*gin.Context, *model.Task, *StoredArtifactRef) error {
return ErrTaskArtifactStoreDisabled
}
var taskArtifactStore TaskArtifactStore = &disabledArtifactStore{}
func init() {
_ = system_setting.LoadTaskArtifactStoreConfig()
}
// GetTaskArtifactStore returns the process-wide artifact storage backend. This
// release always returns the disabled implementation.
func GetTaskArtifactStore() TaskArtifactStore {
return taskArtifactStore
}
+31
View File
@@ -0,0 +1,31 @@
package service
import (
"strings"
"testing"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/types"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestDisabledTaskArtifactStoreHasNoStorageBehavior(t *testing.T) {
store := GetTaskArtifactStore()
require.NotNil(t, store)
assert.False(t, store.Enabled())
task := &model.Task{TaskID: "task-disabled-store"}
ref, err := store.Resolve(task, "video")
require.NoError(t, err)
assert.Nil(t, ref)
ref, err = store.Persist(t.Context(), task, types.TaskArtifact{Key: "video", Type: "video"}, strings.NewReader("content"))
assert.Nil(t, ref)
assert.ErrorIs(t, err, ErrTaskArtifactStoreDisabled)
assert.ErrorIs(t, store.Serve(&gin.Context{}, task, &StoredArtifactRef{Backend: "s3"}), ErrTaskArtifactStoreDisabled)
assert.Same(t, store, GetTaskArtifactStore())
}
var _ TaskArtifactStore = disabledArtifactStore{}
+381
View File
@@ -0,0 +1,381 @@
package service
import (
"context"
"encoding/base64"
"fmt"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/setting/ratio_setting"
"github.com/QuantumNous/new-api/types"
"github.com/gin-gonic/gin"
)
// LogTaskConsumption 记录任务消费日志和统计信息(仅记录,不涉及实际扣费)。
// 实际扣费已由 BillingSession(PreConsumeBilling + SettleBilling)完成。
func LogTaskConsumption(c *gin.Context, info *relaycommon.RelayInfo, task *model.Task) {
tokenName := c.GetString("token_name")
logContent := fmt.Sprintf("操作 %s", info.Action)
// 支持任务仅按次计费
if common.StringsContains(constant.TaskPricePatches, info.OriginModelName) {
logContent = fmt.Sprintf("%s,按次计费", logContent)
} else {
var contents []string
if otherRatios := info.PriceData.OtherRatios(); len(otherRatios) > 0 {
for key, ra := range otherRatios {
if 1.0 != ra {
contents = append(contents, fmt.Sprintf("%s: %.2f", key, ra))
}
}
}
if snap := info.TieredBillingSnapshot; snap != nil {
for key, value := range snap.UsageFacts {
contents = append(contents, fmt.Sprintf("%s: %v", key, value))
}
}
if len(contents) > 0 {
logContent = fmt.Sprintf("%s, 计算参数:%s", logContent, strings.Join(contents, ", "))
}
}
other := model.NewLogOther()
other.SetPublic("is_task", true)
other.SetPublic("request_path", c.Request.URL.Path)
other.SetPublic("model_price", info.PriceData.ModelPrice)
if info.PriceData.ModelRatio > 0 {
other.SetPublic("model_ratio", info.PriceData.ModelRatio)
}
other.SetPublic("group_ratio", info.PriceData.GroupRatioInfo.GroupRatio)
if info.PriceData.GroupRatioInfo.HasSpecialRatio {
other.SetPublic("user_group_ratio", info.PriceData.GroupRatioInfo.GroupSpecialRatio)
}
if info.IsModelMapped {
other.SetPublic("is_model_mapped", true)
other.SetPublic("upstream_model_name", info.UpstreamModelName)
}
if snap := info.TieredBillingSnapshot; snap != nil {
other.SetPublic("billing_mode", "tiered_expr")
other.SetPublic("expr_b64", base64.StdEncoding.EncodeToString([]byte(snap.ExprString)))
other.SetPublic("matched_tier", snap.EstimatedTier)
if len(snap.UsageFacts) > 0 {
other.SetPublic("usage_facts", snap.UsageFacts)
}
}
appendTaskLogInfo(task, other)
attachQuotaSaturation(c, info, other)
model.RecordConsumeLog(c, info.UserId, model.RecordConsumeLogParams{
ChannelId: info.ChannelId,
ModelName: info.OriginModelName,
TokenName: tokenName,
Quota: info.PriceData.Quota,
Content: logContent,
TokenId: info.TokenId,
Group: info.UsingGroup,
Other: other,
})
model.UpdateUserUsedQuotaAndRequestCount(info.UserId, info.PriceData.Quota)
model.UpdateChannelUsedQuota(info.ChannelId, info.PriceData.Quota)
}
// ---------------------------------------------------------------------------
// 异步任务计费辅助函数
// ---------------------------------------------------------------------------
// resolveTokenKey 通过 TokenId 运行时获取令牌 Key(用于 Redis 缓存操作)。
// 如果令牌已被删除或查询失败,返回空字符串。
func resolveTokenKey(ctx context.Context, tokenId int, taskID string) string {
token, err := model.GetTokenById(tokenId)
if err != nil {
logger.LogWarn(ctx, fmt.Sprintf("获取令牌 key 失败 (tokenId=%d, task=%s): %s", tokenId, taskID, err.Error()))
return ""
}
return token.Key
}
// taskIsSubscription 判断任务是否通过订阅计费。
func taskIsSubscription(task *model.Task) bool {
return task.PrivateData.BillingSource == BillingSourceSubscription && task.PrivateData.SubscriptionId > 0
}
// taskAdjustFunding 调整任务的资金来源(钱包或订阅),delta > 0 表示扣费,delta < 0 表示退还。
func taskAdjustFunding(task *model.Task, delta int) error {
if taskIsSubscription(task) {
return model.PostConsumeUserSubscriptionDelta(task.PrivateData.SubscriptionId, int64(delta))
}
if delta > 0 {
return model.DecreaseUserQuota(task.UserId, delta, false)
}
return model.IncreaseUserQuota(task.UserId, -delta, false)
}
// taskAdjustTokenQuota 调整任务的令牌额度,delta > 0 表示扣费,delta < 0 表示退还。
// 需要通过 resolveTokenKey 运行时获取 key(不从 PrivateData 中读取)。
func taskAdjustTokenQuota(ctx context.Context, task *model.Task, delta int) {
if task.PrivateData.TokenId <= 0 || delta == 0 {
return
}
tokenKey := resolveTokenKey(ctx, task.PrivateData.TokenId, task.TaskID)
if tokenKey == "" {
return
}
var err error
if delta > 0 {
err = model.DecreaseTokenQuota(task.PrivateData.TokenId, tokenKey, delta)
} else {
err = model.IncreaseTokenQuota(task.PrivateData.TokenId, tokenKey, -delta)
}
if err != nil {
logger.LogWarn(ctx, fmt.Sprintf("调整令牌额度失败 (delta=%d, task=%s): %s", delta, task.TaskID, err.Error()))
}
}
// taskBillingOther 从 task 的 BillingContext 构建日志 Other 字段。
func taskBillingOther(task *model.Task) *model.LogOther {
other := model.NewLogOther()
if bc := task.PrivateData.BillingContext; bc != nil {
other.SetPublic("model_price", bc.ModelPrice)
if bc.ModelRatio > 0 {
other.SetPublic("model_ratio", bc.ModelRatio)
}
other.SetPublic("group_ratio", bc.GroupRatio)
if priceData := taskBillingContextPriceData(bc); priceData != nil {
for k, v := range priceData.OtherRatios() {
if !other.SetPublic(k, v) {
common.SysError("task billing other ratio key rejected: " + k)
}
}
}
if snap := bc.TieredSnapshot; snap != nil {
other.SetPublic("billing_mode", "tiered_expr")
other.SetPublic("expr_b64", base64.StdEncoding.EncodeToString([]byte(snap.ExprString)))
other.SetPublic("matched_tier", snap.EstimatedTier)
if len(snap.UsageFacts) > 0 {
other.SetPublic("usage_facts", snap.UsageFacts)
}
}
}
props := task.Properties
if props.UpstreamModelName != "" && props.UpstreamModelName != props.OriginModelName {
other.SetPublic("is_model_mapped", true)
other.SetPublic("upstream_model_name", props.UpstreamModelName)
}
appendTaskLogInfo(task, other)
return other
}
func appendTaskLogInfo(task *model.Task, other *model.LogOther) {
if task == nil || other == nil {
return
}
if task.TaskID != "" {
other.SetPublic("task_id", task.TaskID)
}
if task.PrivateData.Execution != nil {
AppendTaskPluginAuditInfo(other, task.PrivateData.Execution.TaskPlugin)
}
if task.PrivateData.UpstreamTaskID == "" && task.PrivateData.NodeName == "" {
return
}
if task.PrivateData.UpstreamTaskID != "" {
other.SetRoot("upstream_task_id", task.PrivateData.UpstreamTaskID)
}
if task.PrivateData.NodeName != "" {
other.SetRoot("node_name", task.PrivateData.NodeName)
}
}
func taskBillingContextPriceData(bc *model.TaskBillingContext) *types.PriceData {
if bc == nil || len(bc.OtherRatios) == 0 {
return nil
}
priceData := &types.PriceData{}
if !priceData.ReplaceOtherRatios(bc.OtherRatios) {
return nil
}
return priceData
}
// taskModelName 从 BillingContext 或 Properties 中获取模型名称。
func taskModelName(task *model.Task) string {
if bc := task.PrivateData.BillingContext; bc != nil && bc.OriginModelName != "" {
return bc.OriginModelName
}
return task.Properties.OriginModelName
}
// RefundTaskQuota 统一的任务失败退款逻辑。
// 当异步任务失败时,退还资金与令牌额度,并回减用户和渠道用量。
// 返回资金来源是否已成功退还;失败时保留 quota,供显式重试或人工对账。
func RefundTaskQuota(ctx context.Context, task *model.Task, reason string) bool {
quota := task.Quota
if quota == 0 {
return true
}
// 1. 退还资金来源(钱包或订阅)
if err := taskAdjustFunding(task, -quota); err != nil {
logger.LogWarn(ctx, fmt.Sprintf("退还资金来源失败 task %s: %s", task.TaskID, err.Error()))
return false
}
// 2. 退还令牌额度
taskAdjustTokenQuota(ctx, task, -quota)
// 3. 回减预扣时累计的用户和渠道用量,请求次数保持不变
model.UpdateUserUsedQuota(task.UserId, -quota)
model.UpdateChannelUsedQuota(task.ChannelId, -quota)
// 4. 记录日志
other := taskBillingOther(task)
other.SetPublic("task_id", task.TaskID)
other.SetPublic("reason", reason)
model.RecordTaskBillingLog(model.RecordTaskBillingLogParams{
UserId: task.UserId,
LogType: model.LogTypeRefund,
Content: "",
ChannelId: task.ChannelId,
ModelName: taskModelName(task),
Quota: quota,
TokenId: task.PrivateData.TokenId,
Group: task.Group,
Other: other,
})
// 5. 资金退款完成后再清除持久化标记。
// 回写失败必须显式告警,避免漏掉潜在的重复退款风险。
task.Quota = 0
if err := task.UpdateQuota(); err != nil {
logger.LogError(ctx, fmt.Sprintf("退款成功但清除 task quota 失败 task %s: %s", task.TaskID, err.Error()))
}
return true
}
// RecalculateTaskQuota 通用的异步差额结算。
// actualQuota 是任务完成后的实际应扣额度,与预扣额度 (task.Quota) 做差额结算。
// reason 用于日志记录(例如 "token重算" 或 "adaptor调整")。
// clamps 可选:若计算 actualQuota 时发生额度饱和,将其记入日志 admin_info(仅管理员可见)。
func RecalculateTaskQuota(ctx context.Context, task *model.Task, actualQuota int, reason string, clamps ...*common.QuotaClamp) {
if actualQuota < 0 {
return
}
preConsumedQuota := task.Quota
quotaDelta := actualQuota - preConsumedQuota
if quotaDelta == 0 {
logger.LogInfo(ctx, fmt.Sprintf("任务 %s 预扣费准确(%s,%s)",
task.TaskID, logger.LogQuota(actualQuota), reason))
return
}
logger.LogInfo(ctx, fmt.Sprintf("任务 %s 差额结算:delta=%s(实际:%s,预扣:%s,%s)",
task.TaskID,
logger.LogQuota(quotaDelta),
logger.LogQuota(actualQuota),
logger.LogQuota(preConsumedQuota),
reason,
))
// 调整资金来源
if err := taskAdjustFunding(task, quotaDelta); err != nil {
logger.LogError(ctx, fmt.Sprintf("差额结算资金调整失败 task %s: %s", task.TaskID, err.Error()))
return
}
// 调整令牌额度
taskAdjustTokenQuota(ctx, task, quotaDelta)
task.Quota = actualQuota
if err := task.UpdateQuota(); err != nil {
logger.LogError(ctx, fmt.Sprintf("差额结算回写 quota 失败 task %s: %s", task.TaskID, err.Error()))
}
// 提交阶段已经累计过一次请求;结算阶段只调整最终用量。
model.UpdateUserUsedQuota(task.UserId, quotaDelta)
model.UpdateChannelUsedQuota(task.ChannelId, quotaDelta)
var logType int
var logQuota int
if quotaDelta > 0 {
logType = model.LogTypeConsume
logQuota = quotaDelta
} else {
logType = model.LogTypeRefund
logQuota = -quotaDelta
}
other := taskBillingOther(task)
other.SetPublic("task_id", task.TaskID)
other.SetPublic("pre_consumed_quota", preConsumedQuota)
other.SetPublic("actual_quota", actualQuota)
for _, clamp := range clamps {
attachQuotaSaturationToOther(other, clamp)
}
model.RecordTaskBillingLog(model.RecordTaskBillingLogParams{
UserId: task.UserId,
LogType: logType,
Content: reason,
ChannelId: task.ChannelId,
ModelName: taskModelName(task),
Quota: logQuota,
TokenId: task.PrivateData.TokenId,
Group: task.Group,
Other: other,
NodeName: task.PrivateData.NodeName,
})
}
// RecalculateTaskQuotaByTokens 根据实际 token 消耗重新计费(异步差额结算)。
// 当任务成功且返回了 totalTokens 时,根据模型倍率和分组倍率重新计算实际扣费额度,
// 与预扣费的差额进行补扣或退还。支持钱包和订阅计费来源。
func RecalculateTaskQuotaByTokens(ctx context.Context, task *model.Task, totalTokens int) bool {
if totalTokens <= 0 {
return false
}
modelName := taskModelName(task)
// 获取模型价格和倍率
modelRatio, hasRatioSetting, _ := ratio_setting.GetModelRatio(modelName)
// 只有配置了倍率(非固定价格)时才按 token 重新计费
if !hasRatioSetting || modelRatio <= 0 {
return false
}
// 获取用户和组的倍率信息
group := task.Group
if group == "" {
user, err := model.GetUserById(task.UserId, false)
if err == nil {
group = user.Group
}
}
if group == "" {
return false
}
groupRatio := ratio_setting.GetGroupRatio(group)
userGroupRatio, hasUserGroupRatio := ratio_setting.GetGroupGroupRatio(group, group)
var finalGroupRatio float64
if hasUserGroupRatio {
finalGroupRatio = userGroupRatio
} else {
finalGroupRatio = groupRatio
}
// 计算 OtherRatios 乘积(视频折扣、时长等)
otherMultiplier := 1.0
if priceData := taskBillingContextPriceData(task.PrivateData.BillingContext); priceData != nil {
otherMultiplier = priceData.OtherRatioMultiplier()
}
// 计算实际应扣费额度: totalTokens * modelRatio * groupRatio * otherMultiplier(饱和转换,防止溢出成负数)
actualQuota, clamp := common.QuotaFromFloatChecked(float64(totalTokens) * modelRatio * finalGroupRatio * otherMultiplier)
reason := fmt.Sprintf("token重算:tokens=%d, modelRatio=%.2f, groupRatio=%.2f, otherMultiplier=%.4f", totalTokens, modelRatio, finalGroupRatio, otherMultiplier)
RecalculateTaskQuota(ctx, task, actualQuota, reason, clamp)
return true
}
File diff suppressed because it is too large. Load diff
+87
View File
@@ -0,0 +1,87 @@
package service
import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
pluginruntime "github.com/QuantumNous/new-api/pkg/jsplugin"
"github.com/gin-gonic/gin"
)
// TaskExecutionSnapshotFromContext captures immutable request and plugin
// provenance at submission time. It never copies plugin source or payloads.
func TaskExecutionSnapshotFromContext(ctx *gin.Context) *model.TaskExecutionSnapshot {
if ctx == nil {
return nil
}
snapshot := &model.TaskExecutionSnapshot{
RequestID: ctx.GetString(common.RequestIdKey),
}
if ctx.Request != nil && ctx.Request.URL != nil {
snapshot.RequestPath = ctx.Request.URL.Path
}
pinnedValue, exists := ctx.Get(pluginruntime.ContextKeyPinnedPlugin)
if exists {
pinned, ok := pinnedValue.(pluginruntime.PinnedPlugin)
if ok && pinned.Plugin != nil {
generation := uint64(0)
if pinned.Generation != nil {
generation = pinned.Generation.Number
}
meta := pinned.Plugin.Meta
snapshot.TaskPlugin = &model.TaskPluginSnapshot{
Key: meta.Key,
Name: meta.Name,
Version: meta.Version,
Author: &model.TaskPluginAuthorSnapshot{
Name: meta.Author.Name,
URL: meta.Author.URL,
},
APIVersion: meta.APIVersion,
Generation: generation,
}
}
}
if snapshot.RequestID == "" && snapshot.RequestPath == "" && snapshot.TaskPlugin == nil {
return nil
}
return snapshot
}
// AppendTaskPluginAuditInfo writes role-separated, credential-free plugin
// provenance into a usage log.
func AppendTaskPluginAuditInfo(other *model.LogOther, snapshot *model.TaskPluginSnapshot) {
if other == nil || snapshot == nil || snapshot.Key == "" {
return
}
taskPlugin := map[string]any{
"key": snapshot.Key,
"name": snapshot.Name,
"version": snapshot.Version,
}
if snapshot.Author != nil && snapshot.Author.Name != "" {
author := map[string]any{"name": snapshot.Author.Name}
if snapshot.Author.URL != "" {
author["url"] = snapshot.Author.URL
}
taskPlugin["author"] = author
}
other.SetAdmin("task_plugin", taskPlugin)
other.SetRoot("task_plugin", map[string]any{
"key": snapshot.Key,
"version": snapshot.Version,
"api_version": snapshot.APIVersion,
"generation": snapshot.Generation,
})
}
// AppendTaskPluginContextAuditInfo is used before a task row exists, such as
// an upstream submission error log.
func AppendTaskPluginContextAuditInfo(ctx *gin.Context, other *model.LogOther) {
execution := TaskExecutionSnapshotFromContext(ctx)
if execution == nil {
return
}
AppendTaskPluginAuditInfo(other, execution.TaskPlugin)
}
+61
View File
@@ -0,0 +1,61 @@
package service
import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/model"
)
// BuildTaskPluginView converts a persisted task into the deliberately narrow
// public shape permitted at JavaScript plugin boundaries.
func BuildTaskPluginView(task *model.Task) (dto.TaskView, error) {
createdAt := task.CreatedAt
if createdAt == 0 {
createdAt = task.SubmitTime
}
view := dto.TaskView{
TaskID: task.TaskID,
Platform: string(task.Platform),
Status: string(task.Status),
Progress: task.Progress,
FailReason: task.FailReason,
CreatedAt: createdAt,
UpdatedAt: task.UpdatedAt,
FinishedAt: task.FinishTime,
}
if len(task.Data) > 0 {
if err := common.Unmarshal(task.Data, &view.Data); err != nil {
return dto.TaskView{}, err
}
view.Data = replacePrivateTaskID(view.Data, task.PrivateData.UpstreamTaskID, task.TaskID)
}
return view, nil
}
// replacePrivateTaskID rewrites exact private IDs only in known task-ID fields.
// Map keys and opaque strings, including URLs containing the ID, are preserved.
func replacePrivateTaskID(value any, privateTaskID, publicTaskID string) any {
if privateTaskID == "" || privateTaskID == publicTaskID {
return value
}
switch typed := value.(type) {
case []any:
replaced := make([]any, len(typed))
for index, item := range typed {
replaced[index] = replacePrivateTaskID(item, privateTaskID, publicTaskID)
}
return replaced
case map[string]any:
replaced := make(map[string]any, len(typed))
for key, item := range typed {
if (key == "id" || key == "task_id" || key == "taskId") && item == privateTaskID {
replaced[key] = publicTaskID
continue
}
replaced[key] = replacePrivateTaskID(item, privateTaskID, publicTaskID)
}
return replaced
default:
return value
}
}
+85
View File
@@ -0,0 +1,85 @@
package service
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
)
func TestBuildTaskPluginViewRewritesOnlyStructuredTaskIDFields(t *testing.T) {
const (
privateTaskID = "upstream-task-123"
publicTaskID = "task_public_123"
resultURL = "https://cdn.example.com/results/upstream-task-123/video.mp4"
)
taskData, err := common.Marshal(map[string]any{
"task_id": privateTaskID,
"id": privateTaskID,
"taskId": privateTaskID,
"url": resultURL,
"message": "completed upstream-task-123",
"nested": []any{
map[string]any{
"task_id": privateTaskID,
"url": resultURL,
},
privateTaskID,
},
privateTaskID: "opaque map key",
})
require.NoError(t, err)
task := &model.Task{
TaskID: publicTaskID,
PrivateData: model.TaskPrivateData{
UpstreamTaskID: privateTaskID,
},
Data: taskData,
}
view, err := BuildTaskPluginView(task)
require.NoError(t, err)
data, ok := view.Data.(map[string]any)
require.True(t, ok)
assert.Equal(t, publicTaskID, data["task_id"])
assert.Equal(t, publicTaskID, data["id"])
assert.Equal(t, publicTaskID, data["taskId"])
assert.Equal(t, resultURL, data["url"])
assert.Equal(t, "completed upstream-task-123", data["message"])
assert.Equal(t, "opaque map key", data[privateTaskID])
nested, ok := data["nested"].([]any)
require.True(t, ok)
nestedData, ok := nested[0].(map[string]any)
require.True(t, ok)
assert.Equal(t, publicTaskID, nestedData["task_id"])
assert.Equal(t, resultURL, nestedData["url"])
assert.Equal(t, privateTaskID, nested[1])
}
func TestBuildTaskPluginViewOmitsPrivatePollState(t *testing.T) {
task := &model.Task{
TaskID: "task_public_view",
Data: []byte(`{"ok":true}`),
PrivateData: model.TaskPrivateData{
PluginState: []byte(`{"req_key":"secret"}`),
PollFailures: 7,
},
}
view, err := BuildTaskPluginView(task)
require.NoError(t, err)
encoded, err := common.Marshal(view)
require.NoError(t, err)
var payload map[string]any
require.NoError(t, common.Unmarshal(encoded, &payload))
assert.NotContains(t, payload, "plugin_state")
assert.NotContains(t, payload, "poll_failures")
assert.NotContains(t, payload, "private_data")
}
+836
View File
@@ -0,0 +1,836 @@
package service
import (
"context"
"fmt"
"io"
"maps"
"net/http"
"sort"
"strings"
"sync"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
taskdto "github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/pkg/billingexpr"
"github.com/QuantumNous/new-api/relay/channel/task/taskcommon"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/bytedance/gopkg/util/gopool"
"github.com/samber/lo"
)
// TaskPollingAdaptor 定义轮询所需的最小适配器接口,避免 service -> relay 的循环依赖
type TaskPollingAdaptor interface {
Init(info *relaycommon.RelayInfo)
FetchTask(baseURL string, key string, task *model.Task, proxy string) (*http.Response, error)
ParseTaskResult(task *model.Task, resp *http.Response, body []byte) (*relaycommon.TaskInfo, error)
// AdjustBillingOnComplete 在任务到达终态(成功/失败)时由轮询循环调用。
// 返回正数触发差额结算(补扣/退还),返回 0 保持预扣费金额不变。
AdjustBillingOnComplete(task *model.Task, taskResult *relaycommon.TaskInfo) int
}
type BatchTaskPollingAdaptor interface {
TaskPollingAdaptor
FetchMode() string
FetchBatchTasks(baseURL, key string, tasks []*model.Task, proxy string) (*http.Response, error)
ParseBatchResult(tasks []*model.Task, resp *http.Response, body []byte) (map[string]*BatchTaskResult, error)
}
const (
pollClassOK = "ok"
pollClassOtherClient = "other_client"
pollClassNotFound = "not_found"
pollClassAuth = "auth"
pollClassTransient = "transient"
pollClassUnrecognized = "unrecognized"
pollClassHookError = "hook_error"
pollClassTransport = "transport_error"
)
type BatchTaskResult struct {
TaskInfo relaycommon.TaskInfo
Action string
SubmitTime int64
StartTime int64
FinishTime int64
Data any
}
// GetTaskAdaptorFunc 由 main 包注入,用于获取指定平台的任务适配器。
// 打破 service -> relay -> relay/channel -> service 的循环依赖。
var GetTaskAdaptorFunc func(platform constant.TaskPlatform) TaskPollingAdaptor
// sweepTimedOutTasks 在主轮询之前独立清理超时任务。
// 每次最多处理 100 条,剩余的下个周期继续处理。
// 使用 per-task CAS (UpdateWithStatus) 防止覆盖被正常轮询已推进的任务。
func sweepTimedOutTasks(ctx context.Context) {
if constant.TaskTimeoutMinutes <= 0 {
return
}
cutoff := time.Now().Unix() - int64(constant.TaskTimeoutMinutes)*60
tasks := model.GetTimedOutUnfinishedTasks(cutoff, 100)
if len(tasks) == 0 {
return
}
reason := fmt.Sprintf("任务超时(%d分钟)", constant.TaskTimeoutMinutes)
legacyReason := "任务超时(旧系统遗留任务,不进行退款,请联系管理员)"
now := time.Now().Unix()
timedOutCount := 0
for _, task := range tasks {
isLegacy := task.SubmitTime > 0 && task.SubmitTime < model.TaskRefundLegacyCutoff
oldStatus := task.Status
task.Status = model.TaskStatusFailure
task.Progress = "100%"
task.FinishTime = now
if isLegacy {
task.FailReason = legacyReason
// 旧系统任务明确不退款,随终态 CAS 一并清掉 quota,
// 避免留下可再次退款的计费状态。
task.Quota = 0
} else {
task.FailReason = reason
}
won, err := task.UpdateWithStatus(oldStatus)
if err != nil {
logger.LogError(ctx, fmt.Sprintf("sweepTimedOutTasks CAS update error for task %s: %v", task.TaskID, err))
continue
}
if !won {
logger.LogInfo(ctx, fmt.Sprintf("sweepTimedOutTasks: task %s already transitioned, skip", task.TaskID))
continue
}
timedOutCount++
if !isLegacy && task.Quota != 0 {
RefundTaskQuota(ctx, task, reason)
}
}
if timedOutCount > 0 {
logger.LogInfo(ctx, fmt.Sprintf("sweepTimedOutTasks: timed out %d tasks", timedOutCount))
}
}
// TaskPollSummary is the result recorded on an async_task_poll system task row,
// summarizing one polling pass.
type TaskPollSummary struct {
UnfinishedTasks int `json:"unfinished_tasks"`
PlatformsScanned int `json:"platforms_scanned"`
NullTasksFailed int `json:"null_tasks_failed"`
}
// RunTaskPollingOnce performs one async-task (Suno/video) polling pass
// synchronously. It honors ctx cancellation (the system-task runner cancels it
// when the lease is lost) and, when report is non-nil, reports progress as
// (processedPlatforms, totalPlatforms). It returns immediately if the task
// adaptor factory has not been wired yet, to avoid a nil call during startup.
func RunTaskPollingOnce(ctx context.Context, report func(processed, total int)) TaskPollSummary {
summary := TaskPollSummary{}
if GetTaskAdaptorFunc == nil {
return summary
}
if ctx == nil {
ctx = context.Background()
}
common.SysLog("任务进度轮询开始")
sweepTimedOutTasks(ctx)
allTasks := model.GetAllUnFinishSyncTasks(constant.TaskQueryLimit)
summary.UnfinishedTasks = len(allTasks)
platformTask := make(map[constant.TaskPlatform][]*model.Task)
for _, t := range allTasks {
platformTask[t.Platform] = append(platformTask[t.Platform], t)
}
totalPlatforms := len(platformTask)
processedPlatforms := 0
for platform, tasks := range platformTask {
if ctx.Err() != nil {
break
}
if report != nil {
report(processedPlatforms, totalPlatforms)
}
processedPlatforms++
if len(tasks) == 0 {
continue
}
summary.PlatformsScanned++
taskChannelM := make(map[int][]string)
taskM := make(map[string]*model.Task)
nullTaskIds := make([]int64, 0)
for _, task := range tasks {
upstreamID := task.GetUpstreamTaskID()
if upstreamID == "" {
// 统计失败的未完成任务
nullTaskIds = append(nullTaskIds, task.ID)
continue
}
taskM[upstreamID] = task
taskChannelM[task.ChannelId] = append(taskChannelM[task.ChannelId], upstreamID)
}
if len(nullTaskIds) > 0 {
summary.NullTasksFailed += len(nullTaskIds)
err := model.TaskBulkUpdateByID(nullTaskIds, map[string]any{
"status": "FAILURE",
"progress": "100%",
})
if err != nil {
logger.LogError(ctx, fmt.Sprintf("Fix null task_id task error: %v", err))
} else {
logger.LogInfo(ctx, fmt.Sprintf("Fix null task_id task success: %v", nullTaskIds))
}
}
if len(taskChannelM) == 0 {
continue
}
DispatchPlatformUpdate(ctx, platform, taskChannelM, taskM)
}
if report != nil && ctx.Err() == nil {
report(totalPlatforms, totalPlatforms)
}
common.SysLog("任务进度轮询完成")
return summary
}
// DispatchPlatformUpdate 按平台分发轮询更新
func DispatchPlatformUpdate(ctx context.Context, platform constant.TaskPlatform, taskChannelM map[int][]string, taskM map[string]*model.Task) {
if ctx == nil {
ctx = context.Background()
}
if platform == constant.TaskPlatformMidjourney {
// MJ 轮询由其自身处理,这里预留入口
return
}
adaptor := GetTaskAdaptorFunc(platform)
if batchAdaptor, ok := adaptor.(BatchTaskPollingAdaptor); ok && batchAdaptor.FetchMode() == "batch" {
if err := UpdateBatchTasks(ctx, batchAdaptor, taskChannelM, taskM); err != nil {
common.SysLog(fmt.Sprintf("UpdateBatchTasks fail: %s", err))
}
return
}
if err := UpdateVideoTasks(ctx, platform, taskChannelM, taskM); err != nil {
common.SysLog(fmt.Sprintf("UpdateVideoTasks fail: %s", err))
}
}
func UpdateBatchTasks(ctx context.Context, adaptor BatchTaskPollingAdaptor, taskChannelM map[int][]string, taskM map[string]*model.Task) error {
for channelId, taskIds := range taskChannelM {
if ctx.Err() != nil {
return ctx.Err()
}
err := updateBatchTasks(ctx, adaptor, channelId, taskIds, taskM)
if err != nil {
logger.LogError(ctx, fmt.Sprintf("渠道 #%d 更新异步任务失败: %s", channelId, err.Error()))
}
}
return nil
}
func updateBatchTasks(ctx context.Context, adaptor BatchTaskPollingAdaptor, channelId int, taskIds []string, taskM map[string]*model.Task) error {
logger.LogInfo(ctx, fmt.Sprintf("渠道 #%d 未完成的任务有: %d", channelId, len(taskIds)))
if ctx.Err() != nil {
return ctx.Err()
}
if len(taskIds) == 0 {
return nil
}
ch, err := model.CacheGetChannel(channelId)
if err != nil {
common.SysLog(fmt.Sprintf("CacheGetChannel: %v", err))
// Collect DB primary key IDs for bulk update (taskIds are upstream IDs, not task_id column values)
var failedIDs []int64
for _, upstreamID := range taskIds {
if t, ok := taskM[upstreamID]; ok {
failedIDs = append(failedIDs, t.ID)
}
}
err = model.TaskBulkUpdateByID(failedIDs, map[string]any{
"fail_reason": fmt.Sprintf("获取渠道信息失败,请联系管理员,渠道ID:%d", channelId),
"status": "FAILURE",
"progress": "100%",
})
if err != nil {
common.SysLog(fmt.Sprintf("UpdateSunoTask error: %v", err))
}
return err
}
proxy := ch.GetSetting().Proxy
baseURL := ch.GetBaseURL()
if baseURL == "" {
baseURL = constant.GetChannelBaseURL(ch.Type)
}
tasks := make([]*model.Task, 0, len(taskIds))
for _, upstreamID := range taskIds {
if task := taskM[upstreamID]; task != nil {
tasks = append(tasks, task)
}
}
info := &relaycommon.RelayInfo{}
info.ChannelMeta = &relaycommon.ChannelMeta{ChannelBaseUrl: baseURL}
info.ApiKey = ch.Key
adaptor.Init(info)
resp, err := adaptor.FetchBatchTasks(baseURL, ch.Key, tasks, proxy)
if err != nil {
common.SysLog(fmt.Sprintf("Get Task Do req error: %v", err))
return recordPollFailureForTasks(ctx, adaptor, tasks, pollClassTransport, 0, err.Error())
}
defer resp.Body.Close()
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
common.SysLog(fmt.Sprintf("Get Suno Task parse body error: %v", err))
return recordPollFailureForTasks(ctx, adaptor, tasks, pollClassTransport, resp.StatusCode, err.Error())
}
switch classifyPollHTTP(resp.StatusCode) {
case pollClassNotFound:
return failTasksFromPoll(ctx, adaptor, tasks, fmt.Sprintf("upstream task not found (HTTP %d)", resp.StatusCode))
case pollClassAuth:
logger.LogWarn(ctx, fmt.Sprintf("task poll auth failure channel_id=%d http=%d", channelId, resp.StatusCode))
return recordPollFailureForTasks(ctx, adaptor, tasks, pollClassAuth, resp.StatusCode, "")
case pollClassTransient:
return recordPollFailureForTasks(ctx, adaptor, tasks, pollClassTransient, resp.StatusCode, "")
}
responseItems, err := adaptor.ParseBatchResult(tasks, resp, responseBody)
if err != nil {
return recordPollFailureForTasks(ctx, adaptor, tasks, pollClassHookError, resp.StatusCode, err.Error())
}
for upstreamID, responseItem := range responseItems {
if ctx.Err() != nil {
return ctx.Err()
}
task := taskM[upstreamID]
if task == nil {
logger.LogWarn(ctx, fmt.Sprintf("Batch task response ignored: unknown task_id=%s", upstreamID))
continue
}
snap := task.Snapshot()
httpClass := classifyPollHTTP(resp.StatusCode)
parsedStatus := model.TaskStatus(responseItem.TaskInfo.Status)
if parsedStatus == model.TaskStatusUnknown || parsedStatus == "" || !knownPollStatus(parsedStatus) {
if err := recordPollFailure(ctx, adaptor, task, snap.Status, pollClassUnrecognized, resp.StatusCode, responseItem.TaskInfo.Reason); err != nil {
common.SysLog("UpdateSunoTask task error: " + err.Error())
}
continue
}
if httpClass == pollClassOtherClient && isNonTerminalPollStatus(parsedStatus) {
if err := recordPollFailure(ctx, adaptor, task, snap.Status, pollClassUnrecognized, resp.StatusCode, responseItem.TaskInfo.Reason); err != nil {
common.SysLog("UpdateSunoTask task error: " + err.Error())
}
continue
}
if isNonTerminalPollStatus(parsedStatus) {
task.PrivateData.PollFailures = 0
}
if len(responseItem.TaskInfo.PluginState) > 0 {
task.PrivateData.PluginState = responseItem.TaskInfo.PluginState
}
task.Status = lo.If(parsedStatus != "", parsedStatus).Else(task.Status)
task.FailReason = lo.If(responseItem.TaskInfo.Reason != "", responseItem.TaskInfo.Reason).Else(task.FailReason)
task.SubmitTime = lo.If(responseItem.SubmitTime != 0, responseItem.SubmitTime).Else(task.SubmitTime)
task.StartTime = lo.If(responseItem.StartTime != 0, responseItem.StartTime).Else(task.StartTime)
task.FinishTime = lo.If(responseItem.FinishTime != 0, responseItem.FinishTime).Else(task.FinishTime)
if responseItem.TaskInfo.Progress != "" {
task.Progress = responseItem.TaskInfo.Progress
}
if responseItem.TaskInfo.Reason != "" || task.Status == model.TaskStatusFailure {
logger.LogInfo(ctx, task.TaskID+" 构建失败,"+task.FailReason)
task.Status = model.TaskStatusFailure
task.Progress = "100%"
}
if responseItem.TaskInfo.Status == model.TaskStatusSuccess {
task.Progress = "100%"
}
if responseItem.Data != nil {
task.SetData(responseItem.Data)
} else if task.Status == model.TaskStatusSuccess || task.Status == model.TaskStatusFailure {
logger.LogWarn(ctx, fmt.Sprintf(
"Batch task %s reached terminal status without data; preserving existing task data",
task.TaskID,
))
}
if responseItem.TaskInfo.Url != "" {
task.PrivateData.ResultURL = responseItem.TaskInfo.Url
}
isDone := task.Status == model.TaskStatusSuccess || task.Status == model.TaskStatusFailure
terminalTransition := isDone && snap.Status != task.Status
won, updateErr := task.UpdateWithStatus(snap.Status)
if updateErr != nil {
common.SysLog("UpdateSunoTask task error: " + updateErr.Error())
continue
}
if !won {
logger.LogWarn(ctx, fmt.Sprintf("Batch task %s already transitioned by another process, skip billing", task.TaskID))
continue
}
if terminalTransition {
billingSettled := settleTaskBillingOnComplete(ctx, adaptor, task, &responseItem.TaskInfo)
if task.Status == model.TaskStatusFailure && !billingSettled && task.Quota != 0 {
RefundTaskQuota(ctx, task, task.FailReason)
}
}
}
return nil
}
// UpdateVideoTasks 按渠道更新所有视频任务
func UpdateVideoTasks(ctx context.Context, platform constant.TaskPlatform, taskChannelM map[int][]string, taskM map[string]*model.Task) error {
channelIDs := make([]int, 0, len(taskChannelM))
for channelID := range taskChannelM {
channelIDs = append(channelIDs, channelID)
}
sort.Ints(channelIDs)
var wg sync.WaitGroup
for _, channelId := range channelIDs {
taskIds := taskChannelM[channelId]
if len(taskIds) == 0 {
continue
}
taskIds = append([]string(nil), taskIds...)
wg.Add(1)
gopool.Go(func() {
defer wg.Done()
if err := updateVideoTasks(ctx, platform, channelId, taskIds, taskM); err != nil {
logger.LogError(ctx, fmt.Sprintf("Channel #%d failed to update video async tasks: %s", channelId, err.Error()))
}
})
}
wg.Wait()
if ctx.Err() != nil {
return ctx.Err()
}
return nil
}
func updateVideoTasks(ctx context.Context, platform constant.TaskPlatform, channelId int, taskIds []string, taskM map[string]*model.Task) error {
logger.LogInfo(ctx, fmt.Sprintf("Channel #%d pending video tasks: %d", channelId, len(taskIds)))
if ctx.Err() != nil {
return ctx.Err()
}
if len(taskIds) == 0 {
return nil
}
cacheGetChannel, err := model.CacheGetChannel(channelId)
if err != nil {
// Collect DB primary key IDs for bulk update (taskIds are upstream IDs, not task_id column values)
var failedIDs []int64
for _, upstreamID := range taskIds {
if t, ok := taskM[upstreamID]; ok {
failedIDs = append(failedIDs, t.ID)
}
}
errUpdate := model.TaskBulkUpdateByID(failedIDs, map[string]any{
"fail_reason": fmt.Sprintf("Failed to get channel info, channel ID: %d", channelId),
"status": "FAILURE",
"progress": "100%",
})
if errUpdate != nil {
common.SysLog(fmt.Sprintf("UpdateVideoTask error: %v", errUpdate))
}
return fmt.Errorf("CacheGetChannel failed: %w", err)
}
adaptor := GetTaskAdaptorFunc(platform)
if adaptor == nil {
return fmt.Errorf("video adaptor not found")
}
info := &relaycommon.RelayInfo{}
info.ChannelMeta = &relaycommon.ChannelMeta{
ChannelBaseUrl: cacheGetChannel.GetBaseURL(),
}
info.ApiKey = cacheGetChannel.Key
adaptor.Init(info)
disablePollingSleep := cacheGetChannel.GetOtherSettings().DisableTaskPollingSleep
for i, taskId := range taskIds {
if ctx.Err() != nil {
return ctx.Err()
}
if err := updateVideoSingleTask(ctx, adaptor, cacheGetChannel, taskId, taskM); err != nil {
logger.LogError(ctx, fmt.Sprintf("Failed to update video task %s: %s", taskId, err.Error()))
}
if disablePollingSleep || i == len(taskIds)-1 {
continue
}
// sleep 1 second between tasks for this channel only.
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(1 * time.Second):
}
}
return nil
}
func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch *model.Channel, taskId string, taskM map[string]*model.Task) error {
if ctx.Err() != nil {
return ctx.Err()
}
baseURL := constant.GetChannelBaseURL(ch.Type)
if ch.GetBaseURL() != "" {
baseURL = ch.GetBaseURL()
}
proxy := ch.GetSetting().Proxy
task := taskM[taskId]
if task == nil {
logger.LogError(ctx, fmt.Sprintf("Task %s not found in taskM", taskId))
return fmt.Errorf("task %s not found", taskId)
}
key := ch.Key
privateData := task.PrivateData
if privateData.Key != "" {
key = privateData.Key
}
snap := task.Snapshot()
resp, err := adaptor.FetchTask(baseURL, key, task, proxy)
if err != nil {
return recordPollFailure(ctx, adaptor, task, snap.Status, pollClassTransport, 0, err.Error())
}
defer resp.Body.Close()
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return recordPollFailure(ctx, adaptor, task, snap.Status, pollClassTransport, resp.StatusCode, err.Error())
}
logger.LogDebug(ctx, "updateVideoSingleTask response: %s", responseBody)
switch classifyPollHTTP(resp.StatusCode) {
case pollClassNotFound:
return failTaskFromPoll(ctx, adaptor, task, snap.Status, fmt.Sprintf("upstream task not found (HTTP %d)", resp.StatusCode))
case pollClassAuth:
logger.LogWarn(ctx, fmt.Sprintf("task poll auth failure channel_id=%d task=%s http=%d", ch.Id, task.TaskID, resp.StatusCode))
return recordPollFailure(ctx, adaptor, task, snap.Status, pollClassAuth, resp.StatusCode, "")
case pollClassTransient:
return recordPollFailure(ctx, adaptor, task, snap.Status, pollClassTransient, resp.StatusCode, "")
}
taskResult := &relaycommon.TaskInfo{}
// try parse as New API response format
var responseItems taskdto.TaskResponse[model.Task]
if err = common.Unmarshal(responseBody, &responseItems); err == nil && responseItems.IsSuccess() {
logger.LogDebug(ctx, "updateVideoSingleTask parsed as new api response format: %+v", responseItems)
t := responseItems.Data
taskResult.TaskID = t.TaskID
taskResult.Status = string(t.Status)
taskResult.Url = t.GetResultURL()
taskResult.Progress = t.Progress
taskResult.Reason = t.FailReason
task.Data = t.Data
} else if taskResult, err = adaptor.ParseTaskResult(task, resp, responseBody); err != nil {
return recordPollFailure(ctx, adaptor, task, snap.Status, pollClassHookError, resp.StatusCode, err.Error())
}
logger.LogDebug(ctx, "updateVideoSingleTask taskResult: %+v", taskResult)
parsedStatus := model.TaskStatus(taskResult.Status)
if parsedStatus == model.TaskStatusUnknown || parsedStatus == "" || !knownPollStatus(parsedStatus) {
return recordPollFailure(ctx, adaptor, task, snap.Status, pollClassUnrecognized, resp.StatusCode, unrecognizedPollDetail(taskResult.Reason, responseBody))
}
if classifyPollHTTP(resp.StatusCode) == pollClassOtherClient && isNonTerminalPollStatus(parsedStatus) {
return recordPollFailure(ctx, adaptor, task, snap.Status, pollClassUnrecognized, resp.StatusCode, unrecognizedPollDetail(taskResult.Reason, responseBody))
}
task.Data = redactVideoResponseBody(responseBody)
if len(taskResult.PluginState) > 0 {
task.PrivateData.PluginState = taskResult.PluginState
}
if isNonTerminalPollStatus(parsedStatus) {
task.PrivateData.PollFailures = 0
}
now := time.Now().Unix()
shouldFinalizeBilling := false
task.Status = parsedStatus
switch parsedStatus {
case model.TaskStatusSubmitted:
task.Progress = taskcommon.ProgressSubmitted
case model.TaskStatusQueued:
task.Progress = taskcommon.ProgressQueued
case model.TaskStatusInProgress:
task.Progress = taskcommon.ProgressInProgress
if task.StartTime == 0 {
task.StartTime = now
}
case model.TaskStatusSuccess:
task.Progress = taskcommon.ProgressComplete
if task.FinishTime == 0 {
task.FinishTime = now
}
if strings.HasPrefix(taskResult.Url, "data:") {
// data: URI (e.g. Vertex base64 encoded video) — keep in Data, not in ResultURL
task.PrivateData.ResultURL = taskcommon.BuildProxyURL(task.TaskID)
} else if taskResult.Url != "" {
// Direct upstream URL (e.g. Kling, Ali, Doubao, etc.)
task.PrivateData.ResultURL = taskResult.Url
} else {
// No URL from adaptor — construct proxy URL using public task ID
task.PrivateData.ResultURL = taskcommon.BuildProxyURL(task.TaskID)
}
shouldFinalizeBilling = true
case model.TaskStatusFailure:
logger.LogJson(ctx, fmt.Sprintf("Task %s failed", taskId), task)
task.Status = model.TaskStatusFailure
task.Progress = taskcommon.ProgressComplete
if task.FinishTime == 0 {
task.FinishTime = now
}
task.FailReason = taskResult.Reason
logger.LogInfo(ctx, fmt.Sprintf("Task %s failed: %s", task.TaskID, task.FailReason))
taskResult.Progress = taskcommon.ProgressComplete
shouldFinalizeBilling = true
}
if taskResult.Progress != "" {
task.Progress = taskResult.Progress
}
isDone := task.Status == model.TaskStatusSuccess || task.Status == model.TaskStatusFailure
if isDone && snap.Status != task.Status {
won, err := task.UpdateWithStatus(snap.Status)
if err != nil {
logger.LogError(ctx, fmt.Sprintf("UpdateWithStatus failed for task %s: %s", task.TaskID, err.Error()))
shouldFinalizeBilling = false
} else if !won {
logger.LogWarn(ctx, fmt.Sprintf("Task %s CAS lost or no-op update, skip billing", task.TaskID))
shouldFinalizeBilling = false
}
} else if !snap.Equal(task.Snapshot()) {
if _, err := task.UpdateWithStatus(snap.Status); err != nil {
logger.LogError(ctx, fmt.Sprintf("Failed to update task %s: %s", task.TaskID, err.Error()))
}
} else {
// No changes, skip update
logger.LogDebug(ctx, "No update needed for task %s", task.TaskID)
}
if shouldFinalizeBilling {
billingSettled := settleTaskBillingOnComplete(ctx, adaptor, task, taskResult)
if task.Status == model.TaskStatusFailure && !billingSettled && task.Quota != 0 {
RefundTaskQuota(ctx, task, task.FailReason)
}
}
return nil
}
func redactVideoResponseBody(body []byte) []byte {
var m map[string]any
if err := common.Unmarshal(body, &m); err != nil {
return body
}
resp, _ := m["response"].(map[string]any)
if resp != nil {
delete(resp, "bytesBase64Encoded")
if v, ok := resp["video"].(string); ok {
resp["video"] = truncateBase64(v)
}
if vs, ok := resp["videos"].([]any); ok {
for i := range vs {
if vm, ok := vs[i].(map[string]any); ok {
delete(vm, "bytesBase64Encoded")
}
}
}
}
b, err := common.Marshal(m)
if err != nil {
return body
}
return b
}
func truncateBase64(s string) string {
const maxKeep = 256
if len(s) <= maxKeep {
return s
}
return s[:maxKeep] + "..."
}
// settleTaskBillingOnComplete 任务完成时的统一计费调整。
// 返回 true 表示用量结算路径已接管最终计费;失败任务仅在返回 false 时补做全额退款。
// 优先级:1. tiered snapshot → 2. adaptor 调整 → 3. token 重算。
//
// 表达式求值失败会保留预扣额度,因此也视为已接管,避免错误全退。
func settleTaskBillingOnComplete(ctx context.Context, adaptor TaskPollingAdaptor, task *model.Task, taskResult *relaycommon.TaskInfo) bool {
if bc := task.PrivateData.BillingContext; bc != nil && bc.TieredSnapshot != nil {
// 用量表达式结算只适用于成功任务;失败任务由调用方全额退款。
if task.Status == model.TaskStatusFailure {
return false
}
usageFacts := make(map[string]any, len(bc.TieredSnapshot.UsageFacts)+len(taskResult.UsageFacts))
maps.Copy(usageFacts, bc.TieredSnapshot.UsageFacts)
maps.Copy(usageFacts, taskResult.UsageFacts)
result, err := billingexpr.ComputeTieredQuotaWithRequest(bc.TieredSnapshot, billingexpr.TokenParams{}, billingexpr.RequestInput{Usage: usageFacts})
if err != nil {
logger.LogWarn(ctx, fmt.Sprintf("任务 %s 表达式结算失败,保留预扣额度: %v", task.TaskID, err))
return true
}
if result.Clamp != nil {
logger.LogWarn(ctx, fmt.Sprintf("任务 %s 表达式结算额度发生饱和: %+v", task.TaskID, result.Clamp))
}
bc.TieredSnapshot.UsageFacts = usageFacts
bc.TieredSnapshot.EstimatedTier = result.MatchedTier
RecalculateTaskQuota(ctx, task, result.ActualQuotaAfterGroup, "任务用量表达式结算", result.Clamp)
return true
}
// 按次计费的成功任务保持预扣;失败任务由调用方全额退款。
if bc := task.PrivateData.BillingContext; bc != nil && bc.PerCallBilling {
logger.LogInfo(ctx, fmt.Sprintf("任务 %s 按次计费,跳过差额结算", task.TaskID))
return false
}
// 优先让 adaptor 决定最终额度。
if actualQuota := adaptor.AdjustBillingOnComplete(task, taskResult); actualQuota > 0 {
RecalculateTaskQuota(ctx, task, actualQuota, "adaptor计费调整")
return true
}
// 回退到 token 重算。
tokens := taskResult.TotalTokens
if tokens == 0 && taskResult.CompletionTokens > 0 {
tokens = taskResult.CompletionTokens
}
if tokens > 0 {
return RecalculateTaskQuotaByTokens(ctx, task, tokens)
}
return false
}
func classifyPollHTTP(statusCode int) string {
switch {
case statusCode >= 200 && statusCode < 300:
return pollClassOK
case statusCode == http.StatusNotFound || statusCode == http.StatusGone:
return pollClassNotFound
case statusCode == http.StatusUnauthorized || statusCode == http.StatusForbidden:
return pollClassAuth
case statusCode == http.StatusTooManyRequests || statusCode >= 500:
return pollClassTransient
case statusCode >= 400 && statusCode < 500:
return pollClassOtherClient
default:
return pollClassTransient
}
}
func knownPollStatus(status model.TaskStatus) bool {
switch status {
case model.TaskStatusNotStart, model.TaskStatusSubmitted, model.TaskStatusQueued, model.TaskStatusInProgress, model.TaskStatusSuccess, model.TaskStatusFailure:
return true
default:
return false
}
}
func isNonTerminalPollStatus(status model.TaskStatus) bool {
switch status {
case model.TaskStatusNotStart, model.TaskStatusSubmitted, model.TaskStatusQueued, model.TaskStatusInProgress:
return true
default:
return false
}
}
func pollFailureReason(class string, statusCode int, detail string) string {
reason := fmt.Sprintf("poll failed: %s", class)
if statusCode > 0 {
reason = fmt.Sprintf("poll failed: %s (HTTP %d)", class, statusCode)
}
if detail != "" {
reason = reason + ": " + detail
}
return reason
}
// unrecognizedPollDetail pairs the plugin's reason with a bounded copy of the
// upstream body so the WARN line is enough to diagnose a parser gap.
func unrecognizedPollDetail(reason string, body []byte) string {
const maxBodyChars = 512
redacted := string(redactVideoResponseBody(body))
if len(redacted) > maxBodyChars {
redacted = redacted[:maxBodyChars] + "…"
}
if strings.TrimSpace(reason) == "" {
return "body=" + redacted
}
return reason + "; body=" + redacted
}
func recordPollFailure(ctx context.Context, adaptor TaskPollingAdaptor, task *model.Task, fromStatus model.TaskStatus, class string, statusCode int, detail string) error {
task.PrivateData.PollFailures++
if class == pollClassUnrecognized || class == pollClassHookError {
// The redacted body is intentionally not persisted to Task.Data on these
// paths, so the WARN line is the only operator-visible copy of what the
// plugin could not interpret.
logger.LogWarn(ctx, fmt.Sprintf("task %s poll %s (failures=%d, http=%d): %s", task.TaskID, class, task.PrivateData.PollFailures, statusCode, detail))
}
// TASK_POLL_MAX_FAILURES <= 0 disables the consecutive-failure cutoff, matching
// TASK_TIMEOUT_MINUTES semantics; the 24h sweep remains the only backstop.
if constant.TaskPollMaxFailures > 0 && task.PrivateData.PollFailures >= constant.TaskPollMaxFailures {
return failTaskFromPoll(ctx, adaptor, task, fromStatus, pollFailureReason(class, statusCode, detail))
}
if _, err := task.UpdateWithStatus(fromStatus); err != nil {
return err
}
return nil
}
func recordPollFailureForTasks(ctx context.Context, adaptor TaskPollingAdaptor, tasks []*model.Task, class string, statusCode int, detail string) error {
var firstErr error
for _, task := range tasks {
if task == nil {
continue
}
if err := recordPollFailure(ctx, adaptor, task, task.Status, class, statusCode, detail); err != nil && firstErr == nil {
firstErr = err
}
}
return firstErr
}
func failTaskFromPoll(ctx context.Context, adaptor TaskPollingAdaptor, task *model.Task, fromStatus model.TaskStatus, reason string) error {
now := time.Now().Unix()
task.Status = model.TaskStatusFailure
task.Progress = taskcommon.ProgressComplete
if task.FinishTime == 0 {
task.FinishTime = now
}
task.FailReason = reason
won, err := task.UpdateWithStatus(fromStatus)
if err != nil {
return err
}
if !won {
return nil
}
taskResult := relaycommon.FailTaskInfo(reason)
billingSettled := settleTaskBillingOnComplete(ctx, adaptor, task, taskResult)
if !billingSettled && task.Quota != 0 {
RefundTaskQuota(ctx, task, reason)
}
return nil
}
func failTasksFromPoll(ctx context.Context, adaptor TaskPollingAdaptor, tasks []*model.Task, reason string) error {
var firstErr error
for _, task := range tasks {
if task == nil {
continue
}
if err := failTaskFromPoll(ctx, adaptor, task, task.Status, reason); err != nil && firstErr == nil {
firstErr = err
}
}
return firstErr
}
+999
View File
@@ -0,0 +1,999 @@
package service
import (
"bytes"
"context"
"io"
"net/http"
"strings"
"sync"
"testing"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
taskdto "github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/pkg/billingexpr"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/bytedance/gopkg/util/gopool"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type taskPollingFetchAdaptor struct {
mu sync.Mutex
taskIDs []string
fetched chan string
blockTaskID string
blockStarted chan struct{}
releaseBlock chan struct{}
blockOnce sync.Once
}
type batchPollingAdaptor struct {
taskPollingFetchAdaptor
batchCalls int
batchIDs []string
results map[string]*BatchTaskResult
}
func (a *batchPollingAdaptor) FetchMode() string { return "batch" }
func (a *batchPollingAdaptor) FetchBatchTasks(_ string, _ string, tasks []*model.Task, _ string) (*http.Response, error) {
a.batchCalls++
a.batchIDs = a.batchIDs[:0]
for _, task := range tasks {
a.batchIDs = append(a.batchIDs, task.GetUpstreamTaskID())
}
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader([]byte(`{}`)))}, nil
}
func (a *batchPollingAdaptor) ParseBatchResult(_ []*model.Task, _ *http.Response, _ []byte) (map[string]*BatchTaskResult, error) {
if a.results != nil {
return a.results, nil
}
results := make(map[string]*BatchTaskResult, len(a.batchIDs))
for _, taskID := range a.batchIDs {
results[taskID] = &BatchTaskResult{TaskInfo: relaycommon.TaskInfo{TaskID: taskID, Status: model.TaskStatusInProgress, Url: "https://example.com/result"}}
}
return results, nil
}
func (a *taskPollingFetchAdaptor) Init(_ *relaycommon.RelayInfo) {}
func (a *taskPollingFetchAdaptor) FetchTask(_ string, _ string, task *model.Task, _ string) (*http.Response, error) {
taskID := ""
if task != nil {
taskID = task.GetUpstreamTaskID()
}
if taskID == a.blockTaskID && a.releaseBlock != nil {
a.blockOnce.Do(func() {
if a.blockStarted != nil {
close(a.blockStarted)
}
})
<-a.releaseBlock
}
a.mu.Lock()
a.taskIDs = append(a.taskIDs, taskID)
a.mu.Unlock()
if a.fetched != nil {
select {
case a.fetched <- taskID:
default:
}
}
response := taskdto.TaskResponse[model.Task]{
Code: taskdto.TaskSuccessCode,
Data: model.Task{
TaskID: taskID,
Status: model.TaskStatusInProgress,
Progress: "30%",
},
}
responseBody, err := common.Marshal(response)
if err != nil {
return nil, err
}
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewReader(responseBody)),
}, nil
}
func (a *taskPollingFetchAdaptor) ParseTaskResult(*model.Task, *http.Response, []byte) (*relaycommon.TaskInfo, error) {
return &relaycommon.TaskInfo{Status: model.TaskStatusInProgress}, nil
}
func (a *taskPollingFetchAdaptor) AdjustBillingOnComplete(_ *model.Task, _ *relaycommon.TaskInfo) int {
return 0
}
func (a *taskPollingFetchAdaptor) fetchCount() int {
a.mu.Lock()
defer a.mu.Unlock()
return len(a.taskIDs)
}
func (a *taskPollingFetchAdaptor) fetchedTaskIDs() []string {
a.mu.Lock()
defer a.mu.Unlock()
return append([]string(nil), a.taskIDs...)
}
func TestRedactVideoResponseBodyPreservesPollingPayloadShape(t *testing.T) {
rawVideo := strings.Repeat("a", 300)
body, err := common.Marshal(map[string]any{
"done": true,
"name": "operations/provider-task",
"response": map[string]any{
"bytesBase64Encoded": "secret-bytes",
"video": rawVideo,
"videos": []any{
map[string]any{
"bytesBase64Encoded": "other-secret-bytes",
"mimeType": "video/mp4",
"uri": "https://media.example/video.mp4",
},
},
},
})
require.NoError(t, err)
var stored map[string]any
require.NoError(t, common.Unmarshal(redactVideoResponseBody(body), &stored))
assert.Equal(t, true, stored["done"])
assert.Equal(t, "operations/provider-task", stored["name"])
response, ok := stored["response"].(map[string]any)
require.True(t, ok)
assert.NotContains(t, response, "bytesBase64Encoded")
assert.Equal(t, strings.Repeat("a", 256)+"...", response["video"])
videos, ok := response["videos"].([]any)
require.True(t, ok)
require.Len(t, videos, 1)
video, ok := videos[0].(map[string]any)
require.True(t, ok)
assert.NotContains(t, video, "bytesBase64Encoded")
assert.Equal(t, "video/mp4", video["mimeType"])
assert.Equal(t, "https://media.example/video.mp4", video["uri"])
}
func seedTaskPollingChannel(t *testing.T, id int, disableSleep bool) {
t.Helper()
ch := &model.Channel{
Id: id,
Type: constant.ChannelTypeKling,
Name: "polling_channel",
Key: "sk-test",
Status: common.ChannelStatusEnabled,
}
if disableSleep {
ch.SetOtherSettings(dto.ChannelOtherSettings{DisableTaskPollingSleep: true})
}
require.NoError(t, model.DB.Create(ch).Error)
}
func seedPollingTask(t *testing.T, channelID int, publicID string, upstreamID string) *model.Task {
t.Helper()
task := &model.Task{
TaskID: publicID,
Platform: constant.TaskPlatform("kling"),
UserId: 1,
ChannelId: channelID,
Action: constant.TaskActionImageToVideo,
Status: model.TaskStatusInProgress,
Progress: "30%",
CreatedAt: time.Now().Unix(),
UpdatedAt: time.Now().Unix(),
PrivateData: model.TaskPrivateData{
UpstreamTaskID: upstreamID,
},
}
require.NoError(t, model.DB.Create(task).Error)
return task
}
func TestUpdateVideoTasksDefaultSleepWaitsBetweenTasks(t *testing.T) {
truncate(t)
const channelID = 101
seedTaskPollingChannel(t, channelID, false)
first := seedPollingTask(t, channelID, "task_public_1", "upstream_1")
second := seedPollingTask(t, channelID, "task_public_2", "upstream_2")
adaptor := &taskPollingFetchAdaptor{}
previousFactory := GetTaskAdaptorFunc
GetTaskAdaptorFunc = func(constant.TaskPlatform) TaskPollingAdaptor { return adaptor }
t.Cleanup(func() { GetTaskAdaptorFunc = previousFactory })
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
err := UpdateVideoTasks(ctx, constant.TaskPlatform("kling"), map[int][]string{
channelID: {
first.GetUpstreamTaskID(),
second.GetUpstreamTaskID(),
},
}, map[string]*model.Task{
first.GetUpstreamTaskID(): first,
second.GetUpstreamTaskID(): second,
})
require.ErrorIs(t, err, context.DeadlineExceeded)
assert.Equal(t, 1, adaptor.fetchCount())
}
func TestDispatchPlatformUpdateUsesFetchMode(t *testing.T) {
truncate(t)
const channelID = 109
seedTaskPollingChannel(t, channelID, true)
task := seedPollingTask(t, channelID, "task_batch", "upstream_batch")
taskChannels := map[int][]string{channelID: {task.GetUpstreamTaskID()}}
tasks := map[string]*model.Task{task.GetUpstreamTaskID(): task}
batch := &batchPollingAdaptor{}
previousFactory := GetTaskAdaptorFunc
GetTaskAdaptorFunc = func(constant.TaskPlatform) TaskPollingAdaptor { return batch }
DispatchPlatformUpdate(context.Background(), "batch-plugin", taskChannels, tasks)
assert.Equal(t, 1, batch.batchCalls)
assert.Equal(t, 0, batch.fetchCount())
var persisted model.Task
require.NoError(t, model.DB.First(&persisted, task.ID).Error)
assert.Equal(t, "https://example.com/result", persisted.GetResultURL())
perTask := &taskPollingFetchAdaptor{}
GetTaskAdaptorFunc = func(constant.TaskPlatform) TaskPollingAdaptor { return perTask }
DispatchPlatformUpdate(context.Background(), "per-task-plugin", taskChannels, tasks)
assert.Equal(t, 1, perTask.fetchCount())
GetTaskAdaptorFunc = func(constant.TaskPlatform) TaskPollingAdaptor { return nil }
assert.NotPanics(t, func() { DispatchPlatformUpdate(context.Background(), "missing-plugin", taskChannels, tasks) })
GetTaskAdaptorFunc = previousFactory
}
func TestUpdateBatchTasksSettlesTieredUsageForTerminalStates(t *testing.T) {
testCases := []struct {
name string
status model.TaskStatus
units float64
actualQuota int
}{
{name: "success with usage", status: model.TaskStatusSuccess, units: 3, actualQuota: 3_000},
{name: "failure with usage", status: model.TaskStatusFailure, units: 3, actualQuota: 0},
{name: "success with zero usage", status: model.TaskStatusSuccess, units: 0, actualQuota: 0},
{name: "failure with zero usage", status: model.TaskStatusFailure, units: 0, actualQuota: 0},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
truncate(t)
const userID, tokenID, channelID = 41, 41, 141
const initialQuota, preConsumedQuota = 10_000, 5_000
const tokenRemain = 8_000
seedUser(t, userID, initialQuota)
seedToken(t, tokenID, userID, "sk-batch-tiered", tokenRemain)
seedTaskPollingChannel(t, channelID, true)
expression := `tier("actual", u("units"))`
task := makeTask(userID, channelID, preConsumedQuota, tokenID, BillingSourceWallet, 0)
task.TaskID = "task_batch_tiered_" + string(testCase.status)
task.Platform = "batch-plugin"
task.PrivateData.UpstreamTaskID = "upstream_batch_tiered_" + string(testCase.status)
task.SetData(map[string]any{"provider_payload": "must-be-preserved"})
task.PrivateData.BillingContext.TieredSnapshot = &billingexpr.BillingSnapshot{
ExprString: expression,
ExprHash: billingexpr.ExprHashString(expression),
GroupRatio: 1,
QuotaPerUnit: 1_000,
ExprVersion: 1,
TaskUsageBilling: true,
}
require.NoError(t, model.DB.Create(task).Error)
upstreamID := task.GetUpstreamTaskID()
reason := ""
if testCase.status == model.TaskStatusFailure {
reason = "upstream failed"
}
result := &BatchTaskResult{TaskInfo: relaycommon.TaskInfo{
TaskID: upstreamID,
Status: string(testCase.status),
Reason: reason,
UsageFacts: map[string]any{"units": testCase.units},
}}
adaptor := &batchPollingAdaptor{results: map[string]*BatchTaskResult{upstreamID: result}}
taskIDs := []string{upstreamID}
taskMap := map[string]*model.Task{upstreamID: task}
require.NoError(t, UpdateBatchTasks(context.Background(), adaptor, map[int][]string{channelID: taskIDs}, taskMap))
var persisted model.Task
require.NoError(t, model.DB.First(&persisted, task.ID).Error)
assert.Equal(t, testCase.status, persisted.Status)
assert.Equal(t, testCase.actualQuota, persisted.Quota)
var persistedData map[string]any
require.NoError(t, common.Unmarshal(persisted.Data, &persistedData))
assert.Equal(t, "must-be-preserved", persistedData["provider_payload"])
assert.Equal(t, initialQuota+(preConsumedQuota-testCase.actualQuota), getUserQuota(t, userID))
assert.Equal(t, tokenRemain+(preConsumedQuota-testCase.actualQuota), getTokenRemainQuota(t, tokenID))
assert.Equal(t, int64(1), countLogs(t))
if testCase.status == model.TaskStatusFailure {
log := getLastLog(t)
require.NotNil(t, log)
assert.Equal(t, model.LogTypeRefund, log.Type)
}
// A duplicate terminal response must not settle the same task twice.
require.NoError(t, UpdateBatchTasks(context.Background(), adaptor, map[int][]string{channelID: taskIDs}, taskMap))
assert.Equal(t, initialQuota+(preConsumedQuota-testCase.actualQuota), getUserQuota(t, userID))
assert.Equal(t, int64(1), countLogs(t))
})
}
}
func TestUpdateBatchTasksRefundsFailedTieredTask(t *testing.T) {
truncate(t)
const userID, tokenID, channelID = 43, 43, 143
const initialQuota, preConsumedQuota, tokenRemain = 10_000, 5_000, 8_000
seedUser(t, userID, initialQuota)
seedToken(t, tokenID, userID, "sk-batch-tiered-refund", tokenRemain)
seedTaskPollingChannel(t, channelID, true)
expression := `tier("actual", u("units"))`
task := makeTask(userID, channelID, preConsumedQuota, tokenID, BillingSourceWallet, 0)
task.TaskID = "task_batch_tiered_refund"
task.Platform = "batch-plugin"
task.PrivateData.UpstreamTaskID = "upstream_batch_tiered_refund"
task.PrivateData.BillingContext.TieredSnapshot = &billingexpr.BillingSnapshot{
ExprString: expression,
ExprHash: billingexpr.ExprHashString(expression),
GroupRatio: 1,
QuotaPerUnit: 1_000,
ExprVersion: 1,
TaskUsageBilling: true,
UsageFacts: map[string]any{"units": float64(5)},
EstimatedTier: "actual",
}
require.NoError(t, model.DB.Create(task).Error)
upstreamID := task.GetUpstreamTaskID()
adaptor := &batchPollingAdaptor{results: map[string]*BatchTaskResult{
upstreamID: {TaskInfo: relaycommon.TaskInfo{
TaskID: upstreamID,
Status: model.TaskStatusFailure,
Reason: "upstream failed",
UsageFacts: map[string]any{"units": float64(5)},
}},
}}
require.NoError(t, UpdateBatchTasks(context.Background(), adaptor, map[int][]string{channelID: {upstreamID}}, map[string]*model.Task{upstreamID: task}))
var persisted model.Task
require.NoError(t, model.DB.First(&persisted, task.ID).Error)
assert.EqualValues(t, model.TaskStatusFailure, persisted.Status)
assert.Zero(t, persisted.Quota)
assert.Equal(t, initialQuota+preConsumedQuota, getUserQuota(t, userID))
assert.Equal(t, tokenRemain+preConsumedQuota, getTokenRemainQuota(t, tokenID))
log := getLastLog(t)
require.NotNil(t, log)
assert.Equal(t, model.LogTypeRefund, log.Type)
assert.Equal(t, preConsumedQuota, log.Quota)
var other map[string]any
require.NoError(t, common.UnmarshalJsonStr(log.Other, &other))
assert.Equal(t, "tiered_expr", other["billing_mode"])
assert.Equal(t, "actual", other["matched_tier"])
facts, ok := other["usage_facts"].(map[string]any)
require.True(t, ok)
assert.Equal(t, map[string]any{"units": float64(5)}, facts)
}
func TestUpdateBatchTasksRefundsFailedTaskWithoutUsageSettlement(t *testing.T) {
truncate(t)
const userID, tokenID, channelID = 42, 42, 142
const initialQuota, preConsumedQuota, tokenRemain = 10_000, 4_000, 7_000
seedUser(t, userID, initialQuota)
seedToken(t, tokenID, userID, "sk-batch-refund", tokenRemain)
seedTaskPollingChannel(t, channelID, true)
task := makeTask(userID, channelID, preConsumedQuota, tokenID, BillingSourceWallet, 0)
task.TaskID = "task_batch_refund"
task.Platform = "batch-plugin"
task.Properties.OriginModelName = "missing-batch-token-price"
task.PrivateData.UpstreamTaskID = "upstream_batch_refund"
task.PrivateData.BillingContext.OriginModelName = "missing-batch-token-price"
require.NoError(t, model.DB.Create(task).Error)
upstreamID := task.GetUpstreamTaskID()
adaptor := &batchPollingAdaptor{results: map[string]*BatchTaskResult{
upstreamID: {TaskInfo: relaycommon.TaskInfo{TaskID: upstreamID, Status: model.TaskStatusFailure, Reason: "upstream failed", TotalTokens: 123}},
}}
require.NoError(t, UpdateBatchTasks(context.Background(), adaptor, map[int][]string{channelID: {upstreamID}}, map[string]*model.Task{upstreamID: task}))
assert.Equal(t, initialQuota+preConsumedQuota, getUserQuota(t, userID))
assert.Equal(t, tokenRemain+preConsumedQuota, getTokenRemainQuota(t, tokenID))
log := getLastLog(t)
require.NotNil(t, log)
assert.Equal(t, model.LogTypeRefund, log.Type)
}
func TestUpdateVideoTasksCanSkipPollingSleepPerChannel(t *testing.T) {
truncate(t)
const channelID = 102
seedTaskPollingChannel(t, channelID, true)
first := seedPollingTask(t, channelID, "task_public_3", "upstream_3")
second := seedPollingTask(t, channelID, "task_public_4", "upstream_4")
adaptor := &taskPollingFetchAdaptor{}
previousFactory := GetTaskAdaptorFunc
GetTaskAdaptorFunc = func(constant.TaskPlatform) TaskPollingAdaptor { return adaptor }
t.Cleanup(func() { GetTaskAdaptorFunc = previousFactory })
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
err := UpdateVideoTasks(ctx, constant.TaskPlatform("kling"), map[int][]string{
channelID: {
first.GetUpstreamTaskID(),
second.GetUpstreamTaskID(),
},
}, map[string]*model.Task{
first.GetUpstreamTaskID(): first,
second.GetUpstreamTaskID(): second,
})
require.NoError(t, err)
assert.Equal(t, 2, adaptor.fetchCount())
}
func TestUpdateVideoTasksDefaultSleepDoesNotBlockOtherChannels(t *testing.T) {
truncate(t)
const firstChannelID = 201
const secondChannelID = 202
seedTaskPollingChannel(t, firstChannelID, false)
seedTaskPollingChannel(t, secondChannelID, false)
firstChannelFirst := seedPollingTask(t, firstChannelID, "task_public_5", "upstream_a_1")
firstChannelSecond := seedPollingTask(t, firstChannelID, "task_public_6", "upstream_a_2")
secondChannelFirst := seedPollingTask(t, secondChannelID, "task_public_7", "upstream_b_1")
secondChannelSecond := seedPollingTask(t, secondChannelID, "task_public_8", "upstream_b_2")
adaptor := &taskPollingFetchAdaptor{}
previousFactory := GetTaskAdaptorFunc
GetTaskAdaptorFunc = func(constant.TaskPlatform) TaskPollingAdaptor { return adaptor }
t.Cleanup(func() { GetTaskAdaptorFunc = previousFactory })
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
err := UpdateVideoTasks(ctx, constant.TaskPlatform("kling"), map[int][]string{
firstChannelID: {
firstChannelFirst.GetUpstreamTaskID(),
firstChannelSecond.GetUpstreamTaskID(),
},
secondChannelID: {
secondChannelFirst.GetUpstreamTaskID(),
secondChannelSecond.GetUpstreamTaskID(),
},
}, map[string]*model.Task{
firstChannelFirst.GetUpstreamTaskID(): firstChannelFirst,
firstChannelSecond.GetUpstreamTaskID(): firstChannelSecond,
secondChannelFirst.GetUpstreamTaskID(): secondChannelFirst,
secondChannelSecond.GetUpstreamTaskID(): secondChannelSecond,
})
require.ErrorIs(t, err, context.DeadlineExceeded)
assert.ElementsMatch(t, []string{"upstream_a_1", "upstream_b_1"}, adaptor.fetchedTaskIDs())
}
func TestUpdateVideoTasksSlowChannelDoesNotBlockOtherChannels(t *testing.T) {
truncate(t)
const slowChannelID = 251
const fastChannelID = 252
seedTaskPollingChannel(t, slowChannelID, false)
seedTaskPollingChannel(t, fastChannelID, true)
slowTask := seedPollingTask(t, slowChannelID, "task_public_slow", "upstream_slow_1")
fastFirst := seedPollingTask(t, fastChannelID, "task_public_fast_1", "upstream_fast_parallel_1")
fastSecond := seedPollingTask(t, fastChannelID, "task_public_fast_2", "upstream_fast_parallel_2")
adaptor := &taskPollingFetchAdaptor{
fetched: make(chan string, 4),
blockTaskID: slowTask.GetUpstreamTaskID(),
blockStarted: make(chan struct{}),
releaseBlock: make(chan struct{}),
}
var releaseOnce sync.Once
releaseBlockedTask := func() {
releaseOnce.Do(func() {
close(adaptor.releaseBlock)
})
}
t.Cleanup(releaseBlockedTask)
previousFactory := GetTaskAdaptorFunc
GetTaskAdaptorFunc = func(constant.TaskPlatform) TaskPollingAdaptor { return adaptor }
t.Cleanup(func() { GetTaskAdaptorFunc = previousFactory })
errCh := make(chan error, 1)
gopool.Go(func() {
errCh <- UpdateVideoTasks(context.Background(), constant.TaskPlatform("kling"), map[int][]string{
slowChannelID: {
slowTask.GetUpstreamTaskID(),
},
fastChannelID: {
fastFirst.GetUpstreamTaskID(),
fastSecond.GetUpstreamTaskID(),
},
}, map[string]*model.Task{
slowTask.GetUpstreamTaskID(): slowTask,
fastFirst.GetUpstreamTaskID(): fastFirst,
fastSecond.GetUpstreamTaskID(): fastSecond,
})
})
select {
case <-adaptor.blockStarted:
case <-time.After(500 * time.Millisecond):
t.Fatal("slow channel did not start blocking")
}
require.Eventually(t, func() bool {
fetchedTaskIDs := adaptor.fetchedTaskIDs()
return len(fetchedTaskIDs) == 2 &&
fetchedTaskIDs[0] == fastFirst.GetUpstreamTaskID() &&
fetchedTaskIDs[1] == fastSecond.GetUpstreamTaskID()
}, 500*time.Millisecond, 10*time.Millisecond)
releaseBlockedTask()
require.NoError(t, <-errCh)
assert.ElementsMatch(t, []string{
slowTask.GetUpstreamTaskID(),
fastFirst.GetUpstreamTaskID(),
fastSecond.GetUpstreamTaskID(),
}, adaptor.fetchedTaskIDs())
}
func TestUpdateVideoTasksMixedChannelSleepSettings(t *testing.T) {
truncate(t)
const sleepyChannelID = 301
const fastChannelID = 302
seedTaskPollingChannel(t, sleepyChannelID, false)
seedTaskPollingChannel(t, fastChannelID, true)
sleepyFirst := seedPollingTask(t, sleepyChannelID, "task_public_9", "upstream_sleepy_1")
sleepySecond := seedPollingTask(t, sleepyChannelID, "task_public_10", "upstream_sleepy_2")
fastFirst := seedPollingTask(t, fastChannelID, "task_public_11", "upstream_fast_1")
fastSecond := seedPollingTask(t, fastChannelID, "task_public_12", "upstream_fast_2")
adaptor := &taskPollingFetchAdaptor{}
previousFactory := GetTaskAdaptorFunc
GetTaskAdaptorFunc = func(constant.TaskPlatform) TaskPollingAdaptor { return adaptor }
t.Cleanup(func() { GetTaskAdaptorFunc = previousFactory })
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
err := UpdateVideoTasks(ctx, constant.TaskPlatform("kling"), map[int][]string{
sleepyChannelID: {
sleepyFirst.GetUpstreamTaskID(),
sleepySecond.GetUpstreamTaskID(),
},
fastChannelID: {
fastFirst.GetUpstreamTaskID(),
fastSecond.GetUpstreamTaskID(),
},
}, map[string]*model.Task{
sleepyFirst.GetUpstreamTaskID(): sleepyFirst,
sleepySecond.GetUpstreamTaskID(): sleepySecond,
fastFirst.GetUpstreamTaskID(): fastFirst,
fastSecond.GetUpstreamTaskID(): fastSecond,
})
require.ErrorIs(t, err, context.DeadlineExceeded)
assert.ElementsMatch(t, []string{"upstream_sleepy_1", "upstream_fast_1", "upstream_fast_2"}, adaptor.fetchedTaskIDs())
}
func TestUpdateSunoTasksStalePollsRefundExactlyOnce(t *testing.T) {
truncate(t)
const userID, tokenID, channelID = 401, 401, 401
const initialUserQuota, initialTokenQuota, taskQuota = 10_000, 6_000, 2_500
const publicTaskID, upstreamTaskID = "suno_public_refund_once", "suno_upstream_refund_once"
seedUser(t, userID, initialUserQuota)
seedToken(t, tokenID, userID, "sk-suno-refund-once", initialTokenQuota)
baseURL := "https://suno.invalid"
require.NoError(t, model.DB.Create(&model.Channel{
Id: channelID,
Type: constant.ChannelTypeSunoAPI,
Name: "suno_refund_once",
Key: "sk-suno-channel",
Status: common.ChannelStatusEnabled,
BaseURL: &baseURL,
}).Error)
task := makeTask(userID, channelID, taskQuota, tokenID, BillingSourceWallet, 0)
task.TaskID = publicTaskID
task.Platform = constant.TaskPlatformSuno
task.Status = model.TaskStatusInProgress
task.Progress = "50%"
task.SubmitTime = time.Now().Unix()
task.PrivateData.UpstreamTaskID = upstreamTaskID
require.NoError(t, model.DB.Create(task).Error)
var firstPollTask model.Task
var staleSecondPollTask model.Task
require.NoError(t, model.DB.First(&firstPollTask, task.ID).Error)
require.NoError(t, model.DB.First(&staleSecondPollTask, task.ID).Error)
adaptor := &batchPollingAdaptor{results: map[string]*BatchTaskResult{
upstreamTaskID: {TaskInfo: relaycommon.TaskInfo{
TaskID: upstreamTaskID,
Status: model.TaskStatusFailure,
Reason: "upstream failed",
}},
}}
previousFactory := GetTaskAdaptorFunc
GetTaskAdaptorFunc = func(constant.TaskPlatform) TaskPollingAdaptor { return adaptor }
t.Cleanup(func() { GetTaskAdaptorFunc = previousFactory })
require.NoError(t, updateBatchTasks(context.Background(), adaptor, channelID, []string{upstreamTaskID}, map[string]*model.Task{
upstreamTaskID: &firstPollTask,
}))
require.NoError(t, updateBatchTasks(context.Background(), adaptor, channelID, []string{upstreamTaskID}, map[string]*model.Task{
upstreamTaskID: &staleSecondPollTask,
}))
var reloaded model.Task
require.NoError(t, model.DB.First(&reloaded, task.ID).Error)
assert.EqualValues(t, model.TaskStatusFailure, reloaded.Status)
assert.Zero(t, reloaded.Quota)
assert.Equal(t, initialUserQuota+taskQuota, getUserQuota(t, userID))
assert.Equal(t, initialTokenQuota+taskQuota, getTokenRemainQuota(t, tokenID))
assert.Equal(t, int64(1), countLogs(t))
}
func TestRunTaskPollingOnceDoesNotRefundHistoricalFailedTask(t *testing.T) {
truncate(t)
const userID, initialQuota, taskQuota = 402, 10_000, 1_200
seedUser(t, userID, initialQuota)
task := makeTask(userID, 0, taskQuota, 0, BillingSourceWallet, 0)
task.TaskID = "historical_failed_already_refunded"
task.Status = model.TaskStatusFailure
task.Progress = "100%"
task.SubmitTime = time.Now().Add(-90 * 24 * time.Hour).Unix()
task.UpdatedAt = time.Now().Add(-time.Minute).Unix()
require.NoError(t, model.DB.Create(task).Error)
previousFactory := GetTaskAdaptorFunc
GetTaskAdaptorFunc = func(constant.TaskPlatform) TaskPollingAdaptor {
return &taskPollingFetchAdaptor{}
}
t.Cleanup(func() { GetTaskAdaptorFunc = previousFactory })
summary := RunTaskPollingOnce(context.Background(), nil)
assert.Zero(t, summary.UnfinishedTasks)
assert.Equal(t, initialQuota, getUserQuota(t, userID))
assert.Equal(t, taskQuota, getTaskQuota(t, task.ID))
assert.Equal(t, int64(0), countLogs(t))
}
func TestSweepTimedOutTasksHonorsRefundRolloutBoundary(t *testing.T) {
truncate(t)
const (
userID = 403
initialQuota = 10_000
legacyTaskQuota = 1_800
modernTaskQuota = 1_200
)
seedUser(t, userID, initialQuota)
legacyTask := makeTask(userID, 0, legacyTaskQuota, 0, BillingSourceWallet, 0)
legacyTask.TaskID = "legacy_timeout_without_refund"
legacyTask.Progress = "50%"
legacyTask.SubmitTime = 1771718399 // 2026-02-21 23:59:59 UTC
require.NoError(t, model.DB.Create(legacyTask).Error)
modernTask := makeTask(userID, 0, modernTaskQuota, 0, BillingSourceWallet, 0)
modernTask.TaskID = "modern_timeout_with_refund"
modernTask.Progress = "50%"
modernTask.SubmitTime = 1771718400 // 2026-02-22 00:00:00 UTC
require.NoError(t, model.DB.Create(modernTask).Error)
previousTimeout := constant.TaskTimeoutMinutes
constant.TaskTimeoutMinutes = 1
t.Cleanup(func() { constant.TaskTimeoutMinutes = previousTimeout })
sweepTimedOutTasks(context.Background())
var reloadedLegacy model.Task
var reloadedModern model.Task
require.NoError(t, model.DB.First(&reloadedLegacy, legacyTask.ID).Error)
require.NoError(t, model.DB.First(&reloadedModern, modernTask.ID).Error)
assert.EqualValues(t, model.TaskStatusFailure, reloadedLegacy.Status)
assert.EqualValues(t, model.TaskStatusFailure, reloadedModern.Status)
assert.Zero(t, reloadedLegacy.Quota)
assert.Zero(t, reloadedModern.Quota)
assert.Contains(t, reloadedLegacy.FailReason, "旧系统遗留任务")
assert.Contains(t, reloadedModern.FailReason, "任务超时")
assert.Equal(t, initialQuota+modernTaskQuota, getUserQuota(t, userID))
assert.Equal(t, int64(1), countLogs(t))
}
type scriptedPollingAdaptor struct {
statusCode int
body []byte
fetchErr error
parse *relaycommon.TaskInfo
parseErr error
}
func (a *scriptedPollingAdaptor) Init(*relaycommon.RelayInfo) {}
func (a *scriptedPollingAdaptor) FetchTask(string, string, *model.Task, string) (*http.Response, error) {
if a.fetchErr != nil {
return nil, a.fetchErr
}
code := a.statusCode
if code == 0 {
code = http.StatusOK
}
body := a.body
if body == nil {
body = []byte(`{}`)
}
return &http.Response{StatusCode: code, Body: io.NopCloser(bytes.NewReader(body))}, nil
}
func (a *scriptedPollingAdaptor) ParseTaskResult(*model.Task, *http.Response, []byte) (*relaycommon.TaskInfo, error) {
if a.parseErr != nil {
return nil, a.parseErr
}
if a.parse != nil {
return a.parse, nil
}
return &relaycommon.TaskInfo{Status: model.TaskStatusInProgress}, nil
}
func (a *scriptedPollingAdaptor) AdjustBillingOnComplete(*model.Task, *relaycommon.TaskInfo) int {
return 0
}
type scriptedBatchPollingAdaptor struct {
scriptedPollingAdaptor
results map[string]*BatchTaskResult
}
func (a *scriptedBatchPollingAdaptor) FetchMode() string { return "batch" }
func (a *scriptedBatchPollingAdaptor) FetchBatchTasks(string, string, []*model.Task, string) (*http.Response, error) {
return a.FetchTask("", "", nil, "")
}
func (a *scriptedBatchPollingAdaptor) ParseBatchResult([]*model.Task, *http.Response, []byte) (map[string]*BatchTaskResult, error) {
if a.parseErr != nil {
return nil, a.parseErr
}
return a.results, nil
}
func TestUpdateVideoSingleTaskPollClassification(t *testing.T) {
testCases := []struct {
name string
statusCode int
fetchErr error
parse *relaycommon.TaskInfo
parseErr error
priorFailures int
priorState string
maxFailures int
wantStatus model.TaskStatus
wantFailures int
wantRefund bool
wantReason string
wantState string
wantUnchanged bool
}{
{
name: "404 fails immediately and refunds",
statusCode: http.StatusNotFound,
wantStatus: model.TaskStatusFailure,
wantRefund: true,
wantReason: "upstream task not found (HTTP 404)",
wantUnchanged: false,
},
{
name: "401 increments without changing status",
statusCode: http.StatusUnauthorized,
wantStatus: model.TaskStatusInProgress,
wantFailures: 1,
wantUnchanged: true,
},
{
name: "429 reaches threshold and refunds",
statusCode: http.StatusTooManyRequests,
priorFailures: 2,
maxFailures: 3,
wantStatus: model.TaskStatusFailure,
wantFailures: 3,
wantRefund: true,
wantReason: "poll failed: transient (HTTP 429)",
},
{
name: "UNKNOWN increments",
statusCode: http.StatusOK,
parse: &relaycommon.TaskInfo{Status: model.TaskStatusUnknown, Reason: "weird"},
wantStatus: model.TaskStatusInProgress,
wantFailures: 1,
wantUnchanged: true,
},
{
name: "valid 2xx resets the failure counter",
statusCode: http.StatusOK,
parse: &relaycommon.TaskInfo{Status: model.TaskStatusInProgress},
priorFailures: 5,
wantStatus: model.TaskStatusInProgress,
wantFailures: 0,
},
{
name: "omit state preserves previous plugin state",
statusCode: http.StatusOK,
parse: &relaycommon.TaskInfo{Status: model.TaskStatusInProgress},
priorState: `{"req_key":"keep"}`,
wantStatus: model.TaskStatusInProgress,
wantState: `{"req_key":"keep"}`,
},
{
name: "returned state replaces plugin state",
statusCode: http.StatusOK,
parse: &relaycommon.TaskInfo{
Status: model.TaskStatusInProgress,
PluginState: []byte(`{"req_key":"new"}`),
},
priorState: `{"req_key":"old"}`,
wantStatus: model.TaskStatusInProgress,
wantState: `{"req_key":"new"}`,
},
{
name: "other 4xx non-terminal is unrecognized",
statusCode: http.StatusBadRequest,
parse: &relaycommon.TaskInfo{Status: model.TaskStatusInProgress},
wantStatus: model.TaskStatusInProgress,
wantFailures: 1,
wantUnchanged: true,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
truncate(t)
const userID, tokenID, channelID = 510, 510, 510
const initialQuota, preConsumed, tokenRemain = 10_000, 4_000, 7_000
seedUser(t, userID, initialQuota)
seedToken(t, tokenID, userID, "sk-poll-class", tokenRemain)
ch := &model.Channel{Id: channelID, Type: constant.ChannelTypeKling, Name: "poll", Key: "sk-test", Status: common.ChannelStatusEnabled}
if testCase.maxFailures > 0 {
previous := constant.TaskPollMaxFailures
constant.TaskPollMaxFailures = testCase.maxFailures
t.Cleanup(func() { constant.TaskPollMaxFailures = previous })
}
task := makeTask(userID, channelID, preConsumed, tokenID, BillingSourceWallet, 0)
task.TaskID = "task_poll_class"
task.PrivateData.UpstreamTaskID = "upstream_poll_class"
task.PrivateData.PollFailures = testCase.priorFailures
if testCase.priorState != "" {
task.PrivateData.PluginState = []byte(testCase.priorState)
}
require.NoError(t, model.DB.Create(task).Error)
adaptor := &scriptedPollingAdaptor{statusCode: testCase.statusCode, fetchErr: testCase.fetchErr, parse: testCase.parse, parseErr: testCase.parseErr}
require.NoError(t, updateVideoSingleTask(context.Background(), adaptor, ch, task.GetUpstreamTaskID(), map[string]*model.Task{
task.GetUpstreamTaskID(): task,
}))
var persisted model.Task
require.NoError(t, model.DB.First(&persisted, task.ID).Error)
assert.EqualValues(t, testCase.wantStatus, persisted.Status)
assert.Equal(t, testCase.wantFailures, persisted.PrivateData.PollFailures)
if testCase.wantUnchanged {
assert.Empty(t, persisted.FailReason)
}
if testCase.wantReason != "" {
assert.Contains(t, persisted.FailReason, testCase.wantReason)
}
if testCase.wantState != "" {
assert.JSONEq(t, testCase.wantState, string(persisted.PrivateData.PluginState))
}
if testCase.wantRefund {
assert.Equal(t, initialQuota+preConsumed, getUserQuota(t, userID))
assert.Equal(t, tokenRemain+preConsumed, getTokenRemainQuota(t, tokenID))
assert.Zero(t, persisted.Quota)
log := getLastLog(t)
require.NotNil(t, log)
assert.Equal(t, model.LogTypeRefund, log.Type)
} else {
assert.Equal(t, initialQuota, getUserQuota(t, userID))
assert.Equal(t, tokenRemain, getTokenRemainQuota(t, tokenID))
}
})
}
}
func TestUpdateBatchTasksPollClassification(t *testing.T) {
testCases := []struct {
name string
statusCode int
resultStatus model.TaskStatus
wantStatus model.TaskStatus
wantFailures int
wantRefund bool
wantReason string
}{
{
name: "404 fails the batch and refunds",
statusCode: http.StatusNotFound,
wantStatus: model.TaskStatusFailure,
wantRefund: true,
wantReason: "upstream task not found (HTTP 404)",
},
{
name: "401 increments every task",
statusCode: http.StatusUnauthorized,
wantStatus: model.TaskStatusInProgress,
wantFailures: 1,
},
{
name: "UNKNOWN increments",
statusCode: http.StatusOK,
resultStatus: model.TaskStatusUnknown,
wantStatus: model.TaskStatusInProgress,
wantFailures: 1,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
truncate(t)
const userID, tokenID, channelID = 610, 610, 610
const initialQuota, preConsumed, tokenRemain = 10_000, 4_000, 7_000
seedUser(t, userID, initialQuota)
seedToken(t, tokenID, userID, "sk-batch-class", tokenRemain)
seedTaskPollingChannel(t, channelID, true)
task := makeTask(userID, channelID, preConsumed, tokenID, BillingSourceWallet, 0)
task.TaskID = "task_batch_class"
task.PrivateData.UpstreamTaskID = "upstream_batch_class"
require.NoError(t, model.DB.Create(task).Error)
upstreamID := task.GetUpstreamTaskID()
adaptor := &scriptedBatchPollingAdaptor{
scriptedPollingAdaptor: scriptedPollingAdaptor{statusCode: testCase.statusCode},
}
if testCase.resultStatus != "" {
adaptor.results = map[string]*BatchTaskResult{
upstreamID: {TaskInfo: relaycommon.TaskInfo{TaskID: upstreamID, Status: string(testCase.resultStatus), Reason: "weird"}},
}
}
require.NoError(t, UpdateBatchTasks(context.Background(), adaptor, map[int][]string{channelID: {upstreamID}}, map[string]*model.Task{upstreamID: task}))
var persisted model.Task
require.NoError(t, model.DB.First(&persisted, task.ID).Error)
assert.EqualValues(t, testCase.wantStatus, persisted.Status)
assert.Equal(t, testCase.wantFailures, persisted.PrivateData.PollFailures)
if testCase.wantReason != "" {
assert.Contains(t, persisted.FailReason, testCase.wantReason)
}
if testCase.wantRefund {
assert.Equal(t, initialQuota+preConsumed, getUserQuota(t, userID))
assert.Zero(t, persisted.Quota)
} else {
assert.Equal(t, initialQuota, getUserQuota(t, userID))
}
})
}
}
+540
View File
@@ -0,0 +1,540 @@
package service
import (
"fmt"
"math"
"sort"
"strings"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/pkg/billingexpr"
perfmetrics "github.com/QuantumNous/new-api/pkg/perf_metrics"
relaycommon "github.com/QuantumNous/new-api/relay/common"
relayconstant "github.com/QuantumNous/new-api/relay/constant"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/bytedance/gopkg/util/gopool"
"github.com/gin-gonic/gin"
"github.com/shopspring/decimal"
)
// ToolSurchargeItem is one billable tool-call line for consume logs.
type ToolSurchargeItem struct {
Name string `json:"name"`
Count int `json:"count"`
Price float64 `json:"price"`
}
func appendToolSurchargeLogInfo(other *model.LogOther, items []ToolSurchargeItem) {
if len(items) == 0 {
return
}
other.SetPublic("tool_surcharges", items)
}
type textQuotaSummary struct {
PromptTokens int
CompletionTokens int
TotalTokens int
CacheTokens int
CacheCreationTokens int
CacheCreationTokens5m int
CacheCreationTokens1h int
ImageTokens int
AudioTokens int
ModelName string
TokenName string
UseTimeSeconds int64
CompletionRatio float64
CacheRatio float64
ImageRatio float64
ModelRatio float64
GroupRatio float64
ModelPrice float64
CacheCreationRatio float64
CacheCreationRatio5m float64
CacheCreationRatio1h float64
Quota int
IsClaudeUsageSemantic bool
UsageSemantic string
AudioInputPrice float64
ToolSurchargeItems []ToolSurchargeItem
ToolCallSurchargeQuota decimal.Decimal
}
// hasBillableUsage reports whether this request should incur any charge.
// A request can carry zero tokens yet still be billable via a tool-call
// surcharge (e.g. /v1/alpha/search returns no usage but bills one web_search
// call), so token count alone is not sufficient to decide.
func (s *textQuotaSummary) hasBillableUsage() bool {
return s.TotalTokens > 0 || !s.ToolCallSurchargeQuota.IsZero()
}
func cacheWriteTokensTotal(summary textQuotaSummary) int {
if summary.CacheCreationTokens5m > 0 || summary.CacheCreationTokens1h > 0 {
splitCacheWriteTokens := summary.CacheCreationTokens5m + summary.CacheCreationTokens1h
if summary.CacheCreationTokens > splitCacheWriteTokens {
return summary.CacheCreationTokens
}
return splitCacheWriteTokens
}
return summary.CacheCreationTokens
}
func isLegacyClaudeDerivedOpenAIUsage(relayInfo *relaycommon.RelayInfo, usage *dto.Usage) bool {
if relayInfo == nil || usage == nil {
return false
}
if relayInfo.GetFinalRequestRelayFormat() == types.RelayFormatClaude {
return false
}
if usage.UsageSource != "" || usage.UsageSemantic != "" {
return false
}
return usage.ClaudeCacheCreation5mTokens > 0 || usage.ClaudeCacheCreation1hTokens > 0
}
func collectToolSurchargeItem(items []ToolSurchargeItem, name string, count int, modelName string) []ToolSurchargeItem {
if count <= 0 {
return items
}
price := operation_setting.GetToolPriceForModel(name, modelName)
if price <= 0 || math.IsNaN(price) || math.IsInf(price, 0) {
return items
}
return append(items, ToolSurchargeItem{
Name: name,
Count: count,
Price: price,
})
}
func mergeToolSurchargeItems(items []ToolSurchargeItem) []ToolSurchargeItem {
if len(items) == 0 {
return nil
}
sort.Slice(items, func(i, j int) bool {
if items[i].Name == items[j].Name {
return items[i].Price < items[j].Price
}
return items[i].Name < items[j].Name
})
merged := items[:0]
for _, item := range items {
lastIndex := len(merged) - 1
if lastIndex >= 0 &&
merged[lastIndex].Name == item.Name &&
merged[lastIndex].Price == item.Price {
if item.Count > math.MaxInt-merged[lastIndex].Count {
common.SysError("tool surcharge call count overflow for " + item.Name)
merged[lastIndex].Count = math.MaxInt
} else {
merged[lastIndex].Count += item.Count
}
continue
}
merged = append(merged, item)
}
return merged
}
func calculateTextToolCallSurcharge(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, summary *textQuotaSummary) decimal.Decimal {
dGroupRatio := decimal.NewFromFloat(summary.GroupRatio)
dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
var items []ToolSurchargeItem
if relayInfo.ResponsesUsageInfo != nil {
for name, tool := range relayInfo.ResponsesUsageInfo.BuiltInTools {
if tool == nil {
continue
}
items = collectToolSurchargeItem(items, name, tool.CallCount, summary.ModelName)
}
}
if relayInfo.RelayMode != relayconstant.RelayModeResponses &&
strings.HasSuffix(summary.ModelName, "search-preview") {
items = collectToolSurchargeItem(items, dto.BuildInToolWebSearchPreview, 1, summary.ModelName)
}
items = collectToolSurchargeItem(
items,
dto.BuildInToolWebSearch,
ctx.GetInt("claude_web_search_requests"),
summary.ModelName,
)
if ctx.GetBool("gemini_google_search_call") {
items = collectToolSurchargeItem(items, dto.BuildInToolGoogleSearch, 1, summary.ModelName)
}
summary.ToolSurchargeItems = mergeToolSurchargeItems(items)
var surcharge decimal.Decimal
for _, item := range summary.ToolSurchargeItems {
surcharge = surcharge.Add(decimal.NewFromFloat(item.Price).
Mul(decimal.NewFromInt(int64(item.Count))).
Div(decimal.NewFromInt(1000)).
Mul(dGroupRatio).
Mul(dQuotaPerUnit))
}
return surcharge
}
// noteQuotaClamp records the first quota saturation event onto relayInfo so it
// can later be attached to the consume/task log for admin auditing. First
// non-nil clamp wins (a single request may hit multiple conversions).
func noteQuotaClamp(relayInfo *relaycommon.RelayInfo, clamp *common.QuotaClamp) {
if clamp == nil || relayInfo == nil {
return
}
if relayInfo.QuotaClamp == nil {
relayInfo.QuotaClamp = clamp
}
}
func composeTieredTextQuota(relayInfo *relaycommon.RelayInfo, summary textQuotaSummary, tieredQuota int, tieredResult *billingexpr.TieredResult) int {
if summary.ToolCallSurchargeQuota.IsZero() {
return tieredQuota
}
if tieredResult != nil {
if snap := relayInfo.TieredBillingSnapshot; snap != nil {
quota, clamp := common.QuotaFromDecimalChecked(decimal.NewFromFloat(tieredResult.ActualQuotaBeforeGroup).
Mul(decimal.NewFromFloat(snap.GroupRatio)).
Add(summary.ToolCallSurchargeQuota))
noteQuotaClamp(relayInfo, clamp)
return quota
}
}
// Saturate the final sum, not just the surcharge: tieredQuota can be near
// MaxQuota and adding the surcharge could push the total past the
// single-request quota policy bound.
total, clamp := common.QuotaFromDecimalChecked(
decimal.NewFromInt(int64(tieredQuota)).Add(summary.ToolCallSurchargeQuota),
)
noteQuotaClamp(relayInfo, clamp)
return total
}
// calculateTextQuotaSummary expects a usage already remapped by
// effectiveBillingUsage; PostTextConsumeQuota performs that remap once and shares
// the result with tiered billing, affinity observation and logging.
func calculateTextQuotaSummary(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.Usage) textQuotaSummary {
summary := textQuotaSummary{
ModelName: relayInfo.GetBillingModelName(),
TokenName: ctx.GetString("token_name"),
UseTimeSeconds: time.Now().Unix() - relayInfo.StartTime.Unix(),
CompletionRatio: relayInfo.PriceData.CompletionRatio,
CacheRatio: relayInfo.PriceData.CacheRatio,
ImageRatio: relayInfo.PriceData.ImageRatio,
ModelRatio: relayInfo.PriceData.ModelRatio,
GroupRatio: relayInfo.PriceData.GroupRatioInfo.GroupRatio,
ModelPrice: relayInfo.PriceData.ModelPrice,
CacheCreationRatio: relayInfo.PriceData.CacheCreationRatio,
CacheCreationRatio5m: relayInfo.PriceData.CacheCreation5mRatio,
CacheCreationRatio1h: relayInfo.PriceData.CacheCreation1hRatio,
UsageSemantic: usageSemanticFromUsage(relayInfo, usage),
}
summary.IsClaudeUsageSemantic = summary.UsageSemantic == "anthropic"
if usage == nil {
usage = &dto.Usage{
PromptTokens: relayInfo.GetEstimatePromptTokens(),
CompletionTokens: 0,
TotalTokens: relayInfo.GetEstimatePromptTokens(),
}
}
summary.PromptTokens = usage.PromptTokens
summary.CompletionTokens = usage.CompletionTokens
summary.TotalTokens = usage.PromptTokens + usage.CompletionTokens
summary.CacheTokens = usage.PromptTokensDetails.CachedTokens
summary.CacheCreationTokens = usage.PromptTokensDetails.CacheCreationTokensTotal()
summary.CacheCreationTokens5m = usage.ClaudeCacheCreation5mTokens
summary.CacheCreationTokens1h = usage.ClaudeCacheCreation1hTokens
summary.ImageTokens = usage.PromptTokensDetails.ImageTokens
summary.AudioTokens = usage.PromptTokensDetails.AudioTokens
legacyClaudeDerived := isLegacyClaudeDerivedOpenAIUsage(relayInfo, usage)
isOpenRouterClaudeBilling := relayInfo.ChannelMeta != nil &&
relayInfo.ChannelType == constant.ChannelTypeOpenRouter &&
summary.IsClaudeUsageSemantic
if isOpenRouterClaudeBilling {
summary.PromptTokens -= summary.CacheTokens
isUsingCustomSettings := relayInfo.PriceData.UsePrice || hasCustomModelRatio(summary.ModelName, relayInfo.PriceData.ModelRatio)
if summary.CacheCreationTokens == 0 && relayInfo.PriceData.CacheCreationRatio != 1 && usage.Cost != 0 && !isUsingCustomSettings {
maybeCacheCreationTokens := CalcOpenRouterCacheCreateTokens(*usage, relayInfo.PriceData)
if maybeCacheCreationTokens >= 0 && summary.PromptTokens >= maybeCacheCreationTokens {
summary.CacheCreationTokens = maybeCacheCreationTokens
}
}
summary.PromptTokens -= summary.CacheCreationTokens
}
dPromptTokens := decimal.NewFromInt(int64(summary.PromptTokens))
dCacheTokens := decimal.NewFromInt(int64(summary.CacheTokens))
dImageTokens := decimal.NewFromInt(int64(summary.ImageTokens))
dAudioTokens := decimal.NewFromInt(int64(summary.AudioTokens))
dCompletionTokens := decimal.NewFromInt(int64(summary.CompletionTokens))
dCachedCreationTokens := decimal.NewFromInt(int64(summary.CacheCreationTokens))
dCompletionRatio := decimal.NewFromFloat(summary.CompletionRatio)
dCacheRatio := decimal.NewFromFloat(summary.CacheRatio)
dImageRatio := decimal.NewFromFloat(summary.ImageRatio)
dModelRatio := decimal.NewFromFloat(summary.ModelRatio)
dGroupRatio := decimal.NewFromFloat(summary.GroupRatio)
dModelPrice := decimal.NewFromFloat(summary.ModelPrice)
dCacheCreationRatio := decimal.NewFromFloat(summary.CacheCreationRatio)
dCacheCreationRatio5m := decimal.NewFromFloat(summary.CacheCreationRatio5m)
dCacheCreationRatio1h := decimal.NewFromFloat(summary.CacheCreationRatio1h)
dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
ratio := dModelRatio.Mul(dGroupRatio)
summary.ToolCallSurchargeQuota = calculateTextToolCallSurcharge(ctx, relayInfo, &summary)
var audioInputQuota decimal.Decimal
if !relayInfo.PriceData.UsePrice {
baseTokens := dPromptTokens
var cachedTokensWithRatio decimal.Decimal
if !dCacheTokens.IsZero() {
if !summary.IsClaudeUsageSemantic && !legacyClaudeDerived {
baseTokens = baseTokens.Sub(dCacheTokens)
}
cachedTokensWithRatio = dCacheTokens.Mul(dCacheRatio)
}
var cachedCreationTokensWithRatio decimal.Decimal
hasSplitCacheCreationTokens := summary.CacheCreationTokens5m > 0 || summary.CacheCreationTokens1h > 0
if !dCachedCreationTokens.IsZero() || hasSplitCacheCreationTokens {
if !summary.IsClaudeUsageSemantic && !legacyClaudeDerived {
baseTokens = baseTokens.Sub(dCachedCreationTokens)
cachedCreationTokensWithRatio = dCachedCreationTokens.Mul(dCacheCreationRatio)
} else {
remaining := max(summary.CacheCreationTokens-summary.CacheCreationTokens5m-summary.CacheCreationTokens1h, 0)
cachedCreationTokensWithRatio = decimal.NewFromInt(int64(remaining)).Mul(dCacheCreationRatio)
cachedCreationTokensWithRatio = cachedCreationTokensWithRatio.Add(decimal.NewFromInt(int64(summary.CacheCreationTokens5m)).Mul(dCacheCreationRatio5m))
cachedCreationTokensWithRatio = cachedCreationTokensWithRatio.Add(decimal.NewFromInt(int64(summary.CacheCreationTokens1h)).Mul(dCacheCreationRatio1h))
}
}
var imageTokensWithRatio decimal.Decimal
if !dImageTokens.IsZero() {
baseTokens = baseTokens.Sub(dImageTokens)
imageTokensWithRatio = dImageTokens.Mul(dImageRatio)
}
if !dAudioTokens.IsZero() {
summary.AudioInputPrice = operation_setting.GetGeminiInputAudioPricePerMillionTokens(summary.ModelName)
if summary.AudioInputPrice > 0 {
baseTokens = baseTokens.Sub(dAudioTokens)
audioInputQuota = decimal.NewFromFloat(summary.AudioInputPrice).
Div(decimal.NewFromInt(1000000)).Mul(dAudioTokens).Mul(dGroupRatio).Mul(dQuotaPerUnit)
}
}
// OpenAI cache-write usage reports unadjusted prefix counts, so
// cached_tokens + cache_write_tokens can exceed prompt_tokens and the
// remainder can go negative. Clamp at zero so overlap never turns into
// a negative base charge.
if baseTokens.IsNegative() {
baseTokens = decimal.Zero
}
promptQuota := baseTokens.Add(cachedTokensWithRatio).Add(imageTokensWithRatio).Add(cachedCreationTokensWithRatio)
completionQuota := dCompletionTokens.Mul(dCompletionRatio)
quotaCalculateDecimal := promptQuota.Add(completionQuota).Mul(ratio)
quotaCalculateDecimal = quotaCalculateDecimal.Add(audioInputQuota)
quotaCalculateDecimal = relayInfo.PriceData.ApplyOtherRatiosToDecimal(quotaCalculateDecimal)
quotaCalculateDecimal = quotaCalculateDecimal.Add(summary.ToolCallSurchargeQuota)
if !ratio.IsZero() && quotaCalculateDecimal.LessThanOrEqual(decimal.Zero) {
quotaCalculateDecimal = decimal.NewFromInt(1)
}
quota, clamp := common.QuotaFromDecimalChecked(quotaCalculateDecimal)
summary.Quota = quota
noteQuotaClamp(relayInfo, clamp)
} else {
quotaCalculateDecimal := dModelPrice.Mul(dQuotaPerUnit).Mul(dGroupRatio)
quotaCalculateDecimal = quotaCalculateDecimal.Add(audioInputQuota)
quotaCalculateDecimal = relayInfo.PriceData.ApplyOtherRatiosToDecimal(quotaCalculateDecimal)
quotaCalculateDecimal = quotaCalculateDecimal.Add(summary.ToolCallSurchargeQuota)
quota, clamp := common.QuotaFromDecimalChecked(quotaCalculateDecimal)
summary.Quota = quota
noteQuotaClamp(relayInfo, clamp)
}
if !summary.hasBillableUsage() {
summary.Quota = 0
} else if !ratio.IsZero() && summary.Quota == 0 {
summary.Quota = 1
}
return summary
}
func usageSemanticFromUsage(relayInfo *relaycommon.RelayInfo, usage *dto.Usage) string {
if usage != nil && usage.UsageSemantic != "" {
return usage.UsageSemantic
}
if relayInfo != nil && relayInfo.GetFinalRequestRelayFormat() == types.RelayFormatClaude {
return "anthropic"
}
return "openai"
}
func PostTextConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.Usage, extraContent []string) {
originUsage := usage
billingUsage := effectiveBillingUsage(usage)
if usage == nil {
extraContent = append(extraContent, "上游无计费信息")
}
if originUsage != nil {
ObserveChannelAffinityUsageCacheByRelayFormat(ctx, billingUsage, relayInfo.GetFinalRequestRelayFormat())
}
adminRejectReason := common.GetContextKeyString(ctx, constant.ContextKeyAdminRejectReason)
summary := calculateTextQuotaSummary(ctx, relayInfo, billingUsage)
var tieredResult *billingexpr.TieredResult
tieredBillingApplied := false
if originUsage != nil {
var tieredUsedVars map[string]bool
if snap := relayInfo.TieredBillingSnapshot; snap != nil {
tieredUsedVars = billingexpr.UsedVars(snap.ExprString)
}
tieredOk, tieredQuota, tieredRes := TryTieredSettle(relayInfo, BuildTieredTokenParams(billingUsage, summary.IsClaudeUsageSemantic, tieredUsedVars))
if tieredOk {
tieredBillingApplied = true
tieredResult = tieredRes
summary.Quota = composeTieredTextQuota(relayInfo, summary, tieredQuota, tieredRes)
}
}
for _, item := range summary.ToolSurchargeItems {
q := decimal.NewFromFloat(item.Price).
Mul(decimal.NewFromInt(int64(item.Count))).
Div(decimal.NewFromInt(1000)).
Mul(decimal.NewFromFloat(summary.GroupRatio)).
Mul(decimal.NewFromFloat(common.QuotaPerUnit))
extraContent = append(extraContent, fmt.Sprintf(
"%s 调用 %d 次,调用花费 %s",
item.Name,
item.Count,
logger.LogQuota(common.QuotaFromDecimal(q)),
))
}
if summary.AudioInputPrice > 0 && summary.AudioTokens > 0 {
q := decimal.NewFromFloat(summary.AudioInputPrice).Div(decimal.NewFromInt(1000000)).Mul(decimal.NewFromInt(int64(summary.AudioTokens))).Mul(decimal.NewFromFloat(summary.GroupRatio)).Mul(decimal.NewFromFloat(common.QuotaPerUnit))
extraContent = append(extraContent, fmt.Sprintf("Audio Input 花费 %s", logger.LogQuota(common.QuotaFromDecimal(q))))
}
if !summary.hasBillableUsage() {
extraContent = append(extraContent, "上游没有返回计费信息,无法扣费(可能是上游超时)")
logger.LogError(ctx, fmt.Sprintf("total tokens is 0, cannot consume quota, userId %d, channelId %d, tokenId %d, model %s, pre-consumed quota %d", relayInfo.UserId, relayInfo.ChannelId, relayInfo.TokenId, summary.ModelName, relayInfo.FinalPreConsumedQuota))
} else {
model.UpdateUserUsedQuotaAndRequestCount(relayInfo.UserId, summary.Quota)
model.UpdateChannelUsedQuota(relayInfo.ChannelId, summary.Quota)
}
if err := SettleBilling(ctx, relayInfo, summary.Quota); err != nil {
logger.LogError(ctx, "error settling billing: "+err.Error())
}
logModel := summary.ModelName
if strings.HasPrefix(logModel, "gpt-4-gizmo") {
logModel = "gpt-4-gizmo-*"
extraContent = append(extraContent, fmt.Sprintf("模型 %s", summary.ModelName))
}
if strings.HasPrefix(logModel, "gpt-4o-gizmo") {
logModel = "gpt-4o-gizmo-*"
extraContent = append(extraContent, fmt.Sprintf("模型 %s", summary.ModelName))
}
logContent := strings.Join(extraContent, ", ")
var other *model.LogOther
if summary.IsClaudeUsageSemantic {
other = GenerateClaudeOtherInfo(ctx, relayInfo,
summary.ModelRatio, summary.GroupRatio, summary.CompletionRatio,
summary.CacheTokens, summary.CacheRatio,
summary.CacheCreationTokens, summary.CacheCreationRatio,
summary.CacheCreationTokens5m, summary.CacheCreationRatio5m,
summary.CacheCreationTokens1h, summary.CacheCreationRatio1h,
summary.ModelPrice, relayInfo.PriceData.GroupRatioInfo.GroupSpecialRatio)
other.SetPublic("usage_semantic", "anthropic")
} else {
other = GenerateTextOtherInfo(ctx, relayInfo, summary.ModelRatio, summary.GroupRatio, summary.CompletionRatio, summary.CacheTokens, summary.CacheRatio, summary.ModelPrice, relayInfo.PriceData.GroupRatioInfo.GroupSpecialRatio)
}
appendUsageBillingPathForLog(other, common.GetContextKeyBool(ctx, constant.ContextKeyLocalCountTokens), originUsage)
if adminRejectReason != "" {
other.SetAdmin("reject_reason", adminRejectReason)
}
if summary.ImageTokens != 0 {
other.SetPublic("image", true)
other.SetPublic("image_ratio", summary.ImageRatio)
other.SetPublic("image_output", summary.ImageTokens)
}
appendToolSurchargeLogInfo(other, summary.ToolSurchargeItems)
if summary.AudioInputPrice > 0 && summary.AudioTokens > 0 {
other.SetPublic("audio_input_seperate_price", true)
other.SetPublic("audio_input_token_count", summary.AudioTokens)
other.SetPublic("audio_input_price", summary.AudioInputPrice)
}
if summary.CacheCreationTokens > 0 {
other.SetPublic("cache_creation_tokens", summary.CacheCreationTokens)
other.SetPublic("cache_creation_ratio", summary.CacheCreationRatio)
}
if summary.CacheCreationTokens5m > 0 {
other.SetPublic("cache_creation_tokens_5m", summary.CacheCreationTokens5m)
other.SetPublic("cache_creation_ratio_5m", summary.CacheCreationRatio5m)
}
if summary.CacheCreationTokens1h > 0 {
other.SetPublic("cache_creation_tokens_1h", summary.CacheCreationTokens1h)
other.SetPublic("cache_creation_ratio_1h", summary.CacheCreationRatio1h)
}
cacheWriteTokens := cacheWriteTokensTotal(summary)
if cacheWriteTokens > 0 {
// cache_write_tokens: normalized cache creation total for UI display.
// If split 5m/1h values are present, this is their sum; otherwise it falls back
// to cache_creation_tokens.
other.SetPublic("cache_write_tokens", cacheWriteTokens)
}
if relayInfo.GetFinalRequestRelayFormat() != types.RelayFormatClaude && billingUsage != nil && billingUsage.UsageSource != "" && billingUsage.InputTokens > 0 {
// input_tokens_total: explicit normalized total input used by the usage log UI.
// Only write this field when upstream/current conversion has already provided a
// reliable total input value and tagged the usage source. Do not infer it from
// prompt/cache fields here, otherwise old upstream payloads may be double-counted.
other.SetPublic("input_tokens_total", billingUsage.InputTokens)
}
if tieredBillingApplied {
InjectTieredBillingInfo(other, relayInfo, tieredResult)
}
attachQuotaSaturation(ctx, relayInfo, other)
model.RecordConsumeLog(ctx, relayInfo.UserId, model.RecordConsumeLogParams{
ChannelId: relayInfo.ChannelId,
PromptTokens: summary.PromptTokens,
CompletionTokens: summary.CompletionTokens,
ModelName: logModel,
TokenName: summary.TokenName,
Quota: summary.Quota,
Content: logContent,
TokenId: relayInfo.TokenId,
UseTimeSeconds: int(summary.UseTimeSeconds),
IsStream: relayInfo.IsStream,
Group: relayInfo.UsingGroup,
Other: other,
})
gopool.Go(func() {
perfmetrics.RecordRelaySample(relayInfo, true, int64(summary.CompletionTokens))
})
}
File diff suppressed because it is too large. Load diff
+189
View File
@@ -0,0 +1,189 @@
package service
import (
"net/http"
"github.com/QuantumNous/new-api/pkg/billingexpr"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/gin-gonic/gin"
)
// TieredResultWrapper wraps billingexpr.TieredResult for use at the service layer.
type TieredResultWrapper = billingexpr.TieredResult
// BuildTieredTokenParams constructs billingexpr.TokenParams from a dto.Usage,
// normalizing P and C so they mean "tokens not separately priced by the
// expression". Sub-categories (cache, image, audio) are only subtracted
// when the expression references them via their own variable.
//
// GPT-format APIs report prompt_tokens / completion_tokens as totals that
// include all sub-categories (cache, image, audio). Claude-format APIs
// report them as text-only. This function normalizes to text-only when
// sub-categories are separately priced.
func BuildTieredTokenParams(usage *dto.Usage, isClaudeUsageSemantic bool, usedVars map[string]bool) billingexpr.TokenParams {
p := float64(usage.PromptTokens)
c := float64(usage.CompletionTokens)
cr := float64(usage.PromptTokensDetails.CachedTokens)
cc5m := float64(usage.PromptTokensDetails.CacheCreationTokensTotal())
cc1h := float64(0)
if usage.UsageSemantic == "anthropic" {
cc1h = float64(usage.ClaudeCacheCreation1hTokens)
cc5m = float64(usage.ClaudeCacheCreation5mTokens)
}
img := float64(usage.PromptTokensDetails.ImageTokens)
ai := float64(usage.PromptTokensDetails.AudioTokens)
imgO := float64(usage.CompletionTokenDetails.ImageTokens)
ao := float64(usage.CompletionTokenDetails.AudioTokens)
// len = total input context length for tier condition evaluation.
// Non-Claude: prompt_tokens already includes everything.
// Claude: input_tokens is text-only, so add cache read + cache creation.
inputLen := p
if isClaudeUsageSemantic {
inputLen = p + cr + cc5m + cc1h
}
if !isClaudeUsageSemantic {
if usedVars["cr"] {
p -= cr
}
if usedVars["cc"] {
p -= cc5m
}
if usedVars["cc1h"] {
p -= cc1h
}
if usedVars["img"] {
p -= img
}
if usedVars["ai"] {
p -= ai
}
if usedVars["img_o"] {
c -= imgO
}
if usedVars["ao"] {
c -= ao
}
}
// OpenAI cache-write usage reports unadjusted prefix counts, so cr + cc can
// exceed the prompt and drive the remainder negative. Clamp at zero.
if p < 0 {
p = 0
}
if c < 0 {
c = 0
}
return billingexpr.TokenParams{
P: p,
C: c,
Len: inputLen,
CR: cr,
CC: cc5m,
CC1h: cc1h,
Img: img,
ImgO: imgO,
AI: ai,
AO: ao,
}
}
func refreshTieredBillingGroup(relayInfo *relaycommon.RelayInfo) (*billingexpr.BillingSnapshot, error) {
if relayInfo == nil {
return nil, nil
}
snap := relayInfo.TieredBillingSnapshot
if snap == nil || snap.BillingMode != "tiered_expr" {
return nil, nil
}
groupRatio := relayInfo.PriceData.GroupRatioInfo.GroupRatio
if snap.GroupRatio == groupRatio {
return snap, nil
}
estimatedQuotaAfterGroup := snap.EstimatedQuotaBeforeGroup * groupRatio
estimatedQuota, err := billingexpr.QuotaRoundStrict(estimatedQuotaAfterGroup)
if err != nil {
return nil, err
}
snap.GroupRatio = groupRatio
snap.EstimatedQuotaAfterGroup = estimatedQuota
return snap, nil
}
// PrepareTieredBillingForSelectedGroup refreshes routing-dependent billing
// state before an upstream attempt. An existing session reserves any higher
// estimate before sending. If the initial group was free and skipped
// pre-consume, switching to a paid group creates the session at that point.
func PrepareTieredBillingForSelectedGroup(c *gin.Context, relayInfo *relaycommon.RelayInfo) *types.NewAPIError {
snap, err := refreshTieredBillingGroup(relayInfo)
if err != nil {
return types.NewErrorWithStatusCode(
err,
types.ErrorCodeModelPriceError,
http.StatusBadRequest,
types.ErrOptionWithSkipRetry(),
)
}
if snap == nil {
return nil
}
if snap.GroupRatio == 0 {
// Paid-to-free keeps FreeModel as-is: FreeModel means "pre-consume was
// skipped", which is not true once a session exists, and settlement
// already yields 0 for a zero group ratio.
return nil
}
// The selected group is paid; clear a FreeModel flag frozen when the
// initial group was free so downstream state stays consistent.
relayInfo.PriceData.FreeModel = false
if relayInfo.Billing == nil {
return PreConsumeBilling(c, snap.EstimatedQuotaAfterGroup, relayInfo)
}
if err := relayInfo.Billing.Reserve(snap.EstimatedQuotaAfterGroup); err != nil {
return types.NewError(err, types.ErrorCodeUpdateDataError, types.ErrOptionWithSkipRetry())
}
relayInfo.FinalPreConsumedQuota = relayInfo.Billing.GetPreConsumedQuota()
return nil
}
// TryTieredSettle checks if the request uses tiered_expr billing and, if so,
// computes the actual quota using the captured BillingSnapshot. Returns:
// - ok=true, quota, result when tiered billing applies
// - ok=false, 0, nil when it doesn't (caller should fall through to existing logic)
func TryTieredSettle(relayInfo *relaycommon.RelayInfo, params billingexpr.TokenParams) (ok bool, quota int, result *billingexpr.TieredResult) {
snap := relayInfo.TieredBillingSnapshot
if snap == nil || snap.BillingMode != "tiered_expr" {
return false, 0, nil
}
requestInput := billingexpr.RequestInput{}
if relayInfo.BillingRequestInput != nil {
requestInput = *relayInfo.BillingRequestInput
}
tr, err := billingexpr.ComputeTieredQuotaWithRequest(snap, params, requestInput)
if err != nil {
quota = relayInfo.FinalPreConsumedQuota
if quota <= 0 {
quota = snap.EstimatedQuotaAfterGroup
}
return true, quota, nil
}
// Surface any single-request saturation from settlement onto RelayInfo so the
// consume log records it under admin_info, regardless of which caller
// (text, audio, WSS) consumes the returned quota. First non-nil wins.
noteQuotaClamp(relayInfo, tr.Clamp)
return true, tr.ActualQuotaAfterGroup, &tr
}
File diff suppressed because it is too large. Load diff
+416
View File
@@ -0,0 +1,416 @@
package service
import (
"errors"
"fmt"
"math"
"path/filepath"
"strings"
"unicode/utf8"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/logger"
relaycommon "github.com/QuantumNous/new-api/relay/common"
constant2 "github.com/QuantumNous/new-api/relay/constant"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/gin-gonic/gin"
)
func getImageToken(c *gin.Context, fileMeta *types.FileMeta, model string, stream bool) (int, error) {
if fileMeta == nil || fileMeta.Source == nil {
return 0, fmt.Errorf("image_url_is_nil")
}
// Defaults for 4o/4.1/4.5 family unless overridden below
baseTokens := 85
tileTokens := 170
// Model classification
lowerModel := strings.ToLower(model)
// Special cases from existing behavior
if strings.HasPrefix(lowerModel, "glm-4") {
return 1047, nil
}
// Patch-based models (32x32 patches, capped at 1536, with multiplier)
isPatchBased := false
multiplier := 1.0
switch {
case strings.Contains(lowerModel, "gpt-4.1-mini"):
isPatchBased = true
multiplier = 1.62
case strings.Contains(lowerModel, "gpt-4.1-nano"):
isPatchBased = true
multiplier = 2.46
case strings.HasPrefix(lowerModel, "o4-mini"):
isPatchBased = true
multiplier = 1.72
case strings.HasPrefix(lowerModel, "gpt-5-mini"):
isPatchBased = true
multiplier = 1.62
case strings.HasPrefix(lowerModel, "gpt-5-nano"):
isPatchBased = true
multiplier = 2.46
}
// Tile-based model tokens and bases per doc
if !isPatchBased {
if strings.HasPrefix(lowerModel, "gpt-4o-mini") {
baseTokens = 2833
tileTokens = 5667
} else if strings.HasPrefix(lowerModel, "gpt-5-chat-latest") || (strings.HasPrefix(lowerModel, "gpt-5") && !strings.Contains(lowerModel, "mini") && !strings.Contains(lowerModel, "nano")) {
baseTokens = 70
tileTokens = 140
} else if strings.HasPrefix(lowerModel, "o1") || strings.HasPrefix(lowerModel, "o3") || strings.HasPrefix(lowerModel, "o1-pro") {
baseTokens = 75
tileTokens = 150
} else if strings.Contains(lowerModel, "computer-use-preview") {
baseTokens = 65
tileTokens = 129
} else if strings.Contains(lowerModel, "4.1") || strings.Contains(lowerModel, "4o") || strings.Contains(lowerModel, "4.5") {
baseTokens = 85
tileTokens = 170
}
}
// Respect existing feature flags/short-circuits
if fileMeta.Detail == "low" && !isPatchBased {
return baseTokens, nil
}
// Whether to count image tokens at all
if !constant.GetMediaToken {
return 3 * baseTokens, nil
}
if !constant.GetMediaTokenNotStream && !stream {
return 3 * baseTokens, nil
}
// Normalize detail
if fileMeta.Detail == "auto" || fileMeta.Detail == "" {
fileMeta.Detail = "high"
}
// 使用统一的文件服务获取图片配置
config, format, err := GetImageConfig(c, fileMeta.Source)
if err != nil {
return 0, err
}
if config.Width == 0 || config.Height == 0 {
// not an image, but might be a valid file
if format != "" {
// file type
return 3 * baseTokens, nil
}
return 0, errors.New(fmt.Sprintf("fail to decode image config: %s", fileMeta.GetIdentifier()))
}
width := config.Width
height := config.Height
logger.LogDebug(c, "image token input: format=%s, width=%d, height=%d", format, width, height)
if isPatchBased {
// 32x32 patch-based calculation with 1536 cap and model multiplier
ceilDiv := func(a, b int) int { return (a + b - 1) / b }
rawPatchesW := ceilDiv(width, 32)
rawPatchesH := ceilDiv(height, 32)
rawPatches := rawPatchesW * rawPatchesH
if rawPatches > 1536 {
// scale down
area := float64(width * height)
r := math.Sqrt(float64(32*32*1536) / area)
wScaled := float64(width) * r
hScaled := float64(height) * r
// adjust to fit whole number of patches after scaling
adjW := math.Floor(wScaled/32.0) / (wScaled / 32.0)
adjH := math.Floor(hScaled/32.0) / (hScaled / 32.0)
adj := math.Min(adjW, adjH)
if !math.IsNaN(adj) && adj > 0 {
r = r * adj
}
wScaled = float64(width) * r
hScaled = float64(height) * r
patchesW := math.Ceil(wScaled / 32.0)
patchesH := math.Ceil(hScaled / 32.0)
imageTokens := min(int(patchesW*patchesH), 1536)
return common.QuotaRound(float64(imageTokens) * multiplier), nil
}
// below cap
imageTokens := rawPatches
return common.QuotaRound(float64(imageTokens) * multiplier), nil
}
// Tile-based calculation for 4o/4.1/4.5/o1/o3/etc.
// Step 1: fit within 2048x2048 square
maxSide := math.Max(float64(width), float64(height))
fitScale := 1.0
if maxSide > 2048 {
fitScale = maxSide / 2048.0
}
fitW := int(math.Round(float64(width) / fitScale))
fitH := int(math.Round(float64(height) / fitScale))
// Step 2: scale so that shortest side is exactly 768
minSide := math.Min(float64(fitW), float64(fitH))
if minSide == 0 {
return baseTokens, nil
}
shortScale := 768.0 / minSide
finalW := int(math.Round(float64(fitW) * shortScale))
finalH := int(math.Round(float64(fitH) * shortScale))
// Count 512px tiles
tilesW := (finalW + 512 - 1) / 512
tilesH := (finalH + 512 - 1) / 512
tiles := tilesW * tilesH
logger.LogDebug(c, "image token scaled size: width=%d, height=%d, tiles=%d", finalW, finalH, tiles)
return tiles*tileTokens + baseTokens, nil
}
func EstimateRequestToken(c *gin.Context, meta *types.TokenCountMeta, info *relaycommon.RelayInfo) (int, error) {
// 是否统计token
if !constant.CountToken {
return 0, nil
}
return CountRequestToken(c, meta, info)
}
// CountRequestToken counts request tokens regardless of the billing estimation
// switch. Utility endpoints such as Claude's messages/count_tokens must remain
// available even when operators disable request-token estimation for relays.
func CountRequestToken(c *gin.Context, meta *types.TokenCountMeta, info *relaycommon.RelayInfo) (int, error) {
if meta == nil {
return 0, errors.New("token count meta is nil")
}
if info.RelayFormat == types.RelayFormatOpenAIRealtime {
return 0, nil
}
if info.RelayMode == constant2.RelayModeAudioTranscription || info.RelayMode == constant2.RelayModeAudioTranslation {
multiForm, err := common.ParseMultipartFormReusable(c)
if err != nil {
return 0, fmt.Errorf("error parsing multipart form: %v", err)
}
fileHeaders := multiForm.File["file"]
totalAudioToken := 0
for _, fileHeader := range fileHeaders {
file, err := fileHeader.Open()
if err != nil {
return 0, fmt.Errorf("error opening audio file: %v", err)
}
defer file.Close()
// get ext and io.seeker
ext := filepath.Ext(fileHeader.Filename)
duration, err := common.GetAudioDuration(c.Request.Context(), file, ext)
if err != nil {
return 0, fmt.Errorf("error getting audio duration: %v", err)
}
// duration 来自用户上传文件的元数据,可被伪造成天文数字或负数。
// 负值会让 token 估算变成负数(低估预扣费),先钳到 0 再转换。
if duration < 0 {
duration = 0
}
// 一分钟 1000 token,与 $price / minute 对齐。
totalAudioToken += common.QuotaRound(math.Ceil(duration) / 60.0 * 1000)
}
return totalAudioToken, nil
}
model := common.GetContextKeyString(c, constant.ContextKeyOriginalModel)
tkm := 0
if meta.TokenType == types.TokenTypeTextNumber {
tkm += utf8.RuneCountInString(meta.CombineText)
} else {
tkm += CountTextToken(meta.CombineText, model)
}
if info.RelayFormat == types.RelayFormatOpenAI {
tkm += meta.ToolsCount * 8
tkm += meta.MessagesCount * 3 // 每条消息的格式化token数量
tkm += meta.NameCount * 3
tkm += 3
}
shouldFetchFiles := true
if info.RelayFormat == types.RelayFormatGemini {
shouldFetchFiles = false
}
// 是否本地计算媒体token数量
if !constant.GetMediaToken {
shouldFetchFiles = false
}
// 是否在非流模式下本地计算媒体token数量
if !constant.GetMediaTokenNotStream && !info.IsStream {
shouldFetchFiles = false
}
// 使用统一的文件服务获取文件类型
for _, file := range meta.Files {
if file.Source == nil {
continue
}
// 如果文件类型未知且需要获取,通过 MIME 类型检测
if file.FileType == "" || (file.Source.IsURL() && shouldFetchFiles) {
// 注意:这里我们直接调用 LoadFileSource 而不是 GetMimeType
// 因为 GetMimeType 内部可能会调用 GetFileTypeFromUrl (HEAD 请求)
// 而我们这里既然要计算 token,通常需要完整数据
cachedData, err := LoadFileSource(c, file.Source, "token_counter")
if err != nil {
if shouldFetchFiles {
return 0, fmt.Errorf("error getting file type: %v", err)
}
continue
}
file.FileType = DetectFileType(cachedData.MimeType)
}
}
for i, file := range meta.Files {
switch file.FileType {
case types.FileTypeImage:
if common.IsOpenAITextModel(model) {
token, err := getImageToken(c, file, model, info.IsStream)
if err != nil {
return 0, fmt.Errorf("error counting image token, media index[%d], identifier[%s], err: %v", i, file.GetIdentifier(), err)
}
tkm += token
} else {
tkm += 520
}
case types.FileTypeAudio:
tkm += 256
case types.FileTypeVideo:
tkm += 4096 * 2
case types.FileTypeFile:
tkm += 4096
default:
tkm += 4096 // Default case for unknown file types
}
}
common.SetContextKey(c, constant.ContextKeyPromptTokens, tkm)
return tkm, nil
}
func CountTokenRealtime(info *relaycommon.RelayInfo, request dto.RealtimeEvent, model string) (int, int, error) {
audioToken := 0
textToken := 0
switch request.Type {
case dto.RealtimeEventTypeSessionUpdate:
if request.Session != nil {
msgTokens := CountTextToken(request.Session.Instructions, model)
textToken += msgTokens
}
case dto.RealtimeEventResponseAudioDelta:
// count audio token
atk, err := CountAudioTokenOutput(request.Delta, info.OutputAudioFormat)
if err != nil {
return 0, 0, fmt.Errorf("error counting audio token: %v", err)
}
audioToken += atk
case dto.RealtimeEventResponseAudioTranscriptionDelta, dto.RealtimeEventResponseFunctionCallArgumentsDelta:
// count text token
tkm := CountTextToken(request.Delta, model)
textToken += tkm
case dto.RealtimeEventInputAudioBufferAppend:
// count audio token
atk, err := CountAudioTokenInput(request.Audio, info.InputAudioFormat)
if err != nil {
return 0, 0, fmt.Errorf("error counting audio token: %v", err)
}
audioToken += atk
case dto.RealtimeEventConversationItemCreated:
if request.Item != nil {
switch request.Item.Type {
case "message":
for _, content := range request.Item.Content {
if content.Type == "input_text" {
tokens := CountTextToken(content.Text, model)
textToken += tokens
}
}
}
}
case dto.RealtimeEventTypeResponseDone:
// count tools token
if !info.IsFirstRequest {
if info.RealtimeTools != nil && len(info.RealtimeTools) > 0 {
for _, tool := range info.RealtimeTools {
toolTokens := CountTokenInput(tool, model)
textToken += 8
textToken += toolTokens
}
}
}
}
return textToken, audioToken, nil
}
func CountTokenInput(input any, model string) int {
switch v := input.(type) {
case string:
return CountTextToken(v, model)
case []string:
var text strings.Builder
for _, s := range v {
text.WriteString(s)
}
return CountTextToken(text.String(), model)
case []any:
var text strings.Builder
for _, item := range v {
text.WriteString(fmt.Sprintf("%v", item))
}
return CountTextToken(text.String(), model)
}
return CountTokenInput(fmt.Sprintf("%v", input), model)
}
func CountAudioTokenInput(audioBase64 string, audioFormat string) (int, error) {
if audioBase64 == "" {
return 0, nil
}
duration, err := parseAudio(audioBase64, audioFormat)
if err != nil {
return 0, err
}
// duration 来自用户提供的音频元数据,饱和转换防止 int 回绕
return common.QuotaFromFloat(duration / 60 * 100 / 0.06), nil
}
func CountAudioTokenOutput(audioBase64 string, audioFormat string) (int, error) {
if audioBase64 == "" {
return 0, nil
}
duration, err := parseAudio(audioBase64, audioFormat)
if err != nil {
return 0, err
}
// duration 来自上游返回的音频元数据,饱和转换防止 int 回绕
return common.QuotaFromFloat(duration / 60 * 200 / 0.24), nil
}
// CountTextToken 统计文本的token数量,仅OpenAI模型使用tokenizer,其余模型使用估算
func CountTextToken(text string, model string) int {
if text == "" {
return 0
}
if common.IsOpenAITextModel(model) {
tokenEncoder := getTokenEncoder(model)
return getTokenNum(tokenEncoder, text)
} else {
// 非openai模型,使用tiktoken-go计算没有意义,使用估算节省资源
return EstimateTokenByModel(model, text)
}
}
+230
View File
@@ -0,0 +1,230 @@
package service
import (
"math"
"strings"
"sync"
"unicode"
)
// Provider 定义模型厂商大类
type Provider string
const (
OpenAI Provider = "openai" // 代表 GPT-3.5, GPT-4, GPT-4o
Gemini Provider = "gemini" // 代表 Gemini 1.0, 1.5 Pro/Flash
Claude Provider = "claude" // 代表 Claude 3, 3.5 Sonnet
Unknown Provider = "unknown" // 兜底默认
)
// multipliers 定义不同厂商的计费权重
type multipliers struct {
Word float64 // 英文单词 (每词)
Number float64 // 数字 (每连续数字串)
CJK float64 // 中日韩字符 (每字)
Symbol float64 // 普通标点符号 (每个)
MathSymbol float64 // 数学符号 (∑,∫,∂,√等,每个)
URLDelim float64 // URL分隔符 (/,:,?,&,=,#,%) - tokenizer优化好
AtSign float64 // @符号 - 导致单词切分,消耗较高
Emoji float64 // Emoji表情 (每个)
Newline float64 // 换行符/制表符 (每个)
Space float64 // 空格 (每个)
BasePad int // 基础起步消耗 (Start/End tokens)
}
var (
multipliersMap = map[Provider]multipliers{
Gemini: {
Word: 1.15, Number: 2.8, CJK: 0.68, Symbol: 0.38, MathSymbol: 1.05, URLDelim: 1.2, AtSign: 2.5, Emoji: 1.08, Newline: 1.15, Space: 0.2, BasePad: 0,
},
Claude: {
Word: 1.13, Number: 1.63, CJK: 1.21, Symbol: 0.4, MathSymbol: 4.52, URLDelim: 1.26, AtSign: 2.82, Emoji: 2.6, Newline: 0.89, Space: 0.39, BasePad: 0,
},
OpenAI: {
Word: 1.02, Number: 1.55, CJK: 0.85, Symbol: 0.4, MathSymbol: 2.68, URLDelim: 1.0, AtSign: 2.0, Emoji: 2.12, Newline: 0.5, Space: 0.42, BasePad: 0,
},
}
multipliersLock sync.RWMutex
)
// getMultipliers 根据厂商获取权重配置
func getMultipliers(p Provider) multipliers {
multipliersLock.RLock()
defer multipliersLock.RUnlock()
switch p {
case Gemini:
return multipliersMap[Gemini]
case Claude:
return multipliersMap[Claude]
case OpenAI:
return multipliersMap[OpenAI]
default:
// 默认兜底 (按 OpenAI 的算)
return multipliersMap[OpenAI]
}
}
// EstimateToken 计算 Token 数量
func EstimateToken(provider Provider, text string) int {
m := getMultipliers(provider)
var count float64
// 状态机变量
type WordType int
const (
None WordType = iota
Latin
Number
)
currentWordType := None
for _, r := range text {
// 1. 处理空格和换行符
if unicode.IsSpace(r) {
currentWordType = None
// 换行符和制表符使用Newline权重
if r == '\n' || r == '\t' {
count += m.Newline
} else {
// 普通空格使用Space权重
count += m.Space
}
continue
}
// 2. 处理 CJK (中日韩) - 按字符计费
if isCJK(r) {
currentWordType = None
count += m.CJK
continue
}
// 3. 处理Emoji - 使用专门的Emoji权重
if isEmoji(r) {
currentWordType = None
count += m.Emoji
continue
}
// 4. 处理拉丁字母/数字 (英文单词)
if isLatinOrNumber(r) {
isNum := unicode.IsNumber(r)
newType := Latin
if isNum {
newType = Number
}
// 如果之前不在单词中,或者类型发生变化(字母<->数字),则视为新token
// 注意:对于OpenAI,通常"version 3.5"会切分,"abc123xyz"有时也会切分
// 这里简单起见,字母和数字切换时增加权重
if currentWordType == None || currentWordType != newType {
if newType == Number {
count += m.Number
} else {
count += m.Word
}
currentWordType = newType
}
// 单词中间的字符不额外计费
continue
}
// 5. 处理标点符号/特殊字符 - 按类型使用不同权重
currentWordType = None
if isMathSymbol(r) {
count += m.MathSymbol
} else if r == '@' {
count += m.AtSign
} else if isURLDelim(r) {
count += m.URLDelim
} else {
count += m.Symbol
}
}
// 向上取整并加上基础 padding
return int(math.Ceil(count)) + m.BasePad
}
// 辅助:判断是否为 CJK 字符
func isCJK(r rune) bool {
return unicode.Is(unicode.Han, r) ||
(r >= 0x3040 && r <= 0x30FF) || // 日文
(r >= 0xAC00 && r <= 0xD7A3) // 韩文
}
// 辅助:判断是否为单词主体 (字母或数字)
func isLatinOrNumber(r rune) bool {
return unicode.IsLetter(r) || unicode.IsNumber(r)
}
// 辅助:判断是否为Emoji字符
func isEmoji(r rune) bool {
// Emoji的Unicode范围
// 基本范围:0x1F300-0x1F9FF (Emoticons, Symbols, Pictographs)
// 补充范围:0x2600-0x26FF (Misc Symbols), 0x2700-0x27BF (Dingbats)
// 表情符号:0x1F600-0x1F64F (Emoticons)
// 其他:0x1F900-0x1F9FF (Supplemental Symbols and Pictographs)
return (r >= 0x1F300 && r <= 0x1F9FF) ||
(r >= 0x2600 && r <= 0x26FF) ||
(r >= 0x2700 && r <= 0x27BF) ||
(r >= 0x1F600 && r <= 0x1F64F) ||
(r >= 0x1F900 && r <= 0x1F9FF) ||
(r >= 0x1FA00 && r <= 0x1FAFF) // Symbols and Pictographs Extended-A
}
// 辅助:判断是否为数学符号
func isMathSymbol(r rune) bool {
// 数学运算符和符号
// 基本数学符号:∑ ∫ ∂ √ ∞ ≤ ≥ ≠ ≈ ± × ÷
// 上下标数字:² ³ ¹ ⁴ ⁵ ⁶ ⁷ ⁸ ⁹ ⁰
// 希腊字母等也常用于数学
mathSymbols := "∑∫∂√∞≤≥≠≈±×÷∈∉∋∌⊂⊃⊆⊇∪∩∧∨¬∀∃∄∅∆∇∝∟∠∡∢°′″‴⁺⁻⁼⁽⁾ⁿ₀₁₂₃₄₅₆₇₈₉₊₋₌₍₎²³¹⁴⁵⁶⁷⁸⁹⁰"
for _, m := range mathSymbols {
if r == m {
return true
}
}
// Mathematical Operators (U+2200–U+22FF)
if r >= 0x2200 && r <= 0x22FF {
return true
}
// Supplemental Mathematical Operators (U+2A00–U+2AFF)
if r >= 0x2A00 && r <= 0x2AFF {
return true
}
// Mathematical Alphanumeric Symbols (U+1D400–U+1D7FF)
if r >= 0x1D400 && r <= 0x1D7FF {
return true
}
return false
}
// 辅助:判断是否为URL分隔符(tokenizer对这些优化较好)
func isURLDelim(r rune) bool {
// URL中常见的分隔符,tokenizer通常优化处理
urlDelims := "/:?&=;#%"
for _, d := range urlDelims {
if r == d {
return true
}
}
return false
}
func EstimateTokenByModel(model, text string) int {
// strings.Contains(model, "gpt-4o")
if text == "" {
return 0
}
model = strings.ToLower(model)
if strings.Contains(model, "gemini") {
return EstimateToken(Gemini, text)
} else if strings.Contains(model, "claude") {
return EstimateToken(Claude, text)
} else {
return EstimateToken(OpenAI, text)
}
}
+63
View File
@@ -0,0 +1,63 @@
package service
import (
"sync"
"github.com/QuantumNous/new-api/common"
"github.com/tiktoken-go/tokenizer"
"github.com/tiktoken-go/tokenizer/codec"
)
// tokenEncoderMap won't grow after initialization
var defaultTokenEncoder tokenizer.Codec
// tokenEncoderMap is used to store token encoders for different models
var tokenEncoderMap = make(map[string]tokenizer.Codec)
// tokenEncoderMutex protects tokenEncoderMap for concurrent access
var tokenEncoderMutex sync.RWMutex
func InitTokenEncoders() {
common.SysLog("initializing token encoders")
defaultTokenEncoder = codec.NewCl100kBase()
common.SysLog("token encoders initialized")
}
func getTokenEncoder(model string) tokenizer.Codec {
// First, try to get the encoder from cache with read lock
tokenEncoderMutex.RLock()
if encoder, exists := tokenEncoderMap[model]; exists {
tokenEncoderMutex.RUnlock()
return encoder
}
tokenEncoderMutex.RUnlock()
// If not in cache, create new encoder with write lock
tokenEncoderMutex.Lock()
defer tokenEncoderMutex.Unlock()
// Double-check if another goroutine already created the encoder
if encoder, exists := tokenEncoderMap[model]; exists {
return encoder
}
// Create new encoder
modelCodec, err := tokenizer.ForModel(tokenizer.Model(model))
if err != nil {
// Cache the default encoder for this model to avoid repeated failures
tokenEncoderMap[model] = defaultTokenEncoder
return defaultTokenEncoder
}
// Cache the new encoder
tokenEncoderMap[model] = modelCodec
return modelCodec
}
func getTokenNum(tokenEncoder tokenizer.Codec, text string) int {
if text == "" {
return 0
}
tkm, _ := tokenEncoder.Count(text)
return tkm
}
Loaded 100 of 2381 files, more files were not shown because too many files have changed in this diff. Show more