fix(billing): 按账号控制 OpenAI 长上下文计费

This commit is contained in:
benjamin
2026-07-13 09:47:19 +08:00
parent e316ebf528
commit 92dcfb5ebc
30 changed files with 793 additions and 297 deletions
@@ -2796,6 +2796,38 @@
</div>
<!-- OpenAI OAuth Codex 官方客户端限制开关 -->
<div
v-if="form.platform === 'openai' && (accountCategory === 'oauth-based' || accountCategory === 'apikey')"
class="border-t border-gray-200 pt-4 dark:border-dark-600"
>
<div class="flex items-center justify-between gap-4">
<div>
<label class="input-label mb-0">{{ t('admin.accounts.openai.longContextBilling') }}</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.openai.longContextBillingDesc') }}
</p>
</div>
<button
type="button"
data-testid="openai-long-context-billing-toggle"
role="switch"
:aria-checked="openAILongContextBillingEnabled"
@click="openAILongContextBillingEnabled = !openAILongContextBillingEnabled"
:class="[
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
openAILongContextBillingEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]"
>
<span
:class="[
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
openAILongContextBillingEnabled ? 'translate-x-5' : 'translate-x-0'
]"
/>
</button>
</div>
</div>
<div
v-if="form.platform === 'openai' && accountCategory === 'oauth-based'"
class="border-t border-gray-200 pt-4 dark:border-dark-600"
@@ -3648,6 +3680,7 @@ const fillHeaderOverrideTemplate = () => {
const interceptWarmupRequests = ref(false)
const autoPauseOnExpired = ref(true)
const openaiPassthroughEnabled = ref(false)
const openAILongContextBillingEnabled = ref(false)
const openAICompactMode = ref<OpenAICompactMode>('auto')
const openAIResponsesMode = ref<OpenAIResponsesMode>('auto')
const openAIEndpointCapabilities = ref<OpenAIEndpointCapability[]>(['chat_completions', 'embeddings'])
@@ -4502,6 +4535,7 @@ const resetForm = () => {
interceptWarmupRequests.value = false
autoPauseOnExpired.value = true
openaiPassthroughEnabled.value = false
openAILongContextBillingEnabled.value = false
openAICompactMode.value = 'auto'
openAIResponsesMode.value = 'auto'
openAIEndpointCapabilities.value = ['chat_completions', 'embeddings']
@@ -4584,6 +4618,11 @@ const buildOpenAIExtra = (base?: Record<string, unknown>): Record<string, unknow
delete extra.openai_passthrough
delete extra.openai_oauth_passthrough
}
if (openAILongContextBillingEnabled.value) {
extra.openai_long_context_billing_enabled = true
} else {
delete extra.openai_long_context_billing_enabled
}
if (accountCategory.value === 'oauth-based' && codexCLIOnlyEnabled.value) {
extra.codex_cli_only = true
@@ -1783,6 +1783,38 @@
</div>
<!-- OpenAI OAuth Codex 官方客户端限制开关 -->
<div
v-if="account?.platform === 'openai' && (account?.type === 'oauth' || account?.type === 'setup-token' || account?.type === 'apikey')"
class="border-t border-gray-200 pt-4 dark:border-dark-600"
>
<div class="flex items-center justify-between gap-4">
<div>
<label class="input-label mb-0">{{ t('admin.accounts.openai.longContextBilling') }}</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.openai.longContextBillingDesc') }}
</p>
</div>
<button
type="button"
data-testid="openai-long-context-billing-toggle"
role="switch"
:aria-checked="openAILongContextBillingEnabled"
@click="openAILongContextBillingEnabled = !openAILongContextBillingEnabled"
:class="[
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
openAILongContextBillingEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]"
>
<span
:class="[
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
openAILongContextBillingEnabled ? 'translate-x-5' : 'translate-x-0'
]"
/>
</button>
</div>
</div>
<div
v-if="account?.platform === 'openai' && (account?.type === 'oauth' || account?.type === 'setup-token')"
class="border-t border-gray-200 pt-4 dark:border-dark-600"
@@ -2757,6 +2789,7 @@ const customBaseUrl = ref('')
// OpenAI 自动透传开关(OAuth/API Key)
const openaiPassthroughEnabled = ref(false)
const openAILongContextBillingEnabled = ref(false)
const openAICompactMode = ref<OpenAICompactMode>('auto')
const openAIResponsesMode = ref<OpenAIResponsesMode>('auto')
const openAIEndpointCapabilities = ref<OpenAIEndpointCapability[]>(['chat_completions', 'embeddings'])
@@ -3183,6 +3216,7 @@ const syncFormFromAccount = (newAccount: Account | null) => {
// Load OpenAI passthrough toggle (OpenAI OAuth/SetupToken/API Key)
openaiPassthroughEnabled.value = false
openAILongContextBillingEnabled.value = false
openAICompactMode.value = 'auto'
openAIResponsesMode.value = 'auto'
openAIEndpointCapabilities.value = ['chat_completions', 'embeddings']
@@ -3197,6 +3231,7 @@ const syncFormFromAccount = (newAccount: Account | null) => {
webSearchEmulationMode.value = 'default'
if (newAccount.platform === 'openai' && (newAccount.type === 'oauth' || newAccount.type === 'setup-token' || newAccount.type === 'apikey')) {
openaiPassthroughEnabled.value = extra?.openai_passthrough === true || extra?.openai_oauth_passthrough === true
openAILongContextBillingEnabled.value = extra?.openai_long_context_billing_enabled === true
openAICompactMode.value = (extra?.openai_compact_mode as OpenAICompactMode) || 'auto'
if (newAccount.type === 'apikey') {
openAIResponsesMode.value = normalizeOpenAIResponsesMode(extra?.openai_responses_mode)
@@ -4351,6 +4386,7 @@ const handleSubmit = async () => {
delete newExtra.openai_passthrough
delete newExtra.openai_oauth_passthrough
}
newExtra.openai_long_context_billing_enabled = openAILongContextBillingEnabled.value
if (openAICompactMode.value === 'auto') {
delete newExtra.openai_compact_mode
} else {
@@ -383,6 +383,27 @@ describe('EditAccountModal', () => {
})
})
it('loads and submits the per-account OpenAI long-context billing toggle', async () => {
const account = buildAccount()
account.extra = {
openai_long_context_billing_enabled: true
}
updateAccountMock.mockReset()
checkMixedChannelRiskMock.mockReset()
checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false })
updateAccountMock.mockResolvedValue(account)
const wrapper = mountModal(account)
const toggle = wrapper.get('[data-testid="openai-long-context-billing-toggle"]')
expect(toggle.attributes('aria-checked')).toBe('true')
await toggle.trigger('click')
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
expect(updateAccountMock).toHaveBeenCalledTimes(1)
expect(updateAccountMock.mock.calls[0]?.[1]?.extra?.openai_long_context_billing_enabled).toBe(false)
})
it('loads and submits Grok OAuth model mapping edits', async () => {
const account = buildGrokOAuthAccount()
updateAccountMock.mockReset()
@@ -168,6 +168,11 @@
<div class="text-sm">
<div class="flex items-center gap-1.5">
<span class="font-medium text-green-600 dark:text-green-400">${{ row.actual_cost?.toFixed(6) || '0.000000' }}</span>
<span
v-if="row.long_context_billing_applied"
data-testid="long-context-billing-marker"
class="inline-flex items-center rounded px-1 py-px text-[10px] font-semibold leading-tight bg-amber-100 text-amber-700 ring-1 ring-inset ring-amber-200 dark:bg-amber-500/20 dark:text-amber-300 dark:ring-amber-500/30"
>x2</span>
<!-- Cost Detail Tooltip -->
<div
class="group relative"
@@ -120,6 +120,38 @@ describe('admin UsageTable tooltip', () => {
} as DOMRect)
})
it('marks only usage rows that actually applied long-context billing', () => {
const wrapper = mount(UsageTable, {
props: {
data: [
{
...baseImageRow,
request_id: 'req-long-context-enabled',
long_context_billing_applied: true,
},
{
...baseImageRow,
request_id: 'req-long-context-disabled',
long_context_billing_applied: false,
},
],
loading: false,
columns: [],
},
global: {
stubs: {
DataTable: DataTableStub,
EmptyState: true,
Icon: true,
Teleport: true,
},
},
})
expect(wrapper.findAll('[data-testid="long-context-billing-marker"]')).toHaveLength(1)
expect(wrapper.get('[data-testid="long-context-billing-marker"]').text()).toBe('x2')
})
it('shows service tier and billing breakdown in cost tooltip', async () => {
const row = {
request_id: 'req-admin-1',
@@ -402,6 +402,9 @@ export default {
oauthPassthrough: 'Auto passthrough (auth only)',
oauthPassthroughDesc:
'When enabled, this OpenAI account uses automatic passthrough: the gateway forwards request/response as-is and only swaps auth, while keeping billing/concurrency/audit and necessary safety filtering.',
longContextBilling: 'API long-context pricing',
longContextBillingDesc:
'Disabled by default. Enable only when this account\'s upstream charges OpenAI API long-context rates above the model threshold.',
responsesWebsocketsV2: 'Responses WebSocket v2',
responsesWebsocketsV2Desc:
'Disabled by default. Enable to allow responses_websockets_v2 capability (still gated by global and account-type switches).',
@@ -505,6 +505,8 @@ export default {
oauthPassthrough: '自动透传(仅替换认证)',
oauthPassthroughDesc:
'开启后,该 OpenAI 账号将自动透传请求与响应,仅替换认证并保留计费/并发/审计及必要安全过滤;如遇兼容性问题可随时关闭回滚。',
longContextBilling: 'API 长上下文计费',
longContextBillingDesc: '默认关闭。仅当该账号的上游会按模型阈值收取 OpenAI API 长上下文费率时启用。',
responsesWebsocketsV2: 'Responses WebSocket v2',
responsesWebsocketsV2Desc:
'默认关闭。开启后可启用 responses_websockets_v2 协议能力(受网关全局开关与账号类型开关约束)。',
+1
View File
@@ -1344,6 +1344,7 @@ export interface UsageLog {
total_cost: number
actual_cost: number
rate_multiplier: number
long_context_billing_applied: boolean
billing_type: number
request_type?: UsageRequestType