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
"backend/vscode:/vscode.git/clone" did not exist on "202ec21babe4daa7b3dac983d64bcea6ae1c3bca"
Commit
b5a3b3db
authored
Feb 14, 2026
by
yangjianbo
Browse files
Merge branch 'test' into release
parents
888f2936
9cafa46d
Changes
57
Hide whitespace changes
Inline
Side-by-side
backend/internal/service/gemini_messages_compat_service_test.go
View file @
b5a3b3db
...
@@ -3,10 +3,15 @@ package service
...
@@ -3,10 +3,15 @@ package service
import
(
import
(
"encoding/json"
"encoding/json"
"fmt"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"strings"
"testing"
"testing"
"time"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/require"
)
)
...
@@ -133,6 +138,38 @@ func TestConvertClaudeToolsToGeminiTools_CustomType(t *testing.T) {
...
@@ -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
)
{
func
TestConvertClaudeMessagesToGeminiGenerateContent_AddsThoughtSignatureForToolUse
(
t
*
testing
.
T
)
{
claudeReq
:=
map
[
string
]
any
{
claudeReq
:=
map
[
string
]
any
{
"model"
:
"claude-haiku-4-5-20251001"
,
"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
...
@@ -313,7 +313,6 @@ func logCodexCLIOnlyDetection(ctx context.Context, c *gin.Context, account *Acco
}
}
log
:=
logger
.
FromContext
(
ctx
)
.
With
(
fields
...
)
log
:=
logger
.
FromContext
(
ctx
)
.
With
(
fields
...
)
if
result
.
Matched
{
if
result
.
Matched
{
log
.
Warn
(
"OpenAI codex_cli_only 允许官方客户端请求"
)
return
return
}
}
log
.
Warn
(
"OpenAI codex_cli_only 拒绝非官方客户端请求"
)
log
.
Warn
(
"OpenAI codex_cli_only 拒绝非官方客户端请求"
)
...
@@ -1277,6 +1276,29 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough(
...
@@ -1277,6 +1276,29 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough(
startTime
time
.
Time
,
startTime
time
.
Time
,
)
(
*
OpenAIForwardResult
,
error
)
{
)
(
*
OpenAIForwardResult
,
error
)
{
if
account
!=
nil
&&
account
.
Type
==
AccountTypeOAuth
{
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
)
normalizedBody
,
normalized
,
err
:=
normalizeOpenAIPassthroughOAuthBody
(
body
)
if
err
!=
nil
{
if
err
!=
nil
{
return
nil
,
err
return
nil
,
err
...
@@ -1396,6 +1418,37 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough(
...
@@ -1396,6 +1418,37 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough(
},
nil
},
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
(
func
(
s
*
OpenAIGatewayService
)
buildUpstreamRequestOpenAIPassthrough
(
ctx
context
.
Context
,
ctx
context
.
Context
,
c
*
gin
.
Context
,
c
*
gin
.
Context
,
...
@@ -1688,8 +1741,18 @@ func (s *OpenAIGatewayService) handleNonStreamingResponsePassthrough(
...
@@ -1688,8 +1741,18 @@ func (s *OpenAIGatewayService) handleNonStreamingResponsePassthrough(
resp
*
http
.
Response
,
resp
*
http
.
Response
,
c
*
gin
.
Context
,
c
*
gin
.
Context
,
)
(
*
OpenAIUsage
,
error
)
{
)
(
*
OpenAIUsage
,
error
)
{
body
,
err
:=
io
.
ReadAll
(
resp
.
Body
)
maxBytes
:=
resolveUpstreamResponseReadLimit
(
s
.
cfg
)
body
,
err
:=
readUpstreamResponseBodyLimited
(
resp
.
Body
,
maxBytes
)
if
err
!=
nil
{
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
return
nil
,
err
}
}
...
@@ -2318,8 +2381,18 @@ func (s *OpenAIGatewayService) parseSSEUsage(data string, usage *OpenAIUsage) {
...
@@ -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
)
{
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
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
return
nil
,
err
}
}
...
@@ -2877,6 +2950,25 @@ func normalizeOpenAIPassthroughOAuthBody(body []byte) ([]byte, bool, error) {
...
@@ -2877,6 +2950,25 @@ func normalizeOpenAIPassthroughOAuthBody(body []byte) ([]byte, bool, error) {
return
normalized
,
changed
,
nil
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
{
func
extractOpenAIReasoningEffortFromBody
(
body
[]
byte
,
requestedModel
string
)
*
string
{
reasoningEffort
:=
strings
.
TrimSpace
(
gjson
.
GetBytes
(
body
,
"reasoning.effort"
)
.
String
())
reasoningEffort
:=
strings
.
TrimSpace
(
gjson
.
GetBytes
(
body
,
"reasoning.effort"
)
.
String
())
if
reasoningEffort
==
""
{
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) {
...
@@ -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
)
logSink
,
restore
:=
captureStructuredLog
(
t
)
defer
restore
()
defer
restore
()
...
@@ -119,7 +119,7 @@ func TestLogCodexCLIOnlyDetection_LogsBothMatchedAndRejected(t *testing.T) {
...
@@ -119,7 +119,7 @@ func TestLogCodexCLIOnlyDetection_LogsBothMatchedAndRejected(t *testing.T) {
Reason
:
CodexClientRestrictionReasonNotMatchedUA
,
Reason
:
CodexClientRestrictionReasonNotMatchedUA
,
},
nil
)
},
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 拒绝非官方客户端请求"
))
require
.
True
(
t
,
logSink
.
ContainsMessage
(
"OpenAI codex_cli_only 拒绝非官方客户端请求"
))
}
}
...
@@ -131,7 +131,7 @@ func TestLogCodexCLIOnlyDetection_RejectedIncludesRequestDetails(t *testing.T) {
...
@@ -131,7 +131,7 @@ func TestLogCodexCLIOnlyDetection_RejectedIncludesRequestDetails(t *testing.T) {
rec
:=
httptest
.
NewRecorder
()
rec
:=
httptest
.
NewRecorder
()
c
,
_
:=
gin
.
CreateTestContext
(
rec
)
c
,
_
:=
gin
.
CreateTestContext
(
rec
)
c
.
Request
=
httptest
.
NewRequest
(
http
.
MethodPost
,
"/v1/responses?trace=1"
,
bytes
.
NewReader
(
nil
))
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
(
"Content-Type"
,
"application/json"
)
c
.
Request
.
Header
.
Set
(
"OpenAI-Beta"
,
"assistants=v2"
)
c
.
Request
.
Header
.
Set
(
"OpenAI-Beta"
,
"assistants=v2"
)
...
@@ -143,7 +143,7 @@ func TestLogCodexCLIOnlyDetection_RejectedIncludesRequestDetails(t *testing.T) {
...
@@ -143,7 +143,7 @@ func TestLogCodexCLIOnlyDetection_RejectedIncludesRequestDetails(t *testing.T) {
Reason
:
CodexClientRestrictionReasonNotMatchedUA
,
Reason
:
CodexClientRestrictionReasonNotMatchedUA
,
},
body
)
},
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_model"
,
"gpt-5.2"
))
require
.
True
(
t
,
logSink
.
ContainsFieldValue
(
"request_query"
,
"trace=1"
))
require
.
True
(
t
,
logSink
.
ContainsFieldValue
(
"request_query"
,
"trace=1"
))
require
.
True
(
t
,
logSink
.
ContainsFieldValue
(
"request_prompt_cache_key_sha256"
,
hashSensitiveValueForLog
(
"pc-123"
)))
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
...
@@ -164,7 +164,7 @@ func TestOpenAIGatewayService_OAuthPassthrough_StreamKeepsToolNameAndBodyNormali
c
.
Request
.
Header
.
Set
(
"Proxy-Authorization"
,
"Basic abc"
)
c
.
Request
.
Header
.
Set
(
"Proxy-Authorization"
,
"Basic abc"
)
c
.
Request
.
Header
.
Set
(
"X-Test"
,
"keep"
)
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
{
upstreamSSE
:=
strings
.
Join
([]
string
{
`data: {"type":"response.output_item.added","item":{"type":"tool_call","tool_calls":[{"function":{"name":"apply_patch"}}]}}`
,
`data: {"type":"response.output_item.added","item":{"type":"tool_call","tool_calls":[{"function":{"name":"apply_patch"}}]}}`
,
...
@@ -211,6 +211,7 @@ func TestOpenAIGatewayService_OAuthPassthrough_StreamKeepsToolNameAndBodyNormali
...
@@ -211,6 +211,7 @@ func TestOpenAIGatewayService_OAuthPassthrough_StreamKeepsToolNameAndBodyNormali
// 1) 透传 OAuth 请求体与旧链路关键行为保持一致:store=false + stream=true。
// 1) 透传 OAuth 请求体与旧链路关键行为保持一致:store=false + stream=true。
require
.
Equal
(
t
,
false
,
gjson
.
GetBytes
(
upstream
.
lastBody
,
"store"
)
.
Bool
())
require
.
Equal
(
t
,
false
,
gjson
.
GetBytes
(
upstream
.
lastBody
,
"store"
)
.
Bool
())
require
.
Equal
(
t
,
true
,
gjson
.
GetBytes
(
upstream
.
lastBody
,
"stream"
)
.
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
,
"gpt-5.2"
,
gjson
.
GetBytes
(
upstream
.
lastBody
,
"model"
)
.
String
())
require
.
Equal
(
t
,
"hi"
,
gjson
.
GetBytes
(
upstream
.
lastBody
,
"input.0.text"
)
.
String
())
require
.
Equal
(
t
,
"hi"
,
gjson
.
GetBytes
(
upstream
.
lastBody
,
"input.0.text"
)
.
String
())
...
@@ -235,6 +236,59 @@ func TestOpenAIGatewayService_OAuthPassthrough_StreamKeepsToolNameAndBodyNormali
...
@@ -235,6 +236,59 @@ func TestOpenAIGatewayService_OAuthPassthrough_StreamKeepsToolNameAndBodyNormali
require
.
NotContains
(
t
,
body
,
"
\"
name
\"
:
\"
edit
\"
"
)
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
)
{
func
TestOpenAIGatewayService_OAuthPassthrough_DisabledUsesLegacyTransform
(
t
*
testing
.
T
)
{
gin
.
SetMode
(
gin
.
TestMode
)
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:
...
@@ -146,6 +146,15 @@ gateway:
# Max request body size in bytes (default: 100MB)
# Max request body size in bytes (default: 100MB)
# 请求体最大字节数(默认 100MB)
# 请求体最大字节数(默认 100MB)
max_body_size
:
104857600
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 max request body size in bytes (0=use max_body_size)
# Sora 请求体最大字节数(0=使用 max_body_size)
# Sora 请求体最大字节数(0=使用 max_body_size)
sora_max_body_size
:
268435456
sora_max_body_size
:
268435456
...
...
frontend/src/App.vue
View file @
b5a3b3db
...
@@ -39,16 +39,6 @@ watch(
...
@@ -39,16 +39,6 @@ watch(
{
immediate
:
true
}
{
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 for authentication state and manage subscription data
watch
(
watch
(
()
=>
authStore
.
isAuthenticated
,
()
=>
authStore
.
isAuthenticated
,
...
...
frontend/src/__tests__/integration/data-import.spec.ts
View file @
b5a3b3db
...
@@ -58,12 +58,16 @@ describe('ImportDataModal', () => {
...
@@ -58,12 +58,16 @@ describe('ImportDataModal', () => {
const
input
=
wrapper
.
find
(
'
input[type="file"]
'
)
const
input
=
wrapper
.
find
(
'
input[type="file"]
'
)
const
file
=
new
File
([
'
invalid json
'
],
'
data.json
'
,
{
type
:
'
application/json
'
})
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
'
,
{
Object
.
defineProperty
(
input
.
element
,
'
files
'
,
{
value
:
[
file
]
value
:
[
file
]
})
})
await
input
.
trigger
(
'
change
'
)
await
input
.
trigger
(
'
change
'
)
await
wrapper
.
find
(
'
form
'
).
trigger
(
'
submit
'
)
await
wrapper
.
find
(
'
form
'
).
trigger
(
'
submit
'
)
await
Promise
.
resolve
()
expect
(
showError
).
toHaveBeenCalledWith
(
'
admin.accounts.dataImportParseFailed
'
)
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', () => {
...
@@ -58,12 +58,16 @@ describe('Proxy ImportDataModal', () => {
const
input
=
wrapper
.
find
(
'
input[type="file"]
'
)
const
input
=
wrapper
.
find
(
'
input[type="file"]
'
)
const
file
=
new
File
([
'
invalid json
'
],
'
data.json
'
,
{
type
:
'
application/json
'
})
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
'
,
{
Object
.
defineProperty
(
input
.
element
,
'
files
'
,
{
value
:
[
file
]
value
:
[
file
]
})
})
await
input
.
trigger
(
'
change
'
)
await
input
.
trigger
(
'
change
'
)
await
wrapper
.
find
(
'
form
'
).
trigger
(
'
submit
'
)
await
wrapper
.
find
(
'
form
'
).
trigger
(
'
submit
'
)
await
Promise
.
resolve
()
expect
(
showError
).
toHaveBeenCalledWith
(
'
admin.proxies.dataImportParseFailed
'
)
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> {
...
@@ -164,10 +164,10 @@ export async function getUsage(id: number): Promise<AccountUsageInfo> {
/**
/**
* Clear account rate limit status
* Clear account rate limit status
* @param id - Account ID
* @param id - Account ID
* @returns
Success confirmation
* @returns
Updated account
*/
*/
export
async
function
clearRateLimit
(
id
:
number
):
Promise
<
{
message
:
string
}
>
{
export
async
function
clearRateLimit
(
id
:
number
):
Promise
<
Account
>
{
const
{
data
}
=
await
apiClient
.
post
<
{
message
:
string
}
>
(
const
{
data
}
=
await
apiClient
.
post
<
Account
>
(
`/admin/accounts/
${
id
}
/clear-rate-limit`
`/admin/accounts/
${
id
}
/clear-rate-limit`
)
)
return
data
return
data
...
...
frontend/src/components/account/BulkEditAccountModal.vue
View file @
b5a3b3db
...
@@ -209,7 +209,7 @@
...
@@ -209,7 +209,7 @@
<
div
v
-
if
=
"
modelMappings.length > 0
"
class
=
"
mb-3 space-y-2
"
>
<
div
v
-
if
=
"
modelMappings.length > 0
"
class
=
"
mb-3 space-y-2
"
>
<
div
<
div
v
-
for
=
"
(mapping, index) in modelMappings
"
v
-
for
=
"
(mapping, index) in modelMappings
"
:
key
=
"
index
"
:
key
=
"
getModelMappingKey(mapping)
"
class
=
"
flex items-center gap-2
"
class
=
"
flex items-center gap-2
"
>
>
<
input
<
input
...
@@ -654,6 +654,7 @@ import Select from '@/components/common/Select.vue'
...
@@ -654,6 +654,7 @@ import Select from '@/components/common/Select.vue'
import
ProxySelector
from
'
@/components/common/ProxySelector.vue
'
import
ProxySelector
from
'
@/components/common/ProxySelector.vue
'
import
GroupSelector
from
'
@/components/common/GroupSelector.vue
'
import
GroupSelector
from
'
@/components/common/GroupSelector.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
{
createStableObjectKeyResolver
}
from
'
@/utils/stableObjectKey
'
interface
Props
{
interface
Props
{
show
:
boolean
show
:
boolean
...
@@ -695,6 +696,7 @@ const baseUrl = ref('')
...
@@ -695,6 +696,7 @@ const baseUrl = ref('')
const
modelRestrictionMode
=
ref
<
'
whitelist
'
|
'
mapping
'
>
(
'
whitelist
'
)
const
modelRestrictionMode
=
ref
<
'
whitelist
'
|
'
mapping
'
>
(
'
whitelist
'
)
const
allowedModels
=
ref
<
string
[]
>
([])
const
allowedModels
=
ref
<
string
[]
>
([])
const
modelMappings
=
ref
<
ModelMapping
[]
>
([])
const
modelMappings
=
ref
<
ModelMapping
[]
>
([])
const
getModelMappingKey
=
createStableObjectKeyResolver
<
ModelMapping
>
(
'
bulk-model-mapping
'
)
const
selectedErrorCodes
=
ref
<
number
[]
>
([])
const
selectedErrorCodes
=
ref
<
number
[]
>
([])
const
customErrorCodeInput
=
ref
<
number
|
null
>
(
null
)
const
customErrorCodeInput
=
ref
<
number
|
null
>
(
null
)
const
interceptWarmupRequests
=
ref
(
false
)
const
interceptWarmupRequests
=
ref
(
false
)
...
...
frontend/src/components/account/CreateAccountModal.vue
View file @
b5a3b3db
...
@@ -714,7 +714,7 @@
...
@@ -714,7 +714,7 @@
<div
v-if=
"antigravityModelMappings.length > 0"
class=
"mb-3 space-y-2"
>
<div
v-if=
"antigravityModelMappings.length > 0"
class=
"mb-3 space-y-2"
>
<div
<div
v-for=
"(mapping, index) in antigravityModelMappings"
v-for=
"(mapping, index) in antigravityModelMappings"
:key=
"
index
"
:key=
"
getAntigravityModelMappingKey(mapping)
"
class=
"space-y-1"
class=
"space-y-1"
>
>
<div
class=
"flex items-center gap-2"
>
<div
class=
"flex items-center gap-2"
>
...
@@ -966,7 +966,7 @@
...
@@ -966,7 +966,7 @@
<
div
v
-
if
=
"
modelMappings.length > 0
"
class
=
"
mb-3 space-y-2
"
>
<
div
v
-
if
=
"
modelMappings.length > 0
"
class
=
"
mb-3 space-y-2
"
>
<
div
<
div
v
-
for
=
"
(mapping, index) in modelMappings
"
v
-
for
=
"
(mapping, index) in modelMappings
"
:
key
=
"
index
"
:
key
=
"
getModelMappingKey(mapping)
"
class
=
"
flex items-center gap-2
"
class
=
"
flex items-center gap-2
"
>
>
<
input
<
input
...
@@ -1225,7 +1225,7 @@
...
@@ -1225,7 +1225,7 @@
<
div
v
-
if
=
"
tempUnschedRules.length > 0
"
class
=
"
space-y-3
"
>
<
div
v
-
if
=
"
tempUnschedRules.length > 0
"
class
=
"
space-y-3
"
>
<
div
<
div
v
-
for
=
"
(rule, index) in tempUnschedRules
"
v
-
for
=
"
(rule, index) in tempUnschedRules
"
:
key
=
"
index
"
:
key
=
"
getTempUnschedRuleKey(rule)
"
class
=
"
rounded-lg border border-gray-200 p-3 dark:border-dark-600
"
class
=
"
rounded-lg border border-gray-200 p-3 dark:border-dark-600
"
>
>
<
div
class
=
"
mb-2 flex items-center justify-between
"
>
<
div
class
=
"
mb-2 flex items-center justify-between
"
>
...
@@ -2097,6 +2097,7 @@ import ProxySelector from '@/components/common/ProxySelector.vue'
...
@@ -2097,6 +2097,7 @@ import ProxySelector from '@/components/common/ProxySelector.vue'
import
GroupSelector
from
'
@/components/common/GroupSelector.vue
'
import
GroupSelector
from
'
@/components/common/GroupSelector.vue
'
import
ModelWhitelistSelector
from
'
@/components/account/ModelWhitelistSelector.vue
'
import
ModelWhitelistSelector
from
'
@/components/account/ModelWhitelistSelector.vue
'
import
{
formatDateTimeLocalInput
,
parseDateTimeLocalInput
}
from
'
@/utils/format
'
import
{
formatDateTimeLocalInput
,
parseDateTimeLocalInput
}
from
'
@/utils/format
'
import
{
createStableObjectKeyResolver
}
from
'
@/utils/stableObjectKey
'
import
OAuthAuthorizationFlow
from
'
./OAuthAuthorizationFlow.vue
'
import
OAuthAuthorizationFlow
from
'
./OAuthAuthorizationFlow.vue
'
// Type for exposed OAuthAuthorizationFlow component
// Type for exposed OAuthAuthorizationFlow component
...
@@ -2227,6 +2228,9 @@ const antigravityModelMappings = ref<ModelMapping[]>([])
...
@@ -2227,6 +2228,9 @@ const antigravityModelMappings = ref<ModelMapping[]>([])
const
antigravityPresetMappings
=
computed
(()
=>
getPresetMappingsByPlatform
(
'
antigravity
'
))
const
antigravityPresetMappings
=
computed
(()
=>
getPresetMappingsByPlatform
(
'
antigravity
'
))
const
tempUnschedEnabled
=
ref
(
false
)
const
tempUnschedEnabled
=
ref
(
false
)
const
tempUnschedRules
=
ref
<
TempUnschedRuleForm
[]
>
([])
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
geminiOAuthType
=
ref
<
'
code_assist
'
|
'
google_one
'
|
'
ai_studio
'
>
(
'
google_one
'
)
const
geminiAIStudioOAuthEnabled
=
ref
(
false
)
const
geminiAIStudioOAuthEnabled
=
ref
(
false
)
...
...
frontend/src/components/account/EditAccountModal.vue
View file @
b5a3b3db
...
@@ -169,7 +169,7 @@
...
@@ -169,7 +169,7 @@
<
div
v
-
if
=
"
modelMappings.length > 0
"
class
=
"
mb-3 space-y-2
"
>
<
div
v
-
if
=
"
modelMappings.length > 0
"
class
=
"
mb-3 space-y-2
"
>
<
div
<
div
v
-
for
=
"
(mapping, index) in modelMappings
"
v
-
for
=
"
(mapping, index) in modelMappings
"
:
key
=
"
index
"
:
key
=
"
getModelMappingKey(mapping)
"
class
=
"
flex items-center gap-2
"
class
=
"
flex items-center gap-2
"
>
>
<
input
<
input
...
@@ -417,7 +417,7 @@
...
@@ -417,7 +417,7 @@
<
div
v
-
if
=
"
antigravityModelMappings.length > 0
"
class
=
"
mb-3 space-y-2
"
>
<
div
v
-
if
=
"
antigravityModelMappings.length > 0
"
class
=
"
mb-3 space-y-2
"
>
<
div
<
div
v
-
for
=
"
(mapping, index) in antigravityModelMappings
"
v
-
for
=
"
(mapping, index) in antigravityModelMappings
"
:
key
=
"
index
"
:
key
=
"
getAntigravityModelMappingKey(mapping)
"
class
=
"
space-y-1
"
class
=
"
space-y-1
"
>
>
<
div
class
=
"
flex items-center gap-2
"
>
<
div
class
=
"
flex items-center gap-2
"
>
...
@@ -542,7 +542,7 @@
...
@@ -542,7 +542,7 @@
<
div
v
-
if
=
"
tempUnschedRules.length > 0
"
class
=
"
space-y-3
"
>
<
div
v
-
if
=
"
tempUnschedRules.length > 0
"
class
=
"
space-y-3
"
>
<
div
<
div
v
-
for
=
"
(rule, index) in tempUnschedRules
"
v
-
for
=
"
(rule, index) in tempUnschedRules
"
:
key
=
"
index
"
:
key
=
"
getTempUnschedRuleKey(rule)
"
class
=
"
rounded-lg border border-gray-200 p-3 dark:border-dark-600
"
class
=
"
rounded-lg border border-gray-200 p-3 dark:border-dark-600
"
>
>
<
div
class
=
"
mb-2 flex items-center justify-between
"
>
<
div
class
=
"
mb-2 flex items-center justify-between
"
>
...
@@ -1093,6 +1093,7 @@ import ProxySelector from '@/components/common/ProxySelector.vue'
...
@@ -1093,6 +1093,7 @@ import ProxySelector from '@/components/common/ProxySelector.vue'
import
GroupSelector
from
'
@/components/common/GroupSelector.vue
'
import
GroupSelector
from
'
@/components/common/GroupSelector.vue
'
import
ModelWhitelistSelector
from
'
@/components/account/ModelWhitelistSelector.vue
'
import
ModelWhitelistSelector
from
'
@/components/account/ModelWhitelistSelector.vue
'
import
{
formatDateTimeLocalInput
,
parseDateTimeLocalInput
}
from
'
@/utils/format
'
import
{
formatDateTimeLocalInput
,
parseDateTimeLocalInput
}
from
'
@/utils/format
'
import
{
createStableObjectKeyResolver
}
from
'
@/utils/stableObjectKey
'
import
{
import
{
getPresetMappingsByPlatform
,
getPresetMappingsByPlatform
,
commonErrorCodes
,
commonErrorCodes
,
...
@@ -1110,7 +1111,7 @@ interface Props {
...
@@ -1110,7 +1111,7 @@ interface Props {
const
props
=
defineProps
<
Props
>
()
const
props
=
defineProps
<
Props
>
()
const
emit
=
defineEmits
<
{
const
emit
=
defineEmits
<
{
close
:
[]
close
:
[]
updated
:
[]
updated
:
[
account
:
Account
]
}
>
()
}
>
()
const
{
t
}
=
useI18n
()
const
{
t
}
=
useI18n
()
...
@@ -1158,6 +1159,9 @@ const antigravityWhitelistModels = ref<string[]>([])
...
@@ -1158,6 +1159,9 @@ const antigravityWhitelistModels = ref<string[]>([])
const
antigravityModelMappings
=
ref
<
ModelMapping
[]
>
([])
const
antigravityModelMappings
=
ref
<
ModelMapping
[]
>
([])
const
tempUnschedEnabled
=
ref
(
false
)
const
tempUnschedEnabled
=
ref
(
false
)
const
tempUnschedRules
=
ref
<
TempUnschedRuleForm
[]
>
([])
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
// Mixed channel warning dialog state
const
showMixedChannelWarning
=
ref
(
false
)
const
showMixedChannelWarning
=
ref
(
false
)
...
@@ -1845,9 +1849,9 @@ const handleSubmit = async () => {
...
@@ -1845,9 +1849,9 @@ const handleSubmit = async () => {
updatePayload
.
extra
=
newExtra
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
'
))
appStore
.
showSuccess
(
t
(
'
admin.accounts.accountUpdated
'
))
emit
(
'
updated
'
)
emit
(
'
updated
'
,
updatedAccount
)
handleClose
()
handleClose
()
}
catch
(
error
:
any
)
{
}
catch
(
error
:
any
)
{
// Handle 409 mixed_channel_warning - show confirmation dialog
// Handle 409 mixed_channel_warning - show confirmation dialog
...
@@ -1875,9 +1879,9 @@ const handleMixedChannelConfirm = async () => {
...
@@ -1875,9 +1879,9 @@ const handleMixedChannelConfirm = async () => {
pendingUpdatePayload
.
value
.
confirm_mixed_channel_risk
=
true
pendingUpdatePayload
.
value
.
confirm_mixed_channel_risk
=
true
submitting
.
value
=
true
submitting
.
value
=
true
try
{
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
'
))
appStore
.
showSuccess
(
t
(
'
admin.accounts.accountUpdated
'
))
emit
(
'
updated
'
)
emit
(
'
updated
'
,
updatedAccount
)
handleClose
()
handleClose
()
}
catch
(
error
:
any
)
{
}
catch
(
error
:
any
)
{
appStore
.
showError
(
error
.
response
?.
data
?.
message
||
error
.
response
?.
data
?.
detail
||
t
(
'
admin.accounts.failedToUpdate
'
))
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 = () => {
...
@@ -143,6 +143,24 @@ const handleClose = () => {
emit
(
'
close
'
)
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
()
=>
{
const
handleImport
=
async
()
=>
{
if
(
!
file
.
value
)
{
if
(
!
file
.
value
)
{
appStore
.
showError
(
t
(
'
admin.accounts.dataImportSelectFile
'
))
appStore
.
showError
(
t
(
'
admin.accounts.dataImportSelectFile
'
))
...
@@ -151,7 +169,7 @@ const handleImport = async () => {
...
@@ -151,7 +169,7 @@ const handleImport = async () => {
importing
.
value
=
true
importing
.
value
=
true
try
{
try
{
const
text
=
await
file
.
value
.
text
(
)
const
text
=
await
readFileAsText
(
file
.
value
)
const
dataPayload
=
JSON
.
parse
(
text
)
const
dataPayload
=
JSON
.
parse
(
text
)
const
res
=
await
adminAPI
.
accounts
.
importData
({
const
res
=
await
adminAPI
.
accounts
.
importData
({
...
...
frontend/src/components/admin/account/ReAuthAccountModal.vue
View file @
b5a3b3db
...
@@ -216,7 +216,7 @@ interface Props {
...
@@ -216,7 +216,7 @@ interface Props {
const
props
=
defineProps
<
Props
>
()
const
props
=
defineProps
<
Props
>
()
const
emit
=
defineEmits
<
{
const
emit
=
defineEmits
<
{
close
:
[]
close
:
[]
reauthorized
:
[]
reauthorized
:
[
account
:
Account
]
}
>
()
}
>
()
const
appStore
=
useAppStore
()
const
appStore
=
useAppStore
()
...
@@ -370,10 +370,10 @@ const handleExchangeCode = async () => {
...
@@ -370,10 +370,10 @@ const handleExchangeCode = async () => {
})
})
// Clear error status after successful re-authorization
// 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
'
))
appStore
.
showSuccess
(
t
(
'
admin.accounts.reAuthorizedSuccess
'
))
emit
(
'
reauthorized
'
)
emit
(
'
reauthorized
'
,
updatedAccount
)
handleClose
()
handleClose
()
}
catch
(
error
:
any
)
{
}
catch
(
error
:
any
)
{
openaiOAuth
.
error
.
value
=
error
.
response
?.
data
?.
detail
||
t
(
'
admin.accounts.oauth.authFailed
'
)
openaiOAuth
.
error
.
value
=
error
.
response
?.
data
?.
detail
||
t
(
'
admin.accounts.oauth.authFailed
'
)
...
@@ -404,9 +404,9 @@ const handleExchangeCode = async () => {
...
@@ -404,9 +404,9 @@ const handleExchangeCode = async () => {
type
:
'
oauth
'
,
type
:
'
oauth
'
,
credentials
credentials
})
})
await
adminAPI
.
accounts
.
clearError
(
props
.
account
.
id
)
const
updatedAccount
=
await
adminAPI
.
accounts
.
clearError
(
props
.
account
.
id
)
appStore
.
showSuccess
(
t
(
'
admin.accounts.reAuthorizedSuccess
'
))
appStore
.
showSuccess
(
t
(
'
admin.accounts.reAuthorizedSuccess
'
))
emit
(
'
reauthorized
'
)
emit
(
'
reauthorized
'
,
updatedAccount
)
handleClose
()
handleClose
()
}
catch
(
error
:
any
)
{
}
catch
(
error
:
any
)
{
geminiOAuth
.
error
.
value
=
error
.
response
?.
data
?.
detail
||
t
(
'
admin.accounts.oauth.authFailed
'
)
geminiOAuth
.
error
.
value
=
error
.
response
?.
data
?.
detail
||
t
(
'
admin.accounts.oauth.authFailed
'
)
...
@@ -436,9 +436,9 @@ const handleExchangeCode = async () => {
...
@@ -436,9 +436,9 @@ const handleExchangeCode = async () => {
type
:
'
oauth
'
,
type
:
'
oauth
'
,
credentials
credentials
})
})
await
adminAPI
.
accounts
.
clearError
(
props
.
account
.
id
)
const
updatedAccount
=
await
adminAPI
.
accounts
.
clearError
(
props
.
account
.
id
)
appStore
.
showSuccess
(
t
(
'
admin.accounts.reAuthorizedSuccess
'
))
appStore
.
showSuccess
(
t
(
'
admin.accounts.reAuthorizedSuccess
'
))
emit
(
'
reauthorized
'
)
emit
(
'
reauthorized
'
,
updatedAccount
)
handleClose
()
handleClose
()
}
catch
(
error
:
any
)
{
}
catch
(
error
:
any
)
{
antigravityOAuth
.
error
.
value
=
error
.
response
?.
data
?.
detail
||
t
(
'
admin.accounts.oauth.authFailed
'
)
antigravityOAuth
.
error
.
value
=
error
.
response
?.
data
?.
detail
||
t
(
'
admin.accounts.oauth.authFailed
'
)
...
@@ -475,10 +475,10 @@ const handleExchangeCode = async () => {
...
@@ -475,10 +475,10 @@ const handleExchangeCode = async () => {
})
})
// Clear error status after successful re-authorization
// 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
'
))
appStore
.
showSuccess
(
t
(
'
admin.accounts.reAuthorizedSuccess
'
))
emit
(
'
reauthorized
'
)
emit
(
'
reauthorized
'
,
updatedAccount
)
handleClose
()
handleClose
()
}
catch
(
error
:
any
)
{
}
catch
(
error
:
any
)
{
claudeOAuth
.
error
.
value
=
error
.
response
?.
data
?.
detail
||
t
(
'
admin.accounts.oauth.authFailed
'
)
claudeOAuth
.
error
.
value
=
error
.
response
?.
data
?.
detail
||
t
(
'
admin.accounts.oauth.authFailed
'
)
...
@@ -518,10 +518,10 @@ const handleCookieAuth = async (sessionKey: string) => {
...
@@ -518,10 +518,10 @@ const handleCookieAuth = async (sessionKey: string) => {
})
})
// Clear error status after successful re-authorization
// 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
'
))
appStore
.
showSuccess
(
t
(
'
admin.accounts.reAuthorizedSuccess
'
))
emit
(
'
reauthorized
'
)
emit
(
'
reauthorized
'
,
updatedAccount
)
handleClose
()
handleClose
()
}
catch
(
error
:
any
)
{
}
catch
(
error
:
any
)
{
claudeOAuth
.
error
.
value
=
claudeOAuth
.
error
.
value
=
...
...
frontend/src/components/admin/proxy/ImportDataModal.vue
View file @
b5a3b3db
...
@@ -143,6 +143,24 @@ const handleClose = () => {
...
@@ -143,6 +143,24 @@ const handleClose = () => {
emit
(
'
close
'
)
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
()
=>
{
const
handleImport
=
async
()
=>
{
if
(
!
file
.
value
)
{
if
(
!
file
.
value
)
{
appStore
.
showError
(
t
(
'
admin.proxies.dataImportSelectFile
'
))
appStore
.
showError
(
t
(
'
admin.proxies.dataImportSelectFile
'
))
...
@@ -151,7 +169,7 @@ const handleImport = async () => {
...
@@ -151,7 +169,7 @@ const handleImport = async () => {
importing
.
value
=
true
importing
.
value
=
true
try
{
try
{
const
text
=
await
file
.
value
.
text
(
)
const
text
=
await
readFileAsText
(
file
.
value
)
const
dataPayload
=
JSON
.
parse
(
text
)
const
dataPayload
=
JSON
.
parse
(
text
)
const
res
=
await
adminAPI
.
proxies
.
importData
({
data
:
dataPayload
})
const
res
=
await
adminAPI
.
proxies
.
importData
({
data
:
dataPayload
})
...
...
frontend/src/components/common/DataTable.vue
View file @
b5a3b3db
...
@@ -3,7 +3,7 @@
...
@@ -3,7 +3,7 @@
<template
v-if=
"loading"
>
<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
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
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-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
class=
"h-4 w-32 animate-pulse rounded bg-gray-200 dark:bg-dark-700"
></div>
</div>
</div>
...
@@ -39,7 +39,7 @@
...
@@ -39,7 +39,7 @@
>
>
<div
class=
"space-y-3"
>
<div
class=
"space-y-3"
>
<div
<div
v-for=
"column in
c
olumns
.filter(c => c.key !== 'actions')
"
v-for=
"column in
dataC
olumns"
:key=
"column.key"
:key=
"column.key"
class=
"flex items-start justify-between gap-4"
class=
"flex items-start justify-between gap-4"
>
>
...
@@ -439,10 +439,15 @@ const resolveRowKey = (row: any, index: number) => {
...
@@ -439,10 +439,15 @@ const resolveRowKey = (row: any, index: number) => {
return
key
??
index
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 会临时修改它,会导致无限循环
// 注意:不能监听 actionsExpanded,因为 checkActionsColumnWidth 会临时修改它,会导致无限循环
watch
(
watch
(
[()
=>
props
.
data
.
length
,
()
=>
props
.
columns
],
[()
=>
props
.
data
.
length
,
columnsSignature
],
async
()
=>
{
async
()
=>
{
await
nextTick
()
await
nextTick
()
checkScrollable
()
checkScrollable
()
...
@@ -555,7 +560,7 @@ onMounted(() => {
...
@@ -555,7 +560,7 @@ onMounted(() => {
})
})
watch
(
watch
(
()
=>
props
.
columns
,
columnsSignature
,
()
=>
{
()
=>
{
// If current sort key is no longer sortable/visible, fall back to default/persisted.
// If current sort key is no longer sortable/visible, fall back to default/persisted.
const
normalized
=
normalizeSortKey
(
sortKey
.
value
)
const
normalized
=
normalizeSortKey
(
sortKey
.
value
)
...
@@ -575,7 +580,7 @@ watch(
...
@@ -575,7 +580,7 @@ watch(
}
}
}
}
},
},
{
deep
:
true
}
{
flush
:
'
post
'
}
)
)
watch
(
watch
(
...
...
frontend/src/components/common/LocaleSwitcher.vue
View file @
b5a3b3db
...
@@ -2,6 +2,7 @@
...
@@ -2,6 +2,7 @@
<div
class=
"relative"
ref=
"dropdownRef"
>
<div
class=
"relative"
ref=
"dropdownRef"
>
<button
<button
@
click=
"toggleDropdown"
@
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"
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"
:title=
"currentLocale?.name"
>
>
...
@@ -23,6 +24,7 @@
...
@@ -23,6 +24,7 @@
<button
<button
v-for=
"locale in availableLocales"
v-for=
"locale in availableLocales"
:key=
"locale.code"
:key=
"locale.code"
:disabled=
"switching"
@
click=
"selectLocale(locale.code)"
@
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=
"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=
"
{
:class=
"
{
...
@@ -49,6 +51,7 @@ const { locale } = useI18n()
...
@@ -49,6 +51,7 @@ const { locale } = useI18n()
const
isOpen
=
ref
(
false
)
const
isOpen
=
ref
(
false
)
const
dropdownRef
=
ref
<
HTMLElement
|
null
>
(
null
)
const
dropdownRef
=
ref
<
HTMLElement
|
null
>
(
null
)
const
switching
=
ref
(
false
)
const
currentLocaleCode
=
computed
(()
=>
locale
.
value
)
const
currentLocaleCode
=
computed
(()
=>
locale
.
value
)
const
currentLocale
=
computed
(()
=>
availableLocales
.
find
((
l
)
=>
l
.
code
===
locale
.
value
))
const
currentLocale
=
computed
(()
=>
availableLocales
.
find
((
l
)
=>
l
.
code
===
locale
.
value
))
...
@@ -57,9 +60,18 @@ function toggleDropdown() {
...
@@ -57,9 +60,18 @@ function toggleDropdown() {
isOpen
.
value
=
!
isOpen
.
value
isOpen
.
value
=
!
isOpen
.
value
}
}
function
selectLocale
(
code
:
string
)
{
async
function
selectLocale
(
code
:
string
)
{
setLocale
(
code
)
if
(
switching
.
value
||
code
===
currentLocaleCode
.
value
)
{
isOpen
.
value
=
false
isOpen
.
value
=
false
return
}
switching
.
value
=
true
try
{
await
setLocale
(
code
)
isOpen
.
value
=
false
}
finally
{
switching
.
value
=
false
}
}
}
function
handleClickOutside
(
event
:
MouseEvent
)
{
function
handleClickOutside
(
event
:
MouseEvent
)
{
...
...
frontend/src/components/common/Pagination.vue
View file @
b5a3b3db
...
@@ -84,8 +84,8 @@
...
@@ -84,8 +84,8 @@
<!--
Page
numbers
-->
<!--
Page
numbers
-->
<
button
<
button
v
-
for
=
"
pageNum in visiblePages
"
v
-
for
=
"
(
pageNum
, index)
in visiblePages
"
:
key
=
"
pageNum
"
:
key
=
"
`${
pageNum
}
-${index
}
`
"
@
click
=
"
typeof pageNum === 'number' && goToPage(pageNum)
"
@
click
=
"
typeof pageNum === 'number' && goToPage(pageNum)
"
:
disabled
=
"
typeof pageNum !== 'number'
"
:
disabled
=
"
typeof pageNum !== 'number'
"
:
class
=
"
[
:
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