From 1262654d9785673cfda3071a714f3a61734933e7 Mon Sep 17 00:00:00 2001 From: erio Date: Mon, 13 Apr 2026 11:37:08 +0800 Subject: [PATCH] feat: WebSearch tri-state, account stats pricing fix, quota cache fix, usage tooltip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WebSearch tri-state switch: - Account-level web_search_emulation changed from bool to tri-state string: "default" (follow channel) / "enabled" / "disabled" - shouldEmulateWebSearch checks channel config when account is "default" - SQL migration converts old bool values - Frontend select replaces toggle in Edit/CreateAccountModal Account stats pricing: - resolveAccountStatsCost uses upstream model (post-mapping) for matching - Priority: custom rules → model pricing file (when toggle on) → default - Custom rules always configurable, independent of toggle - Account ID field changed to searchable selector filtered by platform - Description updated to reflect new behavior Quota notification cache fix: - CheckAccountQuotaAfterIncrement fetches real-time account from DB - Reconstructs pre-increment usage for accurate threshold crossing detection - New AccountQuotaReader interface (minimal: GetByID only) Usage tooltip: - Per-request/image billing shows per-request price instead of $0 token price - Token billing continues to show input/output price per million tokens --- backend/cmd/server/wire_gen.go | 2 +- backend/internal/handler/gateway_handler.go | 3 + backend/internal/service/account.go | 29 +++- .../internal/service/account_stats_pricing.go | 41 ++++- .../service/balance_notify_service.go | 43 +++++- backend/internal/service/gateway_request.go | 3 + backend/internal/service/gateway_service.go | 4 +- .../service/gateway_websearch_emulation.go | 24 ++- .../service/openai_gateway_service.go | 2 +- backend/internal/service/wire.go | 4 +- ...igrate_websearch_emulation_to_tristate.sql | 11 ++ .../components/account/CreateAccountModal.vue | 21 +-- .../components/account/EditAccountModal.vue | 27 +++- .../src/components/admin/usage/UsageTable.vue | 22 ++- frontend/src/i18n/locales/en.ts | 12 +- frontend/src/i18n/locales/zh.ts | 12 +- frontend/src/views/admin/ChannelsView.vue | 143 ++++++++++++++++-- frontend/src/views/user/UsageView.vue | 22 ++- 18 files changed, 346 insertions(+), 79 deletions(-) create mode 100644 backend/migrations/105_migrate_websearch_emulation_to_tristate.sql diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 24ee02fd..69daeecf 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -176,7 +176,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { channelRepository := repository.NewChannelRepository(db) channelService := service.NewChannelService(channelRepository, apiKeyAuthCacheInvalidator) modelPricingResolver := service.NewModelPricingResolver(channelService, billingService) - balanceNotifyService := service.ProvideBalanceNotifyService(emailService, settingRepository) + balanceNotifyService := service.ProvideBalanceNotifyService(emailService, settingRepository, accountRepository) gatewayService := service.NewGatewayService(accountRepository, groupRepository, usageLogRepository, usageBillingRepository, userRepository, userSubscriptionRepository, userGroupRateRepository, gatewayCache, configConfig, schedulerSnapshotService, concurrencyService, billingService, rateLimitService, billingCacheService, identityService, httpUpstream, deferredService, claudeTokenProvider, sessionLimitCache, rpmCache, digestSessionStore, settingService, tlsFingerprintProfileService, channelService, modelPricingResolver, balanceNotifyService) openAITokenProvider := service.ProvideOpenAITokenProvider(accountRepository, geminiTokenCache, openAIOAuthService, oAuthRefreshAPI) openAIGatewayService := service.NewOpenAIGatewayService(accountRepository, usageLogRepository, usageBillingRepository, userRepository, userSubscriptionRepository, userGroupRateRepository, gatewayCache, configConfig, schedulerSnapshotService, concurrencyService, billingService, rateLimitService, billingCacheService, httpUpstream, deferredService, openAITokenProvider, modelPricingResolver, channelService, balanceNotifyService) diff --git a/backend/internal/handler/gateway_handler.go b/backend/internal/handler/gateway_handler.go index 59619d50..8ec54420 100644 --- a/backend/internal/handler/gateway_handler.go +++ b/backend/internal/handler/gateway_handler.go @@ -248,6 +248,9 @@ func (h *GatewayHandler) Messages(c *gin.Context) { return } + // 设置请求所属分组 ID(用于渠道级功能判断,如 WebSearch 模拟) + parsedReq.GroupID = apiKey.GroupID + // 计算粘性会话hash parsedReq.SessionContext = &service.SessionContext{ ClientIP: ip.GetClientIP(c), diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go index 6e5a768f..4d933986 100644 --- a/backend/internal/service/account.go +++ b/backend/internal/service/account.go @@ -1169,15 +1169,30 @@ func (a *Account) IsAnthropicAPIKeyPassthroughEnabled() bool { return ok && enabled } -// IsWebSearchEmulationEnabled 返回 Anthropic API Key 账号是否启用 web search 模拟。 -// 字段:accounts.extra.web_search_emulation。 -// 字段缺失或类型不正确时,按 false(关闭)处理。 -func (a *Account) IsWebSearchEmulationEnabled() bool { +// WebSearch 模拟三态常量 +const ( + WebSearchModeDefault = "default" // 跟随渠道配置 + WebSearchModeEnabled = "enabled" // 强制开启 + WebSearchModeDisabled = "disabled" // 强制关闭 +) + +// GetWebSearchEmulationMode 返回账号的 WebSearch 模拟模式。 +// 三态:default(跟随渠道)/ enabled(强制开启)/ disabled(强制关闭)。 +// 旧 bool 值需通过 SQL 迁移脚本转换,Go 代码不做兼容。 +func (a *Account) GetWebSearchEmulationMode() string { if a == nil || a.Platform != PlatformAnthropic || a.Type != AccountTypeAPIKey || a.Extra == nil { - return false + return WebSearchModeDefault + } + mode, ok := a.Extra[featureKeyWebSearchEmulation].(string) + if !ok { + return WebSearchModeDefault + } + switch mode { + case WebSearchModeEnabled, WebSearchModeDisabled: + return mode + default: + return WebSearchModeDefault } - enabled, ok := a.Extra[featureKeyWebSearchEmulation].(bool) - return ok && enabled } // IsCodexCLIOnlyEnabled 返回 OpenAI OAuth 账号是否启用"仅允许 Codex 官方客户端"。 diff --git a/backend/internal/service/account_stats_pricing.go b/backend/internal/service/account_stats_pricing.go index 4a896d9f..e88f7f8c 100644 --- a/backend/internal/service/account_stats_pricing.go +++ b/backend/internal/service/account_stats_pricing.go @@ -8,11 +8,17 @@ import ( // resolveAccountStatsCost 计算账号统计定价费用。 // 返回 nil 表示不覆盖,使用默认公式(total_cost × account_rate_multiplier)。 -// 仅匹配自定义规则(AccountStatsPricingRules),按数组顺序先命中为准。 -// upstreamModel 是最终发往上游的模型 ID,用于匹配自定义规则中的模型定价。 +// +// 优先级(先命中为准): +// 1. 自定义规则(始终尝试,不依赖 ApplyPricingToAccountStats 开关) +// 2. ApplyPricingToAccountStats 启用时,用模型定价文件(LiteLLM)中上游模型的标准价格计算 +// 3. nil → 走默认公式 +// +// upstreamModel 是最终发往上游的模型 ID。 func resolveAccountStatsCost( ctx context.Context, channelService *ChannelService, + billingService *BillingService, accountID int64, groupID int64, upstreamModel string, @@ -23,12 +29,39 @@ func resolveAccountStatsCost( return nil } channel, err := channelService.GetChannelForGroup(ctx, groupID) - if err != nil || channel == nil || !channel.ApplyPricingToAccountStats { + if err != nil || channel == nil { return nil } platform := channelService.GetGroupPlatform(ctx, groupID) - return tryCustomRules(channel, accountID, groupID, platform, upstreamModel, tokens, requestCount) + + // 优先级 1:自定义规则(始终尝试) + if cost := tryCustomRules(channel, accountID, groupID, platform, upstreamModel, tokens, requestCount); cost != nil { + return cost + } + + // 优先级 2:模型定价文件(LiteLLM/fallback)中上游模型的标准价格 + if channel.ApplyPricingToAccountStats && billingService != nil { + return tryModelFilePricing(billingService, upstreamModel, tokens) + } + + return nil +} + +// tryModelFilePricing 使用模型定价文件(LiteLLM/fallback)中的标准价格计算费用。 +func tryModelFilePricing(billingService *BillingService, model string, tokens UsageTokens) *float64 { + pricing, err := billingService.GetModelPricing(model) + if err != nil || pricing == nil { + return nil + } + cost := float64(tokens.InputTokens)*pricing.InputPricePerToken + + float64(tokens.OutputTokens)*pricing.OutputPricePerToken + + float64(tokens.CacheCreationTokens)*pricing.CacheCreationPricePerToken + + float64(tokens.CacheReadTokens)*pricing.CacheReadPricePerToken + if cost <= 0 { + return nil + } + return &cost } // tryCustomRules 遍历自定义规则,按数组顺序先命中为准。 diff --git a/backend/internal/service/balance_notify_service.go b/backend/internal/service/balance_notify_service.go index 053451e1..23411ed5 100644 --- a/backend/internal/service/balance_notify_service.go +++ b/backend/internal/service/balance_notify_service.go @@ -27,17 +27,24 @@ var quotaDimLabels = map[string]string{ quotaDimTotal: "总限额 / Total", } +// AccountQuotaReader provides read access to account quota data. +type AccountQuotaReader interface { + GetByID(ctx context.Context, id int64) (*Account, error) +} + // BalanceNotifyService handles balance and quota threshold notifications. type BalanceNotifyService struct { emailService *EmailService settingRepo SettingRepository + accountRepo AccountQuotaReader } // NewBalanceNotifyService creates a new BalanceNotifyService. -func NewBalanceNotifyService(emailService *EmailService, settingRepo SettingRepository) *BalanceNotifyService { +func NewBalanceNotifyService(emailService *EmailService, settingRepo SettingRepository, accountRepo AccountQuotaReader) *BalanceNotifyService { return &BalanceNotifyService{ emailService: emailService, settingRepo: settingRepo, + accountRepo: accountRepo, } } @@ -110,7 +117,7 @@ func buildQuotaDims(account *Account) []quotaDim { } // CheckAccountQuotaAfterIncrement checks if any quota dimension crossed above its notify threshold. -// The account's Extra fields contain pre-increment usage values. +// It fetches real-time quota usage from DB to avoid stale snapshot values. func (s *BalanceNotifyService) CheckAccountQuotaAfterIncrement(ctx context.Context, account *Account, cost float64) { if account == nil || s.emailService == nil || s.settingRepo == nil || cost <= 0 { return @@ -123,8 +130,29 @@ func (s *BalanceNotifyService) CheckAccountQuotaAfterIncrement(ctx context.Conte return } + freshAccount := s.fetchFreshAccount(ctx, account) siteName := s.getSiteName(ctx) - for _, dim := range buildQuotaDims(account) { + s.checkQuotaDimCrossings(freshAccount, cost, adminEmails, siteName) +} + +// fetchFreshAccount loads the latest account from DB; falls back to the snapshot on error. +func (s *BalanceNotifyService) fetchFreshAccount(ctx context.Context, snapshot *Account) *Account { + if s.accountRepo == nil { + return snapshot + } + fresh, err := s.accountRepo.GetByID(ctx, snapshot.ID) + if err != nil { + slog.Warn("failed to fetch fresh account for quota notify, using snapshot", + "account_id", snapshot.ID, "error", err) + return snapshot + } + return fresh +} + +// checkQuotaDimCrossings iterates quota dimensions and sends alerts for threshold crossings. +// freshAccount has post-increment values; oldUsed is reconstructed as freshUsed - cost. +func (s *BalanceNotifyService) checkQuotaDimCrossings(freshAccount *Account, cost float64, adminEmails []string, siteName string) { + for _, dim := range buildQuotaDims(freshAccount) { if !dim.enabled || dim.threshold <= 0 { continue } @@ -132,9 +160,12 @@ func (s *BalanceNotifyService) CheckAccountQuotaAfterIncrement(ctx context.Conte if effectiveThreshold <= 0 { continue } - newUsed := dim.oldUsed + cost - if dim.oldUsed < effectiveThreshold && newUsed >= effectiveThreshold { - s.asyncSendQuotaAlert(adminEmails, account.Name, dim, newUsed, effectiveThreshold, siteName) + // dim.oldUsed is actually the post-increment value from fresh DB data; + // reconstruct pre-increment value to detect threshold crossing. + newUsed := dim.oldUsed + oldUsed := dim.oldUsed - cost + if oldUsed < effectiveThreshold && newUsed >= effectiveThreshold { + s.asyncSendQuotaAlert(adminEmails, freshAccount.Name, dim, newUsed, effectiveThreshold, siteName) } } } diff --git a/backend/internal/service/gateway_request.go b/backend/internal/service/gateway_request.go index e2badfed..55cb2c84 100644 --- a/backend/internal/service/gateway_request.go +++ b/backend/internal/service/gateway_request.go @@ -75,6 +75,9 @@ type ParsedRequest struct { MaxTokens int // max_tokens 值(用于探测请求拦截) SessionContext *SessionContext // 可选:请求上下文区分因子(nil 时行为不变) + // GroupID 请求所属分组 ID(来自 API Key) + GroupID *int64 + // OnUpstreamAccepted 上游接受请求后立即调用(用于提前释放串行锁) // 流式请求在收到 2xx 响应头后调用,避免持锁等流完成 OnUpstreamAccepted func() diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index 70dd9b52..5267156d 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -3789,7 +3789,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A } // Web Search 模拟:纯 web_search 请求时,直接调用搜索 API 构造响应 - if account != nil && s.shouldEmulateWebSearch(ctx, account, parsed.Body) { + if account != nil && s.shouldEmulateWebSearch(ctx, account, parsed.GroupID, parsed.Body) { return s.handleWebSearchEmulation(ctx, c, account, parsed) } @@ -7588,7 +7588,7 @@ func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsage upstreamModel = result.Model } usageLog.AccountStatsCost = resolveAccountStatsCost( - ctx, s.channelService, + ctx, s.channelService, s.billingService, account.ID, *apiKey.GroupID, upstreamModel, UsageTokens{ InputTokens: result.Usage.InputTokens, diff --git a/backend/internal/service/gateway_websearch_emulation.go b/backend/internal/service/gateway_websearch_emulation.go index b3a4aa69..0d0b5480 100644 --- a/backend/internal/service/gateway_websearch_emulation.go +++ b/backend/internal/service/gateway_websearch_emulation.go @@ -49,10 +49,9 @@ func getWebSearchManager() *websearch.Manager { // shouldEmulateWebSearch checks whether a request should be intercepted. // -// Judgment chain: manager exists → only web_search tool → global enabled → account enabled. -// Note: channel-level control is enforced via the account's extra field; the channel toggle -// in the admin UI sets the account's flag for all accounts in that channel's groups. -func (s *GatewayService) shouldEmulateWebSearch(ctx context.Context, account *Account, body []byte) bool { +// Judgment chain: manager exists → only web_search tool → global enabled → account/channel enabled. +// Account-level mode: "enabled" (force on), "disabled" (force off), "default" (follow channel). +func (s *GatewayService) shouldEmulateWebSearch(ctx context.Context, account *Account, groupID *int64, body []byte) bool { if getWebSearchManager() == nil { return false } @@ -62,10 +61,23 @@ func (s *GatewayService) shouldEmulateWebSearch(ctx context.Context, account *Ac if !s.settingService.IsWebSearchEmulationEnabled(ctx) { return false } - if !account.IsWebSearchEmulationEnabled() { + + mode := account.GetWebSearchEmulationMode() + switch mode { + case WebSearchModeEnabled: + return true + case WebSearchModeDisabled: return false + default: // "default" → follow channel config + if groupID == nil || s.channelService == nil { + return false + } + ch, err := s.channelService.GetChannelForGroup(ctx, *groupID) + if err != nil || ch == nil { + return false + } + return ch.IsWebSearchEmulationEnabled(account.Platform) } - return true } // isOnlyWebSearchToolInBody checks if the body contains exactly one web_search tool. diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index 98258cd0..e060b981 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -4580,7 +4580,7 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec statsModel = result.Model } usageLog.AccountStatsCost = resolveAccountStatsCost( - ctx, s.channelService, + ctx, s.channelService, s.billingService, account.ID, *apiKey.GroupID, statsModel, tokens, 1, ) diff --git a/backend/internal/service/wire.go b/backend/internal/service/wire.go index b4e33039..9f33c46a 100644 --- a/backend/internal/service/wire.go +++ b/backend/internal/service/wire.go @@ -476,8 +476,8 @@ func ProvidePaymentConfigService(entClient *dbent.Client, settingRepo SettingRep } // ProvideBalanceNotifyService creates BalanceNotifyService -func ProvideBalanceNotifyService(emailService *EmailService, settingRepo SettingRepository) *BalanceNotifyService { - return NewBalanceNotifyService(emailService, settingRepo) +func ProvideBalanceNotifyService(emailService *EmailService, settingRepo SettingRepository, accountRepo AccountRepository) *BalanceNotifyService { + return NewBalanceNotifyService(emailService, settingRepo, accountRepo) } // ProvidePaymentOrderExpiryService creates and starts PaymentOrderExpiryService. diff --git a/backend/migrations/105_migrate_websearch_emulation_to_tristate.sql b/backend/migrations/105_migrate_websearch_emulation_to_tristate.sql new file mode 100644 index 00000000..745e58df --- /dev/null +++ b/backend/migrations/105_migrate_websearch_emulation_to_tristate.sql @@ -0,0 +1,11 @@ +-- Convert old boolean web_search_emulation to tri-state string +-- true → "enabled", false → remove key (becomes "default") +UPDATE accounts +SET extra = (extra - 'web_search_emulation') || jsonb_build_object('web_search_emulation', 'enabled') +WHERE extra ? 'web_search_emulation' + AND extra->>'web_search_emulation' = 'true'; + +UPDATE accounts +SET extra = extra - 'web_search_emulation' +WHERE extra ? 'web_search_emulation' + AND extra->>'web_search_emulation' = 'false'; diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue index e0dbce61..e83e061e 100644 --- a/frontend/src/components/account/CreateAccountModal.vue +++ b/frontend/src/components/account/CreateAccountModal.vue @@ -2337,7 +2337,11 @@ {{ t('admin.accounts.anthropic.webSearchEmulationDesc') }}

- + @@ -2846,7 +2850,6 @@ import ConfirmDialog from '@/components/common/ConfirmDialog.vue' import Select from '@/components/common/Select.vue' import Icon from '@/components/icons/Icon.vue' import ProxySelector from '@/components/common/ProxySelector.vue' -import Toggle from '@/components/common/Toggle.vue' import GroupSelector from '@/components/common/GroupSelector.vue' import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.vue' import QuotaLimitCard from '@/components/account/QuotaLimitCard.vue' @@ -2997,7 +3000,7 @@ const openaiOAuthResponsesWebSocketV2Mode = ref(OPENAI_WS_MODE_OFF const openaiAPIKeyResponsesWebSocketV2Mode = ref(OPENAI_WS_MODE_OFF) const codexCLIOnlyEnabled = ref(false) const anthropicPassthroughEnabled = ref(false) -const webSearchEmulationEnabled = ref(false) +const webSearchEmulationMode = ref('default') const webSearchGlobalEnabled = ref(false) // Load web search global state once @@ -3331,7 +3334,7 @@ watch( } if (newPlatform !== 'anthropic') { anthropicPassthroughEnabled.value = false - webSearchEmulationEnabled.value = false + webSearchEmulationMode.value = 'default' } // Reset OAuth states oauth.resetState() @@ -3351,7 +3354,7 @@ watch( } if (platform !== 'anthropic' || category !== 'apikey') { anthropicPassthroughEnabled.value = false - webSearchEmulationEnabled.value = false + webSearchEmulationMode.value = 'default' } } ) @@ -3716,7 +3719,7 @@ const resetForm = () => { openaiAPIKeyResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF codexCLIOnlyEnabled.value = false anthropicPassthroughEnabled.value = false - webSearchEmulationEnabled.value = false + webSearchEmulationMode.value = 'default' // Reset quota control state windowCostEnabled.value = false windowCostLimit.value = null @@ -3804,10 +3807,10 @@ const buildAnthropicExtra = (base?: Record): Record 0 ? extra : undefined diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue index abb9569e..74e5fc1f 100644 --- a/frontend/src/components/account/EditAccountModal.vue +++ b/frontend/src/components/account/EditAccountModal.vue @@ -1161,7 +1161,11 @@ {{ t('admin.accounts.anthropic.webSearchEmulationDesc') }}

- + @@ -1844,7 +1848,6 @@ import ConfirmDialog from '@/components/common/ConfirmDialog.vue' import Select from '@/components/common/Select.vue' import Icon from '@/components/icons/Icon.vue' import ProxySelector from '@/components/common/ProxySelector.vue' -import Toggle from '@/components/common/Toggle.vue' import GroupSelector from '@/components/common/GroupSelector.vue' import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.vue' import QuotaLimitCard from '@/components/account/QuotaLimitCard.vue' @@ -1986,7 +1989,7 @@ const openaiOAuthResponsesWebSocketV2Mode = ref(OPENAI_WS_MODE_OFF const openaiAPIKeyResponsesWebSocketV2Mode = ref(OPENAI_WS_MODE_OFF) const codexCLIOnlyEnabled = ref(false) const anthropicPassthroughEnabled = ref(false) -const webSearchEmulationEnabled = ref(false) +const webSearchEmulationMode = ref('default') const webSearchGlobalEnabled = ref(false) // Load web search global state once @@ -2171,7 +2174,7 @@ const syncFormFromAccount = (newAccount: Account | null) => { openaiAPIKeyResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF codexCLIOnlyEnabled.value = false anthropicPassthroughEnabled.value = false - webSearchEmulationEnabled.value = false + webSearchEmulationMode.value = 'default' if (newAccount.platform === 'openai' && (newAccount.type === 'oauth' || newAccount.type === 'apikey')) { openaiPassthroughEnabled.value = extra?.openai_passthrough === true || extra?.openai_oauth_passthrough === true openaiOAuthResponsesWebSocketV2Mode.value = resolveOpenAIWSModeFromExtra(extra, { @@ -2192,7 +2195,15 @@ const syncFormFromAccount = (newAccount: Account | null) => { } if (newAccount.platform === 'anthropic' && newAccount.type === 'apikey') { anthropicPassthroughEnabled.value = extra?.anthropic_passthrough === true - webSearchEmulationEnabled.value = extra?.web_search_emulation === true + // 三态:string "default"/"enabled"/"disabled",向后兼容旧 bool + const wsVal = extra?.web_search_emulation + if (wsVal === 'enabled' || wsVal === 'disabled') { + webSearchEmulationMode.value = wsVal + } else if (wsVal === true) { + webSearchEmulationMode.value = 'enabled' + } else { + webSearchEmulationMode.value = 'default' + } } // Load quota limit for apikey/bedrock accounts (bedrock quota is also loaded in its own branch above) @@ -3180,10 +3191,10 @@ const handleSubmit = async () => { } else { delete newExtra.anthropic_passthrough } - if (webSearchEmulationEnabled.value) { - newExtra.web_search_emulation = true - } else { + if (webSearchEmulationMode.value === 'default') { delete newExtra.web_search_emulation + } else { + newExtra.web_search_emulation = webSearchEmulationMode.value } updatePayload.extra = newExtra } diff --git a/frontend/src/components/admin/usage/UsageTable.vue b/frontend/src/components/admin/usage/UsageTable.vue index f4494e69..92c8dd34 100644 --- a/frontend/src/components/admin/usage/UsageTable.vue +++ b/frontend/src/components/admin/usage/UsageTable.vue @@ -279,13 +279,21 @@ {{ t('admin.usage.outputCost') }} ${{ tooltipData.output_cost.toFixed(6) }} -
- {{ t('usage.inputTokenPrice') }} - {{ formatTokenPricePerMillion(tooltipData.input_cost, tooltipData.input_tokens) }} {{ t('usage.perMillionTokens') }} -
-
- {{ t('usage.outputTokenPrice') }} - {{ formatTokenPricePerMillion(tooltipData.output_cost, tooltipData.output_tokens) }} {{ t('usage.perMillionTokens') }} + + + +
+ {{ tooltipData.billing_mode === 'image' ? t('usage.imageUnitPrice') : t('usage.unitPrice') }} + ${{ tooltipData.total_cost?.toFixed(6) || '0.000000' }}
{{ t('admin.usage.cacheCreationCost') }} diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index f45a02f6..056bdde0 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -774,6 +774,8 @@ export default { inputTokenPrice: 'Input price', outputTokenPrice: 'Output price', perMillionTokens: '/ 1M tokens', + unitPrice: 'Per-request price', + imageUnitPrice: 'Per-image price', cacheRead: 'Read', cacheWrite: 'Write', serviceTier: 'Service tier', @@ -1877,14 +1879,15 @@ export default { pricingEntry: 'Pricing Entry', noModels: 'No models added', applyPricingToAccountStats: 'Apply Pricing to Account Stats', - applyPricingToAccountStatsDesc: 'When enabled, custom account stats model pricing rules will be applied.', + applyPricingToAccountStatsDesc: 'When enabled, requests not matched by custom rules will use standard model pricing for account stats calculation', accountStatsPricingRules: 'Custom Account Stats Pricing Rules', addRule: 'Add Rule', noRulesConfigured: 'No custom rules configured. Channel model pricing above will be used.', ruleName: 'Rule name (optional)', ruleGroups: 'Groups', - ruleAccounts: 'Account IDs', - ruleAccountsPlaceholder: 'Enter account IDs, comma-separated', + ruleAccounts: 'Accounts', + searchAccountPlaceholder: 'Search accounts...', + ruleAccountsHint: 'Leave empty to match all accounts', ruleModelPricing: 'Model Pricing', noGroupsInChannel: 'No groups selected in platform tabs above' } @@ -2380,6 +2383,9 @@ export default { webSearchEmulation: 'Web Search Emulation', webSearchEmulationDesc: 'Enable web search emulation for this API Key account. When a pure web_search request is detected, the gateway calls a third-party search API and constructs the response locally.', + webSearchDefault: 'Default (follow channel)', + webSearchEnabled: 'Enabled', + webSearchDisabled: 'Disabled', }, modelRestriction: 'Model Restriction (Optional)', modelWhitelist: 'Model Whitelist', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 61d43a37..47a1f8d4 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -778,6 +778,8 @@ export default { inputTokenPrice: '输入单价', outputTokenPrice: '输出单价', perMillionTokens: '/ 1M Token', + unitPrice: '单次价格', + imageUnitPrice: '单张价格', cacheRead: '读取', cacheWrite: '写入', serviceTier: '服务档位', @@ -1956,14 +1958,15 @@ export default { pricingEntry: '定价配置', noModels: '未添加模型', applyPricingToAccountStats: '应用模型定价到账号统计', - applyPricingToAccountStatsDesc: '启用后将支持自定义账号统计的模型价格', + applyPricingToAccountStatsDesc: '启用后,未被自定义规则匹配的请求将使用模型定价文件中的标准价格计算账号统计费用', accountStatsPricingRules: '自定义账号统计定价规则', addRule: '添加规则', noRulesConfigured: '未配置自定义规则,将使用上方的模型定价。', ruleName: '规则名称(可选)', ruleGroups: '分组', - ruleAccounts: '账号 ID', - ruleAccountsPlaceholder: '输入账号 ID,逗号分隔', + ruleAccounts: '账号', + searchAccountPlaceholder: '搜索账号...', + ruleAccountsHint: '留空表示匹配所有账号', ruleModelPricing: '模型定价', noGroupsInChannel: '上方平台标签页中未选择分组' } @@ -2527,6 +2530,9 @@ export default { webSearchEmulation: 'Web Search 模拟', webSearchEmulationDesc: '为该 API Key 账号启用 web search 模拟。客户端发送纯 web_search 请求时,由网关调用第三方搜索 API 并构造响应返回。', + webSearchDefault: '默认(跟随渠道)', + webSearchEnabled: '开启', + webSearchDisabled: '关闭', }, modelRestriction: '模型限制(可选)', modelWhitelist: '模型白名单', diff --git a/frontend/src/views/admin/ChannelsView.vue b/frontend/src/views/admin/ChannelsView.vue index 2e45ba42..5be45cb5 100644 --- a/frontend/src/views/admin/ChannelsView.vue +++ b/frontend/src/views/admin/ChannelsView.vue @@ -413,8 +413,8 @@
- -
+ +

{{ t('admin.channels.form.accountStatsPricingRules') }} @@ -474,12 +474,51 @@
- + +
+ + {{ getRuleAccountLabel(accountId) }} + + +
+ + +

+ {{ t('admin.channels.form.ruleAccountsHint') }} +

@@ -569,6 +608,7 @@ import PlatformIcon from '@/components/common/PlatformIcon.vue' import Toggle from '@/components/common/Toggle.vue' import PricingEntryCard from '@/components/admin/channel/PricingEntryCard.vue' import { getPersistedPageSize } from '@/composables/usePersistedPageSize' +import { useKeyedDebouncedSearch } from '@/composables/useKeyedDebouncedSearch' const { t } = useI18n() const appStore = useAppStore() @@ -852,6 +892,9 @@ function addRulePricingEntry(ruleIndex: number) { function removeAccountStatsRule(ruleIndex: number) { form.account_stats_pricing_rules.splice(ruleIndex, 1) + // Clear all search state since indices shift after removal + ruleAccountSearchRunner.clearAll() + clearAllRuleAccountSearchState() } function removeRulePricingEntry(ruleIndex: number, pricingIndex: number) { @@ -863,11 +906,78 @@ function getGroupNameById(groupId: number): string { return group ? group.name : `#${groupId}` } -function parseAccountIdsInput(value: string): number[] { - return value - .split(',') - .map(s => parseInt(s.trim())) - .filter(n => !isNaN(n) && n > 0) +// ── Account search for pricing rules ── +interface SimpleAccount { id: number; name: string } + +const ruleAccountSearchKeyword = ref>({}) +const ruleAccountSearchResults = ref>({}) +const showRuleAccountDropdown = ref>({}) +// Cache: account ID → name, populated when search results are selected +const ruleAccountNameCache = ref>({}) + +const ruleAccountSearchRunner = useKeyedDebouncedSearch({ + delay: 300, + search: async (keyword, { key, signal }) => { + const platform = key.split('-')[0] + const res = await adminAPI.accounts.list(1, 20, { platform, search: keyword }, { signal }) + return res.items.map(a => ({ id: a.id, name: a.name })) + }, + onSuccess: (key, result) => { ruleAccountSearchResults.value[key] = result }, + onError: (key) => { ruleAccountSearchResults.value[key] = [] }, +}) + +function onRuleAccountSearchInput(platform: string, ruleIndex: number) { + const key = `${platform}-${ruleIndex}` + showRuleAccountDropdown.value[key] = true + ruleAccountSearchRunner.trigger(key, ruleAccountSearchKeyword.value[key] || '') +} + +function onRuleAccountSearchFocus(platform: string, ruleIndex: number) { + const key = `${platform}-${ruleIndex}` + showRuleAccountDropdown.value[key] = true + if (!ruleAccountSearchResults.value[key]?.length) { + ruleAccountSearchRunner.trigger(key, ruleAccountSearchKeyword.value[key] || '') + } +} + +function selectRuleAccount( + rule: { account_ids: number[] }, + account: SimpleAccount, + platform: string, + ruleIndex: number, +) { + if (!rule.account_ids.includes(account.id)) { + rule.account_ids.push(account.id) + ruleAccountNameCache.value[account.id] = account.name + } + const key = `${platform}-${ruleIndex}` + ruleAccountSearchKeyword.value[key] = '' + showRuleAccountDropdown.value[key] = false +} + +function removeRuleAccount(rule: { account_ids: number[] }, accountId: number) { + const idx = rule.account_ids.indexOf(accountId) + if (idx !== -1) rule.account_ids.splice(idx, 1) +} + +function getRuleAccountLabel(accountId: number): string { + const name = ruleAccountNameCache.value[accountId] + return name ? `${name} #${accountId}` : `#${accountId}` +} + +function handleRuleAccountClickOutside(event: MouseEvent) { + const target = event.target as HTMLElement + if (!target.closest('.rule-account-search-container')) { + Object.keys(showRuleAccountDropdown.value).forEach(key => { + showRuleAccountDropdown.value[key] = false + }) + } +} + +function clearAllRuleAccountSearchState() { + ruleAccountSearchKeyword.value = {} + ruleAccountSearchResults.value = {} + showRuleAccountDropdown.value = {} } function accountStatsRulesToAPI(): AccountStatsPricingRule[] { @@ -1093,6 +1203,9 @@ function resetForm() { form.apply_pricing_to_account_stats = false form.account_stats_pricing_rules = [] activeTab.value = 'basic' + ruleAccountSearchRunner.clearAll() + clearAllRuleAccountSearchState() + ruleAccountNameCache.value = {} } async function openCreateDialog() { @@ -1313,11 +1426,15 @@ onMounted(() => { loadChannels() loadGroups() loadWebSearchGlobalState() + document.addEventListener('click', handleRuleAccountClickOutside) }) onUnmounted(() => { clearTimeout(searchTimeout) abortController?.abort() + document.removeEventListener('click', handleRuleAccountClickOutside) + ruleAccountSearchRunner.clearAll() + clearAllRuleAccountSearchState() }) diff --git a/frontend/src/views/user/UsageView.vue b/frontend/src/views/user/UsageView.vue index 6cb367ed..2ec0fea5 100644 --- a/frontend/src/views/user/UsageView.vue +++ b/frontend/src/views/user/UsageView.vue @@ -447,13 +447,21 @@ {{ t('admin.usage.outputCost') }} ${{ tooltipData.output_cost.toFixed(6) }}
-
- {{ t('usage.inputTokenPrice') }} - {{ formatTokenPricePerMillion(tooltipData.input_cost, tooltipData.input_tokens) }} {{ t('usage.perMillionTokens') }} -
-
- {{ t('usage.outputTokenPrice') }} - {{ formatTokenPricePerMillion(tooltipData.output_cost, tooltipData.output_tokens) }} {{ t('usage.perMillionTokens') }} + + + +
+ {{ tooltipData.billing_mode === 'image' ? t('usage.imageUnitPrice') : t('usage.unitPrice') }} + ${{ tooltipData.total_cost?.toFixed(6) || '0.000000' }}
{{ t('admin.usage.cacheCreationCost') }}