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
186e3675
Unverified
Commit
186e3675
authored
Mar 21, 2026
by
Wesley Liddick
Committed by
GitHub
Mar 21, 2026
Browse files
Merge pull request #1194 from Ethan0x0000/feat/requested-upstream-model-semantics
feat(usage): 统一使用记录中的请求模型与上游模型语义
parents
421728a9
27948c77
Changes
33
Hide whitespace changes
Inline
Side-by-side
backend/internal/service/gemini_messages_compat_service.go
View file @
186e3675
...
...
@@ -1028,14 +1028,15 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex
}
return
&
ForwardResult
{
RequestID
:
requestID
,
Usage
:
*
usage
,
Model
:
originalModel
,
Stream
:
req
.
Stream
,
Duration
:
time
.
Since
(
startTime
),
FirstTokenMs
:
firstTokenMs
,
ImageCount
:
imageCount
,
ImageSize
:
imageSize
,
RequestID
:
requestID
,
Usage
:
*
usage
,
Model
:
originalModel
,
UpstreamModel
:
mappedModel
,
Stream
:
req
.
Stream
,
Duration
:
time
.
Since
(
startTime
),
FirstTokenMs
:
firstTokenMs
,
ImageCount
:
imageCount
,
ImageSize
:
imageSize
,
},
nil
}
...
...
@@ -1241,12 +1242,13 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin.
estimated
:=
estimateGeminiCountTokens
(
body
)
c
.
JSON
(
http
.
StatusOK
,
map
[
string
]
any
{
"totalTokens"
:
estimated
})
return
&
ForwardResult
{
RequestID
:
""
,
Usage
:
ClaudeUsage
{},
Model
:
originalModel
,
Stream
:
false
,
Duration
:
time
.
Since
(
startTime
),
FirstTokenMs
:
nil
,
RequestID
:
""
,
Usage
:
ClaudeUsage
{},
Model
:
originalModel
,
UpstreamModel
:
mappedModel
,
Stream
:
false
,
Duration
:
time
.
Since
(
startTime
),
FirstTokenMs
:
nil
,
},
nil
}
setOpsUpstreamError
(
c
,
0
,
safeErr
,
""
)
...
...
@@ -1310,12 +1312,13 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin.
estimated
:=
estimateGeminiCountTokens
(
body
)
c
.
JSON
(
http
.
StatusOK
,
map
[
string
]
any
{
"totalTokens"
:
estimated
})
return
&
ForwardResult
{
RequestID
:
""
,
Usage
:
ClaudeUsage
{},
Model
:
originalModel
,
Stream
:
false
,
Duration
:
time
.
Since
(
startTime
),
FirstTokenMs
:
nil
,
RequestID
:
""
,
Usage
:
ClaudeUsage
{},
Model
:
originalModel
,
UpstreamModel
:
mappedModel
,
Stream
:
false
,
Duration
:
time
.
Since
(
startTime
),
FirstTokenMs
:
nil
,
},
nil
}
// Final attempt: surface the upstream error body (passed through below) instead of a generic retry error.
...
...
@@ -1350,12 +1353,13 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin.
estimated
:=
estimateGeminiCountTokens
(
body
)
c
.
JSON
(
http
.
StatusOK
,
map
[
string
]
any
{
"totalTokens"
:
estimated
})
return
&
ForwardResult
{
RequestID
:
requestID
,
Usage
:
ClaudeUsage
{},
Model
:
originalModel
,
Stream
:
false
,
Duration
:
time
.
Since
(
startTime
),
FirstTokenMs
:
nil
,
RequestID
:
requestID
,
Usage
:
ClaudeUsage
{},
Model
:
originalModel
,
UpstreamModel
:
mappedModel
,
Stream
:
false
,
Duration
:
time
.
Since
(
startTime
),
FirstTokenMs
:
nil
,
},
nil
}
...
...
@@ -1527,14 +1531,15 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin.
}
return
&
ForwardResult
{
RequestID
:
requestID
,
Usage
:
*
usage
,
Model
:
originalModel
,
Stream
:
stream
,
Duration
:
time
.
Since
(
startTime
),
FirstTokenMs
:
firstTokenMs
,
ImageCount
:
imageCount
,
ImageSize
:
imageSize
,
RequestID
:
requestID
,
Usage
:
*
usage
,
Model
:
originalModel
,
UpstreamModel
:
mappedModel
,
Stream
:
stream
,
Duration
:
time
.
Since
(
startTime
),
FirstTokenMs
:
firstTokenMs
,
ImageCount
:
imageCount
,
ImageSize
:
imageSize
,
},
nil
}
...
...
backend/internal/service/gemini_messages_compat_service_test.go
View file @
186e3675
package
service
import
(
"context"
"encoding/json"
"fmt"
"io"
...
...
@@ -15,6 +16,30 @@ import (
"github.com/stretchr/testify/require"
)
type
geminiCompatHTTPUpstreamStub
struct
{
response
*
http
.
Response
err
error
calls
int
lastReq
*
http
.
Request
}
func
(
s
*
geminiCompatHTTPUpstreamStub
)
Do
(
req
*
http
.
Request
,
proxyURL
string
,
accountID
int64
,
accountConcurrency
int
)
(
*
http
.
Response
,
error
)
{
s
.
calls
++
s
.
lastReq
=
req
if
s
.
err
!=
nil
{
return
nil
,
s
.
err
}
if
s
.
response
==
nil
{
return
nil
,
fmt
.
Errorf
(
"missing stub response"
)
}
resp
:=
*
s
.
response
return
&
resp
,
nil
}
func
(
s
*
geminiCompatHTTPUpstreamStub
)
DoWithTLS
(
req
*
http
.
Request
,
proxyURL
string
,
accountID
int64
,
accountConcurrency
int
,
enableTLSFingerprint
bool
)
(
*
http
.
Response
,
error
)
{
return
s
.
Do
(
req
,
proxyURL
,
accountID
,
accountConcurrency
)
}
// TestConvertClaudeToolsToGeminiTools_CustomType 测试custom类型工具转换
func
TestConvertClaudeToolsToGeminiTools_CustomType
(
t
*
testing
.
T
)
{
tests
:=
[]
struct
{
...
...
@@ -170,6 +195,42 @@ func TestGeminiHandleNativeNonStreamingResponse_DebugDisabledDoesNotEmitHeaderLo
require
.
False
(
t
,
logSink
.
ContainsMessage
(
"[GeminiAPI]"
),
"debug 关闭时不应输出 Gemini 响应头日志"
)
}
func
TestGeminiMessagesCompatServiceForward_PreservesRequestedModelAndMappedUpstreamModel
(
t
*
testing
.
T
)
{
gin
.
SetMode
(
gin
.
TestMode
)
w
:=
httptest
.
NewRecorder
()
c
,
_
:=
gin
.
CreateTestContext
(
w
)
c
.
Request
=
httptest
.
NewRequest
(
http
.
MethodPost
,
"/v1/messages"
,
nil
)
httpStub
:=
&
geminiCompatHTTPUpstreamStub
{
response
:
&
http
.
Response
{
StatusCode
:
http
.
StatusOK
,
Header
:
http
.
Header
{
"x-request-id"
:
[]
string
{
"gemini-req-1"
}},
Body
:
io
.
NopCloser
(
strings
.
NewReader
(
`{"candidates":[{"content":{"parts":[{"text":"hello"}]}}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":5}}`
)),
},
}
svc
:=
&
GeminiMessagesCompatService
{
httpUpstream
:
httpStub
,
cfg
:
&
config
.
Config
{}}
account
:=
&
Account
{
ID
:
1
,
Type
:
AccountTypeAPIKey
,
Credentials
:
map
[
string
]
any
{
"api_key"
:
"test-key"
,
"model_mapping"
:
map
[
string
]
any
{
"claude-sonnet-4"
:
"claude-sonnet-4-20250514"
,
},
},
}
body
:=
[]
byte
(
`{"model":"claude-sonnet-4","max_tokens":16,"messages":[{"role":"user","content":"hello"}]}`
)
result
,
err
:=
svc
.
Forward
(
context
.
Background
(),
c
,
account
,
body
)
require
.
NoError
(
t
,
err
)
require
.
NotNil
(
t
,
result
)
require
.
Equal
(
t
,
"claude-sonnet-4"
,
result
.
Model
)
require
.
Equal
(
t
,
"claude-sonnet-4-20250514"
,
result
.
UpstreamModel
)
require
.
Equal
(
t
,
1
,
httpStub
.
calls
)
require
.
NotNil
(
t
,
httpStub
.
lastReq
)
require
.
Contains
(
t
,
httpStub
.
lastReq
.
URL
.
String
(),
"/models/claude-sonnet-4-20250514:"
)
}
func
TestConvertClaudeMessagesToGeminiGenerateContent_AddsThoughtSignatureForToolUse
(
t
*
testing
.
T
)
{
claudeReq
:=
map
[
string
]
any
{
"model"
:
"claude-haiku-4-5-20251001"
,
...
...
backend/internal/service/openai_gateway_record_usage_test.go
View file @
186e3675
...
...
@@ -879,6 +879,7 @@ func TestOpenAIGatewayServiceRecordUsage_UsesRequestedModelAndUpstreamModelMetad
require
.
NoError
(
t
,
err
)
require
.
NotNil
(
t
,
usageRepo
.
lastLog
)
require
.
Equal
(
t
,
"gpt-5.1"
,
usageRepo
.
lastLog
.
Model
)
require
.
Equal
(
t
,
"gpt-5.1"
,
usageRepo
.
lastLog
.
RequestedModel
)
require
.
NotNil
(
t
,
usageRepo
.
lastLog
.
UpstreamModel
)
require
.
Equal
(
t
,
"gpt-5.1-codex"
,
*
usageRepo
.
lastLog
.
UpstreamModel
)
require
.
NotNil
(
t
,
usageRepo
.
lastLog
.
ServiceTier
)
...
...
@@ -894,6 +895,40 @@ func TestOpenAIGatewayServiceRecordUsage_UsesRequestedModelAndUpstreamModelMetad
require
.
Equal
(
t
,
1
,
userRepo
.
deductCalls
)
}
func
TestOpenAIGatewayServiceRecordUsage_BillsMappedRequestsUsingUpstreamModelFallback
(
t
*
testing
.
T
)
{
usageRepo
:=
&
openAIRecordUsageLogRepoStub
{
inserted
:
true
}
userRepo
:=
&
openAIRecordUsageUserRepoStub
{}
subRepo
:=
&
openAIRecordUsageSubRepoStub
{}
svc
:=
newOpenAIRecordUsageServiceForTest
(
usageRepo
,
userRepo
,
subRepo
,
nil
)
usage
:=
OpenAIUsage
{
InputTokens
:
20
,
OutputTokens
:
10
}
expectedCost
,
err
:=
svc
.
billingService
.
CalculateCost
(
"gpt-5.1-codex"
,
UsageTokens
{
InputTokens
:
20
,
OutputTokens
:
10
,
},
1.1
)
require
.
NoError
(
t
,
err
)
err
=
svc
.
RecordUsage
(
context
.
Background
(),
&
OpenAIRecordUsageInput
{
Result
:
&
OpenAIForwardResult
{
RequestID
:
"resp_upstream_model_billing_fallback"
,
Model
:
"gpt-5.1"
,
UpstreamModel
:
"gpt-5.1-codex"
,
Usage
:
usage
,
Duration
:
time
.
Second
,
},
APIKey
:
&
APIKey
{
ID
:
10
},
User
:
&
User
{
ID
:
20
},
Account
:
&
Account
{
ID
:
30
},
})
require
.
NoError
(
t
,
err
)
require
.
NotNil
(
t
,
usageRepo
.
lastLog
)
require
.
Equal
(
t
,
"gpt-5.1"
,
usageRepo
.
lastLog
.
Model
)
require
.
Equal
(
t
,
expectedCost
.
ActualCost
,
usageRepo
.
lastLog
.
ActualCost
)
require
.
Equal
(
t
,
expectedCost
.
TotalCost
,
usageRepo
.
lastLog
.
TotalCost
)
require
.
Equal
(
t
,
expectedCost
.
ActualCost
,
userRepo
.
lastAmount
)
}
func
TestOpenAIGatewayServiceRecordUsage_SubscriptionBillingSetsSubscriptionFields
(
t
*
testing
.
T
)
{
usageRepo
:=
&
openAIRecordUsageLogRepoStub
{
inserted
:
true
}
userRepo
:=
&
openAIRecordUsageUserRepoStub
{}
...
...
backend/internal/service/openai_gateway_service.go
View file @
186e3675
...
...
@@ -4110,9 +4110,9 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec
multiplier
=
resolver
.
Resolve
(
ctx
,
user
.
ID
,
*
apiKey
.
GroupID
,
apiKey
.
Group
.
RateMultiplier
)
}
billingModel
:=
result
.
Model
billingModel
:=
forwardResultBillingModel
(
result
.
Model
,
result
.
UpstreamModel
)
if
result
.
BillingModel
!=
""
{
billingModel
=
result
.
BillingModel
billingModel
=
strings
.
TrimSpace
(
result
.
BillingModel
)
}
serviceTier
:=
""
if
result
.
ServiceTier
!=
nil
{
...
...
@@ -4140,6 +4140,7 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec
AccountID
:
account
.
ID
,
RequestID
:
requestID
,
Model
:
result
.
Model
,
RequestedModel
:
result
.
Model
,
UpstreamModel
:
optionalNonEqualStringPtr
(
result
.
UpstreamModel
,
result
.
Model
),
ServiceTier
:
result
.
ServiceTier
,
ReasoningEffort
:
result
.
ReasoningEffort
,
...
...
backend/internal/service/openai_ws_forwarder.go
View file @
186e3675
...
...
@@ -2328,6 +2328,7 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2(
RequestID
:
responseID
,
Usage
:
*
usage
,
Model
:
originalModel
,
UpstreamModel
:
mappedModel
,
ServiceTier
:
extractOpenAIServiceTier
(
reqBody
),
ReasoningEffort
:
extractOpenAIReasoningEffort
(
reqBody
,
originalModel
),
Stream
:
reqStream
,
...
...
@@ -2945,6 +2946,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
RequestID
:
responseID
,
Usage
:
usage
,
Model
:
originalModel
,
UpstreamModel
:
mappedModel
,
ServiceTier
:
extractOpenAIServiceTierFromBody
(
payload
),
ReasoningEffort
:
extractOpenAIReasoningEffortFromBody
(
payload
,
originalModel
),
Stream
:
reqStream
,
...
...
backend/internal/service/sora_gateway_service.go
View file @
186e3675
...
...
@@ -148,10 +148,13 @@ func (s *SoraGatewayService) Forward(ctx context.Context, c *gin.Context, accoun
s
.
writeSoraError
(
c
,
http
.
StatusBadRequest
,
"invalid_request_error"
,
"model is required"
,
clientStream
)
return
nil
,
errors
.
New
(
"model is required"
)
}
originalModel
:=
reqModel
mappedModel
:=
account
.
GetMappedModel
(
reqModel
)
var
upstreamModel
string
if
mappedModel
!=
""
&&
mappedModel
!=
reqModel
{
reqModel
=
mappedModel
upstreamModel
=
mappedModel
}
modelCfg
,
ok
:=
GetSoraModelConfig
(
reqModel
)
...
...
@@ -213,13 +216,14 @@ func (s *SoraGatewayService) Forward(ctx context.Context, c *gin.Context, accoun
c
.
JSON
(
http
.
StatusOK
,
buildSoraNonStreamResponse
(
content
,
reqModel
))
}
return
&
ForwardResult
{
RequestID
:
""
,
Model
:
reqModel
,
Stream
:
clientStream
,
Duration
:
time
.
Since
(
startTime
),
FirstTokenMs
:
firstTokenMs
,
Usage
:
ClaudeUsage
{},
MediaType
:
"prompt"
,
RequestID
:
""
,
Model
:
originalModel
,
UpstreamModel
:
upstreamModel
,
Stream
:
clientStream
,
Duration
:
time
.
Since
(
startTime
),
FirstTokenMs
:
firstTokenMs
,
Usage
:
ClaudeUsage
{},
MediaType
:
"prompt"
,
},
nil
}
...
...
@@ -269,13 +273,14 @@ func (s *SoraGatewayService) Forward(ctx context.Context, c *gin.Context, accoun
c
.
JSON
(
http
.
StatusOK
,
resp
)
}
return
&
ForwardResult
{
RequestID
:
""
,
Model
:
reqModel
,
Stream
:
clientStream
,
Duration
:
time
.
Since
(
startTime
),
FirstTokenMs
:
firstTokenMs
,
Usage
:
ClaudeUsage
{},
MediaType
:
"prompt"
,
RequestID
:
""
,
Model
:
originalModel
,
UpstreamModel
:
upstreamModel
,
Stream
:
clientStream
,
Duration
:
time
.
Since
(
startTime
),
FirstTokenMs
:
firstTokenMs
,
Usage
:
ClaudeUsage
{},
MediaType
:
"prompt"
,
},
nil
}
if
characterResult
!=
nil
&&
strings
.
TrimSpace
(
characterResult
.
Username
)
!=
""
{
...
...
@@ -419,16 +424,17 @@ func (s *SoraGatewayService) Forward(ctx context.Context, c *gin.Context, accoun
}
return
&
ForwardResult
{
RequestID
:
taskID
,
Model
:
reqModel
,
Stream
:
clientStream
,
Duration
:
time
.
Since
(
startTime
),
FirstTokenMs
:
firstTokenMs
,
Usage
:
ClaudeUsage
{},
MediaType
:
mediaType
,
MediaURL
:
firstMediaURL
(
finalURLs
),
ImageCount
:
imageCount
,
ImageSize
:
imageSize
,
RequestID
:
taskID
,
Model
:
originalModel
,
UpstreamModel
:
upstreamModel
,
Stream
:
clientStream
,
Duration
:
time
.
Since
(
startTime
),
FirstTokenMs
:
firstTokenMs
,
Usage
:
ClaudeUsage
{},
MediaType
:
mediaType
,
MediaURL
:
firstMediaURL
(
finalURLs
),
ImageCount
:
imageCount
,
ImageSize
:
imageSize
,
},
nil
}
...
...
backend/internal/service/sora_gateway_service_test.go
View file @
186e3675
...
...
@@ -144,6 +144,11 @@ func TestSoraGatewayService_ForwardPromptEnhance(t *testing.T) {
ID
:
1
,
Platform
:
PlatformSora
,
Status
:
StatusActive
,
Credentials
:
map
[
string
]
any
{
"model_mapping"
:
map
[
string
]
any
{
"prompt-enhance-short-10s"
:
"prompt-enhance-short-15s"
,
},
},
}
body
:=
[]
byte
(
`{"model":"prompt-enhance-short-10s","messages":[{"role":"user","content":"cat running"}],"stream":false}`
)
...
...
@@ -152,6 +157,7 @@ func TestSoraGatewayService_ForwardPromptEnhance(t *testing.T) {
require
.
NotNil
(
t
,
result
)
require
.
Equal
(
t
,
"prompt"
,
result
.
MediaType
)
require
.
Equal
(
t
,
"prompt-enhance-short-10s"
,
result
.
Model
)
require
.
Equal
(
t
,
"prompt-enhance-short-15s"
,
result
.
UpstreamModel
)
}
func
TestSoraGatewayService_ForwardStoryboardPrompt
(
t
*
testing
.
T
)
{
...
...
backend/internal/service/usage_log.go
View file @
186e3675
...
...
@@ -98,6 +98,9 @@ type UsageLog struct {
AccountID
int64
RequestID
string
Model
string
// RequestedModel is the client-requested model name recorded for stable user/admin display.
// Empty should be treated as Model for backward compatibility with historical rows.
RequestedModel
string
// UpstreamModel is the actual model sent to the upstream provider after mapping.
// Nil means no mapping was applied (requested model was used as-is).
UpstreamModel
*
string
...
...
backend/internal/service/usage_log_helpers.go
View file @
186e3675
...
...
@@ -19,3 +19,10 @@ func optionalNonEqualStringPtr(value, compare string) *string {
}
return
&
value
}
func
forwardResultBillingModel
(
requestedModel
,
upstreamModel
string
)
string
{
if
trimmedUpstream
:=
strings
.
TrimSpace
(
upstreamModel
);
trimmedUpstream
!=
""
{
return
trimmedUpstream
}
return
strings
.
TrimSpace
(
requestedModel
)
}
backend/migrations/077_add_usage_log_requested_model.sql
0 → 100644
View file @
186e3675
-- Add requested_model field to usage_logs for normalized request/upstream model tracking.
-- NULL means historical rows written before requested_model dual-write was introduced.
ALTER
TABLE
usage_logs
ADD
COLUMN
IF
NOT
EXISTS
requested_model
VARCHAR
(
100
);
backend/migrations/078_add_usage_log_requested_model_index_notx.sql
0 → 100644
View file @
186e3675
-- Support requested_model / upstream_model aggregations with time-range filters.
CREATE
INDEX
CONCURRENTLY
IF
NOT
EXISTS
idx_usage_logs_created_requested_model_upstream_model
ON
usage_logs
(
created_at
,
requested_model
,
upstream_model
);
frontend/src/components/admin/usage/__tests__/UsageTable.spec.ts
View file @
186e3675
...
...
@@ -39,6 +39,7 @@ const DataTableStub = {
template
:
`
<div>
<div v-for="row in data" :key="row.request_id">
<slot name="cell-model" :row="row" :value="row.model" />
<slot name="cell-cost" :row="row" />
</div>
</div>
...
...
@@ -108,4 +109,42 @@ describe('admin UsageTable tooltip', () => {
expect
(
text
).
toContain
(
'
$30.0000 / 1M tokens
'
)
expect
(
text
).
toContain
(
'
$0.069568
'
)
})
it
(
'
shows requested and upstream models separately for admin rows
'
,
()
=>
{
const
row
=
{
request_id
:
'
req-admin-model-1
'
,
model
:
'
claude-sonnet-4
'
,
upstream_model
:
'
claude-sonnet-4-20250514
'
,
actual_cost
:
0
,
total_cost
:
0
,
account_rate_multiplier
:
1
,
rate_multiplier
:
1
,
input_cost
:
0
,
output_cost
:
0
,
cache_creation_cost
:
0
,
cache_read_cost
:
0
,
input_tokens
:
0
,
output_tokens
:
0
,
}
const
wrapper
=
mount
(
UsageTable
,
{
props
:
{
data
:
[
row
],
loading
:
false
,
columns
:
[],
},
global
:
{
stubs
:
{
DataTable
:
DataTableStub
,
EmptyState
:
true
,
Icon
:
true
,
Teleport
:
true
,
},
},
})
const
text
=
wrapper
.
text
()
expect
(
text
).
toContain
(
'
claude-sonnet-4
'
)
expect
(
text
).
toContain
(
'
claude-sonnet-4-20250514
'
)
})
})
frontend/src/types/index.ts
View file @
186e3675
...
...
@@ -978,7 +978,6 @@ export interface UsageLog {
account_id
:
number
|
null
request_id
:
string
model
:
string
upstream_model
?:
string
|
null
service_tier
?:
string
|
null
reasoning_effort
?:
string
|
null
inbound_endpoint
?:
string
|
null
...
...
@@ -1033,6 +1032,8 @@ export interface UsageLogAccountSummary {
}
export
interface
AdminUsageLog
extends
UsageLog
{
upstream_model
?:
string
|
null
// 账号计费倍率(仅管理员可见)
account_rate_multiplier
?:
number
|
null
...
...
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