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
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:
commit
bb7968b612
2381 files changed
+551494
No files matched your search
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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, ", ")
|
||||
}
|
||||
@@ -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
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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.",
|
||||
}},
|
||||
})
|
||||
}
|
||||
@@ -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.",
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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.",
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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}))
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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{}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user