mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 12:02:27 +08:00
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:
@@ -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', () => {
|
||||
|
||||
@@ -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: '全部分组',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' })]),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user