feat(usage): 用量记录落库上游请求标识并支持按账户配置响应头名

- usage_logs 新增 upstream_request_id 列(迁移 232)与仅索引非空行的 CONCURRENTLY 部分索引(迁移 233,runner 登记失效索引清理)
- 账户 extra.upstream_request_id_header 指定要读取的上游响应头,只读该头;留空不记录;创建/更新/批量入口校验头名合法且不超过 64 字节
- ForwardResult/OpenAIForwardResult 携带 UpstreamHeaders,中央计费按账户解析落库,WS 轮次保持 NULL
- 管理端用量明细新增默认隐藏的"上游ID"列,带与请求ID列一致的复制按钮,并同步导出
- 创建/编辑账户弹窗新增"上游ID"输入(UpstreamRequestIdHeaderField),提示按接入类型与平台分条列出常见头名
- HelpTooltip 悬停模式下指针移入提示框时保持打开,便于选中复制提示内容
This commit is contained in:
feeeei
2026-09-03 18:02:15 +08:00
parent 5097b31457
commit de27905e88
62 changed files with 901 additions and 56 deletions
@@ -2922,6 +2922,12 @@
<ProxySelector v-model="form.proxy_id" :proxies="proxies" />
</div>
<UpstreamRequestIdHeaderField
v-model="upstreamRequestIdHeader"
:platform="form.platform"
:type="form.type"
/>
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
<div>
<label class="input-label">{{ t('admin.accounts.concurrency') }}</label>
@@ -3798,6 +3804,7 @@ import BaseDialog from '@/components/common/BaseDialog.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import Select from '@/components/common/Select.vue'
import HelpTooltip from '@/components/common/HelpTooltip.vue'
import UpstreamRequestIdHeaderField from '@/components/account/UpstreamRequestIdHeaderField.vue'
import PlatformIcon from '@/components/common/PlatformIcon.vue'
import Icon from '@/components/icons/Icon.vue'
import ProxySelector from '@/components/common/ProxySelector.vue'
@@ -3871,6 +3878,14 @@ const oauthStepTitle = computed(() => {
})
// Platform-specific hints for API Key type
// 上游ID:直接上游声明请求标识的响应头名,留空不记录。
const upstreamRequestIdHeader = ref('')
const withUpstreamRequestIdHeader = <T extends Record<string, unknown> | undefined>(extra: T): T | Record<string, unknown> => {
const name = upstreamRequestIdHeader.value.trim()
if (!name) return extra
return { ...(extra || {}), upstream_request_id_header: name }
}
const baseUrlHint = computed(() => {
if (form.platform === 'openai') return t('admin.accounts.openai.baseUrlHint')
if (form.platform === 'gemini') return t('admin.accounts.gemini.baseUrlHint')
@@ -5648,7 +5663,7 @@ const handleSubmit = async () => {
await doCreateAccount({
...form,
group_ids: form.group_ids,
extra,
extra: withUpstreamRequestIdHeader(extra),
upstream_billing_probe_enabled: upstreamBillingAutoProbeEnabled.value,
auto_pause_on_expired: autoPauseOnExpired.value
})
@@ -5711,9 +5726,9 @@ const createAccountAndFinish = async (
return
}
// Inject quota limits for apikey/bedrock accounts
let finalExtra = extra
let finalExtra = withUpstreamRequestIdHeader(extra)
if (type === 'apikey' || type === 'bedrock') {
const quotaExtra: Record<string, unknown> = { ...(extra || {}) }
const quotaExtra: Record<string, unknown> = { ...(finalExtra || {}) }
if (editQuotaLimit.value != null && editQuotaLimit.value > 0) {
quotaExtra.quota_limit = editQuotaLimit.value
}
@@ -5837,7 +5852,7 @@ const handleGrokValidateRT = async (refreshTokenInput: string) => {
platform: 'grok',
type: 'oauth',
credentials,
extra,
extra: withUpstreamRequestIdHeader(extra),
proxy_id: form.proxy_id,
concurrency: form.concurrency,
load_factor: form.load_factor ?? undefined,
@@ -6014,7 +6029,7 @@ const handleGrokAuthorizePassword = async (emailPasswordInput: string) => {
platform: 'grok',
type: 'oauth',
credentials,
extra,
extra: withUpstreamRequestIdHeader(extra),
proxy_id: form.proxy_id,
concurrency: form.concurrency,
load_factor: form.load_factor ?? undefined,
@@ -6113,7 +6128,7 @@ const handleOpenAIExchange = async (authCode: string) => {
platform: 'openai',
type: 'oauth',
credentials,
extra,
extra: withUpstreamRequestIdHeader(extra),
proxy_id: form.proxy_id,
concurrency: form.concurrency,
load_factor: form.load_factor ?? undefined,
@@ -6228,7 +6243,7 @@ const handleOpenAIImportCodexSession = async (content: string) => {
expires_at: form.expires_at,
auto_pause_on_expired: autoPauseOnExpired.value,
credential_extras: Object.keys(credentialExtras).length > 0 ? credentialExtras : undefined,
extra,
extra: withUpstreamRequestIdHeader(extra),
update_existing: true
})
@@ -6306,7 +6321,7 @@ const handleOpenAIImportCodexPAT = async (accessToken: string) => {
expires_at: form.expires_at,
auto_pause_on_expired: autoPauseOnExpired.value,
credential_extras: Object.keys(credentialExtras).length > 0 ? credentialExtras : undefined,
extra
extra: withUpstreamRequestIdHeader(extra)
})
appStore.showSuccess(t('admin.accounts.messages.accountCreated'))
@@ -6394,7 +6409,7 @@ const handleOpenAIBatchRT = async (refreshTokenInput: string, clientId?: string)
platform: 'openai',
type: 'oauth',
credentials,
extra,
extra: withUpstreamRequestIdHeader(extra),
proxy_id: form.proxy_id,
concurrency: form.concurrency,
load_factor: form.load_factor ?? undefined,
@@ -6493,7 +6508,7 @@ const handleAntigravityValidateRT = async (refreshTokenInput: string) => {
platform: 'antigravity',
type: 'oauth',
credentials,
extra: {},
extra: withUpstreamRequestIdHeader({}),
proxy_id: form.proxy_id,
concurrency: form.concurrency,
load_factor: form.load_factor ?? undefined,
@@ -6874,7 +6889,7 @@ const handleCookieAuth = async (sessionKey: string) => {
platform: form.platform,
type: addMethod.value, // Use addMethod as type: 'oauth' or 'setup-token'
credentials,
extra,
extra: withUpstreamRequestIdHeader(extra),
proxy_id: form.proxy_id,
concurrency: form.concurrency,
load_factor: form.load_factor ?? undefined,
@@ -1543,6 +1543,12 @@
<ProxySelector v-model="form.proxy_id" :proxies="proxies" />
</div>
<UpstreamRequestIdHeaderField
v-model="upstreamRequestIdHeader"
:platform="account.platform"
:type="account.type"
/>
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
<div>
<label class="input-label">{{ t('admin.accounts.concurrency') }}</label>
@@ -2887,6 +2893,7 @@ import BaseDialog from '@/components/common/BaseDialog.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import Select from '@/components/common/Select.vue'
import HelpTooltip from '@/components/common/HelpTooltip.vue'
import UpstreamRequestIdHeaderField from '@/components/account/UpstreamRequestIdHeaderField.vue'
import Toggle from '@/components/common/Toggle.vue'
import Icon from '@/components/icons/Icon.vue'
import ProxySelector from '@/components/common/ProxySelector.vue'
@@ -3207,6 +3214,12 @@ const autoResetCredit7dThreshold = ref(100)
const upstreamBillingAutoProbeEnabled = ref(false)
const upstreamBillingRateSyncEnabled = ref(false)
const mixedScheduling = ref(false) // For antigravity accounts: enable mixed scheduling
// 上游ID:直接上游声明请求标识的响应头名,留空不记录。
const upstreamRequestIdHeader = ref('')
const readUpstreamRequestIdHeader = (extra: unknown): string => {
const value = (extra as Record<string, unknown> | undefined)?.upstream_request_id_header
return typeof value === 'string' ? value : ''
}
const allowOverages = ref(false) // For antigravity accounts: enable AI Credits overages
const antigravityProjectId = ref('')
const antigravityModelRestrictionMode = ref<'whitelist' | 'mapping'>('whitelist')
@@ -3728,6 +3741,7 @@ const syncFormFromAccount = (newAccount: Account | null) => {
const extra = newAccount.extra as Record<string, unknown> | undefined
mixedScheduling.value = extra?.mixed_scheduling === true
allowOverages.value = extra?.allow_overages === true
upstreamRequestIdHeader.value = readUpstreamRequestIdHeader(extra)
autoPause5hThreshold.value = typeof extra?.auto_pause_5h_threshold === 'number' ? extra.auto_pause_5h_threshold * 100 : null
autoPause7dThreshold.value = typeof extra?.auto_pause_7d_threshold === 'number' ? extra.auto_pause_7d_threshold * 100 : null
autoPause5hDisabled.value = extra?.auto_pause_5h_disabled === true
@@ -5337,6 +5351,19 @@ const handleSubmit = async () => {
updatePayload.extra = newExtra
}
// 上游ID头名只在改动时写回 extra,避免用弹窗打开时的快照覆盖运行态键。
const nextUpstreamRequestIdHeader = upstreamRequestIdHeader.value.trim()
if (nextUpstreamRequestIdHeader !== readUpstreamRequestIdHeader(props.account.extra)) {
const currentExtra = (updatePayload.extra as Record<string, unknown>) || (props.account.extra as Record<string, unknown>) || {}
const newExtra: Record<string, unknown> = { ...currentExtra }
if (nextUpstreamRequestIdHeader) {
newExtra.upstream_request_id_header = nextUpstreamRequestIdHeader
} else {
delete newExtra.upstream_request_id_header
}
updatePayload.extra = newExtra
}
const canContinue = await ensureAntigravityMixedChannelConfirmed(async () => {
await submitUpdateAccount(accountID, updatePayload)
})
@@ -0,0 +1,78 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import HelpTooltip from '@/components/common/HelpTooltip.vue'
const props = defineProps<{
platform?: string
type?: string
}>()
const model = defineModel<string>({ default: '' })
const { t } = useI18n()
interface HeaderExample {
label: string
header: string
note?: string
}
// 各平台官方 API 在响应头中声明请求标识的头名。
const OFFICIAL_REQUEST_ID_HEADERS: Record<string, { platform: string; header: string }> = {
openai: { platform: 'OpenAI', header: 'x-request-id' },
anthropic: { platform: 'Anthropic', header: 'request-id' },
gemini: { platform: 'Gemini', header: 'x-goog-request-id' },
antigravity: { platform: 'Antigravity', header: 'x-goog-request-id' },
grok: { platform: 'Grok', header: 'xai-request-id' }
}
// 只有 API Key 接入可能指向第三方中继;OAuth / Bedrock / Service Account 都直连官方。
const examples = computed<HeaderExample[]>(() => {
const items: HeaderExample[] = []
if (props.type === 'apikey') {
items.push({
label: 'sub2api',
header: 'X-Client-Request-ID',
note: t('admin.accounts.upstreamRequestIdHeaderHelp.sub2apiNote')
})
items.push({ label: 'new-api / one-api', header: 'X-Oneapi-Request-Id' })
}
const official = props.platform ? OFFICIAL_REQUEST_ID_HEADERS[props.platform] : undefined
if (official) {
items.push({
label: t('admin.accounts.upstreamRequestIdHeaderHelp.official', { platform: official.platform }),
header: official.header
})
}
return items
})
</script>
<template>
<div>
<div class="flex items-center">
<label class="input-label">{{ t('admin.accounts.upstreamRequestIdHeader') }}</label>
<HelpTooltip width-class="w-80">
<p>{{ t('admin.accounts.upstreamRequestIdHeaderHelp.intro') }}</p>
<template v-if="examples.length">
<p class="mt-2 font-medium">{{ t('admin.accounts.upstreamRequestIdHeaderHelp.examplesTitle') }}</p>
<ul class="mt-1 space-y-1.5">
<li v-for="item in examples" :key="item.label">
<div class="flex items-center justify-between gap-3">
<span class="text-gray-300">{{ item.label }}</span>
<code class="select-all rounded bg-white/10 px-1.5 py-0.5 font-mono text-[11px] text-white">{{ item.header }}</code>
</div>
<p v-if="item.note" class="mt-0.5 text-gray-400">{{ item.note }}</p>
</li>
</ul>
</template>
</HelpTooltip>
</div>
<input
v-model="model"
data-testid="upstream-request-id-header"
type="text"
class="input"
:placeholder="t('admin.accounts.upstreamRequestIdHeaderPlaceholder')"
/>
</div>
</template>
@@ -247,6 +247,27 @@ describe('CreateAccountModal OpenAI long-context billing', () => {
expect(createAccountMock.mock.calls[0]?.[0]?.extra?.openai_long_context_billing_enabled).toBe(false)
})
it('omits the upstream request id header from extra when left empty', async () => {
await submitApiKeyAccount('openai')
expect(createAccountMock).toHaveBeenCalledTimes(1)
expect(createAccountMock.mock.calls[0]?.[0]?.extra).not.toHaveProperty('upstream_request_id_header')
})
it('sends the trimmed upstream request id header in extra when filled', async () => {
const wrapper = mountModal()
await selectButtonByText(wrapper, 'OpenAI')
await selectButtonByText(wrapper, 'API Key')
await wrapper.get('form#create-account-form input[type="text"]').setValue('openai account')
await wrapper.get('form#create-account-form input[type="password"]').setValue('test-api-key')
await wrapper.get('[data-testid="upstream-request-id-header"]').setValue(' X-Oneapi-Request-Id ')
await wrapper.get('form#create-account-form').trigger('submit.prevent')
await flushPromises()
expect(createAccountMock).toHaveBeenCalledTimes(1)
expect(createAccountMock.mock.calls[0]?.[0]?.extra?.upstream_request_id_header).toBe('X-Oneapi-Request-Id')
})
it('persists upstream model metadata after creating an account from preview', async () => {
const wrapper = mountModal()
await selectButtonByText(wrapper, 'OpenAI')
@@ -675,6 +675,49 @@ describe('EditAccountModal', () => {
)
})
it('writes the upstream request id header into extra only when it changes', async () => {
const account = buildAccount()
account.extra = { openai_compact_mode: 'force_on' }
updateAccountMock.mockReset()
checkMixedChannelRiskMock.mockReset()
checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false })
updateAccountMock.mockResolvedValue(account)
const untouched = mountModal(account)
await untouched.get('form#edit-account-form').trigger('submit.prevent')
expect(updateAccountMock).toHaveBeenCalledTimes(1)
expect(updateAccountMock.mock.calls[0]?.[1]?.extra?.upstream_request_id_header).toBeUndefined()
updateAccountMock.mockClear()
const wrapper = mountModal(account)
await wrapper.get('[data-testid="upstream-request-id-header"]').setValue(' X-Oneapi-Request-Id ')
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
expect(updateAccountMock).toHaveBeenCalledTimes(1)
expect(updateAccountMock.mock.calls[0]?.[1]?.extra).toMatchObject({
openai_compact_mode: 'force_on',
upstream_request_id_header: 'X-Oneapi-Request-Id'
})
})
it('removes the upstream request id header from extra when cleared', async () => {
const account = buildAccount()
account.extra = { upstream_request_id_header: 'X-Request-ID' }
updateAccountMock.mockReset()
checkMixedChannelRiskMock.mockReset()
checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false })
updateAccountMock.mockResolvedValue(account)
const wrapper = mountModal(account)
expect((wrapper.get('[data-testid="upstream-request-id-header"]').element as HTMLInputElement).value).toBe('X-Request-ID')
await wrapper.get('[data-testid="upstream-request-id-header"]').setValue('')
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
expect(updateAccountMock).toHaveBeenCalledTimes(1)
expect(updateAccountMock.mock.calls[0]?.[1]?.extra).toBeDefined()
expect(updateAccountMock.mock.calls[0]?.[1]?.extra).not.toHaveProperty('upstream_request_id_header')
})
it('hides the Codex namespace flatten toggle for non-OAuth OpenAI accounts', async () => {
const account = buildAccount()
const wrapper = mountModal(account)
@@ -0,0 +1,66 @@
import { describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import UpstreamRequestIdHeaderField from '../UpstreamRequestIdHeaderField.vue'
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
return {
...actual,
useI18n: () => ({
t: (key: string, params?: Record<string, unknown>) =>
params?.platform ? `${key}:${String(params.platform)}` : key,
}),
}
})
function mountField(props: { platform?: string; type?: string; modelValue?: string }) {
return mount(UpstreamRequestIdHeaderField, {
props,
global: {
stubs: { Teleport: true },
},
})
}
describe('UpstreamRequestIdHeaderField', () => {
it('lists relay and official header examples for API key accounts', () => {
const wrapper = mountField({ platform: 'openai', type: 'apikey' })
const text = wrapper.text()
expect(text).toContain('admin.accounts.upstreamRequestIdHeaderHelp.intro')
expect(text).toContain('admin.accounts.upstreamRequestIdHeaderHelp.examplesTitle')
expect(text).toContain('X-Client-Request-ID')
expect(text).toContain('admin.accounts.upstreamRequestIdHeaderHelp.sub2apiNote')
expect(text).toContain('X-Oneapi-Request-Id')
expect(text).toContain('admin.accounts.upstreamRequestIdHeaderHelp.official:OpenAI')
expect(text).toContain('x-request-id')
})
it('lists only the official header for accounts that connect to the platform directly', () => {
const wrapper = mountField({ platform: 'anthropic', type: 'oauth' })
const text = wrapper.text()
expect(text).toContain('admin.accounts.upstreamRequestIdHeaderHelp.official:Anthropic')
expect(text).toContain('request-id')
expect(text).not.toContain('X-Client-Request-ID')
expect(text).not.toContain('X-Oneapi-Request-Id')
})
it('omits the examples section when no header is known for the platform', () => {
const wrapper = mountField({ platform: 'kimi', type: 'oauth' })
const text = wrapper.text()
expect(text).toContain('admin.accounts.upstreamRequestIdHeaderHelp.intro')
expect(text).not.toContain('admin.accounts.upstreamRequestIdHeaderHelp.examplesTitle')
expect(wrapper.findAll('code')).toHaveLength(0)
})
it('binds the header name through v-model', async () => {
const wrapper = mountField({ platform: 'openai', type: 'apikey', modelValue: 'X-Request-ID' })
const input = wrapper.get('[data-testid="upstream-request-id-header"]')
expect((input.element as HTMLInputElement).value).toBe('X-Request-ID')
await input.setValue('X-Oneapi-Request-Id')
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['X-Oneapi-Request-Id'])
})
})
@@ -273,6 +273,24 @@
<span v-else class="text-sm text-gray-400 dark:text-gray-500">-</span>
</template>
<template #cell-upstream_request_id="{ row }">
<div v-if="row.upstream_request_id" class="flex max-w-[160px] items-center gap-1.5">
<span class="truncate font-mono text-xs text-gray-500 dark:text-gray-400" :title="row.upstream_request_id">
{{ row.upstream_request_id }}
</span>
<button
type="button"
class="shrink-0 rounded p-0.5 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-dark-700 dark:hover:text-gray-300"
:class="copiedRequestId === row.upstream_request_id ? 'text-green-500 hover:text-green-500' : ''"
:title="copiedRequestId === row.upstream_request_id ? t('keys.copied') : t('keys.copyToClipboard')"
@click="copyUpstreamRequestId(row.upstream_request_id)"
>
<Icon :name="copiedRequestId === row.upstream_request_id ? 'check' : 'copy'" size="sm" class="h-3.5 w-3.5" />
</button>
</div>
<span v-else class="text-sm text-gray-400 dark:text-gray-500">-</span>
</template>
<template #cell-user_agent="{ row }">
<span v-if="row.user_agent" class="text-sm text-gray-600 dark:text-gray-400 block max-w-[320px] truncate" :title="row.user_agent">{{ formatUserAgent(row.user_agent) }}</span>
<span v-else class="text-sm text-gray-400 dark:text-gray-500">-</span>
@@ -653,19 +671,23 @@ const handleBatchFetchIpGeo = async () => {
}
}
const copyRequestId = async (requestId: string) => {
const copyIdentifier = async (value: string, copiedMessage: string) => {
try {
await navigator.clipboard.writeText(requestId)
copiedRequestId.value = requestId
appStore.showSuccess(t('admin.usage.requestIdCopied'))
await navigator.clipboard.writeText(value)
copiedRequestId.value = value
appStore.showSuccess(copiedMessage)
window.setTimeout(() => {
if (copiedRequestId.value === requestId) copiedRequestId.value = null
if (copiedRequestId.value === value) copiedRequestId.value = null
}, 2000)
} catch {
appStore.showError(t('common.copyFailed'))
}
}
const copyRequestId = (requestId: string) => copyIdentifier(requestId, t('admin.usage.requestIdCopied'))
const copyUpstreamRequestId = (upstreamRequestId: string) =>
copyIdentifier(upstreamRequestId, t('admin.usage.upstreamRequestIdCopied'))
// Tooltip state - cost
const tooltipVisible = ref(false)
const tooltipPosition = ref({ x: 0, y: 0 })
@@ -61,6 +61,7 @@ const messages: Record<string, string> = {
'admin.usage.billingModePerRequest': 'Per request',
'admin.usage.billingModeImage': 'Image',
'admin.usage.requestIdCopied': 'Request ID copied',
'admin.usage.upstreamRequestIdCopied': 'Upstream ID copied',
'keys.copied': 'Copied',
'keys.copyToClipboard': 'Copy to clipboard',
'common.copyFailed': 'Copy failed',
@@ -92,6 +93,7 @@ const DataTableStub = {
<slot name="cell-tokens" :row="row" />
<slot name="cell-cost" :row="row" />
<slot name="cell-request_id" :row="row" />
<slot name="cell-upstream_request_id" :row="row" />
</div>
</div>
`,
@@ -587,6 +589,35 @@ describe('admin UsageTable request ID column', () => {
expect(writeText).toHaveBeenCalledWith('req-admin-visible-id')
expect(appStoreMocks.showSuccess).toHaveBeenCalledWith('Request ID copied')
})
it('renders and copies the upstream ID', async () => {
const writeText = vi.fn().mockResolvedValue(undefined)
vi.stubGlobal('navigator', { clipboard: { writeText } })
const wrapper = mount(UsageTable, {
props: {
data: [{ ...baseImageRow, request_id: '', upstream_request_id: '20260903082826779695' }],
loading: false,
columns: [{ key: 'upstream_request_id', label: 'Upstream ID' }],
},
global: {
stubs: {
DataTable: DataTableStub,
EmptyState: true,
Icon: true,
Teleport: true,
},
},
})
expect(wrapper.text()).toContain('20260903082826779695')
const copyButtons = wrapper.findAll('button[title="Copy to clipboard"]')
expect(copyButtons).toHaveLength(1)
await copyButtons[0].trigger('click')
expect(writeText).toHaveBeenCalledWith('20260903082826779695')
expect(appStoreMocks.showSuccess).toHaveBeenCalledWith('Upstream ID copied')
})
})
describe('admin UsageTable IP geolocation batch toolbar', () => {
+16 -2
View File
@@ -29,8 +29,20 @@ function onEnter() {
openTooltip()
}
function onLeave() {
function isInside(container: HTMLElement | null, target: EventTarget | null): boolean {
return target instanceof Node && !!container?.contains(target)
}
// 悬停模式下指针在触发图标与提示框之间往返时保持打开,便于选中提示里的文字。
function onLeave(event: MouseEvent) {
if (props.trigger !== 'hover') return
if (isInside(tooltipRef.value, event.relatedTarget)) return
closeTooltip()
}
function onTooltipLeave(event: MouseEvent) {
if (props.trigger !== 'hover') return
if (isInside(triggerRef.value, event.relatedTarget)) return
closeTooltip()
}
@@ -116,15 +128,17 @@ onBeforeUnmount(() => {
<!-- Teleport to body to escape modal overflow clipping -->
<Teleport to="body">
<!-- before: 伪元素向下延伸一段透明区域,盖住提示框与触发图标之间的空隙,让指针能连续移入提示框。 -->
<div
ref="tooltip"
v-show="show"
role="tooltip"
:class="[
'fixed z-[99999] -translate-x-1/2 -translate-y-full rounded-lg bg-gray-900 p-3 text-xs leading-relaxed text-white shadow-xl ring-1 ring-white/10 dark:bg-gray-800',
'fixed z-[99999] -translate-x-1/2 -translate-y-full rounded-lg bg-gray-900 p-3 text-xs leading-relaxed text-white shadow-xl ring-1 ring-white/10 selection:bg-primary-200 selection:text-gray-900 before:absolute before:inset-x-0 before:top-full before:h-3 dark:bg-gray-800 dark:selection:bg-primary-200 dark:selection:text-gray-900',
props.widthClass,
]"
:style="{ top: `calc(${tooltipStyle.top} - 8px)`, left: tooltipStyle.left }"
@mouseleave="onTooltipLeave"
>
<button
v-if="props.trigger === 'click'"
@@ -40,6 +40,36 @@ describe('HelpTooltip', () => {
wrapper.unmount()
})
it('keeps a hover tooltip open while the pointer moves between the trigger and the tooltip', async () => {
const wrapper = mount(HelpTooltip, {
attachTo: document.body,
props: {
content: 'copyable details',
},
})
const trigger = wrapper.get('.group')
const tooltip = getTooltipElement()
await trigger.trigger('mouseenter')
await nextTick()
expect(tooltip.style.display).not.toBe('none')
await trigger.trigger('mouseleave', { relatedTarget: tooltip })
await nextTick()
expect(tooltip.style.display).not.toBe('none')
tooltip.dispatchEvent(new MouseEvent('mouseleave', { relatedTarget: trigger.element }))
await nextTick()
expect(tooltip.style.display).not.toBe('none')
tooltip.dispatchEvent(new MouseEvent('mouseleave', { relatedTarget: null }))
await nextTick()
expect(tooltip.style.display).toBe('none')
wrapper.unmount()
})
it('supports click-to-toggle details and closes on outside click', async () => {
const wrapper = mount(HelpTooltip, {
attachTo: document.body,
@@ -559,6 +559,14 @@ export default {
apiKeyRequired: 'API Key *',
apiKeyPlaceholder: 'sk-ant-api03-...',
apiKeyHint: 'Your Claude Console API Key',
upstreamRequestIdHeader: 'Upstream ID',
upstreamRequestIdHeaderPlaceholder: 'Leave empty to record nothing',
upstreamRequestIdHeaderHelp: {
intro: 'Name of the response header in which the direct upstream declares its request ID. The value is recorded in the "Upstream ID" column of the usage log; leave empty to record nothing.',
examplesTitle: 'Common values',
sub2apiNote: 'Matches the request ID column of its usage log',
official: '{platform} official API'
},
// OpenAI specific hints
openai: {
baseUrlHint: 'Leave default for official OpenAI API',
@@ -521,7 +521,9 @@ export default {
account: 'Account',
group: 'Group',
requestId: 'Request ID',
upstreamRequestId: 'Upstream ID',
requestIdCopied: 'Request ID copied',
upstreamRequestIdCopied: 'Upstream ID copied',
allModels: 'All Models',
allAccounts: 'All Accounts',
allGroups: 'All Groups',
@@ -644,6 +644,14 @@ export default {
apiKeyRequired: 'API Key *',
apiKeyPlaceholder: 'sk-ant-api03-...',
apiKeyHint: '您的 Claude Console API Key',
upstreamRequestIdHeader: '上游ID',
upstreamRequestIdHeaderPlaceholder: '留空不记录',
upstreamRequestIdHeaderHelp: {
intro: '填写直接上游在响应头中声明请求标识的头名,记录到用量明细的“上游ID”列;留空则不记录。',
examplesTitle: '常见取值',
sub2apiNote: '对应对方用量明细的请求ID列',
official: '{platform} 官方 API'
},
// OpenAI specific hints
openai: {
baseUrlHint: '留空使用官方 OpenAI API',
@@ -518,7 +518,9 @@ export default {
account: '账户',
group: '分组',
requestId: '请求ID',
upstreamRequestId: '上游ID',
requestIdCopied: '请求ID已复制',
upstreamRequestIdCopied: '上游ID已复制',
allModels: '全部模型',
allAccounts: '全部账户',
allGroups: '全部分组',
+1
View File
@@ -1738,6 +1738,7 @@ export interface AdminUsageLog extends UsageLog {
upstream_response_model?: string | null
upstream_model_mismatch?: boolean | null
model_mapping_chain?: string | null
upstream_request_id?: string | null
// 账号计费倍率(仅管理员可见)
account_rate_multiplier?: number | null
+13 -6
View File
@@ -589,7 +589,7 @@ const exportToExcel = async () => {
t('admin.usage.cacheReadCost'), t('admin.usage.cacheCreationCost'),
t('usage.rate'), t('usage.accountMultiplier'), t('usage.original'), t('usage.userBilled'), t('usage.accountBilled'),
t('usage.firstToken'), t('usage.duration'),
t('admin.usage.requestId'), t('usage.userAgent'), t('admin.usage.ipAddress')
t('admin.usage.requestId'), t('admin.usage.upstreamRequestId'), t('usage.userAgent'), t('admin.usage.ipAddress')
]
const ws = XLSX.utils.aoa_to_sheet([headers])
while (true) {
@@ -608,7 +608,7 @@ const exportToExcel = async () => {
log.rate_multiplier?.toPrecision(4) || '1.00', (log.account_rate_multiplier ?? 1).toPrecision(4),
log.total_cost?.toFixed(6) || '0.000000', log.actual_cost?.toFixed(6) || '0.000000',
((log.account_stats_cost ?? log.total_cost) * (log.account_rate_multiplier ?? 1)).toFixed(6), log.first_token_ms ?? '', log.duration_ms,
log.request_id || '', log.user_agent || '', log.ip_address || ''
log.request_id || '', log.upstream_request_id || '', log.user_agent || '', log.ip_address || ''
])
if (rows.length) {
XLSX.utils.sheet_add_aoa(ws, rows, { origin: -1 })
@@ -630,10 +630,12 @@ const exportToExcel = async () => {
// Column visibility
const ALWAYS_VISIBLE = ['user', 'created_at']
const DEFAULT_HIDDEN_COLUMNS = ['reasoning_effort', 'request_id', 'user_agent']
const DEFAULT_HIDDEN_COLUMNS = ['reasoning_effort', 'request_id', 'upstream_request_id', 'user_agent']
const HIDDEN_COLUMNS_KEY = 'usage-hidden-columns'
const HIDDEN_COLUMNS_VERSION_KEY = 'usage-hidden-columns-version'
const HIDDEN_COLUMNS_CURRENT_VERSION = 'request-id-hidden-by-default'
// 隐藏列版本链:每级只把当级新增列加入隐藏集,不重置用户已显式打开的列。
const HIDDEN_COLUMNS_PREV_VERSION = 'request-id-hidden-by-default'
const HIDDEN_COLUMNS_CURRENT_VERSION = 'upstream-request-id-hidden-by-default'
const allColumns = computed(() => [
{ key: 'user', label: t('admin.usage.user'), sortable: false },
@@ -650,6 +652,7 @@ const allColumns = computed(() => [
{ key: 'latency', label: t('usage.latency'), sortable: false },
{ key: 'created_at', label: t('usage.time'), sortable: true },
{ key: 'request_id', label: t('admin.usage.requestId'), sortable: false },
{ key: 'upstream_request_id', label: t('admin.usage.upstreamRequestId'), sortable: false },
{ key: 'user_agent', label: t('usage.userAgent'), sortable: false },
{ key: 'ip_address', label: t('admin.usage.ipAddress'), sortable: false }
])
@@ -757,8 +760,12 @@ const loadSavedColumns = () => {
(JSON.parse(saved) as string[]).forEach((key) => {
hiddenColumns.add(key)
})
if (localStorage.getItem(HIDDEN_COLUMNS_VERSION_KEY) !== HIDDEN_COLUMNS_CURRENT_VERSION) {
hiddenColumns.add('request_id')
const savedVersion = localStorage.getItem(HIDDEN_COLUMNS_VERSION_KEY)
if (savedVersion !== HIDDEN_COLUMNS_CURRENT_VERSION) {
if (savedVersion !== HIDDEN_COLUMNS_PREV_VERSION) {
hiddenColumns.add('request_id')
}
hiddenColumns.add('upstream_request_id')
localStorage.setItem(HIDDEN_COLUMNS_KEY, JSON.stringify([...hiddenColumns]))
localStorage.setItem(HIDDEN_COLUMNS_VERSION_KEY, HIDDEN_COLUMNS_CURRENT_VERSION)
}
@@ -33,6 +33,7 @@ const messages: Record<string, string> = {
'admin.dashboard.hour': 'Hour',
'admin.usage.failedToLoadUser': 'Failed to load user',
'admin.usage.requestId': 'Request ID',
'admin.usage.upstreamRequestId': 'Upstream ID',
'usage.requestedModel': 'Requested model',
'usage.sentUpstreamModel': 'Sent upstream model',
'usage.upstreamResponseModel': 'Upstream response model',
@@ -519,7 +520,48 @@ describe('admin UsageView request ID column visibility', () => {
)
expect(localStorage.setItem).toHaveBeenCalledWith(
'usage-hidden-columns-version',
'request-id-hidden-by-default',
'upstream-request-id-hidden-by-default',
)
})
it('keeps upstream ID hidden by default and allows enabling it from column settings', async () => {
const wrapper = mount(UsageView, {
global: {
stubs: {
AppLayout: AppLayoutStub,
UsageStatsCards: true,
UsageFilters: UsageFiltersStub,
UsageTable: UsageTableStub,
UsageExportProgress: true,
UsageCleanupDialog: true,
UserBalanceHistoryModal: true,
AuditLogModal: true,
Pagination: true,
Select: true,
DateRangePicker: true,
Icon: true,
TokenUsageTrend: true,
ModelDistributionChart: true,
GroupDistributionChart: true,
EndpointDistributionChart: true,
UserTokenRanking: true,
},
},
})
await wrapper.vm.$nextTick()
const usageTable = wrapper.findComponent(UsageTableStub)
expect(usageTable.props('columns')).not.toEqual(
expect.arrayContaining([expect.objectContaining({ key: 'upstream_request_id' })]),
)
await wrapper.get('button[title="admin.users.columnSettings"]').trigger('click')
const upstreamToggle = wrapper.findAll('button').find((button) => button.text() === 'Upstream ID')
expect(upstreamToggle).toBeDefined()
await upstreamToggle!.trigger('click')
expect(usageTable.props('columns')).toEqual(
expect.arrayContaining([expect.objectContaining({ key: 'upstream_request_id', label: 'Upstream ID' })]),
)
})
})