diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index e437b11150..0fa0058a5d 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -830,6 +830,53 @@ type ProxyFallbackConfig struct { type ProxyProbeConfig struct { InsecureSkipVerify bool `mapstructure:"insecure_skip_verify"` // 已禁用:禁止跳过 TLS 证书验证 + // URLs 按优先级排列的自定义探测 URL 列表。 + // 留空时使用内置默认列表(ip-api → ipify)。 + // 某些 AI API 专用代理只允许访问特定域名,配置多个备选可提高探测成功率。 + URLs []ProbeURLConfig `mapstructure:"urls"` +} + +// ProbeURLConfig 描述一个探测端点及其响应解析方式。 +type ProbeURLConfig struct { + URL string `mapstructure:"url"` + Parser string `mapstructure:"parser"` // "ip-api" / "ipify" / "chatgpt-trace" +} + +func normalizeProxyProbeURLs(targets []ProbeURLConfig) ([]ProbeURLConfig, error) { + if len(targets) == 0 { + return nil, nil + } + + normalized := make([]ProbeURLConfig, 0, len(targets)) + for i, target := range targets { + rawURL := strings.TrimSpace(target.URL) + parser := strings.ToLower(strings.TrimSpace(target.Parser)) + if rawURL == "" { + return nil, fmt.Errorf("entry %d: url is required", i) + } + if parser == "" { + return nil, fmt.Errorf("entry %d: parser is required", i) + } + switch parser { + case "ip-api", "ipify", "chatgpt-trace": + default: + return nil, fmt.Errorf("entry %d: unsupported parser %q", i, target.Parser) + } + + parsed, err := url.Parse(rawURL) + if err != nil || parsed.Host == "" { + return nil, fmt.Errorf("entry %d: invalid url %q", i, target.URL) + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return nil, fmt.Errorf("entry %d: url scheme must be http or https", i) + } + + normalized = append(normalized, ProbeURLConfig{ + URL: rawURL, + Parser: parser, + }) + } + return normalized, nil } type BillingConfig struct { @@ -2569,6 +2616,11 @@ func (c *Config) Validate() error { } c.Security.ForwardedClientIPHeaders = forwardedClientIPHeaders c.SetForwardedClientIPSettings(c.Security.TrustForwardedIPForAPIKeyACL, forwardedClientIPHeaders) + proxyProbeURLs, err := normalizeProxyProbeURLs(c.Security.ProxyProbe.URLs) + if err != nil { + return fmt.Errorf("security.proxy_probe.urls: %w", err) + } + c.Security.ProxyProbe.URLs = proxyProbeURLs if c.Server.ReadHeaderTimeout < 1 || c.Server.ReadHeaderTimeout > 60 { return fmt.Errorf("server.read_header_timeout must be between 1 and 60 seconds") } diff --git a/backend/internal/config/env_reachability_test.go b/backend/internal/config/env_reachability_test.go index 8141a2ec3d..cfe39587ed 100644 --- a/backend/internal/config/env_reachability_test.go +++ b/backend/internal/config/env_reachability_test.go @@ -45,6 +45,20 @@ func collectMapstructureKeys(t reflect.Type, prefix string, out map[string]strin // is out of scope here — such settings need a config file either way. continue } + if ft.Kind() == reflect.Slice { + elem := ft.Elem() + for elem.Kind() == reflect.Ptr { + elem = elem.Elem() + } + if elem.Kind() == reflect.Struct { + // AutomaticEnv exposes one string value. Viper's string-to-slice + // hook can populate scalar slices, but it cannot decode a string + // into []struct. Registering a default would turn silent ignore + // into a startup unmarshal error, so structured slices remain + // config-file-only just like maps. + continue + } + } out[strings.ToLower(key)] = ft.String() } } @@ -62,7 +76,7 @@ func collectMapstructureKeys(t reflect.Type, prefix string, out map[string]strin // were lost, silently disabling async image tasks for env-driven deployments. // // When this fails, register a zero-valued default in setEnvReachableDefaults -// for each reported key. +// for each reported scalar key. Maps and slices of structs are config-file-only. func TestConfigKeysAreEnvReachable(t *testing.T) { bound := map[string]string{} collectMapstructureKeys(reflect.TypeOf(Config{}), "", bound) diff --git a/backend/internal/config/proxy_probe_config_test.go b/backend/internal/config/proxy_probe_config_test.go new file mode 100644 index 0000000000..af5b97209e --- /dev/null +++ b/backend/internal/config/proxy_probe_config_test.go @@ -0,0 +1,47 @@ +//go:build unit + +package config + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNormalizeProxyProbeURLs(t *testing.T) { + t.Parallel() + + got, err := normalizeProxyProbeURLs([]ProbeURLConfig{ + {URL: " https://chatgpt.com/cdn-cgi/trace ", Parser: " CHATGPT-TRACE "}, + {URL: "https://api64.ipify.org?format=json", Parser: "ipify"}, + }) + require.NoError(t, err) + require.Equal(t, []ProbeURLConfig{ + {URL: "https://chatgpt.com/cdn-cgi/trace", Parser: "chatgpt-trace"}, + {URL: "https://api64.ipify.org?format=json", Parser: "ipify"}, + }, got) +} + +func TestNormalizeProxyProbeURLsRejectsInvalidEntries(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + target ProbeURLConfig + wantErr string + }{ + {name: "missing URL", target: ProbeURLConfig{Parser: "ipify"}, wantErr: "url is required"}, + {name: "missing parser", target: ProbeURLConfig{URL: "https://example.com"}, wantErr: "parser is required"}, + {name: "unknown parser", target: ProbeURLConfig{URL: "https://example.com", Parser: "ip_api"}, wantErr: "unsupported parser"}, + {name: "relative URL", target: ProbeURLConfig{URL: "/cdn-cgi/trace", Parser: "chatgpt-trace"}, wantErr: "invalid url"}, + {name: "unsupported scheme", target: ProbeURLConfig{URL: "ftp://example.com/file", Parser: "ipify"}, wantErr: "scheme must be http or https"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + _, err := normalizeProxyProbeURLs([]ProbeURLConfig{tt.target}) + require.ErrorContains(t, err, tt.wantErr) + }) + } +} diff --git a/backend/internal/handler/admin/channel_handler.go b/backend/internal/handler/admin/channel_handler.go index dfce024fa3..dc0e5b07e2 100644 --- a/backend/internal/handler/admin/channel_handler.go +++ b/backend/internal/handler/admin/channel_handler.go @@ -64,6 +64,8 @@ type channelModelPricingRequest struct { OutputPrice *float64 `json:"output_price" binding:"omitempty,min=0"` CacheWritePrice *float64 `json:"cache_write_price" binding:"omitempty,min=0"` CacheReadPrice *float64 `json:"cache_read_price" binding:"omitempty,min=0"` + FastMultiplier *float64 `json:"fast_multiplier" binding:"omitempty,gt=0"` + FlexMultiplier *float64 `json:"flex_multiplier" binding:"omitempty,gt=0"` ImageInputPrice *float64 `json:"image_input_price" binding:"omitempty,min=0"` ImageOutputPrice *float64 `json:"image_output_price" binding:"omitempty,min=0"` PerRequestPrice *float64 `json:"per_request_price" binding:"omitempty,min=0"` @@ -83,15 +85,19 @@ type channelTimePricingPeriodRequest struct { } type pricingIntervalRequest struct { - MinTokens int `json:"min_tokens"` - MaxTokens *int `json:"max_tokens"` - TierLabel string `json:"tier_label"` - InputPrice *float64 `json:"input_price"` - OutputPrice *float64 `json:"output_price"` - CacheWritePrice *float64 `json:"cache_write_price"` - CacheReadPrice *float64 `json:"cache_read_price"` - PerRequestPrice *float64 `json:"per_request_price"` - SortOrder int `json:"sort_order"` + MinTokens int `json:"min_tokens"` + MaxTokens *int `json:"max_tokens"` + TierLabel string `json:"tier_label"` + InputPrice *float64 `json:"input_price"` + OutputPrice *float64 `json:"output_price"` + CacheWritePrice *float64 `json:"cache_write_price"` + CacheReadPrice *float64 `json:"cache_read_price"` + InputMultiplier *float64 `json:"input_multiplier" binding:"omitempty,gt=0"` + OutputMultiplier *float64 `json:"output_multiplier" binding:"omitempty,gt=0"` + CacheWriteMultiplier *float64 `json:"cache_write_multiplier" binding:"omitempty,gt=0"` + CacheReadMultiplier *float64 `json:"cache_read_multiplier" binding:"omitempty,gt=0"` + PerRequestPrice *float64 `json:"per_request_price"` + SortOrder int `json:"sort_order"` } type accountStatsPricingRuleRequest struct { @@ -128,6 +134,8 @@ type channelModelPricingResponse struct { OutputPrice *float64 `json:"output_price"` CacheWritePrice *float64 `json:"cache_write_price"` CacheReadPrice *float64 `json:"cache_read_price"` + FastMultiplier *float64 `json:"fast_multiplier"` + FlexMultiplier *float64 `json:"flex_multiplier"` ImageInputPrice *float64 `json:"image_input_price"` ImageOutputPrice *float64 `json:"image_output_price"` PerRequestPrice *float64 `json:"per_request_price"` @@ -147,16 +155,20 @@ type channelTimePricingPeriodResponse struct { } type pricingIntervalResponse struct { - ID int64 `json:"id"` - MinTokens int `json:"min_tokens"` - MaxTokens *int `json:"max_tokens"` - TierLabel string `json:"tier_label,omitempty"` - InputPrice *float64 `json:"input_price"` - OutputPrice *float64 `json:"output_price"` - CacheWritePrice *float64 `json:"cache_write_price"` - CacheReadPrice *float64 `json:"cache_read_price"` - PerRequestPrice *float64 `json:"per_request_price"` - SortOrder int `json:"sort_order"` + ID int64 `json:"id"` + MinTokens int `json:"min_tokens"` + MaxTokens *int `json:"max_tokens"` + TierLabel string `json:"tier_label,omitempty"` + InputPrice *float64 `json:"input_price"` + OutputPrice *float64 `json:"output_price"` + CacheWritePrice *float64 `json:"cache_write_price"` + CacheReadPrice *float64 `json:"cache_read_price"` + InputMultiplier *float64 `json:"input_multiplier"` + OutputMultiplier *float64 `json:"output_multiplier"` + CacheWriteMultiplier *float64 `json:"cache_write_multiplier"` + CacheReadMultiplier *float64 `json:"cache_read_multiplier"` + PerRequestPrice *float64 `json:"per_request_price"` + SortOrder int `json:"sort_order"` } type accountStatsPricingRuleResponse struct { @@ -248,6 +260,8 @@ func pricingToResponse(p *service.ChannelModelPricing) channelModelPricingRespon OutputPrice: p.OutputPrice, CacheWritePrice: p.CacheWritePrice, CacheReadPrice: p.CacheReadPrice, + FastMultiplier: p.FastMultiplier, + FlexMultiplier: p.FlexMultiplier, ImageInputPrice: p.ImageInputPrice, ImageOutputPrice: p.ImageOutputPrice, PerRequestPrice: p.PerRequestPrice, @@ -273,20 +287,24 @@ func timePricingToResponse(value *service.ChannelTimePricing) *channelTimePricin func intervalToResponse(iv service.PricingInterval) pricingIntervalResponse { return pricingIntervalResponse{ - ID: iv.ID, - MinTokens: iv.MinTokens, - MaxTokens: iv.MaxTokens, - TierLabel: iv.TierLabel, - InputPrice: iv.InputPrice, - OutputPrice: iv.OutputPrice, - CacheWritePrice: iv.CacheWritePrice, - CacheReadPrice: iv.CacheReadPrice, - PerRequestPrice: iv.PerRequestPrice, - SortOrder: iv.SortOrder, + ID: iv.ID, + MinTokens: iv.MinTokens, + MaxTokens: iv.MaxTokens, + TierLabel: iv.TierLabel, + InputPrice: iv.InputPrice, + OutputPrice: iv.OutputPrice, + CacheWritePrice: iv.CacheWritePrice, + CacheReadPrice: iv.CacheReadPrice, + InputMultiplier: iv.InputMultiplier, + OutputMultiplier: iv.OutputMultiplier, + CacheWriteMultiplier: iv.CacheWriteMultiplier, + CacheReadMultiplier: iv.CacheReadMultiplier, + PerRequestPrice: iv.PerRequestPrice, + SortOrder: iv.SortOrder, } } -func pricingRequestToService(reqs []channelModelPricingRequest) []service.ChannelModelPricing { +func pricingRequestToService(reqs []channelModelPricingRequest, allowChannelMultipliers bool) []service.ChannelModelPricing { result := make([]service.ChannelModelPricing, 0, len(reqs)) for _, r := range reqs { billingMode := service.BillingMode(r.BillingMode) @@ -296,18 +314,34 @@ func pricingRequestToService(reqs []channelModelPricingRequest) []service.Channe platform := r.Platform intervals := make([]service.PricingInterval, 0, len(r.Intervals)) for _, iv := range r.Intervals { + var inputMultiplier, outputMultiplier, cacheWriteMultiplier, cacheReadMultiplier *float64 + if allowChannelMultipliers { + inputMultiplier = iv.InputMultiplier + outputMultiplier = iv.OutputMultiplier + cacheWriteMultiplier = iv.CacheWriteMultiplier + cacheReadMultiplier = iv.CacheReadMultiplier + } intervals = append(intervals, service.PricingInterval{ - MinTokens: iv.MinTokens, - MaxTokens: iv.MaxTokens, - TierLabel: iv.TierLabel, - InputPrice: iv.InputPrice, - OutputPrice: iv.OutputPrice, - CacheWritePrice: iv.CacheWritePrice, - CacheReadPrice: iv.CacheReadPrice, - PerRequestPrice: iv.PerRequestPrice, - SortOrder: iv.SortOrder, + MinTokens: iv.MinTokens, + MaxTokens: iv.MaxTokens, + TierLabel: iv.TierLabel, + InputPrice: iv.InputPrice, + OutputPrice: iv.OutputPrice, + CacheWritePrice: iv.CacheWritePrice, + CacheReadPrice: iv.CacheReadPrice, + InputMultiplier: inputMultiplier, + OutputMultiplier: outputMultiplier, + CacheWriteMultiplier: cacheWriteMultiplier, + CacheReadMultiplier: cacheReadMultiplier, + PerRequestPrice: iv.PerRequestPrice, + SortOrder: iv.SortOrder, }) } + var fastMultiplier, flexMultiplier *float64 + if allowChannelMultipliers { + fastMultiplier = r.FastMultiplier + flexMultiplier = r.FlexMultiplier + } result = append(result, service.ChannelModelPricing{ Platform: platform, Models: r.Models, @@ -316,6 +350,8 @@ func pricingRequestToService(reqs []channelModelPricingRequest) []service.Channe OutputPrice: r.OutputPrice, CacheWritePrice: r.CacheWritePrice, CacheReadPrice: r.CacheReadPrice, + FastMultiplier: fastMultiplier, + FlexMultiplier: flexMultiplier, ImageInputPrice: r.ImageInputPrice, ImageOutputPrice: r.ImageOutputPrice, PerRequestPrice: r.PerRequestPrice, @@ -346,7 +382,7 @@ func accountStatsPricingRuleRequestToService(r accountStatsPricingRuleRequest) s Name: r.Name, GroupIDs: r.GroupIDs, AccountIDs: r.AccountIDs, - Pricing: pricingRequestToService(r.Pricing), + Pricing: pricingRequestToService(r.Pricing, false), } } @@ -407,7 +443,7 @@ func (h *ChannelHandler) Create(c *gin.Context) { return } - pricing := pricingRequestToService(req.ModelPricing) + pricing := pricingRequestToService(req.ModelPricing, true) // Main model_pricing requires a platform; default to anthropic for backward compatibility. for i := range pricing { if pricing[i].Platform == "" { @@ -481,7 +517,7 @@ func (h *ChannelHandler) Update(c *gin.Context) { ApplyPricingToAccountStats: req.ApplyPricingToAccountStats, } if req.ModelPricing != nil { - pricing := pricingRequestToService(*req.ModelPricing) + pricing := pricingRequestToService(*req.ModelPricing, true) for i := range pricing { if pricing[i].Platform == "" { pricing[i].Platform = service.PlatformAnthropic diff --git a/backend/internal/handler/admin/channel_handler_test.go b/backend/internal/handler/admin/channel_handler_test.go index 6c5ddedc92..75aec7db31 100644 --- a/backend/internal/handler/admin/channel_handler_test.go +++ b/backend/internal/handler/admin/channel_handler_test.go @@ -305,7 +305,7 @@ func TestPricingRequestToService_Defaults(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := pricingRequestToService([]channelModelPricingRequest{tt.req}) + result := pricingRequestToService([]channelModelPricingRequest{tt.req}, true) require.Len(t, result, 1) switch tt.wantField { case "BillingMode": @@ -332,7 +332,7 @@ func TestPricingRequestToService_WithAllFields(t *testing.T) { }, } - result := pricingRequestToService(reqs) + result := pricingRequestToService(reqs, true) require.Len(t, result, 1) r := result[0] require.Equal(t, "openai", r.Platform) @@ -373,7 +373,7 @@ func TestPricingRequestToService_WithIntervals(t *testing.T) { }, } - result := pricingRequestToService(reqs) + result := pricingRequestToService(reqs, true) require.Len(t, result, 1) require.Len(t, result[0].Intervals, 2) @@ -396,7 +396,7 @@ func TestPricingRequestToService_WithIntervals(t *testing.T) { } func TestPricingRequestToService_EmptySlice(t *testing.T) { - result := pricingRequestToService([]channelModelPricingRequest{}) + result := pricingRequestToService([]channelModelPricingRequest{}, true) require.NotNil(t, result) require.Empty(t, result) } @@ -410,7 +410,7 @@ func TestPricingRequestToService_NilPriceFields(t *testing.T) { }, } - result := pricingRequestToService(reqs) + result := pricingRequestToService(reqs, true) require.Len(t, result, 1) r := result[0] require.Nil(t, r.InputPrice) @@ -433,16 +433,52 @@ func TestPricingRequestToService_TimePricing(t *testing.T) { }, } - got := pricingRequestToService([]channelModelPricingRequest{req}) + got := pricingRequestToService([]channelModelPricingRequest{req}, true) require.Equal(t, "Asia/Shanghai", got[0].TimePricing.Timezone) require.Equal(t, 2.0, got[0].TimePricing.Periods[0].Multiplier) } func TestPricingRequestToService_TimePricingNil(t *testing.T) { - got := pricingRequestToService([]channelModelPricingRequest{{Models: []string{"gpt-5"}}}) + got := pricingRequestToService([]channelModelPricingRequest{{Models: []string{"gpt-5"}}}, true) require.Nil(t, got[0].TimePricing) } +// 账号成本统计规则不支持倍率:allowChannelMultipliers=false 时必须丢弃, +// 避免渠道倍率意外污染账号成本口径。 +func TestPricingRequestToService_MultipliersGatedByFlag(t *testing.T) { + req := channelModelPricingRequest{ + Models: []string{"gpt-5"}, + BillingMode: "token", + FastMultiplier: float64Ptr(2.5), + FlexMultiplier: float64Ptr(0.5), + Intervals: []pricingIntervalRequest{{ + MinTokens: 272000, + InputMultiplier: float64Ptr(2), + OutputMultiplier: float64Ptr(1.5), + CacheWriteMultiplier: float64Ptr(2), + CacheReadMultiplier: float64Ptr(2), + }}, + } + + allowed := pricingRequestToService([]channelModelPricingRequest{req}, true) + require.Equal(t, float64Ptr(2.5), allowed[0].FastMultiplier) + require.Equal(t, float64Ptr(0.5), allowed[0].FlexMultiplier) + require.Equal(t, float64Ptr(2), allowed[0].Intervals[0].InputMultiplier) + require.Equal(t, float64Ptr(1.5), allowed[0].Intervals[0].OutputMultiplier) + require.Equal(t, float64Ptr(2), allowed[0].Intervals[0].CacheWriteMultiplier) + require.Equal(t, float64Ptr(2), allowed[0].Intervals[0].CacheReadMultiplier) + + dropped := pricingRequestToService([]channelModelPricingRequest{req}, false) + require.Nil(t, dropped[0].FastMultiplier) + require.Nil(t, dropped[0].FlexMultiplier) + require.Nil(t, dropped[0].Intervals[0].InputMultiplier) + require.Nil(t, dropped[0].Intervals[0].OutputMultiplier) + require.Nil(t, dropped[0].Intervals[0].CacheWriteMultiplier) + require.Nil(t, dropped[0].Intervals[0].CacheReadMultiplier) + // 非倍率字段不受开关影响 + require.Equal(t, 272000, dropped[0].Intervals[0].MinTokens) +} + func TestPricingToResponse_TimePricing(t *testing.T) { got := pricingToResponse(&service.ChannelModelPricing{ BillingMode: service.BillingModeToken, diff --git a/backend/internal/model/error_passthrough_rule.go b/backend/internal/model/error_passthrough_rule.go index aa202069bd..bb3a315771 100644 --- a/backend/internal/model/error_passthrough_rule.go +++ b/backend/internal/model/error_passthrough_rule.go @@ -1,7 +1,11 @@ // Package model 定义服务层使用的数据模型。 package model -import "time" +import ( + "time" + + "github.com/Wei-Shaw/sub2api/internal/domain" +) // ErrorPassthroughRule 全局错误透传规则 // 用于控制上游错误如何返回给客户端 @@ -32,16 +36,28 @@ const MatchModeAll = "all" // 支持的平台常量 const ( - PlatformAnthropic = "anthropic" - PlatformOpenAI = "openai" - PlatformGemini = "gemini" - PlatformAntigravity = "antigravity" - PlatformGrok = "grok" + PlatformAnthropic = domain.PlatformAnthropic + PlatformOpenAI = domain.PlatformOpenAI + PlatformGemini = domain.PlatformGemini + PlatformAntigravity = domain.PlatformAntigravity + PlatformGrok = domain.PlatformGrok + PlatformKimi = domain.PlatformKimi + PlatformZhipu = domain.PlatformZhipu + PlatformDeepseek = domain.PlatformDeepseek ) // AllPlatforms 返回所有支持的平台列表 func AllPlatforms() []string { - return []string{PlatformAnthropic, PlatformOpenAI, PlatformGemini, PlatformAntigravity, PlatformGrok} + return []string{ + PlatformAnthropic, + PlatformOpenAI, + PlatformGemini, + PlatformAntigravity, + PlatformGrok, + PlatformKimi, + PlatformZhipu, + PlatformDeepseek, + } } // Validate 验证规则配置的有效性 diff --git a/backend/internal/model/error_passthrough_rule_test.go b/backend/internal/model/error_passthrough_rule_test.go new file mode 100644 index 0000000000..fc9ff5b385 --- /dev/null +++ b/backend/internal/model/error_passthrough_rule_test.go @@ -0,0 +1,20 @@ +package model + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestAllPlatformsIncludesEveryConcretePlatform(t *testing.T) { + require.ElementsMatch(t, []string{ + "anthropic", + "openai", + "gemini", + "antigravity", + "grok", + "kimi", + "zhipu", + "deepseek", + }, AllPlatforms()) +} diff --git a/backend/internal/pkg/apicompat/responses_client_tools.go b/backend/internal/pkg/apicompat/responses_client_tools.go index 4a8b620905..b7ce024e8a 100644 --- a/backend/internal/pkg/apicompat/responses_client_tools.go +++ b/backend/internal/pkg/apicompat/responses_client_tools.go @@ -27,6 +27,13 @@ func AdaptResponsesClientTools(req map[string]any) (ResponsesClientToolMapping, if !ok || len(tools) == 0 { return ResponsesClientToolMapping{}, false, nil } + discovered, err := promoteResponsesToolSearchDiscoveries(req) + if err != nil { + return ResponsesClientToolMapping{}, false, err + } + if discovered { + tools, _ = req["tools"].([]any) + } adapter := ResponsesClientToolMapping{CustomTools: make(map[string]bool)} functionNames := make(map[string]bool) @@ -73,7 +80,7 @@ func AdaptResponsesClientTools(req map[string]any) (ResponsesClientToolMapping, tools, _ = req["tools"].([]any) lowered := make([]any, 0, len(tools)) - changed := flattened + changed := discovered || flattened seenSearch := false for _, raw := range tools { tool, ok := raw.(map[string]any) @@ -115,7 +122,11 @@ func AdaptResponsesClientTools(req map[string]any) (ResponsesClientToolMapping, if changed { req["tools"] = lowered } - if rewriteClientToolHistory(req["input"], &adapter) { + historyChanged, err := rewriteClientToolHistory(req["input"], &adapter) + if err != nil { + return ResponsesClientToolMapping{}, false, err + } + if historyChanged { changed = true } if rewriteClientToolChoice(req, &adapter) { @@ -138,6 +149,7 @@ func AdaptResponsesClientTools(req map[string]any) (ResponsesClientToolMapping, func AdaptResponsesClientToolsWithInheritedMapping( req map[string]any, inherited ResponsesClientToolMapping, + inheritedLoweredTools ...[]any, ) (ResponsesClientToolMapping, bool, error) { if req == nil { return ResponsesClientToolMapping{}, false, nil @@ -148,8 +160,15 @@ func AdaptResponsesClientToolsWithInheritedMapping( if len(inherited.CustomTools) == 0 && !inherited.ToolSearch && len(inherited.NamespaceTools) == 0 { return ResponsesClientToolMapping{}, false, nil } + if len(inheritedLoweredTools) > 0 && len(inheritedLoweredTools[0]) > 0 { + req["tools"] = restoreInheritedResponsesClientToolDeclarations(inheritedLoweredTools[0], inherited) + return AdaptResponsesClientTools(req) + } - changed := rewriteClientToolHistory(req["input"], &inherited) + changed, err := rewriteClientToolHistory(req["input"], &inherited) + if err != nil { + return ResponsesClientToolMapping{}, false, err + } if len(inherited.NamespaceTools) > 0 { before := changed rewriteNamespaceQualifiedCalls(req["input"], inherited.NamespaceTools) @@ -174,14 +193,16 @@ func copyClientTool(tool map[string]any) map[string]any { return copy } -func rewriteClientToolHistory(value any, adapter *ResponsesClientToolMapping) bool { +func rewriteClientToolHistory(value any, adapter *ResponsesClientToolMapping) (bool, error) { changed := false - var visit func(any) - visit = func(value any) { + var visit func(any) error + visit = func(value any) error { switch typed := value.(type) { case []any: for _, item := range typed { - visit(item) + if err := visit(item); err != nil { + return err + } } case map[string]any: typ := strings.TrimSpace(stringValue(typed["type"])) @@ -210,19 +231,30 @@ func rewriteClientToolHistory(value any, adapter *ResponsesClientToolMapping) bo } case "tool_search_output": if adapter.ToolSearch { + callID := strings.TrimSpace(stringValue(typed["call_id"])) + if callID == "" { + return fmt.Errorf("tool_search_output requires a non-empty string call_id before it can be lowered to function_call_output") + } typed["type"] = "function_call_output" dropInvalidLoweredFunctionItemID(typed) - normalizeToolSearchOutput(typed) + if err := normalizeToolSearchOutput(typed); err != nil { + return err + } changed = true } } for _, child := range typed { - visit(child) + if err := visit(child); err != nil { + return err + } } } + return nil } - visit(value) - return changed + if err := visit(value); err != nil { + return false, err + } + return changed, nil } // dropInvalidLoweredFunctionItemID removes Codex client-only item IDs such as @@ -256,15 +288,42 @@ func normalizeClientToolOutput(item map[string]any) { item["output"] = string(encoded) } -func normalizeToolSearchOutput(item map[string]any) { - if _, exists := item["output"]; !exists { - if tools, hasTools := item["tools"]; hasTools { - item["output"] = tools - } else { - return +// normalizeToolSearchOutput converts both tool_search output wire shapes into +// the string output required by function_call_output. Older clients send an +// output field directly; newer Codex clients return discovered definitions in +// a top-level tools field. Codex treats that field's value as the tool output, +// so serialize the value directly rather than wrapping it in another object. +func normalizeToolSearchOutput(item map[string]any) error { + if output, hasOutput := item["output"]; hasOutput { + switch typed := output.(type) { + case string: + item["output"] = typed + case nil: + item["output"] = "" + default: + encoded, err := json.Marshal(typed) + if err != nil { + return fmt.Errorf("tool_search_output output cannot be encoded as function_call_output output: %w", err) + } + item["output"] = string(encoded) } + dropToolSearchOutputPrivateFields(item) + return nil } - normalizeClientToolOutput(item) + tools, hasTools := item["tools"] + if !hasTools { + return fmt.Errorf("tool_search_output requires output or tools before it can be lowered to function_call_output") + } + encoded, err := json.Marshal(tools) + if err != nil { + return fmt.Errorf("tool_search_output tools cannot be encoded as function_call_output output: %w", err) + } + item["output"] = string(encoded) + dropToolSearchOutputPrivateFields(item) + return nil +} + +func dropToolSearchOutputPrivateFields(item map[string]any) { delete(item, "tools") delete(item, "status") delete(item, "execution") diff --git a/backend/internal/pkg/apicompat/responses_client_tools_test.go b/backend/internal/pkg/apicompat/responses_client_tools_test.go index b52f873627..5b0dac4356 100644 --- a/backend/internal/pkg/apicompat/responses_client_tools_test.go +++ b/backend/internal/pkg/apicompat/responses_client_tools_test.go @@ -66,66 +66,323 @@ func TestAdaptResponsesClientTools_LowersDeclarationsHistoryChoiceAndNamespaces( require.Equal(t, "team__send", namespaceCall["name"]) } -func TestAdaptResponsesClientTools_LowersToolSearchDiscoveryPayloadWithoutOutput(t *testing.T) { - req := map[string]any{ - "tools": []any{map[string]any{"type": "tool_search"}}, - "input": []any{map[string]any{ - "type": "tool_search_output", - "call_id": "search_1", - "tools": []any{map[string]any{"name": "github", "type": "namespace"}}, - "status": "completed", - "execution": "client", +func TestAdaptResponsesClientTools_LowersDiscoveredToolSearchOutput(t *testing.T) { + requestJSON := `{ + "tools":[{"type":"tool_search"}], + "input":[ + {"type":"tool_search_call","id":"tsc_client","call_id":"call_search","arguments":{"query":"codex app"},"execution":"client","status":"completed"}, + {"type":"tool_search_output","id":"tso_client","call_id":"call_search","execution":"client","status":"completed","tools":[ + {"type":"namespace","name":"codex_app","tools":[{"type":"function","name":"load_workspace_dependencies","description":"Load workspace dependencies","parameters":{"type":"object","properties":{},"additionalProperties":false}}]}, + {"type":"namespace","name":"multi_agent_v1","tools":[ + {"type":"function","name":"spawn_agent","description":"Spawn an agent","parameters":{"type":"object","properties":{"message":{"type":"string"}},"required":["message"],"additionalProperties":false}}, + {"type":"function","name":"wait_agent","description":"Wait for agents","parameters":{"type":"object","properties":{},"additionalProperties":false}} + ]} + ]} + ] + }` + + type adaptedRequest struct { + req map[string]any + mapping ResponsesClientToolMapping + } + adapt := func() adaptedRequest { + var req map[string]any + require.NoError(t, json.Unmarshal([]byte(requestJSON), &req)) + mapping, changed, err := AdaptResponsesClientTools(req) + require.NoError(t, err) + require.True(t, changed) + return adaptedRequest{req: req, mapping: mapping} + } + + first := adapt() + second := adapt() + firstInput := requireResponsesClientToolValue[[]any](t, first.req["input"]) + secondInput := requireResponsesClientToolValue[[]any](t, second.req["input"]) + + tools := requireResponsesClientToolValue[[]any](t, first.req["tools"]) + require.Len(t, tools, 4) + require.Equal(t, []string{ + "tool_search", + "codex_app__load_workspace_dependencies", + "multi_agent_v1__spawn_agent", + "multi_agent_v1__wait_agent", + }, responsesClientToolNames(t, tools)) + require.Equal(t, ResponsesNamespaceName{Namespace: "multi_agent_v1", Name: "spawn_agent"}, first.mapping.NamespaceTools["multi_agent_v1__spawn_agent"]) + require.Equal(t, ResponsesNamespaceName{Namespace: "multi_agent_v1", Name: "wait_agent"}, first.mapping.NamespaceTools["multi_agent_v1__wait_agent"]) + + call := requireResponsesClientToolValue[map[string]any](t, firstInput[0]) + require.Equal(t, "function_call", call["type"]) + require.Equal(t, toolSearchProxyName, call["name"]) + require.JSONEq(t, `{"query":"codex app"}`, requireResponsesClientToolValue[string](t, call["arguments"])) + require.NotContains(t, call, "execution") + + output := requireResponsesClientToolValue[map[string]any](t, firstInput[1]) + require.Equal(t, map[string]any{ + "type": "function_call_output", + "call_id": "call_search", + "output": output["output"], + }, output) + outputText := requireResponsesClientToolValue[string](t, output["output"]) + require.JSONEq(t, `[ + {"type":"namespace","name":"codex_app","tools":[{"type":"function","name":"load_workspace_dependencies","description":"Load workspace dependencies","parameters":{"type":"object","properties":{},"additionalProperties":false}}]}, + {"type":"namespace","name":"multi_agent_v1","tools":[ + {"type":"function","name":"spawn_agent","description":"Spawn an agent","parameters":{"type":"object","properties":{"message":{"type":"string"}},"required":["message"],"additionalProperties":false}}, + {"type":"function","name":"wait_agent","description":"Wait for agents","parameters":{"type":"object","properties":{},"additionalProperties":false}} + ]} + ]`, outputText) + secondOutput := requireResponsesClientToolValue[map[string]any](t, secondInput[1]) + require.Equal(t, outputText, secondOutput["output"], "tool discovery output encoding must be deterministic") + + restored, changed, err := RestoreResponsesClientToolPayload( + []byte(`{"output":[{"type":"function_call","name":"multi_agent_v1__spawn_agent","call_id":"call_spawn","arguments":"{\"message\":\"work\"}"}]}`), + first.mapping, + ) + require.NoError(t, err) + require.True(t, changed) + require.JSONEq(t, `{"output":[{"type":"function_call","name":"spawn_agent","namespace":"multi_agent_v1","call_id":"call_spawn","arguments":"{\"message\":\"work\"}"}]}`, string(restored)) +} + +func TestAdaptResponsesClientTools_PromotesDirectDiscoveryAndDeduplicatesIdenticalDeclarations(t *testing.T) { + direct := map[string]any{ + "type": "function", "name": "inspect_result", "description": "Inspect a result", + "parameters": map[string]any{"type": "object", "properties": map[string]any{}}, + } + custom := map[string]any{ + "type": "custom", "name": "run_script", "description": "Run a script", + "format": map[string]any{"type": "grammar"}, + } + namespace := map[string]any{ + "type": "namespace", "name": "multi_agent_v1", "tools": []any{map[string]any{ + "type": "function", "name": "spawn_agent", "parameters": map[string]any{"type": "object"}, }}, } + req := map[string]any{ + "tools": []any{ + map[string]any{"type": "function", "name": "static_first", "parameters": map[string]any{"type": "object"}}, + map[string]any{"type": "tool_search"}, + }, + "input": []any{ + map[string]any{"type": "tool_search_output", "status": "completed", "call_id": "search_1", "tools": []any{direct, custom, namespace}}, + map[string]any{"type": "tool_search_output", "status": "completed", "call_id": "search_2", "tools": []any{copyClientTool(direct), copyClientTool(custom), copyClientTool(namespace)}}, + }, + } mapping, changed, err := AdaptResponsesClientTools(req) require.NoError(t, err) require.True(t, changed) - require.True(t, mapping.ToolSearch) - - item := requireResponsesClientToolValue[map[string]any](t, requireResponsesClientToolValue[[]any](t, req["input"])[0]) - require.Equal(t, "function_call_output", item["type"]) - require.Equal(t, "search_1", item["call_id"]) - require.JSONEq(t, `[{"name":"github","type":"namespace"}]`, requireResponsesClientToolValue[string](t, item["output"])) - require.NotContains(t, item, "tools") - require.NotContains(t, item, "status") - require.NotContains(t, item, "execution") + require.True(t, mapping.CustomTools["run_script"]) + require.Equal(t, ResponsesNamespaceName{Namespace: "multi_agent_v1", Name: "spawn_agent"}, mapping.NamespaceTools["multi_agent_v1__spawn_agent"]) + tools := requireResponsesClientToolValue[[]any](t, req["tools"]) + require.Equal(t, []string{"static_first", "tool_search", "inspect_result", "run_script", "multi_agent_v1__spawn_agent"}, responsesClientToolNames(t, tools)) + customTool := requireResponsesClientToolValue[map[string]any](t, tools[3]) + require.Equal(t, "function", customTool["type"]) + require.NotContains(t, customTool, "format") + for _, raw := range requireResponsesClientToolValue[[]any](t, req["input"]) { + item := requireResponsesClientToolValue[map[string]any](t, raw) + require.Equal(t, "function_call_output", item["type"]) + require.NotContains(t, item, "tools") + require.NotContains(t, item, "status") + } } -func TestAdaptResponsesClientTools_ToolSearchExplicitOutputTakesPriority(t *testing.T) { +func TestAdaptResponsesClientTools_RejectsDiscoveredSchemaAndNamespaceCollisions(t *testing.T) { + tests := []struct { + name string + staticTools []any + discovered []any + }{ + { + name: "direct schema collision", + staticTools: []any{map[string]any{ + "type": "function", "name": "inspect", "parameters": map[string]any{"type": "object"}, + }}, + discovered: []any{map[string]any{ + "type": "function", "name": "inspect", "parameters": map[string]any{"type": "string"}, + }}, + }, + { + name: "namespace schema collision", + staticTools: []any{map[string]any{ + "type": "namespace", "name": "multi_agent_v1", "tools": []any{map[string]any{ + "type": "function", "name": "spawn_agent", "parameters": map[string]any{"type": "object"}, + }}, + }}, + discovered: []any{map[string]any{ + "type": "namespace", "name": "multi_agent_v1", "tools": []any{map[string]any{ + "type": "function", "name": "spawn_agent", "parameters": map[string]any{"type": "string"}, + }}, + }}, + }, + { + name: "flattened namespace collision", + staticTools: []any{map[string]any{"type": "function", "name": "multi_agent_v1__spawn_agent"}}, + discovered: []any{map[string]any{ + "type": "namespace", "name": "multi_agent_v1", "tools": []any{map[string]any{"type": "function", "name": "spawn_agent"}}, + }}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := map[string]any{ + "tools": append(tt.staticTools, map[string]any{"type": "tool_search"}), + "input": []any{map[string]any{ + "type": "tool_search_output", "status": "completed", "tools": tt.discovered, + }}, + } + _, _, err := AdaptResponsesClientTools(req) + require.ErrorContains(t, err, "conflicts") + }) + } +} + +func TestAdaptResponsesClientTools_DoesNotPromoteUnusableDiscoveries(t *testing.T) { req := map[string]any{ "tools": []any{map[string]any{"type": "tool_search"}}, - "input": []any{map[string]any{ - "type": "tool_search_output", "call_id": "search_1", - "output": "authoritative", "tools": []any{map[string]any{"name": "ignored"}}, - }}, + "input": []any{ + map[string]any{"type": "tool_search_output", "call_id": "search_in_progress", "status": "in_progress", "tools": []any{map[string]any{"type": "function", "name": "not_ready"}}}, + map[string]any{"type": "tool_search_output", "call_id": "search_malformed", "status": "completed", "tools": []any{map[string]any{"type": "function"}}}, + }, } _, changed, err := AdaptResponsesClientTools(req) require.NoError(t, err) - require.True(t, changed) - item := requireResponsesClientToolValue[map[string]any](t, requireResponsesClientToolValue[[]any](t, req["input"])[0]) - require.Equal(t, "authoritative", item["output"]) - require.NotContains(t, item, "tools") + require.True(t, changed, "the static tool_search declaration is still lowered") + tools := requireResponsesClientToolValue[[]any](t, req["tools"]) + require.Equal(t, []string{"tool_search"}, responsesClientToolNames(t, tools)) } -func TestAdaptResponsesClientTools_DoesNotInventMissingToolSearchOutput(t *testing.T) { - req := map[string]any{ - "tools": []any{map[string]any{"type": "tool_search"}}, - "input": []any{map[string]any{ - "type": "tool_search_output", "call_id": "search_1", - "status": "incomplete", "execution": "client", - }}, +func responsesClientToolNames(t *testing.T, tools []any) []string { + t.Helper() + names := make([]string, 0, len(tools)) + for _, raw := range tools { + tool := requireResponsesClientToolValue[map[string]any](t, raw) + names = append(names, requireResponsesClientToolValue[string](t, tool["name"])) + } + return names +} + +func TestAdaptResponsesClientTools_ToolSearchOutputEdgeCases(t *testing.T) { + unencodableOutput := make(chan struct{}) + tests := []struct { + name string + item map[string]any + wantOutput any + wantOutputExists bool + wantPrivateKeys []string + wantExactOutput bool + wantErr bool + }{ + { + name: "absent tools and output is rejected", + item: map[string]any{"type": "tool_search_output", "call_id": "call_empty", "status": "completed"}, + wantOutputExists: false, + wantErr: true, + }, + { + name: "preexisting string output wins", + item: map[string]any{ + "type": "tool_search_output", "call_id": "call_legacy", "output": "legacy", + "tools": []any{map[string]any{"type": "function", "name": "ignored"}}, "execution": "client", + }, + wantOutput: "legacy", + wantOutputExists: true, + wantExactOutput: true, + }, + { + name: "preexisting object output remains legacy representation", + item: map[string]any{ + "type": "tool_search_output", "call_id": "call_object", "output": map[string]any{"groups": []any{"github"}}, + "tools": []any{map[string]any{"type": "function", "name": "ignored"}}, + }, + wantOutput: `{"groups":["github"]}`, + wantOutputExists: true, + wantExactOutput: true, + }, + { + name: "unencodable preexisting output is rejected", + item: map[string]any{ + "type": "tool_search_output", "call_id": "call_bad_output", "output": unencodableOutput, + "tools": []any{map[string]any{"type": "function", "name": "retained"}}, "status": "completed", "execution": "client", + }, + wantOutput: unencodableOutput, + wantErr: true, + }, + { + name: "empty tools array is a valid empty output", + item: map[string]any{ + "type": "tool_search_output", "call_id": "call_empty_tools", + "tools": []any{}, "status": "completed", "execution": "client", + }, + wantOutput: `[]`, + wantOutputExists: true, + wantExactOutput: true, + }, + { + name: "non-array tools value is serialized directly", + item: map[string]any{ + "type": "tool_search_output", "call_id": "call_malformed", + "tools": map[string]any{"unexpected": true}, "status": "completed", "execution": "client", + }, + wantOutput: `{"unexpected":true}`, + wantOutputExists: true, + wantExactOutput: true, + }, + { + name: "unencodable tools is rejected", + item: map[string]any{ + "type": "tool_search_output", "call_id": "call_unencodable", "tools": make(chan struct{}), "status": "completed", + }, + wantErr: true, + }, + { + name: "missing call id is rejected", + item: map[string]any{ + "type": "tool_search_output", "tools": []any{}, "status": "completed", + }, + wantErr: true, + }, } - _, changed, err := AdaptResponsesClientTools(req) - require.NoError(t, err) - require.True(t, changed) - item := requireResponsesClientToolValue[map[string]any](t, requireResponsesClientToolValue[[]any](t, req["input"])[0]) - require.Equal(t, "function_call_output", item["type"]) - require.NotContains(t, item, "output") - require.Equal(t, "incomplete", item["status"]) - require.Equal(t, "client", item["execution"]) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := map[string]any{ + "tools": []any{map[string]any{"type": "tool_search"}}, + "input": []any{tt.item}, + } + _, changed, err := AdaptResponsesClientTools(req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.True(t, changed) + input := requireResponsesClientToolValue[[]any](t, req["input"]) + output := requireResponsesClientToolValue[map[string]any](t, input[0]) + require.Equal(t, "function_call_output", output["type"]) + actualOutput, outputExists := output["output"] + require.Equal(t, tt.wantOutputExists, outputExists) + if tt.wantOutputExists { + require.Equal(t, tt.wantOutput, actualOutput) + } + if tt.wantExactOutput { + require.Equal(t, map[string]any{ + "type": "function_call_output", + "call_id": output["call_id"], + "output": tt.wantOutput, + }, output) + } + if len(tt.wantPrivateKeys) > 0 { + for _, key := range tt.wantPrivateKeys { + require.Contains(t, output, key) + } + } else { + require.NotContains(t, output, "tools") + require.NotContains(t, output, "status") + require.NotContains(t, output, "execution") + } + }) + } } func requireResponsesClientToolValue[T any](t *testing.T, value any) T { @@ -179,6 +436,47 @@ func TestAdaptResponsesClientToolsWithInheritedMapping_LowersFollowupHistoryWith require.JSONEq(t, `[{"text":"ok","type":"input_text"}]`, requireResponsesClientToolValue[string](t, output["output"])) } +func TestAdaptResponsesClientToolsWithInheritedMapping_PromotesOmittedToolsDiscoveryIntoEffectiveDeclarations(t *testing.T) { + req := map[string]any{ + "input": []any{map[string]any{ + "type": "tool_search_output", "call_id": "call_search", "status": "completed", "execution": "client", + "tools": []any{map[string]any{ + "type": "namespace", "name": "multi_agent_v1", "tools": []any{map[string]any{ + "type": "function", "name": "spawn_agent", "parameters": map[string]any{"type": "object"}, + }}, + }}, + }}, + } + inherited := ResponsesClientToolMapping{ + ToolSearch: true, + NamespaceTools: map[string]ResponsesNamespaceName{ + "codex_app__read_resource": {Namespace: "codex_app", Name: "read_resource"}, + }, + } + lowered := []any{ + map[string]any{"type": "function", "name": "static_first", "parameters": map[string]any{"type": "object"}}, + map[string]any{"type": "function", "name": "tool_search", "parameters": json.RawMessage(toolSearchProxySchema)}, + map[string]any{"type": "function", "name": "codex_app__read_resource", "parameters": map[string]any{"type": "object"}}, + } + + mapping, changed, err := AdaptResponsesClientToolsWithInheritedMapping(req, inherited, lowered) + require.NoError(t, err) + require.True(t, changed) + require.True(t, mapping.ToolSearch) + require.Equal(t, ResponsesNamespaceName{Namespace: "codex_app", Name: "read_resource"}, mapping.NamespaceTools["codex_app__read_resource"]) + require.Equal(t, ResponsesNamespaceName{Namespace: "multi_agent_v1", Name: "spawn_agent"}, mapping.NamespaceTools["multi_agent_v1__spawn_agent"]) + tools := requireResponsesClientToolValue[[]any](t, req["tools"]) + require.Equal(t, []string{ + "static_first", "tool_search", "codex_app__read_resource", "multi_agent_v1__spawn_agent", + }, responsesClientToolNames(t, tools)) + output := requireResponsesClientToolValue[map[string]any](t, requireResponsesClientToolValue[[]any](t, req["input"])[0]) + require.Equal(t, "function_call_output", output["type"]) + require.IsType(t, "", output["output"]) + require.NotContains(t, output, "tools") + require.NotContains(t, output, "status") + require.NotContains(t, output, "execution") +} + func TestAdaptResponsesClientToolsWithInheritedMapping_ExplicitToolsReplaceInheritedMapping(t *testing.T) { req := map[string]any{ "tools": []any{}, @@ -200,6 +498,28 @@ func TestAdaptResponsesClientToolsWithInheritedMapping_ExplicitToolsReplaceInher require.Equal(t, "custom_tool_call", call["type"]) } +func TestAdaptResponsesClientToolsWithInheritedMapping_ExplicitToolResetDoesNotPromoteDiscovery(t *testing.T) { + for _, reset := range []any{nil, []any{}} { + req := map[string]any{ + "tools": reset, + "input": []any{map[string]any{ + "type": "tool_search_output", "call_id": "call_reset", "status": "completed", + "tools": []any{map[string]any{"type": "function", "name": "must_not_promote"}}, + }}, + } + mapping, changed, err := AdaptResponsesClientToolsWithInheritedMapping( + req, + ResponsesClientToolMapping{ToolSearch: true}, + []any{map[string]any{"type": "function", "name": "tool_search"}}, + ) + require.NoError(t, err) + require.False(t, changed) + require.Empty(t, mapping) + item := requireResponsesClientToolValue[map[string]any](t, requireResponsesClientToolValue[[]any](t, req["input"])[0]) + require.Equal(t, "tool_search_output", item["type"]) + } +} + func TestRestoreResponsesClientToolPayload_RestoresClientAndNamespaceCalls(t *testing.T) { mapping := ResponsesClientToolMapping{ CustomTools: map[string]bool{"exec": true}, ToolSearch: true, diff --git a/backend/internal/pkg/apicompat/responses_tool_search_discoveries.go b/backend/internal/pkg/apicompat/responses_tool_search_discoveries.go new file mode 100644 index 0000000000..2617d5f940 --- /dev/null +++ b/backend/internal/pkg/apicompat/responses_tool_search_discoveries.go @@ -0,0 +1,263 @@ +package apicompat + +import ( + "encoding/json" + "fmt" + "strings" +) + +type responsesDiscoveredToolIdentity struct { + typ string + name string + namespace string + encoded string + ambiguous bool +} + +// promoteResponsesToolSearchDiscoveries makes successfully discovered client +// tools callable for function-only upstreams. The original tool_search_output +// remains request history and is normalized separately; declarations are +// appended after static tools so the client's declaration order stays stable. +func promoteResponsesToolSearchDiscoveries(req map[string]any) (bool, error) { + tools, ok := req["tools"].([]any) + if !ok || len(tools) == 0 || !hasResponsesToolSearchDeclaration(tools) { + return false, nil + } + input, ok := req["input"].([]any) + if !ok || len(input) == 0 { + return false, nil + } + + known := make(map[string]responsesDiscoveredToolIdentity) + for _, raw := range tools { + registerExistingResponsesToolIdentity(known, raw) + } + + promoted := make([]any, 0) + for _, rawItem := range input { + item, ok := rawItem.(map[string]any) + if !ok || strings.TrimSpace(stringValue(item["type"])) != "tool_search_output" || !usableResponsesToolSearchOutput(item) { + continue + } + discoveries, ok := item["tools"].([]any) + if !ok || len(discoveries) == 0 { + continue + } + if _, err := json.Marshal(discoveries); err != nil { + continue + } + for _, rawDiscovery := range discoveries { + discovery, ok := rawDiscovery.(map[string]any) + if !ok { + continue + } + typ := strings.TrimSpace(stringValue(discovery["type"])) + switch typ { + case "function", "custom": + copy, identity, ok := responsesDirectToolDiscovery(discovery, typ) + if !ok { + continue + } + appendTool, err := admitResponsesDiscoveredTool(known, identity.name, identity) + if err != nil { + return false, err + } + if appendTool { + promoted = append(promoted, copy) + } + case "namespace": + copy, identities, ok := responsesNamespaceToolDiscovery(discovery) + if !ok { + continue + } + children := make([]any, 0, len(identities)) + for _, candidate := range identities { + appendTool, err := admitResponsesDiscoveredTool(known, candidate.flat, candidate.identity) + if err != nil { + return false, err + } + if appendTool { + children = append(children, candidate.child) + } + } + if len(children) > 0 { + copy["tools"] = children + delete(copy, "children") + promoted = append(promoted, copy) + } + } + } + } + if len(promoted) == 0 { + return false, nil + } + req["tools"] = append(tools, promoted...) + return true, nil +} + +func hasResponsesToolSearchDeclaration(tools []any) bool { + for _, raw := range tools { + tool, ok := raw.(map[string]any) + if ok && strings.TrimSpace(stringValue(tool["type"])) == "tool_search" { + return true + } + } + return false +} + +func usableResponsesToolSearchOutput(item map[string]any) bool { + status, present := item["status"] + if !present { + return true + } + text, ok := status.(string) + return ok && strings.TrimSpace(text) == "completed" +} + +func registerExistingResponsesToolIdentity(known map[string]responsesDiscoveredToolIdentity, raw any) { + tool, ok := raw.(map[string]any) + if !ok { + return + } + typ := strings.TrimSpace(stringValue(tool["type"])) + switch typ { + case "function", "custom": + _, identity, ok := responsesDirectToolDiscovery(tool, typ) + if ok { + registerExistingResponsesIdentity(known, identity.name, identity) + } + case "namespace": + _, identities, ok := responsesNamespaceToolDiscovery(tool) + if !ok { + return + } + for _, candidate := range identities { + registerExistingResponsesIdentity(known, candidate.flat, candidate.identity) + } + } +} + +func registerExistingResponsesIdentity(known map[string]responsesDiscoveredToolIdentity, key string, identity responsesDiscoveredToolIdentity) { + previous, exists := known[key] + if !exists { + known[key] = identity + return + } + if !sameResponsesDiscoveredTool(previous, identity) { + previous.ambiguous = true + known[key] = previous + } +} + +func admitResponsesDiscoveredTool(known map[string]responsesDiscoveredToolIdentity, key string, identity responsesDiscoveredToolIdentity) (bool, error) { + previous, exists := known[key] + if !exists { + known[key] = identity + return true, nil + } + if !previous.ambiguous && sameResponsesDiscoveredTool(previous, identity) { + return false, nil + } + return false, fmt.Errorf("discovered tool %q conflicts with an existing declaration; this upstream cannot safely disambiguate different names, namespaces, or schemas", key) +} + +func sameResponsesDiscoveredTool(left, right responsesDiscoveredToolIdentity) bool { + return left.typ == right.typ && left.name == right.name && left.namespace == right.namespace && left.encoded == right.encoded +} + +func responsesDirectToolDiscovery(tool map[string]any, typ string) (map[string]any, responsesDiscoveredToolIdentity, bool) { + name := strings.TrimSpace(stringValue(tool["name"])) + if name == "" { + return nil, responsesDiscoveredToolIdentity{}, false + } + copy := copyClientTool(tool) + copy["type"] = typ + copy["name"] = name + identityCopy := copy + if typ == "custom" { + identityCopy = copyClientTool(copy) + identityCopy["type"] = "function" + identityCopy["parameters"] = json.RawMessage(customToolInputSchema) + delete(identityCopy, "format") + } + encoded, err := json.Marshal(identityCopy) + if err != nil { + return nil, responsesDiscoveredToolIdentity{}, false + } + return copy, responsesDiscoveredToolIdentity{typ: typ, name: name, encoded: string(encoded)}, true +} + +// restoreInheritedResponsesClientToolDeclarations reverses only the declaration +// identities recorded by ResponsesClientToolMapping. It is used when a WS +// continuation omits tools but the HTTP function upstream still needs the +// effective session declarations on every request. +func restoreInheritedResponsesClientToolDeclarations(lowered []any, mapping ResponsesClientToolMapping) []any { + restored := make([]any, 0, len(lowered)) + for _, raw := range lowered { + tool, ok := raw.(map[string]any) + if !ok { + restored = append(restored, raw) + continue + } + name := strings.TrimSpace(stringValue(tool["name"])) + switch { + case mapping.ToolSearch && name == toolSearchProxyName: + restored = append(restored, map[string]any{"type": "tool_search"}) + case mapping.CustomTools[name]: + copy := copyClientTool(tool) + copy["type"] = "custom" + restored = append(restored, copy) + case mapping.NamespaceTools[name].Namespace != "": + identity := mapping.NamespaceTools[name] + child := copyClientTool(tool) + child["type"] = "function" + child["name"] = identity.Name + restored = append(restored, map[string]any{ + "type": "namespace", "name": identity.Namespace, "tools": []any{child}, + }) + default: + restored = append(restored, copyClientTool(tool)) + } + } + return restored +} + +type responsesNamespaceToolCandidate struct { + flat string + child map[string]any + identity responsesDiscoveredToolIdentity +} + +func responsesNamespaceToolDiscovery(tool map[string]any) (map[string]any, []responsesNamespaceToolCandidate, bool) { + namespace := strings.TrimSpace(stringValue(tool["name"])) + children := namespaceChildren(tool) + if namespace == "" || len(children) == 0 { + return nil, nil, false + } + copy := copyClientTool(tool) + copy["type"] = "namespace" + copy["name"] = namespace + identities := make([]responsesNamespaceToolCandidate, 0, len(children)) + for _, rawChild := range children { + child, ok := rawChild.(map[string]any) + if !ok || strings.TrimSpace(stringValue(child["type"])) != "function" { + continue + } + childCopy, direct, ok := responsesDirectToolDiscovery(child, "function") + if !ok { + continue + } + flat := flattenNamespaceToolName(namespace, direct.name) + identities = append(identities, responsesNamespaceToolCandidate{ + flat: flat, + child: childCopy, + identity: responsesDiscoveredToolIdentity{ + typ: "namespace", name: direct.name, namespace: namespace, encoded: direct.encoded, + }, + }) + } + if len(identities) == 0 { + return nil, nil, false + } + return copy, identities, true +} diff --git a/backend/internal/repository/channel_repo_pricing.go b/backend/internal/repository/channel_repo_pricing.go index b7a9fd6b02..e25642085a 100644 --- a/backend/internal/repository/channel_repo_pricing.go +++ b/backend/internal/repository/channel_repo_pricing.go @@ -16,7 +16,7 @@ import ( func (r *channelRepository) ListModelPricing(ctx context.Context, channelID int64) ([]service.ChannelModelPricing, error) { rows, err := r.db.QueryContext(ctx, - `SELECT id, channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_read_price, image_input_price, image_output_price, per_request_price, time_pricing, created_at, updated_at + `SELECT id, channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_read_price, fast_multiplier, flex_multiplier, image_input_price, image_output_price, per_request_price, time_pricing, created_at, updated_at FROM channel_model_pricing WHERE channel_id = $1 ORDER BY id`, channelID, ) if err != nil { @@ -61,10 +61,11 @@ func (r *channelRepository) UpdateModelPricing(ctx context.Context, pricing *ser } result, err := r.db.ExecContext(ctx, `UPDATE channel_model_pricing - SET models = $1, billing_mode = $2, input_price = $3, output_price = $4, cache_write_price = $5, cache_read_price = $6, image_input_price = $7, image_output_price = $8, per_request_price = $9, time_pricing = $10, platform = $11, updated_at = NOW() - WHERE id = $12`, + SET models = $1, billing_mode = $2, input_price = $3, output_price = $4, cache_write_price = $5, cache_read_price = $6, fast_multiplier = $7, flex_multiplier = $8, image_input_price = $9, image_output_price = $10, per_request_price = $11, time_pricing = $12, platform = $13, updated_at = NOW() + WHERE id = $14`, modelsJSON, billingMode, pricing.InputPrice, pricing.OutputPrice, pricing.CacheWritePrice, pricing.CacheReadPrice, - pricing.ImageInputPrice, pricing.ImageOutputPrice, pricing.PerRequestPrice, timePricingJSON, pricing.Platform, pricing.ID, + pricing.FastMultiplier, pricing.FlexMultiplier, pricing.ImageInputPrice, pricing.ImageOutputPrice, pricing.PerRequestPrice, + timePricingJSON, pricing.Platform, pricing.ID, ) if err != nil { return fmt.Errorf("update model pricing: %w", err) @@ -95,7 +96,7 @@ func (r *channelRepository) ReplaceModelPricing(ctx context.Context, channelID i // batchLoadModelPricing 批量加载多个渠道的模型定价(含区间) func (r *channelRepository) batchLoadModelPricing(ctx context.Context, channelIDs []int64) (map[int64][]service.ChannelModelPricing, error) { rows, err := r.db.QueryContext(ctx, - `SELECT id, channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_read_price, image_input_price, image_output_price, per_request_price, time_pricing, created_at, updated_at + `SELECT id, channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_read_price, fast_multiplier, flex_multiplier, image_input_price, image_output_price, per_request_price, time_pricing, created_at, updated_at FROM channel_model_pricing WHERE channel_id = ANY($1) ORDER BY channel_id, id`, pq.Array(channelIDs), ) @@ -136,6 +137,7 @@ func (r *channelRepository) batchLoadIntervals(ctx context.Context, pricingIDs [ rows, err := r.db.QueryContext(ctx, `SELECT id, pricing_id, min_tokens, max_tokens, tier_label, input_price, output_price, cache_write_price, cache_read_price, + input_multiplier, output_multiplier, cache_write_multiplier, cache_read_multiplier, per_request_price, sort_order, created_at, updated_at FROM channel_pricing_intervals WHERE pricing_id = ANY($1) ORDER BY pricing_id, sort_order, id`, @@ -152,6 +154,7 @@ func (r *channelRepository) batchLoadIntervals(ctx context.Context, pricingIDs [ if err := rows.Scan( &iv.ID, &iv.PricingID, &iv.MinTokens, &iv.MaxTokens, &iv.TierLabel, &iv.InputPrice, &iv.OutputPrice, &iv.CacheWritePrice, &iv.CacheReadPrice, + &iv.InputMultiplier, &iv.OutputMultiplier, &iv.CacheWriteMultiplier, &iv.CacheReadMultiplier, &iv.PerRequestPrice, &iv.SortOrder, &iv.CreatedAt, &iv.UpdatedAt, ); err != nil { return nil, fmt.Errorf("scan interval: %w", err) @@ -177,6 +180,7 @@ func scanModelPricingRows(rows *sql.Rows) ([]service.ChannelModelPricing, []int6 if err := rows.Scan( &p.ID, &p.ChannelID, &p.Platform, &modelsJSON, &p.BillingMode, &p.InputPrice, &p.OutputPrice, &p.CacheWritePrice, &p.CacheReadPrice, + &p.FastMultiplier, &p.FlexMultiplier, &p.ImageInputPrice, &p.ImageOutputPrice, &p.PerRequestPrice, &timePricingJSON, &p.CreatedAt, &p.UpdatedAt, ); err != nil { return nil, nil, fmt.Errorf("scan model pricing: %w", err) @@ -243,11 +247,12 @@ func createModelPricingExec(ctx context.Context, exec dbExec, pricing *service.C platform = "anthropic" } err = exec.QueryRowContext(ctx, - `INSERT INTO channel_model_pricing (channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_read_price, image_input_price, image_output_price, per_request_price, time_pricing) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING id, created_at, updated_at`, + `INSERT INTO channel_model_pricing (channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_read_price, fast_multiplier, flex_multiplier, image_input_price, image_output_price, per_request_price, time_pricing) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) RETURNING id, created_at, updated_at`, pricing.ChannelID, platform, modelsJSON, billingMode, pricing.InputPrice, pricing.OutputPrice, pricing.CacheWritePrice, pricing.CacheReadPrice, - pricing.ImageInputPrice, pricing.ImageOutputPrice, pricing.PerRequestPrice, timePricingJSON, + pricing.FastMultiplier, pricing.FlexMultiplier, pricing.ImageInputPrice, pricing.ImageOutputPrice, + pricing.PerRequestPrice, timePricingJSON, ).Scan(&pricing.ID, &pricing.CreatedAt, &pricing.UpdatedAt) if err != nil { return fmt.Errorf("insert model pricing: %w", err) @@ -288,10 +293,11 @@ func unmarshalChannelTimePricing(data []byte) (*service.ChannelTimePricing, erro func createIntervalExec(ctx context.Context, exec dbExec, iv *service.PricingInterval) error { return exec.QueryRowContext(ctx, `INSERT INTO channel_pricing_intervals - (pricing_id, min_tokens, max_tokens, tier_label, input_price, output_price, cache_write_price, cache_read_price, per_request_price, sort_order) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id, created_at, updated_at`, + (pricing_id, min_tokens, max_tokens, tier_label, input_price, output_price, cache_write_price, cache_read_price, input_multiplier, output_multiplier, cache_write_multiplier, cache_read_multiplier, per_request_price, sort_order) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) RETURNING id, created_at, updated_at`, iv.PricingID, iv.MinTokens, iv.MaxTokens, iv.TierLabel, iv.InputPrice, iv.OutputPrice, iv.CacheWritePrice, iv.CacheReadPrice, + iv.InputMultiplier, iv.OutputMultiplier, iv.CacheWriteMultiplier, iv.CacheReadMultiplier, iv.PerRequestPrice, iv.SortOrder, ).Scan(&iv.ID, &iv.CreatedAt, &iv.UpdatedAt) } diff --git a/backend/internal/repository/channel_repo_pricing_time_test.go b/backend/internal/repository/channel_repo_pricing_time_test.go index d7af1886e7..38acdbdc73 100644 --- a/backend/internal/repository/channel_repo_pricing_time_test.go +++ b/backend/internal/repository/channel_repo_pricing_time_test.go @@ -16,7 +16,7 @@ import ( var channelModelPricingTimePricingColumns = []string{ "id", "channel_id", "platform", "models", "billing_mode", "input_price", "output_price", - "cache_write_price", "cache_read_price", "image_input_price", "image_output_price", + "cache_write_price", "cache_read_price", "fast_multiplier", "flex_multiplier", "image_input_price", "image_output_price", "per_request_price", "time_pricing", "created_at", "updated_at", } @@ -33,7 +33,7 @@ func newChannelModelPricingTimePricingRepo(t *testing.T) (*channelRepository, sq func modelPricingTimePricingRow(timePricing any) *sqlmock.Rows { return sqlmock.NewRows(channelModelPricingTimePricingColumns).AddRow( int64(11), int64(7), "openai", `["gpt-5"]`, service.BillingModeToken, - nil, nil, nil, nil, nil, nil, nil, timePricing, + nil, nil, nil, nil, nil, nil, nil, nil, nil, timePricing, time.Date(2026, 8, 17, 0, 0, 0, 0, time.UTC), time.Date(2026, 8, 17, 1, 0, 0, 0, time.UTC), ) } @@ -105,10 +105,10 @@ func TestChannelModelPricingTimePricingCreateAndUpdateRoundTrip(t *testing.T) { t.Run("create writes JSON", func(t *testing.T) { repo, mock := newChannelModelPricingTimePricingRepo(t) - mock.ExpectQuery(regexp.QuoteMeta("INSERT INTO channel_model_pricing (channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_read_price, image_input_price, image_output_price, per_request_price, time_pricing)")). + mock.ExpectQuery(regexp.QuoteMeta("INSERT INTO channel_model_pricing (channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_read_price, fast_multiplier, flex_multiplier, image_input_price, image_output_price, per_request_price, time_pricing)")). WithArgs( int64(7), "openai", []byte(`["gpt-5"]`), service.BillingModeToken, - nil, nil, nil, nil, nil, nil, nil, channelModelPricingTimePricingJSON, + nil, nil, nil, nil, nil, nil, nil, nil, nil, channelModelPricingTimePricingJSON, ). WillReturnRows(sqlmock.NewRows([]string{"id", "created_at", "updated_at"}).AddRow(int64(11), time.Time{}, time.Time{})) @@ -118,10 +118,10 @@ func TestChannelModelPricingTimePricingCreateAndUpdateRoundTrip(t *testing.T) { t.Run("update writes JSON and entry ID", func(t *testing.T) { repo, mock := newChannelModelPricingTimePricingRepo(t) - mock.ExpectExec(`(?s)UPDATE channel_model_pricing.*per_request_price = \$9, time_pricing = \$10, platform = \$11.*WHERE id = \$12`). + mock.ExpectExec(`(?s)UPDATE channel_model_pricing.*per_request_price = \$11, time_pricing = \$12, platform = \$13.*WHERE id = \$14`). WithArgs( []byte(`["gpt-5"]`), service.BillingModeToken, - nil, nil, nil, nil, nil, nil, nil, channelModelPricingTimePricingJSON, "openai", int64(11), + nil, nil, nil, nil, nil, nil, nil, nil, nil, channelModelPricingTimePricingJSON, "openai", int64(11), ). WillReturnResult(sqlmock.NewResult(0, 1)) @@ -153,10 +153,10 @@ func TestChannelModelPricingTimePricingCreateAndUpdateWriteNullWhenDisabled(t *t t.Run("create writes SQL NULL", func(t *testing.T) { repo, mock := newChannelModelPricingTimePricingRepo(t) - mock.ExpectQuery(regexp.QuoteMeta("INSERT INTO channel_model_pricing (channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_read_price, image_input_price, image_output_price, per_request_price, time_pricing)")). + mock.ExpectQuery(regexp.QuoteMeta("INSERT INTO channel_model_pricing (channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_read_price, fast_multiplier, flex_multiplier, image_input_price, image_output_price, per_request_price, time_pricing)")). WithArgs( int64(7), "openai", []byte(`["gpt-5"]`), service.BillingModeToken, - nil, nil, nil, nil, nil, nil, nil, nil, + nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, ). WillReturnRows(sqlmock.NewRows([]string{"id", "created_at", "updated_at"}).AddRow(int64(11), time.Time{}, time.Time{})) @@ -166,10 +166,10 @@ func TestChannelModelPricingTimePricingCreateAndUpdateWriteNullWhenDisabled(t *t t.Run("update writes SQL NULL", func(t *testing.T) { repo, mock := newChannelModelPricingTimePricingRepo(t) - mock.ExpectExec(`(?s)UPDATE channel_model_pricing.*per_request_price = \$9, time_pricing = \$10, platform = \$11.*WHERE id = \$12`). + mock.ExpectExec(`(?s)UPDATE channel_model_pricing.*per_request_price = \$11, time_pricing = \$12, platform = \$13.*WHERE id = \$14`). WithArgs( []byte(`["gpt-5"]`), service.BillingModeToken, - nil, nil, nil, nil, nil, nil, nil, nil, "openai", int64(11), + nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, "openai", int64(11), ). WillReturnResult(sqlmock.NewResult(0, 1)) diff --git a/backend/internal/repository/proxy_probe_service.go b/backend/internal/repository/proxy_probe_service.go index 245eaade81..6088edfcc6 100644 --- a/backend/internal/repository/proxy_probe_service.go +++ b/backend/internal/repository/proxy_probe_service.go @@ -31,11 +31,24 @@ func NewProxyExitInfoProber(cfg *config.Config) service.ProxyExitInfoProber { if insecure { log.Printf("[ProxyProbe] Warning: insecure_skip_verify is not allowed and will cause probe failure.") } + // 构建探测 URL 列表:配置存在时覆盖内置默认列表。 + var configuredTargets []configuredProbeTarget + if cfg != nil && len(cfg.Security.ProxyProbe.URLs) > 0 { + configuredTargets = make([]configuredProbeTarget, 0, len(cfg.Security.ProxyProbe.URLs)) + for _, u := range cfg.Security.ProxyProbe.URLs { + configuredTargets = append(configuredTargets, configuredProbeTarget{ + url: u.URL, + parser: u.Parser, + }) + } + } + return &proxyProbeService{ - insecureSkipVerify: insecure, - allowPrivateHosts: allowPrivate, - validateResolvedIP: validateResolvedIP, - maxResponseBytes: maxResponseBytes, + insecureSkipVerify: insecure, + allowPrivateHosts: allowPrivate, + validateResolvedIP: validateResolvedIP, + maxResponseBytes: maxResponseBytes, + configuredProbeURLs: configuredTargets, } } @@ -44,21 +57,27 @@ const ( defaultProxyProbeResponseMaxBytes = int64(1024 * 1024) ) -// probeURLs 按优先级排列的探测 URL 列表 -// 某些 AI API 专用代理只允许访问特定域名,因此需要多个备选 +// probeURLs 按优先级排列的内置探测 URL 列表。 +// 某些 AI API 专用代理只允许访问特定域名,因此需要多个备选。 var probeURLs = []struct { url string - parser string // "ip-api" or "ipify" + parser string }{ {"http://ip-api.com/json/?lang=zh-CN", "ip-api"}, {"http://api64.ipify.org?format=json", "ipify"}, } +type configuredProbeTarget struct { + url string + parser string +} + type proxyProbeService struct { - insecureSkipVerify bool - allowPrivateHosts bool - validateResolvedIP bool - maxResponseBytes int64 + insecureSkipVerify bool + allowPrivateHosts bool + validateResolvedIP bool + maxResponseBytes int64 + configuredProbeURLs []configuredProbeTarget } func (s *proxyProbeService) ProbeProxy(ctx context.Context, proxyURL string) (*service.ProxyExitInfo, int64, error) { @@ -74,6 +93,17 @@ func (s *proxyProbeService) ProbeProxy(ctx context.Context, proxyURL string) (*s } var lastErr error + if len(s.configuredProbeURLs) > 0 { + for _, probe := range s.configuredProbeURLs { + exitInfo, latencyMs, err := s.probeWithURL(ctx, client, probe.url, probe.parser) + if err == nil { + return exitInfo, latencyMs, nil + } + lastErr = err + } + return nil, 0, fmt.Errorf("all probe URLs failed, last error: %w", lastErr) + } + for _, probe := range probeURLs { exitInfo, latencyMs, err := s.probeWithURL(ctx, client, probe.url, probe.parser) if err == nil { @@ -121,6 +151,8 @@ func (s *proxyProbeService) probeWithURL(ctx context.Context, client *http.Clien return s.parseIPAPI(body, latencyMs) case "ipify": return s.parseIPify(body, latencyMs) + case "chatgpt-trace": + return s.parseChatGPTTrace(body, latencyMs) default: return nil, latencyMs, fmt.Errorf("unknown parser: %s", parser) } @@ -179,3 +211,35 @@ func (s *proxyProbeService) parseIPify(body []byte, latencyMs int64) (*service.P IP: result.IP, }, latencyMs, nil } + +// parseChatGPTTrace 解析 Cloudflare trace 端点(如 chatgpt.com/cdn-cgi/trace)的纯文本响应。 +// 响应按行给出键值对,其中 ip= 为出口 IP,loc= 为国家代码。 +func (s *proxyProbeService) parseChatGPTTrace(body []byte, latencyMs int64) (*service.ProxyExitInfo, int64, error) { + var ip, loc string + for _, line := range strings.Split(string(body), "\n") { + key, value, found := strings.Cut(strings.TrimSpace(line), "=") + if !found { + continue + } + switch key { + case "ip": + ip = strings.TrimSpace(value) + case "loc": + loc = strings.TrimSpace(value) + } + } + if ip == "" { + preview := string(body) + if len(preview) > 200 { + preview = preview[:200] + "..." + } + return nil, latencyMs, fmt.Errorf("chatgpt-trace: no ip= found in response (body: %s)", preview) + } + info := &service.ProxyExitInfo{ + IP: ip, + } + if loc != "" { + info.CountryCode = loc + } + return info, latencyMs, nil +} diff --git a/backend/internal/repository/proxy_probe_service_test.go b/backend/internal/repository/proxy_probe_service_test.go index c778f6c1eb..45c342cb4b 100644 --- a/backend/internal/repository/proxy_probe_service_test.go +++ b/backend/internal/repository/proxy_probe_service_test.go @@ -166,6 +166,22 @@ func (s *ProxyProbeServiceSuite) TestParseIPify_NoIP() { require.ErrorContains(s.T(), err, "no IP found") } +func (s *ProxyProbeServiceSuite) TestParseChatGPTTrace_Success() { + body := []byte("fl=abc\nh=chatgpt.com\nip=203.0.113.5\nts=1700000000\nloc=US\ntz=UTC\n") + info, latencyMs, err := s.prober.parseChatGPTTrace(body, 320) + require.NoError(s.T(), err) + require.Equal(s.T(), int64(320), latencyMs) + require.Equal(s.T(), "203.0.113.5", info.IP) + require.Equal(s.T(), "US", info.CountryCode) +} + +func (s *ProxyProbeServiceSuite) TestParseChatGPTTrace_NoIP() { + body := []byte("fl=abc\nh=chatgpt.com\nloc=US\n") + _, _, err := s.prober.parseChatGPTTrace(body, 100) + require.Error(s.T(), err) + require.ErrorContains(s.T(), err, "chatgpt-trace: no ip= found") +} + func TestProxyProbeServiceSuite(t *testing.T) { suite.Run(t, new(ProxyProbeServiceSuite)) } diff --git a/backend/internal/service/account_stats_pricing.go b/backend/internal/service/account_stats_pricing.go index df9e8e05aa..8d5bc144fc 100644 --- a/backend/internal/service/account_stats_pricing.go +++ b/backend/internal/service/account_stats_pricing.go @@ -68,7 +68,7 @@ func tryModelFilePricing(billingService *BillingService, model string, tokens Us return nil } normalizedTier := normalizeBillingServiceTier(serviceTier) - if normalizedTier == "priority" || normalizedTier == "flex" || + if normalizedTier == "priority" || normalizedTier == "fast" || normalizedTier == "flex" || billingService.shouldApplySessionLongContextPricing(tokens, pricing) { breakdown, err := billingService.CalculateCostWithServiceTier(model, tokens, 1, normalizedTier) if err != nil || breakdown == nil || breakdown.TotalCost <= 0 { diff --git a/backend/internal/service/account_stats_pricing_test.go b/backend/internal/service/account_stats_pricing_test.go index 48336a5834..1bd28896fc 100644 --- a/backend/internal/service/account_stats_pricing_test.go +++ b/backend/internal/service/account_stats_pricing_test.go @@ -768,6 +768,26 @@ func TestResolveAccountStatsCost_FallsBackToLiteLLM(t *testing.T) { require.InDelta(t, 0.2, *result, 1e-12) } +func TestResolveAccountStatsCost_FallbackHonorsAnthropicFast(t *testing.T) { + channel := &Channel{ID: 1, Status: StatusActive} + cs := newTestChannelServiceForStats(t, channel, 10, "anthropic") + bs := newTestBillingServiceWithPrices(map[string]*ModelPricing{ + "claude-opus-5": { + InputPricePerToken: 5e-6, + OutputPricePerToken: 25e-6, + }, + }) + + result := resolveAccountStatsCost( + context.Background(), cs, bs, + 1, 10, "claude-opus-5", + UsageTokens{InputTokens: 1_000_000, OutputTokens: 1_000_000}, + 1, 0, "fast", + ) + require.NotNil(t, result) + require.InDelta(t, 60, *result, 1e-12) +} + func TestResolveAccountStatsCost_Gemini36FlashTierUsesFallbackPricing(t *testing.T) { channel := &Channel{ ID: 1, diff --git a/backend/internal/service/billing_service.go b/backend/internal/service/billing_service.go index 6ead8da021..af8c694495 100644 --- a/backend/internal/service/billing_service.go +++ b/backend/internal/service/billing_service.go @@ -91,25 +91,27 @@ type BillingCache interface { // ModelPricing 模型价格配置(per-token价格,与LiteLLM格式一致) type ModelPricing struct { - InputPricePerToken float64 // 每token输入价格 (USD) - InputPricePerTokenPriority float64 // priority service tier 下每token输入价格 (USD) - ImageInputPricePerToken float64 // 图片输入 token 价格 (USD),用于多模态 embedding 等图文不同价场景;为 0 时回退到 InputPricePerToken - OutputPricePerToken float64 // 每token输出价格 (USD) - OutputPricePerTokenPriority float64 // priority service tier 下每token输出价格 (USD) - CacheCreationPricePerToken float64 // 缓存创建每token价格 (USD) - CacheCreationPricePerTokenPriority float64 // priority service tier 下缓存创建每token价格 (USD) - CacheCreationPriceExplicit bool // 是否由渠道/区间定价显式设定(为 true 时即使 == 0 也不回退) - CacheReadPricePerToken float64 // 缓存读取每token价格 (USD) - CacheReadPricePerTokenPriority float64 // priority service tier 下缓存读取每token价格 (USD) - CacheCreation5mPrice float64 // 5分钟缓存创建每token价格 (USD) - CacheCreation1hPrice float64 // 1小时缓存创建每token价格 (USD) - SupportsCacheBreakdown bool // 是否支持详细的缓存分类 - LongContextInputThreshold int // 超过阈值后按整次会话提升输入价格 - LongContextThresholdInclusive bool // 达到阈值即应用(xAI);默认保持严格大于以兼容既有模型 - LongContextInputMultiplier float64 // 长上下文整次会话输入倍率 - LongContextOutputMultiplier float64 // 长上下文整次会话输出倍率 - ImageOutputPricePerToken float64 // 图片输出 token 价格 (USD) - ImageOutputPriceExplicit bool // 是否由渠道定价显式设定(为 true 时即使 == 0 也不回退) + InputPricePerToken float64 // 每token输入价格 (USD) + InputPricePerTokenPriority float64 // priority service tier 下每token输入价格 (USD) + ImageInputPricePerToken float64 // 图片输入 token 价格 (USD),用于多模态 embedding 等图文不同价场景;为 0 时回退到 InputPricePerToken + OutputPricePerToken float64 // 每token输出价格 (USD) + OutputPricePerTokenPriority float64 // priority service tier 下每token输出价格 (USD) + CacheCreationPricePerToken float64 // 缓存创建每token价格 (USD) + CacheCreationPricePerTokenPriority float64 // priority service tier 下缓存创建每token价格 (USD) + CacheCreationPriceExplicit bool // 是否由渠道/区间定价显式设定(为 true 时即使 == 0 也不回退) + CacheReadPricePerToken float64 // 缓存读取每token价格 (USD) + CacheReadPricePerTokenPriority float64 // priority service tier 下缓存读取每token价格 (USD) + FastMultiplier *float64 // 渠道显式 Fast/priority 倍率;nil 时沿用模型目录行为 + FlexMultiplier *float64 // 渠道显式 Flex 倍率;nil 时沿用默认行为 + CacheCreation5mPrice float64 // 5分钟缓存创建每token价格 (USD) + CacheCreation1hPrice float64 // 1小时缓存创建每token价格 (USD) + SupportsCacheBreakdown bool // 是否支持详细的缓存分类 + LongContextInputThreshold int // 超过阈值后按整次会话提升输入价格 + LongContextThresholdInclusive bool // 达到阈值即应用(xAI);默认保持严格大于以兼容既有模型 + LongContextInputMultiplier float64 // 长上下文整次会话输入倍率 + LongContextOutputMultiplier float64 // 长上下文整次会话输出倍率 + ImageOutputPricePerToken float64 // 图片输出 token 价格 (USD) + ImageOutputPriceExplicit bool // 是否由渠道定价显式设定(为 true 时即使 == 0 也不回退) } const ( @@ -123,7 +125,14 @@ func normalizeBillingServiceTier(serviceTier string) string { } func usePriorityServiceTierPricing(serviceTier string, pricing *ModelPricing) bool { - if pricing == nil || normalizeBillingServiceTier(serviceTier) != "priority" { + if pricing == nil { + return false + } + tier := normalizeBillingServiceTier(serviceTier) + if tier != "priority" && tier != "fast" { + return false + } + if pricing.FastMultiplier != nil { return false } return pricing.InputPricePerTokenPriority > 0 || pricing.OutputPricePerTokenPriority > 0 || @@ -132,7 +141,7 @@ func usePriorityServiceTierPricing(serviceTier string, pricing *ModelPricing) bo func serviceTierCostMultiplier(serviceTier string) float64 { switch normalizeBillingServiceTier(serviceTier) { - case "priority": + case "priority", "fast": return 2.0 case "flex": return 0.5 @@ -141,6 +150,34 @@ func serviceTierCostMultiplier(serviceTier string) float64 { } } +func configuredServiceTierMultiplier(serviceTier string, pricing *ModelPricing) float64 { + if pricing != nil { + switch normalizeBillingServiceTier(serviceTier) { + case "priority", "fast": + if pricing.FastMultiplier != nil { + return *pricing.FastMultiplier + } + case "flex": + if pricing.FlexMultiplier != nil { + return *pricing.FlexMultiplier + } + } + } + return serviceTierCostMultiplier(serviceTier) +} + +func pricingWithPriorityMultiplier(base *ModelPricing, multiplier float64) *ModelPricing { + if base == nil { + return nil + } + cloned := *base + cloned.InputPricePerTokenPriority = cloned.InputPricePerToken * multiplier + cloned.OutputPricePerTokenPriority = cloned.OutputPricePerToken * multiplier + cloned.CacheCreationPricePerTokenPriority = cloned.CacheCreationPricePerToken * multiplier + cloned.CacheReadPricePerTokenPriority = cloned.CacheReadPricePerToken * multiplier + return &cloned +} + // UsageTokens 使用的token数量 type UsageTokens struct { InputTokens int @@ -281,10 +318,10 @@ func (s *BillingService) initFallbackPricing() { // Claude 4.7 Opus (暂与4.6同价,待官方定价更新) s.fallbackPrices["claude-opus-4.7"] = s.fallbackPrices["claude-opus-4.6"] - // Claude 4.8 Opus / Claude Opus 5(官方同价:$5 输入 / $25 输出 per MTok)。 + // Claude 4.8 Opus / Claude Opus 5(标准 $5/$25,Fast $10/$50 per MTok)。 // 缺少这两条时 getFallbackPricing 会掉到 claude-3-opus($15/$75),造成 3 倍超收。 - s.fallbackPrices["claude-opus-4.8"] = s.fallbackPrices["claude-opus-4.7"] - s.fallbackPrices["claude-opus-5"] = s.fallbackPrices["claude-opus-4.8"] + s.fallbackPrices["claude-opus-4.8"] = pricingWithPriorityMultiplier(s.fallbackPrices["claude-opus-4.7"], 2) + s.fallbackPrices["claude-opus-5"] = pricingWithPriorityMultiplier(s.fallbackPrices["claude-opus-4.8"], 2) // Gemini 3.1 Pro s.fallbackPrices["gemini-3.1-pro"] = &ModelPricing{ @@ -320,9 +357,31 @@ func (s *BillingService) initFallbackPricing() { LongContextInputMultiplier: openAIGPT54LongContextInputMultiplier, LongContextOutputMultiplier: openAIGPT54LongContextOutputMultiplier, } - // GPT-5.5 / GPT-5.5 Pro 暂无独立定价,回退到 GPT-5.4。 - s.fallbackPrices["gpt-5.5"] = s.fallbackPrices["gpt-5.4"] - s.fallbackPrices["gpt-5.5-pro"] = s.fallbackPrices["gpt-5.4"] + // OpenAI GPT-5.5 官方价格;Fast 为标准价 2.5 倍。 + // Source: https://platform.openai.com/docs/pricing + s.fallbackPrices["gpt-5.5"] = pricingWithPriorityMultiplier(&ModelPricing{ + InputPricePerToken: 5e-6, + OutputPricePerToken: 30e-6, + // 官方未列独立 cache-write 价;内部出现 cache creation token 时按输入价兜底。 + CacheCreationPricePerToken: 5e-6, + CacheReadPricePerToken: 0.5e-6, + SupportsCacheBreakdown: false, + LongContextInputThreshold: openAIGPT54LongContextInputThreshold, + LongContextInputMultiplier: openAIGPT54LongContextInputMultiplier, + LongContextOutputMultiplier: openAIGPT54LongContextOutputMultiplier, + }, 2.5) + // GPT-5.5 Pro 当前不提供 Fast;保留标准、Flex 和长上下文 fallback 价格。 + s.fallbackPrices["gpt-5.5-pro"] = &ModelPricing{ + InputPricePerToken: 30e-6, + OutputPricePerToken: 180e-6, + // 官方未列独立 cached-input/cache-write 价;内部出现对应 token 时按输入价兜底。 + CacheCreationPricePerToken: 30e-6, + CacheReadPricePerToken: 30e-6, + SupportsCacheBreakdown: false, + LongContextInputThreshold: openAIGPT54LongContextInputThreshold, + LongContextInputMultiplier: openAIGPT54LongContextInputMultiplier, + LongContextOutputMultiplier: openAIGPT54LongContextOutputMultiplier, + } // OpenAI GPT-5.6 官方价格(USD/token)。缓存写入为输入价的 1.25 倍。 s.fallbackPrices["gpt-5.6-sol"] = &ModelPricing{ @@ -1009,25 +1068,9 @@ func (s *BillingService) GetModelPricingWithChannel(model string, channelPricing // 防止修改 fallbackPrices 中的共享指针 cloned := *pricing pricing = &cloned - if channelPricing.InputPrice != nil { - pricing.InputPricePerToken = *channelPricing.InputPrice - pricing.InputPricePerTokenPriority = *channelPricing.InputPrice - } - if channelPricing.OutputPrice != nil { - pricing.OutputPricePerToken = *channelPricing.OutputPrice - pricing.OutputPricePerTokenPriority = *channelPricing.OutputPrice - } - if channelPricing.CacheWritePrice != nil { - pricing.CacheCreationPricePerToken = *channelPricing.CacheWritePrice - pricing.CacheCreationPricePerTokenPriority = *channelPricing.CacheWritePrice - pricing.CacheCreationPriceExplicit = true - pricing.CacheCreation5mPrice = *channelPricing.CacheWritePrice - pricing.CacheCreation1hPrice = *channelPricing.CacheWritePrice - } - if channelPricing.CacheReadPrice != nil { - pricing.CacheReadPricePerToken = *channelPricing.CacheReadPrice - pricing.CacheReadPricePerTokenPriority = *channelPricing.CacheReadPrice - } + applyChannelTokenPriceOverrides(pricing, channelPricing) + pricing.FastMultiplier = channelPricing.FastMultiplier + pricing.FlexMultiplier = channelPricing.FlexMultiplier if channelPricing.ImageOutputPrice != nil { pricing.ImageOutputPricePerToken = *channelPricing.ImageOutputPrice } else { @@ -1038,6 +1081,45 @@ func (s *BillingService) GetModelPricingWithChannel(model string, channelPricing return pricing, nil } +// channelTierOverridePrice applies a Standard-tier override while preserving +// an explicit model-catalog Fast/Priority ratio. If the catalog has no tier +// price, generic service-tier defaults remain responsible for the fallback. +func channelTierOverridePrice(baseStandard, baseTier, channelStandard float64) float64 { + if baseStandard > 0 && baseTier > 0 { + return channelStandard * (baseTier / baseStandard) + } + return 0 +} + +func applyChannelTokenPriceOverrides(pricing *ModelPricing, channelPricing *ChannelModelPricing) { + if pricing == nil || channelPricing == nil { + return + } + if channelPricing.InputPrice != nil { + priority := channelTierOverridePrice(pricing.InputPricePerToken, pricing.InputPricePerTokenPriority, *channelPricing.InputPrice) + pricing.InputPricePerToken = *channelPricing.InputPrice + pricing.InputPricePerTokenPriority = priority + } + if channelPricing.OutputPrice != nil { + priority := channelTierOverridePrice(pricing.OutputPricePerToken, pricing.OutputPricePerTokenPriority, *channelPricing.OutputPrice) + pricing.OutputPricePerToken = *channelPricing.OutputPrice + pricing.OutputPricePerTokenPriority = priority + } + if channelPricing.CacheWritePrice != nil { + priority := channelTierOverridePrice(pricing.CacheCreationPricePerToken, pricing.CacheCreationPricePerTokenPriority, *channelPricing.CacheWritePrice) + pricing.CacheCreationPricePerToken = *channelPricing.CacheWritePrice + pricing.CacheCreationPricePerTokenPriority = priority + pricing.CacheCreationPriceExplicit = true + pricing.CacheCreation5mPrice = *channelPricing.CacheWritePrice + pricing.CacheCreation1hPrice = *channelPricing.CacheWritePrice + } + if channelPricing.CacheReadPrice != nil { + priority := channelTierOverridePrice(pricing.CacheReadPricePerToken, pricing.CacheReadPricePerTokenPriority, *channelPricing.CacheReadPrice) + pricing.CacheReadPricePerToken = *channelPricing.CacheReadPrice + pricing.CacheReadPricePerTokenPriority = priority + } +} + // --- 统一计费入口 --- // CostInput 统一计费输入 @@ -1113,18 +1195,27 @@ func (s *BillingService) CalculateCostUnified(input CostInput) (*CostBreakdown, func (s *BillingService) calculateTokenCost(resolved *ResolvedPricing, input CostInput) (*CostBreakdown, error) { totalContext := input.Tokens.InputTokens + input.Tokens.CacheCreationTokens + input.Tokens.CacheReadTokens - pricing := input.Resolver.GetIntervalPricing(resolved, totalContext) + // 分组开关是统一入口;账号 API 开关保留为额外开启能力,但 false 不否决分组配置。 + contextTierPricingEnabled := resolved.longContextPricingEnabled + if input.LongContextBillingEnabled != nil && *input.LongContextBillingEnabled { + contextTierPricingEnabled = true + } + + pricingContext := totalContext + if !contextTierPricingEnabled { + // 渠道可能显式配置了第一档,也可能只配置高上下文档。用 1 token + // 选择最低档;未命中时自然回退到渠道基础价。 + pricingContext = 1 + } + pricing := input.Resolver.GetIntervalPricing(resolved, pricingContext) if pricing == nil { return nil, fmt.Errorf("no pricing available for model: %s: %w", input.Model, ErrModelPricingUnavailable) } pricing = s.applyModelSpecificPricingPolicy(input.Model, pricing) - // 长上下文定价仅在无区间定价时应用(区间定价已包含上下文分层) - applyLongCtx := len(resolved.Intervals) == 0 && resolved.longContextPricingEnabled - if input.LongContextBillingEnabled != nil { - applyLongCtx = applyLongCtx && *input.LongContextBillingEnabled - } + // 官方长上下文阶梯仅在无区间定价时应用(区间定价已包含上下文分层)。 + applyLongCtx := len(resolved.Intervals) == 0 && contextTierPricingEnabled breakdown := s.computeTokenBreakdown(pricing, input.Tokens, input.RateMultiplier, input.ServiceTier, applyLongCtx) applyCostBreakdownMultiplier(breakdown, resolvedChannelTimeMultiplier(resolved, input.PricingAt)) @@ -1164,7 +1255,7 @@ func (s *BillingService) computeTokenBreakdown( cacheCreationPrice = pricing.CacheCreationPricePerTokenPriority } } else { - tierMultiplier = serviceTierCostMultiplier(serviceTier) + tierMultiplier = configuredServiceTierMultiplier(serviceTier, pricing) } longContextPricingEligible := applyLongCtx && s.shouldApplySessionLongContextPricing(tokens, pricing) diff --git a/backend/internal/service/billing_service_test.go b/backend/internal/service/billing_service_test.go index 80d07af44b..1e801d8e95 100644 --- a/backend/internal/service/billing_service_test.go +++ b/backend/internal/service/billing_service_test.go @@ -215,7 +215,7 @@ func TestGetModelPricing_OpenAICompactAliasesFallback(t *testing.T) { cacheRead float64 longContext int }{ - {model: "gpt5.5", inputPrice: 2.5e-6, outputPrice: 15e-6, cacheRead: 0.25e-6, longContext: 272000}, + {model: "gpt5.5", inputPrice: 5e-6, outputPrice: 30e-6, cacheRead: 0.5e-6, longContext: 272000}, {model: "openai/gpt5.4", inputPrice: 2.5e-6, outputPrice: 15e-6, cacheRead: 0.25e-6, longContext: 272000}, {model: "gpt5.4-mini", inputPrice: 7.5e-7, outputPrice: 4.5e-6, cacheRead: 7.5e-8, longContext: 0}, {model: "gpt5.3codexspark", inputPrice: 1.5e-6, outputPrice: 12e-6, cacheRead: 0.15e-6, longContext: 0}, @@ -293,14 +293,40 @@ func TestCalculateCost_OpenAIGPT55ProUsesGPT55PricingPolicy(t *testing.T) { cost, err := svc.CalculateCost("gpt-5.5-pro", tokens, 1.0) require.NoError(t, err) - expectedInput := float64(tokens.InputTokens) * 2.5e-6 * 2.0 - expectedOutput := float64(tokens.OutputTokens) * 15e-6 * 1.5 + expectedInput := float64(tokens.InputTokens) * 30e-6 * 2.0 + expectedOutput := float64(tokens.OutputTokens) * 180e-6 * 1.5 require.InDelta(t, expectedInput, cost.InputCost, 1e-10) require.InDelta(t, expectedOutput, cost.OutputCost, 1e-10) require.InDelta(t, expectedInput+expectedOutput, cost.TotalCost, 1e-10) require.InDelta(t, expectedInput+expectedOutput, cost.ActualCost, 1e-10) } +func TestFallbackPricing_OpenAIGPT55UsesOfficialPrices(t *testing.T) { + svc := newTestBillingService() + + pricing, err := svc.GetModelPricing("gpt-5.5") + require.NoError(t, err) + require.InDelta(t, 5e-6, pricing.InputPricePerToken, 1e-12) + require.InDelta(t, 30e-6, pricing.OutputPricePerToken, 1e-12) + require.InDelta(t, 0.5e-6, pricing.CacheReadPricePerToken, 1e-12) + require.InDelta(t, 5e-6, pricing.CacheCreationPricePerToken, 1e-12) + require.InDelta(t, 12.5e-6, pricing.InputPricePerTokenPriority, 1e-12) + require.InDelta(t, 75e-6, pricing.OutputPricePerTokenPriority, 1e-12) +} + +func TestFallbackPricing_OpenAIGPT55ProUsesOfficialPrices(t *testing.T) { + svc := newTestBillingService() + + pricing, err := svc.GetModelPricing("gpt-5.5-pro") + require.NoError(t, err) + require.InDelta(t, 30e-6, pricing.InputPricePerToken, 1e-12) + require.InDelta(t, 180e-6, pricing.OutputPricePerToken, 1e-12) + require.InDelta(t, 30e-6, pricing.CacheReadPricePerToken, 1e-12) + require.InDelta(t, 30e-6, pricing.CacheCreationPricePerToken, 1e-12) + require.Zero(t, pricing.InputPricePerTokenPriority) + require.Zero(t, pricing.OutputPricePerTokenPriority) +} + // 回归测试 #2293:长上下文计费触发时,cache_read_tokens 也应应用 LongContextInputMultiplier。 // 修复前:CacheReadCost = tokens * 0.25e-6 (漏乘倍率,少计费用)。 // 修复后:CacheReadCost = tokens * 0.25e-6 * LongContextInputMultiplier(=2.0)。 @@ -1594,9 +1620,10 @@ func TestGetModelPricingWithChannel_OverrideInputPriceOnly(t *testing.T) { pricing, err := svc.GetModelPricingWithChannel("claude-sonnet-4", chPricing) require.NoError(t, err) - // InputPrice overridden (both normal and priority) + // InputPrice overridden. claude-sonnet-4 has no catalog priority price, so + // the priority slot is zeroed and serviceTierCostMultiplier owns the surcharge. require.InDelta(t, 99e-6, pricing.InputPricePerToken, 1e-12) - require.InDelta(t, 99e-6, pricing.InputPricePerTokenPriority, 1e-12) + require.Zero(t, pricing.InputPricePerTokenPriority) // OutputPrice unchanged (claude-sonnet-4 fallback = 15e-6) require.InDelta(t, 15e-6, pricing.OutputPricePerToken, 1e-12) @@ -1611,9 +1638,9 @@ func TestGetModelPricingWithChannel_OverrideOutputPriceOnly(t *testing.T) { pricing, err := svc.GetModelPricingWithChannel("claude-sonnet-4", chPricing) require.NoError(t, err) - // OutputPrice overridden + // OutputPrice overridden; no catalog priority price to scale, so the slot is zeroed. require.InDelta(t, 88e-6, pricing.OutputPricePerToken, 1e-12) - require.InDelta(t, 88e-6, pricing.OutputPricePerTokenPriority, 1e-12) + require.Zero(t, pricing.OutputPricePerTokenPriority) // InputPrice unchanged (claude-sonnet-4 fallback = 3e-6) require.InDelta(t, 3e-6, pricing.InputPricePerToken, 1e-12) @@ -1633,15 +1660,18 @@ func TestGetModelPricingWithChannel_OverrideAllFields(t *testing.T) { require.NoError(t, err) require.InDelta(t, 10e-6, pricing.InputPricePerToken, 1e-12) - require.InDelta(t, 10e-6, pricing.InputPricePerTokenPriority, 1e-12) require.InDelta(t, 20e-6, pricing.OutputPricePerToken, 1e-12) - require.InDelta(t, 20e-6, pricing.OutputPricePerTokenPriority, 1e-12) require.InDelta(t, 5e-6, pricing.CacheCreationPricePerToken, 1e-12) require.InDelta(t, 5e-6, pricing.CacheCreation5mPrice, 1e-12) require.InDelta(t, 5e-6, pricing.CacheCreation1hPrice, 1e-12) require.InDelta(t, 1e-6, pricing.CacheReadPricePerToken, 1e-12) - require.InDelta(t, 1e-6, pricing.CacheReadPricePerTokenPriority, 1e-12) require.InDelta(t, 50e-6, pricing.ImageOutputPricePerToken, 1e-12) + + // claude-sonnet-4 carries no catalog Fast/Priority tier, so every priority + // slot stays zero and computeTokenBreakdown falls back to the 2x default. + require.Zero(t, pricing.InputPricePerTokenPriority) + require.Zero(t, pricing.OutputPricePerTokenPriority) + require.Zero(t, pricing.CacheReadPricePerTokenPriority) } func TestGetModelPricingWithChannel_CacheWritePriceAffects5mAnd1h(t *testing.T) { @@ -1668,9 +1698,27 @@ func TestGetModelPricingWithChannel_CacheReadPriceAffectsPriority(t *testing.T) pricing, err := svc.GetModelPricingWithChannel("claude-sonnet-4", chPricing) require.NoError(t, err) - // CacheReadPrice should set both normal and priority + // CacheReadPrice sets the standard slot; the priority slot is zeroed because + // claude-sonnet-4 has no catalog tier ratio to preserve. require.InDelta(t, 2e-6, pricing.CacheReadPricePerToken, 1e-12) - require.InDelta(t, 2e-6, pricing.CacheReadPricePerTokenPriority, 1e-12) + require.Zero(t, pricing.CacheReadPricePerTokenPriority) +} + +// 目录带 tier 价时,渠道覆盖必须按目录比例换算 priority 价,而不是归零。 +func TestGetModelPricingWithChannel_PreservesCatalogPriorityRatio(t *testing.T) { + svc := newTestBillingService() + + // gpt-5.4 目录价:input 2.5/5(2x),output 15/30(2x)。 + pricing, err := svc.GetModelPricingWithChannel("gpt-5.4", &ChannelModelPricing{ + InputPrice: testPtrFloat64(4e-6), + OutputPrice: testPtrFloat64(30e-6), + }) + require.NoError(t, err) + + require.InDelta(t, 4e-6, pricing.InputPricePerToken, 1e-12) + require.InDelta(t, 8e-6, pricing.InputPricePerTokenPriority, 1e-12) + require.InDelta(t, 30e-6, pricing.OutputPricePerToken, 1e-12) + require.InDelta(t, 60e-6, pricing.OutputPricePerTokenPriority, 1e-12) } func TestGetModelPricingWithChannel_UnknownModelReturnsError(t *testing.T) { diff --git a/backend/internal/service/channel.go b/backend/internal/service/channel.go index 5345693eb0..0fe4e3b07c 100644 --- a/backend/internal/service/channel.go +++ b/backend/internal/service/channel.go @@ -96,6 +96,8 @@ type ChannelModelPricing struct { OutputPrice *float64 `json:"output_price"` CacheWritePrice *float64 `json:"cache_write_price"` CacheReadPrice *float64 `json:"cache_read_price"` + FastMultiplier *float64 `json:"fast_multiplier"` + FlexMultiplier *float64 `json:"flex_multiplier"` ImageInputPrice *float64 `json:"image_input_price"` ImageOutputPrice *float64 `json:"image_output_price"` PerRequestPrice *float64 `json:"per_request_price"` @@ -120,19 +122,23 @@ type ChannelTimePricingPeriod struct { // PricingInterval 定价区间(token 区间 / 按次分层 / 图片分辨率分层) type PricingInterval struct { - ID int64 `json:"id,omitempty"` - PricingID int64 `json:"pricing_id,omitempty"` - MinTokens int `json:"min_tokens"` - MaxTokens *int `json:"max_tokens"` - TierLabel string `json:"tier_label"` - InputPrice *float64 `json:"input_price"` - OutputPrice *float64 `json:"output_price"` - CacheWritePrice *float64 `json:"cache_write_price"` - CacheReadPrice *float64 `json:"cache_read_price"` - PerRequestPrice *float64 `json:"per_request_price"` - SortOrder int `json:"sort_order"` - CreatedAt time.Time `json:"created_at,omitempty"` - UpdatedAt time.Time `json:"updated_at,omitempty"` + ID int64 `json:"id,omitempty"` + PricingID int64 `json:"pricing_id,omitempty"` + MinTokens int `json:"min_tokens"` + MaxTokens *int `json:"max_tokens"` + TierLabel string `json:"tier_label"` + InputPrice *float64 `json:"input_price"` + OutputPrice *float64 `json:"output_price"` + CacheWritePrice *float64 `json:"cache_write_price"` + CacheReadPrice *float64 `json:"cache_read_price"` + InputMultiplier *float64 `json:"input_multiplier"` + OutputMultiplier *float64 `json:"output_multiplier"` + CacheWriteMultiplier *float64 `json:"cache_write_multiplier"` + CacheReadMultiplier *float64 `json:"cache_read_multiplier"` + PerRequestPrice *float64 `json:"per_request_price"` + SortOrder int `json:"sort_order"` + CreatedAt time.Time `json:"created_at,omitempty"` + UpdatedAt time.Time `json:"updated_at,omitempty"` } // IsActive 判断渠道是否启用 @@ -358,7 +364,7 @@ func validateSingleInterval(iv *PricingInterval, idx int) error { return validateIntervalPrices(iv, idx) } -// validateIntervalPrices 校验区间内所有价格字段 >= 0 +// validateIntervalPrices 校验区间价格 >= 0、倍率 > 0。 func validateIntervalPrices(iv *PricingInterval, idx int) error { prices := []struct { name string @@ -375,6 +381,20 @@ func validateIntervalPrices(iv *PricingInterval, idx int) error { return fmt.Errorf("interval #%d: %s must be >= 0", idx+1, p.name) } } + multipliers := []struct { + name string + val *float64 + }{ + {"input_multiplier", iv.InputMultiplier}, + {"output_multiplier", iv.OutputMultiplier}, + {"cache_write_multiplier", iv.CacheWriteMultiplier}, + {"cache_read_multiplier", iv.CacheReadMultiplier}, + } + for _, multiplier := range multipliers { + if multiplier.val != nil && *multiplier.val <= 0 { + return fmt.Errorf("interval #%d: %s must be > 0", idx+1, multiplier.name) + } + } return nil } 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/channel_service.go b/backend/internal/service/channel_service.go index ee732dbeef..b3fe7dc63a 100644 --- a/backend/internal/service/channel_service.go +++ b/backend/internal/service/channel_service.go @@ -735,6 +735,17 @@ func checkPricesNotNegative(p ChannelModelPricing) error { return infraerrors.BadRequest("NEGATIVE_PRICE", fmt.Sprintf("%s must be >= 0", c.field)) } } + for _, c := range []struct { + field string + val *float64 + }{ + {"fast_multiplier", p.FastMultiplier}, + {"flex_multiplier", p.FlexMultiplier}, + } { + if c.val != nil && *c.val <= 0 { + return infraerrors.BadRequest("INVALID_MULTIPLIER", fmt.Sprintf("%s must be > 0", c.field)) + } + } return nil } @@ -742,7 +753,9 @@ func checkIntervalsHavePrices(p ChannelModelPricing) error { for _, iv := range p.Intervals { if iv.InputPrice == nil && iv.OutputPrice == nil && iv.CacheWritePrice == nil && iv.CacheReadPrice == nil && - iv.PerRequestPrice == nil { + iv.PerRequestPrice == nil && iv.InputMultiplier == nil && + iv.OutputMultiplier == nil && iv.CacheWriteMultiplier == nil && + iv.CacheReadMultiplier == nil { return infraerrors.BadRequest( "INTERVAL_MISSING_PRICE", fmt.Sprintf("interval [%d, %s] has no price fields set for model %v", 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 6a2e2930cd..7203d618da 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/model_pricing_resolver.go b/backend/internal/service/model_pricing_resolver.go index 52c6b930c5..62283e81b7 100644 --- a/backend/internal/service/model_pricing_resolver.go +++ b/backend/internal/service/model_pricing_resolver.go @@ -120,14 +120,8 @@ func (r *ModelPricingResolver) Resolve(ctx context.Context, input PricingInput) resolved.Source = PricingSourceChannel resolved.channelPricing = chPricing r.applyTokenOverrides(chPricing, resolved) - if !longContextPricingEnabled { - r.applyFirstTokenTier(resolved, chPricing) - } } else if input.GroupID != nil && r.channelService != nil { r.applyChannelOverrides(ctx, *input.GroupID, input.Model, resolved) - if resolved.Source == PricingSourceChannel && !longContextPricingEnabled { - r.applyFirstTokenTier(resolved, resolved.channelPricing) - } } return resolved @@ -172,20 +166,6 @@ func matchGroupModelPricing(group *Group, model string) *ChannelModelPricing { return wildcard } -func (r *ModelPricingResolver) applyFirstTokenTier(resolved *ResolvedPricing, config *ChannelModelPricing) { - if resolved == nil || len(resolved.Intervals) == 0 { - return - } - first := resolved.Intervals[0] - for _, interval := range resolved.Intervals[1:] { - if interval.MinTokens < first.MinTokens { - first = interval - } - } - resolved.BasePricing = intervalToModelPricing(&first, resolved.SupportsCacheBreakdown, config) - resolved.Intervals = nil -} - // resolveBasePricing 从 LiteLLM 或 Fallback 获取基础定价 func (r *ModelPricingResolver) resolveBasePricing(model string) (*ModelPricing, string) { pricing, err := r.billingService.GetModelPricing(model) @@ -221,31 +201,6 @@ func (r *ModelPricingResolver) applyChannelOverrides(ctx context.Context, groupI // applyTokenOverrides 应用 token 模式的渠道覆盖 func (r *ModelPricingResolver) applyTokenOverrides(chPricing *ChannelModelPricing, resolved *ResolvedPricing) { - // 过滤掉所有价格字段都为空的无效 interval - validIntervals := filterValidIntervals(chPricing.Intervals) - - // 如果有有效的区间定价,使用区间 - if len(validIntervals) > 0 { - resolved.Intervals = validIntervals - // 区间不匹配时回退到 BasePricing,也需要覆盖图片价格 - if resolved.BasePricing == nil { - resolved.BasePricing = &ModelPricing{} - } else { - // 防止修改 fallbackPrices 中的共享指针 - cloned := *resolved.BasePricing - resolved.BasePricing = &cloned - } - if chPricing.ImageOutputPrice != nil { - resolved.BasePricing.ImageOutputPricePerToken = *chPricing.ImageOutputPrice - } else { - resolved.BasePricing.ImageOutputPricePerToken = 0 - } - resolved.BasePricing.ImageOutputPriceExplicit = true - applyChannelImageInputPrice(chPricing, resolved.BasePricing) - return - } - - // 否则用 flat 字段覆盖 BasePricing if resolved.BasePricing == nil { resolved.BasePricing = &ModelPricing{} } else { @@ -254,25 +209,9 @@ func (r *ModelPricingResolver) applyTokenOverrides(chPricing *ChannelModelPricin resolved.BasePricing = &cloned } - if chPricing.InputPrice != nil { - resolved.BasePricing.InputPricePerToken = *chPricing.InputPrice - resolved.BasePricing.InputPricePerTokenPriority = *chPricing.InputPrice - } - if chPricing.OutputPrice != nil { - resolved.BasePricing.OutputPricePerToken = *chPricing.OutputPrice - resolved.BasePricing.OutputPricePerTokenPriority = *chPricing.OutputPrice - } - if chPricing.CacheWritePrice != nil { - resolved.BasePricing.CacheCreationPricePerToken = *chPricing.CacheWritePrice - resolved.BasePricing.CacheCreationPricePerTokenPriority = *chPricing.CacheWritePrice - resolved.BasePricing.CacheCreationPriceExplicit = true - resolved.BasePricing.CacheCreation5mPrice = *chPricing.CacheWritePrice - resolved.BasePricing.CacheCreation1hPrice = *chPricing.CacheWritePrice - } - if chPricing.CacheReadPrice != nil { - resolved.BasePricing.CacheReadPricePerToken = *chPricing.CacheReadPrice - resolved.BasePricing.CacheReadPricePerTokenPriority = *chPricing.CacheReadPrice - } + applyChannelTokenPriceOverrides(resolved.BasePricing, chPricing) + resolved.BasePricing.FastMultiplier = chPricing.FastMultiplier + resolved.BasePricing.FlexMultiplier = chPricing.FlexMultiplier // 渠道定价覆盖一切:显式配置则用配置值,未配置则归零(不回退到 LiteLLM) if chPricing.ImageOutputPrice != nil { resolved.BasePricing.ImageOutputPricePerToken = *chPricing.ImageOutputPrice @@ -281,6 +220,9 @@ func (r *ModelPricingResolver) applyTokenOverrides(chPricing *ChannelModelPricin } resolved.BasePricing.ImageOutputPriceExplicit = true applyChannelImageInputPrice(chPricing, resolved.BasePricing) + + // 区间未命中时回退到上面已经应用渠道覆盖的基础价。 + resolved.Intervals = filterValidIntervals(chPricing.Intervals) } // applyChannelImageInputPrice 应用渠道图片输入价:显式配置则用配置值; @@ -311,7 +253,9 @@ func filterValidIntervals(intervals []PricingInterval) []PricingInterval { for _, iv := range intervals { if iv.InputPrice != nil || iv.OutputPrice != nil || iv.CacheWritePrice != nil || iv.CacheReadPrice != nil || - iv.PerRequestPrice != nil { + iv.PerRequestPrice != nil || iv.InputMultiplier != nil || + iv.OutputMultiplier != nil || iv.CacheWriteMultiplier != nil || + iv.CacheReadMultiplier != nil { valid = append(valid, iv) } } @@ -330,32 +274,57 @@ func (r *ModelPricingResolver) GetIntervalPricing(resolved *ResolvedPricing, tot return resolved.BasePricing } - return intervalToModelPricing(iv, resolved.SupportsCacheBreakdown, resolved.channelPricing) + pricing := intervalToModelPricing(iv, resolved.BasePricing, resolved.channelPricing) + // BasePricing 为 nil(仅配置区间)时拷贝不到该标志,从 resolved 回填, + // 保证 computeCacheCreationCost 的 5m/1h 分档判断不被区间路径吞掉。 + pricing.SupportsCacheBreakdown = resolved.SupportsCacheBreakdown + return pricing } // intervalToModelPricing 将区间定价转换为 ModelPricing -func intervalToModelPricing(iv *PricingInterval, supportsCacheBreakdown bool, chPricing *ChannelModelPricing) *ModelPricing { - pricing := &ModelPricing{ - SupportsCacheBreakdown: supportsCacheBreakdown, +func intervalToModelPricing(iv *PricingInterval, base *ModelPricing, chPricing *ChannelModelPricing) *ModelPricing { + pricing := &ModelPricing{} + if base != nil { + *pricing = *base + } + applyMultiplier := func(value float64, multiplier *float64) float64 { + if multiplier == nil { + return value + } + return value * *multiplier } if iv.InputPrice != nil { + pricing.InputPricePerTokenPriority = channelTierOverridePrice(pricing.InputPricePerToken, pricing.InputPricePerTokenPriority, *iv.InputPrice) pricing.InputPricePerToken = *iv.InputPrice - pricing.InputPricePerTokenPriority = *iv.InputPrice + } else if iv.InputMultiplier != nil { + pricing.InputPricePerToken = applyMultiplier(pricing.InputPricePerToken, iv.InputMultiplier) + pricing.InputPricePerTokenPriority = applyMultiplier(pricing.InputPricePerTokenPriority, iv.InputMultiplier) } if iv.OutputPrice != nil { + pricing.OutputPricePerTokenPriority = channelTierOverridePrice(pricing.OutputPricePerToken, pricing.OutputPricePerTokenPriority, *iv.OutputPrice) pricing.OutputPricePerToken = *iv.OutputPrice - pricing.OutputPricePerTokenPriority = *iv.OutputPrice + } else if iv.OutputMultiplier != nil { + pricing.OutputPricePerToken = applyMultiplier(pricing.OutputPricePerToken, iv.OutputMultiplier) + pricing.OutputPricePerTokenPriority = applyMultiplier(pricing.OutputPricePerTokenPriority, iv.OutputMultiplier) } if iv.CacheWritePrice != nil { + pricing.CacheCreationPricePerTokenPriority = channelTierOverridePrice(pricing.CacheCreationPricePerToken, pricing.CacheCreationPricePerTokenPriority, *iv.CacheWritePrice) pricing.CacheCreationPricePerToken = *iv.CacheWritePrice - pricing.CacheCreationPricePerTokenPriority = *iv.CacheWritePrice pricing.CacheCreationPriceExplicit = true pricing.CacheCreation5mPrice = *iv.CacheWritePrice pricing.CacheCreation1hPrice = *iv.CacheWritePrice + } else if iv.CacheWriteMultiplier != nil { + pricing.CacheCreationPricePerToken = applyMultiplier(pricing.CacheCreationPricePerToken, iv.CacheWriteMultiplier) + pricing.CacheCreationPricePerTokenPriority = applyMultiplier(pricing.CacheCreationPricePerTokenPriority, iv.CacheWriteMultiplier) + pricing.CacheCreation5mPrice = applyMultiplier(pricing.CacheCreation5mPrice, iv.CacheWriteMultiplier) + pricing.CacheCreation1hPrice = applyMultiplier(pricing.CacheCreation1hPrice, iv.CacheWriteMultiplier) } if iv.CacheReadPrice != nil { + pricing.CacheReadPricePerTokenPriority = channelTierOverridePrice(pricing.CacheReadPricePerToken, pricing.CacheReadPricePerTokenPriority, *iv.CacheReadPrice) pricing.CacheReadPricePerToken = *iv.CacheReadPrice - pricing.CacheReadPricePerTokenPriority = *iv.CacheReadPrice + } else if iv.CacheReadMultiplier != nil { + pricing.CacheReadPricePerToken = applyMultiplier(pricing.CacheReadPricePerToken, iv.CacheReadMultiplier) + pricing.CacheReadPricePerTokenPriority = applyMultiplier(pricing.CacheReadPricePerTokenPriority, iv.CacheReadMultiplier) } // 渠道定价存在时,ImageOutputPrice 显式覆盖;图片输入价用渠道级配置 // (区间不携带图片输入价,与 image_output 一致)。 diff --git a/backend/internal/service/model_pricing_resolver_test.go b/backend/internal/service/model_pricing_resolver_test.go index 11613fa9eb..3fb3e1c63f 100644 --- a/backend/internal/service/model_pricing_resolver_test.go +++ b/backend/internal/service/model_pricing_resolver_test.go @@ -142,7 +142,7 @@ func TestGPT56ExplicitZeroCacheWritePriceIsPreserved(t *testing.T) { }) t.Run("interval price", func(t *testing.T) { - pricing := intervalToModelPricing(&PricingInterval{CacheWritePrice: &zero}, false, nil) + pricing := intervalToModelPricing(&PricingInterval{CacheWritePrice: &zero}, &ModelPricing{}, nil) require.True(t, pricing.CacheCreationPriceExplicit) cost, err := bs.CalculateCostUnified(CostInput{ @@ -261,9 +261,9 @@ func TestResolve_WithChannelOverride_TokenFlat(t *testing.T) { require.Equal(t, "channel", resolved.Source) require.NotNil(t, resolved.BasePricing) require.InDelta(t, 10e-6, resolved.BasePricing.InputPricePerToken, 1e-12) - require.InDelta(t, 10e-6, resolved.BasePricing.InputPricePerTokenPriority, 1e-12) + require.Zero(t, resolved.BasePricing.InputPricePerTokenPriority) require.InDelta(t, 50e-6, resolved.BasePricing.OutputPricePerToken, 1e-12) - require.InDelta(t, 50e-6, resolved.BasePricing.OutputPricePerTokenPriority, 1e-12) + require.Zero(t, resolved.BasePricing.OutputPricePerTokenPriority) } func TestResolve_WithChannelOverride_TokenPartialOverride(t *testing.T) { @@ -304,10 +304,12 @@ func TestResolve_WithChannelOverride_TokenWithIntervals(t *testing.T) { resolved := r.Resolve(context.Background(), PricingInput{ Model: "claude-sonnet-4", GroupID: groupIDPtr(), + Group: &Group{LongContextPricingEnabled: false}, }) require.NotNil(t, resolved) require.Equal(t, "channel", resolved.Source) + require.False(t, resolved.longContextPricingEnabled) require.Len(t, resolved.Intervals, 2) // GetIntervalPricing should use channel intervals @@ -532,6 +534,7 @@ func TestGetIntervalPricing_ChannelIntervalsNoMatch(t *testing.T) { Platform: "anthropic", Models: []string{"claude-sonnet-4"}, BillingMode: BillingModeToken, + InputPrice: testPtrFloat64(4e-6), Intervals: []PricingInterval{ // Only covers tokens > 50000 {MinTokens: 50000, MaxTokens: testPtrInt(200000), InputPrice: testPtrFloat64(9e-6)}, @@ -545,10 +548,10 @@ func TestGetIntervalPricing_ChannelIntervalsNoMatch(t *testing.T) { // Token count 1000 doesn't match any interval (1000 <= 50000 minTokens) pricing := r.GetIntervalPricing(resolved, 1000) - // Should fall back to BasePricing (from the billing service fallback) + // Should fall back to BasePricing after applying the channel default. require.NotNil(t, pricing) require.Equal(t, resolved.BasePricing, pricing) - require.InDelta(t, 3e-6, pricing.InputPricePerToken, 1e-12) // original base price + require.InDelta(t, 4e-6, pricing.InputPricePerToken, 1e-12) } // =========================================================================== @@ -689,6 +692,13 @@ func TestFilterValidIntervals(t *testing.T) { }, wantLen: 1, }, + { + name: "interval with only multiplier kept", + intervals: []PricingInterval{ + {MinTokens: 272000, InputMultiplier: testPtrFloat64(2)}, + }, + wantLen: 1, + }, { name: "mixed valid and invalid", intervals: []PricingInterval{ diff --git a/backend/internal/service/openai_gateway_grok_tool_protocol.go b/backend/internal/service/openai_gateway_grok_tool_protocol.go index c22282f8ca..9f876a1a6e 100644 --- a/backend/internal/service/openai_gateway_grok_tool_protocol.go +++ b/backend/internal/service/openai_gateway_grok_tool_protocol.go @@ -27,6 +27,7 @@ func adaptResponsesClientToolsForFunctionUpstreamWithMapping( body []byte, upstream string, inherited apicompat.ResponsesClientToolMapping, + inheritedLoweredTools ...[]any, ) ([]byte, apicompat.ResponsesClientToolMapping, error) { decoder := json.NewDecoder(bytes.NewReader(body)) decoder.UseNumber() @@ -35,7 +36,7 @@ func adaptResponsesClientToolsForFunctionUpstreamWithMapping( return body, apicompat.ResponsesClientToolMapping{}, fmt.Errorf("decode %s Responses client tools: %w", upstream, err) } - mapping, changed, err := apicompat.AdaptResponsesClientToolsWithInheritedMapping(requestBody, inherited) + mapping, changed, err := apicompat.AdaptResponsesClientToolsWithInheritedMapping(requestBody, inherited, inheritedLoweredTools...) if err != nil { return body, apicompat.ResponsesClientToolMapping{}, err } diff --git a/backend/internal/service/openai_gateway_grok_tool_protocol_test.go b/backend/internal/service/openai_gateway_grok_tool_protocol_test.go index bca3d3f2bd..cc25cb1924 100644 --- a/backend/internal/service/openai_gateway_grok_tool_protocol_test.go +++ b/backend/internal/service/openai_gateway_grok_tool_protocol_test.go @@ -65,6 +65,60 @@ func TestPatchGrokResponsesBodyWithClientToolsLowersCodexProtocol(t *testing.T) require.False(t, gjson.GetBytes(patched, "input.4.namespace").Exists()) } +func TestPatchGrokResponsesBodyWithClientToolsLowersDiscoveredToolsOutput(t *testing.T) { + t.Parallel() + + body := []byte(`{ + "model":"grok-4.5", + "tools":[{"type":"tool_search"}], + "input":[ + {"type":"tool_search_call","id":"tsc_fixture","call_id":"call_fixture","arguments":{"query":"subagent"},"execution":"client","status":"completed"}, + {"type":"tool_search_output","id":"tso_fixture","call_id":"call_fixture","execution":"client","status":"completed","tools":[ + {"type":"namespace","name":"codex_app","tools":[{"type":"function","name":"load_workspace_dependencies","parameters":{"type":"object","properties":{},"additionalProperties":false}}]}, + {"type":"namespace","name":"multi_agent_v1","tools":[ + {"type":"function","name":"spawn_agent","parameters":{"type":"object","properties":{"message":{"type":"string"}},"required":["message"],"additionalProperties":false}}, + {"type":"function","name":"wait_agent","parameters":{"type":"object","properties":{"timeout_ms":{"type":"integer"}},"additionalProperties":false}} + ]} + ]} + ] + }`) + + patched, mapping, err := patchGrokResponsesBodyWithClientTools(body, "grok-4.5") + require.NoError(t, err) + require.True(t, mapping.ToolSearch) + require.Equal(t, apicompat.ResponsesNamespaceName{Namespace: "multi_agent_v1", Name: "spawn_agent"}, mapping.NamespaceTools["multi_agent_v1__spawn_agent"]) + require.Equal(t, apicompat.ResponsesNamespaceName{Namespace: "multi_agent_v1", Name: "wait_agent"}, mapping.NamespaceTools["multi_agent_v1__wait_agent"]) + output := gjson.GetBytes(patched, "input.1.output").String() + require.JSONEq(t, `[ + {"type":"namespace","name":"codex_app","tools":[{"type":"function","name":"load_workspace_dependencies","parameters":{"type":"object","properties":{},"additionalProperties":false}}]}, + {"type":"namespace","name":"multi_agent_v1","tools":[ + {"type":"function","name":"spawn_agent","parameters":{"type":"object","properties":{"message":{"type":"string"}},"required":["message"],"additionalProperties":false}}, + {"type":"function","name":"wait_agent","parameters":{"type":"object","properties":{"timeout_ms":{"type":"integer"}},"additionalProperties":false}} + ]} + ]`, output) + + require.JSONEq(t, `{ + "model":"grok-4.5", + "tools":[ + {"type":"function","name":"tool_search","description":"Search and load Codex tools, plugins, connectors, and MCP namespaces for the current task.","parameters":{"type":"object","properties":{"query":{"type":"string","description":"Search query for tools or connectors to load."},"limit":{"type":"integer","description":"Maximum number of tool groups to return."}},"required":["query"]}}, + {"type":"function","name":"codex_app__load_workspace_dependencies","parameters":{"type":"object","properties":{},"additionalProperties":false}}, + {"type":"function","name":"multi_agent_v1__spawn_agent","parameters":{"type":"object","properties":{"message":{"type":"string"}},"required":["message"],"additionalProperties":false}}, + {"type":"function","name":"multi_agent_v1__wait_agent","parameters":{"type":"object","properties":{"timeout_ms":{"type":"integer"}},"additionalProperties":false}} + ], + "input":[ + {"type":"function_call","call_id":"call_fixture","name":"tool_search","arguments":"{\"query\":\"subagent\"}","status":"completed"}, + {"type":"function_call_output","call_id":"call_fixture","output":`+string(mustMarshalJSONForTest(t, output))+`} + ] + }`, string(patched)) +} + +func mustMarshalJSONForTest(t *testing.T, value string) []byte { + t.Helper() + encoded, err := json.Marshal(value) + require.NoError(t, err) + return encoded +} + func TestPatchGrokResponsesBodyWithClientToolsRewritesEveryToolChoice(t *testing.T) { t.Parallel() @@ -174,6 +228,32 @@ func TestForwardGrokResponsesClientToolNameConflictReturns400(t *testing.T) { require.Empty(t, upstream.requests, "an ambiguous request must not reach xAI") } +func TestForwardGrokResponsesMalformedToolSearchOutputReturns400BeforeUpstream(t *testing.T) { + gin.SetMode(gin.TestMode) + + body := []byte(`{ + "model":"grok","stream":false, + "tools":[{"type":"tool_search"}], + "input":[{"type":"tool_search_output","status":"completed"}] + }`) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body)) + upstream := &httpUpstreamRecorder{} + svc := &OpenAIGatewayService{httpUpstream: upstream} + account := grokProtocolAPIKeyAccount(7103) + + result, err := svc.forwardGrokResponses(context.Background(), c, account, body, "grok", false, time.Now()) + + require.Error(t, err) + require.Nil(t, result) + require.Equal(t, http.StatusBadRequest, recorder.Code) + require.Equal(t, "invalid_request_error", gjson.Get(recorder.Body.String(), "error.type").String()) + require.Equal(t, "tools", gjson.Get(recorder.Body.String(), "error.param").String()) + require.Contains(t, gjson.Get(recorder.Body.String(), "error.message").String(), "call_id") + require.Empty(t, upstream.requests, "malformed lowered output must not reach xAI") +} + func TestForwardGrokResponsesOAuthRestoresClientToolsNonStreaming(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/backend/internal/service/openai_gateway_record_usage_test.go b/backend/internal/service/openai_gateway_record_usage_test.go index 21e5cd4096..9cca810400 100644 --- a/backend/internal/service/openai_gateway_record_usage_test.go +++ b/backend/internal/service/openai_gateway_record_usage_test.go @@ -1148,7 +1148,7 @@ func TestOpenAIGatewayServiceRecordUsage_GPT56SeparatesCacheWriteForBillingAndSt require.InDelta(t, usageRepo.lastLog.TotalCost*1.1, usageRepo.lastLog.ActualCost, 1e-12) } -func TestOpenAIGatewayServiceRecordUsage_Gpt54LongContextBillingDisabledByDefault(t *testing.T) { +func TestOpenAIGatewayServiceRecordUsage_Gpt54LongContextBillingDisabledWhenGroupAndAccountOff(t *testing.T) { usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} userRepo := &openAIRecordUsageUserRepoStub{} subRepo := &openAIRecordUsageSubRepoStub{} @@ -1164,7 +1164,7 @@ func TestOpenAIGatewayServiceRecordUsage_Gpt54LongContextBillingDisabledByDefaul Model: "gpt-5.4-2026-03-05", Duration: time.Second, }, - APIKey: openAIRecordUsageAPIKeyWithGroup(svc, 1014, true), + APIKey: openAIRecordUsageAPIKeyWithGroup(svc, 1014, false), User: &User{ID: 2014}, Account: &Account{ID: 3014, Platform: PlatformOpenAI}, }) @@ -1219,7 +1219,7 @@ func TestOpenAIGatewayServiceRecordUsage_Gpt54LongContextBillingEnabledPerAccoun require.True(t, usageRepo.lastLog.LongContextBillingApplied) } -func TestOpenAIGatewayServiceRecordUsage_GroupAndAccountLongContextMustBothAllow(t *testing.T) { +func TestOpenAIGatewayServiceRecordUsage_GroupOrAccountLongContextAllows(t *testing.T) { tokens := OpenAIUsage{InputTokens: 300000, OutputTokens: 2000} baseInput := 300000 * 2.5e-6 baseOutput := 2000 * 15e-6 @@ -1234,9 +1234,9 @@ func TestOpenAIGatewayServiceRecordUsage_GroupAndAccountLongContextMustBothAllow Account: &Account{ID: 3020, Platform: PlatformOpenAI}, }) require.NoError(t, err) - require.False(t, usageRepo.lastLog.LongContextBillingApplied) - require.InDelta(t, baseInput, usageRepo.lastLog.InputCost, 1e-10) - require.InDelta(t, baseOutput, usageRepo.lastLog.OutputCost, 1e-10) + require.True(t, usageRepo.lastLog.LongContextBillingApplied) + require.InDelta(t, baseInput*2, usageRepo.lastLog.InputCost, 1e-10) + require.InDelta(t, baseOutput*1.5, usageRepo.lastLog.OutputCost, 1e-10) }) t.Run("group off account on", func(t *testing.T) { @@ -1252,8 +1252,9 @@ func TestOpenAIGatewayServiceRecordUsage_GroupAndAccountLongContextMustBothAllow }, }) require.NoError(t, err) - require.False(t, usageRepo.lastLog.LongContextBillingApplied) - require.InDelta(t, baseInput, usageRepo.lastLog.InputCost, 1e-10) + require.True(t, usageRepo.lastLog.LongContextBillingApplied) + require.InDelta(t, baseInput*2, usageRepo.lastLog.InputCost, 1e-10) + require.InDelta(t, baseOutput*1.5, usageRepo.lastLog.OutputCost, 1e-10) }) t.Run("group on account on", func(t *testing.T) { @@ -1361,7 +1362,7 @@ func TestOpenAIGatewayServiceRecordUsage_SparkShadowUsesCurrentParentBillingSett Model: "gpt-5.4-2026-03-05", Duration: time.Second, }, - APIKey: openAIRecordUsageAPIKeyWithGroup(svc, 1016, true), + APIKey: openAIRecordUsageAPIKeyWithGroup(svc, 1016, false), User: &User{ID: 2016}, Account: &Account{ ID: 3016, diff --git a/backend/internal/service/openai_ws_http_bridge.go b/backend/internal/service/openai_ws_http_bridge.go index 1580b287b1..ae41c3bbbd 100644 --- a/backend/internal/service/openai_ws_http_bridge.go +++ b/backend/internal/service/openai_ws_http_bridge.go @@ -15,7 +15,6 @@ import ( "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" "github.com/gin-gonic/gin" "github.com/tidwall/gjson" - "github.com/tidwall/sjson" ) const ( @@ -48,6 +47,33 @@ func setOpenAIWSHTTPBridgeToolState(c *gin.Context, state openAIWSHTTPBridgeTool c.Set(openAIWSHTTPBridgeToolStateContextKey, state) } +func decodeOpenAIWSHTTPBridgeLoweredTools(raw json.RawMessage) []any { + if len(raw) == 0 { + return nil + } + var tools []any + if err := json.Unmarshal(raw, &tools); err != nil { + return nil + } + return tools +} + +func openAIWSHTTPBridgeRawField(body []byte, name string) (json.RawMessage, bool) { + var fields map[string]json.RawMessage + if err := json.Unmarshal(body, &fields); err != nil { + return nil, false + } + raw, present := fields[name] + return append(json.RawMessage(nil), raw...), present +} + +func openAIWSHTTPBridgeToolUpstreamName(account *Account) string { + if account != nil && account.Platform == PlatformGrok { + return "Grok WS HTTP bridge" + } + return "OpenAI WS HTTP bridge" +} + // ResolveOpenAIWSClientFirstMessageTimeout returns the effective client ingress deadline. func ResolveOpenAIWSClientFirstMessageTimeout(cfg *config.Config) time.Duration { seconds := config.DefaultOpenAIWSClientFirstMessageTimeoutSeconds @@ -283,26 +309,40 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( if err != nil { return nil, fmt.Errorf("prepare http bridge body: %w", err) } + grokIntentSourceBody := append([]byte(nil), body...) + _, grokExplicitToolsField := openAIWSHTTPBridgeRawField(grokIntentSourceBody, "tools") + grokExplicitToolIntent := account.Platform == PlatformGrok && hasGrokResponsesToolIntent(grokIntentSourceBody) var clientToolMapping apicompat.ResponsesClientToolMapping - if account.Platform == PlatformOpenAI && account.Type == AccountTypeAPIKey { + functionToolUpstream := (account.Platform == PlatformOpenAI && account.Type == AccountTypeAPIKey) || account.Platform == PlatformGrok + if functionToolUpstream { + if account.Platform == PlatformGrok { + body, err = sanitizeGrokResponsesInput(body) + if err != nil { + return nil, fmt.Errorf("sanitize Grok WS HTTP bridge input: %w", err) + } + } inheritedState, _ := openAIWSHTTPBridgeToolStateFromContext(c) - toolsPresent := gjson.GetBytes(body, "tools").Exists() + inheritedLoweredTools := decodeOpenAIWSHTTPBridgeLoweredTools(inheritedState.LoweredTools) body, clientToolMapping, err = adaptResponsesClientToolsForFunctionUpstreamWithMapping( body, - "OpenAI WS HTTP bridge", + openAIWSHTTPBridgeToolUpstreamName(account), inheritedState.ClientMapping, + inheritedLoweredTools, ) if err != nil { - return nil, fmt.Errorf("adapt OpenAI WS HTTP bridge client tools: %w", err) + return nil, fmt.Errorf("adapt %s client tools: %w", openAIWSHTTPBridgeToolUpstreamName(account), err) + } + if account.Platform == PlatformGrok && !grokExplicitToolsField && !grokExplicitToolIntent && len(inheritedLoweredTools) > 0 && hasGrokResponsesToolIntent(body) { + // This continuation omitted tools, so the pre-adapter source cannot + // represent the effective inherited declarations. Cache routing must + // see the rehydrated tool intent or it will replace client functions + // with the native-search tool-free route. Explicit current-turn tool + // intent still uses the original pre-sanitization source above. + grokIntentSourceBody = append(grokIntentSourceBody[:0], body...) } loweredTools := inheritedState.LoweredTools - if toolsPresent { - loweredTools = json.RawMessage(gjson.GetBytes(body, "tools").Raw) - } else if len(loweredTools) > 0 { - body, err = sjson.SetRawBytes(body, "tools", loweredTools) - if err != nil { - return nil, fmt.Errorf("inherit OpenAI WS HTTP bridge tools: %w", err) - } + if currentTools, present := openAIWSHTTPBridgeRawField(body, "tools"); present { + loweredTools = currentTools } setOpenAIWSHTTPBridgeToolState(c, openAIWSHTTPBridgeToolState{ ClientMapping: clientToolMapping, @@ -330,7 +370,6 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( } if account.Platform == PlatformGrok { upstreamModel := resolveGrokWSUpstreamModel(account, body, originalModel) - grokIntentSourceBody := body body, err = patchGrokResponsesBody(body, upstreamModel) if err != nil { return nil, err diff --git a/backend/internal/service/openai_ws_http_bridge_test.go b/backend/internal/service/openai_ws_http_bridge_test.go index 15780d399c..5825d180ff 100644 --- a/backend/internal/service/openai_ws_http_bridge_test.go +++ b/backend/internal/service/openai_ws_http_bridge_test.go @@ -173,6 +173,131 @@ func TestProxyOpenAIWSHTTPBridgeTurnAPIKeyRestoresClientToolsInResponseDone(t *t require.Equal(t, "custom_tool_call", gjson.GetBytes(result.wsReplayInput[0], "type").String()) } +func TestProxyOpenAIWSHTTPBridgeTurnGrokPromotesDiscoveryAndRestoresNamespaceSSE(t *testing.T) { + gin.SetMode(gin.TestMode) + + sse := strings.Join([]string{ + `data: {"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"type":"function_call","id":"item_spawn","call_id":"call_spawn","name":"multi_agent_v1__spawn_agent","status":"in_progress"}}`, + "", + `data: {"type":"response.function_call_arguments.done","sequence_number":1,"output_index":0,"item_id":"item_spawn","call_id":"call_spawn","name":"multi_agent_v1__spawn_agent","arguments":"{\"message\":\"work\"}"}`, + "", + `data: {"type":"response.output_item.done","sequence_number":2,"output_index":0,"item":{"type":"function_call","id":"item_spawn","call_id":"call_spawn","name":"multi_agent_v1__spawn_agent","arguments":"{\"message\":\"work\"}","status":"completed"}}`, + "", + `data: {"type":"response.completed","sequence_number":3,"response":{"id":"resp_spawn","status":"completed","output":[{"type":"function_call","id":"item_spawn","call_id":"call_spawn","name":"multi_agent_v1__spawn_agent","arguments":"{\"message\":\"work\"}","status":"completed"}],"usage":{"input_tokens":1,"output_tokens":1}}}`, + "", + }, "\n") + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(sse)), + }} + svc := &OpenAIGatewayService{ + cfg: &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}, + httpUpstream: upstream, + } + account := &Account{ + ID: 5765, Platform: PlatformGrok, Type: AccountTypeOAuth, Concurrency: 1, + Credentials: map[string]any{"base_url": xai.DefaultCLIBaseURL}, + } + payload := []byte(`{ + "type":"response.create","model":"grok-4.5","stream":true, + "tools":[{"type":"tool_search"}], + "input":[ + {"type":"tool_search_call","call_id":"call_search","arguments":{"query":"subagent"},"status":"completed"}, + {"type":"tool_search_output","call_id":"call_search","execution":"client","status":"completed","tools":[ + {"type":"namespace","name":"multi_agent_v1","tools":[{"type":"function","name":"spawn_agent","parameters":{"type":"object"}}]} + ]} + ] + }`) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodGet, "/v1/responses", nil) + var events [][]byte + + result, err := svc.proxyOpenAIWSHTTPBridgeTurn( + context.Background(), c, account, "access-token", payload, len(payload), + "grok-4.5", "", "", "", "", 1, + func(message []byte) error { + events = append(events, append([]byte(nil), message...)) + return nil + }, + ) + + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, "multi_agent_v1__spawn_agent", gjson.GetBytes(upstream.lastBody, "tools.1.name").String()) + require.Equal(t, "function_call_output", gjson.GetBytes(upstream.lastBody, "input.1.type").String()) + require.False(t, gjson.GetBytes(upstream.lastBody, "input.1.tools").Exists()) + require.False(t, gjson.GetBytes(upstream.lastBody, "input.1.status").Exists()) + require.False(t, gjson.GetBytes(upstream.lastBody, "input.1.execution").Exists()) + state, ok := openAIWSHTTPBridgeToolStateFromContext(c) + require.True(t, ok) + require.Equal(t, "multi_agent_v1", state.ClientMapping.NamespaceTools["multi_agent_v1__spawn_agent"].Namespace) + require.Len(t, events, 4) + require.Equal(t, "spawn_agent", gjson.GetBytes(events[0], "item.name").String()) + require.Equal(t, "multi_agent_v1", gjson.GetBytes(events[0], "item.namespace").String()) + require.Equal(t, "spawn_agent", gjson.GetBytes(events[2], "item.name").String()) + require.Equal(t, "multi_agent_v1", gjson.GetBytes(events[2], "item.namespace").String()) + require.Equal(t, "spawn_agent", gjson.GetBytes(events[3], "response.output.0.name").String()) + require.Equal(t, "multi_agent_v1", gjson.GetBytes(events[3], "response.output.0.namespace").String()) +} + +func TestProxyOpenAIWSHTTPBridgeTurnGrokInheritsToolSearchAndPromotesFollowupDiscovery(t *testing.T) { + gin.SetMode(gin.TestMode) + + firstSSE := "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_first\",\"output\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n" + secondSSE := "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_second\",\"output\":[{\"type\":\"function_call\",\"id\":\"item_spawn\",\"call_id\":\"call_spawn\",\"name\":\"multi_agent_v1__spawn_agent\",\"arguments\":\"{}\"}],\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n" + upstream := &httpUpstreamRecorder{responses: []*http.Response{ + {StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(firstSSE))}, + {StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(secondSSE))}, + }} + svc := &OpenAIGatewayService{ + cfg: &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}, + httpUpstream: upstream, + } + account := &Account{ + ID: 5766, Platform: PlatformGrok, Type: AccountTypeOAuth, Concurrency: 1, + Credentials: map[string]any{"base_url": xai.DefaultCLIBaseURL, "subscription_tier": "free"}, + } + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodGet, "/v1/responses", nil) + + first := []byte(`{"type":"response.create","model":"grok-4.5","stream":true,"tools":[{"type":"tool_search"}],"input":"discover tools"}`) + _, err := svc.proxyOpenAIWSHTTPBridgeTurn( + context.Background(), c, account, "access-token", first, len(first), + "grok-4.5", "", "", "", "grok-ws-cache", 1, func([]byte) error { return nil }, + ) + require.NoError(t, err) + + second := []byte(`{ + "type":"response.create","model":"grok-4.5","stream":true, + "input":[{"type":"tool_search_output","call_id":"call_search","status":"completed","tools":[ + {"type":"namespace","name":"multi_agent_v1","tools":[{"type":"function","name":"spawn_agent","parameters":{"type":"object"}}]} + ]}] + }`) + var events [][]byte + _, err = svc.proxyOpenAIWSHTTPBridgeTurn( + context.Background(), c, account, "access-token", second, len(second), + "grok-4.5", "", "", "", "grok-ws-cache", 2, + func(message []byte) error { + events = append(events, append([]byte(nil), message...)) + return nil + }, + ) + + require.NoError(t, err) + require.Len(t, upstream.bodies, 2) + require.Equal(t, "tool_search", gjson.GetBytes(upstream.bodies[1], "tools.0.name").String()) + require.Equal(t, "multi_agent_v1__spawn_agent", gjson.GetBytes(upstream.bodies[1], "tools.1.name").String()) + require.NotEqual(t, grokFreeCacheDisabledToolChoice, gjson.GetBytes(upstream.bodies[1], "tool_choice").String()) + require.Equal(t, "grok-ws-cache", gjson.GetBytes(upstream.bodies[1], "prompt_cache_key").String()) + require.Equal(t, "function_call_output", gjson.GetBytes(upstream.bodies[1], "input.0.type").String()) + require.Len(t, events, 1) + require.Equal(t, "spawn_agent", gjson.GetBytes(events[0], "response.output.0.name").String()) + require.Equal(t, "multi_agent_v1", gjson.GetBytes(events[0], "response.output.0.namespace").String()) +} + func TestOpenAIWSHTTPBridgeAPIKeyReusesClientToolMappingWhenFollowupOmitsTools(t *testing.T) { gin.SetMode(gin.TestMode) @@ -1005,11 +1130,13 @@ func TestProxyOpenAIWSHTTPBridgeTurnPromotesCodexAdditionalToolsForMixedCache(t require.Len(t, events, 2) require.False(t, gjson.GetBytes(upstream.lastBody, `input.#(type=="additional_tools")`).Exists()) tools := gjson.GetBytes(upstream.lastBody, "tools").Array() - require.Len(t, tools, 3) + require.Len(t, tools, 4) require.Equal(t, "function", tools[0].Get("type").String()) require.Equal(t, "lookup", tools[0].Get("name").String()) require.Equal(t, "web_search", tools[1].Get("type").String()) - require.Equal(t, "x_search", tools[2].Get("type").String()) + require.Equal(t, "function", tools[2].Get("type").String()) + require.Equal(t, "apply_patch", tools[2].Get("name").String()) + require.Equal(t, "x_search", tools[3].Get("type").String()) require.False(t, gjson.GetBytes(upstream.lastBody, "tool_choice").Exists()) require.False(t, gjson.GetBytes(upstream.lastBody, `tools.#(type=="custom")`).Exists()) require.False(t, gjson.GetBytes(upstream.lastBody, `tools.#(type=="namespace")`).Exists()) 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 diff --git a/backend/migrations/228_channel_pricing_multipliers.sql b/backend/migrations/228_channel_pricing_multipliers.sql new file mode 100644 index 0000000000..33322ba1a3 --- /dev/null +++ b/backend/migrations/228_channel_pricing_multipliers.sql @@ -0,0 +1,56 @@ +ALTER TABLE channel_model_pricing + ADD COLUMN IF NOT EXISTS fast_multiplier NUMERIC(12,6), + ADD COLUMN IF NOT EXISTS flex_multiplier NUMERIC(12,6); + +ALTER TABLE channel_pricing_intervals + ADD COLUMN IF NOT EXISTS input_multiplier NUMERIC(12,6), + ADD COLUMN IF NOT EXISTS output_multiplier NUMERIC(12,6), + ADD COLUMN IF NOT EXISTS cache_write_multiplier NUMERIC(12,6), + ADD COLUMN IF NOT EXISTS cache_read_multiplier NUMERIC(12,6); + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'channel_model_pricing_fast_multiplier_positive' AND conrelid = 'channel_model_pricing'::regclass) THEN + ALTER TABLE channel_model_pricing + ADD CONSTRAINT channel_model_pricing_fast_multiplier_positive + CHECK (fast_multiplier IS NULL OR fast_multiplier > 0); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'channel_model_pricing_flex_multiplier_positive' AND conrelid = 'channel_model_pricing'::regclass) THEN + ALTER TABLE channel_model_pricing + ADD CONSTRAINT channel_model_pricing_flex_multiplier_positive + CHECK (flex_multiplier IS NULL OR flex_multiplier > 0); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'channel_pricing_intervals_input_multiplier_positive' AND conrelid = 'channel_pricing_intervals'::regclass) THEN + ALTER TABLE channel_pricing_intervals + ADD CONSTRAINT channel_pricing_intervals_input_multiplier_positive + CHECK (input_multiplier IS NULL OR input_multiplier > 0); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'channel_pricing_intervals_output_multiplier_positive' AND conrelid = 'channel_pricing_intervals'::regclass) THEN + ALTER TABLE channel_pricing_intervals + ADD CONSTRAINT channel_pricing_intervals_output_multiplier_positive + CHECK (output_multiplier IS NULL OR output_multiplier > 0); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'channel_pricing_intervals_cache_write_multiplier_positive' AND conrelid = 'channel_pricing_intervals'::regclass) THEN + ALTER TABLE channel_pricing_intervals + ADD CONSTRAINT channel_pricing_intervals_cache_write_multiplier_positive + CHECK (cache_write_multiplier IS NULL OR cache_write_multiplier > 0); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'channel_pricing_intervals_cache_read_multiplier_positive' AND conrelid = 'channel_pricing_intervals'::regclass) THEN + ALTER TABLE channel_pricing_intervals + ADD CONSTRAINT channel_pricing_intervals_cache_read_multiplier_positive + CHECK (cache_read_multiplier IS NULL OR cache_read_multiplier > 0); + END IF; +END $$; + +COMMENT ON COLUMN channel_model_pricing.fast_multiplier IS + 'Fast/priority service tier multiplier applied to the selected standard channel price'; +COMMENT ON COLUMN channel_model_pricing.flex_multiplier IS + 'Flex service tier multiplier applied to the selected standard channel price'; +COMMENT ON COLUMN channel_pricing_intervals.input_multiplier IS + 'Interval input multiplier applied to the channel base input price when input_price is NULL'; +COMMENT ON COLUMN channel_pricing_intervals.output_multiplier IS + 'Interval output multiplier applied to the channel base output price when output_price is NULL'; +COMMENT ON COLUMN channel_pricing_intervals.cache_write_multiplier IS + 'Interval cache-write multiplier applied to the channel base cache-write price when cache_write_price is NULL'; +COMMENT ON COLUMN channel_pricing_intervals.cache_read_multiplier IS + 'Interval cache-read multiplier applied to the channel base cache-read price when cache_read_price is NULL'; diff --git a/backend/migrations/channel_pricing_multipliers_migration_test.go b/backend/migrations/channel_pricing_multipliers_migration_test.go new file mode 100644 index 0000000000..96ff90f53b --- /dev/null +++ b/backend/migrations/channel_pricing_multipliers_migration_test.go @@ -0,0 +1,43 @@ +package migrations + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestChannelPricingMultipliersMigration(t *testing.T) { + content, err := FS.ReadFile("228_channel_pricing_multipliers.sql") + require.NoError(t, err) + + sql := strings.Join(strings.Fields(string(content)), " ") + for _, column := range []string{ + "fast_multiplier NUMERIC(12,6)", + "flex_multiplier NUMERIC(12,6)", + "input_multiplier NUMERIC(12,6)", + "output_multiplier NUMERIC(12,6)", + "cache_write_multiplier NUMERIC(12,6)", + "cache_read_multiplier NUMERIC(12,6)", + } { + require.Contains(t, sql, "ADD COLUMN IF NOT EXISTS "+column) + } + + constraints := []struct { + table string + name string + column string + }{ + {"channel_model_pricing", "channel_model_pricing_fast_multiplier_positive", "fast_multiplier"}, + {"channel_model_pricing", "channel_model_pricing_flex_multiplier_positive", "flex_multiplier"}, + {"channel_pricing_intervals", "channel_pricing_intervals_input_multiplier_positive", "input_multiplier"}, + {"channel_pricing_intervals", "channel_pricing_intervals_output_multiplier_positive", "output_multiplier"}, + {"channel_pricing_intervals", "channel_pricing_intervals_cache_write_multiplier_positive", "cache_write_multiplier"}, + {"channel_pricing_intervals", "channel_pricing_intervals_cache_read_multiplier_positive", "cache_read_multiplier"}, + } + for _, constraint := range constraints { + require.Contains(t, sql, "conname = '"+constraint.name+"' AND conrelid = '"+constraint.table+"'::regclass") + require.Contains(t, sql, "ALTER TABLE "+constraint.table+" ADD CONSTRAINT "+constraint.name+ + " CHECK ("+constraint.column+" IS NULL OR "+constraint.column+" > 0)") + } +} diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index e8c1c84b78..8f134dd609 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -186,6 +186,16 @@ security: # Allow skipping TLS verification for proxy probe (debug only) # 允许代理探测时跳过 TLS 证书验证(仅用于调试) insecure_skip_verify: false + # Optional ordered probe targets. Leave empty to use the built-in ip-api/ipify fallback. + # parser supports: ip-api, ipify, chatgpt-trace + # 可选的有序探测目标。留空时使用内置 ip-api/ipify 回退。 + # parser 支持:ip-api、ipify、chatgpt-trace + urls: [] + # urls: + # - url: "https://chatgpt.com/cdn-cgi/trace" + # parser: "chatgpt-trace" + # - url: "https://api64.ipify.org?format=json" + # parser: "ipify" proxy_fallback: # Allow auxiliary services (update check, pricing data) to fallback to direct # connection when proxy initialization fails. Does NOT affect AI gateway connections. diff --git a/frontend/src/api/admin/channels.ts b/frontend/src/api/admin/channels.ts index 6556417ced..9ce50bcd0a 100644 --- a/frontend/src/api/admin/channels.ts +++ b/frontend/src/api/admin/channels.ts @@ -17,6 +17,10 @@ export interface PricingInterval { output_price: number | null cache_write_price: number | null cache_read_price: number | null + input_multiplier: number | null + output_multiplier: number | null + cache_write_multiplier: number | null + cache_read_multiplier: number | null per_request_price: number | null sort_order: number } @@ -41,6 +45,8 @@ export interface ChannelModelPricing { output_price: number | null cache_write_price: number | null cache_read_price: number | null + fast_multiplier?: number | null + flex_multiplier?: number | null image_input_price: number | null image_output_price: number | null per_request_price: number | null diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue index 55a0831ae1..47ee8c27d5 100644 --- a/frontend/src/components/account/CreateAccountModal.vue +++ b/frontend/src/components/account/CreateAccountModal.vue @@ -2958,6 +2958,7 @@