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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions constant/context_key.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,9 @@ const (
// from X-Payment-Receipt). Value type: map[string]interface{}. Surfaced into
// the consume-log "other" map by service.GenerateTextOtherInfo.
ContextKeyBlockRunSettlement ContextKey = "blockrun_settlement"
// ContextKeyBlockRunPaymentState stores request-scoped signed-payment state.
// Value type: *relay/common.BlockRunPaymentState.
ContextKeyBlockRunPaymentState ContextKey = "blockrun_payment_state"

// ContextKeyRequestSamplingEligible marks user-facing text LLM relay
// requests that may be considered for optional request-parameter sampling.
Expand Down
119 changes: 112 additions & 7 deletions controller/channel.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"math/big"
"net/http"
"strconv"
"strings"
Expand All @@ -19,10 +20,13 @@ import (
"github.com/QuantumNous/new-api/relay/channel/ollama"
"github.com/QuantumNous/new-api/service"

blockrunSDK "github.com/BlockRunAI/blockrun-llm-go"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)

const blockRunSolanaBaseURL = blockrunSDK.DefaultSolanaAPIURL

type OpenAIModel struct {
ID string `json:"id"`
Object string `json:"object"`
Expand Down Expand Up @@ -467,8 +471,8 @@ func validateChannel(channel *model.Channel, isAdd bool) error {
if err := channel.ValidateSettings(); err != nil {
return fmt.Errorf("渠道额外设置[channel setting] 格式错误:%s", err.Error())
}
if channel.Type == constant.ChannelTypeModelAPISeedance && strings.TrimSpace(channel.GetSetting().Proxy) != "" {
return fmt.Errorf("this channel type does not support proxy")
if err := validateBlockRunPaymentSettings(channel); err != nil {
return err
}

// 如果是添加操作,检查 channel 和 key 是否为空
Expand Down Expand Up @@ -524,6 +528,50 @@ func validateChannel(channel *model.Channel, isAdd bool) error {
return nil
}

func validateBlockRunPaymentSettings(channel *model.Channel) error {
if channel.Type != constant.ChannelTypeBlockRun {
return nil
}

settings := dto.ChannelOtherSettings{}
if channel.OtherSettings != "" {
if err := common.UnmarshalJsonStr(channel.OtherSettings, &settings); err != nil {
return fmt.Errorf("BlockRun settings must be valid JSON: %w", err)
}
}

switch settings.GetBlockRunPaymentChain() {
case dto.BlockRunPaymentChainBase:
return nil
case dto.BlockRunPaymentChainSolana:
if channel.BaseURL == nil || (*channel.BaseURL != blockRunSolanaBaseURL && *channel.BaseURL != blockRunSolanaBaseURL+"/") {
return fmt.Errorf("Solana BlockRun base_url must be %s", blockRunSolanaBaseURL)
}
if _, err := blockrunSDK.GetSolanaPublicKey(strings.TrimSpace(channel.Key)); err != nil {
return fmt.Errorf("Solana BlockRun key is invalid: %w", err)
}
if !isPositiveDecimalInteger(settings.BlockRunMaxPaymentAtomic) {
return fmt.Errorf("Solana BlockRun blockrun_max_payment_atomic must be a positive decimal integer")
}
return nil
default:
return fmt.Errorf("unsupported BlockRun payment chain %q", settings.BlockRunPaymentChain)
}
}

func isPositiveDecimalInteger(value string) bool {
if value == "" {
return false
}
for _, ch := range value {
if ch < '0' || ch > '9' {
return false
}
}
parsed, ok := new(big.Int).SetString(value, 10)
return ok && parsed.Sign() > 0
}

func RefreshCodexChannelCredential(c *gin.Context) {
channelId, err := strconv.Atoi(c.Param("id"))
if err != nil {
Expand Down Expand Up @@ -888,17 +936,37 @@ func UpdateChannel(c *gin.Context) {
return
}

// 使用统一的校验函数
if err := validateChannel(&channel.Channel, false); err != nil {
// Preserve existing ChannelInfo to ensure multi-key channels keep correct state even if the client does not send ChannelInfo in the request.
originChannel, err := model.GetChannelById(channel.Id, true)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
// Preserve existing ChannelInfo to ensure multi-key channels keep correct state even if the client does not send ChannelInfo in the request.
originChannel, err := model.GetChannelById(channel.Id, true)
if err != nil {

validationChannel := channel.Channel
if validationChannel.Type == 0 {
validationChannel.Type = originChannel.Type
}
if validationChannel.Key == "" {
validationChannel.Key = originChannel.Key
}
if validationChannel.BaseURL == nil {
validationChannel.BaseURL = originChannel.BaseURL
}
if validationChannel.OtherSettings == "" {
validationChannel.OtherSettings = originChannel.OtherSettings
}
if err := validateChannel(&validationChannel, false); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
if err := validateBlockRunPaymentChainTransition(originChannel, &validationChannel); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
Expand Down Expand Up @@ -1011,6 +1079,43 @@ func UpdateChannel(c *gin.Context) {
return
}

func validateBlockRunPaymentChainTransition(originChannel, updatedChannel *model.Channel) error {
if originChannel == nil || updatedChannel == nil {
return nil
}
originIsBlockRun := originChannel.Type == constant.ChannelTypeBlockRun
updatedIsBlockRun := updatedChannel.Type == constant.ChannelTypeBlockRun
if !originIsBlockRun && !updatedIsBlockRun {
return nil
}
if originIsBlockRun != updatedIsBlockRun {
return fmt.Errorf("existing channel cannot change type into or out of BlockRun")
}

getChain := func(channel *model.Channel) (dto.BlockRunPaymentChain, error) {
settings := dto.ChannelOtherSettings{}
if channel.OtherSettings != "" {
if err := common.UnmarshalJsonStr(channel.OtherSettings, &settings); err != nil {
return "", fmt.Errorf("BlockRun settings must be valid JSON: %w", err)
}
}
return settings.GetBlockRunPaymentChain(), nil
}

originChain, err := getChain(originChannel)
if err != nil {
return err
}
updatedChain, err := getChain(updatedChannel)
if err != nil {
return err
}
if originChain != updatedChain {
return fmt.Errorf("existing BlockRun channel payment chain cannot change from %s to %s", originChain, updatedChain)
}
return nil
}

func FetchModels(c *gin.Context) {
var req struct {
BaseURL string `json:"base_url"`
Expand Down
128 changes: 128 additions & 0 deletions controller/channel_blockrun_payment_validation_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
package controller

import (
"testing"

"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/model"
"github.com/stretchr/testify/require"
)

const validSolanaSeed = "11111111111111111111111111111111"

func blockRunChannelForValidation(t *testing.T, channelType int, chain dto.BlockRunPaymentChain, baseURL, key, cap string) *model.Channel {
t.Helper()
settings, err := common.Marshal(dto.ChannelOtherSettings{
BlockRunPaymentChain: chain,
BlockRunMaxPaymentAtomic: cap,
})
require.NoError(t, err)
return &model.Channel{
Type: channelType,
Key: key,
BaseURL: common.GetPointer(baseURL),
OtherSettings: string(settings),
}
}

func TestValidateChannelBlockRunPaymentSettings(t *testing.T) {
t.Run("missing chain keeps Base behavior", func(t *testing.T) {
channel := blockRunChannelForValidation(t, constant.ChannelTypeBlockRun, "", "", "base-key-is-not-validated", "")
require.NoError(t, validateChannel(channel, true))
})

t.Run("explicit Base ignores Solana-only fields", func(t *testing.T) {
channel := blockRunChannelForValidation(t, constant.ChannelTypeBlockRun, dto.BlockRunPaymentChainBase, "https://custom-base.example", "base-key", "not-a-number")
require.NoError(t, validateChannel(channel, true))
})

t.Run("Solana accepts the official URL with an optional trailing slash", func(t *testing.T) {
for _, baseURL := range []string{blockRunSolanaBaseURL, blockRunSolanaBaseURL + "/"} {
channel := blockRunChannelForValidation(t, constant.ChannelTypeBlockRun, dto.BlockRunPaymentChainSolana, baseURL, validSolanaSeed, "1000000")
require.NoError(t, validateChannel(channel, true))
}
})

t.Run("Solana requires the exact official URL", func(t *testing.T) {
for _, baseURL := range []string{"", "https://blockrun.ai/api", blockRunSolanaBaseURL + "/v1"} {
channel := blockRunChannelForValidation(t, constant.ChannelTypeBlockRun, dto.BlockRunPaymentChainSolana, baseURL, validSolanaSeed, "1000000")
require.ErrorContains(t, validateChannel(channel, true), "base_url")
}
})

t.Run("Solana requires a parseable wallet key", func(t *testing.T) {
channel := blockRunChannelForValidation(t, constant.ChannelTypeBlockRun, dto.BlockRunPaymentChainSolana, blockRunSolanaBaseURL, "not-base58", "1000000")
require.ErrorContains(t, validateChannel(channel, true), "key is invalid")
})

t.Run("Solana cap must be a positive decimal string", func(t *testing.T) {
for _, cap := range []string{"", "0", "-1", "+1", "1.5", " 1"} {
channel := blockRunChannelForValidation(t, constant.ChannelTypeBlockRun, dto.BlockRunPaymentChainSolana, blockRunSolanaBaseURL, validSolanaSeed, cap)
require.ErrorContains(t, validateChannel(channel, true), "blockrun_max_payment_atomic")
}
channel := blockRunChannelForValidation(t, constant.ChannelTypeBlockRun, dto.BlockRunPaymentChainSolana, blockRunSolanaBaseURL, validSolanaSeed, "18446744073709551616")
require.NoError(t, validateChannel(channel, true))
})

t.Run("unknown payment chain fails closed", func(t *testing.T) {
channel := blockRunChannelForValidation(t, constant.ChannelTypeBlockRun, dto.BlockRunPaymentChain("polygon"), "", "", "")
require.ErrorContains(t, validateChannel(channel, true), "unsupported BlockRun payment chain")
})

t.Run("BlockRun 101 and 102 are unaffected", func(t *testing.T) {
for _, channelType := range []int{constant.ChannelTypeBlockRunVideo, constant.ChannelTypeBlockRunSeedance} {
channel := blockRunChannelForValidation(t, channelType, dto.BlockRunPaymentChainSolana, "https://unrelated.example", "not-a-solana-key", "0")
require.NoError(t, validateChannel(channel, true))
}
})
}

func TestValidateBlockRunPaymentChainTransition(t *testing.T) {
t.Run("rejects Base to Solana", func(t *testing.T) {
origin := blockRunChannelForValidation(t, constant.ChannelTypeBlockRun, "", "", "base-key", "")
updated := blockRunChannelForValidation(t, constant.ChannelTypeBlockRun, dto.BlockRunPaymentChainSolana, blockRunSolanaBaseURL, validSolanaSeed, "1000000")
require.ErrorContains(t, validateBlockRunPaymentChainTransition(origin, updated), "cannot change from base to solana")
})

t.Run("rejects Solana to Base", func(t *testing.T) {
origin := blockRunChannelForValidation(t, constant.ChannelTypeBlockRun, dto.BlockRunPaymentChainSolana, blockRunSolanaBaseURL, validSolanaSeed, "1000000")
updated := blockRunChannelForValidation(t, constant.ChannelTypeBlockRun, "", "", "base-key", "")
require.ErrorContains(t, validateBlockRunPaymentChainTransition(origin, updated), "cannot change from solana to base")
})

t.Run("allows same effective chain updates", func(t *testing.T) {
baseOrigin := blockRunChannelForValidation(t, constant.ChannelTypeBlockRun, "", "", "old-base-key", "")
baseUpdated := blockRunChannelForValidation(t, constant.ChannelTypeBlockRun, dto.BlockRunPaymentChainBase, "https://custom-base.example", "new-base-key", "")
require.NoError(t, validateBlockRunPaymentChainTransition(baseOrigin, baseUpdated))

solanaOrigin := blockRunChannelForValidation(t, constant.ChannelTypeBlockRun, dto.BlockRunPaymentChainSolana, blockRunSolanaBaseURL, validSolanaSeed, "1000000")
solanaUpdated := blockRunChannelForValidation(t, constant.ChannelTypeBlockRun, dto.BlockRunPaymentChainSolana, blockRunSolanaBaseURL+"/", validSolanaSeed, "2000000")
require.NoError(t, validateBlockRunPaymentChainTransition(solanaOrigin, solanaUpdated))
})

t.Run("does not affect BlockRun video channel types", func(t *testing.T) {
for _, channelType := range []int{constant.ChannelTypeBlockRunVideo, constant.ChannelTypeBlockRunSeedance} {
origin := blockRunChannelForValidation(t, channelType, dto.BlockRunPaymentChainBase, "", "", "")
updatedType := constant.ChannelTypeBlockRunVideo
if channelType == constant.ChannelTypeBlockRunVideo {
updatedType = constant.ChannelTypeBlockRunSeedance
}
updated := blockRunChannelForValidation(t, updatedType, dto.BlockRunPaymentChainSolana, "", "", "")
require.NoError(t, validateBlockRunPaymentChainTransition(origin, updated))
}
})

t.Run("rejects changing Type 100 to Type 101", func(t *testing.T) {
origin := blockRunChannelForValidation(t, constant.ChannelTypeBlockRun, dto.BlockRunPaymentChainBase, "", "base-key", "")
updated := blockRunChannelForValidation(t, constant.ChannelTypeBlockRunVideo, dto.BlockRunPaymentChainBase, "", "base-key", "")
require.ErrorContains(t, validateBlockRunPaymentChainTransition(origin, updated), "cannot change type into or out of BlockRun")
})

t.Run("rejects changing Type 101 to Type 100", func(t *testing.T) {
origin := blockRunChannelForValidation(t, constant.ChannelTypeBlockRunVideo, dto.BlockRunPaymentChainBase, "", "base-key", "")
updated := blockRunChannelForValidation(t, constant.ChannelTypeBlockRun, dto.BlockRunPaymentChainSolana, blockRunSolanaBaseURL, validSolanaSeed, "1000000")
require.ErrorContains(t, validateBlockRunPaymentChainTransition(origin, updated), "cannot change type into or out of BlockRun")
})
}
Loading