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
3718d6dc
Unverified
Commit
3718d6dc
authored
Mar 15, 2026
by
IanShaw
Committed by
GitHub
Mar 15, 2026
Browse files
Merge branch 'Wei-Shaw:main' into fix/open-issues-cleanup
parents
90b38381
8321e4a6
Changes
38
Show whitespace changes
Inline
Side-by-side
backend/internal/service/gateway_request_test.go
View file @
3718d6dc
...
...
@@ -972,6 +972,76 @@ func BenchmarkParseGatewayRequest_Old_Large(b *testing.B) {
}
}
func
TestParseGatewayRequest_OutputEffort
(
t
*
testing
.
T
)
{
tests
:=
[]
struct
{
name
string
body
string
wantEffort
string
}{
{
name
:
"output_config.effort present"
,
body
:
`{"model":"claude-opus-4-6","output_config":{"effort":"medium"},"messages":[]}`
,
wantEffort
:
"medium"
,
},
{
name
:
"output_config.effort max"
,
body
:
`{"model":"claude-opus-4-6","output_config":{"effort":"max"},"messages":[]}`
,
wantEffort
:
"max"
,
},
{
name
:
"output_config without effort"
,
body
:
`{"model":"claude-opus-4-6","output_config":{},"messages":[]}`
,
wantEffort
:
""
,
},
{
name
:
"no output_config"
,
body
:
`{"model":"claude-opus-4-6","messages":[]}`
,
wantEffort
:
""
,
},
{
name
:
"effort with whitespace trimmed"
,
body
:
`{"model":"claude-opus-4-6","output_config":{"effort":" high "},"messages":[]}`
,
wantEffort
:
"high"
,
},
}
for
_
,
tt
:=
range
tests
{
t
.
Run
(
tt
.
name
,
func
(
t
*
testing
.
T
)
{
parsed
,
err
:=
ParseGatewayRequest
([]
byte
(
tt
.
body
),
""
)
require
.
NoError
(
t
,
err
)
require
.
Equal
(
t
,
tt
.
wantEffort
,
parsed
.
OutputEffort
)
})
}
}
func
TestNormalizeClaudeOutputEffort
(
t
*
testing
.
T
)
{
tests
:=
[]
struct
{
input
string
want
*
string
}{
{
"low"
,
strPtr
(
"low"
)},
{
"medium"
,
strPtr
(
"medium"
)},
{
"high"
,
strPtr
(
"high"
)},
{
"max"
,
strPtr
(
"max"
)},
{
"LOW"
,
strPtr
(
"low"
)},
{
"Max"
,
strPtr
(
"max"
)},
{
" medium "
,
strPtr
(
"medium"
)},
{
""
,
nil
},
{
"unknown"
,
nil
},
{
"xhigh"
,
nil
},
}
for
_
,
tt
:=
range
tests
{
t
.
Run
(
tt
.
input
,
func
(
t
*
testing
.
T
)
{
got
:=
NormalizeClaudeOutputEffort
(
tt
.
input
)
if
tt
.
want
==
nil
{
require
.
Nil
(
t
,
got
)
}
else
{
require
.
NotNil
(
t
,
got
)
require
.
Equal
(
t
,
*
tt
.
want
,
*
got
)
}
})
}
}
func
BenchmarkParseGatewayRequest_New_Large
(
b
*
testing
.
B
)
{
data
:=
buildLargeJSON
()
b
.
SetBytes
(
int64
(
len
(
data
)))
...
...
backend/internal/service/gateway_service.go
View file @
3718d6dc
...
...
@@ -492,6 +492,7 @@ type ForwardResult struct {
Duration
time
.
Duration
FirstTokenMs
*
int
// 首字时间(流式请求)
ClientDisconnect
bool
// 客户端是否在流式传输过程中断开
ReasoningEffort
*
string
// 图片生成计费字段(图片生成模型使用)
ImageCount
int
// 生成的图片数量
...
...
@@ -7523,6 +7524,7 @@ func (s *GatewayService) RecordUsage(ctx context.Context, input *RecordUsageInpu
AccountID
:
account
.
ID
,
RequestID
:
requestID
,
Model
:
result
.
Model
,
ReasoningEffort
:
result
.
ReasoningEffort
,
InputTokens
:
result
.
Usage
.
InputTokens
,
OutputTokens
:
result
.
Usage
.
OutputTokens
,
CacheCreationTokens
:
result
.
Usage
.
CacheCreationInputTokens
,
...
...
@@ -7699,6 +7701,7 @@ func (s *GatewayService) RecordUsageWithLongContext(ctx context.Context, input *
AccountID
:
account
.
ID
,
RequestID
:
requestID
,
Model
:
result
.
Model
,
ReasoningEffort
:
result
.
ReasoningEffort
,
InputTokens
:
result
.
Usage
.
InputTokens
,
OutputTokens
:
result
.
Usage
.
OutputTokens
,
CacheCreationTokens
:
result
.
Usage
.
CacheCreationInputTokens
,
...
...
backend/internal/service/openai_gateway_record_usage_test.go
View file @
3718d6dc
...
...
@@ -226,6 +226,41 @@ func TestOpenAIGatewayServiceRecordUsage_UsesUserSpecificGroupRate(t *testing.T)
require
.
Equal
(
t
,
1
,
userRepo
.
deductCalls
)
}
func
TestOpenAIGatewayServiceRecordUsage_IncludesEndpointMetadata
(
t
*
testing
.
T
)
{
usageRepo
:=
&
openAIRecordUsageLogRepoStub
{
inserted
:
true
}
userRepo
:=
&
openAIRecordUsageUserRepoStub
{}
subRepo
:=
&
openAIRecordUsageSubRepoStub
{}
rateRepo
:=
&
openAIUserGroupRateRepoStub
{}
svc
:=
newOpenAIRecordUsageServiceForTest
(
usageRepo
,
userRepo
,
subRepo
,
rateRepo
)
err
:=
svc
.
RecordUsage
(
context
.
Background
(),
&
OpenAIRecordUsageInput
{
Result
:
&
OpenAIForwardResult
{
RequestID
:
"resp_endpoint_metadata"
,
Usage
:
OpenAIUsage
{
InputTokens
:
8
,
OutputTokens
:
2
,
},
Model
:
"gpt-5.1"
,
Duration
:
time
.
Second
,
},
APIKey
:
&
APIKey
{
ID
:
1002
,
Group
:
&
Group
{
RateMultiplier
:
1
},
},
User
:
&
User
{
ID
:
2002
},
Account
:
&
Account
{
ID
:
3002
},
InboundEndpoint
:
" /v1/chat/completions "
,
UpstreamEndpoint
:
" /v1/responses "
,
})
require
.
NoError
(
t
,
err
)
require
.
NotNil
(
t
,
usageRepo
.
lastLog
)
require
.
NotNil
(
t
,
usageRepo
.
lastLog
.
InboundEndpoint
)
require
.
Equal
(
t
,
"/v1/chat/completions"
,
*
usageRepo
.
lastLog
.
InboundEndpoint
)
require
.
NotNil
(
t
,
usageRepo
.
lastLog
.
UpstreamEndpoint
)
require
.
Equal
(
t
,
"/v1/responses"
,
*
usageRepo
.
lastLog
.
UpstreamEndpoint
)
}
func
TestOpenAIGatewayServiceRecordUsage_FallsBackToGroupDefaultRateOnResolverError
(
t
*
testing
.
T
)
{
groupID
:=
int64
(
12
)
groupRate
:=
1.6
...
...
backend/internal/service/openai_gateway_service.go
View file @
3718d6dc
...
...
@@ -4028,6 +4028,8 @@ type OpenAIRecordUsageInput struct {
User
*
User
Account
*
Account
Subscription
*
UserSubscription
InboundEndpoint
string
UpstreamEndpoint
string
UserAgent
string
// 请求的 User-Agent
IPAddress
string
// 请求的客户端 IP 地址
RequestPayloadHash
string
...
...
@@ -4106,6 +4108,8 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec
Model
:
billingModel
,
ServiceTier
:
result
.
ServiceTier
,
ReasoningEffort
:
result
.
ReasoningEffort
,
InboundEndpoint
:
optionalTrimmedStringPtr
(
input
.
InboundEndpoint
),
UpstreamEndpoint
:
optionalTrimmedStringPtr
(
input
.
UpstreamEndpoint
),
InputTokens
:
actualInputTokens
,
OutputTokens
:
result
.
Usage
.
OutputTokens
,
CacheCreationTokens
:
result
.
Usage
.
CacheCreationInputTokens
,
...
...
@@ -4125,7 +4129,6 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec
FirstTokenMs
:
result
.
FirstTokenMs
,
CreatedAt
:
time
.
Now
(),
}
// 添加 UserAgent
if
input
.
UserAgent
!=
""
{
usageLog
.
UserAgent
=
&
input
.
UserAgent
...
...
@@ -4668,3 +4671,11 @@ func normalizeOpenAIReasoningEffort(raw string) string {
return
""
}
}
func
optionalTrimmedStringPtr
(
raw
string
)
*
string
{
trimmed
:=
strings
.
TrimSpace
(
raw
)
if
trimmed
==
""
{
return
nil
}
return
&
trimmed
}
backend/internal/service/usage_log.go
View file @
3718d6dc
...
...
@@ -100,9 +100,14 @@ type UsageLog struct {
Model
string
// ServiceTier records the OpenAI service tier used for billing, e.g. "priority" / "flex".
ServiceTier
*
string
// ReasoningEffort is the request's reasoning effort level (OpenAI Responses API),
// e.g. "low" / "medium" / "high" / "xhigh". Nil means not provided / not applicable.
// ReasoningEffort is the request's reasoning effort level.
// OpenAI: "low" / "medium" / "high" / "xhigh"; Claude: "low" / "medium" / "high" / "max".
// Nil means not provided / not applicable.
ReasoningEffort
*
string
// InboundEndpoint is the client-facing API endpoint path, e.g. /v1/chat/completions.
InboundEndpoint
*
string
// UpstreamEndpoint is the normalized upstream endpoint path, e.g. /v1/responses.
UpstreamEndpoint
*
string
GroupID
*
int64
SubscriptionID
*
int64
...
...
backend/migrations/074_add_usage_log_endpoints.sql
0 → 100644
View file @
3718d6dc
-- Add endpoint tracking fields to usage_logs.
-- inbound_endpoint: client-facing API route (e.g. /v1/chat/completions, /v1/messages, /v1/responses)
-- upstream_endpoint: normalized upstream route (e.g. /v1/responses)
ALTER
TABLE
usage_logs
ADD
COLUMN
IF
NOT
EXISTS
inbound_endpoint
VARCHAR
(
128
);
ALTER
TABLE
usage_logs
ADD
COLUMN
IF
NOT
EXISTS
upstream_endpoint
VARCHAR
(
128
);
frontend/src/api/admin/usage.ts
View file @
3718d6dc
...
...
@@ -5,6 +5,7 @@
import
{
apiClient
}
from
'
../client
'
import
type
{
AdminUsageLog
,
UsageQueryParams
,
PaginatedResponse
,
UsageRequestType
}
from
'
@/types
'
import
type
{
EndpointStat
}
from
'
@/types
'
// ==================== Types ====================
...
...
@@ -18,6 +19,9 @@ export interface AdminUsageStatsResponse {
total_actual_cost
:
number
total_account_cost
?:
number
average_duration_ms
:
number
endpoints
?:
EndpointStat
[]
upstream_endpoints
?:
EndpointStat
[]
endpoint_paths
?:
EndpointStat
[]
}
export
interface
SimpleUser
{
...
...
frontend/src/components/account/AccountStatsModal.vue
View file @
3718d6dc
...
...
@@ -446,6 +446,18 @@
<!--
Model
Distribution
-->
<
ModelDistributionChart
:
model
-
stats
=
"
stats.models
"
:
loading
=
"
false
"
/>
<
EndpointDistributionChart
:
endpoint
-
stats
=
"
stats.endpoints || []
"
:
loading
=
"
false
"
:
title
=
"
t('usage.inboundEndpoint')
"
/>
<
EndpointDistributionChart
:
endpoint
-
stats
=
"
stats.upstream_endpoints || []
"
:
loading
=
"
false
"
:
title
=
"
t('usage.upstreamEndpoint')
"
/>
<
/template
>
<!--
No
Data
State
-->
...
...
@@ -489,6 +501,7 @@ import { Line } from 'vue-chartjs'
import
BaseDialog
from
'
@/components/common/BaseDialog.vue
'
import
LoadingSpinner
from
'
@/components/common/LoadingSpinner.vue
'
import
ModelDistributionChart
from
'
@/components/charts/ModelDistributionChart.vue
'
import
EndpointDistributionChart
from
'
@/components/charts/EndpointDistributionChart.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
type
{
Account
,
AccountUsageStatsResponse
}
from
'
@/types
'
...
...
frontend/src/components/admin/account/AccountStatsModal.vue
View file @
3718d6dc
...
...
@@ -410,6 +410,18 @@
<!--
Model
Distribution
-->
<
ModelDistributionChart
:
model
-
stats
=
"
stats.models
"
:
loading
=
"
false
"
/>
<
EndpointDistributionChart
:
endpoint
-
stats
=
"
stats.endpoints || []
"
:
loading
=
"
false
"
:
title
=
"
t('usage.inboundEndpoint')
"
/>
<
EndpointDistributionChart
:
endpoint
-
stats
=
"
stats.upstream_endpoints || []
"
:
loading
=
"
false
"
:
title
=
"
t('usage.upstreamEndpoint')
"
/>
<
/template
>
<!--
No
Data
State
-->
...
...
@@ -453,6 +465,7 @@ import { Line } from 'vue-chartjs'
import
BaseDialog
from
'
@/components/common/BaseDialog.vue
'
import
LoadingSpinner
from
'
@/components/common/LoadingSpinner.vue
'
import
ModelDistributionChart
from
'
@/components/charts/ModelDistributionChart.vue
'
import
EndpointDistributionChart
from
'
@/components/charts/EndpointDistributionChart.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
type
{
Account
,
AccountUsageStatsResponse
}
from
'
@/types
'
...
...
frontend/src/components/admin/account/AccountTableFilters.vue
View file @
3718d6dc
...
...
@@ -24,7 +24,7 @@ const updateType = (value: string | number | boolean | null) => { emit('update:f
const
updateStatus
=
(
value
:
string
|
number
|
boolean
|
null
)
=>
{
emit
(
'
update:filters
'
,
{
...
props
.
filters
,
status
:
value
})
}
const
updateGroup
=
(
value
:
string
|
number
|
boolean
|
null
)
=>
{
emit
(
'
update:filters
'
,
{
...
props
.
filters
,
group
:
value
})
}
const
pOpts
=
computed
(()
=>
[{
value
:
''
,
label
:
t
(
'
admin.accounts.allPlatforms
'
)
},
{
value
:
'
anthropic
'
,
label
:
'
Anthropic
'
},
{
value
:
'
openai
'
,
label
:
'
OpenAI
'
},
{
value
:
'
gemini
'
,
label
:
'
Gemini
'
},
{
value
:
'
antigravity
'
,
label
:
'
Antigravity
'
},
{
value
:
'
sora
'
,
label
:
'
Sora
'
}])
const
tOpts
=
computed
(()
=>
[{
value
:
''
,
label
:
t
(
'
admin.accounts.allTypes
'
)
},
{
value
:
'
oauth
'
,
label
:
t
(
'
admin.accounts.oauthType
'
)
},
{
value
:
'
setup-token
'
,
label
:
t
(
'
admin.accounts.setupToken
'
)
},
{
value
:
'
apikey
'
,
label
:
t
(
'
admin.accounts.apiKey
'
)
}])
const
tOpts
=
computed
(()
=>
[{
value
:
''
,
label
:
t
(
'
admin.accounts.allTypes
'
)
},
{
value
:
'
oauth
'
,
label
:
t
(
'
admin.accounts.oauthType
'
)
},
{
value
:
'
setup-token
'
,
label
:
t
(
'
admin.accounts.setupToken
'
)
},
{
value
:
'
apikey
'
,
label
:
t
(
'
admin.accounts.apiKey
'
)
},
{
value
:
'
bedrock
'
,
label
:
'
AWS Bedrock
'
}])
const
sOpts
=
computed
(()
=>
[{
value
:
''
,
label
:
t
(
'
admin.accounts.allStatus
'
)
},
{
value
:
'
active
'
,
label
:
t
(
'
admin.accounts.status.active
'
)
},
{
value
:
'
inactive
'
,
label
:
t
(
'
admin.accounts.status.inactive
'
)
},
{
value
:
'
error
'
,
label
:
t
(
'
admin.accounts.status.error
'
)
},
{
value
:
'
rate_limited
'
,
label
:
t
(
'
admin.accounts.status.rateLimited
'
)
},
{
value
:
'
temp_unschedulable
'
,
label
:
t
(
'
admin.accounts.status.tempUnschedulable
'
)
}])
const
gOpts
=
computed
(()
=>
[{
value
:
''
,
label
:
t
(
'
admin.accounts.allGroups
'
)
},
...(
props
.
groups
||
[]).
map
(
g
=>
({
value
:
String
(
g
.
id
),
label
:
g
.
name
}))])
</
script
>
frontend/src/components/admin/usage/UsageTable.vue
View file @
3718d6dc
...
...
@@ -35,6 +35,19 @@
</span>
</
template
>
<
template
#cell-endpoint=
"{ row }"
>
<div
class=
"max-w-[320px] space-y-1 text-xs"
>
<div
class=
"break-all text-gray-700 dark:text-gray-300"
>
<span
class=
"font-medium text-gray-500 dark:text-gray-400"
>
{{
t
(
'
usage.inbound
'
)
}}
:
</span>
<span
class=
"ml-1"
>
{{
row
.
inbound_endpoint
?.
trim
()
||
'
-
'
}}
</span>
</div>
<div
class=
"break-all text-gray-700 dark:text-gray-300"
>
<span
class=
"font-medium text-gray-500 dark:text-gray-400"
>
{{
t
(
'
usage.upstream
'
)
}}
:
</span>
<span
class=
"ml-1"
>
{{
row
.
upstream_endpoint
?.
trim
()
||
'
-
'
}}
</span>
</div>
</div>
</
template
>
<
template
#cell-group=
"{ row }"
>
<span
v-if=
"row.group"
class=
"inline-flex items-center rounded px-2 py-0.5 text-xs font-medium bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-200"
>
{{
row
.
group
.
name
}}
...
...
@@ -328,6 +341,7 @@ const getRequestTypeBadgeClass = (row: AdminUsageLog): string => {
if
(
requestType
===
'
sync
'
)
return
'
bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200
'
return
'
bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200
'
}
const
formatCacheTokens
=
(
tokens
:
number
):
string
=>
{
if
(
tokens
>=
1000000
)
return
`
${(
tokens
/
1000000
).
toFixed
(
1
)}
M`
if
(
tokens
>=
1000
)
return
`
${(
tokens
/
1000
).
toFixed
(
1
)}
K`
...
...
frontend/src/components/charts/EndpointDistributionChart.vue
0 → 100644
View file @
3718d6dc
<
template
>
<div
class=
"card p-4"
>
<div
class=
"mb-4 flex items-start justify-between gap-3"
>
<h3
class=
"text-sm font-semibold text-gray-900 dark:text-white"
>
{{
title
||
t
(
'
usage.endpointDistribution
'
)
}}
</h3>
<div
class=
"flex flex-col items-end gap-2"
>
<div
v-if=
"showSourceToggle"
class=
"inline-flex rounded-lg border border-gray-200 bg-gray-50 p-0.5 dark:border-gray-700 dark:bg-dark-800"
>
<button
type=
"button"
class=
"rounded-md px-2.5 py-1 text-xs font-medium transition-colors"
:class=
"source === 'inbound'
? 'bg-white text-gray-900 shadow-sm dark:bg-dark-700 dark:text-white'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'"
@
click=
"emit('update:source', 'inbound')"
>
{{
t
(
'
usage.inbound
'
)
}}
</button>
<button
type=
"button"
class=
"rounded-md px-2.5 py-1 text-xs font-medium transition-colors"
:class=
"source === 'upstream'
? 'bg-white text-gray-900 shadow-sm dark:bg-dark-700 dark:text-white'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'"
@
click=
"emit('update:source', 'upstream')"
>
{{
t
(
'
usage.upstream
'
)
}}
</button>
<button
type=
"button"
class=
"rounded-md px-2.5 py-1 text-xs font-medium transition-colors"
:class=
"source === 'path'
? 'bg-white text-gray-900 shadow-sm dark:bg-dark-700 dark:text-white'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'"
@
click=
"emit('update:source', 'path')"
>
{{
t
(
'
usage.path
'
)
}}
</button>
</div>
<div
v-if=
"showMetricToggle"
class=
"inline-flex rounded-lg border border-gray-200 bg-gray-50 p-0.5 dark:border-gray-700 dark:bg-dark-800"
>
<button
type=
"button"
class=
"rounded-md px-2.5 py-1 text-xs font-medium transition-colors"
:class=
"metric === 'tokens'
? 'bg-white text-gray-900 shadow-sm dark:bg-dark-700 dark:text-white'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'"
@
click=
"emit('update:metric', 'tokens')"
>
{{
t
(
'
admin.dashboard.metricTokens
'
)
}}
</button>
<button
type=
"button"
class=
"rounded-md px-2.5 py-1 text-xs font-medium transition-colors"
:class=
"metric === 'actual_cost'
? 'bg-white text-gray-900 shadow-sm dark:bg-dark-700 dark:text-white'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'"
@
click=
"emit('update:metric', 'actual_cost')"
>
{{
t
(
'
admin.dashboard.metricActualCost
'
)
}}
</button>
</div>
</div>
</div>
<div
v-if=
"loading"
class=
"flex h-48 items-center justify-center"
>
<LoadingSpinner
/>
</div>
<div
v-else-if=
"displayEndpointStats.length > 0 && chartData"
class=
"flex items-center gap-6"
>
<div
class=
"h-48 w-48"
>
<Doughnut
:data=
"chartData"
:options=
"doughnutOptions"
/>
</div>
<div
class=
"max-h-48 flex-1 overflow-y-auto"
>
<table
class=
"w-full text-xs"
>
<thead>
<tr
class=
"text-gray-500 dark:text-gray-400"
>
<th
class=
"pb-2 text-left"
>
{{
t
(
'
usage.endpoint
'
)
}}
</th>
<th
class=
"pb-2 text-right"
>
{{
t
(
'
admin.dashboard.requests
'
)
}}
</th>
<th
class=
"pb-2 text-right"
>
{{
t
(
'
admin.dashboard.tokens
'
)
}}
</th>
<th
class=
"pb-2 text-right"
>
{{
t
(
'
admin.dashboard.actual
'
)
}}
</th>
<th
class=
"pb-2 text-right"
>
{{
t
(
'
admin.dashboard.standard
'
)
}}
</th>
</tr>
</thead>
<tbody>
<tr
v-for=
"item in displayEndpointStats"
:key=
"item.endpoint"
class=
"border-t border-gray-100 dark:border-gray-700"
>
<td
class=
"max-w-[180px] truncate py-1.5 font-medium text-gray-900 dark:text-white"
:title=
"item.endpoint"
>
{{
item
.
endpoint
}}
</td>
<td
class=
"py-1.5 text-right text-gray-600 dark:text-gray-400"
>
{{
formatNumber
(
item
.
requests
)
}}
</td>
<td
class=
"py-1.5 text-right text-gray-600 dark:text-gray-400"
>
{{
formatTokens
(
item
.
total_tokens
)
}}
</td>
<td
class=
"py-1.5 text-right text-green-600 dark:text-green-400"
>
$
{{
formatCost
(
item
.
actual_cost
)
}}
</td>
<td
class=
"py-1.5 text-right text-gray-400 dark:text-gray-500"
>
$
{{
formatCost
(
item
.
cost
)
}}
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div
v-else
class=
"flex h-48 items-center justify-center text-sm text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.dashboard.noDataAvailable
'
)
}}
</div>
</div>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
computed
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
Chart
as
ChartJS
,
ArcElement
,
Tooltip
,
Legend
}
from
'
chart.js
'
import
{
Doughnut
}
from
'
vue-chartjs
'
import
LoadingSpinner
from
'
@/components/common/LoadingSpinner.vue
'
import
type
{
EndpointStat
}
from
'
@/types
'
ChartJS
.
register
(
ArcElement
,
Tooltip
,
Legend
)
const
{
t
}
=
useI18n
()
type
DistributionMetric
=
'
tokens
'
|
'
actual_cost
'
type
EndpointSource
=
'
inbound
'
|
'
upstream
'
|
'
path
'
const
props
=
withDefaults
(
defineProps
<
{
endpointStats
:
EndpointStat
[]
upstreamEndpointStats
?:
EndpointStat
[]
endpointPathStats
?:
EndpointStat
[]
loading
?:
boolean
title
?:
string
metric
?:
DistributionMetric
source
?:
EndpointSource
showMetricToggle
?:
boolean
showSourceToggle
?:
boolean
}
>
(),
{
upstreamEndpointStats
:
()
=>
[],
endpointPathStats
:
()
=>
[],
loading
:
false
,
title
:
''
,
metric
:
'
tokens
'
,
source
:
'
inbound
'
,
showMetricToggle
:
false
,
showSourceToggle
:
false
}
)
const
emit
=
defineEmits
<
{
'
update:metric
'
:
[
value
:
DistributionMetric
]
'
update:source
'
:
[
value
:
EndpointSource
]
}
>
()
const
chartColors
=
[
'
#3b82f6
'
,
'
#10b981
'
,
'
#f59e0b
'
,
'
#ef4444
'
,
'
#8b5cf6
'
,
'
#ec4899
'
,
'
#14b8a6
'
,
'
#f97316
'
,
'
#6366f1
'
,
'
#84cc16
'
,
'
#06b6d4
'
,
'
#a855f7
'
]
const
displayEndpointStats
=
computed
(()
=>
{
const
sourceStats
=
props
.
source
===
'
upstream
'
?
props
.
upstreamEndpointStats
:
props
.
source
===
'
path
'
?
props
.
endpointPathStats
:
props
.
endpointStats
if
(
!
sourceStats
?.
length
)
return
[]
const
metricKey
=
props
.
metric
===
'
actual_cost
'
?
'
actual_cost
'
:
'
total_tokens
'
return
[...
sourceStats
].
sort
((
a
,
b
)
=>
b
[
metricKey
]
-
a
[
metricKey
])
})
const
chartData
=
computed
(()
=>
{
if
(
!
displayEndpointStats
.
value
?.
length
)
return
null
return
{
labels
:
displayEndpointStats
.
value
.
map
((
item
)
=>
item
.
endpoint
),
datasets
:
[
{
data
:
displayEndpointStats
.
value
.
map
((
item
)
=>
props
.
metric
===
'
actual_cost
'
?
item
.
actual_cost
:
item
.
total_tokens
),
backgroundColor
:
chartColors
.
slice
(
0
,
displayEndpointStats
.
value
.
length
),
borderWidth
:
0
}
]
}
})
const
doughnutOptions
=
computed
(()
=>
({
responsive
:
true
,
maintainAspectRatio
:
false
,
plugins
:
{
legend
:
{
display
:
false
},
tooltip
:
{
callbacks
:
{
label
:
(
context
:
any
)
=>
{
const
value
=
context
.
raw
as
number
const
total
=
context
.
dataset
.
data
.
reduce
((
a
:
number
,
b
:
number
)
=>
a
+
b
,
0
)
const
percentage
=
total
>
0
?
((
value
/
total
)
*
100
).
toFixed
(
1
)
:
'
0.0
'
const
formattedValue
=
props
.
metric
===
'
actual_cost
'
?
`$
${
formatCost
(
value
)}
`
:
formatTokens
(
value
)
return
`
${
context
.
label
}
:
${
formattedValue
}
(
${
percentage
}
%)`
}
}
}
}
}))
const
formatTokens
=
(
value
:
number
):
string
=>
{
if
(
value
>=
1
_000_000_000
)
{
return
`
${(
value
/
1
_000_000_000
).
toFixed
(
2
)}
B`
}
else
if
(
value
>=
1
_000_000
)
{
return
`
${(
value
/
1
_000_000
).
toFixed
(
2
)}
M`
}
else
if
(
value
>=
1
_000
)
{
return
`
${(
value
/
1
_000
).
toFixed
(
2
)}
K`
}
return
value
.
toLocaleString
()
}
const
formatNumber
=
(
value
:
number
):
string
=>
{
return
value
.
toLocaleString
()
}
const
formatCost
=
(
value
:
number
):
string
=>
{
if
(
value
>=
1000
)
{
return
(
value
/
1000
).
toFixed
(
2
)
+
'
K
'
}
else
if
(
value
>=
1
)
{
return
value
.
toFixed
(
2
)
}
else
if
(
value
>=
0.01
)
{
return
value
.
toFixed
(
3
)
}
return
value
.
toFixed
(
4
)
}
</
script
>
frontend/src/i18n/locales/en.ts
View file @
3718d6dc
...
...
@@ -718,6 +718,13 @@ export default {
preparingExport
:
'
Preparing export...
'
,
model
:
'
Model
'
,
reasoningEffort
:
'
Reasoning Effort
'
,
endpoint
:
'
Endpoint
'
,
endpointDistribution
:
'
Endpoint Distribution
'
,
inbound
:
'
Inbound
'
,
upstream
:
'
Upstream
'
,
path
:
'
Path
'
,
inboundEndpoint
:
'
Inbound Endpoint
'
,
upstreamEndpoint
:
'
Upstream Endpoint
'
,
type
:
'
Type
'
,
tokens
:
'
Tokens
'
,
cost
:
'
Cost
'
,
...
...
frontend/src/i18n/locales/zh.ts
View file @
3718d6dc
...
...
@@ -723,6 +723,13 @@ export default {
preparingExport
:
'
正在准备导出...
'
,
model
:
'
模型
'
,
reasoningEffort
:
'
推理强度
'
,
endpoint
:
'
端点
'
,
endpointDistribution
:
'
端点分布
'
,
inbound
:
'
入站
'
,
upstream
:
'
上游
'
,
path
:
'
路径
'
,
inboundEndpoint
:
'
入站端点
'
,
upstreamEndpoint
:
'
上游端点
'
,
type
:
'
类型
'
,
tokens
:
'
Token
'
,
cost
:
'
费用
'
,
...
...
frontend/src/types/index.ts
View file @
3718d6dc
...
...
@@ -962,6 +962,8 @@ export interface UsageLog {
model
:
string
service_tier
?:
string
|
null
reasoning_effort
?:
string
|
null
inbound_endpoint
?:
string
|
null
upstream_endpoint
?:
string
|
null
group_id
:
number
|
null
subscription_id
:
number
|
null
...
...
@@ -1168,6 +1170,14 @@ export interface ModelStat {
actual_cost
:
number
// 实际扣除
}
export
interface
EndpointStat
{
endpoint
:
string
requests
:
number
total_tokens
:
number
cost
:
number
actual_cost
:
number
}
export
interface
GroupStat
{
group_id
:
number
group_name
:
string
...
...
@@ -1362,6 +1372,8 @@ export interface AccountUsageStatsResponse {
history
:
AccountUsageHistory
[]
summary
:
AccountUsageSummary
models
:
ModelStat
[]
endpoints
:
EndpointStat
[]
upstream_endpoints
:
EndpointStat
[]
}
// ==================== User Attribute Types ====================
...
...
frontend/src/views/admin/GroupsView.vue
View file @
3718d6dc
...
...
@@ -2402,6 +2402,11 @@ const handleCreateGroup = async () => {
sora_storage_quota_bytes
:
createQuotaGb
?
Math
.
round
(
createQuotaGb
*
1024
*
1024
*
1024
)
:
0
,
model_routing
:
convertRoutingRulesToApiFormat
(
createModelRoutingRules
.
value
)
}
// v-model.number 清空输入框时产生 "",转为 null 让后端设为无限制
const
emptyToNull
=
(
v
:
any
)
=>
v
===
''
?
null
:
v
requestData
.
daily_limit_usd
=
emptyToNull
(
requestData
.
daily_limit_usd
)
requestData
.
weekly_limit_usd
=
emptyToNull
(
requestData
.
weekly_limit_usd
)
requestData
.
monthly_limit_usd
=
emptyToNull
(
requestData
.
monthly_limit_usd
)
await
adminAPI
.
groups
.
create
(
requestData
)
appStore
.
showSuccess
(
t
(
'
admin.groups.groupCreated
'
))
closeCreateModal
()
...
...
@@ -2488,6 +2493,11 @@ const handleUpdateGroup = async () => {
:
editForm
.
fallback_group_id_on_invalid_request
,
model_routing
:
convertRoutingRulesToApiFormat
(
editModelRoutingRules
.
value
)
}
// v-model.number 清空输入框时产生 "",转为 null 让后端设为无限制
const
emptyToNull
=
(
v
:
any
)
=>
v
===
''
?
null
:
v
payload
.
daily_limit_usd
=
emptyToNull
(
payload
.
daily_limit_usd
)
payload
.
weekly_limit_usd
=
emptyToNull
(
payload
.
weekly_limit_usd
)
payload
.
monthly_limit_usd
=
emptyToNull
(
payload
.
monthly_limit_usd
)
await
adminAPI
.
groups
.
update
(
editingGroup
.
value
.
id
,
payload
)
appStore
.
showSuccess
(
t
(
'
admin.groups.groupUpdated
'
))
closeEditModal
()
...
...
frontend/src/views/admin/UsageView.vue
View file @
3718d6dc
...
...
@@ -26,8 +26,21 @@
:show-metric-toggle=
"true"
/>
</div>
<div
class=
"grid grid-cols-1 gap-6 lg:grid-cols-2"
>
<EndpointDistributionChart
v-model:source=
"endpointDistributionSource"
v-model:metric=
"endpointDistributionMetric"
:endpoint-stats=
"inboundEndpointStats"
:upstream-endpoint-stats=
"upstreamEndpointStats"
:endpoint-path-stats=
"endpointPathStats"
:loading=
"endpointStatsLoading"
:show-source-toggle=
"true"
:show-metric-toggle=
"true"
:title=
"t('usage.endpointDistribution')"
/>
<TokenUsageTrend
:trend-data=
"trendData"
:loading=
"chartsLoading"
/>
</div>
</div>
<UsageFilters
v-model=
"filters"
v-model:startDate=
"startDate"
v-model:endDate=
"endDate"
:exporting=
"exporting"
@
change=
"applyFilters"
@
refresh=
"refreshData"
@
reset=
"resetFilters"
@
cleanup=
"openCleanupDialog"
@
export=
"exportToExcel"
>
<template
#after-reset
>
<div
class=
"relative"
ref=
"columnDropdownRef"
>
...
...
@@ -99,19 +112,28 @@ import UsageTable from '@/components/admin/usage/UsageTable.vue'; import UsageEx
import
UsageCleanupDialog
from
'
@/components/admin/usage/UsageCleanupDialog.vue
'
import
UserBalanceHistoryModal
from
'
@/components/admin/user/UserBalanceHistoryModal.vue
'
import
ModelDistributionChart
from
'
@/components/charts/ModelDistributionChart.vue
'
;
import
GroupDistributionChart
from
'
@/components/charts/GroupDistributionChart.vue
'
;
import
TokenUsageTrend
from
'
@/components/charts/TokenUsageTrend.vue
'
import
EndpointDistributionChart
from
'
@/components/charts/EndpointDistributionChart.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
type
{
AdminUsageLog
,
TrendDataPoint
,
ModelStat
,
GroupStat
,
AdminUser
}
from
'
@/types
'
;
import
type
{
AdminUsageStatsResponse
,
AdminUsageQueryParams
}
from
'
@/api/admin/usage
'
import
type
{
AdminUsageLog
,
TrendDataPoint
,
ModelStat
,
GroupStat
,
EndpointStat
,
AdminUser
}
from
'
@/types
'
;
import
type
{
AdminUsageStatsResponse
,
AdminUsageQueryParams
}
from
'
@/api/admin/usage
'
const
{
t
}
=
useI18n
()
const
appStore
=
useAppStore
()
type
DistributionMetric
=
'
tokens
'
|
'
actual_cost
'
type
EndpointSource
=
'
inbound
'
|
'
upstream
'
|
'
path
'
const
route
=
useRoute
()
const
usageStats
=
ref
<
AdminUsageStatsResponse
|
null
>
(
null
);
const
usageLogs
=
ref
<
AdminUsageLog
[]
>
([]);
const
loading
=
ref
(
false
);
const
exporting
=
ref
(
false
)
const
trendData
=
ref
<
TrendDataPoint
[]
>
([]);
const
modelStats
=
ref
<
ModelStat
[]
>
([]);
const
groupStats
=
ref
<
GroupStat
[]
>
([]);
const
chartsLoading
=
ref
(
false
);
const
granularity
=
ref
<
'
day
'
|
'
hour
'
>
(
'
day
'
)
const
modelDistributionMetric
=
ref
<
DistributionMetric
>
(
'
tokens
'
)
const
groupDistributionMetric
=
ref
<
DistributionMetric
>
(
'
tokens
'
)
const
endpointDistributionMetric
=
ref
<
DistributionMetric
>
(
'
tokens
'
)
const
endpointDistributionSource
=
ref
<
EndpointSource
>
(
'
inbound
'
)
const
inboundEndpointStats
=
ref
<
EndpointStat
[]
>
([])
const
upstreamEndpointStats
=
ref
<
EndpointStat
[]
>
([])
const
endpointPathStats
=
ref
<
EndpointStat
[]
>
([])
const
endpointStatsLoading
=
ref
(
false
)
let
abortController
:
AbortController
|
null
=
null
;
let
exportAbortController
:
AbortController
|
null
=
null
let
chartReqSeq
=
0
let
statsReqSeq
=
0
const
exportProgress
=
reactive
({
show
:
false
,
progress
:
0
,
current
:
0
,
total
:
0
,
estimatedTime
:
''
})
const
cleanupDialogVisible
=
ref
(
false
)
// Balance history modal state
...
...
@@ -183,13 +205,25 @@ const loadLogs = async () => {
}
catch
(
error
:
any
)
{
if
(
error
?.
name
!==
'
AbortError
'
)
console
.
error
(
'
Failed to load usage logs:
'
,
error
)
}
finally
{
if
(
abortController
===
c
)
loading
.
value
=
false
}
}
const
loadStats
=
async
()
=>
{
const
seq
=
++
statsReqSeq
endpointStatsLoading
.
value
=
true
try
{
const
requestType
=
filters
.
value
.
request_type
const
legacyStream
=
requestType
?
requestTypeToLegacyStream
(
requestType
)
:
filters
.
value
.
stream
const
s
=
await
adminAPI
.
usage
.
getStats
({
...
filters
.
value
,
stream
:
legacyStream
===
null
?
undefined
:
legacyStream
})
if
(
seq
!==
statsReqSeq
)
return
usageStats
.
value
=
s
inboundEndpointStats
.
value
=
s
.
endpoints
||
[]
upstreamEndpointStats
.
value
=
s
.
upstream_endpoints
||
[]
endpointPathStats
.
value
=
s
.
endpoint_paths
||
[]
}
catch
(
error
)
{
if
(
seq
!==
statsReqSeq
)
return
console
.
error
(
'
Failed to load usage stats:
'
,
error
)
inboundEndpointStats
.
value
=
[]
upstreamEndpointStats
.
value
=
[]
endpointPathStats
.
value
=
[]
}
finally
{
if
(
seq
===
statsReqSeq
)
endpointStatsLoading
.
value
=
false
}
}
const
loadChartData
=
async
()
=>
{
...
...
@@ -246,6 +280,7 @@ const exportToExcel = async () => {
const
headers
=
[
t
(
'
usage.time
'
),
t
(
'
admin.usage.user
'
),
t
(
'
usage.apiKeyFilter
'
),
t
(
'
admin.usage.account
'
),
t
(
'
usage.model
'
),
t
(
'
usage.reasoningEffort
'
),
t
(
'
admin.usage.group
'
),
t
(
'
usage.inboundEndpoint
'
),
t
(
'
usage.upstreamEndpoint
'
),
t
(
'
usage.type
'
),
t
(
'
admin.usage.inputTokens
'
),
t
(
'
admin.usage.outputTokens
'
),
t
(
'
admin.usage.cacheReadTokens
'
),
t
(
'
admin.usage.cacheCreationTokens
'
),
...
...
@@ -263,7 +298,8 @@ const exportToExcel = async () => {
if
(
c
.
signal
.
aborted
)
break
;
if
(
p
===
1
)
{
total
=
res
.
total
;
exportProgress
.
total
=
total
}
const
rows
=
(
res
.
items
||
[]).
map
((
log
:
AdminUsageLog
)
=>
[
log
.
created_at
,
log
.
user
?.
email
||
''
,
log
.
api_key
?.
name
||
''
,
log
.
account
?.
name
||
''
,
log
.
model
,
formatReasoningEffort
(
log
.
reasoning_effort
),
log
.
group
?.
name
||
''
,
getRequestTypeLabel
(
log
),
formatReasoningEffort
(
log
.
reasoning_effort
),
log
.
group
?.
name
||
''
,
log
.
inbound_endpoint
||
''
,
log
.
upstream_endpoint
||
''
,
getRequestTypeLabel
(
log
),
log
.
input_tokens
,
log
.
output_tokens
,
log
.
cache_read_tokens
,
log
.
cache_creation_tokens
,
log
.
input_cost
?.
toFixed
(
6
)
||
'
0.000000
'
,
log
.
output_cost
?.
toFixed
(
6
)
||
'
0.000000
'
,
log
.
cache_read_cost
?.
toFixed
(
6
)
||
'
0.000000
'
,
log
.
cache_creation_cost
?.
toFixed
(
6
)
||
'
0.000000
'
,
...
...
@@ -301,6 +337,7 @@ const allColumns = computed(() => [
{
key
:
'
account
'
,
label
:
t
(
'
admin.usage.account
'
),
sortable
:
false
},
{
key
:
'
model
'
,
label
:
t
(
'
usage.model
'
),
sortable
:
true
},
{
key
:
'
reasoning_effort
'
,
label
:
t
(
'
usage.reasoningEffort
'
),
sortable
:
false
},
{
key
:
'
endpoint
'
,
label
:
t
(
'
usage.endpoint
'
),
sortable
:
false
},
{
key
:
'
group
'
,
label
:
t
(
'
admin.usage.group
'
),
sortable
:
false
},
{
key
:
'
stream
'
,
label
:
t
(
'
usage.type
'
),
sortable
:
false
},
{
key
:
'
tokens
'
,
label
:
t
(
'
usage.tokens
'
),
sortable
:
false
},
...
...
@@ -343,12 +380,18 @@ const loadSavedColumns = () => {
try
{
const
saved
=
localStorage
.
getItem
(
HIDDEN_COLUMNS_KEY
)
if
(
saved
)
{
(
JSON
.
parse
(
saved
)
as
string
[]).
forEach
(
key
=>
hiddenColumns
.
add
(
key
))
(
JSON
.
parse
(
saved
)
as
string
[]).
forEach
((
key
)
=>
{
hiddenColumns
.
add
(
key
)
})
}
else
{
DEFAULT_HIDDEN_COLUMNS
.
forEach
(
key
=>
hiddenColumns
.
add
(
key
))
DEFAULT_HIDDEN_COLUMNS
.
forEach
((
key
)
=>
{
hiddenColumns
.
add
(
key
)
})
}
}
catch
{
DEFAULT_HIDDEN_COLUMNS
.
forEach
(
key
=>
hiddenColumns
.
add
(
key
))
DEFAULT_HIDDEN_COLUMNS
.
forEach
((
key
)
=>
{
hiddenColumns
.
add
(
key
)
})
}
}
...
...
frontend/src/views/user/UsageView.vue
View file @
3718d6dc
...
...
@@ -166,6 +166,12 @@
</span>
</
template
>
<
template
#cell-endpoint=
"{ row }"
>
<span
class=
"text-sm text-gray-600 dark:text-gray-300 block max-w-[320px] whitespace-normal break-all"
>
{{
formatUsageEndpoints
(
row
)
}}
</span>
</
template
>
<
template
#cell-stream=
"{ row }"
>
<span
class=
"inline-flex items-center rounded px-2 py-0.5 text-xs font-medium"
...
...
@@ -516,6 +522,7 @@ const columns = computed<Column[]>(() => [
{
key
:
'
api_key
'
,
label
:
t
(
'
usage.apiKeyFilter
'
),
sortable
:
false
},
{
key
:
'
model
'
,
label
:
t
(
'
usage.model
'
),
sortable
:
true
},
{
key
:
'
reasoning_effort
'
,
label
:
t
(
'
usage.reasoningEffort
'
),
sortable
:
false
},
{
key
:
'
endpoint
'
,
label
:
t
(
'
usage.endpoint
'
),
sortable
:
false
},
{
key
:
'
stream
'
,
label
:
t
(
'
usage.type
'
),
sortable
:
false
},
{
key
:
'
tokens
'
,
label
:
t
(
'
usage.tokens
'
),
sortable
:
false
},
{
key
:
'
cost
'
,
label
:
t
(
'
usage.cost
'
),
sortable
:
false
},
...
...
@@ -615,6 +622,11 @@ const getRequestTypeExportText = (log: UsageLog): string => {
return
'
Unknown
'
}
const
formatUsageEndpoints
=
(
log
:
UsageLog
):
string
=>
{
const
inbound
=
log
.
inbound_endpoint
?.
trim
()
return
inbound
||
'
-
'
}
const
formatTokens
=
(
value
:
number
):
string
=>
{
if
(
value
>=
1
_000_000_000
)
{
return
`
${(
value
/
1
_000_000_000
).
toFixed
(
2
)}
B`
...
...
@@ -789,6 +801,7 @@ const exportToCSV = async () => {
'
API Key Name
'
,
'
Model
'
,
'
Reasoning Effort
'
,
'
Inbound Endpoint
'
,
'
Type
'
,
'
Input Tokens
'
,
'
Output Tokens
'
,
...
...
@@ -806,6 +819,7 @@ const exportToCSV = async () => {
log
.
api_key
?.
name
||
''
,
log
.
model
,
formatReasoningEffort
(
log
.
reasoning_effort
),
log
.
inbound_endpoint
||
''
,
getRequestTypeExportText
(
log
),
log
.
input_tokens
,
log
.
output_tokens
,
...
...
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