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
b5a3b3db
Commit
b5a3b3db
authored
Feb 14, 2026
by
yangjianbo
Browse files
Merge branch 'test' into release
parents
888f2936
9cafa46d
Changes
57
Show whitespace changes
Inline
Side-by-side
backend/internal/service/gemini_messages_compat_service_test.go
View file @
b5a3b3db
...
...
@@ -3,10 +3,15 @@ package service
import
(
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
...
...
@@ -133,6 +138,38 @@ func TestConvertClaudeToolsToGeminiTools_CustomType(t *testing.T) {
}
}
func
TestGeminiHandleNativeNonStreamingResponse_DebugDisabledDoesNotEmitHeaderLogs
(
t
*
testing
.
T
)
{
gin
.
SetMode
(
gin
.
TestMode
)
logSink
,
restore
:=
captureStructuredLog
(
t
)
defer
restore
()
svc
:=
&
GeminiMessagesCompatService
{
cfg
:
&
config
.
Config
{
Gateway
:
config
.
GatewayConfig
{
GeminiDebugResponseHeaders
:
false
,
},
},
}
w
:=
httptest
.
NewRecorder
()
c
,
_
:=
gin
.
CreateTestContext
(
w
)
c
.
Request
=
httptest
.
NewRequest
(
http
.
MethodPost
,
"/v1/messages"
,
nil
)
resp
:=
&
http
.
Response
{
StatusCode
:
http
.
StatusOK
,
Header
:
http
.
Header
{
"Content-Type"
:
[]
string
{
"application/json"
},
"X-RateLimit-Limit"
:
[]
string
{
"60"
},
},
Body
:
io
.
NopCloser
(
strings
.
NewReader
(
`{"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":2}}`
)),
}
usage
,
err
:=
svc
.
handleNativeNonStreamingResponse
(
c
,
resp
,
false
)
require
.
NoError
(
t
,
err
)
require
.
NotNil
(
t
,
usage
)
require
.
False
(
t
,
logSink
.
ContainsMessage
(
"[GeminiAPI]"
),
"debug 关闭时不应输出 Gemini 响应头日志"
)
}
func
TestConvertClaudeMessagesToGeminiGenerateContent_AddsThoughtSignatureForToolUse
(
t
*
testing
.
T
)
{
claudeReq
:=
map
[
string
]
any
{
"model"
:
"claude-haiku-4-5-20251001"
,
...
...
backend/internal/service/openai_gateway_service.go
View file @
b5a3b3db
...
...
@@ -313,7 +313,6 @@ func logCodexCLIOnlyDetection(ctx context.Context, c *gin.Context, account *Acco
}
log
:=
logger
.
FromContext
(
ctx
)
.
With
(
fields
...
)
if
result
.
Matched
{
log
.
Warn
(
"OpenAI codex_cli_only 允许官方客户端请求"
)
return
}
log
.
Warn
(
"OpenAI codex_cli_only 拒绝非官方客户端请求"
)
...
...
@@ -1277,6 +1276,29 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough(
startTime
time
.
Time
,
)
(
*
OpenAIForwardResult
,
error
)
{
if
account
!=
nil
&&
account
.
Type
==
AccountTypeOAuth
{
if
rejectReason
:=
detectOpenAIPassthroughInstructionsRejectReason
(
reqModel
,
body
);
rejectReason
!=
""
{
rejectMsg
:=
"OpenAI codex passthrough requires a non-empty instructions field"
setOpsUpstreamError
(
c
,
http
.
StatusForbidden
,
rejectMsg
,
""
)
appendOpsUpstreamError
(
c
,
OpsUpstreamErrorEvent
{
Platform
:
account
.
Platform
,
AccountID
:
account
.
ID
,
AccountName
:
account
.
Name
,
UpstreamStatusCode
:
http
.
StatusForbidden
,
Passthrough
:
true
,
Kind
:
"request_error"
,
Message
:
rejectMsg
,
Detail
:
rejectReason
,
})
logOpenAIPassthroughInstructionsRejected
(
ctx
,
c
,
account
,
reqModel
,
rejectReason
,
body
)
c
.
JSON
(
http
.
StatusForbidden
,
gin
.
H
{
"error"
:
gin
.
H
{
"type"
:
"forbidden_error"
,
"message"
:
rejectMsg
,
},
})
return
nil
,
fmt
.
Errorf
(
"openai passthrough rejected before upstream: %s"
,
rejectReason
)
}
normalizedBody
,
normalized
,
err
:=
normalizeOpenAIPassthroughOAuthBody
(
body
)
if
err
!=
nil
{
return
nil
,
err
...
...
@@ -1396,6 +1418,37 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough(
},
nil
}
func
logOpenAIPassthroughInstructionsRejected
(
ctx
context
.
Context
,
c
*
gin
.
Context
,
account
*
Account
,
reqModel
string
,
rejectReason
string
,
body
[]
byte
,
)
{
if
ctx
==
nil
{
ctx
=
context
.
Background
()
}
accountID
:=
int64
(
0
)
accountName
:=
""
accountType
:=
""
if
account
!=
nil
{
accountID
=
account
.
ID
accountName
=
strings
.
TrimSpace
(
account
.
Name
)
accountType
=
strings
.
TrimSpace
(
string
(
account
.
Type
))
}
fields
:=
[]
zap
.
Field
{
zap
.
String
(
"component"
,
"service.openai_gateway"
),
zap
.
Int64
(
"account_id"
,
accountID
),
zap
.
String
(
"account_name"
,
accountName
),
zap
.
String
(
"account_type"
,
accountType
),
zap
.
String
(
"request_model"
,
strings
.
TrimSpace
(
reqModel
)),
zap
.
String
(
"reject_reason"
,
strings
.
TrimSpace
(
rejectReason
)),
}
fields
=
appendCodexCLIOnlyRejectedRequestFields
(
fields
,
c
,
body
)
logger
.
FromContext
(
ctx
)
.
With
(
fields
...
)
.
Warn
(
"OpenAI passthrough 本地拦截:Codex 请求缺少有效 instructions"
)
}
func
(
s
*
OpenAIGatewayService
)
buildUpstreamRequestOpenAIPassthrough
(
ctx
context
.
Context
,
c
*
gin
.
Context
,
...
...
@@ -1688,8 +1741,18 @@ func (s *OpenAIGatewayService) handleNonStreamingResponsePassthrough(
resp
*
http
.
Response
,
c
*
gin
.
Context
,
)
(
*
OpenAIUsage
,
error
)
{
body
,
err
:=
io
.
ReadAll
(
resp
.
Body
)
maxBytes
:=
resolveUpstreamResponseReadLimit
(
s
.
cfg
)
body
,
err
:=
readUpstreamResponseBodyLimited
(
resp
.
Body
,
maxBytes
)
if
err
!=
nil
{
if
errors
.
Is
(
err
,
ErrUpstreamResponseBodyTooLarge
)
{
setOpsUpstreamError
(
c
,
http
.
StatusBadGateway
,
"upstream response too large"
,
""
)
c
.
JSON
(
http
.
StatusBadGateway
,
gin
.
H
{
"error"
:
gin
.
H
{
"type"
:
"upstream_error"
,
"message"
:
"Upstream response too large"
,
},
})
}
return
nil
,
err
}
...
...
@@ -2318,8 +2381,18 @@ func (s *OpenAIGatewayService) parseSSEUsage(data string, usage *OpenAIUsage) {
}
func
(
s
*
OpenAIGatewayService
)
handleNonStreamingResponse
(
ctx
context
.
Context
,
resp
*
http
.
Response
,
c
*
gin
.
Context
,
account
*
Account
,
originalModel
,
mappedModel
string
)
(
*
OpenAIUsage
,
error
)
{
body
,
err
:=
io
.
ReadAll
(
resp
.
Body
)
maxBytes
:=
resolveUpstreamResponseReadLimit
(
s
.
cfg
)
body
,
err
:=
readUpstreamResponseBodyLimited
(
resp
.
Body
,
maxBytes
)
if
err
!=
nil
{
if
errors
.
Is
(
err
,
ErrUpstreamResponseBodyTooLarge
)
{
setOpsUpstreamError
(
c
,
http
.
StatusBadGateway
,
"upstream response too large"
,
""
)
c
.
JSON
(
http
.
StatusBadGateway
,
gin
.
H
{
"error"
:
gin
.
H
{
"type"
:
"upstream_error"
,
"message"
:
"Upstream response too large"
,
},
})
}
return
nil
,
err
}
...
...
@@ -2877,6 +2950,25 @@ func normalizeOpenAIPassthroughOAuthBody(body []byte) ([]byte, bool, error) {
return
normalized
,
changed
,
nil
}
func
detectOpenAIPassthroughInstructionsRejectReason
(
reqModel
string
,
body
[]
byte
)
string
{
model
:=
strings
.
ToLower
(
strings
.
TrimSpace
(
reqModel
))
if
!
strings
.
Contains
(
model
,
"codex"
)
{
return
""
}
instructions
:=
gjson
.
GetBytes
(
body
,
"instructions"
)
if
!
instructions
.
Exists
()
{
return
"instructions_missing"
}
if
instructions
.
Type
!=
gjson
.
String
{
return
"instructions_not_string"
}
if
strings
.
TrimSpace
(
instructions
.
String
())
==
""
{
return
"instructions_empty"
}
return
""
}
func
extractOpenAIReasoningEffortFromBody
(
body
[]
byte
,
requestedModel
string
)
*
string
{
reasoningEffort
:=
strings
.
TrimSpace
(
gjson
.
GetBytes
(
body
,
"reasoning.effort"
)
.
String
())
if
reasoningEffort
==
""
{
...
...
backend/internal/service/openai_gateway_service_codex_cli_only_test.go
View file @
b5a3b3db
...
...
@@ -103,7 +103,7 @@ func TestLogCodexCLIOnlyDetection_NilSafety(t *testing.T) {
})
}
func
TestLogCodexCLIOnlyDetection_Logs
BothMatchedAnd
Rejected
(
t
*
testing
.
T
)
{
func
TestLogCodexCLIOnlyDetection_
Only
LogsRejected
(
t
*
testing
.
T
)
{
logSink
,
restore
:=
captureStructuredLog
(
t
)
defer
restore
()
...
...
@@ -119,7 +119,7 @@ func TestLogCodexCLIOnlyDetection_LogsBothMatchedAndRejected(t *testing.T) {
Reason
:
CodexClientRestrictionReasonNotMatchedUA
,
},
nil
)
require
.
Tru
e
(
t
,
logSink
.
ContainsMessage
(
"OpenAI codex_cli_only 允许官方客户端请求"
))
require
.
Fals
e
(
t
,
logSink
.
ContainsMessage
(
"OpenAI codex_cli_only 允许官方客户端请求"
))
require
.
True
(
t
,
logSink
.
ContainsMessage
(
"OpenAI codex_cli_only 拒绝非官方客户端请求"
))
}
...
...
@@ -131,7 +131,7 @@ func TestLogCodexCLIOnlyDetection_RejectedIncludesRequestDetails(t *testing.T) {
rec
:=
httptest
.
NewRecorder
()
c
,
_
:=
gin
.
CreateTestContext
(
rec
)
c
.
Request
=
httptest
.
NewRequest
(
http
.
MethodPost
,
"/v1/responses?trace=1"
,
bytes
.
NewReader
(
nil
))
c
.
Request
.
Header
.
Set
(
"User-Agent"
,
"c
url/8.0
"
)
c
.
Request
.
Header
.
Set
(
"User-Agent"
,
"c
odex_cli_rs/0.98.0 (Windows 10.0.19045; x86_64) unknown
"
)
c
.
Request
.
Header
.
Set
(
"Content-Type"
,
"application/json"
)
c
.
Request
.
Header
.
Set
(
"OpenAI-Beta"
,
"assistants=v2"
)
...
...
@@ -143,7 +143,7 @@ func TestLogCodexCLIOnlyDetection_RejectedIncludesRequestDetails(t *testing.T) {
Reason
:
CodexClientRestrictionReasonNotMatchedUA
,
},
body
)
require
.
True
(
t
,
logSink
.
ContainsFieldValue
(
"request_user_agent"
,
"c
url/8.0
"
))
require
.
True
(
t
,
logSink
.
ContainsFieldValue
(
"request_user_agent"
,
"c
odex_cli_rs/0.98.0 (Windows 10.0.19045; x86_64) unknown
"
))
require
.
True
(
t
,
logSink
.
ContainsFieldValue
(
"request_model"
,
"gpt-5.2"
))
require
.
True
(
t
,
logSink
.
ContainsFieldValue
(
"request_query"
,
"trace=1"
))
require
.
True
(
t
,
logSink
.
ContainsFieldValue
(
"request_prompt_cache_key_sha256"
,
hashSensitiveValueForLog
(
"pc-123"
)))
...
...
backend/internal/service/openai_oauth_passthrough_test.go
View file @
b5a3b3db
...
...
@@ -164,7 +164,7 @@ func TestOpenAIGatewayService_OAuthPassthrough_StreamKeepsToolNameAndBodyNormali
c
.
Request
.
Header
.
Set
(
"Proxy-Authorization"
,
"Basic abc"
)
c
.
Request
.
Header
.
Set
(
"X-Test"
,
"keep"
)
originalBody
:=
[]
byte
(
`{"model":"gpt-5.2","stream":true,"store":true,"input":[{"type":"text","text":"hi"}]}`
)
originalBody
:=
[]
byte
(
`{"model":"gpt-5.2","stream":true,"store":true,"
instructions":"local-test-instructions","
input":[{"type":"text","text":"hi"}]}`
)
upstreamSSE
:=
strings
.
Join
([]
string
{
`data: {"type":"response.output_item.added","item":{"type":"tool_call","tool_calls":[{"function":{"name":"apply_patch"}}]}}`
,
...
...
@@ -211,6 +211,7 @@ func TestOpenAIGatewayService_OAuthPassthrough_StreamKeepsToolNameAndBodyNormali
// 1) 透传 OAuth 请求体与旧链路关键行为保持一致:store=false + stream=true。
require
.
Equal
(
t
,
false
,
gjson
.
GetBytes
(
upstream
.
lastBody
,
"store"
)
.
Bool
())
require
.
Equal
(
t
,
true
,
gjson
.
GetBytes
(
upstream
.
lastBody
,
"stream"
)
.
Bool
())
require
.
Equal
(
t
,
"local-test-instructions"
,
strings
.
TrimSpace
(
gjson
.
GetBytes
(
upstream
.
lastBody
,
"instructions"
)
.
String
()))
// 其余关键字段保持原值。
require
.
Equal
(
t
,
"gpt-5.2"
,
gjson
.
GetBytes
(
upstream
.
lastBody
,
"model"
)
.
String
())
require
.
Equal
(
t
,
"hi"
,
gjson
.
GetBytes
(
upstream
.
lastBody
,
"input.0.text"
)
.
String
())
...
...
@@ -235,6 +236,59 @@ func TestOpenAIGatewayService_OAuthPassthrough_StreamKeepsToolNameAndBodyNormali
require
.
NotContains
(
t
,
body
,
"
\"
name
\"
:
\"
edit
\"
"
)
}
func
TestOpenAIGatewayService_OAuthPassthrough_CodexMissingInstructionsRejectedBeforeUpstream
(
t
*
testing
.
T
)
{
gin
.
SetMode
(
gin
.
TestMode
)
logSink
,
restore
:=
captureStructuredLog
(
t
)
defer
restore
()
rec
:=
httptest
.
NewRecorder
()
c
,
_
:=
gin
.
CreateTestContext
(
rec
)
c
.
Request
=
httptest
.
NewRequest
(
http
.
MethodPost
,
"/v1/responses?trace=1"
,
bytes
.
NewReader
(
nil
))
c
.
Request
.
Header
.
Set
(
"User-Agent"
,
"codex_cli_rs/0.98.0 (Windows 10.0.19045; x86_64) unknown"
)
c
.
Request
.
Header
.
Set
(
"Content-Type"
,
"application/json"
)
c
.
Request
.
Header
.
Set
(
"OpenAI-Beta"
,
"responses=experimental"
)
// Codex 模型且缺少 instructions,应在本地直接 403 拒绝,不触达上游。
originalBody
:=
[]
byte
(
`{"model":"gpt-5.1-codex-max","stream":false,"store":true,"input":[{"type":"text","text":"hi"}]}`
)
upstream
:=
&
httpUpstreamRecorder
{
resp
:
&
http
.
Response
{
StatusCode
:
http
.
StatusOK
,
Header
:
http
.
Header
{
"Content-Type"
:
[]
string
{
"application/json"
},
"x-request-id"
:
[]
string
{
"rid"
}},
Body
:
io
.
NopCloser
(
strings
.
NewReader
(
`{"output":[],"usage":{"input_tokens":1,"output_tokens":1}}`
)),
},
}
svc
:=
&
OpenAIGatewayService
{
cfg
:
&
config
.
Config
{
Gateway
:
config
.
GatewayConfig
{
ForceCodexCLI
:
false
}},
httpUpstream
:
upstream
,
}
account
:=
&
Account
{
ID
:
123
,
Name
:
"acc"
,
Platform
:
PlatformOpenAI
,
Type
:
AccountTypeOAuth
,
Concurrency
:
1
,
Credentials
:
map
[
string
]
any
{
"access_token"
:
"oauth-token"
,
"chatgpt_account_id"
:
"chatgpt-acc"
},
Extra
:
map
[
string
]
any
{
"openai_passthrough"
:
true
},
Status
:
StatusActive
,
Schedulable
:
true
,
RateMultiplier
:
f64p
(
1
),
}
result
,
err
:=
svc
.
Forward
(
context
.
Background
(),
c
,
account
,
originalBody
)
require
.
Error
(
t
,
err
)
require
.
Nil
(
t
,
result
)
require
.
Equal
(
t
,
http
.
StatusForbidden
,
rec
.
Code
)
require
.
Contains
(
t
,
rec
.
Body
.
String
(),
"requires a non-empty instructions field"
)
require
.
Nil
(
t
,
upstream
.
lastReq
)
require
.
True
(
t
,
logSink
.
ContainsMessage
(
"OpenAI passthrough 本地拦截:Codex 请求缺少有效 instructions"
))
require
.
True
(
t
,
logSink
.
ContainsFieldValue
(
"request_user_agent"
,
"codex_cli_rs/0.98.0 (Windows 10.0.19045; x86_64) unknown"
))
require
.
True
(
t
,
logSink
.
ContainsFieldValue
(
"reject_reason"
,
"instructions_missing"
))
}
func
TestOpenAIGatewayService_OAuthPassthrough_DisabledUsesLegacyTransform
(
t
*
testing
.
T
)
{
gin
.
SetMode
(
gin
.
TestMode
)
...
...
backend/internal/service/upstream_response_limit.go
0 → 100644
View file @
b5a3b3db
package
service
import
(
"errors"
"fmt"
"io"
"github.com/Wei-Shaw/sub2api/internal/config"
)
var
ErrUpstreamResponseBodyTooLarge
=
errors
.
New
(
"upstream response body too large"
)
const
defaultUpstreamResponseReadMaxBytes
int64
=
8
*
1024
*
1024
func
resolveUpstreamResponseReadLimit
(
cfg
*
config
.
Config
)
int64
{
if
cfg
!=
nil
&&
cfg
.
Gateway
.
UpstreamResponseReadMaxBytes
>
0
{
return
cfg
.
Gateway
.
UpstreamResponseReadMaxBytes
}
return
defaultUpstreamResponseReadMaxBytes
}
func
readUpstreamResponseBodyLimited
(
reader
io
.
Reader
,
maxBytes
int64
)
([]
byte
,
error
)
{
if
reader
==
nil
{
return
nil
,
errors
.
New
(
"response body is nil"
)
}
if
maxBytes
<=
0
{
maxBytes
=
defaultUpstreamResponseReadMaxBytes
}
body
,
err
:=
io
.
ReadAll
(
io
.
LimitReader
(
reader
,
maxBytes
+
1
))
if
err
!=
nil
{
return
nil
,
err
}
if
int64
(
len
(
body
))
>
maxBytes
{
return
nil
,
fmt
.
Errorf
(
"%w: limit=%d"
,
ErrUpstreamResponseBodyTooLarge
,
maxBytes
)
}
return
body
,
nil
}
backend/internal/service/upstream_response_limit_test.go
0 → 100644
View file @
b5a3b3db
package
service
import
(
"bytes"
"errors"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/stretchr/testify/require"
)
func
TestResolveUpstreamResponseReadLimit
(
t
*
testing
.
T
)
{
t
.
Run
(
"use default when config missing"
,
func
(
t
*
testing
.
T
)
{
require
.
Equal
(
t
,
defaultUpstreamResponseReadMaxBytes
,
resolveUpstreamResponseReadLimit
(
nil
))
})
t
.
Run
(
"use configured value"
,
func
(
t
*
testing
.
T
)
{
cfg
:=
&
config
.
Config
{}
cfg
.
Gateway
.
UpstreamResponseReadMaxBytes
=
1234
require
.
Equal
(
t
,
int64
(
1234
),
resolveUpstreamResponseReadLimit
(
cfg
))
})
}
func
TestReadUpstreamResponseBodyLimited
(
t
*
testing
.
T
)
{
t
.
Run
(
"within limit"
,
func
(
t
*
testing
.
T
)
{
body
,
err
:=
readUpstreamResponseBodyLimited
(
bytes
.
NewReader
([]
byte
(
"ok"
)),
2
)
require
.
NoError
(
t
,
err
)
require
.
Equal
(
t
,
[]
byte
(
"ok"
),
body
)
})
t
.
Run
(
"exceeds limit"
,
func
(
t
*
testing
.
T
)
{
body
,
err
:=
readUpstreamResponseBodyLimited
(
bytes
.
NewReader
([]
byte
(
"toolong"
)),
3
)
require
.
Nil
(
t
,
body
)
require
.
Error
(
t
,
err
)
require
.
True
(
t
,
errors
.
Is
(
err
,
ErrUpstreamResponseBodyTooLarge
))
})
}
deploy/config.example.yaml
View file @
b5a3b3db
...
...
@@ -146,6 +146,15 @@ gateway:
# Max request body size in bytes (default: 100MB)
# 请求体最大字节数(默认 100MB)
max_body_size
:
104857600
# Max bytes to read for non-stream upstream responses (default: 8MB)
# 非流式上游响应体读取上限(默认 8MB)
upstream_response_read_max_bytes
:
8388608
# Max bytes to read for proxy probe responses (default: 1MB)
# 代理探测响应体读取上限(默认 1MB)
proxy_probe_response_read_max_bytes
:
1048576
# Enable Gemini upstream response header debug logs (default: false)
# 是否开启 Gemini 上游响应头调试日志(默认 false)
gemini_debug_response_headers
:
false
# Sora max request body size in bytes (0=use max_body_size)
# Sora 请求体最大字节数(0=使用 max_body_size)
sora_max_body_size
:
268435456
...
...
frontend/src/App.vue
View file @
b5a3b3db
...
...
@@ -39,16 +39,6 @@ watch(
{
immediate
:
true
}
)
watch
(
()
=>
appStore
.
siteName
,
(
newName
)
=>
{
if
(
newName
)
{
document
.
title
=
`
${
newName
}
- AI API Gateway`
}
},
{
immediate
:
true
}
)
// Watch for authentication state and manage subscription data
watch
(
()
=>
authStore
.
isAuthenticated
,
...
...
frontend/src/__tests__/integration/data-import.spec.ts
View file @
b5a3b3db
...
...
@@ -58,12 +58,16 @@ describe('ImportDataModal', () => {
const
input
=
wrapper
.
find
(
'
input[type="file"]
'
)
const
file
=
new
File
([
'
invalid json
'
],
'
data.json
'
,
{
type
:
'
application/json
'
})
Object
.
defineProperty
(
file
,
'
text
'
,
{
value
:
()
=>
Promise
.
resolve
(
'
invalid json
'
)
})
Object
.
defineProperty
(
input
.
element
,
'
files
'
,
{
value
:
[
file
]
})
await
input
.
trigger
(
'
change
'
)
await
wrapper
.
find
(
'
form
'
).
trigger
(
'
submit
'
)
await
Promise
.
resolve
()
expect
(
showError
).
toHaveBeenCalledWith
(
'
admin.accounts.dataImportParseFailed
'
)
})
...
...
frontend/src/__tests__/integration/proxy-data-import.spec.ts
View file @
b5a3b3db
...
...
@@ -58,12 +58,16 @@ describe('Proxy ImportDataModal', () => {
const
input
=
wrapper
.
find
(
'
input[type="file"]
'
)
const
file
=
new
File
([
'
invalid json
'
],
'
data.json
'
,
{
type
:
'
application/json
'
})
Object
.
defineProperty
(
file
,
'
text
'
,
{
value
:
()
=>
Promise
.
resolve
(
'
invalid json
'
)
})
Object
.
defineProperty
(
input
.
element
,
'
files
'
,
{
value
:
[
file
]
})
await
input
.
trigger
(
'
change
'
)
await
wrapper
.
find
(
'
form
'
).
trigger
(
'
submit
'
)
await
Promise
.
resolve
()
expect
(
showError
).
toHaveBeenCalledWith
(
'
admin.proxies.dataImportParseFailed
'
)
})
...
...
frontend/src/api/admin/accounts.ts
View file @
b5a3b3db
...
...
@@ -164,10 +164,10 @@ export async function getUsage(id: number): Promise<AccountUsageInfo> {
/**
* Clear account rate limit status
* @param id - Account ID
* @returns
Success confirmation
* @returns
Updated account
*/
export
async
function
clearRateLimit
(
id
:
number
):
Promise
<
{
message
:
string
}
>
{
const
{
data
}
=
await
apiClient
.
post
<
{
message
:
string
}
>
(
export
async
function
clearRateLimit
(
id
:
number
):
Promise
<
Account
>
{
const
{
data
}
=
await
apiClient
.
post
<
Account
>
(
`/admin/accounts/
${
id
}
/clear-rate-limit`
)
return
data
...
...
frontend/src/components/account/BulkEditAccountModal.vue
View file @
b5a3b3db
...
...
@@ -209,7 +209,7 @@
<
div
v
-
if
=
"
modelMappings.length > 0
"
class
=
"
mb-3 space-y-2
"
>
<
div
v
-
for
=
"
(mapping, index) in modelMappings
"
:
key
=
"
index
"
:
key
=
"
getModelMappingKey(mapping)
"
class
=
"
flex items-center gap-2
"
>
<
input
...
...
@@ -654,6 +654,7 @@ import Select from '@/components/common/Select.vue'
import
ProxySelector
from
'
@/components/common/ProxySelector.vue
'
import
GroupSelector
from
'
@/components/common/GroupSelector.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
{
createStableObjectKeyResolver
}
from
'
@/utils/stableObjectKey
'
interface
Props
{
show
:
boolean
...
...
@@ -695,6 +696,7 @@ const baseUrl = ref('')
const
modelRestrictionMode
=
ref
<
'
whitelist
'
|
'
mapping
'
>
(
'
whitelist
'
)
const
allowedModels
=
ref
<
string
[]
>
([])
const
modelMappings
=
ref
<
ModelMapping
[]
>
([])
const
getModelMappingKey
=
createStableObjectKeyResolver
<
ModelMapping
>
(
'
bulk-model-mapping
'
)
const
selectedErrorCodes
=
ref
<
number
[]
>
([])
const
customErrorCodeInput
=
ref
<
number
|
null
>
(
null
)
const
interceptWarmupRequests
=
ref
(
false
)
...
...
frontend/src/components/account/CreateAccountModal.vue
View file @
b5a3b3db
...
...
@@ -714,7 +714,7 @@
<div
v-if=
"antigravityModelMappings.length > 0"
class=
"mb-3 space-y-2"
>
<div
v-for=
"(mapping, index) in antigravityModelMappings"
:key=
"
index
"
:key=
"
getAntigravityModelMappingKey(mapping)
"
class=
"space-y-1"
>
<div
class=
"flex items-center gap-2"
>
...
...
@@ -966,7 +966,7 @@
<
div
v
-
if
=
"
modelMappings.length > 0
"
class
=
"
mb-3 space-y-2
"
>
<
div
v
-
for
=
"
(mapping, index) in modelMappings
"
:
key
=
"
index
"
:
key
=
"
getModelMappingKey(mapping)
"
class
=
"
flex items-center gap-2
"
>
<
input
...
...
@@ -1225,7 +1225,7 @@
<
div
v
-
if
=
"
tempUnschedRules.length > 0
"
class
=
"
space-y-3
"
>
<
div
v
-
for
=
"
(rule, index) in tempUnschedRules
"
:
key
=
"
index
"
:
key
=
"
getTempUnschedRuleKey(rule)
"
class
=
"
rounded-lg border border-gray-200 p-3 dark:border-dark-600
"
>
<
div
class
=
"
mb-2 flex items-center justify-between
"
>
...
...
@@ -2097,6 +2097,7 @@ import ProxySelector from '@/components/common/ProxySelector.vue'
import
GroupSelector
from
'
@/components/common/GroupSelector.vue
'
import
ModelWhitelistSelector
from
'
@/components/account/ModelWhitelistSelector.vue
'
import
{
formatDateTimeLocalInput
,
parseDateTimeLocalInput
}
from
'
@/utils/format
'
import
{
createStableObjectKeyResolver
}
from
'
@/utils/stableObjectKey
'
import
OAuthAuthorizationFlow
from
'
./OAuthAuthorizationFlow.vue
'
// Type for exposed OAuthAuthorizationFlow component
...
...
@@ -2227,6 +2228,9 @@ const antigravityModelMappings = ref<ModelMapping[]>([])
const
antigravityPresetMappings
=
computed
(()
=>
getPresetMappingsByPlatform
(
'
antigravity
'
))
const
tempUnschedEnabled
=
ref
(
false
)
const
tempUnschedRules
=
ref
<
TempUnschedRuleForm
[]
>
([])
const
getModelMappingKey
=
createStableObjectKeyResolver
<
ModelMapping
>
(
'
create-model-mapping
'
)
const
getAntigravityModelMappingKey
=
createStableObjectKeyResolver
<
ModelMapping
>
(
'
create-antigravity-model-mapping
'
)
const
getTempUnschedRuleKey
=
createStableObjectKeyResolver
<
TempUnschedRuleForm
>
(
'
create-temp-unsched-rule
'
)
const
geminiOAuthType
=
ref
<
'
code_assist
'
|
'
google_one
'
|
'
ai_studio
'
>
(
'
google_one
'
)
const
geminiAIStudioOAuthEnabled
=
ref
(
false
)
...
...
frontend/src/components/account/EditAccountModal.vue
View file @
b5a3b3db
...
...
@@ -169,7 +169,7 @@
<
div
v
-
if
=
"
modelMappings.length > 0
"
class
=
"
mb-3 space-y-2
"
>
<
div
v
-
for
=
"
(mapping, index) in modelMappings
"
:
key
=
"
index
"
:
key
=
"
getModelMappingKey(mapping)
"
class
=
"
flex items-center gap-2
"
>
<
input
...
...
@@ -417,7 +417,7 @@
<
div
v
-
if
=
"
antigravityModelMappings.length > 0
"
class
=
"
mb-3 space-y-2
"
>
<
div
v
-
for
=
"
(mapping, index) in antigravityModelMappings
"
:
key
=
"
index
"
:
key
=
"
getAntigravityModelMappingKey(mapping)
"
class
=
"
space-y-1
"
>
<
div
class
=
"
flex items-center gap-2
"
>
...
...
@@ -542,7 +542,7 @@
<
div
v
-
if
=
"
tempUnschedRules.length > 0
"
class
=
"
space-y-3
"
>
<
div
v
-
for
=
"
(rule, index) in tempUnschedRules
"
:
key
=
"
index
"
:
key
=
"
getTempUnschedRuleKey(rule)
"
class
=
"
rounded-lg border border-gray-200 p-3 dark:border-dark-600
"
>
<
div
class
=
"
mb-2 flex items-center justify-between
"
>
...
...
@@ -1093,6 +1093,7 @@ import ProxySelector from '@/components/common/ProxySelector.vue'
import
GroupSelector
from
'
@/components/common/GroupSelector.vue
'
import
ModelWhitelistSelector
from
'
@/components/account/ModelWhitelistSelector.vue
'
import
{
formatDateTimeLocalInput
,
parseDateTimeLocalInput
}
from
'
@/utils/format
'
import
{
createStableObjectKeyResolver
}
from
'
@/utils/stableObjectKey
'
import
{
getPresetMappingsByPlatform
,
commonErrorCodes
,
...
...
@@ -1110,7 +1111,7 @@ interface Props {
const
props
=
defineProps
<
Props
>
()
const
emit
=
defineEmits
<
{
close
:
[]
updated
:
[]
updated
:
[
account
:
Account
]
}
>
()
const
{
t
}
=
useI18n
()
...
...
@@ -1158,6 +1159,9 @@ const antigravityWhitelistModels = ref<string[]>([])
const
antigravityModelMappings
=
ref
<
ModelMapping
[]
>
([])
const
tempUnschedEnabled
=
ref
(
false
)
const
tempUnschedRules
=
ref
<
TempUnschedRuleForm
[]
>
([])
const
getModelMappingKey
=
createStableObjectKeyResolver
<
ModelMapping
>
(
'
edit-model-mapping
'
)
const
getAntigravityModelMappingKey
=
createStableObjectKeyResolver
<
ModelMapping
>
(
'
edit-antigravity-model-mapping
'
)
const
getTempUnschedRuleKey
=
createStableObjectKeyResolver
<
TempUnschedRuleForm
>
(
'
edit-temp-unsched-rule
'
)
// Mixed channel warning dialog state
const
showMixedChannelWarning
=
ref
(
false
)
...
...
@@ -1845,9 +1849,9 @@ const handleSubmit = async () => {
updatePayload
.
extra
=
newExtra
}
await
adminAPI
.
accounts
.
update
(
props
.
account
.
id
,
updatePayload
)
const
updatedAccount
=
await
adminAPI
.
accounts
.
update
(
props
.
account
.
id
,
updatePayload
)
appStore
.
showSuccess
(
t
(
'
admin.accounts.accountUpdated
'
))
emit
(
'
updated
'
)
emit
(
'
updated
'
,
updatedAccount
)
handleClose
()
}
catch
(
error
:
any
)
{
// Handle 409 mixed_channel_warning - show confirmation dialog
...
...
@@ -1875,9 +1879,9 @@ const handleMixedChannelConfirm = async () => {
pendingUpdatePayload
.
value
.
confirm_mixed_channel_risk
=
true
submitting
.
value
=
true
try
{
await
adminAPI
.
accounts
.
update
(
props
.
account
.
id
,
pendingUpdatePayload
.
value
)
const
updatedAccount
=
await
adminAPI
.
accounts
.
update
(
props
.
account
.
id
,
pendingUpdatePayload
.
value
)
appStore
.
showSuccess
(
t
(
'
admin.accounts.accountUpdated
'
))
emit
(
'
updated
'
)
emit
(
'
updated
'
,
updatedAccount
)
handleClose
()
}
catch
(
error
:
any
)
{
appStore
.
showError
(
error
.
response
?.
data
?.
message
||
error
.
response
?.
data
?.
detail
||
t
(
'
admin.accounts.failedToUpdate
'
))
...
...
frontend/src/components/admin/account/ImportDataModal.vue
View file @
b5a3b3db
...
...
@@ -143,6 +143,24 @@ const handleClose = () => {
emit
(
'
close
'
)
}
const
readFileAsText
=
async
(
sourceFile
:
File
):
Promise
<
string
>
=>
{
if
(
typeof
sourceFile
.
text
===
'
function
'
)
{
return
sourceFile
.
text
()
}
if
(
typeof
sourceFile
.
arrayBuffer
===
'
function
'
)
{
const
buffer
=
await
sourceFile
.
arrayBuffer
()
return
new
TextDecoder
().
decode
(
buffer
)
}
return
await
new
Promise
<
string
>
((
resolve
,
reject
)
=>
{
const
reader
=
new
FileReader
()
reader
.
onload
=
()
=>
resolve
(
String
(
reader
.
result
??
''
))
reader
.
onerror
=
()
=>
reject
(
reader
.
error
||
new
Error
(
'
Failed to read file
'
))
reader
.
readAsText
(
sourceFile
)
})
}
const
handleImport
=
async
()
=>
{
if
(
!
file
.
value
)
{
appStore
.
showError
(
t
(
'
admin.accounts.dataImportSelectFile
'
))
...
...
@@ -151,7 +169,7 @@ const handleImport = async () => {
importing
.
value
=
true
try
{
const
text
=
await
file
.
value
.
text
(
)
const
text
=
await
readFileAsText
(
file
.
value
)
const
dataPayload
=
JSON
.
parse
(
text
)
const
res
=
await
adminAPI
.
accounts
.
importData
({
...
...
frontend/src/components/admin/account/ReAuthAccountModal.vue
View file @
b5a3b3db
...
...
@@ -216,7 +216,7 @@ interface Props {
const
props
=
defineProps
<
Props
>
()
const
emit
=
defineEmits
<
{
close
:
[]
reauthorized
:
[]
reauthorized
:
[
account
:
Account
]
}
>
()
const
appStore
=
useAppStore
()
...
...
@@ -370,10 +370,10 @@ const handleExchangeCode = async () => {
})
// Clear error status after successful re-authorization
await
adminAPI
.
accounts
.
clearError
(
props
.
account
.
id
)
const
updatedAccount
=
await
adminAPI
.
accounts
.
clearError
(
props
.
account
.
id
)
appStore
.
showSuccess
(
t
(
'
admin.accounts.reAuthorizedSuccess
'
))
emit
(
'
reauthorized
'
)
emit
(
'
reauthorized
'
,
updatedAccount
)
handleClose
()
}
catch
(
error
:
any
)
{
openaiOAuth
.
error
.
value
=
error
.
response
?.
data
?.
detail
||
t
(
'
admin.accounts.oauth.authFailed
'
)
...
...
@@ -404,9 +404,9 @@ const handleExchangeCode = async () => {
type
:
'
oauth
'
,
credentials
})
await
adminAPI
.
accounts
.
clearError
(
props
.
account
.
id
)
const
updatedAccount
=
await
adminAPI
.
accounts
.
clearError
(
props
.
account
.
id
)
appStore
.
showSuccess
(
t
(
'
admin.accounts.reAuthorizedSuccess
'
))
emit
(
'
reauthorized
'
)
emit
(
'
reauthorized
'
,
updatedAccount
)
handleClose
()
}
catch
(
error
:
any
)
{
geminiOAuth
.
error
.
value
=
error
.
response
?.
data
?.
detail
||
t
(
'
admin.accounts.oauth.authFailed
'
)
...
...
@@ -436,9 +436,9 @@ const handleExchangeCode = async () => {
type
:
'
oauth
'
,
credentials
})
await
adminAPI
.
accounts
.
clearError
(
props
.
account
.
id
)
const
updatedAccount
=
await
adminAPI
.
accounts
.
clearError
(
props
.
account
.
id
)
appStore
.
showSuccess
(
t
(
'
admin.accounts.reAuthorizedSuccess
'
))
emit
(
'
reauthorized
'
)
emit
(
'
reauthorized
'
,
updatedAccount
)
handleClose
()
}
catch
(
error
:
any
)
{
antigravityOAuth
.
error
.
value
=
error
.
response
?.
data
?.
detail
||
t
(
'
admin.accounts.oauth.authFailed
'
)
...
...
@@ -475,10 +475,10 @@ const handleExchangeCode = async () => {
})
// Clear error status after successful re-authorization
await
adminAPI
.
accounts
.
clearError
(
props
.
account
.
id
)
const
updatedAccount
=
await
adminAPI
.
accounts
.
clearError
(
props
.
account
.
id
)
appStore
.
showSuccess
(
t
(
'
admin.accounts.reAuthorizedSuccess
'
))
emit
(
'
reauthorized
'
)
emit
(
'
reauthorized
'
,
updatedAccount
)
handleClose
()
}
catch
(
error
:
any
)
{
claudeOAuth
.
error
.
value
=
error
.
response
?.
data
?.
detail
||
t
(
'
admin.accounts.oauth.authFailed
'
)
...
...
@@ -518,10 +518,10 @@ const handleCookieAuth = async (sessionKey: string) => {
})
// Clear error status after successful re-authorization
await
adminAPI
.
accounts
.
clearError
(
props
.
account
.
id
)
const
updatedAccount
=
await
adminAPI
.
accounts
.
clearError
(
props
.
account
.
id
)
appStore
.
showSuccess
(
t
(
'
admin.accounts.reAuthorizedSuccess
'
))
emit
(
'
reauthorized
'
)
emit
(
'
reauthorized
'
,
updatedAccount
)
handleClose
()
}
catch
(
error
:
any
)
{
claudeOAuth
.
error
.
value
=
...
...
frontend/src/components/admin/proxy/ImportDataModal.vue
View file @
b5a3b3db
...
...
@@ -143,6 +143,24 @@ const handleClose = () => {
emit
(
'
close
'
)
}
const
readFileAsText
=
async
(
sourceFile
:
File
):
Promise
<
string
>
=>
{
if
(
typeof
sourceFile
.
text
===
'
function
'
)
{
return
sourceFile
.
text
()
}
if
(
typeof
sourceFile
.
arrayBuffer
===
'
function
'
)
{
const
buffer
=
await
sourceFile
.
arrayBuffer
()
return
new
TextDecoder
().
decode
(
buffer
)
}
return
await
new
Promise
<
string
>
((
resolve
,
reject
)
=>
{
const
reader
=
new
FileReader
()
reader
.
onload
=
()
=>
resolve
(
String
(
reader
.
result
??
''
))
reader
.
onerror
=
()
=>
reject
(
reader
.
error
||
new
Error
(
'
Failed to read file
'
))
reader
.
readAsText
(
sourceFile
)
})
}
const
handleImport
=
async
()
=>
{
if
(
!
file
.
value
)
{
appStore
.
showError
(
t
(
'
admin.proxies.dataImportSelectFile
'
))
...
...
@@ -151,7 +169,7 @@ const handleImport = async () => {
importing
.
value
=
true
try
{
const
text
=
await
file
.
value
.
text
(
)
const
text
=
await
readFileAsText
(
file
.
value
)
const
dataPayload
=
JSON
.
parse
(
text
)
const
res
=
await
adminAPI
.
proxies
.
importData
({
data
:
dataPayload
})
...
...
frontend/src/components/common/DataTable.vue
View file @
b5a3b3db
...
...
@@ -3,7 +3,7 @@
<template
v-if=
"loading"
>
<div
v-for=
"i in 5"
:key=
"i"
class=
"rounded-lg border border-gray-200 bg-white p-4 dark:border-dark-700 dark:bg-dark-900"
>
<div
class=
"space-y-3"
>
<div
v-for=
"column in
c
olumns
.filter(c => c.key !== 'actions')
"
:key=
"column.key"
class=
"flex justify-between"
>
<div
v-for=
"column in
dataC
olumns"
:key=
"column.key"
class=
"flex justify-between"
>
<div
class=
"h-4 w-20 animate-pulse rounded bg-gray-200 dark:bg-dark-700"
></div>
<div
class=
"h-4 w-32 animate-pulse rounded bg-gray-200 dark:bg-dark-700"
></div>
</div>
...
...
@@ -39,7 +39,7 @@
>
<div
class=
"space-y-3"
>
<div
v-for=
"column in
c
olumns
.filter(c => c.key !== 'actions')
"
v-for=
"column in
dataC
olumns"
:key=
"column.key"
class=
"flex items-start justify-between gap-4"
>
...
...
@@ -439,10 +439,15 @@ const resolveRowKey = (row: any, index: number) => {
return
key
??
index
}
const
dataColumns
=
computed
(()
=>
props
.
columns
.
filter
((
column
)
=>
column
.
key
!==
'
actions
'
))
const
columnsSignature
=
computed
(()
=>
props
.
columns
.
map
((
column
)
=>
`
${
column
.
key
}
:
${
column
.
sortable
?
'
1
'
:
'
0
'
}
`
).
join
(
'
|
'
)
)
// 数据/列变化时重新检查滚动状态
// 注意:不能监听 actionsExpanded,因为 checkActionsColumnWidth 会临时修改它,会导致无限循环
watch
(
[()
=>
props
.
data
.
length
,
()
=>
props
.
columns
],
[()
=>
props
.
data
.
length
,
columnsSignature
],
async
()
=>
{
await
nextTick
()
checkScrollable
()
...
...
@@ -555,7 +560,7 @@ onMounted(() => {
})
watch
(
()
=>
props
.
columns
,
columnsSignature
,
()
=>
{
// If current sort key is no longer sortable/visible, fall back to default/persisted.
const
normalized
=
normalizeSortKey
(
sortKey
.
value
)
...
...
@@ -575,7 +580,7 @@ watch(
}
}
},
{
deep
:
true
}
{
flush
:
'
post
'
}
)
watch
(
...
...
frontend/src/components/common/LocaleSwitcher.vue
View file @
b5a3b3db
...
...
@@ -2,6 +2,7 @@
<div
class=
"relative"
ref=
"dropdownRef"
>
<button
@
click=
"toggleDropdown"
:disabled=
"switching"
class=
"flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
:title=
"currentLocale?.name"
>
...
...
@@ -23,6 +24,7 @@
<button
v-for=
"locale in availableLocales"
:key=
"locale.code"
:disabled=
"switching"
@
click=
"selectLocale(locale.code)"
class=
"flex w-full items-center gap-2 px-3 py-2 text-sm text-gray-700 transition-colors hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-dark-700"
:class=
"
{
...
...
@@ -49,6 +51,7 @@ const { locale } = useI18n()
const
isOpen
=
ref
(
false
)
const
dropdownRef
=
ref
<
HTMLElement
|
null
>
(
null
)
const
switching
=
ref
(
false
)
const
currentLocaleCode
=
computed
(()
=>
locale
.
value
)
const
currentLocale
=
computed
(()
=>
availableLocales
.
find
((
l
)
=>
l
.
code
===
locale
.
value
))
...
...
@@ -57,9 +60,18 @@ function toggleDropdown() {
isOpen
.
value
=
!
isOpen
.
value
}
function
selectLocale
(
code
:
string
)
{
se
tLocale
(
c
ode
)
async
function
selectLocale
(
code
:
string
)
{
if
(
switching
.
value
||
code
===
curren
tLocale
C
ode
.
value
)
{
isOpen
.
value
=
false
return
}
switching
.
value
=
true
try
{
await
setLocale
(
code
)
isOpen
.
value
=
false
}
finally
{
switching
.
value
=
false
}
}
function
handleClickOutside
(
event
:
MouseEvent
)
{
...
...
frontend/src/components/common/Pagination.vue
View file @
b5a3b3db
...
...
@@ -84,8 +84,8 @@
<!--
Page
numbers
-->
<
button
v
-
for
=
"
pageNum in visiblePages
"
:
key
=
"
pageNum
"
v
-
for
=
"
(
pageNum
, index)
in visiblePages
"
:
key
=
"
`${
pageNum
}
-${index
}
`
"
@
click
=
"
typeof pageNum === 'number' && goToPage(pageNum)
"
:
disabled
=
"
typeof pageNum !== 'number'
"
:
class
=
"
[
...
...
Prev
1
2
3
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