Commit d9321b7d by Seefs Committed by GitHub

feat: channel affinity (#2669)

* feat: channel affinity

* feat: channel affinity -> model setting

* fix: channel affinity

* feat: channel affinity op

* feat: channel_type setting

* feat: clean

* feat: cache supports both memory and Redis.

* feat: Optimise ui/ux

* feat: Optimise ui/ux

* feat: Optimise codex usage ui/ux

* feat: Optimise ui/ux

* feat: Optimise ui/ux

* feat: Optimise ui/ux

* feat: If the affinitized channel fails and a retry succeeds on another channel, update the affinity to the successful channel
parent 9ac94f03
package controller
import (
"net/http"
"strings"
"github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin"
)
func GetChannelAffinityCacheStats(c *gin.Context) {
stats := service.GetChannelAffinityCacheStats()
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": stats,
})
}
func ClearChannelAffinityCache(c *gin.Context) {
all := strings.TrimSpace(c.Query("all"))
ruleName := strings.TrimSpace(c.Query("rule_name"))
if all == "true" {
deleted := service.ClearChannelAffinityCacheAll()
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": gin.H{
"deleted": deleted,
},
})
return
}
if ruleName == "" {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "缺少参数:rule_name,或使用 all=true 清空全部",
})
return
}
deleted, err := service.ClearChannelAffinityCacheByRuleName(ruleName)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": gin.H{
"deleted": deleted,
},
})
}
...@@ -362,6 +362,7 @@ func processChannelError(c *gin.Context, channelError types.ChannelError, err *t ...@@ -362,6 +362,7 @@ func processChannelError(c *gin.Context, channelError types.ChannelError, err *t
adminInfo["is_multi_key"] = true adminInfo["is_multi_key"] = true
adminInfo["multi_key_index"] = common.GetContextKeyInt(c, constant.ContextKeyChannelMultiKeyIndex) adminInfo["multi_key_index"] = common.GetContextKeyInt(c, constant.ContextKeyChannelMultiKeyIndex)
} }
service.AppendChannelAffinityAdminInfo(c, adminInfo)
other["admin_info"] = adminInfo other["admin_info"] = adminInfo
model.RecordErrorLog(c, userId, channelId, modelName, tokenName, err.MaskSensitiveErrorWithStatusCode(), tokenId, 0, false, userGroup, other) model.RecordErrorLog(c, userId, channelId, modelName, tokenName, err.MaskSensitiveErrorWithStatusCode(), tokenId, 0, false, userGroup, other)
} }
......
...@@ -55,16 +55,18 @@ require ( ...@@ -55,16 +55,18 @@ require (
) )
require ( require (
github.com/DmitriyVTitov/size v1.5.0 // indirect
github.com/anknown/darts v0.0.0-20151216065714-83ff685239e6 // indirect github.com/anknown/darts v0.0.0-20151216065714-83ff685239e6 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.0 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.0 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.2 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.2 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.2 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.2 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/boombuler/barcode v1.1.0 // indirect github.com/boombuler/barcode v1.1.0 // indirect
github.com/bytedance/sonic v1.14.1 // indirect github.com/bytedance/sonic v1.14.1 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect github.com/cloudwego/base64x v0.1.6 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
...@@ -94,7 +96,7 @@ require ( ...@@ -94,7 +96,7 @@ require (
github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.17.8 // indirect github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
...@@ -103,10 +105,17 @@ require ( ...@@ -103,10 +105,17 @@ require (
github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/pelletier/go-toml/v2 v2.2.1 // indirect github.com/pelletier/go-toml/v2 v2.2.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/client_golang v1.22.0 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.62.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/samber/go-singleflightx v0.3.2 // indirect
github.com/samber/hot v0.11.0 // indirect
github.com/stretchr/objx v0.5.2 // indirect github.com/stretchr/objx v0.5.2 // indirect
github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect github.com/tidwall/pretty v1.2.0 // indirect
...@@ -120,7 +129,7 @@ require ( ...@@ -120,7 +129,7 @@ require (
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/sys v0.38.0 // indirect golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.31.0 // indirect golang.org/x/text v0.31.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect google.golang.org/protobuf v1.36.5 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.66.10 // indirect modernc.org/libc v1.66.10 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
......
github.com/Calcium-Ion/go-epay v0.0.4 h1:C96M7WfRLadcIVscWzwLiYs8etI1wrDmtFMuK2zP22A= github.com/Calcium-Ion/go-epay v0.0.4 h1:C96M7WfRLadcIVscWzwLiYs8etI1wrDmtFMuK2zP22A=
github.com/Calcium-Ion/go-epay v0.0.4/go.mod h1:cxo/ZOg8ClvE3VAnCmEzbuyAZINSq7kFEN9oHj5WQ2U= github.com/Calcium-Ion/go-epay v0.0.4/go.mod h1:cxo/ZOg8ClvE3VAnCmEzbuyAZINSq7kFEN9oHj5WQ2U=
github.com/DmitriyVTitov/size v1.5.0 h1:/PzqxYrOyOUX1BXj6J9OuVRVGe+66VL4D9FlUaW515g=
github.com/DmitriyVTitov/size v1.5.0/go.mod h1:le6rNI4CoLQV1b9gzp1+3d7hMAD/uu2QcJ+aYbNgiU0=
github.com/abema/go-mp4 v1.4.1 h1:YoS4VRqd+pAmddRPLFf8vMk74kuGl6ULSjzhsIqwr6M= github.com/abema/go-mp4 v1.4.1 h1:YoS4VRqd+pAmddRPLFf8vMk74kuGl6ULSjzhsIqwr6M=
github.com/abema/go-mp4 v1.4.1/go.mod h1:vPl9t5ZK7K0x68jh12/+ECWBCXoWuIDtNgPtU2f04ws= github.com/abema/go-mp4 v1.4.1/go.mod h1:vPl9t5ZK7K0x68jh12/+ECWBCXoWuIDtNgPtU2f04ws=
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
...@@ -22,6 +24,8 @@ github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.33.0 h1:JzidOz4Hcn2RbP5fv ...@@ -22,6 +24,8 @@ github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.33.0 h1:JzidOz4Hcn2RbP5fv
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.33.0/go.mod h1:9A4/PJYlWjvjEzzoOLGQjkLt4bYK9fRWi7uz1GSsAcA= github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.33.0/go.mod h1:9A4/PJYlWjvjEzzoOLGQjkLt4bYK9fRWi7uz1GSsAcA=
github.com/aws/smithy-go v1.22.5 h1:P9ATCXPMb2mPjYBgueqJNCA5S9UfktsW0tTxi+a7eqw= github.com/aws/smithy-go v1.22.5 h1:P9ATCXPMb2mPjYBgueqJNCA5S9UfktsW0tTxi+a7eqw=
github.com/aws/smithy-go v1.22.5/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI= github.com/aws/smithy-go v1.22.5/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo= github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo=
github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
...@@ -40,6 +44,8 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3 ...@@ -40,6 +44,8 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
...@@ -110,6 +116,7 @@ github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= ...@@ -110,6 +116,7 @@ github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
...@@ -165,6 +172,8 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr ...@@ -165,6 +172,8 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.17.8 h1:YcnTYrq7MikUT7k0Yb5eceMmALQPYBW/Xltxn0NAMnU= github.com/klauspost/compress v1.17.8 h1:YcnTYrq7MikUT7k0Yb5eceMmALQPYBW/Xltxn0NAMnU=
github.com/klauspost/compress v1.17.8/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= github.com/klauspost/compress v1.17.8/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
...@@ -200,6 +209,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJ ...@@ -200,6 +209,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJ
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
...@@ -218,13 +229,27 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= ...@@ -218,13 +229,27 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs= github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io=
github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I=
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8= github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
github.com/samber/go-singleflightx v0.3.2 h1:jXbUU0fvis8Fdv4HGONboX5WdEZcYLoBEcKiE+ITCyQ=
github.com/samber/go-singleflightx v0.3.2/go.mod h1:X2BR+oheHIYc73PvxRMlcASg6KYYTQyUYpdVU7t/ux4=
github.com/samber/hot v0.11.0 h1:JhV9hk8SmZIqB0To8OyCzPubvszkuoSXWx/7FCEGO+Q=
github.com/samber/hot v0.11.0/go.mod h1:NB9v5U4NfDx7jmlrP+zHuqCuLUsywgAtCH7XOAkOxAg=
github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw= github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw=
github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0= github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI= github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI=
...@@ -332,6 +357,8 @@ google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp0 ...@@ -332,6 +357,8 @@ google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp0
google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
......
...@@ -97,6 +97,31 @@ func Distribute() func(c *gin.Context) { ...@@ -97,6 +97,31 @@ func Distribute() func(c *gin.Context) {
common.SetContextKey(c, constant.ContextKeyUsingGroup, usingGroup) common.SetContextKey(c, constant.ContextKeyUsingGroup, usingGroup)
} }
} }
if preferredChannelID, found := service.GetPreferredChannelByAffinity(c, modelRequest.Model, usingGroup); found {
preferred, err := model.CacheGetChannel(preferredChannelID)
if err == nil && preferred != nil && preferred.Status == common.ChannelStatusEnabled {
if usingGroup == "auto" {
userGroup := common.GetContextKeyString(c, constant.ContextKeyUserGroup)
autoGroups := service.GetUserAutoGroup(userGroup)
for _, g := range autoGroups {
if model.IsChannelEnabledForGroupModel(g, modelRequest.Model, preferred.Id) {
selectGroup = g
common.SetContextKey(c, constant.ContextKeyAutoGroup, g)
channel = preferred
service.MarkChannelAffinityUsed(c, g, preferred.Id)
break
}
}
} else if model.IsChannelEnabledForGroupModel(usingGroup, modelRequest.Model, preferred.Id) {
channel = preferred
selectGroup = usingGroup
service.MarkChannelAffinityUsed(c, usingGroup, preferred.Id)
}
}
}
if channel == nil {
channel, selectGroup, err = service.CacheGetRandomSatisfiedChannel(&service.RetryParam{ channel, selectGroup, err = service.CacheGetRandomSatisfiedChannel(&service.RetryParam{
Ctx: c, Ctx: c,
ModelName: modelRequest.Model, ModelName: modelRequest.Model,
...@@ -123,9 +148,13 @@ func Distribute() func(c *gin.Context) { ...@@ -123,9 +148,13 @@ func Distribute() func(c *gin.Context) {
} }
} }
} }
}
common.SetContextKey(c, constant.ContextKeyRequestStartTime, time.Now()) common.SetContextKey(c, constant.ContextKeyRequestStartTime, time.Now())
SetupContextForSelectedChannel(c, channel, modelRequest.Model) SetupContextForSelectedChannel(c, channel, modelRequest.Model)
c.Next() c.Next()
if channel != nil && c.Writer != nil && c.Writer.Status() < http.StatusBadRequest {
service.RecordChannelAffinity(c, channel.Id)
}
} }
} }
......
package model
import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/setting/ratio_setting"
)
func IsChannelEnabledForGroupModel(group string, modelName string, channelID int) bool {
if group == "" || modelName == "" || channelID <= 0 {
return false
}
if !common.MemoryCacheEnabled {
return isChannelEnabledForGroupModelDB(group, modelName, channelID)
}
channelSyncLock.RLock()
defer channelSyncLock.RUnlock()
if group2model2channels == nil {
return false
}
if isChannelIDInList(group2model2channels[group][modelName], channelID) {
return true
}
normalized := ratio_setting.FormatMatchingModelName(modelName)
if normalized != "" && normalized != modelName {
return isChannelIDInList(group2model2channels[group][normalized], channelID)
}
return false
}
func IsChannelEnabledForAnyGroupModel(groups []string, modelName string, channelID int) bool {
if len(groups) == 0 {
return false
}
for _, g := range groups {
if IsChannelEnabledForGroupModel(g, modelName, channelID) {
return true
}
}
return false
}
func isChannelEnabledForGroupModelDB(group string, modelName string, channelID int) bool {
var count int64
err := DB.Model(&Ability{}).
Where(commonGroupCol+" = ? and model = ? and channel_id = ? and enabled = ?", group, modelName, channelID, true).
Count(&count).Error
if err == nil && count > 0 {
return true
}
normalized := ratio_setting.FormatMatchingModelName(modelName)
if normalized == "" || normalized == modelName {
return false
}
count = 0
err = DB.Model(&Ability{}).
Where(commonGroupCol+" = ? and model = ? and channel_id = ? and enabled = ?", group, normalized, channelID, true).
Count(&count).Error
return err == nil && count > 0
}
func isChannelIDInList(list []int, channelID int) bool {
for _, id := range list {
if id == channelID {
return true
}
}
return false
}
package cachex
import (
"encoding/json"
"fmt"
"strconv"
"strings"
)
type ValueCodec[V any] interface {
Encode(v V) (string, error)
Decode(s string) (V, error)
}
type IntCodec struct{}
func (c IntCodec) Encode(v int) (string, error) {
return strconv.Itoa(v), nil
}
func (c IntCodec) Decode(s string) (int, error) {
s = strings.TrimSpace(s)
if s == "" {
return 0, fmt.Errorf("empty int value")
}
return strconv.Atoi(s)
}
type StringCodec struct{}
func (c StringCodec) Encode(v string) (string, error) { return v, nil }
func (c StringCodec) Decode(s string) (string, error) { return s, nil }
type JSONCodec[V any] struct{}
func (c JSONCodec[V]) Encode(v V) (string, error) {
b, err := json.Marshal(v)
if err != nil {
return "", err
}
return string(b), nil
}
func (c JSONCodec[V]) Decode(s string) (V, error) {
var v V
if strings.TrimSpace(s) == "" {
return v, fmt.Errorf("empty json value")
}
if err := json.Unmarshal([]byte(s), &v); err != nil {
return v, err
}
return v, nil
}
package cachex
import (
"context"
"errors"
"strings"
"sync"
"time"
"github.com/go-redis/redis/v8"
"github.com/samber/hot"
)
const (
defaultRedisOpTimeout = 2 * time.Second
defaultRedisScanTimeout = 30 * time.Second
defaultRedisDelTimeout = 10 * time.Second
)
type HybridCacheConfig[V any] struct {
Namespace Namespace
// Redis is used when RedisEnabled returns true (or RedisEnabled is nil) and Redis is not nil.
Redis *redis.Client
RedisCodec ValueCodec[V]
RedisEnabled func() bool
// Memory builds a hot cache used when Redis is disabled. Keys stored in memory are fully namespaced.
Memory func() *hot.HotCache[string, V]
}
// HybridCache is a small helper that uses Redis when enabled, otherwise falls back to in-memory hot cache.
type HybridCache[V any] struct {
ns Namespace
redis *redis.Client
redisCodec ValueCodec[V]
redisEnabled func() bool
memOnce sync.Once
memInit func() *hot.HotCache[string, V]
mem *hot.HotCache[string, V]
}
func NewHybridCache[V any](cfg HybridCacheConfig[V]) *HybridCache[V] {
return &HybridCache[V]{
ns: cfg.Namespace,
redis: cfg.Redis,
redisCodec: cfg.RedisCodec,
redisEnabled: cfg.RedisEnabled,
memInit: cfg.Memory,
}
}
func (c *HybridCache[V]) FullKey(key string) string {
return c.ns.FullKey(key)
}
func (c *HybridCache[V]) redisOn() bool {
if c.redis == nil || c.redisCodec == nil {
return false
}
if c.redisEnabled == nil {
return true
}
return c.redisEnabled()
}
func (c *HybridCache[V]) memCache() *hot.HotCache[string, V] {
c.memOnce.Do(func() {
if c.memInit == nil {
c.mem = hot.NewHotCache[string, V](hot.LRU, 1).Build()
return
}
c.mem = c.memInit()
})
return c.mem
}
func (c *HybridCache[V]) Get(key string) (value V, found bool, err error) {
full := c.ns.FullKey(key)
if full == "" {
var zero V
return zero, false, nil
}
if c.redisOn() {
ctx, cancel := context.WithTimeout(context.Background(), defaultRedisOpTimeout)
defer cancel()
raw, e := c.redis.Get(ctx, full).Result()
if e == nil {
v, decErr := c.redisCodec.Decode(raw)
if decErr != nil {
var zero V
return zero, false, decErr
}
return v, true, nil
}
if errors.Is(e, redis.Nil) {
var zero V
return zero, false, nil
}
var zero V
return zero, false, e
}
return c.memCache().Get(full)
}
func (c *HybridCache[V]) SetWithTTL(key string, v V, ttl time.Duration) error {
full := c.ns.FullKey(key)
if full == "" {
return nil
}
if c.redisOn() {
raw, err := c.redisCodec.Encode(v)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), defaultRedisOpTimeout)
defer cancel()
return c.redis.Set(ctx, full, raw, ttl).Err()
}
c.memCache().SetWithTTL(full, v, ttl)
return nil
}
// Keys returns keys with valid values. In Redis, it returns all matching keys.
func (c *HybridCache[V]) Keys() ([]string, error) {
if c.redisOn() {
return c.scanKeys(c.ns.MatchPattern())
}
return c.memCache().Keys(), nil
}
func (c *HybridCache[V]) scanKeys(match string) ([]string, error) {
ctx, cancel := context.WithTimeout(context.Background(), defaultRedisScanTimeout)
defer cancel()
var cursor uint64
keys := make([]string, 0, 1024)
for {
k, next, err := c.redis.Scan(ctx, cursor, match, 1000).Result()
if err != nil {
return keys, err
}
keys = append(keys, k...)
cursor = next
if cursor == 0 {
break
}
}
return keys, nil
}
func (c *HybridCache[V]) Purge() error {
if c.redisOn() {
keys, err := c.scanKeys(c.ns.MatchPattern())
if err != nil {
return err
}
if len(keys) == 0 {
return nil
}
_, err = c.DeleteMany(keys)
return err
}
c.memCache().Purge()
return nil
}
func (c *HybridCache[V]) DeleteByPrefix(prefix string) (int, error) {
fullPrefix := c.ns.FullKey(prefix)
if fullPrefix == "" {
return 0, nil
}
if !strings.HasSuffix(fullPrefix, ":") {
fullPrefix += ":"
}
if c.redisOn() {
match := fullPrefix + "*"
keys, err := c.scanKeys(match)
if err != nil {
return 0, err
}
if len(keys) == 0 {
return 0, nil
}
res, err := c.DeleteMany(keys)
if err != nil {
return 0, err
}
deleted := 0
for _, ok := range res {
if ok {
deleted++
}
}
return deleted, nil
}
// In memory, we filter keys and bulk delete.
allKeys := c.memCache().Keys()
keys := make([]string, 0, 128)
for _, k := range allKeys {
if strings.HasPrefix(k, fullPrefix) {
keys = append(keys, k)
}
}
if len(keys) == 0 {
return 0, nil
}
res, _ := c.DeleteMany(keys)
deleted := 0
for _, ok := range res {
if ok {
deleted++
}
}
return deleted, nil
}
// DeleteMany accepts either fully namespaced keys or raw keys and deletes them.
// It returns a map keyed by fully namespaced keys.
func (c *HybridCache[V]) DeleteMany(keys []string) (map[string]bool, error) {
res := make(map[string]bool, len(keys))
if len(keys) == 0 {
return res, nil
}
fullKeys := make([]string, 0, len(keys))
for _, k := range keys {
k = c.ns.FullKey(k)
if k == "" {
continue
}
fullKeys = append(fullKeys, k)
}
if len(fullKeys) == 0 {
return res, nil
}
if c.redisOn() {
ctx, cancel := context.WithTimeout(context.Background(), defaultRedisDelTimeout)
defer cancel()
pipe := c.redis.Pipeline()
cmds := make([]*redis.IntCmd, 0, len(fullKeys))
for _, k := range fullKeys {
// UNLINK is non-blocking vs DEL for large key batches.
cmds = append(cmds, pipe.Unlink(ctx, k))
}
_, err := pipe.Exec(ctx)
if err != nil && !errors.Is(err, redis.Nil) {
return res, err
}
for i, cmd := range cmds {
deleted := cmd != nil && cmd.Err() == nil && cmd.Val() > 0
res[fullKeys[i]] = deleted
}
return res, nil
}
return c.memCache().DeleteMany(fullKeys), nil
}
func (c *HybridCache[V]) Capacity() (mainCacheCapacity int, missingCacheCapacity int) {
if c.redisOn() {
return 0, 0
}
return c.memCache().Capacity()
}
func (c *HybridCache[V]) Algorithm() (mainCacheAlgorithm string, missingCacheAlgorithm string) {
if c.redisOn() {
return "redis", ""
}
return c.memCache().Algorithm()
}
package cachex
import "strings"
// Namespace isolates keys between different cache use-cases. (e.g. "channel_affinity:v1").
type Namespace string
func (n Namespace) prefix() string {
ns := strings.TrimSpace(string(n))
ns = strings.TrimRight(ns, ":")
if ns == "" {
return ""
}
return ns + ":"
}
func (n Namespace) FullKey(key string) string {
key = strings.TrimSpace(key)
if key == "" {
return ""
}
p := n.prefix()
if p == "" {
return strings.TrimLeft(key, ":")
}
if strings.HasPrefix(key, p) {
return key
}
return p + strings.TrimLeft(key, ":")
}
func (n Namespace) MatchPattern() string {
p := n.prefix()
if p == "" {
return "*"
}
return p + "*"
}
...@@ -79,7 +79,7 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types ...@@ -79,7 +79,7 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types
if info.RelayMode == relayconstant.RelayModeChatCompletions && if info.RelayMode == relayconstant.RelayModeChatCompletions &&
!passThroughGlobal && !passThroughGlobal &&
!info.ChannelSetting.PassThroughBodyEnabled && !info.ChannelSetting.PassThroughBodyEnabled &&
shouldChatCompletionsViaResponses(info) { service.ShouldChatCompletionsUseResponsesGlobal(info.ChannelId, info.ChannelType, info.OriginModelName) {
applySystemPromptIfNeeded(c, info, request) applySystemPromptIfNeeded(c, info, request)
usage, newApiErr := chatCompletionsViaResponses(c, info, adaptor, request) usage, newApiErr := chatCompletionsViaResponses(c, info, adaptor, request)
if newApiErr != nil { if newApiErr != nil {
...@@ -218,16 +218,6 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types ...@@ -218,16 +218,6 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types
return nil return nil
} }
func shouldChatCompletionsViaResponses(info *relaycommon.RelayInfo) bool {
if info == nil {
return false
}
if info.RelayMode != relayconstant.RelayModeChatCompletions {
return false
}
return service.ShouldChatCompletionsUseResponsesGlobal(info.ChannelId, info.OriginModelName)
}
func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.Usage, extraContent ...string) { func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.Usage, extraContent ...string) {
if usage == nil { if usage == nil {
usage = &dto.Usage{ usage = &dto.Usage{
......
...@@ -123,6 +123,8 @@ func SetApiRouter(router *gin.Engine) { ...@@ -123,6 +123,8 @@ func SetApiRouter(router *gin.Engine) {
{ {
optionRoute.GET("/", controller.GetOptions) optionRoute.GET("/", controller.GetOptions)
optionRoute.PUT("/", controller.UpdateOption) optionRoute.PUT("/", controller.UpdateOption)
optionRoute.GET("/channel_affinity_cache", controller.GetChannelAffinityCacheStats)
optionRoute.DELETE("/channel_affinity_cache", controller.ClearChannelAffinityCache)
optionRoute.POST("/rest_model_ratio", controller.ResetModelRatio) optionRoute.POST("/rest_model_ratio", controller.ResetModelRatio)
optionRoute.POST("/migrate_console_setting", controller.MigrateConsoleSetting) // 用于迁移检测的旧键,下个版本会删除 optionRoute.POST("/migrate_console_setting", controller.MigrateConsoleSetting) // 用于迁移检测的旧键,下个版本会删除
} }
......
package service
import (
"fmt"
"regexp"
"strconv"
"strings"
"sync"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/pkg/cachex"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/gin-gonic/gin"
"github.com/samber/hot"
"github.com/tidwall/gjson"
)
const (
ginKeyChannelAffinityCacheKey = "channel_affinity_cache_key"
ginKeyChannelAffinityTTLSeconds = "channel_affinity_ttl_seconds"
ginKeyChannelAffinityMeta = "channel_affinity_meta"
ginKeyChannelAffinityLogInfo = "channel_affinity_log_info"
channelAffinityCacheNamespace = "new-api:channel_affinity:v1"
)
var (
channelAffinityCacheOnce sync.Once
channelAffinityCache *cachex.HybridCache[int]
channelAffinityRegexCache sync.Map // map[string]*regexp.Regexp
)
type channelAffinityMeta struct {
CacheKey string
TTLSeconds int
RuleName string
KeySourceType string
KeySourceKey string
KeySourcePath string
KeyFingerprint string
UsingGroup string
ModelName string
RequestPath string
}
type ChannelAffinityCacheStats struct {
Enabled bool `json:"enabled"`
Total int `json:"total"`
Unknown int `json:"unknown"`
ByRuleName map[string]int `json:"by_rule_name"`
CacheCapacity int `json:"cache_capacity"`
CacheAlgo string `json:"cache_algo"`
}
func getChannelAffinityCache() *cachex.HybridCache[int] {
channelAffinityCacheOnce.Do(func() {
setting := operation_setting.GetChannelAffinitySetting()
capacity := setting.MaxEntries
if capacity <= 0 {
capacity = 100_000
}
defaultTTLSeconds := setting.DefaultTTLSeconds
if defaultTTLSeconds <= 0 {
defaultTTLSeconds = 3600
}
channelAffinityCache = cachex.NewHybridCache[int](cachex.HybridCacheConfig[int]{
Namespace: cachex.Namespace(channelAffinityCacheNamespace),
Redis: common.RDB,
RedisEnabled: func() bool {
return common.RedisEnabled && common.RDB != nil
},
RedisCodec: cachex.IntCodec{},
Memory: func() *hot.HotCache[string, int] {
return hot.NewHotCache[string, int](hot.LRU, capacity).
WithTTL(time.Duration(defaultTTLSeconds) * time.Second).
WithJanitor().
Build()
},
})
})
return channelAffinityCache
}
func GetChannelAffinityCacheStats() ChannelAffinityCacheStats {
setting := operation_setting.GetChannelAffinitySetting()
if setting == nil {
return ChannelAffinityCacheStats{
Enabled: false,
Total: 0,
Unknown: 0,
ByRuleName: map[string]int{},
}
}
cache := getChannelAffinityCache()
mainCap, _ := cache.Capacity()
mainAlgo, _ := cache.Algorithm()
rules := setting.Rules
ruleByName := make(map[string]operation_setting.ChannelAffinityRule, len(rules))
for _, r := range rules {
name := strings.TrimSpace(r.Name)
if name == "" {
continue
}
if !r.IncludeRuleName {
continue
}
ruleByName[name] = r
}
byRuleName := make(map[string]int, len(ruleByName))
for name := range ruleByName {
byRuleName[name] = 0
}
keys, err := cache.Keys()
if err != nil {
common.SysError(fmt.Sprintf("channel affinity cache list keys failed: err=%v", err))
keys = nil
}
total := len(keys)
unknown := 0
for _, k := range keys {
prefix := channelAffinityCacheNamespace + ":"
if !strings.HasPrefix(k, prefix) {
unknown++
continue
}
rest := strings.TrimPrefix(k, prefix)
parts := strings.Split(rest, ":")
if len(parts) < 2 {
unknown++
continue
}
ruleName := parts[0]
rule, ok := ruleByName[ruleName]
if !ok {
unknown++
continue
}
if rule.IncludeUsingGroup {
if len(parts) < 3 {
unknown++
continue
}
}
byRuleName[ruleName]++
}
return ChannelAffinityCacheStats{
Enabled: setting.Enabled,
Total: total,
Unknown: unknown,
ByRuleName: byRuleName,
CacheCapacity: mainCap,
CacheAlgo: mainAlgo,
}
}
func ClearChannelAffinityCacheAll() int {
cache := getChannelAffinityCache()
keys, err := cache.Keys()
if err != nil {
common.SysError(fmt.Sprintf("channel affinity cache list keys failed: err=%v", err))
keys = nil
}
if len(keys) > 0 {
if _, err := cache.DeleteMany(keys); err != nil {
common.SysError(fmt.Sprintf("channel affinity cache delete many failed: err=%v", err))
}
}
return len(keys)
}
func ClearChannelAffinityCacheByRuleName(ruleName string) (int, error) {
ruleName = strings.TrimSpace(ruleName)
if ruleName == "" {
return 0, fmt.Errorf("rule_name 不能为空")
}
setting := operation_setting.GetChannelAffinitySetting()
if setting == nil {
return 0, fmt.Errorf("channel_affinity_setting 未初始化")
}
var matchedRule *operation_setting.ChannelAffinityRule
for i := range setting.Rules {
r := &setting.Rules[i]
if strings.TrimSpace(r.Name) != ruleName {
continue
}
matchedRule = r
break
}
if matchedRule == nil {
return 0, fmt.Errorf("未知规则名称")
}
if !matchedRule.IncludeRuleName {
return 0, fmt.Errorf("该规则未启用 include_rule_name,无法按规则清空缓存")
}
cache := getChannelAffinityCache()
deleted, err := cache.DeleteByPrefix(ruleName)
if err != nil {
return 0, err
}
return deleted, nil
}
func matchAnyRegexCached(patterns []string, s string) bool {
if len(patterns) == 0 || s == "" {
return false
}
for _, pattern := range patterns {
if pattern == "" {
continue
}
re, ok := channelAffinityRegexCache.Load(pattern)
if !ok {
compiled, err := regexp.Compile(pattern)
if err != nil {
continue
}
re = compiled
channelAffinityRegexCache.Store(pattern, re)
}
if re.(*regexp.Regexp).MatchString(s) {
return true
}
}
return false
}
func matchAnyIncludeFold(patterns []string, s string) bool {
if len(patterns) == 0 || s == "" {
return false
}
sLower := strings.ToLower(s)
for _, p := range patterns {
p = strings.TrimSpace(p)
if p == "" {
continue
}
if strings.Contains(sLower, strings.ToLower(p)) {
return true
}
}
return false
}
func extractChannelAffinityValue(c *gin.Context, src operation_setting.ChannelAffinityKeySource) string {
switch src.Type {
case "context_int":
if src.Key == "" {
return ""
}
v := c.GetInt(src.Key)
if v <= 0 {
return ""
}
return strconv.Itoa(v)
case "context_string":
if src.Key == "" {
return ""
}
return strings.TrimSpace(c.GetString(src.Key))
case "gjson":
if src.Path == "" {
return ""
}
body, err := common.GetRequestBody(c)
if err != nil || len(body) == 0 {
return ""
}
res := gjson.GetBytes(body, src.Path)
if !res.Exists() {
return ""
}
switch res.Type {
case gjson.String, gjson.Number, gjson.True, gjson.False:
return strings.TrimSpace(res.String())
default:
return strings.TrimSpace(res.Raw)
}
default:
return ""
}
}
func buildChannelAffinityCacheKeySuffix(rule operation_setting.ChannelAffinityRule, usingGroup string, affinityValue string) string {
parts := make([]string, 0, 3)
if rule.IncludeRuleName && rule.Name != "" {
parts = append(parts, rule.Name)
}
if rule.IncludeUsingGroup && usingGroup != "" {
parts = append(parts, usingGroup)
}
parts = append(parts, affinityValue)
return strings.Join(parts, ":")
}
func setChannelAffinityContext(c *gin.Context, meta channelAffinityMeta) {
c.Set(ginKeyChannelAffinityCacheKey, meta.CacheKey)
c.Set(ginKeyChannelAffinityTTLSeconds, meta.TTLSeconds)
c.Set(ginKeyChannelAffinityMeta, meta)
}
func getChannelAffinityContext(c *gin.Context) (string, int, bool) {
keyAny, ok := c.Get(ginKeyChannelAffinityCacheKey)
if !ok {
return "", 0, false
}
key, ok := keyAny.(string)
if !ok || key == "" {
return "", 0, false
}
ttlAny, ok := c.Get(ginKeyChannelAffinityTTLSeconds)
if !ok {
return key, 0, true
}
ttlSeconds, _ := ttlAny.(int)
return key, ttlSeconds, true
}
func getChannelAffinityMeta(c *gin.Context) (channelAffinityMeta, bool) {
anyMeta, ok := c.Get(ginKeyChannelAffinityMeta)
if !ok {
return channelAffinityMeta{}, false
}
meta, ok := anyMeta.(channelAffinityMeta)
if !ok {
return channelAffinityMeta{}, false
}
return meta, true
}
func affinityFingerprint(s string) string {
if s == "" {
return ""
}
hex := common.Sha1([]byte(s))
if len(hex) >= 8 {
return hex[:8]
}
return hex
}
func GetPreferredChannelByAffinity(c *gin.Context, modelName string, usingGroup string) (int, bool) {
setting := operation_setting.GetChannelAffinitySetting()
if setting == nil || !setting.Enabled {
return 0, false
}
path := ""
if c != nil && c.Request != nil && c.Request.URL != nil {
path = c.Request.URL.Path
}
userAgent := ""
if c != nil && c.Request != nil {
userAgent = c.Request.UserAgent()
}
for _, rule := range setting.Rules {
if !matchAnyRegexCached(rule.ModelRegex, modelName) {
continue
}
if len(rule.PathRegex) > 0 && !matchAnyRegexCached(rule.PathRegex, path) {
continue
}
if len(rule.UserAgentInclude) > 0 && !matchAnyIncludeFold(rule.UserAgentInclude, userAgent) {
continue
}
var affinityValue string
var usedSource operation_setting.ChannelAffinityKeySource
for _, src := range rule.KeySources {
affinityValue = extractChannelAffinityValue(c, src)
if affinityValue != "" {
usedSource = src
break
}
}
if affinityValue == "" {
continue
}
if rule.ValueRegex != "" && !matchAnyRegexCached([]string{rule.ValueRegex}, affinityValue) {
continue
}
ttlSeconds := rule.TTLSeconds
if ttlSeconds <= 0 {
ttlSeconds = setting.DefaultTTLSeconds
}
cacheKeySuffix := buildChannelAffinityCacheKeySuffix(rule, usingGroup, affinityValue)
cacheKeyFull := channelAffinityCacheNamespace + ":" + cacheKeySuffix
setChannelAffinityContext(c, channelAffinityMeta{
CacheKey: cacheKeyFull,
TTLSeconds: ttlSeconds,
RuleName: rule.Name,
KeySourceType: strings.TrimSpace(usedSource.Type),
KeySourceKey: strings.TrimSpace(usedSource.Key),
KeySourcePath: strings.TrimSpace(usedSource.Path),
KeyFingerprint: affinityFingerprint(affinityValue),
UsingGroup: usingGroup,
ModelName: modelName,
RequestPath: path,
})
cache := getChannelAffinityCache()
channelID, found, err := cache.Get(cacheKeySuffix)
if err != nil {
common.SysError(fmt.Sprintf("channel affinity cache get failed: key=%s, err=%v", cacheKeyFull, err))
return 0, false
}
if found {
return channelID, true
}
return 0, false
}
return 0, false
}
func MarkChannelAffinityUsed(c *gin.Context, selectedGroup string, channelID int) {
if c == nil || channelID <= 0 {
return
}
meta, ok := getChannelAffinityMeta(c)
if !ok {
return
}
info := map[string]interface{}{
"reason": meta.RuleName,
"rule_name": meta.RuleName,
"using_group": meta.UsingGroup,
"selected_group": selectedGroup,
"model": meta.ModelName,
"request_path": meta.RequestPath,
"channel_id": channelID,
"key_source": meta.KeySourceType,
"key_key": meta.KeySourceKey,
"key_path": meta.KeySourcePath,
"key_fp": meta.KeyFingerprint,
}
c.Set(ginKeyChannelAffinityLogInfo, info)
}
func AppendChannelAffinityAdminInfo(c *gin.Context, adminInfo map[string]interface{}) {
if c == nil || adminInfo == nil {
return
}
anyInfo, ok := c.Get(ginKeyChannelAffinityLogInfo)
if !ok || anyInfo == nil {
return
}
adminInfo["channel_affinity"] = anyInfo
}
func RecordChannelAffinity(c *gin.Context, channelID int) {
if channelID <= 0 {
return
}
setting := operation_setting.GetChannelAffinitySetting()
if setting == nil || !setting.Enabled {
return
}
if setting.SwitchOnSuccess && c != nil {
if successChannelID := c.GetInt("channel_id"); successChannelID > 0 {
channelID = successChannelID
}
}
cacheKey, ttlSeconds, ok := getChannelAffinityContext(c)
if !ok {
return
}
if ttlSeconds <= 0 {
ttlSeconds = setting.DefaultTTLSeconds
}
if ttlSeconds <= 0 {
ttlSeconds = 3600
}
cache := getChannelAffinityCache()
if err := cache.SetWithTTL(cacheKey, channelID, time.Duration(ttlSeconds)*time.Second); err != nil {
common.SysError(fmt.Sprintf("channel affinity cache set failed: key=%s, err=%v", cacheKey, err))
}
}
...@@ -68,6 +68,8 @@ func GenerateTextOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, m ...@@ -68,6 +68,8 @@ func GenerateTextOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, m
adminInfo["local_count_tokens"] = isLocalCountTokens adminInfo["local_count_tokens"] = isLocalCountTokens
} }
AppendChannelAffinityAdminInfo(ctx, adminInfo)
other["admin_info"] = adminInfo other["admin_info"] = adminInfo
appendRequestPath(ctx, relayInfo, other) appendRequestPath(ctx, relayInfo, other)
appendRequestConversionChain(relayInfo, other) appendRequestConversionChain(relayInfo, other)
......
...@@ -5,10 +5,10 @@ import ( ...@@ -5,10 +5,10 @@ import (
"github.com/QuantumNous/new-api/setting/model_setting" "github.com/QuantumNous/new-api/setting/model_setting"
) )
func ShouldChatCompletionsUseResponsesPolicy(policy model_setting.ChatCompletionsToResponsesPolicy, channelID int, model string) bool { func ShouldChatCompletionsUseResponsesPolicy(policy model_setting.ChatCompletionsToResponsesPolicy, channelID int, channelType int, model string) bool {
return openaicompat.ShouldChatCompletionsUseResponsesPolicy(policy, channelID, model) return openaicompat.ShouldChatCompletionsUseResponsesPolicy(policy, channelID, channelType, model)
} }
func ShouldChatCompletionsUseResponsesGlobal(channelID int, model string) bool { func ShouldChatCompletionsUseResponsesGlobal(channelID int, channelType int, model string) bool {
return openaicompat.ShouldChatCompletionsUseResponsesGlobal(channelID, model) return openaicompat.ShouldChatCompletionsUseResponsesGlobal(channelID, channelType, model)
} }
...@@ -2,17 +2,18 @@ package openaicompat ...@@ -2,17 +2,18 @@ package openaicompat
import "github.com/QuantumNous/new-api/setting/model_setting" import "github.com/QuantumNous/new-api/setting/model_setting"
func ShouldChatCompletionsUseResponsesPolicy(policy model_setting.ChatCompletionsToResponsesPolicy, channelID int, model string) bool { func ShouldChatCompletionsUseResponsesPolicy(policy model_setting.ChatCompletionsToResponsesPolicy, channelID int, channelType int, model string) bool {
if !policy.IsChannelEnabled(channelID) { if !policy.IsChannelEnabled(channelID, channelType) {
return false return false
} }
return matchAnyRegex(policy.ModelPatterns, model) return matchAnyRegex(policy.ModelPatterns, model)
} }
func ShouldChatCompletionsUseResponsesGlobal(channelID int, model string) bool { func ShouldChatCompletionsUseResponsesGlobal(channelID int, channelType int, model string) bool {
return ShouldChatCompletionsUseResponsesPolicy( return ShouldChatCompletionsUseResponsesPolicy(
model_setting.GetGlobalSettings().ChatCompletionsToResponsesPolicy, model_setting.GetGlobalSettings().ChatCompletionsToResponsesPolicy,
channelID, channelID,
channelType,
model, model,
) )
} }
...@@ -11,20 +11,25 @@ type ChatCompletionsToResponsesPolicy struct { ...@@ -11,20 +11,25 @@ type ChatCompletionsToResponsesPolicy struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
AllChannels bool `json:"all_channels"` AllChannels bool `json:"all_channels"`
ChannelIDs []int `json:"channel_ids,omitempty"` ChannelIDs []int `json:"channel_ids,omitempty"`
ChannelTypes []int `json:"channel_types,omitempty"`
ModelPatterns []string `json:"model_patterns,omitempty"` ModelPatterns []string `json:"model_patterns,omitempty"`
} }
func (p ChatCompletionsToResponsesPolicy) IsChannelEnabled(channelID int) bool { func (p ChatCompletionsToResponsesPolicy) IsChannelEnabled(channelID int, channelType int) bool {
if !p.Enabled { if !p.Enabled {
return false return false
} }
if p.AllChannels { if p.AllChannels {
return true return true
} }
if channelID == 0 || len(p.ChannelIDs) == 0 {
return false if channelID > 0 && len(p.ChannelIDs) > 0 && slices.Contains(p.ChannelIDs, channelID) {
return true
} }
return slices.Contains(p.ChannelIDs, channelID) if channelType > 0 && len(p.ChannelTypes) > 0 && slices.Contains(p.ChannelTypes, channelType) {
return true
}
return false
} }
type GlobalSettings struct { type GlobalSettings struct {
......
package operation_setting
import "github.com/QuantumNous/new-api/setting/config"
type ChannelAffinityKeySource struct {
Type string `json:"type"` // context_int, context_string, gjson
Key string `json:"key,omitempty"`
Path string `json:"path,omitempty"`
}
type ChannelAffinityRule struct {
Name string `json:"name"`
ModelRegex []string `json:"model_regex"`
PathRegex []string `json:"path_regex"`
UserAgentInclude []string `json:"user_agent_include,omitempty"`
KeySources []ChannelAffinityKeySource `json:"key_sources"`
ValueRegex string `json:"value_regex"`
TTLSeconds int `json:"ttl_seconds"`
IncludeUsingGroup bool `json:"include_using_group"`
IncludeRuleName bool `json:"include_rule_name"`
}
type ChannelAffinitySetting struct {
Enabled bool `json:"enabled"`
SwitchOnSuccess bool `json:"switch_on_success"`
MaxEntries int `json:"max_entries"`
DefaultTTLSeconds int `json:"default_ttl_seconds"`
Rules []ChannelAffinityRule `json:"rules"`
}
var channelAffinitySetting = ChannelAffinitySetting{
Enabled: false,
SwitchOnSuccess: true,
MaxEntries: 100_000,
DefaultTTLSeconds: 3600,
Rules: []ChannelAffinityRule{},
}
func init() {
config.GlobalConfig.Register("channel_affinity_setting", &channelAffinitySetting)
}
func GetChannelAffinitySetting() *ChannelAffinitySetting {
return &channelAffinitySetting
}
...@@ -25,6 +25,7 @@ import { useTranslation } from 'react-i18next'; ...@@ -25,6 +25,7 @@ import { useTranslation } from 'react-i18next';
import SettingGeminiModel from '../../pages/Setting/Model/SettingGeminiModel'; import SettingGeminiModel from '../../pages/Setting/Model/SettingGeminiModel';
import SettingClaudeModel from '../../pages/Setting/Model/SettingClaudeModel'; import SettingClaudeModel from '../../pages/Setting/Model/SettingClaudeModel';
import SettingGlobalModel from '../../pages/Setting/Model/SettingGlobalModel'; import SettingGlobalModel from '../../pages/Setting/Model/SettingGlobalModel';
import SettingsChannelAffinity from '../../pages/Setting/Operation/SettingsChannelAffinity';
const ModelSetting = () => { const ModelSetting = () => {
const { t } = useTranslation(); const { t } = useTranslation();
...@@ -109,6 +110,10 @@ const ModelSetting = () => { ...@@ -109,6 +110,10 @@ const ModelSetting = () => {
<Card style={{ marginTop: '10px' }}> <Card style={{ marginTop: '10px' }}>
<SettingGlobalModel options={inputs} refresh={onRefresh} /> <SettingGlobalModel options={inputs} refresh={onRefresh} />
</Card> </Card>
{/* Channel affinity */}
<Card style={{ marginTop: '10px' }}>
<SettingsChannelAffinity options={inputs} refresh={onRefresh} />
</Card>
{/* Gemini */} {/* Gemini */}
<Card style={{ marginTop: '10px' }}> <Card style={{ marginTop: '10px' }}>
<SettingGeminiModel options={inputs} refresh={onRefresh} /> <SettingGeminiModel options={inputs} refresh={onRefresh} />
......
...@@ -17,8 +17,9 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -17,8 +17,9 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import React from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Modal, Button, Progress, Tag, Typography } from '@douyinfe/semi-ui'; import { Modal, Button, Progress, Tag, Typography, Spin } from '@douyinfe/semi-ui';
import { API, showError } from '../../../../helpers';
const { Text } = Typography; const { Text } = Typography;
...@@ -101,7 +102,7 @@ const RateLimitWindowCard = ({ t, title, windowData }) => { ...@@ -101,7 +102,7 @@ const RateLimitWindowCard = ({ t, title, windowData }) => {
); );
}; };
export const openCodexUsageModal = ({ t, record, payload, onCopy }) => { const CodexUsageView = ({ t, record, payload, onCopy, onRefresh }) => {
const tt = typeof t === 'function' ? t : (v) => v; const tt = typeof t === 'function' ? t : (v) => v;
const data = payload?.data ?? null; const data = payload?.data ?? null;
const rateLimit = data?.rate_limit ?? {}; const rateLimit = data?.rate_limit ?? {};
...@@ -123,17 +124,7 @@ export const openCodexUsageModal = ({ t, record, payload, onCopy }) => { ...@@ -123,17 +124,7 @@ export const openCodexUsageModal = ({ t, record, payload, onCopy }) => {
const rawText = const rawText =
typeof data === 'string' ? data : JSON.stringify(data ?? payload, null, 2); typeof data === 'string' ? data : JSON.stringify(data ?? payload, null, 2);
Modal.info({ return (
title: (
<div className='flex items-center gap-2'>
<span>{tt('Codex 用量')}</span>
{statusTag}
</div>
),
centered: true,
width: 900,
style: { maxWidth: '95vw' },
content: (
<div className='flex flex-col gap-3'> <div className='flex flex-col gap-3'>
<div className='flex flex-wrap items-center justify-between gap-2'> <div className='flex flex-wrap items-center justify-between gap-2'>
<Text type='tertiary' size='small'> <Text type='tertiary' size='small'>
...@@ -141,6 +132,15 @@ export const openCodexUsageModal = ({ t, record, payload, onCopy }) => { ...@@ -141,6 +132,15 @@ export const openCodexUsageModal = ({ t, record, payload, onCopy }) => {
{record?.name || '-'} ({tt('编号:')} {record?.name || '-'} ({tt('编号:')}
{record?.id || '-'}) {record?.id || '-'})
</Text> </Text>
<div className='flex items-center gap-2'>
{statusTag}
<Button size='small' type='tertiary' theme='borderless' onClick={onRefresh}>
{tt('刷新')}
</Button>
</div>
</div>
<div className='flex flex-wrap items-center justify-between gap-2'>
<Text type='tertiary' size='small'> <Text type='tertiary' size='small'>
{tt('上游状态码:')} {tt('上游状态码:')}
{upstreamStatus ?? '-'} {upstreamStatus ?? '-'}
...@@ -178,6 +178,105 @@ export const openCodexUsageModal = ({ t, record, payload, onCopy }) => { ...@@ -178,6 +178,105 @@ export const openCodexUsageModal = ({ t, record, payload, onCopy }) => {
</pre> </pre>
</div> </div>
</div> </div>
);
};
const CodexUsageLoader = ({ t, record, initialPayload, onCopy }) => {
const tt = typeof t === 'function' ? t : (v) => v;
const [loading, setLoading] = useState(!initialPayload);
const [payload, setPayload] = useState(initialPayload ?? null);
const hasShownErrorRef = useRef(false);
const mountedRef = useRef(true);
const recordId = record?.id;
const fetchUsage = useCallback(async () => {
if (!recordId) {
if (mountedRef.current) setPayload(null);
return;
}
if (mountedRef.current) setLoading(true);
try {
const res = await API.get(`/api/channel/${recordId}/codex/usage`, {
skipErrorHandler: true,
});
if (!mountedRef.current) return;
setPayload(res?.data ?? null);
if (!res?.data?.success && !hasShownErrorRef.current) {
hasShownErrorRef.current = true;
showError(tt('获取用量失败'));
}
} catch (error) {
if (!mountedRef.current) return;
if (!hasShownErrorRef.current) {
hasShownErrorRef.current = true;
showError(tt('获取用量失败'));
}
setPayload({ success: false, message: String(error) });
} finally {
if (mountedRef.current) setLoading(false);
}
}, [recordId, tt]);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
useEffect(() => {
if (initialPayload) return;
fetchUsage().catch(() => {});
}, [fetchUsage, initialPayload]);
if (loading) {
return (
<div className='flex items-center justify-center py-10'>
<Spin spinning={true} size='large' tip={tt('加载中...')} />
</div>
);
}
if (!payload) {
return (
<div className='flex flex-col gap-3'>
<Text type='danger'>{tt('获取用量失败')}</Text>
<div className='flex justify-end'>
<Button size='small' type='primary' theme='outline' onClick={fetchUsage}>
{tt('刷新')}
</Button>
</div>
</div>
);
}
return (
<CodexUsageView
t={tt}
record={record}
payload={payload}
onCopy={onCopy}
onRefresh={fetchUsage}
/>
);
};
export const openCodexUsageModal = ({ t, record, payload, onCopy }) => {
const tt = typeof t === 'function' ? t : (v) => v;
Modal.info({
title: tt('Codex 用量'),
centered: true,
width: 900,
style: { maxWidth: '95vw' },
content: (
<CodexUsageLoader
t={tt}
record={record}
initialPayload={payload}
onCopy={onCopy}
/>
), ),
footer: ( footer: (
<div className='flex justify-end gap-2'> <div className='flex justify-end gap-2'>
......
...@@ -40,7 +40,7 @@ import { ...@@ -40,7 +40,7 @@ import {
renderClaudeModelPrice, renderClaudeModelPrice,
renderModelPrice, renderModelPrice,
} from '../../../helpers'; } from '../../../helpers';
import { IconHelpCircle } from '@douyinfe/semi-icons'; import { IconHelpCircle, IconStarStroked } from '@douyinfe/semi-icons';
import { Route } from 'lucide-react'; import { Route } from 'lucide-react';
const colors = [ const colors = [
...@@ -498,6 +498,7 @@ export const getLogsColumns = ({ ...@@ -498,6 +498,7 @@ export const getLogsColumns = ({
return <></>; return <></>;
} }
let content = t('渠道') + `:${record.channel}`; let content = t('渠道') + `:${record.channel}`;
let affinity = null;
if (record.other !== '') { if (record.other !== '') {
let other = JSON.parse(record.other); let other = JSON.parse(record.other);
if (other === null) { if (other === null) {
...@@ -513,9 +514,55 @@ export const getLogsColumns = ({ ...@@ -513,9 +514,55 @@ export const getLogsColumns = ({
let useChannelStr = useChannel.join('->'); let useChannelStr = useChannel.join('->');
content = t('渠道') + `:${useChannelStr}`; content = t('渠道') + `:${useChannelStr}`;
} }
if (other.admin_info.channel_affinity) {
affinity = other.admin_info.channel_affinity;
} }
} }
return isAdminUser ? <div>{content}</div> : <></>; }
return isAdminUser ? (
<Space>
<div>{content}</div>
{affinity ? (
<Tooltip
content={
<div style={{ lineHeight: 1.6 }}>
<Typography.Text strong>{t('渠道亲和性')}</Typography.Text>
<div>
<Typography.Text type='secondary'>
{t('规则')}{affinity.rule_name || '-'}
</Typography.Text>
</div>
<div>
<Typography.Text type='secondary'>
{t('分组')}{affinity.selected_group || '-'}
</Typography.Text>
</div>
<div>
<Typography.Text type='secondary'>
{t('Key')}
{(affinity.key_source || '-') +
':' +
(affinity.key_path || affinity.key_key || '-') +
(affinity.key_fp ? `#${affinity.key_fp}` : '')}
</Typography.Text>
</div>
</div>
}
>
<span>
<Tag className='channel-affinity-tag' color='cyan' shape='circle'>
<span className='channel-affinity-tag-content'>
<IconStarStroked style={{ fontSize: 13 }} />
{t('优选')}
</span>
</Tag>
</span>
</Tooltip>
) : null}
</Space>
) : (
<></>
);
}, },
}, },
{ {
...@@ -552,9 +599,13 @@ export const getLogsColumns = ({ ...@@ -552,9 +599,13 @@ export const getLogsColumns = ({
other.cache_creation_tokens || 0, other.cache_creation_tokens || 0,
other.cache_creation_ratio || 1.0, other.cache_creation_ratio || 1.0,
other.cache_creation_tokens_5m || 0, other.cache_creation_tokens_5m || 0,
other.cache_creation_ratio_5m || other.cache_creation_ratio || 1.0, other.cache_creation_ratio_5m ||
other.cache_creation_ratio ||
1.0,
other.cache_creation_tokens_1h || 0, other.cache_creation_tokens_1h || 0,
other.cache_creation_ratio_1h || other.cache_creation_ratio || 1.0, other.cache_creation_ratio_1h ||
other.cache_creation_ratio ||
1.0,
false, false,
1.0, 1.0,
other?.is_system_prompt_overwritten, other?.is_system_prompt_overwritten,
......
...@@ -747,28 +747,15 @@ export const useChannelsData = () => { ...@@ -747,28 +747,15 @@ export const useChannelsData = () => {
const updateChannelBalance = async (record) => { const updateChannelBalance = async (record) => {
if (record?.type === 57) { if (record?.type === 57) {
try {
const res = await API.get(`/api/channel/${record.id}/codex/usage`, {
skipErrorHandler: true,
});
if (!res?.data?.success) {
console.error('Codex usage fetch failed:', res?.data?.message);
showError(t('获取用量失败'));
}
openCodexUsageModal({ openCodexUsageModal({
t, t,
record, record,
payload: res?.data,
onCopy: async (text) => { onCopy: async (text) => {
const ok = await copy(text); const ok = await copy(text);
if (ok) showSuccess(t('已复制')); if (ok) showSuccess(t('已复制'));
else showError(t('复制失败')); else showError(t('复制失败'));
}, },
}); });
} catch (error) {
console.error('Codex usage fetch error:', error);
showError(t('获取用量失败'));
}
return; return;
} }
......
...@@ -818,6 +818,34 @@ html.dark .with-pastel-balls::before { ...@@ -818,6 +818,34 @@ html.dark .with-pastel-balls::before {
padding: 10px !important; padding: 10px !important;
} }
/* ==================== 使用日志: channel affinity tag ==================== */
.semi-tag.channel-affinity-tag {
border: 1px solid rgba(var(--semi-cyan-5), 0.35);
background-color: rgba(var(--semi-cyan-5), 0.15);
color: rgba(var(--semi-cyan-9), 1);
cursor: help;
transition:
background-color 120ms ease,
border-color 120ms ease,
box-shadow 120ms ease;
}
.semi-tag.channel-affinity-tag:hover {
background-color: rgba(var(--semi-cyan-5), 0.22);
border-color: rgba(var(--semi-cyan-5), 0.6);
box-shadow: 0 0 0 2px rgba(var(--semi-cyan-5), 0.18);
}
.semi-tag.channel-affinity-tag:active {
background-color: rgba(var(--semi-cyan-5), 0.28);
}
.semi-tag.channel-affinity-tag .channel-affinity-tag-content {
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
/* ==================== 自定义圆角样式 ==================== */ /* ==================== 自定义圆角样式 ==================== */
.semi-radio, .semi-radio,
.semi-tagInput, .semi-tagInput,
......
...@@ -49,6 +49,7 @@ const chatCompletionsToResponsesPolicyExample = JSON.stringify( ...@@ -49,6 +49,7 @@ const chatCompletionsToResponsesPolicyExample = JSON.stringify(
enabled: true, enabled: true,
all_channels: false, all_channels: false,
channel_ids: [1, 2], channel_ids: [1, 2],
channel_types: [1],
model_patterns: ['^gpt-4o.*$', '^gpt-5.*$'], model_patterns: ['^gpt-4o.*$', '^gpt-5.*$'],
}, },
null, null,
......
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React, { useEffect, useRef, useState } from 'react';
import {
Banner,
Button,
Col,
Collapse,
Divider,
Form,
Input,
Modal,
Row,
Select,
Space,
Spin,
Table,
Tag,
Typography,
} from '@douyinfe/semi-ui';
import {
IconClose,
IconDelete,
IconEdit,
IconPlus,
IconRefresh,
} from '@douyinfe/semi-icons';
import {
API,
compareObjects,
showError,
showSuccess,
showWarning,
toBoolean,
verifyJSON,
} from '../../../helpers';
import { useTranslation } from 'react-i18next';
const KEY_ENABLED = 'channel_affinity_setting.enabled';
const KEY_SWITCH_ON_SUCCESS = 'channel_affinity_setting.switch_on_success';
const KEY_MAX_ENTRIES = 'channel_affinity_setting.max_entries';
const KEY_DEFAULT_TTL = 'channel_affinity_setting.default_ttl_seconds';
const KEY_RULES = 'channel_affinity_setting.rules';
const KEY_SOURCE_TYPES = [
{ label: 'context_int', value: 'context_int' },
{ label: 'context_string', value: 'context_string' },
{ label: 'gjson', value: 'gjson' },
];
const RULE_TEMPLATES = {
codex: {
name: 'codex优选',
model_regex: ['^gpt-.*$'],
path_regex: ['/v1/responses'],
key_sources: [{ type: 'gjson', path: 'prompt_cache_key' }],
value_regex: '',
ttl_seconds: 0,
include_using_group: true,
include_rule_name: true,
},
claudeCode: {
name: 'claude-code优选',
model_regex: ['^claude-.*$'],
path_regex: ['/v1/messages'],
key_sources: [{ type: 'gjson', path: 'metadata.user_id' }],
value_regex: '',
ttl_seconds: 0,
include_using_group: true,
include_rule_name: true,
},
};
const CONTEXT_KEY_PRESETS = [
{ key: 'id', label: 'id(用户 ID)' },
{ key: 'token_id', label: 'token_id' },
{ key: 'token_key', label: 'token_key' },
{ key: 'token_group', label: 'token_group' },
{ key: 'group', label: 'group(using_group)' },
{ key: 'username', label: 'username' },
{ key: 'user_group', label: 'user_group' },
{ key: 'user_email', label: 'user_email' },
{ key: 'specific_channel_id', label: 'specific_channel_id' },
];
const RULES_JSON_PLACEHOLDER = `[
{
"name": "prefer-by-conversation-id",
"model_regex": ["^gpt-.*$"],
"path_regex": ["/v1/chat/completions"],
"user_agent_include": ["curl", "PostmanRuntime"],
"key_sources": [
{ "type": "gjson", "path": "metadata.conversation_id" },
{ "type": "context_string", "key": "conversation_id" }
],
"value_regex": "^[-0-9A-Za-z._:]{1,128}$",
"ttl_seconds": 600,
"include_using_group": true,
"include_rule_name": true
}
]`;
const normalizeStringList = (text) => {
if (!text) return [];
return text
.split('\n')
.map((s) => s.trim())
.filter((s) => s.length > 0);
};
const stringifyPretty = (v) => JSON.stringify(v, null, 2);
const stringifyCompact = (v) => JSON.stringify(v);
const parseRulesJson = (jsonString) => {
try {
const parsed = JSON.parse(jsonString || '[]');
if (!Array.isArray(parsed)) return [];
return parsed.map((rule, index) => ({
id: index,
...(rule || {}),
}));
} catch (e) {
return [];
}
};
const rulesToJson = (rules) => {
const payload = (rules || []).map((r) => {
const { id, ...rest } = r || {};
return rest;
});
return stringifyPretty(payload);
};
const normalizeKeySource = (src) => {
const type = (src?.type || '').trim();
const key = (src?.key || '').trim();
const path = (src?.path || '').trim();
return { type, key, path };
};
const makeUniqueName = (existingNames, baseName) => {
const base = (baseName || '').trim() || 'rule';
if (!existingNames.has(base)) return base;
for (let i = 2; i < 1000; i++) {
const n = `${base}-${i}`;
if (!existingNames.has(n)) return n;
}
return `${base}-${Date.now()}`;
};
const tryParseRulesJsonArray = (jsonString) => {
const raw = jsonString || '[]';
if (!verifyJSON(raw)) return { ok: false, message: 'Rules JSON is invalid' };
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed))
return { ok: false, message: 'Rules JSON must be an array' };
return { ok: true, value: parsed };
} catch (e) {
return { ok: false, message: 'Rules JSON is invalid' };
}
};
export default function SettingsChannelAffinity(props) {
const { t } = useTranslation();
const { Text } = Typography;
const [loading, setLoading] = useState(false);
const [cacheLoading, setCacheLoading] = useState(false);
const [cacheStats, setCacheStats] = useState({
enabled: false,
total: 0,
unknown: 0,
by_rule_name: {},
cache_capacity: 0,
cache_algo: '',
});
const [inputs, setInputs] = useState({
[KEY_ENABLED]: false,
[KEY_SWITCH_ON_SUCCESS]: true,
[KEY_MAX_ENTRIES]: 100000,
[KEY_DEFAULT_TTL]: 3600,
[KEY_RULES]: '[]',
});
const refForm = useRef();
const [inputsRow, setInputsRow] = useState(inputs);
const [editMode, setEditMode] = useState('visual');
const prevEditModeRef = useRef(editMode);
const [rules, setRules] = useState([]);
const [modalVisible, setModalVisible] = useState(false);
const [editingRule, setEditingRule] = useState(null);
const [isEdit, setIsEdit] = useState(false);
const modalFormRef = useRef();
const [modalInitValues, setModalInitValues] = useState(null);
const [modalFormKey, setModalFormKey] = useState(0);
const [modalAdvancedActiveKey, setModalAdvancedActiveKey] = useState([]);
const effectiveDefaultTTLSeconds =
Number(inputs?.[KEY_DEFAULT_TTL] || 0) > 0
? Number(inputs?.[KEY_DEFAULT_TTL] || 0)
: 3600;
const buildModalFormValues = (rule) => {
const r = rule || {};
return {
name: r.name || '',
model_regex_text: (r.model_regex || []).join('\n'),
path_regex_text: (r.path_regex || []).join('\n'),
user_agent_include_text: (r.user_agent_include || []).join('\n'),
value_regex: r.value_regex || '',
ttl_seconds: Number(r.ttl_seconds || 0),
include_using_group: r.include_using_group ?? true,
include_rule_name: r.include_rule_name ?? true,
};
};
const refreshCacheStats = async () => {
try {
setCacheLoading(true);
const res = await API.get('/api/option/channel_affinity_cache', {
disableDuplicate: true,
});
const { success, message, data } = res.data;
if (!success) return showError(t(message));
setCacheStats(data || {});
} catch (e) {
showError(t('刷新缓存统计失败'));
} finally {
setCacheLoading(false);
}
};
const confirmClearAllCache = () => {
Modal.confirm({
title: t('确认清空全部渠道亲和性缓存'),
content: (
<div style={{ lineHeight: '1.6' }}>
<Text>{t('将删除所有仍在内存中的渠道亲和性缓存条目。')}</Text>
</div>
),
onOk: async () => {
const res = await API.delete('/api/option/channel_affinity_cache', {
params: { all: true },
});
const { success, message } = res.data;
if (!success) {
showError(t(message));
return;
}
showSuccess(t('已清空'));
await refreshCacheStats();
},
});
};
const confirmClearRuleCache = (rule) => {
const name = (rule?.name || '').trim();
if (!name) return;
if (!rule?.include_rule_name) {
showWarning(
t('该规则未启用“作用域:包含规则名称”,无法按规则清空缓存。'),
);
return;
}
Modal.confirm({
title: t('确认清空该规则缓存'),
content: (
<div style={{ lineHeight: '1.6' }}>
<Text>{t('规则')}</Text> <Text strong>{name}</Text>
</div>
),
onOk: async () => {
const res = await API.delete('/api/option/channel_affinity_cache', {
params: { rule_name: name },
});
const { success, message } = res.data;
if (!success) {
showError(t(message));
return;
}
showSuccess(t('已清空'));
await refreshCacheStats();
},
});
};
const setRulesJsonToForm = (jsonString) => {
if (!refForm.current) return;
// Use setValue instead of setValues. Semi Form's setValues assigns undefined
// to every registered field not included in the payload, which can wipe other inputs.
refForm.current.setValue(KEY_RULES, jsonString || '[]');
};
const switchToJsonMode = () => {
// Ensure a stable source of truth when entering JSON mode.
// Semi Form may ignore setValues() for an unmounted field, so we seed state first.
const jsonString = rulesToJson(rules);
setInputs((prev) => ({ ...(prev || {}), [KEY_RULES]: jsonString }));
setEditMode('json');
};
const switchToVisualMode = () => {
const validation = tryParseRulesJsonArray(inputs[KEY_RULES] || '[]');
if (!validation.ok) {
showError(t(validation.message));
return;
}
setEditMode('visual');
};
const updateRulesState = (nextRules) => {
setRules(nextRules);
const jsonString = rulesToJson(nextRules);
setInputs((prev) => ({ ...prev, [KEY_RULES]: jsonString }));
if (refForm.current && editMode === 'json') {
refForm.current.setValue(KEY_RULES, jsonString);
}
};
const appendCodexAndClaudeCodeTemplates = () => {
const doAppend = () => {
const existingNames = new Set(
(rules || [])
.map((r) => (r?.name || '').trim())
.filter((x) => x.length > 0),
);
const templates = [RULE_TEMPLATES.codex, RULE_TEMPLATES.claudeCode].map(
(tpl) => {
const name = makeUniqueName(existingNames, tpl.name);
existingNames.add(name);
return { ...tpl, name };
},
);
const next = [...(rules || []), ...templates].map((r, idx) => ({
...(r || {}),
id: idx,
}));
updateRulesState(next);
showSuccess(t('已填充模版'));
};
if ((rules || []).length === 0) {
doAppend();
return;
}
Modal.confirm({
title: t('填充 Codex / Claude Code 模版'),
content: (
<div style={{ lineHeight: '1.6' }}>
<Text type='tertiary'>{t('将追加 2 条规则到现有规则列表。')}</Text>
</div>
),
onOk: doAppend,
});
};
const ruleColumns = [
{
title: t('名称'),
dataIndex: 'name',
render: (text) => <Text>{text || '-'}</Text>,
},
{
title: t('模型正则'),
dataIndex: 'model_regex',
render: (list) =>
(list || []).length > 0
? (list || []).slice(0, 3).map((v, idx) => (
<Tag key={`${v}-${idx}`} style={{ marginRight: 4 }}>
{v}
</Tag>
))
: '-',
},
{
title: t('路径正则'),
dataIndex: 'path_regex',
render: (list) =>
(list || []).length > 0
? (list || []).slice(0, 2).map((v, idx) => (
<Tag key={`${v}-${idx}`} style={{ marginRight: 4 }}>
{v}
</Tag>
))
: '-',
},
{
title: t('User-Agent include'),
dataIndex: 'user_agent_include',
render: (list) =>
(list || []).length > 0
? (list || []).slice(0, 2).map((v, idx) => (
<Tag key={`${v}-${idx}`} style={{ marginRight: 4 }}>
{v}
</Tag>
))
: '-',
},
{
title: t('Key 来源'),
dataIndex: 'key_sources',
render: (list) => {
const xs = list || [];
if (xs.length === 0) return '-';
return xs.slice(0, 3).map((src, idx) => {
const s = normalizeKeySource(src);
const detail = s.type === 'gjson' ? s.path : s.key;
return (
<Tag key={`${s.type}-${idx}`} style={{ marginRight: 4 }}>
{s.type}:{detail}
</Tag>
);
});
},
},
{
title: t('TTL(秒)'),
dataIndex: 'ttl_seconds',
render: (v) => <Text>{Number(v || 0) || '-'}</Text>,
},
{
title: t('缓存条目数'),
render: (_, record) => {
const name = (record?.name || '').trim();
if (!name || !record?.include_rule_name) {
return <Text type='tertiary'>N/A</Text>;
}
const n = Number(cacheStats?.by_rule_name?.[name] || 0);
return <Text>{n}</Text>;
},
},
{
title: t('作用域'),
render: (_, record) => {
const tags = [];
if (record?.include_using_group) tags.push('分组');
if (record?.include_rule_name) tags.push('规则');
if (tags.length === 0) return '-';
return tags.map((x) => (
<Tag key={x} style={{ marginRight: 4 }}>
{x}
</Tag>
));
},
},
{
title: t('操作'),
render: (_, record) => (
<Space>
<Button
icon={<IconClose />}
theme='borderless'
type='warning'
disabled={!record?.include_rule_name}
title={t('清空该规则缓存')}
aria-label={t('清空该规则缓存')}
onClick={() => confirmClearRuleCache(record)}
/>
<Button
icon={<IconEdit />}
theme='borderless'
title={t('编辑规则')}
aria-label={t('编辑规则')}
onClick={() => handleEditRule(record)}
/>
<Button
icon={<IconDelete />}
theme='borderless'
type='danger'
title={t('删除规则')}
aria-label={t('删除规则')}
onClick={() => handleDeleteRule(record.id)}
/>
</Space>
),
},
];
const validateKeySources = (keySources) => {
const xs = (keySources || []).map(normalizeKeySource).filter((x) => x.type);
if (xs.length === 0) return { ok: false, message: 'Key 来源不能为空' };
for (const x of xs) {
if (x.type === 'context_int' || x.type === 'context_string') {
if (!x.key) return { ok: false, message: 'Key 不能为空' };
} else if (x.type === 'gjson') {
if (!x.path) return { ok: false, message: 'Path 不能为空' };
} else {
return { ok: false, message: 'Key 来源类型不合法' };
}
}
return { ok: true, value: xs };
};
const openAddModal = () => {
const nextRule = {
name: '',
model_regex: [],
path_regex: [],
user_agent_include: [],
key_sources: [{ type: 'gjson', path: '' }],
value_regex: '',
ttl_seconds: 0,
include_using_group: true,
include_rule_name: true,
};
setEditingRule(nextRule);
setIsEdit(false);
modalFormRef.current = null;
setModalInitValues(buildModalFormValues(nextRule));
setModalAdvancedActiveKey([]);
setModalFormKey((k) => k + 1);
setModalVisible(true);
};
const handleEditRule = (rule) => {
const r = rule || {};
const nextRule = {
...r,
user_agent_include: Array.isArray(r.user_agent_include)
? r.user_agent_include
: [],
key_sources: (r.key_sources || []).map(normalizeKeySource),
};
setEditingRule(nextRule);
setIsEdit(true);
modalFormRef.current = null;
setModalInitValues(buildModalFormValues(nextRule));
setModalAdvancedActiveKey([]);
setModalFormKey((k) => k + 1);
setModalVisible(true);
};
const handleDeleteRule = (id) => {
const next = (rules || []).filter((r) => r.id !== id);
updateRulesState(next.map((r, idx) => ({ ...r, id: idx })));
showSuccess(t('删除成功'));
};
const handleModalSave = async () => {
try {
const values = await modalFormRef.current.validate();
const modelRegex = normalizeStringList(values.model_regex_text);
if (modelRegex.length === 0) return showError(t('模型正则不能为空'));
const keySourcesValidation = validateKeySources(editingRule?.key_sources);
if (!keySourcesValidation.ok)
return showError(t(keySourcesValidation.message));
const userAgentInclude = normalizeStringList(
values.user_agent_include_text,
);
const rulePayload = {
id: isEdit ? editingRule.id : rules.length,
name: (values.name || '').trim(),
model_regex: modelRegex,
path_regex: normalizeStringList(values.path_regex_text),
key_sources: keySourcesValidation.value,
value_regex: (values.value_regex || '').trim(),
ttl_seconds: Number(values.ttl_seconds || 0),
include_using_group: !!values.include_using_group,
include_rule_name: !!values.include_rule_name,
...(userAgentInclude.length > 0
? { user_agent_include: userAgentInclude }
: {}),
};
if (!rulePayload.name) return showError(t('名称不能为空'));
const next = [...(rules || [])];
if (isEdit) {
let idx = next.findIndex((r) => r.id === editingRule?.id);
if (idx < 0 && editingRule?.name) {
idx = next.findIndex(
(r) => (r?.name || '').trim() === (editingRule?.name || '').trim(),
);
}
if (idx < 0) return showError(t('规则未找到,请刷新后重试'));
next[idx] = rulePayload;
} else {
next.push(rulePayload);
}
updateRulesState(next.map((r, idx) => ({ ...r, id: idx })));
setModalVisible(false);
setEditingRule(null);
setModalInitValues(null);
showSuccess(t('保存成功'));
} catch (e) {
showError(t('请检查输入'));
}
};
const updateKeySource = (index, patch) => {
const next = [...(editingRule?.key_sources || [])];
next[index] = normalizeKeySource({
...(next[index] || {}),
...(patch || {}),
});
setEditingRule((prev) => ({ ...(prev || {}), key_sources: next }));
};
const addKeySource = () => {
const next = [...(editingRule?.key_sources || [])];
next.push({ type: 'gjson', path: '' });
setEditingRule((prev) => ({ ...(prev || {}), key_sources: next }));
};
const removeKeySource = (index) => {
const next = [...(editingRule?.key_sources || [])].filter(
(_, i) => i !== index,
);
setEditingRule((prev) => ({ ...(prev || {}), key_sources: next }));
};
async function onSubmit() {
const updateArray = compareObjects(inputs, inputsRow);
if (!updateArray.length) return showWarning(t('你似乎并没有修改什么'));
if (!verifyJSON(inputs[KEY_RULES] || '[]'))
return showError(t('规则 JSON 格式不正确'));
let compactRules;
try {
compactRules = stringifyCompact(JSON.parse(inputs[KEY_RULES] || '[]'));
} catch (e) {
return showError(t('规则 JSON 格式不正确'));
}
const requestQueue = updateArray.map((item) => {
let value = '';
if (item.key === KEY_RULES) {
value = compactRules;
} else if (typeof inputs[item.key] === 'boolean') {
value = String(inputs[item.key]);
} else {
value = String(inputs[item.key] ?? '');
}
return API.put('/api/option/', { key: item.key, value });
});
setLoading(true);
Promise.all(requestQueue)
.then((res) => {
if (requestQueue.length === 1) {
if (res.includes(undefined)) return;
} else if (requestQueue.length > 1) {
if (res.includes(undefined))
return showError(t('部分保存失败,请重试'));
}
showSuccess(t('保存成功'));
props.refresh();
})
.catch(() => showError(t('保存失败,请重试')))
.finally(() => setLoading(false));
}
useEffect(() => {
const currentInputs = { ...inputs };
for (let key in props.options) {
if (
![
KEY_ENABLED,
KEY_SWITCH_ON_SUCCESS,
KEY_MAX_ENTRIES,
KEY_DEFAULT_TTL,
KEY_RULES,
].includes(key)
)
continue;
if (key === KEY_ENABLED)
currentInputs[key] = toBoolean(props.options[key]);
else if (key === KEY_SWITCH_ON_SUCCESS)
currentInputs[key] = toBoolean(props.options[key]);
else if (key === KEY_MAX_ENTRIES)
currentInputs[key] = Number(props.options[key] || 0) || 0;
else if (key === KEY_DEFAULT_TTL)
currentInputs[key] = Number(props.options[key] || 0) || 0;
else if (key === KEY_RULES) {
try {
const obj = JSON.parse(props.options[key] || '[]');
currentInputs[key] = stringifyPretty(obj);
} catch (e) {
currentInputs[key] = props.options[key] || '[]';
}
}
}
setInputs(currentInputs);
setInputsRow(structuredClone(currentInputs));
if (refForm.current) refForm.current.setValues(currentInputs);
setRules(parseRulesJson(currentInputs[KEY_RULES]));
refreshCacheStats();
}, [props.options]);
useEffect(() => {
const prevEditMode = prevEditModeRef.current;
prevEditModeRef.current = editMode;
// On switching from visual -> json, ensure the JSON editor is seeded.
// Semi Form may ignore setValues() for an unmounted field.
if (prevEditMode === editMode) return;
if (editMode !== 'json') return;
if (!refForm.current) return;
refForm.current.setValue(KEY_RULES, inputs[KEY_RULES] || '[]');
}, [editMode, inputs]);
useEffect(() => {
if (editMode === 'visual') {
setRules(parseRulesJson(inputs[KEY_RULES]));
}
}, [inputs[KEY_RULES], editMode]);
const banner = (
<Banner
fullMode={false}
type='info'
description={t(
'渠道亲和性会基于从请求上下文或 JSON Body 提取的 Key,优先复用上一次成功的渠道。',
)}
/>
);
return (
<>
<Spin spinning={loading}>
<Form
values={inputs}
getFormApi={(formAPI) => (refForm.current = formAPI)}
style={{ marginBottom: 15 }}
>
<Form.Section text={t('渠道亲和性')}>
{banner}
<Divider style={{ marginTop: 12, marginBottom: 12 }} />
<Row gutter={16}>
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
<Form.Switch
field={KEY_ENABLED}
label={t('启用')}
checkedText='|'
uncheckedText='O'
onChange={(value) =>
setInputs({ ...inputs, [KEY_ENABLED]: value })
}
/>
<Text type='tertiary' size='small'>
{t('启用后将优先复用上一次成功的渠道(粘滞选路)。')}
</Text>
</Col>
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
<Form.InputNumber
field={KEY_MAX_ENTRIES}
label={t('最大条目数')}
min={0}
placeholder='例如 100000…'
extraText={
<Text type='tertiary' size='small'>
{t(
'内存缓存最大条目数。0 表示使用后端默认容量:100000。',
)}
</Text>
}
onChange={(value) =>
setInputs({
...inputs,
[KEY_MAX_ENTRIES]: Number(value || 0),
})
}
/>
</Col>
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
<Form.InputNumber
field={KEY_DEFAULT_TTL}
label={t('默认 TTL(秒)')}
min={0}
placeholder='例如 3600…'
extraText={
<Text type='tertiary' size='small'>
{t(
'规则 ttl_seconds 为 0 时使用。0 表示使用后端默认 TTL:3600 秒。',
)}
</Text>
}
onChange={(value) =>
setInputs({
...inputs,
[KEY_DEFAULT_TTL]: Number(value || 0),
})
}
/>
</Col>
</Row>
<Row gutter={16} style={{ marginTop: 12 }}>
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
<Form.Switch
field={KEY_SWITCH_ON_SUCCESS}
label={t('成功后切换亲和')}
checkedText='|'
uncheckedText='O'
onChange={(value) =>
setInputs({ ...inputs, [KEY_SWITCH_ON_SUCCESS]: value })
}
/>
<Text type='tertiary' size='small'>
{t(
'如果亲和到的渠道失败,重试到其他渠道成功后,将亲和更新到成功的渠道。',
)}
</Text>
</Col>
</Row>
<Divider style={{ marginTop: 12, marginBottom: 12 }} />
<Space style={{ marginBottom: 10 }}>
<Button
type={editMode === 'visual' ? 'primary' : 'tertiary'}
onClick={switchToVisualMode}
>
{t('可视化')}
</Button>
<Button
type={editMode === 'json' ? 'primary' : 'tertiary'}
onClick={switchToJsonMode}
>
{t('JSON 模式')}
</Button>
<Button onClick={appendCodexAndClaudeCodeTemplates}>
{t('填充 Codex / Claude Code 模版')}
</Button>
<Button icon={<IconPlus />} onClick={openAddModal}>
{t('新增规则')}
</Button>
<Button theme='solid' onClick={onSubmit}>
{t('保存')}
</Button>
<Button
icon={<IconRefresh />}
loading={cacheLoading}
onClick={refreshCacheStats}
>
{t('刷新缓存统计')}
</Button>
<Button type='danger' onClick={confirmClearAllCache}>
{t('清空全部缓存')}
</Button>
</Space>
{editMode === 'visual' ? (
<Table
columns={ruleColumns}
dataSource={rules}
rowKey='id'
pagination={false}
size='small'
/>
) : (
<Form.TextArea
field={KEY_RULES}
label={t('规则 JSON')}
extraText={t(
'规则为 JSON 数组;可视化与 JSON 模式共用同一份数据。',
)}
placeholder={RULES_JSON_PLACEHOLDER}
style={{ width: '100%' }}
autosize={{ minRows: 10, maxRows: 28 }}
rules={[
{
validator: (rule, value) => verifyJSON(value || '[]'),
},
]}
onChange={(value) =>
setInputs({ ...inputs, [KEY_RULES]: value })
}
/>
)}
</Form.Section>
</Form>
</Spin>
<Modal
title={isEdit ? t('编辑规则') : t('新增规则')}
visible={modalVisible}
onCancel={() => {
setModalVisible(false);
setEditingRule(null);
setModalInitValues(null);
setModalAdvancedActiveKey([]);
}}
onOk={handleModalSave}
okText={t('保存')}
cancelText={t('取消')}
width={720}
>
<Form
key={`channel-affinity-rule-form-${modalFormKey}`}
initValues={modalInitValues || {}}
getFormApi={(formAPI) => {
modalFormRef.current = formAPI;
}}
>
<Form.Input
field='name'
label={t('名称')}
extraText={t('规则名称(可读性更好,也会出现在管理侧日志中)。')}
placeholder='例如 prefer-by-conversation-id…'
rules={[{ required: true }]}
onChange={(value) =>
setEditingRule((prev) => ({ ...(prev || {}), name: value }))
}
/>
<Row gutter={16}>
<Col xs={24} sm={12}>
<Form.TextArea
field='model_regex_text'
label={t('模型正则(每行一个)')}
extraText={t(
'必填。对请求的 model 名称进行匹配,任意一条匹配即命中该规则。',
)}
placeholder={'^gpt-4o.*$\n^claude-3.*$…'}
autosize={{ minRows: 4, maxRows: 10 }}
rules={[{ required: true }]}
/>
</Col>
<Col xs={24} sm={12}>
<Form.TextArea
field='path_regex_text'
label={t('路径正则(每行一个)')}
extraText={t(
'可选。对请求路径进行匹配;不填表示匹配所有路径。',
)}
placeholder={'/v1/chat/completions\n/v1/responses…'}
autosize={{ minRows: 4, maxRows: 10 }}
/>
</Col>
</Row>
<Collapse
keepDOM
activeKey={modalAdvancedActiveKey}
onChange={(activeKey) => {
const keys = Array.isArray(activeKey) ? activeKey : [activeKey];
setModalAdvancedActiveKey(keys.filter(Boolean));
}}
>
<Collapse.Panel header={t('高级设置')} itemKey='advanced'>
<Row gutter={16}>
<Col xs={24}>
<Form.TextArea
field='user_agent_include_text'
label={t('User-Agent include(每行一个,可不写)')}
extraText={
<Text type='tertiary' size='small'>
{t(
'可选。匹配入口请求的 User-Agent;任意一行作为子串匹配(忽略大小写)即命中。',
)}
<br />
{t(
'NewAPI 默认不会将入口请求的 User-Agent 透传到上游渠道;该条件仅用于识别访问本站点的客户端。',
)}
<br />
{t(
'为保证匹配准确,请确保客户端直连本站点(避免反向代理/网关改写 User-Agent)。',
)}
</Text>
}
placeholder={'curl\nPostmanRuntime\nMyApp/…'}
autosize={{ minRows: 3, maxRows: 8 }}
/>
</Col>
</Row>
<Row gutter={16}>
<Col xs={24} sm={12}>
<Form.Input
field='value_regex'
label={t('Value 正则')}
placeholder='^[-0-9A-Za-z._:]{1,128}$'
extraText={t(
'可选。对提取到的亲和 Key 做正则校验;不填表示不校验。',
)}
/>
</Col>
<Col xs={24} sm={12}>
<Form.InputNumber
field='ttl_seconds'
label={t('TTL(秒,0 表示默认)')}
placeholder='例如 600…'
min={0}
extraText={
<Text type='tertiary' size='small'>
{t('该规则的缓存保留时长;0 表示使用默认 TTL:')}
{effectiveDefaultTTLSeconds}
{t(' 秒。')}
</Text>
}
/>
</Col>
</Row>
<Row gutter={16}>
<Col xs={24} sm={12}>
<Form.Switch
field='include_using_group'
label={t('作用域:包含分组')}
/>
<Text type='tertiary' size='small'>
{t(
'开启后,using_group 会参与 cache key(不同分组隔离)。',
)}
</Text>
</Col>
<Col xs={24} sm={12}>
<Form.Switch
field='include_rule_name'
label={t('作用域:包含规则名称')}
/>
<Text type='tertiary' size='small'>
{t('开启后,规则名称会参与 cache key(不同规则隔离)。')}
</Text>
</Col>
</Row>
</Collapse.Panel>
</Collapse>
<Divider style={{ marginTop: 12, marginBottom: 12 }} />
<Space style={{ marginBottom: 10 }}>
<Text>{t('Key 来源')}</Text>
<Button icon={<IconPlus />} onClick={addKeySource}>
{t('新增 Key 来源')}
</Button>
</Space>
<Text type='tertiary' size='small'>
{t(
'context_int/context_string 从请求上下文读取;gjson 从入口请求的 JSON body 按 gjson path 读取。',
)}
</Text>
<div style={{ marginTop: 8, marginBottom: 8 }}>
<Text type='tertiary' size='small'>
{t('常用上下文 Key(用于 context_*)')}
</Text>
<div style={{ marginTop: 6 }}>
{(CONTEXT_KEY_PRESETS || []).map((x) => (
<Tag key={x.key} style={{ marginRight: 6, marginBottom: 6 }}>
{x.label}
</Tag>
))}
</div>
</div>
<Table
columns={[
{
title: t('类型'),
render: (_, __, idx) => (
<Select
style={{ width: 160 }}
optionList={KEY_SOURCE_TYPES}
value={(
editingRule?.key_sources?.[idx]?.type || 'gjson'
).trim()}
aria-label={t('Key 来源类型')}
onChange={(value) => updateKeySource(idx, { type: value })}
/>
),
},
{
title: t('Key 或 Path'),
render: (_, __, idx) => {
const src = normalizeKeySource(
editingRule?.key_sources?.[idx],
);
const isGjson = src.type === 'gjson';
return (
<Input
placeholder={
isGjson ? 'metadata.conversation_id' : 'user_id'
}
aria-label={t('Key 或 Path')}
value={isGjson ? src.path : src.key}
onChange={(value) =>
updateKeySource(
idx,
isGjson ? { path: value } : { key: value },
)
}
/>
);
},
},
{
title: t('操作'),
width: 90,
render: (_, __, idx) => (
<Button
icon={<IconDelete />}
theme='borderless'
type='danger'
title={t('删除 Key 来源')}
aria-label={t('删除 Key 来源')}
onClick={() => removeKeySource(idx)}
/>
),
},
]}
dataSource={(editingRule?.key_sources || []).map((x, idx) => ({
id: idx,
...x,
}))}
rowKey='id'
pagination={false}
size='small'
/>
</Form>
</Modal>
</>
);
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment