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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 23 additions & 18 deletions backend/internal/service/account_test_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/tidwall/gjson"
"golang.org/x/sync/singleflight"
)

const (
Expand Down Expand Up @@ -152,24 +153,28 @@ func normalizeGrokAccountTestMode(mode string) string {

// AccountTestService handles account testing operations
type AccountTestService struct {
quotaActivity *QuotaActivityService
accountRepo AccountRepository
geminiTokenProvider *GeminiTokenProvider
claudeTokenProvider *ClaudeTokenProvider
grokTokenProvider *GrokTokenProvider
antigravityGatewayService *AntigravityGatewayService
httpUpstream HTTPUpstream
cfg *config.Config
settingService *SettingService
tlsFPProfileService *TLSFingerprintProfileService
openAIGatewayService *OpenAIGatewayService
openaiGatewayService *OpenAIGatewayService
modelMetadataRegistryMu sync.Mutex
modelMetadataRegistry map[string]modelsDevProvider
modelMetadataRegistryAt time.Time
pluginManager *PluginManager
agentIdentityTaskMu sync.Mutex
agentIdentityWS agentIdentityWSConnectionInvalidator
quotaActivity *QuotaActivityService
accountRepo AccountRepository
geminiTokenProvider *GeminiTokenProvider
claudeTokenProvider *ClaudeTokenProvider
grokTokenProvider *GrokTokenProvider
antigravityGatewayService *AntigravityGatewayService
httpUpstream HTTPUpstream
cfg *config.Config
settingService *SettingService
tlsFPProfileService *TLSFingerprintProfileService
openAIGatewayService *OpenAIGatewayService
openaiGatewayService *OpenAIGatewayService
modelMetadataRegistryMu sync.Mutex
modelMetadataRegistry map[string]modelsDevProvider
modelMetadataRegistryAt time.Time
cindyMetadataRegistryMu sync.Mutex
cindyMetadataRegistry map[string]UpstreamModelMetadata
cindyMetadataRegistryAt time.Time
cindyMetadataRegistryFlight singleflight.Group
pluginManager *PluginManager
agentIdentityTaskMu sync.Mutex
agentIdentityWS agentIdentityWSConnectionInvalidator
// grokWSDialer is optional; realtime account tests use the default OpenAI-style
// WS dialer when nil (supports proxy + coder/websocket handshake).
grokWSDialer openAIWSClientDialer
Expand Down
9 changes: 8 additions & 1 deletion backend/internal/service/billing_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,7 @@ func NewBillingService(cfg *config.Config, pricingService *PricingService) *Bill
// initFallbackPricing 初始化硬编码回退价格(当动态价格不可用时使用)
// 价格单位:USD per token(与LiteLLM格式一致)
func (s *BillingService) initFallbackPricing() {
addConversationStandardFallbackPricing(s.fallbackPrices)
// Claude 4.5 Opus
s.fallbackPrices["claude-opus-4.5"] = &ModelPricing{
InputPricePerToken: 5e-6, // $5 per MTok
Expand Down Expand Up @@ -905,6 +906,12 @@ func (s *BillingService) initFallbackPricing() {
// getFallbackPricing 根据模型系列获取回退价格
func (s *BillingService) getFallbackPricing(model string) *ModelPricing {
modelLower := strings.ToLower(model)
switch modelLower {
case "hy3", "tencent/hy3", "hy4-preview", "tencent/hy4-preview",
"qwen3.8-27b", "qwen/qwen3.8-27b", "qwen3.8-flash", "qwen/qwen3.8-flash",
"qwen3.8-omni-flash", "qwen/qwen3.8-omni-flash", "muse-spark-1.3", "meta/muse-spark-1.3":
return s.fallbackPrices[modelLower]
}
if modelLower == "jev-latest" {
return s.fallbackPrices["jev-latest"]
}
Expand Down Expand Up @@ -1262,7 +1269,7 @@ func (s *BillingService) GetModelPricingWithChannel(model string, channelPricing
if err != nil {
return nil, err
}
if channelPricing == nil {
if channelPricing == nil || channelPricing.IsTokenAllowlistOnly() {
return pricing, nil
}
// 防止修改 fallbackPrices 中的共享指针
Expand Down
10 changes: 10 additions & 0 deletions backend/internal/service/channel.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,16 @@ type ChannelModelPricing struct {
UpdatedAt time.Time `json:"updated_at,omitempty"`
}

// IsTokenAllowlistOnly distinguishes a model restriction from a price override.
// An explicitly configured zero remains an override.
func (p *ChannelModelPricing) IsTokenAllowlistOnly() bool {
return p != nil && (p.BillingMode == "" || p.BillingMode == BillingModeToken) &&
p.InputPrice == nil && p.OutputPrice == nil && p.CacheWritePrice == nil && p.CacheWrite1hPrice == nil &&
p.CacheReadPrice == nil && p.FastMultiplier == nil && p.FlexMultiplier == nil &&
len(p.ReasoningEffortMultipliers) == 0 && p.ImageInputPrice == nil && p.ImageOutputPrice == nil &&
p.PerRequestPrice == nil && len(p.Intervals) == 0 && p.TimePricing == nil
}

// ChannelTimePricing 渠道模型定价的分时倍率配置。
type ChannelTimePricing struct {
Timezone string `json:"timezone"`
Expand Down
31 changes: 31 additions & 0 deletions backend/internal/service/conversation_standard_pricing.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package service

// Exact international USD API prices verified 2026-10-04. These enter the
// ordinary fallback table; a dynamic catalog price remains authoritative.
// Tencent: https://intl.cloud.tencent.com/zh/document/product/1300/78937
// Qwen: https://www.alibabacloud.com/help/en/model-studio/qwen3-8-27b
//
// https://www.alibabacloud.com/help/en/model-studio/qwen3-8-flash
// https://www.alibabacloud.com/help/en/model-studio/model-pricing
//
// Meta: https://dev.meta.ai/docs/pricing-rate-limits
func addConversationStandardFallbackPricing(prices map[string]*ModelPricing) {
for _, row := range []struct {
name, provider string
input, output, cacheRead, cacheWrite float64 // USD / million tokens
}{
{"hy3", "tencent", 0.132, 0.528, 0.033, 0},
{"hy4-preview", "tencent", 0.834, 2.501, 0.042, 0},
// The single cached-input field uses the implicit-cache rate. Explicit
// cache creation has its own published rate; no TTL ladder is assumed.
{"qwen3.8-27b", "qwen", 0.50, 3.00, 0.10, 0.625},
{"qwen3.8-flash", "qwen", 0.15, 0.47, 0.016, 0.20},
{"qwen3.8-omni-flash", "qwen", 0.15, 0.47, 0.016, 0},
{"muse-spark-1.3", "meta", 1.25, 4.25, 0.15, 0},
} {
price := &ModelPricing{InputPricePerToken: row.input / 1e6, OutputPricePerToken: row.output / 1e6,
CacheReadPricePerToken: row.cacheRead / 1e6, CacheCreationPricePerToken: row.cacheWrite / 1e6}
prices[row.name] = price
prices[row.provider+"/"+row.name] = price
}
}
65 changes: 65 additions & 0 deletions backend/internal/service/conversation_standard_pricing_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
//go:build unit

package service

import (
"context"
"testing"

"github.com/stretchr/testify/require"
)

func TestConversationStandardPricingExactFallbackAndDynamicPriority(t *testing.T) {
bs := &BillingService{fallbackPrices: make(map[string]*ModelPricing)}
bs.initFallbackPricing()
for _, row := range []struct {
model, namespace string
input, output, read, write float64
}{
{"hy3", "tencent", .132, .528, .033, 0}, {"hy4-preview", "tencent", .834, 2.501, .042, 0},
{"qwen3.8-27b", "qwen", .5, 3, .1, .625}, {"qwen3.8-flash", "qwen", .15, .47, .016, .2},
{"qwen3.8-omni-flash", "qwen", .15, .47, .016, 0}, {"muse-spark-1.3", "meta", 1.25, 4.25, .15, 0},
} {
for _, model := range []string{row.model, row.namespace + "/" + row.model} {
price, err := bs.GetModelPricing(model)
require.NoError(t, err, model)
require.InDelta(t, row.input/1e6, price.InputPricePerToken, 1e-14)
require.InDelta(t, row.output/1e6, price.OutputPricePerToken, 1e-14)
require.InDelta(t, row.read/1e6, price.CacheReadPricePerToken, 1e-14)
require.InDelta(t, row.write/1e6, price.CacheCreationPricePerToken, 1e-14)
}
}
for _, unknown := range []string{"qwen3.8-unknown", "qwen3.8-flash-next", "hy4-preview-unknown", "muse-spark-1.3-contributor", "other/hy3"} {
_, err := bs.GetModelPricing(unknown)
require.ErrorIs(t, err, ErrModelPricingUnavailable, unknown)
}
bs.pricingService = newStubPricingServiceFromJSON(t, `{"hy3":{"input_cost_per_token":0.000001,"output_cost_per_token":0.000002,"litellm_provider":"tencent","mode":"chat"}}`)
price, err := bs.GetModelPricing("hy3")
require.NoError(t, err)
require.Equal(t, 1e-6, price.InputPricePerToken)
}

func TestTokenAllowlistOnlyPreservesDefaultPricing(t *testing.T) {
bs := newTestBillingServiceForResolver()
base := bs.fallbackPrices["claude-sonnet-4"]
fast, flex := 2.0, .5
base.FastMultiplier, base.FlexMultiplier = &fast, &flex
base.ReasoningEffortMultipliers = map[string]float64{"high": 1.5}
card := ChannelModelPricing{Platform: PlatformOpenAI, Models: []string{"claude-sonnet-4"}, BillingMode: BillingModeToken}
require.True(t, card.IsTokenAllowlistOnly())
cs := newChannelServiceWithPricings(12, []ChannelModelPricing{card})
groupID := int64(12)
resolved := NewModelPricingResolver(cs, bs).Resolve(context.Background(), PricingInput{Model: "claude-sonnet-4", GroupID: &groupID})
require.Equal(t, PricingSourceLiteLLM, resolved.Source)
require.Equal(t, base, resolved.BasePricing)
price, err := bs.GetModelPricingWithChannel("claude-sonnet-4", &card)
require.NoError(t, err)
require.Equal(t, base, price)
zero := 0.0
card.InputPrice = &zero
require.False(t, card.IsTokenAllowlistOnly())
price, err = bs.GetModelPricingWithChannel("claude-sonnet-4", &card)
require.NoError(t, err)
require.Zero(t, price.InputPricePerToken)
require.Equal(t, 3e-6, base.InputPricePerToken, "explicit overrides do not mutate shared defaults")
}
8 changes: 7 additions & 1 deletion backend/internal/service/model_pricing_resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,9 @@ func (r *ModelPricingResolver) Resolve(ctx context.Context, input PricingInput)
var chPricing *ChannelModelPricing
if input.GroupID != nil && r.channelService != nil {
chPricing = r.lookupChannelPricingNormalized(ctx, *input.GroupID, input.Model)
if chPricing.IsTokenAllowlistOnly() {
chPricing = nil
}
if chPricing != nil {
mode := chPricing.BillingMode
if mode == "" {
Expand Down Expand Up @@ -225,7 +228,7 @@ func (r *ModelPricingResolver) lookupChannelPricingNormalized(ctx context.Contex
// applyChannelOverrides 应用渠道定价覆盖
func (r *ModelPricingResolver) applyChannelOverrides(ctx context.Context, groupID int64, model string, resolved *ResolvedPricing) {
chPricing := r.lookupChannelPricingNormalized(ctx, groupID, model)
if chPricing == nil {
if chPricing == nil || chPricing.IsTokenAllowlistOnly() {
return
}

Expand All @@ -246,6 +249,9 @@ func (r *ModelPricingResolver) applyChannelOverrides(ctx context.Context, groupI

// applyTokenOverrides 应用 token 模式的渠道覆盖
func (r *ModelPricingResolver) applyTokenOverrides(chPricing *ChannelModelPricing, resolved *ResolvedPricing) {
if chPricing.IsTokenAllowlistOnly() {
return
}
if resolved.BasePricing == nil {
resolved.BasePricing = &ModelPricing{}
} else {
Expand Down
Loading
Loading