From 7dae055f2afe4921ddb84d6634afa07dba436b8a Mon Sep 17 00:00:00 2001 From: IanShaw <131567472+IanShaw027@users.noreply.github.com> Date: Wed, 19 Aug 2026 05:28:35 -0700 Subject: [PATCH] =?UTF-8?q?=E8=AE=A1=E8=B4=B9=EF=BC=9A=E8=AF=86=E5=88=AB?= =?UTF-8?q?=E5=B9=B6=E8=AE=B0=E5=BD=95=20Anthropic=20Fast=20=E8=AF=B7?= =?UTF-8?q?=E6=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../channel_pricing_multipliers_test.go | 288 ++++++++++++++++++ backend/internal/service/gateway_forward.go | 56 +++- backend/internal/service/gateway_request.go | 5 + .../internal/service/gateway_request_test.go | 16 + backend/internal/service/gateway_service.go | 3 + .../internal/service/gateway_usage_billing.go | 5 +- backend/internal/service/usage_log.go | 3 +- backend/internal/service/usage_log_helpers.go | 7 + 8 files changed, 380 insertions(+), 3 deletions(-) create mode 100644 backend/internal/service/channel_pricing_multipliers_test.go diff --git a/backend/internal/service/channel_pricing_multipliers_test.go b/backend/internal/service/channel_pricing_multipliers_test.go new file mode 100644 index 0000000000..5b38fddec2 --- /dev/null +++ b/backend/internal/service/channel_pricing_multipliers_test.go @@ -0,0 +1,288 @@ +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func pricingMultiplier(value float64) *float64 { return &value } + +func TestConfiguredServiceTierMultiplier(t *testing.T) { + tests := []struct { + name string + serviceTier string + pricing *ModelPricing + want float64 + }{ + {name: "gpt-5.5 fast", serviceTier: "fast", pricing: &ModelPricing{FastMultiplier: pricingMultiplier(2.5)}, want: 2.5}, + {name: "priority alias", serviceTier: "priority", pricing: &ModelPricing{FastMultiplier: pricingMultiplier(2)}, want: 2}, + {name: "flex configured", serviceTier: "flex", pricing: &ModelPricing{FlexMultiplier: pricingMultiplier(0.4)}, want: 0.4}, + {name: "legacy fast default", serviceTier: "fast", pricing: &ModelPricing{}, want: 2}, + {name: "legacy flex default", serviceTier: "flex", pricing: &ModelPricing{}, want: 0.5}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.InDelta(t, tt.want, configuredServiceTierMultiplier(tt.serviceTier, tt.pricing), 1e-12) + }) + } +} + +func TestConfiguredServiceTierMultiplierAppliesToEveryTokenComponent(t *testing.T) { + pricing := &ModelPricing{ + InputPricePerToken: 5e-6, + OutputPricePerToken: 30e-6, + CacheCreationPricePerToken: 6.25e-6, + CacheReadPricePerToken: 0.5e-6, + FastMultiplier: pricingMultiplier(2.5), + FlexMultiplier: pricingMultiplier(0.5), + } + tokens := UsageTokens{ + InputTokens: 1_000_000, + OutputTokens: 1_000_000, + CacheCreationTokens: 1_000_000, + CacheReadTokens: 1_000_000, + } + service := &BillingService{} + + fast := service.computeTokenBreakdown(pricing, tokens, 1, "fast", false) + require.InDelta(t, 12.5, fast.InputCost, 1e-12) + require.InDelta(t, 75, fast.OutputCost, 1e-12) + require.InDelta(t, 15.625, fast.CacheCreationCost, 1e-12) + require.InDelta(t, 1.25, fast.CacheReadCost, 1e-12) + + flex := service.computeTokenBreakdown(pricing, tokens, 1, "flex", false) + require.InDelta(t, 2.5, flex.InputCost, 1e-12) + require.InDelta(t, 15, flex.OutputCost, 1e-12) + require.InDelta(t, 3.125, flex.CacheCreationCost, 1e-12) + require.InDelta(t, 0.25, flex.CacheReadCost, 1e-12) +} + +func TestChannelOverridePreservesCatalogFastRatioByDefault(t *testing.T) { + pricing := &ModelPricing{ + InputPricePerToken: 2, + InputPricePerTokenPriority: 4, + OutputPricePerToken: 6, + OutputPricePerTokenPriority: 12, + } + applyChannelTokenPriceOverrides(pricing, &ChannelModelPricing{ + InputPrice: pricingMultiplier(3), + OutputPrice: pricingMultiplier(9), + }) + + require.InDelta(t, 3, pricing.InputPricePerToken, 1e-12) + require.InDelta(t, 6, pricing.InputPricePerTokenPriority, 1e-12) + require.InDelta(t, 9, pricing.OutputPricePerToken, 1e-12) + require.InDelta(t, 18, pricing.OutputPricePerTokenPriority, 1e-12) +} + +func TestAnthropicFastUsesDefaultMultiplierWithoutCatalogTier(t *testing.T) { + pricing := &ModelPricing{InputPricePerToken: 5e-6, OutputPricePerToken: 25e-6} + cost := (&BillingService{}).computeTokenBreakdown(pricing, UsageTokens{ + InputTokens: 1_000_000, OutputTokens: 1_000_000, + }, 1, "fast", false) + + require.InDelta(t, 10, cost.InputCost, 1e-12) + require.InDelta(t, 50, cost.OutputCost, 1e-12) +} + +func TestBuiltInModelFastDefaults(t *testing.T) { + service := &BillingService{fallbackPrices: make(map[string]*ModelPricing)} + service.initFallbackPricing() + + for _, tt := range []struct { + model string + want float64 + }{ + {model: "gpt-5.5", want: 2.5}, + {model: "claude-opus-4.8", want: 2}, + {model: "claude-opus-5", want: 2}, + } { + pricing := service.fallbackPrices[tt.model] + require.NotNil(t, pricing) + require.InDelta(t, tt.want, pricing.InputPricePerTokenPriority/pricing.InputPricePerToken, 1e-12) + require.InDelta(t, tt.want, pricing.OutputPricePerTokenPriority/pricing.OutputPricePerToken, 1e-12) + } +} + +func TestIntervalMultipliersApplyToChannelBase(t *testing.T) { + base := &ModelPricing{ + InputPricePerToken: 5, + OutputPricePerToken: 30, + CacheCreationPricePerToken: 6.25, + CacheCreation5mPrice: 6.25, + CacheCreation1hPrice: 6.25, + CacheReadPricePerToken: 0.5, + FastMultiplier: pricingMultiplier(2), + FlexMultiplier: pricingMultiplier(0.5), + } + resolved := &ResolvedPricing{ + BasePricing: base, + Intervals: []PricingInterval{{ + MinTokens: 272000, + InputMultiplier: pricingMultiplier(2), + OutputMultiplier: pricingMultiplier(1.5), + CacheWriteMultiplier: pricingMultiplier(2), + CacheReadMultiplier: pricingMultiplier(2), + }}, + } + + pricing := (&ModelPricingResolver{}).GetIntervalPricing(resolved, 272001) + require.InDelta(t, 10, pricing.InputPricePerToken, 1e-12) + require.InDelta(t, 45, pricing.OutputPricePerToken, 1e-12) + require.InDelta(t, 12.5, pricing.CacheCreationPricePerToken, 1e-12) + require.InDelta(t, 1, pricing.CacheReadPricePerToken, 1e-12) + require.Same(t, base, (&ModelPricingResolver{}).GetIntervalPricing(resolved, 272000)) +} + +func TestIntervalExplicitPriceTakesPrecedenceOverMultiplier(t *testing.T) { + pricing := intervalToModelPricing(&PricingInterval{ + InputPrice: pricingMultiplier(7), + InputMultiplier: pricingMultiplier(2), + }, &ModelPricing{InputPricePerToken: 5}, nil) + + require.InDelta(t, 7, pricing.InputPricePerToken, 1e-12) +} + +func TestIntervalPricePreservesDefaultFastRatio(t *testing.T) { + pricing := intervalToModelPricing(&PricingInterval{ + InputPrice: pricingMultiplier(7), + }, &ModelPricing{ + InputPricePerToken: 5, + InputPricePerTokenPriority: 10, + }, nil) + + require.InDelta(t, 7, pricing.InputPricePerToken, 1e-12) + require.InDelta(t, 14, pricing.InputPricePerTokenPriority, 1e-12) +} + +func TestAnthropicSpeedServiceTier(t *testing.T) { + account := &Account{Platform: PlatformAnthropic} + + for _, model := range []string{"claude-opus-5", "claude-opus-4-8", "claude-opus-4.8"} { + tier := anthropicSpeedServiceTier(account, "fast", model) + require.NotNil(t, tier, "model %s should bill as fast", model) + require.Equal(t, "fast", *tier) + } + + require.Nil(t, anthropicSpeedServiceTier(&Account{Platform: PlatformOpenAI}, "fast", "claude-opus-5")) + require.Nil(t, anthropicSpeedServiceTier(account, "standard", "claude-opus-5")) +} + +// fast mode 不存在于这些模型/承载上,即便客户端传了 speed=fast 也不能计 2x。 +func TestAnthropicSpeedServiceTierRejectsUnsupportedTargets(t *testing.T) { + account := &Account{Platform: PlatformAnthropic} + + for _, model := range []string{ + "claude-opus-4-7", // fast mode 已被移除 + "claude-opus-4-6", // + "claude-opus-4-5", // 不能被 "opus-5" 规则误判 + "claude-sonnet-5", // 非 Opus + "claude-haiku-4-5", // + "", // + } { + require.Nil(t, anthropicSpeedServiceTier(account, "fast", model), + "model %q must not bill as fast", model) + } + + bedrock := &Account{Platform: PlatformAnthropic, Type: AccountTypeBedrock} + require.Nil(t, anthropicSpeedServiceTier(bedrock, "fast", "claude-opus-5")) +} + +func TestAnthropicSpeedModelPrefersMappedUpstreamModel(t *testing.T) { + parsed := &ParsedRequest{Model: "claude-opus-5"} + require.Equal(t, "claude-opus-4-7", anthropicSpeedModel(parsed, &ForwardResult{ + UpstreamModel: "claude-opus-4-7", + })) + require.Equal(t, "claude-opus-5", anthropicSpeedModel(parsed, &ForwardResult{})) +} + +func TestMultiplierOnlyIntervalIsValid(t *testing.T) { + require.NoError(t, ValidateIntervals([]PricingInterval{{ + MinTokens: 199999, + InputMultiplier: pricingMultiplier(2), + }}, BillingModeToken)) + require.NoError(t, checkIntervalsHavePrices(ChannelModelPricing{ + Models: []string{"grok-4.6"}, + Intervals: []PricingInterval{{ + MinTokens: 199999, + InputMultiplier: pricingMultiplier(2), + }}, + })) +} + +func TestChannelMultipliersMustBePositive(t *testing.T) { + zero := 0.0 + require.Error(t, checkPricesNotNegative(ChannelModelPricing{FastMultiplier: &zero})) + require.Error(t, checkPricesNotNegative(ChannelModelPricing{FlexMultiplier: &zero})) + require.Error(t, ValidateIntervals([]PricingInterval{{ + MinTokens: 100, + InputMultiplier: &zero, + }}, BillingModeToken)) +} + +func TestCalculateTokenCostContextTierEnablement(t *testing.T) { + base := &ModelPricing{InputPricePerToken: 1e-6} + resolved := &ResolvedPricing{ + BasePricing: base, + Intervals: []PricingInterval{{ + MinTokens: 100, + InputMultiplier: pricingMultiplier(2), + }}, + } + resolver := &ModelPricingResolver{} + service := &BillingService{} + tokens := UsageTokens{InputTokens: 200} + + t.Run("group disabled uses base tier", func(t *testing.T) { + resolved.longContextPricingEnabled = false + cost, err := service.calculateTokenCost(resolved, CostInput{ + Model: "custom", Tokens: tokens, RateMultiplier: 1, Resolver: resolver, + }) + require.NoError(t, err) + require.InDelta(t, 200e-6, cost.TotalCost, 1e-12) + }) + + t.Run("group enabled uses interval", func(t *testing.T) { + resolved.longContextPricingEnabled = true + accountDisabled := false + cost, err := service.calculateTokenCost(resolved, CostInput{ + Model: "custom", Tokens: tokens, RateMultiplier: 1, Resolver: resolver, + LongContextBillingEnabled: &accountDisabled, + }) + require.NoError(t, err) + require.InDelta(t, 400e-6, cost.TotalCost, 1e-12) + }) + + t.Run("account enabled overrides disabled group", func(t *testing.T) { + resolved.longContextPricingEnabled = false + accountEnabled := true + cost, err := service.calculateTokenCost(resolved, CostInput{ + Model: "custom", Tokens: tokens, RateMultiplier: 1, Resolver: resolver, + LongContextBillingEnabled: &accountEnabled, + }) + require.NoError(t, err) + require.InDelta(t, 400e-6, cost.TotalCost, 1e-12) + }) +} + +func TestCalculateTokenCostCombinesIntervalAndFastMultiplier(t *testing.T) { + resolved := &ResolvedPricing{ + BasePricing: &ModelPricing{ + InputPricePerToken: 1e-6, + FastMultiplier: pricingMultiplier(2.5), + }, + Intervals: []PricingInterval{{ + MinTokens: 100, + InputMultiplier: pricingMultiplier(2), + }}, + longContextPricingEnabled: true, + } + cost, err := (&BillingService{}).calculateTokenCost(resolved, CostInput{ + Model: "custom", Tokens: UsageTokens{InputTokens: 200}, RateMultiplier: 1, + ServiceTier: "fast", Resolver: &ModelPricingResolver{}, + }) + require.NoError(t, err) + require.InDelta(t, 1e-3, cost.TotalCost, 1e-12) +} diff --git a/backend/internal/service/gateway_forward.go b/backend/internal/service/gateway_forward.go index 863e4d2e83..d8a105c7d8 100644 --- a/backend/internal/service/gateway_forward.go +++ b/backend/internal/service/gateway_forward.go @@ -88,11 +88,21 @@ func sleepWithContext(ctx context.Context, d time.Duration) error { } // Forward 转发请求到Claude API -func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *Account, parsed *ParsedRequest) (*ForwardResult, error) { +func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *Account, parsed *ParsedRequest) (result *ForwardResult, err error) { startTime := time.Now() if parsed == nil { return nil, fmt.Errorf("parse request: empty request") } + // Anthropic Fast is requested with speed=fast rather than OpenAI's + // service_tier. Attach it at this shared boundary so passthrough, OAuth and + // partial-stream results all use the same billing and usage-log path. + defer func() { + if result != nil { + if tier := anthropicSpeedServiceTier(account, parsed.Speed, anthropicSpeedModel(parsed, result)); tier != nil { + result.ServiceTier = tier + } + } + }() beginUpstreamResponseModelObservation(c) // Web Search 模拟:纯 web_search 请求时,直接调用搜索 API 构造响应 @@ -885,6 +895,50 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A }, nil } +func anthropicSpeedModel(parsed *ParsedRequest, result *ForwardResult) string { + if result != nil { + if upstreamModel := strings.TrimSpace(result.UpstreamModel); upstreamModel != "" { + return upstreamModel + } + } + if parsed == nil { + return "" + } + return parsed.Model +} + +// anthropicSpeedServiceTier 把 Anthropic 的 speed=fast 归一成可计费的 "fast" tier。 +// +// Fast mode 目前只在 Claude Opus 5 / Opus 4.8 上存在,且不支持 Bedrock 等第三方 +// 承载(Opus 4.7 的 fast mode 已被移除,传 speed=fast 会直接报错)。这里按模型和 +// 平台收紧,避免上游根本没跑 fast 时仍然按 2x 计费——宁可漏收也不能多收。 +// +// 注:判据是请求参数而非响应里的 usage.speed。等 usage 解析链路统一暴露该字段后, +// 应改为以响应为准。 +func anthropicSpeedServiceTier(account *Account, speed, model string) *string { + if account == nil || account.Platform != PlatformAnthropic || speed != "fast" { + return nil + } + if account.IsBedrock() || !modelSupportsAnthropicFastMode(model) { + return nil + } + tier := "fast" + return &tier +} + +// modelSupportsAnthropicFastMode 判断模型是否属于支持 fast mode 的 Opus 5 / Opus 4.8。 +func modelSupportsAnthropicFastMode(model string) bool { + modelLower := strings.ToLower(strings.TrimSpace(model)) + if !strings.Contains(modelLower, "opus") { + return false + } + // "opus-5" 必须先判:不能用裸 "5" 匹配,否则 claude-opus-4-5 会被误判。 + if strings.Contains(modelLower, "opus-5") || strings.Contains(modelLower, "opus5") { + return true + } + return strings.Contains(modelLower, "4.8") || strings.Contains(modelLower, "4-8") +} + // ResolveChannelMapping 委托渠道服务解析模型映射 func (s *GatewayService) ResolveChannelMapping(ctx context.Context, groupID int64, model string) ChannelMappingResult { if s.channelService == nil { diff --git a/backend/internal/service/gateway_request.go b/backend/internal/service/gateway_request.go index 4ffd5ee8e9..111b807f2f 100644 --- a/backend/internal/service/gateway_request.go +++ b/backend/internal/service/gateway_request.go @@ -117,6 +117,7 @@ func clearGatewayRequestDerivedState(parsed *ParsedRequest) { parsed.HasSystem = false parsed.ThinkingEnabled = false parsed.OutputEffort = "" + parsed.Speed = "" parsed.MaxTokens = 0 parsed.systemRange = missingJSONRange() parsed.messagesRange = missingJSONRange() @@ -224,6 +225,9 @@ func parseGatewayRequestCurrentBody(parsed *ParsedRequest, protocol string) erro parsed.ThinkingEnabled = thinkingType == "enabled" || thinkingType == "adaptive" parsed.OutputEffort = strings.TrimSpace(gjson.Get(jsonStr, "output_config.effort").String()) + if protocol == domain.PlatformAnthropic { + parsed.Speed = strings.ToLower(strings.TrimSpace(gjson.Get(jsonStr, "speed").String())) + } maxTokensResult := gjson.Get(jsonStr, "max_tokens") if maxTokensResult.Exists() && maxTokensResult.Type == gjson.Number { @@ -282,6 +286,7 @@ type ParsedRequest struct { HasSystem bool // 是否包含 system 字段(包含 null 也视为显式传入) ThinkingEnabled bool // 是否开启 thinking(部分平台会影响最终模型名) OutputEffort string // output_config.effort(Claude API 的推理强度控制) + Speed string // Anthropic speed(当前可计费值为 "fast") MaxTokens int // max_tokens 值(用于探测请求拦截) SessionContext *SessionContext // 可选:请求上下文区分因子(nil 时行为不变) diff --git a/backend/internal/service/gateway_request_test.go b/backend/internal/service/gateway_request_test.go index d4e010a5e8..6074c02991 100644 --- a/backend/internal/service/gateway_request_test.go +++ b/backend/internal/service/gateway_request_test.go @@ -42,6 +42,22 @@ func TestParseGatewayRequest_ThinkingAdaptiveEnabled(t *testing.T) { require.True(t, parsed.ThinkingEnabled) } +func TestParseGatewayRequest_AnthropicFastSpeed(t *testing.T) { + parsed, err := ParseGatewayRequest( + NewRequestBodyRef([]byte(`{"model":"claude-opus-4-8","speed":" FAST "}`)), + domain.PlatformAnthropic, + ) + require.NoError(t, err) + require.Equal(t, "fast", parsed.Speed) + + nonAnthropic, err := ParseGatewayRequest( + NewRequestBodyRef([]byte(`{"model":"gpt-5.4","speed":"fast"}`)), + "responses", + ) + require.NoError(t, err) + require.Empty(t, nonAnthropic.Speed) +} + func TestParseGatewayRequest_MaxTokens(t *testing.T) { body := []byte(`{"model":"claude-haiku-4-5","max_tokens":1}`) parsed, err := ParseGatewayRequest(NewRequestBodyRef(body), "") diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index 2578d59b1d..20e7e5b9e6 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -619,6 +619,9 @@ type ForwardResult struct { FirstTokenMs *int // 首字时间(流式请求) ClientDisconnect bool // 客户端是否在流式传输过程中断开 ReasoningEffort *string + // ServiceTier records the billable request tier. OpenAI uses service_tier; + // Anthropic speed=fast is normalized to "fast". + ServiceTier *string // 图片生成计费字段(图片生成模型使用) ImageCount int // 生成的图片数量 diff --git a/backend/internal/service/gateway_usage_billing.go b/backend/internal/service/gateway_usage_billing.go index c9671328be..e1fac3d6cf 100644 --- a/backend/internal/service/gateway_usage_billing.go +++ b/backend/internal/service/gateway_usage_billing.go @@ -1157,6 +1157,7 @@ func (s *GatewayService) calculateTokenCost( RequestCount: 1, RateMultiplier: multiplier, PricingAt: pricingAt, + ServiceTier: optionalStringValue(result.ServiceTier), Resolver: s.resolver, Resolved: resolved, }) @@ -1167,7 +1168,8 @@ func (s *GatewayService) calculateTokenCost( gid := apiKey.Group.ID cost, err = s.billingService.CalculateCostUnified(CostInput{ Ctx: ctx, Model: billingModel, GroupID: &gid, Group: apiKey.Group, - Tokens: tokens, RequestCount: 1, RateMultiplier: multiplier, PricingAt: pricingAt, Resolver: s.resolver, + Tokens: tokens, RequestCount: 1, RateMultiplier: multiplier, PricingAt: pricingAt, + ServiceTier: optionalStringValue(result.ServiceTier), Resolver: s.resolver, }) } else { cost, err = s.billingService.CalculateCost(billingModel, tokens, multiplier) @@ -1219,6 +1221,7 @@ func (s *GatewayService) buildRecordUsageLog( UpstreamModel: optionalTrimmedStringPtr(result.UpstreamModel), UpstreamResponseModel: optionalTrimmedStringPtr(result.UpstreamResponseModel), UpstreamModelMismatch: upstreamModelMismatch(sentModel, result.UpstreamResponseModel), + ServiceTier: result.ServiceTier, ReasoningEffort: result.ReasoningEffort, InboundEndpoint: optionalTrimmedStringPtr(input.InboundEndpoint), UpstreamEndpoint: optionalTrimmedStringPtr(input.UpstreamEndpoint), diff --git a/backend/internal/service/usage_log.go b/backend/internal/service/usage_log.go index 7a41555ece..b6dc2a9d0e 100644 --- a/backend/internal/service/usage_log.go +++ b/backend/internal/service/usage_log.go @@ -128,7 +128,8 @@ type UsageLog struct { BillingTier *string // BillingMode 计费模式:token/image BillingMode *string - // ServiceTier records the OpenAI service tier used for billing, e.g. "priority" / "flex". + // ServiceTier records the billable request tier, e.g. OpenAI "priority" / "flex" + // or Anthropic "fast". ServiceTier *string // ReasoningEffort is the request's reasoning effort level. // OpenAI: "low" / "medium" / "high" / "xhigh"; Claude: "low" / "medium" / "high" / "max". diff --git a/backend/internal/service/usage_log_helpers.go b/backend/internal/service/usage_log_helpers.go index b431b50aef..deb102e736 100644 --- a/backend/internal/service/usage_log_helpers.go +++ b/backend/internal/service/usage_log_helpers.go @@ -10,6 +10,13 @@ func optionalTrimmedStringPtr(raw string) *string { return &trimmed } +func optionalStringValue(value *string) string { + if value == nil { + return "" + } + return strings.TrimSpace(*value) +} + func forwardResultBillingModel(requestedModel, upstreamModel string) string { if trimmed := strings.TrimSpace(requestedModel); trimmed != "" { return trimmed