Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
陈曦
sub2api
Commits
391e79f8
Unverified
Commit
391e79f8
authored
Mar 09, 2026
by
Wesley Liddick
Committed by
GitHub
Mar 09, 2026
Browse files
Merge pull request #875 from mt21625457/fix/openai-fast-billing-clean
fix(billing): 修复 OpenAI fast 档位计费并补齐展示
parents
c7fcb7a8
87f4ed59
Changes
28
Hide whitespace changes
Inline
Side-by-side
frontend/src/i18n/locales/en.ts
View file @
391e79f8
...
...
@@ -726,8 +726,15 @@ export default {
unknown
:
'
Unknown
'
,
in
:
'
In
'
,
out
:
'
Out
'
,
inputTokenPrice
:
'
Input price
'
,
outputTokenPrice
:
'
Output price
'
,
perMillionTokens
:
'
/ 1M tokens
'
,
cacheRead
:
'
Read
'
,
cacheWrite
:
'
Write
'
,
serviceTier
:
'
Service tier
'
,
serviceTierPriority
:
'
Fast
'
,
serviceTierFlex
:
'
Flex
'
,
serviceTierStandard
:
'
Standard
'
,
rate
:
'
Rate
'
,
original
:
'
Original
'
,
billed
:
'
Billed
'
,
...
...
frontend/src/i18n/locales/zh.ts
View file @
391e79f8
...
...
@@ -731,8 +731,15 @@ export default {
unknown
:
'
未知
'
,
in
:
'
输入
'
,
out
:
'
输出
'
,
inputTokenPrice
:
'
输入单价
'
,
outputTokenPrice
:
'
输出单价
'
,
perMillionTokens
:
'
/ 1M Token
'
,
cacheRead
:
'
读取
'
,
cacheWrite
:
'
写入
'
,
serviceTier
:
'
服务档位
'
,
serviceTierPriority
:
'
Fast
'
,
serviceTierFlex
:
'
Flex
'
,
serviceTierStandard
:
'
Standard
'
,
rate
:
'
倍率
'
,
original
:
'
原始
'
,
billed
:
'
计费
'
,
...
...
frontend/src/types/index.ts
View file @
391e79f8
...
...
@@ -934,6 +934,7 @@ export interface UsageLog {
account_id
:
number
|
null
request_id
:
string
model
:
string
service_tier
?:
string
|
null
reasoning_effort
?:
string
|
null
group_id
:
number
|
null
...
...
frontend/src/utils/__tests__/usageServiceTier.spec.ts
0 → 100644
View file @
391e79f8
import
{
describe
,
expect
,
it
}
from
'
vitest
'
import
{
formatUsageServiceTier
,
getUsageServiceTierLabel
,
normalizeUsageServiceTier
}
from
'
@/utils/usageServiceTier
'
describe
(
'
usageServiceTier utils
'
,
()
=>
{
it
(
'
normalizes fast/default aliases
'
,
()
=>
{
expect
(
normalizeUsageServiceTier
(
'
fast
'
)).
toBe
(
'
priority
'
)
expect
(
normalizeUsageServiceTier
(
'
default
'
)).
toBe
(
'
standard
'
)
expect
(
normalizeUsageServiceTier
(
'
STANDARD
'
)).
toBe
(
'
standard
'
)
})
it
(
'
preserves supported tiers
'
,
()
=>
{
expect
(
normalizeUsageServiceTier
(
'
priority
'
)).
toBe
(
'
priority
'
)
expect
(
normalizeUsageServiceTier
(
'
flex
'
)).
toBe
(
'
flex
'
)
})
it
(
'
formats empty values as standard
'
,
()
=>
{
expect
(
formatUsageServiceTier
()).
toBe
(
'
standard
'
)
expect
(
formatUsageServiceTier
(
''
)).
toBe
(
'
standard
'
)
})
it
(
'
passes through unknown non-empty tiers for display fallback
'
,
()
=>
{
expect
(
normalizeUsageServiceTier
(
'
custom-tier
'
)).
toBe
(
'
custom-tier
'
)
expect
(
formatUsageServiceTier
(
'
custom-tier
'
)).
toBe
(
'
custom-tier
'
)
})
it
(
'
maps tiers to translated labels
'
,
()
=>
{
const
translate
=
(
key
:
string
)
=>
({
'
usage.serviceTierPriority
'
:
'
Fast
'
,
'
usage.serviceTierFlex
'
:
'
Flex
'
,
'
usage.serviceTierStandard
'
:
'
Standard
'
,
})[
key
]
??
key
expect
(
getUsageServiceTierLabel
(
'
fast
'
,
translate
)).
toBe
(
'
Fast
'
)
expect
(
getUsageServiceTierLabel
(
'
flex
'
,
translate
)).
toBe
(
'
Flex
'
)
expect
(
getUsageServiceTierLabel
(
undefined
,
translate
)).
toBe
(
'
Standard
'
)
expect
(
getUsageServiceTierLabel
(
'
custom-tier
'
,
translate
)).
toBe
(
'
custom-tier
'
)
})
})
frontend/src/utils/usagePricing.ts
0 → 100644
View file @
391e79f8
export
const
TOKENS_PER_MILLION
=
1
_000_000
interface
TokenPriceFormatOptions
{
fractionDigits
?:
number
withCurrencySymbol
?:
boolean
emptyValue
?:
string
}
function
isFiniteNumber
(
value
:
unknown
):
value
is
number
{
return
typeof
value
===
'
number
'
&&
Number
.
isFinite
(
value
)
}
export
function
calculateTokenUnitPrice
(
cost
:
number
|
null
|
undefined
,
tokens
:
number
|
null
|
undefined
):
number
|
null
{
if
(
!
isFiniteNumber
(
cost
)
||
!
isFiniteNumber
(
tokens
)
||
tokens
<=
0
)
{
return
null
}
return
cost
/
tokens
}
export
function
calculateTokenPricePerMillion
(
cost
:
number
|
null
|
undefined
,
tokens
:
number
|
null
|
undefined
):
number
|
null
{
const
unitPrice
=
calculateTokenUnitPrice
(
cost
,
tokens
)
if
(
unitPrice
==
null
)
{
return
null
}
return
unitPrice
*
TOKENS_PER_MILLION
}
export
function
formatTokenPricePerMillion
(
cost
:
number
|
null
|
undefined
,
tokens
:
number
|
null
|
undefined
,
options
:
TokenPriceFormatOptions
=
{}
):
string
{
const
pricePerMillion
=
calculateTokenPricePerMillion
(
cost
,
tokens
)
if
(
pricePerMillion
==
null
)
{
return
options
.
emptyValue
??
'
-
'
}
const
fractionDigits
=
options
.
fractionDigits
??
4
const
formatted
=
pricePerMillion
.
toFixed
(
fractionDigits
)
return
options
.
withCurrencySymbol
==
false
?
formatted
:
`$
${
formatted
}
`
}
frontend/src/utils/usageServiceTier.ts
0 → 100644
View file @
391e79f8
export
function
normalizeUsageServiceTier
(
serviceTier
?:
string
|
null
):
string
|
null
{
const
value
=
serviceTier
?.
trim
().
toLowerCase
()
if
(
!
value
)
return
null
if
(
value
===
'
fast
'
)
return
'
priority
'
if
(
value
===
'
default
'
||
value
===
'
standard
'
)
return
'
standard
'
if
(
value
===
'
priority
'
||
value
===
'
flex
'
)
return
value
return
value
}
export
function
formatUsageServiceTier
(
serviceTier
?:
string
|
null
):
string
{
const
normalized
=
normalizeUsageServiceTier
(
serviceTier
)
if
(
!
normalized
)
return
'
standard
'
return
normalized
}
export
function
getUsageServiceTierLabel
(
serviceTier
:
string
|
null
|
undefined
,
translate
:
(
key
:
string
)
=>
string
,
):
string
{
const
tier
=
formatUsageServiceTier
(
serviceTier
)
if
(
tier
===
'
priority
'
)
return
translate
(
'
usage.serviceTierPriority
'
)
if
(
tier
===
'
flex
'
)
return
translate
(
'
usage.serviceTierFlex
'
)
if
(
tier
===
'
standard
'
)
return
translate
(
'
usage.serviceTierStandard
'
)
return
tier
}
frontend/src/views/user/UsageView.vue
View file @
391e79f8
...
...
@@ -426,6 +426,14 @@
<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>
</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>
<span
class=
"font-medium text-white"
>
${{ tooltipData.cache_creation_cost.toFixed(6) }}
</span>
...
...
@@ -436,6 +444,10 @@
</div>
</div>
<!-- Rate and Summary -->
<div
class=
"flex items-center justify-between gap-6"
>
<span
class=
"text-gray-400"
>
{{ t('usage.serviceTier') }}
</span>
<span
class=
"font-semibold text-cyan-300"
>
{{ getUsageServiceTierLabel(tooltipData?.service_tier, t) }}
</span>
</div>
<div
class=
"flex items-center justify-between gap-6"
>
<span
class=
"text-gray-400"
>
{{ t('usage.rate') }}
</span>
<span
class=
"font-semibold text-blue-400"
...
...
@@ -478,6 +490,8 @@ import Icon from '@/components/icons/Icon.vue'
import
type
{
UsageLog
,
ApiKey
,
UsageQueryParams
,
UsageStatsResponse
}
from
'
@/types
'
import
type
{
Column
}
from
'
@/components/common/types
'
import
{
formatDateTime
,
formatReasoningEffort
}
from
'
@/utils/format
'
import
{
formatTokenPricePerMillion
}
from
'
@/utils/usagePricing
'
import
{
getUsageServiceTierLabel
}
from
'
@/utils/usageServiceTier
'
import
{
resolveUsageRequestType
}
from
'
@/utils/usageRequestType
'
const
{
t
}
=
useI18n
()
...
...
frontend/src/views/user/__tests__/UsageView.spec.ts
0 → 100644
View file @
391e79f8
import
{
describe
,
expect
,
it
,
vi
,
beforeEach
}
from
'
vitest
'
import
{
flushPromises
,
mount
}
from
'
@vue/test-utils
'
import
{
nextTick
}
from
'
vue
'
import
UsageView
from
'
../UsageView.vue
'
const
{
query
,
getStatsByDateRange
,
list
,
showError
,
showWarning
,
showSuccess
,
showInfo
}
=
vi
.
hoisted
(()
=>
({
query
:
vi
.
fn
(),
getStatsByDateRange
:
vi
.
fn
(),
list
:
vi
.
fn
(),
showError
:
vi
.
fn
(),
showWarning
:
vi
.
fn
(),
showSuccess
:
vi
.
fn
(),
showInfo
:
vi
.
fn
(),
}))
const
messages
:
Record
<
string
,
string
>
=
{
'
usage.costDetails
'
:
'
Cost Breakdown
'
,
'
admin.usage.inputCost
'
:
'
Input Cost
'
,
'
admin.usage.outputCost
'
:
'
Output Cost
'
,
'
admin.usage.cacheCreationCost
'
:
'
Cache Creation Cost
'
,
'
admin.usage.cacheReadCost
'
:
'
Cache Read Cost
'
,
'
usage.inputTokenPrice
'
:
'
Input price
'
,
'
usage.outputTokenPrice
'
:
'
Output price
'
,
'
usage.perMillionTokens
'
:
'
/ 1M tokens
'
,
'
usage.serviceTier
'
:
'
Service tier
'
,
'
usage.serviceTierPriority
'
:
'
Fast
'
,
'
usage.serviceTierFlex
'
:
'
Flex
'
,
'
usage.serviceTierStandard
'
:
'
Standard
'
,
'
usage.rate
'
:
'
Rate
'
,
'
usage.original
'
:
'
Original
'
,
'
usage.billed
'
:
'
Billed
'
,
'
usage.allApiKeys
'
:
'
All API Keys
'
,
'
usage.apiKeyFilter
'
:
'
API Key
'
,
'
usage.model
'
:
'
Model
'
,
'
usage.reasoningEffort
'
:
'
Reasoning Effort
'
,
'
usage.type
'
:
'
Type
'
,
'
usage.tokens
'
:
'
Tokens
'
,
'
usage.cost
'
:
'
Cost
'
,
'
usage.firstToken
'
:
'
First Token
'
,
'
usage.duration
'
:
'
Duration
'
,
'
usage.time
'
:
'
Time
'
,
'
usage.userAgent
'
:
'
User Agent
'
,
}
vi
.
mock
(
'
@/api
'
,
()
=>
({
usageAPI
:
{
query
,
getStatsByDateRange
,
},
keysAPI
:
{
list
,
},
}))
vi
.
mock
(
'
@/stores/app
'
,
()
=>
({
useAppStore
:
()
=>
({
showError
,
showWarning
,
showSuccess
,
showInfo
}),
}))
vi
.
mock
(
'
vue-i18n
'
,
async
()
=>
{
const
actual
=
await
vi
.
importActual
<
typeof
import
(
'
vue-i18n
'
)
>
(
'
vue-i18n
'
)
return
{
...
actual
,
useI18n
:
()
=>
({
t
:
(
key
:
string
)
=>
messages
[
key
]
??
key
,
}),
}
})
const
AppLayoutStub
=
{
template
:
'
<div><slot /></div>
'
}
const
TablePageLayoutStub
=
{
template
:
'
<div><slot name="actions" /><slot name="filters" /><slot /></div>
'
,
}
describe
(
'
user UsageView tooltip
'
,
()
=>
{
beforeEach
(()
=>
{
query
.
mockReset
()
getStatsByDateRange
.
mockReset
()
list
.
mockReset
()
showError
.
mockReset
()
showWarning
.
mockReset
()
showSuccess
.
mockReset
()
showInfo
.
mockReset
()
vi
.
spyOn
(
HTMLElement
.
prototype
,
'
getBoundingClientRect
'
).
mockReturnValue
({
x
:
0
,
y
:
0
,
top
:
20
,
left
:
20
,
right
:
120
,
bottom
:
40
,
width
:
100
,
height
:
20
,
toJSON
:
()
=>
({}),
}
as
DOMRect
)
;(
globalThis
as
any
).
ResizeObserver
=
class
{
observe
()
{}
disconnect
()
{}
}
})
it
(
'
shows fast service tier and unit prices in user tooltip
'
,
async
()
=>
{
query
.
mockResolvedValue
({
items
:
[
{
request_id
:
'
req-user-1
'
,
actual_cost
:
0.092883
,
total_cost
:
0.092883
,
rate_multiplier
:
1
,
service_tier
:
'
priority
'
,
input_cost
:
0.020285
,
output_cost
:
0.00303
,
cache_creation_cost
:
0
,
cache_read_cost
:
0.069568
,
input_tokens
:
4057
,
output_tokens
:
101
,
cache_creation_tokens
:
0
,
cache_read_tokens
:
278272
,
cache_creation_5m_tokens
:
0
,
cache_creation_1h_tokens
:
0
,
image_count
:
0
,
image_size
:
null
,
first_token_ms
:
null
,
duration_ms
:
1
,
created_at
:
'
2026-03-08T00:00:00Z
'
,
},
],
total
:
1
,
pages
:
1
,
})
getStatsByDateRange
.
mockResolvedValue
({
total_requests
:
1
,
total_tokens
:
100
,
total_cost
:
0.1
,
avg_duration_ms
:
1
,
})
list
.
mockResolvedValue
({
items
:
[]
})
const
wrapper
=
mount
(
UsageView
,
{
global
:
{
stubs
:
{
AppLayout
:
AppLayoutStub
,
TablePageLayout
:
TablePageLayoutStub
,
Pagination
:
true
,
EmptyState
:
true
,
Select
:
true
,
DateRangePicker
:
true
,
Icon
:
true
,
Teleport
:
true
,
},
},
})
await
flushPromises
()
await
nextTick
()
const
setupState
=
(
wrapper
.
vm
as
any
).
$
?.
setupState
setupState
.
tooltipData
=
{
request_id
:
'
req-user-1
'
,
actual_cost
:
0.092883
,
total_cost
:
0.092883
,
rate_multiplier
:
1
,
service_tier
:
'
priority
'
,
input_cost
:
0.020285
,
output_cost
:
0.00303
,
cache_creation_cost
:
0
,
cache_read_cost
:
0.069568
,
input_tokens
:
4057
,
output_tokens
:
101
,
}
setupState
.
tooltipVisible
=
true
await
nextTick
()
const
text
=
wrapper
.
text
()
expect
(
text
).
toContain
(
'
Service tier
'
)
expect
(
text
).
toContain
(
'
Fast
'
)
expect
(
text
).
toContain
(
'
Rate
'
)
expect
(
text
).
toContain
(
'
1.00x
'
)
expect
(
text
).
toContain
(
'
Billed
'
)
expect
(
text
).
toContain
(
'
$0.092883
'
)
expect
(
text
).
toContain
(
'
$5.0000 / 1M tokens
'
)
expect
(
text
).
toContain
(
'
$30.0000 / 1M tokens
'
)
})
it
(
'
exports csv with input and output unit price columns
'
,
async
()
=>
{
const
exportedLogs
=
[
{
request_id
:
'
req-user-export
'
,
actual_cost
:
0.092883
,
total_cost
:
0.092883
,
rate_multiplier
:
1
,
service_tier
:
'
priority
'
,
input_cost
:
0.020285
,
output_cost
:
0.00303
,
cache_creation_cost
:
0.000001
,
cache_read_cost
:
0.069568
,
input_tokens
:
4057
,
output_tokens
:
101
,
cache_creation_tokens
:
4
,
cache_read_tokens
:
278272
,
cache_creation_5m_tokens
:
0
,
cache_creation_1h_tokens
:
0
,
image_count
:
0
,
image_size
:
null
,
first_token_ms
:
12
,
duration_ms
:
345
,
created_at
:
'
2026-03-08T00:00:00Z
'
,
model
:
'
gpt-5.4
'
,
reasoning_effort
:
null
,
api_key
:
{
name
:
'
demo-key
'
},
},
]
query
.
mockResolvedValue
({
items
:
exportedLogs
,
total
:
1
,
pages
:
1
,
})
getStatsByDateRange
.
mockResolvedValue
({
total_requests
:
1
,
total_tokens
:
100
,
total_cost
:
0.1
,
avg_duration_ms
:
1
,
})
list
.
mockResolvedValue
({
items
:
[]
})
let
exportedBlob
:
Blob
|
null
=
null
const
originalCreateObjectURL
=
window
.
URL
.
createObjectURL
const
originalRevokeObjectURL
=
window
.
URL
.
revokeObjectURL
window
.
URL
.
createObjectURL
=
vi
.
fn
((
blob
:
Blob
|
MediaSource
)
=>
{
exportedBlob
=
blob
as
Blob
return
'
blob:usage-export
'
})
as
typeof
window
.
URL
.
createObjectURL
window
.
URL
.
revokeObjectURL
=
vi
.
fn
(()
=>
{})
as
typeof
window
.
URL
.
revokeObjectURL
const
clickSpy
=
vi
.
spyOn
(
HTMLAnchorElement
.
prototype
,
'
click
'
).
mockImplementation
(()
=>
{})
const
wrapper
=
mount
(
UsageView
,
{
global
:
{
stubs
:
{
AppLayout
:
AppLayoutStub
,
TablePageLayout
:
TablePageLayoutStub
,
Pagination
:
true
,
EmptyState
:
true
,
Select
:
true
,
DateRangePicker
:
true
,
Icon
:
true
,
Teleport
:
true
,
},
},
})
await
flushPromises
()
const
setupState
=
(
wrapper
.
vm
as
any
).
$
?.
setupState
await
setupState
.
exportToCSV
()
expect
(
exportedBlob
).
not
.
toBeNull
()
expect
(
clickSpy
).
toHaveBeenCalled
()
expect
(
showSuccess
).
toHaveBeenCalled
()
window
.
URL
.
createObjectURL
=
originalCreateObjectURL
window
.
URL
.
revokeObjectURL
=
originalRevokeObjectURL
clickSpy
.
mockRestore
()
})
})
Prev
1
2
Next
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment