diff --git a/constant/context_key.go b/constant/context_key.go index e4e90ac86fc..9aca4682dc1 100644 --- a/constant/context_key.go +++ b/constant/context_key.go @@ -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. diff --git a/controller/channel.go b/controller/channel.go index 55b623d1ad0..71eef7ad89c 100644 --- a/controller/channel.go +++ b/controller/channel.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "math/big" "net/http" "strconv" "strings" @@ -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"` @@ -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 是否为空 @@ -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 { @@ -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(), @@ -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"` diff --git a/controller/channel_blockrun_payment_validation_test.go b/controller/channel_blockrun_payment_validation_test.go new file mode 100644 index 00000000000..a05a65cd354 --- /dev/null +++ b/controller/channel_blockrun_payment_validation_test.go @@ -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") + }) +} diff --git a/controller/relay.go b/controller/relay.go index 709dd7be201..ecbbf0968c6 100644 --- a/controller/relay.go +++ b/controller/relay.go @@ -226,6 +226,7 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) { default: newAPIError = relayHandler(c, relayInfo) } + newAPIError = normalizeBlockRunPaymentError(c, newAPIError) releaseChannelConcurrencyForRequest(c) perfmetrics.RecordChannelAttempt(relayInfo, channel.Id, channel.Name, attemptStartedAt, newAPIError) @@ -397,6 +398,9 @@ func getChannel(c *gin.Context, info *relaycommon.RelayInfo, retryParam *service } func shouldRetry(c *gin.Context, openaiErr *types.NewAPIError, retryTimes int) bool { + if state, ok := relaycommon.GetBlockRunPaymentState(c); ok && state.Attempted { + return false + } if openaiErr == nil { return false } @@ -433,7 +437,8 @@ func shouldRetry(c *gin.Context, openaiErr *types.NewAPIError, retryTimes int) b func processChannelError(c *gin.Context, channelError types.ChannelError, err *types.NewAPIError) { logger.LogError(c, fmt.Sprintf("channel error (channel #%d, status code: %d): %s", channelError.ChannelId, err.StatusCode, common.LocalLogPreview(err.Error()))) - if shouldMarkChannelConcurrencyCooldown(err) { + applyPenalty := shouldApplyChannelPenalty(err) + if applyPenalty && shouldMarkChannelConcurrencyCooldown(err) { cooldownCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() if cooldownErr := service.MarkChannelConcurrencyCooldown(cooldownCtx, channelError.ChannelId, 0, err.ErrorWithStatusCode()); cooldownErr != nil { @@ -442,7 +447,7 @@ func processChannelError(c *gin.Context, channelError types.ChannelError, err *t } // 不要使用context获取渠道信息,异步处理时可能会出现渠道信息不一致的情况 // do not use context to get channel info, there may be inconsistent channel info when processing asynchronously - if service.ShouldDisableChannel(err) && channelError.AutoBan { + if applyPenalty && service.ShouldDisableChannel(err) && channelError.AutoBan { gopool.Go(func() { service.DisableChannel(channelError, err.ErrorWithStatusCode()) }) @@ -475,6 +480,9 @@ func processChannelError(c *gin.Context, channelError types.ChannelError, err *t adminInfo["multi_key_index"] = common.GetContextKeyInt(c, constant.ContextKeyChannelMultiKeyIndex) } service.AppendChannelAffinityAdminInfo(c, adminInfo) + if paymentState, ok := relaycommon.GetBlockRunPaymentState(c); ok { + adminInfo["blockrun_payment"] = *paymentState + } other["admin_info"] = adminInfo startTime := common.GetContextKeyTime(c, constant.ContextKeyRequestStartTime) if startTime.IsZero() { @@ -486,6 +494,50 @@ func processChannelError(c *gin.Context, channelError types.ChannelError, err *t } +func normalizeBlockRunPaymentError(c *gin.Context, err *types.NewAPIError) *types.NewAPIError { + state, ok := relaycommon.GetBlockRunPaymentState(c) + if !ok || !state.Attempted { + return err + } + if err == nil { + relaycommon.UpdateBlockRunPaymentOutcome(c, relaycommon.BlockRunPaymentOutcomeSucceeded, false) + return nil + } + streamTruncated := c != nil && c.Writer != nil && c.Writer.Written() + if state.Outcome == relaycommon.BlockRunPaymentOutcomeRejected || err.StatusCode == http.StatusPaymentRequired { + relaycommon.UpdateBlockRunPaymentOutcome(c, relaycommon.BlockRunPaymentOutcomeRejected, streamTruncated) + return types.NewErrorWithStatusCode( + errors.New("BlockRun payment was rejected after signing; this request was not retried"), + types.ErrorCodeBlockRunPaymentRejected, + http.StatusPaymentRequired, + types.ErrOptionWithSkipRetry(), + ) + } + relaycommon.UpdateBlockRunPaymentOutcome(c, relaycommon.BlockRunPaymentOutcomeSettlementUnknown, streamTruncated) + statusCode := err.StatusCode + if statusCode < 400 || statusCode > 599 { + statusCode = http.StatusBadGateway + } + return types.NewErrorWithStatusCode( + errors.New("BlockRun signed payment settlement is unknown and may have been charged; automatic retry is disabled, reconcile using the request ID"), + types.ErrorCodeBlockRunSettlementUnknown, + statusCode, + types.ErrOptionWithSkipRetry(), + ) +} + +func isBlockRunPaidError(err *types.NewAPIError) bool { + if err == nil { + return false + } + return err.GetErrorCode() == types.ErrorCodeBlockRunPaymentRejected || + err.GetErrorCode() == types.ErrorCodeBlockRunSettlementUnknown +} + +func shouldApplyChannelPenalty(err *types.NewAPIError) bool { + return !isBlockRunPaidError(err) +} + func shouldMarkChannelConcurrencyCooldown(err *types.NewAPIError) bool { if err == nil { return false diff --git a/controller/relay_blockrun_payment_test.go b/controller/relay_blockrun_payment_test.go new file mode 100644 index 00000000000..778720087a8 --- /dev/null +++ b/controller/relay_blockrun_payment_test.go @@ -0,0 +1,88 @@ +package controller + +import ( + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/QuantumNous/new-api/dto" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/types" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func newBlockRunPaymentTestContext() (*gin.Context, *httptest.ResponseRecorder) { + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil) + return ctx, recorder +} + +func TestShouldRetryStopsBaseFailoverAfterSignedPaymentAttempt(t *testing.T) { + ctx, _ := newBlockRunPaymentTestContext() + relaycommon.MarkBlockRunPaymentAttempt(ctx, dto.BlockRunPaymentChainBase, 101, "request=req-1") + + channelErr := types.NewError(errors.New("first Base channel failed"), types.ErrorCodeChannelResponseTimeExceeded) + require.False(t, shouldRetry(ctx, channelErr, 2)) +} + +func TestShouldRetryPreservesUnsignedChannelRetry(t *testing.T) { + ctx, _ := newBlockRunPaymentTestContext() + channelErr := types.NewError(errors.New("unsigned channel failed"), types.ErrorCodeChannelResponseTimeExceeded) + + require.True(t, shouldRetry(ctx, channelErr, 2)) +} + +func TestNormalizeBlockRunPaymentErrors(t *testing.T) { + t.Run("signed 402 is rejected", func(t *testing.T) { + ctx, _ := newBlockRunPaymentTestContext() + relaycommon.MarkBlockRunPaymentAttempt(ctx, dto.BlockRunPaymentChainSolana, 202, "request=req-2") + relaycommon.UpdateBlockRunPaymentOutcome(ctx, relaycommon.BlockRunPaymentOutcomeRejected, false) + + got := normalizeBlockRunPaymentError(ctx, types.NewError(errors.New("wrapped rejection"), types.ErrorCodeDoRequestFailed)) + require.Equal(t, types.ErrorCodeBlockRunPaymentRejected, got.GetErrorCode()) + require.Equal(t, http.StatusPaymentRequired, got.StatusCode) + require.True(t, types.IsSkipRetryError(got)) + }) + + t.Run("signed transport failure is settlement unknown", func(t *testing.T) { + ctx, _ := newBlockRunPaymentTestContext() + relaycommon.MarkBlockRunPaymentAttempt(ctx, dto.BlockRunPaymentChainBase, 203, "request=req-3") + + got := normalizeBlockRunPaymentError(ctx, types.NewError(errors.New("connection reset"), types.ErrorCodeDoRequestFailed)) + require.Equal(t, types.ErrorCodeBlockRunSettlementUnknown, got.GetErrorCode()) + require.True(t, types.IsSkipRetryError(got)) + state, ok := relaycommon.GetBlockRunPaymentState(ctx) + require.True(t, ok) + require.Equal(t, relaycommon.BlockRunPaymentOutcomeSettlementUnknown, state.Outcome) + }) + + t.Run("written stream records truncation", func(t *testing.T) { + ctx, recorder := newBlockRunPaymentTestContext() + relaycommon.MarkBlockRunPaymentAttempt(ctx, dto.BlockRunPaymentChainSolana, 204, "request=req-4") + ctx.String(http.StatusOK, "partial") + + got := normalizeBlockRunPaymentError(ctx, types.NewError(errors.New("unexpected EOF"), types.ErrorCodeReadResponseBodyFailed)) + require.Equal(t, types.ErrorCodeBlockRunSettlementUnknown, got.GetErrorCode()) + require.Equal(t, "partial", recorder.Body.String()) + state, ok := relaycommon.GetBlockRunPaymentState(ctx) + require.True(t, ok) + require.True(t, state.StreamTruncated) + }) +} + +func TestPaidBlockRunErrorsNeverApplyChannelPenalty(t *testing.T) { + for _, code := range []types.ErrorCode{ + types.ErrorCodeBlockRunPaymentRejected, + types.ErrorCodeBlockRunSettlementUnknown, + } { + err := types.NewErrorWithStatusCode(errors.New("paid failure"), code, http.StatusTooManyRequests) + require.False(t, shouldApplyChannelPenalty(err), code) + } + + unsigned := types.NewErrorWithStatusCode(errors.New("rate limited"), types.ErrorCodeDoRequestFailed, http.StatusTooManyRequests) + require.True(t, shouldApplyChannelPenalty(unsigned)) + require.True(t, shouldMarkChannelConcurrencyCooldown(unsigned)) +} diff --git a/dto/channel_settings.go b/dto/channel_settings.go index 88a5889ed0e..8d54e6c6b50 100644 --- a/dto/channel_settings.go +++ b/dto/channel_settings.go @@ -35,24 +35,40 @@ const ( AwsKeyTypeApiKey AwsKeyType = "api_key" ) +type BlockRunPaymentChain string + +const ( + BlockRunPaymentChainBase BlockRunPaymentChain = "base" + BlockRunPaymentChainSolana BlockRunPaymentChain = "solana" +) + type ChannelOtherSettings struct { - AzureResponsesVersion string `json:"azure_responses_version,omitempty"` - VertexKeyType VertexKeyType `json:"vertex_key_type,omitempty"` // "json" or "api_key" - OpenRouterEnterprise *bool `json:"openrouter_enterprise,omitempty"` - ClaudeBetaQuery bool `json:"claude_beta_query,omitempty"` // Claude 渠道是否强制追加 ?beta=true - AllowServiceTier bool `json:"allow_service_tier,omitempty"` // 是否允许 service_tier 透传(默认过滤以避免额外计费) - AllowInferenceGeo bool `json:"allow_inference_geo,omitempty"` // 是否允许 inference_geo 透传(仅 Claude,默认过滤以满足数据驻留合规 - AllowSpeed bool `json:"allow_speed,omitempty"` // 是否允许 speed 透传(仅 Claude,默认过滤以避免意外切换推理速度模式) - AllowSafetyIdentifier bool `json:"allow_safety_identifier,omitempty"` // 是否允许 safety_identifier 透传(默认过滤以保护用户隐私) - DisableStore bool `json:"disable_store,omitempty"` // 是否禁用 store 透传(默认允许透传,禁用后可能导致 Codex 无法使用) - AllowIncludeObfuscation bool `json:"allow_include_obfuscation,omitempty"` // 是否允许 stream_options.include_obfuscation 透传(默认过滤以避免关闭流混淆保护) - AwsKeyType AwsKeyType `json:"aws_key_type,omitempty"` - UpstreamModelUpdateCheckEnabled bool `json:"upstream_model_update_check_enabled,omitempty"` // 是否检测上游模型更新 - UpstreamModelUpdateAutoSyncEnabled bool `json:"upstream_model_update_auto_sync_enabled,omitempty"` // 是否自动同步上游模型更新 - UpstreamModelUpdateLastCheckTime int64 `json:"upstream_model_update_last_check_time,omitempty"` // 上次检测时间 - UpstreamModelUpdateLastDetectedModels []string `json:"upstream_model_update_last_detected_models,omitempty"` // 上次检测到的可加入模型 - UpstreamModelUpdateLastRemovedModels []string `json:"upstream_model_update_last_removed_models,omitempty"` // 上次检测到的可删除模型 - UpstreamModelUpdateIgnoredModels []string `json:"upstream_model_update_ignored_models,omitempty"` // 手动忽略的模型 + AzureResponsesVersion string `json:"azure_responses_version,omitempty"` + VertexKeyType VertexKeyType `json:"vertex_key_type,omitempty"` // "json" or "api_key" + OpenRouterEnterprise *bool `json:"openrouter_enterprise,omitempty"` + ClaudeBetaQuery bool `json:"claude_beta_query,omitempty"` // Claude 渠道是否强制追加 ?beta=true + AllowServiceTier bool `json:"allow_service_tier,omitempty"` // 是否允许 service_tier 透传(默认过滤以避免额外计费) + AllowInferenceGeo bool `json:"allow_inference_geo,omitempty"` // 是否允许 inference_geo 透传(仅 Claude,默认过滤以满足数据驻留合规 + AllowSpeed bool `json:"allow_speed,omitempty"` // 是否允许 speed 透传(仅 Claude,默认过滤以避免意外切换推理速度模式) + AllowSafetyIdentifier bool `json:"allow_safety_identifier,omitempty"` // 是否允许 safety_identifier 透传(默认过滤以保护用户隐私) + DisableStore bool `json:"disable_store,omitempty"` // 是否禁用 store 透传(默认允许透传,禁用后可能导致 Codex 无法使用) + AllowIncludeObfuscation bool `json:"allow_include_obfuscation,omitempty"` // 是否允许 stream_options.include_obfuscation 透传(默认过滤以避免关闭流混淆保护) + AwsKeyType AwsKeyType `json:"aws_key_type,omitempty"` + UpstreamModelUpdateCheckEnabled bool `json:"upstream_model_update_check_enabled,omitempty"` // 是否检测上游模型更新 + UpstreamModelUpdateAutoSyncEnabled bool `json:"upstream_model_update_auto_sync_enabled,omitempty"` // 是否自动同步上游模型更新 + UpstreamModelUpdateLastCheckTime int64 `json:"upstream_model_update_last_check_time,omitempty"` // 上次检测时间 + UpstreamModelUpdateLastDetectedModels []string `json:"upstream_model_update_last_detected_models,omitempty"` // 上次检测到的可加入模型 + UpstreamModelUpdateLastRemovedModels []string `json:"upstream_model_update_last_removed_models,omitempty"` // 上次检测到的可删除模型 + UpstreamModelUpdateIgnoredModels []string `json:"upstream_model_update_ignored_models,omitempty"` // 手动忽略的模型 + BlockRunPaymentChain BlockRunPaymentChain `json:"blockrun_payment_chain,omitempty"` + BlockRunMaxPaymentAtomic string `json:"blockrun_max_payment_atomic,omitempty"` +} + +func (s ChannelOtherSettings) GetBlockRunPaymentChain() BlockRunPaymentChain { + if s.BlockRunPaymentChain == "" { + return BlockRunPaymentChainBase + } + return s.BlockRunPaymentChain } func (s *ChannelOtherSettings) IsOpenRouterEnterprise() bool { diff --git a/dto/channel_settings_test.go b/dto/channel_settings_test.go index 34e6f8c12eb..1659585cc6b 100644 --- a/dto/channel_settings_test.go +++ b/dto/channel_settings_test.go @@ -1,31 +1,15 @@ package dto -import ( - "testing" - - "github.com/QuantumNous/new-api/common" -) - -func TestChannelSettingsReturnSourceURLJSON(t *testing.T) { - t.Run("enabled", func(t *testing.T) { - encoded, err := common.Marshal(ChannelSettings{ReturnSourceURL: true}) - if err != nil { - t.Fatalf("marshal channel settings: %v", err) - } - - if string(encoded) != `{"proxy":"","return_source_url":true}` { - t.Fatalf("encoded settings = %s", encoded) - } - }) - - t.Run("empty settings", func(t *testing.T) { - encoded, err := common.Marshal(ChannelSettings{}) - if err != nil { - t.Fatalf("marshal channel settings: %v", err) - } - - if string(encoded) != `{"proxy":""}` { - t.Fatalf("encoded settings = %s", encoded) - } - }) +import "testing" + +func TestChannelOtherSettingsBlockRunPaymentChainDefaultsToBase(t *testing.T) { + settings := ChannelOtherSettings{} + if got := settings.GetBlockRunPaymentChain(); got != BlockRunPaymentChainBase { + t.Fatalf("GetBlockRunPaymentChain() = %q, want %q", got, BlockRunPaymentChainBase) + } + + settings.BlockRunPaymentChain = BlockRunPaymentChainSolana + if got := settings.GetBlockRunPaymentChain(); got != BlockRunPaymentChainSolana { + t.Fatalf("GetBlockRunPaymentChain() = %q, want %q", got, BlockRunPaymentChainSolana) + } } diff --git a/go.mod b/go.mod index cb164b34aed..acc3b02eb16 100644 --- a/go.mod +++ b/go.mod @@ -64,9 +64,11 @@ require ( require ( cloud.google.com/go/compute/metadata v0.9.0 cloud.google.com/go/storage v1.64.0 - github.com/BlockRunAI/blockrun-llm-go v0.17.0 + github.com/BlockRunAI/blockrun-llm-go v0.19.5 github.com/alicebob/miniredis/v2 v2.38.0 github.com/ethereum/go-ethereum v1.14.12 + github.com/gagliardetto/solana-go v1.12.0 + github.com/mr-tron/base58 v1.3.0 github.com/phuslu/iploc v1.0.20260701 github.com/stripe/stripe-go/v86 v86.1.1 github.com/waffo-com/waffo-pancake-sdk-go v0.3.1 @@ -81,10 +83,12 @@ require ( cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/iam v1.11.0 // indirect cloud.google.com/go/monitoring v1.29.0 // indirect + filippo.io/edwards25519 v1.0.0-rc.1 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0 // indirect github.com/bits-and-blooms/bitset v1.13.0 // indirect + github.com/blendle/zapdriver v1.3.1 // indirect github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect github.com/consensys/bavard v0.1.13 // indirect github.com/consensys/gnark-crypto v0.12.1 // indirect @@ -95,7 +99,10 @@ require ( github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect github.com/ethereum/c-kzg-4844 v1.0.0 // indirect github.com/ethereum/go-verkle v0.1.1-0.20240829091221-dffa7562dbe9 // indirect + github.com/fatih/color v1.16.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/gagliardetto/binary v0.8.0 // indirect + github.com/gagliardetto/treeout v0.1.4 // indirect github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect @@ -103,11 +110,17 @@ require ( github.com/googleapis/enterprise-certificate-proxy v0.3.18 // indirect github.com/googleapis/gax-go/v2 v2.23.0 // indirect github.com/holiman/uint256 v1.3.1 // indirect + github.com/logrusorgru/aurora v2.0.3+incompatible // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mitchellh/go-testing-interface v1.14.1 // indirect github.com/mmcloughlin/addchain v0.4.0 // indirect + github.com/mostynb/zstdpool-freelist v0.0.0-20201229113212-927304c0c3b1 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect + github.com/streamingfast/logging v0.0.0-20230608130331-f22c91403091 // indirect github.com/supranational/blst v0.3.13 // indirect github.com/yuin/gopher-lua v1.1.1 // indirect + go.mongodb.org/mongo-driver v1.12.2 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/detectors/gcp v1.43.0 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0 // indirect @@ -117,7 +130,11 @@ require ( go.opentelemetry.io/otel/sdk v1.44.0 // indirect go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect go.opentelemetry.io/otel/trace v1.44.0 // indirect + go.uber.org/atomic v1.7.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.21.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect + golang.org/x/term v0.45.0 // indirect golang.org/x/time v0.15.0 // indirect google.golang.org/genproto v0.0.0-20260519071638-aa98bba5eb94 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7 // indirect diff --git a/go.sum b/go.sum index 4cf2aac79ce..3bcf568233a 100644 --- a/go.sum +++ b/go.sum @@ -20,8 +20,10 @@ cloud.google.com/go/storage v1.64.0 h1:KLpxI/oX9LxeRsNqn877d2WyeT3ryiEwnGt8pwcSP cloud.google.com/go/storage v1.64.0/go.mod h1:lWyAtwvDZHdL3k68WVKbESP6bmWaV23ZJJ/JEVw/ZaQ= cloud.google.com/go/trace v1.16.0 h1:GmQovzFc5F0CNfl0VLgL64aoTtu7xsM0YajW2GlG9+E= cloud.google.com/go/trace v1.16.0/go.mod h1:r+bdAn16dKLSV1G2D5v3e58IlQlizfxWrUfjx7kM7X0= -github.com/BlockRunAI/blockrun-llm-go v0.17.0 h1:Myd8E+OS6XiGTkzzBl9W2EVFe+fO3uNYnEJtGrRIjEA= -github.com/BlockRunAI/blockrun-llm-go v0.17.0/go.mod h1:di8carn7SxW3YaJ6jw2r3N91cMadiUWBNwq9V1pV6JE= +filippo.io/edwards25519 v1.0.0-rc.1 h1:m0VOOB23frXZvAOK44usCgLWvtsxIoMCTBGJZlpmGfU= +filippo.io/edwards25519 v1.0.0-rc.1/go.mod h1:N1IkdkCkiLB6tki+MYJoSx2JTY9NUlxZE7eHn5EwJns= +github.com/BlockRunAI/blockrun-llm-go v0.19.5 h1:lHztp6pGtZYZYmhwfPvxlRzB3Q8JRCv2rjdHUfBoA0A= +github.com/BlockRunAI/blockrun-llm-go v0.19.5/go.mod h1:aDi38xbZA4C4r8ysaOqKndVAcDCZBqjtu5AqmJXP61o= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/Calcium-Ion/go-epay v0.0.4 h1:C96M7WfRLadcIVscWzwLiYs8etI1wrDmtFMuK2zP22A= @@ -62,10 +64,14 @@ github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.4 h1:W6tKfa/s37faUnwJ7 github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.4/go.mod h1:BZ+9thH0QOTDUwE8KAv/ZwUzsNC7CSMJXj/wtnZMs5k= github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng= github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8= +github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= 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/bits-and-blooms/bitset v1.13.0 h1:bAQ9OPNFYbGHV6Nez0tmNI0RiEu7/hxlYJRUA0wFAVE= github.com/bits-and-blooms/bitset v1.13.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= +github.com/blendle/zapdriver v1.3.1 h1:C3dydBOWYRiOk+B8X9IVZ5IOe+7cl+tGOexN4QqHfpE= +github.com/blendle/zapdriver v1.3.1/go.mod h1:mdXfREi6u5MArG4j9fewC+FGnXaBR+T4Ox4J2u4eHCc= 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/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= @@ -121,6 +127,8 @@ github.com/ethereum/go-verkle v0.1.1-0.20240829091221-dffa7562dbe9 h1:8NfxH2iXvJ github.com/ethereum/go-verkle v0.1.1-0.20240829091221-dffa7562dbe9/go.mod h1:M3b90YRnzqKyyzBEWJGqj8Qff4IDeXnzFw0P9bFw3uk= github.com/expr-lang/expr v1.17.8 h1:W1loDTT+0PQf5YteHSTpju2qfUfNoBt4yw9+wOEU9VM= github.com/expr-lang/expr v1.17.8/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4= +github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= +github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= @@ -129,6 +137,12 @@ github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sa github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/gagliardetto/binary v0.8.0 h1:U9ahc45v9HW0d15LoN++vIXSJyqR/pWw8DDlhd7zvxg= +github.com/gagliardetto/binary v0.8.0/go.mod h1:2tfj51g5o9dnvsc+fL3Jxr22MuWzYXwx9wEoN0XQ7/c= +github.com/gagliardetto/solana-go v1.12.0 h1:rzsbilDPj6p+/DOPXBMLhwMZeBgeRuXjm5zQFCoXgsg= +github.com/gagliardetto/solana-go v1.12.0/go.mod h1:l/qqqIN6qJJPtxW/G1PF4JtcE3Zg2vD2EliZrr9Gn5k= +github.com/gagliardetto/treeout v0.1.4 h1:ozeYerrLCmCubo1TcIjFiOWTTGteOOHND1twdFpgwaw= +github.com/gagliardetto/treeout v0.1.4/go.mod h1:loUefvXTrlRG5rYmJmExNryyBRh8f89VZhmMOyCyqok= github.com/gin-contrib/cors v1.7.2 h1:oLDHxdg8W/XDoN/8zamqk/Drgt4oVZDvaV0YmvVICQw= github.com/gin-contrib/cors v1.7.2/go.mod h1:SUJVARKgQ40dmrzgXEVxj2m7Ig1v1qIboQkPDTQ9t2E= github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4= @@ -203,8 +217,10 @@ github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaW github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/golang/snappy v0.0.5-0.20220116011046-fa5810519dcb h1:PBC98N2aIaM3XXiurYmW7fx4GZkL8feAMVq7nEjURHk= github.com/golang/snappy v0.0.5-0.20220116011046-fa5810519dcb/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= @@ -267,6 +283,8 @@ github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwA github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/compress v1.11.4/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= +github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= 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= @@ -289,9 +307,14 @@ github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgx github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/logrusorgru/aurora v2.0.3+incompatible h1:tOpm7WcpBTn4fjmVfgpQq0EfczGlG91VSDkswnjF5A8= +github.com/logrusorgru/aurora v2.0.3+incompatible/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4= github.com/mattetti/audio v0.0.0-20180912171649-01576cde1f21/go.mod h1:LlQmBGkOuV/SKzEDXBPKauvN2UqCgzXO2XjecTGj40s= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= @@ -306,6 +329,8 @@ github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d h1:IL2tii4jXLdhCeQN69HN github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d/go.mod h1:SIpumAnUWSy0q9RzKD3pyH3g1t5vdawUAPcW5tQrUtI= github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985 h1:h8O1byDZ1uk6RUXMhj1QJU3VXFKXHDZxr4TXRPGeBa8= github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985/go.mod h1:uiPmbdUbdt1NkGApKl7htQjZ8S7XaGUAVulJUJ9v6q4= +github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU= +github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mmcloughlin/addchain v0.4.0 h1:SobOdjm2xLj1KkXN5/n0xTIWyZA2+s99UCY1iPfkHRY= @@ -317,6 +342,11 @@ 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 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc= +github.com/mostynb/zstdpool-freelist v0.0.0-20201229113212-927304c0c3b1 h1:mPMvm6X6tf4w8y7j9YIt6V9jfWhL6QlbEc7CCmeQlWk= +github.com/mostynb/zstdpool-freelist v0.0.0-20201229113212-927304c0c3b1/go.mod h1:ye2e/VUEtE2BHE+G/QcKkcLQVAEJoYRFj5VUOQatCRE= +github.com/mr-tron/base58 v1.3.0 h1:K6Y13R2h+dku0wOqKtecgRnBUBPrZzLZy5aIj8lCcJI= +github.com/mr-tron/base58 v1.3.0/go.mod h1:2BuubE67DCSWwVfx37JWNG8emOC0sHEU4/HpcYgCLX8= 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= @@ -339,6 +369,7 @@ github.com/pelletier/go-toml/v2 v2.2.1/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h github.com/phuslu/iploc v1.0.20260701 h1:HmkA8K3AcAw2Qwx1DYop8F3Gi/I/B1+oyFdA1tBBS4g= github.com/phuslu/iploc v1.0.20260701/go.mod h1:VZqAWoi2A80YPvfk1AizLGHavNIG9nhBC8d87D/SeVs= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= @@ -372,11 +403,14 @@ 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/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI= github.com/shirou/gopsutil v3.21.11+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= +github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= +github.com/streamingfast/logging v0.0.0-20230608130331-f22c91403091 h1:RN5mrigyirb8anBEtdjtHFIufXdacyTi6i4KBfeNXeo= +github.com/streamingfast/logging v0.0.0-20230608130331-f22c91403091/go.mod h1:VlduQ80JcGJSargkRU4Sg9Xo63wZD/l8A5NC/Uo1/uU= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -400,6 +434,8 @@ github.com/supranational/blst v0.3.13 h1:AYeSxdOMacwu7FBmpfloBz5pbFXDmJL33RuwnKt github.com/supranational/blst v0.3.13/go.mod h1:jZJtfjgudtNl4en1tzwPIV3KjUnQUvG3/j+w+fVonLw= github.com/tcolgate/mp3 v0.0.0-20170426193717-e79c5a46d300 h1:XQdibLKagjdevRB6vAjVY4qbSr8rQ610YzTkWcxzxSI= github.com/tcolgate/mp3 v0.0.0-20170426193717-e79c5a46d300/go.mod h1:FNa/dfN95vAYCNFrIKRrlRo+MBLbwmR9Asa5f2ljmBI= +github.com/test-go/testify v1.1.4 h1:Tf9lntrKUMHiXQ07qBScBTSA0dhYQlu83hswqelv1iE= +github.com/test-go/testify v1.1.4/go.mod h1:rH7cfJo/47vWGdi4GPj16x3/t1xGOj2YxzmNQzk2ghU= github.com/thanhpk/randstr v1.0.6 h1:psAOktJFD4vV9NEVb3qkhRSMvYh4ORRaj1+w/hn4B+o= github.com/thanhpk/randstr v1.0.6/go.mod h1:M/H2P1eNLZzlDwAzpkkkUvoyNNMbzRGhESZuEQk3r0U= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= @@ -433,14 +469,22 @@ github.com/waffo-com/waffo-pancake-sdk-go v0.3.1 h1:ngQSN/oVB35xTwFPLfg++bxPC+Sp github.com/waffo-com/waffo-pancake-sdk-go v0.3.1/go.mod h1:OB2MyFIQaefoPO0FV3J+yu9sDP8RVFQ+sbFsXqGuObc= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4= +github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yapingcat/gomedia v0.0.0-20240906162731-17feea57090c h1:xA2TJS9Hu/ivzaZIrDcwvpJ3Fnpsk5fDOJ4iSnL6J0w= github.com/yapingcat/gomedia v0.0.0-20240906162731-17feea57090c/go.mod h1:WSZ59bidJOO40JSJmLqlkBJrjZCtjbKKkygEMfzY/kc= +github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFiw= github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.mongodb.org/mongo-driver v1.12.2 h1:gbWY1bJkkmUB9jjZzcdhOL8O85N9H+Vvsf2yFN0RDws= +go.mongodb.org/mongo-driver v1.12.2/go.mod h1:/rGBTebI3XYboVmgz+Wv3Bcbl3aD0QF9zl6kDDw18rQ= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/detectors/gcp v1.43.0 h1:62yY3dT7/ShwOxzA0RsKRgshBmfElKI4d/Myu2OxDFU= @@ -463,37 +507,74 @@ go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRk go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= +go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= +go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= +go.uber.org/zap v1.21.0 h1:WefMeulhovoZ2sYXz7st6K0sLj7bBhpiFaud4r4zST8= +go.uber.org/zap v1.21.0/go.mod h1:wjWOCqI0f2ZZrJF/UufIOkiC8ii6tm1iqIsLo76RfJw= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/arch v0.21.0 h1:iTC9o7+wP6cPWpDWkivCvQFGAHDQ59SrSxsLPcnkArw= golang.org/x/arch v0.21.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE= golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -502,18 +583,30 @@ golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/api v0.290.0 h1:eMw0Xo+IfbbMlKmW7aHvpyQRv9RCXuWx/vs8AD+0x9A= diff --git a/relay/channel/api_request.go b/relay/channel/api_request.go index ff7f3d607a0..383c3d067f9 100644 --- a/relay/channel/api_request.go +++ b/relay/channel/api_request.go @@ -6,12 +6,14 @@ import ( "fmt" "io" "net/http" + "net/url" "regexp" "strings" "sync" "time" common2 "github.com/QuantumNous/new-api/common" + rootconstant "github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/logger" "github.com/QuantumNous/new-api/relay/common" "github.com/QuantumNous/new-api/relay/constant" @@ -95,6 +97,13 @@ var passthroughSkipHeaderNamesLower = map[string]struct{}{ "x-api-key": {}, "x-goog-api-key": {}, + // x402 payment routing and authorization headers must only be generated by + // the owning adaptor. Never copy them from client wildcard/regex passthrough. + "payment-signature": {}, + "x-payment": {}, + "x-blockrun-facilitator": {}, + "x-payer-wallet": {}, + // WebSocket handshake headers are generated by the client/dialer. "sec-websocket-key": {}, "sec-websocket-version": {}, @@ -503,6 +512,7 @@ func doRequest(c *gin.Context, req *http.Request, info *common.RelayInfo) (*http } else { client = service.GetHttpClient() } + client = clientForRelayRequest(client, req, info) var stopPinger context.CancelFunc if info.IsStream { @@ -540,6 +550,51 @@ func doRequest(c *gin.Context, req *http.Request, info *common.RelayInfo) (*http return resp, nil } +func clientForRelayRequest(client *http.Client, req *http.Request, info *common.RelayInfo) *http.Client { + if client == nil || req == nil || info == nil || info.ChannelMeta == nil || info.ChannelType != rootconstant.ChannelTypeBlockRun { + return client + } + clientCopy := *client + origin := req.URL + signed := req.Header.Get("Payment-Signature") != "" || req.Header.Get("X-Payment") != "" + originalCheckRedirect := client.CheckRedirect + clientCopy.CheckRedirect = func(next *http.Request, via []*http.Request) error { + if signed || !sameHTTPOrigin(origin, next.URL) { + return http.ErrUseLastResponse + } + if originalCheckRedirect != nil { + return originalCheckRedirect(next, via) + } + if len(via) >= 10 { + return errors.New("stopped after 10 redirects") + } + return nil + } + return &clientCopy +} + +func sameHTTPOrigin(left, right *url.URL) bool { + if left == nil || right == nil { + return false + } + return strings.EqualFold(left.Scheme, right.Scheme) && + strings.EqualFold(left.Hostname(), right.Hostname()) && + effectiveHTTPPort(left) == effectiveHTTPPort(right) +} + +func effectiveHTTPPort(value *url.URL) string { + if port := value.Port(); port != "" { + return port + } + if strings.EqualFold(value.Scheme, "http") { + return "80" + } + if strings.EqualFold(value.Scheme, "https") { + return "443" + } + return "" +} + func DoTaskApiRequest(a TaskAdaptor, c *gin.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) { fullRequestURL, err := a.BuildRequestURL(info) if err != nil { diff --git a/relay/channel/api_request_test.go b/relay/channel/api_request_test.go index e9640029fce..9e3e946fbc4 100644 --- a/relay/channel/api_request_test.go +++ b/relay/channel/api_request_test.go @@ -2,12 +2,16 @@ package channel import ( "context" + "errors" "io" "net/http" "net/http/httptest" + "strconv" "strings" + "sync/atomic" "testing" + rootconstant "github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/dto" relaycommon "github.com/QuantumNous/new-api/relay/common" "github.com/QuantumNous/new-api/service" @@ -144,6 +148,128 @@ func TestProcessHeaderOverride_PassthroughSkipsAcceptEncoding(t *testing.T) { require.False(t, hasAcceptEncoding) } +func TestProcessHeaderOverride_PassthroughSkipsPaymentHeaders(t *testing.T) { + gin.SetMode(gin.TestMode) + protected := []string{"Payment-Signature", "X-Payment", "X-Blockrun-Facilitator", "X-Payer-Wallet"} + for _, rule := range []string{"*", `regex:^(?i:payment-signature|x-payment|x-blockrun-facilitator|x-payer-wallet|x-trace-id)$`} { + t.Run(rule, func(t *testing.T) { + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil) + for _, name := range protected { + ctx.Request.Header.Set(name, "client-controlled") + } + ctx.Request.Header.Set("X-Trace-Id", "trace-123") + info := &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{HeadersOverride: map[string]any{rule: ""}}} + + headers, err := processHeaderOverride(info, ctx) + require.NoError(t, err) + require.Equal(t, "trace-123", headers["x-trace-id"]) + for _, name := range protected { + require.NotContains(t, headers, strings.ToLower(name)) + } + }) + } +} + +func TestProcessHeaderOverride_ExplicitPaymentHeaderRemainsAvailableOutsideType100(t *testing.T) { + gin.SetMode(gin.TestMode) + ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) + ctx.Request = httptest.NewRequest(http.MethodPost, "/v1/videos", nil) + info := &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{ + ChannelType: rootconstant.ChannelTypeBlockRunSeedance, + HeadersOverride: map[string]any{"X-Payer-Wallet": "operator-value"}, + }} + headers, err := processHeaderOverride(info, ctx) + require.NoError(t, err) + require.Equal(t, "operator-value", headers["x-payer-wallet"]) +} + +func TestDoRequest_BlockRunRedirectPolicyIsRequestScoped(t *testing.T) { + service.InitHttpClient() + t.Cleanup(service.ResetProxyClientCache) + + var destinationHits atomic.Int32 + destination := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + destinationHits.Add(1) + w.WriteHeader(http.StatusNoContent) + })) + defer destination.Close() + + var sameOriginHits atomic.Int32 + var source *httptest.Server + source = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/same-origin" { + sameOriginHits.Add(1) + w.WriteHeader(http.StatusNoContent) + return + } + switch r.URL.Path { + case "/redirect-same": + status := http.StatusFound + if raw := r.URL.Query().Get("status"); raw != "" { + status, _ = strconv.Atoi(raw) + } + http.Redirect(w, r, source.URL+"/same-origin", status) + case "/redirect-cross": + http.Redirect(w, r, destination.URL, http.StatusFound) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer source.Close() + + request := func(path string, channelType int, signed bool) *http.Response { + t.Helper() + ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) + ctx.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil) + req, err := http.NewRequest(http.MethodPost, source.URL+path, strings.NewReader("{}")) + require.NoError(t, err) + if signed { + req.Header.Set("Payment-Signature", "signed-payload") + } + resp, err := doRequest(ctx, req, &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{ChannelType: channelType}}) + require.NoError(t, err) + t.Cleanup(func() { _ = resp.Body.Close() }) + return resp + } + + require.Equal(t, http.StatusFound, request("/redirect-cross", rootconstant.ChannelTypeBlockRun, false).StatusCode) + require.EqualValues(t, 0, destinationHits.Load(), "unsigned Type 100 must not follow cross-origin redirects") + for _, status := range []int{http.StatusMovedPermanently, http.StatusFound, http.StatusSeeOther, http.StatusTemporaryRedirect, http.StatusPermanentRedirect} { + require.Equal(t, status, request("/redirect-same?status="+strconv.Itoa(status), rootconstant.ChannelTypeBlockRun, true).StatusCode) + } + require.EqualValues(t, 0, sameOriginHits.Load(), "signed Type 100 must not follow any redirect") + baseClient := &http.Client{} + nonBlockRunReq, err := http.NewRequest(http.MethodPost, source.URL+"/redirect-cross", nil) + require.NoError(t, err) + nonBlockRun := clientForRelayRequest(baseClient, nonBlockRunReq, &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{ChannelType: rootconstant.ChannelTypeOpenAI}}) + require.Same(t, baseClient, nonBlockRun, "non-Type 100 must keep the shared client and redirect behavior") +} + +func TestClientForRelayRequest_BlockRunPreservesRedirectPolicy(t *testing.T) { + req, err := http.NewRequest(http.MethodPost, "https://blockrun.example/v1/chat/completions", nil) + require.NoError(t, err) + next, err := http.NewRequest(http.MethodGet, "https://blockrun.example/redirected", nil) + require.NoError(t, err) + info := &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{ChannelType: rootconstant.ChannelTypeBlockRun}} + + var hookCalls atomic.Int32 + wantErr := errors.New("custom redirect rejected") + client := &http.Client{CheckRedirect: func(_ *http.Request, _ []*http.Request) error { + hookCalls.Add(1) + return wantErr + }} + redirectClient := clientForRelayRequest(client, req, info) + require.ErrorIs(t, redirectClient.CheckRedirect(next, []*http.Request{req}), wantErr) + require.EqualValues(t, 1, hookCalls.Load()) + + defaultClient := clientForRelayRequest(&http.Client{}, req, info) + require.NoError(t, defaultClient.CheckRedirect(next, []*http.Request{req})) + via := make([]*http.Request, 10) + require.EqualError(t, defaultClient.CheckRedirect(next, via), "stopped after 10 redirects") +} + func TestProcessHeaderOverride_PassHeadersTemplateSetsRuntimeHeaders(t *testing.T) { t.Parallel() diff --git a/relay/channel/blockrun/adaptor.go b/relay/channel/blockrun/adaptor.go index ad1bedf3d57..c19e7e58896 100644 --- a/relay/channel/blockrun/adaptor.go +++ b/relay/channel/blockrun/adaptor.go @@ -43,12 +43,17 @@ package blockrun import ( + "crypto/sha256" "errors" "fmt" "io" + "math/big" "net/http" "net/url" + "strings" + blockrunSDK "github.com/BlockRunAI/blockrun-llm-go" + common2 "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/dto" "github.com/QuantumNous/new-api/relay/channel" "github.com/QuantumNous/new-api/relay/channel/claude" @@ -57,6 +62,7 @@ import ( relayconstant "github.com/QuantumNous/new-api/relay/constant" "github.com/QuantumNous/new-api/types" + ethcrypto "github.com/ethereum/go-ethereum/crypto" "github.com/gin-gonic/gin" "github.com/tidwall/sjson" ) @@ -73,6 +79,19 @@ const ctxKeyPaymentSignature = "blockrun_payment_signature" // client did not supply an anthropic-version header. const defaultAnthropicVersion = "2023-06-01" +const ( + headerBlockRunFacilitator = "X-Blockrun-Facilitator" + headerPayerWallet = "X-Payer-Wallet" + blockRunFacilitator = "figment" +) + +var blockRunProtectedPaymentHeaders = map[string]struct{}{ + "payment-signature": {}, + "x-payment": {}, + "x-blockrun-facilitator": {}, + "x-payer-wallet": {}, +} + // Adaptor implements the channel.Adaptor interface for BlockRun as a VIP native // passthrough. It embeds BOTH the openai and claude adaptors and dispatches each // interface method by info.RelayFormat: Claude inbound is forwarded natively to @@ -97,6 +116,9 @@ func (a *Adaptor) Init(info *relaycommon.RelayInfo) { // passthrough: Anthropic → /v1/messages, OpenAI Chat → /v1/chat/completions, // Gemini rejected. func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { + if _, _, err := validateBlockRunPaymentConfig(info); err != nil { + return "", err + } switch info.RelayMode { case relayconstant.RelayModeImagesGenerations: return fmt.Sprintf("%s/v1/images/generations", info.ChannelBaseUrl), nil @@ -154,7 +176,18 @@ func shouldAppendClaudeBetaQuery(info *relaycommon.RelayInfo) bool { // those by default, which is exactly why we override here and do NOT delegate. // Authentication is the EIP-712 x402 signature, never a transmitted secret. func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error { + chain, payer, err := validateBlockRunPaymentConfig(info) + if err != nil { + return err + } + if err := rejectProtectedPaymentHeaderOverrides(info); err != nil { + return err + } channel.SetupApiRequestHeader(info, c, req) + if chain == dto.BlockRunPaymentChainSolana { + req.Set(headerBlockRunFacilitator, blockRunFacilitator) + req.Set(headerPayerWallet, payer) + } // Image legs always send a JSON body (generations passthrough / image2image), // so force application/json. channel.SetupApiRequestHeader copies the inbound @@ -285,6 +318,10 @@ func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommo // 4. If the retry STILL returns 402 the signature was rejected — surface a // clear error instead of looping (which would burn more USDC trying). func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) { + chain, payer, err := validateBlockRunPaymentConfig(info) + if err != nil { + return nil, err + } bodyBytes, err := cacheRequestBody(requestBody) if err != nil { return nil, err @@ -325,7 +362,10 @@ func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, request // window cap for them; chat and Responses keep the default 300s window. var paymentB64 string var signErr error - if info.RelayMode == relayconstant.RelayModeImagesGenerations || info.RelayMode == relayconstant.RelayModeImagesEdits { + if chain == dto.BlockRunPaymentChainSolana { + maxAmountAtomic, _ := new(big.Int).SetString(strings.TrimSpace(info.ChannelOtherSettings.BlockRunMaxPaymentAtomic), 10) + paymentB64, signErr = SignSolanaX402Payment(firstResp, info.ApiKey, fullURL, maxAmountAtomic) + } else if info.RelayMode == relayconstant.RelayModeImagesGenerations || info.RelayMode == relayconstant.RelayModeImagesEdits { paymentB64, signErr = SignX402PaymentWithCaps(firstResp, info.ApiKey, fullURL, nil, maxImageAuthorizationWindowSeconds) } else { paymentB64, signErr = SignX402Payment(firstResp, info.ApiKey, fullURL) @@ -337,21 +377,102 @@ func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, request c.Set(ctxKeyPaymentSignature, paymentB64) defer delete(c.Keys, ctxKeyPaymentSignature) + if chain == dto.BlockRunPaymentChainBase { + if privateKey, parseErr := parsePrivateKey(info.ApiKey); parseErr == nil { + payer = ethcrypto.PubkeyToAddress(privateKey.PublicKey).Hex() + } + } + relaycommon.MarkBlockRunPaymentAttempt(c, chain, info.ChannelId, blockRunPaymentReconciliation(c, payer, paymentB64)) retryResp, err := channel.DoApiRequest(a, c, info, bodyReader(bodyBytes)) if err != nil { return nil, err } if retryResp.StatusCode == http.StatusPaymentRequired { + relaycommon.UpdateBlockRunPaymentOutcome(c, relaycommon.BlockRunPaymentOutcomeRejected, false) // Signature was rejected (insufficient balance, replay, expired window, // payTo mismatch, …). Do NOT loop — every signed attempt risks an - // on-chain settle. Surface the upstream body to help operators debug. - body, _ := io.ReadAll(retryResp.Body) + // on-chain settle. Never surface the upstream body: it may echo the full + // payment signature and must not reach API errors or logs. + _, _ = io.CopyN(io.Discard, retryResp.Body, 512<<10) _ = retryResp.Body.Close() - return nil, fmt.Errorf("blockrun: payment signature rejected by upstream (status 402 after signing): %s", string(body)) + return nil, errors.New("blockrun: payment signature rejected by upstream (status 402 after signing)") } return resolveImageResult(c, info, retryResp, paymentB64) } +func blockRunPaymentReconciliation(c *gin.Context, payer, paymentPayload string) string { + payerHash := sha256.Sum256([]byte(payer)) + payloadHash := sha256.Sum256([]byte(paymentPayload)) + reconciliation := fmt.Sprintf("payer_sha256=%x;payload_sha256=%x", payerHash[:8], payloadHash[:8]) + if c != nil { + if requestID := c.GetString(common2.RequestIdKey); requestID != "" { + reconciliation += ";request_id=" + requestID + } + if upstreamRequestID := c.GetString(common2.UpstreamRequestIdKey); upstreamRequestID != "" { + reconciliation += ";upstream_request_id=" + upstreamRequestID + } + } + return reconciliation +} + +func validateBlockRunPaymentConfig(info *relaycommon.RelayInfo) (dto.BlockRunPaymentChain, string, error) { + if info == nil || info.ChannelMeta == nil { + return "", "", errors.New("blockrun: missing channel configuration") + } + chain := info.ChannelOtherSettings.GetBlockRunPaymentChain() + switch chain { + case dto.BlockRunPaymentChainBase: + return chain, "", nil + case dto.BlockRunPaymentChainSolana: + if !blockRunSolanaSupportsRequest(info) { + return "", "", errors.New("blockrun: Solana payment only supports /v1/chat/completions, /v1/messages, and /v1/responses") + } + if strings.TrimRight(strings.TrimSpace(info.ChannelBaseUrl), "/") != blockrunSDK.DefaultSolanaAPIURL { + return "", "", fmt.Errorf("blockrun: Solana base URL must be %s", blockrunSDK.DefaultSolanaAPIURL) + } + capAmount, ok := new(big.Int).SetString(strings.TrimSpace(info.ChannelOtherSettings.BlockRunMaxPaymentAtomic), 10) + if !ok || capAmount.Sign() <= 0 { + return "", "", errors.New("blockrun: Solana per-call payment cap must be configured as a positive integer") + } + payer, err := blockrunSDK.GetSolanaPublicKey(strings.TrimSpace(info.ApiKey)) + if err != nil { + return "", "", errors.New("blockrun: Solana wallet key is invalid") + } + return chain, payer, nil + default: + return "", "", fmt.Errorf("blockrun: unsupported payment chain %q", chain) + } +} + +func blockRunSolanaSupportsRequest(info *relaycommon.RelayInfo) bool { + if info == nil { + return false + } + switch info.RequestURLPath { + case "/v1/chat/completions": + return info.RelayMode == relayconstant.RelayModeChatCompletions && info.RelayFormat == types.RelayFormatOpenAI + case "/v1/messages": + return info.RelayMode == relayconstant.RelayModeChatCompletions && info.RelayFormat == types.RelayFormatClaude + case "/v1/responses": + return info.RelayMode == relayconstant.RelayModeResponses && + (info.RelayFormat == types.RelayFormatOpenAI || info.RelayFormat == types.RelayFormatOpenAIResponses) + default: + return false + } +} + +func rejectProtectedPaymentHeaderOverrides(info *relaycommon.RelayInfo) error { + for key := range relaycommon.GetEffectiveHeaderOverride(info) { + if channel.IsHeaderPassthroughRuleKey(key) { + continue + } + if _, protected := blockRunProtectedPaymentHeaders[strings.ToLower(strings.TrimSpace(key))]; protected { + return fmt.Errorf("blockrun: header override %q is reserved for x402 payment", key) + } + } + return nil +} + // removeBlockRunResponsesStreamOptions enforces the provider constraint at the // final outbound boundary, after channel parameter overrides have run. This is // intentionally Responses-only so BlockRun Chat keeps its existing behavior. diff --git a/relay/channel/blockrun/adaptor_solana_test.go b/relay/channel/blockrun/adaptor_solana_test.go new file mode 100644 index 00000000000..3886ee87804 --- /dev/null +++ b/relay/channel/blockrun/adaptor_solana_test.go @@ -0,0 +1,227 @@ +package blockrun + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + blockrunSDK "github.com/BlockRunAI/blockrun-llm-go" + common2 "github.com/QuantumNous/new-api/common" + rootconstant "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/dto" + relaycommon "github.com/QuantumNous/new-api/relay/common" + relayconstant "github.com/QuantumNous/new-api/relay/constant" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/types" +) + +func TestBlockRunSolanaSupportsRequest_FullAllowlist(t *testing.T) { + base := &relaycommon.RelayInfo{} + tests := []struct { + name string + path string + mode int + format types.RelayFormat + want bool + }{ + {name: "chat", path: "/v1/chat/completions", mode: relayconstant.RelayModeChatCompletions, format: types.RelayFormatOpenAI, want: true}, + {name: "messages", path: "/v1/messages", mode: relayconstant.RelayModeChatCompletions, format: types.RelayFormatClaude, want: true}, + {name: "responses native format", path: "/v1/responses", mode: relayconstant.RelayModeResponses, format: types.RelayFormatOpenAIResponses, want: true}, + {name: "responses handler format", path: "/v1/responses", mode: relayconstant.RelayModeResponses, format: types.RelayFormatOpenAI, want: true}, + {name: "chat wrong format", path: "/v1/chat/completions", mode: relayconstant.RelayModeChatCompletions, format: types.RelayFormatClaude}, + {name: "messages wrong mode", path: "/v1/messages", mode: relayconstant.RelayModeCompletions, format: types.RelayFormatClaude}, + {name: "completions", path: "/v1/completions", mode: relayconstant.RelayModeCompletions, format: types.RelayFormatOpenAI}, + {name: "embeddings", path: "/v1/embeddings", mode: relayconstant.RelayModeEmbeddings, format: types.RelayFormatEmbedding}, + {name: "moderations", path: "/v1/moderations", mode: relayconstant.RelayModeModerations, format: types.RelayFormatOpenAI}, + {name: "images generations", path: "/v1/images/generations", mode: relayconstant.RelayModeImagesGenerations, format: types.RelayFormatOpenAIImage}, + {name: "images edits", path: "/v1/images/edits", mode: relayconstant.RelayModeImagesEdits, format: types.RelayFormatOpenAIImage}, + {name: "audio", path: "/v1/audio/speech", mode: relayconstant.RelayModeAudioSpeech, format: types.RelayFormatOpenAIAudio}, + {name: "rerank", path: "/v1/rerank", mode: relayconstant.RelayModeRerank, format: types.RelayFormatRerank}, + {name: "realtime", path: "/v1/realtime", mode: relayconstant.RelayModeRealtime, format: types.RelayFormatOpenAIRealtime}, + {name: "responses compact", path: "/v1/responses/compact", mode: relayconstant.RelayModeResponsesCompact, format: types.RelayFormatOpenAIResponsesCompaction}, + {name: "gemini", path: "/v1beta/models/gemini:generateContent", mode: relayconstant.RelayModeGemini, format: types.RelayFormatGemini}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + *base = relaycommon.RelayInfo{RequestURLPath: tt.path, RelayMode: tt.mode, RelayFormat: tt.format} + if got := blockRunSolanaSupportsRequest(base); got != tt.want { + t.Fatalf("blockRunSolanaSupportsRequest() = %t, want %t", got, tt.want) + } + }) + } +} + +func TestGetRequestURL_SolanaRevalidatesConfigAndAllowedEndpoints(t *testing.T) { + key, _, _, _ := solanaTestKeys() + adaptor := &Adaptor{} + allowed := []struct { + path string + mode int + format types.RelayFormat + }{ + {path: "/v1/chat/completions", mode: relayconstant.RelayModeChatCompletions, format: types.RelayFormatOpenAI}, + {path: "/v1/messages", mode: relayconstant.RelayModeChatCompletions, format: types.RelayFormatClaude}, + {path: "/v1/responses", mode: relayconstant.RelayModeResponses, format: types.RelayFormatOpenAIResponses}, + } + for _, endpoint := range allowed { + info := solanaRequestInfo(key, endpoint.path, endpoint.mode, endpoint.format) + got, err := adaptor.GetRequestURL(info) + if err != nil || got != blockrunSDK.DefaultSolanaAPIURL+endpoint.path { + t.Fatalf("GetRequestURL(%s) = %q, %v", endpoint.path, got, err) + } + } + + valid := solanaRequestInfo(key, "/v1/chat/completions", relayconstant.RelayModeChatCompletions, types.RelayFormatOpenAI) + tests := []struct { + name string + mutate func(*relaycommon.RelayInfo) + want string + }{ + {name: "empty URL", mutate: func(info *relaycommon.RelayInfo) { info.ChannelBaseUrl = "" }, want: "base URL"}, + {name: "non-official URL", mutate: func(info *relaycommon.RelayInfo) { info.ChannelBaseUrl = "https://example.com" }, want: "base URL"}, + {name: "missing cap", mutate: func(info *relaycommon.RelayInfo) { info.ChannelOtherSettings.BlockRunMaxPaymentAtomic = "" }, want: "cap"}, + {name: "zero cap", mutate: func(info *relaycommon.RelayInfo) { info.ChannelOtherSettings.BlockRunMaxPaymentAtomic = "0" }, want: "cap"}, + {name: "malformed cap", mutate: func(info *relaycommon.RelayInfo) { info.ChannelOtherSettings.BlockRunMaxPaymentAtomic = "1.5" }, want: "cap"}, + {name: "invalid key", mutate: func(info *relaycommon.RelayInfo) { info.ApiKey = "not-base58" }, want: "wallet key"}, + {name: "unknown chain", mutate: func(info *relaycommon.RelayInfo) { info.ChannelOtherSettings.BlockRunPaymentChain = "polygon" }, want: "unsupported payment chain"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + copyInfo := *valid + meta := *valid.ChannelMeta + copyInfo.ChannelMeta = &meta + tt.mutate(©Info) + if _, err := adaptor.GetRequestURL(©Info); err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("error = %v, want %q", err, tt.want) + } + }) + } + + base := &relaycommon.RelayInfo{RelayMode: relayconstant.RelayModeChatCompletions, RelayFormat: types.RelayFormatOpenAI, RequestURLPath: "/v1/chat/completions", ChannelMeta: &relaycommon.ChannelMeta{ChannelBaseUrl: "https://blockrun.ai/api"}} + if got, err := adaptor.GetRequestURL(base); err != nil || got != "https://blockrun.ai/api/v1/chat/completions" { + t.Fatalf("legacy Base default changed: url=%q err=%v", got, err) + } +} + +func TestSetupRequestHeader_SolanaAddsRoutingHeadersAndRejectsProtectedOverride(t *testing.T) { + key, _, _, _ := solanaTestKeys() + payer, err := blockrunSDK.GetSolanaPublicKey(key) + if err != nil { + t.Fatal(err) + } + info := solanaRequestInfo(key, "/v1/chat/completions", relayconstant.RelayModeChatCompletions, types.RelayFormatOpenAI) + req := &http.Header{} + if err := (&Adaptor{}).SetupRequestHeader(blockRunRequestContext(), req, info); err != nil { + t.Fatal(err) + } + if got := req.Get(headerBlockRunFacilitator); got != blockRunFacilitator { + t.Fatalf("facilitator = %q", got) + } + if got := req.Get(headerPayerWallet); got != payer { + t.Fatalf("payer = %q, want %q", got, payer) + } + + for _, name := range []string{"Payment-Signature", "X-Payment", "X-Blockrun-Facilitator", "X-Payer-Wallet"} { + t.Run(name, func(t *testing.T) { + copyInfo := *info + meta := *info.ChannelMeta + meta.HeadersOverride = map[string]any{name: "operator-controlled"} + copyInfo.ChannelMeta = &meta + if err := (&Adaptor{}).SetupRequestHeader(blockRunRequestContext(), &http.Header{}, ©Info); err == nil || !strings.Contains(err.Error(), "reserved") { + t.Fatalf("expected protected override rejection, got %v", err) + } + }) + } + + base := &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{ChannelBaseUrl: "https://blockrun.ai/api", HeadersOverride: map[string]any{"X-Payer-Wallet": "legacy"}}} + if err := (&Adaptor{}).SetupRequestHeader(blockRunRequestContext(), &http.Header{}, base); err == nil { + t.Fatal("Type 100 Base protected override must also fail before payment") + } + base.ChannelMeta.HeadersOverride = nil + baseHeaders := &http.Header{} + if err := (&Adaptor{}).SetupRequestHeader(blockRunRequestContext(), baseHeaders, base); err != nil { + t.Fatal(err) + } + if baseHeaders.Get(headerBlockRunFacilitator) != "" || baseHeaders.Get(headerPayerWallet) != "" { + t.Fatalf("Base request received Solana headers: %#v", baseHeaders) + } +} + +func TestBlockRunDoRequest_MarksAttemptBeforeSignedTransportError(t *testing.T) { + service.InitHttpClient() + var attempts int + baseURL := "http://blockrun.test" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + attempts++ + if attempts == 1 { + w.Header().Set(headerPaymentRequired, paymentRequiredHeader(t, baseURL+"/v1/responses")) + w.WriteHeader(http.StatusPaymentRequired) + return + } + hijacker, ok := w.(http.Hijacker) + if !ok { + t.Fatal("response writer does not support hijacking") + } + conn, _, err := hijacker.Hijack() + if err != nil { + t.Fatal(err) + } + _ = conn.Close() + })) + defer server.Close() + t.Cleanup(service.ResetProxyClientCache) + + ctx := blockRunRequestContext() + ctx.Set(common2.RequestIdKey, "request-safe-id") + info := blockRunResponsesRequestInfo(baseURL, server.URL) + info.ChannelType = rootconstant.ChannelTypeBlockRun + info.ChannelId = 42 + _, err := (&Adaptor{}).DoRequest(ctx, info, strings.NewReader(`{"model":"test","input":"ping"}`)) + if err == nil { + t.Fatal("expected signed transport error") + } + state, ok := relaycommon.GetBlockRunPaymentState(ctx) + if !ok || !state.Attempted || state.Outcome != relaycommon.BlockRunPaymentOutcomeSigned { + t.Fatalf("payment state was not marked before transport error: %#v", state) + } + if state.Chain != dto.BlockRunPaymentChainBase || state.ChannelID != 42 { + t.Fatalf("unexpected payment state: %#v", state) + } + if strings.Contains(state.Reconciliation, fakeWalletKey) || !strings.Contains(state.Reconciliation, "payload_sha256=") || !strings.Contains(state.Reconciliation, "request_id=request-safe-id") { + t.Fatalf("unsafe or incomplete reconciliation: %q", state.Reconciliation) + } + if attempts != 2 { + t.Fatalf("attempts = %d, want unsigned + one signed attempt", attempts) + } +} + +func TestBlockRunDoRequest_SolanaRejectsUnsupportedBeforeUnsignedRequest(t *testing.T) { + key, _, _, _ := solanaTestKeys() + var hits int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { hits++ })) + defer server.Close() + info := solanaRequestInfo(key, "/v1/images/generations", relayconstant.RelayModeImagesGenerations, types.RelayFormatOpenAIImage) + info.ChannelSetting.Proxy = server.URL + _, err := (&Adaptor{}).DoRequest(blockRunRequestContext(), info, strings.NewReader(`{}`)) + if err == nil || hits != 0 { + t.Fatalf("unsupported Solana request must fail before upstream: hits=%d err=%v", hits, err) + } +} + +func solanaRequestInfo(key, path string, mode int, format types.RelayFormat) *relaycommon.RelayInfo { + return &relaycommon.RelayInfo{ + RelayMode: mode, + RelayFormat: format, + RequestURLPath: path, + ChannelMeta: &relaycommon.ChannelMeta{ + ChannelType: rootconstant.ChannelTypeBlockRun, + ChannelBaseUrl: blockrunSDK.DefaultSolanaAPIURL, + ApiKey: key, + ChannelOtherSettings: dto.ChannelOtherSettings{ + BlockRunPaymentChain: dto.BlockRunPaymentChainSolana, + BlockRunMaxPaymentAtomic: "1000", + }, + }, + } +} diff --git a/relay/channel/blockrun/base_payload_compat.go b/relay/channel/blockrun/base_payload_compat.go new file mode 100644 index 00000000000..ba288a2a897 --- /dev/null +++ b/relay/channel/blockrun/base_payload_compat.go @@ -0,0 +1,56 @@ +package blockrun + +import ( + "crypto/ecdsa" + "encoding/base64" + "fmt" + + blockrunSDK "github.com/BlockRunAI/blockrun-llm-go" + "github.com/QuantumNous/new-api/common" +) + +// CreateBasePaymentPayloadCompat delegates all signing to the BlockRun SDK, +// then restores the caller-supplied extensions. SDK v0.19.5 adds an unsigned +// builder-code extension; removing only that SDK-added envelope field preserves +// the Base wire semantics used by existing Type 100 and Type 102 channels. +func CreateBasePaymentPayloadCompat( + privateKey *ecdsa.PrivateKey, + recipient string, + amount string, + network string, + resourceURL string, + resourceDescription string, + maxTimeoutSeconds int, + extra map[string]any, + extensions map[string]any, +) (string, error) { + paymentB64, err := blockrunSDK.CreatePaymentPayload( + privateKey, + recipient, + amount, + network, + resourceURL, + resourceDescription, + maxTimeoutSeconds, + extra, + extensions, + ) + if err != nil { + return "", err + } + + paymentJSON, err := base64.StdEncoding.DecodeString(paymentB64) + if err != nil { + return "", fmt.Errorf("blockrun: decode Base x402 payload: %w", err) + } + var payload blockrunSDK.PaymentPayload + if err := common.Unmarshal(paymentJSON, &payload); err != nil { + return "", fmt.Errorf("blockrun: parse Base x402 payload: %w", err) + } + payload.Extensions = extensions + paymentJSON, err = common.Marshal(payload) + if err != nil { + return "", fmt.Errorf("blockrun: encode Base x402 payload: %w", err) + } + return base64.StdEncoding.EncodeToString(paymentJSON), nil +} diff --git a/relay/channel/blockrun/base_payload_compat_test.go b/relay/channel/blockrun/base_payload_compat_test.go new file mode 100644 index 00000000000..0b02a32349e --- /dev/null +++ b/relay/channel/blockrun/base_payload_compat_test.go @@ -0,0 +1,106 @@ +package blockrun + +import ( + "encoding/base64" + "reflect" + "testing" + + blockrunSDK "github.com/BlockRunAI/blockrun-llm-go" + "github.com/QuantumNous/new-api/common" + ethcrypto "github.com/ethereum/go-ethereum/crypto" +) + +func TestCreateBasePaymentPayloadCompatPreservesExtensions(t *testing.T) { + privateKey, err := ethcrypto.HexToECDSA("4f3edf983ac636a65a842ce7c78d9aa706d3b113bce036f4e9d7f86f79bf5b84") + if err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + extensions map[string]any + }{ + {name: "nil", extensions: nil}, + {name: "empty", extensions: map[string]any{}}, + { + name: "existing builder code", + extensions: map[string]any{ + "builder-code": map[string]any{"info": map[string]any{"a": []any{"application"}}}, + }, + }, + { + name: "arbitrary extension", + extensions: map[string]any{"custom": map[string]any{"enabled": true, "label": "kept"}}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + encoded, err := CreateBasePaymentPayloadCompat( + privateKey, + "0xe9030014F5DAe217d0A152f02A043567b16c1aBf", + "1000", + expectedNetworkBase, + "https://blockrun.ai/api/v1/chat/completions", + "test", + 300, + map[string]any{"name": "USD Coin", "version": "2"}, + tt.extensions, + ) + if err != nil { + t.Fatalf("CreateBasePaymentPayloadCompat: %v", err) + } + + decoded, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + t.Fatalf("decode payload: %v", err) + } + var payload blockrunSDK.PaymentPayload + if err := common.Unmarshal(decoded, &payload); err != nil { + t.Fatalf("unmarshal payload: %v", err) + } + + if len(tt.extensions) == 0 { + if payload.Extensions != nil { + t.Fatalf("empty extensions should retain v0.17 omitted-field semantics, got %#v", payload.Extensions) + } + return + } + if !reflect.DeepEqual(payload.Extensions, tt.extensions) { + t.Fatalf("extensions changed:\n got: %#v\nwant: %#v", payload.Extensions, tt.extensions) + } + }) + } +} + +func TestCreateBasePaymentPayloadCompatRemovesSDKBuilderCode(t *testing.T) { + privateKey, err := ethcrypto.HexToECDSA("4f3edf983ac636a65a842ce7c78d9aa706d3b113bce036f4e9d7f86f79bf5b84") + if err != nil { + t.Fatal(err) + } + encoded, err := CreateBasePaymentPayloadCompat( + privateKey, + "0xe9030014F5DAe217d0A152f02A043567b16c1aBf", + "1000", + expectedNetworkBase, + "https://blockrun.ai/api/v1/chat/completions", + "test", + 300, + nil, + map[string]any{"trace": "original"}, + ) + if err != nil { + t.Fatal(err) + } + decoded, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + t.Fatal(err) + } + var payload blockrunSDK.PaymentPayload + if err := common.Unmarshal(decoded, &payload); err != nil { + t.Fatal(err) + } + if _, exists := payload.Extensions["builder-code"]; exists { + t.Fatalf("SDK-added builder-code leaked into Base payload: %#v", payload.Extensions) + } +} diff --git a/relay/channel/blockrun/do_request_test.go b/relay/channel/blockrun/do_request_test.go index e7fe0801ecf..d58378a2728 100644 --- a/relay/channel/blockrun/do_request_test.go +++ b/relay/channel/blockrun/do_request_test.go @@ -94,6 +94,7 @@ func TestBlockRunDoRequest_ResponsesX402DoubleHop(t *testing.T) { } func TestBlockRunDoRequest_ResponsesSecond402Stops(t *testing.T) { + const upstreamSecret = "Payment-Signature eyJmdWxsX3NpZ25hdHVyZSI6InNlY3JldC1zZW50aW5lbCJ9" var ( mu sync.Mutex requests []blockRunRecordedRequest @@ -110,16 +111,20 @@ func TestBlockRunDoRequest_ResponsesSecond402Stops(t *testing.T) { w.Header().Set(headerPaymentRequired, paymentRequiredHeader(t, baseURL+"/v1/responses")) } w.WriteHeader(http.StatusPaymentRequired) - _, _ = w.Write([]byte(`{"error":"signature rejected"}`)) + _, _ = w.Write([]byte(`{"error":"signature rejected","echo":"` + upstreamSecret + `"}`)) })) defer srv.Close() t.Cleanup(service.ResetProxyClientCache) body := `{"model":"openai/gpt-5.4","input":"ping","stream_options":{"include_usage":true}}` - resp, err := (&Adaptor{}).DoRequest(blockRunRequestContext(), blockRunResponsesRequestInfo(baseURL, srv.URL), strings.NewReader(body)) + ctx := blockRunRequestContext() + resp, err := (&Adaptor{}).DoRequest(ctx, blockRunResponsesRequestInfo(baseURL, srv.URL), strings.NewReader(body)) if err == nil || !strings.Contains(err.Error(), "status 402 after signing") { t.Fatalf("expected signed 402 hard failure, got resp=%v err=%v", resp, err) } + if strings.Contains(err.Error(), upstreamSecret) { + t.Fatalf("signed 402 error leaked upstream response body: %v", err) + } mu.Lock() got := append([]blockRunRecordedRequest(nil), requests...) @@ -133,6 +138,13 @@ func TestBlockRunDoRequest_ResponsesSecond402Stops(t *testing.T) { if got[0].body != got[1].body || strings.Contains(got[0].body, "stream_options") { t.Fatalf("request body changed across payment retry: %#v", got) } + state, ok := relaycommon.GetBlockRunPaymentState(ctx) + if !ok || state.Outcome != relaycommon.BlockRunPaymentOutcomeRejected { + t.Fatalf("signed 402 payment state = %#v, want rejected", state) + } + if strings.Contains(state.Reconciliation, upstreamSecret) || strings.Contains(string(state.Outcome), upstreamSecret) { + t.Fatalf("signed 402 loggable payment state leaked upstream response body: %#v", state) + } } func TestBlockRunDoRequest_ChatKeepsStreamOptions(t *testing.T) { diff --git a/relay/channel/blockrun/solana_x402.go b/relay/channel/blockrun/solana_x402.go new file mode 100644 index 00000000000..2cae2b62a10 --- /dev/null +++ b/relay/channel/blockrun/solana_x402.go @@ -0,0 +1,97 @@ +package blockrun + +import ( + "fmt" + "math/big" + "net/http" + "reflect" + "strings" + + blockrunSDK "github.com/BlockRunAI/blockrun-llm-go" + "github.com/gagliardetto/solana-go" +) + +const expectedNetworkSolana = "solana" + +func isSolanaNetwork(network string) bool { + return network == expectedNetworkSolana || strings.HasPrefix(network, expectedNetworkSolana+":") +} + +// SignSolanaX402Payment signs only an unambiguous exact-scheme Solana USDC +// option. It is intentionally separate from the shared Base signer so existing +// Type 100 Base and Type 102 callers cannot enter the Solana payment path. +func SignSolanaX402Payment(resp *http.Response, privateKey, resourceURLFallback string, maxAmountAtomic *big.Int) (string, error) { + privateKey = strings.TrimSpace(privateKey) + payReq, err := extractPaymentRequired(resp) + if err != nil { + return "", err + } + opt, err := selectSolanaPaymentOption(payReq.Accepts) + if err != nil { + return "", err + } + if err := validateSolanaPaymentOption(opt, maxAmountAtomic); err != nil { + return "", err + } + if _, err := blockrunSDK.GetSolanaPublicKey(privateKey); err != nil { + return "", fmt.Errorf("blockrun: Solana wallet key is invalid") + } + + resourceURL := payReq.Resource.URL + if resourceURL == "" { + resourceURL = resourceURLFallback + } + paymentB64, err := blockrunSDK.CreateSolanaPaymentPayload( + privateKey, + opt, + resourceURL, + payReq.Resource.Description, + payReq.Extensions, + blockrunSDK.DefaultSolanaRPCURL, + ) + if err != nil { + return "", fmt.Errorf("blockrun: build Solana x402 payload: %w", err) + } + return paymentB64, nil +} + +func selectSolanaPaymentOption(accepts []blockrunSDK.PaymentOption) (*blockrunSDK.PaymentOption, error) { + var selected *blockrunSDK.PaymentOption + for i := range accepts { + candidate := &accepts[i] + if candidate.Scheme != "exact" || !isSolanaNetwork(candidate.Network) || candidate.Asset != blockrunSDK.USDCSolanaMainnet { + continue + } + if selected == nil { + selected = candidate + continue + } + if !reflect.DeepEqual(*selected, *candidate) { + return nil, fmt.Errorf("blockrun: ambiguous Solana payment options") + } + } + if selected == nil { + return nil, fmt.Errorf("blockrun: no exact Solana USDC payment option") + } + return selected, nil +} + +func validateSolanaPaymentOption(opt *blockrunSDK.PaymentOption, maxAmountAtomic *big.Int) error { + if opt.MaxTimeoutSeconds <= 0 || opt.MaxTimeoutSeconds > maxAuthorizationWindowSeconds { + return fmt.Errorf("blockrun: refusing %ds Solana authorization window (cap %ds)", opt.MaxTimeoutSeconds, maxAuthorizationWindowSeconds) + } + if maxAmountAtomic == nil || maxAmountAtomic.Sign() <= 0 { + return fmt.Errorf("blockrun: Solana per-call payment cap must be configured as a positive integer") + } + if err := assertAmountWithinCap(opt.Amount, maxAmountAtomic); err != nil { + return err + } + if _, err := solana.PublicKeyFromBase58(opt.PayTo); err != nil { + return fmt.Errorf("blockrun: payTo %q is not a valid Solana public key", opt.PayTo) + } + feePayer, _ := opt.Extra["feePayer"].(string) + if _, err := solana.PublicKeyFromBase58(feePayer); err != nil { + return fmt.Errorf("blockrun: feePayer is not a valid Solana public key") + } + return nil +} diff --git a/relay/channel/blockrun/solana_x402_test.go b/relay/channel/blockrun/solana_x402_test.go new file mode 100644 index 00000000000..3419c864bed --- /dev/null +++ b/relay/channel/blockrun/solana_x402_test.go @@ -0,0 +1,223 @@ +package blockrun + +import ( + "bytes" + "crypto/ed25519" + "encoding/base64" + "math/big" + "net/http" + "strings" + "testing" + + blockrunSDK "github.com/BlockRunAI/blockrun-llm-go" + "github.com/QuantumNous/new-api/common" + "github.com/mr-tron/base58" +) + +func TestSignSolanaX402PaymentSelectsSolanaFromMixedAccepts(t *testing.T) { + key, payer, payTo, recentBlockhash := solanaTestKeys() + solanaOption := validSolanaOption(payer, payTo, recentBlockhash) + resp := solanaPaymentRequiredResponse(t, []blockrunSDK.PaymentOption{validOption(), solanaOption}) + + encoded, err := SignSolanaX402Payment(resp, key, "https://fallback.invalid", big.NewInt(1000)) + if err != nil { + t.Fatalf("SignSolanaX402Payment: %v", err) + } + decoded, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + t.Fatal(err) + } + var envelope struct { + Accepted blockrunSDK.PaymentOption `json:"accepted"` + Payload map[string]string `json:"payload"` + } + if err := common.Unmarshal(decoded, &envelope); err != nil { + t.Fatal(err) + } + if envelope.Accepted.Network != expectedNetworkSolana || envelope.Accepted.Asset != blockrunSDK.USDCSolanaMainnet { + t.Fatalf("wrong payment option selected: %#v", envelope.Accepted) + } + if envelope.Payload["transaction"] == "" { + t.Fatal("signed Solana transaction is empty") + } +} + +func TestSignSolanaX402PaymentTrimsPrivateKey(t *testing.T) { + key, payer, payTo, recentBlockhash := solanaTestKeys() + resp := solanaPaymentRequiredResponse(t, []blockrunSDK.PaymentOption{ + validSolanaOption(payer, payTo, recentBlockhash), + }) + + encoded, err := SignSolanaX402Payment(resp, " \n\t"+key+"\r ", "https://fallback.invalid", big.NewInt(1000)) + if err != nil { + t.Fatalf("SignSolanaX402Payment with surrounding whitespace: %v", err) + } + if encoded == "" { + t.Fatal("signed Solana payment payload is empty") + } +} + +func TestSelectSolanaPaymentOptionRejectsMissingAndAmbiguousOptions(t *testing.T) { + _, payer, payTo, recentBlockhash := solanaTestKeys() + valid := validSolanaOption(payer, payTo, recentBlockhash) + + tests := []struct { + name string + accepts []blockrunSDK.PaymentOption + want string + }{ + {name: "empty", accepts: nil, want: "no exact Solana USDC"}, + {name: "wrong scheme", accepts: []blockrunSDK.PaymentOption{withSolanaOption(valid, func(o *blockrunSDK.PaymentOption) { o.Scheme = "upto" })}, want: "no exact Solana USDC"}, + {name: "wrong network", accepts: []blockrunSDK.PaymentOption{withSolanaOption(valid, func(o *blockrunSDK.PaymentOption) { o.Network = "solana-devnet" })}, want: "no exact Solana USDC"}, + {name: "wrong asset", accepts: []blockrunSDK.PaymentOption{withSolanaOption(valid, func(o *blockrunSDK.PaymentOption) { o.Asset = payTo })}, want: "no exact Solana USDC"}, + { + name: "different matching options", + accepts: []blockrunSDK.PaymentOption{ + valid, + withSolanaOption(valid, func(o *blockrunSDK.PaymentOption) { o.Amount = "3" }), + }, + want: "ambiguous", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := selectSolanaPaymentOption(tt.accepts) + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("error = %v, want substring %q", err, tt.want) + } + }) + } + + selected, err := selectSolanaPaymentOption([]blockrunSDK.PaymentOption{valid, valid}) + if err != nil { + t.Fatalf("identical duplicate options should not be ambiguous: %v", err) + } + if selected.Amount != valid.Amount { + t.Fatalf("selected option = %#v", selected) + } + + caip, err := selectSolanaPaymentOption([]blockrunSDK.PaymentOption{withSolanaOption(valid, func(o *blockrunSDK.PaymentOption) { + o.Network = "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp" + })}) + if err != nil { + t.Fatalf("CAIP-2 Solana network should be accepted: %v", err) + } + if caip.Network != "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp" { + t.Fatalf("selected CAIP network = %#v", caip) + } +} + +func TestIsSolanaNetwork(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + network string + want bool + }{ + {network: "solana", want: true}, + {network: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", want: true}, + {network: "solana-devnet", want: false}, + {network: "base", want: false}, + } { + if got := isSolanaNetwork(tc.network); got != tc.want { + t.Fatalf("isSolanaNetwork(%q) = %v, want %v", tc.network, got, tc.want) + } + } +} + +func TestSignSolanaX402PaymentRejectsInvalidTrustBoundaryInputs(t *testing.T) { + key, payer, payTo, recentBlockhash := solanaTestKeys() + valid := validSolanaOption(payer, payTo, recentBlockhash) + + tests := []struct { + name string + key string + cap *big.Int + mutate func(*blockrunSDK.PaymentOption) + want string + }{ + {name: "missing cap", key: key, cap: nil, want: "cap must be configured"}, + {name: "zero cap", key: key, cap: big.NewInt(0), want: "cap must be configured"}, + {name: "amount zero", key: key, cap: big.NewInt(1000), mutate: func(o *blockrunSDK.PaymentOption) { o.Amount = "0" }, want: "positive decimal integer"}, + {name: "amount malformed", key: key, cap: big.NewInt(1000), mutate: func(o *blockrunSDK.PaymentOption) { o.Amount = "1.5" }, want: "positive decimal integer"}, + {name: "amount over cap", key: key, cap: big.NewInt(1), want: "exceeds per-call cap"}, + {name: "timeout zero", key: key, cap: big.NewInt(1000), mutate: func(o *blockrunSDK.PaymentOption) { o.MaxTimeoutSeconds = 0 }, want: "authorization window"}, + {name: "timeout over cap", key: key, cap: big.NewInt(1000), mutate: func(o *blockrunSDK.PaymentOption) { o.MaxTimeoutSeconds = 301 }, want: "authorization window"}, + {name: "invalid payTo", key: key, cap: big.NewInt(1000), mutate: func(o *blockrunSDK.PaymentOption) { o.PayTo = "invalid" }, want: "valid Solana public key"}, + {name: "missing fee payer", key: key, cap: big.NewInt(1000), mutate: func(o *blockrunSDK.PaymentOption) { delete(o.Extra, "feePayer") }, want: "feePayer"}, + {name: "invalid fee payer", key: key, cap: big.NewInt(1000), mutate: func(o *blockrunSDK.PaymentOption) { o.Extra["feePayer"] = "invalid" }, want: "feePayer"}, + {name: "invalid wallet key", key: "not-base58!", cap: big.NewInt(1000), want: "wallet key is invalid"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + option := valid + option.Extra = cloneAnyMap(valid.Extra) + if tt.mutate != nil { + tt.mutate(&option) + } + resp := solanaPaymentRequiredResponse(t, []blockrunSDK.PaymentOption{option}) + _, err := SignSolanaX402Payment(resp, tt.key, "https://fallback.invalid", tt.cap) + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("error = %v, want substring %q", err, tt.want) + } + }) + } +} + +func validSolanaOption(feePayer, payTo, recentBlockhash string) blockrunSDK.PaymentOption { + return blockrunSDK.PaymentOption{ + Scheme: "exact", + Network: expectedNetworkSolana, + Amount: "2", + Asset: blockrunSDK.USDCSolanaMainnet, + PayTo: payTo, + MaxTimeoutSeconds: 300, + Extra: map[string]any{ + "feePayer": feePayer, + "recentBlockhash": recentBlockhash, + }, + } +} + +func solanaPaymentRequiredResponse(t *testing.T, accepts []blockrunSDK.PaymentOption) *http.Response { + t.Helper() + requirement := blockrunSDK.PaymentRequirement{ + X402Version: 2, + Accepts: accepts, + Resource: blockrunSDK.ResourceInfo{ + URL: "https://sol.blockrun.ai/api/v1/chat/completions", + Description: "test", + }, + Extensions: map[string]any{"test": true}, + } + encodedJSON, err := common.Marshal(requirement) + if err != nil { + t.Fatal(err) + } + resp := &http.Response{Header: make(http.Header)} + resp.Header.Set(headerPaymentRequired, base64.StdEncoding.EncodeToString(encodedJSON)) + return resp +} + +func solanaTestKeys() (privateKey, feePayer, payTo, recentBlockhash string) { + wallet := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{1}, ed25519.SeedSize)) + feePayerKey := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{2}, ed25519.SeedSize)) + payToKey := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{3}, ed25519.SeedSize)) + hash := bytes.Repeat([]byte{4}, 32) + return base58.Encode(wallet), base58.Encode(feePayerKey.Public().(ed25519.PublicKey)), base58.Encode(payToKey.Public().(ed25519.PublicKey)), base58.Encode(hash) +} + +func withSolanaOption(option blockrunSDK.PaymentOption, mutate func(*blockrunSDK.PaymentOption)) blockrunSDK.PaymentOption { + option.Extra = cloneAnyMap(option.Extra) + mutate(&option) + return option +} + +func cloneAnyMap(input map[string]any) map[string]any { + cloned := make(map[string]any, len(input)) + for key, value := range input { + cloned[key] = value + } + return cloned +} diff --git a/relay/channel/blockrun/x402.go b/relay/channel/blockrun/x402.go index 8c1f2dd98f0..af0b3771ef9 100644 --- a/relay/channel/blockrun/x402.go +++ b/relay/channel/blockrun/x402.go @@ -101,7 +101,7 @@ func SignX402PaymentWithCaps(resp *http.Response, privateKeyHex, resourceURLFall if resourceURL == "" { resourceURL = resourceURLFallback } - paymentB64, err := blockrunSDK.CreatePaymentPayload( + paymentB64, err := CreateBasePaymentPayloadCompat( privKey, opt.PayTo, opt.Amount, opt.Network, resourceURL, payReq.Resource.Description, opt.MaxTimeoutSeconds, opt.Extra, payReq.Extensions, ) diff --git a/relay/common/blockrun_payment.go b/relay/common/blockrun_payment.go new file mode 100644 index 00000000000..7187837a686 --- /dev/null +++ b/relay/common/blockrun_payment.go @@ -0,0 +1,58 @@ +package common + +import ( + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/dto" + "github.com/gin-gonic/gin" +) + +type BlockRunPaymentOutcome string + +const ( + BlockRunPaymentOutcomeSigned BlockRunPaymentOutcome = "signed" + BlockRunPaymentOutcomeSucceeded BlockRunPaymentOutcome = "succeeded" + BlockRunPaymentOutcomeRejected BlockRunPaymentOutcome = "payment_rejected" + BlockRunPaymentOutcomeSettlementUnknown BlockRunPaymentOutcome = "settlement_unknown" +) + +// BlockRunPaymentState records only request-scoped facts needed to prevent a +// second paid attempt and to reconcile an ambiguous signed request. +type BlockRunPaymentState struct { + Attempted bool `json:"attempted"` + Chain dto.BlockRunPaymentChain `json:"chain"` + ChannelID int `json:"channel_id"` + Outcome BlockRunPaymentOutcome `json:"outcome"` + Reconciliation string `json:"reconciliation,omitempty"` + StreamTruncated bool `json:"stream_truncated,omitempty"` +} + +func MarkBlockRunPaymentAttempt(c *gin.Context, chain dto.BlockRunPaymentChain, channelID int, reconciliation string) { + if c == nil { + return + } + common.SetContextKey(c, constant.ContextKeyBlockRunPaymentState, &BlockRunPaymentState{ + Attempted: true, + Chain: chain, + ChannelID: channelID, + Outcome: BlockRunPaymentOutcomeSigned, + Reconciliation: reconciliation, + }) +} + +func GetBlockRunPaymentState(c *gin.Context) (*BlockRunPaymentState, bool) { + if c == nil { + return nil, false + } + state, ok := common.GetContextKeyType[*BlockRunPaymentState](c, constant.ContextKeyBlockRunPaymentState) + return state, ok && state != nil +} + +func UpdateBlockRunPaymentOutcome(c *gin.Context, outcome BlockRunPaymentOutcome, streamTruncated bool) { + state, ok := GetBlockRunPaymentState(c) + if !ok { + return + } + state.Outcome = outcome + state.StreamTruncated = streamTruncated +} diff --git a/service/channel_select.go b/service/channel_select.go index 9aee7ec5e35..4d7a5c78a09 100644 --- a/service/channel_select.go +++ b/service/channel_select.go @@ -4,11 +4,13 @@ import ( "context" "errors" "fmt" + "net/http" "sort" "strings" "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/dto" "github.com/QuantumNous/new-api/logger" "github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/setting" @@ -190,15 +192,15 @@ func CacheGetRandomSatisfiedChannel(param *RetryParam) (*model.Channel, string, } func buildEndpointChannelFilter(c *gin.Context, modelName string) model.ChannelFilter { - if requestedEndpointType(c) == "" { - return nil - } return func(channel *model.Channel) bool { return ChannelSupportsRequestEndpoint(c, channel, modelName) } } func ChannelSupportsRequestEndpoint(c *gin.Context, channel *model.Channel, modelName string) bool { + if !blockRunSolanaSupportsRequest(c, channel) { + return false + } endpointType := requestedEndpointType(c) if endpointType == "" { return true @@ -206,6 +208,22 @@ func ChannelSupportsRequestEndpoint(c *gin.Context, channel *model.Channel, mode return channelSupportsRequestedEndpoint(channel, modelName, endpointType) } +func blockRunSolanaSupportsRequest(c *gin.Context, channel *model.Channel) bool { + if channel == nil || channel.Type != constant.ChannelTypeBlockRun || + channel.GetOtherSettings().GetBlockRunPaymentChain() != dto.BlockRunPaymentChainSolana { + return true + } + if c == nil || c.Request == nil || c.Request.URL == nil || c.Request.Method != http.MethodPost { + return false + } + switch c.Request.URL.Path { + case "/v1/chat/completions", "/v1/messages", "/v1/responses": + return true + default: + return false + } +} + func requestedEndpointType(c *gin.Context) constant.EndpointType { if c == nil || c.Request == nil || c.Request.URL == nil { return "" diff --git a/service/channel_select_blockrun_solana_test.go b/service/channel_select_blockrun_solana_test.go new file mode 100644 index 00000000000..aa61f19a111 --- /dev/null +++ b/service/channel_select_blockrun_solana_test.go @@ -0,0 +1,59 @@ +package service + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/model" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func blockRunChannelWithSettings(channelType int, settings string) *model.Channel { + return &model.Channel{Type: channelType, OtherSettings: settings} +} + +func TestBlockRunSolanaEndpointAllowlist(t *testing.T) { + gin.SetMode(gin.TestMode) + solana := blockRunChannelWithSettings(constant.ChannelTypeBlockRun, `{"blockrun_payment_chain":"solana"}`) + + tests := []struct { + method string + path string + want bool + }{ + {http.MethodPost, "/v1/chat/completions", true}, + {http.MethodPost, "/v1/messages", true}, + {http.MethodPost, "/v1/responses", true}, + {http.MethodPost, "/v1/responses/compact", false}, + {http.MethodPost, "/v1/embeddings", false}, + {http.MethodPost, "/v1/images/generations", false}, + {http.MethodPost, "/v1/audio/speech", false}, + {http.MethodPost, "/v1/rerank", false}, + {http.MethodPost, "/v1/video/generations", false}, + {http.MethodPost, "/pg/chat/completions", false}, + {http.MethodGet, "/v1/responses", false}, + } + + for _, tc := range tests { + t.Run(tc.method+" "+tc.path, func(t *testing.T) { + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Request = httptest.NewRequest(tc.method, tc.path, nil) + require.Equal(t, tc.want, ChannelSupportsRequestEndpoint(ctx, solana, "model")) + }) + } +} + +func TestBlockRunSolanaFilterDoesNotAffectBaseOrOtherBlockRunTypes(t *testing.T) { + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Request = httptest.NewRequest(http.MethodPost, "/v1/images/generations", nil) + + require.True(t, ChannelSupportsRequestEndpoint(ctx, blockRunChannelWithSettings(constant.ChannelTypeBlockRun, `{}`), "image-model")) + require.True(t, ChannelSupportsRequestEndpoint(ctx, blockRunChannelWithSettings(constant.ChannelTypeBlockRun, `{"blockrun_payment_chain":"base"}`), "image-model")) + require.True(t, ChannelSupportsRequestEndpoint(ctx, blockRunChannelWithSettings(constant.ChannelTypeBlockRunVideo, `{"blockrun_payment_chain":"solana"}`), "video-model")) + require.True(t, ChannelSupportsRequestEndpoint(ctx, blockRunChannelWithSettings(constant.ChannelTypeBlockRunSeedance, `{"blockrun_payment_chain":"solana"}`), "video-model")) +} diff --git a/types/error.go b/types/error.go index 893b4c68e8d..e1a8f63fe0d 100644 --- a/types/error.go +++ b/types/error.go @@ -52,6 +52,8 @@ const ( ErrorCodeDoRequestFailed ErrorCode = "do_request_failed" ErrorCodeGetChannelFailed ErrorCode = "get_channel_failed" ErrorCodeGenRelayInfoFailed ErrorCode = "gen_relay_info_failed" + ErrorCodeBlockRunPaymentRejected ErrorCode = "blockrun_payment_rejected" + ErrorCodeBlockRunSettlementUnknown ErrorCode = "blockrun_settlement_unknown" // channel error ErrorCodeChannelNoAvailableKey ErrorCode = "channel:no_available_key" diff --git a/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx b/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx index 6568e9874f1..77e1ec79530 100644 --- a/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx +++ b/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx @@ -86,6 +86,7 @@ import { import { Skeleton } from '@/components/ui/skeleton' import { Switch } from '@/components/ui/switch' import { Textarea } from '@/components/ui/textarea' +import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group' import { Tooltip, TooltipContent, @@ -127,6 +128,11 @@ import { import { useChannelMutateForm } from '../../hooks/use-channel-mutate-form' import { CHANNEL_FORM_DEFAULT_VALUES, + BLOCKRUN_BASE_API_URL, + BLOCKRUN_SOLANA_API_URL, + inspectSolanaPrivateKey, + resolveBlockRunCreateBaseURL, + resolveBlockRunPaymentChainChange, channelFormSchema, channelsQueryKeys, transformChannelToFormDefaults, @@ -357,6 +363,8 @@ export function ChannelMutateDrawer({ 'upstream_model_update_check_enabled' ) const currentSettings = form.watch('settings') + const blockRunPaymentChain = form.watch('blockrun_payment_chain') + const currentKey = form.watch('key') const { unlocked: doubaoApiEditUnlocked, handleClick: handleApiConfigSecretClick, @@ -379,6 +387,11 @@ export function ChannelMutateDrawer({ const isBatchMode = multiKeyMode === 'batch' || multiKeyMode === 'multi_to_single' const isChannelDetailLoading = isEditing && isChannelLoading + const isBlockRunSolana = + currentType === 100 && blockRunPaymentChain === 'solana' + const solanaPrivateKeyInspection = isBlockRunSolana + ? inspectSolanaPrivateKey(currentKey) + : null // Get all models list const allModelsList = useMemo( @@ -593,6 +606,19 @@ export function ChannelMutateDrawer({ } } + if (currentType === 100) { + const currentBaseUrlValue = form.getValues('base_url') + const nextBaseUrl = resolveBlockRunCreateBaseURL({ + channelType: currentType, + isEditing, + paymentChain: blockRunPaymentChain, + currentBaseUrl: currentBaseUrlValue || '', + }) + if (currentBaseUrlValue !== nextBaseUrl) { + form.setValue('base_url', nextBaseUrl) + } + } + // Type 18 (Xunfei) - set default other (version) if (currentType === 18) { const currentOther = form.getValues('other') @@ -600,7 +626,7 @@ export function ChannelMutateDrawer({ form.setValue('other', 'v2.1') } } - }, [currentType, isEditing, form]) + }, [blockRunPaymentChain, currentType, isEditing, form]) // Validate base_url - warn if it ends with /v1 useEffect(() => { @@ -1754,8 +1780,131 @@ export function ChannelMutateDrawer({ /> )} + {currentType === 100 && ( + <> + ( + + {t('Payment chain')} + + { + const nextChain = values[0] + if ( + nextChain !== 'base' && + nextChain !== 'solana' + ) { + return + } + const change = + resolveBlockRunPaymentChainChange({ + channelType: currentType, + isEditing, + currentChain: field.value, + currentBaseUrl: + form.getValues('base_url') || '', + requestedChain: nextChain, + }) + field.onChange(change.paymentChain) + form.setValue('base_url', change.baseUrl, { + shouldDirty: true, + shouldValidate: true, + }) + }} + > + + {t('Base')} + + + {t('Solana')} + + + + + {isEditing + ? t( + 'The payment chain cannot be changed after the channel is created.' + ) + : isBlockRunSolana + ? t('Pay with USDC on Solana.') + : t('Pay with USDC on Base.')} + + + + )} + /> + + ( + + {t('BlockRun API URL *')} + + + + + {isBlockRunSolana + ? t( + 'The Solana payment endpoint is fixed to the official BlockRun URL.' + ) + : t( + 'The Base payment endpoint defaults to the official BlockRun URL.' + )} + + + + )} + /> + + {isBlockRunSolana && ( + ( + + + {t( + 'Maximum payment per request (atomic units) *' + )} + + + + + + {t( + 'Enter a positive decimal integer in USDC atomic units (1 USDC = 1000000).' + )} + + + + )} + /> + )} + + )} + {/* General base_url for other types */} - {![3, 8, 22, 36, 45].includes(currentType) && ( + {![3, 8, 22, 36, 45, 100].includes(currentType) && ( - {t('API Key *')} + + {isBlockRunSolana + ? t('Solana private key *') + : t('API Key *')} +