Commit 1262654d authored by erio's avatar erio
Browse files

feat: WebSearch tri-state, account stats pricing fix, quota cache fix, usage tooltip

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
parent 11c46068
...@@ -176,7 +176,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { ...@@ -176,7 +176,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
channelRepository := repository.NewChannelRepository(db) channelRepository := repository.NewChannelRepository(db)
channelService := service.NewChannelService(channelRepository, apiKeyAuthCacheInvalidator) channelService := service.NewChannelService(channelRepository, apiKeyAuthCacheInvalidator)
modelPricingResolver := service.NewModelPricingResolver(channelService, billingService) 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) 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) 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) openAIGatewayService := service.NewOpenAIGatewayService(accountRepository, usageLogRepository, usageBillingRepository, userRepository, userSubscriptionRepository, userGroupRateRepository, gatewayCache, configConfig, schedulerSnapshotService, concurrencyService, billingService, rateLimitService, billingCacheService, httpUpstream, deferredService, openAITokenProvider, modelPricingResolver, channelService, balanceNotifyService)
......
...@@ -248,6 +248,9 @@ func (h *GatewayHandler) Messages(c *gin.Context) { ...@@ -248,6 +248,9 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
return return
} }
// 设置请求所属分组 ID(用于渠道级功能判断,如 WebSearch 模拟)
parsedReq.GroupID = apiKey.GroupID
// 计算粘性会话hash // 计算粘性会话hash
parsedReq.SessionContext = &service.SessionContext{ parsedReq.SessionContext = &service.SessionContext{
ClientIP: ip.GetClientIP(c), ClientIP: ip.GetClientIP(c),
......
...@@ -1169,15 +1169,30 @@ func (a *Account) IsAnthropicAPIKeyPassthroughEnabled() bool { ...@@ -1169,15 +1169,30 @@ func (a *Account) IsAnthropicAPIKeyPassthroughEnabled() bool {
return ok && enabled return ok && enabled
} }
// IsWebSearchEmulationEnabled 返回 Anthropic API Key 账号是否启用 web search 模拟。 // WebSearch 模拟三态常量
// 字段:accounts.extra.web_search_emulation。 const (
// 字段缺失或类型不正确时,按 false(关闭)处理。 WebSearchModeDefault = "default" // 跟随渠道配置
func (a *Account) IsWebSearchEmulationEnabled() bool { 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 { 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 官方客户端"。 // IsCodexCLIOnlyEnabled 返回 OpenAI OAuth 账号是否启用"仅允许 Codex 官方客户端"。
......
...@@ -8,11 +8,17 @@ import ( ...@@ -8,11 +8,17 @@ import (
// resolveAccountStatsCost 计算账号统计定价费用。 // resolveAccountStatsCost 计算账号统计定价费用。
// 返回 nil 表示不覆盖,使用默认公式(total_cost × account_rate_multiplier)。 // 返回 nil 表示不覆盖,使用默认公式(total_cost × account_rate_multiplier)。
// 仅匹配自定义规则(AccountStatsPricingRules),按数组顺序先命中为准。 //
// upstreamModel 是最终发往上游的模型 ID,用于匹配自定义规则中的模型定价。 // 优先级(先命中为准):
// 1. 自定义规则(始终尝试,不依赖 ApplyPricingToAccountStats 开关)
// 2. ApplyPricingToAccountStats 启用时,用模型定价文件(LiteLLM)中上游模型的标准价格计算
// 3. nil → 走默认公式
//
// upstreamModel 是最终发往上游的模型 ID。
func resolveAccountStatsCost( func resolveAccountStatsCost(
ctx context.Context, ctx context.Context,
channelService *ChannelService, channelService *ChannelService,
billingService *BillingService,
accountID int64, accountID int64,
groupID int64, groupID int64,
upstreamModel string, upstreamModel string,
...@@ -23,12 +29,39 @@ func resolveAccountStatsCost( ...@@ -23,12 +29,39 @@ func resolveAccountStatsCost(
return nil return nil
} }
channel, err := channelService.GetChannelForGroup(ctx, groupID) channel, err := channelService.GetChannelForGroup(ctx, groupID)
if err != nil || channel == nil || !channel.ApplyPricingToAccountStats { if err != nil || channel == nil {
return nil return nil
} }
platform := channelService.GetGroupPlatform(ctx, groupID) 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 遍历自定义规则,按数组顺序先命中为准。 // tryCustomRules 遍历自定义规则,按数组顺序先命中为准。
......
...@@ -27,17 +27,24 @@ var quotaDimLabels = map[string]string{ ...@@ -27,17 +27,24 @@ var quotaDimLabels = map[string]string{
quotaDimTotal: "总限额 / Total", 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. // BalanceNotifyService handles balance and quota threshold notifications.
type BalanceNotifyService struct { type BalanceNotifyService struct {
emailService *EmailService emailService *EmailService
settingRepo SettingRepository settingRepo SettingRepository
accountRepo AccountQuotaReader
} }
// NewBalanceNotifyService creates a new BalanceNotifyService. // NewBalanceNotifyService creates a new BalanceNotifyService.
func NewBalanceNotifyService(emailService *EmailService, settingRepo SettingRepository) *BalanceNotifyService { func NewBalanceNotifyService(emailService *EmailService, settingRepo SettingRepository, accountRepo AccountQuotaReader) *BalanceNotifyService {
return &BalanceNotifyService{ return &BalanceNotifyService{
emailService: emailService, emailService: emailService,
settingRepo: settingRepo, settingRepo: settingRepo,
accountRepo: accountRepo,
} }
} }
...@@ -110,7 +117,7 @@ func buildQuotaDims(account *Account) []quotaDim { ...@@ -110,7 +117,7 @@ func buildQuotaDims(account *Account) []quotaDim {
} }
// CheckAccountQuotaAfterIncrement checks if any quota dimension crossed above its notify threshold. // 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) { func (s *BalanceNotifyService) CheckAccountQuotaAfterIncrement(ctx context.Context, account *Account, cost float64) {
if account == nil || s.emailService == nil || s.settingRepo == nil || cost <= 0 { if account == nil || s.emailService == nil || s.settingRepo == nil || cost <= 0 {
return return
...@@ -123,8 +130,29 @@ func (s *BalanceNotifyService) CheckAccountQuotaAfterIncrement(ctx context.Conte ...@@ -123,8 +130,29 @@ func (s *BalanceNotifyService) CheckAccountQuotaAfterIncrement(ctx context.Conte
return return
} }
freshAccount := s.fetchFreshAccount(ctx, account)
siteName := s.getSiteName(ctx) 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 { if !dim.enabled || dim.threshold <= 0 {
continue continue
} }
...@@ -132,9 +160,12 @@ func (s *BalanceNotifyService) CheckAccountQuotaAfterIncrement(ctx context.Conte ...@@ -132,9 +160,12 @@ func (s *BalanceNotifyService) CheckAccountQuotaAfterIncrement(ctx context.Conte
if effectiveThreshold <= 0 { if effectiveThreshold <= 0 {
continue continue
} }
newUsed := dim.oldUsed + cost // dim.oldUsed is actually the post-increment value from fresh DB data;
if dim.oldUsed < effectiveThreshold && newUsed >= effectiveThreshold { // reconstruct pre-increment value to detect threshold crossing.
s.asyncSendQuotaAlert(adminEmails, account.Name, dim, newUsed, effectiveThreshold, siteName) newUsed := dim.oldUsed
oldUsed := dim.oldUsed - cost
if oldUsed < effectiveThreshold && newUsed >= effectiveThreshold {
s.asyncSendQuotaAlert(adminEmails, freshAccount.Name, dim, newUsed, effectiveThreshold, siteName)
} }
} }
} }
......
...@@ -75,6 +75,9 @@ type ParsedRequest struct { ...@@ -75,6 +75,9 @@ type ParsedRequest struct {
MaxTokens int // max_tokens 值(用于探测请求拦截) MaxTokens int // max_tokens 值(用于探测请求拦截)
SessionContext *SessionContext // 可选:请求上下文区分因子(nil 时行为不变) SessionContext *SessionContext // 可选:请求上下文区分因子(nil 时行为不变)
// GroupID 请求所属分组 ID(来自 API Key)
GroupID *int64
// OnUpstreamAccepted 上游接受请求后立即调用(用于提前释放串行锁) // OnUpstreamAccepted 上游接受请求后立即调用(用于提前释放串行锁)
// 流式请求在收到 2xx 响应头后调用,避免持锁等流完成 // 流式请求在收到 2xx 响应头后调用,避免持锁等流完成
OnUpstreamAccepted func() OnUpstreamAccepted func()
......
...@@ -3789,7 +3789,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A ...@@ -3789,7 +3789,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
} }
// Web Search 模拟:纯 web_search 请求时,直接调用搜索 API 构造响应 // 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) return s.handleWebSearchEmulation(ctx, c, account, parsed)
} }
...@@ -7588,7 +7588,7 @@ func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsage ...@@ -7588,7 +7588,7 @@ func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsage
upstreamModel = result.Model upstreamModel = result.Model
} }
usageLog.AccountStatsCost = resolveAccountStatsCost( usageLog.AccountStatsCost = resolveAccountStatsCost(
ctx, s.channelService, ctx, s.channelService, s.billingService,
account.ID, *apiKey.GroupID, upstreamModel, account.ID, *apiKey.GroupID, upstreamModel,
UsageTokens{ UsageTokens{
InputTokens: result.Usage.InputTokens, InputTokens: result.Usage.InputTokens,
......
...@@ -49,10 +49,9 @@ func getWebSearchManager() *websearch.Manager { ...@@ -49,10 +49,9 @@ func getWebSearchManager() *websearch.Manager {
// shouldEmulateWebSearch checks whether a request should be intercepted. // shouldEmulateWebSearch checks whether a request should be intercepted.
// //
// Judgment chain: manager exists → only web_search tool → global enabled → account enabled. // Judgment chain: manager exists → only web_search tool → global enabled → account/channel enabled.
// Note: channel-level control is enforced via the account's extra field; the channel toggle // Account-level mode: "enabled" (force on), "disabled" (force off), "default" (follow channel).
// 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, groupID *int64, body []byte) bool {
func (s *GatewayService) shouldEmulateWebSearch(ctx context.Context, account *Account, body []byte) bool {
if getWebSearchManager() == nil { if getWebSearchManager() == nil {
return false return false
} }
...@@ -62,10 +61,23 @@ func (s *GatewayService) shouldEmulateWebSearch(ctx context.Context, account *Ac ...@@ -62,10 +61,23 @@ func (s *GatewayService) shouldEmulateWebSearch(ctx context.Context, account *Ac
if !s.settingService.IsWebSearchEmulationEnabled(ctx) { if !s.settingService.IsWebSearchEmulationEnabled(ctx) {
return false return false
} }
if !account.IsWebSearchEmulationEnabled() {
mode := account.GetWebSearchEmulationMode()
switch mode {
case WebSearchModeEnabled:
return true
case WebSearchModeDisabled:
return false 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. // isOnlyWebSearchToolInBody checks if the body contains exactly one web_search tool.
......
...@@ -4580,7 +4580,7 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec ...@@ -4580,7 +4580,7 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec
statsModel = result.Model statsModel = result.Model
} }
usageLog.AccountStatsCost = resolveAccountStatsCost( usageLog.AccountStatsCost = resolveAccountStatsCost(
ctx, s.channelService, ctx, s.channelService, s.billingService,
account.ID, *apiKey.GroupID, statsModel, account.ID, *apiKey.GroupID, statsModel,
tokens, 1, tokens, 1,
) )
......
...@@ -476,8 +476,8 @@ func ProvidePaymentConfigService(entClient *dbent.Client, settingRepo SettingRep ...@@ -476,8 +476,8 @@ func ProvidePaymentConfigService(entClient *dbent.Client, settingRepo SettingRep
} }
// ProvideBalanceNotifyService creates BalanceNotifyService // ProvideBalanceNotifyService creates BalanceNotifyService
func ProvideBalanceNotifyService(emailService *EmailService, settingRepo SettingRepository) *BalanceNotifyService { func ProvideBalanceNotifyService(emailService *EmailService, settingRepo SettingRepository, accountRepo AccountRepository) *BalanceNotifyService {
return NewBalanceNotifyService(emailService, settingRepo) return NewBalanceNotifyService(emailService, settingRepo, accountRepo)
} }
// ProvidePaymentOrderExpiryService creates and starts PaymentOrderExpiryService. // ProvidePaymentOrderExpiryService creates and starts PaymentOrderExpiryService.
......
-- 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';
...@@ -2337,7 +2337,11 @@ ...@@ -2337,7 +2337,11 @@
{{ t('admin.accounts.anthropic.webSearchEmulationDesc') }} {{ t('admin.accounts.anthropic.webSearchEmulationDesc') }}
</p> </p>
</div> </div>
<Toggle v-model="webSearchEmulationEnabled" /> <select v-model="webSearchEmulationMode" class="input w-32 text-sm">
<option value="default">{{ t('admin.accounts.anthropic.webSearchDefault') }}</option>
<option value="enabled">{{ t('admin.accounts.anthropic.webSearchEnabled') }}</option>
<option value="disabled">{{ t('admin.accounts.anthropic.webSearchDisabled') }}</option>
</select>
</div> </div>
</div> </div>
...@@ -2846,7 +2850,6 @@ import ConfirmDialog from '@/components/common/ConfirmDialog.vue' ...@@ -2846,7 +2850,6 @@ import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import Select from '@/components/common/Select.vue' import Select from '@/components/common/Select.vue'
import Icon from '@/components/icons/Icon.vue' import Icon from '@/components/icons/Icon.vue'
import ProxySelector from '@/components/common/ProxySelector.vue' import ProxySelector from '@/components/common/ProxySelector.vue'
import Toggle from '@/components/common/Toggle.vue'
import GroupSelector from '@/components/common/GroupSelector.vue' import GroupSelector from '@/components/common/GroupSelector.vue'
import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.vue' import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.vue'
import QuotaLimitCard from '@/components/account/QuotaLimitCard.vue' import QuotaLimitCard from '@/components/account/QuotaLimitCard.vue'
...@@ -2997,7 +3000,7 @@ const openaiOAuthResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF ...@@ -2997,7 +3000,7 @@ const openaiOAuthResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF
const openaiAPIKeyResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF) const openaiAPIKeyResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF)
const codexCLIOnlyEnabled = ref(false) const codexCLIOnlyEnabled = ref(false)
const anthropicPassthroughEnabled = ref(false) const anthropicPassthroughEnabled = ref(false)
const webSearchEmulationEnabled = ref(false) const webSearchEmulationMode = ref('default')
const webSearchGlobalEnabled = ref(false) const webSearchGlobalEnabled = ref(false)
// Load web search global state once // Load web search global state once
...@@ -3331,7 +3334,7 @@ watch( ...@@ -3331,7 +3334,7 @@ watch(
} }
if (newPlatform !== 'anthropic') { if (newPlatform !== 'anthropic') {
anthropicPassthroughEnabled.value = false anthropicPassthroughEnabled.value = false
webSearchEmulationEnabled.value = false webSearchEmulationMode.value = 'default'
} }
// Reset OAuth states // Reset OAuth states
oauth.resetState() oauth.resetState()
...@@ -3351,7 +3354,7 @@ watch( ...@@ -3351,7 +3354,7 @@ watch(
} }
if (platform !== 'anthropic' || category !== 'apikey') { if (platform !== 'anthropic' || category !== 'apikey') {
anthropicPassthroughEnabled.value = false anthropicPassthroughEnabled.value = false
webSearchEmulationEnabled.value = false webSearchEmulationMode.value = 'default'
} }
} }
) )
...@@ -3716,7 +3719,7 @@ const resetForm = () => { ...@@ -3716,7 +3719,7 @@ const resetForm = () => {
openaiAPIKeyResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF openaiAPIKeyResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF
codexCLIOnlyEnabled.value = false codexCLIOnlyEnabled.value = false
anthropicPassthroughEnabled.value = false anthropicPassthroughEnabled.value = false
webSearchEmulationEnabled.value = false webSearchEmulationMode.value = 'default'
// Reset quota control state // Reset quota control state
windowCostEnabled.value = false windowCostEnabled.value = false
windowCostLimit.value = null windowCostLimit.value = null
...@@ -3804,10 +3807,10 @@ const buildAnthropicExtra = (base?: Record<string, unknown>): Record<string, unk ...@@ -3804,10 +3807,10 @@ const buildAnthropicExtra = (base?: Record<string, unknown>): Record<string, unk
} else { } else {
delete extra.anthropic_passthrough delete extra.anthropic_passthrough
} }
if (webSearchEmulationEnabled.value) { if (webSearchEmulationMode.value === 'default') {
extra.web_search_emulation = true
} else {
delete extra.web_search_emulation delete extra.web_search_emulation
} else {
extra.web_search_emulation = webSearchEmulationMode.value
} }
return Object.keys(extra).length > 0 ? extra : undefined return Object.keys(extra).length > 0 ? extra : undefined
......
...@@ -1161,7 +1161,11 @@ ...@@ -1161,7 +1161,11 @@
{{ t('admin.accounts.anthropic.webSearchEmulationDesc') }} {{ t('admin.accounts.anthropic.webSearchEmulationDesc') }}
</p> </p>
</div> </div>
<Toggle v-model="webSearchEmulationEnabled" /> <select v-model="webSearchEmulationMode" class="input w-32 text-sm">
<option value="default">{{ t('admin.accounts.anthropic.webSearchDefault') }}</option>
<option value="enabled">{{ t('admin.accounts.anthropic.webSearchEnabled') }}</option>
<option value="disabled">{{ t('admin.accounts.anthropic.webSearchDisabled') }}</option>
</select>
</div> </div>
</div> </div>
...@@ -1844,7 +1848,6 @@ import ConfirmDialog from '@/components/common/ConfirmDialog.vue' ...@@ -1844,7 +1848,6 @@ import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import Select from '@/components/common/Select.vue' import Select from '@/components/common/Select.vue'
import Icon from '@/components/icons/Icon.vue' import Icon from '@/components/icons/Icon.vue'
import ProxySelector from '@/components/common/ProxySelector.vue' import ProxySelector from '@/components/common/ProxySelector.vue'
import Toggle from '@/components/common/Toggle.vue'
import GroupSelector from '@/components/common/GroupSelector.vue' import GroupSelector from '@/components/common/GroupSelector.vue'
import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.vue' import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.vue'
import QuotaLimitCard from '@/components/account/QuotaLimitCard.vue' import QuotaLimitCard from '@/components/account/QuotaLimitCard.vue'
...@@ -1986,7 +1989,7 @@ const openaiOAuthResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF ...@@ -1986,7 +1989,7 @@ const openaiOAuthResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF
const openaiAPIKeyResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF) const openaiAPIKeyResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF)
const codexCLIOnlyEnabled = ref(false) const codexCLIOnlyEnabled = ref(false)
const anthropicPassthroughEnabled = ref(false) const anthropicPassthroughEnabled = ref(false)
const webSearchEmulationEnabled = ref(false) const webSearchEmulationMode = ref('default')
const webSearchGlobalEnabled = ref(false) const webSearchGlobalEnabled = ref(false)
// Load web search global state once // Load web search global state once
...@@ -2171,7 +2174,7 @@ const syncFormFromAccount = (newAccount: Account | null) => { ...@@ -2171,7 +2174,7 @@ const syncFormFromAccount = (newAccount: Account | null) => {
openaiAPIKeyResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF openaiAPIKeyResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF
codexCLIOnlyEnabled.value = false codexCLIOnlyEnabled.value = false
anthropicPassthroughEnabled.value = false anthropicPassthroughEnabled.value = false
webSearchEmulationEnabled.value = false webSearchEmulationMode.value = 'default'
if (newAccount.platform === 'openai' && (newAccount.type === 'oauth' || newAccount.type === 'apikey')) { if (newAccount.platform === 'openai' && (newAccount.type === 'oauth' || newAccount.type === 'apikey')) {
openaiPassthroughEnabled.value = extra?.openai_passthrough === true || extra?.openai_oauth_passthrough === true openaiPassthroughEnabled.value = extra?.openai_passthrough === true || extra?.openai_oauth_passthrough === true
openaiOAuthResponsesWebSocketV2Mode.value = resolveOpenAIWSModeFromExtra(extra, { openaiOAuthResponsesWebSocketV2Mode.value = resolveOpenAIWSModeFromExtra(extra, {
...@@ -2192,7 +2195,15 @@ const syncFormFromAccount = (newAccount: Account | null) => { ...@@ -2192,7 +2195,15 @@ const syncFormFromAccount = (newAccount: Account | null) => {
} }
if (newAccount.platform === 'anthropic' && newAccount.type === 'apikey') { if (newAccount.platform === 'anthropic' && newAccount.type === 'apikey') {
anthropicPassthroughEnabled.value = extra?.anthropic_passthrough === true 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) // Load quota limit for apikey/bedrock accounts (bedrock quota is also loaded in its own branch above)
...@@ -3180,10 +3191,10 @@ const handleSubmit = async () => { ...@@ -3180,10 +3191,10 @@ const handleSubmit = async () => {
} else { } else {
delete newExtra.anthropic_passthrough delete newExtra.anthropic_passthrough
} }
if (webSearchEmulationEnabled.value) { if (webSearchEmulationMode.value === 'default') {
newExtra.web_search_emulation = true
} else {
delete newExtra.web_search_emulation delete newExtra.web_search_emulation
} else {
newExtra.web_search_emulation = webSearchEmulationMode.value
} }
updatePayload.extra = newExtra updatePayload.extra = newExtra
} }
......
...@@ -279,13 +279,21 @@ ...@@ -279,13 +279,21 @@
<span class="text-gray-400">{{ t('admin.usage.outputCost') }}</span> <span class="text-gray-400">{{ t('admin.usage.outputCost') }}</span>
<span class="font-medium text-white">${{ tooltipData.output_cost.toFixed(6) }}</span> <span class="font-medium text-white">${{ tooltipData.output_cost.toFixed(6) }}</span>
</div> </div>
<div v-if="tooltipData && tooltipData.input_tokens > 0" class="flex items-center justify-between gap-4"> <!-- Token billing: show unit prices per 1M tokens -->
<span class="text-gray-400">{{ t('usage.inputTokenPrice') }}</span> <template v-if="!tooltipData?.billing_mode || tooltipData.billing_mode === 'token'">
<span class="font-medium text-sky-300">{{ formatTokenPricePerMillion(tooltipData.input_cost, tooltipData.input_tokens) }} {{ t('usage.perMillionTokens') }}</span> <div v-if="tooltipData && tooltipData.input_tokens > 0" class="flex items-center justify-between gap-4">
</div> <span class="text-gray-400">{{ t('usage.inputTokenPrice') }}</span>
<div v-if="tooltipData && tooltipData.output_tokens > 0" class="flex items-center justify-between gap-4"> <span class="font-medium text-sky-300">{{ formatTokenPricePerMillion(tooltipData.input_cost, tooltipData.input_tokens) }} {{ t('usage.perMillionTokens') }}</span>
<span class="text-gray-400">{{ t('usage.outputTokenPrice') }}</span> </div>
<span class="font-medium text-violet-300">{{ formatTokenPricePerMillion(tooltipData.output_cost, tooltipData.output_tokens) }} {{ t('usage.perMillionTokens') }}</span> <div v-if="tooltipData && tooltipData.output_tokens > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.outputTokenPrice') }}</span>
<span class="font-medium text-violet-300">{{ formatTokenPricePerMillion(tooltipData.output_cost, tooltipData.output_tokens) }} {{ t('usage.perMillionTokens') }}</span>
</div>
</template>
<!-- Per-request / image billing: show unit price -->
<div v-else class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ tooltipData.billing_mode === 'image' ? t('usage.imageUnitPrice') : t('usage.unitPrice') }}</span>
<span class="font-medium text-sky-300">${{ tooltipData.total_cost?.toFixed(6) || '0.000000' }}</span>
</div> </div>
<div v-if="tooltipData && tooltipData.cache_creation_cost > 0" class="flex items-center justify-between gap-4"> <div v-if="tooltipData && tooltipData.cache_creation_cost > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('admin.usage.cacheCreationCost') }}</span> <span class="text-gray-400">{{ t('admin.usage.cacheCreationCost') }}</span>
......
...@@ -774,6 +774,8 @@ export default { ...@@ -774,6 +774,8 @@ export default {
inputTokenPrice: 'Input price', inputTokenPrice: 'Input price',
outputTokenPrice: 'Output price', outputTokenPrice: 'Output price',
perMillionTokens: '/ 1M tokens', perMillionTokens: '/ 1M tokens',
unitPrice: 'Per-request price',
imageUnitPrice: 'Per-image price',
cacheRead: 'Read', cacheRead: 'Read',
cacheWrite: 'Write', cacheWrite: 'Write',
serviceTier: 'Service tier', serviceTier: 'Service tier',
...@@ -1877,14 +1879,15 @@ export default { ...@@ -1877,14 +1879,15 @@ export default {
pricingEntry: 'Pricing Entry', pricingEntry: 'Pricing Entry',
noModels: 'No models added', noModels: 'No models added',
applyPricingToAccountStats: 'Apply Pricing to Account Stats', 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', accountStatsPricingRules: 'Custom Account Stats Pricing Rules',
addRule: 'Add Rule', addRule: 'Add Rule',
noRulesConfigured: 'No custom rules configured. Channel model pricing above will be used.', noRulesConfigured: 'No custom rules configured. Channel model pricing above will be used.',
ruleName: 'Rule name (optional)', ruleName: 'Rule name (optional)',
ruleGroups: 'Groups', ruleGroups: 'Groups',
ruleAccounts: 'Account IDs', ruleAccounts: 'Accounts',
ruleAccountsPlaceholder: 'Enter account IDs, comma-separated', searchAccountPlaceholder: 'Search accounts...',
ruleAccountsHint: 'Leave empty to match all accounts',
ruleModelPricing: 'Model Pricing', ruleModelPricing: 'Model Pricing',
noGroupsInChannel: 'No groups selected in platform tabs above' noGroupsInChannel: 'No groups selected in platform tabs above'
} }
...@@ -2380,6 +2383,9 @@ export default { ...@@ -2380,6 +2383,9 @@ export default {
webSearchEmulation: 'Web Search Emulation', webSearchEmulation: 'Web Search Emulation',
webSearchEmulationDesc: 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.', '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)', modelRestriction: 'Model Restriction (Optional)',
modelWhitelist: 'Model Whitelist', modelWhitelist: 'Model Whitelist',
......
...@@ -778,6 +778,8 @@ export default { ...@@ -778,6 +778,8 @@ export default {
inputTokenPrice: '输入单价', inputTokenPrice: '输入单价',
outputTokenPrice: '输出单价', outputTokenPrice: '输出单价',
perMillionTokens: '/ 1M Token', perMillionTokens: '/ 1M Token',
unitPrice: '单次价格',
imageUnitPrice: '单张价格',
cacheRead: '读取', cacheRead: '读取',
cacheWrite: '写入', cacheWrite: '写入',
serviceTier: '服务档位', serviceTier: '服务档位',
...@@ -1956,14 +1958,15 @@ export default { ...@@ -1956,14 +1958,15 @@ export default {
pricingEntry: '定价配置', pricingEntry: '定价配置',
noModels: '未添加模型', noModels: '未添加模型',
applyPricingToAccountStats: '应用模型定价到账号统计', applyPricingToAccountStats: '应用模型定价到账号统计',
applyPricingToAccountStatsDesc: '启用后将支持自定义账号统计的模型价格', applyPricingToAccountStatsDesc: '启用后,未被自定义规则匹配的请求将使用模型定价文件中的标准价格计算账号统计费用',
accountStatsPricingRules: '自定义账号统计定价规则', accountStatsPricingRules: '自定义账号统计定价规则',
addRule: '添加规则', addRule: '添加规则',
noRulesConfigured: '未配置自定义规则,将使用上方的模型定价。', noRulesConfigured: '未配置自定义规则,将使用上方的模型定价。',
ruleName: '规则名称(可选)', ruleName: '规则名称(可选)',
ruleGroups: '分组', ruleGroups: '分组',
ruleAccounts: '账号 ID', ruleAccounts: '账号',
ruleAccountsPlaceholder: '输入账号 ID,逗号分隔', searchAccountPlaceholder: '搜索账号...',
ruleAccountsHint: '留空表示匹配所有账号',
ruleModelPricing: '模型定价', ruleModelPricing: '模型定价',
noGroupsInChannel: '上方平台标签页中未选择分组' noGroupsInChannel: '上方平台标签页中未选择分组'
} }
...@@ -2527,6 +2530,9 @@ export default { ...@@ -2527,6 +2530,9 @@ export default {
webSearchEmulation: 'Web Search 模拟', webSearchEmulation: 'Web Search 模拟',
webSearchEmulationDesc: webSearchEmulationDesc:
'为该 API Key 账号启用 web search 模拟。客户端发送纯 web_search 请求时,由网关调用第三方搜索 API 并构造响应返回。', '为该 API Key 账号启用 web search 模拟。客户端发送纯 web_search 请求时,由网关调用第三方搜索 API 并构造响应返回。',
webSearchDefault: '默认(跟随渠道)',
webSearchEnabled: '开启',
webSearchDisabled: '关闭',
}, },
modelRestriction: '模型限制(可选)', modelRestriction: '模型限制(可选)',
modelWhitelist: '模型白名单', modelWhitelist: '模型白名单',
......
...@@ -413,8 +413,8 @@ ...@@ -413,8 +413,8 @@
</div> </div>
</div> </div>
<!-- Account Stats Pricing Rules (per-platform, only when global toggle is on) --> <!-- Account Stats Pricing Rules (per-platform, always visible) -->
<div v-if="form.apply_pricing_to_account_stats" class="mt-4 border-t border-gray-200 pt-4 dark:border-dark-700 space-y-3"> <div class="mt-4 border-t border-gray-200 pt-4 dark:border-dark-700 space-y-3">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300"> <h4 class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.channels.form.accountStatsPricingRules') }} {{ t('admin.channels.form.accountStatsPricingRules') }}
...@@ -474,12 +474,51 @@ ...@@ -474,12 +474,51 @@
<div> <div>
<label class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.channels.form.ruleAccounts') }}</label> <label class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.channels.form.ruleAccounts') }}</label>
<input <!-- Selected account chips -->
:value="rule.account_ids.join(', ')" <div class="mt-1 flex flex-wrap gap-1">
@change="rule.account_ids = parseAccountIdsInput(($event.target as HTMLInputElement).value)" <span
:placeholder="t('admin.channels.form.ruleAccountsPlaceholder')" v-for="accountId in rule.account_ids"
class="input mt-1 text-sm" :key="accountId"
/> class="inline-flex items-center gap-1 rounded-md border border-primary-300 bg-primary-50 px-2 py-0.5 text-xs dark:border-primary-700 dark:bg-primary-900/20"
>
<span>{{ getRuleAccountLabel(accountId) }}</span>
<button type="button" @click="removeRuleAccount(rule, accountId)" class="text-gray-400 hover:text-red-500">
<Icon name="x" size="xs" />
</button>
</span>
</div>
<!-- Account search input -->
<div class="relative mt-1 rule-account-search-container">
<input
v-model="ruleAccountSearchKeyword[`${section.platform}-${ruleIndex}`]"
type="text"
class="input text-sm"
:placeholder="t('admin.channels.form.searchAccountPlaceholder')"
@input="onRuleAccountSearchInput(section.platform, ruleIndex)"
@focus="onRuleAccountSearchFocus(section.platform, ruleIndex)"
/>
<!-- Search results dropdown -->
<div
v-if="showRuleAccountDropdown[`${section.platform}-${ruleIndex}`] && (ruleAccountSearchResults[`${section.platform}-${ruleIndex}`]?.length ?? 0) > 0"
class="absolute z-50 mt-1 max-h-48 w-full overflow-auto rounded-lg border bg-white shadow-lg dark:border-dark-600 dark:bg-dark-800"
>
<button
v-for="account in ruleAccountSearchResults[`${section.platform}-${ruleIndex}`]"
:key="account.id"
type="button"
@click="selectRuleAccount(rule, account, section.platform, ruleIndex)"
class="w-full px-3 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-dark-700"
:class="{ 'opacity-50': rule.account_ids.includes(account.id) }"
:disabled="rule.account_ids.includes(account.id)"
>
<span>{{ account.name }}</span>
<span class="ml-2 text-xs text-gray-400">#{{ account.id }}</span>
</button>
</div>
</div>
<p class="mt-1 text-xs text-gray-400">
{{ t('admin.channels.form.ruleAccountsHint') }}
</p>
</div> </div>
<div> <div>
...@@ -569,6 +608,7 @@ import PlatformIcon from '@/components/common/PlatformIcon.vue' ...@@ -569,6 +608,7 @@ import PlatformIcon from '@/components/common/PlatformIcon.vue'
import Toggle from '@/components/common/Toggle.vue' import Toggle from '@/components/common/Toggle.vue'
import PricingEntryCard from '@/components/admin/channel/PricingEntryCard.vue' import PricingEntryCard from '@/components/admin/channel/PricingEntryCard.vue'
import { getPersistedPageSize } from '@/composables/usePersistedPageSize' import { getPersistedPageSize } from '@/composables/usePersistedPageSize'
import { useKeyedDebouncedSearch } from '@/composables/useKeyedDebouncedSearch'
const { t } = useI18n() const { t } = useI18n()
const appStore = useAppStore() const appStore = useAppStore()
...@@ -852,6 +892,9 @@ function addRulePricingEntry(ruleIndex: number) { ...@@ -852,6 +892,9 @@ function addRulePricingEntry(ruleIndex: number) {
function removeAccountStatsRule(ruleIndex: number) { function removeAccountStatsRule(ruleIndex: number) {
form.account_stats_pricing_rules.splice(ruleIndex, 1) 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) { function removeRulePricingEntry(ruleIndex: number, pricingIndex: number) {
...@@ -863,11 +906,78 @@ function getGroupNameById(groupId: number): string { ...@@ -863,11 +906,78 @@ function getGroupNameById(groupId: number): string {
return group ? group.name : `#${groupId}` return group ? group.name : `#${groupId}`
} }
function parseAccountIdsInput(value: string): number[] { // ── Account search for pricing rules ──
return value interface SimpleAccount { id: number; name: string }
.split(',')
.map(s => parseInt(s.trim())) const ruleAccountSearchKeyword = ref<Record<string, string>>({})
.filter(n => !isNaN(n) && n > 0) const ruleAccountSearchResults = ref<Record<string, SimpleAccount[]>>({})
const showRuleAccountDropdown = ref<Record<string, boolean>>({})
// Cache: account ID → name, populated when search results are selected
const ruleAccountNameCache = ref<Record<number, string>>({})
const ruleAccountSearchRunner = useKeyedDebouncedSearch<SimpleAccount[]>({
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[] { function accountStatsRulesToAPI(): AccountStatsPricingRule[] {
...@@ -1093,6 +1203,9 @@ function resetForm() { ...@@ -1093,6 +1203,9 @@ function resetForm() {
form.apply_pricing_to_account_stats = false form.apply_pricing_to_account_stats = false
form.account_stats_pricing_rules = [] form.account_stats_pricing_rules = []
activeTab.value = 'basic' activeTab.value = 'basic'
ruleAccountSearchRunner.clearAll()
clearAllRuleAccountSearchState()
ruleAccountNameCache.value = {}
} }
async function openCreateDialog() { async function openCreateDialog() {
...@@ -1313,11 +1426,15 @@ onMounted(() => { ...@@ -1313,11 +1426,15 @@ onMounted(() => {
loadChannels() loadChannels()
loadGroups() loadGroups()
loadWebSearchGlobalState() loadWebSearchGlobalState()
document.addEventListener('click', handleRuleAccountClickOutside)
}) })
onUnmounted(() => { onUnmounted(() => {
clearTimeout(searchTimeout) clearTimeout(searchTimeout)
abortController?.abort() abortController?.abort()
document.removeEventListener('click', handleRuleAccountClickOutside)
ruleAccountSearchRunner.clearAll()
clearAllRuleAccountSearchState()
}) })
</script> </script>
......
...@@ -447,13 +447,21 @@ ...@@ -447,13 +447,21 @@
<span class="text-gray-400">{{ t('admin.usage.outputCost') }}</span> <span class="text-gray-400">{{ t('admin.usage.outputCost') }}</span>
<span class="font-medium text-white">${{ tooltipData.output_cost.toFixed(6) }}</span> <span class="font-medium text-white">${{ tooltipData.output_cost.toFixed(6) }}</span>
</div> </div>
<div v-if="tooltipData && tooltipData.input_tokens > 0" class="flex items-center justify-between gap-4"> <!-- Token billing: show unit prices per 1M tokens -->
<span class="text-gray-400">{{ t('usage.inputTokenPrice') }}</span> <template v-if="!tooltipData?.billing_mode || tooltipData.billing_mode === 'token'">
<span class="font-medium text-sky-300">{{ formatTokenPricePerMillion(tooltipData.input_cost, tooltipData.input_tokens) }} {{ t('usage.perMillionTokens') }}</span> <div v-if="tooltipData && tooltipData.input_tokens > 0" class="flex items-center justify-between gap-4">
</div> <span class="text-gray-400">{{ t('usage.inputTokenPrice') }}</span>
<div v-if="tooltipData && tooltipData.output_tokens > 0" class="flex items-center justify-between gap-4"> <span class="font-medium text-sky-300">{{ formatTokenPricePerMillion(tooltipData.input_cost, tooltipData.input_tokens) }} {{ t('usage.perMillionTokens') }}</span>
<span class="text-gray-400">{{ t('usage.outputTokenPrice') }}</span> </div>
<span class="font-medium text-violet-300">{{ formatTokenPricePerMillion(tooltipData.output_cost, tooltipData.output_tokens) }} {{ t('usage.perMillionTokens') }}</span> <div v-if="tooltipData && tooltipData.output_tokens > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.outputTokenPrice') }}</span>
<span class="font-medium text-violet-300">{{ formatTokenPricePerMillion(tooltipData.output_cost, tooltipData.output_tokens) }} {{ t('usage.perMillionTokens') }}</span>
</div>
</template>
<!-- Per-request / image billing: show unit price -->
<div v-else class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ tooltipData.billing_mode === 'image' ? t('usage.imageUnitPrice') : t('usage.unitPrice') }}</span>
<span class="font-medium text-sky-300">${{ tooltipData.total_cost?.toFixed(6) || '0.000000' }}</span>
</div> </div>
<div v-if="tooltipData && tooltipData.cache_creation_cost > 0" class="flex items-center justify-between gap-4"> <div v-if="tooltipData && tooltipData.cache_creation_cost > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('admin.usage.cacheCreationCost') }}</span> <span class="text-gray-400">{{ t('admin.usage.cacheCreationCost') }}</span>
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment