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
This commit is contained in:
erio
2026-04-13 11:37:08 +08:00
parent 11c4606874
commit 1262654d97
18 changed files with 346 additions and 79 deletions

View File

@@ -2337,7 +2337,11 @@
{{ t('admin.accounts.anthropic.webSearchEmulationDesc') }}
</p>
</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>
@@ -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<OpenAIWSMode>(OPENAI_WS_MODE_OFF
const openaiAPIKeyResponsesWebSocketV2Mode = ref<OpenAIWSMode>(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<string, unknown>): Record<string, unk
} else {
delete extra.anthropic_passthrough
}
if (webSearchEmulationEnabled.value) {
extra.web_search_emulation = true
} else {
if (webSearchEmulationMode.value === 'default') {
delete extra.web_search_emulation
} else {
extra.web_search_emulation = webSearchEmulationMode.value
}
return Object.keys(extra).length > 0 ? extra : undefined

View File

@@ -1161,7 +1161,11 @@
{{ t('admin.accounts.anthropic.webSearchEmulationDesc') }}
</p>
</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>
@@ -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<OpenAIWSMode>(OPENAI_WS_MODE_OFF
const openaiAPIKeyResponsesWebSocketV2Mode = ref<OpenAIWSMode>(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
}

View File

@@ -279,13 +279,21 @@
<span class="text-gray-400">{{ t('admin.usage.outputCost') }}</span>
<span class="font-medium text-white">${{ tooltipData.output_cost.toFixed(6) }}</span>
</div>
<div v-if="tooltipData && tooltipData.input_tokens > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.inputTokenPrice') }}</span>
<span class="font-medium text-sky-300">{{ formatTokenPricePerMillion(tooltipData.input_cost, tooltipData.input_tokens) }} {{ t('usage.perMillionTokens') }}</span>
</div>
<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>
<!-- Token billing: show unit prices per 1M tokens -->
<template v-if="!tooltipData?.billing_mode || tooltipData.billing_mode === 'token'">
<div v-if="tooltipData && tooltipData.input_tokens > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.inputTokenPrice') }}</span>
<span class="font-medium text-sky-300">{{ formatTokenPricePerMillion(tooltipData.input_cost, tooltipData.input_tokens) }} {{ t('usage.perMillionTokens') }}</span>
</div>
<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 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>